Files
im_webapp/web/src/lib/tinode/send.ts
T
Your NameandClaude Sonnet 5 5c3f3b7653 feat(web): complete groups/contacts/favorites/settings/calls/system domains + extensive review-round fixes
Rounds out the full rewrite with the remaining feature domains: group
management (admin panel, mute, files, announcements, QR-based invite/join),
contacts (friend profile, add-friend, blocklist, new-friends), favorites
(real media rendering across text/image/video/voice/file/link), settings
(profile/security/privacy/general/about), calls (Agora-backed audio/video
signaling + call history), and system messages/file assistant.

Also folds in a large batch of live-verified fixes surfaced by repeated
fresh code-review passes and hands-on E2E testing against the real
backend, including: WS reconnect not resubscribing any topic after a
disconnect, missing x-im-client-mid on every send path (protocol
compliance for retry dedup), group mute/member-settings/self-profile
endpoint corrections, relationship-state type fixes across search/QR/
friend-profile, tenant-switch logout gap, password-policy now read from
the real per-tenant config instead of guessed, NavRail highlight/
aria-current desync, several call-state-machine races, and responsive
layout fixes across all four breakpoints (mobile/tablet/laptop/desktop)
including a full mobile rebuild of favorites, call UI, and the settings
home/sub-list split.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-09 20:16:33 +08:00

114 lines
6.7 KiB
TypeScript

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<string, unknown>): Promise<unknown> {
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<string, unknown>): Promise<unknown> {
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<unknown> | 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<unknown> {
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<unknown> {
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());
}