diff --git a/apps/dashboard/src/features/conversations/conversation-api.ts b/apps/dashboard/src/features/conversations/conversation-api.ts index 98ecf62..64cdc88 100644 --- a/apps/dashboard/src/features/conversations/conversation-api.ts +++ b/apps/dashboard/src/features/conversations/conversation-api.ts @@ -11,9 +11,16 @@ import { StartConversationResponseSchema, SuccessResponseSchema, SuggestedReplyResponseSchema, + BulkMarkConversationsReadRequestSchema, + BulkMarkConversationsReadResponseSchema, + MarkConversationReadRequestSchema, + MarkConversationUnreadRequestSchema, + SetConversationStatusRequestSchema, type ActiveChat, + type BulkMarkConversationsReadResponse, type ConversationDetailResponse, type ConversationMediaShareOptions, + type ConversationTriageStatus, type ProcessedPost, type SuccessResponse, } from '../../../../../packages/api-contracts/src/index.js'; @@ -42,6 +49,18 @@ export interface ConversationApi { sendReply(id: string, message: string): Promise; shareMedia(id: string, assetId: string): Promise; startConversation(accountId: string, username: string, message: string): Promise; + markRead(id: string, throughTimestamp?: number): Promise; + markUnread(id: string, beforeTimestamp?: number): Promise; + setStatus(id: string, status: ConversationTriageStatus, snoozeUntil?: number | null): Promise; + bulkMarkRead(ids: string[]): Promise; +} + +function jsonPost(body: unknown): RequestInit { + return { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }; } export function createConversationApi(fetchImplementation?: typeof fetch): ConversationApi { @@ -121,6 +140,40 @@ export function createConversationApi(fetchImplementation?: typeof fetch): Conve ); return response.chat; }, + async markRead(id, throughTimestamp) { + const body = MarkConversationReadRequestSchema.parse( + throughTimestamp === undefined ? {} : { throughTimestamp }, + ); + return parseResponse( + await request(`${conversationPath(id)}/read`, jsonPost(body)), + SuccessResponseSchema, + ); + }, + async markUnread(id, beforeTimestamp) { + const body = MarkConversationUnreadRequestSchema.parse( + beforeTimestamp === undefined ? {} : { beforeTimestamp }, + ); + return parseResponse( + await request(`${conversationPath(id)}/unread`, jsonPost(body)), + SuccessResponseSchema, + ); + }, + async setStatus(id, status, snoozeUntil) { + const body = SetConversationStatusRequestSchema.parse( + snoozeUntil === undefined ? { status } : { status, snoozeUntil }, + ); + return parseResponse( + await request(`${conversationPath(id)}/status`, jsonPost(body)), + SuccessResponseSchema, + ); + }, + async bulkMarkRead(ids) { + const body = BulkMarkConversationsReadRequestSchema.parse({ conversationIds: ids }); + return parseResponse( + await request('/api/conversations/bulk-read', jsonPost(body)), + BulkMarkConversationsReadResponseSchema, + ); + }, }; } diff --git a/apps/dashboard/src/features/conversations/conversation-detail.tsx b/apps/dashboard/src/features/conversations/conversation-detail.tsx index 8a77eae..ce1b083 100644 --- a/apps/dashboard/src/features/conversations/conversation-detail.tsx +++ b/apps/dashboard/src/features/conversations/conversation-detail.tsx @@ -1,11 +1,18 @@ import { useCallback, useEffect, useRef, useState } from 'react'; -import type { ConversationDetailResponse, MediaLibraryAsset } from '../../../../../packages/api-contracts/src/index.js'; +import type { + ChatMessage, + ConversationDetailResponse, + MediaLibraryAsset, +} from '../../../../../packages/api-contracts/src/index.js'; import { RedditUsernameLink, redditUsernameLabel } from '../../components/reddit-username-link.js'; import { OriginPostCard } from '../../components/origin-post.js'; import { useConversationDetailQuery, useConversationMediaShareOptionsQuery, + useMarkConversationReadMutation, + useMarkConversationUnreadMutation, useSendReplyMutation, + useSetConversationStatusMutation, useShareConversationMediaMutation, useSuggestMediaAskMutation, useSuggestReplyMutation, @@ -13,14 +20,19 @@ import { import { SendConfirmationDialog } from './send-confirmation-dialog.js'; import { MediaShareConfirmationDialog } from './media-share-confirmation-dialog.js'; import { AccountOwnerBadge } from '../../components/account-owner-badge.js'; -import { formatExactTime } from './relative-time.js'; +import { formatDayLabel, formatExactTime, formatTimeOfDay, isSameDay } from './relative-time.js'; import { getStoredComposerDraft, storeComposerDraft } from './composer-drafts.js'; +import { SNOOZE_OPTIONS, isUnread, triageStatus } from './conversation-triage.js'; /** Reddit chat rejects messages longer than roughly this many characters. */ export const REDDIT_CHAT_MAX_MESSAGE_LENGTH = 10_000; const CHARACTER_WARNING_MARGIN = 500; const SUCCESS_NOTICE_DISMISS_MS = 5_000; const AT_BOTTOM_THRESHOLD_PX = 48; +/** Keeps a little context above the "New messages" rule when jumping to it. */ +const UNREAD_DIVIDER_HEADROOM_PX = 12; +/** Grace period before an open conversation is considered read. */ +export const AUTO_MARK_READ_DELAY_MS = 1_000; interface ConversationDetailProps { conversationId: string; @@ -40,12 +52,35 @@ function useAutoDismiss(isVisible: boolean, dismiss: () => void) { }, [isVisible, dismiss]); } -function MessageHistory({ conversation }: { conversation: ConversationDetailResponse }) { +/** + * Index of the first inbound message the operator has not read. + * + * Returns -1 unless the boundary falls *inside* the loaded history — a + * conversation being opened for the first time has no meaningful boundary, and + * a "New messages" rule above the very first message is noise rather than + * information. + */ +export function firstUnreadIndex(messages: ChatMessage[], lastReadMessageTs: number | undefined): number { + if (!lastReadMessageTs) return -1; + const index = messages.findIndex((message) => !message.isSelf && message.timestamp > lastReadMessageTs); + return index > 0 ? index : -1; +} + +function MessageHistory({ conversation, now }: { conversation: ConversationDetailResponse; now: number }) { const [revealedMedia, setRevealedMedia] = useState>(() => new Set()); const listRef = useRef(null); + const dividerRef = useRef(null); const isAtBottomRef = useRef(true); const [isAtBottom, setIsAtBottom] = useState(true); - const messageCount = conversation.messages?.length ?? 0; + const messages = conversation.messages ?? []; + const messageCount = messages.length; + // Pinned at mount: opening the conversation advances the read watermark a + // second later, and the marker must not disappear from under the operator + // while they are still reading. It resets when they switch conversations, + // because the panel is keyed on the conversation id. + const [readBoundary] = useState(() => conversation.lastReadMessageTs ?? 0); + const unreadIndex = firstUnreadIndex(messages, readBoundary); + const hasScrolledToBoundaryRef = useRef(false); const scrollToBottom = useCallback(() => { const list = listRef.current; @@ -55,11 +90,24 @@ function MessageHistory({ conversation }: { conversation: ConversationDetailResp setIsAtBottom(true); }, []); - // Stick to the latest message on mount and when new messages arrive, - // unless the operator has scrolled up to read history. + // Land on the first unread message rather than the bottom, so the operator + // starts reading where they left off. Only done once per mount — after that, + // stick to the latest message unless they have scrolled up to read history. useEffect(() => { + const list = listRef.current; + const divider = dividerRef.current; + if (unreadIndex >= 0 && !hasScrolledToBoundaryRef.current && list && divider) { + hasScrolledToBoundaryRef.current = true; + // Scroll the history pane only. `scrollIntoView` would also scroll every + // ancestor scroller, yanking the whole page out of position. + const offset = divider.getBoundingClientRect().top - list.getBoundingClientRect().top; + list.scrollTop = Math.max(0, list.scrollTop + offset - UNREAD_DIVIDER_HEADROOM_PX); + isAtBottomRef.current = false; + setIsAtBottom(false); + return; + } if (isAtBottomRef.current) scrollToBottom(); - }, [messageCount, scrollToBottom]); + }, [messageCount, scrollToBottom, unreadIndex]); function handleScroll() { const list = listRef.current; @@ -69,58 +117,76 @@ function MessageHistory({ conversation }: { conversation: ConversationDetailResp setIsAtBottom(atBottom); } - if (!conversation.messages || conversation.messages.length === 0) { + if (messageCount === 0) { return
No messages have been loaded for this conversation.
; } return (
    - {conversation.messages.map((message) => ( -
  1. -
    - {message.isSelf - ? `You${conversation.owner ? ` (u/${conversation.owner.redditUsername})` : ''}` - : } - {message.isAi ? AI : null} - -
    -

    {message.text}

    - {message.media?.length ? ( -
    - {message.media.map((media) => { - const mediaKey = `${message.timestamp}-${media.localPath}`; - const sender = message.isSelf ? 'sent by you' : `received from ${redditUsernameLabel(conversation.username)}`; - const isRevealed = revealedMedia.has(mediaKey); + {messages.map((message, index) => { + const previous = index > 0 ? messages[index - 1] : null; + const startsNewDay = previous === null || !isSameDay(previous.timestamp, message.timestamp); + const key = message.eventId ?? `${message.timestamp}-${message.sender}-${message.text}`; - return ( -
    - {`Image - -
    - ); - })} + return [ + startsNewDay ? ( +
  2. + ) : null, + index === unreadIndex ? ( +
  3. + New messages +
  4. + ) : null, +
  5. +
    + {message.isSelf + ? `You${conversation.owner ? ` (u/${conversation.owner.redditUsername})` : ''}` + : } + {message.isAi ? AI : null} +
    - ) : null} -
  6. - ))} +

    {message.text}

    + {message.media?.length ? ( +
    + {message.media.map((media) => { + const mediaKey = `${message.timestamp}-${media.localPath}`; + const sender = message.isSelf ? 'sent by you' : `received from ${redditUsernameLabel(conversation.username)}`; + const isRevealed = revealedMedia.has(mediaKey); + + return ( +
    + {`Image + +
    + ); + })} +
    + ) : null} + , + ]; + })}
{!isAtBottom ? (
-
- {conversation.automation.remainingTurns} automated {conversation.automation.remainingTurns === 1 ? 'turn' : 'turns'} remaining - {automationStatusMessage(conversation)} -