289 lines
10 KiB
TypeScript
289 lines
10 KiB
TypeScript
import { useCallback, useEffect, useMemo } from 'react';
|
|
import { createPortal } from 'react-dom';
|
|
import { X, UserPlus, Globe, Users, Lock } from 'lucide-react';
|
|
import { HumanAvatar } from '@/components/human-avatar';
|
|
import { ScrollArea } from '@/components/ui/scroll-area';
|
|
import { KeyHint } from '@/components/key-hint';
|
|
import {
|
|
buildCustomVisibility,
|
|
buildNetworkVisibility,
|
|
parseVisibleTo,
|
|
} from '@/lib/stream-visibility';
|
|
import { updateParticleVisibleTo } from '@/lib/firestore-particles';
|
|
import { toFirestoreDocPath, type ParticlePath } from '@/lib/particle-path';
|
|
import { useNetwork } from '@/hooks/use-networks';
|
|
import { cn, getInitials } from '@/lib/utils';
|
|
import { resolveHumanDisplay } from '@/lib/humans';
|
|
import type { Particle } from '@/api/types';
|
|
import { useSuspendPlayback } from '@/hooks/use-suspend-playback';
|
|
|
|
interface StreamMembersOverlayProps {
|
|
networkId: string;
|
|
streamPath: ParticlePath;
|
|
streamParticle: Particle & { type: 'stream' };
|
|
isCreator: boolean;
|
|
onClose: () => void;
|
|
}
|
|
|
|
export function StreamMembersOverlay({
|
|
networkId,
|
|
streamPath,
|
|
streamParticle,
|
|
isCreator,
|
|
onClose,
|
|
}: StreamMembersOverlayProps) {
|
|
useSuspendPlayback(true, 'stream-members');
|
|
|
|
const network = useNetwork(networkId);
|
|
const humans = network?.humans ?? [];
|
|
const creatorId = streamParticle.created_by_human_id;
|
|
const visibility = useMemo(
|
|
() => parseVisibleTo(streamParticle.visible_to, networkId),
|
|
[streamParticle.visible_to, networkId],
|
|
);
|
|
|
|
const docPath = useMemo(() => toFirestoreDocPath(streamPath), [streamPath]);
|
|
|
|
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 setNetworkWide = useCallback(() => {
|
|
void updateParticleVisibleTo(docPath, buildNetworkVisibility(networkId));
|
|
}, [docPath, networkId]);
|
|
|
|
const setCustomOnlyCreator = useCallback(() => {
|
|
void updateParticleVisibleTo(docPath, buildCustomVisibility([creatorId]));
|
|
}, [docPath, creatorId]);
|
|
|
|
const removeMember = useCallback(
|
|
(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 updateParticleVisibleTo(docPath, buildCustomVisibility(next));
|
|
},
|
|
[docPath, creatorId, visibility],
|
|
);
|
|
|
|
const addMember = useCallback(
|
|
(id: string) => {
|
|
if (visibility.mode !== 'custom') return;
|
|
void updateParticleVisibleTo(
|
|
docPath,
|
|
buildCustomVisibility([...visibility.humanIds, id]),
|
|
);
|
|
},
|
|
[docPath, visibility],
|
|
);
|
|
|
|
useEffect(() => {
|
|
const handler = (e: KeyboardEvent) => {
|
|
if (e.key === 'Escape') {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
onClose();
|
|
}
|
|
};
|
|
window.addEventListener('keydown', handler, { capture: true });
|
|
return () =>
|
|
window.removeEventListener('keydown', handler, { capture: true });
|
|
}, [onClose]);
|
|
|
|
return createPortal(
|
|
<div className="fixed inset-0 z-[100]">
|
|
<div
|
|
className="bg-scrim/60 absolute inset-0 backdrop-blur-sm"
|
|
onClick={onClose}
|
|
/>
|
|
<div className="bg-popover/95 text-popover-foreground border-border absolute left-1/2 top-1/2 flex max-h-[80vh] w-full max-w-sm -translate-x-1/2 -translate-y-1/2 flex-col overflow-hidden rounded-2xl border p-6 shadow-2xl backdrop-blur-xl">
|
|
{/* Header */}
|
|
<div className="mb-4 flex items-center justify-between">
|
|
<h2 className="text-sm font-semibold">Members</h2>
|
|
<KeyHint
|
|
keys="Esc"
|
|
onClick={onClose}
|
|
title="Close (or press Esc)"
|
|
className="text-muted-foreground text-xs"
|
|
>
|
|
to close
|
|
</KeyHint>
|
|
</div>
|
|
|
|
{/* Visibility */}
|
|
<section className="mb-4">
|
|
<h3 className="text-muted-foreground mb-2 text-[10px] font-medium uppercase tracking-widest">
|
|
Visibility
|
|
</h3>
|
|
{isCreator ? (
|
|
<div className="bg-muted grid grid-cols-2 gap-1 rounded-lg p-1">
|
|
<VisibilityPill
|
|
active={visibility.mode === 'network'}
|
|
icon={<Globe className="size-3.5" />}
|
|
label="Network-wide"
|
|
onClick={setNetworkWide}
|
|
/>
|
|
<VisibilityPill
|
|
active={visibility.mode === 'custom'}
|
|
icon={<Lock className="size-3.5" />}
|
|
label="Specific people"
|
|
onClick={setCustomOnlyCreator}
|
|
/>
|
|
</div>
|
|
) : (
|
|
<div className="text-foreground flex items-center gap-2 text-sm">
|
|
{visibility.mode === 'network' ? (
|
|
<>
|
|
<Globe className="text-muted-foreground size-3.5" />
|
|
<span>Everyone in {network?.name ?? 'network'}</span>
|
|
</>
|
|
) : (
|
|
<>
|
|
<Lock className="text-muted-foreground size-3.5" />
|
|
<span>{memberIds.length} specific people</span>
|
|
</>
|
|
)}
|
|
</div>
|
|
)}
|
|
</section>
|
|
|
|
{/* Member list */}
|
|
<section className="flex min-h-0 flex-1 flex-col">
|
|
<h3 className="text-muted-foreground mb-2 text-[10px] font-medium uppercase tracking-widest">
|
|
{visibility.mode === 'network' ? 'Has access' : 'People'}{' '}
|
|
<span className="text-muted-foreground ml-1">
|
|
{memberIds.length}
|
|
</span>
|
|
</h3>
|
|
<ScrollArea className="min-h-0 flex-1">
|
|
<ul className="flex flex-col gap-0.5 pr-2">
|
|
{memberIds.map((id) => {
|
|
const display = resolveHumanDisplay(id, humans);
|
|
const isCreatorRow = id === creatorId;
|
|
const canRemove =
|
|
isCreator && visibility.mode === 'custom' && !isCreatorRow;
|
|
return (
|
|
<li
|
|
key={id}
|
|
className="text-foreground group flex items-center gap-2.5 rounded px-2 py-1.5 text-sm"
|
|
>
|
|
<HumanAvatar
|
|
size="sm"
|
|
avatarObjectId={display.avatarObjectId}
|
|
initials={display.initials}
|
|
fallbackClassName="text-[10px]"
|
|
/>
|
|
<span
|
|
className={cn(
|
|
'flex-1 truncate',
|
|
!display.exists && 'text-muted-foreground italic',
|
|
)}
|
|
>
|
|
{display.displayName}
|
|
</span>
|
|
{isCreatorRow && (
|
|
<span className="text-muted-foreground text-[10px] uppercase tracking-wider">
|
|
Creator
|
|
</span>
|
|
)}
|
|
{canRemove && (
|
|
<button
|
|
type="button"
|
|
onClick={() => removeMember(id)}
|
|
className="text-muted-foreground hover:bg-accent hover:text-foreground rounded p-1 opacity-0 transition-opacity group-hover:opacity-100"
|
|
aria-label={`Remove ${display.displayName}`}
|
|
>
|
|
<X className="size-3.5" />
|
|
</button>
|
|
)}
|
|
</li>
|
|
);
|
|
})}
|
|
</ul>
|
|
</ScrollArea>
|
|
</section>
|
|
|
|
{/* Add */}
|
|
{isCreator &&
|
|
visibility.mode === 'custom' &&
|
|
availableToAdd.length > 0 && (
|
|
<section className="border-border mt-4 border-t pt-4">
|
|
<h3 className="text-muted-foreground mb-2 flex items-center gap-1.5 text-[10px] font-medium uppercase tracking-widest">
|
|
<UserPlus className="size-3" />
|
|
Add people
|
|
</h3>
|
|
<ScrollArea className="max-h-32">
|
|
<ul className="flex flex-col gap-0.5 pr-2">
|
|
{availableToAdd.map((human) => (
|
|
<li key={human.id}>
|
|
<button
|
|
type="button"
|
|
onClick={() => addMember(human.id)}
|
|
className={cn(
|
|
'text-foreground hover:bg-accent flex w-full items-center gap-2.5 rounded px-2 py-1.5 text-left text-sm transition-colors',
|
|
)}
|
|
>
|
|
<HumanAvatar
|
|
size="sm"
|
|
avatarObjectId={human.avatar_object_id}
|
|
initials={getInitials(human.email)}
|
|
fallbackClassName="text-[10px]"
|
|
/>
|
|
<span className="flex-1 truncate">
|
|
{human.email_prefix}
|
|
</span>
|
|
<UserPlus className="text-muted-foreground size-3.5" />
|
|
</button>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
</ScrollArea>
|
|
</section>
|
|
)}
|
|
|
|
{isCreator &&
|
|
visibility.mode === 'custom' &&
|
|
availableToAdd.length === 0 && (
|
|
<p className="text-muted-foreground mt-4 text-center text-xs">
|
|
<Users className="mr-1 inline size-3" />
|
|
Everyone in the network is already a member
|
|
</p>
|
|
)}
|
|
</div>
|
|
</div>,
|
|
document.body,
|
|
);
|
|
}
|
|
|
|
function VisibilityPill({
|
|
active,
|
|
icon,
|
|
label,
|
|
onClick,
|
|
}: {
|
|
active: boolean;
|
|
icon: React.ReactNode;
|
|
label: string;
|
|
onClick: () => void;
|
|
}) {
|
|
return (
|
|
<button
|
|
type="button"
|
|
onClick={onClick}
|
|
className={cn(
|
|
'flex items-center justify-center gap-1.5 rounded-md px-2 py-1.5 text-xs transition-colors',
|
|
active
|
|
? 'bg-background text-foreground shadow-sm'
|
|
: 'text-muted-foreground hover:text-foreground',
|
|
)}
|
|
>
|
|
{icon}
|
|
{label}
|
|
</button>
|
|
);
|
|
}
|