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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user