An ATproto social media client -- with an independent Appview.
6
fork

Configure Feed

Select the types of activity you want to include in your feed.

Implement thread locking (#4545)

* Add the ability to edit threadgates

* Fix bottom border on mobile

* Refresh thread after threadgate edit

authored by

Paul Frazee and committed by
GitHub
d6ce16d1 4165a02b

+219 -108
+2
src/lib/analytics/types.ts
··· 32 32 'Post:ThreadMute': {} // CAN BE SERVER 33 33 'Post:ThreadUnmute': {} // CAN BE SERVER 34 34 'Post:Reply': {} // CAN BE SERVER 35 + 'Post:EditThreadgateOpened': {} 36 + 'Post:ThreadgateEdited': {} 35 37 // PROFILE events 36 38 'Profile:Follow': { 37 39 username: string
+12 -5
src/lib/api/index.ts
··· 270 270 return res 271 271 } 272 272 273 - async function createThreadgate( 273 + export async function createThreadgate( 274 274 agent: BskyAgent, 275 275 postUri: string, 276 276 threadgate: ThreadgateSetting[], ··· 296 296 } 297 297 298 298 const postUrip = new AtUri(postUri) 299 - await agent.api.app.bsky.feed.threadgate.create( 300 - {repo: agent.session!.did, rkey: postUrip.rkey}, 301 - {post: postUri, createdAt: new Date().toISOString(), allow}, 302 - ) 299 + await agent.api.com.atproto.repo.putRecord({ 300 + repo: agent.session!.did, 301 + collection: 'app.bsky.feed.threadgate', 302 + rkey: postUrip.rkey, 303 + record: { 304 + $type: 'app.bsky.feed.threadgate', 305 + post: postUri, 306 + allow, 307 + createdAt: new Date().toISOString(), 308 + }, 309 + }) 303 310 } 304 311 305 312 // helpers
+2 -1
src/state/modals/index.tsx
··· 70 70 export interface ThreadgateModal { 71 71 name: 'threadgate' 72 72 settings: ThreadgateSetting[] 73 - onChange: (settings: ThreadgateSetting[]) => void 73 + onChange?: (settings: ThreadgateSetting[]) => void 74 + onConfirm?: (settings: ThreadgateSetting[]) => void 74 75 } 75 76 76 77 export interface ChangeHandleModal {
+1 -1
src/state/queries/post-thread.ts
··· 32 32 } from './util' 33 33 34 34 const REPLY_TREE_DEPTH = 10 35 - const RQKEY_ROOT = 'post-thread' 35 + export const RQKEY_ROOT = 'post-thread' 36 36 export const RQKEY = (uri: string) => [RQKEY_ROOT, uri] 37 37 type ThreadViewNode = AppBskyFeedGetPostThread.OutputSchema['thread'] 38 38
+33
src/state/queries/threadgate.ts
··· 1 + import {AppBskyFeedDefs, AppBskyFeedThreadgate} from '@atproto/api' 2 + 1 3 export type ThreadgateSetting = 2 4 | {type: 'nobody'} 3 5 | {type: 'mention'} 4 6 | {type: 'following'} 5 7 | {type: 'list'; list: string} 8 + 9 + export function threadgateViewToSettings( 10 + threadgate: AppBskyFeedDefs.ThreadgateView | undefined, 11 + ): ThreadgateSetting[] { 12 + const record = 13 + threadgate && 14 + AppBskyFeedThreadgate.isRecord(threadgate.record) && 15 + AppBskyFeedThreadgate.validateRecord(threadgate.record).success 16 + ? threadgate.record 17 + : null 18 + if (!record) { 19 + return [] 20 + } 21 + if (!record.allow?.length) { 22 + return [{type: 'nobody'}] 23 + } 24 + return record.allow 25 + .map(allow => { 26 + if (allow.$type === 'app.bsky.feed.threadgate#mentionRule') { 27 + return {type: 'mention'} 28 + } 29 + if (allow.$type === 'app.bsky.feed.threadgate#followingRule') { 30 + return {type: 'following'} 31 + } 32 + if (allow.$type === 'app.bsky.feed.threadgate#listRule') { 33 + return {type: 'list', list: allow.list} 34 + } 35 + return undefined 36 + }) 37 + .filter(Boolean) as ThreadgateSetting[] 38 + }
+7 -4
src/view/com/modals/Threadgate.tsx
··· 26 26 export function Component({ 27 27 settings, 28 28 onChange, 29 + onConfirm, 29 30 }: { 30 31 settings: ThreadgateSetting[] 31 - onChange: (settings: ThreadgateSetting[]) => void 32 + onChange?: (settings: ThreadgateSetting[]) => void 33 + onConfirm?: (settings: ThreadgateSetting[]) => void 32 34 }) { 33 35 const pal = usePalette('default') 34 36 const {closeModal} = useModalControls() ··· 38 40 39 41 const onPressEverybody = () => { 40 42 setSelected([]) 41 - onChange([]) 43 + onChange?.([]) 42 44 } 43 45 44 46 const onPressNobody = () => { 45 47 setSelected([{type: 'nobody'}]) 46 - onChange([{type: 'nobody'}]) 48 + onChange?.([{type: 'nobody'}]) 47 49 } 48 50 49 51 const onPressAudience = (setting: ThreadgateSetting) => { ··· 57 59 newSelected.splice(i, 1) 58 60 } 59 61 setSelected(newSelected) 60 - onChange(newSelected) 62 + onChange?.(newSelected) 61 63 } 62 64 63 65 return ( ··· 124 126 testID="confirmBtn" 125 127 onPress={() => { 126 128 closeModal() 129 + onConfirm?.(selected) 127 130 }} 128 131 style={styles.btn} 129 132 accessibilityRole="button"
+23 -2
src/view/com/post-thread/PostThreadItem.tsx
··· 25 25 import {countLines} from 'lib/strings/helpers' 26 26 import {niceDate} from 'lib/strings/time' 27 27 import {s} from 'lib/styles' 28 - import {isWeb} from 'platform/detection' 28 + import {isNative, isWeb} from 'platform/detection' 29 29 import {useSession} from 'state/session' 30 30 import {PostThreadFollowBtn} from 'view/com/post-thread/PostThreadFollowBtn' 31 31 import {atoms as a} from '#/alf' ··· 189 189 const itemTitle = _(msg`Post by ${post.author.handle}`) 190 190 const authorHref = makeProfileLink(post.author) 191 191 const authorTitle = post.author.handle 192 + const isThreadAuthor = getThreadAuthor(post, record) === currentAccount?.did 192 193 const likesHref = React.useMemo(() => { 193 194 const urip = new AtUri(post.uri) 194 195 return makeProfileLink(post.author, 'post', urip.rkey, 'liked-by') ··· 395 396 </View> 396 397 </View> 397 398 </View> 398 - <WhoCanReply post={post} /> 399 + <WhoCanReply 400 + post={post} 401 + isThreadAuthor={isThreadAuthor} 402 + style={{borderBottomWidth: isNative ? 1 : 0}} 403 + /> 399 404 </> 400 405 ) 401 406 } else { ··· 578 583 post={post} 579 584 style={{ 580 585 marginTop: 4, 586 + borderBottomWidth: 1, 581 587 }} 588 + isThreadAuthor={isThreadAuthor} 582 589 /> 583 590 </> 584 591 ) ··· 679 686 )} 680 687 </View> 681 688 ) 689 + } 690 + 691 + function getThreadAuthor( 692 + post: AppBskyFeedDefs.PostView, 693 + record: AppBskyFeedPost.Record, 694 + ): string { 695 + if (!record.reply) { 696 + return post.author.did 697 + } 698 + try { 699 + return new AtUri(record.reply.root.uri).host 700 + } catch { 701 + return '' 702 + } 682 703 } 683 704 684 705 const styles = StyleSheet.create({
+139 -95
src/view/com/threadgate/WhoCanReply.tsx
··· 1 1 import React from 'react' 2 - import {StyleProp, View, ViewStyle} from 'react-native' 3 - import { 4 - AppBskyFeedDefs, 5 - AppBskyFeedThreadgate, 6 - AppBskyGraphDefs, 7 - AtUri, 8 - } from '@atproto/api' 9 - import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' 10 - import {Trans} from '@lingui/macro' 2 + import {Keyboard, StyleProp, View, ViewStyle} from 'react-native' 3 + import {AppBskyFeedDefs, AppBskyGraphDefs, AtUri} from '@atproto/api' 4 + import {msg, Trans} from '@lingui/macro' 5 + import {useLingui} from '@lingui/react' 6 + import {useQueryClient} from '@tanstack/react-query' 11 7 8 + import {useAnalytics} from '#/lib/analytics/analytics' 9 + import {createThreadgate} from '#/lib/api' 12 10 import {useColorSchemeStyle} from '#/lib/hooks/useColorSchemeStyle' 13 11 import {usePalette} from '#/lib/hooks/usePalette' 14 - import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries' 15 12 import {makeListLink, makeProfileLink} from '#/lib/routes/links' 16 13 import {colors} from '#/lib/styles' 14 + import {logger} from '#/logger' 15 + import {isNative} from '#/platform/detection' 16 + import {useModalControls} from '#/state/modals' 17 + import {RQKEY_ROOT as POST_THREAD_RQKEY_ROOT} from '#/state/queries/post-thread' 18 + import { 19 + ThreadgateSetting, 20 + threadgateViewToSettings, 21 + } from '#/state/queries/threadgate' 22 + import {useAgent} from '#/state/session' 23 + import * as Toast from 'view/com/util/Toast' 24 + import {Button} from '#/components/Button' 17 25 import {TextLink} from '../util/Link' 18 26 import {Text} from '../util/text/Text' 19 27 20 28 export function WhoCanReply({ 21 29 post, 30 + isThreadAuthor, 22 31 style, 23 32 }: { 24 33 post: AppBskyFeedDefs.PostView 34 + isThreadAuthor: boolean 25 35 style?: StyleProp<ViewStyle> 26 36 }) { 37 + const {track} = useAnalytics() 38 + const {_} = useLingui() 27 39 const pal = usePalette('default') 28 - const {isMobile} = useWebMediaQueries() 40 + const agent = useAgent() 41 + const queryClient = useQueryClient() 42 + const {openModal} = useModalControls() 29 43 const containerStyles = useColorSchemeStyle( 30 44 { 31 - borderColor: pal.colors.unreadNotifBorder, 32 45 backgroundColor: pal.colors.unreadNotifBg, 33 46 }, 34 47 { 35 - borderColor: pal.colors.unreadNotifBorder, 36 48 backgroundColor: pal.colors.unreadNotifBg, 37 49 }, 38 50 ) 39 - const iconStyles = useColorSchemeStyle( 51 + const textStyles = useColorSchemeStyle( 52 + {color: colors.blue5}, 53 + {color: colors.blue1}, 54 + ) 55 + const hoverStyles = useColorSchemeStyle( 40 56 { 41 - backgroundColor: colors.blue3, 57 + backgroundColor: colors.white, 42 58 }, 43 59 { 44 - backgroundColor: colors.blue3, 60 + backgroundColor: pal.colors.background, 45 61 }, 46 62 ) 47 - const textStyles = useColorSchemeStyle( 48 - {color: colors.gray7}, 49 - {color: colors.blue1}, 50 - ) 51 - const record = React.useMemo( 52 - () => 53 - post.threadgate && 54 - AppBskyFeedThreadgate.isRecord(post.threadgate.record) && 55 - AppBskyFeedThreadgate.validateRecord(post.threadgate.record).success 56 - ? post.threadgate.record 57 - : null, 63 + const settings = React.useMemo( 64 + () => threadgateViewToSettings(post.threadgate), 58 65 [post], 59 66 ) 60 - if (record) { 61 - return ( 62 - <View 63 - style={[ 64 - { 65 - flexDirection: 'row', 66 - alignItems: 'center', 67 - gap: isMobile ? 8 : 10, 68 - paddingHorizontal: isMobile ? 16 : 18, 69 - paddingVertical: 12, 70 - borderWidth: 1, 71 - borderLeftWidth: isMobile ? 0 : 1, 72 - borderRightWidth: isMobile ? 0 : 1, 73 - }, 74 - containerStyles, 75 - style, 76 - ]}> 77 - <View 78 - style={[ 79 - { 80 - flexDirection: 'row', 81 - alignItems: 'center', 82 - justifyContent: 'center', 83 - width: 32, 84 - height: 32, 85 - borderRadius: 19, 86 - }, 87 - iconStyles, 88 - ]}> 89 - <FontAwesomeIcon 90 - icon={['far', 'comments']} 91 - size={16} 92 - color={'#fff'} 93 - /> 94 - </View> 95 - <View style={{flex: 1}}> 96 - <Text type="sm" style={[{flexWrap: 'wrap'}, textStyles]}> 97 - {!record.allow?.length ? ( 98 - <Trans>Replies to this thread are disabled</Trans> 99 - ) : ( 100 - <Trans> 101 - Only{' '} 102 - {record.allow.map((rule, i) => ( 103 - <> 104 - <Rule 105 - key={`rule-${i}`} 106 - rule={rule} 107 - post={post} 108 - lists={post.threadgate!.lists} 109 - /> 110 - <Separator 111 - key={`sep-${i}`} 112 - i={i} 113 - length={record.allow!.length} 114 - /> 115 - </> 116 - ))}{' '} 117 - can reply. 118 - </Trans> 67 + const isRootPost = !('reply' in post.record) 68 + 69 + const onPressEdit = () => { 70 + track('Post:EditThreadgateOpened') 71 + if (isNative && Keyboard.isVisible()) { 72 + Keyboard.dismiss() 73 + } 74 + openModal({ 75 + name: 'threadgate', 76 + settings, 77 + async onConfirm(newSettings: ThreadgateSetting[]) { 78 + try { 79 + if (newSettings.length) { 80 + await createThreadgate(agent, post.uri, newSettings) 81 + } else { 82 + await agent.api.com.atproto.repo.deleteRecord({ 83 + repo: agent.session!.did, 84 + collection: 'app.bsky.feed.threadgate', 85 + rkey: new AtUri(post.uri).rkey, 86 + }) 87 + } 88 + Toast.show('Thread settings updated') 89 + queryClient.invalidateQueries({ 90 + queryKey: [POST_THREAD_RQKEY_ROOT], 91 + }) 92 + track('Post:ThreadgateEdited') 93 + } catch (err) { 94 + Toast.show( 95 + 'There was an issue. Please check your internet connection and try again.', 96 + ) 97 + logger.error('Failed to edit threadgate', {message: err}) 98 + } 99 + }, 100 + }) 101 + } 102 + 103 + if (!isRootPost) { 104 + return null 105 + } 106 + if (!settings.length && !isThreadAuthor) { 107 + return null 108 + } 109 + 110 + return ( 111 + <View 112 + style={[ 113 + { 114 + flexDirection: 'row', 115 + alignItems: 'center', 116 + gap: 10, 117 + paddingLeft: 18, 118 + paddingRight: 14, 119 + paddingVertical: 10, 120 + borderTopWidth: 1, 121 + }, 122 + pal.border, 123 + containerStyles, 124 + style, 125 + ]}> 126 + <View style={{flex: 1, paddingVertical: 6}}> 127 + <Text type="sm" style={[{flexWrap: 'wrap'}, textStyles]}> 128 + {!settings.length ? ( 129 + <Trans>Everybody can reply.</Trans> 130 + ) : settings[0].type === 'nobody' ? ( 131 + <Trans>Replies to this thread are disabled.</Trans> 132 + ) : ( 133 + <Trans> 134 + Only{' '} 135 + {settings.map((rule, i) => ( 136 + <> 137 + <Rule 138 + key={`rule-${i}`} 139 + rule={rule} 140 + post={post} 141 + lists={post.threadgate!.lists} 142 + /> 143 + <Separator key={`sep-${i}`} i={i} length={settings.length} /> 144 + </> 145 + ))}{' '} 146 + can reply. 147 + </Trans> 148 + )} 149 + </Text> 150 + </View> 151 + {isThreadAuthor && ( 152 + <View> 153 + <Button label={_(msg`Edit`)} onPress={onPressEdit}> 154 + {({hovered}) => ( 155 + <View 156 + style={[ 157 + hovered && hoverStyles, 158 + {paddingVertical: 6, paddingHorizontal: 8, borderRadius: 8}, 159 + ]}> 160 + <Text type="sm" style={pal.link}> 161 + <Trans>Edit</Trans> 162 + </Text> 163 + </View> 119 164 )} 120 - </Text> 165 + </Button> 121 166 </View> 122 - </View> 123 - ) 124 - } 125 - return null 167 + )} 168 + </View> 169 + ) 126 170 } 127 171 128 172 function Rule({ ··· 130 174 post, 131 175 lists, 132 176 }: { 133 - rule: any 177 + rule: ThreadgateSetting 134 178 post: AppBskyFeedDefs.PostView 135 179 lists: AppBskyGraphDefs.ListViewBasic[] | undefined 136 180 }) { 137 181 const pal = usePalette('default') 138 - if (AppBskyFeedThreadgate.isMentionRule(rule)) { 182 + if (rule.type === 'mention') { 139 183 return <Trans>mentioned users</Trans> 140 184 } 141 - if (AppBskyFeedThreadgate.isFollowingRule(rule)) { 185 + if (rule.type === 'following') { 142 186 return ( 143 187 <Trans> 144 188 users followed by{' '} ··· 151 195 </Trans> 152 196 ) 153 197 } 154 - if (AppBskyFeedThreadgate.isListRule(rule)) { 198 + if (rule.type === 'list') { 155 199 const list = lists?.find(l => l.uri === rule.list) 156 200 if (list) { 157 201 const listUrip = new AtUri(list.uri)