3p moderation services [WIP] (#2550)

* Add modservice screen and profile-header-card

* Drop the guidelines for now

* Remove ununsed constants

* Add label & label group descriptions

* Not found state

* Reorg, add icon

* Subheader

* Header

* Complete header

* Clean up

* Add all groups

* Fix scroll view

* Dialogs side quest

* Remove log

* Add (WIP) debug mod page

* Dialog solution

* Add note

* Clean up and reorganize localized moderation strings

* Memoize

* Add example

* Add first ReportDialog screen

* Report dialog step 2

* Submit

* Integrate updates

* Move moderation screen

* Migrate buttons

* Migrate everything

* Rough sketch

* Fix types

* Update atoms values

* Abstract ModerationServiceCard

* Hook up data to settings page

* Handle subscription

* Rough enablement

* Rough enablement

* Some validation, fixes

* More work on the mod debug screen

* Hook up data

* Update invalidation

* Hook up data to ReportDialog

* Fix native error

* Refactor/rewrite the entire moderation-application system

* Fix toggles

* Add copyright and other option to report

* Handle reports on profile vs content

* Little cleanup

* Get post hiding back in gear

* Better loading flow on Mod screen

* Clean up Mod screen

* Clean up ProfileMod screen

* Handle muting correctly

* Update enablement on ProfileMod screen

* Improve Moderation screen and dialog

* Styling, handle disabled labelers

* Rework list of labels on own content

* Use moderateNotification()

* ReportDialog updates

* Fix button overflow

* Simplify the ProfileModerationService ui

* Mod screen design

* Move moderation card from the profile header to a tab

* Small tweaks to the moderation screen

* Enable toggle on mod page

* Add notifs to debugmod and dont filter notifs from followed users

* Add moderator-service profile view

* Wire up more of the modservice data to profiles

* A bunch of speculative non-working UI

* Cleanup: delete old code

* Update ModerationDetailsDialog

* Update ReportDialog

* Update LabelsOnMe dialog

* Handle ReportDialog load better

* Rename LabelsOnMeDialog, fix close

* Experiment to put labeling under a tab of a normal profile

* Moderator variation of profile

* Remove dead code and start moving toward latest modsdk

* Remove a bunch of now-dead label strings

* Update ModDebug to be a bit more intuitive and support custom labels

* Minor ui tweaks

* Improve consistency of display name blurring

* Fix profile-card warning rendering

* More debugmod UI tuning

* Update to use new labeler semantics

* Delete some dead code and do some refactoring

* Update profile to pull from labeler definition

* Implement new label config controls (wip)

* Tweak ui

* Implement preference controls on labelers

* Rework label pref ui

* Get moderation screen working

* Add asyncstorage query persistence

* Implement label handling

* Small cleanup

* Implement Likes dialog

* Fix: remove text outside of text element

* Cleanup

* Fix likes dialog on mobile

* Implement the label appeal flow

* Get report flow working again with temporarily fixed report options

* Update onboarding

* Enforce limit of ten labeler subscriptions

* Fix type errors

* Fix lint errors

* Improve types of RQ

* Some work on Likes dialog, needs discussion

* Bit of ReportDialog cleanup

* Replace non-single-path SVG

* Update nudity descriptions

* Update to use new sdk updates

* Add adult-content-enabled behavior to label config

* Use the default setting of custom labels

* Handle global moderation label prefs with the global settings

* Fix missing postAuthor

* Fix empty moderation page

* Add mutewords control back to Mod screen

* Tweak adult setting styles

* Remove deprecated global labels

* Handle underage users on mod screen

* Adjust font sizes

* Swap in RichText

* Like button improvements

* Tweaks to Labeler profile

* Design tweaks for mod pref dialog

* Add tertiary button color

* Switch moderation UIs to tertiary color

* Update mutewords and hiddenposts to use the new sdk

* Add test-environment mod authority

* Switch 'gore' to 'graphic-media'

* Move nudity out of the adult content control

* Remove focus styles from buttons - let the browser behavior handle it

* Fixes to the adult content age-gating in moderaiton

* Ditch tertiary button color, lighten secondary button

* Fix some colors

* Remove focused overrides from toggles

* Liked by screen

* Rework the moderationlabelpref

* Fix optimistic like

* Cleanup

* Change how onboarding handles adult content enabled/disabled

* Add special handling of the mod authorities

* Tweaks

* Update the default labeler avatar to a shield

* Add route to go server

* Avoid dups due to bad config

* Fix attrs

* Fix: dont try to detect link/label mismatches on post meta

* Correctly show the label behavior when adult content is disabled

* Readd the local hiddenPosts handling

* WIP

* Fix bad merge

* Conten hider design tweaks

* Fix text string breakage

* Adjust source text in ContentHider

* Fix link bug

* Design tweaks to ContentHider and ModDetailsDialog

* Adjust spacing of inform badges

* Adjust spacing of embeds in posts

* Style tweaks to post/profile alerts

* Labels on me and dialog

* Remove bad focus styles from post dropdown

* Better spacing solution

* Tune moderation UIs

* Moderation UI tweaks for mobile

* Move labelers query on Mod screen

* Update to use new SDK appLabelers semantics

* Implement report submission

* Replace the report modal entirely with the report dialog

* Add @ to mod details dialog handle

* Bump SDK package

* Remove silly type

* Add to AWS build CI

* Fix ToggleButton overflow

* Clean up ModServiceCard, rename to LabelingServiceCard

* Hackfix to translate gore labels to graphic-media

* Tune content hider sizing on web desktop

* Handle self labels

* Fix spacing below text-only posts

* Fix: send appeals to the right labeler

* Give mod page links interactive states

* Fix references

* Remove focus handling

* Remove remnant

* Remove the like count from the subscribed labeler listing

* Bump @atproto/api@0.11.1

* Remove extra @

* Fix: persist labels to local storage to reduce coverage gaps

* update dipendencies

* revert dipendencies

* Add some explainers on how blocking affects labelers

* Tweak copy

* Fix underline color in header

* Fix profile menu

* Handle card overflow

* Remove metrics from header

* Mute 'account' not 'user'

* Show metrics if self

* Show the labels tab on logged out view

* Fix bad merge

* Use purple theming on labelers

* Tighten space on LabelerCard

* Set staleTime to 6hrs for labeler details

* Memoize the memoizers

* Drop staleTime to 60s

* Move label defs into a context to reduce recomputes

* Submit view tweaks

* Move labeler fetch below auth

* Mitigation: hardcode the bluesky moderation labeler name

* Bump sdk

* Add missing translated string

Co-authored-by: Takayuki KUSANO <65759+tkusano@users.noreply.github.com>

* Add missing translated string

Co-authored-by: Takayuki KUSANO <65759+tkusano@users.noreply.github.com>

* Hailey's fix for incorrect profile tabs

Co-authored-by: Hailey <me@haileyok.com>

* Feedback

* Fix borders, add bottom space

* Hailey's fix pt 2

Co-authored-by: Hailey <me@haileyok.com>

* Fix post tabs

* Integrate feedback pt 1

Co-authored-by: Takayuki KUSANO <65759+tkusano@users.noreply.github.com>

* Integrate feedback pt 2

Co-authored-by: Takayuki KUSANO <65759+tkusano@users.noreply.github.com>

* Integrate feedback pt 3

Co-authored-by: Takayuki KUSANO <65759+tkusano@users.noreply.github.com>

* Integrate feedback pt 4

Co-authored-by: Takayuki KUSANO <65759+tkusano@users.noreply.github.com>

* Integrate feedback pt 5

Co-authored-by: Takayuki KUSANO <65759+tkusano@users.noreply.github.com>

* Integrate feedback pt 6

Co-authored-by: Takayuki KUSANO <65759+tkusano@users.noreply.github.com>

* Integrate feedback pt 7

Co-authored-by: Takayuki KUSANO <65759+tkusano@users.noreply.github.com>

* Integrate feedback pt 8

Co-authored-by: Takayuki KUSANO <65759+tkusano@users.noreply.github.com>

* Format

* Integrate new bday modal

* Use public agent for getServices

* Update casing

---------

Co-authored-by: Eric Bailey <git@esb.lol>
Co-authored-by: Takayuki KUSANO <65759+tkusano@users.noreply.github.com>
Co-authored-by: Hailey <me@haileyok.com>
This commit is contained in:
Paul Frazee 2024-03-18 12:46:28 -07:00 committed by GitHub
parent d5ebbeb3fc
commit 20d463ff2f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
165 changed files with 7034 additions and 5009 deletions

View file

@ -1,5 +1,5 @@
import React from 'react'
import {AppBskyActorDefs, AppBskyGraphDefs, ModerationUI} from '@atproto/api'
import {AppBskyActorDefs, AppBskyGraphDefs} from '@atproto/api'
import {Image as RNImage} from 'react-native-image-crop-picker'
import {ImageModel} from '#/state/models/media/image'
@ -14,32 +14,6 @@ export interface EditProfileModal {
onUpdate?: () => void
}
export interface ModerationDetailsModal {
name: 'moderation-details'
context: 'account' | 'content'
moderation: ModerationUI
}
export type ReportModal = {
name: 'report'
} & (
| {
uri: string
cid: string
}
| {did: string}
)
export type AppealLabelModal = {
name: 'appeal-label'
} & (
| {
uri: string
cid: string
}
| {did: string}
)
export interface CreateOrEditListModal {
name: 'create-or-edit-list'
purpose?: string
@ -123,10 +97,6 @@ export interface AddAppPasswordModal {
name: 'add-app-password'
}
export interface ContentFilteringSettingsModal {
name: 'content-filtering-settings'
}
export interface ContentLanguagesSettingsModal {
name: 'content-languages-settings'
}
@ -181,15 +151,9 @@ export type Modal =
| SwitchAccountModal
// Curation
| ContentFilteringSettingsModal
| ContentLanguagesSettingsModal
| PostLanguagesSettingsModal
// Moderation
| ModerationDetailsModal
| ReportModal
| AppealLabelModal
// Lists
| CreateOrEditListModal
| UserAddRemoveListsModal

View file

@ -15,6 +15,7 @@ export {
useSetExternalEmbedPref,
} from './external-embeds-prefs'
export * from './hidden-posts'
export {useLabelDefinitions} from './label-defs'
export function Provider({children}: React.PropsWithChildren<{}>) {
return (

View file

@ -0,0 +1,25 @@
import React from 'react'
import {InterpretedLabelValueDefinition, AppBskyLabelerDefs} from '@atproto/api'
import {useLabelDefinitionsQuery} from '../queries/preferences'
interface StateContext {
labelDefs: Record<string, InterpretedLabelValueDefinition[]>
labelers: AppBskyLabelerDefs.LabelerViewDetailed[]
}
const stateContext = React.createContext<StateContext>({
labelDefs: {},
labelers: [],
})
export function Provider({children}: React.PropsWithChildren<{}>) {
const {labelDefs, labelers} = useLabelDefinitionsQuery()
const state = {labelDefs, labelers}
return <stateContext.Provider value={state}>{children}</stateContext.Provider>
}
export function useLabelDefinitions() {
return React.useContext(stateContext)
}

View file

@ -6,17 +6,14 @@ import {logger} from '#/logger'
import {getAgent} from '#/state/session'
import {useMyFollowsQuery} from '#/state/queries/my-follows'
import {STALE} from '#/state/queries'
import {
DEFAULT_LOGGED_OUT_PREFERENCES,
getModerationOpts,
useModerationOpts,
} from './preferences'
import {DEFAULT_LOGGED_OUT_PREFERENCES, useModerationOpts} from './preferences'
import {isInvalidHandle} from '#/lib/strings/handles'
import {isJustAMute} from '#/lib/moderation'
const DEFAULT_MOD_OPTS = getModerationOpts({
userDid: '',
preferences: DEFAULT_LOGGED_OUT_PREFERENCES,
})
const DEFAULT_MOD_OPTS = {
userDid: undefined,
prefs: DEFAULT_LOGGED_OUT_PREFERENCES.moderationPrefs,
}
export const RQKEY = (prefix: string) => ['actor-autocomplete', prefix]
@ -114,8 +111,8 @@ function computeSuggestions(
}
}
return items.filter(profile => {
const mod = moderateProfile(profile, moderationOpts)
return !mod.account.filter && mod.account.cause?.type !== 'muted'
const modui = moderateProfile(profile, moderationOpts).ui('profileList')
return !modui.filter || isJustAMute(modui)
})
}

View file

@ -0,0 +1,89 @@
import {z} from 'zod'
import {useQuery, useMutation, useQueryClient} from '@tanstack/react-query'
import {AppBskyLabelerDefs} from '@atproto/api'
import {getAgent} from '#/state/session'
import {preferencesQueryKey} from '#/state/queries/preferences'
import {STALE, PUBLIC_BSKY_AGENT} from '#/state/queries'
export const labelerInfoQueryKey = (did: string) => ['labeler-info', did]
export const labelersInfoQueryKey = (dids: string[]) => [
'labelers-info',
dids.sort(),
]
export const labelersDetailedInfoQueryKey = (dids: string[]) => [
'labelers-detailed-info',
dids,
]
export function useLabelerInfoQuery({
did,
enabled,
}: {
did?: string
enabled?: boolean
}) {
return useQuery({
enabled: !!did && enabled !== false,
queryKey: labelerInfoQueryKey(did as string),
queryFn: async () => {
const res = await PUBLIC_BSKY_AGENT.app.bsky.labeler.getServices({
dids: [did as string],
detailed: true,
})
return res.data.views[0] as AppBskyLabelerDefs.LabelerViewDetailed
},
})
}
export function useLabelersInfoQuery({dids}: {dids: string[]}) {
return useQuery({
enabled: !!dids.length,
queryKey: labelersInfoQueryKey(dids),
queryFn: async () => {
const res = await PUBLIC_BSKY_AGENT.app.bsky.labeler.getServices({dids})
return res.data.views as AppBskyLabelerDefs.LabelerView[]
},
})
}
export function useLabelersDetailedInfoQuery({dids}: {dids: string[]}) {
return useQuery({
enabled: !!dids.length,
queryKey: labelersDetailedInfoQueryKey(dids),
gcTime: 1000 * 60 * 60 * 6, // 6 hours
staleTime: STALE.MINUTES.ONE,
queryFn: async () => {
const res = await PUBLIC_BSKY_AGENT.app.bsky.labeler.getServices({
dids,
detailed: true,
})
return res.data.views as AppBskyLabelerDefs.LabelerViewDetailed[]
},
})
}
export function useLabelerSubscriptionMutation() {
const queryClient = useQueryClient()
return useMutation({
async mutationFn({did, subscribe}: {did: string; subscribe: boolean}) {
// TODO
z.object({
did: z.string(),
subscribe: z.boolean(),
}).parse({did, subscribe})
if (subscribe) {
await getAgent().addLabeler(did)
} else {
await getAgent().removeLabeler(did)
}
},
onSuccess() {
queryClient.invalidateQueries({
queryKey: preferencesQueryKey,
})
},
})
}

View file

@ -1,14 +1,13 @@
import {
AppBskyNotificationListNotifications,
ModerationOpts,
moderateProfile,
moderateNotification,
AppBskyFeedDefs,
AppBskyFeedPost,
AppBskyFeedRepost,
AppBskyFeedLike,
AppBskyEmbedRecord,
} from '@atproto/api'
import {moderatePost_wrapped as moderatePost} from '#/lib/moderatePost_wrapped'
import chunk from 'lodash.chunk'
import {QueryClient} from '@tanstack/react-query'
import {getAgent} from '../../session'
@ -88,37 +87,20 @@ export async function fetchPage({
// internal methods
// =
// TODO this should be in the sdk as moderateNotification -prf
function shouldFilterNotif(
export function shouldFilterNotif(
notif: AppBskyNotificationListNotifications.Notification,
moderationOpts: ModerationOpts | undefined,
): boolean {
if (!moderationOpts) {
return false
}
const profile = moderateProfile(notif.author, moderationOpts)
if (
profile.account.filter ||
profile.profile.filter ||
notif.author.viewer?.muted
) {
return true
if (notif.author.viewer?.following) {
return false
}
if (
notif.type === 'reply' ||
notif.type === 'quote' ||
notif.type === 'mention'
) {
// NOTE: the notification overlaps the post enough for this to work
const post = moderatePost(notif, moderationOpts)
if (post.content.filter) {
return true
}
}
return false
return moderateNotification(notif, moderationOpts).ui('contentList').filter
}
function groupNotifications(
export function groupNotifications(
notifs: AppBskyNotificationListNotifications.Notification[],
): FeedNotification[] {
const groupedNotifs: FeedNotification[] = []

View file

@ -3,8 +3,8 @@ import {AppState} from 'react-native'
import {
AppBskyFeedDefs,
AppBskyFeedPost,
ModerationDecision,
AtUri,
PostModeration,
} from '@atproto/api'
import {
useInfiniteQuery,
@ -29,7 +29,6 @@ import {STALE} from '#/state/queries'
import {precacheFeedPostProfiles} from './profile'
import {getAgent} from '#/state/session'
import {DEFAULT_LOGGED_OUT_PREFERENCES} from '#/state/queries/preferences/const'
import {getModerationOpts} from '#/state/queries/preferences/moderation'
import {KnownError} from '#/view/com/posts/FeedErrorMessage'
import {embedViewRecordToPostView, getEmbeddedPost} from './util'
import {useModerationOpts} from './preferences'
@ -69,7 +68,7 @@ export interface FeedPostSliceItem {
post: AppBskyFeedDefs.PostView
record: AppBskyFeedPost.Record
reason?: AppBskyFeedDefs.ReasonRepost | ReasonFeedSource
moderation: PostModeration
moderation: ModerationDecision
}
export interface FeedPostSlice {
@ -250,9 +249,17 @@ export function usePostFeedQuery(
// apply moderation filter
for (let i = 0; i < slice.items.length; i++) {
const ignoreFilter =
slice.items[i].post.author.did === ignoreFilterFor
if (ignoreFilter) {
// remove mutes to avoid confused UIs
moderations[i].causes = moderations[i].causes.filter(
cause => cause.type !== 'muted',
)
}
if (
moderations[i]?.content.filter &&
slice.items[i].post.author.did !== ignoreFilterFor
!ignoreFilter &&
moderations[i]?.ui('contentList').filter
) {
return undefined
}
@ -435,13 +442,12 @@ function assertSomePostsPassModeration(feed: AppBskyFeedDefs.FeedViewPost[]) {
let somePostsPassModeration = false
for (const item of feed) {
const moderationOpts = getModerationOpts({
userDid: '',
preferences: DEFAULT_LOGGED_OUT_PREFERENCES,
const moderation = moderatePost(item.post, {
userDid: undefined,
prefs: DEFAULT_LOGGED_OUT_PREFERENCES.moderationPrefs,
})
const moderation = moderatePost(item.post, moderationOpts)
if (!moderation.content.filter) {
if (!moderation.ui('contentList').filter) {
// we have a sfw post
somePostsPassModeration = true
}

View file

@ -12,9 +12,9 @@ const PAGE_SIZE = 30
type RQPageParam = string | undefined
// TODO refactor invalidate on mutate?
export const RQKEY = (resolvedUri: string) => ['post-liked-by', resolvedUri]
export const RQKEY = (resolvedUri: string) => ['liked-by', resolvedUri]
export function usePostLikedByQuery(resolvedUri: string | undefined) {
export function useLikedByQuery(resolvedUri: string | undefined) {
return useInfiniteQuery<
AppBskyFeedGetLikes.OutputSchema,
Error,

View file

@ -29,26 +29,20 @@ export const DEFAULT_PROD_FEEDS = {
export const DEFAULT_LOGGED_OUT_PREFERENCES: UsePreferencesQueryResponse = {
birthDate: new Date('2022-11-17'), // TODO(pwi)
adultContentEnabled: false,
feeds: {
saved: [],
pinned: [],
unpinned: [],
},
// labels are undefined until set by user
contentLabels: {
nsfw: DEFAULT_LOGGED_OUT_LABEL_PREFERENCES.nsfw,
nudity: DEFAULT_LOGGED_OUT_LABEL_PREFERENCES.nudity,
suggestive: DEFAULT_LOGGED_OUT_LABEL_PREFERENCES.suggestive,
gore: DEFAULT_LOGGED_OUT_LABEL_PREFERENCES.gore,
hate: DEFAULT_LOGGED_OUT_LABEL_PREFERENCES.hate,
spam: DEFAULT_LOGGED_OUT_LABEL_PREFERENCES.spam,
impersonation: DEFAULT_LOGGED_OUT_LABEL_PREFERENCES.impersonation,
moderationPrefs: {
adultContentEnabled: false,
labels: DEFAULT_LOGGED_OUT_LABEL_PREFERENCES,
labelers: [],
mutedWords: [],
hiddenPosts: [],
},
feedViewPrefs: DEFAULT_HOME_FEED_PREFS,
threadViewPrefs: DEFAULT_THREAD_VIEW_PREFS,
userAge: 13, // TODO(pwi)
interests: {tags: []},
mutedWords: [],
hiddenPosts: [],
}

View file

@ -1,29 +1,27 @@
import {useMemo} from 'react'
import {useMemo, createContext, useContext} from 'react'
import {useQuery, useMutation, useQueryClient} from '@tanstack/react-query'
import {
LabelPreference,
BskyFeedViewPreference,
ModerationOpts,
AppBskyActorDefs,
} from '@atproto/api'
import {track} from '#/lib/analytics/analytics'
import {getAge} from '#/lib/strings/time'
import {useSession, getAgent} from '#/state/session'
import {DEFAULT_LABEL_PREFERENCES} from '#/state/queries/preferences/moderation'
import {getAgent, useSession} from '#/state/session'
import {
ConfigurableLabelGroup,
UsePreferencesQueryResponse,
ThreadViewPreferences,
} from '#/state/queries/preferences/types'
import {temp__migrateLabelPref} from '#/state/queries/preferences/util'
import {
DEFAULT_HOME_FEED_PREFS,
DEFAULT_THREAD_VIEW_PREFS,
DEFAULT_LOGGED_OUT_PREFERENCES,
} from '#/state/queries/preferences/const'
import {getModerationOpts} from '#/state/queries/preferences/moderation'
import {STALE} from '#/state/queries'
import {useHiddenPosts} from '#/state/preferences/hidden-posts'
import {useHiddenPosts, useLabelDefinitions} from '#/state/preferences'
import {saveLabelers} from '#/state/session/agent-config'
export * from '#/state/queries/preferences/types'
export * from '#/state/queries/preferences/moderation'
@ -44,6 +42,13 @@ export function usePreferencesQuery() {
return DEFAULT_LOGGED_OUT_PREFERENCES
} else {
const res = await agent.getPreferences()
// save to local storage to ensure there are labels on initial requests
saveLabelers(
agent.session.did,
res.moderationPrefs.labelers.map(l => l.did),
)
const preferences: UsePreferencesQueryResponse = {
...res,
feeds: {
@ -54,32 +59,6 @@ export function usePreferencesQuery() {
return !res.feeds.pinned?.includes(f)
}) || [],
},
// labels are undefined until set by user
contentLabels: {
nsfw: temp__migrateLabelPref(
res.contentLabels?.nsfw || DEFAULT_LABEL_PREFERENCES.nsfw,
),
nudity: temp__migrateLabelPref(
res.contentLabels?.nudity || DEFAULT_LABEL_PREFERENCES.nudity,
),
suggestive: temp__migrateLabelPref(
res.contentLabels?.suggestive ||
DEFAULT_LABEL_PREFERENCES.suggestive,
),
gore: temp__migrateLabelPref(
res.contentLabels?.gore || DEFAULT_LABEL_PREFERENCES.gore,
),
hate: temp__migrateLabelPref(
res.contentLabels?.hate || DEFAULT_LABEL_PREFERENCES.hate,
),
spam: temp__migrateLabelPref(
res.contentLabels?.spam || DEFAULT_LABEL_PREFERENCES.spam,
),
impersonation: temp__migrateLabelPref(
res.contentLabels?.impersonation ||
DEFAULT_LABEL_PREFERENCES.impersonation,
),
},
feedViewPrefs: {
...DEFAULT_HOME_FEED_PREFS,
...(res.feedViewPrefs.home || {}),
@ -96,25 +75,30 @@ export function usePreferencesQuery() {
})
}
// used in the moderation state devtool
export const moderationOptsOverrideContext = createContext<
ModerationOpts | undefined
>(undefined)
export function useModerationOpts() {
const override = useContext(moderationOptsOverrideContext)
const {currentAccount} = useSession()
const prefs = usePreferencesQuery()
const hiddenPosts = useHiddenPosts()
const opts = useMemo(() => {
const {labelDefs} = useLabelDefinitions()
const hiddenPosts = useHiddenPosts() // TODO move this into pds-stored prefs
const opts = useMemo<ModerationOpts | undefined>(() => {
if (override) {
return override
}
if (!prefs.data) {
return
}
const moderationOpts = getModerationOpts({
userDid: currentAccount?.did || '',
preferences: prefs.data,
})
return {
...moderationOpts,
hiddenPosts,
mutedWords: prefs.data.mutedWords || [],
userDid: currentAccount?.did,
prefs: {...prefs.data.moderationPrefs, hiddenPosts: hiddenPosts || []},
labelDefs,
}
}, [currentAccount?.did, prefs.data, hiddenPosts])
}, [override, currentAccount, labelDefs, prefs.data, hiddenPosts])
return opts
}
@ -138,10 +122,32 @@ export function usePreferencesSetContentLabelMutation() {
return useMutation<
void,
unknown,
{labelGroup: ConfigurableLabelGroup; visibility: LabelPreference}
{label: string; visibility: LabelPreference; labelerDid: string | undefined}
>({
mutationFn: async ({labelGroup, visibility}) => {
await getAgent().setContentLabelPref(labelGroup, visibility)
mutationFn: async ({label, visibility, labelerDid}) => {
await getAgent().setContentLabelPref(label, visibility, labelerDid)
// triggers a refetch
await queryClient.invalidateQueries({
queryKey: preferencesQueryKey,
})
},
})
}
export function useSetContentLabelMutation() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async ({
label,
visibility,
labelerDid,
}: {
label: string
visibility: LabelPreference
labelerDid?: string
}) => {
await getAgent().setContentLabelPref(label, visibility, labelerDid)
// triggers a refetch
await queryClient.invalidateQueries({
queryKey: preferencesQueryKey,

View file

@ -1,181 +1,53 @@
import React from 'react'
import {
LabelPreference,
ComAtprotoLabelDefs,
ModerationOpts,
DEFAULT_LABEL_SETTINGS,
BskyAgent,
interpretLabelValueDefinitions,
} from '@atproto/api'
import {
LabelGroup,
ConfigurableLabelGroup,
UsePreferencesQueryResponse,
} from '#/state/queries/preferences/types'
export type Label = ComAtprotoLabelDefs.Label
export type LabelGroupConfig = {
id: LabelGroup
title: string
isAdultImagery?: boolean
subtitle?: string
warning: string
values: string[]
}
export const DEFAULT_LABEL_PREFERENCES: Record<
ConfigurableLabelGroup,
LabelPreference
> = {
nsfw: 'hide',
nudity: 'warn',
suggestive: 'warn',
gore: 'warn',
hate: 'hide',
spam: 'hide',
impersonation: 'hide',
}
import {usePreferencesQuery} from './index'
import {useLabelersDetailedInfoQuery} from '../labeler'
/**
* More strict than our default settings for logged in users.
*
* TODO(pwi)
*/
export const DEFAULT_LOGGED_OUT_LABEL_PREFERENCES: Record<
ConfigurableLabelGroup,
LabelPreference
> = {
nsfw: 'hide',
nudity: 'hide',
suggestive: 'hide',
gore: 'hide',
hate: 'hide',
spam: 'hide',
impersonation: 'hide',
export const DEFAULT_LOGGED_OUT_LABEL_PREFERENCES: typeof DEFAULT_LABEL_SETTINGS =
Object.fromEntries(
Object.entries(DEFAULT_LABEL_SETTINGS).map(([key, _pref]) => [key, 'hide']),
)
export function useMyLabelersQuery() {
const prefs = usePreferencesQuery()
const dids = Array.from(
new Set(
BskyAgent.appLabelers.concat(
prefs.data?.moderationPrefs.labelers.map(l => l.did) || [],
),
),
)
const labelers = useLabelersDetailedInfoQuery({dids})
const isLoading = prefs.isLoading || labelers.isLoading
const error = prefs.error || labelers.error
return React.useMemo(() => {
return {
isLoading,
error,
data: labelers.data,
}
}, [labelers, isLoading, error])
}
export const ILLEGAL_LABEL_GROUP: LabelGroupConfig = {
id: 'illegal',
title: 'Illegal Content',
warning: 'Illegal Content',
values: ['csam', 'dmca-violation', 'nudity-nonconsensual'],
}
export const ALWAYS_FILTER_LABEL_GROUP: LabelGroupConfig = {
id: 'always-filter',
title: 'Content Warning',
warning: 'Content Warning',
values: ['!filter'],
}
export const ALWAYS_WARN_LABEL_GROUP: LabelGroupConfig = {
id: 'always-warn',
title: 'Content Warning',
warning: 'Content Warning',
values: ['!warn', 'account-security'],
}
export const UNKNOWN_LABEL_GROUP: LabelGroupConfig = {
id: 'unknown',
title: 'Unknown Label',
warning: 'Content Warning',
values: [],
}
export const CONFIGURABLE_LABEL_GROUPS: Record<
ConfigurableLabelGroup,
LabelGroupConfig
> = {
nsfw: {
id: 'nsfw',
title: 'Explicit Sexual Images',
subtitle: 'i.e. pornography',
warning: 'Sexually Explicit',
values: ['porn', 'nsfl'],
isAdultImagery: true,
},
nudity: {
id: 'nudity',
title: 'Other Nudity',
subtitle: 'Including non-sexual and artistic',
warning: 'Nudity',
values: ['nudity'],
isAdultImagery: true,
},
suggestive: {
id: 'suggestive',
title: 'Sexually Suggestive',
subtitle: 'Does not include nudity',
warning: 'Sexually Suggestive',
values: ['sexual'],
isAdultImagery: true,
},
gore: {
id: 'gore',
title: 'Violent / Bloody',
subtitle: 'Gore, self-harm, torture',
warning: 'Violence',
values: ['gore', 'self-harm', 'torture', 'nsfl', 'corpse'],
isAdultImagery: true,
},
hate: {
id: 'hate',
title: 'Hate Group Iconography',
subtitle: 'Images of terror groups, articles covering events, etc.',
warning: 'Hate Groups',
values: ['icon-kkk', 'icon-nazi', 'icon-intolerant', 'behavior-intolerant'],
},
spam: {
id: 'spam',
title: 'Spam',
subtitle: 'Excessive unwanted interactions',
warning: 'Spam',
values: ['spam'],
},
impersonation: {
id: 'impersonation',
title: 'Impersonation',
subtitle: 'Accounts falsely claiming to be people or orgs',
warning: 'Impersonation',
values: ['impersonation'],
},
}
export function getModerationOpts({
userDid,
preferences,
}: {
userDid: string
preferences: UsePreferencesQueryResponse
}): ModerationOpts {
return {
userDid: userDid,
adultContentEnabled: preferences.adultContentEnabled,
labels: {
porn: preferences.contentLabels.nsfw,
sexual: preferences.contentLabels.suggestive,
nudity: preferences.contentLabels.nudity,
nsfl: preferences.contentLabels.gore,
corpse: preferences.contentLabels.gore,
gore: preferences.contentLabels.gore,
torture: preferences.contentLabels.gore,
'self-harm': preferences.contentLabels.gore,
'intolerant-race': preferences.contentLabels.hate,
'intolerant-gender': preferences.contentLabels.hate,
'intolerant-sexual-orientation': preferences.contentLabels.hate,
'intolerant-religion': preferences.contentLabels.hate,
intolerant: preferences.contentLabels.hate,
'icon-intolerant': preferences.contentLabels.hate,
spam: preferences.contentLabels.spam,
impersonation: preferences.contentLabels.impersonation,
scam: 'warn',
},
labelers: [
{
labeler: {
did: '',
displayName: 'Bluesky Social',
},
labels: {},
},
],
}
export function useLabelDefinitionsQuery() {
const labelers = useMyLabelersQuery()
return React.useMemo(() => {
return {
labelDefs: Object.fromEntries(
(labelers.data || []).map(labeler => [
labeler.creator.did,
interpretLabelValueDefinitions(labeler),
]),
),
labelers: labelers.data || [],
}
}, [labelers])
}

View file

@ -1,46 +1,13 @@
import {
BskyPreferences,
LabelPreference,
BskyThreadViewPreference,
BskyFeedViewPreference,
} from '@atproto/api'
export const configurableAdultLabelGroups = [
'nsfw',
'nudity',
'suggestive',
'gore',
] as const
export const configurableOtherLabelGroups = [
'hate',
'spam',
'impersonation',
] as const
export const configurableLabelGroups = [
...configurableAdultLabelGroups,
...configurableOtherLabelGroups,
] as const
export type ConfigurableLabelGroup = (typeof configurableLabelGroups)[number]
export type LabelGroup =
| ConfigurableLabelGroup
| 'illegal'
| 'always-filter'
| 'always-warn'
| 'unknown'
export type UsePreferencesQueryResponse = Omit<
BskyPreferences,
'contentLabels' | 'feedViewPrefs' | 'feeds'
> & {
/*
* Content labels previously included 'show', which has been deprecated in
* favor of 'ignore'. The API can return legacy data from the database, and
* we clean up the data in `usePreferencesQuery`.
*/
contentLabels: Record<ConfigurableLabelGroup, LabelPreference>
feedViewPrefs: BskyFeedViewPreference & {
lab_mergeFeedEnabled?: boolean
}

View file

@ -1,16 +0,0 @@
import {LabelPreference} from '@atproto/api'
/**
* Content labels previously included 'show', which has been deprecated in
* favor of 'ignore'. The API can return legacy data from the database, and
* we clean up the data in `usePreferencesQuery`.
*
* @deprecated
*/
export function temp__migrateLabelPref(
pref: LabelPreference | 'show',
): LabelPreference {
// @ts-ignore
if (pref === 'show') return 'ignore'
return pref
}

View file

@ -1,34 +0,0 @@
import {useQuery} from '@tanstack/react-query'
import {getAgent} from '#/state/session'
import {STALE} from '#/state/queries'
// TODO refactor invalidate on mutate?
export const RQKEY = (did: string) => ['profile-extra-info', did]
/**
* Fetches some additional information for the profile screen which
* is not available in the API's ProfileView
*/
export function useProfileExtraInfoQuery(did: string) {
return useQuery({
staleTime: STALE.MINUTES.ONE,
queryKey: RQKEY(did),
async queryFn() {
const [listsRes, feedsRes] = await Promise.all([
getAgent().app.bsky.graph.getLists({
actor: did,
limit: 1,
}),
getAgent().app.bsky.feed.getActorFeeds({
actor: did,
limit: 1,
}),
])
return {
hasLists: listsRes.data.lists.length > 0,
hasFeedgens: feedsRes.data.feeds.length > 0,
}
},
})
}

View file

@ -46,7 +46,8 @@ export function useSuggestedFollowsQuery() {
res.data.actors = res.data.actors
.filter(
actor => !moderateProfile(actor, moderationOpts!).account.filter,
actor =>
!moderateProfile(actor, moderationOpts!).ui('profileList').filter,
)
.filter(actor => {
const viewer = actor.viewer

View file

@ -0,0 +1,12 @@
import AsyncStorage from '@react-native-async-storage/async-storage'
const PREFIX = 'agent-labelers'
export async function saveLabelers(did: string, value: string[]) {
await AsyncStorage.setItem(`${PREFIX}:${did}`, JSON.stringify(value))
}
export async function readLabelers(did: string): Promise<string[] | undefined> {
const rawData = await AsyncStorage.getItem(`${PREFIX}:${did}`)
return rawData ? JSON.parse(rawData) : undefined
}

View file

@ -1,8 +1,15 @@
import React from 'react'
import {BskyAgent, AtpPersistSessionHandler} from '@atproto/api'
import {
BskyAgent,
AtpPersistSessionHandler,
BSKY_LABELER_DID,
} from '@atproto/api'
import {useQueryClient} from '@tanstack/react-query'
import {jwtDecode} from 'jwt-decode'
import {IS_DEV} from '#/env'
import {IS_TEST_USER} from '#/lib/constants'
import {isWeb} from '#/platform/detection'
import {networkRetry} from '#/lib/async/retry'
import {logger} from '#/logger'
import * as persisted from '#/state/persisted'
@ -12,6 +19,7 @@ import {useLoggedOutViewControls} from '#/state/shell/logged-out'
import {useCloseAllActiveElements} from '#/state/util'
import {track} from '#/lib/analytics/analytics'
import {hasProp} from '#/lib/type-guards'
import {readLabelers} from './agent-config'
let __globalAgent: BskyAgent = PUBLIC_BSKY_AGENT
@ -255,6 +263,8 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
deactivated,
}
await configureModeration(agent, account)
agent.setPersistSessionHandler(
createPersistSessionHandler(
account,
@ -298,6 +308,8 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
deactivated: isSessionDeactivated(agent.session.accessJwt),
}
await configureModeration(agent, account)
agent.setPersistSessionHandler(
createPersistSessionHandler(
account,
@ -309,6 +321,8 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
)
__globalAgent = agent
// @ts-ignore
if (IS_DEV && isWeb) window.agent = agent
queryClient.clear()
upsertAccount(account)
@ -348,6 +362,9 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
{networkErrorCallback: clearCurrentAccount},
),
})
// @ts-ignore
if (IS_DEV && isWeb) window.agent = agent
await configureModeration(agent, account)
let canReusePrevSession = false
try {
@ -643,6 +660,28 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
)
}
async function configureModeration(agent: BskyAgent, account: SessionAccount) {
if (IS_TEST_USER(account.handle)) {
const did = (
await agent
.resolveHandle({handle: 'mod-authority.test'})
.catch(_ => undefined)
)?.data.did
if (did) {
console.warn('USING TEST ENV MODERATION')
BskyAgent.configure({appLabelers: [did]})
}
} else {
BskyAgent.configure({appLabelers: [BSKY_LABELER_DID]})
const labelerDids = await readLabelers(account.did).catch(_ => {})
if (labelerDids) {
agent.configureLabelersHeader(
labelerDids.filter(did => did !== BSKY_LABELER_DID),
)
}
}
}
export function useSession() {
return React.useContext(StateContext)
}

View file

@ -2,7 +2,7 @@ import React from 'react'
import {
AppBskyEmbedRecord,
AppBskyRichtextFacet,
PostModeration,
ModerationDecision,
} from '@atproto/api'
import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback'
@ -16,7 +16,7 @@ export interface ComposerOptsPostRef {
avatar?: string
}
embed?: AppBskyEmbedRecord.ViewRecord['embed']
moderation?: PostModeration
moderation?: ModerationDecision
}
export interface ComposerOptsQuote {
uri: string