feat: stream list view and tasks (#279)
* first attempt at stream sidebar, tasks, and events * fix folder from root * cleanup folders and events, and condense changes * cleanup and add toggle for sidebar * cleanup * fix nits
This commit was merged in pull request #279.
This commit is contained in:
@@ -0,0 +1,121 @@
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import { where } 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 } from '@/api/types';
|
||||
|
||||
const INITIAL_PAGE_SIZE = 9;
|
||||
const PAGE_INCREMENT = 9;
|
||||
|
||||
// Stable references so the Firestore subscriptions don't re-attach per render.
|
||||
const OPEN_STATUS_FILTER = where('status', '==', 'open');
|
||||
const CLOSED_STATUS_FILTER = where('status', '==', 'closed');
|
||||
const CONTAINER_TYPE_FILTER = where('type', 'in', ['stream', 'folder']);
|
||||
const LEAF_TYPE_FILTER = where('type', 'in', [
|
||||
'media',
|
||||
'file',
|
||||
'text',
|
||||
'task',
|
||||
'paper',
|
||||
]);
|
||||
|
||||
interface UseContainerChildrenOptions {
|
||||
/**
|
||||
* When set, list only streams with this status, filtered server-side like
|
||||
* the pre-folder query (reuses its composite indexes). Used by the network
|
||||
* root while folders are shelved; folders keep the full mixed-type behavior
|
||||
* so the container model can be picked back up later. Only streams carry
|
||||
* `status`, so the filter excludes other types by itself.
|
||||
*/
|
||||
streamStatus?: 'open' | 'closed';
|
||||
}
|
||||
|
||||
interface UseContainerChildrenResult {
|
||||
/** All visible children, sorted by latest activity (then creation). */
|
||||
items: Particle[];
|
||||
isLoading: boolean;
|
||||
canLoadMore: boolean;
|
||||
loadMore: () => void;
|
||||
}
|
||||
|
||||
function activityTime(particle: Particle): number {
|
||||
if (
|
||||
(particle.type === 'stream' || particle.type === 'folder') &&
|
||||
particle.last_child_created_at
|
||||
) {
|
||||
return particle.last_child_created_at.getTime();
|
||||
}
|
||||
return particle.created_at.getTime();
|
||||
}
|
||||
|
||||
/**
|
||||
* Children of a container (network root or folder), all particle types.
|
||||
*
|
||||
* Two merged subscriptions because visibility scoping filters on `visible_to`
|
||||
* with array-contains-any, and leaf particles don't carry that field — a
|
||||
* single scoped query would silently exclude them. Containers (streams,
|
||||
* folders) are visibility-scoped; leaves inherit access from their container.
|
||||
*/
|
||||
export function useContainerChildren(
|
||||
path: ParticlePath,
|
||||
{ streamStatus }: UseContainerChildrenOptions = {},
|
||||
): UseContainerChildrenResult {
|
||||
const { networkId } = parseParticlePath(path);
|
||||
const userId = useAuthStore((s) => s.user?.id);
|
||||
|
||||
const visibilityScopes = useMemo(() => {
|
||||
const scopes: string[] = [];
|
||||
if (userId) scopes.push(`human:${userId}`);
|
||||
scopes.push(`network:${networkId}`);
|
||||
return scopes;
|
||||
}, [userId, networkId]);
|
||||
|
||||
const [limit, setLimit] = useState(INITIAL_PAGE_SIZE);
|
||||
|
||||
const containerFilter = streamStatus
|
||||
? streamStatus === 'open'
|
||||
? OPEN_STATUS_FILTER
|
||||
: CLOSED_STATUS_FILTER
|
||||
: CONTAINER_TYPE_FILTER;
|
||||
|
||||
const { children: containers, isLoading: containersLoading } =
|
||||
useLiveParticleChildren(path, {
|
||||
orderByField: 'last_child_created_at',
|
||||
orderDirection: 'desc',
|
||||
visibilityScopes,
|
||||
whereFilter: containerFilter,
|
||||
limit,
|
||||
});
|
||||
|
||||
// Passing undefined disables the subscription entirely in streams-only mode.
|
||||
const { children: leaves, isLoading: leavesLoading } =
|
||||
useLiveParticleChildren(streamStatus ? undefined : path, {
|
||||
orderByField: 'created_at',
|
||||
orderDirection: 'desc',
|
||||
whereFilter: LEAF_TYPE_FILTER,
|
||||
limit,
|
||||
});
|
||||
|
||||
const items = useMemo(
|
||||
() =>
|
||||
[...containers, ...leaves].sort(
|
||||
(a, b) => activityTime(b) - activityTime(a),
|
||||
),
|
||||
[containers, leaves],
|
||||
);
|
||||
|
||||
// Heuristic: a query returning a full page may have more behind it.
|
||||
const canLoadMore = containers.length >= limit || leaves.length >= limit;
|
||||
|
||||
const loadMore = useCallback(() => {
|
||||
setLimit((prev) => prev + PAGE_INCREMENT);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
items,
|
||||
isLoading: containersLoading || leavesLoading,
|
||||
canLoadMore,
|
||||
loadMore,
|
||||
};
|
||||
}
|
||||
@@ -29,6 +29,8 @@ interface CreateParticleParams<T extends ParticleType = ParticleType> {
|
||||
type: T;
|
||||
properties: ParticlePropertiesMap[T];
|
||||
createdByHumanId: string;
|
||||
// Required for container types
|
||||
visibleTo?: string[];
|
||||
}
|
||||
|
||||
export function useCreateParticle() {
|
||||
@@ -59,6 +61,7 @@ export function useCreateParticle() {
|
||||
params.type,
|
||||
params.properties,
|
||||
params.createdByHumanId,
|
||||
params.visibleTo,
|
||||
);
|
||||
|
||||
if (!CONTAINER_TYPES.has(params.type)) {
|
||||
@@ -76,15 +79,17 @@ type CreateStreamParticleParams = {
|
||||
properties: ParticlePropertiesMap['stream'];
|
||||
createdByHumanId: string;
|
||||
visibleTo?: string[];
|
||||
// Container to create the stream in; defaults to the network root.
|
||||
parentPath?: ParticlePath;
|
||||
};
|
||||
|
||||
export function useCreateStreamParticle() {
|
||||
return useMutation({
|
||||
mutationFn: async (params: CreateStreamParticleParams) => {
|
||||
const path = particlePath(params.networkId, []);
|
||||
const networkCollectionPath = toFirestoreChildrenPath(path);
|
||||
const path = params.parentPath ?? particlePath(params.networkId, []);
|
||||
const collectionPath = toFirestoreChildrenPath(path);
|
||||
return await createStreamParticle(
|
||||
networkCollectionPath,
|
||||
collectionPath,
|
||||
params.properties,
|
||||
params.createdByHumanId,
|
||||
params.visibleTo,
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
|
||||
const TICK_MS = 100;
|
||||
|
||||
interface UseFixedDwellOptions {
|
||||
/** Reset key — restarts the timer when it changes (particle id). */
|
||||
id: string;
|
||||
durationS: number;
|
||||
paused: boolean;
|
||||
onEnded: () => void;
|
||||
onProgress?: (ratio: number) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Drives a fixed-duration dwell timer for particles without intrinsic
|
||||
* playback (text, tasks, events): reports progress and fires onEnded once
|
||||
* the duration elapses. Pausing freezes elapsed time rather than resetting.
|
||||
*/
|
||||
export function useFixedDwell({
|
||||
id,
|
||||
durationS,
|
||||
paused,
|
||||
onEnded,
|
||||
onProgress,
|
||||
}: UseFixedDwellOptions): void {
|
||||
const elapsedRef = useRef(0);
|
||||
|
||||
useEffect(() => {
|
||||
elapsedRef.current = 0;
|
||||
}, [id]);
|
||||
|
||||
useEffect(() => {
|
||||
if (paused) return;
|
||||
|
||||
const interval = setInterval(() => {
|
||||
elapsedRef.current += TICK_MS / 1000;
|
||||
const ratio = Math.min(elapsedRef.current / durationS, 1);
|
||||
onProgress?.(ratio);
|
||||
|
||||
if (ratio >= 1) {
|
||||
clearInterval(interval);
|
||||
onEnded();
|
||||
}
|
||||
}, TICK_MS);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [paused, durationS, onEnded, onProgress, id]);
|
||||
}
|
||||
+25
-21
@@ -1,29 +1,33 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useMediaSettingsStore } from '@/stores/media-settings-store';
|
||||
|
||||
interface UseStreamKeyboardNavOptions {
|
||||
streams: Array<{ id: string }>;
|
||||
interface UseListKeyboardNavOptions {
|
||||
items: Array<{ id: string }>;
|
||||
enabled: boolean;
|
||||
onNavigate: (streamId: string) => void;
|
||||
onOpen: (id: string) => void;
|
||||
}
|
||||
|
||||
export function useStreamKeyboardNav({
|
||||
streams,
|
||||
/**
|
||||
* Shared keyboard grammar for browsable lists (network root, folders):
|
||||
* ↑/↓ move selection, Enter opens, 1–9 jump, V toggles video/audio.
|
||||
*/
|
||||
export function useListKeyboardNav({
|
||||
items,
|
||||
enabled,
|
||||
onNavigate,
|
||||
}: UseStreamKeyboardNavOptions) {
|
||||
onOpen,
|
||||
}: UseListKeyboardNavOptions) {
|
||||
const [selectedIndex, setSelectedIndex] = useState<number | null>(
|
||||
streams.length > 0 ? 0 : null,
|
||||
items.length > 0 ? 0 : null,
|
||||
);
|
||||
const [prevStreamCount, setPrevStreamCount] = useState(streams.length);
|
||||
const [prevItemCount, setPrevItemCount] = useState(items.length);
|
||||
const recordingMode = useMediaSettingsStore((s) => s.recordingMode);
|
||||
const setRecordingMode = useMediaSettingsStore((s) => s.setRecordingMode);
|
||||
|
||||
// Select the first stream once they load and clear when empty — but not on
|
||||
// Select the first item 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) {
|
||||
if (items.length !== prevItemCount) {
|
||||
setPrevItemCount(items.length);
|
||||
if (items.length === 0) {
|
||||
setSelectedIndex(null);
|
||||
} else if (selectedIndex === null) {
|
||||
setSelectedIndex(0);
|
||||
@@ -31,7 +35,7 @@ export function useStreamKeyboardNav({
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled || streams.length === 0) return;
|
||||
if (!enabled || items.length === 0) return;
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.defaultPrevented) return;
|
||||
@@ -49,19 +53,19 @@ export function useStreamKeyboardNav({
|
||||
const digit = parseInt(e.key, 10);
|
||||
if (digit >= 1 && digit <= 9) {
|
||||
const index = digit - 1;
|
||||
if (index < streams.length) {
|
||||
if (index < items.length) {
|
||||
e.preventDefault();
|
||||
onNavigate(streams[index].id);
|
||||
onOpen(items[index].id);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Enter: navigate to selected
|
||||
// Enter: open selected
|
||||
if (e.key === 'Enter') {
|
||||
setSelectedIndex((idx) => {
|
||||
if (idx !== null && idx < streams.length) {
|
||||
if (idx !== null && idx < items.length) {
|
||||
e.preventDefault();
|
||||
onNavigate(streams[idx].id);
|
||||
onOpen(items[idx].id);
|
||||
}
|
||||
return idx;
|
||||
});
|
||||
@@ -86,7 +90,7 @@ export function useStreamKeyboardNav({
|
||||
setSelectedIndex((prev) => {
|
||||
if (prev === null) return 0;
|
||||
const next = prev + delta;
|
||||
return Math.max(0, Math.min(next, streams.length - 1));
|
||||
return Math.max(0, Math.min(next, items.length - 1));
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -95,7 +99,7 @@ export function useStreamKeyboardNav({
|
||||
// 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]);
|
||||
}, [enabled, items, onOpen, recordingMode, setRecordingMode]);
|
||||
|
||||
return { selectedIndex };
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
|
||||
const DEFAULT_DEBOUNCE_MS = 600;
|
||||
|
||||
interface UseLiveDraftFieldOptions {
|
||||
remoteValue: string;
|
||||
commit: (value: string) => void | Promise<void>;
|
||||
debounceMs?: number;
|
||||
}
|
||||
|
||||
export interface LiveDraftField {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
onFocus: () => void;
|
||||
onBlur: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* State for a multiplayer always-editable text field: local draft while
|
||||
* typing, debounced write-through, immediate flush on blur. Incoming remote
|
||||
* values are applied only while the field is unfocused, so collaborators'
|
||||
* snapshot updates never clobber in-progress typing (blur flushes pending
|
||||
* writes synchronously, so unfocused implies no pending draft). Concurrent
|
||||
* edits to the same field are last-write-wins.
|
||||
*/
|
||||
export function useLiveDraftField({
|
||||
remoteValue,
|
||||
commit,
|
||||
debounceMs = DEFAULT_DEBOUNCE_MS,
|
||||
}: UseLiveDraftFieldOptions): LiveDraftField {
|
||||
const [value, setValue] = useState(remoteValue);
|
||||
const [focused, setFocused] = useState(false);
|
||||
|
||||
// Sync from remote during render (derived-state pattern) unless the user
|
||||
// is editing the field.
|
||||
const [prevRemote, setPrevRemote] = useState(remoteValue);
|
||||
if (remoteValue !== prevRemote) {
|
||||
setPrevRemote(remoteValue);
|
||||
if (!focused) {
|
||||
setValue(remoteValue);
|
||||
}
|
||||
}
|
||||
|
||||
// Written only in handlers; read by flush() so its identity stays stable.
|
||||
const valueRef = useRef(remoteValue);
|
||||
const pendingRef = useRef(false);
|
||||
const timerRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
|
||||
const commitRef = useRef(commit);
|
||||
useEffect(() => {
|
||||
commitRef.current = commit;
|
||||
}, [commit]);
|
||||
|
||||
const flush = useCallback(() => {
|
||||
clearTimeout(timerRef.current);
|
||||
timerRef.current = undefined;
|
||||
if (!pendingRef.current) return;
|
||||
pendingRef.current = false;
|
||||
void commitRef.current(valueRef.current);
|
||||
}, []);
|
||||
|
||||
// Flush any pending write when the field unmounts (e.g. playback advances).
|
||||
useEffect(() => flush, [flush]);
|
||||
|
||||
const onChange = useCallback(
|
||||
(next: string) => {
|
||||
valueRef.current = next;
|
||||
setValue(next);
|
||||
pendingRef.current = true;
|
||||
clearTimeout(timerRef.current);
|
||||
timerRef.current = setTimeout(flush, debounceMs);
|
||||
},
|
||||
[debounceMs, flush],
|
||||
);
|
||||
|
||||
const onFocus = useCallback(() => {
|
||||
setFocused(true);
|
||||
}, []);
|
||||
|
||||
const onBlur = useCallback(() => {
|
||||
setFocused(false);
|
||||
flush();
|
||||
}, [flush]);
|
||||
|
||||
return { value, onChange, onFocus, onBlur };
|
||||
}
|
||||
@@ -1,4 +1,10 @@
|
||||
import { useEffect, useRef, useState, type RefObject } from 'react';
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
type RefObject,
|
||||
} from 'react';
|
||||
import type { MediaParticleHandle } from '@/features/particles/media-particle-view';
|
||||
import { useSuspendPlayback } from '@/hooks/use-suspend-playback';
|
||||
import {
|
||||
@@ -15,6 +21,10 @@ interface UsePlaybackKeysOptions {
|
||||
|
||||
interface UsePlaybackKeysResult {
|
||||
fastPlayback: boolean;
|
||||
/** True while playback is paused by the user's space toggle/hold. */
|
||||
spacePaused: boolean;
|
||||
/** Clear the user's space pause (e.g. clicking the on-screen play button). */
|
||||
resume: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -31,6 +41,11 @@ export function usePlaybackKeys({
|
||||
|
||||
useSuspendPlayback(spaceHeld, 'hold-space');
|
||||
|
||||
const resume = useCallback(() => {
|
||||
setSpaceHeld(false);
|
||||
spaceStartRef.current = 0;
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const isExternallyPaused = () =>
|
||||
selectIsPaused(usePlaybackPauseStore.getState()) && !spaceHeld;
|
||||
@@ -92,5 +107,5 @@ export function usePlaybackKeys({
|
||||
};
|
||||
}, [mediaRef, spaceHeld]);
|
||||
|
||||
return { fastPlayback };
|
||||
return { fastPlayback, spacePaused: spaceHeld, resume };
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ interface UseStreamNavigationKeysOptions {
|
||||
childrenLength: number;
|
||||
mediaRef: RefObject<MediaParticleHandle | null>;
|
||||
onExit: () => void;
|
||||
onToggleViewMode: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -24,6 +25,7 @@ export function useStreamNavigationKeys({
|
||||
childrenLength,
|
||||
mediaRef,
|
||||
onExit,
|
||||
onToggleViewMode,
|
||||
}: UseStreamNavigationKeysOptions) {
|
||||
useEffect(() => {
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
@@ -54,10 +56,25 @@ export function useStreamNavigationKeys({
|
||||
e.preventDefault();
|
||||
onExit();
|
||||
break;
|
||||
case 'l':
|
||||
case 'L':
|
||||
// Registered here (not in action keys) because the toggle must work
|
||||
// in list mode, where playback is suspended and action keys bail.
|
||||
e.preventDefault();
|
||||
onToggleViewMode();
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('keydown', onKeyDown);
|
||||
return () => window.removeEventListener('keydown', onKeyDown);
|
||||
}, [next, prev, currentIndex, childrenLength, mediaRef, onExit]);
|
||||
}, [
|
||||
next,
|
||||
prev,
|
||||
currentIndex,
|
||||
childrenLength,
|
||||
mediaRef,
|
||||
onExit,
|
||||
onToggleViewMode,
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -1,80 +0,0 @@
|
||||
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;
|
||||
};
|
||||
|
||||
const INITIAL_PAGE_SIZE = 12;
|
||||
const PAGE_INCREMENT = 12;
|
||||
|
||||
// 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
|
||||
status: 'open' | 'closed';
|
||||
}
|
||||
|
||||
interface UseStreamParticlesResult {
|
||||
streams: StreamParticle[];
|
||||
isLoading: boolean;
|
||||
networkId: string;
|
||||
/** True when more streams may exist beyond the current window. */
|
||||
canLoadMore: boolean;
|
||||
/** Extend the pagination window. */
|
||||
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 [limit, setLimit] = useState(INITIAL_PAGE_SIZE);
|
||||
|
||||
const whereFilter: QueryFieldFilterConstraint =
|
||||
status === 'open' ? OPEN_STATUS_FILTER : CLOSED_STATUS_FILTER;
|
||||
|
||||
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 = streams.length >= limit;
|
||||
|
||||
const loadMore = useCallback(() => {
|
||||
setLimit((prev) => prev + PAGE_INCREMENT);
|
||||
}, []);
|
||||
|
||||
return { streams, isLoading, networkId, canLoadMore, loadMore };
|
||||
}
|
||||
@@ -98,12 +98,26 @@ interface UseStreamPlaybackResult {
|
||||
goToParticle: (particleId: string) => void;
|
||||
}
|
||||
|
||||
interface UseStreamPlaybackOptions {
|
||||
/**
|
||||
* When false, newly arriving particles don't pull playback forward after
|
||||
* it has ended (list mode browses; selection must stay put). Default true.
|
||||
*/
|
||||
autoAdvanceOnNew?: boolean;
|
||||
}
|
||||
|
||||
export function useStreamPlayback(
|
||||
streamParticle: Particle & { type: 'stream' },
|
||||
path: ParticlePath,
|
||||
{ autoAdvanceOnNew = true }: UseStreamPlaybackOptions = {},
|
||||
): UseStreamPlaybackResult {
|
||||
const userId = useAuthStore((s) => s.user?.id);
|
||||
const [state, dispatch] = useReducer(playbackReducer, initialState);
|
||||
// Read via ref so the onAdded subscription callback stays stable.
|
||||
const autoAdvanceOnNewRef = useRef(autoAdvanceOnNew);
|
||||
useEffect(() => {
|
||||
autoAdvanceOnNewRef.current = autoAdvanceOnNew;
|
||||
}, [autoAdvanceOnNew]);
|
||||
// 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
|
||||
@@ -115,6 +129,7 @@ export function useStreamPlayback(
|
||||
|
||||
// --- Firestore change callbacks ---
|
||||
const onParticleAdded = useCallback((particle: Particle) => {
|
||||
if (!autoAdvanceOnNewRef.current) return;
|
||||
dispatch({ type: 'PARTICLE_ADDED', particleId: particle.id });
|
||||
}, []);
|
||||
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import { useCallback, useState } from 'react';
|
||||
import type { Particle } from '@/api/types';
|
||||
|
||||
export type StreamViewMode = 'player' | 'list';
|
||||
|
||||
function decideMode(
|
||||
streamParticle: Particle & { type: 'stream' },
|
||||
userId: string | undefined,
|
||||
): StreamViewMode {
|
||||
const marker = userId ? streamParticle.playback_markers?.[userId] : undefined;
|
||||
const lastChildAt = streamParticle.last_child_created_at;
|
||||
const caughtUp =
|
||||
!!marker && !!lastChildAt && lastChildAt.getTime() <= marker.getTime();
|
||||
return caughtUp ? 'list' : 'player';
|
||||
}
|
||||
|
||||
/**
|
||||
* Which mode a stream opens in: the player (autoplay catch-up) when there's
|
||||
* unseen content, the browsable list when the user is fully caught up.
|
||||
* Decided once on entry from the playback marker vs. the stream's last
|
||||
* activity — browsing afterwards advances the marker, but the mode only
|
||||
* changes via the user's toggle.
|
||||
*/
|
||||
export function useStreamViewMode(
|
||||
streamParticle: Particle & { type: 'stream' },
|
||||
userId: string | undefined,
|
||||
): { mode: StreamViewMode; toggle: () => void } {
|
||||
const [mode, setMode] = useState<StreamViewMode>(() =>
|
||||
decideMode(streamParticle, userId),
|
||||
);
|
||||
|
||||
// Re-decide when navigating between streams without an unmount.
|
||||
const [prevStreamId, setPrevStreamId] = useState(streamParticle.id);
|
||||
if (prevStreamId !== streamParticle.id) {
|
||||
setPrevStreamId(streamParticle.id);
|
||||
setMode(decideMode(streamParticle, userId));
|
||||
}
|
||||
|
||||
const toggle = useCallback(() => {
|
||||
setMode((prev) => (prev === 'player' ? 'list' : 'player'));
|
||||
}, []);
|
||||
|
||||
return { mode, toggle };
|
||||
}
|
||||
Reference in New Issue
Block a user