mirror of
https://github.com/jjbrunton/Sauci.git
synced 2026-11-02 10:19:43 +00:00
The sender can require photo or audio proof when sending any dare, catalogue or custom. The recipient captures and uploads the proof as a new couple-scoped 'dare_proof' media kind and attaches it on submit; the server enforces ownership, kind, couple, and MIME-type match. The proof renders inline on the dare card for the sender to review before confirming completion. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
113 lines
4.2 KiB
TypeScript
113 lines
4.2 KiB
TypeScript
import { useCallback, useEffect, useState } from "react";
|
|
import { ActivityIndicator, Image, Pressable, StyleSheet, Text, View } from "react-native";
|
|
import { Ionicons } from "@expo/vector-icons";
|
|
import { Audio } from "expo-av";
|
|
import { getMediaUrl } from "../../../lib/mediaApi";
|
|
import { colors, featureColors, radius, spacing, typography } from "../../../theme";
|
|
import type { DareProofType } from "../types";
|
|
|
|
const ACCENT = featureColors.dares.accent;
|
|
|
|
export interface DareProofViewProps {
|
|
proofMediaId: string;
|
|
proofType: DareProofType;
|
|
}
|
|
|
|
/**
|
|
* Renders the attached proof inline on a dare card: the photo itself, or a
|
|
* play button for audio. Signed URLs are short-lived, so the URL is resolved
|
|
* lazily on first render (photo) or first play (audio).
|
|
*/
|
|
export function DareProofView({ proofMediaId, proofType }: DareProofViewProps) {
|
|
const [url, setUrl] = useState<string | null>(null);
|
|
const [failed, setFailed] = useState(false);
|
|
const [sound, setSound] = useState<Audio.Sound | null>(null);
|
|
const [playing, setPlaying] = useState(false);
|
|
const [loadingAudio, setLoadingAudio] = useState(false);
|
|
|
|
useEffect(() => {
|
|
if (proofType !== "photo") return;
|
|
let cancelled = false;
|
|
getMediaUrl(proofMediaId)
|
|
.then((result) => { if (!cancelled) setUrl(result.url); })
|
|
.catch(() => { if (!cancelled) setFailed(true); });
|
|
return () => { cancelled = true; };
|
|
}, [proofMediaId, proofType]);
|
|
|
|
useEffect(() => {
|
|
return () => { if (sound) void sound.unloadAsync(); };
|
|
}, [sound]);
|
|
|
|
const togglePlayback = useCallback(async () => {
|
|
try {
|
|
if (sound) {
|
|
const status = await sound.getStatusAsync();
|
|
if (status.isLoaded && status.isPlaying) {
|
|
await sound.pauseAsync();
|
|
setPlaying(false);
|
|
} else {
|
|
await sound.replayAsync();
|
|
setPlaying(true);
|
|
}
|
|
return;
|
|
}
|
|
setLoadingAudio(true);
|
|
const { url: signedUrl } = await getMediaUrl(proofMediaId);
|
|
const created = await Audio.Sound.createAsync(
|
|
{ uri: signedUrl },
|
|
{ shouldPlay: true },
|
|
(status) => {
|
|
if (status.isLoaded && status.didJustFinish) setPlaying(false);
|
|
},
|
|
);
|
|
setSound(created.sound);
|
|
setPlaying(true);
|
|
} catch {
|
|
setFailed(true);
|
|
} finally {
|
|
setLoadingAudio(false);
|
|
}
|
|
}, [sound, proofMediaId]);
|
|
|
|
if (failed) {
|
|
return <Text style={styles.failed}>Proof could not be loaded.</Text>;
|
|
}
|
|
|
|
if (proofType === "photo") {
|
|
return url ? (
|
|
<Image source={{ uri: url }} style={styles.photo} resizeMode="cover" />
|
|
) : (
|
|
<ActivityIndicator size="small" color={ACCENT} style={styles.loader} />
|
|
);
|
|
}
|
|
|
|
return (
|
|
<Pressable style={styles.audioRow} onPress={() => void togglePlayback()} disabled={loadingAudio}>
|
|
{loadingAudio ? (
|
|
<ActivityIndicator size="small" color={ACCENT} />
|
|
) : (
|
|
<Ionicons name={playing ? "pause-circle" : "play-circle"} size={28} color={ACCENT} />
|
|
)}
|
|
<Text style={styles.audioLabel}>{playing ? "Playing proof…" : "Play audio proof"}</Text>
|
|
</Pressable>
|
|
);
|
|
}
|
|
|
|
const styles = StyleSheet.create({
|
|
photo: {
|
|
width: "100%", height: 180, marginTop: spacing.sm,
|
|
borderRadius: radius.md, borderWidth: 1, borderColor: colors.border,
|
|
},
|
|
loader: { marginTop: spacing.sm, alignSelf: "flex-start" },
|
|
audioRow: {
|
|
flexDirection: "row", alignItems: "center", gap: spacing.sm,
|
|
marginTop: spacing.sm,
|
|
paddingHorizontal: spacing.md, paddingVertical: spacing.sm,
|
|
borderRadius: radius.md, borderWidth: 1, borderColor: colors.border,
|
|
backgroundColor: colors.background,
|
|
alignSelf: "flex-start",
|
|
},
|
|
audioLabel: { ...typography.subhead, color: colors.text, fontWeight: "600" },
|
|
failed: { ...typography.footnote, color: colors.textTertiary, marginTop: spacing.sm },
|
|
});
|