Bluesky app fork with some witchin' additions 💫
0
fork

Configure Feed

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

[APP-782] Support invalid handles correctly (#1049)

* Update profile link construction to support handle.invalid

* Update list links to support using handles

* Use did for isMe check to ensure invalid handles dont distort the check

* Shift the red (error) colors away from the pink spectrum

* Add ThemedText helper component

* Add sanitizedHandle() helper to render invalid handles well

* Fix regression: only show avatar in PostMeta when needed

* Restore the color of likes

* Remove users with invalid handles from default autosuggests

authored by

Paul Frazee and committed by
GitHub
49356700 5a0899b9

+291 -117
+15
src/lib/routes/links.ts
··· 1 + import {isInvalidHandle} from 'lib/strings/handles' 2 + 3 + export function makeProfileLink( 4 + info: { 5 + did: string 6 + handle: string 7 + }, 8 + ...segments: string[] 9 + ) { 10 + return [ 11 + `/profile`, 12 + `${isInvalidHandle(info.handle) ? info.did : info.handle}`, 13 + ...segments, 14 + ].join('/') 15 + }
+8
src/lib/strings/handles.ts
··· 11 11 domain = (domain || '').replace(/^[.]+/, '') 12 12 return `${name}.${domain}` 13 13 } 14 + 15 + export function isInvalidHandle(handle: string): boolean { 16 + return handle === 'handle.invalid' 17 + } 18 + 19 + export function sanitizeHandle(handle: string, prefix = ''): string { 20 + return isInvalidHandle(handle) ? '⚠Invalid Handle' : `${prefix}${handle}` 21 + }
+9 -7
src/lib/styles.ts
··· 25 25 blue6: '#012561', 26 26 blue7: '#001040', 27 27 28 - red1: '#ffe6f2', 29 - red2: '#fba2ce', 30 - red3: '#ec4899', 31 - red4: '#d1106f', 32 - red5: '#97074e', 33 - red6: '#690436', 34 - red7: '#4F0328', 28 + red1: '#ffe6eb', 29 + red2: '#fba2b2', 30 + red3: '#ec4868', 31 + red4: '#d11043', 32 + red5: '#970721', 33 + red6: '#690419', 34 + red7: '#4F0314', 35 35 36 36 pink1: '#f8ccff', 37 37 pink2: '#e966ff', ··· 53 53 54 54 unreadNotifBg: '#ebf6ff', 55 55 brandBlue: '#0066FF', 56 + like: '#ec4899', 56 57 } 57 58 58 59 export const gradients = { ··· 224 225 green5: {color: colors.green5}, 225 226 226 227 brandBlue: {color: colors.brandBlue}, 228 + likeColor: {color: colors.like}, 227 229 }) 228 230 229 231 export function lh(
+22 -1
src/state/models/content/list.ts
··· 1 - import {makeAutoObservable} from 'mobx' 1 + import {makeAutoObservable, runInAction} from 'mobx' 2 2 import { 3 3 AtUri, 4 4 AppBskyGraphGetList as GetList, ··· 115 115 } 116 116 this._xLoading(replace) 117 117 try { 118 + await this._resolveUri() 118 119 const res = await this.rootStore.agent.app.bsky.graph.getList({ 119 120 list: this.uri, 120 121 limit: PAGE_SIZE, ··· 146 147 if (!this.isOwner) { 147 148 throw new Error('Cannot edit this list') 148 149 } 150 + await this._resolveUri() 149 151 150 152 // get the current record 151 153 const {rkey} = new AtUri(this.uri) ··· 179 181 if (!this.list) { 180 182 return 181 183 } 184 + await this._resolveUri() 182 185 183 186 // fetch all the listitem records that belong to this list 184 187 let cursor ··· 220 223 if (!this.list) { 221 224 return 222 225 } 226 + await this._resolveUri() 223 227 await this.rootStore.agent.app.bsky.graph.muteActorList({ 224 228 list: this.list.uri, 225 229 }) ··· 231 235 if (!this.list) { 232 236 return 233 237 } 238 + await this._resolveUri() 234 239 await this.rootStore.agent.app.bsky.graph.unmuteActorList({ 235 240 list: this.list.uri, 236 241 }) ··· 272 277 273 278 // helper functions 274 279 // = 280 + 281 + async _resolveUri() { 282 + const urip = new AtUri(this.uri) 283 + if (!urip.host.startsWith('did:')) { 284 + try { 285 + urip.host = await apilib.resolveName(this.rootStore, urip.host) 286 + } catch (e: any) { 287 + runInAction(() => { 288 + this.error = e.toString() 289 + }) 290 + } 291 + } 292 + runInAction(() => { 293 + this.uri = urip.toString() 294 + }) 295 + } 275 296 276 297 _replaceAll(res: GetList.Response) { 277 298 this.items = []
+2 -1
src/state/models/discovery/user-autocomplete.ts
··· 2 2 import {AppBskyActorDefs} from '@atproto/api' 3 3 import AwaitLock from 'await-lock' 4 4 import {RootStoreModel} from '../root-store' 5 + import {isInvalidHandle} from 'lib/strings/handles' 5 6 6 7 export class UserAutocompleteModel { 7 8 // state ··· 81 82 actor: this.rootStore.me.did || '', 82 83 }) 83 84 runInAction(() => { 84 - this.follows = res.data.follows 85 + this.follows = res.data.follows.filter(f => !isInvalidHandle(f.handle)) 85 86 for (const f of this.follows) { 86 87 this.knownHandles.add(f.handle) 87 88 }
+2 -1
src/state/models/feeds/custom-feed.ts
··· 2 2 import {makeAutoObservable, runInAction} from 'mobx' 3 3 import {RootStoreModel} from 'state/models/root-store' 4 4 import {sanitizeDisplayName} from 'lib/strings/display-names' 5 + import {sanitizeHandle} from 'lib/strings/handles' 5 6 import {updateDataOptimistically} from 'lib/async/revertible' 6 7 import {track} from 'lib/analytics/analytics' 7 8 ··· 42 43 if (this.data.displayName) { 43 44 return sanitizeDisplayName(this.data.displayName) 44 45 } 45 - return `Feed by @${this.data.creator.handle}` 46 + return `Feed by ${sanitizeHandle(this.data.creator.handle, '@')}` 46 47 } 47 48 48 49 get isSaved() {
+2 -1
src/state/models/feeds/multi-feed.ts
··· 5 5 import {CustomFeedModel} from './custom-feed' 6 6 import {PostsFeedModel} from './posts' 7 7 import {PostsFeedSliceModel} from './posts-slice' 8 + import {makeProfileLink} from 'lib/routes/links' 8 9 9 10 const FEED_PAGE_SIZE = 10 10 11 const FEEDS_PAGE_SIZE = 3 ··· 107 108 _reactKey: `__feed_footer_${i}__`, 108 109 type: 'feed-footer', 109 110 title: feedInfo.displayName, 110 - uri: `/profile/${feedInfo.data.creator.did}/feed/${urip.rkey}`, 111 + uri: makeProfileLink(feedInfo.data.creator, 'feed', urip.rkey), 111 112 }) 112 113 } 113 114 if (!this.hasMore) {
+1
src/state/models/ui/shell.ts
··· 208 208 text: string 209 209 indexedAt: string 210 210 author: { 211 + did: string 211 212 handle: string 212 213 displayName?: string 213 214 avatar?: string
+3 -1
src/view/com/composer/Composer.tsx
··· 30 30 import {ComposerOpts} from 'state/models/ui/shell' 31 31 import {s, colors, gradients} from 'lib/styles' 32 32 import {sanitizeDisplayName} from 'lib/strings/display-names' 33 + import {sanitizeHandle} from 'lib/strings/handles' 33 34 import {cleanError} from 'lib/strings/errors' 34 35 import {SelectPhotoBtn} from './photos/SelectPhotoBtn' 35 36 import {OpenCameraBtn} from './photos/OpenCameraBtn' ··· 319 320 <View style={styles.replyToPost}> 320 321 <Text type="xl-medium" style={[pal.text]}> 321 322 {sanitizeDisplayName( 322 - replyTo.author.displayName || replyTo.author.handle, 323 + replyTo.author.displayName || 324 + sanitizeHandle(replyTo.author.handle), 323 325 )} 324 326 </Text> 325 327 <Text type="post-text" style={pal.text} numberOfLines={6}>
+2 -1
src/view/com/feeds/CustomFeed.tsx
··· 20 20 import {pluralize} from 'lib/strings/helpers' 21 21 import {AtUri} from '@atproto/api' 22 22 import * as Toast from 'view/com/util/Toast' 23 + import {sanitizeHandle} from 'lib/strings/handles' 23 24 24 25 export const CustomFeed = observer( 25 26 ({ ··· 86 87 {item.displayName} 87 88 </Text> 88 89 <Text style={[pal.textLight]} numberOfLines={3}> 89 - by @{item.data.creator.handle} 90 + by {sanitizeHandle(item.data.creator.handle, '@')} 90 91 </Text> 91 92 </View> 92 93 {showSaveBtn && (
+4 -2
src/view/com/lists/ListCard.tsx
··· 9 9 import {usePalette} from 'lib/hooks/usePalette' 10 10 import {useStores} from 'state/index' 11 11 import {sanitizeDisplayName} from 'lib/strings/display-names' 12 + import {sanitizeHandle} from 'lib/strings/handles' 13 + import {makeProfileLink} from 'lib/routes/links' 12 14 13 15 export const ListCard = ({ 14 16 testID, ··· 57 59 !noBg && pal.view, 58 60 style, 59 61 ]} 60 - href={`/profile/${list.creator.did}/lists/${rkey}`} 62 + href={makeProfileLink(list.creator, 'lists', rkey)} 61 63 title={list.name} 62 64 asAnchor 63 65 anchorNoUnderline> ··· 77 79 {list.purpose === 'app.bsky.graph.defs#modlist' && 'Mute list'} by{' '} 78 80 {list.creator.did === store.me.did 79 81 ? 'you' 80 - : `@${list.creator.handle}`} 82 + : sanitizeHandle(list.creator.handle, '@')} 81 83 </Text> 82 84 {!!list.viewer?.muted && ( 83 85 <View style={s.flexRow}>
+4 -2
src/view/com/lists/ListItems.tsx
··· 26 26 import {s} from 'lib/styles' 27 27 import {isDesktopWeb} from 'platform/detection' 28 28 import {ListActions} from './ListActions' 29 + import {makeProfileLink} from 'lib/routes/links' 30 + import {sanitizeHandle} from 'lib/strings/handles' 29 31 30 32 const LOADING_ITEM = {_reactKey: '__loading__'} 31 33 const HEADER_ITEM = {_reactKey: '__header__'} ··· 296 298 'you' 297 299 ) : ( 298 300 <TextLink 299 - text={`@${list.creator.handle}`} 300 - href={`/profile/${list.creator.did}`} 301 + text={sanitizeHandle(list.creator.handle, '@')} 302 + href={makeProfileLink(list.creator)} 301 303 /> 302 304 )} 303 305 </Text>
+2 -1
src/view/com/modals/ListAddRemoveUser.tsx
··· 16 16 import * as Toast from '../util/Toast' 17 17 import {useStores} from 'state/index' 18 18 import {sanitizeDisplayName} from 'lib/strings/display-names' 19 + import {sanitizeHandle} from 'lib/strings/handles' 19 20 import {s} from 'lib/styles' 20 21 import {usePalette} from 'lib/hooks/usePalette' 21 22 import {isDesktopWeb, isAndroid} from 'platform/detection' ··· 122 123 by{' '} 123 124 {list.creator.did === store.me.did 124 125 ? 'you' 125 - : `@${list.creator.handle}`} 126 + : sanitizeHandle(list.creator.handle, '@')} 126 127 </Text> 127 128 </View> 128 129 <View
+7 -5
src/view/com/notifications/FeedItem.tsx
··· 19 19 import {s, colors} from 'lib/styles' 20 20 import {ago} from 'lib/strings/time' 21 21 import {sanitizeDisplayName} from 'lib/strings/display-names' 22 + import {sanitizeHandle} from 'lib/strings/handles' 22 23 import {pluralize} from 'lib/strings/helpers' 23 24 import {HeartIconSolid} from 'lib/icons' 24 25 import {Text} from '../util/text/Text' ··· 36 37 } from 'lib/labeling/helpers' 37 38 import {ProfileModeration} from 'lib/labeling/types' 38 39 import {formatCount} from '../util/numeric/format' 40 + import {makeProfileLink} from 'lib/routes/links' 39 41 40 42 const MAX_AUTHORS = 5 41 43 ··· 63 65 const urip = new AtUri(item.subjectUri) 64 66 return `/profile/${urip.host}/post/${urip.rkey}` 65 67 } else if (item.isFollow) { 66 - return `/profile/${item.author.handle}` 68 + return makeProfileLink(item.author) 67 69 } else if (item.isReply) { 68 70 const urip = new AtUri(item.uri) 69 71 return `/profile/${urip.host}/post/${urip.rkey}` ··· 92 94 const authors: Author[] = useMemo(() => { 93 95 return [ 94 96 { 95 - href: `/profile/${item.author.handle}`, 97 + href: makeProfileLink(item.author), 96 98 did: item.author.did, 97 99 handle: item.author.handle, 98 100 displayName: item.author.displayName, ··· 104 106 }, 105 107 ...(item.additional?.map(({author}) => { 106 108 return { 107 - href: `/profile/${author.handle}`, 109 + href: makeProfileLink(author), 108 110 did: author.did, 109 111 handle: author.handle, 110 112 displayName: author.displayName, ··· 158 160 action = 'liked your post' 159 161 icon = 'HeartIconSolid' 160 162 iconStyle = [ 161 - s.red3 as FontAwesomeIconStyle, 163 + s.likeColor as FontAwesomeIconStyle, 162 164 {position: 'relative', top: -4}, 163 165 ] 164 166 } else if (item.isRepost) { ··· 377 379 {sanitizeDisplayName(author.displayName || author.handle)} 378 380 &nbsp; 379 381 <Text style={[pal.textLight]} lineHeight={1.2}> 380 - {author.handle} 382 + {sanitizeHandle(author.handle)} 381 383 </Text> 382 384 </Text> 383 385 </View>
+3 -2
src/view/com/notifications/InvitedUsers.tsx
··· 16 16 import {usePalette} from 'lib/hooks/usePalette' 17 17 import {s} from 'lib/styles' 18 18 import {sanitizeDisplayName} from 'lib/strings/display-names' 19 + import {makeProfileLink} from 'lib/routes/links' 19 20 20 21 export const InvitedUsers = observer(() => { 21 22 const store = useStores() ··· 58 59 /> 59 60 </View> 60 61 <View style={s.flex1}> 61 - <Link href={`/profile/${profile.handle}`}> 62 + <Link href={makeProfileLink(profile)}> 62 63 <UserAvatar avatar={profile.avatar} size={35} /> 63 64 </Link> 64 65 <Text style={[styles.desc, pal.text]}> 65 66 <TextLink 66 67 type="md-bold" 67 68 style={pal.text} 68 - href={`/profile/${profile.handle}`} 69 + href={makeProfileLink(profile)} 69 70 text={sanitizeDisplayName(profile.displayName || profile.handle)} 70 71 />{' '} 71 72 joined using your invite code!
+15 -21
src/view/com/post-thread/PostThreadItem.tsx
··· 17 17 import {s} from 'lib/styles' 18 18 import {niceDate} from 'lib/strings/time' 19 19 import {sanitizeDisplayName} from 'lib/strings/display-names' 20 + import {sanitizeHandle} from 'lib/strings/handles' 20 21 import {pluralize} from 'lib/strings/helpers' 21 22 import {getTranslatorLink, isPostInLanguage} from '../../../locale/helpers' 22 23 import {useStores} from 'state/index' ··· 31 32 import {usePalette} from 'lib/hooks/usePalette' 32 33 import {formatCount} from '../util/numeric/format' 33 34 import {TimeElapsed} from 'view/com/util/TimeElapsed' 35 + import {makeProfileLink} from 'lib/routes/links' 34 36 35 37 const PARENT_REPLY_LINE_LENGTH = 8 36 38 ··· 51 53 const itemCid = item.post.cid 52 54 const itemHref = React.useMemo(() => { 53 55 const urip = new AtUri(item.post.uri) 54 - return `/profile/${item.post.author.handle}/post/${urip.rkey}` 55 - }, [item.post.uri, item.post.author.handle]) 56 + return makeProfileLink(item.post.author, 'post', urip.rkey) 57 + }, [item.post.uri, item.post.author]) 56 58 const itemTitle = `Post by ${item.post.author.handle}` 57 - const authorHref = `/profile/${item.post.author.handle}` 59 + const authorHref = makeProfileLink(item.post.author) 58 60 const authorTitle = item.post.author.handle 59 61 const likesHref = React.useMemo(() => { 60 62 const urip = new AtUri(item.post.uri) 61 - return `/profile/${item.post.author.handle}/post/${urip.rkey}/liked-by` 62 - }, [item.post.uri, item.post.author.handle]) 63 + return makeProfileLink(item.post.author, 'post', urip.rkey, 'liked-by') 64 + }, [item.post.uri, item.post.author]) 63 65 const likesTitle = 'Likes on this post' 64 66 const repostsHref = React.useMemo(() => { 65 67 const urip = new AtUri(item.post.uri) 66 - return `/profile/${item.post.author.handle}/post/${urip.rkey}/reposted-by` 67 - }, [item.post.uri, item.post.author.handle]) 68 + return makeProfileLink(item.post.author, 'post', urip.rkey, 'reposted-by') 69 + }, [item.post.uri, item.post.author]) 68 70 const repostsTitle = 'Reposts of this post' 69 71 70 72 const primaryLanguage = store.preferences.contentLanguages[0] || 'en' ··· 185 187 numberOfLines={1} 186 188 lineHeight={1.2}> 187 189 {sanitizeDisplayName( 188 - item.post.author.displayName || item.post.author.handle, 190 + item.post.author.displayName || 191 + sanitizeHandle(item.post.author.handle), 189 192 )} 190 193 </Text> 191 194 </Link> ··· 223 226 href={authorHref} 224 227 title={authorTitle}> 225 228 <Text type="md" style={[pal.textLight]} numberOfLines={1}> 226 - @{item.post.author.handle} 229 + {sanitizeHandle(item.post.author.handle, '@')} 227 230 </Text> 228 231 </Link> 229 232 </View> ··· 297 300 itemCid={itemCid} 298 301 itemHref={itemHref} 299 302 itemTitle={itemTitle} 300 - author={{ 301 - avatar: item.post.author.avatar!, 302 - handle: item.post.author.handle, 303 - displayName: item.post.author.displayName!, 304 - }} 303 + author={item.post.author} 305 304 text={item.richText?.text || record.text} 306 305 indexedAt={item.post.indexedAt} 307 306 isAuthor={item.post.author.did === store.me.did} ··· 362 361 </View> 363 362 <View style={styles.layoutContent}> 364 363 <PostMeta 365 - authorHandle={item.post.author.handle} 366 - authorDisplayName={item.post.author.displayName} 364 + author={item.post.author} 367 365 authorHasWarning={!!item.post.author.labels?.length} 368 366 timestamp={item.post.indexedAt} 369 367 postHref={itemHref} ··· 399 397 itemCid={itemCid} 400 398 itemHref={itemHref} 401 399 itemTitle={itemTitle} 402 - author={{ 403 - avatar: item.post.author.avatar!, 404 - handle: item.post.author.handle, 405 - displayName: item.post.author.displayName!, 406 - }} 400 + author={item.post.author} 407 401 text={item.richText?.text || record.text} 408 402 indexedAt={item.post.indexedAt} 409 403 isAuthor={item.post.author.did === store.me.did}
+4 -8
src/view/com/post/Post.tsx
··· 30 30 import {s, colors} from 'lib/styles' 31 31 import {usePalette} from 'lib/hooks/usePalette' 32 32 import {getTranslatorLink, isPostInLanguage} from '../../../locale/helpers' 33 + import {makeProfileLink} from 'lib/routes/links' 33 34 34 35 export const Post = observer(function Post({ 35 36 uri, ··· 125 126 const itemUri = item.post.uri 126 127 const itemCid = item.post.cid 127 128 const itemUrip = new AtUri(item.post.uri) 128 - const itemHref = `/profile/${item.post.author.handle}/post/${itemUrip.rkey}` 129 + const itemHref = makeProfileLink(item.post.author, 'post', itemUrip.rkey) 129 130 const itemTitle = `Post by ${item.post.author.handle}` 130 131 let replyAuthorDid = '' 131 132 if (record.reply) { ··· 222 223 </View> 223 224 <View style={styles.layoutContent}> 224 225 <PostMeta 225 - authorHandle={item.post.author.handle} 226 - authorDisplayName={item.post.author.displayName} 226 + author={item.post.author} 227 227 authorHasWarning={!!item.post.author.labels?.length} 228 228 timestamp={item.post.indexedAt} 229 229 postHref={itemHref} ··· 282 282 itemCid={itemCid} 283 283 itemHref={itemHref} 284 284 itemTitle={itemTitle} 285 - author={{ 286 - avatar: item.post.author.avatar!, 287 - handle: item.post.author.handle, 288 - displayName: item.post.author.displayName!, 289 - }} 285 + author={item.post.author} 290 286 indexedAt={item.post.indexedAt} 291 287 text={item.richText?.text || record.text} 292 288 isAuthor={item.post.author.did === store.me.did}
+10 -12
src/view/com/posts/FeedItem.tsx
··· 27 27 import {usePalette} from 'lib/hooks/usePalette' 28 28 import {useAnalytics} from 'lib/analytics/analytics' 29 29 import {sanitizeDisplayName} from 'lib/strings/display-names' 30 + import {sanitizeHandle} from 'lib/strings/handles' 30 31 import {getTranslatorLink, isPostInLanguage} from '../../../locale/helpers' 32 + import {makeProfileLink} from 'lib/routes/links' 31 33 32 34 export const FeedItem = observer(function ({ 33 35 item, ··· 50 52 const itemCid = item.post.cid 51 53 const itemHref = useMemo(() => { 52 54 const urip = new AtUri(item.post.uri) 53 - return `/profile/${item.post.author.handle}/post/${urip.rkey}` 54 - }, [item.post.uri, item.post.author.handle]) 55 + return makeProfileLink(item.post.author, 'post', urip.rkey) 56 + }, [item.post.uri, item.post.author]) 55 57 const itemTitle = `Post by ${item.post.author.handle}` 56 58 const replyAuthorDid = useMemo(() => { 57 59 if (!record?.reply) { ··· 178 180 {item.reasonRepost && ( 179 181 <Link 180 182 style={styles.includeReason} 181 - href={`/profile/${item.reasonRepost.by.handle}`} 183 + href={makeProfileLink(item.reasonRepost.by)} 182 184 title={sanitizeDisplayName( 183 185 item.reasonRepost.by.displayName || item.reasonRepost.by.handle, 184 186 )}> ··· 201 203 lineHeight={1.2} 202 204 numberOfLines={1} 203 205 text={sanitizeDisplayName( 204 - item.reasonRepost.by.displayName || item.reasonRepost.by.handle, 206 + item.reasonRepost.by.displayName || 207 + sanitizeHandle(item.reasonRepost.by.handle), 205 208 )} 206 - href={`/profile/${item.reasonRepost.by.handle}`} 209 + href={makeProfileLink(item.reasonRepost.by)} 207 210 /> 208 211 </Text> 209 212 </Link> ··· 221 224 </View> 222 225 <View style={styles.layoutContent}> 223 226 <PostMeta 224 - authorHandle={item.post.author.handle} 225 - authorDisplayName={item.post.author.displayName} 227 + author={item.post.author} 226 228 authorHasWarning={!!item.post.author.labels?.length} 227 229 timestamp={item.post.indexedAt} 228 230 postHref={itemHref} ··· 284 286 itemCid={itemCid} 285 287 itemHref={itemHref} 286 288 itemTitle={itemTitle} 287 - author={{ 288 - avatar: item.post.author.avatar!, 289 - handle: item.post.author.handle, 290 - displayName: item.post.author.displayName!, 291 - }} 289 + author={item.post.author} 292 290 text={item.richText?.text || record.text} 293 291 indexedAt={item.post.indexedAt} 294 292 isAuthor={item.post.author.did === store.me.did}
+3 -2
src/view/com/posts/FeedSlice.tsx
··· 8 8 import {FeedItem} from './FeedItem' 9 9 import {usePalette} from 'lib/hooks/usePalette' 10 10 import {ModerationBehaviorCode} from 'lib/labeling/types' 11 + import {makeProfileLink} from 'lib/routes/links' 11 12 12 13 export function FeedSlice({ 13 14 slice, ··· 70 71 const pal = usePalette('default') 71 72 const itemHref = React.useMemo(() => { 72 73 const urip = new AtUri(slice.rootItem.post.uri) 73 - return `/profile/${slice.rootItem.post.author.handle}/post/${urip.rkey}` 74 - }, [slice.rootItem.post.uri, slice.rootItem.post.author.handle]) 74 + return makeProfileLink(slice.rootItem.post.author, 'post', urip.rkey) 75 + }, [slice.rootItem.post.uri, slice.rootItem.post.author]) 75 76 76 77 return ( 77 78 <Link style={[pal.view, styles.viewFullThread]} href={itemHref} noFeedback>
+8 -4
src/view/com/profile/ProfileCard.tsx
··· 10 10 import {useStores} from 'state/index' 11 11 import {FollowButton} from './FollowButton' 12 12 import {sanitizeDisplayName} from 'lib/strings/display-names' 13 + import {sanitizeHandle} from 'lib/strings/handles' 13 14 import { 14 15 getProfileViewBasicLabelInfo, 15 16 getProfileModeration, 16 17 } from 'lib/labeling/helpers' 17 18 import {ModerationBehaviorCode} from 'lib/labeling/types' 19 + import {makeProfileLink} from 'lib/routes/links' 18 20 19 21 export const ProfileCard = observer( 20 22 ({ ··· 60 62 noBorder && styles.outerNoBorder, 61 63 !noBg && pal.view, 62 64 ]} 63 - href={`/profile/${profile.handle}`} 65 + href={makeProfileLink(profile)} 64 66 title={profile.handle} 65 67 asAnchor 66 68 anchorNoUnderline> ··· 78 80 style={[s.bold, pal.text]} 79 81 numberOfLines={1} 80 82 lineHeight={1.2}> 81 - {sanitizeDisplayName(profile.displayName || profile.handle)} 83 + {sanitizeDisplayName( 84 + profile.displayName || sanitizeHandle(profile.handle), 85 + )} 82 86 </Text> 83 87 <Text type="md" style={[pal.textLight]} numberOfLines={1}> 84 - @{profile.handle} 88 + {sanitizeHandle(profile.handle, '@')} 85 89 </Text> 86 90 {!!profile.viewer?.followedBy && ( 87 91 <View style={s.flexRow}> ··· 160 164 followers?: AppBskyActorDefs.ProfileView[] | undefined 161 165 }) => { 162 166 const store = useStores() 163 - const isMe = store.me.handle === profile.handle 167 + const isMe = store.me.did === profile.did 164 168 165 169 return ( 166 170 <ProfileCard
+35 -8
src/view/com/profile/ProfileHeader.tsx
··· 15 15 import {pluralize} from 'lib/strings/helpers' 16 16 import {toShareUrl} from 'lib/strings/url-helpers' 17 17 import {sanitizeDisplayName} from 'lib/strings/display-names' 18 + import {sanitizeHandle} from 'lib/strings/handles' 18 19 import {s, colors} from 'lib/styles' 19 20 import {DropdownButton, DropdownItem} from '../util/forms/DropdownButton' 20 21 import * as Toast from '../util/Toast' 21 22 import {LoadingPlaceholder} from '../util/LoadingPlaceholder' 22 23 import {Text} from '../util/text/Text' 24 + import {ThemedText} from '../util/text/ThemedText' 23 25 import {TextLink} from '../util/Link' 24 26 import {RichText} from '../util/text/RichText' 25 27 import {UserAvatar} from '../util/UserAvatar' ··· 34 36 import {shareUrl} from 'lib/sharing' 35 37 import {formatCount} from '../util/numeric/format' 36 38 import {navigate} from '../../../Navigation' 39 + import {isInvalidHandle} from 'lib/strings/handles' 40 + import {makeProfileLink} from 'lib/routes/links' 37 41 38 42 const BACK_HITSLOP = {left: 30, top: 30, right: 30, bottom: 30} 39 43 ··· 67 71 </View> 68 72 <View> 69 73 <Text type="title-2xl" style={[pal.text, styles.title]}> 70 - {sanitizeDisplayName(view.displayName || view.handle)} 74 + {sanitizeDisplayName( 75 + view.displayName || sanitizeHandle(view.handle), 76 + )} 71 77 </Text> 72 78 </View> 73 79 </View> ··· 104 110 const store = useStores() 105 111 const navigation = useNavigation<NavigationProp>() 106 112 const {track} = useAnalytics() 113 + const invalidHandle = isInvalidHandle(view.handle) 107 114 108 115 const onPressBack = React.useCallback(() => { 109 116 navigation.goBack() ··· 144 151 145 152 const onPressFollowers = React.useCallback(() => { 146 153 track('ProfileHeader:FollowersButtonClicked') 147 - navigate('ProfileFollowers', {name: view.handle}) 154 + navigate('ProfileFollowers', { 155 + name: isInvalidHandle(view.handle) ? view.did : view.handle, 156 + }) 148 157 store.shell.closeAllActiveElements() // for when used in the profile preview modal 149 158 }, [track, view, store.shell]) 150 159 151 160 const onPressFollows = React.useCallback(() => { 152 161 track('ProfileHeader:FollowsButtonClicked') 153 - navigate('ProfileFollows', {name: view.handle}) 162 + navigate('ProfileFollows', { 163 + name: isInvalidHandle(view.handle) ? view.did : view.handle, 164 + }) 154 165 store.shell.closeAllActiveElements() // for when used in the profile preview modal 155 166 }, [track, view, store.shell]) 156 167 157 168 const onPressShare = React.useCallback(() => { 158 169 track('ProfileHeader:ShareButtonClicked') 159 - const url = toShareUrl(`/profile/${view.handle}`) 170 + const url = toShareUrl(makeProfileLink(view)) 160 171 shareUrl(url) 161 172 }, [track, view]) 162 173 ··· 338 349 style={[styles.btn, styles.mainBtn, pal.btn]} 339 350 accessibilityRole="button" 340 351 accessibilityLabel={`Unfollow ${view.handle}`} 341 - accessibilityHint={`Hides direct posts from ${view.handle} in your feed`}> 352 + accessibilityHint={`Hides posts from ${view.handle} in your feed`}> 342 353 <FontAwesomeIcon 343 354 icon="check" 344 355 style={[pal.text, s.mr5]} ··· 355 366 style={[styles.btn, styles.mainBtn, palInverted.view]} 356 367 accessibilityRole="button" 357 368 accessibilityLabel={`Follow ${view.handle}`} 358 - accessibilityHint={`Shows direct posts from ${view.handle} in your feed`}> 369 + accessibilityHint={`Shows posts from ${view.handle} in your feed`}> 359 370 <FontAwesomeIcon 360 371 icon="plus" 361 372 style={[palInverted.text, s.mr5]} ··· 382 393 testID="profileHeaderDisplayName" 383 394 type="title-2xl" 384 395 style={[pal.text, styles.title]}> 385 - {sanitizeDisplayName(view.displayName || view.handle)} 396 + {sanitizeDisplayName( 397 + view.displayName || sanitizeHandle(view.handle), 398 + )} 386 399 </Text> 387 400 </View> 388 401 <View style={styles.handleLine}> ··· 393 406 </Text> 394 407 </View> 395 408 ) : undefined} 396 - <Text style={[pal.textLight, styles.handle]}>@{view.handle}</Text> 409 + <ThemedText 410 + type={invalidHandle ? 'xs' : 'md'} 411 + fg={invalidHandle ? 'error' : 'light'} 412 + border={invalidHandle ? 'error' : undefined} 413 + style={[ 414 + invalidHandle ? styles.invalidHandle : undefined, 415 + styles.handle, 416 + ]}> 417 + {invalidHandle ? '⚠Invalid Handle' : `@${view.handle}`} 418 + </ThemedText> 397 419 </View> 398 420 {!blockHide && ( 399 421 <> ··· 600 622 // @ts-ignore web only -prf 601 623 wordBreak: 'break-all', 602 624 }, 625 + invalidHandle: { 626 + borderWidth: 1, 627 + borderRadius: 4, 628 + paddingHorizontal: 4, 629 + }, 603 630 604 631 handleLine: { 605 632 flexDirection: 'row',
+2 -1
src/view/com/search/Suggestions.tsx
··· 12 12 import {ProfileCardWithFollowBtn} from '../profile/ProfileCard' 13 13 import {ProfileCardFeedLoadingPlaceholder} from 'view/com/util/LoadingPlaceholder' 14 14 import {sanitizeDisplayName} from 'lib/strings/display-names' 15 + import {sanitizeHandle} from 'lib/strings/handles' 15 16 import {RefWithInfoAndFollowers} from 'state/models/discovery/foafs' 16 17 import {usePalette} from 'lib/hooks/usePalette' 17 18 ··· 99 100 _reactKey: `__${item.did}_heading__`, 100 101 type: 'heading', 101 102 title: `Followed by ${sanitizeDisplayName( 102 - item.displayName || item.handle, 103 + item.displayName || sanitizeHandle(item.handle), 103 104 )}`, 104 105 }, 105 106 ])
+19 -12
src/view/com/util/PostMeta.tsx
··· 7 7 import {UserAvatar} from './UserAvatar' 8 8 import {observer} from 'mobx-react-lite' 9 9 import {sanitizeDisplayName} from 'lib/strings/display-names' 10 + import {sanitizeHandle} from 'lib/strings/handles' 10 11 import {isAndroid} from 'platform/detection' 11 12 import {TimeElapsed} from './TimeElapsed' 13 + import {makeProfileLink} from 'lib/routes/links' 12 14 13 15 interface PostMetaOpts { 14 - authorAvatar?: string 15 - authorHandle: string 16 - authorDisplayName: string | undefined 16 + author: { 17 + avatar?: string 18 + did: string 19 + handle: string 20 + displayName?: string | undefined 21 + } 22 + showAvatar?: boolean 17 23 authorHasWarning: boolean 18 24 postHref: string 19 25 timestamp: string ··· 21 27 22 28 export const PostMeta = observer(function (opts: PostMetaOpts) { 23 29 const pal = usePalette('default') 24 - const displayName = opts.authorDisplayName || opts.authorHandle 25 - const handle = opts.authorHandle 30 + const displayName = opts.author.displayName || opts.author.handle 31 + const handle = opts.author.handle 26 32 27 33 return ( 28 34 <View style={styles.metaOneLine}> 29 - {typeof opts.authorAvatar !== 'undefined' && ( 35 + {opts.showAvatar && typeof opts.author.avatar !== 'undefined' && ( 30 36 <View style={styles.avatar}> 31 37 <UserAvatar 32 - avatar={opts.authorAvatar} 38 + avatar={opts.author.avatar} 33 39 size={16} 34 40 // TODO moderation 35 41 /> ··· 43 49 lineHeight={1.2} 44 50 text={ 45 51 <> 46 - {sanitizeDisplayName(displayName)} 52 + {sanitizeDisplayName(displayName)}&nbsp; 47 53 <Text 48 54 type="md" 49 - style={[pal.textLight]} 50 55 numberOfLines={1} 51 - lineHeight={1.2}> 52 - &nbsp;@{handle} 56 + lineHeight={1.2} 57 + style={pal.textLight}> 58 + {sanitizeHandle(handle, '@')} 53 59 </Text> 54 60 </> 55 61 } 56 - href={`/profile/${opts.authorHandle}`} 62 + href={makeProfileLink(opts.author)} 57 63 /> 58 64 </View> 59 65 {!isAndroid && ( ··· 85 91 const styles = StyleSheet.create({ 86 92 metaOneLine: { 87 93 flexDirection: 'row', 94 + alignItems: 'baseline', 88 95 paddingBottom: 2, 89 96 gap: 4, 90 97 },
+4 -2
src/view/com/util/UserInfoText.tsx
··· 7 7 import {useStores} from 'state/index' 8 8 import {TypographyVariant} from 'lib/ThemeContext' 9 9 import {sanitizeDisplayName} from 'lib/strings/display-names' 10 + import {sanitizeHandle} from 'lib/strings/handles' 11 + import {makeProfileLink} from 'lib/routes/links' 10 12 11 13 export function UserInfoText({ 12 14 type = 'md', ··· 68 70 style={style} 69 71 lineHeight={1.2} 70 72 numberOfLines={1} 71 - href={`/profile/${profile.handle}`} 73 + href={makeProfileLink(profile)} 72 74 text={`${prefix || ''}${sanitizeDisplayName( 73 75 typeof profile[attr] === 'string' && profile[attr] 74 76 ? (profile[attr] as string) 75 - : profile.handle, 77 + : sanitizeHandle(profile.handle), 76 78 )}`} 77 79 /> 78 80 )
+2 -1
src/view/com/util/UserPreviewLink.tsx
··· 3 3 import {useStores} from 'state/index' 4 4 import {Link} from './Link' 5 5 import {isDesktopWeb} from 'platform/detection' 6 + import {makeProfileLink} from 'lib/routes/links' 6 7 7 8 interface UserPreviewLinkProps { 8 9 did: string ··· 17 18 if (isDesktopWeb) { 18 19 return ( 19 20 <Link 20 - href={`/profile/${props.handle}`} 21 + href={makeProfileLink(props)} 21 22 title={props.handle} 22 23 asAnchor 23 24 style={props.style}>
+4 -3
src/view/com/util/post-ctrls/PostCtrls.tsx
··· 32 32 itemTitle: string 33 33 isAuthor: boolean 34 34 author: { 35 + did: string 35 36 handle: string 36 - displayName: string 37 - avatar: string 37 + displayName?: string | undefined 38 + avatar?: string | undefined 38 39 } 39 40 text: string 40 41 indexedAt: string ··· 269 270 margin: -5, 270 271 }, 271 272 ctrlIconLiked: { 272 - color: colors.red3, 273 + color: colors.like, 273 274 }, 274 275 mt1: { 275 276 marginTop: 1,
+4 -4
src/view/com/util/post-embeds/QuoteEmbed.tsx
··· 8 8 import {usePalette} from 'lib/hooks/usePalette' 9 9 import {ComposerOptsQuote} from 'state/models/ui/shell' 10 10 import {PostEmbeds} from '.' 11 + import {makeProfileLink} from 'lib/routes/links' 11 12 12 13 export function QuoteEmbed({ 13 14 quote, ··· 18 19 }) { 19 20 const pal = usePalette('default') 20 21 const itemUrip = new AtUri(quote.uri) 21 - const itemHref = `/profile/${quote.author.handle}/post/${itemUrip.rkey}` 22 + const itemHref = makeProfileLink(quote.author, 'post', itemUrip.rkey) 22 23 const itemTitle = `Post by ${quote.author.handle}` 23 24 const isEmpty = React.useMemo( 24 25 () => quote.text.trim().length === 0, ··· 39 40 href={itemHref} 40 41 title={itemTitle}> 41 42 <PostMeta 42 - authorAvatar={quote.author.avatar} 43 - authorHandle={quote.author.handle} 44 - authorDisplayName={quote.author.displayName} 43 + author={quote.author} 44 + showAvatar 45 45 authorHasWarning={false} 46 46 postHref={itemHref} 47 47 timestamp={quote.indexedAt}
+80
src/view/com/util/text/ThemedText.tsx
··· 1 + import React from 'react' 2 + import {CustomTextProps, Text} from './Text' 3 + import {usePalette} from 'lib/hooks/usePalette' 4 + import {addStyle} from 'lib/styles' 5 + 6 + export type ThemedTextProps = CustomTextProps & { 7 + fg?: 'default' | 'light' | 'error' | 'inverted' | 'inverted-light' 8 + bg?: 'default' | 'light' | 'error' | 'inverted' | 'inverted-light' 9 + border?: 'default' | 'dark' | 'error' | 'inverted' | 'inverted-dark' 10 + lineHeight?: number 11 + } 12 + 13 + export function ThemedText({ 14 + fg, 15 + bg, 16 + border, 17 + style, 18 + children, 19 + ...props 20 + }: React.PropsWithChildren<ThemedTextProps>) { 21 + const pal = usePalette('default') 22 + const palInverted = usePalette('inverted') 23 + const palError = usePalette('error') 24 + switch (fg) { 25 + case 'default': 26 + style = addStyle(style, pal.text) 27 + break 28 + case 'light': 29 + style = addStyle(style, pal.textLight) 30 + break 31 + case 'error': 32 + style = addStyle(style, {color: palError.colors.background}) 33 + break 34 + case 'inverted': 35 + style = addStyle(style, palInverted.text) 36 + break 37 + case 'inverted-light': 38 + style = addStyle(style, palInverted.textLight) 39 + break 40 + } 41 + switch (bg) { 42 + case 'default': 43 + style = addStyle(style, pal.view) 44 + break 45 + case 'light': 46 + style = addStyle(style, pal.viewLight) 47 + break 48 + case 'error': 49 + style = addStyle(style, palError.view) 50 + break 51 + case 'inverted': 52 + style = addStyle(style, palInverted.view) 53 + break 54 + case 'inverted-light': 55 + style = addStyle(style, palInverted.viewLight) 56 + break 57 + } 58 + switch (border) { 59 + case 'default': 60 + style = addStyle(style, pal.border) 61 + break 62 + case 'dark': 63 + style = addStyle(style, pal.borderDark) 64 + break 65 + case 'error': 66 + style = addStyle(style, palError.border) 67 + break 68 + case 'inverted': 69 + style = addStyle(style, palInverted.border) 70 + break 71 + case 'inverted-dark': 72 + style = addStyle(style, palInverted.borderDark) 73 + break 74 + } 75 + return ( 76 + <Text style={style} {...props}> 77 + {children} 78 + </Text> 79 + ) 80 + }
+7 -2
src/view/screens/CustomFeed.tsx
··· 14 14 import {withAuthRequired} from 'view/com/auth/withAuthRequired' 15 15 import {Feed} from 'view/com/posts/Feed' 16 16 import {pluralize} from 'lib/strings/helpers' 17 + import {sanitizeHandle} from 'lib/strings/handles' 17 18 import {TextLink} from 'view/com/util/Link' 18 19 import {UserAvatar} from 'view/com/util/UserAvatar' 19 20 import {ViewHeader} from 'view/com/util/ViewHeader' ··· 32 33 import {useOnMainScroll} from 'lib/hooks/useOnMainScroll' 33 34 import {EmptyState} from 'view/com/util/EmptyState' 34 35 import {useAnalytics} from 'lib/analytics/analytics' 36 + import {makeProfileLink} from 'lib/routes/links' 35 37 36 38 type Props = NativeStackScreenProps<CommonNavigatorParams, 'CustomFeed'> 37 39 export const CustomFeedScreen = withAuthRequired( ··· 216 218 'you' 217 219 ) : ( 218 220 <TextLink 219 - text={`@${currentFeed.data.creator.handle}`} 220 - href={`/profile/${currentFeed.data.creator.did}`} 221 + text={sanitizeHandle( 222 + currentFeed.data.creator.handle, 223 + '@', 224 + )} 225 + href={makeProfileLink(currentFeed.data.creator)} 221 226 style={[pal.textLight]} 222 227 /> 223 228 )}
+2 -1
src/view/screens/Settings.tsx
··· 43 43 import {formatCount} from 'view/com/util/numeric/format' 44 44 import Clipboard from '@react-native-clipboard/clipboard' 45 45 import {reset as resetNavigation} from '../../Navigation' 46 + import {makeProfileLink} from 'lib/routes/links' 46 47 47 48 // TEMPORARY (APP-700) 48 49 // remove after backend testing finishes ··· 229 230 </View> 230 231 ) : ( 231 232 <Link 232 - href={`/profile/${store.me.handle}`} 233 + href={makeProfileLink(store.me)} 233 234 title="Your profile" 234 235 noFeedback> 235 236 <View style={[pal.view, styles.linkCard]}>
+2 -1
src/view/shell/bottom-bar/BottomBarWeb.tsx
··· 21 21 } from 'lib/icons' 22 22 import {Link} from 'view/com/util/Link' 23 23 import {useMinimalShellMode} from 'lib/hooks/useMinimalShellMode' 24 + import {makeProfileLink} from 'lib/routes/links' 24 25 25 26 export const BottomBarWeb = observer(() => { 26 27 const store = useStores() ··· 87 88 ) 88 89 }} 89 90 </NavItem> 90 - <NavItem routeName="Profile" href={`/profile/${store.me.handle}`}> 91 + <NavItem routeName="Profile" href={makeProfileLink(store.me)}> 91 92 {() => ( 92 93 <UserIcon 93 94 size={28}
+3 -5
src/view/shell/desktop/LeftNav.tsx
··· 36 36 import {getCurrentRoute, isTab, isStateAtTabRoot} from 'lib/routes/helpers' 37 37 import {NavigationProp, CommonNavigatorParams} from 'lib/routes/types' 38 38 import {router} from '../../../routes' 39 + import {makeProfileLink} from 'lib/routes/links' 39 40 40 41 const ProfileCard = observer(() => { 41 42 const store = useStores() 42 43 return ( 43 - <Link 44 - href={`/profile/${store.me.handle}`} 45 - style={styles.profileCard} 46 - asAnchor> 44 + <Link href={makeProfileLink(store.me)} style={styles.profileCard} asAnchor> 47 45 <UserAvatar avatar={store.me.avatar} size={64} /> 48 46 </Link> 49 47 ) ··· 252 250 /> 253 251 {store.session.hasSession && ( 254 252 <NavItem 255 - href={`/profile/${store.me.handle}`} 253 + href={makeProfileLink(store.me)} 256 254 icon={<UserIcon strokeWidth={1.75} size={28} style={pal.text} />} 257 255 iconFilled={ 258 256 <UserIconSolid strokeWidth={1.75} size={28} style={pal.text} />