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>coderabbitai[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 PatelGitHubCodeRabbitcoderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
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: on:
pull_request: pull_request:
paths: paths:
- "js/mobile/**"
- "js/desktop/**" - "js/desktop/**"
branches: [ main ] branches: [ main ]
types: [ opened, synchronize, reopened ] types: [ opened, synchronize, reopened ]
jobs: jobs:
mobile: 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
desktop:
name: Lint & format check (desktop) name: Lint & format check (desktop)
runs-on: ubuntu-latest runs-on: ubuntu-latest
defaults: 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 # Project Rules
## Architecture ## Architecture
- Electron typescript/react app is in `js/` folder - Electron typescript/react app is in `js/desktop` folder
- Orion is the api server which lives in the `go/` 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 - `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. Whenever implementing anything, make sure to take into account best practices without over-engineering.
## Electron App ## Electron App
### Quality ### 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. 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. 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 ### Package Manager
- Use **yarn** (not npm) for all dependency management - 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. 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. 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 { useEffect } from 'react';
import { createPortal } from 'react-dom'; import { createPortal } from 'react-dom';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { KeyHint } from '@/components/key-hint';
interface ConfirmDestructiveOverlayProps { interface ConfirmDestructiveOverlayProps {
title: string; 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="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"> <div className="mb-3 flex items-center justify-between">
<h2 className="text-sm font-semibold text-white/70">{title}</h2> <h2 className="text-sm font-semibold text-white/70">{title}</h2>
<span className="text-xs text-white/30"> <KeyHint
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs"> keys="Esc"
Esc onClick={onClose}
</kbd>{' '} title="Close (or press Esc)"
className="text-xs text-white/30"
>
to close to close
</span> </KeyHint>
</div> </div>
<div className="text-sm text-white/60">{description}</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 { useSuspendPlayback } from '@/hooks/use-suspend-playback';
import { useEffect } from 'react'; import { useEffect } from 'react';
import { createPortal } from 'react-dom'; import { createPortal } from 'react-dom';
import { KeyHint } from '@/components/key-hint';
export interface KeybindingEntry { export interface KeybindingEntry {
keys: string[]; 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="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"> <div className="mb-5 flex items-center justify-between">
<h2 className="text-sm font-semibold text-white/70">{title}</h2> <h2 className="text-sm font-semibold text-white/70">{title}</h2>
<span className="text-xs text-white/30"> <KeyHint
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs"> keys={['Esc', '?']}
Esc separator="or"
</kbd>{' '} onClick={onClose}
or{' '} title="Close (or press Esc / ?)"
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs"> className="text-xs text-white/30"
? >
</kbd>{' '}
to close to close
</span> </KeyHint>
</div> </div>
<div className="flex flex-col gap-5"> <div className="flex flex-col gap-5">
{groups.map((group) => ( {groups.map((group) => (
@@ -80,16 +80,10 @@ export function KeybindingsOverlay({
<span className="text-sm text-white/70"> <span className="text-sm text-white/70">
{binding.description} {binding.description}
</span> </span>
<span className="flex items-center gap-1"> <KeyHint
{binding.keys.map((k) => ( keys={binding.keys}
<kbd className="flex items-center gap-1 text-white/60"
key={k} />
className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs text-white/60"
>
{k}
</kbd>
))}
</span>
</li> </li>
))} ))}
</ul> </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 { Video, Mic } from 'lucide-react';
import { KeyHint } from '@/components/key-hint';
import { useMediaSettingsStore } from '@/stores/media-settings-store'; import { useMediaSettingsStore } from '@/stores/media-settings-store';
export function VideoAudioToggle() { export function VideoAudioToggle() {
@@ -6,8 +7,8 @@ export function VideoAudioToggle() {
const setRecordingMode = useMediaSettingsStore((s) => s.setRecordingMode); const setRecordingMode = useMediaSettingsStore((s) => s.setRecordingMode);
return ( return (
<span <KeyHint
role="button" keys="V"
onClick={() => onClick={() =>
setRecordingMode(recordingMode === 'video' ? 'audio' : 'video') setRecordingMode(recordingMode === 'video' ? 'audio' : 'video')
} }
@@ -16,11 +17,7 @@ export function VideoAudioToggle() {
? 'Switch to audio-only (V)' ? 'Switch to audio-only (V)'
: 'Switch to video (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' ? ( {recordingMode === 'video' ? (
<> <>
<Video className="inline size-3" /> video <Video className="inline size-3" /> video
@@ -30,6 +27,6 @@ export function VideoAudioToggle() {
<Mic className="inline size-3" /> audio <Mic className="inline size-3" /> audio
</> </>
)} )}
</span> </KeyHint>
); );
} }
@@ -12,6 +12,7 @@ import {
import { useDownloadUrl } from '@/hooks/use-download-url'; import { useDownloadUrl } from '@/hooks/use-download-url';
import { useObjectUrl } from '@/hooks/use-object-url'; import { useObjectUrl } from '@/hooks/use-object-url';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { KeyHint } from '@/components/key-hint';
import { useSuspendPlayback } from '@/hooks/use-suspend-playback'; import { useSuspendPlayback } from '@/hooks/use-suspend-playback';
import { platform } from '@/lib/platform'; 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"> <div className="absolute bottom-4 left-1/2 flex -translate-x-1/2 items-center gap-4 text-xs text-white/50">
{hasMultiple && ( {hasMultiple && (
<span className="flex items-center gap-1.5"> <span className="flex items-center gap-1.5">
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs"> <KeyHint
keys="←"
</kbd> onClick={() => goTo(-1)}
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs"> title="Previous (or press ←)"
aria-label="Previous attachment"
</kbd> />
<KeyHint
keys="→"
onClick={() => goTo(1)}
title="Next (or press →)"
aria-label="Next attachment"
/>
navigate navigate
</span> </span>
)} )}
{canDownload && ( {canDownload && (
<span className="flex items-center gap-1.5"> <KeyHint
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs"> keys="D"
D onClick={handleDownload}
</kbd> title="Download (or press D)"
>
download download
</span> </KeyHint>
)} )}
{onRemove && ( {onRemove && (
<span className="flex items-center gap-1.5"> <KeyHint
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs"> keys="⌫"
onClick={handleRemove}
</kbd> title="Remove (or press Backspace)"
>
remove remove
</span> </KeyHint>
)} )}
<span className="flex items-center gap-1.5"> <KeyHint
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs"> keys="Esc"
Esc onClick={() => onOpenChange(null)}
</kbd> title="Close (or press Esc)"
>
close close
</span> </KeyHint>
</div> </div>
</> </>
)} )}
@@ -17,6 +17,7 @@ import { particlePath, parseParticlePath } from '@/lib/particle-path';
import type { ParticlePath } from '@/lib/particle-path'; import type { ParticlePath } from '@/lib/particle-path';
import { RecordingOverlay } from '@/features/compose/recording-overlay'; import { RecordingOverlay } from '@/features/compose/recording-overlay';
import { ScreenSourcePicker } from '@/components/screen-source-picker'; import { ScreenSourcePicker } from '@/components/screen-source-picker';
import { KeyHint } from '@/components/key-hint';
import { TextComposeStep } from '@/features/compose/text-compose-step'; import { TextComposeStep } from '@/features/compose/text-compose-step';
import { ConfigureStreamStep } from '@/features/compose/configure-stream-step'; import { ConfigureStreamStep } from '@/features/compose/configure-stream-step';
import { apiClient } from '@/api/client'; import { apiClient } from '@/api/client';
@@ -726,28 +727,20 @@ export function ComposeOverlay({
</div> </div>
</div> </div>
<div className="absolute bottom-4 z-10 flex items-center gap-4 text-sm text-white/50"> <div className="absolute bottom-4 z-10 flex items-center gap-4 text-sm text-white/50">
<button <KeyHint
type="button" keys="S"
onClick={handleStopIntent} onClick={handleStopIntent}
className="cursor-pointer rounded transition-colors hover:text-white/80"
title="Stop screen recording (or press S)" 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 stop
</button> </KeyHint>
<button <KeyHint
type="button" keys="Q"
onClick={handleCancelIntent} onClick={handleCancelIntent}
className="cursor-pointer rounded transition-colors hover:text-white/80"
title="Cancel screen recording (or press Q)" 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 cancel
</button> </KeyHint>
</div> </div>
</div> </div>
)} )}
@@ -5,6 +5,7 @@ import { metaKey } from '@/lib/platform';
import { useAuthStore } from '@/stores/auth-store'; import { useAuthStore } from '@/stores/auth-store';
import { generateRandomName } from '@/lib/random-name'; import { generateRandomName } from '@/lib/random-name';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { KeyHint } from '@/components/key-hint';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
import { Checkbox } from '@/components/ui/checkbox'; import { Checkbox } from '@/components/ui/checkbox';
import { ScrollArea } from '@/components/ui/scroll-area'; import { ScrollArea } from '@/components/ui/scroll-area';
@@ -158,18 +159,16 @@ export function ConfigureStreamStep({
{/* Keyboard hints */} {/* Keyboard hints */}
<div className="absolute bottom-8 left-0 right-0 flex items-center justify-center gap-4 text-sm text-white/50"> <div className="absolute bottom-8 left-0 right-0 flex items-center justify-center gap-4 text-sm text-white/50">
<span> <KeyHint keys="Esc" onClick={onCancel} title="Cancel (or press Esc)">
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs">
Esc
</kbd>{' '}
cancel cancel
</span> </KeyHint>
<span> <KeyHint
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs"> keys={`${metaKey}+Enter`}
{metaKey}+Enter onClick={handleSubmit}
</kbd>{' '} title={`Create stream (or press ${metaKey}+Enter)`}
>
create create
</span> </KeyHint>
</div> </div>
</div> </div>
); );
@@ -1,13 +1,14 @@
import { useEffect, useRef, useState } from 'react'; import { useEffect, useRef, useState } from 'react';
import { Paperclip } from 'lucide-react'; import { Paperclip } from 'lucide-react';
import type { RecordingMode } from '@/hooks/use-recording-mode'; import type { RecordingMode } from '@/stores/media-settings-store';
import { AudioLevelBars } from '@/components/audio/audio-level-bars'; import { CenteredWaveform } from '@/components/audio/centered-waveform';
import { useAudioSource } from '@/components/audio/use-audio-source'; import { useAudioSource } from '@/components/audio/use-audio-source';
import { useObjectUrl } from '@/hooks/use-object-url'; import { useObjectUrl } from '@/hooks/use-object-url';
import { AttachmentStrip } from '@/features/compose/attachment-strip'; import { AttachmentStrip } from '@/features/compose/attachment-strip';
import type { PendingAttachment } from '@/features/compose/attachment-strip'; import type { PendingAttachment } from '@/features/compose/attachment-strip';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { KeyHint } from '@/components/key-hint';
import { useComposeIntentStore } from '@/stores/compose-intent-store'; import { useComposeIntentStore } from '@/stores/compose-intent-store';
interface RecordingOverlayProps { interface RecordingOverlayProps {
@@ -67,7 +68,6 @@ function ReviewPlayback({
objectFit?: 'cover' | 'contain'; objectFit?: 'cover' | 'contain';
}) { }) {
const objectUrl = useObjectUrl(blob); const objectUrl = useObjectUrl(blob);
const audioElRef = useRef<HTMLAudioElement | null>(null);
const [audioEl, setAudioEl] = useState<HTMLAudioElement | null>(null); const [audioEl, setAudioEl] = useState<HTMLAudioElement | null>(null);
const audioSource = useAudioSource(isVideo ? null : audioEl); const audioSource = useAudioSource(isVideo ? null : audioEl);
@@ -87,17 +87,12 @@ function ReviewPlayback({
return ( return (
<div className="flex flex-col items-center gap-3"> <div className="flex flex-col items-center gap-3">
<audio <audio ref={setAudioEl} src={objectUrl} autoPlay loop />
ref={(el) => {
audioElRef.current = el;
setAudioEl(el);
}}
src={objectUrl}
autoPlay
loop
/>
{audioSource ? ( {audioSource ? (
<AudioLevelBars sourceNode={audioSource.sourceNode} /> <CenteredWaveform
sourceNode={audioSource.sourceNode}
className="text-white"
/>
) : ( ) : (
<span className="text-sm text-white/60">Playing back audio...</span> <span className="text-sm text-white/60">Playing back audio...</span>
)} )}
@@ -198,43 +193,35 @@ export function RecordingOverlay({
) : null} ) : null}
</div> </div>
{/* Bottom center: audio level bars (recording with active stream) */} {/* Bottom center: live waveform (recording with active stream) */}
{isRecording && recordingAudioSource && ( {isRecording && recordingAudioSource && (
<div className="z-10 absolute bottom-15"> <div className="z-10 absolute bottom-15">
<AudioLevelBars sourceNode={recordingAudioSource.sourceNode} /> <CenteredWaveform
sourceNode={recordingAudioSource.sourceNode}
className="text-white"
/>
</div> </div>
)} )}
{/* Bottom center: keyboard hints */} {/* Bottom center: keyboard hints */}
{isRecording && !isLoading && ( {isRecording && !isLoading && (
<div className="absolute bottom-4 z-10 flex items-center gap-4 text-sm text-white/50"> <div className="absolute bottom-4 z-10 flex items-center gap-4 text-sm text-white/50">
<button <KeyHint
type="button" keys="`"
prefix="Release"
onClick={() => requestIntent('stop')} onClick={() => requestIntent('stop')}
className="cursor-pointer rounded transition-colors hover:text-white/80"
title="Finish recording (or release `)" 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 to review
</button> </KeyHint>
<button <KeyHint
type="button" keys={['Esc', 'Q']}
separator="or"
onClick={() => requestIntent('cancel')} onClick={() => requestIntent('cancel')}
className="cursor-pointer rounded transition-colors hover:text-white/80"
title="Discard recording (or press Esc / Q)" 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 to cancel
</button> </KeyHint>
</div> </div>
)} )}
@@ -250,32 +237,21 @@ export function RecordingOverlay({
</div> </div>
)} )}
<div className="flex items-center gap-4 text-sm text-white/50"> <div className="flex items-center gap-4 text-sm text-white/50">
<button <KeyHint
type="button" keys="Enter"
onClick={() => requestIntent('send')} onClick={() => requestIntent('send')}
className="cursor-pointer rounded transition-colors hover:text-white/80"
title="Send (or press Enter)" title="Send (or press Enter)"
> >
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs">
Enter
</kbd>{' '}
next next
</button> </KeyHint>
<button <KeyHint
type="button" keys={['Esc', 'Q']}
separator="or"
onClick={() => requestIntent('cancel')} onClick={() => requestIntent('cancel')}
className="cursor-pointer rounded transition-colors hover:text-white/80"
title="Discard (or press Esc / Q)" 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 to cancel
</button> </KeyHint>
<span> <span>
<Button <Button
variant="ghost" 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 { AttachmentStrip } from '@/features/compose/attachment-strip';
import type { PendingAttachment } from '@/features/compose/attachment-strip'; import type { PendingAttachment } from '@/features/compose/attachment-strip';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { KeyHint } from '@/components/key-hint';
import { MarkdownEditor } from '@/features/compose/markdown-editor'; import { MarkdownEditor } from '@/features/compose/markdown-editor';
export interface TextEditorAttachmentProps { export interface TextEditorAttachmentProps {
@@ -111,25 +112,26 @@ export function TextEditor({
const keyboardHints = ( const keyboardHints = (
<div className="absolute bottom-4 flex items-center gap-4 text-sm text-white/50"> <div className="absolute bottom-4 flex items-center gap-4 text-sm text-white/50">
<span> <KeyHint keys="Esc" onClick={onCancel} title="Cancel (or press Esc)">
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs">
Esc
</kbd>{' '}
cancel cancel
</span> </KeyHint>
<span> <KeyHint
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs"> keys={`${metaKey}+Enter`}
{metaKey}+Enter onClick={() => {
</kbd>{' '} if (textContent.trim()) onSubmit();
}}
title={`Submit (or press ${metaKey}+Enter)`}
>
{submitHint} {submitHint}
</span> </KeyHint>
{immersive && ( {immersive && (
<span> <KeyHint
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs"> keys={`${metaKey}+M`}
{metaKey}+M onClick={() => setForceCardMode(true)}
</kbd>{' '} title={`Switch to markdown editor (or press ${metaKey}+M)`}
>
markdown markdown
</span> </KeyHint>
)} )}
{attachmentProps && ( {attachmentProps && (
<span> <span>
@@ -1,5 +1,5 @@
import { useCallback, useEffect, useRef } from 'react'; 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_PREFERRED_MIME = 'video/webm;codecs=vp9,opus';
const VIDEO_FALLBACK_MIME = 'video/webm'; 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 { particlePath } from '@/lib/particle-path';
import { ParticleListView } from '@/features/particles/particle-list-view'; import { ParticleListView } from '@/features/particles/particle-list-view';
import { VideoAudioToggle } from '@/components/video-audio-toggle'; import { VideoAudioToggle } from '@/components/video-audio-toggle';
import { KeyHint } from '@/components/key-hint';
import { ComposeOverlay } from './compose/compose-overlay'; import { ComposeOverlay } from './compose/compose-overlay';
import { useComposeIntentStore } from '@/stores/compose-intent-store'; import { useComposeIntentStore } from '@/stores/compose-intent-store';
import { ComposeQuotaIndicator } from './compose/compose-quota-indicator'; import { ComposeQuotaIndicator } from './compose/compose-quota-indicator';
@@ -103,44 +104,23 @@ function NetworkRootControls() {
const requestIntent = useComposeIntentStore((s) => s.request); const requestIntent = useComposeIntentStore((s) => s.request);
return ( return (
<div className="flex items-center gap-4 text-sm text-white/50"> <div className="flex items-center gap-4 text-sm text-white/50">
<span> <KeyHint keys={['↑↓', 'Enter']}>navigate</KeyHint>
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs"> <KeyHint keys="19">jump</KeyHint>
</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>
<VideoAudioToggle /> <VideoAudioToggle />
<button <KeyHint
type="button" keys="Hold `"
onClick={() => requestIntent('record')} onClick={() => requestIntent('record')}
className="cursor-pointer rounded transition-colors hover:text-white/80"
title="Start recording (or hold `)" 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 to start
</button> </KeyHint>
<button <KeyHint
type="button" keys="T"
onClick={() => requestIntent('text')} onClick={() => requestIntent('text')}
className="cursor-pointer rounded transition-colors hover:text-white/80"
title="Compose text (or press T)" 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 text
</button> </KeyHint>
</div> </div>
); );
} }
+1 -1
View File
@@ -137,7 +137,7 @@ function CreateNetworkDialog({
toast.success(`Created ${network.name}`); toast.success(`Created ${network.name}`);
onOpenChange(false); onOpenChange(false);
setName(''); 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 { 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 { toast } from 'sonner';
import { Avatar, AvatarFallback } from '@/components/ui/avatar'; import { Avatar, AvatarFallback } from '@/components/ui/avatar';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { ScrollArea } from '@/components/ui/scroll-area'; import { ScrollArea } from '@/components/ui/scroll-area';
import { Separator } from '@/components/ui/separator'; import { Separator } from '@/components/ui/separator';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Muted } from '@/components/ui/typography'; import { Muted } from '@/components/ui/typography';
import { WindowControls } from '@/components/window-controls'; import { WindowControls } from '@/components/window-controls';
import { useNetworks } from '@/hooks/use-networks'; import { useNetworks } from '@/hooks/use-networks';
import { import {
useNetworkInvitations, useNetworkInvitations,
useInviteMembers,
useRevokeInvitation, useRevokeInvitation,
useRemoveMember, useRemoveMember,
} from '@/hooks/use-member-management'; } from '@/hooks/use-member-management';
import { useAuthStore } from '@/stores/auth-store'; import { useAuthStore } from '@/stores/auth-store';
import { BillingSection } from '@/features/network-billing'; import { BillingSection } from '@/features/network-billing';
import { AddMembersDialog } from '@/features/network-settings/add-members-dialog';
import { ConfirmDestructiveOverlay } from '@/components/confirm-destructive-overlay'; import { ConfirmDestructiveOverlay } from '@/components/confirm-destructive-overlay';
import type { Human } from '@/api/types'; import type { Human } from '@/api/types';
type Section = 'members' | 'billing';
function MemberRow({ function MemberRow({
human, human,
isAdmin, 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({ function PendingInvitationRow({
email, email,
networkId, networkId,
@@ -141,36 +114,38 @@ function PendingInvitationRow({
); );
} }
function SectionHeader({ function SectionHeading({
icon,
title, title,
description, description,
trailing, count,
action,
}: { }: {
icon: React.ReactNode;
title: string; title: string;
description?: string; description?: string;
trailing?: React.ReactNode; count?: number;
action?: React.ReactNode;
}) { }) {
return ( return (
<div className="flex items-start gap-3 px-4 pb-2 pt-6"> <div className="mb-3 flex items-start justify-between gap-3">
<span className="text-muted-foreground mt-0.5 flex size-4 items-center justify-center"> <div className="min-w-0">
{icon}
</span>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<h2 className="text-sm font-semibold tracking-tight">{title}</h2> <h2 className="text-base font-semibold tracking-tight">{title}</h2>
{trailing} {count != null && (
<Badge variant="secondary" className="tabular-nums">
{count}
</Badge>
)}
</div> </div>
{description && <Muted className="text-xs">{description}</Muted>} {description && <Muted className="mt-0.5 text-xs">{description}</Muted>}
</div> </div>
{action}
</div> </div>
); );
} }
function Section({ children }: { children: React.ReactNode }) { function Panel({ children }: { children: React.ReactNode }) {
return ( 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} {children}
</section> </section>
); );
@@ -181,7 +156,7 @@ export default function NetworkSettingsPage() {
const { networkId } = useParams<{ networkId: string }>(); const { networkId } = useParams<{ networkId: string }>();
if (!networkId) if (!networkId)
throw new Error('NetworkSettingsPage requires a :networkId route param'); throw new Error('NetworkSettingsPage requires a :networkId route param');
const [searchParams] = useSearchParams(); const [searchParams, setSearchParams] = useSearchParams();
const { data: networks } = useNetworks(); const { data: networks } = useNetworks();
const network = networks?.find((n) => n.id === networkId); const network = networks?.find((n) => n.id === networkId);
const { data: invitations, error: invitationsError } = const { data: invitations, error: invitationsError } =
@@ -189,18 +164,35 @@ export default function NetworkSettingsPage() {
const currentUser = useAuthStore((s) => s.user); const currentUser = useAuthStore((s) => s.user);
const isAdmin = currentUser?.id === network?.admin_human.id; const isAdmin = currentUser?.id === network?.admin_human.id;
const [memberToRemove, setMemberToRemove] = useState<Human | null>(null); 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 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(() => { useEffect(() => {
if (searchParams.get('section') === 'billing') { if (searchParams.get('add') !== '1') return;
billingRef.current?.scrollIntoView({ setSearchParams(
behavior: 'smooth', (prev) => {
block: 'start', const next = new URLSearchParams(prev);
}); next.delete('add');
} return next;
}, [searchParams]); },
{ replace: true },
);
}, [setSearchParams, searchParams]);
const networkName = network?.name ?? 'Network'; const networkName = network?.name ?? 'Network';
const memberCount = network?.humans.length ?? 0; const memberCount = network?.humans.length ?? 0;
@@ -223,121 +215,136 @@ export default function NetworkSettingsPage() {
<div className="flex-1" /> <div className="flex-1" />
</div> </div>
<ScrollArea className="min-h-0 flex-1"> <Tabs
<div className="flex items-center gap-3 px-4 pb-4 pt-6"> value={section}
<Avatar size="lg"> onValueChange={setSection}
<AvatarFallback className="bg-primary/10 text-primary font-medium"> orientation="vertical"
{networkInitials} className="min-h-0 flex-1 gap-0"
</AvatarFallback> >
</Avatar> <aside className="flex w-52 shrink-0 flex-col gap-4 border-r p-3">
<div className="min-w-0 flex-1"> <div className="flex items-center gap-3 px-1 pt-1">
<p className="truncate text-base font-semibold">{networkName}</p> <Avatar>
<Muted className="text-xs"> <AvatarFallback className="bg-primary/10 text-primary font-medium">
{memberCount} {memberCount === 1 ? 'member' : 'members'} {networkInitials}
{isAdmin ? " · You're an admin" : ''} </AvatarFallback>
</Muted> </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>
</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> <ScrollArea className="min-h-0 flex-1">
<SectionHeader <TabsContent value="members" className="p-4">
icon={<Users className="size-4" />} <SectionHeading
title="Members" title="Members"
description="People with access to this network." description="People with access to this network."
trailing={ count={memberCount}
<Badge variant="secondary" className="tabular-nums"> action={
{memberCount} isAdmin ? (
</Badge> <Button
} size="sm"
/> className="shrink-0"
<Separator /> onClick={() => setAddOpen(true)}
{network?.humans.map((human, index) => { >
const isRowAdmin = human.id === network.admin_human.id; <UserPlus className="mr-1 size-3.5" />
const canRemove = Add members
isAdmin && !isRowAdmin && human.id !== currentUser?.id; </Button>
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 ) : undefined
} }
/> />
<Separator /> <Panel>
<InviteForm networkId={networkId} /> {network?.humans.map((human, index) => {
{invitationsError && ( const isRowAdmin = human.id === network.admin_human.id;
<> const canRemove =
<Separator /> isAdmin && !isRowAdmin && human.id !== currentUser?.id;
<p className="text-muted-foreground px-4 py-3 text-xs"> return (
Couldn't load pending invitations. <div key={human.id}>
</p> <MemberRow
</> human={human}
)} isAdmin={isRowAdmin}
{invitations && invitations.length > 0 && ( onRemove={
<> canRemove ? () => setMemberToRemove(human) : undefined
<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 < invitations.length - 1 && ( {index < network.humans.length - 1 && (
<Separator className="mx-4" /> <Separator className="mx-4" />
)} )}
</div> </div>
))} );
</> })}
)} </Panel>
</Section>
)}
<div ref={billingRef}> {isAdmin && (
<Section> <div className="mt-6">
<SectionHeader <SectionHeading
icon={<CreditCard className="size-4" />} title="Pending invitations"
title="Billing" 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={ description={
isAdmin isAdmin
? 'Manage your plan, seats, and payment.' ? 'Manage your plan, seats, and payment.'
: "Your network's current plan and usage." : "Your network's current plan and usage."
} }
/> />
<Separator /> <Panel>
<BillingSection networkId={networkId} /> <BillingSection networkId={networkId} />
</Section> </Panel>
</div> </TabsContent>
</ScrollArea>
</Tabs>
<div className="h-6" /> {isAdmin && (
</ScrollArea> <AddMembersDialog
networkId={networkId}
open={addOpen}
onOpenChange={setAddOpen}
/>
)}
{memberToRemove && ( {memberToRemove && (
<ConfirmDestructiveOverlay <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 { useTranscriptPlayback } from '@/hooks/use-transcript-playback';
import { TranscriptOverlay } from '@/features/particles/transcript-overlay'; import { TranscriptOverlay } from '@/features/particles/transcript-overlay';
import { Skeleton } from '@/components/ui/skeleton'; 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 { useAudioSource } from '@/components/audio/use-audio-source';
import { useParticleAttachments } from '@/hooks/use-particle-attachments'; import { useParticleAttachments } from '@/hooks/use-particle-attachments';
import { ParticleAttachments } from '@/features/particles/particle-attachments'; import { ParticleAttachments } from '@/features/particles/particle-attachments';
@@ -89,7 +89,7 @@ export const MediaParticleView = forwardRef<
currentTime, 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 [audioEl, setAudioEl] = useState<HTMLAudioElement | null>(null);
const audioSource = useAudioSource(audioEl); const audioSource = useAudioSource(audioEl);
@@ -156,7 +156,10 @@ export const MediaParticleView = forwardRef<
{audioSource && ( {audioSource && (
<div className="z-10 absolute bottom-20"> <div className="z-10 absolute bottom-20">
<AudioLevelBars sourceNode={audioSource.sourceNode} /> <CenteredWaveform
sourceNode={audioSource.sourceNode}
className="text-white"
/>
</div> </div>
)} )}
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useState } from 'react';
import { createPortal } from 'react-dom'; import { createPortal } from 'react-dom';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { KeyHint } from '@/components/key-hint';
import { updateParticleProperties } from '@/lib/firestore-particles'; import { updateParticleProperties } from '@/lib/firestore-particles';
import { particlePath, toFirestoreDocPath } from '@/lib/particle-path'; import { particlePath, toFirestoreDocPath } from '@/lib/particle-path';
import type { Particle } from '@/api/types'; 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="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"> <div className="mb-4 flex items-center justify-between">
<h2 className="text-sm font-semibold text-white/70">Rename stream</h2> <h2 className="text-sm font-semibold text-white/70">Rename stream</h2>
<span className="text-xs text-white/30"> <KeyHint
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs"> keys="Esc"
Esc onClick={onClose}
</kbd>{' '} title="Close (or press Esc)"
className="text-xs text-white/30"
>
to close to close
</span> </KeyHint>
</div> </div>
<Input <Input
@@ -3,6 +3,7 @@ import { createPortal } from 'react-dom';
import { X, UserPlus, Globe, Users, Lock } from 'lucide-react'; import { X, UserPlus, Globe, Users, Lock } from 'lucide-react';
import { Avatar, AvatarFallback } from '@/components/ui/avatar'; import { Avatar, AvatarFallback } from '@/components/ui/avatar';
import { ScrollArea } from '@/components/ui/scroll-area'; import { ScrollArea } from '@/components/ui/scroll-area';
import { KeyHint } from '@/components/key-hint';
import { import {
buildCustomVisibility, buildCustomVisibility,
buildNetworkVisibility, buildNetworkVisibility,
@@ -104,12 +105,14 @@ export function StreamMembersOverlay({
{/* Header */} {/* Header */}
<div className="mb-4 flex items-center justify-between"> <div className="mb-4 flex items-center justify-between">
<h2 className="text-sm font-semibold text-white/70">Members</h2> <h2 className="text-sm font-semibold text-white/70">Members</h2>
<span className="text-xs text-white/30"> <KeyHint
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs"> keys="Esc"
Esc onClick={onClose}
</kbd>{' '} title="Close (or press Esc)"
className="text-xs text-white/30"
>
to close to close
</span> </KeyHint>
</div> </div>
{/* Visibility */} {/* Visibility */}
@@ -29,6 +29,7 @@ import { TextParticleView } from '@/features/particles/text-particle-view';
import { FallbackParticleView } from '@/features/particles/fallback-particle-view'; import { FallbackParticleView } from '@/features/particles/fallback-particle-view';
import { DeletedParticleView } from '@/features/particles/deleted-particle-view'; import { DeletedParticleView } from '@/features/particles/deleted-particle-view';
import { VideoAudioToggle } from '@/components/video-audio-toggle'; import { VideoAudioToggle } from '@/components/video-audio-toggle';
import { KeyHint } from '@/components/key-hint';
import { useMediaSettingsStore } from '@/stores/media-settings-store'; import { useMediaSettingsStore } from '@/stores/media-settings-store';
import { import {
KeybindingsOverlay, KeybindingsOverlay,
@@ -259,12 +260,17 @@ function StreamViewInner({ path, streamParticle }: StreamViewProps) {
const { fastPlayback } = usePlaybackKeys({ mediaRef }); const { fastPlayback } = usePlaybackKeys({ mediaRef });
const handleExitNavigate = useCallback(() => {
navigate(`/${networkId}`);
}, [navigate, networkId]);
useStreamNavigationKeys({ useStreamNavigationKeys({
next, next,
prev, prev,
currentIndex, currentIndex,
childrenLength: children.length, childrenLength: children.length,
mediaRef, mediaRef,
onExit: handleExitNavigate,
}); });
const handleOpenHuddle = useCallback(() => { const handleOpenHuddle = useCallback(() => {
@@ -325,10 +331,6 @@ function StreamViewInner({ path, streamParticle }: StreamViewProps) {
// Always show controls when compose is active or exit countdown is visible // Always show controls when compose is active or exit countdown is visible
const controlsVisible = showControls || composeActive || status === 'ended'; const controlsVisible = showControls || composeActive || status === 'ended';
const handleExitNavigate = useCallback(() => {
navigate(`/${networkId}`);
}, [navigate, networkId]);
const exitRemainingMs = useExitCountdown(status, paused, handleExitNavigate); const exitRemainingMs = useExitCountdown(status, paused, handleExitNavigate);
// Reset progress when the particle changes. // Reset progress when the particle changes.
@@ -362,6 +364,8 @@ function StreamViewInner({ path, streamParticle }: StreamViewProps) {
<StreamViewControls <StreamViewControls
showEscape showEscape
onOpenKeybindings={() => setShowKeybindings(true)} onOpenKeybindings={() => setShowKeybindings(true)}
onOpenHuddle={handleOpenHuddle}
onExit={handleExitNavigate}
/> />
<ComposeOverlay <ComposeOverlay
networkId={networkId} networkId={networkId}
@@ -505,6 +509,8 @@ function StreamViewInner({ path, streamParticle }: StreamViewProps) {
onlineHumanIds={onlineHumanIds} onlineHumanIds={onlineHumanIds}
exitRemainingMs={exitRemainingMs} exitRemainingMs={exitRemainingMs}
onOpenKeybindings={() => setShowKeybindings(true)} onOpenKeybindings={() => setShowKeybindings(true)}
onOpenHuddle={handleOpenHuddle}
onExit={handleExitNavigate}
/> />
<KeybindingsOverlay <KeybindingsOverlay
@@ -527,6 +533,8 @@ function BottomBar({
onlineHumanIds, onlineHumanIds,
exitRemainingMs, exitRemainingMs,
onOpenKeybindings, onOpenKeybindings,
onOpenHuddle,
onExit,
}: { }: {
visible: boolean; visible: boolean;
total: number; total: number;
@@ -540,6 +548,8 @@ function BottomBar({
onlineHumanIds: Set<string>; onlineHumanIds: Set<string>;
exitRemainingMs: number | null; exitRemainingMs: number | null;
onOpenKeybindings: () => void; onOpenKeybindings: () => void;
onOpenHuddle: () => void;
onExit: () => void;
}) { }) {
return ( return (
<div <div
@@ -580,6 +590,8 @@ function BottomBar({
<StreamViewControls <StreamViewControls
showEscape showEscape
onOpenKeybindings={onOpenKeybindings} onOpenKeybindings={onOpenKeybindings}
onOpenHuddle={onOpenHuddle}
onExit={onExit}
/> />
</div> </div>
</div> </div>
@@ -590,58 +602,54 @@ function BottomBar({
function StreamViewControls({ function StreamViewControls({
showEscape, showEscape,
onOpenKeybindings, onOpenKeybindings,
onOpenHuddle,
onExit,
}: { }: {
showEscape?: boolean; showEscape?: boolean;
onOpenKeybindings: () => void; onOpenKeybindings: () => void;
onOpenHuddle: () => void;
onExit: () => void;
}) { }) {
const requestIntent = useComposeIntentStore((s) => s.request); const requestIntent = useComposeIntentStore((s) => s.request);
return ( return (
<div className="flex items-center gap-4 text-sm text-white/50"> <div className="flex items-center gap-4 text-sm text-white/50">
{showEscape && ( {showEscape && (
<span> <KeyHint
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs"> keys="Esc"
Esc onClick={onExit}
</kbd>{' '} title="Back to network (or press Esc)"
>
back back
</span> </KeyHint>
)} )}
<VideoAudioToggle /> <VideoAudioToggle />
<button <KeyHint
type="button" keys="Hold `"
onClick={() => requestIntent('record')} onClick={() => requestIntent('record')}
className="cursor-pointer rounded transition-colors hover:text-white/80"
title="Reply with a recording (or hold `)" 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 to reply
</button> </KeyHint>
<button <KeyHint
type="button" keys="T"
onClick={() => requestIntent('text')} onClick={() => requestIntent('text')}
className="cursor-pointer rounded transition-colors hover:text-white/80"
title="Reply with text (or press T)" 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 text
</button> </KeyHint>
<span> <KeyHint
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs"> keys="H"
H onClick={onOpenHuddle}
</kbd>{' '} title="Start a huddle (or press H)"
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"
> >
? huddle
</kbd> </KeyHint>
<KeyHint
keys="?"
onClick={onOpenKeybindings}
title="Show all shortcuts"
aria-label="Show keyboard shortcuts"
/>
</div> </div>
); );
} }
@@ -12,7 +12,7 @@ import {
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from '@/components/ui/select'; } 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 { useAudioSource } from '@/components/audio/use-audio-source';
import { useMediaDevices } from '@/hooks/use-media-devices'; import { useMediaDevices } from '@/hooks/use-media-devices';
import { import {
@@ -105,20 +105,17 @@ function FieldLabel({ children }: { children: React.ReactNode }) {
function InlineLevelMeter({ stream }: { stream: MediaStream | null }) { function InlineLevelMeter({ stream }: { stream: MediaStream | null }) {
const audioSource = useAudioSource(stream); const audioSource = useAudioSource(stream);
if (!audioSource) { if (!audioSource) {
return ( return <div className="bg-muted h-3 w-20 rounded-sm opacity-50" />;
<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 ( return (
<div className="flex h-3 items-end"> <CenteredWaveform
<div className="scale-[0.55] origin-right"> sourceNode={audioSource.sourceNode}
<AudioLevelBars sourceNode={audioSource.sourceNode} /> width={80}
</div> height={12}
</div> 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 { useEffect, type RefObject } from 'react';
import { useNavigate } from 'react-router-dom';
import type { MediaParticleHandle } from '@/features/particles/media-particle-view'; import type { MediaParticleHandle } from '@/features/particles/media-particle-view';
import { isTypingTarget } from '@/lib/keyboard'; import { isTypingTarget } from '@/lib/keyboard';
@@ -11,6 +10,7 @@ interface UseStreamNavigationKeysOptions {
currentIndex: number; currentIndex: number;
childrenLength: number; childrenLength: number;
mediaRef: RefObject<MediaParticleHandle | null>; mediaRef: RefObject<MediaParticleHandle | null>;
onExit: () => void;
} }
/** /**
@@ -23,9 +23,8 @@ export function useStreamNavigationKeys({
currentIndex, currentIndex,
childrenLength, childrenLength,
mediaRef, mediaRef,
onExit,
}: UseStreamNavigationKeysOptions) { }: UseStreamNavigationKeysOptions) {
const navigate = useNavigate();
useEffect(() => { useEffect(() => {
const onKeyDown = (e: KeyboardEvent) => { const onKeyDown = (e: KeyboardEvent) => {
if (isTypingTarget(e)) return; if (isTypingTarget(e)) return;
@@ -53,12 +52,12 @@ export function useStreamNavigationKeys({
break; break;
case 'Escape': case 'Escape':
e.preventDefault(); e.preventDefault();
navigate(-1); onExit();
break; break;
} }
}; };
window.addEventListener('keydown', onKeyDown); window.addEventListener('keydown', onKeyDown);
return () => window.removeEventListener('keydown', onKeyDown); return () => window.removeEventListener('keydown', onKeyDown);
}, [next, prev, currentIndex, childrenLength, mediaRef, navigate]); }, [next, prev, currentIndex, childrenLength, mediaRef, onExit]);
} }