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
+8 -7
View File
@@ -1,17 +1,18 @@
import { useQuery, useMutation } from "@tanstack/react-query";
import { apiClient } from "@/api/client";
import type { BillingCadence } from "@/api/types";
import { useQuery, useMutation, skipToken } from '@tanstack/react-query';
import { apiClient } from '@/api/client';
import type { BillingCadence } from '@/api/types';
export function useNetworkBilling(networkId: string | undefined) {
return useQuery({
queryKey: ["network-billing", networkId],
queryFn: () => apiClient.getNetworkBilling(networkId!),
enabled: !!networkId,
queryKey: ['network-billing', networkId],
queryFn: networkId
? () => apiClient.getNetworkBilling(networkId)
: skipToken,
// Refetch on window focus so the UI catches up after the user returns
// from Stripe Checkout (webhook may land a second or two later).
// FIX: doesn't work with electron
refetchOnWindowFocus: true,
refetchInterval: 10000
refetchInterval: 10000,
});
}
+22 -27
View File
@@ -1,6 +1,6 @@
import { useEffect, useState, useCallback, useRef } from "react";
import { usePusherClient } from "@/lib/pusher-provider";
import type { ChannelMessage } from "@/lib/pusher-client";
import { useEffect, useState, useCallback } from 'react';
import { usePusherClient } from '@/lib/pusher-provider';
import type { ChannelMessage } from '@/lib/pusher-client';
interface UseChannelResult {
/** Current set of humanIds present in the channel */
@@ -23,11 +23,7 @@ export function useChannel(channelId: string | null): UseChannelResult {
const [messages, setMessages] = useState<ChannelMessage[]>([]);
useEffect(() => {
if (!client || !channelId) {
setPresence([]);
setMessages([]);
return;
}
if (!client || !channelId) return;
client.subscribe(channelId);
@@ -36,11 +32,11 @@ export function useChannel(channelId: string | null): UseChannelResult {
};
const onJoin = (msg: { humanId?: string }) => {
if (msg.humanId) {
setPresence((prev) =>
prev.includes(msg.humanId!) ? prev : [...prev, msg.humanId!],
);
}
const humanId = msg.humanId;
if (!humanId) return;
setPresence((prev) =>
prev.includes(humanId) ? prev : [...prev, humanId],
);
};
const onLeave = (msg: { humanId?: string }) => {
@@ -50,25 +46,24 @@ export function useChannel(channelId: string | null): UseChannelResult {
};
const onMessage = (msg: { humanId?: string; payload?: unknown }) => {
if (msg.humanId) {
setMessages((prev) => [
...prev,
{ humanId: msg.humanId!, payload: msg.payload },
]);
}
const humanId = msg.humanId;
if (!humanId) return;
setMessages((prev) => [...prev, { humanId, payload: msg.payload }]);
};
client.on(channelId, "subscribed", onSubscribed);
client.on(channelId, "join", onJoin);
client.on(channelId, "leave", onLeave);
client.on(channelId, "message", onMessage);
client.on(channelId, 'subscribed', onSubscribed);
client.on(channelId, 'join', onJoin);
client.on(channelId, 'leave', onLeave);
client.on(channelId, 'message', onMessage);
return () => {
client.off(channelId, "subscribed", onSubscribed);
client.off(channelId, "join", onJoin);
client.off(channelId, "leave", onLeave);
client.off(channelId, "message", onMessage);
client.off(channelId, 'subscribed', onSubscribed);
client.off(channelId, 'join', onJoin);
client.off(channelId, 'leave', onLeave);
client.off(channelId, 'message', onMessage);
client.unsubscribe(channelId);
setPresence([]);
setMessages([]);
};
}, [client, channelId]);
+24 -9
View File
@@ -1,14 +1,27 @@
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { createParticle, createStreamParticle } from "@/lib/firestore-particles";
import { CONTAINER_TYPES, type NetworkUsage, type ParticleType, type ParticlePropertiesMap } from "@/api/types";
import { parseParticlePath, particlePath, ParticlePath, toFirestoreChildrenPath } from "@/lib/particle-path";
import { QuotaExceededError } from "@/lib/errors";
import { useMutation, useQueryClient } from '@tanstack/react-query';
import {
createParticle,
createStreamParticle,
} from '@/lib/firestore-particles';
import {
CONTAINER_TYPES,
type NetworkUsage,
type ParticleType,
type ParticlePropertiesMap,
} from '@/api/types';
import {
parseParticlePath,
particlePath,
ParticlePath,
toFirestoreChildrenPath,
} from '@/lib/particle-path';
import { QuotaExceededError } from '@/lib/errors';
import {
isUsageExhausted,
networkUsageQueryKey,
useBumpNetworkUsage,
useInvalidateNetworkUsage,
} from "./use-network-usage";
} from './use-network-usage';
interface CreateParticleParams<T extends ParticleType = ParticleType> {
// Path to which the new particle will be added as a child
@@ -32,7 +45,9 @@ export function useCreateParticle() {
// Containers aren't counted server-side, so we block them here
if (!CONTAINER_TYPES.has(params.type)) {
const cached = qc.getQueryData<NetworkUsage>(networkUsageQueryKey(networkId));
const cached = qc.getQueryData<NetworkUsage>(
networkUsageQueryKey(networkId),
);
if (isUsageExhausted(cached)) {
throw new QuotaExceededError(networkId);
}
@@ -58,7 +73,7 @@ export function useCreateParticle() {
type CreateStreamParticleParams = {
networkId: string;
properties: ParticlePropertiesMap["stream"];
properties: ParticlePropertiesMap['stream'];
createdByHumanId: string;
visibleTo?: string[];
};
@@ -74,6 +89,6 @@ export function useCreateStreamParticle() {
params.createdByHumanId,
params.visibleTo,
);
}
},
});
}
+15 -12
View File
@@ -1,14 +1,17 @@
import { useEffect, useMemo } from "react";
import { where } from "firebase/firestore";
import { useLiveParticleChildren } from "@/hooks/use-particle";
import { useAuthStore } from "@/stores/auth-store";
import { particlePath } from "@/lib/particle-path";
import type { Particle, StreamProperties } from "@/api/types";
import { platform } from "@/lib/platform";
import { useEffect, useMemo } from 'react';
import { where } from 'firebase/firestore';
import { useLiveParticleChildren } from '@/hooks/use-particle';
import { useAuthStore } from '@/stores/auth-store';
import { particlePath } from '@/lib/particle-path';
import type { Particle, StreamProperties } from '@/api/types';
import { platform } from '@/lib/platform';
type StreamParticle = Particle & { type: "stream"; properties: StreamProperties };
type StreamParticle = Particle & {
type: 'stream';
properties: StreamProperties;
};
const openStatusFilter = where("status", "==", "open");
const openStatusFilter = where('status', '==', 'open');
/**
* Self-contained hook that syncs the macOS dock badge with the count of
@@ -31,8 +34,8 @@ export function useDockBadge(networkId: string | undefined) {
const path = networkId ? particlePath(networkId, []) : undefined;
const { children } = useLiveParticleChildren(path, {
orderByField: "last_child_created_at",
orderDirection: "desc",
orderByField: 'last_child_created_at',
orderDirection: 'desc',
visibilityScopes,
whereFilter: openStatusFilter,
});
@@ -40,7 +43,7 @@ export function useDockBadge(networkId: string | undefined) {
const unseenCount = useMemo(() => {
if (!userId) return 0;
return children.filter((c): c is StreamParticle => {
if (c.type !== "stream") return false;
if (c.type !== 'stream') return false;
const lastActivity = c.last_child_created_at?.getTime();
if (!lastActivity) return false;
const marker = c.playback_markers?.[userId]?.getTime();
+6 -5
View File
@@ -1,11 +1,12 @@
import { useQuery } from "@tanstack/react-query";
import { apiClient } from "@/api/client";
import { useQuery, skipToken } from '@tanstack/react-query';
import { apiClient } from '@/api/client';
export function useDownloadUrl(objectId?: string) {
return useQuery({
queryKey: ["download-url", objectId],
queryFn: () => apiClient.getParticleDownloadUrl(objectId!),
enabled: !!objectId,
queryKey: ['download-url', objectId],
queryFn: objectId
? () => apiClient.getParticleDownloadUrl(objectId)
: skipToken,
staleTime: 1000 * 60 * 60, // 1 hour — signed URLs valid for 24h
});
}
@@ -1,4 +1,4 @@
import type { SavedDevice } from "@/stores/media-devices-store";
import type { SavedDevice } from '@/stores/media-devices-store';
/**
* Resolves a saved device preference against the currently available
+17 -11
View File
@@ -1,29 +1,35 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { useCallback, useEffect, useRef, useState } from 'react';
interface UseFileInputOptions {
onFilesSelected: (files: File[]) => void;
enabled: boolean;
}
export function useFileInput({ onFilesSelected, enabled }: UseFileInputOptions) {
export function useFileInput({
onFilesSelected,
enabled,
}: UseFileInputOptions) {
const [isDragging, setIsDragging] = useState(false);
const inputRef = useRef<HTMLInputElement | null>(null);
const dragCountRef = useRef(0);
// Stable ref for the callback to avoid re-registering effects
// Latest callback in a ref, so the effects below don't re-register when the
// caller passes a new function each render.
const onFilesRef = useRef(onFilesSelected);
onFilesRef.current = onFilesSelected;
useEffect(() => {
onFilesRef.current = onFilesSelected;
}, [onFilesSelected]);
// Hidden file input element
useEffect(() => {
const input = document.createElement("input");
input.type = "file";
const input = document.createElement('input');
input.type = 'file';
input.multiple = true;
input.style.display = "none";
input.addEventListener("change", () => {
input.style.display = 'none';
input.addEventListener('change', () => {
if (input.files?.length) {
onFilesRef.current(Array.from(input.files));
input.value = "";
input.value = '';
}
});
document.body.appendChild(input);
@@ -50,8 +56,8 @@ export function useFileInput({ onFilesSelected, enabled }: UseFileInputOptions)
}
};
window.addEventListener("paste", handlePaste);
return () => window.removeEventListener("paste", handlePaste);
window.addEventListener('paste', handlePaste);
return () => window.removeEventListener('paste', handlePaste);
}, [enabled]);
// Drag and drop handlers
+6 -7
View File
@@ -1,12 +1,11 @@
import { useQueries, useQuery } from "@tanstack/react-query";
import { extractUrls, type LinkMetadata } from "@/lib/link-metadata";
import { platform } from "@/lib/platform";
import { useQueries, useQuery, skipToken } from '@tanstack/react-query';
import { extractUrls, type LinkMetadata } from '@/lib/link-metadata';
import { platform } from '@/lib/platform';
export function useLinkMetadata(url: string | null) {
return useQuery<LinkMetadata | null>({
queryKey: ["link-metadata", url],
queryFn: () => platform.link.fetchMetadata(url!),
enabled: !!url,
queryKey: ['link-metadata', url],
queryFn: url ? () => platform.link.fetchMetadata(url) : skipToken,
staleTime: Infinity,
gcTime: 30 * 60 * 1000,
retry: 1,
@@ -30,7 +29,7 @@ export function useAllLinkMetadata(text: string): LinkPreviewEntry[] {
const results = useQueries({
queries: urls.map((url) => ({
queryKey: ["link-metadata", url],
queryKey: ['link-metadata', url],
queryFn: () => platform.link.fetchMetadata(url),
staleTime: Infinity,
gcTime: 30 * 60 * 1000,
+34 -25
View File
@@ -1,6 +1,6 @@
import { useCallback, useEffect, useState } from "react";
import { useCallback, useEffect, useState } from 'react';
export type PermissionState = "unknown" | "granted" | "denied";
export type PermissionState = 'unknown' | 'granted' | 'denied';
interface UseMediaDevicesResult {
audioInputs: MediaDeviceInfo[];
@@ -22,24 +22,26 @@ interface UseMediaDevicesResult {
export function useMediaDevices(): UseMediaDevicesResult {
const [devices, setDevices] = useState<MediaDeviceInfo[]>([]);
const [permissionState, setPermissionState] =
useState<PermissionState>("unknown");
useState<PermissionState>('unknown');
const [error, setError] = useState<string | null>(null);
const refresh = useCallback(async () => {
try {
const list = await navigator.mediaDevices.enumerateDevices();
setDevices(list);
// If at least one input device has a non-empty label, permission
// has been granted at some point for that device kind.
const hasLabels = list.some(
(d) =>
(d.kind === "audioinput" || d.kind === "videoinput") &&
d.label.length > 0,
);
if (hasLabels) setPermissionState("granted");
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to list devices");
}
const refresh = useCallback(() => {
return navigator.mediaDevices
.enumerateDevices()
.then((list) => {
setDevices(list);
// If at least one input device has a non-empty label, permission
// has been granted at some point for that device kind.
const hasLabels = list.some(
(d) =>
(d.kind === 'audioinput' || d.kind === 'videoinput') &&
d.label.length > 0,
);
if (hasLabels) setPermissionState('granted');
})
.catch((err) => {
setError(err instanceof Error ? err.message : 'Failed to list devices');
});
}, []);
const requestLabels = useCallback(async () => {
@@ -50,13 +52,13 @@ export function useMediaDevices(): UseMediaDevicesResult {
});
// Immediately stop — we only needed the permission grant.
stream.getTracks().forEach((t) => t.stop());
setPermissionState("granted");
setPermissionState('granted');
setError(null);
await refresh();
} catch (err) {
setPermissionState("denied");
setPermissionState('denied');
setError(
err instanceof Error ? err.message : "Microphone/camera access denied",
err instanceof Error ? err.message : 'Microphone/camera access denied',
);
}
}, [refresh]);
@@ -66,15 +68,22 @@ export function useMediaDevices(): UseMediaDevicesResult {
const handle = () => {
refresh();
};
navigator.mediaDevices.addEventListener("devicechange", handle);
navigator.mediaDevices.addEventListener('devicechange', handle);
return () => {
navigator.mediaDevices.removeEventListener("devicechange", handle);
navigator.mediaDevices.removeEventListener('devicechange', handle);
};
}, [refresh]);
return {
audioInputs: devices.filter((d) => d.kind === "audioinput"),
videoInputs: devices.filter((d) => d.kind === "videoinput"),
// Before permission is granted, enumerateDevices returns placeholder
// entries with an empty deviceId — filter them out so consumers never
// render an empty-value <SelectItem />, which Radix rejects.
audioInputs: devices.filter(
(d) => d.kind === 'audioinput' && d.deviceId !== '',
),
videoInputs: devices.filter(
(d) => d.kind === 'videoinput' && d.deviceId !== '',
),
permissionState,
refresh,
requestLabels,
+14 -10
View File
@@ -1,9 +1,9 @@
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { apiClient } from "@/api/client";
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { apiClient } from '@/api/client';
export function useMyInvitations() {
return useQuery({
queryKey: ["my-invitations"],
queryKey: ['my-invitations'],
queryFn: () => apiClient.listMyInvitations(),
refetchInterval: 10_000,
});
@@ -11,7 +11,7 @@ export function useMyInvitations() {
export function useNetworkInvitations(networkId: string) {
return useQuery({
queryKey: ["network-invitations", networkId],
queryKey: ['network-invitations', networkId],
queryFn: () => apiClient.listNetworkInvitations(networkId),
});
}
@@ -22,8 +22,10 @@ export function useInviteMembers(networkId: string) {
mutationFn: (emailAddresses: string[]) =>
apiClient.addMembers(networkId, { email_addresses: emailAddresses }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["network-invitations", networkId] });
queryClient.invalidateQueries({ queryKey: ["networks"] });
queryClient.invalidateQueries({
queryKey: ['network-invitations', networkId],
});
queryClient.invalidateQueries({ queryKey: ['networks'] });
},
});
}
@@ -34,8 +36,8 @@ export function useAcceptInvitation() {
mutationFn: (networkId: string) =>
apiClient.acceptInvitation({ network_id: networkId }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["my-invitations"] });
queryClient.invalidateQueries({ queryKey: ["networks"] });
queryClient.invalidateQueries({ queryKey: ['my-invitations'] });
queryClient.invalidateQueries({ queryKey: ['networks'] });
},
});
}
@@ -46,7 +48,9 @@ export function useRevokeInvitation(networkId: string) {
mutationFn: (email: string) =>
apiClient.revokeInvitation(networkId, { email }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["network-invitations", networkId] });
queryClient.invalidateQueries({
queryKey: ['network-invitations', networkId],
});
},
});
}
@@ -56,7 +60,7 @@ export function useRemoveMember(networkId: string) {
return useMutation({
mutationFn: (humanId: string) => apiClient.removeMember(networkId, humanId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["networks"] });
queryClient.invalidateQueries({ queryKey: ['networks'] });
},
});
}
+8 -9
View File
@@ -1,21 +1,20 @@
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useCallback } from "react";
import { apiClient } from "@/api/client";
import type { NetworkUsage } from "@/api/types";
import { useQuery, useQueryClient, skipToken } from '@tanstack/react-query';
import { useCallback } from 'react';
import { apiClient } from '@/api/client';
import type { NetworkUsage } from '@/api/types';
export const networkUsageQueryKey = (networkId: string | undefined) =>
["network-usage", networkId] as const;
['network-usage', networkId] as const;
export function useNetworkUsage(networkId: string | undefined) {
return useQuery({
queryKey: networkUsageQueryKey(networkId),
queryFn: () => apiClient.getNetworkUsage(networkId!),
enabled: !!networkId,
queryFn: networkId ? () => apiClient.getNetworkUsage(networkId) : skipToken,
// Refetch whenever a consumer mounts (billing settings, compose indicator)
// so users land on fresh quota state without listener wiring.
refetchOnMount: "always",
refetchOnMount: 'always',
refetchOnWindowFocus: true,
refetchInterval: 10000
refetchInterval: 10000,
});
}
+4 -4
View File
@@ -1,10 +1,10 @@
import { useQuery } from "@tanstack/react-query";
import { apiClient } from "@/api/client";
import { useAuthStore } from "@/stores/auth-store";
import { useQuery } from '@tanstack/react-query';
import { apiClient } from '@/api/client';
import { useAuthStore } from '@/stores/auth-store';
export function useNetworks() {
return useQuery({
queryKey: ["networks"],
queryKey: ['networks'],
queryFn: () => apiClient.listNetworks(),
});
}
+23
View File
@@ -0,0 +1,23 @@
import { useEffect, useState } from 'react';
/**
* Creates an object URL for a Blob/File and revokes it when the source changes
* or the component unmounts. Returns null when given null.
*/
export function useObjectUrl(source: Blob | null): string | null {
const [url, setUrl] = useState<string | null>(null);
useEffect(() => {
if (!source) return;
const objectUrl = URL.createObjectURL(source);
// Intended external-resource publish to sync, not a render cascade.
// eslint-disable-next-line react-hooks/set-state-in-effect
setUrl(objectUrl);
return () => {
URL.revokeObjectURL(objectUrl);
setUrl(null); // in cleanup, not the effect body — so no suppression needed
};
}, [source]);
return source ? url : null;
}
@@ -1,9 +1,13 @@
import { useMemo } from "react";
import type { Particle } from "@/api/types";
import { useLiveParticleChildren } from "@/hooks/use-particle";
import { particlePath, type ParticlePath, parseParticlePath } from "@/lib/particle-path";
import { useMemo } from 'react';
import type { Particle } from '@/api/types';
import { useLiveParticleChildren } from '@/hooks/use-particle';
import {
particlePath,
type ParticlePath,
parseParticlePath,
} from '@/lib/particle-path';
type FileParticle = Extract<Particle, { type: "file" }>;
type FileParticle = Extract<Particle, { type: 'file' }>;
/**
* Fetches file children (attachments) of a particle in a stream.
@@ -21,7 +25,7 @@ export function useParticleAttachments(
const { children, isLoading } = useLiveParticleChildren(childrenPath);
const attachments = useMemo(
() => (children ?? []).filter((c): c is FileParticle => c.type === "file"),
() => (children ?? []).filter((c): c is FileParticle => c.type === 'file'),
[children],
);
+73 -53
View File
@@ -1,19 +1,19 @@
import { useState, useEffect } from "react";
import { useState, useEffect, useRef } from 'react';
import {
subscribeToParticle,
subscribeToParticleChildren,
subscribeToLatestChild,
getParticle,
getParticleChildren,
} from "@/lib/firestore-particles";
import type { Particle } from "@/api/types";
} from '@/lib/firestore-particles';
import type { Particle } from '@/api/types';
import {
type ParticlePath,
toFirestoreDocPath,
toFirestoreChildrenPath,
} from "@/lib/particle-path";
import { useQuery } from "@tanstack/react-query";
import { QueryFieldFilterConstraint } from "firebase/firestore";
} from '@/lib/particle-path';
import { useQuery } from '@tanstack/react-query';
import { QueryFieldFilterConstraint } from 'firebase/firestore';
interface UseLiveParticleResult {
particle: Particle | null;
@@ -27,10 +27,6 @@ export function useLiveParticle(path: ParticlePath): UseLiveParticleResult {
const [error, setError] = useState<Error | null>(null);
useEffect(() => {
setIsLoading(true);
setError(null);
setParticle(null);
const docPath = toFirestoreDocPath(path);
const unsubscribe = subscribeToParticle(
docPath,
@@ -44,7 +40,12 @@ export function useLiveParticle(path: ParticlePath): UseLiveParticleResult {
},
);
return unsubscribe;
return () => {
unsubscribe();
setIsLoading(true);
setError(null);
setParticle(null);
};
}, [path]);
return { particle, isLoading, error };
@@ -58,7 +59,7 @@ interface UseLiveParticleChildrenResult {
interface UseLiveParticleChildrenParams {
orderByField?: string;
orderDirection?: "asc" | "desc";
orderDirection?: 'asc' | 'desc';
visibilityScopes?: string[];
onAdded?: (child: Particle) => void;
onRemoved?: (child: Particle, updatedChildren: Particle[]) => void;
@@ -70,56 +71,72 @@ interface UseLiveParticleChildrenParams {
export function useLiveParticleChildren(
path: ParticlePath | undefined,
{
orderByField = "created_at",
orderDirection = "desc",
orderByField = 'created_at',
orderDirection = 'desc',
visibilityScopes,
onAdded,
onRemoved,
whereFilter,
limit,
}: UseLiveParticleChildrenParams = {}
}: UseLiveParticleChildrenParams = {},
): UseLiveParticleChildrenResult {
const [children, setChildren] = useState<Particle[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<Error | null>(null);
// Keep the latest add/remove callbacks in refs so changing them doesn't force
// the subscription to re-attach — they're notifications, not query params.
const onAddedRef = useRef(onAdded);
const onRemovedRef = useRef(onRemoved);
useEffect(() => {
if (!path) {
setChildren([]);
setIsLoading(false);
return;
}
onAddedRef.current = onAdded;
onRemovedRef.current = onRemoved;
}, [onAdded, onRemoved]);
setIsLoading(true);
setError(null);
setChildren([]);
useEffect(() => {
if (!path) return;
const collectionPath = toFirestoreChildrenPath(path);
const unsubscribe = subscribeToParticleChildren(
collectionPath,
{
onData: (data) => {
setChildren(data);
setIsLoading(false);
},
onError: (err) => {
console.warn(err);
setError(err);
setIsLoading(false);
},
visibilityScopes,
orderByField,
orderDirection,
onAdded,
onRemoved,
whereFilter,
limit,
}
);
const unsubscribe = subscribeToParticleChildren(collectionPath, {
onData: (data) => {
setChildren(data);
setIsLoading(false);
},
onError: (err) => {
console.warn(err);
setError(err);
setIsLoading(false);
},
visibilityScopes,
orderByField,
orderDirection,
onAdded: (child: Particle) => onAddedRef.current?.(child),
onRemoved: (child: Particle, updatedChildren: Particle[]) =>
onRemovedRef.current?.(child, updatedChildren),
whereFilter,
limit,
});
return unsubscribe;
}, [path, whereFilter, limit]);
return () => {
unsubscribe();
setChildren([]);
setError(null);
setIsLoading(true);
};
}, [
path,
whereFilter,
limit,
orderByField,
orderDirection,
visibilityScopes,
]);
// No path: nothing to load, so report an empty non-loading state.
if (!path) {
return { children: [], isLoading: false, error: null };
}
return { children, isLoading, error };
}
@@ -129,14 +146,13 @@ interface UseLiveLatestChildResult {
isLoading: boolean;
}
export function useLiveLatestChild(path: ParticlePath): UseLiveLatestChildResult {
export function useLiveLatestChild(
path: ParticlePath,
): UseLiveLatestChildResult {
const [latestChild, setLatestChild] = useState<Particle | null>(null);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
setIsLoading(true);
setLatestChild(null);
const unsubscribe = subscribeToLatestChild(
toFirestoreChildrenPath(path),
(data) => {
@@ -148,7 +164,11 @@ export function useLiveLatestChild(path: ParticlePath): UseLiveLatestChildResult
},
);
return unsubscribe;
return () => {
unsubscribe();
setIsLoading(true);
setLatestChild(null);
};
}, [path]);
return { latestChild, isLoading };
@@ -156,7 +176,7 @@ export function useLiveLatestChild(path: ParticlePath): UseLiveLatestChildResult
export function useParticle(path?: ParticlePath) {
return useQuery({
queryKey: ["particle", path],
queryKey: ['particle', path],
queryFn: async () => {
if (!path) return null;
const docPath = toFirestoreDocPath(path);
@@ -169,7 +189,7 @@ export function useParticle(path?: ParticlePath) {
export function useParticleChildren(path?: ParticlePath) {
return useQuery({
queryKey: ["particle-children", path],
queryKey: ['particle-children', path],
queryFn: async () => {
if (!path) return [];
const collectionPath = toFirestoreChildrenPath(path);
+20 -16
View File
@@ -1,9 +1,11 @@
import { useEffect, useRef, useState, type RefObject } from "react";
import type { MediaParticleHandle } from "@/features/particles/media-particle-view";
import { useSuspendPlayback } from "@/hooks/use-suspend-playback";
import { selectIsPaused, usePlaybackPauseStore } from "@/stores/playback-pause-store";
import { isTypingTarget } from "@/lib/keyboard";
import { set } from "zod";
import { useEffect, useRef, useState, type RefObject } from 'react';
import type { MediaParticleHandle } from '@/features/particles/media-particle-view';
import { useSuspendPlayback } from '@/hooks/use-suspend-playback';
import {
selectIsPaused,
usePlaybackPauseStore,
} from '@/stores/playback-pause-store';
import { isTypingTarget } from '@/lib/keyboard';
const SPACE_TAP_THRESHOLD_MS = 250;
@@ -20,12 +22,14 @@ interface UsePlaybackKeysResult {
* manages its own suspender via useSuspendPlayback); other keys bail when
* playback is already paused for an external reason.
*/
export function usePlaybackKeys({ mediaRef }: UsePlaybackKeysOptions): UsePlaybackKeysResult {
export function usePlaybackKeys({
mediaRef,
}: UsePlaybackKeysOptions): UsePlaybackKeysResult {
const [spaceHeld, setSpaceHeld] = useState(false);
const [fastPlayback, setFastPlayback] = useState(false);
const spaceStartRef = useRef(0);
useSuspendPlayback(spaceHeld, "hold-space");
useSuspendPlayback(spaceHeld, 'hold-space');
useEffect(() => {
const isExternallyPaused = () =>
@@ -34,7 +38,7 @@ export function usePlaybackKeys({ mediaRef }: UsePlaybackKeysOptions): UsePlayba
const onKeyDown = (e: KeyboardEvent) => {
if (isTypingTarget(e)) return;
if (e.key === " ") {
if (e.key === ' ') {
e.preventDefault();
if (!e.repeat) {
if (spaceHeld) {
@@ -50,7 +54,7 @@ export function usePlaybackKeys({ mediaRef }: UsePlaybackKeysOptions): UsePlayba
if (isExternallyPaused()) return;
if (e.key === "Shift" && !e.repeat) {
if (e.key === 'Shift' && !e.repeat) {
mediaRef.current?.setPlaybackRate(1.5);
setFastPlayback(true);
}
@@ -59,7 +63,7 @@ export function usePlaybackKeys({ mediaRef }: UsePlaybackKeysOptions): UsePlayba
const onKeyUp = (e: KeyboardEvent) => {
if (isTypingTarget(e)) return;
if (e.key === " ") {
if (e.key === ' ') {
e.preventDefault();
// keep space-held if it was a quick tap, to allow for space-to-toggle behavior
@@ -74,17 +78,17 @@ export function usePlaybackKeys({ mediaRef }: UsePlaybackKeysOptions): UsePlayba
if (isExternallyPaused()) return;
if (e.key === "Shift") {
if (e.key === 'Shift') {
mediaRef.current?.setPlaybackRate(1);
setFastPlayback(false);
}
};
window.addEventListener("keydown", onKeyDown);
window.addEventListener("keyup", onKeyUp);
window.addEventListener('keydown', onKeyDown);
window.addEventListener('keyup', onKeyUp);
return () => {
window.removeEventListener("keydown", onKeyDown);
window.removeEventListener("keyup", onKeyUp);
window.removeEventListener('keydown', onKeyDown);
window.removeEventListener('keyup', onKeyUp);
};
}, [mediaRef, spaceHeld]);
@@ -1,8 +1,8 @@
import { useEffect } from "react";
import { preload } from "react-dom";
import { useQueryClient } from "@tanstack/react-query";
import type { Particle } from "@/api/types";
import { apiClient } from "@/api/client";
import { useEffect } from 'react';
import { preload } from 'react-dom';
import { useQueryClient } from '@tanstack/react-query';
import type { Particle } from '@/api/types';
import { apiClient } from '@/api/client';
// TODO: verify that caching is actually working by slowing down our network to simulate
/**
@@ -20,7 +20,7 @@ export function usePrefetchAdjacentMedia(
.filter((i) => i >= 0 && i < children.length)
.map((i) => children[i])
.filter(
(p): p is Extract<Particle, { type: "media" }> => p.type === "media",
(p): p is Extract<Particle, { type: 'media' }> => p.type === 'media',
);
for (const particle of mediaParticles) {
@@ -28,17 +28,17 @@ export function usePrefetchAdjacentMedia(
queryClient
.prefetchQuery({
queryKey: ["download-url", objectId],
queryKey: ['download-url', objectId],
queryFn: () => apiClient.getParticleDownloadUrl(objectId),
staleTime: 1000 * 60 * 60,
})
.then(() => {
const url = queryClient.getQueryData<string>([
"download-url",
'download-url',
objectId,
]);
if (url) {
preload(url, { as: "fetch", crossOrigin: "anonymous" });
preload(url, { as: 'fetch', crossOrigin: 'anonymous' });
}
});
}
@@ -1,5 +1,5 @@
import { useMemo } from "react";
import type { Human, Particle } from "@/api/types";
import { useMemo } from 'react';
import type { Human, Particle } from '@/api/types';
export interface HumanPresence {
humanId: string;
@@ -18,7 +18,8 @@ export function usePresencePositions(
): Map<number, HumanPresence[]> {
return useMemo(() => {
const result = new Map<number, HumanPresence[]>();
if (!playbackMarkers || !networkHumans || children.length === 0) return result;
if (!playbackMarkers || !networkHumans || children.length === 0)
return result;
for (const [userId, markerTimestamp] of Object.entries(playbackMarkers)) {
if (userId === currentUserId) continue;
@@ -37,7 +38,11 @@ export function usePresencePositions(
if (segmentIndex === -1) continue;
const existing = result.get(segmentIndex);
const presence: HumanPresence = { humanId: human.id, email: human.email, emailPrefix: human.email_prefix };
const presence: HumanPresence = {
humanId: human.id,
email: human.email,
emailPrefix: human.email_prefix,
};
if (existing) {
existing.push(presence);
} else {
+8 -5
View File
@@ -1,13 +1,16 @@
import { useState, useCallback } from "react";
import { useState, useCallback } from 'react';
export type RecordingMode = "video" | "audio";
export type RecordingMode = 'video' | 'audio';
const KEY = "llink:recording-mode";
const KEY = 'llink:recording-mode';
export function useRecordingMode(): [RecordingMode, (mode: RecordingMode) => void] {
export function useRecordingMode(): [
RecordingMode,
(mode: RecordingMode) => void,
] {
const [mode, setModeState] = useState<RecordingMode>(() => {
const stored = localStorage.getItem(KEY);
return stored === "audio" ? "audio" : "video";
return stored === 'audio' ? 'audio' : 'video';
});
const setMode = useCallback((m: RecordingMode) => {
+27 -18
View File
@@ -1,7 +1,10 @@
import { useEffect } from "react";
import { REACTION_EMOJIS } from "@/api/types";
import { selectIsPaused, usePlaybackPauseStore } from "@/stores/playback-pause-store";
import { isTypingTarget } from "@/lib/keyboard";
import { useEffect } from 'react';
import { REACTION_EMOJIS } from '@/api/types';
import {
selectIsPaused,
usePlaybackPauseStore,
} from '@/stores/playback-pause-store';
import { isTypingTarget } from '@/lib/keyboard';
interface UseStreamActionKeysOptions {
onToggleReaction: (emoji: string) => void;
@@ -28,36 +31,42 @@ export function useStreamActionKeys({
if (selectIsPaused(usePlaybackPauseStore.getState())) return;
switch (e.key) {
case "h":
case 'h':
e.preventDefault();
onOpenHuddle();
break;
case "v":
case 'v':
e.preventDefault();
onToggleRecordingMode();
break;
case "r":
case 'r':
e.preventDefault();
onOpenTextReaction();
break;
case "?":
case '?':
e.preventDefault();
onToggleKeybindings();
break;
case "1":
case "2":
case "3":
case "4":
case "5":
case "6":
case "7":
case '1':
case '2':
case '3':
case '4':
case '5':
case '6':
case '7':
e.preventDefault();
onToggleReaction(REACTION_EMOJIS[parseInt(e.key) - 1]);
break;
}
};
window.addEventListener("keydown", onKeyDown);
return () => window.removeEventListener("keydown", onKeyDown);
}, [onToggleReaction, onOpenHuddle, onToggleRecordingMode, onToggleKeybindings, onOpenTextReaction]);
window.addEventListener('keydown', onKeyDown);
return () => window.removeEventListener('keydown', onKeyDown);
}, [
onToggleReaction,
onOpenHuddle,
onToggleRecordingMode,
onToggleKeybindings,
onOpenTextReaction,
]);
}
+56 -47
View File
@@ -1,12 +1,12 @@
import { useEffect, useRef } from "react";
import beepSound from "../../assets/sounds/beep.wav";
import type { Network, Particle, StreamProperties } from "@/api/types";
import { apiClient } from "@/api/client";
import { useAuthStore } from "@/stores/auth-store";
import { useAutoplayStore } from "@/stores/autoplay-store";
import { resolveHumanDisplay } from "@/lib/humans";
import { logError } from "@/lib/errors";
import { platform } from "@/lib/platform";
import { useEffect, useEffectEvent, useRef } from 'react';
import beepSound from '../../assets/sounds/beep.wav';
import type { Network, Particle, StreamProperties } from '@/api/types';
import { apiClient } from '@/api/client';
import { useAuthStore } from '@/stores/auth-store';
import { useAutoplayStore } from '@/stores/autoplay-store';
import { resolveHumanDisplay } from '@/lib/humans';
import { logError } from '@/lib/errors';
import { platform } from '@/lib/platform';
/**
* Triggers autoplay when a stream's latest child changes to a new media particle.
@@ -15,13 +15,56 @@ import { platform } from "@/lib/platform";
*/
export function useStreamAutoplay(
latestChild: Particle | null,
streamParticle: Particle & { type: "stream"; properties: StreamProperties },
streamParticle: Particle & { type: 'stream'; properties: StreamProperties },
networkId: string,
network: Network | undefined,
) {
const userId = useAuthStore((s) => s.user?.id) ?? "";
const userId = useAuthStore((s) => s.user?.id) ?? '';
const settledIdRef = useRef<string | undefined>(undefined);
// The autoplay action reads the latest userId/network/stream/networkId at the
// moment a new child settles, without making any of them a reactive trigger —
// the only thing that should fire this is a change in the latest child's id.
const onNewLatestChild = useEffectEvent((child: Particle) => {
if (child.created_by_human_id === userId) return;
if (useAutoplayStore.getState().muted) return;
if (child.type === 'text') {
// Browser autoplay policy can block this before user interaction; that's
// fine — the beep is a nice-to-have, not a critical signal.
new Audio(beepSound)
.play()
.catch((err) => logError(err, { scope: 'autoplay.beep' }));
return;
}
if (child.type !== 'media') return;
const { displayName, initials } = resolveHumanDisplay(
child.created_by_human_id,
network?.humans,
);
apiClient
.getParticleDownloadUrl(child.properties.object_id)
.then((downloadUrl) => {
platform.autoplay.play({
particleId: child.id,
streamId: streamParticle.id,
networkId,
downloadUrl,
mimeType: child.properties.mime_type,
durationMs: child.properties.duration_ms,
senderName: displayName,
senderInitials: initials,
});
})
.catch((err) =>
logError(err, { scope: 'autoplay.fetchUrl', particleId: child.id }),
);
});
useEffect(() => {
if (!latestChild) return;
@@ -34,40 +77,6 @@ export function useStreamAutoplay(
if (latestChild.id === settledIdRef.current) return;
settledIdRef.current = latestChild.id;
if (latestChild.created_by_human_id === userId) return;
if (useAutoplayStore.getState().muted) return;
if (latestChild.type === "text") {
// Browser autoplay policy can block this before user interaction; that's
// fine — the beep is a nice-to-have, not a critical signal.
new Audio(beepSound).play().catch((err) =>
logError(err, { scope: "autoplay.beep" }),
);
return;
}
if (latestChild.type !== "media") return;
const particle = latestChild;
const { displayName, initials } = resolveHumanDisplay(
particle.created_by_human_id,
network?.humans,
);
apiClient.getParticleDownloadUrl(particle.properties.object_id).then((downloadUrl) => {
platform.autoplay.play({
particleId: particle.id,
streamId: streamParticle.id,
networkId,
downloadUrl,
mimeType: particle.properties.mime_type,
durationMs: particle.properties.duration_ms,
senderName: displayName,
senderInitials: initials,
});
}).catch((err) =>
logError(err, { scope: "autoplay.fetchUrl", particleId: particle.id }),
);
}, [latestChild?.id]); // eslint-disable-line react-hooks/exhaustive-deps
onNewLatestChild(latestChild);
}, [latestChild]);
}
+25 -21
View File
@@ -1,5 +1,5 @@
import { useEffect, useState } from "react";
import { useMediaSettingsStore } from "@/stores/media-settings-store";
import { useEffect, useState } from 'react';
import { useMediaSettingsStore } from '@/stores/media-settings-store';
interface UseStreamKeyboardNavOptions {
streams: Array<{ id: string }>;
@@ -12,19 +12,23 @@ export function useStreamKeyboardNav({
enabled,
onNavigate,
}: UseStreamKeyboardNavOptions) {
const [selectedIndex, setSelectedIndex] = useState<number | null>(null);
const [selectedIndex, setSelectedIndex] = useState<number | null>(
streams.length > 0 ? 0 : null,
);
const [prevStreamCount, setPrevStreamCount] = useState(streams.length);
const recordingMode = useMediaSettingsStore((s) => s.recordingMode);
const setRecordingMode = useMediaSettingsStore((s) => s.setRecordingMode);
// Initialize selection when streams first load; clear if streams become empty.
// Do NOT reset on every Firestore update — that would scroll the list to the top.
useEffect(() => {
setSelectedIndex((prev) => {
if (streams.length === 0) return null;
if (prev === null) return 0;
return prev;
});
}, [streams.length]);
// Select the first stream once they load and clear when empty — but not on
// every Firestore update, which would scroll the list back to the top.
if (streams.length !== prevStreamCount) {
setPrevStreamCount(streams.length);
if (streams.length === 0) {
setSelectedIndex(null);
} else if (selectedIndex === null) {
setSelectedIndex(0);
}
}
useEffect(() => {
if (!enabled || streams.length === 0) return;
@@ -34,8 +38,8 @@ export function useStreamKeyboardNav({
const target = e.target as HTMLElement;
if (
target.tagName === "INPUT" ||
target.tagName === "TEXTAREA" ||
target.tagName === 'INPUT' ||
target.tagName === 'TEXTAREA' ||
target.isContentEditable
) {
return;
@@ -53,7 +57,7 @@ export function useStreamKeyboardNav({
}
// Enter: navigate to selected
if (e.key === "Enter") {
if (e.key === 'Enter') {
setSelectedIndex((idx) => {
if (idx !== null && idx < streams.length) {
e.preventDefault();
@@ -65,17 +69,17 @@ export function useStreamKeyboardNav({
}
// V: toggle video / audio
if (e.key === "v" || e.key === "V") {
if (e.key === 'v' || e.key === 'V') {
e.preventDefault();
setRecordingMode(recordingMode === "video" ? "audio" : "video");
setRecordingMode(recordingMode === 'video' ? 'audio' : 'video');
return;
}
// Arrow keys: move selection
let delta: number | null = null;
if (e.key === "ArrowDown") delta = 1;
else if (e.key === "ArrowUp") delta = -1;
if (e.key === 'ArrowDown') delta = 1;
else if (e.key === 'ArrowUp') delta = -1;
if (delta !== null) {
e.preventDefault();
@@ -89,8 +93,8 @@ export function useStreamKeyboardNav({
// Use capture phase so arrow keys are intercepted before Radix UI
// components (ToggleGroup, etc.) consume them for their own navigation.
window.addEventListener("keydown", handleKeyDown, true);
return () => window.removeEventListener("keydown", handleKeyDown, true);
window.addEventListener('keydown', handleKeyDown, true);
return () => window.removeEventListener('keydown', handleKeyDown, true);
}, [enabled, streams, onNavigate, recordingMode, setRecordingMode]);
return { selectedIndex };
@@ -1,8 +1,7 @@
import { useEffect, type RefObject } from "react";
import { useNavigate } from "react-router-dom";
import type { MediaParticleHandle } from "@/features/particles/media-particle-view";
import { selectIsPaused, usePlaybackPauseStore } from "@/stores/playback-pause-store";
import { isTypingTarget } from "@/lib/keyboard";
import { useEffect, type RefObject } from 'react';
import { useNavigate } from 'react-router-dom';
import type { MediaParticleHandle } from '@/features/particles/media-particle-view';
import { isTypingTarget } from '@/lib/keyboard';
const SEEK_DELTA_SEC = 5;
@@ -34,32 +33,32 @@ export function useStreamNavigationKeys({
const hasNext = currentIndex >= 0 && currentIndex < childrenLength - 1;
switch (e.key) {
case "ArrowRight":
case 'ArrowRight':
e.preventDefault();
if (!e.shiftKey || !mediaRef.current?.seek(SEEK_DELTA_SEC)) {
if (hasNext) next();
}
break;
case "ArrowDown":
case 'ArrowDown':
e.preventDefault();
if (hasNext) next();
break;
case "ArrowLeft":
case 'ArrowLeft':
e.preventDefault();
if (!e.shiftKey || !mediaRef.current?.seek(-SEEK_DELTA_SEC)) prev();
break;
case "ArrowUp":
case 'ArrowUp':
e.preventDefault();
prev();
break;
case "Escape":
case 'Escape':
e.preventDefault();
navigate(-1);
break;
}
};
window.addEventListener("keydown", onKeyDown);
return () => window.removeEventListener("keydown", onKeyDown);
window.addEventListener('keydown', onKeyDown);
return () => window.removeEventListener('keydown', onKeyDown);
}, [next, prev, currentIndex, childrenLength, mediaRef, navigate]);
}
+27 -22
View File
@@ -1,19 +1,22 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { where, type QueryFieldFilterConstraint } from "firebase/firestore";
import { useLiveParticleChildren } from "@/hooks/use-particle";
import { useAuthStore } from "@/stores/auth-store";
import { parseParticlePath, type ParticlePath } from "@/lib/particle-path";
import type { Particle, StreamProperties } from "@/api/types";
import { useCallback, useMemo, useState } from 'react';
import { where, type QueryFieldFilterConstraint } from 'firebase/firestore';
import { useLiveParticleChildren } from '@/hooks/use-particle';
import { useAuthStore } from '@/stores/auth-store';
import { parseParticlePath, type ParticlePath } from '@/lib/particle-path';
import type { Particle, StreamProperties } from '@/api/types';
export type StreamParticle = Particle & { type: "stream"; properties: StreamProperties };
export type StreamParticle = Particle & {
type: 'stream';
properties: StreamProperties;
};
const CLOSED_INITIAL_PAGE_SIZE = 50;
const CLOSED_PAGE_INCREMENT = 50;
// Stable where-constraint references so the Firestore subscription only
// re-attaches when the tab actually changes, not on every render.
const OPEN_STATUS_FILTER = where("status", "==", "open");
const CLOSED_STATUS_FILTER = where("status", "==", "closed");
const OPEN_STATUS_FILTER = where('status', '==', 'open');
const CLOSED_STATUS_FILTER = where('status', '==', 'closed');
function useVisibilityScopes(userId?: string, networkId?: string) {
return useMemo(() => {
@@ -30,7 +33,7 @@ interface UseStreamParticlesOptions {
* by active work — full realtime coverage is needed for autoplay/huddles).
* Closed streams are paginated via `loadMore`.
*/
status: "open" | "closed";
status: 'open' | 'closed';
}
interface UseStreamParticlesResult {
@@ -52,38 +55,40 @@ export function useStreamParticles(
const visibilityScopes = useVisibilityScopes(user?.id, networkId);
const [closedLimit, setClosedLimit] = useState(CLOSED_INITIAL_PAGE_SIZE);
const [prevStatus, setPrevStatus] = useState(status);
// Every time the user switches back to the closed tab, start with a fresh
// window. Avoids an ever-growing subscription across a long session.
useEffect(() => {
if (status === "closed") {
// Switching back to the closed tab starts a fresh window, avoiding an
// ever-growing subscription across a long session.
if (status !== prevStatus) {
setPrevStatus(status);
if (status === 'closed') {
setClosedLimit(CLOSED_INITIAL_PAGE_SIZE);
}
}, [status]);
}
const whereFilter: QueryFieldFilterConstraint =
status === "open" ? OPEN_STATUS_FILTER : CLOSED_STATUS_FILTER;
const limit = status === "closed" ? closedLimit : undefined;
status === 'open' ? OPEN_STATUS_FILTER : CLOSED_STATUS_FILTER;
const limit = status === 'closed' ? closedLimit : undefined;
const { children, isLoading } = useLiveParticleChildren(path, {
orderByField: "last_child_created_at",
orderDirection: "desc",
orderByField: 'last_child_created_at',
orderDirection: 'desc',
visibilityScopes,
whereFilter,
limit,
});
const streams = useMemo(
() => children.filter((c): c is StreamParticle => c.type === "stream"),
() => children.filter((c): c is StreamParticle => c.type === 'stream'),
[children],
);
// Heuristic: if we got back as many items as we asked for, assume there
// might be more. Clicking load-more when there are no more is a no-op.
const canLoadMore = status === "closed" && streams.length >= closedLimit;
const canLoadMore = status === 'closed' && streams.length >= closedLimit;
const loadMore = useCallback(() => {
if (status !== "closed") return;
if (status !== 'closed') return;
setClosedLimit((prev) => prev + CLOSED_PAGE_INCREMENT);
}, [status]);
+126 -67
View File
@@ -1,13 +1,20 @@
import { useCallback, useEffect, useEffectEvent, useMemo, useReducer, useRef } from "react";
import { useAuthStore } from "@/stores/auth-store";
import type { Particle } from "@/api/types";
import { useLiveParticleChildren } from "@/hooks/use-particle";
import { toFirestoreDocPath, type ParticlePath } from "@/lib/particle-path";
import { updateStreamPlaybackMarker } from "@/lib/firestore-particles";
import {
useCallback,
useEffect,
useEffectEvent,
useMemo,
useReducer,
useRef,
} from 'react';
import { useAuthStore } from '@/stores/auth-store';
import type { Particle } from '@/api/types';
import { useLiveParticleChildren } from '@/hooks/use-particle';
import { toFirestoreDocPath, type ParticlePath } from '@/lib/particle-path';
import { updateStreamPlaybackMarker } from '@/lib/firestore-particles';
// --- Playback reducer (ID-based) ---
type PlaybackStatus = "idle" | "playing" | "ended";
type PlaybackStatus = 'idle' | 'playing' | 'ended';
interface PlaybackState {
currentParticleId: string | null;
@@ -16,45 +23,60 @@ interface PlaybackState {
}
type PlaybackAction =
| { type: "INIT"; particleId: string }
| { type: "SET_PARTICLE"; particleId: string }
| { type: "END" }
| { type: "PARTICLE_ADDED"; particleId: string }
| { type: "PARTICLE_REMOVED"; removedParticleId: string; fallbackParticleId: string | null };
| { type: 'INIT'; particleId: string }
| { type: 'SET_PARTICLE'; particleId: string }
| { type: 'END' }
| { type: 'PARTICLE_ADDED'; particleId: string }
| {
type: 'PARTICLE_REMOVED';
removedParticleId: string;
fallbackParticleId: string | null;
};
const initialState: PlaybackState = {
currentParticleId: null,
status: "idle",
status: 'idle',
initialized: false,
};
function playbackReducer(state: PlaybackState, action: PlaybackAction): PlaybackState {
function playbackReducer(
state: PlaybackState,
action: PlaybackAction,
): PlaybackState {
switch (action.type) {
case "INIT":
case 'INIT':
return {
currentParticleId: action.particleId,
status: "playing",
status: 'playing',
initialized: true,
};
case "SET_PARTICLE":
case 'SET_PARTICLE':
return {
...state,
currentParticleId: action.particleId,
status: "playing",
status: 'playing',
};
case "END":
return { ...state, status: "ended" };
case "PARTICLE_ADDED":
if (state.status === "ended") {
return { ...state, currentParticleId: action.particleId, status: "playing" };
case 'END':
return { ...state, status: 'ended' };
case 'PARTICLE_ADDED':
if (state.status === 'ended') {
return {
...state,
currentParticleId: action.particleId,
status: 'playing',
};
}
return state;
case "PARTICLE_REMOVED":
case 'PARTICLE_REMOVED':
if (action.removedParticleId !== state.currentParticleId) return state;
if (action.fallbackParticleId) {
return { ...state, currentParticleId: action.fallbackParticleId, status: "playing" };
return {
...state,
currentParticleId: action.fallbackParticleId,
status: 'playing',
};
}
return { ...state, currentParticleId: null, status: "idle" };
return { ...state, currentParticleId: null, status: 'idle' };
}
}
@@ -77,39 +99,48 @@ interface UseStreamPlaybackResult {
}
export function useStreamPlayback(
streamParticle: Particle & { type: "stream" },
streamParticle: Particle & { type: 'stream' },
path: ParticlePath,
): UseStreamPlaybackResult {
const userId = useAuthStore((s) => s.user?.id);
const [state, dispatch] = useReducer(playbackReducer, initialState);
// Track the stream ID we've initialized for, to reset when navigating between streams
const initializedForRef = useRef<string | null>(null);
// Latest currentIndex for onParticleRemoved, which is passed into
// useLiveParticleChildren. Reading it through a ref keeps the callback stable
// (no re-subscription) and breaks the declaration cycle
// children -> currentIndex -> callback -> children. useEffectEvent can't be
// used here — Effect Events may not be passed to another hook.
const currentIndexRef = useRef(0);
// --- Firestore change callbacks ---
const onParticleAdded = useCallback((particle: Particle) => {
dispatch({ type: "PARTICLE_ADDED", particleId: particle.id });
dispatch({ type: 'PARTICLE_ADDED', particleId: particle.id });
}, []);
const onParticleRemoved = useEffectEvent((removed: Particle, updatedChildren: Particle[]) => {
const fallbackIndex = Math.min(currentIndex, updatedChildren.length - 1);
const fallback = updatedChildren[Math.max(0, fallbackIndex)];
dispatch({
type: "PARTICLE_REMOVED",
removedParticleId: removed.id,
fallbackParticleId: fallback?.id ?? null,
});
});
const { children } = useLiveParticleChildren(
path,
{
orderByField: "created_at",
orderDirection: "asc",
onAdded: onParticleAdded,
onRemoved: onParticleRemoved
}
const onParticleRemoved = useCallback(
(removed: Particle, updatedChildren: Particle[]) => {
const fallbackIndex = Math.min(
currentIndexRef.current,
updatedChildren.length - 1,
);
const fallback = updatedChildren[Math.max(0, fallbackIndex)];
dispatch({
type: 'PARTICLE_REMOVED',
removedParticleId: removed.id,
fallbackParticleId: fallback?.id ?? null,
});
},
[],
);
const { children } = useLiveParticleChildren(path, {
orderByField: 'created_at',
orderDirection: 'asc',
onAdded: onParticleAdded,
onRemoved: onParticleRemoved,
});
// Derive current index and particle from ID
const currentIndex = useMemo(() => {
if (!state.currentParticleId) return -1;
@@ -118,31 +149,40 @@ export function useStreamPlayback(
const currentParticle = currentIndex !== -1 ? children[currentIndex] : null;
// Keep the ref read by onParticleRemoved in sync with the derived index.
useEffect(() => {
currentIndexRef.current = currentIndex;
}, [currentIndex]);
// Fallback init — always sees latest children/state via useEffectEvent
const initFallback = useEffectEvent(() => {
if (state.initialized || children.length === 0) return;
initializedForRef.current = streamParticle.id;
dispatch({ type: "INIT", particleId: children[0].id });
dispatch({ type: 'INIT', particleId: children[0].id });
});
// --- Init logic: runs on every children change until initialized ---
useEffect(() => {
// Reset if we navigated to a different stream
if (initializedForRef.current !== null && initializedForRef.current !== streamParticle.id) {
if (
initializedForRef.current !== null &&
initializedForRef.current !== streamParticle.id
) {
initializedForRef.current = null;
}
// Already initialized for this stream
if (state.initialized && initializedForRef.current === streamParticle.id) return;
if (state.initialized && initializedForRef.current === streamParticle.id)
return;
if (children.length === 0) return;
const playbackPosition = streamParticle.playback_markers?.[userId ?? ""];
const playbackPosition = streamParticle.playback_markers?.[userId ?? ''];
if (!playbackPosition) {
// No marker — start from the beginning
initializedForRef.current = streamParticle.id;
dispatch({ type: "INIT", particleId: children[0].id });
dispatch({ type: 'INIT', particleId: children[0].id });
return;
}
@@ -153,17 +193,23 @@ export function useStreamPlayback(
if (found) {
initializedForRef.current = streamParticle.id;
dispatch({ type: "INIT", particleId: found.id });
dispatch({ type: 'INIT', particleId: found.id });
return;
} else {
initializedForRef.current = streamParticle.id;
dispatch({ type: "INIT", particleId: children[children.length - 1].id });
dispatch({ type: 'INIT', particleId: children[children.length - 1].id });
}
// Marker target not found yet — fall back after timeout
const timeout = setTimeout(initFallback, INIT_FALLBACK_TIMEOUT_MS);
return () => clearTimeout(timeout);
}, [children, streamParticle.id, streamParticle.playback_markers, userId, state.initialized]);
}, [
children,
streamParticle.id,
streamParticle.playback_markers,
userId,
state.initialized,
]);
// --- Persist playback marker (only advance forward, never backwards) ---
const lastPersistedMarkerRef = useRef<Date | null>(null);
@@ -173,47 +219,60 @@ export function useStreamPlayback(
const currentTime = currentParticle.created_at;
const existingMarker =
lastPersistedMarkerRef.current ?? streamParticle.playback_markers?.[userId];
lastPersistedMarkerRef.current ??
streamParticle.playback_markers?.[userId];
// Only update if advancing beyond the current marker
if (existingMarker && currentTime.getTime() <= existingMarker.getTime()) return;
if (existingMarker && currentTime.getTime() <= existingMarker.getTime())
return;
lastPersistedMarkerRef.current = currentTime;
const streamDocPath = toFirestoreDocPath(path);
updateStreamPlaybackMarker(streamDocPath, userId, currentTime);
}, [currentParticle?.id, state.initialized, userId, path]);
}, [
currentParticle,
streamParticle.playback_markers,
state.initialized,
userId,
path,
]);
// --- Navigation callbacks ---
const next = useCallback(() => {
if (currentIndex === -1) return;
if (currentIndex < children.length - 1) {
dispatch({ type: "SET_PARTICLE", particleId: children[currentIndex + 1].id });
dispatch({
type: 'SET_PARTICLE',
particleId: children[currentIndex + 1].id,
});
} else {
dispatch({ type: "END" });
dispatch({ type: 'END' });
}
}, [children, currentIndex]);
const prev = useCallback(() => {
if (currentIndex <= 0) return;
dispatch({ type: "SET_PARTICLE", particleId: children[currentIndex - 1].id });
dispatch({
type: 'SET_PARTICLE',
particleId: children[currentIndex - 1].id,
});
}, [children, currentIndex]);
const goTo = useCallback(
(index: number) => {
if (index >= 0 && index < children.length) {
dispatch({ type: "SET_PARTICLE", particleId: children[index].id });
dispatch({ type: 'SET_PARTICLE', particleId: children[index].id });
}
},
[children],
);
// NOTE: if particle doesn't exist in children, this will lead to a brief moment where currentParticle is null
const goToParticle = useCallback(
(particleId: string) => {
// Dispatch directly by ID — if the particle isn't in children yet
// (e.g. just created), it will resolve once the live query delivers it.
dispatch({ type: "SET_PARTICLE", particleId });
}, []);
const goToParticle = useCallback((particleId: string) => {
// Dispatch directly by ID — if the particle isn't in children yet
// (e.g. just created), it will resolve once the live query delivers it.
dispatch({ type: 'SET_PARTICLE', particleId });
}, []);
return {
children,
+2 -2
View File
@@ -1,5 +1,5 @@
import { useEffect, useId } from "react";
import { usePlaybackPauseStore } from "@/stores/playback-pause-store";
import { useEffect, useId } from 'react';
import { usePlaybackPauseStore } from '@/stores/playback-pause-store';
/**
* Suspend stream playback while `active` is true. The hook owns its own
@@ -1,7 +1,7 @@
import { useMemo } from "react";
import type { Transcript } from "@/api/types";
import { useMemo } from 'react';
import type { Transcript } from '@/api/types';
type Sentence = Transcript["paragraphs"][number]["sentences"][number];
type Sentence = Transcript['paragraphs'][number]['sentences'][number];
interface TranscriptPlaybackState {
/** The sentence currently being spoken, or null if between sentences */