项目文件夹

文件
wehub-resource-sync 2114b14ee0
Sync main into demo / sync (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:35:26 +08:00

512 行
21 KiB
TypeScript

import React from 'react';
import { IcNavBack, IcMoreVert, IcSend, IcReply, IcCopy, IcShare, IcDelete, IcAdd } from '../res/icons';
import { useLocation } from 'react-router-dom';
import { useRedditStore } from '../state';
import { useShallow } from 'zustand/react/shallow';
import { useRedditGestures } from '../hooks/useRedditGestures';
import { formatChatDateShort, formatChatMessageTime, getChatDayKey } from '../utils/chatTime';
import { getUserAvatar } from '../utils/userIdentity';
import { KeyboardService } from '@/os/keyboard';
import { ClipboardService } from '@/os/clipboard';
type ChatMessage = {
id: string;
from: 'me' | 'them';
body: string;
created_utc: number;
};
const pickAvatar = (usernameLike: string): string | undefined => getUserAvatar(usernameLike);
function getUsernameFromPath(pathname: string): string | null {
const m = pathname.match(/^\/chat\/([^/?#]+)/);
if (!m) return null;
const u = decodeURIComponent(m[1]);
if (!u || u === 'new') return null;
return u;
}
export const ChatThreadPage: React.FC = () => {
const { chatThreads, chatReplies, user } = useRedditStore(useShallow((s) => ({
chatThreads: s.chatThreads,
chatReplies: s.chatReplies,
user: s.user,
})));
const storeSeedChatThread = useRedditStore((s) => s.seedChatThread);
const storeSendChatMessage = useRedditStore((s) => s.sendChatMessage);
const storeDeleteChatMessage = useRedditStore((s) => s.deleteChatMessage);
const { bindBack, bindTap, bindLongPress } = useRedditGestures();
const location = useLocation();
const username = getUsernameFromPath(location.pathname);
const [draft, setDraft] = React.useState('');
const [longPressMenu, setLongPressMenu] = React.useState<ChatMessage | null>(null);
const [confirmDelete, setConfirmDelete] = React.useState<ChatMessage | null>(null);
const inputRef = React.useRef<HTMLTextAreaElement | null>(null);
const messagesEndRef = React.useRef<HTMLDivElement | null>(null);
const scrollToBottom = React.useCallback((instant = false) => {
messagesEndRef.current?.scrollIntoView({ behavior: instant ? 'auto' : 'smooth' });
}, []);
const thread = React.useMemo(() => {
if (!username) return [];
const list = chatThreads[username];
return Array.isArray(list) ? (list as ChatMessage[]) : [];
}, [chatThreads, username]);
// Seed initial message for the demo chats, if empty.
React.useEffect(() => {
if (!username) return;
const existing = chatThreads[username];
if (Array.isArray(existing) && existing.length > 0) return;
const seedBody = username === 'Objective-Skill-2591' ? "well,it's so funny" : 'hello';
storeSeedChatThread(username, seedBody);
}, [storeSeedChatThread, chatThreads, username]);
// 消息变化时滚到底部
React.useLayoutEffect(() => {
scrollToBottom(true);
}, [thread]);
// 键盘弹出时瞬间滚到底部,避免最新消息被遮挡
React.useEffect(() => {
let wasVisible = false;
return KeyboardService.subscribe(() => {
const nowVisible = KeyboardService.isVisible();
if (nowVisible && !wasVisible) {
setTimeout(() => scrollToBottom(true), 50);
}
wasVisible = nowVisible;
});
}, []);
const canSend = draft.trim().length > 0 && !!username;
const closeLongPressMenu = React.useCallback(() => setLongPressMenu(null), []);
const copyText = React.useCallback((text: string) => {
ClipboardService.copyText(String(text ?? ''));
}, []);
const deleteMessage = React.useCallback((messageId: string) => {
if (!username) return;
storeDeleteChatMessage(username, messageId);
}, [storeDeleteChatMessage, username]);
const send = React.useCallback(() => {
if (!username) return;
const body = draft.trim();
if (!body) return;
storeSendChatMessage(username, body);
setDraft('');
requestAnimationFrame(() => {
if (inputRef.current) {
inputRef.current.style.height = 'auto';
inputRef.current.style.overflow = 'hidden';
inputRef.current.focus();
}
});
}, [draft, storeSendChatMessage, username]);
const getThreadInfo = React.useCallback(
(messageId: string): { count: number; lastFrom: 'me' | 'them' } => {
if (!username) return { count: 0, lastFrom: 'me' };
const k = `${username}:${messageId}`;
const list = chatReplies?.[k];
if (!Array.isArray(list) || list.length === 0) return { count: 0, lastFrom: 'me' };
const last = list[list.length - 1] as ChatMessage;
return { count: list.length, lastFrom: last?.from ?? 'me' };
},
[chatReplies, username],
);
if (!username) {
return (
<div className="flex flex-col h-full bg-app-surface">
<div className="px-4 pt-10">Invalid chat.</div>
</div>
);
}
const avatarSrc = pickAvatar(username);
const meName = user.username || 'Embarrassed_Fee8630';
const myAvatarSrc = user.avatar || pickAvatar(meName);
return (
<div className="flex flex-col h-full bg-app-surface">
{/* Top bar */}
<div className="flex items-center gap-2 px-2 pt-10 pb-2 border-b border-gray-100">
<button
type="button"
aria-label="Back"
className="w-10 h-10 rounded-full flex items-center justify-center active:bg-gray-100"
{...bindBack()}
>
<IcNavBack className="w-6 h-6 text-gray-800" strokeWidth={2} />
</button>
<div className="w-10 h-10 rounded-full bg-gray-200 overflow-hidden flex-shrink-0">
<img
src={avatarSrc}
className="w-full h-full object-cover"
alt=""
draggable={false}
onError={(e) => (e.currentTarget.style.display = 'none')}
/>
</div>
<div className="flex-1 min-w-0">
<div className="text-[16px] font-semibold text-app-text truncate">{username}</div>
</div>
<button
type="button"
aria-label="More"
onClick={() => {}}
className="w-10 h-10 rounded-full flex items-center justify-center active:bg-gray-100"
>
<IcMoreVert className="w-6 h-6 text-gray-700" strokeWidth={2} />
</button>
</div>
<div className="flex-1 overflow-y-auto no-scrollbar bg-app-surface" data-scroll-container="main" data-scroll-direction="vertical">
<div className="min-h-full flex flex-col">
{/* Profile header like screenshot */}
<div className="pt-8 pb-10 flex flex-col items-center">
<div className="w-28 h-28 rounded-full bg-gray-200 overflow-hidden">
<img
src={avatarSrc}
className="w-full h-full object-cover"
alt=""
draggable={false}
loading="lazy"
onError={(e) => (e.currentTarget.style.display = 'none')}
/>
</div>
<div className="mt-4 text-[18px] font-semibold text-app-text">{username}</div>
<div className="mt-1 text-[13px] text-app-text-muted">531 karma redditor for 1y 10m</div>
<button
type="button"
onClick={() => {}}
className="mt-4 h-10 px-6 rounded-full bg-gray-100 text-app-text font-semibold"
>
View profile
</button>
</div>
{/* Message list (stick to bottom when few) */}
<div className="mt-auto px-4 pb-2">
{thread.map((m, idx) => {
const currentKey = getChatDayKey(m.created_utc);
const prevKey = idx > 0 ? getChatDayKey(thread[idx - 1].created_utc) : null;
const showDay = idx === 0 || currentKey !== prevKey;
const threadInfo = getThreadInfo(m.id);
const replyCount = threadInfo.count;
const openThreadProps = bindTap('chatThread.message.thread.open', {
params: { username, messageId: m.id },
});
return (
<React.Fragment key={m.id}>
{showDay && (
<div className="flex justify-center text-[12px] text-gray-400 mb-4">
{formatChatDateShort(m.created_utc)}
</div>
)}
<div
className="flex gap-3 mb-4"
{...bindLongPress(
{ kind: 'action', id: 'chatThread.message.longPress.open' },
{
duration: 600,
params: { username, messageId: m.id },
onTrigger: () => setLongPressMenu(m),
},
)}
>
<div className="w-10 h-10 rounded-full bg-gray-200 overflow-hidden flex-shrink-0">
<img
src={m.from === 'me' ? myAvatarSrc : avatarSrc}
className="w-full h-full object-cover"
alt=""
draggable={false}
loading="lazy"
onError={(e) => (e.currentTarget.style.display = 'none')}
/>
</div>
<div className="min-w-0 flex-1" {...openThreadProps}>
<div className="text-[13px] text-gray-600">
<span className="font-semibold text-app-text">{m.from === 'me' ? meName : username}</span>{' '}
<span className="text-gray-400">{formatChatMessageTime(m.created_utc)}</span>
</div>
<div className="text-[15px] text-app-text leading-relaxed whitespace-pre-wrap">{m.body}</div>
{replyCount > 0 && (
<button
type="button"
{...openThreadProps}
className="mt-2 flex items-center gap-2 text-[14px] font-semibold text-[#0045AC] active:opacity-90"
aria-label="Open thread"
>
<div className="w-6 h-6 rounded-full bg-gray-200 overflow-hidden flex-shrink-0">
<img
src={threadInfo.lastFrom === 'me' ? myAvatarSrc : avatarSrc}
className="w-full h-full object-cover"
alt=""
draggable={false}
onError={(e) => (e.currentTarget.style.display = 'none')}
/>
</div>
<span>
{replyCount} {replyCount === 1 ? 'reply' : 'replies'}
</span>
</button>
)}
</div>
</div>
</React.Fragment>
);
})}
<div ref={messagesEndRef} />
</div>
</div>
</div>
{/* Input bar */}
<div className="flex-shrink-0 border-t border-app-border bg-app-surface px-4 py-3" data-keep-keyboard="true">
<div className="flex items-center gap-3">
<textarea
ref={inputRef}
rows={1}
value={draft}
onChange={(e) => setDraft(e.target.value)}
placeholder="Message"
className="flex-1 min-h-[44px] max-h-[120px] px-4 py-2.5 rounded-[22px] bg-gray-100 outline-none text-[15px] text-app-text placeholder-gray-500 resize-none leading-[22px]"
style={{ height: 'auto', overflow: 'hidden' }}
onInput={(e) => {
const el = e.currentTarget;
el.style.height = 'auto';
el.style.height = Math.min(el.scrollHeight, 120) + 'px';
el.style.overflow = el.scrollHeight > 120 ? 'auto' : 'hidden';
}}
/>
<button
type="button"
aria-label="Send"
onPointerDown={(e) => e.preventDefault()}
{...bindTap(
{ kind: 'action', id: 'chatThread.message.submit' },
{ params: { username, body: draft }, onTrigger: send },
)}
className={`w-11 h-11 rounded-full flex items-center justify-center ${
canSend ? 'bg-[#0045AC] text-white' : 'bg-gray-200 text-gray-400'
}`}
>
<IcSend className="w-5 h-5" strokeWidth={2} />
</button>
</div>
</div>
{/* Long-press menu overlay */}
{longPressMenu && (
<div className="fixed inset-0 z-[700]">
<button
type="button"
aria-label="Close message menu"
className="absolute inset-0 bg-black/55"
onClick={closeLongPressMenu}
/>
{/* content */}
<div className="absolute left-0 right-0 bottom-0 pb-6">
{/* preview bubble */}
<div className="px-4 pb-3" onClick={(e) => e.stopPropagation()} onPointerDown={(e) => e.stopPropagation()}>
<div className="bg-app-surface rounded-2xl px-4 py-3 shadow-[0_10px_30px_rgba(0,0,0,0.25)]">
<div className="flex items-start gap-3">
<div className="w-10 h-10 rounded-full bg-gray-200 overflow-hidden flex-shrink-0">
<img
src={longPressMenu.from === 'me' ? myAvatarSrc : avatarSrc}
className="w-full h-full object-cover"
alt=""
draggable={false}
loading="lazy"
onError={(e) => (e.currentTarget.style.display = 'none')}
/>
</div>
<div className="min-w-0">
<div className="text-[14px] text-gray-700">
<span className="font-semibold text-app-text">
{longPressMenu.from === 'me' ? meName : username}
</span>{' '}
<span className="text-gray-400">{formatChatMessageTime(longPressMenu.created_utc)}</span>
</div>
<div className="mt-1 text-[15px] text-app-text leading-relaxed whitespace-pre-wrap">
{longPressMenu.body}
</div>
</div>
</div>
</div>
</div>
{/* emoji row (visual) */}
<div className="px-4 pb-3">
<div
className="bg-app-surface rounded-2xl px-4 py-3 shadow-[0_10px_30px_rgba(0,0,0,0.18)]"
onClick={(e) => e.stopPropagation()}
onPointerDown={(e) => e.stopPropagation()}
>
<div className="flex items-center justify-between">
{[
{ id: 'like', emoji: '👍', label: 'Like' },
{ id: 'love', emoji: '❤️', label: 'Love' },
{ id: 'laugh', emoji: '😂', label: 'Funny' },
{ id: 'surprised', emoji: '😮', label: 'Wow' },
{ id: 'sad', emoji: '😢', label: 'Sad' },
].map((item) => (
<button
key={item.id}
type="button"
className="w-11 h-11 rounded-full bg-gray-100 flex flex-col items-center justify-center active:bg-gray-200"
aria-label={item.label}
>
<span className="text-[20px] leading-none">{item.emoji}</span>
</button>
))}
<button
type="button"
className="w-11 h-11 rounded-full bg-gray-100 flex items-center justify-center active:bg-gray-200"
aria-label="More reactions"
>
<IcAdd className="w-6 h-6 text-gray-700" strokeWidth={2} />
</button>
</div>
</div>
</div>
{/* action sheet */}
<div
className="bg-app-surface rounded-t-3xl shadow-[0_-12px_28px_rgba(0,0,0,0.20)]"
onClick={(e) => e.stopPropagation()}
onPointerDown={(e) => e.stopPropagation()}
>
{[
{
id: 'chatThread.message.menu.reply',
icon: <IcReply className="w-6 h-6" strokeWidth={2} />,
label: 'Reply',
onTrigger: () => {
// Open thread page (same UI as screenshot), then close menus.
setConfirmDelete(null);
setLongPressMenu(null);
},
},
{
id: 'chatThread.message.menu.copy',
icon: <IcCopy className="w-6 h-6" strokeWidth={2} />,
label: 'Copy text',
onTrigger: async () => {
await copyText(longPressMenu.body);
closeLongPressMenu();
},
},
{
id: 'chatThread.message.menu.share',
icon: <IcShare className="w-6 h-6" strokeWidth={2} />,
label: 'Share',
onTrigger: () => {
// Visual-only for now
closeLongPressMenu();
},
},
{
id: 'chatThread.message.menu.delete',
icon: <IcDelete className="w-6 h-6" strokeWidth={2} />,
label: 'Delete message',
onTrigger: () => {
// Open confirm dialog (do not delete immediately)
setConfirmDelete(longPressMenu);
},
},
].map((item) => (
<button
key={item.id}
type="button"
{...(item.id === 'chatThread.message.menu.reply'
? bindTap('chatThread.message.thread.open', {
params: { username, messageId: longPressMenu.id },
beforeTrigger: () => {
// Close overlays before navigating.
setConfirmDelete(null);
setLongPressMenu(null);
},
})
: bindTap(
{ kind: 'action', id: item.id },
{
params: { username, messageId: longPressMenu.id },
onTrigger: item.onTrigger,
},
))}
className="w-full flex items-center gap-4 px-6 py-4 text-left active:bg-gray-50"
>
<div className="w-7 h-7 flex items-center justify-center text-gray-800">{item.icon}</div>
<div className="text-[18px] text-app-text">{item.label}</div>
</button>
))}
<div className="h-3" />
</div>
</div>
</div>
)}
{/* Delete confirm dialog (shown above long-press menu) */}
{confirmDelete && (
<div className="fixed inset-0 z-[820] flex items-center justify-center px-5">
<button
type="button"
aria-label="Close delete dialog"
className="absolute inset-0 bg-black/35"
onClick={() => setConfirmDelete(null)}
/>
<div
className="relative w-full max-w-[420px] bg-app-surface rounded-2xl shadow-[0_18px_40px_rgba(0,0,0,0.30)] overflow-hidden"
onClick={(e) => e.stopPropagation()}
onPointerDown={(e) => e.stopPropagation()}
>
<div className="px-6 pt-6">
<div className="text-[24px] font-black text-app-text">Delete this message?</div>
<div className="mt-3 text-[15px] text-gray-600 leading-relaxed">
It will be removed for everyone in this chat.
</div>
</div>
<div className="px-6 pb-6 pt-6 space-y-3">
<button
type="button"
className="w-full h-12 rounded-full bg-[#0045AC] text-white font-black active:opacity-95"
onClick={() => {
deleteMessage(confirmDelete.id);
setConfirmDelete(null);
setLongPressMenu(null);
}}
>
Yes, Delete
</button>
<button
type="button"
className="w-full h-12 rounded-full bg-gray-200 text-gray-800 font-black active:opacity-95"
onClick={() => setConfirmDelete(null)}
>
Cancel
</button>
</div>
</div>
</div>
)}
</div>
);
};