import { useEffect, useMemo, useState } from 'react'; import { filterMedia, mediaAlt, mediaConversation, type MediaFilters } from './filters.js'; import { FullSyncDialog } from './full-sync-dialog.js'; import { MediaCard } from './media-card.js'; import { MediaPreviewDialog, type MediaPreviewEntry } from './media-preview-dialog.js'; import { useMediaQuery, useMediaSyncStatusQuery, useTriggerMediaSyncMutation } from './queries.js'; import './media.css'; export const initialMediaFilters: MediaFilters = { conversation: 'all', sender: 'all', type: 'all', dateRange: 'all', }; const PAGE_SIZE = 48; const SUCCESS_NOTICE_MS = 5_000; function errorMessage(error: Error | null): string { return error?.message ?? 'Something went wrong.'; } function formatLastSync(timestamp: number): string { return new Intl.DateTimeFormat(undefined, { dateStyle: 'medium', timeStyle: 'short' }).format(timestamp); } export interface MediaPageProps { filters?: MediaFilters; onFiltersChange?(filters: MediaFilters): void; } export function MediaPage({ filters: controlledFilters, onFiltersChange }: MediaPageProps) { const mediaQuery = useMediaQuery(); const statusQuery = useMediaSyncStatusQuery(); const syncMutation = useTriggerMediaSyncMutation(); const [localFilters, setLocalFilters] = useState(initialMediaFilters); const filters = controlledFilters ?? localFilters; const [revealedIds, setRevealedIds] = useState>(() => new Set()); const [isRevealAll, setIsRevealAll] = useState(false); const [isFullSyncDialogOpen, setIsFullSyncDialogOpen] = useState(false); const [previewItemId, setPreviewItemId] = useState(null); const [visibleLimit, setVisibleLimit] = useState(PAGE_SIZE); const items = mediaQuery.data ?? []; const visibleItems = useMemo(() => filterMedia(items, filters, Date.now()), [items, filters]); const displayedItems = visibleItems.slice(0, visibleLimit); const conversations = useMemo( () => [...new Set(items.map(mediaConversation))].sort((left, right) => left.localeCompare(right)), [items], ); const senders = useMemo( () => [...new Set(items.map((item) => item.sender))].sort((left, right) => left.localeCompare(right)), [items], ); const isSyncBusy = statusQuery.data?.isSyncing === true || syncMutation.isPending; const isFiltered = filters.conversation !== 'all' || filters.sender !== 'all' || filters.type !== 'all' || filters.dateRange !== 'all'; const previewIndex = previewItemId === null ? -1 : visibleItems.findIndex((item) => item.id === previewItemId); const previewEntries: MediaPreviewEntry[] = useMemo( () => visibleItems.map((item) => ({ id: item.id, src: item.urlPath, alt: mediaAlt(item), label: `Media from ${item.sender}`, captionTitle: `u/${item.sender}`, captionSubtitle: formatLastSync(item.timestamp), })), [visibleItems], ); const isSyncSuccess = syncMutation.isSuccess; useEffect(() => { if (!isSyncSuccess) return; const timer = setTimeout(() => syncMutation.reset(), SUCCESS_NOTICE_MS); return () => clearTimeout(timer); // syncMutation is a fresh object every render; keying on isSuccess is intentional. // eslint-disable-next-line react-hooks/exhaustive-deps }, [isSyncSuccess]); function updateFilters(next: MediaFilters) { if (controlledFilters === undefined) setLocalFilters(next); onFiltersChange?.(next); setVisibleLimit(PAGE_SIZE); } function updateFilter(key: Key, value: MediaFilters[Key]) { updateFilters({ ...filters, [key]: value }); } function toggleItem(itemId: string) { if (isRevealAll) { setIsRevealAll(false); setRevealedIds(new Set(items.filter((item) => item.id !== itemId).map((item) => item.id))); return; } setRevealedIds((current) => { const next = new Set(current); if (next.has(itemId)) next.delete(itemId); else next.add(itemId); return next; }); } function runSync(isFull: boolean) { syncMutation.mutate(isFull, { onSuccess: () => { if (isFull) setIsFullSyncDialogOpen(false); }, onError: () => { if (isFull) setIsFullSyncDialogOpen(false); }, }); } if (mediaQuery.isPending) { return
Loading media…
; } if (mediaQuery.isError) { return (
Media unavailable {errorMessage(mediaQuery.error)}
); } return (
Private gallery

Media

Browse authenticated conversation media. Everything stays blurred until you choose to reveal it.

{items.length > 0 ? ( ) : null}
{statusQuery.isPending ? Loading sync status… : null} {statusQuery.isError ? (
{errorMessage(statusQuery.error)}
) : null} {statusQuery.data ? ( statusQuery.data.isSyncing ? (
Media sync in progress
) : (
) ) : null} {statusQuery.data?.lastSyncAt ? {formatLastSync(statusQuery.data.lastSyncAt)} : null}
{statusQuery.data?.lastError ?
Last sync failed{statusQuery.data.lastError}
: null} {syncMutation.isSuccess ?
{syncMutation.variables ? 'Full media sync completed.' : 'Media sync completed.'}
: null} {syncMutation.isError ?
{errorMessage(syncMutation.error)}
: null} {items.length === 0 ? (
No media yet.Use Sync new media to check recent conversations.
) : ( <>
Showing {displayedItems.length} of {items.length} items {isFiltered ? ( ) : null}
{visibleItems.length === 0 ? (
No media matches these filters. Try broadening one or more filters.
) : ( <>
{displayedItems.map((item) => ( setPreviewItemId(item.id)} onToggleReveal={() => toggleItem(item.id)} /> ))}
{visibleItems.length > displayedItems.length ? (
) : null} )} )} {isFullSyncDialogOpen ? ( setIsFullSyncDialogOpen(false)} onConfirm={() => runSync(true)} /> ) : null} {previewIndex >= 0 ? ( setPreviewItemId(null)} onNavigate={(nextIndex) => setPreviewItemId(visibleItems[nextIndex]?.id ?? null)} /> ) : null}
); }