Files
NextEdu/src/modules/messaging/components/message-detail.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

361 lines
13 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 { useEffect, useState } from "react"
import Link from "next/link"
import { useRouter } from "next/navigation"
import { toast } from "sonner"
import { useTranslations } from "next-intl"
import { ArrowLeft, Loader2, Mail, Reply, RotateCcw, Star, Trash2 } from "lucide-react"
import { Badge } from "@/shared/components/ui/badge"
import { Button } from "@/shared/components/ui/button"
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/shared/components/ui/dialog"
import { ConfirmDeleteDialog } from "@/shared/components/ui/confirm-delete-dialog"
import { cn, formatDate } from "@/shared/lib/utils"
import { usePermission } from "@/shared/hooks/use-permission"
import { Permissions } from "@/shared/types/permissions"
import { deleteMessageAction, getMessageThreadAction, recallMessageAction, toggleMessageStarAction } from "../actions"
import { buildReplyHref } from "../lib/build-reply-href"
import { MessageReportBlock } from "./message-report-block"
import type { Message } from "../types"
export function MessageDetail({
message,
currentUserId,
backHref = "/messages",
}: {
message: Message
currentUserId: string
backHref?: string
}) {
const t = useTranslations("messages")
const router = useRouter()
const [isWorking, setIsWorking] = useState(false)
const [deleteOpen, setDeleteOpen] = useState(false)
const [recallOpen, setRecallOpen] = useState(false)
const [isRecalling, setIsRecalling] = useState(false)
const [isStarred, setIsStarred] = useState(message.isStarred)
const [isTogglingStar, setIsTogglingStar] = useState(false)
// P1-3: 线程状态
const [thread, setThread] = useState<Message[]>([])
const [loadingThread, setLoadingThread] = useState(true)
// P2-4: 撤回状态(同步 UI撤回成功后立即显示占位
const [isRecalled, setIsRecalled] = useState(message.recalledAt !== null)
const { hasPermission } = usePermission()
const canSend = hasPermission(Permissions.MESSAGE_SEND)
const canDelete = hasPermission(Permissions.MESSAGE_DELETE)
const isReceived = message.receiverId === currentUserId
// P2-4: 仅发送方在 2 分钟窗口内、且消息未被撤回时可撤回
const canRecall =
!isReceived &&
canSend &&
!isRecalled &&
Date.now() - new Date(message.createdAt).getTime() <= 2 * 60 * 1000
const counterpart = isReceived ? message.senderName : message.receiverName
const counterpartLabel = isReceived ? t("meta.from") : t("meta.to")
// P1-3: 获取消息线程(根消息 + 回复链)
useEffect(() => {
let active = true
setLoadingThread(true)
void getMessageThreadAction(message.id)
.then((res) => {
if (!active) return
if (res.success && res.data) {
setThread(res.data)
} else {
setThread([])
}
})
.catch(() => {
if (!active) return
setThread([])
})
.finally(() => {
if (active) setLoadingThread(false)
})
return () => {
active = false
}
}, [message.id])
const handleDelete = async () => {
setIsWorking(true)
try {
const res = await deleteMessageAction(message.id)
if (res.success) {
toast.success(res.message)
router.push("/messages")
router.refresh()
} else {
toast.error(res.message || t("messages.deleteFailed"))
}
} catch {
toast.error(t("messages.deleteFailed"))
} finally {
setIsWorking(false)
setDeleteOpen(false)
}
}
const handleToggleStar = async () => {
setIsTogglingStar(true)
const prevStarred = isStarred
// 乐观更新
setIsStarred(!prevStarred)
try {
const res = await toggleMessageStarAction(message.id)
if (res.success) {
toast.success(t("messages.starToggled"))
} else {
// 回滚
setIsStarred(prevStarred)
toast.error(res.message)
}
} catch {
// 回滚
setIsStarred(prevStarred)
toast.error(t("messages.sendFailed"))
} finally {
setIsTogglingStar(false)
}
}
// P2-4: 消息撤回仅发送方2 分钟窗口内)
const handleRecall = async () => {
setIsRecalling(true)
try {
const res = await recallMessageAction(message.id)
if (res.success) {
// 同步 UI立即显示"已撤回"占位
setIsRecalled(true)
toast.success(t("messages.recalled"))
router.refresh()
} 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 {
setIsRecalling(false)
setRecallOpen(false)
}
}
// P1-7: 使用纯函数构建回复链接(替代组件内字符串拼接)
const replyHref = buildReplyHref(message, currentUserId, canSend)
// 线程展示:根消息已渲染在主区域,这里仅展示回复(时间正序)
// data-access 返回倒序最新在前UI 反转为正序(最早回复在前)
const replies = thread
.filter((m) => m.id !== message.id)
.slice()
.reverse()
return (
<div className="space-y-6">
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2">
<Button asChild variant="ghost" size="icon" aria-label={t("actions.back")}>
<Link href={backHref}>
<ArrowLeft className="h-4 w-4" />
</Link>
</Button>
<h2 className="text-2xl font-bold tracking-tight">{t("title.detail")}</h2>
</div>
<div className="flex flex-wrap items-center gap-2">
<Button
onClick={handleToggleStar}
disabled={isTogglingStar}
variant={isStarred ? "default" : "outline"}
aria-pressed={isStarred}
>
<Star
className={cn("mr-2 h-4 w-4", isStarred && "fill-current")}
/>
{isStarred ? t("actions.unstar") : t("actions.star")}
</Button>
{/* P2-4: 撤回按钮(仅发送方可见且未撤回、未超时) */}
{canRecall ? (
<Button
onClick={() => setRecallOpen(true)}
disabled={isRecalling}
variant="outline"
>
<RotateCcw className="mr-2 h-4 w-4" />
{t("actions.recall")}
</Button>
) : null}
{canSend && !isRecalled ? (
<Button asChild variant="outline">
<Link href={replyHref ?? "#"}>
<Reply className="mr-2 h-4 w-4" />
{t("actions.reply")}
</Link>
</Button>
) : null}
{canDelete ? (
<Button onClick={() => setDeleteOpen(true)} disabled={isWorking} variant="destructive">
<Trash2 className="mr-2 h-4 w-4" />
{t("actions.delete")}
</Button>
) : null}
{/* P2-5: 举报 + 屏蔽(仅收到的消息,非自己发送的) */}
{isReceived && !isRecalled ? (
<MessageReportBlock
messageId={message.id}
senderId={message.senderId}
currentUserId={currentUserId}
/>
) : null}
</div>
</div>
<Card>
<CardHeader className="space-y-2">
<div className="flex flex-wrap items-center gap-2">
<Mail className="text-muted-foreground h-4 w-4" aria-hidden="true" />
{/* P2-4: 已撤回状态优先显示 */}
{isRecalled ? (
<Badge variant="secondary">{t("status.recalled")}</Badge>
) : isReceived && !message.isRead ? (
<Badge variant="default">{t("status.new")}</Badge>
) : isReceived ? (
<Badge variant="secondary">{t("status.read")}</Badge>
) : (
<Badge variant="outline">{t("status.sent")}</Badge>
)}
{isStarred ? (
<span className="inline-flex items-center gap-1 text-xs text-yellow-600">
<Star className="h-3.5 w-3.5 fill-yellow-400 text-yellow-400" aria-hidden="true" />
{t("actions.star")}
</span>
) : null}
</div>
<CardTitle className="text-2xl">{message.subject ?? t("meta.noSubject")}</CardTitle>
<div className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
<span>
{counterpartLabel}: <span className="font-medium">{counterpart ?? t("meta.unknown")}</span>
</span>
<span>·</span>
<span>{formatDate(message.createdAt)}</span>
{message.readAt && isReceived ? (
<>
<span>·</span>
<span>{t("meta.readAt", { date: formatDate(message.readAt) })}</span>
</>
) : null}
</div>
</CardHeader>
<CardContent>
{/* P2-4: 已撤回消息显示占位文案,隐藏原始内容 */}
{isRecalled ? (
<p className="text-muted-foreground italic text-sm">{t("status.recalled")}</p>
) : (
<p className="text-sm leading-relaxed whitespace-pre-wrap">{message.content}</p>
)}
</CardContent>
</Card>
{/* P1-3: 消息线程(回复链) */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
{t("thread.title")}
{loadingThread ? (
<Loader2 className="h-4 w-4 animate-spin" aria-hidden="true" />
) : (
<span className="text-muted-foreground text-xs">
{replies.length > 0
? t("thread.replyCount", { count: replies.length })
: t("thread.noReplies")}
</span>
)}
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
{loadingThread ? null : replies.length === 0 ? (
<p className="text-muted-foreground text-sm">{t("thread.noReplies")}</p>
) : (
replies.map((reply) => {
const replyIsReceived = reply.receiverId === currentUserId
const replyCounterpart = replyIsReceived ? reply.senderName : reply.receiverName
const replyIsSelf = reply.senderId === currentUserId
const replyRecalled = reply.recalledAt !== null
return (
<div
key={reply.id}
className={cn(
"rounded-md border p-3",
replyIsSelf ? "border-primary/40 bg-primary/5" : "bg-muted/30"
)}
>
<div className="mb-1 flex items-center justify-between gap-2 text-xs text-muted-foreground">
<span className="font-medium">
{replyIsSelf ? t("thread.you") : (replyCounterpart ?? t("meta.unknown"))}
</span>
<span>{formatDate(reply.createdAt)}</span>
</div>
{/* P2-4: 线程中已撤回回复显示占位文案 */}
{replyRecalled ? (
<p className="text-muted-foreground italic text-sm">{t("status.recalled")}</p>
) : (
<p className="text-sm leading-relaxed whitespace-pre-wrap">{reply.content}</p>
)}
</div>
)
})
)}
</CardContent>
</Card>
<ConfirmDeleteDialog
open={deleteOpen}
onOpenChange={setDeleteOpen}
title={t("empty.deleteTitle")}
description={t("empty.deleteDesc", { subject: message.subject ?? t("meta.noSubject") })}
onConfirm={handleDelete}
isWorking={isWorking}
/>
{/* P2-4: 撤回确认对话框 */}
<Dialog open={recallOpen} onOpenChange={setRecallOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t("actions.recall")}</DialogTitle>
<DialogDescription>{t("messages.recallConfirm")}</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setRecallOpen(false)} disabled={isRecalling}>
{t("actions.cancel")}
</Button>
<Button onClick={handleRecall} disabled={isRecalling}>
{isRecalling ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" aria-hidden="true" />
) : (
<RotateCcw className="mr-2 h-4 w-4" aria-hidden="true" />
)}
{t("actions.recall")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
)
}