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
+ }
}