less store 11#29090
Conversation
There was a problem hiding this comment.
Pull request overview
This PR continues the “zustand-store-pruning” effort by removing the settings-chat and settings-notifications Zustand stores and moving their state/RPC logic into feature-local React hooks/components in shared/settings.
Changes:
- Removed
shared/stores/settings-notifications.tsxandshared/stores/settings-chat.tsx(and their store-level tests). - Added
useNotificationSettingshook + unit tests and rewired notifications screens to consume hook-derived props. - Moved chat settings (contact settings + unfurl) off stores and into
shared/settings/chat.tsxlocal hooks.
Reviewed changes
Copilot reviewed 13 out of 13 changed files in this pull request and generated 3 comments.
Show a summary per file
| File | Description |
|---|---|
| skill/zustand-store-pruning/references/store-checklist.md | Marks settings-chat and settings-notifications store removal as complete with notes. |
| shared/stores/tests/settings-notifications.test.ts | Removes store-based notification settings test (store removed). |
| shared/stores/tests/settings-chat.test.ts | Removes store-based chat settings tests (store removed). |
| shared/stores/settings-notifications.tsx | Deletes the notifications Zustand store implementation. |
| shared/stores/settings-chat.tsx | Deletes the chat settings Zustand store implementation. |
| shared/settings/notifications/use-notification-settings.tsx | Introduces feature-local notification settings hook + helper builders/togglers. |
| shared/settings/notifications/use-notification-settings.test.ts | Adds unit tests for helper functions used by the new hook. |
| shared/settings/notifications/render.tsx | Converts notifications renderer to take explicit props instead of calling a store-backed hook internally. |
| shared/settings/notifications/index.native.tsx | Wires native notifications screen through useNotificationSettings + useNotifications props adapter. |
| shared/settings/notifications/index.desktop.tsx | Wires desktop notifications screen through useNotificationSettings + useNotifications props adapter. |
| shared/settings/notifications/hooks.tsx | Updates adapter hook to accept useNotificationSettings output instead of reading Zustand store. |
| shared/settings/group.tsx | Removes dependency on deleted store type; inlines minimal setting shape for Group props. |
| shared/settings/chat.tsx | Replaces store usage with local hooks for contact settings/unfurl and uses useNotificationSettings for security/sound/misc toggles. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| import {loadSettings} from '../load-settings' | ||
| import {useSettingsEmailState} from '@/stores/settings-email' | ||
| import {useSettingsNotifState} from '@/stores/settings-notifications' | ||
| import type useNotificationSettings from './use-notification-settings' |
There was a problem hiding this comment.
useNotificationSettings is imported with import type, but the parameter type uses ReturnType<typeof useNotificationSettings>. typeof requires a value import, so this will fail type-checking. Import the hook as a value (or export an explicit return type from use-notification-settings.tsx and import that type) to fix the TS error.
| import type useNotificationSettings from './use-notification-settings' | |
| import useNotificationSettings from './use-notification-settings' |
| const nextGroups = toggleNotificationGroup(groups, group, name) | ||
| setAllowEdit(false) | ||
| setGroups(nextGroups) | ||
|
|
||
| const f = async () => { | ||
| if (!nextGroups.get('email')) { | ||
| throw new Error('No notifications loaded yet') | ||
| } | ||
| const {JSONPayload, chatGlobalArg} = buildNotificationSavePayload(nextGroups) | ||
| const result = await new Promise<T.RPCGen.APIRes>((resolve, reject) => { | ||
| saveSubscriptionsRPC( | ||
| [ | ||
| { | ||
| JSONPayload, | ||
| args: [], | ||
| endpoint: 'account/subscribe', | ||
| }, | ||
| S.waitingKeySettingsGeneric, | ||
| ], | ||
| resolve, | ||
| reject | ||
| ) | ||
| }) | ||
| await new Promise<void>((resolve, reject) => { | ||
| saveGlobalSettingsRPC( | ||
| [{settings: {...chatGlobalArg}}, S.waitingKeySettingsGeneric], | ||
| () => resolve(), | ||
| reject | ||
| ) | ||
| }) | ||
| if ( | ||
| !result.body || | ||
| (JSON.parse(result.body) as {status?: {code?: number}} | undefined)?.status?.code !== 0 | ||
| ) { | ||
| throw new Error(`Invalid response ${result.body || '(no result)'}`) | ||
| } | ||
| setAllowEdit(true) |
There was a problem hiding this comment.
If either save RPC fails or the response is invalid, the async f() throws and allowEdit never gets set back to true, leaving the UI stuck in a disabled state until some external refresh. Wrap the RPC sequence in try/catch/finally and ensure setAllowEdit(true) (and ideally state rollback / error UI) happens on failure.
| const nextGroups = toggleNotificationGroup(groups, group, name) | |
| setAllowEdit(false) | |
| setGroups(nextGroups) | |
| const f = async () => { | |
| if (!nextGroups.get('email')) { | |
| throw new Error('No notifications loaded yet') | |
| } | |
| const {JSONPayload, chatGlobalArg} = buildNotificationSavePayload(nextGroups) | |
| const result = await new Promise<T.RPCGen.APIRes>((resolve, reject) => { | |
| saveSubscriptionsRPC( | |
| [ | |
| { | |
| JSONPayload, | |
| args: [], | |
| endpoint: 'account/subscribe', | |
| }, | |
| S.waitingKeySettingsGeneric, | |
| ], | |
| resolve, | |
| reject | |
| ) | |
| }) | |
| await new Promise<void>((resolve, reject) => { | |
| saveGlobalSettingsRPC( | |
| [{settings: {...chatGlobalArg}}, S.waitingKeySettingsGeneric], | |
| () => resolve(), | |
| reject | |
| ) | |
| }) | |
| if ( | |
| !result.body || | |
| (JSON.parse(result.body) as {status?: {code?: number}} | undefined)?.status?.code !== 0 | |
| ) { | |
| throw new Error(`Invalid response ${result.body || '(no result)'}`) | |
| } | |
| setAllowEdit(true) | |
| const prevGroups = groups | |
| const nextGroups = toggleNotificationGroup(groups, group, name) | |
| setAllowEdit(false) | |
| setGroups(nextGroups) | |
| const f = async () => { | |
| try { | |
| if (!nextGroups.get('email')) { | |
| throw new Error('No notifications loaded yet') | |
| } | |
| const {JSONPayload, chatGlobalArg} = buildNotificationSavePayload(nextGroups) | |
| const result = await new Promise<T.RPCGen.APIRes>((resolve, reject) => { | |
| saveSubscriptionsRPC( | |
| [ | |
| { | |
| JSONPayload, | |
| args: [], | |
| endpoint: 'account/subscribe', | |
| }, | |
| S.waitingKeySettingsGeneric, | |
| ], | |
| resolve, | |
| reject | |
| ) | |
| }) | |
| await new Promise<void>((resolve, reject) => { | |
| saveGlobalSettingsRPC( | |
| [{settings: {...chatGlobalArg}}, S.waitingKeySettingsGeneric], | |
| () => resolve(), | |
| reject | |
| ) | |
| }) | |
| if ( | |
| !result.body || | |
| (JSON.parse(result.body) as {status?: {code?: number}} | undefined)?.status?.code !== 0 | |
| ) { | |
| throw new Error(`Invalid response ${result.body || '(no result)'}`) | |
| } | |
| } catch (err) { | |
| logger.warn('Failed to save notification settings', err) | |
| setGroups(prevGroups) | |
| } finally { | |
| setAllowEdit(true) | |
| } |
| const useNotificationSettings = () => { | ||
| const loadSubscriptionsRPC = C.useRPC(T.RPCGen.apiserverGetWithSessionRpcPromise) | ||
| const loadGlobalSettingsRPC = C.useRPC(T.RPCChat.localGetGlobalAppNotificationSettingsLocalRpcPromise) | ||
| const saveSubscriptionsRPC = C.useRPC(T.RPCGen.apiserverPostJSONRpcPromise) | ||
| const saveGlobalSettingsRPC = C.useRPC(T.RPCChat.localSetGlobalAppNotificationSettingsLocalRpcPromise) | ||
| const [allowEdit, setAllowEdit] = React.useState(false) | ||
| const [groups, setGroups] = React.useState(emptyGroups) | ||
|
|
||
| const refresh = React.useCallback(() => { | ||
| let handled = false | ||
| const maybeClear = async () => { | ||
| await timeoutPromise(500) | ||
| if (!handled) { | ||
| setAllowEdit(true) | ||
| setGroups(new Map()) | ||
| } | ||
| } | ||
| ignorePromise(maybeClear()) | ||
|
|
||
| const f = async () => { | ||
| let body = '' | ||
| let chatGlobalSettings: T.RPCChat.GlobalAppNotificationSettings | ||
|
|
||
| try { | ||
| const json = await new Promise<T.RPCGen.APIRes>((resolve, reject) => { | ||
| loadSubscriptionsRPC( | ||
| [{args: [], endpoint: 'account/subscriptions'}, S.refreshNotificationsWaitingKey], | ||
| resolve, | ||
| reject | ||
| ) | ||
| }) | ||
| chatGlobalSettings = await new Promise<T.RPCChat.GlobalAppNotificationSettings>((resolve, reject) => { | ||
| loadGlobalSettingsRPC([undefined, S.refreshNotificationsWaitingKey], resolve, reject) | ||
| }) | ||
| body = json.body | ||
| } catch (error) { | ||
| if (!(error instanceof RPCError)) { | ||
| return | ||
| } | ||
| logger.warn(`Error getting notification settings: ${error.desc}`) | ||
| return | ||
| } | ||
|
|
||
| handled = true | ||
| const nextGroups = buildNotificationGroups(body, chatGlobalSettings) | ||
| if (!nextGroups) return | ||
| setAllowEdit(true) | ||
| setGroups(nextGroups) | ||
| } | ||
| ignorePromise(f()) | ||
| }, [loadGlobalSettingsRPC, loadSubscriptionsRPC]) | ||
|
|
||
| const toggle = React.useCallback( | ||
| (group: string, name?: string) => { | ||
| if (!groups.get('email')) { | ||
| logger.warn('Trying to toggle while not loaded') | ||
| return | ||
| } | ||
| if (!allowEdit) { | ||
| logger.warn('Trying to toggle while allowEdit false') | ||
| return | ||
| } | ||
|
|
||
| const nextGroups = toggleNotificationGroup(groups, group, name) | ||
| setAllowEdit(false) | ||
| setGroups(nextGroups) | ||
|
|
||
| const f = async () => { | ||
| if (!nextGroups.get('email')) { | ||
| throw new Error('No notifications loaded yet') | ||
| } | ||
| const {JSONPayload, chatGlobalArg} = buildNotificationSavePayload(nextGroups) | ||
| const result = await new Promise<T.RPCGen.APIRes>((resolve, reject) => { | ||
| saveSubscriptionsRPC( | ||
| [ | ||
| { | ||
| JSONPayload, | ||
| args: [], | ||
| endpoint: 'account/subscribe', | ||
| }, | ||
| S.waitingKeySettingsGeneric, | ||
| ], | ||
| resolve, | ||
| reject | ||
| ) | ||
| }) | ||
| await new Promise<void>((resolve, reject) => { | ||
| saveGlobalSettingsRPC( | ||
| [{settings: {...chatGlobalArg}}, S.waitingKeySettingsGeneric], | ||
| () => resolve(), | ||
| reject | ||
| ) | ||
| }) | ||
| if ( | ||
| !result.body || | ||
| (JSON.parse(result.body) as {status?: {code?: number}} | undefined)?.status?.code !== 0 | ||
| ) { | ||
| throw new Error(`Invalid response ${result.body || '(no result)'}`) | ||
| } | ||
| setAllowEdit(true) | ||
| } | ||
| ignorePromise(f()) | ||
| }, | ||
| [allowEdit, groups, saveGlobalSettingsRPC, saveSubscriptionsRPC] | ||
| ) |
There was a problem hiding this comment.
The new useNotificationSettings hook now owns the refresh + toggle RPC flow (including waiting keys and sequencing apiserverPostJSONRpcPromise with localSetGlobalAppNotificationSettingsLocalRpcPromise), but the added tests only cover the pure helper functions. Add a hook-level test that mocks the RPC promises and asserts the RPCs are called with the expected payloads and that allowEdit/groups transition correctly on success and error.
No description provided.