3 Commits

Author SHA1 Message Date
Arjun Patel 1cf144bce3 nits 2026-06-11 10:45:11 -07:00
Arjun Patel dd850d28d1 Update js/desktop/src/components/key-hint.tsx
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2026-06-11 10:40:19 -07:00
Arjun Patel 023e332e01 allow clicking keyboard hints 2026-06-11 10:25:01 -07:00
13 changed files with 376 additions and 637 deletions
@@ -1,14 +1,43 @@
name: PR Quality Gate (Desktop)
name: PR Quality Gate (client applications)
on:
pull_request:
paths:
- "js/mobile/**"
- "js/desktop/**"
branches: [ main ]
types: [ opened, synchronize, reopened ]
jobs:
verify:
mobile:
name: Lint & format check (mobile)
runs-on: ubuntu-latest
defaults:
run:
working-directory: js/mobile
steps:
- name: Check out repository code
uses: actions/checkout@v4
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
- name: Enable Corepack
run: corepack enable
- name: Install dependencies
run: yarn install --immutable
- name: Run Code Linter
run: yarn lint
- name: Run Format Check
run: yarn format:check
desktop:
name: Lint & format check (desktop)
runs-on: ubuntu-latest
defaults:
@@ -1,37 +0,0 @@
name: PR Quality Gate (Mobile)
on:
pull_request:
paths:
- "js/mobile/**"
branches: [ main ]
types: [ opened, synchronize, reopened ]
jobs:
verify:
name: Lint & format check (mobile)
runs-on: ubuntu-latest
defaults:
run:
working-directory: js/mobile
steps:
- name: Check out repository code
uses: actions/checkout@v4
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
- name: Enable Corepack
run: corepack enable
- name: Install dependencies
run: yarn install --immutable
- name: Run Code Linter
run: yarn lint
- name: Run Format Check
run: yarn format:check
+6 -7
View File
@@ -1,24 +1,20 @@
# Project Rules
## Architecture
- Electron typescript/react app is in `js/desktop` folder
- Mobile react native expo app is in `js/mobile`
- Orion is the api server which lives in the `go/` folder along with other workers, jobs, services
- Electron typescript/react app is in `js/` folder
- Orion is the api server which lives in the `go/` folder
- `cpp/` points to our prototype of a C++ Qt widgets client
Whenever implementing anything, make sure to take into account best practices without over-engineering.
## Electron App
### Quality
We care about overall architectural quality and keeping consistent patterns according to best practices. Feel free to move stuff around to make things more elegant, instead of bolting on features.
We care about overall architectural quality and keeping consistent patterns according to best practices.
Another tradeoff we make is simpler, maintainable code over clever behavior.
As an example, we have as high of a bar as a product team like Linear and Apple, which outputs high quality software. Let's avoid slop at all costs.
### Comments
Don't add non-essential comments everywhere. Clean them up if so.
### Package Manager
- Use **yarn** (not npm) for all dependency management
@@ -29,3 +25,6 @@ Whenever possible, we should use the design system components. If we need to add
When adding a feature on the client side, make sure that the api actually supports it by just checking orion implementation all the way through.
IF you find that the API is poorly designed, please suggest changes to improve the client experience.
## Verification
Check using `yarn compile` which lives in the package.json as a script.
@@ -0,0 +1,122 @@
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 (
<div className="flex items-end gap-1.5">
{Array.from({ length: BAR_COUNT }, (_, i) => (
<div
key={i}
ref={(el) => {
barRefs.current[i] = el;
}}
className="w-1.5 rounded-full bg-green-400"
style={{ height: `${MIN_HEIGHT_PX}px` }}
/>
))}
</div>
);
}
@@ -1,213 +0,0 @@
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<HTMLCanvasElement | null>(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 (
<canvas ref={canvasRef} className={className} style={{ width, height }} />
);
}
/**
* 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;
};
}
@@ -1,7 +1,7 @@
import { useEffect, useRef, useState } from 'react';
import { Paperclip } from 'lucide-react';
import type { RecordingMode } from '@/stores/media-settings-store';
import { CenteredWaveform } from '@/components/audio/centered-waveform';
import type { RecordingMode } from '@/hooks/use-recording-mode';
import { AudioLevelBars } from '@/components/audio/audio-level-bars';
import { useAudioSource } from '@/components/audio/use-audio-source';
import { useObjectUrl } from '@/hooks/use-object-url';
import { AttachmentStrip } from '@/features/compose/attachment-strip';
@@ -68,6 +68,7 @@ function ReviewPlayback({
objectFit?: 'cover' | 'contain';
}) {
const objectUrl = useObjectUrl(blob);
const audioElRef = useRef<HTMLAudioElement | null>(null);
const [audioEl, setAudioEl] = useState<HTMLAudioElement | null>(null);
const audioSource = useAudioSource(isVideo ? null : audioEl);
@@ -87,12 +88,17 @@ function ReviewPlayback({
return (
<div className="flex flex-col items-center gap-3">
<audio ref={setAudioEl} src={objectUrl} autoPlay loop />
<audio
ref={(el) => {
audioElRef.current = el;
setAudioEl(el);
}}
src={objectUrl}
autoPlay
loop
/>
{audioSource ? (
<CenteredWaveform
sourceNode={audioSource.sourceNode}
className="text-white"
/>
<AudioLevelBars sourceNode={audioSource.sourceNode} />
) : (
<span className="text-sm text-white/60">Playing back audio...</span>
)}
@@ -193,13 +199,10 @@ export function RecordingOverlay({
) : null}
</div>
{/* Bottom center: live waveform (recording with active stream) */}
{/* Bottom center: audio level bars (recording with active stream) */}
{isRecording && recordingAudioSource && (
<div className="z-10 absolute bottom-15">
<CenteredWaveform
sourceNode={recordingAudioSource.sourceNode}
className="text-white"
/>
<AudioLevelBars sourceNode={recordingAudioSource.sourceNode} />
</div>
)}
@@ -1,5 +1,5 @@
import { useCallback, useEffect, useRef } from 'react';
import type { RecordingMode } from '@/stores/media-settings-store';
import type { RecordingMode } from '@/hooks/use-recording-mode';
const VIDEO_PREFERRED_MIME = 'video/webm;codecs=vp9,opus';
const VIDEO_FALLBACK_MIME = 'video/webm';
+1 -1
View File
@@ -137,7 +137,7 @@ function CreateNetworkDialog({
toast.success(`Created ${network.name}`);
onOpenChange(false);
setName('');
navigate(`/${network.id}/settings?section=members&add=1`);
navigate(`/${network.id}/settings`);
},
});
+162 -169
View File
@@ -1,37 +1,27 @@
import { useEffect, useState } from 'react';
import { useEffect, useRef, useState } from 'react';
import { useNavigate, useParams, useSearchParams } from 'react-router-dom';
import {
ArrowLeft,
CreditCard,
Mail,
Shield,
UserPlus,
Users,
X,
} from 'lucide-react';
import { ArrowLeft, CreditCard, Mail, Shield, 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,
@@ -75,6 +65,43 @@ 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 (
<form onSubmit={handleSubmit} className="flex items-center gap-2 px-4 py-3">
<Input
type="email"
placeholder="name@example.com"
value={email}
onChange={(e) => setEmail(e.target.value)}
className="flex-1"
/>
<Button
type="submit"
size="sm"
disabled={!email.trim() || inviteMembers.isPending}
>
{inviteMembers.isPending ? 'Sending...' : 'Invite'}
</Button>
</form>
);
}
function PendingInvitationRow({
email,
networkId,
@@ -114,38 +141,36 @@ function PendingInvitationRow({
);
}
function SectionHeading({
function SectionHeader({
icon,
title,
description,
count,
action,
trailing,
}: {
icon: React.ReactNode;
title: string;
description?: string;
count?: number;
action?: React.ReactNode;
trailing?: React.ReactNode;
}) {
return (
<div className="mb-3 flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="flex items-start gap-3 px-4 pb-2 pt-6">
<span className="text-muted-foreground mt-0.5 flex size-4 items-center justify-center">
{icon}
</span>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<h2 className="text-base font-semibold tracking-tight">{title}</h2>
{count != null && (
<Badge variant="secondary" className="tabular-nums">
{count}
</Badge>
)}
<h2 className="text-sm font-semibold tracking-tight">{title}</h2>
{trailing}
</div>
{description && <Muted className="mt-0.5 text-xs">{description}</Muted>}
{description && <Muted className="text-xs">{description}</Muted>}
</div>
{action}
</div>
);
}
function Panel({ children }: { children: React.ReactNode }) {
function Section({ children }: { children: React.ReactNode }) {
return (
<section className="bg-card/40 overflow-hidden rounded-lg border">
<section className="bg-card/40 mx-4 mb-2 overflow-hidden rounded-lg border">
{children}
</section>
);
@@ -156,7 +181,7 @@ export default function NetworkSettingsPage() {
const { networkId } = useParams<{ networkId: string }>();
if (!networkId)
throw new Error('NetworkSettingsPage requires a :networkId route param');
const [searchParams, setSearchParams] = useSearchParams();
const [searchParams] = useSearchParams();
const { data: networks } = useNetworks();
const network = networks?.find((n) => n.id === networkId);
const { data: invitations, error: invitationsError } =
@@ -164,35 +189,18 @@ export default function NetworkSettingsPage() {
const currentUser = useAuthStore((s) => s.user);
const isAdmin = currentUser?.id === network?.admin_human.id;
const [memberToRemove, setMemberToRemove] = useState<Human | null>(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 section: Section =
searchParams.get('section') === 'billing' ? 'billing' : 'members';
const billingRef = useRef<HTMLDivElement>(null);
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('add') !== '1') return;
setSearchParams(
(prev) => {
const next = new URLSearchParams(prev);
next.delete('add');
return next;
},
{ replace: true },
);
}, [setSearchParams, searchParams]);
if (searchParams.get('section') === 'billing') {
billingRef.current?.scrollIntoView({
behavior: 'smooth',
block: 'start',
});
}
}, [searchParams]);
const networkName = network?.name ?? 'Network';
const memberCount = network?.humans.length ?? 0;
@@ -215,136 +223,121 @@ export default function NetworkSettingsPage() {
<div className="flex-1" />
</div>
<Tabs
value={section}
onValueChange={setSection}
orientation="vertical"
className="min-h-0 flex-1 gap-0"
>
<aside className="flex w-52 shrink-0 flex-col gap-4 border-r p-3">
<div className="flex items-center gap-3 px-1 pt-1">
<Avatar>
<AvatarFallback className="bg-primary/10 text-primary font-medium">
{networkInitials}
</AvatarFallback>
</Avatar>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-semibold">{networkName}</p>
<Muted className="text-xs">
{memberCount} {memberCount === 1 ? 'member' : 'members'}
</Muted>
</div>
<ScrollArea className="min-h-0 flex-1">
<div className="flex items-center gap-3 px-4 pb-4 pt-6">
<Avatar size="lg">
<AvatarFallback className="bg-primary/10 text-primary font-medium">
{networkInitials}
</AvatarFallback>
</Avatar>
<div className="min-w-0 flex-1">
<p className="truncate text-base font-semibold">{networkName}</p>
<Muted className="text-xs">
{memberCount} {memberCount === 1 ? 'member' : 'members'}
{isAdmin ? " · You're an admin" : ''}
</Muted>
</div>
<TabsList variant="line" className="w-full gap-1">
<TabsTrigger value="members">
<Users />
Members
</TabsTrigger>
<TabsTrigger value="billing">
<CreditCard />
Plan & Billing
</TabsTrigger>
</TabsList>
</aside>
</div>
<ScrollArea className="min-h-0 flex-1">
<TabsContent value="members" className="p-4">
<SectionHeading
title="Members"
description="People with access to this network."
count={memberCount}
action={
isAdmin ? (
<Button
size="sm"
className="shrink-0"
onClick={() => setAddOpen(true)}
>
<UserPlus className="mr-1 size-3.5" />
Add members
</Button>
<Section>
<SectionHeader
icon={<Users className="size-4" />}
title="Members"
description="People with access to this network."
trailing={
<Badge variant="secondary" className="tabular-nums">
{memberCount}
</Badge>
}
/>
<Separator />
{network?.humans.map((human, index) => {
const isRowAdmin = human.id === network.admin_human.id;
const canRemove =
isAdmin && !isRowAdmin && human.id !== currentUser?.id;
return (
<div key={human.id}>
<MemberRow
human={human}
isAdmin={isRowAdmin}
onRemove={
canRemove ? () => setMemberToRemove(human) : undefined
}
/>
{index < network.humans.length - 1 && (
<Separator className="mx-4" />
)}
</div>
);
})}
</Section>
{isAdmin && network && (
<Section>
<SectionHeader
icon={<Mail className="size-4" />}
title="Invitations"
description="Invite teammates by email. They'll get a link to join."
trailing={
pendingCount > 0 ? (
<Badge variant="secondary" className="tabular-nums">
{pendingCount} pending
</Badge>
) : undefined
}
/>
<Panel>
{network?.humans.map((human, index) => {
const isRowAdmin = human.id === network.admin_human.id;
const canRemove =
isAdmin && !isRowAdmin && human.id !== currentUser?.id;
return (
<div key={human.id}>
<MemberRow
human={human}
isAdmin={isRowAdmin}
onRemove={
canRemove ? () => setMemberToRemove(human) : undefined
}
<Separator />
<InviteForm networkId={networkId} />
{invitationsError && (
<>
<Separator />
<p className="text-muted-foreground px-4 py-3 text-xs">
Couldn't load pending invitations.
</p>
</>
)}
{invitations && invitations.length > 0 && (
<>
<Separator />
<div className="px-4 pb-1 pt-3">
<Muted className="text-xs font-medium uppercase tracking-wider">
Pending
</Muted>
</div>
{invitations.map((inv, index) => (
<div key={inv.email}>
<PendingInvitationRow
email={inv.email}
networkId={networkId}
/>
{index < network.humans.length - 1 && (
{index < invitations.length - 1 && (
<Separator className="mx-4" />
)}
</div>
);
})}
</Panel>
{isAdmin && (
<div className="mt-6">
<SectionHeading
title="Pending invitations"
description="Invites that haven't been accepted yet."
count={pendingCount > 0 ? pendingCount : undefined}
/>
{invitationsError ? (
<Panel>
<p className="text-muted-foreground px-4 py-3 text-xs">
Couldn't load pending invitations.
</p>
</Panel>
) : invitations && invitations.length > 0 ? (
<Panel>
{invitations.map((inv, index) => (
<div key={inv.email}>
<PendingInvitationRow
email={inv.email}
networkId={networkId}
/>
{index < invitations.length - 1 && (
<Separator className="mx-4" />
)}
</div>
))}
</Panel>
) : (
<Muted className="text-xs">No pending invitations.</Muted>
)}
</div>
))}
</>
)}
</TabsContent>
</Section>
)}
<TabsContent value="billing" className="p-4">
<SectionHeading
title="Plan & Billing"
<div ref={billingRef}>
<Section>
<SectionHeader
icon={<CreditCard className="size-4" />}
title="Billing"
description={
isAdmin
? 'Manage your plan, seats, and payment.'
: "Your network's current plan and usage."
}
/>
<Panel>
<BillingSection networkId={networkId} />
</Panel>
</TabsContent>
</ScrollArea>
</Tabs>
<Separator />
<BillingSection networkId={networkId} />
</Section>
</div>
{isAdmin && (
<AddMembersDialog
networkId={networkId}
open={addOpen}
onOpenChange={setAddOpen}
/>
)}
<div className="h-6" />
</ScrollArea>
{memberToRemove && (
<ConfirmDestructiveOverlay
@@ -1,179 +0,0 @@
import { useState } from 'react';
import { X } from 'lucide-react';
import { toast } from 'sonner';
import { z } from 'zod';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Muted } from '@/components/ui/typography';
import { useInviteMembers } from '@/hooks/use-member-management';
const emailSchema = z.string().email();
function EmailChip({
email,
onRemove,
}: {
email: string;
onRemove: () => void;
}) {
return (
<span className="bg-secondary text-secondary-foreground inline-flex items-center gap-1 rounded-md py-0.5 pl-2 pr-1 text-xs">
{email}
<Button
type="button"
variant="ghost"
size="icon-sm"
onClick={onRemove}
aria-label={`Remove ${email}`}
className="text-muted-foreground hover:text-foreground size-5"
>
<X className="size-3" />
</Button>
</span>
);
}
export function AddMembersDialog({
networkId,
open,
onOpenChange,
}: {
networkId: string;
open: boolean;
onOpenChange: (open: boolean) => void;
}) {
const [emails, setEmails] = useState<string[]>([]);
const [input, setInput] = useState('');
const [error, setError] = useState<string | null>(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<HTMLInputElement>) => {
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 (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>Add members</DialogTitle>
<DialogDescription>
Enter email addresses to add people to this network.
</DialogDescription>
</DialogHeader>
<form onSubmit={handleSubmit}>
<div className="border-input focus-within:border-ring focus-within:ring-ring/50 flex flex-wrap items-center gap-1.5 rounded-md border px-2 py-1.5 transition-colors focus-within:ring-[3px]">
{emails.map((email) => (
<EmailChip
key={email}
email={email}
onRemove={() => setEmails(emails.filter((x) => x !== email))}
/>
))}
<Input
type="email"
value={input}
onChange={(e) => {
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
/>
</div>
{error ? (
<p className="text-destructive mt-1.5 text-xs">{error}</p>
) : (
<Muted className="mt-1.5 text-xs">
Press Enter or comma to add each email.
</Muted>
)}
<DialogFooter className="mt-4">
<Button
type="button"
variant="outline"
onClick={() => handleOpenChange(false)}
>
Cancel
</Button>
<Button
type="submit"
disabled={
inviteMembers.isPending ||
(emails.length === 0 && input.trim() === '')
}
>
{inviteMembers.isPending ? 'Adding…' : 'Add members'}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}
@@ -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 { CenteredWaveform } from '@/components/audio/centered-waveform';
import { AudioLevelBars } from '@/components/audio/audio-level-bars';
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,
);
// useAudioSource needs the audio element, but audioRef is only set after mount
// WORKAROUND: useAudioSource needs an audio element, but audioRef is only set after mount
const [audioEl, setAudioEl] = useState<HTMLAudioElement | null>(null);
const audioSource = useAudioSource(audioEl);
@@ -156,10 +156,7 @@ export const MediaParticleView = forwardRef<
{audioSource && (
<div className="z-10 absolute bottom-20">
<CenteredWaveform
sourceNode={audioSource.sourceNode}
className="text-white"
/>
<AudioLevelBars sourceNode={audioSource.sourceNode} />
</div>
)}
@@ -12,7 +12,7 @@ import {
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { CenteredWaveform } from '@/components/audio/centered-waveform';
import { AudioLevelBars } from '@/components/audio/audio-level-bars';
import { useAudioSource } from '@/components/audio/use-audio-source';
import { useMediaDevices } from '@/hooks/use-media-devices';
import {
@@ -105,17 +105,20 @@ function FieldLabel({ children }: { children: React.ReactNode }) {
function InlineLevelMeter({ stream }: { stream: MediaStream | null }) {
const audioSource = useAudioSource(stream);
if (!audioSource) {
return <div className="bg-muted h-3 w-20 rounded-sm opacity-50" />;
return (
<div className="flex h-3 items-end gap-1">
{[0, 1, 2].map((i) => (
<div key={i} className="bg-muted h-1 w-1 rounded-full" />
))}
</div>
);
}
return (
<CenteredWaveform
sourceNode={audioSource.sourceNode}
width={80}
height={12}
barWidth={2}
gap={3}
className="text-primary"
/>
<div className="flex h-3 items-end">
<div className="scale-[0.55] origin-right">
<AudioLevelBars sourceNode={audioSource.sourceNode} />
</div>
</div>
);
}
@@ -0,0 +1,22 @@
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<RecordingMode>(() => {
const stored = localStorage.getItem(KEY);
return stored === 'audio' ? 'audio' : 'video';
});
const setMode = useCallback((m: RecordingMode) => {
localStorage.setItem(KEY, m);
setModeState(m);
}, []);
return [mode, setMode];
}