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.
+341
-228
Diff
round #1
+15
-7
README.md
+15
-7
README.md
···
21
21
- Embed player works with [stream.place](https://stream.place/) links!
22
22
- Open skeets in PDSls and original pages of bridged posts
23
23
- You can redraft skeets
24
-
- Better defaults (alt text required 😉 autoplay off 🫨)
24
+
- Better defaults (alt text required 😉)
25
25
- More unique repost icons
26
26
- Can download videos
27
27
- 'Mutuals' in place of 'Following' when relevant
···
50
50
- Toggle similar account recommendations
51
51
- Toggle to make all user avatars square (like labelers)
52
52
- Toggle for more square-ish UI (still slightly rounded)
53
-
- Toggle to remove the composer prompt at the top of the Following & Discover feeds
54
-
- Change post translation provider (between Google, Kagi, Papago, and LibreTranslate)
55
53
56
54
#### Metrics
57
55
···
66
64
- following
67
65
- & who someone's followed by
68
66
67
+
#### Gates
68
+
69
+
- Toggle for an alternate share icon
70
+
- Toggle to show feed context for debugging
71
+
- Toggle to hide the 'show latest' button
72
+
- Toggle to make reply button open thread from feeds
73
+
- More may be available in developer mode? Often less 🤷
74
+
- (Accessible by holding the version in the About settings screen)
75
+
69
76
## Upcoming or wishful features
70
77
71
78
- Better OpenGraph support for sharing profiles & skeets (including videos & fixing quotes)
···
76
83
### TODO: Xan
77
84
78
85
- [ ] Setup App Linking for Android (.well-known w/ app package fingerprint)
79
-
- [ ] Fallback/email addresses to use witchsky.social in Automatic PDS detection
86
+
- [ ] Automatic PDS detection like other social-app forks (fallback/email addresses to use witchsky.social)
80
87
- [ ] Change followed accounts [on onboarding](https://github.com/blacksky-algorithms/blacksky.community/commit/e36ee43efb4999f070860d7f70122e45b28c1e2b)
81
-
- [ ] Join date & switch accounts in composer from a fork like [deer.aylac.top](https://github.com/ayla6/deer-social-test)
88
+
- [ ] Join date & switch accounts from composer from a fork like [deer.aylac.top](https://github.com/ayla6/deer-social-test)
82
89
- [ ] Visual replies indicator like the [Firmament userstyle](https://witchsky.app/profile/did:plc:jwhxcrf5uvl3vyw7nurecgt5/post/3m4rr3vzmak2a) (and likes?)
90
+
- [ ] Additional translation service providers + setting (Deepl, Kagi)
83
91
- [ ] Put DeerSettings into separate subpages
84
92
- [ ] After subpages for options, add [Outlinks page](https://witchsky.app/profile/did:plc:q7suwaz53ztc4mbiqyygbn43/post/3m5zjhhshic2g) &
85
93
- [ ] ShareMenuItems.tsx, ShareMenuItems.web.tsx
86
94
- [ ] For profile meatball button, Open profile in PDSls & Open bridged OG fedi account page
87
95
- [ ] ProfileMenu.tsx
88
-
- [ ] Witchsky PDS and .social site (list good songs containing 'bitch' in their titles for related site)
96
+
- [ ] Witchsky PDS and .social site (list good songs containing 'bitch' in their titles)
89
97
90
98
### Even more wishful or far off
91
99
···
93
101
- [ ] Submit releases to the Google Play Store and iOS App Store
94
102
- [ ] Move from [Cloudflare Pages](https://pages.cloudflare.com/) to [wisp.place](https://wisp.place/) (needs serverless for embeds)
95
103
- [ ] Toggle between handle and DID in share links
96
-
- [ ] Move TOS and privacy policy to Jollywhoppers website
104
+
- [ ] Move TOS and privacy policy to Jollywhoppers website?
97
105
- [ ] Ignore `!no-unauthenticated` labels
98
106
- [ ] Material 3 Expressive theming on Android (Liquid **ass on iOS)
99
107
+1
-10
src/lib/hooks/useTranslate.ts
+1
-10
src/lib/hooks/useTranslate.ts
···
1
1
import {useCallback} from 'react'
2
2
import * as IntentLauncher from 'expo-intent-launcher'
3
3
4
-
import {
5
-
getTranslatorLink,
6
-
getTranslatorLinkKagi,
7
-
getTranslatorLinkLibreTranslate,
8
-
getTranslatorLinkPapago,
9
-
} from '#/locale/helpers'
4
+
import {getTranslatorLink, getTranslatorLinkKagi} from '#/locale/helpers'
10
5
import {useTranslationServicePreference} from '#/state/preferences/translation-service-preference'
11
6
import {IS_ANDROID} from '#/env'
12
7
import {useOpenLink} from './useOpenLink'
···
24
19
// it is a mystery https://www.youtube.com/watch?v=fq3abPnEEGE
25
20
if (translationServicePreference == 'kagi') {
26
21
translateUrl = getTranslatorLinkKagi(text, language)
27
-
} else if (translationServicePreference == 'papago') {
28
-
translateUrl = getTranslatorLinkPapago(text, language)
29
-
} else if (translationServicePreference == 'libreTranslate') {
30
-
translateUrl = getTranslatorLinkLibreTranslate(text, language)
31
22
} else {
32
23
translateUrl = getTranslatorLink(text, language)
33
24
}
-17
src/locale/helpers.ts
-17
src/locale/helpers.ts
···
3
3
import lande from 'lande'
4
4
5
5
import {hasProp} from '#/lib/type-guards'
6
-
import * as persisted from '#/state/persisted'
7
6
import {
8
7
AppLanguage,
9
8
type Language,
···
139
138
return `https://translate.kagi.com/?from=auto&to=${lang}&text=${encodeURIComponent(
140
139
text,
141
140
)}`
142
-
}
143
-
144
-
export function getTranslatorLinkPapago(text: string, lang: string): string {
145
-
return `https://papago.naver.com/?sk=auto&tk=${lang}&st=${encodeURIComponent(
146
-
text,
147
-
)}`
148
-
}
149
-
150
-
export function getTranslatorLinkLibreTranslate(
151
-
text: string,
152
-
lang: string,
153
-
): string {
154
-
const instance =
155
-
persisted.get('libreTranslateInstance') ??
156
-
persisted.defaults.libreTranslateInstance!
157
-
return `${instance}?source=auto&target=${lang}&q=${encodeURIComponent(text)}`
158
141
}
159
142
160
143
/**
+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
+
}
+19
-142
src/screens/Settings/DeerSettings.tsx
+19
-142
src/screens/Settings/DeerSettings.tsx
···
108
108
useShowLinkInHandle,
109
109
} from '#/state/preferences/show-link-in-handle.tsx'
110
110
import {
111
-
useLibreTranslateInstance,
112
-
useSetLibreTranslateInstance,
113
111
useSetTranslationServicePreference,
114
112
useTranslationServicePreference,
115
113
} from '#/state/preferences/translation-service-preference'
···
130
128
import {Star_Stroke2_Corner0_Rounded as StarIcon} from '#/components/icons/Star'
131
129
import {Verified_Stroke2_Corner2_Rounded as VerifiedIcon} from '#/components/icons/Verified'
132
130
import * as Layout from '#/components/Layout'
133
-
import {InlineLinkText} from '#/components/Link'
134
131
import {Text} from '#/components/Typography'
135
132
import {IS_WEB} from '#/env'
136
133
import {SearchProfileCard} from '../Search/components/SearchProfileCard'
···
145
142
const pal = usePalette('default')
146
143
const {_} = useLingui()
147
144
148
-
const constellationInstance = useConstellationInstance()
149
-
const [url, setUrl] = useState(constellationInstance ?? '')
145
+
const [url, setUrl] = useState('')
150
146
const setConstellationInstance = useSetConstellationInstance()
151
147
152
148
const submit = () => {
···
166
162
<Dialog.Outer
167
163
control={control}
168
164
nativeOptions={{preventExpansion: true}}
169
-
onClose={() => setUrl(constellationInstance ?? '')}>
165
+
onClose={() => setUrl('')}>
170
166
<Dialog.Handle />
171
167
<Dialog.ScrollableInner label={_(msg`Constellations instance URL`)}>
172
168
<View style={[a.gap_sm, a.pb_lg]}>
···
189
185
accessibilityHint={_(
190
186
msg`Input the url of the constellations instance to use`,
191
187
)}
192
-
defaultValue={constellationInstance}
193
-
/>
194
-
195
-
<View style={IS_WEB && [a.flex_row, a.justify_end]}>
196
-
<Button
197
-
label={_(msg`Save`)}
198
-
size="large"
199
-
onPress={submit}
200
-
variant="solid"
201
-
color="primary"
202
-
disabled={shouldDisable()}>
203
-
<ButtonText>
204
-
<Trans>Save</Trans>
205
-
</ButtonText>
206
-
</Button>
207
-
</View>
208
-
</View>
209
-
210
-
<Dialog.Close />
211
-
</Dialog.ScrollableInner>
212
-
</Dialog.Outer>
213
-
)
214
-
}
215
-
216
-
function LibreTranslateInstanceDialog({
217
-
control,
218
-
}: {
219
-
control: Dialog.DialogControlProps
220
-
}) {
221
-
const pal = usePalette('default')
222
-
const {_} = useLingui()
223
-
224
-
const libreTranslateInstance = useLibreTranslateInstance()
225
-
const [url, setUrl] = useState(libreTranslateInstance ?? '')
226
-
const setLibreTranslateInstance = useSetLibreTranslateInstance()
227
-
228
-
const submit = () => {
229
-
setLibreTranslateInstance(url)
230
-
control.close()
231
-
}
232
-
233
-
const shouldDisable = () => {
234
-
try {
235
-
return !new URL(url).hostname.includes('.')
236
-
} catch (e) {
237
-
return true
238
-
}
239
-
}
240
-
241
-
return (
242
-
<Dialog.Outer
243
-
control={control}
244
-
nativeOptions={{preventExpansion: true}}
245
-
onClose={() => setUrl(libreTranslateInstance ?? '')}>
246
-
<Dialog.Handle />
247
-
<Dialog.ScrollableInner label={_(msg`LibreTranslate instance URL`)}>
248
-
<View style={[a.gap_sm, a.pb_lg]}>
249
-
<Text style={[a.text_2xl, a.font_bold]}>
250
-
<Trans>LibreTranslate instance URL</Trans>
251
-
</Text>
252
-
</View>
253
-
254
-
<View style={a.gap_lg}>
255
-
<Dialog.Input
256
-
label="Text input field"
257
-
autoFocus
258
-
style={[styles.textInput, pal.border, pal.text]}
259
-
onChangeText={value => {
260
-
setUrl(value)
261
-
}}
262
-
placeholder={persisted.defaults.libreTranslateInstance}
263
-
placeholderTextColor={pal.colors.textLight}
264
-
onSubmitEditing={submit}
265
-
accessibilityHint={_(
266
-
msg`Input the url of the LibreTranslate instance to use`,
267
-
)}
268
-
defaultValue={libreTranslateInstance}
269
188
/>
270
189
271
190
<View style={IS_WEB && [a.flex_row, a.justify_end]}>
···
423
342
const translationServicePreference = useTranslationServicePreference()
424
343
const setTranslationServicePreference = useSetTranslationServicePreference()
425
344
426
-
const setLibreTranslateInstanceControl = Dialog.useDialogControl()
427
-
428
345
return (
429
346
<Layout.Screen>
430
347
<Layout.Header.Outer>
···
472
389
<Toggle.LabelText style={[a.flex_1]}>
473
390
<Trans>
474
391
Fetch records directly from PDS to see contents of blocked and
475
-
detached quotes
392
+
detatched quotes
476
393
</Trans>
477
394
</Toggle.LabelText>
478
395
<Toggle.Platform />
···
563
480
<Trans>
564
481
Constellation is used to supplement AppView responses for custom
565
482
verifications and nuclear block bypass, via backlinks. Current
566
-
instance:
567
-
<InlineLinkText
568
-
to={constellationInstance}
569
-
label={constellationInstance}>
570
-
{constellationInstance}
571
-
</InlineLinkText>
483
+
instance: {constellationInstance}
572
484
</Trans>
573
485
</Admonition>
574
486
</SettingsList.Item>
575
-
576
-
<SettingsList.Divider />
577
487
578
488
<SettingsList.Group contentContainerStyle={[a.gap_sm]}>
579
489
<SettingsList.ItemIcon icon={PaintRollerIcon} />
···
733
643
</Admonition>
734
644
</SettingsList.Group>
735
645
736
-
<SettingsList.Divider />
737
-
738
646
<SettingsList.Group contentContainerStyle={[a.gap_sm]}>
739
647
<SettingsList.ItemIcon icon={EarthIcon} />
740
648
<SettingsList.ItemText>
741
-
<Trans>Post Translation Engine</Trans>
649
+
<Trans>Translation Engine</Trans>
742
650
</SettingsList.ItemText>
651
+
652
+
<Admonition type="info" style={[a.flex_1]}>
653
+
<Trans>Choose the engine to use when translating posts.</Trans>
654
+
</Admonition>
743
655
744
656
<Toggle.Item
745
657
name="service_google"
···
764
676
</Toggle.LabelText>
765
677
<Toggle.Radio />
766
678
</Toggle.Item>
767
-
768
-
<Toggle.Item
769
-
name="service_papago"
770
-
label={_(msg`Use Naver Papago`)}
771
-
value={translationServicePreference === 'papago'}
772
-
onChange={() => setTranslationServicePreference('papago')}
773
-
style={[a.w_full]}>
774
-
<Toggle.LabelText style={[a.flex_1]}>
775
-
<Trans>Use Naver Papago</Trans>
776
-
</Toggle.LabelText>
777
-
<Toggle.Radio />
778
-
</Toggle.Item>
779
-
780
-
<Toggle.Item
781
-
name="service_libreTranslate"
782
-
label={_(msg`Use LibreTranslate`)}
783
-
value={translationServicePreference === 'libreTranslate'}
784
-
onChange={() => setTranslationServicePreference('libreTranslate')}
785
-
style={[a.w_full]}>
786
-
<Toggle.LabelText style={[a.flex_1]}>
787
-
<Trans>Use LibreTranslate</Trans>
788
-
</Toggle.LabelText>
789
-
<Toggle.Radio />
790
-
</Toggle.Item>
791
679
</SettingsList.Group>
792
680
793
-
{translationServicePreference === 'libreTranslate' && (
794
-
<SettingsList.Item>
795
-
<SettingsList.ItemIcon icon={EarthIcon} />
796
-
<SettingsList.ItemText>
797
-
<Trans>{`LibreTranslate Instance`}</Trans>
798
-
</SettingsList.ItemText>
799
-
<SettingsList.BadgeButton
800
-
label={_(msg`Change`)}
801
-
onPress={() => setLibreTranslateInstanceControl.open()}
802
-
/>
803
-
</SettingsList.Item>
804
-
)}
805
-
806
-
<SettingsList.Divider />
807
-
808
681
<SettingsList.Group contentContainerStyle={[a.gap_sm]}>
809
682
<SettingsList.ItemIcon icon={VisibilityIcon} />
810
683
<SettingsList.ItemText>
···
825
698
826
699
<Toggle.Item
827
700
name="disable_reposts_metrics"
828
-
label={_(msg`Disable reskeet metrics`)}
701
+
label={_(msg`Disable reskeets metrics`)}
829
702
value={disableRepostsMetrics}
830
703
onChange={value => setDisableRepostsMetrics(value)}
831
704
style={[a.w_full]}>
832
705
<Toggle.LabelText style={[a.flex_1]}>
833
-
<Trans>Disable reskeet metrics</Trans>
706
+
<Trans>Disable reskeets metrics</Trans>
834
707
</Toggle.LabelText>
835
708
<Toggle.Platform />
836
709
</Toggle.Item>
···
920
793
</Toggle.Item>
921
794
</SettingsList.Group>
922
795
923
-
<SettingsList.Divider />
796
+
<SettingsList.Item>
797
+
<Admonition type="warning" style={[a.flex_1]}>
798
+
<Trans>
799
+
These settings might summon nasal demons! Restart the app after
800
+
changing if anything breaks.
801
+
</Trans>
802
+
</Admonition>
803
+
</SettingsList.Item>
924
804
925
805
<SettingsList.Group contentContainerStyle={[a.gap_sm]}>
926
806
<SettingsList.ItemIcon icon={RaisingHandIcon} />
···
967
847
</Layout.Content>
968
848
<ConstellationInstanceDialog control={setConstellationInstanceControl} />
969
849
<TrustedVerifiersDialog control={setTrustedVerifiersDialogControl} />
970
-
<LibreTranslateInstanceDialog
971
-
control={setLibreTranslateInstanceControl}
972
-
/>
973
850
</Layout.Screen>
974
851
)
975
852
}
+1
-8
src/state/persisted/schema.ts
+1
-8
src/state/persisted/schema.ts
···
172
172
173
173
showExternalShareButtons: z.boolean().optional(),
174
174
175
-
translationServicePreference: z.enum([
176
-
'google',
177
-
'kagi',
178
-
'papago',
179
-
'libreTranslate',
180
-
]),
181
-
libreTranslateInstance: z.string().optional(),
175
+
translationServicePreference: z.enum(['google', 'kagi']),
182
176
183
177
/** @deprecated */
184
178
mutedThreads: z.array(z.string()),
···
290
284
hideUnreplyablePosts: false,
291
285
showExternalShareButtons: false,
292
286
translationServicePreference: 'google',
293
-
libreTranslateInstance: 'https://libretranslate.com/',
294
287
}
295
288
296
289
export function tryParse(rawData: string): Schema | undefined {
+1
-43
src/state/preferences/translation-service-preference.tsx
+1
-43
src/state/preferences/translation-service-preference.tsx
···
4
4
5
5
type StateContext = persisted.Schema['translationServicePreference']
6
6
type SetContext = (v: persisted.Schema['translationServicePreference']) => void
7
-
type InstanceStateContext = persisted.Schema['libreTranslateInstance']
8
-
type SetInstanceContext = (
9
-
v: persisted.Schema['libreTranslateInstance'],
10
-
) => void
11
7
12
8
const stateContext = React.createContext<StateContext>(
13
9
persisted.defaults.translationServicePreference,
···
15
11
const setContext = React.createContext<SetContext>(
16
12
(_: persisted.Schema['translationServicePreference']) => {},
17
13
)
18
-
const instanceStateContext = React.createContext<InstanceStateContext>(
19
-
persisted.defaults.libreTranslateInstance,
20
-
)
21
-
const setInstanceContext = React.createContext<SetInstanceContext>(
22
-
(_: persisted.Schema['libreTranslateInstance']) => {},
23
-
)
24
14
25
15
export function Provider({children}: React.PropsWithChildren<{}>) {
26
16
const [state, setState] = React.useState(
27
17
persisted.get('translationServicePreference'),
28
18
)
29
-
const [instanceState, setInstanceState] = React.useState(
30
-
persisted.get('libreTranslateInstance'),
31
-
)
32
19
33
20
const setStateWrapped = React.useCallback(
34
21
(
···
43
30
[setState],
44
31
)
45
32
46
-
const setInstanceStateWrapped = React.useCallback(
47
-
(libreTranslateInstance: persisted.Schema['libreTranslateInstance']) => {
48
-
setInstanceState(libreTranslateInstance)
49
-
persisted.write('libreTranslateInstance', libreTranslateInstance)
50
-
},
51
-
[setInstanceState],
52
-
)
53
-
54
33
React.useEffect(() => {
55
34
return persisted.onUpdate(
56
35
'translationServicePreference',
···
60
39
)
61
40
}, [setStateWrapped])
62
41
63
-
React.useEffect(() => {
64
-
return persisted.onUpdate('libreTranslateInstance', nextInstance => {
65
-
setInstanceState(nextInstance)
66
-
})
67
-
}, [setInstanceStateWrapped])
68
-
69
42
return (
70
43
<stateContext.Provider value={state}>
71
44
<setContext.Provider value={setStateWrapped}>
72
-
<instanceStateContext.Provider value={instanceState}>
73
-
<setInstanceContext.Provider value={setInstanceStateWrapped}>
74
-
{children}
75
-
</setInstanceContext.Provider>
76
-
</instanceStateContext.Provider>
45
+
{children}
77
46
</setContext.Provider>
78
47
</stateContext.Provider>
79
48
)
···
86
55
export function useSetTranslationServicePreference() {
87
56
return React.useContext(setContext)
88
57
}
89
-
90
-
export function useLibreTranslateInstance() {
91
-
return (
92
-
React.useContext(instanceStateContext) ??
93
-
persisted.defaults.libreTranslateInstance!
94
-
)
95
-
}
96
-
97
-
export function useSetLibreTranslateInstance() {
98
-
return React.useContext(setInstanceContext)
99
-
}
+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,
···
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}
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.