From c877626db6e032acb5dda55d291c204a4385938d Mon Sep 17 00:00:00 2001 From: Eric Bailey Date: Tue, 5 Aug 2025 11:51:25 -0500 Subject: [PATCH] Add syncing, tests --- .../__tests__/useAnnouncementState.test.ts | 195 ++++++++++++++++++ .../dialogs/BlockingAnnouncements/config.ts | 3 + .../dialogs/BlockingAnnouncements/index.tsx | 5 +- .../useAnnouncementState.ts | 109 ++++++++-- src/state/queries/nuxs/__mocks__/index.ts | 25 +++ src/storage/schema.ts | 7 + 6 files changed, 328 insertions(+), 16 deletions(-) create mode 100644 src/components/dialogs/BlockingAnnouncements/__tests__/useAnnouncementState.test.ts create mode 100644 src/components/dialogs/BlockingAnnouncements/config.ts create mode 100644 src/state/queries/nuxs/__mocks__/index.ts diff --git a/src/components/dialogs/BlockingAnnouncements/__tests__/useAnnouncementState.test.ts b/src/components/dialogs/BlockingAnnouncements/__tests__/useAnnouncementState.test.ts new file mode 100644 index 0000000000..649175e2a8 --- /dev/null +++ b/src/components/dialogs/BlockingAnnouncements/__tests__/useAnnouncementState.test.ts @@ -0,0 +1,195 @@ +import {describe, test} from '@jest/globals' + +import { + computeCompletedState, + syncCompletedState, +} from '#/components/dialogs/BlockingAnnouncements/useAnnouncementState' + +jest.mock('../../../../state/queries/nuxs') + +describe('computeCompletedState', () => { + test(`initial state`, () => { + const completed = computeCompletedState({ + nuxIsReady: false, + nuxIsCompleted: false, + nuxIsOptimisticallyCompleted: false, + completedForDevice: undefined, + }) + + expect(completed).toBe(true) + }) + + test(`nux loaded state`, () => { + const completed = computeCompletedState({ + nuxIsReady: true, + nuxIsCompleted: false, + nuxIsOptimisticallyCompleted: false, + completedForDevice: undefined, + }) + + expect(completed).toBe(false) + }) + + test(`nux saving state`, () => { + const completed = computeCompletedState({ + nuxIsReady: true, + nuxIsCompleted: false, + nuxIsOptimisticallyCompleted: true, + completedForDevice: undefined, + }) + + expect(completed).toBe(true) + }) + + test(`nux is completed`, () => { + const completed = computeCompletedState({ + nuxIsReady: true, + nuxIsCompleted: true, + nuxIsOptimisticallyCompleted: false, + completedForDevice: undefined, + }) + + expect(completed).toBe(true) + }) + + test(`initial state, but already completed for device`, () => { + const completed = computeCompletedState({ + nuxIsReady: false, + nuxIsCompleted: false, + nuxIsOptimisticallyCompleted: false, + completedForDevice: true, + }) + + expect(completed).toBe(true) + }) +}) + +describe('syncCompletedState', () => { + describe('!nuxIsReady', () => { + test(`!completedForDevice, no-op`, () => { + const save = jest.fn() + const setCompletedForDevice = jest.fn() + syncCompletedState({ + nuxIsReady: false, + nuxIsCompleted: false, + nuxIsOptimisticallyCompleted: false, + completedForDevice: false, + save, + setCompletedForDevice, + }) + + expect(save).not.toHaveBeenCalled() + expect(setCompletedForDevice).not.toHaveBeenCalled() + }) + + test(`completedForDevice, no-op`, () => { + const save = jest.fn() + const setCompletedForDevice = jest.fn() + syncCompletedState({ + nuxIsReady: false, + nuxIsCompleted: false, + nuxIsOptimisticallyCompleted: false, + completedForDevice: true, + save, + setCompletedForDevice, + }) + + expect(save).not.toHaveBeenCalled() + expect(setCompletedForDevice).not.toHaveBeenCalled() + }) + }) + + describe('nuxIsReady', () => { + describe(`!nuxIsCompleted`, () => { + describe(`!nuxIsOptimisticallyCompleted`, () => { + test(`!completedForDevice, no-op`, () => { + const save = jest.fn() + const setCompletedForDevice = jest.fn() + syncCompletedState({ + nuxIsReady: true, + nuxIsCompleted: false, + nuxIsOptimisticallyCompleted: false, + completedForDevice: false, + save, + setCompletedForDevice, + }) + + expect(save).not.toHaveBeenCalled() + expect(setCompletedForDevice).not.toHaveBeenCalled() + }) + + test(`completedForDevice, syncs to server`, () => { + const save = jest.fn() + const setCompletedForDevice = jest.fn() + syncCompletedState({ + nuxIsReady: true, + nuxIsCompleted: false, + nuxIsOptimisticallyCompleted: false, + completedForDevice: true, + save, + setCompletedForDevice, + }) + + expect(save).toHaveBeenCalled() + expect(setCompletedForDevice).not.toHaveBeenCalled() + }) + }) + + /** + * Catches the case where we already called `save` to sync device state + * to server, thus `nuxIsOptimisticallyCompleted` is true. + */ + describe(`nuxIsOptimisticallyCompleted`, () => { + test(`completedForDevice, no-op`, () => { + const save = jest.fn() + const setCompletedForDevice = jest.fn() + syncCompletedState({ + nuxIsReady: true, + nuxIsCompleted: false, + nuxIsOptimisticallyCompleted: true, + completedForDevice: true, + save, + setCompletedForDevice, + }) + + expect(save).not.toHaveBeenCalled() + expect(setCompletedForDevice).not.toHaveBeenCalled() + }) + }) + }) + + describe(`nuxIsCompleted`, () => { + test(`!completedForDevice, syncs to device`, () => { + const save = jest.fn() + const setCompletedForDevice = jest.fn() + syncCompletedState({ + nuxIsReady: true, + nuxIsCompleted: true, + nuxIsOptimisticallyCompleted: false, + completedForDevice: false, + save, + setCompletedForDevice, + }) + + expect(save).not.toHaveBeenCalled() + expect(setCompletedForDevice).toHaveBeenCalled() + }) + + test(`completedForDevice, no-op`, () => { + const save = jest.fn() + const setCompletedForDevice = jest.fn() + syncCompletedState({ + nuxIsReady: true, + nuxIsCompleted: true, + nuxIsOptimisticallyCompleted: false, + completedForDevice: true, + save, + setCompletedForDevice, + }) + + expect(save).not.toHaveBeenCalled() + expect(setCompletedForDevice).not.toHaveBeenCalled() + }) + }) + }) +}) diff --git a/src/components/dialogs/BlockingAnnouncements/config.ts b/src/components/dialogs/BlockingAnnouncements/config.ts new file mode 100644 index 0000000000..9d154c9e98 --- /dev/null +++ b/src/components/dialogs/BlockingAnnouncements/config.ts @@ -0,0 +1,3 @@ +import {Nux} from '#/state/queries/nuxs' + +export const ACTIVE_ANNOUNCEMENT = Nux.BlockingAnnouncementPolicyUpdate202508 diff --git a/src/components/dialogs/BlockingAnnouncements/index.tsx b/src/components/dialogs/BlockingAnnouncements/index.tsx index 350e1c58cd..546ec846c1 100644 --- a/src/components/dialogs/BlockingAnnouncements/index.tsx +++ b/src/components/dialogs/BlockingAnnouncements/index.tsx @@ -1,6 +1,5 @@ import {View} from 'react-native' -import {Nux} from '#/state/queries/nuxs' import {atoms as a} from '#/alf' import {Announcement} from '#/components/dialogs/BlockingAnnouncements/announcements/PolicyUpdate202508' import {useAnnouncementState} from '#/components/dialogs/BlockingAnnouncements/useAnnouncementState' @@ -13,9 +12,7 @@ export const Portal = portalGroup.Portal export const Outlet = portalGroup.Outlet export function BlockingAnnouncements() { - const state = useAnnouncementState({ - id: Nux.BlockingAnnouncementPolicyUpdate202508, - }) + const state = useAnnouncementState() /* * See `window.clearNux` example in `/state/queries/nuxs` for a way to clear diff --git a/src/components/dialogs/BlockingAnnouncements/useAnnouncementState.ts b/src/components/dialogs/BlockingAnnouncements/useAnnouncementState.ts index 47fecf7ed0..8a12b5627f 100644 --- a/src/components/dialogs/BlockingAnnouncements/useAnnouncementState.ts +++ b/src/components/dialogs/BlockingAnnouncements/useAnnouncementState.ts @@ -1,34 +1,119 @@ import {useMemo} from 'react' -import {type Nux, useNux, useSaveNux} from '#/state/queries/nuxs' +import {useNux, useSaveNux} from '#/state/queries/nuxs' +import {ACTIVE_ANNOUNCEMENT} from '#/components/dialogs/BlockingAnnouncements/config' +import {device, useStorage} from '#/storage' export type AnnouncementState = { completed: boolean complete: () => void } -export function useAnnouncementState({id}: {id: Nux}) { - const nux = useNux(id) +export function useAnnouncementState() { + const nux = useNux(ACTIVE_ANNOUNCEMENT) const {mutate: save, variables} = useSaveNux() + const deviceStorage = useStorage(device, [ACTIVE_ANNOUNCEMENT]) return useMemo(() => { - /** - * Until data has loaded, assumed completed - */ - let completed = nux.status === 'ready' ? nux.nux?.completed === true : true + const nuxIsReady = nux.status === 'ready' + const nuxIsCompleted = nux.nux?.completed === true + const nuxIsOptimisticallyCompleted = !!variables?.completed + const [completedForDevice, setCompletedForDevice] = deviceStorage - if (variables?.completed) { - completed = true - } + const completed = computeCompletedState({ + nuxIsReady, + nuxIsCompleted, + nuxIsOptimisticallyCompleted, + completedForDevice, + }) + + syncCompletedState({ + nuxIsReady, + nuxIsCompleted, + nuxIsOptimisticallyCompleted, + completedForDevice, + save, + setCompletedForDevice, + }) return { completed, complete() { save({ - id, + id: ACTIVE_ANNOUNCEMENT, completed: true, data: undefined, }) }, } - }, [id, nux, save, variables]) + }, [nux, save, variables, deviceStorage]) +} + +export function computeCompletedState({ + nuxIsReady, + nuxIsCompleted, + nuxIsOptimisticallyCompleted, + completedForDevice, +}: { + nuxIsReady: boolean + nuxIsCompleted: boolean + nuxIsOptimisticallyCompleted: boolean + completedForDevice: boolean | undefined +}): boolean { + /** + * Assume completed to prevent flash + */ + let completed = true + + /** + * Prefer server state, if available + */ + if (nuxIsReady) { + completed = nuxIsCompleted + } + + /** + * Override with optimistic state or device state + */ + if (nuxIsOptimisticallyCompleted || !!completedForDevice) { + completed = true + } + + return completed +} + +export function syncCompletedState({ + nuxIsReady, + nuxIsCompleted, + nuxIsOptimisticallyCompleted, + completedForDevice, + save, + setCompletedForDevice, +}: { + nuxIsReady: boolean + nuxIsCompleted: boolean + nuxIsOptimisticallyCompleted: boolean + completedForDevice: boolean | undefined + save: ReturnType['mutate'] + setCompletedForDevice: (value: boolean) => void +}) { + /* + * Sync device state to server state for this account + */ + if ( + nuxIsReady && + !nuxIsCompleted && + !nuxIsOptimisticallyCompleted && + !!completedForDevice + ) { + save({ + id: ACTIVE_ANNOUNCEMENT, + completed: true, + data: undefined, + }) + } else if (nuxIsReady && nuxIsCompleted && !completedForDevice) { + /* + * Sync server state to device state + */ + setCompletedForDevice(true) + } } diff --git a/src/state/queries/nuxs/__mocks__/index.ts b/src/state/queries/nuxs/__mocks__/index.ts new file mode 100644 index 0000000000..c718b15940 --- /dev/null +++ b/src/state/queries/nuxs/__mocks__/index.ts @@ -0,0 +1,25 @@ +import {jest} from '@jest/globals' + +export {Nux} from '#/state/queries/nuxs/definitions' + +export const useNuxs = jest.fn(() => { + return { + nuxs: undefined, + status: 'loading' as const, + } +}) + +export const useNux = jest.fn((id: string) => { + return { + nux: undefined, + status: 'loading' as const, + } +}) + +export const useSaveNux = jest.fn(() => { + return {} +}) + +export const useResetNuxs = jest.fn(() => { + return {} +}) diff --git a/src/storage/schema.ts b/src/storage/schema.ts index c05a7531df..527e0d9e96 100644 --- a/src/storage/schema.ts +++ b/src/storage/schema.ts @@ -1,3 +1,5 @@ +import {type Nux} from '#/state/queries/nuxs' + /** * Device data that's specific to the device and does not vary based account */ @@ -13,6 +15,11 @@ export type Device = { devMode: boolean demoMode: boolean activitySubscriptionsNudged?: boolean + + /** + * Blocking announcements. New IDs are required for each new announcement. + */ + [Nux.BlockingAnnouncementPolicyUpdate202508]?: boolean } export type Account = {