about summary refs log tree commit diff
path: root/src/screens/Search/components/StarterPackCard.tsx
diff options
context:
space:
mode:
authorSamuel Newman <mozzius@protonmail.com>2025-04-03 03:21:15 +0300
committerGitHub <noreply@github.com>2025-04-02 17:21:15 -0700
commit87da619aaa92e0ec762e68c13b24e58a25da10a8 (patch)
tree4da902d3ca43a226f6da8e5c090ab33c2df3297a /src/screens/Search/components/StarterPackCard.tsx
parent8d1f97b5ffac5d86762f1d4e9384ff3097acbc52 (diff)
downloadvoidsky-87da619aaa92e0ec762e68c13b24e58a25da10a8.tar.zst
[Explore] Base (#8053)
* migrate to #/screens

* rm unneeded import

* block drawer gesture on recent profiles

* rm recommendations (#8056)

* [Explore] Disable Trending videos (#8054)

* remove giant header

* disable

* [Explore] Dynamic module ordering (#8066)

* Dynamic module ordering

* [Explore] New headers, metrics (#8067)

* new sticky headers

* improve spacing between modules

* view metric on modules

* update metrics names

* [Explore] Suggested accounts module (#8072)

* use modern profile card, update load more

* add tab bar

* tabbed suggested accounts

* [Explore] Discover feeds module (#8073)

* cap number of feeds to 3

* change feed pin button

* Apply suggestions from code review

Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com>

* restore statsig to log events

* filter out followed profiles, make suer enough are loaded (#8090)

* [Explore] Trending topics (#8055)

* redesigned trending topics

* rm borders on web

* get post count / age / ranking from api

* spacing tweaks

* fetch more topics then slice

* use api data for avis/category

* rm top border

* Integrate new SDK, part out components

* Clean up

* Use status field

* Bump SDK

* Send up interests and langs

---------

Co-authored-by: Eric Bailey <git@esb.lol>

* Clean up module spacing and borders

(cherry picked from commit 63d19b6c2d67e226e0e14709b1047a1f88b3ce1c)
(cherry picked from commit 62d7d394ab1dc31b40b9c2cf59075adbf94737a1)

* Switch back border ordering

(cherry picked from commit 34e3789f8b410132c1390df3c2bb8257630ebdd9)

* [Explore] Starter Packs (#8095)

* Temp WIP

(cherry picked from commit 43b5d7b1e64b3adb1ed162262d0310e0bf026c18)

* New SP card

* Load state

* Revert change

* Cleanup

* Interests and caching

* Count total

* Format

* Caching

* [Explore] Feed previews module (#8075)

* wip new hook

* get fetching working, maybe

* get feed previews rendering!

* fix header height

* working pin button

* extract out FeedLink

* add loader

* only make preview:header sticky

* Fix headers

* Header tweaks

* Fix moderation filter

* Fix threading

---------

Co-authored-by: Eric Bailey <git@esb.lol>

* Space it out

* Fix query key

* Mock new endpoint, filter saved feeds

* Make sure we're pinning, lower cache time

* add news category

* Remove log

* Improve suggested accounts load state

* Integrate new app view endpoint

* fragment

* Update src/screens/Search/modules/ExploreTrendingTopics.tsx

Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com>

* Update src/screens/Search/modules/ExploreTrendingTopics.tsx

Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com>

* lint

* maybe fix this

---------

Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com>
Co-authored-by: Eric Bailey <git@esb.lol>
Co-authored-by: Hailey <me@haileyok.com>
Diffstat (limited to 'src/screens/Search/components/StarterPackCard.tsx')
-rw-r--r--src/screens/Search/components/StarterPackCard.tsx296
1 files changed, 296 insertions, 0 deletions
diff --git a/src/screens/Search/components/StarterPackCard.tsx b/src/screens/Search/components/StarterPackCard.tsx
new file mode 100644
index 000000000..9520dd5a7
--- /dev/null
+++ b/src/screens/Search/components/StarterPackCard.tsx
@@ -0,0 +1,296 @@
+import React from 'react'
+import {View} from 'react-native'
+import {
+  type AppBskyGraphDefs,
+  AppBskyGraphStarterpack,
+  moderateProfile,
+} from '@atproto/api'
+import {msg, Trans} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+
+import {sanitizeHandle} from '#/lib/strings/handles'
+import {useModerationOpts} from '#/state/preferences/moderation-opts'
+import {useSession} from '#/state/session'
+import {LoadingPlaceholder} from '#/view/com/util/LoadingPlaceholder'
+import {UserAvatar} from '#/view/com/util/UserAvatar'
+import {atoms as a, useBreakpoints, useTheme, web} from '#/alf'
+import {ButtonText} from '#/components/Button'
+import {PlusSmall_Stroke2_Corner0_Rounded as Plus} from '#/components/icons/Plus'
+import {Link} from '#/components/Link'
+import {MediaInsetBorder} from '#/components/MediaInsetBorder'
+import {useStarterPackLink} from '#/components/StarterPack/StarterPackCard'
+import {Text} from '#/components/Typography'
+import * as bsky from '#/types/bsky'
+
+export function StarterPackCard({
+  view,
+}: {
+  view: AppBskyGraphDefs.StarterPackView
+}) {
+  const t = useTheme()
+  const {_} = useLingui()
+  const {currentAccount} = useSession()
+  const {gtPhone} = useBreakpoints()
+  const link = useStarterPackLink({view})
+
+  if (
+    !bsky.dangerousIsType<AppBskyGraphStarterpack.Record>(
+      view.record,
+      AppBskyGraphStarterpack.isRecord,
+    )
+  ) {
+    return null
+  }
+
+  const profileCount = gtPhone ? 11 : 8
+  const profiles = view.listItemsSample
+    ?.slice(0, profileCount)
+    .map(item => item.subject)
+
+  return (
+    <View
+      style={[
+        a.w_full,
+        a.p_lg,
+        a.gap_md,
+        a.border,
+        a.rounded_sm,
+        a.overflow_hidden,
+        t.atoms.border_contrast_low,
+      ]}>
+      <View aria-hidden style={[a.absolute, a.inset_0, a.z_40]}>
+        <Link
+          to={link.to}
+          label={link.label}
+          style={[a.absolute, a.inset_0]}
+          onHoverIn={link.precache}
+          onPress={link.precache}>
+          <View />
+        </Link>
+      </View>
+
+      <AvatarStack
+        profiles={profiles ?? []}
+        numPending={profileCount}
+        total={view.list?.listItemCount}
+      />
+
+      <View
+        style={[
+          a.w_full,
+          a.flex_row,
+          a.align_start,
+          a.gap_lg,
+          web({
+            position: 'static',
+            zIndex: 'unset',
+          }),
+        ]}>
+        <View style={[a.flex_1]}>
+          <Text
+            emoji
+            style={[a.text_md, a.font_bold, a.leading_snug]}
+            numberOfLines={1}>
+            {view.record.name}
+          </Text>
+          <Text
+            emoji
+            style={[a.text_sm, a.leading_snug, t.atoms.text_contrast_medium]}
+            numberOfLines={1}>
+            {view.creator?.did === currentAccount?.did
+              ? _(msg`By you`)
+              : _(msg`By ${sanitizeHandle(view.creator.handle, '@')}`)}
+          </Text>
+        </View>
+        <Link
+          to={link.to}
+          label={link.label}
+          onHoverIn={link.precache}
+          onPress={link.precache}
+          variant="solid"
+          color="secondary"
+          size="small"
+          style={[a.z_50]}>
+          <ButtonText>
+            <Trans>Open pack</Trans>
+          </ButtonText>
+        </Link>
+      </View>
+    </View>
+  )
+}
+
+export function AvatarStack({
+  profiles,
+  numPending,
+  total,
+}: {
+  profiles: bsky.profile.AnyProfileView[]
+  numPending: number
+  total?: number
+}) {
+  const t = useTheme()
+  const {gtPhone} = useBreakpoints()
+  const moderationOpts = useModerationOpts()
+  const computedTotal = (total ?? numPending) - numPending
+  const circlesCount = numPending + 1 // add total at end
+  const widthPerc = 100 / circlesCount
+  const [size, setSize] = React.useState<number | null>(null)
+
+  const isPending = (numPending && profiles.length === 0) || !moderationOpts
+
+  const items = isPending
+    ? Array.from({length: numPending ?? circlesCount}).map((_, i) => ({
+        key: i,
+        profile: null,
+        moderation: null,
+      }))
+    : profiles.map(item => ({
+        key: item.did,
+        profile: item,
+        moderation: moderateProfile(item, moderationOpts),
+      }))
+
+  return (
+    <View
+      style={[
+        a.w_full,
+        a.flex_row,
+        a.align_center,
+        a.relative,
+        {width: `${100 - widthPerc * 0.2}%`},
+      ]}>
+      {items.map((item, i) => (
+        <View
+          key={item.key}
+          style={[
+            {
+              width: `${widthPerc}%`,
+              zIndex: 100 - i,
+            },
+          ]}>
+          <View
+            style={[
+              a.relative,
+              {
+                width: '120%',
+              },
+            ]}>
+            <View
+              onLayout={e => setSize(e.nativeEvent.layout.width)}
+              style={[
+                a.rounded_full,
+                t.atoms.bg_contrast_25,
+                {
+                  paddingTop: '100%',
+                },
+              ]}>
+              {size && item.profile ? (
+                <UserAvatar
+                  size={size}
+                  avatar={item.profile.avatar}
+                  type={item.profile.associated?.labeler ? 'labeler' : 'user'}
+                  moderation={item.moderation.ui('avatar')}
+                  style={[a.absolute, a.inset_0]}
+                />
+              ) : (
+                <MediaInsetBorder style={[a.rounded_full]} />
+              )}
+            </View>
+          </View>
+        </View>
+      ))}
+      <View
+        style={[
+          {
+            width: `${widthPerc}%`,
+            zIndex: 1,
+          },
+        ]}>
+        <View
+          style={[
+            a.relative,
+            {
+              width: '120%',
+            },
+          ]}>
+          <View
+            style={[
+              {
+                paddingTop: '100%',
+              },
+            ]}>
+            <View
+              style={[
+                a.absolute,
+                a.inset_0,
+                a.rounded_full,
+                a.align_center,
+                a.justify_center,
+                {
+                  backgroundColor: t.atoms.text_contrast_low.color,
+                },
+              ]}>
+              {computedTotal > 0 ? (
+                <Text
+                  style={[
+                    gtPhone ? a.text_md : a.text_sm,
+                    a.font_bold,
+                    a.leading_snug,
+                    {color: 'white'},
+                  ]}>
+                  <Trans comment="Indicates the number of additional profiles are in the Starter Pack e.g. +12">
+                    +{computedTotal}
+                  </Trans>
+                </Text>
+              ) : (
+                <Plus fill="white" />
+              )}
+            </View>
+          </View>
+        </View>
+      </View>
+    </View>
+  )
+}
+
+export function StarterPackCardSkeleton() {
+  const t = useTheme()
+  const {gtPhone} = useBreakpoints()
+
+  const profileCount = gtPhone ? 11 : 8
+
+  return (
+    <View
+      style={[
+        a.w_full,
+        a.p_lg,
+        a.gap_md,
+        a.border,
+        a.rounded_sm,
+        a.overflow_hidden,
+        t.atoms.border_contrast_low,
+      ]}>
+      <AvatarStack profiles={[]} numPending={profileCount} />
+
+      <View
+        style={[
+          a.w_full,
+          a.flex_row,
+          a.align_start,
+          a.gap_lg,
+          web({
+            position: 'static',
+            zIndex: 'unset',
+          }),
+        ]}>
+        <View style={[a.flex_1, a.gap_xs]}>
+          <LoadingPlaceholder width={180} height={18} />
+          <LoadingPlaceholder width={120} height={14} />
+        </View>
+
+        <LoadingPlaceholder width={100} height={33} />
+      </View>
+    </View>
+  )
+}