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
+55 -2
View File
@@ -14,7 +14,7 @@ import type { Contact, StoredMessage, Topic } from 'tinode-sdk';
import { MESSAGES_PAGE } from '@/lib/config';
import { getTinode } from './connection';
import { getTinode, onReauthenticated } from './connection';
const topicListeners = new Map<string, Set<() => void>>();
const anyTopicListeners = new Set<() => void>();
@@ -45,11 +45,33 @@ export function subscribeAnyTopicChange(callback: () => void): () => void {
return () => anyTopicListeners.delete(callback);
}
export interface RawTopicInfo {
what: 'read' | 'recv' | 'kp' | 'call';
seq?: number;
from?: string;
event?: string;
payload?: Record<string, unknown>;
}
const infoListeners = new Set<(topicName: string, info: RawTopicInfo) => void>();
/** Raw `{info}` payloads (read/recv/kp/call), forwarded verbatim -- unlike `subscribeAnyTopicChange`/
* `subscribeTopicChanges`, which only signal "something changed, re-read the snapshot" for the
* chat-list/message-list hooks, calls.ts needs the actual `event`/`payload` fields off a `what:
* "call"` info to drive its own ringing/accept/hang-up state machine -- those aren't reconstructable
* from a message snapshot re-read the way a new chat message is. */
export function subscribeTopicInfo(callback: (topicName: string, info: RawTopicInfo) => void): () => void {
infoListeners.add(callback);
return () => infoListeners.delete(callback);
}
function wireTopicCallbacks(topic: Topic) {
if (wiredTopics.has(topic)) return;
const notify = () => notifyTopic(topic.name);
topic.onData = notify;
topic.onInfo = notify;
topic.onInfo = (info) => {
notify();
if (info) infoListeners.forEach((cb) => cb(topic.name, info));
};
topic.onMetaDesc = notify;
topic.onSubsUpdated = notify;
topic.onPres = notify;
@@ -220,3 +242,34 @@ function invalidateChatListSnapshot() {
chatListSnapshot = null;
}
anyTopicListeners.add(invalidateChatListSnapshot);
/**
* Re-fires the `me` topic subscribe and every contact's preview subscribe once a WS reconnect has
* successfully re-authenticated (connection.ts's onReauthenticated). Bypasses subscribeMeTopic's/
* prefetchPreview's own "already done" guards on purpose -- those guards exist to stop a second
* *concurrent* call from firing a redundant `{sub}` while one is already in flight, not to reflect
* whether the topic is still genuinely attached, and after any disconnect it never is (the SDK
* resets `_attached` on every cached topic, confirmed by reading tinode.dev.js's #disconnected()).
* `Topic.subscribe()` itself only short-circuits when `_attached` is still true, so calling it again
* here for an already-detached topic genuinely resends `{sub}`, not a silent no-op.
*
* Deliberately scoped to topics this module already knows about (the `me` topic + everything
* prefetchPreview has ever touched) rather than tracking "the one conversation currently open" as
* separate state -- in practice the chat list and an open conversation are mounted together in this
* app's layout, so the open topic's contact row has already gone through prefetchPreview by the time
* anyone could be looking at it. A conversation reached by a deep link that never rendered the chat
* list at all is the one case this doesn't cover; also out of scope: backfilling any messages that
* were sent by the peer during the drop itself -- this only resumes *live* updates going forward,
* it doesn't gap-fill what was missed while disconnected.
*/
onReauthenticated(() => {
const me = getTinode().getMeTopic();
wireTopicCallbacks(me);
me.onContactUpdate = () => notifyTopic(me.name);
me.subscribe(me.startMetaQuery().withLaterSub().withDesc().withTags().withCred().build()).catch(() => {});
for (const topicName of previewPrefetched) {
const topic = getTinode().getTopic(topicName);
wireTopicCallbacks(topic);
topic.subscribe(topic.startMetaQuery().withLaterData(1).build()).catch(() => {});
}
});