Author SHA1 Message Date
Arjun Patel 11d88dbb1b update claude.md 2026-06-11 10:58:06 -07:00
Arjun PatelandGitHub b9a8529e72 ci: split pr quality gates for mobile and desktop (#268) 2026-06-11 10:57:03 -07:00
Arjun PatelGitHubcoderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
95f2362947 feat: allow clicking keyboard hints throughout (#266)
* allow clicking keyboard hints

* Update js/desktop/src/components/key-hint.tsx

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>

* nits

---------

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2026-06-11 10:48:59 -07:00
Arjun PatelGitHubcoderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>CodeRabbit
9fd7e611f3 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 <[email protected]>

* nits

---------

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
Co-authored-by: CodeRabbit <[email protected]>
2026-06-11 10:21:19 -07:00
Arjun PatelandGitHub 849d41fa06 feat: use sexier audio waveform (#265)
* use sexier audio waveform

* format
2026-06-11 10:07:25 -07:00
27 changed files with 921 additions and 585 deletions
@@ -1,43 +1,14 @@
name: PR Quality Gate (client applications)
name: PR Quality Gate (Desktop)
on:
pull_request:
paths:
- "js/mobile/**"
- "js/desktop/**"
branches: [ main ]
types: [ opened, synchronize, reopened ]
jobs:
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:
verify:
name: Lint & format check (desktop)
runs-on: ubuntu-latest
defaults:
@@ -0,0 +1,37 @@
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
+7 -6
View File
@@ -1,20 +1,24 @@
# Project Rules
## Architecture
- Electron typescript/react app is in `js/` folder
- Orion is the api server which lives in the `go/` folder
- 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
- `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.
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.
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
@@ -25,6 +29,3 @@ 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.
@@ -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 (
<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>
);
}
@@ -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<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,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({
<div className="absolute left-1/2 top-1/2 w-full max-w-sm -translate-x-1/2 -translate-y-1/2 rounded-2xl border border-white/10 bg-white/5 p-6 shadow-2xl backdrop-blur-xl">
<div className="mb-3 flex items-center justify-between">
<h2 className="text-sm font-semibold text-white/70">{title}</h2>
<span className="text-xs text-white/30">
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs">
Esc
</kbd>{' '}
<KeyHint
keys="Esc"
onClick={onClose}
title="Close (or press Esc)"
className="text-xs text-white/30"
>
to close
</span>
</KeyHint>
</div>
<div className="text-sm text-white/60">{description}</div>
+79
View File
@@ -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 <button> with hover affordance; otherwise a plain <span>. */
onClick?: () => void;
/** Tooltip explaining the action; pass alongside onClick. */
title?: string;
className?: string;
'aria-label'?: string;
}
/**
* A keyboard-shortcut hint: one or more key chips with optional surrounding
* text. Keyboard-first, but every hint with an `onClick` is also a real
* button so mouse users can trigger the same action by clicking it.
*/
export function KeyHint({
keys,
separator,
prefix,
children,
onClick,
title,
className,
...rest
}: KeyHintProps) {
const keyList = Array.isArray(keys) ? keys : [keys];
const content = (
<>
{prefix != null && <>{prefix} </>}
{keyList.map((k, i) => (
<Fragment key={`${k}-${i}`}>
{i > 0 && (separator != null ? <> {separator} </> : ' ')}
<Kbd className={chipClass}>{k}</Kbd>
</Fragment>
))}
{children != null && <> {children}</>}
</>
);
if (!onClick) {
return (
<span className={className} {...rest}>
{content}
</span>
);
}
return (
<button
type="button"
onClick={onClick}
// Keep focus where it is (e.g. the compose textarea); the click still fires.
onMouseDown={(e) => e.preventDefault()}
title={title}
className={cn(
'cursor-pointer rounded transition-colors hover:text-white/80',
className,
)}
{...rest}
>
{content}
</button>
);
}
@@ -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({
<div className="absolute left-1/2 top-1/2 w-full max-w-sm -translate-x-1/2 -translate-y-1/2 rounded-2xl border border-white/10 bg-white/5 p-6 shadow-2xl backdrop-blur-xl">
<div className="mb-5 flex items-center justify-between">
<h2 className="text-sm font-semibold text-white/70">{title}</h2>
<span className="text-xs text-white/30">
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs">
Esc
</kbd>{' '}
or{' '}
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs">
?
</kbd>{' '}
<KeyHint
keys={['Esc', '?']}
separator="or"
onClick={onClose}
title="Close (or press Esc / ?)"
className="text-xs text-white/30"
>
to close
</span>
</KeyHint>
</div>
<div className="flex flex-col gap-5">
{groups.map((group) => (
@@ -80,16 +80,10 @@ export function KeybindingsOverlay({
<span className="text-sm text-white/70">
{binding.description}
</span>
<span className="flex items-center gap-1">
{binding.keys.map((k) => (
<kbd
key={k}
className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs text-white/60"
>
{k}
</kbd>
))}
</span>
<KeyHint
keys={binding.keys}
className="flex items-center gap-1 text-white/60"
/>
</li>
))}
</ul>
+26
View File
@@ -0,0 +1,26 @@
import { cn } from '@/lib/utils';
function Kbd({ className, ...props }: React.ComponentProps<'kbd'>) {
return (
<kbd
data-slot="kbd"
className={cn(
"pointer-events-none inline-flex h-5 w-fit min-w-5 items-center justify-center gap-1 rounded-sm bg-muted px-1 font-sans text-xs font-medium text-muted-foreground select-none in-data-[slot=tooltip-content]:bg-background/20 in-data-[slot=tooltip-content]:text-background dark:in-data-[slot=tooltip-content]:bg-background/10 [&_svg:not([class*='size-'])]:size-3",
className,
)}
{...props}
/>
);
}
function KbdGroup({ className, ...props }: React.ComponentProps<'div'>) {
return (
<kbd
data-slot="kbd-group"
className={cn('inline-flex items-center gap-1', className)}
{...props}
/>
);
}
export { Kbd, KbdGroup };
@@ -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 (
<span
role="button"
<KeyHint
keys="V"
onClick={() =>
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"
>
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs">
V
</kbd>{' '}
{recordingMode === 'video' ? (
<>
<Video className="inline size-3" /> video
@@ -30,6 +27,6 @@ export function VideoAudioToggle() {
<Mic className="inline size-3" /> audio
</>
)}
</span>
</KeyHint>
);
}
@@ -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({
<div className="absolute bottom-4 left-1/2 flex -translate-x-1/2 items-center gap-4 text-xs text-white/50">
{hasMultiple && (
<span className="flex items-center gap-1.5">
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs">
</kbd>
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs">
</kbd>
<KeyHint
keys="←"
onClick={() => goTo(-1)}
title="Previous (or press ←)"
aria-label="Previous attachment"
/>
<KeyHint
keys="→"
onClick={() => goTo(1)}
title="Next (or press →)"
aria-label="Next attachment"
/>
navigate
</span>
)}
{canDownload && (
<span className="flex items-center gap-1.5">
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs">
D
</kbd>
<KeyHint
keys="D"
onClick={handleDownload}
title="Download (or press D)"
>
download
</span>
</KeyHint>
)}
{onRemove && (
<span className="flex items-center gap-1.5">
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs">
</kbd>
<KeyHint
keys="⌫"
onClick={handleRemove}
title="Remove (or press Backspace)"
>
remove
</span>
</KeyHint>
)}
<span className="flex items-center gap-1.5">
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs">
Esc
</kbd>
<KeyHint
keys="Esc"
onClick={() => onOpenChange(null)}
title="Close (or press Esc)"
>
close
</span>
</KeyHint>
</div>
</>
)}
@@ -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({
</div>
</div>
<div className="absolute bottom-4 z-10 flex items-center gap-4 text-sm text-white/50">
<button
type="button"
<KeyHint
keys="S"
onClick={handleStopIntent}
className="cursor-pointer rounded transition-colors hover:text-white/80"
title="Stop screen recording (or press S)"
>
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs">
S
</kbd>{' '}
stop
</button>
<button
type="button"
</KeyHint>
<KeyHint
keys="Q"
onClick={handleCancelIntent}
className="cursor-pointer rounded transition-colors hover:text-white/80"
title="Cancel screen recording (or press Q)"
>
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs">
Q
</kbd>{' '}
cancel
</button>
</KeyHint>
</div>
</div>
)}
@@ -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 */}
<div className="absolute bottom-8 left-0 right-0 flex items-center justify-center gap-4 text-sm text-white/50">
<span>
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs">
Esc
</kbd>{' '}
<KeyHint keys="Esc" onClick={onCancel} title="Cancel (or press Esc)">
cancel
</span>
<span>
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs">
{metaKey}+Enter
</kbd>{' '}
</KeyHint>
<KeyHint
keys={`${metaKey}+Enter`}
onClick={handleSubmit}
title={`Create stream (or press ${metaKey}+Enter)`}
>
create
</span>
</KeyHint>
</div>
</div>
);
@@ -1,13 +1,14 @@
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';
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 {
@@ -67,7 +68,6 @@ 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,17 +87,12 @@ function ReviewPlayback({
return (
<div className="flex flex-col items-center gap-3">
<audio
ref={(el) => {
audioElRef.current = el;
setAudioEl(el);
}}
src={objectUrl}
autoPlay
loop
/>
<audio ref={setAudioEl} src={objectUrl} autoPlay loop />
{audioSource ? (
<AudioLevelBars sourceNode={audioSource.sourceNode} />
<CenteredWaveform
sourceNode={audioSource.sourceNode}
className="text-white"
/>
) : (
<span className="text-sm text-white/60">Playing back audio...</span>
)}
@@ -198,43 +193,35 @@ export function RecordingOverlay({
) : null}
</div>
{/* Bottom center: audio level bars (recording with active stream) */}
{/* Bottom center: live waveform (recording with active stream) */}
{isRecording && recordingAudioSource && (
<div className="z-10 absolute bottom-15">
<AudioLevelBars sourceNode={recordingAudioSource.sourceNode} />
<CenteredWaveform
sourceNode={recordingAudioSource.sourceNode}
className="text-white"
/>
</div>
)}
{/* Bottom center: keyboard hints */}
{isRecording && !isLoading && (
<div className="absolute bottom-4 z-10 flex items-center gap-4 text-sm text-white/50">
<button
type="button"
<KeyHint
keys="`"
prefix="Release"
onClick={() => requestIntent('stop')}
className="cursor-pointer rounded transition-colors hover:text-white/80"
title="Finish recording (or release `)"
>
Release{' '}
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs">
`
</kbd>{' '}
to review
</button>
<button
type="button"
</KeyHint>
<KeyHint
keys={['Esc', 'Q']}
separator="or"
onClick={() => requestIntent('cancel')}
className="cursor-pointer rounded transition-colors hover:text-white/80"
title="Discard recording (or press Esc / Q)"
>
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs">
Esc
</kbd>
{' or '}
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs">
Q
</kbd>{' '}
to cancel
</button>
</KeyHint>
</div>
)}
@@ -250,32 +237,21 @@ export function RecordingOverlay({
</div>
)}
<div className="flex items-center gap-4 text-sm text-white/50">
<button
type="button"
<KeyHint
keys="Enter"
onClick={() => requestIntent('send')}
className="cursor-pointer rounded transition-colors hover:text-white/80"
title="Send (or press Enter)"
>
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs">
Enter
</kbd>{' '}
next
</button>
<button
type="button"
</KeyHint>
<KeyHint
keys={['Esc', 'Q']}
separator="or"
onClick={() => requestIntent('cancel')}
className="cursor-pointer rounded transition-colors hover:text-white/80"
title="Discard (or press Esc / Q)"
>
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs">
Esc
</kbd>
{' or '}
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs">
Q
</kbd>{' '}
to cancel
</button>
</KeyHint>
<span>
<Button
variant="ghost"
+17 -15
View File
@@ -6,6 +6,7 @@ import { useAllLinkMetadata } from '@/hooks/use-link-metadata';
import { AttachmentStrip } from '@/features/compose/attachment-strip';
import type { PendingAttachment } from '@/features/compose/attachment-strip';
import { Button } from '@/components/ui/button';
import { KeyHint } from '@/components/key-hint';
import { MarkdownEditor } from '@/features/compose/markdown-editor';
export interface TextEditorAttachmentProps {
@@ -111,25 +112,26 @@ export function TextEditor({
const keyboardHints = (
<div className="absolute bottom-4 flex items-center gap-4 text-sm text-white/50">
<span>
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs">
Esc
</kbd>{' '}
<KeyHint keys="Esc" onClick={onCancel} title="Cancel (or press Esc)">
cancel
</span>
<span>
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs">
{metaKey}+Enter
</kbd>{' '}
</KeyHint>
<KeyHint
keys={`${metaKey}+Enter`}
onClick={() => {
if (textContent.trim()) onSubmit();
}}
title={`Submit (or press ${metaKey}+Enter)`}
>
{submitHint}
</span>
</KeyHint>
{immersive && (
<span>
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs">
{metaKey}+M
</kbd>{' '}
<KeyHint
keys={`${metaKey}+M`}
onClick={() => setForceCardMode(true)}
title={`Switch to markdown editor (or press ${metaKey}+M)`}
>
markdown
</span>
</KeyHint>
)}
{attachmentProps && (
<span>
@@ -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';
+9 -29
View File
@@ -4,6 +4,7 @@ import { CircleDot, CircleCheckBig } from 'lucide-react';
import { particlePath } from '@/lib/particle-path';
import { ParticleListView } from '@/features/particles/particle-list-view';
import { VideoAudioToggle } from '@/components/video-audio-toggle';
import { KeyHint } from '@/components/key-hint';
import { ComposeOverlay } from './compose/compose-overlay';
import { useComposeIntentStore } from '@/stores/compose-intent-store';
import { ComposeQuotaIndicator } from './compose/compose-quota-indicator';
@@ -103,44 +104,23 @@ function NetworkRootControls() {
const requestIntent = useComposeIntentStore((s) => s.request);
return (
<div className="flex items-center gap-4 text-sm text-white/50">
<span>
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs">
</kbd>{' '}
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs">
Enter
</kbd>{' '}
navigate
</span>
<span>
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs">
19
</kbd>{' '}
jump
</span>
<KeyHint keys={['↑↓', 'Enter']}>navigate</KeyHint>
<KeyHint keys="19">jump</KeyHint>
<VideoAudioToggle />
<button
type="button"
<KeyHint
keys="Hold `"
onClick={() => requestIntent('record')}
className="cursor-pointer rounded transition-colors hover:text-white/80"
title="Start recording (or hold `)"
>
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs">
Hold `
</kbd>{' '}
to start
</button>
<button
type="button"
</KeyHint>
<KeyHint
keys="T"
onClick={() => requestIntent('text')}
className="cursor-pointer rounded transition-colors hover:text-white/80"
title="Compose text (or press T)"
>
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs">
T
</kbd>{' '}
text
</button>
</KeyHint>
</div>
);
}
+1 -1
View File
@@ -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`);
},
});
+170 -163
View File
@@ -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 (
<form onSubmit={handleSubmit} className="flex items-center gap-2 px-4 py-3">
<Input
type="email"
placeholder="[email protected]"
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,
@@ -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 (
<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="mb-3 flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="flex items-center gap-2">
<h2 className="text-sm font-semibold tracking-tight">{title}</h2>
{trailing}
<h2 className="text-base font-semibold tracking-tight">{title}</h2>
{count != null && (
<Badge variant="secondary" className="tabular-nums">
{count}
</Badge>
)}
</div>
{description && <Muted className="text-xs">{description}</Muted>}
{description && <Muted className="mt-0.5 text-xs">{description}</Muted>}
</div>
{action}
</div>
);
}
function Section({ children }: { children: React.ReactNode }) {
function Panel({ children }: { children: React.ReactNode }) {
return (
<section className="bg-card/40 mx-4 mb-2 overflow-hidden rounded-lg border">
<section className="bg-card/40 overflow-hidden rounded-lg border">
{children}
</section>
);
@@ -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<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 billingRef = useRef<HTMLDivElement>(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() {
<div className="flex-1" />
</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>
<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>
</div>
</div>
<TabsList variant="line" className="w-full gap-1">
<TabsTrigger value="members">
<Users />
Members
</TabsTrigger>
<TabsTrigger value="billing">
<CreditCard />
Plan & Billing
</TabsTrigger>
</TabsList>
</aside>
<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>
<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>
) : 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}
<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
}
/>
{index < invitations.length - 1 && (
{index < network.humans.length - 1 && (
<Separator className="mx-4" />
)}
</div>
))}
</>
)}
</Section>
)}
);
})}
</Panel>
<div ref={billingRef}>
<Section>
<SectionHeader
icon={<CreditCard className="size-4" />}
title="Billing"
{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>
<TabsContent value="billing" className="p-4">
<SectionHeading
title="Plan & Billing"
description={
isAdmin
? 'Manage your plan, seats, and payment.'
: "Your network's current plan and usage."
}
/>
<Separator />
<BillingSection networkId={networkId} />
</Section>
</div>
<Panel>
<BillingSection networkId={networkId} />
</Panel>
</TabsContent>
</ScrollArea>
</Tabs>
<div className="h-6" />
</ScrollArea>
{isAdmin && (
<AddMembersDialog
networkId={networkId}
open={addOpen}
onOpenChange={setAddOpen}
/>
)}
{memberToRemove && (
<ConfirmDestructiveOverlay
@@ -0,0 +1,179 @@
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 ? '[email protected]' : '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 { 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<HTMLAudioElement | null>(null);
const audioSource = useAudioSource(audioEl);
@@ -156,7 +156,10 @@ export const MediaParticleView = forwardRef<
{audioSource && (
<div className="z-10 absolute bottom-20">
<AudioLevelBars sourceNode={audioSource.sourceNode} />
<CenteredWaveform
sourceNode={audioSource.sourceNode}
className="text-white"
/>
</div>
)}
@@ -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({
<div className="absolute left-1/2 top-1/2 w-full max-w-sm -translate-x-1/2 -translate-y-1/2 rounded-2xl border border-white/10 bg-white/5 p-6 shadow-2xl backdrop-blur-xl">
<div className="mb-4 flex items-center justify-between">
<h2 className="text-sm font-semibold text-white/70">Rename stream</h2>
<span className="text-xs text-white/30">
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs">
Esc
</kbd>{' '}
<KeyHint
keys="Esc"
onClick={onClose}
title="Close (or press Esc)"
className="text-xs text-white/30"
>
to close
</span>
</KeyHint>
</div>
<Input
@@ -3,6 +3,7 @@ import { createPortal } from 'react-dom';
import { X, UserPlus, Globe, Users, Lock } from 'lucide-react';
import { Avatar, AvatarFallback } from '@/components/ui/avatar';
import { ScrollArea } from '@/components/ui/scroll-area';
import { KeyHint } from '@/components/key-hint';
import {
buildCustomVisibility,
buildNetworkVisibility,
@@ -104,12 +105,14 @@ export function StreamMembersOverlay({
{/* Header */}
<div className="mb-4 flex items-center justify-between">
<h2 className="text-sm font-semibold text-white/70">Members</h2>
<span className="text-xs text-white/30">
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs">
Esc
</kbd>{' '}
<KeyHint
keys="Esc"
onClick={onClose}
title="Close (or press Esc)"
className="text-xs text-white/30"
>
to close
</span>
</KeyHint>
</div>
{/* Visibility */}
@@ -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,
@@ -259,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(() => {
@@ -325,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.
@@ -362,6 +364,8 @@ function StreamViewInner({ path, streamParticle }: StreamViewProps) {
<StreamViewControls
showEscape
onOpenKeybindings={() => setShowKeybindings(true)}
onOpenHuddle={handleOpenHuddle}
onExit={handleExitNavigate}
/>
<ComposeOverlay
networkId={networkId}
@@ -505,6 +509,8 @@ function StreamViewInner({ path, streamParticle }: StreamViewProps) {
onlineHumanIds={onlineHumanIds}
exitRemainingMs={exitRemainingMs}
onOpenKeybindings={() => setShowKeybindings(true)}
onOpenHuddle={handleOpenHuddle}
onExit={handleExitNavigate}
/>
<KeybindingsOverlay
@@ -527,6 +533,8 @@ function BottomBar({
onlineHumanIds,
exitRemainingMs,
onOpenKeybindings,
onOpenHuddle,
onExit,
}: {
visible: boolean;
total: number;
@@ -540,6 +548,8 @@ function BottomBar({
onlineHumanIds: Set<string>;
exitRemainingMs: number | null;
onOpenKeybindings: () => void;
onOpenHuddle: () => void;
onExit: () => void;
}) {
return (
<div
@@ -580,6 +590,8 @@ function BottomBar({
<StreamViewControls
showEscape
onOpenKeybindings={onOpenKeybindings}
onOpenHuddle={onOpenHuddle}
onExit={onExit}
/>
</div>
</div>
@@ -590,58 +602,54 @@ function BottomBar({
function StreamViewControls({
showEscape,
onOpenKeybindings,
onOpenHuddle,
onExit,
}: {
showEscape?: boolean;
onOpenKeybindings: () => void;
onOpenHuddle: () => void;
onExit: () => void;
}) {
const requestIntent = useComposeIntentStore((s) => s.request);
return (
<div className="flex items-center gap-4 text-sm text-white/50">
{showEscape && (
<span>
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs">
Esc
</kbd>{' '}
<KeyHint
keys="Esc"
onClick={onExit}
title="Back to network (or press Esc)"
>
back
</span>
</KeyHint>
)}
<VideoAudioToggle />
<button
type="button"
<KeyHint
keys="Hold `"
onClick={() => requestIntent('record')}
className="cursor-pointer rounded transition-colors hover:text-white/80"
title="Reply with a recording (or hold `)"
>
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs">
Hold `
</kbd>{' '}
to reply
</button>
<button
type="button"
</KeyHint>
<KeyHint
keys="T"
onClick={() => requestIntent('text')}
className="cursor-pointer rounded transition-colors hover:text-white/80"
title="Reply with text (or press T)"
>
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs">
T
</kbd>{' '}
text
</button>
<span>
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs">
H
</kbd>{' '}
huddle
</span>
<kbd
role="button"
onClick={onOpenKeybindings}
className="cursor-pointer rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs transition-colors hover:text-white/80"
title="Show all shortcuts"
</KeyHint>
<KeyHint
keys="H"
onClick={onOpenHuddle}
title="Start a huddle (or press H)"
>
?
</kbd>
huddle
</KeyHint>
<KeyHint
keys="?"
onClick={onOpenKeybindings}
title="Show all shortcuts"
aria-label="Show keyboard shortcuts"
/>
</div>
);
}
@@ -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 (
<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 <div className="bg-muted h-3 w-20 rounded-sm opacity-50" />;
}
return (
<div className="flex h-3 items-end">
<div className="scale-[0.55] origin-right">
<AudioLevelBars sourceNode={audioSource.sourceNode} />
</div>
</div>
<CenteredWaveform
sourceNode={audioSource.sourceNode}
width={80}
height={12}
barWidth={2}
gap={3}
className="text-primary"
/>
);
}
@@ -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<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];
}
@@ -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<MediaParticleHandle | null>;
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]);
}