Q142: a conversation is read only up to the message the reader actually saw
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
87c297dd45
commit
b18eb9ad14
11 files changed
+167
-55
No files matched your search
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, type FormEvent } from "react";
|
||||
import { useCallback, useMemo, useRef, useState, type FormEvent } from "react";
|
||||
import { Field } from "../shared/ui";
|
||||
import { Dialog } from "../shared/Dialog";
|
||||
import { t } from "../i18n/i18n";
|
||||
@@ -90,21 +90,10 @@ export function ChatPage({ snapshot, actions }: PageProps) {
|
||||
// whichever space just got a message and marking it read unseen.
|
||||
if (!selectedSpaceId && firstAvailableSpaceId) setSelectedSpaceId(firstAvailableSpaceId);
|
||||
const activeSpace = spaces.find((space) => space.id === activeSpaceId) ?? null;
|
||||
const latestActiveMessageId = useMemo(() => {
|
||||
if (!activeSpaceId) return null;
|
||||
const inSpace = messagesAll.filter((message) => message.spaceId === activeSpaceId);
|
||||
if (inSpace.length === 0) return null;
|
||||
return inSpace[inSpace.length - 1].id;
|
||||
}, [messagesAll, activeSpaceId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!activeSpaceId || !latestActiveMessageId) return;
|
||||
void actions.run(() => window.agentCompany.markChatSpaceRead({
|
||||
orgId,
|
||||
employeeId: employee.id,
|
||||
spaceId: activeSpaceId,
|
||||
}));
|
||||
}, [activeSpaceId, latestActiveMessageId, orgId, employee.id, actions]);
|
||||
// The conversation reports what the reader actually has in view; only that counts as read.
|
||||
const markRead = useCallback((spaceId: string, upToMessageId: string) => {
|
||||
void actions.run(() => window.agentCompany.markChatSpaceRead({ orgId, employeeId: employee.id, spaceId, upToMessageId }));
|
||||
}, [actions, orgId, employee.id]);
|
||||
|
||||
const messagesInSpace = useMemo(
|
||||
() => (activeSpace ? messagesAll.filter((m) => m.spaceId === activeSpace.id) : []),
|
||||
@@ -271,6 +260,7 @@ export function ChatPage({ snapshot, actions }: PageProps) {
|
||||
onDeleteMessage={deleteChatMessage}
|
||||
onOpenFile={openFile}
|
||||
onSubmit={sendInChannel}
|
||||
onRead={markRead}
|
||||
onReplyInThread={replyInThread}
|
||||
send={(activeSpaceId ? sends[activeSpaceId] : undefined) ?? IDLE_SEND}
|
||||
composerRef={composerRef}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useLayoutEffect, useMemo, useRef, useState, type FormEvent, type MutableRefObject } from "react";
|
||||
import { useEffect, useLayoutEffect, useMemo, useRef, useState, type FormEvent, type MutableRefObject } from "react";
|
||||
import { AvatarStack, Icon, IconButton } from "../../shared/ui";
|
||||
import type {
|
||||
ApprovalRecord,
|
||||
@@ -40,6 +40,7 @@ export function ChatConversation({
|
||||
onDeleteMessage,
|
||||
onOpenFile,
|
||||
onSubmit,
|
||||
onRead,
|
||||
onReplyInThread,
|
||||
send,
|
||||
composerRef,
|
||||
@@ -66,6 +67,8 @@ export function ChatConversation({
|
||||
onDeleteMessage: (messageId: string) => void;
|
||||
onOpenFile: (fileId: string) => void;
|
||||
onSubmit: (event: FormEvent<HTMLFormElement>) => void;
|
||||
/** Marks the conversation read up to a message the reader has in view; must be stable. */
|
||||
onRead: (spaceId: string, upToMessageId: string) => void;
|
||||
onReplyInThread: (threadId: string, body: string) => Promise<void>;
|
||||
send: SendState;
|
||||
composerRef: MutableRefObject<HTMLTextAreaElement | null>;
|
||||
@@ -112,6 +115,20 @@ export function ChatConversation({
|
||||
if (atBottomRef.current) jumpToLatest("auto");
|
||||
}, [lastMessageId]);
|
||||
|
||||
// Read means seen: the newest message is in view, the window is visible, and no search hides messages.
|
||||
const newestId = messagesInSpace.length > 0 ? messagesInSpace[messagesInSpace.length - 1].id : null;
|
||||
const spaceId = space?.id ?? null;
|
||||
const searching = trimmed.length > 0;
|
||||
useEffect(() => {
|
||||
const markIfSeen = () => {
|
||||
// The DOM is measured, not the last scroll state: clearing a search can grow the list under a reader who was at the bottom.
|
||||
if (spaceId && newestId && !searching && measureAtBottom() && document.visibilityState === "visible") onRead(spaceId, newestId);
|
||||
};
|
||||
markIfSeen();
|
||||
document.addEventListener("visibilitychange", markIfSeen);
|
||||
return () => document.removeEventListener("visibilitychange", markIfSeen);
|
||||
}, [spaceId, newestId, atBottom, searching, onRead]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<section style={{ flex: 1, minWidth: 0, display: "flex", flexDirection: "column", background: "var(--panel)" }}>
|
||||
|
||||
@@ -115,6 +115,34 @@ export interface ConversationItem {
|
||||
unread: number;
|
||||
}
|
||||
|
||||
/** Unread messages per space for one reader, counted by position after the last read message (time then insertion order). */
|
||||
export function unreadBySpace(messages: ChatMessageRecord[], readStates: ChatReadStateRecord[], employeeId: string): Map<string, number> {
|
||||
const countsSince = new Map<string, number>();
|
||||
const readStateBySpace = new Map<string, ChatReadStateRecord>();
|
||||
for (const state of readStates) {
|
||||
if (state.employeeId === employeeId) readStateBySpace.set(state.spaceId, state);
|
||||
}
|
||||
const readPassed = new Set<string>();
|
||||
const loadedIds = new Set(messages.map((message) => message.id));
|
||||
for (const message of messages) {
|
||||
const state = readStateBySpace.get(message.spaceId);
|
||||
const readId = state?.lastReadMessageId ?? null;
|
||||
const loadedRead = readId !== null && loadedIds.has(readId);
|
||||
if (message.id === readId) {
|
||||
readPassed.add(message.spaceId);
|
||||
continue;
|
||||
}
|
||||
if (message.senderType === "employee" && message.senderId === employeeId) continue;
|
||||
// A read message older than the loaded window means every loaded message came after it.
|
||||
const unread = !state ? true
|
||||
: loadedRead ? readPassed.has(message.spaceId)
|
||||
: readId !== null ? true
|
||||
: message.createdAt > state.lastReadAt;
|
||||
if (unread) countsSince.set(message.spaceId, (countsSince.get(message.spaceId) ?? 0) + 1);
|
||||
}
|
||||
return countsSince;
|
||||
}
|
||||
|
||||
/**
|
||||
* Folds spaces, messages, memberships, and per-employee read state into the
|
||||
* conversation list: every channel and direct conversation as its own row,
|
||||
@@ -133,18 +161,8 @@ export function buildConversations(input: {
|
||||
ceoAgentId: string | null;
|
||||
}): ConversationItem[] {
|
||||
const lastBySpace = new Map<string, ChatMessageRecord>();
|
||||
const countsSince = new Map<string, number>();
|
||||
const lastReadBySpace = new Map<string, string>();
|
||||
for (const state of input.readStates) {
|
||||
if (state.employeeId === input.currentEmployeeId) lastReadBySpace.set(state.spaceId, state.lastReadAt);
|
||||
}
|
||||
for (const message of input.messages) {
|
||||
lastBySpace.set(message.spaceId, message);
|
||||
if (message.senderType === "employee" && message.senderId === input.currentEmployeeId) continue;
|
||||
const lastRead = lastReadBySpace.get(message.spaceId);
|
||||
if (lastRead && message.createdAt <= lastRead) continue;
|
||||
countsSince.set(message.spaceId, (countsSince.get(message.spaceId) ?? 0) + 1);
|
||||
}
|
||||
for (const message of input.messages) lastBySpace.set(message.spaceId, message);
|
||||
const countsSince = unreadBySpace(input.messages, input.readStates, input.currentEmployeeId);
|
||||
const membersBySpace = new Map<string, ChatMembershipRecord[]>();
|
||||
for (const membership of input.memberships) {
|
||||
const group = membersBySpace.get(membership.spaceId);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { unreadBySpace } from "../pages/chat/chat-helpers";
|
||||
import { useEffect, useState, type FormEvent, type ReactNode } from "react";
|
||||
import type { AppSnapshot, EmployeeRecord } from "../../../shared/domain";
|
||||
import { Avatar, Field, Icon, IconButton } from "./ui";
|
||||
@@ -225,19 +226,10 @@ function computeBadges(snapshot: AppSnapshot, orgId: string): Partial<Record<Rou
|
||||
}
|
||||
|
||||
function countUnreadMessages(snapshot: AppSnapshot, orgId: string, employeeId: string): number {
|
||||
const readByspace = new Map<string, string>();
|
||||
for (const state of snapshot.chatReadStates) {
|
||||
if (state.orgId !== orgId || state.employeeId !== employeeId) continue;
|
||||
readByspace.set(state.spaceId, state.lastReadAt);
|
||||
}
|
||||
const messages = snapshot.chatMessages.filter((message) => message.orgId === orgId);
|
||||
const readStates = snapshot.chatReadStates.filter((state) => state.orgId === orgId);
|
||||
let unread = 0;
|
||||
for (const message of snapshot.chatMessages) {
|
||||
if (message.orgId !== orgId) continue;
|
||||
if (message.senderType === "employee" && message.senderId === employeeId) continue;
|
||||
const lastRead = readByspace.get(message.spaceId);
|
||||
if (lastRead && message.createdAt <= lastRead) continue;
|
||||
unread += 1;
|
||||
}
|
||||
for (const count of unreadBySpace(messages, readStates, employeeId).values()) unread += count;
|
||||
return unread;
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user