infra: add linting and formatting for js projects (#230)
* wip * wip * wip * format * wip(mobile): lint and format * cleanup * nits * nits * idiomatic react * nit * format all root files
This commit was merged in pull request #230.
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { useState } from "react";
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
KeyboardAvoidingView,
|
||||
Platform,
|
||||
@@ -6,32 +6,32 @@ import {
|
||||
Text,
|
||||
TextInput,
|
||||
View,
|
||||
} from "react-native";
|
||||
import { SafeAreaView } from "react-native-safe-area-context";
|
||||
import { useAuthStore } from "@/stores/auth-store";
|
||||
} from 'react-native';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
|
||||
type Step = "email" | "code";
|
||||
type Step = 'email' | 'code';
|
||||
|
||||
export function SignInScreen() {
|
||||
const [step, setStep] = useState<Step>("email");
|
||||
const [email, setEmail] = useState("");
|
||||
const [step, setStep] = useState<Step>('email');
|
||||
const [email, setEmail] = useState('');
|
||||
|
||||
return (
|
||||
<SafeAreaView className="flex-1 bg-background">
|
||||
<KeyboardAvoidingView
|
||||
behavior={Platform.OS === "ios" ? "padding" : undefined}
|
||||
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
|
||||
className="flex-1"
|
||||
>
|
||||
<View className="flex-1 justify-center px-6">
|
||||
{step === "email" ? (
|
||||
{step === 'email' ? (
|
||||
<EmailStep
|
||||
onCodeSent={(submittedEmail) => {
|
||||
setEmail(submittedEmail);
|
||||
setStep("code");
|
||||
setStep('code');
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<CodeStep email={email} onBack={() => setStep("email")} />
|
||||
<CodeStep email={email} onBack={() => setStep('email')} />
|
||||
)}
|
||||
</View>
|
||||
</KeyboardAvoidingView>
|
||||
@@ -40,7 +40,7 @@ export function SignInScreen() {
|
||||
}
|
||||
|
||||
function EmailStep({ onCodeSent }: { onCodeSent: (email: string) => void }) {
|
||||
const [email, setEmail] = useState("");
|
||||
const [email, setEmail] = useState('');
|
||||
const isRequestingCode = useAuthStore((s) => s.isRequestingCode);
|
||||
const error = useAuthStore((s) => s.error);
|
||||
const requestCode = useAuthStore((s) => s.requestCode);
|
||||
@@ -88,23 +88,21 @@ function EmailStep({ onCodeSent }: { onCodeSent: (email: string) => void }) {
|
||||
/>
|
||||
</View>
|
||||
|
||||
{error ? (
|
||||
<Text className="text-destructive text-sm">{error}</Text>
|
||||
) : null}
|
||||
{error ? <Text className="text-destructive text-sm">{error}</Text> : null}
|
||||
|
||||
<Pressable
|
||||
onPress={submit}
|
||||
disabled={disabled}
|
||||
className={`rounded-lg px-4 py-3.5 items-center ${
|
||||
disabled ? "bg-muted" : "bg-primary"
|
||||
disabled ? 'bg-muted' : 'bg-primary'
|
||||
}`}
|
||||
>
|
||||
<Text
|
||||
className={`text-base font-semibold ${
|
||||
disabled ? "text-muted-foreground" : "text-primary-foreground"
|
||||
disabled ? 'text-muted-foreground' : 'text-primary-foreground'
|
||||
}`}
|
||||
>
|
||||
{isRequestingCode ? "Sending..." : "Continue"}
|
||||
{isRequestingCode ? 'Sending...' : 'Continue'}
|
||||
</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
@@ -112,7 +110,7 @@ function EmailStep({ onCodeSent }: { onCodeSent: (email: string) => void }) {
|
||||
}
|
||||
|
||||
function CodeStep({ email, onBack }: { email: string; onBack: () => void }) {
|
||||
const [code, setCode] = useState("");
|
||||
const [code, setCode] = useState('');
|
||||
const isSigningIn = useAuthStore((s) => s.isSigningIn);
|
||||
const error = useAuthStore((s) => s.error);
|
||||
const signIn = useAuthStore((s) => s.signIn);
|
||||
@@ -135,7 +133,7 @@ function CodeStep({ email, onBack }: { email: string; onBack: () => void }) {
|
||||
Check your email
|
||||
</Text>
|
||||
<Text className="text-muted-foreground text-base">
|
||||
We sent a code to{" "}
|
||||
We sent a code to{' '}
|
||||
<Text className="text-foreground font-medium">{email}</Text>.
|
||||
</Text>
|
||||
</View>
|
||||
@@ -159,24 +157,22 @@ function CodeStep({ email, onBack }: { email: string; onBack: () => void }) {
|
||||
/>
|
||||
</View>
|
||||
|
||||
{error ? (
|
||||
<Text className="text-destructive text-sm">{error}</Text>
|
||||
) : null}
|
||||
{error ? <Text className="text-destructive text-sm">{error}</Text> : null}
|
||||
|
||||
<View className="gap-2">
|
||||
<Pressable
|
||||
onPress={submit}
|
||||
disabled={disabled}
|
||||
className={`rounded-lg px-4 py-3.5 items-center ${
|
||||
disabled ? "bg-muted" : "bg-primary"
|
||||
disabled ? 'bg-muted' : 'bg-primary'
|
||||
}`}
|
||||
>
|
||||
<Text
|
||||
className={`text-base font-semibold ${
|
||||
disabled ? "text-muted-foreground" : "text-primary-foreground"
|
||||
disabled ? 'text-muted-foreground' : 'text-primary-foreground'
|
||||
}`}
|
||||
>
|
||||
{isSigningIn ? "Signing in..." : "Sign in"}
|
||||
{isSigningIn ? 'Signing in...' : 'Sign in'}
|
||||
</Text>
|
||||
</Pressable>
|
||||
<Pressable
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -1,14 +1,8 @@
|
||||
import { useCallback, useEffect } from "react";
|
||||
import {
|
||||
Alert,
|
||||
Dimensions,
|
||||
Pressable,
|
||||
Text,
|
||||
View,
|
||||
} from "react-native";
|
||||
import type { Human } from "@/api/types";
|
||||
import { SafeAreaView } from "react-native-safe-area-context";
|
||||
import { StatusBar } from "expo-status-bar";
|
||||
import { useCallback, useEffect } from 'react';
|
||||
import { Alert, Dimensions, Pressable, Text, View } from 'react-native';
|
||||
import type { Human } from '@/api/types';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
import { StatusBar } from 'expo-status-bar';
|
||||
import {
|
||||
AudioSession,
|
||||
LiveKitRoom,
|
||||
@@ -17,14 +11,14 @@ import {
|
||||
useLocalParticipant,
|
||||
useRoomContext,
|
||||
useTracks,
|
||||
} from "@livekit/react-native";
|
||||
import type { TrackReferenceOrPlaceholder } from "@livekit/components-core";
|
||||
import { Track } from "livekit-client";
|
||||
import { Mic, MicOff, PhoneOff, Video, VideoOff } from "lucide-react-native";
|
||||
import { useNetwork } from "@/hooks/use-networks";
|
||||
import { resolveHumanDisplay } from "@/lib/humans";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { RootStackScreenProps } from "@/navigation/types";
|
||||
} from '@livekit/react-native';
|
||||
import type { TrackReferenceOrPlaceholder } from '@livekit/components-core';
|
||||
import { Track } from 'livekit-client';
|
||||
import { Mic, MicOff, PhoneOff, Video, VideoOff } from 'lucide-react-native';
|
||||
import { useNetwork } from '@/hooks/use-networks';
|
||||
import { resolveHumanDisplay } from '@/lib/humans';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { RootStackScreenProps } from '@/navigation/types';
|
||||
|
||||
/**
|
||||
* Mobile huddle screen — LiveKit room with a tile grid, basic mic/camera
|
||||
@@ -35,7 +29,7 @@ import type { RootStackScreenProps } from "@/navigation/types";
|
||||
export function HuddleScreen({
|
||||
route,
|
||||
navigation,
|
||||
}: RootStackScreenProps<"Huddle">) {
|
||||
}: RootStackScreenProps<'Huddle'>) {
|
||||
const { token, serverUrl, streamName, networkId } = route.params;
|
||||
|
||||
// iOS in particular requires us to bracket the room session with
|
||||
@@ -66,10 +60,10 @@ export function HuddleScreen({
|
||||
connect={true}
|
||||
audio={true}
|
||||
video={false}
|
||||
options={{ adaptiveStream: { pixelDensity: "screen" } }}
|
||||
options={{ adaptiveStream: { pixelDensity: 'screen' } }}
|
||||
onDisconnected={leave}
|
||||
onError={(err) => {
|
||||
Alert.alert("Huddle error", err.message ?? "Failed to connect.");
|
||||
Alert.alert('Huddle error', err.message ?? 'Failed to connect.');
|
||||
leave();
|
||||
}}
|
||||
>
|
||||
@@ -119,15 +113,18 @@ function HuddleRoom({ networkId, streamName, onLeave }: HuddleRoomProps) {
|
||||
}, [room, onLeave]);
|
||||
|
||||
return (
|
||||
<SafeAreaView className="flex-1" edges={["top", "bottom"]}>
|
||||
<SafeAreaView className="flex-1" edges={['top', 'bottom']}>
|
||||
<View className="flex-row items-center justify-between px-4 pt-2 pb-3">
|
||||
<View className="flex-1">
|
||||
<Text className="text-white text-base font-semibold" numberOfLines={1}>
|
||||
<Text
|
||||
className="text-white text-base font-semibold"
|
||||
numberOfLines={1}
|
||||
>
|
||||
{streamName}
|
||||
</Text>
|
||||
<Text className="text-white/60 text-xs mt-0.5">
|
||||
{tracks.length === 1
|
||||
? "1 participant"
|
||||
? '1 participant'
|
||||
: `${tracks.length} participants`}
|
||||
</Text>
|
||||
</View>
|
||||
@@ -139,7 +136,7 @@ function HuddleRoom({ networkId, streamName, onLeave }: HuddleRoomProps) {
|
||||
|
||||
<View className="flex-row items-center justify-center gap-4 px-4 py-4">
|
||||
<ControlButton
|
||||
label={isMicrophoneEnabled ? "Mute" : "Unmute"}
|
||||
label={isMicrophoneEnabled ? 'Mute' : 'Unmute'}
|
||||
active={isMicrophoneEnabled}
|
||||
onPress={toggleMic}
|
||||
icon={
|
||||
@@ -151,7 +148,7 @@ function HuddleRoom({ networkId, streamName, onLeave }: HuddleRoomProps) {
|
||||
}
|
||||
/>
|
||||
<ControlButton
|
||||
label={isCameraEnabled ? "Stop video" : "Start video"}
|
||||
label={isCameraEnabled ? 'Stop video' : 'Start video'}
|
||||
active={isCameraEnabled}
|
||||
onPress={toggleCamera}
|
||||
icon={
|
||||
@@ -182,7 +179,7 @@ function TileGrid({ tiles, humans }: TileGridProps) {
|
||||
// Compute a square-ish grid: 1 → 1col, 2 → 1col (stacked), 3-4 → 2col,
|
||||
// 5+ → 2col with scroll. Keeps each tile big enough on a phone screen.
|
||||
const columns = tiles.length <= 1 ? 1 : 2;
|
||||
const { width, height } = Dimensions.get("window");
|
||||
const { width, height } = Dimensions.get('window');
|
||||
const rows = Math.max(1, Math.ceil(tiles.length / columns));
|
||||
const tileWidth = (width - 16) / columns - 8;
|
||||
// Subtract approx chrome height (header + control bar ≈ 200px). This is a
|
||||
@@ -229,16 +226,12 @@ function Tile({
|
||||
return (
|
||||
<View
|
||||
className={cn(
|
||||
"flex-1 overflow-hidden rounded-2xl bg-neutral-900",
|
||||
isSpeaking && "border-2 border-emerald-400",
|
||||
'flex-1 overflow-hidden rounded-2xl bg-neutral-900',
|
||||
isSpeaking && 'border-2 border-emerald-400',
|
||||
)}
|
||||
>
|
||||
{hasVideo ? (
|
||||
<VideoTrack
|
||||
trackRef={tile}
|
||||
style={{ flex: 1 }}
|
||||
objectFit="cover"
|
||||
/>
|
||||
<VideoTrack trackRef={tile} style={{ flex: 1 }} objectFit="cover" />
|
||||
) : (
|
||||
<View className="flex-1 items-center justify-center">
|
||||
<View className="h-16 w-16 items-center justify-center rounded-full bg-neutral-700">
|
||||
@@ -261,7 +254,9 @@ function Tile({
|
||||
}
|
||||
|
||||
function trackKey(tile: TrackReferenceOrPlaceholder): string {
|
||||
const sid = isTrackReference(tile) ? tile.publication.trackSid : "placeholder";
|
||||
const sid = isTrackReference(tile)
|
||||
? tile.publication.trackSid
|
||||
: 'placeholder';
|
||||
return `${tile.participant.identity}:${tile.source}:${sid}`;
|
||||
}
|
||||
|
||||
@@ -270,7 +265,7 @@ interface ControlButtonProps {
|
||||
label: string;
|
||||
onPress: () => void;
|
||||
active?: boolean;
|
||||
tone?: "default" | "danger";
|
||||
tone?: 'default' | 'danger';
|
||||
}
|
||||
|
||||
function ControlButton({
|
||||
@@ -278,7 +273,7 @@ function ControlButton({
|
||||
label,
|
||||
onPress,
|
||||
active = false,
|
||||
tone = "default",
|
||||
tone = 'default',
|
||||
}: ControlButtonProps) {
|
||||
// Used purely for the visual state — destructive tone always wins so
|
||||
// "Leave" is unmistakable regardless of toggle state.
|
||||
@@ -287,16 +282,15 @@ function ControlButton({
|
||||
onPress={onPress}
|
||||
accessibilityLabel={label}
|
||||
className={cn(
|
||||
"h-14 w-14 items-center justify-center rounded-full",
|
||||
tone === "danger"
|
||||
? "bg-red-600 active:bg-red-700"
|
||||
'h-14 w-14 items-center justify-center rounded-full',
|
||||
tone === 'danger'
|
||||
? 'bg-red-600 active:bg-red-700'
|
||||
: active
|
||||
? "bg-white/20 active:bg-white/30"
|
||||
: "bg-white/10 active:bg-white/20",
|
||||
? 'bg-white/20 active:bg-white/30'
|
||||
: 'bg-white/10 active:bg-white/20',
|
||||
)}
|
||||
>
|
||||
{icon}
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { useCallback, useState } from "react";
|
||||
import { useNavigation } from "@react-navigation/native";
|
||||
import { toast } from "sonner-native";
|
||||
import { apiClient } from "@/api/client";
|
||||
import { toUserMessage } from "@/lib/errors";
|
||||
import type { RootStackParamList } from "@/navigation/types";
|
||||
import type { NativeStackNavigationProp } from "@react-navigation/native-stack";
|
||||
import { useCallback, useState } from 'react';
|
||||
import { useNavigation } from '@react-navigation/native';
|
||||
import { toast } from 'sonner-native';
|
||||
import { apiClient } from '@/api/client';
|
||||
import { toUserMessage } from '@/lib/errors';
|
||||
import type { RootStackParamList } from '@/navigation/types';
|
||||
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
|
||||
|
||||
/**
|
||||
* Mirrors desktop's `handleOpenHuddle` (stream-view.tsx) — fetch a fresh
|
||||
@@ -26,7 +26,7 @@ export function useOpenHuddle() {
|
||||
networkId,
|
||||
streamId,
|
||||
);
|
||||
navigation.navigate("Huddle", {
|
||||
navigation.navigate('Huddle', {
|
||||
networkId,
|
||||
streamId,
|
||||
streamName,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useEffect, useState } from 'react';
|
||||
import {
|
||||
Animated,
|
||||
Dimensions,
|
||||
@@ -7,15 +7,15 @@ import {
|
||||
Pressable,
|
||||
Text,
|
||||
View,
|
||||
} from "react-native";
|
||||
} from 'react-native';
|
||||
import {
|
||||
initialWindowMetrics,
|
||||
SafeAreaProvider,
|
||||
SafeAreaView,
|
||||
} from "react-native-safe-area-context";
|
||||
import { useAuthStore } from "@/stores/auth-store";
|
||||
} from 'react-native-safe-area-context';
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
|
||||
const SCREEN_WIDTH = Dimensions.get("window").width;
|
||||
const SCREEN_WIDTH = Dimensions.get('window').width;
|
||||
const DRAWER_WIDTH = Math.min(320, Math.round(SCREEN_WIDTH * 0.82));
|
||||
const ANIM_MS = 220;
|
||||
|
||||
@@ -26,13 +26,11 @@ interface DrawerProps {
|
||||
onNavigateSettings: () => void;
|
||||
}
|
||||
|
||||
export function Drawer({
|
||||
open,
|
||||
onClose,
|
||||
onNavigateAccount,
|
||||
}: DrawerProps) {
|
||||
const translateX = useRef(new Animated.Value(-DRAWER_WIDTH)).current;
|
||||
const backdropOpacity = useRef(new Animated.Value(0)).current;
|
||||
export function Drawer({ open, onClose, onNavigateAccount }: DrawerProps) {
|
||||
// Lazy-init so each Animated.Value is created once; the setters are never
|
||||
// called — the values are mutated internally by the native driver.
|
||||
const [translateX] = useState(() => new Animated.Value(-DRAWER_WIDTH));
|
||||
const [backdropOpacity] = useState(() => new Animated.Value(0));
|
||||
|
||||
useEffect(() => {
|
||||
Animated.parallel([
|
||||
@@ -55,7 +53,7 @@ export function Drawer({
|
||||
const signOut = useAuthStore((s) => s.signOut);
|
||||
const isSigningOut = useAuthStore((s) => s.isSigningOut);
|
||||
|
||||
const initials = user?.email_prefix.slice(0, 2).toUpperCase() ?? "??";
|
||||
const initials = user?.email_prefix.slice(0, 2).toUpperCase() ?? '??';
|
||||
|
||||
return (
|
||||
<Modal
|
||||
@@ -69,68 +67,68 @@ export function Drawer({
|
||||
inside reports {0,0,0,0} on the first frame and content snaps from
|
||||
the status bar down to the safe area once metrics resolve. */}
|
||||
<SafeAreaProvider initialMetrics={initialWindowMetrics}>
|
||||
<View className="flex-1">
|
||||
<Animated.View
|
||||
pointerEvents={open ? "auto" : "none"}
|
||||
style={{ opacity: backdropOpacity }}
|
||||
className="absolute inset-0 bg-black"
|
||||
>
|
||||
<Pressable className="flex-1" onPress={onClose} />
|
||||
</Animated.View>
|
||||
<View className="flex-1">
|
||||
<Animated.View
|
||||
pointerEvents={open ? 'auto' : 'none'}
|
||||
style={{ opacity: backdropOpacity }}
|
||||
className="absolute inset-0 bg-black"
|
||||
>
|
||||
<Pressable className="flex-1" onPress={onClose} />
|
||||
</Animated.View>
|
||||
|
||||
<Animated.View
|
||||
style={{
|
||||
width: DRAWER_WIDTH,
|
||||
transform: [{ translateX }],
|
||||
}}
|
||||
className="absolute left-0 top-0 bottom-0 bg-sidebar"
|
||||
>
|
||||
<SafeAreaView edges={["top", "bottom", "left"]} className="flex-1">
|
||||
<View className="border-sidebar-border border-b px-5 py-5 flex-row items-center gap-3">
|
||||
<View className="bg-sidebar-accent h-10 w-10 items-center justify-center rounded-full">
|
||||
<Text className="text-sidebar-accent-foreground text-sm font-semibold">
|
||||
{initials}
|
||||
</Text>
|
||||
<Animated.View
|
||||
style={{
|
||||
width: DRAWER_WIDTH,
|
||||
transform: [{ translateX }],
|
||||
}}
|
||||
className="absolute left-0 top-0 bottom-0 bg-sidebar"
|
||||
>
|
||||
<SafeAreaView edges={['top', 'bottom', 'left']} className="flex-1">
|
||||
<View className="border-sidebar-border border-b px-5 py-5 flex-row items-center gap-3">
|
||||
<View className="bg-sidebar-accent h-10 w-10 items-center justify-center rounded-full">
|
||||
<Text className="text-sidebar-accent-foreground text-sm font-semibold">
|
||||
{initials}
|
||||
</Text>
|
||||
</View>
|
||||
<View className="flex-1">
|
||||
<Text
|
||||
className="text-sidebar-foreground text-base font-medium"
|
||||
numberOfLines={1}
|
||||
>
|
||||
{user?.email_prefix ?? ''}
|
||||
</Text>
|
||||
<Text
|
||||
className="text-muted-foreground text-xs"
|
||||
numberOfLines={1}
|
||||
>
|
||||
{user?.email ?? ''}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
<View className="flex-1">
|
||||
<Text
|
||||
className="text-sidebar-foreground text-base font-medium"
|
||||
numberOfLines={1}
|
||||
>
|
||||
{user?.email_prefix ?? ""}
|
||||
</Text>
|
||||
<Text
|
||||
className="text-muted-foreground text-xs"
|
||||
numberOfLines={1}
|
||||
>
|
||||
{user?.email ?? ""}
|
||||
</Text>
|
||||
|
||||
<View className="flex-1 py-2">
|
||||
<DrawerRow
|
||||
label="Account"
|
||||
onPress={() => {
|
||||
onClose();
|
||||
onNavigateAccount();
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View className="flex-1 py-2">
|
||||
<DrawerRow
|
||||
label="Account"
|
||||
onPress={() => {
|
||||
onClose();
|
||||
onNavigateAccount();
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View className="border-sidebar-border border-t px-2 py-2">
|
||||
<DrawerRow
|
||||
label={isSigningOut ? "Signing out..." : "Sign out"}
|
||||
disabled={isSigningOut}
|
||||
onPress={() => {
|
||||
void signOut();
|
||||
}}
|
||||
tone="destructive"
|
||||
/>
|
||||
</View>
|
||||
</SafeAreaView>
|
||||
</Animated.View>
|
||||
</View>
|
||||
<View className="border-sidebar-border border-t px-2 py-2">
|
||||
<DrawerRow
|
||||
label={isSigningOut ? 'Signing out...' : 'Sign out'}
|
||||
disabled={isSigningOut}
|
||||
onPress={() => {
|
||||
void signOut();
|
||||
}}
|
||||
tone="destructive"
|
||||
/>
|
||||
</View>
|
||||
</SafeAreaView>
|
||||
</Animated.View>
|
||||
</View>
|
||||
</SafeAreaProvider>
|
||||
</Modal>
|
||||
);
|
||||
@@ -140,12 +138,12 @@ function DrawerRow({
|
||||
label,
|
||||
onPress,
|
||||
disabled,
|
||||
tone = "default",
|
||||
tone = 'default',
|
||||
}: {
|
||||
label: string;
|
||||
onPress: () => void;
|
||||
disabled?: boolean;
|
||||
tone?: "default" | "destructive";
|
||||
tone?: 'default' | 'destructive';
|
||||
}) {
|
||||
return (
|
||||
<Pressable
|
||||
@@ -155,10 +153,10 @@ function DrawerRow({
|
||||
>
|
||||
<Text
|
||||
className={`text-base font-medium ${
|
||||
tone === "destructive"
|
||||
? "text-destructive"
|
||||
: "text-sidebar-foreground"
|
||||
} ${disabled ? "opacity-50" : ""}`}
|
||||
tone === 'destructive'
|
||||
? 'text-destructive'
|
||||
: 'text-sidebar-foreground'
|
||||
} ${disabled ? 'opacity-50' : ''}`}
|
||||
>
|
||||
{label}
|
||||
</Text>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useState } from "react";
|
||||
import { useCallback, useState } from 'react';
|
||||
import {
|
||||
ActivityIndicator,
|
||||
FlatList,
|
||||
@@ -6,24 +6,24 @@ import {
|
||||
RefreshControl,
|
||||
Text,
|
||||
View,
|
||||
} from "react-native";
|
||||
import { SafeAreaView } from "react-native-safe-area-context";
|
||||
import type { Network } from "@/api/types";
|
||||
import { useNetworks } from "@/hooks/use-networks";
|
||||
import { useAuthStore } from "@/stores/auth-store";
|
||||
import { toUserMessage } from "@/lib/errors";
|
||||
import type { RootStackScreenProps } from "@/navigation/types";
|
||||
import { FlowyLogo } from "@/components/FlowyLogo";
|
||||
import { ListSeparator } from "@/components/ListSeparator";
|
||||
import { Drawer } from "./Drawer";
|
||||
} from 'react-native';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
import type { Network } from '@/api/types';
|
||||
import { useNetworks } from '@/hooks/use-networks';
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
import { toUserMessage } from '@/lib/errors';
|
||||
import type { RootStackScreenProps } from '@/navigation/types';
|
||||
import { FlowyLogo } from '@/components/FlowyLogo';
|
||||
import { ListSeparator } from '@/components/ListSeparator';
|
||||
import { Drawer } from './Drawer';
|
||||
|
||||
export function NetworkListScreen({
|
||||
navigation,
|
||||
}: RootStackScreenProps<"NetworkList">) {
|
||||
}: RootStackScreenProps<'NetworkList'>) {
|
||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||
const { data, isLoading, refetch, error } = useNetworks();
|
||||
const user = useAuthStore((s) => s.user);
|
||||
const initials = user?.email_prefix.slice(0, 2).toUpperCase() ?? "??";
|
||||
const initials = user?.email_prefix.slice(0, 2).toUpperCase() ?? '??';
|
||||
|
||||
// Local refreshing state — driving RefreshControl from react-query's
|
||||
// isRefetching can leave the native spinner visually stuck after the
|
||||
@@ -39,7 +39,7 @@ export function NetworkListScreen({
|
||||
}, [refetch]);
|
||||
|
||||
return (
|
||||
<SafeAreaView className="flex-1 bg-background" edges={["top"]}>
|
||||
<SafeAreaView className="flex-1 bg-background" edges={['top']}>
|
||||
<View className="flex-row items-center justify-between px-4 py-3 border-b border-border">
|
||||
<Pressable
|
||||
onPress={() => setDrawerOpen(true)}
|
||||
@@ -81,7 +81,7 @@ export function NetworkListScreen({
|
||||
<NetworkCard
|
||||
network={item}
|
||||
onPress={() =>
|
||||
navigation.navigate("StreamList", { networkId: item.id })
|
||||
navigation.navigate('StreamList', { networkId: item.id })
|
||||
}
|
||||
/>
|
||||
)}
|
||||
@@ -91,8 +91,8 @@ export function NetworkListScreen({
|
||||
<Drawer
|
||||
open={drawerOpen}
|
||||
onClose={() => setDrawerOpen(false)}
|
||||
onNavigateAccount={() => navigation.navigate("Account")}
|
||||
onNavigateSettings={() => navigation.navigate("Settings")}
|
||||
onNavigateAccount={() => navigation.navigate('Account')}
|
||||
onNavigateSettings={() => navigation.navigate('Settings')}
|
||||
/>
|
||||
</SafeAreaView>
|
||||
);
|
||||
@@ -115,8 +115,8 @@ function NetworkCard({
|
||||
{network.name}
|
||||
</Text>
|
||||
<Text className="text-muted-foreground text-sm">
|
||||
{network.humans.length}{" "}
|
||||
{network.humans.length === 1 ? "member" : "members"}
|
||||
{network.humans.length}{' '}
|
||||
{network.humans.length === 1 ? 'member' : 'members'}
|
||||
</Text>
|
||||
</View>
|
||||
<Text className="text-muted-foreground text-xl">›</Text>
|
||||
@@ -128,7 +128,7 @@ function EmptyState() {
|
||||
return (
|
||||
<View className="flex-1 items-center justify-center px-6">
|
||||
<Text className="text-foreground text-lg font-medium text-center">
|
||||
You aren't in any networks yet.
|
||||
You aren’t in any networks yet.
|
||||
</Text>
|
||||
<Text className="text-muted-foreground mt-2 text-center">
|
||||
Ask a friend for an invite, or create one on desktop.
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import { Pressable, Text, View } from "react-native";
|
||||
import { SafeAreaView } from "react-native-safe-area-context";
|
||||
import { useAuthStore } from "@/stores/auth-store";
|
||||
import type { RootStackScreenProps } from "@/navigation/types";
|
||||
import { Pressable, Text, View } from 'react-native';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
import type { RootStackScreenProps } from '@/navigation/types';
|
||||
|
||||
export function AccountScreen({ navigation }: RootStackScreenProps<"Account">) {
|
||||
export function AccountScreen({ navigation }: RootStackScreenProps<'Account'>) {
|
||||
const user = useAuthStore((s) => s.user);
|
||||
|
||||
return (
|
||||
<SafeAreaView className="flex-1 bg-background" edges={["top"]}>
|
||||
<SafeAreaView className="flex-1 bg-background" edges={['top']}>
|
||||
<View className="flex-row items-center px-3 py-3 border-b border-border">
|
||||
<Pressable onPress={() => navigation.goBack()} className="px-2 py-1">
|
||||
<Text className="text-foreground text-2xl">‹</Text>
|
||||
@@ -19,7 +19,7 @@ export function AccountScreen({ navigation }: RootStackScreenProps<"Account">) {
|
||||
</View>
|
||||
|
||||
<View className="px-6 py-6 gap-4">
|
||||
<Field label="Email" value={user?.email ?? "—"} />
|
||||
<Field label="Email" value={user?.email ?? '—'} />
|
||||
</View>
|
||||
</SafeAreaView>
|
||||
);
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { Pressable, Text, View } from "react-native";
|
||||
import { SafeAreaView } from "react-native-safe-area-context";
|
||||
import type { RootStackScreenProps } from "@/navigation/types";
|
||||
import { Pressable, Text, View } from 'react-native';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
import type { RootStackScreenProps } from '@/navigation/types';
|
||||
|
||||
export function SettingsScreen({
|
||||
navigation,
|
||||
}: RootStackScreenProps<"Settings">) {
|
||||
}: RootStackScreenProps<'Settings'>) {
|
||||
return (
|
||||
<SafeAreaView className="flex-1 bg-background" edges={["top"]}>
|
||||
<SafeAreaView className="flex-1 bg-background" edges={['top']}>
|
||||
<View className="flex-row items-center px-3 py-3 border-b border-border">
|
||||
<Pressable onPress={() => navigation.goBack()} className="px-2 py-1">
|
||||
<Text className="text-foreground text-2xl">‹</Text>
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { useEffect } from "react";
|
||||
import { Text, View } from "react-native";
|
||||
import { Trash2 } from "lucide-react-native";
|
||||
import type { Particle } from "@/api/types";
|
||||
import { useNetwork } from "@/hooks/use-networks";
|
||||
import { resolveHumanDisplay } from "@/lib/humans";
|
||||
import { useEffect } from 'react';
|
||||
import { Text, View } from 'react-native';
|
||||
import { Trash2 } from 'lucide-react-native';
|
||||
import type { Particle } from '@/api/types';
|
||||
import { useNetwork } from '@/hooks/use-networks';
|
||||
import { resolveHumanDisplay } from '@/lib/humans';
|
||||
|
||||
// How long to linger on a tombstone before auto-advancing. Same cadence as
|
||||
// desktop — a beat long enough to read "this was deleted," not so long it
|
||||
@@ -25,7 +25,9 @@ export function DeletedParticleView({
|
||||
}: DeletedParticleViewProps) {
|
||||
const network = useNetwork(networkId);
|
||||
const deleterId =
|
||||
"deleted_by_human_id" in particle ? particle.deleted_by_human_id : undefined;
|
||||
'deleted_by_human_id' in particle
|
||||
? particle.deleted_by_human_id
|
||||
: undefined;
|
||||
const deleter = deleterId
|
||||
? resolveHumanDisplay(deleterId, network?.humans)
|
||||
: null;
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Pressable, Text, TextInput, View } from "react-native";
|
||||
import { toast } from "sonner-native";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { toUserMessage } from "@/lib/errors";
|
||||
import { editTextParticleContent } from "@/lib/firestore-particles";
|
||||
import { particlePath, toFirestoreDocPath } from "@/lib/particle-path";
|
||||
import { useSuspendPlayback } from "@/hooks/use-suspend-playback";
|
||||
import { BottomSheet } from "@/components/BottomSheet";
|
||||
import { useState } from 'react';
|
||||
import { Pressable, Text, TextInput, View } from 'react-native';
|
||||
import { toast } from 'sonner-native';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { toUserMessage } from '@/lib/errors';
|
||||
import { editTextParticleContent } from '@/lib/firestore-particles';
|
||||
import { particlePath, toFirestoreDocPath } from '@/lib/particle-path';
|
||||
import { useSuspendPlayback } from '@/hooks/use-suspend-playback';
|
||||
import { BottomSheet } from '@/components/BottomSheet';
|
||||
|
||||
interface EditParticleSheetProps {
|
||||
open: boolean;
|
||||
@@ -25,17 +25,20 @@ export function EditParticleSheet({
|
||||
particleId,
|
||||
currentContent,
|
||||
}: EditParticleSheetProps) {
|
||||
useSuspendPlayback(open, "edit-particle");
|
||||
useSuspendPlayback(open, 'edit-particle');
|
||||
|
||||
const [content, setContent] = useState(currentContent);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
// Reset the editor each time the sheet opens fresh.
|
||||
const [prevOpen, setPrevOpen] = useState(open);
|
||||
if (open !== prevOpen) {
|
||||
setPrevOpen(open);
|
||||
if (open) {
|
||||
setContent(currentContent);
|
||||
setSaving(false);
|
||||
}
|
||||
}, [open, currentContent]);
|
||||
}
|
||||
|
||||
const trimmed = content.trim();
|
||||
const canSave = !saving && trimmed.length > 0 && trimmed !== currentContent;
|
||||
@@ -65,11 +68,11 @@ export function EditParticleSheet({
|
||||
<Pressable onPress={handleSave} disabled={!canSave} hitSlop={12}>
|
||||
<Text
|
||||
className={cn(
|
||||
"text-base font-semibold",
|
||||
canSave ? "text-white" : "text-white/30",
|
||||
'text-base font-semibold',
|
||||
canSave ? 'text-white' : 'text-white/30',
|
||||
)}
|
||||
>
|
||||
{saving ? "Saving..." : "Save"}
|
||||
{saving ? 'Saving...' : 'Save'}
|
||||
</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
@@ -1,20 +1,20 @@
|
||||
import { useEffect } from "react";
|
||||
import { Text, View } from "react-native";
|
||||
import { useEffect } from 'react';
|
||||
import { Text, View } from 'react-native';
|
||||
import {
|
||||
FileIcon,
|
||||
HelpCircle,
|
||||
ScrollText,
|
||||
BookOpen,
|
||||
type LucideIcon,
|
||||
} from "lucide-react-native";
|
||||
import type { Particle } from "@/api/types";
|
||||
import { useNetwork } from "@/hooks/use-networks";
|
||||
import { resolveHumanDisplay } from "@/lib/humans";
|
||||
} from 'lucide-react-native';
|
||||
import type { Particle } from '@/api/types';
|
||||
import { useNetwork } from '@/hooks/use-networks';
|
||||
import { resolveHumanDisplay } from '@/lib/humans';
|
||||
|
||||
const TYPE_META: Record<string, { icon: LucideIcon; label: string }> = {
|
||||
quest: { icon: ScrollText, label: "Quest" },
|
||||
paper: { icon: BookOpen, label: "Paper" },
|
||||
file: { icon: FileIcon, label: "File" },
|
||||
quest: { icon: ScrollText, label: 'Quest' },
|
||||
paper: { icon: BookOpen, label: 'Paper' },
|
||||
file: { icon: FileIcon, label: 'File' },
|
||||
};
|
||||
|
||||
const PLACEHOLDER_DURATION_MS = 5000;
|
||||
@@ -44,13 +44,13 @@ export function FallbackParticleView({
|
||||
const Icon = meta.icon;
|
||||
const title = (() => {
|
||||
switch (particle.type) {
|
||||
case "quest":
|
||||
case 'quest':
|
||||
return particle.properties.title;
|
||||
case "paper":
|
||||
case 'paper':
|
||||
return particle.properties.title;
|
||||
case "file":
|
||||
case 'file':
|
||||
return particle.properties.filename;
|
||||
case "folder":
|
||||
case 'folder':
|
||||
return particle.properties.name;
|
||||
default:
|
||||
return null;
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { ActivityIndicator, Text, View } from "react-native";
|
||||
import { Mic, Video as VideoIcon } from "lucide-react-native";
|
||||
import { useEventListener } from "expo";
|
||||
import { useVideoPlayer, VideoView, type VideoPlayerStatus } from "expo-video";
|
||||
import type { Particle } from "@/api/types";
|
||||
import { apiClient } from "@/api/client";
|
||||
import { logError } from "@/lib/errors";
|
||||
import { useEvent } from "@/hooks/use-event";
|
||||
import { useTranscriptPlayback } from "@/hooks/use-transcript-playback";
|
||||
import { TranscriptOverlay } from "./TranscriptOverlay";
|
||||
import { useStreamSafeArea } from "./stream-safe-area";
|
||||
import { useEffect, useState } from 'react';
|
||||
import { ActivityIndicator, Text, View } from 'react-native';
|
||||
import { Mic, Video as VideoIcon } from 'lucide-react-native';
|
||||
import { useEventListener } from 'expo';
|
||||
import { useVideoPlayer, VideoView, type VideoPlayerStatus } from 'expo-video';
|
||||
import type { Particle } from '@/api/types';
|
||||
import { apiClient } from '@/api/client';
|
||||
import { logError } from '@/lib/errors';
|
||||
import { useEvent } from '@/hooks/use-event';
|
||||
import { useTranscriptPlayback } from '@/hooks/use-transcript-playback';
|
||||
import { TranscriptOverlay } from './TranscriptOverlay';
|
||||
import { useStreamSafeArea } from './stream-safe-area';
|
||||
|
||||
type MediaParticle = Extract<Particle, { type: "media" }>;
|
||||
type MediaParticle = Extract<Particle, { type: 'media' }>;
|
||||
|
||||
interface MediaParticleViewProps {
|
||||
particle: MediaParticle;
|
||||
@@ -19,7 +19,7 @@ interface MediaParticleViewProps {
|
||||
onEnded: () => void;
|
||||
onProgress: (ratio: number) => void;
|
||||
/** "cover" fills the screen (may crop); "contain" fits the whole frame. */
|
||||
contentFit?: "cover" | "contain";
|
||||
contentFit?: 'cover' | 'contain';
|
||||
}
|
||||
|
||||
const TICK_MS = 150;
|
||||
@@ -42,13 +42,13 @@ export function MediaParticleView({
|
||||
paused,
|
||||
onEnded,
|
||||
onProgress,
|
||||
contentFit = "cover",
|
||||
contentFit = 'cover',
|
||||
}: MediaParticleViewProps) {
|
||||
const activeObjectId =
|
||||
particle.properties.transcoded_object_id ?? particle.properties.object_id;
|
||||
const activeMime =
|
||||
particle.properties.transcoded_mime_type ?? particle.properties.mime_type;
|
||||
const isAudio = activeMime.startsWith("audio/");
|
||||
const isAudio = activeMime.startsWith('audio/');
|
||||
const isPlayable = isPlayableMime(activeMime);
|
||||
|
||||
// Reset progress as the active particle changes — independent of playback
|
||||
@@ -89,7 +89,7 @@ function PlayableMediaView({
|
||||
paused: boolean;
|
||||
onEnded: () => void;
|
||||
onProgress: (ratio: number) => void;
|
||||
contentFit: "cover" | "contain";
|
||||
contentFit: 'cover' | 'contain';
|
||||
}) {
|
||||
const [sourceUri, setSourceUri] = useState<string | null>(null);
|
||||
const [resolveError, setResolveError] = useState<Error | null>(null);
|
||||
@@ -101,24 +101,24 @@ function PlayableMediaView({
|
||||
// resolves a new active object id.
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setSourceUri(null);
|
||||
setResolveError(null);
|
||||
setCurrentTime(0);
|
||||
apiClient
|
||||
.getParticleDownloadUrl(activeObjectId)
|
||||
.then((url) => {
|
||||
if (!cancelled) setSourceUri(url);
|
||||
})
|
||||
.catch((err) => {
|
||||
logError(err, { scope: "media.download-url" });
|
||||
logError(err, { scope: 'media.download-url' });
|
||||
if (!cancelled) setResolveError(err as Error);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
setSourceUri(null);
|
||||
setResolveError(null);
|
||||
setCurrentTime(0);
|
||||
};
|
||||
}, [activeObjectId, particle.id]);
|
||||
|
||||
const player = useVideoPlayer(sourceUri ?? "", (p) => {
|
||||
const player = useVideoPlayer(sourceUri ?? '', (p) => {
|
||||
p.loop = false;
|
||||
p.muted = false;
|
||||
p.timeUpdateEventInterval = 0.15;
|
||||
@@ -126,7 +126,7 @@ function PlayableMediaView({
|
||||
// the player blocks expo-camera from acquiring the session for video
|
||||
// recording (audio works because expo-audio deactivates other sessions
|
||||
// natively before claiming the session).
|
||||
p.audioMixingMode = "mixWithOthers";
|
||||
p.audioMixingMode = 'mixWithOthers';
|
||||
});
|
||||
|
||||
// Drive play/pause from the suspender store. The player itself is forgiving
|
||||
@@ -142,8 +142,8 @@ function PlayableMediaView({
|
||||
|
||||
// End-of-clip → advance. We listen to status flips rather than computing
|
||||
// duration ratios because video duration may be 0 for the first frame or two.
|
||||
useEventListener(player, "statusChange", ({ status }) => {
|
||||
if (status === ("idle" satisfies VideoPlayerStatus)) {
|
||||
useEventListener(player, 'statusChange', ({ status }) => {
|
||||
if (status === ('idle' satisfies VideoPlayerStatus)) {
|
||||
// ignored — happens during source swap
|
||||
}
|
||||
});
|
||||
@@ -151,7 +151,7 @@ function PlayableMediaView({
|
||||
// Drive caption highlighting from the player's own timeUpdate cadence
|
||||
// (timeUpdateEventInterval = 0.15s above). Pausing halts the events, which
|
||||
// naturally freezes the active word/sentence — no extra plumbing needed.
|
||||
useEventListener(player, "timeUpdate", ({ currentTime: t }) => {
|
||||
useEventListener(player, 'timeUpdate', ({ currentTime: t }) => {
|
||||
setCurrentTime(t);
|
||||
});
|
||||
|
||||
@@ -190,7 +190,7 @@ function PlayableMediaView({
|
||||
return (
|
||||
<View className="flex-1 items-center justify-center px-8">
|
||||
<Text className="text-white/80 text-base text-center">
|
||||
Couldn't load this {isAudio ? "voice message" : "video"}.
|
||||
Couldn’t load this {isAudio ? 'voice message' : 'video'}.
|
||||
</Text>
|
||||
<Text className="text-white/50 text-sm text-center mt-2">
|
||||
Tap forward to continue.
|
||||
@@ -284,12 +284,10 @@ function ProcessingForMobilePlaceholder({ isAudio }: { isAudio: boolean }) {
|
||||
)}
|
||||
</View>
|
||||
<Text className="text-white mt-6 text-lg font-medium">
|
||||
{isAudio ? "Voice message" : "Video message"}
|
||||
{isAudio ? 'Voice message' : 'Video message'}
|
||||
</Text>
|
||||
<View className="flex-row items-center mt-3">
|
||||
<Text className="text-white/60 ml-3 text-sm">
|
||||
View on desktop
|
||||
</Text>
|
||||
<Text className="text-white/60 ml-3 text-sm">View on desktop</Text>
|
||||
</View>
|
||||
<Text className="text-white/40 mt-2 text-xs text-center">
|
||||
Please view this on desktop only.
|
||||
@@ -302,11 +300,11 @@ function isPlayableMime(mime: string): boolean {
|
||||
// expo-video uses AVPlayer on iOS — reliable for h264 in mp4 / mov / m4a.
|
||||
// WebM/VP9 (the legacy desktop format) is not decodable.
|
||||
return (
|
||||
mime === "video/mp4" ||
|
||||
mime === "video/quicktime" ||
|
||||
mime === "audio/mp4" ||
|
||||
mime === "audio/aac" ||
|
||||
mime === "audio/x-m4a" ||
|
||||
mime === "audio/mpeg"
|
||||
mime === 'video/mp4' ||
|
||||
mime === 'video/quicktime' ||
|
||||
mime === 'audio/mp4' ||
|
||||
mime === 'audio/aac' ||
|
||||
mime === 'audio/x-m4a' ||
|
||||
mime === 'audio/mpeg'
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect } from "react";
|
||||
import { Text, View } from "react-native";
|
||||
import { useEffect } from 'react';
|
||||
import { Text, View } from 'react-native';
|
||||
|
||||
import Animated, {
|
||||
Easing,
|
||||
@@ -7,7 +7,7 @@ import Animated, {
|
||||
useAnimatedStyle,
|
||||
useSharedValue,
|
||||
withTiming,
|
||||
} from "react-native-reanimated";
|
||||
} from 'react-native-reanimated';
|
||||
|
||||
interface PlaybackPageIndicatorProps {
|
||||
total: number;
|
||||
@@ -78,7 +78,7 @@ export function PlaybackPageIndicator({
|
||||
{paginated && current >= 0 && (
|
||||
<Text
|
||||
className="pt-1 text-center font-medium text-white/40"
|
||||
style={{ fontSize: 10, fontVariant: ["tabular-nums"] }}
|
||||
style={{ fontSize: 10, fontVariant: ['tabular-nums'] }}
|
||||
>
|
||||
{current + 1} / {total}
|
||||
</Text>
|
||||
@@ -95,7 +95,11 @@ function GhostStub({ visible }: { visible: boolean }) {
|
||||
return (
|
||||
<View
|
||||
className="overflow-hidden rounded-full bg-white/15"
|
||||
style={{ width: STUB_WIDTH, height: SEGMENT_HEIGHT, alignSelf: "flex-end" }}
|
||||
style={{
|
||||
width: STUB_WIDTH,
|
||||
height: SEGMENT_HEIGHT,
|
||||
alignSelf: 'flex-end',
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -117,7 +121,10 @@ function Segment({ isActive, isPast, progress, paused }: SegmentProps) {
|
||||
useEffect(() => {
|
||||
if (isPast) {
|
||||
cancelAnimation(fill);
|
||||
fill.value = withTiming(1, { duration: 120, easing: Easing.out(Easing.cubic) });
|
||||
fill.value = withTiming(1, {
|
||||
duration: 120,
|
||||
easing: Easing.out(Easing.cubic),
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (!isActive) {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import {
|
||||
Dimensions,
|
||||
KeyboardAvoidingView,
|
||||
@@ -8,18 +8,15 @@ import {
|
||||
Text,
|
||||
TextInput,
|
||||
View,
|
||||
} from "react-native";
|
||||
} from 'react-native';
|
||||
import {
|
||||
initialWindowMetrics,
|
||||
SafeAreaProvider,
|
||||
SafeAreaView,
|
||||
} from "react-native-safe-area-context";
|
||||
import { Send, X } from "lucide-react-native";
|
||||
import * as Haptics from "expo-haptics";
|
||||
import {
|
||||
Gesture,
|
||||
GestureDetector,
|
||||
} from "react-native-gesture-handler";
|
||||
} from 'react-native-safe-area-context';
|
||||
import { Send, X } from 'lucide-react-native';
|
||||
import * as Haptics from 'expo-haptics';
|
||||
import { Gesture, GestureDetector } from 'react-native-gesture-handler';
|
||||
import Animated, {
|
||||
Easing,
|
||||
Extrapolation,
|
||||
@@ -29,16 +26,15 @@ import Animated, {
|
||||
useSharedValue,
|
||||
withSpring,
|
||||
withTiming,
|
||||
} from "react-native-reanimated";
|
||||
import { REACTION_EMOJIS, type Reactions } from "@/api/types";
|
||||
import type { Human } from "@/api/types";
|
||||
import { resolveHumanDisplay } from "@/lib/humans";
|
||||
import { sanitizeReactionText } from "@/lib/firestore-particles";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useSuspendPlayback } from "@/hooks/use-suspend-playback";
|
||||
} from 'react-native-reanimated';
|
||||
import { REACTION_EMOJIS, type Reactions, type Human } from '@/api/types';
|
||||
import { resolveHumanDisplay } from '@/lib/humans';
|
||||
import { sanitizeReactionText } from '@/lib/firestore-particles';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useSuspendPlayback } from '@/hooks/use-suspend-playback';
|
||||
|
||||
const TEXT_REACTION_MAX = 40;
|
||||
const SCREEN_HEIGHT = Dimensions.get("window").height;
|
||||
const SCREEN_HEIGHT = Dimensions.get('window').height;
|
||||
const ANIMATION_MS = 240;
|
||||
|
||||
const EMOJI_SET = new Set<string>(REACTION_EMOJIS);
|
||||
@@ -75,7 +71,7 @@ export function ReactionSheet({
|
||||
}: ReactionSheetProps) {
|
||||
// Suspend playback whenever the sheet is mounted-and-open. The Modal
|
||||
// controls visibility so we tie the suspender to `open` directly.
|
||||
useSuspendPlayback(open, "reactions-sheet");
|
||||
useSuspendPlayback(open, 'reactions-sheet');
|
||||
|
||||
// We mount the modal slightly delayed from `open` so the slide-up animation
|
||||
// has its starting position rendered. Using local `mounted` state lets us
|
||||
@@ -85,8 +81,7 @@ export function ReactionSheet({
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setMounted(true);
|
||||
// Schedule animation after the modal mounts
|
||||
// Schedule the slide-in after the modal mounts (handled at render time).
|
||||
requestAnimationFrame(() => {
|
||||
translateY.value = withSpring(0, {
|
||||
damping: 24,
|
||||
@@ -115,14 +110,18 @@ export function ReactionSheet({
|
||||
.activeOffsetY(10)
|
||||
.failOffsetX([-25, 25])
|
||||
.onUpdate((e) => {
|
||||
"worklet";
|
||||
'worklet';
|
||||
// Reanimated shared values are mutated by design; react-hooks/immutability
|
||||
// doesn't model worklets, so the mutations below are flagged spuriously.
|
||||
// eslint-disable-next-line react-hooks/immutability
|
||||
translateY.value = Math.max(0, e.translationY);
|
||||
})
|
||||
.onEnd((e) => {
|
||||
"worklet";
|
||||
'worklet';
|
||||
if (e.translationY > 120 || e.velocityY > 800) {
|
||||
runOnJS(dismiss)();
|
||||
} else {
|
||||
// eslint-disable-next-line react-hooks/immutability
|
||||
translateY.value = withSpring(0, {
|
||||
damping: 24,
|
||||
stiffness: 260,
|
||||
@@ -152,25 +151,31 @@ export function ReactionSheet({
|
||||
const activeTextKeys = useMemo(
|
||||
() =>
|
||||
Object.keys(reactions ?? {}).filter(
|
||||
(k) =>
|
||||
!EMOJI_SET.has(k) && (reactions?.[k]?.length ?? 0) > 0,
|
||||
(k) => !EMOJI_SET.has(k) && (reactions?.[k]?.length ?? 0) > 0,
|
||||
),
|
||||
[reactions],
|
||||
);
|
||||
|
||||
// --- Text reaction input ---
|
||||
const [text, setText] = useState("");
|
||||
const [text, setText] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
if (open) setText("");
|
||||
}, [open]);
|
||||
// Mount on open (staying mounted through the exit animation) and clear the
|
||||
// input. Render-time adjustment avoids a setState-in-effect cascade.
|
||||
const [prevOpen, setPrevOpen] = useState(open);
|
||||
if (open !== prevOpen) {
|
||||
setPrevOpen(open);
|
||||
if (open) {
|
||||
setMounted(true);
|
||||
setText('');
|
||||
}
|
||||
}
|
||||
|
||||
const submitText = () => {
|
||||
const trimmed = text.trim();
|
||||
if (!trimmed) return;
|
||||
void Haptics.selectionAsync();
|
||||
onToggle(trimmed.slice(0, TEXT_REACTION_MAX));
|
||||
setText("");
|
||||
setText('');
|
||||
onClose();
|
||||
};
|
||||
|
||||
@@ -190,180 +195,184 @@ export function ReactionSheet({
|
||||
onRequestClose={dismiss}
|
||||
>
|
||||
<SafeAreaProvider initialMetrics={initialWindowMetrics}>
|
||||
<View style={{ flex: 1 }}>
|
||||
<Animated.View
|
||||
pointerEvents={open ? "auto" : "none"}
|
||||
style={[
|
||||
{ position: "absolute", inset: 0, backgroundColor: "black" },
|
||||
backdropStyle,
|
||||
]}
|
||||
>
|
||||
<Pressable style={{ flex: 1 }} onPress={dismiss} />
|
||||
</Animated.View>
|
||||
<View style={{ flex: 1 }}>
|
||||
<Animated.View
|
||||
pointerEvents={open ? 'auto' : 'none'}
|
||||
style={[
|
||||
{ position: 'absolute', inset: 0, backgroundColor: 'black' },
|
||||
backdropStyle,
|
||||
]}
|
||||
>
|
||||
<Pressable style={{ flex: 1 }} onPress={dismiss} />
|
||||
</Animated.View>
|
||||
|
||||
<KeyboardAvoidingView
|
||||
behavior={Platform.OS === "ios" ? "padding" : undefined}
|
||||
style={{ flex: 1, justifyContent: "flex-end" }}
|
||||
pointerEvents="box-none"
|
||||
>
|
||||
<GestureDetector gesture={sheetPan}>
|
||||
<Animated.View
|
||||
style={[
|
||||
{
|
||||
backgroundColor: "#1c1c1c",
|
||||
borderTopLeftRadius: 22,
|
||||
borderTopRightRadius: 22,
|
||||
overflow: "hidden",
|
||||
},
|
||||
sheetStyle,
|
||||
]}
|
||||
>
|
||||
<SafeAreaView edges={["bottom"]}>
|
||||
<View className="px-5 pt-3 pb-2 items-center">
|
||||
{/* Drag handle — affords downward dismissal at a glance. */}
|
||||
<View className="bg-white/25 h-1 w-12 rounded-full mb-3" />
|
||||
<KeyboardAvoidingView
|
||||
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
|
||||
style={{ flex: 1, justifyContent: 'flex-end' }}
|
||||
pointerEvents="box-none"
|
||||
>
|
||||
<GestureDetector gesture={sheetPan}>
|
||||
<Animated.View
|
||||
style={[
|
||||
{
|
||||
backgroundColor: '#1c1c1c',
|
||||
borderTopLeftRadius: 22,
|
||||
borderTopRightRadius: 22,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
sheetStyle,
|
||||
]}
|
||||
>
|
||||
<SafeAreaView edges={['bottom']}>
|
||||
<View className="px-5 pt-3 pb-2 items-center">
|
||||
{/* Drag handle — affords downward dismissal at a glance. */}
|
||||
<View className="bg-white/25 h-1 w-12 rounded-full mb-3" />
|
||||
|
||||
<View className="flex-row items-center justify-between w-full">
|
||||
<Text className="text-white text-base font-semibold">
|
||||
React
|
||||
</Text>
|
||||
<Pressable
|
||||
onPress={dismiss}
|
||||
hitSlop={12}
|
||||
accessibilityLabel="Close reactions"
|
||||
>
|
||||
<X color="rgba(255,255,255,0.6)" size={20} />
|
||||
</Pressable>
|
||||
<View className="flex-row items-center justify-between w-full">
|
||||
<Text className="text-white text-base font-semibold">
|
||||
React
|
||||
</Text>
|
||||
<Pressable
|
||||
onPress={dismiss}
|
||||
hitSlop={12}
|
||||
accessibilityLabel="Close reactions"
|
||||
>
|
||||
<X color="rgba(255,255,255,0.6)" size={20} />
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Existing reactions row — tap a pill to toggle yours. */}
|
||||
{activeEmojis.length > 0 || activeTextKeys.length > 0 ? (
|
||||
<View className="px-5 pb-3 flex-row flex-wrap gap-2">
|
||||
{activeEmojis.map((emoji) => {
|
||||
const reactors = reactions![emoji];
|
||||
const isMine = reactors.includes(currentHumanId);
|
||||
{/* Existing reactions row — tap a pill to toggle yours. */}
|
||||
{activeEmojis.length > 0 || activeTextKeys.length > 0 ? (
|
||||
<View className="px-5 pb-3 flex-row flex-wrap gap-2">
|
||||
{activeEmojis.map((emoji) => {
|
||||
const reactors = reactions![emoji];
|
||||
const isMine = reactors.includes(currentHumanId);
|
||||
return (
|
||||
<Pressable
|
||||
key={emoji}
|
||||
onPress={() => handleEmoji(emoji)}
|
||||
className={cn(
|
||||
'flex-row items-center gap-1.5 rounded-full px-3 py-1.5',
|
||||
isMine
|
||||
? 'bg-white/25 border border-white/40'
|
||||
: 'bg-white/10',
|
||||
)}
|
||||
>
|
||||
<Text className="text-base">{emoji}</Text>
|
||||
<Text className="text-white/85 text-xs font-semibold">
|
||||
{reactors.length}
|
||||
</Text>
|
||||
</Pressable>
|
||||
);
|
||||
})}
|
||||
|
||||
{activeTextKeys.map((key) => {
|
||||
const reactors = reactions![key];
|
||||
const isMine = reactors.includes(currentHumanId);
|
||||
const firstReactor = resolveHumanDisplay(
|
||||
reactors[0],
|
||||
humans,
|
||||
);
|
||||
return (
|
||||
<Pressable
|
||||
key={key}
|
||||
onPress={() => handleEmoji(key)}
|
||||
className={cn(
|
||||
'flex-row items-center gap-1.5 rounded-full px-2.5 py-1.5 max-w-[220px]',
|
||||
isMine
|
||||
? 'bg-white/25 border border-white/40'
|
||||
: 'bg-white/10',
|
||||
)}
|
||||
>
|
||||
<View className="bg-white/20 h-5 w-5 items-center justify-center rounded-full">
|
||||
<Text className="text-white text-[9px] font-semibold">
|
||||
{firstReactor.initials}
|
||||
</Text>
|
||||
</View>
|
||||
<Text
|
||||
className="text-white/90 text-xs"
|
||||
numberOfLines={1}
|
||||
>
|
||||
{key}
|
||||
</Text>
|
||||
{reactors.length > 1 ? (
|
||||
<Text className="text-white/60 text-xs font-medium">
|
||||
{reactors.length}
|
||||
</Text>
|
||||
) : null}
|
||||
</Pressable>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
{/* Quick-pick emoji palette — six big tappable buttons. */}
|
||||
<View className="px-3 pt-2 pb-4 flex-row justify-around">
|
||||
{REACTION_EMOJIS.slice(0, 6).map((emoji) => {
|
||||
const isMine =
|
||||
reactions?.[emoji]?.includes(currentHumanId) ?? false;
|
||||
return (
|
||||
<Pressable
|
||||
key={emoji}
|
||||
onPress={() => handleEmoji(emoji)}
|
||||
accessibilityLabel={`React with ${emoji}`}
|
||||
className={cn(
|
||||
"flex-row items-center gap-1.5 rounded-full px-3 py-1.5",
|
||||
isMine
|
||||
? "bg-white/25 border border-white/40"
|
||||
: "bg-white/10",
|
||||
'h-14 w-14 items-center justify-center rounded-full',
|
||||
isMine ? 'bg-white/25' : 'bg-white/10',
|
||||
)}
|
||||
>
|
||||
<Text className="text-base">{emoji}</Text>
|
||||
<Text className="text-white/85 text-xs font-semibold">
|
||||
{reactors.length}
|
||||
</Text>
|
||||
</Pressable>
|
||||
);
|
||||
})}
|
||||
|
||||
{activeTextKeys.map((key) => {
|
||||
const reactors = reactions![key];
|
||||
const isMine = reactors.includes(currentHumanId);
|
||||
const firstReactor = resolveHumanDisplay(
|
||||
reactors[0],
|
||||
humans,
|
||||
);
|
||||
return (
|
||||
<Pressable
|
||||
key={key}
|
||||
onPress={() => handleEmoji(key)}
|
||||
className={cn(
|
||||
"flex-row items-center gap-1.5 rounded-full px-2.5 py-1.5 max-w-[220px]",
|
||||
isMine
|
||||
? "bg-white/25 border border-white/40"
|
||||
: "bg-white/10",
|
||||
)}
|
||||
>
|
||||
<View className="bg-white/20 h-5 w-5 items-center justify-center rounded-full">
|
||||
<Text className="text-white text-[9px] font-semibold">
|
||||
{firstReactor.initials}
|
||||
</Text>
|
||||
</View>
|
||||
<Text
|
||||
className="text-white/90 text-xs"
|
||||
numberOfLines={1}
|
||||
>
|
||||
{key}
|
||||
</Text>
|
||||
{reactors.length > 1 ? (
|
||||
<Text className="text-white/60 text-xs font-medium">
|
||||
{reactors.length}
|
||||
</Text>
|
||||
) : null}
|
||||
<Text style={{ fontSize: 28 }}>{emoji}</Text>
|
||||
</Pressable>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
{/* Quick-pick emoji palette — six big tappable buttons. */}
|
||||
<View className="px-3 pt-2 pb-4 flex-row justify-around">
|
||||
{REACTION_EMOJIS.slice(0, 6).map((emoji) => {
|
||||
const isMine =
|
||||
reactions?.[emoji]?.includes(currentHumanId) ?? false;
|
||||
return (
|
||||
<Pressable
|
||||
key={emoji}
|
||||
onPress={() => handleEmoji(emoji)}
|
||||
accessibilityLabel={`React with ${emoji}`}
|
||||
className={cn(
|
||||
"h-14 w-14 items-center justify-center rounded-full",
|
||||
isMine ? "bg-white/25" : "bg-white/10",
|
||||
)}
|
||||
>
|
||||
<Text style={{ fontSize: 28 }}>{emoji}</Text>
|
||||
</Pressable>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
|
||||
{/* Text reaction input — 40-char cap matches desktop. */}
|
||||
<View className="px-4 pb-4 flex-row items-center gap-2">
|
||||
<View className="flex-1 bg-white/10 rounded-full px-4 py-2.5">
|
||||
<TextInput
|
||||
value={text}
|
||||
onChangeText={(v) =>
|
||||
setText(sanitizeReactionText(v).slice(0, TEXT_REACTION_MAX))
|
||||
}
|
||||
placeholder="Send a quick reply..."
|
||||
placeholderTextColor="rgba(255,255,255,0.4)"
|
||||
maxLength={TEXT_REACTION_MAX}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
onSubmitEditing={submitText}
|
||||
returnKeyType="send"
|
||||
className="text-white text-base"
|
||||
/>
|
||||
{/* Text reaction input — 40-char cap matches desktop. */}
|
||||
<View className="px-4 pb-4 flex-row items-center gap-2">
|
||||
<View className="flex-1 bg-white/10 rounded-full px-4 py-2.5">
|
||||
<TextInput
|
||||
value={text}
|
||||
onChangeText={(v) =>
|
||||
setText(
|
||||
sanitizeReactionText(v).slice(0, TEXT_REACTION_MAX),
|
||||
)
|
||||
}
|
||||
placeholder="Send a quick reply..."
|
||||
placeholderTextColor="rgba(255,255,255,0.4)"
|
||||
maxLength={TEXT_REACTION_MAX}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
onSubmitEditing={submitText}
|
||||
returnKeyType="send"
|
||||
className="text-white text-base"
|
||||
/>
|
||||
</View>
|
||||
<Pressable
|
||||
onPress={submitText}
|
||||
disabled={text.trim().length === 0}
|
||||
accessibilityLabel="Send text reaction"
|
||||
className={cn(
|
||||
'h-11 w-11 items-center justify-center rounded-full',
|
||||
text.trim().length === 0 ? 'bg-white/10' : 'bg-white',
|
||||
)}
|
||||
>
|
||||
<Send
|
||||
color={
|
||||
text.trim().length === 0
|
||||
? 'rgba(255,255,255,0.3)'
|
||||
: 'black'
|
||||
}
|
||||
size={18}
|
||||
strokeWidth={2}
|
||||
/>
|
||||
</Pressable>
|
||||
</View>
|
||||
<Pressable
|
||||
onPress={submitText}
|
||||
disabled={text.trim().length === 0}
|
||||
accessibilityLabel="Send text reaction"
|
||||
className={cn(
|
||||
"h-11 w-11 items-center justify-center rounded-full",
|
||||
text.trim().length === 0
|
||||
? "bg-white/10"
|
||||
: "bg-white",
|
||||
)}
|
||||
>
|
||||
<Send
|
||||
color={text.trim().length === 0 ? "rgba(255,255,255,0.3)" : "black"}
|
||||
size={18}
|
||||
strokeWidth={2}
|
||||
/>
|
||||
</Pressable>
|
||||
</View>
|
||||
</SafeAreaView>
|
||||
</Animated.View>
|
||||
</GestureDetector>
|
||||
</KeyboardAvoidingView>
|
||||
</View>
|
||||
</SafeAreaView>
|
||||
</Animated.View>
|
||||
</GestureDetector>
|
||||
</KeyboardAvoidingView>
|
||||
</View>
|
||||
</SafeAreaProvider>
|
||||
</Modal>
|
||||
);
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import { useMemo } from "react";
|
||||
import { Pressable, Text, View } from "react-native";
|
||||
import { Plus } from "lucide-react-native";
|
||||
import * as Haptics from "expo-haptics";
|
||||
import { REACTION_EMOJIS, type Reactions } from "@/api/types";
|
||||
import type { Human } from "@/api/types";
|
||||
import { resolveHumanDisplay } from "@/lib/humans";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useMemo } from 'react';
|
||||
import { Pressable, Text, View } from 'react-native';
|
||||
import { Plus } from 'lucide-react-native';
|
||||
import * as Haptics from 'expo-haptics';
|
||||
import { REACTION_EMOJIS, type Reactions, type Human } from '@/api/types';
|
||||
import { resolveHumanDisplay } from '@/lib/humans';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const EMOJI_SET = new Set<string>(REACTION_EMOJIS);
|
||||
|
||||
@@ -58,12 +57,12 @@ export function ReactionStack({
|
||||
key={emoji}
|
||||
onPress={() => handleToggle(emoji)}
|
||||
className={cn(
|
||||
"flex-row items-center gap-1 rounded-full px-2 py-1",
|
||||
isMine ? "bg-white/25" : "bg-black/45",
|
||||
'flex-row items-center gap-1 rounded-full px-2 py-1',
|
||||
isMine ? 'bg-white/25' : 'bg-black/45',
|
||||
)}
|
||||
style={
|
||||
isMine
|
||||
? { borderWidth: 1, borderColor: "rgba(255,255,255,0.4)" }
|
||||
? { borderWidth: 1, borderColor: 'rgba(255,255,255,0.4)' }
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
@@ -84,13 +83,13 @@ export function ReactionStack({
|
||||
key={text}
|
||||
onPress={() => handleToggle(text)}
|
||||
className={cn(
|
||||
"flex-row items-center gap-1.5 rounded-full py-1 pl-1 pr-2.5",
|
||||
isMine ? "bg-white/25" : "bg-black/45",
|
||||
'flex-row items-center gap-1.5 rounded-full py-1 pl-1 pr-2.5',
|
||||
isMine ? 'bg-white/25' : 'bg-black/45',
|
||||
)}
|
||||
style={[
|
||||
{ maxWidth: 200 },
|
||||
isMine
|
||||
? { borderWidth: 1, borderColor: "rgba(255,255,255,0.4)" }
|
||||
? { borderWidth: 1, borderColor: 'rgba(255,255,255,0.4)' }
|
||||
: null,
|
||||
]}
|
||||
>
|
||||
@@ -99,10 +98,7 @@ export function ReactionStack({
|
||||
{firstReactor.initials}
|
||||
</Text>
|
||||
</View>
|
||||
<Text
|
||||
className="text-white/90 text-xs"
|
||||
numberOfLines={1}
|
||||
>
|
||||
<Text className="text-white/90 text-xs" numberOfLines={1}>
|
||||
{text}
|
||||
</Text>
|
||||
{reactors.length > 1 ? (
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Pressable, Text, TextInput, View } from "react-native";
|
||||
import { toast } from "sonner-native";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { toUserMessage } from "@/lib/errors";
|
||||
import { updateParticleProperties } from "@/lib/firestore-particles";
|
||||
import { particlePath, toFirestoreDocPath } from "@/lib/particle-path";
|
||||
import { useSuspendPlayback } from "@/hooks/use-suspend-playback";
|
||||
import { BottomSheet } from "@/components/BottomSheet";
|
||||
import { useState } from 'react';
|
||||
import { Pressable, Text, TextInput, View } from 'react-native';
|
||||
import { toast } from 'sonner-native';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { toUserMessage } from '@/lib/errors';
|
||||
import { updateParticleProperties } from '@/lib/firestore-particles';
|
||||
import { particlePath, toFirestoreDocPath } from '@/lib/particle-path';
|
||||
import { useSuspendPlayback } from '@/hooks/use-suspend-playback';
|
||||
import { BottomSheet } from '@/components/BottomSheet';
|
||||
|
||||
interface RenameStreamSheetProps {
|
||||
open: boolean;
|
||||
@@ -23,17 +23,20 @@ export function RenameStreamSheet({
|
||||
streamId,
|
||||
currentName,
|
||||
}: RenameStreamSheetProps) {
|
||||
useSuspendPlayback(open, "rename-stream");
|
||||
useSuspendPlayback(open, 'rename-stream');
|
||||
|
||||
const [name, setName] = useState(currentName);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
// Reset the field each time the sheet opens fresh.
|
||||
const [prevOpen, setPrevOpen] = useState(open);
|
||||
if (open !== prevOpen) {
|
||||
setPrevOpen(open);
|
||||
if (open) {
|
||||
setName(currentName);
|
||||
setSaving(false);
|
||||
}
|
||||
}, [open, currentName]);
|
||||
}
|
||||
|
||||
const trimmed = name.trim();
|
||||
const canSave = !saving && trimmed.length > 0 && trimmed !== currentName;
|
||||
@@ -43,7 +46,7 @@ export function RenameStreamSheet({
|
||||
setSaving(true);
|
||||
try {
|
||||
const docPath = toFirestoreDocPath(particlePath(networkId, [streamId]));
|
||||
await updateParticleProperties<"stream">(docPath, { name: trimmed });
|
||||
await updateParticleProperties<'stream'>(docPath, { name: trimmed });
|
||||
onClose();
|
||||
} catch (err) {
|
||||
toast.error(toUserMessage(err));
|
||||
@@ -58,18 +61,14 @@ export function RenameStreamSheet({
|
||||
<Text className="text-white/70 text-base">Cancel</Text>
|
||||
</Pressable>
|
||||
<Text className="text-white text-base font-semibold">Rename</Text>
|
||||
<Pressable
|
||||
onPress={handleSave}
|
||||
disabled={!canSave}
|
||||
hitSlop={12}
|
||||
>
|
||||
<Pressable onPress={handleSave} disabled={!canSave} hitSlop={12}>
|
||||
<Text
|
||||
className={cn(
|
||||
"text-base font-semibold",
|
||||
canSave ? "text-white" : "text-white/30",
|
||||
'text-base font-semibold',
|
||||
canSave ? 'text-white' : 'text-white/30',
|
||||
)}
|
||||
>
|
||||
{saving ? "Saving..." : "Save"}
|
||||
{saving ? 'Saving...' : 'Save'}
|
||||
</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
@@ -1,27 +1,27 @@
|
||||
import { useState } from "react";
|
||||
import { Pressable, Text, View } from "react-native";
|
||||
import { useState } from 'react';
|
||||
import { Pressable, Text, View } from 'react-native';
|
||||
import {
|
||||
CircleCheckBig,
|
||||
CircleDot,
|
||||
Pencil,
|
||||
Trash2,
|
||||
Users,
|
||||
} from "lucide-react-native";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { BottomSheet } from "@/components/BottomSheet";
|
||||
} from 'lucide-react-native';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { BottomSheet } from '@/components/BottomSheet';
|
||||
|
||||
export type StreamActionId =
|
||||
| "toggle-status"
|
||||
| "rename"
|
||||
| "members"
|
||||
| "edit-particle"
|
||||
| "delete-particle";
|
||||
| 'toggle-status'
|
||||
| 'rename'
|
||||
| 'members'
|
||||
| 'edit-particle'
|
||||
| 'delete-particle';
|
||||
|
||||
interface StreamActionsSheetProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onSelect: (action: StreamActionId) => void;
|
||||
streamStatus: "open" | "closed";
|
||||
streamStatus: 'open' | 'closed';
|
||||
isCreator: boolean;
|
||||
/** True when the *current* particle is a text particle this user authored. */
|
||||
canEditParticle: boolean;
|
||||
@@ -63,34 +63,32 @@ export function StreamActionsSheet({
|
||||
<View className="py-2">
|
||||
<ActionRow
|
||||
icon={
|
||||
streamStatus === "open" ? (
|
||||
streamStatus === 'open' ? (
|
||||
<CircleCheckBig color="white" size={20} />
|
||||
) : (
|
||||
<CircleDot color="#22c55e" size={20} />
|
||||
)
|
||||
}
|
||||
label={
|
||||
streamStatus === "open" ? "Close stream" : "Reopen stream"
|
||||
}
|
||||
onPress={() => choose("toggle-status")}
|
||||
label={streamStatus === 'open' ? 'Close stream' : 'Reopen stream'}
|
||||
onPress={() => choose('toggle-status')}
|
||||
/>
|
||||
<ActionRow
|
||||
icon={<Users color="white" size={20} />}
|
||||
label="Members"
|
||||
onPress={() => choose("members")}
|
||||
onPress={() => choose('members')}
|
||||
/>
|
||||
{isCreator ? (
|
||||
<ActionRow
|
||||
icon={<Pencil color="white" size={20} />}
|
||||
label="Rename stream"
|
||||
onPress={() => choose("rename")}
|
||||
onPress={() => choose('rename')}
|
||||
/>
|
||||
) : null}
|
||||
{canEditParticle ? (
|
||||
<ActionRow
|
||||
icon={<Pencil color="white" size={20} />}
|
||||
label="Edit particle"
|
||||
onPress={() => choose("edit-particle")}
|
||||
onPress={() => choose('edit-particle')}
|
||||
/>
|
||||
) : null}
|
||||
{canDeleteParticle ? (
|
||||
@@ -98,7 +96,7 @@ export function StreamActionsSheet({
|
||||
icon={<Trash2 color="#ef4444" size={20} />}
|
||||
label="Delete particle"
|
||||
tone="destructive"
|
||||
onPress={() => choose("delete-particle")}
|
||||
onPress={() => choose('delete-particle')}
|
||||
/>
|
||||
) : null}
|
||||
</View>
|
||||
@@ -119,12 +117,12 @@ function ActionRow({
|
||||
icon,
|
||||
label,
|
||||
onPress,
|
||||
tone = "default",
|
||||
tone = 'default',
|
||||
}: {
|
||||
icon: React.ReactNode;
|
||||
label: string;
|
||||
onPress: () => void;
|
||||
tone?: "default" | "destructive";
|
||||
tone?: 'default' | 'destructive';
|
||||
}) {
|
||||
return (
|
||||
<Pressable
|
||||
@@ -134,8 +132,8 @@ function ActionRow({
|
||||
<View className="w-6 items-center">{icon}</View>
|
||||
<Text
|
||||
className={cn(
|
||||
"text-base",
|
||||
tone === "destructive" ? "text-red-400" : "text-white",
|
||||
'text-base',
|
||||
tone === 'destructive' ? 'text-red-400' : 'text-white',
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
|
||||
@@ -1,28 +1,28 @@
|
||||
import { useMemo } from "react";
|
||||
import { Pressable, ScrollView, Text, View } from "react-native";
|
||||
import { Globe, Lock, X } from "lucide-react-native";
|
||||
import { toast } from "sonner-native";
|
||||
import type { Particle } from "@/api/types";
|
||||
import { resolveHumanDisplay } from "@/lib/humans";
|
||||
import { useNetwork } from "@/hooks/use-networks";
|
||||
import { particlePath, toFirestoreDocPath } from "@/lib/particle-path";
|
||||
import { updateParticleVisibleTo } from "@/lib/firestore-particles";
|
||||
import { useMemo } from 'react';
|
||||
import { Pressable, ScrollView, Text, View } from 'react-native';
|
||||
import { Globe, Lock, X } from 'lucide-react-native';
|
||||
import { toast } from 'sonner-native';
|
||||
import type { Particle } from '@/api/types';
|
||||
import { resolveHumanDisplay } from '@/lib/humans';
|
||||
import { useNetwork } from '@/hooks/use-networks';
|
||||
import { particlePath, toFirestoreDocPath } from '@/lib/particle-path';
|
||||
import { updateParticleVisibleTo } from '@/lib/firestore-particles';
|
||||
import {
|
||||
buildCustomVisibility,
|
||||
buildNetworkVisibility,
|
||||
parseVisibleTo,
|
||||
} from "@/lib/stream-visibility";
|
||||
import { toUserMessage } from "@/lib/errors";
|
||||
import { useSuspendPlayback } from "@/hooks/use-suspend-playback";
|
||||
import { BottomSheet } from "@/components/BottomSheet";
|
||||
import { Avatar } from "@/components/Avatar";
|
||||
import { useStreamPresence } from "./stream-presence-context";
|
||||
} from '@/lib/stream-visibility';
|
||||
import { toUserMessage } from '@/lib/errors';
|
||||
import { useSuspendPlayback } from '@/hooks/use-suspend-playback';
|
||||
import { BottomSheet } from '@/components/BottomSheet';
|
||||
import { Avatar } from '@/components/Avatar';
|
||||
import { useStreamPresence } from './stream-presence-context';
|
||||
|
||||
interface StreamMembersSheetProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
networkId: string;
|
||||
streamParticle: Particle & { type: "stream" };
|
||||
streamParticle: Particle & { type: 'stream' };
|
||||
isCreator: boolean;
|
||||
}
|
||||
|
||||
@@ -38,7 +38,7 @@ export function StreamMembersSheet({
|
||||
streamParticle,
|
||||
isCreator,
|
||||
}: StreamMembersSheetProps) {
|
||||
useSuspendPlayback(open, "stream-members");
|
||||
useSuspendPlayback(open, 'stream-members');
|
||||
|
||||
const { onlineHumanIds } = useStreamPresence();
|
||||
const network = useNetwork(networkId);
|
||||
@@ -52,7 +52,7 @@ export function StreamMembersSheet({
|
||||
);
|
||||
|
||||
const memberIds =
|
||||
visibility.mode === "network"
|
||||
visibility.mode === 'network'
|
||||
? humans.map((h) => h.id)
|
||||
: visibility.humanIds;
|
||||
const memberSet = new Set(memberIds);
|
||||
@@ -70,7 +70,7 @@ export function StreamMembersSheet({
|
||||
const setCustomOnlyCreator = () => apply(buildCustomVisibility([creatorId]));
|
||||
|
||||
const removeMember = (id: string) => {
|
||||
if (visibility.mode !== "custom") return;
|
||||
if (visibility.mode !== 'custom') return;
|
||||
if (id === creatorId) return;
|
||||
const next = visibility.humanIds.filter((x) => x !== id);
|
||||
if (next.length === 0) return;
|
||||
@@ -78,7 +78,7 @@ export function StreamMembersSheet({
|
||||
};
|
||||
|
||||
const addMember = (id: string) => {
|
||||
if (visibility.mode !== "custom") return;
|
||||
if (visibility.mode !== 'custom') return;
|
||||
void apply(buildCustomVisibility([...visibility.humanIds, id]));
|
||||
};
|
||||
|
||||
@@ -99,13 +99,13 @@ export function StreamMembersSheet({
|
||||
{isCreator ? (
|
||||
<View className="flex-row gap-1 bg-white/5 rounded-lg p-1">
|
||||
<ModePill
|
||||
active={visibility.mode === "network"}
|
||||
active={visibility.mode === 'network'}
|
||||
icon={<Globe color="white" size={14} />}
|
||||
label="Network-wide"
|
||||
onPress={setNetworkWide}
|
||||
/>
|
||||
<ModePill
|
||||
active={visibility.mode === "custom"}
|
||||
active={visibility.mode === 'custom'}
|
||||
icon={<Lock color="white" size={14} />}
|
||||
label="Specific people"
|
||||
onPress={setCustomOnlyCreator}
|
||||
@@ -113,19 +113,19 @@ export function StreamMembersSheet({
|
||||
</View>
|
||||
) : (
|
||||
<View className="flex-row items-center gap-2">
|
||||
{visibility.mode === "network" ? (
|
||||
{visibility.mode === 'network' ? (
|
||||
<>
|
||||
<Globe color="rgba(255,255,255,0.5)" size={14} />
|
||||
<Text className="text-white/70 text-sm">
|
||||
Everyone in {network?.name ?? "network"}
|
||||
Everyone in {network?.name ?? 'network'}
|
||||
</Text>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Lock color="rgba(255,255,255,0.5)" size={14} />
|
||||
<Text className="text-white/70 text-sm">
|
||||
{memberIds.length} specific{" "}
|
||||
{memberIds.length === 1 ? "person" : "people"}
|
||||
{memberIds.length} specific{' '}
|
||||
{memberIds.length === 1 ? 'person' : 'people'}
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
@@ -136,19 +136,16 @@ export function StreamMembersSheet({
|
||||
<ScrollView contentContainerClassName="pb-4">
|
||||
<View className="px-5 pt-2">
|
||||
<Text className="text-white/40 text-[10px] font-semibold uppercase tracking-widest mb-2">
|
||||
{visibility.mode === "network" ? "Has access" : "People"} ·{" "}
|
||||
{visibility.mode === 'network' ? 'Has access' : 'People'} ·{' '}
|
||||
{memberIds.length}
|
||||
</Text>
|
||||
{memberIds.map((id) => {
|
||||
const display = resolveHumanDisplay(id, humans);
|
||||
const isCreatorRow = id === creatorId;
|
||||
const canRemove =
|
||||
isCreator && visibility.mode === "custom" && !isCreatorRow;
|
||||
isCreator && visibility.mode === 'custom' && !isCreatorRow;
|
||||
return (
|
||||
<View
|
||||
key={id}
|
||||
className="flex-row items-center gap-3 py-2.5"
|
||||
>
|
||||
<View key={id} className="flex-row items-center gap-3 py-2.5">
|
||||
<Avatar
|
||||
humanId={id}
|
||||
humans={humans}
|
||||
@@ -159,18 +156,15 @@ export function StreamMembersSheet({
|
||||
<Text
|
||||
className={
|
||||
display.exists
|
||||
? "text-white text-sm font-medium"
|
||||
: "text-white/50 italic text-sm font-medium"
|
||||
? 'text-white text-sm font-medium'
|
||||
: 'text-white/50 italic text-sm font-medium'
|
||||
}
|
||||
numberOfLines={1}
|
||||
>
|
||||
{display.displayName}
|
||||
</Text>
|
||||
{display.exists ? (
|
||||
<Text
|
||||
className="text-white/40 text-xs"
|
||||
numberOfLines={1}
|
||||
>
|
||||
<Text className="text-white/40 text-xs" numberOfLines={1}>
|
||||
{display.email}
|
||||
</Text>
|
||||
) : null}
|
||||
@@ -194,7 +188,7 @@ export function StreamMembersSheet({
|
||||
</View>
|
||||
|
||||
{isCreator &&
|
||||
visibility.mode === "custom" &&
|
||||
visibility.mode === 'custom' &&
|
||||
availableToAdd.length > 0 ? (
|
||||
<View className="px-5 pt-4 mt-2 border-t border-white/5">
|
||||
<Text className="text-white/40 text-[10px] font-semibold uppercase tracking-widest mt-3 mb-2">
|
||||
@@ -221,10 +215,7 @@ export function StreamMembersSheet({
|
||||
>
|
||||
{display.displayName}
|
||||
</Text>
|
||||
<Text
|
||||
className="text-white/40 text-xs"
|
||||
numberOfLines={1}
|
||||
>
|
||||
<Text className="text-white/40 text-xs" numberOfLines={1}>
|
||||
{display.email}
|
||||
</Text>
|
||||
</View>
|
||||
@@ -254,16 +245,14 @@ function ModePill({
|
||||
<Pressable
|
||||
onPress={onPress}
|
||||
className={
|
||||
"flex-1 flex-row items-center justify-center gap-1.5 rounded-md py-2 " +
|
||||
(active ? "bg-white/15" : "")
|
||||
'flex-1 flex-row items-center justify-center gap-1.5 rounded-md py-2 ' +
|
||||
(active ? 'bg-white/15' : '')
|
||||
}
|
||||
>
|
||||
{icon}
|
||||
<Text
|
||||
className={
|
||||
active
|
||||
? "text-white text-xs font-semibold"
|
||||
: "text-white/60 text-xs"
|
||||
active ? 'text-white text-xs font-semibold' : 'text-white/60 text-xs'
|
||||
}
|
||||
>
|
||||
{label}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { Text, View } from "react-native";
|
||||
import type { Network, Particle } from "@/api/types";
|
||||
import { resolveHumanDisplay } from "@/lib/humans";
|
||||
import { RelativeTimestamp } from "@/components/RelativeTimestamp";
|
||||
import { Avatar } from "@/components/Avatar";
|
||||
import { useStreamPresence } from "./stream-presence-context";
|
||||
import { Text, View } from 'react-native';
|
||||
import type { Network, Particle } from '@/api/types';
|
||||
import { resolveHumanDisplay } from '@/lib/humans';
|
||||
import { RelativeTimestamp } from '@/components/RelativeTimestamp';
|
||||
import { Avatar } from '@/components/Avatar';
|
||||
import { useStreamPresence } from './stream-presence-context';
|
||||
|
||||
interface StreamMetadataHeaderProps {
|
||||
particle: Particle | null;
|
||||
@@ -26,7 +26,7 @@ export function StreamMetadataHeader({
|
||||
);
|
||||
|
||||
const editedAt =
|
||||
particle.type === "text" ? particle.properties.edited_at : undefined;
|
||||
particle.type === 'text' ? particle.properties.edited_at : undefined;
|
||||
const isOnline = particle.created_by_human_id
|
||||
? onlineHumanIds.has(particle.created_by_human_id)
|
||||
: false;
|
||||
@@ -40,10 +40,7 @@ export function StreamMetadataHeader({
|
||||
online={isOnline}
|
||||
/>
|
||||
<View className="flex-1">
|
||||
<Text
|
||||
className="text-white text-sm font-semibold"
|
||||
numberOfLines={1}
|
||||
>
|
||||
<Text className="text-white text-sm font-semibold" numberOfLines={1}>
|
||||
{display.displayName}
|
||||
</Text>
|
||||
<View className="flex-row items-center gap-2">
|
||||
@@ -53,7 +50,7 @@ export function StreamMetadataHeader({
|
||||
/>
|
||||
{editedAt ? (
|
||||
<Text className="text-white/40 text-xs">
|
||||
· edited{" "}
|
||||
· edited{' '}
|
||||
<RelativeTimestamp date={editedAt} className="text-white/40" />
|
||||
</Text>
|
||||
) : null}
|
||||
|
||||
@@ -1,23 +1,23 @@
|
||||
import { ActivityIndicator, Pressable, Text, View } from "react-native";
|
||||
import { ActivityIndicator, Pressable, Text, View } from 'react-native';
|
||||
import {
|
||||
EllipsisVertical,
|
||||
Globe,
|
||||
Headphones,
|
||||
Maximize2,
|
||||
Minimize2,
|
||||
} from "lucide-react-native";
|
||||
import type { Human, Particle } from "@/api/types";
|
||||
import { parseVisibleTo } from "@/lib/stream-visibility";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Avatar } from "@/components/Avatar";
|
||||
import { useOpenHuddle } from "@/features/huddle/use-open-huddle";
|
||||
import { useStreamPresence } from "./stream-presence-context";
|
||||
} from 'lucide-react-native';
|
||||
import type { Human, Particle } from '@/api/types';
|
||||
import { parseVisibleTo } from '@/lib/stream-visibility';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Avatar } from '@/components/Avatar';
|
||||
import { useOpenHuddle } from '@/features/huddle/use-open-huddle';
|
||||
import { useStreamPresence } from './stream-presence-context';
|
||||
|
||||
interface StreamTopActionsProps {
|
||||
networkId: string;
|
||||
streamParticle: Particle & { type: "stream" };
|
||||
streamParticle: Particle & { type: 'stream' };
|
||||
humans: Human[];
|
||||
videoFit: "cover" | "contain";
|
||||
videoFit: 'cover' | 'contain';
|
||||
onToggleVideoFit: () => void;
|
||||
onOpenMembers: () => void;
|
||||
onOpenActions: () => void;
|
||||
@@ -48,7 +48,7 @@ export function StreamTopActions({
|
||||
const huddleActive = huddleCount > 0;
|
||||
const visibility = parseVisibleTo(streamParticle.visible_to, networkId);
|
||||
const memberIds =
|
||||
visibility.mode === "network"
|
||||
visibility.mode === 'network'
|
||||
? humans.map((h) => h.id)
|
||||
: visibility.humanIds;
|
||||
const shown = memberIds.slice(0, MAX_AVATARS);
|
||||
@@ -61,15 +61,12 @@ export function StreamTopActions({
|
||||
accessibilityLabel="Stream members"
|
||||
className="bg-white/10 active:bg-white/20 rounded-full px-2 py-1 flex-row items-center gap-1"
|
||||
>
|
||||
{visibility.mode === "network" && memberIds.length === 0 ? (
|
||||
{visibility.mode === 'network' && memberIds.length === 0 ? (
|
||||
<Globe color="rgba(255,255,255,0.85)" size={14} />
|
||||
) : (
|
||||
<View className="flex-row">
|
||||
{shown.map((id, idx) => (
|
||||
<View
|
||||
key={id}
|
||||
style={{ marginLeft: idx === 0 ? 0 : -8 }}
|
||||
>
|
||||
<View key={id} style={{ marginLeft: idx === 0 ? 0 : -8 }}>
|
||||
{/* The stack ring matches the chrome's translucent bg so it
|
||||
reads as a separator without painting hard black halos. */}
|
||||
<Avatar
|
||||
@@ -98,12 +95,12 @@ export function StreamTopActions({
|
||||
)
|
||||
}
|
||||
disabled={huddleLoading}
|
||||
accessibilityLabel={huddleActive ? "Join huddle" : "Start huddle"}
|
||||
accessibilityLabel={huddleActive ? 'Join huddle' : 'Start huddle'}
|
||||
className={cn(
|
||||
"h-8 items-center justify-center rounded-full px-2.5 flex-row gap-1",
|
||||
'h-8 items-center justify-center rounded-full px-2.5 flex-row gap-1',
|
||||
huddleActive
|
||||
? "bg-red-500/90 active:bg-red-600"
|
||||
: "bg-white/10 active:bg-white/20",
|
||||
? 'bg-red-500/90 active:bg-red-600'
|
||||
: 'bg-white/10 active:bg-white/20',
|
||||
)}
|
||||
>
|
||||
{huddleLoading ? (
|
||||
@@ -124,14 +121,16 @@ export function StreamTopActions({
|
||||
<Pressable
|
||||
onPress={onToggleVideoFit}
|
||||
accessibilityLabel={
|
||||
videoFit === "cover" ? "Fit video to screen" : "Fill screen with video"
|
||||
videoFit === 'cover'
|
||||
? 'Fit video to screen'
|
||||
: 'Fill screen with video'
|
||||
}
|
||||
className={cn(
|
||||
"h-8 w-8 items-center justify-center rounded-full",
|
||||
"bg-white/10 active:bg-white/20",
|
||||
'h-8 w-8 items-center justify-center rounded-full',
|
||||
'bg-white/10 active:bg-white/20',
|
||||
)}
|
||||
>
|
||||
{videoFit === "cover" ? (
|
||||
{videoFit === 'cover' ? (
|
||||
<Minimize2 color="white" size={15} strokeWidth={1.8} />
|
||||
) : (
|
||||
<Maximize2 color="white" size={15} strokeWidth={1.8} />
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { Alert, Dimensions, Pressable, Text, View } from "react-native";
|
||||
import { useIsFocused } from "@react-navigation/native";
|
||||
import { SafeAreaView, useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { StatusBar } from "expo-status-bar";
|
||||
import * as Haptics from "expo-haptics";
|
||||
import { ChevronDown } from "lucide-react-native";
|
||||
import { useCallback, useState } from 'react';
|
||||
import { Alert, Dimensions, Pressable, Text, View } from 'react-native';
|
||||
import { useIsFocused } from '@react-navigation/native';
|
||||
import {
|
||||
Gesture,
|
||||
GestureDetector,
|
||||
} from "react-native-gesture-handler";
|
||||
SafeAreaView,
|
||||
useSafeAreaInsets,
|
||||
} from 'react-native-safe-area-context';
|
||||
import { StatusBar } from 'expo-status-bar';
|
||||
import * as Haptics from 'expo-haptics';
|
||||
import { ChevronDown } from 'lucide-react-native';
|
||||
import { Gesture, GestureDetector } from 'react-native-gesture-handler';
|
||||
import Animated, {
|
||||
Extrapolation,
|
||||
interpolate,
|
||||
@@ -17,54 +17,54 @@ import Animated, {
|
||||
useSharedValue,
|
||||
withSpring,
|
||||
withTiming,
|
||||
} from "react-native-reanimated";
|
||||
import Svg, { Defs, LinearGradient, Rect, Stop } from "react-native-svg";
|
||||
import { isParticleDeleted, type Particle } from "@/api/types";
|
||||
} from 'react-native-reanimated';
|
||||
import Svg, { Defs, LinearGradient, Rect, Stop } from 'react-native-svg';
|
||||
import { isParticleDeleted, type Particle } from '@/api/types';
|
||||
import {
|
||||
parseParticlePath,
|
||||
particlePath,
|
||||
toFirestoreDocPath,
|
||||
type ParticlePath,
|
||||
} from "@/lib/particle-path";
|
||||
} from '@/lib/particle-path';
|
||||
import {
|
||||
softDeleteParticle,
|
||||
toggleParticleReaction,
|
||||
updateStreamStatus,
|
||||
} from "@/lib/firestore-particles";
|
||||
import { toast } from "sonner-native";
|
||||
import { toUserMessage } from "@/lib/errors";
|
||||
import { useNetwork } from "@/hooks/use-networks";
|
||||
import { useStreamPlayback } from "@/hooks/use-stream-playback";
|
||||
import { useSuspendPlayback } from "@/hooks/use-suspend-playback";
|
||||
} from '@/lib/firestore-particles';
|
||||
import { toast } from 'sonner-native';
|
||||
import { toUserMessage } from '@/lib/errors';
|
||||
import { useNetwork } from '@/hooks/use-networks';
|
||||
import { useStreamPlayback } from '@/hooks/use-stream-playback';
|
||||
import { useSuspendPlayback } from '@/hooks/use-suspend-playback';
|
||||
import {
|
||||
selectIsComposing,
|
||||
selectIsPaused,
|
||||
usePlaybackPauseStore,
|
||||
} from "@/stores/playback-pause-store";
|
||||
import { useAuthStore } from "@/stores/auth-store";
|
||||
import { ComposeDock } from "@/features/compose/ComposeDock";
|
||||
import { ComposingIndicator } from "@/components/ComposingIndicator";
|
||||
import { PlaybackPageIndicator } from "./PlaybackPageIndicator";
|
||||
import { ReactionSheet } from "./ReactionSheet";
|
||||
import { StreamMetadataHeader } from "./StreamMetadataHeader";
|
||||
import { StreamSafeAreaProvider } from "./stream-safe-area";
|
||||
} from '@/stores/playback-pause-store';
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
import { ComposeDock } from '@/features/compose/ComposeDock';
|
||||
import { ComposingIndicator } from '@/components/ComposingIndicator';
|
||||
import { PlaybackPageIndicator } from './PlaybackPageIndicator';
|
||||
import { ReactionSheet } from './ReactionSheet';
|
||||
import { StreamMetadataHeader } from './StreamMetadataHeader';
|
||||
import { StreamSafeAreaProvider } from './stream-safe-area';
|
||||
import {
|
||||
StreamPresenceProvider,
|
||||
useStreamComposing,
|
||||
} from "./stream-presence-context";
|
||||
import { TextParticleView } from "./TextParticleView";
|
||||
import { MediaParticleView } from "./MediaParticleView";
|
||||
import { DeletedParticleView } from "./DeletedParticleView";
|
||||
import { FallbackParticleView } from "./FallbackParticleView";
|
||||
import { useExitCountdown } from "./use-exit-countdown";
|
||||
import { StreamTopActions } from "./StreamTopActions";
|
||||
import { StreamActionsSheet, type StreamActionId } from "./StreamActionsSheet";
|
||||
import { StreamMembersSheet } from "./StreamMembersSheet";
|
||||
import { RenameStreamSheet } from "./RenameStreamSheet";
|
||||
import { EditParticleSheet } from "./EditParticleSheet";
|
||||
import { ReactionStack } from "./ReactionStack";
|
||||
} from './stream-presence-context';
|
||||
import { TextParticleView } from './TextParticleView';
|
||||
import { MediaParticleView } from './MediaParticleView';
|
||||
import { DeletedParticleView } from './DeletedParticleView';
|
||||
import { FallbackParticleView } from './FallbackParticleView';
|
||||
import { useExitCountdown } from './use-exit-countdown';
|
||||
import { StreamTopActions } from './StreamTopActions';
|
||||
import { StreamActionsSheet, type StreamActionId } from './StreamActionsSheet';
|
||||
import { StreamMembersSheet } from './StreamMembersSheet';
|
||||
import { RenameStreamSheet } from './RenameStreamSheet';
|
||||
import { EditParticleSheet } from './EditParticleSheet';
|
||||
import { ReactionStack } from './ReactionStack';
|
||||
|
||||
const SCREEN_HEIGHT = Dimensions.get("window").height;
|
||||
const SCREEN_HEIGHT = Dimensions.get('window').height;
|
||||
// Tap-zone split: left 28% goes back, right 72% goes forward — matching the
|
||||
// asymmetric "Snapchat thumb-zone" so right-handed taps default to forward.
|
||||
const PREV_ZONE_RATIO = 0.28;
|
||||
@@ -80,7 +80,7 @@ const REACTIONS_VELOCITY = 600;
|
||||
const COMPOSE_DOCK_HEIGHT = 50;
|
||||
|
||||
interface StreamViewProps {
|
||||
streamParticle: Particle & { type: "stream" };
|
||||
streamParticle: Particle & { type: 'stream' };
|
||||
path: ParticlePath;
|
||||
onExit: () => void;
|
||||
}
|
||||
@@ -118,19 +118,26 @@ function StreamViewInner({ streamParticle, path, onExit }: StreamViewProps) {
|
||||
const paused = usePlaybackPauseStore(selectIsPaused);
|
||||
const composing = usePlaybackPauseStore(selectIsComposing);
|
||||
const [progress, setProgress] = useState(0);
|
||||
const userId = useAuthStore((s) => s.user?.id) ?? "";
|
||||
const userId = useAuthStore((s) => s.user?.id) ?? '';
|
||||
|
||||
// Reset progress whenever the active particle changes.
|
||||
const [prevParticleId, setPrevParticleId] = useState(currentParticle?.id);
|
||||
if (currentParticle?.id !== prevParticleId) {
|
||||
setPrevParticleId(currentParticle?.id);
|
||||
setProgress(0);
|
||||
}
|
||||
|
||||
// Local hold state drives the "touch-hold" pause suspender. We wrap the JS
|
||||
// setter inside a runOnJS callback dispatched from the worklet thread.
|
||||
const [holdActive, setHoldActive] = useState(false);
|
||||
useSuspendPlayback(holdActive, "touch-hold");
|
||||
useSuspendPlayback(holdActive, 'touch-hold');
|
||||
|
||||
// Suspend playback whenever another screen (Huddle, NewStream, modals
|
||||
// routed as screens) is on top. Native stack keeps StreamView mounted, so
|
||||
// without this the stream would keep advancing — and the exit countdown
|
||||
// would fire — behind the huddle.
|
||||
const isFocused = useIsFocused();
|
||||
useSuspendPlayback(!isFocused, "screen-unfocused");
|
||||
useSuspendPlayback(!isFocused, 'screen-unfocused');
|
||||
|
||||
// Reaction sheet — opens via swipe-up on the canvas.
|
||||
const [reactionsOpen, setReactionsOpen] = useState(false);
|
||||
@@ -142,32 +149,32 @@ function StreamViewInner({ streamParticle, path, onExit }: StreamViewProps) {
|
||||
const [membersOpen, setMembersOpen] = useState(false);
|
||||
const [renameOpen, setRenameOpen] = useState(false);
|
||||
const [editOpen, setEditOpen] = useState(false);
|
||||
const [videoFit, setVideoFit] = useState<"cover" | "contain">("cover");
|
||||
const [videoFit, setVideoFit] = useState<'cover' | 'contain'>('cover');
|
||||
|
||||
const isCreator = !!userId && userId === streamParticle.created_by_human_id;
|
||||
const canDeleteCurrentParticle =
|
||||
!!currentParticle &&
|
||||
!!userId &&
|
||||
currentParticle.created_by_human_id === userId &&
|
||||
currentParticle.type !== "stream" &&
|
||||
currentParticle.type !== "folder" &&
|
||||
currentParticle.type !== 'stream' &&
|
||||
currentParticle.type !== 'folder' &&
|
||||
!isParticleDeleted(currentParticle);
|
||||
const canEditCurrentParticle =
|
||||
!!currentParticle &&
|
||||
!!userId &&
|
||||
currentParticle.created_by_human_id === userId &&
|
||||
currentParticle.type === "text" &&
|
||||
currentParticle.type === 'text' &&
|
||||
!isParticleDeleted(currentParticle);
|
||||
const editableTextParticle =
|
||||
canEditCurrentParticle && currentParticle && currentParticle.type === "text"
|
||||
canEditCurrentParticle && currentParticle && currentParticle.type === 'text'
|
||||
? currentParticle
|
||||
: null;
|
||||
|
||||
const showFitToggle =
|
||||
!!currentParticle &&
|
||||
!isParticleDeleted(currentParticle) &&
|
||||
currentParticle.type === "media" &&
|
||||
!currentParticle.properties.mime_type.startsWith("audio/");
|
||||
currentParticle.type === 'media' &&
|
||||
!currentParticle.properties.mime_type.startsWith('audio/');
|
||||
|
||||
const handleStreamAction = useCallback(
|
||||
async (action: StreamActionId) => {
|
||||
@@ -175,38 +182,38 @@ function StreamViewInner({ streamParticle, path, onExit }: StreamViewProps) {
|
||||
particlePath(networkId, [streamParticle.id]),
|
||||
);
|
||||
switch (action) {
|
||||
case "toggle-status": {
|
||||
case 'toggle-status': {
|
||||
try {
|
||||
await updateStreamStatus(
|
||||
streamDocPath,
|
||||
streamParticle.status === "open" ? "closed" : "open",
|
||||
streamParticle.status === 'open' ? 'closed' : 'open',
|
||||
);
|
||||
} catch (err) {
|
||||
toast.error(toUserMessage(err));
|
||||
}
|
||||
return;
|
||||
}
|
||||
case "rename":
|
||||
case 'rename':
|
||||
setRenameOpen(true);
|
||||
return;
|
||||
case "members":
|
||||
case 'members':
|
||||
setMembersOpen(true);
|
||||
return;
|
||||
case "edit-particle":
|
||||
case 'edit-particle':
|
||||
if (!canEditCurrentParticle) return;
|
||||
setEditOpen(true);
|
||||
return;
|
||||
case "delete-particle": {
|
||||
case 'delete-particle': {
|
||||
if (!currentParticle || !userId) return;
|
||||
if (!canDeleteCurrentParticle) return;
|
||||
Alert.alert(
|
||||
"Delete this particle?",
|
||||
"This cannot be undone. Other viewers will see a \"deleted\" message in its place.",
|
||||
'Delete this particle?',
|
||||
'This cannot be undone. Other viewers will see a "deleted" message in its place.',
|
||||
[
|
||||
{ text: "Cancel", style: "cancel" },
|
||||
{ text: 'Cancel', style: 'cancel' },
|
||||
{
|
||||
text: "Delete",
|
||||
style: "destructive",
|
||||
text: 'Delete',
|
||||
style: 'destructive',
|
||||
onPress: async () => {
|
||||
try {
|
||||
const docPath = toFirestoreDocPath(
|
||||
@@ -240,7 +247,7 @@ function StreamViewInner({ streamParticle, path, onExit }: StreamViewProps) {
|
||||
|
||||
const reactionsOnCurrent =
|
||||
currentParticle && !isParticleDeleted(currentParticle)
|
||||
? currentParticle.type === "media" || currentParticle.type === "text"
|
||||
? currentParticle.type === 'media' || currentParticle.type === 'text'
|
||||
? currentParticle.reactions
|
||||
: undefined
|
||||
: undefined;
|
||||
@@ -252,12 +259,7 @@ function StreamViewInner({ streamParticle, path, onExit }: StreamViewProps) {
|
||||
const docPath = toFirestoreDocPath(
|
||||
particlePath(networkId, [streamParticle.id, currentParticle.id]),
|
||||
);
|
||||
void toggleParticleReaction(
|
||||
docPath,
|
||||
key,
|
||||
userId,
|
||||
reactionsOnCurrent,
|
||||
);
|
||||
void toggleParticleReaction(docPath, key, userId, reactionsOnCurrent);
|
||||
},
|
||||
[userId, currentParticle, networkId, streamParticle.id, reactionsOnCurrent],
|
||||
);
|
||||
@@ -280,11 +282,6 @@ function StreamViewInner({ streamParticle, path, onExit }: StreamViewProps) {
|
||||
[children.length, currentIndex, goToParticle],
|
||||
);
|
||||
|
||||
// Reset progress whenever the active particle changes.
|
||||
useEffect(() => {
|
||||
setProgress(0);
|
||||
}, [currentParticle?.id]);
|
||||
|
||||
const handleTap = useCallback(
|
||||
(xRatio: number) => {
|
||||
if (xRatio < PREV_ZONE_RATIO) {
|
||||
@@ -303,7 +300,7 @@ function StreamViewInner({ streamParticle, path, onExit }: StreamViewProps) {
|
||||
|
||||
// --- Swipe-down dismiss ---
|
||||
const translateY = useSharedValue(0);
|
||||
const screenWidth = Dimensions.get("window").width;
|
||||
const screenWidth = Dimensions.get('window').width;
|
||||
|
||||
const exit = useCallback(() => {
|
||||
onExit();
|
||||
@@ -314,15 +311,12 @@ function StreamViewInner({ streamParticle, path, onExit }: StreamViewProps) {
|
||||
.failOffsetX([-30, 30])
|
||||
.failOffsetY(-20)
|
||||
.onUpdate((e) => {
|
||||
"worklet";
|
||||
'worklet';
|
||||
translateY.value = Math.max(0, e.translationY);
|
||||
})
|
||||
.onEnd((e) => {
|
||||
"worklet";
|
||||
if (
|
||||
e.translationY > DISMISS_DISTANCE ||
|
||||
e.velocityY > DISMISS_VELOCITY
|
||||
) {
|
||||
'worklet';
|
||||
if (e.translationY > DISMISS_DISTANCE || e.velocityY > DISMISS_VELOCITY) {
|
||||
translateY.value = withTiming(SCREEN_HEIGHT, { duration: 220 });
|
||||
runOnJS(exit)();
|
||||
} else {
|
||||
@@ -341,7 +335,7 @@ function StreamViewInner({ streamParticle, path, onExit }: StreamViewProps) {
|
||||
.failOffsetX([-30, 30])
|
||||
.failOffsetY(20)
|
||||
.onEnd((e) => {
|
||||
"worklet";
|
||||
'worklet';
|
||||
if (
|
||||
e.translationY < -REACTIONS_DISTANCE ||
|
||||
e.velocityY < -REACTIONS_VELOCITY
|
||||
@@ -355,7 +349,7 @@ function StreamViewInner({ streamParticle, path, onExit }: StreamViewProps) {
|
||||
.maxDuration(180)
|
||||
.maxDistance(15)
|
||||
.onEnd((e, success) => {
|
||||
"worklet";
|
||||
'worklet';
|
||||
if (!success) return;
|
||||
const ratio = e.x / screenWidth;
|
||||
runOnJS(handleTap)(ratio);
|
||||
@@ -366,15 +360,15 @@ function StreamViewInner({ streamParticle, path, onExit }: StreamViewProps) {
|
||||
.minDuration(180)
|
||||
.maxDistance(15)
|
||||
.onStart(() => {
|
||||
"worklet";
|
||||
'worklet';
|
||||
runOnJS(setHoldActive)(true);
|
||||
})
|
||||
.onTouchesUp(() => {
|
||||
"worklet";
|
||||
'worklet';
|
||||
runOnJS(setHoldActive)(false);
|
||||
})
|
||||
.onFinalize(() => {
|
||||
"worklet";
|
||||
'worklet';
|
||||
runOnJS(setHoldActive)(false);
|
||||
});
|
||||
|
||||
@@ -441,7 +435,7 @@ function StreamViewInner({ streamParticle, path, onExit }: StreamViewProps) {
|
||||
);
|
||||
}
|
||||
switch (particle.type) {
|
||||
case "text":
|
||||
case 'text':
|
||||
return (
|
||||
<TextParticleView
|
||||
key={particle.id}
|
||||
@@ -451,7 +445,7 @@ function StreamViewInner({ streamParticle, path, onExit }: StreamViewProps) {
|
||||
onProgress={setProgress}
|
||||
/>
|
||||
);
|
||||
case "media":
|
||||
case 'media':
|
||||
return (
|
||||
<MediaParticleView
|
||||
key={particle.id}
|
||||
@@ -612,7 +606,7 @@ function StreamViewInner({ streamParticle, path, onExit }: StreamViewProps) {
|
||||
humans={network?.humans ?? []}
|
||||
videoFit={videoFit}
|
||||
onToggleVideoFit={() =>
|
||||
setVideoFit((v) => (v === "cover" ? "contain" : "cover"))
|
||||
setVideoFit((v) => (v === 'cover' ? 'contain' : 'cover'))
|
||||
}
|
||||
onOpenMembers={() => setMembersOpen(true)}
|
||||
onOpenActions={() => setActionsOpen(true)}
|
||||
@@ -637,15 +631,15 @@ function StreamViewInner({ streamParticle, path, onExit }: StreamViewProps) {
|
||||
{currentParticle &&
|
||||
!composing &&
|
||||
!isParticleDeleted(currentParticle) &&
|
||||
(currentParticle.type === "media" ||
|
||||
currentParticle.type === "text") ? (
|
||||
(currentParticle.type === 'media' ||
|
||||
currentParticle.type === 'text') ? (
|
||||
<View
|
||||
pointerEvents="box-none"
|
||||
className="absolute right-3"
|
||||
style={{
|
||||
top: insets.top + 100,
|
||||
bottom: insets.bottom + COMPOSE_DOCK_HEIGHT + 40,
|
||||
justifyContent: "center",
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
<ReactionStack
|
||||
@@ -660,7 +654,7 @@ function StreamViewInner({ streamParticle, path, onExit }: StreamViewProps) {
|
||||
|
||||
{/* Safe-area sentinel for top notch — kept outside GestureDetector so
|
||||
iOS's status-bar tap doesn't fight our gestures. */}
|
||||
<SafeAreaView edges={["top"]} pointerEvents="none" />
|
||||
<SafeAreaView edges={['top']} pointerEvents="none" />
|
||||
|
||||
{/* Compose dock + recording overlays. Sits above the GestureDetector
|
||||
so its hold-FAB pan gesture isn't competed-with by the StreamView
|
||||
@@ -686,7 +680,7 @@ function StreamViewInner({ streamParticle, path, onExit }: StreamViewProps) {
|
||||
open={actionsOpen}
|
||||
onClose={() => setActionsOpen(false)}
|
||||
onSelect={(action) => void handleStreamAction(action)}
|
||||
streamStatus={streamParticle.status ?? "open"}
|
||||
streamStatus={streamParticle.status ?? 'open'}
|
||||
isCreator={isCreator}
|
||||
canEditParticle={canEditCurrentParticle}
|
||||
canDeleteParticle={canDeleteCurrentParticle}
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import { ActivityIndicator, Pressable, Text, View } from "react-native";
|
||||
import { StatusBar } from "expo-status-bar";
|
||||
import type { RootStackScreenProps } from "@/navigation/types";
|
||||
import { particlePath } from "@/lib/particle-path";
|
||||
import { useLiveParticle } from "@/hooks/use-particle";
|
||||
import { StreamView } from "./StreamView";
|
||||
import { ActivityIndicator, Pressable, Text, View } from 'react-native';
|
||||
import { StatusBar } from 'expo-status-bar';
|
||||
import type { RootStackScreenProps } from '@/navigation/types';
|
||||
import { particlePath } from '@/lib/particle-path';
|
||||
import { useLiveParticle } from '@/hooks/use-particle';
|
||||
import { StreamView } from './StreamView';
|
||||
|
||||
export function StreamViewScreen({
|
||||
navigation,
|
||||
route,
|
||||
}: RootStackScreenProps<"StreamView">) {
|
||||
}: RootStackScreenProps<'StreamView'>) {
|
||||
const { networkId, streamId } = route.params;
|
||||
const streamPath = particlePath(networkId, [streamId]);
|
||||
const { particle, isLoading, error } = useLiveParticle(streamPath);
|
||||
@@ -22,16 +22,19 @@ export function StreamViewScreen({
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !particle || particle.type !== "stream") {
|
||||
if (error || !particle || particle.type !== 'stream') {
|
||||
return (
|
||||
<View className="flex-1 bg-black items-center justify-center px-8">
|
||||
<StatusBar style="light" hidden />
|
||||
<Text className="text-white/70 text-center">
|
||||
{error
|
||||
? "Couldn't load this stream."
|
||||
: "This stream is no longer available."}
|
||||
: 'This stream is no longer available.'}
|
||||
</Text>
|
||||
<Pressable onPress={() => navigation.goBack()} className="mt-6 px-4 py-2">
|
||||
<Pressable
|
||||
onPress={() => navigation.goBack()}
|
||||
className="mt-6 px-4 py-2"
|
||||
>
|
||||
<Text className="text-white/60">Close</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { useEffect, useRef, type ReactNode } from "react";
|
||||
import { Platform, ScrollView, Text, View, type ViewStyle } from "react-native";
|
||||
import { Renderer, useMarkdown, type MarkedStyles } from "react-native-marked";
|
||||
import type { Particle } from "@/api/types";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { RelativeTimestamp } from "@/components/RelativeTimestamp";
|
||||
import { useStreamSafeArea } from "./stream-safe-area";
|
||||
import { useEffect, useRef, type ReactNode } from 'react';
|
||||
import { Platform, ScrollView, Text, View, type ViewStyle } from 'react-native';
|
||||
import { Renderer, useMarkdown, type MarkedStyles } from 'react-native-marked';
|
||||
import type { Particle } from '@/api/types';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { RelativeTimestamp } from '@/components/RelativeTimestamp';
|
||||
import { useStreamSafeArea } from './stream-safe-area';
|
||||
|
||||
type TextParticle = Extract<Particle, { type: "text" }>;
|
||||
type TextParticle = Extract<Particle, { type: 'text' }>;
|
||||
|
||||
interface TextParticleViewProps {
|
||||
particle: TextParticle;
|
||||
@@ -31,11 +31,9 @@ function computeReadDuration(text: string): number {
|
||||
}
|
||||
|
||||
function getImmersiveStyle(length: number) {
|
||||
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 < 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' };
|
||||
}
|
||||
|
||||
// Mirrors desktop's text-particle-view: short plain notes get the immersive
|
||||
@@ -59,7 +57,7 @@ function withTaskCheckboxes(markdown: string): string {
|
||||
return markdown.replace(
|
||||
TASK_ITEM_RE,
|
||||
(_match, indent: string, mark: string) =>
|
||||
`${indent}${mark === " " ? "☐" : "☑"} `,
|
||||
`${indent}${mark === ' ' ? '☐' : '☑'} `,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -72,11 +70,11 @@ function withTaskCheckboxes(markdown: string): string {
|
||||
// Known gap vs desktop: fenced code blocks aren't syntax-highlighted (Crepe
|
||||
// uses CodeMirror; react-native-marked only exposes the language tag). They
|
||||
// render as plain monospace on the dark surface, which is acceptable for v1.
|
||||
const TEXT_COLOR = "rgba(255,255,255,0.92)";
|
||||
const ACCENT = "#60a5fa";
|
||||
const SURFACE = "rgba(24,24,28,0.96)";
|
||||
const OUTLINE = "rgba(255,255,255,0.2)";
|
||||
const MONO = Platform.OS === "ios" ? "Menlo" : "monospace";
|
||||
const TEXT_COLOR = 'rgba(255,255,255,0.92)';
|
||||
const ACCENT = '#60a5fa';
|
||||
const SURFACE = 'rgba(24,24,28,0.96)';
|
||||
const OUTLINE = 'rgba(255,255,255,0.2)';
|
||||
const MONO = Platform.OS === 'ios' ? 'Menlo' : 'monospace';
|
||||
|
||||
const MARKDOWN_THEME = {
|
||||
colors: {
|
||||
@@ -90,26 +88,88 @@ const MARKDOWN_THEME = {
|
||||
const MARKDOWN_STYLES: MarkedStyles = {
|
||||
text: { color: TEXT_COLOR, fontSize: 18, lineHeight: 28 },
|
||||
li: { color: TEXT_COLOR, fontSize: 18, lineHeight: 28 },
|
||||
strong: { fontWeight: "700" },
|
||||
em: { fontStyle: "italic" },
|
||||
strong: { fontWeight: '700' },
|
||||
em: { fontStyle: 'italic' },
|
||||
strikethrough: {
|
||||
textDecorationLine: "line-through",
|
||||
color: "rgba(255,255,255,0.6)",
|
||||
textDecorationLine: 'line-through',
|
||||
color: 'rgba(255,255,255,0.6)',
|
||||
},
|
||||
// fontStyle "normal" cancels react-native-marked's italic-by-default for
|
||||
// links and inline code (desktop renders neither italic).
|
||||
link: { color: ACCENT, fontStyle: "normal" },
|
||||
link: { color: ACCENT, fontStyle: 'normal' },
|
||||
// borderBottomWidth 0 removes the library's default heading underline rule,
|
||||
// which desktop's headings don't have.
|
||||
h1: { color: "#ffffff", fontSize: 28, lineHeight: 34, fontWeight: "700", marginTop: 8, marginBottom: 8, borderBottomWidth: 0 },
|
||||
h2: { color: "#ffffff", fontSize: 24, lineHeight: 30, fontWeight: "700", marginTop: 8, marginBottom: 6, borderBottomWidth: 0 },
|
||||
h3: { color: "#ffffff", fontSize: 20, lineHeight: 26, fontWeight: "600", marginTop: 6, marginBottom: 4 },
|
||||
h4: { color: "#ffffff", fontSize: 18, lineHeight: 24, fontWeight: "600", marginTop: 6, marginBottom: 4 },
|
||||
h5: { color: "#ffffff", fontSize: 16, lineHeight: 22, fontWeight: "600", marginTop: 4, marginBottom: 2 },
|
||||
h6: { color: "rgba(255,255,255,0.7)", fontSize: 15, lineHeight: 20, fontWeight: "600", marginTop: 4, marginBottom: 2 },
|
||||
codespan: { color: "#fca5a5", fontFamily: MONO, fontStyle: "normal", backgroundColor: "rgba(255,255,255,0.1)" },
|
||||
code: { backgroundColor: SURFACE, borderColor: OUTLINE, borderWidth: 1, borderRadius: 8, padding: 12, marginVertical: 6 },
|
||||
blockquote: { borderLeftWidth: 3, borderLeftColor: OUTLINE, paddingLeft: 12, marginVertical: 6, opacity: 0.85 },
|
||||
h1: {
|
||||
color: '#ffffff',
|
||||
fontSize: 28,
|
||||
lineHeight: 34,
|
||||
fontWeight: '700',
|
||||
marginTop: 8,
|
||||
marginBottom: 8,
|
||||
borderBottomWidth: 0,
|
||||
},
|
||||
h2: {
|
||||
color: '#ffffff',
|
||||
fontSize: 24,
|
||||
lineHeight: 30,
|
||||
fontWeight: '700',
|
||||
marginTop: 8,
|
||||
marginBottom: 6,
|
||||
borderBottomWidth: 0,
|
||||
},
|
||||
h3: {
|
||||
color: '#ffffff',
|
||||
fontSize: 20,
|
||||
lineHeight: 26,
|
||||
fontWeight: '600',
|
||||
marginTop: 6,
|
||||
marginBottom: 4,
|
||||
},
|
||||
h4: {
|
||||
color: '#ffffff',
|
||||
fontSize: 18,
|
||||
lineHeight: 24,
|
||||
fontWeight: '600',
|
||||
marginTop: 6,
|
||||
marginBottom: 4,
|
||||
},
|
||||
h5: {
|
||||
color: '#ffffff',
|
||||
fontSize: 16,
|
||||
lineHeight: 22,
|
||||
fontWeight: '600',
|
||||
marginTop: 4,
|
||||
marginBottom: 2,
|
||||
},
|
||||
h6: {
|
||||
color: 'rgba(255,255,255,0.7)',
|
||||
fontSize: 15,
|
||||
lineHeight: 20,
|
||||
fontWeight: '600',
|
||||
marginTop: 4,
|
||||
marginBottom: 2,
|
||||
},
|
||||
codespan: {
|
||||
color: '#fca5a5',
|
||||
fontFamily: MONO,
|
||||
fontStyle: 'normal',
|
||||
backgroundColor: 'rgba(255,255,255,0.1)',
|
||||
},
|
||||
code: {
|
||||
backgroundColor: SURFACE,
|
||||
borderColor: OUTLINE,
|
||||
borderWidth: 1,
|
||||
borderRadius: 8,
|
||||
padding: 12,
|
||||
marginVertical: 6,
|
||||
},
|
||||
blockquote: {
|
||||
borderLeftWidth: 3,
|
||||
borderLeftColor: OUTLINE,
|
||||
paddingLeft: 12,
|
||||
marginVertical: 6,
|
||||
opacity: 0.85,
|
||||
},
|
||||
// hr is left to the library default, which already draws a 1px rule in the
|
||||
// themed border color (OUTLINE).
|
||||
table: { borderWidth: 1, borderColor: OUTLINE, marginVertical: 6 },
|
||||
@@ -187,7 +247,10 @@ export function TextParticleView({
|
||||
// Immersive (short, plain): centered, large type — feels like a lock-screen
|
||||
// note. Short messages that contain markdown fall through to the rendered
|
||||
// card so formatting isn't shown as raw syntax.
|
||||
if (content.length < IMMERSIVE_CHAR_LIMIT && !hasMarkdownFormatting(content)) {
|
||||
if (
|
||||
content.length < IMMERSIVE_CHAR_LIMIT &&
|
||||
!hasMarkdownFormatting(content)
|
||||
) {
|
||||
const style = getImmersiveStyle(content.length);
|
||||
return (
|
||||
<View
|
||||
@@ -198,7 +261,7 @@ export function TextParticleView({
|
||||
}}
|
||||
>
|
||||
<Text
|
||||
className={cn("text-white text-center max-w-xl", style.className)}
|
||||
className={cn('text-white text-center max-w-xl', style.className)}
|
||||
>
|
||||
{content}
|
||||
</Text>
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { useMemo, useRef } from "react";
|
||||
import { Text, View } from "react-native";
|
||||
import type { Transcript } from "@/api/types";
|
||||
import { useMemo, useState } from 'react';
|
||||
import { Text, View } from 'react-native';
|
||||
import type { Transcript } from '@/api/types';
|
||||
|
||||
type Sentence = Transcript["paragraphs"][number]["sentences"][number];
|
||||
type Word = Transcript["words"][number];
|
||||
type Sentence = Transcript['paragraphs'][number]['sentences'][number];
|
||||
type Word = Transcript['words'][number];
|
||||
|
||||
const CHUNK_SIZE = 9;
|
||||
|
||||
@@ -41,35 +41,40 @@ export function TranscriptOverlay({
|
||||
const activeWord =
|
||||
activeWordIndex !== null ? transcript.words[activeWordIndex] : null;
|
||||
|
||||
const lastSpokenWordRef = useRef<Word | null>(null);
|
||||
if (activeWord) {
|
||||
lastSpokenWordRef.current = activeWord;
|
||||
// Remember the last spoken word so highlights hold during pauses.
|
||||
const [lastSpokenWord, setLastSpokenWord] = useState<Word | null>(null);
|
||||
if (activeWord && activeWord !== lastSpokenWord) {
|
||||
setLastSpokenWord(activeWord);
|
||||
}
|
||||
const highlightWord = activeWord ?? lastSpokenWordRef.current;
|
||||
const highlightWord = activeWord ?? lastSpokenWord;
|
||||
|
||||
const lastChunkRef = useRef<Word[] | null>(null);
|
||||
|
||||
const activeChunk = useMemo(() => {
|
||||
if (activeWord) {
|
||||
for (const chunk of chunks) {
|
||||
if (
|
||||
chunk.some(
|
||||
(w) => w.start === activeWord.start && w.end === activeWord.end,
|
||||
)
|
||||
) {
|
||||
lastChunkRef.current = chunk;
|
||||
return chunk;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (lastChunkRef.current && chunks.some((c) => c === lastChunkRef.current)) {
|
||||
return lastChunkRef.current;
|
||||
}
|
||||
const fallback = chunks[0] ?? null;
|
||||
lastChunkRef.current = fallback;
|
||||
return fallback;
|
||||
// The chunk currently being spoken (null during a pause or if not found).
|
||||
const spokenChunk = useMemo(() => {
|
||||
if (!activeWord) return null;
|
||||
return (
|
||||
chunks.find((chunk) =>
|
||||
chunk.some(
|
||||
(w) => w.start === activeWord.start && w.end === activeWord.end,
|
||||
),
|
||||
) ?? null
|
||||
);
|
||||
}, [chunks, activeWord]);
|
||||
|
||||
// Resolve which chunk to display: the spoken one, else hold the last one while
|
||||
// it's still part of the current sentence, else fall back to the first chunk.
|
||||
const [lastChunk, setLastChunk] = useState<Word[] | null>(null);
|
||||
let activeChunk: Word[] | null;
|
||||
if (spokenChunk) {
|
||||
activeChunk = spokenChunk;
|
||||
} else if (lastChunk && chunks.includes(lastChunk)) {
|
||||
activeChunk = lastChunk;
|
||||
} else {
|
||||
activeChunk = chunks[0] ?? null;
|
||||
}
|
||||
if (activeChunk !== lastChunk) {
|
||||
setLastChunk(activeChunk);
|
||||
}
|
||||
|
||||
if (!activeSentence || !activeChunk || activeChunk.length === 0) return null;
|
||||
|
||||
return (
|
||||
@@ -87,10 +92,10 @@ export function TranscriptOverlay({
|
||||
<Text
|
||||
key={`${word.start}-${i}`}
|
||||
className={
|
||||
isSpoken ? "text-white font-medium" : "text-white/40"
|
||||
isSpoken ? 'text-white font-medium' : 'text-white/40'
|
||||
}
|
||||
>
|
||||
{i > 0 ? " " : ""}
|
||||
{i > 0 ? ' ' : ''}
|
||||
{word.word}
|
||||
</Text>
|
||||
);
|
||||
|
||||
@@ -7,11 +7,11 @@ import {
|
||||
useRef,
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import { useChannel } from "@/hooks/use-channel";
|
||||
import { useAuthStore } from "@/stores/auth-store";
|
||||
} from 'react';
|
||||
import { useChannel } from '@/hooks/use-channel';
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
|
||||
export type ComposingMode = "recording" | "typing" | "screen";
|
||||
export type ComposingMode = 'recording' | 'typing' | 'screen';
|
||||
|
||||
export interface ComposingUser {
|
||||
humanId: string;
|
||||
@@ -74,14 +74,14 @@ export function StreamPresenceProvider({
|
||||
if (!payload?.type) continue;
|
||||
if (msg.humanId === currentUserId) continue;
|
||||
|
||||
if (payload.type === "composing_start" && payload.mode) {
|
||||
if (payload.type === 'composing_start' && payload.mode) {
|
||||
map.set(msg.humanId, {
|
||||
humanId: msg.humanId,
|
||||
mode: payload.mode as ComposingMode,
|
||||
lastSeen: Date.now(),
|
||||
});
|
||||
changed = true;
|
||||
} else if (payload.type === "composing_stop") {
|
||||
} else if (payload.type === 'composing_stop') {
|
||||
if (map.delete(msg.humanId)) changed = true;
|
||||
}
|
||||
}
|
||||
@@ -139,10 +139,10 @@ export function StreamPresenceProvider({
|
||||
|
||||
const startComposing = useCallback(
|
||||
(mode: ComposingMode) => {
|
||||
sendMessage({ type: "composing_start", mode });
|
||||
sendMessage({ type: 'composing_start', mode });
|
||||
if (heartbeatRef.current) clearInterval(heartbeatRef.current);
|
||||
heartbeatRef.current = setInterval(() => {
|
||||
sendMessage({ type: "composing_start", mode });
|
||||
sendMessage({ type: 'composing_start', mode });
|
||||
}, COMPOSING_HEARTBEAT_MS);
|
||||
},
|
||||
[sendMessage],
|
||||
@@ -151,7 +151,7 @@ export function StreamPresenceProvider({
|
||||
const stopComposing = useCallback(() => {
|
||||
if (heartbeatRef.current) clearInterval(heartbeatRef.current);
|
||||
heartbeatRef.current = undefined;
|
||||
sendMessage({ type: "composing_stop" });
|
||||
sendMessage({ type: 'composing_stop' });
|
||||
}, [sendMessage]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -181,7 +181,7 @@ function useStreamPresenceContext() {
|
||||
const ctx = useContext(StreamPresenceContext);
|
||||
if (!ctx) {
|
||||
throw new Error(
|
||||
"useStreamPresence must be used within a StreamPresenceProvider",
|
||||
'useStreamPresence must be used within a StreamPresenceProvider',
|
||||
);
|
||||
}
|
||||
return ctx;
|
||||
@@ -201,3 +201,17 @@ export function useStreamComposingBroadcast() {
|
||||
const { startComposing, stopComposing } = useStreamPresenceContext();
|
||||
return { startComposing, stopComposing };
|
||||
}
|
||||
|
||||
/**
|
||||
* Like {@link useStreamComposingBroadcast}, but returns null instead of throwing
|
||||
* when rendered outside a provider — for callers (e.g. the compose dock) that
|
||||
* can appear both inside and outside a stream.
|
||||
*/
|
||||
export function useStreamComposingBroadcastOptional() {
|
||||
const ctx = useContext(StreamPresenceContext);
|
||||
if (!ctx) return null;
|
||||
return {
|
||||
startComposing: ctx.startComposing,
|
||||
stopComposing: ctx.stopComposing,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { createContext, useContext, type ReactNode } from "react";
|
||||
import { createContext, useContext, type ReactNode } from 'react';
|
||||
|
||||
interface StreamSafeArea {
|
||||
/** Pixels from the screen top reserved for the segmented bar + metadata. */
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useEvent } from "@/hooks/use-event";
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useEvent } from '@/hooks/use-event';
|
||||
|
||||
export const EXIT_DELAY_MS = 5000;
|
||||
export const EXIT_TICK_MS = 100;
|
||||
|
||||
type PlaybackStatus = "idle" | "playing" | "ended";
|
||||
type PlaybackStatus = 'idle' | 'playing' | 'ended';
|
||||
|
||||
/**
|
||||
* Returns the remaining ms when the stream has ended, or null otherwise.
|
||||
@@ -16,18 +16,19 @@ export function useExitCountdown(
|
||||
onExit: () => void,
|
||||
): number | null {
|
||||
const [remainingMs, setRemainingMs] = useState<number | null>(null);
|
||||
const [prevStatus, setPrevStatus] = useState(status);
|
||||
const handleExit = useEvent(onExit);
|
||||
|
||||
useEffect(() => {
|
||||
if (status === "ended") {
|
||||
setRemainingMs(EXIT_DELAY_MS);
|
||||
} else {
|
||||
setRemainingMs(null);
|
||||
}
|
||||
}, [status]);
|
||||
// Start the countdown when playback ends; clear it on any other transition.
|
||||
if (status !== prevStatus) {
|
||||
setPrevStatus(status);
|
||||
setRemainingMs(status === 'ended' ? EXIT_DELAY_MS : null);
|
||||
}
|
||||
|
||||
const isCounting = remainingMs !== null && remainingMs > 0;
|
||||
|
||||
useEffect(() => {
|
||||
if (remainingMs === null || remainingMs <= 0 || paused) return;
|
||||
if (!isCounting || paused) return;
|
||||
const interval = setInterval(() => {
|
||||
setRemainingMs((prev) => {
|
||||
if (prev === null) return null;
|
||||
@@ -36,7 +37,7 @@ export function useExitCountdown(
|
||||
});
|
||||
}, EXIT_TICK_MS);
|
||||
return () => clearInterval(interval);
|
||||
}, [remainingMs !== null && remainingMs > 0, paused, remainingMs]);
|
||||
}, [isCounting, paused]);
|
||||
|
||||
useEffect(() => {
|
||||
if (remainingMs !== null && remainingMs <= 0) {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { useMemo, useState } from 'react';
|
||||
import {
|
||||
KeyboardAvoidingView,
|
||||
Platform,
|
||||
@@ -6,24 +6,24 @@ import {
|
||||
Text,
|
||||
TextInput,
|
||||
View,
|
||||
} from "react-native";
|
||||
import { SafeAreaView } from "react-native-safe-area-context";
|
||||
import { StatusBar } from "expo-status-bar";
|
||||
import { ChevronRight, Globe, Lock, X } from "lucide-react-native";
|
||||
import { toast } from "sonner-native";
|
||||
import { ComposeDock } from "@/features/compose/ComposeDock";
|
||||
import { useNetwork } from "@/hooks/use-networks";
|
||||
import { particlePath } from "@/lib/particle-path";
|
||||
import { generateRandomName } from "@/lib/random-name";
|
||||
import { createStreamWithFirstParticle } from "@/lib/upload";
|
||||
import { toUserMessage } from "@/lib/errors";
|
||||
} from 'react-native';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
import { StatusBar } from 'expo-status-bar';
|
||||
import { ChevronRight, Globe, Lock, X } from 'lucide-react-native';
|
||||
import { toast } from 'sonner-native';
|
||||
import { ComposeDock } from '@/features/compose/ComposeDock';
|
||||
import { useNetwork } from '@/hooks/use-networks';
|
||||
import { particlePath } from '@/lib/particle-path';
|
||||
import { generateRandomName } from '@/lib/random-name';
|
||||
import { createStreamWithFirstParticle } from '@/lib/upload';
|
||||
import { toUserMessage } from '@/lib/errors';
|
||||
import {
|
||||
buildNetworkVisibility,
|
||||
parseVisibleTo,
|
||||
} from "@/lib/stream-visibility";
|
||||
import { useAuthStore } from "@/stores/auth-store";
|
||||
import type { RootStackScreenProps } from "@/navigation/types";
|
||||
import { VisibilityPickerSheet } from "./VisibilityPickerSheet";
|
||||
} from '@/lib/stream-visibility';
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
import type { RootStackScreenProps } from '@/navigation/types';
|
||||
import { VisibilityPickerSheet } from './VisibilityPickerSheet';
|
||||
|
||||
const STREAM_NAME_MAX = 60;
|
||||
|
||||
@@ -35,13 +35,13 @@ const STREAM_NAME_MAX = 60;
|
||||
export function NewStreamScreen({
|
||||
route,
|
||||
navigation,
|
||||
}: RootStackScreenProps<"NewStream">) {
|
||||
}: RootStackScreenProps<'NewStream'>) {
|
||||
const { networkId } = route.params;
|
||||
const network = useNetwork(networkId);
|
||||
const userId = useAuthStore((s) => s.user?.id);
|
||||
|
||||
const suggestion = useMemo(() => generateRandomName(), []);
|
||||
const [name, setName] = useState("");
|
||||
const [name, setName] = useState('');
|
||||
const [visibleTo, setVisibleTo] = useState<string[]>(() =>
|
||||
buildNetworkVisibility(networkId),
|
||||
);
|
||||
@@ -50,18 +50,18 @@ export function NewStreamScreen({
|
||||
const effectiveName = name.trim() || suggestion;
|
||||
|
||||
const handleStreamCreated = (streamId: string) => {
|
||||
navigation.replace("StreamView", { networkId, streamId });
|
||||
navigation.replace('StreamView', { networkId, streamId });
|
||||
};
|
||||
|
||||
const submitText = async (content: string) => {
|
||||
if (!userId) throw new Error("Not signed in.");
|
||||
if (!userId) throw new Error('Not signed in.');
|
||||
try {
|
||||
const { streamId } = await createStreamWithFirstParticle({
|
||||
networkId,
|
||||
name: effectiveName,
|
||||
visibleTo,
|
||||
createdByHumanId: userId,
|
||||
firstParticle: { type: "text", content },
|
||||
firstParticle: { type: 'text', content },
|
||||
});
|
||||
handleStreamCreated(streamId);
|
||||
} catch (err) {
|
||||
@@ -79,9 +79,9 @@ export function NewStreamScreen({
|
||||
fileUri: string;
|
||||
mimeType: string;
|
||||
durationMs: number;
|
||||
source: "camera" | "screen";
|
||||
source: 'camera' | 'screen';
|
||||
}) => {
|
||||
if (!userId) throw new Error("Not signed in.");
|
||||
if (!userId) throw new Error('Not signed in.');
|
||||
try {
|
||||
const { streamId } = await createStreamWithFirstParticle({
|
||||
networkId,
|
||||
@@ -89,7 +89,7 @@ export function NewStreamScreen({
|
||||
visibleTo,
|
||||
createdByHumanId: userId,
|
||||
firstParticle: {
|
||||
type: "media",
|
||||
type: 'media',
|
||||
fileUri,
|
||||
mimeType,
|
||||
durationMs,
|
||||
@@ -107,17 +107,17 @@ export function NewStreamScreen({
|
||||
|
||||
const visibility = parseVisibleTo(visibleTo, networkId);
|
||||
const visibleSummary =
|
||||
visibility.mode === "network"
|
||||
? `Everyone in ${network?.name ?? "this network"}`
|
||||
visibility.mode === 'network'
|
||||
? `Everyone in ${network?.name ?? 'this network'}`
|
||||
: `${visibility.humanIds.length} ${
|
||||
visibility.humanIds.length === 1 ? "person" : "people"
|
||||
visibility.humanIds.length === 1 ? 'person' : 'people'
|
||||
}`;
|
||||
|
||||
return (
|
||||
<View className="flex-1 bg-black">
|
||||
<StatusBar style="light" />
|
||||
|
||||
<SafeAreaView edges={["top"]}>
|
||||
<SafeAreaView edges={['top']}>
|
||||
<View className="flex-row items-center justify-between px-4 pt-3 pb-2">
|
||||
<Pressable
|
||||
onPress={() => navigation.goBack()}
|
||||
@@ -126,15 +126,13 @@ export function NewStreamScreen({
|
||||
>
|
||||
<X color="white" size={22} strokeWidth={1.8} />
|
||||
</Pressable>
|
||||
<Text className="text-white text-base font-semibold">
|
||||
New stream
|
||||
</Text>
|
||||
<Text className="text-white text-base font-semibold">New stream</Text>
|
||||
<View style={{ width: 22 }} />
|
||||
</View>
|
||||
</SafeAreaView>
|
||||
|
||||
<KeyboardAvoidingView
|
||||
behavior={Platform.OS === "ios" ? "padding" : undefined}
|
||||
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
|
||||
className="flex-1"
|
||||
>
|
||||
<View className="flex-1 px-6 pt-4">
|
||||
@@ -159,8 +157,12 @@ export function NewStreamScreen({
|
||||
onPress={() => setPickerOpen(true)}
|
||||
className="bg-white/10 active:bg-white/15 rounded-xl px-4 py-3 flex-row items-center gap-3"
|
||||
>
|
||||
{visibility.mode === "network" ? (
|
||||
<Globe color="rgba(255,255,255,0.7)" size={18} strokeWidth={1.6} />
|
||||
{visibility.mode === 'network' ? (
|
||||
<Globe
|
||||
color="rgba(255,255,255,0.7)"
|
||||
size={18}
|
||||
strokeWidth={1.6}
|
||||
/>
|
||||
) : (
|
||||
<Lock color="rgba(255,255,255,0.7)" size={18} strokeWidth={1.6} />
|
||||
)}
|
||||
@@ -176,7 +178,7 @@ export function NewStreamScreen({
|
||||
|
||||
<View className="mt-6 px-1">
|
||||
<Text className="text-white/50 text-sm">
|
||||
Hold the button below to record a voice or video message — that's
|
||||
Hold the button below to record a voice or video message — that’s
|
||||
the first particle in your new stream.
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
import { memo, useMemo } from "react";
|
||||
import { Pressable, Text, View } from "react-native";
|
||||
import { Headphones } from "lucide-react-native";
|
||||
import type { Particle, StreamProperties } from "@/api/types";
|
||||
import { isParticleDeleted } from "@/api/types";
|
||||
import { RelativeTimestamp } from "@/components/RelativeTimestamp";
|
||||
import { useLiveLatestChild } from "@/hooks/use-particle";
|
||||
import { useNetwork } from "@/hooks/use-networks";
|
||||
import { particlePath } from "@/lib/particle-path";
|
||||
import { cn, getInitials } from "@/lib/utils";
|
||||
import { useAuthStore } from "@/stores/auth-store";
|
||||
import { memo, useMemo } from 'react';
|
||||
import { Pressable, Text, View } from 'react-native';
|
||||
import { Headphones } from 'lucide-react-native';
|
||||
import type { Particle, StreamProperties } from '@/api/types';
|
||||
import { isParticleDeleted } from '@/api/types';
|
||||
import { RelativeTimestamp } from '@/components/RelativeTimestamp';
|
||||
import { useLiveLatestChild } from '@/hooks/use-particle';
|
||||
import { useNetwork } from '@/hooks/use-networks';
|
||||
import { particlePath } from '@/lib/particle-path';
|
||||
import { cn, getInitials } from '@/lib/utils';
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
|
||||
interface StreamCardProps {
|
||||
particle: Particle & { type: "stream"; properties: StreamProperties };
|
||||
particle: Particle & { type: 'stream'; properties: StreamProperties };
|
||||
networkId: string;
|
||||
onPress: () => void;
|
||||
}
|
||||
@@ -28,12 +28,12 @@ export const StreamCard = memo(function StreamCard({
|
||||
}: StreamCardProps) {
|
||||
const streamPath = particlePath(networkId, [particle.id]);
|
||||
const { latestChild } = useLiveLatestChild(streamPath);
|
||||
const userId = useAuthStore((s) => s.user?.id) ?? "";
|
||||
const userId = useAuthStore((s) => s.user?.id) ?? '';
|
||||
const network = useNetwork(networkId);
|
||||
|
||||
const isDM =
|
||||
particle.visible_to.length === 2 &&
|
||||
particle.visible_to.every((v) => v.startsWith("human:"));
|
||||
particle.visible_to.every((v) => v.startsWith('human:'));
|
||||
|
||||
const initials = useMemo(() => {
|
||||
if (isDM) {
|
||||
@@ -41,7 +41,7 @@ export const StreamCard = memo(function StreamCard({
|
||||
(v) => v !== `human:${userId}`,
|
||||
);
|
||||
if (otherEntry) {
|
||||
const otherId = otherEntry.replace("human:", "");
|
||||
const otherId = otherEntry.replace('human:', '');
|
||||
const otherHuman = network?.humans?.find((h) => h.id === otherId);
|
||||
if (otherHuman) return getInitials(otherHuman.email);
|
||||
}
|
||||
@@ -73,45 +73,45 @@ export const StreamCard = memo(function StreamCard({
|
||||
}, [latestChild, particle.playback_markers, userId]);
|
||||
|
||||
const previewLabel = useMemo(() => {
|
||||
if (!latestChild) return "No messages yet";
|
||||
if (isParticleDeleted(latestChild)) return "Message deleted";
|
||||
if (!latestChild) return 'No messages yet';
|
||||
if (isParticleDeleted(latestChild)) return 'Message deleted';
|
||||
switch (latestChild.type) {
|
||||
case "media": {
|
||||
case 'media': {
|
||||
const mime = latestChild.properties.mime_type;
|
||||
if (mime.startsWith("image/")) return "Photo";
|
||||
if (mime.startsWith('image/')) return 'Photo';
|
||||
const transcriptText = latestChild.properties.transcript?.transcript;
|
||||
if (transcriptText) return transcriptText;
|
||||
return mime.startsWith("audio/") ? "Voice note" : "Video clip";
|
||||
return mime.startsWith('audio/') ? 'Voice note' : 'Video clip';
|
||||
}
|
||||
case "text":
|
||||
case 'text':
|
||||
return latestChild.properties.content;
|
||||
case "file":
|
||||
case 'file':
|
||||
return latestChild.properties.filename;
|
||||
case "quest":
|
||||
case 'quest':
|
||||
return latestChild.properties.title;
|
||||
case "paper":
|
||||
case 'paper':
|
||||
return latestChild.properties.title;
|
||||
default:
|
||||
return "Update";
|
||||
return 'Update';
|
||||
}
|
||||
}, [latestChild]);
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
onPress={onPress}
|
||||
android_ripple={{ color: "rgba(0,0,0,0.05)" }}
|
||||
android_ripple={{ color: 'rgba(0,0,0,0.05)' }}
|
||||
className="bg-card flex-row items-center gap-3 px-4 py-3 active:bg-accent"
|
||||
>
|
||||
<View
|
||||
className={cn(
|
||||
"h-10 w-10 items-center justify-center rounded-full",
|
||||
isUnseen ? "bg-primary" : "bg-muted",
|
||||
'h-10 w-10 items-center justify-center rounded-full',
|
||||
isUnseen ? 'bg-primary' : 'bg-muted',
|
||||
)}
|
||||
>
|
||||
<Text
|
||||
className={cn(
|
||||
"text-xs font-semibold",
|
||||
isUnseen ? "text-primary-foreground" : "text-muted-foreground",
|
||||
'text-xs font-semibold',
|
||||
isUnseen ? 'text-primary-foreground' : 'text-muted-foreground',
|
||||
)}
|
||||
>
|
||||
{initials}
|
||||
@@ -122,10 +122,10 @@ export const StreamCard = memo(function StreamCard({
|
||||
<Text
|
||||
numberOfLines={1}
|
||||
className={cn(
|
||||
"text-base",
|
||||
'text-base',
|
||||
isUnseen
|
||||
? "text-foreground font-semibold"
|
||||
: "text-foreground font-medium",
|
||||
? 'text-foreground font-semibold'
|
||||
: 'text-foreground font-medium',
|
||||
)}
|
||||
>
|
||||
{particle.properties.name}
|
||||
@@ -143,8 +143,8 @@ export const StreamCard = memo(function StreamCard({
|
||||
<RelativeTimestamp
|
||||
date={latestChild.created_at}
|
||||
className={cn(
|
||||
"text-xs",
|
||||
isUnseen ? "text-primary" : "text-muted-foreground",
|
||||
'text-xs',
|
||||
isUnseen ? 'text-primary' : 'text-muted-foreground',
|
||||
)}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
@@ -4,33 +4,33 @@ import {
|
||||
Pressable,
|
||||
Text,
|
||||
View,
|
||||
} from "react-native";
|
||||
import { SafeAreaView } from "react-native-safe-area-context";
|
||||
import { ListSeparator } from "@/components/ListSeparator";
|
||||
import { toUserMessage } from "@/lib/errors";
|
||||
import { particlePath } from "@/lib/particle-path";
|
||||
import { useNetwork } from "@/hooks/use-networks";
|
||||
import { useStreamParticles } from "@/hooks/use-stream-particles";
|
||||
import type { RootStackScreenProps } from "@/navigation/types";
|
||||
import { StreamCard } from "./StreamCard";
|
||||
} from 'react-native';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
import { ListSeparator } from '@/components/ListSeparator';
|
||||
import { toUserMessage } from '@/lib/errors';
|
||||
import { particlePath } from '@/lib/particle-path';
|
||||
import { useNetwork } from '@/hooks/use-networks';
|
||||
import { useStreamParticles } from '@/hooks/use-stream-particles';
|
||||
import type { RootStackScreenProps } from '@/navigation/types';
|
||||
import { StreamCard } from './StreamCard';
|
||||
|
||||
export function StreamListScreen({
|
||||
route,
|
||||
navigation,
|
||||
}: RootStackScreenProps<"StreamList">) {
|
||||
}: RootStackScreenProps<'StreamList'>) {
|
||||
const { networkId } = route.params;
|
||||
const network = useNetwork(networkId);
|
||||
|
||||
const path = particlePath(networkId, []);
|
||||
|
||||
const { streams, isLoading, error } = useStreamParticles(path, {
|
||||
status: "open",
|
||||
status: 'open',
|
||||
});
|
||||
|
||||
return (
|
||||
<SafeAreaView className="flex-1 bg-background" edges={["top"]}>
|
||||
<SafeAreaView className="flex-1 bg-background" edges={['top']}>
|
||||
<Header
|
||||
title={network?.name ?? "Streams"}
|
||||
title={network?.name ?? 'Streams'}
|
||||
onBack={() => navigation.goBack()}
|
||||
/>
|
||||
|
||||
@@ -50,7 +50,7 @@ export function StreamListScreen({
|
||||
particle={item}
|
||||
networkId={networkId}
|
||||
onPress={() =>
|
||||
navigation.navigate("StreamView", {
|
||||
navigation.navigate('StreamView', {
|
||||
networkId,
|
||||
streamId: item.id,
|
||||
})
|
||||
@@ -61,19 +61,13 @@ export function StreamListScreen({
|
||||
)}
|
||||
|
||||
<ComposeFab
|
||||
onPress={() => navigation.navigate("NewStream", { networkId })}
|
||||
onPress={() => navigation.navigate('NewStream', { networkId })}
|
||||
/>
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
|
||||
function Header({
|
||||
title,
|
||||
onBack,
|
||||
}: {
|
||||
title: string;
|
||||
onBack: () => void;
|
||||
}) {
|
||||
function Header({ title, onBack }: { title: string; onBack: () => void }) {
|
||||
return (
|
||||
<View className="flex-row items-center px-3 py-3 border-b border-border">
|
||||
<Pressable
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Pressable, ScrollView, Text, View } from "react-native";
|
||||
import { Check, Globe, Lock, X } from "lucide-react-native";
|
||||
import type { Human } from "@/api/types";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { resolveHumanDisplay } from "@/lib/humans";
|
||||
import { useMemo, useState } from 'react';
|
||||
import { Pressable, ScrollView, Text, View } from 'react-native';
|
||||
import { Check, Globe, Lock, X } from 'lucide-react-native';
|
||||
import type { Human } from '@/api/types';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { resolveHumanDisplay } from '@/lib/humans';
|
||||
import {
|
||||
buildCustomVisibility,
|
||||
buildNetworkVisibility,
|
||||
parseVisibleTo,
|
||||
} from "@/lib/stream-visibility";
|
||||
import { BottomSheet } from "@/components/BottomSheet";
|
||||
import { Avatar } from "@/components/Avatar";
|
||||
} from '@/lib/stream-visibility';
|
||||
import { BottomSheet } from '@/components/BottomSheet';
|
||||
import { Avatar } from '@/components/Avatar';
|
||||
|
||||
interface VisibilityPickerSheetProps {
|
||||
open: boolean;
|
||||
@@ -43,18 +43,20 @@ export function VisibilityPickerSheet({
|
||||
[visibleTo, networkId],
|
||||
);
|
||||
|
||||
const [mode, setMode] = useState<"network" | "custom">(initial.mode);
|
||||
const [mode, setMode] = useState<'network' | 'custom'>(initial.mode);
|
||||
const [selected, setSelected] = useState<Set<string>>(
|
||||
() => new Set(initial.mode === "custom" ? initial.humanIds : []),
|
||||
() => new Set(initial.mode === 'custom' ? initial.humanIds : []),
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setMode(initial.mode);
|
||||
setSelected(
|
||||
new Set(initial.mode === "custom" ? initial.humanIds : []),
|
||||
);
|
||||
}, [open, initial]);
|
||||
// Re-seed from the committed value each time the sheet opens fresh.
|
||||
const [prevOpen, setPrevOpen] = useState(open);
|
||||
if (open !== prevOpen) {
|
||||
setPrevOpen(open);
|
||||
if (open) {
|
||||
setMode(initial.mode);
|
||||
setSelected(new Set(initial.mode === 'custom' ? initial.humanIds : []));
|
||||
}
|
||||
}
|
||||
|
||||
const others = humans.filter((h) => h.id !== selfHumanId);
|
||||
|
||||
@@ -68,7 +70,7 @@ export function VisibilityPickerSheet({
|
||||
};
|
||||
|
||||
const commit = () => {
|
||||
if (mode === "network") {
|
||||
if (mode === 'network') {
|
||||
onChange(buildNetworkVisibility(networkId));
|
||||
} else {
|
||||
const ids = selfHumanId
|
||||
@@ -80,7 +82,7 @@ export function VisibilityPickerSheet({
|
||||
};
|
||||
|
||||
const customCount = selected.size + (selfHumanId ? 1 : 0);
|
||||
const canCommit = mode === "network" || customCount >= 2;
|
||||
const canCommit = mode === 'network' || customCount >= 2;
|
||||
|
||||
return (
|
||||
<BottomSheet open={open} onClose={onClose} maxHeight="80%">
|
||||
@@ -92,8 +94,8 @@ export function VisibilityPickerSheet({
|
||||
<Pressable onPress={commit} disabled={!canCommit} hitSlop={12}>
|
||||
<Text
|
||||
className={cn(
|
||||
"text-base font-semibold",
|
||||
canCommit ? "text-white" : "text-white/30",
|
||||
'text-base font-semibold',
|
||||
canCommit ? 'text-white' : 'text-white/30',
|
||||
)}
|
||||
>
|
||||
Done
|
||||
@@ -104,31 +106,31 @@ export function VisibilityPickerSheet({
|
||||
<View className="px-5 pb-3">
|
||||
<View className="flex-row gap-1 bg-white/5 rounded-lg p-1">
|
||||
<ModePill
|
||||
active={mode === "network"}
|
||||
active={mode === 'network'}
|
||||
icon={<Globe color="white" size={14} />}
|
||||
label="Everyone"
|
||||
onPress={() => setMode("network")}
|
||||
onPress={() => setMode('network')}
|
||||
/>
|
||||
<ModePill
|
||||
active={mode === "custom"}
|
||||
active={mode === 'custom'}
|
||||
icon={<Lock color="white" size={14} />}
|
||||
label="Specific people"
|
||||
onPress={() => setMode("custom")}
|
||||
onPress={() => setMode('custom')}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{mode === "network" ? (
|
||||
{mode === 'network' ? (
|
||||
<View className="px-5 pb-6">
|
||||
<Text className="text-white/60 text-sm">
|
||||
Everyone in {networkName ?? "this network"} can see this stream.
|
||||
Everyone in {networkName ?? 'this network'} can see this stream.
|
||||
</Text>
|
||||
</View>
|
||||
) : (
|
||||
<ScrollView contentContainerClassName="px-2 pb-4">
|
||||
{others.length === 0 ? (
|
||||
<Text className="text-white/50 text-sm px-3 py-4">
|
||||
You're the only member of this network. Invite people on desktop,
|
||||
You’re the only member of this network. Invite people on desktop,
|
||||
then come back to choose specific viewers.
|
||||
</Text>
|
||||
) : (
|
||||
@@ -140,15 +142,11 @@ export function VisibilityPickerSheet({
|
||||
key={human.id}
|
||||
onPress={() => toggle(human.id)}
|
||||
className={cn(
|
||||
"flex-row items-center gap-3 px-3 py-2.5 rounded-lg",
|
||||
isSelected ? "bg-white/10" : "active:bg-white/5",
|
||||
'flex-row items-center gap-3 px-3 py-2.5 rounded-lg',
|
||||
isSelected ? 'bg-white/10' : 'active:bg-white/5',
|
||||
)}
|
||||
>
|
||||
<Avatar
|
||||
humanId={human.id}
|
||||
humans={humans}
|
||||
size="sm"
|
||||
/>
|
||||
<Avatar humanId={human.id} humans={humans} size="sm" />
|
||||
<View className="flex-1">
|
||||
<Text
|
||||
className="text-white text-sm font-medium"
|
||||
@@ -156,19 +154,14 @@ export function VisibilityPickerSheet({
|
||||
>
|
||||
{display.displayName}
|
||||
</Text>
|
||||
<Text
|
||||
className="text-white/40 text-xs"
|
||||
numberOfLines={1}
|
||||
>
|
||||
<Text className="text-white/40 text-xs" numberOfLines={1}>
|
||||
{display.email}
|
||||
</Text>
|
||||
</View>
|
||||
<View
|
||||
className={cn(
|
||||
"h-6 w-6 items-center justify-center rounded-full border",
|
||||
isSelected
|
||||
? "bg-white border-white"
|
||||
: "border-white/30",
|
||||
'h-6 w-6 items-center justify-center rounded-full border',
|
||||
isSelected ? 'bg-white border-white' : 'border-white/30',
|
||||
)}
|
||||
>
|
||||
{isSelected ? (
|
||||
@@ -200,15 +193,15 @@ function ModePill({
|
||||
<Pressable
|
||||
onPress={onPress}
|
||||
className={cn(
|
||||
"flex-1 flex-row items-center justify-center gap-1.5 rounded-md py-2",
|
||||
active ? "bg-white/15" : "",
|
||||
'flex-1 flex-row items-center justify-center gap-1.5 rounded-md py-2',
|
||||
active ? 'bg-white/15' : '',
|
||||
)}
|
||||
>
|
||||
{icon}
|
||||
<Text
|
||||
className={cn(
|
||||
"text-xs",
|
||||
active ? "text-white font-semibold" : "text-white/60",
|
||||
'text-xs',
|
||||
active ? 'text-white font-semibold' : 'text-white/60',
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
|
||||
Reference in New Issue
Block a user