Files
iios/packages/iios-messaging-ui/src/hooks/use-messages.ts
T
maaz519 7a0fb2ca97 feat(messaging-ui): reaction picker + attachments in the Thread
- useMessages exposes upload(); Thread gains a hover reaction picker (react to
  a message) and an attach button (upload → stage → send), plus inline image /
  file rendering of message attachments — all capability-degrading (hidden when
  the adapter lacks react/upload)
- themeable styles for the picker, attach button, staged chip, and attachments
- 57 tests still green

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 00:15:21 +05:30

216 lines
6.9 KiB
TypeScript

import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useAdapter } from '../provider';
import { isOwnMessage } from '../types';
import type { Attachment, Message, SendOpts } from '../types';
const TYPING_TTL_MS = 3500;
export interface UiMessage extends Message {
mine: boolean;
}
export interface MessagesState {
messages: UiMessage[];
loading: boolean;
error: string | null;
send: (content: string, opts?: SendOpts) => Promise<void>;
react: (messageId: string, emoji: string) => Promise<void>;
upload: (file: File) => Promise<Attachment>;
typingUserIds: string[];
seenIds: Set<string>;
sendTyping: () => void;
canReact: boolean;
canUpload: boolean;
}
let optimisticSeq = 0;
export function useMessages(threadId: string | null): MessagesState {
const adapter = useAdapter();
const [raw, setRaw] = useState<Message[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [typing, setTyping] = useState<Record<string, number>>({});
const [seenIds, setSeenIds] = useState<Set<string>>(new Set());
const currentActorId = adapter.currentActorId();
const actorRef = useRef(currentActorId);
actorRef.current = currentActorId;
// Load history, then subscribe. Reconciliation is by message id, so an echoed
// send never duplicates the optimistic row.
useEffect(() => {
if (!threadId) {
setRaw([]);
setLoading(false);
return;
}
let alive = true;
setLoading(true);
setRaw([]);
setError(null);
setSeenIds(new Set());
setTyping({});
adapter
.history(threadId)
.then((h) => {
if (!alive) return;
// Merge, don't clobber: a live message can arrive via subscribe while this
// history fetch is still in flight. Blindly setting raw = h would drop it.
setRaw((live) => {
const histIds = new Set(h.map((m) => m.id));
const extras = live.filter((m) => !histIds.has(m.id));
return extras.length ? [...h, ...extras] : h;
});
setError(null);
})
.catch((e: unknown) => {
if (alive) setError(e instanceof Error ? e.message : String(e));
})
.finally(() => {
if (alive) setLoading(false);
});
const off = adapter.subscribe(threadId, (e) => {
if (!alive) return;
switch (e.kind) {
case 'message':
setRaw((l) => (l.some((m) => m.id === e.message.id) ? l : [...l, e.message]));
break;
case 'typing':
if (e.userId !== actorRef.current) {
setTyping((t) => ({ ...t, [e.userId]: Date.now() + TYPING_TTL_MS }));
}
break;
case 'receipt':
// Only the OTHER side reading my message counts as "seen".
if (e.actorId !== actorRef.current) {
setSeenIds((s) => (s.has(e.messageId) ? s : new Set(s).add(e.messageId)));
}
break;
case 'reaction':
setRaw((l) => l.map((m) => (m.id === e.messageId ? { ...m, reactions: e.reactions } : m)));
break;
}
});
return () => {
alive = false;
off();
};
}, [adapter, threadId]);
const messages: UiMessage[] = useMemo(
() => raw.map((m) => ({ ...m, mine: isOwnMessage(m, currentActorId) })),
[raw, currentActorId],
);
const send = useCallback(
async (content: string, opts?: SendOpts) => {
if (!threadId) return;
const tempId = `optimistic_${optimisticSeq++}`;
const optimistic: Message = {
id: tempId,
actorId: actorRef.current,
text: content,
at: new Date().toISOString(),
pending: true,
reactions: [],
...(opts?.parentInteractionId ? { parentInteractionId: opts.parentInteractionId } : {}),
...(opts?.attachment ? { attachment: opts.attachment } : {}),
};
setRaw((l) => [...l, optimistic]);
try {
const saved = await adapter.send(threadId, content, opts);
setError(null);
// Replace the optimistic row with the server's. If the subscribe echo already
// added the real message, just drop the optimistic one.
setRaw((l) => {
const withoutTemp = l.filter((m) => m.id !== tempId);
return withoutTemp.some((m) => m.id === saved.id) ? withoutTemp : [...withoutTemp, saved];
});
} catch (e: unknown) {
setRaw((l) => l.filter((m) => m.id !== tempId));
setError(e instanceof Error ? e.message : String(e));
throw e;
}
},
[adapter, threadId],
);
const react = useCallback(
async (messageId: string, emoji: string) => {
if (!threadId || !adapter.react) return;
await adapter.react(threadId, messageId, emoji);
},
[adapter, threadId],
);
const upload = useCallback(
async (file: File): Promise<Attachment> => {
if (!adapter.upload) throw new Error('uploads are not supported by this adapter');
return adapter.upload(file);
},
[adapter],
);
const sendTyping = useCallback(() => {
if (threadId) adapter.sendTyping(threadId);
}, [adapter, threadId]);
// The newest acknowledged (non-pending) message id — what we report as read.
const lastReadableId = useMemo(() => {
for (let i = raw.length - 1; i >= 0; i--) {
if (!raw[i]!.pending) return raw[i]!.id;
}
return null;
}, [raw]);
// Report my read of the newest message (drives the other side's "seen" tick).
// Keyed on the id, not the whole array, so reaction/optimistic churn doesn't re-fire it.
useEffect(() => {
if (!threadId || !lastReadableId) return;
void adapter.markRead(threadId, lastReadableId).catch(() => {});
}, [adapter, threadId, lastReadableId]);
const typingUserIds = useMemo(() => {
const now = Date.now();
return Object.entries(typing)
.filter(([, exp]) => exp > now)
.map(([u]) => u);
}, [typing]);
// Expire stale typing entries. Bumping `typing` to a new reference forces the
// memo above to recompute with a fresh `now`, dropping entries past their TTL.
// (A bump of unrelated state can't do this — the memo is keyed on `typing`, so it
// would return its cached array and the indicator would stick forever.)
useEffect(() => {
if (typingUserIds.length === 0) return;
const t = setTimeout(() => setTyping((p) => ({ ...p })), TYPING_TTL_MS);
return () => clearTimeout(t);
}, [typingUserIds.length, typing]);
// Only my messages that the other side has read.
const seenMine = useMemo(() => {
const out = new Set<string>();
for (const id of seenIds) if (messages.some((m) => m.id === id && m.mine)) out.add(id);
return out;
}, [seenIds, messages]);
return {
messages,
loading,
error,
send,
react,
upload,
typingUserIds,
seenIds: seenMine,
sendTyping,
canReact: typeof adapter.react === 'function',
canUpload: typeof adapter.upload === 'function',
};
}