diff --git a/src/components/ProfileLinkatSection.tsx b/src/components/ProfileLinkatSection.tsx new file mode 100644 index 000000000..17236106b --- /dev/null +++ b/src/components/ProfileLinkatSection.tsx @@ -0,0 +1,3 @@ +// This file has been moved to /src/screens/Profile/Sections/Linkat.tsx +// Keeping this stub to prevent import errors +export {} diff --git a/src/screens/Profile/Sections/Linkat.tsx b/src/screens/Profile/Sections/Linkat.tsx new file mode 100644 index 000000000..638430076 --- /dev/null +++ b/src/screens/Profile/Sections/Linkat.tsx @@ -0,0 +1,199 @@ +import React, { + useCallback, + useEffect, + useImperativeHandle, + useMemo, +} from 'react' +import { + findNodeHandle, + type ListRenderItemInfo, + useWindowDimensions, + View, +} from 'react-native' +import {msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' + +import {useLinkatBoardQuery} from '#/state/queries/linkat' +import {EmptyState} from '#/view/com/util/EmptyState' +import {List, type ListRef} from '#/view/com/util/List' +import {FeedLoadingPlaceholder} from '#/view/com/util/LoadingPlaceholder' +import {atoms as a, useTheme} from '#/alf' +import {ChainLink_Stroke2_Corner0_Rounded as LinkIcon} from '#/components/icons/ChainLink' +import {Link as InternalLink} from '#/components/Link' +import {Text} from '#/components/Typography' +import {IS_NATIVE} from '#/env' +import {type SectionRef} from './types' + +const LOADING = {_reactKey: '__loading__'} +const EMPTY = {_reactKey: '__empty__'} + +interface Props { + ref?: React.Ref + did: string + headerHeight: number + isFocused: boolean + scrollElRef: ListRef + setScrollViewTag: (tag: number | null) => void +} + +export function ProfileLinkatSection({ + ref, + did, + headerHeight, + isFocused, + scrollElRef, + setScrollViewTag, +}: Props) { + const {_} = useLingui() + const {height} = useWindowDimensions() + const {data: linkatBoard, isLoading} = useLinkatBoardQuery(did) + + const items = useMemo(() => { + let listItems: any[] = [] + + if (isLoading) { + listItems = listItems.concat([LOADING]) + } else if ( + !linkatBoard || + !linkatBoard.cards || + linkatBoard.cards.length === 0 + ) { + listItems = listItems.concat([EMPTY]) + } else { + listItems = listItems.concat( + linkatBoard.cards.map((card, index) => ({ + ...card, + _reactKey: `link-${index}`, + })), + ) + } + + return listItems + }, [linkatBoard, isLoading]) + + const onScrollToTop = useCallback(() => { + scrollElRef.current?.scrollToOffset({ + animated: true, + offset: -headerHeight, + }) + }, [scrollElRef, headerHeight]) + + useImperativeHandle(ref, () => ({ + scrollToTop: onScrollToTop, + })) + + const renderItem = useCallback( + ({item}: ListRenderItemInfo) => { + if (item === EMPTY) { + return ( + + + + ) + } else if (item === LOADING) { + return ( + + + + ) + } + + return + }, + [_, height, headerHeight], + ) + + useEffect(() => { + if (IS_NATIVE && isFocused && scrollElRef.current) { + const nativeTag = findNodeHandle(scrollElRef.current) + setScrollViewTag(nativeTag) + } + }, [isFocused, scrollElRef, setScrollViewTag]) + + return ( + + item._reactKey} + renderItem={renderItem} + contentContainerStyle={{ + paddingTop: headerHeight, + minHeight: height, + }} + style={{flex: 1}} + // @ts-ignore web only -prf + desktopFixedHeight={IS_NATIVE ? undefined : height} + /> + + ) +} + +function LinkatCard({ + card, +}: { + card: {url: string; text: string; emoji?: string} +}) { + const t = useTheme() + + return ( + + {card.emoji && ( + + + {card.emoji} + + + )} + + + {card.text} + + + {new URL(card.url).hostname} + + + + + + + ) +} diff --git a/src/state/queries/linkat.ts b/src/state/queries/linkat.ts new file mode 100644 index 000000000..8613d63e3 --- /dev/null +++ b/src/state/queries/linkat.ts @@ -0,0 +1,75 @@ +/** + * Linkat integration for Witchsky + * Fetches and caches blue.linkat.board records + */ +import {useQuery} from '@tanstack/react-query' + +import {useAgent} from '#/state/session' + +export interface LinkatCard { + url: string + text: string + emoji?: string +} + +export interface LinkatBoard { + cards: LinkatCard[] +} + +interface LinkatBoardRecord { + cards: Array<{ + url?: string + text?: string + emoji?: string + }> +} + +const LINKAT_COLLECTION = 'blue.linkat.board' +const LINKAT_RKEY = 'self' +const STALE_TIME = 5 * 60 * 1000 // 5 minutes +const CACHE_TIME = 10 * 60 * 1000 // 10 minutes + +/** + * Hook to fetch a user's Linkat board + */ +export function useLinkatBoardQuery(did: string | undefined) { + const agent = useAgent() + + return useQuery({ + queryKey: ['linkat-board', did], + queryFn: async () => { + if (!did || !agent) return null + + try { + const response = await agent.com.atproto.repo.getRecord({ + repo: did, + collection: LINKAT_COLLECTION, + rkey: LINKAT_RKEY, + }) + + if (!response.data.value || typeof response.data.value !== 'object') { + return null + } + + const value = response.data.value as LinkatBoardRecord + if (!Array.isArray(value.cards)) { + return null + } + + return { + cards: value.cards.map(card => ({ + url: card.url || '', + text: card.text || '', + emoji: card.emoji, + })), + } + } catch (error) { + // Return null if record not found or other error + return null + } + }, + enabled: !!did && !!agent, + staleTime: STALE_TIME, + gcTime: CACHE_TIME, + }) +} diff --git a/src/view/screens/Profile.tsx b/src/view/screens/Profile.tsx index d4ae03d79..72d74b0bf 100644 --- a/src/view/screens/Profile.tsx +++ b/src/view/screens/Profile.tsx @@ -29,6 +29,7 @@ import {useProfileShadow} from '#/state/cache/profile-shadow' import {listenSoftReset} from '#/state/events' import {useModerationOpts} from '#/state/preferences/moderation-opts' import {useLabelerInfoQuery} from '#/state/queries/labeler' +import {useLinkatBoardQuery} from '#/state/queries/linkat' import {resetProfilePostsQueries} from '#/state/queries/post-feed' import {useProfileQuery} from '#/state/queries/profile' import {useResolveDidQuery} from '#/state/queries/resolve-uri' @@ -43,6 +44,7 @@ import {type ListRef} from '#/view/com/util/List' import {ProfileHeader, ProfileHeaderLoading} from '#/screens/Profile/Header' import {ProfileFeedSection} from '#/screens/Profile/Sections/Feed' import {ProfileLabelsSection} from '#/screens/Profile/Sections/Labels' +import {ProfileLinkatSection} from '#/screens/Profile/Sections/Linkat' import {atoms as a} from '#/alf' import {Circle_And_Square_Stroke1_Corner0_Rounded_Filled as CircleAndSquareIcon} from '#/components/icons/CircleAndSquare' import {Heart2_Stroke1_Corner0_Rounded as HeartIcon} from '#/components/icons/Heart2' @@ -200,6 +202,7 @@ function ProfileScreenLoaded({ const listsSectionRef = React.useRef(null) const starterPacksSectionRef = React.useRef(null) const labelsSectionRef = React.useRef(null) + const linksSectionRef = React.useRef(null) useSetTitle(combinedDisplayName(profile)) @@ -227,6 +230,9 @@ function ProfileScreenLoaded({ // subtract starterpack count from list count, since starterpacks are a type of list const listCount = (profile.associated?.lists || 0) - starterPackCount const showListsTab = hasSession && (isMe || listCount > 0) + // Check if user has Linkat board + const {data: linkatBoard} = useLinkatBoardQuery(profile.did) + const showLinksTab = Boolean(linkatBoard?.cards?.length) const sectionTitles = [ showFiltersTab ? _(msg`Labels`) : undefined, @@ -236,6 +242,7 @@ function ProfileScreenLoaded({ showMediaTab ? _(msg`Media`) : undefined, showVideosTab ? _(msg`Videos`) : undefined, showLikesTab ? _(msg`Likes`) : undefined, + showLinksTab ? _(msg`Links`) : undefined, showFeedsTab ? _(msg`Feeds`) : undefined, showStarterPacksTab ? _(msg`Starter Packs`) : undefined, showListsTab && !hasLabeler ? _(msg`Lists`) : undefined, @@ -248,12 +255,16 @@ function ProfileScreenLoaded({ let mediaIndex: number | null = null let videosIndex: number | null = null let likesIndex: number | null = null + let linksIndex: number | null = null let feedsIndex: number | null = null let starterPacksIndex: number | null = null let listsIndex: number | null = null if (showFiltersTab) { filtersIndex = nextIndex++ } + if (showListsTab && hasLabeler) { + listsIndex = nextIndex++ + } if (showPostsTab) { postsIndex = nextIndex++ } @@ -269,13 +280,16 @@ function ProfileScreenLoaded({ if (showLikesTab) { likesIndex = nextIndex++ } + if (showLinksTab) { + linksIndex = nextIndex++ + } if (showFeedsTab) { feedsIndex = nextIndex++ } if (showStarterPacksTab) { starterPacksIndex = nextIndex++ } - if (showListsTab) { + if (showListsTab && !hasLabeler) { listsIndex = nextIndex++ } @@ -293,6 +307,8 @@ function ProfileScreenLoaded({ videosSectionRef.current?.scrollToTop() } else if (index === likesIndex) { likesSectionRef.current?.scrollToTop() + } else if (index === linksIndex) { + linksSectionRef.current?.scrollToTop() } else if (index === feedsIndex) { feedsSectionRef.current?.scrollToTop() } else if (index === starterPacksIndex) { @@ -308,6 +324,7 @@ function ProfileScreenLoaded({ mediaIndex, videosIndex, likesIndex, + linksIndex, feedsIndex, listsIndex, starterPacksIndex, @@ -525,6 +542,18 @@ function ProfileScreenLoaded({ /> ) : null} + {showLinksTab + ? ({headerHeight, isFocused, scrollElRef}) => ( + + ) + : null} {showFeedsTab ? ({headerHeight, isFocused, scrollElRef}) => (