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:
Your Name
2026-09-09 20:16:33 +08:00
co-authored by Claude Sonnet 5
parent 72b7b79caf
commit 5c3f3b7653
89 changed files with 10158 additions and 442 deletions
+111 -63
View File
@@ -2,7 +2,8 @@ import { Fragment, type ReactNode } from 'react';
import { Drafty, type DraftyContent } from 'tinode-sdk';
import { AudioAttachment, FileAttachment, ImageAttachment } from '@/features/messages/AttachmentPreview';
import { AudioAttachment, FileAttachment, ImageAttachment, VideoAttachment } from '@/features/messages/AttachmentPreview';
import { SafeLink } from '@/features/messages/SafeLink';
/**
* Bubble content renderer for the Drafty style tags this app currently sends/receives. Grown
@@ -15,70 +16,117 @@ import { AudioAttachment, FileAttachment, ImageAttachment } from '@/features/mes
* not an oversight (confirmed against the old app's formatters.js:122-128, which does hash-color
* mentions the same way avatars are colored).
*/
function formatNode(
style: string | undefined,
data: Record<string, unknown> | undefined,
values: ReactNode[] | null,
key: number | string,
): ReactNode {
const children = values ?? undefined;
switch (style) {
case 'ST':
return <b key={key}>{children}</b>;
case 'EM':
return <i key={key}>{children}</i>;
case 'DL':
return <s key={key}>{children}</s>;
case 'CO':
return (
<code key={key} className="rounded bg-surface-input px-1 py-0.5 font-mono text-[0.9em]">
{children}
</code>
);
case 'BR':
return <br key={key} />;
case 'MN':
return (
<span key={key} className="text-accent">
{children}
</span>
);
case 'HL':
return (
<mark key={key} className="bg-warn-bg">
{children}
</mark>
);
case 'LN': {
const url = typeof data?.url === 'string' ? data.url : undefined;
return (
<a key={key} href={url} target="_blank" rel="noreferrer" className="text-accent underline">
{children}
</a>
);
}
case 'IM':
return <ImageAttachment key={key} data={data || {}} />;
case 'EX':
return <FileAttachment key={key} data={data || {}} />;
case 'AU':
return <AudioAttachment key={key} data={data || {}} />;
case 'QQ':
// Drafty.quote() has no `key`/`data` of its own (tinode.dev.js:2160-2168) -- it's a plain style
// span wrapping mention(header,uid) + linebreak + body, all already rendered into `children`.
return (
<div key={key} className="mb-1.5 rounded border-l-2 border-accent bg-white/50 px-2 py-0.5 text-xs text-[#4a4f57]">
{children}
</div>
);
default:
return <Fragment key={key}>{children}</Fragment>;
/** README §6.3 (mock `05/1e`/`1f`): search-result keyword matches render as `<mark style="background:
* transparent; color:#1677ff; font-weight:500">`, not a yellow highlight -- a plain-text style, not
* the SDK's own unrelated `HL` Drafty tag (that one is `bg-warn-bg`, a different, currently-unused
* feature for user-authored highlight formatting, not search-match highlighting). Global,
* case-insensitive, one `<mark>` per match. */
function highlightMatches(text: string, query: string, keyPrefix: string | number): ReactNode[] {
const needle = query.trim().toLowerCase();
if (!needle) return [text];
const haystack = text.toLowerCase();
const parts: ReactNode[] = [];
let cursor = 0;
let idx = haystack.indexOf(needle, cursor);
if (idx === -1) return [text];
let n = 0;
while (idx !== -1) {
if (idx > cursor) parts.push(text.slice(cursor, idx));
parts.push(
<mark key={`${keyPrefix}-hl-${n++}`} className="bg-transparent font-medium text-accent">
{text.slice(idx, idx + query.trim().length)}
</mark>,
);
cursor = idx + query.trim().length;
idx = haystack.indexOf(needle, cursor);
}
if (cursor < text.length) parts.push(text.slice(cursor));
return parts;
}
export function renderDrafty(content: DraftyContent | string | null | undefined): ReactNode {
/** Curried on `highlightQuery` rather than taking it as a plain 5th parameter -- `Drafty.format`'s
* own callback type has a REAL 5th parameter (`stack: unknown[]`, see treeBottomUp's actual call),
* so a `formatNode` typed to accept `highlightQuery` in that same slot would stop structurally
* matching the formatter type `Drafty.format` expects wherever it's passed directly by reference. */
function makeFormatNode(highlightQuery: string | undefined) {
return function formatNode(
style: string | undefined,
data: Record<string, unknown> | undefined,
values: ReactNode[] | null,
key: number | string,
): ReactNode {
const children = values ?? undefined;
switch (style) {
case 'ST':
return <b key={key}>{children}</b>;
case 'EM':
return <i key={key}>{children}</i>;
case 'DL':
return <s key={key}>{children}</s>;
case 'CO':
return (
<code key={key} className="rounded bg-surface-input px-1 py-0.5 font-mono text-[0.9em]">
{children}
</code>
);
case 'BR':
return <br key={key} />;
case 'MN':
return (
<span key={key} className="text-accent">
{children}
</span>
);
case 'HL':
return (
<mark key={key} className="bg-warn-bg">
{children}
</mark>
);
case 'LN': {
const url = typeof data?.url === 'string' ? data.url : undefined;
return (
<SafeLink key={key} href={url}>
{children}
</SafeLink>
);
}
case 'IM':
return <ImageAttachment key={key} data={data || {}} />;
case 'EX':
return <FileAttachment key={key} data={data || {}} />;
case 'AU':
return <AudioAttachment key={key} data={data || {}} />;
case 'VD':
return <VideoAttachment key={key} data={data || {}} />;
case 'QQ':
// Drafty.quote() has no `key`/`data` of its own (tinode.dev.js:2160-2168) -- it's a plain
// style span wrapping mention(header,uid) + linebreak + body, already in `children`.
return (
<div key={key} className="mb-1.5 rounded border-l-2 border-accent bg-white/50 px-2 py-0.5 text-xs text-[#4a4f57]">
{children}
</div>
);
default:
// A genuine plain-text leaf (per treeBottomUp: no children, so `values` collapses to the
// node's own raw `[text]`) is the only shape safe to slice up for highlighting -- anything
// else (an already-formatted child array from a styled ancestor) is left exactly as before.
if (highlightQuery && values?.length === 1 && typeof values[0] === 'string') {
return <Fragment key={key}>{highlightMatches(values[0], highlightQuery, key)}</Fragment>;
}
return <Fragment key={key}>{children}</Fragment>;
}
};
}
/** `highlightQuery` is opt-in and additive -- every existing call site that omits it renders exactly
* as before; only HistorySearchModal's search results pass one. */
export function renderDrafty(content: DraftyContent | string | null | undefined, highlightQuery?: string): ReactNode {
if (content == null) return null;
if (typeof content === 'string') return content;
if (Drafty.isPlainText(content)) return Drafty.toPlainText(content);
return Drafty.format(content, formatNode);
if (typeof content === 'string') return highlightQuery ? <>{highlightMatches(content, highlightQuery, 'plain')}</> : content;
if (Drafty.isPlainText(content)) {
const text = Drafty.toPlainText(content);
return highlightQuery ? <>{highlightMatches(text, highlightQuery, 'plain')}</> : text;
}
return Drafty.format(content, makeFormatNode(highlightQuery));
}