diff options
115 files changed, 6329 insertions, 230 deletions
diff --git a/__tests__/lib/string.test.ts b/__tests__/lib/string.test.ts index 30072ccb1..0da9551e3 100644 --- a/__tests__/lib/string.test.ts +++ b/__tests__/lib/string.test.ts @@ -1,6 +1,11 @@ import {RichText} from '@atproto/api' import {parseEmbedPlayerFromUrl} from 'lib/strings/embed-player' +import { + createStarterPackGooglePlayUri, + createStarterPackLinkFromAndroidReferrer, + parseStarterPackUri, +} from 'lib/strings/starter-pack' import {cleanError} from '../../src/lib/strings/errors' import {createFullHandle, makeValidHandle} from '../../src/lib/strings/handles' 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) + }) +}) diff --git a/app.config.js b/app.config.js index eafacc6cc..57d430586 100644 --- a/app.config.js +++ b/app.config.js @@ -39,6 +39,17 @@ module.exports = function (config) { const IS_TESTFLIGHT = process.env.EXPO_PUBLIC_ENV === 'testflight' 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 ? 'testflight' : IS_PRODUCTION @@ -83,7 +94,7 @@ module.exports = function (config) { NSPhotoLibraryUsageDescription: 'Used for profile pictures, posts, and other kinds of content', }, - associatedDomains: ['applinks:bsky.app', 'applinks:staging.bsky.app'], + associatedDomains: ASSOCIATED_DOMAINS, splash: { ...SPLASH_CONFIG, dark: DARK_SPLASH_CONFIG, @@ -202,6 +213,7 @@ module.exports = function (config) { sounds: PLATFORM === 'ios' ? ['assets/dm.aiff'] : ['assets/dm.mp3'], }, ], + './plugins/starterPackAppClipExtension/withStarterPackAppClip.js', './plugins/withAndroidManifestPlugin.js', './plugins/withAndroidManifestFCMIconPlugin.js', './plugins/withAndroidStylesWindowBackgroundPlugin.js', @@ -234,6 +246,10 @@ module.exports = function (config) { ], }, }, + { + targetName: 'BlueskyClip', + bundleIdentifier: 'xyz.blueskyweb.app.AppClip', + }, ], }, }, diff --git a/assets/icons/qrCode_stroke2_corner0_rounded.svg b/assets/icons/qrCode_stroke2_corner0_rounded.svg new file mode 100644 index 000000000..b17db3953 --- /dev/null +++ b/assets/icons/qrCode_stroke2_corner0_rounded.svg @@ -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> diff --git a/assets/icons/starterPack.svg b/assets/icons/starterPack.svg new file mode 100644 index 000000000..7f0df5595 --- /dev/null +++ b/assets/icons/starterPack.svg @@ -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> diff --git a/assets/icons/starter_pack_icon.svg b/assets/icons/starter_pack_icon.svg new file mode 100644 index 000000000..47a2f49b6 --- /dev/null +++ b/assets/icons/starter_pack_icon.svg @@ -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> \ No newline at end of file diff --git a/assets/logo.png b/assets/logo.png new file mode 100644 index 000000000..cc2d66034 --- /dev/null +++ b/assets/logo.png Binary files differdiff --git a/bskyweb/cmd/bskyweb/server.go b/bskyweb/cmd/bskyweb/server.go index 6d32e0e21..96fb07ddf 100644 --- a/bskyweb/cmd/bskyweb/server.go +++ b/bskyweb/cmd/bskyweb/server.go @@ -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/reposted-by", server.WebGeneric) + // starter packs + e.GET("/starter-pack/:handleOrDID/:rkey", server.WebGeneric) + e.GET("/start/:handleOrDID/:rkey", server.WebGeneric) + if linkHost != "" { linkUrl, err := url.Parse(linkHost) if err != nil { diff --git a/bskyweb/static/.well-known/apple-app-site-association b/bskyweb/static/.well-known/apple-app-site-association index 232acdf25..0a05fa35f 100644 --- a/bskyweb/static/.well-known/apple-app-site-association +++ b/bskyweb/static/.well-known/apple-app-site-association @@ -1,6 +1,8 @@ { "applinks": { - "apps": [], + "appclips": { + "apps": ["B3LX46C5HS.xyz.blueskyweb.app.AppClip"] + }, "details": [ { "appID": "B3LX46C5HS.xyz.blueskyweb.app", @@ -10,4 +12,4 @@ } ] } -} \ No newline at end of file +} diff --git a/modules/BlueskyClip/AppDelegate.swift b/modules/BlueskyClip/AppDelegate.swift new file mode 100644 index 000000000..684194953 --- /dev/null +++ b/modules/BlueskyClip/AppDelegate.swift @@ -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 + } +} diff --git a/modules/BlueskyClip/Images.xcassets/AppIcon.appiconset/App-Icon-1024x1024@1x.png b/modules/BlueskyClip/Images.xcassets/AppIcon.appiconset/App-Icon-1024x1024@1x.png new file mode 100644 index 000000000..75ce4b813 --- /dev/null +++ b/modules/BlueskyClip/Images.xcassets/AppIcon.appiconset/App-Icon-1024x1024@1x.png Binary files differdiff --git a/modules/BlueskyClip/Images.xcassets/AppIcon.appiconset/Contents.json b/modules/BlueskyClip/Images.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 000000000..c3bb428db --- /dev/null +++ b/modules/BlueskyClip/Images.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,14 @@ +{ + "images" : [ + { + "filename" : "App-Icon-1024x1024@1x.png", + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/modules/BlueskyClip/Images.xcassets/Contents.json b/modules/BlueskyClip/Images.xcassets/Contents.json new file mode 100644 index 000000000..73c00596a --- /dev/null +++ b/modules/BlueskyClip/Images.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/modules/BlueskyClip/ViewController.swift b/modules/BlueskyClip/ViewController.swift new file mode 100644 index 000000000..b178644b8 --- /dev/null +++ b/modules/BlueskyClip/ViewController.swift @@ -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? +} diff --git a/modules/expo-bluesky-swiss-army/android/build.gradle b/modules/expo-bluesky-swiss-army/android/build.gradle new file mode 100644 index 000000000..b031cde57 --- /dev/null +++ b/modules/expo-bluesky-swiss-army/android/build.gradle @@ -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") +} diff --git a/modules/expo-bluesky-swiss-army/android/src/main/AndroidManifest.xml b/modules/expo-bluesky-swiss-army/android/src/main/AndroidManifest.xml new file mode 100644 index 000000000..bdae66c8f --- /dev/null +++ b/modules/expo-bluesky-swiss-army/android/src/main/AndroidManifest.xml @@ -0,0 +1,2 @@ +<manifest> +</manifest> diff --git a/modules/expo-bluesky-swiss-army/android/src/main/java/expo/modules/blueskyswissarmy/deviceprefs/ExpoBlueskyDevicePrefsModule.kt b/modules/expo-bluesky-swiss-army/android/src/main/java/expo/modules/blueskyswissarmy/deviceprefs/ExpoBlueskyDevicePrefsModule.kt new file mode 100644 index 000000000..29017f17a --- /dev/null +++ b/modules/expo-bluesky-swiss-army/android/src/main/java/expo/modules/blueskyswissarmy/deviceprefs/ExpoBlueskyDevicePrefsModule.kt @@ -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") + } +} diff --git a/modules/expo-bluesky-swiss-army/android/src/main/java/expo/modules/blueskyswissarmy/referrer/ExpoBlueskyReferrerModule.kt b/modules/expo-bluesky-swiss-army/android/src/main/java/expo/modules/blueskyswissarmy/referrer/ExpoBlueskyReferrerModule.kt new file mode 100644 index 000000000..3589b364e --- /dev/null +++ b/modules/expo-bluesky-swiss-army/android/src/main/java/expo/modules/blueskyswissarmy/referrer/ExpoBlueskyReferrerModule.kt @@ -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") + ) + } + }) + } + } +} \ No newline at end of file diff --git a/modules/expo-bluesky-swiss-army/expo-module.config.json b/modules/expo-bluesky-swiss-army/expo-module.config.json new file mode 100644 index 000000000..730bc6114 --- /dev/null +++ b/modules/expo-bluesky-swiss-army/expo-module.config.json @@ -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" + ] + } +} diff --git a/modules/expo-bluesky-swiss-army/index.ts b/modules/expo-bluesky-swiss-army/index.ts new file mode 100644 index 000000000..1b2f89249 --- /dev/null +++ b/modules/expo-bluesky-swiss-army/index.ts @@ -0,0 +1,4 @@ +import * as DevicePrefs from './src/DevicePrefs' +import * as Referrer from './src/Referrer' + +export {DevicePrefs, Referrer} diff --git a/modules/expo-bluesky-swiss-army/ios/DevicePrefs/ExpoBlueskyDevicePrefsModule.swift b/modules/expo-bluesky-swiss-army/ios/DevicePrefs/ExpoBlueskyDevicePrefsModule.swift new file mode 100644 index 000000000..b13a9fe3f --- /dev/null +++ b/modules/expo-bluesky-swiss-army/ios/DevicePrefs/ExpoBlueskyDevicePrefsModule.swift @@ -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) + } + } +} diff --git a/modules/expo-bluesky-swiss-army/ios/ExpoBlueskySwissArmy.podspec b/modules/expo-bluesky-swiss-army/ios/ExpoBlueskySwissArmy.podspec new file mode 100644 index 000000000..be4b0eae4 --- /dev/null +++ b/modules/expo-bluesky-swiss-army/ios/ExpoBlueskySwissArmy.podspec @@ -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 diff --git a/modules/expo-bluesky-swiss-army/ios/Referrer/ExpoBlueskyReferrerModule.swift b/modules/expo-bluesky-swiss-army/ios/Referrer/ExpoBlueskyReferrerModule.swift new file mode 100644 index 000000000..fd28c51e6 --- /dev/null +++ b/modules/expo-bluesky-swiss-army/ios/Referrer/ExpoBlueskyReferrerModule.swift @@ -0,0 +1,7 @@ +import ExpoModulesCore + +public class ExpoBlueskyReferrerModule: Module { + public func definition() -> ModuleDefinition { + Name("ExpoBlueskyReferrer") + } +} diff --git a/modules/expo-bluesky-swiss-army/src/DevicePrefs/index.ios.ts b/modules/expo-bluesky-swiss-army/src/DevicePrefs/index.ios.ts new file mode 100644 index 000000000..427185086 --- /dev/null +++ b/modules/expo-bluesky-swiss-army/src/DevicePrefs/index.ios.ts @@ -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) +} diff --git a/modules/expo-bluesky-swiss-army/src/DevicePrefs/index.ts b/modules/expo-bluesky-swiss-army/src/DevicePrefs/index.ts new file mode 100644 index 000000000..f1eee6c28 --- /dev/null +++ b/modules/expo-bluesky-swiss-army/src/DevicePrefs/index.ts @@ -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}) +} diff --git a/modules/expo-bluesky-swiss-army/src/NotImplemented.ts b/modules/expo-bluesky-swiss-army/src/NotImplemented.ts new file mode 100644 index 000000000..876cd7b32 --- /dev/null +++ b/modules/expo-bluesky-swiss-army/src/NotImplemented.ts @@ -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') + } + } +} diff --git a/modules/expo-bluesky-swiss-army/src/Referrer/index.android.ts b/modules/expo-bluesky-swiss-army/src/Referrer/index.android.ts new file mode 100644 index 000000000..06dfd2d09 --- /dev/null +++ b/modules/expo-bluesky-swiss-army/src/Referrer/index.android.ts @@ -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() +} diff --git a/modules/expo-bluesky-swiss-army/src/Referrer/index.ts b/modules/expo-bluesky-swiss-army/src/Referrer/index.ts new file mode 100644 index 000000000..255398552 --- /dev/null +++ b/modules/expo-bluesky-swiss-army/src/Referrer/index.ts @@ -0,0 +1,7 @@ +import {NotImplementedError} from '../NotImplemented' +import {GooglePlayReferrerInfo} from './types' + +// @ts-ignore throws +export function getGooglePlayReferrerInfoAsync(): Promise<GooglePlayReferrerInfo> { + throw new NotImplementedError() +} diff --git a/modules/expo-bluesky-swiss-army/src/Referrer/types.ts b/modules/expo-bluesky-swiss-army/src/Referrer/types.ts new file mode 100644 index 000000000..55faaff4d --- /dev/null +++ b/modules/expo-bluesky-swiss-army/src/Referrer/types.ts @@ -0,0 +1,7 @@ +export type GooglePlayReferrerInfo = + | { + installReferrer?: string + clickTimestamp?: number + installTimestamp?: number + } + | undefined diff --git a/package.json b/package.json index bcd5a1d37..657770309 100644 --- a/package.json +++ b/package.json @@ -49,7 +49,7 @@ "open-analyzer": "EXPO_PUBLIC_OPEN_ANALYZER=1 yarn build-web" }, "dependencies": { - "@atproto/api": "^0.12.20", + "@atproto/api": "0.12.22-next.0", "@bam.tech/react-native-image-resizer": "^3.0.4", "@braintree/sanitize-url": "^6.0.2", "@discord/bottom-sheet": "bluesky-social/react-native-bottom-sheet", @@ -177,6 +177,7 @@ "react-native-pager-view": "6.2.3", "react-native-picker-select": "^9.1.3", "react-native-progress": "bluesky-social/react-native-progress", + "react-native-qrcode-styled": "^0.3.1", "react-native-reanimated": "^3.11.0", "react-native-root-siblings": "^4.1.1", "react-native-safe-area-context": "4.10.1", @@ -205,7 +206,7 @@ "@babel/preset-env": "^7.20.0", "@babel/runtime": "^7.20.0", "@did-plc/server": "^0.0.1", - "@expo/config-plugins": "7.8.0", + "@expo/config-plugins": "8.0.4", "@expo/prebuild-config": "6.7.0", "@lingui/cli": "^4.5.0", "@lingui/macro": "^4.5.0", diff --git a/plugins/starterPackAppClipExtension/withAppEntitlements.js b/plugins/starterPackAppClipExtension/withAppEntitlements.js new file mode 100644 index 000000000..1bffd8208 --- /dev/null +++ b/plugins/starterPackAppClipExtension/withAppEntitlements.js @@ -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} diff --git a/plugins/starterPackAppClipExtension/withClipEntitlements.js b/plugins/starterPackAppClipExtension/withClipEntitlements.js new file mode 100644 index 000000000..77636b5c9 --- /dev/null +++ b/plugins/starterPackAppClipExtension/withClipEntitlements.js @@ -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} diff --git a/plugins/starterPackAppClipExtension/withClipInfoPlist.js b/plugins/starterPackAppClipExtension/withClipInfoPlist.js new file mode 100644 index 000000000..59fbed1a9 --- /dev/null +++ b/plugins/starterPackAppClipExtension/withClipInfoPlist.js @@ -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} diff --git a/plugins/starterPackAppClipExtension/withFiles.js b/plugins/starterPackAppClipExtension/withFiles.js new file mode 100644 index 000000000..ad99f5ae4 --- /dev/null +++ b/plugins/starterPackAppClipExtension/withFiles.js @@ -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} diff --git a/plugins/starterPackAppClipExtension/withStarterPackAppClip.js b/plugins/starterPackAppClipExtension/withStarterPackAppClip.js new file mode 100644 index 000000000..1e3f0b702 --- /dev/null +++ b/plugins/starterPackAppClipExtension/withStarterPackAppClip.js @@ -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 diff --git a/plugins/starterPackAppClipExtension/withXcodeTarget.js b/plugins/starterPackAppClipExtension/withXcodeTarget.js new file mode 100644 index 000000000..61d5f81b0 --- /dev/null +++ b/plugins/starterPackAppClipExtension/withXcodeTarget.js @@ -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} diff --git a/scripts/updateExtensions.sh b/scripts/updateExtensions.sh index f3e972aa7..b01134eeb 100755 --- a/scripts/updateExtensions.sh +++ b/scripts/updateExtensions.sh @@ -1,6 +1,7 @@ #!/bin/bash IOS_SHARE_EXTENSION_DIRECTORY="./ios/Share-with-Bluesky" IOS_NOTIFICATION_EXTENSION_DIRECTORY="./ios/BlueskyNSE" +IOS_APP_CLIP_DIRECTORY="./ios/BlueskyClip" MODULES_DIRECTORY="./modules" if [ ! -d $IOS_SHARE_EXTENSION_DIRECTORY ]; then @@ -16,3 +17,11 @@ if [ ! -d $IOS_NOTIFICATION_EXTENSION_DIRECTORY ]; then else cp -R $IOS_NOTIFICATION_EXTENSION_DIRECTORY $MODULES_DIRECTORY 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 diff --git a/src/App.native.tsx b/src/App.native.tsx index 4c73d8752..639276a12 100644 --- a/src/App.native.tsx +++ b/src/App.native.tsx @@ -46,11 +46,13 @@ import {readLastActiveAccount} from '#/state/session/util' import {Provider as ShellStateProvider} from '#/state/shell' import {Provider as LoggedOutViewProvider} from '#/state/shell/logged-out' 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 * as Toast from '#/view/com/util/Toast' import {Shell} from '#/view/shell' import {ThemeProvider as Alf} from '#/alf' import {useColorModeTheme} from '#/alf/util/useColorModeTheme' +import {useStarterPackEntry} from '#/components/hooks/useStarterPackEntry' import {Provider as PortalProvider} from '#/components/Portal' import {Splash} from '#/Splash' import {BackgroundNotificationPreferencesProvider} from '../modules/expo-background-notification-handler/src/BackgroundNotificationHandlerProvider' @@ -67,6 +69,7 @@ function InnerApp() { const {_} = useLingui() useIntentHandler() + const hasCheckedReferrer = useStarterPackEntry() // init useEffect(() => { @@ -98,7 +101,7 @@ function InnerApp() { <SafeAreaProvider initialMetrics={initialWindowMetrics}> <Alf theme={theme}> <ThemeProvider theme={theme}> - <Splash isReady={isReady}> + <Splash isReady={isReady && hasCheckedReferrer}> <RootSiblingParent> <React.Fragment // Resets the entire tree below when it changes: @@ -164,7 +167,9 @@ function App() { <LightboxStateProvider> <I18nProvider> <PortalProvider> - <InnerApp /> + <StarterPackProvider> + <InnerApp /> + </StarterPackProvider> </PortalProvider> </I18nProvider> </LightboxStateProvider> diff --git a/src/App.web.tsx b/src/App.web.tsx index 00939c9eb..31a59d97d 100644 --- a/src/App.web.tsx +++ b/src/App.web.tsx @@ -35,11 +35,13 @@ import {readLastActiveAccount} from '#/state/session/util' import {Provider as ShellStateProvider} from '#/state/shell' import {Provider as LoggedOutViewProvider} from '#/state/shell/logged-out' 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 {ToastContainer} from '#/view/com/util/Toast.web' import {Shell} from '#/view/shell/index' import {ThemeProvider as Alf} from '#/alf' import {useColorModeTheme} from '#/alf/util/useColorModeTheme' +import {useStarterPackEntry} from '#/components/hooks/useStarterPackEntry' import {Provider as PortalProvider} from '#/components/Portal' import {BackgroundNotificationPreferencesProvider} from '../modules/expo-background-notification-handler/src/BackgroundNotificationHandlerProvider' import I18nProvider from './locale/i18nProvider' @@ -52,6 +54,7 @@ function InnerApp() { const theme = useColorModeTheme() const {_} = useLingui() useIntentHandler() + const hasCheckedReferrer = useStarterPackEntry() // init useEffect(() => { @@ -77,7 +80,7 @@ function InnerApp() { }, [_]) // wait for session to resume - if (!isReady) return null + if (!isReady || !hasCheckedReferrer) return null return ( <KeyboardProvider enabled={false}> @@ -146,7 +149,9 @@ function App() { <LightboxStateProvider> <I18nProvider> <PortalProvider> - <InnerApp /> + <StarterPackProvider> + <InnerApp /> + </StarterPackProvider> </PortalProvider> </I18nProvider> </LightboxStateProvider> diff --git a/src/Navigation.tsx b/src/Navigation.tsx index f2b7cd911..5cb4f4105 100644 --- a/src/Navigation.tsx +++ b/src/Navigation.tsx @@ -43,6 +43,8 @@ import HashtagScreen from '#/screens/Hashtag' import {ModerationScreen} from '#/screens/Moderation' import {ProfileKnownFollowersScreen} from '#/screens/Profile/KnownFollowers' 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 {useWebScrollRestoration} from './lib/hooks/useWebScrollRestoration' import {attachRouteToLogEvents, logEvent} from './lib/statsig/statsig' @@ -317,6 +319,21 @@ function commonScreens(Stack: typeof HomeTab, unreadCountLabel?: string) { getComponent={() => FeedsScreen} 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, }}> <HomeTab.Screen name="Home" getComponent={() => HomeScreen} /> + <HomeTab.Screen name="Start" getComponent={() => HomeScreen} /> {commonScreens(HomeTab)} </HomeTab.Navigator> ) @@ -507,6 +525,11 @@ const FlatNavigator = () => { getComponent={() => MessagesScreen} options={{title: title(msg`Messages`), requireAuth: true}} /> + <Flat.Screen + name="Start" + getComponent={() => HomeScreen} + options={{title: title(msg`Home`)}} + /> {commonScreens(Flat as typeof HomeTab, numUnread)} </Flat.Navigator> ) diff --git a/src/components/LinearGradientBackground.tsx b/src/components/LinearGradientBackground.tsx new file mode 100644 index 000000000..f516b19f5 --- /dev/null +++ b/src/components/LinearGradientBackground.tsx @@ -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> + ) +} diff --git a/src/components/NewskieDialog.tsx b/src/components/NewskieDialog.tsx index 0354bfc43..6743a592b 100644 --- a/src/components/NewskieDialog.tsx +++ b/src/components/NewskieDialog.tsx @@ -9,11 +9,13 @@ import {useGetTimeAgo} from '#/lib/hooks/useTimeAgo' import {useModerationOpts} from '#/state/preferences/moderation-opts' import {HITSLOP_10} from 'lib/constants' import {sanitizeDisplayName} from 'lib/strings/display-names' -import {atoms as a} from '#/alf' -import {Button} from '#/components/Button' +import {isWeb} from 'platform/detection' +import {atoms as a, useTheme} from '#/alf' +import {Button, ButtonText} from '#/components/Button' import * as Dialog from '#/components/Dialog' import {useDialogControl} from '#/components/Dialog' import {Newskie} from '#/components/icons/Newskie' +import * as StarterPackCard from '#/components/StarterPack/StarterPackCard' import {Text} from '#/components/Typography' export function NewskieDialog({ @@ -24,6 +26,7 @@ export function NewskieDialog({ disabled?: boolean }) { const {_} = useLingui() + const t = useTheme() const moderationOpts = useModerationOpts() const control = useDialogControl() const profileName = React.useMemo(() => { @@ -68,15 +71,62 @@ export function NewskieDialog({ label={_(msg`New user info dialog`)} style={[{width: 'auto', maxWidth: 400, minWidth: 200}]}> <View style={[a.gap_sm]}> - <Text style={[a.font_bold, a.text_xl]}> - <Trans>Say hello!</Trans> - </Text> - <Text style={[a.text_md]}> - <Trans> - {profileName} joined Bluesky{' '} - {timeAgo(createdAt, now, {format: 'long'})} ago - </Trans> + <View style={[a.align_center]}> + <Newskie + width={64} + height={64} + fill="#FFC404" + style={{marginTop: -10}} + /> + <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> + {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> </Dialog.ScrollableInner> </Dialog.Outer> diff --git a/src/components/ProfileCard.tsx b/src/components/ProfileCard.tsx new file mode 100644 index 000000000..a0d222854 --- /dev/null +++ b/src/components/ProfileCard.tsx @@ -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> + ) +} diff --git a/src/components/ReportDialog/SelectReportOptionView.tsx b/src/components/ReportDialog/SelectReportOptionView.tsx index 4413cbe89..169c07d73 100644 --- a/src/components/ReportDialog/SelectReportOptionView.tsx +++ b/src/components/ReportDialog/SelectReportOptionView.tsx @@ -55,6 +55,9 @@ export function SelectReportOptionView({ } else if (props.params.type === 'feedgen') { title = _(msg`Report this feed`) 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') { title = _(msg`Report this message`) description = _(msg`Why should this message be reviewed?`) diff --git a/src/components/ReportDialog/types.ts b/src/components/ReportDialog/types.ts index ceabe0b90..3f43db4a1 100644 --- a/src/components/ReportDialog/types.ts +++ b/src/components/ReportDialog/types.ts @@ -4,7 +4,7 @@ export type ReportDialogProps = { control: Dialog.DialogOuterProps['control'] params: | { - type: 'post' | 'list' | 'feedgen' | 'other' + type: 'post' | 'list' | 'feedgen' | 'starterpack' | 'other' uri: string cid: string } diff --git a/src/components/StarterPack/Main/FeedsList.tsx b/src/components/StarterPack/Main/FeedsList.tsx new file mode 100644 index 000000000..e350a422c --- /dev/null +++ b/src/components/StarterPack/Main/FeedsList.tsx @@ -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} + /> + ) + }, +) diff --git a/src/components/StarterPack/Main/ProfilesList.tsx b/src/components/StarterPack/Main/ProfilesList.tsx new file mode 100644 index 000000000..72d35fe2b --- /dev/null +++ b/src/components/StarterPack/Main/ProfilesList.tsx @@ -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) + }} + /> + ) + }, +) diff --git a/src/components/StarterPack/ProfileStarterPacks.tsx b/src/components/StarterPack/ProfileStarterPacks.tsx new file mode 100644 index 000000000..096f04f2d --- /dev/null +++ b/src/components/StarterPack/ProfileStarterPacks.tsx @@ -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> + ) +} diff --git a/src/components/StarterPack/QrCode.tsx b/src/components/StarterPack/QrCode.tsx new file mode 100644 index 000000000..08ee03d62 --- /dev/null +++ b/src/components/StarterPack/QrCode.tsx @@ -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, + }} + /> + ) +} diff --git a/src/components/StarterPack/QrCodeDialog.tsx b/src/components/StarterPack/QrCodeDialog.tsx new file mode 100644 index 000000000..580c6cc7c --- /dev/null +++ b/src/components/StarterPack/QrCodeDialog.tsx @@ -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> + ) +} diff --git a/src/components/StarterPack/ShareDialog.tsx b/src/components/StarterPack/ShareDialog.tsx new file mode 100644 index 000000000..23fa10fb3 --- /dev/null +++ b/src/components/StarterPack/ShareDialog.tsx @@ -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> + </> + ) +} diff --git a/src/components/StarterPack/StarterPackCard.tsx b/src/components/StarterPack/StarterPackCard.tsx new file mode 100644 index 000000000..ab904d7ff --- /dev/null +++ b/src/components/StarterPack/StarterPackCard.tsx @@ -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> + ) +} diff --git a/src/components/StarterPack/Wizard/ScreenTransition.tsx b/src/components/StarterPack/Wizard/ScreenTransition.tsx new file mode 100644 index 000000000..b7cd4e4c1 --- /dev/null +++ b/src/components/StarterPack/Wizard/ScreenTransition.tsx @@ -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> + ) +} diff --git a/src/components/StarterPack/Wizard/WizardEditListDialog.tsx b/src/components/StarterPack/Wizard/WizardEditListDialog.tsx new file mode 100644 index 000000000..bf250ac35 --- /dev/null +++ b/src/components/StarterPack/Wizard/WizardEditListDialog.tsx @@ -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> + ) +} diff --git a/src/components/StarterPack/Wizard/WizardListCard.tsx b/src/components/StarterPack/Wizard/WizardListCard.tsx new file mode 100644 index 000000000..f1332011d --- /dev/null +++ b/src/components/StarterPack/Wizard/WizardListCard.tsx @@ -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} + /> + ) +} diff --git a/src/components/forms/TextField.tsx b/src/components/forms/TextField.tsx index f7a827b49..d513a6db9 100644 --- a/src/components/forms/TextField.tsx +++ b/src/components/forms/TextField.tsx @@ -140,6 +140,7 @@ export function createInput(Component: typeof TextInput) { onChangeText, isInvalid, inputRef, + style, ...rest }: InputProps) { const t = useTheme() @@ -206,6 +207,7 @@ export function createInput(Component: typeof TextInput) { android({ paddingBottom: 16, }), + style, ]} /> diff --git a/src/components/hooks/useStarterPackEntry.native.ts b/src/components/hooks/useStarterPackEntry.native.ts new file mode 100644 index 000000000..b6e4ab05b --- /dev/null +++ b/src/components/hooks/useStarterPackEntry.native.ts @@ -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 +} diff --git a/src/components/hooks/useStarterPackEntry.ts b/src/components/hooks/useStarterPackEntry.ts new file mode 100644 index 000000000..dba801e09 --- /dev/null +++ b/src/components/hooks/useStarterPackEntry.ts @@ -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 +} diff --git a/src/components/icons/QrCode.tsx b/src/components/icons/QrCode.tsx new file mode 100644 index 000000000..e841071f7 --- /dev/null +++ b/src/components/icons/QrCode.tsx @@ -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', +}) diff --git a/src/components/icons/StarterPack.tsx b/src/components/icons/StarterPack.tsx new file mode 100644 index 000000000..8c678bca4 --- /dev/null +++ b/src/components/icons/StarterPack.tsx @@ -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', + ], +}) diff --git a/src/components/icons/TEMPLATE.tsx b/src/components/icons/TEMPLATE.tsx index f49c4280b..47a5c36b2 100644 --- a/src/components/icons/TEMPLATE.tsx +++ b/src/components/icons/TEMPLATE.tsx @@ -30,7 +30,7 @@ export const IconTemplate_Stroke2_Corner0_Rounded = React.forwardRef( export function createSinglePathSVG({path}: {path: string}) { 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 ( <Svg @@ -41,8 +41,37 @@ export function createSinglePathSVG({path}: {path: string}) { width={size} height={size} style={[style]}> + {gradient} <Path fill={fill} fillRule="evenodd" clipRule="evenodd" d={path} /> </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> + ) + }) +} diff --git a/src/components/icons/common.ts b/src/components/icons/common.ts deleted file mode 100644 index 669c157f5..000000000 --- a/src/components/icons/common.ts +++ /dev/null @@ -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, - } -} diff --git a/src/components/icons/common.tsx b/src/components/icons/common.tsx new file mode 100644 index 000000000..662718338 --- /dev/null +++ b/src/components/icons/common.tsx @@ -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, + } +} diff --git a/src/lib/browser.native.ts b/src/lib/browser.native.ts index fb9be56f1..8e045138c 100644 --- a/src/lib/browser.native.ts +++ b/src/lib/browser.native.ts @@ -1,3 +1,4 @@ export const isSafari = false export const isFirefox = false export const isTouchDevice = true +export const isAndroidWeb = false diff --git a/src/lib/browser.ts b/src/lib/browser.ts index d178a9a64..08c43fbfd 100644 --- a/src/lib/browser.ts +++ b/src/lib/browser.ts @@ -5,3 +5,5 @@ export const isSafari = /^((?!chrome|android).)*safari/i.test( export const isFirefox = /firefox|fxios/i.test(navigator.userAgent) export const isTouchDevice = 'ontouchstart' in window || navigator.maxTouchPoints > 1 +export const isAndroidWeb = + /android/i.test(navigator.userAgent) && isTouchDevice diff --git a/src/lib/generate-starterpack.ts b/src/lib/generate-starterpack.ts new file mode 100644 index 000000000..64d30a954 --- /dev/null +++ b/src/lib/generate-starterpack.ts @@ -0,0 +1,164 @@ +import { + AppBskyActorDefs, + AppBskyGraphGetStarterPack, + BskyAgent, + Facet, +} from '@atproto/api' +import {msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' +import {useMutation} from '@tanstack/react-query' + +import {until} from 'lib/async/until' +import {sanitizeDisplayName} from 'lib/strings/display-names' +import {sanitizeHandle} from 'lib/strings/handles' +import {enforceLen} from 'lib/strings/helpers' +import {useAgent} from 'state/session' + +export const createStarterPackList = async ({ + name, + description, + descriptionFacets, + profiles, + agent, +}: { + name: string + description?: string + descriptionFacets?: Facet[] + profiles: AppBskyActorDefs.ProfileViewBasic[] + agent: BskyAgent +}): Promise<{uri: string; cid: string}> => { + if (profiles.length === 0) throw new Error('No profiles given') + + const list = await agent.app.bsky.graph.list.create( + {repo: agent.session!.did}, + { + name, + description, + descriptionFacets, + avatar: undefined, + createdAt: new Date().toISOString(), + purpose: 'app.bsky.graph.defs#referencelist', + }, + ) + if (!list) throw new Error('List creation failed') + await agent.com.atproto.repo.applyWrites({ + repo: agent.session!.did, + writes: [ + createListItem({did: agent.session!.did, listUri: list.uri}), + ].concat( + profiles + // Ensure we don't have ourselves in this list twice + .filter(p => p.did !== agent.session!.did) + .map(p => createListItem({did: p.did, listUri: list.uri})), + ), + }) + + return list +} + +export function useGenerateStarterPackMutation({ + onSuccess, + onError, +}: { + onSuccess: ({uri, cid}: {uri: string; cid: string}) => void + onError: (e: Error) => void +}) { + const {_} = useLingui() + const agent = useAgent() + const starterPackString = _(msg`Starter Pack`) + + return useMutation<{uri: string; cid: string}, Error, void>({ + mutationFn: async () => { + let profile: AppBskyActorDefs.ProfileViewBasic | undefined + let profiles: AppBskyActorDefs.ProfileViewBasic[] | undefined + + await Promise.all([ + (async () => { + profile = ( + await agent.app.bsky.actor.getProfile({ + actor: agent.session!.did, + }) + ).data + })(), + (async () => { + profiles = ( + await agent.app.bsky.actor.searchActors({ + q: encodeURIComponent('*'), + limit: 49, + }) + ).data.actors.filter(p => p.viewer?.following) + })(), + ]) + + if (!profile || !profiles) { + throw new Error('ERROR_DATA') + } + + // We include ourselves when we make the list + if (profiles.length < 7) { + throw new Error('NOT_ENOUGH_FOLLOWERS') + } + + const displayName = enforceLen( + profile.displayName + ? sanitizeDisplayName(profile.displayName) + : `@${sanitizeHandle(profile.handle)}`, + 25, + true, + ) + const starterPackName = `${displayName}'s ${starterPackString}` + + const list = await createStarterPackList({ + name: starterPackName, + profiles, + agent, + }) + + return await agent.app.bsky.graph.starterpack.create( + { + repo: agent.session!.did, + }, + { + name: starterPackName, + list: list.uri, + createdAt: new Date().toISOString(), + }, + ) + }, + onSuccess: async data => { + await whenAppViewReady(agent, data.uri, v => { + return typeof v?.data.starterPack.uri === 'string' + }) + onSuccess(data) + }, + onError: error => { + onError(error) + }, + }) +} + +function createListItem({did, listUri}: {did: string; listUri: string}) { + return { + $type: 'com.atproto.repo.applyWrites#create', + collection: 'app.bsky.graph.listitem', + value: { + $type: 'app.bsky.graph.listitem', + subject: did, + list: listUri, + createdAt: new Date().toISOString(), + }, + } +} + +async function whenAppViewReady( + agent: BskyAgent, + uri: string, + fn: (res?: AppBskyGraphGetStarterPack.Response) => boolean, +) { + await until( + 5, // 5 tries + 1e3, // 1s delay between tries + fn, + () => agent.app.bsky.graph.getStarterPack({starterPack: uri}), + ) +} diff --git a/src/lib/hooks/useBottomBarOffset.ts b/src/lib/hooks/useBottomBarOffset.ts new file mode 100644 index 000000000..945c98062 --- /dev/null +++ b/src/lib/hooks/useBottomBarOffset.ts @@ -0,0 +1,14 @@ +import {useSafeAreaInsets} from 'react-native-safe-area-context' + +import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' +import {clamp} from 'lib/numbers' +import {isWeb} from 'platform/detection' + +export function useBottomBarOffset(modifier: number = 0) { + const {isTabletOrDesktop} = useWebMediaQueries() + const {bottom: bottomInset} = useSafeAreaInsets() + return ( + (isWeb && isTabletOrDesktop ? 0 : clamp(60 + bottomInset, 60, 75)) + + modifier + ) +} diff --git a/src/lib/hooks/useNotificationHandler.ts b/src/lib/hooks/useNotificationHandler.ts index 347062beb..e4e7e1474 100644 --- a/src/lib/hooks/useNotificationHandler.ts +++ b/src/lib/hooks/useNotificationHandler.ts @@ -26,6 +26,7 @@ type NotificationReason = | 'reply' | 'quote' | 'chat-message' + | 'starterpack-joined' type NotificationPayload = | { @@ -142,6 +143,7 @@ export function useNotificationsHandler() { case 'mention': case 'quote': case 'reply': + case 'starterpack-joined': resetToTab('NotificationsTab') break // TODO implement these after we have an idea of how to handle each individual case diff --git a/src/lib/moderation/create-sanitized-display-name.ts b/src/lib/moderation/create-sanitized-display-name.ts new file mode 100644 index 000000000..16135b274 --- /dev/null +++ b/src/lib/moderation/create-sanitized-display-name.ts @@ -0,0 +1,21 @@ +import {AppBskyActorDefs} from '@atproto/api' + +import {sanitizeDisplayName} from 'lib/strings/display-names' +import {sanitizeHandle} from 'lib/strings/handles' + +export function createSanitizedDisplayName( + profile: + | AppBskyActorDefs.ProfileViewBasic + | AppBskyActorDefs.ProfileViewDetailed, + noAt = false, +) { + if (profile.displayName != null && profile.displayName !== '') { + return sanitizeDisplayName(profile.displayName) + } else { + let sanitizedHandle = sanitizeHandle(profile.handle) + if (!noAt) { + sanitizedHandle = `@${sanitizedHandle}` + } + return sanitizedHandle + } +} diff --git a/src/lib/moderation/useReportOptions.ts b/src/lib/moderation/useReportOptions.ts index 54b727b76..91656857e 100644 --- a/src/lib/moderation/useReportOptions.ts +++ b/src/lib/moderation/useReportOptions.ts @@ -13,6 +13,7 @@ interface ReportOptions { account: ReportOption[] post: ReportOption[] list: ReportOption[] + starterpack: ReportOption[] feedgen: ReportOption[] other: ReportOption[] convoMessage: ReportOption[] @@ -94,6 +95,14 @@ export function useReportOptions(): ReportOptions { }, ...common, ], + starterpack: [ + { + reason: ComAtprotoModerationDefs.REASONVIOLATION, + title: _(msg`Name or Description Violates Community Standards`), + description: _(msg`Terms used violate community standards`), + }, + ...common, + ], feedgen: [ { reason: ComAtprotoModerationDefs.REASONVIOLATION, diff --git a/src/lib/routes/links.ts b/src/lib/routes/links.ts index 9dfdab909..56b716677 100644 --- a/src/lib/routes/links.ts +++ b/src/lib/routes/links.ts @@ -1,3 +1,5 @@ +import {AppBskyGraphDefs, AtUri} from '@atproto/api' + import {isInvalidHandle} from 'lib/strings/handles' export function makeProfileLink( @@ -35,3 +37,18 @@ export function makeSearchLink(props: {query: string; from?: 'me' | string}) { props.query + (props.from ? ` from:${props.from}` : ''), )}` } + +export function makeStarterPackLink( + starterPackOrName: + | AppBskyGraphDefs.StarterPackViewBasic + | AppBskyGraphDefs.StarterPackView + | string, + rkey?: string, +) { + if (typeof starterPackOrName === 'string') { + return `https://bsky.app/start/${starterPackOrName}/${rkey}` + } else { + const uriRkey = new AtUri(starterPackOrName.uri).rkey + return `https://bsky.app/start/${starterPackOrName.creator.handle}/${uriRkey}` + } +} diff --git a/src/lib/routes/types.ts b/src/lib/routes/types.ts index 403c2bb67..8a173b675 100644 --- a/src/lib/routes/types.ts +++ b/src/lib/routes/types.ts @@ -42,6 +42,12 @@ export type CommonNavigatorParams = { MessagesConversation: {conversation: string; embed?: string} MessagesSettings: undefined Feeds: undefined + Start: {name: string; rkey: string} + StarterPack: {name: string; rkey: string; new?: boolean} + StarterPackWizard: undefined + StarterPackEdit: { + rkey?: string + } } export type BottomTabNavigatorParams = CommonNavigatorParams & { @@ -93,6 +99,12 @@ export type AllNavigatorParams = CommonNavigatorParams & { Hashtag: {tag: string; author?: string} MessagesTab: undefined Messages: {animation?: 'push' | 'pop'} + Start: {name: string; rkey: string} + StarterPack: {name: string; rkey: string; new?: boolean} + StarterPackWizard: undefined + StarterPackEdit: { + rkey?: string + } } // NOTE diff --git a/src/lib/statsig/events.ts b/src/lib/statsig/events.ts index 2e8cedb54..07ed8c0ca 100644 --- a/src/lib/statsig/events.ts +++ b/src/lib/statsig/events.ts @@ -53,7 +53,14 @@ export type LogEvents = { } 'onboarding:moderation:nextPressed': {} 'onboarding:profile:nextPressed': {} - 'onboarding:finished:nextPressed': {} + 'onboarding:finished:nextPressed': { + usedStarterPack: boolean + starterPackName?: string + starterPackCreator?: string + starterPackUri?: string + profilesFollowed: number + feedsPinned: number + } 'onboarding:finished:avatarResult': { avatarResult: 'default' | 'created' | 'uploaded' } @@ -61,7 +68,12 @@ export type LogEvents = { feedUrl: string feedType: string index: number - reason: 'focus' | 'tabbar-click' | 'pager-swipe' | 'desktop-sidebar-click' + reason: + | 'focus' + | 'tabbar-click' + | 'pager-swipe' + | 'desktop-sidebar-click' + | 'starter-pack-initial-feed' } 'feed:endReached:sampled': { feedUrl: string @@ -134,6 +146,7 @@ export type LogEvents = { | 'ProfileMenu' | 'ProfileHoverCard' | 'AvatarButton' + | 'StarterPackProfilesList' } 'profile:unfollow': { logContext: @@ -146,6 +159,7 @@ export type LogEvents = { | 'ProfileHoverCard' | 'Chat' | 'AvatarButton' + | 'StarterPackProfilesList' } 'chat:create': { logContext: 'ProfileHeader' | 'NewChatDialog' | 'SendViaChatDialog' @@ -157,6 +171,23 @@ export type LogEvents = { | 'ChatsList' | 'SendViaChatDialog' } + 'starterPack:share': { + starterPack: string + shareType: 'link' | 'qrcode' + qrShareType?: 'save' | 'copy' | 'share' + } + 'starterPack:followAll': { + logContext: 'StarterPackProfilesList' | 'Onboarding' + starterPack: string + count: number + } + 'starterPack:delete': {} + 'starterPack:create': { + setName: boolean + setDescription: boolean + profilesCount: number + feedsCount: number + } 'test:all:always': {} 'test:all:sometimes': {} diff --git a/src/lib/statsig/gates.ts b/src/lib/statsig/gates.ts index 46ef934ef..bf2484ccb 100644 --- a/src/lib/statsig/gates.ts +++ b/src/lib/statsig/gates.ts @@ -5,3 +5,4 @@ export type Gate = | 'request_notifications_permission_after_onboarding_v2' | 'show_avi_follow_button' | 'show_follow_back_label_v2' + | 'starter_packs_enabled' diff --git a/src/lib/strings/starter-pack.ts b/src/lib/strings/starter-pack.ts new file mode 100644 index 000000000..489d0b923 --- /dev/null +++ b/src/lib/strings/starter-pack.ts @@ -0,0 +1,101 @@ +import {AppBskyGraphDefs, AtUri} from '@atproto/api' + +export function createStarterPackLinkFromAndroidReferrer( + referrerQueryString: string, +): string | null { + try { + // The referrer string is just some URL parameters, so lets add them to a fake URL + const url = new URL('http://throwaway.com/?' + referrerQueryString) + const utmContent = url.searchParams.get('utm_content') + const utmSource = url.searchParams.get('utm_source') + + if (!utmContent) return null + if (utmSource !== 'bluesky') return null + + // This should be a string like `starterpack_haileyok.com_rkey` + const contentParts = utmContent.split('_') + + if (contentParts[0] !== 'starterpack') return null + if (contentParts.length !== 3) return null + + return `at://${contentParts[1]}/app.bsky.graph.starterpack/${contentParts[2]}` + } catch (e) { + return null + } +} + +export function parseStarterPackUri(uri?: string): { + name: string + rkey: string +} | null { + if (!uri) return null + + try { + if (uri.startsWith('at://')) { + const atUri = new AtUri(uri) + if (atUri.collection !== 'app.bsky.graph.starterpack') return null + if (atUri.rkey) { + return { + name: atUri.hostname, + rkey: atUri.rkey, + } + } + return null + } else { + const url = new URL(uri) + const parts = url.pathname.split('/') + const [_, path, name, rkey] = parts + + if (parts.length !== 4) return null + if (path !== 'starter-pack' && path !== 'start') return null + if (!name || !rkey) return null + return { + name, + rkey, + } + } + } catch (e) { + return null + } +} + +export function createStarterPackGooglePlayUri( + name: string, + rkey: string, +): string | null { + if (!name || !rkey) return null + return `https://play.google.com/store/apps/details?id=xyz.blueskyweb.app&referrer=utm_source%3Dbluesky%26utm_medium%3Dstarterpack%26utm_content%3Dstarterpack_${name}_${rkey}` +} + +export function httpStarterPackUriToAtUri(httpUri?: string): string | null { + if (!httpUri) return null + + const parsed = parseStarterPackUri(httpUri) + if (!parsed) return null + + if (httpUri.startsWith('at://')) return httpUri + + return `at://${parsed.name}/app.bsky.graph.starterpack/${parsed.rkey}` +} + +export function getStarterPackOgCard( + didOrStarterPack: AppBskyGraphDefs.StarterPackView | string, + rkey?: string, +) { + if (typeof didOrStarterPack === 'string') { + return `https://ogcard.cdn.bsky.app/start/${didOrStarterPack}/${rkey}` + } else { + const rkey = new AtUri(didOrStarterPack.uri).rkey + return `https://ogcard.cdn.bsky.app/start/${didOrStarterPack.creator.did}/${rkey}` + } +} + +export function createStarterPackUri({ + did, + rkey, +}: { + did: string + rkey: string +}): string | null { + return new AtUri(`at://${did}/app.bsky.graph.starterpack/${rkey}`).toString() +} diff --git a/src/routes.ts b/src/routes.ts index de711f5dc..f241d37a0 100644 --- a/src/routes.ts +++ b/src/routes.ts @@ -41,4 +41,8 @@ export const router = new Router({ Messages: '/messages', MessagesSettings: '/messages/settings', MessagesConversation: '/messages/:conversation', + Start: '/start/:name/:rkey', + StarterPackEdit: '/starter-pack/edit/:rkey', + StarterPack: '/starter-pack/:name/:rkey', + StarterPackWizard: '/starter-pack/create', }) diff --git a/src/screens/Login/LoginForm.tsx b/src/screens/Login/LoginForm.tsx index dfa10668b..7cfd38e34 100644 --- a/src/screens/Login/LoginForm.tsx +++ b/src/screens/Login/LoginForm.tsx @@ -21,6 +21,7 @@ import {logger} from '#/logger' import {useSessionApi} from '#/state/session' import {useLoggedOutViewControls} from '#/state/shell/logged-out' import {useRequestNotificationsPermission} from 'lib/notifications/notifications' +import {useSetHasCheckedForStarterPack} from 'state/preferences/used-starter-packs' import {atoms as a, useTheme} from '#/alf' import {Button, ButtonIcon, ButtonText} from '#/components/Button' import {FormError} from '#/components/forms/FormError' @@ -69,6 +70,7 @@ export const LoginForm = ({ const {login} = useSessionApi() const requestNotificationsPermission = useRequestNotificationsPermission() const {setShowLoggedOut} = useLoggedOutViewControls() + const setHasCheckedForStarterPack = useSetHasCheckedForStarterPack() const onPressSelectService = React.useCallback(() => { Keyboard.dismiss() @@ -116,6 +118,7 @@ export const LoginForm = ({ 'LoginForm', ) setShowLoggedOut(false) + setHasCheckedForStarterPack(true) requestNotificationsPermission('Login') } catch (e: any) { const errMsg = e.toString() diff --git a/src/screens/Login/ScreenTransition.tsx b/src/screens/Login/ScreenTransition.tsx index ab0a22367..6fad26680 100644 --- a/src/screens/Login/ScreenTransition.tsx +++ b/src/screens/Login/ScreenTransition.tsx @@ -1,9 +1,16 @@ import React from 'react' +import {StyleProp, ViewStyle} from 'react-native' 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 ( - <Animated.View entering={FadeInRight} exiting={FadeOutLeft}> + <Animated.View style={style} entering={FadeInRight} exiting={FadeOutLeft}> {children} </Animated.View> ) diff --git a/src/screens/Onboarding/StepFinished.tsx b/src/screens/Onboarding/StepFinished.tsx index c75dd4fa7..c7a459659 100644 --- a/src/screens/Onboarding/StepFinished.tsx +++ b/src/screens/Onboarding/StepFinished.tsx @@ -1,11 +1,18 @@ import React from 'react' 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 {useLingui} from '@lingui/react' import {useQueryClient} from '@tanstack/react-query' 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 {logger} from '#/logger' import {preferencesQueryKey} from '#/state/queries/preferences' @@ -14,6 +21,11 @@ import {useAgent} from '#/state/session' import {useOnboardingDispatch} from '#/state/shell' import {uploadBlob} from 'lib/api' import {useRequestNotificationsPermission} from 'lib/notifications/notifications' +import {useSetHasCheckedForStarterPack} from 'state/preferences/used-starter-packs' +import { + useActiveStarterPack, + useSetActiveStarterPack, +} from 'state/shell/starter-pack' import { DescriptionText, OnboardingControls, @@ -41,17 +53,74 @@ export function StepFinished() { const queryClient = useQueryClient() const agent = useAgent() const requestNotificationsPermission = useRequestNotificationsPermission() + const activeStarterPack = useActiveStarterPack() + const setActiveStarterPack = useSetActiveStarterPack() + const setHasCheckedForStarterPack = useSetHasCheckedForStarterPack() const finishOnboarding = React.useCallback(async () => { setSaving(true) - const {interestsStepResults, profileStepResults} = state - const {selectedInterests} = interestsStepResults + let starterPack: AppBskyGraphDefs.StarterPackView | undefined + 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 { + const {interestsStepResults, profileStepResults} = state + const {selectedInterests} = interestsStepResults + await Promise.all([ - bulkWriteFollows(agent, [BSKY_APP_ACCOUNT_DID]), + bulkWriteFollows(agent, [ + BSKY_APP_ACCOUNT_DID, + ...(listItems?.map(i => i.subject.did) ?? []), + ]), (async () => { + // Interests need to get saved first, then we can write the feeds to prefs 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 () => { const {imageUri, imageMime} = profileStepResults @@ -63,9 +132,24 @@ export function StepFinished() { if (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 }) } + logEvent('onboarding:finished:avatarResult', { avatarResult: profileStepResults.isCreatedAvatar ? 'created' @@ -96,19 +180,40 @@ export function StepFinished() { }) setSaving(false) + setActiveStarterPack(undefined) + setHasCheckedForStarterPack(true) dispatch({type: 'finish'}) onboardDispatch({type: 'finish'}) track('OnboardingV2:StepFinished:End') 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, agent, dispatch, onboardDispatch, track, + activeStarterPack, + state, requestNotificationsPermission, + setActiveStarterPack, + setHasCheckedForStarterPack, ]) React.useEffect(() => { diff --git a/src/screens/Profile/Header/DisplayName.tsx b/src/screens/Profile/Header/DisplayName.tsx index b6d88db71..c63658a44 100644 --- a/src/screens/Profile/Header/DisplayName.tsx +++ b/src/screens/Profile/Header/DisplayName.tsx @@ -1,10 +1,10 @@ import React from 'react' import {View} from 'react-native' 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 {Text} from '#/components/Typography' diff --git a/src/screens/Signup/index.tsx b/src/screens/Signup/index.tsx index 2cc1bcab0..3203d443c 100644 --- a/src/screens/Signup/index.tsx +++ b/src/screens/Signup/index.tsx @@ -1,6 +1,11 @@ import React from 'react' 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 {useLingui} from '@lingui/react' @@ -11,6 +16,8 @@ import {createFullHandle} from '#/lib/strings/handles' import {logger} from '#/logger' import {useServiceQuery} from '#/state/queries/service' 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 { initialState, @@ -26,6 +33,7 @@ import {atoms as a, useBreakpoints, useTheme} from '#/alf' import {AppLanguageDropdown} from '#/components/AppLanguageDropdown' import {Button, ButtonText} from '#/components/Button' import {Divider} from '#/components/Divider' +import {LinearGradientBackground} from '#/components/LinearGradientBackground' import {InlineLinkText} from '#/components/Link' import {Text} from '#/components/Typography' @@ -38,6 +46,11 @@ export function Signup({onPressBack}: {onPressBack: () => void}) { const {gtMobile} = useBreakpoints() const agent = useAgent() + const activeStarterPack = useActiveStarterPack() + const {data: starterPack} = useStarterPackQuery({ + uri: activeStarterPack?.uri, + }) + const { data: serviceInfo, isFetching, @@ -142,6 +155,31 @@ export function Signup({onPressBack}: {onPressBack: () => void}) { description={_(msg`We're so excited to have you join us!`)} scrollable> <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 style={[ a.flex_1, diff --git a/src/screens/StarterPack/StarterPackLandingScreen.tsx b/src/screens/StarterPack/StarterPackLandingScreen.tsx new file mode 100644 index 000000000..1c9587a79 --- /dev/null +++ b/src/screens/StarterPack/StarterPackLandingScreen.tsx @@ -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> + ) +} diff --git a/src/screens/StarterPack/StarterPackScreen.tsx b/src/screens/StarterPack/StarterPackScreen.tsx new file mode 100644 index 000000000..46ce25236 --- /dev/null +++ b/src/screens/StarterPack/StarterPackScreen.tsx @@ -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> + ) +} diff --git a/src/screens/StarterPack/Wizard/State.tsx b/src/screens/StarterPack/Wizard/State.tsx new file mode 100644 index 000000000..ea9bbf9d3 --- /dev/null +++ b/src/screens/StarterPack/Wizard/State.tsx @@ -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, +} diff --git a/src/screens/StarterPack/Wizard/StepDetails.tsx b/src/screens/StarterPack/Wizard/StepDetails.tsx new file mode 100644 index 000000000..24c992c60 --- /dev/null +++ b/src/screens/StarterPack/Wizard/StepDetails.tsx @@ -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> + ) +} diff --git a/src/screens/StarterPack/Wizard/StepFeeds.tsx b/src/screens/StarterPack/Wizard/StepFeeds.tsx new file mode 100644 index 000000000..6752a95db --- /dev/null +++ b/src/screens/StarterPack/Wizard/StepFeeds.tsx @@ -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> + ) +} diff --git a/src/screens/StarterPack/Wizard/StepFinished.tsx b/src/screens/StarterPack/Wizard/StepFinished.tsx new file mode 100644 index 000000000..e69de29bb --- /dev/null +++ b/src/screens/StarterPack/Wizard/StepFinished.tsx diff --git a/src/screens/StarterPack/Wizard/StepProfiles.tsx b/src/screens/StarterPack/Wizard/StepProfiles.tsx new file mode 100644 index 000000000..8fe7f52fe --- /dev/null +++ b/src/screens/StarterPack/Wizard/StepProfiles.tsx @@ -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> + ) +} diff --git a/src/screens/StarterPack/Wizard/index.tsx b/src/screens/StarterPack/Wizard/index.tsx new file mode 100644 index 000000000..76691dc98 --- /dev/null +++ b/src/screens/StarterPack/Wizard/index.tsx @@ -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 '' +} diff --git a/src/state/persisted/schema.ts b/src/state/persisted/schema.ts index c942828f2..88fc370a6 100644 --- a/src/state/persisted/schema.ts +++ b/src/state/persisted/schema.ts @@ -88,6 +88,7 @@ export const schema = z.object({ disableHaptics: z.boolean().optional(), disableAutoplay: z.boolean().optional(), kawaii: z.boolean().optional(), + hasCheckedForStarterPack: z.boolean().optional(), /** @deprecated */ mutedThreads: z.array(z.string()), }) @@ -129,4 +130,5 @@ export const defaults: Schema = { disableHaptics: false, disableAutoplay: prefersReducedMotion, kawaii: false, + hasCheckedForStarterPack: false, } diff --git a/src/state/preferences/index.tsx b/src/state/preferences/index.tsx index e1a35f193..e6b53d5be 100644 --- a/src/state/preferences/index.tsx +++ b/src/state/preferences/index.tsx @@ -9,6 +9,7 @@ import {Provider as InAppBrowserProvider} from './in-app-browser' import {Provider as KawaiiProvider} from './kawaii' import {Provider as LanguagesProvider} from './languages' import {Provider as LargeAltBadgeProvider} from './large-alt-badge' +import {Provider as UsedStarterPacksProvider} from './used-starter-packs' export { useRequireAltTextEnabled, @@ -34,7 +35,9 @@ export function Provider({children}: React.PropsWithChildren<{}>) { <InAppBrowserProvider> <DisableHapticsProvider> <AutoplayProvider> - <KawaiiProvider>{children}</KawaiiProvider> + <UsedStarterPacksProvider> + <KawaiiProvider>{children}</KawaiiProvider> + </UsedStarterPacksProvider> </AutoplayProvider> </DisableHapticsProvider> </InAppBrowserProvider> diff --git a/src/state/preferences/used-starter-packs.tsx b/src/state/preferences/used-starter-packs.tsx new file mode 100644 index 000000000..8d5d9e828 --- /dev/null +++ b/src/state/preferences/used-starter-packs.tsx @@ -0,0 +1,37 @@ +import React from 'react' + +import * as persisted from '#/state/persisted' + +type StateContext = boolean | undefined +type SetContext = (v: boolean) => void + +const stateContext = React.createContext<StateContext>(false) +const setContext = React.createContext<SetContext>((_: boolean) => {}) + +export function Provider({children}: {children: React.ReactNode}) { + const [state, setState] = React.useState<StateContext>(() => + persisted.get('hasCheckedForStarterPack'), + ) + + const setStateWrapped = (v: boolean) => { + setState(v) + persisted.write('hasCheckedForStarterPack', v) + } + + React.useEffect(() => { + return persisted.onUpdate(() => { + setState(persisted.get('hasCheckedForStarterPack')) + }) + }, []) + + return ( + <stateContext.Provider value={state}> + <setContext.Provider value={setStateWrapped}> + {children} + </setContext.Provider> + </stateContext.Provider> + ) +} + +export const useHasCheckedForStarterPack = () => React.useContext(stateContext) +export const useSetHasCheckedForStarterPack = () => React.useContext(setContext) diff --git a/src/state/queries/actor-search.ts b/src/state/queries/actor-search.ts index 1e301a1ba..479fc1a9f 100644 --- a/src/state/queries/actor-search.ts +++ b/src/state/queries/actor-search.ts @@ -1,5 +1,11 @@ -import {AppBskyActorDefs} from '@atproto/api' -import {QueryClient, useQuery} from '@tanstack/react-query' +import {AppBskyActorDefs, AppBskyActorSearchActors} from '@atproto/api' +import { + InfiniteData, + QueryClient, + QueryKey, + useInfiniteQuery, + useQuery, +} from '@tanstack/react-query' import {STALE} from '#/state/queries' import {useAgent} from '#/state/session' @@ -7,6 +13,11 @@ import {useAgent} from '#/state/session' const RQKEY_ROOT = 'actor-search' export const RQKEY = (query: string) => [RQKEY_ROOT, query] +export const RQKEY_PAGINATED = (query: string) => [ + `${RQKEY_ROOT}_paginated`, + query, +] + export function useActorSearch({ query, enabled, @@ -28,6 +39,37 @@ export function useActorSearch({ }) } +export function useActorSearchPaginated({ + query, + enabled, +}: { + query: string + enabled?: boolean +}) { + const agent = useAgent() + return useInfiniteQuery< + AppBskyActorSearchActors.OutputSchema, + Error, + InfiniteData<AppBskyActorSearchActors.OutputSchema>, + QueryKey, + string | undefined + >({ + staleTime: STALE.MINUTES.FIVE, + queryKey: RQKEY_PAGINATED(query), + queryFn: async ({pageParam}) => { + const res = await agent.searchActors({ + q: query, + limit: 25, + cursor: pageParam, + }) + return res.data + }, + enabled: enabled && !!query, + initialPageParam: undefined, + getNextPageParam: lastPage => lastPage.cursor, + }) +} + export function* findAllProfilesInQueryData( queryClient: QueryClient, did: string, diff --git a/src/state/queries/actor-starter-packs.ts b/src/state/queries/actor-starter-packs.ts new file mode 100644 index 000000000..9de80b07d --- /dev/null +++ b/src/state/queries/actor-starter-packs.ts @@ -0,0 +1,47 @@ +import {AppBskyGraphGetActorStarterPacks} from '@atproto/api' +import { + InfiniteData, + QueryClient, + QueryKey, + useInfiniteQuery, +} from '@tanstack/react-query' + +import {useAgent} from 'state/session' + +const RQKEY_ROOT = 'actor-starter-packs' +export const RQKEY = (did?: string) => [RQKEY_ROOT, did] + +export function useActorStarterPacksQuery({did}: {did?: string}) { + const agent = useAgent() + + return useInfiniteQuery< + AppBskyGraphGetActorStarterPacks.OutputSchema, + Error, + InfiniteData<AppBskyGraphGetActorStarterPacks.OutputSchema>, + QueryKey, + string | undefined + >({ + queryKey: RQKEY(did), + queryFn: async ({pageParam}: {pageParam?: string}) => { + const res = await agent.app.bsky.graph.getActorStarterPacks({ + actor: did!, + limit: 10, + cursor: pageParam, + }) + return res.data + }, + enabled: Boolean(did), + initialPageParam: undefined, + getNextPageParam: lastPage => lastPage.cursor, + }) +} + +export async function invalidateActorStarterPacksQuery({ + queryClient, + did, +}: { + queryClient: QueryClient + did: string +}) { + await queryClient.invalidateQueries({queryKey: RQKEY(did)}) +} diff --git a/src/state/queries/feed.ts b/src/state/queries/feed.ts index e5d615177..dea6f5d77 100644 --- a/src/state/queries/feed.ts +++ b/src/state/queries/feed.ts @@ -9,6 +9,7 @@ import { } from '@atproto/api' import { InfiniteData, + keepPreviousData, QueryClient, QueryKey, useInfiniteQuery, @@ -315,6 +316,22 @@ export function useSearchPopularFeedsMutation() { }) } +export function useSearchPopularFeedsQuery({q}: {q: string}) { + const agent = useAgent() + return useQuery({ + queryKey: ['searchPopularFeeds', q], + queryFn: async () => { + const res = await agent.app.bsky.unspecced.getPopularFeedGenerators({ + limit: 15, + query: q, + }) + + return res.data.feeds + }, + placeholderData: keepPreviousData, + }) +} + const popularFeedsSearchQueryKeyRoot = 'popularFeedsSearch' export const createPopularFeedsSearchQueryKey = (query: string) => [ popularFeedsSearchQueryKeyRoot, diff --git a/src/state/queries/list-members.ts b/src/state/queries/list-members.ts index de9a36ab7..3131a2ec3 100644 --- a/src/state/queries/list-members.ts +++ b/src/state/queries/list-members.ts @@ -15,7 +15,7 @@ type RQPageParam = string | undefined const RQKEY_ROOT = 'list-members' export const RQKEY = (uri: string) => [RQKEY_ROOT, uri] -export function useListMembersQuery(uri: string) { +export function useListMembersQuery(uri?: string, limit: number = PAGE_SIZE) { const agent = useAgent() return useInfiniteQuery< AppBskyGraphGetList.OutputSchema, @@ -25,20 +25,31 @@ export function useListMembersQuery(uri: string) { RQPageParam >({ staleTime: STALE.MINUTES.ONE, - queryKey: RQKEY(uri), + queryKey: RQKEY(uri ?? ''), async queryFn({pageParam}: {pageParam: RQPageParam}) { const res = await agent.app.bsky.graph.getList({ - list: uri, - limit: PAGE_SIZE, + list: uri!, // the enabled flag will prevent this from running until uri is set + limit, cursor: pageParam, }) return res.data }, initialPageParam: undefined, getNextPageParam: lastPage => lastPage.cursor, + enabled: Boolean(uri), }) } +export async function invalidateListMembersQuery({ + queryClient, + uri, +}: { + queryClient: QueryClient + uri: string +}) { + await queryClient.invalidateQueries({queryKey: RQKEY(uri)}) +} + export function* findAllProfilesInQueryData( queryClient: QueryClient, did: string, diff --git a/src/state/queries/notifications/feed.ts b/src/state/queries/notifications/feed.ts index 0607f07a1..13ca3ffde 100644 --- a/src/state/queries/notifications/feed.ts +++ b/src/state/queries/notifications/feed.ts @@ -155,8 +155,10 @@ export function* findAllPostsInQueryData( for (const page of queryData?.pages) { for (const item of page.items) { - if (item.subject && didOrHandleUriMatches(atUri, item.subject)) { - yield item.subject + if (item.type !== 'starterpack-joined') { + if (item.subject && didOrHandleUriMatches(atUri, item.subject)) { + yield item.subject + } } const quotedPost = getEmbeddedPost(item.subject?.embed) @@ -181,7 +183,10 @@ export function* findAllProfilesInQueryData( } for (const page of queryData?.pages) { for (const item of page.items) { - if (item.subject?.author.did === did) { + if ( + item.type !== 'starterpack-joined' && + item.subject?.author.did === did + ) { yield item.subject.author } const quotedPost = getEmbeddedPost(item.subject?.embed) diff --git a/src/state/queries/notifications/types.ts b/src/state/queries/notifications/types.ts index 812236cf0..d40a07b12 100644 --- a/src/state/queries/notifications/types.ts +++ b/src/state/queries/notifications/types.ts @@ -1,26 +1,22 @@ import { - AppBskyNotificationListNotifications, AppBskyFeedDefs, + AppBskyGraphDefs, + AppBskyNotificationListNotifications, } from '@atproto/api' export type NotificationType = - | 'post-like' - | 'feedgen-like' - | 'repost' - | 'mention' - | 'reply' - | 'quote' - | 'follow' - | 'unknown' + | StarterPackNotificationType + | OtherNotificationType -export interface FeedNotification { - _reactKey: string - type: NotificationType - notification: AppBskyNotificationListNotifications.Notification - additional?: AppBskyNotificationListNotifications.Notification[] - subjectUri?: string - subject?: AppBskyFeedDefs.PostView -} +export type FeedNotification = + | (FeedNotificationBase & { + type: StarterPackNotificationType + subject?: AppBskyGraphDefs.StarterPackViewBasic + }) + | (FeedNotificationBase & { + type: OtherNotificationType + subject?: AppBskyFeedDefs.PostView + }) export interface FeedPage { cursor: string | undefined @@ -37,3 +33,22 @@ export interface CachedFeedPage { data: FeedPage | undefined unreadCount: number } + +type StarterPackNotificationType = 'starterpack-joined' +type OtherNotificationType = + | 'post-like' + | 'repost' + | 'mention' + | 'reply' + | 'quote' + | 'follow' + | 'feedgen-like' + | 'unknown' + +type FeedNotificationBase = { + _reactKey: string + notification: AppBskyNotificationListNotifications.Notification + additional?: AppBskyNotificationListNotifications.Notification[] + subjectUri?: string + subject?: AppBskyFeedDefs.PostView | AppBskyGraphDefs.StarterPackViewBasic +} diff --git a/src/state/queries/notifications/util.ts b/src/state/queries/notifications/util.ts index 8ed1c0390..ade98b317 100644 --- a/src/state/queries/notifications/util.ts +++ b/src/state/queries/notifications/util.ts @@ -3,6 +3,8 @@ import { AppBskyFeedLike, AppBskyFeedPost, AppBskyFeedRepost, + AppBskyGraphDefs, + AppBskyGraphStarterpack, AppBskyNotificationListNotifications, BskyAgent, moderateNotification, @@ -40,6 +42,7 @@ export async function fetchPage({ limit, cursor, }) + const indexedAt = res.data.notifications[0]?.indexedAt // filter out notifs by mod rules @@ -56,9 +59,18 @@ export async function fetchPage({ const subjects = await fetchSubjects(agent, notifsGrouped) for (const notif of notifsGrouped) { if (notif.subjectUri) { - notif.subject = subjects.get(notif.subjectUri) - if (notif.subject) { - precacheProfile(queryClient, notif.subject.author) + if ( + notif.type === 'starterpack-joined' && + notif.notification.reasonSubject + ) { + notif.subject = subjects.starterPacks.get( + notif.notification.reasonSubject, + ) + } else { + notif.subject = subjects.posts.get(notif.subjectUri) + if (notif.subject) { + precacheProfile(queryClient, notif.subject.author) + } } } } @@ -120,12 +132,21 @@ export function groupNotifications( } if (!grouped) { const type = toKnownType(notif) - groupedNotifs.push({ - _reactKey: `notif-${notif.uri}`, - type, - notification: notif, - subjectUri: getSubjectUri(type, notif), - }) + if (type !== 'starterpack-joined') { + groupedNotifs.push({ + _reactKey: `notif-${notif.uri}`, + type, + notification: notif, + subjectUri: getSubjectUri(type, notif), + }) + } else { + groupedNotifs.push({ + _reactKey: `notif-${notif.uri}`, + type: 'starterpack-joined', + notification: notif, + subjectUri: notif.uri, + }) + } } } return groupedNotifs @@ -134,29 +155,54 @@ export function groupNotifications( async function fetchSubjects( agent: BskyAgent, groupedNotifs: FeedNotification[], -): Promise<Map<string, AppBskyFeedDefs.PostView>> { - const uris = new Set<string>() +): Promise<{ + posts: Map<string, AppBskyFeedDefs.PostView> + starterPacks: Map<string, AppBskyGraphDefs.StarterPackViewBasic> +}> { + const postUris = new Set<string>() + const packUris = new Set<string>() for (const notif of groupedNotifs) { if (notif.subjectUri?.includes('app.bsky.feed.post')) { - uris.add(notif.subjectUri) + postUris.add(notif.subjectUri) + } else if ( + notif.notification.reasonSubject?.includes('app.bsky.graph.starterpack') + ) { + packUris.add(notif.notification.reasonSubject) } } - const uriChunks = chunk(Array.from(uris), 25) + const postUriChunks = chunk(Array.from(postUris), 25) + const packUriChunks = chunk(Array.from(packUris), 25) const postsChunks = await Promise.all( - uriChunks.map(uris => + postUriChunks.map(uris => agent.app.bsky.feed.getPosts({uris}).then(res => res.data.posts), ), ) - const map = new Map<string, AppBskyFeedDefs.PostView>() + const packsChunks = await Promise.all( + packUriChunks.map(uris => + agent.app.bsky.graph + .getStarterPacks({uris}) + .then(res => res.data.starterPacks), + ), + ) + const postsMap = new Map<string, AppBskyFeedDefs.PostView>() + const packsMap = new Map<string, AppBskyGraphDefs.StarterPackView>() for (const post of postsChunks.flat()) { if ( AppBskyFeedPost.isRecord(post.record) && AppBskyFeedPost.validateRecord(post.record).success ) { - map.set(post.uri, post) + postsMap.set(post.uri, post) + } + } + for (const pack of packsChunks.flat()) { + if (AppBskyGraphStarterpack.isRecord(pack.record)) { + packsMap.set(pack.uri, pack) } } - return map + return { + posts: postsMap, + starterPacks: packsMap, + } } function toKnownType( @@ -173,7 +219,8 @@ function toKnownType( notif.reason === 'mention' || notif.reason === 'reply' || notif.reason === 'quote' || - notif.reason === 'follow' + notif.reason === 'follow' || + notif.reason === 'starterpack-joined' ) { return notif.reason as NotificationType } diff --git a/src/state/queries/profile-lists.ts b/src/state/queries/profile-lists.ts index 2bb5f4d28..112a62c83 100644 --- a/src/state/queries/profile-lists.ts +++ b/src/state/queries/profile-lists.ts @@ -26,7 +26,15 @@ export function useProfileListsQuery(did: string, opts?: {enabled?: boolean}) { limit: PAGE_SIZE, cursor: pageParam, }) - return res.data + + // Starter packs use a reference list, which we do not want to show on profiles. At some point we could probably + // just filter this out on the backend instead of in the client. + return { + ...res.data, + lists: res.data.lists.filter( + l => l.purpose !== 'app.bsky.graph.defs#referencelist', + ), + } }, initialPageParam: undefined, getNextPageParam: lastPage => lastPage.cursor, diff --git a/src/state/queries/shorten-link.ts b/src/state/queries/shorten-link.ts new file mode 100644 index 000000000..76c63c356 --- /dev/null +++ b/src/state/queries/shorten-link.ts @@ -0,0 +1,23 @@ +import {logger} from '#/logger' + +export function useShortenLink() { + return async (inputUrl: string): Promise<{url: string}> => { + const url = new URL(inputUrl) + const res = await fetch('https://go.bsky.app/link', { + method: 'POST', + body: JSON.stringify({ + path: url.pathname, + }), + headers: { + 'Content-Type': 'application/json', + }, + }) + + if (!res.ok) { + logger.error('Failed to shorten link', {safeMessage: res.status}) + return {url: inputUrl} + } + + return res.json() + } +} diff --git a/src/state/queries/starter-packs.ts b/src/state/queries/starter-packs.ts new file mode 100644 index 000000000..241bc6419 --- /dev/null +++ b/src/state/queries/starter-packs.ts @@ -0,0 +1,317 @@ +import { + AppBskyActorDefs, + AppBskyFeedDefs, + AppBskyGraphDefs, + AppBskyGraphGetStarterPack, + AppBskyGraphStarterpack, + AtUri, + BskyAgent, +} from '@atproto/api' +import {StarterPackView} from '@atproto/api/dist/client/types/app/bsky/graph/defs' +import { + QueryClient, + useMutation, + useQuery, + useQueryClient, +} from '@tanstack/react-query' + +import {until} from 'lib/async/until' +import {createStarterPackList} from 'lib/generate-starterpack' +import { + createStarterPackUri, + httpStarterPackUriToAtUri, + parseStarterPackUri, +} from 'lib/strings/starter-pack' +import {invalidateActorStarterPacksQuery} from 'state/queries/actor-starter-packs' +import {invalidateListMembersQuery} from 'state/queries/list-members' +import {useAgent} from 'state/session' + +const RQKEY_ROOT = 'starter-pack' +const RQKEY = (did?: string, rkey?: string) => { + if (did?.startsWith('https://') || did?.startsWith('at://')) { + const parsed = parseStarterPackUri(did) + return [RQKEY_ROOT, parsed?.name, parsed?.rkey] + } else { + return [RQKEY_ROOT, did, rkey] + } +} + +export function useStarterPackQuery({ + uri, + did, + rkey, +}: { + uri?: string + did?: string + rkey?: string +}) { + const agent = useAgent() + + return useQuery<StarterPackView>({ + queryKey: RQKEY(did, rkey), + queryFn: async () => { + if (!uri) { + uri = `at://${did}/app.bsky.graph.starterpack/${rkey}` + } else if (uri && !uri.startsWith('at://')) { + uri = httpStarterPackUriToAtUri(uri) as string + } + + const res = await agent.app.bsky.graph.getStarterPack({ + starterPack: uri, + }) + return res.data.starterPack + }, + enabled: Boolean(uri) || Boolean(did && rkey), + }) +} + +export async function invalidateStarterPack({ + queryClient, + did, + rkey, +}: { + queryClient: QueryClient + did: string + rkey: string +}) { + await queryClient.invalidateQueries({queryKey: RQKEY(did, rkey)}) +} + +interface UseCreateStarterPackMutationParams { + name: string + description?: string + descriptionFacets: [] + profiles: AppBskyActorDefs.ProfileViewBasic[] + feeds?: AppBskyFeedDefs.GeneratorView[] +} + +export function useCreateStarterPackMutation({ + onSuccess, + onError, +}: { + onSuccess: (data: {uri: string; cid: string}) => void + onError: (e: Error) => void +}) { + const queryClient = useQueryClient() + const agent = useAgent() + + return useMutation< + {uri: string; cid: string}, + Error, + UseCreateStarterPackMutationParams + >({ + mutationFn: async params => { + let listRes + listRes = await createStarterPackList({...params, agent}) + return await agent.app.bsky.graph.starterpack.create( + { + repo: agent.session?.did, + }, + { + ...params, + list: listRes?.uri, + createdAt: new Date().toISOString(), + }, + ) + }, + onSuccess: async data => { + await whenAppViewReady(agent, data.uri, v => { + return typeof v?.data.starterPack.uri === 'string' + }) + await invalidateActorStarterPacksQuery({ + queryClient, + did: agent.session!.did, + }) + onSuccess(data) + }, + onError: async error => { + onError(error) + }, + }) +} + +export function useEditStarterPackMutation({ + onSuccess, + onError, +}: { + onSuccess: () => void + onError: (error: Error) => void +}) { + const queryClient = useQueryClient() + const agent = useAgent() + + return useMutation< + void, + Error, + UseCreateStarterPackMutationParams & { + currentStarterPack: AppBskyGraphDefs.StarterPackView + currentListItems: AppBskyGraphDefs.ListItemView[] + } + >({ + mutationFn: async params => { + const { + name, + description, + descriptionFacets, + feeds, + profiles, + currentStarterPack, + currentListItems, + } = params + + if (!AppBskyGraphStarterpack.isRecord(currentStarterPack.record)) { + throw new Error('Invalid starter pack') + } + + const removedItems = currentListItems.filter( + i => + i.subject.did !== agent.session?.did && + !profiles.find(p => p.did === i.subject.did && p.did), + ) + + if (removedItems.length !== 0) { + await agent.com.atproto.repo.applyWrites({ + repo: agent.session!.did, + writes: removedItems.map(i => ({ + $type: 'com.atproto.repo.applyWrites#delete', + collection: 'app.bsky.graph.listitem', + rkey: new AtUri(i.uri).rkey, + })), + }) + } + + const addedProfiles = profiles.filter( + p => !currentListItems.find(i => i.subject.did === p.did), + ) + + if (addedProfiles.length > 0) { + await agent.com.atproto.repo.applyWrites({ + repo: agent.session!.did, + writes: addedProfiles.map(p => ({ + $type: 'com.atproto.repo.applyWrites#create', + collection: 'app.bsky.graph.listitem', + value: { + $type: 'app.bsky.graph.listitem', + subject: p.did, + list: currentStarterPack.list?.uri, + createdAt: new Date().toISOString(), + }, + })), + }) + } + + const rkey = parseStarterPackUri(currentStarterPack.uri)!.rkey + await agent.com.atproto.repo.putRecord({ + repo: agent.session!.did, + collection: 'app.bsky.graph.starterpack', + rkey, + record: { + name, + description, + descriptionFacets, + list: currentStarterPack.list?.uri, + feeds, + createdAt: currentStarterPack.record.createdAt, + updatedAt: new Date().toISOString(), + }, + }) + }, + onSuccess: async (_, {currentStarterPack}) => { + const parsed = parseStarterPackUri(currentStarterPack.uri) + await whenAppViewReady(agent, currentStarterPack.uri, v => { + return currentStarterPack.cid !== v?.data.starterPack.cid + }) + await invalidateActorStarterPacksQuery({ + queryClient, + did: agent.session!.did, + }) + if (currentStarterPack.list) { + await invalidateListMembersQuery({ + queryClient, + uri: currentStarterPack.list.uri, + }) + } + await invalidateStarterPack({ + queryClient, + did: agent.session!.did, + rkey: parsed!.rkey, + }) + onSuccess() + }, + onError: error => { + onError(error) + }, + }) +} + +export function useDeleteStarterPackMutation({ + onSuccess, + onError, +}: { + onSuccess: () => void + onError: (error: Error) => void +}) { + const agent = useAgent() + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: async ({listUri, rkey}: {listUri?: string; rkey: string}) => { + if (!agent.session) { + throw new Error(`Requires logged in user`) + } + + if (listUri) { + await agent.app.bsky.graph.list.delete({ + repo: agent.session.did, + rkey: new AtUri(listUri).rkey, + }) + } + await agent.app.bsky.graph.starterpack.delete({ + repo: agent.session.did, + rkey, + }) + }, + onSuccess: async (_, {listUri, rkey}) => { + const uri = createStarterPackUri({ + did: agent.session!.did, + rkey, + }) + + if (uri) { + await whenAppViewReady(agent, uri, v => { + return Boolean(v?.data?.starterPack) === false + }) + } + + if (listUri) { + await invalidateListMembersQuery({queryClient, uri: listUri}) + } + await invalidateActorStarterPacksQuery({ + queryClient, + did: agent.session!.did, + }) + await invalidateStarterPack({ + queryClient, + did: agent.session!.did, + rkey, + }) + onSuccess() + }, + onError: error => { + onError(error) + }, + }) +} + +async function whenAppViewReady( + agent: BskyAgent, + uri: string, + fn: (res?: AppBskyGraphGetStarterPack.Response) => boolean, +) { + await until( + 5, // 5 tries + 1e3, // 1s delay between tries + fn, + () => agent.app.bsky.graph.getStarterPack({starterPack: uri}), + ) +} diff --git a/src/state/session/agent.ts b/src/state/session/agent.ts index 5a58937fa..4bcb4c11c 100644 --- a/src/state/session/agent.ts +++ b/src/state/session/agent.ts @@ -127,18 +127,6 @@ export async function createAgentAndCreateAccount( const account = agentToSessionAccountOrThrow(agent) const gates = tryFetchGates(account.did, 'prefer-fresh-gates') const moderation = configureModerationForAccount(agent, account) - if (!account.signupQueued) { - /*dont await*/ agent.upsertProfile(_existing => { - return { - displayName: '', - // HACKFIX - // creating a bunch of identical profile objects is breaking the relay - // tossing this unspecced field onto it to reduce the size of the problem - // -prf - createdAt: new Date().toISOString(), - } - }) - } // Not awaited so that we can still get into onboarding. // This is OK because we won't let you toggle adult stuff until you set the date. diff --git a/src/state/shell/logged-out.tsx b/src/state/shell/logged-out.tsx index 8fe2a9c01..dc78d03d5 100644 --- a/src/state/shell/logged-out.tsx +++ b/src/state/shell/logged-out.tsx @@ -1,5 +1,9 @@ import React from 'react' +import {isWeb} from 'platform/detection' +import {useSession} from 'state/session' +import {useActiveStarterPack} from 'state/shell/starter-pack' + type State = { showLoggedOut: boolean /** @@ -22,7 +26,7 @@ type Controls = { /** * The did of the account to populate the login form with. */ - requestedAccount?: string | 'none' | 'new' + requestedAccount?: string | 'none' | 'new' | 'starterpack' }) => void /** * Clears the requested account so that next time the logged out view is @@ -43,9 +47,16 @@ const ControlsContext = React.createContext<Controls>({ }) export function Provider({children}: React.PropsWithChildren<{}>) { + const activeStarterPack = useActiveStarterPack() + const {hasSession} = useSession() + const shouldShowStarterPack = Boolean(activeStarterPack?.uri) && !hasSession const [state, setState] = React.useState<State>({ - showLoggedOut: false, - requestedAccountSwitchTo: undefined, + showLoggedOut: shouldShowStarterPack, + requestedAccountSwitchTo: shouldShowStarterPack + ? isWeb + ? 'starterpack' + : 'new' + : undefined, }) const controls = React.useMemo<Controls>( diff --git a/src/state/shell/starter-pack.tsx b/src/state/shell/starter-pack.tsx new file mode 100644 index 000000000..f564712f0 --- /dev/null +++ b/src/state/shell/starter-pack.tsx @@ -0,0 +1,25 @@ +import React from 'react' + +type StateContext = + | { + uri: string + isClip?: boolean + } + | undefined +type SetContext = (v: StateContext) => void + +const stateContext = React.createContext<StateContext>(undefined) +const setContext = React.createContext<SetContext>((_: StateContext) => {}) + +export function Provider({children}: {children: React.ReactNode}) { + const [state, setState] = React.useState<StateContext>() + + return ( + <stateContext.Provider value={state}> + <setContext.Provider value={setState}>{children}</setContext.Provider> + </stateContext.Provider> + ) +} + +export const useActiveStarterPack = () => React.useContext(stateContext) +export const useSetActiveStarterPack = () => React.useContext(setContext) diff --git a/src/view/com/auth/LoggedOut.tsx b/src/view/com/auth/LoggedOut.tsx index c8c81dd77..29127ec45 100644 --- a/src/view/com/auth/LoggedOut.tsx +++ b/src/view/com/auth/LoggedOut.tsx @@ -7,7 +7,6 @@ import {useNavigation} from '@react-navigation/native' import {useAnalytics} from '#/lib/analytics/analytics' import {usePalette} from '#/lib/hooks/usePalette' -import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries' import {logEvent} from '#/lib/statsig/statsig' import {s} from '#/lib/styles' import {isIOS, isNative} from '#/platform/detection' @@ -22,13 +21,16 @@ import {ErrorBoundary} from '#/view/com/util/ErrorBoundary' import {Text} from '#/view/com/util/text/Text' import {Login} from '#/screens/Login' import {Signup} from '#/screens/Signup' +import {LandingScreen} from '#/screens/StarterPack/StarterPackLandingScreen' import {SplashScreen} from './SplashScreen' enum ScreenState { S_LoginOrCreateAccount, S_Login, S_CreateAccount, + S_StarterPack, } +export {ScreenState as LoggedOutScreenState} export function LoggedOut({onDismiss}: {onDismiss?: () => void}) { const {hasSession} = useSession() @@ -37,18 +39,21 @@ export function LoggedOut({onDismiss}: {onDismiss?: () => void}) { const setMinimalShellMode = useSetMinimalShellMode() const {screen} = useAnalytics() const {requestedAccountSwitchTo} = useLoggedOutView() - const [screenState, setScreenState] = React.useState<ScreenState>( - requestedAccountSwitchTo - ? requestedAccountSwitchTo === 'new' - ? ScreenState.S_CreateAccount - : ScreenState.S_Login - : ScreenState.S_LoginOrCreateAccount, - ) - const {isMobile} = useWebMediaQueries() + const [screenState, setScreenState] = React.useState<ScreenState>(() => { + if (requestedAccountSwitchTo === 'new') { + return ScreenState.S_CreateAccount + } else if (requestedAccountSwitchTo === 'starterpack') { + return ScreenState.S_StarterPack + } else if (requestedAccountSwitchTo != null) { + return ScreenState.S_Login + } else { + return ScreenState.S_LoginOrCreateAccount + } + }) const {clearRequestedAccount} = useLoggedOutViewControls() const navigation = useNavigation<NavigationProp>() - const isFirstScreen = screenState === ScreenState.S_LoginOrCreateAccount + const isFirstScreen = screenState === ScreenState.S_LoginOrCreateAccount React.useEffect(() => { screen('Login') setMinimalShellMode(true) @@ -66,18 +71,9 @@ export function LoggedOut({onDismiss}: {onDismiss?: () => void}) { }, [navigation]) return ( - <View - testID="noSessionView" - style={[ - s.hContentRegion, - pal.view, - { - // only needed if dismiss button is present - paddingTop: onDismiss && isMobile ? 40 : 0, - }, - ]}> + <View testID="noSessionView" style={[s.hContentRegion, pal.view]}> <ErrorBoundary> - {onDismiss ? ( + {onDismiss && screenState === ScreenState.S_LoginOrCreateAccount ? ( <Pressable accessibilityHint={_(msg`Go back`)} accessibilityLabel={_(msg`Go back`)} @@ -132,7 +128,9 @@ export function LoggedOut({onDismiss}: {onDismiss?: () => void}) { </Pressable> ) : null} - {screenState === ScreenState.S_LoginOrCreateAccount ? ( + {screenState === ScreenState.S_StarterPack ? ( + <LandingScreen setScreenState={setScreenState} /> + ) : screenState === ScreenState.S_LoginOrCreateAccount ? ( <SplashScreen onPressSignin={() => { setScreenState(ScreenState.S_Login) diff --git a/src/view/com/feeds/FeedSourceCard.tsx b/src/view/com/feeds/FeedSourceCard.tsx index a61789434..d216849c5 100644 --- a/src/view/com/feeds/FeedSourceCard.tsx +++ b/src/view/com/feeds/FeedSourceCard.tsx @@ -329,6 +329,9 @@ const styles = StyleSheet.create({ flex: 1, gap: 14, }, + border: { + borderTopWidth: hairlineWidth, + }, headerContainer: { flexDirection: 'row', }, diff --git a/src/view/com/notifications/FeedItem.tsx b/src/view/com/notifications/FeedItem.tsx index 9cd7a2917..2f8d65a1d 100644 --- a/src/view/com/notifications/FeedItem.tsx +++ b/src/view/com/notifications/FeedItem.tsx @@ -52,7 +52,16 @@ import {TimeElapsed} from '../util/TimeElapsed' import {PreviewableUserAvatar, UserAvatar} from '../util/UserAvatar' import hairlineWidth = StyleSheet.hairlineWidth +import {useNavigation} from '@react-navigation/native' + import {parseTenorGif} from '#/lib/strings/embed-player' +import {logger} from '#/logger' +import {NavigationProp} from 'lib/routes/types' +import {DM_SERVICE_HEADERS} from 'state/queries/messages/const' +import {useAgent} from 'state/session' +import {Button, ButtonText} from '#/components/Button' +import {StarterPack} from '#/components/icons/StarterPack' +import {Notification as StarterPackCard} from '#/components/StarterPack/StarterPackCard' const MAX_AUTHORS = 5 @@ -89,7 +98,10 @@ let FeedItem = ({ } else if (item.type === 'reply') { const urip = new AtUri(item.notification.uri) return `/profile/${urip.host}/post/${urip.rkey}` - } else if (item.type === 'feedgen-like') { + } else if ( + item.type === 'feedgen-like' || + item.type === 'starterpack-joined' + ) { if (item.subjectUri) { const urip = new AtUri(item.subjectUri) return `/profile/${urip.host}/feed/${urip.rkey}` @@ -176,6 +188,13 @@ let FeedItem = ({ icon = <PersonPlusIcon size="xl" style={{color: t.palette.primary_500}} /> } else if (item.type === 'feedgen-like') { action = _(msg`liked your custom feed`) + } else if (item.type === 'starterpack-joined') { + icon = ( + <View style={{height: 30, width: 30}}> + <StarterPack width={30} gradient="sky" /> + </View> + ) + action = _(msg`signed up with your starter pack`) } else { return null } @@ -289,6 +308,20 @@ let FeedItem = ({ showLikes /> ) : null} + {item.type === 'starterpack-joined' ? ( + <View> + <View + style={[ + a.border, + a.p_sm, + a.rounded_sm, + a.mt_sm, + t.atoms.border_contrast_low, + ]}> + <StarterPackCard starterPack={item.subject} /> + </View> + </View> + ) : null} </View> </Link> ) @@ -319,14 +352,63 @@ function ExpandListPressable({ } } +function SayHelloBtn({profile}: {profile: AppBskyActorDefs.ProfileViewBasic}) { + const {_} = useLingui() + const agent = useAgent() + const navigation = useNavigation<NavigationProp>() + const [isLoading, setIsLoading] = React.useState(false) + + if ( + profile.associated?.chat?.allowIncoming === 'none' || + (profile.associated?.chat?.allowIncoming === 'following' && + !profile.viewer?.followedBy) + ) { + return null + } + + return ( + <Button + label={_(msg`Say hello!`)} + variant="ghost" + color="primary" + size="xsmall" + style={[a.self_center, {marginLeft: 'auto'}]} + disabled={isLoading} + onPress={async () => { + try { + setIsLoading(true) + const res = await agent.api.chat.bsky.convo.getConvoForMembers( + { + members: [profile.did, agent.session!.did!], + }, + {headers: DM_SERVICE_HEADERS}, + ) + navigation.navigate('MessagesConversation', { + conversation: res.data.convo.id, + }) + } catch (e) { + logger.error('Failed to get conversation', {safeMessage: e}) + } finally { + setIsLoading(false) + } + }}> + <ButtonText> + <Trans>Say hello!</Trans> + </ButtonText> + </Button> + ) +} + function CondensedAuthorsList({ visible, authors, onToggleAuthorsExpanded, + showDmButton = true, }: { visible: boolean authors: Author[] onToggleAuthorsExpanded: () => void + showDmButton?: boolean }) { const pal = usePalette('default') const {_} = useLingui() @@ -355,7 +437,7 @@ function CondensedAuthorsList({ } if (authors.length === 1) { return ( - <View style={styles.avis}> + <View style={[styles.avis]}> <PreviewableUserAvatar size={35} profile={authors[0].profile} @@ -363,6 +445,7 @@ function CondensedAuthorsList({ type={authors[0].profile.associated?.labeler ? 'labeler' : 'user'} accessible={false} /> + {showDmButton ? <SayHelloBtn profile={authors[0].profile} /> : null} </View> ) } diff --git a/src/view/com/profile/FollowButton.tsx b/src/view/com/profile/FollowButton.tsx index 7b090ffeb..8e63da85b 100644 --- a/src/view/com/profile/FollowButton.tsx +++ b/src/view/com/profile/FollowButton.tsx @@ -1,12 +1,13 @@ import React from 'react' import {StyleProp, TextStyle, View} from 'react-native' import {AppBskyActorDefs} from '@atproto/api' +import {msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' + +import {Shadow} from '#/state/cache/types' +import {useProfileFollowMutationQueue} from '#/state/queries/profile' import {Button, ButtonType} from '../util/forms/Button' import * as Toast from '../util/Toast' -import {useProfileFollowMutationQueue} from '#/state/queries/profile' -import {Shadow} from '#/state/cache/types' -import {useLingui} from '@lingui/react' -import {msg} from '@lingui/macro' export function FollowButton({ unfollowedType = 'inverted', @@ -19,7 +20,7 @@ export function FollowButton({ followedType?: ButtonType profile: Shadow<AppBskyActorDefs.ProfileViewBasic> labelStyle?: StyleProp<TextStyle> - logContext: 'ProfileCard' + logContext: 'ProfileCard' | 'StarterPackProfilesList' }) { const [queueFollow, queueUnfollow] = useProfileFollowMutationQueue( profile, diff --git a/src/view/com/profile/ProfileCard.tsx b/src/view/com/profile/ProfileCard.tsx index a3cd5ca1b..d7ed0dd6a 100644 --- a/src/view/com/profile/ProfileCard.tsx +++ b/src/view/com/profile/ProfileCard.tsx @@ -251,12 +251,14 @@ export function ProfileCardWithFollowBtn({ noBorder, followers, onPress, + logContext = 'ProfileCard', }: { profile: AppBskyActorDefs.ProfileViewBasic noBg?: boolean noBorder?: boolean followers?: AppBskyActorDefs.ProfileView[] | undefined onPress?: () => void + logContext?: 'ProfileCard' | 'StarterPackProfilesList' }) { const {currentAccount} = useSession() const isMe = profile.did === currentAccount?.did @@ -271,7 +273,7 @@ export function ProfileCardWithFollowBtn({ isMe ? undefined : profileShadow => ( - <FollowButton profile={profileShadow} logContext="ProfileCard" /> + <FollowButton profile={profileShadow} logContext={logContext} /> ) } onPress={onPress} @@ -314,6 +316,7 @@ const styles = StyleSheet.create({ paddingRight: 10, }, details: { + justifyContent: 'center', paddingLeft: 54, paddingRight: 10, paddingBottom: 10, @@ -339,7 +342,6 @@ const styles = StyleSheet.create({ followedBy: { flexDirection: 'row', - alignItems: 'center', paddingLeft: 54, paddingRight: 20, marginBottom: 10, diff --git a/src/view/com/profile/ProfileSubpageHeader.tsx b/src/view/com/profile/ProfileSubpageHeader.tsx index edc6b75f9..ac5febcda 100644 --- a/src/view/com/profile/ProfileSubpageHeader.tsx +++ b/src/view/com/profile/ProfileSubpageHeader.tsx @@ -21,7 +21,9 @@ import {Text} from '../util/text/Text' import {UserAvatar, UserAvatarType} from '../util/UserAvatar' import {CenteredView} from '../util/Views' import hairlineWidth = StyleSheet.hairlineWidth + import {Menu_Stroke2_Corner0_Rounded as Menu} from '#/components/icons/Menu' +import {StarterPack} from '#/components/icons/StarterPack' export function ProfileSubpageHeader({ isLoading, @@ -44,7 +46,7 @@ export function ProfileSubpageHeader({ handle: string } | undefined - avatarType: UserAvatarType + avatarType: UserAvatarType | 'starter-pack' }>) { const setDrawerOpen = useSetDrawerOpen() const navigation = useNavigation<NavigationProp>() @@ -127,7 +129,11 @@ export function ProfileSubpageHeader({ accessibilityLabel={_(msg`View the avatar`)} accessibilityHint="" style={{width: 58}}> - <UserAvatar type={avatarType} size={58} avatar={avatar} /> + {avatarType === 'starter-pack' ? ( + <StarterPack width={58} gradient="sky" /> + ) : ( + <UserAvatar type={avatarType} size={58} avatar={avatar} /> + )} </Pressable> <View style={{flex: 1}}> {isLoading ? ( diff --git a/src/view/screens/Home.tsx b/src/view/screens/Home.tsx index e49f2fbb2..dfadf9bbe 100644 --- a/src/view/screens/Home.tsx +++ b/src/view/screens/Home.tsx @@ -30,7 +30,7 @@ import {FollowingEndOfFeed} from 'view/com/posts/FollowingEndOfFeed' import {NoFeedsPinned} from '#/screens/Home/NoFeedsPinned' import {HomeHeader} from '../com/home/HomeHeader' -type Props = NativeStackScreenProps<HomeTabNavigatorParams, 'Home'> +type Props = NativeStackScreenProps<HomeTabNavigatorParams, 'Home' | 'Start'> export function HomeScreen(props: Props) { const {data: preferences} = usePreferencesQuery() const {data: pinnedFeedInfos, isLoading: isPinnedFeedsLoading} = diff --git a/src/view/screens/Profile.tsx b/src/view/screens/Profile.tsx index 734230c6c..946f6ac54 100644 --- a/src/view/screens/Profile.tsx +++ b/src/view/screens/Profile.tsx @@ -1,7 +1,8 @@ -import React, {useMemo} from 'react' +import React, {useCallback, useMemo} from 'react' import {StyleSheet} from 'react-native' import { AppBskyActorDefs, + AppBskyGraphGetActorStarterPacks, moderateProfile, ModerationOpts, RichText as RichTextAPI, @@ -9,7 +10,11 @@ import { import {msg} from '@lingui/macro' import {useLingui} from '@lingui/react' import {useFocusEffect} from '@react-navigation/native' -import {useQueryClient} from '@tanstack/react-query' +import { + InfiniteData, + UseInfiniteQueryResult, + useQueryClient, +} from '@tanstack/react-query' import {cleanError} from '#/lib/strings/errors' import {useProfileShadow} from '#/state/cache/profile-shadow' @@ -22,18 +27,23 @@ import {useAgent, useSession} from '#/state/session' import {useSetDrawerSwipeDisabled, useSetMinimalShellMode} from '#/state/shell' import {useComposerControls} from '#/state/shell/composer' import {useAnalytics} from 'lib/analytics/analytics' +import {IS_DEV, IS_TESTFLIGHT} from 'lib/app-info' import {useSetTitle} from 'lib/hooks/useSetTitle' import {ComposeIcon2} from 'lib/icons' import {CommonNavigatorParams, NativeStackScreenProps} from 'lib/routes/types' +import {useGate} from 'lib/statsig/statsig' import {combinedDisplayName} from 'lib/strings/display-names' import {isInvalidHandle} from 'lib/strings/handles' import {colors, s} from 'lib/styles' +import {isWeb} from 'platform/detection' import {listenSoftReset} from 'state/events' +import {useActorStarterPacksQuery} from 'state/queries/actor-starter-packs' import {PagerWithHeader} from 'view/com/pager/PagerWithHeader' import {ProfileHeader, ProfileHeaderLoading} from '#/screens/Profile/Header' import {ProfileFeedSection} from '#/screens/Profile/Sections/Feed' import {ProfileLabelsSection} from '#/screens/Profile/Sections/Labels' import {ScreenHider} from '#/components/moderation/ScreenHider' +import {ProfileStarterPacks} from '#/components/StarterPack/ProfileStarterPacks' import {ExpoScrollForwarderView} from '../../../modules/expo-scroll-forwarder' import {ProfileFeedgens} from '../com/feeds/ProfileFeedgens' import {ProfileLists} from '../com/lists/ProfileLists' @@ -69,6 +79,7 @@ export function ProfileScreen({route}: Props) { } = useProfileQuery({ did: resolvedDid, }) + const starterPacksQuery = useActorStarterPacksQuery({did: resolvedDid}) const onPressTryAgain = React.useCallback(() => { if (resolveError) { @@ -86,7 +97,7 @@ export function ProfileScreen({route}: Props) { }, [queryClient, profile?.viewer?.blockedBy, resolvedDid]) // Most pushes will happen here, since we will have only placeholder data - if (isLoadingDid || isLoadingProfile) { + if (isLoadingDid || isLoadingProfile || starterPacksQuery.isLoading) { return ( <CenteredView> <ProfileHeaderLoading /> @@ -108,6 +119,7 @@ export function ProfileScreen({route}: Props) { return ( <ProfileScreenLoaded profile={profile} + starterPacksQuery={starterPacksQuery} moderationOpts={moderationOpts} isPlaceholderProfile={isPlaceholderProfile} hideBackButton={!!route.params.hideBackButton} @@ -131,11 +143,16 @@ function ProfileScreenLoaded({ isPlaceholderProfile, moderationOpts, hideBackButton, + starterPacksQuery, }: { profile: AppBskyActorDefs.ProfileViewDetailed moderationOpts: ModerationOpts hideBackButton: boolean isPlaceholderProfile: boolean + starterPacksQuery: UseInfiniteQueryResult< + InfiniteData<AppBskyGraphGetActorStarterPacks.OutputSchema, unknown>, + Error + > }) { const profile = useProfileShadow(profileUnshadowed) const {hasSession, currentAccount} = useSession() @@ -153,6 +170,9 @@ function ProfileScreenLoaded({ const [currentPage, setCurrentPage] = React.useState(0) const {_} = useLingui() const setDrawerSwipeDisabled = useSetDrawerSwipeDisabled() + const gate = useGate() + const starterPacksEnabled = + IS_DEV || IS_TESTFLIGHT || (!isWeb && gate('starter_packs_enabled')) const [scrollViewTag, setScrollViewTag] = React.useState<number | null>(null) @@ -162,6 +182,7 @@ function ProfileScreenLoaded({ const likesSectionRef = React.useRef<SectionRef>(null) const feedsSectionRef = React.useRef<SectionRef>(null) const listsSectionRef = React.useRef<SectionRef>(null) + const starterPacksSectionRef = React.useRef<SectionRef>(null) const labelsSectionRef = React.useRef<SectionRef>(null) useSetTitle(combinedDisplayName(profile)) @@ -183,31 +204,23 @@ function ProfileScreenLoaded({ const showMediaTab = !hasLabeler const showLikesTab = isMe const showFeedsTab = isMe || (profile.associated?.feedgens || 0) > 0 + const showStarterPacksTab = + starterPacksEnabled && + (isMe || !!starterPacksQuery.data?.pages?.[0].starterPacks.length) const showListsTab = hasSession && (isMe || (profile.associated?.lists || 0) > 0) - const sectionTitles = useMemo<string[]>(() => { - return [ - showFiltersTab ? _(msg`Labels`) : undefined, - showListsTab && hasLabeler ? _(msg`Lists`) : undefined, - showPostsTab ? _(msg`Posts`) : undefined, - showRepliesTab ? _(msg`Replies`) : undefined, - showMediaTab ? _(msg`Media`) : undefined, - showLikesTab ? _(msg`Likes`) : undefined, - showFeedsTab ? _(msg`Feeds`) : undefined, - showListsTab && !hasLabeler ? _(msg`Lists`) : undefined, - ].filter(Boolean) as string[] - }, [ - showPostsTab, - showRepliesTab, - showMediaTab, - showLikesTab, - showFeedsTab, - showListsTab, - showFiltersTab, - hasLabeler, - _, - ]) + const sectionTitles = [ + showFiltersTab ? _(msg`Labels`) : undefined, + showListsTab && hasLabeler ? _(msg`Lists`) : undefined, + showPostsTab ? _(msg`Posts`) : undefined, + showRepliesTab ? _(msg`Replies`) : undefined, + showMediaTab ? _(msg`Media`) : undefined, + showLikesTab ? _(msg`Likes`) : undefined, + showFeedsTab ? _(msg`Feeds`) : undefined, + showStarterPacksTab ? _(msg`Starter Packs`) : undefined, + showListsTab && !hasLabeler ? _(msg`Lists`) : undefined, + ].filter(Boolean) as string[] let nextIndex = 0 let filtersIndex: number | null = null @@ -216,6 +229,7 @@ function ProfileScreenLoaded({ let mediaIndex: number | null = null let likesIndex: number | null = null let feedsIndex: number | null = null + let starterPacksIndex: number | null = null let listsIndex: number | null = null if (showFiltersTab) { filtersIndex = nextIndex++ @@ -235,11 +249,14 @@ function ProfileScreenLoaded({ if (showFeedsTab) { feedsIndex = nextIndex++ } + if (showStarterPacksTab) { + starterPacksIndex = nextIndex++ + } if (showListsTab) { listsIndex = nextIndex++ } - const scrollSectionToTop = React.useCallback( + const scrollSectionToTop = useCallback( (index: number) => { if (index === filtersIndex) { labelsSectionRef.current?.scrollToTop() @@ -253,6 +270,8 @@ function ProfileScreenLoaded({ likesSectionRef.current?.scrollToTop() } else if (index === feedsIndex) { feedsSectionRef.current?.scrollToTop() + } else if (index === starterPacksIndex) { + starterPacksSectionRef.current?.scrollToTop() } else if (index === listsIndex) { listsSectionRef.current?.scrollToTop() } @@ -265,6 +284,7 @@ function ProfileScreenLoaded({ likesIndex, feedsIndex, listsIndex, + starterPacksIndex, ], ) @@ -290,7 +310,7 @@ function ProfileScreenLoaded({ // events // = - const onPressCompose = React.useCallback(() => { + const onPressCompose = () => { track('ProfileScreen:PressCompose') const mention = profile.handle === currentAccount?.handle || @@ -298,23 +318,20 @@ function ProfileScreenLoaded({ ? undefined : profile.handle openComposer({mention}) - }, [openComposer, currentAccount, track, profile]) + } - const onPageSelected = React.useCallback((i: number) => { + const onPageSelected = (i: number) => { setCurrentPage(i) - }, []) + } - const onCurrentPageSelected = React.useCallback( - (index: number) => { - scrollSectionToTop(index) - }, - [scrollSectionToTop], - ) + const onCurrentPageSelected = (index: number) => { + scrollSectionToTop(index) + } // rendering // = - const renderHeader = React.useCallback(() => { + const renderHeader = () => { return ( <ExpoScrollForwarderView scrollViewTag={scrollViewTag}> <ProfileHeader @@ -327,16 +344,7 @@ function ProfileScreenLoaded({ /> </ExpoScrollForwarderView> ) - }, [ - scrollViewTag, - profile, - labelerInfo, - hasDescription, - descriptionRT, - moderationOpts, - hideBackButton, - showPlaceholder, - ]) + } return ( <ScreenHider @@ -442,6 +450,19 @@ function ProfileScreenLoaded({ /> ) : null} + {showStarterPacksTab + ? ({headerHeight, isFocused, scrollElRef}) => ( + <ProfileStarterPacks + ref={starterPacksSectionRef} + isMe={isMe} + starterPacksQuery={starterPacksQuery} + scrollElRef={scrollElRef as ListRef} + headerOffset={headerHeight} + enabled={isFocused} + setScrollViewTag={setScrollViewTag} + /> + ) + : null} {showListsTab && !profile.associated?.labeler ? ({headerHeight, isFocused, scrollElRef}) => ( <ProfileLists diff --git a/src/view/screens/Storybook/Icons.tsx b/src/view/screens/Storybook/Icons.tsx index bff1fdc9b..9de126d6b 100644 --- a/src/view/screens/Storybook/Icons.tsx +++ b/src/view/screens/Storybook/Icons.tsx @@ -45,6 +45,14 @@ export function Icons() { <Loader size="lg" fill={t.atoms.text.color} /> <Loader size="xl" fill={t.atoms.text.color} /> </View> + + <View style={[a.flex_row, a.gap_xl]}> + <Globe size="xs" gradient="sky" /> + <Globe size="sm" gradient="sky" /> + <Globe size="md" gradient="sky" /> + <Globe size="lg" gradient="sky" /> + <Globe size="xl" gradient="sky" /> + </View> </View> ) } diff --git a/src/view/shell/desktop/LeftNav.tsx b/src/view/shell/desktop/LeftNav.tsx index 9b2b4922a..ca8073f57 100644 --- a/src/view/shell/desktop/LeftNav.tsx +++ b/src/view/shell/desktop/LeftNav.tsx @@ -100,12 +100,18 @@ function ProfileCard() { ) } +const HIDDEN_BACK_BNT_ROUTES = ['StarterPackWizard', 'StarterPackEdit'] + function BackBtn() { const {isTablet} = useWebMediaQueries() const pal = usePalette('default') const navigation = useNavigation<NavigationProp>() const {_} = useLingui() - const shouldShow = useNavigationState(state => !isStateAtTabRoot(state)) + const shouldShow = useNavigationState( + state => + !isStateAtTabRoot(state) && + !HIDDEN_BACK_BNT_ROUTES.includes(getCurrentRoute(state).name), + ) const onPressBack = React.useCallback(() => { if (navigation.canGoBack()) { diff --git a/yarn.lock b/yarn.lock index a0fd8749a..b93f93304 100644 --- a/yarn.lock +++ b/yarn.lock @@ -34,10 +34,10 @@ jsonpointer "^5.0.0" leven "^3.1.0" -"@atproto/api@^0.12.20": - version "0.12.20" - resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.12.20.tgz#2cada08c24bc61eb1775ee4c8010c7ed9dc5d6f3" - integrity sha512-nt7ZKUQL9j2yQ3tmCCueiIuc0FwdxZYn2fXdLYqltuxlaO5DmaqqULMBKeYJLq4GbvVl/G+ikPJccoSaMWDYOg== +"@atproto/api@0.12.22-next.0": + version "0.12.22-next.0" + resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.12.22-next.0.tgz#7996f651468e3fb151663df28a9938d92bd0660a" + integrity sha512-LKmOrQvBvIlheLv+ns85bCrP23DbYfk8UQkFikLBEqPKQW10F9ZwsJ6oBUfrWv6pEI4Mn0mrn8cFQkvdZ2i2sg== dependencies: "@atproto/common-web" "^0.3.0" "@atproto/lexicon" "^0.4.0" @@ -3382,46 +3382,46 @@ node-forge "^1.2.1" nullthrows "^1.1.1" -"@expo/config-plugins@7.8.0", "@expo/config-plugins@~7.8.0": - version "7.8.0" - resolved "https://registry.yarnpkg.com/@expo/config-plugins/-/config-plugins-7.8.0.tgz#70fd87237faf6a5c3bf47277b67f7b22f9b12c05" - integrity sha512-bCJB/uTP2D520l36M0zMVzxzu25ISdEniE42SjgtFnbIzKae2s9Jd91CT/90qEoF2EXeAVlXwn2nCIiY8FTU3A== +"@expo/config-plugins@8.0.4", "@expo/config-plugins@~8.0.0", "@expo/config-plugins@~8.0.0-beta.0": + version "8.0.4" + resolved "https://registry.yarnpkg.com/@expo/config-plugins/-/config-plugins-8.0.4.tgz#1e781cd971fab27409ed2f8d621db6d29cce3036" + integrity sha512-Hi+xuyNWE2LT4LVbGttHJgl9brnsdWAhEB42gWKb5+8ae86Nr/KwUBQJsJppirBYTeLjj5ZlY0glYnAkDa2jqw== dependencies: - "@expo/config-types" "^50.0.0-alpha.1" - "@expo/fingerprint" "^0.6.0" + "@expo/config-types" "^51.0.0-unreleased" "@expo/json-file" "~8.3.0" "@expo/plist" "^0.1.0" "@expo/sdk-runtime-versions" "^1.0.0" - "@react-native/normalize-color" "^2.0.0" chalk "^4.1.2" debug "^4.3.1" find-up "~5.0.0" getenv "^1.0.0" glob "7.1.6" resolve-from "^5.0.0" - semver "^7.5.3" + semver "^7.5.4" slash "^3.0.0" + slugify "^1.6.6" xcode "^3.0.1" xml2js "0.6.0" -"@expo/config-plugins@8.0.4", "@expo/config-plugins@~8.0.0", "@expo/config-plugins@~8.0.0-beta.0": - version "8.0.4" - resolved "https://registry.yarnpkg.com/@expo/config-plugins/-/config-plugins-8.0.4.tgz#1e781cd971fab27409ed2f8d621db6d29cce3036" - integrity sha512-Hi+xuyNWE2LT4LVbGttHJgl9brnsdWAhEB42gWKb5+8ae86Nr/KwUBQJsJppirBYTeLjj5ZlY0glYnAkDa2jqw== +"@expo/config-plugins@~7.8.0": + version "7.8.0" + resolved "https://registry.yarnpkg.com/@expo/config-plugins/-/config-plugins-7.8.0.tgz#70fd87237faf6a5c3bf47277b67f7b22f9b12c05" + integrity sha512-bCJB/uTP2D520l36M0zMVzxzu25ISdEniE42SjgtFnbIzKae2s9Jd91CT/90qEoF2EXeAVlXwn2nCIiY8FTU3A== dependencies: - "@expo/config-types" "^51.0.0-unreleased" + "@expo/config-types" "^50.0.0-alpha.1" + "@expo/fingerprint" "^0.6.0" "@expo/json-file" "~8.3.0" "@expo/plist" "^0.1.0" "@expo/sdk-runtime-versions" "^1.0.0" + "@react-native/normalize-color" "^2.0.0" chalk "^4.1.2" debug "^4.3.1" find-up "~5.0.0" getenv "^1.0.0" glob "7.1.6" resolve-from "^5.0.0" - semver "^7.5.4" + semver "^7.5.3" slash "^3.0.0" - slugify "^1.6.6" xcode "^3.0.1" xml2js "0.6.0" @@ -10969,6 +10969,11 @@ diff@^4.0.1: resolved "https://registry.yarnpkg.com/diff/-/diff-4.0.2.tgz#60f3aecb89d5fae520c11aa19efc2bb982aade7d" integrity sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A== +dijkstrajs@^1.0.1: + version "1.0.3" + resolved "https://registry.yarnpkg.com/dijkstrajs/-/dijkstrajs-1.0.3.tgz#4c8dbdea1f0f6478bff94d9c49c784d623e4fc23" + integrity sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA== + dir-glob@^3.0.1: version "3.0.1" resolved "https://registry.yarnpkg.com/dir-glob/-/dir-glob-3.0.1.tgz#56dbf73d992a4a93ba1584f4534063fd2e41717f" @@ -11234,6 +11239,11 @@ emojis-list@^3.0.0: resolved "https://registry.yarnpkg.com/emojis-list/-/emojis-list-3.0.0.tgz#5570662046ad29e2e916e71aae260abdff4f6a78" integrity sha512-/kyM18EfinwXZbno9FyUGeFh87KC8HRQBQGildHZbEuRyWFOmv1U10o9BBp8XVZDVNNuQKyIGIu5ZYAAXJ0V2Q== +encode-utf8@^1.0.3: + version "1.0.3" + resolved "https://registry.yarnpkg.com/encode-utf8/-/encode-utf8-1.0.3.tgz#f30fdd31da07fb596f281beb2f6b027851994cda" + integrity sha512-ucAnuBEhUK4boH2HjVYG5Q2mQyPorvv0u/ocS+zhdw0S8AlHYY+GOFhP1Gio5z4icpP2ivFSvhtFjQi8+T9ppw== + encodeurl@~1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/encodeurl/-/encodeurl-1.0.2.tgz#ad3ff4c86ec2d029322f5a02c3a9a606c95b3f59" @@ -17609,6 +17619,11 @@ pngjs@^3.3.0: resolved "https://registry.yarnpkg.com/pngjs/-/pngjs-3.4.0.tgz#99ca7d725965fb655814eaf65f38f12bbdbf555f" integrity sha512-NCrCHhWmnQklfH4MtJMRjZ2a8c80qXeMlQMv2uVp9ISJMTt562SbGd6n2oq0PaPgKm7Z6pL9E2UlLIhC+SHL3w== +pngjs@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/pngjs/-/pngjs-5.0.0.tgz#e79dd2b215767fd9c04561c01236df960bce7fbb" + integrity sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw== + pofile@^1.1.4: version "1.1.4" resolved "https://registry.yarnpkg.com/pofile/-/pofile-1.1.4.tgz#eab7e29f5017589b2a61b2259dff608c0cad76a2" @@ -18565,6 +18580,16 @@ qrcode-terminal@0.11.0: resolved "https://registry.yarnpkg.com/qrcode-terminal/-/qrcode-terminal-0.11.0.tgz#ffc6c28a2fc0bfb47052b47e23f4f446a5fbdb9e" integrity sha512-Uu7ii+FQy4Qf82G4xu7ShHhjhGahEpCWc3x8UavY3CTcWV+ufmmCtwkr7ZKsX42jdL0kr1B5FKUeqJvAn51jzQ== +qrcode@^1.5.1: + version "1.5.3" + resolved "https://registry.yarnpkg.com/qrcode/-/qrcode-1.5.3.tgz#03afa80912c0dccf12bc93f615a535aad1066170" + integrity sha512-puyri6ApkEHYiVl4CFzo1tDkAZ+ATcnbJrJ6RiBM1Fhctdn/ix9MTE3hRph33omisEbC/2fcfemsseiKgBPKZg== + dependencies: + dijkstrajs "^1.0.1" + encode-utf8 "^1.0.3" + pngjs "^5.0.0" + yargs "^15.3.1" + qs@6.11.0: version "6.11.0" resolved "https://registry.yarnpkg.com/qs/-/qs-6.11.0.tgz#fd0d963446f7a65e1367e01abd85429453f0c37a" @@ -18860,6 +18885,13 @@ react-native-progress@bluesky-social/react-native-progress: dependencies: prop-types "^15.7.2" +react-native-qrcode-styled@^0.3.1: + version "0.3.1" + resolved "https://registry.yarnpkg.com/react-native-qrcode-styled/-/react-native-qrcode-styled-0.3.1.tgz#be6a0fab173511b0d3d8d71588771c2230982dbf" + integrity sha512-Q4EqbIFV0rpCYcdmWY51+H8Vrc0fvP01hPkiSqPEmjjxhm6mqyAuTMdNHNEddLXZzCVQCJujvj6IrHjdAhKjnA== + dependencies: + qrcode "^1.5.1" + react-native-reanimated@^3.11.0: version "3.11.0" resolved "https://registry.yarnpkg.com/react-native-reanimated/-/react-native-reanimated-3.11.0.tgz#d4265d4e0232623f5958ed60e1686ca884fc3452" @@ -22395,7 +22427,7 @@ yargs-parser@^21.1.1: resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-21.1.1.tgz#9096bceebf990d21bb31fa9516e0ede294a77d35" integrity sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw== -yargs@^15.1.0: +yargs@^15.1.0, yargs@^15.3.1: version "15.4.1" resolved "https://registry.yarnpkg.com/yargs/-/yargs-15.4.1.tgz#0d87a16de01aee9d8bec2bfbf74f67851730f4f8" integrity sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A== |