From b758bade67c6e933e11683efbcee3c6d5dd88a9a Mon Sep 17 00:00:00 2001 From: Max Patiiuk Date: Sun, 19 Feb 2023 10:50:51 -0600 Subject: [PATCH] Properly handle side bar being collapsed Fixes #101 --- src/src/components/Charts/TimeChart.tsx | 24 ++- .../components/Contexts/CalendarsContext.tsx | 198 +++++++++++++----- .../Contexts/CurrentViewContext.tsx | 2 +- src/src/components/Molecules/Portal.tsx | 11 +- src/src/components/PowerTools/GhostEvents.tsx | 7 +- src/src/hooks/useStorage.tsx | 1 + 6 files changed, 168 insertions(+), 75 deletions(-) diff --git a/src/src/components/Charts/TimeChart.tsx b/src/src/components/Charts/TimeChart.tsx index ca8bcc3..219ff09 100644 --- a/src/src/components/Charts/TimeChart.tsx +++ b/src/src/components/Charts/TimeChart.tsx @@ -86,14 +86,17 @@ export function TimeChart({ - {calendars.map((calendar) => ( - - ))} + {calendars.map((calendar) => + // If calendar has just been unhidden, it's data might not be loaded yet + typeof times[calendar.id] === 'object' ? ( + + ) : undefined + )} @@ -238,7 +241,10 @@ function TotalsRow({ times }: { readonly times: IR }): JSX.Element { const totals = React.useMemo( () => Array.from({ length: DAY / HOUR }, (_, index) => - calendars.reduce((total, { id }) => total + times[id].hourly[index], 0) + calendars.reduce( + (total, { id }) => total + (times[id]?.hourly[index] ?? 0), + 0 + ) ), [times, calendars] ); diff --git a/src/src/components/Contexts/CalendarsContext.tsx b/src/src/components/Contexts/CalendarsContext.tsx index eb70dad..6222270 100644 --- a/src/src/components/Contexts/CalendarsContext.tsx +++ b/src/src/components/Contexts/CalendarsContext.tsx @@ -1,27 +1,44 @@ +import React from 'react'; + import { useAsyncState } from '../../hooks/useAsyncState'; +import { useSimpleStorage } from '../../hooks/useStorage'; import { ajax } from '../../utils/ajax'; -import React from 'react'; -import { filterArray, GetOrSet, RA } from '../../utils/types'; -import { formatUrl } from '../../utils/queryString'; -import { sortFunction, split } from '../../utils/utils'; +import { randomColor } from '../../utils/colors'; import { listen } from '../../utils/events'; +import { formatUrl } from '../../utils/queryString'; +import type { GetSet, RA } from '../../utils/types'; +import { filterArray } from '../../utils/types'; +import { debounce, sortFunction, split } from '../../utils/utils'; import { AuthContext } from './AuthContext'; -import { randomColor } from '../../utils/colors'; type RawCalendarListEntry = Pick< gapi.client.calendar.CalendarListEntry, - 'id' | 'summary' | 'primary' | 'backgroundColor' + 'backgroundColor' | 'id' | 'primary' | 'summary' >; export type CalendarListEntry = RawCalendarListEntry & { readonly backgroundColor: string; }; +const emptyObject = [] as const; + export function CalendarsSpy({ children, }: { readonly children: React.ReactNode; }): JSX.Element { + /* + * Cache the list of visible calendars so that we can use it if side menu + * is collapsed + */ + const [visibleCalendars, setVisibleCalendars] = useSimpleStorage( + 'visibleCalendars', + emptyObject, + 'local' + ); + + const isCacheEmpty = visibleCalendars === emptyObject; + const { token } = React.useContext(AuthContext); const isAuthenticated = typeof token === 'string'; const [calendars] = useAsyncState>( @@ -54,11 +71,16 @@ export function CalendarsSpy({ false ); - const [visibleCalendars, setVisibleCalendars] = React.useState< - RA | undefined - >(undefined); + /* + * If it's first time opening the extension, and the side bar is hidden, + * don't know which calendars are hidden so show all of them + */ + React.useEffect(() => { + if (isCacheEmpty && Array.isArray(calendars)) + setVisibleCalendars(calendars.map(({ id }) => id)); + }, [isCacheEmpty, calendars, setVisibleCalendars]); - useVisibilityChangeSpy(calendars, setVisibleCalendars); + useVisibilityChangeSpy(calendars, [visibleCalendars, setVisibleCalendars]); const filteredCalendars = React.useMemo( () => calendars?.filter(({ id }) => visibleCalendars?.includes(id)), @@ -76,74 +98,134 @@ export const CalendarsContext = React.createContext< >(undefined); CalendarsContext.displayName = 'CalendarsContext'; +function findSideBar(): HTMLElement | undefined { + const sideBar = document.querySelector('[role="complementary"]'); + return (sideBar?.querySelector('input[type="checkbox"]') ?? undefined) === + undefined + ? undefined + : (sideBar as HTMLElement) ?? undefined; +} + function useVisibilityChangeSpy( calendars: React.ContextType, - handleChange: GetOrSet | undefined>[1] + [visibleCalendars, setVisibleCalendars]: GetSet | undefined> ): void { - const [sideBar] = useAsyncState( - React.useCallback(async () => { - if (calendars === undefined) return; + const [sideBar, setSideBar] = React.useState(undefined); - const sideBar = await awaitElement(() => { - const sideBar = document.querySelector('[role="complementary"]'); - if ( - (sideBar?.querySelector('input[type="checkbox"]') ?? undefined) === - undefined - ) - return undefined; - else return sideBar ?? undefined; - }); - if (sideBar === undefined) console.error('Unable to locate the sidebar'); - return sideBar; - }, [calendars]), - false - ); + const visibleCalendarsRef = React.useRef(visibleCalendars); + const cacheLoaded = Array.isArray(visibleCalendars); + visibleCalendarsRef.current = visibleCalendars; + + /* + * When side menu is collapsed/expanded, 's class names change + * We can use that to detect when the sidebar is opened/closed + * Note, this may break in the future + */ React.useEffect(() => { - if (calendars === undefined || sideBar === undefined) return; + let sideBarRef: HTMLElement | undefined = undefined; - handleChange( - filterArray( - Array.from( - sideBar.querySelectorAll('input[type="checkbox"]'), - parseCheckbox - ) - ) - .filter(([_calendarId, checked]) => checked) - .map(([calendarId]) => calendarId) - ); + function handleChange(): void { + if (sideBarRef === undefined) { + sideBarRef = findSideBar(); + if (sideBarRef === undefined) return; + setSideBar(sideBarRef); + } + // If side bar is hidden, it has width of 0 + const isVisible = sideBarRef.offsetWidth > 0; + setSideBar(isVisible ? sideBarRef : undefined); + } + + handleChange(); + const observer = new MutationObserver(debounce(handleChange, 60)); + observer.observe(document.body, { + attributes: true, + attributeFilter: ['class'], + }); + return (): void => observer.disconnect(); + }, []); - function parseCheckbox( + const calendarsRef = React.useRef(calendars); + calendarsRef.current = calendars; + const parseCheckbox = React.useCallback( + ( checkbox: HTMLInputElement - ): readonly [id: string, checked: boolean] | undefined { - if (calendars === undefined) return undefined; + ): readonly [id: string, checked: boolean] | undefined => { + if (calendarsRef.current === undefined) return undefined; const calendarName = checkbox.ariaLabel; const calendar = - calendars.find(({ summary }) => summary === calendarName) ?? + calendarsRef.current.find(({ summary }) => summary === calendarName) ?? /* * Summary for the primary calendar does not match what is displayed * in the UI */ - calendars.find(({ primary }) => primary); + calendarsRef.current.find(({ primary }) => primary); if (calendar === undefined) { console.error('Unable to locate the calendar', calendarName); - return; + return undefined; } return [calendar.id, checkbox.checked]; + }, + [] + ); + + /* + * Detect calendars being loaded (initially the side bar contains just the + * primary calendar) + */ + React.useEffect(() => { + if (sideBar === undefined || !cacheLoaded) return undefined; + + const getVisible = (): RA => + filterArray( + Array.from( + sideBar.querySelectorAll('input[type="checkbox"]'), + parseCheckbox + ) + ) + .filter(([_calendarId, checked]) => checked) + .map(([calendarId]) => calendarId); + + let timeOut: ReturnType; + + function handleChange(): void { + clearTimeout(timeOut); + setVisibleCalendars(getVisible()); } - return listen(sideBar, 'click', ({ target }) => { - const element = target as HTMLInputElement; - if (element.tagName !== 'INPUT' || element.type !== 'checkbox') return; - const data = parseCheckbox(element)?.[0]; - if (data === undefined) return; - const [calendarId, checked] = data; - handleChange((visibleCalendars) => - checked - ? visibleCalendars?.filter((id) => id !== calendarId) - : [...(visibleCalendars ?? []), calendarId] - ); - }); - }, [calendars, sideBar]); + // Get list of calendars loaded so far + const visible = getVisible(); + /* + * If side bar contains fewer elements than in the cache, it's likely + * that side bar hasn't been fully loaded yet. Wait and try it again + */ + if (visible.length < visibleCalendarsRef.current!.length) + timeOut = setTimeout(handleChange, 2000); + else handleChange(); + + const observer = new MutationObserver(debounce(handleChange, 60)); + observer.observe(sideBar, { childList: true, subtree: true }); + return (): void => observer.disconnect(); + }, [sideBar, parseCheckbox, setVisibleCalendars, cacheLoaded]); + + React.useEffect( + () => + sideBar === undefined + ? undefined + : listen(sideBar, 'click', ({ target }) => { + const element = target as HTMLInputElement; + if (element.tagName !== 'INPUT' || element.type !== 'checkbox') + return; + const data = parseCheckbox(element)?.[0]; + if (data === undefined) return; + const [calendarId, checked] = data; + setVisibleCalendars( + checked + ? visibleCalendarsRef.current?.filter((id) => id !== calendarId) + : [...(visibleCalendarsRef.current ?? []), calendarId] + ); + }), + [sideBar, setVisibleCalendars] + ); } /** diff --git a/src/src/components/Contexts/CurrentViewContext.tsx b/src/src/components/Contexts/CurrentViewContext.tsx index f7fc663..938edd4 100644 --- a/src/src/components/Contexts/CurrentViewContext.tsx +++ b/src/src/components/Contexts/CurrentViewContext.tsx @@ -151,7 +151,7 @@ function resolveBoundaries( ), }; else if (viewName === 'week') { - // BUG: detect first of the week. This incorrectly assumes Sunday is first + // BUG: detect first day of the week. This incorrectly assumes Sunday is first const dayOffset = selectedDay.getDate() - selectedDay.getDay(); return { firstDay: new Date( diff --git a/src/src/components/Molecules/Portal.tsx b/src/src/components/Molecules/Portal.tsx index 88d2faa..024fcba 100644 --- a/src/src/components/Molecules/Portal.tsx +++ b/src/src/components/Molecules/Portal.tsx @@ -2,7 +2,7 @@ import React from 'react'; import ReactDOM from 'react-dom'; let portalRoot: HTMLElement | undefined = undefined; -let portalStack = new Set(); +const portalStack = new Set(); /** * A React Portal wrapper @@ -42,7 +42,7 @@ export function Portal({ // Nearest parent for both main content and portal container const commonContainer = mainContainer.parentElement!; - commonContainer.appendChild(portalRoot); + commonContainer.append(portalRoot); } portalRoot.append(element); @@ -66,6 +66,13 @@ export function Portal({ export const findMainContainer = (): Element | undefined => document.querySelector('[role="main"]') ?? undefined; +export function useMainContainer(): HTMLElement | undefined { + return React.useMemo( + () => findMainContainer()?.parentElement ?? undefined, + [] + ); +} + export const PortalContext = React.createContext( undefined ); diff --git a/src/src/components/PowerTools/GhostEvents.tsx b/src/src/components/PowerTools/GhostEvents.tsx index c9d5fa3..aff7211 100644 --- a/src/src/components/PowerTools/GhostEvents.tsx +++ b/src/src/components/PowerTools/GhostEvents.tsx @@ -7,7 +7,7 @@ import React from 'react'; import { useSafeStorage } from '../../hooks/useStorage'; import { listen } from '../../utils/events'; import { f } from '../../utils/functools'; -import { findMainContainer } from '../Molecules/Portal'; +import { useMainContainer } from '../Molecules/Portal'; import { usePref } from '../Preferences/usePref'; import { usePageListener } from './PageListener'; @@ -33,10 +33,7 @@ export function GhostEvents(): null { ); }, [ghostEventOpacity, isDisabled]); - const mainContainer = React.useMemo( - () => findMainContainer()?.parentElement ?? undefined, - [] - ); + const mainContainer = useMainContainer(); const doGhosting = React.useCallback( (): void => mainContainer === undefined diff --git a/src/src/hooks/useStorage.tsx b/src/src/hooks/useStorage.tsx index 3bfc1a0..21110a7 100644 --- a/src/src/hooks/useStorage.tsx +++ b/src/src/hooks/useStorage.tsx @@ -23,6 +23,7 @@ export type StorageDefinitions = { readonly customViewSize: number; readonly timeChartMode: TimeChartMode; readonly synonyms: RA; + readonly visibleCalendars: RA; }; /**