diff --git a/shared/settings/chat.tsx b/shared/settings/chat.tsx index cb5324ca5d29..a3b14c9d1525 100644 --- a/shared/settings/chat.tsx +++ b/shared/settings/chat.tsx @@ -5,38 +5,132 @@ import * as T from '@/constants/types' import * as React from 'react' import Group from './group' import {loadSettings} from './load-settings' -import {useSettingsChatState as useSettingsChatState} from '@/stores/settings-chat' -import {useSettingsNotifState} from '@/stores/settings-notifications' +import useNotificationSettings from './notifications/use-notification-settings' import {useConfigState} from '@/stores/config' const emptyList = new Array() -const Security = () => { - const {allowEdit, groups, notifRefresh} = useSettingsNotifState( - C.useShallow(s => ({ - allowEdit: s.allowEdit, - groups: s.groups, - notifRefresh: s.dispatch.refresh, - })) +type ContactSettingsTeamsList = {[k in T.RPCGen.TeamID]: boolean} +type NotificationSettingsState = ReturnType + +const useContactSettings = () => { + const loadContactSettingsRPC = C.useRPC(T.RPCGen.accountUserGetContactSettingsRpcPromise) + const saveContactSettingsRPC = C.useRPC(T.RPCGen.accountUserSetContactSettingsRpcPromise) + const [error, setError] = React.useState('') + const [settings, setSettings] = React.useState() + + const contactSettingsRefresh = React.useCallback(() => { + if (!useConfigState.getState().loggedIn) { + return + } + loadContactSettingsRPC( + [undefined], + nextSettings => { + setError('') + setSettings(nextSettings) + }, + () => { + setError('Unable to load contact settings, please try again.') + } + ) + }, [loadContactSettingsRPC]) + + const contactSettingsSaved = React.useCallback( + ( + enabled: boolean, + indirectFollowees: boolean, + teamsEnabled: boolean, + teamsList: ContactSettingsTeamsList + ) => { + if (!useConfigState.getState().loggedIn) { + return + } + const teams = Object.entries(teamsList).map(([teamID, teamEnabled]) => ({ + enabled: teamEnabled, + teamID, + })) + saveContactSettingsRPC( + [ + { + settings: { + allowFolloweeDegrees: indirectFollowees ? 2 : 1, + allowGoodTeams: teamsEnabled, + enabled, + teams, + }, + }, + C.waitingKeySettingsChatContactSettingsSave, + ], + () => { + contactSettingsRefresh() + }, + () => { + setError('Unable to save contact settings, please try again.') + } + ) + }, + [contactSettingsRefresh, saveContactSettingsRPC] ) - const chatState = useSettingsChatState( - C.useShallow(s => ({ - _contactSettingsEnabled: s.contactSettings.settings?.enabled, - _contactSettingsIndirectFollowees: s.contactSettings.settings?.allowFolloweeDegrees === 2, - _contactSettingsTeams: s.contactSettings.settings?.teams, - _contactSettingsTeamsEnabled: s.contactSettings.settings?.allowGoodTeams, - contactSettingsError: s.contactSettings.error, - contactSettingsRefresh: s.dispatch.contactSettingsRefresh, - contactSettingsSaved: s.dispatch.contactSettingsSaved, - })) + + return {contactSettingsRefresh, contactSettingsSaved, error, settings} +} + +const useUnfurlSettings = () => { + const loadUnfurlSettingsRPC = C.useRPC(T.RPCChat.localGetUnfurlSettingsRpcPromise) + const saveUnfurlSettingsRPC = C.useRPC(T.RPCChat.localSaveUnfurlSettingsRpcPromise) + const [error, setError] = React.useState('') + const [mode, setMode] = React.useState() + const [whitelist, setWhitelist] = React.useState>(emptyList) + + const unfurlSettingsRefresh = React.useCallback(() => { + if (!useConfigState.getState().loggedIn) { + return + } + loadUnfurlSettingsRPC( + [undefined, C.waitingKeySettingsChatUnfurl], + result => { + setError('') + setMode(result.mode) + setWhitelist(result.whitelist ?? emptyList) + }, + () => { + setError('Unable to load link preview settings, please try again.') + } + ) + }, [loadUnfurlSettingsRPC]) + + const unfurlSettingsSaved = React.useCallback( + (unfurlMode: T.RPCChat.UnfurlMode, unfurlWhitelist: ReadonlyArray) => { + setError('') + setMode(unfurlMode) + setWhitelist(unfurlWhitelist) + if (!useConfigState.getState().loggedIn) { + return + } + saveUnfurlSettingsRPC( + [{mode: unfurlMode, whitelist: unfurlWhitelist}, C.waitingKeySettingsChatUnfurl], + () => { + unfurlSettingsRefresh() + }, + () => { + setError('Unable to save link preview settings, please try again.') + } + ) + }, + [saveUnfurlSettingsRPC, unfurlSettingsRefresh] ) - const {_contactSettingsEnabled, _contactSettingsIndirectFollowees, _contactSettingsTeams} = chatState - const {_contactSettingsTeamsEnabled, contactSettingsError, contactSettingsRefresh, contactSettingsSaved} = - chatState - const onContactSettingsSave = contactSettingsSaved - const onToggle = useSettingsNotifState(s => s.dispatch.toggle) + + return {error, mode, unfurlSettingsRefresh, unfurlSettingsSaved, whitelist} +} + +const Security = ({allowEdit, groups, refresh, toggle}: NotificationSettingsState) => { + const {contactSettingsRefresh, contactSettingsSaved, error, settings} = useContactSettings() const _teamMeta = Teams.useTeamsState(s => s.teamMeta) const teamMeta = Teams.sortTeamsByName(_teamMeta) + const _contactSettingsEnabled = settings?.enabled + const _contactSettingsIndirectFollowees = settings?.allowFolloweeDegrees === 2 + const _contactSettingsTeams = settings?.teams + const _contactSettingsTeamsEnabled = settings?.allowGoodTeams const [contactSettingsEnabled, setContactSettingsEnabled] = React.useState(_contactSettingsEnabled) const [contactSettingsIndirectFollowees, setContactSettingsIndirectFollowees] = React.useState( @@ -107,9 +201,9 @@ const Security = () => { React.useEffect(() => { loadSettings() - notifRefresh() + refresh() contactSettingsRefresh() - }, [contactSettingsRefresh, notifRefresh]) + }, [contactSettingsRefresh, refresh]) return ( <> @@ -119,13 +213,13 @@ const Security = () => { {!!groups.get('security')?.settings && ( - + )} @@ -187,7 +281,7 @@ const Security = () => { - onContactSettingsSave( + contactSettingsSaved( !!contactSettingsEnabled, !!contactSettingsIndirectFollowees, !!contactSettingsTeamsEnabled, @@ -199,9 +293,9 @@ const Security = () => { style={styles.save} waitingKey={C.waitingKeySettingsChatContactSettingsSave} /> - {!!contactSettingsError && ( + {!!error && ( - {contactSettingsError} + {error} )} @@ -212,15 +306,7 @@ const Security = () => { } const Links = () => { - const {error, mode, onUnfurlSave, unfurlSettingsRefresh, whitelist} = useSettingsChatState( - C.useShallow(s => ({ - error: s.unfurl.unfurlError, - mode: s.unfurl.unfurlMode, - onUnfurlSave: s.dispatch.unfurlSettingsSaved, - unfurlSettingsRefresh: s.dispatch.unfurlSettingsRefresh, - whitelist: s.unfurl.unfurlWhitelist ?? emptyList, - })) - ) + const {error, mode, unfurlSettingsRefresh, unfurlSettingsSaved, whitelist} = useUnfurlSettings() const [selected, setSelected] = React.useState(mode) const [unfurlWhitelistRemoved, setUnfurlWhitelistRemoved] = React.useState<{[K in string]: boolean}>({}) const getUnfurlWhitelist = (filtered: boolean) => @@ -230,7 +316,7 @@ const Links = () => { const next = whitelist.filter(w => { return !unfurlWhitelistRemoved[w] }) - onUnfurlSave(selected || T.RPCChat.UnfurlMode.always, next) + unfurlSettingsSaved(selected || T.RPCChat.UnfurlMode.always, next) } const toggleUnfurlWhitelist = (domain: string) => { @@ -350,20 +436,13 @@ const Links = () => { ) } -const Sound = () => { +const Sound = ({allowEdit, groups, toggle}: NotificationSettingsState) => { const {onToggleSound, sound} = useConfigState( C.useShallow(s => ({ onToggleSound: s.dispatch.setNotifySound, sound: s.notifySound, })) ) - const {allowEdit, groups, onToggle} = useSettingsNotifState( - C.useShallow(s => ({ - allowEdit: s.allowEdit, - groups: s.groups, - onToggle: s.dispatch.toggle, - })) - ) const showDesktopSound = !C.isMobile && !C.isLinux const showMobileSound = !!groups.get('sound')?.settings.length if (!showDesktopSound && !showMobileSound) return null @@ -380,7 +459,7 @@ const Sound = () => { @@ -391,14 +470,7 @@ const Sound = () => { ) } -const Misc = () => { - const {allowEdit, groups, onToggle} = useSettingsNotifState( - C.useShallow(s => ({ - allowEdit: s.allowEdit, - groups: s.groups, - onToggle: s.dispatch.toggle, - })) - ) +const Misc = ({allowEdit, groups, toggle}: NotificationSettingsState) => { const showMisc = C.isMac || C.isIOS if (!showMisc) return null return ( @@ -411,7 +483,7 @@ const Misc = () => { @@ -423,15 +495,16 @@ const Misc = () => { } const Chat = () => { + const notificationSettings = useNotificationSettings() return ( - + - - + + diff --git a/shared/settings/group.tsx b/shared/settings/group.tsx index fefd140db264..c97ca33f6089 100644 --- a/shared/settings/group.tsx +++ b/shared/settings/group.tsx @@ -1,5 +1,4 @@ import * as Kb from '@/common-adapters' -import type {NotificationsSettingsState} from '@/stores/settings-notifications' type GroupProps = { allowEdit: boolean @@ -7,7 +6,11 @@ type GroupProps = { label?: string onToggle: (groupName: string, name: string) => void onToggleUnsubscribeAll?: () => void - settings?: ReadonlyArray + settings?: ReadonlyArray<{ + description: string + name: string + subscribed: boolean + }> title?: string unsub?: string unsubscribedFromAll: boolean diff --git a/shared/settings/notifications/hooks.tsx b/shared/settings/notifications/hooks.tsx index 1a56254f143e..72768e5caf4a 100644 --- a/shared/settings/notifications/hooks.tsx +++ b/shared/settings/notifications/hooks.tsx @@ -1,42 +1,23 @@ import * as C from '@/constants' import {settingsAccountTab} from '@/constants/settings' -import {loadSettings} from '../load-settings' import {useSettingsEmailState} from '@/stores/settings-email' -import {useSettingsNotifState} from '@/stores/settings-notifications' +import type useNotificationSettings from './use-notification-settings' -const useNotifications = () => { - const _groups = useSettingsNotifState(s => s.groups) - const allowEdit = useSettingsNotifState(s => s.allowEdit) - const toggle = useSettingsNotifState(s => s.dispatch.toggle) +const useNotifications = (notificationSettings: ReturnType) => { + const {allowEdit, groups, toggle} = notificationSettings const showEmailSection = useSettingsEmailState(s => s.emails.size > 0) - const waitingForResponse = C.Waiting.useAnyWaiting(C.waitingKeySettingsGeneric) - const navigateUp = C.useRouterState(s => s.dispatch.navigateUp) - const onBack = () => { - navigateUp() - } const navigateAppend = C.useRouterState(s => s.dispatch.navigateAppend) const onClickYourAccount = () => { navigateAppend(settingsAccountTab) } - const onToggle = toggle - const onToggleUnsubscribeAll = toggle - const refresh = useSettingsNotifState(s => s.dispatch.refresh) - - const onRefresh = () => { - loadSettings() - refresh() - } return { allowEdit, - groups: _groups, - onBack, + groups, onClickYourAccount, - onRefresh, - onToggle, - onToggleUnsubscribeAll, - showEmailSection: showEmailSection, - waitingForResponse: waitingForResponse, + onToggle: toggle, + onToggleUnsubscribeAll: toggle, + showEmailSection, } } diff --git a/shared/settings/notifications/index.desktop.tsx b/shared/settings/notifications/index.desktop.tsx index d14c87e75c05..e4dbd2652a3e 100644 --- a/shared/settings/notifications/index.desktop.tsx +++ b/shared/settings/notifications/index.desktop.tsx @@ -1,14 +1,16 @@ import {Reloadable} from '@/common-adapters' import * as C from '@/constants' import Render from './render' +import useNotifications from './hooks' +import useNotificationSettings from './use-notification-settings' import {loadSettings} from '../load-settings' -import {useSettingsNotifState} from '@/stores/settings-notifications' const Notifications = () => { - const refresh = useSettingsNotifState(s => s.dispatch.refresh) + const notificationSettings = useNotificationSettings() + const props = useNotifications(notificationSettings) const onReload = () => { loadSettings() - refresh() + notificationSettings.refresh() } return ( { onReload={onReload} reloadOnMount={true} > - + ) } diff --git a/shared/settings/notifications/index.native.tsx b/shared/settings/notifications/index.native.tsx index c60e45740ad5..eba59a56d2d7 100644 --- a/shared/settings/notifications/index.native.tsx +++ b/shared/settings/notifications/index.native.tsx @@ -1,17 +1,19 @@ import * as C from '@/constants' import * as Kb from '@/common-adapters' import Notifications from './render' +import useNotifications from './hooks' +import useNotificationSettings from './use-notification-settings' import {Reloadable} from '@/common-adapters' import {loadSettings} from '../load-settings' -import {useSettingsNotifState} from '@/stores/settings-notifications' import {usePushState} from '@/stores/push' const MobileNotifications = () => { - const refresh = useSettingsNotifState(s => s.dispatch.refresh) + const notificationSettings = useNotificationSettings() + const props = useNotifications(notificationSettings) const navigateUp = C.useRouterState(s => s.dispatch.navigateUp) const onReload = () => { loadSettings() - refresh() + notificationSettings.refresh() } return ( { > - + ) diff --git a/shared/settings/notifications/render.tsx b/shared/settings/notifications/render.tsx index 76a05bee8555..bee2d35f974e 100644 --- a/shared/settings/notifications/render.tsx +++ b/shared/settings/notifications/render.tsx @@ -1,9 +1,25 @@ import * as Kb from '@/common-adapters' -import useNotifications from './hooks' import Group from '../group' import {usePushState} from '@/stores/push' -type Props = ReturnType +type Props = { + allowEdit: boolean + groups: ReadonlyMap< + string, + { + settings: ReadonlyArray<{ + description: string + name: string + subscribed: boolean + }> + unsub: boolean + } + > + onClickYourAccount: () => void + onToggle: (groupName: string, name: string) => void + onToggleUnsubscribeAll: (groupName: string) => void + showEmailSection: boolean +} const EmailSection = (props: Pick) => ( ( unsubscribedFromAll={props.groups.get('app_push')!.unsub} /> ) -const Notifications = () => { - const props = useNotifications() +const Notifications = (props: Props) => { const mobileHasPermissions = usePushState(s => s.hasPermissions) return !props.groups.get('email')?.settings ? ( diff --git a/shared/settings/notifications/use-notification-settings.test.ts b/shared/settings/notifications/use-notification-settings.test.ts new file mode 100644 index 000000000000..eef21945c23c --- /dev/null +++ b/shared/settings/notifications/use-notification-settings.test.ts @@ -0,0 +1,75 @@ +/// +import * as T from '@/constants/types' +import { + buildNotificationGroups, + buildNotificationSavePayload, + toggleNotificationGroup, +} from './use-notification-settings' + +const makeChatGlobalSettings = (settings: {[key: string]: boolean} = {}) => + ({settings} as T.RPCChat.GlobalAppNotificationSettings) + +test('buildNotificationGroups merges API and chat-global notification settings', () => { + const groups = buildNotificationGroups( + JSON.stringify({ + notifications: { + email: { + settings: [{description: 'Email', description_h: 'Email', name: 'newmessages', subscribed: true}], + unsub: false, + }, + }, + }), + makeChatGlobalSettings({ + [`${T.RPCChat.GlobalAppNotificationSetting.plaintextmobile}`]: true, + [`${T.RPCChat.GlobalAppNotificationSetting.plaintextdesktop}`]: false, + [`${T.RPCChat.GlobalAppNotificationSetting.disabletyping}`]: false, + [`${T.RPCChat.GlobalAppNotificationSetting.convertheic}`]: true, + }) + ) + + expect(groups?.get('email')?.settings[0]?.name).toBe('newmessages') + expect(groups?.get('security')?.settings.map(setting => setting.name)).toEqual([ + 'plaintextmobile', + 'plaintextdesktop', + 'disabletyping', + ]) + expect(groups?.get('security')?.settings[0]?.subscribed).toBe(true) + expect(groups?.get('security')?.settings[1]?.subscribed).toBe(false) + expect(groups?.get('security')?.settings[2]?.subscribed).toBe(true) + expect(groups?.get('misc')?.settings[0]?.subscribed).toBe(true) +}) + +test('toggleNotificationGroup and buildNotificationSavePayload preserve optimistic toggle semantics', () => { + const groups = new Map([ + [ + 'email', + { + settings: [{description: 'Email', name: 'newmessages', subscribed: true}], + unsub: false, + }, + ], + [ + 'security', + { + settings: [ + {description: 'Phone', name: 'plaintextmobile', subscribed: true}, + {description: 'Typing', name: 'disabletyping', subscribed: false}, + ], + unsub: false, + }, + ], + ]) as Parameters[0] + + const nextGroups = toggleNotificationGroup(groups, 'security', 'plaintextmobile') + expect(nextGroups.get('security')?.settings[0]?.subscribed).toBe(false) + + const payload = buildNotificationSavePayload(nextGroups) + expect(payload.JSONPayload).toEqual([ + {key: 'newmessages|email', value: '1'}, + {key: 'unsub|email', value: '0'}, + ]) + expect(payload.chatGlobalArg).toEqual({ + [`${T.RPCChat.GlobalAppNotificationSetting.disabletyping}`]: true, + [`${T.RPCChat.GlobalAppNotificationSetting.plaintextmobile}`]: false, + }) +}) diff --git a/shared/settings/notifications/use-notification-settings.tsx b/shared/settings/notifications/use-notification-settings.tsx new file mode 100644 index 000000000000..3d6ae7776444 --- /dev/null +++ b/shared/settings/notifications/use-notification-settings.tsx @@ -0,0 +1,275 @@ +import * as C from '@/constants' +import * as S from '@/constants/strings' +import * as T from '@/constants/types' +import {isAndroidNewerThanN} from '@/constants/platform' +import {ignorePromise, timeoutPromise} from '@/constants/utils' +import logger from '@/logger' +import {RPCError} from '@/util/errors' +import * as React from 'react' + +const securityGroup = 'security' +const soundGroup = 'sound' +const miscGroup = 'misc' + +type NotificationSetting = { + name: + | 'newmessages' + | 'plaintextmobile' + | 'plaintextdesktop' + | 'defaultsoundmobile' + | 'disabletyping' + | 'convertheic' + subscribed: boolean + description: string +} + +type NotificationsGroupStateFromServer = { + notifications: { + [key: string]: { + settings: Array<{ + description: string + description_h: string + name: NotificationSetting['name'] + subscribed: boolean + }> + unsub: boolean + } + } +} + +type NotificationsGroupState = { + settings: Array + unsub: boolean +} + +type NotificationSavePayload = { + JSONPayload: Array<{key: string; value: string}> + chatGlobalArg: {[key: string]: boolean} +} + +const emptyGroups = new Map() + +export const buildNotificationGroups = ( + body: string, + chatGlobalSettings: T.RPCChat.GlobalAppNotificationSettings +): Map | undefined => { + const results = JSON.parse(body) as undefined | NotificationsGroupStateFromServer + if (!results) return + + results.notifications[securityGroup] = { + settings: [ + { + description: 'Show message content in phone chat notifications', + description_h: 'Show message content in phone chat notifications', + name: 'plaintextmobile', + subscribed: + !!chatGlobalSettings.settings?.[`${T.RPCChat.GlobalAppNotificationSetting.plaintextmobile}`], + }, + { + description: 'Show message content in computer chat notifications', + description_h: 'Show message content in computer chat notifications', + name: 'plaintextdesktop', + subscribed: + !!chatGlobalSettings.settings?.[`${T.RPCChat.GlobalAppNotificationSetting.plaintextdesktop}`], + }, + { + description: "Show others when you're typing", + description_h: "Show others when you're typing", + name: 'disabletyping', + subscribed: !chatGlobalSettings.settings?.[`${T.RPCChat.GlobalAppNotificationSetting.disabletyping}`], + }, + ], + unsub: false, + } + results.notifications[soundGroup] = { + settings: isAndroidNewerThanN + ? [] + : [ + { + description: 'Phone: use default sound for new messages', + description_h: 'Phone: use default sound for new messages', + name: 'defaultsoundmobile', + subscribed: + !!chatGlobalSettings.settings?.[`${T.RPCChat.GlobalAppNotificationSetting.defaultsoundmobile}`], + } as const, + ], + unsub: false, + } + results.notifications[miscGroup] = { + settings: [ + { + description: 'Convert HEIC images to JPEG for chat attachments', + description_h: 'Convert HEIC images to JPEG for chat attachments', + name: 'convertheic', + subscribed: !!chatGlobalSettings.settings?.[`${T.RPCChat.GlobalAppNotificationSetting.convertheic}`], + }, + ], + unsub: false, + } + + return Object.keys(results.notifications).reduce((m, name) => { + m.set(name, results.notifications[name]!) + return m + }, new Map()) +} + +export const toggleNotificationGroup = ( + groups: ReadonlyMap, + group: string, + name?: string +) => { + const groupState = groups.get(group) ?? {settings: [], unsub: false} + const nextGroups = new Map(groups) + nextGroups.set(group, { + settings: groupState.settings.map(setting => { + let subscribed = setting.subscribed + if (!name) { + subscribed = false + } else if (name === setting.name) { + subscribed = !subscribed + } + return {...setting, subscribed} + }), + unsub: !name && !groupState.unsub, + }) + return nextGroups +} + +export const buildNotificationSavePayload = ( + groups: ReadonlyMap +): NotificationSavePayload => { + const JSONPayload: Array<{key: string; value: string}> = [] + const chatGlobalArg: {[key: string]: boolean} = {} + groups.forEach((group, groupName) => { + if (groupName === securityGroup || groupName === soundGroup || groupName === miscGroup) { + group.settings.forEach(setting => { + chatGlobalArg[`${T.RPCChat.GlobalAppNotificationSetting[setting.name]}`] = + setting.name === 'disabletyping' ? !setting.subscribed : !!setting.subscribed + }) + return + } + + group.settings.forEach(setting => + JSONPayload.push({ + key: `${setting.name}|${groupName}`, + value: setting.subscribed ? '1' : '0', + }) + ) + JSONPayload.push({ + key: `unsub|${groupName}`, + value: group.unsub ? '1' : '0', + }) + }) + return {JSONPayload, chatGlobalArg} +} + +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((resolve, reject) => { + loadSubscriptionsRPC( + [{args: [], endpoint: 'account/subscriptions'}, S.refreshNotificationsWaitingKey], + resolve, + reject + ) + }) + chatGlobalSettings = await new Promise((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((resolve, reject) => { + saveSubscriptionsRPC( + [ + { + JSONPayload, + args: [], + endpoint: 'account/subscribe', + }, + S.waitingKeySettingsGeneric, + ], + resolve, + reject + ) + }) + await new Promise((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] + ) + + return {allowEdit, groups, refresh, toggle} +} + +export default useNotificationSettings diff --git a/shared/stores/settings-chat.tsx b/shared/stores/settings-chat.tsx deleted file mode 100644 index 142fc6d6e882..000000000000 --- a/shared/stores/settings-chat.tsx +++ /dev/null @@ -1,153 +0,0 @@ -import * as T from '@/constants/types' -import {ignorePromise} from '@/constants/utils' -import * as S from '@/constants/strings' -import * as Z from '@/util/zustand' -import {useConfigState} from '@/stores/config' - -export type ChatUnfurlState = { - unfurlMode?: T.RPCChat.UnfurlMode - unfurlWhitelist?: ReadonlyArray - unfurlError?: string -} - -export type ContactSettingsState = { - error: string - settings?: T.RPCGen.ContactSettings -} - -export type ContactSettingsTeamsList = {[k in T.RPCGen.TeamID]: boolean} - -type Store = T.Immutable<{ - contactSettings: ContactSettingsState - unfurl: ChatUnfurlState -}> - -const initialStore: Store = { - contactSettings: { - error: '', - settings: undefined, - }, - unfurl: {unfurlWhitelist: []}, -} - -export type State = Store & { - dispatch: { - contactSettingsSaved: ( - enabled: boolean, - indirectFollowees: boolean, - teamsEnabled: boolean, - teamsList: ContactSettingsTeamsList - ) => void - contactSettingsRefresh: () => void - unfurlSettingsRefresh: () => void - unfurlSettingsSaved: (mode: T.RPCChat.UnfurlMode, whitelist: ReadonlyArray) => void - resetState: () => void - } -} - -export const useSettingsChatState = Z.createZustand('settings-chat', (set, get) => { - const dispatch: State['dispatch'] = { - contactSettingsRefresh: () => { - const f = async () => { - if (!useConfigState.getState().loggedIn) { - return - } - try { - const settings = await T.RPCGen.accountUserGetContactSettingsRpcPromise(undefined) - set(s => { - s.contactSettings = T.castDraft({error: '', settings}) - }) - } catch { - set(s => { - s.contactSettings.error = 'Unable to load contact settings, please try again.' - }) - } - } - ignorePromise(f()) - }, - contactSettingsSaved: (enabled, indirectFollowees, teamsEnabled, teamsList) => { - const f = async () => { - if (!useConfigState.getState().loggedIn) { - return - } - - // Convert the selected teams object into the RPC format. - const teams = Object.entries(teamsList).map(([teamID, enabled]) => ({ - enabled, - teamID, - })) - const allowFolloweeDegrees = indirectFollowees ? 2 : 1 - const settings = { - allowFolloweeDegrees, - allowGoodTeams: teamsEnabled, - enabled, - teams, - } - try { - await T.RPCGen.accountUserSetContactSettingsRpcPromise( - {settings}, - S.waitingKeySettingsChatContactSettingsSave - ) - get().dispatch.contactSettingsRefresh() - } catch { - set(s => { - s.contactSettings.error = 'Unable to save contact settings, please try again.' - }) - } - } - ignorePromise(f()) - }, - resetState: Z.defaultReset, - unfurlSettingsRefresh: () => { - const f = async () => { - if (!useConfigState.getState().loggedIn) { - return - } - try { - const result = await T.RPCChat.localGetUnfurlSettingsRpcPromise( - undefined, - S.waitingKeySettingsChatUnfurl - ) - set(s => { - s.unfurl = { - unfurlError: undefined, - unfurlMode: result.mode, - unfurlWhitelist: T.castDraft(result.whitelist ?? []), - } - }) - } catch { - set(s => { - s.unfurl.unfurlError = 'Unable to load link preview settings, please try again.' - }) - } - } - ignorePromise(f()) - }, - unfurlSettingsSaved: (unfurlMode, unfurlWhitelist) => { - set(s => { - s.unfurl = T.castDraft({unfurlError: undefined, unfurlMode, unfurlWhitelist}) - }) - const f = async () => { - if (!useConfigState.getState().loggedIn) { - return - } - try { - await T.RPCChat.localSaveUnfurlSettingsRpcPromise( - {mode: unfurlMode, whitelist: unfurlWhitelist}, - S.waitingKeySettingsChatUnfurl - ) - get().dispatch.unfurlSettingsRefresh() - } catch { - set(s => { - s.unfurl.unfurlError = 'Unable to save link preview settings, please try again.' - }) - } - } - ignorePromise(f()) - }, - } - return { - ...initialStore, - dispatch, - } -}) diff --git a/shared/stores/settings-notifications.tsx b/shared/stores/settings-notifications.tsx deleted file mode 100644 index 5e974e83d4cd..000000000000 --- a/shared/stores/settings-notifications.tsx +++ /dev/null @@ -1,272 +0,0 @@ -import * as Z from '@/util/zustand' -import * as S from '@/constants/strings' -import {isAndroidNewerThanN} from '@/constants/platform' -import {ignorePromise, timeoutPromise} from '@/constants/utils' -import {RPCError} from '@/util/errors' -import logger from '@/logger' -import * as T from '@/constants/types' - -const securityGroup = 'security' -const soundGroup = 'sound' -const miscGroup = 'misc' - -export type NotificationsSettingsState = { - name: - | 'newmessages' - | 'plaintextmobile' - | 'plaintextdesktop' - | 'defaultsoundmobile' - | 'disabletyping' - | 'convertheic' - subscribed: boolean - description: string -} - -type NotificationsGroupStateFromServer = { - notifications: { - [key: string]: { - settings: Array<{ - description: string - description_h: string - name: NotificationsSettingsState['name'] - subscribed: boolean - }> - unsub: boolean - } - } -} - -export type NotificationsGroupState = { - settings: Array - unsub: boolean -} - -type Store = T.Immutable<{ - allowEdit: boolean - groups: Map -}> - -const initialStore: Store = { - allowEdit: false, - groups: new Map(), -} - -export type State = Store & { - dispatch: { - resetState: () => void - toggle: (group: string, name?: string) => void - refresh: () => void - } -} - -export const useSettingsNotifState = Z.createZustand('settings-notifications', (set, get) => { - const dispatch: State['dispatch'] = { - refresh: () => { - const f = async () => { - let handled = false - // If the rpc is fast don't clear it out first - const maybeClear = async () => { - await timeoutPromise(500) - if (!handled) { - set(s => { - s.allowEdit = true - s.groups = new Map() - }) - } - } - ignorePromise(maybeClear()) - - let body = '' - let chatGlobalSettings: T.RPCChat.GlobalAppNotificationSettings - - try { - const json = await T.RPCGen.apiserverGetWithSessionRpcPromise( - {args: [], endpoint: 'account/subscriptions'}, - S.refreshNotificationsWaitingKey - ) - chatGlobalSettings = await T.RPCChat.localGetGlobalAppNotificationSettingsLocalRpcPromise( - undefined, - S.refreshNotificationsWaitingKey - ) - body = json.body - } catch (error) { - if (!(error instanceof RPCError)) { - return - } - // No need to throw black bars -- handled by Reloadable. - logger.warn(`Error getting notification settings: ${error.desc}`) - return - } - - handled = true - - const results = JSON.parse(body) as undefined | NotificationsGroupStateFromServer - if (!results) return - // Add security group extra since it does not come from API endpoint - results.notifications[securityGroup] = { - settings: [ - { - description: 'Show message content in phone chat notifications', - description_h: 'Show message content in phone chat notifications', - name: 'plaintextmobile', - subscribed: - !!chatGlobalSettings.settings?.[`${T.RPCChat.GlobalAppNotificationSetting.plaintextmobile}`], - }, - { - description: 'Show message content in computer chat notifications', - description_h: 'Show message content in computer chat notifications', - name: 'plaintextdesktop', - subscribed: - !!chatGlobalSettings.settings?.[`${T.RPCChat.GlobalAppNotificationSetting.plaintextdesktop}`], - }, - { - description: "Show others when you're typing", - description_h: "Show others when you're typing", - name: 'disabletyping', - subscribed: - !chatGlobalSettings.settings?.[`${T.RPCChat.GlobalAppNotificationSetting.disabletyping}`], - }, - ], - unsub: false, - } - results.notifications[soundGroup] = { - settings: isAndroidNewerThanN - ? [] - : [ - { - description: 'Phone: use default sound for new messages', - description_h: 'Phone: use default sound for new messages', - name: 'defaultsoundmobile', - subscribed: - !!chatGlobalSettings.settings?.[ - `${T.RPCChat.GlobalAppNotificationSetting.defaultsoundmobile}` - ], - } as const, - ], - unsub: false, - } - results.notifications[miscGroup] = { - settings: [ - { - description: 'Convert HEIC images to JPEG for chat attachments', - description_h: 'Convert HEIC images to JPEG for chat attachments', - name: 'convertheic', - subscribed: - !!chatGlobalSettings.settings?.[`${T.RPCChat.GlobalAppNotificationSetting.convertheic}`], - }, - ], - unsub: false, - } - - set(s => { - s.allowEdit = true - s.groups = Object.keys(results.notifications).reduce((m, n) => { - m.set(n, results.notifications[n]!) - return m - }, new Map()) - }) - } - ignorePromise(f()) - }, - resetState: Z.defaultReset, - toggle: (group, name) => { - const {groups} = get() - if (!groups.get('email')) { - logger.warn('Trying to toggle while not loaded') - return - } - - if (!get().allowEdit) { - logger.warn('Trying to toggle while allowEdit false') - return - } - - const updateSubscribe = (setting: NotificationsSettingsState, storeGroup: string) => { - let subscribed = setting.subscribed - if (!name) { - // clicked unsub all - subscribed = false - } else if (name === setting.name && group === storeGroup) { - // flip if it's the one we're looking for - subscribed = !subscribed - } - return {...setting, subscribed} - } - - const groupMap = get().groups.get(group) ?? { - settings: [], - unsub: false, - } - - const {settings, unsub} = groupMap - - set(s => { - s.allowEdit = false - s.groups.set(group, { - settings: settings.map(s => updateSubscribe(s, group)), - // No name means toggle the unsubscribe option - unsub: !name && !unsub, - }) - }) - - const f = async () => { - const {groups} = get() - if (!groups.get('email')) { - throw new Error('No notifications loaded yet') - } - - const JSONPayload: Array<{key: string; value: string}> = [] - const chatGlobalArg: {[key: string]: boolean} = {} - groups.forEach((group, groupName) => { - if (groupName === securityGroup || groupName === soundGroup || groupName === miscGroup) { - // Special case this since it will go to chat settings endpoint - group.settings.forEach( - setting => - (chatGlobalArg[`${T.RPCChat.GlobalAppNotificationSetting[setting.name]}`] = - setting.name === 'disabletyping' ? !setting.subscribed : !!setting.subscribed) - ) - } else { - group.settings.forEach(setting => - JSONPayload.push({ - key: `${setting.name}|${groupName}`, - value: setting.subscribed ? '1' : '0', - }) - ) - JSONPayload.push({ - key: `unsub|${groupName}`, - value: group.unsub ? '1' : '0', - }) - } - }) - - const result = await T.RPCGen.apiserverPostJSONRpcPromise( - { - JSONPayload, - args: [], - endpoint: 'account/subscribe', - }, - S.waitingKeySettingsGeneric - ) - await T.RPCChat.localSetGlobalAppNotificationSettingsLocalRpcPromise( - {settings: {...chatGlobalArg}}, - S.waitingKeySettingsGeneric - ) - - if ( - !result.body || - (JSON.parse(result.body) as {status?: {code?: number}} | undefined)?.status?.code !== 0 - ) { - throw new Error(`Invalid response ${result.body || '(no result)'}`) - } - set(s => { - s.allowEdit = true - }) - } - ignorePromise(f()) - }, - } - return { - ...initialStore, - dispatch, - } -}) diff --git a/shared/stores/tests/settings-chat.test.ts b/shared/stores/tests/settings-chat.test.ts deleted file mode 100644 index 03c2975e7094..000000000000 --- a/shared/stores/tests/settings-chat.test.ts +++ /dev/null @@ -1,91 +0,0 @@ -/// -import * as S from '../../constants/strings' -import * as T from '../../constants/types' -import {resetAllStores} from '../../util/zustand' -import {useConfigState} from '../config' -import {useSettingsChatState} from '../settings-chat' - -const mockAccountUserGetContactSettingsRpcPromise = jest.fn() -const mockAccountUserSetContactSettingsRpcPromise = jest.fn() -const mockLocalGetUnfurlSettingsRpcPromise = jest.fn() -const mockLocalSaveUnfurlSettingsRpcPromise = jest.fn() - -const flush = async () => new Promise(resolve => setImmediate(resolve)) - -beforeEach(() => { - useConfigState.setState({loggedIn: true}) -}) - -afterEach(() => { - mockAccountUserGetContactSettingsRpcPromise.mockReset() - mockAccountUserSetContactSettingsRpcPromise.mockReset() - mockLocalGetUnfurlSettingsRpcPromise.mockReset() - mockLocalSaveUnfurlSettingsRpcPromise.mockReset() - jest.restoreAllMocks() - resetAllStores() -}) - -test('contactSettingsSaved writes the transformed settings and refreshes state', async () => { - const settings = {display: 'contact-settings'} as any - mockAccountUserSetContactSettingsRpcPromise.mockResolvedValue({}) - mockAccountUserGetContactSettingsRpcPromise.mockResolvedValue(settings) - jest.spyOn(T.RPCGen, 'accountUserSetContactSettingsRpcPromise').mockImplementation( - mockAccountUserSetContactSettingsRpcPromise - ) - jest.spyOn(T.RPCGen, 'accountUserGetContactSettingsRpcPromise').mockImplementation( - mockAccountUserGetContactSettingsRpcPromise - ) - - useSettingsChatState.getState().dispatch.contactSettingsSaved(true, true, false, { - team1: true, - team2: false, - }) - await flush() - await flush() - await flush() - - expect(mockAccountUserSetContactSettingsRpcPromise).toHaveBeenCalledWith( - { - settings: { - allowFolloweeDegrees: 2, - allowGoodTeams: false, - enabled: true, - teams: [ - {enabled: true, teamID: 'team1'}, - {enabled: false, teamID: 'team2'}, - ], - }, - }, - S.waitingKeySettingsChatContactSettingsSave - ) - expect(useSettingsChatState.getState().contactSettings).toEqual({error: '', settings}) -}) - -test('unfurlSettingsSaved updates local state and persists the new settings', async () => { - mockLocalSaveUnfurlSettingsRpcPromise.mockResolvedValue({}) - mockLocalGetUnfurlSettingsRpcPromise.mockResolvedValue({ - mode: T.RPCChat.UnfurlMode.always, - whitelist: ['keybase.io'], - }) - jest.spyOn(T.RPCChat, 'localSaveUnfurlSettingsRpcPromise').mockImplementation( - mockLocalSaveUnfurlSettingsRpcPromise - ) - jest.spyOn(T.RPCChat, 'localGetUnfurlSettingsRpcPromise').mockImplementation( - mockLocalGetUnfurlSettingsRpcPromise - ) - - useSettingsChatState.getState().dispatch.unfurlSettingsSaved(T.RPCChat.UnfurlMode.always, ['keybase.io']) - await flush() - await flush() - await flush() - - expect(useSettingsChatState.getState().unfurl).toEqual({ - unfurlError: undefined, - unfurlMode: T.RPCChat.UnfurlMode.always, - unfurlWhitelist: ['keybase.io'], - }) - expect(mockLocalSaveUnfurlSettingsRpcPromise).toHaveBeenCalledWith( - {mode: T.RPCChat.UnfurlMode.always, whitelist: ['keybase.io']}, - S.waitingKeySettingsChatUnfurl - ) -}) diff --git a/shared/stores/tests/settings-notifications.test.ts b/shared/stores/tests/settings-notifications.test.ts deleted file mode 100644 index da1df05b9dac..000000000000 --- a/shared/stores/tests/settings-notifications.test.ts +++ /dev/null @@ -1,63 +0,0 @@ -/// -import * as T from '../../constants/types' -import {resetAllStores} from '../../util/zustand' -import {useSettingsNotifState} from '../settings-notifications' - -const mockApiserverPostJSONRpcPromise = jest.fn() -const mockLocalSetGlobalAppNotificationSettingsLocalRpcPromise = jest.fn() - -const flush = async () => new Promise(resolve => setImmediate(resolve)) - -afterEach(() => { - mockApiserverPostJSONRpcPromise.mockReset() - mockLocalSetGlobalAppNotificationSettingsLocalRpcPromise.mockReset() - jest.restoreAllMocks() - resetAllStores() -}) - -test('toggle updates the in-memory group state and persists the change', async () => { - mockApiserverPostJSONRpcPromise.mockResolvedValue({body: JSON.stringify({status: {code: 0}})}) - mockLocalSetGlobalAppNotificationSettingsLocalRpcPromise.mockResolvedValue({}) - jest.spyOn(T.RPCGen, 'apiserverPostJSONRpcPromise').mockImplementation(mockApiserverPostJSONRpcPromise) - jest.spyOn(T.RPCChat, 'localSetGlobalAppNotificationSettingsLocalRpcPromise').mockImplementation( - mockLocalSetGlobalAppNotificationSettingsLocalRpcPromise - ) - - useSettingsNotifState.setState(state => ({ - ...state, - allowEdit: true, - groups: new Map([ - [ - 'email', - { - settings: [], - unsub: false, - }, - ], - [ - 'security', - { - settings: [ - {description: 'phone', name: 'plaintextmobile', subscribed: true}, - {description: 'desktop', name: 'plaintextdesktop', subscribed: true}, - {description: 'typing', name: 'disabletyping', subscribed: false}, - ], - unsub: false, - }, - ], - ]), - })) - - useSettingsNotifState.getState().dispatch.toggle('security', 'plaintextmobile') - expect(useSettingsNotifState.getState().allowEdit).toBe(false) - const security = useSettingsNotifState.getState().groups.get('security') - expect(security?.settings[0]?.subscribed).toBe(false) - - await flush() - await flush() - await flush() - - expect(mockApiserverPostJSONRpcPromise).toHaveBeenCalled() - expect(mockLocalSetGlobalAppNotificationSettingsLocalRpcPromise).toHaveBeenCalled() - expect(useSettingsNotifState.getState().allowEdit).toBe(true) -}) diff --git a/skill/zustand-store-pruning/references/store-checklist.md b/skill/zustand-store-pruning/references/store-checklist.md index 5fd240ce8707..ba1946709611 100644 --- a/skill/zustand-store-pruning/references/store-checklist.md +++ b/skill/zustand-store-pruning/references/store-checklist.md @@ -29,9 +29,9 @@ Status: - [x] `profile` kept proof/PGP listener callbacks plus shared navigation hooks in store; moved visible proof/PGP/revoke state and one-screen RPCs into route params or owning components - [x] `recover-password` kept only session callbacks plus `resetEmailSent`; moved recover-flow display state and navigation context into route params - [x] `settings` removed the store entirely; shared settings reload is now a feature helper and lockdown/developer/account actions live in their owning settings screens -- [ ] `settings-chat` +- [x] `settings-chat` removed the store; chat settings screen now owns contact-settings and unfurl RPC/state locally with the same waiting keys and error strings - [x] `settings-email` moved add-email submit/error state into components; kept notification-backed `emails`, `addedEmail`, and row actions in store -- [ ] `settings-notifications` +- [x] `settings-notifications` removed the store; notifications screens and chat settings now own refresh/toggle RPC state in a feature-local hook while preserving the existing settings load path - [x] `settings-password` kept only `randomPW` in store; moved submit/load flows into settings screens - [x] `settings-phone` kept notification-backed `phones` and `addedPhone`; moved add/verify/default-country flow into local hooks and route params - [ ] `signup`