Track per-employee chat read state and surface real unread counts

Adds chat_read_state migration (employee_id + space_id, last read
message + timestamp) wired through ChatService.markSpaceRead, IPC
markChatSpaceRead, and an effect in ChatPage that flushes the active
space whenever the latest message id changes.

Shell.computeBadges now derives the Messages badge from
(message.createdAt > read_state.last_read_at) minus messages the viewer
sent, so the sidebar shows true unread instead of total throughput.
This commit is contained in:
ZacharyZhang-NY committed 2026-05-21 20:25:07 -04:00
1 parent b44e0e3657
commit 1f78ae6f2e
11 files changed
+100 -3

No files matched your search

@@ -74,6 +74,7 @@ export function registerIpcHandlers(deps: IpcDeps): void {
ipcMain.handle(IPC_CHANNELS.decideApproval, (_event, input) => mutate(() => approvals.decide(input)));
ipcMain.handle(IPC_CHANNELS.createChatSpace, (_event, input) => mutate(() => chat.createSpace(input)));
ipcMain.handle(IPC_CHANNELS.sendMessage, (_event, input) => mutate(() => chat.sendMessage(input)));
ipcMain.handle(IPC_CHANNELS.markChatSpaceRead, (_event, input) => mutate(() => chat.markSpaceRead(input)));
ipcMain.handle(IPC_CHANNELS.saveProviderCredential, (_event, input) => mutate(() => providers.saveCredential(input)));
ipcMain.handle(IPC_CHANNELS.createProviderResponse, async (_event, input) => {
try {
+20 -1
View File
@@ -1,6 +1,6 @@
import type { DatabaseSync } from "node:sqlite";
const SCHEMA_VERSION = 4;
const SCHEMA_VERSION = 5;
export function installSchema(db: DatabaseSync): void {
db.exec(`
@@ -484,5 +484,24 @@ export function installSchema(db: DatabaseSync): void {
insert into schema_migrations(version, name, applied_at)
values (4, 'files_pinned_and_scheduled_posts', datetime('now'));
`);
currentVersion = 4;
}
if (currentVersion < 5) {
db.exec(`
create table if not exists chat_read_state (
org_id text not null references organizations(id) on delete cascade,
employee_id text not null references employees(id) on delete cascade,
space_id text not null references chat_spaces(id) on delete cascade,
last_read_message_id text references chat_messages(id) on delete set null,
last_read_at text not null,
primary key (employee_id, space_id)
);
create index if not exists idx_chat_read_state_org on chat_read_state(org_id, employee_id);
insert into schema_migrations(version, name, applied_at)
values (5, 'chat_read_state', datetime('now'));
`);
}
}
@@ -1,5 +1,5 @@
import type { DatabaseSync } from "node:sqlite";
import type { CreateChatSpaceInput, SendMessageInput } from "../../shared/commands";
import type { CreateChatSpaceInput, MarkChatSpaceReadInput, SendMessageInput } from "../../shared/commands";
import { createId } from "../../shared/ids";
import { AuditService } from "./audit-service";
@@ -101,6 +101,28 @@ export class ChatService {
return id;
}
markSpaceRead(input: MarkChatSpaceReadInput): void {
const space = this.db.prepare("select id from chat_spaces where id = ? and org_id = ?")
.get(input.spaceId, input.orgId) as { id: string } | undefined;
if (!space) throw new Error("Chat space was not found.");
const employee = this.db.prepare("select id from employees where id = ? and org_id = ?")
.get(input.employeeId, input.orgId) as { id: string } | undefined;
if (!employee) throw new Error("Employee was not found.");
const latest = this.db.prepare(`
select id from chat_messages
where space_id = ? and org_id = ?
order by created_at desc
limit 1
`).get(input.spaceId, input.orgId) as { id: string } | undefined;
const now = new Date().toISOString();
this.db.prepare(`
insert into chat_read_state(org_id, employee_id, space_id, last_read_message_id, last_read_at)
values (?, ?, ?, ?, ?)
on conflict(employee_id, space_id)
do update set last_read_message_id = excluded.last_read_message_id, last_read_at = excluded.last_read_at
`).run(input.orgId, input.employeeId, input.spaceId, latest?.id ?? null, now);
}
private recordMentions(messageId: string, body: string): void {
const seen = new Set<string>();
for (const match of body.matchAll(/(^|\s)@([A-Za-z][A-Za-z0-9_-]{1,63})/g)) {
@@ -64,6 +64,7 @@ export class SnapshotService {
chatThreads: list(this.db, "chat_threads", "updated_at desc"),
chatAttachments: list(this.db, "chat_attachments", "message_id asc"),
chatPresence: list(this.db, "chat_presence", "last_seen_at desc"),
chatReadStates: list(this.db, "chat_read_state", "last_read_at desc"),
files,
scheduledPosts: list(this.db, "scheduled_posts", "scheduled_for asc"),
providers,