Files
NextEdu/src/modules/messaging/components/message-list.tsx
SpecialX 2859ef74f2 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
2026-07-03 10:24:37 +08:00

385 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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>
)
}