Bluesky app fork with some witchin' additions 💫 witchsky.app
bluesky fork client

feat: add Linkat links tab to profile #52

closed opened by ewancroft.uk targeting main from linkat-integration

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.

Labels

None yet.

assignee

None yet.

Participants 1
AT URI
at://did:plc:ofrbh253gwicbkc5nktqepol/sh.tangled.repo.pull/3mddzf72chb22
+341 -228
Diff #1
+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 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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}

History

2 rounds 0 comments
sign up or login to add to the discussion
2 commits
expand
feat: add Linkat links tab to profile
chore: remove unnecessary file
expand 0 comments
closed without merging
1 commit
expand
feat: add Linkat links tab to profile
expand 0 comments