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
+111
-63
@@ -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));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user