- 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
361 lines
13 KiB
TypeScript
361 lines
13 KiB
TypeScript
"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>
|
||
)
|
||
}
|