about summary refs log tree commit diff
diff options
context:
space:
mode:
authorHailey <me@haileyok.com>2024-07-03 18:15:08 -0700
committerGitHub <noreply@github.com>2024-07-04 02:15:08 +0100
commitaa7117edb60711a67464f7559118334185f01680 (patch)
treeb0ccd3d7ef0d792613542a1af48c3fbae1f36f21
parenta3d4fb652b888ba81aecbf0e81a954968ea65d39 (diff)
downloadvoidsky-aa7117edb60711a67464f7559118334185f01680.tar.zst
Add starter pack embeds to posts (#4699)
* starter pack embeds

* revert test code

* Types

* add `BaseLink`

* precache on click

* rm log

* add a comment

* loading state

* top margin

---------

Co-authored-by: Dan Abramov <dan.abramov@gmail.com>
-rw-r--r--src/components/Link.tsx49
-rw-r--r--src/components/StarterPack/Main/ProfilesList.tsx21
-rw-r--r--src/components/StarterPack/StarterPackCard.tsx60
-rw-r--r--src/lib/link-meta/bsky.ts51
-rw-r--r--src/lib/strings/starter-pack.ts2
-rw-r--r--src/lib/strings/url-helpers.ts24
-rw-r--r--src/screens/StarterPack/StarterPackScreen.tsx22
-rw-r--r--src/state/queries/starter-packs.ts33
-rw-r--r--src/view/com/composer/useExternalLinkFetch.ts20
-rw-r--r--src/view/com/util/post-embeds/index.tsx5
10 files changed, 246 insertions, 41 deletions
diff --git a/src/components/Link.tsx b/src/components/Link.tsx
index d8ac829b6..a8b478be7 100644
--- a/src/components/Link.tsx
+++ b/src/components/Link.tsx
@@ -1,5 +1,10 @@
 import React from 'react'
-import {GestureResponderEvent} from 'react-native'
+import {
+  GestureResponderEvent,
+  Pressable,
+  StyleProp,
+  ViewStyle,
+} from 'react-native'
 import {sanitizeUrl} from '@braintree/sanitize-url'
 import {StackActions, useLinkProps} from '@react-navigation/native'
 
@@ -323,3 +328,45 @@ export function InlineLinkText({
     </Text>
   )
 }
+
+/**
+ * A Pressable that uses useLink to handle navigation. It is unstyled, so can be used in cases where the Button styles
+ * in Link are not desired.
+ * @param displayText
+ * @param style
+ * @param children
+ * @param rest
+ * @constructor
+ */
+export function BaseLink({
+  displayText,
+  onPress: onPressOuter,
+  style,
+  children,
+  ...rest
+}: {
+  style?: StyleProp<ViewStyle>
+  children: React.ReactNode
+  to: string
+  action: 'push' | 'replace' | 'navigate'
+  onPress?: () => false | void
+  shareOnLongPress?: boolean
+  label: string
+  displayText?: string
+}) {
+  const {onPress, ...btnProps} = useLink({
+    displayText: displayText ?? rest.to,
+    ...rest,
+  })
+  return (
+    <Pressable
+      style={style}
+      onPress={e => {
+        onPressOuter?.()
+        onPress(e)
+      }}
+      {...btnProps}>
+      {children}
+    </Pressable>
+  )
+}
diff --git a/src/components/StarterPack/Main/ProfilesList.tsx b/src/components/StarterPack/Main/ProfilesList.tsx
index 0cc911d66..3249f1b32 100644
--- a/src/components/StarterPack/Main/ProfilesList.tsx
+++ b/src/components/StarterPack/Main/ProfilesList.tsx
@@ -11,10 +11,12 @@ import {InfiniteData, UseInfiniteQueryResult} from '@tanstack/react-query'
 import {useBottomBarOffset} from 'lib/hooks/useBottomBarOffset'
 import {isBlockedOrBlocking} from 'lib/moderation/blocked-and-muted'
 import {isNative, isWeb} from 'platform/detection'
+import {useListMembersQuery} from 'state/queries/list-members'
 import {useSession} from 'state/session'
 import {List, ListRef} from 'view/com/util/List'
 import {SectionRef} from '#/screens/Profile/Sections/types'
 import {atoms as a, useTheme} from '#/alf'
+import {ListMaybePlaceholder} from '#/components/Lists'
 import {Default as ProfileCard} from '#/components/ProfileCard'
 
 function keyExtractor(item: AppBskyActorDefs.ProfileViewBasic, index: number) {
@@ -33,18 +35,17 @@ interface ProfilesListProps {
 
 export const ProfilesList = React.forwardRef<SectionRef, ProfilesListProps>(
   function ProfilesListImpl(
-    {listUri, listMembersQuery, moderationOpts, headerHeight, scrollElRef},
+    {listUri, moderationOpts, headerHeight, scrollElRef},
     ref,
   ) {
     const t = useTheme()
     const [initialHeaderHeight] = React.useState(headerHeight)
     const bottomBarOffset = useBottomBarOffset(20)
     const {currentAccount} = useSession()
+    const {data, refetch, isError} = useListMembersQuery(listUri, 50)
 
     const [isPTRing, setIsPTRing] = React.useState(false)
 
-    const {data, refetch} = listMembersQuery
-
     // The server returns these sorted by descending creation date, so we want to invert
     const profiles = data?.pages
       .flatMap(p => p.items.map(i => i.subject))
@@ -96,7 +97,19 @@ export const ProfilesList = React.forwardRef<SectionRef, ProfilesListProps>(
       )
     }
 
-    if (listMembersQuery)
+    if (!data) {
+      return (
+        <View style={{marginTop: headerHeight, marginBottom: bottomBarOffset}}>
+          <ListMaybePlaceholder
+            isLoading={true}
+            isError={isError}
+            onRetry={refetch}
+          />
+        </View>
+      )
+    }
+
+    if (data)
       return (
         <List
           data={getSortedProfiles()}
diff --git a/src/components/StarterPack/StarterPackCard.tsx b/src/components/StarterPack/StarterPackCard.tsx
index ab904d7ff..dc9e4b70d 100644
--- a/src/components/StarterPack/StarterPackCard.tsx
+++ b/src/components/StarterPack/StarterPackCard.tsx
@@ -1,15 +1,20 @@
 import React from 'react'
 import {View} from 'react-native'
+import {Image} from 'expo-image'
 import {AppBskyGraphStarterpack, AtUri} from '@atproto/api'
 import {StarterPackViewBasic} from '@atproto/api/dist/client/types/app/bsky/graph/defs'
 import {msg, Trans} from '@lingui/macro'
 import {useLingui} from '@lingui/react'
+import {useQueryClient} from '@tanstack/react-query'
 
 import {sanitizeHandle} from 'lib/strings/handles'
+import {getStarterPackOgCard} from 'lib/strings/starter-pack'
+import {precacheResolvedUri} from 'state/queries/resolve-uri'
+import {precacheStarterPack} from 'state/queries/starter-packs'
 import {useSession} from 'state/session'
 import {atoms as a, useTheme} from '#/alf'
 import {StarterPack} from '#/components/icons/StarterPack'
-import {Link as InternalLink, LinkProps} from '#/components/Link'
+import {BaseLink} from '#/components/Link'
 import {Text} from '#/components/Typography'
 
 export function Default({starterPack}: {starterPack?: StarterPackViewBasic}) {
@@ -88,10 +93,13 @@ export function Card({
 export function Link({
   starterPack,
   children,
-  ...rest
 }: {
   starterPack: StarterPackViewBasic
-} & Omit<LinkProps, 'to'>) {
+  onPress?: () => void
+  children: React.ReactNode
+}) {
+  const {_} = useLingui()
+  const queryClient = useQueryClient()
   const {record} = starterPack
   const {rkey, handleOrDid} = React.useMemo(() => {
     const rkey = new AtUri(starterPack.uri).rkey
@@ -104,14 +112,46 @@ export function Link({
   }
 
   return (
-    <InternalLink
-      label={record.name}
-      {...rest}
-      to={{
-        screen: 'StarterPack',
-        params: {name: handleOrDid, rkey},
+    <BaseLink
+      action="push"
+      to={`/starter-pack/${handleOrDid}/${rkey}`}
+      label={_(msg`Navigate to ${record.name}`)}
+      onPress={() => {
+        precacheResolvedUri(
+          queryClient,
+          starterPack.creator.handle,
+          starterPack.creator.did,
+        )
+        precacheStarterPack(queryClient, starterPack)
       }}>
       {children}
-    </InternalLink>
+    </BaseLink>
+  )
+}
+
+export function Embed({starterPack}: {starterPack: StarterPackViewBasic}) {
+  const t = useTheme()
+  const imageUri = getStarterPackOgCard(starterPack)
+
+  return (
+    <View
+      style={[
+        a.mt_xs,
+        a.border,
+        a.rounded_sm,
+        a.overflow_hidden,
+        t.atoms.border_contrast_low,
+      ]}>
+      <Link starterPack={starterPack}>
+        <Image
+          source={imageUri}
+          style={[a.w_full, {aspectRatio: 1.91}]}
+          accessibilityIgnoresInvertColors={true}
+        />
+        <View style={[a.px_sm, a.py_md]}>
+          <Card starterPack={starterPack} />
+        </View>
+      </Link>
+    </View>
   )
 }
diff --git a/src/lib/link-meta/bsky.ts b/src/lib/link-meta/bsky.ts
index c1fbb34b3..e3b4ea0c9 100644
--- a/src/lib/link-meta/bsky.ts
+++ b/src/lib/link-meta/bsky.ts
@@ -1,11 +1,16 @@
-import {AppBskyFeedPost, BskyAgent} from '@atproto/api'
+import {AppBskyFeedPost, AppBskyGraphStarterpack, BskyAgent} from '@atproto/api'
+
+import {useFetchDid} from '#/state/queries/handle'
+import {useGetPost} from '#/state/queries/post'
 import * as apilib from 'lib/api/index'
-import {LikelyType, LinkMeta} from './link-meta'
+import {
+  createStarterPackUri,
+  parseStarterPackUri,
+} from 'lib/strings/starter-pack'
+import {ComposerOptsQuote} from 'state/shell/composer'
 // import {match as matchRoute} from 'view/routes'
 import {convertBskyAppUrlIfNeeded, makeRecordUri} from '../strings/url-helpers'
-import {ComposerOptsQuote} from 'state/shell/composer'
-import {useGetPost} from '#/state/queries/post'
-import {useFetchDid} from '#/state/queries/handle'
+import {LikelyType, LinkMeta} from './link-meta'
 
 // TODO
 // import {Home} from 'view/screens/Home'
@@ -174,3 +179,39 @@ export async function getListAsEmbed(
     },
   }
 }
+
+export async function getStarterPackAsEmbed(
+  agent: BskyAgent,
+  fetchDid: ReturnType<typeof useFetchDid>,
+  url: string,
+): Promise<apilib.ExternalEmbedDraft> {
+  const parsed = parseStarterPackUri(url)
+  if (!parsed) {
+    throw new Error(
+      'Unexepectedly called getStarterPackAsEmbed with a non-starterpack url',
+    )
+  }
+  const did = await fetchDid(parsed.name)
+  const starterPack = createStarterPackUri({did, rkey: parsed.rkey})
+  const res = await agent.app.bsky.graph.getStarterPack({starterPack})
+  const record = res.data.starterPack.record
+  return {
+    isLoading: false,
+    uri: starterPack,
+    meta: {
+      url: starterPack,
+      likelyType: LikelyType.AtpData,
+      // Validation here should never fail
+      title: AppBskyGraphStarterpack.isRecord(record)
+        ? record.name
+        : 'Starter Pack',
+    },
+    embed: {
+      $type: 'app.bsky.embed.record',
+      record: {
+        uri: res.data.starterPack.uri,
+        cid: res.data.starterPack.cid,
+      },
+    },
+  }
+}
diff --git a/src/lib/strings/starter-pack.ts b/src/lib/strings/starter-pack.ts
index 01b5a6587..ca3410015 100644
--- a/src/lib/strings/starter-pack.ts
+++ b/src/lib/strings/starter-pack.ts
@@ -96,7 +96,7 @@ export function createStarterPackUri({
 }: {
   did: string
   rkey: string
-}): string | null {
+}): string {
   return new AtUri(`at://${did}/app.bsky.graph.starterpack/${rkey}`).toString()
 }
 
diff --git a/src/lib/strings/url-helpers.ts b/src/lib/strings/url-helpers.ts
index 948279fce..742c7ef79 100644
--- a/src/lib/strings/url-helpers.ts
+++ b/src/lib/strings/url-helpers.ts
@@ -152,6 +152,30 @@ export function isBskyListUrl(url: string): boolean {
   return false
 }
 
+export function isBskyStartUrl(url: string): boolean {
+  if (isBskyAppUrl(url)) {
+    try {
+      const urlp = new URL(url)
+      return /start\/(?<name>[^/]+)\/(?<rkey>[^/]+)/i.test(urlp.pathname)
+    } catch {
+      console.error('Unexpected error in isBskyStartUrl()', url)
+    }
+  }
+  return false
+}
+
+export function isBskyStarterPackUrl(url: string): boolean {
+  if (isBskyAppUrl(url)) {
+    try {
+      const urlp = new URL(url)
+      return /starter-pack\/(?<name>[^/]+)\/(?<rkey>[^/]+)/i.test(urlp.pathname)
+    } catch {
+      console.error('Unexpected error in isBskyStartUrl()', url)
+    }
+  }
+  return false
+}
+
 export function isBskyDownloadUrl(url: string): boolean {
   if (isExternalUrl(url)) {
     return false
diff --git a/src/screens/StarterPack/StarterPackScreen.tsx b/src/screens/StarterPack/StarterPackScreen.tsx
index 2b6f673b1..9b66e5157 100644
--- a/src/screens/StarterPack/StarterPackScreen.tsx
+++ b/src/screens/StarterPack/StarterPackScreen.tsx
@@ -3,7 +3,6 @@ import {View} from 'react-native'
 import {Image} from 'expo-image'
 import {
   AppBskyGraphDefs,
-  AppBskyGraphGetList,
   AppBskyGraphStarterpack,
   AtUri,
   ModerationOpts,
@@ -14,11 +13,7 @@ import {msg, Trans} from '@lingui/macro'
 import {useLingui} from '@lingui/react'
 import {useNavigation} from '@react-navigation/native'
 import {NativeStackScreenProps} from '@react-navigation/native-stack'
-import {
-  InfiniteData,
-  UseInfiniteQueryResult,
-  useQueryClient,
-} from '@tanstack/react-query'
+import {useQueryClient} from '@tanstack/react-query'
 
 import {cleanError} from '#/lib/strings/errors'
 import {logger} from '#/logger'
@@ -33,7 +28,6 @@ import {getStarterPackOgCard} from 'lib/strings/starter-pack'
 import {isWeb} from 'platform/detection'
 import {updateProfileShadow} from 'state/cache/profile-shadow'
 import {useModerationOpts} from 'state/preferences/moderation-opts'
-import {useListMembersQuery} from 'state/queries/list-members'
 import {useResolvedStarterPackShortLink} from 'state/queries/resolve-short-link'
 import {useResolveDidQuery} from 'state/queries/resolve-uri'
 import {useShortenLink} from 'state/queries/shorten-link'
@@ -123,7 +117,6 @@ export function StarterPackScreenInner({
     isLoading: isLoadingStarterPack,
     isError: isErrorStarterPack,
   } = useStarterPackQuery({did, rkey})
-  const listMembersQuery = useListMembersQuery(starterPack?.list?.uri, 50)
 
   const isValid =
     starterPack &&
@@ -134,12 +127,7 @@ export function StarterPackScreenInner({
   if (!did || !starterPack || !isValid || !moderationOpts) {
     return (
       <ListMaybePlaceholder
-        isLoading={
-          isLoadingDid ||
-          isLoadingStarterPack ||
-          listMembersQuery.isLoading ||
-          !moderationOpts
-        }
+        isLoading={isLoadingDid || isLoadingStarterPack || !moderationOpts}
         isError={isErrorDid || isErrorStarterPack || !isValid}
         errorMessage={_(msg`That starter pack could not be found.`)}
         emptyMessage={_(msg`That starter pack could not be found.`)}
@@ -155,7 +143,6 @@ export function StarterPackScreenInner({
     <StarterPackScreenLoaded
       starterPack={starterPack}
       routeParams={routeParams}
-      listMembersQuery={listMembersQuery}
       moderationOpts={moderationOpts}
     />
   )
@@ -164,14 +151,10 @@ export function StarterPackScreenInner({
 function StarterPackScreenLoaded({
   starterPack,
   routeParams,
-  listMembersQuery,
   moderationOpts,
 }: {
   starterPack: AppBskyGraphDefs.StarterPackView
   routeParams: StarterPackScreeProps['route']['params']
-  listMembersQuery: UseInfiniteQueryResult<
-    InfiniteData<AppBskyGraphGetList.OutputSchema>
-  >
   moderationOpts: ModerationOpts
 }) {
   const showPeopleTab = Boolean(starterPack.list)
@@ -242,7 +225,6 @@ function StarterPackScreenLoaded({
                   headerHeight={headerHeight}
                   // @ts-expect-error
                   scrollElRef={scrollElRef}
-                  listMembersQuery={listMembersQuery}
                   moderationOpts={moderationOpts}
                 />
               )
diff --git a/src/state/queries/starter-packs.ts b/src/state/queries/starter-packs.ts
index f441a8ed2..2cdb6b850 100644
--- a/src/state/queries/starter-packs.ts
+++ b/src/state/queries/starter-packs.ts
@@ -347,3 +347,36 @@ async function whenAppViewReady(
     () => agent.app.bsky.graph.getStarterPack({starterPack: uri}),
   )
 }
+
+export async function precacheStarterPack(
+  queryClient: QueryClient,
+  starterPack:
+    | AppBskyGraphDefs.StarterPackViewBasic
+    | AppBskyGraphDefs.StarterPackView,
+) {
+  if (!AppBskyGraphStarterpack.isRecord(starterPack.record)) {
+    return
+  }
+
+  let starterPackView: AppBskyGraphDefs.StarterPackView | undefined
+  if (AppBskyGraphDefs.isStarterPackView(starterPack)) {
+    starterPackView = starterPack
+  } else if (AppBskyGraphDefs.isStarterPackViewBasic(starterPack)) {
+    const listView: AppBskyGraphDefs.ListViewBasic = {
+      uri: starterPack.record.list,
+      // This will be populated once the data from server is fetched
+      cid: '',
+      name: starterPack.record.name,
+      purpose: 'app.bsky.graph.defs#referencelist',
+    }
+    starterPackView = {
+      ...starterPack,
+      $type: 'app.bsky.graph.defs#starterPackView',
+      list: listView,
+    }
+  }
+
+  if (starterPackView) {
+    queryClient.setQueryData(RQKEY({uri: starterPack.uri}), starterPackView)
+  }
+}
diff --git a/src/view/com/composer/useExternalLinkFetch.ts b/src/view/com/composer/useExternalLinkFetch.ts
index 743535a5e..2938ea25a 100644
--- a/src/view/com/composer/useExternalLinkFetch.ts
+++ b/src/view/com/composer/useExternalLinkFetch.ts
@@ -10,6 +10,7 @@ import {
   getFeedAsEmbed,
   getListAsEmbed,
   getPostAsQuote,
+  getStarterPackAsEmbed,
 } from 'lib/link-meta/bsky'
 import {getLinkMeta} from 'lib/link-meta/link-meta'
 import {resolveShortLink} from 'lib/link-meta/resolve-short-link'
@@ -18,6 +19,8 @@ import {
   isBskyCustomFeedUrl,
   isBskyListUrl,
   isBskyPostUrl,
+  isBskyStarterPackUrl,
+  isBskyStartUrl,
   isShortLink,
 } from 'lib/strings/url-helpers'
 import {ImageModel} from 'state/models/media/image'
@@ -96,6 +99,23 @@ export function useExternalLinkFetch({
             setExtLink(undefined)
           },
         )
+      } else if (
+        isBskyStartUrl(extLink.uri) ||
+        isBskyStarterPackUrl(extLink.uri)
+      ) {
+        getStarterPackAsEmbed(agent, fetchDid, extLink.uri).then(
+          ({embed, meta}) => {
+            if (aborted) {
+              return
+            }
+            setExtLink({
+              uri: extLink.uri,
+              isLoading: false,
+              meta,
+              embed,
+            })
+          },
+        )
       } else if (isShortLink(extLink.uri)) {
         if (isShortLink(extLink.uri)) {
           resolveShortLink(extLink.uri).then(res => {
diff --git a/src/view/com/util/post-embeds/index.tsx b/src/view/com/util/post-embeds/index.tsx
index be34a2869..942ad57b8 100644
--- a/src/view/com/util/post-embeds/index.tsx
+++ b/src/view/com/util/post-embeds/index.tsx
@@ -30,6 +30,7 @@ import {ListEmbed} from './ListEmbed'
 import {MaybeQuoteEmbed} from './QuoteEmbed'
 import hairlineWidth = StyleSheet.hairlineWidth
 import {useLargeAltBadgeEnabled} from '#/state/preferences/large-alt-badge'
+import {Embed as StarterPackCard} from '#/components/StarterPack/StarterPackCard'
 
 type Embed =
   | AppBskyEmbedRecord.View
@@ -90,6 +91,10 @@ export function PostEmbeds({
       return <ListEmbed item={embed.record} />
     }
 
+    if (AppBskyGraphDefs.isStarterPackViewBasic(embed.record)) {
+      return <StarterPackCard starterPack={embed.record} />
+    }
+
     // quote post
     // =
     return (