mirror of https://git.lenooby09.tech/LeNooby09/social-app.git
1/**
2 * The root store is the base of all modeled state.
3 */
4
5import {makeAutoObservable} from 'mobx'
6import {BskyAgent} from '@atproto/api'
7import {createContext, useContext} from 'react'
8import {DeviceEventEmitter, EmitterSubscription} from 'react-native'
9import {z} from 'zod'
10import {isObj, hasProp} from 'lib/type-guards'
11import {LogModel} from './log'
12import {SessionModel} from './session'
13import {ShellUiModel} from './ui/shell'
14import {HandleResolutionsCache} from './cache/handle-resolutions'
15import {ProfilesCache} from './cache/profiles-view'
16import {PostsCache} from './cache/posts'
17import {LinkMetasCache} from './cache/link-metas'
18import {MeModel} from './me'
19import {InvitedUsers} from './invited-users'
20import {PreferencesModel} from './ui/preferences'
21import {resetToTab} from '../../Navigation'
22import {ImageSizesCache} from './cache/image-sizes'
23import {MutedThreads} from './muted-threads'
24import {reset as resetNavigation} from '../../Navigation'
25
26// TEMPORARY (APP-700)
27// remove after backend testing finishes
28// -prf
29import {applyDebugHeader} from 'lib/api/debug-appview-proxy-header'
30import {OnboardingModel} from './discovery/onboarding'
31
32export const appInfo = z.object({
33 build: z.string(),
34 name: z.string(),
35 namespace: z.string(),
36 version: z.string(),
37})
38export type AppInfo = z.infer<typeof appInfo>
39
40export class RootStoreModel {
41 agent: BskyAgent
42 appInfo?: AppInfo
43 log = new LogModel()
44 session = new SessionModel(this)
45 shell = new ShellUiModel(this)
46 preferences = new PreferencesModel(this)
47 me = new MeModel(this)
48 onboarding = new OnboardingModel(this)
49 invitedUsers = new InvitedUsers(this)
50 handleResolutions = new HandleResolutionsCache()
51 profiles = new ProfilesCache(this)
52 posts = new PostsCache(this)
53 linkMetas = new LinkMetasCache(this)
54 imageSizes = new ImageSizesCache()
55 mutedThreads = new MutedThreads()
56
57 constructor(agent: BskyAgent) {
58 this.agent = agent
59 makeAutoObservable(this, {
60 agent: false,
61 serialize: false,
62 hydrate: false,
63 })
64 }
65
66 setAppInfo(info: AppInfo) {
67 this.appInfo = info
68 }
69
70 serialize(): unknown {
71 return {
72 appInfo: this.appInfo,
73 session: this.session.serialize(),
74 me: this.me.serialize(),
75 onboarding: this.onboarding.serialize(),
76 shell: this.shell.serialize(),
77 preferences: this.preferences.serialize(),
78 invitedUsers: this.invitedUsers.serialize(),
79 mutedThreads: this.mutedThreads.serialize(),
80 }
81 }
82
83 hydrate(v: unknown) {
84 if (isObj(v)) {
85 if (hasProp(v, 'appInfo')) {
86 const appInfoParsed = appInfo.safeParse(v.appInfo)
87 if (appInfoParsed.success) {
88 this.setAppInfo(appInfoParsed.data)
89 }
90 }
91 if (hasProp(v, 'me')) {
92 this.me.hydrate(v.me)
93 }
94 if (hasProp(v, 'onboarding')) {
95 this.onboarding.hydrate(v.onboarding)
96 }
97 if (hasProp(v, 'session')) {
98 this.session.hydrate(v.session)
99 }
100 if (hasProp(v, 'shell')) {
101 this.shell.hydrate(v.shell)
102 }
103 if (hasProp(v, 'preferences')) {
104 this.preferences.hydrate(v.preferences)
105 }
106 if (hasProp(v, 'invitedUsers')) {
107 this.invitedUsers.hydrate(v.invitedUsers)
108 }
109 if (hasProp(v, 'mutedThreads')) {
110 this.mutedThreads.hydrate(v.mutedThreads)
111 }
112 }
113 }
114
115 /**
116 * Called during init to resume any stored session.
117 */
118 async attemptSessionResumption() {
119 this.log.debug('RootStoreModel:attemptSessionResumption')
120 try {
121 await this.session.attemptSessionResumption()
122 this.log.debug('Session initialized', {
123 hasSession: this.session.hasSession,
124 })
125 this.updateSessionState()
126 } catch (e: any) {
127 this.log.warn('Failed to initialize session', e)
128 }
129 }
130
131 /**
132 * Called by the session model. Refreshes session-oriented state.
133 */
134 async handleSessionChange(
135 agent: BskyAgent,
136 {hadSession}: {hadSession: boolean},
137 ) {
138 this.log.debug('RootStoreModel:handleSessionChange')
139 this.agent = agent
140 applyDebugHeader(this.agent)
141 this.me.clear()
142 /* dont await */ this.preferences.sync()
143 await this.me.load()
144 if (!hadSession) {
145 await resetNavigation()
146 }
147 this.emitSessionReady()
148 }
149
150 /**
151 * Called by the session model. Handles session drops by informing the user.
152 */
153 async handleSessionDrop() {
154 this.log.debug('RootStoreModel:handleSessionDrop')
155 resetToTab('HomeTab')
156 this.me.clear()
157 this.emitSessionDropped()
158 }
159
160 /**
161 * Clears all session-oriented state.
162 */
163 clearAllSessionState() {
164 this.log.debug('RootStoreModel:clearAllSessionState')
165 this.session.clear()
166 resetToTab('HomeTab')
167 this.me.clear()
168 }
169
170 /**
171 * Periodic poll for new session state.
172 */
173 async updateSessionState() {
174 if (!this.session.hasSession) {
175 return
176 }
177 try {
178 await this.me.updateIfNeeded()
179 await this.preferences.sync()
180 } catch (e: any) {
181 this.log.error('Failed to fetch latest state', e)
182 }
183 }
184
185 // global event bus
186 // =
187 // - some events need to be passed around between views and models
188 // in order to keep state in sync; these methods are for that
189
190 // a post was deleted by the local user
191 onPostDeleted(handler: (uri: string) => void): EmitterSubscription {
192 return DeviceEventEmitter.addListener('post-deleted', handler)
193 }
194 emitPostDeleted(uri: string) {
195 DeviceEventEmitter.emit('post-deleted', uri)
196 }
197
198 // a list was deleted by the local user
199 onListDeleted(handler: (uri: string) => void): EmitterSubscription {
200 return DeviceEventEmitter.addListener('list-deleted', handler)
201 }
202 emitListDeleted(uri: string) {
203 DeviceEventEmitter.emit('list-deleted', uri)
204 }
205
206 // the session has started and been fully hydrated
207 onSessionLoaded(handler: () => void): EmitterSubscription {
208 return DeviceEventEmitter.addListener('session-loaded', handler)
209 }
210 emitSessionLoaded() {
211 DeviceEventEmitter.emit('session-loaded')
212 }
213
214 // the session has completed all setup; good for post-initialization behaviors like triggering modals
215 onSessionReady(handler: () => void): EmitterSubscription {
216 return DeviceEventEmitter.addListener('session-ready', handler)
217 }
218 emitSessionReady() {
219 DeviceEventEmitter.emit('session-ready')
220 }
221
222 // the session was dropped due to bad/expired refresh tokens
223 onSessionDropped(handler: () => void): EmitterSubscription {
224 return DeviceEventEmitter.addListener('session-dropped', handler)
225 }
226 emitSessionDropped() {
227 DeviceEventEmitter.emit('session-dropped')
228 }
229
230 // the current screen has changed
231 // TODO is this still needed?
232 onNavigation(handler: () => void): EmitterSubscription {
233 return DeviceEventEmitter.addListener('navigation', handler)
234 }
235 emitNavigation() {
236 DeviceEventEmitter.emit('navigation')
237 }
238
239 // a "soft reset" typically means scrolling to top and loading latest
240 // but it can depend on the screen
241 onScreenSoftReset(handler: () => void): EmitterSubscription {
242 return DeviceEventEmitter.addListener('screen-soft-reset', handler)
243 }
244 emitScreenSoftReset() {
245 DeviceEventEmitter.emit('screen-soft-reset')
246 }
247
248 // the unread notifications count has changed
249 onUnreadNotifications(handler: (count: number) => void): EmitterSubscription {
250 return DeviceEventEmitter.addListener('unread-notifications', handler)
251 }
252 emitUnreadNotifications(count: number) {
253 DeviceEventEmitter.emit('unread-notifications', count)
254 }
255}
256
257const throwawayInst = new RootStoreModel(
258 new BskyAgent({service: 'http://localhost'}),
259) // this will be replaced by the loader, we just need to supply a value at init
260const RootStoreContext = createContext<RootStoreModel>(throwawayInst)
261export const RootStoreProvider = RootStoreContext.Provider
262export const useStores = () => useContext(RootStoreContext)