feat: eigene Story löschen (Web + App)

- 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 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 5.5 committed 2026-10-01 21:17:16 +02:00
1 parent 4ec5d8c5f4
commit 81d9c6da30
9 files changed
+259 -27

No files matched your search

+8 -1
View File
@@ -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",
+8 -1
View File
@@ -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",
+59 -17
View File
@@ -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() {
</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>
<View style={[styles.ownerActions, { top: insets.top + 8 }]}>
<Pressable
style={styles.viewerCount}
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>
<Pressable
style={styles.deleteButton}
onPress={() => confirmDelete(currentStory.id)}
disabled={deleteMutation.isPending}
accessibilityRole="button"
accessibilityLabel={t("storyViewer.deleteStory")}
hitSlop={8}
>
{deleteMutation.isPending ? <ActivityIndicator color={colors.pureWhite} size="small" /> : <Trash2 size={16} color={colors.pureWhite} />}
</Pressable>
</View>
)}
{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: {
+4 -1
View File
@@ -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.
+6 -1
View File
@@ -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."
}
}
+6 -1
View File
@@ -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."
}
}
+67
View File
@@ -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();
+68 -5
View File
@@ -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<string | null>(null);
const [isMuted, setIsMuted] = useState(true);
const advancedRef = useRef(false);
const musicAudioRef = useRef<HTMLAudioElement>(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({
<X className="h-5 w-5" aria-hidden="true" />
</button>
{/* Viewer count — top left (owner only) */}
{/* Viewer count + delete — top left (owner only) */}
{isOwnPet && (
<div className="absolute top-8 left-4 flex items-center gap-2">
<button
className="absolute top-8 left-4 flex items-center gap-1.5 px-3 py-2 rounded-full bg-black/40 text-white text-xs hover:bg-black/60"
className="flex items-center gap-1.5 px-3 py-2 rounded-full bg-black/40 text-white text-xs hover:bg-black/60"
onClick={() => setViewerSheetOpen(true)}
aria-label={
viewers.length === 1
@@ -413,6 +446,15 @@ export function StoryViewer({
<Eye className="h-3.5 w-3.5" aria-hidden="true" />
<span>{viewers.length}</span>
</button>
<button
type="button"
className="flex h-[34px] w-[34px] items-center justify-center rounded-full bg-black/40 text-white hover:bg-red-600/80 focus-visible:outline-2 focus-visible:outline-white"
onClick={() => setDeleteTargetId(currentStory.id)}
aria-label={t("deleteStory")}
>
<Trash2 className="h-3.5 w-3.5" aria-hidden="true" />
</button>
</div>
)}
{/* Gitea #29 — paw + comment buttons, bottom right */}
@@ -493,6 +535,27 @@ export function StoryViewer({
)}
</SheetContent>
</Sheet>
<AlertDialog open={!!deleteTargetId} onOpenChange={(next) => !next && !deleteStoryMutation.isPending && setDeleteTargetId(null)}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{t("deleteStoryTitle")}</AlertDialogTitle>
<AlertDialogDescription>{t("deleteStoryDescription")}</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={deleteStoryMutation.isPending}>{t("deleteStoryCancel")}</AlertDialogCancel>
<AlertDialogAction
disabled={deleteStoryMutation.isPending}
onClick={(event) => {
event.preventDefault();
if (deleteTargetId) deleteStoryMutation.mutate({ storyId: deleteTargetId });
}}
className="bg-red-600 text-white hover:bg-red-700"
>
{deleteStoryMutation.isPending ? <Loader2 className="h-4 w-4 animate-spin" /> : t("deleteStoryConfirm")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
);
}
+33
View File
@@ -7,6 +7,7 @@ import {
createSignedUploadUrl,
ALLOWED_CONTENT_TYPES,
MAX_FILE_SIZE,
deleteFiles,
} from "@/lib/supabase-storage";
import { randomUUID } from "crypto";
import { petIdentitySelect } from "@/repositories/pet-repository";
@@ -442,6 +443,38 @@ export const storiesRouter = router({
return comment;
}),
/**
* Owner löscht eine eigene Story (auch eine abgelaufene). Entfernt Aufrufe
* (StoryView hat bewusst kein onDelete: Cascade, D-08), dann die Story —
* Pfoten und Kommentare hängen per Cascade dran. Bild in Supabase Storage
* und Mux-Asset werden danach best-effort gelöscht: ein Fehler dort darf
* das Löschen für den Nutzer nicht scheitern lassen (nur geloggt).
*/
delete: protectedProcedure
.use(rateLimited("stories.delete", 20, 60))
.input(z.object({ storyId: z.string().min(1) }))
.mutation(async ({ ctx, input }): Promise<{ deleted: true }> => {
const story = await ctx.prisma.story.findUnique({
where: { id: input.storyId },
select: { id: true, petId: true, storageKey: true, muxAssetId: true },
});
if (!story) throw new TRPCError({ code: "NOT_FOUND" });
await assertPetOwnership(ctx.prisma, story.petId, ctx.userId);
await ctx.prisma.$transaction([
ctx.prisma.storyView.deleteMany({ where: { storyId: story.id } }),
ctx.prisma.story.delete({ where: { id: story.id } }),
]);
if (story.storageKey) {
await deleteFiles([story.storageKey]).catch((err) => console.error("[stories] storage delete failed", err));
}
if (story.muxAssetId) {
await mux.video.assets.delete(story.muxAssetId).catch((err) => console.error("[stories] mux delete failed", err));
}
return { deleted: true };
}),
deleteComment: protectedProcedure
.input(z.object({ commentId: z.string().min(1), petId: z.string().min(1) }))
.mutation(async ({ ctx, input }): Promise<void> => {