From c85a271ef63ac006bf10f71adae102552298b661 Mon Sep 17 00:00:00 2001 From: Eric Bailey Date: Fri, 20 Sep 2024 14:16:23 -0500 Subject: [PATCH] Additional moderation (#5172) * Set up additional mod authorities * Filter out non-configurable mod authorities * WIP * Working * Cleanup, add mod * Cleanup * Add more debug logs * Tweak logs * Filter out imperative labels from typeaheads * Filter hideable content from notifications * Add api * Fall back in dev * Remove space * Use prod endpoint * Add tiny notice * Add notice to labeler card, show all labelers --- src/App.native.tsx | 76 ++++---- src/App.web.tsx | 66 ++++--- src/components/LabelingServiceCard/index.tsx | 28 ++- src/lib/moderation.ts | 14 ++ src/screens/Moderation/index.tsx | 6 +- src/state/geolocation.tsx | 169 ++++++++++++++++++ src/state/queries/actor-autocomplete.ts | 8 +- src/state/queries/notifications/util.ts | 5 + .../additional-moderation-authorities.ts | 41 +++++ src/state/session/moderation.ts | 4 + src/storage/index.ts | 10 +- src/storage/schema.ts | 3 + 12 files changed, 365 insertions(+), 65 deletions(-) create mode 100644 src/state/geolocation.tsx create mode 100644 src/state/session/additional-moderation-authorities.ts diff --git a/src/App.native.tsx b/src/App.native.tsx index 2ec666e2cc..e2fcd6d2ec 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' @@ -66,6 +71,11 @@ import {BackgroundNotificationPreferencesProvider} from '../modules/expo-backgro SplashScreen.preventAutoHideAsync() +/** + * Begin geolocation ASAP + */ +beginResolveGeolocation() + function InnerApp() { const [isReady, setIsReady] = React.useState(false) const {currentAccount} = useSession() @@ -158,7 +168,9 @@ function App() { const [isReady, setReady] = useState(false) React.useEffect(() => { - initPersistedState().then(() => setReady(true)) + Promise.all([initPersistedState(), ensureGeolocationResolved()]).then(() => + setReady(true), + ) }, []) if (!isReady) { @@ -170,36 +182,38 @@ 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 fa5f1de934..c81ed10d33 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' @@ -54,6 +59,11 @@ import {Provider as IntentDialogProvider} from '#/components/intents/IntentDialo import {Provider as PortalProvider} from '#/components/Portal' import {BackgroundNotificationPreferencesProvider} from '../modules/expo-background-notification-handler/src/BackgroundNotificationHandlerProvider' +/** + * Begin geolocation ASAP + */ +beginResolveGeolocation() + function InnerApp() { const [isReady, setIsReady] = React.useState(false) const {currentAccount} = useSession() @@ -148,7 +158,9 @@ function App() { const [isReady, setReady] = useState(false) React.useEffect(() => { - initPersistedState().then(() => setReady(true)) + Promise.all([initPersistedState(), ensureGeolocationResolved()]).then(() => + setReady(true), + ) }, []) if (!isReady) { @@ -160,31 +172,33 @@ function App() { * that is set up in the InnerApp component above. */ return ( - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + ) } diff --git a/src/components/LabelingServiceCard/index.tsx b/src/components/LabelingServiceCard/index.tsx index 542f2d2993..ff32c36bc9 100644 --- a/src/components/LabelingServiceCard/index.tsx +++ b/src/components/LabelingServiceCard/index.tsx @@ -9,6 +9,7 @@ import {sanitizeHandle} from '#/lib/strings/handles' import {useLabelerInfoQuery} from '#/state/queries/labeler' import {UserAvatar} from '#/view/com/util/UserAvatar' import {atoms as a, useTheme, ViewStyleProp} from '#/alf' +import {Flag_Stroke2_Corner0_Rounded as Flag} from '#/components/icons/Flag' import {Link as InternalLink, LinkProps} from '#/components/Link' import {RichText} from '#/components/RichText' import {Text} from '#/components/Typography' @@ -43,21 +44,40 @@ export function Avatar({avatar}: {avatar?: string}) { } export function Title({value}: {value: string}) { - return {value} + return {value} } export function Description({value, handle}: {value?: string; handle: string}) { return value ? ( - + ) : ( - + By {sanitizeHandle(handle, '@')} ) } +export function RegionalNotice() { + const t = useTheme() + return ( + + + + Required in your region + + + ) +} + export function LikeCount({count}: {count: number}) { const t = useTheme() return ( @@ -85,7 +105,7 @@ export function Content({children}: React.PropsWithChildren<{}>) { a.align_center, a.justify_between, ]}> - {children} + {children} diff --git a/src/lib/moderation.ts b/src/lib/moderation.ts index 59d88023bf..7576a9c33c 100644 --- a/src/lib/moderation.ts +++ b/src/lib/moderation.ts @@ -33,6 +33,20 @@ export function isJustAMute(modui: ModerationUI): boolean { return modui.filters.length === 1 && modui.filters[0].type === 'muted' } +export function moduiContainsHideableOffense(modui: ModerationUI): boolean { + const label = modui.filters.at(0) + if (label && label.type === 'label') { + return labelIsHideableOffense(label.label) + } + return false +} + +export function labelIsHideableOffense( + label: ComAtprotoLabelDefs.Label, +): boolean { + return ['!hide', '!takedown'].includes(label.val) +} + export function getLabelingServiceTitle({ displayName, handle, diff --git a/src/screens/Moderation/index.tsx b/src/screens/Moderation/index.tsx index cd3179674c..ad59c42dc2 100644 --- a/src/screens/Moderation/index.tsx +++ b/src/screens/Moderation/index.tsx @@ -7,6 +7,7 @@ import {msg, Trans} from '@lingui/macro' import {useLingui} from '@lingui/react' import {useFocusEffect} from '@react-navigation/native' +import {useAnalytics} from '#/lib/analytics/analytics' import {getLabelingServiceTitle} from '#/lib/moderation' import {CommonNavigatorParams, NativeStackScreenProps} from '#/lib/routes/types' import {logger} from '#/logger' @@ -22,8 +23,8 @@ import { useProfileUpdateMutation, } from '#/state/queries/profile' import {useSession} from '#/state/session' +import {isNonConfigurableModerationAuthority} from '#/state/session/additional-moderation-authorities' import {useSetMinimalShellMode} from '#/state/shell' -import {useAnalytics} from 'lib/analytics/analytics' import {ViewHeader} from '#/view/com/util/ViewHeader' import {CenteredView} from '#/view/com/util/Views' import {ScrollView} from '#/view/com/util/Views' @@ -455,6 +456,9 @@ export function ModerationScreenInner({ value={labeler.creator.description} handle={labeler.creator.handle} /> + {isNonConfigurableModerationAuthority( + labeler.creator.did, + ) && } )} diff --git a/src/state/geolocation.tsx b/src/state/geolocation.tsx new file mode 100644 index 0000000000..4d45bb574b --- /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 { + 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 | 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({ + 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/queries/actor-autocomplete.ts b/src/state/queries/actor-autocomplete.ts index abf78da3ce..acc0467715 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 e0ee02294e..a251d170ec 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 0000000000..c594294b2a --- /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 d8ded90f69..01684fe0ba 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() { diff --git a/src/storage/index.ts b/src/storage/index.ts index 4be08170dd..7ef226d3aa 100644 --- a/src/storage/index.ts +++ b/src/storage/index.ts @@ -1,5 +1,6 @@ import {MMKV} from 'react-native-mmkv' +import {IS_DEV} from '#/env' import {Device} from '#/storage/schema' export * from '#/storage/schema' @@ -71,4 +72,11 @@ export class Storage { * * `device.set([key], true)` */ -export const device = new Storage<[], Device>({id: 'device'}) +export const device = new Storage<[], Device>({id: 'bsky_device'}) + +if (IS_DEV && typeof window !== 'undefined') { + // @ts-ignore + window.bsky_storage = { + device, + } +} diff --git a/src/storage/schema.ts b/src/storage/schema.ts index 1a9656fede..cf410c77de 100644 --- a/src/storage/schema.ts +++ b/src/storage/schema.ts @@ -5,4 +5,7 @@ export type Device = { fontScale: '-2' | '-1' | '0' | '1' | '2' fontFamily: 'system' | 'theme' lastNuxDialog: string | undefined + geolocation?: { + countryCode: string | undefined + } }