import { h } from 'preact'; import ActivityIndicator from './components/ActivityIndicator'; import Card from './components/Card'; import Heading from './components/Heading'; import Link from './components/Link'; import Select from './components/Select'; import produce from 'immer'; import { route } from 'preact-router'; import { FetchStatus, useApiHost, useConfig, useEvents } from './api'; import { Table, Thead, Tbody, Tfoot, Th, Tr, Td } from './components/Table'; import { useCallback, useContext, useEffect, useMemo, useRef, useReducer, useState } from 'preact/hooks'; const API_LIMIT = 25; const initialState = Object.freeze({ events: [], reachedEnd: false, searchStrings: {} }); const reducer = (state = initialState, action) => { switch (action.type) { case 'APPEND_EVENTS': { const { meta: { searchString }, payload, } = action; return produce(state, (draftState) => { draftState.searchStrings[searchString] = true; draftState.events.push(...payload); }); } case 'REACHED_END': { const { meta: { searchString }, } = action; return produce(state, (draftState) => { draftState.reachedEnd = true; draftState.searchStrings[searchString] = true; }); } case 'RESET': return initialState; default: return state; } }; const defaultSearchString = `include_thumbnails=0&limit=${API_LIMIT}`; function removeDefaultSearchKeys(searchParams) { searchParams.delete('limit'); searchParams.delete('include_thumbnails'); searchParams.delete('before'); } export default function Events({ path: pathname } = {}) { const apiHost = useApiHost(); const [{ events, reachedEnd, searchStrings }, dispatch] = useReducer(reducer, initialState); const { searchParams: initialSearchParams } = new URL(window.location); const [searchString, setSearchString] = useState(`${defaultSearchString}&${initialSearchParams.toString()}`); const { data, status } = useEvents(searchString); useEffect(() => { if (data && !(searchString in searchStrings)) { dispatch({ type: 'APPEND_EVENTS', payload: data, meta: { searchString } }); } if (Array.isArray(data) && data.length < API_LIMIT) { dispatch({ type: 'REACHED_END', meta: { searchString } }); } }, [data]); const observer = useRef( new IntersectionObserver((entries, observer) => { window.requestAnimationFrame(() => { if (entries.length === 0) { return; } // under certain edge cases, a ref may be applied / in memory twice // avoid fetching twice by grabbing the last observed entry only const entry = entries[entries.length - 1]; if (entry.isIntersecting) { const { startTime } = entry.target.dataset; const { searchParams } = new URL(window.location); searchParams.set('before', parseFloat(startTime) - 0.0001); setSearchString(`${defaultSearchString}&${searchParams.toString()}`); } }); }) ); const lastCellRef = useCallback( (node) => { if (node !== null) { observer.current.disconnect(); if (!reachedEnd) { observer.current.observe(node); } } }, [observer.current, reachedEnd] ); const handleFilter = useCallback( (searchParams) => { dispatch({ type: 'RESET' }); removeDefaultSearchKeys(searchParams); setSearchString(`${defaultSearchString}&${searchParams.toString()}`); route(`${pathname}?${searchParams.toString()}`); }, [pathname, setSearchString] ); const searchParams = useMemo(() => new URLSearchParams(searchString), [searchString]); return (