feat : copy youtube studio
This commit is contained in:
@@ -11,7 +11,7 @@ export default async function AdminCommentsPage({ searchParams }: { searchParams
|
||||
const query = await searchParams;
|
||||
const initialTarget = query.target;
|
||||
const initialComment = initialTarget && query.comment && /^[0-9a-f-]{36}$/i.test(query.comment) ? query.comment : undefined;
|
||||
const [initial, targets] = await Promise.all([listComments({ viewer, inbox: true, target: initialTarget }), listCommentTargets()]);
|
||||
const [initial, targets] = await Promise.all([listComments({ viewer, inbox: true, grouped: true, target: initialTarget }), listCommentTargets()]);
|
||||
return <><AdminHeader /><main className="mx-auto flex w-full max-w-7xl flex-col gap-6 p-4 sm:p-6 lg:p-8">
|
||||
<header><h1 className="font-heading text-3xl font-semibold">ความคิดเห็น</h1><p className="mt-2 text-muted-foreground">ตอบกลับและจัดการความคิดเห็นจากทุกไกด์</p></header>
|
||||
<AdminCommentInbox key={`${initialTarget ?? "all"}:${initialComment ?? ""}`} initialTarget={initialTarget} initialComment={initialComment} initial={initial} targets={targets} />
|
||||
|
||||
@@ -6,7 +6,7 @@ export async function GET(request: Request) {
|
||||
const viewer = await requireCommentViewer();
|
||||
requireCommentAdmin(viewer);
|
||||
const query = new URL(request.url).searchParams;
|
||||
return Response.json(await listComments({ viewer, inbox: true, target: query.get("target") || undefined,
|
||||
return Response.json(await listComments({ viewer, inbox: true, grouped: query.get("grouped") === "true", target: query.get("target") || undefined,
|
||||
status: query.get("status") || undefined, unanswered: query.get("unanswered") === "true", cursor: query.get("cursor") }),
|
||||
{ headers: { "Cache-Control": "private, no-store" } });
|
||||
} catch (cause) { return errorResponse(cause); }
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { after } from "next/server";
|
||||
import { sendCommentPush } from "@/lib/comments/push";
|
||||
import { authorizeComment, commentId, getCommentViewer, listComments, mutateComment, requireCommentViewer } from "@/lib/comments/repository";
|
||||
import { publishComment } from "@/lib/comments/publish";
|
||||
import { errorResponse, requireSameOrigin } from "@/lib/security/http";
|
||||
@@ -18,7 +20,9 @@ export async function PATCH(request: Request, context: Context) {
|
||||
requireSameOrigin(request);
|
||||
const viewer = await requireCommentViewer();
|
||||
await limitRequest("comment-publish", viewer.id, 20);
|
||||
return Response.json(await publishComment(request, viewer, { id: commentId((await context.params).id) }));
|
||||
const result = await publishComment(request, viewer, { id: commentId((await context.params).id) });
|
||||
after(async () => { await sendCommentPush(result.id, result.version).catch(() => console.error("Comment notification failed")); });
|
||||
return Response.json(result);
|
||||
} catch (cause) { return errorResponse(cause); }
|
||||
}
|
||||
export async function DELETE(request: Request, context: Context) {
|
||||
|
||||
@@ -21,7 +21,7 @@ export async function POST(request: Request) {
|
||||
const target = new URL(request.url).searchParams.get("target");
|
||||
if (!target) throw new HttpError(400, "target-required");
|
||||
const result = await publishComment(request, viewer, { target });
|
||||
after(async () => { await sendCommentPush(result.id).catch(() => console.error("Comment notification failed")); });
|
||||
after(async () => { await sendCommentPush(result.id, result.version).catch(() => console.error("Comment notification failed")); });
|
||||
return Response.json(result, { status: 201 });
|
||||
} catch (cause) { return errorResponse(cause); }
|
||||
}
|
||||
|
||||
@@ -13,7 +13,6 @@ import {
|
||||
SwordsIcon,
|
||||
} from "lucide-react";
|
||||
|
||||
import { AdminUnreadCount } from "@/components/comments/admin-unread-comments";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { PublicAccountMenu } from "@/components/public/public-account-menu";
|
||||
import { NotificationBell } from "@/components/notifications/notification-bell";
|
||||
@@ -35,7 +34,7 @@ export function AdminHeader() {
|
||||
<nav aria-label="เมนูแอดมิน" className="flex min-w-0 flex-1 items-center gap-1 overflow-x-auto sm:justify-end">
|
||||
<Button variant={pathname.startsWith("/admin/comments") ? "secondary" : "ghost"} size="sm" nativeButton={false}
|
||||
render={<Link href="/admin/comments" prefetch={false} />} aria-label="ความคิดเห็น">
|
||||
<Image src="/icon/nav/Comment.webp" alt="" width={20} height={20} className="size-5 object-contain" /><span className="hidden xl:inline">ความคิดเห็น</span><AdminUnreadCount />
|
||||
<Image src="/icon/nav/Comment.webp" alt="" width={20} height={20} className="size-5 object-contain" /><span className="hidden xl:inline">ความคิดเห็น</span>
|
||||
</Button>
|
||||
<Button variant={pathname.startsWith("/admin/commission") ? "secondary" : "ghost"}
|
||||
size="sm" nativeButton={false} render={<Link href="/admin/commission" />} aria-label="Commission">
|
||||
|
||||
@@ -14,7 +14,6 @@ import type { CommentPage } from "@/lib/comments/types";
|
||||
import { characterRarityGradientClass } from "@/lib/catalog/rarity";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useMarkCommentsRead } from "./admin-unread-comments";
|
||||
import { CommentPushPrompt } from "./comment-push-prompt";
|
||||
import { CommentCard, CommentThread } from "./comment-card";
|
||||
import { CommentSkeleton } from "./comment-skeleton";
|
||||
import { useCommentEvents } from "./use-comment-events";
|
||||
@@ -26,14 +25,13 @@ export function AdminCommentInbox({ initial, targets, initialTarget, initialComm
|
||||
const [target, setTarget] = useState<TargetOption | null>(targets.find((item) => item.value === initialTarget) ?? null);
|
||||
const [status, setStatus] = useState("all");
|
||||
const [answered, setAnswered] = useState("all");
|
||||
const query = new URLSearchParams({ view: "inbox", status, unanswered: String(answered === "unanswered") });
|
||||
const query = new URLSearchParams({ view: "inbox", grouped: "true", status, unanswered: String(answered === "unanswered") });
|
||||
if (target) query.set("target", target.value);
|
||||
const feed = useCommentFeed(`/api/admin/comments?${query}`, initial);
|
||||
useCommentEvents({ admin: true }, () => feed.refresh({ silent: true }));
|
||||
useCommentEvents({ admin: true }, () => feed.refresh({ silent: true, forceRevision: true }));
|
||||
const markRead = useMarkCommentsRead();
|
||||
useEffect(() => { void markRead(target?.value ?? "all"); }, [markRead, target, feed.revision]);
|
||||
return <div className="flex flex-col gap-6">
|
||||
{feed.page.viewer && <CommentPushPrompt userId={feed.page.viewer.id} admin />}
|
||||
{initialComment && initialTarget && <Dialog open={notificationOpen} onOpenChange={setNotificationOpen}><DialogContent className="max-h-[90svh] overflow-y-auto sm:max-w-4xl">
|
||||
<DialogHeader><DialogTitle>การสนทนา</DialogTitle><DialogDescription>ความคิดเห็นจากการแจ้งเตือนและคำตอบกลับ</DialogDescription></DialogHeader>
|
||||
{notificationOpen && <CommentThread rootId={initialComment} expectedTarget={initialTarget} onChange={() => feed.refresh({ silent: true })} />}
|
||||
@@ -53,9 +51,9 @@ export function AdminCommentInbox({ initial, targets, initialTarget, initialComm
|
||||
<ToggleGroupItem value="all">ทุกความคิดเห็น</ToggleGroupItem><ToggleGroupItem value="unanswered">ยังไม่ได้ตอบ</ToggleGroupItem>
|
||||
</ToggleGroup></Field>
|
||||
</FieldGroup>
|
||||
<Button variant="outline" className="self-end" disabled={feed.busy} onClick={() => void feed.refresh()}><RefreshCwIcon data-icon="inline-start" />รีเฟรช</Button>
|
||||
<Button variant="outline" className="self-end" disabled={feed.busy} onClick={() => void feed.refresh({ silent: false, forceRevision: true })}><RefreshCwIcon data-icon="inline-start" />รีเฟรช</Button>
|
||||
{feed.error && <Alert variant="destructive"><AlertDescription>{feed.error}</AlertDescription></Alert>}
|
||||
{feed.page.items.map((item) => <CommentCard key={item.id} item={item} viewer={feed.page.viewer} inbox replies={false} onChange={() => feed.refresh({ silent: true })} revision={feed.revision} />)}
|
||||
{feed.page.items.map((item) => <CommentCard key={item.id} item={item} viewer={feed.page.viewer} inbox onChange={() => feed.refresh({ silent: true, forceRevision: true })} revision={feed.revision} />)}
|
||||
{!feed.page.items.length && !feed.busy && !feed.error && <Empty><EmptyHeader><EmptyTitle>ไม่พบความคิดเห็นที่ตรงกัน</EmptyTitle><EmptyDescription>ความคิดเห็นจากทุกไกด์และทุกรอบ Stygian จะแสดงที่นี่</EmptyDescription></EmptyHeader></Empty>}
|
||||
{feed.busy && <CommentSkeleton count={feed.page.items.length ? 1 : 3} />}
|
||||
{feed.page.nextCursor && <Button variant="outline" disabled={feed.busy} onClick={() => void feed.more()}>ดูความคิดเห็นเพิ่มเติม</Button>}
|
||||
|
||||
@@ -28,7 +28,7 @@ export function useMarkCommentsRead() { return useContext(UnreadContext).markRea
|
||||
export function AdminUnreadCount({ target }: { target?: string }) {
|
||||
const state = useContext(UnreadContext);
|
||||
const count = target ? state.counts[target] ?? 0 : state.total;
|
||||
return count > 0 ? <Badge variant="destructive" className="min-w-5 justify-center rounded-full px-1.5" aria-label={`ความคิดเห็นใหม่ ${count} รายการ`}>{count > 99 ? "99+" : count}</Badge> : null;
|
||||
return count > 0 ? <Badge variant="destructive" className="min-w-5 justify-center rounded-full bg-destructive px-1.5 text-background dark:bg-destructive" aria-label={`ความคิดเห็นใหม่ ${count} รายการ`}>{count > 99 ? "99+" : count}</Badge> : null;
|
||||
}
|
||||
export function AdminUnreadBadge({ target }: { target: string }) {
|
||||
const state = useContext(UnreadContext);
|
||||
|
||||
@@ -6,6 +6,7 @@ export const COMMENT_ERRORS: Record<string, string> = {
|
||||
"body-too-large": "แนบรูปภาพได้สูงสุด 5 รูป รูปละไม่เกิน 10 MiB",
|
||||
"unsupported-image-type": "รองรับรูปภาพ JPEG, PNG และ WebP ไม่รองรับวิดีโอ",
|
||||
"invalid-image": "ไม่สามารถอ่านรูปภาพได้ กรุณาเลือกไฟล์รูปภาพที่ถูกต้อง",
|
||||
"comment-image-upload-failed": "อัปโหลดรูปภาพไม่สำเร็จ กรุณาลองใหม่ ข้อความร่างและรูปภาพของคุณยังอยู่",
|
||||
"empty-comment": "เขียนความคิดเห็นหรือแนบรูปภาพ",
|
||||
"invalid-comment": "เขียนได้สูงสุด 4,000 ตัวอักษร และแนบรูปภาพได้ 5 รูป",
|
||||
"comment-edited-reload": "ความคิดเห็นนี้ถูกแก้ไขแล้ว กรุณาโหลดใหม่ก่อนบันทึก ข้อความร่างของคุณยังอยู่",
|
||||
|
||||
@@ -10,7 +10,6 @@ import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import { Empty, EmptyHeader, EmptyTitle, EmptyDescription } from "@/components/ui/empty";
|
||||
import type { CommentPage } from "@/lib/comments/types";
|
||||
import { CommentPushPrompt } from "./comment-push-prompt";
|
||||
import { CommentComposer } from "./comment-composer";
|
||||
import { CommentCard, CommentThread } from "./comment-card";
|
||||
import { commentSignInHref } from "./client";
|
||||
@@ -39,7 +38,6 @@ export function CommentBoard({ target, initial }: { target: string; initial: Com
|
||||
return () => window.removeEventListener("hashchange", showThread);
|
||||
}, []);
|
||||
return <div className="flex flex-col gap-6 py-2">
|
||||
{feed.page.viewer && <CommentPushPrompt userId={feed.page.viewer.id} admin={feed.page.viewer.admin} />}
|
||||
<div className="flex items-start gap-3"><Avatar className="size-10 shrink-0"><AvatarImage src={feed.page.viewer?.image ?? undefined} alt={feed.page.viewer?.name ?? "โปรไฟล์"} /><AvatarFallback>{feed.page.viewer?.name?.slice(0, 2) || "คุณ"}</AvatarFallback></Avatar><div className="min-w-0 flex-1">{feed.page.viewer ? <CommentComposer target={target} onSaved={() => void feed.refresh()} />
|
||||
: <div className="flex flex-col items-start gap-2"><p className="text-sm text-muted-foreground">เข้าสู่ระบบเพื่อแสดงความคิดเห็น ตอบกลับ และกดถูกใจ</p><Button onClick={() => router.push(commentSignInHref())}>เข้าสู่ระบบ</Button></div>}</div></div>
|
||||
<div className="order-first flex flex-wrap items-center gap-3"><h2 className="font-heading text-xl font-semibold">ความคิดเห็น</h2>
|
||||
|
||||
@@ -117,8 +117,8 @@ function RevisionHistory({ id }: { id: string }) {
|
||||
</div>;
|
||||
}
|
||||
|
||||
export function CommentCard({ item, viewer, onChange, revision = 0, inbox = false, replies = true, defaultExpanded = false, focusReplyId, highlighted = false }: {
|
||||
item: CommentItem; viewer: CommentViewer | null; onChange: () => void | Promise<unknown>; revision?: number; inbox?: boolean; replies?: boolean; defaultExpanded?: boolean; focusReplyId?: string; highlighted?: boolean;
|
||||
export function CommentCard({ item, viewer, onChange, revision = 0, inbox = false, moderationDetails = false, replies = true, defaultExpanded = false, focusReplyId, highlighted = false }: {
|
||||
item: CommentItem; viewer: CommentViewer | null; onChange: () => void | Promise<unknown>; revision?: number; inbox?: boolean; moderationDetails?: boolean; replies?: boolean; defaultExpanded?: boolean; focusReplyId?: string; highlighted?: boolean;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const [mode, setMode] = useState<"reply" | "edit" | null>(null);
|
||||
@@ -212,8 +212,8 @@ export function CommentCard({ item, viewer, onChange, revision = 0, inbox = fals
|
||||
<div className="flex items-start justify-between gap-2"><div className="flex min-w-0 flex-wrap items-center gap-x-2 gap-y-1"><span className="text-sm font-semibold">{item.authorName}</span>{item.authorAdmin && <Badge variant="secondary">ผู้ดูแล</Badge>}
|
||||
<CommentTimestamp value={item.createdAt} />
|
||||
{inbox && item.rootId && <Badge variant="outline">ตอบกลับ</Badge>}
|
||||
{inbox && item.hidden && <Badge variant="secondary">{item.ownHidden ? "ซ่อนอยู่" : "การสนทนาถูกซ่อน"}</Badge>}
|
||||
{inbox && item.deleted && <Badge variant="secondary">ลบแล้ว</Badge>}
|
||||
{(inbox || moderationDetails) && item.hidden && <Badge variant="secondary">{item.ownHidden ? "ซ่อนอยู่" : "การสนทนาถูกซ่อน"}</Badge>}
|
||||
{(inbox || moderationDetails) && item.deleted && <Badge variant="secondary">ลบแล้ว</Badge>}
|
||||
{item.version > 1 && canReadHistory && <Button variant="link" size="xs" onClick={() => setHistory(true)}>แก้ไขแล้ว</Button>}
|
||||
</div>
|
||||
{(item.canEdit || viewer?.admin) && <DropdownMenu>
|
||||
@@ -236,14 +236,14 @@ export function CommentCard({ item, viewer, onChange, revision = 0, inbox = fals
|
||||
{viewer?.admin ? <Button variant={optimistic.hearted ? "secondary" : "ghost"} size="sm" aria-pressed={optimistic.hearted} disabled={pending || !item.canInteract} onClick={() => void mutate("heart", !desired.current.hearted)}>
|
||||
<HeartIcon data-icon="inline-start" className={cn(optimistic.hearted && "text-destructive")} fill={optimistic.hearted ? "currentColor" : "none"} />{optimistic.hearted ? "ผู้ดูแลถูกใจ" : null}</Button>
|
||||
: optimistic.hearted && !item.deleted && !item.hidden && <Badge variant="secondary"><HeartIcon data-icon="inline-start" className="text-destructive" fill="currentColor" />ผู้ดูแลถูกใจ</Badge>}
|
||||
{inbox && item.hasReplies && <Button variant="outline" size="sm" aria-haspopup="dialog" onClick={() => setThread(true)}>ดูการสนทนา</Button>}
|
||||
{inbox && !replies && item.hasReplies && <Button variant="outline" size="sm" aria-haspopup="dialog" onClick={() => setThread(true)}>ดูการสนทนา</Button>}
|
||||
</div>
|
||||
{mode && <CommentComposer key={mode} target={item.target} replyTo={mode === "reply" ? item : undefined} editing={mode === "edit" ? item : undefined}
|
||||
onCancel={() => setMode(null)} onSaved={() => { setMode(null); expandReplies(true); onChange(); }} />}
|
||||
{replies && !item.rootId && (item.replyCount > 0 || expanded) && <Accordion value={expanded ? ["replies"] : []} onValueChange={(value) => expandReplies(value.includes("replies"))}>
|
||||
<AccordionItem value="replies"><AccordionTrigger ref={replyToggleRef} className="flex-none gap-2 rounded-full px-3" headerClassName={expanded ? "hidden" : "justify-start"}>{`การตอบกลับ ${item.replyCount.toLocaleString("th-TH")} รายการ`}</AccordionTrigger>
|
||||
<AccordionContent keepMounted panelClassName="data-open:overflow-visible" className="[&_p]:mb-0">{hasOpenedReplies && <div className="flex flex-col gap-3">
|
||||
<CommentReplies rootId={item.id} focusReplyId={focusReplyId} viewer={viewer} onChange={onChange} revision={revision} />
|
||||
<CommentReplies rootId={item.id} focusReplyId={focusReplyId} viewer={viewer} onChange={onChange} revision={revision} moderationDetails={inbox || moderationDetails} />
|
||||
<div className="relative self-start before:absolute before:-left-8 before:top-0 before:h-5 before:w-8 before:rounded-bl-2xl before:border-b before:border-l before:border-border">
|
||||
<Button variant="ghost" size="sm" className="rounded-full" onClick={() => { setExpanded(false); requestAnimationFrame(() => replyToggleRef.current?.focus()); }}><ChevronUpIcon data-icon="inline-start" />Show less</Button>
|
||||
</div>
|
||||
@@ -271,7 +271,7 @@ export function CommentCard({ item, viewer, onChange, revision = 0, inbox = fals
|
||||
</article>;
|
||||
}
|
||||
|
||||
function CommentReplies({ rootId, revision, onChange, focusReplyId }: { focusReplyId?: string; rootId: string; viewer: CommentViewer | null; revision: number; onChange: () => void | Promise<unknown> }) {
|
||||
function CommentReplies({ rootId, revision, onChange, focusReplyId, moderationDetails = false }: { focusReplyId?: string; rootId: string; viewer: CommentViewer | null; revision: number; onChange: () => void | Promise<unknown>; moderationDetails?: boolean }) {
|
||||
const feed = useCommentFeed(`/api/comments/${rootId}/replies?view=thread`);
|
||||
const { refresh, busy, error, page, more } = feed;
|
||||
const previousRevision = useRef(revision);
|
||||
@@ -292,14 +292,14 @@ function CommentReplies({ rootId, revision, onChange, focusReplyId }: { focusRep
|
||||
}, [focusReplyId, busy, error, page, more]);
|
||||
return <div id={`replies-${rootId}`} className="flex flex-col gap-3 pt-2">
|
||||
{feed.error && <Alert variant="destructive"><AlertDescription>{feed.error}</AlertDescription></Alert>}
|
||||
<CommentReplyTree focusReplyId={focusReplyId} items={feed.page.items} viewer={feed.page.viewer} onChange={async () => { await refresh({ silent: true }); await onChange(); }} />
|
||||
<CommentReplyTree focusReplyId={focusReplyId} items={feed.page.items} viewer={feed.page.viewer} moderationDetails={moderationDetails} onChange={async () => { await refresh({ silent: true }); await onChange(); }} />
|
||||
{feed.busy && <CommentSkeleton count={2} />}
|
||||
{feed.page.nextCursor && <Button variant="outline" disabled={feed.busy} onClick={() => void feed.more()}>ดูคำตอบเพิ่มเติม</Button>}
|
||||
{feed.error && <Button variant="outline" disabled={feed.busy} onClick={() => void refresh()}>ลองใหม่</Button>}
|
||||
</div>;
|
||||
}
|
||||
|
||||
function CommentReplyTree({ items, viewer, onChange, focusReplyId }: { focusReplyId?: string; items: CommentItem[]; viewer: CommentViewer | null; onChange: () => void | Promise<unknown> }) {
|
||||
function CommentReplyTree({ items, viewer, onChange, focusReplyId, moderationDetails = false }: { focusReplyId?: string; items: CommentItem[]; viewer: CommentViewer | null; onChange: () => void | Promise<unknown>; moderationDetails?: boolean }) {
|
||||
const loaded = new Set(items.map((item) => item.id));
|
||||
const groups = new Map<string | null, CommentItem[]>();
|
||||
for (const item of items) {
|
||||
@@ -314,7 +314,7 @@ function CommentReplyTree({ items, viewer, onChange, focusReplyId }: { focusRepl
|
||||
parent !== null && index < siblings.length - 1 && "after:absolute after:-bottom-3 after:-left-8 after:top-0 after:border-l after:border-border",
|
||||
)}>
|
||||
<div className={cn(groups.has(reply.id) && "relative after:absolute after:-bottom-3 after:left-4 after:top-10 after:border-l after:border-border")}>
|
||||
<CommentCard item={reply} highlighted={reply.id === focusReplyId} viewer={viewer} replies={false} onChange={onChange} />
|
||||
<CommentCard item={reply} highlighted={reply.id === focusReplyId} viewer={viewer} moderationDetails={moderationDetails} replies={false} onChange={onChange} />
|
||||
</div>
|
||||
{groups.has(reply.id) && <div className="ml-4 flex min-w-0 flex-col gap-3 pl-8">
|
||||
{branch(reply.id)}
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
'use client';
|
||||
import { BellIcon } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
export function CommentPushPrompt({ admin = false }: { userId: string; admin?: boolean }) {
|
||||
return <Button variant="outline" size="sm" className="self-start" onClick={() => window.dispatchEvent(new Event('notifications:enable'))}><BellIcon data-icon="inline-start" />{admin ? 'แจ้งเตือนความคิดเห็นใหม่' : 'แจ้งเตือนเมื่อมีคนตอบกลับ'}</Button>;
|
||||
}
|
||||
@@ -63,7 +63,7 @@ export function NotificationBell() {
|
||||
const [open, setOpen] = useState(false);
|
||||
return <Popover open={open} onOpenChange={value => { setOpen(value); if (value) void state.refresh(); }}>
|
||||
<PopoverTrigger render={<Button variant="ghost" size="icon" className="relative shrink-0" aria-label={`การแจ้งเตือน${state.unread ? ` ใหม่ ${state.unread} รายการ` : ''}`} />}><BellIcon />
|
||||
{state.unread > 0 && <Badge variant="destructive" className="absolute -right-1 -top-1 min-w-5 justify-center rounded-full px-1">{state.unread > 9 ? '9+' : state.unread}</Badge>}
|
||||
{state.unread > 0 && <Badge variant="destructive" className="absolute -right-1 -top-1 min-w-5 justify-center rounded-full bg-destructive px-1 text-background dark:bg-destructive">{state.unread > 9 ? '9+' : state.unread}</Badge>}
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="end" className="w-96 max-w-[calc(100vw-2rem)]">
|
||||
<div className="flex items-center justify-between gap-2"><h2 className="font-semibold">การแจ้งเตือน</h2><Button variant="ghost" size="icon-sm" aria-label="ตั้งค่าการแจ้งเตือน" onClick={() => { setOpen(false); window.dispatchEvent(new Event('notifications:enable')); }}><SettingsIcon /></Button></div>
|
||||
|
||||
+23
-4
@@ -16,6 +16,24 @@ type ParsedForm = ReturnType<typeof parseCommentForm>;
|
||||
type Upload = { id: string; objectKey: string; mimeType: string; byteSize: number };
|
||||
type Writer = Parameters<Parameters<ReturnType<typeof getDb>["transaction"]>[0]>[0];
|
||||
|
||||
async function uploadCommentImage(storage: Awaited<ReturnType<typeof getMediaStorage>>, image: Upload, bytes: Uint8Array) {
|
||||
for (let attempt = 0; ; attempt++) {
|
||||
try {
|
||||
// Retry the same object key, never the database mutation.
|
||||
await storage.write(image.objectKey, bytes, { type: image.mimeType, acl: "public-read", retry: 0 });
|
||||
return;
|
||||
} catch (cause) {
|
||||
const error = cause as { code?: string; name?: string; statusCode?: number } | null;
|
||||
const transient = error && (
|
||||
["ECONNRESET", "ECONNREFUSED", "EPIPE", "ETIMEDOUT", "ConnectionClosed", "SocketClosed", "InternalError", "SlowDown", "ServiceUnavailable", "RequestTimeout"].includes(error.code ?? "") ||
|
||||
error.name === "TimeoutError" || error.statusCode === 429 || (error.statusCode !== undefined && error.statusCode >= 500)
|
||||
);
|
||||
if (!transient || attempt === 2) throw new HttpError(503, "comment-image-upload-failed");
|
||||
await new Promise((resolve) => setTimeout(resolve, 200 * (attempt + 1)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function saveRevision(tx: Pick<Writer, "insert" | "select">, id: string, version: number, form: ParsedForm, uploaded: Upload[]) {
|
||||
if (form.keep.length) {
|
||||
const own = await tx.select({ id: commentAttachments.id }).from(commentAttachments)
|
||||
@@ -45,18 +63,19 @@ export async function publishComment(request: Request, viewer: CommentViewer, op
|
||||
const image = { id: crypto.randomUUID(), objectKey: `comments/${crypto.randomUUID()}`, mimeType: file.type, byteSize: file.size };
|
||||
uploaded.push(image);
|
||||
// Browser images load directly from the configured S3 public CDN.
|
||||
await storage!.write(image.objectKey, bytes, { type: file.type, acl: "public-read" });
|
||||
await uploadCommentImage(storage!, image, bytes);
|
||||
}
|
||||
if (options.id) {
|
||||
await withCommentLock(options.id, viewer, async (tx, context) => {
|
||||
const version = await withCommentLock(options.id, viewer, async (tx, context) => {
|
||||
const c = context.comment;
|
||||
if (c.authorId !== viewer.id) throw new HttpError(403, "not-comment-author");
|
||||
if (c.deletedAt || c.hidden || context.rootHidden) throw new HttpError(409, "comment-unavailable");
|
||||
if (c.version !== form.version) throw new HttpError(409, "comment-edited-reload");
|
||||
await saveRevision(tx, c.id, c.version + 1, form, uploaded);
|
||||
await tx.update(comments).set({ version: c.version + 1 }).where(eq(comments.id, c.id));
|
||||
return c.version + 1;
|
||||
});
|
||||
return { id: options.id };
|
||||
return { id: options.id, version };
|
||||
}
|
||||
const thread = await ensureCommentThread(options.target!, viewer);
|
||||
return await getDb().transaction(async (tx) => {
|
||||
@@ -72,7 +91,7 @@ export async function publishComment(request: Request, viewer: CommentViewer, op
|
||||
}
|
||||
const [comment] = await tx.insert(comments).values({ threadId: thread.id, authorId: viewer.id, rootId, replyToId: form.replyToId }).returning({ id: comments.id });
|
||||
await saveRevision(tx, comment.id, 1, form, uploaded);
|
||||
return { id: comment.id };
|
||||
return { id: comment.id, version: 1 };
|
||||
});
|
||||
} catch (cause) {
|
||||
if (uploaded.length) {
|
||||
|
||||
@@ -8,9 +8,11 @@ import { pushPublicKey, validPushEndpoint } from "@/lib/commission/push";
|
||||
import { createNotifications } from "@/lib/notifications/repository";
|
||||
import { authorizeComment } from "./repository";
|
||||
|
||||
export async function sendCommentPush(id: string) {
|
||||
export async function sendCommentPush(id: string, version?: number) {
|
||||
const [reply] = await getDb().select().from(comments).where(eq(comments.id, id)).limit(1);
|
||||
if (!reply || reply.hidden || reply.deletedAt) return;
|
||||
const notificationVersion = version ?? reply.version;
|
||||
const edited = notificationVersion > 1;
|
||||
let recipients: { id: string; admin: boolean }[];
|
||||
let url: string;
|
||||
let title: string;
|
||||
@@ -25,7 +27,7 @@ export async function sendCommentPush(id: string) {
|
||||
} catch { return; }
|
||||
recipients = [{ id: recipient.id, admin: false }];
|
||||
url = `${destination.href}${destination.href.includes("?") ? "&" : "?"}reply=${id}#comment-${reply.rootId}`;
|
||||
title = "ตอบกลับความคิดเห็นของคุณ";
|
||||
title = edited ? "แก้ไขคำตอบกลับความคิดเห็นของคุณ" : "ตอบกลับความคิดเห็นของคุณ";
|
||||
} else {
|
||||
recipients = (await getDb().select({ id: users.id }).from(users)
|
||||
.where(and(eq(users.role, "admin"), eq(users.emailVerified, true), ne(users.id, reply.authorId), sql`${users.banned} is not true`)))
|
||||
@@ -37,15 +39,16 @@ export async function sendCommentPush(id: string) {
|
||||
} catch { return; }
|
||||
if (!destination.writable) return;
|
||||
url = `/admin/comments?target=${encodeURIComponent(destination.target)}&comment=${id}`;
|
||||
title = `แสดงความคิดเห็นใหม่ใน ${destination.name}`;
|
||||
title = edited ? `แก้ไขความคิดเห็นใน ${destination.name}` : `แสดงความคิดเห็นใหม่ใน ${destination.name}`;
|
||||
}
|
||||
const [author] = await getDb().select({ name: users.name }).from(users).where(eq(users.id, reply.authorId)).limit(1);
|
||||
const [revision] = await getDb().select({ text: commentRevisions.text }).from(commentRevisions)
|
||||
.where(and(eq(commentRevisions.commentId, id), eq(commentRevisions.version, reply.version))).limit(1);
|
||||
.where(and(eq(commentRevisions.commentId, id), eq(commentRevisions.version, notificationVersion))).limit(1);
|
||||
if (!revision) return;
|
||||
const notificationTitle = `${author?.name ?? "ผู้ใช้"} ${title}`;
|
||||
const body = revision?.text.slice(0, 300) || "ส่งรูปภาพ";
|
||||
await createNotifications(recipients.map(recipient => ({ userId: recipient.id, adminOnly: recipient.admin, url })), {
|
||||
eventKey: `comment:${id}`, kind: reply.replyToId ? "comment_reply" : "comment_new", title: notificationTitle, body, commentId: id,
|
||||
eventKey: edited ? `comment:${id}:edit:${notificationVersion}` : `comment:${id}`, kind: edited ? "comment_edit" : reply.replyToId ? "comment_reply" : "comment_new", title: notificationTitle, body, commentId: id,
|
||||
});
|
||||
if (!pushPublicKey()) return;
|
||||
const subscriptions = await getDb().select().from(commentPushSubscriptions).where(inArray(commentPushSubscriptions.userId, recipients.map(recipient => recipient.id)));
|
||||
|
||||
@@ -98,10 +98,11 @@ async function revisionImages(ids: string[], db: Reader = getDb()) {
|
||||
|
||||
export async function listComments(options: {
|
||||
viewer: CommentViewer | null; target?: string; rootId?: string; id?: string; cursor?: string | null;
|
||||
sort?: string; inbox?: boolean; status?: string; unanswered?: boolean;
|
||||
sort?: string; inbox?: boolean; grouped?: boolean; status?: string; unanswered?: boolean;
|
||||
}): Promise<CommentPage> {
|
||||
const { viewer } = options;
|
||||
if (options.inbox) requireCommentAdmin(viewer);
|
||||
const grouped = Boolean(options.inbox && options.grouped && !options.id && !options.rootId);
|
||||
const conditions: SQL[] = [];
|
||||
if (options.id) conditions.push(eq(comments.id, commentId(options.id)));
|
||||
if (options.target) {
|
||||
@@ -114,14 +115,21 @@ export async function listComments(options: {
|
||||
if (context.comment.rootId) throw new HttpError(400, "root-required");
|
||||
if (options.target && context.destination.target !== options.target) throw new HttpError(404, "comment-not-found");
|
||||
conditions.push(eq(comments.rootId, options.rootId));
|
||||
} else if (!options.inbox && !options.id) conditions.push(isNull(comments.rootId));
|
||||
} else if (grouped || (!options.inbox && !options.id)) conditions.push(isNull(comments.rootId));
|
||||
if (!viewer?.admin) {
|
||||
conditions.push(sql`coalesce(${root.hidden}, false) = false`, publicTarget);
|
||||
if (!options.rootId) conditions.push(eq(comments.hidden, false));
|
||||
if (!options.rootId && !options.id) conditions.push(or(isNull(comments.deletedAt), sql`${replyCount} > 0`)!);
|
||||
}
|
||||
if (options.status === "hidden") conditions.push(or(eq(comments.hidden, true), eq(root.hidden, true))!);
|
||||
if (options.status === "visible") conditions.push(eq(comments.hidden, false), sql`coalesce(${root.hidden}, false) = false`, isNull(comments.deletedAt));
|
||||
if (grouped) {
|
||||
if (options.status === "hidden") conditions.push(or(eq(comments.hidden, true), sql`exists (
|
||||
select 1 from ${comments} reply where reply.root_id = ${comments.id} and reply.hidden)`)!);
|
||||
if (options.status === "visible") conditions.push(eq(comments.hidden, false), or(isNull(comments.deletedAt), sql`exists (
|
||||
select 1 from ${comments} reply where reply.root_id = ${comments.id} and not reply.hidden and reply.deleted_at is null)`)!);
|
||||
} else {
|
||||
if (options.status === "hidden") conditions.push(or(eq(comments.hidden, true), eq(root.hidden, true))!);
|
||||
if (options.status === "visible") conditions.push(eq(comments.hidden, false), sql`coalesce(${root.hidden}, false) = false`, isNull(comments.deletedAt));
|
||||
}
|
||||
if (options.unanswered) conditions.push(isNull(comments.rootId), isNull(comments.deletedAt), sql`not exists (
|
||||
select 1 from ${comments} reply join ${users} author on author.id = reply.author_id
|
||||
where reply.root_id = ${comments.id} and author.role = 'admin' and author.email_verified and not reply.hidden and reply.deleted_at is null)`);
|
||||
|
||||
@@ -2,6 +2,7 @@ import { randomInt, randomUUID } from "node:crypto";
|
||||
import { eq, inArray } from "drizzle-orm";
|
||||
import sharp from "sharp";
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { after } from "next/server";
|
||||
|
||||
vi.mock("server-only", () => ({}));
|
||||
vi.mock("@/lib/notifications/events", () => ({ notifyNotificationChange: vi.fn() }));
|
||||
@@ -84,6 +85,7 @@ describeDatabase("guide comments against PostgreSQL", { timeout: 30000 }, () =>
|
||||
storage.write.mockReset().mockResolvedValue(undefined); storage.delete.mockReset().mockResolvedValue(undefined);
|
||||
session.get.mockResolvedValue(null);
|
||||
rateLimit.mockReset().mockResolvedValue(undefined);
|
||||
vi.mocked(after).mockClear();
|
||||
});
|
||||
afterAll(async () => {
|
||||
const db = getDb();
|
||||
@@ -127,6 +129,39 @@ describeDatabase("guide comments against PostgreSQL", { timeout: 30000 }, () =>
|
||||
expect((await page()).items[0].replyCount).toBe(2);
|
||||
await expect(publishComment(request("Bad reply", { reply: reply.id }), author, { target: `guide:${otherGuideId}` })).rejects.toMatchObject({ status: 400 });
|
||||
});
|
||||
it("groups the admin inbox and keeps nested admin replies attached to their recipient", async () => {
|
||||
const root = await post();
|
||||
const reply = await post("Reader reply", { reply: root.id });
|
||||
const nested = await publishComment(request("Admin reply", { reply: reply.id }), admin, { target });
|
||||
const second = await post("Second conversation");
|
||||
const grouped = await listComments({ viewer: admin, inbox: true, grouped: true, target });
|
||||
expect(grouped.items.map((item) => item.id)).toEqual([second.id, root.id]);
|
||||
expect(grouped.items[1].replyCount).toBe(2);
|
||||
const replies = await listComments({ viewer: admin, rootId: root.id });
|
||||
expect(replies.items[1]).toMatchObject({ id: nested.id, rootId: root.id, replyToId: reply.id, authorAdmin: true });
|
||||
expect((await listComments({ viewer: admin, inbox: true, grouped: true, target, unanswered: true })).items.map((item) => item.id)).toEqual([second.id]);
|
||||
expect((await listComments({ viewer: admin, inbox: true, target })).items).toHaveLength(4);
|
||||
expect((await listComments({ viewer: admin, inbox: true, grouped: true, target: `guide:${otherGuideId}` })).items).toHaveLength(0);
|
||||
session.get.mockResolvedValue({ user: { id: admin.id } });
|
||||
const response = await inboxRoute.GET(new Request(`https://guide.example.test/api/admin/comments?grouped=true&target=${target}`));
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.headers.get("Cache-Control")).toBe("private, no-store");
|
||||
expect((await response.json()).items.map((item: { id: string }) => item.id)).toEqual([second.id, root.id]);
|
||||
});
|
||||
it("finds grouped conversations by reply visibility without filtering their context", async () => {
|
||||
const root = await post();
|
||||
const reply = await post("Reply", { reply: root.id });
|
||||
const nested = await post("Nested reply", { reply: reply.id });
|
||||
await mutateComment(nested.id, admin, "moderation", true);
|
||||
const hidden = await listComments({ viewer: admin, inbox: true, grouped: true, target, status: "hidden" });
|
||||
expect(hidden.items.map((item) => item.id)).toEqual([root.id]);
|
||||
expect(hidden.items[0].hidden).toBe(false);
|
||||
expect((await listComments({ viewer: admin, rootId: root.id })).items).toHaveLength(2);
|
||||
expect((await listComments({ viewer: admin, inbox: true, grouped: true, target, status: "visible" })).items.map((item) => item.id)).toEqual([root.id]);
|
||||
await mutateComment(root.id, admin, "moderation", true);
|
||||
expect((await listComments({ viewer: admin, inbox: true, grouped: true, target, status: "visible" })).items).toHaveLength(0);
|
||||
expect((await listComments({ viewer: admin, inbox: true, grouped: true, target, status: "hidden" })).items).toHaveLength(1);
|
||||
});
|
||||
it("keeps Stygian schedule threads independent", async () => {
|
||||
await publishComment(request("Schedule A"), author, { target: `stygian:${scheduleId}` });
|
||||
expect((await listComments({ viewer: author, target: `stygian:${scheduleId + 1}` })).items).toHaveLength(0);
|
||||
@@ -174,6 +209,47 @@ describeDatabase("guide comments against PostgreSQL", { timeout: 30000 }, () =>
|
||||
await expect(publishComment(request("Foreign image", { version: 1, keep: [image.id] }), author, { id: otherRoot.id })).rejects.toMatchObject({ status: 400 });
|
||||
await expect(mutateComment(root.id, other, "delete")).rejects.toMatchObject({ status: 403 });
|
||||
});
|
||||
it("edits images through PATCH while preserving retained images and revision history", async () => {
|
||||
const root = await post("Original", { images: [png] });
|
||||
const originalImage = (await page()).items[0].images[0];
|
||||
session.get.mockResolvedValue({ user: { id: author.id } });
|
||||
const context = { params: Promise.resolve({ id: root.id }) };
|
||||
const edit = request("Added image", { version: 1, keep: [originalImage.id], images: [png] });
|
||||
const response = await itemRoute.PATCH(new Request(edit, { method: "PATCH" }), context);
|
||||
expect(await response.json()).toMatchObject({ id: root.id });
|
||||
expect(response.status).toBe(200);
|
||||
const updated = (await page()).items[0];
|
||||
expect(updated).toMatchObject({ version: 2, text: "Added image" });
|
||||
expect(updated.images).toHaveLength(2);
|
||||
expect(updated.images[0]).toEqual(originalImage);
|
||||
const replacement = request("", { version: 2, images: [png] });
|
||||
expect((await itemRoute.PATCH(new Request(replacement, { method: "PATCH" }), context)).status).toBe(200);
|
||||
expect((await page()).items[0].images).toHaveLength(1);
|
||||
const history = await commentHistory(root.id, author);
|
||||
expect(history.items.map((item) => item.images.length)).toEqual([1, 2, 1]);
|
||||
});
|
||||
it("retries temporary image-storage failures without duplicating the edit", async () => {
|
||||
const root = await post("Original");
|
||||
storage.write.mockRejectedValueOnce(Object.assign(new Error("Temporary storage failure"), { code: "ECONNRESET" }));
|
||||
await publishComment(request("Edited", { version: 1, images: [png] }), author, { id: root.id });
|
||||
expect(storage.write).toHaveBeenCalledTimes(2);
|
||||
expect(storage.write.mock.calls[0][0]).toBe(storage.write.mock.calls[1][0]);
|
||||
expect((await page()).items[0]).toMatchObject({ text: "Edited", version: 2 });
|
||||
expect((await commentHistory(root.id, author)).items).toHaveLength(2);
|
||||
expect(storage.delete).not.toHaveBeenCalled();
|
||||
});
|
||||
it("keeps the original revision and cleans up images when storage retries fail", async () => {
|
||||
const root = await post("Original");
|
||||
storage.write.mockRejectedValue(Object.assign(new Error("Storage unavailable"), { statusCode: 503 }));
|
||||
session.get.mockResolvedValue({ user: { id: author.id } });
|
||||
const response = await itemRoute.PATCH(request("Failed edit", { version: 1, images: [png] }), { params: Promise.resolve({ id: root.id }) });
|
||||
expect(response.status).toBe(503);
|
||||
expect(await response.json()).toEqual({ error: "comment-image-upload-failed" });
|
||||
expect(storage.write).toHaveBeenCalledTimes(3);
|
||||
expect(storage.delete).toHaveBeenCalledTimes(1);
|
||||
expect((await page()).items[0]).toMatchObject({ text: "Original", version: 1 });
|
||||
expect(after).not.toHaveBeenCalled();
|
||||
});
|
||||
it("blocks hidden-root replies, history, and attachments and restores access", async () => {
|
||||
const root = await post("Root", { images: [png] }); const reply = await post("Reply", { reply: root.id, images: [png] });
|
||||
const image = (await page()).items[0].images[0].id;
|
||||
@@ -255,6 +331,10 @@ describeDatabase("guide comments against PostgreSQL", { timeout: 30000 }, () =>
|
||||
const replies = await listComments({ viewer: author, rootId: roots[0].id });
|
||||
expect(replies.items).toHaveLength(20);
|
||||
expect((await listComments({ viewer: author, rootId: roots[0].id, cursor: replies.nextCursor })).items).toHaveLength(2);
|
||||
const grouped = await listComments({ viewer: admin, inbox: true, grouped: true, target });
|
||||
const groupedNext = await listComments({ viewer: admin, inbox: true, grouped: true, target, cursor: grouped.nextCursor });
|
||||
expect(grouped.items).toHaveLength(20); expect(groupedNext.items).toHaveLength(3);
|
||||
expect(new Set([...grouped.items, ...groupedNext.items].map((item) => item.id))).toEqual(new Set(roots.map((item) => item.id)));
|
||||
}, 30000);
|
||||
it("filters unanswered roots and treats an admin reply as an answer", async () => {
|
||||
const root = await post();
|
||||
@@ -267,7 +347,7 @@ describeDatabase("guide comments against PostgreSQL", { timeout: 30000 }, () =>
|
||||
await expect(post("Fake", { images: [new Uint8Array([0, 1, 2])] })).rejects.toMatchObject({ status: 422 });
|
||||
expect(storage.write).not.toHaveBeenCalled();
|
||||
storage.write.mockRejectedValueOnce(new Error("storage unavailable"));
|
||||
await expect(post("Failed", { images: [png] })).rejects.toThrow("storage unavailable");
|
||||
await expect(post("Failed", { images: [png] })).rejects.toMatchObject({ status: 503, message: "comment-image-upload-failed" });
|
||||
expect(storage.delete).toHaveBeenCalledWith(storage.write.mock.calls[0][0]);
|
||||
expect((await page()).items).toHaveLength(0);
|
||||
});
|
||||
@@ -466,6 +546,52 @@ describeDatabase("guide comments against PostgreSQL", { timeout: 30000 }, () =>
|
||||
expect(notifyNotificationChange).toHaveBeenCalledWith(admin.id);
|
||||
expect((await listNotifications(adminUser)).items).toHaveLength(0);
|
||||
}, 60000);
|
||||
it("notifies admins for each saved edit without duplicating the same revision", async () => {
|
||||
const root = await post("Original");
|
||||
const adminUser = { ...admin, role: "admin", emailVerified: true };
|
||||
await sendCommentPush(root.id, 1);
|
||||
await readNotifications(admin.id);
|
||||
session.get.mockResolvedValue({ user: { id: author.id } });
|
||||
const response = await itemRoute.PATCH(request("Edited with image", { version: 1, images: [png] }), { params: Promise.resolve({ id: root.id }) });
|
||||
expect(response.status).toBe(200);
|
||||
expect(await response.json()).toMatchObject({ id: root.id, version: 2 });
|
||||
expect(after).toHaveBeenCalledTimes(1);
|
||||
const task = vi.mocked(after).mock.calls[0][0];
|
||||
if (typeof task !== "function") throw new Error("Expected scheduled notification callback");
|
||||
await task();
|
||||
await task();
|
||||
const feed = await listNotifications(adminUser);
|
||||
expect(feed.unread).toBe(1);
|
||||
expect(feed.items[0]).toMatchObject({ kind: "comment_edit", eventKey: `comment:${root.id}:edit:2`, body: "Edited with image", readAt: null });
|
||||
await publishComment(request("Third revision", { version: 2 }), author, { id: root.id });
|
||||
await sendCommentPush(root.id, 3);
|
||||
expect((await listNotifications(adminUser)).unread).toBe(2);
|
||||
// A delayed notification must use the revision that triggered it.
|
||||
await sendCommentPush(root.id, 2);
|
||||
expect((await listNotifications(adminUser)).unread).toBe(2);
|
||||
});
|
||||
it("notifies the direct recipient of edited replies and suppresses hidden edits", async () => {
|
||||
await saveSubscription(author.id);
|
||||
const root = await post("Root");
|
||||
const reply = await publishComment(request("Reply", { reply: root.id }), other, { target });
|
||||
await sendCommentPush(reply.id, 1);
|
||||
await readNotifications(author.id);
|
||||
pushTransport.sendNotification.mockClear();
|
||||
await publishComment(request("Edited reply", { version: 1, images: [png] }), other, { id: reply.id });
|
||||
await sendCommentPush(reply.id, 2);
|
||||
expect(pushTransport.sendNotification).toHaveBeenCalledTimes(1);
|
||||
expect(JSON.parse(pushTransport.sendNotification.mock.calls[0][1])).toMatchObject({ body: "Edited reply", title: "Reader แก้ไขคำตอบกลับความคิดเห็นของคุณ" });
|
||||
const feed = await listNotifications({ ...author, role: "user", emailVerified: true });
|
||||
expect(feed.unread).toBe(1);
|
||||
expect(feed.items[0]).toMatchObject({ kind: "comment_edit", body: "Edited reply" });
|
||||
expect(feed.items[0].url).toContain(`reply=${reply.id}#comment-${root.id}`);
|
||||
await publishComment(request("Hidden revision", { version: 2 }), other, { id: reply.id });
|
||||
await mutateComment(root.id, admin, "moderation", true);
|
||||
pushTransport.sendNotification.mockClear();
|
||||
await sendCommentPush(reply.id, 3);
|
||||
expect(pushTransport.sendNotification).not.toHaveBeenCalled();
|
||||
expect((await getDb().select().from(schema.notifications).where(eq(schema.notifications.eventKey, `comment:${reply.id}:edit:3`)))).toHaveLength(0);
|
||||
});
|
||||
it("pages notification history without dropping notifications sharing a timestamp", async () => {
|
||||
const user = { ...author, role: "user", emailVerified: true };
|
||||
for (let index = 0; index < 25; index++) await createNotifications([{ userId: author.id, url: "/" }], { eventKey: `fixture:${index}`, kind: "future", title: "Future notification", body: "Text" });
|
||||
|
||||
Reference in New Issue
Block a user