* wip * wip * wip * format * wip(mobile): lint and format * cleanup * nits * nits * idiomatic react * nit * format all root files
263 lines
8.7 KiB
TypeScript
263 lines
8.7 KiB
TypeScript
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';
|
|
|
|
interface StreamMembersSheetProps {
|
|
open: boolean;
|
|
onClose: () => void;
|
|
networkId: string;
|
|
streamParticle: Particle & { type: 'stream' };
|
|
isCreator: boolean;
|
|
}
|
|
|
|
/**
|
|
* Read-only-for-non-creators view of who can see the stream, plus an inline
|
|
* editor for creators to flip between network-wide and per-person and to
|
|
* add/remove people. Mobile counterpart of stream-members-overlay.tsx.
|
|
*/
|
|
export function StreamMembersSheet({
|
|
open,
|
|
onClose,
|
|
networkId,
|
|
streamParticle,
|
|
isCreator,
|
|
}: StreamMembersSheetProps) {
|
|
useSuspendPlayback(open, 'stream-members');
|
|
|
|
const { onlineHumanIds } = useStreamPresence();
|
|
const network = useNetwork(networkId);
|
|
const humans = network?.humans ?? [];
|
|
const creatorId = streamParticle.created_by_human_id;
|
|
const visibility = parseVisibleTo(streamParticle.visible_to, networkId);
|
|
|
|
const docPath = useMemo(
|
|
() => toFirestoreDocPath(particlePath(networkId, [streamParticle.id])),
|
|
[networkId, streamParticle.id],
|
|
);
|
|
|
|
const memberIds =
|
|
visibility.mode === 'network'
|
|
? humans.map((h) => h.id)
|
|
: visibility.humanIds;
|
|
const memberSet = new Set(memberIds);
|
|
const availableToAdd = humans.filter((h) => !memberSet.has(h.id));
|
|
|
|
const apply = async (next: string[]) => {
|
|
try {
|
|
await updateParticleVisibleTo(docPath, next);
|
|
} catch (err) {
|
|
toast.error(toUserMessage(err));
|
|
}
|
|
};
|
|
|
|
const setNetworkWide = () => apply(buildNetworkVisibility(networkId));
|
|
const setCustomOnlyCreator = () => apply(buildCustomVisibility([creatorId]));
|
|
|
|
const removeMember = (id: string) => {
|
|
if (visibility.mode !== 'custom') return;
|
|
if (id === creatorId) return;
|
|
const next = visibility.humanIds.filter((x) => x !== id);
|
|
if (next.length === 0) return;
|
|
void apply(buildCustomVisibility(next));
|
|
};
|
|
|
|
const addMember = (id: string) => {
|
|
if (visibility.mode !== 'custom') return;
|
|
void apply(buildCustomVisibility([...visibility.humanIds, id]));
|
|
};
|
|
|
|
return (
|
|
<BottomSheet open={open} onClose={onClose} maxHeight="85%">
|
|
<View className="flex-row items-center justify-between px-5 pb-3">
|
|
<View style={{ width: 22 }} />
|
|
<Text className="text-white text-base font-semibold">Members</Text>
|
|
<Pressable onPress={onClose} hitSlop={12}>
|
|
<X color="rgba(255,255,255,0.7)" size={22} />
|
|
</Pressable>
|
|
</View>
|
|
|
|
<View className="px-5 pb-3">
|
|
<Text className="text-white/40 text-[10px] font-semibold uppercase tracking-widest mb-2">
|
|
Visibility
|
|
</Text>
|
|
{isCreator ? (
|
|
<View className="flex-row gap-1 bg-white/5 rounded-lg p-1">
|
|
<ModePill
|
|
active={visibility.mode === 'network'}
|
|
icon={<Globe color="white" size={14} />}
|
|
label="Network-wide"
|
|
onPress={setNetworkWide}
|
|
/>
|
|
<ModePill
|
|
active={visibility.mode === 'custom'}
|
|
icon={<Lock color="white" size={14} />}
|
|
label="Specific people"
|
|
onPress={setCustomOnlyCreator}
|
|
/>
|
|
</View>
|
|
) : (
|
|
<View className="flex-row items-center gap-2">
|
|
{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'}
|
|
</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'}
|
|
</Text>
|
|
</>
|
|
)}
|
|
</View>
|
|
)}
|
|
</View>
|
|
|
|
<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'} ·{' '}
|
|
{memberIds.length}
|
|
</Text>
|
|
{memberIds.map((id) => {
|
|
const display = resolveHumanDisplay(id, humans);
|
|
const isCreatorRow = id === creatorId;
|
|
const canRemove =
|
|
isCreator && visibility.mode === 'custom' && !isCreatorRow;
|
|
return (
|
|
<View key={id} className="flex-row items-center gap-3 py-2.5">
|
|
<Avatar
|
|
humanId={id}
|
|
humans={humans}
|
|
size="sm"
|
|
online={onlineHumanIds.has(id)}
|
|
/>
|
|
<View className="flex-1">
|
|
<Text
|
|
className={
|
|
display.exists
|
|
? '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}>
|
|
{display.email}
|
|
</Text>
|
|
) : null}
|
|
</View>
|
|
{isCreatorRow ? (
|
|
<Text className="text-white/30 text-[10px] uppercase tracking-wider">
|
|
Creator
|
|
</Text>
|
|
) : canRemove ? (
|
|
<Pressable
|
|
onPress={() => removeMember(id)}
|
|
hitSlop={10}
|
|
accessibilityLabel={`Remove ${display.displayName}`}
|
|
>
|
|
<X color="rgba(255,255,255,0.6)" size={18} />
|
|
</Pressable>
|
|
) : null}
|
|
</View>
|
|
);
|
|
})}
|
|
</View>
|
|
|
|
{isCreator &&
|
|
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">
|
|
Add people
|
|
</Text>
|
|
{availableToAdd.map((human) => {
|
|
const display = resolveHumanDisplay(human.id, humans);
|
|
return (
|
|
<Pressable
|
|
key={human.id}
|
|
onPress={() => addMember(human.id)}
|
|
className="flex-row items-center gap-3 py-2.5 active:bg-white/5 rounded-lg"
|
|
>
|
|
<Avatar
|
|
humanId={human.id}
|
|
humans={humans}
|
|
size="sm"
|
|
online={onlineHumanIds.has(human.id)}
|
|
/>
|
|
<View className="flex-1">
|
|
<Text
|
|
className="text-white text-sm font-medium"
|
|
numberOfLines={1}
|
|
>
|
|
{display.displayName}
|
|
</Text>
|
|
<Text className="text-white/40 text-xs" numberOfLines={1}>
|
|
{display.email}
|
|
</Text>
|
|
</View>
|
|
<Text className="text-white/60 text-sm">Add</Text>
|
|
</Pressable>
|
|
);
|
|
})}
|
|
</View>
|
|
) : null}
|
|
</ScrollView>
|
|
</BottomSheet>
|
|
);
|
|
}
|
|
|
|
function ModePill({
|
|
active,
|
|
icon,
|
|
label,
|
|
onPress,
|
|
}: {
|
|
active: boolean;
|
|
icon: React.ReactNode;
|
|
label: string;
|
|
onPress: () => void;
|
|
}) {
|
|
return (
|
|
<Pressable
|
|
onPress={onPress}
|
|
className={
|
|
'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'
|
|
}
|
|
>
|
|
{label}
|
|
</Text>
|
|
</Pressable>
|
|
);
|
|
}
|