about summary refs log tree commit diff
diff options
context:
space:
mode:
-rw-r--r--src/App.native.tsx76
-rw-r--r--src/App.web.tsx66
-rw-r--r--src/components/LabelingServiceCard/index.tsx28
-rw-r--r--src/lib/moderation.ts14
-rw-r--r--src/screens/Moderation/index.tsx6
-rw-r--r--src/state/geolocation.tsx169
-rw-r--r--src/state/queries/actor-autocomplete.ts8
-rw-r--r--src/state/queries/notifications/util.ts5
-rw-r--r--src/state/session/additional-moderation-authorities.ts41
-rw-r--r--src/state/session/moderation.ts4
-rw-r--r--src/storage/index.ts10
-rw-r--r--src/storage/schema.ts3
12 files changed, 365 insertions, 65 deletions
diff --git a/src/App.native.tsx b/src/App.native.tsx
index 2ec666e2c..e2fcd6d2e 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 (
-    <A11yProvider>
-      <KeyboardProvider enabled={false} statusBarTranslucent={true}>
-        <SessionProvider>
-          <PrefsStateProvider>
-            <I18nProvider>
-              <ShellStateProvider>
-                <InvitesStateProvider>
-                  <ModalStateProvider>
-                    <DialogStateProvider>
-                      <LightboxStateProvider>
-                        <PortalProvider>
-                          <StarterPackProvider>
-                            <SafeAreaProvider
-                              initialMetrics={initialWindowMetrics}>
-                              <IntentDialogProvider>
-                                <InnerApp />
-                              </IntentDialogProvider>
-                            </SafeAreaProvider>
-                          </StarterPackProvider>
-                        </PortalProvider>
-                      </LightboxStateProvider>
-                    </DialogStateProvider>
-                  </ModalStateProvider>
-                </InvitesStateProvider>
-              </ShellStateProvider>
-            </I18nProvider>
-          </PrefsStateProvider>
-        </SessionProvider>
-      </KeyboardProvider>
-    </A11yProvider>
+    <GeolocationProvider>
+      <A11yProvider>
+        <KeyboardProvider enabled={false} statusBarTranslucent={true}>
+          <SessionProvider>
+            <PrefsStateProvider>
+              <I18nProvider>
+                <ShellStateProvider>
+                  <InvitesStateProvider>
+                    <ModalStateProvider>
+                      <DialogStateProvider>
+                        <LightboxStateProvider>
+                          <PortalProvider>
+                            <StarterPackProvider>
+                              <SafeAreaProvider
+                                initialMetrics={initialWindowMetrics}>
+                                <IntentDialogProvider>
+                                  <InnerApp />
+                                </IntentDialogProvider>
+                              </SafeAreaProvider>
+                            </StarterPackProvider>
+                          </PortalProvider>
+                        </LightboxStateProvider>
+                      </DialogStateProvider>
+                    </ModalStateProvider>
+                  </InvitesStateProvider>
+                </ShellStateProvider>
+              </I18nProvider>
+            </PrefsStateProvider>
+          </SessionProvider>
+        </KeyboardProvider>
+      </A11yProvider>
+    </GeolocationProvider>
   )
 }
 
diff --git a/src/App.web.tsx b/src/App.web.tsx
index fa5f1de93..c81ed10d3 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 (
-    <A11yProvider>
-      <SessionProvider>
-        <PrefsStateProvider>
-          <I18nProvider>
-            <ShellStateProvider>
-              <InvitesStateProvider>
-                <ModalStateProvider>
-                  <DialogStateProvider>
-                    <LightboxStateProvider>
-                      <PortalProvider>
-                        <StarterPackProvider>
-                          <IntentDialogProvider>
-                            <InnerApp />
-                          </IntentDialogProvider>
-                        </StarterPackProvider>
-                      </PortalProvider>
-                    </LightboxStateProvider>
-                  </DialogStateProvider>
-                </ModalStateProvider>
-              </InvitesStateProvider>
-            </ShellStateProvider>
-          </I18nProvider>
-        </PrefsStateProvider>
-      </SessionProvider>
-    </A11yProvider>
+    <GeolocationProvider>
+      <A11yProvider>
+        <SessionProvider>
+          <PrefsStateProvider>
+            <I18nProvider>
+              <ShellStateProvider>
+                <InvitesStateProvider>
+                  <ModalStateProvider>
+                    <DialogStateProvider>
+                      <LightboxStateProvider>
+                        <PortalProvider>
+                          <StarterPackProvider>
+                            <IntentDialogProvider>
+                              <InnerApp />
+                            </IntentDialogProvider>
+                          </StarterPackProvider>
+                        </PortalProvider>
+                      </LightboxStateProvider>
+                    </DialogStateProvider>
+                  </ModalStateProvider>
+                </InvitesStateProvider>
+              </ShellStateProvider>
+            </I18nProvider>
+          </PrefsStateProvider>
+        </SessionProvider>
+      </A11yProvider>
+    </GeolocationProvider>
   )
 }
 
diff --git a/src/components/LabelingServiceCard/index.tsx b/src/components/LabelingServiceCard/index.tsx
index 542f2d299..ff32c36bc 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 <Text style={[a.text_md, a.font_bold]}>{value}</Text>
+  return <Text style={[a.text_md, a.font_bold, a.leading_tight]}>{value}</Text>
 }
 
 export function Description({value, handle}: {value?: string; handle: string}) {
   return value ? (
     <Text numberOfLines={2}>
-      <RichText value={value} style={[]} />
+      <RichText value={value} style={[a.leading_snug]} />
     </Text>
   ) : (
-    <Text>
+    <Text style={[a.leading_snug]}>
       <Trans>By {sanitizeHandle(handle, '@')}</Trans>
     </Text>
   )
 }
 
+export function RegionalNotice() {
+  const t = useTheme()
+  return (
+    <View
+      style={[
+        a.flex_row,
+        a.align_center,
+        a.gap_xs,
+        a.pt_2xs,
+        {marginLeft: -2},
+      ]}>
+      <Flag fill={t.atoms.text_contrast_low.color} size="sm" />
+      <Text style={[a.italic, a.leading_snug]}>
+        <Trans>Required in your region</Trans>
+      </Text>
+    </View>
+  )
+}
+
 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,
       ]}>
-      <View style={[a.gap_xs, a.flex_1]}>{children}</View>
+      <View style={[a.gap_2xs, a.flex_1]}>{children}</View>
 
       <ChevronRight size="md" style={[a.z_10, t.atoms.text_contrast_low]} />
     </View>
diff --git a/src/lib/moderation.ts b/src/lib/moderation.ts
index 59d88023b..7576a9c33 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 cd3179674..ad59c42dc 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,
+                        ) && <LabelingService.RegionalNotice />}
                       </LabelingService.Content>
                     </LabelingService.Outer>
                   )}
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() {
diff --git a/src/storage/index.ts b/src/storage/index.ts
index 4be08170d..7ef226d3a 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<Scopes extends unknown[], Schema> {
  *
  *   `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 1a9656fed..cf410c77d 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
+  }
 }