An ATproto social media client -- with an independent Appview.

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

authored by

Eric Bailey and committed by
GitHub
c85a271e 395edfe7

+365 -65
+45 -31
src/App.native.tsx
··· 29 29 import {Provider as MutedThreadsProvider} from '#/state/cache/thread-mutes' 30 30 import {Provider as DialogStateProvider} from '#/state/dialogs' 31 31 import {listenSessionDropped} from '#/state/events' 32 + import { 33 + beginResolveGeolocation, 34 + ensureGeolocationResolved, 35 + Provider as GeolocationProvider, 36 + } from '#/state/geolocation' 32 37 import {Provider as InvitesStateProvider} from '#/state/invites' 33 38 import {Provider as LightboxStateProvider} from '#/state/lightbox' 34 39 import {MessagesProvider} from '#/state/messages' ··· 65 70 import {BackgroundNotificationPreferencesProvider} from '../modules/expo-background-notification-handler/src/BackgroundNotificationHandlerProvider' 66 71 67 72 SplashScreen.preventAutoHideAsync() 73 + 74 + /** 75 + * Begin geolocation ASAP 76 + */ 77 + beginResolveGeolocation() 68 78 69 79 function InnerApp() { 70 80 const [isReady, setIsReady] = React.useState(false) ··· 158 168 const [isReady, setReady] = useState(false) 159 169 160 170 React.useEffect(() => { 161 - initPersistedState().then(() => setReady(true)) 171 + Promise.all([initPersistedState(), ensureGeolocationResolved()]).then(() => 172 + setReady(true), 173 + ) 162 174 }, []) 163 175 164 176 if (!isReady) { ··· 170 182 * that is set up in the InnerApp component above. 171 183 */ 172 184 return ( 173 - <A11yProvider> 174 - <KeyboardProvider enabled={false} statusBarTranslucent={true}> 175 - <SessionProvider> 176 - <PrefsStateProvider> 177 - <I18nProvider> 178 - <ShellStateProvider> 179 - <InvitesStateProvider> 180 - <ModalStateProvider> 181 - <DialogStateProvider> 182 - <LightboxStateProvider> 183 - <PortalProvider> 184 - <StarterPackProvider> 185 - <SafeAreaProvider 186 - initialMetrics={initialWindowMetrics}> 187 - <IntentDialogProvider> 188 - <InnerApp /> 189 - </IntentDialogProvider> 190 - </SafeAreaProvider> 191 - </StarterPackProvider> 192 - </PortalProvider> 193 - </LightboxStateProvider> 194 - </DialogStateProvider> 195 - </ModalStateProvider> 196 - </InvitesStateProvider> 197 - </ShellStateProvider> 198 - </I18nProvider> 199 - </PrefsStateProvider> 200 - </SessionProvider> 201 - </KeyboardProvider> 202 - </A11yProvider> 185 + <GeolocationProvider> 186 + <A11yProvider> 187 + <KeyboardProvider enabled={false} statusBarTranslucent={true}> 188 + <SessionProvider> 189 + <PrefsStateProvider> 190 + <I18nProvider> 191 + <ShellStateProvider> 192 + <InvitesStateProvider> 193 + <ModalStateProvider> 194 + <DialogStateProvider> 195 + <LightboxStateProvider> 196 + <PortalProvider> 197 + <StarterPackProvider> 198 + <SafeAreaProvider 199 + initialMetrics={initialWindowMetrics}> 200 + <IntentDialogProvider> 201 + <InnerApp /> 202 + </IntentDialogProvider> 203 + </SafeAreaProvider> 204 + </StarterPackProvider> 205 + </PortalProvider> 206 + </LightboxStateProvider> 207 + </DialogStateProvider> 208 + </ModalStateProvider> 209 + </InvitesStateProvider> 210 + </ShellStateProvider> 211 + </I18nProvider> 212 + </PrefsStateProvider> 213 + </SessionProvider> 214 + </KeyboardProvider> 215 + </A11yProvider> 216 + </GeolocationProvider> 203 217 ) 204 218 } 205 219
+40 -26
src/App.web.tsx
··· 18 18 import {Provider as MutedThreadsProvider} from '#/state/cache/thread-mutes' 19 19 import {Provider as DialogStateProvider} from '#/state/dialogs' 20 20 import {listenSessionDropped} from '#/state/events' 21 + import { 22 + beginResolveGeolocation, 23 + ensureGeolocationResolved, 24 + Provider as GeolocationProvider, 25 + } from '#/state/geolocation' 21 26 import {Provider as InvitesStateProvider} from '#/state/invites' 22 27 import {Provider as LightboxStateProvider} from '#/state/lightbox' 23 28 import {MessagesProvider} from '#/state/messages' ··· 53 58 import {Provider as IntentDialogProvider} from '#/components/intents/IntentDialogs' 54 59 import {Provider as PortalProvider} from '#/components/Portal' 55 60 import {BackgroundNotificationPreferencesProvider} from '../modules/expo-background-notification-handler/src/BackgroundNotificationHandlerProvider' 61 + 62 + /** 63 + * Begin geolocation ASAP 64 + */ 65 + beginResolveGeolocation() 56 66 57 67 function InnerApp() { 58 68 const [isReady, setIsReady] = React.useState(false) ··· 148 158 const [isReady, setReady] = useState(false) 149 159 150 160 React.useEffect(() => { 151 - initPersistedState().then(() => setReady(true)) 161 + Promise.all([initPersistedState(), ensureGeolocationResolved()]).then(() => 162 + setReady(true), 163 + ) 152 164 }, []) 153 165 154 166 if (!isReady) { ··· 160 172 * that is set up in the InnerApp component above. 161 173 */ 162 174 return ( 163 - <A11yProvider> 164 - <SessionProvider> 165 - <PrefsStateProvider> 166 - <I18nProvider> 167 - <ShellStateProvider> 168 - <InvitesStateProvider> 169 - <ModalStateProvider> 170 - <DialogStateProvider> 171 - <LightboxStateProvider> 172 - <PortalProvider> 173 - <StarterPackProvider> 174 - <IntentDialogProvider> 175 - <InnerApp /> 176 - </IntentDialogProvider> 177 - </StarterPackProvider> 178 - </PortalProvider> 179 - </LightboxStateProvider> 180 - </DialogStateProvider> 181 - </ModalStateProvider> 182 - </InvitesStateProvider> 183 - </ShellStateProvider> 184 - </I18nProvider> 185 - </PrefsStateProvider> 186 - </SessionProvider> 187 - </A11yProvider> 175 + <GeolocationProvider> 176 + <A11yProvider> 177 + <SessionProvider> 178 + <PrefsStateProvider> 179 + <I18nProvider> 180 + <ShellStateProvider> 181 + <InvitesStateProvider> 182 + <ModalStateProvider> 183 + <DialogStateProvider> 184 + <LightboxStateProvider> 185 + <PortalProvider> 186 + <StarterPackProvider> 187 + <IntentDialogProvider> 188 + <InnerApp /> 189 + </IntentDialogProvider> 190 + </StarterPackProvider> 191 + </PortalProvider> 192 + </LightboxStateProvider> 193 + </DialogStateProvider> 194 + </ModalStateProvider> 195 + </InvitesStateProvider> 196 + </ShellStateProvider> 197 + </I18nProvider> 198 + </PrefsStateProvider> 199 + </SessionProvider> 200 + </A11yProvider> 201 + </GeolocationProvider> 188 202 ) 189 203 } 190 204
+24 -4
src/components/LabelingServiceCard/index.tsx
··· 9 9 import {useLabelerInfoQuery} from '#/state/queries/labeler' 10 10 import {UserAvatar} from '#/view/com/util/UserAvatar' 11 11 import {atoms as a, useTheme, ViewStyleProp} from '#/alf' 12 + import {Flag_Stroke2_Corner0_Rounded as Flag} from '#/components/icons/Flag' 12 13 import {Link as InternalLink, LinkProps} from '#/components/Link' 13 14 import {RichText} from '#/components/RichText' 14 15 import {Text} from '#/components/Typography' ··· 43 44 } 44 45 45 46 export function Title({value}: {value: string}) { 46 - return <Text style={[a.text_md, a.font_bold]}>{value}</Text> 47 + return <Text style={[a.text_md, a.font_bold, a.leading_tight]}>{value}</Text> 47 48 } 48 49 49 50 export function Description({value, handle}: {value?: string; handle: string}) { 50 51 return value ? ( 51 52 <Text numberOfLines={2}> 52 - <RichText value={value} style={[]} /> 53 + <RichText value={value} style={[a.leading_snug]} /> 53 54 </Text> 54 55 ) : ( 55 - <Text> 56 + <Text style={[a.leading_snug]}> 56 57 <Trans>By {sanitizeHandle(handle, '@')}</Trans> 57 58 </Text> 58 59 ) 59 60 } 60 61 62 + export function RegionalNotice() { 63 + const t = useTheme() 64 + return ( 65 + <View 66 + style={[ 67 + a.flex_row, 68 + a.align_center, 69 + a.gap_xs, 70 + a.pt_2xs, 71 + {marginLeft: -2}, 72 + ]}> 73 + <Flag fill={t.atoms.text_contrast_low.color} size="sm" /> 74 + <Text style={[a.italic, a.leading_snug]}> 75 + <Trans>Required in your region</Trans> 76 + </Text> 77 + </View> 78 + ) 79 + } 80 + 61 81 export function LikeCount({count}: {count: number}) { 62 82 const t = useTheme() 63 83 return ( ··· 85 105 a.align_center, 86 106 a.justify_between, 87 107 ]}> 88 - <View style={[a.gap_xs, a.flex_1]}>{children}</View> 108 + <View style={[a.gap_2xs, a.flex_1]}>{children}</View> 89 109 90 110 <ChevronRight size="md" style={[a.z_10, t.atoms.text_contrast_low]} /> 91 111 </View>
+14
src/lib/moderation.ts
··· 33 33 return modui.filters.length === 1 && modui.filters[0].type === 'muted' 34 34 } 35 35 36 + export function moduiContainsHideableOffense(modui: ModerationUI): boolean { 37 + const label = modui.filters.at(0) 38 + if (label && label.type === 'label') { 39 + return labelIsHideableOffense(label.label) 40 + } 41 + return false 42 + } 43 + 44 + export function labelIsHideableOffense( 45 + label: ComAtprotoLabelDefs.Label, 46 + ): boolean { 47 + return ['!hide', '!takedown'].includes(label.val) 48 + } 49 + 36 50 export function getLabelingServiceTitle({ 37 51 displayName, 38 52 handle,
+5 -1
src/screens/Moderation/index.tsx
··· 7 7 import {useLingui} from '@lingui/react' 8 8 import {useFocusEffect} from '@react-navigation/native' 9 9 10 + import {useAnalytics} from '#/lib/analytics/analytics' 10 11 import {getLabelingServiceTitle} from '#/lib/moderation' 11 12 import {CommonNavigatorParams, NativeStackScreenProps} from '#/lib/routes/types' 12 13 import {logger} from '#/logger' ··· 22 23 useProfileUpdateMutation, 23 24 } from '#/state/queries/profile' 24 25 import {useSession} from '#/state/session' 26 + import {isNonConfigurableModerationAuthority} from '#/state/session/additional-moderation-authorities' 25 27 import {useSetMinimalShellMode} from '#/state/shell' 26 - import {useAnalytics} from 'lib/analytics/analytics' 27 28 import {ViewHeader} from '#/view/com/util/ViewHeader' 28 29 import {CenteredView} from '#/view/com/util/Views' 29 30 import {ScrollView} from '#/view/com/util/Views' ··· 455 456 value={labeler.creator.description} 456 457 handle={labeler.creator.handle} 457 458 /> 459 + {isNonConfigurableModerationAuthority( 460 + labeler.creator.did, 461 + ) && <LabelingService.RegionalNotice />} 458 462 </LabelingService.Content> 459 463 </LabelingService.Outer> 460 464 )}
+169
src/state/geolocation.tsx
··· 1 + import React from 'react' 2 + import EventEmitter from 'eventemitter3' 3 + 4 + import {networkRetry} from '#/lib/async/retry' 5 + import {logger} from '#/logger' 6 + import {IS_DEV} from '#/env' 7 + import {Device, device} from '#/storage' 8 + 9 + const events = new EventEmitter() 10 + const EVENT = 'geolocation-updated' 11 + const emitGeolocationUpdate = (geolocation: Device['geolocation']) => { 12 + events.emit(EVENT, geolocation) 13 + } 14 + const onGeolocationUpdate = ( 15 + listener: (geolocation: Device['geolocation']) => void, 16 + ) => { 17 + events.on(EVENT, listener) 18 + return () => { 19 + events.off(EVENT, listener) 20 + } 21 + } 22 + 23 + /** 24 + * Default geolocation value. IF undefined, we fail closed and apply all 25 + * additional mod authorities. 26 + */ 27 + export const DEFAULT_GEOLOCATION: Device['geolocation'] = { 28 + countryCode: undefined, 29 + } 30 + 31 + async function getGeolocation(): Promise<Device['geolocation']> { 32 + const res = await fetch(`https://bsky.app/ipcc`) 33 + 34 + if (!res.ok) { 35 + throw new Error(`geolocation: lookup failed ${res.status}`) 36 + } 37 + 38 + const json = await res.json() 39 + 40 + if (json.countryCode) { 41 + return { 42 + countryCode: json.countryCode, 43 + } 44 + } else { 45 + return undefined 46 + } 47 + } 48 + 49 + /** 50 + * Local promise used within this file only. 51 + */ 52 + let geolocationResolution: Promise<void> | undefined 53 + 54 + /** 55 + * Begin the process of resolving geolocation. This should be called once at 56 + * app start. 57 + * 58 + * THIS METHOD SHOULD NEVER THROW. 59 + * 60 + * This method is otherwise not used for any purpose. To ensure geolocation is 61 + * resolved, use {@link ensureGeolocationResolved} 62 + */ 63 + export function beginResolveGeolocation() { 64 + /** 65 + * In dev, IP server is unavailable, so we just set the default geolocation 66 + * and fail closed. 67 + */ 68 + if (IS_DEV) { 69 + geolocationResolution = new Promise(y => y()) 70 + device.set(['geolocation'], DEFAULT_GEOLOCATION) 71 + return 72 + } 73 + 74 + geolocationResolution = new Promise(async resolve => { 75 + try { 76 + // Try once, fail fast 77 + const geolocation = await getGeolocation() 78 + if (geolocation) { 79 + device.set(['geolocation'], geolocation) 80 + emitGeolocationUpdate(geolocation) 81 + logger.debug(`geolocation: success`, {geolocation}) 82 + } else { 83 + // endpoint should throw on all failures, this is insurance 84 + throw new Error(`geolocation: nothing returned from initial request`) 85 + } 86 + } catch (e: any) { 87 + logger.error(`geolocation: failed initial request`, { 88 + safeMessage: e.message, 89 + }) 90 + 91 + // set to default 92 + device.set(['geolocation'], DEFAULT_GEOLOCATION) 93 + 94 + // retry 3 times, but don't await, proceed with default 95 + networkRetry(3, getGeolocation) 96 + .then(geolocation => { 97 + if (geolocation) { 98 + device.set(['geolocation'], geolocation) 99 + emitGeolocationUpdate(geolocation) 100 + logger.debug(`geolocation: success`, {geolocation}) 101 + } else { 102 + // endpoint should throw on all failures, this is insurance 103 + throw new Error(`geolocation: nothing returned from retries`) 104 + } 105 + }) 106 + .catch((e: any) => { 107 + // complete fail closed 108 + logger.error(`geolocation: failed retries`, {safeMessage: e.message}) 109 + }) 110 + } finally { 111 + resolve(undefined) 112 + } 113 + }) 114 + } 115 + 116 + /** 117 + * Ensure that geolocation has been resolved, or at the very least attempted 118 + * once. Subsequent retries will not be captured by this `await`. Those will be 119 + * reported via {@link events}. 120 + */ 121 + export async function ensureGeolocationResolved() { 122 + if (!geolocationResolution) { 123 + throw new Error(`geolocation: beginResolveGeolocation not called yet`) 124 + } 125 + 126 + const cached = device.get(['geolocation']) 127 + if (cached) { 128 + logger.debug(`geolocation: using cache`, {cached}) 129 + } else { 130 + logger.debug(`geolocation: no cache`) 131 + await geolocationResolution 132 + logger.debug(`geolocation: resolved`, { 133 + resolved: device.get(['geolocation']), 134 + }) 135 + } 136 + } 137 + 138 + type Context = { 139 + geolocation: Device['geolocation'] 140 + } 141 + 142 + const context = React.createContext<Context>({ 143 + geolocation: DEFAULT_GEOLOCATION, 144 + }) 145 + 146 + export function Provider({children}: {children: React.ReactNode}) { 147 + const [geolocation, setGeolocation] = React.useState(() => { 148 + const initial = device.get(['geolocation']) || DEFAULT_GEOLOCATION 149 + return initial 150 + }) 151 + 152 + React.useEffect(() => { 153 + return onGeolocationUpdate(geolocation => { 154 + setGeolocation(geolocation!) 155 + }) 156 + }, []) 157 + 158 + const ctx = React.useMemo(() => { 159 + return { 160 + geolocation, 161 + } 162 + }, [geolocation]) 163 + 164 + return <context.Provider value={ctx}>{children}</context.Provider> 165 + } 166 + 167 + export function useGeolocation() { 168 + return React.useContext(context) 169 + }
+6 -2
src/state/queries/actor-autocomplete.ts
··· 2 2 import {AppBskyActorDefs, moderateProfile, ModerationOpts} from '@atproto/api' 3 3 import {keepPreviousData, useQuery, useQueryClient} from '@tanstack/react-query' 4 4 5 - import {isJustAMute} from '#/lib/moderation' 5 + import {isJustAMute, moduiContainsHideableOffense} from '#/lib/moderation' 6 6 import {logger} from '#/logger' 7 7 import {STALE} from '#/state/queries' 8 8 import {useAgent} from '#/state/session' ··· 113 113 return items.filter(profile => { 114 114 const modui = moderateProfile(profile, moderationOpts).ui('profileList') 115 115 const isExactMatch = q && profile.handle.toLowerCase() === q 116 - return isExactMatch || !modui.filter || isJustAMute(modui) 116 + return ( 117 + (isExactMatch && !moduiContainsHideableOffense(modui)) || 118 + !modui.filter || 119 + isJustAMute(modui) 120 + ) 117 121 }) 118 122 }
+5
src/state/queries/notifications/util.ts
··· 13 13 import {QueryClient} from '@tanstack/react-query' 14 14 import chunk from 'lodash.chunk' 15 15 16 + import {labelIsHideableOffense} from '#/lib/moderation' 16 17 import {precacheProfile} from '../profile' 17 18 import {FeedNotification, FeedPage, NotificationType} from './types' 18 19 ··· 104 105 notif: AppBskyNotificationListNotifications.Notification, 105 106 moderationOpts: ModerationOpts | undefined, 106 107 ): boolean { 108 + const containsImperative = !!notif.author.labels?.some(labelIsHideableOffense) 109 + if (containsImperative) { 110 + return true 111 + } 107 112 if (!moderationOpts) { 108 113 return false 109 114 }
+41
src/state/session/additional-moderation-authorities.ts
··· 1 + import {BskyAgent} from '@atproto/api' 2 + 3 + import {logger} from '#/logger' 4 + import {device} from '#/storage' 5 + 6 + export const BR_LABELER = 'did:plc:ekitcvx7uwnauoqy5oest3hm' 7 + export const ADDITIONAL_LABELERS_MAP: { 8 + [countryCode: string]: string[] 9 + } = { 10 + BR: [BR_LABELER], 11 + } 12 + export const ALL_ADDITIONAL_LABELERS = Object.values( 13 + ADDITIONAL_LABELERS_MAP, 14 + ).flat() 15 + export const NON_CONFIGURABLE_LABELERS = [BR_LABELER] 16 + 17 + export function isNonConfigurableModerationAuthority(did: string) { 18 + return NON_CONFIGURABLE_LABELERS.includes(did) 19 + } 20 + 21 + export function configureAdditionalModerationAuthorities() { 22 + const geolocation = device.get(['geolocation']) 23 + let additionalLabelers: string[] = ALL_ADDITIONAL_LABELERS 24 + 25 + if (geolocation?.countryCode) { 26 + additionalLabelers = ADDITIONAL_LABELERS_MAP[geolocation.countryCode] ?? [] 27 + } else { 28 + logger.info(`no geolocation, cannot apply mod authorities`) 29 + } 30 + 31 + const appLabelers = Array.from( 32 + new Set([...BskyAgent.appLabelers, ...additionalLabelers]), 33 + ) 34 + 35 + logger.info(`applying mod authorities`, { 36 + additionalLabelers, 37 + appLabelers, 38 + }) 39 + 40 + BskyAgent.configure({appLabelers}) 41 + }
+4
src/state/session/moderation.ts
··· 1 1 import {BSKY_LABELER_DID, BskyAgent} from '@atproto/api' 2 2 3 3 import {IS_TEST_USER} from '#/lib/constants' 4 + import {configureAdditionalModerationAuthorities} from './additional-moderation-authorities' 4 5 import {readLabelers} from './agent-config' 5 6 import {SessionAccount} from './types' 6 7 ··· 8 9 // This global mutation is *only* OK because this code is only relevant for testing. 9 10 // Don't add any other global behavior here! 10 11 switchToBskyAppLabeler() 12 + configureAdditionalModerationAuthorities() 11 13 } 12 14 13 15 export async function configureModerationForAccount( ··· 31 33 // If there are no headers in the storage, we'll not send them on the initial requests. 32 34 // If we wanted to fix this, we could block on the preferences query here. 33 35 } 36 + 37 + configureAdditionalModerationAuthorities() 34 38 } 35 39 36 40 function switchToBskyAppLabeler() {
+9 -1
src/storage/index.ts
··· 1 1 import {MMKV} from 'react-native-mmkv' 2 2 3 + import {IS_DEV} from '#/env' 3 4 import {Device} from '#/storage/schema' 4 5 5 6 export * from '#/storage/schema' ··· 71 72 * 72 73 * `device.set([key], true)` 73 74 */ 74 - export const device = new Storage<[], Device>({id: 'device'}) 75 + export const device = new Storage<[], Device>({id: 'bsky_device'}) 76 + 77 + if (IS_DEV && typeof window !== 'undefined') { 78 + // @ts-ignore 79 + window.bsky_storage = { 80 + device, 81 + } 82 + }
+3
src/storage/schema.ts
··· 5 5 fontScale: '-2' | '-1' | '0' | '1' | '2' 6 6 fontFamily: 'system' | 'theme' 7 7 lastNuxDialog: string | undefined 8 + geolocation?: { 9 + countryCode: string | undefined 10 + } 8 11 }