about summary refs log tree commit diff
path: root/src/lib
diff options
context:
space:
mode:
Diffstat (limited to 'src/lib')
-rw-r--r--src/lib/browser.native.ts1
-rw-r--r--src/lib/browser.ts2
-rw-r--r--src/lib/generate-starterpack.ts164
-rw-r--r--src/lib/hooks/useBottomBarOffset.ts14
-rw-r--r--src/lib/hooks/useNotificationHandler.ts2
-rw-r--r--src/lib/moderation/create-sanitized-display-name.ts21
-rw-r--r--src/lib/moderation/useReportOptions.ts9
-rw-r--r--src/lib/routes/links.ts17
-rw-r--r--src/lib/routes/types.ts12
-rw-r--r--src/lib/statsig/events.ts35
-rw-r--r--src/lib/statsig/gates.ts1
-rw-r--r--src/lib/strings/starter-pack.ts101
12 files changed, 377 insertions, 2 deletions
diff --git a/src/lib/browser.native.ts b/src/lib/browser.native.ts
index fb9be56f1..8e045138c 100644
--- a/src/lib/browser.native.ts
+++ b/src/lib/browser.native.ts
@@ -1,3 +1,4 @@
 export const isSafari = false
 export const isFirefox = false
 export const isTouchDevice = true
+export const isAndroidWeb = false
diff --git a/src/lib/browser.ts b/src/lib/browser.ts
index d178a9a64..08c43fbfd 100644
--- a/src/lib/browser.ts
+++ b/src/lib/browser.ts
@@ -5,3 +5,5 @@ export const isSafari = /^((?!chrome|android).)*safari/i.test(
 export const isFirefox = /firefox|fxios/i.test(navigator.userAgent)
 export const isTouchDevice =
   'ontouchstart' in window || navigator.maxTouchPoints > 1
+export const isAndroidWeb =
+  /android/i.test(navigator.userAgent) && isTouchDevice
diff --git a/src/lib/generate-starterpack.ts b/src/lib/generate-starterpack.ts
new file mode 100644
index 000000000..64d30a954
--- /dev/null
+++ b/src/lib/generate-starterpack.ts
@@ -0,0 +1,164 @@
+import {
+  AppBskyActorDefs,
+  AppBskyGraphGetStarterPack,
+  BskyAgent,
+  Facet,
+} from '@atproto/api'
+import {msg} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+import {useMutation} from '@tanstack/react-query'
+
+import {until} from 'lib/async/until'
+import {sanitizeDisplayName} from 'lib/strings/display-names'
+import {sanitizeHandle} from 'lib/strings/handles'
+import {enforceLen} from 'lib/strings/helpers'
+import {useAgent} from 'state/session'
+
+export const createStarterPackList = async ({
+  name,
+  description,
+  descriptionFacets,
+  profiles,
+  agent,
+}: {
+  name: string
+  description?: string
+  descriptionFacets?: Facet[]
+  profiles: AppBskyActorDefs.ProfileViewBasic[]
+  agent: BskyAgent
+}): Promise<{uri: string; cid: string}> => {
+  if (profiles.length === 0) throw new Error('No profiles given')
+
+  const list = await agent.app.bsky.graph.list.create(
+    {repo: agent.session!.did},
+    {
+      name,
+      description,
+      descriptionFacets,
+      avatar: undefined,
+      createdAt: new Date().toISOString(),
+      purpose: 'app.bsky.graph.defs#referencelist',
+    },
+  )
+  if (!list) throw new Error('List creation failed')
+  await agent.com.atproto.repo.applyWrites({
+    repo: agent.session!.did,
+    writes: [
+      createListItem({did: agent.session!.did, listUri: list.uri}),
+    ].concat(
+      profiles
+        // Ensure we don't have ourselves in this list twice
+        .filter(p => p.did !== agent.session!.did)
+        .map(p => createListItem({did: p.did, listUri: list.uri})),
+    ),
+  })
+
+  return list
+}
+
+export function useGenerateStarterPackMutation({
+  onSuccess,
+  onError,
+}: {
+  onSuccess: ({uri, cid}: {uri: string; cid: string}) => void
+  onError: (e: Error) => void
+}) {
+  const {_} = useLingui()
+  const agent = useAgent()
+  const starterPackString = _(msg`Starter Pack`)
+
+  return useMutation<{uri: string; cid: string}, Error, void>({
+    mutationFn: async () => {
+      let profile: AppBskyActorDefs.ProfileViewBasic | undefined
+      let profiles: AppBskyActorDefs.ProfileViewBasic[] | undefined
+
+      await Promise.all([
+        (async () => {
+          profile = (
+            await agent.app.bsky.actor.getProfile({
+              actor: agent.session!.did,
+            })
+          ).data
+        })(),
+        (async () => {
+          profiles = (
+            await agent.app.bsky.actor.searchActors({
+              q: encodeURIComponent('*'),
+              limit: 49,
+            })
+          ).data.actors.filter(p => p.viewer?.following)
+        })(),
+      ])
+
+      if (!profile || !profiles) {
+        throw new Error('ERROR_DATA')
+      }
+
+      // We include ourselves when we make the list
+      if (profiles.length < 7) {
+        throw new Error('NOT_ENOUGH_FOLLOWERS')
+      }
+
+      const displayName = enforceLen(
+        profile.displayName
+          ? sanitizeDisplayName(profile.displayName)
+          : `@${sanitizeHandle(profile.handle)}`,
+        25,
+        true,
+      )
+      const starterPackName = `${displayName}'s ${starterPackString}`
+
+      const list = await createStarterPackList({
+        name: starterPackName,
+        profiles,
+        agent,
+      })
+
+      return await agent.app.bsky.graph.starterpack.create(
+        {
+          repo: agent.session!.did,
+        },
+        {
+          name: starterPackName,
+          list: list.uri,
+          createdAt: new Date().toISOString(),
+        },
+      )
+    },
+    onSuccess: async data => {
+      await whenAppViewReady(agent, data.uri, v => {
+        return typeof v?.data.starterPack.uri === 'string'
+      })
+      onSuccess(data)
+    },
+    onError: error => {
+      onError(error)
+    },
+  })
+}
+
+function createListItem({did, listUri}: {did: string; listUri: string}) {
+  return {
+    $type: 'com.atproto.repo.applyWrites#create',
+    collection: 'app.bsky.graph.listitem',
+    value: {
+      $type: 'app.bsky.graph.listitem',
+      subject: did,
+      list: listUri,
+      createdAt: new Date().toISOString(),
+    },
+  }
+}
+
+async function whenAppViewReady(
+  agent: BskyAgent,
+  uri: string,
+  fn: (res?: AppBskyGraphGetStarterPack.Response) => boolean,
+) {
+  await until(
+    5, // 5 tries
+    1e3, // 1s delay between tries
+    fn,
+    () => agent.app.bsky.graph.getStarterPack({starterPack: uri}),
+  )
+}
diff --git a/src/lib/hooks/useBottomBarOffset.ts b/src/lib/hooks/useBottomBarOffset.ts
new file mode 100644
index 000000000..945c98062
--- /dev/null
+++ b/src/lib/hooks/useBottomBarOffset.ts
@@ -0,0 +1,14 @@
+import {useSafeAreaInsets} from 'react-native-safe-area-context'
+
+import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
+import {clamp} from 'lib/numbers'
+import {isWeb} from 'platform/detection'
+
+export function useBottomBarOffset(modifier: number = 0) {
+  const {isTabletOrDesktop} = useWebMediaQueries()
+  const {bottom: bottomInset} = useSafeAreaInsets()
+  return (
+    (isWeb && isTabletOrDesktop ? 0 : clamp(60 + bottomInset, 60, 75)) +
+    modifier
+  )
+}
diff --git a/src/lib/hooks/useNotificationHandler.ts b/src/lib/hooks/useNotificationHandler.ts
index 347062beb..e4e7e1474 100644
--- a/src/lib/hooks/useNotificationHandler.ts
+++ b/src/lib/hooks/useNotificationHandler.ts
@@ -26,6 +26,7 @@ type NotificationReason =
   | 'reply'
   | 'quote'
   | 'chat-message'
+  | 'starterpack-joined'
 
 type NotificationPayload =
   | {
@@ -142,6 +143,7 @@ export function useNotificationsHandler() {
           case 'mention':
           case 'quote':
           case 'reply':
+          case 'starterpack-joined':
             resetToTab('NotificationsTab')
             break
           // TODO implement these after we have an idea of how to handle each individual case
diff --git a/src/lib/moderation/create-sanitized-display-name.ts b/src/lib/moderation/create-sanitized-display-name.ts
new file mode 100644
index 000000000..16135b274
--- /dev/null
+++ b/src/lib/moderation/create-sanitized-display-name.ts
@@ -0,0 +1,21 @@
+import {AppBskyActorDefs} from '@atproto/api'
+
+import {sanitizeDisplayName} from 'lib/strings/display-names'
+import {sanitizeHandle} from 'lib/strings/handles'
+
+export function createSanitizedDisplayName(
+  profile:
+    | AppBskyActorDefs.ProfileViewBasic
+    | AppBskyActorDefs.ProfileViewDetailed,
+  noAt = false,
+) {
+  if (profile.displayName != null && profile.displayName !== '') {
+    return sanitizeDisplayName(profile.displayName)
+  } else {
+    let sanitizedHandle = sanitizeHandle(profile.handle)
+    if (!noAt) {
+      sanitizedHandle = `@${sanitizedHandle}`
+    }
+    return sanitizedHandle
+  }
+}
diff --git a/src/lib/moderation/useReportOptions.ts b/src/lib/moderation/useReportOptions.ts
index 54b727b76..91656857e 100644
--- a/src/lib/moderation/useReportOptions.ts
+++ b/src/lib/moderation/useReportOptions.ts
@@ -13,6 +13,7 @@ interface ReportOptions {
   account: ReportOption[]
   post: ReportOption[]
   list: ReportOption[]
+  starterpack: ReportOption[]
   feedgen: ReportOption[]
   other: ReportOption[]
   convoMessage: ReportOption[]
@@ -94,6 +95,14 @@ export function useReportOptions(): ReportOptions {
         },
         ...common,
       ],
+      starterpack: [
+        {
+          reason: ComAtprotoModerationDefs.REASONVIOLATION,
+          title: _(msg`Name or Description Violates Community Standards`),
+          description: _(msg`Terms used violate community standards`),
+        },
+        ...common,
+      ],
       feedgen: [
         {
           reason: ComAtprotoModerationDefs.REASONVIOLATION,
diff --git a/src/lib/routes/links.ts b/src/lib/routes/links.ts
index 9dfdab909..56b716677 100644
--- a/src/lib/routes/links.ts
+++ b/src/lib/routes/links.ts
@@ -1,3 +1,5 @@
+import {AppBskyGraphDefs, AtUri} from '@atproto/api'
+
 import {isInvalidHandle} from 'lib/strings/handles'
 
 export function makeProfileLink(
@@ -35,3 +37,18 @@ export function makeSearchLink(props: {query: string; from?: 'me' | string}) {
     props.query + (props.from ? ` from:${props.from}` : ''),
   )}`
 }
+
+export function makeStarterPackLink(
+  starterPackOrName:
+    | AppBskyGraphDefs.StarterPackViewBasic
+    | AppBskyGraphDefs.StarterPackView
+    | string,
+  rkey?: string,
+) {
+  if (typeof starterPackOrName === 'string') {
+    return `https://bsky.app/start/${starterPackOrName}/${rkey}`
+  } else {
+    const uriRkey = new AtUri(starterPackOrName.uri).rkey
+    return `https://bsky.app/start/${starterPackOrName.creator.handle}/${uriRkey}`
+  }
+}
diff --git a/src/lib/routes/types.ts b/src/lib/routes/types.ts
index 403c2bb67..8a173b675 100644
--- a/src/lib/routes/types.ts
+++ b/src/lib/routes/types.ts
@@ -42,6 +42,12 @@ export type CommonNavigatorParams = {
   MessagesConversation: {conversation: string; embed?: string}
   MessagesSettings: undefined
   Feeds: undefined
+  Start: {name: string; rkey: string}
+  StarterPack: {name: string; rkey: string; new?: boolean}
+  StarterPackWizard: undefined
+  StarterPackEdit: {
+    rkey?: string
+  }
 }
 
 export type BottomTabNavigatorParams = CommonNavigatorParams & {
@@ -93,6 +99,12 @@ export type AllNavigatorParams = CommonNavigatorParams & {
   Hashtag: {tag: string; author?: string}
   MessagesTab: undefined
   Messages: {animation?: 'push' | 'pop'}
+  Start: {name: string; rkey: string}
+  StarterPack: {name: string; rkey: string; new?: boolean}
+  StarterPackWizard: undefined
+  StarterPackEdit: {
+    rkey?: string
+  }
 }
 
 // NOTE
diff --git a/src/lib/statsig/events.ts b/src/lib/statsig/events.ts
index 2e8cedb54..07ed8c0ca 100644
--- a/src/lib/statsig/events.ts
+++ b/src/lib/statsig/events.ts
@@ -53,7 +53,14 @@ export type LogEvents = {
   }
   'onboarding:moderation:nextPressed': {}
   'onboarding:profile:nextPressed': {}
-  'onboarding:finished:nextPressed': {}
+  'onboarding:finished:nextPressed': {
+    usedStarterPack: boolean
+    starterPackName?: string
+    starterPackCreator?: string
+    starterPackUri?: string
+    profilesFollowed: number
+    feedsPinned: number
+  }
   'onboarding:finished:avatarResult': {
     avatarResult: 'default' | 'created' | 'uploaded'
   }
@@ -61,7 +68,12 @@ export type LogEvents = {
     feedUrl: string
     feedType: string
     index: number
-    reason: 'focus' | 'tabbar-click' | 'pager-swipe' | 'desktop-sidebar-click'
+    reason:
+      | 'focus'
+      | 'tabbar-click'
+      | 'pager-swipe'
+      | 'desktop-sidebar-click'
+      | 'starter-pack-initial-feed'
   }
   'feed:endReached:sampled': {
     feedUrl: string
@@ -134,6 +146,7 @@ export type LogEvents = {
       | 'ProfileMenu'
       | 'ProfileHoverCard'
       | 'AvatarButton'
+      | 'StarterPackProfilesList'
   }
   'profile:unfollow': {
     logContext:
@@ -146,6 +159,7 @@ export type LogEvents = {
       | 'ProfileHoverCard'
       | 'Chat'
       | 'AvatarButton'
+      | 'StarterPackProfilesList'
   }
   'chat:create': {
     logContext: 'ProfileHeader' | 'NewChatDialog' | 'SendViaChatDialog'
@@ -157,6 +171,23 @@ export type LogEvents = {
       | 'ChatsList'
       | 'SendViaChatDialog'
   }
+  'starterPack:share': {
+    starterPack: string
+    shareType: 'link' | 'qrcode'
+    qrShareType?: 'save' | 'copy' | 'share'
+  }
+  'starterPack:followAll': {
+    logContext: 'StarterPackProfilesList' | 'Onboarding'
+    starterPack: string
+    count: number
+  }
+  'starterPack:delete': {}
+  'starterPack:create': {
+    setName: boolean
+    setDescription: boolean
+    profilesCount: number
+    feedsCount: number
+  }
 
   'test:all:always': {}
   'test:all:sometimes': {}
diff --git a/src/lib/statsig/gates.ts b/src/lib/statsig/gates.ts
index 46ef934ef..bf2484ccb 100644
--- a/src/lib/statsig/gates.ts
+++ b/src/lib/statsig/gates.ts
@@ -5,3 +5,4 @@ export type Gate =
   | 'request_notifications_permission_after_onboarding_v2'
   | 'show_avi_follow_button'
   | 'show_follow_back_label_v2'
+  | 'starter_packs_enabled'
diff --git a/src/lib/strings/starter-pack.ts b/src/lib/strings/starter-pack.ts
new file mode 100644
index 000000000..489d0b923
--- /dev/null
+++ b/src/lib/strings/starter-pack.ts
@@ -0,0 +1,101 @@
+import {AppBskyGraphDefs, AtUri} from '@atproto/api'
+
+export function createStarterPackLinkFromAndroidReferrer(
+  referrerQueryString: string,
+): string | null {
+  try {
+    // The referrer string is just some URL parameters, so lets add them to a fake URL
+    const url = new URL('http://throwaway.com/?' + referrerQueryString)
+    const utmContent = url.searchParams.get('utm_content')
+    const utmSource = url.searchParams.get('utm_source')
+
+    if (!utmContent) return null
+    if (utmSource !== 'bluesky') return null
+
+    // This should be a string like `starterpack_haileyok.com_rkey`
+    const contentParts = utmContent.split('_')
+
+    if (contentParts[0] !== 'starterpack') return null
+    if (contentParts.length !== 3) return null
+
+    return `at://${contentParts[1]}/app.bsky.graph.starterpack/${contentParts[2]}`
+  } catch (e) {
+    return null
+  }
+}
+
+export function parseStarterPackUri(uri?: string): {
+  name: string
+  rkey: string
+} | null {
+  if (!uri) return null
+
+  try {
+    if (uri.startsWith('at://')) {
+      const atUri = new AtUri(uri)
+      if (atUri.collection !== 'app.bsky.graph.starterpack') return null
+      if (atUri.rkey) {
+        return {
+          name: atUri.hostname,
+          rkey: atUri.rkey,
+        }
+      }
+      return null
+    } else {
+      const url = new URL(uri)
+      const parts = url.pathname.split('/')
+      const [_, path, name, rkey] = parts
+
+      if (parts.length !== 4) return null
+      if (path !== 'starter-pack' && path !== 'start') return null
+      if (!name || !rkey) return null
+      return {
+        name,
+        rkey,
+      }
+    }
+  } catch (e) {
+    return null
+  }
+}
+
+export function createStarterPackGooglePlayUri(
+  name: string,
+  rkey: string,
+): string | null {
+  if (!name || !rkey) return null
+  return `https://play.google.com/store/apps/details?id=xyz.blueskyweb.app&referrer=utm_source%3Dbluesky%26utm_medium%3Dstarterpack%26utm_content%3Dstarterpack_${name}_${rkey}`
+}
+
+export function httpStarterPackUriToAtUri(httpUri?: string): string | null {
+  if (!httpUri) return null
+
+  const parsed = parseStarterPackUri(httpUri)
+  if (!parsed) return null
+
+  if (httpUri.startsWith('at://')) return httpUri
+
+  return `at://${parsed.name}/app.bsky.graph.starterpack/${parsed.rkey}`
+}
+
+export function getStarterPackOgCard(
+  didOrStarterPack: AppBskyGraphDefs.StarterPackView | string,
+  rkey?: string,
+) {
+  if (typeof didOrStarterPack === 'string') {
+    return `https://ogcard.cdn.bsky.app/start/${didOrStarterPack}/${rkey}`
+  } else {
+    const rkey = new AtUri(didOrStarterPack.uri).rkey
+    return `https://ogcard.cdn.bsky.app/start/${didOrStarterPack.creator.did}/${rkey}`
+  }
+}
+
+export function createStarterPackUri({
+  did,
+  rkey,
+}: {
+  did: string
+  rkey: string
+}): string | null {
+  return new AtUri(`at://${did}/app.bsky.graph.starterpack/${rkey}`).toString()
+}