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:
parent
a20d034ba5
commit
2fed6c4021
41 changed files with 1292 additions and 530 deletions
|
@ -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: {
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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]}>
|
||||
|
|
|
@ -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',
|
||||
},
|
||||
})
|
|
@ -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,
|
||||
},
|
||||
})
|
||||
|
|
109
src/view/com/util/moderation/ContentHider.tsx
Normal file
109
src/view/com/util/moderation/ContentHider.tsx
Normal 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: {},
|
||||
})
|
105
src/view/com/util/moderation/PostHider.tsx
Normal file
105
src/view/com/util/moderation/PostHider.tsx
Normal 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,
|
||||
},
|
||||
})
|
55
src/view/com/util/moderation/ProfileHeaderLabels.tsx
Normal file
55
src/view/com/util/moderation/ProfileHeaderLabels.tsx
Normal 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,
|
||||
},
|
||||
})
|
|
@ -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}
|
||||
/>
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue