infra: add linting and formatting for js projects (#230)

* wip

* wip

* wip

* format

* wip(mobile): lint and format

* cleanup

* nits

* nits

* idiomatic react

* nit

* format all root files
This commit was merged in pull request #230.
This commit is contained in:
Arjun Patel
2026-06-02 07:44:24 -07:00
committed by GitHub
parent 2fe562ce2b
commit a8a0b7db1b
258 changed files with 7822 additions and 5195 deletions
+3 -3
View File
@@ -4,7 +4,7 @@ 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 SUPPORT_EMAIL = '[email protected]';
export const PRIVACY_URL = "https://flowylabs.ai/llink/privacy";
export const TERMS_URL = "https://flowylabs.ai/llink/tos";
export const PRIVACY_URL = 'https://flowylabs.ai/llink/privacy';
export const TERMS_URL = 'https://flowylabs.ai/llink/tos';
+21 -18
View File
@@ -1,5 +1,5 @@
import { z } from "zod";
import { appEnv } from "@/config/env";
import { z } from 'zod';
import { appEnv } from '@/config/env';
export class ApiError extends Error {
constructor(
@@ -7,7 +7,7 @@ export class ApiError extends Error {
message: string,
) {
super(message);
this.name = "ApiError";
this.name = 'ApiError';
}
}
@@ -18,44 +18,47 @@ export class ApiError extends Error {
*/
export class QuotaExceededError extends Error {
constructor(public readonly networkId: string) {
super("Daily message limit reached");
this.name = "QuotaExceededError";
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();
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 === 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 === 404) return 'Not found.';
if (err.status === 408 || err.status === 429) {
return "Please try again in a moment.";
return 'Please try again in a moment.';
}
if (err.status >= 500) {
return "Something went wrong on our end. Please try again.";
return 'Something went wrong on our end. Please try again.';
}
return normalizeMessage(err.message) || "Request failed.";
return normalizeMessage(err.message) || 'Request failed.';
}
if (err instanceof z.ZodError) {
return "Received unexpected data from the server.";
return 'Received unexpected data from the server.';
}
if (err instanceof TypeError && /fetch|network/i.test(err.message)) {
return "Network error. Check your connection.";
return 'Network error. Check your connection.';
}
if (err instanceof Error) {
return normalizeMessage(err.message) || "Something went wrong.";
return normalizeMessage(err.message) || 'Something went wrong.';
}
return "Something went wrong.";
return 'Something went wrong.';
}
type ErrorContext = Record<string, unknown>;
@@ -77,9 +80,9 @@ export function installErrorSinks(sinks: {
/** Expected-but-recordable failures. Breadcrumb only — never pages anyone. */
export function logError(err: unknown, context?: ErrorContext): void {
if (appEnv === "dev") {
if (appEnv === 'dev') {
// eslint-disable-next-line no-console
console.error("[error]", err, context ?? {});
console.error('[error]', err, context ?? {});
}
breadcrumbSink?.(err, context);
}
@@ -87,6 +90,6 @@ export function logError(err: unknown, context?: ErrorContext): void {
/** 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 ?? {});
console.error('[error]', err, context ?? {});
captureSink?.(err, context);
}
+76 -55
View File
@@ -21,17 +21,23 @@ import {
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";
} 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;
const deletedAt =
'deleted_at' in particle ? particle.deleted_at : undefined;
return {
...rest,
created_at: Timestamp.fromDate(created_at),
@@ -44,51 +50,58 @@ const particleConverter: FirestoreDataConverter<Particle> = {
options?: SnapshotOptions,
): Particle {
const raw = snap.data(options);
if (typeof raw.type !== "string") {
if (typeof raw.type !== 'string') {
throw new Error(`Invalid particle type: ${raw.type}`);
}
const type = raw.type as ParticleType;
switch (type) {
case "stream":
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,
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(),
]),
)
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,
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":
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,
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": {
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
type === 'text' && raw.properties?.edited_at
? {
...raw.properties,
edited_at: (raw.properties.edited_at as Timestamp).toDate(),
@@ -100,9 +113,13 @@ const particleConverter: FirestoreDataConverter<Particle> = {
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,
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_at: raw.deleted_at
? (raw.deleted_at as Timestamp).toDate()
: undefined,
deleted_by_human_id: raw.deleted_by_human_id ?? undefined,
});
}
@@ -149,15 +166,18 @@ export async function getParticle(docPath: string): Promise<Particle | null> {
export interface GetParticleChildrenOptions {
orderByField: string;
orderDirection: "asc" | "desc";
orderDirection: 'asc' | 'desc';
}
export async function getParticleChildren(
collectionPath: string,
{
orderByField = "created_at",
orderDirection = "asc",
}: GetParticleChildrenOptions = { orderByField: "created_at", orderDirection: "asc" },
orderByField = 'created_at',
orderDirection = 'asc',
}: GetParticleChildrenOptions = {
orderByField: 'created_at',
orderDirection: 'asc',
},
): Promise<Particle[]> {
const q = query(
typedCollection(collectionPath),
@@ -172,7 +192,7 @@ export interface SubscribeToParticleChildrenOptions {
onError: (error: Error) => void;
visibilityScopes?: string[];
orderByField?: string;
orderDirection?: "asc" | "desc";
orderDirection?: 'asc' | 'desc';
onAdded?: (child: Particle) => void;
onRemoved?: (child: Particle, updatedChildren: Particle[]) => void;
whereFilter?: QueryFieldFilterConstraint;
@@ -186,20 +206,20 @@ export function subscribeToParticleChildren(
onData,
onError,
visibilityScopes = [],
orderByField = "created_at",
orderDirection = "desc",
orderByField = 'created_at',
orderDirection = 'desc',
onAdded,
onRemoved,
whereFilter,
limit: limitValue,
}: SubscribeToParticleChildrenOptions
}: SubscribeToParticleChildrenOptions,
): Unsubscribe {
let q = query(typedCollection(collectionPath), orderBy(orderByField, orderDirection));
let q = query(
typedCollection(collectionPath),
orderBy(orderByField, orderDirection),
);
if (visibilityScopes.length > 0) {
q = query(
q,
where("visible_to", "array-contains-any", visibilityScopes),
);
q = query(q, where('visible_to', 'array-contains-any', visibilityScopes));
}
if (whereFilter) {
q = query(q, whereFilter);
@@ -215,8 +235,9 @@ export function subscribeToParticleChildren(
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);
if (change.type === 'added' && onAdded) onAdded(change.doc.data());
if (change.type === 'removed' && onRemoved)
onRemoved(change.doc.data(), updatedChildren);
}
}
},
@@ -231,7 +252,7 @@ export function subscribeToLatestChild(
): Unsubscribe {
const q = query(
typedCollection(collectionPath),
orderBy("created_at", "desc"),
orderBy('created_at', 'desc'),
limit(1),
);
return onSnapshot(
@@ -259,7 +280,7 @@ export async function createParticle<T extends ParticleType>(
}
const particle: Particle = ParticleSchema.parse({
id: "", // ignored by toFirestore, but needed to satisfy the type
id: '', // ignored by toFirestore, but needed to satisfy the type
type,
properties,
created_at: new Date(),
@@ -272,22 +293,22 @@ export async function createParticle<T extends ParticleType>(
export async function createStreamParticle(
collectionPath: string,
properties: ParticlePropertiesMap["stream"],
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");
throw new Error('visibleTo is required for streams and cannot be empty');
}
const particle: Particle = ParticleSchema.parse({
id: "",
type: "stream",
id: '',
type: 'stream',
properties,
created_at: new Date(),
created_by_human_id: createdByHumanId,
visible_to: visibleTo,
status: "open",
status: 'open',
});
const ref = await addDoc(typedCollection(collectionPath), particle);
return ref.id;
@@ -301,7 +322,7 @@ export async function updateParticleProperties<T extends ParticleType>(
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> = {};
const updatedProperties: Record<string, any> = {}; // eslint-disable-line @typescript-eslint/no-explicit-any
for (const key in properties) {
updatedProperties[`properties.${key}`] = properties[key];
}
@@ -320,8 +341,8 @@ export async function editTextParticleContent(
): Promise<void> {
const particleRef = typedDoc(docPath);
await updateDoc(particleRef, {
"properties.content": content,
"properties.edited_at": serverTimestamp(),
'properties.content': content,
'properties.edited_at': serverTimestamp(),
updated_at: serverTimestamp(),
});
}
@@ -352,7 +373,7 @@ export async function updateParticleVisibleTo(
export async function updateParticle(
docPath: string,
fieldName: string,
value: any,
value: any, // eslint-disable-line @typescript-eslint/no-explicit-any
): Promise<void> {
const particleRef = typedDoc(docPath);
await updateDoc(particleRef, {
@@ -363,7 +384,7 @@ export async function updateParticle(
export async function updateStreamStatus(
docPath: string,
status: "open" | "closed",
status: 'open' | 'closed',
): Promise<void> {
const particleRef = typedDoc(docPath);
await updateDoc(particleRef, { status, updated_at: serverTimestamp() });
@@ -406,7 +427,7 @@ export async function updateStreamPlaybackMarker(
const RESERVED_REACTION_CHARS = /[~*/[\]]/g;
export function sanitizeReactionText(text: string): string {
return text.replace(RESERVED_REACTION_CHARS, "");
return text.replace(RESERVED_REACTION_CHARS, '');
}
export async function toggleParticleReaction(
@@ -421,9 +442,9 @@ export async function toggleParticleReaction(
const alreadyReacted = currentReactions?.[key]?.includes(humanId) ?? false;
await updateDoc(
particleRef,
new FieldPath("reactions", key),
new FieldPath('reactions', key),
alreadyReacted ? arrayRemove(humanId) : arrayUnion(humanId),
"updated_at",
'updated_at',
serverTimestamp(),
);
}
+4 -4
View File
@@ -1,8 +1,8 @@
import type { Human } from "@/api/types";
import { getInitials } from "@/lib/utils";
import type { Human } from '@/api/types';
import { getInitials } from '@/lib/utils';
export const REMOVED_MEMBER_LABEL = "Removed member";
export const REMOVED_MEMBER_INITIALS = "";
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. */
+4 -3
View File
@@ -7,7 +7,7 @@ export async function createImageThumbnail(
file: File,
maxDim = 200,
): Promise<string | undefined> {
if (!file.type.startsWith("image/")) return 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));
@@ -15,10 +15,11 @@ export async function createImageThumbnail(
const h = Math.round(bitmap.height * scale);
const canvas = new OffscreenCanvas(w, h);
const ctx = canvas.getContext("2d")!;
const ctx = canvas.getContext('2d');
if (!ctx) throw new Error('Failed to acquire 2D canvas context');
ctx.drawImage(bitmap, 0, 0, w, h);
bitmap.close();
const blob = await canvas.convertToBlob({ type: "image/jpeg", quality: 0.7 });
const blob = await canvas.convertToBlob({ type: 'image/jpeg', quality: 0.7 });
return URL.createObjectURL(blob);
}
+2 -2
View File
@@ -2,8 +2,8 @@ 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.tagName === 'INPUT' ||
target.tagName === 'TEXTAREA' ||
target.isContentEditable
);
}
+8 -5
View File
@@ -16,8 +16,11 @@ export type ParticlePath = string & { readonly [__brand]: true };
* 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;
export function particlePath(
networkId: string,
segments: string[] = [],
): ParticlePath {
return `/${[networkId, ...segments].join('/')}` as ParticlePath;
}
/**
@@ -27,7 +30,7 @@ export function parseParticlePath(path: ParticlePath): {
networkId: string;
segments: string[];
} {
const parts = path.split("/").filter(Boolean);
const parts = path.split('/').filter(Boolean);
return { networkId: parts[0], segments: parts.slice(1) };
}
@@ -46,9 +49,9 @@ export function toFirestoreDocPath(path: ParticlePath): string {
const parts: string[] = [base, segments[0]];
for (let i = 1; i < segments.length; i++) {
parts.push("children", segments[i]);
parts.push('children', segments[i]);
}
return parts.join("/");
return parts.join('/');
}
/**
+6 -5
View File
@@ -1,5 +1,5 @@
import { toast } from "sonner";
import { platform, DESKTOP_DOWNLOAD_URL } from "@/lib/platform";
import { toast } from 'sonner';
import { platform, DESKTOP_DOWNLOAD_URL } from '@/lib/platform';
/**
* Gate desktop-only features (huddle, screen recording). On Electron this
@@ -7,11 +7,12 @@ import { platform, DESKTOP_DOWNLOAD_URL } from "@/lib/platform";
* download and returns false — callers should bail.
*/
export function requireDesktop(feature: string): boolean {
if (platform.kind === "electron") return true;
if (platform.kind === 'electron') return true;
toast.message(`${feature} is only available in the desktop app`, {
action: {
label: "Download",
onClick: () => window.open(DESKTOP_DOWNLOAD_URL, "_blank", "noopener,noreferrer"),
label: 'Download',
onClick: () =>
window.open(DESKTOP_DOWNLOAD_URL, '_blank', 'noopener,noreferrer'),
},
});
return false;
+6 -5
View File
@@ -1,8 +1,8 @@
import { apiClient } from "@/api/client";
import type { Platform } from "./types";
import { apiClient } from '@/api/client';
import type { Platform } from './types';
export const electronPlatform: Platform = {
kind: "electron",
kind: 'electron',
window: {
minimize: () => window.electronWindow.minimize(),
@@ -12,7 +12,7 @@ export const electronPlatform: Platform = {
onMaximizeChange: (cb) => window.electronWindow.onMaximizeChange(cb),
get platform() {
const p = window.electronWindow?.platform;
return p === "darwin" || p === "win32" || p === "linux" ? p : "linux";
return p === 'darwin' || p === 'win32' || p === 'linux' ? p : 'linux';
},
},
@@ -47,7 +47,8 @@ export const electronPlatform: Platform = {
},
attachment: {
download: (url, filename) => window.electronAttachment.download(url, filename),
download: (url, filename) =>
window.electronAttachment.download(url, filename),
},
app: {
+5 -5
View File
@@ -1,8 +1,8 @@
import { electronPlatform } from "./electron";
import { electronPlatform } from './electron';
export const platform = electronPlatform;
export type { Platform, ScreenSource } from "./types";
export { DESKTOP_DOWNLOAD_URL } from "./types";
export type { Platform, ScreenSource } from './types';
export { DESKTOP_DOWNLOAD_URL } from './types';
export const isMac = platform.window.platform === "darwin";
export const metaKey = isMac ? "⌘" : "Ctrl";
export const isMac = platform.window.platform === 'darwin';
export const metaKey = isMac ? '⌘' : 'Ctrl';
+5 -5
View File
@@ -1,8 +1,8 @@
import { webPlatform } from "./web";
import { webPlatform } from './web';
export const platform = webPlatform;
export type { Platform, ScreenSource } from "./types";
export { DESKTOP_DOWNLOAD_URL } from "./types";
export type { Platform, ScreenSource } from './types';
export { DESKTOP_DOWNLOAD_URL } from './types';
export const isMac = platform.window.platform === "darwin";
export const metaKey = isMac ? "⌘" : "Ctrl";
export const isMac = platform.window.platform === 'darwin';
export const metaKey = isMac ? '⌘' : 'Ctrl';
+8 -6
View File
@@ -1,5 +1,5 @@
import type { AutoplayPayload } from "@/lib/autoplay-ipc";
import type { LinkMetadata } from "@/lib/link-metadata";
import type { AutoplayPayload } from '@/lib/autoplay-ipc';
import type { LinkMetadata } from '@/lib/link-metadata';
export interface ScreenSource {
id: string;
@@ -9,7 +9,7 @@ export interface ScreenSource {
}
export interface Platform {
kind: "electron" | "web";
kind: 'electron' | 'web';
window: {
minimize: () => void;
@@ -17,7 +17,7 @@ export interface Platform {
fullscreen: () => void;
close: () => void;
onMaximizeChange: (cb: (m: boolean) => void) => () => void;
platform: "darwin" | "win32" | "linux" | "web";
platform: 'darwin' | 'win32' | 'linux' | 'web';
};
huddle: {
@@ -42,7 +42,9 @@ export interface Platform {
navigate: (d: { networkId: string; streamId: string }) => void;
onPlay: (cb: (payload: AutoplayPayload) => void) => () => void;
onStop: (cb: () => void) => () => void;
onNavigate: (cb: (d: { networkId: string; streamId: string }) => void) => () => void;
onNavigate: (
cb: (d: { networkId: string; streamId: string }) => void,
) => () => void;
};
link: {
@@ -60,4 +62,4 @@ export interface Platform {
};
}
export const DESKTOP_DOWNLOAD_URL = "https://flowylabs.ai/llink/download";
export const DESKTOP_DOWNLOAD_URL = 'https://flowylabs.ai/llink/download';
+36 -23
View File
@@ -1,41 +1,43 @@
import { useAutoplayPayloadStore } from "@/stores/autoplay-payload-store";
import type { AutoplayPayload } from "@/lib/autoplay-ipc";
import { apiClient } from "@/api/client";
import type { Platform } from "./types";
/* eslint-disable @typescript-eslint/no-empty-function */
import { useAutoplayPayloadStore } from '@/stores/autoplay-payload-store';
import type { AutoplayPayload } from '@/lib/autoplay-ipc';
import { apiClient } from '@/api/client';
import type { Platform } from './types';
declare const __APP_VERSION__: string;
function detectWebPlatform(): "darwin" | "win32" | "linux" | "web" {
if (typeof navigator === "undefined") return "web";
function detectWebPlatform(): 'darwin' | 'win32' | 'linux' | 'web' {
if (typeof navigator === 'undefined') return 'web';
const ua = navigator.userAgent;
if (/Mac|iPhone|iPad|iPod/i.test(ua)) return "darwin";
if (/Win/i.test(ua)) return "win32";
if (/Linux|X11/i.test(ua)) return "linux";
return "web";
if (/Mac|iPhone|iPad|iPod/i.test(ua)) return 'darwin';
if (/Win/i.test(ua)) return 'win32';
if (/Linux|X11/i.test(ua)) return 'linux';
return 'web';
}
function downloadCrossOrigin(url: string, filename?: string) {
const a = document.createElement("a");
const a = document.createElement('a');
a.href = url;
if (filename) a.download = filename;
a.target = "_blank";
a.rel = "noopener noreferrer";
a.target = '_blank';
a.rel = 'noopener noreferrer';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
}
const baseTitle = typeof document !== "undefined" ? document.title : "llink";
const baseTitle = typeof document !== 'undefined' ? document.title : 'llink';
function applyDockBadge(count: number) {
if (typeof document === "undefined") return;
if (typeof document === 'undefined') return;
document.title = count > 0 ? `(${count}) ${baseTitle}` : baseTitle;
}
const NOT_SUPPORTED = "Not supported in the web app";
const NOT_SUPPORTED = 'Not supported in the web app';
export const webPlatform: Platform = {
kind: "web",
kind: 'web',
window: {
minimize: () => {},
@@ -48,18 +50,26 @@ export const webPlatform: Platform = {
huddle: {
isSupported: false,
open: () => { throw new Error(NOT_SUPPORTED); },
open: () => {
throw new Error(NOT_SUPPORTED);
},
close: () => {},
getScreenSources: async () => { throw new Error(NOT_SUPPORTED); },
getScreenSources: async () => {
throw new Error(NOT_SUPPORTED);
},
},
screenRecord: {
isSupported: false,
start: () => { throw new Error(NOT_SUPPORTED); },
start: () => {
throw new Error(NOT_SUPPORTED);
},
stop: () => {},
cancel: () => {},
onStopRequested: () => () => {},
getScreenSources: async () => { throw new Error(NOT_SUPPORTED); },
getScreenSources: async () => {
throw new Error(NOT_SUPPORTED);
},
},
autoplay: {
@@ -84,7 +94,10 @@ export const webPlatform: Platform = {
onNavigate: (cb) =>
useAutoplayPayloadStore.subscribe((state, prev) => {
if (state.pendingNav && state.pendingNav !== prev.pendingNav) {
cb({ networkId: state.pendingNav.networkId, streamId: state.pendingNav.streamId });
cb({
networkId: state.pendingNav.networkId,
streamId: state.pendingNav.streamId,
});
}
}),
},
@@ -92,7 +105,7 @@ export const webPlatform: Platform = {
link: {
fetchMetadata: (url) => apiClient.getLinkMetadata(url).catch(() => null),
openExternal: async (url) => {
window.open(url, "_blank", "noopener,noreferrer");
window.open(url, '_blank', 'noopener,noreferrer');
},
},
+40 -36
View File
@@ -4,13 +4,13 @@
* channel subscriptions, and event dispatching.
*/
import { logError, reportError } from "@/lib/errors";
import { logError, reportError } from '@/lib/errors';
export type ConnectionState =
| "disconnected"
| "connecting"
| "connected"
| "reconnecting";
| 'disconnected'
| 'connecting'
| 'connected'
| 'reconnecting';
export interface ChannelMessage {
humanId: string;
@@ -19,7 +19,7 @@ export interface ChannelMessage {
// Server → Client message shape
interface ServerMessage {
type: "subscribed" | "join" | "leave" | "message" | "error";
type: 'subscribed' | 'join' | 'leave' | 'message' | 'error';
channel?: string;
humanId?: string;
presence?: string[];
@@ -27,7 +27,7 @@ interface ServerMessage {
message?: string;
}
type ChannelEventType = "subscribed" | "join" | "leave" | "message";
type ChannelEventType = 'subscribed' | 'join' | 'leave' | 'message';
type ChannelEventCallback = (msg: ServerMessage) => void;
interface PusherClientConfig {
@@ -42,7 +42,7 @@ 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 state: ConnectionState = 'disconnected';
private stateListeners = new Set<(state: ConnectionState) => void>();
// Channel event listeners: channelId → eventType → callbacks
@@ -75,20 +75,20 @@ export class PusherClient {
const token = this.config.getToken();
if (!token) {
console.warn("[pusher] no token available, cannot connect");
console.warn('[pusher] no token available, cannot connect');
return;
}
this.shouldReconnect = true;
this.setState(
this.state === "reconnecting" ? "reconnecting" : "connecting",
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.setState('connected');
this.reconnectDelay = INITIAL_RECONNECT_DELAY;
this.startPing();
this.resubscribeAll();
@@ -103,7 +103,7 @@ export class PusherClient {
this.ws.onerror = (event) => {
// onclose fires after onerror — reconnection is handled there.
logError(event, { scope: "pusher.ws" });
logError(event, { scope: 'pusher.ws' });
};
this.ws.onmessage = (event) => {
@@ -116,21 +116,21 @@ export class PusherClient {
this.clearReconnectTimer();
this.cleanup();
this.activeSubscriptions.clear();
this.setState("disconnected");
this.setState('disconnected');
}
subscribe(channelId: string): void {
this.activeSubscriptions.add(channelId);
this.send({ type: "subscribe", channel: channelId });
this.send({ type: 'subscribe', channel: channelId });
}
unsubscribe(channelId: string): void {
this.activeSubscriptions.delete(channelId);
this.send({ type: "unsubscribe", channel: channelId });
this.send({ type: 'unsubscribe', channel: channelId });
}
sendMessage(channelId: string, payload: unknown): void {
this.send({ type: "message", channel: channelId, payload });
this.send({ type: 'message', channel: channelId, payload });
}
on(
@@ -138,14 +138,17 @@ export class PusherClient {
event: ChannelEventType,
callback: ChannelEventCallback,
): void {
if (!this.listeners.has(channelId)) {
this.listeners.set(channelId, new Map());
let channelListeners = this.listeners.get(channelId);
if (!channelListeners) {
channelListeners = new Map();
this.listeners.set(channelId, channelListeners);
}
const channelListeners = this.listeners.get(channelId)!;
if (!channelListeners.has(event)) {
channelListeners.set(event, new Set());
let eventListeners = channelListeners.get(event);
if (!eventListeners) {
eventListeners = new Set();
channelListeners.set(event, eventListeners);
}
channelListeners.get(event)!.add(callback);
eventListeners.add(callback);
}
off(
@@ -171,7 +174,11 @@ export class PusherClient {
// --- Private ---
private send(msg: { type: string; channel?: string; payload?: unknown }): void {
private send(msg: {
type: string;
channel?: string;
payload?: unknown;
}): void {
if (this.ws?.readyState === WebSocket.OPEN) {
this.ws.send(JSON.stringify(msg));
}
@@ -179,19 +186,19 @@ export class PusherClient {
private handleMessage(data: string): void {
// Ignore keep-alive pong responses
if (data === "pong") return;
if (data === 'pong') return;
let msg: ServerMessage;
try {
msg = JSON.parse(data);
} catch (err) {
logError(err, { scope: "pusher.parse", data });
logError(err, { scope: 'pusher.parse', data });
return;
}
if (msg.type === "error") {
logError(new Error(msg.message ?? "pusher server error"), {
scope: "pusher.server",
if (msg.type === 'error') {
logError(new Error(msg.message ?? 'pusher server error'), {
scope: 'pusher.server',
});
return;
}
@@ -209,26 +216,23 @@ export class PusherClient {
cb(msg);
} catch (err) {
// Listener bugs silently break user flows — escalate to reportError.
reportError(err, { scope: "pusher.listener", channel: msg.channel });
reportError(err, { scope: 'pusher.listener', channel: msg.channel });
}
}
}
private resubscribeAll(): void {
for (const channelId of this.activeSubscriptions) {
this.send({ type: "subscribe", channel: channelId });
this.send({ type: 'subscribe', channel: channelId });
}
}
private scheduleReconnect(): void {
this.setState("reconnecting");
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,
);
const delay = Math.min(this.reconnectDelay * jitter, MAX_RECONNECT_DELAY);
this.reconnectTimer = setTimeout(() => {
this.reconnectDelay = Math.min(
@@ -268,7 +272,7 @@ export class PusherClient {
this.pingTimer = setInterval(() => {
// Send an empty message as a keep-alive
if (this.ws?.readyState === WebSocket.OPEN) {
this.ws.send("ping");
this.ws.send('ping');
}
}, PING_INTERVAL);
}
+18 -21
View File
@@ -2,40 +2,37 @@ import {
createContext,
useContext,
useEffect,
useRef,
useMemo,
useState,
type ReactNode,
} from "react";
import { PusherClient, type ConnectionState } from "./pusher-client";
import { useSessionStore } from "@/stores/session-store";
import { appConfig } from "@/config/env";
} 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");
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");
useState<ConnectionState>('disconnected');
useEffect(() => {
const client = useMemo(() => {
if (!token) {
// Disconnect if token is cleared (logout)
if (clientRef.current) {
clientRef.current.disconnect();
clientRef.current = null;
setConnectionState("disconnected");
}
return;
return null;
}
const client = new PusherClient({
return new PusherClient({
url: appConfig.pusherUrl,
getToken: () => useSessionStore.getState().token,
});
}, [token]);
clientRef.current = client;
useEffect(() => {
if (!client) {
return;
}
const unsubscribeState = client.onStateChange((state) => {
setConnectionState(state);
@@ -46,12 +43,12 @@ export function PusherProvider({ children }: { children: ReactNode }) {
return () => {
unsubscribeState();
client.disconnect();
clientRef.current = null;
setConnectionState('disconnected');
};
}, [token]);
}, [client]);
return (
<PusherContext.Provider value={clientRef.current}>
<PusherContext.Provider value={client}>
<PusherStateContext.Provider value={connectionState}>
{children}
</PusherStateContext.Provider>
+6 -10
View File
@@ -1,12 +1,8 @@
import {
MutationCache,
QueryCache,
QueryClient,
} from "@tanstack/react-query";
import { toast } from "sonner";
import { ApiError, logError, reportError, toUserMessage } from "@/lib/errors";
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" {
declare module '@tanstack/react-query' {
interface Register {
queryMeta: { toastOnError?: boolean };
mutationMeta: { suppressToast?: boolean };
@@ -36,7 +32,7 @@ export function createQueryClient(): QueryClient {
},
queryCache: new QueryCache({
onError: (err, query) => {
logError(err, { scope: "query", queryKey: query.queryKey });
logError(err, { scope: 'query', queryKey: query.queryKey });
if (query.meta?.toastOnError) {
toast.error(toUserMessage(err));
}
@@ -45,7 +41,7 @@ export function createQueryClient(): QueryClient {
mutationCache: new MutationCache({
onError: (err, _variables, _context, mutation) => {
reportError(err, {
scope: "mutation",
scope: 'mutation',
mutationKey: mutation.options.mutationKey,
});
if (mutation.meta?.suppressToast) return;
+60 -8
View File
@@ -1,15 +1,67 @@
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",
'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",
'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 {
+2 -2
View File
@@ -1,5 +1,5 @@
import type { PropsWithChildren } from "react";
import { HashRouter } from "react-router-dom";
import type { PropsWithChildren } from 'react';
import { HashRouter } from 'react-router-dom';
export function RouterShell({ children }: PropsWithChildren) {
return <HashRouter>{children}</HashRouter>;
+2 -2
View File
@@ -1,5 +1,5 @@
import type { PropsWithChildren } from "react";
import { BrowserRouter } from "react-router-dom";
import type { PropsWithChildren } from 'react';
import { BrowserRouter } from 'react-router-dom';
export function RouterShell({ children }: PropsWithChildren) {
return <BrowserRouter>{children}</BrowserRouter>;
+6 -7
View File
@@ -1,6 +1,6 @@
import * as Sentry from "@sentry/electron/renderer";
import { appConfig, appEnv } from "@/config/env";
import { installErrorSinks } from "@/lib/errors";
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
@@ -16,12 +16,11 @@ export function initSentryRenderer(): void {
});
installErrorSinks({
capture: (err, context) =>
Sentry.captureException(err, { extra: context }),
capture: (err, context) => Sentry.captureException(err, { extra: context }),
breadcrumb: (err, context) =>
Sentry.addBreadcrumb({
category: "error",
level: "error",
category: 'error',
level: 'error',
message: err instanceof Error ? err.message : String(err),
data: context,
}),
+6 -7
View File
@@ -1,6 +1,6 @@
import * as Sentry from "@sentry/react";
import { appConfig, appEnv } from "@/config/env";
import { installErrorSinks } from "@/lib/errors";
import * as Sentry from '@sentry/react';
import { appConfig, appEnv } from '@/config/env';
import { installErrorSinks } from '@/lib/errors';
export function initSentryRenderer(): void {
if (!appConfig.sentryDsn) return;
@@ -12,12 +12,11 @@ export function initSentryRenderer(): void {
});
installErrorSinks({
capture: (err, context) =>
Sentry.captureException(err, { extra: context }),
capture: (err, context) => Sentry.captureException(err, { extra: context }),
breadcrumb: (err, context) =>
Sentry.addBreadcrumb({
category: "error",
level: "error",
category: 'error',
level: 'error',
message: err instanceof Error ? err.message : String(err),
data: context,
}),
+8 -8
View File
@@ -7,7 +7,7 @@
* trick games use to keep keypress chirps from grating.
*/
import { logError } from "@/lib/errors";
import { logError } from '@/lib/errors';
type EngineOptions = {
/** Master volume 0..1 applied on top of per-call volume. */
@@ -68,7 +68,7 @@ class SoundEffectsEngine {
this.limiter.connect(this.ctx.destination);
return this.ctx;
} catch (err) {
logError(err, { scope: "soundEffects.createContext" });
logError(err, { scope: 'soundEffects.createContext' });
return null;
}
}
@@ -79,7 +79,7 @@ class SoundEffectsEngine {
*/
preload(name: string, url: string): Promise<AudioBuffer | null> {
if (this.buffers.has(name)) {
return Promise.resolve(this.buffers.get(name)!);
return Promise.resolve(this.buffers.get(name) ?? null);
}
const existing = this.loading.get(name);
if (existing) return existing;
@@ -98,7 +98,7 @@ class SoundEffectsEngine {
return buffer;
})
.catch((err) => {
logError(err, { scope: "soundEffects.preload", name });
logError(err, { scope: 'soundEffects.preload', name });
return null;
});
@@ -118,10 +118,10 @@ class SoundEffectsEngine {
// 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" }),
);
if (ctx.state === 'suspended') {
void ctx
.resume()
.catch((err) => logError(err, { scope: 'soundEffects.resume' }));
}
const throttleMs = opts.throttleMs ?? 15;
@@ -1,5 +1,5 @@
import { useEffect } from "react";
import { preloadAllSounds, playSound } from "./sounds";
import { useEffect } from 'react';
import { preloadAllSounds, playSound } from './sounds';
/**
* Mounts global HUD sound behavior:
@@ -11,7 +11,11 @@ import { preloadAllSounds, playSound } from "./sounds";
* - 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 }) {
export function SoundEffectsProvider({
children,
}: {
children: React.ReactNode;
}) {
useEffect(() => {
preloadAllSounds();
}, []);
@@ -20,10 +24,10 @@ export function SoundEffectsProvider({ children }: { children: React.ReactNode }
const handler = (e: MouseEvent) => {
if (e.button !== 0) return; // left click only
if (!(e.target instanceof Element)) return;
if (isClickable(e.target)) playSound("click");
if (isClickable(e.target)) playSound('click');
};
document.addEventListener("click", handler);
return () => document.removeEventListener("click", handler);
document.addEventListener('click', handler);
return () => document.removeEventListener('click', handler);
}, []);
useEffect(() => {
@@ -32,30 +36,30 @@ export function SoundEffectsProvider({ children }: { children: React.ReactNode }
if (isPureModifier(e.key)) return;
if (isTextInputTarget(e.target)) return;
if (e.key === "Escape" || e.key === "Enter" || e.key === "Tab") {
playSound("key-action");
if (e.key === 'Escape' || e.key === 'Enter' || e.key === 'Tab') {
playSound('key-action');
} else {
playSound("key-tap");
playSound('key-tap');
}
};
window.addEventListener("keydown", handler);
return () => window.removeEventListener("keydown", handler);
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",
'button',
'link',
'menuitem',
'menuitemcheckbox',
'menuitemradio',
'tab',
'switch',
'checkbox',
'radio',
'option',
]);
/**
@@ -68,33 +72,35 @@ function isClickable(target: Element): boolean {
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 (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;
if (window.getComputedStyle(el).cursor === 'pointer') return true;
}
return false;
}
function isPureModifier(key: string): boolean {
return key === "Shift" || key === "Control" || key === "Meta" || key === "Alt";
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") {
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"
type === '' ||
type === 'text' ||
type === 'search' ||
type === 'email' ||
type === 'url' ||
type === 'password' ||
type === 'tel' ||
type === 'number'
);
}
if (target.isContentEditable) return true;
+12 -12
View File
@@ -7,18 +7,18 @@
* as more are added.
*/
import clickUrl from "../../../assets/sounds/click.mp3";
import { soundEffects, type PlayOptions } from "./engine";
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";
| '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>> = {
@@ -28,15 +28,15 @@ const REGISTERED: Partial<Record<SoundName, string>> = {
/** 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 },
'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";
const FALLBACK: SoundName = 'click';
let preloaded = false;
@@ -1,6 +1,6 @@
import { useCallback } from "react";
import { playSound, type SoundName } from "./sounds";
import type { PlayOptions } from "./engine";
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
+7 -7
View File
@@ -1,23 +1,23 @@
import { removeDuplicates } from "@/lib/utils";
import { removeDuplicates } from '@/lib/utils';
const HUMAN_PREFIX = "human:";
const NETWORK_PREFIX = "network:";
const HUMAN_PREFIX = 'human:';
const NETWORK_PREFIX = 'network:';
export type StreamVisibility =
| { mode: "network" }
| { mode: "custom"; humanIds: string[] };
| { mode: 'network' }
| { mode: 'custom'; humanIds: string[] };
export function parseVisibleTo(
visibleTo: string[],
networkId: string,
): StreamVisibility {
if (visibleTo.includes(`${NETWORK_PREFIX}${networkId}`)) {
return { mode: "network" };
return { mode: 'network' };
}
const humanIds = visibleTo
.filter((v) => v.startsWith(HUMAN_PREFIX))
.map((v) => v.slice(HUMAN_PREFIX.length));
return { mode: "custom", humanIds };
return { mode: 'custom', humanIds };
}
export function buildNetworkVisibility(networkId: string): string[] {
+1 -1
View File
@@ -10,7 +10,7 @@ export function formatDistanceToNow(isoString: string): string {
(Date.now() - new Date(isoString).getTime()) / 1000,
);
if (seconds < 5) return "just now";
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`;
+5 -5
View File
@@ -1,12 +1,12 @@
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
import { clsx, type ClassValue } from 'clsx';
import { twMerge } from 'tailwind-merge';
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
return twMerge(clsx(inputs));
}
export function getInitials(email: string): string {
const prefix = email.split("@")[0] ?? "";
const prefix = email.split('@')[0] ?? '';
return prefix.slice(0, 2).toUpperCase();
}