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
);
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.
);
}
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 (