* 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>
372 lines
12 KiB
TypeScript
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>
|
|
);
|
|
}
|