support huddles (#106)

* add token endpoint for livekit

* fix: invalid type passed to hook

* fix: inject livekit env variables for orion

* fix: show controls indicator above stream # shortcut

* return livekit server url from api

* simple huddle implementation with streams

* set human name in livekit room context

* simplify deployment tooling

* join huddle with audio automatically

* 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.

* use headphones icon for huddles

* fix: screenshare not working in electron

The default VideoConference component from livekit doesn't support
screenshare in electron. This attempts to compose our own layout with
livekit components ourselves and introduces our own flow for
screenshare.
This commit was merged in pull request #106.
This commit is contained in:
Arjun Patel
2026-04-01 10:32:15 -07:00
committed by GitHub
parent 47e2a9baa4
commit 4d1ad717ad
32 changed files with 1358 additions and 243 deletions
+7
View File
@@ -2,6 +2,7 @@ import { useSessionStore } from "@/stores/session-store";
import type { z } from "zod";
import {
DepotObjectSchema,
GetLivekitTokenResponseSchema,
HumanSchema,
ListInvitationsResponseSchema,
ListNetworksResponseSchema,
@@ -211,6 +212,12 @@ class ApiClient {
async revokeInvitation(networkId: string, data: RevokeInvitationRequest): Promise<void> {
await this.requestVoid("DELETE", `/networks/${networkId}/invitations`, data);
}
// --- LiveKit ---
async getLivekitToken(networkId: string, streamId: string) {
return this.request(GetLivekitTokenResponseSchema, "POST", "/livekit/token", { network_id: networkId, stream_id: streamId });
}
}
export const apiClient = new ApiClient({
+10
View File
@@ -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,
@@ -209,6 +211,14 @@ export function isContainerType(type: ParticleType): boolean {
return CONTAINER_TYPES.has(type);
}
// --- LiveKit types ---
export const GetLivekitTokenResponseSchema = z.object({
token: z.string(),
server_url: z.string(),
});
export type GetLivekitTokenResponse = z.infer<typeof GetLivekitTokenResponseSchema>;
// --- Auth types ---
const RequestCodeRequestSchema = z.object({
+12 -1
View File
@@ -2,15 +2,26 @@ import type { LinkMetadata } from './lib/link-metadata';
import type { AutoplayPayload } from './lib/autoplay-ipc';
declare global {
interface ScreenSource {
id: string;
name: string;
thumbnailDataUrl: string;
appIconDataUrl: string | null;
}
interface Window {
electronWindow: {
minimize: () => void;
maximize: () => void;
fullscreen: () => void;
close: () => void;
openHuddle: () => void;
openHuddle: (data: { token: string; serverUrl: string }) => void;
closeHuddle: () => void;
};
electronHuddle: {
onConnect: (callback: (data: { token: string; serverUrl: string }) => void) => () => void;
getScreenSources: () => Promise<ScreenSource[]>;
};
electronAutoplay: {
play: (payload: AutoplayPayload) => void;
dismiss: () => void;
@@ -1,5 +1,6 @@
import { Video, Mic, Paperclip } from "lucide-react";
import { useMediaSettingsStore } from "@/stores/media-settings-store";
import { PropsWithChildren } from "react";
interface ControlsIndicatorProps {
type: "reply" | "new";
@@ -12,7 +13,8 @@ export default function ControlsIndicator({
attachmentCount = 0,
showEscape = false,
onOpenAttachments,
}: ControlsIndicatorProps) {
children
}: PropsWithChildren<ControlsIndicatorProps>) {
const recordingMode = useMediaSettingsStore((s) => s.recordingMode);
const setRecordingMode = useMediaSettingsStore((s) => s.setRecordingMode);
@@ -82,6 +84,7 @@ export default function ControlsIndicator({
{attachmentCount} {attachmentCount === 1 ? "attachment" : "attachments"}
</button>
)}
{children}
</div>
);
}
+1 -1
View File
@@ -71,7 +71,7 @@ export default function NetworkRoot() {
</div>
<ComposeOverlay networkId={networkId!} onActiveChange={setComposeActive} />
<div className="pointer-events-none absolute inset-x-0 bottom-0 flex justify-center p-3">
<div className="pointer-events-none absolute inset-x-0 bottom-0 z-20 flex justify-center p-3">
<div className="pointer-events-auto">
<ControlsIndicator type={"new"} />
</div>
@@ -10,6 +10,7 @@ import {
CircleCheck,
StickyNote,
Timer,
Headphones,
type LucideIcon,
} from "lucide-react";
import { cn } from "@/lib/utils";
@@ -94,13 +95,17 @@ function StreamRow({
const userId = user?.id ?? "";
const network = useNetwork(networkId);
useStreamAutoplay(latestChild, particle, networkId, network);
useStreamAutoplay(latestChild, particle, networkId, network ?? undefined);
const expiringSoon = useExpiringSoon(
particle.last_child_created_at,
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">
<Headphones 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" />
)}
+16 -2
View File
@@ -1,5 +1,5 @@
import { forwardRef, useMemo } from "react";
import { Timer } from "lucide-react";
import { Timer, Headphones } from "lucide-react";
import { cn, getInitials } from "@/lib/utils";
import { useLiveLatestChild } from "@/hooks/use-particle";
import { useAuthStore } from "@/stores/auth-store";
@@ -27,13 +27,17 @@ export const StreamCard = forwardRef<HTMLDivElement, StreamCardProps>(function S
const userId = useAuthStore((s) => s.user?.id) ?? "";
const network = useNetwork(networkId);
useStreamAutoplay(latestChild, particle, networkId, network);
useStreamAutoplay(latestChild, particle, networkId, network ?? undefined);
const expiringSoon = useExpiringSoon(
particle.last_child_created_at,
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">
<Headphones 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" />
)}
+67 -6
View File
@@ -1,6 +1,7 @@
import { useState, useEffect, useEffectEvent, useCallback } from "react";
import { useNavigate } from "react-router-dom";
import { useAuthStore } from "@/stores/auth-store";
import { apiClient } from "@/api/client";
import type { Particle } from "@/api/types";
import { parseParticlePath, type ParticlePath } from "@/lib/particle-path";
import { ComposeOverlay } from "@/features/compose/compose-overlay";
@@ -19,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) {
@@ -170,12 +172,19 @@ export function StreamView({ path, streamParticle }: StreamViewProps) {
e.preventDefault();
navigate(`/${networkId}`);
break;
case "h": {
e.preventDefault();
apiClient.getLivekitToken(networkId, streamParticle.id).then(({ token, server_url }) => {
window.electronWindow.openHuddle({ token, serverUrl: server_url });
});
break;
}
}
}
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
},
[composeActive, next, prev, navigate, networkId],
[composeActive, next, prev, navigate, networkId, streamParticle.id],
);
// Click-to-navigate: left 30% = prev, right 70% = next
@@ -204,7 +213,14 @@ export function StreamView({ path, streamParticle }: StreamViewProps) {
<p className="text-muted-foreground text-sm">
No particles in this stream yet
</p>
<ControlsIndicator type="reply" showEscape={true} />
<ControlsIndicator type="reply" showEscape={true}>
<span>
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs">
H
</kbd>{" "}
huddle
</span>
</ControlsIndicator>
<ComposeOverlay
networkId={networkId}
targetPath={path}
@@ -299,10 +315,14 @@ export function StreamView({ path, streamParticle }: StreamViewProps) {
{/* Bottom-center: controls */}
<div className="absolute inset-x-0 bottom-0 z-10 flex justify-center pb-3">
<ControlsIndicator
showEscape={true}
type="reply"
/>
<ControlsIndicator type="reply" showEscape={true}>
<span>
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs">
H
</kbd>{" "}
huddle
</span>
</ControlsIndicator>
</div>
</div>
);
@@ -310,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">
@@ -336,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"
-47
View File
@@ -1,47 +0,0 @@
import { Mic, MicOff, Video, VideoOff, PhoneOff } from 'lucide-react';
import { useState } from 'react';
export function HuddleApp() {
const [micOn, setMicOn] = useState(true);
const [videoOn, setVideoOn] = useState(true);
const handleLeave = () => {
window.electronWindow.closeHuddle();
};
return (
<div className="flex h-screen flex-col bg-background text-foreground">
{/* Video grid */}
<div className="flex flex-1 items-center justify-center gap-4 p-6">
<div className="bg-muted flex aspect-video w-full max-w-md items-center justify-center rounded-xl">
<span className="text-muted-foreground text-sm">You</span>
</div>
<div className="bg-muted flex aspect-video w-full max-w-md items-center justify-center rounded-xl">
<span className="text-muted-foreground text-sm">Participant</span>
</div>
</div>
{/* Controls bar */}
<div className="flex items-center justify-center gap-3 border-t px-6 py-4">
<button
className="rounded-full bg-muted p-3 hover:bg-muted/80 transition-colors"
onClick={() => setMicOn(!micOn)}
>
{micOn ? <Mic className="size-5" /> : <MicOff className="size-5 text-destructive" />}
</button>
<button
className="rounded-full bg-muted p-3 hover:bg-muted/80 transition-colors"
onClick={() => setVideoOn(!videoOn)}
>
{videoOn ? <Video className="size-5" /> : <VideoOff className="size-5 text-destructive" />}
</button>
<button
className="rounded-full bg-destructive p-3 text-destructive-foreground hover:bg-destructive/90 transition-colors"
onClick={handleLeave}
>
<PhoneOff className="size-5" />
</button>
</div>
</div>
);
}
+208
View File
@@ -0,0 +1,208 @@
import '@livekit/components-styles';
import {
CarouselLayout,
Chat,
ControlBar,
FocusLayout,
FocusLayoutContainer,
GridLayout,
LayoutContextProvider,
LiveKitRoom,
ParticipantTile,
RoomAudioRenderer,
StartAudio,
isTrackReference,
useCreateLayoutContext,
usePinnedTracks,
useRoomContext,
useTracks,
ScreenShareIcon,
ScreenShareStopIcon,
} from '@livekit/components-react';
import { RoomEvent, Track } from 'livekit-client';
import { useState, useEffect, useRef, useCallback } from 'react';
import { ScreenPicker } from './ScreenPicker';
export function HuddleApp() {
const [connection, setConnection] = useState<{ token: string; serverUrl: string } | null>(null);
useEffect(() => {
return window.electronHuddle.onConnect((data) => {
setConnection(data);
});
}, []);
const handleDisconnected = () => {
window.electronWindow.closeHuddle();
};
if (!connection) {
return (
<div className="flex h-screen items-center justify-center bg-background text-foreground">
<p className="text-muted-foreground text-sm">Connecting to huddle...</p>
</div>
);
}
return (
<LiveKitRoom
token={connection.token}
serverUrl={connection.serverUrl}
connect={true}
audio={true}
video={false}
onDisconnected={handleDisconnected}
data-lk-theme="default"
style={{ height: '100vh' }}
>
<HuddleContent />
</LiveKitRoom>
);
}
function HuddleContent() {
const room = useRoomContext();
const [showPicker, setShowPicker] = useState(false);
const [isSharing, setIsSharing] = useState(false);
const [widgetState, setWidgetState] = useState({ showChat: false, unreadMessages: 0 });
const tracks = useTracks(
[
{ source: Track.Source.Camera, withPlaceholder: true },
{ source: Track.Source.ScreenShare, withPlaceholder: false },
],
{ updateOnlyOn: [RoomEvent.ActiveSpeakersChanged], onlySubscribed: false },
);
const layoutContext = useCreateLayoutContext();
const screenShareTracks = tracks
.filter(isTrackReference)
.filter((t) => t.publication.source === Track.Source.ScreenShare);
const pinnedTracks = usePinnedTracks(layoutContext);
const focusTrack = pinnedTracks?.[0];
const remainingTracks = tracks.filter(
(t) =>
!focusTrack ||
t.participant.identity !== focusTrack.participant.identity ||
t.source !== focusTrack.source,
);
// Auto-pin/unpin screen share tracks (same logic as VideoConference)
const lastAutoPin = useRef<{ trackSid: string } | null>(null);
useEffect(() => {
if (
screenShareTracks.some((t) => t.publication.isSubscribed) &&
lastAutoPin.current === null
) {
layoutContext.pin.dispatch?.({ msg: 'set_pin', trackReference: screenShareTracks[0] });
lastAutoPin.current = { trackSid: screenShareTracks[0].publication.trackSid };
} else if (
lastAutoPin.current &&
!screenShareTracks.some(
(t) => t.publication.trackSid === lastAutoPin.current?.trackSid,
)
) {
layoutContext.pin.dispatch?.({ msg: 'clear_pin' });
lastAutoPin.current = null;
}
}, [
screenShareTracks.map((t) => `${t.publication.trackSid}_${t.publication.isSubscribed}`).join(),
]);
// Sync isSharing state with actual track state
useEffect(() => {
const onLocalTrackUnpublished = () => {
const pub = room.localParticipant.getTrackPublication(Track.Source.ScreenShare);
if (!pub) setIsSharing(false);
};
room.on(RoomEvent.LocalTrackUnpublished, onLocalTrackUnpublished);
return () => {
room.off(RoomEvent.LocalTrackUnpublished, onLocalTrackUnpublished);
};
}, [room]);
const handleScreenShare = useCallback(
async (sourceId: string) => {
setShowPicker(false);
try {
const stream = await navigator.mediaDevices.getUserMedia({
audio: false,
video: {
mandatory: {
chromeMediaSource: 'desktop',
chromeMediaSourceId: sourceId,
},
} as MediaTrackConstraints,
});
const videoTrack = stream.getVideoTracks()[0];
await room.localParticipant.publishTrack(videoTrack, {
source: Track.Source.ScreenShare,
});
setIsSharing(true);
videoTrack.onended = () => stopScreenShare();
} catch (err) {
console.error('Failed to start screen share:', err);
}
},
[room],
);
const stopScreenShare = useCallback(async () => {
const pub = room.localParticipant.getTrackPublication(Track.Source.ScreenShare);
if (pub?.track) {
await room.localParticipant.unpublishTrack(pub.track.mediaStreamTrack);
pub.track.stop();
}
setIsSharing(false);
}, [room]);
return (
<div className="lk-video-conference">
<LayoutContextProvider value={layoutContext} onWidgetChange={setWidgetState}>
<div className="lk-video-conference-inner">
{focusTrack ? (
<div className="lk-focus-layout-wrapper">
<FocusLayoutContainer>
<CarouselLayout tracks={remainingTracks}>
<ParticipantTile />
</CarouselLayout>
<FocusLayout trackRef={focusTrack} />
</FocusLayoutContainer>
</div>
) : (
<div className="lk-grid-layout-wrapper">
<GridLayout tracks={tracks}>
<ParticipantTile />
</GridLayout>
</div>
)}
<div className="lk-control-bar">
<ControlBar
controls={{ screenShare: false, chat: true }}
style={{ border: 'none', padding: 0, maxHeight: 'none' }}
/>
<button
className="lk-button"
onClick={isSharing ? stopScreenShare : () => setShowPicker(true)}
aria-label={isSharing ? 'Stop sharing' : 'Share screen'}
>
{isSharing ? <ScreenShareStopIcon /> : <ScreenShareIcon />}
{isSharing ? 'Stop share' : 'Share screen'}
</button>
</div>
</div>
<Chat style={{ display: widgetState.showChat ? 'grid' : 'none' }} />
</LayoutContextProvider>
<RoomAudioRenderer />
<StartAudio label="Allow audio" />
{showPicker && (
<ScreenPicker
onSelect={handleScreenShare}
onCancel={() => setShowPicker(false)}
/>
)}
</div>
);
}
+117
View File
@@ -0,0 +1,117 @@
import { useState, useEffect } from 'react';
interface ScreenPickerProps {
onSelect: (sourceId: string) => void;
onCancel: () => void;
}
export function ScreenPicker({ onSelect, onCancel }: ScreenPickerProps) {
const [sources, setSources] = useState<ScreenSource[]>([]);
const [selectedId, setSelectedId] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
window.electronHuddle.getScreenSources().then((result) => {
setSources(result);
setLoading(false);
});
}, []);
const screens = sources.filter((s) => s.id.startsWith('screen:'));
const windows = sources.filter((s) => s.id.startsWith('window:'));
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60">
<div className="mx-4 flex max-h-[80vh] w-full max-w-2xl flex-col rounded-lg bg-zinc-900 shadow-xl">
<div className="flex items-center justify-between border-b border-zinc-700 px-5 py-4">
<h2 className="text-base font-medium text-zinc-100">Share your screen</h2>
<button
onClick={onCancel}
className="text-zinc-400 hover:text-zinc-200"
>
</button>
</div>
<div className="flex-1 overflow-y-auto px-5 py-4">
{loading ? (
<p className="text-center text-sm text-zinc-400">Loading sources</p>
) : (
<>
{screens.length > 0 && (
<SourceSection
title="Screens"
sources={screens}
selectedId={selectedId}
onSelect={setSelectedId}
/>
)}
{windows.length > 0 && (
<SourceSection
title="Windows"
sources={windows}
selectedId={selectedId}
onSelect={setSelectedId}
/>
)}
</>
)}
</div>
<div className="flex justify-end gap-2 border-t border-zinc-700 px-5 py-3">
<button
onClick={onCancel}
className="rounded-md px-4 py-2 text-sm text-zinc-300 hover:bg-zinc-800"
>
Cancel
</button>
<button
disabled={!selectedId}
onClick={() => selectedId && onSelect(selectedId)}
className="rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-500 disabled:opacity-40 disabled:hover:bg-blue-600"
>
Share
</button>
</div>
</div>
</div>
);
}
function SourceSection({
title,
sources,
selectedId,
onSelect,
}: {
title: string;
sources: ScreenSource[];
selectedId: string | null;
onSelect: (id: string) => void;
}) {
return (
<div className="mb-4">
<h3 className="mb-2 text-xs font-medium uppercase tracking-wide text-zinc-400">{title}</h3>
<div className="grid grid-cols-3 gap-3">
{sources.map((source) => (
<button
key={source.id}
onClick={() => onSelect(source.id)}
className={`overflow-hidden rounded-lg border-2 text-left transition-colors ${
selectedId === source.id
? 'border-blue-500 bg-zinc-800'
: 'border-transparent bg-zinc-800/50 hover:border-zinc-600'
}`}
>
<img
src={source.thumbnailDataUrl}
alt={source.name}
className="aspect-video w-full object-cover"
/>
<p className="truncate px-2 py-1.5 text-xs text-zinc-300">{source.name}</p>
</button>
))}
</div>
</div>
);
}
+1 -1
View File
@@ -1,5 +1,5 @@
import { createRoot } from 'react-dom/client';
import { HuddleApp } from '@/huddle/HuddleApp';
import { HuddleApp } from './HuddleApp';
import '@/styles/globals.css';
const root = createRoot(document.getElementById('root')!);
+1
View File
@@ -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({
+23 -2
View File
@@ -1,4 +1,4 @@
import { app, BrowserWindow, ipcMain, screen, session, shell } from 'electron';
import { app, BrowserWindow, desktopCapturer, ipcMain, screen, session, shell } from 'electron';
import path from 'node:path';
import started from 'electron-squirrel-startup';
import { updateElectronApp, UpdateSourceType } from 'update-electron-app';
@@ -141,13 +141,34 @@ ipcMain.on('window:fullscreen', (event) => {
});
// Secondary window IPC handlers
ipcMain.on('window:open-huddle', () => {
ipcMain.on('window:open-huddle', (_event, data: { token: string; serverUrl: string }) => {
createHuddleWindow();
// Send connection data once the huddle window is ready
huddleWindow?.webContents.once('did-finish-load', () => {
huddleWindow?.webContents.send('huddle:connect', data);
});
// If already loaded, send immediately
if (!huddleWindow?.webContents.isLoading()) {
huddleWindow?.webContents.send('huddle:connect', data);
}
});
ipcMain.on('window:close-huddle', () => {
huddleWindow?.close();
});
ipcMain.handle('screen:get-sources', async () => {
const sources = await desktopCapturer.getSources({
types: ['screen', 'window'],
thumbnailSize: { width: 320, height: 180 },
});
return sources.map((source) => ({
id: source.id,
name: source.name,
thumbnailDataUrl: source.thumbnail.toDataURL(),
appIconDataUrl: source.appIcon?.toDataURL() ?? null,
}));
});
// Autoplay IPC handlers
ipcMain.on('autoplay:play', (_event, payload) => {
if (!autoplayWindow) createAutoplayWindow();
+10 -1
View File
@@ -7,10 +7,19 @@ contextBridge.exposeInMainWorld('electronWindow', {
maximize: () => ipcRenderer.send('window:maximize'),
fullscreen: () => ipcRenderer.send('window:fullscreen'),
close: () => ipcRenderer.send('window:close'),
openHuddle: () => ipcRenderer.send('window:open-huddle'),
openHuddle: (data: { token: string; serverUrl: string }) => ipcRenderer.send('window:open-huddle', data),
closeHuddle: () => ipcRenderer.send('window:close-huddle'),
});
contextBridge.exposeInMainWorld('electronHuddle', {
onConnect: (callback: (data: { token: string; serverUrl: string }) => void) => {
const handler = (_event: Electron.IpcRendererEvent, data: { token: string; serverUrl: string }) => callback(data);
ipcRenderer.on('huddle:connect', handler);
return () => { ipcRenderer.removeListener('huddle:connect', handler); };
},
getScreenSources: () => ipcRenderer.invoke('screen:get-sources'),
});
contextBridge.exposeInMainWorld('electronAutoplay', {
play: (payload: unknown) => ipcRenderer.send('autoplay:play', payload),
dismiss: () => ipcRenderer.send('autoplay:dismiss'),