Messenger/packages/web/src/features/messaging/model.ts
Заид Омар Медхат | Zaid Omar Medhat f5b620111b redesign
2026-07-11 04:49:07 +05:00

767 lines
25 KiB
TypeScript

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<void>;
sendMedia: (
file: File,
caption: string,
kindOverride?: MediaKind,
durationSec?: number,
) => Promise<void>;
/** 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<boolean>;
/** Prepend an older page (scroll-up pagination). */
loadOlder: () => Promise<void>;
/** Append a newer page while detached; reattaches at the tail. */
loadNewer: () => Promise<void>;
/** Replace the window with the live tail (jump to newest). */
reloadTail: () => Promise<void>;
edit: (messageId: string, content: string) => Promise<void>;
/** Delete for everyone (sender, or group owner as moderation). */
remove: (messageId: string) => Promise<void>;
/** Delete for me only. */
hide: (messageId: string) => Promise<void>;
toggleReaction: (message: Message, emoji: string) => Promise<void>;
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<Message[]>([]);
const [loading, setLoading] = useState(true);
const [typingUserIds, setTypingUserIds] = useState<string[]>([]);
const [peerReadSeq, setPeerReadSeq] = useState(0);
const [pinned, setPinned] = useState<Message[]>([]);
const [detached, setDetached] = useState(false);
const [hasMoreUp, setHasMoreUp] = useState(true);
const messagesRef = useRef<Message[]>([]);
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<Map<string, ReturnType<typeof setTimeout>>>(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<PendingUpload[]>([]);
const allUploadsRef = useRef<PendingUpload[]>([]);
allUploadsRef.current = allUploads;
const uploadJobs = useRef<Map<string, UploadJob>>(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<void> => {
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<void> => {
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<boolean> => {
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<void> => {
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<void> => {
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<void> => {
// 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<void> => {
const job = uploadJobs.current.get(uploadId);
if (job === undefined) {
return;
}
const patch = (partial: Partial<PendingUpload>): 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<void> => {
// 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<void> => {
const updated = await apiEdit(apiConfig, conversationId, messageId, { content });
setMessages((prev) => mergeMessages(prev, [updated]));
},
[conversationId],
);
const remove = useCallback(
async (messageId: string): Promise<void> => {
await apiDelete(apiConfig, conversationId, messageId);
},
[conversationId],
);
const hide = useCallback(
async (messageId: string): Promise<void> => {
// 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<void> => {
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,
};
};