refactor: organize desktop vs. mobile into separate folders
This commit is contained in:
@@ -0,0 +1,29 @@
|
||||
import { useQuery, useMutation } 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,
|
||||
// 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
|
||||
});
|
||||
}
|
||||
|
||||
export function useCreateCheckoutSession(networkId: string) {
|
||||
return useMutation({
|
||||
mutationFn: (cadence: BillingCadence) =>
|
||||
apiClient.createCheckoutSession(networkId, cadence),
|
||||
});
|
||||
}
|
||||
|
||||
export function useCreatePortalSession(networkId: string) {
|
||||
return useMutation({
|
||||
mutationFn: () => apiClient.createPortalSession(networkId),
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import { useEffect, useState, useCallback, useRef } 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 */
|
||||
presence: string[];
|
||||
/** Messages received on this channel (since the hook mounted) */
|
||||
messages: ChannelMessage[];
|
||||
/** Send a message to the channel */
|
||||
sendMessage: (payload: unknown) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to a pusher channel. Manages presence tracking and message delivery.
|
||||
* Subscribes on mount, unsubscribes on unmount.
|
||||
*
|
||||
* @param channelId - The channel to subscribe to, or null to skip.
|
||||
*/
|
||||
export function useChannel(channelId: string | null): UseChannelResult {
|
||||
const client = usePusherClient();
|
||||
const [presence, setPresence] = useState<string[]>([]);
|
||||
const [messages, setMessages] = useState<ChannelMessage[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!client || !channelId) {
|
||||
setPresence([]);
|
||||
setMessages([]);
|
||||
return;
|
||||
}
|
||||
|
||||
client.subscribe(channelId);
|
||||
|
||||
const onSubscribed = (msg: { presence?: string[] }) => {
|
||||
setPresence(msg.presence ?? []);
|
||||
};
|
||||
|
||||
const onJoin = (msg: { humanId?: string }) => {
|
||||
if (msg.humanId) {
|
||||
setPresence((prev) =>
|
||||
prev.includes(msg.humanId!) ? prev : [...prev, msg.humanId!],
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const onLeave = (msg: { humanId?: string }) => {
|
||||
if (msg.humanId) {
|
||||
setPresence((prev) => prev.filter((id) => id !== msg.humanId));
|
||||
}
|
||||
};
|
||||
|
||||
const onMessage = (msg: { humanId?: string; payload?: unknown }) => {
|
||||
if (msg.humanId) {
|
||||
setMessages((prev) => [
|
||||
...prev,
|
||||
{ humanId: msg.humanId!, payload: msg.payload },
|
||||
]);
|
||||
}
|
||||
};
|
||||
|
||||
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.unsubscribe(channelId);
|
||||
};
|
||||
}, [client, channelId]);
|
||||
|
||||
const sendMessage = useCallback(
|
||||
(payload: unknown) => {
|
||||
if (client && channelId) {
|
||||
client?.sendMessage(channelId, payload);
|
||||
}
|
||||
},
|
||||
[client, channelId],
|
||||
);
|
||||
|
||||
return { presence, messages, sendMessage };
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
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";
|
||||
|
||||
interface CreateParticleParams<T extends ParticleType = ParticleType> {
|
||||
// Path to which the new particle will be added as a child
|
||||
path: ParticlePath;
|
||||
type: T;
|
||||
properties: ParticlePropertiesMap[T];
|
||||
createdByHumanId: string;
|
||||
}
|
||||
|
||||
export function useCreateParticle() {
|
||||
const qc = useQueryClient();
|
||||
const bumpUsage = useBumpNetworkUsage();
|
||||
const invalidateUsage = useInvalidateNetworkUsage();
|
||||
|
||||
return useMutation({
|
||||
// Compose UI renders a custom quota-exceeded toast + cancels the overlay.
|
||||
// Opt out of the global mutation error toast to avoid a double-toast.
|
||||
meta: { suppressToast: true },
|
||||
mutationFn: async (params: CreateParticleParams) => {
|
||||
const { networkId } = parseParticlePath(params.path);
|
||||
|
||||
// Containers aren't counted server-side, so we block them here
|
||||
if (!CONTAINER_TYPES.has(params.type)) {
|
||||
const cached = qc.getQueryData<NetworkUsage>(networkUsageQueryKey(networkId));
|
||||
if (isUsageExhausted(cached)) {
|
||||
throw new QuotaExceededError(networkId);
|
||||
}
|
||||
}
|
||||
|
||||
const collectionPath = toFirestoreChildrenPath(params.path);
|
||||
const result = await createParticle(
|
||||
collectionPath,
|
||||
params.type,
|
||||
params.properties,
|
||||
params.createdByHumanId,
|
||||
);
|
||||
|
||||
if (!CONTAINER_TYPES.has(params.type)) {
|
||||
bumpUsage(networkId);
|
||||
void invalidateUsage(networkId);
|
||||
}
|
||||
|
||||
return result;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
type CreateStreamParticleParams = {
|
||||
networkId: string;
|
||||
properties: ParticlePropertiesMap["stream"];
|
||||
createdByHumanId: string;
|
||||
visibleTo?: string[];
|
||||
};
|
||||
|
||||
export function useCreateStreamParticle() {
|
||||
return useMutation({
|
||||
mutationFn: async (params: CreateStreamParticleParams) => {
|
||||
const path = particlePath(params.networkId, []);
|
||||
const networkCollectionPath = toFirestoreChildrenPath(path);
|
||||
return await createStreamParticle(
|
||||
networkCollectionPath,
|
||||
params.properties,
|
||||
params.createdByHumanId,
|
||||
params.visibleTo,
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
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";
|
||||
|
||||
type StreamParticle = Particle & { type: "stream"; properties: StreamProperties };
|
||||
|
||||
const openStatusFilter = where("status", "==", "open");
|
||||
|
||||
/**
|
||||
* Self-contained hook that syncs the macOS dock badge with the count of
|
||||
* unseen open streams the current user is involved in.
|
||||
*
|
||||
* Sets up its own Firestore listener so it works independently of
|
||||
* whatever stream list is rendered on screen.
|
||||
*/
|
||||
export function useDockBadge(networkId: string | undefined) {
|
||||
const user = useAuthStore((s) => s.user);
|
||||
const userId = user?.id;
|
||||
|
||||
const visibilityScopes = useMemo(() => {
|
||||
const scopes: string[] = [];
|
||||
if (userId) scopes.push(`human:${userId}`);
|
||||
if (networkId) scopes.push(`network:${networkId}`);
|
||||
return scopes;
|
||||
}, [userId, networkId]);
|
||||
|
||||
const path = networkId ? particlePath(networkId, []) : undefined;
|
||||
|
||||
const { children } = useLiveParticleChildren(path, {
|
||||
orderByField: "last_child_created_at",
|
||||
orderDirection: "desc",
|
||||
visibilityScopes,
|
||||
whereFilter: openStatusFilter,
|
||||
});
|
||||
|
||||
const unseenCount = useMemo(() => {
|
||||
if (!userId) return 0;
|
||||
return children.filter((c): c is StreamParticle => {
|
||||
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();
|
||||
if (marker === undefined) return false;
|
||||
return lastActivity > marker;
|
||||
}).length;
|
||||
}, [children, userId]);
|
||||
|
||||
useEffect(() => {
|
||||
window.electronApp.setDockBadge(unseenCount);
|
||||
return () => window.electronApp.setDockBadge(0);
|
||||
}, [unseenCount]);
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { useQuery } 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,
|
||||
staleTime: 1000 * 60 * 60, // 1 hour — signed URLs valid for 24h
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import type { SavedDevice } from "@/stores/media-devices-store";
|
||||
|
||||
/**
|
||||
* Resolves a saved device preference against the currently available
|
||||
* devices. Returns the saved `deviceId` only if it still appears in the
|
||||
* list — otherwise `undefined` so getUserMedia falls back to the
|
||||
* system default. This keeps "unplugged device" handling in one place.
|
||||
*/
|
||||
export function resolveEffectiveDeviceId(
|
||||
saved: SavedDevice | null,
|
||||
available: MediaDeviceInfo[],
|
||||
): string | undefined {
|
||||
if (!saved) return undefined;
|
||||
const match = available.find((d) => d.deviceId === saved.deviceId);
|
||||
return match ? match.deviceId : undefined;
|
||||
}
|
||||
|
||||
export function isSavedDeviceAvailable(
|
||||
saved: SavedDevice | null,
|
||||
available: MediaDeviceInfo[],
|
||||
): boolean {
|
||||
if (!saved) return true;
|
||||
return available.some((d) => d.deviceId === saved.deviceId);
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
|
||||
interface UseFileInputOptions {
|
||||
onFilesSelected: (files: File[]) => void;
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
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
|
||||
const onFilesRef = useRef(onFilesSelected);
|
||||
onFilesRef.current = onFilesSelected;
|
||||
|
||||
// Hidden file input element
|
||||
useEffect(() => {
|
||||
const input = document.createElement("input");
|
||||
input.type = "file";
|
||||
input.multiple = true;
|
||||
input.style.display = "none";
|
||||
input.addEventListener("change", () => {
|
||||
if (input.files?.length) {
|
||||
onFilesRef.current(Array.from(input.files));
|
||||
input.value = "";
|
||||
}
|
||||
});
|
||||
document.body.appendChild(input);
|
||||
inputRef.current = input;
|
||||
return () => {
|
||||
document.body.removeChild(input);
|
||||
inputRef.current = null;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const openFilePicker = useCallback(() => {
|
||||
inputRef.current?.click();
|
||||
}, []);
|
||||
|
||||
// Clipboard paste
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
|
||||
const handlePaste = (e: ClipboardEvent) => {
|
||||
const files = Array.from(e.clipboardData?.files ?? []);
|
||||
if (files.length > 0) {
|
||||
e.preventDefault();
|
||||
onFilesRef.current(files);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("paste", handlePaste);
|
||||
return () => window.removeEventListener("paste", handlePaste);
|
||||
}, [enabled]);
|
||||
|
||||
// Drag and drop handlers
|
||||
const onDragOver = useCallback(
|
||||
(e: React.DragEvent) => {
|
||||
if (!enabled) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
},
|
||||
[enabled],
|
||||
);
|
||||
|
||||
const onDragEnter = useCallback(
|
||||
(e: React.DragEvent) => {
|
||||
if (!enabled) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
dragCountRef.current++;
|
||||
if (dragCountRef.current === 1) setIsDragging(true);
|
||||
},
|
||||
[enabled],
|
||||
);
|
||||
|
||||
const onDragLeave = useCallback(
|
||||
(e: React.DragEvent) => {
|
||||
if (!enabled) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
dragCountRef.current--;
|
||||
if (dragCountRef.current === 0) setIsDragging(false);
|
||||
},
|
||||
[enabled],
|
||||
);
|
||||
|
||||
const onDrop = useCallback(
|
||||
(e: React.DragEvent) => {
|
||||
if (!enabled) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
dragCountRef.current = 0;
|
||||
setIsDragging(false);
|
||||
const files = Array.from(e.dataTransfer.files);
|
||||
if (files.length > 0) {
|
||||
onFilesRef.current(files);
|
||||
}
|
||||
},
|
||||
[enabled],
|
||||
);
|
||||
|
||||
return {
|
||||
openFilePicker,
|
||||
isDragging,
|
||||
dropZoneProps: { onDragOver, onDragEnter, onDragLeave, onDrop },
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { useQueries, useQuery } from "@tanstack/react-query";
|
||||
import { extractUrls, type LinkMetadata } from "@/lib/link-metadata";
|
||||
|
||||
export function useLinkMetadata(url: string | null) {
|
||||
return useQuery<LinkMetadata | null>({
|
||||
queryKey: ["link-metadata", url],
|
||||
queryFn: () => window.electronLink.fetchMetadata(url!),
|
||||
enabled: !!url,
|
||||
staleTime: Infinity,
|
||||
gcTime: 30 * 60 * 1000,
|
||||
retry: 1,
|
||||
});
|
||||
}
|
||||
|
||||
export function useFirstLinkMetadata(text: string) {
|
||||
const urls = extractUrls(text);
|
||||
const firstUrl = urls[0] ?? null;
|
||||
return { ...useLinkMetadata(firstUrl), url: firstUrl };
|
||||
}
|
||||
|
||||
export interface LinkPreviewEntry {
|
||||
url: string;
|
||||
metadata: LinkMetadata | null | undefined;
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
export function useAllLinkMetadata(text: string): LinkPreviewEntry[] {
|
||||
const urls = extractUrls(text);
|
||||
|
||||
const results = useQueries({
|
||||
queries: urls.map((url) => ({
|
||||
queryKey: ["link-metadata", url],
|
||||
queryFn: () => window.electronLink.fetchMetadata(url),
|
||||
staleTime: Infinity,
|
||||
gcTime: 30 * 60 * 1000,
|
||||
retry: 1,
|
||||
})),
|
||||
});
|
||||
|
||||
return urls.map((url, i) => ({
|
||||
url,
|
||||
metadata: results[i].data,
|
||||
isLoading: results[i].isLoading,
|
||||
}));
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
|
||||
export type PermissionState = "unknown" | "granted" | "denied";
|
||||
|
||||
interface UseMediaDevicesResult {
|
||||
audioInputs: MediaDeviceInfo[];
|
||||
videoInputs: MediaDeviceInfo[];
|
||||
permissionState: PermissionState;
|
||||
refresh: () => Promise<void>;
|
||||
requestLabels: () => Promise<void>;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Enumerates input devices and stays subscribed to `devicechange`.
|
||||
*
|
||||
* Labels are only populated after the user has granted mic/camera
|
||||
* permission — `requestLabels` triggers a brief getUserMedia so that
|
||||
* subsequent enumerations return human-readable names, matching the
|
||||
* pattern most video-conferencing apps use.
|
||||
*/
|
||||
export function useMediaDevices(): UseMediaDevicesResult {
|
||||
const [devices, setDevices] = useState<MediaDeviceInfo[]>([]);
|
||||
const [permissionState, setPermissionState] =
|
||||
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 requestLabels = useCallback(async () => {
|
||||
try {
|
||||
const stream = await navigator.mediaDevices.getUserMedia({
|
||||
audio: true,
|
||||
video: true,
|
||||
});
|
||||
// Immediately stop — we only needed the permission grant.
|
||||
stream.getTracks().forEach((t) => t.stop());
|
||||
setPermissionState("granted");
|
||||
setError(null);
|
||||
await refresh();
|
||||
} catch (err) {
|
||||
setPermissionState("denied");
|
||||
setError(
|
||||
err instanceof Error ? err.message : "Microphone/camera access denied",
|
||||
);
|
||||
}
|
||||
}, [refresh]);
|
||||
|
||||
useEffect(() => {
|
||||
refresh();
|
||||
const handle = () => {
|
||||
refresh();
|
||||
};
|
||||
navigator.mediaDevices.addEventListener("devicechange", handle);
|
||||
return () => {
|
||||
navigator.mediaDevices.removeEventListener("devicechange", handle);
|
||||
};
|
||||
}, [refresh]);
|
||||
|
||||
return {
|
||||
audioInputs: devices.filter((d) => d.kind === "audioinput"),
|
||||
videoInputs: devices.filter((d) => d.kind === "videoinput"),
|
||||
permissionState,
|
||||
refresh,
|
||||
requestLabels,
|
||||
error,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { apiClient } from "@/api/client";
|
||||
|
||||
export function useMyInvitations() {
|
||||
return useQuery({
|
||||
queryKey: ["my-invitations"],
|
||||
queryFn: () => apiClient.listMyInvitations(),
|
||||
refetchInterval: 10_000,
|
||||
});
|
||||
}
|
||||
|
||||
export function useNetworkInvitations(networkId: string) {
|
||||
return useQuery({
|
||||
queryKey: ["network-invitations", networkId],
|
||||
queryFn: () => apiClient.listNetworkInvitations(networkId),
|
||||
});
|
||||
}
|
||||
|
||||
export function useInviteMembers(networkId: string) {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (emailAddresses: string[]) =>
|
||||
apiClient.addMembers(networkId, { email_addresses: emailAddresses }),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["network-invitations", networkId] });
|
||||
queryClient.invalidateQueries({ queryKey: ["networks"] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useAcceptInvitation() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (networkId: string) =>
|
||||
apiClient.acceptInvitation({ network_id: networkId }),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["my-invitations"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["networks"] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useRevokeInvitation(networkId: string) {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (email: string) =>
|
||||
apiClient.revokeInvitation(networkId, { email }),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["network-invitations", networkId] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useRemoveMember(networkId: string) {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (humanId: string) => apiClient.removeMember(networkId, humanId),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["networks"] });
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { useQuery, useQueryClient } 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;
|
||||
|
||||
export function useNetworkUsage(networkId: string | undefined) {
|
||||
return useQuery({
|
||||
queryKey: networkUsageQueryKey(networkId),
|
||||
queryFn: () => apiClient.getNetworkUsage(networkId!),
|
||||
enabled: !!networkId,
|
||||
// Refetch whenever a consumer mounts (billing settings, compose indicator)
|
||||
// so users land on fresh quota state without listener wiring.
|
||||
refetchOnMount: "always",
|
||||
refetchOnWindowFocus: true,
|
||||
refetchInterval: 10000
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a callback that invalidates the usage query for a network.
|
||||
* Callers: own-send success path, inbound-particle listener.
|
||||
*/
|
||||
export function useInvalidateNetworkUsage() {
|
||||
const qc = useQueryClient();
|
||||
return useCallback(
|
||||
(networkId: string) =>
|
||||
qc.invalidateQueries({ queryKey: networkUsageQueryKey(networkId) }),
|
||||
[qc],
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Optimistic bump of the cached `used` count. The worker-written truth is
|
||||
* reconciled on the next invalidation/refetch.
|
||||
*/
|
||||
export function useBumpNetworkUsage() {
|
||||
const qc = useQueryClient();
|
||||
return useCallback(
|
||||
(networkId: string) => {
|
||||
qc.setQueryData<NetworkUsage>(networkUsageQueryKey(networkId), (prev) =>
|
||||
prev ? { ...prev, used: prev.used + 1 } : prev,
|
||||
);
|
||||
},
|
||||
[qc],
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* True iff the network is on the free plan and has exhausted today's quota.
|
||||
*/
|
||||
export function isUsageExhausted(usage: NetworkUsage | undefined): boolean {
|
||||
if (!usage) return false;
|
||||
if (usage.limit == null) return false;
|
||||
return usage.used >= usage.limit;
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { apiClient } from "@/api/client";
|
||||
import { useAuthStore } from "@/stores/auth-store";
|
||||
|
||||
export function useNetworks() {
|
||||
return useQuery({
|
||||
queryKey: ["networks"],
|
||||
queryFn: () => apiClient.listNetworks(),
|
||||
});
|
||||
}
|
||||
|
||||
export function useNetwork(networkId: string) {
|
||||
const { data: networks } = useNetworks();
|
||||
return networks?.find((n) => n.id === networkId) || null;
|
||||
}
|
||||
|
||||
export function useIsNetworkAdmin(networkId: string): boolean {
|
||||
const network = useNetwork(networkId);
|
||||
const userId = useAuthStore((s) => s.user?.id);
|
||||
if (!network || !userId) return false;
|
||||
return network.admin_human.id === userId;
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
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" }>;
|
||||
|
||||
/**
|
||||
* Fetches file children (attachments) of a particle in a stream.
|
||||
* Uses a one-shot query since attachments are immutable after creation.
|
||||
*/
|
||||
export function useParticleAttachments(
|
||||
streamPath: ParticlePath,
|
||||
particleId: string,
|
||||
) {
|
||||
const childrenPath = useMemo(() => {
|
||||
const { networkId, segments } = parseParticlePath(streamPath);
|
||||
return particlePath(networkId, [...segments, particleId]);
|
||||
}, [streamPath, particleId]);
|
||||
|
||||
const { children, isLoading } = useLiveParticleChildren(childrenPath);
|
||||
|
||||
const attachments = useMemo(
|
||||
() => (children ?? []).filter((c): c is FileParticle => c.type === "file"),
|
||||
[children],
|
||||
);
|
||||
|
||||
return { attachments, isLoading };
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import {
|
||||
subscribeToParticle,
|
||||
subscribeToParticleChildren,
|
||||
subscribeToLatestChild,
|
||||
getParticle,
|
||||
getParticleChildren,
|
||||
} 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";
|
||||
|
||||
interface UseLiveParticleResult {
|
||||
particle: Particle | null;
|
||||
isLoading: boolean;
|
||||
error: Error | null;
|
||||
}
|
||||
|
||||
export function useLiveParticle(path: ParticlePath): UseLiveParticleResult {
|
||||
const [particle, setParticle] = useState<Particle | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<Error | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
setParticle(null);
|
||||
|
||||
const docPath = toFirestoreDocPath(path);
|
||||
const unsubscribe = subscribeToParticle(
|
||||
docPath,
|
||||
(data) => {
|
||||
setParticle(data);
|
||||
setIsLoading(false);
|
||||
},
|
||||
(err) => {
|
||||
setError(err);
|
||||
setIsLoading(false);
|
||||
},
|
||||
);
|
||||
|
||||
return unsubscribe;
|
||||
}, [path]);
|
||||
|
||||
return { particle, isLoading, error };
|
||||
}
|
||||
|
||||
interface UseLiveParticleChildrenResult {
|
||||
children: Particle[];
|
||||
isLoading: boolean;
|
||||
error: Error | null;
|
||||
}
|
||||
|
||||
interface UseLiveParticleChildrenParams {
|
||||
orderByField?: string;
|
||||
orderDirection?: "asc" | "desc";
|
||||
visibilityScopes?: string[];
|
||||
onAdded?: (child: Particle) => void;
|
||||
onRemoved?: (child: Particle, updatedChildren: Particle[]) => void;
|
||||
whereFilter?: QueryFieldFilterConstraint;
|
||||
/** Optional cap on results. Changes trigger a re-subscription. */
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
export function useLiveParticleChildren(
|
||||
path: ParticlePath | undefined,
|
||||
{
|
||||
orderByField = "created_at",
|
||||
orderDirection = "desc",
|
||||
visibilityScopes,
|
||||
onAdded,
|
||||
onRemoved,
|
||||
whereFilter,
|
||||
limit,
|
||||
}: UseLiveParticleChildrenParams = {}
|
||||
): UseLiveParticleChildrenResult {
|
||||
const [children, setChildren] = useState<Particle[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<Error | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!path) {
|
||||
setChildren([]);
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
setChildren([]);
|
||||
|
||||
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,
|
||||
}
|
||||
);
|
||||
|
||||
return unsubscribe;
|
||||
}, [path, whereFilter, limit]);
|
||||
|
||||
return { children, isLoading, error };
|
||||
}
|
||||
|
||||
interface UseLiveLatestChildResult {
|
||||
latestChild: Particle | null;
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
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) => {
|
||||
setLatestChild(data);
|
||||
setIsLoading(false);
|
||||
},
|
||||
() => {
|
||||
setIsLoading(false);
|
||||
},
|
||||
);
|
||||
|
||||
return unsubscribe;
|
||||
}, [path]);
|
||||
|
||||
return { latestChild, isLoading };
|
||||
}
|
||||
|
||||
export function useParticle(path?: ParticlePath) {
|
||||
return useQuery({
|
||||
queryKey: ["particle", path],
|
||||
queryFn: async () => {
|
||||
if (!path) return null;
|
||||
const docPath = toFirestoreDocPath(path);
|
||||
const particle = await getParticle(docPath);
|
||||
return particle;
|
||||
},
|
||||
enabled: !!path,
|
||||
});
|
||||
}
|
||||
|
||||
export function useParticleChildren(path?: ParticlePath) {
|
||||
return useQuery({
|
||||
queryKey: ["particle-children", path],
|
||||
queryFn: async () => {
|
||||
if (!path) return [];
|
||||
const collectionPath = toFirestoreChildrenPath(path);
|
||||
return getParticleChildren(collectionPath);
|
||||
},
|
||||
enabled: !!path,
|
||||
staleTime: 1000 * 60 * 5, // 5 min — attachments don't change
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
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";
|
||||
|
||||
const SPACE_TAP_THRESHOLD_MS = 250;
|
||||
|
||||
interface UsePlaybackKeysOptions {
|
||||
mediaRef: RefObject<MediaParticleHandle | null>;
|
||||
}
|
||||
|
||||
interface UsePlaybackKeysResult {
|
||||
fastPlayback: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hold-space to pause, hold-shift for 1.5x. Space is always handled (it
|
||||
* manages its own suspender via useSuspendPlayback); other keys bail when
|
||||
* playback is already paused for an external reason.
|
||||
*/
|
||||
export function usePlaybackKeys({ mediaRef }: UsePlaybackKeysOptions): UsePlaybackKeysResult {
|
||||
const [spaceHeld, setSpaceHeld] = useState(false);
|
||||
const [fastPlayback, setFastPlayback] = useState(false);
|
||||
const spaceStartRef = useRef(0);
|
||||
|
||||
useSuspendPlayback(spaceHeld, "hold-space");
|
||||
|
||||
useEffect(() => {
|
||||
const isExternallyPaused = () =>
|
||||
selectIsPaused(usePlaybackPauseStore.getState()) && !spaceHeld;
|
||||
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
if (isTypingTarget(e)) return;
|
||||
|
||||
if (e.key === " ") {
|
||||
e.preventDefault();
|
||||
if (!e.repeat) {
|
||||
if (spaceHeld) {
|
||||
setSpaceHeld(false);
|
||||
spaceStartRef.current = 0;
|
||||
} else {
|
||||
setSpaceHeld(true);
|
||||
spaceStartRef.current = Date.now();
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (isExternallyPaused()) return;
|
||||
|
||||
if (e.key === "Shift" && !e.repeat) {
|
||||
mediaRef.current?.setPlaybackRate(1.5);
|
||||
setFastPlayback(true);
|
||||
}
|
||||
};
|
||||
|
||||
const onKeyUp = (e: KeyboardEvent) => {
|
||||
if (isTypingTarget(e)) return;
|
||||
|
||||
if (e.key === " ") {
|
||||
e.preventDefault();
|
||||
|
||||
// keep space-held if it was a quick tap, to allow for space-to-toggle behavior
|
||||
if (Date.now() - spaceStartRef.current < SPACE_TAP_THRESHOLD_MS) {
|
||||
return;
|
||||
}
|
||||
|
||||
setSpaceHeld(false);
|
||||
spaceStartRef.current = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
if (isExternallyPaused()) return;
|
||||
|
||||
if (e.key === "Shift") {
|
||||
mediaRef.current?.setPlaybackRate(1);
|
||||
setFastPlayback(false);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("keydown", onKeyDown);
|
||||
window.addEventListener("keyup", onKeyUp);
|
||||
return () => {
|
||||
window.removeEventListener("keydown", onKeyDown);
|
||||
window.removeEventListener("keyup", onKeyUp);
|
||||
};
|
||||
}, [mediaRef, spaceHeld]);
|
||||
|
||||
return { fastPlayback };
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
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
|
||||
/**
|
||||
* Prefetches download URLs and warms the browser cache for adjacent media particles.
|
||||
*/
|
||||
export function usePrefetchAdjacentMedia(
|
||||
children: Particle[],
|
||||
currentIndex: number,
|
||||
) {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
useEffect(() => {
|
||||
const adjacentIndices = [currentIndex - 1, currentIndex + 1];
|
||||
const mediaParticles = adjacentIndices
|
||||
.filter((i) => i >= 0 && i < children.length)
|
||||
.map((i) => children[i])
|
||||
.filter(
|
||||
(p): p is Extract<Particle, { type: "media" }> => p.type === "media",
|
||||
);
|
||||
|
||||
for (const particle of mediaParticles) {
|
||||
const objectId = particle.properties.object_id;
|
||||
|
||||
queryClient
|
||||
.prefetchQuery({
|
||||
queryKey: ["download-url", objectId],
|
||||
queryFn: () => apiClient.getParticleDownloadUrl(objectId),
|
||||
staleTime: 1000 * 60 * 60,
|
||||
})
|
||||
.then(() => {
|
||||
const url = queryClient.getQueryData<string>([
|
||||
"download-url",
|
||||
objectId,
|
||||
]);
|
||||
if (url) {
|
||||
preload(url, { as: "fetch", crossOrigin: "anonymous" });
|
||||
}
|
||||
});
|
||||
}
|
||||
}, [children, currentIndex, queryClient]);
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { useMemo } from "react";
|
||||
import type { Human, Particle } from "@/api/types";
|
||||
|
||||
export interface HumanPresence {
|
||||
humanId: string;
|
||||
email: string;
|
||||
emailPrefix: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps playback markers to segment indices, returning which users are present at each particle.
|
||||
*/
|
||||
export function usePresencePositions(
|
||||
playbackMarkers: Record<string, Date> | undefined,
|
||||
children: Particle[],
|
||||
networkHumans: Human[] | undefined,
|
||||
currentUserId: string | undefined,
|
||||
): Map<number, HumanPresence[]> {
|
||||
return useMemo(() => {
|
||||
const result = new Map<number, HumanPresence[]>();
|
||||
if (!playbackMarkers || !networkHumans || children.length === 0) return result;
|
||||
|
||||
for (const [userId, markerTimestamp] of Object.entries(playbackMarkers)) {
|
||||
if (userId === currentUserId) continue;
|
||||
|
||||
const human = networkHumans.find((h) => h.id === userId);
|
||||
if (!human) continue;
|
||||
|
||||
// Find the last particle whose created_at <= marker timestamp
|
||||
let segmentIndex = -1;
|
||||
for (let i = children.length - 1; i >= 0; i--) {
|
||||
if (children[i].created_at.getTime() <= markerTimestamp.getTime()) {
|
||||
segmentIndex = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (segmentIndex === -1) continue;
|
||||
|
||||
const existing = result.get(segmentIndex);
|
||||
const presence: HumanPresence = { humanId: human.id, email: human.email, emailPrefix: human.email_prefix };
|
||||
if (existing) {
|
||||
existing.push(presence);
|
||||
} else {
|
||||
result.set(segmentIndex, [presence]);
|
||||
}
|
||||
}
|
||||
|
||||
// Sort each segment's presence list by humanId for stable render order
|
||||
for (const presenceList of result.values()) {
|
||||
presenceList.sort((a, b) => a.humanId.localeCompare(b.humanId));
|
||||
}
|
||||
|
||||
return result;
|
||||
}, [playbackMarkers, children, networkHumans, currentUserId]);
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { useState, useCallback } from "react";
|
||||
|
||||
export type RecordingMode = "video" | "audio";
|
||||
|
||||
const KEY = "llink:recording-mode";
|
||||
|
||||
export function useRecordingMode(): [RecordingMode, (mode: RecordingMode) => void] {
|
||||
const [mode, setModeState] = useState<RecordingMode>(() => {
|
||||
const stored = localStorage.getItem(KEY);
|
||||
return stored === "audio" ? "audio" : "video";
|
||||
});
|
||||
|
||||
const setMode = useCallback((m: RecordingMode) => {
|
||||
localStorage.setItem(KEY, m);
|
||||
setModeState(m);
|
||||
}, []);
|
||||
|
||||
return [mode, setMode];
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
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;
|
||||
onOpenHuddle: () => void;
|
||||
onToggleRecordingMode: () => void;
|
||||
onToggleKeybindings: () => void;
|
||||
onOpenTextReaction: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reactions 1–7, `r` quick text reply, `h` huddle, `v` toggle recording mode,
|
||||
* `?` toggle keybindings overlay. Skipped while playback is paused for any reason.
|
||||
*/
|
||||
export function useStreamActionKeys({
|
||||
onToggleReaction,
|
||||
onOpenHuddle,
|
||||
onToggleRecordingMode,
|
||||
onToggleKeybindings,
|
||||
onOpenTextReaction,
|
||||
}: UseStreamActionKeysOptions) {
|
||||
useEffect(() => {
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
if (isTypingTarget(e)) return;
|
||||
if (selectIsPaused(usePlaybackPauseStore.getState())) return;
|
||||
|
||||
switch (e.key) {
|
||||
case "h":
|
||||
e.preventDefault();
|
||||
onOpenHuddle();
|
||||
break;
|
||||
case "v":
|
||||
e.preventDefault();
|
||||
onToggleRecordingMode();
|
||||
break;
|
||||
case "r":
|
||||
e.preventDefault();
|
||||
onOpenTextReaction();
|
||||
break;
|
||||
case "?":
|
||||
e.preventDefault();
|
||||
onToggleKeybindings();
|
||||
break;
|
||||
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]);
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import beepSound from "../../assets/sound.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";
|
||||
|
||||
/**
|
||||
* Triggers autoplay when a stream's latest child changes to a new media particle.
|
||||
* Plays a beep sound for new text particles from other users.
|
||||
* Sends autoplay data to the separate autoplay window via IPC.
|
||||
*/
|
||||
export function useStreamAutoplay(
|
||||
latestChild: Particle | null,
|
||||
streamParticle: Particle & { type: "stream"; properties: StreamProperties },
|
||||
networkId: string,
|
||||
network: Network | undefined,
|
||||
) {
|
||||
const userId = useAuthStore((s) => s.user?.id) ?? "";
|
||||
const settledIdRef = useRef<string | undefined>(undefined);
|
||||
|
||||
useEffect(() => {
|
||||
if (!latestChild) return;
|
||||
|
||||
// First real value: record as baseline, don't autoplay
|
||||
if (settledIdRef.current === undefined) {
|
||||
settledIdRef.current = latestChild.id;
|
||||
return;
|
||||
}
|
||||
|
||||
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) => {
|
||||
window.electronAutoplay.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
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useMediaSettingsStore } from "@/stores/media-settings-store";
|
||||
|
||||
interface UseStreamKeyboardNavOptions {
|
||||
streams: Array<{ id: string }>;
|
||||
enabled: boolean;
|
||||
onNavigate: (streamId: string) => void;
|
||||
}
|
||||
|
||||
export function useStreamKeyboardNav({
|
||||
streams,
|
||||
enabled,
|
||||
onNavigate,
|
||||
}: UseStreamKeyboardNavOptions) {
|
||||
const [selectedIndex, setSelectedIndex] = useState<number | null>(null);
|
||||
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]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled || streams.length === 0) return;
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.defaultPrevented) return;
|
||||
|
||||
const target = e.target as HTMLElement;
|
||||
if (
|
||||
target.tagName === "INPUT" ||
|
||||
target.tagName === "TEXTAREA" ||
|
||||
target.isContentEditable
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Number keys 1-9: immediate navigation
|
||||
const digit = parseInt(e.key, 10);
|
||||
if (digit >= 1 && digit <= 9) {
|
||||
const index = digit - 1;
|
||||
if (index < streams.length) {
|
||||
e.preventDefault();
|
||||
onNavigate(streams[index].id);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Enter: navigate to selected
|
||||
if (e.key === "Enter") {
|
||||
setSelectedIndex((idx) => {
|
||||
if (idx !== null && idx < streams.length) {
|
||||
e.preventDefault();
|
||||
onNavigate(streams[idx].id);
|
||||
}
|
||||
return idx;
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// V: toggle video / audio
|
||||
if (e.key === "v" || e.key === "V") {
|
||||
e.preventDefault();
|
||||
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 (delta !== null) {
|
||||
e.preventDefault();
|
||||
setSelectedIndex((prev) => {
|
||||
if (prev === null) return 0;
|
||||
const next = prev + delta;
|
||||
return Math.max(0, Math.min(next, streams.length - 1));
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// 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);
|
||||
}, [enabled, streams, onNavigate, recordingMode, setRecordingMode]);
|
||||
|
||||
return { selectedIndex };
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
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";
|
||||
|
||||
const SEEK_DELTA_SEC = 5;
|
||||
|
||||
interface UseStreamNavigationKeysOptions {
|
||||
next: () => void;
|
||||
prev: () => void;
|
||||
currentIndex: number;
|
||||
childrenLength: number;
|
||||
mediaRef: RefObject<MediaParticleHandle | null>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Arrow keys (with shift+arrow seek), Escape. Skipped while playback is
|
||||
* paused for any reason (overlay, hold-space, compose).
|
||||
*/
|
||||
export function useStreamNavigationKeys({
|
||||
next,
|
||||
prev,
|
||||
currentIndex,
|
||||
childrenLength,
|
||||
mediaRef,
|
||||
}: UseStreamNavigationKeysOptions) {
|
||||
const navigate = useNavigate();
|
||||
|
||||
useEffect(() => {
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
if (isTypingTarget(e)) return;
|
||||
|
||||
const hasNext = currentIndex >= 0 && currentIndex < childrenLength - 1;
|
||||
|
||||
switch (e.key) {
|
||||
case "ArrowRight":
|
||||
e.preventDefault();
|
||||
if (!e.shiftKey || !mediaRef.current?.seek(SEEK_DELTA_SEC)) {
|
||||
if (hasNext) next();
|
||||
}
|
||||
break;
|
||||
case "ArrowDown":
|
||||
e.preventDefault();
|
||||
if (hasNext) next();
|
||||
break;
|
||||
case "ArrowLeft":
|
||||
e.preventDefault();
|
||||
if (!e.shiftKey || !mediaRef.current?.seek(-SEEK_DELTA_SEC)) prev();
|
||||
break;
|
||||
case "ArrowUp":
|
||||
e.preventDefault();
|
||||
prev();
|
||||
break;
|
||||
case "Escape":
|
||||
e.preventDefault();
|
||||
navigate(-1);
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("keydown", onKeyDown);
|
||||
return () => window.removeEventListener("keydown", onKeyDown);
|
||||
}, [next, prev, currentIndex, childrenLength, mediaRef, navigate]);
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
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";
|
||||
|
||||
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");
|
||||
|
||||
function useVisibilityScopes(userId?: string, networkId?: string) {
|
||||
return useMemo(() => {
|
||||
const scopes: string[] = [];
|
||||
if (userId) scopes.push(`human:${userId}`);
|
||||
if (networkId) scopes.push(`network:${networkId}`);
|
||||
return scopes;
|
||||
}, [userId, networkId]);
|
||||
}
|
||||
|
||||
interface UseStreamParticlesOptions {
|
||||
/**
|
||||
* Which streams to subscribe to. Open streams are loaded in full (bounded
|
||||
* by active work — full realtime coverage is needed for autoplay/huddles).
|
||||
* Closed streams are paginated via `loadMore`.
|
||||
*/
|
||||
status: "open" | "closed";
|
||||
}
|
||||
|
||||
interface UseStreamParticlesResult {
|
||||
streams: StreamParticle[];
|
||||
isLoading: boolean;
|
||||
networkId: string;
|
||||
/** True when more closed streams may exist beyond the current window. */
|
||||
canLoadMore: boolean;
|
||||
/** Extend the pagination window. No-op on the open tab. */
|
||||
loadMore: () => void;
|
||||
}
|
||||
|
||||
export function useStreamParticles(
|
||||
path: ParticlePath,
|
||||
{ status }: UseStreamParticlesOptions,
|
||||
): UseStreamParticlesResult {
|
||||
const { networkId } = parseParticlePath(path);
|
||||
const user = useAuthStore((s) => s.user);
|
||||
const visibilityScopes = useVisibilityScopes(user?.id, networkId);
|
||||
|
||||
const [closedLimit, setClosedLimit] = useState(CLOSED_INITIAL_PAGE_SIZE);
|
||||
|
||||
// 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") {
|
||||
setClosedLimit(CLOSED_INITIAL_PAGE_SIZE);
|
||||
}
|
||||
}, [status]);
|
||||
|
||||
const whereFilter: QueryFieldFilterConstraint =
|
||||
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",
|
||||
visibilityScopes,
|
||||
whereFilter,
|
||||
limit,
|
||||
});
|
||||
|
||||
const streams = useMemo(
|
||||
() => 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 loadMore = useCallback(() => {
|
||||
if (status !== "closed") return;
|
||||
setClosedLimit((prev) => prev + CLOSED_PAGE_INCREMENT);
|
||||
}, [status]);
|
||||
|
||||
return { streams, isLoading, networkId, canLoadMore, loadMore };
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
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";
|
||||
|
||||
interface PlaybackState {
|
||||
currentParticleId: string | null;
|
||||
status: PlaybackStatus;
|
||||
initialized: boolean;
|
||||
}
|
||||
|
||||
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 };
|
||||
|
||||
const initialState: PlaybackState = {
|
||||
currentParticleId: null,
|
||||
status: "idle",
|
||||
initialized: false,
|
||||
};
|
||||
|
||||
function playbackReducer(state: PlaybackState, action: PlaybackAction): PlaybackState {
|
||||
switch (action.type) {
|
||||
case "INIT":
|
||||
return {
|
||||
currentParticleId: action.particleId,
|
||||
status: "playing",
|
||||
initialized: true,
|
||||
};
|
||||
case "SET_PARTICLE":
|
||||
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":
|
||||
if (action.removedParticleId !== state.currentParticleId) return state;
|
||||
if (action.fallbackParticleId) {
|
||||
return { ...state, currentParticleId: action.fallbackParticleId, status: "playing" };
|
||||
}
|
||||
return { ...state, currentParticleId: null, status: "idle" };
|
||||
}
|
||||
}
|
||||
|
||||
// --- Init timeout ---
|
||||
|
||||
const INIT_FALLBACK_TIMEOUT_MS = 5000;
|
||||
|
||||
// --- Hook ---
|
||||
|
||||
interface UseStreamPlaybackResult {
|
||||
children: Particle[];
|
||||
currentParticle: Particle | null;
|
||||
currentIndex: number;
|
||||
status: PlaybackStatus;
|
||||
initialized: boolean;
|
||||
next: () => void;
|
||||
prev: () => void;
|
||||
goTo: (index: number) => void;
|
||||
goToParticle: (particleId: string) => void;
|
||||
}
|
||||
|
||||
export function useStreamPlayback(
|
||||
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);
|
||||
|
||||
// --- Firestore change callbacks ---
|
||||
const onParticleAdded = useCallback((particle: Particle) => {
|
||||
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
|
||||
}
|
||||
);
|
||||
|
||||
// Derive current index and particle from ID
|
||||
const currentIndex = useMemo(() => {
|
||||
if (!state.currentParticleId) return -1;
|
||||
return children.findIndex((c) => c.id === state.currentParticleId);
|
||||
}, [children, state.currentParticleId]);
|
||||
|
||||
const currentParticle = currentIndex !== -1 ? children[currentIndex] : null;
|
||||
|
||||
// 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 });
|
||||
});
|
||||
|
||||
// --- 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) {
|
||||
initializedForRef.current = null;
|
||||
}
|
||||
|
||||
// Already initialized for this stream
|
||||
if (state.initialized && initializedForRef.current === streamParticle.id) return;
|
||||
|
||||
if (children.length === 0) return;
|
||||
|
||||
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 });
|
||||
return;
|
||||
}
|
||||
|
||||
// Try to find the marker's target particle
|
||||
const found = children.find(
|
||||
(c) => c.created_at.getTime() > playbackPosition.getTime(),
|
||||
);
|
||||
|
||||
if (found) {
|
||||
initializedForRef.current = streamParticle.id;
|
||||
dispatch({ type: "INIT", particleId: found.id });
|
||||
return;
|
||||
} else {
|
||||
initializedForRef.current = streamParticle.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]);
|
||||
|
||||
// --- Persist playback marker (only advance forward, never backwards) ---
|
||||
const lastPersistedMarkerRef = useRef<Date | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!userId || !state.initialized || !currentParticle) return;
|
||||
|
||||
const currentTime = currentParticle.created_at;
|
||||
const existingMarker =
|
||||
lastPersistedMarkerRef.current ?? streamParticle.playback_markers?.[userId];
|
||||
|
||||
// Only update if advancing beyond the current marker
|
||||
if (existingMarker && currentTime.getTime() <= existingMarker.getTime()) return;
|
||||
|
||||
lastPersistedMarkerRef.current = currentTime;
|
||||
const streamDocPath = toFirestoreDocPath(path);
|
||||
updateStreamPlaybackMarker(streamDocPath, userId, currentTime);
|
||||
}, [currentParticle?.id, 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 });
|
||||
} else {
|
||||
dispatch({ type: "END" });
|
||||
}
|
||||
}, [children, currentIndex]);
|
||||
|
||||
const prev = useCallback(() => {
|
||||
if (currentIndex <= 0) return;
|
||||
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 });
|
||||
}
|
||||
},
|
||||
[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 });
|
||||
}, []);
|
||||
|
||||
return {
|
||||
children,
|
||||
currentParticle,
|
||||
currentIndex,
|
||||
status: state.status,
|
||||
initialized: state.initialized,
|
||||
next,
|
||||
prev,
|
||||
goTo,
|
||||
goToParticle,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { useEffect, useId } from "react";
|
||||
import { usePlaybackPauseStore } from "@/stores/playback-pause-store";
|
||||
|
||||
/**
|
||||
* Suspend stream playback while `active` is true. The hook owns its own
|
||||
* registration id; multiple instances compose. `label` is for devtools only.
|
||||
*/
|
||||
export function useSuspendPlayback(active: boolean, label: string) {
|
||||
const id = useId();
|
||||
|
||||
useEffect(() => {
|
||||
if (!active) return;
|
||||
const { add, remove } = usePlaybackPauseStore.getState();
|
||||
add(id, label);
|
||||
return () => remove(id);
|
||||
}, [active, id, label]);
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { useMemo } from "react";
|
||||
import type { Transcript } from "@/api/types";
|
||||
|
||||
type Sentence = Transcript["paragraphs"][number]["sentences"][number];
|
||||
|
||||
interface TranscriptPlaybackState {
|
||||
/** The sentence currently being spoken, or null if between sentences */
|
||||
activeSentence: Sentence | null;
|
||||
/** Index of the active word within the transcript's flat words array */
|
||||
activeWordIndex: number | null;
|
||||
}
|
||||
|
||||
export function useTranscriptPlayback(
|
||||
transcript: Transcript | undefined,
|
||||
currentTime: number,
|
||||
): TranscriptPlaybackState {
|
||||
return useMemo(() => {
|
||||
if (!transcript) return { activeSentence: null, activeWordIndex: null };
|
||||
|
||||
// Find the active sentence across all paragraphs
|
||||
let activeSentence: Sentence | null = null;
|
||||
for (const paragraph of transcript.paragraphs) {
|
||||
const sentence = paragraph.sentences.find(
|
||||
(s) => currentTime >= s.start && currentTime <= s.end,
|
||||
);
|
||||
if (sentence) {
|
||||
activeSentence = sentence;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Binary search for active word
|
||||
const words = transcript.words;
|
||||
let activeWordIndex: number | null = null;
|
||||
let lo = 0;
|
||||
let hi = words.length - 1;
|
||||
while (lo <= hi) {
|
||||
const mid = (lo + hi) >>> 1;
|
||||
if (currentTime < words[mid].start) hi = mid - 1;
|
||||
else if (currentTime > words[mid].end) lo = mid + 1;
|
||||
else {
|
||||
activeWordIndex = mid;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return { activeSentence, activeWordIndex };
|
||||
}, [transcript, currentTime]);
|
||||
}
|
||||
Reference in New Issue
Block a user