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