diff options
-rw-r--r-- | src/lib/analytics/types.ts | 2 | ||||
-rw-r--r-- | src/lib/api/index.ts | 17 | ||||
-rw-r--r-- | src/state/modals/index.tsx | 3 | ||||
-rw-r--r-- | src/state/queries/post-thread.ts | 2 | ||||
-rw-r--r-- | src/state/queries/threadgate.ts | 33 | ||||
-rw-r--r-- | src/view/com/modals/Threadgate.tsx | 11 | ||||
-rw-r--r-- | src/view/com/post-thread/PostThreadItem.tsx | 25 | ||||
-rw-r--r-- | src/view/com/threadgate/WhoCanReply.tsx | 234 |
8 files changed, 219 insertions, 108 deletions
diff --git a/src/lib/analytics/types.ts b/src/lib/analytics/types.ts index cdf535dec..720495ea1 100644 --- a/src/lib/analytics/types.ts +++ b/src/lib/analytics/types.ts @@ -32,6 +32,8 @@ export type TrackPropertiesMap = { 'Post:ThreadMute': {} // CAN BE SERVER 'Post:ThreadUnmute': {} // CAN BE SERVER 'Post:Reply': {} // CAN BE SERVER + 'Post:EditThreadgateOpened': {} + 'Post:ThreadgateEdited': {} // PROFILE events 'Profile:Follow': { username: string diff --git a/src/lib/api/index.ts b/src/lib/api/index.ts index dfaae2e01..5b1c998cb 100644 --- a/src/lib/api/index.ts +++ b/src/lib/api/index.ts @@ -270,7 +270,7 @@ export async function post(agent: BskyAgent, opts: PostOpts) { return res } -async function createThreadgate( +export async function createThreadgate( agent: BskyAgent, postUri: string, threadgate: ThreadgateSetting[], @@ -296,10 +296,17 @@ async function createThreadgate( } const postUrip = new AtUri(postUri) - await agent.api.app.bsky.feed.threadgate.create( - {repo: agent.session!.did, rkey: postUrip.rkey}, - {post: postUri, createdAt: new Date().toISOString(), allow}, - ) + await agent.api.com.atproto.repo.putRecord({ + repo: agent.session!.did, + collection: 'app.bsky.feed.threadgate', + rkey: postUrip.rkey, + record: { + $type: 'app.bsky.feed.threadgate', + post: postUri, + allow, + createdAt: new Date().toISOString(), + }, + }) } // helpers diff --git a/src/state/modals/index.tsx b/src/state/modals/index.tsx index ced14335b..685b10bd8 100644 --- a/src/state/modals/index.tsx +++ b/src/state/modals/index.tsx @@ -70,7 +70,8 @@ export interface SelfLabelModal { export interface ThreadgateModal { name: 'threadgate' settings: ThreadgateSetting[] - onChange: (settings: ThreadgateSetting[]) => void + onChange?: (settings: ThreadgateSetting[]) => void + onConfirm?: (settings: ThreadgateSetting[]) => void } export interface ChangeHandleModal { diff --git a/src/state/queries/post-thread.ts b/src/state/queries/post-thread.ts index f7e5e2ecb..db85e8a17 100644 --- a/src/state/queries/post-thread.ts +++ b/src/state/queries/post-thread.ts @@ -32,7 +32,7 @@ import { } from './util' const REPLY_TREE_DEPTH = 10 -const RQKEY_ROOT = 'post-thread' +export const RQKEY_ROOT = 'post-thread' export const RQKEY = (uri: string) => [RQKEY_ROOT, uri] type ThreadViewNode = AppBskyFeedGetPostThread.OutputSchema['thread'] diff --git a/src/state/queries/threadgate.ts b/src/state/queries/threadgate.ts index 489117582..67c6f8c08 100644 --- a/src/state/queries/threadgate.ts +++ b/src/state/queries/threadgate.ts @@ -1,5 +1,38 @@ +import {AppBskyFeedDefs, AppBskyFeedThreadgate} from '@atproto/api' + export type ThreadgateSetting = | {type: 'nobody'} | {type: 'mention'} | {type: 'following'} | {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[] +} diff --git a/src/view/com/modals/Threadgate.tsx b/src/view/com/modals/Threadgate.tsx index a2e9f391c..4a9a9e2ab 100644 --- a/src/view/com/modals/Threadgate.tsx +++ b/src/view/com/modals/Threadgate.tsx @@ -26,9 +26,11 @@ export const snapPoints = ['60%'] export function Component({ settings, onChange, + onConfirm, }: { settings: ThreadgateSetting[] - onChange: (settings: ThreadgateSetting[]) => void + onChange?: (settings: ThreadgateSetting[]) => void + onConfirm?: (settings: ThreadgateSetting[]) => void }) { const pal = usePalette('default') const {closeModal} = useModalControls() @@ -38,12 +40,12 @@ export function Component({ const onPressEverybody = () => { setSelected([]) - onChange([]) + onChange?.([]) } const onPressNobody = () => { setSelected([{type: 'nobody'}]) - onChange([{type: 'nobody'}]) + onChange?.([{type: 'nobody'}]) } const onPressAudience = (setting: ThreadgateSetting) => { @@ -57,7 +59,7 @@ export function Component({ newSelected.splice(i, 1) } setSelected(newSelected) - onChange(newSelected) + onChange?.(newSelected) } return ( @@ -124,6 +126,7 @@ export function Component({ testID="confirmBtn" onPress={() => { closeModal() + onConfirm?.(selected) }} style={styles.btn} accessibilityRole="button" diff --git a/src/view/com/post-thread/PostThreadItem.tsx b/src/view/com/post-thread/PostThreadItem.tsx index 5ee60e4ea..6d03029d7 100644 --- a/src/view/com/post-thread/PostThreadItem.tsx +++ b/src/view/com/post-thread/PostThreadItem.tsx @@ -25,7 +25,7 @@ import {sanitizeHandle} from 'lib/strings/handles' import {countLines} from 'lib/strings/helpers' import {niceDate} from 'lib/strings/time' import {s} from 'lib/styles' -import {isWeb} from 'platform/detection' +import {isNative, isWeb} from 'platform/detection' import {useSession} from 'state/session' import {PostThreadFollowBtn} from 'view/com/post-thread/PostThreadFollowBtn' import {atoms as a} from '#/alf' @@ -189,6 +189,7 @@ let PostThreadItemLoaded = ({ const itemTitle = _(msg`Post by ${post.author.handle}`) const authorHref = makeProfileLink(post.author) const authorTitle = post.author.handle + const isThreadAuthor = getThreadAuthor(post, record) === currentAccount?.did const likesHref = React.useMemo(() => { const urip = new AtUri(post.uri) return makeProfileLink(post.author, 'post', urip.rkey, 'liked-by') @@ -395,7 +396,11 @@ let PostThreadItemLoaded = ({ </View> </View> </View> - <WhoCanReply post={post} /> + <WhoCanReply + post={post} + isThreadAuthor={isThreadAuthor} + style={{borderBottomWidth: isNative ? 1 : 0}} + /> </> ) } else { @@ -578,7 +583,9 @@ let PostThreadItemLoaded = ({ post={post} style={{ 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({ outer: { borderTopWidth: hairlineWidth, diff --git a/src/view/com/threadgate/WhoCanReply.tsx b/src/view/com/threadgate/WhoCanReply.tsx index c1e36d481..3ffbaa7ae 100644 --- a/src/view/com/threadgate/WhoCanReply.tsx +++ b/src/view/com/threadgate/WhoCanReply.tsx @@ -1,128 +1,172 @@ import React from 'react' -import {StyleProp, View, ViewStyle} from 'react-native' -import { - AppBskyFeedDefs, - AppBskyFeedThreadgate, - AppBskyGraphDefs, - AtUri, -} from '@atproto/api' -import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' -import {Trans} from '@lingui/macro' +import {Keyboard, StyleProp, View, ViewStyle} from 'react-native' +import {AppBskyFeedDefs, AppBskyGraphDefs, AtUri} from '@atproto/api' +import {msg, Trans} from '@lingui/macro' +import {useLingui} from '@lingui/react' +import {useQueryClient} from '@tanstack/react-query' +import {useAnalytics} from '#/lib/analytics/analytics' +import {createThreadgate} from '#/lib/api' import {useColorSchemeStyle} from '#/lib/hooks/useColorSchemeStyle' import {usePalette} from '#/lib/hooks/usePalette' -import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries' import {makeListLink, makeProfileLink} from '#/lib/routes/links' 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 {Text} from '../util/text/Text' export function WhoCanReply({ post, + isThreadAuthor, style, }: { post: AppBskyFeedDefs.PostView + isThreadAuthor: boolean style?: StyleProp<ViewStyle> }) { + const {track} = useAnalytics() + const {_} = useLingui() const pal = usePalette('default') - const {isMobile} = useWebMediaQueries() + const agent = useAgent() + const queryClient = useQueryClient() + const {openModal} = useModalControls() const containerStyles = useColorSchemeStyle( { - borderColor: pal.colors.unreadNotifBorder, backgroundColor: pal.colors.unreadNotifBg, }, { - borderColor: pal.colors.unreadNotifBorder, backgroundColor: pal.colors.unreadNotifBg, }, ) - const iconStyles = useColorSchemeStyle( + const textStyles = useColorSchemeStyle( + {color: colors.blue5}, + {color: colors.blue1}, + ) + const hoverStyles = useColorSchemeStyle( { - backgroundColor: colors.blue3, + backgroundColor: colors.white, }, { - backgroundColor: colors.blue3, + backgroundColor: pal.colors.background, }, ) - const textStyles = useColorSchemeStyle( - {color: colors.gray7}, - {color: colors.blue1}, - ) - const record = React.useMemo( - () => - post.threadgate && - AppBskyFeedThreadgate.isRecord(post.threadgate.record) && - AppBskyFeedThreadgate.validateRecord(post.threadgate.record).success - ? post.threadgate.record - : null, + const settings = React.useMemo( + () => threadgateViewToSettings(post.threadgate), [post], ) - if (record) { - return ( - <View - style={[ - { - flexDirection: 'row', - alignItems: 'center', - gap: isMobile ? 8 : 10, - paddingHorizontal: isMobile ? 16 : 18, - paddingVertical: 12, - borderWidth: 1, - borderLeftWidth: isMobile ? 0 : 1, - borderRightWidth: isMobile ? 0 : 1, - }, - containerStyles, - style, - ]}> - <View - 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]}> - {!record.allow?.length ? ( - <Trans>Replies to this thread are disabled</Trans> - ) : ( - <Trans> - Only{' '} - {record.allow.map((rule, i) => ( - <> - <Rule - key={`rule-${i}`} - rule={rule} - post={post} - lists={post.threadgate!.lists} - /> - <Separator - key={`sep-${i}`} - i={i} - length={record.allow!.length} - /> - </> - ))}{' '} - can reply. - </Trans> + 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 ( + <View + style={[ + { + flexDirection: 'row', + alignItems: 'center', + gap: 10, + paddingLeft: 18, + paddingRight: 14, + paddingVertical: 10, + borderTopWidth: 1, + }, + pal.border, + containerStyles, + style, + ]}> + <View style={{flex: 1, paddingVertical: 6}}> + <Text type="sm" style={[{flexWrap: 'wrap'}, textStyles]}> + {!settings.length ? ( + <Trans>Everybody can reply.</Trans> + ) : settings[0].type === 'nobody' ? ( + <Trans>Replies to this thread are disabled.</Trans> + ) : ( + <Trans> + Only{' '} + {settings.map((rule, i) => ( + <> + <Rule + key={`rule-${i}`} + rule={rule} + post={post} + lists={post.threadgate!.lists} + /> + <Separator key={`sep-${i}`} i={i} length={settings.length} /> + </> + ))}{' '} + can reply. + </Trans> + )} + </Text> + </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> )} - </Text> + </Button> </View> - </View> - ) - } - return null + )} + </View> + ) } function Rule({ @@ -130,15 +174,15 @@ function Rule({ post, lists, }: { - rule: any + rule: ThreadgateSetting post: AppBskyFeedDefs.PostView lists: AppBskyGraphDefs.ListViewBasic[] | undefined }) { const pal = usePalette('default') - if (AppBskyFeedThreadgate.isMentionRule(rule)) { + if (rule.type === 'mention') { return <Trans>mentioned users</Trans> } - if (AppBskyFeedThreadgate.isFollowingRule(rule)) { + if (rule.type === 'following') { return ( <Trans> users followed by{' '} @@ -151,7 +195,7 @@ function Rule({ </Trans> ) } - if (AppBskyFeedThreadgate.isListRule(rule)) { + if (rule.type === 'list') { const list = lists?.find(l => l.uri === rule.list) if (list) { const listUrip = new AtUri(list.uri) |