about summary refs log tree commit diff
path: root/src/state
diff options
context:
space:
mode:
Diffstat (limited to 'src/state')
-rw-r--r--src/state/persisted/schema.ts2
-rw-r--r--src/state/preferences/index.tsx5
-rw-r--r--src/state/preferences/used-starter-packs.tsx37
-rw-r--r--src/state/queries/actor-search.ts46
-rw-r--r--src/state/queries/actor-starter-packs.ts47
-rw-r--r--src/state/queries/feed.ts17
-rw-r--r--src/state/queries/list-members.ts19
-rw-r--r--src/state/queries/notifications/feed.ts11
-rw-r--r--src/state/queries/notifications/types.ts49
-rw-r--r--src/state/queries/notifications/util.ts83
-rw-r--r--src/state/queries/profile-lists.ts10
-rw-r--r--src/state/queries/shorten-link.ts23
-rw-r--r--src/state/queries/starter-packs.ts317
-rw-r--r--src/state/session/agent.ts12
-rw-r--r--src/state/shell/logged-out.tsx17
-rw-r--r--src/state/shell/starter-pack.tsx25
16 files changed, 659 insertions, 61 deletions
diff --git a/src/state/persisted/schema.ts b/src/state/persisted/schema.ts
index c942828f2..88fc370a6 100644
--- a/src/state/persisted/schema.ts
+++ b/src/state/persisted/schema.ts
@@ -88,6 +88,7 @@ export const schema = z.object({
   disableHaptics: z.boolean().optional(),
   disableAutoplay: z.boolean().optional(),
   kawaii: z.boolean().optional(),
+  hasCheckedForStarterPack: z.boolean().optional(),
   /** @deprecated */
   mutedThreads: z.array(z.string()),
 })
@@ -129,4 +130,5 @@ export const defaults: Schema = {
   disableHaptics: false,
   disableAutoplay: prefersReducedMotion,
   kawaii: false,
+  hasCheckedForStarterPack: false,
 }
diff --git a/src/state/preferences/index.tsx b/src/state/preferences/index.tsx
index e1a35f193..e6b53d5be 100644
--- a/src/state/preferences/index.tsx
+++ b/src/state/preferences/index.tsx
@@ -9,6 +9,7 @@ import {Provider as InAppBrowserProvider} from './in-app-browser'
 import {Provider as KawaiiProvider} from './kawaii'
 import {Provider as LanguagesProvider} from './languages'
 import {Provider as LargeAltBadgeProvider} from './large-alt-badge'
+import {Provider as UsedStarterPacksProvider} from './used-starter-packs'
 
 export {
   useRequireAltTextEnabled,
@@ -34,7 +35,9 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
               <InAppBrowserProvider>
                 <DisableHapticsProvider>
                   <AutoplayProvider>
-                    <KawaiiProvider>{children}</KawaiiProvider>
+                    <UsedStarterPacksProvider>
+                      <KawaiiProvider>{children}</KawaiiProvider>
+                    </UsedStarterPacksProvider>
                   </AutoplayProvider>
                 </DisableHapticsProvider>
               </InAppBrowserProvider>
diff --git a/src/state/preferences/used-starter-packs.tsx b/src/state/preferences/used-starter-packs.tsx
new file mode 100644
index 000000000..8d5d9e828
--- /dev/null
+++ b/src/state/preferences/used-starter-packs.tsx
@@ -0,0 +1,37 @@
+import React from 'react'
+
+import * as persisted from '#/state/persisted'
+
+type StateContext = boolean | undefined
+type SetContext = (v: boolean) => void
+
+const stateContext = React.createContext<StateContext>(false)
+const setContext = React.createContext<SetContext>((_: boolean) => {})
+
+export function Provider({children}: {children: React.ReactNode}) {
+  const [state, setState] = React.useState<StateContext>(() =>
+    persisted.get('hasCheckedForStarterPack'),
+  )
+
+  const setStateWrapped = (v: boolean) => {
+    setState(v)
+    persisted.write('hasCheckedForStarterPack', v)
+  }
+
+  React.useEffect(() => {
+    return persisted.onUpdate(() => {
+      setState(persisted.get('hasCheckedForStarterPack'))
+    })
+  }, [])
+
+  return (
+    <stateContext.Provider value={state}>
+      <setContext.Provider value={setStateWrapped}>
+        {children}
+      </setContext.Provider>
+    </stateContext.Provider>
+  )
+}
+
+export const useHasCheckedForStarterPack = () => React.useContext(stateContext)
+export const useSetHasCheckedForStarterPack = () => React.useContext(setContext)
diff --git a/src/state/queries/actor-search.ts b/src/state/queries/actor-search.ts
index 1e301a1ba..479fc1a9f 100644
--- a/src/state/queries/actor-search.ts
+++ b/src/state/queries/actor-search.ts
@@ -1,5 +1,11 @@
-import {AppBskyActorDefs} from '@atproto/api'
-import {QueryClient, useQuery} from '@tanstack/react-query'
+import {AppBskyActorDefs, AppBskyActorSearchActors} from '@atproto/api'
+import {
+  InfiniteData,
+  QueryClient,
+  QueryKey,
+  useInfiniteQuery,
+  useQuery,
+} from '@tanstack/react-query'
 
 import {STALE} from '#/state/queries'
 import {useAgent} from '#/state/session'
@@ -7,6 +13,11 @@ import {useAgent} from '#/state/session'
 const RQKEY_ROOT = 'actor-search'
 export const RQKEY = (query: string) => [RQKEY_ROOT, query]
 
+export const RQKEY_PAGINATED = (query: string) => [
+  `${RQKEY_ROOT}_paginated`,
+  query,
+]
+
 export function useActorSearch({
   query,
   enabled,
@@ -28,6 +39,37 @@ export function useActorSearch({
   })
 }
 
+export function useActorSearchPaginated({
+  query,
+  enabled,
+}: {
+  query: string
+  enabled?: boolean
+}) {
+  const agent = useAgent()
+  return useInfiniteQuery<
+    AppBskyActorSearchActors.OutputSchema,
+    Error,
+    InfiniteData<AppBskyActorSearchActors.OutputSchema>,
+    QueryKey,
+    string | undefined
+  >({
+    staleTime: STALE.MINUTES.FIVE,
+    queryKey: RQKEY_PAGINATED(query),
+    queryFn: async ({pageParam}) => {
+      const res = await agent.searchActors({
+        q: query,
+        limit: 25,
+        cursor: pageParam,
+      })
+      return res.data
+    },
+    enabled: enabled && !!query,
+    initialPageParam: undefined,
+    getNextPageParam: lastPage => lastPage.cursor,
+  })
+}
+
 export function* findAllProfilesInQueryData(
   queryClient: QueryClient,
   did: string,
diff --git a/src/state/queries/actor-starter-packs.ts b/src/state/queries/actor-starter-packs.ts
new file mode 100644
index 000000000..9de80b07d
--- /dev/null
+++ b/src/state/queries/actor-starter-packs.ts
@@ -0,0 +1,47 @@
+import {AppBskyGraphGetActorStarterPacks} from '@atproto/api'
+import {
+  InfiniteData,
+  QueryClient,
+  QueryKey,
+  useInfiniteQuery,
+} from '@tanstack/react-query'
+
+import {useAgent} from 'state/session'
+
+const RQKEY_ROOT = 'actor-starter-packs'
+export const RQKEY = (did?: string) => [RQKEY_ROOT, did]
+
+export function useActorStarterPacksQuery({did}: {did?: string}) {
+  const agent = useAgent()
+
+  return useInfiniteQuery<
+    AppBskyGraphGetActorStarterPacks.OutputSchema,
+    Error,
+    InfiniteData<AppBskyGraphGetActorStarterPacks.OutputSchema>,
+    QueryKey,
+    string | undefined
+  >({
+    queryKey: RQKEY(did),
+    queryFn: async ({pageParam}: {pageParam?: string}) => {
+      const res = await agent.app.bsky.graph.getActorStarterPacks({
+        actor: did!,
+        limit: 10,
+        cursor: pageParam,
+      })
+      return res.data
+    },
+    enabled: Boolean(did),
+    initialPageParam: undefined,
+    getNextPageParam: lastPage => lastPage.cursor,
+  })
+}
+
+export async function invalidateActorStarterPacksQuery({
+  queryClient,
+  did,
+}: {
+  queryClient: QueryClient
+  did: string
+}) {
+  await queryClient.invalidateQueries({queryKey: RQKEY(did)})
+}
diff --git a/src/state/queries/feed.ts b/src/state/queries/feed.ts
index e5d615177..dea6f5d77 100644
--- a/src/state/queries/feed.ts
+++ b/src/state/queries/feed.ts
@@ -9,6 +9,7 @@ import {
 } from '@atproto/api'
 import {
   InfiniteData,
+  keepPreviousData,
   QueryClient,
   QueryKey,
   useInfiniteQuery,
@@ -315,6 +316,22 @@ export function useSearchPopularFeedsMutation() {
   })
 }
 
+export function useSearchPopularFeedsQuery({q}: {q: string}) {
+  const agent = useAgent()
+  return useQuery({
+    queryKey: ['searchPopularFeeds', q],
+    queryFn: async () => {
+      const res = await agent.app.bsky.unspecced.getPopularFeedGenerators({
+        limit: 15,
+        query: q,
+      })
+
+      return res.data.feeds
+    },
+    placeholderData: keepPreviousData,
+  })
+}
+
 const popularFeedsSearchQueryKeyRoot = 'popularFeedsSearch'
 export const createPopularFeedsSearchQueryKey = (query: string) => [
   popularFeedsSearchQueryKeyRoot,
diff --git a/src/state/queries/list-members.ts b/src/state/queries/list-members.ts
index de9a36ab7..3131a2ec3 100644
--- a/src/state/queries/list-members.ts
+++ b/src/state/queries/list-members.ts
@@ -15,7 +15,7 @@ type RQPageParam = string | undefined
 const RQKEY_ROOT = 'list-members'
 export const RQKEY = (uri: string) => [RQKEY_ROOT, uri]
 
-export function useListMembersQuery(uri: string) {
+export function useListMembersQuery(uri?: string, limit: number = PAGE_SIZE) {
   const agent = useAgent()
   return useInfiniteQuery<
     AppBskyGraphGetList.OutputSchema,
@@ -25,20 +25,31 @@ export function useListMembersQuery(uri: string) {
     RQPageParam
   >({
     staleTime: STALE.MINUTES.ONE,
-    queryKey: RQKEY(uri),
+    queryKey: RQKEY(uri ?? ''),
     async queryFn({pageParam}: {pageParam: RQPageParam}) {
       const res = await agent.app.bsky.graph.getList({
-        list: uri,
-        limit: PAGE_SIZE,
+        list: uri!, // the enabled flag will prevent this from running until uri is set
+        limit,
         cursor: pageParam,
       })
       return res.data
     },
     initialPageParam: undefined,
     getNextPageParam: lastPage => lastPage.cursor,
+    enabled: Boolean(uri),
   })
 }
 
+export async function invalidateListMembersQuery({
+  queryClient,
+  uri,
+}: {
+  queryClient: QueryClient
+  uri: string
+}) {
+  await queryClient.invalidateQueries({queryKey: RQKEY(uri)})
+}
+
 export function* findAllProfilesInQueryData(
   queryClient: QueryClient,
   did: string,
diff --git a/src/state/queries/notifications/feed.ts b/src/state/queries/notifications/feed.ts
index 0607f07a1..13ca3ffde 100644
--- a/src/state/queries/notifications/feed.ts
+++ b/src/state/queries/notifications/feed.ts
@@ -155,8 +155,10 @@ export function* findAllPostsInQueryData(
 
     for (const page of queryData?.pages) {
       for (const item of page.items) {
-        if (item.subject && didOrHandleUriMatches(atUri, item.subject)) {
-          yield item.subject
+        if (item.type !== 'starterpack-joined') {
+          if (item.subject && didOrHandleUriMatches(atUri, item.subject)) {
+            yield item.subject
+          }
         }
 
         const quotedPost = getEmbeddedPost(item.subject?.embed)
@@ -181,7 +183,10 @@ export function* findAllProfilesInQueryData(
     }
     for (const page of queryData?.pages) {
       for (const item of page.items) {
-        if (item.subject?.author.did === did) {
+        if (
+          item.type !== 'starterpack-joined' &&
+          item.subject?.author.did === did
+        ) {
           yield item.subject.author
         }
         const quotedPost = getEmbeddedPost(item.subject?.embed)
diff --git a/src/state/queries/notifications/types.ts b/src/state/queries/notifications/types.ts
index 812236cf0..d40a07b12 100644
--- a/src/state/queries/notifications/types.ts
+++ b/src/state/queries/notifications/types.ts
@@ -1,26 +1,22 @@
 import {
-  AppBskyNotificationListNotifications,
   AppBskyFeedDefs,
+  AppBskyGraphDefs,
+  AppBskyNotificationListNotifications,
 } from '@atproto/api'
 
 export type NotificationType =
-  | 'post-like'
-  | 'feedgen-like'
-  | 'repost'
-  | 'mention'
-  | 'reply'
-  | 'quote'
-  | 'follow'
-  | 'unknown'
+  | StarterPackNotificationType
+  | OtherNotificationType
 
-export interface FeedNotification {
-  _reactKey: string
-  type: NotificationType
-  notification: AppBskyNotificationListNotifications.Notification
-  additional?: AppBskyNotificationListNotifications.Notification[]
-  subjectUri?: string
-  subject?: AppBskyFeedDefs.PostView
-}
+export type FeedNotification =
+  | (FeedNotificationBase & {
+      type: StarterPackNotificationType
+      subject?: AppBskyGraphDefs.StarterPackViewBasic
+    })
+  | (FeedNotificationBase & {
+      type: OtherNotificationType
+      subject?: AppBskyFeedDefs.PostView
+    })
 
 export interface FeedPage {
   cursor: string | undefined
@@ -37,3 +33,22 @@ export interface CachedFeedPage {
   data: FeedPage | undefined
   unreadCount: number
 }
+
+type StarterPackNotificationType = 'starterpack-joined'
+type OtherNotificationType =
+  | 'post-like'
+  | 'repost'
+  | 'mention'
+  | 'reply'
+  | 'quote'
+  | 'follow'
+  | 'feedgen-like'
+  | 'unknown'
+
+type FeedNotificationBase = {
+  _reactKey: string
+  notification: AppBskyNotificationListNotifications.Notification
+  additional?: AppBskyNotificationListNotifications.Notification[]
+  subjectUri?: string
+  subject?: AppBskyFeedDefs.PostView | AppBskyGraphDefs.StarterPackViewBasic
+}
diff --git a/src/state/queries/notifications/util.ts b/src/state/queries/notifications/util.ts
index 8ed1c0390..ade98b317 100644
--- a/src/state/queries/notifications/util.ts
+++ b/src/state/queries/notifications/util.ts
@@ -3,6 +3,8 @@ import {
   AppBskyFeedLike,
   AppBskyFeedPost,
   AppBskyFeedRepost,
+  AppBskyGraphDefs,
+  AppBskyGraphStarterpack,
   AppBskyNotificationListNotifications,
   BskyAgent,
   moderateNotification,
@@ -40,6 +42,7 @@ export async function fetchPage({
     limit,
     cursor,
   })
+
   const indexedAt = res.data.notifications[0]?.indexedAt
 
   // filter out notifs by mod rules
@@ -56,9 +59,18 @@ export async function fetchPage({
     const subjects = await fetchSubjects(agent, notifsGrouped)
     for (const notif of notifsGrouped) {
       if (notif.subjectUri) {
-        notif.subject = subjects.get(notif.subjectUri)
-        if (notif.subject) {
-          precacheProfile(queryClient, notif.subject.author)
+        if (
+          notif.type === 'starterpack-joined' &&
+          notif.notification.reasonSubject
+        ) {
+          notif.subject = subjects.starterPacks.get(
+            notif.notification.reasonSubject,
+          )
+        } else {
+          notif.subject = subjects.posts.get(notif.subjectUri)
+          if (notif.subject) {
+            precacheProfile(queryClient, notif.subject.author)
+          }
         }
       }
     }
@@ -120,12 +132,21 @@ export function groupNotifications(
     }
     if (!grouped) {
       const type = toKnownType(notif)
-      groupedNotifs.push({
-        _reactKey: `notif-${notif.uri}`,
-        type,
-        notification: notif,
-        subjectUri: getSubjectUri(type, notif),
-      })
+      if (type !== 'starterpack-joined') {
+        groupedNotifs.push({
+          _reactKey: `notif-${notif.uri}`,
+          type,
+          notification: notif,
+          subjectUri: getSubjectUri(type, notif),
+        })
+      } else {
+        groupedNotifs.push({
+          _reactKey: `notif-${notif.uri}`,
+          type: 'starterpack-joined',
+          notification: notif,
+          subjectUri: notif.uri,
+        })
+      }
     }
   }
   return groupedNotifs
@@ -134,29 +155,54 @@ export function groupNotifications(
 async function fetchSubjects(
   agent: BskyAgent,
   groupedNotifs: FeedNotification[],
-): Promise<Map<string, AppBskyFeedDefs.PostView>> {
-  const uris = new Set<string>()
+): Promise<{
+  posts: Map<string, AppBskyFeedDefs.PostView>
+  starterPacks: Map<string, AppBskyGraphDefs.StarterPackViewBasic>
+}> {
+  const postUris = new Set<string>()
+  const packUris = new Set<string>()
   for (const notif of groupedNotifs) {
     if (notif.subjectUri?.includes('app.bsky.feed.post')) {
-      uris.add(notif.subjectUri)
+      postUris.add(notif.subjectUri)
+    } else if (
+      notif.notification.reasonSubject?.includes('app.bsky.graph.starterpack')
+    ) {
+      packUris.add(notif.notification.reasonSubject)
     }
   }
-  const uriChunks = chunk(Array.from(uris), 25)
+  const postUriChunks = chunk(Array.from(postUris), 25)
+  const packUriChunks = chunk(Array.from(packUris), 25)
   const postsChunks = await Promise.all(
-    uriChunks.map(uris =>
+    postUriChunks.map(uris =>
       agent.app.bsky.feed.getPosts({uris}).then(res => res.data.posts),
     ),
   )
-  const map = new Map<string, AppBskyFeedDefs.PostView>()
+  const packsChunks = await Promise.all(
+    packUriChunks.map(uris =>
+      agent.app.bsky.graph
+        .getStarterPacks({uris})
+        .then(res => res.data.starterPacks),
+    ),
+  )
+  const postsMap = new Map<string, AppBskyFeedDefs.PostView>()
+  const packsMap = new Map<string, AppBskyGraphDefs.StarterPackView>()
   for (const post of postsChunks.flat()) {
     if (
       AppBskyFeedPost.isRecord(post.record) &&
       AppBskyFeedPost.validateRecord(post.record).success
     ) {
-      map.set(post.uri, post)
+      postsMap.set(post.uri, post)
+    }
+  }
+  for (const pack of packsChunks.flat()) {
+    if (AppBskyGraphStarterpack.isRecord(pack.record)) {
+      packsMap.set(pack.uri, pack)
     }
   }
-  return map
+  return {
+    posts: postsMap,
+    starterPacks: packsMap,
+  }
 }
 
 function toKnownType(
@@ -173,7 +219,8 @@ function toKnownType(
     notif.reason === 'mention' ||
     notif.reason === 'reply' ||
     notif.reason === 'quote' ||
-    notif.reason === 'follow'
+    notif.reason === 'follow' ||
+    notif.reason === 'starterpack-joined'
   ) {
     return notif.reason as NotificationType
   }
diff --git a/src/state/queries/profile-lists.ts b/src/state/queries/profile-lists.ts
index 2bb5f4d28..112a62c83 100644
--- a/src/state/queries/profile-lists.ts
+++ b/src/state/queries/profile-lists.ts
@@ -26,7 +26,15 @@ export function useProfileListsQuery(did: string, opts?: {enabled?: boolean}) {
         limit: PAGE_SIZE,
         cursor: pageParam,
       })
-      return res.data
+
+      // Starter packs use a reference list, which we do not want to show on profiles. At some point we could probably
+      // just filter this out on the backend instead of in the client.
+      return {
+        ...res.data,
+        lists: res.data.lists.filter(
+          l => l.purpose !== 'app.bsky.graph.defs#referencelist',
+        ),
+      }
     },
     initialPageParam: undefined,
     getNextPageParam: lastPage => lastPage.cursor,
diff --git a/src/state/queries/shorten-link.ts b/src/state/queries/shorten-link.ts
new file mode 100644
index 000000000..76c63c356
--- /dev/null
+++ b/src/state/queries/shorten-link.ts
@@ -0,0 +1,23 @@
+import {logger} from '#/logger'
+
+export function useShortenLink() {
+  return async (inputUrl: string): Promise<{url: string}> => {
+    const url = new URL(inputUrl)
+    const res = await fetch('https://go.bsky.app/link', {
+      method: 'POST',
+      body: JSON.stringify({
+        path: url.pathname,
+      }),
+      headers: {
+        'Content-Type': 'application/json',
+      },
+    })
+
+    if (!res.ok) {
+      logger.error('Failed to shorten link', {safeMessage: res.status})
+      return {url: inputUrl}
+    }
+
+    return res.json()
+  }
+}
diff --git a/src/state/queries/starter-packs.ts b/src/state/queries/starter-packs.ts
new file mode 100644
index 000000000..241bc6419
--- /dev/null
+++ b/src/state/queries/starter-packs.ts
@@ -0,0 +1,317 @@
+import {
+  AppBskyActorDefs,
+  AppBskyFeedDefs,
+  AppBskyGraphDefs,
+  AppBskyGraphGetStarterPack,
+  AppBskyGraphStarterpack,
+  AtUri,
+  BskyAgent,
+} from '@atproto/api'
+import {StarterPackView} from '@atproto/api/dist/client/types/app/bsky/graph/defs'
+import {
+  QueryClient,
+  useMutation,
+  useQuery,
+  useQueryClient,
+} from '@tanstack/react-query'
+
+import {until} from 'lib/async/until'
+import {createStarterPackList} from 'lib/generate-starterpack'
+import {
+  createStarterPackUri,
+  httpStarterPackUriToAtUri,
+  parseStarterPackUri,
+} from 'lib/strings/starter-pack'
+import {invalidateActorStarterPacksQuery} from 'state/queries/actor-starter-packs'
+import {invalidateListMembersQuery} from 'state/queries/list-members'
+import {useAgent} from 'state/session'
+
+const RQKEY_ROOT = 'starter-pack'
+const RQKEY = (did?: string, rkey?: string) => {
+  if (did?.startsWith('https://') || did?.startsWith('at://')) {
+    const parsed = parseStarterPackUri(did)
+    return [RQKEY_ROOT, parsed?.name, parsed?.rkey]
+  } else {
+    return [RQKEY_ROOT, did, rkey]
+  }
+}
+
+export function useStarterPackQuery({
+  uri,
+  did,
+  rkey,
+}: {
+  uri?: string
+  did?: string
+  rkey?: string
+}) {
+  const agent = useAgent()
+
+  return useQuery<StarterPackView>({
+    queryKey: RQKEY(did, rkey),
+    queryFn: async () => {
+      if (!uri) {
+        uri = `at://${did}/app.bsky.graph.starterpack/${rkey}`
+      } else if (uri && !uri.startsWith('at://')) {
+        uri = httpStarterPackUriToAtUri(uri) as string
+      }
+
+      const res = await agent.app.bsky.graph.getStarterPack({
+        starterPack: uri,
+      })
+      return res.data.starterPack
+    },
+    enabled: Boolean(uri) || Boolean(did && rkey),
+  })
+}
+
+export async function invalidateStarterPack({
+  queryClient,
+  did,
+  rkey,
+}: {
+  queryClient: QueryClient
+  did: string
+  rkey: string
+}) {
+  await queryClient.invalidateQueries({queryKey: RQKEY(did, rkey)})
+}
+
+interface UseCreateStarterPackMutationParams {
+  name: string
+  description?: string
+  descriptionFacets: []
+  profiles: AppBskyActorDefs.ProfileViewBasic[]
+  feeds?: AppBskyFeedDefs.GeneratorView[]
+}
+
+export function useCreateStarterPackMutation({
+  onSuccess,
+  onError,
+}: {
+  onSuccess: (data: {uri: string; cid: string}) => void
+  onError: (e: Error) => void
+}) {
+  const queryClient = useQueryClient()
+  const agent = useAgent()
+
+  return useMutation<
+    {uri: string; cid: string},
+    Error,
+    UseCreateStarterPackMutationParams
+  >({
+    mutationFn: async params => {
+      let listRes
+      listRes = await createStarterPackList({...params, agent})
+      return await agent.app.bsky.graph.starterpack.create(
+        {
+          repo: agent.session?.did,
+        },
+        {
+          ...params,
+          list: listRes?.uri,
+          createdAt: new Date().toISOString(),
+        },
+      )
+    },
+    onSuccess: async data => {
+      await whenAppViewReady(agent, data.uri, v => {
+        return typeof v?.data.starterPack.uri === 'string'
+      })
+      await invalidateActorStarterPacksQuery({
+        queryClient,
+        did: agent.session!.did,
+      })
+      onSuccess(data)
+    },
+    onError: async error => {
+      onError(error)
+    },
+  })
+}
+
+export function useEditStarterPackMutation({
+  onSuccess,
+  onError,
+}: {
+  onSuccess: () => void
+  onError: (error: Error) => void
+}) {
+  const queryClient = useQueryClient()
+  const agent = useAgent()
+
+  return useMutation<
+    void,
+    Error,
+    UseCreateStarterPackMutationParams & {
+      currentStarterPack: AppBskyGraphDefs.StarterPackView
+      currentListItems: AppBskyGraphDefs.ListItemView[]
+    }
+  >({
+    mutationFn: async params => {
+      const {
+        name,
+        description,
+        descriptionFacets,
+        feeds,
+        profiles,
+        currentStarterPack,
+        currentListItems,
+      } = params
+
+      if (!AppBskyGraphStarterpack.isRecord(currentStarterPack.record)) {
+        throw new Error('Invalid starter pack')
+      }
+
+      const removedItems = currentListItems.filter(
+        i =>
+          i.subject.did !== agent.session?.did &&
+          !profiles.find(p => p.did === i.subject.did && p.did),
+      )
+
+      if (removedItems.length !== 0) {
+        await agent.com.atproto.repo.applyWrites({
+          repo: agent.session!.did,
+          writes: removedItems.map(i => ({
+            $type: 'com.atproto.repo.applyWrites#delete',
+            collection: 'app.bsky.graph.listitem',
+            rkey: new AtUri(i.uri).rkey,
+          })),
+        })
+      }
+
+      const addedProfiles = profiles.filter(
+        p => !currentListItems.find(i => i.subject.did === p.did),
+      )
+
+      if (addedProfiles.length > 0) {
+        await agent.com.atproto.repo.applyWrites({
+          repo: agent.session!.did,
+          writes: addedProfiles.map(p => ({
+            $type: 'com.atproto.repo.applyWrites#create',
+            collection: 'app.bsky.graph.listitem',
+            value: {
+              $type: 'app.bsky.graph.listitem',
+              subject: p.did,
+              list: currentStarterPack.list?.uri,
+              createdAt: new Date().toISOString(),
+            },
+          })),
+        })
+      }
+
+      const rkey = parseStarterPackUri(currentStarterPack.uri)!.rkey
+      await agent.com.atproto.repo.putRecord({
+        repo: agent.session!.did,
+        collection: 'app.bsky.graph.starterpack',
+        rkey,
+        record: {
+          name,
+          description,
+          descriptionFacets,
+          list: currentStarterPack.list?.uri,
+          feeds,
+          createdAt: currentStarterPack.record.createdAt,
+          updatedAt: new Date().toISOString(),
+        },
+      })
+    },
+    onSuccess: async (_, {currentStarterPack}) => {
+      const parsed = parseStarterPackUri(currentStarterPack.uri)
+      await whenAppViewReady(agent, currentStarterPack.uri, v => {
+        return currentStarterPack.cid !== v?.data.starterPack.cid
+      })
+      await invalidateActorStarterPacksQuery({
+        queryClient,
+        did: agent.session!.did,
+      })
+      if (currentStarterPack.list) {
+        await invalidateListMembersQuery({
+          queryClient,
+          uri: currentStarterPack.list.uri,
+        })
+      }
+      await invalidateStarterPack({
+        queryClient,
+        did: agent.session!.did,
+        rkey: parsed!.rkey,
+      })
+      onSuccess()
+    },
+    onError: error => {
+      onError(error)
+    },
+  })
+}
+
+export function useDeleteStarterPackMutation({
+  onSuccess,
+  onError,
+}: {
+  onSuccess: () => void
+  onError: (error: Error) => void
+}) {
+  const agent = useAgent()
+  const queryClient = useQueryClient()
+
+  return useMutation({
+    mutationFn: async ({listUri, rkey}: {listUri?: string; rkey: string}) => {
+      if (!agent.session) {
+        throw new Error(`Requires logged in user`)
+      }
+
+      if (listUri) {
+        await agent.app.bsky.graph.list.delete({
+          repo: agent.session.did,
+          rkey: new AtUri(listUri).rkey,
+        })
+      }
+      await agent.app.bsky.graph.starterpack.delete({
+        repo: agent.session.did,
+        rkey,
+      })
+    },
+    onSuccess: async (_, {listUri, rkey}) => {
+      const uri = createStarterPackUri({
+        did: agent.session!.did,
+        rkey,
+      })
+
+      if (uri) {
+        await whenAppViewReady(agent, uri, v => {
+          return Boolean(v?.data?.starterPack) === false
+        })
+      }
+
+      if (listUri) {
+        await invalidateListMembersQuery({queryClient, uri: listUri})
+      }
+      await invalidateActorStarterPacksQuery({
+        queryClient,
+        did: agent.session!.did,
+      })
+      await invalidateStarterPack({
+        queryClient,
+        did: agent.session!.did,
+        rkey,
+      })
+      onSuccess()
+    },
+    onError: error => {
+      onError(error)
+    },
+  })
+}
+
+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/state/session/agent.ts b/src/state/session/agent.ts
index 5a58937fa..4bcb4c11c 100644
--- a/src/state/session/agent.ts
+++ b/src/state/session/agent.ts
@@ -127,18 +127,6 @@ export async function createAgentAndCreateAccount(
   const account = agentToSessionAccountOrThrow(agent)
   const gates = tryFetchGates(account.did, 'prefer-fresh-gates')
   const moderation = configureModerationForAccount(agent, account)
-  if (!account.signupQueued) {
-    /*dont await*/ agent.upsertProfile(_existing => {
-      return {
-        displayName: '',
-        // HACKFIX
-        // creating a bunch of identical profile objects is breaking the relay
-        // tossing this unspecced field onto it to reduce the size of the problem
-        // -prf
-        createdAt: new Date().toISOString(),
-      }
-    })
-  }
 
   // Not awaited so that we can still get into onboarding.
   // This is OK because we won't let you toggle adult stuff until you set the date.
diff --git a/src/state/shell/logged-out.tsx b/src/state/shell/logged-out.tsx
index 8fe2a9c01..dc78d03d5 100644
--- a/src/state/shell/logged-out.tsx
+++ b/src/state/shell/logged-out.tsx
@@ -1,5 +1,9 @@
 import React from 'react'
 
+import {isWeb} from 'platform/detection'
+import {useSession} from 'state/session'
+import {useActiveStarterPack} from 'state/shell/starter-pack'
+
 type State = {
   showLoggedOut: boolean
   /**
@@ -22,7 +26,7 @@ type Controls = {
     /**
      * The did of the account to populate the login form with.
      */
-    requestedAccount?: string | 'none' | 'new'
+    requestedAccount?: string | 'none' | 'new' | 'starterpack'
   }) => void
   /**
    * Clears the requested account so that next time the logged out view is
@@ -43,9 +47,16 @@ const ControlsContext = React.createContext<Controls>({
 })
 
 export function Provider({children}: React.PropsWithChildren<{}>) {
+  const activeStarterPack = useActiveStarterPack()
+  const {hasSession} = useSession()
+  const shouldShowStarterPack = Boolean(activeStarterPack?.uri) && !hasSession
   const [state, setState] = React.useState<State>({
-    showLoggedOut: false,
-    requestedAccountSwitchTo: undefined,
+    showLoggedOut: shouldShowStarterPack,
+    requestedAccountSwitchTo: shouldShowStarterPack
+      ? isWeb
+        ? 'starterpack'
+        : 'new'
+      : undefined,
   })
 
   const controls = React.useMemo<Controls>(
diff --git a/src/state/shell/starter-pack.tsx b/src/state/shell/starter-pack.tsx
new file mode 100644
index 000000000..f564712f0
--- /dev/null
+++ b/src/state/shell/starter-pack.tsx
@@ -0,0 +1,25 @@
+import React from 'react'
+
+type StateContext =
+  | {
+      uri: string
+      isClip?: boolean
+    }
+  | undefined
+type SetContext = (v: StateContext) => void
+
+const stateContext = React.createContext<StateContext>(undefined)
+const setContext = React.createContext<SetContext>((_: StateContext) => {})
+
+export function Provider({children}: {children: React.ReactNode}) {
+  const [state, setState] = React.useState<StateContext>()
+
+  return (
+    <stateContext.Provider value={state}>
+      <setContext.Provider value={setState}>{children}</setContext.Provider>
+    </stateContext.Provider>
+  )
+}
+
+export const useActiveStarterPack = () => React.useContext(stateContext)
+export const useSetActiveStarterPack = () => React.useContext(setContext)