From 81d9c6da30e3e2cd974be43320fae3a18ebcac84 Mon Sep 17 00:00:00 2001 From: neodk2004 Date: Thu, 1 Oct 2026 21:17:16 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20eigene=20Story=20l=C3=B6schen=20(Web=20?= =?UTF-8?q?+=20App)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - stories.delete: nur Owner; Aufrufe, Story (Pfoten/Kommentare per Cascade), danach Bild in Supabase Storage bzw. Mux-Asset best-effort - Web: Papierkorb neben der Zuschauerzahl + Rückfrage, Timer pausiert, gelöscht wird die beim Antippen gemerkte Story - App: gleicher Papierkorb mit nativer Rückfrage, Timer pausiert - found.md: Nutzer-Feedback "Story löschbar" erledigt, 7-Tage-Wunsch mit Befund als offener Punkt Co-Authored-By: Claude Opus 5.5 --- messages/de.json | 9 ++- messages/en.json | 9 ++- mobile/app/(app)/story-viewer.tsx | 76 ++++++++++++++++++++------ mobile/found.md | 5 +- mobile/src/i18n/locales/de.json | 7 ++- mobile/src/i18n/locales/en.json | 7 ++- src/__tests__/stories.test.ts | 67 +++++++++++++++++++++++ src/components/stories/StoryViewer.tsx | 73 +++++++++++++++++++++++-- src/trpc/routers/stories.ts | 33 +++++++++++ 9 files changed, 259 insertions(+), 27 deletions(-) diff --git a/messages/de.json b/messages/de.json index 6670c3c..f8d1d8d 100644 --- a/messages/de.json +++ b/messages/de.json @@ -581,7 +581,14 @@ "mute": "Stummschalten", "unmute": "Ton einschalten", "videoError": "Video konnte nicht verarbeitet werden.", - "openComments": "Kommentare öffnen" + "openComments": "Kommentare öffnen", + "deleteStory": "Story löschen", + "deleteStoryTitle": "Story löschen?", + "deleteStoryDescription": "Die Story wird mit allen Aufrufen, Pfoten und Kommentaren endgültig gelöscht.", + "deleteStoryCancel": "Abbrechen", + "deleteStoryConfirm": "Löschen", + "storyDeleted": "Story gelöscht", + "storyDeleteError": "Die Story konnte nicht gelöscht werden. Bitte versuch es noch einmal." }, "StoryTray": { "storiesAriaLabel": "Storys", diff --git a/messages/en.json b/messages/en.json index c8ff329..ac03e9e 100644 --- a/messages/en.json +++ b/messages/en.json @@ -581,7 +581,14 @@ "mute": "Mute", "unmute": "Unmute", "videoError": "Video could not be processed.", - "openComments": "Open comments" + "openComments": "Open comments", + "deleteStory": "Delete story", + "deleteStoryTitle": "Delete story?", + "deleteStoryDescription": "The story and all its views, paws and comments will be deleted permanently.", + "deleteStoryCancel": "Cancel", + "deleteStoryConfirm": "Delete", + "storyDeleted": "Story deleted", + "storyDeleteError": "The story could not be deleted. Please try again." }, "StoryTray": { "storiesAriaLabel": "Stories", diff --git a/mobile/app/(app)/story-viewer.tsx b/mobile/app/(app)/story-viewer.tsx index 1bd2739..5f452bd 100644 --- a/mobile/app/(app)/story-viewer.tsx +++ b/mobile/app/(app)/story-viewer.tsx @@ -1,11 +1,11 @@ import { useState, useEffect, useCallback, useRef } from "react"; -import { View, Text, Pressable, StyleSheet } from "react-native"; +import { View, Text, Pressable, StyleSheet, Alert, ActivityIndicator } 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 { Eye, MessageCircle, X } from "lucide-react-native"; +import { Eye, MessageCircle, Trash2, X } from "lucide-react-native"; import { useTRPC } from "../../src/api/trpc"; import { useActivePet } from "../../src/context/ActivePetContext"; import { PetAvatar } from "../../src/components/PetAvatar"; @@ -17,15 +17,15 @@ import { colors } from "../../src/theme/tokens"; const STORY_DURATION_MS = 5000; -type Sheet = "comments" | "viewers" | null; +type Sheet = "comments" | "viewers" | "confirm-delete" | null; /** * Port of web's src/components/stories/StoryViewer.tsx, scoped to photo * 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. + * Story — die Zuschauerliste und Löschen wie im Web; solange ein Sheet oder + * die Lösch-Rückfrage offen ist, hält der Timer an. */ export default function StoryViewerScreen() { const { petId, petName, petAvatarKey } = useLocalSearchParams<{ @@ -53,6 +53,37 @@ export default function StoryViewerScreen() { const isPaused = openSheet !== null; const recordViewMutation = useMutation(trpc.stories.recordView.mutationOptions()); + const deleteMutation = useMutation( + trpc.stories.delete.mutationOptions({ + onSuccess: () => { + const remaining = stories.length - 1; + setOpenSheet(null); + void queryClient.invalidateQueries(trpc.stories.pathFilter()); + void queryClient.invalidateQueries(trpc.follows.pathFilter()); + if (remaining <= 0) router.back(); + else setIndex((i) => Math.min(i, remaining - 1)); + }, + onError: () => { + setOpenSheet(null); + Alert.alert(t("storyViewer.deleteError")); + }, + }) + ); + + // Story-ID beim Antippen festhalten: gelöscht wird genau die angezeigte Story. + const confirmDelete = (storyId: string) => { + setOpenSheet("confirm-delete"); + Alert.alert( + t("storyViewer.deleteTitle"), + t("storyViewer.deleteDescription"), + [ + { text: t("common.cancel"), style: "cancel", onPress: () => setOpenSheet(null) }, + { text: t("storyViewer.deleteConfirm"), style: "destructive", onPress: () => deleteMutation.mutate({ storyId }) }, + ], + { cancelable: true, onDismiss: () => setOpenSheet(null) } + ); + }; + const { data: viewers = [] } = useQuery({ ...trpc.stories.getViewers.queryOptions({ storyId: currentStory?.id ?? "" }), enabled: isOwnStory && !!currentStory, @@ -155,15 +186,27 @@ export default function StoryViewerScreen() { {isOwnStory && ( - setOpenSheet("viewers")} - accessibilityRole="button" - accessibilityLabel={t("storyViewer.viewersTitle", { count: viewers.length })} - > - - {viewers.length} - + + setOpenSheet("viewers")} + accessibilityRole="button" + accessibilityLabel={t("storyViewer.viewersTitle", { count: viewers.length })} + > + + {viewers.length} + + confirmDelete(currentStory.id)} + disabled={deleteMutation.isPending} + accessibilityRole="button" + accessibilityLabel={t("storyViewer.deleteStory")} + hitSlop={8} + > + {deleteMutation.isPending ? : } + + )} {activePetId && ( @@ -238,9 +281,9 @@ const styles = StyleSheet.create({ 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 }, + ownerActions: { position: "absolute", left: 16, flexDirection: "row", alignItems: "center", gap: 8, zIndex: 2 }, + deleteButton: { width: 36, height: 36, borderRadius: 18, backgroundColor: "rgba(0,0,0,0.4)", alignItems: "center", justifyContent: "center" }, viewerCount: { - position: "absolute", - left: 16, flexDirection: "row", alignItems: "center", gap: 6, @@ -248,7 +291,6 @@ const styles = StyleSheet.create({ height: 36, borderRadius: 18, backgroundColor: "rgba(0,0,0,0.4)", - zIndex: 2, }, viewerCountText: { color: colors.pureWhite, fontSize: 13, fontWeight: "600" }, closeButton: { diff --git a/mobile/found.md b/mobile/found.md index c19d7ef..316ded7 100644 --- a/mobile/found.md +++ b/mobile/found.md @@ -156,7 +156,10 @@ Neu 7: 2. **Web, alle Bild-Uploads** (Beiträge, Storys, Meilensteine, Tierbild): nur JPEG/PNG/WebP erlaubt; ein `.heic` vom Mac oder eine rohe HEIC-Datei aus iOS-Safari (Storys nutzen `accept="image/*,video/*"`, das Safaris automatische HEIC→JPEG-Umwandlung aushebelt) wurde abgelehnt. Fix: App — HEIC/HEIF in `post-new.tsx` zugelassen, plus Hinweis-Alert, falls doch einmal Bilder übersprungen werden (`post.unsupportedFormat`). Web — neues `src/lib/heic-to-jpeg.ts` wandelt HEIC vor dem Upload per Browser-Dekodierung (Canvas → JPEG 0,9) um, eingebaut in `MultiImageUpload`, `StoryForm`, `MilestoneForm`, `AvatarUpload`; `accept` um HEIC ergänzt, damit der Mac-Dateidialog sie anbietet. Bewusst **ohne** Zusatzbibliothek (heic-to: LGPL-3.0, ~26 MB; heic2any: seit 2023 ungepflegt): Safari (iOS 17+/macOS 14+) dekodiert HEIC selbst; Browser ohne HEIC-Support (z. B. Chrome/Windows) bekommen eine klare Meldung „Öffne PawFeed in Safari oder speichere das Foto als JPG“ (`heicUnsupported`). Tests in `src/__tests__/heic-to-jpeg.test.ts`, alle 266 Tests + Build grün. Nicht auf einem echten iPhone getestet. Commit `1bf0f92`, Web per Rolling-Deploy live, OTA Runtime 1.0.6: Android `1b4baa80-a35a-49b7-ac6e-f44ebe93b4db`, iOS `c31382ee-83b7-4a81-a06d-1a2e53180d7d`. Rückmeldung des Nutzers steht noch aus. -- Nutzerfeedback - Story löschbar ? wie kann man die story löschen, falls man mal was falsches in die story geladen hat ? +- [x] Nutzerfeedback - Story löschbar ? wie kann man die story löschen, falls man mal was falsches in die story geladen hat ? + → Gab es bisher nirgends. Neu: Server `stories.delete` (nur Owner; löscht Aufrufe — `StoryView` hat bewusst kein Cascade —, dann die Story, Pfoten/Kommentare per Cascade; danach Bild in Supabase Storage bzw. Mux-Asset best-effort). Web: Papierkorb neben der Zuschauerzahl in der eigenen Story + Rückfrage (`AlertDialog`), Bild-Timer pausiert während der Rückfrage, gelöscht wird die beim Antippen gemerkte Story-ID. App: gleicher Papierkorb + native Rückfrage, Timer pausiert. Nach dem Löschen springt der Viewer zur nächsten Story bzw. schließt, wenn keine mehr da ist. 5 neue Tests in `src/__tests__/stories.test.ts`. Nicht auf einem Gerät getestet. +- [ ] Storys nur für Ersteller länger einsehbar wegen den Kommentaren (7Tage) + → Noch offen. Befund: abgelaufene Storys werden nie gelöscht (Einträge, Bilder, Mux-Videos bleiben liegen) — Vorschlag: grauer Ring "Deine Story" 7 Tage für den Ersteller, danach Aufräum-Cron (braucht einen Auslöser auf dem NAS, die bestehenden Cron-Routen haben keinen) + Satz unter "Speicherdauer" in der Datenschutzerklärung. diff --git a/mobile/src/i18n/locales/de.json b/mobile/src/i18n/locales/de.json index 43c59da..55fb7c6 100644 --- a/mobile/src/i18n/locales/de.json +++ b/mobile/src/i18n/locales/de.json @@ -411,6 +411,11 @@ "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." + "noViewers": "Noch hat niemand deine Story gesehen.", + "deleteStory": "Story löschen", + "deleteTitle": "Story löschen?", + "deleteDescription": "Die Story wird mit allen Aufrufen, Pfoten und Kommentaren endgültig gelöscht.", + "deleteConfirm": "Löschen", + "deleteError": "Die Story konnte nicht gelöscht werden. Bitte versuch es noch einmal." } } diff --git a/mobile/src/i18n/locales/en.json b/mobile/src/i18n/locales/en.json index c9e263a..b270f98 100644 --- a/mobile/src/i18n/locales/en.json +++ b/mobile/src/i18n/locales/en.json @@ -411,6 +411,11 @@ "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." + "noViewers": "Nobody has seen your story yet.", + "deleteStory": "Delete story", + "deleteTitle": "Delete story?", + "deleteDescription": "The story and all its views, paws and comments will be deleted permanently.", + "deleteConfirm": "Delete", + "deleteError": "The story could not be deleted. Please try again." } } diff --git a/src/__tests__/stories.test.ts b/src/__tests__/stories.test.ts index dc2c1c2..c949bbe 100644 --- a/src/__tests__/stories.test.ts +++ b/src/__tests__/stories.test.ts @@ -16,8 +16,16 @@ vi.mock("@/lib/supabase-storage", () => ({ }), ALLOWED_CONTENT_TYPES: ["image/jpeg", "image/png", "image/webp"], MAX_FILE_SIZE: 10 * 1024 * 1024, + deleteFiles: (...args: unknown[]) => mockDeleteFiles(...args), })); +const mockDeleteFiles = vi.fn().mockResolvedValue(undefined); +const mockMuxDelete = vi.fn().mockResolvedValue(undefined); +vi.mock("@/lib/mux", () => ({ + mux: { video: { assets: { delete: (...args: unknown[]) => mockMuxDelete(...args), retrieve: vi.fn() } } }, +})); +vi.mock("@/lib/rate-limit", () => ({ checkRateLimit: vi.fn().mockResolvedValue({ success: true }) })); + vi.mock("@clerk/nextjs/server", () => ({ auth: vi.fn().mockResolvedValue({ userId: "owner-1" }), })); @@ -141,6 +149,65 @@ describe("stories.create", () => { }); }); +describe("stories.delete", () => { + beforeEach(() => { + mockDeleteFiles.mockClear(); + mockMuxDelete.mockClear(); + }); + + it("deletes the owner's photo story with its views and storage file", async () => { + const { caller, mockPrisma } = createCaller(); + mockPrisma.story.findUnique.mockResolvedValue({ id: "s1", petId: "pet-1", storageKey: "stories/a.jpg", muxAssetId: null }); + mockPrisma.pet.findUnique.mockResolvedValue({ id: "pet-1", ownerId: "owner-1" }); + mockPrisma.$transaction.mockResolvedValue([]); + + await expect(caller.delete({ storyId: "s1" })).resolves.toEqual({ deleted: true }); + + expect(mockPrisma.storyView.deleteMany).toHaveBeenCalledWith({ where: { storyId: "s1" } }); + expect(mockPrisma.story.delete).toHaveBeenCalledWith({ where: { id: "s1" } }); + expect(mockDeleteFiles).toHaveBeenCalledWith(["stories/a.jpg"]); + expect(mockMuxDelete).not.toHaveBeenCalled(); + }); + + it("also removes the Mux asset of a video story", async () => { + const { caller, mockPrisma } = createCaller(); + mockPrisma.story.findUnique.mockResolvedValue({ id: "s2", petId: "pet-1", storageKey: null, muxAssetId: "asset-9" }); + mockPrisma.pet.findUnique.mockResolvedValue({ id: "pet-1", ownerId: "owner-1" }); + mockPrisma.$transaction.mockResolvedValue([]); + + await caller.delete({ storyId: "s2" }); + + expect(mockMuxDelete).toHaveBeenCalledWith("asset-9"); + expect(mockDeleteFiles).not.toHaveBeenCalled(); + }); + + it("refuses to delete another owner's story", async () => { + const { caller, mockPrisma } = createCaller(); + mockPrisma.story.findUnique.mockResolvedValue({ id: "s3", petId: "pet-2", storageKey: "stories/b.jpg", muxAssetId: null }); + mockPrisma.pet.findUnique.mockResolvedValue({ id: "pet-2", ownerId: "owner-2" }); + + await expect(caller.delete({ storyId: "s3" })).rejects.toMatchObject({ code: "FORBIDDEN" }); + expect(mockPrisma.$transaction).not.toHaveBeenCalled(); + expect(mockDeleteFiles).not.toHaveBeenCalled(); + }); + + it("still succeeds when the storage cleanup fails", async () => { + const { caller, mockPrisma } = createCaller(); + mockPrisma.story.findUnique.mockResolvedValue({ id: "s4", petId: "pet-1", storageKey: "stories/c.jpg", muxAssetId: null }); + mockPrisma.pet.findUnique.mockResolvedValue({ id: "pet-1", ownerId: "owner-1" }); + mockPrisma.$transaction.mockResolvedValue([]); + mockDeleteFiles.mockRejectedValueOnce(new Error("storage down")); + + await expect(caller.delete({ storyId: "s4" })).resolves.toEqual({ deleted: true }); + }); + + it("returns NOT_FOUND for unknown stories", async () => { + const { caller, mockPrisma } = createCaller(); + mockPrisma.story.findUnique.mockResolvedValue(null); + await expect(caller.delete({ storyId: "nope" })).rejects.toMatchObject({ code: "NOT_FOUND" }); + }); +}); + describe("stories.listActive", () => { it("marks stories the viewer already pawed and strips the raw reactions", async () => { const { caller, mockPrisma } = createCaller(); diff --git a/src/components/stories/StoryViewer.tsx b/src/components/stories/StoryViewer.tsx index d449625..1bf22d6 100644 --- a/src/components/stories/StoryViewer.tsx +++ b/src/components/stories/StoryViewer.tsx @@ -3,7 +3,18 @@ import { useState, useEffect, useCallback, useRef } from "react"; import { createPortal } from "react-dom"; import dynamic from "next/dynamic"; -import { X, Eye, Volume2, VolumeX, Loader2, MessageCircle } from "lucide-react"; +import { X, Eye, Volume2, VolumeX, Loader2, MessageCircle, Trash2 } from "lucide-react"; +import { toast } from "sonner"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog"; import { useTranslations } from "next-intl"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { useTRPC } from "@/trpc/client"; @@ -70,6 +81,9 @@ export function StoryViewer({ const [progress, setProgress] = useState(0); const [viewerSheetOpen, setViewerSheetOpen] = useState(false); const [commentsOpen, setCommentsOpen] = useState(false); + // Story-ID beim Antippen des Papierkorbs festhalten — der Timer kann + // (bei Videos) weiterlaufen, gelöscht wird trotzdem genau diese Story. + const [deleteTargetId, setDeleteTargetId] = useState(null); const [isMuted, setIsMuted] = useState(true); const advancedRef = useRef(false); const musicAudioRef = useRef(null); @@ -139,7 +153,7 @@ export function StoryViewer({ // Image progress bar — fixed duration, unused for video stories (those // advance on the player's "ended" event instead, see onVideoEnded below). useEffect(() => { - if (!open || !currentStory || isVideo) return; + if (!open || !currentStory || isVideo || deleteTargetId) return; setProgress(0); advancedRef.current = false; @@ -159,7 +173,7 @@ export function StoryViewer({ rafId = requestAnimationFrame(tick); return () => cancelAnimationFrame(rafId); - }, [open, currentStory, isVideo, advance]); + }, [open, currentStory, isVideo, advance, deleteTargetId]); // Reset progress/advance-guard when a video story becomes current. useEffect(() => { @@ -228,6 +242,24 @@ export function StoryViewer({ queryClient.invalidateQueries(trpc.stories.hasActiveStory.queryOptions({ petId })); }, [onOpenChange, queryClient, trpc, petId]); + const deleteStoryMutation = useMutation( + trpc.stories.delete.mutationOptions({ + onSuccess: () => { + const remaining = stories.length - 1; + setDeleteTargetId(null); + toast.success(t("storyDeleted")); + void queryClient.invalidateQueries(trpc.stories.listActive.queryOptions({ petId })); + void queryClient.invalidateQueries(trpc.follows.pathFilter()); + if (remaining <= 0) handleClose(); + else setCurrentIndex((i) => Math.min(i, remaining - 1)); + }, + onError: () => { + setDeleteTargetId(null); + toast.error(t("storyDeleteError")); + }, + }) + ); + if (!currentStory) return null; const mediaUrl = currentStory.storageKey ? getMediaUrl(currentStory.storageKey) : null; @@ -399,10 +431,11 @@ export function StoryViewer({