From ccebfa46a643d1d315d8c28f395577ec48b714ea Mon Sep 17 00:00:00 2001 From: neodk2004 Date: Thu, 1 Oct 2026 20:59:02 +0200 Subject: [PATCH] feat: Story-Kommentare, Pfoten und Zuschauerliste in der App MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - App-Viewer: Pfote, Kommentar-Sheet (schreiben, eigene löschen) und für die eigene Story die Zuschauerliste; Timer pausiert, solange ein Sheet offen ist - stories.listActive: optionales viewerPetId → reactedByViewer, damit die Pfote gefüllt startet (abwärtskompatibel für alte App-Builds) - Web-StoryPawButton nutzt reactedByViewer ebenfalls und wird pro Story neu initialisiert Co-Authored-By: Claude Opus 5.5 --- mobile/app/(app)/story-viewer.tsx | 132 +++++++++++-- mobile/found.md | 2 + .../components/stories/StoryCommentsSheet.tsx | 174 ++++++++++++++++++ .../src/components/stories/StoryPawButton.tsx | 72 ++++++++ .../components/stories/StoryViewersSheet.tsx | 88 +++++++++ mobile/src/i18n/locales/de.json | 11 ++ mobile/src/i18n/locales/en.json | 11 ++ src/__tests__/stories.test.ts | 32 ++++ src/components/stories/StoryPawButton.tsx | 11 +- src/components/stories/StoryViewer.tsx | 4 +- src/trpc/routers/stories.ts | 18 +- 11 files changed, 533 insertions(+), 22 deletions(-) create mode 100644 mobile/src/components/stories/StoryCommentsSheet.tsx create mode 100644 mobile/src/components/stories/StoryPawButton.tsx create mode 100644 mobile/src/components/stories/StoryViewersSheet.tsx diff --git a/mobile/app/(app)/story-viewer.tsx b/mobile/app/(app)/story-viewer.tsx index 85460f3..1bd2739 100644 --- a/mobile/app/(app)/story-viewer.tsx +++ b/mobile/app/(app)/story-viewer.tsx @@ -2,24 +2,30 @@ import { useState, useEffect, useCallback, useRef } from "react"; import { View, Text, Pressable, StyleSheet } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useLocalSearchParams, useRouter } from "expo-router"; +import { useTranslation } from "react-i18next"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { Image } from "expo-image"; -import { X } from "lucide-react-native"; +import { Eye, MessageCircle, X } from "lucide-react-native"; import { useTRPC } from "../../src/api/trpc"; import { useActivePet } from "../../src/context/ActivePetContext"; import { PetAvatar } from "../../src/components/PetAvatar"; +import { StoryPawButton } from "../../src/components/stories/StoryPawButton"; +import { StoryCommentsSheet } from "../../src/components/stories/StoryCommentsSheet"; +import { StoryViewersSheet } from "../../src/components/stories/StoryViewersSheet"; import { getMediaUrl } from "../../src/lib/media-url"; import { colors } from "../../src/theme/tokens"; const STORY_DURATION_MS = 5000; +type Sheet = "comments" | "viewers" | null; + /** * Port of web's src/components/stories/StoryViewer.tsx, scoped to photo - * stories only (no Mux video story playback — see story-new.tsx). Also - * drops the owner-only viewer-count/list Sheet and the paw-reaction/comment - * overlay (Gitea #29 additions on web) — fast-follows, not core to "view a - * story". Tap the left third to go back a story, the rest to skip ahead; - * a fixed timer still auto-advances either way. + * stories (no Mux video story playback — see story-new.tsx). Tap the left + * third to go back a story, the rest to skip ahead; a fixed timer + * auto-advances. Pfote, Kommentare (Bottom-Sheet) und — nur für die eigene + * Story — die Zuschauerliste wie im Web; solange ein Sheet offen ist, hält + * der Timer an. */ export default function StoryViewerScreen() { const { petId, petName, petAvatarKey } = useLocalSearchParams<{ @@ -27,26 +33,45 @@ export default function StoryViewerScreen() { petName: string; petAvatarKey?: string; }>(); + const { t } = useTranslation(); const router = useRouter(); const trpc = useTRPC(); const queryClient = useQueryClient(); const { activePetId } = useActivePet(); const insets = useSafeAreaInsets(); + const isOwnStory = activePetId === petId; - const { data: stories = [] } = useQuery(trpc.stories.listActive.queryOptions({ petId })); + const listQuery = trpc.stories.listActive.queryOptions({ petId, viewerPetId: activePetId ?? undefined }); + const { data: stories = [] } = useQuery(listQuery); const [index, setIndex] = useState(0); const [progress, setProgress] = useState(0); + const [openSheet, setOpenSheet] = useState(null); const advancedRef = useRef(false); + const elapsedRef = useRef(0); // bereits abgelaufene Zeit der aktuellen Story (für Pause/Weiter) const currentStory = stories[index]; + const isPaused = openSheet !== null; const recordViewMutation = useMutation(trpc.stories.recordView.mutationOptions()); + const { data: viewers = [] } = useQuery({ + ...trpc.stories.getViewers.queryOptions({ storyId: currentStory?.id ?? "" }), + enabled: isOwnStory && !!currentStory, + }); + const close = useCallback(() => { queryClient.invalidateQueries(trpc.stories.hasActiveStory.queryOptions({ petId })); queryClient.invalidateQueries(trpc.follows.pathFilter()); router.back(); }, [queryClient, trpc, petId, router]); + const openPet = useCallback( + (targetPetId: string) => { + setOpenSheet(null); + router.push(`/pets/${targetPetId}`); + }, + [router] + ); + const advance = useCallback(() => { if (advancedRef.current) return; advancedRef.current = true; @@ -70,16 +95,24 @@ export default function StoryViewerScreen() { // eslint-disable-next-line react-hooks/exhaustive-deps }, [currentStory?.id, activePetId]); - // Fixed-duration progress bar, auto-advances/closes when it fills. + // Neue Story → Fortschritt zurücksetzen. useEffect(() => { - if (!currentStory) return; - setProgress(0); + elapsedRef.current = 0; advancedRef.current = false; - const startTime = Date.now(); + setProgress(0); + }, [currentStory?.id]); + + // Fixed-duration progress bar; pausiert, solange ein Sheet offen ist. + useEffect(() => { + if (!currentStory || isPaused) return; + const resumedAt = Date.now(); + const elapsedBefore = elapsedRef.current; let rafId: number; function tick() { - const pct = Math.min(((Date.now() - startTime) / STORY_DURATION_MS) * 100, 100); + const elapsed = elapsedBefore + (Date.now() - resumedAt); + elapsedRef.current = elapsed; + const pct = Math.min((elapsed / STORY_DURATION_MS) * 100, 100); setProgress(pct); if (pct < 100) rafId = requestAnimationFrame(tick); else advance(); @@ -87,11 +120,12 @@ export default function StoryViewerScreen() { rafId = requestAnimationFrame(tick); return () => cancelAnimationFrame(rafId); - }, [currentStory, advance]); + }, [currentStory, isPaused, advance]); if (!currentStory) return null; const mediaUrl = currentStory.storageKey ? getMediaUrl(currentStory.storageKey) : null; + const refreshStories = () => void queryClient.invalidateQueries({ queryKey: listQuery.queryKey }); return ( @@ -120,9 +154,64 @@ export default function StoryViewerScreen() { {petName} + {isOwnStory && ( + setOpenSheet("viewers")} + accessibilityRole="button" + accessibilityLabel={t("storyViewer.viewersTitle", { count: viewers.length })} + > + + {viewers.length} + + )} + + {activePetId && ( + + + setOpenSheet("comments")} + accessibilityRole="button" + accessibilityLabel={t("storyViewer.openComments")} + hitSlop={8} + style={styles.actionWrap} + > + + + + {currentStory.commentCount > 0 && {currentStory.commentCount}} + + + )} + + + {activePetId && ( + setOpenSheet(null)} + onChanged={refreshStories} + onPressPet={openPet} + /> + )} + {isOwnStory && ( + setOpenSheet(null)} + onPressPet={openPet} + /> + )} ); } @@ -145,6 +234,23 @@ const styles = StyleSheet.create({ zIndex: 2, }, petName: { color: colors.pureWhite, fontWeight: "600", fontSize: 14 }, + actions: { position: "absolute", right: 16, alignItems: "center", gap: 16, zIndex: 2 }, + actionWrap: { alignItems: "center", gap: 2 }, + actionCircle: { width: 44, height: 44, borderRadius: 22, backgroundColor: "rgba(0,0,0,0.4)", alignItems: "center", justifyContent: "center" }, + actionCount: { color: colors.pureWhite, fontSize: 12, fontWeight: "600", textShadowColor: "rgba(0,0,0,0.6)", textShadowRadius: 3 }, + viewerCount: { + position: "absolute", + left: 16, + flexDirection: "row", + alignItems: "center", + gap: 6, + paddingHorizontal: 12, + height: 36, + borderRadius: 18, + backgroundColor: "rgba(0,0,0,0.4)", + zIndex: 2, + }, + viewerCountText: { color: colors.pureWhite, fontSize: 13, fontWeight: "600" }, closeButton: { position: "absolute", right: 16, diff --git a/mobile/found.md b/mobile/found.md index a97a331..91957ff 100644 --- a/mobile/found.md +++ b/mobile/found.md @@ -142,6 +142,8 @@ Neu 7: - [x] Nutzer-Feedback: Nach dem Posten einer Story erscheint man selbst nicht als Ring — man kann die eigene Story nicht ansehen. → Ursache (Web **und** App gleich): Die Story-Leiste zeigt nur die „+“-Kachel und Storys gefolgter Tiere (`follows.getPetsWithActiveStories`); das eigene aktive Tier ist darin nie enthalten. Fix: zusätzlicher Ring „Deine Story“ direkt nach der „+“-Kachel, solange das aktive Tier eine aktive Story hat (`stories.hasActiveStory`, wird nach dem Posten schon invalidiert). Web: `src/components/stories/StoryTray.tsx` (öffnet den StoryViewer — dort sieht der Owner auch die Zuschauerliste); App: `src/components/StoryTray.tsx`. Typecheck/Tests/Build grün; die 2 Lint-Fehler „Cannot access refs during render“ in der Web-StoryTray bestanden schon vorher (horizontales Scrollen). **Story-Kommentare:** Backend (`stories.addComment/listComments/deleteComment`, `toggleReaction`, `getViewers`) und Web-UI (`StoryCommentsOverlay`) existieren bereits — nur der App-Viewer (`app/(app)/story-viewer.tsx`) hat Kommentare, Reaktionen und Zuschauerliste beim Portieren bewusst ausgelassen. Offen als eigener Punkt. +- [x] Story-Kommentare, Pfoten-Reaktionen und Zuschauerliste in der App (Folge-Punkt zum Story-Feedback). + → Neuer App-Viewer (`app/(app)/story-viewer.tsx`) mit Pfote (`src/components/stories/StoryPawButton.tsx`), Kommentar-Bottom-Sheet (`StoryCommentsSheet.tsx`, Schreiben + eigene löschen) und — nur bei der eigenen Story — Zuschauerliste (`StoryViewersSheet.tsx`, Augen-Symbol oben links). Solange ein Sheet offen ist, pausiert der Story-Timer (anders als im Web, damit die Story beim Tippen nicht weiterspringt). Nutzt die vorhandenen Server-Prozeduren; einzige Server-Änderung: `stories.listActive` nimmt optional `viewerPetId` und liefert `reactedByViewer`, damit die Pfote gefüllt startet, wenn man schon reagiert hat — abwärtskompatibel, und der Web-Viewer nutzt es jetzt auch (bekannte Einschränkung "Pfote startet immer leer" behoben). Tests in `src/__tests__/stories.test.ts`, alle 268 Tests + Build + App-Typecheck grün. Nicht auf einem Gerät getestet; Tastaturverhalten im Kommentar-Sheet (Android `KeyboardAvoidingView` behavior "height" im Modal) bitte bei Testern beobachten. Weiterhin offen: keine Benachrichtigung bei Story-Kommentaren/-Pfoten (Notification hat keine `storyId`, bräuchte Schemaänderung); Video-Storys spielt der App-Viewer weiterhin nicht ab. - diff --git a/mobile/src/components/stories/StoryCommentsSheet.tsx b/mobile/src/components/stories/StoryCommentsSheet.tsx new file mode 100644 index 0000000..0605093 --- /dev/null +++ b/mobile/src/components/stories/StoryCommentsSheet.tsx @@ -0,0 +1,174 @@ +import { useMemo, useState } from "react"; +import { + ActivityIndicator, + FlatList, + KeyboardAvoidingView, + Modal, + Platform, + Pressable, + StyleSheet, + Text, + View, +} from "react-native"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; +import { useTranslation } from "react-i18next"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { Send, Trash2, X } from "lucide-react-native"; +import { useTRPC } from "../../api/trpc"; +import { useTheme } from "../../context/ThemeContext"; +import { PetAvatar } from "../PetAvatar"; +import { TextField } from "../TextField"; +import { formatPetName, formatRelativeTime } from "../../lib/format"; +import { getTypography, radii, type ColorTokens } from "../../theme/tokens"; + +type Props = { + storyId: string; + activePetId: string; + visible: boolean; + onClose: () => void; + /** Wird nach Hinzufügen/Löschen aufgerufen, damit der Viewer den Zähler neu lädt. */ + onChanged: () => void; + onPressPet: (petId: string) => void; +}; + +/** + * Port of web's StoryCommentsOverlay als Bottom-Sheet. Der Viewer pausiert die + * Story, solange das Sheet offen ist (anders als im Web — auf dem Handy + * verdeckt die Tastatur sonst die laufende Story und sie springt weiter, + * während man noch tippt). + */ +export function StoryCommentsSheet({ storyId, activePetId, visible, onClose, onChanged, onPressPet }: Props) { + const { t } = useTranslation(); + const { colors } = useTheme(); + const insets = useSafeAreaInsets(); + const styles = useMemo(() => makeStyles(colors, insets.bottom), [colors, insets.bottom]); + const trpc = useTRPC(); + const qc = useQueryClient(); + const [body, setBody] = useState(""); + + const { data: comments = [], isLoading } = useQuery({ + ...trpc.stories.listComments.queryOptions({ storyId }), + enabled: visible, + }); + + const refresh = () => { + void qc.invalidateQueries({ queryKey: trpc.stories.listComments.queryKey({ storyId }) }); + onChanged(); + }; + + const add = useMutation( + trpc.stories.addComment.mutationOptions({ + onSuccess: () => { + setBody(""); + refresh(); + }, + }) + ); + const remove = useMutation(trpc.stories.deleteComment.mutationOptions({ onSuccess: refresh })); + + const trimmed = body.trim(); + + return ( + + + + + + {t("storyViewer.commentsTitle")} + + + + + + {isLoading ? ( + + ) : ( + item.id} + contentContainerStyle={styles.list} + ListEmptyComponent={{t("comments.empty")}} + renderItem={({ item }) => ( + + onPressPet(item.pet.id)}> + + + + + onPressPet(item.pet.id)}> + {formatPetName(item.pet)} + + {formatRelativeTime(item.createdAt)} + + {item.body} + + {item.pet.id === activePetId && ( + remove.mutate({ commentId: item.id, petId: activePetId })} + hitSlop={8} + accessibilityLabel={t("storyViewer.deleteComment")} + > + + + )} + + )} + /> + )} + + + trimmed && add.mutate({ storyId, petId: activePetId, body: trimmed })} + /> + add.mutate({ storyId, petId: activePetId, body: trimmed })} + disabled={!trimmed || add.isPending} + style={[styles.send, (!trimmed || add.isPending) && styles.sendDisabled]} + accessibilityLabel={t("messages.send")} + > + {add.isPending ? : } + + + {add.isError && {t("storyViewer.commentError")}} + + + + ); +} + +function makeStyles(colors: ColorTokens, insetBottom: number) { + const typography = getTypography(colors); + return StyleSheet.create({ + flex: { flex: 1 }, + backdrop: { flex: 1, backgroundColor: "rgba(0,0,0,0.35)" }, + sheet: { + maxHeight: "65%", + backgroundColor: colors.porchWhite, + borderTopLeftRadius: radii["2xl"], + borderTopRightRadius: radii["2xl"], + paddingBottom: insetBottom + 12, + }, + header: { flexDirection: "row", alignItems: "center", justifyContent: "space-between", padding: 16, borderBottomWidth: 1, borderBottomColor: colors.hairlineGray }, + title: { ...typography.title, fontSize: 16 }, + loading: { padding: 24 }, + list: { padding: 16, gap: 14 }, + muted: { ...typography.body, color: colors.quietGray, textAlign: "center", paddingVertical: 16 }, + row: { flexDirection: "row", gap: 10, alignItems: "flex-start" }, + rowBody: { flex: 1, gap: 2 }, + rowHeader: { flexDirection: "row", alignItems: "baseline", gap: 8 }, + petName: { ...typography.body, fontWeight: "600", fontSize: 13 }, + time: { ...typography.body, fontSize: 11, color: colors.quietGray }, + body: { ...typography.body }, + inputRow: { flexDirection: "row", alignItems: "center", gap: 8, paddingHorizontal: 12, paddingTop: 10 }, + input: { flex: 1 }, + send: { width: 48, height: 48, borderRadius: radii.lg, backgroundColor: colors.marigoldOrange, alignItems: "center", justifyContent: "center" }, + sendDisabled: { opacity: 0.5 }, + error: { ...typography.body, fontSize: 12, color: colors.alertRed, paddingHorizontal: 12, paddingTop: 6 }, + }); +} diff --git a/mobile/src/components/stories/StoryPawButton.tsx b/mobile/src/components/stories/StoryPawButton.tsx new file mode 100644 index 0000000..029a805 --- /dev/null +++ b/mobile/src/components/stories/StoryPawButton.tsx @@ -0,0 +1,72 @@ +import { useState } from "react"; +import { Pressable, Text, View, StyleSheet } from "react-native"; +import { useTranslation } from "react-i18next"; +import { useMutation } from "@tanstack/react-query"; +import { PawPrint } from "lucide-react-native"; +import { useTRPC } from "../../api/trpc"; +import { colors } from "../../theme/tokens"; + +type Props = { + storyId: string; + petId: string; + initialCount: number; + initialReacted: boolean; +}; + +/** + * Port of web's StoryPawButton — Pfote auf einer Story. Optimistisch: Zustand + * und Zähler springen sofort um und werden bei einem Fehler zurückgesetzt. + * Der Viewer setzt `key={story.id}`, damit der Zustand pro Story neu startet. + */ +export function StoryPawButton({ storyId, petId, initialCount, initialReacted }: Props) { + const { t } = useTranslation(); + const trpc = useTRPC(); + const [reacted, setReacted] = useState(initialReacted); + const [count, setCount] = useState(initialCount); + + const toggle = useMutation( + trpc.stories.toggleReaction.mutationOptions({ + onMutate: () => { + const previous = { reacted, count }; + setReacted(!reacted); + setCount(reacted ? Math.max(0, count - 1) : count + 1); + return previous; + }, + // Der Server ist maßgeblich: Zähler aus dem Stand vor dem Tippen + tatsächlicher Änderung. + onSuccess: (data, _vars, previous) => { + setReacted(data.reacted); + if (!previous) return; + const delta = data.reacted === previous.reacted ? 0 : data.reacted ? 1 : -1; + setCount(Math.max(0, previous.count + delta)); + }, + onError: (_err, _vars, previous) => { + if (previous) { + setReacted(previous.reacted); + setCount(previous.count); + } + }, + }) + ); + + return ( + !toggle.isPending && toggle.mutate({ storyId, petId })} + accessibilityRole="button" + accessibilityLabel={reacted ? t("storyViewer.removePaw") : t("storyViewer.paw")} + accessibilityState={{ selected: reacted }} + hitSlop={8} + style={styles.wrap} + > + + + + {count > 0 && {count}} + + ); +} + +const styles = StyleSheet.create({ + wrap: { alignItems: "center", gap: 2 }, + circle: { width: 44, height: 44, borderRadius: 22, backgroundColor: "rgba(0,0,0,0.4)", alignItems: "center", justifyContent: "center" }, + count: { color: colors.pureWhite, fontSize: 12, fontWeight: "600", textShadowColor: "rgba(0,0,0,0.6)", textShadowRadius: 3 }, +}); diff --git a/mobile/src/components/stories/StoryViewersSheet.tsx b/mobile/src/components/stories/StoryViewersSheet.tsx new file mode 100644 index 0000000..51accde --- /dev/null +++ b/mobile/src/components/stories/StoryViewersSheet.tsx @@ -0,0 +1,88 @@ +import { useMemo } from "react"; +import { ActivityIndicator, FlatList, Modal, Pressable, StyleSheet, Text, View } from "react-native"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; +import { useTranslation } from "react-i18next"; +import { useQuery } from "@tanstack/react-query"; +import { X } from "lucide-react-native"; +import { useTRPC } from "../../api/trpc"; +import { useTheme } from "../../context/ThemeContext"; +import { PetAvatar } from "../PetAvatar"; +import { formatPetName, formatRelativeTime } from "../../lib/format"; +import { getTypography, radii, type ColorTokens } from "../../theme/tokens"; + +type Props = { + storyId: string; + visible: boolean; + onClose: () => void; + onPressPet: (petId: string) => void; +}; + +/** + * Port of web's owner-only viewer Sheet — wer hat diese Story gesehen. + * stories.getViewers prüft serverseitig, dass nur der Owner der Story fragt. + */ +export function StoryViewersSheet({ storyId, visible, onClose, onPressPet }: Props) { + const { t } = useTranslation(); + const { colors } = useTheme(); + const insets = useSafeAreaInsets(); + const styles = useMemo(() => makeStyles(colors, insets.bottom), [colors, insets.bottom]); + const trpc = useTRPC(); + + const { data: viewers = [], isLoading } = useQuery({ + ...trpc.stories.getViewers.queryOptions({ storyId }), + enabled: visible, + }); + + return ( + + + + + {t("storyViewer.viewersTitle", { count: viewers.length })} + + + + + {isLoading ? ( + + ) : ( + item.viewerPet.id} + contentContainerStyle={styles.list} + ListEmptyComponent={{t("storyViewer.noViewers")}} + renderItem={({ item }) => ( + onPressPet(item.viewerPet.id)}> + + {formatPetName(item.viewerPet)} + {formatRelativeTime(item.viewedAt)} + + )} + /> + )} + + + ); +} + +function makeStyles(colors: ColorTokens, insetBottom: number) { + const typography = getTypography(colors); + return StyleSheet.create({ + backdrop: { flex: 1, backgroundColor: "rgba(0,0,0,0.35)" }, + sheet: { + maxHeight: "60%", + backgroundColor: colors.porchWhite, + borderTopLeftRadius: radii["2xl"], + borderTopRightRadius: radii["2xl"], + paddingBottom: insetBottom + 12, + }, + header: { flexDirection: "row", alignItems: "center", justifyContent: "space-between", padding: 16, borderBottomWidth: 1, borderBottomColor: colors.hairlineGray }, + title: { ...typography.title, fontSize: 16 }, + loading: { padding: 24 }, + list: { padding: 16, gap: 12 }, + muted: { ...typography.body, color: colors.quietGray, textAlign: "center", paddingVertical: 16 }, + row: { flexDirection: "row", alignItems: "center", gap: 10 }, + name: { ...typography.body, flex: 1, fontWeight: "600" }, + time: { ...typography.body, fontSize: 11, color: colors.quietGray }, + }); +} diff --git a/mobile/src/i18n/locales/de.json b/mobile/src/i18n/locales/de.json index 2e2ff09..43c59da 100644 --- a/mobile/src/i18n/locales/de.json +++ b/mobile/src/i18n/locales/de.json @@ -401,5 +401,16 @@ "source_STORE_SEARCH": "App Store oder Google Play", "source_PRESS": "Zeitung, Radio oder Blog", "source_OTHER": "Anders" + }, + "storyViewer": { + "paw": "Pfote geben", + "removePaw": "Pfote zurücknehmen", + "openComments": "Kommentare öffnen", + "commentsTitle": "Kommentare", + "deleteComment": "Kommentar löschen", + "commentError": "Kommentar konnte nicht gesendet werden. Bitte versuch es noch einmal.", + "viewersTitle_one": "{{count}} Aufruf", + "viewersTitle_other": "{{count}} Aufrufe", + "noViewers": "Noch hat niemand deine Story gesehen." } } diff --git a/mobile/src/i18n/locales/en.json b/mobile/src/i18n/locales/en.json index 0dc223d..c9e263a 100644 --- a/mobile/src/i18n/locales/en.json +++ b/mobile/src/i18n/locales/en.json @@ -401,5 +401,16 @@ "source_STORE_SEARCH": "App Store or Google Play", "source_PRESS": "Newspaper, radio or blog", "source_OTHER": "Other" + }, + "storyViewer": { + "paw": "Give a paw", + "removePaw": "Remove paw", + "openComments": "Open comments", + "commentsTitle": "Comments", + "deleteComment": "Delete comment", + "commentError": "Your comment could not be sent. Please try again.", + "viewersTitle_one": "{{count}} view", + "viewersTitle_other": "{{count}} views", + "noViewers": "Nobody has seen your story yet." } } diff --git a/src/__tests__/stories.test.ts b/src/__tests__/stories.test.ts index 8e5839a..dc2c1c2 100644 --- a/src/__tests__/stories.test.ts +++ b/src/__tests__/stories.test.ts @@ -141,6 +141,38 @@ describe("stories.create", () => { }); }); +describe("stories.listActive", () => { + it("marks stories the viewer already pawed and strips the raw reactions", async () => { + const { caller, mockPrisma } = createCaller(); + mockPrisma.story.findMany.mockResolvedValue([ + { id: "s1", petId: "pet-a", reactionCount: 2, reactions: [{ petId: "pet-me" }] }, + { id: "s2", petId: "pet-a", reactionCount: 0, reactions: [] }, + ]); + + const result = await caller.listActive({ petId: "pet-a", viewerPetId: "pet-me" }); + + expect(result.map((s) => s.reactedByViewer)).toEqual([true, false]); + expect(result[0]).not.toHaveProperty("reactions"); + expect(mockPrisma.story.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + include: expect.objectContaining({ reactions: { where: { petId: "pet-me" }, select: { petId: true }, take: 1 } }), + }) + ); + }); + + it("stays compatible with older clients that send only petId", async () => { + const { caller, mockPrisma } = createCaller(); + mockPrisma.story.findMany.mockResolvedValue([{ id: "s1", petId: "pet-a", reactionCount: 1 }]); + + const result = await caller.listActive({ petId: "pet-a" }); + + expect(result[0].reactedByViewer).toBe(false); + expect(mockPrisma.story.findMany).toHaveBeenCalledWith( + expect.objectContaining({ include: { musicTrack: true } }) + ); + }); +}); + describe("stories.hasActiveStory", () => { beforeEach(() => vi.clearAllMocks()); diff --git a/src/components/stories/StoryPawButton.tsx b/src/components/stories/StoryPawButton.tsx index 23fe8bb..7a68b0c 100644 --- a/src/components/stories/StoryPawButton.tsx +++ b/src/components/stories/StoryPawButton.tsx @@ -9,17 +9,18 @@ interface StoryPawButtonProps { storyId: string; petId: string; initialCount: number; + /** From stories.listActive({ viewerPetId }) — whether the active pet already pawed this story. */ + initialReacted?: boolean; } /** * 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. + * initialReacted comes from stories.listActive({ viewerPetId }); the + * parent keys this component by story id so state resets per story. */ -export function StoryPawButton({ storyId, petId, initialCount }: StoryPawButtonProps) { +export function StoryPawButton({ storyId, petId, initialCount, initialReacted = false }: StoryPawButtonProps) { const trpc = useTRPC(); - const [reacted, setReacted] = useState(false); + const [reacted, setReacted] = useState(initialReacted); const [count, setCount] = useState(initialCount); const [animationKey, setAnimationKey] = useState(0); const [showBurst, setShowBurst] = useState(false); diff --git a/src/components/stories/StoryViewer.tsx b/src/components/stories/StoryViewer.tsx index e2f565d..d449625 100644 --- a/src/components/stories/StoryViewer.tsx +++ b/src/components/stories/StoryViewer.tsx @@ -81,7 +81,7 @@ export function StoryViewer({ }, []); const { data: stories = [] } = useQuery( - trpc.stories.listActive.queryOptions({ petId }) + trpc.stories.listActive.queryOptions({ petId, viewerPetId: activePetId ?? undefined }) ); const currentStory = stories[currentIndex]; @@ -419,9 +419,11 @@ export function StoryViewer({ {activePetId && (