import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import type { Conversation, Message, MessageNewEvent, MessageEditEvent, MessageDeleteEvent, MessageHiddenEvent, ConversationClearedEvent, ReactionEvent, ReadReceiptEvent, TypingEvent, PublicUser, ReactionSummary, MediaRef, MediaKind, } from '@altricade/core'; import { conversationChannel, userChannel, ephemeralChannel, mergeMessages, OPTIMISTIC_SEQ, EventType, } from '@altricade/core'; import { getHistory, getMessageContext, sendMessage, editMessage as apiEdit, deleteMessage as apiDelete, hideMessage as apiHide, addReaction, removeReaction, markRead, getUploadUrl, uploadToUrl, listPinned, } from '@altricade/core/api'; import type { RealtimeEvent } from '@altricade/core/realtime'; import { apiConfig } from '../../shared/api'; import { useRealtime } from '../realtime'; export type UploadStatus = 'uploading' | 'sending' | 'error'; export interface PendingUpload { /** Doubles as the message clientMsgId — stable across retries so the backend dedupes the send. */ id: string; conversationId: string; media: MediaRef; caption: string; /** Object URL for a local image/video preview while the bytes are in flight. */ previewUrl: string | null; /** Uploaded fraction, 0..1 (meaningful while status is 'uploading'). */ progress: number; status: UploadStatus; } export interface UseConversationMessages { messages: Message[]; loading: boolean; typingUserIds: string[]; /** For a direct conversation: the peer's last-read seq (drives ✓✓). */ peerReadSeq: number; send: (content: string, replyToId?: string) => Promise; sendMedia: ( file: File, caption: string, kindOverride?: MediaKind, durationSec?: number, ) => Promise; /** In-flight media sends for this conversation, oldest first. */ uploads: PendingUpload[]; /** Re-run a failed upload from the step that failed (same clientMsgId → no duplicate message). */ retryUpload: (uploadId: string) => void; /** Abort (if in flight) and discard a pending upload. */ cancelUpload: (uploadId: string) => void; /** Pinned messages for this conversation, newest pin first. */ pinned: Message[]; /** True when the loaded window is a jumped-to context, not the live tail. */ detached: boolean; /** False once the top of the conversation has been reached. */ hasMoreUp: boolean; /** Ensure `messageId` is in the window (fetching its context when needed). * The jump-to-message primitive shared by replies, pins, and future search. */ jumpTo: (messageId: string) => Promise; /** Prepend an older page (scroll-up pagination). */ loadOlder: () => Promise; /** Append a newer page while detached; reattaches at the tail. */ loadNewer: () => Promise; /** Replace the window with the live tail (jump to newest). */ reloadTail: () => Promise; edit: (messageId: string, content: string) => Promise; /** Delete for everyone (sender, or group owner as moderation). */ remove: (messageId: string) => Promise; /** Delete for me only. */ hide: (messageId: string) => Promise; toggleReaction: (message: Message, emoji: string) => Promise; notifyTyping: () => void; } const mimeToKind = (mime: string): MediaKind => { if (mime.startsWith('image/')) return 'image'; if (mime.startsWith('video/')) return 'video'; if (mime.startsWith('audio/')) return 'voice'; return 'file'; }; // Media dimensions are captured at send time and stored in the MediaRef so // every client can reserve the exact box before the file loads — this is what // keeps chat layout deterministic (stable scroll positions). const probeImage = async (file: File): Promise<{ width: number; height: number } | null> => { try { const bitmap = await createImageBitmap(file); const dims = { width: bitmap.width, height: bitmap.height }; bitmap.close(); return dims; } catch { return null; } }; const probeVideo = ( file: File, ): Promise<{ width: number; height: number; durationSec: number } | null> => new Promise((resolve) => { const url = URL.createObjectURL(file); const video = document.createElement('video'); video.preload = 'metadata'; video.onloadedmetadata = () => { const result = video.videoWidth > 0 ? { width: video.videoWidth, height: video.videoHeight, durationSec: Number.isFinite(video.duration) ? video.duration : 0, } : null; URL.revokeObjectURL(url); resolve(result); }; video.onerror = () => { URL.revokeObjectURL(url); resolve(null); }; video.src = url; }); const hasType = (data: unknown): data is { type: string } => typeof data === 'object' && data !== null && 'type' in data && typeof data.type === 'string'; const isMessageEvent = (d: unknown): d is MessageNewEvent | MessageEditEvent => hasType(d) && (d.type === EventType.MessageNew || d.type === EventType.MessageEdit) && 'message' in d; const isDeleteEvent = (d: unknown): d is MessageDeleteEvent => hasType(d) && d.type === EventType.MessageDelete && 'messageId' in d && 'conversationId' in d; const isReactionEvent = (d: unknown): d is ReactionEvent => hasType(d) && (d.type === EventType.ReactionAdd || d.type === EventType.ReactionRemove); const isReadEvent = (d: unknown): d is ReadReceiptEvent => hasType(d) && d.type === EventType.ReadReceipt; const isHiddenEvent = (d: unknown): d is MessageHiddenEvent => hasType(d) && d.type === EventType.MessageHidden; const isClearedEvent = (d: unknown): d is ConversationClearedEvent => hasType(d) && d.type === EventType.ConversationCleared; const isPinEvent = (d: unknown): d is { type: string; conversationId: string } => hasType(d) && (d.type === EventType.MessagePin || d.type === EventType.MessageUnpin); const isTypingEvent = (d: unknown): d is TypingEvent => hasType(d) && (d.type === EventType.TypingStart || d.type === EventType.TypingStop); // Everything needed to (re)run an upload without re-asking the user for the // file: retry after a failed transfer re-presigns and re-uploads; retry after // a failed send reuses the already-uploaded objectKey and only re-sends. interface UploadJob { file: File; mime: string; media: MediaRef; caption: string; conversationId: string; /** Set once the bytes are in MinIO — a later retry skips the transfer. */ objectKey: string | null; controller: AbortController | null; previewUrl: string | null; } const applyReaction = ( reactions: ReactionSummary[], emoji: string, delta: 1 | -1, fromMe: boolean, ): ReactionSummary[] => { const existing = reactions.find((r) => r.emoji === emoji); if (existing === undefined) { return delta === 1 ? [...reactions, { emoji, count: 1, mine: fromMe }] : reactions; } return reactions .map((r) => { if (r.emoji !== emoji) { return r; } const mine = fromMe ? delta === 1 : r.mine; return { emoji, count: r.count + delta, mine }; }) .filter((r) => r.count > 0); }; export const useConversationMessages = ( conversation: Conversation, me: PublicUser, ): UseConversationMessages => { const { subscribe, publish } = useRealtime(); const [messages, setMessages] = useState([]); const [loading, setLoading] = useState(true); const [typingUserIds, setTypingUserIds] = useState([]); const [peerReadSeq, setPeerReadSeq] = useState(0); const [pinned, setPinned] = useState([]); const [detached, setDetached] = useState(false); const [hasMoreUp, setHasMoreUp] = useState(true); const messagesRef = useRef([]); messagesRef.current = messages; // Highest confirmed seq we know exists (live events keep it fresh even while // the window is detached and not merging them). const latestSeqRef = useRef(0); const detachedRef = useRef(false); detachedRef.current = detached; const pagingRef = useRef(false); const typingTimers = useRef>>(new Map()); const lastTypingSent = useRef(0); // Uploads outlive conversation switches (the transfer keeps running in the // background), so entries are kept globally and filtered per conversation. const [allUploads, setAllUploads] = useState([]); const allUploadsRef = useRef([]); allUploadsRef.current = allUploads; const uploadJobs = useRef>(new Map()); const conversationId = conversation.id; const conversationIdRef = useRef(conversationId); conversationIdRef.current = conversationId; // Groups deliver on the conversation channel; DMs on the personal channel. // Per-user view-state events (hide/clear) always arrive on the personal one, // so that subscription is unconditional. const groupChannel = conversation.type === 'group' ? conversationChannel(conversationId) : null; useEffect(() => { let cancelled = false; setMessages([]); setLoading(true); setTypingUserIds([]); setPeerReadSeq(0); setPinned([]); setDetached(false); setHasMoreUp(true); latestSeqRef.current = 0; pagingRef.current = false; const timers = typingTimers.current; const refreshPinned = (): void => { void listPinned(apiConfig, conversationId) .then((list) => { if (!cancelled) { setPinned(list); } }) .catch(() => { /* not permitted / gone */ }); }; refreshPinned(); const clearTyping = (userId: string): void => { setTypingUserIds((prev) => prev.filter((id) => id !== userId)); }; const handler = (event: RealtimeEvent): void => { const data = event.data; if (isMessageEvent(data)) { if (data.message.conversationId === conversationId) { const { message } = data; if (message.seq !== OPTIMISTIC_SEQ) { latestSeqRef.current = Math.max(latestSeqRef.current, message.seq); } if (data.type === EventType.MessageEdit) { // Edits apply only to messages inside the loaded window — merging // one from outside would punch a hole into the contiguous window. setMessages((prev) => prev.some((m) => m.id === message.id) ? mergeMessages(prev, [message]) : prev, ); return; } // While detached (viewing an old context) new messages aren't merged; // they're fetched by loadNewer/reloadTail when returning to the tail. if (!detachedRef.current) { setMessages((prev) => mergeMessages(prev, [message])); } } return; } if (isDeleteEvent(data)) { if (data.conversationId === conversationId) { const { messageId } = data; setMessages((prev) => prev.map((m) => m.id === messageId ? { ...m, content: '', deletedAt: new Date().toISOString() } : m, ), ); } return; } if (isReactionEvent(data)) { if (data.conversationId === conversationId) { const { messageId, emoji, userId } = data; const delta = data.type === EventType.ReactionAdd ? 1 : -1; setMessages((prev) => prev.map((m) => m.id === messageId ? { ...m, reactions: applyReaction(m.reactions, emoji, delta, userId === me.id) } : m, ), ); } return; } if (isReadEvent(data)) { if (data.conversationId === conversationId && data.userId !== me.id) { const { seq } = data; setPeerReadSeq((prev) => Math.max(prev, seq)); } return; } if (isHiddenEvent(data)) { // "Delete for me" done on another of my devices. if (data.conversationId === conversationId) { const { messageId } = data; setMessages((prev) => prev.filter((m) => m.id !== messageId)); } return; } if (isClearedEvent(data)) { if (data.conversationId === conversationId) { const { upToSeq } = data; // Optimistic sends carry OPTIMISTIC_SEQ (MAX_SAFE_INTEGER) — they survive. setMessages((prev) => prev.filter((m) => m.seq > upToSeq)); } return; } if (isPinEvent(data)) { if (data.conversationId === conversationId) { refreshPinned(); } return; } if (isTypingEvent(data)) { if (data.conversationId !== conversationId || data.userId === me.id) { return; } const { userId } = data; if (data.type === EventType.TypingStop) { clearTyping(userId); return; } setTypingUserIds((prev) => (prev.includes(userId) ? prev : [...prev, userId])); const existing = timers.get(userId); if (existing !== undefined) { clearTimeout(existing); } timers.set( userId, setTimeout(() => { clearTyping(userId); }, 4000), ); } }; // Durable events arrive on the personal channel (DMs + per-user view state) // and, for groups, additionally on the conversation channel; typing on the // ephemeral channel. const unsubUser = subscribe(userChannel(me.id), handler); const unsubGroup = groupChannel !== null ? subscribe(groupChannel, handler) : null; const unsubEphemeral = subscribe(ephemeralChannel(conversationId), handler); const load = async (): Promise => { try { const history = await getHistory(apiConfig, conversationId, { limit: 50 }); if (!cancelled) { latestSeqRef.current = history.reduce( (max, m) => (m.seq === OPTIMISTIC_SEQ ? max : Math.max(max, m.seq)), latestSeqRef.current, ); if (history.length < 50) { setHasMoreUp(false); } setMessages((prev) => mergeMessages(prev, history)); } } finally { if (!cancelled) { setLoading(false); } } }; void load(); return () => { cancelled = true; unsubUser(); unsubGroup?.(); unsubEphemeral(); for (const timer of timers.values()) { clearTimeout(timer); } timers.clear(); }; }, [conversationId, groupChannel, subscribe, me.id]); // Mark the conversation read up to the newest confirmed message. useEffect(() => { const maxSeq = messages .filter((m) => m.seq !== OPTIMISTIC_SEQ) .reduce((max, m) => Math.max(max, m.seq), 0); if (maxSeq > 0) { void markRead(apiConfig, conversationId, maxSeq); } }, [messages, conversationId]); // ---- windowed history operations ---- const reloadTail = useCallback(async (): Promise => { const history = await getHistory(apiConfig, conversationId, { limit: 50 }); latestSeqRef.current = history.reduce( (max, m) => (m.seq === OPTIMISTIC_SEQ ? max : Math.max(max, m.seq)), latestSeqRef.current, ); setHasMoreUp(history.length >= 50); setDetached(false); detachedRef.current = false; setMessages(mergeMessages([], history)); }, [conversationId]); const jumpTo = useCallback( async (messageId: string): Promise => { if (messagesRef.current.some((m) => m.id === messageId)) { return true; } try { const context = await getMessageContext(apiConfig, conversationId, messageId, 60); if (!context.some((m) => m.id === messageId)) { return false; } const maxSeq = context.reduce((max, m) => Math.max(max, m.seq), 0); latestSeqRef.current = Math.max(latestSeqRef.current, maxSeq); const nowDetached = maxSeq < latestSeqRef.current; setMessages(mergeMessages([], context)); setDetached(nowDetached); detachedRef.current = nowDetached; setHasMoreUp(true); return true; } catch { return false; } }, [conversationId], ); const loadOlder = useCallback(async (): Promise => { if (pagingRef.current) { return; } const real = messagesRef.current.filter((m) => m.seq !== OPTIMISTIC_SEQ); const first = real[0]; if (first === undefined) { return; } pagingRef.current = true; try { const batch = await getHistory(apiConfig, conversationId, { before: first.seq, limit: 50 }); if (batch.length < 50) { setHasMoreUp(false); } if (batch.length > 0) { setMessages((prev) => mergeMessages(prev, batch)); } } finally { pagingRef.current = false; } }, [conversationId]); const loadNewer = useCallback(async (): Promise => { if (pagingRef.current || !detachedRef.current) { return; } const real = messagesRef.current.filter((m) => m.seq !== OPTIMISTIC_SEQ); const last = real[real.length - 1]; if (last === undefined) { return; } pagingRef.current = true; try { const batch = await getHistory(apiConfig, conversationId, { after: last.seq, limit: 50 }); if (batch.length > 0) { latestSeqRef.current = batch.reduce( (max, m) => Math.max(max, m.seq), latestSeqRef.current, ); setMessages((prev) => mergeMessages(prev, batch)); } if (batch.length < 50) { // Caught up with the live tail — reattach (live merges resume). setDetached(false); detachedRef.current = false; } } finally { pagingRef.current = false; } }, [conversationId]); const send = useCallback( async (content: string, replyToId?: string): Promise => { // Sending from a jumped-to context returns to the live tail first. if (detachedRef.current) { await reloadTail(); } const clientMsgId = crypto.randomUUID(); const optimistic: Message = { id: `optimistic:${clientMsgId}`, conversationId, senderId: me.id, sender: me, content, contentType: 'text', encryption: null, clientMsgId, seq: OPTIMISTIC_SEQ, createdAt: new Date().toISOString(), editedAt: null, deletedAt: null, reactions: [], media: null, replyTo: null, forwarded: false, forwardedFrom: null, }; setMessages((prev) => mergeMessages(prev, [optimistic])); const confirmed = await sendMessage(apiConfig, conversationId, { content, clientMsgId, ...(replyToId !== undefined ? { replyToId } : {}), }); setMessages((prev) => mergeMessages(prev, [confirmed])); }, [conversationId, me, reloadTail], ); // ---- media uploads ---- // Run (or resume) the presign → transfer → send pipeline for one upload. // Any failure parks the entry in 'error' for the user to retry or discard; // nothing is ever dropped silently. const runUpload = useCallback(async (uploadId: string): Promise => { const job = uploadJobs.current.get(uploadId); if (job === undefined) { return; } const patch = (partial: Partial): void => { setAllUploads((prev) => prev.map((u) => (u.id === uploadId ? { ...u, ...partial } : u))); }; try { let objectKey = job.objectKey; if (objectKey === null) { patch({ status: 'uploading', progress: 0 }); // Presigned URLs expire — every (re)attempt asks for a fresh one. const target = await getUploadUrl(apiConfig, { kind: job.media.kind, mime: job.mime, size: job.file.size, }); const controller = new AbortController(); job.controller = controller; await uploadToUrl(target.uploadUrl, job.file, job.mime, { signal: controller.signal, onProgress: (loaded, total) => { patch({ progress: total > 0 ? loaded / total : 0 }); }, }); job.controller = null; objectKey = target.objectKey; job.objectKey = objectKey; } patch({ status: 'sending', progress: 1 }); const confirmed = await sendMessage(apiConfig, job.conversationId, { content: job.caption, clientMsgId: uploadId, mediaKey: objectKey, media: job.media, }); uploadJobs.current.delete(uploadId); if (job.previewUrl !== null) { URL.revokeObjectURL(job.previewUrl); } setAllUploads((prev) => prev.filter((u) => u.id !== uploadId)); // Merge only while the user still looks at this conversation's live tail; // otherwise history load / the realtime channel delivers it. if (job.conversationId === conversationIdRef.current && !detachedRef.current) { setMessages((prev) => mergeMessages(prev, [confirmed])); } } catch { job.controller = null; // A cancelled upload removed its job already — don't resurrect the entry. if (uploadJobs.current.has(uploadId)) { patch({ status: 'error' }); } } }, []); const retryUpload = useCallback( (uploadId: string): void => { const entry = allUploadsRef.current.find((u) => u.id === uploadId); if (entry?.status === 'error') { void runUpload(uploadId); } }, [runUpload], ); const cancelUpload = useCallback((uploadId: string): void => { const job = uploadJobs.current.get(uploadId); if (job === undefined) { return; } uploadJobs.current.delete(uploadId); job.controller?.abort(); if (job.previewUrl !== null) { URL.revokeObjectURL(job.previewUrl); } setAllUploads((prev) => prev.filter((u) => u.id !== uploadId)); }, []); const uploads = useMemo( () => allUploads.filter((u) => u.conversationId === conversationId), [allUploads, conversationId], ); const sendMedia = useCallback( async ( file: File, caption: string, kindOverride?: MediaKind, durationSec?: number, ): Promise => { // Sending from a jumped-to context returns to the live tail first. if (detachedRef.current) { await reloadTail(); } const mime = file.type.length > 0 ? file.type : 'application/octet-stream'; const kind = kindOverride ?? mimeToKind(mime); const media: MediaRef = { kind, mime, size: file.size, name: file.name }; // Capture intrinsic dimensions/duration up front (deterministic layout). if (kind === 'image') { const dims = await probeImage(file); if (dims !== null) { media.width = dims.width; media.height = dims.height; } } else if (kind === 'video' || kind === 'video_note') { const dims = await probeVideo(file); if (dims !== null) { media.width = dims.width; media.height = dims.height; if (dims.durationSec > 0) { media.durationSec = dims.durationSec; } } } if (durationSec !== undefined && durationSec > 0) { media.durationSec = durationSec; } const uploadId = crypto.randomUUID(); const previewUrl = kind === 'image' || kind === 'video' || kind === 'video_note' ? URL.createObjectURL(file) : null; uploadJobs.current.set(uploadId, { file, mime, media, caption, conversationId, objectKey: null, controller: null, previewUrl, }); setAllUploads((prev) => [ ...prev, { id: uploadId, conversationId, media, caption, previewUrl, progress: 0, status: 'uploading' }, ]); await runUpload(uploadId); }, [conversationId, reloadTail, runUpload], ); const edit = useCallback( async (messageId: string, content: string): Promise => { const updated = await apiEdit(apiConfig, conversationId, messageId, { content }); setMessages((prev) => mergeMessages(prev, [updated])); }, [conversationId], ); const remove = useCallback( async (messageId: string): Promise => { await apiDelete(apiConfig, conversationId, messageId); }, [conversationId], ); const hide = useCallback( async (messageId: string): Promise => { // Optimistic local removal; the personal-channel event covers other devices. setMessages((prev) => prev.filter((m) => m.id !== messageId)); await apiHide(apiConfig, conversationId, messageId); }, [conversationId], ); const toggleReaction = useCallback( async (message: Message, emoji: string): Promise => { const mine = message.reactions.some((r) => r.emoji === emoji && r.mine); if (mine) { await removeReaction(apiConfig, conversationId, message.id, emoji); } else { await addReaction(apiConfig, conversationId, message.id, { emoji }); } }, [conversationId], ); const notifyTyping = useCallback((): void => { const now = Date.now(); if (now - lastTypingSent.current > 2500) { lastTypingSent.current = now; // Client-side publish straight to Centrifugo (ephemeral, no backend round-trip). const event: TypingEvent = { type: EventType.TypingStart, conversationId, userId: me.id }; void publish(ephemeralChannel(conversationId), event); } }, [conversationId, me.id, publish]); return { messages, loading, typingUserIds, peerReadSeq, pinned, detached, hasMoreUp, jumpTo, loadOlder, loadNewer, reloadTail, send, sendMedia, uploads, retryUpload, cancelUpload, edit, remove, hide, toggleReaction, notifyTyping, }; };