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:
Arjun Patel
2026-06-12 12:26:49 -07:00
committed by GitHub
parent a358774106
commit 095b9876f9
37 changed files with 2384 additions and 791 deletions
@@ -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,
};
}
+8 -3
View File
@@ -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,
+48
View File
@@ -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]);
}
@@ -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, 19 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 };
}
+17 -2
View File
@@ -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 };
}