diff options
Diffstat (limited to 'src/view/com')
-rw-r--r-- | src/view/com/post-thread/PostThreadItem.tsx | 5 | ||||
-rw-r--r-- | src/view/com/post/Post.tsx | 15 | ||||
-rw-r--r-- | src/view/com/posts/AviFollowButton.tsx | 143 | ||||
-rw-r--r-- | src/view/com/posts/AviFollowButton.web.tsx | 5 | ||||
-rw-r--r-- | src/view/com/posts/PostFeed.tsx | 32 | ||||
-rw-r--r-- | src/view/com/posts/PostFeedItem.tsx | 23 | ||||
-rw-r--r-- | src/view/com/profile/ProfileMenu.tsx | 38 | ||||
-rw-r--r-- | src/view/com/util/PostMeta.tsx | 4 | ||||
-rw-r--r-- | src/view/com/util/UserAvatar.tsx | 88 |
9 files changed, 169 insertions, 184 deletions
diff --git a/src/view/com/post-thread/PostThreadItem.tsx b/src/view/com/post-thread/PostThreadItem.tsx index 10c3e6b4d..3925ce9bd 100644 --- a/src/view/com/post-thread/PostThreadItem.tsx +++ b/src/view/com/post-thread/PostThreadItem.tsx @@ -16,6 +16,7 @@ import { import {msg, Plural, Trans} from '@lingui/macro' import {useLingui} from '@lingui/react' +import {useActorStatus} from '#/lib/actor-status' import {MAX_POST_LINES} from '#/lib/constants' import {useOpenComposer} from '#/lib/hooks/useOpenComposer' import {useOpenLink} from '#/lib/hooks/useOpenLink' @@ -287,6 +288,8 @@ let PostThreadItemLoaded = ({ setLimitLines(false) }, [setLimitLines]) + const {isActive: live} = useActorStatus(post.author) + if (!record) { return <ErrorMessage message={_(msg`Invalid or unsupported post record`)} /> } @@ -330,6 +333,7 @@ let PostThreadItemLoaded = ({ profile={post.author} moderation={moderation.ui('avatar')} type={post.author.associated?.labeler ? 'labeler' : 'user'} + live={live} /> <View style={[a.flex_1]}> <View style={[a.flex_row, a.align_center]}> @@ -575,6 +579,7 @@ let PostThreadItemLoaded = ({ profile={post.author} moderation={moderation.ui('avatar')} type={post.author.associated?.labeler ? 'labeler' : 'user'} + live={live} /> {showChildReplyLine && ( diff --git a/src/view/com/post/Post.tsx b/src/view/com/post/Post.tsx index c6cf254f3..03463f977 100644 --- a/src/view/com/post/Post.tsx +++ b/src/view/com/post/Post.tsx @@ -27,7 +27,6 @@ import { import {useModerationOpts} from '#/state/preferences/moderation-opts' import {precacheProfile} from '#/state/queries/profile' import {useSession} from '#/state/session' -import {AviFollowButton} from '#/view/com/posts/AviFollowButton' import {atoms as a} from '#/alf' import {ProfileHoverCard} from '#/components/ProfileHoverCard' import {RichText} from '#/components/RichText' @@ -174,14 +173,12 @@ function PostInner({ {showReplyLine && <View style={styles.replyLine} />} <View style={styles.layout}> <View style={styles.layoutAvi}> - <AviFollowButton author={post.author} moderation={moderation}> - <PreviewableUserAvatar - size={42} - profile={post.author} - moderation={moderation.ui('avatar')} - type={post.author.associated?.labeler ? 'labeler' : 'user'} - /> - </AviFollowButton> + <PreviewableUserAvatar + size={42} + profile={post.author} + moderation={moderation.ui('avatar')} + type={post.author.associated?.labeler ? 'labeler' : 'user'} + /> </View> <View style={styles.layoutContent}> <PostMeta diff --git a/src/view/com/posts/AviFollowButton.tsx b/src/view/com/posts/AviFollowButton.tsx deleted file mode 100644 index 1c894bffe..000000000 --- a/src/view/com/posts/AviFollowButton.tsx +++ /dev/null @@ -1,143 +0,0 @@ -import React from 'react' -import {View} from 'react-native' -import {AppBskyActorDefs, ModerationDecision} from '@atproto/api' -import {msg} from '@lingui/macro' -import {useLingui} from '@lingui/react' -import {useNavigation} from '@react-navigation/native' - -import {NavigationProp} from '#/lib/routes/types' -import {sanitizeDisplayName} from '#/lib/strings/display-names' -import {useProfileShadow} from '#/state/cache/profile-shadow' -import {useSession} from '#/state/session' -import { - DropdownItem, - NativeDropdown, -} from '#/view/com/util/forms/NativeDropdown' -import * as Toast from '#/view/com/util/Toast' -import {atoms as a, select, useTheme} from '#/alf' -import {Button} from '#/components/Button' -import {useFollowMethods} from '#/components/hooks/useFollowMethods' -import {PlusSmall_Stroke2_Corner0_Rounded as Plus} from '#/components/icons/Plus' - -export function AviFollowButton({ - author, - moderation, - children, -}: { - author: AppBskyActorDefs.ProfileViewBasic - moderation: ModerationDecision - children: React.ReactNode -}) { - const {_} = useLingui() - const t = useTheme() - const profile = useProfileShadow(author) - const {follow} = useFollowMethods({ - profile: profile, - logContext: 'AvatarButton', - }) - const {currentAccount, hasSession} = useSession() - const navigation = useNavigation<NavigationProp>() - - const name = sanitizeDisplayName( - profile.displayName || profile.handle, - moderation.ui('displayName'), - ) - const isFollowing = - profile.viewer?.following || profile.did === currentAccount?.did - - function onPress() { - follow() - Toast.show(_(msg`Following ${name}`)) - } - - const items: DropdownItem[] = [ - { - label: _(msg`View profile`), - onPress: () => { - navigation.navigate('Profile', {name: profile.did}) - }, - icon: { - ios: { - name: 'arrow.up.right.square', - }, - android: '', - web: ['far', 'arrow-up-right-from-square'], - }, - }, - { - label: _(msg`Follow ${name}`), - onPress: onPress, - icon: { - ios: { - name: 'person.badge.plus', - }, - android: '', - web: ['far', 'user-plus'], - }, - }, - ] - - return hasSession ? ( - <View style={a.relative}> - {children} - - {!isFollowing && ( - <Button - label={_(msg`Open ${name} profile shortcut menu`)} - style={[ - a.rounded_full, - a.absolute, - { - bottom: -7, - right: -7, - }, - ]}> - <NativeDropdown items={items}> - <View - style={[ - { - // An asymmetric hit slop - // to prioritize bottom right taps. - paddingTop: 2, - paddingLeft: 2, - paddingBottom: 6, - paddingRight: 6, - }, - a.align_center, - a.justify_center, - a.rounded_full, - ]}> - <View - style={[ - a.rounded_full, - a.align_center, - select(t.name, { - light: t.atoms.bg_contrast_100, - dim: t.atoms.bg_contrast_100, - dark: t.atoms.bg_contrast_200, - }), - { - borderWidth: 1, - borderColor: t.atoms.bg.backgroundColor, - }, - ]}> - <Plus - size="sm" - fill={ - select(t.name, { - light: t.atoms.bg_contrast_600, - dim: t.atoms.bg_contrast_500, - dark: t.atoms.bg_contrast_600, - }).backgroundColor - } - /> - </View> - </View> - </NativeDropdown> - </Button> - )} - </View> - ) : ( - children - ) -} diff --git a/src/view/com/posts/AviFollowButton.web.tsx b/src/view/com/posts/AviFollowButton.web.tsx deleted file mode 100644 index 90b2ddeec..000000000 --- a/src/view/com/posts/AviFollowButton.web.tsx +++ /dev/null @@ -1,5 +0,0 @@ -import React from 'react' - -export function AviFollowButton({children}: {children: React.ReactNode}) { - return children -} diff --git a/src/view/com/posts/PostFeed.tsx b/src/view/com/posts/PostFeed.tsx index 181b35026..b4c2b2710 100644 --- a/src/view/com/posts/PostFeed.tsx +++ b/src/view/com/posts/PostFeed.tsx @@ -1,4 +1,4 @@ -import React, {memo, useCallback} from 'react' +import React, {memo, useCallback, useRef} from 'react' import { ActivityIndicator, AppState, @@ -19,6 +19,7 @@ import {msg} from '@lingui/macro' import {useLingui} from '@lingui/react' import {useQueryClient} from '@tanstack/react-query' +import {isStatusStillActive} from '#/lib/actor-status' import {DISCOVER_FEED_URI, KNOWN_SHUTDOWN_FEEDS} from '#/lib/constants' import {useInitialNumToRender} from '#/lib/hooks/useInitialNumToRender' import {logEvent} from '#/lib/statsig/statsig' @@ -52,6 +53,7 @@ import { } from '#/components/feeds/PostFeedVideoGridRow' import {TrendingInterstitial} from '#/components/interstitials/Trending' import {TrendingVideos as TrendingVideosInterstitial} from '#/components/interstitials/TrendingVideos' +import {temp__canBeLive, temp__isStatusValid} from '#/components/live/temp' import {DiscoverFallbackHeader} from './DiscoverFallbackHeader' import {FeedShutdownMsg} from './FeedShutdownMsg' import {PostFeedErrorMessage} from './PostFeedErrorMessage' @@ -775,6 +777,31 @@ let PostFeed = ({ ) }, [isFetchingNextPage, shouldRenderEndOfFeed, renderEndOfFeed, headerOffset]) + const seenActorWithStatusRef = useRef<Set<string>>(new Set()) + const onItemSeen = useCallback( + (item: FeedRow) => { + feedFeedback.onItemSeen(item) + if (item.type === 'sliceItem') { + const actor = item.slice.items[item.indexInSlice].post.author + if ( + actor.status && + temp__canBeLive(actor) && + temp__isStatusValid(actor.status) && + isStatusStillActive(actor.status.expiresAt) + ) { + if (!seenActorWithStatusRef.current.has(actor.did)) { + seenActorWithStatusRef.current.add(actor.did) + logger.metric('live:view:post', { + subject: actor.did, + feed, + }) + } + } + } + }, + [feedFeedback, feed], + ) + return ( <View testID={testID} style={style}> <List @@ -797,7 +824,6 @@ let PostFeed = ({ onEndReachedThreshold={2} // number of posts left to trigger load more removeClippedSubviews={true} extraData={extraData} - // @ts-ignore our .web version only -prf desktopFixedHeight={ desktopFixedHeightOffset ? desktopFixedHeightOffset : true } @@ -805,7 +831,7 @@ let PostFeed = ({ windowSize={9} maxToRenderPerBatch={isIOS ? 5 : 1} updateCellsBatchingPeriod={40} - onItemSeen={feedFeedback.onItemSeen} + onItemSeen={onItemSeen} /> </View> ) diff --git a/src/view/com/posts/PostFeedItem.tsx b/src/view/com/posts/PostFeedItem.tsx index 123a8b0c2..ceb653b9c 100644 --- a/src/view/com/posts/PostFeedItem.tsx +++ b/src/view/com/posts/PostFeedItem.tsx @@ -17,6 +17,7 @@ import {msg, Trans} from '@lingui/macro' import {useLingui} from '@lingui/react' import {useQueryClient} from '@tanstack/react-query' +import {useActorStatus} from '#/lib/actor-status' import {isReasonFeedSource, type ReasonFeedSource} from '#/lib/api/feed/types' import {MAX_POST_LINES} from '#/lib/constants' import {useOpenComposer} from '#/lib/hooks/useOpenComposer' @@ -53,7 +54,6 @@ import {RichText} from '#/components/RichText' import {SubtleWebHover} from '#/components/SubtleWebHover' import * as bsky from '#/types/bsky' import {Link, TextLink, TextLinkOnWebOnly} from '../util/Link' -import {AviFollowButton} from './AviFollowButton' interface FeedItemProps { record: AppBskyFeedPost.Record @@ -251,6 +251,8 @@ let FeedItemInner = ({ ? rootPost.threadgate.record : undefined + const {isActive: live} = useActorStatus(post.author) + return ( <Link testID={`feedItem-by-${post.author.handle}`} @@ -381,15 +383,14 @@ let FeedItemInner = ({ <View style={styles.layout}> <View style={styles.layoutAvi}> - <AviFollowButton author={post.author} moderation={moderation}> - <PreviewableUserAvatar - size={42} - profile={post.author} - moderation={moderation.ui('avatar')} - type={post.author.associated?.labeler ? 'labeler' : 'user'} - onBeforePress={onOpenAuthor} - /> - </AviFollowButton> + <PreviewableUserAvatar + size={42} + profile={post.author} + moderation={moderation.ui('avatar')} + type={post.author.associated?.labeler ? 'labeler' : 'user'} + onBeforePress={onOpenAuthor} + live={live} + /> {isThreadParent && ( <View style={[ @@ -397,7 +398,7 @@ let FeedItemInner = ({ { flexGrow: 1, backgroundColor: pal.colors.replyLine, - marginTop: 4, + marginTop: live ? 8 : 4, }, ]} /> diff --git a/src/view/com/profile/ProfileMenu.tsx b/src/view/com/profile/ProfileMenu.tsx index 43ec44834..1c2a7d62d 100644 --- a/src/view/com/profile/ProfileMenu.tsx +++ b/src/view/com/profile/ProfileMenu.tsx @@ -5,6 +5,7 @@ import {useLingui} from '@lingui/react' import {useNavigation} from '@react-navigation/native' import {useQueryClient} from '@tanstack/react-query' +import {useActorStatus} from '#/lib/actor-status' import {HITSLOP_20} from '#/lib/constants' import {makeProfileLink} from '#/lib/routes/links' import {type NavigationProp} from '#/lib/routes/types' @@ -23,12 +24,14 @@ import {useSession} from '#/state/session' import {EventStopper} from '#/view/com/util/EventStopper' import * as Toast from '#/view/com/util/Toast' import {Button, ButtonIcon} from '#/components/Button' +import {useDialogControl} from '#/components/Dialog' import {ArrowOutOfBox_Stroke2_Corner0_Rounded as Share} from '#/components/icons/ArrowOutOfBox' import {CircleCheck_Stroke2_Corner0_Rounded as CircleCheck} from '#/components/icons/CircleCheck' import {CircleX_Stroke2_Corner0_Rounded as CircleX} from '#/components/icons/CircleX' import {DotGrid_Stroke2_Corner0_Rounded as Ellipsis} from '#/components/icons/DotGrid' import {Flag_Stroke2_Corner0_Rounded as Flag} from '#/components/icons/Flag' import {ListSparkle_Stroke2_Corner0_Rounded as List} from '#/components/icons/ListSparkle' +import {Live_Stroke2_Corner0_Rounded as LiveIcon} from '#/components/icons/Live' import {MagnifyingGlass2_Stroke2_Corner0_Rounded as SearchIcon} from '#/components/icons/MagnifyingGlass2' import {Mute_Stroke2_Corner0_Rounded as Mute} from '#/components/icons/Mute' import {PeopleRemove2_Stroke2_Corner0_Rounded as UserMinus} from '#/components/icons/PeopleRemove2' @@ -38,6 +41,9 @@ import { } from '#/components/icons/Person' import {PlusLarge_Stroke2_Corner0_Rounded as Plus} from '#/components/icons/Plus' import {SpeakerVolumeFull_Stroke2_Corner0_Rounded as Unmute} from '#/components/icons/Speaker' +import {EditLiveDialog} from '#/components/live/EditLiveDialog' +import {GoLiveDialog} from '#/components/live/GoLiveDialog' +import {temp__canGoLive} from '#/components/live/temp' import * as Menu from '#/components/Menu' import { ReportDialog, @@ -77,6 +83,7 @@ let ProfileMenu = ({ const blockPromptControl = Prompt.usePromptControl() const loggedOutWarningPromptControl = Prompt.usePromptControl() + const goLiveDialogControl = useDialogControl() const showLoggedOutWarning = React.useMemo(() => { return ( @@ -201,6 +208,8 @@ let ProfileMenu = ({ return v.issuer === currentAccount?.did }) ?? [] + const status = useActorStatus(profile) + return ( <EventStopper onKeyDown={false}> <Menu.Root> @@ -290,6 +299,25 @@ let ProfileMenu = ({ </Menu.ItemText> <Menu.ItemIcon icon={List} /> </Menu.Item> + {isSelf && temp__canGoLive(profile) && ( + <Menu.Item + testID="profileHeaderDropdownListAddRemoveBtn" + label={ + status.isActive + ? _(msg`Edit live status`) + : _(msg`Go live`) + } + onPress={goLiveDialogControl.open}> + <Menu.ItemText> + {status.isActive ? ( + <Trans>Edit live status</Trans> + ) : ( + <Trans>Go live</Trans> + )} + </Menu.ItemText> + <Menu.ItemIcon icon={LiveIcon} /> + </Menu.Item> + )} {verification.viewer.role === 'verifier' && !verification.profile.isViewer && (verification.viewer.hasIssuedVerification ? ( @@ -456,6 +484,16 @@ let ProfileMenu = ({ profile={profile} verifications={currentAccountVerifications} /> + + {status.isActive ? ( + <EditLiveDialog + control={goLiveDialogControl} + status={status} + embed={status.embed} + /> + ) : ( + <GoLiveDialog control={goLiveDialogControl} profile={profile} /> + )} </EventStopper> ) } diff --git a/src/view/com/util/PostMeta.tsx b/src/view/com/util/PostMeta.tsx index fd8e3a38b..62ba32c9b 100644 --- a/src/view/com/util/PostMeta.tsx +++ b/src/view/com/util/PostMeta.tsx @@ -6,6 +6,7 @@ import {useLingui} from '@lingui/react' import {useQueryClient} from '@tanstack/react-query' import type React from 'react' +import {useActorStatus} from '#/lib/actor-status' import {makeProfileLink} from '#/lib/routes/links' import {forceLTR} from '#/lib/strings/bidi' import {NON_BREAKING_SPACE} from '#/lib/strings/constants' @@ -55,6 +56,7 @@ let PostMeta = (opts: PostMetaOpts): React.ReactNode => { const timestampLabel = niceDate(i18n, opts.timestamp) const verification = useSimpleVerificationState({profile: author}) + const {isActive: live} = useActorStatus(author) return ( <View @@ -74,6 +76,8 @@ let PostMeta = (opts: PostMetaOpts): React.ReactNode => { profile={author} moderation={opts.moderation?.ui('avatar')} type={author.associated?.labeler ? 'labeler' : 'user'} + live={live} + hideLiveBadge /> </View> )} diff --git a/src/view/com/util/UserAvatar.tsx b/src/view/com/util/UserAvatar.tsx index 326a2fff8..b3bf144f7 100644 --- a/src/view/com/util/UserAvatar.tsx +++ b/src/view/com/util/UserAvatar.tsx @@ -14,6 +14,9 @@ import {msg, Trans} from '@lingui/macro' import {useLingui} from '@lingui/react' import {useQueryClient} from '@tanstack/react-query' +import {useActorStatus} from '#/lib/actor-status' +import {isTouchDevice} from '#/lib/browser' +import {useHaptics} from '#/lib/haptics' import { useCameraPermission, usePhotoLibraryPermission, @@ -22,6 +25,8 @@ import {compressIfNeeded} from '#/lib/media/manip' import {openCamera, openCropper, openPicker} from '#/lib/media/picker' import {type PickerImage} from '#/lib/media/picker.shared' import {makeProfileLink} from '#/lib/routes/links' +import {sanitizeDisplayName} from '#/lib/strings/display-names' +import {sanitizeHandle} from '#/lib/strings/handles' import {logger} from '#/logger' import {isAndroid, isNative, isWeb} from '#/platform/detection' import { @@ -33,6 +38,7 @@ import {unstableCacheProfileView} from '#/state/queries/unstable-profile-cache' import {EditImageDialog} from '#/view/com/composer/photos/EditImageDialog' import {HighPriorityImage} from '#/view/com/util/images/Image' import {atoms as a, tokens, useTheme} from '#/alf' +import {Button} from '#/components/Button' import {useDialogControl} from '#/components/Dialog' import {useSheetWrapper} from '#/components/Dialog/sheet-wrapper' import { @@ -42,6 +48,8 @@ import { import {StreamingLive_Stroke2_Corner0_Rounded as LibraryIcon} from '#/components/icons/StreamingLive' import {Trash_Stroke2_Corner0_Rounded as TrashIcon} from '#/components/icons/Trash' import {Link} from '#/components/Link' +import {LiveIndicator} from '#/components/live/LiveIndicator' +import {LiveStatusDialog} from '#/components/live/LiveStatusDialog' import {MediaInsetBorder} from '#/components/MediaInsetBorder' import * as Menu from '#/components/Menu' import {ProfileHoverCard} from '#/components/ProfileHoverCard' @@ -54,6 +62,8 @@ interface BaseUserAvatarProps { shape?: 'circle' | 'square' size: number avatar?: string | null + live?: boolean + hideLiveBadge?: boolean } interface UserAvatarProps extends BaseUserAvatarProps { @@ -196,27 +206,38 @@ let UserAvatar = ({ usePlainRNImage = false, onLoad, style, + live, + hideLiveBadge, }: UserAvatarProps): React.ReactNode => { const t = useTheme() - const backgroundColor = t.palette.contrast_25 const finalShape = overrideShape ?? (type === 'user' ? 'circle' : 'square') const aviStyle = useMemo(() => { + let borderRadius if (finalShape === 'square') { - return { - width: size, - height: size, - borderRadius: size > 32 ? 8 : 3, - backgroundColor, - } + borderRadius = size > 32 ? 8 : 3 + } else { + borderRadius = Math.floor(size / 2) } + return { width: size, height: size, - borderRadius: Math.floor(size / 2), - backgroundColor, + borderRadius, + backgroundColor: t.palette.contrast_25, } - }, [finalShape, size, backgroundColor]) + }, [finalShape, size, t]) + + const borderStyle = useMemo(() => { + return [ + {borderRadius: aviStyle.borderRadius}, + live && { + borderColor: t.palette.negative_500, + borderWidth: size > 16 ? 2 : 1, + opacity: 1, + }, + ] + }, [aviStyle.borderRadius, live, t, size]) const alert = useMemo(() => { if (!moderation?.alert) { @@ -277,12 +298,19 @@ let UserAvatar = ({ onLoad={onLoad} /> )} - <MediaInsetBorder style={[{borderRadius: aviStyle.borderRadius}]} /> + <MediaInsetBorder style={borderStyle} /> + {live && size > 16 && !hideLiveBadge && ( + <LiveIndicator size={size > 32 ? 'small' : 'tiny'} /> + )} {alert} </View> ) : ( <View style={containerStyle}> <DefaultAvatar type={type} shape={finalShape} size={size} /> + <MediaInsetBorder style={borderStyle} /> + {live && size > 16 && !hideLiveBadge && ( + <LiveIndicator size={size > 32 ? 'small' : 'tiny'} /> + )} {alert} </View> ) @@ -486,21 +514,32 @@ let PreviewableUserAvatar = ({ disableHoverCard, disableNavigation, onBeforePress, + live, ...rest }: PreviewableUserAvatarProps): React.ReactNode => { const {_} = useLingui() const queryClient = useQueryClient() + const status = useActorStatus(profile) + const liveControl = useDialogControl() + const playHaptic = useHaptics() - const onPress = React.useCallback(() => { + const onPress = useCallback(() => { onBeforePress?.() unstableCacheProfileView(queryClient, profile) }, [profile, queryClient, onBeforePress]) + const onOpenLiveStatus = useCallback(() => { + playHaptic('Light') + logger.metric('live:card:open', {subject: profile.did, from: 'post'}) + liveControl.open() + }, [liveControl, playHaptic, profile.did]) + const avatarEl = ( <UserAvatar avatar={profile.avatar} moderation={moderation} type={profile.associated?.labeler ? 'labeler' : 'user'} + live={status.isActive || live} {...rest} /> ) @@ -509,9 +548,32 @@ let PreviewableUserAvatar = ({ <ProfileHoverCard did={profile.did} disable={disableHoverCard}> {disableNavigation ? ( avatarEl + ) : status.isActive && (isNative || isTouchDevice) ? ( + <> + <Button + label={_( + msg`${sanitizeDisplayName( + profile.displayName || sanitizeHandle(profile.handle), + )}'s avatar`, + )} + accessibilityHint={_(msg`Opens live status dialog`)} + onPress={onOpenLiveStatus}> + {avatarEl} + </Button> + <LiveStatusDialog + control={liveControl} + profile={profile} + status={status} + embed={status.embed} + /> + </> ) : ( <Link - label={_(msg`${profile.displayName || profile.handle}'s avatar`)} + label={_( + msg`${sanitizeDisplayName( + profile.displayName || sanitizeHandle(profile.handle), + )}'s avatar`, + )} accessibilityHint={_(msg`Opens this profile`)} to={makeProfileLink({ did: profile.did, |