wip(mobile): lint and format
This commit is contained in:
@@ -1,16 +1,16 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import { Pressable, StyleSheet, Text, View } from "react-native";
|
||||
import { Mic } from "lucide-react-native";
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { Pressable, StyleSheet, Text, View } from 'react-native';
|
||||
import { Mic } from 'lucide-react-native';
|
||||
import {
|
||||
RecordingPresets,
|
||||
useAudioRecorder,
|
||||
useAudioRecorderState,
|
||||
} from "expo-audio";
|
||||
import { logError } from "@/lib/errors";
|
||||
} from 'expo-audio';
|
||||
import { logError } from '@/lib/errors';
|
||||
import {
|
||||
acquireRecordingAudioSession,
|
||||
releaseRecordingAudioSession,
|
||||
} from "@/lib/recording-audio-session";
|
||||
} from '@/lib/recording-audio-session';
|
||||
|
||||
const MAX_DURATION_S = 60;
|
||||
|
||||
@@ -36,7 +36,7 @@ export function AudioRecordingOverlay({
|
||||
if (!active) return;
|
||||
recorder.record();
|
||||
} catch (err) {
|
||||
logError(err, { scope: "compose.audio.start" });
|
||||
logError(err, { scope: 'compose.audio.start' });
|
||||
if (active) onCancel();
|
||||
}
|
||||
})();
|
||||
@@ -48,7 +48,7 @@ export function AudioRecordingOverlay({
|
||||
recorder.stop().catch(() => {});
|
||||
}
|
||||
void releaseRecordingAudioSession().catch((err) =>
|
||||
logError(err, { scope: "compose.audio.exit" }),
|
||||
logError(err, { scope: 'compose.audio.exit' }),
|
||||
);
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
@@ -56,23 +56,16 @@ export function AudioRecordingOverlay({
|
||||
|
||||
const elapsedMs = state.durationMillis ?? 0;
|
||||
|
||||
useEffect(() => {
|
||||
if (elapsedMs >= MAX_DURATION_S * 1000 && !finalizedRef.current) {
|
||||
void finish("commit");
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [elapsedMs]);
|
||||
|
||||
const finish = async (kind: "commit" | "cancel") => {
|
||||
const finish = async (kind: 'commit' | 'cancel') => {
|
||||
if (finalizedRef.current) return;
|
||||
finalizedRef.current = true;
|
||||
const durationMs = state.durationMillis ?? 0;
|
||||
try {
|
||||
await recorder.stop();
|
||||
} catch (err) {
|
||||
logError(err, { scope: "compose.audio.stop" });
|
||||
logError(err, { scope: 'compose.audio.stop' });
|
||||
}
|
||||
if (kind === "cancel") {
|
||||
if (kind === 'cancel') {
|
||||
onCancel();
|
||||
return;
|
||||
}
|
||||
@@ -84,6 +77,14 @@ export function AudioRecordingOverlay({
|
||||
onComplete({ uri, durationMs });
|
||||
};
|
||||
|
||||
// Auto-commit when we hit the max duration.
|
||||
useEffect(() => {
|
||||
if (elapsedMs >= MAX_DURATION_S * 1000 && !finalizedRef.current) {
|
||||
void finish('commit');
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [elapsedMs]);
|
||||
|
||||
const elapsedSec = Math.floor(elapsedMs / 1000);
|
||||
|
||||
return (
|
||||
@@ -97,22 +98,22 @@ export function AudioRecordingOverlay({
|
||||
</View>
|
||||
</View>
|
||||
<Text className="text-white mt-6 text-lg font-semibold">
|
||||
{state.isRecording ? "Recording" : "Starting…"}
|
||||
{state.isRecording ? 'Recording' : 'Starting…'}
|
||||
</Text>
|
||||
<Text className="text-white/60 mt-1 text-sm">
|
||||
{elapsedSec.toString().padStart(2, "0")}s · max {MAX_DURATION_S}s
|
||||
{elapsedSec.toString().padStart(2, '0')}s · max {MAX_DURATION_S}s
|
||||
</Text>
|
||||
|
||||
<View className="absolute inset-x-0 bottom-12 flex-row items-center justify-around px-8">
|
||||
<Pressable
|
||||
onPress={() => void finish("cancel")}
|
||||
onPress={() => void finish('cancel')}
|
||||
accessibilityLabel="Cancel recording"
|
||||
className="rounded-full bg-white/15 px-6 py-3"
|
||||
>
|
||||
<Text className="text-white text-base font-medium">Cancel</Text>
|
||||
</Pressable>
|
||||
<Pressable
|
||||
onPress={() => void finish("commit")}
|
||||
onPress={() => void finish('commit')}
|
||||
accessibilityLabel="Stop recording"
|
||||
className="rounded-full bg-white px-7 py-3"
|
||||
>
|
||||
|
||||
@@ -1,43 +1,37 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { Pressable, Text, View } from "react-native";
|
||||
import { Mic, Type as TypeIcon, Video as VideoIcon } from "lucide-react-native";
|
||||
import * as Haptics from "expo-haptics";
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { Pressable, Text, View } from 'react-native';
|
||||
import { Mic, Type as TypeIcon, Video as VideoIcon } from 'lucide-react-native';
|
||||
import * as Haptics from 'expo-haptics';
|
||||
import { useCameraPermissions, useMicrophonePermissions } from 'expo-camera';
|
||||
import { toast } from 'sonner-native';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useEvent } from '@/hooks/use-event';
|
||||
import { usePlaybackPauseStore } from '@/stores/playback-pause-store';
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
import { createTextParticle, uploadMediaParticle } from '@/lib/upload';
|
||||
import type { ParticlePath } from '@/lib/particle-path';
|
||||
import {
|
||||
useCameraPermissions,
|
||||
useMicrophonePermissions,
|
||||
} from "expo-camera";
|
||||
import { toast } from "sonner-native";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useEvent } from "@/hooks/use-event";
|
||||
import { usePlaybackPauseStore } from "@/stores/playback-pause-store";
|
||||
import { useAuthStore } from "@/stores/auth-store";
|
||||
import {
|
||||
createTextParticle,
|
||||
uploadMediaParticle,
|
||||
} from "@/lib/upload";
|
||||
import type { ParticlePath } from "@/lib/particle-path";
|
||||
import {
|
||||
useStreamComposingBroadcast,
|
||||
useStreamComposingBroadcastOptional,
|
||||
type ComposingMode,
|
||||
} from "@/features/stream-view/stream-presence-context";
|
||||
import { TextComposeModal } from "./TextComposeModal";
|
||||
import { VideoRecordingOverlay } from "./VideoRecordingOverlay";
|
||||
import { AudioRecordingOverlay } from "./AudioRecordingOverlay";
|
||||
import { ReviewSheet } from "./ReviewSheet";
|
||||
} from '@/features/stream-view/stream-presence-context';
|
||||
import { TextComposeModal } from './TextComposeModal';
|
||||
import { VideoRecordingOverlay } from './VideoRecordingOverlay';
|
||||
import { AudioRecordingOverlay } from './AudioRecordingOverlay';
|
||||
import { ReviewSheet } from './ReviewSheet';
|
||||
|
||||
type RecordingMode = "video" | "audio";
|
||||
type RecordingMode = 'video' | 'audio';
|
||||
|
||||
type ComposeUiState =
|
||||
| { kind: "idle" }
|
||||
| { kind: "recording"; mode: RecordingMode }
|
||||
| { kind: 'idle' }
|
||||
| { kind: 'recording'; mode: RecordingMode }
|
||||
| {
|
||||
kind: "review";
|
||||
kind: 'review';
|
||||
mode: RecordingMode;
|
||||
uri: string;
|
||||
durationMs: number;
|
||||
}
|
||||
| {
|
||||
kind: "uploading";
|
||||
kind: 'uploading';
|
||||
mode: RecordingMode;
|
||||
uri: string;
|
||||
durationMs: number;
|
||||
@@ -47,7 +41,7 @@ interface SubmitMediaParams {
|
||||
fileUri: string;
|
||||
mimeType: string;
|
||||
durationMs: number;
|
||||
source: "camera" | "screen";
|
||||
source: 'camera' | 'screen';
|
||||
}
|
||||
|
||||
interface ComposeDockProps {
|
||||
@@ -75,8 +69,8 @@ export function ComposeDock({
|
||||
}: ComposeDockProps) {
|
||||
const userId = useAuthStore((s) => s.user?.id);
|
||||
|
||||
const [mode, setMode] = useState<RecordingMode>("video");
|
||||
const [ui, setUi] = useState<ComposeUiState>({ kind: "idle" });
|
||||
const [mode, setMode] = useState<RecordingMode>('video');
|
||||
const [ui, setUi] = useState<ComposeUiState>({ kind: 'idle' });
|
||||
const [textOpen, setTextOpen] = useState(false);
|
||||
|
||||
const [camPerm, requestCamPerm] = useCameraPermissions();
|
||||
@@ -85,7 +79,7 @@ export function ComposeDock({
|
||||
// Tell StreamView to fully unmount its expo-video player while we record.
|
||||
// That player otherwise holds the iOS AVAudioSession and crashes the camera.
|
||||
const setComposing = usePlaybackPauseStore((s) => s.setComposing);
|
||||
const isComposing = ui.kind !== "idle" || textOpen;
|
||||
const isComposing = ui.kind !== 'idle' || textOpen;
|
||||
useEffect(() => {
|
||||
setComposing(isComposing);
|
||||
return () => setComposing(false);
|
||||
@@ -98,13 +92,13 @@ export function ComposeDock({
|
||||
if (forVideo) {
|
||||
const cam = camPerm?.granted ? camPerm : await requestCamPerm();
|
||||
if (!cam.granted) {
|
||||
toast.error("Camera permission is required to record video.");
|
||||
toast.error('Camera permission is required to record video.');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
const mic = micPerm?.granted ? micPerm : await requestMicPerm();
|
||||
if (!mic.granted) {
|
||||
toast.error("Microphone permission is required to record.");
|
||||
toast.error('Microphone permission is required to record.');
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
@@ -113,46 +107,45 @@ export function ComposeDock({
|
||||
);
|
||||
|
||||
const startRecording = useEvent(async () => {
|
||||
if (ui.kind !== "idle") return;
|
||||
const ok = await ensurePermissions(mode === "video");
|
||||
if (ui.kind !== 'idle') return;
|
||||
const ok = await ensurePermissions(mode === 'video');
|
||||
if (!ok) return;
|
||||
void Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium);
|
||||
setUi({ kind: "recording", mode });
|
||||
setUi({ kind: 'recording', mode });
|
||||
});
|
||||
|
||||
const handleRecordingComplete = useCallback(
|
||||
({ uri, durationMs }: { uri: string; durationMs: number }) => {
|
||||
void Haptics.selectionAsync();
|
||||
setUi((prev) => {
|
||||
const m = "mode" in prev ? prev.mode : mode;
|
||||
return { kind: "review", mode: m, uri, durationMs };
|
||||
const m = 'mode' in prev ? prev.mode : mode;
|
||||
return { kind: 'review', mode: m, uri, durationMs };
|
||||
});
|
||||
},
|
||||
[mode],
|
||||
);
|
||||
|
||||
const handleRecordingCancel = useCallback(() => {
|
||||
setUi({ kind: "idle" });
|
||||
setUi({ kind: 'idle' });
|
||||
}, []);
|
||||
|
||||
const sendReview = useEvent(async () => {
|
||||
if (ui.kind !== "review" || !userId) return;
|
||||
if (ui.kind !== 'review' || !userId) return;
|
||||
const captured = ui;
|
||||
setUi({
|
||||
kind: "uploading",
|
||||
kind: 'uploading',
|
||||
mode: captured.mode,
|
||||
uri: captured.uri,
|
||||
durationMs: captured.durationMs,
|
||||
});
|
||||
try {
|
||||
const mimeType =
|
||||
captured.mode === "audio" ? "audio/mp4" : "video/mp4";
|
||||
const mimeType = captured.mode === 'audio' ? 'audio/mp4' : 'video/mp4';
|
||||
if (submitMedia) {
|
||||
await submitMedia({
|
||||
fileUri: captured.uri,
|
||||
mimeType,
|
||||
durationMs: captured.durationMs,
|
||||
source: "camera",
|
||||
source: 'camera',
|
||||
});
|
||||
} else {
|
||||
const particleId = await uploadMediaParticle({
|
||||
@@ -161,13 +154,13 @@ export function ComposeDock({
|
||||
fileUri: captured.uri,
|
||||
mimeType,
|
||||
durationMs: captured.durationMs,
|
||||
source: "camera",
|
||||
source: 'camera',
|
||||
createdByHumanId: userId,
|
||||
});
|
||||
onParticleCreated?.(particleId);
|
||||
}
|
||||
void Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
|
||||
setUi({ kind: "idle" });
|
||||
setUi({ kind: 'idle' });
|
||||
} catch (err) {
|
||||
void Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error);
|
||||
setUi(captured);
|
||||
@@ -175,11 +168,11 @@ export function ComposeDock({
|
||||
}
|
||||
});
|
||||
|
||||
const retake = useCallback(() => setUi({ kind: "idle" }), []);
|
||||
const cancelReview = useCallback(() => setUi({ kind: "idle" }), []);
|
||||
const retake = useCallback(() => setUi({ kind: 'idle' }), []);
|
||||
const cancelReview = useCallback(() => setUi({ kind: 'idle' }), []);
|
||||
|
||||
const submitText = useEvent(async (content: string) => {
|
||||
if (!userId) throw new Error("Not signed in.");
|
||||
if (!userId) throw new Error('Not signed in.');
|
||||
if (submitTextOverride) {
|
||||
await submitTextOverride(content);
|
||||
} else {
|
||||
@@ -195,9 +188,7 @@ export function ComposeDock({
|
||||
});
|
||||
|
||||
const dockHidden =
|
||||
ui.kind === "review" ||
|
||||
ui.kind === "uploading" ||
|
||||
ui.kind === "recording";
|
||||
ui.kind === 'review' || ui.kind === 'uploading' || ui.kind === 'recording';
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -209,18 +200,18 @@ export function ComposeDock({
|
||||
>
|
||||
<Pressable
|
||||
onPress={() =>
|
||||
setMode((m) => (m === "video" ? "audio" : "video"))
|
||||
setMode((m) => (m === 'video' ? 'audio' : 'video'))
|
||||
}
|
||||
disabled={ui.kind !== "idle"}
|
||||
disabled={ui.kind !== 'idle'}
|
||||
accessibilityLabel={`Switch to ${
|
||||
mode === "video" ? "audio" : "video"
|
||||
mode === 'video' ? 'audio' : 'video'
|
||||
} mode`}
|
||||
className={cn(
|
||||
"h-11 w-11 items-center justify-center rounded-full bg-white/15",
|
||||
ui.kind !== "idle" && "opacity-40",
|
||||
'h-11 w-11 items-center justify-center rounded-full bg-white/15',
|
||||
ui.kind !== 'idle' && 'opacity-40',
|
||||
)}
|
||||
>
|
||||
{mode === "video" ? (
|
||||
{mode === 'video' ? (
|
||||
<VideoIcon color="white" size={20} strokeWidth={1.6} />
|
||||
) : (
|
||||
<Mic color="white" size={20} strokeWidth={1.6} />
|
||||
@@ -230,24 +221,22 @@ export function ComposeDock({
|
||||
<View className="items-center">
|
||||
<Pressable
|
||||
onPress={startRecording}
|
||||
disabled={ui.kind !== "idle"}
|
||||
disabled={ui.kind !== 'idle'}
|
||||
accessibilityLabel={`Record ${mode}`}
|
||||
className="h-20 w-20 items-center justify-center rounded-full bg-white"
|
||||
>
|
||||
<View className="h-6 w-6 rounded bg-black" />
|
||||
</Pressable>
|
||||
<Text className="text-white/60 mt-2 text-xs">
|
||||
Tap to record
|
||||
</Text>
|
||||
<Text className="text-white/60 mt-2 text-xs">Tap to record</Text>
|
||||
</View>
|
||||
|
||||
<Pressable
|
||||
onPress={() => setTextOpen(true)}
|
||||
disabled={ui.kind !== "idle"}
|
||||
disabled={ui.kind !== 'idle'}
|
||||
accessibilityLabel="Compose text"
|
||||
className={cn(
|
||||
"h-11 w-11 items-center justify-center rounded-full bg-white/15",
|
||||
ui.kind !== "idle" && "opacity-40",
|
||||
'h-11 w-11 items-center justify-center rounded-full bg-white/15',
|
||||
ui.kind !== 'idle' && 'opacity-40',
|
||||
)}
|
||||
>
|
||||
<TypeIcon color="white" size={20} strokeWidth={1.6} />
|
||||
@@ -256,8 +245,8 @@ export function ComposeDock({
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
{ui.kind === "recording" ? (
|
||||
ui.mode === "video" ? (
|
||||
{ui.kind === 'recording' ? (
|
||||
ui.mode === 'video' ? (
|
||||
<VideoRecordingOverlay
|
||||
onComplete={handleRecordingComplete}
|
||||
onCancel={handleRecordingCancel}
|
||||
@@ -271,17 +260,13 @@ export function ComposeDock({
|
||||
) : null}
|
||||
|
||||
<ReviewSheet
|
||||
open={ui.kind === "review" || ui.kind === "uploading"}
|
||||
uri={
|
||||
ui.kind === "review" || ui.kind === "uploading" ? ui.uri : null
|
||||
}
|
||||
mode={
|
||||
ui.kind === "review" || ui.kind === "uploading" ? ui.mode : null
|
||||
}
|
||||
open={ui.kind === 'review' || ui.kind === 'uploading'}
|
||||
uri={ui.kind === 'review' || ui.kind === 'uploading' ? ui.uri : null}
|
||||
mode={ui.kind === 'review' || ui.kind === 'uploading' ? ui.mode : null}
|
||||
durationMs={
|
||||
ui.kind === "review" || ui.kind === "uploading" ? ui.durationMs : 0
|
||||
ui.kind === 'review' || ui.kind === 'uploading' ? ui.durationMs : 0
|
||||
}
|
||||
sending={ui.kind === "uploading"}
|
||||
sending={ui.kind === 'uploading'}
|
||||
onSend={sendReview}
|
||||
onRetake={retake}
|
||||
onCancel={cancelReview}
|
||||
@@ -305,15 +290,11 @@ function useComposingBroadcast({
|
||||
textOpen: boolean;
|
||||
silent: boolean;
|
||||
}) {
|
||||
let broadcast: ReturnType<typeof useStreamComposingBroadcast> | null;
|
||||
try {
|
||||
broadcast = useStreamComposingBroadcast();
|
||||
} catch {
|
||||
broadcast = null;
|
||||
}
|
||||
// null when the dock is rendered outside a stream (no presence provider).
|
||||
const broadcast = useStreamComposingBroadcastOptional();
|
||||
|
||||
const mode: ComposingMode | null =
|
||||
ui.kind === "recording" ? "recording" : textOpen ? "typing" : null;
|
||||
ui.kind === 'recording' ? 'recording' : textOpen ? 'typing' : null;
|
||||
|
||||
useEffect(() => {
|
||||
if (silent || !broadcast) return;
|
||||
|
||||
@@ -1,21 +1,21 @@
|
||||
import { useEffect } from "react";
|
||||
import { ActivityIndicator, Modal, Pressable, Text, View } from "react-native";
|
||||
import { useEffect } from 'react';
|
||||
import { ActivityIndicator, Modal, Pressable, Text, View } from 'react-native';
|
||||
import {
|
||||
initialWindowMetrics,
|
||||
SafeAreaProvider,
|
||||
SafeAreaView,
|
||||
} from "react-native-safe-area-context";
|
||||
import { useVideoPlayer, VideoView } from "expo-video";
|
||||
import { Mic } from "lucide-react-native";
|
||||
import { toast } from "sonner-native";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { toUserMessage } from "@/lib/errors";
|
||||
} from 'react-native-safe-area-context';
|
||||
import { useVideoPlayer, VideoView } from 'expo-video';
|
||||
import { Mic } from 'lucide-react-native';
|
||||
import { toast } from 'sonner-native';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { toUserMessage } from '@/lib/errors';
|
||||
|
||||
interface ReviewSheetProps {
|
||||
open: boolean;
|
||||
/** Local file URI from the recorder. */
|
||||
uri: string | null;
|
||||
mode: "video" | "audio" | null;
|
||||
mode: 'video' | 'audio' | null;
|
||||
durationMs: number;
|
||||
/**
|
||||
* True once the parent has flipped to the uploading state. The sheet stays
|
||||
@@ -43,10 +43,10 @@ export function ReviewSheet({
|
||||
onRetake,
|
||||
onCancel,
|
||||
}: ReviewSheetProps) {
|
||||
const player = useVideoPlayer(uri ?? "", (p) => {
|
||||
const player = useVideoPlayer(uri ?? '', (p) => {
|
||||
p.loop = true;
|
||||
p.muted = false;
|
||||
p.audioMixingMode = "mixWithOthers";
|
||||
p.audioMixingMode = 'mixWithOthers';
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
@@ -77,102 +77,102 @@ export function ReviewSheet({
|
||||
onRequestClose={sending ? undefined : onCancel}
|
||||
>
|
||||
<SafeAreaProvider initialMetrics={initialWindowMetrics}>
|
||||
<View className="flex-1 bg-black">
|
||||
{uri ? (
|
||||
mode === "audio" ? (
|
||||
<View className="flex-1 items-center justify-center px-8">
|
||||
<View className="bg-white/10 h-28 w-28 items-center justify-center rounded-full">
|
||||
<Mic color="white" size={42} strokeWidth={1.5} />
|
||||
<View className="flex-1 bg-black">
|
||||
{uri ? (
|
||||
mode === 'audio' ? (
|
||||
<View className="flex-1 items-center justify-center px-8">
|
||||
<View className="bg-white/10 h-28 w-28 items-center justify-center rounded-full">
|
||||
<Mic color="white" size={42} strokeWidth={1.5} />
|
||||
</View>
|
||||
<Text className="text-white mt-6 text-lg font-semibold">
|
||||
Voice message · {seconds}s
|
||||
</Text>
|
||||
<Text className="text-white/50 mt-2 text-sm">
|
||||
Tap send to share, or retake.
|
||||
</Text>
|
||||
<View className="absolute" style={{ width: 1, height: 1 }}>
|
||||
<VideoView
|
||||
style={{ width: 1, height: 1 }}
|
||||
player={player}
|
||||
nativeControls={false}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
<Text className="text-white mt-6 text-lg font-semibold">
|
||||
Voice message · {seconds}s
|
||||
</Text>
|
||||
<Text className="text-white/50 mt-2 text-sm">
|
||||
Tap send to share, or retake.
|
||||
</Text>
|
||||
<View className="absolute" style={{ width: 1, height: 1 }}>
|
||||
<VideoView
|
||||
style={{ width: 1, height: 1 }}
|
||||
player={player}
|
||||
nativeControls={false}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
) : (
|
||||
<VideoView
|
||||
style={{ flex: 1 }}
|
||||
player={player}
|
||||
nativeControls={false}
|
||||
contentFit="cover"
|
||||
allowsFullscreen={false}
|
||||
allowsPictureInPicture={false}
|
||||
/>
|
||||
)
|
||||
) : null}
|
||||
) : (
|
||||
<VideoView
|
||||
style={{ flex: 1 }}
|
||||
player={player}
|
||||
nativeControls={false}
|
||||
contentFit="cover"
|
||||
allowsFullscreen={false}
|
||||
allowsPictureInPicture={false}
|
||||
/>
|
||||
)
|
||||
) : null}
|
||||
|
||||
<SafeAreaView
|
||||
edges={["top"]}
|
||||
className="absolute top-0 left-0 right-0"
|
||||
>
|
||||
<View className="px-4 pt-3">
|
||||
<Pressable
|
||||
onPress={onCancel}
|
||||
disabled={sending}
|
||||
hitSlop={12}
|
||||
accessibilityLabel="Cancel"
|
||||
>
|
||||
<Text
|
||||
className={cn(
|
||||
"text-base",
|
||||
sending ? "text-white/30" : "text-white/80",
|
||||
)}
|
||||
<SafeAreaView
|
||||
edges={['top']}
|
||||
className="absolute top-0 left-0 right-0"
|
||||
>
|
||||
<View className="px-4 pt-3">
|
||||
<Pressable
|
||||
onPress={onCancel}
|
||||
disabled={sending}
|
||||
hitSlop={12}
|
||||
accessibilityLabel="Cancel"
|
||||
>
|
||||
Cancel
|
||||
</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
</SafeAreaView>
|
||||
<Text
|
||||
className={cn(
|
||||
'text-base',
|
||||
sending ? 'text-white/30' : 'text-white/80',
|
||||
)}
|
||||
>
|
||||
Cancel
|
||||
</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
</SafeAreaView>
|
||||
|
||||
<SafeAreaView
|
||||
edges={["bottom"]}
|
||||
className="absolute bottom-0 left-0 right-0"
|
||||
>
|
||||
<View className="flex-row items-center justify-between px-6 pb-4 pt-3">
|
||||
<Pressable
|
||||
onPress={onRetake}
|
||||
disabled={sending}
|
||||
className={cn(
|
||||
"rounded-full bg-white/15 px-5 py-3",
|
||||
sending && "opacity-40",
|
||||
)}
|
||||
accessibilityLabel="Retake"
|
||||
>
|
||||
<Text className="text-white text-base font-medium">Retake</Text>
|
||||
</Pressable>
|
||||
<SafeAreaView
|
||||
edges={['bottom']}
|
||||
className="absolute bottom-0 left-0 right-0"
|
||||
>
|
||||
<View className="flex-row items-center justify-between px-6 pb-4 pt-3">
|
||||
<Pressable
|
||||
onPress={onRetake}
|
||||
disabled={sending}
|
||||
className={cn(
|
||||
'rounded-full bg-white/15 px-5 py-3',
|
||||
sending && 'opacity-40',
|
||||
)}
|
||||
accessibilityLabel="Retake"
|
||||
>
|
||||
<Text className="text-white text-base font-medium">Retake</Text>
|
||||
</Pressable>
|
||||
|
||||
<Pressable
|
||||
onPress={handleSend}
|
||||
disabled={sending}
|
||||
className={cn(
|
||||
"rounded-full px-7 py-3",
|
||||
sending ? "bg-white/40" : "bg-white",
|
||||
)}
|
||||
accessibilityLabel="Send"
|
||||
>
|
||||
<Text className="text-black text-base font-semibold">
|
||||
{sending ? "Sending..." : "Send"}
|
||||
</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
</SafeAreaView>
|
||||
<Pressable
|
||||
onPress={handleSend}
|
||||
disabled={sending}
|
||||
className={cn(
|
||||
'rounded-full px-7 py-3',
|
||||
sending ? 'bg-white/40' : 'bg-white',
|
||||
)}
|
||||
accessibilityLabel="Send"
|
||||
>
|
||||
<Text className="text-black text-base font-semibold">
|
||||
{sending ? 'Sending...' : 'Send'}
|
||||
</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
</SafeAreaView>
|
||||
|
||||
{sending ? (
|
||||
<View className="absolute inset-0 items-center justify-center bg-black/85">
|
||||
<ActivityIndicator color="white" />
|
||||
<Text className="text-white/70 mt-4 text-sm">Sending...</Text>
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
{sending ? (
|
||||
<View className="absolute inset-0 items-center justify-center bg-black/85">
|
||||
<ActivityIndicator color="white" />
|
||||
<Text className="text-white/70 mt-4 text-sm">Sending...</Text>
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
</SafeAreaProvider>
|
||||
</Modal>
|
||||
);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import {
|
||||
KeyboardAvoidingView,
|
||||
Modal,
|
||||
@@ -7,26 +7,23 @@ import {
|
||||
Text,
|
||||
TextInput,
|
||||
View,
|
||||
} from "react-native";
|
||||
} from 'react-native';
|
||||
import {
|
||||
initialWindowMetrics,
|
||||
SafeAreaProvider,
|
||||
SafeAreaView,
|
||||
} from "react-native-safe-area-context";
|
||||
import { toast } from "sonner-native";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { toUserMessage } from "@/lib/errors";
|
||||
} from 'react-native-safe-area-context';
|
||||
import { toast } from 'sonner-native';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { toUserMessage } from '@/lib/errors';
|
||||
|
||||
const IMMERSIVE_CHAR_LIMIT = 120;
|
||||
|
||||
function getImmersiveStyle(length: number) {
|
||||
if (length === 0)
|
||||
return { className: "text-3xl font-semibold leading-snug" };
|
||||
if (length < 30)
|
||||
return { className: "text-5xl font-semibold leading-tight" };
|
||||
if (length < 70)
|
||||
return { className: "text-3xl font-semibold leading-snug" };
|
||||
return { className: "text-2xl font-normal leading-snug" };
|
||||
if (length === 0) return { className: 'text-3xl font-semibold leading-snug' };
|
||||
if (length < 30) return { className: 'text-5xl font-semibold leading-tight' };
|
||||
if (length < 70) return { className: 'text-3xl font-semibold leading-snug' };
|
||||
return { className: 'text-2xl font-normal leading-snug' };
|
||||
}
|
||||
|
||||
interface TextComposeModalProps {
|
||||
@@ -50,20 +47,26 @@ export function TextComposeModal({
|
||||
onClose,
|
||||
onSubmit,
|
||||
}: TextComposeModalProps) {
|
||||
const [content, setContent] = useState("");
|
||||
const [content, setContent] = useState('');
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const inputRef = useRef<TextInput>(null);
|
||||
|
||||
// Reset whenever the modal opens fresh.
|
||||
useEffect(() => {
|
||||
const [prevOpen, setPrevOpen] = useState(open);
|
||||
if (open !== prevOpen) {
|
||||
setPrevOpen(open);
|
||||
if (open) {
|
||||
setContent("");
|
||||
setContent('');
|
||||
setSubmitting(false);
|
||||
// Re-focus on next tick; iOS occasionally drops the autoFocus call when
|
||||
// the modal animation is mid-flight.
|
||||
const t = setTimeout(() => inputRef.current?.focus(), 60);
|
||||
return () => clearTimeout(t);
|
||||
}
|
||||
}
|
||||
|
||||
// Re-focus on next tick; iOS occasionally drops the autoFocus call when the
|
||||
// modal animation is mid-flight.
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const t = setTimeout(() => inputRef.current?.focus(), 60);
|
||||
return () => clearTimeout(t);
|
||||
}, [open]);
|
||||
|
||||
const trimmed = content.trim();
|
||||
@@ -92,60 +95,60 @@ export function TextComposeModal({
|
||||
onRequestClose={onClose}
|
||||
>
|
||||
<SafeAreaProvider initialMetrics={initialWindowMetrics}>
|
||||
<SafeAreaView className="flex-1 bg-black" edges={["top", "bottom"]}>
|
||||
<KeyboardAvoidingView
|
||||
behavior={Platform.OS === "ios" ? "padding" : undefined}
|
||||
className="flex-1"
|
||||
>
|
||||
<View className="flex-row items-center justify-between px-4 py-3">
|
||||
<Pressable
|
||||
onPress={onClose}
|
||||
accessibilityLabel="Cancel"
|
||||
hitSlop={12}
|
||||
>
|
||||
<Text className="text-white/70 text-base">Cancel</Text>
|
||||
</Pressable>
|
||||
<Pressable
|
||||
onPress={handleSend}
|
||||
disabled={!canSend}
|
||||
hitSlop={12}
|
||||
accessibilityLabel="Send"
|
||||
>
|
||||
<Text
|
||||
className={cn(
|
||||
"text-base font-semibold",
|
||||
canSend ? "text-white" : "text-white/30",
|
||||
)}
|
||||
<SafeAreaView className="flex-1 bg-black" edges={['top', 'bottom']}>
|
||||
<KeyboardAvoidingView
|
||||
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
|
||||
className="flex-1"
|
||||
>
|
||||
<View className="flex-row items-center justify-between px-4 py-3">
|
||||
<Pressable
|
||||
onPress={onClose}
|
||||
accessibilityLabel="Cancel"
|
||||
hitSlop={12}
|
||||
>
|
||||
{submitting ? "Sending..." : "Send"}
|
||||
</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
<Text className="text-white/70 text-base">Cancel</Text>
|
||||
</Pressable>
|
||||
<Pressable
|
||||
onPress={handleSend}
|
||||
disabled={!canSend}
|
||||
hitSlop={12}
|
||||
accessibilityLabel="Send"
|
||||
>
|
||||
<Text
|
||||
className={cn(
|
||||
'text-base font-semibold',
|
||||
canSend ? 'text-white' : 'text-white/30',
|
||||
)}
|
||||
>
|
||||
{submitting ? 'Sending...' : 'Send'}
|
||||
</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
<View className="flex-1 justify-center px-6 pb-6">
|
||||
<TextInput
|
||||
ref={inputRef}
|
||||
value={content}
|
||||
onChangeText={setContent}
|
||||
placeholder="Type a message"
|
||||
placeholderTextColor="rgba(255,255,255,0.4)"
|
||||
multiline
|
||||
autoFocus
|
||||
autoCorrect
|
||||
autoCapitalize="sentences"
|
||||
editable={!submitting}
|
||||
scrollEnabled={!isImmersive}
|
||||
textAlignVertical={isImmersive ? "center" : "top"}
|
||||
style={{
|
||||
color: "white",
|
||||
textAlign: isImmersive ? "center" : "left",
|
||||
maxHeight: isImmersive ? undefined : 540,
|
||||
}}
|
||||
className={cn("text-white", style.className)}
|
||||
/>
|
||||
</View>
|
||||
</KeyboardAvoidingView>
|
||||
</SafeAreaView>
|
||||
<View className="flex-1 justify-center px-6 pb-6">
|
||||
<TextInput
|
||||
ref={inputRef}
|
||||
value={content}
|
||||
onChangeText={setContent}
|
||||
placeholder="Type a message"
|
||||
placeholderTextColor="rgba(255,255,255,0.4)"
|
||||
multiline
|
||||
autoFocus
|
||||
autoCorrect
|
||||
autoCapitalize="sentences"
|
||||
editable={!submitting}
|
||||
scrollEnabled={!isImmersive}
|
||||
textAlignVertical={isImmersive ? 'center' : 'top'}
|
||||
style={{
|
||||
color: 'white',
|
||||
textAlign: isImmersive ? 'center' : 'left',
|
||||
maxHeight: isImmersive ? undefined : 540,
|
||||
}}
|
||||
className={cn('text-white', style.className)}
|
||||
/>
|
||||
</View>
|
||||
</KeyboardAvoidingView>
|
||||
</SafeAreaView>
|
||||
</SafeAreaProvider>
|
||||
</Modal>
|
||||
);
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Platform, Pressable, StyleSheet, Text, View } from "react-native";
|
||||
import { CameraView, type CameraType } from "expo-camera";
|
||||
import { SwitchCamera } from "lucide-react-native";
|
||||
import { logError } from "@/lib/errors";
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { Platform, Pressable, StyleSheet, Text, View } from 'react-native';
|
||||
import { CameraView, type CameraType } from 'expo-camera';
|
||||
import { SwitchCamera } from 'lucide-react-native';
|
||||
import { logError } from '@/lib/errors';
|
||||
import {
|
||||
acquireRecordingAudioSession,
|
||||
releaseRecordingAudioSession,
|
||||
} from "@/lib/recording-audio-session";
|
||||
} from '@/lib/recording-audio-session';
|
||||
|
||||
const MAX_DURATION_S = 60;
|
||||
const VIDEO_BITRATE_BPS = 1_200_000;
|
||||
@@ -24,7 +24,7 @@ export function VideoRecordingOverlay({
|
||||
const [cameraReady, setCameraReady] = useState(false);
|
||||
const [recording, setRecording] = useState(false);
|
||||
const [elapsedMs, setElapsedMs] = useState(0);
|
||||
const [facing, setFacing] = useState<CameraType>("front");
|
||||
const [facing, setFacing] = useState<CameraType>('front');
|
||||
const startedAtRef = useRef<number | null>(null);
|
||||
const cancelledRef = useRef(false);
|
||||
|
||||
@@ -32,7 +32,7 @@ export function VideoRecordingOverlay({
|
||||
return () => {
|
||||
cancelledRef.current = true;
|
||||
void releaseRecordingAudioSession().catch((err) =>
|
||||
logError(err, { scope: "compose.video.exit" }),
|
||||
logError(err, { scope: 'compose.video.exit' }),
|
||||
);
|
||||
};
|
||||
}, []);
|
||||
@@ -44,7 +44,7 @@ export function VideoRecordingOverlay({
|
||||
try {
|
||||
await acquireRecordingAudioSession();
|
||||
} catch (err) {
|
||||
logError(err, { scope: "compose.video.audioSession" });
|
||||
logError(err, { scope: 'compose.video.audioSession' });
|
||||
onCancel();
|
||||
return;
|
||||
}
|
||||
@@ -56,16 +56,16 @@ export function VideoRecordingOverlay({
|
||||
try {
|
||||
result = await cam.recordAsync({
|
||||
maxDuration: MAX_DURATION_S,
|
||||
...(Platform.OS === "ios" ? { codec: "hvc1" as const } : {}),
|
||||
...(Platform.OS === 'ios' ? { codec: 'hvc1' as const } : {}),
|
||||
});
|
||||
} catch (err) {
|
||||
if (cancelledRef.current) return;
|
||||
logError(err, { scope: "compose.video.recordAsync" });
|
||||
logError(err, { scope: 'compose.video.recordAsync' });
|
||||
onCancel();
|
||||
return;
|
||||
} finally {
|
||||
void releaseRecordingAudioSession().catch((err) =>
|
||||
logError(err, { scope: "compose.video.release" }),
|
||||
logError(err, { scope: 'compose.video.release' }),
|
||||
);
|
||||
}
|
||||
if (cancelledRef.current) return;
|
||||
@@ -118,9 +118,7 @@ export function VideoRecordingOverlay({
|
||||
{!recording && cameraReady ? (
|
||||
<View className="absolute top-0 right-0 pt-14 pr-5">
|
||||
<Pressable
|
||||
onPress={() =>
|
||||
setFacing((f) => (f === "front" ? "back" : "front"))
|
||||
}
|
||||
onPress={() => setFacing((f) => (f === 'front' ? 'back' : 'front'))}
|
||||
accessibilityLabel="Flip camera"
|
||||
className="h-11 w-11 items-center justify-center rounded-full bg-white/15"
|
||||
>
|
||||
@@ -137,7 +135,7 @@ export function VideoRecordingOverlay({
|
||||
<View className="bg-red-500/90 flex-row items-center gap-2 rounded-full px-3 py-1.5">
|
||||
<View className="h-2 w-2 rounded-full bg-white" />
|
||||
<Text className="text-white text-xs font-semibold tracking-wide">
|
||||
REC · {elapsedSec.toString().padStart(2, "0")}s
|
||||
REC · {elapsedSec.toString().padStart(2, '0')}s
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
@@ -166,8 +164,8 @@ export function VideoRecordingOverlay({
|
||||
accessibilityLabel="Start recording"
|
||||
className={
|
||||
cameraReady
|
||||
? "h-20 w-20 items-center justify-center rounded-full bg-white"
|
||||
: "h-20 w-20 items-center justify-center rounded-full bg-white/40"
|
||||
? 'h-20 w-20 items-center justify-center rounded-full bg-white'
|
||||
: 'h-20 w-20 items-center justify-center rounded-full bg-white/40'
|
||||
}
|
||||
>
|
||||
<View className="h-16 w-16 rounded-full bg-red-500" />
|
||||
|
||||
Reference in New Issue
Block a user