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
+18 -20
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,42 +18,42 @@ 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';
}
}
function normalizeMessage(message: string): string {
return message.replace(/^Error:\s*/, "").trim();
return message.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>;
@@ -75,16 +75,14 @@ export function installErrorSinks(sinks: {
/** 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 ?? {});
if (appEnv === 'dev') {
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 ?? {});
console.error('[error]', err, context ?? {});
captureSink?.(err, context);
}
+38 -38
View File
@@ -21,15 +21,15 @@ import {
type SnapshotOptions,
type Unsubscribe,
type QueryFieldFilterConstraint,
} from "firebase/firestore";
import { firestoreDb } from "@/firebase";
import { isContainerType, ParticleSchema } 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";
} from '@/api/types';
// --- Converter ---
@@ -37,7 +37,7 @@ 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;
'deleted_at' in particle ? particle.deleted_at : undefined;
return {
...rest,
created_at: Timestamp.fromDate(created_at),
@@ -50,12 +50,12 @@ 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,
@@ -81,7 +81,7 @@ const particleConverter: FirestoreDataConverter<Particle> = {
raw.huddle_active_participants ?? undefined,
status: raw.status ?? undefined,
});
case "folder":
case 'folder':
return ParticleSchema.parse({
id: snap.id,
type: raw.type,
@@ -93,15 +93,15 @@ const particleConverter: FirestoreDataConverter<Particle> = {
: 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(),
@@ -165,17 +165,17 @@ 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",
orderByField = 'created_at',
orderDirection = 'asc',
}: GetParticleChildrenOptions = {
orderByField: "created_at",
orderDirection: "asc",
orderByField: 'created_at',
orderDirection: 'asc',
},
): Promise<Particle[]> {
const q = query(
@@ -191,7 +191,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;
@@ -205,8 +205,8 @@ export function subscribeToParticleChildren(
onData,
onError,
visibilityScopes = [],
orderByField = "created_at",
orderDirection = "desc",
orderByField = 'created_at',
orderDirection = 'desc',
onAdded,
onRemoved,
whereFilter,
@@ -218,7 +218,7 @@ export function subscribeToParticleChildren(
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);
@@ -234,8 +234,8 @@ 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)
if (change.type === 'added' && onAdded) onAdded(change.doc.data());
if (change.type === 'removed' && onRemoved)
onRemoved(change.doc.data(), updatedChildren);
}
}
@@ -251,7 +251,7 @@ export function subscribeToLatestChild(
): Unsubscribe {
const q = query(
typedCollection(collectionPath),
orderBy("created_at", "desc"),
orderBy('created_at', 'desc'),
limit(1),
);
return onSnapshot(
@@ -279,7 +279,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(),
@@ -292,22 +292,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;
@@ -340,8 +340,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(),
});
}
@@ -383,7 +383,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() });
@@ -426,7 +426,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(
@@ -441,9 +441,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. */
+10 -10
View File
@@ -1,6 +1,6 @@
import { createNavigationContainerRef } from "@react-navigation/native";
import type { Notification } from "expo-notifications";
import { logError } from "@/lib/errors";
import { createNavigationContainerRef } from '@react-navigation/native';
import type { Notification } from 'expo-notifications';
import { logError } from '@/lib/errors';
// Shared ref so non-component code (notification handlers, deep links) can
// drive navigation without prop-drilling. Typed via the global
@@ -10,7 +10,7 @@ export const navigationRef = createNavigationContainerRef();
// Shape the worker (go/internal/human/pushnotify/notifier.go::buildMessages)
// puts in `Notifications.notification.request.content.data`.
type ParticleCreatedData = {
kind: "particle_created";
kind: 'particle_created';
network_id: string;
stream_id: string;
particle_id: string;
@@ -20,11 +20,11 @@ type ParticleCreatedData = {
function isParticleCreatedData(data: unknown): data is ParticleCreatedData {
return (
typeof data === "object" &&
typeof data === 'object' &&
data !== null &&
(data as { kind?: unknown }).kind === "particle_created" &&
typeof (data as { network_id?: unknown }).network_id === "string" &&
typeof (data as { stream_id?: unknown }).stream_id === "string"
(data as { kind?: unknown }).kind === 'particle_created' &&
typeof (data as { network_id?: unknown }).network_id === 'string' &&
typeof (data as { stream_id?: unknown }).stream_id === 'string'
);
}
@@ -48,7 +48,7 @@ export function routeNotificationTap(notification: Notification): void {
}
navigateToStream(data);
} catch (err) {
logError(err, { scope: "push.route" });
logError(err, { scope: 'push.route' });
}
}
@@ -66,7 +66,7 @@ export function flushPendingNavigation(): void {
}
function navigateToStream(data: ParticleCreatedData): void {
navigationRef.navigate("StreamView", {
navigationRef.navigate('StreamView', {
networkId: data.network_id,
streamId: data.stream_id,
});
+4 -4
View File
@@ -20,7 +20,7 @@ export function particlePath(
networkId: string,
segments: string[] = [],
): ParticlePath {
return `/${[networkId, ...segments].join("/")}` as ParticlePath;
return `/${[networkId, ...segments].join('/')}` as ParticlePath;
}
/**
@@ -30,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) };
}
@@ -49,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('/');
}
/**
+18 -18
View File
@@ -1,13 +1,13 @@
import Constants from "expo-constants";
import * as Device from "expo-device";
import * as Notifications from "expo-notifications";
import * as SecureStore from "expo-secure-store";
import { Platform } from "react-native";
import { apiClient } from "@/api/client";
import { logError } from "@/lib/errors";
import { routeNotificationTap } from "@/lib/notification-routing";
import Constants from 'expo-constants';
import * as Device from 'expo-device';
import * as Notifications from 'expo-notifications';
import * as SecureStore from 'expo-secure-store';
import { Platform } from 'react-native';
import { apiClient } from '@/api/client';
import { logError } from '@/lib/errors';
import { routeNotificationTap } from '@/lib/notification-routing';
const STORED_TOKEN_KEY = "expo_push_token";
const STORED_TOKEN_KEY = 'expo_push_token';
let configured = false;
let tokenListenerSubscription: Notifications.Subscription | null = null;
@@ -76,18 +76,18 @@ async function acquirePushToken(): Promise<string | null> {
const existing = await Notifications.getPermissionsAsync();
let status = existing.status;
if (status !== "granted") {
if (status !== 'granted') {
const requested = await Notifications.requestPermissionsAsync();
status = requested.status;
}
if (status !== "granted") return null;
if (status !== 'granted') return null;
const projectId =
Constants.expoConfig?.extra?.eas?.projectId ??
Constants.easConfig?.projectId;
if (!projectId) {
logError(new Error("EAS projectId missing — cannot fetch push token"), {
scope: "push.acquire",
logError(new Error('EAS projectId missing — cannot fetch push token'), {
scope: 'push.acquire',
});
return null;
}
@@ -108,7 +108,7 @@ async function setStoredToken(token: string): Promise<void> {
try {
await SecureStore.setItemAsync(STORED_TOKEN_KEY, token);
} catch (err) {
logError(err, { scope: "push.store" });
logError(err, { scope: 'push.store' });
}
}
@@ -133,8 +133,8 @@ export async function syncPushToken(token?: string | null): Promise<void> {
const stored = await getStoredToken();
if (stored === next) return;
const platform = Platform.OS === "ios" ? "ios" : "android";
const appVersion = Constants.expoConfig?.version ?? "";
const platform = Platform.OS === 'ios' ? 'ios' : 'android';
const appVersion = Constants.expoConfig?.version ?? '';
await apiClient.registerPushToken({
token: next,
@@ -143,7 +143,7 @@ export async function syncPushToken(token?: string | null): Promise<void> {
});
await setStoredToken(next);
} catch (err) {
logError(err, { scope: "push.sync" });
logError(err, { scope: 'push.sync' });
}
}
@@ -158,7 +158,7 @@ export async function unregisterPushToken(): Promise<void> {
try {
await apiClient.unregisterPushToken(stored);
} catch (err) {
logError(err, { scope: "push.unregister" });
logError(err, { scope: 'push.unregister' });
}
}
} finally {
+25 -25
View File
@@ -7,13 +7,13 @@
* React Native ships a WebSocket polyfill, so this code runs unchanged.
*/
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;
@@ -21,7 +21,7 @@ export interface ChannelMessage {
}
interface ServerMessage {
type: "subscribed" | "join" | "leave" | "message" | "error";
type: 'subscribed' | 'join' | 'leave' | 'message' | 'error';
channel?: string;
humanId?: string;
presence?: string[];
@@ -29,7 +29,7 @@ interface ServerMessage {
message?: string;
}
type ChannelEventType = "subscribed" | "join" | "leave" | "message";
type ChannelEventType = 'subscribed' | 'join' | 'leave' | 'message';
type ChannelEventCallback = (msg: ServerMessage) => void;
interface PusherClientConfig {
@@ -44,7 +44,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>();
private listeners = new Map<
@@ -73,20 +73,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();
@@ -101,7 +101,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) => {
@@ -114,21 +114,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(
@@ -181,19 +181,19 @@ export class PusherClient {
}
private handleMessage(data: string): void {
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;
}
@@ -210,19 +210,19 @@ export class PusherClient {
try {
cb(msg);
} catch (err) {
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');
const jitter = Math.random() * 0.5 + 0.75;
const delay = Math.min(this.reconnectDelay * jitter, MAX_RECONNECT_DELAY);
@@ -264,7 +264,7 @@ export class PusherClient {
this.stopPing();
this.pingTimer = setInterval(() => {
if (this.ws?.readyState === WebSocket.OPEN) {
this.ws.send("ping");
this.ws.send('ping');
}
}, PING_INTERVAL);
}
+18 -20
View File
@@ -2,39 +2,37 @@ import {
createContext,
useContext,
useEffect,
useRef,
useMemo,
useState,
type ReactNode,
} from "react";
import { PusherClient, type ConnectionState } from "./pusher-client";
import { useAuthStore } from "@/stores/auth-store";
import { appConfig } from "@/config/env";
} from 'react';
import { PusherClient, type ConnectionState } from './pusher-client';
import { useAuthStore } from '@/stores/auth-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 = useAuthStore((s) => s.token);
const clientRef = useRef<PusherClient | null>(null);
const [connectionState, setConnectionState] =
useState<ConnectionState>("disconnected");
useState<ConnectionState>('disconnected');
useEffect(() => {
const client = useMemo(() => {
if (!token) {
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: () => useAuthStore.getState().token,
});
}, [token]);
clientRef.current = client;
useEffect(() => {
if (!client) {
return;
}
const unsubscribeState = client.onStateChange((state) => {
setConnectionState(state);
@@ -45,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>
+7 -11
View File
@@ -1,13 +1,9 @@
import {
MutationCache,
QueryCache,
QueryClient,
} from "@tanstack/react-query";
import { toast } from "sonner-native";
import { ApiError, logError, reportError, toUserMessage } from "@/lib/errors";
import { useAuthStore } from "@/stores/auth-store";
import { MutationCache, QueryCache, QueryClient } from '@tanstack/react-query';
import { toast } from 'sonner-native';
import { ApiError, logError, reportError, toUserMessage } from '@/lib/errors';
import { useAuthStore } from '@/stores/auth-store';
declare module "@tanstack/react-query" {
declare module '@tanstack/react-query' {
interface Register {
queryMeta: { toastOnError?: boolean };
mutationMeta: { suppressToast?: boolean };
@@ -48,7 +44,7 @@ export function createQueryClient(): QueryClient {
queryCache: new QueryCache({
onError: (err, query) => {
handleUnauthorized(err);
logError(err, { scope: "query", queryKey: query.queryKey });
logError(err, { scope: 'query', queryKey: query.queryKey });
if (query.meta?.toastOnError) {
toast.error(toUserMessage(err));
}
@@ -58,7 +54,7 @@ export function createQueryClient(): QueryClient {
onError: (err, _variables, _context, mutation) => {
handleUnauthorized(err);
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 {
+3 -3
View File
@@ -1,4 +1,4 @@
import { setAudioModeAsync, setIsAudioActiveAsync } from "expo-audio";
import { setAudioModeAsync, setIsAudioActiveAsync } from 'expo-audio';
// Around camera/mic recording we switch the iOS audio session to playAndRecord
// with `doNotMix`, which cleanly interrupts other apps' audio (Spotify, Apple
@@ -10,7 +10,7 @@ export async function acquireRecordingAudioSession() {
await setAudioModeAsync({
allowsRecording: true,
playsInSilentMode: true,
interruptionMode: "doNotMix",
interruptionMode: 'doNotMix',
});
}
@@ -18,7 +18,7 @@ export async function releaseRecordingAudioSession() {
await setAudioModeAsync({
allowsRecording: false,
playsInSilentMode: true,
interruptionMode: "mixWithOthers",
interruptionMode: 'mixWithOthers',
});
await setIsAudioActiveAsync(false);
}
+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[] {
+3 -2
View File
@@ -6,10 +6,11 @@ const MONTH = 2592000;
const YEAR = 31536000;
export function formatDistanceToNow(date: Date | string): string {
const ms = typeof date === "string" ? new Date(date).getTime() : date.getTime();
const ms =
typeof date === 'string' ? new Date(date).getTime() : date.getTime();
const seconds = Math.floor((Date.now() - ms) / 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`;
+20 -27
View File
@@ -2,17 +2,17 @@ import {
FileSystemUploadType,
getInfoAsync,
uploadAsync,
} from "expo-file-system/legacy";
import { apiClient } from "@/api/client";
} from 'expo-file-system/legacy';
import { apiClient } from '@/api/client';
import {
createParticle,
createStreamParticle,
} from "@/lib/firestore-particles";
} from '@/lib/firestore-particles';
import {
particlePath,
toFirestoreChildrenPath,
type ParticlePath,
} from "@/lib/particle-path";
} from '@/lib/particle-path';
interface UploadMediaParticleParams {
networkId: string;
@@ -21,7 +21,7 @@ interface UploadMediaParticleParams {
fileUri: string;
mimeType: string;
durationMs: number;
source: "camera" | "screen";
source: 'camera' | 'screen';
createdByHumanId: string;
}
@@ -44,11 +44,11 @@ export async function uploadMediaParticle({
}: UploadMediaParticleParams): Promise<string> {
const info = await getInfoAsync(fileUri);
if (!info.exists || info.size === undefined) {
throw new Error("Recording file disappeared before upload.");
throw new Error('Recording file disappeared before upload.');
}
const sizeBytes = info.size;
const namePrefix = mimeType.startsWith("audio/") ? "voice" : "video";
const namePrefix = mimeType.startsWith('audio/') ? 'voice' : 'video';
const ext = extensionFromMime(mimeType);
const name = `${namePrefix}-${Date.now()}${ext}`;
@@ -61,15 +61,13 @@ export async function uploadMediaParticle({
});
const uploadResult = await uploadAsync(upload_url, fileUri, {
httpMethod: "PUT",
httpMethod: 'PUT',
uploadType: FileSystemUploadType.BINARY_CONTENT,
headers: upload_headers,
});
if (uploadResult.status < 200 || uploadResult.status >= 300) {
throw new Error(
`Upload to depot failed (HTTP ${uploadResult.status}).`,
);
throw new Error(`Upload to depot failed (HTTP ${uploadResult.status}).`);
}
await apiClient.confirmUpload(object_id);
@@ -77,7 +75,7 @@ export async function uploadMediaParticle({
const collectionPath = toFirestoreChildrenPath(targetPath);
return createParticle(
collectionPath,
"media",
'media',
{
object_id,
mime_type: mimeType,
@@ -102,20 +100,15 @@ export async function createTextParticle({
createdByHumanId,
}: CreateTextParticleParams): Promise<string> {
const collectionPath = toFirestoreChildrenPath(targetPath);
return createParticle(
collectionPath,
"text",
{ content },
createdByHumanId,
);
return createParticle(collectionPath, 'text', { content }, createdByHumanId);
}
function extensionFromMime(mime: string): string {
if (mime === "video/mp4") return ".mp4";
if (mime === "video/quicktime") return ".mov";
if (mime === "audio/mp4") return ".m4a";
if (mime === "audio/webm") return ".webm";
return "";
if (mime === 'video/mp4') return '.mp4';
if (mime === 'video/quicktime') return '.mov';
if (mime === 'audio/mp4') return '.m4a';
if (mime === 'audio/webm') return '.webm';
return '';
}
// Helper kept here so callers can construct a fresh stream's child-path before
@@ -137,13 +130,13 @@ interface CreateStreamWithFirstParticleParams {
createdByHumanId: string;
/** First particle to write into the new stream. Required — empty streams are not useful. */
firstParticle:
| { type: "text"; content: string }
| { type: 'text'; content: string }
| {
type: "media";
type: 'media';
fileUri: string;
mimeType: string;
durationMs: number;
source: "camera" | "screen";
source: 'camera' | 'screen';
};
}
@@ -178,7 +171,7 @@ export async function createStreamWithFirstParticle({
const streamPath = particlePath(networkId, [streamId]);
// 2. The first child goes inside the new stream.
if (firstParticle.type === "text") {
if (firstParticle.type === 'text') {
await createTextParticle({
networkId,
targetPath: streamPath,
+3 -3
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));
}
export function getInitials(email: string): string {
const prefix = email.split("@")[0] ?? "";
const prefix = email.split('@')[0] ?? '';
return prefix.slice(0, 2).toUpperCase();
}