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:
Arjun Patel
2026-06-02 07:44:24 -07:00
committed by GitHub
parent 2fe562ce2b
commit a8a0b7db1b
258 changed files with 7822 additions and 5195 deletions
@@ -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"}.
Couldnt 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) {