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:
+18
-20
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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('/');
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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[] {
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user