((_: boolean) => {})\n\nexport function Provider({children}: React.PropsWithChildren<{}>) {\n const [state, setState] = React.useState(false)\n\n return (\n \n {children}\n \n )\n}\n\nexport function useIsDrawerOpen() {\n return React.useContext(stateContext)\n}\n\nexport function useSetDrawerOpen() {\n return React.useContext(setContext)\n}\n","import {useCallback} from 'react'\n\nimport {useDialogStateControlContext} from '#/state/dialogs'\nimport {useLightboxControls} from './lightbox'\nimport {useModalControls} from './modals'\nimport {useComposerControls} from './shell/composer'\nimport {useSetDrawerOpen} from './shell/drawer-open'\n\n/**\n * returns true if something was closed\n * (used by the android hardware back btn)\n */\nexport function useCloseAnyActiveElement() {\n const {closeLightbox} = useLightboxControls()\n const {closeModal} = useModalControls()\n const {closeComposer} = useComposerControls()\n const {closeAllDialogs} = useDialogStateControlContext()\n const setDrawerOpen = useSetDrawerOpen()\n return useCallback(() => {\n if (closeLightbox()) {\n return true\n }\n if (closeModal()) {\n return true\n }\n if (closeAllDialogs()) {\n return true\n }\n if (closeComposer()) {\n return true\n }\n setDrawerOpen(false)\n return false\n }, [closeLightbox, closeModal, closeComposer, setDrawerOpen, closeAllDialogs])\n}\n\n/**\n * used to clear out any modals, eg for a navigation\n */\nexport function useCloseAllActiveElements() {\n const {closeLightbox} = useLightboxControls()\n const {closeAllModals} = useModalControls()\n const {closeComposer} = useComposerControls()\n const {closeAllDialogs: closeAlfDialogs} = useDialogStateControlContext()\n const setDrawerOpen = useSetDrawerOpen()\n return useCallback(() => {\n closeLightbox()\n closeAllModals()\n closeComposer()\n closeAlfDialogs()\n setDrawerOpen(false)\n }, [\n closeLightbox,\n closeAllModals,\n closeComposer,\n closeAlfDialogs,\n setDrawerOpen,\n ])\n}\n","import {t} from '@lingui/macro'\n\nexport function cleanError(str: any): string {\n if (!str) {\n return ''\n }\n if (typeof str !== 'string') {\n str = str.toString()\n }\n if (isNetworkError(str)) {\n return t`Unable to connect. Please check your internet connection and try again.`\n }\n if (\n str.includes('Upstream Failure') ||\n str.includes('NotEnoughResources') ||\n str.includes('pipethrough network error')\n ) {\n return t`The server appears to be experiencing issues. Please try again in a few moments.`\n }\n if (str.includes('Bad token scope')) {\n return t`This feature is not available while using an App Password. Please sign in with your main password.`\n }\n if (str.startsWith('Error: ')) {\n return str.slice('Error: '.length)\n }\n return str\n}\n\nconst NETWORK_ERRORS = [\n 'Abort',\n 'Network request failed',\n 'Failed to fetch',\n 'Load failed',\n]\n\nexport function isNetworkError(e: unknown) {\n const str = String(e)\n for (const err of NETWORK_ERRORS) {\n if (str.includes(err)) {\n return true\n }\n }\n return false\n}\n","import {isNetworkError} from '#/lib/strings/errors'\n\nexport async function retry(\n retries: number,\n cond: (err: any) => boolean,\n fn: () => Promise
,\n): Promise
{\n let lastErr\n while (retries > 0) {\n try {\n return await fn()\n } catch (e: any) {\n lastErr = e\n if (cond(e)) {\n retries--\n continue\n }\n throw e\n }\n }\n throw lastErr\n}\n\nexport async function networkRetry
(\n retries: number,\n fn: () => Promise
,\n): Promise
{\n return retry(retries, isNetworkError, fn)\n}\n","import React from 'react'\n\nimport * as persisted from '#/state/persisted'\n\nexport const OnboardingScreenSteps = {\n Welcome: 'Welcome',\n RecommendedFeeds: 'RecommendedFeeds',\n RecommendedFollows: 'RecommendedFollows',\n Home: 'Home',\n} as const\n\ntype OnboardingStep =\n (typeof OnboardingScreenSteps)[keyof typeof OnboardingScreenSteps]\nconst OnboardingStepsArray = Object.values(OnboardingScreenSteps)\n\ntype Action =\n | {type: 'set'; step: OnboardingStep}\n | {type: 'next'; currentStep?: OnboardingStep}\n | {type: 'start'}\n | {type: 'finish'}\n | {type: 'skip'}\n\nexport type StateContext = persisted.Schema['onboarding'] & {\n isComplete: boolean\n isActive: boolean\n}\nexport type DispatchContext = (action: Action) => void\n\nconst stateContext = React.createContext(\n compute(persisted.defaults.onboarding),\n)\nconst dispatchContext = React.createContext((_: Action) => {})\n\nfunction reducer(state: StateContext, action: Action): StateContext {\n switch (action.type) {\n case 'set': {\n if (OnboardingStepsArray.includes(action.step)) {\n persisted.write('onboarding', {step: action.step})\n return compute({...state, step: action.step})\n }\n return state\n }\n case 'next': {\n const currentStep = action.currentStep || state.step\n let nextStep = 'Home'\n if (currentStep === 'Welcome') {\n nextStep = 'RecommendedFeeds'\n } else if (currentStep === 'RecommendedFeeds') {\n nextStep = 'RecommendedFollows'\n } else if (currentStep === 'RecommendedFollows') {\n nextStep = 'Home'\n }\n persisted.write('onboarding', {step: nextStep})\n return compute({...state, step: nextStep})\n }\n case 'start': {\n persisted.write('onboarding', {step: 'Welcome'})\n return compute({...state, step: 'Welcome'})\n }\n case 'finish': {\n persisted.write('onboarding', {step: 'Home'})\n return compute({...state, step: 'Home'})\n }\n case 'skip': {\n persisted.write('onboarding', {step: 'Home'})\n return compute({...state, step: 'Home'})\n }\n default: {\n throw new Error('Invalid action')\n }\n }\n}\n\nexport function Provider({children}: React.PropsWithChildren<{}>) {\n const [state, dispatch] = React.useReducer(\n reducer,\n compute(persisted.get('onboarding')),\n )\n\n React.useEffect(() => {\n return persisted.onUpdate('onboarding', nextOnboarding => {\n const next = nextOnboarding.step\n // TODO we've introduced a footgun\n if (state.step !== next) {\n dispatch({\n type: 'set',\n step: nextOnboarding.step as OnboardingStep,\n })\n }\n })\n }, [state, dispatch])\n\n return (\n \n \n {children}\n \n \n )\n}\n\nexport function useOnboardingState() {\n return React.useContext(stateContext)\n}\n\nexport function useOnboardingDispatch() {\n return React.useContext(dispatchContext)\n}\n\nexport function isOnboardingActive() {\n return compute(persisted.get('onboarding')).isActive\n}\n\nfunction compute(state: persisted.Schema['onboarding']): StateContext {\n return {\n ...state,\n isActive: state.step !== 'Home',\n isComplete: state.step === 'Home',\n }\n}\n","import {simpleAreDatesEqual} from '#/lib/strings/time'\nimport {logger} from '#/logger'\nimport * as persisted from '#/state/persisted'\nimport {SessionAccount} from '../session'\nimport {isOnboardingActive} from './onboarding'\n\nexport function shouldRequestEmailConfirmation(account: SessionAccount) {\n // ignore logged out\n if (!account) return false\n // ignore confirmed accounts, this is the success state of this reminder\n if (account.emailConfirmed) return false\n // wait for onboarding to complete\n if (isOnboardingActive()) return false\n\n const snoozedAt = persisted.get('reminders').lastEmailConfirm\n const today = new Date()\n\n logger.debug('Checking email confirmation reminder', {\n today,\n snoozedAt,\n })\n\n // never been snoozed, new account\n if (!snoozedAt) {\n return true\n }\n\n // already snoozed today\n if (simpleAreDatesEqual(new Date(Date.parse(snoozedAt)), new Date())) {\n return false\n }\n\n return true\n}\n\nexport function snoozeEmailConfirmationPrompt() {\n const lastEmailConfirm = new Date().toISOString()\n logger.debug('Snoozing email confirmation reminder', {\n snoozedAt: lastEmailConfirm,\n })\n persisted.write('reminders', {\n ...persisted.get('reminders'),\n lastEmailConfirm,\n })\n}\n","import AsyncStorage from '@react-native-async-storage/async-storage'\n\nconst PREFIX = 'agent-labelers'\n\nexport async function saveLabelers(did: string, value: string[]) {\n await AsyncStorage.setItem(`${PREFIX}:${did}`, JSON.stringify(value))\n}\n\nexport async function readLabelers(did: string): Promise {\n const rawData = await AsyncStorage.getItem(`${PREFIX}:${did}`)\n return rawData ? JSON.parse(rawData) : undefined\n}\n","import {BSKY_LABELER_DID, BskyAgent} from '@atproto/api'\n\nimport {IS_TEST_USER} from '#/lib/constants'\nimport {configureAdditionalModerationAuthorities} from './additional-moderation-authorities'\nimport {readLabelers} from './agent-config'\nimport {SessionAccount} from './types'\n\nexport function configureModerationForGuest() {\n // This global mutation is *only* OK because this code is only relevant for testing.\n // Don't add any other global behavior here!\n switchToBskyAppLabeler()\n configureAdditionalModerationAuthorities()\n}\n\nexport async function configureModerationForAccount(\n agent: BskyAgent,\n account: SessionAccount,\n) {\n // This global mutation is *only* OK because this code is only relevant for testing.\n // Don't add any other global behavior here!\n switchToBskyAppLabeler()\n if (IS_TEST_USER(account.handle)) {\n await trySwitchToTestAppLabeler(agent)\n }\n\n // The code below is actually relevant to production (and isn't global).\n const labelerDids = await readLabelers(account.did).catch(_ => {})\n if (labelerDids) {\n agent.configureLabelersHeader(\n labelerDids.filter(did => did !== BSKY_LABELER_DID),\n )\n } else {\n // If there are no headers in the storage, we'll not send them on the initial requests.\n // If we wanted to fix this, we could block on the preferences query here.\n }\n\n configureAdditionalModerationAuthorities()\n}\n\nfunction switchToBskyAppLabeler() {\n BskyAgent.configure({appLabelers: [BSKY_LABELER_DID]})\n}\n\nasync function trySwitchToTestAppLabeler(agent: BskyAgent) {\n const did = (\n await agent\n .resolveHandle({handle: 'mod-authority.test'})\n .catch(_ => undefined)\n )?.data.did\n if (did) {\n console.warn('USING TEST ENV MODERATION')\n BskyAgent.configure({appLabelers: [did]})\n }\n}\n","import {jwtDecode} from 'jwt-decode'\n\nimport {hasProp} from '#/lib/type-guards'\nimport {logger} from '#/logger'\nimport * as persisted from '#/state/persisted'\nimport {SessionAccount} from './types'\n\nexport function readLastActiveAccount() {\n const {currentAccount, accounts} = persisted.get('session')\n return accounts.find(a => a.did === currentAccount?.did)\n}\n\nexport function isSignupQueued(accessJwt: string | undefined) {\n if (accessJwt) {\n const sessData = jwtDecode(accessJwt)\n return (\n hasProp(sessData, 'scope') &&\n sessData.scope === 'com.atproto.signupQueued'\n )\n }\n return false\n}\n\nexport function isSessionExpired(account: SessionAccount) {\n try {\n if (account.accessJwt) {\n const decoded = jwtDecode(account.accessJwt)\n if (decoded.exp) {\n const didExpire = Date.now() >= decoded.exp * 1000\n return didExpire\n }\n }\n } catch (e) {\n logger.error(`session: could not decode jwt`)\n }\n return true\n}\n","import {AtpSessionData, AtpSessionEvent, BskyAgent} from '@atproto/api'\nimport {TID} from '@atproto/common-web'\n\nimport {networkRetry} from '#/lib/async/retry'\nimport {\n DISCOVER_SAVED_FEED,\n IS_PROD_SERVICE,\n PUBLIC_BSKY_SERVICE,\n TIMELINE_SAVED_FEED,\n} from '#/lib/constants'\nimport {getAge} from '#/lib/strings/time'\nimport {logger} from '#/logger'\nimport {snoozeEmailConfirmationPrompt} from '#/state/shell/reminders'\nimport {emitNetworkConfirmed, emitNetworkLost} from '../events'\nimport {\n configureModerationForAccount,\n configureModerationForGuest,\n} from './moderation'\nimport {SessionAccount} from './types'\nimport {isSessionExpired, isSignupQueued} from './util'\n\nexport function createPublicAgent() {\n configureModerationForGuest() // Side effect but only relevant for tests\n return new BskyAppAgent({service: PUBLIC_BSKY_SERVICE})\n}\n\nexport async function createAgentAndResume(\n storedAccount: SessionAccount,\n onSessionChange: (\n agent: BskyAgent,\n did: string,\n event: AtpSessionEvent,\n ) => void,\n) {\n const agent = new BskyAppAgent({service: storedAccount.service})\n if (storedAccount.pdsUrl) {\n agent.sessionManager.pdsUrl = new URL(storedAccount.pdsUrl)\n }\n const moderation = configureModerationForAccount(agent, storedAccount)\n const prevSession: AtpSessionData = sessionAccountToSession(storedAccount)\n if (isSessionExpired(storedAccount)) {\n await networkRetry(1, () => agent.resumeSession(prevSession))\n } else {\n agent.sessionManager.session = prevSession\n if (!storedAccount.signupQueued) {\n networkRetry(3, () => agent.resumeSession(prevSession)).catch(\n (e: any) => {\n logger.error(`networkRetry failed to resume session`, {\n status: e?.status || 'unknown',\n // this field name is ignored by Sentry scrubbers\n safeMessage: e?.message || 'unknown',\n })\n\n throw e\n },\n )\n }\n }\n\n return agent.prepare(moderation, onSessionChange)\n}\n\nexport async function createAgentAndLogin(\n {\n service,\n identifier,\n password,\n authFactorToken,\n }: {\n service: string\n identifier: string\n password: string\n authFactorToken?: string\n },\n onSessionChange: (\n agent: BskyAgent,\n did: string,\n event: AtpSessionEvent,\n ) => void,\n) {\n const agent = new BskyAppAgent({service})\n await agent.login({identifier, password, authFactorToken})\n\n const account = agentToSessionAccountOrThrow(agent)\n const moderation = configureModerationForAccount(agent, account)\n return agent.prepare(moderation, onSessionChange)\n}\n\nexport async function createAgentAndCreateAccount(\n {\n service,\n email,\n password,\n handle,\n birthDate,\n inviteCode,\n verificationPhone,\n verificationCode,\n }: {\n service: string\n email: string\n password: string\n handle: string\n birthDate: Date\n inviteCode?: string\n verificationPhone?: string\n verificationCode?: string\n },\n onSessionChange: (\n agent: BskyAgent,\n did: string,\n event: AtpSessionEvent,\n ) => void,\n) {\n const agent = new BskyAppAgent({service})\n await agent.createAccount({\n email,\n password,\n handle,\n inviteCode,\n verificationPhone,\n verificationCode,\n })\n const account = agentToSessionAccountOrThrow(agent)\n const moderation = configureModerationForAccount(agent, account)\n\n // Not awaited so that we can still get into onboarding.\n // This is OK because we won't let you toggle adult stuff until you set the date.\n if (IS_PROD_SERVICE(service)) {\n try {\n networkRetry(1, async () => {\n await agent.setPersonalDetails({birthDate: birthDate.toISOString()})\n await agent.overwriteSavedFeeds([\n {\n ...DISCOVER_SAVED_FEED,\n id: TID.nextStr(),\n },\n {\n ...TIMELINE_SAVED_FEED,\n id: TID.nextStr(),\n },\n ])\n\n if (getAge(birthDate) < 18) {\n await agent.api.com.atproto.repo.putRecord({\n repo: account.did,\n collection: 'chat.bsky.actor.declaration',\n rkey: 'self',\n record: {\n $type: 'chat.bsky.actor.declaration',\n allowIncoming: 'none',\n },\n })\n }\n })\n } catch (e: any) {\n logger.error(e, {\n context: `session: createAgentAndCreateAccount failed to save personal details and feeds`,\n })\n }\n } else {\n agent.setPersonalDetails({birthDate: birthDate.toISOString()})\n }\n\n try {\n // snooze first prompt after signup, defer to next prompt\n snoozeEmailConfirmationPrompt()\n } catch (e: any) {\n logger.error(e, {context: `session: failed snoozeEmailConfirmationPrompt`})\n }\n\n return agent.prepare(moderation, onSessionChange)\n}\n\nexport function agentToSessionAccountOrThrow(agent: BskyAgent): SessionAccount {\n const account = agentToSessionAccount(agent)\n if (!account) {\n throw Error('Expected an active session')\n }\n return account\n}\n\nexport function agentToSessionAccount(\n agent: BskyAgent,\n): SessionAccount | undefined {\n if (!agent.session) {\n return undefined\n }\n return {\n service: agent.service.toString(),\n did: agent.session.did,\n handle: agent.session.handle,\n email: agent.session.email,\n emailConfirmed: agent.session.emailConfirmed || false,\n emailAuthFactor: agent.session.emailAuthFactor || false,\n refreshJwt: agent.session.refreshJwt,\n accessJwt: agent.session.accessJwt,\n signupQueued: isSignupQueued(agent.session.accessJwt),\n active: agent.session.active,\n status: agent.session.status as SessionAccount['status'],\n pdsUrl: agent.pdsUrl?.toString(),\n }\n}\n\nexport function sessionAccountToSession(\n account: SessionAccount,\n): AtpSessionData {\n return {\n // Sorted in the same property order as when returned by BskyAgent (alphabetical).\n accessJwt: account.accessJwt ?? '',\n did: account.did,\n email: account.email,\n emailAuthFactor: account.emailAuthFactor,\n emailConfirmed: account.emailConfirmed,\n handle: account.handle,\n refreshJwt: account.refreshJwt ?? '',\n /**\n * @see https://github.com/bluesky-social/atproto/blob/c5d36d5ba2a2c2a5c4f366a5621c06a5608e361e/packages/api/src/agent.ts#L188\n */\n active: account.active ?? true,\n status: account.status,\n }\n}\n\n// Not exported. Use factories above to create it.\nlet realFetch = globalThis.fetch\nclass BskyAppAgent extends BskyAgent {\n persistSessionHandler: ((event: AtpSessionEvent) => void) | undefined =\n undefined\n\n constructor({service}: {service: string}) {\n super({\n service,\n async fetch(...args) {\n let success = false\n try {\n const result = await realFetch(...args)\n success = true\n return result\n } catch (e) {\n success = false\n throw e\n } finally {\n if (success) {\n emitNetworkConfirmed()\n } else {\n emitNetworkLost()\n }\n }\n },\n persistSession: (event: AtpSessionEvent) => {\n if (this.persistSessionHandler) {\n this.persistSessionHandler(event)\n }\n },\n })\n }\n\n async prepare(\n // Not awaited in the calling code so we can delay blocking on them.\n moderation: Promise,\n onSessionChange: (\n agent: BskyAgent,\n did: string,\n event: AtpSessionEvent,\n ) => void,\n ) {\n // There's nothing else left to do, so block on them here.\n await Promise.all([moderation])\n\n // Now the agent is ready.\n const account = agentToSessionAccountOrThrow(this)\n let lastSession = this.sessionManager.session\n this.persistSessionHandler = event => {\n if (this.sessionManager.session) {\n lastSession = this.sessionManager.session\n } else if (event === 'network-error') {\n // Put it back, we'll try again later.\n this.sessionManager.session = lastSession\n }\n\n onSessionChange(this, account.did, event)\n }\n return {account, agent: this}\n }\n\n dispose() {\n this.sessionManager.session = undefined\n this.persistSessionHandler = undefined\n }\n}\n\nexport type {BskyAppAgent}\n","import {AtpSessionEvent} from '@atproto/api'\n\nimport {createPublicAgent} from './agent'\nimport {SessionAccount} from './types'\n\n// A hack so that the reducer can't read anything from the agent.\n// From the reducer's point of view, it should be a completely opaque object.\ntype OpaqueBskyAgent = {\n readonly service: URL\n readonly api: unknown\n readonly app: unknown\n readonly com: unknown\n}\n\ntype AgentState = {\n readonly agent: OpaqueBskyAgent\n readonly did: string | undefined\n}\n\nexport type State = {\n readonly accounts: SessionAccount[]\n readonly currentAgentState: AgentState\n needsPersist: boolean // Mutated in an effect.\n}\n\nexport type Action =\n | {\n type: 'received-agent-event'\n agent: OpaqueBskyAgent\n accountDid: string\n refreshedAccount: SessionAccount | undefined\n sessionEvent: AtpSessionEvent\n }\n | {\n type: 'switched-to-account'\n newAgent: OpaqueBskyAgent\n newAccount: SessionAccount\n }\n | {\n type: 'removed-account'\n accountDid: string\n }\n | {\n type: 'logged-out-current-account'\n }\n | {\n type: 'logged-out-every-account'\n }\n | {\n type: 'synced-accounts'\n syncedAccounts: SessionAccount[]\n syncedCurrentDid: string | undefined\n }\n\nfunction createPublicAgentState(): AgentState {\n return {\n agent: createPublicAgent(),\n did: undefined,\n }\n}\n\nexport function getInitialState(persistedAccounts: SessionAccount[]): State {\n return {\n accounts: persistedAccounts,\n currentAgentState: createPublicAgentState(),\n needsPersist: false,\n }\n}\n\nlet reducer = (state: State, action: Action): State => {\n switch (action.type) {\n case 'received-agent-event': {\n const {agent, accountDid, refreshedAccount, sessionEvent} = action\n if (\n refreshedAccount === undefined &&\n agent !== state.currentAgentState.agent\n ) {\n // If the session got cleared out (e.g. due to expiry or network error) but\n // this account isn't the active one, don't clear it out at this time.\n // This way, if the problem is transient, it'll work on next resume.\n return state\n }\n if (sessionEvent === 'network-error') {\n // Assume it's transient.\n return state\n }\n const existingAccount = state.accounts.find(a => a.did === accountDid)\n if (\n !existingAccount ||\n JSON.stringify(existingAccount) === JSON.stringify(refreshedAccount)\n ) {\n // Fast path without a state update.\n return state\n }\n return {\n accounts: state.accounts.map(a => {\n if (a.did === accountDid) {\n if (refreshedAccount) {\n return refreshedAccount\n } else {\n return {\n ...a,\n // If we didn't receive a refreshed account, clear out the tokens.\n accessJwt: undefined,\n refreshJwt: undefined,\n }\n }\n } else {\n return a\n }\n }),\n currentAgentState: refreshedAccount\n ? state.currentAgentState\n : createPublicAgentState(), // Log out if expired.\n needsPersist: true,\n }\n }\n case 'switched-to-account': {\n const {newAccount, newAgent} = action\n return {\n accounts: [\n newAccount,\n ...state.accounts.filter(a => a.did !== newAccount.did),\n ],\n currentAgentState: {\n did: newAccount.did,\n agent: newAgent,\n },\n needsPersist: true,\n }\n }\n case 'removed-account': {\n const {accountDid} = action\n return {\n accounts: state.accounts.filter(a => a.did !== accountDid),\n currentAgentState:\n state.currentAgentState.did === accountDid\n ? createPublicAgentState() // Log out if removing the current one.\n : state.currentAgentState,\n needsPersist: true,\n }\n }\n case 'logged-out-current-account': {\n const {currentAgentState} = state\n return {\n accounts: state.accounts.map(a =>\n a.did === currentAgentState.did\n ? {\n ...a,\n refreshJwt: undefined,\n accessJwt: undefined,\n }\n : a,\n ),\n currentAgentState: createPublicAgentState(),\n needsPersist: true,\n }\n }\n case 'logged-out-every-account': {\n return {\n accounts: state.accounts.map(a => ({\n ...a,\n // Clear tokens for *every* account (this is a hard logout).\n refreshJwt: undefined,\n accessJwt: undefined,\n })),\n currentAgentState: createPublicAgentState(),\n needsPersist: true,\n }\n }\n case 'synced-accounts': {\n const {syncedAccounts, syncedCurrentDid} = action\n return {\n accounts: syncedAccounts,\n currentAgentState:\n syncedCurrentDid === state.currentAgentState.did\n ? state.currentAgentState\n : createPublicAgentState(), // Log out if different user.\n needsPersist: false, // Synced from another tab. Don't persist to avoid cycles.\n }\n }\n }\n}\nexport {reducer}\n","import React from 'react'\nimport {AtpSessionEvent, BskyAgent} from '@atproto/api'\n\nimport {isWeb} from '#/platform/detection'\nimport * as persisted from '#/state/persisted'\nimport {useCloseAllActiveElements} from '#/state/util'\nimport {useGlobalDialogsControlContext} from '#/components/dialogs/Context'\nimport {IS_DEV} from '#/env'\nimport {emitSessionDropped} from '../events'\nimport {\n agentToSessionAccount,\n BskyAppAgent,\n createAgentAndCreateAccount,\n createAgentAndLogin,\n createAgentAndResume,\n sessionAccountToSession,\n} from './agent'\nimport {getInitialState, reducer} from './reducer'\n\nexport {isSignupQueued} from './util'\nexport type {SessionAccount} from '#/state/session/types'\nimport {SessionApiContext, SessionStateContext} from '#/state/session/types'\n\nconst StateContext = React.createContext({\n accounts: [],\n currentAccount: undefined,\n hasSession: false,\n})\n\nconst AgentContext = React.createContext(null)\n\nconst ApiContext = React.createContext({\n createAccount: async () => {},\n login: async () => {},\n logoutCurrentAccount: async () => {},\n logoutEveryAccount: async () => {},\n resumeSession: async () => {},\n removeAccount: () => {},\n})\n\nexport function Provider({children}: React.PropsWithChildren<{}>) {\n const cancelPendingTask = useOneTaskAtATime()\n const [state, dispatch] = React.useReducer(reducer, null, () => {\n const initialState = getInitialState(persisted.get('session').accounts)\n return initialState\n })\n\n const onAgentSessionChange = React.useCallback(\n (agent: BskyAgent, accountDid: string, sessionEvent: AtpSessionEvent) => {\n const refreshedAccount = agentToSessionAccount(agent) // Mutable, so snapshot it right away.\n if (sessionEvent === 'expired' || sessionEvent === 'create-failed') {\n emitSessionDropped()\n }\n dispatch({\n type: 'received-agent-event',\n agent,\n refreshedAccount,\n accountDid,\n sessionEvent,\n })\n },\n [],\n )\n\n const createAccount = React.useCallback(\n async params => {\n const signal = cancelPendingTask()\n const {agent, account} = await createAgentAndCreateAccount(\n params,\n onAgentSessionChange,\n )\n\n if (signal.aborted) {\n return\n }\n dispatch({\n type: 'switched-to-account',\n newAgent: agent,\n newAccount: account,\n })\n },\n [onAgentSessionChange, cancelPendingTask],\n )\n\n const login = React.useCallback(\n async (params, logContext) => {\n const signal = cancelPendingTask()\n const {agent, account} = await createAgentAndLogin(\n params,\n onAgentSessionChange,\n )\n\n if (signal.aborted) {\n return\n }\n dispatch({\n type: 'switched-to-account',\n newAgent: agent,\n newAccount: account,\n })\n },\n [onAgentSessionChange, cancelPendingTask],\n )\n\n const logoutCurrentAccount = React.useCallback<\n SessionApiContext['logoutEveryAccount']\n >(\n logContext => {\n cancelPendingTask()\n dispatch({\n type: 'logged-out-current-account',\n })\n },\n [cancelPendingTask],\n )\n\n const logoutEveryAccount = React.useCallback<\n SessionApiContext['logoutEveryAccount']\n >(\n logContext => {\n cancelPendingTask()\n dispatch({\n type: 'logged-out-every-account',\n })\n },\n [cancelPendingTask],\n )\n\n const resumeSession = React.useCallback(\n async storedAccount => {\n const signal = cancelPendingTask()\n const {agent, account} = await createAgentAndResume(\n storedAccount,\n onAgentSessionChange,\n )\n\n if (signal.aborted) {\n return\n }\n dispatch({\n type: 'switched-to-account',\n newAgent: agent,\n newAccount: account,\n })\n },\n [onAgentSessionChange, cancelPendingTask],\n )\n\n const removeAccount = React.useCallback(\n account => {\n cancelPendingTask()\n dispatch({\n type: 'removed-account',\n accountDid: account.did,\n })\n },\n [cancelPendingTask],\n )\n\n React.useEffect(() => {\n if (state.needsPersist) {\n state.needsPersist = false\n const persistedData = {\n accounts: state.accounts,\n currentAccount: state.accounts.find(\n a => a.did === state.currentAgentState.did,\n ),\n }\n persisted.write('session', persistedData)\n }\n }, [state])\n\n React.useEffect(() => {\n return persisted.onUpdate('session', nextSession => {\n const synced = nextSession\n dispatch({\n type: 'synced-accounts',\n syncedAccounts: synced.accounts,\n syncedCurrentDid: synced.currentAccount?.did,\n })\n const syncedAccount = synced.accounts.find(\n a => a.did === synced.currentAccount?.did,\n )\n if (syncedAccount && syncedAccount.refreshJwt) {\n if (syncedAccount.did !== state.currentAgentState.did) {\n resumeSession(syncedAccount)\n } else {\n const agent = state.currentAgentState.agent as BskyAgent\n const prevSession = agent.session\n agent.sessionManager.session = sessionAccountToSession(syncedAccount)\n }\n }\n })\n }, [state, resumeSession])\n\n const stateContext = React.useMemo(\n () => ({\n accounts: state.accounts,\n currentAccount: state.accounts.find(\n a => a.did === state.currentAgentState.did,\n ),\n hasSession: !!state.currentAgentState.did,\n }),\n [state],\n )\n\n const api = React.useMemo(\n () => ({\n createAccount,\n login,\n logoutCurrentAccount,\n logoutEveryAccount,\n resumeSession,\n removeAccount,\n }),\n [\n createAccount,\n login,\n logoutCurrentAccount,\n logoutEveryAccount,\n resumeSession,\n removeAccount,\n ],\n )\n\n // @ts-ignore\n if (IS_DEV && isWeb) window.agent = state.currentAgentState.agent\n\n const agent = state.currentAgentState.agent as BskyAppAgent\n const currentAgentRef = React.useRef(agent)\n React.useEffect(() => {\n if (currentAgentRef.current !== agent) {\n // Read the previous value and immediately advance the pointer.\n const prevAgent = currentAgentRef.current\n currentAgentRef.current = agent\n // We never reuse agents so let's fully neutralize the previous one.\n // This ensures it won't try to consume any refresh tokens.\n prevAgent.dispose()\n }\n }, [agent])\n\n return (\n \n \n {children}\n \n \n )\n}\n\nfunction useOneTaskAtATime() {\n const abortController = React.useRef(null)\n const cancelPendingTask = React.useCallback(() => {\n if (abortController.current) {\n abortController.current.abort()\n }\n abortController.current = new AbortController()\n return abortController.current.signal\n }, [])\n return cancelPendingTask\n}\n\nexport function useSession() {\n return React.useContext(StateContext)\n}\n\nexport function useSessionApi() {\n return React.useContext(ApiContext)\n}\n\nexport function useRequireAuth() {\n const {hasSession} = useSession()\n const closeAll = useCloseAllActiveElements()\n const {signinDialogControl} = useGlobalDialogsControlContext()\n\n return React.useCallback(\n (fn: () => void) => {\n if (hasSession) {\n fn()\n } else {\n closeAll()\n signinDialogControl.open()\n }\n },\n [hasSession, signinDialogControl, closeAll],\n )\n}\n\nexport function useAgent(): BskyAgent {\n const agent = React.useContext(AgentContext)\n if (!agent) {\n throw Error('useAgent() must be below .')\n }\n return agent\n}\n","import {AppBskyLabelerDefs} from '@atproto/api'\nimport {useMutation, useQuery, useQueryClient} from '@tanstack/react-query'\nimport {z} from 'zod'\n\nimport {MAX_LABELERS} from '#/lib/constants'\nimport {labelersDetailedInfoQueryKeyRoot} from '#/lib/react-query'\nimport {STALE} from '#/state/queries'\nimport {\n preferencesQueryKey,\n usePreferencesQuery,\n} from '#/state/queries/preferences'\nimport {useAgent} from '#/state/session'\n\nconst labelerInfoQueryKeyRoot = 'labeler-info'\nexport const labelerInfoQueryKey = (did: string) => [\n labelerInfoQueryKeyRoot,\n did,\n]\n\nconst labelersInfoQueryKeyRoot = 'labelers-info'\nexport const labelersInfoQueryKey = (dids: string[]) => [\n labelersInfoQueryKeyRoot,\n dids.slice().sort(),\n]\n\nexport const labelersDetailedInfoQueryKey = (dids: string[]) => [\n labelersDetailedInfoQueryKeyRoot,\n dids,\n]\n\nexport function useLabelerInfoQuery({\n did,\n enabled,\n}: {\n did?: string\n enabled?: boolean\n}) {\n const agent = useAgent()\n return useQuery({\n enabled: !!did && enabled !== false,\n queryKey: labelerInfoQueryKey(did as string),\n queryFn: async () => {\n const res = await agent.app.bsky.labeler.getServices({\n dids: [did as string],\n detailed: true,\n })\n return res.data.views[0] as AppBskyLabelerDefs.LabelerViewDetailed\n },\n })\n}\n\nexport function useLabelersInfoQuery({dids}: {dids: string[]}) {\n const agent = useAgent()\n return useQuery({\n enabled: !!dids.length,\n queryKey: labelersInfoQueryKey(dids),\n queryFn: async () => {\n const res = await agent.app.bsky.labeler.getServices({dids})\n return res.data.views as AppBskyLabelerDefs.LabelerView[]\n },\n })\n}\n\nexport function useLabelersDetailedInfoQuery({dids}: {dids: string[]}) {\n const agent = useAgent()\n return useQuery({\n enabled: !!dids.length,\n queryKey: labelersDetailedInfoQueryKey(dids),\n gcTime: 1000 * 60 * 60 * 6, // 6 hours\n staleTime: STALE.MINUTES.ONE,\n queryFn: async () => {\n const res = await agent.app.bsky.labeler.getServices({\n dids,\n detailed: true,\n })\n return res.data.views as AppBskyLabelerDefs.LabelerViewDetailed[]\n },\n })\n}\n\nexport function useLabelerSubscriptionMutation() {\n const queryClient = useQueryClient()\n const agent = useAgent()\n const preferences = usePreferencesQuery()\n\n return useMutation({\n async mutationFn({did, subscribe}: {did: string; subscribe: boolean}) {\n // TODO\n z.object({\n did: z.string(),\n subscribe: z.boolean(),\n }).parse({did, subscribe})\n\n /**\n * If a user has invalid/takendown/deactivated labelers, we need to\n * remove them. We don't have a great way to do this atm on the server,\n * so we do it here.\n *\n * We also need to push validation into this method, since we need to\n * check {@link MAX_LABELERS} _after_ we've removed invalid or takendown\n * labelers.\n */\n const labelerDids = (\n preferences.data?.moderationPrefs?.labelers ?? []\n ).map(l => l.did)\n const invalidLabelers: string[] = []\n if (labelerDids.length) {\n const profiles = await agent.getProfiles({actors: labelerDids})\n if (profiles.data) {\n for (const did of labelerDids) {\n const exists = profiles.data.profiles.find(p => p.did === did)\n if (exists) {\n // profile came back but it's not a valid labeler\n if (exists.associated && !exists.associated.labeler) {\n invalidLabelers.push(did)\n }\n } else {\n // no response came back, might be deactivated or takendown\n invalidLabelers.push(did)\n }\n }\n }\n }\n if (invalidLabelers.length) {\n await Promise.all(invalidLabelers.map(did => agent.removeLabeler(did)))\n }\n\n if (subscribe) {\n const labelerCount = labelerDids.length - invalidLabelers.length\n if (labelerCount >= MAX_LABELERS) {\n throw new Error('MAX_LABELERS')\n }\n await agent.addLabeler(did)\n } else {\n await agent.removeLabeler(did)\n }\n },\n async onSuccess() {\n await queryClient.invalidateQueries({\n queryKey: preferencesQueryKey,\n })\n },\n })\n}\n","import React from 'react'\nimport {\n BskyAgent,\n DEFAULT_LABEL_SETTINGS,\n interpretLabelValueDefinitions,\n} from '@atproto/api'\n\nimport {isNonConfigurableModerationAuthority} from '#/state/session/additional-moderation-authorities'\nimport {useLabelersDetailedInfoQuery} from '../labeler'\nimport {usePreferencesQuery} from './index'\n\n/**\n * More strict than our default settings for logged in users.\n */\nexport const DEFAULT_LOGGED_OUT_LABEL_PREFERENCES: typeof DEFAULT_LABEL_SETTINGS =\n Object.fromEntries(\n Object.entries(DEFAULT_LABEL_SETTINGS).map(([key, _pref]) => [key, 'hide']),\n )\n\nexport function useMyLabelersQuery({\n excludeNonConfigurableLabelers = false,\n}: {\n excludeNonConfigurableLabelers?: boolean\n} = {}) {\n const prefs = usePreferencesQuery()\n let dids = Array.from(\n new Set(\n BskyAgent.appLabelers.concat(\n prefs.data?.moderationPrefs.labelers.map(l => l.did) || [],\n ),\n ),\n )\n if (excludeNonConfigurableLabelers) {\n dids = dids.filter(did => !isNonConfigurableModerationAuthority(did))\n }\n const labelers = useLabelersDetailedInfoQuery({dids})\n const isLoading = prefs.isLoading || labelers.isLoading\n const error = prefs.error || labelers.error\n return React.useMemo(() => {\n return {\n isLoading,\n error,\n data: labelers.data,\n }\n }, [labelers, isLoading, error])\n}\n\nexport function useLabelDefinitionsQuery() {\n const labelers = useMyLabelersQuery()\n return React.useMemo(() => {\n return {\n labelDefs: Object.fromEntries(\n (labelers.data || []).map(labeler => [\n labeler.creator.did,\n interpretLabelValueDefinitions(labeler),\n ]),\n ),\n labelers: labelers.data || [],\n }\n }, [labelers])\n}\n","import {DEFAULT_LOGGED_OUT_LABEL_PREFERENCES} from '#/state/queries/preferences/moderation'\nimport {\n ThreadViewPreferences,\n UsePreferencesQueryResponse,\n} from '#/state/queries/preferences/types'\n\nexport const DEFAULT_HOME_FEED_PREFS: UsePreferencesQueryResponse['feedViewPrefs'] =\n {\n hideReplies: false,\n hideRepliesByUnfollowed: true, // Legacy, ignored\n hideRepliesByLikeCount: 0, // Legacy, ignored\n hideReposts: false,\n hideQuotePosts: false,\n lab_mergeFeedEnabled: false, // experimental\n }\n\nexport const DEFAULT_THREAD_VIEW_PREFS: ThreadViewPreferences = {\n sort: 'hotness',\n prioritizeFollowedUsers: true,\n lab_treeViewEnabled: false,\n}\n\nexport const DEFAULT_LOGGED_OUT_PREFERENCES: UsePreferencesQueryResponse = {\n birthDate: new Date('2022-11-17'), // TODO(pwi)\n moderationPrefs: {\n adultContentEnabled: false,\n labels: DEFAULT_LOGGED_OUT_LABEL_PREFERENCES,\n labelers: [],\n mutedWords: [],\n hiddenPosts: [],\n },\n feedViewPrefs: DEFAULT_HOME_FEED_PREFS,\n threadViewPrefs: DEFAULT_THREAD_VIEW_PREFS,\n userAge: 13, // TODO(pwi)\n interests: {tags: []},\n savedFeeds: [],\n bskyAppState: {\n queuedNudges: [],\n activeProgressGuide: undefined,\n nuxs: [],\n },\n}\n","import {\n AppBskyActorDefs,\n BskyFeedViewPreference,\n LabelPreference,\n} from '@atproto/api'\nimport {useMutation, useQuery, useQueryClient} from '@tanstack/react-query'\n\nimport {PROD_DEFAULT_FEED} from '#/lib/constants'\nimport {replaceEqualDeep} from '#/lib/functions'\nimport {getAge} from '#/lib/strings/time'\nimport {STALE} from '#/state/queries'\nimport {\n DEFAULT_HOME_FEED_PREFS,\n DEFAULT_LOGGED_OUT_PREFERENCES,\n DEFAULT_THREAD_VIEW_PREFS,\n} from '#/state/queries/preferences/const'\nimport {\n ThreadViewPreferences,\n UsePreferencesQueryResponse,\n} from '#/state/queries/preferences/types'\nimport {useAgent} from '#/state/session'\nimport {saveLabelers} from '#/state/session/agent-config'\n\nexport * from '#/state/queries/preferences/const'\nexport * from '#/state/queries/preferences/moderation'\nexport * from '#/state/queries/preferences/types'\n\nconst preferencesQueryKeyRoot = 'getPreferences'\nexport const preferencesQueryKey = [preferencesQueryKeyRoot]\n\nexport function usePreferencesQuery() {\n const agent = useAgent()\n return useQuery({\n staleTime: STALE.SECONDS.FIFTEEN,\n structuralSharing: replaceEqualDeep,\n refetchOnWindowFocus: true,\n queryKey: preferencesQueryKey,\n queryFn: async () => {\n if (!agent.did) {\n return DEFAULT_LOGGED_OUT_PREFERENCES\n } else {\n const res = await agent.getPreferences()\n\n // save to local storage to ensure there are labels on initial requests\n saveLabelers(\n agent.did,\n res.moderationPrefs.labelers.map(l => l.did),\n )\n\n const preferences: UsePreferencesQueryResponse = {\n ...res,\n savedFeeds: res.savedFeeds.filter(f => f.type !== 'unknown'),\n /**\n * Special preference, only used for following feed, previously\n * called `home`\n */\n feedViewPrefs: {\n ...DEFAULT_HOME_FEED_PREFS,\n ...(res.feedViewPrefs.home || {}),\n },\n threadViewPrefs: {\n ...DEFAULT_THREAD_VIEW_PREFS,\n ...(res.threadViewPrefs ?? {}),\n },\n userAge: res.birthDate ? getAge(res.birthDate) : undefined,\n }\n return preferences\n }\n },\n })\n}\n\nexport function useClearPreferencesMutation() {\n const queryClient = useQueryClient()\n const agent = useAgent()\n\n return useMutation({\n mutationFn: async () => {\n await agent.app.bsky.actor.putPreferences({preferences: []})\n // triggers a refetch\n await queryClient.invalidateQueries({\n queryKey: preferencesQueryKey,\n })\n },\n })\n}\n\nexport function usePreferencesSetContentLabelMutation() {\n const agent = useAgent()\n const queryClient = useQueryClient()\n\n return useMutation<\n void,\n unknown,\n {label: string; visibility: LabelPreference; labelerDid: string | undefined}\n >({\n mutationFn: async ({label, visibility, labelerDid}) => {\n await agent.setContentLabelPref(label, visibility, labelerDid)\n // triggers a refetch\n await queryClient.invalidateQueries({\n queryKey: preferencesQueryKey,\n })\n },\n })\n}\n\nexport function useSetContentLabelMutation() {\n const queryClient = useQueryClient()\n const agent = useAgent()\n\n return useMutation({\n mutationFn: async ({\n label,\n visibility,\n labelerDid,\n }: {\n label: string\n visibility: LabelPreference\n labelerDid?: string\n }) => {\n await agent.setContentLabelPref(label, visibility, labelerDid)\n // triggers a refetch\n await queryClient.invalidateQueries({\n queryKey: preferencesQueryKey,\n })\n },\n })\n}\n\nexport function usePreferencesSetAdultContentMutation() {\n const queryClient = useQueryClient()\n const agent = useAgent()\n\n return useMutation({\n mutationFn: async ({enabled}) => {\n await agent.setAdultContentEnabled(enabled)\n // triggers a refetch\n await queryClient.invalidateQueries({\n queryKey: preferencesQueryKey,\n })\n },\n })\n}\n\nexport function usePreferencesSetBirthDateMutation() {\n const queryClient = useQueryClient()\n const agent = useAgent()\n\n return useMutation({\n mutationFn: async ({birthDate}: {birthDate: Date}) => {\n await agent.setPersonalDetails({birthDate: birthDate.toISOString()})\n // triggers a refetch\n await queryClient.invalidateQueries({\n queryKey: preferencesQueryKey,\n })\n },\n })\n}\n\nexport function useSetFeedViewPreferencesMutation() {\n const queryClient = useQueryClient()\n const agent = useAgent()\n\n return useMutation>({\n mutationFn: async prefs => {\n /*\n * special handling here, merged into `feedViewPrefs` above, since\n * following was previously called `home`\n */\n await agent.setFeedViewPrefs('home', prefs)\n // triggers a refetch\n await queryClient.invalidateQueries({\n queryKey: preferencesQueryKey,\n })\n },\n })\n}\n\nexport function useSetThreadViewPreferencesMutation() {\n const queryClient = useQueryClient()\n const agent = useAgent()\n\n return useMutation>({\n mutationFn: async prefs => {\n await agent.setThreadViewPrefs(prefs)\n // triggers a refetch\n await queryClient.invalidateQueries({\n queryKey: preferencesQueryKey,\n })\n },\n })\n}\n\nexport function useOverwriteSavedFeedsMutation() {\n const queryClient = useQueryClient()\n const agent = useAgent()\n\n return useMutation({\n mutationFn: async savedFeeds => {\n await agent.overwriteSavedFeeds(savedFeeds)\n // triggers a refetch\n await queryClient.invalidateQueries({\n queryKey: preferencesQueryKey,\n })\n },\n })\n}\n\nexport function useAddSavedFeedsMutation() {\n const queryClient = useQueryClient()\n const agent = useAgent()\n\n return useMutation<\n void,\n unknown,\n Pick[]\n >({\n mutationFn: async savedFeeds => {\n await agent.addSavedFeeds(savedFeeds)\n // triggers a refetch\n await queryClient.invalidateQueries({\n queryKey: preferencesQueryKey,\n })\n },\n })\n}\n\nexport function useRemoveFeedMutation() {\n const queryClient = useQueryClient()\n const agent = useAgent()\n\n return useMutation>({\n mutationFn: async savedFeed => {\n await agent.removeSavedFeeds([savedFeed.id])\n // triggers a refetch\n await queryClient.invalidateQueries({\n queryKey: preferencesQueryKey,\n })\n },\n })\n}\n\nexport function useReplaceForYouWithDiscoverFeedMutation() {\n const queryClient = useQueryClient()\n const agent = useAgent()\n\n return useMutation({\n mutationFn: async ({\n forYouFeedConfig,\n discoverFeedConfig,\n }: {\n forYouFeedConfig: AppBskyActorDefs.SavedFeed | undefined\n discoverFeedConfig: AppBskyActorDefs.SavedFeed | undefined\n }) => {\n if (forYouFeedConfig) {\n await agent.removeSavedFeeds([forYouFeedConfig.id])\n }\n if (!discoverFeedConfig) {\n await agent.addSavedFeeds([\n {\n type: 'feed',\n value: PROD_DEFAULT_FEED('whats-hot'),\n pinned: true,\n },\n ])\n } else {\n await agent.updateSavedFeeds([\n {\n ...discoverFeedConfig,\n pinned: true,\n },\n ])\n }\n // triggers a refetch\n await queryClient.invalidateQueries({\n queryKey: preferencesQueryKey,\n })\n },\n })\n}\n\nexport function useUpdateSavedFeedsMutation() {\n const queryClient = useQueryClient()\n const agent = useAgent()\n\n return useMutation({\n mutationFn: async feeds => {\n await agent.updateSavedFeeds(feeds)\n\n // triggers a refetch\n await queryClient.invalidateQueries({\n queryKey: preferencesQueryKey,\n })\n },\n })\n}\n\nexport function useUpsertMutedWordsMutation() {\n const queryClient = useQueryClient()\n const agent = useAgent()\n\n return useMutation({\n mutationFn: async (mutedWords: AppBskyActorDefs.MutedWord[]) => {\n await agent.upsertMutedWords(mutedWords)\n // triggers a refetch\n await queryClient.invalidateQueries({\n queryKey: preferencesQueryKey,\n })\n },\n })\n}\n\nexport function useUpdateMutedWordMutation() {\n const queryClient = useQueryClient()\n const agent = useAgent()\n\n return useMutation({\n mutationFn: async (mutedWord: AppBskyActorDefs.MutedWord) => {\n await agent.updateMutedWord(mutedWord)\n // triggers a refetch\n await queryClient.invalidateQueries({\n queryKey: preferencesQueryKey,\n })\n },\n })\n}\n\nexport function useRemoveMutedWordMutation() {\n const queryClient = useQueryClient()\n const agent = useAgent()\n\n return useMutation({\n mutationFn: async (mutedWord: AppBskyActorDefs.MutedWord) => {\n await agent.removeMutedWord(mutedWord)\n // triggers a refetch\n await queryClient.invalidateQueries({\n queryKey: preferencesQueryKey,\n })\n },\n })\n}\n\nexport function useRemoveMutedWordsMutation() {\n const queryClient = useQueryClient()\n const agent = useAgent()\n\n return useMutation({\n mutationFn: async (mutedWords: AppBskyActorDefs.MutedWord[]) => {\n await agent.removeMutedWords(mutedWords)\n // triggers a refetch\n await queryClient.invalidateQueries({\n queryKey: preferencesQueryKey,\n })\n },\n })\n}\n\nexport function useQueueNudgesMutation() {\n const queryClient = useQueryClient()\n const agent = useAgent()\n\n return useMutation({\n mutationFn: async (nudges: string | string[]) => {\n await agent.bskyAppQueueNudges(nudges)\n // triggers a refetch\n await queryClient.invalidateQueries({\n queryKey: preferencesQueryKey,\n })\n },\n })\n}\n\nexport function useDismissNudgesMutation() {\n const queryClient = useQueryClient()\n const agent = useAgent()\n\n return useMutation({\n mutationFn: async (nudges: string | string[]) => {\n await agent.bskyAppDismissNudges(nudges)\n // triggers a refetch\n await queryClient.invalidateQueries({\n queryKey: preferencesQueryKey,\n })\n },\n })\n}\n\nexport function useSetActiveProgressGuideMutation() {\n const queryClient = useQueryClient()\n const agent = useAgent()\n\n return useMutation({\n mutationFn: async (\n guide: AppBskyActorDefs.BskyAppProgressGuide | undefined,\n ) => {\n await agent.bskyAppSetActiveProgressGuide(guide)\n // triggers a refetch\n await queryClient.invalidateQueries({\n queryKey: preferencesQueryKey,\n })\n },\n })\n}\n","import React from 'react'\nimport {AppBskyLabelerDefs, InterpretedLabelValueDefinition} from '@atproto/api'\n\nimport {useLabelDefinitionsQuery} from '../queries/preferences'\n\ninterface StateContext {\n labelDefs: Record\n labelers: AppBskyLabelerDefs.LabelerViewDetailed[]\n}\n\nconst stateContext = React.createContext({\n labelDefs: {},\n labelers: [],\n})\n\nexport function Provider({children}: React.PropsWithChildren<{}>) {\n const state = useLabelDefinitionsQuery()\n return {children}\n}\n\nexport function useLabelDefinitions() {\n return React.useContext(stateContext)\n}\n","import React from 'react'\n\nimport {Provider as AltTextRequiredProvider} from './alt-text-required'\nimport {Provider as AutoplayProvider} from './autoplay'\nimport {Provider as DisableHapticsProvider} from './disable-haptics'\nimport {Provider as ExternalEmbedsProvider} from './external-embeds-prefs'\nimport {Provider as HiddenPostsProvider} from './hidden-posts'\nimport {Provider as InAppBrowserProvider} from './in-app-browser'\nimport {Provider as KawaiiProvider} from './kawaii'\nimport {Provider as LanguagesProvider} from './languages'\nimport {Provider as LargeAltBadgeProvider} from './large-alt-badge'\nimport {Provider as SubtitlesProvider} from './subtitles'\nimport {Provider as UsedStarterPacksProvider} from './used-starter-packs'\n\nexport {\n useRequireAltTextEnabled,\n useSetRequireAltTextEnabled,\n} from './alt-text-required'\nexport {useAutoplayDisabled, useSetAutoplayDisabled} from './autoplay'\nexport {useHapticsDisabled, useSetHapticsDisabled} from './disable-haptics'\nexport {\n useExternalEmbedsPrefs,\n useSetExternalEmbedPref,\n} from './external-embeds-prefs'\nexport * from './hidden-posts'\nexport {useLabelDefinitions} from './label-defs'\nexport {useLanguagePrefs, useLanguagePrefsApi} from './languages'\nexport {useSetSubtitlesEnabled, useSubtitlesEnabled} from './subtitles'\n\nexport function Provider({children}: React.PropsWithChildren<{}>) {\n return (\n \n \n \n \n \n \n \n \n \n \n {children}\n \n \n \n \n \n \n \n \n \n \n )\n}\n","import {useEffect} from 'react'\nimport {i18n} from '@lingui/core'\n\nimport {sanitizeAppLanguageSetting} from '#/locale/helpers'\nimport {AppLanguage} from '#/locale/languages'\nimport {useLanguagePrefs} from '#/state/preferences'\n\n/**\n * We do a dynamic import of just the catalog that we need\n */\nexport async function dynamicActivate(locale: AppLanguage) {\n let mod: any\n\n switch (locale) {\n case AppLanguage.an: {\n mod = await import(`./locales/an/messages`)\n break\n }\n case AppLanguage.ast: {\n mod = await import(`./locales/ast/messages`)\n break\n }\n case AppLanguage.ca: {\n mod = await import(`./locales/ca/messages`)\n break\n }\n case AppLanguage.de: {\n mod = await import(`./locales/de/messages`)\n break\n }\n case AppLanguage.en_GB: {\n mod = await import(`./locales/en-GB/messages`)\n break\n }\n case AppLanguage.es: {\n mod = await import(`./locales/es/messages`)\n break\n }\n case AppLanguage.fi: {\n mod = await import(`./locales/fi/messages`)\n break\n }\n case AppLanguage.fr: {\n mod = await import(`./locales/fr/messages`)\n break\n }\n case AppLanguage.ga: {\n mod = await import(`./locales/ga/messages`)\n break\n }\n case AppLanguage.gl: {\n mod = await import(`./locales/gl/messages`)\n break\n }\n case AppLanguage.hi: {\n mod = await import(`./locales/hi/messages`)\n break\n }\n case AppLanguage.hu: {\n mod = await import(`./locales/hu/messages`)\n break\n }\n case AppLanguage.id: {\n mod = await import(`./locales/id/messages`)\n break\n }\n case AppLanguage.it: {\n mod = await import(`./locales/it/messages`)\n break\n }\n case AppLanguage.ja: {\n mod = await import(`./locales/ja/messages`)\n break\n }\n case AppLanguage.ko: {\n mod = await import(`./locales/ko/messages`)\n break\n }\n case AppLanguage.nl: {\n mod = await import(`./locales/nl/messages`)\n break\n }\n case AppLanguage.pl: {\n mod = await import(`./locales/pl/messages`)\n break\n }\n case AppLanguage.pt_BR: {\n mod = await import(`./locales/pt-BR/messages`)\n break\n }\n case AppLanguage.ru: {\n mod = await import(`./locales/ru/messages`)\n break\n }\n case AppLanguage.th: {\n mod = await import(`./locales/th/messages`)\n break\n }\n case AppLanguage.tr: {\n mod = await import(`./locales/tr/messages`)\n break\n }\n case AppLanguage.uk: {\n mod = await import(`./locales/uk/messages`)\n break\n }\n case AppLanguage.vi: {\n mod = await import(`./locales/vi/messages`)\n break\n }\n case AppLanguage.zh_CN: {\n mod = await import(`./locales/zh-CN/messages`)\n break\n }\n case AppLanguage.zh_HK: {\n mod = await import(`./locales/zh-HK/messages`)\n break\n }\n case AppLanguage.zh_TW: {\n mod = await import(`./locales/zh-TW/messages`)\n break\n }\n default: {\n mod = await import(`./locales/en/messages`)\n break\n }\n }\n\n i18n.load(locale, mod.messages)\n i18n.activate(locale)\n}\n\nexport async function useLocaleLanguage() {\n const {appLanguage} = useLanguagePrefs()\n useEffect(() => {\n const sanitizedLanguage = sanitizeAppLanguageSetting(appLanguage)\n\n document.documentElement.lang = sanitizedLanguage\n dynamicActivate(sanitizedLanguage)\n }, [appLanguage])\n}\n","import React from 'react'\nimport {i18n} from '@lingui/core'\nimport {I18nProvider as DefaultI18nProvider} from '@lingui/react'\n\nimport {useLocaleLanguage} from './i18n'\n\nexport default function I18nProvider({children}: {children: React.ReactNode}) {\n useLocaleLanguage()\n return {children}\n}\n","import React from 'react'\nimport {AccessibilityInfo} from 'react-native'\n\nimport {isWeb} from '#/platform/detection'\nimport {PlatformInfo} from '../../modules/expo-bluesky-swiss-army'\n\nconst Context = React.createContext({\n reduceMotionEnabled: false,\n screenReaderEnabled: false,\n})\n\nexport function useA11y() {\n return React.useContext(Context)\n}\n\nexport function Provider({children}: React.PropsWithChildren<{}>) {\n const [reduceMotionEnabled, setReduceMotionEnabled] = React.useState(() =>\n PlatformInfo.getIsReducedMotionEnabled(),\n )\n const [screenReaderEnabled, setScreenReaderEnabled] = React.useState(false)\n\n React.useEffect(() => {\n const reduceMotionChangedSubscription = AccessibilityInfo.addEventListener(\n 'reduceMotionChanged',\n enabled => {\n setReduceMotionEnabled(enabled)\n },\n )\n const screenReaderChangedSubscription = AccessibilityInfo.addEventListener(\n 'screenReaderChanged',\n enabled => {\n setScreenReaderEnabled(enabled)\n },\n )\n\n ;(async () => {\n const [_reduceMotionEnabled, _screenReaderEnabled] = await Promise.all([\n AccessibilityInfo.isReduceMotionEnabled(),\n AccessibilityInfo.isScreenReaderEnabled(),\n ])\n setReduceMotionEnabled(_reduceMotionEnabled)\n setScreenReaderEnabled(_screenReaderEnabled)\n })()\n\n return () => {\n reduceMotionChangedSubscription.remove()\n screenReaderChangedSubscription.remove()\n }\n }, [])\n\n const ctx = React.useMemo(() => {\n return {\n reduceMotionEnabled,\n /**\n * Always returns true on web. For now, we're using this for mobile a11y,\n * so we reset to false on web.\n *\n * @see https://github.com/necolas/react-native-web/discussions/2072\n */\n screenReaderEnabled: isWeb ? false : screenReaderEnabled,\n }\n }, [reduceMotionEnabled, screenReaderEnabled])\n\n return {children}\n}\n","import React, {useEffect} from 'react'\n\nimport * as persisted from '#/state/persisted'\nimport {useAgent, useSession} from '../session'\n\ntype StateContext = Map\ntype SetStateContext = (uri: string, value: boolean) => void\n\nconst stateContext = React.createContext(new Map())\nconst setStateContext = React.createContext(\n (_: string) => false,\n)\n\nexport function Provider({children}: React.PropsWithChildren<{}>) {\n const [state, setState] = React.useState(() => new Map())\n\n const setThreadMute = React.useCallback(\n (uri: string, value: boolean) => {\n setState(prev => {\n const next = new Map(prev)\n next.set(uri, value)\n return next\n })\n },\n [setState],\n )\n\n useMigrateMutes(setThreadMute)\n\n return (\n \n \n {children}\n \n \n )\n}\n\nexport function useMutedThreads() {\n return React.useContext(stateContext)\n}\n\nexport function useIsThreadMuted(uri: string, defaultValue = false) {\n const state = React.useContext(stateContext)\n return state.get(uri) ?? defaultValue\n}\n\nexport function useSetThreadMute() {\n return React.useContext(setStateContext)\n}\n\nfunction useMigrateMutes(setThreadMute: SetStateContext) {\n const agent = useAgent()\n const {currentAccount} = useSession()\n\n useEffect(() => {\n if (currentAccount) {\n if (\n !persisted\n .get('mutedThreads')\n .some(uri => uri.includes(currentAccount.did))\n ) {\n return\n }\n\n let cancelled = false\n\n const migrate = async () => {\n while (!cancelled) {\n const threads = persisted.get('mutedThreads')\n\n const root = threads.findLast(uri => uri.includes(currentAccount.did))\n\n if (!root) break\n\n persisted.write(\n 'mutedThreads',\n threads.filter(uri => uri !== root),\n )\n\n setThreadMute(root, true)\n\n await agent.api.app.bsky.graph\n .muteThread({root})\n // not a big deal if this fails, since the post might have been deleted\n .catch(console.error)\n }\n }\n\n migrate()\n\n return () => {\n cancelled = true\n }\n }\n }, [agent, currentAccount, setThreadMute])\n}\n","import React from 'react'\nimport EventEmitter from 'eventemitter3'\n\nimport {networkRetry} from '#/lib/async/retry'\nimport {logger} from '#/logger'\nimport {IS_DEV} from '#/env'\nimport {Device, device} from '#/storage'\n\nconst events = new EventEmitter()\nconst EVENT = 'geolocation-updated'\nconst emitGeolocationUpdate = (geolocation: Device['geolocation']) => {\n events.emit(EVENT, geolocation)\n}\nconst onGeolocationUpdate = (\n listener: (geolocation: Device['geolocation']) => void,\n) => {\n events.on(EVENT, listener)\n return () => {\n events.off(EVENT, listener)\n }\n}\n\n/**\n * Default geolocation value. IF undefined, we fail closed and apply all\n * additional mod authorities.\n */\nexport const DEFAULT_GEOLOCATION: Device['geolocation'] = {\n countryCode: undefined,\n}\n\nasync function getGeolocation(): Promise {\n const res = await fetch(`https://app.bsky.transgirl.fr/ipcc`)\n\n if (!res.ok) {\n throw new Error(`geolocation: lookup failed ${res.status}`)\n }\n\n const json = await res.json()\n\n if (json.countryCode) {\n return {\n countryCode: json.countryCode,\n }\n } else {\n return undefined\n }\n}\n\n/**\n * Local promise used within this file only.\n */\nlet geolocationResolution: Promise | undefined\n\n/**\n * Begin the process of resolving geolocation. This should be called once at\n * app start.\n *\n * THIS METHOD SHOULD NEVER THROW.\n *\n * This method is otherwise not used for any purpose. To ensure geolocation is\n * resolved, use {@link ensureGeolocationResolved}\n */\nexport function beginResolveGeolocation() {\n /**\n * In dev, IP server is unavailable, so we just set the default geolocation\n * and fail closed.\n */\n if (IS_DEV) {\n geolocationResolution = new Promise(y => y())\n device.set(['geolocation'], DEFAULT_GEOLOCATION)\n return\n }\n\n geolocationResolution = new Promise(async resolve => {\n try {\n // Try once, fail fast\n const geolocation = await getGeolocation()\n if (geolocation) {\n device.set(['geolocation'], geolocation)\n emitGeolocationUpdate(geolocation)\n logger.debug(`geolocation: success`, {geolocation})\n } else {\n // endpoint should throw on all failures, this is insurance\n throw new Error(`geolocation: nothing returned from initial request`)\n }\n } catch (e: any) {\n logger.error(`geolocation: failed initial request`, {\n safeMessage: e.message,\n })\n\n // set to default\n device.set(['geolocation'], DEFAULT_GEOLOCATION)\n\n // retry 3 times, but don't await, proceed with default\n networkRetry(3, getGeolocation)\n .then(geolocation => {\n if (geolocation) {\n device.set(['geolocation'], geolocation)\n emitGeolocationUpdate(geolocation)\n logger.debug(`geolocation: success`, {geolocation})\n } else {\n // endpoint should throw on all failures, this is insurance\n throw new Error(`geolocation: nothing returned from retries`)\n }\n })\n .catch((e: any) => {\n // complete fail closed\n logger.error(`geolocation: failed retries`, {safeMessage: e.message})\n })\n } finally {\n resolve(undefined)\n }\n })\n}\n\n/**\n * Ensure that geolocation has been resolved, or at the very least attempted\n * once. Subsequent retries will not be captured by this `await`. Those will be\n * reported via {@link events}.\n */\nexport async function ensureGeolocationResolved() {\n if (!geolocationResolution) {\n throw new Error(`geolocation: beginResolveGeolocation not called yet`)\n }\n\n const cached = device.get(['geolocation'])\n if (cached) {\n logger.debug(`geolocation: using cache`, {cached})\n } else {\n logger.debug(`geolocation: no cache`)\n await geolocationResolution\n logger.debug(`geolocation: resolved`, {\n resolved: device.get(['geolocation']),\n })\n }\n}\n\ntype Context = {\n geolocation: Device['geolocation']\n}\n\nconst context = React.createContext({\n geolocation: DEFAULT_GEOLOCATION,\n})\n\nexport function Provider({children}: {children: React.ReactNode}) {\n const [geolocation, setGeolocation] = React.useState(() => {\n const initial = device.get(['geolocation']) || DEFAULT_GEOLOCATION\n return initial\n })\n\n React.useEffect(() => {\n return onGeolocationUpdate(geolocation => {\n setGeolocation(geolocation!)\n })\n }, [])\n\n const ctx = React.useMemo(() => {\n return {\n geolocation,\n }\n }, [geolocation])\n\n return {children}\n}\n\nexport function useGeolocation() {\n return React.useContext(context)\n}\n","import React from 'react'\n\nimport * as persisted from '#/state/persisted'\n\ntype StateContext = persisted.Schema['invites']\ntype ApiContext = {\n setInviteCopied: (code: string) => void\n}\n\nconst stateContext = React.createContext(\n persisted.defaults.invites,\n)\nconst apiContext = React.createContext({\n setInviteCopied(_: string) {},\n})\n\nexport function Provider({children}: React.PropsWithChildren<{}>) {\n const [state, setState] = React.useState(persisted.get('invites'))\n\n const api = React.useMemo(\n () => ({\n setInviteCopied(code: string) {\n setState(state => {\n state = {\n ...state,\n copiedInvites: state.copiedInvites.includes(code)\n ? state.copiedInvites\n : state.copiedInvites.concat([code]),\n }\n persisted.write('invites', state)\n return state\n })\n },\n }),\n [setState],\n )\n\n React.useEffect(() => {\n return persisted.onUpdate('invites', nextInvites => {\n setState(nextInvites)\n })\n }, [setState])\n\n return (\n \n {children}\n \n )\n}\n\nexport function useInvitesState() {\n return React.useContext(stateContext)\n}\n\nexport function useInvitesAPI() {\n return React.useContext(apiContext)\n}\n","import React from 'react'\n\nconst CurrentConvoIdContext = React.createContext<{\n currentConvoId: string | undefined\n setCurrentConvoId: (convoId: string | undefined) => void\n}>({\n currentConvoId: undefined,\n setCurrentConvoId: () => {},\n})\n\nexport function useCurrentConvoId() {\n const ctx = React.useContext(CurrentConvoIdContext)\n if (!ctx) {\n throw new Error(\n 'useCurrentConvoId must be used within a CurrentConvoIdProvider',\n )\n }\n return ctx\n}\n\nexport function CurrentConvoIdProvider({\n children,\n}: {\n children: React.ReactNode\n}) {\n const [currentConvoId, setCurrentConvoId] = React.useState<\n string | undefined\n >()\n const ctx = React.useMemo(\n () => ({currentConvoId, setCurrentConvoId}),\n [currentConvoId],\n )\n return (\n \n {children}\n \n )\n}\n","export const DEFAULT_POLL_INTERVAL = 60e3\nexport const BACKGROUND_POLL_INTERVAL = 60e3 * 5\n","import {BskyAgent, ChatBskyConvoGetLog} from '@atproto/api'\n\nexport type MessagesEventBusParams = {\n agent: BskyAgent\n}\n\nexport enum MessagesEventBusStatus {\n Initializing = 'initializing',\n Ready = 'ready',\n Error = 'error',\n Backgrounded = 'backgrounded',\n Suspended = 'suspended',\n}\n\nexport enum MessagesEventBusDispatchEvent {\n Ready = 'ready',\n Error = 'error',\n Background = 'background',\n Suspend = 'suspend',\n Resume = 'resume',\n UpdatePoll = 'updatePoll',\n}\n\nexport enum MessagesEventBusErrorCode {\n Unknown = 'unknown',\n InitFailed = 'initFailed',\n PollFailed = 'pollFailed',\n}\n\nexport type MessagesEventBusError = {\n code: MessagesEventBusErrorCode\n exception?: Error\n retry: () => void\n}\n\nexport type MessagesEventBusDispatch =\n | {\n event: MessagesEventBusDispatchEvent.Ready\n }\n | {\n event: MessagesEventBusDispatchEvent.Background\n }\n | {\n event: MessagesEventBusDispatchEvent.Suspend\n }\n | {\n event: MessagesEventBusDispatchEvent.Resume\n }\n | {\n event: MessagesEventBusDispatchEvent.Error\n payload: MessagesEventBusError\n }\n | {\n event: MessagesEventBusDispatchEvent.UpdatePoll\n }\n\nexport type MessagesEventBusEvent =\n | {\n type: 'connect'\n }\n | {\n type: 'error'\n error: MessagesEventBusError\n }\n | {\n type: 'logs'\n logs: ChatBskyConvoGetLog.OutputSchema['logs']\n }\n","export const DM_SERVICE_HEADERS = {\n 'atproto-proxy': 'did:web:api.bsky.chat#bsky_chat',\n}\n","import {BskyAgent, ChatBskyConvoGetLog} from '@atproto/api'\nimport EventEmitter from 'eventemitter3'\nimport {nanoid} from 'nanoid/non-secure'\n\nimport {networkRetry} from '#/lib/async/retry'\nimport {logger} from '#/logger'\nimport {\n BACKGROUND_POLL_INTERVAL,\n DEFAULT_POLL_INTERVAL,\n} from '#/state/messages/events/const'\nimport {\n MessagesEventBusDispatch,\n MessagesEventBusDispatchEvent,\n MessagesEventBusErrorCode,\n MessagesEventBusEvent,\n MessagesEventBusParams,\n MessagesEventBusStatus,\n} from '#/state/messages/events/types'\nimport {DM_SERVICE_HEADERS} from '#/state/queries/messages/const'\n\nconst LOGGER_CONTEXT = 'MessagesEventBus'\n\nexport class MessagesEventBus {\n private id: string\n\n private agent: BskyAgent\n private emitter = new EventEmitter<{event: [MessagesEventBusEvent]}>()\n\n private status: MessagesEventBusStatus = MessagesEventBusStatus.Initializing\n private latestRev: string | undefined = undefined\n private pollInterval = DEFAULT_POLL_INTERVAL\n private requestedPollIntervals: Map = new Map()\n\n constructor(params: MessagesEventBusParams) {\n this.id = nanoid(3)\n this.agent = params.agent\n\n this.init()\n }\n\n requestPollInterval(interval: number) {\n const id = nanoid()\n this.requestedPollIntervals.set(id, interval)\n this.dispatch({\n event: MessagesEventBusDispatchEvent.UpdatePoll,\n })\n return () => {\n this.requestedPollIntervals.delete(id)\n this.dispatch({\n event: MessagesEventBusDispatchEvent.UpdatePoll,\n })\n }\n }\n\n getLatestRev() {\n return this.latestRev\n }\n\n on(\n handler: (event: MessagesEventBusEvent) => void,\n options: {\n convoId?: string\n },\n ) {\n const handle = (event: MessagesEventBusEvent) => {\n if (event.type === 'logs' && options.convoId) {\n const filteredLogs = event.logs.filter(log => {\n if (\n typeof log.convoId === 'string' &&\n log.convoId === options.convoId\n ) {\n return log.convoId === options.convoId\n }\n return false\n })\n\n if (filteredLogs.length > 0) {\n handler({\n ...event,\n logs: filteredLogs,\n })\n }\n } else {\n handler(event)\n }\n }\n\n this.emitter.on('event', handle)\n\n return () => {\n this.emitter.off('event', handle)\n }\n }\n\n background() {\n logger.debug(`${LOGGER_CONTEXT}: background`, {}, logger.DebugContext.convo)\n this.dispatch({event: MessagesEventBusDispatchEvent.Background})\n }\n\n suspend() {\n logger.debug(`${LOGGER_CONTEXT}: suspend`, {}, logger.DebugContext.convo)\n this.dispatch({event: MessagesEventBusDispatchEvent.Suspend})\n }\n\n resume() {\n logger.debug(`${LOGGER_CONTEXT}: resume`, {}, logger.DebugContext.convo)\n this.dispatch({event: MessagesEventBusDispatchEvent.Resume})\n }\n\n private dispatch(action: MessagesEventBusDispatch) {\n const prevStatus = this.status\n\n switch (this.status) {\n case MessagesEventBusStatus.Initializing: {\n switch (action.event) {\n case MessagesEventBusDispatchEvent.Ready: {\n this.status = MessagesEventBusStatus.Ready\n this.resetPoll()\n this.emitter.emit('event', {type: 'connect'})\n break\n }\n case MessagesEventBusDispatchEvent.Background: {\n this.status = MessagesEventBusStatus.Backgrounded\n this.resetPoll()\n this.emitter.emit('event', {type: 'connect'})\n break\n }\n case MessagesEventBusDispatchEvent.Suspend: {\n this.status = MessagesEventBusStatus.Suspended\n break\n }\n case MessagesEventBusDispatchEvent.Error: {\n this.status = MessagesEventBusStatus.Error\n this.emitter.emit('event', {type: 'error', error: action.payload})\n break\n }\n }\n break\n }\n case MessagesEventBusStatus.Ready: {\n switch (action.event) {\n case MessagesEventBusDispatchEvent.Background: {\n this.status = MessagesEventBusStatus.Backgrounded\n this.resetPoll()\n break\n }\n case MessagesEventBusDispatchEvent.Suspend: {\n this.status = MessagesEventBusStatus.Suspended\n this.stopPoll()\n break\n }\n case MessagesEventBusDispatchEvent.Error: {\n this.status = MessagesEventBusStatus.Error\n this.stopPoll()\n this.emitter.emit('event', {type: 'error', error: action.payload})\n break\n }\n case MessagesEventBusDispatchEvent.UpdatePoll: {\n this.resetPoll()\n break\n }\n }\n break\n }\n case MessagesEventBusStatus.Backgrounded: {\n switch (action.event) {\n case MessagesEventBusDispatchEvent.Resume: {\n this.status = MessagesEventBusStatus.Ready\n this.resetPoll()\n break\n }\n case MessagesEventBusDispatchEvent.Suspend: {\n this.status = MessagesEventBusStatus.Suspended\n this.stopPoll()\n break\n }\n case MessagesEventBusDispatchEvent.Error: {\n this.status = MessagesEventBusStatus.Error\n this.stopPoll()\n this.emitter.emit('event', {type: 'error', error: action.payload})\n break\n }\n case MessagesEventBusDispatchEvent.UpdatePoll: {\n this.resetPoll()\n break\n }\n }\n break\n }\n case MessagesEventBusStatus.Suspended: {\n switch (action.event) {\n case MessagesEventBusDispatchEvent.Resume: {\n this.status = MessagesEventBusStatus.Ready\n this.resetPoll()\n break\n }\n case MessagesEventBusDispatchEvent.Background: {\n this.status = MessagesEventBusStatus.Backgrounded\n this.resetPoll()\n break\n }\n case MessagesEventBusDispatchEvent.Error: {\n this.status = MessagesEventBusStatus.Error\n this.stopPoll()\n this.emitter.emit('event', {type: 'error', error: action.payload})\n break\n }\n }\n break\n }\n case MessagesEventBusStatus.Error: {\n switch (action.event) {\n case MessagesEventBusDispatchEvent.UpdatePoll:\n case MessagesEventBusDispatchEvent.Resume: {\n // basically reset\n this.status = MessagesEventBusStatus.Initializing\n this.latestRev = undefined\n this.init()\n break\n }\n }\n break\n }\n default:\n break\n }\n\n logger.debug(\n `${LOGGER_CONTEXT}: dispatch '${action.event}'`,\n {\n id: this.id,\n prev: prevStatus,\n next: this.status,\n },\n logger.DebugContext.convo,\n )\n }\n\n private async init() {\n logger.debug(`${LOGGER_CONTEXT}: init`, {}, logger.DebugContext.convo)\n\n try {\n const response = await networkRetry(2, () => {\n return this.agent.api.chat.bsky.convo.listConvos(\n {\n limit: 1,\n },\n {headers: DM_SERVICE_HEADERS},\n )\n })\n // throw new Error('UNCOMMENT TO TEST INIT FAILURE')\n\n const {convos} = response.data\n\n for (const convo of convos) {\n if (convo.rev > (this.latestRev = this.latestRev || convo.rev)) {\n this.latestRev = convo.rev\n }\n }\n\n this.dispatch({event: MessagesEventBusDispatchEvent.Ready})\n } catch (e: any) {\n logger.error(e, {\n context: `${LOGGER_CONTEXT}: init failed`,\n })\n\n this.dispatch({\n event: MessagesEventBusDispatchEvent.Error,\n payload: {\n exception: e,\n code: MessagesEventBusErrorCode.InitFailed,\n retry: () => {\n this.dispatch({event: MessagesEventBusDispatchEvent.Resume})\n },\n },\n })\n }\n }\n\n /*\n * Polling\n */\n\n private isPolling = false\n private pollIntervalRef: NodeJS.Timeout | undefined\n\n private getPollInterval() {\n switch (this.status) {\n case MessagesEventBusStatus.Ready: {\n const requested = Array.from(this.requestedPollIntervals.values())\n const lowest = Math.min(DEFAULT_POLL_INTERVAL, ...requested)\n return lowest\n }\n case MessagesEventBusStatus.Backgrounded: {\n return BACKGROUND_POLL_INTERVAL\n }\n default:\n return DEFAULT_POLL_INTERVAL\n }\n }\n\n private resetPoll() {\n this.pollInterval = this.getPollInterval()\n this.stopPoll()\n this.startPoll()\n }\n\n private startPoll() {\n if (!this.isPolling) this.poll()\n\n this.pollIntervalRef = setInterval(() => {\n if (this.isPolling) return\n this.poll()\n }, this.pollInterval)\n }\n\n private stopPoll() {\n if (this.pollIntervalRef) clearInterval(this.pollIntervalRef)\n }\n\n private async poll() {\n if (this.isPolling) return\n\n this.isPolling = true\n\n // logger.debug(\n // `${LOGGER_CONTEXT}: poll`,\n // {\n // requestedPollIntervals: Array.from(\n // this.requestedPollIntervals.values(),\n // ),\n // },\n // logger.DebugContext.convo,\n // )\n\n try {\n const response = await networkRetry(2, () => {\n return this.agent.api.chat.bsky.convo.getLog(\n {\n cursor: this.latestRev,\n },\n {headers: DM_SERVICE_HEADERS},\n )\n })\n\n // throw new Error('UNCOMMENT TO TEST POLL FAILURE')\n\n const {logs: events} = response.data\n\n let needsEmit = false\n let batch: ChatBskyConvoGetLog.OutputSchema['logs'] = []\n\n for (const ev of events) {\n /*\n * If there's a rev, we should handle it. If there's not a rev, we don't\n * know what it is.\n */\n if (typeof ev.rev === 'string') {\n /*\n * We only care about new events\n */\n if (ev.rev > (this.latestRev = this.latestRev || ev.rev)) {\n /*\n * Update rev regardless of if it's a ev type we care about or not\n */\n this.latestRev = ev.rev\n needsEmit = true\n batch.push(ev)\n }\n }\n }\n\n if (needsEmit) {\n try {\n this.emitter.emit('event', {type: 'logs', logs: batch})\n } catch (e: any) {\n logger.error(e, {\n context: `${LOGGER_CONTEXT}: process latest events`,\n })\n }\n }\n } catch (e: any) {\n logger.error(e, {context: `${LOGGER_CONTEXT}: poll events failed`})\n\n this.dispatch({\n event: MessagesEventBusDispatchEvent.Error,\n payload: {\n exception: e,\n code: MessagesEventBusErrorCode.PollFailed,\n retry: () => {\n this.dispatch({event: MessagesEventBusDispatchEvent.Resume})\n },\n },\n })\n } finally {\n this.isPolling = false\n }\n }\n}\n","import React from 'react'\nimport {AppState} from 'react-native'\n\nimport {MessagesEventBus} from '#/state/messages/events/agent'\nimport {useAgent, useSession} from '#/state/session'\n\nconst MessagesEventBusContext = React.createContext(\n null,\n)\n\nexport function useMessagesEventBus() {\n const ctx = React.useContext(MessagesEventBusContext)\n if (!ctx) {\n throw new Error(\n 'useMessagesEventBus must be used within a MessagesEventBusProvider',\n )\n }\n return ctx\n}\n\nexport function MessagesEventBusProvider({\n children,\n}: {\n children: React.ReactNode\n}) {\n const {currentAccount} = useSession()\n\n if (!currentAccount) {\n return (\n \n {children}\n \n )\n }\n\n return (\n {children}\n )\n}\n\nexport function MessagesEventBusProviderInner({\n children,\n}: {\n children: React.ReactNode\n}) {\n const agent = useAgent()\n const [bus] = React.useState(\n () =>\n new MessagesEventBus({\n agent,\n }),\n )\n\n React.useEffect(() => {\n bus.resume()\n\n return () => {\n bus.suspend()\n }\n }, [bus])\n\n React.useEffect(() => {\n const handleAppStateChange = (nextAppState: string) => {\n if (nextAppState === 'active') {\n bus.resume()\n } else {\n bus.background()\n }\n }\n\n const sub = AppState.addEventListener('change', handleAppStateChange)\n\n return () => {\n sub.remove()\n }\n }, [bus])\n\n return (\n \n {children}\n \n )\n}\n","import React, {createContext, useContext, useMemo} from 'react'\nimport {BskyAgent, ModerationOpts} from '@atproto/api'\n\nimport {useHiddenPosts, useLabelDefinitions} from '#/state/preferences'\nimport {DEFAULT_LOGGED_OUT_LABEL_PREFERENCES} from '#/state/queries/preferences/moderation'\nimport {useSession} from '#/state/session'\nimport {usePreferencesQuery} from '../queries/preferences'\n\nexport const moderationOptsContext = createContext(\n undefined,\n)\n\n// used in the moderation state devtool\nexport const moderationOptsOverrideContext = createContext<\n ModerationOpts | undefined\n>(undefined)\n\nexport function useModerationOpts() {\n return useContext(moderationOptsContext)\n}\n\nexport function Provider({children}: React.PropsWithChildren<{}>) {\n const override = useContext(moderationOptsOverrideContext)\n const {currentAccount} = useSession()\n const prefs = usePreferencesQuery()\n const {labelDefs} = useLabelDefinitions()\n const hiddenPosts = useHiddenPosts() // TODO move this into pds-stored prefs\n\n const userDid = currentAccount?.did\n const moderationPrefs = prefs.data?.moderationPrefs\n const value = useMemo(() => {\n if (override) {\n return override\n }\n if (!moderationPrefs) {\n return undefined\n }\n return {\n userDid,\n prefs: {\n ...moderationPrefs,\n labelers: moderationPrefs.labelers.length\n ? moderationPrefs.labelers\n : BskyAgent.appLabelers.map(did => ({\n did,\n labels: DEFAULT_LOGGED_OUT_LABEL_PREFERENCES,\n })),\n hiddenPosts: hiddenPosts || [],\n },\n labelDefs,\n }\n }, [override, userDid, labelDefs, moderationPrefs, hiddenPosts])\n\n return (\n \n {children}\n \n )\n}\n","import React, {\n createContext,\n useCallback,\n useContext,\n useEffect,\n useMemo,\n} from 'react'\nimport {\n ChatBskyConvoDefs,\n ChatBskyConvoListConvos,\n moderateProfile,\n} from '@atproto/api'\nimport {\n InfiniteData,\n QueryClient,\n useInfiniteQuery,\n useQueryClient,\n} from '@tanstack/react-query'\nimport throttle from 'lodash.throttle'\n\nimport {useCurrentConvoId} from '#/state/messages/current-convo-id'\nimport {useMessagesEventBus} from '#/state/messages/events'\nimport {useModerationOpts} from '#/state/preferences/moderation-opts'\nimport {DM_SERVICE_HEADERS} from '#/state/queries/messages/const'\nimport {useAgent, useSession} from '#/state/session'\n\nexport const RQKEY = ['convo-list']\ntype RQPageParam = string | undefined\n\nexport function useListConvosQuery({\n enabled,\n}: {\n enabled?: boolean\n} = {}) {\n const agent = useAgent()\n\n return useInfiniteQuery({\n enabled,\n queryKey: RQKEY,\n queryFn: async ({pageParam}) => {\n const {data} = await agent.api.chat.bsky.convo.listConvos(\n {cursor: pageParam, limit: 20},\n {headers: DM_SERVICE_HEADERS},\n )\n\n return data\n },\n initialPageParam: undefined as RQPageParam,\n getNextPageParam: lastPage => lastPage.cursor,\n })\n}\n\nconst ListConvosContext = createContext(\n null,\n)\n\nexport function useListConvos() {\n const ctx = useContext(ListConvosContext)\n if (!ctx) {\n throw new Error('useListConvos must be used within a ListConvosProvider')\n }\n return ctx\n}\n\nexport function ListConvosProvider({children}: {children: React.ReactNode}) {\n const {hasSession} = useSession()\n\n if (!hasSession) {\n return (\n \n {children}\n \n )\n }\n\n return {children}\n}\n\nexport function ListConvosProviderInner({\n children,\n}: {\n children: React.ReactNode\n}) {\n const {refetch, data} = useListConvosQuery()\n const messagesBus = useMessagesEventBus()\n const queryClient = useQueryClient()\n const {currentConvoId} = useCurrentConvoId()\n const {currentAccount} = useSession()\n\n const debouncedRefetch = useMemo(\n () =>\n throttle(refetch, 500, {\n leading: true,\n trailing: true,\n }),\n [refetch],\n )\n\n useEffect(() => {\n const unsub = messagesBus.on(\n events => {\n if (events.type !== 'logs') return\n\n events.logs.forEach(log => {\n if (ChatBskyConvoDefs.isLogBeginConvo(log)) {\n debouncedRefetch()\n } else if (ChatBskyConvoDefs.isLogLeaveConvo(log)) {\n queryClient.setQueryData(RQKEY, (old: ConvoListQueryData) =>\n optimisticDelete(log.convoId, old),\n )\n } else if (ChatBskyConvoDefs.isLogDeleteMessage(log)) {\n queryClient.setQueryData(RQKEY, (old: ConvoListQueryData) =>\n optimisticUpdate(log.convoId, old, convo =>\n log.message.id === convo.lastMessage?.id\n ? {\n ...convo,\n rev: log.rev,\n lastMessage: log.message,\n }\n : convo,\n ),\n )\n } else if (ChatBskyConvoDefs.isLogCreateMessage(log)) {\n queryClient.setQueryData(RQKEY, (old: ConvoListQueryData) => {\n if (!old) return old\n\n function updateConvo(convo: ChatBskyConvoDefs.ConvoView) {\n if (!ChatBskyConvoDefs.isLogCreateMessage(log)) return convo\n\n let unreadCount = convo.unreadCount\n if (convo.id !== currentConvoId) {\n if (\n ChatBskyConvoDefs.isMessageView(log.message) ||\n ChatBskyConvoDefs.isDeletedMessageView(log.message)\n ) {\n if (log.message.sender.did !== currentAccount?.did) {\n unreadCount++\n }\n }\n } else {\n unreadCount = 0\n }\n\n return {\n ...convo,\n rev: log.rev,\n lastMessage: log.message,\n unreadCount,\n }\n }\n\n function filterConvoFromPage(\n convo: ChatBskyConvoDefs.ConvoView[],\n ) {\n return convo.filter(c => c.id !== log.convoId)\n }\n\n const existingConvo = getConvoFromQueryData(log.convoId, old)\n\n if (existingConvo) {\n return {\n ...old,\n pages: old.pages.map((page, i) => {\n if (i === 0) {\n return {\n ...page,\n convos: [\n updateConvo(existingConvo),\n ...filterConvoFromPage(page.convos),\n ],\n }\n }\n return {\n ...page,\n convos: filterConvoFromPage(page.convos),\n }\n }),\n }\n } else {\n /**\n * We received a message from an conversation old enough that\n * it doesn't exist in the query cache, meaning we need to\n * refetch and bump the old convo to the top.\n */\n debouncedRefetch()\n }\n })\n }\n })\n },\n {\n // get events for all chats\n convoId: undefined,\n },\n )\n\n return () => unsub()\n }, [\n messagesBus,\n currentConvoId,\n refetch,\n queryClient,\n currentAccount?.did,\n debouncedRefetch,\n ])\n\n const ctx = useMemo(() => {\n return data?.pages.flatMap(page => page.convos) ?? []\n }, [data])\n\n return (\n \n {children}\n \n )\n}\n\nexport function useUnreadMessageCount() {\n const {currentConvoId} = useCurrentConvoId()\n const {currentAccount} = useSession()\n const convos = useListConvos()\n const moderationOpts = useModerationOpts()\n\n const count = useMemo(() => {\n return (\n convos\n .filter(convo => convo.id !== currentConvoId)\n .reduce((acc, convo) => {\n const otherMember = convo.members.find(\n member => member.did !== currentAccount?.did,\n )\n\n if (!otherMember || !moderationOpts) return acc\n\n const moderation = moderateProfile(otherMember, moderationOpts)\n const shouldIgnore =\n convo.muted ||\n moderation.blocked ||\n otherMember.did === 'missing.invalid'\n const unreadCount = !shouldIgnore && convo.unreadCount > 0 ? 1 : 0\n\n return acc + unreadCount\n }, 0) ?? 0\n )\n }, [convos, currentAccount?.did, currentConvoId, moderationOpts])\n\n return useMemo(() => {\n return {\n count,\n numUnread: count > 0 ? (count > 10 ? '10+' : String(count)) : undefined,\n }\n }, [count])\n}\n\nexport type ConvoListQueryData = {\n pageParams: Array\n pages: Array\n}\n\nexport function useOnMarkAsRead() {\n const queryClient = useQueryClient()\n\n return useCallback(\n (chatId: string) => {\n queryClient.setQueryData(RQKEY, (old: ConvoListQueryData) => {\n return optimisticUpdate(chatId, old, convo => ({\n ...convo,\n unreadCount: 0,\n }))\n })\n },\n [queryClient],\n )\n}\n\nfunction optimisticUpdate(\n chatId: string,\n old: ConvoListQueryData,\n updateFn: (convo: ChatBskyConvoDefs.ConvoView) => ChatBskyConvoDefs.ConvoView,\n) {\n if (!old) return old\n\n return {\n ...old,\n pages: old.pages.map(page => ({\n ...page,\n convos: page.convos.map(convo =>\n chatId === convo.id ? updateFn(convo) : convo,\n ),\n })),\n }\n}\n\nfunction optimisticDelete(chatId: string, old: ConvoListQueryData) {\n if (!old) return old\n\n return {\n ...old,\n pages: old.pages.map(page => ({\n ...page,\n convos: page.convos.filter(convo => chatId !== convo.id),\n })),\n }\n}\n\nexport function getConvoFromQueryData(chatId: string, old: ConvoListQueryData) {\n for (const page of old.pages) {\n for (const convo of page.convos) {\n if (convo.id === chatId) {\n return convo\n }\n }\n }\n return null\n}\n\nexport function* findAllProfilesInQueryData(\n queryClient: QueryClient,\n did: string,\n) {\n const queryDatas = queryClient.getQueriesData<\n InfiniteData\n >({\n queryKey: RQKEY,\n })\n for (const [_queryKey, queryData] of queryDatas) {\n if (!queryData?.pages) {\n continue\n }\n\n for (const page of queryData.pages) {\n for (const convo of page.convos) {\n for (const member of convo.members) {\n if (member.did === did) {\n yield member\n }\n }\n }\n }\n }\n}\n","import React, {useEffect, useMemo, useReducer, useRef} from 'react'\n\nimport {useCurrentConvoId} from './current-convo-id'\n\nconst MessageDraftsContext = React.createContext<{\n state: State\n dispatch: React.Dispatch\n} | null>(null)\n\nfunction useMessageDraftsContext() {\n const ctx = React.useContext(MessageDraftsContext)\n if (!ctx) {\n throw new Error(\n 'useMessageDrafts must be used within a MessageDraftsContext',\n )\n }\n return ctx\n}\n\nexport function useMessageDraft() {\n const {currentConvoId} = useCurrentConvoId()\n const {state, dispatch} = useMessageDraftsContext()\n return useMemo(\n () => ({\n getDraft: () => (currentConvoId && state[currentConvoId]) || '',\n clearDraft: () => {\n if (currentConvoId) {\n dispatch({type: 'clear', convoId: currentConvoId})\n }\n },\n }),\n [state, dispatch, currentConvoId],\n )\n}\n\nexport function useSaveMessageDraft(message: string) {\n const {currentConvoId} = useCurrentConvoId()\n const {dispatch} = useMessageDraftsContext()\n const messageRef = useRef(message)\n messageRef.current = message\n\n useEffect(() => {\n return () => {\n if (currentConvoId) {\n dispatch({\n type: 'set',\n convoId: currentConvoId,\n draft: messageRef.current,\n })\n }\n }\n }, [currentConvoId, dispatch])\n}\n\ntype State = {[convoId: string]: string}\ntype Actions =\n | {type: 'set'; convoId: string; draft: string}\n | {type: 'clear'; convoId: string}\n\nfunction reducer(state: State, action: Actions): State {\n switch (action.type) {\n case 'set':\n return {...state, [action.convoId]: action.draft}\n case 'clear':\n return {...state, [action.convoId]: ''}\n default:\n return state\n }\n}\n\nexport function MessageDraftsProvider({children}: {children: React.ReactNode}) {\n const [state, dispatch] = useReducer(reducer, {})\n\n const ctx = useMemo(() => {\n return {state, dispatch}\n }, [state])\n\n return (\n \n {children}\n \n )\n}\n","import React from 'react'\n\nimport {CurrentConvoIdProvider} from '#/state/messages/current-convo-id'\nimport {MessagesEventBusProvider} from '#/state/messages/events'\nimport {ListConvosProvider} from '#/state/queries/messages/list-converations'\nimport {MessageDraftsProvider} from './message-drafts'\n\nexport function MessagesProvider({children}: {children: React.ReactNode}) {\n return (\n \n \n \n {children}\n \n \n \n )\n}\n","import {\n BackgroundNotificationHandlerPreferences,\n ExpoBackgroundNotificationHandlerModule,\n} from './ExpoBackgroundNotificationHandler.types'\n\n// Stub for web\nexport const BackgroundNotificationHandler = {\n getAllPrefsAsync: async () => {\n return {} as BackgroundNotificationHandlerPreferences\n },\n getBoolAsync: async (_: string) => {\n return false\n },\n getStringAsync: async (_: string) => {\n return ''\n },\n getStringArrayAsync: async (_: string) => {\n return []\n },\n setBoolAsync: async (_: string, __: boolean) => {},\n setStringAsync: async (_: string, __: string) => {},\n setStringArrayAsync: async (_: string, __: string[]) => {},\n addToStringArrayAsync: async (_: string, __: string) => {},\n removeFromStringArrayAsync: async (_: string, __: string) => {},\n addManyToStringArrayAsync: async (_: string, __: string[]) => {},\n removeManyFromStringArrayAsync: async (_: string, __: string[]) => {},\n setBadgeCountAsync: async (_: number) => {},\n} as ExpoBackgroundNotificationHandlerModule\n","import {BackgroundNotificationHandler} from './src/ExpoBackgroundNotificationHandlerModule'\nexport default BackgroundNotificationHandler\n","import React from 'react'\nimport * as Notifications from 'expo-notifications'\nimport {getBadgeCountAsync, setBadgeCountAsync} from 'expo-notifications'\nimport {BskyAgent} from '@atproto/api'\n\nimport {logger} from '#/logger'\nimport {devicePlatform, isAndroid, isNative} from '#/platform/detection'\nimport {SessionAccount, useAgent, useSession} from '#/state/session'\nimport BackgroundNotificationHandler from '../../../modules/expo-background-notification-handler'\n\nconst SERVICE_DID = (serviceUrl?: string) =>\n serviceUrl?.includes('staging')\n ? 'did:web:api.staging.bsky.dev'\n : 'did:web:api.bsky.app'\n\nasync function registerPushToken(\n agent: BskyAgent,\n account: SessionAccount,\n token: Notifications.DevicePushToken,\n) {\n try {\n await agent.api.app.bsky.notification.registerPush({\n serviceDid: SERVICE_DID(account.service),\n platform: devicePlatform,\n token: token.data,\n appId: 'xyz.blueskyweb.app',\n })\n logger.debug(\n 'Notifications: Sent push token (init)',\n {\n tokenType: token.type,\n token: token.data,\n },\n logger.DebugContext.notifications,\n )\n } catch (error) {\n logger.error('Notifications: Failed to set push token', {message: error})\n }\n}\n\nasync function getPushToken(skipPermissionCheck = false) {\n const granted =\n skipPermissionCheck || (await Notifications.getPermissionsAsync()).granted\n if (granted) {\n return Notifications.getDevicePushTokenAsync()\n }\n}\n\nexport function useNotificationsRegistration() {\n const agent = useAgent()\n const {currentAccount} = useSession()\n\n React.useEffect(() => {\n if (!currentAccount) {\n return\n }\n\n // HACK - see https://github.com/bluesky-social/social-app/pull/4467\n // An apparent regression in expo-notifications causes `addPushTokenListener` to not fire on Android whenever the\n // token changes by calling `getPushToken()`. This is a workaround to ensure we register the token once it is\n // generated on Android.\n if (isAndroid) {\n ;(async () => {\n const token = await getPushToken()\n\n // Token will be undefined if we don't have notifications permission\n if (token) {\n registerPushToken(agent, currentAccount, token)\n }\n })()\n } else {\n getPushToken()\n }\n\n // According to the Expo docs, there is a chance that the token will change while the app is open in some rare\n // cases. This will fire `registerPushToken` whenever that happens.\n const subscription = Notifications.addPushTokenListener(async newToken => {\n registerPushToken(agent, currentAccount, newToken)\n })\n\n return () => {\n subscription.remove()\n }\n }, [currentAccount, agent])\n}\n\nexport function useRequestNotificationsPermission() {\n const {currentAccount} = useSession()\n const agent = useAgent()\n\n return async (\n context: 'StartOnboarding' | 'AfterOnboarding' | 'Login' | 'Home',\n ) => {\n const permissions = await Notifications.getPermissionsAsync()\n\n if (\n !isNative ||\n permissions?.status === 'granted' ||\n (permissions?.status === 'denied' && !permissions.canAskAgain)\n ) {\n return\n }\n if (context === 'AfterOnboarding') {\n return\n }\n if (context === 'Home' && !currentAccount) {\n return\n }\n\n const res = await Notifications.requestPermissionsAsync()\n\n if (res.granted) {\n // This will fire a pushTokenEvent, which will handle registration of the token\n const token = await getPushToken(true)\n\n // Same hack as above. We cannot rely on the `addPushTokenListener` to fire on Android due to an Expo bug, so we\n // will manually register it here. Note that this will occur only:\n // 1. right after the user signs in, leading to no `currentAccount` account being available - this will be instead\n // picked up from the useEffect above on `currentAccount` change\n // 2. right after onboarding. In this case, we _need_ this registration, since `currentAccount` will not change\n // and we need to ensure the token is registered right after permission is granted. `currentAccount` will already\n // be available in this case, so the registration will succeed.\n // We should remove this once expo-notifications (and possibly FCMv1) is fixed and the `addPushTokenListener` is\n // working again. See https://github.com/expo/expo/issues/28656\n if (isAndroid && currentAccount && token) {\n registerPushToken(agent, currentAccount, token)\n }\n }\n }\n}\n\nexport async function decrementBadgeCount(by: number) {\n if (!isNative) return\n\n let count = await getBadgeCountAsync()\n count -= by\n if (count < 0) {\n count = 0\n }\n\n await BackgroundNotificationHandler.setBadgeCountAsync(count)\n await setBadgeCountAsync(count)\n}\n\nexport async function resetBadgeCount() {\n await BackgroundNotificationHandler.setBadgeCountAsync(0)\n await setBadgeCountAsync(0)\n}\n","import {\n AppBskyActorDefs,\n AppBskyEmbedRecord,\n AppBskyEmbedRecordWithMedia,\n AppBskyFeedDefs,\n AppBskyFeedPost,\n AtUri,\n} from '@atproto/api'\nimport {InfiniteData, QueryClient, QueryKey} from '@tanstack/react-query'\n\nexport function truncateAndInvalidate(\n queryClient: QueryClient,\n queryKey: QueryKey,\n) {\n queryClient.setQueriesData>({queryKey}, data => {\n if (data) {\n return {\n pageParams: data.pageParams.slice(0, 1),\n pages: data.pages.slice(0, 1),\n }\n }\n return data\n })\n queryClient.invalidateQueries({queryKey})\n}\n\n// Given an AtUri, this function will check if the AtUri matches a\n// hit regardless of whether the AtUri uses a DID or handle as a host.\n//\n// AtUri should be the URI that is being searched for, while currentUri\n// is the URI that is being checked. currentAuthor is the author\n// of the currentUri that is being checked.\nexport function didOrHandleUriMatches(\n atUri: AtUri,\n record: {uri: string; author: AppBskyActorDefs.ProfileViewBasic},\n) {\n if (atUri.host.startsWith('did:')) {\n return atUri.href === record.uri\n }\n\n return atUri.host === record.author.handle && record.uri.endsWith(atUri.rkey)\n}\n\nexport function getEmbeddedPost(\n v: unknown,\n): AppBskyEmbedRecord.ViewRecord | undefined {\n if (AppBskyEmbedRecord.isView(v)) {\n if (\n AppBskyEmbedRecord.isViewRecord(v.record) &&\n AppBskyFeedPost.isRecord(v.record.value)\n ) {\n return v.record\n }\n }\n if (AppBskyEmbedRecordWithMedia.isView(v)) {\n if (\n AppBskyEmbedRecord.isViewRecord(v.record.record) &&\n AppBskyFeedPost.isRecord(v.record.record.value)\n ) {\n return v.record.record\n }\n }\n}\n\nexport function embedViewRecordToPostView(\n v: AppBskyEmbedRecord.ViewRecord,\n): AppBskyFeedDefs.PostView {\n return {\n uri: v.uri,\n cid: v.cid,\n author: v.author,\n record: v.value,\n indexedAt: v.indexedAt,\n labels: v.labels,\n embed: v.embeds?.[0],\n likeCount: v.likeCount,\n quoteCount: v.quoteCount,\n replyCount: v.replyCount,\n repostCount: v.repostCount,\n }\n}\n","/* eslint-disable-next-line no-restricted-imports */\nimport {BSKY_LABELER_DID, moderatePost} from '@atproto/api'\n\ntype ModeratePost = typeof moderatePost\ntype Options = Parameters[1]\n\nexport function moderatePost_wrapped(\n subject: Parameters[0],\n opts: Options,\n) {\n // HACK\n // temporarily translate 'gore' into 'graphic-media' during the transition period\n // can remove this in a few months\n // -prf\n translateOldLabels(subject)\n\n return moderatePost(subject, opts)\n}\n\nfunction translateOldLabels(subject: Parameters[0]) {\n if (subject.labels) {\n for (const label of subject.labels) {\n if (\n label.val === 'gore' &&\n (!label.src || label.src === BSKY_LABELER_DID)\n ) {\n label.val = 'graphic-media'\n }\n }\n }\n}\n","import React from 'react'\nimport {AppBskyFeedThreadgate} from '@atproto/api'\n\ntype StateContext = {\n uris: Set\n recentlyUnhiddenUris: Set\n}\ntype ApiContext = {\n addHiddenReplyUri: (uri: string) => void\n removeHiddenReplyUri: (uri: string) => void\n}\n\nconst StateContext = React.createContext({\n uris: new Set(),\n recentlyUnhiddenUris: new Set(),\n})\n\nconst ApiContext = React.createContext({\n addHiddenReplyUri: () => {},\n removeHiddenReplyUri: () => {},\n})\n\nexport function Provider({children}: {children: React.ReactNode}) {\n const [uris, setHiddenReplyUris] = React.useState>(new Set())\n const [recentlyUnhiddenUris, setRecentlyUnhiddenUris] = React.useState<\n Set\n >(new Set())\n\n const stateCtx = React.useMemo(\n () => ({\n uris,\n recentlyUnhiddenUris,\n }),\n [uris, recentlyUnhiddenUris],\n )\n\n const apiCtx = React.useMemo(\n () => ({\n addHiddenReplyUri(uri: string) {\n setHiddenReplyUris(prev => new Set(prev.add(uri)))\n setRecentlyUnhiddenUris(prev => {\n prev.delete(uri)\n return new Set(prev)\n })\n },\n removeHiddenReplyUri(uri: string) {\n setHiddenReplyUris(prev => {\n prev.delete(uri)\n return new Set(prev)\n })\n setRecentlyUnhiddenUris(prev => new Set(prev.add(uri)))\n },\n }),\n [setHiddenReplyUris],\n )\n\n return (\n \n {children}\n \n )\n}\n\nexport function useThreadgateHiddenReplyUris() {\n return React.useContext(StateContext)\n}\n\nexport function useThreadgateHiddenReplyUrisAPI() {\n return React.useContext(ApiContext)\n}\n\nexport function useMergedThreadgateHiddenReplies({\n threadgateRecord,\n}: {\n threadgateRecord?: AppBskyFeedThreadgate.Record\n}) {\n const {uris, recentlyUnhiddenUris} = useThreadgateHiddenReplyUris()\n return React.useMemo(() => {\n const set = new Set([...(threadgateRecord?.hiddenReplies || []), ...uris])\n for (const uri of recentlyUnhiddenUris) {\n set.delete(uri)\n }\n return set\n }, [uris, recentlyUnhiddenUris, threadgateRecord])\n}\n","import {ModerationUI} from '@atproto/api'\n\n// \\u2705 = ✅\n// \\u2713 = ✓\n// \\u2714 = ✔\n// \\u2611 = ☑\nconst CHECK_MARKS_RE = /[\\u2705\\u2713\\u2714\\u2611]/gu\nconst CONTROL_CHARS_RE =\n /[\\u0000-\\u001F\\u007F-\\u009F\\u061C\\u200E\\u200F\\u202A-\\u202E\\u2066-\\u2069]/g\nconst MULTIPLE_SPACES_RE = /[\\s][\\s\\u200B]+/g\n\nexport function sanitizeDisplayName(\n str: string,\n moderation?: ModerationUI,\n): string {\n if (moderation?.blur) {\n return ''\n }\n if (typeof str === 'string') {\n return str\n .replace(CHECK_MARKS_RE, '')\n .replace(CONTROL_CHARS_RE, '')\n .replace(MULTIPLE_SPACES_RE, ' ')\n .trim()\n }\n return ''\n}\n\nexport function combinedDisplayName({\n handle,\n displayName,\n}: {\n handle?: string\n displayName?: string\n}): string {\n if (!handle) {\n return ''\n }\n return displayName\n ? `${sanitizeDisplayName(displayName)} (@${handle})`\n : `@${handle}`\n}\n","import React from 'react'\nimport {\n AppBskyLabelerDefs,\n BskyAgent,\n ComAtprotoLabelDefs,\n InterpretedLabelValueDefinition,\n LABELS,\n ModerationCause,\n ModerationOpts,\n ModerationUI,\n} from '@atproto/api'\n\nimport {sanitizeDisplayName} from '#/lib/strings/display-names'\nimport {sanitizeHandle} from '#/lib/strings/handles'\nimport {AppModerationCause} from '#/components/Pills'\n\nexport const ADULT_CONTENT_LABELS = ['sexual', 'nudity', 'porn']\nexport const OTHER_SELF_LABELS = ['graphic-media']\nexport const SELF_LABELS = [...ADULT_CONTENT_LABELS, ...OTHER_SELF_LABELS]\n\nexport type AdultSelfLabel = (typeof ADULT_CONTENT_LABELS)[number]\nexport type OtherSelfLabel = (typeof OTHER_SELF_LABELS)[number]\nexport type SelfLabel = (typeof SELF_LABELS)[number]\n\nexport function getModerationCauseKey(\n cause: ModerationCause | AppModerationCause,\n): string {\n const source =\n cause.source.type === 'labeler'\n ? cause.source.did\n : cause.source.type === 'list'\n ? cause.source.list.uri\n : 'user'\n if (cause.type === 'label') {\n return `label:${cause.label.val}:${source}`\n }\n return `${cause.type}:${source}`\n}\n\nexport function isJustAMute(modui: ModerationUI): boolean {\n return modui.filters.length === 1 && modui.filters[0].type === 'muted'\n}\n\nexport function moduiContainsHideableOffense(modui: ModerationUI): boolean {\n const label = modui.filters.at(0)\n if (label && label.type === 'label') {\n return labelIsHideableOffense(label.label)\n }\n return false\n}\n\nexport function labelIsHideableOffense(\n label: ComAtprotoLabelDefs.Label,\n): boolean {\n return ['!hide', '!takedown'].includes(label.val)\n}\n\nexport function getLabelingServiceTitle({\n displayName,\n handle,\n}: {\n displayName?: string\n handle: string\n}) {\n return displayName\n ? sanitizeDisplayName(displayName)\n : sanitizeHandle(handle, '@')\n}\n\nexport function lookupLabelValueDefinition(\n labelValue: string,\n customDefs: InterpretedLabelValueDefinition[] | undefined,\n): InterpretedLabelValueDefinition | undefined {\n let def\n if (!labelValue.startsWith('!') && customDefs) {\n def = customDefs.find(d => d.identifier === labelValue)\n }\n if (!def) {\n def = LABELS[labelValue as keyof typeof LABELS]\n }\n return def\n}\n\nexport function isAppLabeler(\n labeler:\n | string\n | AppBskyLabelerDefs.LabelerView\n | AppBskyLabelerDefs.LabelerViewDetailed,\n): boolean {\n if (typeof labeler === 'string') {\n return BskyAgent.appLabelers.includes(labeler)\n }\n return BskyAgent.appLabelers.includes(labeler.creator.did)\n}\n\nexport function isLabelerSubscribed(\n labeler:\n | string\n | AppBskyLabelerDefs.LabelerView\n | AppBskyLabelerDefs.LabelerViewDetailed,\n modOpts: ModerationOpts,\n) {\n labeler = typeof labeler === 'string' ? labeler : labeler.creator.did\n if (isAppLabeler(labeler)) {\n return true\n }\n return modOpts.prefs.labelers.find(l => l.did === labeler)\n}\n\nexport type Subject =\n | {\n uri: string\n cid: string\n }\n | {\n did: string\n }\n\nexport function useLabelSubject({label}: {label: ComAtprotoLabelDefs.Label}): {\n subject: Subject\n} {\n return React.useMemo(() => {\n const {cid, uri} = label\n if (cid) {\n return {\n subject: {\n uri,\n cid,\n },\n }\n } else {\n return {\n subject: {\n did: uri,\n },\n }\n }\n }, [label])\n}\n","import {AppBskyRichtextFacet, RichText, UnicodeString} from '@atproto/api'\n\nimport {toShortUrl} from './url-helpers'\n\nexport function shortenLinks(rt: RichText): RichText {\n if (!rt.facets?.length) {\n return rt\n }\n rt = rt.clone()\n // enumerate the link facets\n if (rt.facets) {\n for (const facet of rt.facets) {\n const isLink = !!facet.features.find(AppBskyRichtextFacet.isLink)\n if (!isLink) {\n continue\n }\n\n // extract and shorten the URL\n const {byteStart, byteEnd} = facet.index\n const url = rt.unicodeText.slice(byteStart, byteEnd)\n const shortened = new UnicodeString(toShortUrl(url))\n\n // insert the shorten URL\n rt.insert(byteStart, shortened.utf16)\n // update the facet to cover the new shortened URL\n facet.index.byteStart = byteStart\n facet.index.byteEnd = byteStart + shortened.length\n // remove the old URL\n rt.delete(byteStart + shortened.length, byteEnd + shortened.length)\n }\n }\n return rt\n}\n\n// filter out any mention facets that didn't map to a user\nexport function stripInvalidMentions(rt: RichText): RichText {\n if (!rt.facets?.length) {\n return rt\n }\n rt = rt.clone()\n if (rt.facets) {\n rt.facets = rt.facets?.filter(facet => {\n const mention = facet.features.find(AppBskyRichtextFacet.isMention)\n if (mention && !mention.did) {\n return false\n }\n return true\n })\n }\n return rt\n}\n","export function timeout(ms: number): Promise {\n return new Promise(r => setTimeout(r, ms))\n}\n","import {timeout} from './timeout'\n\nexport async function until(\n retries: number,\n delay: number,\n cond: (v: T, err: any) => boolean,\n fn: () => Promise,\n): Promise {\n while (retries > 0) {\n try {\n const v = await fn()\n if (cond(v, undefined)) {\n return true\n }\n } catch (e: any) {\n // TODO: change the type signature of cond to accept undefined\n // however this breaks every existing usage of until -sfn\n if (cond(undefined as unknown as T, e)) {\n return true\n }\n }\n await timeout(delay)\n retries--\n }\n return false\n}\n","import {\n AppBskyActorDefs,\n AppBskyEmbedRecord,\n AppBskyFeedDefs,\n AppBskyFeedGetQuotes,\n AtUri,\n} from '@atproto/api'\nimport {\n InfiniteData,\n QueryClient,\n QueryKey,\n useInfiniteQuery,\n} from '@tanstack/react-query'\n\nimport {useAgent} from '#/state/session'\nimport {\n didOrHandleUriMatches,\n embedViewRecordToPostView,\n getEmbeddedPost,\n} from './util'\n\nconst PAGE_SIZE = 30\ntype RQPageParam = string | undefined\n\nconst RQKEY_ROOT = 'post-quotes'\nexport const RQKEY = (resolvedUri: string) => [RQKEY_ROOT, resolvedUri]\n\nexport function usePostQuotesQuery(resolvedUri: string | undefined) {\n const agent = useAgent()\n return useInfiniteQuery<\n AppBskyFeedGetQuotes.OutputSchema,\n Error,\n InfiniteData,\n QueryKey,\n RQPageParam\n >({\n queryKey: RQKEY(resolvedUri || ''),\n async queryFn({pageParam}: {pageParam: RQPageParam}) {\n const res = await agent.api.app.bsky.feed.getQuotes({\n uri: resolvedUri || '',\n limit: PAGE_SIZE,\n cursor: pageParam,\n })\n return res.data\n },\n initialPageParam: undefined,\n getNextPageParam: lastPage => lastPage.cursor,\n enabled: !!resolvedUri,\n select: data => {\n return {\n ...data,\n pages: data.pages.map(page => {\n return {\n ...page,\n posts: page.posts.filter(post => {\n if (post.embed && AppBskyEmbedRecord.isView(post.embed)) {\n if (AppBskyEmbedRecord.isViewDetached(post.embed.record)) {\n return false\n }\n }\n return true\n }),\n }\n }),\n }\n },\n })\n}\n\nexport function* findAllProfilesInQueryData(\n queryClient: QueryClient,\n did: string,\n): Generator {\n const queryDatas = queryClient.getQueriesData<\n InfiniteData\n >({\n queryKey: [RQKEY_ROOT],\n })\n for (const [_queryKey, queryData] of queryDatas) {\n if (!queryData?.pages) {\n continue\n }\n for (const page of queryData?.pages) {\n for (const item of page.posts) {\n if (item.author.did === did) {\n yield item.author\n }\n const quotedPost = getEmbeddedPost(item.embed)\n if (quotedPost?.author.did === did) {\n yield quotedPost.author\n }\n }\n }\n }\n}\n\nexport function* findAllPostsInQueryData(\n queryClient: QueryClient,\n uri: string,\n): Generator {\n const queryDatas = queryClient.getQueriesData<\n InfiniteData\n >({\n queryKey: [RQKEY_ROOT],\n })\n const atUri = new AtUri(uri)\n for (const [_queryKey, queryData] of queryDatas) {\n if (!queryData?.pages) {\n continue\n }\n for (const page of queryData?.pages) {\n for (const post of page.posts) {\n if (didOrHandleUriMatches(atUri, post)) {\n yield post\n }\n\n const quotedPost = getEmbeddedPost(post.embed)\n if (quotedPost && didOrHandleUriMatches(atUri, quotedPost)) {\n yield embedViewRecordToPostView(quotedPost)\n }\n }\n }\n }\n}\n","import React from 'react'\nimport {\n AppBskyActorDefs,\n AppBskyFeedDefs,\n AppBskyFeedSearchPosts,\n AtUri,\n} from '@atproto/api'\nimport {\n InfiniteData,\n QueryClient,\n QueryKey,\n useInfiniteQuery,\n} from '@tanstack/react-query'\n\nimport {moderatePost_wrapped as moderatePost} from '#/lib/moderatePost_wrapped'\nimport {useModerationOpts} from '#/state/preferences/moderation-opts'\nimport {useAgent} from '#/state/session'\nimport {\n didOrHandleUriMatches,\n embedViewRecordToPostView,\n getEmbeddedPost,\n} from './util'\n\nconst searchPostsQueryKeyRoot = 'search-posts'\nconst searchPostsQueryKey = ({query, sort}: {query: string; sort?: string}) => [\n searchPostsQueryKeyRoot,\n query,\n sort,\n]\n\nexport function useSearchPostsQuery({\n query,\n sort,\n enabled,\n}: {\n query: string\n sort?: 'top' | 'latest'\n enabled?: boolean\n}) {\n const agent = useAgent()\n const moderationOpts = useModerationOpts()\n const selectArgs = React.useMemo(\n () => ({\n isSearchingSpecificUser: /from:(\\w+)/.test(query),\n moderationOpts,\n }),\n [query, moderationOpts],\n )\n const lastRun = React.useRef<{\n data: InfiniteData\n args: typeof selectArgs\n result: InfiniteData\n } | null>(null)\n\n return useInfiniteQuery<\n AppBskyFeedSearchPosts.OutputSchema,\n Error,\n InfiniteData,\n QueryKey,\n string | undefined\n >({\n queryKey: searchPostsQueryKey({query, sort}),\n queryFn: async ({pageParam}) => {\n const res = await agent.app.bsky.feed.searchPosts({\n q: query,\n limit: 25,\n cursor: pageParam,\n sort,\n })\n return res.data\n },\n initialPageParam: undefined,\n getNextPageParam: lastPage => lastPage.cursor,\n enabled: enabled ?? !!moderationOpts,\n select: React.useCallback(\n (data: InfiniteData) => {\n const {moderationOpts, isSearchingSpecificUser} = selectArgs\n\n /*\n * If a user applies the `from:` filter, don't apply any\n * moderation. Note that if we add any more filtering logic below, we\n * may need to adjust this.\n */\n if (isSearchingSpecificUser) {\n return data\n }\n\n // Keep track of the last run and whether we can reuse\n // some already selected pages from there.\n let reusedPages = []\n if (lastRun.current) {\n const {\n data: lastData,\n args: lastArgs,\n result: lastResult,\n } = lastRun.current\n let canReuse = true\n for (let key in selectArgs) {\n if (selectArgs.hasOwnProperty(key)) {\n if ((selectArgs as any)[key] !== (lastArgs as any)[key]) {\n // Can't do reuse anything if any input has changed.\n canReuse = false\n break\n }\n }\n }\n if (canReuse) {\n for (let i = 0; i < data.pages.length; i++) {\n if (data.pages[i] && lastData.pages[i] === data.pages[i]) {\n reusedPages.push(lastResult.pages[i])\n continue\n }\n // Stop as soon as pages stop matching up.\n break\n }\n }\n }\n\n const result = {\n ...data,\n pages: [\n ...reusedPages,\n ...data.pages.slice(reusedPages.length).map(page => {\n return {\n ...page,\n posts: page.posts.filter(post => {\n const mod = moderatePost(post, moderationOpts!)\n return !mod.ui('contentList').filter\n }),\n }\n }),\n ],\n }\n\n lastRun.current = {data, result, args: selectArgs}\n\n return result\n },\n [selectArgs],\n ),\n })\n}\n\nexport function* findAllPostsInQueryData(\n queryClient: QueryClient,\n uri: string,\n): Generator {\n const queryDatas = queryClient.getQueriesData<\n InfiniteData\n >({\n queryKey: [searchPostsQueryKeyRoot],\n })\n const atUri = new AtUri(uri)\n\n for (const [_queryKey, queryData] of queryDatas) {\n if (!queryData?.pages) {\n continue\n }\n for (const page of queryData?.pages) {\n for (const post of page.posts) {\n if (didOrHandleUriMatches(atUri, post)) {\n yield post\n }\n\n const quotedPost = getEmbeddedPost(post.embed)\n if (quotedPost && didOrHandleUriMatches(atUri, quotedPost)) {\n yield embedViewRecordToPostView(quotedPost)\n }\n }\n }\n }\n}\n\nexport function* findAllProfilesInQueryData(\n queryClient: QueryClient,\n did: string,\n): Generator {\n const queryDatas = queryClient.getQueriesData<\n InfiniteData\n >({\n queryKey: [searchPostsQueryKeyRoot],\n })\n for (const [_queryKey, queryData] of queryDatas) {\n if (!queryData?.pages) {\n continue\n }\n for (const page of queryData?.pages) {\n for (const post of page.posts) {\n if (post.author.did === did) {\n yield post.author\n }\n const quotedPost = getEmbeddedPost(post.embed)\n if (quotedPost?.author.did === did) {\n yield quotedPost.author\n }\n }\n }\n }\n}\n","import {\n AppBskyFeedDefs,\n AppBskyFeedGetAuthorFeed as GetAuthorFeed,\n BskyAgent,\n} from '@atproto/api'\n\nimport {FeedAPI, FeedAPIResponse} from './types'\n\nexport class AuthorFeedAPI implements FeedAPI {\n agent: BskyAgent\n _params: GetAuthorFeed.QueryParams\n\n constructor({\n agent,\n feedParams,\n }: {\n agent: BskyAgent\n feedParams: GetAuthorFeed.QueryParams\n }) {\n this.agent = agent\n this._params = feedParams\n }\n\n get params() {\n const params = {...this._params}\n params.includePins = params.filter !== 'posts_with_media'\n return params\n }\n\n async peekLatest(): Promise {\n const res = await this.agent.getAuthorFeed({\n ...this.params,\n limit: 1,\n })\n return res.data.feed[0]\n }\n\n async fetch({\n cursor,\n limit,\n }: {\n cursor: string | undefined\n limit: number\n }): Promise {\n const res = await this.agent.getAuthorFeed({\n ...this.params,\n cursor,\n limit,\n })\n if (res.success) {\n return {\n cursor: res.data.cursor,\n feed: this._filter(res.data.feed),\n }\n }\n return {\n feed: [],\n }\n }\n\n _filter(feed: AppBskyFeedDefs.FeedViewPost[]) {\n if (this.params.filter === 'posts_and_author_threads') {\n return feed.filter(post => {\n const isReply = post.reply\n const isRepost = AppBskyFeedDefs.isReasonRepost(post.reason)\n const isPin = AppBskyFeedDefs.isReasonPin(post.reason)\n if (!isReply) return true\n if (isRepost || isPin) return true\n return isReply && isAuthorReplyChain(this.params.actor, post, feed)\n })\n }\n\n return feed\n }\n}\n\nfunction isAuthorReplyChain(\n actor: string,\n post: AppBskyFeedDefs.FeedViewPost,\n posts: AppBskyFeedDefs.FeedViewPost[],\n): boolean {\n // current post is by a different user (shouldn't happen)\n if (post.post.author.did !== actor) return false\n\n const replyParent = post.reply?.parent\n\n if (AppBskyFeedDefs.isPostView(replyParent)) {\n // reply parent is by a different user\n if (replyParent.author.did !== actor) return false\n\n // A top-level post that matches the parent of the current post.\n const parentPost = posts.find(p => p.post.uri === replyParent.uri)\n\n /*\n * Either we haven't fetched the parent at the top level, or the only\n * record we have is on feedItem.reply.parent, which we've already checked\n * above.\n */\n if (!parentPost) return true\n\n // Walk up to parent\n return isAuthorReplyChain(actor, parentPost, posts)\n }\n\n // Just default to showing it\n return true\n}\n","import {AtUri} from '@atproto/api'\n\nimport {BSKY_FEED_OWNER_DIDS} from '#/lib/constants'\nimport {isWeb} from '#/platform/detection'\nimport {UsePreferencesQueryResponse} from '#/state/queries/preferences'\n\nlet debugTopics = ''\nif (isWeb && typeof window !== 'undefined') {\n const params = new URLSearchParams(window.location.search)\n debugTopics = params.get('debug_topics') ?? ''\n}\n\nexport function createBskyTopicsHeader(userInterests?: string) {\n return {\n 'X-Bsky-Topics': debugTopics || userInterests || '',\n }\n}\n\nexport function aggregateUserInterests(\n preferences?: UsePreferencesQueryResponse,\n) {\n return preferences?.interests?.tags?.join(',') || ''\n}\n\nexport function isBlueskyOwnedFeed(feedUri: string) {\n const uri = new AtUri(feedUri)\n return BSKY_FEED_OWNER_DIDS.includes(uri.host)\n}\n","import {\n AppBskyFeedDefs,\n AppBskyFeedGetFeed as GetCustomFeed,\n BskyAgent,\n jsonStringToLex,\n} from '@atproto/api'\n\nimport {\n getAppLanguageAsContentLanguage,\n getContentLanguages,\n} from '#/state/preferences/languages'\nimport {FeedAPI, FeedAPIResponse} from './types'\nimport {createBskyTopicsHeader, isBlueskyOwnedFeed} from './utils'\n\nexport class CustomFeedAPI implements FeedAPI {\n agent: BskyAgent\n params: GetCustomFeed.QueryParams\n userInterests?: string\n\n constructor({\n agent,\n feedParams,\n userInterests,\n }: {\n agent: BskyAgent\n feedParams: GetCustomFeed.QueryParams\n userInterests?: string\n }) {\n this.agent = agent\n this.params = feedParams\n this.userInterests = userInterests\n }\n\n async peekLatest(): Promise {\n const contentLangs = getContentLanguages().join(',')\n const res = await this.agent.app.bsky.feed.getFeed(\n {\n ...this.params,\n limit: 1,\n },\n {headers: {'Accept-Language': contentLangs}},\n )\n return res.data.feed[0]\n }\n\n async fetch({\n cursor,\n limit,\n }: {\n cursor: string | undefined\n limit: number\n }): Promise {\n const contentLangs = getContentLanguages().join(',')\n const agent = this.agent\n const isBlueskyOwned = isBlueskyOwnedFeed(this.params.feed)\n\n const res = agent.did\n ? await this.agent.app.bsky.feed.getFeed(\n {\n ...this.params,\n cursor,\n limit,\n },\n {\n headers: {\n ...(isBlueskyOwned\n ? createBskyTopicsHeader(this.userInterests)\n : {}),\n 'Accept-Language': contentLangs,\n },\n },\n )\n : await loggedOutFetch({...this.params, cursor, limit})\n if (res.success) {\n // NOTE\n // some custom feeds fail to enforce the pagination limit\n // so we manually truncate here\n // -prf\n if (res.data.feed.length > limit) {\n res.data.feed = res.data.feed.slice(0, limit)\n }\n return {\n cursor: res.data.feed.length ? res.data.cursor : undefined,\n feed: res.data.feed,\n }\n }\n return {\n feed: [],\n }\n }\n}\n\n// HACK\n// we want feeds to give language-specific results immediately when a\n// logged-out user changes their language. this comes with two problems:\n// 1. not all languages have content, and\n// 2. our public caching layer isnt correctly busting against the accept-language header\n// for now we handle both of these with a manual workaround\n// -prf\nasync function loggedOutFetch({\n feed,\n limit,\n cursor,\n}: {\n feed: string\n limit: number\n cursor?: string\n}) {\n let contentLangs = getAppLanguageAsContentLanguage()\n\n /**\n * Copied from our root `Agent` class\n * @see https://github.com/bluesky-social/atproto/blob/60df3fc652b00cdff71dd9235d98a7a4bb828f05/packages/api/src/agent.ts#L120\n */\n const labelersHeader = {\n 'atproto-accept-labelers': BskyAgent.appLabelers\n .map(l => `${l};redact`)\n .join(', '),\n }\n\n // manually construct fetch call so we can add the `lang` cache-busting param\n let res = await fetch(\n `https://api.bsky.app/xrpc/app.bsky.feed.getFeed?feed=${feed}${\n cursor ? `&cursor=${cursor}` : ''\n }&limit=${limit}&lang=${contentLangs}`,\n {\n method: 'GET',\n headers: {'Accept-Language': contentLangs, ...labelersHeader},\n },\n )\n let data = res.ok\n ? (jsonStringToLex(await res.text()) as GetCustomFeed.OutputSchema)\n : null\n if (data?.feed?.length) {\n return {\n success: true,\n data,\n }\n }\n\n // no data, try again with language headers removed\n res = await fetch(\n `https://api.bsky.app/xrpc/app.bsky.feed.getFeed?feed=${feed}${\n cursor ? `&cursor=${cursor}` : ''\n }&limit=${limit}`,\n {method: 'GET', headers: {'Accept-Language': '', ...labelersHeader}},\n )\n data = res.ok\n ? (jsonStringToLex(await res.text()) as GetCustomFeed.OutputSchema)\n : null\n if (data?.feed?.length) {\n return {\n success: true,\n data,\n }\n }\n\n return {\n success: false,\n data: {feed: []},\n }\n}\n","import {AppBskyFeedDefs, BskyAgent} from '@atproto/api'\n\nimport {FeedAPI, FeedAPIResponse} from './types'\n\nexport class FollowingFeedAPI implements FeedAPI {\n agent: BskyAgent\n\n constructor({agent}: {agent: BskyAgent}) {\n this.agent = agent\n }\n\n async peekLatest(): Promise {\n const res = await this.agent.getTimeline({\n limit: 1,\n })\n return res.data.feed[0]\n }\n\n async fetch({\n cursor,\n limit,\n }: {\n cursor: string | undefined\n limit: number\n }): Promise {\n const res = await this.agent.getTimeline({\n cursor,\n limit,\n })\n if (res.success) {\n return {\n cursor: res.data.cursor,\n feed: res.data.feed,\n }\n }\n return {\n feed: [],\n }\n }\n}\n","import {AppBskyFeedDefs, BskyAgent} from '@atproto/api'\n\nimport {PROD_DEFAULT_FEED} from '#/lib/constants'\nimport {CustomFeedAPI} from './custom'\nimport {FollowingFeedAPI} from './following'\nimport {FeedAPI, FeedAPIResponse} from './types'\n\n// HACK\n// the feed API does not include any facilities for passing down\n// non-post elements. adding that is a bit of a heavy lift, and we\n// have just one temporary usecase for it: flagging when the home feed\n// falls back to discover.\n// we use this fallback marker post to drive this instead. see Feed.tsx\n// for the usage.\n// -prf\nexport const FALLBACK_MARKER_POST: AppBskyFeedDefs.FeedViewPost = {\n post: {\n uri: 'fallback-marker-post',\n cid: 'fake',\n record: {},\n author: {\n did: 'did:fake',\n handle: 'fake.com',\n },\n indexedAt: new Date().toISOString(),\n },\n}\n\nexport class HomeFeedAPI implements FeedAPI {\n agent: BskyAgent\n following: FollowingFeedAPI\n discover: CustomFeedAPI\n usingDiscover = false\n itemCursor = 0\n userInterests?: string\n\n constructor({\n userInterests,\n agent,\n }: {\n userInterests?: string\n agent: BskyAgent\n }) {\n this.agent = agent\n this.following = new FollowingFeedAPI({agent})\n this.discover = new CustomFeedAPI({\n agent,\n feedParams: {feed: PROD_DEFAULT_FEED('whats-hot')},\n })\n this.userInterests = userInterests\n }\n\n reset() {\n this.following = new FollowingFeedAPI({agent: this.agent})\n this.discover = new CustomFeedAPI({\n agent: this.agent,\n feedParams: {feed: PROD_DEFAULT_FEED('whats-hot')},\n userInterests: this.userInterests,\n })\n this.usingDiscover = false\n this.itemCursor = 0\n }\n\n async peekLatest(): Promise {\n if (this.usingDiscover) {\n return this.discover.peekLatest()\n }\n return this.following.peekLatest()\n }\n\n async fetch({\n cursor,\n limit,\n }: {\n cursor: string | undefined\n limit: number\n }): Promise {\n if (!cursor) {\n this.reset()\n }\n\n let returnCursor\n let posts: AppBskyFeedDefs.FeedViewPost[] = []\n\n if (!this.usingDiscover) {\n const res = await this.following.fetch({cursor, limit})\n returnCursor = res.cursor\n posts = posts.concat(res.feed)\n if (!returnCursor) {\n cursor = ''\n posts.push(FALLBACK_MARKER_POST)\n this.usingDiscover = true\n }\n }\n\n if (this.usingDiscover) {\n const res = await this.discover.fetch({cursor, limit})\n returnCursor = res.cursor\n posts = posts.concat(res.feed)\n }\n\n return {\n cursor: returnCursor,\n feed: posts,\n }\n }\n}\n","import {\n AppBskyFeedDefs,\n AppBskyFeedGetActorLikes as GetActorLikes,\n BskyAgent,\n} from '@atproto/api'\n\nimport {FeedAPI, FeedAPIResponse} from './types'\n\nexport class LikesFeedAPI implements FeedAPI {\n agent: BskyAgent\n params: GetActorLikes.QueryParams\n\n constructor({\n agent,\n feedParams,\n }: {\n agent: BskyAgent\n feedParams: GetActorLikes.QueryParams\n }) {\n this.agent = agent\n this.params = feedParams\n }\n\n async peekLatest(): Promise {\n const res = await this.agent.getActorLikes({\n ...this.params,\n limit: 1,\n })\n return res.data.feed[0]\n }\n\n async fetch({\n cursor,\n limit,\n }: {\n cursor: string | undefined\n limit: number\n }): Promise {\n const res = await this.agent.getActorLikes({\n ...this.params,\n cursor,\n limit,\n })\n if (res.success) {\n return {\n cursor: res.data.cursor,\n feed: res.data.feed,\n }\n }\n return {\n feed: [],\n }\n }\n}\n","import {\n AppBskyFeedDefs,\n AppBskyFeedGetListFeed as GetListFeed,\n BskyAgent,\n} from '@atproto/api'\n\nimport {FeedAPI, FeedAPIResponse} from './types'\n\nexport class ListFeedAPI implements FeedAPI {\n agent: BskyAgent\n params: GetListFeed.QueryParams\n\n constructor({\n agent,\n feedParams,\n }: {\n agent: BskyAgent\n feedParams: GetListFeed.QueryParams\n }) {\n this.agent = agent\n this.params = feedParams\n }\n\n async peekLatest(): Promise {\n const res = await this.agent.app.bsky.feed.getListFeed({\n ...this.params,\n limit: 1,\n })\n return res.data.feed[0]\n }\n\n async fetch({\n cursor,\n limit,\n }: {\n cursor: string | undefined\n limit: number\n }): Promise {\n const res = await this.agent.app.bsky.feed.getListFeed({\n ...this.params,\n cursor,\n limit,\n })\n if (res.success) {\n return {\n cursor: res.data.cursor,\n feed: res.data.feed,\n }\n }\n return {\n feed: [],\n }\n }\n}\n","import {\n AppBskyActorDefs,\n AppBskyEmbedRecord,\n AppBskyEmbedRecordWithMedia,\n AppBskyFeedDefs,\n AppBskyFeedPost,\n} from '@atproto/api'\n\nimport {isPostInLanguage} from '../../locale/helpers'\nimport {FALLBACK_MARKER_POST} from './feed/home'\nimport {ReasonFeedSource} from './feed/types'\n\ntype FeedViewPost = AppBskyFeedDefs.FeedViewPost\n\nexport type FeedTunerFn = (\n tuner: FeedTuner,\n slices: FeedViewPostsSlice[],\n dryRun: boolean,\n) => FeedViewPostsSlice[]\n\ntype FeedSliceItem = {\n post: AppBskyFeedDefs.PostView\n record: AppBskyFeedPost.Record\n parentAuthor: AppBskyActorDefs.ProfileViewBasic | undefined\n isParentBlocked: boolean\n isParentNotFound: boolean\n}\n\ntype AuthorContext = {\n author: AppBskyActorDefs.ProfileViewBasic\n parentAuthor: AppBskyActorDefs.ProfileViewBasic | undefined\n grandparentAuthor: AppBskyActorDefs.ProfileViewBasic | undefined\n rootAuthor: AppBskyActorDefs.ProfileViewBasic | undefined\n}\n\nexport class FeedViewPostsSlice {\n _reactKey: string\n _feedPost: FeedViewPost\n items: FeedSliceItem[]\n isIncompleteThread: boolean\n isFallbackMarker: boolean\n isOrphan: boolean\n rootUri: string\n\n constructor(feedPost: FeedViewPost) {\n const {post, reply, reason} = feedPost\n this.items = []\n this.isIncompleteThread = false\n this.isFallbackMarker = false\n this.isOrphan = false\n if (AppBskyFeedDefs.isPostView(reply?.root)) {\n this.rootUri = reply.root.uri\n } else {\n this.rootUri = post.uri\n }\n this._feedPost = feedPost\n this._reactKey = `slice-${post.uri}-${\n feedPost.reason?.indexedAt || post.indexedAt\n }`\n if (feedPost.post.uri === FALLBACK_MARKER_POST.post.uri) {\n this.isFallbackMarker = true\n return\n }\n if (\n !AppBskyFeedPost.isRecord(post.record) ||\n !AppBskyFeedPost.validateRecord(post.record).success\n ) {\n return\n }\n const parent = reply?.parent\n const isParentBlocked = AppBskyFeedDefs.isBlockedPost(parent)\n const isParentNotFound = AppBskyFeedDefs.isNotFoundPost(parent)\n let parentAuthor: AppBskyActorDefs.ProfileViewBasic | undefined\n if (AppBskyFeedDefs.isPostView(parent)) {\n parentAuthor = parent.author\n }\n this.items.push({\n post,\n record: post.record,\n parentAuthor,\n isParentBlocked,\n isParentNotFound,\n })\n if (!reply) {\n if (post.record.reply) {\n // This reply wasn't properly hydrated by the AppView.\n this.isOrphan = true\n this.items[0].isParentNotFound = true\n }\n return\n }\n if (reason) {\n return\n }\n if (\n !AppBskyFeedDefs.isPostView(parent) ||\n !AppBskyFeedPost.isRecord(parent.record) ||\n !AppBskyFeedPost.validateRecord(parent.record).success\n ) {\n this.isOrphan = true\n return\n }\n const root = reply.root\n const rootIsView =\n AppBskyFeedDefs.isPostView(root) ||\n AppBskyFeedDefs.isBlockedPost(root) ||\n AppBskyFeedDefs.isNotFoundPost(root)\n /*\n * If the parent is also the root, we just so happen to have the data we\n * need to compute if the parent's parent (grandparent) is blocked. This\n * doesn't always happen, of course, but we can take advantage of it when\n * it does.\n */\n const grandparent =\n rootIsView && parent.record.reply?.parent.uri === root.uri\n ? root\n : undefined\n const grandparentAuthor = reply.grandparentAuthor\n const isGrandparentBlocked = Boolean(\n grandparent && AppBskyFeedDefs.isBlockedPost(grandparent),\n )\n const isGrandparentNotFound = Boolean(\n grandparent && AppBskyFeedDefs.isNotFoundPost(grandparent),\n )\n this.items.unshift({\n post: parent,\n record: parent.record,\n parentAuthor: grandparentAuthor,\n isParentBlocked: isGrandparentBlocked,\n isParentNotFound: isGrandparentNotFound,\n })\n if (isGrandparentBlocked) {\n this.isOrphan = true\n // Keep going, it might still have a root, and we need this for thread\n // de-deduping\n }\n if (\n !AppBskyFeedDefs.isPostView(root) ||\n !AppBskyFeedPost.isRecord(root.record) ||\n !AppBskyFeedPost.validateRecord(root.record).success\n ) {\n this.isOrphan = true\n return\n }\n if (root.uri === parent.uri) {\n return\n }\n this.items.unshift({\n post: root,\n record: root.record,\n isParentBlocked: false,\n isParentNotFound: false,\n parentAuthor: undefined,\n })\n if (parent.record.reply?.parent.uri !== root.uri) {\n this.isIncompleteThread = true\n }\n }\n\n get isQuotePost() {\n const embed = this._feedPost.post.embed\n return (\n AppBskyEmbedRecord.isView(embed) ||\n AppBskyEmbedRecordWithMedia.isView(embed)\n )\n }\n\n get isReply() {\n return (\n AppBskyFeedPost.isRecord(this._feedPost.post.record) &&\n !!this._feedPost.post.record.reply\n )\n }\n\n get reason() {\n return '__source' in this._feedPost\n ? (this._feedPost.__source as ReasonFeedSource)\n : this._feedPost.reason\n }\n\n get feedContext() {\n return this._feedPost.feedContext\n }\n\n get isRepost() {\n const reason = this._feedPost.reason\n return AppBskyFeedDefs.isReasonRepost(reason)\n }\n\n get likeCount() {\n return this._feedPost.post.likeCount ?? 0\n }\n\n containsUri(uri: string) {\n return !!this.items.find(item => item.post.uri === uri)\n }\n\n getAuthors(): AuthorContext {\n const feedPost = this._feedPost\n let author: AppBskyActorDefs.ProfileViewBasic = feedPost.post.author\n let parentAuthor: AppBskyActorDefs.ProfileViewBasic | undefined\n let grandparentAuthor: AppBskyActorDefs.ProfileViewBasic | undefined\n let rootAuthor: AppBskyActorDefs.ProfileViewBasic | undefined\n if (feedPost.reply) {\n if (AppBskyFeedDefs.isPostView(feedPost.reply.parent)) {\n parentAuthor = feedPost.reply.parent.author\n }\n if (feedPost.reply.grandparentAuthor) {\n grandparentAuthor = feedPost.reply.grandparentAuthor\n }\n if (AppBskyFeedDefs.isPostView(feedPost.reply.root)) {\n rootAuthor = feedPost.reply.root.author\n }\n }\n return {\n author,\n parentAuthor,\n grandparentAuthor,\n rootAuthor,\n }\n }\n}\n\nexport class FeedTuner {\n seenKeys: Set = new Set()\n seenUris: Set = new Set()\n seenRootUris: Set = new Set()\n\n constructor(public tunerFns: FeedTunerFn[]) {}\n\n tune(\n feed: FeedViewPost[],\n {dryRun}: {dryRun: boolean} = {\n dryRun: false,\n },\n ): FeedViewPostsSlice[] {\n let slices: FeedViewPostsSlice[] = feed\n .map(item => new FeedViewPostsSlice(item))\n .filter(s => s.items.length > 0 || s.isFallbackMarker)\n\n // run the custom tuners\n for (const tunerFn of this.tunerFns) {\n slices = tunerFn(this, slices.slice(), dryRun)\n }\n\n slices = slices.filter(slice => {\n if (this.seenKeys.has(slice._reactKey)) {\n return false\n }\n // Some feeds, like Following, dedupe by thread, so you only see the most recent reply.\n // However, we don't want per-thread dedupe for author feeds (where we need to show every post)\n // or for feedgens (where we want to let the feed serve multiple replies if it chooses to).\n // To avoid showing the same context (root and/or parent) more than once, we do last resort\n // per-post deduplication. It hides already seen posts as long as this doesn't break the thread.\n for (let i = 0; i < slice.items.length; i++) {\n const item = slice.items[i]\n if (this.seenUris.has(item.post.uri)) {\n if (i === 0) {\n // Omit contiguous seen leading items.\n // For example, [A -> B -> C], [A -> D -> E], [A -> D -> F]\n // would turn into [A -> B -> C], [D -> E], [F].\n slice.items.splice(0, 1)\n i--\n }\n if (i === slice.items.length - 1) {\n // If the last item in the slice was already seen, omit the whole slice.\n // This means we'd miss its parents, but the user can \"show more\" to see them.\n // For example, [A ... E -> F], [A ... D -> E], [A ... C -> D], [A -> B -> C]\n // would get collapsed into [A ... E -> F], with B/C/D considered seen.\n return false\n }\n } else {\n if (!dryRun) {\n // Reposting a reply elevates it to top-level, so its parent/root won't be displayed.\n // Disable in-thread dedupe for this case since we don't want to miss them later.\n const disableDedupe = slice.isReply && slice.isRepost\n if (!disableDedupe) {\n this.seenUris.add(item.post.uri)\n }\n }\n }\n }\n if (!dryRun) {\n this.seenKeys.add(slice._reactKey)\n }\n return true\n })\n\n return slices\n }\n\n static removeReplies(\n tuner: FeedTuner,\n slices: FeedViewPostsSlice[],\n _dryRun: boolean,\n ) {\n for (let i = 0; i < slices.length; i++) {\n const slice = slices[i]\n if (\n slice.isReply &&\n !slice.isRepost &&\n // This is not perfect but it's close as we can get to\n // detecting threads without having to peek ahead.\n !areSameAuthor(slice.getAuthors())\n ) {\n slices.splice(i, 1)\n i--\n }\n }\n return slices\n }\n\n static removeReposts(\n tuner: FeedTuner,\n slices: FeedViewPostsSlice[],\n _dryRun: boolean,\n ) {\n for (let i = 0; i < slices.length; i++) {\n if (slices[i].isRepost) {\n slices.splice(i, 1)\n i--\n }\n }\n return slices\n }\n\n static removeQuotePosts(\n tuner: FeedTuner,\n slices: FeedViewPostsSlice[],\n _dryRun: boolean,\n ) {\n for (let i = 0; i < slices.length; i++) {\n if (slices[i].isQuotePost) {\n slices.splice(i, 1)\n i--\n }\n }\n return slices\n }\n\n static removeOrphans(\n tuner: FeedTuner,\n slices: FeedViewPostsSlice[],\n _dryRun: boolean,\n ) {\n for (let i = 0; i < slices.length; i++) {\n if (slices[i].isOrphan) {\n slices.splice(i, 1)\n i--\n }\n }\n return slices\n }\n\n static dedupThreads(\n tuner: FeedTuner,\n slices: FeedViewPostsSlice[],\n dryRun: boolean,\n ): FeedViewPostsSlice[] {\n for (let i = 0; i < slices.length; i++) {\n const rootUri = slices[i].rootUri\n if (!slices[i].isRepost && tuner.seenRootUris.has(rootUri)) {\n slices.splice(i, 1)\n i--\n } else {\n if (!dryRun) {\n tuner.seenRootUris.add(rootUri)\n }\n }\n }\n return slices\n }\n\n static followedRepliesOnly({userDid}: {userDid: string}) {\n return (\n tuner: FeedTuner,\n slices: FeedViewPostsSlice[],\n _dryRun: boolean,\n ): FeedViewPostsSlice[] => {\n for (let i = 0; i < slices.length; i++) {\n const slice = slices[i]\n if (\n slice.isReply &&\n !slice.isRepost &&\n !shouldDisplayReplyInFollowing(slice.getAuthors(), userDid)\n ) {\n slices.splice(i, 1)\n i--\n }\n }\n return slices\n }\n }\n\n /**\n * This function filters a list of FeedViewPostsSlice items based on whether they contain text in a\n * preferred language.\n * @param {string[]} preferredLangsCode2 - An array of preferred language codes in ISO 639-1 or ISO 639-2 format.\n * @returns A function that takes in a `FeedTuner` and an array of `FeedViewPostsSlice` objects and\n * returns an array of `FeedViewPostsSlice` objects.\n */\n static preferredLangOnly(preferredLangsCode2: string[]) {\n return (\n tuner: FeedTuner,\n slices: FeedViewPostsSlice[],\n _dryRun: boolean,\n ): FeedViewPostsSlice[] => {\n // early return if no languages have been specified\n if (!preferredLangsCode2.length || preferredLangsCode2.length === 0) {\n return slices\n }\n\n const candidateSlices = slices.filter(slice => {\n for (const item of slice.items) {\n if (isPostInLanguage(item.post, preferredLangsCode2)) {\n return true\n }\n }\n // if item does not fit preferred language, remove it\n return false\n })\n\n // if the language filter cleared out the entire page, return the original set\n // so that something always shows\n if (candidateSlices.length === 0) {\n return slices\n }\n\n return candidateSlices\n }\n }\n}\n\nfunction areSameAuthor(authors: AuthorContext): boolean {\n const {author, parentAuthor, grandparentAuthor, rootAuthor} = authors\n const authorDid = author.did\n if (parentAuthor && parentAuthor.did !== authorDid) {\n return false\n }\n if (grandparentAuthor && grandparentAuthor.did !== authorDid) {\n return false\n }\n if (rootAuthor && rootAuthor.did !== authorDid) {\n return false\n }\n return true\n}\n\nfunction shouldDisplayReplyInFollowing(\n authors: AuthorContext,\n userDid: string,\n): boolean {\n const {author, parentAuthor, grandparentAuthor, rootAuthor} = authors\n if (!isSelfOrFollowing(author, userDid)) {\n // Only show replies from self or people you follow.\n return false\n }\n if (\n (!parentAuthor || parentAuthor.did === author.did) &&\n (!rootAuthor || rootAuthor.did === author.did) &&\n (!grandparentAuthor || grandparentAuthor.did === author.did)\n ) {\n // Always show self-threads.\n return true\n }\n // From this point on we need at least one more reason to show it.\n if (\n parentAuthor &&\n parentAuthor.did !== author.did &&\n isSelfOrFollowing(parentAuthor, userDid)\n ) {\n return true\n }\n if (\n grandparentAuthor &&\n grandparentAuthor.did !== author.did &&\n isSelfOrFollowing(grandparentAuthor, userDid)\n ) {\n return true\n }\n if (\n rootAuthor &&\n rootAuthor.did !== author.did &&\n isSelfOrFollowing(rootAuthor, userDid)\n ) {\n return true\n }\n return false\n}\n\nfunction isSelfOrFollowing(\n profile: AppBskyActorDefs.ProfileViewBasic,\n userDid: string,\n) {\n return Boolean(profile.did === userDid || profile.viewer?.following)\n}\n","import {AppBskyFeedDefs, AppBskyFeedGetTimeline, BskyAgent} from '@atproto/api'\nimport shuffle from 'lodash.shuffle'\n\nimport {bundleAsync} from '#/lib/async/bundle'\nimport {timeout} from '#/lib/async/timeout'\nimport {feedUriToHref} from '#/lib/strings/url-helpers'\nimport {getContentLanguages} from '#/state/preferences/languages'\nimport {FeedParams} from '#/state/queries/post-feed'\nimport {FeedTuner} from '../feed-manip'\nimport {FeedTunerFn} from '../feed-manip'\nimport {FeedAPI, FeedAPIResponse, ReasonFeedSource} from './types'\nimport {createBskyTopicsHeader, isBlueskyOwnedFeed} from './utils'\n\nconst REQUEST_WAIT_MS = 500 // 500ms\nconst POST_AGE_CUTOFF = 60e3 * 60 * 24 // 24hours\n\nexport class MergeFeedAPI implements FeedAPI {\n userInterests?: string\n agent: BskyAgent\n params: FeedParams\n feedTuners: FeedTunerFn[]\n following: MergeFeedSource_Following\n customFeeds: MergeFeedSource_Custom[] = []\n feedCursor = 0\n itemCursor = 0\n sampleCursor = 0\n\n constructor({\n agent,\n feedParams,\n feedTuners,\n userInterests,\n }: {\n agent: BskyAgent\n feedParams: FeedParams\n feedTuners: FeedTunerFn[]\n userInterests?: string\n }) {\n this.agent = agent\n this.params = feedParams\n this.feedTuners = feedTuners\n this.userInterests = userInterests\n this.following = new MergeFeedSource_Following({\n agent: this.agent,\n feedTuners: this.feedTuners,\n })\n }\n\n reset() {\n this.following = new MergeFeedSource_Following({\n agent: this.agent,\n feedTuners: this.feedTuners,\n })\n this.customFeeds = []\n this.feedCursor = 0\n this.itemCursor = 0\n this.sampleCursor = 0\n if (this.params.mergeFeedSources) {\n this.customFeeds = shuffle(\n this.params.mergeFeedSources.map(\n feedUri =>\n new MergeFeedSource_Custom({\n agent: this.agent,\n feedUri,\n feedTuners: this.feedTuners,\n userInterests: this.userInterests,\n }),\n ),\n )\n } else {\n this.customFeeds = []\n }\n }\n\n async peekLatest(): Promise