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 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 5.5 committed 2026-10-04 16:38:24 +02:00
1 parent 795a39355e
commit 0c448de4c1
15 files changed
+1341 -155

No files matched your search

+18
View File
@@ -22,6 +22,9 @@ const ACTOR_MESSAGE_KEYS: Partial<Record<NotificationItem["type"], string>> = {
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<Record<NotificationItem["type"], string>> = {
* 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":
+164 -142
View File
@@ -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<ImagePicker.ImagePickerAsset | null>(null);
const { data: flags } = useQuery({ ...trpc.stories.featureFlags.queryOptions(), staleTime: 60_000 });
const editorEnabled = flags?.storyEditor ?? false;
const [media, setMedia] = useState<EditorMedia | null>(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<string | null>(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<number | null>(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 (
<StoryEditor
media={media}
petId={activePetId}
toolsEnabled={editorEnabled}
isSubmitting={isSubmitting}
submitLabel={isSubmitting ? t("milestones.sharing") : t("story.shareButton")}
onSubmit={handleSubmit}
onCancel={() => {
if (!isSubmitting) {
setMedia(null);
setError(null);
}
}}
progress={
<>
{error ? <Text style={styles.errorOnDark}>{error}</Text> : null}
{isSubmitting && media.kind === "video" ? <UploadProgress percent={uploadPercent} styles={styles} /> : null}
</>
}
/>
);
}
return (
<View style={styles.container}>
{asset ? (
<Pressable style={styles.preview} onPress={() => setAsset(null)} disabled={isSubmitting}>
{isVideo ? (
<VideoPreview uri={asset.uri} style={styles.previewImage} />
) : (
<RNImage source={{ uri: asset.uri }} style={styles.previewImage} />
)}
<Text style={styles.previewHint}>{isVideo ? t("story.changeVideoHint") : t("story.changePhotoHint")}</Text>
<View style={styles.pickerRow}>
<Pressable style={styles.pickerButton} onPress={pickFromGallery}>
<ImagePlus color={colors.marigoldOrange} size={22} />
<Text style={styles.pickerText}>{t("onboarding.galleryButton")}</Text>
</Pressable>
<Pressable style={styles.pickerButton} onPress={takePhoto}>
<Camera color={colors.marigoldOrange} size={22} />
<Text style={styles.pickerText}>{t("onboarding.cameraButton")}</Text>
</Pressable>
</View>
{editorEnabled && (
<Pressable style={styles.textStoryButton} onPress={() => setMedia({ kind: "text" })}>
<Type color={colors.pureWhite} size={22} />
<Text style={styles.textStoryText}>{t("storyEditor.textStory")}</Text>
</Pressable>
) : (
<View style={styles.pickerRow}>
<Pressable style={styles.pickerButton} onPress={pickFromGallery}>
<ImagePlus color={colors.marigoldOrange} size={22} />
<Text style={styles.pickerText}>{t("onboarding.galleryButton")}</Text>
</Pressable>
<Pressable style={styles.pickerButton} onPress={takePhoto}>
<Camera color={colors.marigoldOrange} size={22} />
<Text style={styles.pickerText}>{t("onboarding.cameraButton")}</Text>
</Pressable>
</View>
)}
{error ? <Text style={styles.error}>{error}</Text> : null}
<Button
title={isSubmitting ? t("milestones.sharing") : t("story.shareButton")}
onPress={handleSubmit}
disabled={!asset || isSubmitting}
/>
{isSubmitting && isVideo ? (
<UploadProgress percent={uploadPercent} styles={styles} />
) : (
isSubmitting && <ActivityIndicator color={colors.marigoldOrange} />
)}
</View>
);
@@ -210,17 +236,6 @@ function UploadProgress({ percent, styles }: { percent: number | null; styles: R
);
}
/** Muted, looping local-file preview — separate component so `useVideoPlayer` isn't called conditionally in the screen body. */
function VideoPreview({ uri, style }: { uri: string; style: StyleProp<ViewStyle> }) {
const player = useVideoPlayer(uri, (p) => {
p.loop = true;
p.muted = true;
p.play();
});
return <VideoView player={player} style={style} contentFit="contain" nativeControls={false} />;
}
function makeStyles(colors: ColorTokens, bottomInset: number) {
const typography = getTypography(colors);
return StyleSheet.create({
@@ -244,13 +259,20 @@ function makeStyles(colors: ColorTokens, bottomInset: number) {
borderColor: colors.hairlineGray,
},
pickerText: { ...typography.body, color: colors.ink },
preview: { flex: 1, borderRadius: radii.md, overflow: "hidden", backgroundColor: colors.hairlineGray, gap: 8 },
previewImage: { width: "100%", flex: 1 },
previewHint: { ...typography.label, textTransform: "none", letterSpacing: 0, textAlign: "center", paddingVertical: 6 },
error: { ...typography.body, color: colors.alertRed },
textStoryButton: {
flexDirection: "row",
alignItems: "center",
justifyContent: "center",
gap: 8,
height: 48,
borderRadius: radii.lg,
backgroundColor: colors.marigoldOrange,
},
textStoryText: { ...typography.body, color: colors.pureWhite, fontFamily: typography.title.fontFamily },
errorOnDark: { ...typography.body, color: "#f87171", textAlign: "center" },
progressWrap: { gap: 6 },
progressTrack: { height: 6, borderRadius: 3, backgroundColor: colors.hairlineGray, overflow: "hidden" },
progressTrack: { height: 6, borderRadius: 3, backgroundColor: "rgba(255,255,255,0.2)", overflow: "hidden" },
progressFill: { height: "100%", borderRadius: 3, backgroundColor: colors.marigoldOrange },
progressLabel: { ...typography.label, textTransform: "none", letterSpacing: 0, textAlign: "center", color: colors.ink },
progressLabel: { ...typography.label, textTransform: "none", letterSpacing: 0, textAlign: "center", color: "#ffffff" },
});
}
+56 -5
View File
@@ -1,7 +1,7 @@
import { useState, useEffect, useCallback, useRef } from "react";
import { View, Text, Pressable, StyleSheet, Alert, ActivityIndicator } from "react-native";
import { View, Text, Pressable, StyleSheet, Alert, ActivityIndicator, type LayoutChangeEvent } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { useLocalSearchParams, useRouter } from "expo-router";
import { useFocusEffect, useLocalSearchParams, useRouter } from "expo-router";
import { useTranslation } from "react-i18next";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { Image } from "expo-image";
@@ -15,11 +15,15 @@ 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 { StoryOverlayLayer } from "../../src/components/story-editor/StoryOverlayLayer";
import { StoryBackground } from "../../src/components/story-editor/StoryBackground";
import { containRect, storyMediaSize } from "../../src/lib/story-overlays";
import { colors } from "../../src/theme/tokens";
const IMAGE_STORY_DURATION_MS = 5000;
type Sheet = "comments" | "viewers" | "confirm-delete" | null;
// "away" = über eine Markierung ins Tierprofil gewechselt — Story pausiert bis zur Rückkehr.
type Sheet = "comments" | "viewers" | "confirm-delete" | "away" | null;
/**
* Port of web's src/components/stories/StoryViewer.tsx. Tap the left third
@@ -44,7 +48,7 @@ export default function StoryViewerScreen() {
const isOwnStory = activePetId === petId;
const listQuery = trpc.stories.listActive.queryOptions({ petId, viewerPetId: activePetId ?? undefined });
const { data: stories = [] } = useQuery(listQuery);
const { data: stories = [], isSuccess: storiesLoaded } = useQuery(listQuery);
const [index, setIndex] = useState(0);
const [progress, setProgress] = useState(0);
const [openSheet, setOpenSheet] = useState<Sheet>(null);
@@ -54,6 +58,18 @@ export default function StoryViewerScreen() {
const currentStory = stories[index];
const isPaused = openSheet !== null;
const isVideo = currentStory?.mediaType === "VIDEO";
const isText = currentStory?.mediaType === "TEXT";
const [screen, setScreen] = useState({ width: 0, height: 0 });
const { data: flags } = useQuery({ ...trpc.stories.featureFlags.queryOptions(), staleTime: 60_000 });
// Notschalter: Overlays nur zeigen, wenn der Story-Editor im Admin an ist.
const showOverlays = flags?.storyEditor ?? false;
// Zurück aus dem Tierprofil (über eine Markierung) → Story läuft weiter.
useFocusEffect(
useCallback(() => {
setOpenSheet((sheet) => (sheet === "away" ? null : sheet));
}, [])
);
const recordViewMutation = useMutation(trpc.stories.recordView.mutationOptions());
const deleteMutation = useMutation(
@@ -115,6 +131,14 @@ export default function StoryViewerScreen() {
[router]
);
const openMentionedPet = useCallback(
(targetPetId: string) => {
setOpenSheet("away");
router.push(`/pets/${targetPetId}`);
},
[router]
);
const advance = useCallback(() => {
if (advancedRef.current) return;
advancedRef.current = true;
@@ -166,13 +190,25 @@ export default function StoryViewerScreen() {
return () => cancelAnimationFrame(rafId);
}, [currentStory, isVideo, isPaused, advance]);
// Keine (laufende) Story mehr, z. B. über eine alte Mitteilung geöffnet → zurück statt schwarzem Bildschirm.
useEffect(() => {
if (storiesLoaded && stories.length === 0) router.back();
}, [storiesLoaded, stories.length, router]);
if (!currentStory) return null;
const mediaUrl = currentStory.storageKey ? getMediaUrl(currentStory.storageKey) : null;
const refreshStories = () => void queryClient.invalidateQueries({ queryKey: listQuery.queryKey });
// Medienfläche wie bei contentFit="contain" — darauf beziehen sich die Overlays.
const mediaSize = storyMediaSize(currentStory);
const rect = mediaSize ? containRect(screen.width, screen.height, mediaSize.width, mediaSize.height) : null;
const onLayout = (e: LayoutChangeEvent) => {
const { width, height } = e.nativeEvent.layout;
setScreen((prev) => (prev.width === width && prev.height === height ? prev : { width, height }));
};
return (
<View style={styles.backdrop}>
<View style={styles.backdrop} onLayout={onLayout}>
<View style={[styles.progressRow, { top: insets.top + 12 }]}>
{stories.map((s, i) => (
<View key={s.id} style={styles.progressTrack}>
@@ -195,6 +231,12 @@ export default function StoryViewerScreen() {
onProgress={setProgress}
onEnded={advance}
/>
) : isText ? (
rect && (
<View style={{ position: "absolute", left: rect.x, top: rect.y }}>
<StoryBackground backgroundKey={currentStory.background ?? "sunset"} width={rect.width} height={rect.height} />
</View>
)
) : (
mediaUrl && <Image source={{ uri: mediaUrl }} style={styles.media} contentFit="contain" />
)}
@@ -204,6 +246,15 @@ export default function StoryViewerScreen() {
<Pressable style={styles.tapZoneRight} onPress={advance} />
</View>
{showOverlays && rect && (
<StoryOverlayLayer
overlays={currentStory.overlays}
rect={rect}
mentionedPets={currentStory.mentionedPets}
onPressMention={openMentionedPet}
/>
)}
<View style={[styles.petInfo, { bottom: insets.bottom + 24 }]}>
<PetAvatar name={petName} avatarKey={petAvatarKey || null} size="sm" />
<Text style={styles.petName}>{petName}</Text>
@@ -0,0 +1,128 @@
import { useMemo, useRef } from "react";
import { PanResponder, View, type GestureResponderEvent, type PanResponderGestureState } from "react-native";
import { OverlayContent } from "./OverlayContent";
import { PositionedOverlay } from "./StoryOverlayLayer";
import { angleDelta, applyGesture, touchGeometry, type Rect, type StoryOverlay } from "../../lib/story-overlays";
const TAP_MAX_MOVE = 6;
const TAP_MAX_MS = 250;
type Props = {
overlay: StoryOverlay;
rect: Rect;
mentionName?: string;
onChange: (next: StoryOverlay) => void;
onTap: () => void;
/** Ziehen beginnt/endet — der Editor blendet dann den Papierkorb ein bzw. aus. */
onDragStateChange: (dragging: boolean, pageY: number) => void;
/** Loslassen über dem Papierkorb? (pageY des Fingers) */
isOverTrash: (pageY: number) => boolean;
onDelete: () => void;
};
/**
* Ein verschiebbares Overlay im Story-Editor. Gesten mit React-Natives
* eingebautem PanResponder statt gesture-handler/reanimated: Die sind zwar
* über expo-router installiert, aber ob sie im Store-Build 1.0.6 nativ stecken,
* ist nicht sicher belegt — ein OTA-Update mit ihrem Import würde dann beim
* Start abstürzen. PanResponder ist RN-Core und damit OTA-sicher.
*
* 1 Finger: verschieben · 2 Finger: zoomen + drehen (+ verschieben) ·
* kurz tippen: bearbeiten · auf den Papierkorb ziehen: löschen.
*/
export function EditableOverlay({ overlay, rect, mentionName, onChange, onTap, onDragStateChange, isOverTrash, onDelete }: Props) {
// Aktuelle Props für die (einmal erzeugten) PanResponder-Handler.
const latest = useRef({ overlay, rect, onChange, onTap, onDragStateChange, isOverTrash, onDelete });
latest.current = { overlay, rect, onChange, onTap, onDragStateChange, isOverTrash, onDelete };
const gesture = useRef({
base: overlay,
offsetX: 0,
offsetY: 0,
touches: 0,
startDistance: 1,
startAngle: 0,
startCentroid: { x: 0, y: 0 },
startedAt: 0,
moved: 0,
});
const responder = useMemo(
() =>
PanResponder.create({
onStartShouldSetPanResponder: () => true,
onMoveShouldSetPanResponder: () => true,
onPanResponderTerminationRequest: () => false,
onPanResponderGrant: (evt) => {
const g = gesture.current;
g.base = latest.current.overlay;
g.offsetX = 0;
g.offsetY = 0;
g.touches = 0;
g.startedAt = Date.now();
g.moved = 0;
rebase(evt, { dx: 0, dy: 0 } as PanResponderGestureState);
latest.current.onDragStateChange(true, evt.nativeEvent.pageY);
},
onPanResponderMove: (evt, state) => {
const g = gesture.current;
const touches = evt.nativeEvent.touches;
// Fingerzahl geändert → neue Ausgangslage, sonst springt das Overlay.
if (touches.length !== g.touches) rebase(evt, state);
g.moved = Math.max(g.moved, Math.hypot(state.dx, state.dy));
let pinch = 1;
let rotationDelta = 0;
let dx = state.dx - g.offsetX;
let dy = state.dy - g.offsetY;
if (touches.length >= 2 && g.startDistance > 0) {
const now = touchGeometry(touches[0], touches[1]);
pinch = now.distance / g.startDistance;
rotationDelta = angleDelta(g.startAngle, now.angle);
dx = (touches[0].pageX + touches[1].pageX) / 2 - g.startCentroid.x;
dy = (touches[0].pageY + touches[1].pageY) / 2 - g.startCentroid.y;
g.moved = Math.max(g.moved, TAP_MAX_MOVE + 1);
}
latest.current.onChange(applyGesture(g.base, latest.current.rect, { dx, dy, pinch, rotationDelta }));
latest.current.onDragStateChange(true, evt.nativeEvent.pageY);
},
onPanResponderRelease: (evt) => finish(evt),
onPanResponderTerminate: (evt) => finish(evt),
}),
[]
);
function rebase(evt: GestureResponderEvent, state: Pick<PanResponderGestureState, "dx" | "dy">) {
const g = gesture.current;
const touches = evt.nativeEvent.touches;
g.base = latest.current.overlay;
g.offsetX = state.dx;
g.offsetY = state.dy;
g.touches = touches.length;
if (touches.length >= 2) {
const geo = touchGeometry(touches[0], touches[1]);
g.startDistance = geo.distance;
g.startAngle = geo.angle;
g.startCentroid = { x: (touches[0].pageX + touches[1].pageX) / 2, y: (touches[0].pageY + touches[1].pageY) / 2 };
}
}
function finish(evt: GestureResponderEvent) {
const g = gesture.current;
const { pageY } = evt.nativeEvent;
latest.current.onDragStateChange(false, pageY);
if (g.moved <= TAP_MAX_MOVE && Date.now() - g.startedAt <= TAP_MAX_MS) {
latest.current.onTap();
} else if (latest.current.isOverTrash(pageY)) {
latest.current.onDelete();
}
}
return (
<PositionedOverlay overlay={overlay} rect={rect} interactive>
<View {...responder.panHandlers} hitSlop={12}>
<OverlayContent overlay={overlay} baseWidth={rect.width} mentionName={mentionName} />
</View>
</PositionedOverlay>
);
}
@@ -0,0 +1,95 @@
import { useEffect, useState } from "react";
import { View, Text, TextInput, Pressable, FlatList, ActivityIndicator, useWindowDimensions, StyleSheet } from "react-native";
import { useTranslation } from "react-i18next";
import { useQuery } from "@tanstack/react-query";
import { Image } from "expo-image";
import { useTRPC } from "../../api/trpc";
import { useDebounce } from "../../hooks/useDebounce";
import { fontFamily } from "../../theme/tokens";
import { PickerSheet } from "./PickerSheet";
export type PickedGif = { id: string; url: string; width: number; height: number };
const COLUMNS = 3;
const GAP = 6;
/**
* GIF-Suche über unseren Server (stories.searchGifs → GIPHY, nur Freigabe G).
* Ohne Suchbegriff erscheinen die aktuellen Trends. „Powered by GIPHY“ ist
* Pflicht laut GIPHY-Bedingungen.
*/
export function GifPicker({ visible, onSelect, onClose }: { visible: boolean; onSelect: (gif: PickedGif) => void; onClose: () => void }) {
const { t } = useTranslation();
const trpc = useTRPC();
const { width } = useWindowDimensions();
const [query, setQuery] = useState("");
const debounced = useDebounce(query.trim(), 400);
const [offset, setOffset] = useState(0);
const [items, setItems] = useState<PickedGif[]>([]);
// Neue Suche → von vorne
useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect -- Liste beim Suchwechsel leeren ist gewollt
setOffset(0);
setItems([]);
}, [debounced]);
const { data, isFetching, isError } = useQuery({
...trpc.stories.searchGifs.queryOptions({ query: debounced, offset }),
enabled: visible,
staleTime: 10 * 60 * 1000,
});
useEffect(() => {
if (!data) return;
// eslint-disable-next-line react-hooks/set-state-in-effect -- Seiten werden angehängt, sobald sie da sind
setItems((prev) => {
const seen = new Set(prev.map((g) => g.id));
return [...prev, ...data.gifs.filter((g) => !seen.has(g.id))];
});
}, [data]);
const tile = (width - 32 - GAP * (COLUMNS - 1)) / COLUMNS;
return (
<PickerSheet visible={visible} title={t("storyEditor.gifTitle")} onClose={onClose}>
<TextInput
value={query}
onChangeText={setQuery}
placeholder={t("storyEditor.gifSearch")}
placeholderTextColor="#a3a3a3"
autoCorrect={false}
style={styles.search}
/>
{isError && <Text style={styles.hint}>{t("storyEditor.gifError")}</Text>}
<FlatList
data={items}
keyExtractor={(g) => g.id}
numColumns={COLUMNS}
columnWrapperStyle={{ gap: GAP }}
contentContainerStyle={{ gap: GAP }}
keyboardShouldPersistTaps="handled"
onEndReachedThreshold={0.6}
onEndReached={() => {
if (data?.nextOffset && !isFetching) setOffset(data.nextOffset);
}}
renderItem={({ item }) => (
<Pressable onPress={() => onSelect(item)} accessibilityRole="button" accessibilityLabel="GIF">
<Image source={{ uri: item.url }} style={{ width: tile, height: tile, borderRadius: 8, backgroundColor: "#2a2a2a" }} contentFit="cover" autoplay />
</Pressable>
)}
ListFooterComponent={isFetching ? <ActivityIndicator color="#f97316" style={{ marginVertical: 12 }} /> : null}
/>
<View style={styles.attribution}>
<Text style={styles.attributionText}>Powered by GIPHY</Text>
</View>
</PickerSheet>
);
}
const styles = StyleSheet.create({
search: { backgroundColor: "#2a2a2a", color: "#ffffff", borderRadius: 12, paddingHorizontal: 14, height: 44, fontFamily: fontFamily.regular, fontSize: 15 },
hint: { color: "#a3a3a3", fontFamily: fontFamily.regular, fontSize: 13 },
attribution: { alignItems: "center", paddingTop: 4 },
attributionText: { color: "#a3a3a3", fontFamily: fontFamily.bold, fontSize: 12, letterSpacing: 0.5 },
});
@@ -0,0 +1,74 @@
import { useState } from "react";
import { View, Text, TextInput, Pressable, FlatList, ActivityIndicator, StyleSheet } from "react-native";
import { useTranslation } from "react-i18next";
import { useQuery } from "@tanstack/react-query";
import { useTRPC } from "../../api/trpc";
import { useDebounce } from "../../hooks/useDebounce";
import { PetAvatar } from "../PetAvatar";
import { fontFamily } from "../../theme/tokens";
import { PickerSheet } from "./PickerSheet";
/**
* Tier zum Markieren suchen. Der Server liefert nur Tiere, die man laut ihrer
* Einstellung „Wer darf die Follower sehen?“ markieren darf (und keine
* blockierten) — darauf weist der Hinweis unten hin.
*/
export function MentionPicker({
visible,
petId,
onSelect,
onClose,
}: {
visible: boolean;
petId: string;
onSelect: (pet: { id: string; name: string }) => void;
onClose: () => void;
}) {
const { t } = useTranslation();
const trpc = useTRPC();
const [query, setQuery] = useState("");
const debounced = useDebounce(query.trim(), 300);
const { data: pets = [], isFetching } = useQuery({
...trpc.stories.searchMentionable.queryOptions({ petId, query: debounced }),
enabled: visible && debounced.length > 0,
});
return (
<PickerSheet visible={visible} title={t("storyEditor.mentionTitle")} onClose={onClose}>
<TextInput
value={query}
onChangeText={setQuery}
placeholder={t("storyEditor.mentionSearch")}
placeholderTextColor="#a3a3a3"
autoFocus
autoCapitalize="none"
autoCorrect={false}
style={styles.search}
/>
{isFetching && <ActivityIndicator color="#f97316" />}
<FlatList
data={debounced ? pets : []}
keyExtractor={(p) => p.id}
keyboardShouldPersistTaps="handled"
renderItem={({ item }) => (
<Pressable style={styles.row} onPress={() => onSelect({ id: item.id, name: item.name })} accessibilityRole="button">
<PetAvatar name={item.name} avatarKey={item.avatarKey} size="sm" owner={item.owner} />
<Text style={styles.name}>{item.name}</Text>
</Pressable>
)}
ListEmptyComponent={
debounced && !isFetching ? <Text style={styles.hint}>{t("storyEditor.mentionNoResults")}</Text> : null
}
/>
<Text style={styles.hint}>{t("storyEditor.mentionPrivacyHint")}</Text>
</PickerSheet>
);
}
const styles = StyleSheet.create({
search: { backgroundColor: "#2a2a2a", color: "#ffffff", borderRadius: 12, paddingHorizontal: 14, height: 44, fontFamily: fontFamily.regular, fontSize: 15 },
row: { flexDirection: "row", alignItems: "center", gap: 12, paddingVertical: 10 },
name: { color: "#ffffff", fontFamily: fontFamily.semiBold, fontSize: 15 },
hint: { color: "#a3a3a3", fontFamily: fontFamily.regular, fontSize: 12, paddingVertical: 6 },
});
@@ -0,0 +1,140 @@
import { memo } from "react";
import { View, Text, StyleSheet } from "react-native";
import { Image } from "expo-image";
import Svg, { Circle, Path, Rect as SvgRect } from "react-native-svg";
import { fontFamily } from "../../theme/tokens";
import { OVERLAY_SIZES, contrastTextColor, type StoryOverlay, type TextOverlay } from "../../lib/story-overlays";
import { STORY_STICKERS, STICKER_OUTLINE, type StickerPart } from "../../lib/story-stickers";
/**
* Sichtbarer Inhalt eines Story-Overlays, ohne Position/Drehung/Skalierung —
* das übernehmen StoryOverlayLayer (Anzeige) bzw. EditableOverlay (Editor).
* Alle Größen hängen nur von `baseWidth` (Breite der Medienfläche) ab, damit
* es auf jedem Bildschirm und im Web (StoryOverlayLayer.tsx dort) gleich aussieht.
*/
export const OverlayContent = memo(function OverlayContent({
overlay,
baseWidth,
mentionName,
}: {
overlay: StoryOverlay;
baseWidth: number;
mentionName?: string;
}) {
switch (overlay.kind) {
case "text":
return <TextContent overlay={overlay} baseWidth={baseWidth} />;
case "mention":
return <MentionChip name={mentionName ?? "…"} baseWidth={baseWidth} />;
case "gif": {
const width = OVERLAY_SIZES.gifWidth * baseWidth;
return (
<Image
source={{ uri: overlay.url }}
style={{ width, height: (width * overlay.height) / overlay.width, borderRadius: baseWidth * 0.02 }}
autoplay
contentFit="cover"
accessibilityLabel="GIF"
/>
);
}
case "sticker":
return <StickerContent stickerId={overlay.stickerId} size={OVERLAY_SIZES.sticker * baseWidth} />;
}
});
function TextContent({ overlay, baseWidth }: { overlay: TextOverlay; baseWidth: number }) {
const fontSize = OVERLAY_SIZES.textFont * baseWidth;
const { textColor, background } = textAppearance(overlay);
return (
<View
style={{
maxWidth: OVERLAY_SIZES.textMaxWidth * baseWidth,
backgroundColor: background,
borderRadius: fontSize * 0.3,
paddingHorizontal: background ? fontSize * 0.4 : 0,
paddingVertical: background ? fontSize * 0.18 : 0,
}}
>
<Text
style={[
styles.text,
{
fontSize,
lineHeight: fontSize * 1.2,
color: textColor,
fontFamily: overlay.font === "bold" ? fontFamily.bold : fontFamily.regular,
},
!background && styles.textShadow,
]}
>
{overlay.text}
</Text>
</View>
);
}
/** Farben je Textstil — identische Regeln im Web (src/components/stories/StoryOverlayLayer.tsx). */
export function textAppearance(overlay: Pick<TextOverlay, "color" | "style">): { textColor: string; background?: string } {
switch (overlay.style) {
case "box":
return { textColor: contrastTextColor(overlay.color), background: overlay.color };
case "soft":
return {
textColor: overlay.color,
background: overlay.color === "#171717" ? "rgba(255,255,255,0.85)" : "rgba(0,0,0,0.55)",
};
default:
return { textColor: overlay.color };
}
}
export function MentionChip({ name, baseWidth }: { name: string; baseWidth: number }) {
const fontSize = OVERLAY_SIZES.mentionFont * baseWidth;
return (
<View
style={{
backgroundColor: "#ffffff",
borderRadius: fontSize,
paddingHorizontal: fontSize * 0.6,
paddingVertical: fontSize * 0.25,
}}
>
<Text style={{ fontSize, lineHeight: fontSize * 1.25, color: "#f97316", fontFamily: fontFamily.bold }} numberOfLines={1}>
@{name}
</Text>
</View>
);
}
export function StickerContent({ stickerId, size }: { stickerId: string; size: number }) {
const sticker = STORY_STICKERS.find((s) => s.id === stickerId);
if (!sticker) return null;
return (
<Svg width={size} height={size} viewBox="-6 -6 112 112" accessibilityLabel={sticker.label}>
{sticker.parts.map((part, i) => (
<StickerShape key={`o${i}`} part={part} fill={STICKER_OUTLINE.color} stroke={STICKER_OUTLINE.color} strokeWidth={STICKER_OUTLINE.width} />
))}
{sticker.parts.map((part, i) => (
<StickerShape key={`f${i}`} part={part} fill={part.fill} />
))}
</Svg>
);
}
function StickerShape({ part, fill, stroke, strokeWidth }: { part: StickerPart; fill: string; stroke?: string; strokeWidth?: number }) {
const strokeProps = stroke ? { stroke, strokeWidth, strokeLinejoin: "round" as const } : {};
switch (part.type) {
case "circle":
return <Circle cx={part.cx} cy={part.cy} r={part.r} fill={fill} {...strokeProps} />;
case "rect":
return <SvgRect x={part.x} y={part.y} width={part.width} height={part.height} rx={part.rx} fill={fill} {...strokeProps} />;
case "path":
return <Path d={part.d} fill={fill} {...strokeProps} />;
}
}
const styles = StyleSheet.create({
text: { textAlign: "center" },
textShadow: { textShadowColor: "rgba(0,0,0,0.45)", textShadowOffset: { width: 0, height: 1 }, textShadowRadius: 6 },
});
@@ -0,0 +1,33 @@
import { Modal, View, Text, Pressable, KeyboardAvoidingView, Platform, StyleSheet } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { X } from "lucide-react-native";
import { fontFamily } from "../../theme/tokens";
/** Dunkles Bottom-Sheet für die Auswahlfenster des Story-Editors (Markieren, GIF, Sticker). */
export function PickerSheet({ visible, title, onClose, children }: { visible: boolean; title: string; onClose: () => void; children: React.ReactNode }) {
const insets = useSafeAreaInsets();
return (
<Modal visible={visible} transparent animationType="slide" onRequestClose={onClose} statusBarTranslucent>
<KeyboardAvoidingView style={styles.backdrop} behavior={Platform.OS === "ios" ? "padding" : "height"}>
<Pressable style={styles.dismiss} onPress={onClose} accessibilityLabel={title} />
<View style={[styles.sheet, { paddingBottom: insets.bottom + 12 }]}>
<View style={styles.header}>
<Text style={styles.title}>{title}</Text>
<Pressable onPress={onClose} hitSlop={12} accessibilityRole="button">
<X color="#ffffff" size={22} />
</Pressable>
</View>
{children}
</View>
</KeyboardAvoidingView>
</Modal>
);
}
const styles = StyleSheet.create({
backdrop: { flex: 1, justifyContent: "flex-end" },
dismiss: { flex: 1 },
sheet: { backgroundColor: "#1c1c1c", borderTopLeftRadius: 20, borderTopRightRadius: 20, paddingTop: 12, paddingHorizontal: 16, maxHeight: "75%", gap: 12 },
header: { flexDirection: "row", alignItems: "center", justifyContent: "space-between" },
title: { color: "#ffffff", fontFamily: fontFamily.bold, fontSize: 17 },
});
@@ -0,0 +1,31 @@
import { View, Pressable, StyleSheet } from "react-native";
import { useTranslation } from "react-i18next";
import { STORY_STICKERS } from "../../lib/story-stickers";
import { StickerContent } from "./OverlayContent";
import { PickerSheet } from "./PickerSheet";
export function StickerPicker({ visible, onSelect, onClose }: { visible: boolean; onSelect: (stickerId: string) => void; onClose: () => void }) {
const { t } = useTranslation();
return (
<PickerSheet visible={visible} title={t("storyEditor.stickerTitle")} onClose={onClose}>
<View style={styles.grid}>
{STORY_STICKERS.map((sticker) => (
<Pressable
key={sticker.id}
style={styles.cell}
onPress={() => onSelect(sticker.id)}
accessibilityRole="button"
accessibilityLabel={sticker.label}
>
<StickerContent stickerId={sticker.id} size={64} />
</Pressable>
))}
</View>
</PickerSheet>
);
}
const styles = StyleSheet.create({
grid: { flexDirection: "row", flexWrap: "wrap", justifyContent: "space-between", rowGap: 12, paddingBottom: 8 },
cell: { width: "23%", aspectRatio: 1, alignItems: "center", justifyContent: "center", borderRadius: 12, backgroundColor: "#2a2a2a" },
});
@@ -0,0 +1,22 @@
import Svg, { Defs, LinearGradient, Rect, Stop } from "react-native-svg";
import { STORY_BACKGROUNDS } from "../../lib/story-overlays";
/**
* Farbverlauf einer Text-Story. Über react-native-svg statt expo-linear-gradient:
* letzteres wäre ein neues natives Modul (= Store-Build), svg ist schon drin.
*/
export function StoryBackground({ backgroundKey, width, height, radius = 0 }: { backgroundKey: string; width: number; height: number; radius?: number }) {
const [from, to] = STORY_BACKGROUNDS[backgroundKey] ?? STORY_BACKGROUNDS.sunset;
const id = `bg-${backgroundKey}`;
return (
<Svg width={width} height={height}>
<Defs>
<LinearGradient id={id} x1="0" y1="0" x2="0.35" y2="1">
<Stop offset="0" stopColor={from} />
<Stop offset="1" stopColor={to} />
</LinearGradient>
</Defs>
<Rect x={0} y={0} width={width} height={height} rx={radius} fill={`url(#${id})`} />
</Svg>
);
}
@@ -0,0 +1,311 @@
import { useCallback, useMemo, useRef, useState } from "react";
import { View, Text, Pressable, Image as RNImage, ActivityIndicator, Alert, StyleSheet, type LayoutChangeEvent } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { useTranslation } from "react-i18next";
import { useVideoPlayer, VideoView } from "expo-video";
import { AtSign, Smile, Trash2, Type, X } from "lucide-react-native";
import { fontFamily } from "../../theme/tokens";
import {
MAX_GIFS,
MAX_MENTIONS,
MAX_OVERLAYS,
STORY_BACKGROUND_KEYS,
STORY_BACKGROUNDS,
TEXT_STORY_ASPECT,
containRect,
type StoryOverlay,
type TextOverlay,
} from "../../lib/story-overlays";
import { EditableOverlay } from "./EditableOverlay";
import { StoryBackground } from "./StoryBackground";
import { TextEditorModal, type TextDraft } from "./TextEditorModal";
import { MentionPicker } from "./MentionPicker";
import { GifPicker } from "./GifPicker";
import { StickerPicker } from "./StickerPicker";
export type EditorMedia =
| { kind: "image"; uri: string; width: number; height: number }
| { kind: "video"; uri: string; width: number; height: number; mimeType?: string }
| { kind: "text" };
export type EditorResult = { overlays: StoryOverlay[]; background?: string; mentionNames: Record<string, string> };
type Picker = "text" | "mention" | "gif" | "sticker" | null;
const TRASH_ZONE_HEIGHT = 120;
/**
* Story-Editor (feature/story-editor): Foto, Video oder Farbverlauf als Fläche,
* darauf Text, @-Markierungen, GIFs und Sticker. Positionen werden relativ zur
* Medienfläche gespeichert (lib/story-overlays.ts), damit sie im Viewer — App
* und Web — an derselben Stelle sitzen.
*/
export function StoryEditor({
media,
petId,
isSubmitting,
submitLabel,
progress,
toolsEnabled = true,
onSubmit,
onCancel,
}: {
media: EditorMedia;
/** Notschalter aus → nur Vorschau + Teilen, keine Werkzeuge (wie vor dem Editor). */
toolsEnabled?: boolean;
petId: string;
isSubmitting: boolean;
submitLabel: string;
/** Upload-Fortschritt/Status unter dem Teilen-Knopf (Video). */
progress?: React.ReactNode;
onSubmit: (result: EditorResult) => void;
onCancel: () => void;
}) {
const { t } = useTranslation();
const insets = useSafeAreaInsets();
const [canvas, setCanvas] = useState({ width: 0, height: 0 });
const [overlays, setOverlays] = useState<StoryOverlay[]>([]);
const [background, setBackground] = useState<string>(STORY_BACKGROUND_KEYS[0]);
const [mentionNames, setMentionNames] = useState<Record<string, string>>({});
const [picker, setPicker] = useState<Picker>(media.kind === "text" ? "text" : null);
const [editingIndex, setEditingIndex] = useState<number | null>(null);
const [dragging, setDragging] = useState(false);
const [overTrash, setOverTrash] = useState(false);
const canvasTopRef = useRef(0);
const canvasRef = useRef<View>(null);
const mediaSize = media.kind === "text" ? TEXT_STORY_ASPECT : { width: media.width, height: media.height };
const rect = useMemo(
() => containRect(canvas.width, canvas.height, mediaSize.width, mediaSize.height),
[canvas.width, canvas.height, mediaSize.width, mediaSize.height]
);
const onCanvasLayout = (e: LayoutChangeEvent) => {
const { width, height } = e.nativeEvent.layout;
setCanvas({ width, height });
canvasRef.current?.measureInWindow((_x, y) => {
canvasTopRef.current = y;
});
};
const trashTop = () => canvasTopRef.current + canvas.height - TRASH_ZONE_HEIGHT;
const isOverTrash = useCallback((pageY: number) => pageY >= trashTop(), [canvas.height]); // eslint-disable-line react-hooks/exhaustive-deps
const gifCount = overlays.filter((o) => o.kind === "gif").length;
const mentionCount = new Set(overlays.flatMap((o) => (o.kind === "mention" ? [o.petId] : []))).size;
const isFull = overlays.length >= MAX_OVERLAYS;
const add = (overlay: StoryOverlay) => {
if (isFull) {
Alert.alert(t("storyEditor.limitOverlays", { max: MAX_OVERLAYS }));
return;
}
setOverlays((list) => [...list, overlay]);
};
// Neue Overlays erscheinen leicht versetzt in der Mitte, damit sie sich nicht genau überdecken.
const spawnPosition = () => {
const offset = (overlays.length % 5) * 0.04;
return { x: 0.5, y: 0.42 + offset, scale: 1, rotation: 0 };
};
const handleTextDone = (draft: TextDraft) => {
const text = draft.text.trim();
if (editingIndex !== null) {
setOverlays((list) =>
text
? list.map((o, i) => (i === editingIndex && o.kind === "text" ? { ...o, ...draft, text } : o))
: list.filter((_, i) => i !== editingIndex)
);
} else if (text) {
add({ kind: "text", ...spawnPosition(), ...draft, text });
}
setEditingIndex(null);
setPicker(null);
};
const editingText = editingIndex !== null && overlays[editingIndex]?.kind === "text" ? (overlays[editingIndex] as TextOverlay) : undefined;
const hasText = overlays.some((o) => o.kind === "text");
const canSubmit = !isSubmitting && rect.width > 0 && (media.kind !== "text" || hasText);
return (
<View style={styles.root}>
<View ref={canvasRef} style={styles.canvas} onLayout={onCanvasLayout}>
{rect.width > 0 && (
<View style={{ position: "absolute", left: rect.x, top: rect.y, width: rect.width, height: rect.height }}>
{media.kind === "text" ? (
<StoryBackground backgroundKey={background} width={rect.width} height={rect.height} />
) : media.kind === "video" ? (
<EditorVideo uri={media.uri} />
) : (
<RNImage source={{ uri: media.uri }} style={StyleSheet.absoluteFill} resizeMode="cover" />
)}
</View>
)}
{overlays.map((overlay, index) => (
<EditableOverlay
key={index}
overlay={overlay}
rect={rect}
mentionName={overlay.kind === "mention" ? mentionNames[overlay.petId] : undefined}
onChange={(next) => setOverlays((list) => list.map((o, i) => (i === index ? next : o)))}
onTap={() => {
if (overlay.kind === "text") {
setEditingIndex(index);
setPicker("text");
}
}}
onDragStateChange={(isDragging, pageY) => {
setDragging(isDragging);
setOverTrash(isDragging && isOverTrash(pageY));
}}
isOverTrash={isOverTrash}
onDelete={() => setOverlays((list) => list.filter((_, i) => i !== index))}
/>
))}
{toolsEnabled && overlays.length === 0 && media.kind !== "text" && rect.width > 0 && (
<View pointerEvents="none" style={[styles.hintWrap, { top: rect.y + rect.height / 2 - 20 }]}>
<Text style={styles.hint}>{t("storyEditor.emptyHint")}</Text>
</View>
)}
{dragging && (
<View pointerEvents="none" style={[styles.trashZone, { height: TRASH_ZONE_HEIGHT }]}>
<View style={[styles.trashCircle, overTrash && styles.trashCircleActive]}>
<Trash2 color="#ffffff" size={24} />
</View>
</View>
)}
</View>
<View style={[styles.topBar, { top: insets.top + 8 }]}>
<ToolButton onPress={onCancel} label={t("common.cancel")}>
<X color="#ffffff" size={22} />
</ToolButton>
{toolsEnabled && <View style={styles.tools}>
{media.kind === "text" && (
<ToolButton
label={t("storyEditor.background")}
onPress={() => setBackground((bg) => STORY_BACKGROUND_KEYS[(STORY_BACKGROUND_KEYS.indexOf(bg) + 1) % STORY_BACKGROUND_KEYS.length])}
>
<View style={[styles.swatch, { backgroundColor: STORY_BACKGROUNDS[background][0], borderColor: STORY_BACKGROUNDS[background][1] }]} />
</ToolButton>
)}
<ToolButton label={t("storyEditor.addText")} onPress={() => setPicker("text")}>
<Type color="#ffffff" size={22} />
</ToolButton>
<ToolButton
label={t("storyEditor.addMention")}
onPress={() => (mentionCount >= MAX_MENTIONS ? Alert.alert(t("storyEditor.limitMentions", { max: MAX_MENTIONS })) : setPicker("mention"))}
>
<AtSign color="#ffffff" size={22} />
</ToolButton>
<ToolButton
label={t("storyEditor.addGif")}
onPress={() => (gifCount >= MAX_GIFS ? Alert.alert(t("storyEditor.limitGifs", { max: MAX_GIFS })) : setPicker("gif"))}
>
<Text style={styles.gifLabel}>GIF</Text>
</ToolButton>
<ToolButton label={t("storyEditor.addSticker")} onPress={() => setPicker("sticker")}>
<Smile color="#ffffff" size={22} />
</ToolButton>
</View>}
</View>
<View style={[styles.bottomBar, { paddingBottom: insets.bottom + 12 }]}>
<Pressable
style={[styles.shareButton, !canSubmit && styles.shareButtonDisabled]}
disabled={!canSubmit}
onPress={() => onSubmit({ overlays, background: media.kind === "text" ? background : undefined, mentionNames })}
accessibilityRole="button"
>
{isSubmitting ? <ActivityIndicator color="#ffffff" /> : <Text style={styles.shareText}>{submitLabel}</Text>}
</Pressable>
{progress}
</View>
<TextEditorModal
visible={picker === "text"}
initial={editingText ? { text: editingText.text, color: editingText.color, style: editingText.style, font: editingText.font } : undefined}
onDone={handleTextDone}
onCancel={() => {
setEditingIndex(null);
setPicker(null);
if (media.kind === "text" && !hasText && editingIndex === null && overlays.length === 0) onCancel();
}}
/>
<MentionPicker
visible={picker === "mention"}
petId={petId}
onClose={() => setPicker(null)}
onSelect={(pet) => {
setMentionNames((names) => ({ ...names, [pet.id]: pet.name }));
if (!overlays.some((o) => o.kind === "mention" && o.petId === pet.id)) add({ kind: "mention", ...spawnPosition(), petId: pet.id });
setPicker(null);
}}
/>
<GifPicker
visible={picker === "gif"}
onClose={() => setPicker(null)}
onSelect={(gif) => {
add({ kind: "gif", ...spawnPosition(), gifId: gif.id, url: gif.url, width: gif.width, height: gif.height });
setPicker(null);
}}
/>
<StickerPicker
visible={picker === "sticker"}
onClose={() => setPicker(null)}
onSelect={(stickerId) => {
add({ kind: "sticker", ...spawnPosition(), stickerId });
setPicker(null);
}}
/>
</View>
);
}
function ToolButton({ label, onPress, children }: { label: string; onPress: () => void; children: React.ReactNode }) {
return (
<Pressable style={styles.toolButton} onPress={onPress} hitSlop={6} accessibilityRole="button" accessibilityLabel={label}>
{children}
</Pressable>
);
}
/** Stumm in Schleife — eigene Komponente, damit useVideoPlayer nicht bedingt aufgerufen wird. */
function EditorVideo({ uri }: { uri: string }) {
const player = useVideoPlayer(uri, (p) => {
p.loop = true;
p.muted = true;
p.play();
});
return <VideoView player={player} style={StyleSheet.absoluteFill} contentFit="cover" nativeControls={false} />;
}
const styles = StyleSheet.create({
root: { flex: 1, backgroundColor: "#111111" },
canvas: { flex: 1 },
topBar: { position: "absolute", left: 12, right: 12, flexDirection: "row", justifyContent: "space-between", alignItems: "center" },
tools: { flexDirection: "row", gap: 8 },
toolButton: { width: 42, height: 42, borderRadius: 21, backgroundColor: "rgba(0,0,0,0.45)", alignItems: "center", justifyContent: "center" },
gifLabel: { color: "#ffffff", fontFamily: fontFamily.extraBold, fontSize: 13, letterSpacing: 0.5 },
swatch: { width: 22, height: 22, borderRadius: 11, borderWidth: 3 },
bottomBar: { paddingHorizontal: 16, paddingTop: 12, gap: 8, backgroundColor: "#111111" },
shareButton: { height: 48, borderRadius: 24, backgroundColor: "#f97316", alignItems: "center", justifyContent: "center" },
shareButtonDisabled: { opacity: 0.5 },
shareText: { color: "#ffffff", fontFamily: fontFamily.bold, fontSize: 16 },
hintWrap: { position: "absolute", left: 24, right: 24, alignItems: "center" },
hint: {
color: "#ffffff",
fontFamily: fontFamily.semiBold,
fontSize: 15,
textAlign: "center",
textShadowColor: "rgba(0,0,0,0.6)",
textShadowRadius: 6,
},
trashZone: { position: "absolute", left: 0, right: 0, bottom: 0, alignItems: "center", justifyContent: "center" },
trashCircle: { width: 56, height: 56, borderRadius: 28, backgroundColor: "rgba(0,0,0,0.55)", alignItems: "center", justifyContent: "center", borderWidth: 2, borderColor: "rgba(255,255,255,0.6)" },
trashCircleActive: { backgroundColor: "#dc2626", transform: [{ scale: 1.2 }] },
});
@@ -0,0 +1,79 @@
import { View, Pressable, StyleSheet } from "react-native";
import { OverlayContent } from "./OverlayContent";
import { overlayCenter, type Rect, type StoryOverlay } from "../../lib/story-overlays";
type MentionedPet = { id: string; name: string };
/**
* Anzeige-Ebene im Story-Viewer: legt die Overlays auf die Medienfläche `rect`
* (gleiches Koordinatensystem wie im Editor). Nur Markierungen sind antippbar
* (→ Tierprofil), alles andere lässt Berührungen zu den Weiter/Zurück-Zonen durch.
*/
export function StoryOverlayLayer({
overlays,
rect,
mentionedPets,
onPressMention,
}: {
overlays: readonly StoryOverlay[];
rect: Rect;
mentionedPets: readonly MentionedPet[];
onPressMention?: (petId: string) => void;
}) {
if (!overlays.length || rect.width <= 0) return null;
const names = new Map(mentionedPets.map((p) => [p.id, p.name]));
return (
<View style={StyleSheet.absoluteFill} pointerEvents="box-none">
{overlays.map((overlay, i) => {
const content = (
<OverlayContent overlay={overlay} baseWidth={rect.width} mentionName={overlay.kind === "mention" ? names.get(overlay.petId) : undefined} />
);
return (
<PositionedOverlay key={i} overlay={overlay} rect={rect} interactive={overlay.kind === "mention" && !!onPressMention}>
{overlay.kind === "mention" && onPressMention ? (
<Pressable onPress={() => onPressMention(overlay.petId)} hitSlop={8} accessibilityRole="link">
{content}
</Pressable>
) : (
content
)}
</PositionedOverlay>
);
})}
</View>
);
}
/**
* Zentriert den Inhalt auf (x, y) der Medienfläche, ohne ihn messen zu müssen:
* ein großes, unsichtbares Quadrat um den Mittelpunkt mit zentriertem Inhalt.
* Drehung und Skalierung wirken um die Mitte — wie im Web (transform-origin center).
*/
export function PositionedOverlay({
overlay,
rect,
interactive,
children,
}: {
overlay: StoryOverlay;
rect: Rect;
interactive: boolean;
children: React.ReactNode;
}) {
const box = rect.width * 2;
const { left, top } = overlayCenter(rect, overlay);
return (
<View
pointerEvents="box-none"
style={{ position: "absolute", left: left - box / 2, top: top - box / 2, width: box, height: box, alignItems: "center", justifyContent: "center" }}
>
<View
pointerEvents={interactive ? "auto" : "none"}
style={{ transform: [{ rotate: `${overlay.rotation}deg` }, { scale: overlay.scale }] }}
>
{children}
</View>
</View>
);
}
@@ -0,0 +1,120 @@
import { useEffect, useState } from "react";
import { Modal, View, Text, TextInput, Pressable, KeyboardAvoidingView, Platform, StyleSheet } from "react-native";
import { useTranslation } from "react-i18next";
import { fontFamily } from "../../theme/tokens";
import { MAX_TEXT_LENGTH, TEXT_COLORS, TEXT_STYLES, type TextColor, type TextFont, type TextStyle } from "../../lib/story-overlays";
import { textAppearance } from "./OverlayContent";
export type TextDraft = { text: string; color: TextColor; style: TextStyle; font: TextFont };
const DEFAULT_DRAFT: TextDraft = { text: "", color: "#ffffff", style: "plain", font: "bold" };
/**
* Vollbild-Texteingabe über dem Editor (wie Instagram): Text tippen, Farbe
* (8 Farben), Stil (schlicht → Box → weich) und Schrift (fett/normal) wählen.
* Leerer Text beim Fertig-Drücken = Overlay entfernen bzw. nicht anlegen.
*/
export function TextEditorModal({
visible,
initial,
onDone,
onCancel,
}: {
visible: boolean;
initial?: TextDraft;
onDone: (draft: TextDraft) => void;
onCancel: () => void;
}) {
const { t } = useTranslation();
const [draft, setDraft] = useState<TextDraft>(initial ?? DEFAULT_DRAFT);
// Beim Öffnen mit dem bearbeiteten Overlay bzw. leer starten.
useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect -- Zurücksetzen beim Öffnen ist hier gewollt
if (visible) setDraft(initial ?? DEFAULT_DRAFT);
}, [visible, initial]);
const { textColor, background } = textAppearance(draft);
const nextStyle = TEXT_STYLES[(TEXT_STYLES.indexOf(draft.style) + 1) % TEXT_STYLES.length];
return (
<Modal visible={visible} transparent animationType="fade" onRequestClose={onCancel} statusBarTranslucent>
<KeyboardAvoidingView style={styles.backdrop} behavior={Platform.OS === "ios" ? "padding" : "height"}>
<View style={styles.topBar}>
<Pressable onPress={onCancel} hitSlop={12} accessibilityRole="button">
<Text style={styles.topButton}>{t("common.cancel")}</Text>
</Pressable>
<View style={styles.topTools}>
<Pressable
style={styles.toolButton}
onPress={() => setDraft((d) => ({ ...d, style: nextStyle }))}
accessibilityRole="button"
accessibilityLabel={t("storyEditor.textStyle")}
>
<Text style={styles.toolButtonText}>{t(`storyEditor.style.${draft.style}`)}</Text>
</Pressable>
<Pressable
style={styles.toolButton}
onPress={() => setDraft((d) => ({ ...d, font: d.font === "bold" ? "regular" : "bold" }))}
accessibilityRole="button"
accessibilityLabel={t("storyEditor.textFont")}
>
<Text style={[styles.toolButtonText, { fontFamily: draft.font === "bold" ? fontFamily.bold : fontFamily.regular }]}>Aa</Text>
</Pressable>
</View>
<Pressable onPress={() => onDone(draft)} hitSlop={12} accessibilityRole="button">
<Text style={[styles.topButton, styles.done]}>{t("common.done")}</Text>
</Pressable>
</View>
<View style={styles.center}>
<View style={[styles.preview, background ? { backgroundColor: background } : null]}>
<TextInput
value={draft.text}
onChangeText={(text) => setDraft((d) => ({ ...d, text }))}
autoFocus
multiline
maxLength={MAX_TEXT_LENGTH}
placeholder={t("storyEditor.textPlaceholder")}
placeholderTextColor="rgba(255,255,255,0.6)"
style={[
styles.input,
{ color: textColor, fontFamily: draft.font === "bold" ? fontFamily.bold : fontFamily.regular },
]}
/>
</View>
</View>
<View style={styles.colors}>
{TEXT_COLORS.map((color) => (
<Pressable
key={color}
onPress={() => setDraft((d) => ({ ...d, color }))}
style={[styles.colorDot, { backgroundColor: color }, draft.color === color && styles.colorDotActive]}
accessibilityRole="button"
accessibilityLabel={color}
accessibilityState={{ selected: draft.color === color }}
hitSlop={4}
/>
))}
</View>
</KeyboardAvoidingView>
</Modal>
);
}
const styles = StyleSheet.create({
backdrop: { flex: 1, backgroundColor: "rgba(0,0,0,0.7)", paddingTop: 48, paddingBottom: 16 },
topBar: { flexDirection: "row", alignItems: "center", justifyContent: "space-between", paddingHorizontal: 16 },
topButton: { color: "#ffffff", fontFamily: fontFamily.semiBold, fontSize: 16 },
done: { color: "#f97316" },
topTools: { flexDirection: "row", gap: 10 },
toolButton: { borderWidth: 1.5, borderColor: "#ffffff", borderRadius: 999, paddingHorizontal: 12, paddingVertical: 4 },
toolButtonText: { color: "#ffffff", fontFamily: fontFamily.semiBold, fontSize: 14 },
center: { flex: 1, alignItems: "center", justifyContent: "center", paddingHorizontal: 24 },
preview: { borderRadius: 10, paddingHorizontal: 12, paddingVertical: 6, maxWidth: "100%" },
input: { fontSize: 28, lineHeight: 34, textAlign: "center", minWidth: 120, textShadowColor: "rgba(0,0,0,0.35)", textShadowRadius: 4 },
colors: { flexDirection: "row", justifyContent: "center", gap: 12, paddingVertical: 8 },
colorDot: { width: 28, height: 28, borderRadius: 14, borderWidth: 2, borderColor: "rgba(255,255,255,0.6)" },
colorDotActive: { borderColor: "#ffffff", borderWidth: 3, transform: [{ scale: 1.15 }] },
});
+35 -4
View File
@@ -210,7 +210,10 @@
"petSuggestion": "könnte dir gefallen!",
"empty": "Keine Benachrichtigungen.",
"referralJoined": "ist über deine Einladung zu PawFeed gekommen.",
"referralRankUp": "Du hast einen neuen Empfehlungsrang erreicht!"
"referralRankUp": "Du hast einen neuen Empfehlungsrang erreicht!",
"storyMention": "hat dich in einer Story markiert.",
"storyComment": "hat deine Story kommentiert.",
"storyReaction": "hat deiner Story eine Pfote gegeben."
},
"pets": {
"empty": "Noch keine Tiere angelegt.",
@@ -449,9 +452,37 @@
"whoCanSeeFollowers": "Wer darf die Follower von {{name}} sehen?",
"onlyMyFollowers": "Nur meine Follower",
"onlyMe": "Nur ich",
"followerEveryoneHint": "Jeder kann die Follower- und Folgt-Listen von {{name}} sehen.",
"followerFollowersOnlyHint": "Nur Tiere, die {{name}} folgen, können die Follower- und Folgt-Listen sehen.",
"followerNobodyHint": "Nur du kannst die Follower- und Folgt-Listen von {{name}} sehen.",
"followerEveryoneHint": "Jeder kann die Follower- und Folgt-Listen von {{name}} sehen und {{name}} in Storys markieren.",
"followerFollowersOnlyHint": "Nur Tiere, die {{name}} folgen, können die Follower- und Folgt-Listen sehen und {{name}} in Storys markieren.",
"followerNobodyHint": "Nur du kannst die Follower- und Folgt-Listen von {{name}} sehen — markieren in Storys kann {{name}} niemand.",
"saveFailed": "Privatsphäre-Einstellung konnte nicht gespeichert werden"
},
"storyEditor": {
"textStory": "Text-Story",
"addText": "Text hinzufügen",
"addMention": "Tier markieren",
"addGif": "GIF hinzufügen",
"addSticker": "Sticker hinzufügen",
"background": "Hintergrund wechseln",
"emptyHint": "Füge Text, @Markierungen, GIFs oder Sticker hinzu — oder teile direkt.",
"textPlaceholder": "Schreib etwas …",
"textStyle": "Textstil",
"textFont": "Schrift",
"style": {
"plain": "Schlicht",
"box": "Box",
"soft": "Weich"
},
"mentionTitle": "Tier markieren",
"mentionSearch": "Tiername suchen",
"mentionNoResults": "Kein Tier gefunden, das du markieren darfst.",
"mentionPrivacyHint": "Tiere, deren Follower nur für ihre Follower oder niemanden sichtbar sind, kannst du nur markieren, wenn du ihnen folgst bzw. gar nicht.",
"gifTitle": "GIF auswählen",
"gifSearch": "GIPHY durchsuchen",
"gifError": "Die GIF-Suche ist gerade nicht erreichbar.",
"stickerTitle": "Sticker",
"limitOverlays": "Höchstens {{max}} Elemente pro Story.",
"limitMentions": "Höchstens {{max}} Tiere pro Story markieren.",
"limitGifs": "Höchstens {{max}} GIFs pro Story."
}
}
+35 -4
View File
@@ -210,7 +210,10 @@
"petSuggestion": "might be a good match!",
"empty": "No notifications.",
"referralJoined": "joined PawFeed through your invite.",
"referralRankUp": "You reached a new referral rank!"
"referralRankUp": "You reached a new referral rank!",
"storyMention": "tagged you in a story.",
"storyComment": "commented on your story.",
"storyReaction": "gave your story a paw."
},
"pets": {
"empty": "No pets added yet.",
@@ -449,9 +452,37 @@
"whoCanSeeFollowers": "Who can see {{name}}'s followers?",
"onlyMyFollowers": "Only my followers",
"onlyMe": "Only me",
"followerEveryoneHint": "Anyone can see {{name}}'s followers and following lists.",
"followerFollowersOnlyHint": "Only pets that follow {{name}} can see the followers and following lists.",
"followerNobodyHint": "Only you can see {{name}}'s followers and following lists.",
"followerEveryoneHint": "Anyone can see {{name}}'s followers and following lists and tag {{name}} in stories.",
"followerFollowersOnlyHint": "Only pets that follow {{name}} can see the followers and following lists and tag {{name}} in stories.",
"followerNobodyHint": "Only you can see {{name}}'s followers and following lists — nobody can tag {{name}} in stories.",
"saveFailed": "Failed to save privacy setting"
},
"storyEditor": {
"textStory": "Text story",
"addText": "Add text",
"addMention": "Tag a pet",
"addGif": "Add GIF",
"addSticker": "Add sticker",
"background": "Change background",
"emptyHint": "Add text, @tags, GIFs or stickers — or share right away.",
"textPlaceholder": "Write something …",
"textStyle": "Text style",
"textFont": "Font",
"style": {
"plain": "Plain",
"box": "Box",
"soft": "Soft"
},
"mentionTitle": "Tag a pet",
"mentionSearch": "Search pet name",
"mentionNoResults": "No pet found that you're allowed to tag.",
"mentionPrivacyHint": "Pets whose followers are visible only to their followers or nobody can only be tagged if you follow them, or not at all.",
"gifTitle": "Choose a GIF",
"gifSearch": "Search GIPHY",
"gifError": "GIF search is unavailable right now.",
"stickerTitle": "Stickers",
"limitOverlays": "At most {{max}} items per story.",
"limitMentions": "Tag at most {{max}} pets per story.",
"limitGifs": "At most {{max}} GIFs per story."
}
}