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>zio/stable
parent
35f64535cb
commit
f089f45781
|
@ -1,6 +1,11 @@
|
||||||
import {RichText} from '@atproto/api'
|
import {RichText} from '@atproto/api'
|
||||||
|
|
||||||
import {parseEmbedPlayerFromUrl} from 'lib/strings/embed-player'
|
import {parseEmbedPlayerFromUrl} from 'lib/strings/embed-player'
|
||||||
|
import {
|
||||||
|
createStarterPackGooglePlayUri,
|
||||||
|
createStarterPackLinkFromAndroidReferrer,
|
||||||
|
parseStarterPackUri,
|
||||||
|
} from 'lib/strings/starter-pack'
|
||||||
import {cleanError} from '../../src/lib/strings/errors'
|
import {cleanError} from '../../src/lib/strings/errors'
|
||||||
import {createFullHandle, makeValidHandle} from '../../src/lib/strings/handles'
|
import {createFullHandle, makeValidHandle} from '../../src/lib/strings/handles'
|
||||||
import {enforceLen} from '../../src/lib/strings/helpers'
|
import {enforceLen} from '../../src/lib/strings/helpers'
|
||||||
|
@ -796,3 +801,179 @@ describe('parseEmbedPlayerFromUrl', () => {
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('createStarterPackLinkFromAndroidReferrer', () => {
|
||||||
|
const validOutput = 'at://haileyok.com/app.bsky.graph.starterpack/rkey'
|
||||||
|
|
||||||
|
it('returns a link when input contains utm_source and utm_content', () => {
|
||||||
|
expect(
|
||||||
|
createStarterPackLinkFromAndroidReferrer(
|
||||||
|
'utm_source=bluesky&utm_content=starterpack_haileyok.com_rkey',
|
||||||
|
),
|
||||||
|
).toEqual(validOutput)
|
||||||
|
|
||||||
|
expect(
|
||||||
|
createStarterPackLinkFromAndroidReferrer(
|
||||||
|
'utm_source=bluesky&utm_content=starterpack_test-lover-9000.com_rkey',
|
||||||
|
),
|
||||||
|
).toEqual('at://test-lover-9000.com/app.bsky.graph.starterpack/rkey')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns a link when input contains utm_source and utm_content in different order', () => {
|
||||||
|
expect(
|
||||||
|
createStarterPackLinkFromAndroidReferrer(
|
||||||
|
'utm_content=starterpack_haileyok.com_rkey&utm_source=bluesky',
|
||||||
|
),
|
||||||
|
).toEqual(validOutput)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns a link when input contains other parameters as well', () => {
|
||||||
|
expect(
|
||||||
|
createStarterPackLinkFromAndroidReferrer(
|
||||||
|
'utm_source=bluesky&utm_medium=starterpack&utm_content=starterpack_haileyok.com_rkey',
|
||||||
|
),
|
||||||
|
).toEqual(validOutput)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns null when utm_source is not present', () => {
|
||||||
|
expect(
|
||||||
|
createStarterPackLinkFromAndroidReferrer(
|
||||||
|
'utm_content=starterpack_haileyok.com_rkey',
|
||||||
|
),
|
||||||
|
).toEqual(null)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns null when utm_content is not present', () => {
|
||||||
|
expect(
|
||||||
|
createStarterPackLinkFromAndroidReferrer('utm_source=bluesky'),
|
||||||
|
).toEqual(null)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns null when utm_content is malformed', () => {
|
||||||
|
expect(
|
||||||
|
createStarterPackLinkFromAndroidReferrer(
|
||||||
|
'utm_content=starterpack_haileyok.com',
|
||||||
|
),
|
||||||
|
).toEqual(null)
|
||||||
|
|
||||||
|
expect(
|
||||||
|
createStarterPackLinkFromAndroidReferrer('utm_content=starterpack'),
|
||||||
|
).toEqual(null)
|
||||||
|
|
||||||
|
expect(
|
||||||
|
createStarterPackLinkFromAndroidReferrer(
|
||||||
|
'utm_content=starterpack_haileyok.com_rkey_more',
|
||||||
|
),
|
||||||
|
).toEqual(null)
|
||||||
|
|
||||||
|
expect(
|
||||||
|
createStarterPackLinkFromAndroidReferrer(
|
||||||
|
'utm_content=notastarterpack_haileyok.com_rkey',
|
||||||
|
),
|
||||||
|
).toEqual(null)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('parseStarterPackHttpUri', () => {
|
||||||
|
const baseUri = 'https://bsky.app/start'
|
||||||
|
|
||||||
|
it('returns a valid at uri when http uri is valid', () => {
|
||||||
|
const validHttpUri = `${baseUri}/haileyok.com/rkey`
|
||||||
|
expect(parseStarterPackUri(validHttpUri)).toEqual({
|
||||||
|
name: 'haileyok.com',
|
||||||
|
rkey: 'rkey',
|
||||||
|
})
|
||||||
|
|
||||||
|
const validHttpUri2 = `${baseUri}/haileyok.com/ilovetesting`
|
||||||
|
expect(parseStarterPackUri(validHttpUri2)).toEqual({
|
||||||
|
name: 'haileyok.com',
|
||||||
|
rkey: 'ilovetesting',
|
||||||
|
})
|
||||||
|
|
||||||
|
const validHttpUri3 = `${baseUri}/testlover9000.com/rkey`
|
||||||
|
expect(parseStarterPackUri(validHttpUri3)).toEqual({
|
||||||
|
name: 'testlover9000.com',
|
||||||
|
rkey: 'rkey',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns null when there is no rkey', () => {
|
||||||
|
const validHttpUri = `${baseUri}/haileyok.com`
|
||||||
|
expect(parseStarterPackUri(validHttpUri)).toEqual(null)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns null when there is an extra path', () => {
|
||||||
|
const validHttpUri = `${baseUri}/haileyok.com/rkey/other`
|
||||||
|
expect(parseStarterPackUri(validHttpUri)).toEqual(null)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns null when there is no handle or rkey', () => {
|
||||||
|
const validHttpUri = `${baseUri}`
|
||||||
|
expect(parseStarterPackUri(validHttpUri)).toEqual(null)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns null when the route is not /start or /starter-pack', () => {
|
||||||
|
const validHttpUri = 'https://bsky.app/start/haileyok.com/rkey'
|
||||||
|
expect(parseStarterPackUri(validHttpUri)).toEqual({
|
||||||
|
name: 'haileyok.com',
|
||||||
|
rkey: 'rkey',
|
||||||
|
})
|
||||||
|
|
||||||
|
const validHttpUri2 = 'https://bsky.app/starter-pack/haileyok.com/rkey'
|
||||||
|
expect(parseStarterPackUri(validHttpUri2)).toEqual({
|
||||||
|
name: 'haileyok.com',
|
||||||
|
rkey: 'rkey',
|
||||||
|
})
|
||||||
|
|
||||||
|
const invalidHttpUri = 'https://bsky.app/profile/haileyok.com/rkey'
|
||||||
|
expect(parseStarterPackUri(invalidHttpUri)).toEqual(null)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns the at uri when the input is a valid starterpack at uri', () => {
|
||||||
|
const validAtUri = 'at://did:123/app.bsky.graph.starterpack/rkey'
|
||||||
|
expect(parseStarterPackUri(validAtUri)).toEqual({
|
||||||
|
name: 'did:123',
|
||||||
|
rkey: 'rkey',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns null when the at uri has no rkey', () => {
|
||||||
|
const validAtUri = 'at://did:123/app.bsky.graph.starterpack'
|
||||||
|
expect(parseStarterPackUri(validAtUri)).toEqual(null)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns null when the collection is not app.bsky.graph.starterpack', () => {
|
||||||
|
const validAtUri = 'at://did:123/app.bsky.graph.list/rkey'
|
||||||
|
expect(parseStarterPackUri(validAtUri)).toEqual(null)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns null when the input is undefined', () => {
|
||||||
|
expect(parseStarterPackUri(undefined)).toEqual(null)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('createStarterPackGooglePlayUri', () => {
|
||||||
|
const base =
|
||||||
|
'https://play.google.com/store/apps/details?id=xyz.blueskyweb.app&referrer=utm_source%3Dbluesky%26utm_medium%3Dstarterpack%26utm_content%3Dstarterpack_'
|
||||||
|
|
||||||
|
it('returns valid google play uri when input is valid', () => {
|
||||||
|
expect(createStarterPackGooglePlayUri('name', 'rkey')).toEqual(
|
||||||
|
`${base}name_rkey`,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns null when no rkey is supplied', () => {
|
||||||
|
// @ts-expect-error test
|
||||||
|
expect(createStarterPackGooglePlayUri('name', undefined)).toEqual(null)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns null when no name or rkey are supplied', () => {
|
||||||
|
// @ts-expect-error test
|
||||||
|
expect(createStarterPackGooglePlayUri(undefined, undefined)).toEqual(null)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns null when rkey is supplied but no name', () => {
|
||||||
|
// @ts-expect-error test
|
||||||
|
expect(createStarterPackGooglePlayUri(undefined, 'rkey')).toEqual(null)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
|
@ -39,6 +39,17 @@ module.exports = function (config) {
|
||||||
const IS_TESTFLIGHT = process.env.EXPO_PUBLIC_ENV === 'testflight'
|
const IS_TESTFLIGHT = process.env.EXPO_PUBLIC_ENV === 'testflight'
|
||||||
const IS_PRODUCTION = process.env.EXPO_PUBLIC_ENV === 'production'
|
const IS_PRODUCTION = process.env.EXPO_PUBLIC_ENV === 'production'
|
||||||
|
|
||||||
|
const ASSOCIATED_DOMAINS = [
|
||||||
|
'applinks:bsky.app',
|
||||||
|
'applinks:staging.bsky.app',
|
||||||
|
'appclips:bsky.app',
|
||||||
|
'appclips:go.bsky.app', // Allows App Clip to work when scanning QR codes
|
||||||
|
// When testing local services, enter an ngrok (et al) domain here. It must use a standard HTTP/HTTPS port.
|
||||||
|
...(IS_DEV || IS_TESTFLIGHT
|
||||||
|
? ['appclips:sptesting.haileyok.com', 'applinks:sptesting.haileyok.com']
|
||||||
|
: []),
|
||||||
|
]
|
||||||
|
|
||||||
const UPDATES_CHANNEL = IS_TESTFLIGHT
|
const UPDATES_CHANNEL = IS_TESTFLIGHT
|
||||||
? 'testflight'
|
? 'testflight'
|
||||||
: IS_PRODUCTION
|
: IS_PRODUCTION
|
||||||
|
@ -83,7 +94,7 @@ module.exports = function (config) {
|
||||||
NSPhotoLibraryUsageDescription:
|
NSPhotoLibraryUsageDescription:
|
||||||
'Used for profile pictures, posts, and other kinds of content',
|
'Used for profile pictures, posts, and other kinds of content',
|
||||||
},
|
},
|
||||||
associatedDomains: ['applinks:bsky.app', 'applinks:staging.bsky.app'],
|
associatedDomains: ASSOCIATED_DOMAINS,
|
||||||
splash: {
|
splash: {
|
||||||
...SPLASH_CONFIG,
|
...SPLASH_CONFIG,
|
||||||
dark: DARK_SPLASH_CONFIG,
|
dark: DARK_SPLASH_CONFIG,
|
||||||
|
@ -202,6 +213,7 @@ module.exports = function (config) {
|
||||||
sounds: PLATFORM === 'ios' ? ['assets/dm.aiff'] : ['assets/dm.mp3'],
|
sounds: PLATFORM === 'ios' ? ['assets/dm.aiff'] : ['assets/dm.mp3'],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
'./plugins/starterPackAppClipExtension/withStarterPackAppClip.js',
|
||||||
'./plugins/withAndroidManifestPlugin.js',
|
'./plugins/withAndroidManifestPlugin.js',
|
||||||
'./plugins/withAndroidManifestFCMIconPlugin.js',
|
'./plugins/withAndroidManifestFCMIconPlugin.js',
|
||||||
'./plugins/withAndroidStylesWindowBackgroundPlugin.js',
|
'./plugins/withAndroidStylesWindowBackgroundPlugin.js',
|
||||||
|
@ -234,6 +246,10 @@ module.exports = function (config) {
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
targetName: 'BlueskyClip',
|
||||||
|
bundleIdentifier: 'xyz.blueskyweb.app.AppClip',
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
|
@ -0,0 +1 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#080B12" fill-rule="evenodd" d="M3 5a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5Zm6 0H5v4h4V5ZM3 15a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4Zm6 0H5v4h4v-4ZM13 5a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v4a2 2 0 0 1-2 2h-4a2 2 0 0 1-2-2V5Zm6 0h-4v4h4V5ZM14 13a1 1 0 0 1 1 1v1h1a1 1 0 1 1 0 2h-2a1 1 0 0 1-1-1v-2a1 1 0 0 1 1-1Zm3 1a1 1 0 0 1 1-1h2a1 1 0 1 1 0 2h-2a1 1 0 0 1-1-1Zm0 4a1 1 0 0 1 1-1h2a1 1 0 1 1 0 2h-1v1a1 1 0 1 1-2 0v-2Z" clip-rule="evenodd"/></svg>
|
After Width: | Height: | Size: 580 B |
|
@ -0,0 +1 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" fill-rule="evenodd" d="M11.26 5.227 5.02 6.899c-.734.197-1.17.95-.973 1.685l1.672 6.24c.197.734.951 1.17 1.685.973l6.24-1.672c.734-.197 1.17-.951.973-1.685L12.945 6.2a1.375 1.375 0 0 0-1.685-.973Zm-6.566.459a2.632 2.632 0 0 0-1.86 3.223l1.672 6.24a2.632 2.632 0 0 0 3.223 1.861l6.24-1.672a2.631 2.631 0 0 0 1.861-3.223l-1.672-6.24a2.632 2.632 0 0 0-3.223-1.861l-6.24 1.672Z" clip-rule="evenodd"/><path fill="#000" fill-rule="evenodd" d="M15.138 18.411a4.606 4.606 0 1 0 0-9.211 4.606 4.606 0 0 0 0 9.211Zm0 1.257a5.862 5.862 0 1 0 0-11.724 5.862 5.862 0 0 0 0 11.724Z" clip-rule="evenodd"/></svg>
|
After Width: | Height: | Size: 687 B |
|
@ -0,0 +1 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 153 133"><path fill="url(#a)" fill-rule="evenodd" d="m60.196 105.445-18.1 4.85c-11.73 3.143-23.788-3.819-26.931-15.55L1.19 42.597c-3.143-11.731 3.819-23.79 15.55-26.932L68.889 1.69C80.62-1.452 92.68 5.51 95.821 17.241l4.667 17.416a49.7 49.7 0 0 1 3.522-.125c27.053 0 48.984 21.931 48.984 48.984S131.063 132.5 104.01 132.5c-19.17 0-35.769-11.012-43.814-27.055ZM19.457 25.804 71.606 11.83c6.131-1.643 12.434 1.996 14.076 8.127l4.44 16.571c-20.289 5.987-35.096 24.758-35.096 46.988 0 4.157.517 8.193 1.492 12.047l-17.138 4.593c-6.131 1.642-12.434-1.996-14.077-8.128L11.33 39.88c-1.643-6.131 1.996-12.434 8.127-14.077Zm83.812 19.232c.246-.005.493-.007.741-.007 21.256 0 38.487 17.231 38.487 38.487s-17.231 38.488-38.487 38.488c-14.29 0-26.76-7.788-33.4-19.35l23.635-6.333c11.731-3.143 18.693-15.2 15.55-26.932l-6.526-24.353Zm-10.428 1.638 6.815 25.432c1.642 6.131-1.996 12.434-8.128 14.076l-24.867 6.664a38.57 38.57 0 0 1-1.139-9.33c0-17.372 11.51-32.056 27.32-36.842Z" clip-rule="evenodd"/><defs><linearGradient id="a" x1="76.715" x2="76.715" y1=".937" y2="132.5" gradientUnits="userSpaceOnUse"><stop stop-color="#0A7AFF"/><stop offset="1" stop-color="#59B9FF"/></linearGradient></defs></svg>
|
After Width: | Height: | Size: 1.2 KiB |
Binary file not shown.
After Width: | Height: | Size: 9.9 KiB |
|
@ -223,6 +223,10 @@ func serve(cctx *cli.Context) error {
|
||||||
e.GET("/profile/:handleOrDID/post/:rkey/liked-by", server.WebGeneric)
|
e.GET("/profile/:handleOrDID/post/:rkey/liked-by", server.WebGeneric)
|
||||||
e.GET("/profile/:handleOrDID/post/:rkey/reposted-by", server.WebGeneric)
|
e.GET("/profile/:handleOrDID/post/:rkey/reposted-by", server.WebGeneric)
|
||||||
|
|
||||||
|
// starter packs
|
||||||
|
e.GET("/starter-pack/:handleOrDID/:rkey", server.WebGeneric)
|
||||||
|
e.GET("/start/:handleOrDID/:rkey", server.WebGeneric)
|
||||||
|
|
||||||
if linkHost != "" {
|
if linkHost != "" {
|
||||||
linkUrl, err := url.Parse(linkHost)
|
linkUrl, err := url.Parse(linkHost)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
|
@ -1,6 +1,8 @@
|
||||||
{
|
{
|
||||||
"applinks": {
|
"applinks": {
|
||||||
"apps": [],
|
"appclips": {
|
||||||
|
"apps": ["B3LX46C5HS.xyz.blueskyweb.app.AppClip"]
|
||||||
|
},
|
||||||
"details": [
|
"details": [
|
||||||
{
|
{
|
||||||
"appID": "B3LX46C5HS.xyz.blueskyweb.app",
|
"appID": "B3LX46C5HS.xyz.blueskyweb.app",
|
||||||
|
@ -10,4 +12,4 @@
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,32 @@
|
||||||
|
import UIKit
|
||||||
|
|
||||||
|
@main
|
||||||
|
class AppDelegate: UIResponder, UIApplicationDelegate {
|
||||||
|
var window: UIWindow?
|
||||||
|
var controller: ViewController?
|
||||||
|
|
||||||
|
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
|
||||||
|
let window = UIWindow()
|
||||||
|
self.window = UIWindow()
|
||||||
|
|
||||||
|
let controller = ViewController(window: window)
|
||||||
|
self.controller = controller
|
||||||
|
|
||||||
|
window.rootViewController = self.controller
|
||||||
|
window.makeKeyAndVisible()
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func application(_ app: UIApplication, open url: URL, options: [UIApplication.OpenURLOptionsKey: Any] = [:]) -> Bool {
|
||||||
|
self.controller?.handleURL(url: url)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func application(_ application: UIApplication, continue userActivity: NSUserActivity, restorationHandler: @escaping ([UIUserActivityRestoring]?) -> Void) -> Bool {
|
||||||
|
if let incomingURL = userActivity.webpageURL {
|
||||||
|
self.controller?.handleURL(url: incomingURL)
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
Binary file not shown.
After Width: | Height: | Size: 463 KiB |
|
@ -0,0 +1,14 @@
|
||||||
|
{
|
||||||
|
"images" : [
|
||||||
|
{
|
||||||
|
"filename" : "App-Icon-1024x1024@1x.png",
|
||||||
|
"idiom" : "universal",
|
||||||
|
"platform" : "ios",
|
||||||
|
"size" : "1024x1024"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,6 @@
|
||||||
|
{
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,133 @@
|
||||||
|
import UIKit
|
||||||
|
import WebKit
|
||||||
|
import StoreKit
|
||||||
|
|
||||||
|
class ViewController: UIViewController, WKScriptMessageHandler, WKNavigationDelegate {
|
||||||
|
let defaults = UserDefaults(suiteName: "group.app.bsky")
|
||||||
|
|
||||||
|
var window: UIWindow
|
||||||
|
var webView: WKWebView?
|
||||||
|
|
||||||
|
var prevUrl: URL?
|
||||||
|
var starterPackUrl: URL?
|
||||||
|
|
||||||
|
init(window: UIWindow) {
|
||||||
|
self.window = window
|
||||||
|
super.init(nibName: nil, bundle: nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
required init?(coder: NSCoder) {
|
||||||
|
fatalError("init(coder:) has not been implemented")
|
||||||
|
}
|
||||||
|
|
||||||
|
override func viewDidLoad() {
|
||||||
|
super.viewDidLoad()
|
||||||
|
|
||||||
|
let contentController = WKUserContentController()
|
||||||
|
contentController.add(self, name: "onMessage")
|
||||||
|
let configuration = WKWebViewConfiguration()
|
||||||
|
configuration.userContentController = contentController
|
||||||
|
|
||||||
|
let webView = WKWebView(frame: self.view.bounds, configuration: configuration)
|
||||||
|
webView.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
webView.contentMode = .scaleToFill
|
||||||
|
webView.navigationDelegate = self
|
||||||
|
self.view.addSubview(webView)
|
||||||
|
self.webView = webView
|
||||||
|
}
|
||||||
|
|
||||||
|
func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) {
|
||||||
|
guard let response = message.body as? String,
|
||||||
|
let data = response.data(using: .utf8),
|
||||||
|
let payload = try? JSONDecoder().decode(WebViewActionPayload.self, from: data) else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
switch payload.action {
|
||||||
|
case .present:
|
||||||
|
guard let url = self.starterPackUrl else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
self.presentAppStoreOverlay()
|
||||||
|
defaults?.setValue(url.absoluteString, forKey: "starterPackUri")
|
||||||
|
|
||||||
|
case .store:
|
||||||
|
guard let keyToStoreAs = payload.keyToStoreAs, let jsonToStore = payload.jsonToStore else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
self.defaults?.setValue(jsonToStore, forKey: keyToStoreAs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction) async -> WKNavigationActionPolicy {
|
||||||
|
// Detect when we land on the right URL. This is incase of a short link opening the app clip
|
||||||
|
guard let url = navigationAction.request.url else {
|
||||||
|
return .allow
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store the previous one to compare later, but only set starterPackUrl when we find the right one
|
||||||
|
prevUrl = url
|
||||||
|
// pathComponents starts with "/" as the first component, then each path name. so...
|
||||||
|
// ["/", "start", "name", "rkey"]
|
||||||
|
if url.pathComponents.count == 4,
|
||||||
|
url.pathComponents[1] == "start" {
|
||||||
|
self.starterPackUrl = url
|
||||||
|
}
|
||||||
|
|
||||||
|
return .allow
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleURL(url: URL) {
|
||||||
|
let urlString = "\(url.absoluteString)?clip=true"
|
||||||
|
if let url = URL(string: urlString) {
|
||||||
|
self.webView?.load(URLRequest(url: url))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func presentAppStoreOverlay() {
|
||||||
|
guard let windowScene = self.window.windowScene else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let configuration = SKOverlay.AppClipConfiguration(position: .bottomRaised)
|
||||||
|
let overlay = SKOverlay(configuration: configuration)
|
||||||
|
|
||||||
|
overlay.present(in: windowScene)
|
||||||
|
}
|
||||||
|
|
||||||
|
func getHost(_ url: URL?) -> String? {
|
||||||
|
if #available(iOS 16.0, *) {
|
||||||
|
return url?.host()
|
||||||
|
} else {
|
||||||
|
return url?.host
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func getQuery(_ url: URL?) -> String? {
|
||||||
|
if #available(iOS 16.0, *) {
|
||||||
|
return url?.query()
|
||||||
|
} else {
|
||||||
|
return url?.query
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func urlMatchesPrevious(_ url: URL?) -> Bool {
|
||||||
|
if #available(iOS 16.0, *) {
|
||||||
|
return url?.query() == prevUrl?.query() && url?.host() == prevUrl?.host() && url?.query() == prevUrl?.query()
|
||||||
|
} else {
|
||||||
|
return url?.query == prevUrl?.query && url?.host == prevUrl?.host && url?.query == prevUrl?.query
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct WebViewActionPayload: Decodable {
|
||||||
|
enum Action: String, Decodable {
|
||||||
|
case present, store
|
||||||
|
}
|
||||||
|
|
||||||
|
let action: Action
|
||||||
|
let keyToStoreAs: String?
|
||||||
|
let jsonToStore: String?
|
||||||
|
}
|
|
@ -0,0 +1,47 @@
|
||||||
|
apply plugin: 'com.android.library'
|
||||||
|
|
||||||
|
group = 'expo.modules.blueskyswissarmy'
|
||||||
|
version = '0.6.0'
|
||||||
|
|
||||||
|
def expoModulesCorePlugin = new File(project(":expo-modules-core").projectDir.absolutePath, "ExpoModulesCorePlugin.gradle")
|
||||||
|
apply from: expoModulesCorePlugin
|
||||||
|
applyKotlinExpoModulesCorePlugin()
|
||||||
|
useCoreDependencies()
|
||||||
|
useExpoPublishing()
|
||||||
|
|
||||||
|
// If you want to use the managed Android SDK versions from expo-modules-core, set this to true.
|
||||||
|
// The Android SDK versions will be bumped from time to time in SDK releases and may introduce breaking changes in your module code.
|
||||||
|
// Most of the time, you may like to manage the Android SDK versions yourself.
|
||||||
|
def useManagedAndroidSdkVersions = false
|
||||||
|
if (useManagedAndroidSdkVersions) {
|
||||||
|
useDefaultAndroidSdkVersions()
|
||||||
|
} else {
|
||||||
|
buildscript {
|
||||||
|
// Simple helper that allows the root project to override versions declared by this library.
|
||||||
|
ext.safeExtGet = { prop, fallback ->
|
||||||
|
rootProject.ext.has(prop) ? rootProject.ext.get(prop) : fallback
|
||||||
|
}
|
||||||
|
}
|
||||||
|
project.android {
|
||||||
|
compileSdkVersion safeExtGet("compileSdkVersion", 34)
|
||||||
|
defaultConfig {
|
||||||
|
minSdkVersion safeExtGet("minSdkVersion", 21)
|
||||||
|
targetSdkVersion safeExtGet("targetSdkVersion", 34)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
android {
|
||||||
|
namespace "expo.modules.blueskyswissarmy"
|
||||||
|
defaultConfig {
|
||||||
|
versionCode 1
|
||||||
|
versionName "0.6.0"
|
||||||
|
}
|
||||||
|
lintOptions {
|
||||||
|
abortOnError false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
implementation("com.android.installreferrer:installreferrer:2.2")
|
||||||
|
}
|
|
@ -0,0 +1,2 @@
|
||||||
|
<manifest>
|
||||||
|
</manifest>
|
|
@ -0,0 +1,10 @@
|
||||||
|
package expo.modules.blueskyswissarmy.deviceprefs
|
||||||
|
|
||||||
|
import expo.modules.kotlin.modules.Module
|
||||||
|
import expo.modules.kotlin.modules.ModuleDefinition
|
||||||
|
|
||||||
|
class ExpoBlueskyDevicePrefsModule : Module() {
|
||||||
|
override fun definition() = ModuleDefinition {
|
||||||
|
Name("ExpoBlueskyDevicePrefs")
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,54 @@
|
||||||
|
package expo.modules.blueskyswissarmy.referrer
|
||||||
|
|
||||||
|
import android.util.Log
|
||||||
|
import com.android.installreferrer.api.InstallReferrerClient
|
||||||
|
import com.android.installreferrer.api.InstallReferrerStateListener
|
||||||
|
import expo.modules.kotlin.modules.Module
|
||||||
|
import expo.modules.kotlin.modules.ModuleDefinition
|
||||||
|
import expo.modules.kotlin.Promise
|
||||||
|
|
||||||
|
class ExpoBlueskyReferrerModule : Module() {
|
||||||
|
override fun definition() = ModuleDefinition {
|
||||||
|
Name("ExpoBlueskyReferrer")
|
||||||
|
|
||||||
|
AsyncFunction("getGooglePlayReferrerInfoAsync") { promise: Promise ->
|
||||||
|
val referrerClient = InstallReferrerClient.newBuilder(appContext.reactContext).build()
|
||||||
|
referrerClient.startConnection(object : InstallReferrerStateListener {
|
||||||
|
override fun onInstallReferrerSetupFinished(responseCode: Int) {
|
||||||
|
if (responseCode == InstallReferrerClient.InstallReferrerResponse.OK) {
|
||||||
|
Log.d("ExpoGooglePlayReferrer", "Successfully retrieved referrer info.")
|
||||||
|
|
||||||
|
val response = referrerClient.installReferrer
|
||||||
|
Log.d("ExpoGooglePlayReferrer", "Install referrer: ${response.installReferrer}")
|
||||||
|
|
||||||
|
promise.resolve(
|
||||||
|
mapOf(
|
||||||
|
"installReferrer" to response.installReferrer,
|
||||||
|
"clickTimestamp" to response.referrerClickTimestampSeconds,
|
||||||
|
"installTimestamp" to response.installBeginTimestampSeconds
|
||||||
|
)
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Log.d("ExpoGooglePlayReferrer", "Failed to get referrer info. Unknown error.")
|
||||||
|
promise.reject(
|
||||||
|
"ERR_GOOGLE_PLAY_REFERRER_UNKNOWN",
|
||||||
|
"Failed to get referrer info",
|
||||||
|
Exception("Failed to get referrer info")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
referrerClient.endConnection()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onInstallReferrerServiceDisconnected() {
|
||||||
|
Log.d("ExpoGooglePlayReferrer", "Failed to get referrer info. Service disconnected.")
|
||||||
|
referrerClient.endConnection()
|
||||||
|
promise.reject(
|
||||||
|
"ERR_GOOGLE_PLAY_REFERRER_DISCONNECTED",
|
||||||
|
"Failed to get referrer info",
|
||||||
|
Exception("Failed to get referrer info")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,12 @@
|
||||||
|
{
|
||||||
|
"platforms": ["ios", "tvos", "android", "web"],
|
||||||
|
"ios": {
|
||||||
|
"modules": ["ExpoBlueskyDevicePrefsModule", "ExpoBlueskyReferrerModule"]
|
||||||
|
},
|
||||||
|
"android": {
|
||||||
|
"modules": [
|
||||||
|
"expo.modules.blueskyswissarmy.deviceprefs.ExpoBlueskyDevicePrefsModule",
|
||||||
|
"expo.modules.blueskyswissarmy.referrer.ExpoBlueskyReferrerModule"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,4 @@
|
||||||
|
import * as DevicePrefs from './src/DevicePrefs'
|
||||||
|
import * as Referrer from './src/Referrer'
|
||||||
|
|
||||||
|
export {DevicePrefs, Referrer}
|
|
@ -0,0 +1,23 @@
|
||||||
|
import ExpoModulesCore
|
||||||
|
|
||||||
|
public class ExpoBlueskyDevicePrefsModule: Module {
|
||||||
|
func getDefaults(_ useAppGroup: Bool) -> UserDefaults? {
|
||||||
|
if useAppGroup {
|
||||||
|
return UserDefaults(suiteName: "group.app.bsky")
|
||||||
|
} else {
|
||||||
|
return UserDefaults.standard
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public func definition() -> ModuleDefinition {
|
||||||
|
Name("ExpoBlueskyDevicePrefs")
|
||||||
|
|
||||||
|
AsyncFunction("getStringValueAsync") { (key: String, useAppGroup: Bool) in
|
||||||
|
return self.getDefaults(useAppGroup)?.string(forKey: key)
|
||||||
|
}
|
||||||
|
|
||||||
|
AsyncFunction("setStringValueAsync") { (key: String, value: String?, useAppGroup: Bool) in
|
||||||
|
self.getDefaults(useAppGroup)?.setValue(value, forKey: key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,21 @@
|
||||||
|
Pod::Spec.new do |s|
|
||||||
|
s.name = 'ExpoBlueskySwissArmy'
|
||||||
|
s.version = '1.0.0'
|
||||||
|
s.summary = 'A collection of native tools for Bluesky'
|
||||||
|
s.description = 'A collection of native tools for Bluesky'
|
||||||
|
s.author = ''
|
||||||
|
s.homepage = 'https://github.com/bluesky-social/social-app'
|
||||||
|
s.platforms = { :ios => '13.4', :tvos => '13.4' }
|
||||||
|
s.source = { git: '' }
|
||||||
|
s.static_framework = true
|
||||||
|
|
||||||
|
s.dependency 'ExpoModulesCore'
|
||||||
|
|
||||||
|
# Swift/Objective-C compatibility
|
||||||
|
s.pod_target_xcconfig = {
|
||||||
|
'DEFINES_MODULE' => 'YES',
|
||||||
|
'SWIFT_COMPILATION_MODE' => 'wholemodule'
|
||||||
|
}
|
||||||
|
|
||||||
|
s.source_files = "**/*.{h,m,mm,swift,hpp,cpp}"
|
||||||
|
end
|
|
@ -0,0 +1,7 @@
|
||||||
|
import ExpoModulesCore
|
||||||
|
|
||||||
|
public class ExpoBlueskyReferrerModule: Module {
|
||||||
|
public func definition() -> ModuleDefinition {
|
||||||
|
Name("ExpoBlueskyReferrer")
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,18 @@
|
||||||
|
import {requireNativeModule} from 'expo-modules-core'
|
||||||
|
|
||||||
|
const NativeModule = requireNativeModule('ExpoBlueskyDevicePrefs')
|
||||||
|
|
||||||
|
export function getStringValueAsync(
|
||||||
|
key: string,
|
||||||
|
useAppGroup?: boolean,
|
||||||
|
): Promise<string | null> {
|
||||||
|
return NativeModule.getStringValueAsync(key, useAppGroup)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setStringValueAsync(
|
||||||
|
key: string,
|
||||||
|
value: string | null,
|
||||||
|
useAppGroup?: boolean,
|
||||||
|
): Promise<void> {
|
||||||
|
return NativeModule.setStringValueAsync(key, value, useAppGroup)
|
||||||
|
}
|
|
@ -0,0 +1,16 @@
|
||||||
|
import {NotImplementedError} from '../NotImplemented'
|
||||||
|
|
||||||
|
export function getStringValueAsync(
|
||||||
|
key: string,
|
||||||
|
useAppGroup?: boolean,
|
||||||
|
): Promise<string | null> {
|
||||||
|
throw new NotImplementedError({key, useAppGroup})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setStringValueAsync(
|
||||||
|
key: string,
|
||||||
|
value: string | null,
|
||||||
|
useAppGroup?: boolean,
|
||||||
|
): Promise<string | null> {
|
||||||
|
throw new NotImplementedError({key, value, useAppGroup})
|
||||||
|
}
|
|
@ -0,0 +1,16 @@
|
||||||
|
import {Platform} from 'react-native'
|
||||||
|
|
||||||
|
export class NotImplementedError extends Error {
|
||||||
|
constructor(params = {}) {
|
||||||
|
if (__DEV__) {
|
||||||
|
const caller = new Error().stack?.split('\n')[2]
|
||||||
|
super(
|
||||||
|
`Not implemented on ${Platform.OS}. Given params: ${JSON.stringify(
|
||||||
|
params,
|
||||||
|
)} ${caller}`,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
super('Not implemented')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,9 @@
|
||||||
|
import {requireNativeModule} from 'expo'
|
||||||
|
|
||||||
|
import {GooglePlayReferrerInfo} from './types'
|
||||||
|
|
||||||
|
export const NativeModule = requireNativeModule('ExpoBlueskyReferrer')
|
||||||
|
|
||||||
|
export function getGooglePlayReferrerInfoAsync(): Promise<GooglePlayReferrerInfo> {
|
||||||
|
return NativeModule.getGooglePlayReferrerInfoAsync()
|
||||||
|
}
|
|
@ -0,0 +1,7 @@
|
||||||
|
import {NotImplementedError} from '../NotImplemented'
|
||||||
|
import {GooglePlayReferrerInfo} from './types'
|
||||||
|
|
||||||
|
// @ts-ignore throws
|
||||||
|
export function getGooglePlayReferrerInfoAsync(): Promise<GooglePlayReferrerInfo> {
|
||||||
|
throw new NotImplementedError()
|
||||||
|
}
|
|
@ -0,0 +1,7 @@
|
||||||
|
export type GooglePlayReferrerInfo =
|
||||||
|
| {
|
||||||
|
installReferrer?: string
|
||||||
|
clickTimestamp?: number
|
||||||
|
installTimestamp?: number
|
||||||
|
}
|
||||||
|
| undefined
|
|
@ -49,7 +49,7 @@
|
||||||
"open-analyzer": "EXPO_PUBLIC_OPEN_ANALYZER=1 yarn build-web"
|
"open-analyzer": "EXPO_PUBLIC_OPEN_ANALYZER=1 yarn build-web"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@atproto/api": "^0.12.20",
|
"@atproto/api": "0.12.22-next.0",
|
||||||
"@bam.tech/react-native-image-resizer": "^3.0.4",
|
"@bam.tech/react-native-image-resizer": "^3.0.4",
|
||||||
"@braintree/sanitize-url": "^6.0.2",
|
"@braintree/sanitize-url": "^6.0.2",
|
||||||
"@discord/bottom-sheet": "bluesky-social/react-native-bottom-sheet",
|
"@discord/bottom-sheet": "bluesky-social/react-native-bottom-sheet",
|
||||||
|
@ -177,6 +177,7 @@
|
||||||
"react-native-pager-view": "6.2.3",
|
"react-native-pager-view": "6.2.3",
|
||||||
"react-native-picker-select": "^9.1.3",
|
"react-native-picker-select": "^9.1.3",
|
||||||
"react-native-progress": "bluesky-social/react-native-progress",
|
"react-native-progress": "bluesky-social/react-native-progress",
|
||||||
|
"react-native-qrcode-styled": "^0.3.1",
|
||||||
"react-native-reanimated": "^3.11.0",
|
"react-native-reanimated": "^3.11.0",
|
||||||
"react-native-root-siblings": "^4.1.1",
|
"react-native-root-siblings": "^4.1.1",
|
||||||
"react-native-safe-area-context": "4.10.1",
|
"react-native-safe-area-context": "4.10.1",
|
||||||
|
@ -205,7 +206,7 @@
|
||||||
"@babel/preset-env": "^7.20.0",
|
"@babel/preset-env": "^7.20.0",
|
||||||
"@babel/runtime": "^7.20.0",
|
"@babel/runtime": "^7.20.0",
|
||||||
"@did-plc/server": "^0.0.1",
|
"@did-plc/server": "^0.0.1",
|
||||||
"@expo/config-plugins": "7.8.0",
|
"@expo/config-plugins": "8.0.4",
|
||||||
"@expo/prebuild-config": "6.7.0",
|
"@expo/prebuild-config": "6.7.0",
|
||||||
"@lingui/cli": "^4.5.0",
|
"@lingui/cli": "^4.5.0",
|
||||||
"@lingui/macro": "^4.5.0",
|
"@lingui/macro": "^4.5.0",
|
||||||
|
|
|
@ -0,0 +1,16 @@
|
||||||
|
const {withEntitlementsPlist} = require('@expo/config-plugins')
|
||||||
|
|
||||||
|
const withAppEntitlements = config => {
|
||||||
|
// eslint-disable-next-line no-shadow
|
||||||
|
return withEntitlementsPlist(config, async config => {
|
||||||
|
config.modResults['com.apple.security.application-groups'] = [
|
||||||
|
`group.app.bsky`,
|
||||||
|
]
|
||||||
|
config.modResults[
|
||||||
|
'com.apple.developer.associated-appclip-app-identifiers'
|
||||||
|
] = [`$(AppIdentifierPrefix)${config.ios.bundleIdentifier}.AppClip`]
|
||||||
|
return config
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {withAppEntitlements}
|
|
@ -0,0 +1,32 @@
|
||||||
|
const {withInfoPlist} = require('@expo/config-plugins')
|
||||||
|
const plist = require('@expo/plist')
|
||||||
|
const path = require('path')
|
||||||
|
const fs = require('fs')
|
||||||
|
|
||||||
|
const withClipEntitlements = (config, {targetName}) => {
|
||||||
|
// eslint-disable-next-line no-shadow
|
||||||
|
return withInfoPlist(config, config => {
|
||||||
|
const entitlementsPath = path.join(
|
||||||
|
config.modRequest.platformProjectRoot,
|
||||||
|
targetName,
|
||||||
|
`${targetName}.entitlements`,
|
||||||
|
)
|
||||||
|
|
||||||
|
const appClipEntitlements = {
|
||||||
|
'com.apple.security.application-groups': [`group.app.bsky`],
|
||||||
|
'com.apple.developer.parent-application-identifiers': [
|
||||||
|
`$(AppIdentifierPrefix)${config.ios.bundleIdentifier}`,
|
||||||
|
],
|
||||||
|
'com.apple.developer.associated-domains': config.ios.associatedDomains,
|
||||||
|
}
|
||||||
|
|
||||||
|
fs.mkdirSync(path.dirname(entitlementsPath), {
|
||||||
|
recursive: true,
|
||||||
|
})
|
||||||
|
fs.writeFileSync(entitlementsPath, plist.default.build(appClipEntitlements))
|
||||||
|
|
||||||
|
return config
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {withClipEntitlements}
|
|
@ -0,0 +1,38 @@
|
||||||
|
const {withInfoPlist} = require('@expo/config-plugins')
|
||||||
|
const plist = require('@expo/plist')
|
||||||
|
const path = require('path')
|
||||||
|
const fs = require('fs')
|
||||||
|
|
||||||
|
const withClipInfoPlist = (config, {targetName}) => {
|
||||||
|
// eslint-disable-next-line no-shadow
|
||||||
|
return withInfoPlist(config, config => {
|
||||||
|
const targetPath = path.join(
|
||||||
|
config.modRequest.platformProjectRoot,
|
||||||
|
targetName,
|
||||||
|
'Info.plist',
|
||||||
|
)
|
||||||
|
|
||||||
|
const newPlist = plist.default.build({
|
||||||
|
NSAppClip: {
|
||||||
|
NSAppClipRequestEphemeralUserNotification: false,
|
||||||
|
NSAppClipRequestLocationConfirmation: false,
|
||||||
|
},
|
||||||
|
UILaunchScreen: {},
|
||||||
|
CFBundleName: '$(PRODUCT_NAME)',
|
||||||
|
CFBundleIdentifier: '$(PRODUCT_BUNDLE_IDENTIFIER)',
|
||||||
|
CFBundleVersion: '$(CURRENT_PROJECT_VERSION)',
|
||||||
|
CFBundleExecutable: '$(EXECUTABLE_NAME)',
|
||||||
|
CFBundlePackageType: '$(PRODUCT_BUNDLE_PACKAGE_TYPE)',
|
||||||
|
CFBundleShortVersionString: config.version,
|
||||||
|
CFBundleIconName: 'AppIcon',
|
||||||
|
UIViewControllerBasedStatusBarAppearance: 'NO',
|
||||||
|
})
|
||||||
|
|
||||||
|
fs.mkdirSync(path.dirname(targetPath), {recursive: true})
|
||||||
|
fs.writeFileSync(targetPath, newPlist)
|
||||||
|
|
||||||
|
return config
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {withClipInfoPlist}
|
|
@ -0,0 +1,40 @@
|
||||||
|
const {withXcodeProject} = require('@expo/config-plugins')
|
||||||
|
const path = require('path')
|
||||||
|
const fs = require('fs')
|
||||||
|
|
||||||
|
const FILES = ['AppDelegate.swift', 'ViewController.swift']
|
||||||
|
|
||||||
|
const withFiles = (config, {targetName}) => {
|
||||||
|
// eslint-disable-next-line no-shadow
|
||||||
|
return withXcodeProject(config, config => {
|
||||||
|
const basePath = path.join(
|
||||||
|
config.modRequest.projectRoot,
|
||||||
|
'modules',
|
||||||
|
targetName,
|
||||||
|
)
|
||||||
|
|
||||||
|
for (const file of FILES) {
|
||||||
|
const sourcePath = path.join(basePath, file)
|
||||||
|
const targetPath = path.join(
|
||||||
|
config.modRequest.platformProjectRoot,
|
||||||
|
targetName,
|
||||||
|
file,
|
||||||
|
)
|
||||||
|
|
||||||
|
fs.mkdirSync(path.dirname(targetPath), {recursive: true})
|
||||||
|
fs.copyFileSync(sourcePath, targetPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
const imagesBasePath = path.join(basePath, 'Images.xcassets')
|
||||||
|
const imagesTargetPath = path.join(
|
||||||
|
config.modRequest.platformProjectRoot,
|
||||||
|
targetName,
|
||||||
|
'Images.xcassets',
|
||||||
|
)
|
||||||
|
fs.cpSync(imagesBasePath, imagesTargetPath, {recursive: true})
|
||||||
|
|
||||||
|
return config
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {withFiles}
|
|
@ -0,0 +1,40 @@
|
||||||
|
const {withPlugins} = require('@expo/config-plugins')
|
||||||
|
const {withAppEntitlements} = require('./withAppEntitlements')
|
||||||
|
const {withClipEntitlements} = require('./withClipEntitlements')
|
||||||
|
const {withClipInfoPlist} = require('./withClipInfoPlist')
|
||||||
|
const {withFiles} = require('./withFiles')
|
||||||
|
const {withXcodeTarget} = require('./withXcodeTarget')
|
||||||
|
|
||||||
|
const APP_CLIP_TARGET_NAME = 'BlueskyClip'
|
||||||
|
|
||||||
|
const withStarterPackAppClip = config => {
|
||||||
|
return withPlugins(config, [
|
||||||
|
withAppEntitlements,
|
||||||
|
[
|
||||||
|
withClipEntitlements,
|
||||||
|
{
|
||||||
|
targetName: APP_CLIP_TARGET_NAME,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[
|
||||||
|
withClipInfoPlist,
|
||||||
|
{
|
||||||
|
targetName: APP_CLIP_TARGET_NAME,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[
|
||||||
|
withFiles,
|
||||||
|
{
|
||||||
|
targetName: APP_CLIP_TARGET_NAME,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[
|
||||||
|
withXcodeTarget,
|
||||||
|
{
|
||||||
|
targetName: APP_CLIP_TARGET_NAME,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = withStarterPackAppClip
|
|
@ -0,0 +1,91 @@
|
||||||
|
const {withXcodeProject} = require('@expo/config-plugins')
|
||||||
|
|
||||||
|
const BUILD_PHASE_FILES = ['AppDelegate.swift', 'ViewController.swift']
|
||||||
|
|
||||||
|
const withXcodeTarget = (config, {targetName}) => {
|
||||||
|
// eslint-disable-next-line no-shadow
|
||||||
|
return withXcodeProject(config, config => {
|
||||||
|
const pbxProject = config.modResults
|
||||||
|
|
||||||
|
const target = pbxProject.addTarget(targetName, 'application', targetName)
|
||||||
|
target.pbxNativeTarget.productType = `"com.apple.product-type.application.on-demand-install-capable"`
|
||||||
|
pbxProject.addBuildPhase(
|
||||||
|
BUILD_PHASE_FILES.map(f => `${targetName}/${f}`),
|
||||||
|
'PBXSourcesBuildPhase',
|
||||||
|
'Sources',
|
||||||
|
target.uuid,
|
||||||
|
'application',
|
||||||
|
'"AppClips"',
|
||||||
|
)
|
||||||
|
pbxProject.addBuildPhase(
|
||||||
|
[`${targetName}/Images.xcassets`],
|
||||||
|
'PBXResourcesBuildPhase',
|
||||||
|
'Resources',
|
||||||
|
target.uuid,
|
||||||
|
'application',
|
||||||
|
'"AppClips"',
|
||||||
|
)
|
||||||
|
|
||||||
|
const pbxGroup = pbxProject.addPbxGroup([
|
||||||
|
'AppDelegate.swift',
|
||||||
|
'ViewController.swift',
|
||||||
|
'Images.xcassets',
|
||||||
|
`${targetName}.entitlements`,
|
||||||
|
'Info.plist',
|
||||||
|
])
|
||||||
|
|
||||||
|
pbxProject.addFile(`${targetName}/Info.plist`, pbxGroup.uuid)
|
||||||
|
const configurations = pbxProject.pbxXCBuildConfigurationSection()
|
||||||
|
for (const key in configurations) {
|
||||||
|
if (typeof configurations[key].buildSettings !== 'undefined') {
|
||||||
|
const buildSettingsObj = configurations[key].buildSettings
|
||||||
|
if (
|
||||||
|
typeof buildSettingsObj.PRODUCT_NAME !== 'undefined' &&
|
||||||
|
buildSettingsObj.PRODUCT_NAME === `"${targetName}"`
|
||||||
|
) {
|
||||||
|
buildSettingsObj.CLANG_ENABLE_MODULES = 'YES'
|
||||||
|
buildSettingsObj.INFOPLIST_FILE = `"${targetName}/Info.plist"`
|
||||||
|
buildSettingsObj.CODE_SIGN_ENTITLEMENTS = `"${targetName}/${targetName}.entitlements"`
|
||||||
|
buildSettingsObj.CODE_SIGN_STYLE = 'Automatic'
|
||||||
|
buildSettingsObj.CURRENT_PROJECT_VERSION = `"${
|
||||||
|
process.env.BSKY_IOS_BUILD_NUMBER ?? '1'
|
||||||
|
}"`
|
||||||
|
buildSettingsObj.GENERATE_INFOPLIST_FILE = 'YES'
|
||||||
|
buildSettingsObj.MARKETING_VERSION = `"${config.version}"`
|
||||||
|
buildSettingsObj.PRODUCT_BUNDLE_IDENTIFIER = `"${config.ios?.bundleIdentifier}.AppClip"`
|
||||||
|
buildSettingsObj.SWIFT_EMIT_LOC_STRINGS = 'YES'
|
||||||
|
buildSettingsObj.SWIFT_VERSION = '5.0'
|
||||||
|
buildSettingsObj.TARGETED_DEVICE_FAMILY = `"1"`
|
||||||
|
buildSettingsObj.DEVELOPMENT_TEAM = 'B3LX46C5HS'
|
||||||
|
buildSettingsObj.IPHONEOS_DEPLOYMENT_TARGET = '14.0'
|
||||||
|
buildSettingsObj.ASSETCATALOG_COMPILER_APPICON_NAME = 'AppIcon'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pbxProject.addTargetAttribute('DevelopmentTeam', 'B3LX46C5HS', targetName)
|
||||||
|
|
||||||
|
if (!pbxProject.hash.project.objects.PBXTargetDependency) {
|
||||||
|
pbxProject.hash.project.objects.PBXTargetDependency = {}
|
||||||
|
}
|
||||||
|
if (!pbxProject.hash.project.objects.PBXContainerItemProxy) {
|
||||||
|
pbxProject.hash.project.objects.PBXContainerItemProxy = {}
|
||||||
|
}
|
||||||
|
pbxProject.addTargetDependency(pbxProject.getFirstTarget().uuid, [
|
||||||
|
target.uuid,
|
||||||
|
])
|
||||||
|
|
||||||
|
pbxProject.addBuildPhase(
|
||||||
|
[`${targetName}.app`],
|
||||||
|
'PBXCopyFilesBuildPhase',
|
||||||
|
'Embed App Clips',
|
||||||
|
pbxProject.getFirstTarget().uuid,
|
||||||
|
'application',
|
||||||
|
'"AppClips"',
|
||||||
|
)
|
||||||
|
|
||||||
|
return config
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {withXcodeTarget}
|
|
@ -1,6 +1,7 @@
|
||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
IOS_SHARE_EXTENSION_DIRECTORY="./ios/Share-with-Bluesky"
|
IOS_SHARE_EXTENSION_DIRECTORY="./ios/Share-with-Bluesky"
|
||||||
IOS_NOTIFICATION_EXTENSION_DIRECTORY="./ios/BlueskyNSE"
|
IOS_NOTIFICATION_EXTENSION_DIRECTORY="./ios/BlueskyNSE"
|
||||||
|
IOS_APP_CLIP_DIRECTORY="./ios/BlueskyClip"
|
||||||
MODULES_DIRECTORY="./modules"
|
MODULES_DIRECTORY="./modules"
|
||||||
|
|
||||||
if [ ! -d $IOS_SHARE_EXTENSION_DIRECTORY ]; then
|
if [ ! -d $IOS_SHARE_EXTENSION_DIRECTORY ]; then
|
||||||
|
@ -16,3 +17,11 @@ if [ ! -d $IOS_NOTIFICATION_EXTENSION_DIRECTORY ]; then
|
||||||
else
|
else
|
||||||
cp -R $IOS_NOTIFICATION_EXTENSION_DIRECTORY $MODULES_DIRECTORY
|
cp -R $IOS_NOTIFICATION_EXTENSION_DIRECTORY $MODULES_DIRECTORY
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
|
||||||
|
if [ ! -d $IOS_APP_CLIP_DIRECTORY ]; then
|
||||||
|
echo "$IOS_APP_CLIP_DIRECTORY not found inside of your iOS project."
|
||||||
|
exit 1
|
||||||
|
else
|
||||||
|
cp -R $IOS_APP_CLIP_DIRECTORY $MODULES_DIRECTORY
|
||||||
|
fi
|
||||||
|
|
|
@ -46,11 +46,13 @@ import {readLastActiveAccount} from '#/state/session/util'
|
||||||
import {Provider as ShellStateProvider} from '#/state/shell'
|
import {Provider as ShellStateProvider} from '#/state/shell'
|
||||||
import {Provider as LoggedOutViewProvider} from '#/state/shell/logged-out'
|
import {Provider as LoggedOutViewProvider} from '#/state/shell/logged-out'
|
||||||
import {Provider as SelectedFeedProvider} from '#/state/shell/selected-feed'
|
import {Provider as SelectedFeedProvider} from '#/state/shell/selected-feed'
|
||||||
|
import {Provider as StarterPackProvider} from '#/state/shell/starter-pack'
|
||||||
import {TestCtrls} from '#/view/com/testing/TestCtrls'
|
import {TestCtrls} from '#/view/com/testing/TestCtrls'
|
||||||
import * as Toast from '#/view/com/util/Toast'
|
import * as Toast from '#/view/com/util/Toast'
|
||||||
import {Shell} from '#/view/shell'
|
import {Shell} from '#/view/shell'
|
||||||
import {ThemeProvider as Alf} from '#/alf'
|
import {ThemeProvider as Alf} from '#/alf'
|
||||||
import {useColorModeTheme} from '#/alf/util/useColorModeTheme'
|
import {useColorModeTheme} from '#/alf/util/useColorModeTheme'
|
||||||
|
import {useStarterPackEntry} from '#/components/hooks/useStarterPackEntry'
|
||||||
import {Provider as PortalProvider} from '#/components/Portal'
|
import {Provider as PortalProvider} from '#/components/Portal'
|
||||||
import {Splash} from '#/Splash'
|
import {Splash} from '#/Splash'
|
||||||
import {BackgroundNotificationPreferencesProvider} from '../modules/expo-background-notification-handler/src/BackgroundNotificationHandlerProvider'
|
import {BackgroundNotificationPreferencesProvider} from '../modules/expo-background-notification-handler/src/BackgroundNotificationHandlerProvider'
|
||||||
|
@ -67,6 +69,7 @@ function InnerApp() {
|
||||||
const {_} = useLingui()
|
const {_} = useLingui()
|
||||||
|
|
||||||
useIntentHandler()
|
useIntentHandler()
|
||||||
|
const hasCheckedReferrer = useStarterPackEntry()
|
||||||
|
|
||||||
// init
|
// init
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
@ -98,7 +101,7 @@ function InnerApp() {
|
||||||
<SafeAreaProvider initialMetrics={initialWindowMetrics}>
|
<SafeAreaProvider initialMetrics={initialWindowMetrics}>
|
||||||
<Alf theme={theme}>
|
<Alf theme={theme}>
|
||||||
<ThemeProvider theme={theme}>
|
<ThemeProvider theme={theme}>
|
||||||
<Splash isReady={isReady}>
|
<Splash isReady={isReady && hasCheckedReferrer}>
|
||||||
<RootSiblingParent>
|
<RootSiblingParent>
|
||||||
<React.Fragment
|
<React.Fragment
|
||||||
// Resets the entire tree below when it changes:
|
// Resets the entire tree below when it changes:
|
||||||
|
@ -164,7 +167,9 @@ function App() {
|
||||||
<LightboxStateProvider>
|
<LightboxStateProvider>
|
||||||
<I18nProvider>
|
<I18nProvider>
|
||||||
<PortalProvider>
|
<PortalProvider>
|
||||||
<InnerApp />
|
<StarterPackProvider>
|
||||||
|
<InnerApp />
|
||||||
|
</StarterPackProvider>
|
||||||
</PortalProvider>
|
</PortalProvider>
|
||||||
</I18nProvider>
|
</I18nProvider>
|
||||||
</LightboxStateProvider>
|
</LightboxStateProvider>
|
||||||
|
|
|
@ -35,11 +35,13 @@ import {readLastActiveAccount} from '#/state/session/util'
|
||||||
import {Provider as ShellStateProvider} from '#/state/shell'
|
import {Provider as ShellStateProvider} from '#/state/shell'
|
||||||
import {Provider as LoggedOutViewProvider} from '#/state/shell/logged-out'
|
import {Provider as LoggedOutViewProvider} from '#/state/shell/logged-out'
|
||||||
import {Provider as SelectedFeedProvider} from '#/state/shell/selected-feed'
|
import {Provider as SelectedFeedProvider} from '#/state/shell/selected-feed'
|
||||||
|
import {Provider as StarterPackProvider} from '#/state/shell/starter-pack'
|
||||||
import * as Toast from '#/view/com/util/Toast'
|
import * as Toast from '#/view/com/util/Toast'
|
||||||
import {ToastContainer} from '#/view/com/util/Toast.web'
|
import {ToastContainer} from '#/view/com/util/Toast.web'
|
||||||
import {Shell} from '#/view/shell/index'
|
import {Shell} from '#/view/shell/index'
|
||||||
import {ThemeProvider as Alf} from '#/alf'
|
import {ThemeProvider as Alf} from '#/alf'
|
||||||
import {useColorModeTheme} from '#/alf/util/useColorModeTheme'
|
import {useColorModeTheme} from '#/alf/util/useColorModeTheme'
|
||||||
|
import {useStarterPackEntry} from '#/components/hooks/useStarterPackEntry'
|
||||||
import {Provider as PortalProvider} from '#/components/Portal'
|
import {Provider as PortalProvider} from '#/components/Portal'
|
||||||
import {BackgroundNotificationPreferencesProvider} from '../modules/expo-background-notification-handler/src/BackgroundNotificationHandlerProvider'
|
import {BackgroundNotificationPreferencesProvider} from '../modules/expo-background-notification-handler/src/BackgroundNotificationHandlerProvider'
|
||||||
import I18nProvider from './locale/i18nProvider'
|
import I18nProvider from './locale/i18nProvider'
|
||||||
|
@ -52,6 +54,7 @@ function InnerApp() {
|
||||||
const theme = useColorModeTheme()
|
const theme = useColorModeTheme()
|
||||||
const {_} = useLingui()
|
const {_} = useLingui()
|
||||||
useIntentHandler()
|
useIntentHandler()
|
||||||
|
const hasCheckedReferrer = useStarterPackEntry()
|
||||||
|
|
||||||
// init
|
// init
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
@ -77,7 +80,7 @@ function InnerApp() {
|
||||||
}, [_])
|
}, [_])
|
||||||
|
|
||||||
// wait for session to resume
|
// wait for session to resume
|
||||||
if (!isReady) return null
|
if (!isReady || !hasCheckedReferrer) return null
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<KeyboardProvider enabled={false}>
|
<KeyboardProvider enabled={false}>
|
||||||
|
@ -146,7 +149,9 @@ function App() {
|
||||||
<LightboxStateProvider>
|
<LightboxStateProvider>
|
||||||
<I18nProvider>
|
<I18nProvider>
|
||||||
<PortalProvider>
|
<PortalProvider>
|
||||||
<InnerApp />
|
<StarterPackProvider>
|
||||||
|
<InnerApp />
|
||||||
|
</StarterPackProvider>
|
||||||
</PortalProvider>
|
</PortalProvider>
|
||||||
</I18nProvider>
|
</I18nProvider>
|
||||||
</LightboxStateProvider>
|
</LightboxStateProvider>
|
||||||
|
|
|
@ -43,6 +43,8 @@ import HashtagScreen from '#/screens/Hashtag'
|
||||||
import {ModerationScreen} from '#/screens/Moderation'
|
import {ModerationScreen} from '#/screens/Moderation'
|
||||||
import {ProfileKnownFollowersScreen} from '#/screens/Profile/KnownFollowers'
|
import {ProfileKnownFollowersScreen} from '#/screens/Profile/KnownFollowers'
|
||||||
import {ProfileLabelerLikedByScreen} from '#/screens/Profile/ProfileLabelerLikedBy'
|
import {ProfileLabelerLikedByScreen} from '#/screens/Profile/ProfileLabelerLikedBy'
|
||||||
|
import {StarterPackScreen} from '#/screens/StarterPack/StarterPackScreen'
|
||||||
|
import {Wizard} from '#/screens/StarterPack/Wizard'
|
||||||
import {init as initAnalytics} from './lib/analytics/analytics'
|
import {init as initAnalytics} from './lib/analytics/analytics'
|
||||||
import {useWebScrollRestoration} from './lib/hooks/useWebScrollRestoration'
|
import {useWebScrollRestoration} from './lib/hooks/useWebScrollRestoration'
|
||||||
import {attachRouteToLogEvents, logEvent} from './lib/statsig/statsig'
|
import {attachRouteToLogEvents, logEvent} from './lib/statsig/statsig'
|
||||||
|
@ -317,6 +319,21 @@ function commonScreens(Stack: typeof HomeTab, unreadCountLabel?: string) {
|
||||||
getComponent={() => FeedsScreen}
|
getComponent={() => FeedsScreen}
|
||||||
options={{title: title(msg`Feeds`)}}
|
options={{title: title(msg`Feeds`)}}
|
||||||
/>
|
/>
|
||||||
|
<Stack.Screen
|
||||||
|
name="StarterPack"
|
||||||
|
getComponent={() => StarterPackScreen}
|
||||||
|
options={{title: title(msg`Starter Pack`), requireAuth: true}}
|
||||||
|
/>
|
||||||
|
<Stack.Screen
|
||||||
|
name="StarterPackWizard"
|
||||||
|
getComponent={() => Wizard}
|
||||||
|
options={{title: title(msg`Create a starter pack`), requireAuth: true}}
|
||||||
|
/>
|
||||||
|
<Stack.Screen
|
||||||
|
name="StarterPackEdit"
|
||||||
|
getComponent={() => Wizard}
|
||||||
|
options={{title: title(msg`Edit your starter pack`), requireAuth: true}}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
@ -371,6 +388,7 @@ function HomeTabNavigator() {
|
||||||
contentStyle: pal.view,
|
contentStyle: pal.view,
|
||||||
}}>
|
}}>
|
||||||
<HomeTab.Screen name="Home" getComponent={() => HomeScreen} />
|
<HomeTab.Screen name="Home" getComponent={() => HomeScreen} />
|
||||||
|
<HomeTab.Screen name="Start" getComponent={() => HomeScreen} />
|
||||||
{commonScreens(HomeTab)}
|
{commonScreens(HomeTab)}
|
||||||
</HomeTab.Navigator>
|
</HomeTab.Navigator>
|
||||||
)
|
)
|
||||||
|
@ -507,6 +525,11 @@ const FlatNavigator = () => {
|
||||||
getComponent={() => MessagesScreen}
|
getComponent={() => MessagesScreen}
|
||||||
options={{title: title(msg`Messages`), requireAuth: true}}
|
options={{title: title(msg`Messages`), requireAuth: true}}
|
||||||
/>
|
/>
|
||||||
|
<Flat.Screen
|
||||||
|
name="Start"
|
||||||
|
getComponent={() => HomeScreen}
|
||||||
|
options={{title: title(msg`Home`)}}
|
||||||
|
/>
|
||||||
{commonScreens(Flat as typeof HomeTab, numUnread)}
|
{commonScreens(Flat as typeof HomeTab, numUnread)}
|
||||||
</Flat.Navigator>
|
</Flat.Navigator>
|
||||||
)
|
)
|
||||||
|
|
|
@ -0,0 +1,23 @@
|
||||||
|
import React from 'react'
|
||||||
|
import {StyleProp, ViewStyle} from 'react-native'
|
||||||
|
import {LinearGradient} from 'expo-linear-gradient'
|
||||||
|
|
||||||
|
import {gradients} from '#/alf/tokens'
|
||||||
|
|
||||||
|
export function LinearGradientBackground({
|
||||||
|
style,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
style: StyleProp<ViewStyle>
|
||||||
|
children: React.ReactNode
|
||||||
|
}) {
|
||||||
|
const gradient = gradients.sky.values.map(([_, color]) => {
|
||||||
|
return color
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<LinearGradient colors={gradient} style={style}>
|
||||||
|
{children}
|
||||||
|
</LinearGradient>
|
||||||
|
)
|
||||||
|
}
|
|
@ -9,11 +9,13 @@ import {useGetTimeAgo} from '#/lib/hooks/useTimeAgo'
|
||||||
import {useModerationOpts} from '#/state/preferences/moderation-opts'
|
import {useModerationOpts} from '#/state/preferences/moderation-opts'
|
||||||
import {HITSLOP_10} from 'lib/constants'
|
import {HITSLOP_10} from 'lib/constants'
|
||||||
import {sanitizeDisplayName} from 'lib/strings/display-names'
|
import {sanitizeDisplayName} from 'lib/strings/display-names'
|
||||||
import {atoms as a} from '#/alf'
|
import {isWeb} from 'platform/detection'
|
||||||
import {Button} from '#/components/Button'
|
import {atoms as a, useTheme} from '#/alf'
|
||||||
|
import {Button, ButtonText} from '#/components/Button'
|
||||||
import * as Dialog from '#/components/Dialog'
|
import * as Dialog from '#/components/Dialog'
|
||||||
import {useDialogControl} from '#/components/Dialog'
|
import {useDialogControl} from '#/components/Dialog'
|
||||||
import {Newskie} from '#/components/icons/Newskie'
|
import {Newskie} from '#/components/icons/Newskie'
|
||||||
|
import * as StarterPackCard from '#/components/StarterPack/StarterPackCard'
|
||||||
import {Text} from '#/components/Typography'
|
import {Text} from '#/components/Typography'
|
||||||
|
|
||||||
export function NewskieDialog({
|
export function NewskieDialog({
|
||||||
|
@ -24,6 +26,7 @@ export function NewskieDialog({
|
||||||
disabled?: boolean
|
disabled?: boolean
|
||||||
}) {
|
}) {
|
||||||
const {_} = useLingui()
|
const {_} = useLingui()
|
||||||
|
const t = useTheme()
|
||||||
const moderationOpts = useModerationOpts()
|
const moderationOpts = useModerationOpts()
|
||||||
const control = useDialogControl()
|
const control = useDialogControl()
|
||||||
const profileName = React.useMemo(() => {
|
const profileName = React.useMemo(() => {
|
||||||
|
@ -68,15 +71,62 @@ export function NewskieDialog({
|
||||||
label={_(msg`New user info dialog`)}
|
label={_(msg`New user info dialog`)}
|
||||||
style={[{width: 'auto', maxWidth: 400, minWidth: 200}]}>
|
style={[{width: 'auto', maxWidth: 400, minWidth: 200}]}>
|
||||||
<View style={[a.gap_sm]}>
|
<View style={[a.gap_sm]}>
|
||||||
<Text style={[a.font_bold, a.text_xl]}>
|
<View style={[a.align_center]}>
|
||||||
<Trans>Say hello!</Trans>
|
<Newskie
|
||||||
</Text>
|
width={64}
|
||||||
<Text style={[a.text_md]}>
|
height={64}
|
||||||
<Trans>
|
fill="#FFC404"
|
||||||
{profileName} joined Bluesky{' '}
|
style={{marginTop: -10}}
|
||||||
{timeAgo(createdAt, now, {format: 'long'})} ago
|
/>
|
||||||
</Trans>
|
<Text style={[a.font_bold, a.text_xl, {marginTop: -10}]}>
|
||||||
|
<Trans>Say hello!</Trans>
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<Text style={[a.text_md, a.text_center, a.leading_tight]}>
|
||||||
|
{profile.joinedViaStarterPack ? (
|
||||||
|
<Trans>
|
||||||
|
{profileName} joined Bluesky using a starter pack{' '}
|
||||||
|
{timeAgo(createdAt, now, {format: 'long'})} ago
|
||||||
|
</Trans>
|
||||||
|
) : (
|
||||||
|
<Trans>
|
||||||
|
{profileName} joined Bluesky{' '}
|
||||||
|
{timeAgo(createdAt, now, {format: 'long'})} ago
|
||||||
|
</Trans>
|
||||||
|
)}
|
||||||
</Text>
|
</Text>
|
||||||
|
{profile.joinedViaStarterPack ? (
|
||||||
|
<StarterPackCard.Link
|
||||||
|
starterPack={profile.joinedViaStarterPack}
|
||||||
|
onPress={() => {
|
||||||
|
control.close()
|
||||||
|
}}>
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
a.flex_1,
|
||||||
|
a.mt_sm,
|
||||||
|
a.p_lg,
|
||||||
|
a.border,
|
||||||
|
a.rounded_sm,
|
||||||
|
t.atoms.border_contrast_low,
|
||||||
|
]}>
|
||||||
|
<StarterPackCard.Card
|
||||||
|
starterPack={profile.joinedViaStarterPack}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</StarterPackCard.Link>
|
||||||
|
) : null}
|
||||||
|
<Button
|
||||||
|
label={_(msg`Close`)}
|
||||||
|
variant="solid"
|
||||||
|
color="secondary"
|
||||||
|
size="small"
|
||||||
|
style={[a.mt_sm, isWeb && [a.self_center, {marginLeft: 'auto'}]]}
|
||||||
|
onPress={() => control.close()}>
|
||||||
|
<ButtonText>
|
||||||
|
<Trans>Close</Trans>
|
||||||
|
</ButtonText>
|
||||||
|
</Button>
|
||||||
</View>
|
</View>
|
||||||
</Dialog.ScrollableInner>
|
</Dialog.ScrollableInner>
|
||||||
</Dialog.Outer>
|
</Dialog.Outer>
|
||||||
|
|
|
@ -0,0 +1,91 @@
|
||||||
|
import React from 'react'
|
||||||
|
import {View} from 'react-native'
|
||||||
|
import {AppBskyActorDefs, moderateProfile, ModerationOpts} from '@atproto/api'
|
||||||
|
|
||||||
|
import {createSanitizedDisplayName} from 'lib/moderation/create-sanitized-display-name'
|
||||||
|
import {sanitizeHandle} from 'lib/strings/handles'
|
||||||
|
import {useProfileShadow} from 'state/cache/profile-shadow'
|
||||||
|
import {useSession} from 'state/session'
|
||||||
|
import {FollowButton} from 'view/com/profile/FollowButton'
|
||||||
|
import {ProfileCardPills} from 'view/com/profile/ProfileCard'
|
||||||
|
import {UserAvatar} from 'view/com/util/UserAvatar'
|
||||||
|
import {atoms as a, useTheme} from '#/alf'
|
||||||
|
import {Link} from '#/components/Link'
|
||||||
|
import {Text} from '#/components/Typography'
|
||||||
|
|
||||||
|
export function Default({
|
||||||
|
profile: profileUnshadowed,
|
||||||
|
moderationOpts,
|
||||||
|
logContext = 'ProfileCard',
|
||||||
|
}: {
|
||||||
|
profile: AppBskyActorDefs.ProfileViewDetailed
|
||||||
|
moderationOpts: ModerationOpts
|
||||||
|
logContext?: 'ProfileCard' | 'StarterPackProfilesList'
|
||||||
|
}) {
|
||||||
|
const t = useTheme()
|
||||||
|
const {currentAccount, hasSession} = useSession()
|
||||||
|
|
||||||
|
const profile = useProfileShadow(profileUnshadowed)
|
||||||
|
const name = createSanitizedDisplayName(profile)
|
||||||
|
const handle = `@${sanitizeHandle(profile.handle)}`
|
||||||
|
const moderation = moderateProfile(profile, moderationOpts)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Wrapper did={profile.did}>
|
||||||
|
<View style={[a.flex_row, a.gap_sm]}>
|
||||||
|
<UserAvatar
|
||||||
|
size={42}
|
||||||
|
avatar={profile.avatar}
|
||||||
|
type={
|
||||||
|
profile.associated?.labeler
|
||||||
|
? 'labeler'
|
||||||
|
: profile.associated?.feedgens
|
||||||
|
? 'algo'
|
||||||
|
: 'user'
|
||||||
|
}
|
||||||
|
moderation={moderation.ui('avatar')}
|
||||||
|
/>
|
||||||
|
<View style={[a.flex_1]}>
|
||||||
|
<Text
|
||||||
|
style={[a.text_md, a.font_bold, a.leading_snug]}
|
||||||
|
numberOfLines={1}>
|
||||||
|
{name}
|
||||||
|
</Text>
|
||||||
|
<Text
|
||||||
|
style={[a.leading_snug, t.atoms.text_contrast_medium]}
|
||||||
|
numberOfLines={1}>
|
||||||
|
{handle}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
{hasSession && profile.did !== currentAccount?.did && (
|
||||||
|
<View style={[a.justify_center, {marginLeft: 'auto'}]}>
|
||||||
|
<FollowButton profile={profile} logContext={logContext} />
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
<View style={[a.mb_xs]}>
|
||||||
|
<ProfileCardPills
|
||||||
|
followedBy={Boolean(profile.viewer?.followedBy)}
|
||||||
|
moderation={moderation}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
{profile.description && (
|
||||||
|
<Text numberOfLines={3} style={[a.leading_snug]}>
|
||||||
|
{profile.description}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Wrapper>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Wrapper({did, children}: {did: string; children: React.ReactNode}) {
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
to={{
|
||||||
|
screen: 'Profile',
|
||||||
|
params: {name: did},
|
||||||
|
}}>
|
||||||
|
<View style={[a.flex_1, a.gap_xs]}>{children}</View>
|
||||||
|
</Link>
|
||||||
|
)
|
||||||
|
}
|
|
@ -55,6 +55,9 @@ export function SelectReportOptionView({
|
||||||
} else if (props.params.type === 'feedgen') {
|
} else if (props.params.type === 'feedgen') {
|
||||||
title = _(msg`Report this feed`)
|
title = _(msg`Report this feed`)
|
||||||
description = _(msg`Why should this feed be reviewed?`)
|
description = _(msg`Why should this feed be reviewed?`)
|
||||||
|
} else if (props.params.type === 'starterpack') {
|
||||||
|
title = _(msg`Report this starter pack`)
|
||||||
|
description = _(msg`Why should this starter pack be reviewed?`)
|
||||||
} else if (props.params.type === 'convoMessage') {
|
} else if (props.params.type === 'convoMessage') {
|
||||||
title = _(msg`Report this message`)
|
title = _(msg`Report this message`)
|
||||||
description = _(msg`Why should this message be reviewed?`)
|
description = _(msg`Why should this message be reviewed?`)
|
||||||
|
|
|
@ -4,7 +4,7 @@ export type ReportDialogProps = {
|
||||||
control: Dialog.DialogOuterProps['control']
|
control: Dialog.DialogOuterProps['control']
|
||||||
params:
|
params:
|
||||||
| {
|
| {
|
||||||
type: 'post' | 'list' | 'feedgen' | 'other'
|
type: 'post' | 'list' | 'feedgen' | 'starterpack' | 'other'
|
||||||
uri: string
|
uri: string
|
||||||
cid: string
|
cid: string
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,68 @@
|
||||||
|
import React, {useCallback} from 'react'
|
||||||
|
import {ListRenderItemInfo, View} from 'react-native'
|
||||||
|
import {AppBskyFeedDefs} from '@atproto/api'
|
||||||
|
import {GeneratorView} from '@atproto/api/dist/client/types/app/bsky/feed/defs'
|
||||||
|
|
||||||
|
import {useBottomBarOffset} from 'lib/hooks/useBottomBarOffset'
|
||||||
|
import {isNative, isWeb} from 'platform/detection'
|
||||||
|
import {List, ListRef} from 'view/com/util/List'
|
||||||
|
import {SectionRef} from '#/screens/Profile/Sections/types'
|
||||||
|
import {atoms as a, useTheme} from '#/alf'
|
||||||
|
import * as FeedCard from '#/components/FeedCard'
|
||||||
|
|
||||||
|
function keyExtractor(item: AppBskyFeedDefs.GeneratorView) {
|
||||||
|
return item.uri
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ProfilesListProps {
|
||||||
|
feeds: AppBskyFeedDefs.GeneratorView[]
|
||||||
|
headerHeight: number
|
||||||
|
scrollElRef: ListRef
|
||||||
|
}
|
||||||
|
|
||||||
|
export const FeedsList = React.forwardRef<SectionRef, ProfilesListProps>(
|
||||||
|
function FeedsListImpl({feeds, headerHeight, scrollElRef}, ref) {
|
||||||
|
const [initialHeaderHeight] = React.useState(headerHeight)
|
||||||
|
const bottomBarOffset = useBottomBarOffset(20)
|
||||||
|
const t = useTheme()
|
||||||
|
|
||||||
|
const onScrollToTop = useCallback(() => {
|
||||||
|
scrollElRef.current?.scrollToOffset({
|
||||||
|
animated: isNative,
|
||||||
|
offset: -headerHeight,
|
||||||
|
})
|
||||||
|
}, [scrollElRef, headerHeight])
|
||||||
|
|
||||||
|
React.useImperativeHandle(ref, () => ({
|
||||||
|
scrollToTop: onScrollToTop,
|
||||||
|
}))
|
||||||
|
|
||||||
|
const renderItem = ({item, index}: ListRenderItemInfo<GeneratorView>) => {
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
a.p_lg,
|
||||||
|
(isWeb || index !== 0) && a.border_t,
|
||||||
|
t.atoms.border_contrast_low,
|
||||||
|
]}>
|
||||||
|
<FeedCard.Default type="feed" view={item} />
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<List
|
||||||
|
data={feeds}
|
||||||
|
renderItem={renderItem}
|
||||||
|
keyExtractor={keyExtractor}
|
||||||
|
ref={scrollElRef}
|
||||||
|
headerOffset={headerHeight}
|
||||||
|
ListFooterComponent={
|
||||||
|
<View style={[{height: initialHeaderHeight + bottomBarOffset}]} />
|
||||||
|
}
|
||||||
|
showsVerticalScrollIndicator={false}
|
||||||
|
desktopFixedHeight={true}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
|
@ -0,0 +1,119 @@
|
||||||
|
import React, {useCallback} from 'react'
|
||||||
|
import {ListRenderItemInfo, View} from 'react-native'
|
||||||
|
import {
|
||||||
|
AppBskyActorDefs,
|
||||||
|
AppBskyGraphGetList,
|
||||||
|
AtUri,
|
||||||
|
ModerationOpts,
|
||||||
|
} from '@atproto/api'
|
||||||
|
import {InfiniteData, UseInfiniteQueryResult} from '@tanstack/react-query'
|
||||||
|
|
||||||
|
import {useBottomBarOffset} from 'lib/hooks/useBottomBarOffset'
|
||||||
|
import {isNative, isWeb} from 'platform/detection'
|
||||||
|
import {useSession} from 'state/session'
|
||||||
|
import {List, ListRef} from 'view/com/util/List'
|
||||||
|
import {SectionRef} from '#/screens/Profile/Sections/types'
|
||||||
|
import {atoms as a, useTheme} from '#/alf'
|
||||||
|
import {Default as ProfileCard} from '#/components/ProfileCard'
|
||||||
|
|
||||||
|
function keyExtractor(item: AppBskyActorDefs.ProfileViewBasic, index: number) {
|
||||||
|
return `${item.did}-${index}`
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ProfilesListProps {
|
||||||
|
listUri: string
|
||||||
|
listMembersQuery: UseInfiniteQueryResult<
|
||||||
|
InfiniteData<AppBskyGraphGetList.OutputSchema>
|
||||||
|
>
|
||||||
|
moderationOpts: ModerationOpts
|
||||||
|
headerHeight: number
|
||||||
|
scrollElRef: ListRef
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ProfilesList = React.forwardRef<SectionRef, ProfilesListProps>(
|
||||||
|
function ProfilesListImpl(
|
||||||
|
{listUri, listMembersQuery, moderationOpts, headerHeight, scrollElRef},
|
||||||
|
ref,
|
||||||
|
) {
|
||||||
|
const t = useTheme()
|
||||||
|
const [initialHeaderHeight] = React.useState(headerHeight)
|
||||||
|
const bottomBarOffset = useBottomBarOffset(20)
|
||||||
|
const {currentAccount} = useSession()
|
||||||
|
|
||||||
|
const [isPTRing, setIsPTRing] = React.useState(false)
|
||||||
|
|
||||||
|
const {data, refetch} = listMembersQuery
|
||||||
|
|
||||||
|
// The server returns these sorted by descending creation date, so we want to invert
|
||||||
|
const profiles = data?.pages
|
||||||
|
.flatMap(p => p.items.map(i => i.subject))
|
||||||
|
.reverse()
|
||||||
|
const isOwn = new AtUri(listUri).host === currentAccount?.did
|
||||||
|
|
||||||
|
const getSortedProfiles = () => {
|
||||||
|
if (!profiles) return
|
||||||
|
if (!isOwn) return profiles
|
||||||
|
|
||||||
|
const myIndex = profiles.findIndex(p => p.did === currentAccount?.did)
|
||||||
|
return myIndex !== -1
|
||||||
|
? [
|
||||||
|
profiles[myIndex],
|
||||||
|
...profiles.slice(0, myIndex),
|
||||||
|
...profiles.slice(myIndex + 1),
|
||||||
|
]
|
||||||
|
: profiles
|
||||||
|
}
|
||||||
|
const onScrollToTop = useCallback(() => {
|
||||||
|
scrollElRef.current?.scrollToOffset({
|
||||||
|
animated: isNative,
|
||||||
|
offset: -headerHeight,
|
||||||
|
})
|
||||||
|
}, [scrollElRef, headerHeight])
|
||||||
|
|
||||||
|
React.useImperativeHandle(ref, () => ({
|
||||||
|
scrollToTop: onScrollToTop,
|
||||||
|
}))
|
||||||
|
|
||||||
|
const renderItem = ({
|
||||||
|
item,
|
||||||
|
index,
|
||||||
|
}: ListRenderItemInfo<AppBskyActorDefs.ProfileViewBasic>) => {
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
a.p_lg,
|
||||||
|
t.atoms.border_contrast_low,
|
||||||
|
(isWeb || index !== 0) && a.border_t,
|
||||||
|
]}>
|
||||||
|
<ProfileCard
|
||||||
|
profile={item}
|
||||||
|
moderationOpts={moderationOpts}
|
||||||
|
logContext="StarterPackProfilesList"
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (listMembersQuery)
|
||||||
|
return (
|
||||||
|
<List
|
||||||
|
data={getSortedProfiles()}
|
||||||
|
renderItem={renderItem}
|
||||||
|
keyExtractor={keyExtractor}
|
||||||
|
ref={scrollElRef}
|
||||||
|
headerOffset={headerHeight}
|
||||||
|
ListFooterComponent={
|
||||||
|
<View style={[{height: initialHeaderHeight + bottomBarOffset}]} />
|
||||||
|
}
|
||||||
|
showsVerticalScrollIndicator={false}
|
||||||
|
desktopFixedHeight
|
||||||
|
refreshing={isPTRing}
|
||||||
|
onRefresh={async () => {
|
||||||
|
setIsPTRing(true)
|
||||||
|
await refetch()
|
||||||
|
setIsPTRing(false)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
|
@ -0,0 +1,320 @@
|
||||||
|
import React from 'react'
|
||||||
|
import {
|
||||||
|
findNodeHandle,
|
||||||
|
ListRenderItemInfo,
|
||||||
|
StyleProp,
|
||||||
|
View,
|
||||||
|
ViewStyle,
|
||||||
|
} from 'react-native'
|
||||||
|
import {AppBskyGraphDefs, AppBskyGraphGetActorStarterPacks} from '@atproto/api'
|
||||||
|
import {msg, Trans} from '@lingui/macro'
|
||||||
|
import {useLingui} from '@lingui/react'
|
||||||
|
import {useNavigation} from '@react-navigation/native'
|
||||||
|
import {InfiniteData, UseInfiniteQueryResult} from '@tanstack/react-query'
|
||||||
|
|
||||||
|
import {logger} from '#/logger'
|
||||||
|
import {useGenerateStarterPackMutation} from 'lib/generate-starterpack'
|
||||||
|
import {useBottomBarOffset} from 'lib/hooks/useBottomBarOffset'
|
||||||
|
import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
|
||||||
|
import {NavigationProp} from 'lib/routes/types'
|
||||||
|
import {parseStarterPackUri} from 'lib/strings/starter-pack'
|
||||||
|
import {List, ListRef} from 'view/com/util/List'
|
||||||
|
import {Text} from 'view/com/util/text/Text'
|
||||||
|
import {atoms as a, useTheme} from '#/alf'
|
||||||
|
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
|
||||||
|
import {useDialogControl} from '#/components/Dialog'
|
||||||
|
import {LinearGradientBackground} from '#/components/LinearGradientBackground'
|
||||||
|
import {Loader} from '#/components/Loader'
|
||||||
|
import * as Prompt from '#/components/Prompt'
|
||||||
|
import {Default as StarterPackCard} from '#/components/StarterPack/StarterPackCard'
|
||||||
|
import {PlusSmall_Stroke2_Corner0_Rounded as Plus} from '../icons/Plus'
|
||||||
|
|
||||||
|
interface SectionRef {
|
||||||
|
scrollToTop: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ProfileFeedgensProps {
|
||||||
|
starterPacksQuery: UseInfiniteQueryResult<
|
||||||
|
InfiniteData<AppBskyGraphGetActorStarterPacks.OutputSchema, unknown>,
|
||||||
|
Error
|
||||||
|
>
|
||||||
|
scrollElRef: ListRef
|
||||||
|
headerOffset: number
|
||||||
|
enabled?: boolean
|
||||||
|
style?: StyleProp<ViewStyle>
|
||||||
|
testID?: string
|
||||||
|
setScrollViewTag: (tag: number | null) => void
|
||||||
|
isMe: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
function keyExtractor(item: AppBskyGraphDefs.StarterPackView) {
|
||||||
|
return item.uri
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ProfileStarterPacks = React.forwardRef<
|
||||||
|
SectionRef,
|
||||||
|
ProfileFeedgensProps
|
||||||
|
>(function ProfileFeedgensImpl(
|
||||||
|
{
|
||||||
|
starterPacksQuery: query,
|
||||||
|
scrollElRef,
|
||||||
|
headerOffset,
|
||||||
|
enabled,
|
||||||
|
style,
|
||||||
|
testID,
|
||||||
|
setScrollViewTag,
|
||||||
|
isMe,
|
||||||
|
},
|
||||||
|
ref,
|
||||||
|
) {
|
||||||
|
const t = useTheme()
|
||||||
|
const bottomBarOffset = useBottomBarOffset(100)
|
||||||
|
const [isPTRing, setIsPTRing] = React.useState(false)
|
||||||
|
const {data, refetch, isFetching, hasNextPage, fetchNextPage} = query
|
||||||
|
const {isTabletOrDesktop} = useWebMediaQueries()
|
||||||
|
|
||||||
|
const items = data?.pages.flatMap(page => page.starterPacks)
|
||||||
|
|
||||||
|
React.useImperativeHandle(ref, () => ({
|
||||||
|
scrollToTop: () => {},
|
||||||
|
}))
|
||||||
|
|
||||||
|
const onRefresh = React.useCallback(async () => {
|
||||||
|
setIsPTRing(true)
|
||||||
|
try {
|
||||||
|
await refetch()
|
||||||
|
} catch (err) {
|
||||||
|
logger.error('Failed to refresh starter packs', {message: err})
|
||||||
|
}
|
||||||
|
setIsPTRing(false)
|
||||||
|
}, [refetch, setIsPTRing])
|
||||||
|
|
||||||
|
const onEndReached = React.useCallback(async () => {
|
||||||
|
if (isFetching || !hasNextPage) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
await fetchNextPage()
|
||||||
|
} catch (err) {
|
||||||
|
logger.error('Failed to load more starter packs', {message: err})
|
||||||
|
}
|
||||||
|
}, [isFetching, hasNextPage, fetchNextPage])
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (enabled && scrollElRef.current) {
|
||||||
|
const nativeTag = findNodeHandle(scrollElRef.current)
|
||||||
|
setScrollViewTag(nativeTag)
|
||||||
|
}
|
||||||
|
}, [enabled, scrollElRef, setScrollViewTag])
|
||||||
|
|
||||||
|
const renderItem = ({
|
||||||
|
item,
|
||||||
|
index,
|
||||||
|
}: ListRenderItemInfo<AppBskyGraphDefs.StarterPackView>) => {
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
a.p_lg,
|
||||||
|
(isTabletOrDesktop || index !== 0) && a.border_t,
|
||||||
|
t.atoms.border_contrast_low,
|
||||||
|
]}>
|
||||||
|
<StarterPackCard starterPack={item} />
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View testID={testID} style={style}>
|
||||||
|
<List
|
||||||
|
testID={testID ? `${testID}-flatlist` : undefined}
|
||||||
|
ref={scrollElRef}
|
||||||
|
data={items}
|
||||||
|
renderItem={renderItem}
|
||||||
|
keyExtractor={keyExtractor}
|
||||||
|
refreshing={isPTRing}
|
||||||
|
headerOffset={headerOffset}
|
||||||
|
contentContainerStyle={{paddingBottom: headerOffset + bottomBarOffset}}
|
||||||
|
indicatorStyle={t.name === 'light' ? 'black' : 'white'}
|
||||||
|
removeClippedSubviews={true}
|
||||||
|
desktopFixedHeight
|
||||||
|
onEndReached={onEndReached}
|
||||||
|
onRefresh={onRefresh}
|
||||||
|
ListEmptyComponent={Empty}
|
||||||
|
ListFooterComponent={
|
||||||
|
items?.length !== 0 && isMe ? CreateAnother : undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
function CreateAnother() {
|
||||||
|
const {_} = useLingui()
|
||||||
|
const t = useTheme()
|
||||||
|
const navigation = useNavigation<NavigationProp>()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
a.pr_md,
|
||||||
|
a.pt_lg,
|
||||||
|
a.gap_lg,
|
||||||
|
a.border_t,
|
||||||
|
t.atoms.border_contrast_low,
|
||||||
|
]}>
|
||||||
|
<Button
|
||||||
|
label={_(msg`Create a starter pack`)}
|
||||||
|
variant="solid"
|
||||||
|
color="secondary"
|
||||||
|
size="small"
|
||||||
|
style={[a.self_center]}
|
||||||
|
onPress={() => navigation.navigate('StarterPackWizard')}>
|
||||||
|
<ButtonText>
|
||||||
|
<Trans>Create another</Trans>
|
||||||
|
</ButtonText>
|
||||||
|
<ButtonIcon icon={Plus} position="right" />
|
||||||
|
</Button>
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Empty() {
|
||||||
|
const {_} = useLingui()
|
||||||
|
const t = useTheme()
|
||||||
|
const navigation = useNavigation<NavigationProp>()
|
||||||
|
const confirmDialogControl = useDialogControl()
|
||||||
|
const followersDialogControl = useDialogControl()
|
||||||
|
const errorDialogControl = useDialogControl()
|
||||||
|
|
||||||
|
const [isGenerating, setIsGenerating] = React.useState(false)
|
||||||
|
|
||||||
|
const {mutate: generateStarterPack} = useGenerateStarterPackMutation({
|
||||||
|
onSuccess: ({uri}) => {
|
||||||
|
const parsed = parseStarterPackUri(uri)
|
||||||
|
if (parsed) {
|
||||||
|
navigation.push('StarterPack', {
|
||||||
|
name: parsed.name,
|
||||||
|
rkey: parsed.rkey,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
setIsGenerating(false)
|
||||||
|
},
|
||||||
|
onError: e => {
|
||||||
|
logger.error('Failed to generate starter pack', {safeMessage: e})
|
||||||
|
setIsGenerating(false)
|
||||||
|
if (e.name === 'NOT_ENOUGH_FOLLOWERS') {
|
||||||
|
followersDialogControl.open()
|
||||||
|
} else {
|
||||||
|
errorDialogControl.open()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const generate = () => {
|
||||||
|
setIsGenerating(true)
|
||||||
|
generateStarterPack()
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<LinearGradientBackground
|
||||||
|
style={[
|
||||||
|
a.px_lg,
|
||||||
|
a.py_lg,
|
||||||
|
a.justify_between,
|
||||||
|
a.gap_lg,
|
||||||
|
a.shadow_lg,
|
||||||
|
{marginTop: 2},
|
||||||
|
]}>
|
||||||
|
<View style={[a.gap_xs]}>
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
a.font_bold,
|
||||||
|
a.text_lg,
|
||||||
|
t.atoms.text_contrast_medium,
|
||||||
|
{color: 'white'},
|
||||||
|
]}>
|
||||||
|
You haven't created a starter pack yet!
|
||||||
|
</Text>
|
||||||
|
<Text style={[a.text_md, {color: 'white'}]}>
|
||||||
|
Starter packs let you easily share your favorite feeds and people with
|
||||||
|
your friends.
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<View style={[a.flex_row, a.gap_md, {marginLeft: 'auto'}]}>
|
||||||
|
<Button
|
||||||
|
label={_(msg`Create a starter pack for me`)}
|
||||||
|
variant="ghost"
|
||||||
|
color="primary"
|
||||||
|
size="small"
|
||||||
|
disabled={isGenerating}
|
||||||
|
onPress={confirmDialogControl.open}
|
||||||
|
style={{backgroundColor: 'transparent'}}>
|
||||||
|
<ButtonText style={{color: 'white'}}>
|
||||||
|
<Trans>Make one for me</Trans>
|
||||||
|
</ButtonText>
|
||||||
|
{isGenerating && <Loader size="md" />}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
label={_(msg`Create a starter pack`)}
|
||||||
|
variant="ghost"
|
||||||
|
color="primary"
|
||||||
|
size="small"
|
||||||
|
disabled={isGenerating}
|
||||||
|
onPress={() => navigation.navigate('StarterPackWizard')}
|
||||||
|
style={{
|
||||||
|
backgroundColor: 'white',
|
||||||
|
borderColor: 'white',
|
||||||
|
width: 100,
|
||||||
|
}}
|
||||||
|
hoverStyle={[{backgroundColor: '#dfdfdf'}]}>
|
||||||
|
<ButtonText>
|
||||||
|
<Trans>Create</Trans>
|
||||||
|
</ButtonText>
|
||||||
|
</Button>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<Prompt.Outer control={confirmDialogControl}>
|
||||||
|
<Prompt.TitleText>
|
||||||
|
<Trans>Generate a starter pack</Trans>
|
||||||
|
</Prompt.TitleText>
|
||||||
|
<Prompt.DescriptionText>
|
||||||
|
<Trans>
|
||||||
|
Bluesky will choose a set of recommended accounts from people in
|
||||||
|
your network.
|
||||||
|
</Trans>
|
||||||
|
</Prompt.DescriptionText>
|
||||||
|
<Prompt.Actions>
|
||||||
|
<Prompt.Action
|
||||||
|
color="primary"
|
||||||
|
cta={_(msg`Choose for me`)}
|
||||||
|
onPress={generate}
|
||||||
|
/>
|
||||||
|
<Prompt.Action
|
||||||
|
color="secondary"
|
||||||
|
cta={_(msg`Let me choose`)}
|
||||||
|
onPress={() => {
|
||||||
|
navigation.navigate('StarterPackWizard')
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Prompt.Actions>
|
||||||
|
</Prompt.Outer>
|
||||||
|
<Prompt.Basic
|
||||||
|
control={followersDialogControl}
|
||||||
|
title={_(msg`Oops!`)}
|
||||||
|
description={_(
|
||||||
|
msg`You must be following at least seven other people to generate a starter pack.`,
|
||||||
|
)}
|
||||||
|
onConfirm={() => {}}
|
||||||
|
showCancel={false}
|
||||||
|
/>
|
||||||
|
<Prompt.Basic
|
||||||
|
control={errorDialogControl}
|
||||||
|
title={_(msg`Oops!`)}
|
||||||
|
description={_(
|
||||||
|
msg`An error occurred while generating your starter pack. Want to try again?`,
|
||||||
|
)}
|
||||||
|
onConfirm={generate}
|
||||||
|
confirmButtonCta={_(msg`Retry`)}
|
||||||
|
/>
|
||||||
|
</LinearGradientBackground>
|
||||||
|
)
|
||||||
|
}
|
|
@ -0,0 +1,119 @@
|
||||||
|
import React from 'react'
|
||||||
|
import {View} from 'react-native'
|
||||||
|
import QRCode from 'react-native-qrcode-styled'
|
||||||
|
import ViewShot from 'react-native-view-shot'
|
||||||
|
import {AppBskyGraphDefs, AppBskyGraphStarterpack} from '@atproto/api'
|
||||||
|
import {Trans} from '@lingui/macro'
|
||||||
|
|
||||||
|
import {isWeb} from 'platform/detection'
|
||||||
|
import {Logo} from 'view/icons/Logo'
|
||||||
|
import {Logotype} from 'view/icons/Logotype'
|
||||||
|
import {useTheme} from '#/alf'
|
||||||
|
import {atoms as a} from '#/alf'
|
||||||
|
import {LinearGradientBackground} from '#/components/LinearGradientBackground'
|
||||||
|
import {Text} from '#/components/Typography'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
starterPack: AppBskyGraphDefs.StarterPackView
|
||||||
|
link: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const QrCode = React.forwardRef<ViewShot, Props>(function QrCode(
|
||||||
|
{starterPack, link},
|
||||||
|
ref,
|
||||||
|
) {
|
||||||
|
const {record} = starterPack
|
||||||
|
|
||||||
|
if (!AppBskyGraphStarterpack.isRecord(record)) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ViewShot ref={ref}>
|
||||||
|
<LinearGradientBackground
|
||||||
|
style={[
|
||||||
|
{width: 300, minHeight: 390},
|
||||||
|
a.align_center,
|
||||||
|
a.px_sm,
|
||||||
|
a.py_xl,
|
||||||
|
a.rounded_sm,
|
||||||
|
a.justify_between,
|
||||||
|
a.gap_md,
|
||||||
|
]}>
|
||||||
|
<View style={[a.gap_sm]}>
|
||||||
|
<Text
|
||||||
|
style={[a.font_bold, a.text_3xl, a.text_center, {color: 'white'}]}>
|
||||||
|
{record.name}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<View style={[a.gap_xl, a.align_center]}>
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
a.font_bold,
|
||||||
|
a.text_center,
|
||||||
|
{color: 'white', fontSize: 18},
|
||||||
|
]}>
|
||||||
|
<Trans>Join the conversation</Trans>
|
||||||
|
</Text>
|
||||||
|
<View style={[a.rounded_sm, a.overflow_hidden]}>
|
||||||
|
<QrCodeInner link={link} />
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={[a.flex_row, a.align_center, {gap: 5}]}>
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
a.font_bold,
|
||||||
|
a.text_center,
|
||||||
|
{color: 'white', fontSize: 18},
|
||||||
|
]}>
|
||||||
|
<Trans>on</Trans>
|
||||||
|
</Text>
|
||||||
|
<Logo width={26} fill="white" />
|
||||||
|
<View style={[{marginTop: 5, marginLeft: 2.5}]}>
|
||||||
|
<Logotype width={68} fill="white" />
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</LinearGradientBackground>
|
||||||
|
</ViewShot>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
export function QrCodeInner({link}: {link: string}) {
|
||||||
|
const t = useTheme()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<QRCode
|
||||||
|
data={link}
|
||||||
|
style={[
|
||||||
|
a.rounded_sm,
|
||||||
|
{height: 225, width: 225, backgroundColor: '#f3f3f3'},
|
||||||
|
]}
|
||||||
|
pieceSize={isWeb ? 8 : 6}
|
||||||
|
padding={20}
|
||||||
|
// pieceLiquidRadius={2}
|
||||||
|
pieceBorderRadius={isWeb ? 4.5 : 3.5}
|
||||||
|
outerEyesOptions={{
|
||||||
|
topLeft: {
|
||||||
|
borderRadius: [12, 12, 0, 12],
|
||||||
|
color: t.palette.primary_500,
|
||||||
|
},
|
||||||
|
topRight: {
|
||||||
|
borderRadius: [12, 12, 12, 0],
|
||||||
|
color: t.palette.primary_500,
|
||||||
|
},
|
||||||
|
bottomLeft: {
|
||||||
|
borderRadius: [12, 0, 12, 12],
|
||||||
|
color: t.palette.primary_500,
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
innerEyesOptions={{borderRadius: 3}}
|
||||||
|
logo={{
|
||||||
|
href: require('../../../assets/logo.png'),
|
||||||
|
scale: 1.2,
|
||||||
|
padding: 2,
|
||||||
|
hidePieces: true,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
|
@ -0,0 +1,201 @@
|
||||||
|
import React from 'react'
|
||||||
|
import {View} from 'react-native'
|
||||||
|
import ViewShot from 'react-native-view-shot'
|
||||||
|
import * as FS from 'expo-file-system'
|
||||||
|
import {requestMediaLibraryPermissionsAsync} from 'expo-image-picker'
|
||||||
|
import * as Sharing from 'expo-sharing'
|
||||||
|
import {AppBskyGraphDefs, AppBskyGraphStarterpack} from '@atproto/api'
|
||||||
|
import {msg, Trans} from '@lingui/macro'
|
||||||
|
import {useLingui} from '@lingui/react'
|
||||||
|
import {nanoid} from 'nanoid/non-secure'
|
||||||
|
|
||||||
|
import {logger} from '#/logger'
|
||||||
|
import {saveImageToMediaLibrary} from 'lib/media/manip'
|
||||||
|
import {logEvent} from 'lib/statsig/statsig'
|
||||||
|
import {isNative, isWeb} from 'platform/detection'
|
||||||
|
import * as Toast from '#/view/com/util/Toast'
|
||||||
|
import {atoms as a} from '#/alf'
|
||||||
|
import {Button, ButtonText} from '#/components/Button'
|
||||||
|
import * as Dialog from '#/components/Dialog'
|
||||||
|
import {DialogControlProps} from '#/components/Dialog'
|
||||||
|
import {Loader} from '#/components/Loader'
|
||||||
|
import {QrCode} from '#/components/StarterPack/QrCode'
|
||||||
|
|
||||||
|
export function QrCodeDialog({
|
||||||
|
starterPack,
|
||||||
|
link,
|
||||||
|
control,
|
||||||
|
}: {
|
||||||
|
starterPack: AppBskyGraphDefs.StarterPackView
|
||||||
|
link?: string
|
||||||
|
control: DialogControlProps
|
||||||
|
}) {
|
||||||
|
const {_} = useLingui()
|
||||||
|
const [isProcessing, setIsProcessing] = React.useState(false)
|
||||||
|
|
||||||
|
const ref = React.useRef<ViewShot>(null)
|
||||||
|
|
||||||
|
const getCanvas = (base64: string): Promise<HTMLCanvasElement> => {
|
||||||
|
return new Promise(resolve => {
|
||||||
|
const image = new Image()
|
||||||
|
image.onload = () => {
|
||||||
|
const canvas = document.createElement('canvas')
|
||||||
|
canvas.width = image.width
|
||||||
|
canvas.height = image.height
|
||||||
|
|
||||||
|
const ctx = canvas.getContext('2d')
|
||||||
|
ctx?.drawImage(image, 0, 0)
|
||||||
|
resolve(canvas)
|
||||||
|
}
|
||||||
|
image.src = base64
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const onSavePress = async () => {
|
||||||
|
ref.current?.capture?.().then(async (uri: string) => {
|
||||||
|
if (isNative) {
|
||||||
|
const res = await requestMediaLibraryPermissionsAsync()
|
||||||
|
|
||||||
|
if (!res) {
|
||||||
|
Toast.show(
|
||||||
|
_(
|
||||||
|
msg`You must grant access to your photo library to save a QR code`,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const filename = `${FS.documentDirectory}/${nanoid(12)}.png`
|
||||||
|
|
||||||
|
// Incase of a FS failure, don't crash the app
|
||||||
|
try {
|
||||||
|
await FS.copyAsync({from: uri, to: filename})
|
||||||
|
await saveImageToMediaLibrary({uri: filename})
|
||||||
|
await FS.deleteAsync(filename)
|
||||||
|
} catch (e: unknown) {
|
||||||
|
Toast.show(_(msg`An error occurred while saving the QR code!`))
|
||||||
|
logger.error('Failed to save QR code', {error: e})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setIsProcessing(true)
|
||||||
|
|
||||||
|
if (!AppBskyGraphStarterpack.isRecord(starterPack.record)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const canvas = await getCanvas(uri)
|
||||||
|
const imgHref = canvas
|
||||||
|
.toDataURL('image/png')
|
||||||
|
.replace('image/png', 'image/octet-stream')
|
||||||
|
|
||||||
|
const link = document.createElement('a')
|
||||||
|
link.setAttribute(
|
||||||
|
'download',
|
||||||
|
`${starterPack.record.name.replaceAll(' ', '_')}_Share_Card.png`,
|
||||||
|
)
|
||||||
|
link.setAttribute('href', imgHref)
|
||||||
|
link.click()
|
||||||
|
}
|
||||||
|
|
||||||
|
logEvent('starterPack:share', {
|
||||||
|
starterPack: starterPack.uri,
|
||||||
|
shareType: 'qrcode',
|
||||||
|
qrShareType: 'save',
|
||||||
|
})
|
||||||
|
setIsProcessing(false)
|
||||||
|
Toast.show(
|
||||||
|
isWeb
|
||||||
|
? _(msg`QR code has been downloaded!`)
|
||||||
|
: _(msg`QR code saved to your camera roll!`),
|
||||||
|
)
|
||||||
|
control.close()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const onCopyPress = async () => {
|
||||||
|
setIsProcessing(true)
|
||||||
|
ref.current?.capture?.().then(async (uri: string) => {
|
||||||
|
const canvas = await getCanvas(uri)
|
||||||
|
// @ts-expect-error web only
|
||||||
|
canvas.toBlob((blob: Blob) => {
|
||||||
|
const item = new ClipboardItem({'image/png': blob})
|
||||||
|
navigator.clipboard.write([item])
|
||||||
|
})
|
||||||
|
|
||||||
|
logEvent('starterPack:share', {
|
||||||
|
starterPack: starterPack.uri,
|
||||||
|
shareType: 'qrcode',
|
||||||
|
qrShareType: 'copy',
|
||||||
|
})
|
||||||
|
Toast.show(_(msg`QR code copied to your clipboard!`))
|
||||||
|
setIsProcessing(false)
|
||||||
|
control.close()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const onSharePress = async () => {
|
||||||
|
ref.current?.capture?.().then(async (uri: string) => {
|
||||||
|
control.close(() => {
|
||||||
|
Sharing.shareAsync(uri, {mimeType: 'image/png', UTI: 'image/png'}).then(
|
||||||
|
() => {
|
||||||
|
logEvent('starterPack:share', {
|
||||||
|
starterPack: starterPack.uri,
|
||||||
|
shareType: 'qrcode',
|
||||||
|
qrShareType: 'share',
|
||||||
|
})
|
||||||
|
},
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog.Outer control={control}>
|
||||||
|
<Dialog.Handle />
|
||||||
|
<Dialog.ScrollableInner
|
||||||
|
label={_(msg`Create a QR code for a starter pack`)}>
|
||||||
|
<View style={[a.flex_1, a.align_center, a.gap_5xl]}>
|
||||||
|
{!link ? (
|
||||||
|
<View style={[a.align_center, a.p_xl]}>
|
||||||
|
<Loader size="xl" />
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<QrCode starterPack={starterPack} link={link} ref={ref} />
|
||||||
|
{isProcessing ? (
|
||||||
|
<View>
|
||||||
|
<Loader size="xl" />
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
<View
|
||||||
|
style={[a.w_full, a.gap_md, isWeb && [a.flex_row_reverse]]}>
|
||||||
|
<Button
|
||||||
|
label={_(msg`Copy QR code`)}
|
||||||
|
variant="solid"
|
||||||
|
color="secondary"
|
||||||
|
size="small"
|
||||||
|
onPress={isWeb ? onCopyPress : onSharePress}>
|
||||||
|
<ButtonText>
|
||||||
|
{isWeb ? <Trans>Copy</Trans> : <Trans>Share</Trans>}
|
||||||
|
</ButtonText>
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
label={_(msg`Save QR code`)}
|
||||||
|
variant="solid"
|
||||||
|
color="secondary"
|
||||||
|
size="small"
|
||||||
|
onPress={onSavePress}>
|
||||||
|
<ButtonText>
|
||||||
|
<Trans>Save</Trans>
|
||||||
|
</ButtonText>
|
||||||
|
</Button>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</Dialog.ScrollableInner>
|
||||||
|
</Dialog.Outer>
|
||||||
|
)
|
||||||
|
}
|
|
@ -0,0 +1,180 @@
|
||||||
|
import React from 'react'
|
||||||
|
import {View} from 'react-native'
|
||||||
|
import * as FS from 'expo-file-system'
|
||||||
|
import {Image} from 'expo-image'
|
||||||
|
import {requestMediaLibraryPermissionsAsync} from 'expo-image-picker'
|
||||||
|
import {AppBskyGraphDefs} from '@atproto/api'
|
||||||
|
import {msg, Trans} from '@lingui/macro'
|
||||||
|
import {useLingui} from '@lingui/react'
|
||||||
|
import {nanoid} from 'nanoid/non-secure'
|
||||||
|
|
||||||
|
import {logger} from '#/logger'
|
||||||
|
import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
|
||||||
|
import {saveImageToMediaLibrary} from 'lib/media/manip'
|
||||||
|
import {shareUrl} from 'lib/sharing'
|
||||||
|
import {logEvent} from 'lib/statsig/statsig'
|
||||||
|
import {getStarterPackOgCard} from 'lib/strings/starter-pack'
|
||||||
|
import {isNative, isWeb} from 'platform/detection'
|
||||||
|
import * as Toast from 'view/com/util/Toast'
|
||||||
|
import {atoms as a, useTheme} from '#/alf'
|
||||||
|
import {Button, ButtonText} from '#/components/Button'
|
||||||
|
import {DialogControlProps} from '#/components/Dialog'
|
||||||
|
import * as Dialog from '#/components/Dialog'
|
||||||
|
import {Loader} from '#/components/Loader'
|
||||||
|
import {Text} from '#/components/Typography'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
starterPack: AppBskyGraphDefs.StarterPackView
|
||||||
|
link?: string
|
||||||
|
imageLoaded?: boolean
|
||||||
|
qrDialogControl: DialogControlProps
|
||||||
|
control: DialogControlProps
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ShareDialog(props: Props) {
|
||||||
|
return (
|
||||||
|
<Dialog.Outer control={props.control}>
|
||||||
|
<ShareDialogInner {...props} />
|
||||||
|
</Dialog.Outer>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ShareDialogInner({
|
||||||
|
starterPack,
|
||||||
|
link,
|
||||||
|
imageLoaded,
|
||||||
|
qrDialogControl,
|
||||||
|
control,
|
||||||
|
}: Props) {
|
||||||
|
const {_} = useLingui()
|
||||||
|
const t = useTheme()
|
||||||
|
const {isTabletOrDesktop} = useWebMediaQueries()
|
||||||
|
|
||||||
|
const imageUrl = getStarterPackOgCard(starterPack)
|
||||||
|
|
||||||
|
const onShareLink = async () => {
|
||||||
|
if (!link) return
|
||||||
|
shareUrl(link)
|
||||||
|
logEvent('starterPack:share', {
|
||||||
|
starterPack: starterPack.uri,
|
||||||
|
shareType: 'link',
|
||||||
|
})
|
||||||
|
control.close()
|
||||||
|
}
|
||||||
|
|
||||||
|
const onSave = async () => {
|
||||||
|
const res = await requestMediaLibraryPermissionsAsync()
|
||||||
|
|
||||||
|
if (!res) {
|
||||||
|
Toast.show(
|
||||||
|
_(msg`You must grant access to your photo library to save the image.`),
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const cachePath = await Image.getCachePathAsync(imageUrl)
|
||||||
|
const filename = `${FS.documentDirectory}/${nanoid(12)}.png`
|
||||||
|
|
||||||
|
if (!cachePath) {
|
||||||
|
Toast.show(_(msg`An error occurred while saving the image.`))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await FS.copyAsync({from: cachePath, to: filename})
|
||||||
|
await saveImageToMediaLibrary({uri: filename})
|
||||||
|
await FS.deleteAsync(filename)
|
||||||
|
|
||||||
|
Toast.show(_(msg`Image saved to your camera roll!`))
|
||||||
|
control.close()
|
||||||
|
} catch (e: unknown) {
|
||||||
|
Toast.show(_(msg`An error occurred while saving the QR code!`))
|
||||||
|
logger.error('Failed to save QR code', {error: e})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Dialog.Handle />
|
||||||
|
<Dialog.ScrollableInner label={_(msg`Share link dialog`)}>
|
||||||
|
{!imageLoaded || !link ? (
|
||||||
|
<View style={[a.p_xl, a.align_center]}>
|
||||||
|
<Loader size="xl" />
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
<View style={[!isTabletOrDesktop && a.gap_lg]}>
|
||||||
|
<View style={[a.gap_sm, isTabletOrDesktop && a.pb_lg]}>
|
||||||
|
<Text style={[a.font_bold, a.text_2xl]}>
|
||||||
|
<Trans>Invite people to this starter pack!</Trans>
|
||||||
|
</Text>
|
||||||
|
<Text style={[a.text_md, t.atoms.text_contrast_medium]}>
|
||||||
|
<Trans>
|
||||||
|
Share this starter pack and help people join your community on
|
||||||
|
Bluesky.
|
||||||
|
</Trans>
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<Image
|
||||||
|
source={{uri: imageUrl}}
|
||||||
|
style={[
|
||||||
|
a.rounded_sm,
|
||||||
|
{
|
||||||
|
aspectRatio: 1200 / 630,
|
||||||
|
transform: [{scale: isTabletOrDesktop ? 0.85 : 1}],
|
||||||
|
marginTop: isTabletOrDesktop ? -20 : 0,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
accessibilityIgnoresInvertColors={true}
|
||||||
|
/>
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
a.gap_md,
|
||||||
|
isWeb && [a.gap_sm, a.flex_row_reverse, {marginLeft: 'auto'}],
|
||||||
|
]}>
|
||||||
|
<Button
|
||||||
|
label="Share link"
|
||||||
|
variant="solid"
|
||||||
|
color="secondary"
|
||||||
|
size="small"
|
||||||
|
style={[isWeb && a.self_center]}
|
||||||
|
onPress={onShareLink}>
|
||||||
|
<ButtonText>
|
||||||
|
{isWeb ? <Trans>Copy Link</Trans> : <Trans>Share Link</Trans>}
|
||||||
|
</ButtonText>
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
label="Create QR code"
|
||||||
|
variant="solid"
|
||||||
|
color="secondary"
|
||||||
|
size="small"
|
||||||
|
style={[isWeb && a.self_center]}
|
||||||
|
onPress={() => {
|
||||||
|
control.close(() => {
|
||||||
|
qrDialogControl.open()
|
||||||
|
})
|
||||||
|
}}>
|
||||||
|
<ButtonText>
|
||||||
|
<Trans>Create QR code</Trans>
|
||||||
|
</ButtonText>
|
||||||
|
</Button>
|
||||||
|
{isNative && (
|
||||||
|
<Button
|
||||||
|
label={_(msg`Save image`)}
|
||||||
|
variant="ghost"
|
||||||
|
color="secondary"
|
||||||
|
size="small"
|
||||||
|
style={[isWeb && a.self_center]}
|
||||||
|
onPress={onSave}>
|
||||||
|
<ButtonText>
|
||||||
|
<Trans>Save image</Trans>
|
||||||
|
</ButtonText>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</Dialog.ScrollableInner>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
|
@ -0,0 +1,117 @@
|
||||||
|
import React from 'react'
|
||||||
|
import {View} from 'react-native'
|
||||||
|
import {AppBskyGraphStarterpack, AtUri} from '@atproto/api'
|
||||||
|
import {StarterPackViewBasic} from '@atproto/api/dist/client/types/app/bsky/graph/defs'
|
||||||
|
import {msg, Trans} from '@lingui/macro'
|
||||||
|
import {useLingui} from '@lingui/react'
|
||||||
|
|
||||||
|
import {sanitizeHandle} from 'lib/strings/handles'
|
||||||
|
import {useSession} from 'state/session'
|
||||||
|
import {atoms as a, useTheme} from '#/alf'
|
||||||
|
import {StarterPack} from '#/components/icons/StarterPack'
|
||||||
|
import {Link as InternalLink, LinkProps} from '#/components/Link'
|
||||||
|
import {Text} from '#/components/Typography'
|
||||||
|
|
||||||
|
export function Default({starterPack}: {starterPack?: StarterPackViewBasic}) {
|
||||||
|
if (!starterPack) return null
|
||||||
|
return (
|
||||||
|
<Link starterPack={starterPack}>
|
||||||
|
<Card starterPack={starterPack} />
|
||||||
|
</Link>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Notification({
|
||||||
|
starterPack,
|
||||||
|
}: {
|
||||||
|
starterPack?: StarterPackViewBasic
|
||||||
|
}) {
|
||||||
|
if (!starterPack) return null
|
||||||
|
return (
|
||||||
|
<Link starterPack={starterPack}>
|
||||||
|
<Card starterPack={starterPack} noIcon={true} noDescription={true} />
|
||||||
|
</Link>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Card({
|
||||||
|
starterPack,
|
||||||
|
noIcon,
|
||||||
|
noDescription,
|
||||||
|
}: {
|
||||||
|
starterPack: StarterPackViewBasic
|
||||||
|
noIcon?: boolean
|
||||||
|
noDescription?: boolean
|
||||||
|
}) {
|
||||||
|
const {record, creator, joinedAllTimeCount} = starterPack
|
||||||
|
|
||||||
|
const {_} = useLingui()
|
||||||
|
const t = useTheme()
|
||||||
|
const {currentAccount} = useSession()
|
||||||
|
|
||||||
|
if (!AppBskyGraphStarterpack.isRecord(record)) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={[a.flex_1, a.gap_md]}>
|
||||||
|
<View style={[a.flex_row, a.gap_sm]}>
|
||||||
|
{!noIcon ? <StarterPack width={40} gradient="sky" /> : null}
|
||||||
|
<View>
|
||||||
|
<Text style={[a.text_md, a.font_bold, a.leading_snug]}>
|
||||||
|
{record.name}
|
||||||
|
</Text>
|
||||||
|
<Text style={[a.leading_snug, t.atoms.text_contrast_medium]}>
|
||||||
|
<Trans>
|
||||||
|
Starter pack by{' '}
|
||||||
|
{creator?.did === currentAccount?.did
|
||||||
|
? _(msg`you`)
|
||||||
|
: `@${sanitizeHandle(creator.handle)}`}
|
||||||
|
</Trans>
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
{!noDescription && record.description ? (
|
||||||
|
<Text numberOfLines={3} style={[a.leading_snug]}>
|
||||||
|
{record.description}
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
{!!joinedAllTimeCount && joinedAllTimeCount >= 50 && (
|
||||||
|
<Text style={[a.font_bold, t.atoms.text_contrast_medium]}>
|
||||||
|
{joinedAllTimeCount} users have joined!
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Link({
|
||||||
|
starterPack,
|
||||||
|
children,
|
||||||
|
...rest
|
||||||
|
}: {
|
||||||
|
starterPack: StarterPackViewBasic
|
||||||
|
} & Omit<LinkProps, 'to'>) {
|
||||||
|
const {record} = starterPack
|
||||||
|
const {rkey, handleOrDid} = React.useMemo(() => {
|
||||||
|
const rkey = new AtUri(starterPack.uri).rkey
|
||||||
|
const {creator} = starterPack
|
||||||
|
return {rkey, handleOrDid: creator.handle || creator.did}
|
||||||
|
}, [starterPack])
|
||||||
|
|
||||||
|
if (!AppBskyGraphStarterpack.isRecord(record)) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<InternalLink
|
||||||
|
label={record.name}
|
||||||
|
{...rest}
|
||||||
|
to={{
|
||||||
|
screen: 'StarterPack',
|
||||||
|
params: {name: handleOrDid, rkey},
|
||||||
|
}}>
|
||||||
|
{children}
|
||||||
|
</InternalLink>
|
||||||
|
)
|
||||||
|
}
|
|
@ -0,0 +1,31 @@
|
||||||
|
import React from 'react'
|
||||||
|
import {StyleProp, ViewStyle} from 'react-native'
|
||||||
|
import Animated, {
|
||||||
|
FadeIn,
|
||||||
|
FadeOut,
|
||||||
|
SlideInLeft,
|
||||||
|
SlideInRight,
|
||||||
|
} from 'react-native-reanimated'
|
||||||
|
|
||||||
|
import {isWeb} from 'platform/detection'
|
||||||
|
|
||||||
|
export function ScreenTransition({
|
||||||
|
direction,
|
||||||
|
style,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
direction: 'Backward' | 'Forward'
|
||||||
|
style?: StyleProp<ViewStyle>
|
||||||
|
children: React.ReactNode
|
||||||
|
}) {
|
||||||
|
const entering = direction === 'Forward' ? SlideInRight : SlideInLeft
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Animated.View
|
||||||
|
entering={isWeb ? FadeIn.duration(90) : entering}
|
||||||
|
exiting={FadeOut.duration(90)} // Totally vibes based
|
||||||
|
style={style}>
|
||||||
|
{children}
|
||||||
|
</Animated.View>
|
||||||
|
)
|
||||||
|
}
|
|
@ -0,0 +1,152 @@
|
||||||
|
import React, {useRef} from 'react'
|
||||||
|
import type {ListRenderItemInfo} from 'react-native'
|
||||||
|
import {View} from 'react-native'
|
||||||
|
import {AppBskyActorDefs, ModerationOpts} from '@atproto/api'
|
||||||
|
import {GeneratorView} from '@atproto/api/dist/client/types/app/bsky/feed/defs'
|
||||||
|
import {BottomSheetFlatListMethods} from '@discord/bottom-sheet'
|
||||||
|
import {msg, Trans} from '@lingui/macro'
|
||||||
|
import {useLingui} from '@lingui/react'
|
||||||
|
|
||||||
|
import {isWeb} from 'platform/detection'
|
||||||
|
import {useSession} from 'state/session'
|
||||||
|
import {WizardAction, WizardState} from '#/screens/StarterPack/Wizard/State'
|
||||||
|
import {atoms as a, native, useTheme, web} from '#/alf'
|
||||||
|
import {Button, ButtonText} from '#/components/Button'
|
||||||
|
import * as Dialog from '#/components/Dialog'
|
||||||
|
import {
|
||||||
|
WizardFeedCard,
|
||||||
|
WizardProfileCard,
|
||||||
|
} from '#/components/StarterPack/Wizard/WizardListCard'
|
||||||
|
import {Text} from '#/components/Typography'
|
||||||
|
|
||||||
|
function keyExtractor(
|
||||||
|
item: AppBskyActorDefs.ProfileViewBasic | GeneratorView,
|
||||||
|
index: number,
|
||||||
|
) {
|
||||||
|
return `${item.did}-${index}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function WizardEditListDialog({
|
||||||
|
control,
|
||||||
|
state,
|
||||||
|
dispatch,
|
||||||
|
moderationOpts,
|
||||||
|
profile,
|
||||||
|
}: {
|
||||||
|
control: Dialog.DialogControlProps
|
||||||
|
state: WizardState
|
||||||
|
dispatch: (action: WizardAction) => void
|
||||||
|
moderationOpts: ModerationOpts
|
||||||
|
profile: AppBskyActorDefs.ProfileViewBasic
|
||||||
|
}) {
|
||||||
|
const {_} = useLingui()
|
||||||
|
const t = useTheme()
|
||||||
|
const {currentAccount} = useSession()
|
||||||
|
|
||||||
|
const listRef = useRef<BottomSheetFlatListMethods>(null)
|
||||||
|
|
||||||
|
const getData = () => {
|
||||||
|
if (state.currentStep === 'Feeds') return state.feeds
|
||||||
|
|
||||||
|
return [
|
||||||
|
profile,
|
||||||
|
...state.profiles.filter(p => p.did !== currentAccount?.did),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
const renderItem = ({item}: ListRenderItemInfo<any>) =>
|
||||||
|
state.currentStep === 'Profiles' ? (
|
||||||
|
<WizardProfileCard
|
||||||
|
profile={item}
|
||||||
|
state={state}
|
||||||
|
dispatch={dispatch}
|
||||||
|
moderationOpts={moderationOpts}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<WizardFeedCard
|
||||||
|
generator={item}
|
||||||
|
state={state}
|
||||||
|
dispatch={dispatch}
|
||||||
|
moderationOpts={moderationOpts}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog.Outer
|
||||||
|
control={control}
|
||||||
|
testID="newChatDialog"
|
||||||
|
nativeOptions={{sheet: {snapPoints: ['95%']}}}>
|
||||||
|
<Dialog.Handle />
|
||||||
|
<Dialog.InnerFlatList
|
||||||
|
ref={listRef}
|
||||||
|
data={getData()}
|
||||||
|
renderItem={renderItem}
|
||||||
|
keyExtractor={keyExtractor}
|
||||||
|
ListHeaderComponent={
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
a.flex_row,
|
||||||
|
a.justify_between,
|
||||||
|
a.border_b,
|
||||||
|
a.px_sm,
|
||||||
|
a.mb_sm,
|
||||||
|
t.atoms.bg,
|
||||||
|
t.atoms.border_contrast_medium,
|
||||||
|
isWeb
|
||||||
|
? [
|
||||||
|
a.align_center,
|
||||||
|
{
|
||||||
|
height: 48,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: [
|
||||||
|
a.pb_sm,
|
||||||
|
a.align_end,
|
||||||
|
{
|
||||||
|
height: 68,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
]}>
|
||||||
|
<View style={{width: 60}} />
|
||||||
|
<Text style={[a.font_bold, a.text_xl]}>
|
||||||
|
{state.currentStep === 'Profiles' ? (
|
||||||
|
<Trans>Edit People</Trans>
|
||||||
|
) : (
|
||||||
|
<Trans>Edit Feeds</Trans>
|
||||||
|
)}
|
||||||
|
</Text>
|
||||||
|
<View style={{width: 60}}>
|
||||||
|
{isWeb && (
|
||||||
|
<Button
|
||||||
|
label={_(msg`Close`)}
|
||||||
|
variant="ghost"
|
||||||
|
color="primary"
|
||||||
|
size="xsmall"
|
||||||
|
onPress={() => control.close()}>
|
||||||
|
<ButtonText>
|
||||||
|
<Trans>Close</Trans>
|
||||||
|
</ButtonText>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
}
|
||||||
|
stickyHeaderIndices={[0]}
|
||||||
|
style={[
|
||||||
|
web([a.py_0, {height: '100vh', maxHeight: 600}, a.px_0]),
|
||||||
|
native({
|
||||||
|
height: '100%',
|
||||||
|
paddingHorizontal: 0,
|
||||||
|
marginTop: 0,
|
||||||
|
paddingTop: 0,
|
||||||
|
borderTopLeftRadius: 40,
|
||||||
|
borderTopRightRadius: 40,
|
||||||
|
}),
|
||||||
|
]}
|
||||||
|
webInnerStyle={[a.py_0, {maxWidth: 500, minWidth: 200}]}
|
||||||
|
keyboardDismissMode="on-drag"
|
||||||
|
removeClippedSubviews={true}
|
||||||
|
/>
|
||||||
|
</Dialog.Outer>
|
||||||
|
)
|
||||||
|
}
|
|
@ -0,0 +1,182 @@
|
||||||
|
import React from 'react'
|
||||||
|
import {Keyboard, View} from 'react-native'
|
||||||
|
import {
|
||||||
|
AppBskyActorDefs,
|
||||||
|
AppBskyFeedDefs,
|
||||||
|
moderateFeedGenerator,
|
||||||
|
moderateProfile,
|
||||||
|
ModerationOpts,
|
||||||
|
ModerationUI,
|
||||||
|
} from '@atproto/api'
|
||||||
|
import {GeneratorView} from '@atproto/api/dist/client/types/app/bsky/feed/defs'
|
||||||
|
import {msg} from '@lingui/macro'
|
||||||
|
import {useLingui} from '@lingui/react'
|
||||||
|
|
||||||
|
import {DISCOVER_FEED_URI} from 'lib/constants'
|
||||||
|
import {sanitizeDisplayName} from 'lib/strings/display-names'
|
||||||
|
import {sanitizeHandle} from 'lib/strings/handles'
|
||||||
|
import {useSession} from 'state/session'
|
||||||
|
import {UserAvatar} from 'view/com/util/UserAvatar'
|
||||||
|
import {WizardAction, WizardState} from '#/screens/StarterPack/Wizard/State'
|
||||||
|
import {atoms as a, useTheme} from '#/alf'
|
||||||
|
import * as Toggle from '#/components/forms/Toggle'
|
||||||
|
import {Checkbox} from '#/components/forms/Toggle'
|
||||||
|
import {Text} from '#/components/Typography'
|
||||||
|
|
||||||
|
function WizardListCard({
|
||||||
|
type,
|
||||||
|
displayName,
|
||||||
|
subtitle,
|
||||||
|
onPress,
|
||||||
|
avatar,
|
||||||
|
included,
|
||||||
|
disabled,
|
||||||
|
moderationUi,
|
||||||
|
}: {
|
||||||
|
type: 'user' | 'algo'
|
||||||
|
profile?: AppBskyActorDefs.ProfileViewBasic
|
||||||
|
feed?: AppBskyFeedDefs.GeneratorView
|
||||||
|
displayName: string
|
||||||
|
subtitle: string
|
||||||
|
onPress: () => void
|
||||||
|
avatar?: string
|
||||||
|
included?: boolean
|
||||||
|
disabled?: boolean
|
||||||
|
moderationUi: ModerationUI
|
||||||
|
}) {
|
||||||
|
const t = useTheme()
|
||||||
|
const {_} = useLingui()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Toggle.Item
|
||||||
|
name={type === 'user' ? _(msg`Person toggle`) : _(msg`Feed toggle`)}
|
||||||
|
label={
|
||||||
|
included
|
||||||
|
? _(msg`Remove ${displayName} from starter pack`)
|
||||||
|
: _(msg`Add ${displayName} to starter pack`)
|
||||||
|
}
|
||||||
|
value={included}
|
||||||
|
disabled={disabled}
|
||||||
|
onChange={onPress}
|
||||||
|
style={[
|
||||||
|
a.flex_row,
|
||||||
|
a.align_center,
|
||||||
|
a.px_lg,
|
||||||
|
a.py_md,
|
||||||
|
a.gap_md,
|
||||||
|
a.border_b,
|
||||||
|
t.atoms.border_contrast_low,
|
||||||
|
]}>
|
||||||
|
<UserAvatar
|
||||||
|
size={45}
|
||||||
|
avatar={avatar}
|
||||||
|
moderation={moderationUi}
|
||||||
|
type={type}
|
||||||
|
/>
|
||||||
|
<View style={[a.flex_1, a.gap_2xs]}>
|
||||||
|
<Text
|
||||||
|
style={[a.flex_1, a.font_bold, a.text_md, a.leading_tight]}
|
||||||
|
numberOfLines={1}>
|
||||||
|
{displayName}
|
||||||
|
</Text>
|
||||||
|
<Text
|
||||||
|
style={[a.flex_1, a.leading_tight, t.atoms.text_contrast_medium]}
|
||||||
|
numberOfLines={1}>
|
||||||
|
{subtitle}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<Checkbox />
|
||||||
|
</Toggle.Item>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function WizardProfileCard({
|
||||||
|
state,
|
||||||
|
dispatch,
|
||||||
|
profile,
|
||||||
|
moderationOpts,
|
||||||
|
}: {
|
||||||
|
state: WizardState
|
||||||
|
dispatch: (action: WizardAction) => void
|
||||||
|
profile: AppBskyActorDefs.ProfileViewBasic
|
||||||
|
moderationOpts: ModerationOpts
|
||||||
|
}) {
|
||||||
|
const {currentAccount} = useSession()
|
||||||
|
|
||||||
|
const isMe = profile.did === currentAccount?.did
|
||||||
|
const included = isMe || state.profiles.some(p => p.did === profile.did)
|
||||||
|
const disabled = isMe || (!included && state.profiles.length >= 49)
|
||||||
|
const moderationUi = moderateProfile(profile, moderationOpts).ui('avatar')
|
||||||
|
const displayName = profile.displayName
|
||||||
|
? sanitizeDisplayName(profile.displayName)
|
||||||
|
: `@${sanitizeHandle(profile.handle)}`
|
||||||
|
|
||||||
|
const onPress = () => {
|
||||||
|
if (disabled) return
|
||||||
|
|
||||||
|
Keyboard.dismiss()
|
||||||
|
if (profile.did === currentAccount?.did) return
|
||||||
|
|
||||||
|
if (!included) {
|
||||||
|
dispatch({type: 'AddProfile', profile})
|
||||||
|
} else {
|
||||||
|
dispatch({type: 'RemoveProfile', profileDid: profile.did})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<WizardListCard
|
||||||
|
type="user"
|
||||||
|
displayName={displayName}
|
||||||
|
subtitle={`@${sanitizeHandle(profile.handle)}`}
|
||||||
|
onPress={onPress}
|
||||||
|
avatar={profile.avatar}
|
||||||
|
included={included}
|
||||||
|
disabled={disabled}
|
||||||
|
moderationUi={moderationUi}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function WizardFeedCard({
|
||||||
|
generator,
|
||||||
|
state,
|
||||||
|
dispatch,
|
||||||
|
moderationOpts,
|
||||||
|
}: {
|
||||||
|
generator: GeneratorView
|
||||||
|
state: WizardState
|
||||||
|
dispatch: (action: WizardAction) => void
|
||||||
|
moderationOpts: ModerationOpts
|
||||||
|
}) {
|
||||||
|
const isDiscover = generator.uri === DISCOVER_FEED_URI
|
||||||
|
const included = isDiscover || state.feeds.some(f => f.uri === generator.uri)
|
||||||
|
const disabled = isDiscover || (!included && state.feeds.length >= 3)
|
||||||
|
const moderationUi = moderateFeedGenerator(generator, moderationOpts).ui(
|
||||||
|
'avatar',
|
||||||
|
)
|
||||||
|
|
||||||
|
const onPress = () => {
|
||||||
|
if (disabled) return
|
||||||
|
|
||||||
|
Keyboard.dismiss()
|
||||||
|
if (included) {
|
||||||
|
dispatch({type: 'RemoveFeed', feedUri: generator.uri})
|
||||||
|
} else {
|
||||||
|
dispatch({type: 'AddFeed', feed: generator})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<WizardListCard
|
||||||
|
type="algo"
|
||||||
|
displayName={sanitizeDisplayName(generator.displayName)}
|
||||||
|
subtitle={`Feed by @${sanitizeHandle(generator.creator.handle)}`}
|
||||||
|
onPress={onPress}
|
||||||
|
avatar={generator.avatar}
|
||||||
|
included={included}
|
||||||
|
disabled={disabled}
|
||||||
|
moderationUi={moderationUi}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
|
@ -140,6 +140,7 @@ export function createInput(Component: typeof TextInput) {
|
||||||
onChangeText,
|
onChangeText,
|
||||||
isInvalid,
|
isInvalid,
|
||||||
inputRef,
|
inputRef,
|
||||||
|
style,
|
||||||
...rest
|
...rest
|
||||||
}: InputProps) {
|
}: InputProps) {
|
||||||
const t = useTheme()
|
const t = useTheme()
|
||||||
|
@ -206,6 +207,7 @@ export function createInput(Component: typeof TextInput) {
|
||||||
android({
|
android({
|
||||||
paddingBottom: 16,
|
paddingBottom: 16,
|
||||||
}),
|
}),
|
||||||
|
style,
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|
|
@ -0,0 +1,68 @@
|
||||||
|
import React from 'react'
|
||||||
|
|
||||||
|
import {
|
||||||
|
createStarterPackLinkFromAndroidReferrer,
|
||||||
|
httpStarterPackUriToAtUri,
|
||||||
|
} from 'lib/strings/starter-pack'
|
||||||
|
import {isAndroid} from 'platform/detection'
|
||||||
|
import {useHasCheckedForStarterPack} from 'state/preferences/used-starter-packs'
|
||||||
|
import {useSetActiveStarterPack} from 'state/shell/starter-pack'
|
||||||
|
import {DevicePrefs, Referrer} from '../../../modules/expo-bluesky-swiss-army'
|
||||||
|
|
||||||
|
export function useStarterPackEntry() {
|
||||||
|
const [ready, setReady] = React.useState(false)
|
||||||
|
const setActiveStarterPack = useSetActiveStarterPack()
|
||||||
|
const hasCheckedForStarterPack = useHasCheckedForStarterPack()
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (ready) return
|
||||||
|
|
||||||
|
// On Android, we cannot clear the referral link. It gets stored for 90 days and all we can do is query for it. So,
|
||||||
|
// let's just ensure we never check again after the first time.
|
||||||
|
if (hasCheckedForStarterPack) {
|
||||||
|
setReady(true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Safety for Android. Very unlike this could happen, but just in case. The response should be nearly immediate
|
||||||
|
const timeout = setTimeout(() => {
|
||||||
|
setReady(true)
|
||||||
|
}, 500)
|
||||||
|
|
||||||
|
;(async () => {
|
||||||
|
let uri: string | null | undefined
|
||||||
|
|
||||||
|
if (isAndroid) {
|
||||||
|
const res = await Referrer.getGooglePlayReferrerInfoAsync()
|
||||||
|
|
||||||
|
if (res && res.installReferrer) {
|
||||||
|
uri = createStarterPackLinkFromAndroidReferrer(res.installReferrer)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const res = await DevicePrefs.getStringValueAsync(
|
||||||
|
'starterPackUri',
|
||||||
|
true,
|
||||||
|
)
|
||||||
|
|
||||||
|
if (res) {
|
||||||
|
uri = httpStarterPackUriToAtUri(res)
|
||||||
|
DevicePrefs.setStringValueAsync('starterPackUri', null, true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (uri) {
|
||||||
|
setActiveStarterPack({
|
||||||
|
uri,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
setReady(true)
|
||||||
|
})()
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
clearTimeout(timeout)
|
||||||
|
}
|
||||||
|
}, [ready, setActiveStarterPack, hasCheckedForStarterPack])
|
||||||
|
|
||||||
|
return ready
|
||||||
|
}
|
|
@ -0,0 +1,29 @@
|
||||||
|
import React from 'react'
|
||||||
|
|
||||||
|
import {httpStarterPackUriToAtUri} from 'lib/strings/starter-pack'
|
||||||
|
import {useSetActiveStarterPack} from 'state/shell/starter-pack'
|
||||||
|
|
||||||
|
export function useStarterPackEntry() {
|
||||||
|
const [ready, setReady] = React.useState(false)
|
||||||
|
|
||||||
|
const setActiveStarterPack = useSetActiveStarterPack()
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
const href = window.location.href
|
||||||
|
const atUri = httpStarterPackUriToAtUri(href)
|
||||||
|
|
||||||
|
if (atUri) {
|
||||||
|
const url = new URL(href)
|
||||||
|
// Determines if an App Clip is loading this landing page
|
||||||
|
const isClip = url.searchParams.get('clip') === 'true'
|
||||||
|
setActiveStarterPack({
|
||||||
|
uri: atUri,
|
||||||
|
isClip,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
setReady(true)
|
||||||
|
}, [setActiveStarterPack])
|
||||||
|
|
||||||
|
return ready
|
||||||
|
}
|
|
@ -0,0 +1,5 @@
|
||||||
|
import {createSinglePathSVG} from './TEMPLATE'
|
||||||
|
|
||||||
|
export const QrCode_Stroke2_Corner0_Rounded = createSinglePathSVG({
|
||||||
|
path: 'M3 5a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5Zm6 0H5v4h4V5ZM3 15a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4Zm6 0H5v4h4v-4ZM13 5a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v4a2 2 0 0 1-2 2h-4a2 2 0 0 1-2-2V5Zm6 0h-4v4h4V5ZM14 13a1 1 0 0 1 1 1v1h1a1 1 0 1 1 0 2h-2a1 1 0 0 1-1-1v-2a1 1 0 0 1 1-1Zm3 1a1 1 0 0 1 1-1h2a1 1 0 1 1 0 2h-2a1 1 0 0 1-1-1Zm0 4a1 1 0 0 1 1-1h2a1 1 0 1 1 0 2h-1v1a1 1 0 1 1-2 0v-2Z',
|
||||||
|
})
|
|
@ -0,0 +1,8 @@
|
||||||
|
import {createMultiPathSVG} from './TEMPLATE'
|
||||||
|
|
||||||
|
export const StarterPack = createMultiPathSVG({
|
||||||
|
paths: [
|
||||||
|
'M11.26 5.227 5.02 6.899c-.734.197-1.17.95-.973 1.685l1.672 6.24c.197.734.951 1.17 1.685.973l6.24-1.672c.734-.197 1.17-.951.973-1.685L12.945 6.2a1.375 1.375 0 0 0-1.685-.973Zm-6.566.459a2.632 2.632 0 0 0-1.86 3.223l1.672 6.24a2.632 2.632 0 0 0 3.223 1.861l6.24-1.672a2.631 2.631 0 0 0 1.861-3.223l-1.672-6.24a2.632 2.632 0 0 0-3.223-1.861l-6.24 1.672Z',
|
||||||
|
'M15.138 18.411a4.606 4.606 0 1 0 0-9.211 4.606 4.606 0 0 0 0 9.211Zm0 1.257a5.862 5.862 0 1 0 0-11.724 5.862 5.862 0 0 0 0 11.724Z',
|
||||||
|
],
|
||||||
|
})
|
|
@ -30,7 +30,7 @@ export const IconTemplate_Stroke2_Corner0_Rounded = React.forwardRef(
|
||||||
|
|
||||||
export function createSinglePathSVG({path}: {path: string}) {
|
export function createSinglePathSVG({path}: {path: string}) {
|
||||||
return React.forwardRef<Svg, Props>(function LogoImpl(props, ref) {
|
return React.forwardRef<Svg, Props>(function LogoImpl(props, ref) {
|
||||||
const {fill, size, style, ...rest} = useCommonSVGProps(props)
|
const {fill, size, style, gradient, ...rest} = useCommonSVGProps(props)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Svg
|
<Svg
|
||||||
|
@ -41,8 +41,37 @@ export function createSinglePathSVG({path}: {path: string}) {
|
||||||
width={size}
|
width={size}
|
||||||
height={size}
|
height={size}
|
||||||
style={[style]}>
|
style={[style]}>
|
||||||
|
{gradient}
|
||||||
<Path fill={fill} fillRule="evenodd" clipRule="evenodd" d={path} />
|
<Path fill={fill} fillRule="evenodd" clipRule="evenodd" d={path} />
|
||||||
</Svg>
|
</Svg>
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function createMultiPathSVG({paths}: {paths: string[]}) {
|
||||||
|
return React.forwardRef<Svg, Props>(function LogoImpl(props, ref) {
|
||||||
|
const {fill, size, style, gradient, ...rest} = useCommonSVGProps(props)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Svg
|
||||||
|
fill="none"
|
||||||
|
{...rest}
|
||||||
|
ref={ref}
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
width={size}
|
||||||
|
height={size}
|
||||||
|
style={[style]}>
|
||||||
|
{gradient}
|
||||||
|
{paths.map((path, i) => (
|
||||||
|
<Path
|
||||||
|
key={i}
|
||||||
|
fill={fill}
|
||||||
|
fillRule="evenodd"
|
||||||
|
clipRule="evenodd"
|
||||||
|
d={path}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Svg>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
|
@ -1,32 +0,0 @@
|
||||||
import {StyleSheet, TextProps} from 'react-native'
|
|
||||||
import type {PathProps, SvgProps} from 'react-native-svg'
|
|
||||||
|
|
||||||
import {tokens} from '#/alf'
|
|
||||||
|
|
||||||
export type Props = {
|
|
||||||
fill?: PathProps['fill']
|
|
||||||
style?: TextProps['style']
|
|
||||||
size?: keyof typeof sizes
|
|
||||||
} & Omit<SvgProps, 'style' | 'size'>
|
|
||||||
|
|
||||||
export const sizes = {
|
|
||||||
xs: 12,
|
|
||||||
sm: 16,
|
|
||||||
md: 20,
|
|
||||||
lg: 24,
|
|
||||||
xl: 28,
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useCommonSVGProps(props: Props) {
|
|
||||||
const {fill, size, ...rest} = props
|
|
||||||
const style = StyleSheet.flatten(rest.style)
|
|
||||||
const _fill = fill || style?.color || tokens.color.blue_500
|
|
||||||
const _size = Number(size ? sizes[size] : rest.width || sizes.md)
|
|
||||||
|
|
||||||
return {
|
|
||||||
fill: _fill,
|
|
||||||
size: _size,
|
|
||||||
style,
|
|
||||||
...rest,
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -0,0 +1,59 @@
|
||||||
|
import React from 'react'
|
||||||
|
import {StyleSheet, TextProps} from 'react-native'
|
||||||
|
import type {PathProps, SvgProps} from 'react-native-svg'
|
||||||
|
import {Defs, LinearGradient, Stop} from 'react-native-svg'
|
||||||
|
import {nanoid} from 'nanoid/non-secure'
|
||||||
|
|
||||||
|
import {tokens} from '#/alf'
|
||||||
|
|
||||||
|
export type Props = {
|
||||||
|
fill?: PathProps['fill']
|
||||||
|
style?: TextProps['style']
|
||||||
|
size?: keyof typeof sizes
|
||||||
|
gradient?: keyof typeof tokens.gradients
|
||||||
|
} & Omit<SvgProps, 'style' | 'size'>
|
||||||
|
|
||||||
|
export const sizes = {
|
||||||
|
xs: 12,
|
||||||
|
sm: 16,
|
||||||
|
md: 20,
|
||||||
|
lg: 24,
|
||||||
|
xl: 28,
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useCommonSVGProps(props: Props) {
|
||||||
|
const {fill, size, gradient, ...rest} = props
|
||||||
|
const style = StyleSheet.flatten(rest.style)
|
||||||
|
const _size = Number(size ? sizes[size] : rest.width || sizes.md)
|
||||||
|
let _fill = fill || style?.color || tokens.color.blue_500
|
||||||
|
let gradientDef = null
|
||||||
|
|
||||||
|
if (gradient && tokens.gradients[gradient]) {
|
||||||
|
const id = gradient + '_' + nanoid()
|
||||||
|
const config = tokens.gradients[gradient]
|
||||||
|
_fill = `url(#${id})`
|
||||||
|
gradientDef = (
|
||||||
|
<Defs>
|
||||||
|
<LinearGradient
|
||||||
|
id={id}
|
||||||
|
x1="0"
|
||||||
|
y1="0"
|
||||||
|
x2="100%"
|
||||||
|
y2="0"
|
||||||
|
gradientTransform="rotate(45)">
|
||||||
|
{config.values.map(([stop, fill]) => (
|
||||||
|
<Stop key={stop} offset={stop} stopColor={fill} />
|
||||||
|
))}
|
||||||
|
</LinearGradient>
|
||||||
|
</Defs>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
fill: _fill,
|
||||||
|
size: _size,
|
||||||
|
style,
|
||||||
|
gradient: gradientDef,
|
||||||
|
...rest,
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,3 +1,4 @@
|
||||||
export const isSafari = false
|
export const isSafari = false
|
||||||
export const isFirefox = false
|
export const isFirefox = false
|
||||||
export const isTouchDevice = true
|
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 isFirefox = /firefox|fxios/i.test(navigator.userAgent)
|
||||||
export const isTouchDevice =
|
export const isTouchDevice =
|
||||||
'ontouchstart' in window || navigator.maxTouchPoints > 1
|
'ontouchstart' in window || navigator.maxTouchPoints > 1
|
||||||
|
export const isAndroidWeb =
|
||||||
|
/android/i.test(navigator.userAgent) && isTouchDevice
|
||||||
|
|
|
@ -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}),
|
||||||
|
)
|
||||||
|
}
|
|
@ -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'
|
| 'reply'
|
||||||
| 'quote'
|
| 'quote'
|
||||||
| 'chat-message'
|
| 'chat-message'
|
||||||
|
| 'starterpack-joined'
|
||||||
|
|
||||||
type NotificationPayload =
|
type NotificationPayload =
|
||||||
| {
|
| {
|
||||||
|
@ -142,6 +143,7 @@ export function useNotificationsHandler() {
|
||||||
case 'mention':
|
case 'mention':
|
||||||
case 'quote':
|
case 'quote':
|
||||||
case 'reply':
|
case 'reply':
|
||||||
|
case 'starterpack-joined':
|
||||||
resetToTab('NotificationsTab')
|
resetToTab('NotificationsTab')
|
||||||
break
|
break
|
||||||
// TODO implement these after we have an idea of how to handle each individual case
|
// TODO implement these after we have an idea of how to handle each individual case
|
||||||
|
|
|
@ -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[]
|
account: ReportOption[]
|
||||||
post: ReportOption[]
|
post: ReportOption[]
|
||||||
list: ReportOption[]
|
list: ReportOption[]
|
||||||
|
starterpack: ReportOption[]
|
||||||
feedgen: ReportOption[]
|
feedgen: ReportOption[]
|
||||||
other: ReportOption[]
|
other: ReportOption[]
|
||||||
convoMessage: ReportOption[]
|
convoMessage: ReportOption[]
|
||||||
|
@ -94,6 +95,14 @@ export function useReportOptions(): ReportOptions {
|
||||||
},
|
},
|
||||||
...common,
|
...common,
|
||||||
],
|
],
|
||||||
|
starterpack: [
|
||||||
|
{
|
||||||
|
reason: ComAtprotoModerationDefs.REASONVIOLATION,
|
||||||
|
title: _(msg`Name or Description Violates Community Standards`),
|
||||||
|
description: _(msg`Terms used violate community standards`),
|
||||||
|
},
|
||||||
|
...common,
|
||||||
|
],
|
||||||
feedgen: [
|
feedgen: [
|
||||||
{
|
{
|
||||||
reason: ComAtprotoModerationDefs.REASONVIOLATION,
|
reason: ComAtprotoModerationDefs.REASONVIOLATION,
|
||||||
|
|
|
@ -1,3 +1,5 @@
|
||||||
|
import {AppBskyGraphDefs, AtUri} from '@atproto/api'
|
||||||
|
|
||||||
import {isInvalidHandle} from 'lib/strings/handles'
|
import {isInvalidHandle} from 'lib/strings/handles'
|
||||||
|
|
||||||
export function makeProfileLink(
|
export function makeProfileLink(
|
||||||
|
@ -35,3 +37,18 @@ export function makeSearchLink(props: {query: string; from?: 'me' | string}) {
|
||||||
props.query + (props.from ? ` from:${props.from}` : ''),
|
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}
|
MessagesConversation: {conversation: string; embed?: string}
|
||||||
MessagesSettings: undefined
|
MessagesSettings: undefined
|
||||||
Feeds: undefined
|
Feeds: undefined
|
||||||
|
Start: {name: string; rkey: string}
|
||||||
|
StarterPack: {name: string; rkey: string; new?: boolean}
|
||||||
|
StarterPackWizard: undefined
|
||||||
|
StarterPackEdit: {
|
||||||
|
rkey?: string
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export type BottomTabNavigatorParams = CommonNavigatorParams & {
|
export type BottomTabNavigatorParams = CommonNavigatorParams & {
|
||||||
|
@ -93,6 +99,12 @@ export type AllNavigatorParams = CommonNavigatorParams & {
|
||||||
Hashtag: {tag: string; author?: string}
|
Hashtag: {tag: string; author?: string}
|
||||||
MessagesTab: undefined
|
MessagesTab: undefined
|
||||||
Messages: {animation?: 'push' | 'pop'}
|
Messages: {animation?: 'push' | 'pop'}
|
||||||
|
Start: {name: string; rkey: string}
|
||||||
|
StarterPack: {name: string; rkey: string; new?: boolean}
|
||||||
|
StarterPackWizard: undefined
|
||||||
|
StarterPackEdit: {
|
||||||
|
rkey?: string
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// NOTE
|
// NOTE
|
||||||
|
|
|
@ -53,7 +53,14 @@ export type LogEvents = {
|
||||||
}
|
}
|
||||||
'onboarding:moderation:nextPressed': {}
|
'onboarding:moderation:nextPressed': {}
|
||||||
'onboarding:profile: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': {
|
'onboarding:finished:avatarResult': {
|
||||||
avatarResult: 'default' | 'created' | 'uploaded'
|
avatarResult: 'default' | 'created' | 'uploaded'
|
||||||
}
|
}
|
||||||
|
@ -61,7 +68,12 @@ export type LogEvents = {
|
||||||
feedUrl: string
|
feedUrl: string
|
||||||
feedType: string
|
feedType: string
|
||||||
index: number
|
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': {
|
'feed:endReached:sampled': {
|
||||||
feedUrl: string
|
feedUrl: string
|
||||||
|
@ -134,6 +146,7 @@ export type LogEvents = {
|
||||||
| 'ProfileMenu'
|
| 'ProfileMenu'
|
||||||
| 'ProfileHoverCard'
|
| 'ProfileHoverCard'
|
||||||
| 'AvatarButton'
|
| 'AvatarButton'
|
||||||
|
| 'StarterPackProfilesList'
|
||||||
}
|
}
|
||||||
'profile:unfollow': {
|
'profile:unfollow': {
|
||||||
logContext:
|
logContext:
|
||||||
|
@ -146,6 +159,7 @@ export type LogEvents = {
|
||||||
| 'ProfileHoverCard'
|
| 'ProfileHoverCard'
|
||||||
| 'Chat'
|
| 'Chat'
|
||||||
| 'AvatarButton'
|
| 'AvatarButton'
|
||||||
|
| 'StarterPackProfilesList'
|
||||||
}
|
}
|
||||||
'chat:create': {
|
'chat:create': {
|
||||||
logContext: 'ProfileHeader' | 'NewChatDialog' | 'SendViaChatDialog'
|
logContext: 'ProfileHeader' | 'NewChatDialog' | 'SendViaChatDialog'
|
||||||
|
@ -157,6 +171,23 @@ export type LogEvents = {
|
||||||
| 'ChatsList'
|
| 'ChatsList'
|
||||||
| 'SendViaChatDialog'
|
| '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:always': {}
|
||||||
'test:all:sometimes': {}
|
'test:all:sometimes': {}
|
||||||
|
|
|
@ -5,3 +5,4 @@ export type Gate =
|
||||||
| 'request_notifications_permission_after_onboarding_v2'
|
| 'request_notifications_permission_after_onboarding_v2'
|
||||||
| 'show_avi_follow_button'
|
| 'show_avi_follow_button'
|
||||||
| 'show_follow_back_label_v2'
|
| 'show_follow_back_label_v2'
|
||||||
|
| 'starter_packs_enabled'
|
||||||
|
|
|
@ -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()
|
||||||
|
}
|
|
@ -41,4 +41,8 @@ export const router = new Router({
|
||||||
Messages: '/messages',
|
Messages: '/messages',
|
||||||
MessagesSettings: '/messages/settings',
|
MessagesSettings: '/messages/settings',
|
||||||
MessagesConversation: '/messages/:conversation',
|
MessagesConversation: '/messages/:conversation',
|
||||||
|
Start: '/start/:name/:rkey',
|
||||||
|
StarterPackEdit: '/starter-pack/edit/:rkey',
|
||||||
|
StarterPack: '/starter-pack/:name/:rkey',
|
||||||
|
StarterPackWizard: '/starter-pack/create',
|
||||||
})
|
})
|
||||||
|
|
|
@ -21,6 +21,7 @@ import {logger} from '#/logger'
|
||||||
import {useSessionApi} from '#/state/session'
|
import {useSessionApi} from '#/state/session'
|
||||||
import {useLoggedOutViewControls} from '#/state/shell/logged-out'
|
import {useLoggedOutViewControls} from '#/state/shell/logged-out'
|
||||||
import {useRequestNotificationsPermission} from 'lib/notifications/notifications'
|
import {useRequestNotificationsPermission} from 'lib/notifications/notifications'
|
||||||
|
import {useSetHasCheckedForStarterPack} from 'state/preferences/used-starter-packs'
|
||||||
import {atoms as a, useTheme} from '#/alf'
|
import {atoms as a, useTheme} from '#/alf'
|
||||||
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
|
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
|
||||||
import {FormError} from '#/components/forms/FormError'
|
import {FormError} from '#/components/forms/FormError'
|
||||||
|
@ -69,6 +70,7 @@ export const LoginForm = ({
|
||||||
const {login} = useSessionApi()
|
const {login} = useSessionApi()
|
||||||
const requestNotificationsPermission = useRequestNotificationsPermission()
|
const requestNotificationsPermission = useRequestNotificationsPermission()
|
||||||
const {setShowLoggedOut} = useLoggedOutViewControls()
|
const {setShowLoggedOut} = useLoggedOutViewControls()
|
||||||
|
const setHasCheckedForStarterPack = useSetHasCheckedForStarterPack()
|
||||||
|
|
||||||
const onPressSelectService = React.useCallback(() => {
|
const onPressSelectService = React.useCallback(() => {
|
||||||
Keyboard.dismiss()
|
Keyboard.dismiss()
|
||||||
|
@ -116,6 +118,7 @@ export const LoginForm = ({
|
||||||
'LoginForm',
|
'LoginForm',
|
||||||
)
|
)
|
||||||
setShowLoggedOut(false)
|
setShowLoggedOut(false)
|
||||||
|
setHasCheckedForStarterPack(true)
|
||||||
requestNotificationsPermission('Login')
|
requestNotificationsPermission('Login')
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
const errMsg = e.toString()
|
const errMsg = e.toString()
|
||||||
|
|
|
@ -1,9 +1,16 @@
|
||||||
import React from 'react'
|
import React from 'react'
|
||||||
|
import {StyleProp, ViewStyle} from 'react-native'
|
||||||
import Animated, {FadeInRight, FadeOutLeft} from 'react-native-reanimated'
|
import Animated, {FadeInRight, FadeOutLeft} from 'react-native-reanimated'
|
||||||
|
|
||||||
export function ScreenTransition({children}: {children: React.ReactNode}) {
|
export function ScreenTransition({
|
||||||
|
style,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
style?: StyleProp<ViewStyle>
|
||||||
|
children: React.ReactNode
|
||||||
|
}) {
|
||||||
return (
|
return (
|
||||||
<Animated.View entering={FadeInRight} exiting={FadeOutLeft}>
|
<Animated.View style={style} entering={FadeInRight} exiting={FadeOutLeft}>
|
||||||
{children}
|
{children}
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
)
|
)
|
||||||
|
|
|
@ -1,11 +1,18 @@
|
||||||
import React from 'react'
|
import React from 'react'
|
||||||
import {View} from 'react-native'
|
import {View} from 'react-native'
|
||||||
|
import {AppBskyGraphDefs, AppBskyGraphStarterpack} from '@atproto/api'
|
||||||
|
import {SavedFeed} from '@atproto/api/dist/client/types/app/bsky/actor/defs'
|
||||||
|
import {TID} from '@atproto/common-web'
|
||||||
import {msg, Trans} from '@lingui/macro'
|
import {msg, Trans} from '@lingui/macro'
|
||||||
import {useLingui} from '@lingui/react'
|
import {useLingui} from '@lingui/react'
|
||||||
import {useQueryClient} from '@tanstack/react-query'
|
import {useQueryClient} from '@tanstack/react-query'
|
||||||
|
|
||||||
import {useAnalytics} from '#/lib/analytics/analytics'
|
import {useAnalytics} from '#/lib/analytics/analytics'
|
||||||
import {BSKY_APP_ACCOUNT_DID} from '#/lib/constants'
|
import {
|
||||||
|
BSKY_APP_ACCOUNT_DID,
|
||||||
|
DISCOVER_SAVED_FEED,
|
||||||
|
TIMELINE_SAVED_FEED,
|
||||||
|
} from '#/lib/constants'
|
||||||
import {logEvent} from '#/lib/statsig/statsig'
|
import {logEvent} from '#/lib/statsig/statsig'
|
||||||
import {logger} from '#/logger'
|
import {logger} from '#/logger'
|
||||||
import {preferencesQueryKey} from '#/state/queries/preferences'
|
import {preferencesQueryKey} from '#/state/queries/preferences'
|
||||||
|
@ -14,6 +21,11 @@ import {useAgent} from '#/state/session'
|
||||||
import {useOnboardingDispatch} from '#/state/shell'
|
import {useOnboardingDispatch} from '#/state/shell'
|
||||||
import {uploadBlob} from 'lib/api'
|
import {uploadBlob} from 'lib/api'
|
||||||
import {useRequestNotificationsPermission} from 'lib/notifications/notifications'
|
import {useRequestNotificationsPermission} from 'lib/notifications/notifications'
|
||||||
|
import {useSetHasCheckedForStarterPack} from 'state/preferences/used-starter-packs'
|
||||||
|
import {
|
||||||
|
useActiveStarterPack,
|
||||||
|
useSetActiveStarterPack,
|
||||||
|
} from 'state/shell/starter-pack'
|
||||||
import {
|
import {
|
||||||
DescriptionText,
|
DescriptionText,
|
||||||
OnboardingControls,
|
OnboardingControls,
|
||||||
|
@ -41,17 +53,74 @@ export function StepFinished() {
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
const agent = useAgent()
|
const agent = useAgent()
|
||||||
const requestNotificationsPermission = useRequestNotificationsPermission()
|
const requestNotificationsPermission = useRequestNotificationsPermission()
|
||||||
|
const activeStarterPack = useActiveStarterPack()
|
||||||
|
const setActiveStarterPack = useSetActiveStarterPack()
|
||||||
|
const setHasCheckedForStarterPack = useSetHasCheckedForStarterPack()
|
||||||
|
|
||||||
const finishOnboarding = React.useCallback(async () => {
|
const finishOnboarding = React.useCallback(async () => {
|
||||||
setSaving(true)
|
setSaving(true)
|
||||||
|
|
||||||
const {interestsStepResults, profileStepResults} = state
|
let starterPack: AppBskyGraphDefs.StarterPackView | undefined
|
||||||
const {selectedInterests} = interestsStepResults
|
let listItems: AppBskyGraphDefs.ListItemView[] | undefined
|
||||||
|
|
||||||
|
if (activeStarterPack?.uri) {
|
||||||
|
try {
|
||||||
|
const spRes = await agent.app.bsky.graph.getStarterPack({
|
||||||
|
starterPack: activeStarterPack.uri,
|
||||||
|
})
|
||||||
|
starterPack = spRes.data.starterPack
|
||||||
|
|
||||||
|
if (starterPack.list) {
|
||||||
|
const listRes = await agent.app.bsky.graph.getList({
|
||||||
|
list: starterPack.list.uri,
|
||||||
|
limit: 50,
|
||||||
|
})
|
||||||
|
listItems = listRes.data.items
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
logger.error('Failed to fetch starter pack', {safeMessage: e})
|
||||||
|
// don't tell the user, just get them through onboarding.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
const {interestsStepResults, profileStepResults} = state
|
||||||
|
const {selectedInterests} = interestsStepResults
|
||||||
|
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
bulkWriteFollows(agent, [BSKY_APP_ACCOUNT_DID]),
|
bulkWriteFollows(agent, [
|
||||||
|
BSKY_APP_ACCOUNT_DID,
|
||||||
|
...(listItems?.map(i => i.subject.did) ?? []),
|
||||||
|
]),
|
||||||
(async () => {
|
(async () => {
|
||||||
|
// Interests need to get saved first, then we can write the feeds to prefs
|
||||||
await agent.setInterestsPref({tags: selectedInterests})
|
await agent.setInterestsPref({tags: selectedInterests})
|
||||||
|
|
||||||
|
// Default feeds that every user should have pinned when landing in the app
|
||||||
|
const feedsToSave: SavedFeed[] = [
|
||||||
|
{
|
||||||
|
...DISCOVER_SAVED_FEED,
|
||||||
|
id: TID.nextStr(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
...TIMELINE_SAVED_FEED,
|
||||||
|
id: TID.nextStr(),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
// Any starter pack feeds will be pinned _after_ the defaults
|
||||||
|
if (starterPack && starterPack.feeds?.length) {
|
||||||
|
feedsToSave.concat(
|
||||||
|
starterPack.feeds.map(f => ({
|
||||||
|
type: 'feed',
|
||||||
|
value: f.uri,
|
||||||
|
pinned: true,
|
||||||
|
id: TID.nextStr(),
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
await agent.overwriteSavedFeeds(feedsToSave)
|
||||||
})(),
|
})(),
|
||||||
(async () => {
|
(async () => {
|
||||||
const {imageUri, imageMime} = profileStepResults
|
const {imageUri, imageMime} = profileStepResults
|
||||||
|
@ -63,9 +132,24 @@ export function StepFinished() {
|
||||||
if (res.data.blob) {
|
if (res.data.blob) {
|
||||||
existing.avatar = res.data.blob
|
existing.avatar = res.data.blob
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (starterPack) {
|
||||||
|
existing.joinedViaStarterPack = {
|
||||||
|
uri: starterPack.uri,
|
||||||
|
cid: starterPack.cid,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
existing.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
|
||||||
|
existing.createdAt = new Date().toISOString()
|
||||||
return existing
|
return existing
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
logEvent('onboarding:finished:avatarResult', {
|
logEvent('onboarding:finished:avatarResult', {
|
||||||
avatarResult: profileStepResults.isCreatedAvatar
|
avatarResult: profileStepResults.isCreatedAvatar
|
||||||
? 'created'
|
? 'created'
|
||||||
|
@ -96,19 +180,40 @@ export function StepFinished() {
|
||||||
})
|
})
|
||||||
|
|
||||||
setSaving(false)
|
setSaving(false)
|
||||||
|
setActiveStarterPack(undefined)
|
||||||
|
setHasCheckedForStarterPack(true)
|
||||||
dispatch({type: 'finish'})
|
dispatch({type: 'finish'})
|
||||||
onboardDispatch({type: 'finish'})
|
onboardDispatch({type: 'finish'})
|
||||||
track('OnboardingV2:StepFinished:End')
|
track('OnboardingV2:StepFinished:End')
|
||||||
track('OnboardingV2:Complete')
|
track('OnboardingV2:Complete')
|
||||||
logEvent('onboarding:finished:nextPressed', {})
|
logEvent('onboarding:finished:nextPressed', {
|
||||||
|
usedStarterPack: Boolean(starterPack),
|
||||||
|
starterPackName: AppBskyGraphStarterpack.isRecord(starterPack?.record)
|
||||||
|
? starterPack.record.name
|
||||||
|
: undefined,
|
||||||
|
starterPackCreator: starterPack?.creator.did,
|
||||||
|
starterPackUri: starterPack?.uri,
|
||||||
|
profilesFollowed: listItems?.length ?? 0,
|
||||||
|
feedsPinned: starterPack?.feeds?.length ?? 0,
|
||||||
|
})
|
||||||
|
if (starterPack && listItems?.length) {
|
||||||
|
logEvent('starterPack:followAll', {
|
||||||
|
logContext: 'Onboarding',
|
||||||
|
starterPack: starterPack.uri,
|
||||||
|
count: listItems?.length,
|
||||||
|
})
|
||||||
|
}
|
||||||
}, [
|
}, [
|
||||||
state,
|
|
||||||
queryClient,
|
queryClient,
|
||||||
agent,
|
agent,
|
||||||
dispatch,
|
dispatch,
|
||||||
onboardDispatch,
|
onboardDispatch,
|
||||||
track,
|
track,
|
||||||
|
activeStarterPack,
|
||||||
|
state,
|
||||||
requestNotificationsPermission,
|
requestNotificationsPermission,
|
||||||
|
setActiveStarterPack,
|
||||||
|
setHasCheckedForStarterPack,
|
||||||
])
|
])
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
|
|
|
@ -1,10 +1,10 @@
|
||||||
import React from 'react'
|
import React from 'react'
|
||||||
import {View} from 'react-native'
|
import {View} from 'react-native'
|
||||||
import {AppBskyActorDefs, ModerationDecision} from '@atproto/api'
|
import {AppBskyActorDefs, ModerationDecision} from '@atproto/api'
|
||||||
import {sanitizeHandle} from 'lib/strings/handles'
|
|
||||||
import {sanitizeDisplayName} from 'lib/strings/display-names'
|
|
||||||
import {Shadow} from '#/state/cache/types'
|
|
||||||
|
|
||||||
|
import {Shadow} from '#/state/cache/types'
|
||||||
|
import {sanitizeDisplayName} from 'lib/strings/display-names'
|
||||||
|
import {sanitizeHandle} from 'lib/strings/handles'
|
||||||
import {atoms as a, useTheme} from '#/alf'
|
import {atoms as a, useTheme} from '#/alf'
|
||||||
import {Text} from '#/components/Typography'
|
import {Text} from '#/components/Typography'
|
||||||
|
|
||||||
|
|
|
@ -1,6 +1,11 @@
|
||||||
import React from 'react'
|
import React from 'react'
|
||||||
import {View} from 'react-native'
|
import {View} from 'react-native'
|
||||||
import {LayoutAnimationConfig} from 'react-native-reanimated'
|
import Animated, {
|
||||||
|
FadeIn,
|
||||||
|
FadeOut,
|
||||||
|
LayoutAnimationConfig,
|
||||||
|
} from 'react-native-reanimated'
|
||||||
|
import {AppBskyGraphStarterpack} from '@atproto/api'
|
||||||
import {msg, Trans} from '@lingui/macro'
|
import {msg, Trans} from '@lingui/macro'
|
||||||
import {useLingui} from '@lingui/react'
|
import {useLingui} from '@lingui/react'
|
||||||
|
|
||||||
|
@ -11,6 +16,8 @@ import {createFullHandle} from '#/lib/strings/handles'
|
||||||
import {logger} from '#/logger'
|
import {logger} from '#/logger'
|
||||||
import {useServiceQuery} from '#/state/queries/service'
|
import {useServiceQuery} from '#/state/queries/service'
|
||||||
import {useAgent} from '#/state/session'
|
import {useAgent} from '#/state/session'
|
||||||
|
import {useStarterPackQuery} from 'state/queries/starter-packs'
|
||||||
|
import {useActiveStarterPack} from 'state/shell/starter-pack'
|
||||||
import {LoggedOutLayout} from '#/view/com/util/layouts/LoggedOutLayout'
|
import {LoggedOutLayout} from '#/view/com/util/layouts/LoggedOutLayout'
|
||||||
import {
|
import {
|
||||||
initialState,
|
initialState,
|
||||||
|
@ -26,6 +33,7 @@ import {atoms as a, useBreakpoints, useTheme} from '#/alf'
|
||||||
import {AppLanguageDropdown} from '#/components/AppLanguageDropdown'
|
import {AppLanguageDropdown} from '#/components/AppLanguageDropdown'
|
||||||
import {Button, ButtonText} from '#/components/Button'
|
import {Button, ButtonText} from '#/components/Button'
|
||||||
import {Divider} from '#/components/Divider'
|
import {Divider} from '#/components/Divider'
|
||||||
|
import {LinearGradientBackground} from '#/components/LinearGradientBackground'
|
||||||
import {InlineLinkText} from '#/components/Link'
|
import {InlineLinkText} from '#/components/Link'
|
||||||
import {Text} from '#/components/Typography'
|
import {Text} from '#/components/Typography'
|
||||||
|
|
||||||
|
@ -38,6 +46,11 @@ export function Signup({onPressBack}: {onPressBack: () => void}) {
|
||||||
const {gtMobile} = useBreakpoints()
|
const {gtMobile} = useBreakpoints()
|
||||||
const agent = useAgent()
|
const agent = useAgent()
|
||||||
|
|
||||||
|
const activeStarterPack = useActiveStarterPack()
|
||||||
|
const {data: starterPack} = useStarterPackQuery({
|
||||||
|
uri: activeStarterPack?.uri,
|
||||||
|
})
|
||||||
|
|
||||||
const {
|
const {
|
||||||
data: serviceInfo,
|
data: serviceInfo,
|
||||||
isFetching,
|
isFetching,
|
||||||
|
@ -142,6 +155,31 @@ export function Signup({onPressBack}: {onPressBack: () => void}) {
|
||||||
description={_(msg`We're so excited to have you join us!`)}
|
description={_(msg`We're so excited to have you join us!`)}
|
||||||
scrollable>
|
scrollable>
|
||||||
<View testID="createAccount" style={a.flex_1}>
|
<View testID="createAccount" style={a.flex_1}>
|
||||||
|
{state.activeStep === SignupStep.INFO &&
|
||||||
|
starterPack &&
|
||||||
|
AppBskyGraphStarterpack.isRecord(starterPack.record) ? (
|
||||||
|
<Animated.View entering={FadeIn} exiting={FadeOut}>
|
||||||
|
<LinearGradientBackground
|
||||||
|
style={[a.mx_lg, a.p_lg, a.gap_sm, a.rounded_sm]}>
|
||||||
|
<Text style={[a.font_bold, a.text_xl, {color: 'white'}]}>
|
||||||
|
{starterPack.record.name}
|
||||||
|
</Text>
|
||||||
|
<Text style={[{color: 'white'}]}>
|
||||||
|
{starterPack.feeds?.length ? (
|
||||||
|
<Trans>
|
||||||
|
You'll follow the suggested users and feeds once you
|
||||||
|
finish creating your account!
|
||||||
|
</Trans>
|
||||||
|
) : (
|
||||||
|
<Trans>
|
||||||
|
You'll follow the suggested users once you finish creating
|
||||||
|
your account!
|
||||||
|
</Trans>
|
||||||
|
)}
|
||||||
|
</Text>
|
||||||
|
</LinearGradientBackground>
|
||||||
|
</Animated.View>
|
||||||
|
) : null}
|
||||||
<View
|
<View
|
||||||
style={[
|
style={[
|
||||||
a.flex_1,
|
a.flex_1,
|
||||||
|
|
|
@ -0,0 +1,378 @@
|
||||||
|
import React from 'react'
|
||||||
|
import {Pressable, ScrollView, View} from 'react-native'
|
||||||
|
import Animated, {FadeIn, FadeOut} from 'react-native-reanimated'
|
||||||
|
import {
|
||||||
|
AppBskyGraphDefs,
|
||||||
|
AppBskyGraphStarterpack,
|
||||||
|
AtUri,
|
||||||
|
ModerationOpts,
|
||||||
|
} from '@atproto/api'
|
||||||
|
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
|
||||||
|
import {msg, Trans} from '@lingui/macro'
|
||||||
|
import {useLingui} from '@lingui/react'
|
||||||
|
|
||||||
|
import {isAndroidWeb} from 'lib/browser'
|
||||||
|
import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
|
||||||
|
import {createStarterPackGooglePlayUri} from 'lib/strings/starter-pack'
|
||||||
|
import {isWeb} from 'platform/detection'
|
||||||
|
import {useModerationOpts} from 'state/preferences/moderation-opts'
|
||||||
|
import {useStarterPackQuery} from 'state/queries/starter-packs'
|
||||||
|
import {
|
||||||
|
useActiveStarterPack,
|
||||||
|
useSetActiveStarterPack,
|
||||||
|
} from 'state/shell/starter-pack'
|
||||||
|
import {LoggedOutScreenState} from 'view/com/auth/LoggedOut'
|
||||||
|
import {CenteredView} from 'view/com/util/Views'
|
||||||
|
import {Logo} from 'view/icons/Logo'
|
||||||
|
import {atoms as a, useTheme} from '#/alf'
|
||||||
|
import {Button, ButtonText} from '#/components/Button'
|
||||||
|
import {useDialogControl} from '#/components/Dialog'
|
||||||
|
import * as FeedCard from '#/components/FeedCard'
|
||||||
|
import {LinearGradientBackground} from '#/components/LinearGradientBackground'
|
||||||
|
import {ListMaybePlaceholder} from '#/components/Lists'
|
||||||
|
import {Default as ProfileCard} from '#/components/ProfileCard'
|
||||||
|
import * as Prompt from '#/components/Prompt'
|
||||||
|
import {Text} from '#/components/Typography'
|
||||||
|
|
||||||
|
const AnimatedPressable = Animated.createAnimatedComponent(Pressable)
|
||||||
|
|
||||||
|
interface AppClipMessage {
|
||||||
|
action: 'present' | 'store'
|
||||||
|
keyToStoreAs?: string
|
||||||
|
jsonToStore?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function postAppClipMessage(message: AppClipMessage) {
|
||||||
|
// @ts-expect-error safari webview only
|
||||||
|
window.webkit.messageHandlers.onMessage.postMessage(JSON.stringify(message))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LandingScreen({
|
||||||
|
setScreenState,
|
||||||
|
}: {
|
||||||
|
setScreenState: (state: LoggedOutScreenState) => void
|
||||||
|
}) {
|
||||||
|
const moderationOpts = useModerationOpts()
|
||||||
|
const activeStarterPack = useActiveStarterPack()
|
||||||
|
|
||||||
|
const {data: starterPack, isError: isErrorStarterPack} = useStarterPackQuery({
|
||||||
|
uri: activeStarterPack?.uri,
|
||||||
|
})
|
||||||
|
|
||||||
|
const isValid =
|
||||||
|
starterPack &&
|
||||||
|
starterPack.list &&
|
||||||
|
AppBskyGraphDefs.validateStarterPackView(starterPack) &&
|
||||||
|
AppBskyGraphStarterpack.validateRecord(starterPack.record)
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (isErrorStarterPack || (starterPack && !isValid)) {
|
||||||
|
setScreenState(LoggedOutScreenState.S_LoginOrCreateAccount)
|
||||||
|
}
|
||||||
|
}, [isErrorStarterPack, setScreenState, isValid, starterPack])
|
||||||
|
|
||||||
|
if (!starterPack || !isValid || !moderationOpts) {
|
||||||
|
return <ListMaybePlaceholder isLoading={true} />
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<LandingScreenLoaded
|
||||||
|
starterPack={starterPack}
|
||||||
|
setScreenState={setScreenState}
|
||||||
|
moderationOpts={moderationOpts}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function LandingScreenLoaded({
|
||||||
|
starterPack,
|
||||||
|
setScreenState,
|
||||||
|
// TODO apply this to profile card
|
||||||
|
|
||||||
|
moderationOpts,
|
||||||
|
}: {
|
||||||
|
starterPack: AppBskyGraphDefs.StarterPackView
|
||||||
|
setScreenState: (state: LoggedOutScreenState) => void
|
||||||
|
moderationOpts: ModerationOpts
|
||||||
|
}) {
|
||||||
|
const {record, creator, listItemsSample, feeds, joinedWeekCount} = starterPack
|
||||||
|
const {_} = useLingui()
|
||||||
|
const t = useTheme()
|
||||||
|
const activeStarterPack = useActiveStarterPack()
|
||||||
|
const setActiveStarterPack = useSetActiveStarterPack()
|
||||||
|
const {isTabletOrDesktop} = useWebMediaQueries()
|
||||||
|
const androidDialogControl = useDialogControl()
|
||||||
|
|
||||||
|
const [appClipOverlayVisible, setAppClipOverlayVisible] =
|
||||||
|
React.useState(false)
|
||||||
|
|
||||||
|
const listItemsCount = starterPack.list?.listItemCount ?? 0
|
||||||
|
|
||||||
|
const onContinue = () => {
|
||||||
|
setActiveStarterPack({
|
||||||
|
uri: starterPack.uri,
|
||||||
|
})
|
||||||
|
setScreenState(LoggedOutScreenState.S_CreateAccount)
|
||||||
|
}
|
||||||
|
|
||||||
|
const onJoinPress = () => {
|
||||||
|
if (activeStarterPack?.isClip) {
|
||||||
|
setAppClipOverlayVisible(true)
|
||||||
|
postAppClipMessage({
|
||||||
|
action: 'present',
|
||||||
|
})
|
||||||
|
} else if (isAndroidWeb) {
|
||||||
|
androidDialogControl.open()
|
||||||
|
} else {
|
||||||
|
onContinue()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const onJoinWithoutPress = () => {
|
||||||
|
if (activeStarterPack?.isClip) {
|
||||||
|
setAppClipOverlayVisible(true)
|
||||||
|
postAppClipMessage({
|
||||||
|
action: 'present',
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
setActiveStarterPack(undefined)
|
||||||
|
setScreenState(LoggedOutScreenState.S_CreateAccount)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!AppBskyGraphStarterpack.isRecord(record)) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CenteredView style={a.flex_1}>
|
||||||
|
<ScrollView
|
||||||
|
style={[a.flex_1, t.atoms.bg]}
|
||||||
|
contentContainerStyle={{paddingBottom: 100}}>
|
||||||
|
<LinearGradientBackground
|
||||||
|
style={[
|
||||||
|
a.align_center,
|
||||||
|
a.gap_sm,
|
||||||
|
a.px_lg,
|
||||||
|
a.py_2xl,
|
||||||
|
isTabletOrDesktop && [a.mt_2xl, a.rounded_md],
|
||||||
|
activeStarterPack?.isClip && {
|
||||||
|
paddingTop: 100,
|
||||||
|
},
|
||||||
|
]}>
|
||||||
|
<View style={[a.flex_row, a.gap_md, a.pb_sm]}>
|
||||||
|
<Logo width={76} fill="white" />
|
||||||
|
</View>
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
a.font_bold,
|
||||||
|
a.text_4xl,
|
||||||
|
a.text_center,
|
||||||
|
a.leading_tight,
|
||||||
|
{color: 'white'},
|
||||||
|
]}>
|
||||||
|
{record.name}
|
||||||
|
</Text>
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
a.text_center,
|
||||||
|
a.font_semibold,
|
||||||
|
a.text_md,
|
||||||
|
{color: 'white'},
|
||||||
|
]}>
|
||||||
|
Starter pack by {`@${creator.handle}`}
|
||||||
|
</Text>
|
||||||
|
</LinearGradientBackground>
|
||||||
|
<View style={[a.gap_2xl, a.mx_lg, a.my_2xl]}>
|
||||||
|
{record.description ? (
|
||||||
|
<Text style={[a.text_md, t.atoms.text_contrast_medium]}>
|
||||||
|
{record.description}
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
<View style={[a.gap_sm]}>
|
||||||
|
<Button
|
||||||
|
label={_(msg`Join Bluesky`)}
|
||||||
|
onPress={onJoinPress}
|
||||||
|
variant="solid"
|
||||||
|
color="primary"
|
||||||
|
size="large">
|
||||||
|
<ButtonText style={[a.text_lg]}>
|
||||||
|
<Trans>Join Bluesky</Trans>
|
||||||
|
</ButtonText>
|
||||||
|
</Button>
|
||||||
|
{joinedWeekCount && joinedWeekCount >= 25 ? (
|
||||||
|
<View style={[a.flex_row, a.align_center, a.gap_sm]}>
|
||||||
|
<FontAwesomeIcon
|
||||||
|
icon="arrow-trend-up"
|
||||||
|
size={12}
|
||||||
|
color={t.atoms.text_contrast_medium.color}
|
||||||
|
/>
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
a.font_semibold,
|
||||||
|
a.text_sm,
|
||||||
|
t.atoms.text_contrast_medium,
|
||||||
|
]}
|
||||||
|
numberOfLines={1}>
|
||||||
|
123,659 joined this week
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
<View style={[a.gap_3xl]}>
|
||||||
|
{Boolean(listItemsSample?.length) && (
|
||||||
|
<View style={[a.gap_md]}>
|
||||||
|
<Text style={[a.font_heavy, a.text_lg]}>
|
||||||
|
{listItemsCount <= 8 ? (
|
||||||
|
<Trans>You'll follow these people right away</Trans>
|
||||||
|
) : (
|
||||||
|
<Trans>
|
||||||
|
You'll follow these people and {listItemsCount - 8} others
|
||||||
|
</Trans>
|
||||||
|
)}
|
||||||
|
</Text>
|
||||||
|
<View>
|
||||||
|
{starterPack.listItemsSample?.slice(0, 8).map(item => (
|
||||||
|
<View
|
||||||
|
key={item.subject.did}
|
||||||
|
style={[
|
||||||
|
a.py_lg,
|
||||||
|
a.px_md,
|
||||||
|
a.border_t,
|
||||||
|
t.atoms.border_contrast_low,
|
||||||
|
]}>
|
||||||
|
<ProfileCard
|
||||||
|
profile={item.subject}
|
||||||
|
moderationOpts={moderationOpts}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
{feeds?.length ? (
|
||||||
|
<View style={[a.gap_md]}>
|
||||||
|
<Text style={[a.font_heavy, a.text_lg]}>
|
||||||
|
<Trans>You'll stay updated with these feeds</Trans>
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<View style={[{pointerEvents: 'none'}]}>
|
||||||
|
{feeds?.map(feed => (
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
a.py_lg,
|
||||||
|
a.px_md,
|
||||||
|
a.border_t,
|
||||||
|
t.atoms.border_contrast_low,
|
||||||
|
]}
|
||||||
|
key={feed.uri}>
|
||||||
|
<FeedCard.Default type="feed" view={feed} />
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
<Button
|
||||||
|
label={_(msg`Signup without a starter pack`)}
|
||||||
|
variant="solid"
|
||||||
|
color="secondary"
|
||||||
|
size="medium"
|
||||||
|
style={[a.py_lg]}
|
||||||
|
onPress={onJoinWithoutPress}>
|
||||||
|
<ButtonText>
|
||||||
|
<Trans>Signup without a starter pack</Trans>
|
||||||
|
</ButtonText>
|
||||||
|
</Button>
|
||||||
|
</View>
|
||||||
|
</ScrollView>
|
||||||
|
<AppClipOverlay
|
||||||
|
visible={appClipOverlayVisible}
|
||||||
|
setIsVisible={setAppClipOverlayVisible}
|
||||||
|
/>
|
||||||
|
<Prompt.Outer control={androidDialogControl}>
|
||||||
|
<Prompt.TitleText>
|
||||||
|
<Trans>Download Bluesky</Trans>
|
||||||
|
</Prompt.TitleText>
|
||||||
|
<Prompt.DescriptionText>
|
||||||
|
<Trans>
|
||||||
|
The experience is better in the app. Download Bluesky now and we'll
|
||||||
|
pick back up where you left off.
|
||||||
|
</Trans>
|
||||||
|
</Prompt.DescriptionText>
|
||||||
|
<Prompt.Actions>
|
||||||
|
<Prompt.Action
|
||||||
|
cta="Download on Google Play"
|
||||||
|
color="primary"
|
||||||
|
onPress={() => {
|
||||||
|
const rkey = new AtUri(starterPack.uri).rkey
|
||||||
|
if (!rkey) return
|
||||||
|
|
||||||
|
const googlePlayUri = createStarterPackGooglePlayUri(
|
||||||
|
creator.handle,
|
||||||
|
rkey,
|
||||||
|
)
|
||||||
|
if (!googlePlayUri) return
|
||||||
|
|
||||||
|
window.location.href = googlePlayUri
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Prompt.Action
|
||||||
|
cta="Continue on web"
|
||||||
|
color="secondary"
|
||||||
|
onPress={onContinue}
|
||||||
|
/>
|
||||||
|
</Prompt.Actions>
|
||||||
|
</Prompt.Outer>
|
||||||
|
{isWeb && (
|
||||||
|
<meta
|
||||||
|
name="apple-itunes-app"
|
||||||
|
content="app-id=xyz.blueskyweb.app, app-clip-bundle-id=xyz.blueskyweb.app.AppClip, app-clip-display=card"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</CenteredView>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AppClipOverlay({
|
||||||
|
visible,
|
||||||
|
setIsVisible,
|
||||||
|
}: {
|
||||||
|
visible: boolean
|
||||||
|
setIsVisible: (visible: boolean) => void
|
||||||
|
}) {
|
||||||
|
if (!visible) return
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AnimatedPressable
|
||||||
|
accessibilityRole="button"
|
||||||
|
style={[
|
||||||
|
a.absolute,
|
||||||
|
{
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
bottom: 0,
|
||||||
|
backgroundColor: 'rgba(0, 0, 0, 0.95)',
|
||||||
|
zIndex: 1,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
entering={FadeIn}
|
||||||
|
exiting={FadeOut}
|
||||||
|
onPress={() => setIsVisible(false)}>
|
||||||
|
<View style={[a.flex_1, a.px_lg, {marginTop: 250}]}>
|
||||||
|
{/* Webkit needs this to have a zindex of 2? */}
|
||||||
|
<View style={[a.gap_md, {zIndex: 2}]}>
|
||||||
|
<Text
|
||||||
|
style={[a.font_bold, a.text_4xl, {lineHeight: 40, color: 'white'}]}>
|
||||||
|
Download Bluesky to get started!
|
||||||
|
</Text>
|
||||||
|
<Text style={[a.text_lg, {color: 'white'}]}>
|
||||||
|
We'll remember the starter pack you chose and use it when you create
|
||||||
|
an account in the app.
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</AnimatedPressable>
|
||||||
|
)
|
||||||
|
}
|
|
@ -0,0 +1,627 @@
|
||||||
|
import React from 'react'
|
||||||
|
import {View} from 'react-native'
|
||||||
|
import {Image} from 'expo-image'
|
||||||
|
import {
|
||||||
|
AppBskyGraphDefs,
|
||||||
|
AppBskyGraphGetList,
|
||||||
|
AppBskyGraphStarterpack,
|
||||||
|
AtUri,
|
||||||
|
ModerationOpts,
|
||||||
|
} from '@atproto/api'
|
||||||
|
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
|
||||||
|
import {msg, Trans} from '@lingui/macro'
|
||||||
|
import {useLingui} from '@lingui/react'
|
||||||
|
import {useNavigation} from '@react-navigation/native'
|
||||||
|
import {NativeStackScreenProps} from '@react-navigation/native-stack'
|
||||||
|
import {
|
||||||
|
InfiniteData,
|
||||||
|
UseInfiniteQueryResult,
|
||||||
|
useQueryClient,
|
||||||
|
} from '@tanstack/react-query'
|
||||||
|
|
||||||
|
import {cleanError} from '#/lib/strings/errors'
|
||||||
|
import {logger} from '#/logger'
|
||||||
|
import {useDeleteStarterPackMutation} from '#/state/queries/starter-packs'
|
||||||
|
import {HITSLOP_20} from 'lib/constants'
|
||||||
|
import {makeProfileLink, makeStarterPackLink} from 'lib/routes/links'
|
||||||
|
import {CommonNavigatorParams, NavigationProp} from 'lib/routes/types'
|
||||||
|
import {logEvent} from 'lib/statsig/statsig'
|
||||||
|
import {getStarterPackOgCard} from 'lib/strings/starter-pack'
|
||||||
|
import {isWeb} from 'platform/detection'
|
||||||
|
import {useModerationOpts} from 'state/preferences/moderation-opts'
|
||||||
|
import {RQKEY, useListMembersQuery} from 'state/queries/list-members'
|
||||||
|
import {useResolveDidQuery} from 'state/queries/resolve-uri'
|
||||||
|
import {useShortenLink} from 'state/queries/shorten-link'
|
||||||
|
import {useStarterPackQuery} from 'state/queries/starter-packs'
|
||||||
|
import {useAgent, useSession} from 'state/session'
|
||||||
|
import * as Toast from '#/view/com/util/Toast'
|
||||||
|
import {PagerWithHeader} from 'view/com/pager/PagerWithHeader'
|
||||||
|
import {ProfileSubpageHeader} from 'view/com/profile/ProfileSubpageHeader'
|
||||||
|
import {CenteredView} from 'view/com/util/Views'
|
||||||
|
import {bulkWriteFollows} from '#/screens/Onboarding/util'
|
||||||
|
import {atoms as a, useBreakpoints, useTheme} from '#/alf'
|
||||||
|
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
|
||||||
|
import {useDialogControl} from '#/components/Dialog'
|
||||||
|
import {ArrowOutOfBox_Stroke2_Corner0_Rounded as ArrowOutOfBox} from '#/components/icons/ArrowOutOfBox'
|
||||||
|
import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfo} from '#/components/icons/CircleInfo'
|
||||||
|
import {DotGrid_Stroke2_Corner0_Rounded as Ellipsis} from '#/components/icons/DotGrid'
|
||||||
|
import {Pencil_Stroke2_Corner0_Rounded as Pencil} from '#/components/icons/Pencil'
|
||||||
|
import {Trash_Stroke2_Corner0_Rounded as Trash} from '#/components/icons/Trash'
|
||||||
|
import {ListMaybePlaceholder} from '#/components/Lists'
|
||||||
|
import {Loader} from '#/components/Loader'
|
||||||
|
import * as Menu from '#/components/Menu'
|
||||||
|
import * as Prompt from '#/components/Prompt'
|
||||||
|
import {ReportDialog, useReportDialogControl} from '#/components/ReportDialog'
|
||||||
|
import {FeedsList} from '#/components/StarterPack/Main/FeedsList'
|
||||||
|
import {ProfilesList} from '#/components/StarterPack/Main/ProfilesList'
|
||||||
|
import {QrCodeDialog} from '#/components/StarterPack/QrCodeDialog'
|
||||||
|
import {ShareDialog} from '#/components/StarterPack/ShareDialog'
|
||||||
|
import {Text} from '#/components/Typography'
|
||||||
|
|
||||||
|
type StarterPackScreeProps = NativeStackScreenProps<
|
||||||
|
CommonNavigatorParams,
|
||||||
|
'StarterPack'
|
||||||
|
>
|
||||||
|
|
||||||
|
export function StarterPackScreen({route}: StarterPackScreeProps) {
|
||||||
|
const {_} = useLingui()
|
||||||
|
const {currentAccount} = useSession()
|
||||||
|
|
||||||
|
const {name, rkey} = route.params
|
||||||
|
const moderationOpts = useModerationOpts()
|
||||||
|
const {
|
||||||
|
data: did,
|
||||||
|
isLoading: isLoadingDid,
|
||||||
|
isError: isErrorDid,
|
||||||
|
} = useResolveDidQuery(name)
|
||||||
|
const {
|
||||||
|
data: starterPack,
|
||||||
|
isLoading: isLoadingStarterPack,
|
||||||
|
isError: isErrorStarterPack,
|
||||||
|
} = useStarterPackQuery({did, rkey})
|
||||||
|
const listMembersQuery = useListMembersQuery(starterPack?.list?.uri, 50)
|
||||||
|
|
||||||
|
const isValid =
|
||||||
|
starterPack &&
|
||||||
|
(starterPack.list || starterPack?.creator?.did === currentAccount?.did) &&
|
||||||
|
AppBskyGraphDefs.validateStarterPackView(starterPack) &&
|
||||||
|
AppBskyGraphStarterpack.validateRecord(starterPack.record)
|
||||||
|
|
||||||
|
if (!did || !starterPack || !isValid || !moderationOpts) {
|
||||||
|
return (
|
||||||
|
<ListMaybePlaceholder
|
||||||
|
isLoading={
|
||||||
|
isLoadingDid ||
|
||||||
|
isLoadingStarterPack ||
|
||||||
|
listMembersQuery.isLoading ||
|
||||||
|
!moderationOpts
|
||||||
|
}
|
||||||
|
isError={isErrorDid || isErrorStarterPack || !isValid}
|
||||||
|
errorMessage={_(msg`That starter pack could not be found.`)}
|
||||||
|
emptyMessage={_(msg`That starter pack could not be found.`)}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!starterPack.list && starterPack.creator.did === currentAccount?.did) {
|
||||||
|
return <InvalidStarterPack rkey={rkey} />
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<StarterPackScreenInner
|
||||||
|
starterPack={starterPack}
|
||||||
|
routeParams={route.params}
|
||||||
|
listMembersQuery={listMembersQuery}
|
||||||
|
moderationOpts={moderationOpts}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function StarterPackScreenInner({
|
||||||
|
starterPack,
|
||||||
|
routeParams,
|
||||||
|
listMembersQuery,
|
||||||
|
moderationOpts,
|
||||||
|
}: {
|
||||||
|
starterPack: AppBskyGraphDefs.StarterPackView
|
||||||
|
routeParams: StarterPackScreeProps['route']['params']
|
||||||
|
listMembersQuery: UseInfiniteQueryResult<
|
||||||
|
InfiniteData<AppBskyGraphGetList.OutputSchema>
|
||||||
|
>
|
||||||
|
moderationOpts: ModerationOpts
|
||||||
|
}) {
|
||||||
|
const tabs = [
|
||||||
|
...(starterPack.list ? ['People'] : []),
|
||||||
|
...(starterPack.feeds?.length ? ['Feeds'] : []),
|
||||||
|
]
|
||||||
|
|
||||||
|
const qrCodeDialogControl = useDialogControl()
|
||||||
|
const shareDialogControl = useDialogControl()
|
||||||
|
|
||||||
|
const shortenLink = useShortenLink()
|
||||||
|
const [link, setLink] = React.useState<string>()
|
||||||
|
const [imageLoaded, setImageLoaded] = React.useState(false)
|
||||||
|
|
||||||
|
const onOpenShareDialog = React.useCallback(() => {
|
||||||
|
const rkey = new AtUri(starterPack.uri).rkey
|
||||||
|
shortenLink(makeStarterPackLink(starterPack.creator.did, rkey)).then(
|
||||||
|
res => {
|
||||||
|
setLink(res.url)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
Image.prefetch(getStarterPackOgCard(starterPack))
|
||||||
|
.then(() => {
|
||||||
|
setImageLoaded(true)
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
setImageLoaded(true)
|
||||||
|
})
|
||||||
|
shareDialogControl.open()
|
||||||
|
}, [shareDialogControl, shortenLink, starterPack])
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (routeParams.new) {
|
||||||
|
onOpenShareDialog()
|
||||||
|
}
|
||||||
|
}, [onOpenShareDialog, routeParams.new, shareDialogControl])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CenteredView style={[a.h_full_vh]}>
|
||||||
|
<View style={isWeb ? {minHeight: '100%'} : {height: '100%'}}>
|
||||||
|
<PagerWithHeader
|
||||||
|
items={tabs}
|
||||||
|
isHeaderReady={true}
|
||||||
|
renderHeader={() => (
|
||||||
|
<Header
|
||||||
|
starterPack={starterPack}
|
||||||
|
routeParams={routeParams}
|
||||||
|
onOpenShareDialog={onOpenShareDialog}
|
||||||
|
/>
|
||||||
|
)}>
|
||||||
|
{starterPack.list != null
|
||||||
|
? ({headerHeight, scrollElRef}) => (
|
||||||
|
<ProfilesList
|
||||||
|
key={0}
|
||||||
|
// Validated above
|
||||||
|
listUri={starterPack!.list!.uri}
|
||||||
|
headerHeight={headerHeight}
|
||||||
|
// @ts-expect-error
|
||||||
|
scrollElRef={scrollElRef}
|
||||||
|
listMembersQuery={listMembersQuery}
|
||||||
|
moderationOpts={moderationOpts}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
: null}
|
||||||
|
{starterPack.feeds != null
|
||||||
|
? ({headerHeight, scrollElRef}) => (
|
||||||
|
<FeedsList
|
||||||
|
key={1}
|
||||||
|
// @ts-expect-error ?
|
||||||
|
feeds={starterPack?.feeds}
|
||||||
|
headerHeight={headerHeight}
|
||||||
|
// @ts-expect-error
|
||||||
|
scrollElRef={scrollElRef}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
: null}
|
||||||
|
</PagerWithHeader>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<QrCodeDialog
|
||||||
|
control={qrCodeDialogControl}
|
||||||
|
starterPack={starterPack}
|
||||||
|
link={link}
|
||||||
|
/>
|
||||||
|
<ShareDialog
|
||||||
|
control={shareDialogControl}
|
||||||
|
qrDialogControl={qrCodeDialogControl}
|
||||||
|
starterPack={starterPack}
|
||||||
|
link={link}
|
||||||
|
imageLoaded={imageLoaded}
|
||||||
|
/>
|
||||||
|
</CenteredView>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Header({
|
||||||
|
starterPack,
|
||||||
|
routeParams,
|
||||||
|
onOpenShareDialog,
|
||||||
|
}: {
|
||||||
|
starterPack: AppBskyGraphDefs.StarterPackView
|
||||||
|
routeParams: StarterPackScreeProps['route']['params']
|
||||||
|
onOpenShareDialog: () => void
|
||||||
|
}) {
|
||||||
|
const {_} = useLingui()
|
||||||
|
const t = useTheme()
|
||||||
|
const {currentAccount} = useSession()
|
||||||
|
const agent = useAgent()
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
|
||||||
|
const [isProcessing, setIsProcessing] = React.useState(false)
|
||||||
|
|
||||||
|
const {record, creator} = starterPack
|
||||||
|
const isOwn = creator?.did === currentAccount?.did
|
||||||
|
const joinedAllTimeCount = starterPack.joinedAllTimeCount ?? 0
|
||||||
|
|
||||||
|
const onFollowAll = async () => {
|
||||||
|
if (!starterPack.list) return
|
||||||
|
|
||||||
|
setIsProcessing(true)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const list = await agent.app.bsky.graph.getList({
|
||||||
|
list: starterPack.list.uri,
|
||||||
|
})
|
||||||
|
const dids = list.data.items
|
||||||
|
.filter(li => !li.subject.viewer?.following)
|
||||||
|
.map(li => li.subject.did)
|
||||||
|
|
||||||
|
await bulkWriteFollows(agent, dids)
|
||||||
|
|
||||||
|
await queryClient.refetchQueries({
|
||||||
|
queryKey: RQKEY(starterPack.list.uri),
|
||||||
|
})
|
||||||
|
|
||||||
|
logEvent('starterPack:followAll', {
|
||||||
|
logContext: 'StarterPackProfilesList',
|
||||||
|
starterPack: starterPack.uri,
|
||||||
|
count: dids.length,
|
||||||
|
})
|
||||||
|
Toast.show(_(msg`All accounts have been followed!`))
|
||||||
|
} catch (e) {
|
||||||
|
Toast.show(_(msg`An error occurred while trying to follow all`))
|
||||||
|
} finally {
|
||||||
|
setIsProcessing(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!AppBskyGraphStarterpack.isRecord(record)) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<ProfileSubpageHeader
|
||||||
|
isLoading={false}
|
||||||
|
href={makeProfileLink(creator)}
|
||||||
|
title={record.name}
|
||||||
|
isOwner={isOwn}
|
||||||
|
avatar={undefined}
|
||||||
|
creator={creator}
|
||||||
|
avatarType="starter-pack">
|
||||||
|
<View style={[a.flex_row, a.gap_sm, a.align_center]}>
|
||||||
|
{isOwn ? (
|
||||||
|
<Button
|
||||||
|
label={_(msg`Share this starter pack`)}
|
||||||
|
hitSlop={HITSLOP_20}
|
||||||
|
variant="solid"
|
||||||
|
color="primary"
|
||||||
|
size="small"
|
||||||
|
onPress={onOpenShareDialog}>
|
||||||
|
<ButtonText>
|
||||||
|
<Trans>Share</Trans>
|
||||||
|
</ButtonText>
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
label={_(msg`Follow all`)}
|
||||||
|
variant="solid"
|
||||||
|
color="primary"
|
||||||
|
size="small"
|
||||||
|
disabled={isProcessing}
|
||||||
|
onPress={onFollowAll}>
|
||||||
|
<ButtonText>
|
||||||
|
<Trans>Follow all</Trans>
|
||||||
|
{isProcessing && <Loader size="xs" />}
|
||||||
|
</ButtonText>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<OverflowMenu
|
||||||
|
routeParams={routeParams}
|
||||||
|
starterPack={starterPack}
|
||||||
|
onOpenShareDialog={onOpenShareDialog}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</ProfileSubpageHeader>
|
||||||
|
{record.description || joinedAllTimeCount >= 25 ? (
|
||||||
|
<View style={[a.px_lg, a.pt_md, a.pb_sm, a.gap_md]}>
|
||||||
|
{record.description ? (
|
||||||
|
<Text style={[a.text_md, a.leading_snug]}>
|
||||||
|
{record.description}
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
{joinedAllTimeCount >= 25 ? (
|
||||||
|
<View style={[a.flex_row, a.align_center, a.gap_sm]}>
|
||||||
|
<FontAwesomeIcon
|
||||||
|
icon="arrow-trend-up"
|
||||||
|
size={12}
|
||||||
|
color={t.atoms.text_contrast_medium.color}
|
||||||
|
/>
|
||||||
|
<Text
|
||||||
|
style={[a.font_bold, a.text_sm, t.atoms.text_contrast_medium]}>
|
||||||
|
<Trans>
|
||||||
|
{starterPack.joinedAllTimeCount || 0} people have used this
|
||||||
|
starter pack!
|
||||||
|
</Trans>
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function OverflowMenu({
|
||||||
|
starterPack,
|
||||||
|
routeParams,
|
||||||
|
onOpenShareDialog,
|
||||||
|
}: {
|
||||||
|
starterPack: AppBskyGraphDefs.StarterPackView
|
||||||
|
routeParams: StarterPackScreeProps['route']['params']
|
||||||
|
onOpenShareDialog: () => void
|
||||||
|
}) {
|
||||||
|
const t = useTheme()
|
||||||
|
const {_} = useLingui()
|
||||||
|
const {gtMobile} = useBreakpoints()
|
||||||
|
const {currentAccount} = useSession()
|
||||||
|
const reportDialogControl = useReportDialogControl()
|
||||||
|
const deleteDialogControl = useDialogControl()
|
||||||
|
const navigation = useNavigation<NavigationProp>()
|
||||||
|
|
||||||
|
const {
|
||||||
|
mutate: deleteStarterPack,
|
||||||
|
isPending: isDeletePending,
|
||||||
|
error: deleteError,
|
||||||
|
} = useDeleteStarterPackMutation({
|
||||||
|
onSuccess: () => {
|
||||||
|
logEvent('starterPack:delete', {})
|
||||||
|
deleteDialogControl.close(() => {
|
||||||
|
if (navigation.canGoBack()) {
|
||||||
|
navigation.popToTop()
|
||||||
|
} else {
|
||||||
|
navigation.navigate('Home')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
},
|
||||||
|
onError: e => {
|
||||||
|
logger.error('Failed to delete starter pack', {safeMessage: e})
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const isOwn = starterPack.creator.did === currentAccount?.did
|
||||||
|
|
||||||
|
const onDeleteStarterPack = async () => {
|
||||||
|
if (!starterPack.list) {
|
||||||
|
logger.error(`Unable to delete starterpack because list is missing`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteStarterPack({
|
||||||
|
rkey: routeParams.rkey,
|
||||||
|
listUri: starterPack.list.uri,
|
||||||
|
})
|
||||||
|
logEvent('starterPack:delete', {})
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Menu.Root>
|
||||||
|
<Menu.Trigger label={_(msg`Repost or quote post`)}>
|
||||||
|
{({props}) => (
|
||||||
|
<Button
|
||||||
|
{...props}
|
||||||
|
testID="headerDropdownBtn"
|
||||||
|
label={_(msg`Open starter pack menu`)}
|
||||||
|
hitSlop={HITSLOP_20}
|
||||||
|
variant="solid"
|
||||||
|
color="secondary"
|
||||||
|
size="small"
|
||||||
|
shape="round">
|
||||||
|
<ButtonIcon icon={Ellipsis} />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</Menu.Trigger>
|
||||||
|
<Menu.Outer style={{minWidth: 170}}>
|
||||||
|
{isOwn ? (
|
||||||
|
<>
|
||||||
|
<Menu.Item
|
||||||
|
label={_(msg`Edit starter pack`)}
|
||||||
|
testID="editStarterPackLinkBtn"
|
||||||
|
onPress={() => {
|
||||||
|
navigation.navigate('StarterPackEdit', {
|
||||||
|
rkey: routeParams.rkey,
|
||||||
|
})
|
||||||
|
}}>
|
||||||
|
<Menu.ItemText>
|
||||||
|
<Trans>Edit</Trans>
|
||||||
|
</Menu.ItemText>
|
||||||
|
<Menu.ItemIcon icon={Pencil} position="right" />
|
||||||
|
</Menu.Item>
|
||||||
|
<Menu.Item
|
||||||
|
label={_(msg`Delete starter pack`)}
|
||||||
|
testID="deleteStarterPackBtn"
|
||||||
|
onPress={() => {
|
||||||
|
deleteDialogControl.open()
|
||||||
|
}}>
|
||||||
|
<Menu.ItemText>
|
||||||
|
<Trans>Delete</Trans>
|
||||||
|
</Menu.ItemText>
|
||||||
|
<Menu.ItemIcon icon={Trash} position="right" />
|
||||||
|
</Menu.Item>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Menu.Group>
|
||||||
|
<Menu.Item
|
||||||
|
label={_(msg`Share`)}
|
||||||
|
testID="shareStarterPackLinkBtn"
|
||||||
|
onPress={onOpenShareDialog}>
|
||||||
|
<Menu.ItemText>
|
||||||
|
<Trans>Share link</Trans>
|
||||||
|
</Menu.ItemText>
|
||||||
|
<Menu.ItemIcon icon={ArrowOutOfBox} position="right" />
|
||||||
|
</Menu.Item>
|
||||||
|
</Menu.Group>
|
||||||
|
|
||||||
|
<Menu.Item
|
||||||
|
label={_(msg`Report starter pack`)}
|
||||||
|
onPress={reportDialogControl.open}>
|
||||||
|
<Menu.ItemText>
|
||||||
|
<Trans>Report starter pack</Trans>
|
||||||
|
</Menu.ItemText>
|
||||||
|
<Menu.ItemIcon icon={CircleInfo} position="right" />
|
||||||
|
</Menu.Item>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Menu.Outer>
|
||||||
|
</Menu.Root>
|
||||||
|
|
||||||
|
{starterPack.list && (
|
||||||
|
<ReportDialog
|
||||||
|
control={reportDialogControl}
|
||||||
|
params={{
|
||||||
|
type: 'starterpack',
|
||||||
|
uri: starterPack.uri,
|
||||||
|
cid: starterPack.cid,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Prompt.Outer control={deleteDialogControl}>
|
||||||
|
<Prompt.TitleText>
|
||||||
|
<Trans>Delete starter pack?</Trans>
|
||||||
|
</Prompt.TitleText>
|
||||||
|
<Prompt.DescriptionText>
|
||||||
|
<Trans>Are you sure you want delete this starter pack?</Trans>
|
||||||
|
</Prompt.DescriptionText>
|
||||||
|
{deleteError && (
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
a.flex_row,
|
||||||
|
a.gap_sm,
|
||||||
|
a.rounded_sm,
|
||||||
|
a.p_md,
|
||||||
|
a.mb_lg,
|
||||||
|
a.border,
|
||||||
|
t.atoms.border_contrast_medium,
|
||||||
|
t.atoms.bg_contrast_25,
|
||||||
|
]}>
|
||||||
|
<View style={[a.flex_1, a.gap_2xs]}>
|
||||||
|
<Text style={[a.font_bold]}>
|
||||||
|
<Trans>Unable to delete</Trans>
|
||||||
|
</Text>
|
||||||
|
<Text style={[a.leading_snug]}>{cleanError(deleteError)}</Text>
|
||||||
|
</View>
|
||||||
|
<CircleInfo size="sm" fill={t.palette.negative_400} />
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
<Prompt.Actions>
|
||||||
|
<Button
|
||||||
|
variant="solid"
|
||||||
|
color="negative"
|
||||||
|
size={gtMobile ? 'small' : 'medium'}
|
||||||
|
label={_(msg`Yes, delete this starter pack`)}
|
||||||
|
onPress={onDeleteStarterPack}>
|
||||||
|
<ButtonText>
|
||||||
|
<Trans>Delete</Trans>
|
||||||
|
</ButtonText>
|
||||||
|
{isDeletePending && <ButtonIcon icon={Loader} />}
|
||||||
|
</Button>
|
||||||
|
<Prompt.Cancel />
|
||||||
|
</Prompt.Actions>
|
||||||
|
</Prompt.Outer>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function InvalidStarterPack({rkey}: {rkey: string}) {
|
||||||
|
const {_} = useLingui()
|
||||||
|
const t = useTheme()
|
||||||
|
const navigation = useNavigation<NavigationProp>()
|
||||||
|
const {gtMobile} = useBreakpoints()
|
||||||
|
const [isProcessing, setIsProcessing] = React.useState(false)
|
||||||
|
|
||||||
|
const goBack = () => {
|
||||||
|
if (navigation.canGoBack()) {
|
||||||
|
navigation.goBack()
|
||||||
|
} else {
|
||||||
|
navigation.replace('Home')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const {mutate: deleteStarterPack} = useDeleteStarterPackMutation({
|
||||||
|
onSuccess: () => {
|
||||||
|
setIsProcessing(false)
|
||||||
|
goBack()
|
||||||
|
},
|
||||||
|
onError: e => {
|
||||||
|
setIsProcessing(false)
|
||||||
|
logger.error('Failed to delete invalid starter pack', {safeMessage: e})
|
||||||
|
Toast.show(_(msg`Failed to delete starter pack`))
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CenteredView
|
||||||
|
style={[
|
||||||
|
a.flex_1,
|
||||||
|
a.align_center,
|
||||||
|
a.gap_5xl,
|
||||||
|
!gtMobile && a.justify_between,
|
||||||
|
t.atoms.border_contrast_low,
|
||||||
|
{paddingTop: 175, paddingBottom: 110},
|
||||||
|
]}
|
||||||
|
sideBorders={true}>
|
||||||
|
<View style={[a.w_full, a.align_center, a.gap_lg]}>
|
||||||
|
<Text style={[a.font_bold, a.text_3xl]}>
|
||||||
|
<Trans>Starter pack is invalid</Trans>
|
||||||
|
</Text>
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
a.text_md,
|
||||||
|
a.text_center,
|
||||||
|
t.atoms.text_contrast_high,
|
||||||
|
{lineHeight: 1.4},
|
||||||
|
gtMobile ? {width: 450} : [a.w_full, a.px_lg],
|
||||||
|
]}>
|
||||||
|
<Trans>
|
||||||
|
The starter pack that you are trying to view is invalid. You may
|
||||||
|
delete this starter pack instead.
|
||||||
|
</Trans>
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<View style={[a.gap_md, gtMobile ? {width: 350} : [a.w_full, a.px_lg]]}>
|
||||||
|
<Button
|
||||||
|
variant="solid"
|
||||||
|
color="primary"
|
||||||
|
label={_(msg`Delete starter pack`)}
|
||||||
|
size="large"
|
||||||
|
style={[a.rounded_sm, a.overflow_hidden, {paddingVertical: 10}]}
|
||||||
|
disabled={isProcessing}
|
||||||
|
onPress={() => {
|
||||||
|
setIsProcessing(true)
|
||||||
|
deleteStarterPack({rkey})
|
||||||
|
}}>
|
||||||
|
<ButtonText>
|
||||||
|
<Trans>Delete</Trans>
|
||||||
|
</ButtonText>
|
||||||
|
{isProcessing && <Loader size="xs" color="white" />}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="solid"
|
||||||
|
color="secondary"
|
||||||
|
label={_(msg`Return to previous page`)}
|
||||||
|
size="large"
|
||||||
|
style={[a.rounded_sm, a.overflow_hidden, {paddingVertical: 10}]}
|
||||||
|
disabled={isProcessing}
|
||||||
|
onPress={goBack}>
|
||||||
|
<ButtonText>
|
||||||
|
<Trans>Go Back</Trans>
|
||||||
|
</ButtonText>
|
||||||
|
</Button>
|
||||||
|
</View>
|
||||||
|
</CenteredView>
|
||||||
|
)
|
||||||
|
}
|
|
@ -0,0 +1,163 @@
|
||||||
|
import React from 'react'
|
||||||
|
import {
|
||||||
|
AppBskyActorDefs,
|
||||||
|
AppBskyGraphDefs,
|
||||||
|
AppBskyGraphStarterpack,
|
||||||
|
} from '@atproto/api'
|
||||||
|
import {GeneratorView} from '@atproto/api/dist/client/types/app/bsky/feed/defs'
|
||||||
|
import {msg} from '@lingui/macro'
|
||||||
|
|
||||||
|
import {useSession} from 'state/session'
|
||||||
|
import * as Toast from '#/view/com/util/Toast'
|
||||||
|
|
||||||
|
const steps = ['Details', 'Profiles', 'Feeds'] as const
|
||||||
|
type Step = (typeof steps)[number]
|
||||||
|
|
||||||
|
type Action =
|
||||||
|
| {type: 'Next'}
|
||||||
|
| {type: 'Back'}
|
||||||
|
| {type: 'SetCanNext'; canNext: boolean}
|
||||||
|
| {type: 'SetName'; name: string}
|
||||||
|
| {type: 'SetDescription'; description: string}
|
||||||
|
| {type: 'AddProfile'; profile: AppBskyActorDefs.ProfileViewBasic}
|
||||||
|
| {type: 'RemoveProfile'; profileDid: string}
|
||||||
|
| {type: 'AddFeed'; feed: GeneratorView}
|
||||||
|
| {type: 'RemoveFeed'; feedUri: string}
|
||||||
|
| {type: 'SetProcessing'; processing: boolean}
|
||||||
|
| {type: 'SetError'; error: string}
|
||||||
|
|
||||||
|
interface State {
|
||||||
|
canNext: boolean
|
||||||
|
currentStep: Step
|
||||||
|
name?: string
|
||||||
|
description?: string
|
||||||
|
profiles: AppBskyActorDefs.ProfileViewBasic[]
|
||||||
|
feeds: GeneratorView[]
|
||||||
|
processing: boolean
|
||||||
|
error?: string
|
||||||
|
transitionDirection: 'Backward' | 'Forward'
|
||||||
|
}
|
||||||
|
|
||||||
|
type TStateContext = [State, (action: Action) => void]
|
||||||
|
|
||||||
|
const StateContext = React.createContext<TStateContext>([
|
||||||
|
{} as State,
|
||||||
|
(_: Action) => {},
|
||||||
|
])
|
||||||
|
export const useWizardState = () => React.useContext(StateContext)
|
||||||
|
|
||||||
|
function reducer(state: State, action: Action): State {
|
||||||
|
let updatedState = state
|
||||||
|
|
||||||
|
// -- Navigation
|
||||||
|
const currentIndex = steps.indexOf(state.currentStep)
|
||||||
|
if (action.type === 'Next' && state.currentStep !== 'Feeds') {
|
||||||
|
updatedState = {
|
||||||
|
...state,
|
||||||
|
currentStep: steps[currentIndex + 1],
|
||||||
|
transitionDirection: 'Forward',
|
||||||
|
}
|
||||||
|
} else if (action.type === 'Back' && state.currentStep !== 'Details') {
|
||||||
|
updatedState = {
|
||||||
|
...state,
|
||||||
|
currentStep: steps[currentIndex - 1],
|
||||||
|
transitionDirection: 'Backward',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (action.type) {
|
||||||
|
case 'SetName':
|
||||||
|
updatedState = {...state, name: action.name.slice(0, 50)}
|
||||||
|
break
|
||||||
|
case 'SetDescription':
|
||||||
|
updatedState = {...state, description: action.description}
|
||||||
|
break
|
||||||
|
case 'AddProfile':
|
||||||
|
if (state.profiles.length >= 51) {
|
||||||
|
Toast.show(msg`You may only add up to 50 profiles`.message ?? '')
|
||||||
|
} else {
|
||||||
|
updatedState = {...state, profiles: [...state.profiles, action.profile]}
|
||||||
|
}
|
||||||
|
break
|
||||||
|
case 'RemoveProfile':
|
||||||
|
updatedState = {
|
||||||
|
...state,
|
||||||
|
profiles: state.profiles.filter(
|
||||||
|
profile => profile.did !== action.profileDid,
|
||||||
|
),
|
||||||
|
}
|
||||||
|
break
|
||||||
|
case 'AddFeed':
|
||||||
|
if (state.feeds.length >= 50) {
|
||||||
|
Toast.show(msg`You may only add up to 50 feeds`.message ?? '')
|
||||||
|
} else {
|
||||||
|
updatedState = {...state, feeds: [...state.feeds, action.feed]}
|
||||||
|
}
|
||||||
|
break
|
||||||
|
case 'RemoveFeed':
|
||||||
|
updatedState = {
|
||||||
|
...state,
|
||||||
|
feeds: state.feeds.filter(f => f.uri !== action.feedUri),
|
||||||
|
}
|
||||||
|
break
|
||||||
|
case 'SetProcessing':
|
||||||
|
updatedState = {...state, processing: action.processing}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
return updatedState
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO supply the initial state to this component
|
||||||
|
export function Provider({
|
||||||
|
starterPack,
|
||||||
|
listItems,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
starterPack?: AppBskyGraphDefs.StarterPackView
|
||||||
|
listItems?: AppBskyGraphDefs.ListItemView[]
|
||||||
|
children: React.ReactNode
|
||||||
|
}) {
|
||||||
|
const {currentAccount} = useSession()
|
||||||
|
|
||||||
|
const createInitialState = (): State => {
|
||||||
|
if (starterPack && AppBskyGraphStarterpack.isRecord(starterPack.record)) {
|
||||||
|
return {
|
||||||
|
canNext: true,
|
||||||
|
currentStep: 'Details',
|
||||||
|
name: starterPack.record.name,
|
||||||
|
description: starterPack.record.description,
|
||||||
|
profiles:
|
||||||
|
listItems
|
||||||
|
?.map(i => i.subject)
|
||||||
|
.filter(p => p.did !== currentAccount?.did) ?? [],
|
||||||
|
feeds: starterPack.feeds ?? [],
|
||||||
|
processing: false,
|
||||||
|
transitionDirection: 'Forward',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
canNext: true,
|
||||||
|
currentStep: 'Details',
|
||||||
|
profiles: [],
|
||||||
|
feeds: [],
|
||||||
|
processing: false,
|
||||||
|
transitionDirection: 'Forward',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const [state, dispatch] = React.useReducer(reducer, null, createInitialState)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<StateContext.Provider value={[state, dispatch]}>
|
||||||
|
{children}
|
||||||
|
</StateContext.Provider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
type Action as WizardAction,
|
||||||
|
type State as WizardState,
|
||||||
|
type Step as WizardStep,
|
||||||
|
}
|
|
@ -0,0 +1,84 @@
|
||||||
|
import React from 'react'
|
||||||
|
import {View} from 'react-native'
|
||||||
|
import {msg, Trans} from '@lingui/macro'
|
||||||
|
import {useLingui} from '@lingui/react'
|
||||||
|
|
||||||
|
import {useProfileQuery} from 'state/queries/profile'
|
||||||
|
import {useSession} from 'state/session'
|
||||||
|
import {useWizardState} from '#/screens/StarterPack/Wizard/State'
|
||||||
|
import {atoms as a, useTheme} from '#/alf'
|
||||||
|
import * as TextField from '#/components/forms/TextField'
|
||||||
|
import {StarterPack} from '#/components/icons/StarterPack'
|
||||||
|
import {ScreenTransition} from '#/components/StarterPack/Wizard/ScreenTransition'
|
||||||
|
import {Text} from '#/components/Typography'
|
||||||
|
|
||||||
|
export function StepDetails() {
|
||||||
|
const {_} = useLingui()
|
||||||
|
const t = useTheme()
|
||||||
|
const [state, dispatch] = useWizardState()
|
||||||
|
|
||||||
|
const {currentAccount} = useSession()
|
||||||
|
const {data: currentProfile} = useProfileQuery({
|
||||||
|
did: currentAccount?.did,
|
||||||
|
staleTime: 300,
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ScreenTransition direction={state.transitionDirection}>
|
||||||
|
<View style={[a.px_xl, a.gap_xl, a.mt_4xl]}>
|
||||||
|
<View style={[a.gap_md, a.align_center, a.px_md, a.mb_md]}>
|
||||||
|
<StarterPack width={90} gradient="sky" />
|
||||||
|
<Text style={[a.font_bold, a.text_3xl]}>
|
||||||
|
<Trans>Invites, but personal</Trans>
|
||||||
|
</Text>
|
||||||
|
<Text style={[a.text_center, a.text_md, a.px_md]}>
|
||||||
|
<Trans>
|
||||||
|
Invite your friends to follow your favorite feeds and people
|
||||||
|
</Trans>
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<View>
|
||||||
|
<TextField.LabelText>
|
||||||
|
<Trans>What do you want to call your starter pack?</Trans>
|
||||||
|
</TextField.LabelText>
|
||||||
|
<TextField.Root>
|
||||||
|
<TextField.Input
|
||||||
|
label={_(
|
||||||
|
msg`${
|
||||||
|
currentProfile?.displayName || currentProfile?.handle
|
||||||
|
}'s starter pack`,
|
||||||
|
)}
|
||||||
|
value={state.name}
|
||||||
|
onChangeText={text => dispatch({type: 'SetName', name: text})}
|
||||||
|
/>
|
||||||
|
<TextField.SuffixText label={_(`${state.name?.length} out of 50`)}>
|
||||||
|
<Text style={[t.atoms.text_contrast_medium]}>
|
||||||
|
{state.name?.length ?? 0}/50
|
||||||
|
</Text>
|
||||||
|
</TextField.SuffixText>
|
||||||
|
</TextField.Root>
|
||||||
|
</View>
|
||||||
|
<View>
|
||||||
|
<TextField.LabelText>
|
||||||
|
<Trans>Tell us a little more</Trans>
|
||||||
|
</TextField.LabelText>
|
||||||
|
<TextField.Root>
|
||||||
|
<TextField.Input
|
||||||
|
label={_(
|
||||||
|
msg`${
|
||||||
|
currentProfile?.displayName || currentProfile?.handle
|
||||||
|
}'s favorite feeds and people - join me!`,
|
||||||
|
)}
|
||||||
|
value={state.description}
|
||||||
|
onChangeText={text =>
|
||||||
|
dispatch({type: 'SetDescription', description: text})
|
||||||
|
}
|
||||||
|
multiline
|
||||||
|
style={{minHeight: 150}}
|
||||||
|
/>
|
||||||
|
</TextField.Root>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</ScreenTransition>
|
||||||
|
)
|
||||||
|
}
|
|
@ -0,0 +1,113 @@
|
||||||
|
import React, {useState} from 'react'
|
||||||
|
import {ListRenderItemInfo, View} from 'react-native'
|
||||||
|
import {KeyboardAwareScrollView} from 'react-native-keyboard-controller'
|
||||||
|
import {AppBskyFeedDefs, ModerationOpts} from '@atproto/api'
|
||||||
|
import {Trans} from '@lingui/macro'
|
||||||
|
|
||||||
|
import {useA11y} from '#/state/a11y'
|
||||||
|
import {DISCOVER_FEED_URI} from 'lib/constants'
|
||||||
|
import {
|
||||||
|
useGetPopularFeedsQuery,
|
||||||
|
useSavedFeeds,
|
||||||
|
useSearchPopularFeedsQuery,
|
||||||
|
} from 'state/queries/feed'
|
||||||
|
import {SearchInput} from 'view/com/util/forms/SearchInput'
|
||||||
|
import {List} from 'view/com/util/List'
|
||||||
|
import {useWizardState} from '#/screens/StarterPack/Wizard/State'
|
||||||
|
import {atoms as a, useTheme} from '#/alf'
|
||||||
|
import {useThrottledValue} from '#/components/hooks/useThrottledValue'
|
||||||
|
import {Loader} from '#/components/Loader'
|
||||||
|
import {ScreenTransition} from '#/components/StarterPack/Wizard/ScreenTransition'
|
||||||
|
import {WizardFeedCard} from '#/components/StarterPack/Wizard/WizardListCard'
|
||||||
|
import {Text} from '#/components/Typography'
|
||||||
|
|
||||||
|
function keyExtractor(item: AppBskyFeedDefs.GeneratorView) {
|
||||||
|
return item.uri
|
||||||
|
}
|
||||||
|
|
||||||
|
export function StepFeeds({moderationOpts}: {moderationOpts: ModerationOpts}) {
|
||||||
|
const t = useTheme()
|
||||||
|
const [state, dispatch] = useWizardState()
|
||||||
|
const [query, setQuery] = useState('')
|
||||||
|
const throttledQuery = useThrottledValue(query, 500)
|
||||||
|
const {screenReaderEnabled} = useA11y()
|
||||||
|
|
||||||
|
const {data: savedFeedsAndLists} = useSavedFeeds()
|
||||||
|
const savedFeeds = savedFeedsAndLists?.feeds
|
||||||
|
.filter(f => f.type === 'feed' && f.view.uri !== DISCOVER_FEED_URI)
|
||||||
|
.map(f => f.view) as AppBskyFeedDefs.GeneratorView[]
|
||||||
|
|
||||||
|
const {data: popularFeedsPages, fetchNextPage} = useGetPopularFeedsQuery({
|
||||||
|
limit: 30,
|
||||||
|
})
|
||||||
|
const popularFeeds =
|
||||||
|
popularFeedsPages?.pages
|
||||||
|
.flatMap(page => page.feeds)
|
||||||
|
.filter(f => !savedFeeds?.some(sf => sf?.uri === f.uri)) ?? []
|
||||||
|
|
||||||
|
const suggestedFeeds = savedFeeds?.concat(popularFeeds)
|
||||||
|
|
||||||
|
const {data: searchedFeeds, isLoading: isLoadingSearch} =
|
||||||
|
useSearchPopularFeedsQuery({q: throttledQuery})
|
||||||
|
|
||||||
|
const renderItem = ({
|
||||||
|
item,
|
||||||
|
}: ListRenderItemInfo<AppBskyFeedDefs.GeneratorView>) => {
|
||||||
|
return (
|
||||||
|
<WizardFeedCard
|
||||||
|
generator={item}
|
||||||
|
state={state}
|
||||||
|
dispatch={dispatch}
|
||||||
|
moderationOpts={moderationOpts}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ScreenTransition style={[a.flex_1]} direction={state.transitionDirection}>
|
||||||
|
<View style={[a.border_b, t.atoms.border_contrast_medium]}>
|
||||||
|
<View style={[a.my_sm, a.px_md, {height: 40}]}>
|
||||||
|
<SearchInput
|
||||||
|
query={query}
|
||||||
|
onChangeQuery={t => setQuery(t)}
|
||||||
|
onPressCancelSearch={() => setQuery('')}
|
||||||
|
onSubmitQuery={() => {}}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
<List
|
||||||
|
data={query ? searchedFeeds : suggestedFeeds}
|
||||||
|
renderItem={renderItem}
|
||||||
|
keyExtractor={keyExtractor}
|
||||||
|
contentContainerStyle={{paddingTop: 6}}
|
||||||
|
onEndReached={
|
||||||
|
!query && !screenReaderEnabled ? () => fetchNextPage() : undefined
|
||||||
|
}
|
||||||
|
onEndReachedThreshold={2}
|
||||||
|
renderScrollComponent={props => <KeyboardAwareScrollView {...props} />}
|
||||||
|
keyboardShouldPersistTaps="handled"
|
||||||
|
containWeb={true}
|
||||||
|
sideBorders={false}
|
||||||
|
style={{flex: 1}}
|
||||||
|
ListEmptyComponent={
|
||||||
|
<View style={[a.flex_1, a.align_center, a.mt_lg, a.px_lg]}>
|
||||||
|
{isLoadingSearch ? (
|
||||||
|
<Loader size="lg" />
|
||||||
|
) : (
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
a.font_bold,
|
||||||
|
a.text_lg,
|
||||||
|
a.text_center,
|
||||||
|
a.mt_lg,
|
||||||
|
a.leading_snug,
|
||||||
|
]}>
|
||||||
|
<Trans>No feeds found. Try searching for something else.</Trans>
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</ScreenTransition>
|
||||||
|
)
|
||||||
|
}
|
|
@ -0,0 +1,101 @@
|
||||||
|
import React, {useState} from 'react'
|
||||||
|
import {ListRenderItemInfo, View} from 'react-native'
|
||||||
|
import {KeyboardAwareScrollView} from 'react-native-keyboard-controller'
|
||||||
|
import {AppBskyActorDefs, ModerationOpts} from '@atproto/api'
|
||||||
|
import {Trans} from '@lingui/macro'
|
||||||
|
|
||||||
|
import {useA11y} from '#/state/a11y'
|
||||||
|
import {isNative} from 'platform/detection'
|
||||||
|
import {useActorAutocompleteQuery} from 'state/queries/actor-autocomplete'
|
||||||
|
import {useActorSearchPaginated} from 'state/queries/actor-search'
|
||||||
|
import {SearchInput} from 'view/com/util/forms/SearchInput'
|
||||||
|
import {List} from 'view/com/util/List'
|
||||||
|
import {useWizardState} from '#/screens/StarterPack/Wizard/State'
|
||||||
|
import {atoms as a, useTheme} from '#/alf'
|
||||||
|
import {Loader} from '#/components/Loader'
|
||||||
|
import {ScreenTransition} from '#/components/StarterPack/Wizard/ScreenTransition'
|
||||||
|
import {WizardProfileCard} from '#/components/StarterPack/Wizard/WizardListCard'
|
||||||
|
import {Text} from '#/components/Typography'
|
||||||
|
|
||||||
|
function keyExtractor(item: AppBskyActorDefs.ProfileViewBasic) {
|
||||||
|
return item?.did ?? ''
|
||||||
|
}
|
||||||
|
|
||||||
|
export function StepProfiles({
|
||||||
|
moderationOpts,
|
||||||
|
}: {
|
||||||
|
moderationOpts: ModerationOpts
|
||||||
|
}) {
|
||||||
|
const t = useTheme()
|
||||||
|
const [state, dispatch] = useWizardState()
|
||||||
|
const [query, setQuery] = useState('')
|
||||||
|
const {screenReaderEnabled} = useA11y()
|
||||||
|
|
||||||
|
const {data: topPages, fetchNextPage} = useActorSearchPaginated({
|
||||||
|
query: encodeURIComponent('*'),
|
||||||
|
})
|
||||||
|
const topFollowers = topPages?.pages.flatMap(p => p.actors)
|
||||||
|
|
||||||
|
const {data: results, isLoading: isLoadingResults} =
|
||||||
|
useActorAutocompleteQuery(query, true, 12)
|
||||||
|
|
||||||
|
const renderItem = ({
|
||||||
|
item,
|
||||||
|
}: ListRenderItemInfo<AppBskyActorDefs.ProfileViewBasic>) => {
|
||||||
|
return (
|
||||||
|
<WizardProfileCard
|
||||||
|
profile={item}
|
||||||
|
state={state}
|
||||||
|
dispatch={dispatch}
|
||||||
|
moderationOpts={moderationOpts}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ScreenTransition style={[a.flex_1]} direction={state.transitionDirection}>
|
||||||
|
<View style={[a.border_b, t.atoms.border_contrast_medium]}>
|
||||||
|
<View style={[a.my_sm, a.px_md, {height: 40}]}>
|
||||||
|
<SearchInput
|
||||||
|
query={query}
|
||||||
|
onChangeQuery={setQuery}
|
||||||
|
onPressCancelSearch={() => setQuery('')}
|
||||||
|
onSubmitQuery={() => {}}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
<List
|
||||||
|
data={query ? results : topFollowers}
|
||||||
|
renderItem={renderItem}
|
||||||
|
keyExtractor={keyExtractor}
|
||||||
|
renderScrollComponent={props => <KeyboardAwareScrollView {...props} />}
|
||||||
|
keyboardShouldPersistTaps="handled"
|
||||||
|
containWeb={true}
|
||||||
|
sideBorders={false}
|
||||||
|
style={[a.flex_1]}
|
||||||
|
onEndReached={
|
||||||
|
!query && !screenReaderEnabled ? () => fetchNextPage() : undefined
|
||||||
|
}
|
||||||
|
onEndReachedThreshold={isNative ? 2 : 0.25}
|
||||||
|
ListEmptyComponent={
|
||||||
|
<View style={[a.flex_1, a.align_center, a.mt_lg, a.px_lg]}>
|
||||||
|
{isLoadingResults ? (
|
||||||
|
<Loader size="lg" />
|
||||||
|
) : (
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
a.font_bold,
|
||||||
|
a.text_lg,
|
||||||
|
a.text_center,
|
||||||
|
a.mt_lg,
|
||||||
|
a.leading_snug,
|
||||||
|
]}>
|
||||||
|
<Trans>Nobody was found. Try searching for someone else.</Trans>
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</ScreenTransition>
|
||||||
|
)
|
||||||
|
}
|
|
@ -0,0 +1,575 @@
|
||||||
|
import React from 'react'
|
||||||
|
import {Keyboard, TouchableOpacity, View} from 'react-native'
|
||||||
|
import {
|
||||||
|
KeyboardAwareScrollView,
|
||||||
|
useKeyboardController,
|
||||||
|
} from 'react-native-keyboard-controller'
|
||||||
|
import {useSafeAreaInsets} from 'react-native-safe-area-context'
|
||||||
|
import {Image} from 'expo-image'
|
||||||
|
import {
|
||||||
|
AppBskyActorDefs,
|
||||||
|
AppBskyGraphDefs,
|
||||||
|
AtUri,
|
||||||
|
ModerationOpts,
|
||||||
|
} from '@atproto/api'
|
||||||
|
import {GeneratorView} from '@atproto/api/dist/client/types/app/bsky/feed/defs'
|
||||||
|
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
|
||||||
|
import {msg, Plural, Trans} from '@lingui/macro'
|
||||||
|
import {useLingui} from '@lingui/react'
|
||||||
|
import {useFocusEffect, useNavigation} from '@react-navigation/native'
|
||||||
|
import {NativeStackScreenProps} from '@react-navigation/native-stack'
|
||||||
|
|
||||||
|
import {logger} from '#/logger'
|
||||||
|
import {HITSLOP_10} from 'lib/constants'
|
||||||
|
import {CommonNavigatorParams, NavigationProp} from 'lib/routes/types'
|
||||||
|
import {logEvent} from 'lib/statsig/statsig'
|
||||||
|
import {sanitizeDisplayName} from 'lib/strings/display-names'
|
||||||
|
import {sanitizeHandle} from 'lib/strings/handles'
|
||||||
|
import {enforceLen} from 'lib/strings/helpers'
|
||||||
|
import {
|
||||||
|
getStarterPackOgCard,
|
||||||
|
parseStarterPackUri,
|
||||||
|
} from 'lib/strings/starter-pack'
|
||||||
|
import {isAndroid, isNative, isWeb} from 'platform/detection'
|
||||||
|
import {useModerationOpts} from 'state/preferences/moderation-opts'
|
||||||
|
import {useListMembersQuery} from 'state/queries/list-members'
|
||||||
|
import {useProfileQuery} from 'state/queries/profile'
|
||||||
|
import {
|
||||||
|
useCreateStarterPackMutation,
|
||||||
|
useEditStarterPackMutation,
|
||||||
|
useStarterPackQuery,
|
||||||
|
} from 'state/queries/starter-packs'
|
||||||
|
import {useSession} from 'state/session'
|
||||||
|
import {useSetMinimalShellMode} from 'state/shell'
|
||||||
|
import * as Toast from '#/view/com/util/Toast'
|
||||||
|
import {UserAvatar} from 'view/com/util/UserAvatar'
|
||||||
|
import {CenteredView} from 'view/com/util/Views'
|
||||||
|
import {useWizardState, WizardStep} from '#/screens/StarterPack/Wizard/State'
|
||||||
|
import {StepDetails} from '#/screens/StarterPack/Wizard/StepDetails'
|
||||||
|
import {StepFeeds} from '#/screens/StarterPack/Wizard/StepFeeds'
|
||||||
|
import {StepProfiles} from '#/screens/StarterPack/Wizard/StepProfiles'
|
||||||
|
import {atoms as a, useTheme} from '#/alf'
|
||||||
|
import {Button, ButtonText} from '#/components/Button'
|
||||||
|
import {useDialogControl} from '#/components/Dialog'
|
||||||
|
import {ListMaybePlaceholder} from '#/components/Lists'
|
||||||
|
import {Loader} from '#/components/Loader'
|
||||||
|
import {WizardEditListDialog} from '#/components/StarterPack/Wizard/WizardEditListDialog'
|
||||||
|
import {Text} from '#/components/Typography'
|
||||||
|
import {Provider} from './State'
|
||||||
|
|
||||||
|
export function Wizard({
|
||||||
|
route,
|
||||||
|
}: NativeStackScreenProps<
|
||||||
|
CommonNavigatorParams,
|
||||||
|
'StarterPackEdit' | 'StarterPackWizard'
|
||||||
|
>) {
|
||||||
|
const {rkey} = route.params ?? {}
|
||||||
|
const {currentAccount} = useSession()
|
||||||
|
const moderationOpts = useModerationOpts()
|
||||||
|
|
||||||
|
const {_} = useLingui()
|
||||||
|
|
||||||
|
const {
|
||||||
|
data: starterPack,
|
||||||
|
isLoading: isLoadingStarterPack,
|
||||||
|
isError: isErrorStarterPack,
|
||||||
|
} = useStarterPackQuery({did: currentAccount!.did, rkey})
|
||||||
|
const listUri = starterPack?.list?.uri
|
||||||
|
|
||||||
|
const {
|
||||||
|
data: profilesData,
|
||||||
|
isLoading: isLoadingProfiles,
|
||||||
|
isError: isErrorProfiles,
|
||||||
|
} = useListMembersQuery(listUri, 50)
|
||||||
|
const listItems = profilesData?.pages.flatMap(p => p.items)
|
||||||
|
|
||||||
|
const {
|
||||||
|
data: profile,
|
||||||
|
isLoading: isLoadingProfile,
|
||||||
|
isError: isErrorProfile,
|
||||||
|
} = useProfileQuery({did: currentAccount?.did})
|
||||||
|
|
||||||
|
const isEdit = Boolean(rkey)
|
||||||
|
const isReady =
|
||||||
|
(!isEdit || (isEdit && starterPack && listItems)) &&
|
||||||
|
profile &&
|
||||||
|
moderationOpts
|
||||||
|
|
||||||
|
if (!isReady) {
|
||||||
|
return (
|
||||||
|
<ListMaybePlaceholder
|
||||||
|
isLoading={
|
||||||
|
isLoadingStarterPack || isLoadingProfiles || isLoadingProfile
|
||||||
|
}
|
||||||
|
isError={isErrorStarterPack || isErrorProfiles || isErrorProfile}
|
||||||
|
errorMessage={_(msg`That starter pack could not be found.`)}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
} else if (isEdit && starterPack?.creator.did !== currentAccount?.did) {
|
||||||
|
return (
|
||||||
|
<ListMaybePlaceholder
|
||||||
|
isLoading={false}
|
||||||
|
isError={true}
|
||||||
|
errorMessage={_(msg`That starter pack could not be found.`)}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Provider starterPack={starterPack} listItems={listItems}>
|
||||||
|
<WizardInner
|
||||||
|
currentStarterPack={starterPack}
|
||||||
|
currentListItems={listItems}
|
||||||
|
profile={profile}
|
||||||
|
moderationOpts={moderationOpts}
|
||||||
|
/>
|
||||||
|
</Provider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function WizardInner({
|
||||||
|
currentStarterPack,
|
||||||
|
currentListItems,
|
||||||
|
profile,
|
||||||
|
moderationOpts,
|
||||||
|
}: {
|
||||||
|
currentStarterPack?: AppBskyGraphDefs.StarterPackView
|
||||||
|
currentListItems?: AppBskyGraphDefs.ListItemView[]
|
||||||
|
profile: AppBskyActorDefs.ProfileViewBasic
|
||||||
|
moderationOpts: ModerationOpts
|
||||||
|
}) {
|
||||||
|
const navigation = useNavigation<NavigationProp>()
|
||||||
|
const {_} = useLingui()
|
||||||
|
const t = useTheme()
|
||||||
|
const setMinimalShellMode = useSetMinimalShellMode()
|
||||||
|
const {setEnabled} = useKeyboardController()
|
||||||
|
const [state, dispatch] = useWizardState()
|
||||||
|
const {currentAccount} = useSession()
|
||||||
|
const {data: currentProfile} = useProfileQuery({
|
||||||
|
did: currentAccount?.did,
|
||||||
|
staleTime: 0,
|
||||||
|
})
|
||||||
|
const parsed = parseStarterPackUri(currentStarterPack?.uri)
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
navigation.setOptions({
|
||||||
|
gestureEnabled: false,
|
||||||
|
})
|
||||||
|
}, [navigation])
|
||||||
|
|
||||||
|
useFocusEffect(
|
||||||
|
React.useCallback(() => {
|
||||||
|
setEnabled(true)
|
||||||
|
setMinimalShellMode(true)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
setMinimalShellMode(false)
|
||||||
|
setEnabled(false)
|
||||||
|
}
|
||||||
|
}, [setMinimalShellMode, setEnabled]),
|
||||||
|
)
|
||||||
|
|
||||||
|
const getDefaultName = () => {
|
||||||
|
let displayName
|
||||||
|
if (
|
||||||
|
currentProfile?.displayName != null &&
|
||||||
|
currentProfile?.displayName !== ''
|
||||||
|
) {
|
||||||
|
displayName = sanitizeDisplayName(currentProfile.displayName)
|
||||||
|
} else {
|
||||||
|
displayName = sanitizeHandle(currentProfile!.handle)
|
||||||
|
}
|
||||||
|
return _(msg`${displayName}'s Starter Pack`).slice(0, 50)
|
||||||
|
}
|
||||||
|
|
||||||
|
const wizardUiStrings: Record<
|
||||||
|
WizardStep,
|
||||||
|
{header: string; nextBtn: string; subtitle?: string}
|
||||||
|
> = {
|
||||||
|
Details: {
|
||||||
|
header: _(msg`Starter Pack`),
|
||||||
|
nextBtn: _(msg`Next`),
|
||||||
|
},
|
||||||
|
Profiles: {
|
||||||
|
header: _(msg`People`),
|
||||||
|
nextBtn: _(msg`Next`),
|
||||||
|
subtitle: _(
|
||||||
|
msg`Add people to your starter pack that you think others will enjoy following`,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
Feeds: {
|
||||||
|
header: _(msg`Feeds`),
|
||||||
|
nextBtn: state.feeds.length === 0 ? _(msg`Skip`) : _(msg`Finish`),
|
||||||
|
subtitle: _(msg`Some subtitle`),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
const currUiStrings = wizardUiStrings[state.currentStep]
|
||||||
|
|
||||||
|
const onSuccessCreate = (data: {uri: string; cid: string}) => {
|
||||||
|
const rkey = new AtUri(data.uri).rkey
|
||||||
|
logEvent('starterPack:create', {
|
||||||
|
setName: state.name != null,
|
||||||
|
setDescription: state.description != null,
|
||||||
|
profilesCount: state.profiles.length,
|
||||||
|
feedsCount: state.feeds.length,
|
||||||
|
})
|
||||||
|
Image.prefetch([getStarterPackOgCard(currentProfile!.did, rkey)])
|
||||||
|
dispatch({type: 'SetProcessing', processing: false})
|
||||||
|
navigation.replace('StarterPack', {
|
||||||
|
name: currentAccount!.handle,
|
||||||
|
rkey,
|
||||||
|
new: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const onSuccessEdit = () => {
|
||||||
|
if (navigation.canGoBack()) {
|
||||||
|
navigation.goBack()
|
||||||
|
} else {
|
||||||
|
navigation.replace('StarterPack', {
|
||||||
|
name: currentAccount!.handle,
|
||||||
|
rkey: parsed!.rkey,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const {mutate: createStarterPack} = useCreateStarterPackMutation({
|
||||||
|
onSuccess: onSuccessCreate,
|
||||||
|
onError: e => {
|
||||||
|
logger.error('Failed to create starter pack', {safeMessage: e})
|
||||||
|
dispatch({type: 'SetProcessing', processing: false})
|
||||||
|
Toast.show(_(msg`Failed to create starter pack`))
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const {mutate: editStarterPack} = useEditStarterPackMutation({
|
||||||
|
onSuccess: onSuccessEdit,
|
||||||
|
onError: e => {
|
||||||
|
logger.error('Failed to edit starter pack', {safeMessage: e})
|
||||||
|
dispatch({type: 'SetProcessing', processing: false})
|
||||||
|
Toast.show(_(msg`Failed to create starter pack`))
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const submit = async () => {
|
||||||
|
dispatch({type: 'SetProcessing', processing: true})
|
||||||
|
if (currentStarterPack && currentListItems) {
|
||||||
|
editStarterPack({
|
||||||
|
name: state.name ?? getDefaultName(),
|
||||||
|
description: state.description,
|
||||||
|
descriptionFacets: [],
|
||||||
|
profiles: state.profiles,
|
||||||
|
feeds: state.feeds,
|
||||||
|
currentStarterPack: currentStarterPack,
|
||||||
|
currentListItems: currentListItems,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
createStarterPack({
|
||||||
|
name: state.name ?? getDefaultName(),
|
||||||
|
description: state.description,
|
||||||
|
descriptionFacets: [],
|
||||||
|
profiles: state.profiles,
|
||||||
|
feeds: state.feeds,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const onNext = () => {
|
||||||
|
if (state.currentStep === 'Feeds') {
|
||||||
|
submit()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const keyboardVisible = Keyboard.isVisible()
|
||||||
|
Keyboard.dismiss()
|
||||||
|
setTimeout(
|
||||||
|
() => {
|
||||||
|
dispatch({type: 'Next'})
|
||||||
|
},
|
||||||
|
keyboardVisible ? 16 : 0,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CenteredView style={[a.flex_1]} sideBorders>
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
a.flex_row,
|
||||||
|
a.pb_sm,
|
||||||
|
a.px_md,
|
||||||
|
a.border_b,
|
||||||
|
t.atoms.border_contrast_medium,
|
||||||
|
a.gap_sm,
|
||||||
|
a.justify_between,
|
||||||
|
a.align_center,
|
||||||
|
isAndroid && a.pt_sm,
|
||||||
|
isWeb && [a.py_md],
|
||||||
|
]}>
|
||||||
|
<View style={[{width: 65}]}>
|
||||||
|
<TouchableOpacity
|
||||||
|
testID="viewHeaderDrawerBtn"
|
||||||
|
hitSlop={HITSLOP_10}
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityLabel={_(msg`Back`)}
|
||||||
|
accessibilityHint={_(msg`Go back to the previous step`)}
|
||||||
|
onPress={() => {
|
||||||
|
if (state.currentStep === 'Details') {
|
||||||
|
navigation.pop()
|
||||||
|
} else {
|
||||||
|
dispatch({type: 'Back'})
|
||||||
|
}
|
||||||
|
}}>
|
||||||
|
<FontAwesomeIcon
|
||||||
|
size={18}
|
||||||
|
icon="angle-left"
|
||||||
|
color={t.atoms.text.color}
|
||||||
|
/>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
<Text style={[a.flex_1, a.font_bold, a.text_lg, a.text_center]}>
|
||||||
|
{currUiStrings.header}
|
||||||
|
</Text>
|
||||||
|
<View style={[{width: 65}]} />
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<Container>
|
||||||
|
{state.currentStep === 'Details' ? (
|
||||||
|
<StepDetails />
|
||||||
|
) : state.currentStep === 'Profiles' ? (
|
||||||
|
<StepProfiles moderationOpts={moderationOpts} />
|
||||||
|
) : state.currentStep === 'Feeds' ? (
|
||||||
|
<StepFeeds moderationOpts={moderationOpts} />
|
||||||
|
) : null}
|
||||||
|
</Container>
|
||||||
|
|
||||||
|
{state.currentStep !== 'Details' && (
|
||||||
|
<Footer
|
||||||
|
onNext={onNext}
|
||||||
|
nextBtnText={currUiStrings.nextBtn}
|
||||||
|
moderationOpts={moderationOpts}
|
||||||
|
profile={profile}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</CenteredView>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Container({children}: {children: React.ReactNode}) {
|
||||||
|
const {_} = useLingui()
|
||||||
|
const [state, dispatch] = useWizardState()
|
||||||
|
|
||||||
|
if (state.currentStep === 'Profiles' || state.currentStep === 'Feeds') {
|
||||||
|
return <View style={[a.flex_1]}>{children}</View>
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<KeyboardAwareScrollView
|
||||||
|
style={[a.flex_1]}
|
||||||
|
keyboardShouldPersistTaps="handled">
|
||||||
|
{children}
|
||||||
|
{state.currentStep === 'Details' && (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
label={_(msg`Next`)}
|
||||||
|
variant="solid"
|
||||||
|
color="primary"
|
||||||
|
size="medium"
|
||||||
|
style={[a.mx_xl, a.mb_lg, {marginTop: 35}]}
|
||||||
|
onPress={() => dispatch({type: 'Next'})}>
|
||||||
|
<ButtonText>
|
||||||
|
<Trans>Next</Trans>
|
||||||
|
</ButtonText>
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</KeyboardAwareScrollView>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Footer({
|
||||||
|
onNext,
|
||||||
|
nextBtnText,
|
||||||
|
moderationOpts,
|
||||||
|
profile,
|
||||||
|
}: {
|
||||||
|
onNext: () => void
|
||||||
|
nextBtnText: string
|
||||||
|
moderationOpts: ModerationOpts
|
||||||
|
profile: AppBskyActorDefs.ProfileViewBasic
|
||||||
|
}) {
|
||||||
|
const {_} = useLingui()
|
||||||
|
const t = useTheme()
|
||||||
|
const [state, dispatch] = useWizardState()
|
||||||
|
const editDialogControl = useDialogControl()
|
||||||
|
const {bottom: bottomInset} = useSafeAreaInsets()
|
||||||
|
|
||||||
|
const items =
|
||||||
|
state.currentStep === 'Profiles'
|
||||||
|
? [profile, ...state.profiles]
|
||||||
|
: state.feeds
|
||||||
|
const initialNamesIndex = state.currentStep === 'Profiles' ? 1 : 0
|
||||||
|
|
||||||
|
const isEditEnabled =
|
||||||
|
(state.currentStep === 'Profiles' && items.length > 1) ||
|
||||||
|
(state.currentStep === 'Feeds' && items.length > 0)
|
||||||
|
|
||||||
|
const minimumItems = state.currentStep === 'Profiles' ? 8 : 0
|
||||||
|
|
||||||
|
const textStyles = [a.text_md]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
a.border_t,
|
||||||
|
a.align_center,
|
||||||
|
a.px_lg,
|
||||||
|
a.pt_xl,
|
||||||
|
a.gap_md,
|
||||||
|
t.atoms.bg,
|
||||||
|
t.atoms.border_contrast_medium,
|
||||||
|
{
|
||||||
|
paddingBottom: a.pb_lg.paddingBottom + bottomInset,
|
||||||
|
},
|
||||||
|
isNative && [
|
||||||
|
a.border_l,
|
||||||
|
a.border_r,
|
||||||
|
t.atoms.shadow_md,
|
||||||
|
{
|
||||||
|
borderTopLeftRadius: 14,
|
||||||
|
borderTopRightRadius: 14,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
]}>
|
||||||
|
{items.length > minimumItems && (
|
||||||
|
<View style={[a.absolute, {right: 14, top: 31}]}>
|
||||||
|
<Text style={[a.font_bold]}>
|
||||||
|
{items.length}/{state.currentStep === 'Profiles' ? 50 : 3}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<View style={[a.flex_row, a.gap_xs]}>
|
||||||
|
{items.slice(0, 6).map((p, index) => (
|
||||||
|
<UserAvatar
|
||||||
|
key={index}
|
||||||
|
avatar={p.avatar}
|
||||||
|
size={32}
|
||||||
|
type={state.currentStep === 'Profiles' ? 'user' : 'algo'}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{items.length === 0 ? (
|
||||||
|
<View style={[a.gap_sm]}>
|
||||||
|
<Text style={[a.font_bold, a.text_center, textStyles]}>
|
||||||
|
<Trans>Add some feeds to your starter pack!</Trans>
|
||||||
|
</Text>
|
||||||
|
<Text style={[a.text_center, textStyles]}>
|
||||||
|
<Trans>Search for feeds that you want to suggest to others.</Trans>
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
<Text style={[a.text_center, textStyles]}>
|
||||||
|
{state.currentStep === 'Profiles' && items.length === 1 ? (
|
||||||
|
<Trans>
|
||||||
|
It's just you right now! Add more people to your starter pack by
|
||||||
|
searching above.
|
||||||
|
</Trans>
|
||||||
|
) : items.length === 1 ? (
|
||||||
|
<Trans>
|
||||||
|
<Text style={[a.font_bold, textStyles]}>
|
||||||
|
{getName(items[initialNamesIndex])}
|
||||||
|
</Text>{' '}
|
||||||
|
is included in your starter pack
|
||||||
|
</Trans>
|
||||||
|
) : items.length === 2 ? (
|
||||||
|
<Trans>
|
||||||
|
<Text style={[a.font_bold, textStyles]}>
|
||||||
|
{getName(items[initialNamesIndex])}{' '}
|
||||||
|
</Text>
|
||||||
|
and
|
||||||
|
<Text> </Text>
|
||||||
|
<Text style={[a.font_bold, textStyles]}>
|
||||||
|
{getName(items[state.currentStep === 'Profiles' ? 0 : 1])}{' '}
|
||||||
|
</Text>
|
||||||
|
are included in your starter pack
|
||||||
|
</Trans>
|
||||||
|
) : (
|
||||||
|
<Trans>
|
||||||
|
<Text style={[a.font_bold, textStyles]}>
|
||||||
|
{getName(items[initialNamesIndex])},{' '}
|
||||||
|
</Text>
|
||||||
|
<Text style={[a.font_bold, textStyles]}>
|
||||||
|
{getName(items[initialNamesIndex + 1])},{' '}
|
||||||
|
</Text>
|
||||||
|
and {items.length - 2}{' '}
|
||||||
|
<Plural value={items.length - 2} one="other" other="others" /> are
|
||||||
|
included in your starter pack
|
||||||
|
</Trans>
|
||||||
|
)}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
a.flex_row,
|
||||||
|
a.w_full,
|
||||||
|
a.justify_between,
|
||||||
|
a.align_center,
|
||||||
|
isNative ? a.mt_sm : a.mt_md,
|
||||||
|
]}>
|
||||||
|
{isEditEnabled ? (
|
||||||
|
<Button
|
||||||
|
label={_(msg`Edit`)}
|
||||||
|
variant="solid"
|
||||||
|
color="secondary"
|
||||||
|
size="small"
|
||||||
|
style={{width: 70}}
|
||||||
|
onPress={editDialogControl.open}>
|
||||||
|
<ButtonText>
|
||||||
|
<Trans>Edit</Trans>
|
||||||
|
</ButtonText>
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<View style={{width: 70, height: 35}} />
|
||||||
|
)}
|
||||||
|
{state.currentStep === 'Profiles' && items.length < 8 ? (
|
||||||
|
<>
|
||||||
|
<Text
|
||||||
|
style={[a.font_bold, textStyles, t.atoms.text_contrast_medium]}>
|
||||||
|
<Trans>Add {8 - items.length} more to continue</Trans>
|
||||||
|
</Text>
|
||||||
|
<View style={{width: 70}} />
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
label={nextBtnText}
|
||||||
|
variant="solid"
|
||||||
|
color="primary"
|
||||||
|
size="small"
|
||||||
|
onPress={onNext}
|
||||||
|
disabled={!state.canNext || state.processing}>
|
||||||
|
<ButtonText>{nextBtnText}</ButtonText>
|
||||||
|
{state.processing && <Loader size="xs" style={{color: 'white'}} />}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<WizardEditListDialog
|
||||||
|
control={editDialogControl}
|
||||||
|
state={state}
|
||||||
|
dispatch={dispatch}
|
||||||
|
moderationOpts={moderationOpts}
|
||||||
|
profile={profile}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function getName(item: AppBskyActorDefs.ProfileViewBasic | GeneratorView) {
|
||||||
|
if (typeof item.displayName === 'string') {
|
||||||
|
return enforceLen(sanitizeDisplayName(item.displayName), 16, true)
|
||||||
|
} else if (typeof item.handle === 'string') {
|
||||||
|
return enforceLen(sanitizeHandle(item.handle), 16, true)
|
||||||
|
}
|
||||||
|
return ''
|
||||||
|
}
|
|
@ -88,6 +88,7 @@ export const schema = z.object({
|
||||||
disableHaptics: z.boolean().optional(),
|
disableHaptics: z.boolean().optional(),
|
||||||
disableAutoplay: z.boolean().optional(),
|
disableAutoplay: z.boolean().optional(),
|
||||||
kawaii: z.boolean().optional(),
|
kawaii: z.boolean().optional(),
|
||||||
|
hasCheckedForStarterPack: z.boolean().optional(),
|
||||||
/** @deprecated */
|
/** @deprecated */
|
||||||
mutedThreads: z.array(z.string()),
|
mutedThreads: z.array(z.string()),
|
||||||
})
|
})
|
||||||
|
@ -129,4 +130,5 @@ export const defaults: Schema = {
|
||||||
disableHaptics: false,
|
disableHaptics: false,
|
||||||
disableAutoplay: prefersReducedMotion,
|
disableAutoplay: prefersReducedMotion,
|
||||||
kawaii: false,
|
kawaii: false,
|
||||||
|
hasCheckedForStarterPack: false,
|
||||||
}
|
}
|
||||||
|
|
|
@ -9,6 +9,7 @@ import {Provider as InAppBrowserProvider} from './in-app-browser'
|
||||||
import {Provider as KawaiiProvider} from './kawaii'
|
import {Provider as KawaiiProvider} from './kawaii'
|
||||||
import {Provider as LanguagesProvider} from './languages'
|
import {Provider as LanguagesProvider} from './languages'
|
||||||
import {Provider as LargeAltBadgeProvider} from './large-alt-badge'
|
import {Provider as LargeAltBadgeProvider} from './large-alt-badge'
|
||||||
|
import {Provider as UsedStarterPacksProvider} from './used-starter-packs'
|
||||||
|
|
||||||
export {
|
export {
|
||||||
useRequireAltTextEnabled,
|
useRequireAltTextEnabled,
|
||||||
|
@ -34,7 +35,9 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
|
||||||
<InAppBrowserProvider>
|
<InAppBrowserProvider>
|
||||||
<DisableHapticsProvider>
|
<DisableHapticsProvider>
|
||||||
<AutoplayProvider>
|
<AutoplayProvider>
|
||||||
<KawaiiProvider>{children}</KawaiiProvider>
|
<UsedStarterPacksProvider>
|
||||||
|
<KawaiiProvider>{children}</KawaiiProvider>
|
||||||
|
</UsedStarterPacksProvider>
|
||||||
</AutoplayProvider>
|
</AutoplayProvider>
|
||||||
</DisableHapticsProvider>
|
</DisableHapticsProvider>
|
||||||
</InAppBrowserProvider>
|
</InAppBrowserProvider>
|
||||||
|
|
|
@ -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)
|
|
@ -1,5 +1,11 @@
|
||||||
import {AppBskyActorDefs} from '@atproto/api'
|
import {AppBskyActorDefs, AppBskyActorSearchActors} from '@atproto/api'
|
||||||
import {QueryClient, useQuery} from '@tanstack/react-query'
|
import {
|
||||||
|
InfiniteData,
|
||||||
|
QueryClient,
|
||||||
|
QueryKey,
|
||||||
|
useInfiniteQuery,
|
||||||
|
useQuery,
|
||||||
|
} from '@tanstack/react-query'
|
||||||
|
|
||||||
import {STALE} from '#/state/queries'
|
import {STALE} from '#/state/queries'
|
||||||
import {useAgent} from '#/state/session'
|
import {useAgent} from '#/state/session'
|
||||||
|
@ -7,6 +13,11 @@ import {useAgent} from '#/state/session'
|
||||||
const RQKEY_ROOT = 'actor-search'
|
const RQKEY_ROOT = 'actor-search'
|
||||||
export const RQKEY = (query: string) => [RQKEY_ROOT, query]
|
export const RQKEY = (query: string) => [RQKEY_ROOT, query]
|
||||||
|
|
||||||
|
export const RQKEY_PAGINATED = (query: string) => [
|
||||||
|
`${RQKEY_ROOT}_paginated`,
|
||||||
|
query,
|
||||||
|
]
|
||||||
|
|
||||||
export function useActorSearch({
|
export function useActorSearch({
|
||||||
query,
|
query,
|
||||||
enabled,
|
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(
|
export function* findAllProfilesInQueryData(
|
||||||
queryClient: QueryClient,
|
queryClient: QueryClient,
|
||||||
did: string,
|
did: string,
|
||||||
|
|
|
@ -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)})
|
||||||
|
}
|
|
@ -9,6 +9,7 @@ import {
|
||||||
} from '@atproto/api'
|
} from '@atproto/api'
|
||||||
import {
|
import {
|
||||||
InfiniteData,
|
InfiniteData,
|
||||||
|
keepPreviousData,
|
||||||
QueryClient,
|
QueryClient,
|
||||||
QueryKey,
|
QueryKey,
|
||||||
useInfiniteQuery,
|
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'
|
const popularFeedsSearchQueryKeyRoot = 'popularFeedsSearch'
|
||||||
export const createPopularFeedsSearchQueryKey = (query: string) => [
|
export const createPopularFeedsSearchQueryKey = (query: string) => [
|
||||||
popularFeedsSearchQueryKeyRoot,
|
popularFeedsSearchQueryKeyRoot,
|
||||||
|
|
|
@ -15,7 +15,7 @@ type RQPageParam = string | undefined
|
||||||
const RQKEY_ROOT = 'list-members'
|
const RQKEY_ROOT = 'list-members'
|
||||||
export const RQKEY = (uri: string) => [RQKEY_ROOT, uri]
|
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()
|
const agent = useAgent()
|
||||||
return useInfiniteQuery<
|
return useInfiniteQuery<
|
||||||
AppBskyGraphGetList.OutputSchema,
|
AppBskyGraphGetList.OutputSchema,
|
||||||
|
@ -25,20 +25,31 @@ export function useListMembersQuery(uri: string) {
|
||||||
RQPageParam
|
RQPageParam
|
||||||
>({
|
>({
|
||||||
staleTime: STALE.MINUTES.ONE,
|
staleTime: STALE.MINUTES.ONE,
|
||||||
queryKey: RQKEY(uri),
|
queryKey: RQKEY(uri ?? ''),
|
||||||
async queryFn({pageParam}: {pageParam: RQPageParam}) {
|
async queryFn({pageParam}: {pageParam: RQPageParam}) {
|
||||||
const res = await agent.app.bsky.graph.getList({
|
const res = await agent.app.bsky.graph.getList({
|
||||||
list: uri,
|
list: uri!, // the enabled flag will prevent this from running until uri is set
|
||||||
limit: PAGE_SIZE,
|
limit,
|
||||||
cursor: pageParam,
|
cursor: pageParam,
|
||||||
})
|
})
|
||||||
return res.data
|
return res.data
|
||||||
},
|
},
|
||||||
initialPageParam: undefined,
|
initialPageParam: undefined,
|
||||||
getNextPageParam: lastPage => lastPage.cursor,
|
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(
|
export function* findAllProfilesInQueryData(
|
||||||
queryClient: QueryClient,
|
queryClient: QueryClient,
|
||||||
did: string,
|
did: string,
|
||||||
|
|
|
@ -155,8 +155,10 @@ export function* findAllPostsInQueryData(
|
||||||
|
|
||||||
for (const page of queryData?.pages) {
|
for (const page of queryData?.pages) {
|
||||||
for (const item of page.items) {
|
for (const item of page.items) {
|
||||||
if (item.subject && didOrHandleUriMatches(atUri, item.subject)) {
|
if (item.type !== 'starterpack-joined') {
|
||||||
yield item.subject
|
if (item.subject && didOrHandleUriMatches(atUri, item.subject)) {
|
||||||
|
yield item.subject
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const quotedPost = getEmbeddedPost(item.subject?.embed)
|
const quotedPost = getEmbeddedPost(item.subject?.embed)
|
||||||
|
@ -181,7 +183,10 @@ export function* findAllProfilesInQueryData(
|
||||||
}
|
}
|
||||||
for (const page of queryData?.pages) {
|
for (const page of queryData?.pages) {
|
||||||
for (const item of page.items) {
|
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
|
yield item.subject.author
|
||||||
}
|
}
|
||||||
const quotedPost = getEmbeddedPost(item.subject?.embed)
|
const quotedPost = getEmbeddedPost(item.subject?.embed)
|
||||||
|
|
|
@ -1,26 +1,22 @@
|
||||||
import {
|
import {
|
||||||
AppBskyNotificationListNotifications,
|
|
||||||
AppBskyFeedDefs,
|
AppBskyFeedDefs,
|
||||||
|
AppBskyGraphDefs,
|
||||||
|
AppBskyNotificationListNotifications,
|
||||||
} from '@atproto/api'
|
} from '@atproto/api'
|
||||||
|
|
||||||
export type NotificationType =
|
export type NotificationType =
|
||||||
| 'post-like'
|
| StarterPackNotificationType
|
||||||
| 'feedgen-like'
|
| OtherNotificationType
|
||||||
| 'repost'
|
|
||||||
| 'mention'
|
|
||||||
| 'reply'
|
|
||||||
| 'quote'
|
|
||||||
| 'follow'
|
|
||||||
| 'unknown'
|
|
||||||
|
|
||||||
export interface FeedNotification {
|
export type FeedNotification =
|
||||||
_reactKey: string
|
| (FeedNotificationBase & {
|
||||||
type: NotificationType
|
type: StarterPackNotificationType
|
||||||
notification: AppBskyNotificationListNotifications.Notification
|
subject?: AppBskyGraphDefs.StarterPackViewBasic
|
||||||
additional?: AppBskyNotificationListNotifications.Notification[]
|
})
|
||||||
subjectUri?: string
|
| (FeedNotificationBase & {
|
||||||
subject?: AppBskyFeedDefs.PostView
|
type: OtherNotificationType
|
||||||
}
|
subject?: AppBskyFeedDefs.PostView
|
||||||
|
})
|
||||||
|
|
||||||
export interface FeedPage {
|
export interface FeedPage {
|
||||||
cursor: string | undefined
|
cursor: string | undefined
|
||||||
|
@ -37,3 +33,22 @@ export interface CachedFeedPage {
|
||||||
data: FeedPage | undefined
|
data: FeedPage | undefined
|
||||||
unreadCount: number
|
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
|
||||||
|
}
|
||||||
|
|
|
@ -3,6 +3,8 @@ import {
|
||||||
AppBskyFeedLike,
|
AppBskyFeedLike,
|
||||||
AppBskyFeedPost,
|
AppBskyFeedPost,
|
||||||
AppBskyFeedRepost,
|
AppBskyFeedRepost,
|
||||||
|
AppBskyGraphDefs,
|
||||||
|
AppBskyGraphStarterpack,
|
||||||
AppBskyNotificationListNotifications,
|
AppBskyNotificationListNotifications,
|
||||||
BskyAgent,
|
BskyAgent,
|
||||||
moderateNotification,
|
moderateNotification,
|
||||||
|
@ -40,6 +42,7 @@ export async function fetchPage({
|
||||||
limit,
|
limit,
|
||||||
cursor,
|
cursor,
|
||||||
})
|
})
|
||||||
|
|
||||||
const indexedAt = res.data.notifications[0]?.indexedAt
|
const indexedAt = res.data.notifications[0]?.indexedAt
|
||||||
|
|
||||||
// filter out notifs by mod rules
|
// filter out notifs by mod rules
|
||||||
|
@ -56,9 +59,18 @@ export async function fetchPage({
|
||||||
const subjects = await fetchSubjects(agent, notifsGrouped)
|
const subjects = await fetchSubjects(agent, notifsGrouped)
|
||||||
for (const notif of notifsGrouped) {
|
for (const notif of notifsGrouped) {
|
||||||
if (notif.subjectUri) {
|
if (notif.subjectUri) {
|
||||||
notif.subject = subjects.get(notif.subjectUri)
|
if (
|
||||||
if (notif.subject) {
|
notif.type === 'starterpack-joined' &&
|
||||||
precacheProfile(queryClient, notif.subject.author)
|
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) {
|
if (!grouped) {
|
||||||
const type = toKnownType(notif)
|
const type = toKnownType(notif)
|
||||||
groupedNotifs.push({
|
if (type !== 'starterpack-joined') {
|
||||||
_reactKey: `notif-${notif.uri}`,
|
groupedNotifs.push({
|
||||||
type,
|
_reactKey: `notif-${notif.uri}`,
|
||||||
notification: notif,
|
type,
|
||||||
subjectUri: getSubjectUri(type, notif),
|
notification: notif,
|
||||||
})
|
subjectUri: getSubjectUri(type, notif),
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
groupedNotifs.push({
|
||||||
|
_reactKey: `notif-${notif.uri}`,
|
||||||
|
type: 'starterpack-joined',
|
||||||
|
notification: notif,
|
||||||
|
subjectUri: notif.uri,
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return groupedNotifs
|
return groupedNotifs
|
||||||
|
@ -134,29 +155,54 @@ export function groupNotifications(
|
||||||
async function fetchSubjects(
|
async function fetchSubjects(
|
||||||
agent: BskyAgent,
|
agent: BskyAgent,
|
||||||
groupedNotifs: FeedNotification[],
|
groupedNotifs: FeedNotification[],
|
||||||
): Promise<Map<string, AppBskyFeedDefs.PostView>> {
|
): Promise<{
|
||||||
const uris = new Set<string>()
|
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) {
|
for (const notif of groupedNotifs) {
|
||||||
if (notif.subjectUri?.includes('app.bsky.feed.post')) {
|
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(
|
const postsChunks = await Promise.all(
|
||||||
uriChunks.map(uris =>
|
postUriChunks.map(uris =>
|
||||||
agent.app.bsky.feed.getPosts({uris}).then(res => res.data.posts),
|
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()) {
|
for (const post of postsChunks.flat()) {
|
||||||
if (
|
if (
|
||||||
AppBskyFeedPost.isRecord(post.record) &&
|
AppBskyFeedPost.isRecord(post.record) &&
|
||||||
AppBskyFeedPost.validateRecord(post.record).success
|
AppBskyFeedPost.validateRecord(post.record).success
|
||||||
) {
|
) {
|
||||||
map.set(post.uri, post)
|
postsMap.set(post.uri, post)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return map
|
for (const pack of packsChunks.flat()) {
|
||||||
|
if (AppBskyGraphStarterpack.isRecord(pack.record)) {
|
||||||
|
packsMap.set(pack.uri, pack)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
posts: postsMap,
|
||||||
|
starterPacks: packsMap,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function toKnownType(
|
function toKnownType(
|
||||||
|
@ -173,7 +219,8 @@ function toKnownType(
|
||||||
notif.reason === 'mention' ||
|
notif.reason === 'mention' ||
|
||||||
notif.reason === 'reply' ||
|
notif.reason === 'reply' ||
|
||||||
notif.reason === 'quote' ||
|
notif.reason === 'quote' ||
|
||||||
notif.reason === 'follow'
|
notif.reason === 'follow' ||
|
||||||
|
notif.reason === 'starterpack-joined'
|
||||||
) {
|
) {
|
||||||
return notif.reason as NotificationType
|
return notif.reason as NotificationType
|
||||||
}
|
}
|
||||||
|
|
|
@ -26,7 +26,15 @@ export function useProfileListsQuery(did: string, opts?: {enabled?: boolean}) {
|
||||||
limit: PAGE_SIZE,
|
limit: PAGE_SIZE,
|
||||||
cursor: pageParam,
|
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,
|
initialPageParam: undefined,
|
||||||
getNextPageParam: lastPage => lastPage.cursor,
|
getNextPageParam: lastPage => lastPage.cursor,
|
||||||
|
|
|
@ -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()
|
||||||
|
}
|
||||||
|
}
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue