Add first round of labeling tools (#467)

* Rework notifications to sync locally in full and give users better control

* Fix positioning of load more btn on web

* Improve behavior of load more notifications btn

* Fix to post rendering

* Fix notification fetch abort condition

* Add start of post-hiding by labels

* Create a standard postcontainer and improve show/hide UI on posts

* Add content hiding to expanded post form

* Improve label rendering to give more context to users when appropriate

* Fix rendering bug

* Add user/profile labeling

* Implement content filtering preferences

* Filter notifications by content prefs

* Update test-pds config

* Bump deps
This commit is contained in:
Paul Frazee 2023-04-12 18:26:38 -07:00 committed by GitHub
parent a20d034ba5
commit 2fed6c4021
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
41 changed files with 1292 additions and 530 deletions

View file

@ -10,31 +10,33 @@ import {useStores} from 'state/index'
const HITSLOP = {left: 20, top: 20, right: 20, bottom: 20}
export const LoadLatestBtn = observer(({onPress}: {onPress: () => void}) => {
const store = useStores()
const safeAreaInsets = useSafeAreaInsets()
return (
<TouchableOpacity
style={[
styles.loadLatest,
!store.shell.minimalShellMode && {
bottom: 60 + clamp(safeAreaInsets.bottom, 15, 30),
},
]}
onPress={onPress}
hitSlop={HITSLOP}>
<LinearGradient
colors={[gradients.blueLight.start, gradients.blueLight.end]}
start={{x: 0, y: 0}}
end={{x: 1, y: 1}}
style={styles.loadLatestInner}>
<Text type="md-bold" style={styles.loadLatestText}>
Load new posts
</Text>
</LinearGradient>
</TouchableOpacity>
)
})
export const LoadLatestBtn = observer(
({onPress, label}: {onPress: () => void; label: string}) => {
const store = useStores()
const safeAreaInsets = useSafeAreaInsets()
return (
<TouchableOpacity
style={[
styles.loadLatest,
!store.shell.minimalShellMode && {
bottom: 60 + clamp(safeAreaInsets.bottom, 15, 30),
},
]}
onPress={onPress}
hitSlop={HITSLOP}>
<LinearGradient
colors={[gradients.blueLight.start, gradients.blueLight.end]}
start={{x: 0, y: 0}}
end={{x: 1, y: 1}}
style={styles.loadLatestInner}>
<Text type="md-bold" style={styles.loadLatestText}>
Load new {label}
</Text>
</LinearGradient>
</TouchableOpacity>
)
},
)
const styles = StyleSheet.create({
loadLatest: {

View file

@ -6,7 +6,13 @@ import {UpIcon} from 'lib/icons'
const HITSLOP = {left: 20, top: 20, right: 20, bottom: 20}
export const LoadLatestBtn = ({onPress}: {onPress: () => void}) => {
export const LoadLatestBtn = ({
onPress,
label,
}: {
onPress: () => void
label: string
}) => {
const pal = usePalette('default')
return (
<TouchableOpacity
@ -15,7 +21,7 @@ export const LoadLatestBtn = ({onPress}: {onPress: () => void}) => {
hitSlop={HITSLOP}>
<Text type="md-bold" style={pal.text}>
<UpIcon size={16} strokeWidth={1} style={[pal.text, styles.icon]} />
Load new posts
Load new {label}
</Text>
</TouchableOpacity>
)
@ -25,7 +31,9 @@ const styles = StyleSheet.create({
loadLatest: {
flexDirection: 'row',
position: 'absolute',
left: 'calc(50vw - 80px)',
left: '50vw',
// @ts-ignore web only -prf
transform: 'translateX(-50%)',
top: 30,
shadowColor: '#000',
shadowOpacity: 0.2,

View file

@ -15,6 +15,7 @@ interface PostMetaOpts {
authorAvatar?: string
authorHandle: string
authorDisplayName: string | undefined
authorHasWarning: boolean
postHref: string
timestamp: string
did?: string
@ -93,7 +94,11 @@ export const PostMeta = observer(function (opts: PostMetaOpts) {
<View style={styles.meta}>
{typeof opts.authorAvatar !== 'undefined' && (
<View style={[styles.metaItem, styles.avatar]}>
<UserAvatar avatar={opts.authorAvatar} size={16} />
<UserAvatar
avatar={opts.authorAvatar}
size={16}
hasWarning={opts.authorHasWarning}
/>
</View>
)}
<View style={[styles.metaItem, styles.maxWidth]}>

View file

@ -1,50 +0,0 @@
import React from 'react'
import {StyleSheet, TouchableOpacity, View} from 'react-native'
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
import {usePalette} from 'lib/hooks/usePalette'
import {Text} from './text/Text'
export function PostMutedWrapper({
isMuted,
children,
}: React.PropsWithChildren<{isMuted?: boolean}>) {
const pal = usePalette('default')
const [override, setOverride] = React.useState(false)
if (!isMuted || override) {
return <>{children}</>
}
return (
<View style={[styles.container, pal.view, pal.border]}>
<FontAwesomeIcon
icon={['far', 'eye-slash']}
style={[styles.icon, pal.text]}
/>
<Text type="md" style={pal.textLight}>
Post from an account you muted.
</Text>
<TouchableOpacity
style={styles.showBtn}
onPress={() => setOverride(true)}>
<Text type="md" style={pal.link}>
Show post
</Text>
</TouchableOpacity>
</View>
)
}
const styles = StyleSheet.create({
container: {
flexDirection: 'row',
alignItems: 'center',
paddingVertical: 14,
paddingHorizontal: 18,
borderTopWidth: 1,
},
icon: {
marginRight: 10,
},
showBtn: {
marginLeft: 'auto',
},
})

View file

@ -44,10 +44,12 @@ function DefaultAvatar({size}: {size: number}) {
export function UserAvatar({
size,
avatar,
hasWarning,
onSelectNewAvatar,
}: {
size: number
avatar?: string | null
hasWarning?: boolean
onSelectNewAvatar?: (img: PickedMedia | null) => void
}) {
const store = useStores()
@ -105,6 +107,22 @@ export function UserAvatar({
},
},
]
const warning = React.useMemo(() => {
if (!hasWarning) {
return <></>
}
return (
<View style={[styles.warningIconContainer, pal.view]}>
<FontAwesomeIcon
icon="exclamation-circle"
style={styles.warningIcon}
size={Math.floor(size / 3)}
/>
</View>
)
}, [hasWarning, size, pal])
// onSelectNewAvatar is only passed as prop on the EditProfile component
return onSelectNewAvatar ? (
<DropdownButton
@ -137,14 +155,20 @@ export function UserAvatar({
</View>
</DropdownButton>
) : avatar ? (
<HighPriorityImage
testID="userAvatarImage"
style={{width: size, height: size, borderRadius: Math.floor(size / 2)}}
resizeMode="stretch"
source={{uri: avatar}}
/>
<View style={{width: size, height: size}}>
<HighPriorityImage
testID="userAvatarImage"
style={{width: size, height: size, borderRadius: Math.floor(size / 2)}}
resizeMode="stretch"
source={{uri: avatar}}
/>
{warning}
</View>
) : (
<DefaultAvatar size={size} />
<View style={{width: size, height: size}}>
<DefaultAvatar size={size} />
{warning}
</View>
)
}
@ -165,4 +189,13 @@ const styles = StyleSheet.create({
height: 80,
borderRadius: 40,
},
warningIconContainer: {
position: 'absolute',
right: 0,
bottom: 0,
borderRadius: 100,
},
warningIcon: {
color: colors.red3,
},
})

View file

@ -0,0 +1,109 @@
import React from 'react'
import {
StyleProp,
StyleSheet,
TouchableOpacity,
View,
ViewStyle,
} from 'react-native'
import {ComAtprotoLabelDefs} from '@atproto/api'
import {usePalette} from 'lib/hooks/usePalette'
import {useStores} from 'state/index'
import {Text} from '../text/Text'
import {addStyle} from 'lib/styles'
export function ContentHider({
testID,
isMuted,
labels,
style,
containerStyle,
children,
}: React.PropsWithChildren<{
testID?: string
isMuted?: boolean
labels: ComAtprotoLabelDefs.Label[] | undefined
style?: StyleProp<ViewStyle>
containerStyle?: StyleProp<ViewStyle>
}>) {
const pal = usePalette('default')
const [override, setOverride] = React.useState(false)
const store = useStores()
const labelPref = store.preferences.getLabelPreference(labels)
if (!isMuted && labelPref.pref === 'show') {
return (
<View testID={testID} style={style}>
{children}
</View>
)
}
if (labelPref.pref === 'hide') {
return <></>
}
return (
<View style={[styles.container, pal.view, pal.border, containerStyle]}>
<View
style={[
styles.description,
pal.viewLight,
override && styles.descriptionOpen,
]}>
<Text type="md" style={pal.textLight}>
{isMuted ? (
<>Post from an account you muted.</>
) : (
<>Warning: {labelPref.desc.title}</>
)}
</Text>
<TouchableOpacity
style={styles.showBtn}
onPress={() => setOverride(v => !v)}>
<Text type="md" style={pal.link}>
{override ? 'Hide' : 'Show'}
</Text>
</TouchableOpacity>
</View>
{override && (
<View style={[styles.childrenContainer, pal.border]}>
<View testID={testID} style={addStyle(style, styles.child)}>
{children}
</View>
</View>
)}
</View>
)
}
const styles = StyleSheet.create({
container: {
marginBottom: 10,
borderWidth: 1,
borderRadius: 12,
},
description: {
flexDirection: 'row',
alignItems: 'center',
paddingVertical: 14,
paddingLeft: 14,
paddingRight: 18,
borderRadius: 12,
},
descriptionOpen: {
borderBottomLeftRadius: 0,
borderBottomRightRadius: 0,
},
icon: {
marginRight: 10,
},
showBtn: {
marginLeft: 'auto',
},
childrenContainer: {
paddingHorizontal: 12,
paddingTop: 8,
},
child: {},
})

View file

@ -0,0 +1,105 @@
import React from 'react'
import {
StyleProp,
StyleSheet,
TouchableOpacity,
View,
ViewStyle,
} from 'react-native'
import {ComAtprotoLabelDefs} from '@atproto/api'
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
import {usePalette} from 'lib/hooks/usePalette'
import {Link} from '../Link'
import {Text} from '../text/Text'
import {addStyle} from 'lib/styles'
import {useStores} from 'state/index'
export function PostHider({
testID,
href,
isMuted,
labels,
style,
children,
}: React.PropsWithChildren<{
testID?: string
href: string
isMuted: boolean | undefined
labels: ComAtprotoLabelDefs.Label[] | undefined
style: StyleProp<ViewStyle>
}>) {
const store = useStores()
const pal = usePalette('default')
const [override, setOverride] = React.useState(false)
const bg = override ? pal.viewLight : pal.view
const labelPref = store.preferences.getLabelPreference(labels)
if (labelPref.pref === 'hide') {
return <></>
}
if (!isMuted) {
// NOTE: any further label enforcement should occur in ContentContainer
return (
<Link testID={testID} style={style} href={href} noFeedback>
{children}
</Link>
)
}
return (
<>
<View style={[styles.description, bg, pal.border]}>
<FontAwesomeIcon
icon={['far', 'eye-slash']}
style={[styles.icon, pal.text]}
/>
<Text type="md" style={pal.textLight}>
Post from an account you muted.
</Text>
<TouchableOpacity
style={styles.showBtn}
onPress={() => setOverride(v => !v)}>
<Text type="md" style={pal.link}>
{override ? 'Hide' : 'Show'} post
</Text>
</TouchableOpacity>
</View>
{override && (
<View style={[styles.childrenContainer, pal.border, bg]}>
<Link
testID={testID}
style={addStyle(style, styles.child)}
href={href}
noFeedback>
{children}
</Link>
</View>
)}
</>
)
}
const styles = StyleSheet.create({
description: {
flexDirection: 'row',
alignItems: 'center',
paddingVertical: 14,
paddingHorizontal: 18,
borderTopWidth: 1,
},
icon: {
marginRight: 10,
},
showBtn: {
marginLeft: 'auto',
},
childrenContainer: {
paddingHorizontal: 6,
paddingBottom: 6,
},
child: {
borderWidth: 1,
borderRadius: 12,
},
})

View file

@ -0,0 +1,55 @@
import React from 'react'
import {StyleSheet, View} from 'react-native'
import {ComAtprotoLabelDefs} from '@atproto/api'
import {
FontAwesomeIcon,
FontAwesomeIconStyle,
} from '@fortawesome/react-native-fontawesome'
import {Text} from '../text/Text'
import {usePalette} from 'lib/hooks/usePalette'
import {getLabelValueGroup} from 'lib/labeling/helpers'
export function ProfileHeaderLabels({
labels,
}: {
labels: ComAtprotoLabelDefs.Label[] | undefined
}) {
const palErr = usePalette('error')
if (!labels?.length) {
return null
}
return (
<>
{labels.map((label, i) => {
const labelGroup = getLabelValueGroup(label?.val || '')
return (
<View
key={`${label.val}-${i}`}
style={[styles.container, palErr.border, palErr.view]}>
<FontAwesomeIcon
icon="circle-exclamation"
style={palErr.text as FontAwesomeIconStyle}
size={20}
/>
<Text style={palErr.text}>
This account has been flagged for{' '}
{labelGroup.title.toLocaleLowerCase()}.
</Text>
</View>
)
})}
</>
)
}
const styles = StyleSheet.create({
container: {
flexDirection: 'row',
alignItems: 'center',
gap: 10,
borderWidth: 1,
borderRadius: 6,
paddingHorizontal: 10,
paddingVertical: 8,
},
})

View file

@ -42,6 +42,7 @@ export function QuoteEmbed({
authorAvatar={quote.author.avatar}
authorHandle={quote.author.handle}
authorDisplayName={quote.author.displayName}
authorHasWarning={false}
postHref={itemHref}
timestamp={quote.indexedAt}
/>