my fork of the bluesky client

Profile card hover preview (#3508)

* feat: initial user card hover

* feat: flesh it out some more

* fix: initialize middlewares once

* chore: remove floating-ui react-native

* chore: clean up

* Update moderation apis, fix lint

* Refactor profile hover card to alf

* Clean up

* Debounce, fix positioning when loading

* Fix going away

* Close on all link presses

* Tweak styles

* Disable on mobile web

* cleanup some of the changes pt. 1

* cleanup some of the changes pt. 2

* cleanup some of the changes pt. 3

* cleanup some of the changes pt. 4

* Re-revert files

* Fix handle presentation

* Don't follow yourself, silly

* Collapsed notifications group

* ProfileCard

* Tree view replies

* Suggested follows

* Fix hover-back-on-card edge case

* Moar

---------

Co-authored-by: Mary <git@mary.my.id>
Co-authored-by: Hailey <me@haileyok.com>

authored by

Eric Bailey
Mary
Hailey
and committed by
GitHub
1f61109c f91aa37c

+571 -141
+2
package.json
··· 57 57 "@emoji-mart/react": "^1.1.1", 58 58 "@expo/html-elements": "^0.4.2", 59 59 "@expo/webpack-config": "^19.0.0", 60 + "@floating-ui/dom": "^1.6.3", 61 + "@floating-ui/react-dom": "^2.0.8", 60 62 "@fortawesome/fontawesome-svg-core": "^6.1.1", 61 63 "@fortawesome/free-regular-svg-icons": "^6.1.1", 62 64 "@fortawesome/free-solid-svg-icons": "^6.1.1",
+5
src/components/ProfileHoverCard/index.tsx
··· 1 + import {ProfileHoverCardProps} from './types' 2 + 3 + export function ProfileHoverCard({children}: ProfileHoverCardProps) { 4 + return children 5 + }
+290
src/components/ProfileHoverCard/index.web.tsx
··· 1 + import React from 'react' 2 + import {View} from 'react-native' 3 + import Animated, {FadeIn, FadeOut} from 'react-native-reanimated' 4 + import {AppBskyActorDefs, moderateProfile, ModerationOpts} from '@atproto/api' 5 + import {flip, offset, shift, size, useFloating} from '@floating-ui/react-dom' 6 + import {msg, Trans} from '@lingui/macro' 7 + import {useLingui} from '@lingui/react' 8 + 9 + import {makeProfileLink} from '#/lib/routes/links' 10 + import {sanitizeDisplayName} from '#/lib/strings/display-names' 11 + import {sanitizeHandle} from '#/lib/strings/handles' 12 + import {pluralize} from '#/lib/strings/helpers' 13 + import {useModerationOpts} from '#/state/queries/preferences' 14 + import {usePrefetchProfileQuery, useProfileQuery} from '#/state/queries/profile' 15 + import {useSession} from '#/state/session' 16 + import {useProfileShadow} from 'state/cache/profile-shadow' 17 + import {formatCount} from '#/view/com/util/numeric/format' 18 + import {UserAvatar} from '#/view/com/util/UserAvatar' 19 + import {ProfileHeaderHandle} from '#/screens/Profile/Header/Handle' 20 + import {atoms as a, useTheme} from '#/alf' 21 + import {Button, ButtonIcon, ButtonText} from '#/components/Button' 22 + import {useFollowMethods} from '#/components/hooks/useFollowMethods' 23 + import {useRichText} from '#/components/hooks/useRichText' 24 + import {Check_Stroke2_Corner0_Rounded as Check} from '#/components/icons/Check' 25 + import {PlusLarge_Stroke2_Corner0_Rounded as Plus} from '#/components/icons/Plus' 26 + import {InlineLinkText, Link} from '#/components/Link' 27 + import {Loader} from '#/components/Loader' 28 + import {Portal} from '#/components/Portal' 29 + import {RichText} from '#/components/RichText' 30 + import {Text} from '#/components/Typography' 31 + import {ProfileHoverCardProps} from './types' 32 + 33 + const floatingMiddlewares = [ 34 + offset(4), 35 + flip({padding: 16}), 36 + shift({padding: 16}), 37 + size({ 38 + padding: 16, 39 + apply({availableWidth, availableHeight, elements}) { 40 + Object.assign(elements.floating.style, { 41 + maxWidth: `${availableWidth}px`, 42 + maxHeight: `${availableHeight}px`, 43 + }) 44 + }, 45 + }), 46 + ] 47 + 48 + const isTouchDevice = 'ontouchstart' in window || navigator.maxTouchPoints > 0 49 + 50 + export function ProfileHoverCard(props: ProfileHoverCardProps) { 51 + return isTouchDevice ? props.children : <ProfileHoverCardInner {...props} /> 52 + } 53 + 54 + export function ProfileHoverCardInner(props: ProfileHoverCardProps) { 55 + const [hovered, setHovered] = React.useState(false) 56 + const {refs, floatingStyles} = useFloating({ 57 + middleware: floatingMiddlewares, 58 + }) 59 + const prefetchProfileQuery = usePrefetchProfileQuery() 60 + 61 + const prefetchedProfile = React.useRef(false) 62 + const targetHovered = React.useRef(false) 63 + const cardHovered = React.useRef(false) 64 + const targetClicked = React.useRef(false) 65 + 66 + const onPointerEnterTarget = React.useCallback(() => { 67 + targetHovered.current = true 68 + 69 + if (prefetchedProfile.current) { 70 + // if we're navigating 71 + if (targetClicked.current) return 72 + setHovered(true) 73 + } else { 74 + prefetchProfileQuery(props.did).then(() => { 75 + if (targetHovered.current) { 76 + setHovered(true) 77 + } 78 + prefetchedProfile.current = true 79 + }) 80 + } 81 + }, [props.did, prefetchProfileQuery]) 82 + const onPointerEnterCard = React.useCallback(() => { 83 + cardHovered.current = true 84 + // if we're navigating 85 + if (targetClicked.current) return 86 + setHovered(true) 87 + }, []) 88 + const onPointerLeaveTarget = React.useCallback(() => { 89 + targetHovered.current = false 90 + setTimeout(() => { 91 + if (cardHovered.current) return 92 + setHovered(false) 93 + }, 100) 94 + }, []) 95 + const onPointerLeaveCard = React.useCallback(() => { 96 + cardHovered.current = false 97 + setTimeout(() => { 98 + if (targetHovered.current) return 99 + setHovered(false) 100 + }, 100) 101 + }, []) 102 + const onClickTarget = React.useCallback(() => { 103 + targetClicked.current = true 104 + setHovered(false) 105 + }, []) 106 + const hide = React.useCallback(() => { 107 + setHovered(false) 108 + }, []) 109 + 110 + return ( 111 + <div 112 + ref={refs.setReference} 113 + onPointerEnter={onPointerEnterTarget} 114 + onPointerLeave={onPointerLeaveTarget} 115 + onMouseUp={onClickTarget}> 116 + {props.children} 117 + 118 + {hovered && ( 119 + <Portal> 120 + <Animated.View 121 + entering={FadeIn.duration(80)} 122 + exiting={FadeOut.duration(80)}> 123 + <div 124 + ref={refs.setFloating} 125 + style={floatingStyles} 126 + onPointerEnter={onPointerEnterCard} 127 + onPointerLeave={onPointerLeaveCard}> 128 + <Card did={props.did} hide={hide} /> 129 + </div> 130 + </Animated.View> 131 + </Portal> 132 + )} 133 + </div> 134 + ) 135 + } 136 + 137 + function Card({did, hide}: {did: string; hide: () => void}) { 138 + const t = useTheme() 139 + 140 + const profile = useProfileQuery({did}) 141 + const moderationOpts = useModerationOpts() 142 + 143 + const data = profile.data 144 + 145 + return ( 146 + <View 147 + style={[ 148 + a.p_lg, 149 + a.border, 150 + a.rounded_md, 151 + a.overflow_hidden, 152 + t.atoms.bg, 153 + t.atoms.border_contrast_low, 154 + t.atoms.shadow_lg, 155 + { 156 + width: 300, 157 + }, 158 + ]}> 159 + {data && moderationOpts ? ( 160 + <Inner profile={data} moderationOpts={moderationOpts} hide={hide} /> 161 + ) : ( 162 + <View style={[a.justify_center]}> 163 + <Loader size="xl" /> 164 + </View> 165 + )} 166 + </View> 167 + ) 168 + } 169 + 170 + function Inner({ 171 + profile, 172 + moderationOpts, 173 + hide, 174 + }: { 175 + profile: AppBskyActorDefs.ProfileViewDetailed 176 + moderationOpts: ModerationOpts 177 + hide: () => void 178 + }) { 179 + const t = useTheme() 180 + const {_} = useLingui() 181 + const {currentAccount} = useSession() 182 + const moderation = React.useMemo( 183 + () => moderateProfile(profile, moderationOpts), 184 + [profile, moderationOpts], 185 + ) 186 + const [descriptionRT] = useRichText(profile.description ?? '') 187 + const profileShadow = useProfileShadow(profile) 188 + const {follow, unfollow} = useFollowMethods({ 189 + profile: profileShadow, 190 + logContext: 'ProfileHoverCard', 191 + }) 192 + const blockHide = profile.viewer?.blocking || profile.viewer?.blockedBy 193 + const following = formatCount(profile.followsCount || 0) 194 + const followers = formatCount(profile.followersCount || 0) 195 + const pluralizedFollowers = pluralize(profile.followersCount || 0, 'follower') 196 + const profileURL = makeProfileLink({ 197 + did: profile.did, 198 + handle: profile.handle, 199 + }) 200 + const isMe = React.useMemo( 201 + () => currentAccount?.did === profile.did, 202 + [currentAccount, profile], 203 + ) 204 + 205 + return ( 206 + <View> 207 + <View style={[a.flex_row, a.justify_between, a.align_start]}> 208 + <Link to={profileURL} label={_(msg`View profile`)} onPress={hide}> 209 + <UserAvatar 210 + size={64} 211 + avatar={profile.avatar} 212 + moderation={moderation.ui('avatar')} 213 + /> 214 + </Link> 215 + 216 + {!isMe && ( 217 + <Button 218 + size="small" 219 + color={profileShadow.viewer?.following ? 'secondary' : 'primary'} 220 + variant="solid" 221 + label={ 222 + profileShadow.viewer?.following ? _('Following') : _('Follow') 223 + } 224 + style={[a.rounded_full]} 225 + onPress={profileShadow.viewer?.following ? unfollow : follow}> 226 + <ButtonIcon 227 + position="left" 228 + icon={profileShadow.viewer?.following ? Check : Plus} 229 + /> 230 + <ButtonText> 231 + {profileShadow.viewer?.following ? _('Following') : _('Follow')} 232 + </ButtonText> 233 + </Button> 234 + )} 235 + </View> 236 + 237 + <Link to={profileURL} label={_(msg`View profile`)} onPress={hide}> 238 + <View style={[a.pb_sm, a.flex_1]}> 239 + <Text style={[a.pt_md, a.pb_xs, a.text_lg, a.font_bold]}> 240 + {sanitizeDisplayName( 241 + profile.displayName || sanitizeHandle(profile.handle), 242 + moderation.ui('displayName'), 243 + )} 244 + </Text> 245 + 246 + <ProfileHeaderHandle profile={profileShadow} /> 247 + </View> 248 + </Link> 249 + 250 + {!blockHide && ( 251 + <> 252 + <View style={[a.flex_row, a.flex_wrap, a.gap_md, a.pt_xs]}> 253 + <InlineLinkText 254 + to={makeProfileLink(profile, 'followers')} 255 + label={`${followers} ${pluralizedFollowers}`} 256 + style={[t.atoms.text]} 257 + onPress={hide}> 258 + <Trans> 259 + <Text style={[a.text_md, a.font_bold]}>{followers} </Text> 260 + <Text style={[t.atoms.text_contrast_medium]}> 261 + {pluralizedFollowers} 262 + </Text> 263 + </Trans> 264 + </InlineLinkText> 265 + <InlineLinkText 266 + to={makeProfileLink(profile, 'follows')} 267 + label={_(msg`${following} following`)} 268 + style={[t.atoms.text]} 269 + onPress={hide}> 270 + <Trans> 271 + <Text style={[a.text_md, a.font_bold]}>{following} </Text> 272 + <Text style={[t.atoms.text_contrast_medium]}>following</Text> 273 + </Trans> 274 + </InlineLinkText> 275 + </View> 276 + 277 + {profile.description?.trim() && !moderation.ui('profileView').blur ? ( 278 + <View style={[a.pt_md]}> 279 + <RichText 280 + numberOfLines={8} 281 + value={descriptionRT} 282 + onLinkPress={hide} 283 + /> 284 + </View> 285 + ) : undefined} 286 + </> 287 + )} 288 + </View> 289 + ) 290 + }
+6
src/components/ProfileHoverCard/types.ts
··· 1 + import React from 'react' 2 + 3 + export type ProfileHoverCardProps = { 4 + children: React.ReactElement 5 + did: string 6 + }
+7 -3
src/components/RichText.tsx
··· 7 7 import {isNative} from '#/platform/detection' 8 8 import {atoms as a, flatten, native, TextStyleProp, useTheme, web} from '#/alf' 9 9 import {useInteractionState} from '#/components/hooks/useInteractionState' 10 - import {InlineLinkText} from '#/components/Link' 10 + import {InlineLinkText, LinkProps} from '#/components/Link' 11 11 import {TagMenu, useTagMenuControl} from '#/components/TagMenu' 12 12 import {Text, TextProps} from '#/components/Typography' 13 13 ··· 22 22 selectable, 23 23 enableTags = false, 24 24 authorHandle, 25 + onLinkPress, 25 26 }: TextStyleProp & 26 27 Pick<TextProps, 'selectable'> & { 27 28 value: RichTextAPI | string ··· 30 31 disableLinks?: boolean 31 32 enableTags?: boolean 32 33 authorHandle?: string 34 + onLinkPress?: LinkProps['onPress'] 33 35 }) { 34 36 const richText = React.useMemo( 35 37 () => ··· 90 92 to={`/profile/${mention.did}`} 91 93 style={[...styles, {pointerEvents: 'auto'}]} 92 94 // @ts-ignore TODO 93 - dataSet={WORD_WRAP}> 95 + dataSet={WORD_WRAP} 96 + onPress={onLinkPress}> 94 97 {segment.text} 95 98 </InlineLinkText>, 96 99 ) ··· 106 109 style={[...styles, {pointerEvents: 'auto'}]} 107 110 // @ts-ignore TODO 108 111 dataSet={WORD_WRAP} 109 - shareOnLongPress> 112 + shareOnLongPress 113 + onPress={onLinkPress}> 110 114 {toShortUrl(segment.text)} 111 115 </InlineLinkText>, 112 116 )
+60
src/components/hooks/useFollowMethods.ts
··· 1 + import React from 'react' 2 + import {AppBskyActorDefs} from '@atproto/api' 3 + import {msg} from '@lingui/macro' 4 + import {useLingui} from '@lingui/react' 5 + 6 + import {LogEvents} from '#/lib/statsig/statsig' 7 + import {logger} from '#/logger' 8 + import {Shadow} from '#/state/cache/types' 9 + import {useProfileFollowMutationQueue} from '#/state/queries/profile' 10 + import {useRequireAuth} from '#/state/session' 11 + import * as Toast from '#/view/com/util/Toast' 12 + 13 + export function useFollowMethods({ 14 + profile, 15 + logContext, 16 + }: { 17 + profile: Shadow<AppBskyActorDefs.ProfileViewBasic> 18 + logContext: LogEvents['profile:follow']['logContext'] & 19 + LogEvents['profile:unfollow']['logContext'] 20 + }) { 21 + const {_} = useLingui() 22 + const requireAuth = useRequireAuth() 23 + const [queueFollow, queueUnfollow] = useProfileFollowMutationQueue( 24 + profile, 25 + logContext, 26 + ) 27 + 28 + const follow = React.useCallback(() => { 29 + requireAuth(async () => { 30 + try { 31 + await queueFollow() 32 + } catch (e: any) { 33 + logger.error(`useFollowMethods: failed to follow`, {message: String(e)}) 34 + if (e?.name !== 'AbortError') { 35 + Toast.show(_(msg`An issue occurred, please try again.`)) 36 + } 37 + } 38 + }) 39 + }, [_, queueFollow, requireAuth]) 40 + 41 + const unfollow = React.useCallback(() => { 42 + requireAuth(async () => { 43 + try { 44 + await queueUnfollow() 45 + } catch (e: any) { 46 + logger.error(`useFollowMethods: failed to unfollow`, { 47 + message: String(e), 48 + }) 49 + if (e?.name !== 'AbortError') { 50 + Toast.show(_(msg`An issue occurred, please try again.`)) 51 + } 52 + } 53 + }) 54 + }, [_, queueUnfollow, requireAuth]) 55 + 56 + return { 57 + follow, 58 + unfollow, 59 + } 60 + }
+33
src/components/hooks/useRichText.ts
··· 1 + import React from 'react' 2 + import {RichText as RichTextAPI} from '@atproto/api' 3 + 4 + import {getAgent} from '#/state/session' 5 + 6 + export function useRichText(text: string): [RichTextAPI, boolean] { 7 + const [prevText, setPrevText] = React.useState(text) 8 + const [rawRT, setRawRT] = React.useState(() => new RichTextAPI({text})) 9 + const [resolvedRT, setResolvedRT] = React.useState<RichTextAPI | null>(null) 10 + if (text !== prevText) { 11 + setPrevText(text) 12 + setRawRT(new RichTextAPI({text})) 13 + setResolvedRT(null) 14 + // This will queue an immediate re-render 15 + } 16 + React.useEffect(() => { 17 + let ignore = false 18 + async function resolveRTFacets() { 19 + // new each time 20 + const resolvedRT = new RichTextAPI({text}) 21 + await resolvedRT.detectFacets(getAgent()) 22 + if (!ignore) { 23 + setResolvedRT(resolvedRT) 24 + } 25 + } 26 + resolveRTFacets() 27 + return () => { 28 + ignore = true 29 + } 30 + }, [text]) 31 + const isResolving = resolvedRT === null 32 + return [resolvedRT ?? rawRT, isResolving] 33 + }
+2
src/lib/statsig/events.ts
··· 99 99 | 'ProfileHeader' 100 100 | 'ProfileHeaderSuggestedFollows' 101 101 | 'ProfileMenu' 102 + | 'ProfileHoverCard' 102 103 } 103 104 'profile:unfollow': { 104 105 logContext: ··· 108 109 | 'ProfileHeader' 109 110 | 'ProfileHeaderSuggestedFollows' 110 111 | 'ProfileMenu' 112 + | 'ProfileHoverCard' 111 113 } 112 114 }
+4 -3
src/screens/Profile/Header/Handle.tsx
··· 1 1 import React from 'react' 2 2 import {View} from 'react-native' 3 3 import {AppBskyActorDefs} from '@atproto/api' 4 - import {isInvalidHandle} from 'lib/strings/handles' 5 - import {Shadow} from '#/state/cache/types' 6 4 import {Trans} from '@lingui/macro' 7 5 6 + import {Shadow} from '#/state/cache/types' 7 + import {isInvalidHandle} from 'lib/strings/handles' 8 8 import {atoms as a, useTheme, web} from '#/alf' 9 9 import {Text} from '#/components/Typography' 10 10 ··· 26 26 </View> 27 27 ) : undefined} 28 28 <Text 29 + numberOfLines={1} 29 30 style={[ 30 31 invalidHandle 31 32 ? [ ··· 36 37 a.rounded_xs, 37 38 {borderColor: t.palette.contrast_200}, 38 39 ] 39 - : [a.text_md, t.atoms.text_contrast_medium], 40 + : [a.text_md, a.leading_tight, t.atoms.text_contrast_medium], 40 41 web({wordBreak: 'break-all'}), 41 42 ]}> 42 43 {invalidHandle ? <Trans>⚠Invalid Handle</Trans> : `@${profile.handle}`}
+2 -2
src/state/queries/profile.ts
··· 90 90 export function usePrefetchProfileQuery() { 91 91 const queryClient = useQueryClient() 92 92 const prefetchProfileQuery = useCallback( 93 - (did: string) => { 94 - queryClient.prefetchQuery({ 93 + async (did: string) => { 94 + await queryClient.prefetchQuery({ 95 95 queryKey: RQKEY(did), 96 96 queryFn: async () => { 97 97 const res = await getAgent().getProfile({actor: did || ''})
+56 -45
src/view/com/notifications/FeedItem.tsx
··· 1 - import React, {memo, useMemo, useState, useEffect} from 'react' 1 + import React, {memo, useEffect, useMemo, useState} from 'react' 2 2 import { 3 3 Animated, 4 - TouchableOpacity, 5 4 Pressable, 6 5 StyleSheet, 6 + TouchableOpacity, 7 7 View, 8 8 } from 'react-native' 9 9 import { 10 + AppBskyActorDefs, 10 11 AppBskyEmbedImages, 12 + AppBskyEmbedRecordWithMedia, 11 13 AppBskyFeedDefs, 12 14 AppBskyFeedPost, 15 + moderateProfile, 16 + ModerationDecision, 13 17 ModerationOpts, 14 - ModerationDecision, 15 - moderateProfile, 16 - AppBskyEmbedRecordWithMedia, 17 - AppBskyActorDefs, 18 18 } from '@atproto/api' 19 19 import {AtUri} from '@atproto/api' 20 20 import { ··· 22 22 FontAwesomeIconStyle, 23 23 Props, 24 24 } from '@fortawesome/react-native-fontawesome' 25 + import {msg, Trans} from '@lingui/macro' 26 + import {useLingui} from '@lingui/react' 27 + 25 28 import {FeedNotification} from '#/state/queries/notifications/feed' 26 - import {s, colors} from 'lib/styles' 27 - import {niceDate} from 'lib/strings/time' 29 + import {useAnimatedValue} from 'lib/hooks/useAnimatedValue' 30 + import {usePalette} from 'lib/hooks/usePalette' 31 + import {HeartIconSolid} from 'lib/icons' 32 + import {makeProfileLink} from 'lib/routes/links' 28 33 import {sanitizeDisplayName} from 'lib/strings/display-names' 29 34 import {sanitizeHandle} from 'lib/strings/handles' 30 35 import {pluralize} from 'lib/strings/helpers' 31 - import {HeartIconSolid} from 'lib/icons' 32 - import {Text} from '../util/text/Text' 33 - import {UserAvatar, PreviewableUserAvatar} from '../util/UserAvatar' 34 - import {UserPreviewLink} from '../util/UserPreviewLink' 36 + import {niceDate} from 'lib/strings/time' 37 + import {colors, s} from 'lib/styles' 38 + import {isWeb} from 'platform/detection' 39 + import {Link as NewLink} from '#/components/Link' 40 + import {ProfileHoverCard} from '#/components/ProfileHoverCard' 41 + import {FeedSourceCard} from '../feeds/FeedSourceCard' 42 + import {Post} from '../post/Post' 35 43 import {ImageHorzList} from '../util/images/ImageHorzList' 36 - import {Post} from '../post/Post' 37 44 import {Link, TextLink} from '../util/Link' 38 - import {usePalette} from 'lib/hooks/usePalette' 39 - import {useAnimatedValue} from 'lib/hooks/useAnimatedValue' 40 45 import {formatCount} from '../util/numeric/format' 41 - import {makeProfileLink} from 'lib/routes/links' 46 + import {Text} from '../util/text/Text' 42 47 import {TimeElapsed} from '../util/TimeElapsed' 43 - import {isWeb} from 'platform/detection' 44 - import {Trans, msg} from '@lingui/macro' 45 - import {useLingui} from '@lingui/react' 46 - import {FeedSourceCard} from '../feeds/FeedSourceCard' 48 + import {PreviewableUserAvatar, UserAvatar} from '../util/UserAvatar' 47 49 48 50 const MAX_AUTHORS = 5 49 51 ··· 356 358 <View style={styles.avis}> 357 359 {authors.slice(0, MAX_AUTHORS).map(author => ( 358 360 <View key={author.href} style={s.mr5}> 359 - <UserAvatar 361 + <PreviewableUserAvatar 360 362 size={35} 363 + did={author.did} 364 + handle={author.handle} 361 365 avatar={author.avatar} 362 366 moderation={author.moderation.ui('avatar')} 363 367 type={author.associated?.labeler ? 'labeler' : 'user'} ··· 386 390 visible: boolean 387 391 authors: Author[] 388 392 }) { 393 + const {_} = useLingui() 389 394 const pal = usePalette('default') 390 395 const heightInterp = useAnimatedValue(visible ? 1 : 0) 391 396 const targetHeight = ··· 409 414 visible ? s.mb10 : undefined, 410 415 ]}> 411 416 {authors.map(author => ( 412 - <UserPreviewLink 417 + <NewLink 413 418 key={author.did} 414 - did={author.did} 415 - handle={author.handle} 416 - style={styles.expandedAuthor}> 417 - <View style={styles.expandedAuthorAvi}> 418 - <UserAvatar 419 - size={35} 420 - avatar={author.avatar} 421 - moderation={author.moderation.ui('avatar')} 422 - type={author.associated?.labeler ? 'labeler' : 'user'} 423 - /> 424 - </View> 425 - <View style={s.flex1}> 426 - <Text 427 - type="lg-bold" 428 - numberOfLines={1} 429 - style={pal.text} 430 - lineHeight={1.2}> 431 - {sanitizeDisplayName(author.displayName || author.handle)} 432 - &nbsp; 433 - <Text style={[pal.textLight]} lineHeight={1.2}> 434 - {sanitizeHandle(author.handle)} 419 + label={_(msg`See profile`)} 420 + to={makeProfileLink({ 421 + did: author.did, 422 + handle: author.handle, 423 + })}> 424 + <View style={styles.expandedAuthor}> 425 + <View style={styles.expandedAuthorAvi}> 426 + <ProfileHoverCard did={author.did}> 427 + <UserAvatar 428 + size={35} 429 + avatar={author.avatar} 430 + moderation={author.moderation.ui('avatar')} 431 + type={author.associated?.labeler ? 'labeler' : 'user'} 432 + /> 433 + </ProfileHoverCard> 434 + </View> 435 + <View style={s.flex1}> 436 + <Text 437 + type="lg-bold" 438 + numberOfLines={1} 439 + style={pal.text} 440 + lineHeight={1.2}> 441 + {sanitizeDisplayName(author.displayName || author.handle)} 442 + &nbsp; 443 + <Text style={[pal.textLight]} lineHeight={1.2}> 444 + {sanitizeHandle(author.handle)} 445 + </Text> 435 446 </Text> 436 - </Text> 447 + </View> 437 448 </View> 438 - </UserPreviewLink> 449 + </NewLink> 439 450 ))} 440 451 </Animated.View> 441 452 )
+21 -16
src/view/com/profile/ProfileCard.tsx
··· 6 6 ModerationCause, 7 7 ModerationDecision, 8 8 } from '@atproto/api' 9 + import {Trans} from '@lingui/macro' 10 + 11 + import {useModerationCauseDescription} from '#/lib/moderation/useModerationCauseDescription' 12 + import {useProfileShadow} from '#/state/cache/profile-shadow' 13 + import {Shadow} from '#/state/cache/types' 14 + import {useModerationOpts} from '#/state/queries/preferences' 15 + import {useSession} from '#/state/session' 16 + import {usePalette} from 'lib/hooks/usePalette' 17 + import {getModerationCauseKey, isJustAMute} from 'lib/moderation' 18 + import {makeProfileLink} from 'lib/routes/links' 19 + import {sanitizeDisplayName} from 'lib/strings/display-names' 20 + import {sanitizeHandle} from 'lib/strings/handles' 21 + import {s} from 'lib/styles' 9 22 import {Link} from '../util/Link' 10 23 import {Text} from '../util/text/Text' 11 - import {UserAvatar} from '../util/UserAvatar' 12 - import {s} from 'lib/styles' 13 - import {usePalette} from 'lib/hooks/usePalette' 24 + import {PreviewableUserAvatar} from '../util/UserAvatar' 14 25 import {FollowButton} from './FollowButton' 15 - import {sanitizeDisplayName} from 'lib/strings/display-names' 16 - import {sanitizeHandle} from 'lib/strings/handles' 17 - import {makeProfileLink} from 'lib/routes/links' 18 - import {getModerationCauseKey, isJustAMute} from 'lib/moderation' 19 - import {Shadow} from '#/state/cache/types' 20 - import {useModerationOpts} from '#/state/queries/preferences' 21 - import {useProfileShadow} from '#/state/cache/profile-shadow' 22 - import {useSession} from '#/state/session' 23 - import {Trans} from '@lingui/macro' 24 - import {useModerationCauseDescription} from '#/lib/moderation/useModerationCauseDescription' 25 26 26 27 export function ProfileCard({ 27 28 testID, ··· 76 77 anchorNoUnderline> 77 78 <View style={styles.layout}> 78 79 <View style={styles.layoutAvi}> 79 - <UserAvatar 80 + <PreviewableUserAvatar 80 81 size={40} 82 + did={profile.did} 83 + handle={profile.handle} 81 84 avatar={profile.avatar} 82 85 moderation={moderation.ui('avatar')} 83 86 type={isLabeler ? 'labeler' : 'user'} ··· 221 224 {followersWithMods.slice(0, 3).map(({f, mod}) => ( 222 225 <View key={f.did} style={styles.followedByAviContainer}> 223 226 <View style={[styles.followedByAvi, pal.view]}> 224 - <UserAvatar 225 - avatar={f.avatar} 227 + <PreviewableUserAvatar 226 228 size={32} 229 + did={f.did} 230 + handle={f.handle} 231 + avatar={f.avatar} 227 232 moderation={mod.ui('avatar')} 228 233 type={f.associated?.labeler ? 'labeler' : 'user'} 229 234 />
+17 -15
src/view/com/profile/ProfileHeaderSuggestedFollows.tsx
··· 1 1 import React from 'react' 2 - import {View, StyleSheet, Pressable, ScrollView} from 'react-native' 2 + import {Pressable, ScrollView, StyleSheet, View} from 'react-native' 3 3 import {AppBskyActorDefs, moderateProfile} from '@atproto/api' 4 4 import { 5 5 FontAwesomeIcon, 6 6 FontAwesomeIconStyle, 7 7 } from '@fortawesome/react-native-fontawesome' 8 + import {msg, Trans} from '@lingui/macro' 9 + import {useLingui} from '@lingui/react' 8 10 9 - import * as Toast from '../util/Toast' 11 + import {useProfileShadow} from '#/state/cache/profile-shadow' 12 + import {useModerationOpts} from '#/state/queries/preferences' 13 + import {useProfileFollowMutationQueue} from '#/state/queries/profile' 14 + import {useSuggestedFollowsByActorQuery} from '#/state/queries/suggested-follows' 15 + import {useAnalytics} from 'lib/analytics/analytics' 10 16 import {usePalette} from 'lib/hooks/usePalette' 11 - import {Text} from 'view/com/util/text/Text' 12 - import {UserAvatar} from 'view/com/util/UserAvatar' 13 - import {Button} from 'view/com/util/forms/Button' 17 + import {makeProfileLink} from 'lib/routes/links' 14 18 import {sanitizeDisplayName} from 'lib/strings/display-names' 15 19 import {sanitizeHandle} from 'lib/strings/handles' 16 - import {makeProfileLink} from 'lib/routes/links' 17 - import {Link} from 'view/com/util/Link' 18 - import {useAnalytics} from 'lib/analytics/analytics' 19 20 import {isWeb} from 'platform/detection' 20 - import {useModerationOpts} from '#/state/queries/preferences' 21 - import {useSuggestedFollowsByActorQuery} from '#/state/queries/suggested-follows' 22 - import {useProfileShadow} from '#/state/cache/profile-shadow' 23 - import {useProfileFollowMutationQueue} from '#/state/queries/profile' 24 - import {useLingui} from '@lingui/react' 25 - import {Trans, msg} from '@lingui/macro' 21 + import {Button} from 'view/com/util/forms/Button' 22 + import {Link} from 'view/com/util/Link' 23 + import {Text} from 'view/com/util/text/Text' 24 + import {PreviewableUserAvatar} from 'view/com/util/UserAvatar' 25 + import * as Toast from '../util/Toast' 26 26 27 27 const OUTER_PADDING = 10 28 28 const INNER_PADDING = 14 ··· 218 218 backgroundColor: pal.view.backgroundColor, 219 219 }, 220 220 ]}> 221 - <UserAvatar 221 + <PreviewableUserAvatar 222 222 size={60} 223 + did={profile.did} 224 + handle={profile.handle} 223 225 avatar={profile.avatar} 224 226 moderation={moderation.ui('avatar')} 225 227 />
+13 -10
src/view/com/util/PostMeta.tsx
··· 1 1 import React, {memo} from 'react' 2 2 import {StyleProp, StyleSheet, TextStyle, View, ViewStyle} from 'react-native' 3 - import {Text} from './text/Text' 4 - import {TextLinkOnWebOnly} from './Link' 5 - import {niceDate} from 'lib/strings/time' 3 + import {AppBskyActorDefs, ModerationDecision, ModerationUI} from '@atproto/api' 4 + 5 + import {usePrefetchProfileQuery} from '#/state/queries/profile' 6 6 import {usePalette} from 'lib/hooks/usePalette' 7 - import {TypographyVariant} from 'lib/ThemeContext' 8 - import {UserAvatar} from './UserAvatar' 7 + import {makeProfileLink} from 'lib/routes/links' 9 8 import {sanitizeDisplayName} from 'lib/strings/display-names' 10 9 import {sanitizeHandle} from 'lib/strings/handles' 10 + import {niceDate} from 'lib/strings/time' 11 + import {TypographyVariant} from 'lib/ThemeContext' 11 12 import {isAndroid, isWeb} from 'platform/detection' 13 + import {TextLinkOnWebOnly} from './Link' 14 + import {Text} from './text/Text' 12 15 import {TimeElapsed} from './TimeElapsed' 13 - import {makeProfileLink} from 'lib/routes/links' 14 - import {AppBskyActorDefs, ModerationDecision, ModerationUI} from '@atproto/api' 15 - import {usePrefetchProfileQuery} from '#/state/queries/profile' 16 + import {PreviewableUserAvatar} from './UserAvatar' 16 17 17 18 interface PostMetaOpts { 18 19 author: AppBskyActorDefs.ProfileViewBasic ··· 38 39 <View style={[styles.container, opts.style]}> 39 40 {opts.showAvatar && ( 40 41 <View style={styles.avatar}> 41 - <UserAvatar 42 - avatar={opts.author.avatar} 42 + <PreviewableUserAvatar 43 43 size={opts.avatarSize || 16} 44 + did={opts.author.did} 45 + handle={opts.author.handle} 46 + avatar={opts.author.avatar} 44 47 moderation={opts.avatarModeration} 45 48 type={opts.author.associated?.labeler ? 'labeler' : 'user'} 46 49 />
+26 -16
src/view/com/util/UserAvatar.tsx
··· 1 1 import React, {memo, useMemo} from 'react' 2 2 import {Image, StyleSheet, TouchableOpacity, View} from 'react-native' 3 - import Svg, {Circle, Rect, Path} from 'react-native-svg' 4 3 import {Image as RNImage} from 'react-native-image-crop-picker' 5 - import {useLingui} from '@lingui/react' 4 + import Svg, {Circle, Path, Rect} from 'react-native-svg' 5 + import {ModerationUI} from '@atproto/api' 6 + import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' 6 7 import {msg, Trans} from '@lingui/macro' 7 - import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' 8 - import {ModerationUI} from '@atproto/api' 8 + import {useLingui} from '@lingui/react' 9 9 10 - import {HighPriorityImage} from 'view/com/util/images/Image' 11 - import {openCamera, openCropper, openPicker} from '../../../lib/media/picker' 10 + import {usePalette} from 'lib/hooks/usePalette' 12 11 import { 13 - usePhotoLibraryPermission, 14 12 useCameraPermission, 13 + usePhotoLibraryPermission, 15 14 } from 'lib/hooks/usePermissions' 15 + import {makeProfileLink} from 'lib/routes/links' 16 16 import {colors} from 'lib/styles' 17 - import {usePalette} from 'lib/hooks/usePalette' 18 - import {isWeb, isAndroid, isNative} from 'platform/detection' 19 - import {UserPreviewLink} from './UserPreviewLink' 20 - import * as Menu from '#/components/Menu' 17 + import {isAndroid, isNative, isWeb} from 'platform/detection' 18 + import {HighPriorityImage} from 'view/com/util/images/Image' 19 + import {tokens, useTheme} from '#/alf' 21 20 import { 22 - Camera_Stroke2_Corner0_Rounded as Camera, 23 21 Camera_Filled_Stroke2_Corner0_Rounded as CameraFilled, 22 + Camera_Stroke2_Corner0_Rounded as Camera, 24 23 } from '#/components/icons/Camera' 25 24 import {StreamingLive_Stroke2_Corner0_Rounded as Library} from '#/components/icons/StreamingLive' 26 25 import {Trash_Stroke2_Corner0_Rounded as Trash} from '#/components/icons/Trash' 27 - import {useTheme, tokens} from '#/alf' 26 + import {Link} from '#/components/Link' 27 + import * as Menu from '#/components/Menu' 28 + import {ProfileHoverCard} from '#/components/ProfileHoverCard' 29 + import {openCamera, openCropper, openPicker} from '../../../lib/media/picker' 28 30 29 31 export type UserAvatarType = 'user' | 'algo' | 'list' | 'labeler' 30 32 ··· 372 374 let PreviewableUserAvatar = ( 373 375 props: PreviewableUserAvatarProps, 374 376 ): React.ReactNode => { 377 + const {_} = useLingui() 375 378 return ( 376 - <UserPreviewLink did={props.did} handle={props.handle}> 377 - <UserAvatar {...props} /> 378 - </UserPreviewLink> 379 + <ProfileHoverCard did={props.did}> 380 + <Link 381 + label={_(msg`See profile`)} 382 + to={makeProfileLink({ 383 + did: props.did, 384 + handle: props.handle, 385 + })}> 386 + <UserAvatar {...props} /> 387 + </Link> 388 + </ProfileHoverCard> 379 389 ) 380 390 } 381 391 PreviewableUserAvatar = memo(PreviewableUserAvatar)
-31
src/view/com/util/UserPreviewLink.tsx
··· 1 - import React from 'react' 2 - import {StyleProp, ViewStyle} from 'react-native' 3 - import {Link} from './Link' 4 - import {isWeb} from 'platform/detection' 5 - import {makeProfileLink} from 'lib/routes/links' 6 - import {usePrefetchProfileQuery} from '#/state/queries/profile' 7 - 8 - interface UserPreviewLinkProps { 9 - did: string 10 - handle: string 11 - style?: StyleProp<ViewStyle> 12 - } 13 - export function UserPreviewLink( 14 - props: React.PropsWithChildren<UserPreviewLinkProps>, 15 - ) { 16 - const prefetchProfileQuery = usePrefetchProfileQuery() 17 - return ( 18 - <Link 19 - onPointerEnter={() => { 20 - if (isWeb) { 21 - prefetchProfileQuery(props.did) 22 - } 23 - }} 24 - href={makeProfileLink(props)} 25 - title={props.handle} 26 - asAnchor 27 - style={props.style}> 28 - {props.children} 29 - </Link> 30 - ) 31 - }
+27
yarn.lock
··· 3511 3511 resolved "https://registry.yarnpkg.com/@flatten-js/interval-tree/-/interval-tree-1.1.2.tgz#fcc891da48bc230392884be01c26fe8c625702e8" 3512 3512 integrity sha512-OwLoV9E/XM6b7bes2rSFnGNjyRy7vcoIHFTnmBR2WAaZTf0Fe4EX4GdA65vU1KgFAasti7iRSg2dZfYd1Zt00Q== 3513 3513 3514 + "@floating-ui/core@^1.0.0": 3515 + version "1.6.0" 3516 + resolved "https://registry.yarnpkg.com/@floating-ui/core/-/core-1.6.0.tgz#fa41b87812a16bf123122bf945946bae3fdf7fc1" 3517 + integrity sha512-PcF++MykgmTj3CIyOQbKA/hDzOAiqI3mhuoN44WRCopIs1sgoDoU4oty4Jtqaj/y3oDU6fnVSm4QG0a3t5i0+g== 3518 + dependencies: 3519 + "@floating-ui/utils" "^0.2.1" 3520 + 3514 3521 "@floating-ui/core@^1.4.1": 3515 3522 version "1.4.1" 3516 3523 resolved "https://registry.yarnpkg.com/@floating-ui/core/-/core-1.4.1.tgz#0d633f4b76052668afb932492ac452f7ebe97f17" ··· 3526 3533 "@floating-ui/core" "^1.4.1" 3527 3534 "@floating-ui/utils" "^0.1.1" 3528 3535 3536 + "@floating-ui/dom@^1.6.1", "@floating-ui/dom@^1.6.3": 3537 + version "1.6.3" 3538 + resolved "https://registry.yarnpkg.com/@floating-ui/dom/-/dom-1.6.3.tgz#954e46c1dd3ad48e49db9ada7218b0985cee75ef" 3539 + integrity sha512-RnDthu3mzPlQ31Ss/BTwQ1zjzIhr3lk1gZB1OC56h/1vEtaXkESrOqL5fQVMfXpwGtRwX+YsZBdyHtJMQnkArw== 3540 + dependencies: 3541 + "@floating-ui/core" "^1.0.0" 3542 + "@floating-ui/utils" "^0.2.0" 3543 + 3529 3544 "@floating-ui/react-dom@^2.0.0": 3530 3545 version "2.0.1" 3531 3546 resolved "https://registry.yarnpkg.com/@floating-ui/react-dom/-/react-dom-2.0.1.tgz#7972a4fc488a8c746cded3cfe603b6057c308a91" ··· 3533 3548 dependencies: 3534 3549 "@floating-ui/dom" "^1.3.0" 3535 3550 3551 + "@floating-ui/react-dom@^2.0.8": 3552 + version "2.0.8" 3553 + resolved "https://registry.yarnpkg.com/@floating-ui/react-dom/-/react-dom-2.0.8.tgz#afc24f9756d1b433e1fe0d047c24bd4d9cefaa5d" 3554 + integrity sha512-HOdqOt3R3OGeTKidaLvJKcgg75S6tibQ3Tif4eyd91QnIJWr0NLvoXFpJA/j8HqkFSL68GDca9AuyWEHlhyClw== 3555 + dependencies: 3556 + "@floating-ui/dom" "^1.6.1" 3557 + 3536 3558 "@floating-ui/utils@^0.1.1": 3537 3559 version "0.1.1" 3538 3560 resolved "https://registry.yarnpkg.com/@floating-ui/utils/-/utils-0.1.1.tgz#1a5b1959a528e374e8037c4396c3e825d6cf4a83" 3539 3561 integrity sha512-m0G6wlnhm/AX0H12IOWtK8gASEMffnX08RtKkCgTdHb9JpHKGloI7icFfLg9ZmQeavcvR0PKmzxClyuFPSjKWw== 3562 + 3563 + "@floating-ui/utils@^0.2.0", "@floating-ui/utils@^0.2.1": 3564 + version "0.2.1" 3565 + resolved "https://registry.yarnpkg.com/@floating-ui/utils/-/utils-0.2.1.tgz#16308cea045f0fc777b6ff20a9f25474dd8293d2" 3566 + integrity sha512-9TANp6GPoMtYzQdt54kfAyMmz1+osLlXdg2ENroU7zzrtflTLrrC/lgrIfaSe+Wu0b89GKccT7vxXA0MoAIO+Q== 3540 3567 3541 3568 "@fortawesome/fontawesome-common-types@6.4.2": 3542 3569 version "6.4.2"