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:
1 parent
204d5f6831
commit
ccebfa46a6
11 files changed
+533
-22
No files matched your search
@@ -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,
|
||||
|
||||
@@ -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 },
|
||||
});
|
||||
}
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
}));
|
||||
}),
|
||||
|
||||
/**
|
||||
|
||||
Reference in new issue
Block a user