Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
11d88dbb1b | ||
|
|
b9a8529e72 | ||
|
|
95f2362947 | ||
|
|
9fd7e611f3 | ||
|
|
849d41fa06 |
+2
-31
@@ -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
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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">
|
||||
1–9
|
||||
</kbd>{' '}
|
||||
jump
|
||||
</span>
|
||||
<KeyHint keys={['↑↓', 'Enter']}>navigate</KeyHint>
|
||||
<KeyHint keys="1–9">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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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`);
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user