vojo/src/app/features/bots/useBotConversations.ts

110 lines
5.4 KiB
TypeScript

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<BotConversation>((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]);
};