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:
parent
35f64535cb
commit
f089f45781
115 changed files with 6336 additions and 237 deletions
|
@ -1,3 +1,4 @@
|
|||
export const isSafari = false
|
||||
export const isFirefox = false
|
||||
export const isTouchDevice = true
|
||||
export const isAndroidWeb = false
|
||||
|
|
|
@ -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
|
||||
|
|
164
src/lib/generate-starterpack.ts
Normal file
164
src/lib/generate-starterpack.ts
Normal 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}),
|
||||
)
|
||||
}
|
14
src/lib/hooks/useBottomBarOffset.ts
Normal file
14
src/lib/hooks/useBottomBarOffset.ts
Normal 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
|
||||
)
|
||||
}
|
|
@ -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
|
||||
|
|
21
src/lib/moderation/create-sanitized-display-name.ts
Normal file
21
src/lib/moderation/create-sanitized-display-name.ts
Normal 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
|
||||
}
|
||||
}
|
|
@ -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,
|
||||
|
|
|
@ -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}`
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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': {}
|
||||
|
|
|
@ -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'
|
||||
|
|
101
src/lib/strings/starter-pack.ts
Normal file
101
src/lib/strings/starter-pack.ts
Normal 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()
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue