Files
llink/js/desktop/src/features/particles/stream-top-bar.tsx
T
Arjun PatelGitHubcoderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
4facc6b371 feat: avatars for humans (#273)
* implement avatar backend functionality

* add avatar endpoints

* typo

* implement client side avatar upload and handling

* fixes

* Update go/internal/handler/handler.go

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>

* Update js/desktop/src/lib/avatar-image.ts

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>

* bug in order

* remove unused component

* fix invalid migration

* fix syntax errors

---------

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2026-06-11 15:30:21 -07:00

372 lines
12 KiB
TypeScript

import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useAuthStore } from '@/stores/auth-store';
import { apiClient } from '@/api/client';
import { isParticleDeleted, type Particle } from '@/api/types';
import { particlePath, toFirestoreDocPath } from '@/lib/particle-path';
import { AvatarGroup } from '@/components/ui/avatar';
import { HumanAvatar } from '@/components/human-avatar';
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from '@/components/ui/tooltip';
import { useNetwork } from '@/hooks/use-networks';
import { Button } from '@/components/ui/button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import {
Settings,
CircleCheckBig,
CircleDot,
EllipsisVertical,
Pencil,
Lock,
Globe,
Trash2,
} from 'lucide-react';
import { updateStreamStatus } from '@/lib/firestore-particles';
import { RenameStreamOverlay } from '@/features/particles/rename-stream-overlay';
import { DeleteParticleOverlay } from '@/features/particles/delete-particle-overlay';
import { StreamMembersOverlay } from '@/features/particles/stream-members-overlay';
import { parseVisibleTo } from '@/lib/stream-visibility';
import {
Breadcrumb,
BreadcrumbItem,
BreadcrumbList,
BreadcrumbPage,
BreadcrumbSeparator,
} from '@/components/ui/breadcrumb';
import { WindowControls } from '@/components/window-controls';
import { RelativeTimestamp } from '@/components/relative-timestamp';
import { useStreamPresence } from '@/features/particles/stream-presence-context';
import { resolveHumanDisplay } from '@/lib/humans';
import { platform } from '@/lib/platform';
import { requireDesktop } from '@/lib/platform/desktop-only';
function getParticleDisplayName(particle: Particle): string {
switch (particle.type) {
case 'stream':
case 'folder':
return particle.properties.name;
case 'quest':
return particle.properties.title;
case 'paper':
return particle.properties.title;
case 'file':
return particle.properties.filename;
case 'text':
return particle.properties.content.slice(0, 30);
case 'media':
return particle.type;
}
}
interface TopBarProps {
networkId: string;
particle: Particle | null;
streamParticle: Particle & { type: 'stream' };
}
export function TopBar({ networkId, particle, streamParticle }: TopBarProps) {
const navigate = useNavigate();
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 [deleteOpen, setDeleteOpen] = useState(false);
const canDeleteParticle =
!!particle &&
!!userId &&
particle.created_by_human_id === userId &&
particle.type !== 'stream' &&
particle.type !== 'folder' &&
!isParticleDeleted(particle);
const huddleParticipants = streamParticle.huddle_active_participants ?? [];
const hasActiveHuddle = huddleParticipants.length > 0;
const handleJoinHuddle = () => {
if (!requireDesktop('Huddle')) return;
apiClient
.getLivekitToken(networkId, streamParticle.id)
.then(({ token, server_url }) => {
platform.huddle.open({ token, serverUrl: server_url });
});
};
return (
<div className="drag-region flex flex-row px-2 gap-1 items-center">
<WindowControls />
<Breadcrumb className="no-drag rounded-full bg-black/30 backdrop-blur-sm px-3 py-1 mx-auto">
<BreadcrumbList>
{streamParticle && (
<>
<BreadcrumbItem className="text-xs">
<BreadcrumbPage>
{getParticleDisplayName(streamParticle)}
</BreadcrumbPage>
</BreadcrumbItem>
</>
)}
{particle && (
<>
<BreadcrumbSeparator />
<BreadcrumbItem className="text-xs">
<BreadcrumbPage>
<ParticleBreadcrumbContent
particle={particle}
networkId={networkId}
/>
</BreadcrumbPage>
</BreadcrumbItem>
</>
)}
</BreadcrumbList>
</Breadcrumb>
{hasActiveHuddle && (
<button
onClick={handleJoinHuddle}
className="no-drag flex items-center gap-2 rounded-full bg-red-500/20 px-3 py-1 backdrop-blur-sm transition-colors hover:bg-red-500/30"
>
<span className="relative flex size-2">
<span className="absolute inline-flex size-full animate-ping rounded-full bg-red-400 opacity-75" />
<span className="relative inline-flex size-2 rounded-full bg-red-500" />
</span>
<AvatarGroup>
{huddleParticipants.map((humanId) => {
const display = resolveHumanDisplay(humanId, network?.humans);
return (
<Tooltip key={humanId}>
<TooltipTrigger asChild>
<HumanAvatar
size="sm"
avatarObjectId={display.avatarObjectId}
initials={display.initials}
fallbackClassName="bg-red-500/30 text-[8px] text-red-200"
/>
</TooltipTrigger>
<TooltipContent>{display.email}</TooltipContent>
</Tooltip>
);
})}
</AvatarGroup>
<span className="text-xs font-medium text-red-200">Join</span>
</button>
)}
{streamParticle.status === 'closed' && (
<span className="no-drag flex items-center gap-1 rounded-full bg-white/10 px-2 py-0.5 text-xs text-muted-foreground backdrop-blur-sm">
<CircleCheckBig className="size-3" />
Closed
</span>
)}
<MembersIndicator
networkId={networkId}
streamParticle={streamParticle}
onClick={() => setMembersOpen(true)}
/>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="sm"
className="no-drag text-muted-foreground"
>
<EllipsisVertical className="size-3.5" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onSelect={async () => {
const docPath = toFirestoreDocPath(
particlePath(networkId, [streamParticle.id]),
);
await updateStreamStatus(
docPath,
streamParticle.status === 'open' ? 'closed' : 'open',
);
}}
>
{streamParticle.status === 'open' ? (
<>
<CircleCheckBig className="size-4" />
Close stream
</>
) : (
<>
<CircleDot className="size-4 text-green-500" />
Open stream
</>
)}
</DropdownMenuItem>
{isCreator && (
<DropdownMenuItem onSelect={() => setRenameOpen(true)}>
<Pencil className="size-4" />
Rename stream
</DropdownMenuItem>
)}
{canDeleteParticle && (
<DropdownMenuItem
onSelect={() => setDeleteOpen(true)}
variant="destructive"
>
<Trash2 className="size-4" />
Delete particle
</DropdownMenuItem>
)}
<DropdownMenuItem onSelect={() => navigate('/settings')}>
<Settings className="size-4" />
Settings
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
{renameOpen && isCreator && (
<RenameStreamOverlay
networkId={networkId}
streamParticle={streamParticle}
onClose={() => setRenameOpen(false)}
/>
)}
{deleteOpen && canDeleteParticle && particle && userId && (
<DeleteParticleOverlay
networkId={networkId}
streamId={streamParticle.id}
particle={particle}
userId={userId}
onClose={() => setDeleteOpen(false)}
/>
)}
{membersOpen && (
<StreamMembersOverlay
networkId={networkId}
streamParticle={streamParticle}
isCreator={isCreator}
onClose={() => setMembersOpen(false)}
/>
)}
</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 userId = useAuthStore((s) => s.user?.id);
const humans = network?.humans ?? [];
const isNetworkWide = visibility.mode === 'network';
const allMemberIds = isNetworkWide
? humans.map((h) => h.id)
: visibility.humanIds;
const othersIds = isNetworkWide
? []
: allMemberIds.filter((id) => id != userId);
const isPrivate = othersIds.length == 0;
const shownMembers = othersIds
.slice(0, 3)
.map((id) => humans.find((h) => h.id === id))
.filter((h): h is NonNullable<typeof h> => !!h);
const overflow = othersIds.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"
>
{isNetworkWide ? (
<>
<Globe className="size-3 text-white/50" />
<span>Everyone</span>
</>
) : isPrivate ? (
<>
<Lock className="size-3 text-white/50" />
<span>Private</span>
</>
) : (
<>
<AvatarGroup>
{shownMembers.map((human) => (
<HumanAvatar
key={human.id}
size="sm"
avatarObjectId={human.avatar_object_id}
initials={resolveHumanDisplay(human.id, humans).initials}
fallbackClassName="text-[8px]"
/>
))}
</AvatarGroup>
{overflow > 0 && (
<span className="text-white/50">+{overflow}</span>
)}
</>
)}
</button>
</TooltipTrigger>
<TooltipContent>
{isNetworkWide
? `Everyone in ${network?.name ?? 'network'}`
: `${allMemberIds.length} ${allMemberIds.length === 1 ? 'member' : 'members'}`}
</TooltipContent>
</Tooltip>
);
}
function ParticleBreadcrumbContent({
particle,
networkId,
}: {
particle: Particle;
networkId: string;
}) {
const network = useNetwork(networkId);
const { onlineHumanIds } = useStreamPresence();
const display = resolveHumanDisplay(
particle.created_by_human_id,
network?.humans,
);
const isOnline = particle.created_by_human_id
? onlineHumanIds.has(particle.created_by_human_id)
: false;
return (
<span className="flex items-center gap-1.5">
<HumanAvatar
size="sm"
className={isOnline ? 'ring-2 ring-green-500' : ''}
avatarObjectId={display.avatarObjectId}
initials={display.initials}
/>
{display.displayName} - <RelativeTimestamp date={particle.created_at} />
</span>
);
}