- 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
385 lines
16 KiB
TypeScript
385 lines
16 KiB
TypeScript
"use client"
|
||
|
||
import { useCallback, useMemo, useState } from "react"
|
||
import Link from "next/link"
|
||
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"
|
||
|
||
import { Badge } from "@/shared/components/ui/badge"
|
||
import { Button } from "@/shared/components/ui/button"
|
||
import { Card, CardContent, CardHeader } from "@/shared/components/ui/card"
|
||
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||
import { Input } from "@/shared/components/ui/input"
|
||
import { Tabs, TabsList, TabsTrigger } from "@/shared/components/ui/tabs"
|
||
import { cn, formatDate } from "@/shared/lib/utils"
|
||
import { usePermission } from "@/shared/hooks/use-permission"
|
||
import { Permissions } from "@/shared/types/permissions"
|
||
|
||
import { useMessageSearch } from "../hooks/use-message-search"
|
||
import { useMessageListService } from "../services/message-list-service-context"
|
||
import type { Message, MessageType } from "../types"
|
||
|
||
// 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
|
||
|
||
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: 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, starredMessages, keyword])
|
||
|
||
// 客户端分页:超过 PAGE_SIZE 条时显示分页 UI
|
||
const totalPages = Math.max(1, Math.ceil(filtered.length / PAGE_SIZE))
|
||
const safePage = Math.min(currentPage, totalPages)
|
||
const paged = filtered.slice((safePage - 1) * PAGE_SIZE, safePage * PAGE_SIZE)
|
||
const showPagination = filtered.length > PAGE_SIZE
|
||
|
||
// 切换 tab 或搜索时重置分页
|
||
const handleTabChange = (v: string): void => {
|
||
if (!isTab(v)) return
|
||
setTab(v)
|
||
setCurrentPage(1)
|
||
// P1-2: 切换到 starred 时触发加载
|
||
if (v === "starred") {
|
||
void loadStarred()
|
||
}
|
||
}
|
||
|
||
const handleKeywordChange = (v: string): void => {
|
||
setKeyword(v)
|
||
setCurrentPage(1)
|
||
}
|
||
|
||
const getIsStarred = (m: Message): boolean => {
|
||
const override = starredOverride[m.id]
|
||
return override === undefined ? m.isStarred : override
|
||
}
|
||
|
||
const handleToggleStar = useCallback(
|
||
async (e: React.MouseEvent, messageId: string, currentStarred: boolean): Promise<void> => {
|
||
e.preventDefault()
|
||
e.stopPropagation()
|
||
setTogglingStarId(messageId)
|
||
// 乐观更新
|
||
setStarredOverride((prev) => ({ ...prev, [messageId]: !currentStarred }))
|
||
try {
|
||
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 }))
|
||
toast.error(res.message)
|
||
}
|
||
} catch {
|
||
// 回滚
|
||
setStarredOverride((prev) => ({ ...prev, [messageId]: currentStarred }))
|
||
toast.error(t("messages.sendFailed"))
|
||
} finally {
|
||
setTogglingStarId(null)
|
||
}
|
||
},
|
||
[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">
|
||
<Tabs value={tab} onValueChange={handleTabChange}>
|
||
<TabsList>
|
||
<TabsTrigger value="inbox" className="gap-2">
|
||
<Inbox className="h-4 w-4" />
|
||
{t("tabs.inbox")}
|
||
</TabsTrigger>
|
||
<TabsTrigger value="sent" className="gap-2">
|
||
<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 ? (
|
||
<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>
|
||
|
||
{/* 搜索框 */}
|
||
<div className="relative">
|
||
<Search className="text-muted-foreground absolute left-3 top-1/2 size-4 -translate-y-1/2" aria-hidden="true" />
|
||
<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 || 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={getEmptyState().title}
|
||
description={getEmptyState().description}
|
||
icon={Mail}
|
||
className="h-auto border-none shadow-none"
|
||
/>
|
||
) : (
|
||
<>
|
||
<div className="space-y-3">
|
||
{paged.map((m) => {
|
||
const isReceived = m.receiverId === currentUserId
|
||
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">
|
||
{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 && !isRecalled && "text-primary")}>
|
||
{m.subject ?? t("meta.noSubject")}
|
||
</span>
|
||
{/* 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}
|
||
</div>
|
||
<p className="text-muted-foreground text-xs">
|
||
{isReceived ? t("meta.from") : t("meta.to")}: {counterpart ?? t("meta.unknown")}
|
||
</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
|
||
className={cn(
|
||
"h-4 w-4 transition-colors",
|
||
isStarred && "fill-yellow-400 text-yellow-400"
|
||
)}
|
||
/>
|
||
</button>
|
||
<span className="text-muted-foreground text-xs">
|
||
{formatDate(m.createdAt)}
|
||
</span>
|
||
</div>
|
||
</CardHeader>
|
||
<CardContent className="pt-0">
|
||
{/* 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>
|
||
)
|
||
})}
|
||
</div>
|
||
|
||
{showPagination ? (
|
||
<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={t("pagination.previous")}
|
||
>
|
||
<ChevronLeft className="h-4 w-4" />
|
||
</Button>
|
||
<span className="text-muted-foreground text-sm" aria-live="polite">
|
||
{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={t("pagination.next")}
|
||
>
|
||
<ChevronRight className="h-4 w-4" />
|
||
</Button>
|
||
</div>
|
||
) : null}
|
||
</>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|