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:
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user