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>
114 lines
6.7 KiB
TypeScript
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());
|
|
}
|