Phase 2 core loop: chat list and single-chat conversation now read/write real
Tinode topic data end to end, replacing the earlier hardcoded placeholders.
- lib/tinode/topics.ts: per-topic pub-sub layered on connection.ts's pattern
(Topic.onData/onInfo/etc are single-slot callbacks, claimed once and fanned
out). Implements the old app's subscribe lifecycle: `me` subscribed once
per session (metadata only), each contact gets a one-shot preview
subscription (withLaterData(1), never left), opening a conversation
leaves-then-resubscribes with a full history page (withData, not
withLaterData -- a preview prefetch already nudging _maxSeq would make
withLaterData return empty).
- lib/tinode/hooks.ts: useChatList()/useTopicMessages() via
useSyncExternalStore, snapshots cached and only invalidated on real
topic-change notifications (not recomputed every render).
- lib/tinode/send.ts: sendText/retryMessage via createMessage+publishDraft
(optimistic local echo), not the simpler publish() -- matches the old
app's approach. retryMessage flushes the failed local entry and rebuilds
before republishing, per the old app's context-menu retry handler.
- lib/messages/{time,avatar,attachments,status,preview,dispatch,drafty}:
formatting utilities. Avatar color hash ports the old app's Java-style
string hash but with modulus 11 (this design's palette) not the old app's
16. @mention renders flat accent blue per the new design, not the old
app's per-user hash color -- a deliberate simplification. File-type icons
(PDF/Word/Excel/PPT) are new-design-only; the old app buckets all
documents identically. Date/time formatting is written fresh against the
design spec (today+time combined, weekday tier) since the old app never
combines a relative-date word with a time in one string.
- ChatListColumn, MessageBubble, ConversationView: real components replacing
the placeholder chat list and "占位" conversation view.
- tinode-sdk.d.ts: expanded with the real Topic/MetaGetBuilder/Drafty
surface needed above, verified against the installed SDK source
(createMessage/publishDraft/messages/msgStatus/noteRead/flushMessage/
Drafty.format's exact treeBottomUp contract, MESSAGE_STATUS_* statics).
Verified end to end against the real backend: used the still-functional old
app (a second account, ixp355087) to create a real friendship and exchange
messages with the new app's test account, confirming the new chat list and
conversation view correctly render pre-existing real data, sending a new
message shows the right optimistic-then-delivered status transition, and
the old app receives it live in return.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
38 lines
1.7 KiB
TypeScript
38 lines
1.7 KiB
TypeScript
import type { StoredMessage } from 'tinode-sdk';
|
|
|
|
/**
|
|
* Two different "message is gone" protocol paths render differently (confirmed against the old
|
|
* app's chat-message.jsx, a real, previously-hit gap): `msg.hi` is the server's own soft-delete
|
|
* marker (renders as a centered system-style line, no bubble at all); Tinode's recall protocol
|
|
* instead nulls out `content` on an otherwise-normal message (still a regular aligned bubble, just
|
|
* with placeholder text). Getting these swapped either crashes on a null content or silently
|
|
* misrenders a recalled message as a deleted-system-line.
|
|
*/
|
|
export function isServerDeleted(msg: StoredMessage): boolean {
|
|
return !!(msg as StoredMessage & { hi?: boolean }).hi;
|
|
}
|
|
|
|
export function isRecalled(msg: StoredMessage): boolean {
|
|
return !isServerDeleted(msg) && msg.content == null;
|
|
}
|
|
|
|
interface SystemNoticeContent {
|
|
text: string;
|
|
}
|
|
|
|
function isSystemNoticeContent(content: unknown): content is SystemNoticeContent {
|
|
return !!content && typeof content === 'object' && typeof (content as SystemNoticeContent).text === 'string';
|
|
}
|
|
|
|
/**
|
|
* Plain centered system-event text (e.g. "你们已成为好友,现在可以开始聊天了") -- backend-custom
|
|
* `head['x-im-type']`, not a Drafty content type. Doesn't yet cover the actionable group-invitation
|
|
* card variant (same head type, but with a real join button) since that's group-chat territory
|
|
* (Phase 3); when that lands it needs to be excluded here the same way the old app excludes it.
|
|
*/
|
|
export function systemNoticeText(msg: StoredMessage): string | null {
|
|
const headType = msg.head?.['x-im-type'];
|
|
if (headType !== 'system_notice' && headType !== 'system_action') return null;
|
|
return isSystemNoticeContent(msg.content) ? msg.content.text : null;
|
|
}
|