From 849d41fa0601ec90b9c1d64a2f5b84e055262909 Mon Sep 17 00:00:00 2001 From: Arjun Patel Date: Thu, 11 Jun 2026 10:07:25 -0700 Subject: [PATCH 1/5] feat: use sexier audio waveform (#265) * use sexier audio waveform * format --- .../src/components/audio/audio-level-bars.tsx | 122 ---------- .../components/audio/centered-waveform.tsx | 213 ++++++++++++++++++ .../features/compose/recording-overlay.tsx | 27 +-- .../src/features/compose/use-recorder.ts | 2 +- .../particles/media-particle-view.tsx | 9 +- .../settings/audio-video-settings-page.tsx | 23 +- js/desktop/src/hooks/use-recording-mode.ts | 22 -- 7 files changed, 242 insertions(+), 176 deletions(-) delete mode 100644 js/desktop/src/components/audio/audio-level-bars.tsx create mode 100644 js/desktop/src/components/audio/centered-waveform.tsx delete mode 100644 js/desktop/src/hooks/use-recording-mode.ts diff --git a/js/desktop/src/components/audio/audio-level-bars.tsx b/js/desktop/src/components/audio/audio-level-bars.tsx deleted file mode 100644 index b5a7881..0000000 --- a/js/desktop/src/components/audio/audio-level-bars.tsx +++ /dev/null @@ -1,122 +0,0 @@ -import { useEffect, useRef } from 'react'; - -interface AudioLevelBarsProps { - sourceNode: AudioNode; -} - -const BAR_COUNT = 3; -const MIN_HEIGHT_PX = 6; -const MAX_HEIGHT_PX = 48; - -// dB scale -const NOISE_FLOOR_DB = -50; -const DB_RANGE = -NOISE_FLOOR_DB; // 50dB dynamic range - -// Asymmetric smoothing time constants -const ATTACK_MS = 30; -const RELEASE_MS = 300; - -// Bar activation thresholds on the 0..1 normalized dB scale -const BAR_THRESHOLDS = [0.0, 0.15, 0.35]; - -/** - * 3-bar VU meter that visualizes audio levels from any AudioNode source. - * Works with both live MediaStream sources and MediaElement sources. - * - * Uses direct DOM manipulation with exponential smoothing on a dB scale - * for smooth, jitter-free animation independent of frame rate. - */ -export function AudioLevelBars({ sourceNode }: AudioLevelBarsProps) { - const barRefs = useRef<(HTMLDivElement | null)[]>([]); - - useEffect(() => { - const ctx = sourceNode.context as AudioContext; - const analyser = ctx.createAnalyser(); - analyser.fftSize = 256; - sourceNode.connect(analyser); - - // Connect to destination via silent gain node — without this, - // Chromium suspends processing on disconnected audio graphs. - const silentGain = ctx.createGain(); - silentGain.gain.value = 0; - analyser.connect(silentGain); - silentGain.connect(ctx.destination); - - const dataArray = new Uint8Array(analyser.frequencyBinCount); - - let smoothedLevel = 0; - let lastTime = performance.now(); - let rafId = 0; - - function tick() { - const now = performance.now(); - const dt = now - lastTime; - lastTime = now; - - analyser.getByteTimeDomainData(dataArray); - - // Compute RMS of waveform (128 = silence baseline) - let sumSquares = 0; - for (let i = 0; i < dataArray.length; i++) { - const normalized = (dataArray[i] - 128) / 128; - sumSquares += normalized * normalized; - } - const rms = Math.sqrt(sumSquares / dataArray.length); - - // Convert to dB, clamp to noise floor, normalize to 0..1 - const db = rms > 0 ? 20 * Math.log10(rms) : NOISE_FLOOR_DB; - const normalizedDb = Math.max(0, (db - NOISE_FLOOR_DB) / DB_RANGE); - - // Asymmetric exponential smoothing (frame-rate independent) - const timeConstant = - normalizedDb > smoothedLevel ? ATTACK_MS : RELEASE_MS; - const alpha = 1 - Math.exp(-dt / timeConstant); - smoothedLevel += alpha * (normalizedDb - smoothedLevel); - - // Update bar heights via direct DOM writes - for (let i = 0; i < BAR_COUNT; i++) { - const el = barRefs.current[i]; - if (!el) continue; - - const threshold = BAR_THRESHOLDS[i]; - const barLevel = - smoothedLevel <= threshold - ? 0 - : Math.min(1, (smoothedLevel - threshold) / (1 - threshold)); - const height = - MIN_HEIGHT_PX + barLevel * (MAX_HEIGHT_PX - MIN_HEIGHT_PX); - el.style.height = `${height}px`; - } - - rafId = requestAnimationFrame(tick); - } - - rafId = requestAnimationFrame(tick); - - return () => { - cancelAnimationFrame(rafId); - try { - sourceNode.disconnect(analyser); - analyser.disconnect(silentGain); - silentGain.disconnect(ctx.destination); - } catch { - // Nodes may already be disconnected - } - }; - }, [sourceNode]); - - return ( -
- {Array.from({ length: BAR_COUNT }, (_, i) => ( -
{ - barRefs.current[i] = el; - }} - className="w-1.5 rounded-full bg-green-400" - style={{ height: `${MIN_HEIGHT_PX}px` }} - /> - ))} -
- ); -} diff --git a/js/desktop/src/components/audio/centered-waveform.tsx b/js/desktop/src/components/audio/centered-waveform.tsx new file mode 100644 index 0000000..cc64f70 --- /dev/null +++ b/js/desktop/src/components/audio/centered-waveform.tsx @@ -0,0 +1,213 @@ +import { useEffect, useRef } from 'react'; + +interface CenteredWaveformProps { + sourceNode: AudioNode; + /** Canvas size in CSS pixels. */ + width?: number; + height?: number; + barWidth?: number; + gap?: number; + /** Bars are drawn with currentColor — set a text-* class to color them. */ + className?: string; +} + +// Voice band fanned out from the center bar (lows) to the edges (highs). +const MIN_FREQ_HZ = 100; +const MAX_FREQ_HZ = 4500; + +// Analyser dB range mapped onto bar height; the defaults (-100/-30) waste +// most of the range on inaudible levels. +const MIN_DB = -75; +const MAX_DB = -25; + +// Per-bar asymmetric smoothing time constants. +const ATTACK_MS = 40; +const RELEASE_MS = 220; + +// Edge bars keep a fraction of their response so the whole row stays alive +// instead of only the middle moving. +const EDGE_RESPONSE = 0.3; + +/** + * Centered live voice indicator, like a meeting app's "speaking" glyph. + * Bars fan out symmetrically from the middle: the center tracks the low + * frequencies where voice energy lives, the edges track the highs, and a + * cosine envelope tapers the response so the shape blooms from the center + * while someone speaks and settles into a dot line in silence. + * + * Renders on canvas with an rAF loop and no React state. No reduced-motion + * branch: the animation is signal-bearing audio feedback, not decoration, + * and only runs while audio is being captured or played. + */ +export function CenteredWaveform({ + sourceNode, + width = 168, + height = 56, + barWidth = 4, + gap = 4, + className, +}: CenteredWaveformProps) { + const canvasRef = useRef(null); + + useEffect(() => { + const canvasEl = canvasRef.current; + const ctx2d = canvasEl?.getContext('2d'); + if (!canvasEl || !ctx2d) return; + // Rebind post-guard so the narrowed types carry into the closures below. + const canvas = canvasEl; + const drawCtx = ctx2d; + + const { analyser, detach } = attachAnalyser(sourceNode, 512); + analyser.smoothingTimeConstant = 0.8; + analyser.minDecibels = MIN_DB; + analyser.maxDecibels = MAX_DB; + + const bins = new Uint8Array(analyser.frequencyBinCount); + + const stride = barWidth + gap; + // Odd count so one bar sits exactly at the center. + let barCount = Math.floor((width + gap) / stride); + if (barCount % 2 === 0) barCount -= 1; + const half = (barCount - 1) / 2; + + // Map bar k (0 = center) to a frequency bin, log-spaced so the busy low + // end of the voice spectrum spreads across several bars. + const binHz = sourceNode.context.sampleRate / analyser.fftSize; + const binIndex = new Uint16Array(half + 1); + for (let k = 0; k <= half; k++) { + const freq = + MIN_FREQ_HZ * + Math.pow(MAX_FREQ_HZ / MIN_FREQ_HZ, half === 0 ? 0 : k / half); + binIndex[k] = Math.min(bins.length - 1, Math.round(freq / binHz)); + } + + const levels = new Float32Array(half + 1); + + const ensureBackingStore = createBackingStoreScaler( + canvas, + drawCtx, + width, + height, + ); + // currentColor, cached and refreshed periodically (theme switches show + // within 200ms) instead of paying a computed-style read every frame. + let fillColor = ''; + let lastColorTime = 0; + let lastTime = performance.now(); + let rafId = 0; + + function tick() { + const now = performance.now(); + const dt = now - lastTime; + lastTime = now; + + analyser.getByteFrequencyData(bins); + + for (let k = 0; k <= half; k++) { + const raw = bins[binIndex[k]] / 255; + const envelope = + half === 0 + ? 1 + : EDGE_RESPONSE + + (1 - EDGE_RESPONSE) * Math.cos(((k / half) * Math.PI) / 2); + // Gamma keeps the floor quiet so silence reads as a dot line. + const target = Math.pow(raw, 1.4) * envelope; + + // Asymmetric exponential smoothing (frame-rate independent) + const timeConstant = target > levels[k] ? ATTACK_MS : RELEASE_MS; + const alpha = 1 - Math.exp(-dt / timeConstant); + levels[k] += alpha * (target - levels[k]); + } + + if (ensureBackingStore() || !fillColor || now - lastColorTime > 200) { + fillColor = getComputedStyle(canvas).color; + lastColorTime = now; + } + drawCtx.clearRect(0, 0, width, height); + drawCtx.fillStyle = fillColor; + + const centerX = width / 2 - barWidth / 2; + drawCtx.beginPath(); + for (let k = 0; k <= half; k++) { + const h = Math.max(barWidth, levels[k] * height); + const y = (height - h) / 2; + drawCtx.roundRect(centerX + k * stride, y, barWidth, h, barWidth / 2); + if (k > 0) { + drawCtx.roundRect(centerX - k * stride, y, barWidth, h, barWidth / 2); + } + } + drawCtx.fill(); + + rafId = requestAnimationFrame(tick); + } + + rafId = requestAnimationFrame(tick); + + return () => { + cancelAnimationFrame(rafId); + detach(); + }; + }, [sourceNode, width, height, barWidth, gap]); + + return ( + + ); +} + +/** + * Connects an AnalyserNode to the source for visualization. + * + * The analyser is routed to the destination via a silent gain node — without + * this, Chromium suspends processing on disconnected audio graphs. + */ +function attachAnalyser( + sourceNode: AudioNode, + fftSize: number, +): { analyser: AnalyserNode; detach: () => void } { + const ctx = sourceNode.context; + const analyser = ctx.createAnalyser(); + analyser.fftSize = fftSize; + sourceNode.connect(analyser); + + const silentGain = ctx.createGain(); + silentGain.gain.value = 0; + analyser.connect(silentGain); + silentGain.connect(ctx.destination); + + return { + analyser, + detach() { + try { + sourceNode.disconnect(analyser); + analyser.disconnect(silentGain); + silentGain.disconnect(ctx.destination); + } catch { + // Nodes may already be disconnected + } + }, + }; +} + +/** + * Keeps the canvas backing store sized to width × height CSS pixels at the + * current devicePixelRatio, rescaling if the DPR changes (window moved + * between monitors). Call the returned function each frame; it returns true + * when the store was (re)initialized so callers can refresh cached state. + */ +function createBackingStoreScaler( + canvas: HTMLCanvasElement, + drawCtx: CanvasRenderingContext2D, + width: number, + height: number, +): () => boolean { + let dpr = 0; + return () => { + const currentDpr = window.devicePixelRatio || 1; + if (currentDpr === dpr) return false; + dpr = currentDpr; + canvas.width = Math.round(width * dpr); + canvas.height = Math.round(height * dpr); + drawCtx.setTransform(dpr, 0, 0, dpr, 0, 0); + return true; + }; +} diff --git a/js/desktop/src/features/compose/recording-overlay.tsx b/js/desktop/src/features/compose/recording-overlay.tsx index d720b1f..135404a 100644 --- a/js/desktop/src/features/compose/recording-overlay.tsx +++ b/js/desktop/src/features/compose/recording-overlay.tsx @@ -1,7 +1,7 @@ import { useEffect, useRef, useState } from 'react'; import { Paperclip } from 'lucide-react'; -import type { RecordingMode } from '@/hooks/use-recording-mode'; -import { AudioLevelBars } from '@/components/audio/audio-level-bars'; +import type { RecordingMode } from '@/stores/media-settings-store'; +import { CenteredWaveform } from '@/components/audio/centered-waveform'; import { useAudioSource } from '@/components/audio/use-audio-source'; import { useObjectUrl } from '@/hooks/use-object-url'; import { AttachmentStrip } from '@/features/compose/attachment-strip'; @@ -67,7 +67,6 @@ function ReviewPlayback({ objectFit?: 'cover' | 'contain'; }) { const objectUrl = useObjectUrl(blob); - const audioElRef = useRef(null); const [audioEl, setAudioEl] = useState(null); const audioSource = useAudioSource(isVideo ? null : audioEl); @@ -87,17 +86,12 @@ function ReviewPlayback({ return (
-
- {/* Bottom center: audio level bars (recording with active stream) */} + {/* Bottom center: live waveform (recording with active stream) */} {isRecording && recordingAudioSource && (
- +
)} diff --git a/js/desktop/src/features/compose/use-recorder.ts b/js/desktop/src/features/compose/use-recorder.ts index 2813e0a..b59ae46 100644 --- a/js/desktop/src/features/compose/use-recorder.ts +++ b/js/desktop/src/features/compose/use-recorder.ts @@ -1,5 +1,5 @@ import { useCallback, useEffect, useRef } from 'react'; -import type { RecordingMode } from '@/hooks/use-recording-mode'; +import type { RecordingMode } from '@/stores/media-settings-store'; const VIDEO_PREFERRED_MIME = 'video/webm;codecs=vp9,opus'; const VIDEO_FALLBACK_MIME = 'video/webm'; diff --git a/js/desktop/src/features/particles/media-particle-view.tsx b/js/desktop/src/features/particles/media-particle-view.tsx index ad58c98..65fc725 100644 --- a/js/desktop/src/features/particles/media-particle-view.tsx +++ b/js/desktop/src/features/particles/media-particle-view.tsx @@ -11,7 +11,7 @@ import { useDownloadUrl } from '@/hooks/use-download-url'; import { useTranscriptPlayback } from '@/hooks/use-transcript-playback'; import { TranscriptOverlay } from '@/features/particles/transcript-overlay'; import { Skeleton } from '@/components/ui/skeleton'; -import { AudioLevelBars } from '@/components/audio/audio-level-bars'; +import { CenteredWaveform } from '@/components/audio/centered-waveform'; import { useAudioSource } from '@/components/audio/use-audio-source'; import { useParticleAttachments } from '@/hooks/use-particle-attachments'; import { ParticleAttachments } from '@/features/particles/particle-attachments'; @@ -89,7 +89,7 @@ export const MediaParticleView = forwardRef< currentTime, ); - // WORKAROUND: useAudioSource needs an audio element, but audioRef is only set after mount + // useAudioSource needs the audio element, but audioRef is only set after mount const [audioEl, setAudioEl] = useState(null); const audioSource = useAudioSource(audioEl); @@ -156,7 +156,10 @@ export const MediaParticleView = forwardRef< {audioSource && (
- +
)} diff --git a/js/desktop/src/features/settings/audio-video-settings-page.tsx b/js/desktop/src/features/settings/audio-video-settings-page.tsx index 930e4c7..84dea41 100644 --- a/js/desktop/src/features/settings/audio-video-settings-page.tsx +++ b/js/desktop/src/features/settings/audio-video-settings-page.tsx @@ -12,7 +12,7 @@ import { SelectTrigger, SelectValue, } from '@/components/ui/select'; -import { AudioLevelBars } from '@/components/audio/audio-level-bars'; +import { CenteredWaveform } from '@/components/audio/centered-waveform'; import { useAudioSource } from '@/components/audio/use-audio-source'; import { useMediaDevices } from '@/hooks/use-media-devices'; import { @@ -105,20 +105,17 @@ function FieldLabel({ children }: { children: React.ReactNode }) { function InlineLevelMeter({ stream }: { stream: MediaStream | null }) { const audioSource = useAudioSource(stream); if (!audioSource) { - return ( -
- {[0, 1, 2].map((i) => ( -
- ))} -
- ); + return
; } return ( -
-
- -
-
+ ); } diff --git a/js/desktop/src/hooks/use-recording-mode.ts b/js/desktop/src/hooks/use-recording-mode.ts deleted file mode 100644 index 5daa54c..0000000 --- a/js/desktop/src/hooks/use-recording-mode.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { useState, useCallback } from 'react'; - -export type RecordingMode = 'video' | 'audio'; - -const KEY = 'llink:recording-mode'; - -export function useRecordingMode(): [ - RecordingMode, - (mode: RecordingMode) => void, -] { - const [mode, setModeState] = useState(() => { - const stored = localStorage.getItem(KEY); - return stored === 'audio' ? 'audio' : 'video'; - }); - - const setMode = useCallback((m: RecordingMode) => { - localStorage.setItem(KEY, m); - setModeState(m); - }, []); - - return [mode, setMode]; -} -- 2.54.0 From 9fd7e611f31d09957648ccb5117fc12bbdc6c0bf Mon Sep 17 00:00:00 2001 From: Arjun Patel Date: Thu, 11 Jun 2026 10:21:19 -0700 Subject: [PATCH 2/5] feat: organize network settings into tabs (#264) * implement * fix: apply CodeRabbit auto-fixes Fixed 2 file(s) based on 1 unresolved review comment. Co-authored-by: CodeRabbit * nits --------- Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> Co-authored-by: CodeRabbit --- js/desktop/src/features/network-selector.tsx | 2 +- js/desktop/src/features/network-settings.tsx | 333 +++++++++--------- .../network-settings/add-members-dialog.tsx | 179 ++++++++++ 3 files changed, 350 insertions(+), 164 deletions(-) create mode 100644 js/desktop/src/features/network-settings/add-members-dialog.tsx diff --git a/js/desktop/src/features/network-selector.tsx b/js/desktop/src/features/network-selector.tsx index caef58c..f3f3829 100644 --- a/js/desktop/src/features/network-selector.tsx +++ b/js/desktop/src/features/network-selector.tsx @@ -137,7 +137,7 @@ function CreateNetworkDialog({ toast.success(`Created ${network.name}`); onOpenChange(false); setName(''); - navigate(`/${network.id}/settings`); + navigate(`/${network.id}/settings?section=members&add=1`); }, }); diff --git a/js/desktop/src/features/network-settings.tsx b/js/desktop/src/features/network-settings.tsx index 9f7b972..76586ee 100644 --- a/js/desktop/src/features/network-settings.tsx +++ b/js/desktop/src/features/network-settings.tsx @@ -1,27 +1,37 @@ -import { useEffect, useRef, useState } from 'react'; +import { useEffect, useState } from 'react'; import { useNavigate, useParams, useSearchParams } from 'react-router-dom'; -import { ArrowLeft, CreditCard, Mail, Shield, Users, X } from 'lucide-react'; +import { + ArrowLeft, + CreditCard, + Mail, + Shield, + UserPlus, + Users, + X, +} from 'lucide-react'; import { toast } from 'sonner'; import { Avatar, AvatarFallback } from '@/components/ui/avatar'; import { Badge } from '@/components/ui/badge'; import { Button } from '@/components/ui/button'; -import { Input } from '@/components/ui/input'; import { ScrollArea } from '@/components/ui/scroll-area'; import { Separator } from '@/components/ui/separator'; +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; import { Muted } from '@/components/ui/typography'; import { WindowControls } from '@/components/window-controls'; import { useNetworks } from '@/hooks/use-networks'; import { useNetworkInvitations, - useInviteMembers, useRevokeInvitation, useRemoveMember, } from '@/hooks/use-member-management'; import { useAuthStore } from '@/stores/auth-store'; import { BillingSection } from '@/features/network-billing'; +import { AddMembersDialog } from '@/features/network-settings/add-members-dialog'; import { ConfirmDestructiveOverlay } from '@/components/confirm-destructive-overlay'; import type { Human } from '@/api/types'; +type Section = 'members' | 'billing'; + function MemberRow({ human, isAdmin, @@ -65,43 +75,6 @@ function MemberRow({ ); } -function InviteForm({ networkId }: { networkId: string }) { - const [email, setEmail] = useState(''); - const inviteMembers = useInviteMembers(networkId); - - const handleSubmit = (e: React.FormEvent) => { - e.preventDefault(); - const trimmed = email.trim(); - if (!trimmed) return; - - inviteMembers.mutate([trimmed], { - onSuccess: () => { - toast.success(`Invitation sent to ${trimmed}`); - setEmail(''); - }, - }); - }; - - return ( -
- setEmail(e.target.value)} - className="flex-1" - /> - -
- ); -} - function PendingInvitationRow({ email, networkId, @@ -141,36 +114,38 @@ function PendingInvitationRow({ ); } -function SectionHeader({ - icon, +function SectionHeading({ title, description, - trailing, + count, + action, }: { - icon: React.ReactNode; title: string; description?: string; - trailing?: React.ReactNode; + count?: number; + action?: React.ReactNode; }) { return ( -
- - {icon} - -
+
+
-

{title}

- {trailing} +

{title}

+ {count != null && ( + + {count} + + )}
- {description && {description}} + {description && {description}}
+ {action}
); } -function Section({ children }: { children: React.ReactNode }) { +function Panel({ children }: { children: React.ReactNode }) { return ( -
+
{children}
); @@ -181,7 +156,7 @@ export default function NetworkSettingsPage() { const { networkId } = useParams<{ networkId: string }>(); if (!networkId) throw new Error('NetworkSettingsPage requires a :networkId route param'); - const [searchParams] = useSearchParams(); + const [searchParams, setSearchParams] = useSearchParams(); const { data: networks } = useNetworks(); const network = networks?.find((n) => n.id === networkId); const { data: invitations, error: invitationsError } = @@ -189,18 +164,35 @@ export default function NetworkSettingsPage() { const currentUser = useAuthStore((s) => s.user); const isAdmin = currentUser?.id === network?.admin_human.id; const [memberToRemove, setMemberToRemove] = useState(null); + // Onboarding: opening settings with `?add=1` (e.g. right after creating a + // network) starts with the Add members dialog open. Non-admins never render + // the dialog, so the initial value is harmless for them. + const [addOpen, setAddOpen] = useState(() => searchParams.get('add') === '1'); const removeMember = useRemoveMember(networkId); - const billingRef = useRef(null); + const section: Section = + searchParams.get('section') === 'billing' ? 'billing' : 'members'; + const setSection = (value: string) => { + const next = new URLSearchParams(searchParams); + if (value === 'billing') next.set('section', value); + else next.delete('section'); + setSearchParams(next, { replace: true }); + }; + + // Strip the one-shot `add` param so the dialog doesn't reopen on refresh or + // back navigation. The initial open state was already captured above. useEffect(() => { - if (searchParams.get('section') === 'billing') { - billingRef.current?.scrollIntoView({ - behavior: 'smooth', - block: 'start', - }); - } - }, [searchParams]); + if (searchParams.get('add') !== '1') return; + setSearchParams( + (prev) => { + const next = new URLSearchParams(prev); + next.delete('add'); + return next; + }, + { replace: true }, + ); + }, [setSearchParams, searchParams]); const networkName = network?.name ?? 'Network'; const memberCount = network?.humans.length ?? 0; @@ -223,121 +215,136 @@ export default function NetworkSettingsPage() {
- -
- - - {networkInitials} - - -
-

{networkName}

- - {memberCount} {memberCount === 1 ? 'member' : 'members'} - {isAdmin ? " · You're an admin" : ''} - + +
+ + + + Members + + + + Plan & Billing + + + -
- } - title="Members" - description="People with access to this network." - trailing={ - - {memberCount} - - } - /> - - {network?.humans.map((human, index) => { - const isRowAdmin = human.id === network.admin_human.id; - const canRemove = - isAdmin && !isRowAdmin && human.id !== currentUser?.id; - return ( -
- setMemberToRemove(human) : undefined - } - /> - {index < network.humans.length - 1 && ( - - )} -
- ); - })} -
- - {isAdmin && network && ( -
- } - title="Invitations" - description="Invite teammates by email. They'll get a link to join." - trailing={ - pendingCount > 0 ? ( - - {pendingCount} pending - + + + setAddOpen(true)} + > + + Add members + ) : undefined } /> - - - {invitationsError && ( - <> - -

- Couldn't load pending invitations. -

- - )} - {invitations && invitations.length > 0 && ( - <> - -
- - Pending - -
- {invitations.map((inv, index) => ( -
- + {network?.humans.map((human, index) => { + const isRowAdmin = human.id === network.admin_human.id; + const canRemove = + isAdmin && !isRowAdmin && human.id !== currentUser?.id; + return ( +
+ setMemberToRemove(human) : undefined + } /> - {index < invitations.length - 1 && ( + {index < network.humans.length - 1 && ( )}
- ))} - - )} -
- )} + ); + })} + -
-
- } - title="Billing" + {isAdmin && ( +
+ 0 ? pendingCount : undefined} + /> + {invitationsError ? ( + +

+ Couldn't load pending invitations. +

+
+ ) : invitations && invitations.length > 0 ? ( + + {invitations.map((inv, index) => ( +
+ + {index < invitations.length - 1 && ( + + )} +
+ ))} +
+ ) : ( + No pending invitations. + )} +
+ )} + + + + - - -
-
+ + + + + + -
- + {isAdmin && ( + + )} {memberToRemove && ( void; +}) { + return ( + + {email} + + + ); +} + +export function AddMembersDialog({ + networkId, + open, + onOpenChange, +}: { + networkId: string; + open: boolean; + onOpenChange: (open: boolean) => void; +}) { + const [emails, setEmails] = useState([]); + const [input, setInput] = useState(''); + const [error, setError] = useState(null); + const inviteMembers = useInviteMembers(networkId); + + const reset = () => { + setEmails([]); + setInput(''); + setError(null); + }; + + const handleOpenChange = (next: boolean) => { + if (!next) reset(); + onOpenChange(next); + }; + + // Commits the current input as a chip. Returns the next list of emails so + // callers (like submit) can act on the freshly-committed value. + const commit = (raw: string): string[] | null => { + const trimmed = raw.trim().replace(/,$/, '').trim(); + if (!trimmed) return emails; + if (!emailSchema.safeParse(trimmed).success) { + setError(`"${trimmed}" doesn't look like a valid email.`); + return null; + } + if (emails.includes(trimmed)) { + setInput(''); + return emails; + } + const next = [...emails, trimmed]; + setEmails(next); + setInput(''); + setError(null); + return next; + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Enter' || e.key === ',') { + e.preventDefault(); + commit(input); + } else if (e.key === 'Backspace' && input === '' && emails.length > 0) { + setEmails(emails.slice(0, -1)); + } + }; + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + const next = commit(input); + if (next === null) return; // invalid pending input + if (next.length === 0) return; + + inviteMembers.mutate(next, { + onSuccess: () => { + toast.success( + next.length === 1 + ? `Invited ${next[0]}` + : `Invited ${next.length} people`, + ); + handleOpenChange(false); + }, + }); + }; + + return ( + + + + Add members + + Enter email addresses to add people to this network. + + +
+
+ {emails.map((email) => ( + setEmails(emails.filter((x) => x !== email))} + /> + ))} + { + setInput(e.target.value); + if (error) setError(null); + }} + onKeyDown={handleKeyDown} + onBlur={() => commit(input)} + placeholder={ + emails.length === 0 ? 'name@example.com' : 'Add another…' + } + className="h-7 min-w-[8rem] flex-1 border-0 px-1 shadow-none focus-visible:ring-0" + autoFocus + /> +
+ {error ? ( +

{error}

+ ) : ( + + Press Enter or comma to add each email. + + )} + + + + +
+
+
+ ); +} -- 2.54.0 From 023e332e011714c127bba86ea85b1fada81d43fc Mon Sep 17 00:00:00 2001 From: Arjun Patel Date: Thu, 11 Jun 2026 10:25:01 -0700 Subject: [PATCH 3/5] allow clicking keyboard hints --- .../confirm-destructive-overlay.tsx | 13 +-- js/desktop/src/components/key-hint.tsx | 79 +++++++++++++++++++ .../src/components/keybindings-overlay.tsx | 32 +++----- js/desktop/src/components/ui/kbd.tsx | 26 ++++++ .../src/components/video-audio-toggle.tsx | 11 +-- .../attachments/attachment-lightbox.tsx | 52 +++++++----- .../src/features/compose/compose-overlay.tsx | 21 ++--- .../compose/configure-stream-step.tsx | 19 +++-- .../features/compose/recording-overlay.tsx | 53 ++++--------- .../src/features/compose/text-editor.tsx | 32 ++++---- js/desktop/src/features/network-root.tsx | 38 +++------ .../particles/rename-stream-overlay.tsx | 13 +-- .../particles/stream-members-overlay.tsx | 13 +-- .../src/features/particles/stream-view.tsx | 65 +++++++-------- 14 files changed, 268 insertions(+), 199 deletions(-) create mode 100644 js/desktop/src/components/key-hint.tsx create mode 100644 js/desktop/src/components/ui/kbd.tsx diff --git a/js/desktop/src/components/confirm-destructive-overlay.tsx b/js/desktop/src/components/confirm-destructive-overlay.tsx index cf57b0f..8e19909 100644 --- a/js/desktop/src/components/confirm-destructive-overlay.tsx +++ b/js/desktop/src/components/confirm-destructive-overlay.tsx @@ -1,6 +1,7 @@ import { useEffect } from 'react'; import { createPortal } from 'react-dom'; import { Button } from '@/components/ui/button'; +import { KeyHint } from '@/components/key-hint'; interface ConfirmDestructiveOverlayProps { title: string; @@ -43,12 +44,14 @@ export function ConfirmDestructiveOverlay({

{title}

- - - Esc - {' '} + to close - +
{description}
diff --git a/js/desktop/src/components/key-hint.tsx b/js/desktop/src/components/key-hint.tsx new file mode 100644 index 0000000..d7e4692 --- /dev/null +++ b/js/desktop/src/components/key-hint.tsx @@ -0,0 +1,79 @@ +import { Fragment } from 'react'; +import { Kbd } from '@/components/ui/kbd'; +import { cn } from '@/lib/utils'; + +/** Dark-overlay chip restyle of the design-system Kbd, kept in one place. */ +const chipClass = + 'rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs font-normal text-current'; + +interface KeyHintProps { + /** Key chip(s): "Esc" or ["Esc", "Q"]. */ + keys: string | string[]; + /** Rendered between chips, e.g. "or". Defaults to a plain space. */ + separator?: React.ReactNode; + /** Text before the first chip, e.g. "Release". */ + prefix?: React.ReactNode; + /** Trailing label, e.g. "cancel". May contain icons. */ + children?: React.ReactNode; + /** When set, renders a + ); +} diff --git a/js/desktop/src/components/keybindings-overlay.tsx b/js/desktop/src/components/keybindings-overlay.tsx index 18e1e81..7270b18 100644 --- a/js/desktop/src/components/keybindings-overlay.tsx +++ b/js/desktop/src/components/keybindings-overlay.tsx @@ -1,6 +1,7 @@ import { useSuspendPlayback } from '@/hooks/use-suspend-playback'; import { useEffect } from 'react'; import { createPortal } from 'react-dom'; +import { KeyHint } from '@/components/key-hint'; export interface KeybindingEntry { keys: string[]; @@ -54,16 +55,15 @@ export function KeybindingsOverlay({

{title}

- - - Esc - {' '} - or{' '} - - ? - {' '} + to close - +
{groups.map((group) => ( @@ -80,16 +80,10 @@ export function KeybindingsOverlay({ {binding.description} - - {binding.keys.map((k) => ( - - {k} - - ))} - + ))} diff --git a/js/desktop/src/components/ui/kbd.tsx b/js/desktop/src/components/ui/kbd.tsx new file mode 100644 index 0000000..f9d87d8 --- /dev/null +++ b/js/desktop/src/components/ui/kbd.tsx @@ -0,0 +1,26 @@ +import { cn } from '@/lib/utils'; + +function Kbd({ className, ...props }: React.ComponentProps<'kbd'>) { + return ( + + ); +} + +function KbdGroup({ className, ...props }: React.ComponentProps<'div'>) { + return ( + + ); +} + +export { Kbd, KbdGroup }; diff --git a/js/desktop/src/components/video-audio-toggle.tsx b/js/desktop/src/components/video-audio-toggle.tsx index 9ad3ad6..710fe25 100644 --- a/js/desktop/src/components/video-audio-toggle.tsx +++ b/js/desktop/src/components/video-audio-toggle.tsx @@ -1,4 +1,5 @@ import { Video, Mic } from 'lucide-react'; +import { KeyHint } from '@/components/key-hint'; import { useMediaSettingsStore } from '@/stores/media-settings-store'; export function VideoAudioToggle() { @@ -6,8 +7,8 @@ export function VideoAudioToggle() { const setRecordingMode = useMediaSettingsStore((s) => s.setRecordingMode); return ( - setRecordingMode(recordingMode === 'video' ? 'audio' : 'video') } @@ -16,11 +17,7 @@ export function VideoAudioToggle() { ? 'Switch to audio-only (V)' : 'Switch to video (V)' } - className="cursor-pointer transition-colors hover:text-white/80" > - - V - {' '} {recordingMode === 'video' ? ( <> + ); } diff --git a/js/desktop/src/features/attachments/attachment-lightbox.tsx b/js/desktop/src/features/attachments/attachment-lightbox.tsx index 85a57bc..f46b8c9 100644 --- a/js/desktop/src/features/attachments/attachment-lightbox.tsx +++ b/js/desktop/src/features/attachments/attachment-lightbox.tsx @@ -12,6 +12,7 @@ import { import { useDownloadUrl } from '@/hooks/use-download-url'; import { useObjectUrl } from '@/hooks/use-object-url'; import { Button } from '@/components/ui/button'; +import { KeyHint } from '@/components/key-hint'; import { useSuspendPlayback } from '@/hooks/use-suspend-playback'; import { platform } from '@/lib/platform'; @@ -294,37 +295,46 @@ export function AttachmentLightbox({
{hasMultiple && ( - - ← - - - → - + goTo(-1)} + title="Previous (or press ←)" + aria-label="Previous attachment" + /> + goTo(1)} + title="Next (or press →)" + aria-label="Next attachment" + /> navigate )} {canDownload && ( - - - D - + download - + )} {onRemove && ( - - - ⌫ - + remove - + )} - - - Esc - + onOpenChange(null)} + title="Close (or press Esc)" + > close - +
)} diff --git a/js/desktop/src/features/compose/compose-overlay.tsx b/js/desktop/src/features/compose/compose-overlay.tsx index ef46124..c8e240a 100644 --- a/js/desktop/src/features/compose/compose-overlay.tsx +++ b/js/desktop/src/features/compose/compose-overlay.tsx @@ -17,6 +17,7 @@ import { particlePath, parseParticlePath } from '@/lib/particle-path'; import type { ParticlePath } from '@/lib/particle-path'; import { RecordingOverlay } from '@/features/compose/recording-overlay'; import { ScreenSourcePicker } from '@/components/screen-source-picker'; +import { KeyHint } from '@/components/key-hint'; import { TextComposeStep } from '@/features/compose/text-compose-step'; import { ConfigureStreamStep } from '@/features/compose/configure-stream-step'; import { apiClient } from '@/api/client'; @@ -726,28 +727,20 @@ export function ComposeOverlay({
- - +
)} diff --git a/js/desktop/src/features/compose/configure-stream-step.tsx b/js/desktop/src/features/compose/configure-stream-step.tsx index dce7488..3370cf6 100644 --- a/js/desktop/src/features/compose/configure-stream-step.tsx +++ b/js/desktop/src/features/compose/configure-stream-step.tsx @@ -5,6 +5,7 @@ import { metaKey } from '@/lib/platform'; import { useAuthStore } from '@/stores/auth-store'; import { generateRandomName } from '@/lib/random-name'; import { Input } from '@/components/ui/input'; +import { KeyHint } from '@/components/key-hint'; import { Label } from '@/components/ui/label'; import { Checkbox } from '@/components/ui/checkbox'; import { ScrollArea } from '@/components/ui/scroll-area'; @@ -158,18 +159,16 @@ export function ConfigureStreamStep({ {/* Keyboard hints */}
- - - Esc - {' '} + cancel - - - - {metaKey}+Enter - {' '} + + create - +
); diff --git a/js/desktop/src/features/compose/recording-overlay.tsx b/js/desktop/src/features/compose/recording-overlay.tsx index d720b1f..0022a78 100644 --- a/js/desktop/src/features/compose/recording-overlay.tsx +++ b/js/desktop/src/features/compose/recording-overlay.tsx @@ -8,6 +8,7 @@ import { AttachmentStrip } from '@/features/compose/attachment-strip'; import type { PendingAttachment } from '@/features/compose/attachment-strip'; import { cn } from '@/lib/utils'; import { Button } from '@/components/ui/button'; +import { KeyHint } from '@/components/key-hint'; import { useComposeIntentStore } from '@/stores/compose-intent-store'; interface RecordingOverlayProps { @@ -208,33 +209,22 @@ export function RecordingOverlay({ {/* Bottom center: keyboard hints */} {isRecording && !isLoading && (
- - +
)} @@ -250,32 +240,21 @@ export function RecordingOverlay({
)}
- - + - +
); } diff --git a/js/desktop/src/features/particles/rename-stream-overlay.tsx b/js/desktop/src/features/particles/rename-stream-overlay.tsx index d82d0aa..c05b28e 100644 --- a/js/desktop/src/features/particles/rename-stream-overlay.tsx +++ b/js/desktop/src/features/particles/rename-stream-overlay.tsx @@ -2,6 +2,7 @@ import { useCallback, useEffect, useState } from 'react'; import { createPortal } from 'react-dom'; import { Input } from '@/components/ui/input'; import { Button } from '@/components/ui/button'; +import { KeyHint } from '@/components/key-hint'; import { updateParticleProperties } from '@/lib/firestore-particles'; import { particlePath, toFirestoreDocPath } from '@/lib/particle-path'; import type { Particle } from '@/api/types'; @@ -63,12 +64,14 @@ export function RenameStreamOverlay({

Rename stream

- - - Esc - {' '} + to close - +

Members

- - - Esc - {' '} + to close - +
{/* Visibility */} diff --git a/js/desktop/src/features/particles/stream-view.tsx b/js/desktop/src/features/particles/stream-view.tsx index 3416b21..4f10acb 100644 --- a/js/desktop/src/features/particles/stream-view.tsx +++ b/js/desktop/src/features/particles/stream-view.tsx @@ -29,6 +29,7 @@ import { TextParticleView } from '@/features/particles/text-particle-view'; import { FallbackParticleView } from '@/features/particles/fallback-particle-view'; import { DeletedParticleView } from '@/features/particles/deleted-particle-view'; import { VideoAudioToggle } from '@/components/video-audio-toggle'; +import { KeyHint } from '@/components/key-hint'; import { useMediaSettingsStore } from '@/stores/media-settings-store'; import { KeybindingsOverlay, @@ -362,6 +363,7 @@ function StreamViewInner({ path, streamParticle }: StreamViewProps) { setShowKeybindings(true)} + onOpenHuddle={handleOpenHuddle} /> setShowKeybindings(true)} + onOpenHuddle={handleOpenHuddle} /> ; exitRemainingMs: number | null; onOpenKeybindings: () => void; + onOpenHuddle: () => void; }) { return (
@@ -590,58 +596,53 @@ function BottomBar({ function StreamViewControls({ showEscape, onOpenKeybindings, + onOpenHuddle, }: { showEscape?: boolean; onOpenKeybindings: () => void; + onOpenHuddle: () => void; }) { + const navigate = useNavigate(); const requestIntent = useComposeIntentStore((s) => s.request); return (
{showEscape && ( - - - Esc - {' '} + navigate(-1)} + title="Back to network (or press Esc)" + > back - + )} - - - - - H - {' '} - huddle - - + - ? - + huddle + +
); } -- 2.54.0 From dd850d28d13405a8065caccfef5fb52ed148ad18 Mon Sep 17 00:00:00 2001 From: Arjun Patel Date: Thu, 11 Jun 2026 10:40:19 -0700 Subject: [PATCH 4/5] Update js/desktop/src/components/key-hint.tsx Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- js/desktop/src/components/key-hint.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/js/desktop/src/components/key-hint.tsx b/js/desktop/src/components/key-hint.tsx index d7e4692..f23b281 100644 --- a/js/desktop/src/components/key-hint.tsx +++ b/js/desktop/src/components/key-hint.tsx @@ -43,7 +43,7 @@ export function KeyHint({ <> {prefix != null && <>{prefix} } {keyList.map((k, i) => ( - + {i > 0 && (separator != null ? <> {separator} : ' ')} {k} -- 2.54.0 From 1cf144bce3c8a2d6bf8f414148c22b9ec2781abf Mon Sep 17 00:00:00 2001 From: Arjun Patel Date: Thu, 11 Jun 2026 10:44:59 -0700 Subject: [PATCH 5/5] nits --- .../src/features/particles/stream-view.tsx | 19 +++++++++++++------ .../src/hooks/use-stream-navigation-keys.ts | 9 ++++----- 2 files changed, 17 insertions(+), 11 deletions(-) diff --git a/js/desktop/src/features/particles/stream-view.tsx b/js/desktop/src/features/particles/stream-view.tsx index 4f10acb..2352f7d 100644 --- a/js/desktop/src/features/particles/stream-view.tsx +++ b/js/desktop/src/features/particles/stream-view.tsx @@ -260,12 +260,17 @@ function StreamViewInner({ path, streamParticle }: StreamViewProps) { const { fastPlayback } = usePlaybackKeys({ mediaRef }); + const handleExitNavigate = useCallback(() => { + navigate(`/${networkId}`); + }, [navigate, networkId]); + useStreamNavigationKeys({ next, prev, currentIndex, childrenLength: children.length, mediaRef, + onExit: handleExitNavigate, }); const handleOpenHuddle = useCallback(() => { @@ -326,10 +331,6 @@ function StreamViewInner({ path, streamParticle }: StreamViewProps) { // Always show controls when compose is active or exit countdown is visible const controlsVisible = showControls || composeActive || status === 'ended'; - const handleExitNavigate = useCallback(() => { - navigate(`/${networkId}`); - }, [navigate, networkId]); - const exitRemainingMs = useExitCountdown(status, paused, handleExitNavigate); // Reset progress when the particle changes. @@ -364,6 +365,7 @@ function StreamViewInner({ path, streamParticle }: StreamViewProps) { showEscape onOpenKeybindings={() => setShowKeybindings(true)} onOpenHuddle={handleOpenHuddle} + onExit={handleExitNavigate} /> setShowKeybindings(true)} onOpenHuddle={handleOpenHuddle} + onExit={handleExitNavigate} /> void; onOpenHuddle: () => void; + onExit: () => void; }) { return (
@@ -597,19 +603,20 @@ function StreamViewControls({ showEscape, onOpenKeybindings, onOpenHuddle, + onExit, }: { showEscape?: boolean; onOpenKeybindings: () => void; onOpenHuddle: () => void; + onExit: () => void; }) { - const navigate = useNavigate(); const requestIntent = useComposeIntentStore((s) => s.request); return (
{showEscape && ( navigate(-1)} + onClick={onExit} title="Back to network (or press Esc)" > back diff --git a/js/desktop/src/hooks/use-stream-navigation-keys.ts b/js/desktop/src/hooks/use-stream-navigation-keys.ts index 93bab12..e90209f 100644 --- a/js/desktop/src/hooks/use-stream-navigation-keys.ts +++ b/js/desktop/src/hooks/use-stream-navigation-keys.ts @@ -1,5 +1,4 @@ import { useEffect, type RefObject } from 'react'; -import { useNavigate } from 'react-router-dom'; import type { MediaParticleHandle } from '@/features/particles/media-particle-view'; import { isTypingTarget } from '@/lib/keyboard'; @@ -11,6 +10,7 @@ interface UseStreamNavigationKeysOptions { currentIndex: number; childrenLength: number; mediaRef: RefObject; + onExit: () => void; } /** @@ -23,9 +23,8 @@ export function useStreamNavigationKeys({ currentIndex, childrenLength, mediaRef, + onExit, }: UseStreamNavigationKeysOptions) { - const navigate = useNavigate(); - useEffect(() => { const onKeyDown = (e: KeyboardEvent) => { if (isTypingTarget(e)) return; @@ -53,12 +52,12 @@ export function useStreamNavigationKeys({ break; case 'Escape': e.preventDefault(); - navigate(-1); + onExit(); break; } }; window.addEventListener('keydown', onKeyDown); return () => window.removeEventListener('keydown', onKeyDown); - }, [next, prev, currentIndex, childrenLength, mediaRef, navigate]); + }, [next, prev, currentIndex, childrenLength, mediaRef, onExit]); } -- 2.54.0