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>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
72b7b79caf
commit
5c3f3b7653
@@ -7,6 +7,27 @@ function toDraftyContent(content: DraftyContent | string | null | undefined): Dr
|
||||
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
|
||||
@@ -16,7 +37,20 @@ export function sendText(topic: Topic, text: string, head?: Record<string, unkno
|
||||
const parsed = Drafty.parse(text);
|
||||
const content: DraftyContent | string = Drafty.isPlainText(parsed) ? text : parsed;
|
||||
const msg = topic.createMessage(content, false);
|
||||
if (head) msg.head = Object.assign(msg.head || {}, head);
|
||||
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());
|
||||
}
|
||||
|
||||
@@ -25,12 +59,20 @@ export function sendText(topic: Topic, text: string, head?: Record<string, unkno
|
||||
* 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);
|
||||
if (failed.head) msg.head = Object.assign(msg.head || {}, failed.head);
|
||||
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());
|
||||
}
|
||||
|
||||
@@ -52,7 +94,7 @@ export function sendReply(topic: Topic, text: string, quoted: StoredMessage, quo
|
||||
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) });
|
||||
msg.head = Object.assign(msg.head || {}, { reply: String(quoted.seq), 'x-im-client-mid': newClientMid() });
|
||||
return topic.publishDraft(msg, Promise.resolve());
|
||||
}
|
||||
|
||||
@@ -66,6 +108,6 @@ export function sendReply(topic: Topic, text: string, quoted: StoredMessage, quo
|
||||
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}` });
|
||||
newMsg.head = Object.assign(newMsg.head || {}, { forwarded: `${sourceTopic}:${msg.seq}`, 'x-im-client-mid': newClientMid() });
|
||||
return target.publishDraft(newMsg, Promise.resolve());
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user