From 96d8faf4b052060b8774ac38c3400ab7d75451ad Mon Sep 17 00:00:00 2001 From: Eric Bailey Date: Tue, 7 Nov 2023 16:06:17 -0600 Subject: [PATCH 1/6] Add persistent state provider (#1830) * Add persistent state provider * Catch write error * Handle read errors, update error msgs * Fix lint * Don't provide initial state to loader * Remove colorMode from shell state * Idea: hook into persisted context from other files * Migrate settings to new hook * Rework persisted state to split individual contexts * Tweak persisted schema and validation --------- Co-authored-by: Paul Frazee --- src/App.native.tsx | 51 +++++--- src/App.web.tsx | 51 +++++--- src/state/models/root-store.ts | 4 - src/state/models/ui/shell.ts | 30 ----- src/state/persisted/broadcast/index.ts | 6 + src/state/persisted/broadcast/index.web.ts | 1 + src/state/persisted/index.ts | 91 ++++++++++++++ src/state/persisted/legacy.ts | 137 +++++++++++++++++++++ src/state/persisted/schema.ts | 68 ++++++++++ src/state/persisted/store.ts | 18 +++ src/state/shell/color-mode.tsx | 56 +++++++++ src/state/shell/index.tsx | 6 +- src/view/screens/Settings.tsx | 20 +-- 13 files changed, 465 insertions(+), 74 deletions(-) create mode 100644 src/state/persisted/broadcast/index.ts create mode 100644 src/state/persisted/broadcast/index.web.ts create mode 100644 src/state/persisted/index.ts create mode 100644 src/state/persisted/legacy.ts create mode 100644 src/state/persisted/schema.ts create mode 100644 src/state/persisted/store.ts create mode 100644 src/state/shell/color-mode.tsx diff --git a/src/App.native.tsx b/src/App.native.tsx index a99dbc9515..f5d35cf741 100644 --- a/src/App.native.tsx +++ b/src/App.native.tsx @@ -10,6 +10,8 @@ import {QueryClientProvider} from '@tanstack/react-query' import 'view/icons' +import {init as initPersistedState} from '#/state/persisted' +import {useColorMode} from 'state/shell' import {ThemeProvider} from 'lib/ThemeContext' import {s} from 'lib/styles' import {RootStoreModel, setupState, RootStoreProvider} from './state' @@ -23,7 +25,8 @@ import {Provider as ShellStateProvider} from 'state/shell' SplashScreen.preventAutoHideAsync() -const App = observer(function AppImpl() { +const InnerApp = observer(function AppImpl() { + const colorMode = useColorMode() const [rootStore, setRootStore] = useState( undefined, ) @@ -44,24 +47,40 @@ const App = observer(function AppImpl() { if (!rootStore) { return null } + return ( + + + + + + + + + + + + + + + ) +}) + +function App() { + const [isReady, setReady] = useState(false) + + React.useEffect(() => { + initPersistedState().then(() => setReady(true)) + }, []) + + if (!isReady) { + return null + } + return ( - - - - - - - - - - - - - - + ) -}) +} export default App diff --git a/src/App.web.tsx b/src/App.web.tsx index 6bbc2065db..adad9ddb6a 100644 --- a/src/App.web.tsx +++ b/src/App.web.tsx @@ -8,6 +8,8 @@ import {RootSiblingParent} from 'react-native-root-siblings' import 'view/icons' +import {init as initPersistedState} from '#/state/persisted' +import {useColorMode} from 'state/shell' import * as analytics from 'lib/analytics/analytics' import {RootStoreModel, setupState, RootStoreProvider} from './state' import {Shell} from 'view/shell/index' @@ -16,7 +18,8 @@ import {ThemeProvider} from 'lib/ThemeContext' import {queryClient} from 'lib/react-query' import {Provider as ShellStateProvider} from 'state/shell' -const App = observer(function AppImpl() { +const InnerApp = observer(function AppImpl() { + const colorMode = useColorMode() const [rootStore, setRootStore] = useState( undefined, ) @@ -34,24 +37,40 @@ const App = observer(function AppImpl() { return null } + return ( + + + + + + + + + + + + + + + ) +}) + +function App() { + const [isReady, setReady] = useState(false) + + React.useEffect(() => { + initPersistedState().then(() => setReady(true)) + }, []) + + if (!isReady) { + return null + } + return ( - - - - - - - - - - - - - - + ) -}) +} export default App diff --git a/src/state/models/root-store.ts b/src/state/models/root-store.ts index cf7307ca31..1943f6dbc1 100644 --- a/src/state/models/root-store.ts +++ b/src/state/models/root-store.ts @@ -74,7 +74,6 @@ export class RootStoreModel { session: this.session.serialize(), me: this.me.serialize(), onboarding: this.onboarding.serialize(), - shell: this.shell.serialize(), preferences: this.preferences.serialize(), invitedUsers: this.invitedUsers.serialize(), mutedThreads: this.mutedThreads.serialize(), @@ -99,9 +98,6 @@ export class RootStoreModel { if (hasProp(v, 'session')) { this.session.hydrate(v.session) } - if (hasProp(v, 'shell')) { - this.shell.hydrate(v.shell) - } if (hasProp(v, 'preferences')) { this.preferences.hydrate(v.preferences) } diff --git a/src/state/models/ui/shell.ts b/src/state/models/ui/shell.ts index d690b9331a..d39131629e 100644 --- a/src/state/models/ui/shell.ts +++ b/src/state/models/ui/shell.ts @@ -2,13 +2,11 @@ import {AppBskyEmbedRecord, AppBskyActorDefs, ModerationUI} from '@atproto/api' import {RootStoreModel} from '../root-store' import {makeAutoObservable, runInAction} from 'mobx' import {ProfileModel} from '../content/profile' -import {isObj, hasProp} from 'lib/type-guards' import {Image as RNImage} from 'react-native-image-crop-picker' import {ImageModel} from '../media/image' import {ListModel} from '../content/list' import {GalleryModel} from '../media/gallery' import {StyleProp, ViewStyle} from 'react-native' -import {isWeb} from 'platform/detection' export type ColorMode = 'system' | 'light' | 'dark' @@ -265,7 +263,6 @@ export interface ComposerOpts { } export class ShellUiModel { - colorMode: ColorMode = 'system' isModalActive = false activeModals: Modal[] = [] isLightboxActive = false @@ -276,40 +273,13 @@ export class ShellUiModel { constructor(public rootStore: RootStoreModel) { makeAutoObservable(this, { - serialize: false, rootStore: false, - hydrate: false, }) this.setupClock() this.setupLoginModals() } - serialize(): unknown { - return { - colorMode: this.colorMode, - } - } - - hydrate(v: unknown) { - if (isObj(v)) { - if (hasProp(v, 'colorMode') && isColorMode(v.colorMode)) { - this.setColorMode(v.colorMode) - } - } - } - - setColorMode(mode: ColorMode) { - this.colorMode = mode - - if (isWeb && typeof window !== 'undefined') { - const html = window.document.documentElement - // remove any other color mode classes - html.className = html.className.replace(/colorMode--\w+/g, '') - html.classList.add(`colorMode--${mode}`) - } - } - /** * returns true if something was closed * (used by the android hardware back btn) diff --git a/src/state/persisted/broadcast/index.ts b/src/state/persisted/broadcast/index.ts new file mode 100644 index 0000000000..e0e7f724b7 --- /dev/null +++ b/src/state/persisted/broadcast/index.ts @@ -0,0 +1,6 @@ +export default class BroadcastChannel { + constructor(public name: string) {} + postMessage(_data: any) {} + close() {} + onmessage: (event: MessageEvent) => void = () => {} +} diff --git a/src/state/persisted/broadcast/index.web.ts b/src/state/persisted/broadcast/index.web.ts new file mode 100644 index 0000000000..33b3548ad3 --- /dev/null +++ b/src/state/persisted/broadcast/index.web.ts @@ -0,0 +1 @@ +export default BroadcastChannel diff --git a/src/state/persisted/index.ts b/src/state/persisted/index.ts new file mode 100644 index 0000000000..67fac6b65b --- /dev/null +++ b/src/state/persisted/index.ts @@ -0,0 +1,91 @@ +import EventEmitter from 'eventemitter3' +import {logger} from '#/logger' +import {defaults, Schema} from '#/state/persisted/schema' +import {migrate} from '#/state/persisted/legacy' +import * as store from '#/state/persisted/store' +import BroadcastChannel from '#/state/persisted/broadcast' + +export type {Schema} from '#/state/persisted/schema' +export {defaults as schema} from '#/state/persisted/schema' + +const broadcast = new BroadcastChannel('BSKY_BROADCAST_CHANNEL') +const UPDATE_EVENT = 'BSKY_UPDATE' + +let _state: Schema = defaults +const _emitter = new EventEmitter() + +/** + * Initializes and returns persisted data state, so that it can be passed to + * the Provider. + */ +export async function init() { + logger.debug('persisted state: initializing') + + broadcast.onmessage = onBroadcastMessage + + try { + await migrate() // migrate old store + const stored = await store.read() // check for new store + if (!stored) await store.write(defaults) // opt: init new store + _state = stored || defaults // return new store + } catch (e) { + logger.error('persisted state: failed to load root state from storage', { + error: e, + }) + // AsyncStorage failured, but we can still continue in memory + return defaults + } +} + +export function get(key: K): Schema[K] { + return _state[key] +} + +export async function write( + key: K, + value: Schema[K], +): Promise { + try { + _state[key] = value + await store.write(_state) + // must happen on next tick, otherwise the tab will read stale storage data + setTimeout(() => broadcast.postMessage({event: UPDATE_EVENT}), 0) + logger.debug(`persisted state: wrote root state to storage`) + } catch (e) { + logger.error(`persisted state: failed writing root state to storage`, { + error: e, + }) + } +} + +export function onUpdate(cb: () => void): () => void { + _emitter.addListener('update', cb) + return () => _emitter.removeListener('update', cb) +} + +async function onBroadcastMessage({data}: MessageEvent) { + // validate event + if (typeof data === 'object' && data.event === UPDATE_EVENT) { + try { + // read next state, possibly updated by another tab + const next = await store.read() + + if (next) { + logger.debug(`persisted state: handling update from broadcast channel`) + _state = next + _emitter.emit('update') + } else { + logger.error( + `persisted state: handled update update from broadcast channel, but found no data`, + ) + } + } catch (e) { + logger.error( + `persisted state: failed handling update from broadcast channel`, + { + error: e, + }, + ) + } + } +} diff --git a/src/state/persisted/legacy.ts b/src/state/persisted/legacy.ts new file mode 100644 index 0000000000..6d0a2bccc1 --- /dev/null +++ b/src/state/persisted/legacy.ts @@ -0,0 +1,137 @@ +import AsyncStorage from '@react-native-async-storage/async-storage' + +import {logger} from '#/logger' +import {defaults, Schema} from '#/state/persisted/schema' +import {write, read} from '#/state/persisted/store' + +/** + * The shape of the serialized data from our legacy Mobx store. + */ +type LegacySchema = { + shell: { + colorMode: 'system' | 'light' | 'dark' + } + session: { + data: { + service: string + did: `did:plc:${string}` + } + accounts: { + service: string + did: `did:plc:${string}` + refreshJwt: string + accessJwt: string + handle: string + email: string + displayName: string + aviUrl: string + emailConfirmed: boolean + }[] + } + me: { + did: `did:plc:${string}` + handle: string + displayName: string + description: string + avatar: string + } + onboarding: { + step: string + } + preferences: { + primaryLanguage: string + contentLanguages: string[] + postLanguage: string + postLanguageHistory: string[] + contentLabels: { + nsfw: string + nudity: string + suggestive: string + gore: string + hate: string + spam: string + impersonation: string + } + savedFeeds: string[] + pinnedFeeds: string[] + requireAltTextEnabled: boolean + } + invitedUsers: { + seenDids: string[] + copiedInvites: string[] + } + mutedThreads: {uris: string[]} + reminders: {lastEmailConfirm: string} +} + +const DEPRECATED_ROOT_STATE_STORAGE_KEY = 'root' + +export function transform(legacy: LegacySchema): Schema { + return { + colorMode: legacy.shell?.colorMode || defaults.colorMode, + session: { + accounts: legacy.session.accounts || defaults.session.accounts, + currentAccount: + legacy.session.accounts.find(a => a.did === legacy.session.data.did) || + defaults.session.currentAccount, + }, + reminders: { + lastEmailConfirmReminder: + legacy.reminders.lastEmailConfirm || + defaults.reminders.lastEmailConfirmReminder, + }, + languagePrefs: { + primaryLanguage: + legacy.preferences.primaryLanguage || + defaults.languagePrefs.primaryLanguage, + contentLanguages: + legacy.preferences.contentLanguages || + defaults.languagePrefs.contentLanguages, + postLanguage: + legacy.preferences.postLanguage || defaults.languagePrefs.postLanguage, + postLanguageHistory: + legacy.preferences.postLanguageHistory || + defaults.languagePrefs.postLanguageHistory, + }, + requireAltTextEnabled: + legacy.preferences.requireAltTextEnabled || + defaults.requireAltTextEnabled, + mutedThreads: legacy.mutedThreads.uris || defaults.mutedThreads, + invitedUsers: { + seenDids: legacy.invitedUsers.seenDids || defaults.invitedUsers.seenDids, + copiedInvites: + legacy.invitedUsers.copiedInvites || + defaults.invitedUsers.copiedInvites, + }, + onboarding: { + step: legacy.onboarding.step || defaults.onboarding.step, + }, + } +} + +/** + * Migrates legacy persisted state to new store if new store doesn't exist in + * local storage AND old storage exists. + */ +export async function migrate() { + logger.debug('persisted state: migrate') + + try { + const rawLegacyData = await AsyncStorage.getItem( + DEPRECATED_ROOT_STATE_STORAGE_KEY, + ) + const alreadyMigrated = Boolean(await read()) + + if (!alreadyMigrated && rawLegacyData) { + logger.debug('persisted state: migrating legacy storage') + const legacyData = JSON.parse(rawLegacyData) + const newData = transform(legacyData) + await write(newData) + logger.debug('persisted state: migrated legacy storage') + } + } catch (e) { + logger.error('persisted state: error migrating legacy storage', { + error: String(e), + }) + } +} diff --git a/src/state/persisted/schema.ts b/src/state/persisted/schema.ts new file mode 100644 index 0000000000..1c5d317cce --- /dev/null +++ b/src/state/persisted/schema.ts @@ -0,0 +1,68 @@ +import {z} from 'zod' +import {deviceLocales} from '#/platform/detection' + +// only data needed for rendering account page +const accountSchema = z.object({ + service: z.string(), + did: z.string(), + refreshJwt: z.string().optional(), + accessJwt: z.string().optional(), + handle: z.string(), + displayName: z.string(), + aviUrl: z.string(), +}) + +export const schema = z.object({ + colorMode: z.enum(['system', 'light', 'dark']), + session: z.object({ + accounts: z.array(accountSchema), + currentAccount: accountSchema.optional(), + }), + reminders: z.object({ + lastEmailConfirmReminder: z.string().optional(), + }), + languagePrefs: z.object({ + primaryLanguage: z.string(), // should move to server + contentLanguages: z.array(z.string()), // should move to server + postLanguage: z.string(), // should move to server + postLanguageHistory: z.array(z.string()), + }), + requireAltTextEnabled: z.boolean(), // should move to server + mutedThreads: z.array(z.string()), // should move to server + invitedUsers: z.object({ + seenDids: z.array(z.string()), + copiedInvites: z.array(z.string()), + }), + onboarding: z.object({ + step: z.string(), + }), +}) +export type Schema = z.infer + +export const defaults: Schema = { + colorMode: 'system', + session: { + accounts: [], + currentAccount: undefined, + }, + reminders: { + lastEmailConfirmReminder: undefined, + }, + languagePrefs: { + primaryLanguage: deviceLocales[0] || 'en', + contentLanguages: deviceLocales || [], + postLanguage: deviceLocales[0] || 'en', + postLanguageHistory: (deviceLocales || []) + .concat(['en', 'ja', 'pt', 'de']) + .slice(0, 6), + }, + requireAltTextEnabled: false, + mutedThreads: [], + invitedUsers: { + seenDids: [], + copiedInvites: [], + }, + onboarding: { + step: 'Home', + }, +} diff --git a/src/state/persisted/store.ts b/src/state/persisted/store.ts new file mode 100644 index 0000000000..2b03bec20c --- /dev/null +++ b/src/state/persisted/store.ts @@ -0,0 +1,18 @@ +import AsyncStorage from '@react-native-async-storage/async-storage' + +import {Schema, schema} from '#/state/persisted/schema' + +const BSKY_STORAGE = 'BSKY_STORAGE' + +export async function write(value: Schema) { + schema.parse(value) + await AsyncStorage.setItem(BSKY_STORAGE, JSON.stringify(value)) +} + +export async function read(): Promise { + const rawData = await AsyncStorage.getItem(BSKY_STORAGE) + const objData = rawData ? JSON.parse(rawData) : undefined + if (schema.safeParse(objData).success) { + return objData + } +} diff --git a/src/state/shell/color-mode.tsx b/src/state/shell/color-mode.tsx new file mode 100644 index 0000000000..74379da371 --- /dev/null +++ b/src/state/shell/color-mode.tsx @@ -0,0 +1,56 @@ +import React from 'react' +import {isWeb} from '#/platform/detection' +import * as persisted from '#/state/persisted' + +type StateContext = persisted.Schema['colorMode'] +type SetContext = (v: persisted.Schema['colorMode']) => void + +const stateContext = React.createContext('system') +const setContext = React.createContext( + (_: persisted.Schema['colorMode']) => {}, +) + +export function Provider({children}: React.PropsWithChildren<{}>) { + const [state, setState] = React.useState(persisted.get('colorMode')) + + const setStateWrapped = React.useCallback( + (colorMode: persisted.Schema['colorMode']) => { + setState(colorMode) + persisted.write('colorMode', colorMode) + updateDocument(colorMode) + }, + [setState], + ) + + React.useEffect(() => { + return persisted.onUpdate(() => { + setState(persisted.get('colorMode')) + updateDocument(persisted.get('colorMode')) + }) + }, [setStateWrapped]) + + return ( + + + {children} + + + ) +} + +export function useColorMode() { + return React.useContext(stateContext) +} + +export function useSetColorMode() { + return React.useContext(setContext) +} + +function updateDocument(colorMode: string) { + if (isWeb && typeof window !== 'undefined') { + const html = window.document.documentElement + // remove any other color mode classes + html.className = html.className.replace(/colorMode--\w+/g, '') + html.classList.add(`colorMode--${colorMode}`) + } +} diff --git a/src/state/shell/index.tsx b/src/state/shell/index.tsx index ac2f24b4a9..1e01a4e7d5 100644 --- a/src/state/shell/index.tsx +++ b/src/state/shell/index.tsx @@ -2,6 +2,7 @@ import React from 'react' import {Provider as DrawerOpenProvider} from './drawer-open' import {Provider as DrawerSwipableProvider} from './drawer-swipe-disabled' import {Provider as MinimalModeProvider} from './minimal-mode' +import {Provider as ColorModeProvider} from './color-mode' export {useIsDrawerOpen, useSetDrawerOpen} from './drawer-open' export { @@ -9,12 +10,15 @@ export { useSetDrawerSwipeDisabled, } from './drawer-swipe-disabled' export {useMinimalShellMode, useSetMinimalShellMode} from './minimal-mode' +export {useColorMode, useSetColorMode} from './color-mode' export function Provider({children}: React.PropsWithChildren<{}>) { return ( - {children} + + {children} + ) diff --git a/src/view/screens/Settings.tsx b/src/view/screens/Settings.tsx index 3f957f3ffb..ca4ef2a40d 100644 --- a/src/view/screens/Settings.tsx +++ b/src/view/screens/Settings.tsx @@ -46,7 +46,11 @@ import Clipboard from '@react-native-clipboard/clipboard' import {makeProfileLink} from 'lib/routes/links' import {AccountDropdownBtn} from 'view/com/util/AccountDropdownBtn' import {logger} from '#/logger' -import {useSetMinimalShellMode} from '#/state/shell' +import { + useSetMinimalShellMode, + useColorMode, + useSetColorMode, +} from '#/state/shell' // TEMPORARY (APP-700) // remove after backend testing finishes @@ -57,6 +61,8 @@ import {STATUS_PAGE_URL} from 'lib/constants' type Props = NativeStackScreenProps export const SettingsScreen = withAuthRequired( observer(function Settings({}: Props) { + const colorMode = useColorMode() + const setColorMode = useSetColorMode() const pal = usePalette('default') const store = useStores() const setMinimalShellMode = useSetMinimalShellMode() @@ -379,23 +385,23 @@ export const SettingsScreen = withAuthRequired( store.shell.setColorMode('system')} + onSelect={() => setColorMode('system')} accessibilityHint="Set color theme to system setting" /> store.shell.setColorMode('light')} + onSelect={() => setColorMode('light')} accessibilityHint="Set color theme to light" /> store.shell.setColorMode('dark')} + onSelect={() => setColorMode('dark')} accessibilityHint="Set color theme to dark" /> From 2acc88e78df1c1f4c21bf9a333f3432781a64135 Mon Sep 17 00:00:00 2001 From: Paul Frazee Date: Tue, 7 Nov 2023 16:39:13 -0800 Subject: [PATCH 2/6] Move reminders to new persisted state layer (#1834) --- src/state/models/root-store.ts | 6 --- src/state/models/ui/reminders.e2e.ts | 24 ----------- src/state/models/ui/reminders.ts | 64 ---------------------------- src/state/models/ui/shell.ts | 13 +++++- src/state/persisted/legacy.ts | 4 +- src/state/persisted/schema.ts | 4 +- src/state/shell/reminders.e2e.ts | 11 +++++ src/state/shell/reminders.ts | 40 +++++++++++++++++ 8 files changed, 66 insertions(+), 100 deletions(-) delete mode 100644 src/state/models/ui/reminders.e2e.ts delete mode 100644 src/state/models/ui/reminders.ts create mode 100644 src/state/shell/reminders.e2e.ts create mode 100644 src/state/shell/reminders.ts diff --git a/src/state/models/root-store.ts b/src/state/models/root-store.ts index 1943f6dbc1..6ba78e7115 100644 --- a/src/state/models/root-store.ts +++ b/src/state/models/root-store.ts @@ -20,7 +20,6 @@ import {PreferencesModel} from './ui/preferences' import {resetToTab} from '../../Navigation' import {ImageSizesCache} from './cache/image-sizes' import {MutedThreads} from './muted-threads' -import {Reminders} from './ui/reminders' import {reset as resetNavigation} from '../../Navigation' import {logger} from '#/logger' @@ -53,7 +52,6 @@ export class RootStoreModel { linkMetas = new LinkMetasCache(this) imageSizes = new ImageSizesCache() mutedThreads = new MutedThreads() - reminders = new Reminders(this) constructor(agent: BskyAgent) { this.agent = agent @@ -77,7 +75,6 @@ export class RootStoreModel { preferences: this.preferences.serialize(), invitedUsers: this.invitedUsers.serialize(), mutedThreads: this.mutedThreads.serialize(), - reminders: this.reminders.serialize(), } } @@ -107,9 +104,6 @@ export class RootStoreModel { if (hasProp(v, 'mutedThreads')) { this.mutedThreads.hydrate(v.mutedThreads) } - if (hasProp(v, 'reminders')) { - this.reminders.hydrate(v.reminders) - } } } diff --git a/src/state/models/ui/reminders.e2e.ts b/src/state/models/ui/reminders.e2e.ts deleted file mode 100644 index ec0eca40d2..0000000000 --- a/src/state/models/ui/reminders.e2e.ts +++ /dev/null @@ -1,24 +0,0 @@ -import {makeAutoObservable} from 'mobx' -import {RootStoreModel} from '../root-store' - -export class Reminders { - constructor(public rootStore: RootStoreModel) { - makeAutoObservable( - this, - {serialize: false, hydrate: false}, - {autoBind: true}, - ) - } - - serialize() { - return {} - } - - hydrate(_v: unknown) {} - - get shouldRequestEmailConfirmation() { - return false - } - - setEmailConfirmationRequested() {} -} diff --git a/src/state/models/ui/reminders.ts b/src/state/models/ui/reminders.ts deleted file mode 100644 index c650de0040..0000000000 --- a/src/state/models/ui/reminders.ts +++ /dev/null @@ -1,64 +0,0 @@ -import {makeAutoObservable} from 'mobx' -import {isObj, hasProp} from 'lib/type-guards' -import {RootStoreModel} from '../root-store' -import {toHashCode} from 'lib/strings/helpers' - -export class Reminders { - lastEmailConfirm: Date | null = null - - constructor(public rootStore: RootStoreModel) { - makeAutoObservable( - this, - {serialize: false, hydrate: false}, - {autoBind: true}, - ) - } - - serialize() { - return { - lastEmailConfirm: this.lastEmailConfirm - ? this.lastEmailConfirm.toISOString() - : undefined, - } - } - - hydrate(v: unknown) { - if ( - isObj(v) && - hasProp(v, 'lastEmailConfirm') && - typeof v.lastEmailConfirm === 'string' - ) { - this.lastEmailConfirm = new Date(v.lastEmailConfirm) - } - } - - get shouldRequestEmailConfirmation() { - const sess = this.rootStore.session.currentSession - if (!sess) { - return false - } - if (sess.emailConfirmed) { - return false - } - if (this.rootStore.onboarding.isActive) { - return false - } - // only prompt once - if (this.lastEmailConfirm) { - return false - } - const today = new Date() - // shard the users into 2 day of the week buckets - // (this is to avoid a sudden influx of email updates when - // this feature rolls out) - const code = toHashCode(sess.did) % 7 - if (code !== today.getDay() && code !== (today.getDay() + 1) % 7) { - return false - } - return true - } - - setEmailConfirmationRequested() { - this.lastEmailConfirm = new Date() - } -} diff --git a/src/state/models/ui/shell.ts b/src/state/models/ui/shell.ts index d39131629e..343fff86d2 100644 --- a/src/state/models/ui/shell.ts +++ b/src/state/models/ui/shell.ts @@ -7,6 +7,10 @@ import {ImageModel} from '../media/image' import {ListModel} from '../content/list' import {GalleryModel} from '../media/gallery' import {StyleProp, ViewStyle} from 'react-native' +import { + shouldRequestEmailConfirmation, + setEmailConfirmationRequested, +} from '#/state/shell/reminders' export type ColorMode = 'system' | 'light' | 'dark' @@ -358,9 +362,14 @@ export class ShellUiModel { setupLoginModals() { this.rootStore.onSessionReady(() => { - if (this.rootStore.reminders.shouldRequestEmailConfirmation) { + if ( + shouldRequestEmailConfirmation( + this.rootStore.session, + this.rootStore.onboarding, + ) + ) { this.openModal({name: 'verify-email', showReminder: true}) - this.rootStore.reminders.setEmailConfirmationRequested() + setEmailConfirmationRequested() } }) } diff --git a/src/state/persisted/legacy.ts b/src/state/persisted/legacy.ts index 6d0a2bccc1..67eef81a0f 100644 --- a/src/state/persisted/legacy.ts +++ b/src/state/persisted/legacy.ts @@ -76,9 +76,9 @@ export function transform(legacy: LegacySchema): Schema { defaults.session.currentAccount, }, reminders: { - lastEmailConfirmReminder: + lastEmailConfirm: legacy.reminders.lastEmailConfirm || - defaults.reminders.lastEmailConfirmReminder, + defaults.reminders.lastEmailConfirm, }, languagePrefs: { primaryLanguage: diff --git a/src/state/persisted/schema.ts b/src/state/persisted/schema.ts index 1c5d317cce..c00ee500ab 100644 --- a/src/state/persisted/schema.ts +++ b/src/state/persisted/schema.ts @@ -19,7 +19,7 @@ export const schema = z.object({ currentAccount: accountSchema.optional(), }), reminders: z.object({ - lastEmailConfirmReminder: z.string().optional(), + lastEmailConfirm: z.string().optional(), }), languagePrefs: z.object({ primaryLanguage: z.string(), // should move to server @@ -46,7 +46,7 @@ export const defaults: Schema = { currentAccount: undefined, }, reminders: { - lastEmailConfirmReminder: undefined, + lastEmailConfirm: undefined, }, languagePrefs: { primaryLanguage: deviceLocales[0] || 'en', diff --git a/src/state/shell/reminders.e2e.ts b/src/state/shell/reminders.e2e.ts new file mode 100644 index 0000000000..6238ffa290 --- /dev/null +++ b/src/state/shell/reminders.e2e.ts @@ -0,0 +1,11 @@ +import {OnboardingModel} from '../models/discovery/onboarding' +import {SessionModel} from '../models/session' + +export function shouldRequestEmailConfirmation( + _session: SessionModel, + _onboarding: OnboardingModel, +) { + return false +} + +export function setEmailConfirmationRequested() {} diff --git a/src/state/shell/reminders.ts b/src/state/shell/reminders.ts new file mode 100644 index 0000000000..d68a272ac1 --- /dev/null +++ b/src/state/shell/reminders.ts @@ -0,0 +1,40 @@ +import * as persisted from '#/state/persisted' +import {OnboardingModel} from '../models/discovery/onboarding' +import {SessionModel} from '../models/session' +import {toHashCode} from 'lib/strings/helpers' + +export function shouldRequestEmailConfirmation( + session: SessionModel, + onboarding: OnboardingModel, +) { + const sess = session.currentSession + if (!sess) { + return false + } + if (sess.emailConfirmed) { + return false + } + if (onboarding.isActive) { + return false + } + // only prompt once + if (persisted.get('reminders').lastEmailConfirm) { + return false + } + const today = new Date() + // shard the users into 2 day of the week buckets + // (this is to avoid a sudden influx of email updates when + // this feature rolls out) + const code = toHashCode(sess.did) % 7 + if (code !== today.getDay() && code !== (today.getDay() + 1) % 7) { + return false + } + return true +} + +export function setEmailConfirmationRequested() { + persisted.write('reminders', { + ...persisted.get('reminders'), + lastEmailConfirm: new Date().toISOString(), + }) +} From 3a211017d3d972fb442069e38d1b8ff1a2edbd57 Mon Sep 17 00:00:00 2001 From: Paul Frazee Date: Wed, 8 Nov 2023 08:52:01 -0800 Subject: [PATCH 3/6] Move require alt-text to new persistence + context (#1839) --- src/state/models/ui/preferences.ts | 13 -------- src/state/persisted/index.ts | 2 +- src/state/shell/alt-text-required.tsx | 48 +++++++++++++++++++++++++++ src/state/shell/index.tsx | 9 ++++- src/view/com/composer/Composer.tsx | 14 ++++---- src/view/screens/Settings.tsx | 8 +++-- 6 files changed, 69 insertions(+), 25 deletions(-) create mode 100644 src/state/shell/alt-text-required.tsx diff --git a/src/state/models/ui/preferences.ts b/src/state/models/ui/preferences.ts index 6e43198a31..d03fa8d24a 100644 --- a/src/state/models/ui/preferences.ts +++ b/src/state/models/ui/preferences.ts @@ -83,7 +83,6 @@ export class PreferencesModel { prioritizeFollowedUsers: true, lab_treeViewEnabled: false, // experimental } - requireAltTextEnabled: boolean = false // used to help with transitions from device-stored to server-stored preferences legacyPreferences: LegacyPreferences | undefined @@ -111,7 +110,6 @@ export class PreferencesModel { contentLabels: this.contentLabels, savedFeeds: this.savedFeeds, pinnedFeeds: this.pinnedFeeds, - requireAltTextEnabled: this.requireAltTextEnabled, } } @@ -180,13 +178,6 @@ export class PreferencesModel { ) { this.pinnedFeeds = v.pinnedFeeds } - // check if requiring alt text is enabled in preferences, then hydrate - if ( - hasProp(v, 'requireAltTextEnabled') && - typeof v.requireAltTextEnabled === 'boolean' - ) { - this.requireAltTextEnabled = v.requireAltTextEnabled - } // grab legacy values this.legacyPreferences = getLegacyPreferences(v) } @@ -608,10 +599,6 @@ export class PreferencesModel { } } - toggleRequireAltTextEnabled() { - this.requireAltTextEnabled = !this.requireAltTextEnabled - } - setPrimaryLanguage(lang: string) { this.primaryLanguage = lang } diff --git a/src/state/persisted/index.ts b/src/state/persisted/index.ts index 67fac6b65b..f43cc9527c 100644 --- a/src/state/persisted/index.ts +++ b/src/state/persisted/index.ts @@ -6,7 +6,7 @@ import * as store from '#/state/persisted/store' import BroadcastChannel from '#/state/persisted/broadcast' export type {Schema} from '#/state/persisted/schema' -export {defaults as schema} from '#/state/persisted/schema' +export {defaults} from '#/state/persisted/schema' const broadcast = new BroadcastChannel('BSKY_BROADCAST_CHANNEL') const UPDATE_EVENT = 'BSKY_UPDATE' diff --git a/src/state/shell/alt-text-required.tsx b/src/state/shell/alt-text-required.tsx new file mode 100644 index 0000000000..81de9e0060 --- /dev/null +++ b/src/state/shell/alt-text-required.tsx @@ -0,0 +1,48 @@ +import React from 'react' +import * as persisted from '#/state/persisted' + +type StateContext = persisted.Schema['requireAltTextEnabled'] +type SetContext = (v: persisted.Schema['requireAltTextEnabled']) => void + +const stateContext = React.createContext( + persisted.defaults.requireAltTextEnabled, +) +const setContext = React.createContext( + (_: persisted.Schema['requireAltTextEnabled']) => {}, +) + +export function Provider({children}: React.PropsWithChildren<{}>) { + const [state, setState] = React.useState( + persisted.get('requireAltTextEnabled'), + ) + + const setStateWrapped = React.useCallback( + (requireAltTextEnabled: persisted.Schema['requireAltTextEnabled']) => { + setState(requireAltTextEnabled) + persisted.write('requireAltTextEnabled', requireAltTextEnabled) + }, + [setState], + ) + + React.useEffect(() => { + return persisted.onUpdate(() => { + setState(persisted.get('requireAltTextEnabled')) + }) + }, [setStateWrapped]) + + return ( + + + {children} + + + ) +} + +export function useRequireAltTextEnabled() { + return React.useContext(stateContext) +} + +export function useSetRequireAltTextEnabled() { + return React.useContext(setContext) +} diff --git a/src/state/shell/index.tsx b/src/state/shell/index.tsx index 1e01a4e7d5..807ee79ab8 100644 --- a/src/state/shell/index.tsx +++ b/src/state/shell/index.tsx @@ -3,6 +3,7 @@ import {Provider as DrawerOpenProvider} from './drawer-open' import {Provider as DrawerSwipableProvider} from './drawer-swipe-disabled' import {Provider as MinimalModeProvider} from './minimal-mode' import {Provider as ColorModeProvider} from './color-mode' +import {Provider as AltTextRequiredProvider} from './alt-text-required' export {useIsDrawerOpen, useSetDrawerOpen} from './drawer-open' export { @@ -11,13 +12,19 @@ export { } from './drawer-swipe-disabled' export {useMinimalShellMode, useSetMinimalShellMode} from './minimal-mode' export {useColorMode, useSetColorMode} from './color-mode' +export { + useRequireAltTextEnabled, + useSetRequireAltTextEnabled, +} from './alt-text-required' export function Provider({children}: React.PropsWithChildren<{}>) { return ( - {children} + + {children} + diff --git a/src/view/com/composer/Composer.tsx b/src/view/com/composer/Composer.tsx index e44a0ce010..a08992df42 100644 --- a/src/view/com/composer/Composer.tsx +++ b/src/view/com/composer/Composer.tsx @@ -49,6 +49,7 @@ import {LabelsBtn} from './labels/LabelsBtn' import {SelectLangBtn} from './select-language/SelectLangBtn' import {EmojiPickerButton} from './text-input/web/EmojiPicker.web' import {insertMentionAt} from 'lib/strings/mention-manip' +import {useRequireAltTextEnabled} from '#/state/shell' type Props = ComposerOpts export const ComposePost = observer(function ComposePost({ @@ -61,6 +62,7 @@ export const ComposePost = observer(function ComposePost({ const pal = usePalette('default') const {isDesktop, isMobile} = useWebMediaQueries() const store = useStores() + const requireAltTextEnabled = useRequireAltTextEnabled() const textInput = useRef(null) const [isKeyboardVisible] = useIsKeyboardVisible({iosUseWillEvents: true}) const [isProcessing, setIsProcessing] = useState(false) @@ -187,7 +189,7 @@ export const ComposePost = observer(function ComposePost({ if (isProcessing || graphemeLength > MAX_GRAPHEME_LENGTH) { return } - if (store.preferences.requireAltTextEnabled && gallery.needsAltText) { + if (requireAltTextEnabled && gallery.needsAltText) { return } @@ -241,12 +243,8 @@ export const ComposePost = observer(function ComposePost({ const canPost = useMemo( () => graphemeLength <= MAX_GRAPHEME_LENGTH && - (!store.preferences.requireAltTextEnabled || !gallery.needsAltText), - [ - graphemeLength, - store.preferences.requireAltTextEnabled, - gallery.needsAltText, - ], + (!requireAltTextEnabled || !gallery.needsAltText), + [graphemeLength, requireAltTextEnabled, gallery.needsAltText], ) const selectTextInputPlaceholder = replyTo ? 'Write your reply' : `What's up?` @@ -314,7 +312,7 @@ export const ComposePost = observer(function ComposePost({ )} - {store.preferences.requireAltTextEnabled && gallery.needsAltText && ( + {requireAltTextEnabled && gallery.needsAltText && ( () const {isMobile} = useWebMediaQueries() const {screen, track} = useAnalytics() @@ -372,8 +376,8 @@ export const SettingsScreen = withAuthRequired( type="default-light" label="Require alt text before posting" labelType="lg" - isSelected={store.preferences.requireAltTextEnabled} - onPress={store.preferences.toggleRequireAltTextEnabled} + isSelected={requireAltTextEnabled} + onPress={() => setRequireAltTextEnabled(!requireAltTextEnabled)} /> From 4afed4be281b6319c328938e4ed757624a78b13c Mon Sep 17 00:00:00 2001 From: Paul Frazee Date: Wed, 8 Nov 2023 09:04:06 -0800 Subject: [PATCH 4/6] Move onboarding state to new persistence + reducer context (#1835) --- src/state/models/discovery/onboarding.ts | 106 ---------------- src/state/models/root-store.ts | 6 - src/state/models/ui/create-account.ts | 7 +- src/state/persisted/schema.ts | 6 +- src/state/shell/color-mode.tsx | 2 +- src/state/shell/index.tsx | 6 +- src/state/shell/onboarding.tsx | 119 ++++++++++++++++++ src/view/com/auth/Onboarding.tsx | 15 +-- src/view/com/auth/create/CreateAccount.tsx | 6 +- .../auth/onboarding/RecommendedFollows.tsx | 39 +++--- .../onboarding/RecommendedFollowsItem.tsx | 33 ++--- src/view/com/auth/withAuthRequired.tsx | 4 +- src/view/screens/Settings.tsx | 6 +- src/view/shell/index.web.tsx | 11 +- 14 files changed, 199 insertions(+), 167 deletions(-) delete mode 100644 src/state/models/discovery/onboarding.ts create mode 100644 src/state/shell/onboarding.tsx diff --git a/src/state/models/discovery/onboarding.ts b/src/state/models/discovery/onboarding.ts deleted file mode 100644 index 3638e7f0d2..0000000000 --- a/src/state/models/discovery/onboarding.ts +++ /dev/null @@ -1,106 +0,0 @@ -import {makeAutoObservable} from 'mobx' -import {RootStoreModel} from '../root-store' -import {hasProp} from 'lib/type-guards' -import {track} from 'lib/analytics/analytics' -import {SuggestedActorsModel} from './suggested-actors' - -export const OnboardingScreenSteps = { - Welcome: 'Welcome', - RecommendedFeeds: 'RecommendedFeeds', - RecommendedFollows: 'RecommendedFollows', - Home: 'Home', -} as const - -type OnboardingStep = - (typeof OnboardingScreenSteps)[keyof typeof OnboardingScreenSteps] -const OnboardingStepsArray = Object.values(OnboardingScreenSteps) -export class OnboardingModel { - // state - step: OnboardingStep = 'Home' // default state to skip onboarding, only enabled for new users by calling start() - - // data - suggestedActors: SuggestedActorsModel - - constructor(public rootStore: RootStoreModel) { - this.suggestedActors = new SuggestedActorsModel(this.rootStore) - makeAutoObservable(this, { - rootStore: false, - hydrate: false, - serialize: false, - }) - } - - serialize(): unknown { - return { - step: this.step, - } - } - - hydrate(v: unknown) { - if (typeof v === 'object' && v !== null) { - if ( - hasProp(v, 'step') && - typeof v.step === 'string' && - OnboardingStepsArray.includes(v.step as OnboardingStep) - ) { - this.step = v.step as OnboardingStep - } - } else { - // if there is no valid state, we'll just reset - this.reset() - } - } - - /** - * Returns the name of the next screen in the onboarding process based on the current step or screen name provided. - * @param {OnboardingStep} [currentScreenName] - * @returns name of next screen in the onboarding process - */ - next(currentScreenName?: OnboardingStep) { - currentScreenName = currentScreenName || this.step - if (currentScreenName === 'Welcome') { - this.step = 'RecommendedFeeds' - return this.step - } else if (this.step === 'RecommendedFeeds') { - this.step = 'RecommendedFollows' - // prefetch recommended follows - this.suggestedActors.loadMore(true) - return this.step - } else if (this.step === 'RecommendedFollows') { - this.finish() - return this.step - } else { - // if we get here, we're in an invalid state, let's just go Home - return 'Home' - } - } - - start() { - this.step = 'Welcome' - track('Onboarding:Begin') - } - - finish() { - this.rootStore.me.mainFeed.refresh() // load the selected content - this.step = 'Home' - track('Onboarding:Complete') - } - - reset() { - this.step = 'Welcome' - track('Onboarding:Reset') - } - - skip() { - this.step = 'Home' - track('Onboarding:Skipped') - } - - get isComplete() { - return this.step === 'Home' - } - - get isActive() { - return !this.isComplete - } -} diff --git a/src/state/models/root-store.ts b/src/state/models/root-store.ts index 6ba78e7115..f04a9922d8 100644 --- a/src/state/models/root-store.ts +++ b/src/state/models/root-store.ts @@ -27,7 +27,6 @@ import {logger} from '#/logger' // remove after backend testing finishes // -prf import {applyDebugHeader} from 'lib/api/debug-appview-proxy-header' -import {OnboardingModel} from './discovery/onboarding' export const appInfo = z.object({ build: z.string(), @@ -44,7 +43,6 @@ export class RootStoreModel { shell = new ShellUiModel(this) preferences = new PreferencesModel(this) me = new MeModel(this) - onboarding = new OnboardingModel(this) invitedUsers = new InvitedUsers(this) handleResolutions = new HandleResolutionsCache() profiles = new ProfilesCache(this) @@ -71,7 +69,6 @@ export class RootStoreModel { appInfo: this.appInfo, session: this.session.serialize(), me: this.me.serialize(), - onboarding: this.onboarding.serialize(), preferences: this.preferences.serialize(), invitedUsers: this.invitedUsers.serialize(), mutedThreads: this.mutedThreads.serialize(), @@ -89,9 +86,6 @@ export class RootStoreModel { if (hasProp(v, 'me')) { this.me.hydrate(v.me) } - if (hasProp(v, 'onboarding')) { - this.onboarding.hydrate(v.onboarding) - } if (hasProp(v, 'session')) { this.session.hydrate(v.session) } diff --git a/src/state/models/ui/create-account.ts b/src/state/models/ui/create-account.ts index 1711b530fb..39c881db63 100644 --- a/src/state/models/ui/create-account.ts +++ b/src/state/models/ui/create-account.ts @@ -9,6 +9,7 @@ import {cleanError} from 'lib/strings/errors' import {getAge} from 'lib/strings/time' import {track} from 'lib/analytics/analytics' import {logger} from '#/logger' +import {DispatchContext as OnboardingDispatchContext} from '#/state/shell/onboarding' const DEFAULT_DATE = new Date(Date.now() - 60e3 * 60 * 24 * 365 * 20) // default to 20 years ago @@ -90,7 +91,7 @@ export class CreateAccountModel { } } - async submit() { + async submit(onboardingDispatch: OnboardingDispatchContext) { if (!this.email) { this.setStep(2) return this.setError('Please enter your email.') @@ -111,7 +112,7 @@ export class CreateAccountModel { this.setIsProcessing(true) try { - this.rootStore.onboarding.start() // start now to avoid flashing the wrong view + onboardingDispatch({type: 'start'}) // start now to avoid flashing the wrong view await this.rootStore.session.createAccount({ service: this.serviceUrl, email: this.email, @@ -122,7 +123,7 @@ export class CreateAccountModel { /* dont await */ this.rootStore.preferences.setBirthDate(this.birthDate) track('Create Account') } catch (e: any) { - this.rootStore.onboarding.skip() // undo starting the onboard + onboardingDispatch({type: 'skip'}) // undo starting the onboard let errMsg = e.toString() if (e instanceof ComAtprotoServerCreateAccount.InvalidInviteCodeError) { errMsg = diff --git a/src/state/persisted/schema.ts b/src/state/persisted/schema.ts index c00ee500ab..708930610e 100644 --- a/src/state/persisted/schema.ts +++ b/src/state/persisted/schema.ts @@ -7,9 +7,9 @@ const accountSchema = z.object({ did: z.string(), refreshJwt: z.string().optional(), accessJwt: z.string().optional(), - handle: z.string(), - displayName: z.string(), - aviUrl: z.string(), + handle: z.string().optional(), + displayName: z.string().optional(), + aviUrl: z.string().optional(), }) export const schema = z.object({ diff --git a/src/state/shell/color-mode.tsx b/src/state/shell/color-mode.tsx index 74379da371..c6a4b8a186 100644 --- a/src/state/shell/color-mode.tsx +++ b/src/state/shell/color-mode.tsx @@ -27,7 +27,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) { setState(persisted.get('colorMode')) updateDocument(persisted.get('colorMode')) }) - }, [setStateWrapped]) + }, [setState]) return ( diff --git a/src/state/shell/index.tsx b/src/state/shell/index.tsx index 807ee79ab8..0bb8988a62 100644 --- a/src/state/shell/index.tsx +++ b/src/state/shell/index.tsx @@ -4,6 +4,7 @@ import {Provider as DrawerSwipableProvider} from './drawer-swipe-disabled' import {Provider as MinimalModeProvider} from './minimal-mode' import {Provider as ColorModeProvider} from './color-mode' import {Provider as AltTextRequiredProvider} from './alt-text-required' +import {Provider as OnboardingProvider} from './onboarding' export {useIsDrawerOpen, useSetDrawerOpen} from './drawer-open' export { @@ -16,6 +17,7 @@ export { useRequireAltTextEnabled, useSetRequireAltTextEnabled, } from './alt-text-required' +export {useOnboardingState, useOnboardingDispatch} from './onboarding' export function Provider({children}: React.PropsWithChildren<{}>) { return ( @@ -23,7 +25,9 @@ export function Provider({children}: React.PropsWithChildren<{}>) { - {children} + + {children} + diff --git a/src/state/shell/onboarding.tsx b/src/state/shell/onboarding.tsx new file mode 100644 index 0000000000..5963cc50ec --- /dev/null +++ b/src/state/shell/onboarding.tsx @@ -0,0 +1,119 @@ +import React from 'react' +import * as persisted from '#/state/persisted' +import {track} from '#/lib/analytics/analytics' + +export const OnboardingScreenSteps = { + Welcome: 'Welcome', + RecommendedFeeds: 'RecommendedFeeds', + RecommendedFollows: 'RecommendedFollows', + Home: 'Home', +} as const + +type OnboardingStep = + (typeof OnboardingScreenSteps)[keyof typeof OnboardingScreenSteps] +const OnboardingStepsArray = Object.values(OnboardingScreenSteps) + +type Action = + | {type: 'set'; step: OnboardingStep} + | {type: 'next'; currentStep?: OnboardingStep} + | {type: 'start'} + | {type: 'finish'} + | {type: 'skip'} + +export type StateContext = persisted.Schema['onboarding'] & { + isComplete: boolean + isActive: boolean +} +export type DispatchContext = (action: Action) => void + +const stateContext = React.createContext( + compute(persisted.defaults.onboarding), +) +const dispatchContext = React.createContext((_: Action) => {}) + +function reducer(state: StateContext, action: Action): StateContext { + switch (action.type) { + case 'set': { + if (OnboardingStepsArray.includes(action.step)) { + persisted.write('onboarding', {step: action.step}) + return compute({...state, step: action.step}) + } + return state + } + case 'next': { + const currentStep = action.currentStep || state.step + let nextStep = 'Home' + if (currentStep === 'Welcome') { + nextStep = 'RecommendedFeeds' + } else if (currentStep === 'RecommendedFeeds') { + nextStep = 'RecommendedFollows' + } else if (currentStep === 'RecommendedFollows') { + nextStep = 'Home' + } + persisted.write('onboarding', {step: nextStep}) + return compute({...state, step: nextStep}) + } + case 'start': { + track('Onboarding:Begin') + persisted.write('onboarding', {step: 'Welcome'}) + return compute({...state, step: 'Welcome'}) + } + case 'finish': { + track('Onboarding:Complete') + persisted.write('onboarding', {step: 'Home'}) + return compute({...state, step: 'Home'}) + } + case 'skip': { + track('Onboarding:Skipped') + persisted.write('onboarding', {step: 'Home'}) + return compute({...state, step: 'Home'}) + } + default: { + throw new Error('Invalid action') + } + } +} + +export function Provider({children}: React.PropsWithChildren<{}>) { + const [state, dispatch] = React.useReducer( + reducer, + compute(persisted.get('onboarding')), + ) + + React.useEffect(() => { + return persisted.onUpdate(() => { + dispatch({ + type: 'set', + step: persisted.get('onboarding').step as OnboardingStep, + }) + }) + }, [dispatch]) + + return ( + + + {children} + + + ) +} + +export function useOnboardingState() { + return React.useContext(stateContext) +} + +export function useOnboardingDispatch() { + return React.useContext(dispatchContext) +} + +export function isOnboardingActive() { + return compute(persisted.get('onboarding')).isActive +} + +function compute(state: persisted.Schema['onboarding']): StateContext { + return { + ...state, + isActive: state.step !== 'Home', + isComplete: state.step === 'Home', + } +} diff --git a/src/view/com/auth/Onboarding.tsx b/src/view/com/auth/Onboarding.tsx index bec1dc236a..994f4c1492 100644 --- a/src/view/com/auth/Onboarding.tsx +++ b/src/view/com/auth/Onboarding.tsx @@ -4,34 +4,35 @@ import {observer} from 'mobx-react-lite' import {ErrorBoundary} from 'view/com/util/ErrorBoundary' import {s} from 'lib/styles' import {usePalette} from 'lib/hooks/usePalette' -import {useStores} from 'state/index' import {Welcome} from './onboarding/Welcome' import {RecommendedFeeds} from './onboarding/RecommendedFeeds' import {RecommendedFollows} from './onboarding/RecommendedFollows' import {useSetMinimalShellMode} from '#/state/shell/minimal-mode' +import {useOnboardingState, useOnboardingDispatch} from '#/state/shell' export const Onboarding = observer(function OnboardingImpl() { const pal = usePalette('default') - const store = useStores() const setMinimalShellMode = useSetMinimalShellMode() + const onboardingState = useOnboardingState() + const onboardingDispatch = useOnboardingDispatch() React.useEffect(() => { setMinimalShellMode(true) }, [setMinimalShellMode]) - const next = () => store.onboarding.next() - const skip = () => store.onboarding.skip() + const next = () => onboardingDispatch({type: 'next'}) + const skip = () => onboardingDispatch({type: 'skip'}) return ( - {store.onboarding.step === 'Welcome' && ( + {onboardingState.step === 'Welcome' && ( )} - {store.onboarding.step === 'RecommendedFeeds' && ( + {onboardingState.step === 'RecommendedFeeds' && ( )} - {store.onboarding.step === 'RecommendedFollows' && ( + {onboardingState.step === 'RecommendedFollows' && ( )} diff --git a/src/view/com/auth/create/CreateAccount.tsx b/src/view/com/auth/create/CreateAccount.tsx index 1d64cc0670..c3cfb3ad39 100644 --- a/src/view/com/auth/create/CreateAccount.tsx +++ b/src/view/com/auth/create/CreateAccount.tsx @@ -15,6 +15,7 @@ import {s} from 'lib/styles' import {useStores} from 'state/index' import {CreateAccountModel} from 'state/models/ui/create-account' import {usePalette} from 'lib/hooks/usePalette' +import {useOnboardingDispatch} from '#/state/shell' import {Step1} from './Step1' import {Step2} from './Step2' @@ -29,6 +30,7 @@ export const CreateAccount = observer(function CreateAccountImpl({ const pal = usePalette('default') const store = useStores() const model = React.useMemo(() => new CreateAccountModel(store), [store]) + const onboardingDispatch = useOnboardingDispatch() React.useEffect(() => { screen('CreateAccount') @@ -59,14 +61,14 @@ export const CreateAccount = observer(function CreateAccountImpl({ model.next() } else { try { - await model.submit() + await model.submit(onboardingDispatch) } catch { // dont need to handle here } finally { track('Try Create Account') } } - }, [model, track]) + }, [model, track, onboardingDispatch]) return ( void @@ -21,16 +22,10 @@ export const RecommendedFollows = observer(function RecommendedFollowsImpl({ const store = useStores() const pal = usePalette('default') const {isTabletOrMobile} = useWebMediaQueries() - - React.useEffect(() => { - // Load suggested actors if not already loaded - // prefetch should happen in the onboarding model - if ( - !store.onboarding.suggestedActors.hasLoaded || - store.onboarding.suggestedActors.isEmpty - ) { - store.onboarding.suggestedActors.loadMore(true) - } + const suggestedActors = React.useMemo(() => { + const model = new SuggestedActorsModel(store) + model.refresh() + return model }, [store]) const title = ( @@ -98,13 +93,19 @@ export const RecommendedFollows = observer(function RecommendedFollowsImpl({ horizontal titleStyle={isTabletOrMobile ? undefined : {minWidth: 470}} contentStyle={{paddingHorizontal: 0}}> - {store.onboarding.suggestedActors.isLoading ? ( + {suggestedActors.isLoading ? ( ) : ( ( - + )} keyExtractor={(item, index) => item.did + index.toString()} style={{flex: 1}} @@ -126,13 +127,19 @@ export const RecommendedFollows = observer(function RecommendedFollowsImpl({ users. - {store.onboarding.suggestedActors.isLoading ? ( + {suggestedActors.isLoading ? ( ) : ( ( - + )} keyExtractor={(item, index) => item.did + index.toString()} style={{flex: 1}} diff --git a/src/view/com/auth/onboarding/RecommendedFollowsItem.tsx b/src/view/com/auth/onboarding/RecommendedFollowsItem.tsx index 2b26918d02..f672372b8e 100644 --- a/src/view/com/auth/onboarding/RecommendedFollowsItem.tsx +++ b/src/view/com/auth/onboarding/RecommendedFollowsItem.tsx @@ -1,4 +1,4 @@ -import React, {useMemo} from 'react' +import React from 'react' import {View, StyleSheet, ActivityIndicator} from 'react-native' import {AppBskyActorDefs, moderateProfile} from '@atproto/api' import {observer} from 'mobx-react-lite' @@ -18,22 +18,19 @@ import {useAnalytics} from 'lib/analytics/analytics' type Props = { item: SuggestedActor index: number + insertSuggestionsByActor: (did: string, index: number) => Promise } -export const RecommendedFollowsItem: React.FC = ({item, index}) => { +export const RecommendedFollowsItem: React.FC = ({ + item, + index, + insertSuggestionsByActor, +}) => { const pal = usePalette('default') - const store = useStores() const {isMobile} = useWebMediaQueries() - const delay = useMemo(() => { - return ( - 50 * - (Math.abs(store.onboarding.suggestedActors.lastInsertedAtIndex - index) % - 5) - ) - }, [index, store.onboarding.suggestedActors.lastInsertedAtIndex]) return ( = ({item, index}) => { borderRightWidth: isMobile ? undefined : 1, }, ]}> - + ) } @@ -51,9 +53,11 @@ export const RecommendedFollowsItem: React.FC = ({item, index}) => { export const ProfileCard = observer(function ProfileCardImpl({ profile, index, + insertSuggestionsByActor, }: { profile: AppBskyActorDefs.ProfileViewBasic index: number + insertSuggestionsByActor: (did: string, index: number) => Promise }) { const {track} = useAnalytics() const store = useStores() @@ -94,10 +98,7 @@ export const ProfileCard = observer(function ProfileCardImpl({ onToggleFollow={async isFollow => { if (isFollow) { setAddingMoreSuggestions(true) - await store.onboarding.suggestedActors.insertSuggestionsByActor( - profile.did, - index, - ) + await insertSuggestionsByActor(profile.did, index) setAddingMoreSuggestions(false) track('Onboarding:SuggestedFollowFollowed') } diff --git a/src/view/com/auth/withAuthRequired.tsx b/src/view/com/auth/withAuthRequired.tsx index 25d12165fc..898f81051f 100644 --- a/src/view/com/auth/withAuthRequired.tsx +++ b/src/view/com/auth/withAuthRequired.tsx @@ -13,19 +13,21 @@ import {Onboarding} from './Onboarding' import {Text} from '../util/text/Text' import {usePalette} from 'lib/hooks/usePalette' import {STATUS_PAGE_URL} from 'lib/constants' +import {useOnboardingState} from '#/state/shell' export const withAuthRequired =

( Component: React.ComponentType

, ): React.FC

=> observer(function AuthRequired(props: P) { const store = useStores() + const onboardingState = useOnboardingState() if (store.session.isResumingSession) { return } if (!store.session.hasSession) { return } - if (store.onboarding.isActive) { + if (onboardingState.isActive) { return } return diff --git a/src/view/screens/Settings.tsx b/src/view/screens/Settings.tsx index 09a99e6d22..37c2961b47 100644 --- a/src/view/screens/Settings.tsx +++ b/src/view/screens/Settings.tsx @@ -52,6 +52,7 @@ import { useSetColorMode, useRequireAltTextEnabled, useSetRequireAltTextEnabled, + useOnboardingDispatch, } from '#/state/shell' // TEMPORARY (APP-700) @@ -70,6 +71,7 @@ export const SettingsScreen = withAuthRequired( const setMinimalShellMode = useSetMinimalShellMode() const requireAltTextEnabled = useRequireAltTextEnabled() const setRequireAltTextEnabled = useSetRequireAltTextEnabled() + const onboardingDispatch = useOnboardingDispatch() const navigation = useNavigation() const {isMobile} = useWebMediaQueries() const {screen, track} = useAnalytics() @@ -157,9 +159,9 @@ export const SettingsScreen = withAuthRequired( }, [store]) const onPressResetOnboarding = React.useCallback(async () => { - store.onboarding.reset() + onboardingDispatch({type: 'start'}) Toast.show('Onboarding reset') - }, [store]) + }, [onboardingDispatch]) const onPressBuildInfo = React.useCallback(() => { Clipboard.setString( diff --git a/src/view/shell/index.web.tsx b/src/view/shell/index.web.tsx index 843d0b284b..1731ea2477 100644 --- a/src/view/shell/index.web.tsx +++ b/src/view/shell/index.web.tsx @@ -17,12 +17,17 @@ import {BottomBarWeb} from './bottom-bar/BottomBarWeb' import {useNavigation} from '@react-navigation/native' import {NavigationProp} from 'lib/routes/types' import {useAuxClick} from 'lib/hooks/useAuxClick' -import {useIsDrawerOpen, useSetDrawerOpen} from '#/state/shell' +import { + useIsDrawerOpen, + useSetDrawerOpen, + useOnboardingState, +} from '#/state/shell' const ShellInner = observer(function ShellInnerImpl() { const store = useStores() const isDrawerOpen = useIsDrawerOpen() const setDrawerOpen = useSetDrawerOpen() + const onboardingState = useOnboardingState() const {isDesktop, isMobile} = useWebMediaQueries() const navigator = useNavigation() useAuxClick() @@ -34,9 +39,9 @@ const ShellInner = observer(function ShellInnerImpl() { }) }, [navigator, store.shell, setDrawerOpen]) - const showBottomBar = isMobile && !store.onboarding.isActive + const showBottomBar = isMobile && !onboardingState.isActive const showSideNavs = - !isMobile && store.session.hasSession && !store.onboarding.isActive + !isMobile && store.session.hasSession && !onboardingState.isActive return ( From 74f8390f1d879350ebb6516fade2b1d83d1601e7 Mon Sep 17 00:00:00 2001 From: Paul Frazee Date: Wed, 8 Nov 2023 09:08:42 -0800 Subject: [PATCH 5/6] Move muted threads to new persistence + context (#1838) --- src/App.native.tsx | 5 +- src/App.web.tsx | 5 +- src/state/models/content/post-thread-item.ts | 8 --- src/state/models/content/post-thread.ts | 12 ---- src/state/models/feeds/notifications.ts | 4 +- src/state/models/feeds/post.ts | 18 ------ src/state/models/muted-threads.ts | 29 ---------- src/state/models/root-store.ts | 6 -- src/state/muted-threads.tsx | 59 ++++++++++++++++++++ src/view/com/post-thread/PostThreadItem.tsx | 17 +++--- src/view/com/post/Post.tsx | 13 +++-- src/view/com/posts/FeedItem.tsx | 13 +++-- 12 files changed, 95 insertions(+), 94 deletions(-) delete mode 100644 src/state/models/muted-threads.ts create mode 100644 src/state/muted-threads.tsx diff --git a/src/App.native.tsx b/src/App.native.tsx index f5d35cf741..4500b5d076 100644 --- a/src/App.native.tsx +++ b/src/App.native.tsx @@ -22,6 +22,7 @@ import * as Toast from 'view/com/util/Toast' import {queryClient} from 'lib/react-query' import {TestCtrls} from 'view/com/testing/TestCtrls' import {Provider as ShellStateProvider} from 'state/shell' +import {Provider as MutedThreadsProvider} from 'state/muted-threads' SplashScreen.preventAutoHideAsync() @@ -78,7 +79,9 @@ function App() { return ( - + + + ) } diff --git a/src/App.web.tsx b/src/App.web.tsx index adad9ddb6a..9792274b02 100644 --- a/src/App.web.tsx +++ b/src/App.web.tsx @@ -17,6 +17,7 @@ import {ToastContainer} from 'view/com/util/Toast.web' import {ThemeProvider} from 'lib/ThemeContext' import {queryClient} from 'lib/react-query' import {Provider as ShellStateProvider} from 'state/shell' +import {Provider as MutedThreadsProvider} from 'state/muted-threads' const InnerApp = observer(function AppImpl() { const colorMode = useColorMode() @@ -68,7 +69,9 @@ function App() { return ( - + + + ) } diff --git a/src/state/models/content/post-thread-item.ts b/src/state/models/content/post-thread-item.ts index 942f3acc81..855b038c46 100644 --- a/src/state/models/content/post-thread-item.ts +++ b/src/state/models/content/post-thread-item.ts @@ -63,10 +63,6 @@ export class PostThreadItemModel { return this.post.uri } - get isThreadMuted() { - return this.data.isThreadMuted - } - get moderation(): PostModeration { return this.data.moderation } @@ -129,10 +125,6 @@ export class PostThreadItemModel { this.data.toggleRepost() } - async toggleThreadMute() { - this.data.toggleThreadMute() - } - async delete() { this.data.delete() } diff --git a/src/state/models/content/post-thread.ts b/src/state/models/content/post-thread.ts index fd194056ae..65e74f7ce9 100644 --- a/src/state/models/content/post-thread.ts +++ b/src/state/models/content/post-thread.ts @@ -74,10 +74,6 @@ export class PostThreadModel { return this.resolvedUri } - get isThreadMuted() { - return this.rootStore.mutedThreads.uris.has(this.rootUri) - } - get isCachedPostAReply() { if (AppBskyFeedPost.isRecord(this.thread?.post.record)) { return !!this.thread?.post.record.reply @@ -140,14 +136,6 @@ export class PostThreadModel { this.refresh() } - async toggleThreadMute() { - if (this.isThreadMuted) { - this.rootStore.mutedThreads.uris.delete(this.rootUri) - } else { - this.rootStore.mutedThreads.uris.add(this.rootUri) - } - } - // state transitions // = diff --git a/src/state/models/feeds/notifications.ts b/src/state/models/feeds/notifications.ts index 607e3038bb..272d528811 100644 --- a/src/state/models/feeds/notifications.ts +++ b/src/state/models/feeds/notifications.ts @@ -18,6 +18,7 @@ import {RootStoreModel} from '../root-store' import {PostThreadModel} from '../content/post-thread' import {cleanError} from 'lib/strings/errors' import {logger} from '#/logger' +import {isThreadMuted} from '#/state/muted-threads' const GROUPABLE_REASONS = ['like', 'repost', 'follow'] const PAGE_SIZE = 30 @@ -550,8 +551,7 @@ export class NotificationsFeedModel { .filter(item => { const hideByLabel = item.shouldFilter let mutedThread = !!( - item.reasonSubjectRootUri && - this.rootStore.mutedThreads.uris.has(item.reasonSubjectRootUri) + item.reasonSubjectRootUri && isThreadMuted(item.reasonSubjectRootUri) ) return !hideByLabel && !mutedThread }) diff --git a/src/state/models/feeds/post.ts b/src/state/models/feeds/post.ts index d064edc21b..4fa1213b57 100644 --- a/src/state/models/feeds/post.ts +++ b/src/state/models/feeds/post.ts @@ -75,10 +75,6 @@ export class PostsFeedItemModel { return this.post.uri } - get isThreadMuted() { - return this.rootStore.mutedThreads.uris.has(this.rootUri) - } - get moderation(): PostModeration { return moderatePost(this.post, this.rootStore.preferences.moderationOpts) } @@ -172,20 +168,6 @@ export class PostsFeedItemModel { } } - async toggleThreadMute() { - try { - if (this.isThreadMuted) { - this.rootStore.mutedThreads.uris.delete(this.rootUri) - track('Post:ThreadUnmute') - } else { - this.rootStore.mutedThreads.uris.add(this.rootUri) - track('Post:ThreadMute') - } - } catch (error) { - logger.error('Failed to toggle thread mute', {error}) - } - } - async delete() { try { await this.rootStore.agent.deletePost(this.post.uri) diff --git a/src/state/models/muted-threads.ts b/src/state/models/muted-threads.ts deleted file mode 100644 index e6f2027452..0000000000 --- a/src/state/models/muted-threads.ts +++ /dev/null @@ -1,29 +0,0 @@ -/** - * This is a temporary client-side system for storing muted threads - * When the system lands on prod we should switch to that - */ - -import {makeAutoObservable} from 'mobx' -import {isObj, hasProp, isStrArray} from 'lib/type-guards' - -export class MutedThreads { - uris: Set = new Set() - - constructor() { - makeAutoObservable( - this, - {serialize: false, hydrate: false}, - {autoBind: true}, - ) - } - - serialize() { - return {uris: Array.from(this.uris)} - } - - hydrate(v: unknown) { - if (isObj(v) && hasProp(v, 'uris') && isStrArray(v.uris)) { - this.uris = new Set(v.uris) - } - } -} diff --git a/src/state/models/root-store.ts b/src/state/models/root-store.ts index f04a9922d8..fadd279fc9 100644 --- a/src/state/models/root-store.ts +++ b/src/state/models/root-store.ts @@ -19,7 +19,6 @@ import {InvitedUsers} from './invited-users' import {PreferencesModel} from './ui/preferences' import {resetToTab} from '../../Navigation' import {ImageSizesCache} from './cache/image-sizes' -import {MutedThreads} from './muted-threads' import {reset as resetNavigation} from '../../Navigation' import {logger} from '#/logger' @@ -49,7 +48,6 @@ export class RootStoreModel { posts = new PostsCache(this) linkMetas = new LinkMetasCache(this) imageSizes = new ImageSizesCache() - mutedThreads = new MutedThreads() constructor(agent: BskyAgent) { this.agent = agent @@ -71,7 +69,6 @@ export class RootStoreModel { me: this.me.serialize(), preferences: this.preferences.serialize(), invitedUsers: this.invitedUsers.serialize(), - mutedThreads: this.mutedThreads.serialize(), } } @@ -95,9 +92,6 @@ export class RootStoreModel { if (hasProp(v, 'invitedUsers')) { this.invitedUsers.hydrate(v.invitedUsers) } - if (hasProp(v, 'mutedThreads')) { - this.mutedThreads.hydrate(v.mutedThreads) - } } } diff --git a/src/state/muted-threads.tsx b/src/state/muted-threads.tsx new file mode 100644 index 0000000000..2b3a7de6ab --- /dev/null +++ b/src/state/muted-threads.tsx @@ -0,0 +1,59 @@ +import React from 'react' +import * as persisted from '#/state/persisted' + +type StateContext = persisted.Schema['mutedThreads'] +type ToggleContext = (uri: string) => boolean + +const stateContext = React.createContext( + persisted.defaults.mutedThreads, +) +const toggleContext = React.createContext((_: string) => false) + +export function Provider({children}: React.PropsWithChildren<{}>) { + const [state, setState] = React.useState(persisted.get('mutedThreads')) + + const toggleThreadMute = React.useCallback( + (uri: string) => { + let muted = false + setState((arr: string[]) => { + if (arr.includes(uri)) { + arr = arr.filter(v => v !== uri) + muted = false + } else { + arr = arr.concat([uri]) + muted = true + } + persisted.write('mutedThreads', arr) + return arr + }) + return muted + }, + [setState], + ) + + React.useEffect(() => { + return persisted.onUpdate(() => { + setState(persisted.get('mutedThreads')) + }) + }, [setState]) + + return ( + + + {children} + + + ) +} + +export function useMutedThreads() { + return React.useContext(stateContext) +} + +export function useToggleThreadMute() { + return React.useContext(toggleContext) +} + +export function isThreadMuted(uri: string) { + return persisted.get('mutedThreads').includes(uri) +} diff --git a/src/view/com/post-thread/PostThreadItem.tsx b/src/view/com/post-thread/PostThreadItem.tsx index 351a467062..9aec638ec2 100644 --- a/src/view/com/post-thread/PostThreadItem.tsx +++ b/src/view/com/post-thread/PostThreadItem.tsx @@ -37,6 +37,7 @@ import {makeProfileLink} from 'lib/routes/links' import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' import {MAX_POST_LINES} from 'lib/constants' import {logger} from '#/logger' +import {useMutedThreads, useToggleThreadMute} from '#/state/muted-threads' export const PostThreadItem = observer(function PostThreadItem({ item, @@ -51,6 +52,8 @@ export const PostThreadItem = observer(function PostThreadItem({ }) { const pal = usePalette('default') const store = useStores() + const mutedThreads = useMutedThreads() + const toggleThreadMute = useToggleThreadMute() const [deleted, setDeleted] = React.useState(false) const [limitLines, setLimitLines] = React.useState( countLines(item.richText?.text) >= MAX_POST_LINES, @@ -130,10 +133,10 @@ export const PostThreadItem = observer(function PostThreadItem({ Linking.openURL(translatorUrl) }, [translatorUrl]) - const onToggleThreadMute = React.useCallback(async () => { + const onToggleThreadMute = React.useCallback(() => { try { - await item.toggleThreadMute() - if (item.isThreadMuted) { + const muted = toggleThreadMute(item.data.rootUri) + if (muted) { Toast.show('You will no longer receive notifications for this thread') } else { Toast.show('You will now receive notifications for this thread') @@ -141,7 +144,7 @@ export const PostThreadItem = observer(function PostThreadItem({ } catch (e) { logger.error('Failed to toggle thread mute', {error: e}) } - }, [item]) + }, [item, toggleThreadMute]) const onDeletePost = React.useCallback(() => { item.delete().then( @@ -284,7 +287,7 @@ export const PostThreadItem = observer(function PostThreadItem({ itemHref={itemHref} itemTitle={itemTitle} isAuthor={item.post.author.did === store.me.did} - isThreadMuted={item.isThreadMuted} + isThreadMuted={mutedThreads.includes(item.data.rootUri)} onCopyPostText={onCopyPostText} onOpenTranslate={onOpenTranslate} onToggleThreadMute={onToggleThreadMute} @@ -391,7 +394,7 @@ export const PostThreadItem = observer(function PostThreadItem({ isAuthor={item.post.author.did === store.me.did} isReposted={!!item.post.viewer?.repost} isLiked={!!item.post.viewer?.like} - isThreadMuted={item.isThreadMuted} + isThreadMuted={mutedThreads.includes(item.data.rootUri)} onPressReply={onPressReply} onPressToggleRepost={onPressToggleRepost} onPressToggleLike={onPressToggleLike} @@ -534,7 +537,7 @@ export const PostThreadItem = observer(function PostThreadItem({ likeCount={item.post.likeCount} isReposted={!!item.post.viewer?.repost} isLiked={!!item.post.viewer?.like} - isThreadMuted={item.isThreadMuted} + isThreadMuted={mutedThreads.includes(item.data.rootUri)} onPressReply={onPressReply} onPressToggleRepost={onPressToggleRepost} onPressToggleLike={onPressToggleLike} diff --git a/src/view/com/post/Post.tsx b/src/view/com/post/Post.tsx index 4ec9db77fc..db490333da 100644 --- a/src/view/com/post/Post.tsx +++ b/src/view/com/post/Post.tsx @@ -33,6 +33,7 @@ import {makeProfileLink} from 'lib/routes/links' import {MAX_POST_LINES} from 'lib/constants' import {countLines} from 'lib/strings/helpers' import {logger} from '#/logger' +import {useMutedThreads, useToggleThreadMute} from '#/state/muted-threads' export const Post = observer(function PostImpl({ view, @@ -106,6 +107,8 @@ const PostLoaded = observer(function PostLoadedImpl({ }) { const pal = usePalette('default') const store = useStores() + const mutedThreads = useMutedThreads() + const toggleThreadMute = useToggleThreadMute() const [limitLines, setLimitLines] = React.useState( countLines(item.richText?.text) >= MAX_POST_LINES, ) @@ -161,10 +164,10 @@ const PostLoaded = observer(function PostLoadedImpl({ Linking.openURL(translatorUrl) }, [translatorUrl]) - const onToggleThreadMute = React.useCallback(async () => { + const onToggleThreadMute = React.useCallback(() => { try { - await item.toggleThreadMute() - if (item.isThreadMuted) { + const muted = toggleThreadMute(item.data.rootUri) + if (muted) { Toast.show('You will no longer receive notifications for this thread') } else { Toast.show('You will now receive notifications for this thread') @@ -172,7 +175,7 @@ const PostLoaded = observer(function PostLoadedImpl({ } catch (e) { logger.error('Failed to toggle thread mute', {error: e}) } - }, [item]) + }, [item, toggleThreadMute]) const onDeletePost = React.useCallback(() => { item.delete().then( @@ -286,7 +289,7 @@ const PostLoaded = observer(function PostLoadedImpl({ likeCount={item.post.likeCount} isReposted={!!item.post.viewer?.repost} isLiked={!!item.post.viewer?.like} - isThreadMuted={item.isThreadMuted} + isThreadMuted={mutedThreads.includes(item.data.rootUri)} onPressReply={onPressReply} onPressToggleRepost={onPressToggleRepost} onPressToggleLike={onPressToggleLike} diff --git a/src/view/com/posts/FeedItem.tsx b/src/view/com/posts/FeedItem.tsx index aeee3e20ae..772bb25614 100644 --- a/src/view/com/posts/FeedItem.tsx +++ b/src/view/com/posts/FeedItem.tsx @@ -33,6 +33,7 @@ import {isEmbedByEmbedder} from 'lib/embeds' import {MAX_POST_LINES} from 'lib/constants' import {countLines} from 'lib/strings/helpers' import {logger} from '#/logger' +import {useMutedThreads, useToggleThreadMute} from '#/state/muted-threads' export const FeedItem = observer(function FeedItemImpl({ item, @@ -50,6 +51,8 @@ export const FeedItem = observer(function FeedItemImpl({ }) { const store = useStores() const pal = usePalette('default') + const mutedThreads = useMutedThreads() + const toggleThreadMute = useToggleThreadMute() const {track} = useAnalytics() const [deleted, setDeleted] = useState(false) const [limitLines, setLimitLines] = useState( @@ -114,11 +117,11 @@ export const FeedItem = observer(function FeedItemImpl({ Linking.openURL(translatorUrl) }, [translatorUrl]) - const onToggleThreadMute = React.useCallback(async () => { + const onToggleThreadMute = React.useCallback(() => { track('FeedItem:ThreadMute') try { - await item.toggleThreadMute() - if (item.isThreadMuted) { + const muted = toggleThreadMute(item.rootUri) + if (muted) { Toast.show('You will no longer receive notifications for this thread') } else { Toast.show('You will now receive notifications for this thread') @@ -126,7 +129,7 @@ export const FeedItem = observer(function FeedItemImpl({ } catch (e) { logger.error('Failed to toggle thread mute', {error: e}) } - }, [track, item]) + }, [track, toggleThreadMute, item]) const onDeletePost = React.useCallback(() => { track('FeedItem:PostDelete') @@ -360,7 +363,7 @@ export const FeedItem = observer(function FeedItemImpl({ likeCount={item.post.likeCount} isReposted={!!item.post.viewer?.repost} isLiked={!!item.post.viewer?.like} - isThreadMuted={item.isThreadMuted} + isThreadMuted={mutedThreads.includes(item.rootUri)} onPressReply={onPressReply} onPressToggleRepost={onPressToggleRepost} onPressToggleLike={onPressToggleLike} From e75b2d508baf9b19e7340657ac2951e9f057b735 Mon Sep 17 00:00:00 2001 From: Paul Frazee Date: Wed, 8 Nov 2023 09:10:59 -0800 Subject: [PATCH 6/6] Move invite-state to new persistence + context and replace the notifications with just showing uses in the modal (#1840) --- src/App.native.tsx | 5 +- src/App.web.tsx | 5 +- src/state/invites.tsx | 56 ++++++++++ src/state/models/feeds/notifications.ts | 2 +- src/state/models/invited-users.ts | 88 --------------- src/state/models/me.ts | 1 - src/state/models/root-store.ts | 6 -- src/state/persisted/legacy.ts | 6 +- src/state/persisted/schema.ts | 6 +- src/view/com/modals/InviteCodes.tsx | 105 +++++++++++------- src/view/com/notifications/InvitedUsers.tsx | 114 -------------------- src/view/screens/Notifications.tsx | 2 - 12 files changed, 137 insertions(+), 259 deletions(-) create mode 100644 src/state/invites.tsx delete mode 100644 src/state/models/invited-users.ts delete mode 100644 src/view/com/notifications/InvitedUsers.tsx diff --git a/src/App.native.tsx b/src/App.native.tsx index 4500b5d076..865e6dc195 100644 --- a/src/App.native.tsx +++ b/src/App.native.tsx @@ -23,6 +23,7 @@ import {queryClient} from 'lib/react-query' import {TestCtrls} from 'view/com/testing/TestCtrls' import {Provider as ShellStateProvider} from 'state/shell' import {Provider as MutedThreadsProvider} from 'state/muted-threads' +import {Provider as InvitesStateProvider} from 'state/invites' SplashScreen.preventAutoHideAsync() @@ -80,7 +81,9 @@ function App() { return ( - + + + ) diff --git a/src/App.web.tsx b/src/App.web.tsx index 9792274b02..cfc2a0028b 100644 --- a/src/App.web.tsx +++ b/src/App.web.tsx @@ -18,6 +18,7 @@ import {ThemeProvider} from 'lib/ThemeContext' import {queryClient} from 'lib/react-query' import {Provider as ShellStateProvider} from 'state/shell' import {Provider as MutedThreadsProvider} from 'state/muted-threads' +import {Provider as InvitesStateProvider} from 'state/invites' const InnerApp = observer(function AppImpl() { const colorMode = useColorMode() @@ -70,7 +71,9 @@ function App() { return ( - + + + ) diff --git a/src/state/invites.tsx b/src/state/invites.tsx new file mode 100644 index 0000000000..6a0d1b5900 --- /dev/null +++ b/src/state/invites.tsx @@ -0,0 +1,56 @@ +import React from 'react' +import * as persisted from '#/state/persisted' + +type StateContext = persisted.Schema['invites'] +type ApiContext = { + setInviteCopied: (code: string) => void +} + +const stateContext = React.createContext( + persisted.defaults.invites, +) +const apiContext = React.createContext({ + setInviteCopied(_: string) {}, +}) + +export function Provider({children}: React.PropsWithChildren<{}>) { + const [state, setState] = React.useState(persisted.get('invites')) + + const api = React.useMemo( + () => ({ + setInviteCopied(code: string) { + setState(state => { + state = { + ...state, + copiedInvites: state.copiedInvites.includes(code) + ? state.copiedInvites + : state.copiedInvites.concat([code]), + } + persisted.write('invites', state) + return state + }) + }, + }), + [setState], + ) + + React.useEffect(() => { + return persisted.onUpdate(() => { + setState(persisted.get('invites')) + }) + }, [setState]) + + return ( + + {children} + + ) +} + +export function useInvitesState() { + return React.useContext(stateContext) +} + +export function useInvitesAPI() { + return React.useContext(apiContext) +} diff --git a/src/state/models/feeds/notifications.ts b/src/state/models/feeds/notifications.ts index 272d528811..5f34feb662 100644 --- a/src/state/models/feeds/notifications.ts +++ b/src/state/models/feeds/notifications.ts @@ -304,7 +304,7 @@ export class NotificationsFeedModel { } get unreadCountLabel(): string { - const count = this.unreadCount + this.rootStore.invitedUsers.numNotifs + const count = this.unreadCount if (count >= MAX_VISIBLE_NOTIFS) { return `${MAX_VISIBLE_NOTIFS}+` } diff --git a/src/state/models/invited-users.ts b/src/state/models/invited-users.ts deleted file mode 100644 index 9ba65e19ec..0000000000 --- a/src/state/models/invited-users.ts +++ /dev/null @@ -1,88 +0,0 @@ -import {makeAutoObservable, runInAction} from 'mobx' -import {ComAtprotoServerDefs, AppBskyActorDefs} from '@atproto/api' -import {RootStoreModel} from './root-store' -import {isObj, hasProp, isStrArray} from 'lib/type-guards' -import {logger} from '#/logger' - -export class InvitedUsers { - copiedInvites: string[] = [] - seenDids: string[] = [] - profiles: AppBskyActorDefs.ProfileViewDetailed[] = [] - - get numNotifs() { - return this.profiles.length - } - - constructor(public rootStore: RootStoreModel) { - makeAutoObservable( - this, - {rootStore: false, serialize: false, hydrate: false}, - {autoBind: true}, - ) - } - - serialize() { - return {seenDids: this.seenDids, copiedInvites: this.copiedInvites} - } - - hydrate(v: unknown) { - if (isObj(v) && hasProp(v, 'seenDids') && isStrArray(v.seenDids)) { - this.seenDids = v.seenDids - } - if ( - isObj(v) && - hasProp(v, 'copiedInvites') && - isStrArray(v.copiedInvites) - ) { - this.copiedInvites = v.copiedInvites - } - } - - async fetch(invites: ComAtprotoServerDefs.InviteCode[]) { - // pull the dids of invited users not marked seen - const dids = [] - for (const invite of invites) { - for (const use of invite.uses) { - if (!this.seenDids.includes(use.usedBy)) { - dids.push(use.usedBy) - } - } - } - - // fetch their profiles - this.profiles = [] - if (dids.length) { - try { - const res = await this.rootStore.agent.app.bsky.actor.getProfiles({ - actors: dids, - }) - runInAction(() => { - // save the ones following -- these are the ones we want to notify the user about - this.profiles = res.data.profiles.filter( - profile => !profile.viewer?.following, - ) - }) - this.rootStore.me.follows.hydrateMany(this.profiles) - } catch (e) { - logger.error('Failed to fetch profiles for invited users', { - error: e, - }) - } - } - } - - isInviteCopied(invite: string) { - return this.copiedInvites.includes(invite) - } - - setInviteCopied(invite: string) { - if (!this.isInviteCopied(invite)) { - this.copiedInvites.push(invite) - } - } - - markSeen(did: string) { - this.seenDids.push(did) - this.profiles = this.profiles.filter(profile => profile.did !== did) - } -} diff --git a/src/state/models/me.ts b/src/state/models/me.ts index d12cb68c41..d3061f166b 100644 --- a/src/state/models/me.ts +++ b/src/state/models/me.ts @@ -193,7 +193,6 @@ export class MeModel { error: e, }) } - await this.rootStore.invitedUsers.fetch(this.invites) } } diff --git a/src/state/models/root-store.ts b/src/state/models/root-store.ts index fadd279fc9..d11e9a1480 100644 --- a/src/state/models/root-store.ts +++ b/src/state/models/root-store.ts @@ -15,7 +15,6 @@ import {ProfilesCache} from './cache/profiles-view' import {PostsCache} from './cache/posts' import {LinkMetasCache} from './cache/link-metas' import {MeModel} from './me' -import {InvitedUsers} from './invited-users' import {PreferencesModel} from './ui/preferences' import {resetToTab} from '../../Navigation' import {ImageSizesCache} from './cache/image-sizes' @@ -42,7 +41,6 @@ export class RootStoreModel { shell = new ShellUiModel(this) preferences = new PreferencesModel(this) me = new MeModel(this) - invitedUsers = new InvitedUsers(this) handleResolutions = new HandleResolutionsCache() profiles = new ProfilesCache(this) posts = new PostsCache(this) @@ -68,7 +66,6 @@ export class RootStoreModel { session: this.session.serialize(), me: this.me.serialize(), preferences: this.preferences.serialize(), - invitedUsers: this.invitedUsers.serialize(), } } @@ -89,9 +86,6 @@ export class RootStoreModel { if (hasProp(v, 'preferences')) { this.preferences.hydrate(v.preferences) } - if (hasProp(v, 'invitedUsers')) { - this.invitedUsers.hydrate(v.invitedUsers) - } } } diff --git a/src/state/persisted/legacy.ts b/src/state/persisted/legacy.ts index 67eef81a0f..3da509304c 100644 --- a/src/state/persisted/legacy.ts +++ b/src/state/persisted/legacy.ts @@ -97,11 +97,9 @@ export function transform(legacy: LegacySchema): Schema { legacy.preferences.requireAltTextEnabled || defaults.requireAltTextEnabled, mutedThreads: legacy.mutedThreads.uris || defaults.mutedThreads, - invitedUsers: { - seenDids: legacy.invitedUsers.seenDids || defaults.invitedUsers.seenDids, + invites: { copiedInvites: - legacy.invitedUsers.copiedInvites || - defaults.invitedUsers.copiedInvites, + legacy.invitedUsers.copiedInvites || defaults.invites.copiedInvites, }, onboarding: { step: legacy.onboarding.step || defaults.onboarding.step, diff --git a/src/state/persisted/schema.ts b/src/state/persisted/schema.ts index 708930610e..9c52661e44 100644 --- a/src/state/persisted/schema.ts +++ b/src/state/persisted/schema.ts @@ -29,8 +29,7 @@ export const schema = z.object({ }), requireAltTextEnabled: z.boolean(), // should move to server mutedThreads: z.array(z.string()), // should move to server - invitedUsers: z.object({ - seenDids: z.array(z.string()), + invites: z.object({ copiedInvites: z.array(z.string()), }), onboarding: z.object({ @@ -58,8 +57,7 @@ export const defaults: Schema = { }, requireAltTextEnabled: false, mutedThreads: [], - invitedUsers: { - seenDids: [], + invites: { copiedInvites: [], }, onboarding: { diff --git a/src/view/com/modals/InviteCodes.tsx b/src/view/com/modals/InviteCodes.tsx index 09cfd4de79..a8aa164c38 100644 --- a/src/view/com/modals/InviteCodes.tsx +++ b/src/view/com/modals/InviteCodes.tsx @@ -1,6 +1,7 @@ import React from 'react' import {StyleSheet, TouchableOpacity, View} from 'react-native' import {observer} from 'mobx-react-lite' +import {ComAtprotoServerDefs} from '@atproto/api' import { FontAwesomeIcon, FontAwesomeIconStyle, @@ -14,6 +15,10 @@ import {ScrollView} from './util' import {usePalette} from 'lib/hooks/usePalette' import {isWeb} from 'platform/detection' import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' +import {useInvitesState, useInvitesAPI} from '#/state/invites' +import {UserInfoText} from '../util/UserInfoText' +import {makeProfileLink} from '#/lib/routes/links' +import {Link} from '../util/Link' export const snapPoints = ['70%'] @@ -66,7 +71,7 @@ export function Component({}: {}) { ))} @@ -87,52 +92,81 @@ export function Component({}: {}) { const InviteCode = observer(function InviteCodeImpl({ testID, - code, + invite, used, }: { testID: string - code: string + invite: ComAtprotoServerDefs.InviteCode used?: boolean }) { const pal = usePalette('default') const store = useStores() const {invitesAvailable} = store.me + const invitesState = useInvitesState() + const {setInviteCopied} = useInvitesAPI() const onPress = React.useCallback(() => { - Clipboard.setString(code) + Clipboard.setString(invite.code) Toast.show('Copied to clipboard') - store.invitedUsers.setInviteCopied(code) - }, [store, code]) + setInviteCopied(invite.code) + }, [setInviteCopied, invite]) return ( - - - {code} - - - {!used && store.invitedUsers.isInviteCopied(code) && ( - Copied - )} - {!used && ( - - )} - + + + + {invite.code} + + + {!used && invitesState.copiedInvites.includes(invite.code) && ( + Copied + )} + {!used && ( + + )} + + {invite.uses.length > 0 ? ( + + Used by: + {invite.uses.map(use => ( + + + + + ))} + + ) : null} + ) }) @@ -176,9 +210,6 @@ const styles = StyleSheet.create({ inviteCode: { flexDirection: 'row', alignItems: 'center', - borderBottomWidth: 1, - paddingHorizontal: 20, - paddingVertical: 14, }, codeCopied: { marginRight: 8, diff --git a/src/view/com/notifications/InvitedUsers.tsx b/src/view/com/notifications/InvitedUsers.tsx deleted file mode 100644 index aaf358b876..0000000000 --- a/src/view/com/notifications/InvitedUsers.tsx +++ /dev/null @@ -1,114 +0,0 @@ -import React from 'react' -import { - FontAwesomeIcon, - FontAwesomeIconStyle, -} from '@fortawesome/react-native-fontawesome' -import {StyleSheet, View} from 'react-native' -import {observer} from 'mobx-react-lite' -import {AppBskyActorDefs} from '@atproto/api' -import {UserAvatar} from '../util/UserAvatar' -import {Text} from '../util/text/Text' -import {Link, TextLink} from '../util/Link' -import {Button} from '../util/forms/Button' -import {FollowButton} from '../profile/FollowButton' -import {CenteredView} from '../util/Views.web' -import {useStores} from 'state/index' -import {usePalette} from 'lib/hooks/usePalette' -import {s} from 'lib/styles' -import {sanitizeDisplayName} from 'lib/strings/display-names' -import {makeProfileLink} from 'lib/routes/links' - -export const InvitedUsers = observer(function InvitedUsersImpl() { - const store = useStores() - return ( - - {store.invitedUsers.profiles.map(profile => ( - - ))} - - ) -}) - -function InvitedUser({ - profile, -}: { - profile: AppBskyActorDefs.ProfileViewDetailed -}) { - const pal = usePalette('default') - const store = useStores() - - const onPressDismiss = React.useCallback(() => { - store.invitedUsers.markSeen(profile.did) - }, [store, profile]) - - return ( - - - - - - - - - - {' '} - joined using your invite code! - - - -