767 lines
25 KiB
TypeScript
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,
|
|
};
|
|
};
|