@@ -0,0 +1,98 @@
|
|||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
import { createPortal } from "react-dom";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { updateParticleProperties } from "@/lib/firestore-particles";
|
||||||
|
import { particlePath, toFirestoreDocPath } from "@/lib/particle-path";
|
||||||
|
import type { Particle } from "@/api/types";
|
||||||
|
|
||||||
|
interface RenameStreamOverlayProps {
|
||||||
|
networkId: string;
|
||||||
|
streamParticle: Particle & { type: "stream" };
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RenameStreamOverlay({
|
||||||
|
networkId,
|
||||||
|
streamParticle,
|
||||||
|
onClose,
|
||||||
|
}: RenameStreamOverlayProps) {
|
||||||
|
const [name, setName] = useState(streamParticle.properties.name);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
|
const trimmed = name.trim();
|
||||||
|
const canSave =
|
||||||
|
!saving &&
|
||||||
|
trimmed.length > 0 &&
|
||||||
|
trimmed !== streamParticle.properties.name;
|
||||||
|
|
||||||
|
const handleSave = useCallback(async () => {
|
||||||
|
if (!canSave) return;
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
const docPath = toFirestoreDocPath(particlePath(networkId, [streamParticle.id]));
|
||||||
|
await updateParticleProperties<"stream">(docPath, { name: trimmed });
|
||||||
|
onClose();
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
}, [canSave, networkId, onClose, streamParticle.id, trimmed]);
|
||||||
|
|
||||||
|
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="absolute inset-0 bg-black/60 backdrop-blur-sm"
|
||||||
|
onClick={onClose}
|
||||||
|
/>
|
||||||
|
<div className="absolute left-1/2 top-1/2 w-full max-w-sm -translate-x-1/2 -translate-y-1/2 rounded-2xl border border-white/10 bg-white/5 p-6 shadow-2xl backdrop-blur-xl">
|
||||||
|
<div className="mb-4 flex items-center justify-between">
|
||||||
|
<h2 className="text-sm font-semibold text-white/70">Rename stream</h2>
|
||||||
|
<span className="text-xs text-white/30">
|
||||||
|
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs">
|
||||||
|
Esc
|
||||||
|
</kbd>{" "}
|
||||||
|
to close
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
autoFocus
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
onFocus={(e) => e.currentTarget.select()}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter") {
|
||||||
|
e.preventDefault();
|
||||||
|
handleSave();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
placeholder="Stream name"
|
||||||
|
className="border-white/10 bg-white/5 text-white placeholder-white/30 focus-visible:border-white/30 focus-visible:ring-0"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="mt-4 flex items-center justify-end gap-2">
|
||||||
|
<Button variant="ghost" size="sm" onClick={onClose}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" onClick={handleSave} disabled={!canSave}>
|
||||||
|
Save
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>,
|
||||||
|
document.body,
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,263 @@
|
|||||||
|
import { useCallback, useEffect, useMemo } from "react";
|
||||||
|
import { createPortal } from "react-dom";
|
||||||
|
import { X, UserPlus, Globe, Users, Lock } from "lucide-react";
|
||||||
|
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
|
||||||
|
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||||
|
import {
|
||||||
|
buildCustomVisibility,
|
||||||
|
buildNetworkVisibility,
|
||||||
|
parseVisibleTo,
|
||||||
|
} from "@/lib/stream-visibility";
|
||||||
|
import { updateParticleVisibleTo } from "@/lib/firestore-particles";
|
||||||
|
import { particlePath, toFirestoreDocPath } from "@/lib/particle-path";
|
||||||
|
import { useNetwork } from "@/hooks/use-networks";
|
||||||
|
import { cn, getInitials } from "@/lib/utils";
|
||||||
|
import type { Particle } from "@/api/types";
|
||||||
|
|
||||||
|
interface StreamMembersOverlayProps {
|
||||||
|
networkId: string;
|
||||||
|
streamParticle: Particle & { type: "stream" };
|
||||||
|
isCreator: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function StreamMembersOverlay({
|
||||||
|
networkId,
|
||||||
|
streamParticle,
|
||||||
|
isCreator,
|
||||||
|
onClose,
|
||||||
|
}: StreamMembersOverlayProps) {
|
||||||
|
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 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="absolute inset-0 bg-black/60 backdrop-blur-sm"
|
||||||
|
onClick={onClose}
|
||||||
|
/>
|
||||||
|
<div className="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 border-white/10 bg-white/5 p-6 shadow-2xl backdrop-blur-xl">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="mb-4 flex items-center justify-between">
|
||||||
|
<h2 className="text-sm font-semibold text-white/70">Members</h2>
|
||||||
|
<span className="text-xs text-white/30">
|
||||||
|
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs">
|
||||||
|
Esc
|
||||||
|
</kbd>{" "}
|
||||||
|
to close
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Visibility */}
|
||||||
|
<section className="mb-4">
|
||||||
|
<h3 className="mb-2 text-[10px] font-medium uppercase tracking-widest text-white/30">
|
||||||
|
Visibility
|
||||||
|
</h3>
|
||||||
|
{isCreator ? (
|
||||||
|
<div className="grid grid-cols-2 gap-1 rounded-lg bg-white/5 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="flex items-center gap-2 text-sm text-white/70">
|
||||||
|
{visibility.mode === "network" ? (
|
||||||
|
<>
|
||||||
|
<Globe className="size-3.5 text-white/40" />
|
||||||
|
<span>Everyone in {network?.name ?? "network"}</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Lock className="size-3.5 text-white/40" />
|
||||||
|
<span>{memberIds.length} specific people</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Member list */}
|
||||||
|
<section className="flex min-h-0 flex-1 flex-col">
|
||||||
|
<h3 className="mb-2 text-[10px] font-medium uppercase tracking-widest text-white/30">
|
||||||
|
{visibility.mode === "network" ? "Has access" : "People"}{" "}
|
||||||
|
<span className="ml-1 text-white/20">{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 human = humans.find((h) => h.id === id);
|
||||||
|
const isCreatorRow = id === creatorId;
|
||||||
|
const canRemove =
|
||||||
|
isCreator && visibility.mode === "custom" && !isCreatorRow;
|
||||||
|
return (
|
||||||
|
<li
|
||||||
|
key={id}
|
||||||
|
className="group flex items-center gap-2.5 rounded px-2 py-1.5 text-sm text-white/70"
|
||||||
|
>
|
||||||
|
<Avatar size="sm">
|
||||||
|
<AvatarFallback className="text-[10px]">
|
||||||
|
{human ? getInitials(human.email) : "?"}
|
||||||
|
</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
<span className="flex-1 truncate">
|
||||||
|
{human?.email_prefix ?? id}
|
||||||
|
</span>
|
||||||
|
{isCreatorRow && (
|
||||||
|
<span className="text-[10px] uppercase tracking-wider text-white/30">
|
||||||
|
Creator
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{canRemove && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => removeMember(id)}
|
||||||
|
className="rounded p-1 text-white/30 opacity-0 transition-opacity hover:bg-white/10 hover:text-white/70 group-hover:opacity-100"
|
||||||
|
aria-label={`Remove ${human?.email_prefix ?? id}`}
|
||||||
|
>
|
||||||
|
<X className="size-3.5" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
</ScrollArea>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Add */}
|
||||||
|
{isCreator && visibility.mode === "custom" && availableToAdd.length > 0 && (
|
||||||
|
<section className="mt-4 border-t border-white/5 pt-4">
|
||||||
|
<h3 className="mb-2 flex items-center gap-1.5 text-[10px] font-medium uppercase tracking-widest text-white/30">
|
||||||
|
<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(
|
||||||
|
"flex w-full items-center gap-2.5 rounded px-2 py-1.5 text-left text-sm text-white/70 transition-colors hover:bg-white/5",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Avatar size="sm">
|
||||||
|
<AvatarFallback className="text-[10px]">
|
||||||
|
{getInitials(human.email)}
|
||||||
|
</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
<span className="flex-1 truncate">{human.email_prefix}</span>
|
||||||
|
<UserPlus className="size-3.5 text-white/30" />
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</ScrollArea>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isCreator && visibility.mode === "custom" && availableToAdd.length === 0 && (
|
||||||
|
<p className="mt-4 text-center text-xs text-white/30">
|
||||||
|
<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-white/10 text-white/90"
|
||||||
|
: "text-white/50 hover:text-white/80",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{icon}
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -22,8 +22,11 @@ import {
|
|||||||
DropdownMenuItem,
|
DropdownMenuItem,
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from "@/components/ui/dropdown-menu";
|
} from "@/components/ui/dropdown-menu";
|
||||||
import { Settings, CircleCheckBig, CircleDot, EllipsisVertical } from "lucide-react";
|
import { Settings, CircleCheckBig, CircleDot, EllipsisVertical, Pencil, Globe } from "lucide-react";
|
||||||
import { updateStreamStatus, toggleParticleReaction } from "@/lib/firestore-particles";
|
import { updateStreamStatus, toggleParticleReaction } from "@/lib/firestore-particles";
|
||||||
|
import { RenameStreamOverlay } from "@/features/particles/rename-stream-overlay";
|
||||||
|
import { StreamMembersOverlay } from "@/features/particles/stream-members-overlay";
|
||||||
|
import { parseVisibleTo } from "@/lib/stream-visibility";
|
||||||
import { ReactionBar } from "@/features/particles/reaction-bar";
|
import { ReactionBar } from "@/features/particles/reaction-bar";
|
||||||
import { Breadcrumb, BreadcrumbItem, BreadcrumbList, BreadcrumbPage, BreadcrumbSeparator } from "@/components/ui/breadcrumb";
|
import { Breadcrumb, BreadcrumbItem, BreadcrumbList, BreadcrumbPage, BreadcrumbSeparator } from "@/components/ui/breadcrumb";
|
||||||
import { WindowControls } from "@/components/window-controls";
|
import { WindowControls } from "@/components/window-controls";
|
||||||
@@ -624,6 +627,10 @@ function StreamViewControls({
|
|||||||
function TopBar({ networkId, particle, streamParticle }: { networkId: string; particle: Particle | null; streamParticle: Particle & { type: "stream" } }) {
|
function TopBar({ networkId, particle, streamParticle }: { networkId: string; particle: Particle | null; streamParticle: Particle & { type: "stream" } }) {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const network = useNetwork(networkId);
|
const network = useNetwork(networkId);
|
||||||
|
const userId = useAuthStore((s) => s.user?.id);
|
||||||
|
const isCreator = !!userId && userId === streamParticle.created_by_human_id;
|
||||||
|
const [renameOpen, setRenameOpen] = useState(false);
|
||||||
|
const [membersOpen, setMembersOpen] = useState(false);
|
||||||
|
|
||||||
const huddleParticipants = streamParticle.huddle_active_participants ?? [];
|
const huddleParticipants = streamParticle.huddle_active_participants ?? [];
|
||||||
const hasActiveHuddle = huddleParticipants.length > 0;
|
const hasActiveHuddle = huddleParticipants.length > 0;
|
||||||
@@ -697,6 +704,12 @@ function TopBar({ networkId, particle, streamParticle }: { networkId: string; pa
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<MembersIndicator
|
||||||
|
networkId={networkId}
|
||||||
|
streamParticle={streamParticle}
|
||||||
|
onClick={() => setMembersOpen(true)}
|
||||||
|
/>
|
||||||
|
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<Button
|
<Button
|
||||||
@@ -726,16 +739,99 @@ function TopBar({ networkId, particle, streamParticle }: { networkId: string; pa
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
{isCreator && (
|
||||||
|
<DropdownMenuItem onSelect={() => setRenameOpen(true)}>
|
||||||
|
<Pencil className="size-4" />
|
||||||
|
Rename stream
|
||||||
|
</DropdownMenuItem>
|
||||||
|
)}
|
||||||
<DropdownMenuItem onSelect={() => navigate("/settings")}>
|
<DropdownMenuItem onSelect={() => navigate("/settings")}>
|
||||||
<Settings className="size-4" />
|
<Settings className="size-4" />
|
||||||
Settings
|
Settings
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
|
|
||||||
|
{renameOpen && isCreator && (
|
||||||
|
<RenameStreamOverlay
|
||||||
|
networkId={networkId}
|
||||||
|
streamParticle={streamParticle}
|
||||||
|
onClose={() => setRenameOpen(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{membersOpen && (
|
||||||
|
<StreamMembersOverlay
|
||||||
|
networkId={networkId}
|
||||||
|
streamParticle={streamParticle}
|
||||||
|
isCreator={isCreator}
|
||||||
|
onClose={() => setMembersOpen(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function MembersIndicator({
|
||||||
|
networkId,
|
||||||
|
streamParticle,
|
||||||
|
onClick,
|
||||||
|
}: {
|
||||||
|
networkId: string;
|
||||||
|
streamParticle: Particle & { type: "stream" };
|
||||||
|
onClick: () => void;
|
||||||
|
}) {
|
||||||
|
const network = useNetwork(networkId);
|
||||||
|
const visibility = parseVisibleTo(streamParticle.visible_to, networkId);
|
||||||
|
const humans = network?.humans ?? [];
|
||||||
|
|
||||||
|
const memberIds =
|
||||||
|
visibility.mode === "network"
|
||||||
|
? humans.map((h) => h.id)
|
||||||
|
: visibility.humanIds;
|
||||||
|
const shownMembers = memberIds
|
||||||
|
.slice(0, 3)
|
||||||
|
.map((id) => humans.find((h) => h.id === id))
|
||||||
|
.filter((h): h is NonNullable<typeof h> => !!h);
|
||||||
|
const overflow = memberIds.length - shownMembers.length;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<button
|
||||||
|
onClick={onClick}
|
||||||
|
className="no-drag flex items-center gap-1.5 rounded-full bg-white/5 px-2 py-1 text-xs text-white/70 backdrop-blur-sm transition-colors hover:bg-white/10"
|
||||||
|
>
|
||||||
|
{visibility.mode === "network" ? (
|
||||||
|
<>
|
||||||
|
<Globe className="size-3 text-white/50" />
|
||||||
|
<span>Everyone</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<AvatarGroup>
|
||||||
|
{shownMembers.map((human) => (
|
||||||
|
<Avatar key={human.id} size="sm">
|
||||||
|
<AvatarFallback className="text-[8px]">
|
||||||
|
{getInitials(human.email)}
|
||||||
|
</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
))}
|
||||||
|
</AvatarGroup>
|
||||||
|
{overflow > 0 && <span className="text-white/50">+{overflow}</span>}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
{visibility.mode === "network"
|
||||||
|
? `Everyone in ${network?.name ?? "network"}`
|
||||||
|
: `${memberIds.length} ${memberIds.length === 1 ? "member" : "members"}`}
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function NetworkBreadcrumbContent({ networkId }: { networkId: string }) {
|
function NetworkBreadcrumbContent({ networkId }: { networkId: string }) {
|
||||||
const { data: networks } = useNetworks();
|
const { data: networks } = useNetworks();
|
||||||
const network = networks?.find((n) => n.id === networkId);
|
const network = networks?.find((n) => n.id === networkId);
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { removeDuplicates } from "@/lib/utils";
|
||||||
|
|
||||||
|
const HUMAN_PREFIX = "human:";
|
||||||
|
const NETWORK_PREFIX = "network:";
|
||||||
|
|
||||||
|
export type StreamVisibility =
|
||||||
|
| { mode: "network" }
|
||||||
|
| { mode: "custom"; humanIds: string[] };
|
||||||
|
|
||||||
|
export function parseVisibleTo(
|
||||||
|
visibleTo: string[],
|
||||||
|
networkId: string,
|
||||||
|
): StreamVisibility {
|
||||||
|
if (visibleTo.includes(`${NETWORK_PREFIX}${networkId}`)) {
|
||||||
|
return { mode: "network" };
|
||||||
|
}
|
||||||
|
const humanIds = visibleTo
|
||||||
|
.filter((v) => v.startsWith(HUMAN_PREFIX))
|
||||||
|
.map((v) => v.slice(HUMAN_PREFIX.length));
|
||||||
|
return { mode: "custom", humanIds };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildNetworkVisibility(networkId: string): string[] {
|
||||||
|
return [`${NETWORK_PREFIX}${networkId}`];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildCustomVisibility(humanIds: string[]): string[] {
|
||||||
|
return removeDuplicates(humanIds).map((id) => `${HUMAN_PREFIX}${id}`);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user