Both real mobile clients (iOS, Android) forward messages via POST
/v1/im/messages/forward, never a client-side {pub} packet -- confirmed
by reading their source directly. Switches web's single-message and
bulk forward paths to the same endpoint, which copies message content
verbatim regardless of shape, eliminating the client-side flat-content
detection this used to require. Comment-sending stays on the existing
sendText() path, unchanged, to preserve current ordering.
Removes send.ts's now-unused forwardMessage/flatForwardPayload.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
432 lines
22 KiB
TypeScript
432 lines
22 KiB
TypeScript
import { useState } from 'react';
|
||
|
||
import { useInfiniteQuery, useMutation, useQuery } from '@tanstack/react-query';
|
||
|
||
import { Icon } from '@/app/Icon';
|
||
import { getAllGroupMembers } from '@/lib/api/groups';
|
||
import { getTopicMedia, searchTopicMessages, type MediaFetchType, type MediaItem, type MediaType, type SearchHit } from '@/lib/api/messages';
|
||
import { checkUrlSafety } from '@/lib/api/security';
|
||
import { avatarBgClass, avatarLetter } from '@/lib/avatar';
|
||
import { useAuthedBlobUrl, useDebouncedValue, useEscapeKey } from '@/lib/hooks';
|
||
import { contactCardContentOf, mobileAttachmentContentOf } from '@/lib/messages/dispatch';
|
||
import { renderDrafty } from '@/lib/messages/drafty';
|
||
import { groupSenderLabel } from '@/lib/messages/sender-name';
|
||
import { formatEventTimestamp } from '@/lib/messages/time';
|
||
import { requestConfirm } from '@/stores/confirmDialog';
|
||
import { notify } from '@/stores/globalToast';
|
||
import { useMessageJumpStore } from '@/stores/messageJump';
|
||
import { useSessionStore } from '@/stores/session';
|
||
|
||
type Tab = 'text' | MediaType;
|
||
|
||
const TABS: { key: Tab; label: string; icon: string }[] = [
|
||
{ key: 'text', label: '聊天记录', icon: 'search' },
|
||
{ key: 'image', label: '图片', icon: 'image' },
|
||
{ key: 'video', label: '视频', icon: 'videocam' },
|
||
{ key: 'file', label: '文件', icon: 'description' },
|
||
{ key: 'link', label: '链接', icon: 'link' },
|
||
];
|
||
|
||
function formatDuration(ms: number | undefined): string {
|
||
const totalSeconds = Math.round((ms || 0) / 1000);
|
||
return `${Math.floor(totalSeconds / 60)}:${String(totalSeconds % 60).padStart(2, '0')}`;
|
||
}
|
||
|
||
/** README §3.4 grid thumbnails -- same inline-vs-authenticated-fetch duality as AttachmentPreview's
|
||
* ImageAttachment (a grid cell needs its own aspect-square crop, so this doesn't reuse that
|
||
* component directly, but reads the same `ent[0].data.{val,ref}` shape). */
|
||
function MediaThumb({ item, isVideo }: { item: MediaItem; isVideo: boolean }) {
|
||
const data = item.ent?.[0]?.data;
|
||
const inline = typeof data?.val === 'string' ? `data:${data.mime || 'image/jpeg'};base64,${data.val}` : null;
|
||
const remote = useAuthedBlobUrl(inline ? undefined : data?.ref);
|
||
const src = inline || remote.url;
|
||
|
||
return (
|
||
<div className="relative aspect-square overflow-hidden bg-surface-rail">
|
||
{src ? (
|
||
<img src={src} alt="" className="h-full w-full object-cover" />
|
||
) : (
|
||
<div className="grid h-full w-full place-items-center">
|
||
<Icon name={remote.error ? 'broken_image' : 'image'} className="text-[24px] text-text-tertiary-3" />
|
||
</div>
|
||
)}
|
||
{isVideo && src ? (
|
||
<div className="absolute inset-0 bg-black/10">
|
||
<Icon name="play_circle" className="absolute inset-0 m-auto text-[32px] text-white/90" />
|
||
{data?.duration ? (
|
||
<span className="absolute bottom-1 right-1.5 rounded bg-black/50 px-1 text-[11px] text-white">{formatDuration(data.duration)}</span>
|
||
) : null}
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/** Renders a search hit's original message content (text/file/voice/...) via the same Drafty
|
||
* formatter regular chat bubbles use -- `snapshot.content` is either a real, complete Drafty
|
||
* document OR (for a plain unformatted text message, confirmed live) a bare string, exactly the
|
||
* same duality `renderDrafty` already resolves for every other caller -- so this passes it straight
|
||
* through rather than picking fields back out of it by hand. The previous version's manual
|
||
* `{txt:content.txt||' ', ...}` reconstruction silently produced a single blank space for the bare-
|
||
* string case (`content.txt` on a string primitive is `undefined`), which is exactly what a live
|
||
* search against a real plain-text message showed before this fix.
|
||
*
|
||
* Real, DB/API-confirmed bug fixed here (2026-09-24): a mobile-sent message's `snapshot.content` is
|
||
* a FLAT, non-Drafty object (`{text:...}` for text, `{file_id,...}` for image/file/voice/video,
|
||
* `{user_id,...}` for a contact card), tagged only by `snapshot.head['x-im-type']` -- confirmed live
|
||
* via a real search hit's raw network response (`snapshot.head` IS present despite not previously
|
||
* being modeled on `SearchHit`, same as favorites.ts's own snapshot). Handing that straight to
|
||
* `renderDrafty` used to crash the WHOLE APP the moment a keyword matched one (`Drafty.toPlainText`
|
||
* returns `undefined` for a shape with no `.txt`, and `highlightMatches` then called `.toLowerCase()`
|
||
* on it -- this app has no error boundary anywhere to contain that). Same flat-vs-Drafty shape
|
||
* distinction `dispatch.ts`'s own `mobileAttachmentContentOf`/`contactCardContentOf` already
|
||
* document for message bubbles. */
|
||
function SnapshotContent({ item, highlightQuery }: { item: SearchHit; highlightQuery?: string }) {
|
||
const content = item.snapshot?.content;
|
||
if (!content) return <span className="text-text-tertiary-4">(内容不可用)</span>;
|
||
const asMessage = { content, head: item.snapshot?.head } as Parameters<typeof mobileAttachmentContentOf>[0];
|
||
const mobileAttachment = mobileAttachmentContentOf(asMessage);
|
||
if (mobileAttachment) {
|
||
const label =
|
||
mobileAttachment.kind === 'image'
|
||
? '[图片]'
|
||
: mobileAttachment.kind === 'video'
|
||
? '[视频]'
|
||
: mobileAttachment.kind === 'voice'
|
||
? '[语音]'
|
||
: `[文件] ${mobileAttachment.name || ''}`;
|
||
return <span className="text-text-body">{label}</span>;
|
||
}
|
||
if (contactCardContentOf(asMessage)) return <span className="text-text-body">[名片]</span>;
|
||
// Any OTHER flat shape this app doesn't recognize yet (a future/unknown x-im-type, or corrupted
|
||
// data) still has neither `fmt`/`ent` (so `Drafty.isPlainText` says "plain text") nor `txt` (so
|
||
// `Drafty.toPlainText` returns `undefined`) -- same failure shape as the crash this whole fix
|
||
// addresses, just degrading to a silently blank line (highlightMatches' own defensive guard turns
|
||
// it into `[]`) instead of throwing. Surface that rather than leaving zero visual signal that
|
||
// something is there but unsupported, same spirit as the `(内容不可用)` placeholder above.
|
||
if (typeof content === 'object' && !('txt' in content) && !('fmt' in content) && !('ent' in content)) {
|
||
return <span className="text-text-tertiary-4">[暂不支持的消息类型]</span>;
|
||
}
|
||
return <div className="text-sm text-text-body">{renderDrafty(content, highlightQuery)}</div>;
|
||
}
|
||
|
||
/**
|
||
* docs/IM-前后端对接文档.md:1822,1898 -- links found in chat history must pass a server-side risk
|
||
* check before opening (the server fetches/classifies the URL out-of-band; the client never probes
|
||
* it directly). `warn` still lets the user proceed after an explicit confirm; `block` does not.
|
||
* This only guards the enrichment shape (`item.url` etc, per docs) -- if a hit doesn't carry that
|
||
* (unconfirmed live, see SearchHit's doc comment), it falls back to the plain snapshot rendering,
|
||
* whose inline links aren't gated here (same as any other chat-bubble link).
|
||
*/
|
||
function LinkResult({ item }: { item: SearchHit & { url?: string; title?: string; summary?: string } }) {
|
||
const safetyCheck = useMutation({ mutationFn: (url: string) => checkUrlSafety(url, 'history_search') });
|
||
|
||
if (!item.url) return <SnapshotContent item={item} />;
|
||
|
||
const open = async () => {
|
||
let result;
|
||
try {
|
||
result = await safetyCheck.mutateAsync(item.url!);
|
||
} catch {
|
||
notify('链接安全检查失败,请稍后重试。');
|
||
return;
|
||
}
|
||
if (result.decision === 'block') {
|
||
notify('该链接存在安全风险,已被拦截。');
|
||
return;
|
||
}
|
||
if (result.decision === 'warn' && !(await requestConfirm({ message: '该链接可能存在风险,是否仍要打开?' }))) return;
|
||
window.open(item.url, '_blank', 'noopener,noreferrer');
|
||
};
|
||
|
||
return (
|
||
<div className="rounded-lg bg-surface-rail px-3.5 py-3">
|
||
<div className="text-xs text-text-tertiary-3">{formatEventTimestamp(new Date(item.ts))}</div>
|
||
{item.title ? <div className="mt-0.5 truncate text-sm font-medium text-text-body">{item.title}</div> : null}
|
||
<button
|
||
type="button"
|
||
onClick={open}
|
||
disabled={safetyCheck.isPending}
|
||
className="mt-0.5 line-clamp-2 break-all text-left text-sm text-accent disabled:opacity-60"
|
||
>
|
||
{item.url}
|
||
</button>
|
||
{item.summary ? <div className="mt-0.5 line-clamp-2 text-xs text-text-tertiary-2">{item.summary}</div> : null}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function LoadMoreButton({ onClick, loading }: { onClick: () => void; loading: boolean }) {
|
||
return (
|
||
<button type="button" onClick={onClick} disabled={loading} className="py-3 text-center text-sm text-accent disabled:opacity-60">
|
||
{loading ? '加载中…' : '加载更多'}
|
||
</button>
|
||
);
|
||
}
|
||
|
||
interface HistorySearchModalProps {
|
||
topicId: string;
|
||
initialTab?: Tab;
|
||
onClose: () => void;
|
||
/** For sender-name/avatar resolution on the 消息 tab (`05-favorites-history.dc.html #1b`'s rows
|
||
* each carry the sender's name + colored avatar, e.g. "陈明远"/"接语彤"/"我") -- a group needs a
|
||
* member-list lookup per hit since `from` can be anyone; a P2P conversation only ever has two
|
||
* possible senders (me or the peer), so the caller's already-resolved `peerName` is enough. */
|
||
isGroup: boolean;
|
||
peerName: string;
|
||
}
|
||
|
||
/** README §3.4's "查找聊天记录 ›" + 4-icon grid, opened as a centered modal (README §1.5). */
|
||
export function HistorySearchModal({ topicId, initialTab = 'text', onClose, isGroup, peerName }: HistorySearchModalProps) {
|
||
const [tab, setTab] = useState<Tab>(initialTab);
|
||
const [query, setQuery] = useState('');
|
||
const debouncedQuery = useDebouncedValue(query, 300);
|
||
useEscapeKey(onClose);
|
||
const myUserId = useSessionStore((s) => s.myUserId);
|
||
const membersQuery = useQuery({
|
||
queryKey: ['group-members', topicId],
|
||
queryFn: () => getAllGroupMembers(topicId),
|
||
enabled: isGroup,
|
||
});
|
||
// Confirmed live: this endpoint's `from` (both the top-level field and `snapshot.from`) comes back
|
||
// with its `usr` prefix STRIPPED -- the same quirk already documented for favorites' own
|
||
// `snapshot.from` (see favorites.ts's header comment), just discovered here independently rather
|
||
// than assumed to apply. Comparing it directly against `myUserId`/`GroupMember.user_id` (both full
|
||
// `usr...` ids) would never match "me" or any real member, so every sender always fell back to the
|
||
// raw id. Reconstructing the full id is safe specifically because the stripping is a fixed, known
|
||
// prefix removal, not a lossy truncation.
|
||
const normalizeSender = (from: string) => (from.startsWith('usr') ? from : `usr${from}`);
|
||
const senderLabel = (from: string): string => {
|
||
const full = normalizeSender(from);
|
||
if (myUserId && full === myUserId) return '我';
|
||
if (isGroup) return groupSenderLabel(membersQuery.data?.find((m) => m.user_id === full)?.display_name);
|
||
return peerName;
|
||
};
|
||
|
||
const searchQuery = useInfiniteQuery({
|
||
queryKey: ['topic-search', topicId, debouncedQuery],
|
||
queryFn: ({ pageParam }) => searchTopicMessages(topicId, debouncedQuery, 'text', pageParam),
|
||
initialPageParam: '',
|
||
getNextPageParam: (lastPage) => lastPage.next_cursor || undefined,
|
||
enabled: tab === 'text' && debouncedQuery.trim().length > 0,
|
||
});
|
||
const searchItems = searchQuery.data?.pages.flatMap((p) => p.items) ?? [];
|
||
|
||
const mediaQuery = useInfiniteQuery({
|
||
queryKey: ['topic-media', topicId, tab],
|
||
queryFn: ({ pageParam }) => getTopicMedia(topicId, tab as MediaFetchType, pageParam),
|
||
initialPageParam: '',
|
||
getNextPageParam: (lastPage) => lastPage.next_cursor || undefined,
|
||
enabled: tab === 'image' || tab === 'video',
|
||
});
|
||
const mediaItems = mediaQuery.data?.pages.flatMap((p) => p.items) ?? [];
|
||
|
||
const fileLinkQuery = useInfiniteQuery({
|
||
queryKey: ['topic-search-media', topicId, tab, debouncedQuery],
|
||
// `searchTopicMessages` genuinely supports a keyword filter for file/link results (unlike
|
||
// `getTopicMedia`'s image/video browse, which is date-grouped, not keyword-searchable, per its
|
||
// own signature) -- this used to hardcode `''`, silently discarding the search box's query the
|
||
// moment a user switched off the 消息 tab, even though the box itself is spec'd as one shared
|
||
// header element sitting above all four tabs, not a 消息-tab-only control.
|
||
queryFn: ({ pageParam }) => searchTopicMessages(topicId, debouncedQuery, tab as 'file' | 'link', pageParam),
|
||
initialPageParam: '',
|
||
getNextPageParam: (lastPage) => lastPage.next_cursor || undefined,
|
||
enabled: tab === 'file' || tab === 'link',
|
||
});
|
||
const fileLinkItems = fileLinkQuery.data?.pages.flatMap((p) => p.items) ?? [];
|
||
|
||
// Search hits carry `seq` (used until now only for the timestamp/`key`) but nothing acted on it --
|
||
// clicking a result did nothing at all. ConversationView owns the actual scroll-to/highlight logic
|
||
// (it needs the message loaded first, which this modal has no reason to know how to do); this just
|
||
// hands off the target and gets out of the way.
|
||
const jumpToMessage = (seq: number) => {
|
||
useMessageJumpStore.getState().requestJump(topicId, seq);
|
||
onClose();
|
||
};
|
||
|
||
return (
|
||
// README §6.3 (mock `05/1e`): mobile is a full-bleed PAGE, not a dimmed centered dialog -- no
|
||
// overlay tint, no rounded card, search input lives in the header itself (with a "取消" text
|
||
// button instead of a close icon). Desktop (`md`+) keeps the original centered 720x620 dialog
|
||
// exactly as before.
|
||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-transparent md:bg-[#22262d]/25" onClick={onClose}>
|
||
<div
|
||
className="flex h-full w-full flex-col bg-white md:h-[620px] md:w-[720px] md:rounded-xl md:shadow-modal"
|
||
onClick={(e) => e.stopPropagation()}
|
||
>
|
||
<header className="hidden h-14 flex-none items-center justify-between border-b border-border-list-item px-5 md:flex">
|
||
<strong className="text-[15px] font-semibold text-text-heading">查找聊天记录</strong>
|
||
<button type="button" onClick={onClose} className="grid h-8 w-8 place-items-center text-text-tertiary-4">
|
||
<Icon name="close" />
|
||
</button>
|
||
</header>
|
||
<header className="flex h-[52px] flex-none items-center gap-2.5 px-4 md:hidden">
|
||
<div className="flex h-9.5 flex-1 items-center gap-2 rounded-[10px] bg-surface-search px-2.5">
|
||
<Icon name="search" className="text-[20px] text-text-placeholder-2" />
|
||
<input
|
||
value={query}
|
||
onChange={(e) => setQuery(e.target.value)}
|
||
placeholder={tab === 'text' ? '搜索聊天记录' : tab === 'file' ? '搜索文件' : tab === 'link' ? '搜索链接' : '搜索聊天记录'}
|
||
disabled={tab === 'image' || tab === 'video'}
|
||
className="w-full min-w-0 bg-transparent text-[15px] outline-none placeholder:text-text-placeholder-2 disabled:cursor-not-allowed"
|
||
/>
|
||
{query ? (
|
||
<button type="button" onClick={() => setQuery('')} className="grid flex-none place-items-center">
|
||
<Icon name="cancel" className="text-[18px] text-text-placeholder-2" />
|
||
</button>
|
||
) : null}
|
||
</div>
|
||
<button type="button" onClick={onClose} className="flex-none text-[15px] text-accent">
|
||
取消
|
||
</button>
|
||
</header>
|
||
|
||
<div className="flex flex-none gap-1 overflow-x-auto border-b border-border-list-item px-5 pt-3">
|
||
{TABS.map((t) => (
|
||
<button
|
||
key={t.key}
|
||
type="button"
|
||
onClick={() => setTab(t.key)}
|
||
className={`flex flex-none items-center gap-1.5 whitespace-nowrap rounded-t-lg px-3 py-2 text-sm ${
|
||
tab === t.key ? 'border-b-2 border-accent font-medium text-accent' : 'text-text-secondary'
|
||
}`}
|
||
>
|
||
<Icon name={t.icon} className="text-[16px]" />
|
||
{t.label}
|
||
</button>
|
||
))}
|
||
</div>
|
||
|
||
<div className="hidden flex-none px-5 py-3 md:block">
|
||
<div className="flex h-9 items-center rounded-lg bg-surface-search px-3">
|
||
<Icon name="search" className="text-[18px] text-text-placeholder-2" />
|
||
<input
|
||
autoFocus
|
||
value={query}
|
||
onChange={(e) => setQuery(e.target.value)}
|
||
placeholder={tab === 'text' ? '搜索聊天记录' : tab === 'file' ? '搜索文件' : tab === 'link' ? '搜索链接' : '搜索聊天记录'}
|
||
disabled={tab === 'image' || tab === 'video'}
|
||
className="ml-2 w-full bg-transparent text-sm outline-none placeholder:text-text-placeholder-2 disabled:cursor-not-allowed"
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
{tab === 'text' ? (
|
||
<div className="flex flex-1 flex-col overflow-hidden">
|
||
<div className="flex-1 overflow-y-auto px-5 pb-4">
|
||
{!debouncedQuery.trim() ? (
|
||
<div className="py-10 text-center text-sm text-text-tertiary-4">输入关键词搜索这个会话的聊天记录</div>
|
||
) : searchQuery.isLoading ? (
|
||
<div className="py-10 text-center text-sm text-text-tertiary-4">搜索中…</div>
|
||
) : searchQuery.isError ? (
|
||
<div className="flex flex-col items-center gap-2 py-10 text-sm text-danger-text">
|
||
<span>搜索失败,请重试</span>
|
||
<button type="button" onClick={() => searchQuery.refetch()} className="text-accent">
|
||
重试
|
||
</button>
|
||
</div>
|
||
) : !searchItems.length ? (
|
||
<div className="py-10 text-center text-sm text-text-tertiary-4">没有找到相关消息</div>
|
||
) : (
|
||
<>
|
||
<ul className="flex flex-col gap-1">
|
||
{searchItems.map((item) => {
|
||
const fullFrom = normalizeSender(item.from);
|
||
const name = senderLabel(item.from);
|
||
return (
|
||
<li key={item.seq}>
|
||
<button
|
||
type="button"
|
||
onClick={() => jumpToMessage(item.seq)}
|
||
className="flex w-full items-start gap-3 rounded-lg px-3 py-2.5 text-left hover:bg-surface-rail"
|
||
>
|
||
<span
|
||
className={`grid h-10 w-10 flex-none place-items-center rounded-lg text-sm font-medium text-white ${
|
||
myUserId && fullFrom === myUserId ? 'bg-accent' : avatarBgClass(fullFrom)
|
||
}`}
|
||
>
|
||
{avatarLetter(name)}
|
||
</span>
|
||
<div className="flex min-w-0 flex-1 flex-col gap-1">
|
||
<div className="flex items-center justify-between text-xs text-text-tertiary-3">
|
||
<span className="truncate text-text-tertiary-2">{name}</span>
|
||
<span className="flex-none">{formatEventTimestamp(new Date(item.ts))}</span>
|
||
</div>
|
||
<div className="truncate">
|
||
<SnapshotContent item={item} highlightQuery={debouncedQuery} />
|
||
</div>
|
||
</div>
|
||
</button>
|
||
</li>
|
||
);
|
||
})}
|
||
</ul>
|
||
{searchQuery.hasNextPage ? (
|
||
<LoadMoreButton onClick={() => searchQuery.fetchNextPage()} loading={searchQuery.isFetchingNextPage} />
|
||
) : null}
|
||
</>
|
||
)}
|
||
</div>
|
||
</div>
|
||
) : tab === 'image' || tab === 'video' ? (
|
||
<div className="flex-1 overflow-y-auto p-5">
|
||
{mediaQuery.isLoading ? (
|
||
<div className="py-10 text-center text-sm text-text-tertiary-4">加载中…</div>
|
||
) : mediaQuery.isError ? (
|
||
<div className="flex flex-col items-center gap-2 py-10 text-sm text-danger-text">
|
||
<span>加载失败,请重试</span>
|
||
<button type="button" onClick={() => mediaQuery.refetch()} className="text-accent">
|
||
重试
|
||
</button>
|
||
</div>
|
||
) : !mediaItems.length ? (
|
||
<div className="py-10 text-center text-sm text-text-tertiary-4">暂无内容</div>
|
||
) : (
|
||
<>
|
||
<div className="grid grid-cols-4 gap-0.5 overflow-hidden rounded-lg">
|
||
{mediaItems.map((item) => (
|
||
<MediaThumb key={item.seq} item={item} isVideo={tab === 'video'} />
|
||
))}
|
||
</div>
|
||
{mediaQuery.hasNextPage ? (
|
||
<LoadMoreButton onClick={() => mediaQuery.fetchNextPage()} loading={mediaQuery.isFetchingNextPage} />
|
||
) : null}
|
||
</>
|
||
)}
|
||
</div>
|
||
) : (
|
||
<div className="flex-1 overflow-y-auto p-5">
|
||
{fileLinkQuery.isLoading ? (
|
||
<div className="py-10 text-center text-sm text-text-tertiary-4">加载中…</div>
|
||
) : fileLinkQuery.isError ? (
|
||
<div className="flex flex-col items-center gap-2 py-10 text-sm text-danger-text">
|
||
<span>加载失败,请重试</span>
|
||
<button type="button" onClick={() => fileLinkQuery.refetch()} className="text-accent">
|
||
重试
|
||
</button>
|
||
</div>
|
||
) : !fileLinkItems.length ? (
|
||
<div className="py-10 text-center text-sm text-text-tertiary-4">暂无内容</div>
|
||
) : (
|
||
<>
|
||
<div className="flex flex-col gap-2">
|
||
{fileLinkItems.map((item) =>
|
||
tab === 'link' ? (
|
||
<LinkResult key={item.seq} item={item} />
|
||
) : (
|
||
<SnapshotContent key={item.seq} item={item} highlightQuery={debouncedQuery} />
|
||
),
|
||
)}
|
||
</div>
|
||
{fileLinkQuery.hasNextPage ? (
|
||
<LoadMoreButton onClick={() => fileLinkQuery.fetchNextPage()} loading={fileLinkQuery.isFetchingNextPage} />
|
||
) : null}
|
||
</>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|