feat(messaging): add drafts, group compose, templates, reports, blocks, and services

- Add message-draft-list and message-attachments for draft and attachment management

- Add message-group-compose for group messaging

- Add message-template-picker for message templates

- Add message-report-block for reporting and blocking users

- Add message-list-section and message-list-skeleton for list rendering

- Add lib and services directories for messaging utilities and service layer
This commit is contained in:
SpecialX
2026-07-03 10:24:37 +08:00
parent c935597803
commit 2859ef74f2
20 changed files with 3247 additions and 347 deletions

View File

@@ -2,7 +2,7 @@
import { useCallback, useMemo, useState } from "react"
import Link from "next/link"
import { Mail, MailOpen, Plus, Send, Inbox, Search, Loader2, ChevronLeft, ChevronRight, Star } from "lucide-react"
import { Mail, MailOpen, Plus, Send, Inbox, Search, Loader2, ChevronLeft, ChevronRight, Star, RotateCcw, Users } from "lucide-react"
import { useTranslations } from "next-intl"
import { toast } from "sonner"
@@ -16,11 +16,14 @@ import { cn, formatDate } from "@/shared/lib/utils"
import { usePermission } from "@/shared/hooks/use-permission"
import { Permissions } from "@/shared/types/permissions"
import { getMessagesAction, toggleMessageStarAction } from "../actions"
import { useMessageSearch } from "../hooks/use-message-search"
import { useMessageListService } from "../services/message-list-service-context"
import type { Message, MessageType } from "../types"
type Tab = "inbox" | "sent"
// P1-2: 新增 "starred" Tab 类型
type Tab = "inbox" | "sent" | "starred"
const isTab = (v: string): v is Tab => v === "inbox" || v === "sent" || v === "starred"
/** 客户端分页大小 */
const PAGE_SIZE = 20
@@ -29,31 +32,62 @@ export function MessageList({
messages,
currentUserId,
initialType = "inbox",
canGroupSend = false,
}: {
messages: Message[]
currentUserId: string
initialType?: MessageType
canGroupSend?: boolean
}) {
const t = useTranslations("messages")
const [tab, setTab] = useState<Tab>(initialType === "sent" ? "sent" : "inbox")
const [currentPage, setCurrentPage] = useState(1)
const [starredOverride, setStarredOverride] = useState<Record<string, boolean>>({})
const [togglingStarId, setTogglingStarId] = useState<string | null>(null)
const [starredMessages, setStarredMessages] = useState<Message[] | null>(null)
const [loadingStarred, setLoadingStarred] = useState(false)
// P2-4: 撤回状态覆盖(按消息 ID 维度,撤回成功后立即隐藏原内容)
const [recalledOverride, setRecalledOverride] = useState<Record<string, boolean>>({})
const [recallingId, setRecallingId] = useState<string | null>(null)
const { hasPermission } = usePermission()
const canSend = hasPermission(Permissions.MESSAGE_SEND)
// P1-8: 通过依赖注入获取消息列表服务,而非直接 import actions
const messageService = useMessageListService()
const { keyword, setKeyword, results, searching, isUsingInitial } = useMessageSearch({
searchAction: getMessagesAction,
tab,
searchAction: messageService.search,
tab: tab === "starred" ? "inbox" : tab,
})
// P1-2: 切换到 starred Tab 时单独加载星标消息type=all + starredOnly=true
// 由于 useMessageSearch 仅支持 inbox/sent 搜索starred Tab 使用独立加载逻辑
const loadStarred = useCallback(async () => {
setLoadingStarred(true)
try {
const res = await messageService.search({ type: "all", starredOnly: true })
setStarredMessages(res.success && res.data ? res.data.items : [])
} catch {
setStarredMessages([])
} finally {
setLoadingStarred(false)
}
}, [messageService])
// 客户端过滤仅在初始数据type=all时需要搜索结果已由服务端按 tab 过滤
// P1-2: starred Tab 使用 starredMessages 状态
const filtered = useMemo(() => {
if (tab === "starred") {
// starred Tab若有搜索关键字则用搜索结果否则用 starredMessages
if (keyword.trim().length > 0 && results) {
return results.filter((m) => m.receiverId === currentUserId)
}
return starredMessages ?? []
}
const displayMessages = isUsingInitial ? messages : (results ?? [])
if (!isUsingInitial) return displayMessages
if (tab === "inbox") return displayMessages.filter((m) => m.receiverId === currentUserId)
return displayMessages.filter((m) => m.senderId === currentUserId)
}, [messages, results, tab, currentUserId, isUsingInitial])
}, [messages, results, tab, currentUserId, isUsingInitial, starredMessages, keyword])
// 客户端分页:超过 PAGE_SIZE 条时显示分页 UI
const totalPages = Math.max(1, Math.ceil(filtered.length / PAGE_SIZE))
@@ -63,8 +97,13 @@ export function MessageList({
// 切换 tab 或搜索时重置分页
const handleTabChange = (v: string): void => {
setTab(v as Tab)
if (!isTab(v)) return
setTab(v)
setCurrentPage(1)
// P1-2: 切换到 starred 时触发加载
if (v === "starred") {
void loadStarred()
}
}
const handleKeywordChange = (v: string): void => {
@@ -85,9 +124,13 @@ export function MessageList({
// 乐观更新
setStarredOverride((prev) => ({ ...prev, [messageId]: !currentStarred }))
try {
const res = await toggleMessageStarAction(messageId)
const res = await messageService.toggleStar(messageId)
if (res.success) {
toast.success(t("messages.starToggled"))
// P1-2: 当前在 starred Tab 时重新加载星标列表
if (tab === "starred") {
void loadStarred()
}
} else {
// 回滚
setStarredOverride((prev) => ({ ...prev, [messageId]: currentStarred }))
@@ -101,9 +144,55 @@ export function MessageList({
setTogglingStarId(null)
}
},
[t]
[t, tab, loadStarred, messageService]
)
// P2-4: 撤回消息仅发送方2 分钟窗口内)
const handleRecall = useCallback(
async (e: React.MouseEvent, message: Message): Promise<void> => {
e.preventDefault()
e.stopPropagation()
setRecallingId(message.id)
try {
const res = await messageService.recall(message.id)
if (res.success) {
// 同步 UI立即显示"已撤回"占位
setRecalledOverride((prev) => ({ ...prev, [message.id]: true }))
toast.success(t("messages.recalled"))
} else {
if (res.message === "Recall window expired") {
toast.error(t("messages.recallExpired"))
} else {
toast.error(res.message || t("messages.recallFailed"))
}
}
} catch {
toast.error(t("messages.recallFailed"))
} finally {
setRecallingId(null)
}
},
[t, messageService]
)
const getIsRecalled = (m: Message): boolean => {
const override = recalledOverride[m.id]
return override === undefined ? m.recalledAt !== null : override
}
// P1-4: 空状态文案按 Tab 区分
const getEmptyState = () => {
if (tab === "starred") {
return {
title: t("empty.noStarred"),
description: t("empty.inboxEmptyDesc"),
}
}
return tab === "inbox"
? { title: t("empty.inboxEmpty"), description: t("empty.inboxEmptyDesc") }
: { title: t("empty.sentEmpty"), description: t("empty.sentEmptyDesc") }
}
return (
<div className="space-y-6">
<div className="flex flex-wrap items-center justify-between gap-3">
@@ -117,15 +206,30 @@ export function MessageList({
<Send className="h-4 w-4" />
{t("tabs.sent")}
</TabsTrigger>
{/* P1-2: 新增星标 Tab */}
<TabsTrigger value="starred" className="gap-2">
<Star className="h-4 w-4" />
{t("actions.star")}
</TabsTrigger>
</TabsList>
</Tabs>
{canSend ? (
<Button asChild>
<Link href="/messages/compose">
<Plus className="mr-2 h-4 w-4" />
{t("actions.compose")}
</Link>
</Button>
<div className="flex gap-2">
<Button asChild>
<Link href="/messages/compose">
<Plus className="mr-2 h-4 w-4" />
{t("actions.compose")}
</Link>
</Button>
{canGroupSend ? (
<Button asChild variant="outline">
<Link href="/messages/group-compose">
<Users className="mr-2 h-4 w-4" />
{t("title.groupCompose")}
</Link>
</Button>
) : null}
</div>
) : null}
</div>
@@ -135,24 +239,21 @@ export function MessageList({
<Input
type="search"
aria-label={t("search.placeholder")}
aria-busy={searching || loadingStarred}
placeholder={t("search.placeholder")}
value={keyword}
onChange={(e) => handleKeywordChange(e.target.value)}
className="pl-9"
/>
{searching ? (
{searching || loadingStarred ? (
<Loader2 className="text-muted-foreground absolute right-3 top-1/2 size-4 -translate-y-1/2 animate-spin" aria-hidden="true" />
) : null}
</div>
{paged.length === 0 ? (
<EmptyState
title={tab === "inbox" ? t("empty.inboxEmpty") : t("empty.sentEmpty")}
description={
tab === "inbox"
? t("empty.inboxEmptyDesc")
: t("empty.sentEmptyDesc")
}
title={getEmptyState().title}
description={getEmptyState().description}
icon={Mail}
className="h-auto border-none shadow-none"
/>
@@ -164,21 +265,36 @@ export function MessageList({
const counterpart = isReceived ? m.senderName : m.receiverName
const unread = isReceived && !m.isRead
const isStarred = getIsStarred(m)
// P2-4: 撤回状态
const isRecalled = getIsRecalled(m)
const canRecallItem =
!isReceived &&
canSend &&
!isRecalled &&
Date.now() - new Date(m.createdAt).getTime() <= 2 * 60 * 1000
return (
<Link key={m.id} href={`/messages/${m.id}`} className="block" aria-label={m.subject ?? t("meta.noSubject")}>
<Card className={cn("transition-colors hover:bg-accent/50", unread && "border-primary/40")}>
<CardHeader className="flex flex-row items-start justify-between gap-2 space-y-0 pb-3">
<div className="space-y-1">
<div className="flex items-center gap-2">
{unread ? (
{isRecalled ? (
<RotateCcw className="text-muted-foreground h-4 w-4" aria-hidden="true" />
) : unread ? (
<Mail className="h-4 w-4 text-primary" aria-hidden="true" />
) : (
<MailOpen className="text-muted-foreground h-4 w-4" aria-hidden="true" />
)}
<span className={cn("text-sm font-medium", unread && "text-primary")}>
<span className={cn("text-sm font-medium", unread && !isRecalled && "text-primary")}>
{m.subject ?? t("meta.noSubject")}
</span>
{unread ? <Badge variant="default" className="text-xs">{t("status.new")}</Badge> : null}
{/* P2-4: 已撤回优先显示徽章 */}
{isRecalled ? (
<Badge variant="secondary" className="text-xs">{t("status.recalled")}</Badge>
) : null}
{unread && !isRecalled ? (
<Badge variant="default" className="text-xs">{t("status.new")}</Badge>
) : null}
{isStarred ? (
<Star className="h-3.5 w-3.5 fill-yellow-400 text-yellow-400" aria-hidden="true" />
) : null}
@@ -188,11 +304,24 @@ export function MessageList({
</p>
</div>
<div className="flex shrink-0 items-center gap-1">
{/* P2-4: 撤回按钮(仅发送方在 2 分钟窗口内可见) */}
{canRecallItem ? (
<button
type="button"
onClick={(e) => void handleRecall(e, m)}
disabled={recallingId === m.id}
aria-label={t("actions.recall")}
className="text-muted-foreground hover:text-foreground inline-flex size-7 items-center justify-center rounded-md transition-colors hover:bg-accent disabled:opacity-50"
>
<RotateCcw className="h-4 w-4" />
</button>
) : null}
<button
type="button"
onClick={(e) => void handleToggleStar(e, m.id, isStarred)}
disabled={togglingStarId === m.id}
aria-label={isStarred ? t("actions.unstar") : t("actions.star")}
aria-pressed={isStarred}
className="text-muted-foreground hover:text-foreground inline-flex size-7 items-center justify-center rounded-md transition-colors hover:bg-accent disabled:opacity-50"
>
<Star
@@ -208,9 +337,14 @@ export function MessageList({
</div>
</CardHeader>
<CardContent className="pt-0">
<p className="text-muted-foreground line-clamp-2 text-sm whitespace-pre-wrap">
{m.content}
</p>
{/* P2-4: 已撤回消息显示占位文案,隐藏原始内容 */}
{isRecalled ? (
<p className="text-muted-foreground italic text-sm">{t("status.recalled")}</p>
) : (
<p className="text-muted-foreground line-clamp-2 text-sm whitespace-pre-wrap">
{m.content}
</p>
)}
</CardContent>
</Card>
</Link>
@@ -219,25 +353,25 @@ export function MessageList({
</div>
{showPagination ? (
<div className="flex items-center justify-center gap-4 pt-2" role="navigation" aria-label={t("tabs.inbox")}>
<div className="flex items-center justify-center gap-4 pt-2" role="navigation" aria-label={t("pagination.nav")}>
<Button
variant="outline"
size="sm"
onClick={() => setCurrentPage((p) => Math.max(1, p - 1))}
disabled={safePage <= 1}
aria-label="Previous page"
aria-label={t("pagination.previous")}
>
<ChevronLeft className="h-4 w-4" />
</Button>
<span className="text-muted-foreground text-sm" aria-live="polite">
{safePage} / {totalPages}
{t("pagination.page", { current: safePage, total: totalPages })}
</span>
<Button
variant="outline"
size="sm"
onClick={() => setCurrentPage((p) => Math.min(totalPages, p + 1))}
disabled={safePage >= totalPages}
aria-label="Next page"
aria-label={t("pagination.next")}
>
<ChevronRight className="h-4 w-4" />
</Button>