Files
llink/js/mobile/src/features/streams/NewStreamScreen.tsx
T
Arjun PatelandGitHub a8a0b7db1b 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
2026-06-02 07:44:24 -07:00

209 lines
6.3 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useMemo, useState } from 'react';
import {
KeyboardAvoidingView,
Platform,
Pressable,
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';
import {
buildNetworkVisibility,
parseVisibleTo,
} 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;
/**
* Top-level stream creation. The user names the stream, picks visibility, and
* composes the first particle on one screen — desktop's compose-overlay flow
* collapsed into a touch-native single page.
*/
export function NewStreamScreen({
route,
navigation,
}: 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 [visibleTo, setVisibleTo] = useState<string[]>(() =>
buildNetworkVisibility(networkId),
);
const [pickerOpen, setPickerOpen] = useState(false);
const effectiveName = name.trim() || suggestion;
const handleStreamCreated = (streamId: string) => {
navigation.replace('StreamView', { networkId, streamId });
};
const submitText = async (content: string) => {
if (!userId) throw new Error('Not signed in.');
try {
const { streamId } = await createStreamWithFirstParticle({
networkId,
name: effectiveName,
visibleTo,
createdByHumanId: userId,
firstParticle: { type: 'text', content },
});
handleStreamCreated(streamId);
} catch (err) {
toast.error(toUserMessage(err));
throw err;
}
};
const submitMedia = async ({
fileUri,
mimeType,
durationMs,
source,
}: {
fileUri: string;
mimeType: string;
durationMs: number;
source: 'camera' | 'screen';
}) => {
if (!userId) throw new Error('Not signed in.');
try {
const { streamId } = await createStreamWithFirstParticle({
networkId,
name: effectiveName,
visibleTo,
createdByHumanId: userId,
firstParticle: {
type: 'media',
fileUri,
mimeType,
durationMs,
source,
},
});
handleStreamCreated(streamId);
} catch (err) {
toast.error(toUserMessage(err));
throw err;
}
};
const placeholderPath = particlePath(networkId, []);
const visibility = parseVisibleTo(visibleTo, networkId);
const visibleSummary =
visibility.mode === 'network'
? `Everyone in ${network?.name ?? 'this network'}`
: `${visibility.humanIds.length} ${
visibility.humanIds.length === 1 ? 'person' : 'people'
}`;
return (
<View className="flex-1 bg-black">
<StatusBar style="light" />
<SafeAreaView edges={['top']}>
<View className="flex-row items-center justify-between px-4 pt-3 pb-2">
<Pressable
onPress={() => navigation.goBack()}
hitSlop={12}
accessibilityLabel="Cancel"
>
<X color="white" size={22} strokeWidth={1.8} />
</Pressable>
<Text className="text-white text-base font-semibold">New stream</Text>
<View style={{ width: 22 }} />
</View>
</SafeAreaView>
<KeyboardAvoidingView
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
className="flex-1"
>
<View className="flex-1 px-6 pt-4">
<Text className="text-white/60 text-xs uppercase tracking-wide mb-2">
Name
</Text>
<TextInput
value={name}
onChangeText={(v) => setName(v.slice(0, STREAM_NAME_MAX))}
placeholder={suggestion}
placeholderTextColor="rgba(255,255,255,0.35)"
autoCapitalize="none"
autoCorrect={false}
maxLength={STREAM_NAME_MAX}
className="bg-white/10 rounded-xl px-4 py-3 text-white text-lg"
/>
<Text className="text-white/60 text-xs uppercase tracking-wide mt-6 mb-2">
Visible to
</Text>
<Pressable
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}
/>
) : (
<Lock color="rgba(255,255,255,0.7)" size={18} strokeWidth={1.6} />
)}
<Text className="text-white text-base flex-1" numberOfLines={1}>
{visibleSummary}
</Text>
<ChevronRight
color="rgba(255,255,255,0.5)"
size={18}
strokeWidth={1.6}
/>
</Pressable>
<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
the first particle in your new stream.
</Text>
</View>
</View>
</KeyboardAvoidingView>
<ComposeDock
networkId={networkId}
targetPath={placeholderPath}
silentPresence
submitMedia={submitMedia}
submitText={submitText}
/>
<VisibilityPickerSheet
open={pickerOpen}
onClose={() => setPickerOpen(false)}
networkId={networkId}
networkName={network?.name}
humans={network?.humans ?? []}
selfHumanId={userId}
visibleTo={visibleTo}
onChange={setVisibleTo}
/>
</View>
);
}