Starter Packs (#4332)

Co-authored-by: Dan Abramov <dan.abramov@gmail.com>
Co-authored-by: Paul Frazee <pfrazee@gmail.com>
Co-authored-by: Eric Bailey <git@esb.lol>
Co-authored-by: Samuel Newman <mozzius@protonmail.com>
This commit is contained in:
Hailey 2024-06-21 21:38:04 -07:00 committed by GitHub
parent 35f64535cb
commit f089f45781
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
115 changed files with 6336 additions and 237 deletions

View file

@ -1,3 +1,4 @@
export const isSafari = false
export const isFirefox = false
export const isTouchDevice = true
export const isAndroidWeb = false

View file

@ -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

View file

@ -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}),
)
}

View file

@ -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
)
}

View file

@ -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

View file

@ -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
}
}

View file

@ -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,

View file

@ -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}`
}
}

View file

@ -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

View file

@ -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': {}

View file

@ -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'

View file

@ -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()
}