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:
SpecialX
2026-07-03 10:24:37 +08:00
parent c935597803
commit 2859ef74f2
20 changed files with 3247 additions and 347 deletions

View File

@@ -1,21 +1,31 @@
"use client"
import { useState } from "react"
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, Mail, Reply, Star, Trash2 } from "lucide-react"
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, toggleMessageStarAction } from "../actions"
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({
@@ -31,16 +41,54 @@ export function MessageDetail({
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 {
@@ -83,11 +131,41 @@ export function MessageDetail({
}
}
const replyHref = canSend
? `/messages/compose?parentId=${message.id}&receiverId=${isReceived ? message.senderId : message.receiverId}&subject=${encodeURIComponent(
message.subject?.startsWith("Re:") ? message.subject : `Re: ${message.subject ?? ""}`
)}`
: undefined
// 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">
@@ -105,13 +183,25 @@ export function MessageDetail({
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>
{canSend ? (
{/* 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" />
@@ -125,6 +215,14 @@ export function MessageDetail({
{t("actions.delete")}
</Button>
) : null}
{/* P2-5: 举报 + 屏蔽(仅收到的消息,非自己发送的) */}
{isReceived && !isRecalled ? (
<MessageReportBlock
messageId={message.id}
senderId={message.senderId}
currentUserId={currentUserId}
/>
) : null}
</div>
</div>
@@ -132,7 +230,10 @@ export function MessageDetail({
<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" />
{isReceived && !message.isRead ? (
{/* 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>
@@ -162,7 +263,64 @@ export function MessageDetail({
</div>
</CardHeader>
<CardContent>
<p className="text-sm leading-relaxed whitespace-pre-wrap">{message.content}</p>
{/* 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>
@@ -174,6 +332,29 @@ export function MessageDetail({
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>
)
}