feat: show when there is an active huddle
This introduces a webhook which listens to events from livekit and updates our firestore stream particle. It keeps the client simple, reacting to changes to firestore docs.
This commit is contained in:
@@ -215,8 +215,8 @@ class ApiClient {
|
||||
|
||||
// --- LiveKit ---
|
||||
|
||||
async getLivekitToken(roomId: string) {
|
||||
return this.request(GetLivekitTokenResponseSchema, "POST", "/livekit/token", { room_id: roomId });
|
||||
async getLivekitToken(networkId: string, streamId: string) {
|
||||
return this.request(GetLivekitTokenResponseSchema, "POST", "/livekit/token", { network_id: networkId, stream_id: streamId });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -184,6 +184,8 @@ export const ParticleSchema = z.discriminatedUnion("type", [
|
||||
// Timestamp of the most recent child particle
|
||||
// used for sorting streams by recent activity without needing to query subcollections
|
||||
last_child_created_at: z.coerce.date().optional(),
|
||||
// Array of humanIds currently in the huddle (updated via LiveKit webhooks)
|
||||
huddle_active_participants: z.array(z.string()).optional(),
|
||||
}),
|
||||
ParticleBaseSchema.extend({
|
||||
type: z.literal("folder"), properties: FolderPropertiesSchema,
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
CircleCheck,
|
||||
StickyNote,
|
||||
Timer,
|
||||
Phone,
|
||||
type LucideIcon,
|
||||
} from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
@@ -101,6 +102,10 @@ function StreamRow({
|
||||
network?.message_retention_hours ?? 24,
|
||||
);
|
||||
|
||||
const hasActiveHuddle =
|
||||
particle.huddle_active_participants && particle.huddle_active_participants.length > 0;
|
||||
const huddleCount = particle.huddle_active_participants?.length ?? 0;
|
||||
|
||||
const isDM =
|
||||
particle.visible_to.length === 2 &&
|
||||
particle.visible_to.every((v) => v.startsWith("human:"));
|
||||
@@ -162,6 +167,7 @@ function StreamRow({
|
||||
className={cn(
|
||||
"flex w-full items-center gap-3 px-4 py-3 text-left cursor-pointer transition-colors hover:bg-accent",
|
||||
isSelected && "bg-accent",
|
||||
hasActiveHuddle && "bg-gradient-to-r from-red-500/10 to-transparent",
|
||||
)}
|
||||
>
|
||||
{shortcutKey && (
|
||||
@@ -188,7 +194,13 @@ function StreamRow({
|
||||
>
|
||||
{particle.properties.name}
|
||||
</p>
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
<div className="flex shrink-0 items-center gap-1.5">
|
||||
{hasActiveHuddle && (
|
||||
<span className="flex items-center gap-1 rounded-full bg-red-500/15 px-1.5 py-0.5">
|
||||
<Phone className="size-3 text-red-400" />
|
||||
<span className="text-[10px] font-medium text-red-400">{huddleCount}</span>
|
||||
</span>
|
||||
)}
|
||||
{expiringSoon && (
|
||||
<Timer className="size-3 text-muted-foreground/60" />
|
||||
)}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { forwardRef, useMemo } from "react";
|
||||
import { Timer } from "lucide-react";
|
||||
import { Timer, Phone } from "lucide-react";
|
||||
import { cn, getInitials } from "@/lib/utils";
|
||||
import { useLiveLatestChild } from "@/hooks/use-particle";
|
||||
import { useAuthStore } from "@/stores/auth-store";
|
||||
@@ -34,6 +34,10 @@ export const StreamCard = forwardRef<HTMLDivElement, StreamCardProps>(function S
|
||||
network?.message_retention_hours ?? 24,
|
||||
);
|
||||
|
||||
const hasActiveHuddle =
|
||||
particle.huddle_active_participants && particle.huddle_active_participants.length > 0;
|
||||
const huddleCount = particle.huddle_active_participants?.length ?? 0;
|
||||
|
||||
const isDM =
|
||||
particle.visible_to.length === 2 &&
|
||||
particle.visible_to.every((v) => v.startsWith("human:"));
|
||||
@@ -94,10 +98,14 @@ export const StreamCard = forwardRef<HTMLDivElement, StreamCardProps>(function S
|
||||
"cursor-pointer overflow-hidden rounded-xl ring-1 ring-foreground/10 transition-all hover:ring-foreground/20",
|
||||
isUnseen && "ring-2 ring-primary",
|
||||
isSelected && "ring-2 ring-ring",
|
||||
hasActiveHuddle && "ring-2 ring-red-500/70",
|
||||
)}
|
||||
>
|
||||
{/* Preview area */}
|
||||
<div className="relative aspect-[4/3] overflow-hidden bg-muted">
|
||||
{hasActiveHuddle && (
|
||||
<div className="pointer-events-none absolute inset-0 z-10 bg-gradient-to-b from-red-500/15 to-transparent" />
|
||||
)}
|
||||
{shortcutKey && (
|
||||
<kbd className="absolute top-1.5 left-1.5 z-10 flex size-5 items-center justify-center rounded bg-black/50 font-mono text-xs text-white/70">
|
||||
{shortcutKey}
|
||||
@@ -141,6 +149,12 @@ export const StreamCard = forwardRef<HTMLDivElement, StreamCardProps>(function S
|
||||
{particle.properties.name}
|
||||
</Small>
|
||||
<div className="ml-auto flex shrink-0 items-center gap-1.5">
|
||||
{hasActiveHuddle && (
|
||||
<span className="flex items-center gap-1 rounded-full bg-red-500/15 px-1.5 py-0.5">
|
||||
<Phone className="size-3 text-red-400" />
|
||||
<span className="text-[10px] font-medium text-red-400">{huddleCount}</span>
|
||||
</span>
|
||||
)}
|
||||
{expiringSoon && (
|
||||
<Timer className="size-3 text-muted-foreground/60" />
|
||||
)}
|
||||
|
||||
@@ -20,6 +20,7 @@ import { WindowControls } from "@/components/window-controls";
|
||||
import { RelativeTimestamp } from "@/components/relative-timestamp";
|
||||
import { useStreamPlayback } from "@/hooks/use-stream-playback";
|
||||
import { usePrefetchAdjacentMedia } from "@/hooks/use-prefetch-adjacent-media";
|
||||
import { getInitials } from "@/lib/utils";
|
||||
|
||||
function getParticleDisplayName(particle: Particle): string {
|
||||
switch (particle.type) {
|
||||
@@ -173,8 +174,7 @@ export function StreamView({ path, streamParticle }: StreamViewProps) {
|
||||
break;
|
||||
case "h": {
|
||||
e.preventDefault();
|
||||
const roomId = streamParticle.id;
|
||||
apiClient.getLivekitToken(roomId).then(({ token, server_url }) => {
|
||||
apiClient.getLivekitToken(networkId, streamParticle.id).then(({ token, server_url }) => {
|
||||
window.electronWindow.openHuddle({ token, serverUrl: server_url });
|
||||
});
|
||||
break;
|
||||
@@ -330,6 +330,16 @@ export function StreamView({ path, streamParticle }: StreamViewProps) {
|
||||
|
||||
function TopBar({ networkId, particle, streamParticle }: { networkId: string; particle: Particle | null; streamParticle: Particle & { type: "stream" } }) {
|
||||
const navigate = useNavigate();
|
||||
const network = useNetwork(networkId);
|
||||
|
||||
const huddleParticipants = streamParticle.huddle_active_participants ?? [];
|
||||
const hasActiveHuddle = huddleParticipants.length > 0;
|
||||
|
||||
const handleJoinHuddle = () => {
|
||||
apiClient.getLivekitToken(networkId, streamParticle.id).then(({ token, server_url }) => {
|
||||
window.electronWindow.openHuddle({ token, serverUrl: server_url });
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="drag-region flex flex-row px-4 gap-5 items-center">
|
||||
@@ -356,6 +366,37 @@ function TopBar({ networkId, particle, streamParticle }: { networkId: string; pa
|
||||
</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 human = network?.humans?.find((h) => h.id === humanId);
|
||||
const initials = human ? getInitials(human.email) : "?";
|
||||
return (
|
||||
<Tooltip key={humanId}>
|
||||
<TooltipTrigger asChild>
|
||||
<Avatar size="sm">
|
||||
<AvatarFallback className="bg-red-500/30 text-[8px] text-red-200">
|
||||
{initials}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{human?.email_prefix ?? humanId}</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
})}
|
||||
</AvatarGroup>
|
||||
<span className="text-xs font-medium text-red-200">Join</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
|
||||
@@ -62,6 +62,7 @@ const particleConverter: FirestoreDataConverter<Particle> = {
|
||||
)
|
||||
: undefined,
|
||||
last_child_created_at: raw.last_child_created_at ? (raw.last_child_created_at as Timestamp).toDate() : undefined,
|
||||
huddle_active_participants: raw.huddle_active_participants ?? undefined,
|
||||
});
|
||||
case "folder":
|
||||
return ParticleSchema.parse({
|
||||
|
||||
Reference in New Issue
Block a user