import { Drafty, type DraftyContent, type StoredMessage, type Topic } from 'tinode-sdk'; import { QUOTED_REPLY_LENGTH } from '@/lib/config'; function toDraftyContent(content: DraftyContent | string | null | undefined): DraftyContent { if (content == null) return Drafty.init(''); return typeof content === 'string' ? Drafty.init(content) : content; } /** * docs/IM-前后端对接文档.md:486/952/1128 -- every `{pub}` must carry a client-generated * `x-im-client-mid` (scoped to "tenant + sender"), and a disconnect/failure retry MUST resend the * SAME value rather than mint a new one -- that's the field's entire purpose (line 315's own worked * example: resending an identical client_mid is expected to return the FIRST send's seq, not create * a second message). Every send helper below was missing this entirely, meaning a retry after an * ambiguous failure (the `{pub}` reached the server and was applied, but the `{ctrl}` ack itself * got lost on the way back) had no way to be deduplicated server-side and could double-post. * `crypto.randomUUID()` (the same primitive client.ts's own requestId() already relies on for * REST's Idempotency-Key) satisfies the doc's "ULID/UUID" requirement; kept local to this file * rather than importing requestId() across the REST/WS module boundary for what's a WS-protocol * concern here. Exported for attachments.ts/calls.ts's own `{pub}` sends (image/file/voice/call * invite), which need the identical field for the identical reason -- one source of truth for how * this app generates the id, not a copy in every file that happens to publish a message. */ export function newClientMid(): string { return typeof crypto !== 'undefined' && crypto.randomUUID ? crypto.randomUUID() : `mid_${Date.now().toString(36)}_${Math.random().toString(36).slice(2)}`; } /** * Plain-text send: createMessage + publishDraft (not the simpler `.publish()`), matching the old * app's own choice -- publishDraft inserts the message into the local cache and fires onData * immediately, showing "sending" before the network round trip resolves, rather than waiting on it. */ export function sendText(topic: Topic, text: string, head?: Record): Promise { const parsed = Drafty.parse(text); const content: DraftyContent | string = Drafty.isPlainText(parsed) ? text : parsed; const msg = topic.createMessage(content, false); msg.head = Object.assign(msg.head || {}, head, { 'x-im-client-mid': newClientMid() }); return topic.publishDraft(msg, Promise.resolve()); } /** * Same createMessage+publishDraft mechanics as sendText, but for content already assembled by the * caller (mentions.ts's `buildMentionedContent`, which splices real `Drafty.mention()` entities * into the plain-text draft) rather than a fresh `Drafty.parse` of raw text. Kept separate from * sendText rather than adding an optional param there, so every other existing call site (plain * P2P/group sends) is untouched. */ export function sendPrebuiltContent(topic: Topic, content: DraftyContent | string, head?: Record): Promise { const msg = topic.createMessage(content, false); msg.head = Object.assign(msg.head || {}, head, { 'x-im-client-mid': newClientMid() }); return topic.publishDraft(msg, Promise.resolve()); } /** * Retries a failed send by flushing the old local entry and building+publishing a fresh one with * the same content/head -- ported from the old app's context-menu "重试" handler (context-menu.jsx), * not a simple re-publish of the same object. Returns undefined if `seq` is no longer in the local * cache (already flushed, or never existed). * * Reuses the ORIGINAL failed message's own `x-im-client-mid` rather than minting a fresh one -- see * newClientMid()'s own comment above for why a retry specifically must not get a new id. Falls back * to a fresh one only for a locally-cached failed message from before this field existed at all * (there's no original id to recover in that one case). */ export function retryMessage(topic: Topic, seq: number): Promise | undefined { const failed = topic.flushMessage(seq); if (!failed) return undefined; const msg = topic.createMessage((failed.content as DraftyContent | string) ?? '', false); const existingMid = failed.head?.['x-im-client-mid']; msg.head = Object.assign(msg.head || {}, failed.head, { 'x-im-client-mid': typeof existingMid === 'string' ? existingMid : newClientMid(), }); return topic.publishDraft(msg, Promise.resolve()); } export function messageIsMine(msg: StoredMessage, myUserId: string | null): boolean { return !!myUserId && msg.from === myUserId; } /** * Reply/quote (old app's messages-view.jsx handlePickReply/sendMessage): the quoted body is * truncated (Drafty.replyContent, QUOTED_REPLY_LENGTH) and wrapped with the sender's name * (Drafty.quote), then the whole quote block is INLINED into the outgoing content ahead of the * new text (Drafty.append) -- the receiving side never has to look up the original message to show * a quote preview. `head.reply` carries the original seq separately, purely so a click on the quote * block can scroll back to it. */ export function sendReply(topic: Topic, text: string, quoted: StoredMessage, quotedSenderLabel: string): Promise { const trimmed = Drafty.replyContent(toDraftyContent(quoted.content), QUOTED_REPLY_LENGTH); const quoteBlock = Drafty.quote(quotedSenderLabel, quoted.from || '', trimmed); const sanitized = Drafty.sanitizeEntities(JSON.parse(JSON.stringify(quoteBlock)) as DraftyContent); const finalContent = Drafty.append(sanitized, Drafty.parse(text)); const msg = topic.createMessage(finalContent, false); msg.head = Object.assign(msg.head || {}, { reply: String(quoted.seq), 'x-im-client-mid': newClientMid() }); return topic.publishDraft(msg, Promise.resolve()); } /** * Single-message forward, sent directly to one target topic. Simplified from the old app's own * flow (which stages the forwarded content and lets the user add a comment via the normal composer * before sending) -- this sends immediately on picking a target, trading that one nicety for a * much smaller "pick a contact" dialog. `head.forwarded` records the source for traceability; it * has no reader anywhere yet, matching the old app (no confirmed call site reads it back either). */ export function forwardMessage(sourceTopic: string, msg: StoredMessage, target: Topic): Promise { const content = Drafty.forwardedContent(toDraftyContent(msg.content)); const newMsg = target.createMessage(content, false); newMsg.head = Object.assign(newMsg.head || {}, { forwarded: `${sourceTopic}:${msg.seq}`, 'x-im-client-mid': newClientMid() }); return target.publishDraft(newMsg, Promise.resolve()); }