diff --git a/messages/de.json b/messages/de.json index 98c0734..25e71de 100644 --- a/messages/de.json +++ b/messages/de.json @@ -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", diff --git a/messages/en.json b/messages/en.json index ca01b81..05e097e 100644 --- a/messages/en.json +++ b/messages/en.json @@ -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", diff --git a/prisma/schema.prisma b/prisma/schema.prisma index e5e5b9b..282e1f5 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -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 diff --git a/src/__tests__/helpers/prisma-mock.ts b/src/__tests__/helpers/prisma-mock.ts index c7beccd..7c69281 100644 --- a/src/__tests__/helpers/prisma-mock.ts +++ b/src/__tests__/helpers/prisma-mock.ts @@ -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(), diff --git a/src/__tests__/stories.test.ts b/src/__tests__/stories.test.ts index 44efc38..41ac844 100644 --- a/src/__tests__/stories.test.ts +++ b/src/__tests__/stories.test.ts @@ -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" }); + }); +}); diff --git a/src/components/stories/StoryCommentsOverlay.tsx b/src/components/stories/StoryCommentsOverlay.tsx new file mode 100644 index 0000000..c2c2fe7 --- /dev/null +++ b/src/components/stories/StoryCommentsOverlay.tsx @@ -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(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 ( +
e.stopPropagation()} + > +
+ {t("title")} + +
+ +
+ {isLoading && ( +
+
+ )} + {!isLoading && comments.length === 0 && ( +

{t("empty")}

+ )} + {comments.map((c) => ( +
+ +
+ {c.pet.name} + {c.body} +
+ {c.pet.id === activePetId && ( + + )} +
+ ))} +
+
+ +
+ 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" + /> + +
+
+ ); +} diff --git a/src/components/stories/StoryPawButton.tsx b/src/components/stories/StoryPawButton.tsx new file mode 100644 index 0000000..23fe8bb --- /dev/null +++ b/src/components/stories/StoryPawButton.tsx @@ -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 ( + + ); +} diff --git a/src/components/stories/StoryViewer.tsx b/src/components/stories/StoryViewer.tsx index 6831746..7a995f1 100644 --- a/src/components/stories/StoryViewer.tsx +++ b/src/components/stories/StoryViewer.tsx @@ -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({ {viewers.length} )} + + {/* Gitea #29 — paw + comment buttons, bottom right */} + {activePetId && ( +
+ + +
+ )} + + {activePetId && ( + setCommentsOpen(false)} + /> + )}
, document.body )} diff --git a/src/trpc/routers/stories.ts b/src/trpc/routers/stories.ts index e9f59a9..2cee91f 100644 --- a/src/trpc/routers/stories.ts +++ b/src/trpc/routers/stories.ts @@ -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 => { + 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 } }, + }), + ]); + }), });