import { useState } from 'react'; import { useInfiniteQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { Link } from 'react-router-dom'; import { Icon } from '@/app/Icon'; import { SelectionCheckbox } from '@/app/SelectionCheckbox'; import { Toast } from '@/app/Toast'; import { AudioAttachment } from '@/features/messages/AttachmentPreview'; import { ForwardPickerModal } from '@/features/messages/ForwardPickerModal'; import { batchDeleteFavorites, deleteFavorite, favoriteEntityData, forwardFavorites, getFavorites, type Favorite, type FavoriteType, } from '@/lib/api/favorites'; import { useAuthedBlobUrl, useDebouncedValue } from '@/lib/hooks'; import { fileTypeStyle, formatFileSize } from '@/lib/messages/attachments'; import { formatEventTimestamp } from '@/lib/messages/time'; import { useChatList } from '@/lib/tinode/hooks'; import { requestConfirm } from '@/stores/confirmDialog'; const CATEGORIES: { key: FavoriteType | 'all'; label: string; icon: string }[] = [ { key: 'all', label: '全部', icon: 'all_inbox' }, { key: 'text', label: '文本', icon: 'notes' }, { key: 'image', label: '图片', icon: 'image' }, { key: 'video', label: '视频', icon: 'videocam' }, { key: 'voice', label: '语音', icon: 'mic' }, { key: 'file', label: '文件', icon: 'description' }, { key: 'link', label: '链接', icon: 'link' }, ]; /** * README §6.1's footer "陈明远 · 项目协作群" names both the sender AND the conversation -- the * confirmed real `source` shape (doc line 1933) only carries `{topic, seq}`, no sender field, so * this can only resolve the CONVERSATION side (via the chat list's own contact cache, same lookup * ChatListColumn/ConversationView already do) and shows just that, not a fabricated sender name. * * Confirmed live: a P2P favorite's `source.topic` comes back in the server's internal `p2p...`- * hashed form (e.g. "p2pH5dvZ3ckWxq4XhSKX6MIow"), not the peer's `usr...` id the chat list and * every route in this app actually key P2P conversations by -- the SDK's own `Topic.topicType()` * doesn't even recognize a "p2p" prefix (its table only has usr/grp/new/nch/chn/sys/slf), so this * is a REST-API-only naming quirk, disconnected from the WS/SDK convention. There's no client-side * way to reverse that hash back to the peer's user id, so a chat-list lookup by topic can never * match it -- showing the raw hash string would be confusing, so this falls back to a plain label * for that case instead of guessing. */ function SourceLine({ source }: { source: Favorite['source'] }) { const contacts = useChatList(); if (!source) return null; const known = contacts.find((c) => c.topic === source.topic)?.public?.fn; // Same reasoning as the p2p fallback below -- a group this account has never actually opened as a // real conversation (e.g. joined via an admin action, never chatted in) has no entry in the local // chat-list cache either, and showing the raw internal topic id (`grpXXXXXXXX`) to the user reads // as a broken/leaked implementation detail, not a "no data" state -- fall back to a generic label. const fallback = source.topic.startsWith('p2p') ? '单聊消息' : source.topic.startsWith('grp') ? '群聊' : source.topic; return {known || fallback}; } function FavoriteCard({ item, selectMode, selected, onToggle }: { item: Favorite; selectMode: boolean; selected: boolean; onToggle: () => void }) { const removed = item.availability !== 'available'; const body = (() => { if (removed) return
该内容已失效或被删除
; switch (item.type) { case 'text': return (

{item.snapshot.text || (typeof item.snapshot.content === 'string' ? item.snapshot.content : '')}

); case 'image': case 'video': { const data = favoriteEntityData(item.snapshot.content, item.type === 'video' ? 'VD' : 'IM'); // Video's own `val` is the actual video bytes (webm/mp4) -- not paintable as an -- // the paintable poster frame is the separate inline `data.preview` (confirmed live: base64 // JPEG signature); image has no separate poster since the image itself IS the thumbnail. const inlineVal = typeof (item.type === 'video' ? data?.preview : data?.val) === 'string' ? ((item.type === 'video' ? data?.preview : data?.val) as string) : undefined; const inlineMime = item.type === 'video' ? 'image/jpeg' : typeof data?.mime === 'string' ? data.mime : undefined; return ( ); } case 'voice': { // Reuses the same real, working audio player chat bubbles use (AttachmentPreview.tsx) -- // this card used to be static (a bare play_circle icon with no click handler at all), so a // favorited voice message could never actually be played from this view. const data = favoriteEntityData(item.snapshot.content, 'AU'); return ( // Stops the play/pause click from also bubbling up to the card's own onClick -- in // selectMode that outer handler toggles selection, and without this, tapping play would // simultaneously start/stop audio AND select/deselect the card.
e.stopPropagation()}>
); } case 'file': { const data = favoriteEntityData(item.snapshot.content, 'EX'); const name = (typeof data?.name === 'string' ? data.name : undefined) || item.snapshot.file?.name || item.snapshot.name || ''; const size = (typeof data?.size === 'number' ? data.size : undefined) ?? item.snapshot.file?.size ?? item.snapshot.size ?? 0; const mime = (typeof data?.mime === 'string' ? data.mime : undefined) || item.snapshot.file?.mime || item.snapshot.mime; const style = fileTypeStyle(name, mime); return (
{name} {formatFileSize(size)}
); } case 'link': { // Unlike image/video/voice/file, a Drafty `LN` entity annotates a RANGE of the message's own // text with a hyperlink (see drafty.tsx's own LN case) -- it has no separate title/thumbnail // data of its own the way a standalone attachment entity does. So a "link" favorite's real // snapshot is most likely just the SAME whole-message wrapper every other type turned out to // be (Part D's finding), meaning its own top-level `text` field (identical source the 'text' // case above already reads) is the one thing actually guaranteed to be there -- the flat // `title`/`url`/`thumbnail_file_id` fields below have never been confirmed against real data // and are kept only as an optional enhancement layer, not the sole source. Without this // fallback, a real link favorite whose backend response doesn't happen to populate those flat // fields rendered a card with a completely empty title line. const text = item.snapshot.text || (typeof item.snapshot.content === 'string' ? item.snapshot.content : ''); const title = item.snapshot.title || item.snapshot.url || text; // Only show a second line when it says something the title line doesn't already -- a // hostname alongside a real title/url is useful, but repeating `text` under itself when // that's ALSO what the title line fell back to would just be the same line twice. // `new URL()` throws on anything that isn't a well-formed absolute URL -- `url` can come from // another client's own Drafty implementation or the backend's own extraction logic (docs/ // IM-前后端对接文档.md §7.6), neither of which this codebase controls the shape of. A single // malformed value here must not crash the whole render (there's no error boundary around this // list short of react-router's own default one, which would replace the entire logged-in UI). let subtitle = title === text ? '' : text; if (item.snapshot.url) { try { subtitle = new URL(item.snapshot.url).hostname; } catch { // fall through to the plain-text fallback computed above } } return (
{item.snapshot.thumbnail_file_id ? ( ) : ( )} {title} {subtitle ? {subtitle} : null}
); } default: return null; } })(); // A plain
(not ))}
); const searchBox = (
handleQueryChange(e.target.value)} placeholder="搜索收藏内容" className="ml-2 w-full bg-transparent text-sm text-text-body outline-none placeholder:text-text-placeholder-2" />
); return (
{/* README §6.1's 3-column desktop layout (nav / 260px category rail / content). Below `md` there's no room to dock a 260px rail next to content at all -- screens/05-favorites- history.dc.html's own mobile mock (#1d) doesn't try to: it's a SINGLE screen with a horizontal pill category-switcher inline above the list, not a separate "pick a category" screen you navigate away from. This aside is desktop-only; its mobile equivalent is the pill row rendered inline in the content column below. */}
{selectMode ? (
已选择 {selected.size} 项
) : ( <> {/* Mobile-only header -- `/favorites` is only reachable on a narrow viewport via 设置(我的)>我的收藏 (SettingsLayout.tsx), so "back" returns there, matching every other narrow-only-reachable-via-parent-list page's own back-arrow convention. */}
我的收藏 {items.length ? ( ) : ( )}
{searchBox}
{categoryPicker}
{CATEGORIES.find((c) => c.key === category)?.label} {items.length ? ( ) : null}
)}
{favoritesQuery.isLoading ? (
加载中…
) : favoritesQuery.isError ? (
加载失败,请重试
) : items.length === 0 ? (
暂无收藏内容
) : ( <> {/* 1 column under 640px (matches the mobile mock's plain vertical card list exactly), 3 at md+ (matches the desktop mock's 3-column grid); 2 in between is this app's own judgment call, not in either mock, for the range where 1 column would waste width and 3 would be as cramped as the bug this replaces. */}
{items.map((item) => ( toggle(item.favorite_id)} /> ))}
{favoritesQuery.hasNextPage ? (
) : null} )}
{forwarding ? ( setForwarding(false)} onPick={(targetTopics, comment) => forwardMutation.mutate({ targetTopics, comment })} /> ) : null} setError('')} />
); }