feat: Story-Kommentare, Pfoten und Zuschauerliste in der App

- 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 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 5.5 committed 2026-10-01 20:59:02 +02:00
1 parent 204d5f6831
commit ccebfa46a6
11 files changed
+533 -22

No files matched your search

+119 -13
View File
@@ -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<Sheet>(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 (
<View style={styles.backdrop}>
@@ -120,9 +154,64 @@ export default function StoryViewerScreen() {
<Text style={styles.petName}>{petName}</Text>
</View>
{isOwnStory && (
<Pressable
style={[styles.viewerCount, { top: insets.top + 8 }]}
onPress={() => setOpenSheet("viewers")}
accessibilityRole="button"
accessibilityLabel={t("storyViewer.viewersTitle", { count: viewers.length })}
>
<Eye size={16} color={colors.pureWhite} />
<Text style={styles.viewerCountText}>{viewers.length}</Text>
</Pressable>
)}
{activePetId && (
<View style={[styles.actions, { bottom: insets.bottom + 24 }]}>
<StoryPawButton
key={currentStory.id}
storyId={currentStory.id}
petId={activePetId}
initialCount={currentStory.reactionCount}
initialReacted={currentStory.reactedByViewer}
/>
<Pressable
onPress={() => setOpenSheet("comments")}
accessibilityRole="button"
accessibilityLabel={t("storyViewer.openComments")}
hitSlop={8}
style={styles.actionWrap}
>
<View style={styles.actionCircle}>
<MessageCircle size={20} color={colors.pureWhite} />
</View>
{currentStory.commentCount > 0 && <Text style={styles.actionCount}>{currentStory.commentCount}</Text>}
</Pressable>
</View>
)}
<Pressable style={[styles.closeButton, { top: insets.top + 8 }]} onPress={close} hitSlop={12}>
<X color={colors.pureWhite} size={22} />
</Pressable>
{activePetId && (
<StoryCommentsSheet
storyId={currentStory.id}
activePetId={activePetId}
visible={openSheet === "comments"}
onClose={() => setOpenSheet(null)}
onChanged={refreshStories}
onPressPet={openPet}
/>
)}
{isOwnStory && (
<StoryViewersSheet
storyId={currentStory.id}
visible={openSheet === "viewers"}
onClose={() => setOpenSheet(null)}
onPressPet={openPet}
/>
)}
</View>
);
}
@@ -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,
+2
View File
@@ -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.
-
@@ -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 (
<Modal visible={visible} transparent animationType="slide" onRequestClose={onClose}>
<KeyboardAvoidingView style={styles.flex} behavior={Platform.OS === "ios" ? "padding" : "height"}>
<Pressable style={styles.backdrop} onPress={onClose} accessibilityLabel={t("common.close")} />
<View style={styles.sheet}>
<View style={styles.header}>
<Text style={styles.title}>{t("storyViewer.commentsTitle")}</Text>
<Pressable onPress={onClose} hitSlop={12} accessibilityLabel={t("common.close")}>
<X size={20} color={colors.ink} />
</Pressable>
</View>
{isLoading ? (
<ActivityIndicator style={styles.loading} color={colors.marigoldOrange} />
) : (
<FlatList
data={comments}
keyExtractor={(item) => item.id}
contentContainerStyle={styles.list}
ListEmptyComponent={<Text style={styles.muted}>{t("comments.empty")}</Text>}
renderItem={({ item }) => (
<View style={styles.row}>
<Pressable onPress={() => onPressPet(item.pet.id)}>
<PetAvatar name={item.pet.name} avatarKey={item.pet.avatarKey} size="sm" />
</Pressable>
<View style={styles.rowBody}>
<View style={styles.rowHeader}>
<Text style={styles.petName} onPress={() => onPressPet(item.pet.id)}>
{formatPetName(item.pet)}
</Text>
<Text style={styles.time}>{formatRelativeTime(item.createdAt)}</Text>
</View>
<Text style={styles.body}>{item.body}</Text>
</View>
{item.pet.id === activePetId && (
<Pressable
onPress={() => remove.mutate({ commentId: item.id, petId: activePetId })}
hitSlop={8}
accessibilityLabel={t("storyViewer.deleteComment")}
>
<Trash2 size={16} color={colors.quietGray} />
</Pressable>
)}
</View>
)}
/>
)}
<View style={styles.inputRow}>
<TextField
placeholder={t("comments.placeholder")}
value={body}
onChangeText={setBody}
maxLength={500}
style={styles.input}
returnKeyType="send"
onSubmitEditing={() => trimmed && add.mutate({ storyId, petId: activePetId, body: trimmed })}
/>
<Pressable
onPress={() => 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 ? <ActivityIndicator color="#ffffff" /> : <Send size={18} color="#ffffff" />}
</Pressable>
</View>
{add.isError && <Text style={styles.error}>{t("storyViewer.commentError")}</Text>}
</View>
</KeyboardAvoidingView>
</Modal>
);
}
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 },
});
}
@@ -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 (
<Pressable
onPress={() => !toggle.isPending && toggle.mutate({ storyId, petId })}
accessibilityRole="button"
accessibilityLabel={reacted ? t("storyViewer.removePaw") : t("storyViewer.paw")}
accessibilityState={{ selected: reacted }}
hitSlop={8}
style={styles.wrap}
>
<View style={styles.circle}>
<PawPrint size={20} color={reacted ? colors.marigoldOrange : colors.pureWhite} fill={reacted ? colors.marigoldOrange : "transparent"} />
</View>
{count > 0 && <Text style={styles.count}>{count}</Text>}
</Pressable>
);
}
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 },
});
@@ -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 (
<Modal visible={visible} transparent animationType="slide" onRequestClose={onClose}>
<Pressable style={styles.backdrop} onPress={onClose} accessibilityLabel={t("common.close")} />
<View style={styles.sheet}>
<View style={styles.header}>
<Text style={styles.title}>{t("storyViewer.viewersTitle", { count: viewers.length })}</Text>
<Pressable onPress={onClose} hitSlop={12} accessibilityLabel={t("common.close")}>
<X size={20} color={colors.ink} />
</Pressable>
</View>
{isLoading ? (
<ActivityIndicator style={styles.loading} color={colors.marigoldOrange} />
) : (
<FlatList
data={viewers}
keyExtractor={(item) => item.viewerPet.id}
contentContainerStyle={styles.list}
ListEmptyComponent={<Text style={styles.muted}>{t("storyViewer.noViewers")}</Text>}
renderItem={({ item }) => (
<Pressable style={styles.row} onPress={() => onPressPet(item.viewerPet.id)}>
<PetAvatar name={item.viewerPet.name} avatarKey={item.viewerPet.avatarKey} size="sm" />
<Text style={styles.name} numberOfLines={1}>{formatPetName(item.viewerPet)}</Text>
<Text style={styles.time}>{formatRelativeTime(item.viewedAt)}</Text>
</Pressable>
)}
/>
)}
</View>
</Modal>
);
}
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 },
});
}
+11
View File
@@ -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."
}
}
+11
View File
@@ -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."
}
}
+32
View File
@@ -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());
+6 -5
View File
@@ -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);
+3 -1
View File
@@ -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 && (
<div className="absolute bottom-6 right-4 z-10 flex flex-col items-center gap-4">
<StoryPawButton
key={currentStory.id}
storyId={currentStory.id}
petId={activePetId}
initialCount={currentStory.reactionCount}
initialReacted={currentStory.reactedByViewer}
/>
<button
type="button"
+15 -3
View File
@@ -216,16 +216,28 @@ export const storiesRouter = router({
/**
* List active (non-expired) stories for a pet (D-07 lazy-delete pattern).
* With the optional viewerPetId, each story also carries reactedByViewer so
* the paw button can start filled — optional so older app builds that only
* send { petId } keep working unchanged (reactedByViewer is then false).
*/
listActive: protectedProcedure
.input(z.object({ petId: z.string().min(1) }))
.input(z.object({ petId: z.string().min(1), viewerPetId: z.string().min(1).optional() }))
.query(async ({ ctx, input }) => {
return ctx.prisma.story.findMany({
const stories = await ctx.prisma.story.findMany({
where: { petId: input.petId, expiresAt: { gt: new Date() } },
include: { musicTrack: true },
include: {
musicTrack: true,
...(input.viewerPetId
? { reactions: { where: { petId: input.viewerPetId }, select: { petId: true }, take: 1 } }
: {}),
},
orderBy: { createdAt: "asc" },
take: 50,
});
return stories.map(({ reactions, ...story }: (typeof stories)[number] & { reactions?: { petId: string }[] }) => ({
...story,
reactedByViewer: (reactions?.length ?? 0) > 0,
}));
}),
/**