Implement thread locking (#4545)

* Add the ability to edit threadgates

* Fix bottom border on mobile

* Refresh thread after threadgate edit
zio/stable
Paul Frazee 2024-06-18 12:07:56 -07:00 committed by GitHub
parent 4165a02b2d
commit d6ce16d15a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 222 additions and 111 deletions

View File

@ -32,6 +32,8 @@ export type TrackPropertiesMap = {
'Post:ThreadMute': {} // CAN BE SERVER 'Post:ThreadMute': {} // CAN BE SERVER
'Post:ThreadUnmute': {} // CAN BE SERVER 'Post:ThreadUnmute': {} // CAN BE SERVER
'Post:Reply': {} // CAN BE SERVER 'Post:Reply': {} // CAN BE SERVER
'Post:EditThreadgateOpened': {}
'Post:ThreadgateEdited': {}
// PROFILE events // PROFILE events
'Profile:Follow': { 'Profile:Follow': {
username: string username: string

View File

@ -270,7 +270,7 @@ export async function post(agent: BskyAgent, opts: PostOpts) {
return res return res
} }
async function createThreadgate( export async function createThreadgate(
agent: BskyAgent, agent: BskyAgent,
postUri: string, postUri: string,
threadgate: ThreadgateSetting[], threadgate: ThreadgateSetting[],
@ -296,10 +296,17 @@ async function createThreadgate(
} }
const postUrip = new AtUri(postUri) const postUrip = new AtUri(postUri)
await agent.api.app.bsky.feed.threadgate.create( await agent.api.com.atproto.repo.putRecord({
{repo: agent.session!.did, rkey: postUrip.rkey}, repo: agent.session!.did,
{post: postUri, createdAt: new Date().toISOString(), allow}, collection: 'app.bsky.feed.threadgate',
) rkey: postUrip.rkey,
record: {
$type: 'app.bsky.feed.threadgate',
post: postUri,
allow,
createdAt: new Date().toISOString(),
},
})
} }
// helpers // helpers

View File

@ -70,7 +70,8 @@ export interface SelfLabelModal {
export interface ThreadgateModal { export interface ThreadgateModal {
name: 'threadgate' name: 'threadgate'
settings: ThreadgateSetting[] settings: ThreadgateSetting[]
onChange: (settings: ThreadgateSetting[]) => void onChange?: (settings: ThreadgateSetting[]) => void
onConfirm?: (settings: ThreadgateSetting[]) => void
} }
export interface ChangeHandleModal { export interface ChangeHandleModal {

View File

@ -32,7 +32,7 @@ import {
} from './util' } from './util'
const REPLY_TREE_DEPTH = 10 const REPLY_TREE_DEPTH = 10
const RQKEY_ROOT = 'post-thread' export const RQKEY_ROOT = 'post-thread'
export const RQKEY = (uri: string) => [RQKEY_ROOT, uri] export const RQKEY = (uri: string) => [RQKEY_ROOT, uri]
type ThreadViewNode = AppBskyFeedGetPostThread.OutputSchema['thread'] type ThreadViewNode = AppBskyFeedGetPostThread.OutputSchema['thread']

View File

@ -1,5 +1,38 @@
import {AppBskyFeedDefs, AppBskyFeedThreadgate} from '@atproto/api'
export type ThreadgateSetting = export type ThreadgateSetting =
| {type: 'nobody'} | {type: 'nobody'}
| {type: 'mention'} | {type: 'mention'}
| {type: 'following'} | {type: 'following'}
| {type: 'list'; list: string} | {type: 'list'; list: string}
export function threadgateViewToSettings(
threadgate: AppBskyFeedDefs.ThreadgateView | undefined,
): ThreadgateSetting[] {
const record =
threadgate &&
AppBskyFeedThreadgate.isRecord(threadgate.record) &&
AppBskyFeedThreadgate.validateRecord(threadgate.record).success
? threadgate.record
: null
if (!record) {
return []
}
if (!record.allow?.length) {
return [{type: 'nobody'}]
}
return record.allow
.map(allow => {
if (allow.$type === 'app.bsky.feed.threadgate#mentionRule') {
return {type: 'mention'}
}
if (allow.$type === 'app.bsky.feed.threadgate#followingRule') {
return {type: 'following'}
}
if (allow.$type === 'app.bsky.feed.threadgate#listRule') {
return {type: 'list', list: allow.list}
}
return undefined
})
.filter(Boolean) as ThreadgateSetting[]
}

View File

@ -26,9 +26,11 @@ export const snapPoints = ['60%']
export function Component({ export function Component({
settings, settings,
onChange, onChange,
onConfirm,
}: { }: {
settings: ThreadgateSetting[] settings: ThreadgateSetting[]
onChange: (settings: ThreadgateSetting[]) => void onChange?: (settings: ThreadgateSetting[]) => void
onConfirm?: (settings: ThreadgateSetting[]) => void
}) { }) {
const pal = usePalette('default') const pal = usePalette('default')
const {closeModal} = useModalControls() const {closeModal} = useModalControls()
@ -38,12 +40,12 @@ export function Component({
const onPressEverybody = () => { const onPressEverybody = () => {
setSelected([]) setSelected([])
onChange([]) onChange?.([])
} }
const onPressNobody = () => { const onPressNobody = () => {
setSelected([{type: 'nobody'}]) setSelected([{type: 'nobody'}])
onChange([{type: 'nobody'}]) onChange?.([{type: 'nobody'}])
} }
const onPressAudience = (setting: ThreadgateSetting) => { const onPressAudience = (setting: ThreadgateSetting) => {
@ -57,7 +59,7 @@ export function Component({
newSelected.splice(i, 1) newSelected.splice(i, 1)
} }
setSelected(newSelected) setSelected(newSelected)
onChange(newSelected) onChange?.(newSelected)
} }
return ( return (
@ -124,6 +126,7 @@ export function Component({
testID="confirmBtn" testID="confirmBtn"
onPress={() => { onPress={() => {
closeModal() closeModal()
onConfirm?.(selected)
}} }}
style={styles.btn} style={styles.btn}
accessibilityRole="button" accessibilityRole="button"

View File

@ -25,7 +25,7 @@ import {sanitizeHandle} from 'lib/strings/handles'
import {countLines} from 'lib/strings/helpers' import {countLines} from 'lib/strings/helpers'
import {niceDate} from 'lib/strings/time' import {niceDate} from 'lib/strings/time'
import {s} from 'lib/styles' import {s} from 'lib/styles'
import {isWeb} from 'platform/detection' import {isNative, isWeb} from 'platform/detection'
import {useSession} from 'state/session' import {useSession} from 'state/session'
import {PostThreadFollowBtn} from 'view/com/post-thread/PostThreadFollowBtn' import {PostThreadFollowBtn} from 'view/com/post-thread/PostThreadFollowBtn'
import {atoms as a} from '#/alf' import {atoms as a} from '#/alf'
@ -189,6 +189,7 @@ let PostThreadItemLoaded = ({
const itemTitle = _(msg`Post by ${post.author.handle}`) const itemTitle = _(msg`Post by ${post.author.handle}`)
const authorHref = makeProfileLink(post.author) const authorHref = makeProfileLink(post.author)
const authorTitle = post.author.handle const authorTitle = post.author.handle
const isThreadAuthor = getThreadAuthor(post, record) === currentAccount?.did
const likesHref = React.useMemo(() => { const likesHref = React.useMemo(() => {
const urip = new AtUri(post.uri) const urip = new AtUri(post.uri)
return makeProfileLink(post.author, 'post', urip.rkey, 'liked-by') return makeProfileLink(post.author, 'post', urip.rkey, 'liked-by')
@ -395,7 +396,11 @@ let PostThreadItemLoaded = ({
</View> </View>
</View> </View>
</View> </View>
<WhoCanReply post={post} /> <WhoCanReply
post={post}
isThreadAuthor={isThreadAuthor}
style={{borderBottomWidth: isNative ? 1 : 0}}
/>
</> </>
) )
} else { } else {
@ -578,7 +583,9 @@ let PostThreadItemLoaded = ({
post={post} post={post}
style={{ style={{
marginTop: 4, marginTop: 4,
borderBottomWidth: 1,
}} }}
isThreadAuthor={isThreadAuthor}
/> />
</> </>
) )
@ -681,6 +688,20 @@ function ExpandedPostDetails({
) )
} }
function getThreadAuthor(
post: AppBskyFeedDefs.PostView,
record: AppBskyFeedPost.Record,
): string {
if (!record.reply) {
return post.author.did
}
try {
return new AtUri(record.reply.root.uri).host
} catch {
return ''
}
}
const styles = StyleSheet.create({ const styles = StyleSheet.create({
outer: { outer: {
borderTopWidth: hairlineWidth, borderTopWidth: hairlineWidth,

View File

@ -1,105 +1,138 @@
import React from 'react' import React from 'react'
import {StyleProp, View, ViewStyle} from 'react-native' import {Keyboard, StyleProp, View, ViewStyle} from 'react-native'
import { import {AppBskyFeedDefs, AppBskyGraphDefs, AtUri} from '@atproto/api'
AppBskyFeedDefs, import {msg, Trans} from '@lingui/macro'
AppBskyFeedThreadgate, import {useLingui} from '@lingui/react'
AppBskyGraphDefs, import {useQueryClient} from '@tanstack/react-query'
AtUri,
} from '@atproto/api'
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
import {Trans} from '@lingui/macro'
import {useAnalytics} from '#/lib/analytics/analytics'
import {createThreadgate} from '#/lib/api'
import {useColorSchemeStyle} from '#/lib/hooks/useColorSchemeStyle' import {useColorSchemeStyle} from '#/lib/hooks/useColorSchemeStyle'
import {usePalette} from '#/lib/hooks/usePalette' import {usePalette} from '#/lib/hooks/usePalette'
import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries'
import {makeListLink, makeProfileLink} from '#/lib/routes/links' import {makeListLink, makeProfileLink} from '#/lib/routes/links'
import {colors} from '#/lib/styles' import {colors} from '#/lib/styles'
import {logger} from '#/logger'
import {isNative} from '#/platform/detection'
import {useModalControls} from '#/state/modals'
import {RQKEY_ROOT as POST_THREAD_RQKEY_ROOT} from '#/state/queries/post-thread'
import {
ThreadgateSetting,
threadgateViewToSettings,
} from '#/state/queries/threadgate'
import {useAgent} from '#/state/session'
import * as Toast from 'view/com/util/Toast'
import {Button} from '#/components/Button'
import {TextLink} from '../util/Link' import {TextLink} from '../util/Link'
import {Text} from '../util/text/Text' import {Text} from '../util/text/Text'
export function WhoCanReply({ export function WhoCanReply({
post, post,
isThreadAuthor,
style, style,
}: { }: {
post: AppBskyFeedDefs.PostView post: AppBskyFeedDefs.PostView
isThreadAuthor: boolean
style?: StyleProp<ViewStyle> style?: StyleProp<ViewStyle>
}) { }) {
const {track} = useAnalytics()
const {_} = useLingui()
const pal = usePalette('default') const pal = usePalette('default')
const {isMobile} = useWebMediaQueries() const agent = useAgent()
const queryClient = useQueryClient()
const {openModal} = useModalControls()
const containerStyles = useColorSchemeStyle( const containerStyles = useColorSchemeStyle(
{ {
borderColor: pal.colors.unreadNotifBorder,
backgroundColor: pal.colors.unreadNotifBg, backgroundColor: pal.colors.unreadNotifBg,
}, },
{ {
borderColor: pal.colors.unreadNotifBorder,
backgroundColor: pal.colors.unreadNotifBg, backgroundColor: pal.colors.unreadNotifBg,
}, },
) )
const iconStyles = useColorSchemeStyle(
{
backgroundColor: colors.blue3,
},
{
backgroundColor: colors.blue3,
},
)
const textStyles = useColorSchemeStyle( const textStyles = useColorSchemeStyle(
{color: colors.gray7}, {color: colors.blue5},
{color: colors.blue1}, {color: colors.blue1},
) )
const record = React.useMemo( const hoverStyles = useColorSchemeStyle(
() => {
post.threadgate && backgroundColor: colors.white,
AppBskyFeedThreadgate.isRecord(post.threadgate.record) && },
AppBskyFeedThreadgate.validateRecord(post.threadgate.record).success {
? post.threadgate.record backgroundColor: pal.colors.background,
: null, },
)
const settings = React.useMemo(
() => threadgateViewToSettings(post.threadgate),
[post], [post],
) )
if (record) { const isRootPost = !('reply' in post.record)
const onPressEdit = () => {
track('Post:EditThreadgateOpened')
if (isNative && Keyboard.isVisible()) {
Keyboard.dismiss()
}
openModal({
name: 'threadgate',
settings,
async onConfirm(newSettings: ThreadgateSetting[]) {
try {
if (newSettings.length) {
await createThreadgate(agent, post.uri, newSettings)
} else {
await agent.api.com.atproto.repo.deleteRecord({
repo: agent.session!.did,
collection: 'app.bsky.feed.threadgate',
rkey: new AtUri(post.uri).rkey,
})
}
Toast.show('Thread settings updated')
queryClient.invalidateQueries({
queryKey: [POST_THREAD_RQKEY_ROOT],
})
track('Post:ThreadgateEdited')
} catch (err) {
Toast.show(
'There was an issue. Please check your internet connection and try again.',
)
logger.error('Failed to edit threadgate', {message: err})
}
},
})
}
if (!isRootPost) {
return null
}
if (!settings.length && !isThreadAuthor) {
return null
}
return ( return (
<View <View
style={[ style={[
{ {
flexDirection: 'row', flexDirection: 'row',
alignItems: 'center', alignItems: 'center',
gap: isMobile ? 8 : 10, gap: 10,
paddingHorizontal: isMobile ? 16 : 18, paddingLeft: 18,
paddingVertical: 12, paddingRight: 14,
borderWidth: 1, paddingVertical: 10,
borderLeftWidth: isMobile ? 0 : 1, borderTopWidth: 1,
borderRightWidth: isMobile ? 0 : 1,
}, },
pal.border,
containerStyles, containerStyles,
style, style,
]}> ]}>
<View <View style={{flex: 1, paddingVertical: 6}}>
style={[
{
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
width: 32,
height: 32,
borderRadius: 19,
},
iconStyles,
]}>
<FontAwesomeIcon
icon={['far', 'comments']}
size={16}
color={'#fff'}
/>
</View>
<View style={{flex: 1}}>
<Text type="sm" style={[{flexWrap: 'wrap'}, textStyles]}> <Text type="sm" style={[{flexWrap: 'wrap'}, textStyles]}>
{!record.allow?.length ? ( {!settings.length ? (
<Trans>Replies to this thread are disabled</Trans> <Trans>Everybody can reply.</Trans>
) : settings[0].type === 'nobody' ? (
<Trans>Replies to this thread are disabled.</Trans>
) : ( ) : (
<Trans> <Trans>
Only{' '} Only{' '}
{record.allow.map((rule, i) => ( {settings.map((rule, i) => (
<> <>
<Rule <Rule
key={`rule-${i}`} key={`rule-${i}`}
@ -107,11 +140,7 @@ export function WhoCanReply({
post={post} post={post}
lists={post.threadgate!.lists} lists={post.threadgate!.lists}
/> />
<Separator <Separator key={`sep-${i}`} i={i} length={settings.length} />
key={`sep-${i}`}
i={i}
length={record.allow!.length}
/>
</> </>
))}{' '} ))}{' '}
can reply. can reply.
@ -119,26 +148,41 @@ export function WhoCanReply({
)} )}
</Text> </Text>
</View> </View>
{isThreadAuthor && (
<View>
<Button label={_(msg`Edit`)} onPress={onPressEdit}>
{({hovered}) => (
<View
style={[
hovered && hoverStyles,
{paddingVertical: 6, paddingHorizontal: 8, borderRadius: 8},
]}>
<Text type="sm" style={pal.link}>
<Trans>Edit</Trans>
</Text>
</View>
)}
</Button>
</View>
)}
</View> </View>
) )
} }
return null
}
function Rule({ function Rule({
rule, rule,
post, post,
lists, lists,
}: { }: {
rule: any rule: ThreadgateSetting
post: AppBskyFeedDefs.PostView post: AppBskyFeedDefs.PostView
lists: AppBskyGraphDefs.ListViewBasic[] | undefined lists: AppBskyGraphDefs.ListViewBasic[] | undefined
}) { }) {
const pal = usePalette('default') const pal = usePalette('default')
if (AppBskyFeedThreadgate.isMentionRule(rule)) { if (rule.type === 'mention') {
return <Trans>mentioned users</Trans> return <Trans>mentioned users</Trans>
} }
if (AppBskyFeedThreadgate.isFollowingRule(rule)) { if (rule.type === 'following') {
return ( return (
<Trans> <Trans>
users followed by{' '} users followed by{' '}
@ -151,7 +195,7 @@ function Rule({
</Trans> </Trans>
) )
} }
if (AppBskyFeedThreadgate.isListRule(rule)) { if (rule.type === 'list') {
const list = lists?.find(l => l.uri === rule.list) const list = lists?.find(l => l.uri === rule.list)
if (list) { if (list) {
const listUrip = new AtUri(list.uri) const listUrip = new AtUri(list.uri)