From 0c448de4c1fa13af49d25655dee4790a4eed5270 Mon Sep 17 00:00:00 2001 From: neodk2004 Date: Sun, 4 Oct 2026 16:38:24 +0200 Subject: [PATCH] wip(mobile): Story-Editor-Oberflaeche (Editor, Pickers, Viewer, Mitteilungen) Typecheck gruen. Offen: Lint (React-Compiler-Regeln in EditableOverlay, setState-in-effect im Viewer), Web-Anzeige, Geraetetest, Deploy. Co-Authored-By: Claude Opus 5.5 --- mobile/app/(app)/(tabs)/notifications.tsx | 18 + mobile/app/(app)/story-new.tsx | 306 +++++++++-------- mobile/app/(app)/story-viewer.tsx | 61 +++- .../story-editor/EditableOverlay.tsx | 128 +++++++ .../src/components/story-editor/GifPicker.tsx | 95 ++++++ .../components/story-editor/MentionPicker.tsx | 74 +++++ .../story-editor/OverlayContent.tsx | 140 ++++++++ .../components/story-editor/PickerSheet.tsx | 33 ++ .../components/story-editor/StickerPicker.tsx | 31 ++ .../story-editor/StoryBackground.tsx | 22 ++ .../components/story-editor/StoryEditor.tsx | 311 ++++++++++++++++++ .../story-editor/StoryOverlayLayer.tsx | 79 +++++ .../story-editor/TextEditorModal.tsx | 120 +++++++ mobile/src/i18n/locales/de.json | 39 ++- mobile/src/i18n/locales/en.json | 39 ++- 15 files changed, 1341 insertions(+), 155 deletions(-) create mode 100644 mobile/src/components/story-editor/EditableOverlay.tsx create mode 100644 mobile/src/components/story-editor/GifPicker.tsx create mode 100644 mobile/src/components/story-editor/MentionPicker.tsx create mode 100644 mobile/src/components/story-editor/OverlayContent.tsx create mode 100644 mobile/src/components/story-editor/PickerSheet.tsx create mode 100644 mobile/src/components/story-editor/StickerPicker.tsx create mode 100644 mobile/src/components/story-editor/StoryBackground.tsx create mode 100644 mobile/src/components/story-editor/StoryEditor.tsx create mode 100644 mobile/src/components/story-editor/StoryOverlayLayer.tsx create mode 100644 mobile/src/components/story-editor/TextEditorModal.tsx diff --git a/mobile/app/(app)/(tabs)/notifications.tsx b/mobile/app/(app)/(tabs)/notifications.tsx index 6fdf482..da64e90 100644 --- a/mobile/app/(app)/(tabs)/notifications.tsx +++ b/mobile/app/(app)/(tabs)/notifications.tsx @@ -22,6 +22,9 @@ const ACTOR_MESSAGE_KEYS: Partial> = { PAW_BACK: "notifications.pawBack", PET_SUGGESTION: "notifications.petSuggestion", REFERRAL_JOINED: "notifications.referralJoined", + STORY_MENTION: "notifications.storyMention", + STORY_COMMENT: "notifications.storyComment", + STORY_REACTION: "notifications.storyReaction", }; // BIRTHDAY/ADOPTION_DAY are self-referential ("your pet's day today") — the @@ -66,6 +69,21 @@ const SELF_ICONS: Partial> = { * there so this doesn't try to be smarter about it). */ function getNotificationRoute(item: NotificationItem): string | null { + // Story-Mitteilungen: Story öffnen, solange sie läuft (der Besitzer sieht + // eigene Storys 7 Tage), sonst das Tierprofil. + if (item.story && (item.type === "STORY_MENTION" || item.type === "STORY_COMMENT" || item.type === "STORY_REACTION")) { + const isOwnStory = item.story.petId === item.recipientPetId; + const isRunning = new Date(item.story.expiresAt).getTime() > Date.now(); + if (isRunning || isOwnStory) { + const params = new URLSearchParams({ + petId: item.story.petId, + petName: item.story.pet.name, + petAvatarKey: item.story.pet.avatarKey ?? "", + }); + return `/story-viewer?${params.toString()}`; + } + return `/pets/${item.story.petId}`; + } switch (item.type) { case "FOLLOW": case "PET_SUGGESTION": diff --git a/mobile/app/(app)/story-new.tsx b/mobile/app/(app)/story-new.tsx index 6412c95..42811f9 100644 --- a/mobile/app/(app)/story-new.tsx +++ b/mobile/app/(app)/story-new.tsx @@ -1,39 +1,32 @@ -import { useState, useCallback, useMemo } from "react"; -import { - View, - Text, - Image as RNImage, - Pressable, - ActivityIndicator, - StyleSheet, - Alert, - type StyleProp, - type ViewStyle, -} from "react-native"; -import { useRouter } from "expo-router"; +import { useState, useCallback, useMemo, useLayoutEffect } from "react"; +import { View, Text, Image as RNImage, Pressable, StyleSheet, Alert } from "react-native"; +import { useNavigation, useRouter } from "expo-router"; import * as ImagePicker from "expo-image-picker"; import { useSafeAreaInsets } from "react-native-safe-area-context"; -import { useVideoPlayer, VideoView } from "expo-video"; import { useTranslation } from "react-i18next"; -import { useMutation, useQueryClient } from "@tanstack/react-query"; -import { ImagePlus, Camera } from "lucide-react-native"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { ImagePlus, Camera, Type } from "lucide-react-native"; import { useTRPC } from "../../src/api/trpc"; import { useActivePet } from "../../src/context/ActivePetContext"; import { useTheme } from "../../src/context/ThemeContext"; import { uploadImageToSupabase, uploadFileToUrl } from "../../src/lib/upload"; import { resizeForUpload } from "../../src/lib/resize-image"; import { MAX_STORY_VIDEO_DURATION_SECS } from "../../src/lib/story-video-limits"; -import { Button } from "../../src/components/Button"; +import { StoryEditor, type EditorMedia, type EditorResult } from "../../src/components/story-editor/StoryEditor"; import { radii, getTypography, type ColorTokens } from "../../src/theme/tokens"; +/** Echte Pixelmaße der (verkleinerten) Datei — der Picker meldet bei gedrehten Handyfotos teils vertauschte Werte. */ +function imageSize(uri: string): Promise<{ width: number; height: number }> { + return new Promise((resolve, reject) => RNImage.getSize(uri, (width, height) => resolve({ width, height }), reject)); +} + /** - * Photo or short video story creation. The video path mirrors web's - * StoryForm.tsx: stories.createVideoUpload gets a Mux direct-upload URL and - * creates the (PROCESSING) Story row up front, then the raw bytes go - * straight to Mux, never through our server — story-viewer.tsx polls - * getVideoStatus once this screen has already closed, same as web's viewer. - * Single asset, unlike post-new.tsx's multi-image picker — a story is one - * photo or one video. + * Story erstellen: Foto, kurzes Video oder reine Text-Story (Farbverlauf), + * danach der Story-Editor (Text, @-Markierungen, GIFs, Sticker — siehe + * src/components/story-editor/). Overlays gehen als Daten mit, nicht ins Bild + * gerechnet. Der Video-Pfad entspricht web's StoryForm.tsx: createVideoUpload + * legt die (PROCESSING) Story an, die Bytes gehen direkt zu Mux. Ist der + * Notschalter (Admin) aus, gibt es nur Vorschau + Teilen wie früher. */ export default function NewStoryScreen() { const { t } = useTranslation(); @@ -45,35 +38,31 @@ export default function NewStoryScreen() { const insets = useSafeAreaInsets(); const styles = useMemo(() => makeStyles(colors, insets.bottom), [colors, insets.bottom]); - const [asset, setAsset] = useState(null); + const { data: flags } = useQuery({ ...trpc.stories.featureFlags.queryOptions(), staleTime: 60_000 }); + const editorEnabled = flags?.storyEditor ?? false; + + const [media, setMedia] = useState(null); + const navigation = useNavigation(); + + // Im Editor gehört der ganze Bildschirm der Story — Kopfzeile ausblenden. + useLayoutEffect(() => { + navigation.setOptions({ headerShown: media === null }); + }, [navigation, media]); const [error, setError] = useState(null); - const [isUploadingFile, setIsUploadingFile] = useState(false); + const [isUploading, setIsUploading] = useState(false); // Video-Upload in Prozent (0–100), null solange kein Video hochgeladen wird. const [uploadPercent, setUploadPercent] = useState(null); - // expo-image-picker reports `duration` (ms) only for videos, `null`/`undefined` - // for photos — more reliable than trusting a MIME-type string (see the - // Android codec-suffix lesson in web's StoryForm.tsx handleFile()). - const isVideo = asset?.duration != null; - const getPresignedUrlMutation = useMutation(trpc.stories.getPresignedUrl.mutationOptions()); + const createMutation = useMutation(trpc.stories.create.mutationOptions()); + const createTextMutation = useMutation(trpc.stories.createText.mutationOptions()); + const createVideoUploadMutation = useMutation(trpc.stories.createVideoUpload.mutationOptions()); - const createMutation = useMutation( - trpc.stories.create.mutationOptions({ - onSuccess: async () => { - await queryClient.invalidateQueries(trpc.follows.pathFilter()); - await queryClient.invalidateQueries(trpc.stories.pathFilter()); - router.back(); - }, - onError: (err) => setError(err.message), - }) - ); - - const createVideoUploadMutation = useMutation( - trpc.stories.createVideoUpload.mutationOptions({ - onError: (err) => setError(err.message), - }) - ); + const finish = useCallback(async () => { + await queryClient.invalidateQueries(trpc.follows.pathFilter()); + await queryClient.invalidateQueries(trpc.stories.pathFilter()); + router.back(); + }, [queryClient, trpc, router]); const pickFromGallery = useCallback(async () => { const permission = await ImagePicker.requestMediaLibraryPermissionsAsync(); @@ -84,12 +73,17 @@ export default function NewStoryScreen() { const result = await ImagePicker.launchImageLibraryAsync({ mediaTypes: ["images", "videos"], quality: 0.8 }); if (result.canceled) return; + // expo-image-picker meldet `duration` (ms) nur bei Videos — verlässlicher als ein MIME-Typ. const picked = result.assets[0]; - if (picked.duration != null && picked.duration / 1000 > MAX_STORY_VIDEO_DURATION_SECS) { - Alert.alert(t("story.videoTooLong", { max: MAX_STORY_VIDEO_DURATION_SECS })); + if (picked.duration != null) { + if (picked.duration / 1000 > MAX_STORY_VIDEO_DURATION_SECS) { + Alert.alert(t("story.videoTooLong", { max: MAX_STORY_VIDEO_DURATION_SECS })); + return; + } + setMedia({ kind: "video", uri: picked.uri, width: picked.width, height: picked.height, mimeType: picked.mimeType ?? undefined }); return; } - setAsset(picked); + setMedia({ kind: "image", uri: picked.uri, width: picked.width, height: picked.height }); }, [t]); const takePhoto = useCallback(async () => { @@ -99,88 +93,120 @@ export default function NewStoryScreen() { return; } const result = await ImagePicker.launchCameraAsync({ quality: 0.8 }); - if (!result.canceled) setAsset(result.assets[0]); - }, []); - - const handleSubmit = useCallback(async () => { - if (!asset || !activePetId) return; - setError(null); - - try { - if (isVideo) { - const { uploadUrl } = await createVideoUploadMutation.mutateAsync({ - petId: activePetId, - aiDisclosure: "NONE", - }); - setIsUploadingFile(true); - setUploadPercent(0); - await uploadFileToUrl(asset.uri, uploadUrl ?? "", asset.mimeType ?? "video/mp4", (fraction) => { - const pct = Math.round(fraction * 100); - setUploadPercent((prev) => (prev === pct ? prev : pct)); - }); - // Story row already exists (PROCESSING) — story-viewer.tsx polls - // getVideoStatus for it, same as web's viewer. Nothing to await here. - await queryClient.invalidateQueries(trpc.follows.pathFilter()); - await queryClient.invalidateQueries(trpc.stories.pathFilter()); - router.back(); - return; - } - - const resized = await resizeForUpload(asset.uri, asset.width, asset.height); - const { presignedUrl, key } = await getPresignedUrlMutation.mutateAsync({ - petId: activePetId, - contentType: resized.mimeType, - fileSize: resized.fileSize, - }); - setIsUploadingFile(true); - await uploadImageToSupabase(resized.uri, presignedUrl, resized.mimeType); - createMutation.mutate({ petId: activePetId, storageKey: key, blurhash: null }); - } catch { - setError(t("onboarding.uploadError")); - } finally { - setIsUploadingFile(false); - setUploadPercent(null); + if (!result.canceled) { + const photo = result.assets[0]; + setMedia({ kind: "image", uri: photo.uri, width: photo.width, height: photo.height }); } - }, [asset, activePetId, isVideo, createVideoUploadMutation, getPresignedUrlMutation, createMutation, queryClient, trpc, router]); + }, [t]); + + const handleSubmit = useCallback( + async ({ overlays, background }: EditorResult) => { + if (!media || !activePetId) return; + setError(null); + const editorFields = overlays.length > 0 ? { overlays } : {}; + + try { + if (media.kind === "text") { + await createTextMutation.mutateAsync({ petId: activePetId, background: background ?? "sunset", overlays }); + await finish(); + return; + } + + if (media.kind === "video") { + const { uploadUrl } = await createVideoUploadMutation.mutateAsync({ + petId: activePetId, + aiDisclosure: "NONE", + ...editorFields, + ...(media.width > 0 && media.height > 0 ? { mediaWidth: media.width, mediaHeight: media.height } : {}), + }); + setIsUploading(true); + setUploadPercent(0); + await uploadFileToUrl(media.uri, uploadUrl ?? "", media.mimeType ?? "video/mp4", (fraction) => { + const pct = Math.round(fraction * 100); + setUploadPercent((prev) => (prev === pct ? prev : pct)); + }); + // Story existiert schon (PROCESSING) — story-viewer.tsx fragt den Status ab. + await finish(); + return; + } + + setIsUploading(true); + const resized = await resizeForUpload(media.uri, media.width, media.height); + const size = await imageSize(resized.uri).catch(() => ({ width: media.width, height: media.height })); + const { presignedUrl, key } = await getPresignedUrlMutation.mutateAsync({ + petId: activePetId, + contentType: resized.mimeType, + fileSize: resized.fileSize, + }); + await uploadImageToSupabase(resized.uri, presignedUrl, resized.mimeType); + await createMutation.mutateAsync({ + petId: activePetId, + storageKey: key, + blurhash: null, + ...editorFields, + mediaWidth: size.width, + mediaHeight: size.height, + }); + await finish(); + } catch (err) { + setError(err instanceof Error && err.message ? err.message : t("onboarding.uploadError")); + } finally { + setIsUploading(false); + setUploadPercent(null); + } + }, + [media, activePetId, createTextMutation, createVideoUploadMutation, getPresignedUrlMutation, createMutation, finish, t] + ); const isSubmitting = - getPresignedUrlMutation.isPending || createMutation.isPending || createVideoUploadMutation.isPending || isUploadingFile; + isUploading || + getPresignedUrlMutation.isPending || + createMutation.isPending || + createTextMutation.isPending || + createVideoUploadMutation.isPending; + + if (media && activePetId) { + return ( + { + if (!isSubmitting) { + setMedia(null); + setError(null); + } + }} + progress={ + <> + {error ? {error} : null} + {isSubmitting && media.kind === "video" ? : null} + + } + /> + ); + } return ( - {asset ? ( - setAsset(null)} disabled={isSubmitting}> - {isVideo ? ( - - ) : ( - - )} - {isVideo ? t("story.changeVideoHint") : t("story.changePhotoHint")} + + + + {t("onboarding.galleryButton")} + + + + {t("onboarding.cameraButton")} + + + {editorEnabled && ( + setMedia({ kind: "text" })}> + + {t("storyEditor.textStory")} - ) : ( - - - - {t("onboarding.galleryButton")} - - - - {t("onboarding.cameraButton")} - - - )} - - {error ? {error} : null} - -