diff --git a/src/App.native.tsx b/src/App.native.tsx index 609d316d4b..ca16f85f3d 100644 --- a/src/App.native.tsx +++ b/src/App.native.tsx @@ -29,6 +29,11 @@ import {Provider as A11yProvider} from '#/state/a11y' import {Provider as MutedThreadsProvider} from '#/state/cache/thread-mutes' import {Provider as DialogStateProvider} from '#/state/dialogs' import {listenSessionDropped} from '#/state/events' +import { + beginResolveGeolocation, + ensureGeolocationResolved, + Provider as GeolocationProvider, +} from '#/state/geolocation' import {Provider as InvitesStateProvider} from '#/state/invites' import {Provider as LightboxStateProvider} from '#/state/lightbox' import {MessagesProvider} from '#/state/messages' @@ -85,6 +90,7 @@ function InnerApp() { await initialize() await tryFetchGates(undefined, 'prefer-fresh-gates') } + await ensureGeolocationResolved() } catch (e) { logger.error(`session: resume failed`, {message: e}) } finally { @@ -158,9 +164,12 @@ function App() { const [isReady, setReady] = useState(false) React.useEffect(() => { + beginResolveGeolocation() PlatformInfo.setAudioCategory(AudioCategory.Ambient) PlatformInfo.setAudioActive(false) - initPersistedState().then(() => setReady(true)) + Promise.all([initPersistedState(), ensureGeolocationResolved()]).then(() => + setReady(true), + ) }, []) if (!isReady) { @@ -172,31 +181,33 @@ function App() { * that is set up in the InnerApp component above. */ return ( - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + ) } diff --git a/src/App.web.tsx b/src/App.web.tsx index 8531dc88d6..a3b0fc7702 100644 --- a/src/App.web.tsx +++ b/src/App.web.tsx @@ -18,6 +18,11 @@ import {Provider as A11yProvider} from '#/state/a11y' import {Provider as MutedThreadsProvider} from '#/state/cache/thread-mutes' import {Provider as DialogStateProvider} from '#/state/dialogs' import {listenSessionDropped} from '#/state/events' +import { + beginResolveGeolocation, + ensureGeolocationResolved, + Provider as GeolocationProvider, +} from '#/state/geolocation' import {Provider as InvitesStateProvider} from '#/state/invites' import {Provider as LightboxStateProvider} from '#/state/lightbox' import {MessagesProvider} from '#/state/messages' @@ -71,6 +76,7 @@ function InnerApp() { } finally { setIsReady(true) } + await ensureGeolocationResolved() } const account = readLastActiveAccount() onLaunch(account) @@ -139,7 +145,10 @@ function App() { const [isReady, setReady] = useState(false) React.useEffect(() => { - initPersistedState().then(() => setReady(true)) + beginResolveGeolocation() + Promise.all([initPersistedState(), ensureGeolocationResolved()]).then(() => + setReady(true), + ) }, []) if (!isReady) { @@ -151,29 +160,31 @@ function App() { * that is set up in the InnerApp component above. */ return ( - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + ) } diff --git a/src/lib/geo.ts b/src/lib/geo.ts deleted file mode 100644 index a51debc449..0000000000 --- a/src/lib/geo.ts +++ /dev/null @@ -1,25 +0,0 @@ -import {emitGeoUpdated} from '#/state/events' - -let geo: any - -export async function resolveGeo() { - const req = new Promise(y => { - setTimeout(() => { - try { - geo = {country_code: 'US'} - emitGeoUpdated({geo}) - } catch (e) { - } finally { - y(0) - } - }, 1000) - }) - - if (!geo) { - await req - } -} - -export function getGeo() { - return geo -} diff --git a/src/state/events.ts b/src/state/events.ts index 08536e5062..dcd36464ec 100644 --- a/src/state/events.ts +++ b/src/state/events.ts @@ -45,11 +45,3 @@ export function listenPostCreated(fn: () => void): UnlistenFn { emitter.on('post-created', fn) return () => emitter.off('post-created', fn) } - -export function emitGeoUpdated({geo}: {geo: any}) { - emitter.emit('geo-updated', geo) -} -export function listenGeoUpdated(fn: ({geo}: {geo: any}) => void): UnlistenFn { - emitter.on('geo-updated', fn) - return () => emitter.off('geo-updated', fn) -} diff --git a/src/state/geolocation.tsx b/src/state/geolocation.tsx new file mode 100644 index 0000000000..a71761c733 --- /dev/null +++ b/src/state/geolocation.tsx @@ -0,0 +1,156 @@ +import React from 'react' +import EventEmitter from 'eventemitter3' + +import {networkRetry} from '#/lib/async/retry' +import {logger} from '#/logger' +import {Device, device} from '#/storage' + +const events = new EventEmitter() +const EVENT = 'geolocation-updated' +const emitGeolocationUpdate = (geolocation: Device['geolocation']) => { + events.emit(EVENT, geolocation) +} +const onGeolocationUpdate = ( + listener: (geolocation: Device['geolocation']) => void, +) => { + events.on(EVENT, listener) + return () => { + events.off(EVENT, listener) + } +} + +/** + * Default geolocation value. IF undefined, we fail closed and apply all + * additional mod authorities. + */ +export const DEFAULT_GEOLOCATION: Device['geolocation'] = { + countryCode: undefined, +} + +async function getGeolocation(): Promise { + const res = await fetch(`https://api.bsky.app/xrpc/_health`) + + if (!res.ok) { + throw new Error(`geolocation: lookup failed ${res.status}`) + } + + const json = {country_code: 'US'} // await res.json() + + if (json.country_code) { + return { + countryCode: json.country_code, + } + } else { + return undefined + } +} + +/** + * Local promise used within this file only. + */ +let geolocationResolution: Promise | undefined + +/** + * Begin the process of resolving geolocation. This should be called once at + * app start. + * + * THIS METHOD SHOULD NEVER THROW. + * + * This method is otherwise not used for any purpose. To ensure geolocation is + * resolved, use {@link ensureGeolocationResolved} + */ +export function beginResolveGeolocation() { + geolocationResolution = new Promise(async resolve => { + try { + // Try once, fail fast + const geolocation = await getGeolocation() + if (geolocation) { + device.set(['geolocation'], geolocation) + emitGeolocationUpdate(geolocation) + } else { + // endpoint should throw on all failures, this is insurance + throw new Error(`geolocation: nothing returned from initial request`) + } + } catch (e: any) { + logger.error(`geolocation: failed initial request`, { + safeMessage: e.message, + }) + + // set to default + device.set(['geolocation'], DEFAULT_GEOLOCATION) + + // retry 3 times, but don't await, proceed with default + networkRetry(3, getGeolocation) + .then(geolocation => { + if (geolocation) { + device.set(['geolocation'], geolocation) + emitGeolocationUpdate(geolocation) + } else { + // endpoint should throw on all failures, this is insurance + throw new Error(`geolocation: nothing returned from retries`) + } + }) + .catch((e: any) => { + // complete fail closed + logger.error(`geolocation: failed retries`, {safeMessage: e.message}) + }) + } finally { + resolve(undefined) + } + }) +} + +/** + * Ensure that geolocation has been resolved, or at the very least attempted + * once. Subsequent retries will not be captured by this `await`. Those will be + * reported via {@link events}. + */ +export async function ensureGeolocationResolved() { + if (!geolocationResolution) { + throw new Error(`geolocation: beginResolveGeolocation not called yet`) + } + + const cached = device.get(['geolocation']) + if (cached) { + logger.info(`geolocation: using cache`, {cached}) + } else { + logger.info(`geolocation: no cache`) + await geolocationResolution + logger.info(`geolocation: resolved`, { + resolved: device.get(['geolocation']), + }) + } +} + +type Context = { + geolocation: Device['geolocation'] +} + +const context = React.createContext({ + geolocation: DEFAULT_GEOLOCATION, +}) + +export function Provider({children}: {children: React.ReactNode}) { + const [geolocation, setGeolocation] = React.useState(() => { + const initial = device.get(['geolocation']) || DEFAULT_GEOLOCATION + return initial + }) + + React.useEffect(() => { + return onGeolocationUpdate(geolocation => { + setGeolocation(geolocation!) + }) + }, []) + + const ctx = React.useMemo(() => { + return { + geolocation, + } + }, [geolocation]) + + return {children} +} + +export function useGeolocation() { + return React.useContext(context) +} diff --git a/src/state/session/additional-moderation-authorities.ts b/src/state/session/additional-moderation-authorities.ts index 64cce37ad2..6de51e8f70 100644 --- a/src/state/session/additional-moderation-authorities.ts +++ b/src/state/session/additional-moderation-authorities.ts @@ -1,9 +1,17 @@ import {BskyAgent} from '@atproto/api' +import {logger} from '#/logger' +import {device} from '#/storage' + export const ADDITIONAL_LABELER = 'did:plc:oz5zavafp7szpd2yyko57ccz' -export const ADDITIONAL_LABELERS_MAP = { - category: [ADDITIONAL_LABELER], +export const ADDITIONAL_LABELERS_MAP: { + [countryCode: string]: string[] +} = { + US: [ADDITIONAL_LABELER], } +export const ALL_ADDITIONAL_LABELERS = Object.values( + ADDITIONAL_LABELERS_MAP, +).flat() export const NON_CONFIGURABLE_LABELERS = [ADDITIONAL_LABELER] export function isNonConfigurableModerationAuthority(did: string) { @@ -11,10 +19,23 @@ export function isNonConfigurableModerationAuthority(did: string) { } export function configureAdditionalModerationAuthorities() { - BskyAgent.configure({ - appLabelers: [ - ...BskyAgent.appLabelers, - ...(ADDITIONAL_LABELERS_MAP.category ?? []), - ], + const geolocation = device.get(['geolocation']) + let additionalLabelers: string[] = ALL_ADDITIONAL_LABELERS + + if (geolocation?.countryCode) { + additionalLabelers = ADDITIONAL_LABELERS_MAP[geolocation.countryCode] ?? [] + } else { + logger.info(`no geolocation, cannot apply mod authorities`) + } + + const appLabelers = Array.from( + new Set([...BskyAgent.appLabelers, ...additionalLabelers]), + ) + + logger.debug(`applying mod authorities`, { + additionalLabelers, + appLabelers, }) + + BskyAgent.configure({appLabelers}) } diff --git a/src/storage/index.ts b/src/storage/index.ts index 819ffab7ec..ad5e58032f 100644 --- a/src/storage/index.ts +++ b/src/storage/index.ts @@ -2,6 +2,8 @@ import {MMKV} from 'react-native-mmkv' import {Device} from '#/storage/schema' +export * from '#/storage/schema' + /** * Generic storage class. DO NOT use this directly. Instead, use the exported * storage instances below. @@ -69,4 +71,4 @@ export class Storage { * * `device.set([key], true)` */ -export const device = new Storage<[], Device>({id: 'device'}) +export const device = new Storage<[], Device>({id: 'bsky_device'}) diff --git a/src/storage/schema.ts b/src/storage/schema.ts index 6522d75a36..1f6f484774 100644 --- a/src/storage/schema.ts +++ b/src/storage/schema.ts @@ -1,4 +1,8 @@ /** * Device data that's specific to the device and does not vary based account */ -export type Device = {} +export type Device = { + geolocation?: { + countryCode: string | undefined + } +}