refactor: organize desktop vs. mobile into separate folders

This commit is contained in:
Arjun Patel
2026-04-29 08:42:56 -07:00
parent 3d9fe67936
commit 3a11a82cd3
194 changed files with 213 additions and 213 deletions
+100
View File
@@ -0,0 +1,100 @@
import { useEffect } from "react";
import { HashRouter, Routes, Route, useNavigate } from "react-router-dom";
import { TooltipProvider } from "@/components/ui/tooltip";
import { useAuthStore } from "@/stores/auth-store";
import { LoginPage } from "@/features/auth/login-page";
import { QueryClientProvider } from '@tanstack/react-query';
import SettingsPage from "@/features/settings-page";
import AudioVideoSettingsPage from "@/features/settings/audio-video-settings-page";
import NetworkSelector from "@/features/network-selector";
import NetworkRoot from "@/features/network-root";
import ParticleViewResolver from "@/features/particles/particle-view-resolver";
import Layout from "@/features/layout";
import NetworkSettingsPage from "@/features/network-settings";
import { Toaster } from "@/components/ui/sonner";
import { PusherProvider } from "@/lib/pusher-provider";
import { createQueryClient } from "@/lib/query-client";
import {
RouteErrorBoundary,
TopLevelErrorBoundary,
} from "@/components/app-error-boundary";
import { SoundEffectsProvider } from "@/lib/sound-effects/sound-effects-provider";
const queryClient = createQueryClient();
const App = () => {
const status = useAuthStore((s) => s.status);
const restoreSession = useAuthStore((s) => s.restoreSession);
useEffect(() => {
restoreSession();
}, [restoreSession]);
if (status === "idle" || status === "restoring") {
return (
<div className="flex min-h-screen items-center justify-center">
<p className="text-muted-foreground text-sm">Loading...</p>
</div>
);
}
if (status !== "authenticated") {
return <LoginPage />;
}
return (
<PusherProvider>
<AuthenticatedApp />
</PusherProvider>
);
};
function AutoplayNavigationListener() {
const navigate = useNavigate();
useEffect(() => {
return window.electronAutoplay.onNavigate((data) => {
navigate(`/${data.networkId}/${data.streamId}`);
});
}, [navigate]);
return null;
}
function AuthenticatedApp() {
return (
<HashRouter>
<AutoplayNavigationListener />
<RouteErrorBoundary>
<Routes>
<Route path="settings" element={<SettingsPage />} />
<Route path="settings/audio-video" element={<AudioVideoSettingsPage />} />
<Route path="/">
<Route index element={<Layout><NetworkSelector /></Layout>} />
<Route path=":networkId">
<Route index element={<Layout><NetworkRoot /></Layout>} />
<Route path="settings" element={<NetworkSettingsPage />} />
<Route path="*" element={<ParticleViewResolver />} />
</Route>
</Route>
</Routes>
</RouteErrorBoundary>
</HashRouter>
);
}
const AppWithProviders = () => (
<TopLevelErrorBoundary>
<TooltipProvider>
<QueryClientProvider client={queryClient}>
<SoundEffectsProvider>
<App />
<Toaster />
</SoundEffectsProvider>
</QueryClientProvider>
</TooltipProvider>
</TopLevelErrorBoundary>
);
export default AppWithProviders;
+265
View File
@@ -0,0 +1,265 @@
import { appConfig } from "@/config/env";
import { useSessionStore } from "@/stores/session-store";
import { ApiError } from "@/lib/errors";
import type { z } from "zod";
import {
BillingStatusSchema,
CheckoutSessionResponseSchema,
DepotObjectSchema,
FirebaseTokenResponseSchema,
GetLivekitTokenResponseSchema,
HumanSchema,
ListInvitationsResponseSchema,
ListNetworksResponseSchema,
NetworkSchema,
NetworkUsageSchema,
PortalSessionResponseSchema,
PrepareUploadResponseSchema,
SignInResponseSchema,
} from "./types";
import type {
AcceptInvitationRequest,
AddMembersRequest,
BillingCadence,
CreateNetworkRequest,
PrepareUploadRequest,
RequestCodeRequest,
RevokeInvitationRequest,
SignInRequest,
} from "./types";
interface ApiClientConfig {
baseUrl: string;
getToken: () => string | null;
onUnauthorized: () => void;
}
class ApiClient {
private config: ApiClientConfig;
constructor(config: ApiClientConfig) {
this.config = config;
}
private async fetch(
method: string,
path: string,
body?: unknown,
): Promise<Response> {
const headers: Record<string, string> = {};
if (body) {
headers["Content-Type"] = "application/json";
}
const token = this.config.getToken();
if (token) {
headers["Authorization"] = `Bearer ${token}`;
}
const response = await fetch(`${this.config.baseUrl}${path}`, {
method,
headers,
body: body ? JSON.stringify(body) : undefined,
});
if (response.status === 401) {
this.config.onUnauthorized();
throw new ApiError(401, "Unauthorized");
}
if (!response.ok) {
const text = await response.text().catch(() => "Unknown error");
throw new ApiError(response.status, text);
}
return response;
}
private async request<T>(
schema: z.ZodType<T>,
method: string,
path: string,
body?: unknown,
): Promise<T> {
const response = await this.fetch(method, path, body);
const json = await response.json();
return schema.parse(json);
}
private async requestVoid(
method: string,
path: string,
body?: unknown,
): Promise<void> {
await this.fetch(method, path, body);
}
// --- Auth ---
async requestCode(data: RequestCodeRequest): Promise<void> {
await this.requestVoid("POST", "/auth/request-code", data);
}
async signIn(data: SignInRequest) {
return this.request(SignInResponseSchema, "POST", "/auth/sign-in", data);
}
async me() {
return this.request(HumanSchema, "GET", "/auth/me");
}
async signOut(): Promise<void> {
await this.requestVoid("POST", "/auth/sign-out");
}
async getFirebaseToken() {
return this.request(
FirebaseTokenResponseSchema,
"POST",
"/auth/firebase-token",
);
}
// TODO: security: require passing in the particle id once api deprecates this
async getParticleDownloadUrl(objectId: string): Promise<string> {
const response = await this.fetch(
"GET",
`/particles/${objectId}/download`,
);
const data = await response.json();
return data.url;
}
// --- Settings ---
async updateSettings(data: { email_notifications_enabled?: boolean }): Promise<void> {
await this.requestVoid("PATCH", "/humans/me/settings", data);
}
// --- Depot ---
async prepareUpload(data: PrepareUploadRequest) {
return this.request(
PrepareUploadResponseSchema,
"POST",
"/depot/upload",
data,
);
}
async confirmUpload(objectId: string) {
return this.request(
DepotObjectSchema,
"POST",
`/depot/objects/${objectId}/confirm`,
);
}
// --- Networks ---
async listNetworks() {
return this.request(
ListNetworksResponseSchema,
"GET",
"/networks",
);
}
async createNetwork(data: CreateNetworkRequest) {
return this.request(NetworkSchema, "POST", "/networks", data);
}
async getNetwork(id: string) {
return this.request(NetworkSchema, "GET", `/networks/${id}`);
}
async addMembers(networkId: string, data: AddMembersRequest): Promise<void> {
await this.requestVoid(
"POST",
`/networks/${networkId}/members`,
data,
);
}
async removeMember(networkId: string, humanId: string): Promise<void> {
await this.requestVoid(
"DELETE",
`/networks/${networkId}/members/${humanId}`,
);
}
// --- Invitations ---
async listNetworkInvitations(networkId: string) {
return this.request(
ListInvitationsResponseSchema,
"GET",
`/networks/${networkId}/invitations`,
);
}
async listMyInvitations() {
return this.request(
ListInvitationsResponseSchema,
"GET",
"/invitations",
);
}
async acceptInvitation(data: AcceptInvitationRequest): Promise<void> {
await this.requestVoid("POST", "/invitations/accept", data);
}
async revokeInvitation(networkId: string, data: RevokeInvitationRequest): Promise<void> {
await this.requestVoid("DELETE", `/networks/${networkId}/invitations`, data);
}
// --- LiveKit ---
async getLivekitToken(networkId: string, streamId: string) {
return this.request(GetLivekitTokenResponseSchema, "POST", "/livekit/token", { network_id: networkId, stream_id: streamId });
}
// --- Billing (network admin only) ---
async getNetworkBilling(networkId: string) {
return this.request(
BillingStatusSchema,
"GET",
`/networks/${networkId}/billing`,
);
}
async createCheckoutSession(networkId: string, cadence: BillingCadence) {
return this.request(
CheckoutSessionResponseSchema,
"POST",
`/networks/${networkId}/billing/checkout-session`,
{ cadence },
);
}
async createPortalSession(networkId: string) {
return this.request(
PortalSessionResponseSchema,
"POST",
`/networks/${networkId}/billing/portal-session`,
);
}
async getNetworkUsage(networkId: string) {
return this.request(
NetworkUsageSchema,
"GET",
`/networks/${networkId}/usage`,
);
}
}
export const apiClient = new ApiClient({
baseUrl: appConfig.orionUrl,
getToken: () => useSessionStore.getState().token,
onUnauthorized: () => useSessionStore.getState().clearToken(),
});
+319
View File
@@ -0,0 +1,319 @@
import { z } from "zod";
export const HumanSchema = z.object({
id: z.string(),
created_at: z.coerce.date(),
email: z.string().email(),
email_prefix: z.string(),
email_notifications_enabled: z.boolean(),
});
export type Human = z.infer<typeof HumanSchema>;
export const NetworkSchema = z.object({
id: z.string(),
name: z.string(),
admin_human: HumanSchema,
humans: z.array(HumanSchema),
created_at: z.coerce.date(),
});
export type Network = z.infer<typeof NetworkSchema>;
export const ListNetworksResponseSchema = z.array(NetworkSchema);
export type ListNetworksResponse = z.infer<typeof ListNetworksResponseSchema>;
// --- Network request/response types ---
const CreateNetworkRequestSchema = z.object({
name: z.string(),
});
export type CreateNetworkRequest = z.infer<typeof CreateNetworkRequestSchema>;
const AddMembersRequestSchema = z.object({
email_addresses: z.array(z.string().email()),
});
export type AddMembersRequest = z.infer<typeof AddMembersRequestSchema>;
// --- Invitation types ---
export const InvitationSchema = z.object({
network_id: z.string(),
network_name: z.string(),
email: z.string(),
created_at: z.coerce.date(),
});
export type Invitation = z.infer<typeof InvitationSchema>;
export const ListInvitationsResponseSchema = z.array(InvitationSchema);
export type AcceptInvitationRequest = { network_id: string };
export type RevokeInvitationRequest = { email: string };
// --- Depot types ---
const PrepareUploadRequestSchema = z.object({
network_id: z.string(),
name: z.string(),
content_type: z.string(),
content_length: z.number(),
});
export type PrepareUploadRequest = z.infer<typeof PrepareUploadRequestSchema>;
export const PrepareUploadResponseSchema = z.object({
object_id: z.string(),
upload_url: z.string(),
upload_headers: z.record(z.string(), z.string()),
});
export type PrepareUploadResponse = z.infer<typeof PrepareUploadResponseSchema>;
export const DepotObjectSchema = z.object({
id: z.string(),
name: z.string(),
content_type: z.string(),
content_length: z.number(),
contains_content: z.boolean(),
created_at: z.coerce.date(),
});
export type DepotObject = z.infer<typeof DepotObjectSchema>;
// --- Particle property schemas ---
export const StreamPropertiesSchema = z.object({
name: z.string(),
description: z.string().optional(),
});
export type StreamProperties = z.infer<typeof StreamPropertiesSchema>;
export const FolderPropertiesSchema = z.object({
name: z.string(),
color: z.string().optional(),
});
export type FolderProperties = z.infer<typeof FolderPropertiesSchema>;
const TranscriptWordSchema = z.object({
word: z.string(),
start: z.number(),
end: z.number(),
});
const TranscriptSentenceSchema = z.object({
text: z.string(),
start: z.number(),
end: z.number(),
});
const TranscriptParagraphSchema = z.object({
sentences: z.array(TranscriptSentenceSchema),
start: z.number(),
end: z.number(),
});
export const TranscriptSchema = z.object({
transcript: z.string(),
words: z.array(TranscriptWordSchema),
paragraphs: z.array(TranscriptParagraphSchema),
});
export type Transcript = z.infer<typeof TranscriptSchema>;
export const MediaPropertiesSchema = z.object({
object_id: z.string(),
mime_type: z.string(),
duration_ms: z.number(),
size_bytes: z.number(),
transcript: TranscriptSchema.optional(),
source: z.enum(["camera", "screen"]).optional(),
});
export type MediaProperties = z.infer<typeof MediaPropertiesSchema>;
export const FilePropertiesSchema = z.object({
object_id: z.string(),
filename: z.string(),
mime_type: z.string(),
size_bytes: z.number(),
});
export type FileProperties = z.infer<typeof FilePropertiesSchema>;
export const TextPropertiesSchema = z.object({
content: z.string(),
edited_at: z.coerce.date().optional(),
});
export type TextProperties = z.infer<typeof TextPropertiesSchema>;
export const QuestPropertiesSchema = z.object({
title: z.string(),
description: z.string(),
status: z.string().optional(),
// humanId
assigned_to: z.string().optional(),
});
export type QuestProperties = z.infer<typeof QuestPropertiesSchema>;
export const PaperPropertiesSchema = z.object({
title: z.string(),
content: z.string(),
});
export type PaperProperties = z.infer<typeof PaperPropertiesSchema>;
// --- Reactions ---
export const ReactionsSchema = z.record(z.string(), z.array(z.string())).optional();
export type Reactions = z.infer<typeof ReactionsSchema>;
// --- Tombstone (soft-delete) ---
// Fields added to non-container particles when their creator deletes them.
// We keep the doc around so concurrent viewers can see a "This particle was
// deleted" message in place, rather than being jumped to the next particle.
const TombstoneFields = {
deleted_at: z.coerce.date().optional(),
deleted_by_human_id: z.string().optional(),
};
export const REACTION_EMOJIS = ["\u{1F44D}", "\u{2764}\u{FE0F}", "\u{1F525}", "\u{1F440}", "\u{2705}", "\u{2753}", "\u{1F602}"] as const;
export interface ParticlePropertiesMap {
stream: StreamProperties;
folder: FolderProperties;
media: MediaProperties;
file: FileProperties;
text: TextProperties;
quest: QuestProperties;
paper: PaperProperties;
}
// --- Unified Particle types ---
const ParticleBaseSchema = z.object({
id: z.string(),
created_at: z.coerce.date(),
created_by_human_id: z.string(),
updated_at: z.coerce.date().optional(),
});
export const ParticleSchema = z.discriminatedUnion("type", [
ParticleBaseSchema.extend({
type: z.literal("stream"),
properties: StreamPropertiesSchema,
// e.g. ["human:human_xxxx", "human:human_yyyy"] - visible only to Aron and John
// e.g. ["network:xywx"] - visible to everyone in the network
visible_to: z.array(z.string()),
// Marks human_id to their `playback_position_at`: where they left off in a conversation
playback_markers: z.record(z.string(), z.coerce.date()).optional(),
// Timestamp of the most recent child particle
// used for sorting streams by recent activity without needing to query subcollections
last_child_created_at: z.coerce.date().optional(),
// Array of humanIds currently in the huddle (updated via LiveKit webhooks)
huddle_active_participants: z.array(z.string()).optional(),
status: z.enum(["open", "closed"]).optional(),
}),
ParticleBaseSchema.extend({
type: z.literal("folder"), properties: FolderPropertiesSchema,
// e.g. ["human:human_xxxx", "human:human_yyyy"] - visible only to Aron and John
// e.g. ["network:123"] - visible to everyone in the network
visible_to: z.array(z.string()),
}),
ParticleBaseSchema.extend({ type: z.literal("media"), properties: MediaPropertiesSchema, reactions: ReactionsSchema, ...TombstoneFields }),
ParticleBaseSchema.extend({ type: z.literal("file"), properties: FilePropertiesSchema, ...TombstoneFields }),
ParticleBaseSchema.extend({ type: z.literal("text"), properties: TextPropertiesSchema, reactions: ReactionsSchema, ...TombstoneFields }),
ParticleBaseSchema.extend({ type: z.literal("quest"), properties: QuestPropertiesSchema, ...TombstoneFields }),
ParticleBaseSchema.extend({ type: z.literal("paper"), properties: PaperPropertiesSchema, ...TombstoneFields }),
]);
export type Particle = z.infer<typeof ParticleSchema>;
export type ParticleType = Particle["type"];
/** Container types can have children subcollections */
export const CONTAINER_TYPES: ReadonlySet<ParticleType> = new Set(["stream", "folder"]);
export function isContainerType(type: ParticleType): boolean {
return CONTAINER_TYPES.has(type);
}
/** True when a non-container particle has been soft-deleted (tombstoned). */
export function isParticleDeleted(particle: Particle): boolean {
return "deleted_at" in particle && particle.deleted_at != null;
}
// --- LiveKit types ---
export const GetLivekitTokenResponseSchema = z.object({
token: z.string(),
server_url: z.string(),
});
export type GetLivekitTokenResponse = z.infer<typeof GetLivekitTokenResponseSchema>;
// --- Auth types ---
const RequestCodeRequestSchema = z.object({
email: z.string().email(),
});
export type RequestCodeRequest = z.infer<typeof RequestCodeRequestSchema>;
const SignInRequestSchema = z.object({
email: z.string().email(),
code: z.string(),
});
export type SignInRequest = z.infer<typeof SignInRequestSchema>;
export const SignInResponseSchema = z.object({
human: HumanSchema,
token: z.string(),
});
export type SignInResponse = z.infer<typeof SignInResponseSchema>;
export const FirebaseTokenResponseSchema = z.object({
token: z.string(),
});
export type FirebaseTokenResponse = z.infer<typeof FirebaseTokenResponseSchema>;
// --- Billing types ---
export const BillingCadenceSchema = z.enum(["monthly", "annual"]);
export type BillingCadence = z.infer<typeof BillingCadenceSchema>;
export const NetworkPlanSchema = z.enum(["free", "pro"]);
export type NetworkPlan = z.infer<typeof NetworkPlanSchema>;
// Mirrors Stripe subscription.status plus "active" as the default free-tier value.
export const BillingPlanStatusSchema = z.enum([
"active",
"trialing",
"past_due",
"canceled",
"incomplete",
"incomplete_expired",
"unpaid",
]);
export type BillingPlanStatus = z.infer<typeof BillingPlanStatusSchema>;
export const BillingStatusSchema = z.object({
plan: NetworkPlanSchema,
plan_status: BillingPlanStatusSchema,
cadence: BillingCadenceSchema.nullable(),
seats: z.number().int(),
current_period_end: z.coerce.date().nullable(),
cancel_at_period_end: z.boolean(),
price_monthly_cents: z.number().int(),
price_annual_cents: z.number().int(),
});
export type BillingStatus = z.infer<typeof BillingStatusSchema>;
export const CheckoutSessionResponseSchema = z.object({
url: z.string().url(),
});
export type CheckoutSessionResponse = z.infer<typeof CheckoutSessionResponseSchema>;
export const PortalSessionResponseSchema = z.object({
url: z.string().url(),
});
export type PortalSessionResponse = z.infer<typeof PortalSessionResponseSchema>;
export const NetworkUsageSchema = z.object({
plan: NetworkPlanSchema,
used: z.number().int().nonnegative(),
limit: z.number().int().nonnegative().nullable(),
reset_at: z.coerce.date(),
});
export type NetworkUsage = z.infer<typeof NetworkUsageSchema>;
+9
View File
@@ -0,0 +1,9 @@
declare module "*.wav" {
const src: string;
export default src;
}
declare module "*.mp3" {
const src: string;
export default src;
}
@@ -0,0 +1,102 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { X } from 'lucide-react';
import type { AutoplayPayload } from '@/lib/autoplay-ipc';
export function AutoplayApp() {
const [payload, setPayload] = useState<AutoplayPayload | null>(null);
const mediaRef = useRef<HTMLVideoElement | HTMLAudioElement | null>(null);
useEffect(() => {
return window.electronAutoplay.onPlay((p) => setPayload(p));
}, []);
useEffect(() => {
return window.electronAutoplay.onStop(() => {
mediaRef.current?.pause();
setPayload(null);
});
}, []);
const stop = useCallback(() => {
mediaRef.current?.pause();
setPayload(null);
window.electronAutoplay.dismiss();
}, []);
if (!payload) {
return <div className="h-screen w-screen" />;
}
const isVideo = payload.mimeType.startsWith('video/');
const handleClick = () => {
mediaRef.current?.pause();
window.electronAutoplay.navigate({
networkId: payload.networkId,
streamId: payload.streamId,
});
};
const handleClose = (e: React.MouseEvent) => {
e.stopPropagation();
stop();
};
return (
<div
className="relative h-screen w-screen cursor-pointer overflow-hidden bg-black"
onClick={handleClick}
>
{isVideo ? (
<>
<video
ref={mediaRef as React.Ref<HTMLVideoElement>}
key={payload.particleId}
src={payload.downloadUrl}
autoPlay
playsInline
onEnded={stop}
className="block h-full w-full object-cover"
/>
<button
className="absolute top-1 right-1 z-10 rounded-full bg-black/50 p-0.5 text-white hover:bg-black/70"
onClick={handleClose}
>
<X className="size-3.5" />
</button>
<div className="absolute inset-x-0 bottom-0 flex items-center gap-2 bg-gradient-to-t from-black/60 to-transparent px-3 py-2">
<div className="flex size-6 shrink-0 items-center justify-center rounded-full bg-white/20 text-[10px] font-medium text-white">
{payload.senderInitials}
</div>
<p className="truncate text-xs text-white/80">{payload.senderName}</p>
</div>
</>
) : (
<>
<button
className="absolute top-1 right-1 z-10 rounded-full bg-black/50 p-0.5 text-white hover:bg-black/70"
onClick={handleClose}
>
<X className="size-3.5" />
</button>
<div className="flex h-full w-full items-center gap-2 bg-card px-3 py-3">
<div className="flex size-8 shrink-0 items-center justify-center rounded-full bg-primary/10 text-xs font-medium text-primary">
{payload.senderInitials}
</div>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium text-card-foreground">{payload.senderName}</p>
<p className="text-xs text-muted-foreground">Playing audio...</p>
</div>
</div>
<audio
ref={mediaRef as React.Ref<HTMLAudioElement>}
key={payload.particleId}
src={payload.downloadUrl}
autoPlay
onEnded={stop}
/>
</>
)}
</div>
);
}
+11
View File
@@ -0,0 +1,11 @@
<!doctype html>
<html class="dark">
<head>
<meta charset="UTF-8" />
<title>llink - Autoplay</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="./renderer.tsx"></script>
</body>
</html>
@@ -0,0 +1,9 @@
import { createRoot } from 'react-dom/client';
import { AutoplayApp } from './AutoplayApp';
import { initSentryRenderer } from '@/lib/sentry';
import '@/styles/globals.css';
initSentryRenderer();
const root = createRoot(document.getElementById('root')!);
root.render(<AutoplayApp />);
@@ -0,0 +1,51 @@
import type { PropsWithChildren } from "react";
import { ErrorBoundary } from "react-error-boundary";
import { useLocation } from "react-router-dom";
import { useQueryErrorResetBoundary } from "@tanstack/react-query";
import { reportError } from "@/lib/errors";
import {
RouteErrorFallback,
TopLevelErrorFallback,
} from "@/components/error-fallback";
/** Catches render crashes OUTSIDE the router so bootstrap failures still recover. */
export function TopLevelErrorBoundary({ children }: PropsWithChildren) {
return (
<ErrorBoundary
FallbackComponent={TopLevelErrorFallback}
onError={(error, info) =>
reportError(error, {
boundary: "top",
componentStack: info.componentStack,
})
}
>
{children}
</ErrorBoundary>
);
}
/**
* Route-scoped boundary. Resets on pathname change and clears the React
* Query error cache on retry so stale failures don't stick.
*/
export function RouteErrorBoundary({ children }: PropsWithChildren) {
const location = useLocation();
const { reset: resetQueries } = useQueryErrorResetBoundary();
return (
<ErrorBoundary
FallbackComponent={RouteErrorFallback}
onError={(error, info) =>
reportError(error, {
boundary: "route",
pathname: location.pathname,
componentStack: info.componentStack,
})
}
onReset={() => resetQueries()}
resetKeys={[location.pathname]}
>
{children}
</ErrorBoundary>
);
}
@@ -0,0 +1,121 @@
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,63 @@
import { useEffect, useRef, useState } from "react";
interface AudioSource {
sourceNode: AudioNode;
ctx: AudioContext;
}
/**
* Creates an AudioContext and source node from either a MediaStream (live recording)
* or an HTMLAudioElement (review playback).
*
* Important: `createMediaElementSource` can only be called once per element,
* so we cache the source per element instance.
*/
export function useAudioSource(
source: MediaStream | HTMLAudioElement | null,
): AudioSource | null {
const [audioSource, setAudioSource] = useState<AudioSource | null>(null);
const elementSourceCache = useRef<
WeakMap<HTMLAudioElement, { sourceNode: MediaElementAudioSourceNode; ctx: AudioContext }>
>(new WeakMap());
useEffect(() => {
if (!source) {
setAudioSource(null);
return;
}
if (source instanceof MediaStream) {
const ctx = new AudioContext();
ctx.resume();
const sourceNode = ctx.createMediaStreamSource(source);
setAudioSource({ sourceNode, ctx });
return () => {
ctx.close();
};
}
// HTMLAudioElement — createMediaElementSource can only be called once per element
const cached = elementSourceCache.current.get(source);
if (cached) {
cached.ctx.resume();
setAudioSource(cached);
return;
}
const ctx = new AudioContext();
ctx.resume();
const sourceNode = ctx.createMediaElementSource(source);
// Connect element source to destination so audio is still audible
sourceNode.connect(ctx.destination);
elementSourceCache.current.set(source, { sourceNode, ctx });
setAudioSource({ sourceNode, ctx });
return () => {
ctx.close();
elementSourceCache.current.delete(source);
};
}, [source]);
return audioSource;
}
@@ -0,0 +1,47 @@
import type { Human } from "@/api/types";
import { resolveHumanDisplay } from "@/lib/humans";
import type { ComposingUser } from "@/features/particles/stream-presence-context";
interface ComposingIndicatorProps {
users: ComposingUser[];
networkHumans?: Human[];
}
/**
* Composing indicators pinned to the left edge, text running bottom-to-top
* via writing-mode so it hugs the edge without transform math issues.
*/
export function ComposingIndicator({
users,
networkHumans,
}: ComposingIndicatorProps) {
if (users.length === 0) return null;
return (
<div
className="z-100 absolute left-2 top-1/2 z-20 flex -translate-y-1/2 flex-col gap-1.5 animate-in fade-in duration-200"
style={{ writingMode: "vertical-rl" }}
>
{users.map((u) => {
const { displayName } = resolveHumanDisplay(u.humanId, networkHumans);
const modeLabel = u.mode === "typing" ? "typing" : "recording";
return (
<div
key={u.humanId}
className="flex rotate-180 items-center gap-1.5 rounded-full bg-white/10 px-2 py-1 backdrop-blur-sm"
>
<span className="flex gap-0.5">
<span className="size-1 rounded-full bg-white/70 animate-bounce [animation-delay:0ms]" />
<span className="size-1 rounded-full bg-white/70 animate-bounce [animation-delay:150ms]" />
<span className="size-1 rounded-full bg-white/70 animate-bounce [animation-delay:300ms]" />
</span>
<span className="whitespace-nowrap text-[10px] text-white/50">
{displayName} {modeLabel}
</span>
</div>
);
})}
</div>
);
}
@@ -0,0 +1,72 @@
import { useEffect } from "react";
import { createPortal } from "react-dom";
import { Button } from "@/components/ui/button";
interface ConfirmDestructiveOverlayProps {
title: string;
description: React.ReactNode;
confirmLabel: string;
pendingLabel?: string;
isPending: boolean;
onConfirm: () => void;
onClose: () => void;
}
export function ConfirmDestructiveOverlay({
title,
description,
confirmLabel,
pendingLabel = "Working…",
isPending,
onConfirm,
onClose,
}: ConfirmDestructiveOverlayProps) {
useEffect(() => {
const handler = (e: KeyboardEvent) => {
if (e.key === "Escape") {
e.preventDefault();
e.stopPropagation();
onClose();
}
};
window.addEventListener("keydown", handler, { capture: true });
return () => window.removeEventListener("keydown", handler, { capture: true });
}, [onClose]);
return createPortal(
<div className="fixed inset-0 z-[100]">
<div
className="absolute inset-0 bg-black/60 backdrop-blur-sm"
onClick={onClose}
/>
<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>{" "}
to close
</span>
</div>
<div className="text-sm text-white/60">{description}</div>
<div className="mt-5 flex items-center justify-end gap-2">
<Button variant="ghost" size="sm" onClick={onClose} disabled={isPending}>
Cancel
</Button>
<Button
variant="destructive"
size="sm"
onClick={onConfirm}
disabled={isPending}
>
{isPending ? pendingLabel : confirmLabel}
</Button>
</div>
</div>
</div>,
document.body,
);
}
@@ -0,0 +1,49 @@
import { useEffect, useState } from "react";
import { Check, Copy } from "lucide-react";
import { toast } from "sonner";
import { cn } from "@/lib/utils";
interface CopyableEmailProps {
email: string;
className?: string;
}
export function CopyableEmail({ email, className }: CopyableEmailProps) {
const [copied, setCopied] = useState(false);
useEffect(() => {
if (!copied) return;
const id = setTimeout(() => setCopied(false), 1500);
return () => clearTimeout(id);
}, [copied]);
const handleCopy = async (e: React.MouseEvent) => {
e.stopPropagation();
try {
await navigator.clipboard.writeText(email);
setCopied(true);
toast.success("Email copied");
} catch {
toast.error("Failed to copy email");
}
};
return (
<button
type="button"
onClick={handleCopy}
aria-label={`Copy ${email}`}
className={cn(
"hover:bg-accent inline-flex items-center gap-1.5 rounded-md px-2 py-1 font-mono text-sm transition-colors",
className,
)}
>
<span>{email}</span>
{copied ? (
<Check className="text-muted-foreground size-3.5" />
) : (
<Copy className="text-muted-foreground size-3.5" />
)}
</button>
);
}
@@ -0,0 +1,94 @@
import type { FallbackProps } from "react-error-boundary";
import { useNavigate } from "react-router-dom";
import { AlertTriangle } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { toUserMessage } from "@/lib/errors";
import { appEnv } from "@/config/env";
function ErrorCard({
error,
onGoHome,
onRetry,
}: {
error: unknown;
onGoHome: () => void;
onRetry: () => void;
}) {
return (
<div className="flex h-screen w-full items-center justify-center p-6">
<Card className="w-full max-w-md">
<CardHeader>
<div className="flex items-center gap-2">
<AlertTriangle className="text-destructive size-4" />
<CardTitle>Something went wrong</CardTitle>
</div>
<CardDescription>{toUserMessage(error)}</CardDescription>
</CardHeader>
{appEnv === "dev" && error instanceof Error ? (
<CardContent>
<details className="text-muted-foreground text-xs">
<summary className="cursor-pointer select-none">
Technical details
</summary>
<pre className="mt-2 max-h-48 overflow-auto whitespace-pre-wrap break-all">
{error.stack ?? error.message}
</pre>
</details>
</CardContent>
) : null}
<CardFooter className="flex gap-2">
<Button size="sm" onClick={onGoHome}>
Go home
</Button>
<Button size="sm" variant="outline" onClick={onRetry}>
Try again
</Button>
</CardFooter>
</Card>
</div>
);
}
// Outside the router `useNavigate` is unavailable, so "Go home" drops the hash directly.
export function TopLevelErrorFallback({
error,
resetErrorBoundary,
}: FallbackProps) {
const goHome = () => {
window.location.hash = "#/";
resetErrorBoundary();
};
return (
<ErrorCard
error={error}
onGoHome={goHome}
onRetry={resetErrorBoundary}
/>
);
}
export function RouteErrorFallback({
error,
resetErrorBoundary,
}: FallbackProps) {
const navigate = useNavigate();
const goHome = () => {
navigate("/");
resetErrorBoundary();
};
return (
<ErrorCard
error={error}
onGoHome={goHome}
onRetry={resetErrorBoundary}
/>
);
}
@@ -0,0 +1,102 @@
import { useSuspendPlayback } from "@/hooks/use-suspend-playback";
import { useEffect } from "react";
import { createPortal } from "react-dom";
export interface KeybindingEntry {
keys: string[];
description: string;
}
export interface KeybindingGroup {
label: string;
bindings: KeybindingEntry[];
}
interface KeybindingsOverlayProps {
open: boolean;
onClose: () => void;
groups: KeybindingGroup[];
title?: string;
}
export function KeybindingsOverlay({
open,
onClose,
groups,
title = "Keyboard Shortcuts",
}: KeybindingsOverlayProps) {
useSuspendPlayback(open, "keybindings");
useEffect(() => {
if (!open) return;
const handler = (e: KeyboardEvent) => {
if (e.key === "Escape" || e.key === "?") {
e.preventDefault();
e.stopPropagation();
onClose();
}
};
window.addEventListener("keydown", handler, { capture: true });
return () => window.removeEventListener("keydown", handler, { capture: true });
}, [open, onClose]);
if (!open) return null;
return createPortal(
<div className="fixed inset-0 z-[100]">
{/* Backdrop */}
<div
className="absolute inset-0 bg-black/60 backdrop-blur-sm"
onClick={onClose}
/>
{/* Panel */}
<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>{" "}
to close
</span>
</div>
<div className="flex flex-col gap-5">
{groups.map((group) => (
<section key={group.label}>
<h3 className="mb-2 text-[10px] font-medium uppercase tracking-widest text-white/30">
{group.label}
</h3>
<ul className="flex flex-col gap-1">
{group.bindings.map((binding) => (
<li
key={binding.description}
className="flex items-center justify-between border-b border-white/5 pb-1 last:border-0 last:pb-0"
>
<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>
</li>
))}
</ul>
</section>
))}
</div>
</div>
</div>,
document.body,
);
}
@@ -0,0 +1,107 @@
import { Copy, ExternalLink, Globe } from "lucide-react";
import type { LinkMetadata } from "@/lib/link-metadata";
import { Skeleton } from "@/components/ui/skeleton";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
interface LinkPreviewCardProps {
metadata: LinkMetadata;
compact?: boolean;
}
export function LinkPreviewCard({ metadata, compact }: LinkPreviewCardProps) {
const handleOpen = (e: React.MouseEvent) => {
e.stopPropagation();
window.electronLink.openExternal(metadata.url);
};
const handleCopy = (e: React.MouseEvent) => {
e.stopPropagation();
navigator.clipboard.writeText(metadata.url);
};
return (
<div
className="max-w-sm overflow-hidden rounded-2xl bg-white/10 backdrop-blur-md"
onClick={(e) => {
e.stopPropagation();
handleOpen(e);
}}
>
{metadata.image && (
<img
src={metadata.image}
alt=""
className="h-32 w-full object-cover"
onError={(e) => {
(e.target as HTMLImageElement).style.display = "none";
}}
/>
)}
<div className="flex flex-col gap-1 p-3">
<div className="flex items-center gap-1.5 text-xs text-white/50">
{metadata.favicon ? (
<img
src={metadata.favicon}
alt=""
className="size-4 rounded-sm"
onError={(e) => {
(e.target as HTMLImageElement).replaceWith(
document.createElement("span"),
);
}}
/>
) : (
<Globe className="size-4" />
)}
<span className="truncate">{metadata.domain}</span>
</div>
{metadata.title && (
<p className="truncate text-sm font-semibold leading-snug text-white">
{metadata.title}
</p>
)}
{metadata.description && (
<p className="line-clamp-2 text-xs leading-relaxed text-white/70">
{metadata.description}
</p>
)}
{!compact && (
<div className="mt-1.5 flex gap-2">
<Button
variant="ghost"
size="xs"
className="text-white/70 hover:bg-white/10 hover:text-white"
onClick={handleOpen}
>
<ExternalLink data-icon="inline-start" />
Open
</Button>
<Button
variant="ghost"
size="xs"
className="text-white/70 hover:bg-white/10 hover:text-white"
onClick={handleCopy}
>
<Copy data-icon="inline-start" />
Copy
</Button>
</div>
)}
</div>
</div>
);
}
export function LinkPreviewCardSkeleton() {
return (
<div className="max-w-sm overflow-hidden rounded-2xl bg-white/10 backdrop-blur-md">
<Skeleton className="h-32 w-full rounded-none bg-white/5" />
<div className="flex flex-col gap-2 p-3">
<Skeleton className="h-3 w-24 bg-white/10" />
<Skeleton className="h-4 w-48 bg-white/10" />
<Skeleton className="h-3 w-full bg-white/10" />
</div>
</div>
);
}
@@ -0,0 +1,22 @@
import { useEffect, useState } from "react";
import { formatDistanceToNow } from "@/lib/time-utils";
const TICK_MS = 30_000;
interface RelativeTimestampProps {
date: Date;
}
/**
* Displays a relative timestamp (e.g. "5m ago") that auto-refreshes every 30s.
*/
export function RelativeTimestamp({ date }: RelativeTimestampProps) {
const [, setTick] = useState(0);
useEffect(() => {
const id = setInterval(() => setTick((t) => t + 1), TICK_MS);
return () => clearInterval(id);
}, []);
return <>{formatDistanceToNow(date.toISOString())}</>;
}
@@ -0,0 +1,139 @@
import { useState, useEffect } from "react";
interface ScreenSourcePickerProps {
title?: string;
confirmLabel?: string;
getSources: () => Promise<ScreenSource[]>;
onSelect: (sourceId: string) => void;
onCancel: () => void;
}
export function ScreenSourcePicker({
title = "Select a screen",
confirmLabel = "Select",
getSources,
onSelect,
onCancel,
}: ScreenSourcePickerProps) {
const [sources, setSources] = useState<ScreenSource[]>([]);
const [selectedId, setSelectedId] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
getSources().then((result) => {
setSources(result);
setLoading(false);
});
}, [getSources]);
// Auto-select if there's only one source
useEffect(() => {
if (!loading && sources.length === 1) {
setSelectedId(sources[0].id);
}
}, [loading, sources]);
const screens = sources.filter((s) => s.id.startsWith("screen:"));
const windows = sources.filter((s) => s.id.startsWith("window:"));
return (
<div className="absolute inset-0 z-50 flex items-center justify-center bg-black/90">
<div className="mx-4 flex max-h-[80vh] w-full max-w-2xl flex-col rounded-lg bg-zinc-900 shadow-xl">
<div className="flex items-center justify-between border-b border-zinc-700 px-5 py-4">
<h2 className="text-base font-medium text-zinc-100">{title}</h2>
<button
onClick={onCancel}
className="text-zinc-400 hover:text-zinc-200"
>
</button>
</div>
<div className="flex-1 overflow-y-auto px-5 py-4">
{loading ? (
<p className="text-center text-sm text-zinc-400">
Loading sources...
</p>
) : (
<>
{screens.length > 0 && (
<SourceSection
title="Screens"
sources={screens}
selectedId={selectedId}
onSelect={setSelectedId}
/>
)}
{windows.length > 0 && (
<SourceSection
title="Windows"
sources={windows}
selectedId={selectedId}
onSelect={setSelectedId}
/>
)}
</>
)}
</div>
<div className="flex justify-end gap-2 border-t border-zinc-700 px-5 py-3">
<button
onClick={onCancel}
className="rounded-md px-4 py-2 text-sm text-zinc-300 hover:bg-zinc-800"
>
Cancel
</button>
<button
disabled={!selectedId}
onClick={() => selectedId && onSelect(selectedId)}
className="rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-500 disabled:opacity-40 disabled:hover:bg-blue-600"
>
{confirmLabel}
</button>
</div>
</div>
</div>
);
}
function SourceSection({
title,
sources,
selectedId,
onSelect,
}: {
title: string;
sources: ScreenSource[];
selectedId: string | null;
onSelect: (id: string) => void;
}) {
return (
<div className="mb-4">
<h3 className="mb-2 text-xs font-medium uppercase tracking-wide text-zinc-400">
{title}
</h3>
<div className="grid grid-cols-3 gap-3">
{sources.map((source) => (
<button
key={source.id}
onClick={() => onSelect(source.id)}
className={`overflow-hidden rounded-lg border-2 text-left transition-colors ${
selectedId === source.id
? "border-blue-500 bg-zinc-800"
: "border-transparent bg-zinc-800/50 hover:border-zinc-600"
}`}
>
<img
src={source.thumbnailDataUrl}
alt={source.name}
className="aspect-video w-full object-cover"
/>
<p className="truncate px-2 py-1.5 text-xs text-zinc-300">
{source.name}
</p>
</button>
))}
</div>
</div>
);
}
+108
View File
@@ -0,0 +1,108 @@
import * as React from "react"
import { Avatar as AvatarPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function Avatar({
className,
size = "default",
...props
}: React.ComponentProps<typeof AvatarPrimitive.Root> & {
size?: "default" | "xs" | "sm" | "lg"
}) {
return (
<AvatarPrimitive.Root
data-slot="avatar"
data-size={size}
className={cn(
"size-8 rounded-full after:rounded-full data-[size=lg]:size-10 data-[size=sm]:size-6 data-[size=xs]:size-4 after:border-border group/avatar relative flex shrink-0 select-none after:absolute after:inset-0 after:border after:mix-blend-darken dark:after:mix-blend-lighten",
className
)}
{...props}
/>
)
}
function AvatarImage({
className,
...props
}: React.ComponentProps<typeof AvatarPrimitive.Image>) {
return (
<AvatarPrimitive.Image
data-slot="avatar-image"
className={cn(
"rounded-full aspect-square size-full object-cover",
className
)}
{...props}
/>
)
}
function AvatarFallback({
className,
...props
}: React.ComponentProps<typeof AvatarPrimitive.Fallback>) {
return (
<AvatarPrimitive.Fallback
data-slot="avatar-fallback"
className={cn(
"bg-muted text-muted-foreground rounded-full flex size-full items-center justify-center text-sm group-data-[size=sm]/avatar:text-xs group-data-[size=xs]/avatar:text-[8px]",
className
)}
{...props}
/>
)
}
function AvatarBadge({ className, ...props }: React.ComponentProps<"span">) {
return (
<span
data-slot="avatar-badge"
className={cn(
"bg-primary text-primary-foreground ring-background absolute right-0 bottom-0 z-10 inline-flex items-center justify-center rounded-full bg-blend-color ring-2 select-none",
"group-data-[size=xs]/avatar:size-1.5 group-data-[size=xs]/avatar:[&>svg]:hidden",
"group-data-[size=sm]/avatar:size-2 group-data-[size=sm]/avatar:[&>svg]:hidden",
"group-data-[size=default]/avatar:size-2.5 group-data-[size=default]/avatar:[&>svg]:size-2",
"group-data-[size=lg]/avatar:size-3 group-data-[size=lg]/avatar:[&>svg]:size-2",
className
)}
{...props}
/>
)
}
function AvatarGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="avatar-group"
className={cn(
"*:data-[slot=avatar]:ring-background group/avatar-group flex -space-x-2 *:data-[slot=avatar]:ring-2",
className
)}
{...props}
/>
)
}
function AvatarGroupCount({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="avatar-group-count"
className={cn("bg-muted text-muted-foreground size-8 rounded-full text-sm group-has-data-[size=lg]/avatar-group:size-10 group-has-data-[size=sm]/avatar-group:size-6 group-has-data-[size=xs]/avatar-group:size-4 group-has-data-[size=xs]/avatar-group:text-[8px] [&>svg]:size-4 group-has-data-[size=lg]/avatar-group:[&>svg]:size-5 group-has-data-[size=sm]/avatar-group:[&>svg]:size-3 ring-background relative flex shrink-0 items-center justify-center ring-2", className)}
{...props}
/>
)
}
export {
Avatar,
AvatarImage,
AvatarFallback,
AvatarGroup,
AvatarGroupCount,
AvatarBadge,
}
+45
View File
@@ -0,0 +1,45 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { Slot } from "radix-ui"
import { cn } from "@/lib/utils"
const badgeVariants = cva(
"h-5 gap-1 rounded-4xl border border-transparent px-2 py-0.5 text-xs font-medium transition-all has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&>svg]:size-3! inline-flex items-center justify-center w-fit whitespace-nowrap shrink-0 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive overflow-hidden group/badge",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
secondary: "bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80",
destructive: "bg-destructive/10 [a]:hover:bg-destructive/20 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 text-destructive dark:bg-destructive/20",
outline: "border-border text-foreground [a]:hover:bg-muted [a]:hover:text-muted-foreground",
ghost: "hover:bg-muted hover:text-muted-foreground dark:hover:bg-muted/50",
link: "text-primary underline-offset-4 hover:underline",
},
},
defaultVariants: {
variant: "default",
},
}
)
function Badge({
className,
variant = "default",
asChild = false,
...props
}: React.ComponentProps<"span"> &
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
const Comp = asChild ? Slot.Root : "span"
return (
<Comp
data-slot="badge"
data-variant={variant}
className={cn(badgeVariants({ variant }), className)}
{...props}
/>
)
}
export { Badge, badgeVariants }
+122
View File
@@ -0,0 +1,122 @@
import * as React from "react"
import { Slot } from "radix-ui"
import { cn } from "@/lib/utils"
import { ChevronRightIcon, MoreHorizontalIcon } from "lucide-react"
function Breadcrumb({ className, ...props }: React.ComponentProps<"nav">) {
return (
<nav
aria-label="breadcrumb"
data-slot="breadcrumb"
className={cn(className)}
{...props}
/>
)
}
function BreadcrumbList({ className, ...props }: React.ComponentProps<"ol">) {
return (
<ol
data-slot="breadcrumb-list"
className={cn(
"flex flex-wrap items-center gap-1.5 text-sm wrap-break-word text-muted-foreground",
className
)}
{...props}
/>
)
}
function BreadcrumbItem({ className, ...props }: React.ComponentProps<"li">) {
return (
<li
data-slot="breadcrumb-item"
className={cn("inline-flex items-center gap-1", className)}
{...props}
/>
)
}
function BreadcrumbLink({
asChild,
className,
...props
}: React.ComponentProps<"a"> & {
asChild?: boolean
}) {
const Comp = asChild ? Slot.Root : "a"
return (
<Comp
data-slot="breadcrumb-link"
className={cn("transition-colors hover:text-foreground", className)}
{...props}
/>
)
}
function BreadcrumbPage({ className, ...props }: React.ComponentProps<"span">) {
return (
<span
data-slot="breadcrumb-page"
role="link"
aria-disabled="true"
aria-current="page"
className={cn("font-normal text-foreground", className)}
{...props}
/>
)
}
function BreadcrumbSeparator({
children,
className,
...props
}: React.ComponentProps<"li">) {
return (
<li
data-slot="breadcrumb-separator"
role="presentation"
aria-hidden="true"
className={cn("[&>svg]:size-3.5", className)}
{...props}
>
{children ?? (
<ChevronRightIcon />
)}
</li>
)
}
function BreadcrumbEllipsis({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="breadcrumb-ellipsis"
role="presentation"
aria-hidden="true"
className={cn(
"flex size-5 items-center justify-center [&>svg]:size-4",
className
)}
{...props}
>
<MoreHorizontalIcon
/>
<span className="sr-only">More</span>
</span>
)
}
export {
Breadcrumb,
BreadcrumbList,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbPage,
BreadcrumbSeparator,
BreadcrumbEllipsis,
}
+60
View File
@@ -0,0 +1,60 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { Slot } from "radix-ui"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 rounded-lg border border-transparent bg-clip-padding text-sm font-medium focus-visible:ring-3 aria-invalid:ring-3 [&_svg:not([class*='size-'])]:size-4 inline-flex items-center justify-center whitespace-nowrap transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none shrink-0 [&_svg]:shrink-0 outline-none group/button select-none",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
outline: "border-border bg-background hover:bg-muted hover:text-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50 aria-expanded:bg-muted aria-expanded:text-foreground",
secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80 aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
ghost: "hover:bg-muted hover:text-foreground dark:hover:bg-muted/50 aria-expanded:bg-muted aria-expanded:text-foreground",
destructive: "bg-destructive/10 hover:bg-destructive/20 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/20 text-destructive focus-visible:border-destructive/40 dark:hover:bg-destructive/30",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3",
sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
lg: "h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-3 has-data-[icon=inline-start]:pl-3",
icon: "size-8",
"icon-xs": "size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3",
"icon-sm": "size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg",
"icon-lg": "size-9",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function Button({
className,
variant = "default",
size = "default",
asChild = false,
...props
}: React.ComponentProps<"button"> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean
}) {
const Comp = asChild ? Slot.Root : "button"
return (
<Comp
data-slot="button"
data-variant={variant}
data-size={size}
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
)
}
export { Button, buttonVariants }
+94
View File
@@ -0,0 +1,94 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Card({
className,
size = "default",
...props
}: React.ComponentProps<"div"> & { size?: "default" | "sm" }) {
return (
<div
data-slot="card"
data-size={size}
className={cn("ring-foreground/10 bg-card text-card-foreground gap-4 overflow-hidden rounded-xl py-4 text-sm ring-1 has-data-[slot=card-footer]:pb-0 has-[>img:first-child]:pt-0 data-[size=sm]:gap-3 data-[size=sm]:py-3 data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl group/card flex flex-col", className)}
{...props}
/>
)
}
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-header"
className={cn(
"gap-1 rounded-t-xl px-4 group-data-[size=sm]/card:px-3 [.border-b]:pb-4 group-data-[size=sm]/card:[.border-b]:pb-3 group/card-header @container/card-header grid auto-rows-min items-start has-data-[slot=card-action]:grid-cols-[1fr_auto] has-data-[slot=card-description]:grid-rows-[auto_auto]",
className
)}
{...props}
/>
)
}
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-title"
className={cn("text-base leading-snug font-medium group-data-[size=sm]/card:text-sm", className)}
{...props}
/>
)
}
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-action"
className={cn(
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
className
)}
{...props}
/>
)
}
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-content"
className={cn("px-4 group-data-[size=sm]/card:px-3", className)}
{...props}
/>
)
}
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-footer"
className={cn("bg-muted/50 rounded-b-xl border-t p-4 group-data-[size=sm]/card:p-3 flex items-center", className)}
{...props}
/>
)
}
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardAction,
CardDescription,
CardContent,
}
+31
View File
@@ -0,0 +1,31 @@
import * as React from "react"
import { Checkbox as CheckboxPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
import { CheckIcon } from "lucide-react"
function Checkbox({
className,
...props
}: React.ComponentProps<typeof CheckboxPrimitive.Root>) {
return (
<CheckboxPrimitive.Root
data-slot="checkbox"
className={cn(
"peer relative flex size-4 shrink-0 items-center justify-center rounded-[4px] border border-input transition-colors outline-none group-has-disabled/field:opacity-50 after:absolute after:-inset-x-3 after:-inset-y-2 focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 aria-invalid:aria-checked:border-primary dark:bg-input/30 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 data-checked:border-primary data-checked:bg-primary data-checked:text-primary-foreground dark:data-checked:bg-primary",
className
)}
{...props}
>
<CheckboxPrimitive.Indicator
data-slot="checkbox-indicator"
className="grid place-content-center text-current transition-none [&>svg]:size-3.5"
>
<CheckIcon
/>
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
)
}
export { Checkbox }
@@ -0,0 +1,261 @@
import * as React from "react"
import { ContextMenu as ContextMenuPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
import { ChevronRightIcon, CheckIcon } from "lucide-react"
function ContextMenu({
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Root>) {
return <ContextMenuPrimitive.Root data-slot="context-menu" {...props} />
}
function ContextMenuTrigger({
className,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Trigger>) {
return (
<ContextMenuPrimitive.Trigger
data-slot="context-menu-trigger"
className={cn("select-none", className)}
{...props}
/>
)
}
function ContextMenuGroup({
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Group>) {
return (
<ContextMenuPrimitive.Group data-slot="context-menu-group" {...props} />
)
}
function ContextMenuPortal({
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Portal>) {
return (
<ContextMenuPrimitive.Portal data-slot="context-menu-portal" {...props} />
)
}
function ContextMenuSub({
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Sub>) {
return <ContextMenuPrimitive.Sub data-slot="context-menu-sub" {...props} />
}
function ContextMenuRadioGroup({
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.RadioGroup>) {
return (
<ContextMenuPrimitive.RadioGroup
data-slot="context-menu-radio-group"
{...props}
/>
)
}
function ContextMenuContent({
className,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Content> & {
side?: "top" | "right" | "bottom" | "left"
}) {
return (
<ContextMenuPrimitive.Portal>
<ContextMenuPrimitive.Content
data-slot="context-menu-content"
className={cn("z-50 max-h-(--radix-context-menu-content-available-height) min-w-36 origin-(--radix-context-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-lg bg-popover p-1 text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", className )}
{...props}
/>
</ContextMenuPrimitive.Portal>
)
}
function ContextMenuItem({
className,
inset,
variant = "default",
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Item> & {
inset?: boolean
variant?: "default" | "destructive"
}) {
return (
<ContextMenuPrimitive.Item
data-slot="context-menu-item"
data-inset={inset}
data-variant={variant}
className={cn(
"group/context-menu-item relative flex cursor-default items-center gap-1.5 rounded-md px-1.5 py-1 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-inset:pl-7 data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 focus:*:[svg]:text-accent-foreground data-[variant=destructive]:*:[svg]:text-destructive",
className
)}
{...props}
/>
)
}
function ContextMenuSubTrigger({
className,
inset,
children,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.SubTrigger> & {
inset?: boolean
}) {
return (
<ContextMenuPrimitive.SubTrigger
data-slot="context-menu-sub-trigger"
data-inset={inset}
className={cn(
"flex cursor-default items-center gap-1.5 rounded-md px-1.5 py-1 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-inset:pl-7 data-open:bg-accent data-open:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
{children}
<ChevronRightIcon className="ml-auto" />
</ContextMenuPrimitive.SubTrigger>
)
}
function ContextMenuSubContent({
className,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.SubContent>) {
return (
<ContextMenuPrimitive.SubContent
data-slot="context-menu-sub-content"
className={cn("z-50 min-w-32 origin-(--radix-context-menu-content-transform-origin) overflow-hidden rounded-lg border bg-popover p-1 text-popover-foreground shadow-lg duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", className )}
{...props}
/>
)
}
function ContextMenuCheckboxItem({
className,
children,
checked,
inset,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.CheckboxItem> & {
inset?: boolean
}) {
return (
<ContextMenuPrimitive.CheckboxItem
data-slot="context-menu-checkbox-item"
data-inset={inset}
className={cn(
"relative flex cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-inset:pl-7 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
checked={checked}
{...props}
>
<span className="pointer-events-none absolute right-2">
<ContextMenuPrimitive.ItemIndicator>
<CheckIcon
/>
</ContextMenuPrimitive.ItemIndicator>
</span>
{children}
</ContextMenuPrimitive.CheckboxItem>
)
}
function ContextMenuRadioItem({
className,
children,
inset,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.RadioItem> & {
inset?: boolean
}) {
return (
<ContextMenuPrimitive.RadioItem
data-slot="context-menu-radio-item"
data-inset={inset}
className={cn(
"relative flex cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-inset:pl-7 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<span className="pointer-events-none absolute right-2">
<ContextMenuPrimitive.ItemIndicator>
<CheckIcon
/>
</ContextMenuPrimitive.ItemIndicator>
</span>
{children}
</ContextMenuPrimitive.RadioItem>
)
}
function ContextMenuLabel({
className,
inset,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Label> & {
inset?: boolean
}) {
return (
<ContextMenuPrimitive.Label
data-slot="context-menu-label"
data-inset={inset}
className={cn(
"px-1.5 py-1 text-xs font-medium text-muted-foreground data-inset:pl-7",
className
)}
{...props}
/>
)
}
function ContextMenuSeparator({
className,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Separator>) {
return (
<ContextMenuPrimitive.Separator
data-slot="context-menu-separator"
className={cn("-mx-1 my-1 h-px bg-border", className)}
{...props}
/>
)
}
function ContextMenuShortcut({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="context-menu-shortcut"
className={cn(
"ml-auto text-xs tracking-widest text-muted-foreground group-focus/context-menu-item:text-accent-foreground",
className
)}
{...props}
/>
)
}
export {
ContextMenu,
ContextMenuTrigger,
ContextMenuContent,
ContextMenuItem,
ContextMenuCheckboxItem,
ContextMenuRadioItem,
ContextMenuLabel,
ContextMenuSeparator,
ContextMenuShortcut,
ContextMenuGroup,
ContextMenuPortal,
ContextMenuSub,
ContextMenuSubContent,
ContextMenuSubTrigger,
ContextMenuRadioGroup,
}
+155
View File
@@ -0,0 +1,155 @@
"use client"
import * as React from "react"
import { Dialog as DialogPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { XIcon } from "lucide-react"
function Dialog({
...props
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
return <DialogPrimitive.Root data-slot="dialog" {...props} />
}
function DialogTrigger({
...props
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
}
function DialogPortal({
...props
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
}
function DialogClose({
...props
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
}
function DialogOverlay({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
return (
<DialogPrimitive.Overlay
data-slot="dialog-overlay"
className={cn("data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs fixed inset-0 isolate z-50", className)}
{...props}
/>
)
}
function DialogContent({
className,
children,
showCloseButton = true,
...props
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
showCloseButton?: boolean
}) {
return (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
data-slot="dialog-content"
className={cn(
"bg-background data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 ring-foreground/10 grid max-w-[calc(100%-2rem)] gap-4 rounded-xl p-4 text-sm ring-1 duration-100 sm:max-w-sm fixed top-1/2 left-1/2 z-50 w-full -translate-x-1/2 -translate-y-1/2 outline-none",
className
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close data-slot="dialog-close" asChild>
<Button variant="ghost" className="absolute top-2 right-2" size="icon-sm">
<XIcon
/>
<span className="sr-only">Close</span>
</Button>
</DialogPrimitive.Close>
)}
</DialogPrimitive.Content>
</DialogPortal>
)
}
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-header"
className={cn("gap-2 flex flex-col", className)}
{...props}
/>
)
}
function DialogFooter({
className,
showCloseButton = false,
children,
...props
}: React.ComponentProps<"div"> & {
showCloseButton?: boolean
}) {
return (
<div
data-slot="dialog-footer"
className={cn(
"bg-muted/50 -mx-4 -mb-4 rounded-b-xl border-t p-4 flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
className
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close asChild>
<Button variant="outline">Close</Button>
</DialogPrimitive.Close>
)}
</div>
)
}
function DialogTitle({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
return (
<DialogPrimitive.Title
data-slot="dialog-title"
className={cn("text-base leading-none font-medium", className)}
{...props}
/>
)
}
function DialogDescription({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
return (
<DialogPrimitive.Description
data-slot="dialog-description"
className={cn("text-muted-foreground *:[a]:hover:text-foreground text-sm *:[a]:underline *:[a]:underline-offset-3", className)}
{...props}
/>
)
}
export {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogOverlay,
DialogPortal,
DialogTitle,
DialogTrigger,
}
@@ -0,0 +1,261 @@
import * as React from "react"
import { DropdownMenu as DropdownMenuPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
import { CheckIcon, ChevronRightIcon } from "lucide-react"
function DropdownMenu({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />
}
function DropdownMenuPortal({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
return (
<DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
)
}
function DropdownMenuTrigger({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {
return (
<DropdownMenuPrimitive.Trigger
data-slot="dropdown-menu-trigger"
{...props}
/>
)
}
function DropdownMenuContent({
className,
align = "start",
sideOffset = 4,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
return (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
data-slot="dropdown-menu-content"
sideOffset={sideOffset}
align={align}
className={cn("data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 ring-foreground/10 bg-popover text-popover-foreground min-w-32 rounded-lg p-1 shadow-md ring-1 duration-100 z-50 max-h-(--radix-dropdown-menu-content-available-height) w-(--radix-dropdown-menu-trigger-width) origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto data-[state=closed]:overflow-hidden", className )}
{...props}
/>
</DropdownMenuPrimitive.Portal>
)
}
function DropdownMenuGroup({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
return (
<DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
)
}
function DropdownMenuItem({
className,
inset,
variant = "default",
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean
variant?: "default" | "destructive"
}) {
return (
<DropdownMenuPrimitive.Item
data-slot="dropdown-menu-item"
data-inset={inset}
data-variant={variant}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:text-destructive not-data-[variant=destructive]:focus:**:text-accent-foreground gap-1.5 rounded-md px-1.5 py-1 text-sm data-inset:pl-7 [&_svg:not([class*='size-'])]:size-4 group/dropdown-menu-item relative flex cursor-default items-center outline-hidden select-none data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0",
className
)}
{...props}
/>
)
}
function DropdownMenuCheckboxItem({
className,
children,
checked,
inset,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem> & {
inset?: boolean
}) {
return (
<DropdownMenuPrimitive.CheckboxItem
data-slot="dropdown-menu-checkbox-item"
data-inset={inset}
className={cn(
"focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm data-inset:pl-7 [&_svg:not([class*='size-'])]:size-4 relative flex cursor-default items-center outline-hidden select-none data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0",
className
)}
checked={checked}
{...props}
>
<span
className="absolute right-2 flex items-center justify-center pointer-events-none"
data-slot="dropdown-menu-checkbox-item-indicator"
>
<DropdownMenuPrimitive.ItemIndicator>
<CheckIcon
/>
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
)
}
function DropdownMenuRadioGroup({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {
return (
<DropdownMenuPrimitive.RadioGroup
data-slot="dropdown-menu-radio-group"
{...props}
/>
)
}
function DropdownMenuRadioItem({
className,
children,
inset,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem> & {
inset?: boolean
}) {
return (
<DropdownMenuPrimitive.RadioItem
data-slot="dropdown-menu-radio-item"
data-inset={inset}
className={cn(
"focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm data-inset:pl-7 [&_svg:not([class*='size-'])]:size-4 relative flex cursor-default items-center outline-hidden select-none data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0",
className
)}
{...props}
>
<span
className="absolute right-2 flex items-center justify-center pointer-events-none"
data-slot="dropdown-menu-radio-item-indicator"
>
<DropdownMenuPrimitive.ItemIndicator>
<CheckIcon
/>
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
)
}
function DropdownMenuLabel({
className,
inset,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean
}) {
return (
<DropdownMenuPrimitive.Label
data-slot="dropdown-menu-label"
data-inset={inset}
className={cn("text-muted-foreground px-1.5 py-1 text-xs font-medium data-inset:pl-7", className)}
{...props}
/>
)
}
function DropdownMenuSeparator({
className,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) {
return (
<DropdownMenuPrimitive.Separator
data-slot="dropdown-menu-separator"
className={cn("bg-border -mx-1 my-1 h-px", className)}
{...props}
/>
)
}
function DropdownMenuShortcut({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="dropdown-menu-shortcut"
className={cn("text-muted-foreground group-focus/dropdown-menu-item:text-accent-foreground ml-auto text-xs tracking-widest", className)}
{...props}
/>
)
}
function DropdownMenuSub({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />
}
function DropdownMenuSubTrigger({
className,
inset,
children,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean
}) {
return (
<DropdownMenuPrimitive.SubTrigger
data-slot="dropdown-menu-sub-trigger"
data-inset={inset}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-open:bg-accent data-open:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground gap-1.5 rounded-md px-1.5 py-1 text-sm data-inset:pl-7 [&_svg:not([class*='size-'])]:size-4 flex cursor-default items-center outline-hidden select-none [&_svg]:pointer-events-none [&_svg]:shrink-0",
className
)}
{...props}
>
{children}
<ChevronRightIcon className="ml-auto" />
</DropdownMenuPrimitive.SubTrigger>
)
}
function DropdownMenuSubContent({
className,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {
return (
<DropdownMenuPrimitive.SubContent
data-slot="dropdown-menu-sub-content"
className={cn("data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 ring-foreground/10 bg-popover text-popover-foreground min-w-[96px] rounded-lg p-1 shadow-lg ring-1 duration-100 z-50 origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden", className )}
{...props}
/>
)
}
export {
DropdownMenu,
DropdownMenuPortal,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuLabel,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuSub,
DropdownMenuSubTrigger,
DropdownMenuSubContent,
}
+19
View File
@@ -0,0 +1,19 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
return (
<input
type={type}
data-slot="input"
className={cn(
"dark:bg-input/30 border-input focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 disabled:bg-input/50 dark:disabled:bg-input/80 h-8 rounded-lg border bg-transparent px-2.5 py-1 text-base transition-colors file:h-6 file:text-sm file:font-medium focus-visible:ring-3 aria-invalid:ring-3 md:text-sm file:text-foreground placeholder:text-muted-foreground w-full min-w-0 outline-none file:inline-flex file:border-0 file:bg-transparent disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
/>
)
}
export { Input }
+22
View File
@@ -0,0 +1,22 @@
import * as React from "react"
import { Label as LabelPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function Label({
className,
...props
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
return (
<LabelPrimitive.Root
data-slot="label"
className={cn(
"gap-2 text-sm leading-none font-medium group-data-[disabled=true]:opacity-50 peer-disabled:opacity-50 flex items-center select-none group-data-[disabled=true]:pointer-events-none peer-disabled:cursor-not-allowed",
className
)}
{...props}
/>
)
}
export { Label }
+31
View File
@@ -0,0 +1,31 @@
"use client"
import * as React from "react"
import { Progress as ProgressPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function Progress({
className,
value,
...props
}: React.ComponentProps<typeof ProgressPrimitive.Root>) {
return (
<ProgressPrimitive.Root
data-slot="progress"
className={cn(
"bg-muted h-1 rounded-full relative flex w-full items-center overflow-x-hidden",
className
)}
{...props}
>
<ProgressPrimitive.Indicator
data-slot="progress-indicator"
className="bg-primary size-full flex-1 transition-all"
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
/>
</ProgressPrimitive.Root>
)
}
export { Progress }
@@ -0,0 +1,42 @@
import * as React from "react"
import { RadioGroup as RadioGroupPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function RadioGroup({
className,
...props
}: React.ComponentProps<typeof RadioGroupPrimitive.Root>) {
return (
<RadioGroupPrimitive.Root
data-slot="radio-group"
className={cn("grid w-full gap-2", className)}
{...props}
/>
)
}
function RadioGroupItem({
className,
...props
}: React.ComponentProps<typeof RadioGroupPrimitive.Item>) {
return (
<RadioGroupPrimitive.Item
data-slot="radio-group-item"
className={cn(
"group/radio-group-item peer relative flex aspect-square size-4 shrink-0 rounded-full border border-input outline-none after:absolute after:-inset-x-3 after:-inset-y-2 focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 aria-invalid:aria-checked:border-primary dark:bg-input/30 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 data-checked:border-primary data-checked:bg-primary data-checked:text-primary-foreground dark:data-checked:bg-primary",
className
)}
{...props}
>
<RadioGroupPrimitive.Indicator
data-slot="radio-group-indicator"
className="flex size-4 items-center justify-center"
>
<span className="absolute top-1/2 left-1/2 size-2 -translate-x-1/2 -translate-y-1/2 rounded-full bg-primary-foreground" />
</RadioGroupPrimitive.Indicator>
</RadioGroupPrimitive.Item>
)
}
export { RadioGroup, RadioGroupItem }
@@ -0,0 +1,53 @@
import * as React from "react"
import { ScrollArea as ScrollAreaPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function ScrollArea({
className,
children,
...props
}: React.ComponentProps<typeof ScrollAreaPrimitive.Root>) {
return (
<ScrollAreaPrimitive.Root
data-slot="scroll-area"
className={cn("relative", className)}
{...props}
>
<ScrollAreaPrimitive.Viewport
data-slot="scroll-area-viewport"
className="focus-visible:ring-ring/50 size-full rounded-[inherit] transition-[color,box-shadow] outline-none focus-visible:ring-[3px] focus-visible:outline-1"
>
{children}
</ScrollAreaPrimitive.Viewport>
<ScrollBar />
<ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root>
)
}
function ScrollBar({
className,
orientation = "vertical",
...props
}: React.ComponentProps<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>) {
return (
<ScrollAreaPrimitive.ScrollAreaScrollbar
data-slot="scroll-area-scrollbar"
data-orientation={orientation}
orientation={orientation}
className={cn(
"data-horizontal:h-2.5 data-horizontal:flex-col data-horizontal:border-t data-horizontal:border-t-transparent data-vertical:h-full data-vertical:w-2.5 data-vertical:border-l data-vertical:border-l-transparent flex touch-none p-px transition-colors select-none",
className
)}
{...props}
>
<ScrollAreaPrimitive.ScrollAreaThumb
data-slot="scroll-area-thumb"
className="rounded-full bg-border relative flex-1"
/>
</ScrollAreaPrimitive.ScrollAreaScrollbar>
)
}
export { ScrollArea, ScrollBar }
+186
View File
@@ -0,0 +1,186 @@
"use client"
import * as React from "react"
import { Select as SelectPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
import { ChevronDownIcon, CheckIcon, ChevronUpIcon } from "lucide-react"
function Select({
...props
}: React.ComponentProps<typeof SelectPrimitive.Root>) {
return <SelectPrimitive.Root data-slot="select" {...props} />
}
function SelectGroup({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Group>) {
return (
<SelectPrimitive.Group
data-slot="select-group"
className={cn("scroll-my-1 p-1", className)}
{...props}
/>
)
}
function SelectValue({
...props
}: React.ComponentProps<typeof SelectPrimitive.Value>) {
return <SelectPrimitive.Value data-slot="select-value" {...props} />
}
function SelectTrigger({
className,
size = "default",
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
size?: "sm" | "default"
}) {
return (
<SelectPrimitive.Trigger
data-slot="select-trigger"
data-size={size}
className={cn(
"border-input data-placeholder:text-muted-foreground dark:bg-input/30 dark:hover:bg-input/50 focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 gap-1.5 rounded-lg border bg-transparent py-2 pr-2 pl-2.5 text-sm transition-colors select-none focus-visible:ring-3 aria-invalid:ring-3 data-[size=default]:h-8 data-[size=sm]:h-7 data-[size=sm]:rounded-[min(var(--radius-md),10px)] *:data-[slot=select-value]:gap-1.5 [&_svg:not([class*='size-'])]:size-4 flex w-fit items-center justify-between whitespace-nowrap outline-none disabled:cursor-not-allowed disabled:opacity-50 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center [&_svg]:pointer-events-none [&_svg]:shrink-0",
className
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDownIcon className="text-muted-foreground size-4 pointer-events-none" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
)
}
function SelectContent({
className,
children,
position = "item-aligned",
align = "center",
...props
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
return (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
data-slot="select-content"
data-align-trigger={position === "item-aligned"}
className={cn("bg-popover text-popover-foreground data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 ring-foreground/10 min-w-36 rounded-lg shadow-md ring-1 duration-100 relative z-50 max-h-(--radix-select-content-available-height) origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto data-[align-trigger=true]:animate-none", position ==="popper"&&"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1", className )}
position={position}
align={align}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
data-position={position}
className={cn(
"data-[position=popper]:h-(--radix-select-trigger-height) data-[position=popper]:w-full data-[position=popper]:min-w-(--radix-select-trigger-width)",
position === "popper" && ""
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
)
}
function SelectLabel({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Label>) {
return (
<SelectPrimitive.Label
data-slot="select-label"
className={cn("text-muted-foreground px-1.5 py-1 text-xs", className)}
{...props}
/>
)
}
function SelectItem({
className,
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
return (
<SelectPrimitive.Item
data-slot="select-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2 relative flex w-full cursor-default items-center outline-hidden select-none data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0",
className
)}
{...props}
>
<span className="pointer-events-none absolute right-2 flex size-4 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<CheckIcon className="pointer-events-none" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
)
}
function SelectSeparator({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Separator>) {
return (
<SelectPrimitive.Separator
data-slot="select-separator"
className={cn("bg-border -mx-1 my-1 h-px pointer-events-none", className)}
{...props}
/>
)
}
function SelectScrollUpButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
return (
<SelectPrimitive.ScrollUpButton
data-slot="select-scroll-up-button"
className={cn("bg-popover z-10 flex cursor-default items-center justify-center py-1 [&_svg:not([class*='size-'])]:size-4", className)}
{...props}
>
<ChevronUpIcon
/>
</SelectPrimitive.ScrollUpButton>
)
}
function SelectScrollDownButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
return (
<SelectPrimitive.ScrollDownButton
data-slot="select-scroll-down-button"
className={cn("bg-popover z-10 flex cursor-default items-center justify-center py-1 [&_svg:not([class*='size-'])]:size-4", className)}
{...props}
>
<ChevronDownIcon
/>
</SelectPrimitive.ScrollDownButton>
)
}
export {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectScrollDownButton,
SelectScrollUpButton,
SelectSeparator,
SelectTrigger,
SelectValue,
}
@@ -0,0 +1,26 @@
import * as React from "react"
import { Separator as SeparatorPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function Separator({
className,
orientation = "horizontal",
decorative = true,
...props
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
return (
<SeparatorPrimitive.Root
data-slot="separator"
decorative={decorative}
orientation={orientation}
className={cn(
"bg-border shrink-0 data-horizontal:h-px data-horizontal:w-full data-vertical:w-px data-vertical:self-stretch",
className
)}
{...props}
/>
)
}
export { Separator }
+13
View File
@@ -0,0 +1,13 @@
import { cn } from "@/lib/utils"
function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="skeleton"
className={cn("bg-muted rounded-md animate-pulse", className)}
{...props}
/>
)
}
export { Skeleton }
+57
View File
@@ -0,0 +1,57 @@
import * as React from "react"
import { Slider as SliderPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function Slider({
className,
defaultValue,
value,
min = 0,
max = 100,
...props
}: React.ComponentProps<typeof SliderPrimitive.Root>) {
const _values = React.useMemo(
() =>
Array.isArray(value)
? value
: Array.isArray(defaultValue)
? defaultValue
: [min, max],
[value, defaultValue, min, max]
)
return (
<SliderPrimitive.Root
data-slot="slider"
defaultValue={defaultValue}
value={value}
min={min}
max={max}
className={cn(
"relative flex w-full touch-none items-center select-none data-disabled:opacity-50 data-vertical:h-full data-vertical:min-h-40 data-vertical:w-auto data-vertical:flex-col",
className
)}
{...props}
>
<SliderPrimitive.Track
data-slot="slider-track"
className="relative grow overflow-hidden rounded-full bg-muted data-horizontal:h-1 data-horizontal:w-full data-vertical:h-full data-vertical:w-1"
>
<SliderPrimitive.Range
data-slot="slider-range"
className="absolute bg-primary select-none data-horizontal:h-full data-vertical:w-full"
/>
</SliderPrimitive.Track>
{Array.from({ length: _values.length }, (_, index) => (
<SliderPrimitive.Thumb
data-slot="slider-thumb"
key={index}
className="relative block size-3 shrink-0 rounded-full border border-ring bg-white ring-ring/50 transition-[color,box-shadow] select-none after:absolute after:-inset-2 hover:ring-3 focus-visible:ring-3 focus-visible:outline-hidden active:ring-3 disabled:pointer-events-none disabled:opacity-50"
/>
))}
</SliderPrimitive.Root>
)
}
export { Slider }
+44
View File
@@ -0,0 +1,44 @@
import { Toaster as Sonner, type ToasterProps } from "sonner"
import { CircleCheckIcon, InfoIcon, TriangleAlertIcon, OctagonXIcon, Loader2Icon } from "lucide-react"
const Toaster = ({ ...props }: ToasterProps) => {
return (
<Sonner
theme="dark"
className="toaster group"
icons={{
success: (
<CircleCheckIcon className="size-4" />
),
info: (
<InfoIcon className="size-4" />
),
warning: (
<TriangleAlertIcon className="size-4" />
),
error: (
<OctagonXIcon className="size-4" />
),
loading: (
<Loader2Icon className="size-4 animate-spin" />
),
}}
style={
{
"--normal-bg": "var(--popover)",
"--normal-text": "var(--popover-foreground)",
"--normal-border": "var(--border)",
"--border-radius": "var(--radius)",
} as React.CSSProperties
}
toastOptions={{
classNames: {
toast: "cn-toast",
},
}}
{...props}
/>
)
}
export { Toaster }
+31
View File
@@ -0,0 +1,31 @@
import * as React from "react"
import { Switch as SwitchPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function Switch({
className,
size = "default",
...props
}: React.ComponentProps<typeof SwitchPrimitive.Root> & {
size?: "sm" | "default"
}) {
return (
<SwitchPrimitive.Root
data-slot="switch"
data-size={size}
className={cn(
"peer group/switch relative inline-flex shrink-0 items-center rounded-full border border-transparent transition-all outline-none after:absolute after:-inset-x-3 after:-inset-y-2 focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 data-[size=default]:h-[18.4px] data-[size=default]:w-[32px] data-[size=sm]:h-[14px] data-[size=sm]:w-[24px] dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 data-checked:bg-primary data-unchecked:bg-input dark:data-unchecked:bg-input/80 data-disabled:cursor-not-allowed data-disabled:opacity-50",
className
)}
{...props}
>
<SwitchPrimitive.Thumb
data-slot="switch-thumb"
className="pointer-events-none block rounded-full bg-background ring-0 transition-transform group-data-[size=default]/switch:size-4 group-data-[size=sm]/switch:size-3 group-data-[size=default]/switch:data-checked:translate-x-[calc(100%-2px)] group-data-[size=sm]/switch:data-checked:translate-x-[calc(100%-2px)] dark:data-checked:bg-primary-foreground group-data-[size=default]/switch:data-unchecked:translate-x-0 group-data-[size=sm]/switch:data-unchecked:translate-x-0 dark:data-unchecked:bg-foreground"
/>
</SwitchPrimitive.Root>
)
}
export { Switch }
+90
View File
@@ -0,0 +1,90 @@
"use client"
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { Tabs as TabsPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function Tabs({
className,
orientation = "horizontal",
...props
}: React.ComponentProps<typeof TabsPrimitive.Root>) {
return (
<TabsPrimitive.Root
data-slot="tabs"
data-orientation={orientation}
className={cn(
"group/tabs flex gap-2 data-horizontal:flex-col",
className
)}
{...props}
/>
)
}
const tabsListVariants = cva(
"group/tabs-list inline-flex w-fit items-center justify-center rounded-lg p-[3px] text-muted-foreground group-data-horizontal/tabs:h-8 group-data-vertical/tabs:h-fit group-data-vertical/tabs:flex-col data-[variant=line]:rounded-none",
{
variants: {
variant: {
default: "bg-muted",
line: "gap-1 bg-transparent",
},
},
defaultVariants: {
variant: "default",
},
}
)
function TabsList({
className,
variant = "default",
...props
}: React.ComponentProps<typeof TabsPrimitive.List> &
VariantProps<typeof tabsListVariants>) {
return (
<TabsPrimitive.List
data-slot="tabs-list"
data-variant={variant}
className={cn(tabsListVariants({ variant }), className)}
{...props}
/>
)
}
function TabsTrigger({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Trigger>) {
return (
<TabsPrimitive.Trigger
data-slot="tabs-trigger"
className={cn(
"relative inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-1.5 py-0.5 text-sm font-medium whitespace-nowrap text-foreground/60 transition-all group-data-vertical/tabs:w-full group-data-vertical/tabs:justify-start hover:text-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1 focus-visible:outline-ring disabled:pointer-events-none disabled:opacity-50 has-data-[icon=inline-end]:pr-1 has-data-[icon=inline-start]:pl-1 dark:text-muted-foreground dark:hover:text-foreground group-data-[variant=default]/tabs-list:data-active:shadow-sm group-data-[variant=line]/tabs-list:data-active:shadow-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
"group-data-[variant=line]/tabs-list:bg-transparent group-data-[variant=line]/tabs-list:data-active:bg-transparent dark:group-data-[variant=line]/tabs-list:data-active:border-transparent dark:group-data-[variant=line]/tabs-list:data-active:bg-transparent",
"data-active:bg-background data-active:text-foreground dark:data-active:border-input dark:data-active:bg-input/30 dark:data-active:text-foreground",
"after:absolute after:bg-foreground after:opacity-0 after:transition-opacity group-data-horizontal/tabs:after:inset-x-0 group-data-horizontal/tabs:after:bottom-[-5px] group-data-horizontal/tabs:after:h-0.5 group-data-vertical/tabs:after:inset-y-0 group-data-vertical/tabs:after:-right-1 group-data-vertical/tabs:after:w-0.5 group-data-[variant=line]/tabs-list:data-active:after:opacity-100",
className
)}
{...props}
/>
)
}
function TabsContent({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Content>) {
return (
<TabsPrimitive.Content
data-slot="tabs-content"
className={cn("flex-1 text-sm outline-none", className)}
{...props}
/>
)
}
export { Tabs, TabsList, TabsTrigger, TabsContent, tabsListVariants }
@@ -0,0 +1,87 @@
import * as React from "react"
import { type VariantProps } from "class-variance-authority"
import { ToggleGroup as ToggleGroupPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
import { toggleVariants } from "@/components/ui/toggle"
const ToggleGroupContext = React.createContext<
VariantProps<typeof toggleVariants> & {
spacing?: number
orientation?: "horizontal" | "vertical"
}
>({
size: "default",
variant: "default",
spacing: 0,
orientation: "horizontal",
})
function ToggleGroup({
className,
variant,
size,
spacing = 0,
orientation = "horizontal",
children,
...props
}: React.ComponentProps<typeof ToggleGroupPrimitive.Root> &
VariantProps<typeof toggleVariants> & {
spacing?: number
orientation?: "horizontal" | "vertical"
}) {
return (
<ToggleGroupPrimitive.Root
data-slot="toggle-group"
data-variant={variant}
data-size={size}
data-spacing={spacing}
data-orientation={orientation}
style={{ "--gap": spacing } as React.CSSProperties}
className={cn(
"group/toggle-group flex w-fit flex-row items-center gap-[--spacing(var(--gap))] rounded-lg data-[size=sm]:rounded-[min(var(--radius-md),10px)] data-vertical:flex-col data-vertical:items-stretch",
className
)}
{...props}
>
<ToggleGroupContext.Provider
value={{ variant, size, spacing, orientation }}
>
{children}
</ToggleGroupContext.Provider>
</ToggleGroupPrimitive.Root>
)
}
function ToggleGroupItem({
className,
children,
variant = "default",
size = "default",
...props
}: React.ComponentProps<typeof ToggleGroupPrimitive.Item> &
VariantProps<typeof toggleVariants>) {
const context = React.useContext(ToggleGroupContext)
return (
<ToggleGroupPrimitive.Item
data-slot="toggle-group-item"
data-variant={context.variant || variant}
data-size={context.size || size}
data-spacing={context.spacing}
className={cn(
"shrink-0 group-data-[spacing=0]/toggle-group:rounded-none group-data-[spacing=0]/toggle-group:px-2 focus:z-10 focus-visible:z-10 group-data-horizontal/toggle-group:data-[spacing=0]:first:rounded-l-lg group-data-vertical/toggle-group:data-[spacing=0]:first:rounded-t-lg group-data-horizontal/toggle-group:data-[spacing=0]:last:rounded-r-lg group-data-vertical/toggle-group:data-[spacing=0]:last:rounded-b-lg group-data-horizontal/toggle-group:data-[spacing=0]:data-[variant=outline]:border-l-0 group-data-vertical/toggle-group:data-[spacing=0]:data-[variant=outline]:border-t-0 group-data-horizontal/toggle-group:data-[spacing=0]:data-[variant=outline]:first:border-l group-data-vertical/toggle-group:data-[spacing=0]:data-[variant=outline]:first:border-t",
toggleVariants({
variant: context.variant || variant,
size: context.size || size,
}),
className
)}
{...props}
>
{children}
</ToggleGroupPrimitive.Item>
)
}
export { ToggleGroup, ToggleGroupItem }
+44
View File
@@ -0,0 +1,44 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { Toggle as TogglePrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
const toggleVariants = cva(
"group/toggle inline-flex items-center justify-center gap-1 rounded-lg text-sm font-medium whitespace-nowrap transition-all outline-none hover:bg-muted hover:text-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 aria-pressed:bg-muted data-[state=on]:bg-muted dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
{
variants: {
variant: {
default: "bg-transparent",
outline: "border border-input bg-transparent hover:bg-muted",
},
size: {
default: "h-8 min-w-8 px-2",
sm: "h-7 min-w-7 rounded-[min(var(--radius-md),12px)] px-1.5 text-[0.8rem]",
lg: "h-9 min-w-9 px-2.5",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function Toggle({
className,
variant = "default",
size = "default",
...props
}: React.ComponentProps<typeof TogglePrimitive.Root> &
VariantProps<typeof toggleVariants>) {
return (
<TogglePrimitive.Root
data-slot="toggle"
className={cn(toggleVariants({ variant, size, className }))}
{...props}
/>
)
}
export { Toggle, toggleVariants }
+55
View File
@@ -0,0 +1,55 @@
import * as React from "react"
import { Tooltip as TooltipPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function TooltipProvider({
delayDuration = 0,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Provider>) {
return (
<TooltipPrimitive.Provider
data-slot="tooltip-provider"
delayDuration={delayDuration}
{...props}
/>
)
}
function Tooltip({
...props
}: React.ComponentProps<typeof TooltipPrimitive.Root>) {
return <TooltipPrimitive.Root data-slot="tooltip" {...props} />
}
function TooltipTrigger({
...props
}: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />
}
function TooltipContent({
className,
sideOffset = 0,
children,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Content>) {
return (
<TooltipPrimitive.Portal>
<TooltipPrimitive.Content
data-slot="tooltip-content"
sideOffset={sideOffset}
className={cn(
"data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-[state=delayed-open]:animate-in data-[state=delayed-open]:fade-in-0 data-[state=delayed-open]:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 rounded-md px-3 py-1.5 text-xs bg-foreground text-background z-50 w-fit max-w-xs origin-(--radix-tooltip-content-transform-origin)",
className
)}
{...props}
>
{children}
<TooltipPrimitive.Arrow className="size-2.5 rotate-45 rounded-[2px] bg-foreground fill-foreground z-50 translate-y-[calc(-50%_-_2px)]" />
</TooltipPrimitive.Content>
</TooltipPrimitive.Portal>
)
}
export { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger }
+147
View File
@@ -0,0 +1,147 @@
import { cn } from "@/lib/utils"
function H1({ className, ...props }: React.ComponentProps<"h1">) {
return (
<h1
className={cn(
"scroll-m-20 text-4xl font-extrabold tracking-tight text-balance",
className
)}
{...props}
/>
)
}
function H2({ className, ...props }: React.ComponentProps<"h2">) {
return (
<h2
className={cn(
"scroll-m-20 border-b pb-2 text-3xl font-semibold tracking-tight first:mt-0",
className
)}
{...props}
/>
)
}
function H3({ className, ...props }: React.ComponentProps<"h3">) {
return (
<h3
className={cn(
"scroll-m-20 text-2xl font-semibold tracking-tight",
className
)}
{...props}
/>
)
}
function H4({ className, ...props }: React.ComponentProps<"h4">) {
return (
<h4
className={cn(
"scroll-m-20 text-xl font-semibold tracking-tight",
className
)}
{...props}
/>
)
}
function P({ className, ...props }: React.ComponentProps<"p">) {
return (
<p
className={cn(
"leading-7 [&:not(:first-child)]:mt-6",
className
)}
{...props}
/>
)
}
function Blockquote({ className, ...props }: React.ComponentProps<"blockquote">) {
return (
<blockquote
className={cn(
"mt-6 border-l-2 pl-6 italic",
className
)}
{...props}
/>
)
}
function List({ className, ...props }: React.ComponentProps<"ul">) {
return (
<ul
className={cn(
"my-6 ml-6 list-disc [&>li]:mt-2",
className
)}
{...props}
/>
)
}
function InlineCode({ className, ...props }: React.ComponentProps<"code">) {
return (
<code
className={cn(
"bg-muted relative rounded px-[0.3rem] py-[0.2rem] font-mono text-sm font-semibold",
className
)}
{...props}
/>
)
}
function Lead({ className, ...props }: React.ComponentProps<"p">) {
return (
<p
className={cn(
"text-muted-foreground text-xl",
className
)}
{...props}
/>
)
}
function Large({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
className={cn(
"text-lg font-semibold",
className
)}
{...props}
/>
)
}
function Small({ className, ...props }: React.ComponentProps<"small">) {
return (
<small
className={cn(
"text-sm leading-none font-medium",
className
)}
{...props}
/>
)
}
function Muted({ className, ...props }: React.ComponentProps<"p">) {
return (
<p
className={cn(
"text-muted-foreground text-sm",
className
)}
{...props}
/>
)
}
export { H1, H2, H3, H4, P, Blockquote, List, InlineCode, Lead, Large, Small, Muted }
@@ -0,0 +1,33 @@
import { Video, Mic } from "lucide-react";
import { useMediaSettingsStore } from "@/stores/media-settings-store";
export function VideoAudioToggle() {
const recordingMode = useMediaSettingsStore((s) => s.recordingMode);
const setRecordingMode = useMediaSettingsStore((s) => s.setRecordingMode);
return (
<span
role="button"
onClick={() =>
setRecordingMode(recordingMode === "video" ? "audio" : "video")
}
title={
recordingMode === "video" ? "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
</>
) : (
<>
<Mic className="inline size-3" /> audio
</>
)}
</span>
);
}
@@ -0,0 +1,64 @@
import { useEffect, useState } from "react";
import { Copy, Minus, Square, X } from "lucide-react";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
export function WindowControls() {
const [isMaximized, setIsMaximized] = useState(false);
useEffect(() => {
return window.electronWindow.onMaximizeChange(setIsMaximized);
}, []);
const minimize = (
<Button
key="minimize"
variant="ghost"
size="icon-sm"
onClick={() => window.electronWindow.minimize()}
aria-label="Minimize"
className="dark:hover:bg-white/10 rounded text-white/50"
>
<Minus />
</Button>
);
const maximize = (
<Button
key="maximize"
variant="ghost"
size="icon-sm"
onClick={() => window.electronWindow.maximize()}
aria-label={isMaximized ? "Restore" : "Maximize"}
className="dark:hover:bg-white/10 rounded text-white/50"
>
{isMaximized ? <Copy /> : <Square />}
</Button>
);
const close = (
<Button
key="close"
variant="ghost"
size="icon-sm"
onClick={() => window.electronWindow.close()}
aria-label="Close"
className="hover:text-destructive dark:hover:bg-white/10 rounded text-white/50"
>
<X />
</Button>
);
const buttons = [close, maximize, minimize];
return (
<div
className={cn(
"no-drag flex items-center gap-0.5"
)}
>
{buttons}
</div>
);
}
+60
View File
@@ -0,0 +1,60 @@
// Build-time environment selection.
//
// __APP_ENV__ is replaced by Vite via `define` in each vite.*.config.ts. Selected
// via the APP_ENV env var at build/dev time (defaults to 'dev'). `yarn package`,
// `yarn make`, and `yarn release` set APP_ENV=prod.
//
// Firebase web config is public by design (security is enforced via Firestore
// rules + App Check), so both configs live in source. To refresh, run:
// cd infra/gcp/{dev,prod} && terraform output -json firebase_config
declare const __APP_ENV__: "dev" | "prod";
type FirebaseConfig = {
apiKey: string;
appId: string;
authDomain: string;
messagingSenderId: string;
projectId: string;
storageBucket: string;
};
type AppConfig = {
orionUrl: string;
pusherUrl: string;
firebase: FirebaseConfig;
/** Empty string disables Sentry. Same DSN across envs; events are split by `environment` tag. */
sentryDsn: string;
};
const configs: Record<"dev" | "prod", AppConfig> = {
dev: {
orionUrl: "https://orion.dev.flowy.live",
pusherUrl: "wss://pusher.dev.flowy.live/ws",
firebase: {
apiKey: "AIzaSyDF_fbk7tDY9tNxUiOwwi--2nYZWZygMGk",
appId: "1:1006580076785:web:e2a0736d60a78e02b15950",
authDomain: "flowy-dev-440017.firebaseapp.com",
messagingSenderId: "1006580076785",
projectId: "flowy-dev-440017",
storageBucket: "flowy-dev-440017.firebasestorage.app",
},
sentryDsn: "https://4bb3af832825929eeec8ab4edd56930f@o4511282312118272.ingest.us.sentry.io/4511282313494528",
},
prod: {
orionUrl: "https://orion.flowy.live",
pusherUrl: "wss://pusher.flowy.live/ws",
firebase: {
apiKey: "AIzaSyB8it3SKr9DHScHGlpu4uwWicvt8wzjdBg",
appId: "1:68063426854:web:5054f16f50898f5706e9e7",
authDomain: "flowy-prod-440017.firebaseapp.com",
messagingSenderId: "68063426854",
projectId: "flowy-prod-440017",
storageBucket: "flowy-prod-440017.firebasestorage.app",
},
sentryDsn: "https://4bb3af832825929eeec8ab4edd56930f@o4511282312118272.ingest.us.sentry.io/4511282313494528",
},
};
export const appConfig: AppConfig = configs[__APP_ENV__];
export const appEnv: "dev" | "prod" = __APP_ENV__;
+58
View File
@@ -0,0 +1,58 @@
import type { LinkMetadata } from './lib/link-metadata';
import type { AutoplayPayload } from './lib/autoplay-ipc';
declare global {
interface ScreenSource {
id: string;
name: string;
thumbnailDataUrl: string;
appIconDataUrl: string | null;
}
interface Window {
electronWindow: {
minimize: () => void;
maximize: () => void;
fullscreen: () => void;
close: () => void;
openHuddle: (data: { token: string; serverUrl: string }) => void;
closeHuddle: () => void;
platform: NodeJS.Platform;
onMaximizeChange: (callback: (isMaximized: boolean) => void) => () => void;
};
electronHuddle: {
getScreenSources: () => Promise<ScreenSource[]>;
};
electronAutoplay: {
play: (payload: AutoplayPayload) => void;
dismiss: () => void;
navigate: (data: { networkId: string; streamId: string }) => void;
onPlay: (callback: (payload: AutoplayPayload) => void) => () => void;
onStop: (callback: () => void) => () => void;
onNavigate: (callback: (data: { networkId: string; streamId: string }) => void) => () => void;
};
electronScreen: {
getScreenSources: () => Promise<ScreenSource[]>;
startRecordingWindow: () => void;
stopRecordingWindow: () => void;
onStopRequested: (callback: () => void) => () => void;
};
electronScreenRecord: {
stop: () => void;
onInit: (callback: () => void) => () => void;
};
electronLink: {
fetchMetadata: (url: string) => Promise<LinkMetadata | null>;
openExternal: (url: string) => Promise<void>;
};
electronAttachment: {
download: (url: string, filename?: string) => void;
};
electronApp: {
setDockBadge: (count: number) => void;
getVersion: () => Promise<string>;
};
}
}
export {};
@@ -0,0 +1,327 @@
import { useEffect, useState } from "react";
import { Dialog as DialogPrimitive } from "radix-ui";
import {
ChevronLeft,
ChevronRight,
Download,
FileIcon,
Loader2,
Trash2,
X,
} from "lucide-react";
import { useDownloadUrl } from "@/hooks/use-download-url";
import { Button } from "@/components/ui/button";
import { useSuspendPlayback } from "@/hooks/use-suspend-playback";
export interface AttachmentItem {
id: string;
filename: string;
mimeType: string;
sizeBytes?: number;
source:
| { kind: "remote"; objectId: string }
| { kind: "local"; file: File };
}
interface AttachmentLightboxProps {
items: AttachmentItem[];
openIndex: number | null;
onOpenChange: (index: number | null) => void;
/** When provided, enables the trash button + Backspace/Delete to remove. */
onRemove?: (item: AttachmentItem) => void;
}
/**
* Return `"lightbox"` for mime types that preview in-app, `"external"` otherwise.
* Callers use this to decide whether to open the lightbox or hand off to the OS.
*/
export function getAttachmentHandler(mimeType: string): "lightbox" | "external" {
if (mimeType.startsWith("image/") || mimeType.startsWith("video/")) {
return "lightbox";
}
return "external";
}
function formatSize(bytes?: number): string | null {
if (bytes == null) return null;
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
export function AttachmentLightbox({
items,
openIndex,
onOpenChange,
onRemove,
}: AttachmentLightboxProps) {
const current =
openIndex !== null && openIndex >= 0 && openIndex < items.length
? items[openIndex]
: null;
const isOpen = current !== null;
const hasMultiple = items.length > 1;
// Remote items resolve through the signed-URL cache; disabled when not remote.
const remoteObjectId =
current?.source.kind === "remote" ? current.source.objectId : undefined;
const { data: remoteUrl, isLoading: isRemoteLoading } =
useDownloadUrl(remoteObjectId);
// Local items get a fresh blob URL per item, revoked on change/close.
const [localUrl, setLocalUrl] = useState<string | null>(null);
useEffect(() => {
if (current?.source.kind !== "local") {
setLocalUrl(null);
return;
}
const url = URL.createObjectURL(current.source.file);
setLocalUrl(url);
return () => URL.revokeObjectURL(url);
}, [current?.id, current?.source.kind]);
const url =
current?.source.kind === "remote"
? remoteUrl ?? null
: localUrl;
const canDownload = current?.source.kind === "remote" && !!url;
const goTo = (delta: number) => {
if (openIndex === null || items.length === 0) return;
const next = (openIndex + delta + items.length) % items.length;
onOpenChange(next);
};
const handleDownload = () => {
if (!current || !url || current.source.kind !== "remote") return;
window.electronAttachment.download(url, current.filename);
};
const handleRemove = () => {
if (!current || !onRemove) return;
const wasLast = items.length <= 1;
const wasAtEnd = openIndex === items.length - 1;
onRemove(current);
if (wasLast) {
onOpenChange(null);
} else if (wasAtEnd) {
onOpenChange(openIndex! - 1);
}
// Otherwise openIndex stays — the next item shifts into its place.
};
useSuspendPlayback(isOpen, "attachment-lightbox");
// Keyboard handling — only listens while open. Registered in the capture
// phase with stopImmediatePropagation so we consume keys (arrows, D, ⌫)
// before global listeners like stream-view's particle-navigation.
useEffect(() => {
if (!isOpen) return;
const handle = (e: KeyboardEvent) => {
const target = e.target as HTMLElement | null;
if (
target &&
(target.tagName === "INPUT" ||
target.tagName === "TEXTAREA" ||
target.isContentEditable)
) {
return;
}
const consume = () => {
e.preventDefault();
e.stopImmediatePropagation();
};
if (e.key === "Escape") {
consume();
onOpenChange(null);
} else if (e.key === "ArrowLeft" && hasMultiple) {
consume();
goTo(-1);
} else if (e.key === "ArrowRight" && hasMultiple) {
consume();
goTo(1);
} else if ((e.key === "d" || e.key === "D") && canDownload) {
consume();
handleDownload();
} else if ((e.key === "Backspace" || e.key === "Delete") && onRemove) {
consume();
handleRemove();
}
};
window.addEventListener("keydown", handle, true);
return () => window.removeEventListener("keydown", handle, true);
}, [isOpen, openIndex, items, url, onOpenChange, onRemove, hasMultiple, canDownload]);
const isImage = current?.mimeType.startsWith("image/");
const isVideo = current?.mimeType.startsWith("video/");
const sizeLabel = formatSize(current?.sizeBytes);
return (
<DialogPrimitive.Root
open={isOpen}
onOpenChange={(open) => {
if (!open) onOpenChange(null);
}}
>
<DialogPrimitive.Portal>
<DialogPrimitive.Overlay className="data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 fixed inset-0 z-50 bg-black/90 backdrop-blur-sm duration-100" />
<DialogPrimitive.Content
aria-describedby={undefined}
className="data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 fixed inset-0 z-50 flex items-center justify-center p-16 outline-none duration-100"
>
{current && (
<>
<DialogPrimitive.Title className="sr-only">
{current.filename}
</DialogPrimitive.Title>
{/* Top-left: filename chip */}
<div className="absolute left-4 top-4 flex items-center gap-2 rounded-2xl border border-white/10 bg-white/5 px-3 py-1.5 backdrop-blur-xl">
<span className="max-w-[40vw] truncate text-sm text-white/80">
{current.filename}
</span>
{sizeLabel && (
<span className="text-xs text-white/40">{sizeLabel}</span>
)}
{hasMultiple && (
<span className="border-l border-white/10 pl-2 text-xs text-white/40">
{openIndex! + 1} / {items.length}
</span>
)}
</div>
{/* Top-right: actions */}
<div className="absolute right-4 top-4 flex items-center gap-1 no-drag">
{canDownload && (
<Button
variant="ghost"
size="icon-sm"
onClick={handleDownload}
className="text-white/70 hover:bg-white/10 hover:text-white"
title="Download"
>
<Download />
</Button>
)}
{onRemove && (
<Button
variant="ghost"
size="icon-sm"
onClick={handleRemove}
className="text-white/70 hover:bg-white/10 hover:text-white"
title="Remove"
>
<Trash2 />
</Button>
)}
<DialogPrimitive.Close asChild>
<Button
variant="ghost"
size="icon-sm"
className="text-white/70 hover:bg-white/10 hover:text-white"
title="Close"
>
<X />
</Button>
</DialogPrimitive.Close>
</div>
{/* Side navigation */}
{hasMultiple && (
<>
<Button
variant="ghost"
size="icon"
onClick={() => goTo(-1)}
className="absolute left-4 top-1/2 -translate-y-1/2 text-white/70 hover:bg-white/10 hover:text-white"
aria-label="Previous"
>
<ChevronLeft />
</Button>
<Button
variant="ghost"
size="icon"
onClick={() => goTo(1)}
className="absolute right-4 top-1/2 -translate-y-1/2 text-white/70 hover:bg-white/10 hover:text-white"
aria-label="Next"
>
<ChevronRight />
</Button>
</>
)}
{/* Media body */}
<div className="flex max-h-full max-w-full items-center justify-center">
{!url && isRemoteLoading && (
<Loader2 className="size-8 animate-spin text-white/50" />
)}
{url && isImage && (
<img
key={current.id}
src={url}
alt={current.filename}
onError={() => onOpenChange(null)}
className="max-h-[85vh] max-w-[85vw] rounded-lg object-contain shadow-2xl"
/>
)}
{url && isVideo && (
<video
key={current.id}
src={url}
controls
autoPlay
className="max-h-[85vh] max-w-[85vw] rounded-lg shadow-2xl"
/>
)}
{url && !isImage && !isVideo && (
<div className="flex flex-col items-center gap-3 rounded-2xl border border-white/10 bg-white/5 px-8 py-6 backdrop-blur-xl">
<FileIcon className="size-12 text-white/50" />
<span className="text-sm text-white/80">{current.filename}</span>
</div>
)}
</div>
{/* Footer kbd hints */}
<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>
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>
download
</span>
)}
{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>
remove
</span>
)}
<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>
close
</span>
</div>
</>
)}
</DialogPrimitive.Content>
</DialogPrimitive.Portal>
</DialogPrimitive.Root>
);
}
@@ -0,0 +1,69 @@
import { type FormEvent, useState } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { H3, Muted } from "@/components/ui/typography";
import { useAuthStore } from "@/stores/auth-store";
interface CodeStepProps {
email: string;
onBack: () => void;
}
export function CodeStep({ email, onBack }: CodeStepProps) {
const [code, setCode] = useState("");
const isSigningIn = useAuthStore((s) => s.isSigningIn);
const error = useAuthStore((s) => s.error);
const signIn = useAuthStore((s) => s.signIn);
const clearError = useAuthStore((s) => s.clearError);
const handleSubmit = async (e: FormEvent) => {
e.preventDefault();
try {
await signIn(email, code);
} catch {
// Error is set in the store
}
};
return (
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
<div className="flex flex-col gap-2">
<H3>Check your email</H3>
<Muted>
We sent a code to <strong className="text-foreground">{email}</strong>.
</Muted>
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor="code">Code</Label>
<Input
id="code"
type="text"
inputMode="numeric"
placeholder="Enter code"
value={code}
onChange={(e) => {
setCode(e.target.value);
if (error) clearError();
}}
required
autoFocus
/>
</div>
{error && (
<p className="text-sm text-destructive">{error}</p>
)}
<div className="flex flex-col gap-2">
<Button type="submit" disabled={isSigningIn || !code}>
{isSigningIn ? "Signing in..." : "Sign in"}
</Button>
<Button type="button" variant="ghost" onClick={onBack}>
Back
</Button>
</div>
</form>
);
}
@@ -0,0 +1,82 @@
import { type FormEvent, useState } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { H3, Muted } from "@/components/ui/typography";
import { PRIVACY_URL, TERMS_URL } from "@/lib/constants";
import { useAuthStore } from "@/stores/auth-store";
interface EmailStepProps {
onCodeSent: (email: string) => void;
}
export function EmailStep({ onCodeSent }: EmailStepProps) {
const [email, setEmail] = useState("");
const isRequestingCode = useAuthStore((s) => s.isRequestingCode);
const error = useAuthStore((s) => s.error);
const requestCode = useAuthStore((s) => s.requestCode);
const clearError = useAuthStore((s) => s.clearError);
const handleSubmit = async (e: FormEvent) => {
e.preventDefault();
try {
await requestCode(email);
onCodeSent(email);
} catch {
// Error is set in the store
}
};
return (
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
<div className="flex flex-col gap-2">
<H3>Sign in</H3>
<Muted>Enter your email to receive a sign-in code.</Muted>
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor="email">Email</Label>
<Input
id="email"
type="email"
placeholder="[email protected]"
value={email}
onChange={(e) => {
setEmail(e.target.value);
if (error) clearError();
}}
required
autoFocus
/>
</div>
{error && (
<p className="text-sm text-destructive">{error}</p>
)}
<Button type="submit" disabled={isRequestingCode || !email}>
{isRequestingCode ? "Sending..." : "Continue"}
</Button>
<Muted className="text-center text-xs">
By continuing, you agree to our{" "}
<button
type="button"
onClick={() => window.electronLink.openExternal(TERMS_URL)}
className="underline underline-offset-2 hover:text-foreground"
>
Terms of Service
</button>{" "}
and{" "}
<button
type="button"
onClick={() => window.electronLink.openExternal(PRIVACY_URL)}
className="underline underline-offset-2 hover:text-foreground"
>
Privacy Policy
</button>
.
</Muted>
</form>
);
}
@@ -0,0 +1,36 @@
import { useState } from "react";
import { WindowControls } from "@/components/window-controls";
import { EmailStep } from "./email-step";
import { CodeStep } from "./code-step";
type Step = "email" | "code";
export function LoginPage() {
const [step, setStep] = useState<Step>("email");
const [email, setEmail] = useState("");
return (
<div className="flex h-screen flex-col">
<div className="drag-region flex items-center gap-3 border-b px-3 py-1">
<WindowControls />
</div>
<div className="flex flex-1 items-center justify-center p-4">
<div className="w-full max-w-sm">
{step === "email" ? (
<EmailStep
onCodeSent={(submittedEmail) => {
setEmail(submittedEmail);
setStep("code");
}}
/>
) : (
<CodeStep
email={email}
onBack={() => setStep("email")}
/>
)}
</div>
</div>
</div>
);
}
@@ -0,0 +1,212 @@
import { useMemo, useState } from "react";
import { FileIcon, Globe, Loader2, Plus, X } from "lucide-react";
import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area";
import { Skeleton } from "@/components/ui/skeleton";
import { cn } from "@/lib/utils";
import type { LinkPreviewEntry } from "@/hooks/use-link-metadata";
import {
AttachmentLightbox,
getAttachmentHandler,
type AttachmentItem,
} from "@/features/attachments/attachment-lightbox";
export interface PendingAttachment {
id: string;
file: File;
thumbnailUrl?: string;
status: "pending" | "uploading" | "uploaded" | "error";
}
interface AttachmentStripProps {
attachments: PendingAttachment[];
onRemove: (id: string) => void;
onAddClick: () => void;
linkPreviews?: LinkPreviewEntry[];
}
function pendingToItem(p: PendingAttachment): AttachmentItem {
return {
id: p.id,
filename: p.file.name,
mimeType: p.file.type || "application/octet-stream",
sizeBytes: p.file.size,
source: { kind: "local", file: p.file },
};
}
function formatFileSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
function AttachmentThumbnail({
attachment,
onRemove,
onPreview,
}: {
attachment: PendingAttachment;
onRemove: () => void;
onPreview?: () => void;
}) {
const isImage = attachment.file.type.startsWith("image/");
const isUploading = attachment.status === "uploading";
const isError = attachment.status === "error";
const previewable = getAttachmentHandler(attachment.file.type) === "lightbox";
return (
<div
role={previewable ? "button" : undefined}
tabIndex={previewable ? 0 : undefined}
onClick={previewable && onPreview ? onPreview : undefined}
className={cn(
"group relative flex h-16 w-16 shrink-0 items-center justify-center overflow-hidden rounded-lg bg-white/10",
previewable && "cursor-pointer",
isError && "ring-1 ring-red-400/50",
)}
>
{isImage && attachment.thumbnailUrl ? (
<img
src={attachment.thumbnailUrl}
alt={attachment.file.name}
className="h-full w-full object-cover"
/>
) : (
<div className="flex flex-col items-center gap-0.5 px-1">
<FileIcon className="size-5 text-white/60" />
<span className="max-w-full truncate text-[9px] text-white/50">
{attachment.file.name}
</span>
<span className="text-[9px] text-white/40">
{formatFileSize(attachment.file.size)}
</span>
</div>
)}
{isUploading && (
<div className="absolute inset-0 flex items-center justify-center bg-black/50">
<Loader2 className="size-4 animate-spin text-white/70" />
</div>
)}
<button
type="button"
onClick={(e) => {
e.stopPropagation();
onRemove();
}}
className="absolute right-0.5 top-0.5 hidden rounded-full bg-black/70 p-0.5 text-white/70 hover:text-white group-hover:block"
>
<X className="size-3" />
</button>
</div>
);
}
function LinkPreviewThumbnail({ entry }: { entry: LinkPreviewEntry }) {
if (entry.isLoading) {
return (
<div className="flex h-16 w-28 shrink-0 flex-col gap-1.5 rounded-lg bg-white/10 p-2">
<Skeleton className="h-2 w-16 bg-white/10" />
<Skeleton className="h-3 w-24 bg-white/10" />
</div>
);
}
if (!entry.metadata) return null;
const { metadata } = entry;
return (
<button
type="button"
onClick={() => window.electronLink.openExternal(metadata.url)}
className="flex h-16 w-28 shrink-0 flex-col justify-center gap-1 overflow-hidden rounded-lg bg-white/10 px-2 py-1.5 text-left transition-colors hover:bg-white/15"
>
<div className="flex items-center gap-1 text-[10px] text-white/40">
{metadata.favicon ? (
<img
src={metadata.favicon}
alt=""
className="size-3 rounded-sm"
onError={(e) => {
(e.target as HTMLImageElement).style.display = "none";
}}
/>
) : (
<Globe className="size-3" />
)}
<span className="truncate">{metadata.domain}</span>
</div>
{metadata.title && (
<p className="line-clamp-2 text-[11px] font-medium leading-tight text-white/80">
{metadata.title}
</p>
)}
</button>
);
}
export function AttachmentStrip({
attachments,
onRemove,
onAddClick,
linkPreviews,
}: AttachmentStripProps) {
const hasLinks = linkPreviews && linkPreviews.length > 0;
// Lightbox state — only previewable attachments go in.
const previewable = useMemo(
() => attachments.filter((a) => getAttachmentHandler(a.file.type) === "lightbox"),
[attachments],
);
const items = useMemo(() => previewable.map(pendingToItem), [previewable]);
const [openIndex, setOpenIndex] = useState<number | null>(null);
if (attachments.length === 0 && !hasLinks) return null;
return (
<>
<ScrollArea className="w-full">
<div className="flex items-center gap-2 py-2">
{attachments.map((a) => (
<AttachmentThumbnail
key={a.id}
attachment={a}
onRemove={() => onRemove(a.id)}
onPreview={() => {
const idx = previewable.indexOf(a);
if (idx >= 0) setOpenIndex(idx);
}}
/>
))}
{linkPreviews?.map((entry) => (
<LinkPreviewThumbnail key={entry.url} entry={entry} />
))}
<button
type="button"
onClick={(e) => {
e.stopPropagation();
onAddClick();
}}
className="flex h-16 w-16 shrink-0 items-center justify-center rounded-lg border border-dashed border-white/20 text-white/40 transition-colors hover:border-white/40 hover:text-white/60"
>
<Plus className="size-5" />
</button>
</div>
<ScrollBar orientation="horizontal" />
</ScrollArea>
{items.length > 0 && (
<AttachmentLightbox
items={items}
openIndex={openIndex}
onOpenChange={setOpenIndex}
onRemove={(item) => onRemove(item.id)}
/>
)}
</>
);
}
@@ -0,0 +1,633 @@
import { useCallback, useEffect, useEffectEvent, useRef, useState } from "react";
import { toast } from "sonner";
import { useAuthStore } from "@/stores/auth-store";
import { useCreateParticle, useCreateStreamParticle } from "@/hooks/use-create-particle";
import { QuotaExceededError } from "@/lib/errors";
import { isUsageExhausted, useInvalidateNetworkUsage, useNetworkUsage } from "@/hooks/use-network-usage";
import { useRecorder } from "@/features/compose/use-recorder";
import { useScreenRecorder } from "@/features/compose/use-screen-recorder";
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 { TextComposeStep } from "@/features/compose/text-compose-step";
import { ConfigureStreamStep } from "@/features/compose/configure-stream-step";
import { apiClient } from "@/api/client";
import { useMediaSettingsStore } from "@/stores/media-settings-store";
import { useMediaDevicesStore } from "@/stores/media-devices-store";
import { useMediaDevices } from "@/hooks/use-media-devices";
import { resolveEffectiveDeviceId } from "@/hooks/use-effective-device-id";
import { useFileInput } from "@/hooks/use-file-input";
import { createImageThumbnail } from "@/lib/image-thumbnail";
import { MAX_ATTACHMENT_SIZE_BYTES, MAX_ATTACHMENTS } from "@/lib/constants";
import type { PendingAttachment } from "@/features/compose/attachment-strip";
import { useSuspendPlayback } from "@/hooks/use-suspend-playback";
export type ComposeStep = "idle" | "picking" | "recording" | "reviewing" | "typing" | "configuring" | "submitting";
type RecordingSource = "media" | "screen";
interface ComposeOverlayProps {
networkId: string;
// Optional target path for reply mode. If not provided, compose creates a new stream.
targetPath?: ParticlePath;
onActiveChange?: (active: boolean) => void;
onStepChange?: (step: ComposeStep) => void;
onParticleCreated?: (particleId: string) => void;
/** When true, composing is blocked (e.g. stream is closed). */
disabled?: boolean;
}
const HOLD_THRESHOLD_MS = 250;
/**
* Self-contained compose overlay. Each consumer renders its own instance
* with props that determine the mode (new stream vs. reply).
*/
export function ComposeOverlay({
networkId,
targetPath,
onActiveChange,
onStepChange,
onParticleCreated,
disabled,
}: ComposeOverlayProps) {
const [step, setStep] = useState<ComposeStep>("idle");
const [error, setError] = useState<string | null>(null);
const [textContent, setTextContent] = useState("");
const [mediaStream, setMediaStream] = useState<MediaStream | null>(null);
const [reviewBlob, setReviewBlob] = useState<Blob | null>(null);
const [reviewDurationMs, setReviewDurationMs] = useState(0);
const [reviewMimeType, setReviewMimeType] = useState<string | null>(null);
const [attachments, setAttachments] = useState<PendingAttachment[]>([]);
const [recordingSource, setRecordingSource] = useState<RecordingSource>("media");
const recordingMode = useMediaSettingsStore((s) => s.recordingMode);
const savedMic = useMediaDevicesStore((s) => s.mic);
const savedCamera = useMediaDevicesStore((s) => s.camera);
const { audioInputs, videoInputs } = useMediaDevices();
const micDeviceId = resolveEffectiveDeviceId(savedMic, audioInputs);
const cameraDeviceId = resolveEffectiveDeviceId(savedCamera, videoInputs);
const userId = useAuthStore((s) => s.user?.id);
const createParticle = useCreateParticle();
const createStream = useCreateStreamParticle();
const { data: usage } = useNetworkUsage(networkId);
const invalidateUsage = useInvalidateNetworkUsage();
const quotaExhausted = isUsageExhausted(usage);
// Refs for synchronous reads in keyboard handlers
const stepRef = useRef(step);
const recordStartRef = useRef(0);
const disabledRef = useRef(disabled);
disabledRef.current = disabled;
const quotaExhaustedRef = useRef(quotaExhausted);
quotaExhaustedRef.current = quotaExhausted;
const recordingSourceRef = useRef(recordingSource);
recordingSourceRef.current = recordingSource;
const setStepSync = useCallback((next: ComposeStep) => {
stepRef.current = next;
setStep(next);
}, []);
useSuspendPlayback(step !== "idle", "compose");
// Notify parent when active state changes
useEffect(() => {
onActiveChange?.(step !== "idle");
onStepChange?.(step);
// Refresh quota when the overlay activates — user is about to send, so
// we want the most accurate count before the client-side gate kicks in.
if (step !== "idle") {
void invalidateUsage(networkId);
}
}, [step, onActiveChange, onStepChange, invalidateUsage, networkId]);
const revokeAttachmentThumbnails = useCallback((items: PendingAttachment[]) => {
for (const a of items) {
if (a.thumbnailUrl) URL.revokeObjectURL(a.thumbnailUrl);
}
}, []);
const cancel = useCallback(() => {
setStepSync("idle");
setError(null);
setTextContent("");
setMediaStream(null);
setReviewBlob(null);
setReviewDurationMs(0);
setReviewMimeType(null);
setRecordingSource("media");
setAttachments((prev) => {
revokeAttachmentThumbnails(prev);
return [];
});
}, [setStepSync, revokeAttachmentThumbnails]);
const addAttachments = useCallback(async (files: File[]) => {
const currentCount = attachments.length;
const available = MAX_ATTACHMENTS - currentCount;
if (available <= 0) {
toast.error(`Maximum ${MAX_ATTACHMENTS} attachments`);
return;
}
const accepted = files.slice(0, available);
if (accepted.length < files.length) {
toast.error(`Maximum ${MAX_ATTACHMENTS} attachments — ${files.length - accepted.length} skipped`);
}
const newAttachments: PendingAttachment[] = [];
for (const file of accepted) {
if (file.size > MAX_ATTACHMENT_SIZE_BYTES) {
toast.error(`${file.name} is too large (max 25 MB)`);
continue;
}
const thumbnailUrl = await createImageThumbnail(file);
newAttachments.push({
id: crypto.randomUUID(),
file,
thumbnailUrl,
status: "pending",
});
}
if (newAttachments.length > 0) {
setAttachments((prev) => [...prev, ...newAttachments]);
}
}, [attachments.length]);
const removeAttachment = useCallback((id: string) => {
setAttachments((prev) => {
const removed = prev.find((a) => a.id === id);
if (removed?.thumbnailUrl) URL.revokeObjectURL(removed.thumbnailUrl);
return prev.filter((a) => a.id !== id);
});
}, []);
const { openFilePicker, isDragging, dropZoneProps } = useFileInput({
onFilesSelected: addAttachments,
enabled: step === "typing" || step === "reviewing",
});
const { startRecording, stopRecording, cancelRecording } = useRecorder({
mode: recordingMode,
micDeviceId,
cameraDeviceId,
onStreamReady: (stream) => setMediaStream(stream),
onStreamCleanup: () => setMediaStream(null),
onFinish: (blob, durationMs, mimeType) => {
setStepSync("reviewing");
setReviewBlob(blob);
setReviewDurationMs(durationMs);
setReviewMimeType(mimeType);
},
onError: (message) => setError(message),
});
const {
startRecording: startScreenRecording,
stopRecording: stopScreenRecording,
cancelRecording: cancelScreenRecording,
} = useScreenRecorder({
micDeviceId,
onFinish: (blob, durationMs, mimeType) => {
setStepSync("reviewing");
setReviewBlob(blob);
setReviewDurationMs(durationMs);
setReviewMimeType(mimeType);
},
onError: (message) => {
setError(message);
cancel();
},
});
// --- Submission ---
const uploadMedia = useCallback(
async (blob: Blob, mimeType: string) => {
const ext = "webm";
const fileName = `recording-${Date.now()}.${ext}`;
const { object_id, upload_url, upload_headers } =
await apiClient.prepareUpload({
network_id: networkId,
name: fileName,
content_type: mimeType,
content_length: blob.size,
});
await fetch(upload_url, {
method: "PUT",
headers: upload_headers,
body: blob,
});
await apiClient.confirmUpload(object_id);
return { object_id, size_bytes: blob.size };
},
[networkId],
);
const uploadFile = useCallback(
async (file: File) => {
const { object_id, upload_url, upload_headers } =
await apiClient.prepareUpload({
network_id: networkId,
name: file.name,
content_type: file.type || "application/octet-stream",
content_length: file.size,
});
await fetch(upload_url, {
method: "PUT",
headers: upload_headers,
body: file,
});
await apiClient.confirmUpload(object_id);
return { object_id, size_bytes: file.size };
},
[networkId],
);
const uploadAttachments = useCallback(
async (parentPath: ParticlePath, parentId: string) => {
if (attachments.length === 0 || !userId) return;
const { networkId: netId, segments } = parseParticlePath(parentPath);
const childrenPath = particlePath(netId, [...segments, parentId]);
const results = await Promise.allSettled(
attachments.map(async (attachment) => {
setAttachments((prev) =>
prev.map((a) =>
a.id === attachment.id ? { ...a, status: "uploading" as const } : a,
),
);
const { object_id } = await uploadFile(attachment.file);
await createParticle.mutateAsync({
path: childrenPath,
type: "file",
properties: {
object_id,
filename: attachment.file.name,
mime_type: attachment.file.type || "application/octet-stream",
size_bytes: attachment.file.size,
},
createdByHumanId: userId,
});
}),
);
const failed = results.filter((r) => r.status === "rejected");
if (failed.length > 0) {
toast.error(`${failed.length} attachment${failed.length > 1 ? "s" : ""} failed to upload`);
}
},
[attachments, userId, uploadFile, createParticle],
);
const createChildParticle = useCallback(
async (path: ParticlePath) => {
if (!userId) return;
let particleId: undefined | string;
if (textContent.trim()) {
particleId = await createParticle.mutateAsync({
path,
type: "text",
properties: { content: textContent },
createdByHumanId: userId,
});
} else if (reviewBlob && reviewMimeType) {
const { object_id, size_bytes } = await uploadMedia(
reviewBlob,
reviewMimeType,
);
const isAudioOnly = reviewMimeType.startsWith("audio/");
particleId = await createParticle.mutateAsync({
path,
type: "media",
properties: {
object_id,
mime_type: reviewMimeType,
duration_ms: reviewDurationMs,
size_bytes,
...(!isAudioOnly && {
source: recordingSource === "screen" ? "screen" as const : "camera" as const,
}),
},
createdByHumanId: userId,
});
}
if (particleId) {
await uploadAttachments(path, particleId);
onParticleCreated?.(particleId);
}
},
[
userId,
textContent,
reviewBlob,
reviewMimeType,
reviewDurationMs,
recordingSource,
createParticle,
uploadMedia,
uploadAttachments,
onParticleCreated
],
);
const handleQuotaError = useCallback((err: unknown): boolean => {
if (err instanceof QuotaExceededError) {
toast.error("Daily message limit reached. Upgrade to Pro to keep sending.");
cancel();
return true;
}
return false;
}, [cancel]);
// Reply mode: create particle directly under targetPath
const onSubmitReply = useEffectEvent(async () => {
if (!targetPath || !userId || stepRef.current === "submitting") return;
setStepSync("submitting");
try {
await createChildParticle(targetPath);
cancel();
} catch (err) {
if (!handleQuotaError(err)) throw err;
}
});
// New stream mode: create stream + first child
const handleStreamSubmit = useCallback(
async (streamName: string, visibleTo: string[]) => {
if (!userId || stepRef.current === "submitting") return;
setStepSync("submitting");
try {
const streamId = await createStream.mutateAsync({
networkId,
properties: {
name: streamName,
},
createdByHumanId: userId,
visibleTo,
});
const streamChildrenPath = particlePath(networkId, [streamId]);
await createChildParticle(streamChildrenPath);
cancel();
} catch (err) {
if (!handleQuotaError(err)) throw err;
}
},
[networkId, userId, createStream, createChildParticle, cancel, handleQuotaError],
);
// --- Keyboard handling ---
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
const currentStep = stepRef.current;
if (currentStep === "typing" || currentStep === "configuring" || currentStep === "picking") {
if (e.key === "Escape") {
e.preventDefault();
cancel();
}
return;
}
const target = e.target as HTMLElement;
if (
target.tagName === "INPUT" ||
target.tagName === "TEXTAREA" ||
target.isContentEditable
) {
return;
}
switch (currentStep) {
case "idle": {
if (disabledRef.current) {
if ((e.key === "`" && !e.repeat) || e.key === "t" || e.key === "T" || e.key === "s" || e.key === "S") {
e.preventDefault();
toast.info("This stream is closed");
}
break;
}
if (quotaExhaustedRef.current) {
if ((e.key === "`" && !e.repeat) || e.key === "t" || e.key === "T" || e.key === "s" || e.key === "S") {
e.preventDefault();
toast.info("Daily message limit reached. Upgrade to Pro to keep sending.");
}
break;
}
if (e.key === "`" && !e.repeat) {
e.preventDefault();
recordStartRef.current = Date.now();
setRecordingSource("media");
setStepSync("recording");
startRecording();
} else if (e.key === "s" || e.key === "S") {
e.preventDefault();
setRecordingSource("screen");
setStepSync("picking");
} else if (e.key === "t" || e.key === "T") {
e.preventDefault();
setStepSync("typing");
}
break;
}
case "recording": {
if (e.key === "`" && !e.repeat) {
// Second tap stops recording (toggle mode)
e.preventDefault();
stopRecording();
} else if ((e.key === "s" || e.key === "S") && recordingSourceRef.current === "screen") {
// S stops screen recording when main window is focused
e.preventDefault();
stopScreenRecording();
} else if (e.key === "q" || e.key === "Q" || e.key === "Escape") {
e.preventDefault();
if (recordingSourceRef.current === "screen") {
cancelScreenRecording();
} else {
cancelRecording();
}
cancel();
}
break;
}
case "reviewing": {
if (e.key === "q" || e.key === "Q" || e.key === "Escape") {
e.preventDefault();
if (recordingSourceRef.current === "screen") {
cancelScreenRecording();
} else {
cancelRecording();
}
cancel();
} else if (e.key === "Enter") {
e.preventDefault();
if (targetPath) {
onSubmitReply();
} else {
setStepSync("configuring");
}
}
break;
}
}
};
const handleKeyUp = (e: KeyboardEvent) => {
if (stepRef.current === "recording" && e.key === "`" && recordingSourceRef.current === "media") {
e.preventDefault();
// Only stop on release if held long enough (hold-to-record mode).
// Quick taps are handled by the second keydown (toggle mode).
if (recordStartRef.current > 0 && Date.now() - recordStartRef.current >= HOLD_THRESHOLD_MS) {
stopRecording();
recordStartRef.current = 0;
}
}
};
window.addEventListener("keydown", handleKeyDown);
window.addEventListener("keyup", handleKeyUp);
return () => {
window.removeEventListener("keydown", handleKeyDown);
window.removeEventListener("keyup", handleKeyUp);
};
}, [targetPath, startRecording, stopRecording, cancelRecording, startScreenRecording, stopScreenRecording, cancelScreenRecording, cancel, setStepSync]);
// --- Screen source selection handler ---
const handleScreenSourceSelected = useCallback(
(sourceId: string) => {
setStepSync("recording");
startScreenRecording(sourceId);
},
[setStepSync, startScreenRecording],
);
// --- Render ---
if (step === "idle") return null;
const handleTextAdvance = targetPath
? onSubmitReply
: () => setStepSync("configuring");
return (
<>
{step === "picking" && (
<ScreenSourcePicker
title="Record your screen"
confirmLabel="Record"
getSources={window.electronScreen.getScreenSources}
onSelect={handleScreenSourceSelected}
onCancel={cancel}
/>
)}
{(step === "recording" || step === "reviewing") && recordingSource === "media" && (
<RecordingOverlay
step={step}
mediaStream={mediaStream}
recordingMode={recordingMode}
reviewBlob={reviewBlob}
error={error}
onClose={cancel}
attachments={attachments}
onRemoveAttachment={removeAttachment}
onAddFiles={openFilePicker}
isDragging={isDragging}
dropZoneProps={dropZoneProps}
mirror={true}
objectFit="cover"
/>
)}
{step === "recording" && recordingSource === "screen" && (
<div className="absolute inset-0 z-50 flex flex-col items-center justify-center bg-black/90">
<div className="absolute top-8 z-10">
<div className="flex items-center gap-2">
<span className="h-2.5 w-2.5 animate-pulse rounded-full bg-red-500" />
<span className="text-sm text-white/80">Recording screen</span>
</div>
</div>
<div className="absolute bottom-4 z-10 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">
S
</kbd>{" "}
stop
</span>
<span>
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs">
Q
</kbd>{" "}
cancel
</span>
</div>
</div>
)}
{step === "reviewing" && recordingSource === "screen" && reviewBlob && (
<RecordingOverlay
step="reviewing"
mediaStream={null}
recordingMode="video"
reviewBlob={reviewBlob}
error={error}
onClose={cancel}
attachments={attachments}
onRemoveAttachment={removeAttachment}
onAddFiles={openFilePicker}
isDragging={isDragging}
dropZoneProps={dropZoneProps}
mirror={false}
objectFit="contain"
/>
)}
{step === "typing" && (
<TextComposeStep
textContent={textContent}
onTextChange={setTextContent}
onAdvance={handleTextAdvance}
onCancel={cancel}
attachments={attachments}
onRemoveAttachment={removeAttachment}
onAddFiles={openFilePicker}
isDragging={isDragging}
dropZoneProps={dropZoneProps}
/>
)}
{!targetPath && step === "configuring" && (
<ConfigureStreamStep
networkId={networkId}
onCancel={cancel}
onSubmit={handleStreamSubmit}
/>
)}
{step === "submitting" && (
<div className="absolute inset-0 z-50 flex flex-col items-center justify-center bg-black/90">
<span className="animate-pulse text-sm text-white/60">Sending...</span>
</div>
)}
</>
);
}
@@ -0,0 +1,94 @@
import { useNavigate } from "react-router-dom";
import { Progress } from "@/components/ui/progress";
import { Button } from "@/components/ui/button";
import { useNetworkUsage } from "@/hooks/use-network-usage";
import { useIsNetworkAdmin, useNetwork } from "@/hooks/use-networks";
interface ComposeQuotaIndicatorProps {
networkId: string;
}
const SHOW_PROGRESS_AT_FRACTION = 0.7;
/**
* Surfaces freemium quota state near compose:
* - Nothing below 70% used (avoid nagging).
* - A subtle progress pill between 70% and the limit.
* - A locked banner with an upgrade CTA once the limit is hit.
*
* Pro networks and any network still loading usage render nothing.
*/
export function ComposeQuotaIndicator({ networkId }: ComposeQuotaIndicatorProps) {
const navigate = useNavigate();
const { data: usage } = useNetworkUsage(networkId);
const isAdmin = useIsNetworkAdmin(networkId);
const network = useNetwork(networkId);
if (!usage || usage.limit == null) return null;
const fraction = usage.used / usage.limit;
const exhausted = usage.used >= usage.limit;
if (exhausted) {
return (
<div className="pointer-events-auto flex max-w-md flex-col items-center gap-2 rounded-lg border border-border bg-background/95 px-4 py-3 text-center shadow-lg backdrop-blur">
<div className="text-sm font-medium">
{isAdmin
? `You've reached today's ${usage.limit}-message limit`
: `This network reached today's ${usage.limit}-message limit`}
</div>
<div className="text-xs text-muted-foreground">
Resets {formatResetRelative(usage.reset_at)} ({formatResetAbsolute(usage.reset_at)})
</div>
{isAdmin ? (
<Button
size="sm"
onClick={() => navigate(`/${networkId}/settings?section=billing`)}
>
Upgrade to Pro
</Button>
) : (
<div className="text-xs text-muted-foreground">
Ask{" "}
<span className="font-medium text-foreground">
{network?.admin_human.email_prefix ?? "your admin"}
</span>{" "}
to upgrade to Pro
</div>
)}
</div>
);
}
if (fraction < SHOW_PROGRESS_AT_FRACTION) return null;
return (
<div
className="pointer-events-auto flex items-center gap-3 rounded-full border border-border bg-background/90 px-3 py-1.5 text-xs text-muted-foreground shadow-sm backdrop-blur"
title={`Resets ${formatResetRelative(usage.reset_at)} at ${formatResetAbsolute(usage.reset_at)}`}
>
<span className="tabular-nums">
{usage.used}/{usage.limit} today
</span>
<Progress value={fraction * 100} className="h-1 w-24" />
</div>
);
}
function formatResetRelative(resetAt: Date): string {
const now = new Date();
const diffMs = resetAt.getTime() - now.getTime();
const hours = Math.max(0, Math.round(diffMs / (60 * 60 * 1000)));
if (hours < 1) return "soon";
if (hours === 1) return "in 1 hour";
return `in ${hours} hours`;
}
function formatResetAbsolute(resetAt: Date): string {
// Shows the user their local wall-clock time for the UTC-midnight reset,
// so a user in UTC-8 sees "4:00 PM" instead of a relative hint alone.
return resetAt.toLocaleTimeString(undefined, {
hour: "numeric",
minute: "2-digit",
});
}
@@ -0,0 +1,174 @@
import { useState, useCallback } from "react";
import { useNetworks } from "@/hooks/use-networks";
import { cn, removeDuplicates } from "@/lib/utils";
import { metaKey } from "@/lib/platform";
import { useAuthStore } from "@/stores/auth-store";
import { generateRandomName } from "@/lib/random-name";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Checkbox } from "@/components/ui/checkbox";
import { ScrollArea } from "@/components/ui/scroll-area";
interface ConfigureStreamStepProps {
networkId: string | null;
onCancel: () => void;
onSubmit: (streamName: string, visibleTo: string[]) => void;
}
export function ConfigureStreamStep({
networkId,
onCancel,
onSubmit,
}: ConfigureStreamStepProps) {
const { data: networks } = useNetworks();
const network = networks?.find((n) => n.id === networkId);
const [name, setName] = useState(() => generateRandomName());
const [everyone, setEveryone] = useState(true);
const userId = useAuthStore((s) => s.user?.id);
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
const members = (network?.humans ?? []).filter((h) => h.id !== userId);
const toggleMember = useCallback((id: string) => {
setSelectedIds((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
}, []);
const buildVisibleTo = useCallback((): string[] => {
if (everyone && networkId) return [`network:${networkId}`];
return Array.from(removeDuplicates([...selectedIds, userId].filter(Boolean) as string[])).map((id) => `human:${id}`);
}, [everyone, networkId, selectedIds, userId]);
const handleSubmit = useCallback(() => {
if (!name.trim() || !networkId) return;
onSubmit(name.trim(), buildVisibleTo());
}, [name, networkId, onSubmit, buildVisibleTo]);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
switch (e.key) {
case "Escape":
e.preventDefault();
onCancel();
return;
case "Enter":
if (e.metaKey || e.ctrlKey) {
e.preventDefault();
handleSubmit();
}
return;
}
},
[onCancel, handleSubmit],
);
return (
<div
className="absolute inset-0 z-50 flex flex-col bg-black/90 pt-16"
onKeyDown={handleKeyDown}
tabIndex={-1}
>
<div className="mx-auto w-full max-w-sm space-y-4 px-6">
{/* Stream name */}
<div>
<Label className="mb-1 text-xs text-white/50">Stream name</Label>
<Input
type="text"
autoFocus
value={name}
onChange={(e) => {
setName(e.target.value);
}}
placeholder="Give it a name..."
className="border-white/10 bg-white/5 text-white placeholder-white/30 focus-visible:border-white/30 focus-visible:ring-0"
/>
</div>
{/* Visibility */}
<div>
<Label className="mb-1 text-xs text-white/50">Visible to</Label>
<div className="rounded-md border border-white/10">
{/* Everyone in network */}
<div
role="button"
onClick={() => setEveryone((prev) => !prev)}
className={cn(
"flex w-full cursor-pointer items-center gap-2.5 px-3 py-2 text-left text-sm transition-colors",
"text-white/70 hover:bg-white/5",
)}
>
<Checkbox
checked={everyone}
onCheckedChange={(checked) => setEveryone(checked === true)}
tabIndex={-1}
className="pointer-events-none"
/>
<span className="font-medium">Everyone in network</span>
</div>
{/* Per-member selection */}
{!everyone && members.length > 0 && (
<ScrollArea className="max-h-48">
<div className="space-y-0.5 p-1">
{members.map((member, index) => {
const isSelected = selectedIds.has(member.id);
const initials = member.email_prefix
.slice(0, 2)
.toUpperCase();
return (
<div
key={member.id}
role="button"
onClick={() => toggleMember(member.id)}
className={cn(
"flex w-full cursor-pointer items-center gap-2.5 rounded px-3 py-1.5 text-left text-sm transition-colors",
"text-white/70 hover:bg-white/5",
)}
>
<Checkbox
checked={isSelected}
tabIndex={-1}
className="pointer-events-none"
/>
<span className="flex h-6 w-6 items-center justify-center rounded-full bg-white/10 text-[10px] font-medium">
{initials}
</span>
<span className="flex-1 truncate">
{member.email_prefix}
</span>
</div>
);
})}
</div>
</ScrollArea>
)}
</div>
</div>
</div>
{/* 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>{" "}
cancel
</span>
<span>
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs">
{metaKey}+Enter
</kbd>{" "}
create
</span>
</div>
</div >
);
}
@@ -0,0 +1,293 @@
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 { useAudioSource } from "@/components/audio/use-audio-source";
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";
interface RecordingOverlayProps {
step: "recording" | "reviewing";
mediaStream: MediaStream | null;
recordingMode: RecordingMode;
reviewBlob: Blob | null;
error: string | null;
onClose: () => void;
attachments: PendingAttachment[];
onRemoveAttachment: (id: string) => void;
onAddFiles: () => void;
isDragging: boolean;
dropZoneProps: {
onDragOver: (e: React.DragEvent) => void;
onDragEnter: (e: React.DragEvent) => void;
onDragLeave: (e: React.DragEvent) => void;
onDrop: (e: React.DragEvent) => void;
};
/** Mirror the video horizontally. Defaults to true (selfie-view for webcam). */
mirror?: boolean;
/** How video fills its container. Defaults to "cover". Use "contain" for screen recordings. */
objectFit?: "cover" | "contain";
}
function RecordingTimer() {
const [elapsed, setElapsed] = useState(0);
useEffect(() => {
const interval = setInterval(() => {
setElapsed((prev) => prev + 1);
}, 1000);
return () => clearInterval(interval);
}, []);
const minutes = Math.floor(elapsed / 60);
const seconds = elapsed % 60;
const display = `${minutes}:${seconds.toString().padStart(2, "0")}`;
return (
<div className="flex items-center gap-2">
<span className="h-2.5 w-2.5 animate-pulse rounded-full bg-red-500" />
<span className="font-mono text-sm text-white/80">{display}</span>
</div>
);
}
function ReviewPlayback({
blob,
isVideo,
mirror = true,
objectFit = "cover",
}: {
blob: Blob;
isVideo: boolean;
mirror?: boolean;
objectFit?: "cover" | "contain";
}) {
const urlRef = useRef<string | null>(null);
const [objectUrl, setObjectUrl] = useState<string | null>(null);
const audioElRef = useRef<HTMLAudioElement | null>(null);
const [audioEl, setAudioEl] = useState<HTMLAudioElement | null>(null);
const audioSource = useAudioSource(isVideo ? null : audioEl);
useEffect(() => {
const url = URL.createObjectURL(blob);
urlRef.current = url;
setObjectUrl(url);
return () => {
URL.revokeObjectURL(url);
urlRef.current = null;
};
}, [blob]);
if (!objectUrl) return null;
if (isVideo) {
return (
<video
src={objectUrl}
autoPlay
loop
playsInline
className={`absolute inset-0 h-full w-full ${objectFit === "contain" ? "object-contain" : "object-cover"}${mirror ? " -scale-x-100" : ""}`}
/>
);
}
return (
<div className="flex flex-col items-center gap-3">
<audio
ref={(el) => {
audioElRef.current = el;
setAudioEl(el);
}}
src={objectUrl}
autoPlay
loop
/>
{audioSource ? (
<AudioLevelBars sourceNode={audioSource.sourceNode} />
) : (
<span className="text-sm text-white/60">Playing back audio...</span>
)}
</div>
);
}
export function RecordingOverlay({
step,
mediaStream,
recordingMode,
reviewBlob,
error,
onClose,
attachments,
onRemoveAttachment,
onAddFiles,
isDragging,
dropZoneProps,
mirror = true,
objectFit = "cover",
}: RecordingOverlayProps) {
const videoRef = useRef<HTMLVideoElement>(null);
const recordingAudioSource = useAudioSource(mediaStream ?? null);
// Set video srcObject for live preview
useEffect(() => {
if (videoRef.current && mediaStream && recordingMode === "video") {
videoRef.current.srcObject = mediaStream;
}
}, [mediaStream, recordingMode]);
// Auto-close after error with a brief delay
useEffect(() => {
if (!error) return;
const timeout = setTimeout(onClose, 1500);
return () => clearTimeout(timeout);
}, [error, onClose]);
const isReviewing = step === "reviewing";
const isRecording = step === "recording";
const isLoading = isRecording && !mediaStream;
return (
<div
className={cn(
"absolute inset-0 z-50 flex flex-col items-center justify-center bg-black/90",
isReviewing && isDragging && "ring-2 ring-inset ring-white/30",
)}
{...(isReviewing ? dropZoneProps : {})}
>
{/* Loading state */}
{isLoading && (
<div className="z-10 flex flex-col items-center gap-2">
<span className="animate-pulse text-sm text-white/60">
{recordingMode === "video"
? "Starting camera..."
: "Starting mic..."}
</span>
</div>
)}
{/* Camera preview (video mode, recording) */}
{isRecording && recordingMode === "video" && mediaStream && (
<video
ref={videoRef}
muted
autoPlay
playsInline
className="absolute inset-0 h-full w-full -scale-x-100 object-cover"
/>
)}
{/* Review playback */}
{isReviewing && reviewBlob && (
<ReviewPlayback
blob={reviewBlob}
isVideo={recordingMode === "video"}
mirror={mirror}
objectFit={objectFit}
/>
)}
{/* Bottom gradient scrim for keyboard hint readability */}
{(isRecording || isReviewing) && !isLoading && (
<div className="pointer-events-none absolute inset-x-0 bottom-0 z-[5] h-1/4 bg-gradient-to-t from-black/60 to-transparent" />
)}
{/* Top center: recording indicator */}
<div className="absolute top-8 z-10">
{isRecording && !isLoading ? (
<RecordingTimer />
) : isReviewing ? (
<div className="flex items-center gap-2">
<span className="text-sm text-white/80">Review recording</span>
</div>
) : null}
</div>
{/* Bottom center: audio level bars (recording with active stream) */}
{isRecording && recordingAudioSource && (
<div className="z-10 absolute bottom-15">
<AudioLevelBars sourceNode={recordingAudioSource.sourceNode} />
</div>
)}
{/* Bottom center: keyboard hints */}
{isRecording && !isLoading && (
<div className="absolute bottom-4 z-10 flex items-center gap-4 text-sm text-white/50">
<span>
Release{" "}
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs">
`
</kbd>{" "}
to review
</span>
<span>
<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
</span>
</div>
)}
{isReviewing && (
<div className="absolute bottom-4 z-10 flex flex-col items-center gap-3">
{attachments.length > 0 && (
<div className="px-4">
<AttachmentStrip
attachments={attachments}
onRemove={onRemoveAttachment}
onAddClick={onAddFiles}
/>
</div>
)}
<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">
Enter
</kbd>{" "}
next
</span>
<span>
<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
</span>
<span>
<Button
variant="ghost"
type="button"
onClick={(e) => {
e.stopPropagation();
onAddFiles();
}}
title="Attach files"
>
<Paperclip className="size-4" />
attach
</Button>
</span>
</div>
</div>
)}
{/* Error state */}
{error && (
<div className="z-10 text-sm text-red-400">
{error}
</div>
)}
</div>
);
}
@@ -0,0 +1,48 @@
import { TextEditor } from "@/features/compose/text-editor";
import type { PendingAttachment } from "@/features/compose/attachment-strip";
interface TextComposeStepProps {
textContent: string;
onTextChange: (text: string) => void;
onAdvance: () => void;
onCancel: () => void;
attachments: PendingAttachment[];
onRemoveAttachment: (id: string) => void;
onAddFiles: () => void;
isDragging: boolean;
dropZoneProps: {
onDragOver: (e: React.DragEvent) => void;
onDragEnter: (e: React.DragEvent) => void;
onDragLeave: (e: React.DragEvent) => void;
onDrop: (e: React.DragEvent) => void;
};
}
export function TextComposeStep({
textContent,
onTextChange,
onAdvance,
onCancel,
attachments,
onRemoveAttachment,
onAddFiles,
isDragging,
dropZoneProps,
}: TextComposeStepProps) {
return (
<TextEditor
textContent={textContent}
onTextChange={onTextChange}
onSubmit={onAdvance}
onCancel={onCancel}
submitHint="next"
attachmentProps={{
attachments,
onRemoveAttachment,
onAddFiles,
isDragging,
dropZoneProps,
}}
/>
);
}
@@ -0,0 +1,305 @@
import { useEffect, useRef, useCallback, useState } from "react";
import { Paperclip } from "lucide-react";
import { cn } from "@/lib/utils";
import { metaKey } from "@/lib/platform";
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 ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import rehypeHighlight from "rehype-highlight";
import "highlight.js/styles/github-dark.css";
export interface TextEditorAttachmentProps {
attachments: PendingAttachment[];
onRemoveAttachment: (id: string) => void;
onAddFiles: () => void;
isDragging: boolean;
dropZoneProps: {
onDragOver: (e: React.DragEvent) => void;
onDragEnter: (e: React.DragEvent) => void;
onDragLeave: (e: React.DragEvent) => void;
onDrop: (e: React.DragEvent) => void;
};
}
interface TextEditorProps {
textContent: string;
onTextChange: (text: string) => void;
onSubmit: () => void;
onCancel: () => void;
/** Label on the ⌘+Enter hint. Defaults to "next". */
submitHint?: string;
/** When omitted, the editor renders without attachment support (no attach button, no drop zone, no strip). */
attachmentProps?: TextEditorAttachmentProps;
}
const IMMERSIVE_CHAR_LIMIT = 120;
function getImmersiveTextStyle(length: number) {
if (length < 70) return { size: "text-5xl", weight: "font-semibold" };
if (length < 130) return { size: "text-3xl", weight: "font-semibold" };
return { size: "text-2xl", weight: "font-normal" };
}
const markdownComponents: React.ComponentProps<typeof ReactMarkdown>["components"] = {
h1: ({ children }) => <h1 className="mb-3 text-3xl font-bold text-white">{children}</h1>,
h2: ({ children }) => <h2 className="mb-2 text-2xl font-semibold text-white">{children}</h2>,
h3: ({ children }) => <h3 className="mb-2 text-xl font-semibold text-white">{children}</h3>,
h4: ({ children }) => <h4 className="mb-1 text-lg font-medium text-white">{children}</h4>,
h5: ({ children }) => <h5 className="mb-1 text-base font-medium text-white">{children}</h5>,
h6: ({ children }) => <h6 className="mb-1 text-sm font-medium text-white">{children}</h6>,
p: ({ children }) => <p className="mb-3 leading-relaxed text-white last:mb-0">{children}</p>,
strong: ({ children }) => <strong className="font-semibold text-white">{children}</strong>,
em: ({ children }) => <em className="italic text-white">{children}</em>,
a: ({ href, children }) => (
<a href={href} className="text-blue-400 underline" target="_blank" rel="noreferrer">
{children}
</a>
),
code: ({ className, children, ...props }) => {
const isBlock = className?.startsWith("language-");
if (isBlock) {
return (
<code className={cn(className, "text-sm")} {...props}>
{children}
</code>
);
}
return (
<code className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-sm text-white" {...props}>
{children}
</code>
);
},
pre: ({ children }) => (
<pre className="mb-3 overflow-x-auto rounded-lg bg-black/40 p-4 text-sm last:mb-0">
{children}
</pre>
),
ul: ({ children }) => <ul className="mb-3 list-disc pl-5 text-white last:mb-0">{children}</ul>,
ol: ({ children }) => <ol className="mb-3 list-decimal pl-5 text-white last:mb-0">{children}</ol>,
li: ({ children }) => <li className="mb-1 leading-relaxed">{children}</li>,
blockquote: ({ children }) => (
<blockquote className="mb-3 border-l-2 border-white/30 pl-4 italic text-white/70 last:mb-0">
{children}
</blockquote>
),
hr: () => <hr className="my-4 border-white/10" />,
};
function MarkdownPreview({ content }: { content: string }) {
return (
<div className="min-h-0 flex-1 overflow-y-auto overflow-x-hidden break-words">
<ReactMarkdown
remarkPlugins={[remarkGfm]}
rehypePlugins={[rehypeHighlight]}
components={markdownComponents}
>
{content}
</ReactMarkdown>
</div>
);
}
export function TextEditor({
textContent,
onTextChange,
onSubmit,
onCancel,
submitHint = "next",
attachmentProps,
}: TextEditorProps) {
const textareaRef = useRef<HTMLTextAreaElement>(null);
const [previewMode, setPreviewMode] = useState(false);
const [forceCardMode, setForceCardMode] = useState(false);
const [debouncedText, setDebouncedText] = useState(textContent);
useEffect(() => {
const t = setTimeout(() => setDebouncedText(textContent), 500);
return () => clearTimeout(t);
}, [textContent]);
const linkPreviews = useAllLinkMetadata(debouncedText);
const attachmentCount = attachmentProps?.attachments.length ?? 0;
const hasEnrichments = attachmentCount > 0 || linkPreviews.length > 0;
const immersive =
textContent.length < IMMERSIVE_CHAR_LIMIT && !hasEnrichments && !forceCardMode;
useEffect(() => {
if (!previewMode) {
const t = setTimeout(() => {
const el = textareaRef.current;
if (el) {
el.focus();
el.selectionStart = el.selectionEnd = el.value.length;
}
}, 0);
return () => clearTimeout(t);
}
}, [previewMode, forceCardMode, immersive]);
useEffect(() => {
textareaRef.current?.focus();
}, []);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === "Escape") {
e.preventDefault();
onCancel();
} else if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
e.preventDefault();
if (textContent.trim()) onSubmit();
} else if (e.key === "m" && (e.metaKey || e.ctrlKey)) {
e.preventDefault();
setForceCardMode(true);
}
},
[onCancel, onSubmit, textContent],
);
const strip = attachmentProps && (
<AttachmentStrip
attachments={attachmentProps.attachments}
onRemove={attachmentProps.onRemoveAttachment}
onAddClick={attachmentProps.onAddFiles}
linkPreviews={linkPreviews}
/>
);
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>{" "}
cancel
</span>
<span>
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs">
{metaKey}+Enter
</kbd>{" "}
{submitHint}
</span>
{immersive && (
<span>
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs">
{metaKey}+M
</kbd>{" "}
markdown
</span>
)}
{attachmentProps && (
<span>
<Button
variant="ghost"
type="button"
onClick={(e) => {
e.stopPropagation();
attachmentProps.onAddFiles();
}}
title="Attach files"
>
<Paperclip className="size-4" />
attach
</Button>
</span>
)}
</div>
);
const dropZoneProps = attachmentProps?.dropZoneProps;
const isDragging = attachmentProps?.isDragging ?? false;
if (immersive) {
const style = getImmersiveTextStyle(textContent.length);
return (
<div
className={cn(
"absolute inset-0 z-50 flex items-center justify-center bg-black/90",
isDragging && "ring-2 ring-inset ring-white/30",
)}
{...dropZoneProps}
>
<div className="flex w-full flex-col items-center justify-center gap-6 px-6">
<textarea
ref={textareaRef}
value={textContent}
onChange={(e) => onTextChange(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Type a message..."
className={cn(
"w-full resize-none border-none bg-transparent p-8 text-center text-white placeholder-white/40 outline-none",
style.size,
style.weight,
)}
rows={4}
/>
</div>
{keyboardHints}
</div>
);
}
return (
<div
className={cn(
"absolute inset-0 z-50 flex items-center justify-center bg-black/90",
isDragging && "ring-2 ring-inset ring-white/30",
)}
{...dropZoneProps}
onKeyDown={handleKeyDown}
>
<div className="mx-8 flex h-[calc(100%-8rem)] w-full min-w-0 flex-col overflow-hidden rounded-2xl border border-white/10 bg-white/5 p-5 backdrop-blur-xl">
<div className="mb-3 flex shrink-0 items-center justify-end gap-1">
<button
type="button"
onClick={() => setPreviewMode(false)}
className={cn(
"rounded-md px-3 py-1 text-xs font-medium transition-colors",
!previewMode
? "bg-white/15 text-white"
: "text-white/40 hover:text-white/60",
)}
>
Write
</button>
<button
type="button"
onClick={() => setPreviewMode(true)}
className={cn(
"rounded-md px-3 py-1 text-xs font-medium transition-colors",
previewMode
? "bg-white/15 text-white"
: "text-white/40 hover:text-white/60",
)}
>
Preview
</button>
</div>
{previewMode ? (
<MarkdownPreview content={textContent} />
) : (
<textarea
ref={textareaRef}
value={textContent}
onChange={(e) => onTextChange(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Type a message... (markdown supported)"
className="min-h-0 flex-1 resize-none border-none bg-transparent font-mono text-sm leading-relaxed text-white placeholder-white/40 outline-none"
/>
)}
{hasEnrichments && strip && (
<div className="shrink-0 border-t border-white/10 pt-3">
{strip}
</div>
)}
</div>
{keyboardHints}
</div>
);
}
@@ -0,0 +1,173 @@
import { useCallback, useEffect, useRef } from "react";
import type { RecordingMode } from "@/hooks/use-recording-mode";
const VIDEO_PREFERRED_MIME = "video/webm;codecs=vp9,opus";
const VIDEO_FALLBACK_MIME = "video/webm";
const AUDIO_PREFERRED_MIME = "audio/webm;codecs=opus";
const AUDIO_FALLBACK_MIME = "audio/webm";
function getMediaMime(mode: "video" | "audio"): string {
if (mode === "audio") {
return MediaRecorder.isTypeSupported(AUDIO_PREFERRED_MIME)
? AUDIO_PREFERRED_MIME
: AUDIO_FALLBACK_MIME;
}
return MediaRecorder.isTypeSupported(VIDEO_PREFERRED_MIME)
? VIDEO_PREFERRED_MIME
: VIDEO_FALLBACK_MIME;
}
interface UseRecorderOptions {
mode: RecordingMode;
micDeviceId?: string;
cameraDeviceId?: string;
onStreamReady: (stream: MediaStream) => void;
onStreamCleanup: () => void;
onFinish: (blob: Blob, durationMs: number, mimeType: string) => void;
onError: (message: string) => void;
}
function buildConstraints(
mode: RecordingMode,
micDeviceId: string | undefined,
cameraDeviceId: string | undefined,
): MediaStreamConstraints {
const audio: MediaTrackConstraints | boolean = micDeviceId
? { deviceId: { exact: micDeviceId } }
: true;
if (mode === "audio") return { audio };
const video: MediaTrackConstraints = cameraDeviceId
? { deviceId: { exact: cameraDeviceId }, aspectRatio: { ideal: 4 / 3 } }
: { aspectRatio: { ideal: 4 / 3 } };
return { audio, video };
}
async function getStreamWithFallback(
constraints: MediaStreamConstraints,
hasDeviceId: boolean,
): Promise<MediaStream> {
try {
return await navigator.mediaDevices.getUserMedia(constraints);
} catch (err) {
// When a saved device has been unplugged, `{ exact }` throws
// OverconstrainedError. Fall back to the system default so users
// aren't blocked from recording.
if (
hasDeviceId &&
err instanceof Error &&
(err.name === "OverconstrainedError" || err.name === "NotFoundError")
) {
const relaxed: MediaStreamConstraints = {
audio: typeof constraints.audio === "object" ? true : constraints.audio,
...(constraints.video !== undefined && {
video:
typeof constraints.video === "object"
? { aspectRatio: { ideal: 4 / 3 } }
: constraints.video,
}),
};
return navigator.mediaDevices.getUserMedia(relaxed);
}
throw err;
}
}
/**
* Manages MediaRecorder lifecycle. Pure media utility — knows nothing
* about application state. The consumer provides callbacks for all outputs.
*/
export function useRecorder({
mode,
micDeviceId,
cameraDeviceId,
onStreamReady,
onStreamCleanup,
onFinish,
onError,
}: UseRecorderOptions) {
const recorderRef = useRef<MediaRecorder | null>(null);
const streamRef = useRef<MediaStream | null>(null);
const chunksRef = useRef<Blob[]>([]);
const startTimeRef = useRef<number>(0);
// Refs to avoid stale closures in MediaRecorder event handlers
const onStreamCleanupRef = useRef(onStreamCleanup);
const onFinishRef = useRef(onFinish);
const onErrorRef = useRef(onError);
useEffect(() => {
onStreamCleanupRef.current = onStreamCleanup;
onFinishRef.current = onFinish;
onErrorRef.current = onError;
});
const stopTracks = useCallback(() => {
streamRef.current?.getTracks().forEach((t) => t.stop());
streamRef.current = null;
recorderRef.current = null;
chunksRef.current = [];
onStreamCleanupRef.current();
}, []);
const startRecording = useCallback(async () => {
try {
const constraints = buildConstraints(mode, micDeviceId, cameraDeviceId);
const hasDeviceId = Boolean(micDeviceId || cameraDeviceId);
const mediaStream = await getStreamWithFallback(constraints, hasDeviceId);
streamRef.current = mediaStream;
onStreamReady(mediaStream);
chunksRef.current = [];
startTimeRef.current = Date.now();
const mime = getMediaMime(mode);
const recorder = new MediaRecorder(mediaStream, { mimeType: mime });
recorderRef.current = recorder;
recorder.ondataavailable = (e) => {
if (e.data.size > 0) chunksRef.current.push(e.data);
};
recorder.onstop = () => {
const durationMs = Date.now() - startTimeRef.current;
const blob = new Blob(chunksRef.current, { type: mime });
stopTracks();
if (blob.size > 0) {
onFinishRef.current(blob, durationMs, mime);
}
};
recorder.start();
} catch (err) {
stopTracks();
onErrorRef.current(
err instanceof Error ? err.message : "Failed to start recording",
);
}
}, [mode, micDeviceId, cameraDeviceId, onStreamReady, stopTracks]);
const stopRecording = useCallback(() => {
if (recorderRef.current?.state === "recording") {
recorderRef.current.stop();
}
}, []);
const cancelRecording = useCallback(() => {
if (recorderRef.current) {
recorderRef.current.ondataavailable = null;
recorderRef.current.onstop = null;
if (recorderRef.current.state === "recording") {
recorderRef.current.stop();
}
}
stopTracks();
}, [stopTracks]);
useEffect(() => {
return () => stopTracks();
}, [stopTracks]);
return { startRecording, stopRecording, cancelRecording };
}
@@ -0,0 +1,172 @@
import { useCallback, useEffect, useRef } from "react";
const VIDEO_PREFERRED_MIME = "video/webm;codecs=vp9,opus";
const VIDEO_FALLBACK_MIME = "video/webm";
function getScreenMime(): string {
return MediaRecorder.isTypeSupported(VIDEO_PREFERRED_MIME)
? VIDEO_PREFERRED_MIME
: VIDEO_FALLBACK_MIME;
}
// ---------------------------------------------------------------------------
// Hook
// ---------------------------------------------------------------------------
interface UseScreenRecorderOptions {
micDeviceId?: string;
onFinish: (blob: Blob, durationMs: number, mimeType: string) => void;
onError: (message: string) => void;
}
async function getMicStream(
micDeviceId: string | undefined,
): Promise<MediaStream> {
const constraints: MediaStreamConstraints = {
audio: micDeviceId ? { deviceId: { exact: micDeviceId } } : true,
};
try {
return await navigator.mediaDevices.getUserMedia(constraints);
} catch (err) {
if (
micDeviceId &&
err instanceof Error &&
(err.name === "OverconstrainedError" || err.name === "NotFoundError")
) {
return navigator.mediaDevices.getUserMedia({ audio: true });
}
throw err;
}
}
/**
* Manages screen recording via Electron's desktopCapturer.
* Captures screen video + mic audio.
*/
export function useScreenRecorder({
micDeviceId,
onFinish,
onError,
}: UseScreenRecorderOptions) {
const recorderRef = useRef<MediaRecorder | null>(null);
const screenStreamRef = useRef<MediaStream | null>(null);
const micStreamRef = useRef<MediaStream | null>(null);
const chunksRef = useRef<Blob[]>([]);
const startTimeRef = useRef<number>(0);
const cleanupIpcRef = useRef<(() => void) | null>(null);
const onFinishRef = useRef(onFinish);
const onErrorRef = useRef(onError);
useEffect(() => {
onFinishRef.current = onFinish;
onErrorRef.current = onError;
});
const stopAllTracks = useCallback(() => {
screenStreamRef.current?.getTracks().forEach((t) => t.stop());
micStreamRef.current?.getTracks().forEach((t) => t.stop());
screenStreamRef.current = null;
micStreamRef.current = null;
recorderRef.current = null;
chunksRef.current = [];
cleanupIpcRef.current?.();
cleanupIpcRef.current = null;
}, []);
const startRecording = useCallback(
async (sourceId: string) => {
try {
// 1. Screen video
const screenStream = await navigator.mediaDevices.getUserMedia({
audio: false,
video: {
mandatory: {
chromeMediaSource: "desktop",
chromeMediaSourceId: sourceId,
},
} as MediaTrackConstraints,
});
screenStreamRef.current = screenStream;
// 2. Mic audio
const micStream = await getMicStream(micDeviceId);
micStreamRef.current = micStream;
// 3. Combine screen video + mic audio
const combined = new MediaStream([
...screenStream.getVideoTracks(),
...micStream.getAudioTracks(),
]);
chunksRef.current = [];
startTimeRef.current = Date.now();
const mime = getScreenMime();
const recorder = new MediaRecorder(combined, { mimeType: mime });
recorderRef.current = recorder;
recorder.ondataavailable = (e) => {
if (e.data.size > 0) chunksRef.current.push(e.data);
};
recorder.onstop = () => {
const durationMs = Date.now() - startTimeRef.current;
const blob = new Blob(chunksRef.current, { type: mime });
stopAllTracks();
window.electronScreen.stopRecordingWindow();
if (blob.size > 0) {
onFinishRef.current(blob, durationMs, mime);
}
};
recorder.start(1000);
// 4. Show floating control window
window.electronScreen.startRecordingWindow();
// 5. Listen for stop from floating window
cleanupIpcRef.current = window.electronScreen.onStopRequested(() => {
if (recorderRef.current?.state === "recording") {
recorderRef.current.stop();
}
});
} catch (err) {
stopAllTracks();
window.electronScreen.stopRecordingWindow();
onErrorRef.current(
err instanceof Error ? err.message : "Failed to start screen recording",
);
}
},
[micDeviceId, stopAllTracks],
);
const stopRecording = useCallback(() => {
if (recorderRef.current?.state === "recording") {
recorderRef.current.stop();
}
}, []);
const cancelRecording = useCallback(() => {
if (recorderRef.current) {
recorderRef.current.ondataavailable = null;
recorderRef.current.onstop = null;
if (recorderRef.current.state === "recording") {
recorderRef.current.stop();
}
}
stopAllTracks();
window.electronScreen.stopRecordingWindow();
}, [stopAllTracks]);
// Cleanup on unmount
useEffect(() => {
return () => {
stopAllTracks();
window.electronScreen.stopRecordingWindow();
};
}, [stopAllTracks]);
return { startRecording, stopRecording, cancelRecording };
}
+184
View File
@@ -0,0 +1,184 @@
import { WindowControls } from "@/components/window-controls";
import { Button } from "@/components/ui/button";
import { useNavigate, useParams } from "react-router-dom";
import { Home, Settings, Users, Volume2, VolumeOff } from "lucide-react";
import { Switch } from "@/components/ui/switch";
import { useAutoplayStore } from "@/stores/autoplay-store";
import {
Breadcrumb,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbList,
BreadcrumbPage,
BreadcrumbSeparator,
} from "@/components/ui/breadcrumb";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import { useNetworks } from "@/hooks/use-networks";
import { particlePath } from "@/lib/particle-path";
import { useParticle } from "@/hooks/use-particle";
import type { Particle } from "@/api/types";
import { PropsWithChildren, useCallback } from "react";
import { useDockBadge } from "@/hooks/use-dock-badge";
import { toast } from "sonner";
import { RouteErrorBoundary } from "@/components/app-error-boundary";
function getParticleDisplayName(particle: Particle): string {
switch (particle.type) {
case "stream":
case "folder":
return particle.properties.name;
case "quest":
return particle.properties.title;
case "paper":
return particle.properties.title;
case "file":
return particle.properties.filename;
case "text":
return particle.properties.content.slice(0, 30);
case "media":
return particle.type;
}
}
function NetworkBreadcrumbContent({ networkId }: { networkId: string }) {
const { data: networks } = useNetworks();
const network = networks?.find((n) => n.id === networkId);
const name = network?.name ?? networkId;
const initials = name.slice(0, 2).toUpperCase();
return (
<span className="flex items-center gap-1.5">
<Avatar size="sm">
<AvatarFallback className="bg-primary/10 text-primary text-[10px] font-medium">
{initials}
</AvatarFallback>
</Avatar>
{name}
</span>
);
}
function AutoplayToggle() {
const muted = useAutoplayStore((s) => s.muted);
const toggleMuted = useAutoplayStore((s) => s.toggleMuted);
const handleToggle = useCallback(() => {
toggleMuted();
if (muted) {
toast.success("Auto-play enabled");
} else {
toast.info("Auto-play disabled");
}
}, [toggleMuted, muted]);
return (
<div className="no-drag flex items-center gap-1.5">
{muted ? <VolumeOff className="size-3.5 text-muted-foreground" /> : <Volume2 className="size-3.5" />}
<Switch
checked={!muted}
onCheckedChange={handleToggle}
aria-label={muted ? "Unmute autoplay" : "Mute autoplay"}
/>
</div>
);
}
function TopBar() {
const navigate = useNavigate();
const { networkId, "*": rest } = useParams();
const segments = [networkId, ...rest?.split("/") ?? []].filter(Boolean);
const path = rest && networkId ? particlePath(networkId, rest.split("/").filter(Boolean)) : undefined;
const { data: particle } = useParticle(path);
return (
<div className="drag-region flex items-center gap-1 border-b bg-muted p-1">
<WindowControls />
<Breadcrumb className="no-drag px-2">
<BreadcrumbList>
<BreadcrumbItem className="text-xs">
{segments.length === 0 ? (
<BreadcrumbPage className="flex items-center gap-1">
<Home className="size-3.5" />
</BreadcrumbPage>
) : (
<BreadcrumbLink
className="flex cursor-pointer items-center gap-1"
onClick={() => navigate("/")}
>
<Home className="size-3.5" />
</BreadcrumbLink>
)}
</BreadcrumbItem>
{networkId && (
<span className="contents">
<BreadcrumbSeparator />
<BreadcrumbItem className="text-xs">
{segments.length === 1 ? (
<BreadcrumbPage>
<NetworkBreadcrumbContent networkId={networkId} />
</BreadcrumbPage>
) : (
<BreadcrumbLink
className="cursor-pointer"
onClick={() => navigate(`/${networkId}`)}
>
<NetworkBreadcrumbContent networkId={networkId} />
</BreadcrumbLink>
)}
</BreadcrumbItem>
</span>
)}
{particle && (
<span key={path} className="contents">
<BreadcrumbSeparator />
<BreadcrumbItem className="text-xs">
<BreadcrumbPage>{getParticleDisplayName(particle)}</BreadcrumbPage>
</BreadcrumbItem>
</span>
)}
</BreadcrumbList>
</Breadcrumb>
<div className="flex-1" />
<AutoplayToggle />
{networkId && (
<Button
variant="ghost"
size="sm"
className="no-drag text-muted-foreground"
onClick={() => navigate(`/${networkId}/settings`)}
>
<Users className="size-3.5" />
</Button>
)}
<Button
variant="ghost"
size="sm"
className="no-drag text-muted-foreground"
onClick={() => navigate("/settings")}
>
<Settings className="size-3.5" />
</Button>
</div>
);
}
export default function Layout({ children }: PropsWithChildren) {
const { networkId } = useParams();
useDockBadge(networkId);
return (
<div className="flex h-screen flex-col">
<TopBar />
<RouteErrorBoundary>{children}</RouteErrorBoundary>
</div>
);
}
+350
View File
@@ -0,0 +1,350 @@
import { useState } from "react";
import { ExternalLink } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import { Separator } from "@/components/ui/separator";
import { Muted } from "@/components/ui/typography";
import { CopyableEmail } from "@/components/copyable-email";
import { cn } from "@/lib/utils";
import { toUserMessage } from "@/lib/errors";
import { SUPPORT_EMAIL } from "@/lib/constants";
import {
useCreateCheckoutSession,
useCreatePortalSession,
useNetworkBilling,
} from "@/hooks/use-billing";
import { useNetworkUsage } from "@/hooks/use-network-usage";
import { useIsNetworkAdmin } from "@/hooks/use-networks";
import type { BillingCadence, BillingStatus } from "@/api/types";
function formatCents(cents: number): string {
if (cents % 100 === 0) return `$${cents / 100}`;
return `$${(cents / 100).toFixed(2)}`;
}
function formatDate(date: Date): string {
return date.toLocaleDateString(undefined, {
month: "long",
day: "numeric",
year: "numeric",
});
}
function PlanStatusBadge({ status }: { status: BillingStatus["plan_status"] }) {
if (status === "past_due")
return <Badge variant="destructive">Past due</Badge>;
if (status === "canceled") return <Badge variant="secondary">Canceled</Badge>;
if (status === "trialing") return <Badge variant="secondary">Trialing</Badge>;
return null;
}
function InfoRow({
label,
value,
}: {
label: React.ReactNode;
value: React.ReactNode;
}) {
return (
<div className="flex w-full items-center gap-3 px-4 py-3">
<Muted className="text-sm">{label}</Muted>
<div className="flex-1" />
<div className="text-sm">{value}</div>
</div>
);
}
function CadenceOption({
value,
label,
perSeatCents,
billedNote,
saveBadge,
selected,
}: {
value: BillingCadence;
label: string;
perSeatCents: number;
billedNote: string;
saveBadge?: string;
selected: boolean;
}) {
return (
<Label
htmlFor={`cadence-${value}`}
className={cn(
"hover:bg-accent flex w-full cursor-pointer items-center gap-3 px-4 py-3 font-normal transition-colors",
selected && "bg-accent/50",
)}
>
<RadioGroupItem id={`cadence-${value}`} value={value} />
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<p className="text-sm font-medium">{label}</p>
{saveBadge && <Badge>{saveBadge}</Badge>}
</div>
<Muted className="text-xs">{billedNote}</Muted>
</div>
<div className="shrink-0 text-right">
<p className="text-sm font-medium">{formatCents(perSeatCents)}</p>
<Muted className="text-xs">per seat / mo</Muted>
</div>
</Label>
);
}
function formatResetLocal(resetAt: Date): string {
const time = resetAt.toLocaleTimeString(undefined, {
hour: "numeric",
minute: "2-digit",
});
const now = new Date();
const isSameDay =
resetAt.getFullYear() === now.getFullYear() &&
resetAt.getMonth() === now.getMonth() &&
resetAt.getDate() === now.getDate();
return `${isSameDay ? "today" : "tomorrow"} at ${time}`;
}
/**
* Read-only plan + quota summary, sourced from `/usage` (member-accessible).
* The `/billing` endpoint is admin-gated, so we can't use it for the
* everyone-visible summary.
*/
function PlanSummary({ networkId }: { networkId: string }) {
const { data: usage } = useNetworkUsage(networkId);
if (!usage) return null;
const isPro = usage.plan === "pro";
return (
<>
<InfoRow
label="Plan"
value={
<div className="flex items-center gap-2">
<span>{isPro ? "Llink Pro" : "Llink Free"}</span>
<Badge variant={isPro ? "default" : "secondary"}>
{isPro ? "Pro" : "Free"}
</Badge>
</div>
}
/>
{!isPro && (
<>
<Separator className="mx-4" />
<InfoRow
label="Today's messages"
value={
usage && usage.limit != null ? (
<div className="flex flex-col items-end">
<span className="tabular-nums">
{usage.used} / {usage.limit}
</span>
<Muted className="text-xs">
Resets {formatResetLocal(usage.reset_at)}
</Muted>
</div>
) : (
<Muted className="text-sm"></Muted>
)
}
/>
</>
)}
</>
);
}
function FreeBilling({
networkId,
billing,
}: {
networkId: string;
billing: BillingStatus;
}) {
const createCheckout = useCreateCheckoutSession(networkId);
const [cadence, setCadence] = useState<BillingCadence>("annual");
const handleUpgrade = () => {
createCheckout.mutate(cadence, {
onSuccess: ({ url }) => window.electronLink.openExternal(url),
});
};
const annualPerSeatMonthlyCents = Math.round(billing.price_annual_cents / 12);
const savingsPct = Math.round(
(1 - annualPerSeatMonthlyCents / billing.price_monthly_cents) * 100,
);
return (
<>
<RadioGroup
value={cadence}
onValueChange={(v) => setCadence(v as BillingCadence)}
className="gap-0"
>
<CadenceOption
value="annual"
label="Annual"
perSeatCents={annualPerSeatMonthlyCents}
billedNote="Billed annually"
saveBadge={savingsPct > 0 ? `Save ${savingsPct}%` : undefined}
selected={cadence === "annual"}
/>
<Separator className="mx-4" />
<CadenceOption
value="monthly"
label="Monthly"
perSeatCents={billing.price_monthly_cents}
billedNote="Billed monthly · cancel anytime"
selected={cadence === "monthly"}
/>
</RadioGroup>
<div className="px-4 py-3">
<Button
className="w-full"
onClick={handleUpgrade}
disabled={createCheckout.isPending}
>
{createCheckout.isPending ? "Opening Stripe..." : "Upgrade to Pro"}
</Button>
</div>
</>
);
}
function ProBilling({
networkId,
billing,
}: {
networkId: string;
billing: BillingStatus;
}) {
const createPortal = useCreatePortalSession(networkId);
const handleManage = () => {
createPortal.mutate(undefined, {
onSuccess: ({ url }) => window.electronLink.openExternal(url),
});
};
const cadenceLabel = billing.cadence === "annual" ? "Annual" : "Monthly";
const perSeatCents =
billing.cadence === "annual"
? Math.round(billing.price_annual_cents / 12)
: billing.price_monthly_cents;
const renewal = billing.current_period_end
? formatDate(billing.current_period_end)
: null;
return (
<>
{billing.cancel_at_period_end && renewal && (
<div className="border-destructive/30 bg-destructive/10 text-destructive mx-4 my-2 rounded-md border px-3 py-2 text-sm">
Your subscription is set to downgrade to Free on {renewal}.
</div>
)}
{billing.plan_status === "past_due" && (
<div className="border-destructive/30 bg-destructive/10 text-destructive mx-4 my-2 rounded-md border px-3 py-2 text-sm">
Your last payment failed. Update your payment method to keep Pro
active.
</div>
)}
<InfoRow
label="Billing"
value={
<div className="flex items-center gap-2">
<span>{`${cadenceLabel} · ${formatCents(perSeatCents)} / seat / mo`}</span>
<PlanStatusBadge status={billing.plan_status} />
</div>
}
/>
<Separator className="mx-4" />
<InfoRow label="Seats" value={billing.seats} />
{renewal && (
<>
<Separator className="mx-4" />
<InfoRow
label={billing.cancel_at_period_end ? "Ends" : "Renews"}
value={renewal}
/>
</>
)}
<div className="px-4 py-3">
<Button
variant="outline"
className="w-full"
onClick={handleManage}
disabled={createPortal.isPending}
>
<ExternalLink className="mr-2 size-3.5" />
{createPortal.isPending
? "Opening Stripe..."
: "Manage subscription"}
</Button>
</div>
</>
);
}
/**
* Unified billing section. Shows the plan + usage summary to every member,
* and the admin-only management controls (upgrade / portal) below.
*
* `/billing` is admin-gated, so the management controls are the only part
* that depends on it — members rely on `/usage` for the summary.
*/
export function BillingSection({ networkId }: { networkId: string }) {
const isAdmin = useIsNetworkAdmin(networkId);
return (
<>
<PlanSummary networkId={networkId} />
{isAdmin && (
<>
<Separator className="mx-4" />
<AdminBillingControls networkId={networkId} />
<Separator className="mx-4" />
<InfoRow
label={
<span className="flex flex-col">
<span>Billing support</span>
<span className="text-muted-foreground text-xs">
Invoices, receipts, or plan changes
</span>
</span>
}
value={<CopyableEmail email={SUPPORT_EMAIL} />}
/>
</>
)}
</>
);
}
function AdminBillingControls({ networkId }: { networkId: string }) {
const { data: billing, isLoading, error } = useNetworkBilling(networkId);
if (isLoading || !billing) {
return (
<div className="px-4 py-3">
<Muted className="text-sm">Loading billing...</Muted>
</div>
);
}
if (error) {
return (
<div className="px-4 py-3">
<Muted className="text-sm">Couldn't load billing: {toUserMessage(error)}</Muted>
</div>
);
}
if (billing.plan === "pro") {
return <ProBilling networkId={networkId} billing={billing} />;
}
return <FreeBilling networkId={networkId} billing={billing} />;
}
+126
View File
@@ -0,0 +1,126 @@
import { useCallback, useState } from "react";
import { useNavigate, useParams, useSearchParams } from "react-router-dom";
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 { ComposeOverlay } from "./compose/compose-overlay";
import { ComposeQuotaIndicator } from "./compose/compose-quota-indicator";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { useStreamParticles } from "@/hooks/use-stream-particles";
import { useStreamKeyboardNav } from "@/hooks/use-stream-keyboard-nav";
/**
* Route-level component for /:networkId (index).
* Shows root-level particles for the selected network.
*/
export default function NetworkRoot() {
const { networkId } = useParams();
const navigate = useNavigate();
const path = particlePath(networkId!, []);
const [composeActive, setComposeActive] = useState(false);
const [searchParams, setSearchParams] = useSearchParams();
const statusTab: "open" | "closed" =
searchParams.get("status") === "closed" ? "closed" : "open";
const setStatusTab = (next: "open" | "closed") => {
setSearchParams(
(prev) => {
const params = new URLSearchParams(prev);
if (next === "open") params.delete("status");
else params.set("status", next);
return params;
},
{ replace: true },
);
};
const { streams, isLoading, canLoadMore, loadMore } = useStreamParticles(path, {
status: statusTab,
});
const { selectedIndex } = useStreamKeyboardNav({
streams,
enabled: !composeActive,
onNavigate: useCallback(
(streamId: string) => navigate(`/${networkId}/${streamId}`),
[navigate, networkId],
),
});
return (
<div className="relative flex min-h-0 flex-1 flex-col">
{/* Top bar — stays in place */}
<div className="flex shrink-0 items-center p-1 border-b">
<Tabs
value={statusTab}
onValueChange={(v) => setStatusTab(v === "closed" ? "closed" : "open")}
>
<TabsList>
<TabsTrigger value="open"><CircleDot className="size-3 text-green-500" /> Open</TabsTrigger>
<TabsTrigger value="closed"><CircleCheckBig className="size-3" /> Closed</TabsTrigger>
</TabsList>
</Tabs>
</div>
{/* Scrollable content */}
<div className="min-h-0 flex-1 overflow-y-auto overscroll-contain pb-14 py-2">
<ParticleListView
streams={streams}
networkId={networkId!}
isLoading={isLoading}
selectedIndex={selectedIndex}
canLoadMore={canLoadMore}
onLoadMore={loadMore}
/>
</div>
<ComposeOverlay networkId={networkId!} onActiveChange={setComposeActive} />
{!composeActive && (
<div className="pointer-events-none absolute inset-x-0 bottom-16 z-20 flex justify-center px-3">
<ComposeQuotaIndicator networkId={networkId!} />
</div>
)}
<div className="pointer-events-none absolute inset-x-0 bottom-0 z-20 flex justify-center p-3">
<div className="pointer-events-auto">
<NetworkRootControls />
</div>
</div>
</div>
);
}
function NetworkRootControls() {
return (
<div className="flex items-center gap-4 text-sm text-white/50">
<span>
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs">
</kbd>{" "}
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs">
Enter
</kbd>{" "}
navigate
</span>
<span>
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs">
19
</kbd>{" "}
jump
</span>
<VideoAudioToggle />
<span>
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs">
Hold `
</kbd>{" "}
to start
</span>
<span>
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs">
T
</kbd>{" "}
text
</span>
</div>
);
}
@@ -0,0 +1,302 @@
import { useState } from "react";
import { useNavigate } from "react-router-dom";
import { Check, Plus, Settings, Users } from "lucide-react";
import { toast } from "sonner";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { Small } from "@/components/ui/typography";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Separator } from "@/components/ui/separator";
import { useNetworks } from "@/hooks/use-networks";
import { useMyInvitations, useAcceptInvitation } from "@/hooks/use-member-management";
import { apiClient } from "@/api/client";
import { Progress } from "@/components/ui/progress";
import { toUserMessage } from "@/lib/errors";
import type { Network, Invitation } from "@/api/types";
function NetworkRow({
network,
onClick,
onSettingsClick,
}: {
network: Network;
onClick: () => void;
onSettingsClick: () => void;
}) {
const initials = network.name.slice(0, 2).toUpperCase();
const memberCount = network.humans.length;
return (
<button
type="button"
onClick={onClick}
className="group flex w-full items-center gap-3 px-4 py-3 text-left transition-colors hover:bg-accent"
>
<Avatar>
<AvatarFallback className="bg-primary/10 text-primary font-medium">
{initials}
</AvatarFallback>
</Avatar>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium">{network.name}</p>
<div className="text-muted-foreground flex items-center gap-1">
<Users className="size-3" />
<Small className="text-muted-foreground">
{memberCount} {memberCount === 1 ? "member" : "members"}
</Small>
</div>
</div>
<span
role="button"
tabIndex={0}
className="text-muted-foreground hover:text-foreground opacity-0 transition-opacity group-hover:opacity-100"
onClick={(e) => {
e.stopPropagation();
onSettingsClick();
}}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.stopPropagation();
onSettingsClick();
}
}}
>
<Settings className="size-4" />
</span>
</button>
);
}
function InvitationRow({ invitation }: { invitation: Invitation }) {
const acceptInvitation = useAcceptInvitation();
const initials = invitation.network_name.slice(0, 2).toUpperCase();
const handleAccept = () => {
acceptInvitation.mutate(invitation.network_id, {
onSuccess: () => {
toast.success(`Joined ${invitation.network_name}`);
},
});
};
return (
<div className="flex w-full items-center gap-3 px-4 py-3">
<Avatar>
<AvatarFallback className="bg-primary/10 text-primary font-medium">
{initials}
</AvatarFallback>
</Avatar>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium">
{invitation.network_name}
</p>
<Small className="text-muted-foreground">You've been invited</Small>
</div>
<Button
size="sm"
onClick={handleAccept}
disabled={acceptInvitation.isPending}
>
<Check className="mr-1 size-3.5" />
{acceptInvitation.isPending ? "Joining..." : "Accept"}
</Button>
</div>
);
}
function CreateNetworkDialog({
open,
onOpenChange,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
}) {
const [name, setName] = useState("");
const navigate = useNavigate();
const queryClient = useQueryClient();
const createNetwork = useMutation({
mutationFn: (networkName: string) =>
apiClient.createNetwork({ name: networkName }),
onSuccess: (network) => {
queryClient.invalidateQueries({ queryKey: ["networks"] });
toast.success(`Created ${network.name}`);
onOpenChange(false);
setName("");
navigate(`/${network.id}/settings`);
},
});
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
const trimmed = name.trim();
if (!trimmed) return;
createNetwork.mutate(trimmed);
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>Create a Network</DialogTitle>
<DialogDescription>Caution: creating a network will create a new billing account. If your team already has a network, ask them for an invite.</DialogDescription>
</DialogHeader>
<form onSubmit={handleSubmit}>
<div className="py-4">
<Input
placeholder="Network name"
value={name}
onChange={(e) => setName(e.target.value)}
autoFocus
/>
</div>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
>
Cancel
</Button>
<Button
type="submit"
disabled={!name.trim() || createNetwork.isPending}
>
{createNetwork.isPending ? "Creating..." : "Create"}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}
export default function NetworkSelector() {
const navigate = useNavigate();
const [createDialogOpen, setCreateDialogOpen] = useState(false);
const { data: networks, isPending, error, refetch } = useNetworks();
const { data: invitations } = useMyInvitations();
if (isPending) {
return <Progress />;
}
if (error) {
return (
<div className="flex h-full flex-col items-center justify-center gap-3 p-6 text-center">
<p className="text-sm font-medium">Couldn't load your networks</p>
<p className="text-muted-foreground text-xs">{toUserMessage(error)}</p>
<Button size="sm" variant="outline" onClick={() => refetch()}>
Try again
</Button>
</div>
);
}
const hasInvitations = invitations && invitations.length > 0;
const hasNetworks = networks && networks.length > 0;
if (!hasNetworks && !hasInvitations) {
return (
<div className="mx-auto flex h-full max-w-sm flex-col items-center justify-center gap-4 px-4 text-center">
<p className="text-muted-foreground">
You don't have access to any networks yet. Create one or ask your admin for an invite.
</p>
<div className="flex flex-row gap-1">
<Button variant="outline" onClick={() => refetch()}>Refresh</Button>
<Button onClick={() => setCreateDialogOpen(true)}>
<Plus className="mr-1 size-3.5" />
Create Network
</Button>
</div>
<CreateNetworkDialog
open={createDialogOpen}
onOpenChange={setCreateDialogOpen}
/>
</div>
);
}
return (
<>
<ScrollArea className="h-full">
<div className="py-1">
{hasInvitations && (
<>
<div className="flex items-center gap-2 px-4 pb-1 pt-4">
<p className="text-muted-foreground text-xs font-medium uppercase tracking-wider">
Pending Invitations
</p>
<Badge variant="secondary" className="text-xs">
{invitations.length}
</Badge>
</div>
{invitations.map((inv, index) => (
<div key={`${inv.network_id}-${inv.email}`}>
<InvitationRow invitation={inv} />
{index < invitations.length - 1 && (
<Separator className="mx-4" />
)}
</div>
))}
<Separator className="mx-4 mt-2" />
</>
)}
{hasNetworks && (
<>
{hasInvitations && (
<p className="text-muted-foreground px-4 pb-1 pt-4 text-xs font-medium uppercase tracking-wider">
Your Networks
</p>
)}
{networks.map((network, index) => (
<div key={network.id}>
<NetworkRow
network={network}
onClick={() => navigate(`/${network.id}`)}
onSettingsClick={() =>
navigate(`/${network.id}/settings`)
}
/>
{index < networks.length - 1 && (
<Separator className="mx-4" />
)}
</div>
))}
</>
)}
<Separator className="mx-4 mt-2" />
<div className="px-4 py-3">
<Button
variant="outline"
size="sm"
onClick={() => setCreateDialogOpen(true)}
>
<Plus className="mr-1 size-3.5" />
New Network
</Button>
</div>
</div>
</ScrollArea>
<CreateNetworkDialog
open={createDialogOpen}
onOpenChange={setCreateDialogOpen}
/>
</>
);
}
@@ -0,0 +1,368 @@
import { useEffect, useRef, useState } from "react";
import { useNavigate, useParams, useSearchParams } from "react-router-dom";
import { ArrowLeft, CreditCard, Mail, Shield, Users, X } from "lucide-react";
import { toast } from "sonner";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Separator } from "@/components/ui/separator";
import { 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 { ConfirmDestructiveOverlay } from "@/components/confirm-destructive-overlay";
import type { Human } from "@/api/types";
function MemberRow({
human,
isAdmin,
onRemove,
}: {
human: Human;
isAdmin: boolean;
onRemove?: () => void;
}) {
const initials = human.email_prefix.slice(0, 2).toUpperCase();
return (
<div className="flex w-full items-center gap-3 px-4 py-3">
<Avatar>
<AvatarFallback className="bg-primary/10 text-primary font-medium">
{initials}
</AvatarFallback>
</Avatar>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium">{human.email_prefix}</p>
<Muted className="text-xs">{human.email}</Muted>
</div>
{isAdmin && (
<Badge variant="secondary" className="shrink-0">
<Shield className="mr-1 size-3" />
Admin
</Badge>
)}
{onRemove && (
<Button
variant="ghost"
size="icon-sm"
onClick={onRemove}
className="text-muted-foreground hover:text-destructive shrink-0"
aria-label={`Remove ${human.email}`}
>
<X className="size-3.5" />
</Button>
)}
</div>
);
}
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,
}: {
email: string;
networkId: string;
}) {
const revokeInvitation = useRevokeInvitation(networkId);
const handleRevoke = () => {
revokeInvitation.mutate(email, {
onSuccess: () => {
toast.success(`Invitation to ${email} revoked`);
},
});
};
return (
<div className="flex w-full items-center gap-3 px-4 py-3">
<span className="text-muted-foreground flex size-8 items-center justify-center">
<Mail className="size-4" />
</span>
<div className="min-w-0 flex-1">
<p className="truncate text-sm">{email}</p>
<Muted className="text-xs">Pending</Muted>
</div>
<Button
variant="ghost"
size="icon-sm"
onClick={handleRevoke}
disabled={revokeInvitation.isPending}
className="text-muted-foreground hover:text-destructive shrink-0"
>
<X className="size-3.5" />
</Button>
</div>
);
}
function SectionHeader({
icon,
title,
description,
trailing,
}: {
icon: React.ReactNode;
title: string;
description?: string;
trailing?: 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="flex items-center gap-2">
<h2 className="text-sm font-semibold tracking-tight">{title}</h2>
{trailing}
</div>
{description && <Muted className="text-xs">{description}</Muted>}
</div>
</div>
);
}
function Section({ children }: { children: React.ReactNode }) {
return (
<section className="bg-card/40 mx-4 mb-2 overflow-hidden rounded-lg border">
{children}
</section>
);
}
export default function NetworkSettingsPage() {
const navigate = useNavigate();
const { networkId } = useParams<{ networkId: string }>();
const [searchParams] = useSearchParams();
const { data: networks } = useNetworks();
const network = networks?.find((n) => n.id === networkId);
const { data: invitations, error: invitationsError } = useNetworkInvitations(networkId!);
const currentUser = useAuthStore((s) => s.user);
const isAdmin = currentUser?.id === network?.admin_human.id;
const [memberToRemove, setMemberToRemove] = useState<Human | null>(null);
const removeMember = useRemoveMember(networkId!);
const billingRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (searchParams.get("section") === "billing") {
billingRef.current?.scrollIntoView({ behavior: "smooth", block: "start" });
}
}, [searchParams]);
const networkName = network?.name ?? "Network";
const memberCount = network?.humans.length ?? 0;
const pendingCount = invitations?.length ?? 0;
const networkInitials = networkName.slice(0, 2).toUpperCase();
return (
<div className="flex h-screen flex-col">
<div className="drag-region flex items-center gap-3 border-b px-3 py-1">
<WindowControls />
<Button
variant="ghost"
size="sm"
className="no-drag text-muted-foreground"
onClick={() => navigate(`/${networkId}`)}
>
<ArrowLeft className="size-3.5" />
</Button>
<span className="text-sm font-medium">Settings</span>
<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>
</div>
</div>
<Section>
<SectionHeader
icon={<Users className="size-4" />}
title="Members"
description="People with access to this network."
trailing={
<Badge variant="secondary" className="tabular-nums">
{memberCount}
</Badge>
}
/>
<Separator />
{network?.humans.map((human, index) => {
const isRowAdmin = human.id === network.admin_human.id;
const canRemove =
isAdmin && !isRowAdmin && human.id !== currentUser?.id;
return (
<div key={human.id}>
<MemberRow
human={human}
isAdmin={isRowAdmin}
onRemove={canRemove ? () => setMemberToRemove(human) : undefined}
/>
{index < network.humans.length - 1 && (
<Separator className="mx-4" />
)}
</div>
);
})}
</Section>
{isAdmin && network && (
<Section>
<SectionHeader
icon={<Mail className="size-4" />}
title="Invitations"
description="Invite teammates by email. They'll get a link to join."
trailing={
pendingCount > 0 ? (
<Badge variant="secondary" className="tabular-nums">
{pendingCount} pending
</Badge>
) : undefined
}
/>
<Separator />
<InviteForm networkId={networkId!} />
{invitationsError && (
<>
<Separator />
<p className="text-muted-foreground px-4 py-3 text-xs">
Couldn't load pending invitations.
</p>
</>
)}
{pendingCount > 0 && (
<>
<Separator />
<div className="px-4 pb-1 pt-3">
<Muted className="text-xs font-medium uppercase tracking-wider">
Pending
</Muted>
</div>
{invitations!.map((inv, index) => (
<div key={inv.email}>
<PendingInvitationRow
email={inv.email}
networkId={networkId!}
/>
{index < invitations!.length - 1 && (
<Separator className="mx-4" />
)}
</div>
))}
</>
)}
</Section>
)}
<div ref={billingRef}>
<Section>
<SectionHeader
icon={<CreditCard className="size-4" />}
title="Billing"
description={
isAdmin
? "Manage your plan, seats, and payment."
: "Your network's current plan and usage."
}
/>
<Separator />
<BillingSection networkId={networkId!} />
</Section>
</div>
<div className="h-6" />
</ScrollArea>
{memberToRemove && (
<ConfirmDestructiveOverlay
title={`Remove ${memberToRemove.email_prefix}?`}
description={
<ul className="list-disc space-y-1 pl-4">
<li>
They'll lose access to this network's streams and files within
seconds.
</li>
<li>Any content they posted stays in the network.</li>
<li>
If they're in a live huddle, they may remain until the call ends.
</li>
</ul>
}
confirmLabel="Remove"
pendingLabel="Removing"
isPending={removeMember.isPending}
onConfirm={() => {
const target = memberToRemove;
removeMember.mutate(target.id, {
onSuccess: () => {
toast.success(`Removed ${target.email}`);
setMemberToRemove(null);
},
});
}}
onClose={() => {
if (!removeMember.isPending) setMemberToRemove(null);
}}
/>
)}
</div>
);
}
@@ -0,0 +1,61 @@
import { useCallback, useState } from "react";
import { toast } from "sonner";
import { ConfirmDestructiveOverlay } from "@/components/confirm-destructive-overlay";
import { softDeleteParticle } from "@/lib/firestore-particles";
import { particlePath, toFirestoreDocPath } from "@/lib/particle-path";
import type { Particle } from "@/api/types";
import { useSuspendPlayback } from "@/hooks/use-suspend-playback";
interface DeleteParticleOverlayProps {
networkId: string;
streamId: string;
particle: Particle;
userId: string;
onClose: () => void;
}
export function DeleteParticleOverlay({
networkId,
streamId,
particle,
userId,
onClose,
}: DeleteParticleOverlayProps) {
useSuspendPlayback(true, "delete-particle");
const [deleting, setDeleting] = useState(false);
const handleDelete = useCallback(async () => {
if (deleting) return;
setDeleting(true);
try {
const docPath = toFirestoreDocPath(
particlePath(networkId, [streamId, particle.id]),
);
await softDeleteParticle(docPath, userId);
toast.success("Particle deleted");
onClose();
} catch (e) {
const message = e instanceof Error ? e.message : "Failed to delete particle";
toast.error(message);
setDeleting(false);
}
}, [deleting, networkId, onClose, particle.id, streamId, userId]);
return (
<ConfirmDestructiveOverlay
title="Delete this particle?"
description={
<p>
This cannot be undone. Other viewers will see a "This particle was
deleted" message in its place.
</p>
}
confirmLabel="Delete"
pendingLabel="Deleting…"
isPending={deleting}
onConfirm={handleDelete}
onClose={onClose}
/>
);
}
@@ -0,0 +1,51 @@
import { useEffect } from "react";
import { Trash2 } from "lucide-react";
import type { Particle } from "@/api/types";
import { useNetwork } from "@/hooks/use-networks";
import { resolveHumanDisplay } from "@/lib/humans";
// How long to linger on a tombstone before auto-advancing. Matches the
// "reading" cadence of a short text particle.
const TOMBSTONE_DURATION_MS = 2000;
interface DeletedParticleViewProps {
particle: Particle;
networkId: string;
paused: boolean;
onEnded: () => void;
}
export function DeletedParticleView({
particle,
networkId,
paused,
onEnded,
}: DeletedParticleViewProps) {
const network = useNetwork(networkId);
const deleterId =
"deleted_by_human_id" in particle ? particle.deleted_by_human_id : undefined;
const deleter = deleterId
? resolveHumanDisplay(deleterId, network?.humans)
: null;
useEffect(() => {
if (paused) return;
const timeout = setTimeout(onEnded, TOMBSTONE_DURATION_MS);
return () => clearTimeout(timeout);
}, [paused, onEnded, particle.id]);
return (
<div className="flex h-full w-full items-center justify-center bg-gradient-to-b from-black/40 via-black/20 to-black/40 px-8">
<div className="flex flex-col items-center gap-3 text-center">
<Trash2 className="text-white/40 size-6" />
<p className="text-white/70 text-base font-medium">
This particle was deleted
</p>
{deleter && (
<p className="text-white/40 text-xs">by {deleter.displayName}</p>
)}
</div>
</div>
);
}
@@ -0,0 +1,65 @@
import type { Particle } from "@/api/types";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { FileIcon, HelpCircleIcon, ScrollTextIcon, BookOpenIcon } from "lucide-react";
import { useNetwork } from "@/hooks/use-networks";
import { resolveHumanDisplay } from "@/lib/humans";
const TYPE_META: Record<string, { icon: typeof FileIcon; label: string }> = {
quest: { icon: ScrollTextIcon, label: "Quest" },
paper: { icon: BookOpenIcon, label: "Paper" },
file: { icon: FileIcon, label: "File" },
};
interface FallbackParticleViewProps {
particle: Particle;
networkId: string;
}
export function FallbackParticleView({ particle, networkId }: FallbackParticleViewProps) {
const network = useNetwork(networkId);
const creator = resolveHumanDisplay(particle.created_by_human_id, network?.humans);
const meta = TYPE_META[particle.type] ?? {
icon: HelpCircleIcon,
label: particle.type,
};
const Icon = meta.icon;
const title = (() => {
switch (particle.type) {
case "quest":
return particle.properties.title;
case "paper":
return particle.properties.title;
case "file":
return particle.properties.filename;
case "folder":
return particle.properties.name;
default:
return null;
}
})();
return (
<div className="flex h-full w-full items-center justify-center p-8">
<Card className="w-full max-w-sm">
<CardHeader className="flex flex-row items-center gap-3">
<Icon className="text-muted-foreground h-6 w-6 shrink-0" />
<div>
<CardTitle className="text-base">{meta.label}</CardTitle>
{title && <CardDescription>{title}</CardDescription>}
</div>
</CardHeader>
<CardContent>
<p className="text-muted-foreground text-xs">
From {creator.displayName}
</p>
</CardContent>
</Card>
</div>
);
}
@@ -0,0 +1,23 @@
import { Particle } from "@/api/types";
import { useLiveParticleChildren } from "@/hooks/use-particle";
import { parseParticlePath, type ParticlePath } from "@/lib/particle-path";
import { ComposeOverlay } from "@/features/compose/compose-overlay";
interface FolderViewProps {
folderParticle: Particle;
path: ParticlePath;
}
export function FolderView({ path, folderParticle }: FolderViewProps) {
const { children, error, isLoading } = useLiveParticleChildren(path);
const { networkId } = parseParticlePath(path);
return (
<div className="flex h-full items-center justify-center">
<p className="text-muted-foreground text-sm">
Folder view {folderParticle.id}
</p>
<ComposeOverlay networkId={networkId} />
</div>
);
}
@@ -0,0 +1,170 @@
import { forwardRef, useEffect, useImperativeHandle, useRef, useState } from "react";
import type { Particle } from "@/api/types";
import type { ParticlePath } from "@/lib/particle-path";
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 { useAudioSource } from "@/components/audio/use-audio-source";
import { useParticleAttachments } from "@/hooks/use-particle-attachments";
import { ParticleAttachments } from "@/features/particles/particle-attachments";
type MediaParticle = Extract<Particle, { type: "media" }>;
export interface MediaParticleHandle {
/** Seek by delta. Returns true if seeked, false if at boundary (should navigate). */
seek: (deltaSec: number) => boolean;
setPlaybackRate: (rate: number) => void;
}
interface MediaParticleViewProps {
particle: MediaParticle;
streamPath: ParticlePath;
paused: boolean;
onEnded: () => void;
onProgress?: (ratio: number) => void;
}
export const MediaParticleView = forwardRef<MediaParticleHandle, MediaParticleViewProps>(function MediaParticleView({
particle,
streamPath,
paused,
onEnded,
onProgress,
}, ref) {
const { data: url, error } = useDownloadUrl(particle.properties.object_id);
const { attachments } = useParticleAttachments(streamPath, particle.id);
const videoRef = useRef<HTMLVideoElement>(null);
const audioRef = useRef<HTMLAudioElement>(null);
const isAudio = particle.properties.mime_type?.startsWith("audio/");
useImperativeHandle(ref, () => ({
seek(deltaSec: number) {
const el = isAudio ? audioRef.current : videoRef.current;
if (!el) return false;
if (deltaSec < 0 && el.currentTime < Math.abs(deltaSec)) return false;
if (deltaSec > 0 && el.duration - el.currentTime < deltaSec) return false;
el.currentTime = Math.max(0, Math.min(el.duration, el.currentTime + deltaSec));
return true;
},
setPlaybackRate(rate: number) {
const el = isAudio ? audioRef.current : videoRef.current;
if (el) el.playbackRate = rate;
},
}), [isAudio]);
const [currentTime, setCurrentTime] = useState(0);
const transcript = particle.properties.transcript;
const { activeSentence, activeWordIndex } = useTranscriptPlayback(
transcript,
currentTime,
);
// WORKAROUND: useAudioSource needs an audio element, but audioRef is only set after mount
const [audioEl, setAudioEl] = useState<HTMLAudioElement | null>(null);
const audioSource = useAudioSource(audioEl);
useEffect(() => {
const el = isAudio ? audioRef.current : videoRef.current;
if (!el) return;
if (paused) {
el.pause();
} else if (!el.ended) {
// Calling play() on a naturally-finished element restarts it from 0.
el.play().catch(() => {
console.warn("Playback failed", { particleId: particle.id });
});
}
}, [paused, isAudio, particle.id]);
if (error) {
return (
<div className="text-muted-foreground flex items-center justify-center text-sm">
Failed to load media
</div>
);
}
if (!url) {
return <Skeleton className="h-full w-full rounded-none" />;
}
const handleTimeUpdate = (e: React.SyntheticEvent<HTMLAudioElement | HTMLVideoElement>) => {
const { currentTime: time, duration } = e.currentTarget;
setCurrentTime(time);
// WebM files from MediaRecorder (screen recordings) often report Infinity/NaN
// duration until fully buffered — fall back to the known duration from metadata.
const effectiveDuration = Number.isFinite(duration) && duration > 0
? duration
: particle.properties.duration_ms / 1000;
if (effectiveDuration > 0) onProgress?.(time / effectiveDuration);
};
const attachmentOverlay = attachments.length > 0 && (
<div className="absolute inset-x-0 top-12 z-10 px-4">
<ParticleAttachments attachments={attachments} variant="compact" />
</div>
);
if (isAudio) {
return (
<div className="relative flex h-full w-full items-center justify-center bg-black/90">
<audio
ref={(el) => {
audioRef.current = el;
setAudioEl(el);
}}
crossOrigin="anonymous"
src={url}
autoPlay
onEnded={onEnded}
onTimeUpdate={handleTimeUpdate}
/>
{audioSource && (
<div className="z-10 absolute bottom-20">
<AudioLevelBars sourceNode={audioSource.sourceNode} />
</div>
)}
{transcript && (
<TranscriptOverlay
transcript={transcript}
activeSentence={activeSentence}
activeWordIndex={activeWordIndex}
centered
/>
)}
{attachmentOverlay}
</div>
);
}
return (
<div className="relative h-full w-full">
<video
ref={videoRef}
src={url}
autoPlay
playsInline
onEnded={onEnded}
onTimeUpdate={handleTimeUpdate}
className={`h-full w-full ${particle.properties.source === "screen" ? "object-contain bg-black" : "object-cover"}`}
/>
{transcript && (
<TranscriptOverlay
transcript={transcript}
activeSentence={activeSentence}
activeWordIndex={activeWordIndex}
/>
)}
{attachmentOverlay}
</div>
);
});
@@ -0,0 +1,275 @@
import { useMemo, useState } from "react";
import { Download, ExternalLink, FileIcon, ImageIcon } from "lucide-react";
import type { Particle } from "@/api/types";
import { useDownloadUrl } from "@/hooks/use-download-url";
import { Skeleton } from "@/components/ui/skeleton";
import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area";
import { Button } from "@/components/ui/button";
import {
AttachmentLightbox,
getAttachmentHandler,
type AttachmentItem,
} from "@/features/attachments/attachment-lightbox";
type FileParticle = Extract<Particle, { type: "file" }>;
interface ParticleAttachmentsProps {
attachments: FileParticle[];
variant?: "inline" | "compact";
}
function formatFileSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
function particleToItem(p: FileParticle): AttachmentItem {
return {
id: p.id,
filename: p.properties.filename,
mimeType: p.properties.mime_type,
sizeBytes: p.properties.size_bytes,
source: { kind: "remote", objectId: p.properties.object_id },
};
}
/**
* Open the lightbox for previewable types; hand off to the OS for files.
*/
function openParticle(
particle: FileParticle,
index: number,
url: string | undefined,
onPreview: (index: number) => void,
) {
if (getAttachmentHandler(particle.properties.mime_type) === "lightbox") {
onPreview(index);
} else if (url) {
window.electronLink.openExternal(url);
}
}
function ImageAttachment({
particle,
onPreview,
}: {
particle: FileParticle;
onPreview: () => void;
}) {
const { data: url, isLoading } = useDownloadUrl(particle.properties.object_id);
if (isLoading || !url) {
return <Skeleton className="h-20 w-20 shrink-0 rounded-lg bg-white/10" />;
}
const handleDownload = (e: React.MouseEvent) => {
e.stopPropagation();
window.electronAttachment.download(url, particle.properties.filename);
};
return (
<button
type="button"
onClick={(e) => {
e.stopPropagation();
onPreview();
}}
className="group relative block h-30 w-40 shrink-0 overflow-hidden rounded-lg bg-white/10"
>
<img
src={url}
alt={particle.properties.filename}
className="h-full w-full object-cover"
/>
<span
role="button"
tabIndex={0}
onClick={handleDownload}
className="absolute bottom-1 right-1 rounded-full bg-black/60 p-1 text-white/70 opacity-0 transition-opacity hover:text-white group-hover:opacity-100"
>
<Download className="size-3.5" />
</span>
</button>
);
}
function FileAttachment({
particle,
index,
onPreview,
}: {
particle: FileParticle;
index: number;
onPreview: (index: number) => void;
}) {
const { data: url } = useDownloadUrl(particle.properties.object_id);
const handleOpen = (e: React.MouseEvent) => {
e.stopPropagation();
openParticle(particle, index, url, onPreview);
};
const handleDownload = (e: React.MouseEvent) => {
e.stopPropagation();
if (!url) return;
window.electronAttachment.download(url, particle.properties.filename);
};
return (
<div
role="button"
onClick={handleOpen}
className="flex shrink-0 cursor-pointer flex-col gap-1.5 rounded-lg bg-white/10 px-3 py-2 transition-colors hover:bg-white/15"
>
<div className="flex items-center gap-2">
<FileIcon className="size-4 shrink-0 text-white/60" />
<span className="max-w-[10rem] truncate text-xs font-medium text-white/90">
{particle.properties.filename}
</span>
<span className="text-[10px] text-white/40">
{formatFileSize(particle.properties.size_bytes)}
</span>
</div>
<div className="flex gap-2">
<Button
variant="ghost"
size="xs"
className="text-white/70 hover:bg-white/10 hover:text-white"
onClick={handleOpen}
>
<ExternalLink data-icon="inline-start" />
Open
</Button>
<Button
variant="ghost"
size="xs"
className="text-white/70 hover:bg-white/10 hover:text-white"
onClick={handleDownload}
>
<Download data-icon="inline-start" />
Download
</Button>
</div>
</div>
);
}
function CompactAttachmentItem({
particle,
index,
onPreview,
}: {
particle: FileParticle;
index: number;
onPreview: (index: number) => void;
}) {
const isImage = particle.properties.mime_type.startsWith("image/");
const { data: url } = useDownloadUrl(particle.properties.object_id);
return (
<button
type="button"
onClick={(e) => {
e.stopPropagation();
openParticle(particle, index, url, onPreview);
}}
className="flex w-full items-center gap-2 rounded-md bg-white/10 px-2.5 py-1.5 text-left transition-colors hover:bg-white/15"
>
{isImage ? (
url ? (
<img
src={url}
alt={particle.properties.filename}
className="size-5 shrink-0 rounded object-cover"
/>
) : (
<ImageIcon className="size-4 shrink-0 text-white/50" />
)
) : (
<FileIcon className="size-4 shrink-0 text-white/50" />
)}
<span className="min-w-0 truncate text-xs text-white/80">
{particle.properties.filename}
</span>
<span className="shrink-0 text-[10px] text-white/40">
{formatFileSize(particle.properties.size_bytes)}
</span>
</button>
);
}
export function ParticleAttachments({ attachments, variant = "inline" }: ParticleAttachmentsProps) {
const [openIndex, setOpenIndex] = useState<number | null>(null);
// Only previewable attachments populate the lightbox; the index passed to the
// lightbox is the index into this filtered list, not `attachments`.
const previewable = useMemo(
() => attachments.filter((a) => getAttachmentHandler(a.properties.mime_type) === "lightbox"),
[attachments],
);
const items = useMemo(() => previewable.map(particleToItem), [previewable]);
const handlePreview = (attachmentIndex: number) => {
const particle = attachments[attachmentIndex];
if (!particle) return;
const previewIdx = previewable.indexOf(particle);
if (previewIdx >= 0) setOpenIndex(previewIdx);
};
if (attachments.length === 0) return null;
const lightbox = items.length > 0 && (
<AttachmentLightbox
items={items}
openIndex={openIndex}
onOpenChange={setOpenIndex}
/>
);
if (variant === "compact") {
return (
<>
<div className="flex max-w-48 flex-col gap-1">
{attachments.map((attachment, i) => (
<CompactAttachmentItem
key={attachment.id}
particle={attachment}
index={i}
onPreview={handlePreview}
/>
))}
</div>
{lightbox}
</>
);
}
return (
<>
<ScrollArea className="w-full">
<div className="flex items-center gap-2 py-1">
{attachments.map((attachment, i) => {
const isImage = attachment.properties.mime_type.startsWith("image/");
return isImage ? (
<ImageAttachment
key={attachment.id}
particle={attachment}
onPreview={() => handlePreview(i)}
/>
) : (
<FileAttachment
key={attachment.id}
particle={attachment}
index={i}
onPreview={handlePreview}
/>
);
})}
</div>
<ScrollBar orientation="horizontal" />
</ScrollArea>
{lightbox}
</>
);
}
@@ -0,0 +1,363 @@
import { useMemo, useRef, useEffect, useCallback, memo } from "react";
import { useNavigate } from "react-router-dom";
import {
Radio,
MessageSquare,
Video,
Mic,
Image,
FileText,
CircleCheck,
StickyNote,
Headphones,
Trash2,
type LucideIcon,
} from "lucide-react";
import { cn } from "@/lib/utils";
import { useLiveLatestChild } from "@/hooks/use-particle";
import { useAuthStore } from "@/stores/auth-store";
import { particlePath } from "@/lib/particle-path";
import { getInitials } from "@/lib/utils";
import { resolveHumanDisplay } from "@/lib/humans";
import { RelativeTimestamp } from "@/components/relative-timestamp";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import { Separator } from "@/components/ui/separator";
import { Progress } from "@/components/ui/progress";
import { Small } from "@/components/ui/typography";
import { Button } from "@/components/ui/button";
import { isParticleDeleted, type Particle, type StreamProperties } from "@/api/types";
import type { StreamParticle } from "@/hooks/use-stream-particles";
import { useNetwork } from "@/hooks/use-networks";
import { useStreamAutoplay } from "@/hooks/use-stream-autoplay";
import { useDownloadUrl } from "@/hooks/use-download-url";
import { StreamContextMenu } from "@/features/particles/stream-context-menu";
function VideoThumbnail({
objectId,
isUnseen,
}: {
objectId: string;
isUnseen: boolean;
}) {
const { data: url } = useDownloadUrl(objectId);
return (
<div
className={cn(
"size-8 shrink-0 overflow-hidden rounded-md bg-muted",
isUnseen && "ring-2 ring-primary",
)}
>
{url && (
<video
// Seek ~15 frames in so we skip any initial black/fade-in frames
src={`${url}#t=0.5`}
muted
playsInline
preload="metadata"
className="h-full w-full object-cover"
/>
)}
</div>
);
}
function getParticleTypeIcon(particle: Particle): LucideIcon {
if (isParticleDeleted(particle)) return Trash2;
switch (particle.type) {
case "text":
return MessageSquare;
case "media": {
const mime = particle.properties.mime_type;
if (mime.startsWith("video/")) return Video;
if (mime.startsWith("audio/")) return Mic;
if (mime.startsWith("image/")) return Image;
return Video;
}
case "file":
return FileText;
case "quest":
return CircleCheck;
case "paper":
return StickyNote;
default:
return Radio;
}
}
function getMessagePreview(particle: Particle): string {
if (isParticleDeleted(particle)) return "Deleted particle";
switch (particle.type) {
case "text":
return particle.properties.content;
case "media": {
const mime = particle.properties.mime_type;
if (mime.startsWith("image/")) return "Photo";
if (mime.startsWith("video/") || mime.startsWith("audio/")) {
const transcriptText = particle.properties.transcript?.transcript;
if (transcriptText) return transcriptText;
return mime.startsWith("video/") ? "Video clip" : "Voice note";
}
return "Media";
}
case "file":
return particle.properties.filename;
case "quest":
return particle.properties.title;
case "paper":
return particle.properties.title;
default:
return particle.type;
}
}
const StreamRow = memo(function StreamRow({
particle,
networkId,
onNavigate,
isSelected,
shortcutKey,
}: {
particle: Particle & { type: "stream"; properties: StreamProperties };
networkId: string;
onNavigate: (streamId: string) => void;
isSelected?: boolean;
shortcutKey?: number;
}) {
const streamPath = particlePath(networkId, [particle.id]);
const { latestChild } = useLiveLatestChild(streamPath);
const user = useAuthStore((s) => s.user);
const userId = user?.id ?? "";
const network = useNetwork(networkId);
useStreamAutoplay(latestChild, particle, networkId, network ?? undefined);
const hasActiveHuddle =
particle.huddle_active_participants && particle.huddle_active_participants.length > 0;
const huddleCount = particle.huddle_active_participants?.length ?? 0;
const isDM =
particle.visible_to.length === 2 &&
particle.visible_to.every((v) => v.startsWith("human:"));
const initials = useMemo(() => {
if (isDM) {
const otherEntry = particle.visible_to.find(
(v) => v !== `human:${userId}`,
);
if (otherEntry) {
const otherId = otherEntry.replace("human:", "");
const otherHuman = network?.humans?.find((h) => h.id === otherId);
if (otherHuman) return getInitials(otherHuman.email);
}
}
if (latestChild) {
const creator = network?.humans?.find((h) => h.id === latestChild.created_by_human_id);
if (creator) return getInitials(creator.email);
}
return particle.properties.name.slice(0, 2).toUpperCase();
}, [isDM, particle.visible_to, particle.properties.name, userId, latestChild, network]);
const isUnseen = useMemo(() => {
if (!latestChild) return false;
const latestChildTimestamp = latestChild.created_at.getTime();
const userPlaybackPosition =
particle.playback_markers?.[userId]?.getTime() ?? 0;
return latestChildTimestamp > userPlaybackPosition;
}, [latestChild, particle.playback_markers, userId]);
const senderPrefix = useMemo(() => {
if (!latestChild) return null;
const isCurrentUser = latestChild.created_by_human_id === userId;
if (isDM) {
return isCurrentUser ? "You: " : null;
}
// Group stream
if (isCurrentUser) return "You: ";
const { displayName } = resolveHumanDisplay(
latestChild.created_by_human_id,
network?.humans,
);
const capitalized = displayName.charAt(0).toUpperCase() + displayName.slice(1);
return `${capitalized}: `;
}, [latestChild, userId, isDM, network]);
const subtitle = latestChild
? getMessagePreview(latestChild)
: particle.properties.name;
const TypeIcon = latestChild ? getParticleTypeIcon(latestChild) : Radio;
const videoThumbObjectId =
latestChild &&
!isParticleDeleted(latestChild) &&
latestChild.type === "media" &&
latestChild.properties.mime_type.startsWith("video/")
? latestChild.properties.object_id
: null;
return (
<div
role="button"
tabIndex={0}
onClick={() => onNavigate(particle.id)}
onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") onNavigate(particle.id); }}
className={cn(
"flex w-full items-center gap-3 px-4 py-3 text-left cursor-pointer transition-colors hover:bg-accent",
isSelected && "bg-accent",
hasActiveHuddle && "bg-gradient-to-r from-red-500/10 to-transparent",
)}
>
{shortcutKey && (
<kbd className="flex size-5 shrink-0 items-center justify-center rounded bg-white/10 font-mono text-xs text-muted-foreground">
{shortcutKey}
</kbd>
)}
{videoThumbObjectId ? (
<VideoThumbnail objectId={videoThumbObjectId} isUnseen={!!isUnseen} />
) : (
<Avatar className={cn(isUnseen && "ring-2 ring-primary")}>
<AvatarFallback className="bg-primary/10 text-primary font-medium">
{initials}
</AvatarFallback>
</Avatar>
)}
<div className="min-w-0 flex-1">
<div className="flex items-center justify-between gap-2">
<p
className={cn(
"truncate text-sm",
isUnseen
? "font-semibold text-foreground"
: "font-medium text-muted-foreground",
)}
>
{particle.properties.name}
</p>
<div className="flex shrink-0 items-center gap-1.5">
{hasActiveHuddle && (
<span className="flex items-center gap-1 rounded-full bg-red-500/15 px-1.5 py-0.5">
<Headphones className="size-3 text-red-400" />
<span className="text-[10px] font-medium text-red-400">{huddleCount}</span>
</span>
)}
{latestChild && (
<Small
className={cn(
"shrink-0",
isUnseen ? "text-primary" : "text-muted-foreground",
)}
>
<RelativeTimestamp date={latestChild.created_at} />
</Small>
)}
</div>
</div>
<div className="flex items-center gap-1">
<TypeIcon
className={cn(
"size-3.5 shrink-0",
isUnseen ? "text-foreground" : "text-muted-foreground",
)}
/>
<Small
className={cn(
"truncate",
isUnseen
? "text-foreground font-medium"
: "text-muted-foreground font-normal",
)}
>
{senderPrefix && (
<span className="text-muted-foreground">{senderPrefix}</span>
)}
{subtitle}
</Small>
</div>
</div>
{isUnseen && (
<span className="size-2 shrink-0 rounded-full bg-primary" />
)}
</div>
);
});
interface ParticleListViewProps {
streams: StreamParticle[];
networkId: string;
isLoading: boolean;
selectedIndex?: number | null;
/** When true, render a footer that invokes onLoadMore. */
canLoadMore?: boolean;
onLoadMore?: () => void;
}
/**
* List of stream particles for a container (network root, folder, etc.).
*/
export function ParticleListView({
streams,
networkId,
isLoading,
selectedIndex,
canLoadMore,
onLoadMore,
}: ParticleListViewProps) {
const navigate = useNavigate();
const rowRefs = useRef<(HTMLDivElement | null)[]>([]);
const navigateToStream = useCallback(
(streamId: string) => navigate(`/${networkId}/${streamId}`),
[navigate, networkId],
);
useEffect(() => {
if (selectedIndex !== null && selectedIndex !== undefined && selectedIndex >= 0) {
rowRefs.current[selectedIndex]?.scrollIntoView({ block: "nearest" });
}
}, [selectedIndex]);
if (isLoading) {
return <Progress />;
}
if (streams.length === 0) {
return (
<div className="mx-auto flex h-full max-w-sm flex-col items-center justify-center gap-2 px-4 text-center">
<Radio className="text-muted-foreground size-8" />
<p className="text-muted-foreground text-sm">
No streams here. Start a conversation using the keyboard shortcuts below.
</p>
</div>
);
}
return (
<div>
{streams.map((stream, index) => (
<StreamContextMenu key={stream.id} particle={stream} networkId={networkId}>
<div
ref={(el) => { rowRefs.current[index] = el; }}
>
<StreamRow
particle={stream}
networkId={networkId}
onNavigate={navigateToStream}
isSelected={index === selectedIndex}
shortcutKey={index < 9 ? index + 1 : undefined}
/>
{index < streams.length - 1 && <Separator className="px-4" />}
</div>
</StreamContextMenu>
))}
{canLoadMore && onLoadMore && (
<div className="flex justify-center p-3">
<Button variant="ghost" size="sm" onClick={onLoadMore}>
Load more
</Button>
</div>
)}
</div>
);
}
@@ -0,0 +1,174 @@
import { useEffect, useState } from "react";
import type { Particle } from "@/api/types";
import { apiClient } from "@/api/client";
import { Skeleton } from "@/components/ui/skeleton";
import {
Video,
Mic,
ScrollText,
BookOpen,
FileIcon,
FolderIcon,
} from "lucide-react";
export function ParticlePreview({ particle }: { particle: Particle }) {
switch (particle.type) {
case "text":
return <TextPreview particle={particle} />;
case "media":
return <MediaPreview particle={particle} />;
case "quest":
return <QuestPreview particle={particle} />;
case "paper":
return <PaperPreview particle={particle} />;
case "file":
return <FilePreview particle={particle} />;
case "folder":
return <FolderPreview particle={particle} />;
default:
return <EmptyPreview />;
}
}
function TextPreview({ particle }: { particle: Extract<Particle, { type: "text" }> }) {
const truncated =
particle.properties.content.length > 30
? particle.properties.content.slice(0, 30) + "..."
: particle.properties.content;
return (
<div className="flex h-full w-full items-center justify-center p-4">
<p className="line-clamp-4 text-center text-xl leading-relaxed">
{truncated}
</p>
</div>
);
}
function MediaPreview({ particle }: { particle: Extract<Particle, { type: "media" }> }) {
const { mime_type, duration_ms } = particle.properties;
const isVideo = mime_type.startsWith("video");
const durationSec = Math.round(duration_ms / 1000);
const durationLabel = `${Math.floor(durationSec / 60)}:${String(durationSec % 60).padStart(2, "0")}`;
if (isVideo) {
return <VideoThumbnail particleId={particle.properties.object_id} duration={durationLabel} />;
}
return (
<div className="flex h-full w-full flex-col items-center justify-center gap-2 bg-black/90">
<Mic className="h-8 w-8 text-white/60" />
<span className="font-mono text-xs text-white/50">{durationLabel}</span>
</div>
);
}
function VideoThumbnail({
particleId,
duration,
}: {
particleId: string;
duration: string;
}) {
const [url, setUrl] = useState<string | null>(null);
const [error, setError] = useState(false);
useEffect(() => {
let cancelled = false;
apiClient
.getParticleDownloadUrl(particleId)
.then((downloadUrl) => {
if (!cancelled) setUrl(downloadUrl);
})
.catch(() => {
if (!cancelled) setError(true);
});
return () => {
cancelled = true;
};
}, [particleId]);
if (error) {
return (
<div className="flex h-full w-full items-center justify-center bg-black/80">
<Video className="h-8 w-8 text-white/40" />
</div>
);
}
if (!url) {
return <Skeleton className="h-full w-full rounded-none" />;
}
return (
<div className="relative h-full w-full bg-black">
<video
src={`${url}#t=2`}
preload="metadata"
muted
playsInline
className="h-full w-full object-cover"
/>
<span className="absolute right-1.5 bottom-1.5 rounded bg-black/70 px-1.5 py-0.5 font-mono text-[10px] text-white/80">
{duration}
</span>
</div>
);
}
function QuestPreview({ particle }: { particle: Extract<Particle, { type: "quest" }> }) {
const { title, status } = particle.properties;
return (
<div className="flex h-full w-full flex-col items-center justify-center gap-2 bg-amber-500/10 p-4">
<ScrollText className="h-6 w-6 text-amber-600/70 dark:text-amber-400/70" />
<p className="line-clamp-2 text-center text-sm font-medium">
{title}
</p>
{status && (
<span className="text-muted-foreground text-[10px] uppercase tracking-wide">
{status}
</span>
)}
</div>
);
}
function PaperPreview({ particle }: { particle: Extract<Particle, { type: "paper" }> }) {
return (
<div className="flex h-full w-full flex-col items-center justify-center gap-2 bg-blue-500/10 p-4">
<BookOpen className="h-6 w-6 text-blue-600/70 dark:text-blue-400/70" />
<p className="line-clamp-2 text-center text-sm font-medium">
{particle.properties.title}
</p>
</div>
);
}
function FilePreview({ particle }: { particle: Extract<Particle, { type: "file" }> }) {
return (
<div className="flex h-full w-full flex-col items-center justify-center gap-2 bg-emerald-500/10 p-4">
<FileIcon className="h-6 w-6 text-emerald-600/70 dark:text-emerald-400/70" />
<p className="text-muted-foreground line-clamp-1 text-center text-xs">
{particle.properties.filename}
</p>
</div>
);
}
function FolderPreview({ particle }: { particle: Extract<Particle, { type: "folder" }> }) {
return (
<div className="flex h-full w-full flex-col items-center justify-center gap-2 bg-orange-500/10 p-4">
<FolderIcon className="h-6 w-6 text-orange-600/70 dark:text-orange-400/70" />
<p className="text-muted-foreground line-clamp-1 text-center text-xs">
{particle.properties.name}
</p>
</div>
);
}
function EmptyPreview() {
return (
<div className="flex h-full w-full items-center justify-center">
<p className="text-muted-foreground text-xs italic">No messages yet</p>
</div>
);
}
@@ -0,0 +1,79 @@
import { useEffect } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { useQueryClient } from "@tanstack/react-query";
import { Lock } from "lucide-react";
import { useLiveParticle } from "@/hooks/use-particle";
import { particlePath } from "@/lib/particle-path";
import { Button } from "@/components/ui/button";
import { StreamView } from "@/features/particles/stream-view";
import { FolderView } from "@/features/particles/folder-view";
/**
* Route-level component for /:networkId/*.
* Reads params from the router, resolves the particle, and renders
* the appropriate view based on particle type.
*/
export default function ParticleViewResolver() {
const { networkId, "*": rest } = useParams();
const segments = (rest ?? "").split("/").filter(Boolean);
const path = particlePath(networkId!, segments); // path of current container particle
const { particle, isLoading, error } = useLiveParticle(path);
if (isLoading) {
return (
<div className="flex h-full items-center justify-center">
<p className="text-muted-foreground text-sm">Loading...</p>
</div>
);
}
if (error || !particle) {
// Errors here are almost always Firestore permission-denied — the user lost
// access to the network or to a custom-visibility particle. The React Router
// stays on the dead route, so without an explicit escape the user is stuck.
return <InaccessibleParticle />;
}
switch (particle.type) {
case "stream":
return <StreamView streamParticle={particle} path={path} />;
case "folder":
return <FolderView folderParticle={particle} path={path} />;
default:
return (
<div className="flex h-full items-center justify-center">
<p className="text-muted-foreground text-sm">
{particle.type} particle: {particle.id}
</p>
</div>
);
}
}
function InaccessibleParticle() {
const navigate = useNavigate();
const queryClient = useQueryClient();
useEffect(() => {
// Refresh the networks list so the home page reflects current access.
queryClient.invalidateQueries({ queryKey: ["networks"] });
}, [queryClient]);
return (
<div className="flex h-full flex-col items-center justify-center gap-4 p-8 text-center">
<Lock className="text-muted-foreground size-8" />
<div className="flex max-w-sm flex-col gap-1">
<p className="text-sm font-medium">This particle isn't available</p>
<p className="text-muted-foreground text-xs">
It may have been deleted, or your access was removed.
</p>
</div>
<Button size="sm" onClick={() => navigate("/", { replace: true })}>
Go home
</Button>
</div>
);
}
@@ -0,0 +1,111 @@
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import type { HumanPresence } from "@/hooks/use-presence-positions";
const MAX_VISIBLE_AVATARS = 3;
interface PlaybackPageIndicatorProps {
total: number;
current: number;
progress: number;
onGoTo: (index: number) => void;
presenceBySegment?: Map<number, HumanPresence[]>;
/** Set of humanIds currently online in the stream channel. */
onlineHumanIds?: Set<string>;
/** Render only avatars or only tracks. Omit to render both. */
layer?: "avatars" | "tracks";
}
export function PlaybackPageIndicator({
total,
current,
progress,
onGoTo,
presenceBySegment,
onlineHumanIds,
layer,
}: PlaybackPageIndicatorProps) {
if (total === 0) return null;
const showAvatars = layer !== "tracks";
const showTracks = layer !== "avatars";
return (
<div className="flex w-full items-end gap-px leading-none">
{Array.from({ length: total }, (_, i) => {
const presence = presenceBySegment?.get(i);
return (
<div key={i} className="flex flex-1 flex-col items-stretch">
{showAvatars && presence && presence.length > 0 && (
<SegmentPresenceAvatars presence={presence} onlineHumanIds={onlineHumanIds} />
)}
{showTracks && (
<button
onClick={(e) => {
e.stopPropagation();
onGoTo(i);
}}
className="group relative block h-3 w-full"
>
{/* Dim track */}
<div className="absolute inset-x-0 bottom-0 h-[3px] bg-white/30 transition-all group-hover:h-1.5" />
{/* Fill */}
<div
className="absolute left-0 bottom-0 h-[3px] bg-white/90 transition-all group-hover:h-1.5"
style={{
width:
i < current
? "100%"
: i === current
? `${progress * 100}%`
: "0%",
transition: i === current ? "width 300ms linear" : "none",
}}
/>
</button>
)}
</div>
);
})}
</div>
);
}
function SegmentPresenceAvatars({
presence,
onlineHumanIds,
}: {
presence: HumanPresence[];
onlineHumanIds?: Set<string>;
}) {
const visible = presence.slice(0, MAX_VISIBLE_AVATARS);
const overflow = presence.length - MAX_VISIBLE_AVATARS;
return (
<div className="flex items-center justify-center -space-x-1.5 pb-0.5">
{visible.map((human) => (
<Tooltip key={human.humanId}>
<TooltipTrigger asChild>
<Avatar size="xs" className={onlineHumanIds?.has(human.humanId) ? "ring-2 ring-green-500" : "ring-1 ring-black/50"}>
<AvatarFallback>
{human.emailPrefix.slice(0, 2).toUpperCase()}
</AvatarFallback>
</Avatar>
</TooltipTrigger>
<TooltipContent side="top" className="text-xs">
{human.email}
</TooltipContent>
</Tooltip>
))}
{overflow > 0 && (
<span className="text-[10px] text-white/70 pl-1">
+{overflow}
</span>
)}
</div>
);
}
@@ -0,0 +1,183 @@
import { useState } from "react";
import { Plus, Type, X } from "lucide-react";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import { REACTION_EMOJIS, type Reactions } from "@/api/types";
import { cn } from "@/lib/utils";
import { resolveHumanDisplay } from "@/lib/humans";
import type { Human } from "@/api/types";
interface ReactionBarProps {
reactions: Reactions;
currentHumanId: string;
humans?: Human[];
onToggle: (key: string) => void;
onOpenTextReaction: () => void;
}
const EMOJI_SET = new Set<string>(REACTION_EMOJIS);
function getReactorNames(humanIds: string[], humans?: Human[]): string {
return humanIds
.map((id) => resolveHumanDisplay(id, humans).displayName)
.join(", ");
}
function getReactorList(
humanIds: string[],
humans: Human[] | undefined,
currentHumanId: string,
): { id: string; label: string; isMine: boolean }[] {
return humanIds.map((id) => ({
id,
label: resolveHumanDisplay(id, humans).displayName,
isMine: id === currentHumanId,
}));
}
export function ReactionBar({
reactions,
currentHumanId,
humans,
onToggle,
onOpenTextReaction,
}: ReactionBarProps) {
const [expanded, setExpanded] = useState(false);
const activeEmojis = REACTION_EMOJIS.filter(
(emoji) => reactions?.[emoji] && reactions[emoji].length > 0,
);
const activeTextReactions = Object.keys(reactions ?? {}).filter(
(key) => !EMOJI_SET.has(key) && (reactions?.[key]?.length ?? 0) > 0,
);
const handleToggle = (key: string) => {
onToggle(key);
setExpanded(false);
};
return (
<div className="flex flex-col items-end gap-1.5">
{/* Emoji reaction pills */}
{activeEmojis.map((emoji) => {
const reactors = reactions![emoji];
const isMine = reactors.includes(currentHumanId);
return (
<Tooltip key={emoji}>
<TooltipTrigger asChild>
<button
onClick={(e) => { e.stopPropagation(); handleToggle(emoji); }}
className={cn(
"flex items-center gap-1 rounded-full px-2 py-0.5 text-xs backdrop-blur-sm transition-colors",
isMine
? "bg-white/20 ring-1 ring-white/40"
: "bg-black/40 hover:bg-black/50",
)}
>
<span className="text-sm">{emoji}</span>
<span className="text-white/80">{reactors.length}</span>
</button>
</TooltipTrigger>
<TooltipContent side="left" className="text-xs">
{getReactorNames(reactors, humans)}
</TooltipContent>
</Tooltip>
);
})}
{/* Text reaction pills */}
{activeTextReactions.map((text) => {
const reactors = reactions![text];
const isMine = reactors.includes(currentHumanId);
const firstReactor = resolveHumanDisplay(reactors[0], humans);
const reactorList = getReactorList(reactors, humans, currentHumanId);
return (
<Tooltip key={text}>
<TooltipTrigger asChild>
<button
onClick={(e) => { e.stopPropagation(); handleToggle(text); }}
className={cn(
"flex max-w-[200px] items-center gap-1.5 rounded-full py-0.5 pl-0.5 pr-2.5 text-xs backdrop-blur-sm transition-colors",
isMine
? "bg-white/20 ring-1 ring-white/40"
: "bg-black/40 hover:bg-black/50",
)}
>
<Avatar size="xs" className="shrink-0">
<AvatarFallback className="bg-white/15 text-[9px] font-medium text-white">
{firstReactor.initials}
</AvatarFallback>
</Avatar>
<span className="truncate text-white/90">{text}</span>
{reactors.length > 1 && (
<span className="shrink-0 text-white/60">{reactors.length}</span>
)}
</button>
</TooltipTrigger>
<TooltipContent side="left" className="max-w-[260px] space-y-1.5 text-xs">
<div className="font-medium">{text}</div>
<ul className="flex flex-col gap-0.5 opacity-80">
{reactorList.map((r) => (
<li key={r.id} className={cn(r.isMine && "font-medium opacity-100")}>
{r.label}
{r.isMine && <span className="ml-1 opacity-60">(you)</span>}
</li>
))}
</ul>
<div className="border-t border-current/15 pt-1 text-[10px] opacity-60">
{isMine ? "Click to remove" : "Click to add yours"}
</div>
</TooltipContent>
</Tooltip>
);
})}
{/* Picker / actions */}
{expanded ? (
<div className="flex flex-col items-center gap-0.5 rounded-full bg-black/40 px-0.5 py-1.5 backdrop-blur-sm">
{REACTION_EMOJIS.map((emoji) => {
if (activeEmojis.includes(emoji)) return null;
return (
<button
key={emoji}
onClick={(e) => { e.stopPropagation(); handleToggle(emoji); }}
className="rounded-full px-0.5 py-1 text-sm transition-colors hover:bg-white/15"
>
{emoji}
</button>
);
})}
<button
onClick={(e) => { e.stopPropagation(); setExpanded(false); }}
className="flex size-5 items-center justify-center rounded-full transition-colors hover:bg-white/15"
>
<X className="size-3 text-white/60" />
</button>
</div>
) : (
<div className="flex flex-col items-center gap-1">
<Tooltip>
<TooltipTrigger asChild>
<button
onClick={(e) => { e.stopPropagation(); onOpenTextReaction(); }}
className="flex size-6 items-center justify-center rounded-full bg-black/40 backdrop-blur-sm transition-colors hover:bg-black/50"
>
<Type className="size-3 text-white/60" />
</button>
</TooltipTrigger>
<TooltipContent side="left" className="text-xs">
Quick reply <kbd className="ml-1 rounded bg-white/10 px-1 font-mono text-[10px]">R</kbd>
</TooltipContent>
</Tooltip>
<button
onClick={(e) => { e.stopPropagation(); setExpanded(true); }}
className="flex size-6 items-center justify-center rounded-full bg-black/40 backdrop-blur-sm transition-colors hover:bg-black/50"
>
<Plus className="size-3 text-white/60" />
</button>
</div>
)}
</div>
);
}
@@ -0,0 +1,101 @@
import { useCallback, useEffect, useState } from "react";
import { createPortal } from "react-dom";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { updateParticleProperties } from "@/lib/firestore-particles";
import { particlePath, toFirestoreDocPath } from "@/lib/particle-path";
import type { Particle } from "@/api/types";
import { useSuspendPlayback } from "@/hooks/use-suspend-playback";
interface RenameStreamOverlayProps {
networkId: string;
streamParticle: Particle & { type: "stream" };
onClose: () => void;
}
export function RenameStreamOverlay({
networkId,
streamParticle,
onClose,
}: RenameStreamOverlayProps) {
useSuspendPlayback(true, "rename-stream");
const [name, setName] = useState(streamParticle.properties.name);
const [saving, setSaving] = useState(false);
const trimmed = name.trim();
const canSave =
!saving &&
trimmed.length > 0 &&
trimmed !== streamParticle.properties.name;
const handleSave = useCallback(async () => {
if (!canSave) return;
setSaving(true);
try {
const docPath = toFirestoreDocPath(particlePath(networkId, [streamParticle.id]));
await updateParticleProperties<"stream">(docPath, { name: trimmed });
onClose();
} finally {
setSaving(false);
}
}, [canSave, networkId, onClose, streamParticle.id, trimmed]);
useEffect(() => {
const handler = (e: KeyboardEvent) => {
if (e.key === "Escape") {
e.preventDefault();
e.stopPropagation();
onClose();
}
};
window.addEventListener("keydown", handler, { capture: true });
return () => window.removeEventListener("keydown", handler, { capture: true });
}, [onClose]);
return createPortal(
<div className="fixed inset-0 z-[100]">
<div
className="absolute inset-0 bg-black/60 backdrop-blur-sm"
onClick={onClose}
/>
<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>{" "}
to close
</span>
</div>
<Input
type="text"
autoFocus
value={name}
onChange={(e) => setName(e.target.value)}
onFocus={(e) => e.currentTarget.select()}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
handleSave();
}
}}
placeholder="Stream name"
className="border-white/10 bg-white/5 text-white placeholder-white/30 focus-visible:border-white/30 focus-visible:ring-0"
/>
<div className="mt-4 flex items-center justify-end gap-2">
<Button variant="ghost" size="sm" onClick={onClose}>
Cancel
</Button>
<Button size="sm" onClick={handleSave} disabled={!canSave}>
Save
</Button>
</div>
</div>
</div>,
document.body,
);
}
@@ -0,0 +1,169 @@
import { forwardRef, useMemo } from "react";
import { Headphones } from "lucide-react";
import { cn, getInitials } from "@/lib/utils";
import { useLiveLatestChild } from "@/hooks/use-particle";
import { useAuthStore } from "@/stores/auth-store";
import { particlePath } from "@/lib/particle-path";
import type { Particle, StreamProperties } from "@/api/types";
import { useStreamAutoplay } from "@/hooks/use-stream-autoplay";
import { ParticlePreview } from "@/features/particles/particle-preview";
import { useNetwork } from "@/hooks/use-networks";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import { RelativeTimestamp } from "@/components/relative-timestamp";
import { Small } from "@/components/ui/typography";
interface StreamCardProps {
particle: Particle & { type: "stream"; properties: StreamProperties };
networkId: string;
onClick: () => void;
isSelected?: boolean;
shortcutKey?: number;
}
export const StreamCard = forwardRef<HTMLDivElement, StreamCardProps>(function StreamCard({ particle, networkId, onClick, isSelected, shortcutKey }, ref) {
const streamPath = particlePath(networkId, [particle.id]);
const { latestChild } = useLiveLatestChild(streamPath);
const userId = useAuthStore((s) => s.user?.id) ?? "";
const network = useNetwork(networkId);
useStreamAutoplay(latestChild, particle, networkId, network ?? undefined);
const hasActiveHuddle =
particle.huddle_active_participants && particle.huddle_active_participants.length > 0;
const huddleCount = particle.huddle_active_participants?.length ?? 0;
const isDM =
particle.visible_to.length === 2 &&
particle.visible_to.every((v) => v.startsWith("human:"));
const initials = useMemo(() => {
if (isDM) {
const otherEntry = particle.visible_to.find(
(v) => v !== `human:${userId}`,
);
if (otherEntry) {
const otherId = otherEntry.replace("human:", "");
const otherHuman = network?.humans?.find((h) => h.id === otherId);
if (otherHuman) return getInitials(otherHuman.email);
}
}
if (latestChild) {
const creator = network?.humans?.find(
(h) => h.id === latestChild.created_by_human_id,
);
if (creator) return getInitials(creator.email);
}
return particle.properties.name.slice(0, 2).toUpperCase();
}, [
isDM,
particle.visible_to,
particle.properties.name,
userId,
latestChild,
network,
]);
const isUnseen = useMemo(() => {
if (!latestChild) return false;
const latestChildTimestamp = latestChild.created_at.getTime();
const userPlaybackPosition =
particle.playback_markers?.[userId]?.getTime() ?? 0;
return latestChildTimestamp > userPlaybackPosition;
}, [latestChild, particle.playback_markers, userId]);
// For media particles with a transcript, show it as an overlay on the preview
const transcript =
latestChild?.type === "media"
? latestChild.properties.transcript?.transcript
: undefined;
return (
<div
ref={ref}
role="button"
tabIndex={0}
onClick={onClick}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") onClick();
}}
className={cn(
"cursor-pointer overflow-hidden rounded-xl ring-1 ring-foreground/10 transition-all hover:ring-foreground/20",
isUnseen && "ring-2 ring-primary",
isSelected && "ring-2 ring-ring",
hasActiveHuddle && "ring-2 ring-red-500/70",
)}
>
{/* Preview area */}
<div className="relative aspect-[4/3] overflow-hidden bg-muted">
{hasActiveHuddle && (
<div className="pointer-events-none absolute inset-0 z-10 bg-gradient-to-b from-red-500/15 to-transparent" />
)}
{shortcutKey && (
<kbd className="absolute top-1.5 left-1.5 z-10 flex size-5 items-center justify-center rounded bg-black/50 font-mono text-xs text-white/70">
{shortcutKey}
</kbd>
)}
{latestChild ? (
<ParticlePreview particle={latestChild} />
) : (
<div className="flex h-full w-full items-center justify-center">
<p className="text-muted-foreground text-xs italic">
No messages yet
</p>
</div>
)}
{/* Transcript overlay for media with transcripts */}
{transcript && (
<div className="absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/80 to-transparent px-2.5 pt-6 pb-2">
<p className="line-clamp-2 text-md leading-snug text-white/90">
{transcript}
</p>
</div>
)}
</div>
{/* Info bar */}
<div className="flex items-center gap-2 px-2.5 py-2">
<Avatar className={cn("size-6 shrink-0", isUnseen && "ring-2 ring-primary")}>
<AvatarFallback className="bg-primary/10 text-primary text-[10px] font-medium">
{initials}
</AvatarFallback>
</Avatar>
<Small
className={cn(
"min-w-0 truncate",
isUnseen
? "font-semibold text-foreground"
: "font-medium text-muted-foreground",
)}
>
{particle.properties.name}
</Small>
<div className="ml-auto flex shrink-0 items-center gap-1.5">
{hasActiveHuddle && (
<span className="flex items-center gap-1 rounded-full bg-red-500/15 px-1.5 py-0.5">
<Headphones className="size-3 text-red-400" />
<span className="text-[10px] font-medium text-red-400">{huddleCount}</span>
</span>
)}
{latestChild && (
<Small
className={cn(
"text-[10px]",
isUnseen ? "text-primary" : "text-muted-foreground",
)}
>
<RelativeTimestamp date={latestChild.created_at} />
</Small>
)}
{isUnseen && (
<span className="size-2 shrink-0 rounded-full bg-primary" />
)}
</div>
</div>
</div>
);
});
@@ -0,0 +1,46 @@
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuTrigger,
} from "@/components/ui/context-menu";
import { CircleCheckBig, CircleDot } from "lucide-react";
import { updateStreamStatus } from "@/lib/firestore-particles";
import { toFirestoreDocPath, particlePath } from "@/lib/particle-path";
import type { StreamParticle } from "@/hooks/use-stream-particles";
interface StreamContextMenuProps {
particle: StreamParticle;
networkId: string;
children: React.ReactNode;
}
export function StreamContextMenu({ particle, networkId, children }: StreamContextMenuProps) {
const isOpen = particle.status === "open";
const docPath = toFirestoreDocPath(particlePath(networkId, [particle.id]));
const toggleStatus = async () => {
await updateStreamStatus(docPath, isOpen ? "closed" : "open");
};
return (
<ContextMenu>
<ContextMenuTrigger asChild>{children}</ContextMenuTrigger>
<ContextMenuContent>
<ContextMenuItem onSelect={toggleStatus}>
{isOpen ? (
<>
<CircleCheckBig className="size-4" />
Close stream
</>
) : (
<>
<CircleDot className="size-4 text-green-500" />
Open stream
</>
)}
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
);
}
@@ -0,0 +1,272 @@
import { useCallback, useEffect, useMemo } from "react";
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 {
buildCustomVisibility,
buildNetworkVisibility,
parseVisibleTo,
} from "@/lib/stream-visibility";
import { updateParticleVisibleTo } from "@/lib/firestore-particles";
import { particlePath, toFirestoreDocPath } from "@/lib/particle-path";
import { useNetwork } from "@/hooks/use-networks";
import { cn, getInitials } from "@/lib/utils";
import { resolveHumanDisplay } from "@/lib/humans";
import type { Particle } from "@/api/types";
import { useSuspendPlayback } from "@/hooks/use-suspend-playback";
interface StreamMembersOverlayProps {
networkId: string;
streamParticle: Particle & { type: "stream" };
isCreator: boolean;
onClose: () => void;
}
export function StreamMembersOverlay({
networkId,
streamParticle,
isCreator,
onClose,
}: StreamMembersOverlayProps) {
useSuspendPlayback(true, "stream-members");
const network = useNetwork(networkId);
const humans = network?.humans ?? [];
const creatorId = streamParticle.created_by_human_id;
const visibility = parseVisibleTo(streamParticle.visible_to, networkId);
const docPath = useMemo(
() => toFirestoreDocPath(particlePath(networkId, [streamParticle.id])),
[networkId, streamParticle.id],
);
const memberIds =
visibility.mode === "network"
? humans.map((h) => h.id)
: visibility.humanIds;
const memberSet = new Set(memberIds);
const availableToAdd = humans.filter((h) => !memberSet.has(h.id));
const setNetworkWide = useCallback(() => {
void updateParticleVisibleTo(docPath, buildNetworkVisibility(networkId));
}, [docPath, networkId]);
const setCustomOnlyCreator = useCallback(() => {
void updateParticleVisibleTo(docPath, buildCustomVisibility([creatorId]));
}, [docPath, creatorId]);
const removeMember = useCallback(
(id: string) => {
if (visibility.mode !== "custom") return;
if (id === creatorId) return;
const next = visibility.humanIds.filter((x) => x !== id);
if (next.length === 0) return;
void updateParticleVisibleTo(docPath, buildCustomVisibility(next));
},
[docPath, creatorId, visibility],
);
const addMember = useCallback(
(id: string) => {
if (visibility.mode !== "custom") return;
void updateParticleVisibleTo(
docPath,
buildCustomVisibility([...visibility.humanIds, id]),
);
},
[docPath, visibility],
);
useEffect(() => {
const handler = (e: KeyboardEvent) => {
if (e.key === "Escape") {
e.preventDefault();
e.stopPropagation();
onClose();
}
};
window.addEventListener("keydown", handler, { capture: true });
return () => window.removeEventListener("keydown", handler, { capture: true });
}, [onClose]);
return createPortal(
<div className="fixed inset-0 z-[100]">
<div
className="absolute inset-0 bg-black/60 backdrop-blur-sm"
onClick={onClose}
/>
<div className="absolute left-1/2 top-1/2 flex max-h-[80vh] w-full max-w-sm -translate-x-1/2 -translate-y-1/2 flex-col overflow-hidden rounded-2xl border border-white/10 bg-white/5 p-6 shadow-2xl backdrop-blur-xl">
{/* 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>{" "}
to close
</span>
</div>
{/* Visibility */}
<section className="mb-4">
<h3 className="mb-2 text-[10px] font-medium uppercase tracking-widest text-white/30">
Visibility
</h3>
{isCreator ? (
<div className="grid grid-cols-2 gap-1 rounded-lg bg-white/5 p-1">
<VisibilityPill
active={visibility.mode === "network"}
icon={<Globe className="size-3.5" />}
label="Network-wide"
onClick={setNetworkWide}
/>
<VisibilityPill
active={visibility.mode === "custom"}
icon={<Lock className="size-3.5" />}
label="Specific people"
onClick={setCustomOnlyCreator}
/>
</div>
) : (
<div className="flex items-center gap-2 text-sm text-white/70">
{visibility.mode === "network" ? (
<>
<Globe className="size-3.5 text-white/40" />
<span>Everyone in {network?.name ?? "network"}</span>
</>
) : (
<>
<Lock className="size-3.5 text-white/40" />
<span>{memberIds.length} specific people</span>
</>
)}
</div>
)}
</section>
{/* Member list */}
<section className="flex min-h-0 flex-1 flex-col">
<h3 className="mb-2 text-[10px] font-medium uppercase tracking-widest text-white/30">
{visibility.mode === "network" ? "Has access" : "People"}{" "}
<span className="ml-1 text-white/20">{memberIds.length}</span>
</h3>
<ScrollArea className="min-h-0 flex-1">
<ul className="flex flex-col gap-0.5 pr-2">
{memberIds.map((id) => {
const display = resolveHumanDisplay(id, humans);
const isCreatorRow = id === creatorId;
const canRemove =
isCreator && visibility.mode === "custom" && !isCreatorRow;
return (
<li
key={id}
className="group flex items-center gap-2.5 rounded px-2 py-1.5 text-sm text-white/70"
>
<Avatar size="sm">
<AvatarFallback className="text-[10px]">
{display.initials}
</AvatarFallback>
</Avatar>
<span
className={cn(
"flex-1 truncate",
!display.exists && "italic text-white/40",
)}
>
{display.displayName}
</span>
{isCreatorRow && (
<span className="text-[10px] uppercase tracking-wider text-white/30">
Creator
</span>
)}
{canRemove && (
<button
type="button"
onClick={() => removeMember(id)}
className="rounded p-1 text-white/30 opacity-0 transition-opacity hover:bg-white/10 hover:text-white/70 group-hover:opacity-100"
aria-label={`Remove ${display.displayName}`}
>
<X className="size-3.5" />
</button>
)}
</li>
);
})}
</ul>
</ScrollArea>
</section>
{/* Add */}
{isCreator && visibility.mode === "custom" && availableToAdd.length > 0 && (
<section className="mt-4 border-t border-white/5 pt-4">
<h3 className="mb-2 flex items-center gap-1.5 text-[10px] font-medium uppercase tracking-widest text-white/30">
<UserPlus className="size-3" />
Add people
</h3>
<ScrollArea className="max-h-32">
<ul className="flex flex-col gap-0.5 pr-2">
{availableToAdd.map((human) => (
<li key={human.id}>
<button
type="button"
onClick={() => addMember(human.id)}
className={cn(
"flex w-full items-center gap-2.5 rounded px-2 py-1.5 text-left text-sm text-white/70 transition-colors hover:bg-white/5",
)}
>
<Avatar size="sm">
<AvatarFallback className="text-[10px]">
{getInitials(human.email)}
</AvatarFallback>
</Avatar>
<span className="flex-1 truncate">{human.email_prefix}</span>
<UserPlus className="size-3.5 text-white/30" />
</button>
</li>
))}
</ul>
</ScrollArea>
</section>
)}
{isCreator && visibility.mode === "custom" && availableToAdd.length === 0 && (
<p className="mt-4 text-center text-xs text-white/30">
<Users className="mr-1 inline size-3" />
Everyone in the network is already a member
</p>
)}
</div>
</div>,
document.body,
);
}
function VisibilityPill({
active,
icon,
label,
onClick,
}: {
active: boolean;
icon: React.ReactNode;
label: string;
onClick: () => void;
}) {
return (
<button
type="button"
onClick={onClick}
className={cn(
"flex items-center justify-center gap-1.5 rounded-md px-2 py-1.5 text-xs transition-colors",
active
? "bg-white/10 text-white/90"
: "text-white/50 hover:text-white/80",
)}
>
{icon}
{label}
</button>
);
}
@@ -0,0 +1,229 @@
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
type ReactNode,
} from "react";
import { useChannel } from "@/hooks/use-channel";
import { useAuthStore } from "@/stores/auth-store";
import type { ChannelMessage } from "@/lib/pusher-client";
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export type ComposingMode = "recording" | "typing" | "screen";
export interface ComposingUser {
humanId: string;
mode: ComposingMode;
lastSeen: number;
}
interface StreamPresenceContextValue {
onlineHumanIds: Set<string>;
composingUsers: ComposingUser[];
startComposing: (mode: ComposingMode) => void;
stopComposing: () => void;
}
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
const COMPOSING_TIMEOUT_MS = 10_000;
const COMPOSING_HEARTBEAT_MS = 5_000;
const COMPOSING_CLEANUP_INTERVAL_MS = 2_000;
// ---------------------------------------------------------------------------
// Context
// ---------------------------------------------------------------------------
const StreamPresenceContext = createContext<StreamPresenceContextValue | null>(
null,
);
// ---------------------------------------------------------------------------
// Provider
// ---------------------------------------------------------------------------
interface StreamPresenceProviderProps {
networkId: string;
streamId: string;
children: ReactNode;
}
export function StreamPresenceProvider({
networkId,
streamId,
children,
}: StreamPresenceProviderProps) {
const channelId = `stream:${networkId}:${streamId}`;
const { presence, messages, sendMessage } = useChannel(channelId);
const currentUserId = useAuthStore((s) => s.user?.id);
// --- Online presence ---
const onlineHumanIds = useMemo(() => new Set(presence), [presence]);
// --- Composing state ---
const [composingUsers, setComposingUsers] = useState<ComposingUser[]>([]);
const composingMapRef = useRef(new Map<string, ComposingUser>());
const processedCountRef = useRef(0);
// Process new messages incrementally
useEffect(() => {
if (messages.length <= processedCountRef.current) return;
const newMessages = messages.slice(processedCountRef.current);
processedCountRef.current = messages.length;
let changed = false;
const map = composingMapRef.current;
for (const msg of newMessages) {
const payload = msg.payload as
| { type: string; mode?: string }
| undefined;
if (!payload?.type) continue;
// Skip own events
if (msg.humanId === currentUserId) continue;
if (payload.type === "composing_start" && payload.mode) {
map.set(msg.humanId, {
humanId: msg.humanId,
mode: payload.mode as ComposingMode,
lastSeen: Date.now(),
});
changed = true;
} else if (payload.type === "composing_stop") {
if (map.delete(msg.humanId)) changed = true;
}
}
if (changed) {
setComposingUsers(Array.from(map.values()));
}
}, [messages, currentUserId]);
// Also clear composing when a user leaves the channel
useEffect(() => {
const map = composingMapRef.current;
const onlineSet = new Set(presence);
let changed = false;
for (const humanId of map.keys()) {
if (!onlineSet.has(humanId)) {
map.delete(humanId);
changed = true;
}
}
if (changed) {
setComposingUsers(Array.from(map.values()));
}
}, [presence]);
// Cleanup stale composing entries
useEffect(() => {
const interval = setInterval(() => {
const map = composingMapRef.current;
const now = Date.now();
let changed = false;
for (const [humanId, entry] of map) {
if (now - entry.lastSeen > COMPOSING_TIMEOUT_MS) {
map.delete(humanId);
changed = true;
}
}
if (changed) {
setComposingUsers(Array.from(map.values()));
}
}, COMPOSING_CLEANUP_INTERVAL_MS);
return () => clearInterval(interval);
}, []);
// --- Composing broadcast ---
const heartbeatRef = useRef<ReturnType<typeof setInterval>>(undefined);
const startComposing = useCallback(
(mode: ComposingMode) => {
// Send immediately
sendMessage({ type: "composing_start", mode });
// Clear any existing heartbeat
clearInterval(heartbeatRef.current);
// Start heartbeat
heartbeatRef.current = setInterval(() => {
sendMessage({ type: "composing_start", mode });
}, COMPOSING_HEARTBEAT_MS);
},
[sendMessage],
);
const stopComposing = useCallback(() => {
clearInterval(heartbeatRef.current);
heartbeatRef.current = undefined;
sendMessage({ type: "composing_stop" });
}, [sendMessage]);
// Cleanup heartbeat on unmount
useEffect(() => {
return () => {
clearInterval(heartbeatRef.current);
};
}, []);
const value = useMemo<StreamPresenceContextValue>(
() => ({
onlineHumanIds,
composingUsers,
startComposing,
stopComposing,
}),
[onlineHumanIds, composingUsers, startComposing, stopComposing],
);
return (
<StreamPresenceContext.Provider value={value}>
{children}
</StreamPresenceContext.Provider>
);
}
// ---------------------------------------------------------------------------
// Hooks
// ---------------------------------------------------------------------------
function useStreamPresenceContext() {
const ctx = useContext(StreamPresenceContext);
if (!ctx) {
throw new Error(
"useStreamPresence must be used within a StreamPresenceProvider",
);
}
return ctx;
}
export function useStreamPresence() {
const { onlineHumanIds } = useStreamPresenceContext();
return { onlineHumanIds };
}
export function useStreamComposing() {
const { composingUsers } = useStreamPresenceContext();
return { composingUsers };
}
export function useStreamComposingBroadcast() {
const { startComposing, stopComposing } = useStreamPresenceContext();
return { startComposing, stopComposing };
}
@@ -0,0 +1,304 @@
import { useState } from "react";
import { useNavigate } from "react-router-dom";
import { useAuthStore } from "@/stores/auth-store";
import { apiClient } from "@/api/client";
import { isParticleDeleted, type Particle } from "@/api/types";
import { particlePath, toFirestoreDocPath } from "@/lib/particle-path";
import { Avatar, AvatarFallback, AvatarGroup } from "@/components/ui/avatar";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { useNetwork } from "@/hooks/use-networks";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Settings, CircleCheckBig, CircleDot, EllipsisVertical, Pencil, Globe, Trash2 } from "lucide-react";
import { updateStreamStatus } from "@/lib/firestore-particles";
import { RenameStreamOverlay } from "@/features/particles/rename-stream-overlay";
import { DeleteParticleOverlay } from "@/features/particles/delete-particle-overlay";
import { StreamMembersOverlay } from "@/features/particles/stream-members-overlay";
import { parseVisibleTo } from "@/lib/stream-visibility";
import { Breadcrumb, BreadcrumbItem, BreadcrumbList, BreadcrumbPage, BreadcrumbSeparator } from "@/components/ui/breadcrumb";
import { WindowControls } from "@/components/window-controls";
import { RelativeTimestamp } from "@/components/relative-timestamp";
import { useStreamPresence } from "@/features/particles/stream-presence-context";
import { resolveHumanDisplay } from "@/lib/humans";
function getParticleDisplayName(particle: Particle): string {
switch (particle.type) {
case "stream":
case "folder":
return particle.properties.name;
case "quest":
return particle.properties.title;
case "paper":
return particle.properties.title;
case "file":
return particle.properties.filename;
case "text":
return particle.properties.content.slice(0, 30);
case "media":
return particle.type;
}
}
interface TopBarProps {
networkId: string;
particle: Particle | null;
streamParticle: Particle & { type: "stream" };
}
export function TopBar({ networkId, particle, streamParticle }: TopBarProps) {
const navigate = useNavigate();
const network = useNetwork(networkId);
const userId = useAuthStore((s) => s.user?.id);
const isCreator = !!userId && userId === streamParticle.created_by_human_id;
const [renameOpen, setRenameOpen] = useState(false);
const [membersOpen, setMembersOpen] = useState(false);
const [deleteOpen, setDeleteOpen] = useState(false);
const canDeleteParticle =
!!particle &&
!!userId &&
particle.created_by_human_id === userId &&
particle.type !== "stream" &&
particle.type !== "folder" &&
!isParticleDeleted(particle);
const huddleParticipants = streamParticle.huddle_active_participants ?? [];
const hasActiveHuddle = huddleParticipants.length > 0;
const handleJoinHuddle = () => {
apiClient.getLivekitToken(networkId, streamParticle.id).then(({ token, server_url }) => {
window.electronWindow.openHuddle({ token, serverUrl: server_url });
});
};
return (
<div className="drag-region flex flex-row px-2 gap-1 items-center">
<WindowControls />
<Breadcrumb className="no-drag rounded-full bg-black/30 backdrop-blur-sm px-3 py-1 mx-auto">
<BreadcrumbList>
{streamParticle && (
<>
<BreadcrumbItem className="text-xs">
<BreadcrumbPage>{getParticleDisplayName(streamParticle)}</BreadcrumbPage>
</BreadcrumbItem>
</>
)}
{particle && (
<>
<BreadcrumbSeparator />
<BreadcrumbItem className="text-xs">
<BreadcrumbPage><ParticleBreadcrumbContent particle={particle} networkId={networkId} /></BreadcrumbPage>
</BreadcrumbItem>
</>
)}
</BreadcrumbList>
</Breadcrumb>
{hasActiveHuddle && (
<button
onClick={handleJoinHuddle}
className="no-drag flex items-center gap-2 rounded-full bg-red-500/20 px-3 py-1 backdrop-blur-sm transition-colors hover:bg-red-500/30"
>
<span className="relative flex size-2">
<span className="absolute inline-flex size-full animate-ping rounded-full bg-red-400 opacity-75" />
<span className="relative inline-flex size-2 rounded-full bg-red-500" />
</span>
<AvatarGroup>
{huddleParticipants.map((humanId) => {
const display = resolveHumanDisplay(humanId, network?.humans);
return (
<Tooltip key={humanId}>
<TooltipTrigger asChild>
<Avatar size="sm">
<AvatarFallback className="bg-red-500/30 text-[8px] text-red-200">
{display.initials}
</AvatarFallback>
</Avatar>
</TooltipTrigger>
<TooltipContent>{display.email}</TooltipContent>
</Tooltip>
);
})}
</AvatarGroup>
<span className="text-xs font-medium text-red-200">Join</span>
</button>
)}
{streamParticle.status === "closed" && (
<span className="no-drag flex items-center gap-1 rounded-full bg-white/10 px-2 py-0.5 text-xs text-muted-foreground backdrop-blur-sm">
<CircleCheckBig className="size-3" />
Closed
</span>
)}
<MembersIndicator
networkId={networkId}
streamParticle={streamParticle}
onClick={() => setMembersOpen(true)}
/>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="sm"
className="no-drag text-muted-foreground"
>
<EllipsisVertical className="size-3.5" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onSelect={async () => {
const docPath = toFirestoreDocPath(particlePath(networkId, [streamParticle.id]));
await updateStreamStatus(docPath, streamParticle.status === "open" ? "closed" : "open");
}}
>
{streamParticle.status === "open" ? (
<>
<CircleCheckBig className="size-4" />
Close stream
</>
) : (
<>
<CircleDot className="size-4 text-green-500" />
Open stream
</>
)}
</DropdownMenuItem>
{isCreator && (
<DropdownMenuItem onSelect={() => setRenameOpen(true)}>
<Pencil className="size-4" />
Rename stream
</DropdownMenuItem>
)}
{canDeleteParticle && (
<DropdownMenuItem
onSelect={() => setDeleteOpen(true)}
variant="destructive"
>
<Trash2 className="size-4" />
Delete particle
</DropdownMenuItem>
)}
<DropdownMenuItem onSelect={() => navigate("/settings")}>
<Settings className="size-4" />
Settings
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
{renameOpen && isCreator && (
<RenameStreamOverlay
networkId={networkId}
streamParticle={streamParticle}
onClose={() => setRenameOpen(false)}
/>
)}
{deleteOpen && canDeleteParticle && particle && userId && (
<DeleteParticleOverlay
networkId={networkId}
streamId={streamParticle.id}
particle={particle}
userId={userId}
onClose={() => setDeleteOpen(false)}
/>
)}
{membersOpen && (
<StreamMembersOverlay
networkId={networkId}
streamParticle={streamParticle}
isCreator={isCreator}
onClose={() => setMembersOpen(false)}
/>
)}
</div>
);
}
function MembersIndicator({
networkId,
streamParticle,
onClick,
}: {
networkId: string;
streamParticle: Particle & { type: "stream" };
onClick: () => void;
}) {
const network = useNetwork(networkId);
const visibility = parseVisibleTo(streamParticle.visible_to, networkId);
const humans = network?.humans ?? [];
const memberIds =
visibility.mode === "network"
? humans.map((h) => h.id)
: visibility.humanIds;
const shownMembers = memberIds
.slice(0, 3)
.map((id) => humans.find((h) => h.id === id))
.filter((h): h is NonNullable<typeof h> => !!h);
const overflow = memberIds.length - shownMembers.length;
return (
<Tooltip>
<TooltipTrigger asChild>
<button
onClick={onClick}
className="no-drag flex items-center gap-1.5 rounded-full bg-white/5 px-2 py-1 text-xs text-white/70 backdrop-blur-sm transition-colors hover:bg-white/10"
>
{visibility.mode === "network" ? (
<>
<Globe className="size-3 text-white/50" />
<span>Everyone</span>
</>
) : (
<>
<AvatarGroup>
{shownMembers.map((human) => (
<Avatar key={human.id} size="sm">
<AvatarFallback className="text-[8px]">
{resolveHumanDisplay(human.id, humans).initials}
</AvatarFallback>
</Avatar>
))}
</AvatarGroup>
{overflow > 0 && <span className="text-white/50">+{overflow}</span>}
</>
)}
</button>
</TooltipTrigger>
<TooltipContent>
{visibility.mode === "network"
? `Everyone in ${network?.name ?? "network"}`
: `${memberIds.length} ${memberIds.length === 1 ? "member" : "members"}`}
</TooltipContent>
</Tooltip>
);
}
function ParticleBreadcrumbContent({ particle, networkId }: { particle: Particle; networkId: string }) {
const network = useNetwork(networkId);
const { onlineHumanIds } = useStreamPresence();
const display = resolveHumanDisplay(particle.created_by_human_id, network?.humans);
const isOnline = particle.created_by_human_id ? onlineHumanIds.has(particle.created_by_human_id) : false;
return (
<span className="flex items-center gap-1.5">
<Avatar size="sm" className={isOnline ? "ring-2 ring-green-500" : ""}>
<AvatarFallback>
{display.initials}
</AvatarFallback>
</Avatar>
{display.displayName} - <RelativeTimestamp date={particle.created_at} />
</span>
);
}
@@ -0,0 +1,568 @@
import { useState, useEffect, useEffectEvent, useCallback, useRef } from "react";
import { useNavigate } from "react-router-dom";
import { useAuthStore } from "@/stores/auth-store";
import { apiClient } from "@/api/client";
import { isParticleDeleted, type Particle } from "@/api/types";
import { parseParticlePath, particlePath, toFirestoreDocPath, type ParticlePath } from "@/lib/particle-path";
import { ComposeOverlay, type ComposeStep } from "@/features/compose/compose-overlay";
import { PlaybackPageIndicator } from "@/features/particles/playback-page-indicator";
import { MediaParticleView, type MediaParticleHandle } from "@/features/particles/media-particle-view";
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 { useMediaSettingsStore } from "@/stores/media-settings-store";
import { KeybindingsOverlay, type KeybindingGroup } from "@/components/keybindings-overlay";
import { useNetwork } from "@/hooks/use-networks";
import { toggleParticleReaction } from "@/lib/firestore-particles";
import { ReactionBar } from "@/features/particles/reaction-bar";
import { TextReactionInput } from "@/features/particles/text-reaction-input";
import { TopBar } from "@/features/particles/stream-top-bar";
import { useStreamPlayback } from "@/hooks/use-stream-playback";
import { usePrefetchAdjacentMedia } from "@/hooks/use-prefetch-adjacent-media";
import { usePresencePositions } from "@/hooks/use-presence-positions";
import { StreamPresenceProvider, useStreamPresence, useStreamComposing, useStreamComposingBroadcast, type ComposingMode } from "@/features/particles/stream-presence-context";
import { ComposingIndicator } from "@/components/composing-indicator";
import { cn } from "@/lib/utils";
import { useMount } from "react-use";
import { usePlaybackPauseStore, selectIsPaused } from "@/stores/playback-pause-store";
import { usePlaybackKeys } from "@/hooks/use-playback-keys";
import { useStreamNavigationKeys } from "@/hooks/use-stream-navigation-keys";
import { useStreamActionKeys } from "@/hooks/use-stream-action-keys";
import { c } from "vite/dist/node/types.d-aGj9QkWt";
function getReactions(particle: Particle): Record<string, string[]> | undefined {
if (isParticleDeleted(particle)) return undefined;
if (particle.type === "media" || particle.type === "text") return particle.reactions;
return undefined;
}
// --- Exit countdown hook ---
const EXIT_DELAY_MS = 5000;
const EXIT_TICK_MS = 100;
type PlaybackStatus = "idle" | "playing" | "ended";
function useExitCountdown(
status: PlaybackStatus,
disabled: boolean,
onExit: () => void,
) {
const [remainingMs, setRemainingMs] = useState<number | null>(null);
const handleExit = useEffectEvent(() => {
onExit();
});
// Start/cancel countdown based on playback status
useEffect(() => {
if (status === "ended") {
setRemainingMs(EXIT_DELAY_MS);
} else {
setRemainingMs(null);
}
}, [status]);
// Tick the countdown down (pauses when compose is active)
useEffect(() => {
if (remainingMs === null || remainingMs <= 0 || disabled) return;
const interval = setInterval(() => {
setRemainingMs((prev) => {
if (prev === null) return null;
const next = prev - EXIT_TICK_MS;
return next <= 0 ? 0 : next;
});
}, EXIT_TICK_MS);
return () => clearInterval(interval);
}, [remainingMs !== null && remainingMs > 0, disabled]);
// Navigate once countdown hits zero
useEffect(() => {
if (remainingMs !== null && remainingMs <= 0) {
handleExit();
}
}, [remainingMs]);
return remainingMs;
}
// --- Keybindings ---
const STREAM_VIEW_KEYBINDINGS: KeybindingGroup[] = [
{
label: "Navigation",
bindings: [
{ keys: ["←", "→", "↑", "↓"], description: "Previous / next particle" },
{ keys: ["Esc"], description: "Back to network" },
],
},
{
label: "Playback",
bindings: [
{ keys: ["Space"], description: "Toggle pause" },
{ keys: ["Hold", "Space"], description: "Pause while held" },
{ keys: ["Hold", "Shift"], description: "1.5× speed" },
{ keys: ["Shift", "←", "→"], description: "Seek ±5s" },
],
},
{
label: "Compose",
bindings: [
{ keys: ["Hold", "`"], description: "Reply" },
{ keys: ["S"], description: "Screen record" },
{ keys: ["T"], description: "Text compose" },
{ keys: ["V"], description: "Toggle video / audio" },
{ keys: ["H"], description: "Join huddle" },
],
},
{
label: "Reactions",
bindings: [
{ keys: ["1-7"], description: "Toggle emoji reaction" },
{ keys: ["R"], description: "Quick text reply" },
],
},
];
// --- StreamView ---
interface StreamViewProps {
streamParticle: Particle & { type: "stream" };
path: ParticlePath;
}
export function StreamView({ path, streamParticle }: StreamViewProps) {
const { networkId } = parseParticlePath(path);
return (
<StreamPresenceProvider networkId={networkId} streamId={streamParticle.id}>
<StreamViewInner path={path} streamParticle={streamParticle} />
</StreamPresenceProvider>
);
}
function StreamViewInner({ path, streamParticle }: StreamViewProps) {
const { networkId } = parseParticlePath(path);
const navigate = useNavigate();
useMount(() => {
window.electronAutoplay.dismiss();
});
const {
children,
currentParticle,
currentIndex,
status,
next,
prev,
goTo,
goToParticle
} = useStreamPlayback(streamParticle, path);
usePrefetchAdjacentMedia(children, currentIndex);
const authedUser = useAuthStore((s) => s.user);
const recordingMode = useMediaSettingsStore((s) => s.recordingMode);
const setRecordingMode = useMediaSettingsStore((s) => s.setRecordingMode);
const network = useNetwork(networkId);
const presenceBySegment = usePresencePositions(
streamParticle.playback_markers,
children,
network?.humans,
authedUser?.id,
);
// --- Stream presence (realtime via pusher) ---
const { onlineHumanIds } = useStreamPresence();
const { composingUsers } = useStreamComposing();
const { startComposing, stopComposing } = useStreamComposingBroadcast();
const mediaRef = useRef<MediaParticleHandle>(null);
const handleToggleReaction = useCallback((emoji: string) => {
if (!authedUser || !currentParticle) return;
if (isParticleDeleted(currentParticle)) return;
const currentParticleDocPath = currentParticle
? toFirestoreDocPath(particlePath(networkId, [streamParticle.id, currentParticle.id]))
: null;
if (!currentParticleDocPath) return;
const reactions = getReactions(currentParticle);
toggleParticleReaction(currentParticleDocPath, emoji, authedUser.id, reactions);
}, [authedUser, currentParticle]);
const [composeActive, setComposeActive] = useState(false);
const [composeStep, setComposeStep] = useState<ComposeStep>("idle");
const paused = usePlaybackPauseStore(selectIsPaused);
const [progress, setProgress] = useState(0);
const [showKeybindings, setShowKeybindings] = useState(false);
const [textReactionOpen, setTextReactionOpen] = useState(false);
const handleSubmitTextReaction = useCallback((text: string) => {
handleToggleReaction(text);
}, [handleToggleReaction]);
const { fastPlayback } = usePlaybackKeys({ mediaRef });
useStreamNavigationKeys({
next,
prev,
currentIndex,
childrenLength: children.length,
mediaRef,
});
const handleOpenHuddle = useCallback(() => {
apiClient.getLivekitToken(networkId, streamParticle.id).then(({ token, server_url }) => {
window.electronWindow.openHuddle({ token, serverUrl: server_url });
});
navigate(`/${networkId}`);
}, [networkId, streamParticle.id, navigate]);
const handleToggleRecordingMode = useCallback(() => {
setRecordingMode(recordingMode === "video" ? "audio" : "video");
}, [recordingMode, setRecordingMode]);
const handleToggleKeybindings = useCallback(() => {
setShowKeybindings((v) => !v);
}, []);
useStreamActionKeys({
onToggleReaction: handleToggleReaction,
onOpenHuddle: handleOpenHuddle,
onToggleRecordingMode: handleToggleRecordingMode,
onToggleKeybindings: handleToggleKeybindings,
onOpenTextReaction: () => setTextReactionOpen(true),
});
// Broadcast composing state to other viewers
useEffect(() => {
const stepToMode: Record<string, ComposingMode | null> = {
idle: null,
submitting: null,
recording: "recording",
typing: "typing",
reviewing: "typing",
configuring: "typing",
picking: "screen",
};
const mode = stepToMode[composeStep] ?? null;
if (mode) {
startComposing(mode);
} else {
stopComposing();
}
}, [composeStep, startComposing, stopComposing]);
// Show/hide chrome on mouse activity (YouTube-style)
const [showControls, setShowControls] = useState(true);
const idleTimerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
const handleMouseActivity = useCallback(() => {
setShowControls(true);
clearTimeout(idleTimerRef.current);
idleTimerRef.current = setTimeout(() => setShowControls(false), 3000);
}, []);
useEffect(() => () => clearTimeout(idleTimerRef.current), []);
// 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 particle changes
useEffect(() => {
setProgress(0);
}, [currentParticle?.id]);
const handleParticleCreated = useCallback((particleId: string) => {
if (currentIndex === -1) return;
// When local user is at children.length - 1, and they send a new particle,
// we want to navigate to the new particle immediately so the user is considered caught up in the stream.
// In other cases (e.g. when user is in the middle of the stream and new particles are added),
// we don't want to disrupt their current position by jumping them to the end of the stream.
// NOTE: at this point, `children` contains stale data from the time when compose was sending, so it doesn't include the new particle yet.
if (currentIndex === children.length - 1) {
goToParticle(particleId);
}
}, [children, goToParticle, currentIndex]);
if (children.length === 0) {
return (
<div className="flex h-full flex-col items-center justify-center gap-4 bg-black text-white">
<p className="text-muted-foreground text-sm">
No particles in this stream yet
</p>
<StreamViewControls
showEscape
onOpenKeybindings={() => setShowKeybindings(true)}
/>
<ComposeOverlay
networkId={networkId}
targetPath={path}
onActiveChange={setComposeActive}
disabled={streamParticle.status === "closed"}
onParticleCreated={handleParticleCreated}
/>
</div>
);
}
// Render particle content inline
function renderParticle(particle: Particle) {
if (isParticleDeleted(particle)) {
return (
<DeletedParticleView
key={particle.id}
particle={particle}
networkId={networkId}
paused={paused}
onEnded={next}
/>
);
}
switch (particle.type) {
case "media":
return (
<MediaParticleView
ref={mediaRef}
key={particle.id}
particle={particle}
streamPath={path}
paused={paused}
onEnded={next}
onProgress={setProgress}
/>
);
case "text":
return (
<TextParticleView
key={particle.id}
particle={particle}
streamPath={path}
paused={paused}
onEnded={next}
onProgress={setProgress}
/>
);
default:
return <FallbackParticleView particle={particle} networkId={networkId} />;
}
}
return (
<div
className="relative flex h-screen flex-col overflow-hidden bg-black text-white [--stream-safe-top:4rem] [--stream-safe-bottom:5rem]"
onMouseMove={handleMouseActivity}
onMouseLeave={() => setShowControls(false)}
>
{/* Top gradient safe zone */}
<div className="pointer-events-none absolute inset-x-0 top-0 z-[5] h-32 bg-gradient-to-b from-black/60 to-transparent" />
{/* TopBar — always visible */}
<div className="z-10 absolute left-0 right-0 pt-2">
<TopBar networkId={networkId} particle={currentParticle} streamParticle={streamParticle} />
</div>
{/* Main playback area */}
<div className="flex-1 overflow-hidden">
{currentParticle && (
<div className="relative flex h-full w-full cursor-pointer items-center justify-center">
{renderParticle(currentParticle)}
<div className="pointer-events-none absolute right-4 top-14 z-20 flex flex-col items-end gap-1.5">
{fastPlayback && (
<div className="rounded-full bg-black/50 px-2.5 py-1 text-xs font-medium text-white backdrop-blur-sm">
1.5x
</div>
)}
{paused && (
<div className="rounded-full bg-black/40 px-2.5 py-1 text-xs font-medium text-white/70 backdrop-blur-sm">
Paused
</div>
)}
</div>
</div>
)}
</div>
{/* Reaction bar — always visible */}
{currentParticle && !isParticleDeleted(currentParticle) && (
<div className="absolute right-4 top-1/2 z-10 flex -translate-y-1/2 flex-col items-end gap-2">
<ReactionBar
reactions={getReactions(currentParticle)}
currentHumanId={authedUser?.id ?? ""}
humans={network?.humans}
onToggle={handleToggleReaction}
onOpenTextReaction={() => setTextReactionOpen(true)}
/>
<TextReactionInput
open={textReactionOpen}
onSubmit={handleSubmitTextReaction}
onClose={() => setTextReactionOpen(false)}
/>
</div>
)}
{/* Composing indicator — left edge, always visible */}
<ComposingIndicator users={composingUsers} networkHumans={network?.humans} />
<ComposeOverlay
networkId={networkId}
targetPath={path}
onActiveChange={setComposeActive}
onStepChange={setComposeStep}
disabled={streamParticle.status === "closed"}
onParticleCreated={handleParticleCreated}
/>
{/* Bottom gradient safe zone for keyboard hints */}
<div className="pointer-events-none absolute inset-x-0 bottom-0 z-[5] h-1/4 bg-gradient-to-t from-black/60 to-transparent" />
{/* BottomBar */}
<BottomBar
visible={controlsVisible}
total={children.length}
current={currentIndex}
progress={progress}
onGoTo={goTo}
presenceBySegment={presenceBySegment}
onlineHumanIds={onlineHumanIds}
exitRemainingMs={exitRemainingMs}
onOpenKeybindings={() => setShowKeybindings(true)}
/>
<KeybindingsOverlay
open={showKeybindings}
onClose={() => setShowKeybindings(false)}
groups={STREAM_VIEW_KEYBINDINGS}
title="Stream View"
/>
</div>
);
}
function BottomBar({
visible,
total,
current,
progress,
onGoTo,
presenceBySegment,
onlineHumanIds,
exitRemainingMs,
onOpenKeybindings,
}: {
visible: boolean;
total: number;
current: number;
progress: number;
onGoTo: (index: number) => void;
presenceBySegment: Map<number, import("@/hooks/use-presence-positions").HumanPresence[]>;
onlineHumanIds: Set<string>;
exitRemainingMs: number | null;
onOpenKeybindings: () => void;
}) {
return (
<div className={cn(
"absolute inset-x-0 bottom-0 z-10 transition-all duration-300",
visible ? "opacity-100 translate-y-0" : "opacity-0 translate-y-2 pointer-events-none",
)}>
{/* Presence avatars — above the blurred background */}
<PlaybackPageIndicator
total={total}
current={current}
progress={progress}
onGoTo={onGoTo}
presenceBySegment={presenceBySegment}
onlineHumanIds={onlineHumanIds}
layer="avatars"
/>
{/* Blurred background container — tracks + controls */}
<div className="pb-3">
<PlaybackPageIndicator
total={total}
current={current}
progress={progress}
onGoTo={onGoTo}
layer="tracks"
/>
<div className="flex items-center justify-center px-3 pt-2 gap-2">
{exitRemainingMs !== null && (
<div className="flex justify-center">
<span className="rounded-full bg-black/30 px-2 text-xs text-white/70 backdrop-blur-sm">
Closing in {Math.ceil(exitRemainingMs / 1000)}s
</span>
</div>
)}
<StreamViewControls
showEscape
onOpenKeybindings={onOpenKeybindings}
/>
</div>
</div>
</div>
);
}
function StreamViewControls({
showEscape,
onOpenKeybindings,
}: {
showEscape?: boolean;
onOpenKeybindings: () => void;
}) {
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>{" "}
back
</span>
)}
<VideoAudioToggle />
<span>
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs">
Hold `
</kbd>{" "}
to reply
</span>
<span>
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs">
T
</kbd>{" "}
text
</span>
<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"
>
?
</kbd>
</div>
);
}
@@ -0,0 +1,71 @@
import { useCallback, useState } from "react";
import { createPortal } from "react-dom";
import { toast } from "sonner";
import type { Particle } from "@/api/types";
import {
particlePath,
parseParticlePath,
toFirestoreDocPath,
type ParticlePath,
} from "@/lib/particle-path";
import { editTextParticleContent } from "@/lib/firestore-particles";
import { TextEditor } from "@/features/compose/text-editor";
import { useSuspendPlayback } from "@/hooks/use-suspend-playback";
type TextParticle = Extract<Particle, { type: "text" }>;
interface TextEditOverlayProps {
particle: TextParticle;
streamPath: ParticlePath;
onClose: () => void;
}
export function TextEditOverlay({
particle,
streamPath,
onClose,
}: TextEditOverlayProps) {
useSuspendPlayback(true, "text-edit");
const [textContent, setTextContent] = useState(particle.properties.content);
const [saving, setSaving] = useState(false);
const handleSubmit = useCallback(async () => {
if (saving) return;
const trimmed = textContent.trim();
if (!trimmed) return;
if (trimmed === particle.properties.content) {
onClose();
return;
}
setSaving(true);
try {
const { networkId, segments } = parseParticlePath(streamPath);
const docPath = toFirestoreDocPath(
particlePath(networkId, [...segments, particle.id]),
);
await editTextParticleContent(docPath, trimmed);
onClose();
} catch (err) {
toast.error(err instanceof Error ? err.message : "Failed to save");
setSaving(false);
}
}, [saving, textContent, particle.properties.content, particle.id, streamPath, onClose]);
return createPortal(
// React synthetic events bubble through the React tree (not the DOM tree),
// so clicks here would reach stream-view's click-to-navigate handler even
// though we're portaled to document.body. Stop propagation at the root.
<div className="fixed inset-0 z-[100]" onClick={(e) => e.stopPropagation()}>
<TextEditor
textContent={textContent}
onTextChange={setTextContent}
onSubmit={handleSubmit}
onCancel={onClose}
submitHint="save"
/>
</div>,
document.body,
);
}
@@ -0,0 +1,281 @@
import { useEffect, useRef, useState } from "react";
import { Pencil } from "lucide-react";
import type { Particle } from "@/api/types";
import type { ParticlePath } from "@/lib/particle-path";
import { cn } from "@/lib/utils";
import { useAllLinkMetadata, type LinkPreviewEntry } from "@/hooks/use-link-metadata";
import { extractUrls } from "@/lib/link-metadata";
import {
LinkPreviewCard,
LinkPreviewCardSkeleton,
} from "@/components/link-preview-card";
import { useParticleAttachments } from "@/hooks/use-particle-attachments";
import { ParticleAttachments } from "@/features/particles/particle-attachments";
import { TextEditOverlay } from "@/features/particles/text-edit-overlay";
import { RelativeTimestamp } from "@/components/relative-timestamp";
import { useAuthStore } from "@/stores/auth-store";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import rehypeHighlight from "rehype-highlight";
import "highlight.js/styles/github-dark.css";
type TextParticle = Extract<Particle, { type: "text" }>;
interface TextParticleViewProps {
particle: TextParticle;
streamPath: ParticlePath;
paused: boolean;
onEnded: () => void;
onProgress?: (ratio: number) => void;
}
// Characters per minute (~1000 cpm ≈ 200 wpm at ~5 chars/word)
const CHARS_PER_MINUTE = 1000;
const MIN_DURATION_S = 3;
const MAX_DURATION_S = 15;
const TICK_MS = 100;
const EXTRA_S_PER_LINK = 2;
const EXTRA_S_PER_ATTACHMENT = 2;
// Below this threshold: immersive centered display
const IMMERSIVE_CHAR_LIMIT = 120;
function computeReadDuration(
text: string,
linkCount: number,
attachmentCount: number,
): number {
const base = (text.length / CHARS_PER_MINUTE) * 60;
const extra = linkCount * EXTRA_S_PER_LINK + attachmentCount * EXTRA_S_PER_ATTACHMENT;
return Math.min(Math.max(base + extra, MIN_DURATION_S), MAX_DURATION_S);
}
function getImmersiveTextStyle(length: number) {
if (length < 30) return { size: "text-5xl", weight: "font-semibold" };
if (length < 70) return { size: "text-3xl", weight: "font-semibold" };
return { size: "text-2xl", weight: "font-normal" };
}
function hasMarkdownFormatting(content: string): boolean {
return /^#{1,6} |^\s*[-*+] |^\s*\d+\. |^```|`[^`]+`|\*\*|__|\*[^*]|_[^_]|^>/m.test(content);
}
const markdownComponents: React.ComponentProps<typeof ReactMarkdown>["components"] = {
h1: ({ children }) => <h1 className="mb-3 text-3xl font-bold text-white">{children}</h1>,
h2: ({ children }) => <h2 className="mb-2 text-2xl font-semibold text-white">{children}</h2>,
h3: ({ children }) => <h3 className="mb-2 text-xl font-semibold text-white">{children}</h3>,
h4: ({ children }) => <h4 className="mb-1 text-lg font-medium text-white">{children}</h4>,
h5: ({ children }) => <h5 className="mb-1 text-base font-medium text-white">{children}</h5>,
h6: ({ children }) => <h6 className="mb-1 text-sm font-medium text-white">{children}</h6>,
p: ({ children }) => <p className="mb-3 leading-relaxed text-white last:mb-0">{children}</p>,
strong: ({ children }) => <strong className="font-semibold text-white">{children}</strong>,
em: ({ children }) => <em className="italic text-white">{children}</em>,
a: ({ href, children }) => (
<a href={href} className="text-blue-400 underline" target="_blank" rel="noreferrer">
{children}
</a>
),
code: ({ className, children, ...props }) => {
const isBlock = className?.startsWith("language-");
if (isBlock) {
return (
<code className={cn(className, "text-sm")} {...props}>
{children}
</code>
);
}
return (
<code className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-sm text-white" {...props}>
{children}
</code>
);
},
pre: ({ children }) => (
<pre className="mb-3 overflow-x-auto rounded-lg bg-black/40 p-4 text-sm last:mb-0">
{children}
</pre>
),
ul: ({ children }) => <ul className="mb-3 list-disc pl-5 text-white last:mb-0">{children}</ul>,
ol: ({ children }) => <ol className="mb-3 list-decimal pl-5 text-white last:mb-0">{children}</ol>,
li: ({ children }) => <li className="mb-1 leading-relaxed">{children}</li>,
blockquote: ({ children }) => (
<blockquote className="mb-3 border-l-2 border-white/30 pl-4 italic text-white/70 last:mb-0">
{children}
</blockquote>
),
hr: () => <hr className="my-4 border-white/10" />,
};
function MarkdownContent({ content, className }: { content: string; className?: string }) {
return (
<div className={cn("break-words", className)}>
<ReactMarkdown
remarkPlugins={[remarkGfm]}
rehypePlugins={[rehypeHighlight]}
components={markdownComponents}
>
{content}
</ReactMarkdown>
</div>
);
}
function LinkPreviews({ entries }: { entries: LinkPreviewEntry[] }) {
return (
<div className="flex flex-wrap gap-3">
{entries.map((entry) => (
<div key={entry.url} className="shrink-0">
{entry.isLoading ? (
<LinkPreviewCardSkeleton />
) : entry.metadata ? (
<LinkPreviewCard metadata={entry.metadata} />
) : null}
</div>
))}
</div>
);
}
export function TextParticleView({
particle,
streamPath,
paused,
onEnded,
onProgress,
}: TextParticleViewProps) {
const content = particle.properties.content;
const linkPreviews = useAllLinkMetadata(content);
const { attachments } = useParticleAttachments(streamPath, particle.id);
const urls = extractUrls(content);
const userId = useAuthStore((s) => s.user?.id);
const isCreator = !!userId && userId === particle.created_by_human_id;
const [isEditing, setIsEditing] = useState(false);
const hasLinks = urls.length > 0;
const hasAttachments = attachments.length > 0;
const hasEnrichments = hasLinks || hasAttachments;
const durationS = computeReadDuration(content, urls.length, attachments.length);
const elapsedRef = useRef(0);
// Reset elapsed when particle changes
useEffect(() => {
elapsedRef.current = 0;
}, [particle.id]);
useEffect(() => {
if (paused) return;
const interval = setInterval(() => {
elapsedRef.current += TICK_MS / 1000;
const ratio = Math.min(elapsedRef.current / durationS, 1);
onProgress?.(ratio);
if (ratio >= 1) {
clearInterval(interval);
onEnded();
}
}, TICK_MS);
return () => clearInterval(interval);
}, [paused, durationS, onEnded, onProgress, particle.id]);
// Content is just bare URLs with no surrounding text
const contentTrimmed = content.trim();
const linksOnly = hasLinks && urls.every((url) => contentTrimmed.includes(url)) &&
contentTrimmed.replace(/https?:\/\/[^\s<>"')\]]+/g, "").trim() === "";
const editButton = isCreator && !isEditing && (
<button
type="button"
onMouseDown={(e) => e.stopPropagation()}
onClick={(e) => {
e.stopPropagation();
setIsEditing(true);
}}
title="Edit"
className="absolute bottom-[calc(var(--stream-safe-bottom,2rem)+0.5rem)] left-1/2 -translate-x-1/2 z-30 flex items-center gap-1.5 rounded-full bg-black/40 px-3 py-1.5 text-xs text-white/70 backdrop-blur-sm transition-colors hover:bg-black/60 hover:text-white"
>
<Pencil className="size-3.5" />
Edit
</button>
);
const editedLabel = particle.properties.edited_at && (
<span className="text-xs text-white/40">
edited <RelativeTimestamp date={particle.properties.edited_at} />
</span>
);
const editOverlay = isEditing && (
<TextEditOverlay
particle={particle}
streamPath={streamPath}
onClose={() => setIsEditing(false)}
/>
);
// Mode 1: bare URLs only — show link cards centered
if (linksOnly && !hasAttachments) {
return (
<div className="group relative flex h-full w-full items-center justify-center bg-gradient-to-b from-black/40 via-black/20 to-black/40 px-8 pt-[var(--stream-safe-top,2rem)] pb-[var(--stream-safe-bottom,2rem)]">
<LinkPreviews entries={linkPreviews} />
{editedLabel && (
<div className="absolute bottom-6 left-1/2 -translate-x-1/2">
{editedLabel}
</div>
)}
{editButton}
{editOverlay}
</div>
);
}
// Mode 2: short plain text, no enrichments — immersive centered display
if (content.length < IMMERSIVE_CHAR_LIMIT && !hasEnrichments && !hasMarkdownFormatting(content)) {
const style = getImmersiveTextStyle(content.length);
return (
<div className="group relative flex h-full w-full flex-col items-center justify-center gap-4 bg-gradient-to-b from-black/40 via-black/20 to-black/40 px-8 pt-[var(--stream-safe-top,2rem)] pb-[var(--stream-safe-bottom,2rem)]">
<p
className={cn(
"max-w-2xl break-words text-center leading-relaxed text-white select-text cursor-text",
style.size,
style.weight,
)}
>
{content}
</p>
{editedLabel}
{editButton}
{editOverlay}
</div>
);
}
// Mode 3: card layout
return (
<div className="group relative flex h-full w-full items-center justify-center bg-gradient-to-b from-black/40 via-black/20 to-black/40 px-8 pt-[var(--stream-safe-top,2rem)] pb-[var(--stream-safe-bottom,2rem)]">
<div
className={cn(
"flex max-h-full w-full max-w-2xl flex-col gap-4 overflow-y-auto overscroll-contain rounded-2xl bg-white/10 p-6 backdrop-blur-md",
"[&::-webkit-scrollbar]:w-2",
"[&::-webkit-scrollbar-track]:bg-transparent",
"[&::-webkit-scrollbar-thumb]:rounded-full",
"[&::-webkit-scrollbar-thumb]:bg-white/30",
"[&::-webkit-scrollbar-thumb]:hover:bg-white/50",
)}
>
<MarkdownContent content={content} className="select-text cursor-text pb-3" />
{hasLinks && <LinkPreviews entries={linkPreviews} />}
{hasAttachments && <ParticleAttachments attachments={attachments} />}
{editedLabel}
</div>
{editButton}
{editOverlay}
</div>
);
}
@@ -0,0 +1,86 @@
import { useEffect, useRef, useState } from "react";
import { Send } from "lucide-react";
import { useSuspendPlayback } from "@/hooks/use-suspend-playback";
import { cn } from "@/lib/utils";
const MAX_LENGTH = 40;
interface TextReactionInputProps {
open: boolean;
onSubmit: (text: string) => void;
onClose: () => void;
}
export function TextReactionInput({ open, onSubmit, onClose }: TextReactionInputProps) {
const [value, setValue] = useState("");
const inputRef = useRef<HTMLInputElement>(null);
useSuspendPlayback(open, "text-reaction");
useEffect(() => {
if (!open) return;
setValue("");
const id = requestAnimationFrame(() => inputRef.current?.focus());
return () => cancelAnimationFrame(id);
}, [open]);
if (!open) return null;
const trimmed = value.trim();
const canSubmit = trimmed.length > 0;
const remaining = MAX_LENGTH - value.length;
const handleSubmit = () => {
if (!canSubmit) return;
onSubmit(trimmed);
onClose();
};
return (
<div
onClick={(e) => e.stopPropagation()}
className="flex items-center gap-1 rounded-full bg-black/60 py-1 pl-3 pr-1 shadow-lg ring-1 ring-white/15 backdrop-blur-md"
>
<input
ref={inputRef}
value={value}
onChange={(e) => setValue(e.target.value.slice(0, MAX_LENGTH))}
onBlur={onClose}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
handleSubmit();
} else if (e.key === "Escape") {
e.preventDefault();
onClose();
}
}}
placeholder="Quick reply…"
maxLength={MAX_LENGTH}
className="w-24 bg-transparent text-sm text-white outline-none placeholder:text-white/40"
/>
<span
className={cn(
"min-w-[1.5ch] text-right text-[10px] tabular-nums",
remaining <= 8 ? "text-amber-300/80" : "text-white/30",
)}
>
{remaining}
</span>
<button
onMouseDown={(e) => e.preventDefault()}
onClick={handleSubmit}
disabled={!canSubmit}
className={cn(
"ml-1 flex size-6 items-center justify-center rounded-full transition-colors",
canSubmit
? "bg-white/20 text-white hover:bg-white/30"
: "text-white/30",
)}
aria-label="Send reaction"
>
<Send className="size-3" />
</button>
</div>
);
}
@@ -0,0 +1,102 @@
import { useMemo, useRef } from "react";
import type { Transcript } from "@/api/types";
type Sentence = Transcript["paragraphs"][number]["sentences"][number];
type Word = Transcript["words"][number];
const CHUNK_SIZE = 9;
/** Split an array of words into fixed-size display chunks */
function chunkWords(words: Word[]): Word[][] {
const chunks: Word[][] = [];
for (let i = 0; i < words.length; i += CHUNK_SIZE) {
chunks.push(words.slice(i, i + CHUNK_SIZE));
}
return chunks;
}
interface TranscriptOverlayProps {
transcript: Transcript;
activeSentence: Sentence | null;
activeWordIndex: number | null;
/** Center captions vertically (e.g. for audio-only playback) */
centered?: boolean;
}
export function TranscriptOverlay({
transcript,
activeSentence,
activeWordIndex,
centered = false,
}: TranscriptOverlayProps) {
const sentenceWords = useMemo(() => {
if (!activeSentence) return [];
return transcript.words.filter(
(w) => w.start >= activeSentence.start && w.end <= activeSentence.end,
);
}, [transcript.words, activeSentence]);
const chunks = useMemo(() => chunkWords(sentenceWords), [sentenceWords]);
const activeWord =
activeWordIndex !== null ? transcript.words[activeWordIndex] : null;
// Remember the last spoken word so highlights hold during pauses
const lastSpokenWordRef = useRef<Word | null>(null);
if (activeWord) {
lastSpokenWordRef.current = activeWord;
}
const highlightWord = activeWord ?? lastSpokenWordRef.current;
const lastChunkRef = useRef<Word[] | null>(null);
// Find which chunk contains the active word, holding the last one during pauses
const activeChunk = useMemo(() => {
if (activeWord) {
for (const chunk of chunks) {
if (chunk.some((w) => w.start === activeWord.start && w.end === activeWord.end)) {
lastChunkRef.current = chunk;
return chunk;
}
}
}
// No active word (speaker pausing) — hold the last chunk
if (lastChunkRef.current && chunks.some((c) => c === lastChunkRef.current)) {
return lastChunkRef.current;
}
// Sentence changed, last chunk no longer valid — use first chunk
const fallback = chunks[0] ?? null;
lastChunkRef.current = fallback;
return fallback;
}, [chunks, activeWord]);
if (!activeSentence || !activeChunk || activeChunk.length === 0) return null;
return (
<div className={centered
? "absolute inset-0 flex items-center justify-center px-6"
: "absolute bottom-15 left-0 right-0 flex justify-center px-6"
}>
<p className="rounded-lg px-5 py-3 text-2xl text-center max-w-lg">
{activeChunk.map((word, i) => {
const isSpoken =
highlightWord !== null && word.start <= highlightWord.end;
return (
<span
key={`${word.start}-${i}`}
className={
isSpoken
? "text-white font-medium transition-colors duration-150"
: "text-white/40 transition-colors duration-150"
}
>
{i > 0 ? " " : ""}
{word.word}
</span>
);
})}
</p>
</div>
);
}
+232
View File
@@ -0,0 +1,232 @@
import { useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
import { ChevronRight, LogOut, User, Info, Shield, Mail, Mic, LifeBuoy, FileText, Volume2 } from "lucide-react";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import { Separator } from "@/components/ui/separator";
import { Switch } from "@/components/ui/switch";
import { WindowControls } from "@/components/window-controls";
import { Button } from "@/components/ui/button";
import { Muted } from "@/components/ui/typography";
import { ScrollArea } from "@/components/ui/scroll-area";
import { CopyableEmail } from "@/components/copyable-email";
import { useAuthStore } from "@/stores/auth-store";
import { useSoundEffectsStore } from "@/stores/sound-effects-store";
import { apiClient } from "@/api/client";
import { logError, toUserMessage } from "@/lib/errors";
import { toast } from "sonner";
import { PRIVACY_URL, SUPPORT_EMAIL, TERMS_URL } from "@/lib/constants";
import { ArrowLeft } from "lucide-react";
interface SettingsRowProps {
icon: React.ReactNode;
label: string;
detail?: string;
onClick?: () => void;
destructive?: boolean;
}
function SettingsRow({
icon,
label,
detail,
onClick,
destructive,
}: SettingsRowProps) {
return (
<button
type="button"
onClick={onClick}
className={`flex w-full items-center gap-3 px-4 py-3 text-left transition-colors hover:bg-accent ${destructive ? "text-destructive" : ""}`}
>
<span className="text-muted-foreground flex size-5 items-center justify-center">
{icon}
</span>
<span className="min-w-0 flex-1 text-sm font-medium">{label}</span>
{detail && <Muted className="text-xs">{detail}</Muted>}
{onClick && !destructive && (
<ChevronRight className="text-muted-foreground size-4" />
)}
</button>
);
}
function SettingsGroup({
title,
children,
}: {
title: string;
children: React.ReactNode;
}) {
return (
<div>
<p className="text-muted-foreground px-4 pb-1 pt-4 text-xs font-medium uppercase tracking-wider">
{title}
</p>
<div>{children}</div>
</div>
);
}
export default function SettingsPage() {
const navigate = useNavigate();
const user = useAuthStore((s) => s.user);
const signOut = useAuthStore((s) => s.signOut);
const [emailNotifications, setEmailNotifications] = useState(
user?.email_notifications_enabled ?? true,
);
const soundEffectsEnabled = useSoundEffectsStore((s) => s.enabled);
const setSoundEffectsEnabled = useSoundEffectsStore((s) => s.setEnabled);
const [version, setVersion] = useState<string>();
useEffect(() => {
window.electronApp.getVersion().then(setVersion);
}, []);
const handleToggleEmailNotifications = async (checked: boolean) => {
setEmailNotifications(checked);
useAuthStore.setState((state) => ({
user: state.user ? { ...state.user, email_notifications_enabled: checked } : null,
}));
try {
await apiClient.updateSettings({ email_notifications_enabled: checked });
} catch (err) {
setEmailNotifications(!checked);
useAuthStore.setState((state) => ({
user: state.user ? { ...state.user, email_notifications_enabled: !checked } : null,
}));
toast.error(toUserMessage(err));
logError(err, { scope: "settings.emailNotifications" });
}
};
const initials = user?.email_prefix?.slice(0, 2).toUpperCase() ?? "?";
return (
<div className="flex h-screen flex-col">
<div className="drag-region flex items-center gap-3 border-b px-3 py-1">
<WindowControls />
<Button
variant="ghost"
size="sm"
className="no-drag text-muted-foreground"
onClick={() => navigate(-1)}
>
<ArrowLeft className="size-3.5" />
</Button>
<span className="text-sm font-medium">Settings</span>
<div className="flex-1" />
</div>
<ScrollArea className="flex-1">
{/* Profile header */}
<div className="flex items-center gap-3 px-4 py-5">
<Avatar size="lg">
<AvatarFallback className="bg-primary/10 text-primary font-medium">
{initials}
</AvatarFallback>
</Avatar>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium">
{user?.email_prefix}
</p>
<Muted className="text-xs">{user?.email}</Muted>
</div>
</div>
<Separator />
<SettingsGroup title="Notifications">
<div className="flex w-full items-center gap-3 px-4 py-3">
<span className="text-muted-foreground flex size-5 items-center justify-center">
<Mail className="size-4" />
</span>
<span className="min-w-0 flex-1 text-sm font-medium">
Email notifications
</span>
<Switch
size="sm"
checked={emailNotifications}
onCheckedChange={handleToggleEmailNotifications}
/>
</div>
<div className="flex w-full items-center gap-3 px-4 py-3">
<span className="text-muted-foreground flex size-5 items-center justify-center">
<Volume2 className="size-4" />
</span>
<span className="min-w-0 flex-1 text-sm font-medium">
Sound effects
</span>
<Switch
size="sm"
checked={soundEffectsEnabled}
onCheckedChange={setSoundEffectsEnabled}
/>
</div>
</SettingsGroup>
<Separator className="mt-4" />
<SettingsGroup title="Media">
<SettingsRow
icon={<Mic className="size-4" />}
label="Audio & Video"
onClick={() => navigate("/settings/audio-video")}
/>
</SettingsGroup>
<Separator className="mt-4" />
<SettingsGroup title="About">
<SettingsRow
icon={<Info className="size-4" />}
label="Version"
detail={version}
/>
</SettingsGroup>
<Separator className="mt-4" />
<SettingsGroup title="Legal">
<SettingsRow
icon={<Shield className="size-4" />}
label="Privacy Policy"
onClick={() => window.electronLink.openExternal(PRIVACY_URL)}
/>
<SettingsRow
icon={<FileText className="size-4" />}
label="Terms of Service"
onClick={() => window.electronLink.openExternal(TERMS_URL)}
/>
</SettingsGroup>
<Separator className="mt-4" />
<SettingsGroup title="Contact us">
<div className="flex w-full items-center gap-3 px-4 py-3">
<span className="text-muted-foreground flex size-5 items-center justify-center">
<LifeBuoy className="size-4" />
</span>
<div className="min-w-0 flex-1">
<p className="text-sm font-medium">Email support</p>
<Muted className="text-xs">
Questions, feedback, or bug reports
</Muted>
</div>
<CopyableEmail email={SUPPORT_EMAIL} />
</div>
</SettingsGroup>
<Separator className="mt-4" />
<div className="py-4">
<SettingsRow
icon={<LogOut className="size-4" />}
label="Sign out"
onClick={signOut}
destructive
/>
</div>
</ScrollArea>
</div>
);
}
@@ -0,0 +1,309 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { useNavigate } from "react-router-dom";
import { ArrowLeft, VideoOff } from "lucide-react";
import { WindowControls } from "@/components/window-controls";
import { Button } from "@/components/ui/button";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Muted } from "@/components/ui/typography";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { AudioLevelBars } from "@/components/audio/audio-level-bars";
import { useAudioSource } from "@/components/audio/use-audio-source";
import { useMediaDevices } from "@/hooks/use-media-devices";
import {
resolveEffectiveDeviceId,
isSavedDeviceAvailable,
} from "@/hooks/use-effective-device-id";
import {
useMediaDevicesStore,
type SavedDevice,
} from "@/stores/media-devices-store";
const SYSTEM_DEFAULT = "__system_default__";
function usePreviewStream(
enabled: boolean,
micId: string | undefined,
cameraId: string | undefined,
cameraAvailable: boolean,
): { stream: MediaStream | null; error: string | null } {
const [stream, setStream] = useState<MediaStream | null>(null);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!enabled) {
setStream(null);
return;
}
let cancelled = false;
let active: MediaStream | null = null;
const audio: MediaTrackConstraints | boolean = micId
? { deviceId: { exact: micId } }
: true;
const video: MediaTrackConstraints | false = cameraAvailable
? cameraId
? { deviceId: { exact: cameraId }, aspectRatio: { ideal: 16 / 9 } }
: { aspectRatio: { ideal: 16 / 9 } }
: false;
navigator.mediaDevices
.getUserMedia({ audio, video })
.then((s) => {
if (cancelled) {
s.getTracks().forEach((t) => t.stop());
return;
}
active = s;
setStream(s);
setError(null);
})
.catch((err: unknown) => {
if (cancelled) return;
setStream(null);
setError(err instanceof Error ? err.message : "Unable to access devices");
});
return () => {
cancelled = true;
active?.getTracks().forEach((t) => t.stop());
};
}, [enabled, micId, cameraId, cameraAvailable]);
return { stream, error };
}
function deviceLabel(d: MediaDeviceInfo, index: number): string {
if (d.label) return d.label;
const kind = d.kind === "audioinput" ? "Microphone" : "Camera";
return `${kind} ${index + 1}`;
}
function FieldLabel({ children }: { children: React.ReactNode }) {
return (
<label className="text-muted-foreground text-[11px] font-medium uppercase tracking-wider">
{children}
</label>
);
}
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="flex h-3 items-end">
<div className="scale-[0.55] origin-right">
<AudioLevelBars sourceNode={audioSource.sourceNode} />
</div>
</div>
);
}
function CameraPreview({ stream }: { stream: MediaStream | null }) {
const videoRef = useRef<HTMLVideoElement>(null);
const hasVideoTrack = (stream?.getVideoTracks().length ?? 0) > 0;
useEffect(() => {
if (videoRef.current) {
videoRef.current.srcObject = hasVideoTrack ? stream : null;
}
}, [stream, hasVideoTrack]);
return (
<div className="bg-muted/40 relative aspect-video w-full overflow-hidden rounded-md border">
{hasVideoTrack ? (
<video
ref={videoRef}
muted
autoPlay
playsInline
className="h-full w-full -scale-x-100 object-cover"
/>
) : (
<div className="absolute inset-0 flex flex-col items-center justify-center gap-1.5">
<VideoOff className="text-muted-foreground size-4" />
<Muted className="text-[11px]">No preview</Muted>
</div>
)}
</div>
);
}
function DeviceSelect({
devices,
saved,
onChange,
placeholder,
}: {
devices: MediaDeviceInfo[];
saved: SavedDevice | null;
onChange: (d: SavedDevice | null) => void;
placeholder: string;
}) {
const value = saved?.deviceId ?? SYSTEM_DEFAULT;
return (
<Select
value={value}
onValueChange={(v) => {
if (v === SYSTEM_DEFAULT) {
onChange(null);
return;
}
const match = devices.find((d) => d.deviceId === v);
if (match) onChange({ deviceId: match.deviceId, label: match.label });
}}
>
<SelectTrigger size="sm" className="w-full">
<SelectValue placeholder={placeholder} />
</SelectTrigger>
<SelectContent>
<SelectItem value={SYSTEM_DEFAULT}>System default</SelectItem>
{devices.map((d, i) => (
<SelectItem key={d.deviceId} value={d.deviceId}>
{deviceLabel(d, i)}
</SelectItem>
))}
</SelectContent>
</Select>
);
}
export default function AudioVideoSettingsPage() {
const navigate = useNavigate();
const {
audioInputs,
videoInputs,
permissionState,
requestLabels,
error: deviceError,
} = useMediaDevices();
const mic = useMediaDevicesStore((s) => s.mic);
const camera = useMediaDevicesStore((s) => s.camera);
const setMic = useMediaDevicesStore((s) => s.setMic);
const setCamera = useMediaDevicesStore((s) => s.setCamera);
const effectiveMicId = useMemo(
() => resolveEffectiveDeviceId(mic, audioInputs),
[mic, audioInputs],
);
const effectiveCameraId = useMemo(
() => resolveEffectiveDeviceId(camera, videoInputs),
[camera, videoInputs],
);
const cameraAvailable = videoInputs.length > 0;
const permissionGranted = permissionState === "granted";
const { stream, error: previewError } = usePreviewStream(
permissionGranted,
effectiveMicId,
effectiveCameraId,
cameraAvailable,
);
const micUnavailable =
permissionGranted && !isSavedDeviceAvailable(mic, audioInputs);
const cameraUnavailable =
permissionGranted && !isSavedDeviceAvailable(camera, videoInputs);
return (
<div className="flex h-screen flex-col">
<div className="drag-region flex items-center gap-3 border-b px-3 py-1">
<WindowControls />
<Button
variant="ghost"
size="sm"
className="no-drag text-muted-foreground"
onClick={() => navigate(-1)}
>
<ArrowLeft className="size-3.5" />
</Button>
<span className="text-sm font-medium">Audio & Video</span>
<div className="flex-1" />
</div>
<ScrollArea className="min-h-0 flex-1">
<div className="space-y-5 px-5 py-5">
{!permissionGranted && (
<div className="bg-muted/40 flex items-start justify-between gap-3 rounded-md border px-3 py-2.5">
<div className="min-w-0">
<p className="text-sm font-medium">Allow device access</p>
<Muted className="text-[11px] leading-snug">
Grant permission to see device names and a live preview.
</Muted>
</div>
<Button size="sm" onClick={() => requestLabels()}>
Allow
</Button>
</div>
)}
{/* Microphone */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<FieldLabel>Microphone</FieldLabel>
<InlineLevelMeter stream={permissionGranted ? stream : null} />
</div>
<DeviceSelect
devices={audioInputs}
saved={mic}
onChange={setMic}
placeholder="System default"
/>
{micUnavailable && (
<Muted className="text-[11px]">
Saved mic unavailable using system default.
</Muted>
)}
</div>
{/* Camera */}
<div className="space-y-2">
<FieldLabel>Camera</FieldLabel>
<DeviceSelect
devices={videoInputs}
saved={camera}
onChange={setCamera}
placeholder={
videoInputs.length === 0 ? "No cameras found" : "System default"
}
/>
<CameraPreview stream={permissionGranted ? stream : null} />
{cameraUnavailable && (
<Muted className="text-[11px]">
Saved camera unavailable using system default.
</Muted>
)}
</div>
{(previewError || (deviceError && permissionState === "denied")) && (
<Muted className="text-destructive text-[11px]">
{previewError ?? deviceError}
</Muted>
)}
</div>
</ScrollArea>
<div className="flex items-center justify-end border-t px-5 py-3">
<Button size="sm" onClick={() => navigate(-1)}>
Done
</Button>
</div>
</div>
);
}
+16
View File
@@ -0,0 +1,16 @@
import { initializeApp } from 'firebase/app';
import { getAuth } from "firebase/auth";
import { getFirestore } from "firebase/firestore";
import { appConfig } from "@/config/env";
export const firebaseApp = initializeApp(appConfig.firebase);
export const firebaseAuth = getAuth(firebaseApp);
export const firestoreDb = getFirestore(firebaseApp);
// simplifying setup to debug production issues
// export const firestoreDb = initializeFirestore(firebaseApp,
// {
// localCache:
// persistentLocalCache(/*settings*/{ tabManager: persistentMultipleTabManager() })
// });
+29
View File
@@ -0,0 +1,29 @@
import { useQuery, useMutation } from "@tanstack/react-query";
import { apiClient } from "@/api/client";
import type { BillingCadence } from "@/api/types";
export function useNetworkBilling(networkId: string | undefined) {
return useQuery({
queryKey: ["network-billing", networkId],
queryFn: () => apiClient.getNetworkBilling(networkId!),
enabled: !!networkId,
// Refetch on window focus so the UI catches up after the user returns
// from Stripe Checkout (webhook may land a second or two later).
// FIX: doesn't work with electron
refetchOnWindowFocus: true,
refetchInterval: 10000
});
}
export function useCreateCheckoutSession(networkId: string) {
return useMutation({
mutationFn: (cadence: BillingCadence) =>
apiClient.createCheckoutSession(networkId, cadence),
});
}
export function useCreatePortalSession(networkId: string) {
return useMutation({
mutationFn: () => apiClient.createPortalSession(networkId),
});
}
+85
View File
@@ -0,0 +1,85 @@
import { useEffect, useState, useCallback, useRef } from "react";
import { usePusherClient } from "@/lib/pusher-provider";
import type { ChannelMessage } from "@/lib/pusher-client";
interface UseChannelResult {
/** Current set of humanIds present in the channel */
presence: string[];
/** Messages received on this channel (since the hook mounted) */
messages: ChannelMessage[];
/** Send a message to the channel */
sendMessage: (payload: unknown) => void;
}
/**
* Subscribe to a pusher channel. Manages presence tracking and message delivery.
* Subscribes on mount, unsubscribes on unmount.
*
* @param channelId - The channel to subscribe to, or null to skip.
*/
export function useChannel(channelId: string | null): UseChannelResult {
const client = usePusherClient();
const [presence, setPresence] = useState<string[]>([]);
const [messages, setMessages] = useState<ChannelMessage[]>([]);
useEffect(() => {
if (!client || !channelId) {
setPresence([]);
setMessages([]);
return;
}
client.subscribe(channelId);
const onSubscribed = (msg: { presence?: string[] }) => {
setPresence(msg.presence ?? []);
};
const onJoin = (msg: { humanId?: string }) => {
if (msg.humanId) {
setPresence((prev) =>
prev.includes(msg.humanId!) ? prev : [...prev, msg.humanId!],
);
}
};
const onLeave = (msg: { humanId?: string }) => {
if (msg.humanId) {
setPresence((prev) => prev.filter((id) => id !== msg.humanId));
}
};
const onMessage = (msg: { humanId?: string; payload?: unknown }) => {
if (msg.humanId) {
setMessages((prev) => [
...prev,
{ humanId: msg.humanId!, payload: msg.payload },
]);
}
};
client.on(channelId, "subscribed", onSubscribed);
client.on(channelId, "join", onJoin);
client.on(channelId, "leave", onLeave);
client.on(channelId, "message", onMessage);
return () => {
client.off(channelId, "subscribed", onSubscribed);
client.off(channelId, "join", onJoin);
client.off(channelId, "leave", onLeave);
client.off(channelId, "message", onMessage);
client.unsubscribe(channelId);
};
}, [client, channelId]);
const sendMessage = useCallback(
(payload: unknown) => {
if (client && channelId) {
client?.sendMessage(channelId, payload);
}
},
[client, channelId],
);
return { presence, messages, sendMessage };
}
@@ -0,0 +1,79 @@
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { createParticle, createStreamParticle } from "@/lib/firestore-particles";
import { CONTAINER_TYPES, type NetworkUsage, type ParticleType, type ParticlePropertiesMap } from "@/api/types";
import { parseParticlePath, particlePath, ParticlePath, toFirestoreChildrenPath } from "@/lib/particle-path";
import { QuotaExceededError } from "@/lib/errors";
import {
isUsageExhausted,
networkUsageQueryKey,
useBumpNetworkUsage,
useInvalidateNetworkUsage,
} from "./use-network-usage";
interface CreateParticleParams<T extends ParticleType = ParticleType> {
// Path to which the new particle will be added as a child
path: ParticlePath;
type: T;
properties: ParticlePropertiesMap[T];
createdByHumanId: string;
}
export function useCreateParticle() {
const qc = useQueryClient();
const bumpUsage = useBumpNetworkUsage();
const invalidateUsage = useInvalidateNetworkUsage();
return useMutation({
// Compose UI renders a custom quota-exceeded toast + cancels the overlay.
// Opt out of the global mutation error toast to avoid a double-toast.
meta: { suppressToast: true },
mutationFn: async (params: CreateParticleParams) => {
const { networkId } = parseParticlePath(params.path);
// Containers aren't counted server-side, so we block them here
if (!CONTAINER_TYPES.has(params.type)) {
const cached = qc.getQueryData<NetworkUsage>(networkUsageQueryKey(networkId));
if (isUsageExhausted(cached)) {
throw new QuotaExceededError(networkId);
}
}
const collectionPath = toFirestoreChildrenPath(params.path);
const result = await createParticle(
collectionPath,
params.type,
params.properties,
params.createdByHumanId,
);
if (!CONTAINER_TYPES.has(params.type)) {
bumpUsage(networkId);
void invalidateUsage(networkId);
}
return result;
},
});
}
type CreateStreamParticleParams = {
networkId: string;
properties: ParticlePropertiesMap["stream"];
createdByHumanId: string;
visibleTo?: string[];
};
export function useCreateStreamParticle() {
return useMutation({
mutationFn: async (params: CreateStreamParticleParams) => {
const path = particlePath(params.networkId, []);
const networkCollectionPath = toFirestoreChildrenPath(path);
return await createStreamParticle(
networkCollectionPath,
params.properties,
params.createdByHumanId,
params.visibleTo,
);
}
});
}
+55
View File
@@ -0,0 +1,55 @@
import { useEffect, useMemo } from "react";
import { where } from "firebase/firestore";
import { useLiveParticleChildren } from "@/hooks/use-particle";
import { useAuthStore } from "@/stores/auth-store";
import { particlePath } from "@/lib/particle-path";
import type { Particle, StreamProperties } from "@/api/types";
type StreamParticle = Particle & { type: "stream"; properties: StreamProperties };
const openStatusFilter = where("status", "==", "open");
/**
* Self-contained hook that syncs the macOS dock badge with the count of
* unseen open streams the current user is involved in.
*
* Sets up its own Firestore listener so it works independently of
* whatever stream list is rendered on screen.
*/
export function useDockBadge(networkId: string | undefined) {
const user = useAuthStore((s) => s.user);
const userId = user?.id;
const visibilityScopes = useMemo(() => {
const scopes: string[] = [];
if (userId) scopes.push(`human:${userId}`);
if (networkId) scopes.push(`network:${networkId}`);
return scopes;
}, [userId, networkId]);
const path = networkId ? particlePath(networkId, []) : undefined;
const { children } = useLiveParticleChildren(path, {
orderByField: "last_child_created_at",
orderDirection: "desc",
visibilityScopes,
whereFilter: openStatusFilter,
});
const unseenCount = useMemo(() => {
if (!userId) return 0;
return children.filter((c): c is StreamParticle => {
if (c.type !== "stream") return false;
const lastActivity = c.last_child_created_at?.getTime();
if (!lastActivity) return false;
const marker = c.playback_markers?.[userId]?.getTime();
if (marker === undefined) return false;
return lastActivity > marker;
}).length;
}, [children, userId]);
useEffect(() => {
window.electronApp.setDockBadge(unseenCount);
return () => window.electronApp.setDockBadge(0);
}, [unseenCount]);
}
+11
View File
@@ -0,0 +1,11 @@
import { useQuery } from "@tanstack/react-query";
import { apiClient } from "@/api/client";
export function useDownloadUrl(objectId?: string) {
return useQuery({
queryKey: ["download-url", objectId],
queryFn: () => apiClient.getParticleDownloadUrl(objectId!),
enabled: !!objectId,
staleTime: 1000 * 60 * 60, // 1 hour — signed URLs valid for 24h
});
}
@@ -0,0 +1,24 @@
import type { SavedDevice } from "@/stores/media-devices-store";
/**
* Resolves a saved device preference against the currently available
* devices. Returns the saved `deviceId` only if it still appears in the
* list otherwise `undefined` so getUserMedia falls back to the
* system default. This keeps "unplugged device" handling in one place.
*/
export function resolveEffectiveDeviceId(
saved: SavedDevice | null,
available: MediaDeviceInfo[],
): string | undefined {
if (!saved) return undefined;
const match = available.find((d) => d.deviceId === saved.deviceId);
return match ? match.deviceId : undefined;
}
export function isSavedDeviceAvailable(
saved: SavedDevice | null,
available: MediaDeviceInfo[],
): boolean {
if (!saved) return true;
return available.some((d) => d.deviceId === saved.deviceId);
}
+109
View File
@@ -0,0 +1,109 @@
import { useCallback, useEffect, useRef, useState } from "react";
interface UseFileInputOptions {
onFilesSelected: (files: File[]) => void;
enabled: boolean;
}
export function useFileInput({ onFilesSelected, enabled }: UseFileInputOptions) {
const [isDragging, setIsDragging] = useState(false);
const inputRef = useRef<HTMLInputElement | null>(null);
const dragCountRef = useRef(0);
// Stable ref for the callback to avoid re-registering effects
const onFilesRef = useRef(onFilesSelected);
onFilesRef.current = onFilesSelected;
// Hidden file input element
useEffect(() => {
const input = document.createElement("input");
input.type = "file";
input.multiple = true;
input.style.display = "none";
input.addEventListener("change", () => {
if (input.files?.length) {
onFilesRef.current(Array.from(input.files));
input.value = "";
}
});
document.body.appendChild(input);
inputRef.current = input;
return () => {
document.body.removeChild(input);
inputRef.current = null;
};
}, []);
const openFilePicker = useCallback(() => {
inputRef.current?.click();
}, []);
// Clipboard paste
useEffect(() => {
if (!enabled) return;
const handlePaste = (e: ClipboardEvent) => {
const files = Array.from(e.clipboardData?.files ?? []);
if (files.length > 0) {
e.preventDefault();
onFilesRef.current(files);
}
};
window.addEventListener("paste", handlePaste);
return () => window.removeEventListener("paste", handlePaste);
}, [enabled]);
// Drag and drop handlers
const onDragOver = useCallback(
(e: React.DragEvent) => {
if (!enabled) return;
e.preventDefault();
e.stopPropagation();
},
[enabled],
);
const onDragEnter = useCallback(
(e: React.DragEvent) => {
if (!enabled) return;
e.preventDefault();
e.stopPropagation();
dragCountRef.current++;
if (dragCountRef.current === 1) setIsDragging(true);
},
[enabled],
);
const onDragLeave = useCallback(
(e: React.DragEvent) => {
if (!enabled) return;
e.preventDefault();
e.stopPropagation();
dragCountRef.current--;
if (dragCountRef.current === 0) setIsDragging(false);
},
[enabled],
);
const onDrop = useCallback(
(e: React.DragEvent) => {
if (!enabled) return;
e.preventDefault();
e.stopPropagation();
dragCountRef.current = 0;
setIsDragging(false);
const files = Array.from(e.dataTransfer.files);
if (files.length > 0) {
onFilesRef.current(files);
}
},
[enabled],
);
return {
openFilePicker,
isDragging,
dropZoneProps: { onDragOver, onDragEnter, onDragLeave, onDrop },
};
}
+45
View File
@@ -0,0 +1,45 @@
import { useQueries, useQuery } from "@tanstack/react-query";
import { extractUrls, type LinkMetadata } from "@/lib/link-metadata";
export function useLinkMetadata(url: string | null) {
return useQuery<LinkMetadata | null>({
queryKey: ["link-metadata", url],
queryFn: () => window.electronLink.fetchMetadata(url!),
enabled: !!url,
staleTime: Infinity,
gcTime: 30 * 60 * 1000,
retry: 1,
});
}
export function useFirstLinkMetadata(text: string) {
const urls = extractUrls(text);
const firstUrl = urls[0] ?? null;
return { ...useLinkMetadata(firstUrl), url: firstUrl };
}
export interface LinkPreviewEntry {
url: string;
metadata: LinkMetadata | null | undefined;
isLoading: boolean;
}
export function useAllLinkMetadata(text: string): LinkPreviewEntry[] {
const urls = extractUrls(text);
const results = useQueries({
queries: urls.map((url) => ({
queryKey: ["link-metadata", url],
queryFn: () => window.electronLink.fetchMetadata(url),
staleTime: Infinity,
gcTime: 30 * 60 * 1000,
retry: 1,
})),
});
return urls.map((url, i) => ({
url,
metadata: results[i].data,
isLoading: results[i].isLoading,
}));
}
+83
View File
@@ -0,0 +1,83 @@
import { useCallback, useEffect, useState } from "react";
export type PermissionState = "unknown" | "granted" | "denied";
interface UseMediaDevicesResult {
audioInputs: MediaDeviceInfo[];
videoInputs: MediaDeviceInfo[];
permissionState: PermissionState;
refresh: () => Promise<void>;
requestLabels: () => Promise<void>;
error: string | null;
}
/**
* Enumerates input devices and stays subscribed to `devicechange`.
*
* Labels are only populated after the user has granted mic/camera
* permission `requestLabels` triggers a brief getUserMedia so that
* subsequent enumerations return human-readable names, matching the
* pattern most video-conferencing apps use.
*/
export function useMediaDevices(): UseMediaDevicesResult {
const [devices, setDevices] = useState<MediaDeviceInfo[]>([]);
const [permissionState, setPermissionState] =
useState<PermissionState>("unknown");
const [error, setError] = useState<string | null>(null);
const refresh = useCallback(async () => {
try {
const list = await navigator.mediaDevices.enumerateDevices();
setDevices(list);
// If at least one input device has a non-empty label, permission
// has been granted at some point for that device kind.
const hasLabels = list.some(
(d) =>
(d.kind === "audioinput" || d.kind === "videoinput") &&
d.label.length > 0,
);
if (hasLabels) setPermissionState("granted");
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to list devices");
}
}, []);
const requestLabels = useCallback(async () => {
try {
const stream = await navigator.mediaDevices.getUserMedia({
audio: true,
video: true,
});
// Immediately stop — we only needed the permission grant.
stream.getTracks().forEach((t) => t.stop());
setPermissionState("granted");
setError(null);
await refresh();
} catch (err) {
setPermissionState("denied");
setError(
err instanceof Error ? err.message : "Microphone/camera access denied",
);
}
}, [refresh]);
useEffect(() => {
refresh();
const handle = () => {
refresh();
};
navigator.mediaDevices.addEventListener("devicechange", handle);
return () => {
navigator.mediaDevices.removeEventListener("devicechange", handle);
};
}, [refresh]);
return {
audioInputs: devices.filter((d) => d.kind === "audioinput"),
videoInputs: devices.filter((d) => d.kind === "videoinput"),
permissionState,
refresh,
requestLabels,
error,
};
}
@@ -0,0 +1,62 @@
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { apiClient } from "@/api/client";
export function useMyInvitations() {
return useQuery({
queryKey: ["my-invitations"],
queryFn: () => apiClient.listMyInvitations(),
refetchInterval: 10_000,
});
}
export function useNetworkInvitations(networkId: string) {
return useQuery({
queryKey: ["network-invitations", networkId],
queryFn: () => apiClient.listNetworkInvitations(networkId),
});
}
export function useInviteMembers(networkId: string) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (emailAddresses: string[]) =>
apiClient.addMembers(networkId, { email_addresses: emailAddresses }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["network-invitations", networkId] });
queryClient.invalidateQueries({ queryKey: ["networks"] });
},
});
}
export function useAcceptInvitation() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (networkId: string) =>
apiClient.acceptInvitation({ network_id: networkId }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["my-invitations"] });
queryClient.invalidateQueries({ queryKey: ["networks"] });
},
});
}
export function useRevokeInvitation(networkId: string) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (email: string) =>
apiClient.revokeInvitation(networkId, { email }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["network-invitations", networkId] });
},
});
}
export function useRemoveMember(networkId: string) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (humanId: string) => apiClient.removeMember(networkId, humanId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["networks"] });
},
});
}

Some files were not shown because too many files have changed in this diff Show More