feat(stories): add paw reactions and comments (Gitea #29)
Story reactions/comments mirror the existing Post/Ad engagement models (StoryReaction/StoryComment tables, denormalized counters on Story) with a comment overlay rendered on top of the still-playing story rather than pausing it. No notification wiring yet, matching the Advertisement engagement model's precedent.
This commit is contained in:
+2
-1
@@ -533,7 +533,8 @@
|
||||
"noViewersYet": "Noch niemand hat diese Story angesehen.",
|
||||
"mute": "Stummschalten",
|
||||
"unmute": "Ton einschalten",
|
||||
"videoError": "Video konnte nicht verarbeitet werden."
|
||||
"videoError": "Video konnte nicht verarbeitet werden.",
|
||||
"openComments": "Kommentare öffnen"
|
||||
},
|
||||
"StoryTray": {
|
||||
"storiesAriaLabel": "Storys",
|
||||
|
||||
+2
-1
@@ -533,7 +533,8 @@
|
||||
"noViewersYet": "No one has viewed this story yet.",
|
||||
"mute": "Mute",
|
||||
"unmute": "Unmute",
|
||||
"videoError": "Video could not be processed."
|
||||
"videoError": "Video could not be processed.",
|
||||
"openComments": "Open comments"
|
||||
},
|
||||
"StoryTray": {
|
||||
"storiesAriaLabel": "Stories",
|
||||
|
||||
@@ -163,6 +163,8 @@ model Pet {
|
||||
posts Post[]
|
||||
stories Story[]
|
||||
storyViews StoryView[] @relation("StoryViewer")
|
||||
storyReactions StoryReaction[] @relation("StoryReactions")
|
||||
storyComments StoryComment[] @relation("StoryComments")
|
||||
following Follow[] @relation("Follower")
|
||||
followers Follow[] @relation("Followee")
|
||||
blocking Block[] @relation("Blocker")
|
||||
@@ -372,6 +374,13 @@ model Story {
|
||||
// Gitea #18 — legal AI-content disclosure, set once at creation.
|
||||
aiDisclosure AiDisclosure @default(NONE)
|
||||
|
||||
// Gitea #29 — Denormalized engagement counters, same rationale as Post
|
||||
// (see Post model comment). Maintained transactionally in stories.ts.
|
||||
reactionCount Int @default(0)
|
||||
commentCount Int @default(0)
|
||||
reactions StoryReaction[]
|
||||
comments StoryComment[]
|
||||
|
||||
@@index([petId, expiresAt]) // supports lazy-delete filter query
|
||||
@@index([muxUploadId])
|
||||
@@index([muxAssetId])
|
||||
@@ -393,6 +402,35 @@ model StoryView {
|
||||
@@index([viewedAt]) // supports 30-day cleanup job (D-08)
|
||||
}
|
||||
|
||||
// Gitea #29 — story reactions/comments, mirrors Reaction/Comment (Post) and
|
||||
// AdReaction/AdComment (Advertisement) rather than a shared postId/storyId
|
||||
// polymorphic table, matching this codebase's existing per-target-type
|
||||
// pattern (see AdReaction/AdComment above).
|
||||
model StoryReaction {
|
||||
id String @id @default(cuid())
|
||||
story Story @relation(fields: [storyId], references: [id], onDelete: Cascade)
|
||||
storyId String
|
||||
pet Pet @relation("StoryReactions", fields: [petId], references: [id], onDelete: Cascade)
|
||||
petId String
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@unique([storyId, petId])
|
||||
@@index([storyId])
|
||||
}
|
||||
|
||||
model StoryComment {
|
||||
id String @id @default(cuid())
|
||||
story Story @relation(fields: [storyId], references: [id], onDelete: Cascade)
|
||||
storyId String
|
||||
pet Pet @relation("StoryComments", fields: [petId], references: [id], onDelete: Cascade)
|
||||
petId String
|
||||
body String @db.VarChar(500)
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@index([storyId, createdAt(sort: Asc)])
|
||||
@@index([petId])
|
||||
}
|
||||
|
||||
model Follow {
|
||||
follower Pet @relation("Follower", fields: [followerPetId], references: [id], onDelete: Cascade)
|
||||
followerPetId String
|
||||
|
||||
@@ -178,6 +178,20 @@ interface MockRepostClient {
|
||||
delete: MockFn;
|
||||
}
|
||||
|
||||
// Gitea #29 — story reactions/comments
|
||||
interface MockStoryReactionClient {
|
||||
findUnique: MockFn;
|
||||
create: MockFn;
|
||||
delete: MockFn;
|
||||
}
|
||||
|
||||
interface MockStoryCommentClient {
|
||||
create: MockFn;
|
||||
findMany: MockFn;
|
||||
findUnique: MockFn;
|
||||
delete: MockFn;
|
||||
}
|
||||
|
||||
interface MockHashtagClient {
|
||||
findUnique: MockFn;
|
||||
findMany: MockFn;
|
||||
@@ -300,6 +314,8 @@ interface MockPrismaClient {
|
||||
reaction: MockReactionClient;
|
||||
comment: MockCommentClient;
|
||||
repost: MockRepostClient;
|
||||
storyReaction: MockStoryReactionClient;
|
||||
storyComment: MockStoryCommentClient;
|
||||
hashtag: MockHashtagClient;
|
||||
postHashtag: MockPostHashtagClient;
|
||||
sponsorContribution: MockSponsorContributionClient;
|
||||
@@ -470,6 +486,17 @@ export function createMockPrisma(): MockPrismaClient {
|
||||
create: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
},
|
||||
storyReaction: {
|
||||
findUnique: vi.fn(),
|
||||
create: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
},
|
||||
storyComment: {
|
||||
create: vi.fn(),
|
||||
findMany: vi.fn(),
|
||||
findUnique: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
},
|
||||
hashtag: {
|
||||
findUnique: vi.fn(),
|
||||
findMany: vi.fn(),
|
||||
|
||||
@@ -166,3 +166,152 @@ describe("stories.recordView", () => {
|
||||
).rejects.toMatchObject({ code: "NOT_FOUND" });
|
||||
});
|
||||
});
|
||||
|
||||
// Gitea #29 — story reactions + comments
|
||||
describe("stories.toggleReaction", () => {
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
it("creates a reaction and increments reactionCount when not yet reacted", async () => {
|
||||
const { caller, mockPrisma } = createCaller();
|
||||
|
||||
mockPrisma.pet.findUnique.mockResolvedValue({ id: "pet-2", ownerId: "owner-1" });
|
||||
mockPrisma.storyReaction.findUnique.mockResolvedValue(null);
|
||||
mockPrisma.story.findUnique.mockResolvedValue({
|
||||
petId: "pet-1",
|
||||
expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000),
|
||||
});
|
||||
mockPrisma.$transaction.mockResolvedValue([{}, {}]);
|
||||
|
||||
const result = await caller.toggleReaction({ storyId: "story-1", petId: "pet-2" });
|
||||
|
||||
expect(result).toEqual({ reacted: true });
|
||||
expect(mockPrisma.$transaction).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("deletes the reaction and decrements reactionCount when already reacted", async () => {
|
||||
const { caller, mockPrisma } = createCaller();
|
||||
|
||||
mockPrisma.pet.findUnique.mockResolvedValue({ id: "pet-2", ownerId: "owner-1" });
|
||||
mockPrisma.storyReaction.findUnique.mockResolvedValue({ id: "reaction-1" });
|
||||
mockPrisma.$transaction.mockResolvedValue([{}, {}]);
|
||||
|
||||
const result = await caller.toggleReaction({ storyId: "story-1", petId: "pet-2" });
|
||||
|
||||
expect(result).toEqual({ reacted: false });
|
||||
});
|
||||
|
||||
it("throws NOT_FOUND when reacting to an expired story", async () => {
|
||||
const { caller, mockPrisma } = createCaller();
|
||||
|
||||
mockPrisma.pet.findUnique.mockResolvedValue({ id: "pet-2", ownerId: "owner-1" });
|
||||
mockPrisma.storyReaction.findUnique.mockResolvedValue(null);
|
||||
mockPrisma.story.findUnique.mockResolvedValue({
|
||||
petId: "pet-1",
|
||||
expiresAt: new Date(Date.now() - 1000),
|
||||
});
|
||||
|
||||
await expect(
|
||||
caller.toggleReaction({ storyId: "story-1", petId: "pet-2" })
|
||||
).rejects.toMatchObject({ code: "NOT_FOUND" });
|
||||
});
|
||||
|
||||
it("throws FORBIDDEN when the reactor is blocked by the story owner", async () => {
|
||||
const { caller, mockPrisma } = createCaller();
|
||||
|
||||
mockPrisma.pet.findUnique.mockResolvedValue({ id: "pet-2", ownerId: "owner-1" });
|
||||
mockPrisma.storyReaction.findUnique.mockResolvedValue(null);
|
||||
mockPrisma.story.findUnique.mockResolvedValue({
|
||||
petId: "pet-1",
|
||||
expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000),
|
||||
});
|
||||
mockPrisma.block.findFirst.mockResolvedValue({ id: "block-1" });
|
||||
|
||||
await expect(
|
||||
caller.toggleReaction({ storyId: "story-1", petId: "pet-2" })
|
||||
).rejects.toMatchObject({ code: "FORBIDDEN" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("stories.addComment / listComments / deleteComment", () => {
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
it("creates a comment and increments commentCount", async () => {
|
||||
const { caller, mockPrisma } = createCaller();
|
||||
|
||||
mockPrisma.pet.findUnique.mockResolvedValue({ id: "pet-2", ownerId: "owner-1" });
|
||||
mockPrisma.story.findUnique.mockResolvedValue({
|
||||
petId: "pet-1",
|
||||
expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000),
|
||||
});
|
||||
const mockComment = { id: "comment-1", storyId: "story-1", petId: "pet-2", body: "cute!" };
|
||||
mockPrisma.$transaction.mockResolvedValue([mockComment, {}]);
|
||||
|
||||
const result = await caller.addComment({
|
||||
storyId: "story-1",
|
||||
petId: "pet-2",
|
||||
body: "cute!",
|
||||
});
|
||||
|
||||
expect(result).toEqual(mockComment);
|
||||
});
|
||||
|
||||
it("throws NOT_FOUND when commenting on an expired story", async () => {
|
||||
const { caller, mockPrisma } = createCaller();
|
||||
|
||||
mockPrisma.pet.findUnique.mockResolvedValue({ id: "pet-2", ownerId: "owner-1" });
|
||||
mockPrisma.story.findUnique.mockResolvedValue({
|
||||
petId: "pet-1",
|
||||
expiresAt: new Date(Date.now() - 1000),
|
||||
});
|
||||
|
||||
await expect(
|
||||
caller.addComment({ storyId: "story-1", petId: "pet-2", body: "cute!" })
|
||||
).rejects.toMatchObject({ code: "NOT_FOUND" });
|
||||
});
|
||||
|
||||
it("lists comments ordered ascending by createdAt", async () => {
|
||||
const { caller, mockPrisma } = createCaller();
|
||||
|
||||
mockPrisma.storyComment.findMany.mockResolvedValue([]);
|
||||
|
||||
await caller.listComments({ storyId: "story-1" });
|
||||
|
||||
expect(mockPrisma.storyComment.findMany).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: { storyId: "story-1" },
|
||||
orderBy: { createdAt: "asc" },
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("deletes own comment and decrements commentCount", async () => {
|
||||
const { caller, mockPrisma } = createCaller();
|
||||
|
||||
mockPrisma.pet.findUnique.mockResolvedValue({ id: "pet-2", ownerId: "owner-1" });
|
||||
mockPrisma.storyComment.findUnique.mockResolvedValue({
|
||||
id: "comment-1",
|
||||
storyId: "story-1",
|
||||
petId: "pet-2",
|
||||
});
|
||||
mockPrisma.$transaction.mockResolvedValue([{}, {}]);
|
||||
|
||||
await caller.deleteComment({ commentId: "comment-1", petId: "pet-2" });
|
||||
|
||||
expect(mockPrisma.$transaction).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("throws FORBIDDEN when deleting another pet's comment", async () => {
|
||||
const { caller, mockPrisma } = createCaller();
|
||||
|
||||
mockPrisma.pet.findUnique.mockResolvedValue({ id: "pet-2", ownerId: "owner-1" });
|
||||
mockPrisma.storyComment.findUnique.mockResolvedValue({
|
||||
id: "comment-1",
|
||||
storyId: "story-1",
|
||||
petId: "pet-3",
|
||||
});
|
||||
|
||||
await expect(
|
||||
caller.deleteComment({ commentId: "comment-1", petId: "pet-2" })
|
||||
).rejects.toMatchObject({ code: "FORBIDDEN" });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useRef, useState } from "react";
|
||||
import { Loader2, Send, Trash2, X } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useTRPC } from "@/trpc/client";
|
||||
import { PetAvatar } from "@/components/ui/pet-avatar";
|
||||
|
||||
interface StoryCommentsOverlayProps {
|
||||
storyId: string;
|
||||
activePetId: string;
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gitea #29 — comment overlay for stories. Deliberately does NOT pause the
|
||||
* story's own progress/video (the issue asked for an overlay "on top of the
|
||||
* running video", not a paused one) — bg-black/40 keeps the story visible
|
||||
* and legible behind the panel.
|
||||
*/
|
||||
export function StoryCommentsOverlay({
|
||||
storyId,
|
||||
activePetId,
|
||||
open,
|
||||
onClose,
|
||||
}: StoryCommentsOverlayProps) {
|
||||
const t = useTranslations("Comments");
|
||||
const trpc = useTRPC();
|
||||
const queryClient = useQueryClient();
|
||||
const [body, setBody] = useState("");
|
||||
const listEndRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const { data: comments = [], isLoading } = useQuery({
|
||||
...trpc.stories.listComments.queryOptions({ storyId }),
|
||||
enabled: open,
|
||||
});
|
||||
|
||||
const invalidateComments = useCallback(() => {
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: trpc.stories.listComments.queryOptions({ storyId }).queryKey,
|
||||
});
|
||||
}, [queryClient, trpc, storyId]);
|
||||
|
||||
const createMutation = useMutation(
|
||||
trpc.stories.addComment.mutationOptions({
|
||||
onSuccess: () => {
|
||||
setBody("");
|
||||
invalidateComments();
|
||||
setTimeout(() => listEndRef.current?.scrollIntoView({ behavior: "smooth" }), 50);
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
const deleteMutation = useMutation(
|
||||
trpc.stories.deleteComment.mutationOptions({
|
||||
onSuccess: invalidateComments,
|
||||
})
|
||||
);
|
||||
|
||||
const handleSubmit = useCallback(() => {
|
||||
const trimmed = body.trim();
|
||||
if (!trimmed) return;
|
||||
createMutation.mutate({ storyId, petId: activePetId, body: trimmed });
|
||||
}, [body, storyId, activePetId, createMutation]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="absolute inset-x-0 bottom-0 z-20 flex max-h-[60%] flex-col rounded-t-2xl bg-black/40 backdrop-blur-sm"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="flex items-center justify-between border-b border-white/10 px-4 py-2 shrink-0">
|
||||
<span className="text-sm font-semibold text-white">{t("title")}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
aria-label="Close comments"
|
||||
className="text-white/70 hover:text-white"
|
||||
>
|
||||
<X className="h-4 w-4" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="min-h-[80px] flex-1 space-y-2 overflow-y-auto px-4 py-2">
|
||||
{isLoading && (
|
||||
<div className="flex justify-center py-3">
|
||||
<Loader2 className="h-4 w-4 animate-spin text-white/70" aria-hidden="true" />
|
||||
</div>
|
||||
)}
|
||||
{!isLoading && comments.length === 0 && (
|
||||
<p className="py-3 text-center text-xs text-white/60">{t("empty")}</p>
|
||||
)}
|
||||
{comments.map((c) => (
|
||||
<div key={c.id} className="group flex items-start gap-2">
|
||||
<PetAvatar name={c.pet.name} avatarKey={c.pet.avatarKey} className="h-6 w-6 shrink-0" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<span className="mr-1 text-xs font-semibold text-white">{c.pet.name}</span>
|
||||
<span className="break-words text-xs text-white/90">{c.body}</span>
|
||||
</div>
|
||||
{c.pet.id === activePetId && (
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Delete comment"
|
||||
onClick={() => deleteMutation.mutate({ commentId: c.id, petId: activePetId })}
|
||||
className="shrink-0 text-white/60 opacity-0 transition-opacity hover:text-red-400 group-hover:opacity-100"
|
||||
>
|
||||
<Trash2 className="h-3 w-3" aria-hidden="true" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
<div ref={listEndRef} />
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 border-t border-white/10 px-3 py-2 shrink-0">
|
||||
<input
|
||||
value={body}
|
||||
onChange={(e) => setBody(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
handleSubmit();
|
||||
}
|
||||
}}
|
||||
maxLength={500}
|
||||
placeholder={t("placeholder")}
|
||||
className="flex-1 rounded-full bg-white/10 px-3 py-2 text-sm text-white outline-none placeholder:text-white/50"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSubmit}
|
||||
disabled={!body.trim() || createMutation.isPending}
|
||||
aria-label={t("post")}
|
||||
className="shrink-0 text-orange-400 disabled:opacity-40"
|
||||
>
|
||||
{createMutation.isPending ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" aria-hidden="true" />
|
||||
) : (
|
||||
<Send className="h-4 w-4" aria-hidden="true" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useCallback } from "react";
|
||||
import { PawPrint } from "lucide-react";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { useTRPC } from "@/trpc/client";
|
||||
|
||||
interface StoryPawButtonProps {
|
||||
storyId: string;
|
||||
petId: string;
|
||||
initialCount: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gitea #29 — paw-like button for stories, mirrors PawButton (feed posts).
|
||||
* initialReacted is always false (same limitation PawButton documents:
|
||||
* listActive doesn't return per-viewer reaction state) — the icon starts
|
||||
* unfilled and flips correctly on the first toggle this session.
|
||||
*/
|
||||
export function StoryPawButton({ storyId, petId, initialCount }: StoryPawButtonProps) {
|
||||
const trpc = useTRPC();
|
||||
const [reacted, setReacted] = useState(false);
|
||||
const [count, setCount] = useState(initialCount);
|
||||
const [animationKey, setAnimationKey] = useState(0);
|
||||
const [showBurst, setShowBurst] = useState(false);
|
||||
|
||||
const toggleMutation = useMutation(
|
||||
trpc.stories.toggleReaction.mutationOptions({
|
||||
onMutate: () => {
|
||||
const prevReacted = reacted;
|
||||
const prevCount = count;
|
||||
const nextReacted = !reacted;
|
||||
setReacted(nextReacted);
|
||||
if (nextReacted) {
|
||||
setAnimationKey((key) => key + 1);
|
||||
setShowBurst(true);
|
||||
}
|
||||
return { prevReacted, prevCount };
|
||||
},
|
||||
onSuccess: (data, _vars, context) => {
|
||||
setReacted(data.reacted);
|
||||
if (context) {
|
||||
setCount(data.reacted ? context.prevCount + 1 : Math.max(0, context.prevCount - 1));
|
||||
}
|
||||
},
|
||||
onError: (_err, _vars, context) => {
|
||||
if (context) {
|
||||
setReacted(context.prevReacted);
|
||||
setCount(context.prevCount);
|
||||
}
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
const handleClick = useCallback(() => {
|
||||
if (toggleMutation.isPending) return;
|
||||
toggleMutation.mutate({ storyId, petId });
|
||||
}, [storyId, petId, toggleMutation]);
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClick}
|
||||
disabled={toggleMutation.isPending}
|
||||
aria-label={reacted ? "Remove paw" : "Paw this story"}
|
||||
className="flex flex-col items-center gap-0.5 disabled:opacity-60"
|
||||
>
|
||||
<span className="relative inline-flex h-9 w-9 items-center justify-center rounded-full bg-black/40 hover:bg-black/60">
|
||||
<PawPrint
|
||||
key={animationKey}
|
||||
className={`h-4 w-4 transition-colors ${
|
||||
reacted ? "fill-orange-500 text-orange-500 animate-paw-spring" : "text-white"
|
||||
}`}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{showBurst && (
|
||||
<PawPrint
|
||||
className="absolute inset-0 m-auto h-4 w-4 fill-orange-500 text-orange-500 animate-paw-burst"
|
||||
aria-hidden="true"
|
||||
onAnimationEnd={() => setShowBurst(false)}
|
||||
/>
|
||||
)}
|
||||
</span>
|
||||
{count > 0 && (
|
||||
<span className="text-xs text-white drop-shadow">{count}</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -3,7 +3,7 @@
|
||||
import { useState, useEffect, useCallback, useRef } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import dynamic from "next/dynamic";
|
||||
import { X, Eye, Volume2, VolumeX, Loader2 } from "lucide-react";
|
||||
import { X, Eye, Volume2, VolumeX, Loader2, MessageCircle } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { useTRPC } from "@/trpc/client";
|
||||
@@ -19,6 +19,8 @@ import { getAvatarUrl } from "@/lib/avatar-url";
|
||||
import { getMediaUrl } from "@/lib/media-url";
|
||||
import { BlurImage } from "@/components/ui/blur-image";
|
||||
import { AiDisclosureBadge } from "@/components/content/AiDisclosureBadge";
|
||||
import { StoryPawButton } from "@/components/stories/StoryPawButton";
|
||||
import { StoryCommentsOverlay } from "@/components/stories/StoryCommentsOverlay";
|
||||
|
||||
// Same lazy-load rationale as VideoCard.tsx — keep the HLS.js player runtime
|
||||
// out of the main bundle, only load it when a video story actually renders.
|
||||
@@ -63,6 +65,7 @@ export function StoryViewer({
|
||||
const [currentIndex, setCurrentIndex] = useState(0);
|
||||
const [progress, setProgress] = useState(0);
|
||||
const [viewerSheetOpen, setViewerSheetOpen] = useState(false);
|
||||
const [commentsOpen, setCommentsOpen] = useState(false);
|
||||
const [isMuted, setIsMuted] = useState(true);
|
||||
const advancedRef = useRef(false);
|
||||
|
||||
@@ -115,6 +118,7 @@ export function StoryViewer({
|
||||
const advance = useCallback(() => {
|
||||
if (advancedRef.current) return;
|
||||
advancedRef.current = true;
|
||||
setCommentsOpen(false);
|
||||
if (currentIndex < stories.length - 1) {
|
||||
setCurrentIndex((i) => i + 1);
|
||||
} else {
|
||||
@@ -176,6 +180,7 @@ export function StoryViewer({
|
||||
setCurrentIndex(0);
|
||||
setProgress(0);
|
||||
setIsMuted(true);
|
||||
setCommentsOpen(false);
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
@@ -350,6 +355,41 @@ export function StoryViewer({
|
||||
<span>{viewers.length}</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Gitea #29 — paw + comment buttons, bottom right */}
|
||||
{activePetId && (
|
||||
<div className="absolute bottom-6 right-4 z-10 flex flex-col items-center gap-4">
|
||||
<StoryPawButton
|
||||
storyId={currentStory.id}
|
||||
petId={activePetId}
|
||||
initialCount={currentStory.reactionCount}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCommentsOpen((o) => !o)}
|
||||
aria-label={t("openComments")}
|
||||
className="flex flex-col items-center gap-0.5"
|
||||
>
|
||||
<span className="flex h-9 w-9 items-center justify-center rounded-full bg-black/40 text-white hover:bg-black/60">
|
||||
<MessageCircle className="h-4 w-4" aria-hidden="true" />
|
||||
</span>
|
||||
{currentStory.commentCount > 0 && (
|
||||
<span className="text-xs text-white drop-shadow">
|
||||
{currentStory.commentCount}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activePetId && (
|
||||
<StoryCommentsOverlay
|
||||
storyId={currentStory.id}
|
||||
activePetId={activePetId}
|
||||
open={commentsOpen}
|
||||
onClose={() => setCommentsOpen(false)}
|
||||
/>
|
||||
)}
|
||||
</div>,
|
||||
document.body
|
||||
)}
|
||||
|
||||
+137
-1
@@ -1,7 +1,7 @@
|
||||
import "server-only";
|
||||
import { z } from "zod";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { router, protectedProcedure } from "@/trpc/init";
|
||||
import { router, protectedProcedure, rateLimited } from "@/trpc/init";
|
||||
import {
|
||||
createSignedUploadUrl,
|
||||
ALLOWED_CONTENT_TYPES,
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
import { randomUUID } from "crypto";
|
||||
import { petIdentitySelect } from "@/repositories/pet-repository";
|
||||
import { assertPetOwnership } from "@/lib/assert-pet-ownership";
|
||||
import { assertNotBlocked } from "@/lib/assert-not-blocked";
|
||||
import { mux } from "@/lib/mux";
|
||||
import { MAX_STORY_VIDEO_DURATION_SECS } from "@/lib/story-video-limits";
|
||||
|
||||
@@ -276,4 +277,139 @@ export const storiesRouter = router({
|
||||
take: 200,
|
||||
});
|
||||
}),
|
||||
|
||||
/**
|
||||
* Gitea #29 — paw-like toggle for stories, mirrors reactions.toggle.
|
||||
*/
|
||||
toggleReaction: protectedProcedure
|
||||
.use(rateLimited("stories.toggleReaction", 60, 60))
|
||||
.input(z.object({ storyId: z.string().min(1), petId: z.string().min(1) }))
|
||||
.mutation(async ({ ctx, input }): Promise<{ reacted: boolean }> => {
|
||||
await assertPetOwnership(ctx.prisma, input.petId, ctx.userId);
|
||||
|
||||
const existing = await ctx.prisma.storyReaction.findUnique({
|
||||
where: { storyId_petId: { storyId: input.storyId, petId: input.petId } },
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
await ctx.prisma.$transaction([
|
||||
ctx.prisma.storyReaction.delete({
|
||||
where: { storyId_petId: { storyId: input.storyId, petId: input.petId } },
|
||||
}),
|
||||
ctx.prisma.story.update({
|
||||
where: { id: input.storyId },
|
||||
data: { reactionCount: { decrement: 1 } },
|
||||
}),
|
||||
]);
|
||||
return { reacted: false };
|
||||
}
|
||||
|
||||
const targetStory = await ctx.prisma.story.findUnique({
|
||||
where: { id: input.storyId },
|
||||
select: { petId: true, expiresAt: true },
|
||||
});
|
||||
if (!targetStory || targetStory.expiresAt <= new Date()) {
|
||||
throw new TRPCError({ code: "NOT_FOUND" });
|
||||
}
|
||||
await assertNotBlocked(ctx.prisma, input.petId, targetStory.petId);
|
||||
|
||||
await ctx.prisma.$transaction([
|
||||
ctx.prisma.storyReaction.create({
|
||||
data: { storyId: input.storyId, petId: input.petId },
|
||||
}),
|
||||
ctx.prisma.story.update({
|
||||
where: { id: input.storyId },
|
||||
data: { reactionCount: { increment: 1 } },
|
||||
}),
|
||||
]);
|
||||
|
||||
return { reacted: true };
|
||||
}),
|
||||
|
||||
/**
|
||||
* Gitea #29 — story comments, mirrors comments.listByPost.
|
||||
*/
|
||||
listComments: protectedProcedure
|
||||
.input(z.object({ storyId: z.string().min(1) }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
return ctx.prisma.storyComment.findMany({
|
||||
where: { storyId: input.storyId },
|
||||
include: {
|
||||
pet: { select: petIdentitySelect },
|
||||
},
|
||||
orderBy: { createdAt: "asc" },
|
||||
take: 100,
|
||||
});
|
||||
}),
|
||||
|
||||
/**
|
||||
* Gitea #29 — mirrors comments.create. No mention-notification fan-out
|
||||
* (unlike post comments) — stories expire in 24h and have no notify()
|
||||
* wiring at all yet, matching the Advertisement engagement model.
|
||||
*/
|
||||
addComment: protectedProcedure
|
||||
.use(rateLimited("stories.addComment", 20, 300))
|
||||
.input(
|
||||
z.object({
|
||||
storyId: z.string().min(1),
|
||||
petId: z.string().min(1),
|
||||
body: z.string().min(1).max(500),
|
||||
})
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
await assertPetOwnership(ctx.prisma, input.petId, ctx.userId);
|
||||
|
||||
const targetStory = await ctx.prisma.story.findUnique({
|
||||
where: { id: input.storyId },
|
||||
select: { petId: true, expiresAt: true },
|
||||
});
|
||||
if (!targetStory || targetStory.expiresAt <= new Date()) {
|
||||
throw new TRPCError({ code: "NOT_FOUND" });
|
||||
}
|
||||
await assertNotBlocked(ctx.prisma, input.petId, targetStory.petId);
|
||||
|
||||
const [comment] = await ctx.prisma.$transaction([
|
||||
ctx.prisma.storyComment.create({
|
||||
data: {
|
||||
storyId: input.storyId,
|
||||
petId: input.petId,
|
||||
body: input.body,
|
||||
},
|
||||
include: {
|
||||
pet: { select: petIdentitySelect },
|
||||
},
|
||||
}),
|
||||
ctx.prisma.story.update({
|
||||
where: { id: input.storyId },
|
||||
data: { commentCount: { increment: 1 } },
|
||||
}),
|
||||
]);
|
||||
|
||||
return comment;
|
||||
}),
|
||||
|
||||
deleteComment: protectedProcedure
|
||||
.input(z.object({ commentId: z.string().min(1), petId: z.string().min(1) }))
|
||||
.mutation(async ({ ctx, input }): Promise<void> => {
|
||||
await assertPetOwnership(ctx.prisma, input.petId, ctx.userId);
|
||||
|
||||
const comment = await ctx.prisma.storyComment.findUnique({
|
||||
where: { id: input.commentId },
|
||||
});
|
||||
if (!comment) throw new TRPCError({ code: "NOT_FOUND" });
|
||||
if (comment.petId !== input.petId) {
|
||||
throw new TRPCError({
|
||||
code: "FORBIDDEN",
|
||||
message: "You can only delete your own comments.",
|
||||
});
|
||||
}
|
||||
|
||||
await ctx.prisma.$transaction([
|
||||
ctx.prisma.storyComment.delete({ where: { id: input.commentId } }),
|
||||
ctx.prisma.story.update({
|
||||
where: { id: comment.storyId },
|
||||
data: { commentCount: { decrement: 1 } },
|
||||
}),
|
||||
]);
|
||||
}),
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user