diff options
Diffstat (limited to 'src/state/models/ui')
-rw-r--r-- | src/state/models/ui/create-account.ts | 216 | ||||
-rw-r--r-- | src/state/models/ui/my-feeds.ts | 182 | ||||
-rw-r--r-- | src/state/models/ui/preferences.ts | 702 | ||||
-rw-r--r-- | src/state/models/ui/profile.ts | 257 | ||||
-rw-r--r-- | src/state/models/ui/saved-feeds.ts | 155 | ||||
-rw-r--r-- | src/state/models/ui/search.ts | 69 | ||||
-rw-r--r-- | src/state/models/ui/shell.ts | 376 |
7 files changed, 0 insertions, 1957 deletions
diff --git a/src/state/models/ui/create-account.ts b/src/state/models/ui/create-account.ts deleted file mode 100644 index 1711b530f..000000000 --- a/src/state/models/ui/create-account.ts +++ /dev/null @@ -1,216 +0,0 @@ -import {makeAutoObservable} from 'mobx' -import {RootStoreModel} from '../root-store' -import {ServiceDescription} from '../session' -import {DEFAULT_SERVICE} from 'state/index' -import {ComAtprotoServerCreateAccount} from '@atproto/api' -import * as EmailValidator from 'email-validator' -import {createFullHandle} from 'lib/strings/handles' -import {cleanError} from 'lib/strings/errors' -import {getAge} from 'lib/strings/time' -import {track} from 'lib/analytics/analytics' -import {logger} from '#/logger' - -const DEFAULT_DATE = new Date(Date.now() - 60e3 * 60 * 24 * 365 * 20) // default to 20 years ago - -export class CreateAccountModel { - step: number = 1 - isProcessing = false - isFetchingServiceDescription = false - didServiceDescriptionFetchFail = false - error = '' - - serviceUrl = DEFAULT_SERVICE - serviceDescription: ServiceDescription | undefined = undefined - userDomain = '' - inviteCode = '' - email = '' - password = '' - handle = '' - birthDate = DEFAULT_DATE - - constructor(public rootStore: RootStoreModel) { - makeAutoObservable(this, {}, {autoBind: true}) - } - - get isAge13() { - return getAge(this.birthDate) >= 13 - } - - get isAge18() { - return getAge(this.birthDate) >= 18 - } - - // form state controls - // = - - next() { - this.error = '' - if (this.step === 2) { - if (!this.isAge13) { - this.error = - 'Unfortunately, you do not meet the requirements to create an account.' - return - } - } - this.step++ - } - - back() { - this.error = '' - this.step-- - } - - setStep(v: number) { - this.step = v - } - - async fetchServiceDescription() { - this.setError('') - this.setIsFetchingServiceDescription(true) - this.setDidServiceDescriptionFetchFail(false) - this.setServiceDescription(undefined) - if (!this.serviceUrl) { - return - } - try { - const desc = await this.rootStore.session.describeService(this.serviceUrl) - this.setServiceDescription(desc) - this.setUserDomain(desc.availableUserDomains[0]) - } catch (err: any) { - logger.warn( - `Failed to fetch service description for ${this.serviceUrl}`, - {error: err}, - ) - this.setError( - 'Unable to contact your service. Please check your Internet connection.', - ) - this.setDidServiceDescriptionFetchFail(true) - } finally { - this.setIsFetchingServiceDescription(false) - } - } - - async submit() { - if (!this.email) { - this.setStep(2) - return this.setError('Please enter your email.') - } - if (!EmailValidator.validate(this.email)) { - this.setStep(2) - return this.setError('Your email appears to be invalid.') - } - if (!this.password) { - this.setStep(2) - return this.setError('Please choose your password.') - } - if (!this.handle) { - this.setStep(3) - return this.setError('Please choose your handle.') - } - this.setError('') - this.setIsProcessing(true) - - try { - this.rootStore.onboarding.start() // start now to avoid flashing the wrong view - await this.rootStore.session.createAccount({ - service: this.serviceUrl, - email: this.email, - handle: createFullHandle(this.handle, this.userDomain), - password: this.password, - inviteCode: this.inviteCode.trim(), - }) - /* dont await */ this.rootStore.preferences.setBirthDate(this.birthDate) - track('Create Account') - } catch (e: any) { - this.rootStore.onboarding.skip() // undo starting the onboard - let errMsg = e.toString() - if (e instanceof ComAtprotoServerCreateAccount.InvalidInviteCodeError) { - errMsg = - 'Invite code not accepted. Check that you input it correctly and try again.' - } - logger.error('Failed to create account', {error: e}) - this.setIsProcessing(false) - this.setError(cleanError(errMsg)) - throw e - } - } - - // form state accessors - // = - - get canBack() { - return this.step > 1 - } - - get canNext() { - if (this.step === 1) { - return !!this.serviceDescription - } else if (this.step === 2) { - return ( - (!this.isInviteCodeRequired || this.inviteCode) && - !!this.email && - !!this.password - ) - } - return !!this.handle - } - - get isServiceDescribed() { - return !!this.serviceDescription - } - - get isInviteCodeRequired() { - return this.serviceDescription?.inviteCodeRequired - } - - // setters - // = - - setIsProcessing(v: boolean) { - this.isProcessing = v - } - - setIsFetchingServiceDescription(v: boolean) { - this.isFetchingServiceDescription = v - } - - setDidServiceDescriptionFetchFail(v: boolean) { - this.didServiceDescriptionFetchFail = v - } - - setError(v: string) { - this.error = v - } - - setServiceUrl(v: string) { - this.serviceUrl = v - } - - setServiceDescription(v: ServiceDescription | undefined) { - this.serviceDescription = v - } - - setUserDomain(v: string) { - this.userDomain = v - } - - setInviteCode(v: string) { - this.inviteCode = v - } - - setEmail(v: string) { - this.email = v - } - - setPassword(v: string) { - this.password = v - } - - setHandle(v: string) { - this.handle = v - } - - setBirthDate(v: Date) { - this.birthDate = v - } -} diff --git a/src/state/models/ui/my-feeds.ts b/src/state/models/ui/my-feeds.ts deleted file mode 100644 index ade686338..000000000 --- a/src/state/models/ui/my-feeds.ts +++ /dev/null @@ -1,182 +0,0 @@ -import {makeAutoObservable, reaction} from 'mobx' -import {SavedFeedsModel} from './saved-feeds' -import {FeedsDiscoveryModel} from '../discovery/feeds' -import {FeedSourceModel} from '../content/feed-source' -import {RootStoreModel} from '../root-store' - -export type MyFeedsItem = - | { - _reactKey: string - type: 'spinner' - } - | { - _reactKey: string - type: 'saved-feeds-loading' - numItems: number - } - | { - _reactKey: string - type: 'discover-feeds-loading' - } - | { - _reactKey: string - type: 'error' - error: string - } - | { - _reactKey: string - type: 'saved-feeds-header' - } - | { - _reactKey: string - type: 'saved-feed' - feed: FeedSourceModel - } - | { - _reactKey: string - type: 'saved-feeds-load-more' - } - | { - _reactKey: string - type: 'discover-feeds-header' - } - | { - _reactKey: string - type: 'discover-feeds-no-results' - } - | { - _reactKey: string - type: 'discover-feed' - feed: FeedSourceModel - } - -export class MyFeedsUIModel { - saved: SavedFeedsModel - discovery: FeedsDiscoveryModel - - constructor(public rootStore: RootStoreModel) { - makeAutoObservable(this) - this.saved = new SavedFeedsModel(this.rootStore) - this.discovery = new FeedsDiscoveryModel(this.rootStore) - } - - get isRefreshing() { - return !this.saved.isLoading && this.saved.isRefreshing - } - - get isLoading() { - return this.saved.isLoading || this.discovery.isLoading - } - - async setup() { - if (!this.saved.hasLoaded) { - await this.saved.refresh() - } - if (!this.discovery.hasLoaded) { - await this.discovery.refresh() - } - } - - clear() { - this.saved.clear() - this.discovery.clear() - } - - registerListeners() { - const dispose1 = reaction( - () => this.rootStore.preferences.savedFeeds, - () => this.saved.refresh(), - ) - const dispose2 = reaction( - () => this.rootStore.preferences.pinnedFeeds, - () => this.saved.refresh(), - ) - return () => { - dispose1() - dispose2() - } - } - - async refresh() { - return Promise.all([this.saved.refresh(), this.discovery.refresh()]) - } - - async loadMore() { - return this.discovery.loadMore() - } - - get items() { - let items: MyFeedsItem[] = [] - - items.push({ - _reactKey: '__saved_feeds_header__', - type: 'saved-feeds-header', - }) - if (this.saved.isLoading && !this.saved.hasContent) { - items.push({ - _reactKey: '__saved_feeds_loading__', - type: 'saved-feeds-loading', - numItems: this.rootStore.preferences.savedFeeds.length || 3, - }) - } else if (this.saved.hasError) { - items.push({ - _reactKey: '__saved_feeds_error__', - type: 'error', - error: this.saved.error, - }) - } else { - const savedSorted = this.saved.all - .slice() - .sort((a, b) => a.displayName.localeCompare(b.displayName)) - items = items.concat( - savedSorted.map(feed => ({ - _reactKey: `saved-${feed.uri}`, - type: 'saved-feed', - feed, - })), - ) - items.push({ - _reactKey: '__saved_feeds_load_more__', - type: 'saved-feeds-load-more', - }) - } - - items.push({ - _reactKey: '__discover_feeds_header__', - type: 'discover-feeds-header', - }) - if (this.discovery.isLoading && !this.discovery.hasContent) { - items.push({ - _reactKey: '__discover_feeds_loading__', - type: 'discover-feeds-loading', - }) - } else if (this.discovery.hasError) { - items.push({ - _reactKey: '__discover_feeds_error__', - type: 'error', - error: this.discovery.error, - }) - } else if (this.discovery.isEmpty) { - items.push({ - _reactKey: '__discover_feeds_no_results__', - type: 'discover-feeds-no-results', - }) - } else { - items = items.concat( - this.discovery.feeds.map(feed => ({ - _reactKey: `discover-${feed.uri}`, - type: 'discover-feed', - feed, - })), - ) - if (this.discovery.isLoading) { - items.push({ - _reactKey: '__discover_feeds_loading_more__', - type: 'spinner', - }) - } - } - - return items - } -} diff --git a/src/state/models/ui/preferences.ts b/src/state/models/ui/preferences.ts deleted file mode 100644 index 6e43198a3..000000000 --- a/src/state/models/ui/preferences.ts +++ /dev/null @@ -1,702 +0,0 @@ -import {makeAutoObservable, runInAction} from 'mobx' -import { - LabelPreference as APILabelPreference, - BskyFeedViewPreference, - BskyThreadViewPreference, -} from '@atproto/api' -import AwaitLock from 'await-lock' -import isEqual from 'lodash.isequal' -import {isObj, hasProp} from 'lib/type-guards' -import {RootStoreModel} from '../root-store' -import {ModerationOpts} from '@atproto/api' -import {DEFAULT_FEEDS} from 'lib/constants' -import {deviceLocales} from 'platform/detection' -import {getAge} from 'lib/strings/time' -import {FeedTuner} from 'lib/api/feed-manip' -import {LANGUAGES} from '../../../locale/languages' -import {logger} from '#/logger' - -// TEMP we need to permanently convert 'show' to 'ignore', for now we manually convert -prf -export type LabelPreference = APILabelPreference | 'show' -export type FeedViewPreference = BskyFeedViewPreference & { - lab_mergeFeedEnabled?: boolean | undefined -} -export type ThreadViewPreference = BskyThreadViewPreference & { - lab_treeViewEnabled?: boolean | undefined -} -const LABEL_GROUPS = [ - 'nsfw', - 'nudity', - 'suggestive', - 'gore', - 'hate', - 'spam', - 'impersonation', -] -const VISIBILITY_VALUES = ['ignore', 'warn', 'hide'] -const DEFAULT_LANG_CODES = (deviceLocales || []) - .concat(['en', 'ja', 'pt', 'de']) - .slice(0, 6) -const THREAD_SORT_VALUES = ['oldest', 'newest', 'most-likes', 'random'] - -interface LegacyPreferences { - hideReplies?: boolean - hideRepliesByLikeCount?: number - hideReposts?: boolean - hideQuotePosts?: boolean -} - -export class LabelPreferencesModel { - nsfw: LabelPreference = 'hide' - nudity: LabelPreference = 'warn' - suggestive: LabelPreference = 'warn' - gore: LabelPreference = 'warn' - hate: LabelPreference = 'hide' - spam: LabelPreference = 'hide' - impersonation: LabelPreference = 'warn' - - constructor() { - makeAutoObservable(this, {}, {autoBind: true}) - } -} - -export class PreferencesModel { - adultContentEnabled = false - primaryLanguage: string = deviceLocales[0] || 'en' - contentLanguages: string[] = deviceLocales || [] - postLanguage: string = deviceLocales[0] || 'en' - postLanguageHistory: string[] = DEFAULT_LANG_CODES - contentLabels = new LabelPreferencesModel() - savedFeeds: string[] = [] - pinnedFeeds: string[] = [] - birthDate: Date | undefined = undefined - homeFeed: FeedViewPreference = { - hideReplies: false, - hideRepliesByUnfollowed: false, - hideRepliesByLikeCount: 0, - hideReposts: false, - hideQuotePosts: false, - lab_mergeFeedEnabled: false, // experimental - } - thread: ThreadViewPreference = { - sort: 'oldest', - prioritizeFollowedUsers: true, - lab_treeViewEnabled: false, // experimental - } - requireAltTextEnabled: boolean = false - - // used to help with transitions from device-stored to server-stored preferences - legacyPreferences: LegacyPreferences | undefined - - // used to linearize async modifications to state - lock = new AwaitLock() - - constructor(public rootStore: RootStoreModel) { - makeAutoObservable(this, {lock: false}, {autoBind: true}) - } - - get userAge(): number | undefined { - if (!this.birthDate) { - return undefined - } - return getAge(this.birthDate) - } - - serialize() { - return { - primaryLanguage: this.primaryLanguage, - contentLanguages: this.contentLanguages, - postLanguage: this.postLanguage, - postLanguageHistory: this.postLanguageHistory, - contentLabels: this.contentLabels, - savedFeeds: this.savedFeeds, - pinnedFeeds: this.pinnedFeeds, - requireAltTextEnabled: this.requireAltTextEnabled, - } - } - - /** - * The function hydrates an object with properties related to content languages, labels, saved feeds, - * and pinned feeds that it gets from the parameter `v` (probably local storage) - * @param {unknown} v - the data object to hydrate from - */ - hydrate(v: unknown) { - if (isObj(v)) { - if ( - hasProp(v, 'primaryLanguage') && - typeof v.primaryLanguage === 'string' - ) { - this.primaryLanguage = v.primaryLanguage - } else { - // default to the device languages - this.primaryLanguage = deviceLocales[0] || 'en' - } - // check if content languages in preferences exist, otherwise default to device languages - if ( - hasProp(v, 'contentLanguages') && - Array.isArray(v.contentLanguages) && - typeof v.contentLanguages.every(item => typeof item === 'string') - ) { - this.contentLanguages = v.contentLanguages - } else { - // default to the device languages - this.contentLanguages = deviceLocales - } - if (hasProp(v, 'postLanguage') && typeof v.postLanguage === 'string') { - this.postLanguage = v.postLanguage - } else { - // default to the device languages - this.postLanguage = deviceLocales[0] || 'en' - } - if ( - hasProp(v, 'postLanguageHistory') && - Array.isArray(v.postLanguageHistory) && - typeof v.postLanguageHistory.every(item => typeof item === 'string') - ) { - this.postLanguageHistory = v.postLanguageHistory - .concat(DEFAULT_LANG_CODES) - .slice(0, 6) - } else { - // default to a starter set - this.postLanguageHistory = DEFAULT_LANG_CODES - } - // check if content labels in preferences exist, then hydrate - if (hasProp(v, 'contentLabels') && typeof v.contentLabels === 'object') { - Object.assign(this.contentLabels, v.contentLabels) - } - // check if saved feeds in preferences, then hydrate - if ( - hasProp(v, 'savedFeeds') && - Array.isArray(v.savedFeeds) && - typeof v.savedFeeds.every(item => typeof item === 'string') - ) { - this.savedFeeds = v.savedFeeds - } - // check if pinned feeds in preferences exist, then hydrate - if ( - hasProp(v, 'pinnedFeeds') && - Array.isArray(v.pinnedFeeds) && - typeof v.pinnedFeeds.every(item => typeof item === 'string') - ) { - 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) - } - } - - /** - * This function fetches preferences and sets defaults for missing items. - */ - async sync() { - await this.lock.acquireAsync() - try { - // fetch preferences - const prefs = await this.rootStore.agent.getPreferences() - - runInAction(() => { - if (prefs.feedViewPrefs.home) { - this.homeFeed = prefs.feedViewPrefs.home - } - this.thread = prefs.threadViewPrefs - this.adultContentEnabled = prefs.adultContentEnabled - for (const label in prefs.contentLabels) { - if ( - LABEL_GROUPS.includes(label) && - VISIBILITY_VALUES.includes(prefs.contentLabels[label]) - ) { - this.contentLabels[label as keyof LabelPreferencesModel] = - prefs.contentLabels[label] - } - } - if (prefs.feeds.saved && !isEqual(this.savedFeeds, prefs.feeds.saved)) { - this.savedFeeds = prefs.feeds.saved - } - if ( - prefs.feeds.pinned && - !isEqual(this.pinnedFeeds, prefs.feeds.pinned) - ) { - this.pinnedFeeds = prefs.feeds.pinned - } - this.birthDate = prefs.birthDate - }) - - // sync legacy values if needed - await this.syncLegacyPreferences() - - // set defaults on missing items - if (typeof prefs.feeds.saved === 'undefined') { - try { - const {saved, pinned} = await DEFAULT_FEEDS( - this.rootStore.agent.service.toString(), - (handle: string) => - this.rootStore.agent - .resolveHandle({handle}) - .then(({data}) => data.did), - ) - runInAction(() => { - this.savedFeeds = saved - this.pinnedFeeds = pinned - }) - await this.rootStore.agent.setSavedFeeds(saved, pinned) - } catch (error) { - logger.error('Failed to set default feeds', {error}) - } - } - } finally { - this.lock.release() - } - } - - async syncLegacyPreferences() { - if (this.legacyPreferences) { - this.homeFeed = {...this.homeFeed, ...this.legacyPreferences} - this.legacyPreferences = undefined - await this.rootStore.agent.setFeedViewPrefs('home', this.homeFeed) - } - } - - /** - * This function resets the preferences to an empty array of no preferences. - */ - async reset() { - await this.lock.acquireAsync() - try { - runInAction(() => { - this.contentLabels = new LabelPreferencesModel() - this.contentLanguages = deviceLocales - this.postLanguage = deviceLocales ? deviceLocales.join(',') : 'en' - this.postLanguageHistory = DEFAULT_LANG_CODES - this.savedFeeds = [] - this.pinnedFeeds = [] - }) - await this.rootStore.agent.app.bsky.actor.putPreferences({ - preferences: [], - }) - } finally { - this.lock.release() - } - } - - // languages - // = - - hasContentLanguage(code2: string) { - return this.contentLanguages.includes(code2) - } - - toggleContentLanguage(code2: string) { - if (this.hasContentLanguage(code2)) { - this.contentLanguages = this.contentLanguages.filter( - lang => lang !== code2, - ) - } else { - this.contentLanguages = this.contentLanguages.concat([code2]) - } - } - - /** - * A getter that splits `this.postLanguage` into an array of strings. - * - * This was previously the main field on this model, but now we're - * concatenating lang codes to make multi-selection a little better. - */ - get postLanguages() { - // filter out empty strings if exist - return this.postLanguage.split(',').filter(Boolean) - } - - hasPostLanguage(code2: string) { - return this.postLanguages.includes(code2) - } - - togglePostLanguage(code2: string) { - if (this.hasPostLanguage(code2)) { - this.postLanguage = this.postLanguages - .filter(lang => lang !== code2) - .join(',') - } else { - // sort alphabetically for deterministic comparison in context menu - this.postLanguage = this.postLanguages - .concat([code2]) - .sort((a, b) => a.localeCompare(b)) - .join(',') - } - } - - setPostLanguage(commaSeparatedLangCodes: string) { - this.postLanguage = commaSeparatedLangCodes - } - - /** - * Saves whatever language codes are currently selected into a history array, - * which is then used to populate the language selector menu. - */ - savePostLanguageToHistory() { - // filter out duplicate `this.postLanguage` if exists, and prepend - // value to start of array - this.postLanguageHistory = [this.postLanguage] - .concat( - this.postLanguageHistory.filter( - commaSeparatedLangCodes => - commaSeparatedLangCodes !== this.postLanguage, - ), - ) - .slice(0, 6) - } - - getReadablePostLanguages() { - const all = this.postLanguages.map(code2 => { - const lang = LANGUAGES.find(l => l.code2 === code2) - return lang ? lang.name : code2 - }) - return all.join(', ') - } - - // moderation - // = - - async setContentLabelPref( - key: keyof LabelPreferencesModel, - value: LabelPreference, - ) { - this.contentLabels[key] = value - await this.rootStore.agent.setContentLabelPref(key, value) - } - - async setAdultContentEnabled(v: boolean) { - this.adultContentEnabled = v - await this.rootStore.agent.setAdultContentEnabled(v) - } - - get moderationOpts(): ModerationOpts { - return { - userDid: this.rootStore.session.currentSession?.did || '', - adultContentEnabled: this.adultContentEnabled, - labels: { - // TEMP translate old settings until this UI can be migrated -prf - porn: tempfixLabelPref(this.contentLabels.nsfw), - sexual: tempfixLabelPref(this.contentLabels.suggestive), - nudity: tempfixLabelPref(this.contentLabels.nudity), - nsfl: tempfixLabelPref(this.contentLabels.gore), - corpse: tempfixLabelPref(this.contentLabels.gore), - gore: tempfixLabelPref(this.contentLabels.gore), - torture: tempfixLabelPref(this.contentLabels.gore), - 'self-harm': tempfixLabelPref(this.contentLabels.gore), - 'intolerant-race': tempfixLabelPref(this.contentLabels.hate), - 'intolerant-gender': tempfixLabelPref(this.contentLabels.hate), - 'intolerant-sexual-orientation': tempfixLabelPref( - this.contentLabels.hate, - ), - 'intolerant-religion': tempfixLabelPref(this.contentLabels.hate), - intolerant: tempfixLabelPref(this.contentLabels.hate), - 'icon-intolerant': tempfixLabelPref(this.contentLabels.hate), - spam: tempfixLabelPref(this.contentLabels.spam), - impersonation: tempfixLabelPref(this.contentLabels.impersonation), - scam: 'warn', - }, - labelers: [ - { - labeler: { - did: '', - displayName: 'Bluesky Social', - }, - labels: {}, - }, - ], - } - } - - // feeds - // = - - isPinnedFeed(uri: string) { - return this.pinnedFeeds.includes(uri) - } - - async _optimisticUpdateSavedFeeds( - saved: string[], - pinned: string[], - cb: () => Promise<{saved: string[]; pinned: string[]}>, - ) { - const oldSaved = this.savedFeeds - const oldPinned = this.pinnedFeeds - this.savedFeeds = saved - this.pinnedFeeds = pinned - await this.lock.acquireAsync() - try { - const res = await cb() - runInAction(() => { - this.savedFeeds = res.saved - this.pinnedFeeds = res.pinned - }) - } catch (e) { - runInAction(() => { - this.savedFeeds = oldSaved - this.pinnedFeeds = oldPinned - }) - throw e - } finally { - this.lock.release() - } - } - - async setSavedFeeds(saved: string[], pinned: string[]) { - return this._optimisticUpdateSavedFeeds(saved, pinned, () => - this.rootStore.agent.setSavedFeeds(saved, pinned), - ) - } - - async addSavedFeed(v: string) { - return this._optimisticUpdateSavedFeeds( - [...this.savedFeeds.filter(uri => uri !== v), v], - this.pinnedFeeds, - () => this.rootStore.agent.addSavedFeed(v), - ) - } - - async removeSavedFeed(v: string) { - return this._optimisticUpdateSavedFeeds( - this.savedFeeds.filter(uri => uri !== v), - this.pinnedFeeds.filter(uri => uri !== v), - () => this.rootStore.agent.removeSavedFeed(v), - ) - } - - async addPinnedFeed(v: string) { - return this._optimisticUpdateSavedFeeds( - [...this.savedFeeds.filter(uri => uri !== v), v], - [...this.pinnedFeeds.filter(uri => uri !== v), v], - () => this.rootStore.agent.addPinnedFeed(v), - ) - } - - async removePinnedFeed(v: string) { - return this._optimisticUpdateSavedFeeds( - this.savedFeeds, - this.pinnedFeeds.filter(uri => uri !== v), - () => this.rootStore.agent.removePinnedFeed(v), - ) - } - - // other - // = - - async setBirthDate(birthDate: Date) { - this.birthDate = birthDate - await this.lock.acquireAsync() - try { - await this.rootStore.agent.setPersonalDetails({birthDate}) - } finally { - this.lock.release() - } - } - - async toggleHomeFeedHideReplies() { - this.homeFeed.hideReplies = !this.homeFeed.hideReplies - await this.lock.acquireAsync() - try { - await this.rootStore.agent.setFeedViewPrefs('home', { - hideReplies: this.homeFeed.hideReplies, - }) - } finally { - this.lock.release() - } - } - - async toggleHomeFeedHideRepliesByUnfollowed() { - this.homeFeed.hideRepliesByUnfollowed = - !this.homeFeed.hideRepliesByUnfollowed - await this.lock.acquireAsync() - try { - await this.rootStore.agent.setFeedViewPrefs('home', { - hideRepliesByUnfollowed: this.homeFeed.hideRepliesByUnfollowed, - }) - } finally { - this.lock.release() - } - } - - async setHomeFeedHideRepliesByLikeCount(threshold: number) { - this.homeFeed.hideRepliesByLikeCount = threshold - await this.lock.acquireAsync() - try { - await this.rootStore.agent.setFeedViewPrefs('home', { - hideRepliesByLikeCount: this.homeFeed.hideRepliesByLikeCount, - }) - } finally { - this.lock.release() - } - } - - async toggleHomeFeedHideReposts() { - this.homeFeed.hideReposts = !this.homeFeed.hideReposts - await this.lock.acquireAsync() - try { - await this.rootStore.agent.setFeedViewPrefs('home', { - hideReposts: this.homeFeed.hideReposts, - }) - } finally { - this.lock.release() - } - } - - async toggleHomeFeedHideQuotePosts() { - this.homeFeed.hideQuotePosts = !this.homeFeed.hideQuotePosts - await this.lock.acquireAsync() - try { - await this.rootStore.agent.setFeedViewPrefs('home', { - hideQuotePosts: this.homeFeed.hideQuotePosts, - }) - } finally { - this.lock.release() - } - } - - async toggleHomeFeedMergeFeedEnabled() { - this.homeFeed.lab_mergeFeedEnabled = !this.homeFeed.lab_mergeFeedEnabled - await this.lock.acquireAsync() - try { - await this.rootStore.agent.setFeedViewPrefs('home', { - lab_mergeFeedEnabled: this.homeFeed.lab_mergeFeedEnabled, - }) - } finally { - this.lock.release() - } - } - - async setThreadSort(v: string) { - if (THREAD_SORT_VALUES.includes(v)) { - this.thread.sort = v - await this.lock.acquireAsync() - try { - await this.rootStore.agent.setThreadViewPrefs({sort: v}) - } finally { - this.lock.release() - } - } - } - - async togglePrioritizedFollowedUsers() { - this.thread.prioritizeFollowedUsers = !this.thread.prioritizeFollowedUsers - await this.lock.acquireAsync() - try { - await this.rootStore.agent.setThreadViewPrefs({ - prioritizeFollowedUsers: this.thread.prioritizeFollowedUsers, - }) - } finally { - this.lock.release() - } - } - - async toggleThreadTreeViewEnabled() { - this.thread.lab_treeViewEnabled = !this.thread.lab_treeViewEnabled - await this.lock.acquireAsync() - try { - await this.rootStore.agent.setThreadViewPrefs({ - lab_treeViewEnabled: this.thread.lab_treeViewEnabled, - }) - } finally { - this.lock.release() - } - } - - toggleRequireAltTextEnabled() { - this.requireAltTextEnabled = !this.requireAltTextEnabled - } - - setPrimaryLanguage(lang: string) { - this.primaryLanguage = lang - } - - getFeedTuners( - feedType: 'home' | 'following' | 'author' | 'custom' | 'list' | 'likes', - ) { - if (feedType === 'custom') { - return [ - FeedTuner.dedupReposts, - FeedTuner.preferredLangOnly(this.contentLanguages), - ] - } - if (feedType === 'list') { - return [FeedTuner.dedupReposts] - } - if (feedType === 'home' || feedType === 'following') { - const feedTuners = [] - - if (this.homeFeed.hideReposts) { - feedTuners.push(FeedTuner.removeReposts) - } else { - feedTuners.push(FeedTuner.dedupReposts) - } - - if (this.homeFeed.hideReplies) { - feedTuners.push(FeedTuner.removeReplies) - } else { - feedTuners.push( - FeedTuner.thresholdRepliesOnly({ - userDid: this.rootStore.session.data?.did || '', - minLikes: this.homeFeed.hideRepliesByLikeCount, - followedOnly: !!this.homeFeed.hideRepliesByUnfollowed, - }), - ) - } - - if (this.homeFeed.hideQuotePosts) { - feedTuners.push(FeedTuner.removeQuotePosts) - } - - return feedTuners - } - return [] - } -} - -// TEMP we need to permanently convert 'show' to 'ignore', for now we manually convert -prf -function tempfixLabelPref(pref: LabelPreference): APILabelPreference { - if (pref === 'show') { - return 'ignore' - } - return pref -} - -function getLegacyPreferences( - v: Record<string, unknown>, -): LegacyPreferences | undefined { - const legacyPreferences: LegacyPreferences = {} - if ( - hasProp(v, 'homeFeedRepliesEnabled') && - typeof v.homeFeedRepliesEnabled === 'boolean' - ) { - legacyPreferences.hideReplies = !v.homeFeedRepliesEnabled - } - if ( - hasProp(v, 'homeFeedRepliesThreshold') && - typeof v.homeFeedRepliesThreshold === 'number' - ) { - legacyPreferences.hideRepliesByLikeCount = v.homeFeedRepliesThreshold - } - if ( - hasProp(v, 'homeFeedRepostsEnabled') && - typeof v.homeFeedRepostsEnabled === 'boolean' - ) { - legacyPreferences.hideReposts = !v.homeFeedRepostsEnabled - } - if ( - hasProp(v, 'homeFeedQuotePostsEnabled') && - typeof v.homeFeedQuotePostsEnabled === 'boolean' - ) { - legacyPreferences.hideQuotePosts = !v.homeFeedQuotePostsEnabled - } - if (Object.keys(legacyPreferences).length) { - return legacyPreferences - } - return undefined -} diff --git a/src/state/models/ui/profile.ts b/src/state/models/ui/profile.ts deleted file mode 100644 index f96340c65..000000000 --- a/src/state/models/ui/profile.ts +++ /dev/null @@ -1,257 +0,0 @@ -import {makeAutoObservable, runInAction} from 'mobx' -import {RootStoreModel} from '../root-store' -import {ProfileModel} from '../content/profile' -import {PostsFeedModel} from '../feeds/posts' -import {ActorFeedsModel} from '../lists/actor-feeds' -import {ListsListModel} from '../lists/lists-list' -import {logger} from '#/logger' - -export enum Sections { - PostsNoReplies = 'Posts', - PostsWithReplies = 'Posts & replies', - PostsWithMedia = 'Media', - Likes = 'Likes', - CustomAlgorithms = 'Feeds', - Lists = 'Lists', -} - -export interface ProfileUiParams { - user: string -} - -export class ProfileUiModel { - static LOADING_ITEM = {_reactKey: '__loading__'} - static END_ITEM = {_reactKey: '__end__'} - static EMPTY_ITEM = {_reactKey: '__empty__'} - - isAuthenticatedUser = false - - // data - profile: ProfileModel - feed: PostsFeedModel - algos: ActorFeedsModel - lists: ListsListModel - - // ui state - selectedViewIndex = 0 - - constructor( - public rootStore: RootStoreModel, - public params: ProfileUiParams, - ) { - makeAutoObservable( - this, - { - rootStore: false, - params: false, - }, - {autoBind: true}, - ) - this.profile = new ProfileModel(rootStore, {actor: params.user}) - this.feed = new PostsFeedModel(rootStore, 'author', { - actor: params.user, - limit: 10, - filter: 'posts_no_replies', - }) - this.algos = new ActorFeedsModel(rootStore, {actor: params.user}) - this.lists = new ListsListModel(rootStore, params.user) - } - - get currentView(): PostsFeedModel | ActorFeedsModel | ListsListModel { - if ( - this.selectedView === Sections.PostsNoReplies || - this.selectedView === Sections.PostsWithReplies || - this.selectedView === Sections.PostsWithMedia || - this.selectedView === Sections.Likes - ) { - return this.feed - } else if (this.selectedView === Sections.Lists) { - return this.lists - } - if (this.selectedView === Sections.CustomAlgorithms) { - return this.algos - } - throw new Error(`Invalid selector value: ${this.selectedViewIndex}`) - } - - get isInitialLoading() { - const view = this.currentView - return view.isLoading && !view.isRefreshing && !view.hasContent - } - - get isRefreshing() { - return this.profile.isRefreshing || this.currentView.isRefreshing - } - - get selectorItems() { - const items = [ - Sections.PostsNoReplies, - Sections.PostsWithReplies, - Sections.PostsWithMedia, - this.isAuthenticatedUser && Sections.Likes, - ].filter(Boolean) as string[] - if (this.algos.hasLoaded && !this.algos.isEmpty) { - items.push(Sections.CustomAlgorithms) - } - if (this.lists.hasLoaded && !this.lists.isEmpty) { - items.push(Sections.Lists) - } - return items - } - - get selectedView() { - // If, for whatever reason, the selected view index is not available, default back to posts - // This can happen when the user was focused on a view but performed an action that caused - // the view to disappear (e.g. deleting the last list in their list of lists https://imgflip.com/i/7txu1y) - return this.selectorItems[this.selectedViewIndex] || Sections.PostsNoReplies - } - - get uiItems() { - let arr: any[] = [] - // if loading, return loading item to show loading spinner - if (this.isInitialLoading) { - arr = arr.concat([ProfileUiModel.LOADING_ITEM]) - } else if (this.currentView.hasError) { - // if error, return error item to show error message - arr = arr.concat([ - { - _reactKey: '__error__', - error: this.currentView.error, - }, - ]) - } else { - if ( - this.selectedView === Sections.PostsNoReplies || - this.selectedView === Sections.PostsWithReplies || - this.selectedView === Sections.PostsWithMedia || - this.selectedView === Sections.Likes - ) { - if (this.feed.hasContent) { - arr = this.feed.slices.slice() - if (!this.feed.hasMore) { - arr = arr.concat([ProfileUiModel.END_ITEM]) - } - } else if (this.feed.isEmpty) { - arr = arr.concat([ProfileUiModel.EMPTY_ITEM]) - } - } else if (this.selectedView === Sections.CustomAlgorithms) { - if (this.algos.hasContent) { - arr = this.algos.feeds - } else if (this.algos.isEmpty) { - arr = arr.concat([ProfileUiModel.EMPTY_ITEM]) - } - } else if (this.selectedView === Sections.Lists) { - if (this.lists.hasContent) { - arr = this.lists.lists - } else if (this.lists.isEmpty) { - arr = arr.concat([ProfileUiModel.EMPTY_ITEM]) - } - } else { - // fallback, add empty item, to show empty message - arr = arr.concat([ProfileUiModel.EMPTY_ITEM]) - } - } - return arr - } - - get showLoadingMoreFooter() { - if ( - this.selectedView === Sections.PostsNoReplies || - this.selectedView === Sections.PostsWithReplies || - this.selectedView === Sections.PostsWithMedia || - this.selectedView === Sections.Likes - ) { - return this.feed.hasContent && this.feed.hasMore && this.feed.isLoading - } else if (this.selectedView === Sections.Lists) { - return this.lists.hasContent && this.lists.hasMore && this.lists.isLoading - } - return false - } - - // public api - // = - - setSelectedViewIndex(index: number) { - // ViewSelector fires onSelectView on mount - if (index === this.selectedViewIndex) return - - this.selectedViewIndex = index - - if ( - this.selectedView === Sections.PostsNoReplies || - this.selectedView === Sections.PostsWithReplies || - this.selectedView === Sections.PostsWithMedia - ) { - let filter = 'posts_no_replies' - if (this.selectedView === Sections.PostsWithReplies) { - filter = 'posts_with_replies' - } else if (this.selectedView === Sections.PostsWithMedia) { - filter = 'posts_with_media' - } - - this.feed = new PostsFeedModel( - this.rootStore, - 'author', - { - actor: this.params.user, - limit: 10, - filter, - }, - { - isSimpleFeed: ['posts_with_media'].includes(filter), - }, - ) - - this.feed.setup() - } else if (this.selectedView === Sections.Likes) { - this.feed = new PostsFeedModel( - this.rootStore, - 'likes', - { - actor: this.params.user, - limit: 10, - }, - { - isSimpleFeed: true, - }, - ) - - this.feed.setup() - } - } - - async setup() { - await Promise.all([ - this.profile - .setup() - .catch(err => logger.error('Failed to fetch profile', {error: err})), - this.feed - .setup() - .catch(err => logger.error('Failed to fetch feed', {error: err})), - ]) - runInAction(() => { - this.isAuthenticatedUser = - this.profile.did === this.rootStore.session.currentSession?.did - }) - this.algos.refresh() - // HACK: need to use the DID as a param, not the username -prf - this.lists.source = this.profile.did - this.lists - .loadMore() - .catch(err => logger.error('Failed to fetch lists', {error: err})) - } - - async refresh() { - await Promise.all([this.profile.refresh(), this.currentView.refresh()]) - } - - async loadMore() { - if ( - !this.currentView.isLoading && - !this.currentView.hasError && - !this.currentView.isEmpty - ) { - await this.currentView.loadMore() - } - } -} diff --git a/src/state/models/ui/saved-feeds.ts b/src/state/models/ui/saved-feeds.ts deleted file mode 100644 index 624da4f5f..000000000 --- a/src/state/models/ui/saved-feeds.ts +++ /dev/null @@ -1,155 +0,0 @@ -import {makeAutoObservable, runInAction} from 'mobx' -import {RootStoreModel} from '../root-store' -import {bundleAsync} from 'lib/async/bundle' -import {cleanError} from 'lib/strings/errors' -import {FeedSourceModel} from '../content/feed-source' -import {track} from 'lib/analytics/analytics' -import {logger} from '#/logger' - -export class SavedFeedsModel { - // state - isLoading = false - isRefreshing = false - hasLoaded = false - error = '' - - // data - all: FeedSourceModel[] = [] - - constructor(public rootStore: RootStoreModel) { - makeAutoObservable( - this, - { - rootStore: false, - }, - {autoBind: true}, - ) - } - - get hasContent() { - return this.all.length > 0 - } - - get hasError() { - return this.error !== '' - } - - get isEmpty() { - return this.hasLoaded && !this.hasContent - } - - get pinned(): FeedSourceModel[] { - return this.rootStore.preferences.savedFeeds - .filter(feed => this.rootStore.preferences.isPinnedFeed(feed)) - .map(uri => this.all.find(f => f.uri === uri)) - .filter(Boolean) as FeedSourceModel[] - } - - get unpinned(): FeedSourceModel[] { - return this.rootStore.preferences.savedFeeds - .filter(feed => !this.rootStore.preferences.isPinnedFeed(feed)) - .map(uri => this.all.find(f => f.uri === uri)) - .filter(Boolean) as FeedSourceModel[] - } - - get pinnedFeedNames() { - return this.pinned.map(f => f.displayName) - } - - // public api - // = - - clear() { - this.all = [] - } - - /** - * Refresh the preferences then reload all feed infos - */ - refresh = bundleAsync(async () => { - this._xLoading(true) - try { - await this.rootStore.preferences.sync() - const uris = dedup( - this.rootStore.preferences.pinnedFeeds.concat( - this.rootStore.preferences.savedFeeds, - ), - ) - const feeds = uris.map(uri => new FeedSourceModel(this.rootStore, uri)) - await Promise.all(feeds.map(f => f.setup())) - runInAction(() => { - this.all = feeds - this._updatePinSortOrder() - }) - this._xIdle() - } catch (e: any) { - this._xIdle(e) - } - }) - - async reorderPinnedFeeds(feeds: FeedSourceModel[]) { - this._updatePinSortOrder(feeds.map(f => f.uri)) - await this.rootStore.preferences.setSavedFeeds( - this.rootStore.preferences.savedFeeds, - feeds.filter(feed => feed.isPinned).map(feed => feed.uri), - ) - } - - async movePinnedFeed(item: FeedSourceModel, direction: 'up' | 'down') { - const pinned = this.rootStore.preferences.pinnedFeeds.slice() - const index = pinned.indexOf(item.uri) - if (index === -1) { - return - } - if (direction === 'up' && index !== 0) { - ;[pinned[index], pinned[index - 1]] = [pinned[index - 1], pinned[index]] - } else if (direction === 'down' && index < pinned.length - 1) { - ;[pinned[index], pinned[index + 1]] = [pinned[index + 1], pinned[index]] - } - this._updatePinSortOrder(pinned.concat(this.unpinned.map(f => f.uri))) - await this.rootStore.preferences.setSavedFeeds( - this.rootStore.preferences.savedFeeds, - pinned, - ) - track('CustomFeed:Reorder', { - name: item.displayName, - uri: item.uri, - index: pinned.indexOf(item.uri), - }) - } - - // state transitions - // = - - _xLoading(isRefreshing = false) { - this.isLoading = true - this.isRefreshing = isRefreshing - this.error = '' - } - - _xIdle(err?: any) { - this.isLoading = false - this.isRefreshing = false - this.hasLoaded = true - this.error = cleanError(err) - if (err) { - logger.error('Failed to fetch user feeds', {err}) - } - } - - // helpers - // = - - _updatePinSortOrder(order?: string[]) { - order ??= this.rootStore.preferences.pinnedFeeds.concat( - this.rootStore.preferences.savedFeeds, - ) - this.all.sort((a, b) => { - return order!.indexOf(a.uri) - order!.indexOf(b.uri) - }) - } -} - -function dedup(strings: string[]): string[] { - return Array.from(new Set(strings)) -} diff --git a/src/state/models/ui/search.ts b/src/state/models/ui/search.ts deleted file mode 100644 index 2b2036751..000000000 --- a/src/state/models/ui/search.ts +++ /dev/null @@ -1,69 +0,0 @@ -import {makeAutoObservable, runInAction} from 'mobx' -import {searchProfiles, searchPosts} from 'lib/api/search' -import {PostThreadModel} from '../content/post-thread' -import {AppBskyActorDefs, AppBskyFeedDefs} from '@atproto/api' -import {RootStoreModel} from '../root-store' - -export class SearchUIModel { - isPostsLoading = false - isProfilesLoading = false - query: string = '' - posts: PostThreadModel[] = [] - profiles: AppBskyActorDefs.ProfileView[] = [] - - constructor(public rootStore: RootStoreModel) { - makeAutoObservable(this) - } - - async fetch(q: string) { - this.posts = [] - this.profiles = [] - this.query = q - if (!q.trim()) { - return - } - - this.isPostsLoading = true - this.isProfilesLoading = true - - const [postsSearch, profilesSearch] = await Promise.all([ - searchPosts(q).catch(_e => []), - searchProfiles(q).catch(_e => []), - ]) - - let posts: AppBskyFeedDefs.PostView[] = [] - if (postsSearch?.length) { - do { - const res = await this.rootStore.agent.app.bsky.feed.getPosts({ - uris: postsSearch - .splice(0, 25) - .map(p => `at://${p.user.did}/${p.tid}`), - }) - posts = posts.concat(res.data.posts) - } while (postsSearch.length) - } - runInAction(() => { - this.posts = posts.map(post => - PostThreadModel.fromPostView(this.rootStore, post), - ) - this.isPostsLoading = false - }) - - let profiles: AppBskyActorDefs.ProfileView[] = [] - if (profilesSearch?.length) { - do { - const res = await this.rootStore.agent.getProfiles({ - actors: profilesSearch.splice(0, 25).map(p => p.did), - }) - profiles = profiles.concat(res.data.profiles) - } while (profilesSearch.length) - } - - this.rootStore.me.follows.hydrateMany(profiles) - - runInAction(() => { - this.profiles = profiles - this.isProfilesLoading = false - }) - } -} diff --git a/src/state/models/ui/shell.ts b/src/state/models/ui/shell.ts deleted file mode 100644 index 343fff86d..000000000 --- a/src/state/models/ui/shell.ts +++ /dev/null @@ -1,376 +0,0 @@ -import {AppBskyEmbedRecord, AppBskyActorDefs, ModerationUI} from '@atproto/api' -import {RootStoreModel} from '../root-store' -import {makeAutoObservable, runInAction} from 'mobx' -import {ProfileModel} from '../content/profile' -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 { - shouldRequestEmailConfirmation, - setEmailConfirmationRequested, -} from '#/state/shell/reminders' - -export type ColorMode = 'system' | 'light' | 'dark' - -export function isColorMode(v: unknown): v is ColorMode { - return v === 'system' || v === 'light' || v === 'dark' -} - -export interface ConfirmModal { - name: 'confirm' - title: string - message: string | (() => JSX.Element) - onPressConfirm: () => void | Promise<void> - onPressCancel?: () => void | Promise<void> - confirmBtnText?: string - confirmBtnStyle?: StyleProp<ViewStyle> - cancelBtnText?: string -} - -export interface EditProfileModal { - name: 'edit-profile' - profileView: ProfileModel - onUpdate?: () => void -} - -export interface ProfilePreviewModal { - name: 'profile-preview' - did: string -} - -export interface ServerInputModal { - name: 'server-input' - initialService: string - onSelect: (url: string) => void -} - -export interface ModerationDetailsModal { - name: 'moderation-details' - context: 'account' | 'content' - moderation: ModerationUI -} - -export type ReportModal = { - name: 'report' -} & ( - | { - uri: string - cid: string - } - | {did: string} -) - -export interface CreateOrEditListModal { - name: 'create-or-edit-list' - purpose?: string - list?: ListModel - onSave?: (uri: string) => void -} - -export interface UserAddRemoveListsModal { - name: 'user-add-remove-lists' - subject: string - displayName: string - onAdd?: (listUri: string) => void - onRemove?: (listUri: string) => void -} - -export interface ListAddUserModal { - name: 'list-add-user' - list: ListModel - onAdd?: (profile: AppBskyActorDefs.ProfileViewBasic) => void -} - -export interface EditImageModal { - name: 'edit-image' - image: ImageModel - gallery: GalleryModel -} - -export interface CropImageModal { - name: 'crop-image' - uri: string - onSelect: (img?: RNImage) => void -} - -export interface AltTextImageModal { - name: 'alt-text-image' - image: ImageModel -} - -export interface DeleteAccountModal { - name: 'delete-account' -} - -export interface RepostModal { - name: 'repost' - onRepost: () => void - onQuote: () => void - isReposted: boolean -} - -export interface SelfLabelModal { - name: 'self-label' - labels: string[] - hasMedia: boolean - onChange: (labels: string[]) => void -} - -export interface ChangeHandleModal { - name: 'change-handle' - onChanged: () => void -} - -export interface WaitlistModal { - name: 'waitlist' -} - -export interface InviteCodesModal { - name: 'invite-codes' -} - -export interface AddAppPasswordModal { - name: 'add-app-password' -} - -export interface ContentFilteringSettingsModal { - name: 'content-filtering-settings' -} - -export interface ContentLanguagesSettingsModal { - name: 'content-languages-settings' -} - -export interface PostLanguagesSettingsModal { - name: 'post-languages-settings' -} - -export interface BirthDateSettingsModal { - name: 'birth-date-settings' -} - -export interface VerifyEmailModal { - name: 'verify-email' - showReminder?: boolean -} - -export interface ChangeEmailModal { - name: 'change-email' -} - -export interface SwitchAccountModal { - name: 'switch-account' -} - -export interface LinkWarningModal { - name: 'link-warning' - text: string - href: string -} - -export type Modal = - // Account - | AddAppPasswordModal - | ChangeHandleModal - | DeleteAccountModal - | EditProfileModal - | ProfilePreviewModal - | BirthDateSettingsModal - | VerifyEmailModal - | ChangeEmailModal - | SwitchAccountModal - - // Curation - | ContentFilteringSettingsModal - | ContentLanguagesSettingsModal - | PostLanguagesSettingsModal - - // Moderation - | ModerationDetailsModal - | ReportModal - - // Lists - | CreateOrEditListModal - | UserAddRemoveListsModal - | ListAddUserModal - - // Posts - | AltTextImageModal - | CropImageModal - | EditImageModal - | ServerInputModal - | RepostModal - | SelfLabelModal - - // Bluesky access - | WaitlistModal - | InviteCodesModal - - // Generic - | ConfirmModal - | LinkWarningModal - -interface LightboxModel {} - -export class ProfileImageLightbox implements LightboxModel { - name = 'profile-image' - constructor(public profileView: ProfileModel) { - makeAutoObservable(this) - } -} - -interface ImagesLightboxItem { - uri: string - alt?: string -} - -export class ImagesLightbox implements LightboxModel { - name = 'images' - constructor(public images: ImagesLightboxItem[], public index: number) { - makeAutoObservable(this) - } - setIndex(index: number) { - this.index = index - } -} - -export interface ComposerOptsPostRef { - uri: string - cid: string - text: string - author: { - handle: string - displayName?: string - avatar?: string - } -} -export interface ComposerOptsQuote { - uri: string - cid: string - text: string - indexedAt: string - author: { - did: string - handle: string - displayName?: string - avatar?: string - } - embeds?: AppBskyEmbedRecord.ViewRecord['embeds'] -} -export interface ComposerOpts { - replyTo?: ComposerOptsPostRef - onPost?: () => void - quote?: ComposerOptsQuote - mention?: string // handle of user to mention -} - -export class ShellUiModel { - isModalActive = false - activeModals: Modal[] = [] - isLightboxActive = false - activeLightbox: ProfileImageLightbox | ImagesLightbox | null = null - isComposerActive = false - composerOpts: ComposerOpts | undefined - tickEveryMinute = Date.now() - - constructor(public rootStore: RootStoreModel) { - makeAutoObservable(this, { - rootStore: false, - }) - - this.setupClock() - this.setupLoginModals() - } - - /** - * returns true if something was closed - * (used by the android hardware back btn) - */ - closeAnyActiveElement(): boolean { - if (this.isLightboxActive) { - this.closeLightbox() - return true - } - if (this.isModalActive) { - this.closeModal() - return true - } - if (this.isComposerActive) { - this.closeComposer() - return true - } - return false - } - - /** - * used to clear out any modals, eg for a navigation - */ - closeAllActiveElements() { - if (this.isLightboxActive) { - this.closeLightbox() - } - while (this.isModalActive) { - this.closeModal() - } - if (this.isComposerActive) { - this.closeComposer() - } - } - - openModal(modal: Modal) { - this.rootStore.emitNavigation() - this.isModalActive = true - this.activeModals.push(modal) - } - - closeModal() { - this.activeModals.pop() - this.isModalActive = this.activeModals.length > 0 - } - - openLightbox(lightbox: ProfileImageLightbox | ImagesLightbox) { - this.rootStore.emitNavigation() - this.isLightboxActive = true - this.activeLightbox = lightbox - } - - closeLightbox() { - this.isLightboxActive = false - this.activeLightbox = null - } - - openComposer(opts: ComposerOpts) { - this.rootStore.emitNavigation() - this.isComposerActive = true - this.composerOpts = opts - } - - closeComposer() { - this.isComposerActive = false - this.composerOpts = undefined - } - - setupClock() { - setInterval(() => { - runInAction(() => { - this.tickEveryMinute = Date.now() - }) - }, 60_000) - } - - setupLoginModals() { - this.rootStore.onSessionReady(() => { - if ( - shouldRequestEmailConfirmation( - this.rootStore.session, - this.rootStore.onboarding, - ) - ) { - this.openModal({name: 'verify-email', showReminder: true}) - setEmailConfirmationRequested() - } - }) - } -} |