diff options
Diffstat (limited to 'src/state')
-rw-r--r-- | src/state/geolocation.tsx | 169 | ||||
-rw-r--r-- | src/state/queries/actor-autocomplete.ts | 8 | ||||
-rw-r--r-- | src/state/queries/notifications/util.ts | 5 | ||||
-rw-r--r-- | src/state/session/additional-moderation-authorities.ts | 41 | ||||
-rw-r--r-- | src/state/session/moderation.ts | 4 |
5 files changed, 225 insertions, 2 deletions
diff --git a/src/state/geolocation.tsx b/src/state/geolocation.tsx new file mode 100644 index 000000000..4d45bb574 --- /dev/null +++ b/src/state/geolocation.tsx @@ -0,0 +1,169 @@ +import React from 'react' +import EventEmitter from 'eventemitter3' + +import {networkRetry} from '#/lib/async/retry' +import {logger} from '#/logger' +import {IS_DEV} from '#/env' +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<Device['geolocation']> { + const res = await fetch(`https://bsky.app/ipcc`) + + if (!res.ok) { + throw new Error(`geolocation: lookup failed ${res.status}`) + } + + const json = await res.json() + + if (json.countryCode) { + return { + countryCode: json.countryCode, + } + } else { + return undefined + } +} + +/** + * Local promise used within this file only. + */ +let geolocationResolution: Promise<void> | 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() { + /** + * In dev, IP server is unavailable, so we just set the default geolocation + * and fail closed. + */ + if (IS_DEV) { + geolocationResolution = new Promise(y => y()) + device.set(['geolocation'], DEFAULT_GEOLOCATION) + return + } + + geolocationResolution = new Promise(async resolve => { + try { + // Try once, fail fast + const geolocation = await getGeolocation() + if (geolocation) { + device.set(['geolocation'], geolocation) + emitGeolocationUpdate(geolocation) + logger.debug(`geolocation: success`, {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) + logger.debug(`geolocation: success`, {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.debug(`geolocation: using cache`, {cached}) + } else { + logger.debug(`geolocation: no cache`) + await geolocationResolution + logger.debug(`geolocation: resolved`, { + resolved: device.get(['geolocation']), + }) + } +} + +type Context = { + geolocation: Device['geolocation'] +} + +const context = React.createContext<Context>({ + 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 <context.Provider value={ctx}>{children}</context.Provider> +} + +export function useGeolocation() { + return React.useContext(context) +} diff --git a/src/state/queries/actor-autocomplete.ts b/src/state/queries/actor-autocomplete.ts index abf78da3c..acc046771 100644 --- a/src/state/queries/actor-autocomplete.ts +++ b/src/state/queries/actor-autocomplete.ts @@ -2,7 +2,7 @@ import React from 'react' import {AppBskyActorDefs, moderateProfile, ModerationOpts} from '@atproto/api' import {keepPreviousData, useQuery, useQueryClient} from '@tanstack/react-query' -import {isJustAMute} from '#/lib/moderation' +import {isJustAMute, moduiContainsHideableOffense} from '#/lib/moderation' import {logger} from '#/logger' import {STALE} from '#/state/queries' import {useAgent} from '#/state/session' @@ -113,6 +113,10 @@ function computeSuggestions({ return items.filter(profile => { const modui = moderateProfile(profile, moderationOpts).ui('profileList') const isExactMatch = q && profile.handle.toLowerCase() === q - return isExactMatch || !modui.filter || isJustAMute(modui) + return ( + (isExactMatch && !moduiContainsHideableOffense(modui)) || + !modui.filter || + isJustAMute(modui) + ) }) } diff --git a/src/state/queries/notifications/util.ts b/src/state/queries/notifications/util.ts index e0ee02294..a251d170e 100644 --- a/src/state/queries/notifications/util.ts +++ b/src/state/queries/notifications/util.ts @@ -13,6 +13,7 @@ import { import {QueryClient} from '@tanstack/react-query' import chunk from 'lodash.chunk' +import {labelIsHideableOffense} from '#/lib/moderation' import {precacheProfile} from '../profile' import {FeedNotification, FeedPage, NotificationType} from './types' @@ -104,6 +105,10 @@ export function shouldFilterNotif( notif: AppBskyNotificationListNotifications.Notification, moderationOpts: ModerationOpts | undefined, ): boolean { + const containsImperative = !!notif.author.labels?.some(labelIsHideableOffense) + if (containsImperative) { + return true + } if (!moderationOpts) { return false } diff --git a/src/state/session/additional-moderation-authorities.ts b/src/state/session/additional-moderation-authorities.ts new file mode 100644 index 000000000..c594294b2 --- /dev/null +++ b/src/state/session/additional-moderation-authorities.ts @@ -0,0 +1,41 @@ +import {BskyAgent} from '@atproto/api' + +import {logger} from '#/logger' +import {device} from '#/storage' + +export const BR_LABELER = 'did:plc:ekitcvx7uwnauoqy5oest3hm' +export const ADDITIONAL_LABELERS_MAP: { + [countryCode: string]: string[] +} = { + BR: [BR_LABELER], +} +export const ALL_ADDITIONAL_LABELERS = Object.values( + ADDITIONAL_LABELERS_MAP, +).flat() +export const NON_CONFIGURABLE_LABELERS = [BR_LABELER] + +export function isNonConfigurableModerationAuthority(did: string) { + return NON_CONFIGURABLE_LABELERS.includes(did) +} + +export function configureAdditionalModerationAuthorities() { + 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.info(`applying mod authorities`, { + additionalLabelers, + appLabelers, + }) + + BskyAgent.configure({appLabelers}) +} diff --git a/src/state/session/moderation.ts b/src/state/session/moderation.ts index d8ded90f6..01684fe0b 100644 --- a/src/state/session/moderation.ts +++ b/src/state/session/moderation.ts @@ -1,6 +1,7 @@ import {BSKY_LABELER_DID, BskyAgent} from '@atproto/api' import {IS_TEST_USER} from '#/lib/constants' +import {configureAdditionalModerationAuthorities} from './additional-moderation-authorities' import {readLabelers} from './agent-config' import {SessionAccount} from './types' @@ -8,6 +9,7 @@ export function configureModerationForGuest() { // This global mutation is *only* OK because this code is only relevant for testing. // Don't add any other global behavior here! switchToBskyAppLabeler() + configureAdditionalModerationAuthorities() } export async function configureModerationForAccount( @@ -31,6 +33,8 @@ export async function configureModerationForAccount( // If there are no headers in the storage, we'll not send them on the initial requests. // If we wanted to fix this, we could block on the preferences query here. } + + configureAdditionalModerationAuthorities() } function switchToBskyAppLabeler() { |