Bluesky app fork with some witchin' additions 💫

feat: add Linkat links tab to profile

Adds support for displaying Linkat board links on user profiles.
The Links tab appears when a user has configured a blue.linkat.board
record with link cards.

+307 -1
+3
src/components/ProfileLinkatSection.tsx
··· 1 + // This file has been moved to /src/screens/Profile/Sections/Linkat.tsx 2 + // Keeping this stub to prevent import errors 3 + export {}
+199
src/screens/Profile/Sections/Linkat.tsx
··· 1 + import React, { 2 + useCallback, 3 + useEffect, 4 + useImperativeHandle, 5 + useMemo, 6 + } from 'react' 7 + import { 8 + findNodeHandle, 9 + type ListRenderItemInfo, 10 + useWindowDimensions, 11 + View, 12 + } from 'react-native' 13 + import {msg} from '@lingui/macro' 14 + import {useLingui} from '@lingui/react' 15 + 16 + import {useLinkatBoardQuery} from '#/state/queries/linkat' 17 + import {EmptyState} from '#/view/com/util/EmptyState' 18 + import {List, type ListRef} from '#/view/com/util/List' 19 + import {FeedLoadingPlaceholder} from '#/view/com/util/LoadingPlaceholder' 20 + import {atoms as a, useTheme} from '#/alf' 21 + import {ChainLink_Stroke2_Corner0_Rounded as LinkIcon} from '#/components/icons/ChainLink' 22 + import {Link as InternalLink} from '#/components/Link' 23 + import {Text} from '#/components/Typography' 24 + import {IS_NATIVE} from '#/env' 25 + import {type SectionRef} from './types' 26 + 27 + const LOADING = {_reactKey: '__loading__'} 28 + const EMPTY = {_reactKey: '__empty__'} 29 + 30 + interface Props { 31 + ref?: React.Ref<SectionRef> 32 + did: string 33 + headerHeight: number 34 + isFocused: boolean 35 + scrollElRef: ListRef 36 + setScrollViewTag: (tag: number | null) => void 37 + } 38 + 39 + export function ProfileLinkatSection({ 40 + ref, 41 + did, 42 + headerHeight, 43 + isFocused, 44 + scrollElRef, 45 + setScrollViewTag, 46 + }: Props) { 47 + const {_} = useLingui() 48 + const {height} = useWindowDimensions() 49 + const {data: linkatBoard, isLoading} = useLinkatBoardQuery(did) 50 + 51 + const items = useMemo(() => { 52 + let listItems: any[] = [] 53 + 54 + if (isLoading) { 55 + listItems = listItems.concat([LOADING]) 56 + } else if ( 57 + !linkatBoard || 58 + !linkatBoard.cards || 59 + linkatBoard.cards.length === 0 60 + ) { 61 + listItems = listItems.concat([EMPTY]) 62 + } else { 63 + listItems = listItems.concat( 64 + linkatBoard.cards.map((card, index) => ({ 65 + ...card, 66 + _reactKey: `link-${index}`, 67 + })), 68 + ) 69 + } 70 + 71 + return listItems 72 + }, [linkatBoard, isLoading]) 73 + 74 + const onScrollToTop = useCallback(() => { 75 + scrollElRef.current?.scrollToOffset({ 76 + animated: true, 77 + offset: -headerHeight, 78 + }) 79 + }, [scrollElRef, headerHeight]) 80 + 81 + useImperativeHandle(ref, () => ({ 82 + scrollToTop: onScrollToTop, 83 + })) 84 + 85 + const renderItem = useCallback( 86 + ({item}: ListRenderItemInfo<any>) => { 87 + if (item === EMPTY) { 88 + return ( 89 + <View 90 + style={[ 91 + a.flex_1, 92 + a.align_center, 93 + { 94 + minHeight: height - headerHeight, 95 + paddingTop: headerHeight, 96 + }, 97 + ]}> 98 + <EmptyState 99 + icon={LinkIcon} 100 + iconSize="3xl" 101 + message={_(msg`No links yet`)} 102 + /> 103 + </View> 104 + ) 105 + } else if (item === LOADING) { 106 + return ( 107 + <View style={{paddingTop: headerHeight}}> 108 + <FeedLoadingPlaceholder /> 109 + </View> 110 + ) 111 + } 112 + 113 + return <LinkatCard card={item} /> 114 + }, 115 + [_, height, headerHeight], 116 + ) 117 + 118 + useEffect(() => { 119 + if (IS_NATIVE && isFocused && scrollElRef.current) { 120 + const nativeTag = findNodeHandle(scrollElRef.current) 121 + setScrollViewTag(nativeTag) 122 + } 123 + }, [isFocused, scrollElRef, setScrollViewTag]) 124 + 125 + return ( 126 + <View testID="linkatSection"> 127 + <List 128 + testID="linkatList" 129 + ref={scrollElRef} 130 + data={items} 131 + keyExtractor={(item: any) => item._reactKey} 132 + renderItem={renderItem} 133 + contentContainerStyle={{ 134 + paddingTop: headerHeight, 135 + minHeight: height, 136 + }} 137 + style={{flex: 1}} 138 + // @ts-ignore web only -prf 139 + desktopFixedHeight={IS_NATIVE ? undefined : height} 140 + /> 141 + </View> 142 + ) 143 + } 144 + 145 + function LinkatCard({ 146 + card, 147 + }: { 148 + card: {url: string; text: string; emoji?: string} 149 + }) { 150 + const t = useTheme() 151 + 152 + return ( 153 + <InternalLink 154 + to={card.url} 155 + label={card.text} 156 + style={[ 157 + a.flex_row, 158 + a.align_center, 159 + a.gap_md, 160 + a.px_lg, 161 + a.py_lg, 162 + a.border_b, 163 + t.atoms.border_contrast_low, 164 + t.atoms.bg, 165 + ]} 166 + hoverStyle={[t.atoms.bg_contrast_25]}> 167 + {card.emoji && ( 168 + <View 169 + style={[ 170 + a.justify_center, 171 + a.align_center, 172 + { 173 + width: 48, 174 + height: 48, 175 + }, 176 + ]}> 177 + <Text style={[{fontSize: 32}]} selectable={false}> 178 + {card.emoji} 179 + </Text> 180 + </View> 181 + )} 182 + <View style={[a.flex_1, {minWidth: 0}]}> 183 + <Text 184 + style={[a.text_md, a.font_semibold, a.leading_snug, t.atoms.text]} 185 + numberOfLines={1}> 186 + {card.text} 187 + </Text> 188 + <Text 189 + style={[a.text_sm, a.leading_snug, t.atoms.text_contrast_medium]} 190 + numberOfLines={1}> 191 + {new URL(card.url).hostname} 192 + </Text> 193 + </View> 194 + <View style={[a.justify_center, a.align_center, {width: 24, height: 24}]}> 195 + <LinkIcon size="md" style={t.atoms.text_contrast_medium} /> 196 + </View> 197 + </InternalLink> 198 + ) 199 + }
+75
src/state/queries/linkat.ts
··· 1 + /** 2 + * Linkat integration for Witchsky 3 + * Fetches and caches blue.linkat.board records 4 + */ 5 + import {useQuery} from '@tanstack/react-query' 6 + 7 + import {useAgent} from '#/state/session' 8 + 9 + export interface LinkatCard { 10 + url: string 11 + text: string 12 + emoji?: string 13 + } 14 + 15 + export interface LinkatBoard { 16 + cards: LinkatCard[] 17 + } 18 + 19 + interface LinkatBoardRecord { 20 + cards: Array<{ 21 + url?: string 22 + text?: string 23 + emoji?: string 24 + }> 25 + } 26 + 27 + const LINKAT_COLLECTION = 'blue.linkat.board' 28 + const LINKAT_RKEY = 'self' 29 + const STALE_TIME = 5 * 60 * 1000 // 5 minutes 30 + const CACHE_TIME = 10 * 60 * 1000 // 10 minutes 31 + 32 + /** 33 + * Hook to fetch a user's Linkat board 34 + */ 35 + export function useLinkatBoardQuery(did: string | undefined) { 36 + const agent = useAgent() 37 + 38 + return useQuery({ 39 + queryKey: ['linkat-board', did], 40 + queryFn: async () => { 41 + if (!did || !agent) return null 42 + 43 + try { 44 + const response = await agent.com.atproto.repo.getRecord({ 45 + repo: did, 46 + collection: LINKAT_COLLECTION, 47 + rkey: LINKAT_RKEY, 48 + }) 49 + 50 + if (!response.data.value || typeof response.data.value !== 'object') { 51 + return null 52 + } 53 + 54 + const value = response.data.value as LinkatBoardRecord 55 + if (!Array.isArray(value.cards)) { 56 + return null 57 + } 58 + 59 + return { 60 + cards: value.cards.map(card => ({ 61 + url: card.url || '', 62 + text: card.text || '', 63 + emoji: card.emoji, 64 + })), 65 + } 66 + } catch (error) { 67 + // Return null if record not found or other error 68 + return null 69 + } 70 + }, 71 + enabled: !!did && !!agent, 72 + staleTime: STALE_TIME, 73 + gcTime: CACHE_TIME, 74 + }) 75 + }
+30 -1
src/view/screens/Profile.tsx
··· 29 29 import {listenSoftReset} from '#/state/events' 30 30 import {useModerationOpts} from '#/state/preferences/moderation-opts' 31 31 import {useLabelerInfoQuery} from '#/state/queries/labeler' 32 + import {useLinkatBoardQuery} from '#/state/queries/linkat' 32 33 import {resetProfilePostsQueries} from '#/state/queries/post-feed' 33 34 import {useProfileQuery} from '#/state/queries/profile' 34 35 import {useResolveDidQuery} from '#/state/queries/resolve-uri' ··· 43 44 import {ProfileHeader, ProfileHeaderLoading} from '#/screens/Profile/Header' 44 45 import {ProfileFeedSection} from '#/screens/Profile/Sections/Feed' 45 46 import {ProfileLabelsSection} from '#/screens/Profile/Sections/Labels' 47 + import {ProfileLinkatSection} from '#/screens/Profile/Sections/Linkat' 46 48 import {atoms as a} from '#/alf' 47 49 import {Circle_And_Square_Stroke1_Corner0_Rounded_Filled as CircleAndSquareIcon} from '#/components/icons/CircleAndSquare' 48 50 import {Heart2_Stroke1_Corner0_Rounded as HeartIcon} from '#/components/icons/Heart2' ··· 200 202 const listsSectionRef = React.useRef<SectionRef>(null) 201 203 const starterPacksSectionRef = React.useRef<SectionRef>(null) 202 204 const labelsSectionRef = React.useRef<SectionRef>(null) 205 + const linksSectionRef = React.useRef<SectionRef>(null) 203 206 204 207 useSetTitle(combinedDisplayName(profile)) 205 208 ··· 227 230 // subtract starterpack count from list count, since starterpacks are a type of list 228 231 const listCount = (profile.associated?.lists || 0) - starterPackCount 229 232 const showListsTab = hasSession && (isMe || listCount > 0) 233 + // Check if user has Linkat board 234 + const {data: linkatBoard} = useLinkatBoardQuery(profile.did) 235 + const showLinksTab = Boolean(linkatBoard?.cards?.length) 230 236 231 237 const sectionTitles = [ 232 238 showFiltersTab ? _(msg`Labels`) : undefined, ··· 236 242 showMediaTab ? _(msg`Media`) : undefined, 237 243 showVideosTab ? _(msg`Videos`) : undefined, 238 244 showLikesTab ? _(msg`Likes`) : undefined, 245 + showLinksTab ? _(msg`Links`) : undefined, 239 246 showFeedsTab ? _(msg`Feeds`) : undefined, 240 247 showStarterPacksTab ? _(msg`Starter Packs`) : undefined, 241 248 showListsTab && !hasLabeler ? _(msg`Lists`) : undefined, ··· 248 255 let mediaIndex: number | null = null 249 256 let videosIndex: number | null = null 250 257 let likesIndex: number | null = null 258 + let linksIndex: number | null = null 251 259 let feedsIndex: number | null = null 252 260 let starterPacksIndex: number | null = null 253 261 let listsIndex: number | null = null 254 262 if (showFiltersTab) { 255 263 filtersIndex = nextIndex++ 256 264 } 265 + if (showListsTab && hasLabeler) { 266 + listsIndex = nextIndex++ 267 + } 257 268 if (showPostsTab) { 258 269 postsIndex = nextIndex++ 259 270 } ··· 269 280 if (showLikesTab) { 270 281 likesIndex = nextIndex++ 271 282 } 283 + if (showLinksTab) { 284 + linksIndex = nextIndex++ 285 + } 272 286 if (showFeedsTab) { 273 287 feedsIndex = nextIndex++ 274 288 } 275 289 if (showStarterPacksTab) { 276 290 starterPacksIndex = nextIndex++ 277 291 } 278 - if (showListsTab) { 292 + if (showListsTab && !hasLabeler) { 279 293 listsIndex = nextIndex++ 280 294 } 281 295 ··· 293 307 videosSectionRef.current?.scrollToTop() 294 308 } else if (index === likesIndex) { 295 309 likesSectionRef.current?.scrollToTop() 310 + } else if (index === linksIndex) { 311 + linksSectionRef.current?.scrollToTop() 296 312 } else if (index === feedsIndex) { 297 313 feedsSectionRef.current?.scrollToTop() 298 314 } else if (index === starterPacksIndex) { ··· 308 324 mediaIndex, 309 325 videosIndex, 310 326 likesIndex, 327 + linksIndex, 311 328 feedsIndex, 312 329 listsIndex, 313 330 starterPacksIndex, ··· 522 539 setScrollViewTag={setScrollViewTag} 523 540 emptyStateMessage={_(msg`No likes yet`)} 524 541 emptyStateIcon={HeartIcon} 542 + /> 543 + ) 544 + : null} 545 + {showLinksTab 546 + ? ({headerHeight, isFocused, scrollElRef}) => ( 547 + <ProfileLinkatSection 548 + ref={linksSectionRef} 549 + did={profile.did} 550 + headerHeight={headerHeight} 551 + isFocused={isFocused} 552 + scrollElRef={scrollElRef as ListRef} 553 + setScrollViewTag={setScrollViewTag} 525 554 /> 526 555 ) 527 556 : null}