Improve moderation behaviors: show alert/inform sources and improve UX around threads (#3677)
* Dont show account or profile alerts and informs on posts * Sort threads to put blurred items at bottom * Group blurred replies under a single 'show hidden replies' control * Distinguish between muted and hidden replies in the thread view * Fix types * Modify the label alerts with some minor aesthetic updates and to show the source of a label * Tune when an account-level alert is shown on a post * Revert: show account-level alerts on posts again * Rm unused import * Fix to showing hidden replies when viewing a blurred item * Go ahead and uncover replies when 'show hidden posts' is clicked --------- Co-authored-by: dan <dan.abramov@gmail.com>
This commit is contained in:
parent
d2c42cf169
commit
f7ee532a85
9 changed files with 311 additions and 67 deletions
|
@ -10,8 +10,10 @@ import {ScrollProvider} from '#/lib/ScrollContext'
|
|||
import {isAndroid, isNative, isWeb} from '#/platform/detection'
|
||||
import {useModerationOpts} from '#/state/preferences/moderation-opts'
|
||||
import {
|
||||
fillThreadModerationCache,
|
||||
sortThread,
|
||||
ThreadBlocked,
|
||||
ThreadModerationCache,
|
||||
ThreadNode,
|
||||
ThreadNotFound,
|
||||
ThreadPost,
|
||||
|
@ -31,6 +33,7 @@ import {List, ListMethods} from '../util/List'
|
|||
import {Text} from '../util/text/Text'
|
||||
import {ViewHeader} from '../util/ViewHeader'
|
||||
import {PostThreadItem} from './PostThreadItem'
|
||||
import {PostThreadShowHiddenReplies} from './PostThreadShowHiddenReplies'
|
||||
|
||||
// FlatList maintainVisibleContentPosition breaks if too many items
|
||||
// are prepended. This seems to be an optimal number based on *shrug*.
|
||||
|
@ -45,8 +48,21 @@ const MAINTAIN_VISIBLE_CONTENT_POSITION = {
|
|||
const TOP_COMPONENT = {_reactKey: '__top_component__'}
|
||||
const REPLY_PROMPT = {_reactKey: '__reply__'}
|
||||
const LOAD_MORE = {_reactKey: '__load_more__'}
|
||||
const SHOW_HIDDEN_REPLIES = {_reactKey: '__show_hidden_replies__'}
|
||||
const SHOW_MUTED_REPLIES = {_reactKey: '__show_muted_replies__'}
|
||||
|
||||
type YieldedItem = ThreadPost | ThreadBlocked | ThreadNotFound
|
||||
enum HiddenRepliesState {
|
||||
Hide,
|
||||
Show,
|
||||
ShowAndOverridePostHider,
|
||||
}
|
||||
|
||||
type YieldedItem =
|
||||
| ThreadPost
|
||||
| ThreadBlocked
|
||||
| ThreadNotFound
|
||||
| typeof SHOW_HIDDEN_REPLIES
|
||||
| typeof SHOW_MUTED_REPLIES
|
||||
type RowItem =
|
||||
| YieldedItem
|
||||
// TODO: TS doesn't actually enforce it's one of these, it only enforces matching shape.
|
||||
|
@ -79,6 +95,9 @@ export function PostThread({
|
|||
const {isMobile, isTabletOrMobile} = useWebMediaQueries()
|
||||
const initialNumToRender = useInitialNumToRender()
|
||||
const {height: windowHeight} = useWindowDimensions()
|
||||
const [hiddenRepliesState, setHiddenRepliesState] = React.useState(
|
||||
HiddenRepliesState.Hide,
|
||||
)
|
||||
|
||||
const {data: preferences} = usePreferencesQuery()
|
||||
const {
|
||||
|
@ -135,16 +154,33 @@ export function PostThread({
|
|||
// On the web this is not necessary because we can synchronously adjust the scroll in onContentSizeChange instead.
|
||||
const [deferParents, setDeferParents] = React.useState(isNative)
|
||||
|
||||
const threadModerationCache = React.useMemo(() => {
|
||||
const cache: ThreadModerationCache = new WeakMap()
|
||||
if (thread && moderationOpts) {
|
||||
fillThreadModerationCache(cache, thread, moderationOpts)
|
||||
}
|
||||
return cache
|
||||
}, [thread, moderationOpts])
|
||||
|
||||
const skeleton = React.useMemo(() => {
|
||||
const threadViewPrefs = preferences?.threadViewPrefs
|
||||
if (!threadViewPrefs || !thread) return null
|
||||
|
||||
return createThreadSkeleton(
|
||||
sortThread(thread, threadViewPrefs),
|
||||
sortThread(thread, threadViewPrefs, threadModerationCache),
|
||||
hasSession,
|
||||
treeView,
|
||||
threadModerationCache,
|
||||
hiddenRepliesState !== HiddenRepliesState.Hide,
|
||||
)
|
||||
}, [thread, preferences?.threadViewPrefs, hasSession, treeView])
|
||||
}, [
|
||||
thread,
|
||||
preferences?.threadViewPrefs,
|
||||
hasSession,
|
||||
treeView,
|
||||
threadModerationCache,
|
||||
hiddenRepliesState,
|
||||
])
|
||||
|
||||
const error = React.useMemo(() => {
|
||||
if (AppBskyFeedDefs.isNotFoundPost(thread)) {
|
||||
|
@ -301,6 +337,24 @@ export function PostThread({
|
|||
{!isMobile && <ComposePrompt onPressCompose={onPressReply} />}
|
||||
</View>
|
||||
)
|
||||
} else if (item === SHOW_HIDDEN_REPLIES) {
|
||||
return (
|
||||
<PostThreadShowHiddenReplies
|
||||
type="hidden"
|
||||
onPress={() =>
|
||||
setHiddenRepliesState(HiddenRepliesState.ShowAndOverridePostHider)
|
||||
}
|
||||
/>
|
||||
)
|
||||
} else if (item === SHOW_MUTED_REPLIES) {
|
||||
return (
|
||||
<PostThreadShowHiddenReplies
|
||||
type="muted"
|
||||
onPress={() =>
|
||||
setHiddenRepliesState(HiddenRepliesState.ShowAndOverridePostHider)
|
||||
}
|
||||
/>
|
||||
)
|
||||
} else if (isThreadNotFound(item)) {
|
||||
return (
|
||||
<View style={[pal.border, pal.viewLight, styles.itemContainer]}>
|
||||
|
@ -321,9 +375,12 @@ export function PostThread({
|
|||
const prev = isThreadPost(posts[index - 1])
|
||||
? (posts[index - 1] as ThreadPost)
|
||||
: undefined
|
||||
const next = isThreadPost(posts[index - 1])
|
||||
? (posts[index - 1] as ThreadPost)
|
||||
const next = isThreadPost(posts[index + 1])
|
||||
? (posts[index + 1] as ThreadPost)
|
||||
: undefined
|
||||
const showChildReplyLine = (next?.ctx.depth || 0) > item.ctx.depth
|
||||
const showParentReplyLine =
|
||||
(item.ctx.depth < 0 && !!item.parent) || item.ctx.depth > 1
|
||||
const hasUnrevealedParents =
|
||||
index === 0 &&
|
||||
skeleton?.parents &&
|
||||
|
@ -335,16 +392,20 @@ export function PostThread({
|
|||
<PostThreadItem
|
||||
post={item.post}
|
||||
record={item.record}
|
||||
moderation={threadModerationCache.get(item)}
|
||||
treeView={treeView}
|
||||
depth={item.ctx.depth}
|
||||
prevPost={prev}
|
||||
nextPost={next}
|
||||
isHighlightedPost={item.ctx.isHighlightedPost}
|
||||
hasMore={item.ctx.hasMore}
|
||||
showChildReplyLine={item.ctx.showChildReplyLine}
|
||||
showParentReplyLine={item.ctx.showParentReplyLine}
|
||||
hasPrecedingItem={
|
||||
!!prev?.ctx.showChildReplyLine || !!hasUnrevealedParents
|
||||
showChildReplyLine={showChildReplyLine}
|
||||
showParentReplyLine={showParentReplyLine}
|
||||
hasPrecedingItem={showParentReplyLine || !!hasUnrevealedParents}
|
||||
overrideBlur={
|
||||
hiddenRepliesState ===
|
||||
HiddenRepliesState.ShowAndOverridePostHider &&
|
||||
item.ctx.depth > 0
|
||||
}
|
||||
onPostReply={refetch}
|
||||
/>
|
||||
|
@ -368,6 +429,9 @@ export function PostThread({
|
|||
deferParents,
|
||||
treeView,
|
||||
refetch,
|
||||
threadModerationCache,
|
||||
hiddenRepliesState,
|
||||
setHiddenRepliesState,
|
||||
],
|
||||
)
|
||||
|
||||
|
@ -437,13 +501,23 @@ function createThreadSkeleton(
|
|||
node: ThreadNode,
|
||||
hasSession: boolean,
|
||||
treeView: boolean,
|
||||
modCache: ThreadModerationCache,
|
||||
showHiddenReplies: boolean,
|
||||
): ThreadSkeletonParts | null {
|
||||
if (!node) return null
|
||||
|
||||
return {
|
||||
parents: Array.from(flattenThreadParents(node, hasSession)),
|
||||
highlightedPost: node,
|
||||
replies: Array.from(flattenThreadReplies(node, hasSession, treeView)),
|
||||
replies: Array.from(
|
||||
flattenThreadReplies(
|
||||
node,
|
||||
hasSession,
|
||||
treeView,
|
||||
modCache,
|
||||
showHiddenReplies,
|
||||
),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -465,31 +539,76 @@ function* flattenThreadParents(
|
|||
}
|
||||
}
|
||||
|
||||
// The enum is ordered to make them easy to merge
|
||||
enum HiddenReplyType {
|
||||
None = 0,
|
||||
Muted = 1,
|
||||
Hidden = 2,
|
||||
}
|
||||
|
||||
function* flattenThreadReplies(
|
||||
node: ThreadNode,
|
||||
hasSession: boolean,
|
||||
treeView: boolean,
|
||||
): Generator<YieldedItem, void> {
|
||||
modCache: ThreadModerationCache,
|
||||
showHiddenReplies: boolean,
|
||||
): Generator<YieldedItem, HiddenReplyType> {
|
||||
if (node.type === 'post') {
|
||||
// dont show pwi-opted-out posts to logged out users
|
||||
if (!hasSession && hasPwiOptOut(node)) {
|
||||
return
|
||||
return HiddenReplyType.None
|
||||
}
|
||||
|
||||
// handle blurred items
|
||||
if (node.ctx.depth > 0) {
|
||||
const modui = modCache.get(node)?.ui('contentList')
|
||||
if (modui?.blur) {
|
||||
if (!showHiddenReplies || node.ctx.depth > 1) {
|
||||
if (modui.blurs[0].type === 'muted') {
|
||||
return HiddenReplyType.Muted
|
||||
}
|
||||
return HiddenReplyType.Hidden
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!node.ctx.isHighlightedPost) {
|
||||
yield node
|
||||
}
|
||||
|
||||
if (node.replies?.length) {
|
||||
let hiddenReplies = HiddenReplyType.None
|
||||
for (const reply of node.replies) {
|
||||
yield* flattenThreadReplies(reply, hasSession, treeView)
|
||||
let hiddenReply = yield* flattenThreadReplies(
|
||||
reply,
|
||||
hasSession,
|
||||
treeView,
|
||||
modCache,
|
||||
showHiddenReplies,
|
||||
)
|
||||
if (hiddenReply > hiddenReplies) {
|
||||
hiddenReplies = hiddenReply
|
||||
}
|
||||
if (!treeView && !node.ctx.isHighlightedPost) {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// show control to enable hidden replies
|
||||
if (node.ctx.depth === 0) {
|
||||
if (hiddenReplies === HiddenReplyType.Muted) {
|
||||
yield SHOW_MUTED_REPLIES
|
||||
} else if (hiddenReplies === HiddenReplyType.Hidden) {
|
||||
yield SHOW_HIDDEN_REPLIES
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (node.type === 'not-found') {
|
||||
yield node
|
||||
} else if (node.type === 'blocked') {
|
||||
yield node
|
||||
}
|
||||
return HiddenReplyType.None
|
||||
}
|
||||
|
||||
function hasPwiOptOut(node: ThreadPost) {
|
||||
|
|
|
@ -11,11 +11,9 @@ import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
|
|||
import {msg, Plural, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {moderatePost_wrapped as moderatePost} from '#/lib/moderatePost_wrapped'
|
||||
import {POST_TOMBSTONE, Shadow, usePostShadow} from '#/state/cache/post-shadow'
|
||||
import {useLanguagePrefs} from '#/state/preferences'
|
||||
import {useOpenLink} from '#/state/preferences/in-app-browser'
|
||||
import {useModerationOpts} from '#/state/preferences/moderation-opts'
|
||||
import {ThreadPost} from '#/state/queries/post-thread'
|
||||
import {useComposerControls} from '#/state/shell/composer'
|
||||
import {MAX_POST_LINES} from 'lib/constants'
|
||||
|
@ -50,6 +48,7 @@ import {PreviewableUserAvatar} from '../util/UserAvatar'
|
|||
export function PostThreadItem({
|
||||
post,
|
||||
record,
|
||||
moderation,
|
||||
treeView,
|
||||
depth,
|
||||
prevPost,
|
||||
|
@ -59,10 +58,12 @@ export function PostThreadItem({
|
|||
showChildReplyLine,
|
||||
showParentReplyLine,
|
||||
hasPrecedingItem,
|
||||
overrideBlur,
|
||||
onPostReply,
|
||||
}: {
|
||||
post: AppBskyFeedDefs.PostView
|
||||
record: AppBskyFeedPost.Record
|
||||
moderation: ModerationDecision | undefined
|
||||
treeView: boolean
|
||||
depth: number
|
||||
prevPost: ThreadPost | undefined
|
||||
|
@ -72,9 +73,9 @@ export function PostThreadItem({
|
|||
showChildReplyLine?: boolean
|
||||
showParentReplyLine?: boolean
|
||||
hasPrecedingItem: boolean
|
||||
overrideBlur: boolean
|
||||
onPostReply: () => void
|
||||
}) {
|
||||
const moderationOpts = useModerationOpts()
|
||||
const postShadowed = usePostShadow(post)
|
||||
const richText = useMemo(
|
||||
() =>
|
||||
|
@ -84,11 +85,6 @@ export function PostThreadItem({
|
|||
}),
|
||||
[record],
|
||||
)
|
||||
const moderation = useMemo(
|
||||
() =>
|
||||
post && moderationOpts ? moderatePost(post, moderationOpts) : undefined,
|
||||
[post, moderationOpts],
|
||||
)
|
||||
if (postShadowed === POST_TOMBSTONE) {
|
||||
return <PostThreadItemDeleted />
|
||||
}
|
||||
|
@ -110,6 +106,7 @@ export function PostThreadItem({
|
|||
showChildReplyLine={showChildReplyLine}
|
||||
showParentReplyLine={showParentReplyLine}
|
||||
hasPrecedingItem={hasPrecedingItem}
|
||||
overrideBlur={overrideBlur}
|
||||
onPostReply={onPostReply}
|
||||
/>
|
||||
)
|
||||
|
@ -143,6 +140,7 @@ let PostThreadItemLoaded = ({
|
|||
showChildReplyLine,
|
||||
showParentReplyLine,
|
||||
hasPrecedingItem,
|
||||
overrideBlur,
|
||||
onPostReply,
|
||||
}: {
|
||||
post: Shadow<AppBskyFeedDefs.PostView>
|
||||
|
@ -158,6 +156,7 @@ let PostThreadItemLoaded = ({
|
|||
showChildReplyLine?: boolean
|
||||
showParentReplyLine?: boolean
|
||||
hasPrecedingItem: boolean
|
||||
overrideBlur: boolean
|
||||
onPostReply: () => void
|
||||
}): React.ReactNode => {
|
||||
const pal = usePalette('default')
|
||||
|
@ -394,6 +393,7 @@ let PostThreadItemLoaded = ({
|
|||
<PostHider
|
||||
testID={`postThreadItem-by-${post.author.handle}`}
|
||||
href={postHref}
|
||||
disabled={overrideBlur}
|
||||
style={[pal.view]}
|
||||
modui={moderation.ui('contentList')}
|
||||
iconSize={isThreadedChild ? 26 : 38}
|
||||
|
|
61
src/view/com/post-thread/PostThreadShowHiddenReplies.tsx
Normal file
61
src/view/com/post-thread/PostThreadShowHiddenReplies.tsx
Normal file
|
@ -0,0 +1,61 @@
|
|||
import * as React from 'react'
|
||||
import {View} from 'react-native'
|
||||
import {msg} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import {Button} from '#/components/Button'
|
||||
import {EyeSlash_Stroke2_Corner0_Rounded as EyeSlash} from '#/components/icons/EyeSlash'
|
||||
import {Text} from '#/components/Typography'
|
||||
|
||||
export function PostThreadShowHiddenReplies({
|
||||
type,
|
||||
onPress,
|
||||
}: {
|
||||
type: 'hidden' | 'muted'
|
||||
onPress: () => void
|
||||
}) {
|
||||
const {_} = useLingui()
|
||||
const t = useTheme()
|
||||
const label =
|
||||
type === 'muted' ? _(msg`Show muted replies`) : _(msg`Show hidden replies`)
|
||||
|
||||
return (
|
||||
<Button onPress={onPress} label={label}>
|
||||
{({hovered, pressed}) => (
|
||||
<View
|
||||
style={[
|
||||
a.flex_1,
|
||||
a.flex_row,
|
||||
a.align_center,
|
||||
a.gap_sm,
|
||||
a.py_lg,
|
||||
a.px_xl,
|
||||
a.border_t,
|
||||
t.atoms.border_contrast_low,
|
||||
hovered || pressed ? t.atoms.bg_contrast_25 : t.atoms.bg,
|
||||
]}>
|
||||
<View
|
||||
style={[
|
||||
t.atoms.bg_contrast_25,
|
||||
a.align_center,
|
||||
a.justify_center,
|
||||
{
|
||||
width: 26,
|
||||
height: 26,
|
||||
borderRadius: 13,
|
||||
marginRight: 4,
|
||||
},
|
||||
]}>
|
||||
<EyeSlash size="sm" fill={t.atoms.text_contrast_medium.color} />
|
||||
</View>
|
||||
<Text
|
||||
style={[t.atoms.text_contrast_medium, a.flex_1]}
|
||||
numberOfLines={1}>
|
||||
{label}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</Button>
|
||||
)
|
||||
}
|
|
@ -367,7 +367,7 @@ let PostContent = ({
|
|||
modui={moderation.ui('contentList')}
|
||||
ignoreMute
|
||||
childContainerStyle={styles.contentHiderChild}>
|
||||
<PostAlerts modui={moderation.ui('contentList')} style={[a.py_xs]} />
|
||||
<PostAlerts modui={moderation.ui('contentList')} style={[a.pb_xs]} />
|
||||
{richText.text ? (
|
||||
<View style={styles.postTextContainer}>
|
||||
<RichText
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue