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
+10
View File
@@ -0,0 +1,10 @@
export interface AutoplayPayload {
particleId: string;
streamId: string;
networkId: string;
downloadUrl: string;
mimeType: string;
durationMs: number;
senderName: string;
senderInitials: string;
}
+10
View File
@@ -0,0 +1,10 @@
/** Maximum file size for attachments (25 MB). */
export const MAX_ATTACHMENT_SIZE_BYTES = 25 * 1024 * 1024;
/** Maximum number of file attachments per particle. */
export const MAX_ATTACHMENTS = 10;
export const SUPPORT_EMAIL = "[email protected]";
export const PRIVACY_URL = "https://flowylabs.ai/llink/privacy";
export const TERMS_URL = "https://flowylabs.ai/llink/tos";
+92
View File
@@ -0,0 +1,92 @@
import { z } from "zod";
import { appEnv } from "@/config/env";
export class ApiError extends Error {
constructor(
public status: number,
message: string,
) {
super(message);
this.name = "ApiError";
}
}
/**
* Thrown when a free-plan network attempts to create a non-container particle
* after hitting its daily message limit. Compose UI also disables triggers
* proactively via `useNetworkUsage` — this throw is a last-line defense.
*/
export class QuotaExceededError extends Error {
constructor(public readonly networkId: string) {
super("Daily message limit reached");
this.name = "QuotaExceededError";
}
}
const IPC_PREFIX = /^Error invoking remote method '[^']+':\s*/;
function normalizeMessage(message: string): string {
return message.replace(IPC_PREFIX, "").replace(/^Error:\s*/, "").trim();
}
export function toUserMessage(err: unknown): string {
if (err instanceof ApiError) {
if (err.status === 401) return "Please sign in again.";
if (err.status === 403) return "You don't have permission to do that.";
if (err.status === 404) return "Not found.";
if (err.status === 408 || err.status === 429) {
return "Please try again in a moment.";
}
if (err.status >= 500) {
return "Something went wrong on our end. Please try again.";
}
return normalizeMessage(err.message) || "Request failed.";
}
if (err instanceof z.ZodError) {
return "Received unexpected data from the server.";
}
if (err instanceof TypeError && /fetch|network/i.test(err.message)) {
return "Network error. Check your connection.";
}
if (err instanceof Error) {
return normalizeMessage(err.message) || "Something went wrong.";
}
return "Something went wrong.";
}
type ErrorContext = Record<string, unknown>;
type ErrorSink = (err: unknown, context?: ErrorContext) => void;
// Sentry (or any observability backend) installs itself via `installErrorSinks`
// from renderer.tsx / main.ts. Until then, logError is a dev-only console call
// and reportError always prints — no call site needs to know.
let captureSink: ErrorSink | null = null;
let breadcrumbSink: ErrorSink | null = null;
export function installErrorSinks(sinks: {
capture: ErrorSink;
breadcrumb: ErrorSink;
}): void {
captureSink = sinks.capture;
breadcrumbSink = sinks.breadcrumb;
}
/** Expected-but-recordable failures. Breadcrumb only — never pages anyone. */
export function logError(err: unknown, context?: ErrorContext): void {
if (appEnv === "dev") {
// eslint-disable-next-line no-console
console.error("[error]", err, context ?? {});
}
breadcrumbSink?.(err, context);
}
/** Unexpected failures the user may not see. Always captured. */
export function reportError(err: unknown, context?: ErrorContext): void {
// eslint-disable-next-line no-console
console.error("[error]", err, context ?? {});
captureSink?.(err, context);
}
+415
View File
@@ -0,0 +1,415 @@
import {
collection,
doc,
onSnapshot,
addDoc,
getDoc,
getDocs,
updateDoc,
query,
orderBy,
limit,
serverTimestamp,
where,
Timestamp,
arrayUnion,
arrayRemove,
type DocumentData,
type FirestoreDataConverter,
type QueryDocumentSnapshot,
type SnapshotOptions,
type Unsubscribe,
QueryFieldFilterConstraint,
} from "firebase/firestore";
import { firestoreDb } from "@/firebase";
import { isContainerType, ParticleSchema } from "@/api/types";
import type { Particle, ParticleType, ParticlePropertiesMap, Reactions } from "@/api/types";
// --- Converter ---
const particleConverter: FirestoreDataConverter<Particle> = {
toFirestore(particle: Particle): DocumentData {
const { id: _id, created_at, updated_at, ...rest } = particle;
const deletedAt = "deleted_at" in particle ? particle.deleted_at : undefined;
return {
...rest,
created_at: Timestamp.fromDate(created_at),
...(updated_at && { updated_at: Timestamp.fromDate(updated_at) }),
...(deletedAt && { deleted_at: Timestamp.fromDate(deletedAt) }),
};
},
fromFirestore(
snap: QueryDocumentSnapshot,
options?: SnapshotOptions,
): Particle {
const raw = snap.data(options);
if (typeof raw.type !== "string") {
throw new Error(`Invalid particle type: ${raw.type}`);
}
const type = raw.type as ParticleType;
switch (type) {
case "stream":
return ParticleSchema.parse({
id: snap.id,
type: raw.type,
properties: raw.properties,
created_at: (raw.created_at as Timestamp).toDate(),
created_by_human_id: raw.created_by_human_id,
updated_at: raw.updated_at ? (raw.updated_at as Timestamp).toDate() : undefined,
visible_to: raw.visible_to,
playback_markers: raw.playback_markers
? Object.fromEntries(
Object.entries(raw.playback_markers).map(([key, value]) => [
key,
(value as Timestamp).toDate(),
]),
)
: undefined,
last_child_created_at: raw.last_child_created_at ? (raw.last_child_created_at as Timestamp).toDate() : undefined,
huddle_active_participants: raw.huddle_active_participants ?? undefined,
status: raw.status ?? undefined,
});
case "folder":
return ParticleSchema.parse({
id: snap.id,
type: raw.type,
properties: raw.properties,
created_at: (raw.created_at as Timestamp).toDate(),
created_by_human_id: raw.created_by_human_id,
updated_at: raw.updated_at ? (raw.updated_at as Timestamp).toDate() : undefined,
visible_to: raw.visible_to,
});
case "media":
case "file":
case "text":
case "quest":
case "paper": {
// Firestore stores timestamps as `Timestamp`; zod expects `Date`. Text
// particles carry `properties.edited_at`, so coerce it if present.
const properties =
type === "text" && raw.properties?.edited_at
? {
...raw.properties,
edited_at: (raw.properties.edited_at as Timestamp).toDate(),
}
: raw.properties;
return ParticleSchema.parse({
id: snap.id,
type: raw.type,
properties,
created_at: (raw.created_at as Timestamp).toDate(),
created_by_human_id: raw.created_by_human_id,
updated_at: raw.updated_at ? (raw.updated_at as Timestamp).toDate() : undefined,
reactions: raw.reactions ?? undefined,
deleted_at: raw.deleted_at ? (raw.deleted_at as Timestamp).toDate() : undefined,
deleted_by_human_id: raw.deleted_by_human_id ?? undefined,
});
}
default:
throw new Error(`Unknown particle type: ${type}`);
}
},
};
// --- Typed reference helpers ---
function typedDoc(path: string) {
return doc(firestoreDb, path).withConverter(particleConverter);
}
function typedCollection(path: string) {
return collection(firestoreDb, path).withConverter(particleConverter);
}
// --- Exported operations ---
export function subscribeToParticle(
docPath: string,
onData: (particle: Particle | null) => void,
onError: (error: Error) => void,
): Unsubscribe {
return onSnapshot(
typedDoc(docPath),
(snap) => {
onData(snap.exists() ? snap.data() : null);
},
onError,
);
}
export async function getParticle(docPath: string): Promise<Particle | null> {
const doc = await getDoc(typedDoc(docPath));
if (!doc.exists()) {
return null;
}
return doc.data();
}
export interface GetParticleChildrenOptions {
orderByField: string;
orderDirection: "asc" | "desc";
}
export async function getParticleChildren(
collectionPath: string,
{
orderByField = "created_at",
orderDirection = "asc",
}: GetParticleChildrenOptions = { orderByField: "created_at", orderDirection: "asc" },
): Promise<Particle[]> {
const q = query(
typedCollection(collectionPath),
orderBy(orderByField, orderDirection),
);
const snap = await getDocs(q);
return snap.docs.map((d) => d.data());
}
export interface SubscribeToParticleChildrenOptions {
onData: (children: Particle[]) => void;
onError: (error: Error) => void;
visibilityScopes?: string[];
orderByField?: string;
orderDirection?: "asc" | "desc";
onAdded?: (child: Particle) => void;
onRemoved?: (child: Particle, updatedChildren: Particle[]) => void;
whereFilter?: QueryFieldFilterConstraint;
/** Optional cap on results. Applied after order/where constraints. */
limit?: number;
}
export function subscribeToParticleChildren(
collectionPath: string,
{
onData,
onError,
visibilityScopes = [],
orderByField = "created_at",
orderDirection = "desc",
onAdded,
onRemoved,
whereFilter,
limit: limitValue,
}: SubscribeToParticleChildrenOptions
): Unsubscribe {
let q = query(typedCollection(collectionPath), orderBy(orderByField, orderDirection));
if (visibilityScopes.length > 0) {
q = query(
q,
where("visible_to", "array-contains-any", visibilityScopes),
);
}
if (whereFilter) {
q = query(q, whereFilter);
}
if (limitValue !== undefined) {
q = query(q, limit(limitValue));
}
return onSnapshot(
q,
(snap) => {
const updatedChildren = snap.docs.map((d) => d.data());
onData(updatedChildren);
if (onAdded || onRemoved) {
for (const change of snap.docChanges()) {
if (change.type === "added" && onAdded) onAdded(change.doc.data());
if (change.type === "removed" && onRemoved) onRemoved(change.doc.data(), updatedChildren);
}
}
},
onError,
);
}
export function subscribeToLatestChild(
collectionPath: string,
onData: (child: Particle | null) => void,
onError: (error: Error) => void,
): Unsubscribe {
const q = query(
typedCollection(collectionPath),
orderBy("created_at", "desc"),
limit(1),
);
return onSnapshot(
q,
(snap) => {
onData(snap.empty ? null : snap.docs[0].data());
},
onError,
);
}
// This creates a new particle document with the given properties and returns its ID.
export async function createParticle<T extends ParticleType>(
collectionPath: string,
type: T,
properties: ParticlePropertiesMap[T],
createdByHumanId: string,
// Must be passed for container types
visibleTo?: string[],
): Promise<string> {
if (isContainerType(type) && (!visibleTo || visibleTo.length === 0)) {
throw new Error(
`visibleTo is required for container type ${type} and cannot be empty`,
);
}
const particle: Particle = ParticleSchema.parse({
id: "", // ignored by toFirestore, but needed to satisfy the type
type,
properties,
created_at: new Date(),
created_by_human_id: createdByHumanId,
...(visibleTo ? { visible_to: visibleTo } : {}),
});
const ref = await addDoc(typedCollection(collectionPath), particle);
return ref.id;
}
export async function createStreamParticle(
collectionPath: string,
properties: ParticlePropertiesMap["stream"],
createdByHumanId: string,
visibleTo?: string[],
): Promise<string> {
if (!visibleTo || visibleTo.length === 0) {
throw new Error("visibleTo is required for streams and cannot be empty");
}
const particle: Particle = ParticleSchema.parse({
id: "",
type: "stream",
properties,
created_at: new Date(),
created_by_human_id: createdByHumanId,
visible_to: visibleTo,
status: "open",
});
const ref = await addDoc(typedCollection(collectionPath), particle);
return ref.id;
}
// This allows updating properties without overwriting the entire properties object
export async function updateParticleProperties<T extends ParticleType>(
docPath: string,
properties: Partial<ParticlePropertiesMap[T]>,
): Promise<void> {
const particleRef = typedDoc(docPath);
// Take the partial and create a new object with dot notation
// e.g. { title: "New Title" } becomes { "properties.title": "New Title" }
const updatedProperties: Record<string, any> = {};
for (const key in properties) {
updatedProperties[`properties.${key}`] = properties[key];
}
await updateDoc(particleRef, {
...updatedProperties,
updated_at: serverTimestamp(),
});
}
// Edits the body of a text particle and stamps `properties.edited_at` so
// readers can see that the message was edited (distinct from `updated_at`,
// which is bumped by any write — visibility, reactions, etc.).
export async function editTextParticleContent(
docPath: string,
content: string,
): Promise<void> {
const particleRef = typedDoc(docPath);
await updateDoc(particleRef, {
"properties.content": content,
"properties.edited_at": serverTimestamp(),
updated_at: serverTimestamp(),
});
}
export async function updateParticleVisibleTo(
docPath: string,
visibleTo: string[],
): Promise<void> {
const particleRef = typedDoc(docPath);
const particle = await getParticle(docPath);
if (!particle) {
throw new Error(`Particle not found at path: ${docPath}`);
}
if (!isContainerType(particle.type)) {
throw new Error(
`Only container particles can have visible_to field. Particle at ${docPath} is of type ${particle.type}`,
);
}
await updateDoc(particleRef, {
visible_to: visibleTo,
updated_at: serverTimestamp(),
});
}
// CAUTION: use the other type safe update functions in most cases
// There is no checking whether this field actually exists on the particle type, so it can lead to inconsistent data if used incorrectly
export async function updateParticle(
docPath: string,
fieldName: string,
value: any,
): Promise<void> {
const particleRef = typedDoc(docPath);
await updateDoc(particleRef, {
[fieldName]: value,
updated_at: serverTimestamp(),
});
}
export async function updateStreamStatus(
docPath: string,
status: "open" | "closed",
): Promise<void> {
const particleRef = typedDoc(docPath);
await updateDoc(particleRef, { status, updated_at: serverTimestamp() });
}
/**
* Soft-delete (tombstone) a non-container particle. The Firestore doc stays
* in place so concurrent viewers see the deletion inline rather than being
* bumped to an adjacent particle. Idempotent — re-calling on an already
* tombstoned doc just refreshes the timestamp.
*/
export async function softDeleteParticle(
docPath: string,
humanId: string,
): Promise<void> {
const particleRef = typedDoc(docPath);
await updateDoc(particleRef, {
deleted_at: serverTimestamp(),
deleted_by_human_id: humanId,
updated_at: serverTimestamp(),
});
}
export async function updateStreamPlaybackMarker(
docPath: string,
humanId: string,
playbackPositionAt: Date,
): Promise<void> {
const particleRef = typedDoc(docPath);
const markerField = `playback_markers.${humanId}`;
await updateDoc(particleRef, {
[markerField]: Timestamp.fromDate(playbackPositionAt),
updated_at: serverTimestamp(),
});
}
export async function toggleParticleReaction(
docPath: string,
emoji: string,
humanId: string,
currentReactions?: Reactions,
): Promise<void> {
const particleRef = typedDoc(docPath);
const field = `reactions.${emoji}`;
const alreadyReacted = currentReactions?.[emoji]?.includes(humanId) ?? false;
await updateDoc(particleRef, {
[field]: alreadyReacted ? arrayRemove(humanId) : arrayUnion(humanId),
updated_at: serverTimestamp(),
});
}
+43
View File
@@ -0,0 +1,43 @@
import type { Human } from "@/api/types";
import { getInitials } from "@/lib/utils";
export const REMOVED_MEMBER_LABEL = "Removed member";
export const REMOVED_MEMBER_INITIALS = "";
export interface HumanDisplay {
/** True when the human was found in the provided list. */
exists: boolean;
/** Short name for inline text (e.g. message sender). */
displayName: string;
/** Full email or fallback label for tooltips. */
email: string;
/** Initials for avatar fallback. */
initials: string;
}
/**
* Resolve a human's display info by id, falling back consistently when the
* human has been removed from the network. Member content (particles, reactions,
* etc.) is retained after removal, so every render path needs a graceful fallback
* instead of leaking raw ids into the UI.
*/
export function resolveHumanDisplay(
humanId: string | null | undefined,
humans: Human[] | undefined,
): HumanDisplay {
const human = humanId ? humans?.find((h) => h.id === humanId) : undefined;
if (!human) {
return {
exists: false,
displayName: REMOVED_MEMBER_LABEL,
email: REMOVED_MEMBER_LABEL,
initials: REMOVED_MEMBER_INITIALS,
};
}
return {
exists: true,
displayName: human.email_prefix,
email: human.email,
initials: getInitials(human.email),
};
}
+24
View File
@@ -0,0 +1,24 @@
/**
* Downscale an image file to a thumbnail and return an object URL.
* Returns undefined for non-image files.
* Caller is responsible for revoking the URL via URL.revokeObjectURL().
*/
export async function createImageThumbnail(
file: File,
maxDim = 200,
): Promise<string | undefined> {
if (!file.type.startsWith("image/")) return undefined;
const bitmap = await createImageBitmap(file);
const scale = Math.min(1, maxDim / Math.max(bitmap.width, bitmap.height));
const w = Math.round(bitmap.width * scale);
const h = Math.round(bitmap.height * scale);
const canvas = new OffscreenCanvas(w, h);
const ctx = canvas.getContext("2d")!;
ctx.drawImage(bitmap, 0, 0, w, h);
bitmap.close();
const blob = await canvas.convertToBlob({ type: "image/jpeg", quality: 0.7 });
return URL.createObjectURL(blob);
}
+9
View File
@@ -0,0 +1,9 @@
export function isTypingTarget(e: KeyboardEvent): boolean {
const target = e.target as HTMLElement | null;
if (!target) return false;
return (
target.tagName === "INPUT" ||
target.tagName === "TEXTAREA" ||
target.isContentEditable
);
}
+14
View File
@@ -0,0 +1,14 @@
export interface LinkMetadata {
url: string;
title: string | null;
description: string | null;
image: string | null;
favicon: string | null;
domain: string;
}
const URL_REGEX = /https?:\/\/[^\s<>"')\]]+/g;
export function extractUrls(text: string): string[] {
return Array.from(text.matchAll(URL_REGEX), (m) => m[0]);
}
+67
View File
@@ -0,0 +1,67 @@
/**
* ParticlePath is a branded string type representing a URL-style path
* to a particle in the hierarchy: /{networkId}/{segment1}/{segment2}/...
*
* Using a branded type prevents accidentally passing raw strings where
* a validated particle path is expected.
*/
declare const __brand: unique symbol;
export type ParticlePath = string & { readonly [__brand]: true };
/**
* Construct a ParticlePath from a network ID and optional particle segments.
*
* @example
* particlePath("net1", []) // => "/net1"
* particlePath("net1", ["p1"]) // => "/net1/p1"
* particlePath("net1", ["p1","p2"])// => "/net1/p1/p2"
*/
export function particlePath(networkId: string, segments: string[] = []): ParticlePath {
return `/${[networkId, ...segments].join("/")}` as ParticlePath;
}
/**
* Parse a ParticlePath back into its network ID and particle segments.
*/
export function parseParticlePath(path: ParticlePath): {
networkId: string;
segments: string[];
} {
const parts = path.split("/").filter(Boolean);
return { networkId: parts[0], segments: parts.slice(1) };
}
/**
* Convert a ParticlePath to the Firestore document path for that particle.
*
* Firestore structure:
* /net1 → networks/net1/children (collection)
* /net1/p1 → networks/net1/children/p1 (document)
* /net1/p1/p2 → networks/net1/children/p1/children/p2 (document)
*/
export function toFirestoreDocPath(path: ParticlePath): string {
const { networkId, segments } = parseParticlePath(path);
const base = `networks/${networkId}/children`;
if (segments.length === 0) return base;
const parts: string[] = [base, segments[0]];
for (let i = 1; i < segments.length; i++) {
parts.push("children", segments[i]);
}
return parts.join("/");
}
/**
* Convert a ParticlePath to the Firestore collection path for its children.
*
* /net1 → networks/net1/children (root particles)
* /net1/p1 → networks/net1/children/p1/children
* /net1/p1/p2 → networks/net1/children/p1/children/p2/children
*/
export function toFirestoreChildrenPath(path: ParticlePath): string {
const { segments } = parseParticlePath(path);
if (segments.length === 0) {
return toFirestoreDocPath(path);
}
return `${toFirestoreDocPath(path)}/children`;
}
+5
View File
@@ -0,0 +1,5 @@
export const isMac = window.electronWindow?.platform === "darwin";
// Symbol to show in keyboard hints for the primary modifier
// (Cmd on macOS, Ctrl on Windows/Linux).
export const metaKey = isMac ? "⌘" : "Ctrl";
+290
View File
@@ -0,0 +1,290 @@
/**
* PusherClient manages a WebSocket connection to the pusher service.
* Handles authentication, reconnection with exponential backoff,
* channel subscriptions, and event dispatching.
*/
import { logError, reportError } from "@/lib/errors";
export type ConnectionState =
| "disconnected"
| "connecting"
| "connected"
| "reconnecting";
export interface ChannelMessage {
humanId: string;
payload: unknown;
}
// Server → Client message shape
interface ServerMessage {
type: "subscribed" | "join" | "leave" | "message" | "error";
channel?: string;
humanId?: string;
presence?: string[];
payload?: unknown;
message?: string;
}
type ChannelEventType = "subscribed" | "join" | "leave" | "message";
type ChannelEventCallback = (msg: ServerMessage) => void;
interface PusherClientConfig {
url: string;
getToken: () => string | null;
}
const INITIAL_RECONNECT_DELAY = 1000;
const MAX_RECONNECT_DELAY = 30000;
const PING_INTERVAL = 20000; // 20s — keeps alive through GKE gateway timeout
export class PusherClient {
private config: PusherClientConfig;
private ws: WebSocket | null = null;
private state: ConnectionState = "disconnected";
private stateListeners = new Set<(state: ConnectionState) => void>();
// Channel event listeners: channelId → eventType → callbacks
private listeners = new Map<
string,
Map<ChannelEventType, Set<ChannelEventCallback>>
>();
// Active subscriptions for re-subscribe on reconnect
private activeSubscriptions = new Set<string>();
// Reconnection state
private reconnectDelay = INITIAL_RECONNECT_DELAY;
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
private shouldReconnect = false;
// Keep-alive ping
private pingTimer: ReturnType<typeof setInterval> | null = null;
constructor(config: PusherClientConfig) {
this.config = config;
}
get connectionState(): ConnectionState {
return this.state;
}
connect(): void {
if (this.ws) return;
const token = this.config.getToken();
if (!token) {
console.warn("[pusher] no token available, cannot connect");
return;
}
this.shouldReconnect = true;
this.setState(
this.state === "reconnecting" ? "reconnecting" : "connecting",
);
const url = `${this.config.url}?token=${encodeURIComponent(token)}`;
this.ws = new WebSocket(url);
this.ws.onopen = () => {
this.setState("connected");
this.reconnectDelay = INITIAL_RECONNECT_DELAY;
this.startPing();
this.resubscribeAll();
};
this.ws.onclose = () => {
this.cleanup();
if (this.shouldReconnect) {
this.scheduleReconnect();
}
};
this.ws.onerror = (event) => {
// onclose fires after onerror — reconnection is handled there.
logError(event, { scope: "pusher.ws" });
};
this.ws.onmessage = (event) => {
this.handleMessage(event.data as string);
};
}
disconnect(): void {
this.shouldReconnect = false;
this.clearReconnectTimer();
this.cleanup();
this.activeSubscriptions.clear();
this.setState("disconnected");
}
subscribe(channelId: string): void {
this.activeSubscriptions.add(channelId);
this.send({ type: "subscribe", channel: channelId });
}
unsubscribe(channelId: string): void {
this.activeSubscriptions.delete(channelId);
this.send({ type: "unsubscribe", channel: channelId });
}
sendMessage(channelId: string, payload: unknown): void {
this.send({ type: "message", channel: channelId, payload });
}
on(
channelId: string,
event: ChannelEventType,
callback: ChannelEventCallback,
): void {
if (!this.listeners.has(channelId)) {
this.listeners.set(channelId, new Map());
}
const channelListeners = this.listeners.get(channelId)!;
if (!channelListeners.has(event)) {
channelListeners.set(event, new Set());
}
channelListeners.get(event)!.add(callback);
}
off(
channelId: string,
event: ChannelEventType,
callback: ChannelEventCallback,
): void {
const channelListeners = this.listeners.get(channelId);
if (!channelListeners) return;
const eventListeners = channelListeners.get(event);
if (!eventListeners) return;
eventListeners.delete(callback);
// Cleanup empty maps
if (eventListeners.size === 0) channelListeners.delete(event);
if (channelListeners.size === 0) this.listeners.delete(channelId);
}
onStateChange(callback: (state: ConnectionState) => void): () => void {
this.stateListeners.add(callback);
return () => this.stateListeners.delete(callback);
}
// --- Private ---
private send(msg: { type: string; channel?: string; payload?: unknown }): void {
if (this.ws?.readyState === WebSocket.OPEN) {
this.ws.send(JSON.stringify(msg));
}
}
private handleMessage(data: string): void {
// Ignore keep-alive pong responses
if (data === "pong") return;
let msg: ServerMessage;
try {
msg = JSON.parse(data);
} catch (err) {
logError(err, { scope: "pusher.parse", data });
return;
}
if (msg.type === "error") {
logError(new Error(msg.message ?? "pusher server error"), {
scope: "pusher.server",
});
return;
}
if (!msg.channel) return;
const channelListeners = this.listeners.get(msg.channel);
if (!channelListeners) return;
const eventListeners = channelListeners.get(msg.type as ChannelEventType);
if (!eventListeners) return;
for (const cb of eventListeners) {
try {
cb(msg);
} catch (err) {
// Listener bugs silently break user flows — escalate to reportError.
reportError(err, { scope: "pusher.listener", channel: msg.channel });
}
}
}
private resubscribeAll(): void {
for (const channelId of this.activeSubscriptions) {
this.send({ type: "subscribe", channel: channelId });
}
}
private scheduleReconnect(): void {
this.setState("reconnecting");
// Exponential backoff with jitter
const jitter = Math.random() * 0.5 + 0.75; // 0.75 - 1.25x
const delay = Math.min(
this.reconnectDelay * jitter,
MAX_RECONNECT_DELAY,
);
this.reconnectTimer = setTimeout(() => {
this.reconnectDelay = Math.min(
this.reconnectDelay * 2,
MAX_RECONNECT_DELAY,
);
this.connect();
}, delay);
}
private cleanup(): void {
this.stopPing();
if (this.ws) {
this.ws.onopen = null;
this.ws.onclose = null;
this.ws.onerror = null;
this.ws.onmessage = null;
if (
this.ws.readyState === WebSocket.OPEN ||
this.ws.readyState === WebSocket.CONNECTING
) {
this.ws.close();
}
this.ws = null;
}
}
private clearReconnectTimer(): void {
if (this.reconnectTimer) {
clearTimeout(this.reconnectTimer);
this.reconnectTimer = null;
}
}
private startPing(): void {
this.stopPing();
this.pingTimer = setInterval(() => {
// Send an empty message as a keep-alive
if (this.ws?.readyState === WebSocket.OPEN) {
this.ws.send("ping");
}
}, PING_INTERVAL);
}
private stopPing(): void {
if (this.pingTimer) {
clearInterval(this.pingTimer);
this.pingTimer = null;
}
}
private setState(state: ConnectionState): void {
if (this.state === state) return;
this.state = state;
for (const cb of this.stateListeners) {
cb(state);
}
}
}
+74
View File
@@ -0,0 +1,74 @@
import {
createContext,
useContext,
useEffect,
useRef,
useState,
type ReactNode,
} from "react";
import { PusherClient, type ConnectionState } from "./pusher-client";
import { useSessionStore } from "@/stores/session-store";
import { appConfig } from "@/config/env";
const PusherContext = createContext<PusherClient | null>(null);
const PusherStateContext = createContext<ConnectionState>("disconnected");
export function PusherProvider({ children }: { children: ReactNode }) {
const token = useSessionStore((s) => s.token);
const clientRef = useRef<PusherClient | null>(null);
const [connectionState, setConnectionState] =
useState<ConnectionState>("disconnected");
useEffect(() => {
if (!token) {
// Disconnect if token is cleared (logout)
if (clientRef.current) {
clientRef.current.disconnect();
clientRef.current = null;
setConnectionState("disconnected");
}
return;
}
const client = new PusherClient({
url: appConfig.pusherUrl,
getToken: () => useSessionStore.getState().token,
});
clientRef.current = client;
const unsubscribeState = client.onStateChange((state) => {
setConnectionState(state);
});
client.connect();
return () => {
unsubscribeState();
client.disconnect();
clientRef.current = null;
};
}, [token]);
return (
<PusherContext.Provider value={clientRef.current}>
<PusherStateContext.Provider value={connectionState}>
{children}
</PusherStateContext.Provider>
</PusherContext.Provider>
);
}
/**
* Returns the PusherClient instance, or null if not connected.
*/
export function usePusherClient(): PusherClient | null {
return useContext(PusherContext);
}
/**
* Returns the current WebSocket connection state.
*/
export function usePusherConnectionState(): ConnectionState {
return useContext(PusherStateContext);
}
+56
View File
@@ -0,0 +1,56 @@
import {
MutationCache,
QueryCache,
QueryClient,
} from "@tanstack/react-query";
import { toast } from "sonner";
import { ApiError, logError, reportError, toUserMessage } from "@/lib/errors";
declare module "@tanstack/react-query" {
interface Register {
queryMeta: { toastOnError?: boolean };
mutationMeta: { suppressToast?: boolean };
}
}
function shouldRetryQuery(failureCount: number, err: unknown): boolean {
if (err instanceof ApiError) {
// Retry only on transient status codes; 4xx generally won't succeed on retry.
if (err.status === 408 || err.status === 429) return failureCount < 2;
if (err.status >= 400 && err.status < 500) return false;
}
return failureCount < 2;
}
export function createQueryClient(): QueryClient {
return new QueryClient({
defaultOptions: {
queries: {
retry: shouldRetryQuery,
refetchOnWindowFocus: false,
},
mutations: {
// Mutations have side effects — never auto-retry.
retry: 0,
},
},
queryCache: new QueryCache({
onError: (err, query) => {
logError(err, { scope: "query", queryKey: query.queryKey });
if (query.meta?.toastOnError) {
toast.error(toUserMessage(err));
}
},
}),
mutationCache: new MutationCache({
onError: (err, _variables, _context, mutation) => {
reportError(err, {
scope: "mutation",
mutationKey: mutation.options.mutationKey,
});
if (mutation.meta?.suppressToast) return;
toast.error(toUserMessage(err));
},
}),
});
}
+19
View File
@@ -0,0 +1,19 @@
const ADJECTIVES = [
"amber", "bold", "calm", "crisp", "dark", "eager", "faint", "gentle",
"hasty", "icy", "jade", "keen", "lush", "misty", "nimble", "opal",
"pale", "quiet", "rapid", "sharp", "taut", "vivid", "warm", "zesty",
"bright", "clear", "deep", "fresh", "grand", "swift",
];
const NOUNS = [
"arrow", "bloom", "cedar", "drift", "ember", "flint", "grove", "harbor",
"iris", "jewel", "knoll", "lake", "moss", "nova", "orbit", "petal",
"quartz", "ridge", "spark", "trail", "vale", "wave", "yarn", "zenith",
"brook", "cliff", "delta", "frost", "glow", "reef",
];
export function generateRandomName(): string {
const adj = ADJECTIVES[Math.floor(Math.random() * ADJECTIVES.length)];
const noun = NOUNS[Math.floor(Math.random() * NOUNS.length)];
return `${adj}-${noun}`;
}
+29
View File
@@ -0,0 +1,29 @@
import * as Sentry from "@sentry/electron/renderer";
import { appConfig, appEnv } from "@/config/env";
import { installErrorSinks } from "@/lib/errors";
/**
* Initialise Sentry for a renderer process. No-ops when `sentryDsn` is empty
* so dev builds and unconfigured envs stay quiet.
*/
export function initSentryRenderer(): void {
if (!appConfig.sentryDsn) return;
Sentry.init({
dsn: appConfig.sentryDsn,
environment: appEnv,
tracesSampleRate: 0,
});
installErrorSinks({
capture: (err, context) =>
Sentry.captureException(err, { extra: context }),
breadcrumb: (err, context) =>
Sentry.addBreadcrumb({
category: "error",
level: "error",
message: err instanceof Error ? err.message : String(err),
data: context,
}),
});
}
+152
View File
@@ -0,0 +1,152 @@
/**
* Web Audio engine for HUD sound effects.
*
* Uses AudioBufferSourceNode for low-latency, overlapping playback (HTMLAudio
* can't overlap the same source and stalls when triggered rapidly). Adds a
* small pitch jitter on each play so repeated sounds don't feel robotic — the
* trick games use to keep keypress chirps from grating.
*/
import { logError } from "@/lib/errors";
type EngineOptions = {
/** Master volume 0..1 applied on top of per-call volume. */
masterVolume: number;
/** When false, all play() calls are no-ops. */
enabled: boolean;
};
type PlayOptions = {
/** 0..1, multiplied with master volume. */
volume?: number;
/** ±fraction of playbackRate jitter; 0.05 = ±5%. */
pitchVariance?: number;
/** Skip if the same sound was played within this many ms. Prevents audible doubling on rapid triggers. */
throttleMs?: number;
};
class SoundEffectsEngine {
private ctx: AudioContext | null = null;
private masterGain: GainNode | null = null;
private limiter: DynamicsCompressorNode | null = null;
private buffers = new Map<string, AudioBuffer>();
private loading = new Map<string, Promise<AudioBuffer | null>>();
private lastPlayedAt = new Map<string, number>();
private options: EngineOptions = { masterVolume: 0.02, enabled: true };
setOptions(next: Partial<EngineOptions>) {
this.options = { ...this.options, ...next };
if (this.masterGain) {
this.masterGain.gain.value = this.options.masterVolume;
}
}
/** Lazy AudioContext init. Browsers (and Electron in some configs) start it suspended until a user gesture. */
private getContext(): AudioContext | null {
if (this.ctx) return this.ctx;
try {
const Ctor =
window.AudioContext ??
(window as unknown as { webkitAudioContext?: typeof AudioContext })
.webkitAudioContext;
if (!Ctor) return null;
this.ctx = new Ctor();
this.masterGain = this.ctx.createGain();
this.masterGain.gain.value = this.options.masterVolume;
// Brickwall limiter: caps peaks regardless of system volume so the
// click stays controlled when the OS volume is cranked. Threshold sets
// the ceiling; high ratio + fast attack make it a hard limiter.
this.limiter = this.ctx.createDynamicsCompressor();
this.limiter.threshold.value = -24; // dB ceiling for peaks
this.limiter.knee.value = 0;
this.limiter.ratio.value = 20;
this.limiter.attack.value = 0.001;
this.limiter.release.value = 0.08;
this.masterGain.connect(this.limiter);
this.limiter.connect(this.ctx.destination);
return this.ctx;
} catch (err) {
logError(err, { scope: "soundEffects.createContext" });
return null;
}
}
/**
* Preload a sound by name from a URL. Safe to call multiple times — caches.
* Missing/failed loads resolve to null and the sound silently no-ops on play.
*/
preload(name: string, url: string): Promise<AudioBuffer | null> {
if (this.buffers.has(name)) {
return Promise.resolve(this.buffers.get(name)!);
}
const existing = this.loading.get(name);
if (existing) return existing;
const ctx = this.getContext();
if (!ctx) return Promise.resolve(null);
const promise = fetch(url)
.then((res) => {
if (!res.ok) throw new Error(`HTTP ${res.status} loading ${url}`);
return res.arrayBuffer();
})
.then((data) => ctx.decodeAudioData(data))
.then((buffer) => {
this.buffers.set(name, buffer);
return buffer;
})
.catch((err) => {
logError(err, { scope: "soundEffects.preload", name });
return null;
});
this.loading.set(name, promise);
return promise;
}
play(name: string, opts: PlayOptions = {}) {
if (!this.options.enabled) return;
const buffer = this.buffers.get(name);
if (!buffer) return; // silently skip if not loaded
const ctx = this.ctx;
const master = this.masterGain;
if (!ctx || !master) return;
// Resume suspended context (autoplay policies). Resume is async but
// start(0) is queued correctly once the context resumes.
if (ctx.state === "suspended") {
void ctx.resume().catch((err) =>
logError(err, { scope: "soundEffects.resume" }),
);
}
const throttleMs = opts.throttleMs ?? 15;
const now = ctx.currentTime * 1000;
const last = this.lastPlayedAt.get(name) ?? -Infinity;
if (now - last < throttleMs) return;
this.lastPlayedAt.set(name, now);
const source = ctx.createBufferSource();
source.buffer = buffer;
const variance = opts.pitchVariance ?? 0.04;
if (variance > 0) {
// Centered around 1.0; e.g. variance 0.04 → 0.96..1.04
const jitter = 1 + (Math.random() * 2 - 1) * variance;
source.playbackRate.value = jitter;
}
const gain = ctx.createGain();
gain.gain.value = opts.volume ?? 1;
source.connect(gain).connect(master);
source.start(0);
}
}
export const soundEffects = new SoundEffectsEngine();
export type { PlayOptions };
@@ -0,0 +1,102 @@
import { useEffect } from "react";
import { preloadAllSounds, playSound } from "./sounds";
/**
* Mounts global HUD sound behavior:
* - Preloads every registered sound file once.
* - Plays "click" on any click whose target (or ancestor) is recognizably
* clickable: <button>, <a>, role="button"/etc., or `cursor: pointer`.
* Centralizing here means div/span clickables get sound automatically
* without touching every call site.
* - Plays "key-tap" on keydown OUTSIDE text inputs, so navigating menus and
* overlays feels tactile but typing into compose stays silent.
*/
export function SoundEffectsProvider({ children }: { children: React.ReactNode }) {
useEffect(() => {
preloadAllSounds();
}, []);
useEffect(() => {
const handler = (e: MouseEvent) => {
if (e.button !== 0) return; // left click only
if (!(e.target instanceof Element)) return;
if (isClickable(e.target)) playSound("click");
};
document.addEventListener("click", handler);
return () => document.removeEventListener("click", handler);
}, []);
useEffect(() => {
const handler = (e: KeyboardEvent) => {
if (e.repeat) return;
if (isPureModifier(e.key)) return;
if (isTextInputTarget(e.target)) return;
if (e.key === "Escape" || e.key === "Enter" || e.key === "Tab") {
playSound("key-action");
} else {
playSound("key-tap");
}
};
window.addEventListener("keydown", handler);
return () => window.removeEventListener("keydown", handler);
}, []);
return children;
}
const CLICKABLE_ROLES = new Set([
"button",
"link",
"menuitem",
"menuitemcheckbox",
"menuitemradio",
"tab",
"switch",
"checkbox",
"radio",
"option",
]);
/**
* Walks up to MAX_DEPTH ancestors looking for a recognizably-clickable element.
* Tag/role checks are cheap — getComputedStyle is the fallback for div-style
* clickables that only signal intent through `cursor: pointer`.
*/
function isClickable(target: Element): boolean {
const MAX_DEPTH = 6;
let el: Element | null = target;
for (let depth = 0; el && depth < MAX_DEPTH; depth++, el = el.parentElement) {
const tag = el.tagName;
if (tag === "BUTTON" || tag === "A" || tag === "SUMMARY") return true;
const role = el.getAttribute("role");
if (role && CLICKABLE_ROLES.has(role)) return true;
if (window.getComputedStyle(el).cursor === "pointer") return true;
}
return false;
}
function isPureModifier(key: string): boolean {
return key === "Shift" || key === "Control" || key === "Meta" || key === "Alt";
}
function isTextInputTarget(target: EventTarget | null): boolean {
if (!(target instanceof HTMLElement)) return false;
const tag = target.tagName;
if (tag === "TEXTAREA") return true;
if (tag === "INPUT") {
const type = (target as HTMLInputElement).type;
return (
type === "" ||
type === "text" ||
type === "search" ||
type === "email" ||
type === "url" ||
type === "password" ||
type === "tel" ||
type === "number"
);
}
if (target.isContentEditable) return true;
return false;
}
@@ -0,0 +1,57 @@
/**
* Sound registry. To add a new effect, drop the file under
* `js/assets/sounds/` and add a line to REGISTERED below.
*
* Names referenced from code that aren't in REGISTERED fall back to "click",
* so the system feels alive end-to-end with one asset and stays consistent
* as more are added.
*/
import clickUrl from "../../../assets/sounds/click.mp3";
import { soundEffects, type PlayOptions } from "./engine";
/** Effect names referenced from code. Unmapped names fall back to "click". */
export type SoundName =
| "click"
| "key-tap"
| "key-action"
| "submit"
| "error"
| "open"
| "close";
/** Files that exist on disk. Add a line when you drop a new file. */
const REGISTERED: Partial<Record<SoundName, string>> = {
click: clickUrl,
};
/** Per-sound default play options. Tuned for HUD feel — subtle and slightly varied. */
const SOUND_DEFAULTS: Record<SoundName, PlayOptions> = {
click: { volume: 0.7, pitchVariance: 0.04 },
"key-tap": { volume: 0.4, pitchVariance: 0.08, throttleMs: 25 },
"key-action": { volume: 0.6, pitchVariance: 0.04 },
submit: { volume: 0.85, pitchVariance: 0.02 },
error: { volume: 0.7, pitchVariance: 0 },
open: { volume: 0.6, pitchVariance: 0.03 },
close: { volume: 0.5, pitchVariance: 0.03 },
};
const FALLBACK: SoundName = "click";
let preloaded = false;
/** Preload every registered sound file. Idempotent. Called once at app start. */
export function preloadAllSounds(): void {
if (preloaded) return;
preloaded = true;
for (const [name, url] of Object.entries(REGISTERED)) {
if (url) void soundEffects.preload(name, url);
}
}
/** Play a sound by name. Falls back to "click" if no dedicated file is registered. */
export function playSound(name: SoundName, override?: PlayOptions) {
const defaults = SOUND_DEFAULTS[name];
const resolvedName = REGISTERED[name] ? name : FALLBACK;
soundEffects.play(resolvedName, { ...defaults, ...override });
}
@@ -0,0 +1,15 @@
import { useCallback } from "react";
import { playSound, type SoundName } from "./sounds";
import type { PlayOptions } from "./engine";
/**
* Returns a stable `play(name, opts?)` function. The engine respects the
* global enabled/volume state from the sound effects store, so callers don't
* need to subscribe themselves.
*/
export function useSoundEffect() {
const play = useCallback((name: SoundName, opts?: PlayOptions) => {
playSound(name, opts);
}, []);
return { play };
}
+29
View File
@@ -0,0 +1,29 @@
import { removeDuplicates } from "@/lib/utils";
const HUMAN_PREFIX = "human:";
const NETWORK_PREFIX = "network:";
export type StreamVisibility =
| { mode: "network" }
| { mode: "custom"; humanIds: string[] };
export function parseVisibleTo(
visibleTo: string[],
networkId: string,
): StreamVisibility {
if (visibleTo.includes(`${NETWORK_PREFIX}${networkId}`)) {
return { mode: "network" };
}
const humanIds = visibleTo
.filter((v) => v.startsWith(HUMAN_PREFIX))
.map((v) => v.slice(HUMAN_PREFIX.length));
return { mode: "custom", humanIds };
}
export function buildNetworkVisibility(networkId: string): string[] {
return [`${NETWORK_PREFIX}${networkId}`];
}
export function buildCustomVisibility(humanIds: string[]): string[] {
return removeDuplicates(humanIds).map((id) => `${HUMAN_PREFIX}${id}`);
}
+21
View File
@@ -0,0 +1,21 @@
const MINUTE = 60;
const HOUR = 3600;
const DAY = 86400;
const WEEK = 604800;
const MONTH = 2592000;
const YEAR = 31536000;
export function formatDistanceToNow(isoString: string): string {
const seconds = Math.floor(
(Date.now() - new Date(isoString).getTime()) / 1000,
);
if (seconds < 5) return "just now";
if (seconds < MINUTE) return `${seconds}s ago`;
if (seconds < HOUR) return `${Math.floor(seconds / MINUTE)}m ago`;
if (seconds < DAY) return `${Math.floor(seconds / HOUR)}h ago`;
if (seconds < WEEK) return `${Math.floor(seconds / DAY)}d ago`;
if (seconds < MONTH) return `${Math.floor(seconds / WEEK)}w ago`;
if (seconds < YEAR) return `${Math.floor(seconds / MONTH)}mo ago`;
return `${Math.floor(seconds / YEAR)}y ago`;
}
+15
View File
@@ -0,0 +1,15 @@
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
export function getInitials(email: string): string {
const prefix = email.split("@")[0] ?? "";
return prefix.slice(0, 2).toUpperCase();
}
export function removeDuplicates<T>(array: T[]): T[] {
return [...new Set(array)];
}