110 lines
5.4 KiB
TypeScript
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]);
|
|
};
|