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
Diff
round #0
+3
src/components/ProfileLinkatSection.tsx
+3
src/components/ProfileLinkatSection.tsx
+199
src/screens/Profile/Sections/Linkat.tsx
+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
+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
+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,
···
525
542
/>
526
543
)
527
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}
554
+
/>
555
+
)
556
+
: null}
528
557
{showFeedsTab
529
558
? ({headerHeight, isFocused, scrollElRef}) => (
530
559
<ProfileFeedgens
History
2 rounds
0 comments
ewancroft.uk
submitted
#1
expand 0 comments
closed without merging
ewancroft.uk
submitted
#0
1 commit
expand
collapse
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.