- MessagesPage: 新增 updateConvUnread 统一更新会话未读数及 session 缓存, 缓存命中时仍调用 markConversationRead 并触发未读刷新, 全部标为已读时同步更新 session 缓存避免 stale 数据 - PostDetailPage: 仅 POP 导航类型使用 session 快照恢复, 非 POP 导航和下拉刷新始终强制重拉确保拿到最新数据
1281 lines
48 KiB
TypeScript
1281 lines
48 KiB
TypeScript
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||
import { Link, useNavigate, useSearchParams } from 'react-router-dom';
|
||
import {
|
||
ArrowLeft,
|
||
AtSign,
|
||
Bell,
|
||
CheckCheck,
|
||
Flag,
|
||
ImagePlus,
|
||
Inbox,
|
||
Mail,
|
||
MessageCircleReply,
|
||
Search,
|
||
Send,
|
||
ShieldAlert,
|
||
Smile,
|
||
X,
|
||
XCircle,
|
||
} from 'lucide-react';
|
||
import { Button } from '@/components/ui/button';
|
||
import { Spinner } from '@/components/ui/spinner';
|
||
import { notify } from '@/lib/notify';
|
||
import { api } from '../api/client';
|
||
import type { MessageConversation, PrivateMessage, User } from '../api/types';
|
||
import { useAuth } from '../hooks/useAuth';
|
||
import { loginPath } from '../utils/authRedirect';
|
||
import { useNoIndexSEO } from '../hooks/usePageSEO';
|
||
import { formatConvListTime, formatDateTime, formatTime } from '../utils/content';
|
||
import { postPath } from '../utils/permalink';
|
||
import { userPath } from '../utils/userPath';
|
||
import { InFlowSiteFooter } from '../components/SiteFooter';
|
||
import StickerPicker from '../components/emoji/StickerPicker';
|
||
import type { Sticker } from '../data/stickers';
|
||
import PmComposerInput, { isPmStickerSrc, type PmComposerInputHandle } from '../components/PmComposerInput';
|
||
import { ArticleImagePickerDialog } from '../components/editor/ArticleImagePickerDialog';
|
||
import { Tooltip } from '../components/ui/Tooltip';
|
||
import { cn } from '@/lib/utils';
|
||
import { getSessionSnapshot, setSessionSnapshot, deleteSessionSnapshot } from '../utils/sessionPageCache';
|
||
import { PAGE_FORCE_REFRESH_EVENT } from '../utils/feedCache';
|
||
import { PAGE_SOFT_REFRESH_COMMIT_EVENT } from '../utils/softRefresh';
|
||
|
||
type MsgTab = 'dm' | 'notify';
|
||
type ConvSnap = { conversations: MessageConversation[]; total: number; page: number };
|
||
type ThreadSnap = { messages: PrivateMessage[]; total: number; peerUser: User | null };
|
||
type PmDraftEmbed = { id: string; url: string; name: string };
|
||
type PmDraft = { text: string; embeds: PmDraftEmbed[] };
|
||
|
||
function newEmbedId() {
|
||
return `${Date.now()}-${Math.random().toString(36).slice(2, 9)}`;
|
||
}
|
||
|
||
function serializePmDraft(text: string, embeds: PmDraftEmbed[]) {
|
||
const trimmed = text.trim();
|
||
const parts = [
|
||
...(trimmed ? [trimmed] : []),
|
||
...embeds.map((e) => ``),
|
||
];
|
||
return parts.join('\n');
|
||
}
|
||
|
||
function isDraftEmpty(text: string, embeds: PmDraftEmbed[]) {
|
||
return !text.trim() && embeds.length === 0;
|
||
}
|
||
|
||
/** 搜索/主页跳转用的对方用户摘要 → User */
|
||
function toPeerUser(u: { id: number; username: string; nickname: string; avatar?: string }): User {
|
||
return {
|
||
id: u.id,
|
||
username: u.username,
|
||
nickname: u.nickname,
|
||
avatar: u.avatar || '',
|
||
role: 'user',
|
||
};
|
||
}
|
||
|
||
function makePendingConversation(peer: User): MessageConversation {
|
||
return {
|
||
peer_user_id: peer.id,
|
||
peer_user: peer,
|
||
is_system: false,
|
||
unread_count: 0,
|
||
updated_at: new Date().toISOString(),
|
||
};
|
||
}
|
||
|
||
const NOTIFY_KINDS = [
|
||
{ key: 'all', label: '全部' },
|
||
{ key: 'reply', label: '回复' },
|
||
{ key: 'mention', label: '@提及' },
|
||
{ key: 'moderation', label: '待审' },
|
||
{ key: 'reject', label: '未通过' },
|
||
{ key: 'report_result', label: '举报' },
|
||
{ key: 'system', label: '系统' },
|
||
] as const;
|
||
|
||
function kindLabel(kind: string, relatedStatus?: string) {
|
||
if (kind === 'moderation') {
|
||
if (relatedStatus && relatedStatus !== 'pending') return '审核';
|
||
return '待审';
|
||
}
|
||
switch (kind) {
|
||
case 'reject': return '未通过';
|
||
case 'report_result': return '举报结果';
|
||
case 'reply': return '回复提醒';
|
||
case 'mention': return '@提及';
|
||
case 'system': return '系统通知';
|
||
default: return '通知';
|
||
}
|
||
}
|
||
|
||
function statusLabel(status?: string) {
|
||
switch (status) {
|
||
case 'pending': return '待审';
|
||
case 'published': return '已通过';
|
||
case 'rejected': return '未通过';
|
||
case 'deleted': return '已删除';
|
||
default: return '';
|
||
}
|
||
}
|
||
|
||
/** 仍待处理的审核通知(无状态按待审处理,兼容未回填) */
|
||
function isPendingModeration(m: PrivateMessage) {
|
||
return m.kind === 'moderation' && (!m.related_status || m.related_status === 'pending');
|
||
}
|
||
|
||
/** 私信气泡:纯文本 + 简易 markdown 图片 `` */
|
||
function PmBubbleContent({ content }: { content: string }) {
|
||
const parts = content.split(/(!\[[^\]]*]\([^)]+\))/g);
|
||
return (
|
||
<div className="pm-bubble__text">
|
||
{parts.map((part, i) => {
|
||
const m = part.match(/^!\[([^\]]*)]\(([^)]+)\)$/);
|
||
if (m) {
|
||
const sticker = isPmStickerSrc(m[2]);
|
||
return (
|
||
<img
|
||
key={i}
|
||
className={sticker ? 'pm-bubble__sticker' : 'pm-bubble__img'}
|
||
src={m[2]}
|
||
alt={m[1] || (sticker ? '表情' : '图片')}
|
||
loading="lazy"
|
||
/>
|
||
);
|
||
}
|
||
return part ? <span key={i}>{part}</span> : null;
|
||
})}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function KindIcon({ kind }: { kind: string }) {
|
||
const props = { size: 15, 'aria-hidden': true as const };
|
||
switch (kind) {
|
||
case 'reply': return <MessageCircleReply {...props} />;
|
||
case 'mention': return <AtSign {...props} />;
|
||
case 'moderation': return <ShieldAlert {...props} />;
|
||
case 'reject': return <XCircle {...props} />;
|
||
case 'report_result': return <Flag {...props} />;
|
||
default: return <Bell {...props} />;
|
||
}
|
||
}
|
||
|
||
/** 按通知类型生成跳转目标与 CTA 文案(待审跳前台帖/楼层) */
|
||
function notifyTarget(m: PrivateMessage): { to: string; label: string } | null {
|
||
const postID = m.related_post_id;
|
||
const commentID = m.related_comment_id;
|
||
const floor = m.related_floor && m.related_floor > 0 ? m.related_floor : 0;
|
||
const looksLikeComment =
|
||
(m.subject || '').includes('评论') || (m.content || '').includes('评论');
|
||
|
||
if (m.kind === 'moderation') {
|
||
if (!postID) return null;
|
||
const isComment = !!commentID || looksLikeComment;
|
||
const path = isComment && floor > 0
|
||
? `${postPath(postID)}#floor-${floor}`
|
||
: postPath(postID);
|
||
const pending = isPendingModeration(m);
|
||
if (isComment) {
|
||
return { to: path, label: pending ? '去审核评论' : '查看评论' };
|
||
}
|
||
return { to: path, label: pending ? '去审核帖子' : '查看帖子' };
|
||
}
|
||
|
||
if (postID) {
|
||
const path = floor > 0 ? `${postPath(postID)}#floor-${floor}` : postPath(postID);
|
||
if (m.kind === 'reply' || m.kind === 'mention') {
|
||
return { to: path, label: floor > 0 ? '查看回复' : '查看帖子' };
|
||
}
|
||
if (m.kind === 'reject' && (commentID || looksLikeComment)) {
|
||
return { to: path, label: '查看评论' };
|
||
}
|
||
if (m.kind === 'report_result' && floor > 0) {
|
||
return { to: path, label: '查看评论' };
|
||
}
|
||
return { to: path, label: '查看帖子' };
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
function peerTitle(conv: MessageConversation | null, peerUser: User | null | undefined, peerId: number) {
|
||
if (peerId === 0 || conv?.is_system) return '系统通知';
|
||
return peerUser?.nickname || conv?.peer_user?.nickname || `用户 #${peerId}`;
|
||
}
|
||
|
||
function peerInitial(name: string) {
|
||
return name.trim().charAt(0) || '?';
|
||
}
|
||
|
||
function previewText(msg?: PrivateMessage) {
|
||
if (!msg) return '暂无消息';
|
||
const raw = (msg.content || msg.subject || '').trim();
|
||
if (!raw) return msg.subject || '暂无消息';
|
||
const text = raw
|
||
.replace(/!\[[^\]]*]\([^)]+\)/g, '[图片]')
|
||
.replace(/\s+/g, ' ')
|
||
.trim();
|
||
return text || msg.subject || '暂无消息';
|
||
}
|
||
|
||
function AvatarBubble({
|
||
name,
|
||
avatar,
|
||
system,
|
||
className,
|
||
}: {
|
||
name: string;
|
||
avatar?: string;
|
||
system?: boolean;
|
||
className?: string;
|
||
}) {
|
||
if (system) {
|
||
return (
|
||
<span className={cn('pm-avatar pm-avatar--system', className)} aria-hidden>
|
||
<Bell size={14} />
|
||
</span>
|
||
);
|
||
}
|
||
if (avatar) {
|
||
return <img src={avatar} alt="" className={cn('pm-avatar', className)} loading="lazy" decoding="async" />;
|
||
}
|
||
return <span className={cn('pm-avatar pm-avatar--fallback', className)}>{peerInitial(name)}</span>;
|
||
}
|
||
|
||
function parseTab(raw: string | null, peer: string | null): MsgTab {
|
||
if (peer !== null && peer !== '') return 'dm';
|
||
return raw === 'notify' ? 'notify' : 'dm';
|
||
}
|
||
|
||
export default function MessagesPage() {
|
||
const nav = useNavigate();
|
||
const { user, loading: authLoading } = useAuth();
|
||
const [params, setParams] = useSearchParams();
|
||
useNoIndexSEO('站内消息');
|
||
|
||
const peerParam = params.get('peer');
|
||
const tab = parseTab(params.get('tab'), peerParam);
|
||
const selectedPeer = peerParam === null || peerParam === ''
|
||
? null
|
||
: Number(peerParam);
|
||
const peerSelected = tab === 'dm' && selectedPeer !== null && !Number.isNaN(selectedPeer);
|
||
const notifyKind = params.get('kind') || 'all';
|
||
|
||
const [conversations, setConversations] = useState<MessageConversation[]>([]);
|
||
const [convTotal, setConvTotal] = useState(0);
|
||
const [convPage, setConvPage] = useState(1);
|
||
const [listLoading, setListLoading] = useState(true);
|
||
|
||
const [messages, setMessages] = useState<PrivateMessage[]>([]);
|
||
const [msgTotal, setMsgTotal] = useState(0);
|
||
const [threadLoading, setThreadLoading] = useState(false);
|
||
const [loadingOlder, setLoadingOlder] = useState(false);
|
||
const [peerUser, setPeerUser] = useState<User | null>(null);
|
||
const [draftText, setDraftText] = useState('');
|
||
const [draftEmbeds, setDraftEmbeds] = useState<PmDraftEmbed[]>([]);
|
||
const [showSticker, setShowSticker] = useState(false);
|
||
const [imagePickerOpen, setImagePickerOpen] = useState(false);
|
||
const composerRef = useRef<PmComposerInputHandle>(null);
|
||
const draftsByPeerRef = useRef<Map<number, PmDraft>>(new Map());
|
||
const draftPeerRef = useRef<number | null>(null);
|
||
const draftTextRef = useRef(draftText);
|
||
const draftEmbedsRef = useRef(draftEmbeds);
|
||
const [sending, setSending] = useState(false);
|
||
|
||
const [convSearchQuery, setConvSearchQuery] = useState('');
|
||
const [convSearchResults, setConvSearchResults] = useState<User[]>([]);
|
||
const [convSearchLoading, setConvSearchLoading] = useState(false);
|
||
const [convSearchOpen, setConvSearchOpen] = useState(false);
|
||
const convSearchSeq = useRef(0);
|
||
const convSearchBlurTimer = useRef<number | null>(null);
|
||
|
||
const [notifications, setNotifications] = useState<PrivateMessage[]>([]);
|
||
const [notifyTotal, setNotifyTotal] = useState(0);
|
||
const [notifyPage, setNotifyPage] = useState(1);
|
||
const [notifyLoading, setNotifyLoading] = useState(false);
|
||
const [notifyUnread, setNotifyUnread] = useState(0);
|
||
const [dmUnread, setDmUnread] = useState(0);
|
||
const [unreadOnly, setUnreadOnly] = useState(false);
|
||
|
||
const threadEndRef = useRef<HTMLDivElement>(null);
|
||
const threadScrollRef = useRef<HTMLDivElement>(null);
|
||
const stickToBottomRef = useRef(true);
|
||
const notifyLoadSeq = useRef(0);
|
||
|
||
useEffect(() => {
|
||
draftTextRef.current = draftText;
|
||
}, [draftText]);
|
||
|
||
useEffect(() => {
|
||
draftEmbedsRef.current = draftEmbeds;
|
||
}, [draftEmbeds]);
|
||
|
||
const updateDraftText = useCallback((next: string | ((prev: string) => string)) => {
|
||
setDraftText((prev) => {
|
||
const value = typeof next === 'function' ? next(prev) : next;
|
||
draftTextRef.current = value;
|
||
return value;
|
||
});
|
||
}, []);
|
||
|
||
const updateDraftEmbeds = useCallback((next: PmDraftEmbed[] | ((prev: PmDraftEmbed[]) => PmDraftEmbed[])) => {
|
||
setDraftEmbeds((prev) => {
|
||
const value = typeof next === 'function' ? next(prev) : next;
|
||
draftEmbedsRef.current = value;
|
||
return value;
|
||
});
|
||
}, []);
|
||
|
||
const dmConversations = useMemo(
|
||
() => conversations.filter((c) => !c.is_system && c.peer_user_id > 0),
|
||
[conversations],
|
||
);
|
||
|
||
/** 将会话加入左侧列表(无历史消息时也显示,类似飞书「发起会话」) */
|
||
const ensurePeerConversation = useCallback((peer: User) => {
|
||
setConversations((prev) => {
|
||
const idx = prev.findIndex((c) => c.peer_user_id === peer.id);
|
||
if (idx >= 0) {
|
||
const cur = prev[idx];
|
||
if (cur.peer_user) return prev;
|
||
const next = [...prev];
|
||
next[idx] = { ...cur, peer_user: peer };
|
||
return next;
|
||
}
|
||
return [makePendingConversation(peer), ...prev];
|
||
});
|
||
}, []);
|
||
|
||
/** 更新会话未读数,同时同步 session 缓存防止后续恢复 stale 数据 */
|
||
const updateConvUnread = useCallback((peerId: number, unread: number) => {
|
||
setConversations((prev) => {
|
||
const next = prev.map((c) =>
|
||
c.peer_user_id === peerId ? { ...c, unread_count: unread } : c
|
||
);
|
||
const cached = getSessionSnapshot<ConvSnap>('messages:conv:1');
|
||
if (cached) {
|
||
setSessionSnapshot('messages:conv:1', {
|
||
...cached,
|
||
conversations: next,
|
||
});
|
||
}
|
||
return next;
|
||
});
|
||
}, []);
|
||
|
||
const clearConvSearch = useCallback(() => {
|
||
setConvSearchQuery('');
|
||
setConvSearchResults([]);
|
||
setConvSearchOpen(false);
|
||
}, []);
|
||
|
||
const selectSearchUser = useCallback((peer: User) => {
|
||
ensurePeerConversation(peer);
|
||
clearConvSearch();
|
||
setShowSticker(false);
|
||
setImagePickerOpen(false);
|
||
const p = new URLSearchParams();
|
||
p.set('tab', 'dm');
|
||
p.set('peer', String(peer.id));
|
||
setParams(p, { replace: true });
|
||
}, [clearConvSearch, ensurePeerConversation, setParams]);
|
||
|
||
/** 切换会话:缓存当前草稿、恢复目标草稿,并关闭表情/图片面板 */
|
||
useEffect(() => {
|
||
setShowSticker(false);
|
||
setImagePickerOpen(false);
|
||
|
||
const prev = draftPeerRef.current;
|
||
const nextPeer = peerSelected && selectedPeer !== null ? selectedPeer : null;
|
||
|
||
if (prev != null && prev !== nextPeer) {
|
||
const text = draftTextRef.current;
|
||
const embeds = draftEmbedsRef.current;
|
||
if (isDraftEmpty(text, embeds)) {
|
||
draftsByPeerRef.current.delete(prev);
|
||
} else {
|
||
draftsByPeerRef.current.set(prev, { text, embeds });
|
||
}
|
||
}
|
||
|
||
draftPeerRef.current = nextPeer;
|
||
if (nextPeer != null) {
|
||
const saved = draftsByPeerRef.current.get(nextPeer);
|
||
const text = saved?.text ?? '';
|
||
const embeds = saved?.embeds ?? [];
|
||
draftTextRef.current = text;
|
||
draftEmbedsRef.current = embeds;
|
||
setDraftText(text);
|
||
setDraftEmbeds(embeds);
|
||
} else {
|
||
draftTextRef.current = '';
|
||
draftEmbedsRef.current = [];
|
||
setDraftText('');
|
||
setDraftEmbeds([]);
|
||
}
|
||
}, [peerSelected, selectedPeer]);
|
||
|
||
/** 搜索用户(发起新会话) */
|
||
useEffect(() => {
|
||
const q = convSearchQuery.trim();
|
||
if (!q) {
|
||
setConvSearchResults([]);
|
||
setConvSearchLoading(false);
|
||
return;
|
||
}
|
||
const seq = ++convSearchSeq.current;
|
||
setConvSearchLoading(true);
|
||
const timer = window.setTimeout(() => {
|
||
api.searchUsers(q, 8)
|
||
.then((r) => {
|
||
if (seq !== convSearchSeq.current) return;
|
||
const list = (r.users || [])
|
||
.filter((u) => u.id !== user?.id)
|
||
.map(toPeerUser);
|
||
setConvSearchResults(list);
|
||
})
|
||
.catch(() => {
|
||
if (seq !== convSearchSeq.current) return;
|
||
setConvSearchResults([]);
|
||
})
|
||
.finally(() => {
|
||
if (seq === convSearchSeq.current) setConvSearchLoading(false);
|
||
});
|
||
}, 280);
|
||
return () => window.clearTimeout(timer);
|
||
}, [convSearchQuery, user?.id]);
|
||
|
||
/** URL 指定 peer 但列表尚无该会话时,补入列表并预载用户信息 */
|
||
useEffect(() => {
|
||
if (!user || !peerSelected || selectedPeer === null || selectedPeer <= 0) return;
|
||
const hit = conversations.find((c) => c.peer_user_id === selectedPeer);
|
||
if (hit?.peer_user) {
|
||
setPeerUser((prev) => prev ?? hit.peer_user!);
|
||
return;
|
||
}
|
||
let cancelled = false;
|
||
api.userProfile(selectedPeer)
|
||
.then((r) => {
|
||
if (cancelled) return;
|
||
const peer = toPeerUser(r.user);
|
||
ensurePeerConversation(peer);
|
||
setPeerUser((prev) => prev ?? peer);
|
||
})
|
||
.catch(() => {
|
||
if (cancelled) return;
|
||
ensurePeerConversation({
|
||
id: selectedPeer,
|
||
username: `user${selectedPeer}`,
|
||
nickname: `用户 #${selectedPeer}`,
|
||
avatar: '',
|
||
role: 'user',
|
||
});
|
||
});
|
||
return () => { cancelled = true; };
|
||
}, [user, peerSelected, selectedPeer, conversations, ensurePeerConversation]);
|
||
|
||
const visibleNotifications = useMemo(
|
||
() => (unreadOnly ? notifications.filter((m) => !m.is_read) : notifications),
|
||
[notifications, unreadOnly],
|
||
);
|
||
|
||
const refreshUnreadSplit = useCallback(async () => {
|
||
try {
|
||
const r = await api.messageUnreadCount();
|
||
setDmUnread(r.dm_count ?? 0);
|
||
setNotifyUnread(r.notify_count ?? 0);
|
||
} catch {
|
||
// ignore
|
||
}
|
||
}, []);
|
||
|
||
const loadConversations = useCallback(async (page = 1, append = false) => {
|
||
const key = `messages:conv:${page}`;
|
||
if (!append) {
|
||
const hit = getSessionSnapshot<ConvSnap>(key);
|
||
if (hit) {
|
||
setConversations(hit.conversations);
|
||
setConvTotal(hit.total);
|
||
setConvPage(hit.page);
|
||
setListLoading(false);
|
||
return;
|
||
}
|
||
}
|
||
setListLoading(true);
|
||
try {
|
||
const r = await api.messageConversations({ page, size: 30 });
|
||
const next = r.conversations || [];
|
||
setConversations((prev) => {
|
||
const merged = append ? [...prev, ...next] : next;
|
||
if (!append) {
|
||
setSessionSnapshot(key, { conversations: merged, total: r.total || 0, page: r.page || page });
|
||
}
|
||
return merged;
|
||
});
|
||
setConvTotal(r.total || 0);
|
||
setConvPage(r.page || page);
|
||
} catch (e: unknown) {
|
||
notify.error(e instanceof Error ? e.message : '加载失败');
|
||
} finally {
|
||
setListLoading(false);
|
||
}
|
||
}, []);
|
||
|
||
// 通知列表不走 session 短路,保证 related_status 实时
|
||
const loadNotifications = useCallback(async (page = 1, append = false, kind = 'all') => {
|
||
const seq = ++notifyLoadSeq.current;
|
||
setNotifyLoading(true);
|
||
try {
|
||
const r = await api.messageNotifications({
|
||
page,
|
||
size: 30,
|
||
kind: kind === 'all' ? undefined : kind,
|
||
});
|
||
if (seq !== notifyLoadSeq.current) return;
|
||
const next = r.notifications || [];
|
||
setNotifyTotal(r.total || 0);
|
||
setNotifyPage(r.page || page);
|
||
setNotifications((prev) => (append ? [...prev, ...next] : next));
|
||
} catch (e: unknown) {
|
||
if (seq !== notifyLoadSeq.current) return;
|
||
notify.error(e instanceof Error ? e.message : '加载通知失败');
|
||
} finally {
|
||
if (seq === notifyLoadSeq.current) setNotifyLoading(false);
|
||
}
|
||
}, []);
|
||
|
||
const [threadEpoch, setThreadEpoch] = useState(0);
|
||
|
||
useEffect(() => {
|
||
if (authLoading) return;
|
||
if (!user) {
|
||
nav(loginPath('/messages'));
|
||
return;
|
||
}
|
||
void refreshUnreadSplit();
|
||
if (tab === 'dm') {
|
||
loadConversations(1);
|
||
} else {
|
||
loadNotifications(1, false, notifyKind);
|
||
}
|
||
}, [user, authLoading, nav, tab, notifyKind, loadConversations, loadNotifications, refreshUnreadSplit]);
|
||
|
||
useEffect(() => {
|
||
const onForce = () => {
|
||
void refreshUnreadSplit();
|
||
if (tab === 'dm') {
|
||
void loadConversations(1);
|
||
if (peerSelected && selectedPeer !== null) {
|
||
deleteSessionSnapshot(`messages:thread:${selectedPeer}`);
|
||
setThreadEpoch(n => n + 1);
|
||
}
|
||
} else {
|
||
void loadNotifications(1, false, notifyKind);
|
||
}
|
||
};
|
||
window.addEventListener(PAGE_FORCE_REFRESH_EVENT, onForce);
|
||
window.addEventListener(PAGE_SOFT_REFRESH_COMMIT_EVENT, onForce);
|
||
return () => {
|
||
window.removeEventListener(PAGE_FORCE_REFRESH_EVENT, onForce);
|
||
window.removeEventListener(PAGE_SOFT_REFRESH_COMMIT_EVENT, onForce);
|
||
};
|
||
}, [tab, notifyKind, peerSelected, selectedPeer, loadConversations, loadNotifications, refreshUnreadSplit]);
|
||
|
||
const scrollToBottom = useCallback((smooth = false) => {
|
||
requestAnimationFrame(() => {
|
||
threadEndRef.current?.scrollIntoView({ behavior: smooth ? 'smooth' : 'auto', block: 'end' });
|
||
});
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
if (!user || !peerSelected || selectedPeer === null) {
|
||
setMessages([]);
|
||
setPeerUser(null);
|
||
setMsgTotal(0);
|
||
return;
|
||
}
|
||
const cached = getSessionSnapshot<ThreadSnap>(`messages:thread:${selectedPeer}`);
|
||
if (cached) {
|
||
setMessages(cached.messages);
|
||
setMsgTotal(cached.total);
|
||
setPeerUser(cached.peerUser);
|
||
if (cached.peerUser) ensurePeerConversation(cached.peerUser);
|
||
setThreadLoading(false);
|
||
// 缓存命中时仍需标记后端已读,并更新前端会话列表未读数
|
||
void api.markConversationRead(selectedPeer).catch(() => undefined);
|
||
updateConvUnread(selectedPeer, 0);
|
||
window.dispatchEvent(new Event('messages-unread-refresh'));
|
||
void refreshUnreadSplit();
|
||
return;
|
||
}
|
||
let cancelled = false;
|
||
setThreadLoading(true);
|
||
stickToBottomRef.current = true;
|
||
api.conversationMessages(selectedPeer, { size: 50 })
|
||
.then((r) => {
|
||
if (cancelled) return;
|
||
const messages = r.messages || [];
|
||
const peer = r.peer_user || null;
|
||
setMessages(messages);
|
||
setMsgTotal(r.total || 0);
|
||
setPeerUser(peer);
|
||
if (peer) ensurePeerConversation(peer);
|
||
setSessionSnapshot(`messages:thread:${selectedPeer}`, {
|
||
messages,
|
||
total: r.total || 0,
|
||
peerUser: peer,
|
||
});
|
||
updateConvUnread(selectedPeer, 0);
|
||
window.dispatchEvent(new Event('messages-unread-refresh'));
|
||
void refreshUnreadSplit();
|
||
})
|
||
.catch((e: unknown) => {
|
||
if (!cancelled) notify.error(e instanceof Error ? e.message : '加载会话失败');
|
||
})
|
||
.finally(() => {
|
||
if (!cancelled) setThreadLoading(false);
|
||
});
|
||
return () => { cancelled = true; };
|
||
}, [user, peerSelected, selectedPeer, refreshUnreadSplit, threadEpoch, ensurePeerConversation]);
|
||
|
||
useEffect(() => {
|
||
if (!threadLoading && stickToBottomRef.current) {
|
||
scrollToBottom(false);
|
||
}
|
||
}, [messages, threadLoading, scrollToBottom]);
|
||
|
||
const setTab = (next: MsgTab) => {
|
||
const p = new URLSearchParams();
|
||
if (next === 'notify') {
|
||
p.set('tab', 'notify');
|
||
if (notifyKind !== 'all') p.set('kind', notifyKind);
|
||
}
|
||
setParams(p, { replace: true });
|
||
};
|
||
|
||
const setKind = (kind: string) => {
|
||
const p = new URLSearchParams();
|
||
p.set('tab', 'notify');
|
||
if (kind !== 'all') p.set('kind', kind);
|
||
setParams(p, { replace: true });
|
||
};
|
||
|
||
const openPeer = (peerId: number) => {
|
||
const p = new URLSearchParams();
|
||
p.set('tab', 'dm');
|
||
p.set('peer', String(peerId));
|
||
setParams(p, { replace: true });
|
||
};
|
||
|
||
const closeThread = () => {
|
||
const p = new URLSearchParams();
|
||
p.set('tab', 'dm');
|
||
setParams(p, { replace: true });
|
||
};
|
||
|
||
const markAll = async () => {
|
||
try {
|
||
if (tab === 'notify') {
|
||
await api.markNotificationsRead();
|
||
setNotifications((prev) => prev.map((m) => ({ ...m, is_read: true })));
|
||
setNotifyUnread(0);
|
||
notify.success('通知已全部标为已读');
|
||
} else {
|
||
await api.markAllMessagesRead();
|
||
setConversations((prev) => {
|
||
const next = prev.map((c) => ({ ...c, unread_count: 0 }));
|
||
const cached = getSessionSnapshot<ConvSnap>('messages:conv:1');
|
||
if (cached) {
|
||
setSessionSnapshot('messages:conv:1', { ...cached, conversations: next });
|
||
}
|
||
return next;
|
||
});
|
||
setDmUnread(0);
|
||
setNotifyUnread(0);
|
||
notify.success('已全部标为已读');
|
||
}
|
||
window.dispatchEvent(new Event('messages-unread-refresh'));
|
||
void refreshUnreadSplit();
|
||
} catch (e: unknown) {
|
||
notify.error(e instanceof Error ? e.message : '操作失败');
|
||
}
|
||
};
|
||
|
||
const openNotification = async (m: PrivateMessage) => {
|
||
const target = notifyTarget(m);
|
||
if (!m.is_read) {
|
||
setNotifications((prev) => prev.map((item) => (item.id === m.id ? { ...item, is_read: true } : item)));
|
||
setNotifyUnread((n) => Math.max(0, n - 1));
|
||
api.markMessageRead(m.id)
|
||
.then(() => {
|
||
window.dispatchEvent(new Event('messages-unread-refresh'));
|
||
void refreshUnreadSplit();
|
||
})
|
||
.catch(() => undefined);
|
||
}
|
||
if (target) {
|
||
nav(target.to);
|
||
}
|
||
};
|
||
|
||
const loadOlder = async () => {
|
||
if (!peerSelected || selectedPeer === null || messages.length === 0) return;
|
||
const oldest = messages[0]?.id;
|
||
if (!oldest) return;
|
||
setLoadingOlder(true);
|
||
const el = threadScrollRef.current;
|
||
const prevHeight = el?.scrollHeight ?? 0;
|
||
try {
|
||
const r = await api.conversationMessages(selectedPeer, { size: 40, before: oldest });
|
||
const older = r.messages || [];
|
||
if (older.length === 0) return;
|
||
stickToBottomRef.current = false;
|
||
setMessages((prev) => [...older, ...prev]);
|
||
requestAnimationFrame(() => {
|
||
if (el) el.scrollTop = el.scrollHeight - prevHeight;
|
||
});
|
||
} catch (e: unknown) {
|
||
notify.error(e instanceof Error ? e.message : '加载失败');
|
||
} finally {
|
||
setLoadingOlder(false);
|
||
}
|
||
};
|
||
|
||
const send = async () => {
|
||
if (!peerSelected || selectedPeer === null || selectedPeer === 0) return;
|
||
const content = serializePmDraft(draftTextRef.current, draftEmbedsRef.current);
|
||
if (!content) {
|
||
notify.warning('请填写内容或添加图片');
|
||
return;
|
||
}
|
||
setSending(true);
|
||
try {
|
||
const r = await api.sendMessage({ to_user_id: selectedPeer, content });
|
||
stickToBottomRef.current = true;
|
||
setMessages((prev) => {
|
||
const next = [...prev, r.message];
|
||
if (selectedPeer != null) {
|
||
setSessionSnapshot(`messages:thread:${selectedPeer}`, {
|
||
messages: next,
|
||
total: msgTotal + 1,
|
||
peerUser,
|
||
});
|
||
}
|
||
return next;
|
||
});
|
||
setMsgTotal((n) => n + 1);
|
||
setDraftText('');
|
||
setDraftEmbeds([]);
|
||
draftTextRef.current = '';
|
||
draftEmbedsRef.current = [];
|
||
draftsByPeerRef.current.delete(selectedPeer);
|
||
setShowSticker(false);
|
||
setConversations((prev) => {
|
||
const rest = prev.filter((c) => c.peer_user_id !== selectedPeer);
|
||
const existing = prev.find((c) => c.peer_user_id === selectedPeer);
|
||
const next: MessageConversation = {
|
||
peer_user_id: selectedPeer,
|
||
peer_user: peerUser || existing?.peer_user,
|
||
is_system: false,
|
||
last_message: r.message,
|
||
unread_count: 0,
|
||
updated_at: r.message.created_at,
|
||
};
|
||
const merged = [next, ...rest];
|
||
setSessionSnapshot('messages:conv:1', { conversations: merged, total: convTotal, page: 1 });
|
||
return merged;
|
||
});
|
||
scrollToBottom(true);
|
||
} catch (e: unknown) {
|
||
notify.error(e instanceof Error ? e.message : '发送失败');
|
||
} finally {
|
||
setSending(false);
|
||
}
|
||
};
|
||
|
||
const onPickSticker = useCallback((sticker: Sticker) => {
|
||
if (sticker.type === 'text' && sticker.text) {
|
||
composerRef.current?.insertText(sticker.text);
|
||
} else if (sticker.url) {
|
||
composerRef.current?.insertSticker(sticker);
|
||
}
|
||
setShowSticker(false);
|
||
composerRef.current?.focus();
|
||
}, []);
|
||
|
||
const onInsertImages = useCallback((urls: string[]) => {
|
||
if (!urls.length) return;
|
||
updateDraftEmbeds((prev) => [
|
||
...prev,
|
||
...urls.map((url) => ({ id: newEmbedId(), url, name: '图片' })),
|
||
]);
|
||
}, [updateDraftEmbeds]);
|
||
|
||
const removeEmbed = useCallback((id: string) => {
|
||
updateDraftEmbeds((prev) => prev.filter((e) => e.id !== id));
|
||
}, [updateDraftEmbeds]);
|
||
|
||
if (authLoading || (tab === 'dm' && listLoading && conversations.length === 0 && !peerSelected)) {
|
||
return <div className="flex justify-center py-16"><Spinner size="lg" /></div>;
|
||
}
|
||
if (!user) return null;
|
||
|
||
const activeConv = peerSelected && selectedPeer !== null
|
||
? conversations.find((c) => c.peer_user_id === selectedPeer) || null
|
||
: null;
|
||
const title = peerSelected && selectedPeer !== null
|
||
? peerTitle(activeConv, peerUser, selectedPeer)
|
||
: '';
|
||
const canCompose = peerSelected && selectedPeer !== null && selectedPeer > 0;
|
||
const unreadForTab = tab === 'notify' ? notifyUnread : dmUnread;
|
||
|
||
return (
|
||
<div className="page-wrap">
|
||
<div className="page-inner-wide">
|
||
<div className="pm-page-head">
|
||
<div>
|
||
<h1 className="page-title">站内消息</h1>
|
||
<p className="page-desc">点开才标已读;待审可直达帖内位置</p>
|
||
</div>
|
||
{unreadForTab > 0 && (
|
||
<Button variant="outline" size="sm" onClick={markAll}>
|
||
<CheckCheck size={14} />
|
||
{tab === 'notify' ? '通知全部已读' : '全部已读'}
|
||
</Button>
|
||
)}
|
||
</div>
|
||
|
||
<div className={cn('pm-workspace content-surface', tab === 'dm' && 'pm-workspace--im')}>
|
||
<div className="pm-tabs" role="tablist" aria-label="消息类型">
|
||
<button
|
||
type="button"
|
||
role="tab"
|
||
aria-selected={tab === 'dm'}
|
||
className={cn('pm-tab', tab === 'dm' && 'active')}
|
||
onClick={() => setTab('dm')}
|
||
>
|
||
<Mail size={15} aria-hidden />
|
||
私信
|
||
{dmUnread > 0 && <span className="pm-tab__badge">{dmUnread > 99 ? '99+' : dmUnread}</span>}
|
||
</button>
|
||
<button
|
||
type="button"
|
||
role="tab"
|
||
aria-selected={tab === 'notify'}
|
||
className={cn('pm-tab', tab === 'notify' && 'active')}
|
||
onClick={() => setTab('notify')}
|
||
>
|
||
<Bell size={15} aria-hidden />
|
||
通知
|
||
{notifyUnread > 0 && <span className="pm-tab__badge">{notifyUnread > 99 ? '99+' : notifyUnread}</span>}
|
||
</button>
|
||
</div>
|
||
|
||
{tab === 'notify' ? (
|
||
<div className="pm-notify">
|
||
<div className="pm-notify-toolbar">
|
||
<div className="pm-notify-filters" role="tablist" aria-label="通知类型">
|
||
{NOTIFY_KINDS.map((k) => (
|
||
<button
|
||
key={k.key}
|
||
type="button"
|
||
role="tab"
|
||
aria-selected={notifyKind === k.key}
|
||
className={cn(
|
||
'pm-notify-filter',
|
||
notifyKind === k.key && 'active',
|
||
)}
|
||
onClick={() => setKind(k.key)}
|
||
>
|
||
{k.label}
|
||
</button>
|
||
))}
|
||
</div>
|
||
<button
|
||
type="button"
|
||
className={cn('pm-notify-unread-toggle', unreadOnly && 'active')}
|
||
aria-pressed={unreadOnly}
|
||
onClick={() => setUnreadOnly((v) => !v)}
|
||
>
|
||
只看未读
|
||
</button>
|
||
</div>
|
||
|
||
{notifyLoading && notifications.length === 0 ? (
|
||
<div className="flex justify-center py-16"><Spinner /></div>
|
||
) : visibleNotifications.length === 0 ? (
|
||
<div className="pm-empty">
|
||
<Bell size={28} strokeWidth={1.5} aria-hidden />
|
||
<p>{unreadOnly ? '没有未读通知' : '暂无通知'}</p>
|
||
<span>{unreadOnly ? '切换筛选查看全部通知' : '有人回复你、审核结果等会出现在这里'}</span>
|
||
</div>
|
||
) : (
|
||
<ul className="pm-notify-list">
|
||
{visibleNotifications.map((m) => {
|
||
const target = notifyTarget(m);
|
||
const pendingMod = isPendingModeration(m);
|
||
const st = statusLabel(m.related_status);
|
||
const clickable = !!target || !m.is_read;
|
||
return (
|
||
<li key={m.id}>
|
||
<button
|
||
type="button"
|
||
className={cn(
|
||
'pm-notify-item',
|
||
!m.is_read && 'unread',
|
||
pendingMod && 'pm-notify-item--moderation',
|
||
clickable && 'pm-notify-item--clickable',
|
||
)}
|
||
onClick={() => void openNotification(m)}
|
||
disabled={!clickable}
|
||
>
|
||
<span className="pm-notify-item__icon" aria-hidden>
|
||
<KindIcon kind={m.kind} />
|
||
</span>
|
||
<div className="pm-notify-item__body">
|
||
<div className="pm-notify-item__top">
|
||
<span className="pm-notify-item__kind">
|
||
{kindLabel(m.kind, m.related_status)}
|
||
</span>
|
||
{m.kind === 'moderation' && st && m.related_status && m.related_status !== 'pending' && (
|
||
<span className={cn('pm-notify-status', `pm-notify-status--${m.related_status}`)}>
|
||
{st}
|
||
</span>
|
||
)}
|
||
{!m.is_read && <span className="pm-notify-item__dot" aria-label="未读" />}
|
||
<time className="pm-notify-item__time">{formatTime(m.created_at)}</time>
|
||
</div>
|
||
{m.subject && <div className="pm-notify-item__subject">{m.subject}</div>}
|
||
<div className="pm-notify-item__text">{m.content}</div>
|
||
{target && (
|
||
<span className="pm-notify-item__cta">{target.label} →</span>
|
||
)}
|
||
</div>
|
||
</button>
|
||
</li>
|
||
);
|
||
})}
|
||
</ul>
|
||
)}
|
||
{notifyTotal > notifications.length && (
|
||
<div className="pm-list-more">
|
||
<Button
|
||
variant="ghost"
|
||
size="sm"
|
||
disabled={notifyLoading}
|
||
onClick={() => loadNotifications(notifyPage + 1, true, notifyKind)}
|
||
>
|
||
加载更多通知
|
||
</Button>
|
||
</div>
|
||
)}
|
||
</div>
|
||
) : (
|
||
<div className={cn('pm-layout', peerSelected && 'pm-layout--thread')}>
|
||
<aside className="pm-list" aria-label="会话列表">
|
||
<div className="pm-list-search">
|
||
<Search size={16} className="pm-list-search__icon" aria-hidden />
|
||
<input
|
||
type="search"
|
||
className="pm-list-search__input"
|
||
value={convSearchQuery}
|
||
placeholder="搜索用户…"
|
||
aria-label="搜索用户发起私信"
|
||
aria-expanded={convSearchOpen}
|
||
aria-controls="pm-conv-search-results"
|
||
onChange={(e) => {
|
||
setConvSearchQuery(e.target.value);
|
||
setConvSearchOpen(true);
|
||
}}
|
||
onFocus={() => {
|
||
if (convSearchBlurTimer.current) {
|
||
window.clearTimeout(convSearchBlurTimer.current);
|
||
convSearchBlurTimer.current = null;
|
||
}
|
||
if (convSearchQuery.trim()) setConvSearchOpen(true);
|
||
}}
|
||
onBlur={() => {
|
||
convSearchBlurTimer.current = window.setTimeout(() => {
|
||
setConvSearchOpen(false);
|
||
}, 160);
|
||
}}
|
||
onKeyDown={(e) => {
|
||
if (e.key === 'Escape') clearConvSearch();
|
||
if (e.key === 'Enter' && convSearchResults[0]) {
|
||
e.preventDefault();
|
||
selectSearchUser(convSearchResults[0]);
|
||
}
|
||
}}
|
||
/>
|
||
{convSearchOpen && convSearchQuery.trim() && (
|
||
<ul
|
||
id="pm-conv-search-results"
|
||
className="pm-list-search__dropdown"
|
||
role="listbox"
|
||
aria-label="搜索结果"
|
||
>
|
||
{convSearchLoading ? (
|
||
<li className="pm-list-search__empty">
|
||
<Spinner size="sm" />
|
||
<span>搜索中…</span>
|
||
</li>
|
||
) : convSearchResults.length === 0 ? (
|
||
<li className="pm-list-search__empty">未找到用户</li>
|
||
) : (
|
||
convSearchResults.map((u) => (
|
||
<li key={u.id} role="option">
|
||
<button
|
||
type="button"
|
||
className="pm-list-search__item"
|
||
onMouseDown={(e) => e.preventDefault()}
|
||
onClick={() => selectSearchUser(u)}
|
||
>
|
||
<AvatarBubble name={u.nickname || u.username} avatar={u.avatar} className="pm-avatar--search" />
|
||
<span className="pm-list-search__name">{u.nickname || u.username}</span>
|
||
<span className="pm-list-search__username">@{u.username}</span>
|
||
</button>
|
||
</li>
|
||
))
|
||
)}
|
||
</ul>
|
||
)}
|
||
</div>
|
||
{listLoading && dmConversations.length === 0 ? (
|
||
<div className="flex justify-center py-10"><Spinner /></div>
|
||
) : dmConversations.length === 0 ? (
|
||
<div className="pm-empty">
|
||
<Inbox size={28} strokeWidth={1.5} aria-hidden />
|
||
<p>还没有私信</p>
|
||
<span>搜索用户或在用户主页点击「发私信」</span>
|
||
</div>
|
||
) : (
|
||
dmConversations.map((c) => {
|
||
const name = peerTitle(c, c.peer_user, c.peer_user_id);
|
||
const active = peerSelected && selectedPeer === c.peer_user_id;
|
||
return (
|
||
<button
|
||
key={c.peer_user_id}
|
||
type="button"
|
||
className={cn('pm-conv-item', active && 'active', c.unread_count > 0 && 'unread')}
|
||
onClick={() => openPeer(c.peer_user_id)}
|
||
>
|
||
<AvatarBubble name={name} avatar={c.peer_user?.avatar} />
|
||
<div className="pm-conv-item__body">
|
||
<div className="pm-conv-item__top">
|
||
<span className="pm-conv-item__name">{name}</span>
|
||
{c.last_message && (
|
||
<span className="pm-conv-item__time">
|
||
{formatConvListTime(c.last_message.created_at || c.updated_at)}
|
||
</span>
|
||
)}
|
||
</div>
|
||
<div className="pm-conv-item__preview">
|
||
<span>{previewText(c.last_message)}</span>
|
||
{c.unread_count > 0 && (
|
||
<span className="pm-conv-item__badge">
|
||
{c.unread_count > 99 ? '99+' : c.unread_count}
|
||
</span>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</button>
|
||
);
|
||
})
|
||
)}
|
||
{convTotal > conversations.length && (
|
||
<div className="pm-list-more">
|
||
<Button
|
||
variant="ghost"
|
||
size="sm"
|
||
disabled={listLoading}
|
||
onClick={() => loadConversations(convPage + 1, true)}
|
||
>
|
||
加载更多会话
|
||
</Button>
|
||
</div>
|
||
)}
|
||
</aside>
|
||
|
||
<section className="pm-thread" aria-label="会话内容">
|
||
{!peerSelected || selectedPeer === null ? (
|
||
<div className="pm-empty pm-empty--thread">
|
||
<Send size={32} strokeWidth={1.4} aria-hidden />
|
||
<p>选择左侧会话开始聊天</p>
|
||
<span>系统通知请切换到「通知」页签</span>
|
||
</div>
|
||
) : (
|
||
<>
|
||
<header className="pm-thread-head">
|
||
<button type="button" className="pm-thread-back" onClick={closeThread} aria-label="返回会话列表">
|
||
<ArrowLeft size={18} />
|
||
</button>
|
||
<AvatarBubble
|
||
name={title}
|
||
avatar={peerUser?.avatar || activeConv?.peer_user?.avatar}
|
||
/>
|
||
<div className="pm-thread-head__meta">
|
||
<span className="pm-thread-head__name">{title}</span>
|
||
</div>
|
||
<Link to={userPath(selectedPeer)} className="pm-thread-head__profile">主页</Link>
|
||
</header>
|
||
|
||
<div
|
||
className="pm-thread-scroll"
|
||
ref={threadScrollRef}
|
||
onScroll={(e) => {
|
||
const t = e.currentTarget;
|
||
stickToBottomRef.current = t.scrollHeight - t.scrollTop - t.clientHeight < 80;
|
||
}}
|
||
>
|
||
{threadLoading ? (
|
||
<div className="flex justify-center py-16"><Spinner /></div>
|
||
) : (
|
||
<>
|
||
{msgTotal > messages.length && (
|
||
<div className="pm-thread-older">
|
||
<Button variant="ghost" size="sm" loading={loadingOlder} onClick={loadOlder}>
|
||
查看更早消息
|
||
</Button>
|
||
</div>
|
||
)}
|
||
{messages.length === 0 ? (
|
||
<div className="pm-empty">还没有消息,打个招呼吧</div>
|
||
) : (
|
||
messages.map((m) => {
|
||
const mine = m.from_user_id === user.id;
|
||
const peerAvatarUser = m.from_user
|
||
|| peerUser
|
||
|| activeConv?.peer_user;
|
||
const avatarName = mine
|
||
? (user.nickname || user.username || '我')
|
||
: (peerAvatarUser?.nickname || peerAvatarUser?.username || title);
|
||
const avatarSrc = mine
|
||
? user.avatar
|
||
: peerAvatarUser?.avatar;
|
||
const avatar = (
|
||
<AvatarBubble
|
||
name={avatarName}
|
||
avatar={avatarSrc}
|
||
className="pm-avatar--bubble"
|
||
/>
|
||
);
|
||
return (
|
||
<div
|
||
key={m.id}
|
||
className={cn('pm-bubble-row', mine && 'pm-bubble-row--mine')}
|
||
>
|
||
{!mine && avatar}
|
||
<div className="pm-bubble-stack">
|
||
<div className={cn('pm-bubble', mine && 'pm-bubble--mine')}>
|
||
<PmBubbleContent content={m.content} />
|
||
</div>
|
||
<time className="pm-bubble__time">{formatDateTime(m.created_at)}</time>
|
||
</div>
|
||
{mine && avatar}
|
||
</div>
|
||
);
|
||
})
|
||
)}
|
||
<div ref={threadEndRef} />
|
||
</>
|
||
)}
|
||
</div>
|
||
|
||
{canCompose && (
|
||
<footer className="pm-composer">
|
||
<PmComposerInput
|
||
ref={composerRef}
|
||
value={draftText}
|
||
onChange={updateDraftText}
|
||
placeholder={`发送给 ${title}…`}
|
||
onSubmit={() => { void send(); }}
|
||
disabled={sending}
|
||
/>
|
||
{draftEmbeds.length > 0 && (
|
||
<ul className="pm-composer__embeds" aria-label="待发送图片">
|
||
{draftEmbeds.map((emb) => (
|
||
<li key={emb.id} className="pm-composer__embed">
|
||
<img src={emb.url} alt={emb.name} loading="lazy" />
|
||
<button
|
||
type="button"
|
||
className="pm-composer__embed-remove"
|
||
aria-label={`移除${emb.name}`}
|
||
onClick={() => removeEmbed(emb.id)}
|
||
>
|
||
<X size={12} aria-hidden />
|
||
</button>
|
||
</li>
|
||
))}
|
||
</ul>
|
||
)}
|
||
<div className="pm-composer__toolbar">
|
||
<div className="pm-composer__tools">
|
||
<Tooltip content="表情" side="top">
|
||
<button
|
||
type="button"
|
||
className={cn('pm-composer__tool', showSticker && 'active')}
|
||
aria-label="表情"
|
||
aria-pressed={showSticker}
|
||
onClick={() => {
|
||
setImagePickerOpen(false);
|
||
setShowSticker((v) => !v);
|
||
}}
|
||
>
|
||
<Smile size={18} aria-hidden />
|
||
</button>
|
||
</Tooltip>
|
||
<Tooltip content="图片" side="top">
|
||
<button
|
||
type="button"
|
||
className="pm-composer__tool"
|
||
aria-label="图片"
|
||
onClick={() => {
|
||
setShowSticker(false);
|
||
setImagePickerOpen(true);
|
||
}}
|
||
>
|
||
<ImagePlus size={18} aria-hidden />
|
||
</button>
|
||
</Tooltip>
|
||
</div>
|
||
<button
|
||
type="button"
|
||
className="pm-composer__send"
|
||
disabled={isDraftEmpty(draftText, draftEmbeds) || sending}
|
||
onClick={() => void send()}
|
||
>
|
||
{sending ? (
|
||
<Spinner size="sm" />
|
||
) : (
|
||
<>
|
||
发送
|
||
<Send size={14} aria-hidden />
|
||
</>
|
||
)}
|
||
</button>
|
||
</div>
|
||
{showSticker && (
|
||
<div className="pm-composer__picker">
|
||
<StickerPicker onSelect={onPickSticker} />
|
||
</div>
|
||
)}
|
||
<p className="pm-composer__hint">Enter 发送 · Shift+Enter 换行</p>
|
||
<ArticleImagePickerDialog
|
||
open={imagePickerOpen}
|
||
onOpenChange={setImagePickerOpen}
|
||
onInsert={onInsertImages}
|
||
/>
|
||
</footer>
|
||
)}
|
||
</>
|
||
)}
|
||
</section>
|
||
</div>
|
||
)}
|
||
</div>
|
||
<InFlowSiteFooter />
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|