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
This commit is contained in:
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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})
|
||||
}
|
||||
@@ -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() {
|
||||
|
||||
Reference in New Issue
Block a user