Files
bsky-social-app/src/view/screens/Moderation.tsx
T
Eric Bailey 58aaad704a Add tags and mute words (#2968)
* Add bare minimum hashtags support (#2804)

* Add bare minimum hashtags support

As atproto/api already parses hashtags, this is as simple as hooking it
up like link segments.

This is "bare minimum" because:

- Opening hashtag "#foo" is actually just a search for "foo" right now
  to work around #2491.
- There is no integration in the composer. This hasn't stopped people
  from using hashtags already, and can be added later.
- This change itself only had to hook things up - thank you for having
  already put the hashtag parsing in place.

* Remove workaround for hash search not working now that it's fixed

* Add RichTextTag and TagMenu

* Sketch

* Remove hackfix

* Some cleanup

* Sketch web

* Mobile design

* Mobile handling of tags search

* Web only

* Fix navigation woes

* Use new callback

* Hook it up

* Integrate muted tags

* Fix dropdown styles

* Type error

* Use close callback

* Fix styles

* Cleanup, install latest sdk

* Quick muted words screen

* Targets

* Dir structure

* Icons, list view

* Move to dialog

* Add removal confirmation

* Swap copy

* Improve checkboxees

* Update matching, add tests

* Moderate embeds

* Create global dialogs concept again to prevent flashing

* Add access from moderation screen

* Highlight tags on native

* Add web highlighting

* Add close to web modal

* Adjust close color

* Rename toggles and adjust logic

* Icon update

* Load states

* Improve regex

* Improve regex

* Improve regex

* Revert link test

* Hyphenated words

* Improve matching

* Enhance

* Some tweaks

* Muted words modal changes

* Handle invalid handles, handle long tags

* Remove main regex

* Better test

* Space/punct check drop to includes

* Lowercase post text before comparison

* Add better real world test case

---------

Co-authored-by: Kisaragi Hiu <mail@kisaragi-hiu.com>
2024-02-26 20:33:48 -08:00

305 lines
9.3 KiB
TypeScript

import React from 'react'
import {
ActivityIndicator,
StyleSheet,
TouchableOpacity,
View,
} from 'react-native'
import {useFocusEffect} from '@react-navigation/native'
import {
FontAwesomeIcon,
FontAwesomeIconStyle,
} from '@fortawesome/react-native-fontawesome'
import {ComAtprotoLabelDefs} from '@atproto/api'
import {NativeStackScreenProps, CommonNavigatorParams} from 'lib/routes/types'
import {s} from 'lib/styles'
import {CenteredView} from '../com/util/Views'
import {ViewHeader} from '../com/util/ViewHeader'
import {Link, TextLink} from '../com/util/Link'
import {Text} from '../com/util/text/Text'
import {usePalette} from 'lib/hooks/usePalette'
import {useAnalytics} from 'lib/analytics/analytics'
import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
import {useSetMinimalShellMode} from '#/state/shell'
import {useModalControls} from '#/state/modals'
import {Trans, msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {ToggleButton} from '../com/util/forms/ToggleButton'
import {useSession} from '#/state/session'
import {
useProfileQuery,
useProfileUpdateMutation,
} from '#/state/queries/profile'
import {ScrollView} from '../com/util/Views'
import {useGlobalDialogsControlContext} from '#/components/dialogs/Context'
type Props = NativeStackScreenProps<CommonNavigatorParams, 'Moderation'>
export function ModerationScreen({}: Props) {
const pal = usePalette('default')
const {_} = useLingui()
const setMinimalShellMode = useSetMinimalShellMode()
const {screen, track} = useAnalytics()
const {isTabletOrDesktop} = useWebMediaQueries()
const {openModal} = useModalControls()
const {mutedWordsDialogControl} = useGlobalDialogsControlContext()
useFocusEffect(
React.useCallback(() => {
screen('Moderation')
setMinimalShellMode(false)
}, [screen, setMinimalShellMode]),
)
const onPressContentFiltering = React.useCallback(() => {
track('Moderation:ContentfilteringButtonClicked')
openModal({name: 'content-filtering-settings'})
}, [track, openModal])
return (
<CenteredView
style={[
s.hContentRegion,
pal.border,
isTabletOrDesktop ? styles.desktopContainer : pal.viewLight,
]}
testID="moderationScreen">
<ViewHeader title={_(msg`Moderation`)} showOnDesktop />
<ScrollView contentContainerStyle={[styles.noBorder]}>
<View style={styles.spacer} />
<TouchableOpacity
testID="contentFilteringBtn"
style={[styles.linkCard, pal.view]}
onPress={onPressContentFiltering}
accessibilityRole="tab"
accessibilityHint=""
accessibilityLabel={_(msg`Open content filtering settings`)}>
<View style={[styles.iconContainer, pal.btn]}>
<FontAwesomeIcon
icon="eye"
style={pal.text as FontAwesomeIconStyle}
/>
</View>
<Text type="lg" style={pal.text}>
<Trans>Content filtering</Trans>
</Text>
</TouchableOpacity>
<TouchableOpacity
testID="mutedWordsBtn"
style={[styles.linkCard, pal.view]}
onPress={() => mutedWordsDialogControl.open()}
accessibilityRole="tab"
accessibilityHint=""
accessibilityLabel={_(msg`Open muted words settings`)}>
<View style={[styles.iconContainer, pal.btn]}>
<FontAwesomeIcon
icon="filter"
style={pal.text as FontAwesomeIconStyle}
/>
</View>
<Text type="lg" style={pal.text}>
<Trans>Muted words & tags</Trans>
</Text>
</TouchableOpacity>
<Link
testID="moderationlistsBtn"
style={[styles.linkCard, pal.view]}
href="/moderation/modlists">
<View style={[styles.iconContainer, pal.btn]}>
<FontAwesomeIcon
icon="users-slash"
style={pal.text as FontAwesomeIconStyle}
/>
</View>
<Text type="lg" style={pal.text}>
<Trans>Moderation lists</Trans>
</Text>
</Link>
<Link
testID="mutedAccountsBtn"
style={[styles.linkCard, pal.view]}
href="/moderation/muted-accounts">
<View style={[styles.iconContainer, pal.btn]}>
<FontAwesomeIcon
icon="user-slash"
style={pal.text as FontAwesomeIconStyle}
/>
</View>
<Text type="lg" style={pal.text}>
<Trans>Muted accounts</Trans>
</Text>
</Link>
<Link
testID="blockedAccountsBtn"
style={[styles.linkCard, pal.view]}
href="/moderation/blocked-accounts">
<View style={[styles.iconContainer, pal.btn]}>
<FontAwesomeIcon
icon="ban"
style={pal.text as FontAwesomeIconStyle}
/>
</View>
<Text type="lg" style={pal.text}>
<Trans>Blocked accounts</Trans>
</Text>
</Link>
<Text
type="xl-bold"
style={[
pal.text,
{
paddingHorizontal: 18,
paddingTop: 18,
paddingBottom: 6,
},
]}>
<Trans>Logged-out visibility</Trans>
</Text>
<PwiOptOut />
</ScrollView>
</CenteredView>
)
}
function PwiOptOut() {
const pal = usePalette('default')
const {_} = useLingui()
const {currentAccount} = useSession()
const {data: profile} = useProfileQuery({did: currentAccount?.did})
const updateProfile = useProfileUpdateMutation()
const isOptedOut =
profile?.labels?.some(l => l.val === '!no-unauthenticated') || false
const canToggle = profile && !updateProfile.isPending
const onToggleOptOut = React.useCallback(() => {
if (!profile) {
return
}
let wasAdded = false
updateProfile.mutate({
profile,
updates: existing => {
// create labels attr if needed
existing.labels = ComAtprotoLabelDefs.isSelfLabels(existing.labels)
? existing.labels
: {
$type: 'com.atproto.label.defs#selfLabels',
values: [],
}
// toggle the label
const hasLabel = existing.labels.values.some(
l => l.val === '!no-unauthenticated',
)
if (hasLabel) {
wasAdded = false
existing.labels.values = existing.labels.values.filter(
l => l.val !== '!no-unauthenticated',
)
} else {
wasAdded = true
existing.labels.values.push({val: '!no-unauthenticated'})
}
// delete if no longer needed
if (existing.labels.values.length === 0) {
delete existing.labels
}
return existing
},
checkCommitted: res => {
const exists = !!res.data.labels?.some(
l => l.val === '!no-unauthenticated',
)
return exists === wasAdded
},
})
}, [updateProfile, profile])
return (
<View style={[pal.view, styles.toggleCard]}>
<View
style={{flexDirection: 'row', alignItems: 'center', paddingRight: 14}}>
<ToggleButton
type="default-light"
label={_(
msg`Discourage apps from showing my account to logged-out users`,
)}
labelType="lg"
isSelected={isOptedOut}
onPress={canToggle ? onToggleOptOut : undefined}
style={[canToggle ? undefined : {opacity: 0.5}, {flex: 1}]}
/>
{updateProfile.isPending && <ActivityIndicator />}
</View>
<View
style={{
flexDirection: 'column',
gap: 10,
paddingLeft: 66,
paddingRight: 12,
paddingBottom: 10,
marginBottom: 64,
}}>
<Text style={pal.textLight}>
<Trans>
Bluesky will not show your profile and posts to logged-out users.
Other apps may not honor this request. This does not make your
account private.
</Trans>
</Text>
<Text style={[pal.textLight, {fontWeight: '500'}]}>
<Trans>
Note: Bluesky is an open and public network. This setting only
limits the visibility of your content on the Bluesky app and
website, and other apps may not respect this setting. Your content
may still be shown to logged-out users by other apps and websites.
</Trans>
</Text>
<TextLink
style={pal.link}
href="https://blueskyweb.zendesk.com/hc/en-us/articles/15835264007693-Data-Privacy"
text={_(msg`Learn more about what is public on Bluesky.`)}
/>
</View>
</View>
)
}
const styles = StyleSheet.create({
desktopContainer: {
borderLeftWidth: 1,
borderRightWidth: 1,
},
spacer: {
height: 6,
},
linkCard: {
flexDirection: 'row',
alignItems: 'center',
paddingVertical: 12,
paddingHorizontal: 18,
marginBottom: 1,
},
toggleCard: {
paddingVertical: 8,
paddingTop: 2,
paddingHorizontal: 6,
marginBottom: 1,
},
iconContainer: {
alignItems: 'center',
justifyContent: 'center',
width: 40,
height: 40,
borderRadius: 30,
marginRight: 12,
},
noBorder: {
borderBottomWidth: 0,
borderRightWidth: 0,
borderLeftWidth: 0,
borderTopWidth: 0,
},
})