import { useEffect, useMemo, useState } from 'react'; import { MatrixEvent, MatrixEventEvent, MsgType, Room, ThreadEvent } from 'matrix-js-sdk'; import { trimReplyFromBody } from '../../utils/room'; import { useMatrixClient } from '../../hooks/useMatrixClient'; // One past conversation in an assistant bot's control DM. A conversation IS an m.thread // root: the bot threads every top-level DM turn, so each chat is a thread the server // enumerates for free via /rooms/{id}/threads. export type BotConversation = { rootId: string; // First non-empty line of the thread root's body, reply-fallback stripped. "" when the // root isn't loaded yet or is media-only — the caller renders a localized placeholder. title: string; // Last-activity timestamp (latest reply, else the root), for recency ordering. ts: number; }; // Derive a conversation title from its thread root WITHOUT storing anything: the title is // the root message text (the §4.4 "root-text now" tier). A future account_data override // (io.vojo.thread_titles) would layer on top of this at the call site. const deriveTitle = (room: Room, rootId: string): string => { const rootEvent = room.getThread(rootId)?.rootEvent ?? room.findEventById(rootId); const content = rootEvent?.getContent(); if (!content) return ''; // Only text roots make a sensible title. For m.image/m.file/m.video the body is the // FILENAME (a non-empty string), so without this gate the list would leak "photo.png" as // a chat title; return "" so the caller shows the localized fallback instead. (The bot // never auto-threads media — it reacts "text only" first — but a manually-created media // thread in the same DM would otherwise surface here.) if (content.msgtype !== MsgType.Text && content.msgtype !== MsgType.Emote) return ''; const { body } = content; if (typeof body !== 'string') return ''; const firstLine = trimReplyFromBody(body) .split('\n') .find((line) => line.trim() !== ''); return firstLine?.trim() ?? ''; }; // useBotConversations lists the control DM's conversations (thread roots), most-recent // first, and re-renders as threads are created or get new replies. It triggers a one-shot // server thread-list load (room.fetchRoomThreads) so cold conversations show after a // reload without opening each thread. Read-only — never mutates room state. export const useBotConversations = (room: Room): BotConversation[] => { const mx = useMatrixClient(); const myUserId = mx.getUserId(); const [tick, setTick] = useState(0); useEffect(() => { let cancelled = false; const bump = () => { if (!cancelled) setTick((n) => n + 1); }; // Populate room.getThreads() from the server once; getThreads() then reflects it. // Best-effort — a failure just leaves the list at whatever /sync already materialized. room .fetchRoomThreads() .catch(() => undefined) .finally(bump); // ThreadEvent.New fires on the ROOM when a brand-new conversation roots (the SDK // excludes it from a thread's own emitter). ThreadEvent.Update is re-emitted onto the // room from each thread on every reply / latest-event change, so it covers both "a // reply landed" and the recency re-sort — we no longer also listen on RoomEvent.Timeline // (which fired an extra bump per reply plus one for every unrelated main-timeline event). room.on(ThreadEvent.New, bump); room.on(ThreadEvent.Update, bump); // In an encrypted DM a thread root can decrypt AFTER its last ThreadEvent.Update // (e.g. the bot's reply lands before the root is decrypted); without this the title // stays stuck on the localized fallback until the next reply. The client re-emits // Decrypted for every event, so re-derive when one lands in this room. const onDecrypted = (event: MatrixEvent) => { if (event.getRoomId() === room.roomId) bump(); }; mx.on(MatrixEventEvent.Decrypted, onDecrypted); return () => { cancelled = true; room.off(ThreadEvent.New, bump); room.off(ThreadEvent.Update, bump); mx.off(MatrixEventEvent.Decrypted, onDecrypted); }; }, [room, mx]); return useMemo(() => { const conversations = room .getThreads() // Hide a not-yet-answered conversation from the list: a brand-new chat is the user's own // top-level message that the bot roots a thread on and answers a moment later. Until that // reply lands (or if the bot failed to answer at all), the thread has zero replies and a // self-authored root. Dropping those keeps stale/empty chats out of the history; a real // conversation reappears the instant the bot's threaded reply arrives — and at the moment // a chat is created the user is INSIDE it, not viewing this list. rootEvent unknown ⇒ keep. .filter((thread) => !(thread.length === 0 && thread.rootEvent?.getSender() === myUserId)) .map((thread) => { const last = thread.replyToEvent ?? thread.rootEvent; return { rootId: thread.id, title: deriveTitle(room, thread.id), ts: last?.getTs() ?? 0, }; }); conversations.sort((a, b) => b.ts - a.ts); return conversations; // `tick` is the reactivity trigger (room.getThreads() mutates in place); `room` and // `myUserId` are stable for the lifetime of this surface. // eslint-disable-next-line react-hooks/exhaustive-deps }, [room, tick]); };