Bluesky app fork with some witchin' additions 💫

Starter Packs (#4332)

Co-authored-by: Dan Abramov <dan.abramov@gmail.com>
Co-authored-by: Paul Frazee <pfrazee@gmail.com>
Co-authored-by: Eric Bailey <git@esb.lol>
Co-authored-by: Samuel Newman <mozzius@protonmail.com>

+6329 -230
+181
__tests__/lib/string.test.ts
··· 1 1 import {RichText} from '@atproto/api' 2 2 3 3 import {parseEmbedPlayerFromUrl} from 'lib/strings/embed-player' 4 + import { 5 + createStarterPackGooglePlayUri, 6 + createStarterPackLinkFromAndroidReferrer, 7 + parseStarterPackUri, 8 + } from 'lib/strings/starter-pack' 4 9 import {cleanError} from '../../src/lib/strings/errors' 5 10 import {createFullHandle, makeValidHandle} from '../../src/lib/strings/handles' 6 11 import {enforceLen} from '../../src/lib/strings/helpers' ··· 796 801 } 797 802 }) 798 803 }) 804 + 805 + describe('createStarterPackLinkFromAndroidReferrer', () => { 806 + const validOutput = 'at://haileyok.com/app.bsky.graph.starterpack/rkey' 807 + 808 + it('returns a link when input contains utm_source and utm_content', () => { 809 + expect( 810 + createStarterPackLinkFromAndroidReferrer( 811 + 'utm_source=bluesky&utm_content=starterpack_haileyok.com_rkey', 812 + ), 813 + ).toEqual(validOutput) 814 + 815 + expect( 816 + createStarterPackLinkFromAndroidReferrer( 817 + 'utm_source=bluesky&utm_content=starterpack_test-lover-9000.com_rkey', 818 + ), 819 + ).toEqual('at://test-lover-9000.com/app.bsky.graph.starterpack/rkey') 820 + }) 821 + 822 + it('returns a link when input contains utm_source and utm_content in different order', () => { 823 + expect( 824 + createStarterPackLinkFromAndroidReferrer( 825 + 'utm_content=starterpack_haileyok.com_rkey&utm_source=bluesky', 826 + ), 827 + ).toEqual(validOutput) 828 + }) 829 + 830 + it('returns a link when input contains other parameters as well', () => { 831 + expect( 832 + createStarterPackLinkFromAndroidReferrer( 833 + 'utm_source=bluesky&utm_medium=starterpack&utm_content=starterpack_haileyok.com_rkey', 834 + ), 835 + ).toEqual(validOutput) 836 + }) 837 + 838 + it('returns null when utm_source is not present', () => { 839 + expect( 840 + createStarterPackLinkFromAndroidReferrer( 841 + 'utm_content=starterpack_haileyok.com_rkey', 842 + ), 843 + ).toEqual(null) 844 + }) 845 + 846 + it('returns null when utm_content is not present', () => { 847 + expect( 848 + createStarterPackLinkFromAndroidReferrer('utm_source=bluesky'), 849 + ).toEqual(null) 850 + }) 851 + 852 + it('returns null when utm_content is malformed', () => { 853 + expect( 854 + createStarterPackLinkFromAndroidReferrer( 855 + 'utm_content=starterpack_haileyok.com', 856 + ), 857 + ).toEqual(null) 858 + 859 + expect( 860 + createStarterPackLinkFromAndroidReferrer('utm_content=starterpack'), 861 + ).toEqual(null) 862 + 863 + expect( 864 + createStarterPackLinkFromAndroidReferrer( 865 + 'utm_content=starterpack_haileyok.com_rkey_more', 866 + ), 867 + ).toEqual(null) 868 + 869 + expect( 870 + createStarterPackLinkFromAndroidReferrer( 871 + 'utm_content=notastarterpack_haileyok.com_rkey', 872 + ), 873 + ).toEqual(null) 874 + }) 875 + }) 876 + 877 + describe('parseStarterPackHttpUri', () => { 878 + const baseUri = 'https://bsky.app/start' 879 + 880 + it('returns a valid at uri when http uri is valid', () => { 881 + const validHttpUri = `${baseUri}/haileyok.com/rkey` 882 + expect(parseStarterPackUri(validHttpUri)).toEqual({ 883 + name: 'haileyok.com', 884 + rkey: 'rkey', 885 + }) 886 + 887 + const validHttpUri2 = `${baseUri}/haileyok.com/ilovetesting` 888 + expect(parseStarterPackUri(validHttpUri2)).toEqual({ 889 + name: 'haileyok.com', 890 + rkey: 'ilovetesting', 891 + }) 892 + 893 + const validHttpUri3 = `${baseUri}/testlover9000.com/rkey` 894 + expect(parseStarterPackUri(validHttpUri3)).toEqual({ 895 + name: 'testlover9000.com', 896 + rkey: 'rkey', 897 + }) 898 + }) 899 + 900 + it('returns null when there is no rkey', () => { 901 + const validHttpUri = `${baseUri}/haileyok.com` 902 + expect(parseStarterPackUri(validHttpUri)).toEqual(null) 903 + }) 904 + 905 + it('returns null when there is an extra path', () => { 906 + const validHttpUri = `${baseUri}/haileyok.com/rkey/other` 907 + expect(parseStarterPackUri(validHttpUri)).toEqual(null) 908 + }) 909 + 910 + it('returns null when there is no handle or rkey', () => { 911 + const validHttpUri = `${baseUri}` 912 + expect(parseStarterPackUri(validHttpUri)).toEqual(null) 913 + }) 914 + 915 + it('returns null when the route is not /start or /starter-pack', () => { 916 + const validHttpUri = 'https://bsky.app/start/haileyok.com/rkey' 917 + expect(parseStarterPackUri(validHttpUri)).toEqual({ 918 + name: 'haileyok.com', 919 + rkey: 'rkey', 920 + }) 921 + 922 + const validHttpUri2 = 'https://bsky.app/starter-pack/haileyok.com/rkey' 923 + expect(parseStarterPackUri(validHttpUri2)).toEqual({ 924 + name: 'haileyok.com', 925 + rkey: 'rkey', 926 + }) 927 + 928 + const invalidHttpUri = 'https://bsky.app/profile/haileyok.com/rkey' 929 + expect(parseStarterPackUri(invalidHttpUri)).toEqual(null) 930 + }) 931 + 932 + it('returns the at uri when the input is a valid starterpack at uri', () => { 933 + const validAtUri = 'at://did:123/app.bsky.graph.starterpack/rkey' 934 + expect(parseStarterPackUri(validAtUri)).toEqual({ 935 + name: 'did:123', 936 + rkey: 'rkey', 937 + }) 938 + }) 939 + 940 + it('returns null when the at uri has no rkey', () => { 941 + const validAtUri = 'at://did:123/app.bsky.graph.starterpack' 942 + expect(parseStarterPackUri(validAtUri)).toEqual(null) 943 + }) 944 + 945 + it('returns null when the collection is not app.bsky.graph.starterpack', () => { 946 + const validAtUri = 'at://did:123/app.bsky.graph.list/rkey' 947 + expect(parseStarterPackUri(validAtUri)).toEqual(null) 948 + }) 949 + 950 + it('returns null when the input is undefined', () => { 951 + expect(parseStarterPackUri(undefined)).toEqual(null) 952 + }) 953 + }) 954 + 955 + describe('createStarterPackGooglePlayUri', () => { 956 + const base = 957 + 'https://play.google.com/store/apps/details?id=xyz.blueskyweb.app&referrer=utm_source%3Dbluesky%26utm_medium%3Dstarterpack%26utm_content%3Dstarterpack_' 958 + 959 + it('returns valid google play uri when input is valid', () => { 960 + expect(createStarterPackGooglePlayUri('name', 'rkey')).toEqual( 961 + `${base}name_rkey`, 962 + ) 963 + }) 964 + 965 + it('returns null when no rkey is supplied', () => { 966 + // @ts-expect-error test 967 + expect(createStarterPackGooglePlayUri('name', undefined)).toEqual(null) 968 + }) 969 + 970 + it('returns null when no name or rkey are supplied', () => { 971 + // @ts-expect-error test 972 + expect(createStarterPackGooglePlayUri(undefined, undefined)).toEqual(null) 973 + }) 974 + 975 + it('returns null when rkey is supplied but no name', () => { 976 + // @ts-expect-error test 977 + expect(createStarterPackGooglePlayUri(undefined, 'rkey')).toEqual(null) 978 + }) 979 + })
+17 -1
app.config.js
··· 39 39 const IS_TESTFLIGHT = process.env.EXPO_PUBLIC_ENV === 'testflight' 40 40 const IS_PRODUCTION = process.env.EXPO_PUBLIC_ENV === 'production' 41 41 42 + const ASSOCIATED_DOMAINS = [ 43 + 'applinks:bsky.app', 44 + 'applinks:staging.bsky.app', 45 + 'appclips:bsky.app', 46 + 'appclips:go.bsky.app', // Allows App Clip to work when scanning QR codes 47 + // When testing local services, enter an ngrok (et al) domain here. It must use a standard HTTP/HTTPS port. 48 + ...(IS_DEV || IS_TESTFLIGHT 49 + ? ['appclips:sptesting.haileyok.com', 'applinks:sptesting.haileyok.com'] 50 + : []), 51 + ] 52 + 42 53 const UPDATES_CHANNEL = IS_TESTFLIGHT 43 54 ? 'testflight' 44 55 : IS_PRODUCTION ··· 83 94 NSPhotoLibraryUsageDescription: 84 95 'Used for profile pictures, posts, and other kinds of content', 85 96 }, 86 - associatedDomains: ['applinks:bsky.app', 'applinks:staging.bsky.app'], 97 + associatedDomains: ASSOCIATED_DOMAINS, 87 98 splash: { 88 99 ...SPLASH_CONFIG, 89 100 dark: DARK_SPLASH_CONFIG, ··· 202 213 sounds: PLATFORM === 'ios' ? ['assets/dm.aiff'] : ['assets/dm.mp3'], 203 214 }, 204 215 ], 216 + './plugins/starterPackAppClipExtension/withStarterPackAppClip.js', 205 217 './plugins/withAndroidManifestPlugin.js', 206 218 './plugins/withAndroidManifestFCMIconPlugin.js', 207 219 './plugins/withAndroidStylesWindowBackgroundPlugin.js', ··· 233 245 'group.app.bsky', 234 246 ], 235 247 }, 248 + }, 249 + { 250 + targetName: 'BlueskyClip', 251 + bundleIdentifier: 'xyz.blueskyweb.app.AppClip', 236 252 }, 237 253 ], 238 254 },
+1
assets/icons/qrCode_stroke2_corner0_rounded.svg
··· 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>
+1
assets/icons/starterPack.svg
··· 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>
+1
assets/icons/starter_pack_icon.svg
··· 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>
assets/logo.png

This is a binary file and will not be displayed.

+4
bskyweb/cmd/bskyweb/server.go
··· 223 223 e.GET("/profile/:handleOrDID/post/:rkey/liked-by", server.WebGeneric) 224 224 e.GET("/profile/:handleOrDID/post/:rkey/reposted-by", server.WebGeneric) 225 225 226 + // starter packs 227 + e.GET("/starter-pack/:handleOrDID/:rkey", server.WebGeneric) 228 + e.GET("/start/:handleOrDID/:rkey", server.WebGeneric) 229 + 226 230 if linkHost != "" { 227 231 linkUrl, err := url.Parse(linkHost) 228 232 if err != nil {
+4 -2
bskyweb/static/.well-known/apple-app-site-association
··· 1 1 { 2 2 "applinks": { 3 - "apps": [], 3 + "appclips": { 4 + "apps": ["B3LX46C5HS.xyz.blueskyweb.app.AppClip"] 5 + }, 4 6 "details": [ 5 7 { 6 8 "appID": "B3LX46C5HS.xyz.blueskyweb.app", ··· 10 12 } 11 13 ] 12 14 } 13 - } 15 + }
+32
modules/BlueskyClip/AppDelegate.swift
··· 1 + import UIKit 2 + 3 + @main 4 + class AppDelegate: UIResponder, UIApplicationDelegate { 5 + var window: UIWindow? 6 + var controller: ViewController? 7 + 8 + func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { 9 + let window = UIWindow() 10 + self.window = UIWindow() 11 + 12 + let controller = ViewController(window: window) 13 + self.controller = controller 14 + 15 + window.rootViewController = self.controller 16 + window.makeKeyAndVisible() 17 + 18 + return true 19 + } 20 + 21 + func application(_ app: UIApplication, open url: URL, options: [UIApplication.OpenURLOptionsKey: Any] = [:]) -> Bool { 22 + self.controller?.handleURL(url: url) 23 + return true 24 + } 25 + 26 + func application(_ application: UIApplication, continue userActivity: NSUserActivity, restorationHandler: @escaping ([UIUserActivityRestoring]?) -> Void) -> Bool { 27 + if let incomingURL = userActivity.webpageURL { 28 + self.controller?.handleURL(url: incomingURL) 29 + } 30 + return true 31 + } 32 + }
modules/BlueskyClip/Images.xcassets/AppIcon.appiconset/App-Icon-1024x1024@1x.png

This is a binary file and will not be displayed.

+14
modules/BlueskyClip/Images.xcassets/AppIcon.appiconset/Contents.json
··· 1 + { 2 + "images" : [ 3 + { 4 + "filename" : "App-Icon-1024x1024@1x.png", 5 + "idiom" : "universal", 6 + "platform" : "ios", 7 + "size" : "1024x1024" 8 + } 9 + ], 10 + "info" : { 11 + "author" : "xcode", 12 + "version" : 1 13 + } 14 + }
+6
modules/BlueskyClip/Images.xcassets/Contents.json
··· 1 + { 2 + "info" : { 3 + "author" : "xcode", 4 + "version" : 1 5 + } 6 + }
+133
modules/BlueskyClip/ViewController.swift
··· 1 + import UIKit 2 + import WebKit 3 + import StoreKit 4 + 5 + class ViewController: UIViewController, WKScriptMessageHandler, WKNavigationDelegate { 6 + let defaults = UserDefaults(suiteName: "group.app.bsky") 7 + 8 + var window: UIWindow 9 + var webView: WKWebView? 10 + 11 + var prevUrl: URL? 12 + var starterPackUrl: URL? 13 + 14 + init(window: UIWindow) { 15 + self.window = window 16 + super.init(nibName: nil, bundle: nil) 17 + } 18 + 19 + required init?(coder: NSCoder) { 20 + fatalError("init(coder:) has not been implemented") 21 + } 22 + 23 + override func viewDidLoad() { 24 + super.viewDidLoad() 25 + 26 + let contentController = WKUserContentController() 27 + contentController.add(self, name: "onMessage") 28 + let configuration = WKWebViewConfiguration() 29 + configuration.userContentController = contentController 30 + 31 + let webView = WKWebView(frame: self.view.bounds, configuration: configuration) 32 + webView.translatesAutoresizingMaskIntoConstraints = false 33 + webView.contentMode = .scaleToFill 34 + webView.navigationDelegate = self 35 + self.view.addSubview(webView) 36 + self.webView = webView 37 + } 38 + 39 + func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) { 40 + guard let response = message.body as? String, 41 + let data = response.data(using: .utf8), 42 + let payload = try? JSONDecoder().decode(WebViewActionPayload.self, from: data) else { 43 + return 44 + } 45 + 46 + switch payload.action { 47 + case .present: 48 + guard let url = self.starterPackUrl else { 49 + return 50 + } 51 + 52 + self.presentAppStoreOverlay() 53 + defaults?.setValue(url.absoluteString, forKey: "starterPackUri") 54 + 55 + case .store: 56 + guard let keyToStoreAs = payload.keyToStoreAs, let jsonToStore = payload.jsonToStore else { 57 + return 58 + } 59 + 60 + self.defaults?.setValue(jsonToStore, forKey: keyToStoreAs) 61 + } 62 + } 63 + 64 + func webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction) async -> WKNavigationActionPolicy { 65 + // Detect when we land on the right URL. This is incase of a short link opening the app clip 66 + guard let url = navigationAction.request.url else { 67 + return .allow 68 + } 69 + 70 + // Store the previous one to compare later, but only set starterPackUrl when we find the right one 71 + prevUrl = url 72 + // pathComponents starts with "/" as the first component, then each path name. so... 73 + // ["/", "start", "name", "rkey"] 74 + if url.pathComponents.count == 4, 75 + url.pathComponents[1] == "start" { 76 + self.starterPackUrl = url 77 + } 78 + 79 + return .allow 80 + } 81 + 82 + func handleURL(url: URL) { 83 + let urlString = "\(url.absoluteString)?clip=true" 84 + if let url = URL(string: urlString) { 85 + self.webView?.load(URLRequest(url: url)) 86 + } 87 + } 88 + 89 + func presentAppStoreOverlay() { 90 + guard let windowScene = self.window.windowScene else { 91 + return 92 + } 93 + 94 + let configuration = SKOverlay.AppClipConfiguration(position: .bottomRaised) 95 + let overlay = SKOverlay(configuration: configuration) 96 + 97 + overlay.present(in: windowScene) 98 + } 99 + 100 + func getHost(_ url: URL?) -> String? { 101 + if #available(iOS 16.0, *) { 102 + return url?.host() 103 + } else { 104 + return url?.host 105 + } 106 + } 107 + 108 + func getQuery(_ url: URL?) -> String? { 109 + if #available(iOS 16.0, *) { 110 + return url?.query() 111 + } else { 112 + return url?.query 113 + } 114 + } 115 + 116 + func urlMatchesPrevious(_ url: URL?) -> Bool { 117 + if #available(iOS 16.0, *) { 118 + return url?.query() == prevUrl?.query() && url?.host() == prevUrl?.host() && url?.query() == prevUrl?.query() 119 + } else { 120 + return url?.query == prevUrl?.query && url?.host == prevUrl?.host && url?.query == prevUrl?.query 121 + } 122 + } 123 + } 124 + 125 + struct WebViewActionPayload: Decodable { 126 + enum Action: String, Decodable { 127 + case present, store 128 + } 129 + 130 + let action: Action 131 + let keyToStoreAs: String? 132 + let jsonToStore: String? 133 + }
+47
modules/expo-bluesky-swiss-army/android/build.gradle
··· 1 + apply plugin: 'com.android.library' 2 + 3 + group = 'expo.modules.blueskyswissarmy' 4 + version = '0.6.0' 5 + 6 + def expoModulesCorePlugin = new File(project(":expo-modules-core").projectDir.absolutePath, "ExpoModulesCorePlugin.gradle") 7 + apply from: expoModulesCorePlugin 8 + applyKotlinExpoModulesCorePlugin() 9 + useCoreDependencies() 10 + useExpoPublishing() 11 + 12 + // If you want to use the managed Android SDK versions from expo-modules-core, set this to true. 13 + // The Android SDK versions will be bumped from time to time in SDK releases and may introduce breaking changes in your module code. 14 + // Most of the time, you may like to manage the Android SDK versions yourself. 15 + def useManagedAndroidSdkVersions = false 16 + if (useManagedAndroidSdkVersions) { 17 + useDefaultAndroidSdkVersions() 18 + } else { 19 + buildscript { 20 + // Simple helper that allows the root project to override versions declared by this library. 21 + ext.safeExtGet = { prop, fallback -> 22 + rootProject.ext.has(prop) ? rootProject.ext.get(prop) : fallback 23 + } 24 + } 25 + project.android { 26 + compileSdkVersion safeExtGet("compileSdkVersion", 34) 27 + defaultConfig { 28 + minSdkVersion safeExtGet("minSdkVersion", 21) 29 + targetSdkVersion safeExtGet("targetSdkVersion", 34) 30 + } 31 + } 32 + } 33 + 34 + android { 35 + namespace "expo.modules.blueskyswissarmy" 36 + defaultConfig { 37 + versionCode 1 38 + versionName "0.6.0" 39 + } 40 + lintOptions { 41 + abortOnError false 42 + } 43 + } 44 + 45 + dependencies { 46 + implementation("com.android.installreferrer:installreferrer:2.2") 47 + }
+2
modules/expo-bluesky-swiss-army/android/src/main/AndroidManifest.xml
··· 1 + <manifest> 2 + </manifest>
+10
modules/expo-bluesky-swiss-army/android/src/main/java/expo/modules/blueskyswissarmy/deviceprefs/ExpoBlueskyDevicePrefsModule.kt
··· 1 + package expo.modules.blueskyswissarmy.deviceprefs 2 + 3 + import expo.modules.kotlin.modules.Module 4 + import expo.modules.kotlin.modules.ModuleDefinition 5 + 6 + class ExpoBlueskyDevicePrefsModule : Module() { 7 + override fun definition() = ModuleDefinition { 8 + Name("ExpoBlueskyDevicePrefs") 9 + } 10 + }
+54
modules/expo-bluesky-swiss-army/android/src/main/java/expo/modules/blueskyswissarmy/referrer/ExpoBlueskyReferrerModule.kt
··· 1 + package expo.modules.blueskyswissarmy.referrer 2 + 3 + import android.util.Log 4 + import com.android.installreferrer.api.InstallReferrerClient 5 + import com.android.installreferrer.api.InstallReferrerStateListener 6 + import expo.modules.kotlin.modules.Module 7 + import expo.modules.kotlin.modules.ModuleDefinition 8 + import expo.modules.kotlin.Promise 9 + 10 + class ExpoBlueskyReferrerModule : Module() { 11 + override fun definition() = ModuleDefinition { 12 + Name("ExpoBlueskyReferrer") 13 + 14 + AsyncFunction("getGooglePlayReferrerInfoAsync") { promise: Promise -> 15 + val referrerClient = InstallReferrerClient.newBuilder(appContext.reactContext).build() 16 + referrerClient.startConnection(object : InstallReferrerStateListener { 17 + override fun onInstallReferrerSetupFinished(responseCode: Int) { 18 + if (responseCode == InstallReferrerClient.InstallReferrerResponse.OK) { 19 + Log.d("ExpoGooglePlayReferrer", "Successfully retrieved referrer info.") 20 + 21 + val response = referrerClient.installReferrer 22 + Log.d("ExpoGooglePlayReferrer", "Install referrer: ${response.installReferrer}") 23 + 24 + promise.resolve( 25 + mapOf( 26 + "installReferrer" to response.installReferrer, 27 + "clickTimestamp" to response.referrerClickTimestampSeconds, 28 + "installTimestamp" to response.installBeginTimestampSeconds 29 + ) 30 + ) 31 + } else { 32 + Log.d("ExpoGooglePlayReferrer", "Failed to get referrer info. Unknown error.") 33 + promise.reject( 34 + "ERR_GOOGLE_PLAY_REFERRER_UNKNOWN", 35 + "Failed to get referrer info", 36 + Exception("Failed to get referrer info") 37 + ) 38 + } 39 + referrerClient.endConnection() 40 + } 41 + 42 + override fun onInstallReferrerServiceDisconnected() { 43 + Log.d("ExpoGooglePlayReferrer", "Failed to get referrer info. Service disconnected.") 44 + referrerClient.endConnection() 45 + promise.reject( 46 + "ERR_GOOGLE_PLAY_REFERRER_DISCONNECTED", 47 + "Failed to get referrer info", 48 + Exception("Failed to get referrer info") 49 + ) 50 + } 51 + }) 52 + } 53 + } 54 + }
+12
modules/expo-bluesky-swiss-army/expo-module.config.json
··· 1 + { 2 + "platforms": ["ios", "tvos", "android", "web"], 3 + "ios": { 4 + "modules": ["ExpoBlueskyDevicePrefsModule", "ExpoBlueskyReferrerModule"] 5 + }, 6 + "android": { 7 + "modules": [ 8 + "expo.modules.blueskyswissarmy.deviceprefs.ExpoBlueskyDevicePrefsModule", 9 + "expo.modules.blueskyswissarmy.referrer.ExpoBlueskyReferrerModule" 10 + ] 11 + } 12 + }
+4
modules/expo-bluesky-swiss-army/index.ts
··· 1 + import * as DevicePrefs from './src/DevicePrefs' 2 + import * as Referrer from './src/Referrer' 3 + 4 + export {DevicePrefs, Referrer}
+23
modules/expo-bluesky-swiss-army/ios/DevicePrefs/ExpoBlueskyDevicePrefsModule.swift
··· 1 + import ExpoModulesCore 2 + 3 + public class ExpoBlueskyDevicePrefsModule: Module { 4 + func getDefaults(_ useAppGroup: Bool) -> UserDefaults? { 5 + if useAppGroup { 6 + return UserDefaults(suiteName: "group.app.bsky") 7 + } else { 8 + return UserDefaults.standard 9 + } 10 + } 11 + 12 + public func definition() -> ModuleDefinition { 13 + Name("ExpoBlueskyDevicePrefs") 14 + 15 + AsyncFunction("getStringValueAsync") { (key: String, useAppGroup: Bool) in 16 + return self.getDefaults(useAppGroup)?.string(forKey: key) 17 + } 18 + 19 + AsyncFunction("setStringValueAsync") { (key: String, value: String?, useAppGroup: Bool) in 20 + self.getDefaults(useAppGroup)?.setValue(value, forKey: key) 21 + } 22 + } 23 + }
+21
modules/expo-bluesky-swiss-army/ios/ExpoBlueskySwissArmy.podspec
··· 1 + Pod::Spec.new do |s| 2 + s.name = 'ExpoBlueskySwissArmy' 3 + s.version = '1.0.0' 4 + s.summary = 'A collection of native tools for Bluesky' 5 + s.description = 'A collection of native tools for Bluesky' 6 + s.author = '' 7 + s.homepage = 'https://github.com/bluesky-social/social-app' 8 + s.platforms = { :ios => '13.4', :tvos => '13.4' } 9 + s.source = { git: '' } 10 + s.static_framework = true 11 + 12 + s.dependency 'ExpoModulesCore' 13 + 14 + # Swift/Objective-C compatibility 15 + s.pod_target_xcconfig = { 16 + 'DEFINES_MODULE' => 'YES', 17 + 'SWIFT_COMPILATION_MODE' => 'wholemodule' 18 + } 19 + 20 + s.source_files = "**/*.{h,m,mm,swift,hpp,cpp}" 21 + end
+7
modules/expo-bluesky-swiss-army/ios/Referrer/ExpoBlueskyReferrerModule.swift
··· 1 + import ExpoModulesCore 2 + 3 + public class ExpoBlueskyReferrerModule: Module { 4 + public func definition() -> ModuleDefinition { 5 + Name("ExpoBlueskyReferrer") 6 + } 7 + }
+18
modules/expo-bluesky-swiss-army/src/DevicePrefs/index.ios.ts
··· 1 + import {requireNativeModule} from 'expo-modules-core' 2 + 3 + const NativeModule = requireNativeModule('ExpoBlueskyDevicePrefs') 4 + 5 + export function getStringValueAsync( 6 + key: string, 7 + useAppGroup?: boolean, 8 + ): Promise<string | null> { 9 + return NativeModule.getStringValueAsync(key, useAppGroup) 10 + } 11 + 12 + export function setStringValueAsync( 13 + key: string, 14 + value: string | null, 15 + useAppGroup?: boolean, 16 + ): Promise<void> { 17 + return NativeModule.setStringValueAsync(key, value, useAppGroup) 18 + }
+16
modules/expo-bluesky-swiss-army/src/DevicePrefs/index.ts
··· 1 + import {NotImplementedError} from '../NotImplemented' 2 + 3 + export function getStringValueAsync( 4 + key: string, 5 + useAppGroup?: boolean, 6 + ): Promise<string | null> { 7 + throw new NotImplementedError({key, useAppGroup}) 8 + } 9 + 10 + export function setStringValueAsync( 11 + key: string, 12 + value: string | null, 13 + useAppGroup?: boolean, 14 + ): Promise<string | null> { 15 + throw new NotImplementedError({key, value, useAppGroup}) 16 + }
+16
modules/expo-bluesky-swiss-army/src/NotImplemented.ts
··· 1 + import {Platform} from 'react-native' 2 + 3 + export class NotImplementedError extends Error { 4 + constructor(params = {}) { 5 + if (__DEV__) { 6 + const caller = new Error().stack?.split('\n')[2] 7 + super( 8 + `Not implemented on ${Platform.OS}. Given params: ${JSON.stringify( 9 + params, 10 + )} ${caller}`, 11 + ) 12 + } else { 13 + super('Not implemented') 14 + } 15 + } 16 + }
+9
modules/expo-bluesky-swiss-army/src/Referrer/index.android.ts
··· 1 + import {requireNativeModule} from 'expo' 2 + 3 + import {GooglePlayReferrerInfo} from './types' 4 + 5 + export const NativeModule = requireNativeModule('ExpoBlueskyReferrer') 6 + 7 + export function getGooglePlayReferrerInfoAsync(): Promise<GooglePlayReferrerInfo> { 8 + return NativeModule.getGooglePlayReferrerInfoAsync() 9 + }
+7
modules/expo-bluesky-swiss-army/src/Referrer/index.ts
··· 1 + import {NotImplementedError} from '../NotImplemented' 2 + import {GooglePlayReferrerInfo} from './types' 3 + 4 + // @ts-ignore throws 5 + export function getGooglePlayReferrerInfoAsync(): Promise<GooglePlayReferrerInfo> { 6 + throw new NotImplementedError() 7 + }
+7
modules/expo-bluesky-swiss-army/src/Referrer/types.ts
··· 1 + export type GooglePlayReferrerInfo = 2 + | { 3 + installReferrer?: string 4 + clickTimestamp?: number 5 + installTimestamp?: number 6 + } 7 + | undefined
+3 -2
package.json
··· 49 49 "open-analyzer": "EXPO_PUBLIC_OPEN_ANALYZER=1 yarn build-web" 50 50 }, 51 51 "dependencies": { 52 - "@atproto/api": "^0.12.20", 52 + "@atproto/api": "0.12.22-next.0", 53 53 "@bam.tech/react-native-image-resizer": "^3.0.4", 54 54 "@braintree/sanitize-url": "^6.0.2", 55 55 "@discord/bottom-sheet": "bluesky-social/react-native-bottom-sheet", ··· 177 177 "react-native-pager-view": "6.2.3", 178 178 "react-native-picker-select": "^9.1.3", 179 179 "react-native-progress": "bluesky-social/react-native-progress", 180 + "react-native-qrcode-styled": "^0.3.1", 180 181 "react-native-reanimated": "^3.11.0", 181 182 "react-native-root-siblings": "^4.1.1", 182 183 "react-native-safe-area-context": "4.10.1", ··· 205 206 "@babel/preset-env": "^7.20.0", 206 207 "@babel/runtime": "^7.20.0", 207 208 "@did-plc/server": "^0.0.1", 208 - "@expo/config-plugins": "7.8.0", 209 + "@expo/config-plugins": "8.0.4", 209 210 "@expo/prebuild-config": "6.7.0", 210 211 "@lingui/cli": "^4.5.0", 211 212 "@lingui/macro": "^4.5.0",
+16
plugins/starterPackAppClipExtension/withAppEntitlements.js
··· 1 + const {withEntitlementsPlist} = require('@expo/config-plugins') 2 + 3 + const withAppEntitlements = config => { 4 + // eslint-disable-next-line no-shadow 5 + return withEntitlementsPlist(config, async config => { 6 + config.modResults['com.apple.security.application-groups'] = [ 7 + `group.app.bsky`, 8 + ] 9 + config.modResults[ 10 + 'com.apple.developer.associated-appclip-app-identifiers' 11 + ] = [`$(AppIdentifierPrefix)${config.ios.bundleIdentifier}.AppClip`] 12 + return config 13 + }) 14 + } 15 + 16 + module.exports = {withAppEntitlements}
+32
plugins/starterPackAppClipExtension/withClipEntitlements.js
··· 1 + const {withInfoPlist} = require('@expo/config-plugins') 2 + const plist = require('@expo/plist') 3 + const path = require('path') 4 + const fs = require('fs') 5 + 6 + const withClipEntitlements = (config, {targetName}) => { 7 + // eslint-disable-next-line no-shadow 8 + return withInfoPlist(config, config => { 9 + const entitlementsPath = path.join( 10 + config.modRequest.platformProjectRoot, 11 + targetName, 12 + `${targetName}.entitlements`, 13 + ) 14 + 15 + const appClipEntitlements = { 16 + 'com.apple.security.application-groups': [`group.app.bsky`], 17 + 'com.apple.developer.parent-application-identifiers': [ 18 + `$(AppIdentifierPrefix)${config.ios.bundleIdentifier}`, 19 + ], 20 + 'com.apple.developer.associated-domains': config.ios.associatedDomains, 21 + } 22 + 23 + fs.mkdirSync(path.dirname(entitlementsPath), { 24 + recursive: true, 25 + }) 26 + fs.writeFileSync(entitlementsPath, plist.default.build(appClipEntitlements)) 27 + 28 + return config 29 + }) 30 + } 31 + 32 + module.exports = {withClipEntitlements}
+38
plugins/starterPackAppClipExtension/withClipInfoPlist.js
··· 1 + const {withInfoPlist} = require('@expo/config-plugins') 2 + const plist = require('@expo/plist') 3 + const path = require('path') 4 + const fs = require('fs') 5 + 6 + const withClipInfoPlist = (config, {targetName}) => { 7 + // eslint-disable-next-line no-shadow 8 + return withInfoPlist(config, config => { 9 + const targetPath = path.join( 10 + config.modRequest.platformProjectRoot, 11 + targetName, 12 + 'Info.plist', 13 + ) 14 + 15 + const newPlist = plist.default.build({ 16 + NSAppClip: { 17 + NSAppClipRequestEphemeralUserNotification: false, 18 + NSAppClipRequestLocationConfirmation: false, 19 + }, 20 + UILaunchScreen: {}, 21 + CFBundleName: '$(PRODUCT_NAME)', 22 + CFBundleIdentifier: '$(PRODUCT_BUNDLE_IDENTIFIER)', 23 + CFBundleVersion: '$(CURRENT_PROJECT_VERSION)', 24 + CFBundleExecutable: '$(EXECUTABLE_NAME)', 25 + CFBundlePackageType: '$(PRODUCT_BUNDLE_PACKAGE_TYPE)', 26 + CFBundleShortVersionString: config.version, 27 + CFBundleIconName: 'AppIcon', 28 + UIViewControllerBasedStatusBarAppearance: 'NO', 29 + }) 30 + 31 + fs.mkdirSync(path.dirname(targetPath), {recursive: true}) 32 + fs.writeFileSync(targetPath, newPlist) 33 + 34 + return config 35 + }) 36 + } 37 + 38 + module.exports = {withClipInfoPlist}
+40
plugins/starterPackAppClipExtension/withFiles.js
··· 1 + const {withXcodeProject} = require('@expo/config-plugins') 2 + const path = require('path') 3 + const fs = require('fs') 4 + 5 + const FILES = ['AppDelegate.swift', 'ViewController.swift'] 6 + 7 + const withFiles = (config, {targetName}) => { 8 + // eslint-disable-next-line no-shadow 9 + return withXcodeProject(config, config => { 10 + const basePath = path.join( 11 + config.modRequest.projectRoot, 12 + 'modules', 13 + targetName, 14 + ) 15 + 16 + for (const file of FILES) { 17 + const sourcePath = path.join(basePath, file) 18 + const targetPath = path.join( 19 + config.modRequest.platformProjectRoot, 20 + targetName, 21 + file, 22 + ) 23 + 24 + fs.mkdirSync(path.dirname(targetPath), {recursive: true}) 25 + fs.copyFileSync(sourcePath, targetPath) 26 + } 27 + 28 + const imagesBasePath = path.join(basePath, 'Images.xcassets') 29 + const imagesTargetPath = path.join( 30 + config.modRequest.platformProjectRoot, 31 + targetName, 32 + 'Images.xcassets', 33 + ) 34 + fs.cpSync(imagesBasePath, imagesTargetPath, {recursive: true}) 35 + 36 + return config 37 + }) 38 + } 39 + 40 + module.exports = {withFiles}
+40
plugins/starterPackAppClipExtension/withStarterPackAppClip.js
··· 1 + const {withPlugins} = require('@expo/config-plugins') 2 + const {withAppEntitlements} = require('./withAppEntitlements') 3 + const {withClipEntitlements} = require('./withClipEntitlements') 4 + const {withClipInfoPlist} = require('./withClipInfoPlist') 5 + const {withFiles} = require('./withFiles') 6 + const {withXcodeTarget} = require('./withXcodeTarget') 7 + 8 + const APP_CLIP_TARGET_NAME = 'BlueskyClip' 9 + 10 + const withStarterPackAppClip = config => { 11 + return withPlugins(config, [ 12 + withAppEntitlements, 13 + [ 14 + withClipEntitlements, 15 + { 16 + targetName: APP_CLIP_TARGET_NAME, 17 + }, 18 + ], 19 + [ 20 + withClipInfoPlist, 21 + { 22 + targetName: APP_CLIP_TARGET_NAME, 23 + }, 24 + ], 25 + [ 26 + withFiles, 27 + { 28 + targetName: APP_CLIP_TARGET_NAME, 29 + }, 30 + ], 31 + [ 32 + withXcodeTarget, 33 + { 34 + targetName: APP_CLIP_TARGET_NAME, 35 + }, 36 + ], 37 + ]) 38 + } 39 + 40 + module.exports = withStarterPackAppClip
+91
plugins/starterPackAppClipExtension/withXcodeTarget.js
··· 1 + const {withXcodeProject} = require('@expo/config-plugins') 2 + 3 + const BUILD_PHASE_FILES = ['AppDelegate.swift', 'ViewController.swift'] 4 + 5 + const withXcodeTarget = (config, {targetName}) => { 6 + // eslint-disable-next-line no-shadow 7 + return withXcodeProject(config, config => { 8 + const pbxProject = config.modResults 9 + 10 + const target = pbxProject.addTarget(targetName, 'application', targetName) 11 + target.pbxNativeTarget.productType = `"com.apple.product-type.application.on-demand-install-capable"` 12 + pbxProject.addBuildPhase( 13 + BUILD_PHASE_FILES.map(f => `${targetName}/${f}`), 14 + 'PBXSourcesBuildPhase', 15 + 'Sources', 16 + target.uuid, 17 + 'application', 18 + '"AppClips"', 19 + ) 20 + pbxProject.addBuildPhase( 21 + [`${targetName}/Images.xcassets`], 22 + 'PBXResourcesBuildPhase', 23 + 'Resources', 24 + target.uuid, 25 + 'application', 26 + '"AppClips"', 27 + ) 28 + 29 + const pbxGroup = pbxProject.addPbxGroup([ 30 + 'AppDelegate.swift', 31 + 'ViewController.swift', 32 + 'Images.xcassets', 33 + `${targetName}.entitlements`, 34 + 'Info.plist', 35 + ]) 36 + 37 + pbxProject.addFile(`${targetName}/Info.plist`, pbxGroup.uuid) 38 + const configurations = pbxProject.pbxXCBuildConfigurationSection() 39 + for (const key in configurations) { 40 + if (typeof configurations[key].buildSettings !== 'undefined') { 41 + const buildSettingsObj = configurations[key].buildSettings 42 + if ( 43 + typeof buildSettingsObj.PRODUCT_NAME !== 'undefined' && 44 + buildSettingsObj.PRODUCT_NAME === `"${targetName}"` 45 + ) { 46 + buildSettingsObj.CLANG_ENABLE_MODULES = 'YES' 47 + buildSettingsObj.INFOPLIST_FILE = `"${targetName}/Info.plist"` 48 + buildSettingsObj.CODE_SIGN_ENTITLEMENTS = `"${targetName}/${targetName}.entitlements"` 49 + buildSettingsObj.CODE_SIGN_STYLE = 'Automatic' 50 + buildSettingsObj.CURRENT_PROJECT_VERSION = `"${ 51 + process.env.BSKY_IOS_BUILD_NUMBER ?? '1' 52 + }"` 53 + buildSettingsObj.GENERATE_INFOPLIST_FILE = 'YES' 54 + buildSettingsObj.MARKETING_VERSION = `"${config.version}"` 55 + buildSettingsObj.PRODUCT_BUNDLE_IDENTIFIER = `"${config.ios?.bundleIdentifier}.AppClip"` 56 + buildSettingsObj.SWIFT_EMIT_LOC_STRINGS = 'YES' 57 + buildSettingsObj.SWIFT_VERSION = '5.0' 58 + buildSettingsObj.TARGETED_DEVICE_FAMILY = `"1"` 59 + buildSettingsObj.DEVELOPMENT_TEAM = 'B3LX46C5HS' 60 + buildSettingsObj.IPHONEOS_DEPLOYMENT_TARGET = '14.0' 61 + buildSettingsObj.ASSETCATALOG_COMPILER_APPICON_NAME = 'AppIcon' 62 + } 63 + } 64 + } 65 + 66 + pbxProject.addTargetAttribute('DevelopmentTeam', 'B3LX46C5HS', targetName) 67 + 68 + if (!pbxProject.hash.project.objects.PBXTargetDependency) { 69 + pbxProject.hash.project.objects.PBXTargetDependency = {} 70 + } 71 + if (!pbxProject.hash.project.objects.PBXContainerItemProxy) { 72 + pbxProject.hash.project.objects.PBXContainerItemProxy = {} 73 + } 74 + pbxProject.addTargetDependency(pbxProject.getFirstTarget().uuid, [ 75 + target.uuid, 76 + ]) 77 + 78 + pbxProject.addBuildPhase( 79 + [`${targetName}.app`], 80 + 'PBXCopyFilesBuildPhase', 81 + 'Embed App Clips', 82 + pbxProject.getFirstTarget().uuid, 83 + 'application', 84 + '"AppClips"', 85 + ) 86 + 87 + return config 88 + }) 89 + } 90 + 91 + module.exports = {withXcodeTarget}
+9
scripts/updateExtensions.sh
··· 1 1 #!/bin/bash 2 2 IOS_SHARE_EXTENSION_DIRECTORY="./ios/Share-with-Bluesky" 3 3 IOS_NOTIFICATION_EXTENSION_DIRECTORY="./ios/BlueskyNSE" 4 + IOS_APP_CLIP_DIRECTORY="./ios/BlueskyClip" 4 5 MODULES_DIRECTORY="./modules" 5 6 6 7 if [ ! -d $IOS_SHARE_EXTENSION_DIRECTORY ]; then ··· 16 17 else 17 18 cp -R $IOS_NOTIFICATION_EXTENSION_DIRECTORY $MODULES_DIRECTORY 18 19 fi 20 + 21 + 22 + if [ ! -d $IOS_APP_CLIP_DIRECTORY ]; then 23 + echo "$IOS_APP_CLIP_DIRECTORY not found inside of your iOS project." 24 + exit 1 25 + else 26 + cp -R $IOS_APP_CLIP_DIRECTORY $MODULES_DIRECTORY 27 + fi
+7 -2
src/App.native.tsx
··· 46 46 import {Provider as ShellStateProvider} from '#/state/shell' 47 47 import {Provider as LoggedOutViewProvider} from '#/state/shell/logged-out' 48 48 import {Provider as SelectedFeedProvider} from '#/state/shell/selected-feed' 49 + import {Provider as StarterPackProvider} from '#/state/shell/starter-pack' 49 50 import {TestCtrls} from '#/view/com/testing/TestCtrls' 50 51 import * as Toast from '#/view/com/util/Toast' 51 52 import {Shell} from '#/view/shell' 52 53 import {ThemeProvider as Alf} from '#/alf' 53 54 import {useColorModeTheme} from '#/alf/util/useColorModeTheme' 55 + import {useStarterPackEntry} from '#/components/hooks/useStarterPackEntry' 54 56 import {Provider as PortalProvider} from '#/components/Portal' 55 57 import {Splash} from '#/Splash' 56 58 import {BackgroundNotificationPreferencesProvider} from '../modules/expo-background-notification-handler/src/BackgroundNotificationHandlerProvider' ··· 67 69 const {_} = useLingui() 68 70 69 71 useIntentHandler() 72 + const hasCheckedReferrer = useStarterPackEntry() 70 73 71 74 // init 72 75 useEffect(() => { ··· 98 101 <SafeAreaProvider initialMetrics={initialWindowMetrics}> 99 102 <Alf theme={theme}> 100 103 <ThemeProvider theme={theme}> 101 - <Splash isReady={isReady}> 104 + <Splash isReady={isReady && hasCheckedReferrer}> 102 105 <RootSiblingParent> 103 106 <React.Fragment 104 107 // Resets the entire tree below when it changes: ··· 164 167 <LightboxStateProvider> 165 168 <I18nProvider> 166 169 <PortalProvider> 167 - <InnerApp /> 170 + <StarterPackProvider> 171 + <InnerApp /> 172 + </StarterPackProvider> 168 173 </PortalProvider> 169 174 </I18nProvider> 170 175 </LightboxStateProvider>
+7 -2
src/App.web.tsx
··· 35 35 import {Provider as ShellStateProvider} from '#/state/shell' 36 36 import {Provider as LoggedOutViewProvider} from '#/state/shell/logged-out' 37 37 import {Provider as SelectedFeedProvider} from '#/state/shell/selected-feed' 38 + import {Provider as StarterPackProvider} from '#/state/shell/starter-pack' 38 39 import * as Toast from '#/view/com/util/Toast' 39 40 import {ToastContainer} from '#/view/com/util/Toast.web' 40 41 import {Shell} from '#/view/shell/index' 41 42 import {ThemeProvider as Alf} from '#/alf' 42 43 import {useColorModeTheme} from '#/alf/util/useColorModeTheme' 44 + import {useStarterPackEntry} from '#/components/hooks/useStarterPackEntry' 43 45 import {Provider as PortalProvider} from '#/components/Portal' 44 46 import {BackgroundNotificationPreferencesProvider} from '../modules/expo-background-notification-handler/src/BackgroundNotificationHandlerProvider' 45 47 import I18nProvider from './locale/i18nProvider' ··· 52 54 const theme = useColorModeTheme() 53 55 const {_} = useLingui() 54 56 useIntentHandler() 57 + const hasCheckedReferrer = useStarterPackEntry() 55 58 56 59 // init 57 60 useEffect(() => { ··· 77 80 }, [_]) 78 81 79 82 // wait for session to resume 80 - if (!isReady) return null 83 + if (!isReady || !hasCheckedReferrer) return null 81 84 82 85 return ( 83 86 <KeyboardProvider enabled={false}> ··· 146 149 <LightboxStateProvider> 147 150 <I18nProvider> 148 151 <PortalProvider> 149 - <InnerApp /> 152 + <StarterPackProvider> 153 + <InnerApp /> 154 + </StarterPackProvider> 150 155 </PortalProvider> 151 156 </I18nProvider> 152 157 </LightboxStateProvider>
+23
src/Navigation.tsx
··· 43 43 import {ModerationScreen} from '#/screens/Moderation' 44 44 import {ProfileKnownFollowersScreen} from '#/screens/Profile/KnownFollowers' 45 45 import {ProfileLabelerLikedByScreen} from '#/screens/Profile/ProfileLabelerLikedBy' 46 + import {StarterPackScreen} from '#/screens/StarterPack/StarterPackScreen' 47 + import {Wizard} from '#/screens/StarterPack/Wizard' 46 48 import {init as initAnalytics} from './lib/analytics/analytics' 47 49 import {useWebScrollRestoration} from './lib/hooks/useWebScrollRestoration' 48 50 import {attachRouteToLogEvents, logEvent} from './lib/statsig/statsig' ··· 317 319 getComponent={() => FeedsScreen} 318 320 options={{title: title(msg`Feeds`)}} 319 321 /> 322 + <Stack.Screen 323 + name="StarterPack" 324 + getComponent={() => StarterPackScreen} 325 + options={{title: title(msg`Starter Pack`), requireAuth: true}} 326 + /> 327 + <Stack.Screen 328 + name="StarterPackWizard" 329 + getComponent={() => Wizard} 330 + options={{title: title(msg`Create a starter pack`), requireAuth: true}} 331 + /> 332 + <Stack.Screen 333 + name="StarterPackEdit" 334 + getComponent={() => Wizard} 335 + options={{title: title(msg`Edit your starter pack`), requireAuth: true}} 336 + /> 320 337 </> 321 338 ) 322 339 } ··· 371 388 contentStyle: pal.view, 372 389 }}> 373 390 <HomeTab.Screen name="Home" getComponent={() => HomeScreen} /> 391 + <HomeTab.Screen name="Start" getComponent={() => HomeScreen} /> 374 392 {commonScreens(HomeTab)} 375 393 </HomeTab.Navigator> 376 394 ) ··· 506 524 name="Messages" 507 525 getComponent={() => MessagesScreen} 508 526 options={{title: title(msg`Messages`), requireAuth: true}} 527 + /> 528 + <Flat.Screen 529 + name="Start" 530 + getComponent={() => HomeScreen} 531 + options={{title: title(msg`Home`)}} 509 532 /> 510 533 {commonScreens(Flat as typeof HomeTab, numUnread)} 511 534 </Flat.Navigator>
+23
src/components/LinearGradientBackground.tsx
··· 1 + import React from 'react' 2 + import {StyleProp, ViewStyle} from 'react-native' 3 + import {LinearGradient} from 'expo-linear-gradient' 4 + 5 + import {gradients} from '#/alf/tokens' 6 + 7 + export function LinearGradientBackground({ 8 + style, 9 + children, 10 + }: { 11 + style: StyleProp<ViewStyle> 12 + children: React.ReactNode 13 + }) { 14 + const gradient = gradients.sky.values.map(([_, color]) => { 15 + return color 16 + }) 17 + 18 + return ( 19 + <LinearGradient colors={gradient} style={style}> 20 + {children} 21 + </LinearGradient> 22 + ) 23 + }
+60 -10
src/components/NewskieDialog.tsx
··· 9 9 import {useModerationOpts} from '#/state/preferences/moderation-opts' 10 10 import {HITSLOP_10} from 'lib/constants' 11 11 import {sanitizeDisplayName} from 'lib/strings/display-names' 12 - import {atoms as a} from '#/alf' 13 - import {Button} from '#/components/Button' 12 + import {isWeb} from 'platform/detection' 13 + import {atoms as a, useTheme} from '#/alf' 14 + import {Button, ButtonText} from '#/components/Button' 14 15 import * as Dialog from '#/components/Dialog' 15 16 import {useDialogControl} from '#/components/Dialog' 16 17 import {Newskie} from '#/components/icons/Newskie' 18 + import * as StarterPackCard from '#/components/StarterPack/StarterPackCard' 17 19 import {Text} from '#/components/Typography' 18 20 19 21 export function NewskieDialog({ ··· 24 26 disabled?: boolean 25 27 }) { 26 28 const {_} = useLingui() 29 + const t = useTheme() 27 30 const moderationOpts = useModerationOpts() 28 31 const control = useDialogControl() 29 32 const profileName = React.useMemo(() => { ··· 68 71 label={_(msg`New user info dialog`)} 69 72 style={[{width: 'auto', maxWidth: 400, minWidth: 200}]}> 70 73 <View style={[a.gap_sm]}> 71 - <Text style={[a.font_bold, a.text_xl]}> 72 - <Trans>Say hello!</Trans> 73 - </Text> 74 - <Text style={[a.text_md]}> 75 - <Trans> 76 - {profileName} joined Bluesky{' '} 77 - {timeAgo(createdAt, now, {format: 'long'})} ago 78 - </Trans> 74 + <View style={[a.align_center]}> 75 + <Newskie 76 + width={64} 77 + height={64} 78 + fill="#FFC404" 79 + style={{marginTop: -10}} 80 + /> 81 + <Text style={[a.font_bold, a.text_xl, {marginTop: -10}]}> 82 + <Trans>Say hello!</Trans> 83 + </Text> 84 + </View> 85 + <Text style={[a.text_md, a.text_center, a.leading_tight]}> 86 + {profile.joinedViaStarterPack ? ( 87 + <Trans> 88 + {profileName} joined Bluesky using a starter pack{' '} 89 + {timeAgo(createdAt, now, {format: 'long'})} ago 90 + </Trans> 91 + ) : ( 92 + <Trans> 93 + {profileName} joined Bluesky{' '} 94 + {timeAgo(createdAt, now, {format: 'long'})} ago 95 + </Trans> 96 + )} 79 97 </Text> 98 + {profile.joinedViaStarterPack ? ( 99 + <StarterPackCard.Link 100 + starterPack={profile.joinedViaStarterPack} 101 + onPress={() => { 102 + control.close() 103 + }}> 104 + <View 105 + style={[ 106 + a.flex_1, 107 + a.mt_sm, 108 + a.p_lg, 109 + a.border, 110 + a.rounded_sm, 111 + t.atoms.border_contrast_low, 112 + ]}> 113 + <StarterPackCard.Card 114 + starterPack={profile.joinedViaStarterPack} 115 + /> 116 + </View> 117 + </StarterPackCard.Link> 118 + ) : null} 119 + <Button 120 + label={_(msg`Close`)} 121 + variant="solid" 122 + color="secondary" 123 + size="small" 124 + style={[a.mt_sm, isWeb && [a.self_center, {marginLeft: 'auto'}]]} 125 + onPress={() => control.close()}> 126 + <ButtonText> 127 + <Trans>Close</Trans> 128 + </ButtonText> 129 + </Button> 80 130 </View> 81 131 </Dialog.ScrollableInner> 82 132 </Dialog.Outer>
+91
src/components/ProfileCard.tsx
··· 1 + import React from 'react' 2 + import {View} from 'react-native' 3 + import {AppBskyActorDefs, moderateProfile, ModerationOpts} from '@atproto/api' 4 + 5 + import {createSanitizedDisplayName} from 'lib/moderation/create-sanitized-display-name' 6 + import {sanitizeHandle} from 'lib/strings/handles' 7 + import {useProfileShadow} from 'state/cache/profile-shadow' 8 + import {useSession} from 'state/session' 9 + import {FollowButton} from 'view/com/profile/FollowButton' 10 + import {ProfileCardPills} from 'view/com/profile/ProfileCard' 11 + import {UserAvatar} from 'view/com/util/UserAvatar' 12 + import {atoms as a, useTheme} from '#/alf' 13 + import {Link} from '#/components/Link' 14 + import {Text} from '#/components/Typography' 15 + 16 + export function Default({ 17 + profile: profileUnshadowed, 18 + moderationOpts, 19 + logContext = 'ProfileCard', 20 + }: { 21 + profile: AppBskyActorDefs.ProfileViewDetailed 22 + moderationOpts: ModerationOpts 23 + logContext?: 'ProfileCard' | 'StarterPackProfilesList' 24 + }) { 25 + const t = useTheme() 26 + const {currentAccount, hasSession} = useSession() 27 + 28 + const profile = useProfileShadow(profileUnshadowed) 29 + const name = createSanitizedDisplayName(profile) 30 + const handle = `@${sanitizeHandle(profile.handle)}` 31 + const moderation = moderateProfile(profile, moderationOpts) 32 + 33 + return ( 34 + <Wrapper did={profile.did}> 35 + <View style={[a.flex_row, a.gap_sm]}> 36 + <UserAvatar 37 + size={42} 38 + avatar={profile.avatar} 39 + type={ 40 + profile.associated?.labeler 41 + ? 'labeler' 42 + : profile.associated?.feedgens 43 + ? 'algo' 44 + : 'user' 45 + } 46 + moderation={moderation.ui('avatar')} 47 + /> 48 + <View style={[a.flex_1]}> 49 + <Text 50 + style={[a.text_md, a.font_bold, a.leading_snug]} 51 + numberOfLines={1}> 52 + {name} 53 + </Text> 54 + <Text 55 + style={[a.leading_snug, t.atoms.text_contrast_medium]} 56 + numberOfLines={1}> 57 + {handle} 58 + </Text> 59 + </View> 60 + {hasSession && profile.did !== currentAccount?.did && ( 61 + <View style={[a.justify_center, {marginLeft: 'auto'}]}> 62 + <FollowButton profile={profile} logContext={logContext} /> 63 + </View> 64 + )} 65 + </View> 66 + <View style={[a.mb_xs]}> 67 + <ProfileCardPills 68 + followedBy={Boolean(profile.viewer?.followedBy)} 69 + moderation={moderation} 70 + /> 71 + </View> 72 + {profile.description && ( 73 + <Text numberOfLines={3} style={[a.leading_snug]}> 74 + {profile.description} 75 + </Text> 76 + )} 77 + </Wrapper> 78 + ) 79 + } 80 + 81 + function Wrapper({did, children}: {did: string; children: React.ReactNode}) { 82 + return ( 83 + <Link 84 + to={{ 85 + screen: 'Profile', 86 + params: {name: did}, 87 + }}> 88 + <View style={[a.flex_1, a.gap_xs]}>{children}</View> 89 + </Link> 90 + ) 91 + }
+3
src/components/ReportDialog/SelectReportOptionView.tsx
··· 55 55 } else if (props.params.type === 'feedgen') { 56 56 title = _(msg`Report this feed`) 57 57 description = _(msg`Why should this feed be reviewed?`) 58 + } else if (props.params.type === 'starterpack') { 59 + title = _(msg`Report this starter pack`) 60 + description = _(msg`Why should this starter pack be reviewed?`) 58 61 } else if (props.params.type === 'convoMessage') { 59 62 title = _(msg`Report this message`) 60 63 description = _(msg`Why should this message be reviewed?`)
+1 -1
src/components/ReportDialog/types.ts
··· 4 4 control: Dialog.DialogOuterProps['control'] 5 5 params: 6 6 | { 7 - type: 'post' | 'list' | 'feedgen' | 'other' 7 + type: 'post' | 'list' | 'feedgen' | 'starterpack' | 'other' 8 8 uri: string 9 9 cid: string 10 10 }
+68
src/components/StarterPack/Main/FeedsList.tsx
··· 1 + import React, {useCallback} from 'react' 2 + import {ListRenderItemInfo, View} from 'react-native' 3 + import {AppBskyFeedDefs} from '@atproto/api' 4 + import {GeneratorView} from '@atproto/api/dist/client/types/app/bsky/feed/defs' 5 + 6 + import {useBottomBarOffset} from 'lib/hooks/useBottomBarOffset' 7 + import {isNative, isWeb} from 'platform/detection' 8 + import {List, ListRef} from 'view/com/util/List' 9 + import {SectionRef} from '#/screens/Profile/Sections/types' 10 + import {atoms as a, useTheme} from '#/alf' 11 + import * as FeedCard from '#/components/FeedCard' 12 + 13 + function keyExtractor(item: AppBskyFeedDefs.GeneratorView) { 14 + return item.uri 15 + } 16 + 17 + interface ProfilesListProps { 18 + feeds: AppBskyFeedDefs.GeneratorView[] 19 + headerHeight: number 20 + scrollElRef: ListRef 21 + } 22 + 23 + export const FeedsList = React.forwardRef<SectionRef, ProfilesListProps>( 24 + function FeedsListImpl({feeds, headerHeight, scrollElRef}, ref) { 25 + const [initialHeaderHeight] = React.useState(headerHeight) 26 + const bottomBarOffset = useBottomBarOffset(20) 27 + const t = useTheme() 28 + 29 + const onScrollToTop = useCallback(() => { 30 + scrollElRef.current?.scrollToOffset({ 31 + animated: isNative, 32 + offset: -headerHeight, 33 + }) 34 + }, [scrollElRef, headerHeight]) 35 + 36 + React.useImperativeHandle(ref, () => ({ 37 + scrollToTop: onScrollToTop, 38 + })) 39 + 40 + const renderItem = ({item, index}: ListRenderItemInfo<GeneratorView>) => { 41 + return ( 42 + <View 43 + style={[ 44 + a.p_lg, 45 + (isWeb || index !== 0) && a.border_t, 46 + t.atoms.border_contrast_low, 47 + ]}> 48 + <FeedCard.Default type="feed" view={item} /> 49 + </View> 50 + ) 51 + } 52 + 53 + return ( 54 + <List 55 + data={feeds} 56 + renderItem={renderItem} 57 + keyExtractor={keyExtractor} 58 + ref={scrollElRef} 59 + headerOffset={headerHeight} 60 + ListFooterComponent={ 61 + <View style={[{height: initialHeaderHeight + bottomBarOffset}]} /> 62 + } 63 + showsVerticalScrollIndicator={false} 64 + desktopFixedHeight={true} 65 + /> 66 + ) 67 + }, 68 + )
+119
src/components/StarterPack/Main/ProfilesList.tsx
··· 1 + import React, {useCallback} from 'react' 2 + import {ListRenderItemInfo, View} from 'react-native' 3 + import { 4 + AppBskyActorDefs, 5 + AppBskyGraphGetList, 6 + AtUri, 7 + ModerationOpts, 8 + } from '@atproto/api' 9 + import {InfiniteData, UseInfiniteQueryResult} from '@tanstack/react-query' 10 + 11 + import {useBottomBarOffset} from 'lib/hooks/useBottomBarOffset' 12 + import {isNative, isWeb} from 'platform/detection' 13 + import {useSession} from 'state/session' 14 + import {List, ListRef} from 'view/com/util/List' 15 + import {SectionRef} from '#/screens/Profile/Sections/types' 16 + import {atoms as a, useTheme} from '#/alf' 17 + import {Default as ProfileCard} from '#/components/ProfileCard' 18 + 19 + function keyExtractor(item: AppBskyActorDefs.ProfileViewBasic, index: number) { 20 + return `${item.did}-${index}` 21 + } 22 + 23 + interface ProfilesListProps { 24 + listUri: string 25 + listMembersQuery: UseInfiniteQueryResult< 26 + InfiniteData<AppBskyGraphGetList.OutputSchema> 27 + > 28 + moderationOpts: ModerationOpts 29 + headerHeight: number 30 + scrollElRef: ListRef 31 + } 32 + 33 + export const ProfilesList = React.forwardRef<SectionRef, ProfilesListProps>( 34 + function ProfilesListImpl( 35 + {listUri, listMembersQuery, moderationOpts, headerHeight, scrollElRef}, 36 + ref, 37 + ) { 38 + const t = useTheme() 39 + const [initialHeaderHeight] = React.useState(headerHeight) 40 + const bottomBarOffset = useBottomBarOffset(20) 41 + const {currentAccount} = useSession() 42 + 43 + const [isPTRing, setIsPTRing] = React.useState(false) 44 + 45 + const {data, refetch} = listMembersQuery 46 + 47 + // The server returns these sorted by descending creation date, so we want to invert 48 + const profiles = data?.pages 49 + .flatMap(p => p.items.map(i => i.subject)) 50 + .reverse() 51 + const isOwn = new AtUri(listUri).host === currentAccount?.did 52 + 53 + const getSortedProfiles = () => { 54 + if (!profiles) return 55 + if (!isOwn) return profiles 56 + 57 + const myIndex = profiles.findIndex(p => p.did === currentAccount?.did) 58 + return myIndex !== -1 59 + ? [ 60 + profiles[myIndex], 61 + ...profiles.slice(0, myIndex), 62 + ...profiles.slice(myIndex + 1), 63 + ] 64 + : profiles 65 + } 66 + const onScrollToTop = useCallback(() => { 67 + scrollElRef.current?.scrollToOffset({ 68 + animated: isNative, 69 + offset: -headerHeight, 70 + }) 71 + }, [scrollElRef, headerHeight]) 72 + 73 + React.useImperativeHandle(ref, () => ({ 74 + scrollToTop: onScrollToTop, 75 + })) 76 + 77 + const renderItem = ({ 78 + item, 79 + index, 80 + }: ListRenderItemInfo<AppBskyActorDefs.ProfileViewBasic>) => { 81 + return ( 82 + <View 83 + style={[ 84 + a.p_lg, 85 + t.atoms.border_contrast_low, 86 + (isWeb || index !== 0) && a.border_t, 87 + ]}> 88 + <ProfileCard 89 + profile={item} 90 + moderationOpts={moderationOpts} 91 + logContext="StarterPackProfilesList" 92 + /> 93 + </View> 94 + ) 95 + } 96 + 97 + if (listMembersQuery) 98 + return ( 99 + <List 100 + data={getSortedProfiles()} 101 + renderItem={renderItem} 102 + keyExtractor={keyExtractor} 103 + ref={scrollElRef} 104 + headerOffset={headerHeight} 105 + ListFooterComponent={ 106 + <View style={[{height: initialHeaderHeight + bottomBarOffset}]} /> 107 + } 108 + showsVerticalScrollIndicator={false} 109 + desktopFixedHeight 110 + refreshing={isPTRing} 111 + onRefresh={async () => { 112 + setIsPTRing(true) 113 + await refetch() 114 + setIsPTRing(false) 115 + }} 116 + /> 117 + ) 118 + }, 119 + )
+320
src/components/StarterPack/ProfileStarterPacks.tsx
··· 1 + import React from 'react' 2 + import { 3 + findNodeHandle, 4 + ListRenderItemInfo, 5 + StyleProp, 6 + View, 7 + ViewStyle, 8 + } from 'react-native' 9 + import {AppBskyGraphDefs, AppBskyGraphGetActorStarterPacks} from '@atproto/api' 10 + import {msg, Trans} from '@lingui/macro' 11 + import {useLingui} from '@lingui/react' 12 + import {useNavigation} from '@react-navigation/native' 13 + import {InfiniteData, UseInfiniteQueryResult} from '@tanstack/react-query' 14 + 15 + import {logger} from '#/logger' 16 + import {useGenerateStarterPackMutation} from 'lib/generate-starterpack' 17 + import {useBottomBarOffset} from 'lib/hooks/useBottomBarOffset' 18 + import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' 19 + import {NavigationProp} from 'lib/routes/types' 20 + import {parseStarterPackUri} from 'lib/strings/starter-pack' 21 + import {List, ListRef} from 'view/com/util/List' 22 + import {Text} from 'view/com/util/text/Text' 23 + import {atoms as a, useTheme} from '#/alf' 24 + import {Button, ButtonIcon, ButtonText} from '#/components/Button' 25 + import {useDialogControl} from '#/components/Dialog' 26 + import {LinearGradientBackground} from '#/components/LinearGradientBackground' 27 + import {Loader} from '#/components/Loader' 28 + import * as Prompt from '#/components/Prompt' 29 + import {Default as StarterPackCard} from '#/components/StarterPack/StarterPackCard' 30 + import {PlusSmall_Stroke2_Corner0_Rounded as Plus} from '../icons/Plus' 31 + 32 + interface SectionRef { 33 + scrollToTop: () => void 34 + } 35 + 36 + interface ProfileFeedgensProps { 37 + starterPacksQuery: UseInfiniteQueryResult< 38 + InfiniteData<AppBskyGraphGetActorStarterPacks.OutputSchema, unknown>, 39 + Error 40 + > 41 + scrollElRef: ListRef 42 + headerOffset: number 43 + enabled?: boolean 44 + style?: StyleProp<ViewStyle> 45 + testID?: string 46 + setScrollViewTag: (tag: number | null) => void 47 + isMe: boolean 48 + } 49 + 50 + function keyExtractor(item: AppBskyGraphDefs.StarterPackView) { 51 + return item.uri 52 + } 53 + 54 + export const ProfileStarterPacks = React.forwardRef< 55 + SectionRef, 56 + ProfileFeedgensProps 57 + >(function ProfileFeedgensImpl( 58 + { 59 + starterPacksQuery: query, 60 + scrollElRef, 61 + headerOffset, 62 + enabled, 63 + style, 64 + testID, 65 + setScrollViewTag, 66 + isMe, 67 + }, 68 + ref, 69 + ) { 70 + const t = useTheme() 71 + const bottomBarOffset = useBottomBarOffset(100) 72 + const [isPTRing, setIsPTRing] = React.useState(false) 73 + const {data, refetch, isFetching, hasNextPage, fetchNextPage} = query 74 + const {isTabletOrDesktop} = useWebMediaQueries() 75 + 76 + const items = data?.pages.flatMap(page => page.starterPacks) 77 + 78 + React.useImperativeHandle(ref, () => ({ 79 + scrollToTop: () => {}, 80 + })) 81 + 82 + const onRefresh = React.useCallback(async () => { 83 + setIsPTRing(true) 84 + try { 85 + await refetch() 86 + } catch (err) { 87 + logger.error('Failed to refresh starter packs', {message: err}) 88 + } 89 + setIsPTRing(false) 90 + }, [refetch, setIsPTRing]) 91 + 92 + const onEndReached = React.useCallback(async () => { 93 + if (isFetching || !hasNextPage) return 94 + 95 + try { 96 + await fetchNextPage() 97 + } catch (err) { 98 + logger.error('Failed to load more starter packs', {message: err}) 99 + } 100 + }, [isFetching, hasNextPage, fetchNextPage]) 101 + 102 + React.useEffect(() => { 103 + if (enabled && scrollElRef.current) { 104 + const nativeTag = findNodeHandle(scrollElRef.current) 105 + setScrollViewTag(nativeTag) 106 + } 107 + }, [enabled, scrollElRef, setScrollViewTag]) 108 + 109 + const renderItem = ({ 110 + item, 111 + index, 112 + }: ListRenderItemInfo<AppBskyGraphDefs.StarterPackView>) => { 113 + return ( 114 + <View 115 + style={[ 116 + a.p_lg, 117 + (isTabletOrDesktop || index !== 0) && a.border_t, 118 + t.atoms.border_contrast_low, 119 + ]}> 120 + <StarterPackCard starterPack={item} /> 121 + </View> 122 + ) 123 + } 124 + 125 + return ( 126 + <View testID={testID} style={style}> 127 + <List 128 + testID={testID ? `${testID}-flatlist` : undefined} 129 + ref={scrollElRef} 130 + data={items} 131 + renderItem={renderItem} 132 + keyExtractor={keyExtractor} 133 + refreshing={isPTRing} 134 + headerOffset={headerOffset} 135 + contentContainerStyle={{paddingBottom: headerOffset + bottomBarOffset}} 136 + indicatorStyle={t.name === 'light' ? 'black' : 'white'} 137 + removeClippedSubviews={true} 138 + desktopFixedHeight 139 + onEndReached={onEndReached} 140 + onRefresh={onRefresh} 141 + ListEmptyComponent={Empty} 142 + ListFooterComponent={ 143 + items?.length !== 0 && isMe ? CreateAnother : undefined 144 + } 145 + /> 146 + </View> 147 + ) 148 + }) 149 + 150 + function CreateAnother() { 151 + const {_} = useLingui() 152 + const t = useTheme() 153 + const navigation = useNavigation<NavigationProp>() 154 + 155 + return ( 156 + <View 157 + style={[ 158 + a.pr_md, 159 + a.pt_lg, 160 + a.gap_lg, 161 + a.border_t, 162 + t.atoms.border_contrast_low, 163 + ]}> 164 + <Button 165 + label={_(msg`Create a starter pack`)} 166 + variant="solid" 167 + color="secondary" 168 + size="small" 169 + style={[a.self_center]} 170 + onPress={() => navigation.navigate('StarterPackWizard')}> 171 + <ButtonText> 172 + <Trans>Create another</Trans> 173 + </ButtonText> 174 + <ButtonIcon icon={Plus} position="right" /> 175 + </Button> 176 + </View> 177 + ) 178 + } 179 + 180 + function Empty() { 181 + const {_} = useLingui() 182 + const t = useTheme() 183 + const navigation = useNavigation<NavigationProp>() 184 + const confirmDialogControl = useDialogControl() 185 + const followersDialogControl = useDialogControl() 186 + const errorDialogControl = useDialogControl() 187 + 188 + const [isGenerating, setIsGenerating] = React.useState(false) 189 + 190 + const {mutate: generateStarterPack} = useGenerateStarterPackMutation({ 191 + onSuccess: ({uri}) => { 192 + const parsed = parseStarterPackUri(uri) 193 + if (parsed) { 194 + navigation.push('StarterPack', { 195 + name: parsed.name, 196 + rkey: parsed.rkey, 197 + }) 198 + } 199 + setIsGenerating(false) 200 + }, 201 + onError: e => { 202 + logger.error('Failed to generate starter pack', {safeMessage: e}) 203 + setIsGenerating(false) 204 + if (e.name === 'NOT_ENOUGH_FOLLOWERS') { 205 + followersDialogControl.open() 206 + } else { 207 + errorDialogControl.open() 208 + } 209 + }, 210 + }) 211 + 212 + const generate = () => { 213 + setIsGenerating(true) 214 + generateStarterPack() 215 + } 216 + 217 + return ( 218 + <LinearGradientBackground 219 + style={[ 220 + a.px_lg, 221 + a.py_lg, 222 + a.justify_between, 223 + a.gap_lg, 224 + a.shadow_lg, 225 + {marginTop: 2}, 226 + ]}> 227 + <View style={[a.gap_xs]}> 228 + <Text 229 + style={[ 230 + a.font_bold, 231 + a.text_lg, 232 + t.atoms.text_contrast_medium, 233 + {color: 'white'}, 234 + ]}> 235 + You haven't created a starter pack yet! 236 + </Text> 237 + <Text style={[a.text_md, {color: 'white'}]}> 238 + Starter packs let you easily share your favorite feeds and people with 239 + your friends. 240 + </Text> 241 + </View> 242 + <View style={[a.flex_row, a.gap_md, {marginLeft: 'auto'}]}> 243 + <Button 244 + label={_(msg`Create a starter pack for me`)} 245 + variant="ghost" 246 + color="primary" 247 + size="small" 248 + disabled={isGenerating} 249 + onPress={confirmDialogControl.open} 250 + style={{backgroundColor: 'transparent'}}> 251 + <ButtonText style={{color: 'white'}}> 252 + <Trans>Make one for me</Trans> 253 + </ButtonText> 254 + {isGenerating && <Loader size="md" />} 255 + </Button> 256 + <Button 257 + label={_(msg`Create a starter pack`)} 258 + variant="ghost" 259 + color="primary" 260 + size="small" 261 + disabled={isGenerating} 262 + onPress={() => navigation.navigate('StarterPackWizard')} 263 + style={{ 264 + backgroundColor: 'white', 265 + borderColor: 'white', 266 + width: 100, 267 + }} 268 + hoverStyle={[{backgroundColor: '#dfdfdf'}]}> 269 + <ButtonText> 270 + <Trans>Create</Trans> 271 + </ButtonText> 272 + </Button> 273 + </View> 274 + 275 + <Prompt.Outer control={confirmDialogControl}> 276 + <Prompt.TitleText> 277 + <Trans>Generate a starter pack</Trans> 278 + </Prompt.TitleText> 279 + <Prompt.DescriptionText> 280 + <Trans> 281 + Bluesky will choose a set of recommended accounts from people in 282 + your network. 283 + </Trans> 284 + </Prompt.DescriptionText> 285 + <Prompt.Actions> 286 + <Prompt.Action 287 + color="primary" 288 + cta={_(msg`Choose for me`)} 289 + onPress={generate} 290 + /> 291 + <Prompt.Action 292 + color="secondary" 293 + cta={_(msg`Let me choose`)} 294 + onPress={() => { 295 + navigation.navigate('StarterPackWizard') 296 + }} 297 + /> 298 + </Prompt.Actions> 299 + </Prompt.Outer> 300 + <Prompt.Basic 301 + control={followersDialogControl} 302 + title={_(msg`Oops!`)} 303 + description={_( 304 + msg`You must be following at least seven other people to generate a starter pack.`, 305 + )} 306 + onConfirm={() => {}} 307 + showCancel={false} 308 + /> 309 + <Prompt.Basic 310 + control={errorDialogControl} 311 + title={_(msg`Oops!`)} 312 + description={_( 313 + msg`An error occurred while generating your starter pack. Want to try again?`, 314 + )} 315 + onConfirm={generate} 316 + confirmButtonCta={_(msg`Retry`)} 317 + /> 318 + </LinearGradientBackground> 319 + ) 320 + }
+119
src/components/StarterPack/QrCode.tsx
··· 1 + import React from 'react' 2 + import {View} from 'react-native' 3 + import QRCode from 'react-native-qrcode-styled' 4 + import ViewShot from 'react-native-view-shot' 5 + import {AppBskyGraphDefs, AppBskyGraphStarterpack} from '@atproto/api' 6 + import {Trans} from '@lingui/macro' 7 + 8 + import {isWeb} from 'platform/detection' 9 + import {Logo} from 'view/icons/Logo' 10 + import {Logotype} from 'view/icons/Logotype' 11 + import {useTheme} from '#/alf' 12 + import {atoms as a} from '#/alf' 13 + import {LinearGradientBackground} from '#/components/LinearGradientBackground' 14 + import {Text} from '#/components/Typography' 15 + 16 + interface Props { 17 + starterPack: AppBskyGraphDefs.StarterPackView 18 + link: string 19 + } 20 + 21 + export const QrCode = React.forwardRef<ViewShot, Props>(function QrCode( 22 + {starterPack, link}, 23 + ref, 24 + ) { 25 + const {record} = starterPack 26 + 27 + if (!AppBskyGraphStarterpack.isRecord(record)) { 28 + return null 29 + } 30 + 31 + return ( 32 + <ViewShot ref={ref}> 33 + <LinearGradientBackground 34 + style={[ 35 + {width: 300, minHeight: 390}, 36 + a.align_center, 37 + a.px_sm, 38 + a.py_xl, 39 + a.rounded_sm, 40 + a.justify_between, 41 + a.gap_md, 42 + ]}> 43 + <View style={[a.gap_sm]}> 44 + <Text 45 + style={[a.font_bold, a.text_3xl, a.text_center, {color: 'white'}]}> 46 + {record.name} 47 + </Text> 48 + </View> 49 + <View style={[a.gap_xl, a.align_center]}> 50 + <Text 51 + style={[ 52 + a.font_bold, 53 + a.text_center, 54 + {color: 'white', fontSize: 18}, 55 + ]}> 56 + <Trans>Join the conversation</Trans> 57 + </Text> 58 + <View style={[a.rounded_sm, a.overflow_hidden]}> 59 + <QrCodeInner link={link} /> 60 + </View> 61 + 62 + <View style={[a.flex_row, a.align_center, {gap: 5}]}> 63 + <Text 64 + style={[ 65 + a.font_bold, 66 + a.text_center, 67 + {color: 'white', fontSize: 18}, 68 + ]}> 69 + <Trans>on</Trans> 70 + </Text> 71 + <Logo width={26} fill="white" /> 72 + <View style={[{marginTop: 5, marginLeft: 2.5}]}> 73 + <Logotype width={68} fill="white" /> 74 + </View> 75 + </View> 76 + </View> 77 + </LinearGradientBackground> 78 + </ViewShot> 79 + ) 80 + }) 81 + 82 + export function QrCodeInner({link}: {link: string}) { 83 + const t = useTheme() 84 + 85 + return ( 86 + <QRCode 87 + data={link} 88 + style={[ 89 + a.rounded_sm, 90 + {height: 225, width: 225, backgroundColor: '#f3f3f3'}, 91 + ]} 92 + pieceSize={isWeb ? 8 : 6} 93 + padding={20} 94 + // pieceLiquidRadius={2} 95 + pieceBorderRadius={isWeb ? 4.5 : 3.5} 96 + outerEyesOptions={{ 97 + topLeft: { 98 + borderRadius: [12, 12, 0, 12], 99 + color: t.palette.primary_500, 100 + }, 101 + topRight: { 102 + borderRadius: [12, 12, 12, 0], 103 + color: t.palette.primary_500, 104 + }, 105 + bottomLeft: { 106 + borderRadius: [12, 0, 12, 12], 107 + color: t.palette.primary_500, 108 + }, 109 + }} 110 + innerEyesOptions={{borderRadius: 3}} 111 + logo={{ 112 + href: require('../../../assets/logo.png'), 113 + scale: 1.2, 114 + padding: 2, 115 + hidePieces: true, 116 + }} 117 + /> 118 + ) 119 + }
+201
src/components/StarterPack/QrCodeDialog.tsx
··· 1 + import React from 'react' 2 + import {View} from 'react-native' 3 + import ViewShot from 'react-native-view-shot' 4 + import * as FS from 'expo-file-system' 5 + import {requestMediaLibraryPermissionsAsync} from 'expo-image-picker' 6 + import * as Sharing from 'expo-sharing' 7 + import {AppBskyGraphDefs, AppBskyGraphStarterpack} from '@atproto/api' 8 + import {msg, Trans} from '@lingui/macro' 9 + import {useLingui} from '@lingui/react' 10 + import {nanoid} from 'nanoid/non-secure' 11 + 12 + import {logger} from '#/logger' 13 + import {saveImageToMediaLibrary} from 'lib/media/manip' 14 + import {logEvent} from 'lib/statsig/statsig' 15 + import {isNative, isWeb} from 'platform/detection' 16 + import * as Toast from '#/view/com/util/Toast' 17 + import {atoms as a} from '#/alf' 18 + import {Button, ButtonText} from '#/components/Button' 19 + import * as Dialog from '#/components/Dialog' 20 + import {DialogControlProps} from '#/components/Dialog' 21 + import {Loader} from '#/components/Loader' 22 + import {QrCode} from '#/components/StarterPack/QrCode' 23 + 24 + export function QrCodeDialog({ 25 + starterPack, 26 + link, 27 + control, 28 + }: { 29 + starterPack: AppBskyGraphDefs.StarterPackView 30 + link?: string 31 + control: DialogControlProps 32 + }) { 33 + const {_} = useLingui() 34 + const [isProcessing, setIsProcessing] = React.useState(false) 35 + 36 + const ref = React.useRef<ViewShot>(null) 37 + 38 + const getCanvas = (base64: string): Promise<HTMLCanvasElement> => { 39 + return new Promise(resolve => { 40 + const image = new Image() 41 + image.onload = () => { 42 + const canvas = document.createElement('canvas') 43 + canvas.width = image.width 44 + canvas.height = image.height 45 + 46 + const ctx = canvas.getContext('2d') 47 + ctx?.drawImage(image, 0, 0) 48 + resolve(canvas) 49 + } 50 + image.src = base64 51 + }) 52 + } 53 + 54 + const onSavePress = async () => { 55 + ref.current?.capture?.().then(async (uri: string) => { 56 + if (isNative) { 57 + const res = await requestMediaLibraryPermissionsAsync() 58 + 59 + if (!res) { 60 + Toast.show( 61 + _( 62 + msg`You must grant access to your photo library to save a QR code`, 63 + ), 64 + ) 65 + return 66 + } 67 + 68 + const filename = `${FS.documentDirectory}/${nanoid(12)}.png` 69 + 70 + // Incase of a FS failure, don't crash the app 71 + try { 72 + await FS.copyAsync({from: uri, to: filename}) 73 + await saveImageToMediaLibrary({uri: filename}) 74 + await FS.deleteAsync(filename) 75 + } catch (e: unknown) { 76 + Toast.show(_(msg`An error occurred while saving the QR code!`)) 77 + logger.error('Failed to save QR code', {error: e}) 78 + return 79 + } 80 + } else { 81 + setIsProcessing(true) 82 + 83 + if (!AppBskyGraphStarterpack.isRecord(starterPack.record)) { 84 + return 85 + } 86 + 87 + const canvas = await getCanvas(uri) 88 + const imgHref = canvas 89 + .toDataURL('image/png') 90 + .replace('image/png', 'image/octet-stream') 91 + 92 + const link = document.createElement('a') 93 + link.setAttribute( 94 + 'download', 95 + `${starterPack.record.name.replaceAll(' ', '_')}_Share_Card.png`, 96 + ) 97 + link.setAttribute('href', imgHref) 98 + link.click() 99 + } 100 + 101 + logEvent('starterPack:share', { 102 + starterPack: starterPack.uri, 103 + shareType: 'qrcode', 104 + qrShareType: 'save', 105 + }) 106 + setIsProcessing(false) 107 + Toast.show( 108 + isWeb 109 + ? _(msg`QR code has been downloaded!`) 110 + : _(msg`QR code saved to your camera roll!`), 111 + ) 112 + control.close() 113 + }) 114 + } 115 + 116 + const onCopyPress = async () => { 117 + setIsProcessing(true) 118 + ref.current?.capture?.().then(async (uri: string) => { 119 + const canvas = await getCanvas(uri) 120 + // @ts-expect-error web only 121 + canvas.toBlob((blob: Blob) => { 122 + const item = new ClipboardItem({'image/png': blob}) 123 + navigator.clipboard.write([item]) 124 + }) 125 + 126 + logEvent('starterPack:share', { 127 + starterPack: starterPack.uri, 128 + shareType: 'qrcode', 129 + qrShareType: 'copy', 130 + }) 131 + Toast.show(_(msg`QR code copied to your clipboard!`)) 132 + setIsProcessing(false) 133 + control.close() 134 + }) 135 + } 136 + 137 + const onSharePress = async () => { 138 + ref.current?.capture?.().then(async (uri: string) => { 139 + control.close(() => { 140 + Sharing.shareAsync(uri, {mimeType: 'image/png', UTI: 'image/png'}).then( 141 + () => { 142 + logEvent('starterPack:share', { 143 + starterPack: starterPack.uri, 144 + shareType: 'qrcode', 145 + qrShareType: 'share', 146 + }) 147 + }, 148 + ) 149 + }) 150 + }) 151 + } 152 + 153 + return ( 154 + <Dialog.Outer control={control}> 155 + <Dialog.Handle /> 156 + <Dialog.ScrollableInner 157 + label={_(msg`Create a QR code for a starter pack`)}> 158 + <View style={[a.flex_1, a.align_center, a.gap_5xl]}> 159 + {!link ? ( 160 + <View style={[a.align_center, a.p_xl]}> 161 + <Loader size="xl" /> 162 + </View> 163 + ) : ( 164 + <> 165 + <QrCode starterPack={starterPack} link={link} ref={ref} /> 166 + {isProcessing ? ( 167 + <View> 168 + <Loader size="xl" /> 169 + </View> 170 + ) : ( 171 + <View 172 + style={[a.w_full, a.gap_md, isWeb && [a.flex_row_reverse]]}> 173 + <Button 174 + label={_(msg`Copy QR code`)} 175 + variant="solid" 176 + color="secondary" 177 + size="small" 178 + onPress={isWeb ? onCopyPress : onSharePress}> 179 + <ButtonText> 180 + {isWeb ? <Trans>Copy</Trans> : <Trans>Share</Trans>} 181 + </ButtonText> 182 + </Button> 183 + <Button 184 + label={_(msg`Save QR code`)} 185 + variant="solid" 186 + color="secondary" 187 + size="small" 188 + onPress={onSavePress}> 189 + <ButtonText> 190 + <Trans>Save</Trans> 191 + </ButtonText> 192 + </Button> 193 + </View> 194 + )} 195 + </> 196 + )} 197 + </View> 198 + </Dialog.ScrollableInner> 199 + </Dialog.Outer> 200 + ) 201 + }
+180
src/components/StarterPack/ShareDialog.tsx
··· 1 + import React from 'react' 2 + import {View} from 'react-native' 3 + import * as FS from 'expo-file-system' 4 + import {Image} from 'expo-image' 5 + import {requestMediaLibraryPermissionsAsync} from 'expo-image-picker' 6 + import {AppBskyGraphDefs} from '@atproto/api' 7 + import {msg, Trans} from '@lingui/macro' 8 + import {useLingui} from '@lingui/react' 9 + import {nanoid} from 'nanoid/non-secure' 10 + 11 + import {logger} from '#/logger' 12 + import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' 13 + import {saveImageToMediaLibrary} from 'lib/media/manip' 14 + import {shareUrl} from 'lib/sharing' 15 + import {logEvent} from 'lib/statsig/statsig' 16 + import {getStarterPackOgCard} from 'lib/strings/starter-pack' 17 + import {isNative, isWeb} from 'platform/detection' 18 + import * as Toast from 'view/com/util/Toast' 19 + import {atoms as a, useTheme} from '#/alf' 20 + import {Button, ButtonText} from '#/components/Button' 21 + import {DialogControlProps} from '#/components/Dialog' 22 + import * as Dialog from '#/components/Dialog' 23 + import {Loader} from '#/components/Loader' 24 + import {Text} from '#/components/Typography' 25 + 26 + interface Props { 27 + starterPack: AppBskyGraphDefs.StarterPackView 28 + link?: string 29 + imageLoaded?: boolean 30 + qrDialogControl: DialogControlProps 31 + control: DialogControlProps 32 + } 33 + 34 + export function ShareDialog(props: Props) { 35 + return ( 36 + <Dialog.Outer control={props.control}> 37 + <ShareDialogInner {...props} /> 38 + </Dialog.Outer> 39 + ) 40 + } 41 + 42 + function ShareDialogInner({ 43 + starterPack, 44 + link, 45 + imageLoaded, 46 + qrDialogControl, 47 + control, 48 + }: Props) { 49 + const {_} = useLingui() 50 + const t = useTheme() 51 + const {isTabletOrDesktop} = useWebMediaQueries() 52 + 53 + const imageUrl = getStarterPackOgCard(starterPack) 54 + 55 + const onShareLink = async () => { 56 + if (!link) return 57 + shareUrl(link) 58 + logEvent('starterPack:share', { 59 + starterPack: starterPack.uri, 60 + shareType: 'link', 61 + }) 62 + control.close() 63 + } 64 + 65 + const onSave = async () => { 66 + const res = await requestMediaLibraryPermissionsAsync() 67 + 68 + if (!res) { 69 + Toast.show( 70 + _(msg`You must grant access to your photo library to save the image.`), 71 + ) 72 + return 73 + } 74 + 75 + const cachePath = await Image.getCachePathAsync(imageUrl) 76 + const filename = `${FS.documentDirectory}/${nanoid(12)}.png` 77 + 78 + if (!cachePath) { 79 + Toast.show(_(msg`An error occurred while saving the image.`)) 80 + return 81 + } 82 + 83 + try { 84 + await FS.copyAsync({from: cachePath, to: filename}) 85 + await saveImageToMediaLibrary({uri: filename}) 86 + await FS.deleteAsync(filename) 87 + 88 + Toast.show(_(msg`Image saved to your camera roll!`)) 89 + control.close() 90 + } catch (e: unknown) { 91 + Toast.show(_(msg`An error occurred while saving the QR code!`)) 92 + logger.error('Failed to save QR code', {error: e}) 93 + return 94 + } 95 + } 96 + 97 + return ( 98 + <> 99 + <Dialog.Handle /> 100 + <Dialog.ScrollableInner label={_(msg`Share link dialog`)}> 101 + {!imageLoaded || !link ? ( 102 + <View style={[a.p_xl, a.align_center]}> 103 + <Loader size="xl" /> 104 + </View> 105 + ) : ( 106 + <View style={[!isTabletOrDesktop && a.gap_lg]}> 107 + <View style={[a.gap_sm, isTabletOrDesktop && a.pb_lg]}> 108 + <Text style={[a.font_bold, a.text_2xl]}> 109 + <Trans>Invite people to this starter pack!</Trans> 110 + </Text> 111 + <Text style={[a.text_md, t.atoms.text_contrast_medium]}> 112 + <Trans> 113 + Share this starter pack and help people join your community on 114 + Bluesky. 115 + </Trans> 116 + </Text> 117 + </View> 118 + <Image 119 + source={{uri: imageUrl}} 120 + style={[ 121 + a.rounded_sm, 122 + { 123 + aspectRatio: 1200 / 630, 124 + transform: [{scale: isTabletOrDesktop ? 0.85 : 1}], 125 + marginTop: isTabletOrDesktop ? -20 : 0, 126 + }, 127 + ]} 128 + accessibilityIgnoresInvertColors={true} 129 + /> 130 + <View 131 + style={[ 132 + a.gap_md, 133 + isWeb && [a.gap_sm, a.flex_row_reverse, {marginLeft: 'auto'}], 134 + ]}> 135 + <Button 136 + label="Share link" 137 + variant="solid" 138 + color="secondary" 139 + size="small" 140 + style={[isWeb && a.self_center]} 141 + onPress={onShareLink}> 142 + <ButtonText> 143 + {isWeb ? <Trans>Copy Link</Trans> : <Trans>Share Link</Trans>} 144 + </ButtonText> 145 + </Button> 146 + <Button 147 + label="Create QR code" 148 + variant="solid" 149 + color="secondary" 150 + size="small" 151 + style={[isWeb && a.self_center]} 152 + onPress={() => { 153 + control.close(() => { 154 + qrDialogControl.open() 155 + }) 156 + }}> 157 + <ButtonText> 158 + <Trans>Create QR code</Trans> 159 + </ButtonText> 160 + </Button> 161 + {isNative && ( 162 + <Button 163 + label={_(msg`Save image`)} 164 + variant="ghost" 165 + color="secondary" 166 + size="small" 167 + style={[isWeb && a.self_center]} 168 + onPress={onSave}> 169 + <ButtonText> 170 + <Trans>Save image</Trans> 171 + </ButtonText> 172 + </Button> 173 + )} 174 + </View> 175 + </View> 176 + )} 177 + </Dialog.ScrollableInner> 178 + </> 179 + ) 180 + }
+117
src/components/StarterPack/StarterPackCard.tsx
··· 1 + import React from 'react' 2 + import {View} from 'react-native' 3 + import {AppBskyGraphStarterpack, AtUri} from '@atproto/api' 4 + import {StarterPackViewBasic} from '@atproto/api/dist/client/types/app/bsky/graph/defs' 5 + import {msg, Trans} from '@lingui/macro' 6 + import {useLingui} from '@lingui/react' 7 + 8 + import {sanitizeHandle} from 'lib/strings/handles' 9 + import {useSession} from 'state/session' 10 + import {atoms as a, useTheme} from '#/alf' 11 + import {StarterPack} from '#/components/icons/StarterPack' 12 + import {Link as InternalLink, LinkProps} from '#/components/Link' 13 + import {Text} from '#/components/Typography' 14 + 15 + export function Default({starterPack}: {starterPack?: StarterPackViewBasic}) { 16 + if (!starterPack) return null 17 + return ( 18 + <Link starterPack={starterPack}> 19 + <Card starterPack={starterPack} /> 20 + </Link> 21 + ) 22 + } 23 + 24 + export function Notification({ 25 + starterPack, 26 + }: { 27 + starterPack?: StarterPackViewBasic 28 + }) { 29 + if (!starterPack) return null 30 + return ( 31 + <Link starterPack={starterPack}> 32 + <Card starterPack={starterPack} noIcon={true} noDescription={true} /> 33 + </Link> 34 + ) 35 + } 36 + 37 + export function Card({ 38 + starterPack, 39 + noIcon, 40 + noDescription, 41 + }: { 42 + starterPack: StarterPackViewBasic 43 + noIcon?: boolean 44 + noDescription?: boolean 45 + }) { 46 + const {record, creator, joinedAllTimeCount} = starterPack 47 + 48 + const {_} = useLingui() 49 + const t = useTheme() 50 + const {currentAccount} = useSession() 51 + 52 + if (!AppBskyGraphStarterpack.isRecord(record)) { 53 + return null 54 + } 55 + 56 + return ( 57 + <View style={[a.flex_1, a.gap_md]}> 58 + <View style={[a.flex_row, a.gap_sm]}> 59 + {!noIcon ? <StarterPack width={40} gradient="sky" /> : null} 60 + <View> 61 + <Text style={[a.text_md, a.font_bold, a.leading_snug]}> 62 + {record.name} 63 + </Text> 64 + <Text style={[a.leading_snug, t.atoms.text_contrast_medium]}> 65 + <Trans> 66 + Starter pack by{' '} 67 + {creator?.did === currentAccount?.did 68 + ? _(msg`you`) 69 + : `@${sanitizeHandle(creator.handle)}`} 70 + </Trans> 71 + </Text> 72 + </View> 73 + </View> 74 + {!noDescription && record.description ? ( 75 + <Text numberOfLines={3} style={[a.leading_snug]}> 76 + {record.description} 77 + </Text> 78 + ) : null} 79 + {!!joinedAllTimeCount && joinedAllTimeCount >= 50 && ( 80 + <Text style={[a.font_bold, t.atoms.text_contrast_medium]}> 81 + {joinedAllTimeCount} users have joined! 82 + </Text> 83 + )} 84 + </View> 85 + ) 86 + } 87 + 88 + export function Link({ 89 + starterPack, 90 + children, 91 + ...rest 92 + }: { 93 + starterPack: StarterPackViewBasic 94 + } & Omit<LinkProps, 'to'>) { 95 + const {record} = starterPack 96 + const {rkey, handleOrDid} = React.useMemo(() => { 97 + const rkey = new AtUri(starterPack.uri).rkey 98 + const {creator} = starterPack 99 + return {rkey, handleOrDid: creator.handle || creator.did} 100 + }, [starterPack]) 101 + 102 + if (!AppBskyGraphStarterpack.isRecord(record)) { 103 + return null 104 + } 105 + 106 + return ( 107 + <InternalLink 108 + label={record.name} 109 + {...rest} 110 + to={{ 111 + screen: 'StarterPack', 112 + params: {name: handleOrDid, rkey}, 113 + }}> 114 + {children} 115 + </InternalLink> 116 + ) 117 + }
+31
src/components/StarterPack/Wizard/ScreenTransition.tsx
··· 1 + import React from 'react' 2 + import {StyleProp, ViewStyle} from 'react-native' 3 + import Animated, { 4 + FadeIn, 5 + FadeOut, 6 + SlideInLeft, 7 + SlideInRight, 8 + } from 'react-native-reanimated' 9 + 10 + import {isWeb} from 'platform/detection' 11 + 12 + export function ScreenTransition({ 13 + direction, 14 + style, 15 + children, 16 + }: { 17 + direction: 'Backward' | 'Forward' 18 + style?: StyleProp<ViewStyle> 19 + children: React.ReactNode 20 + }) { 21 + const entering = direction === 'Forward' ? SlideInRight : SlideInLeft 22 + 23 + return ( 24 + <Animated.View 25 + entering={isWeb ? FadeIn.duration(90) : entering} 26 + exiting={FadeOut.duration(90)} // Totally vibes based 27 + style={style}> 28 + {children} 29 + </Animated.View> 30 + ) 31 + }
+152
src/components/StarterPack/Wizard/WizardEditListDialog.tsx
··· 1 + import React, {useRef} from 'react' 2 + import type {ListRenderItemInfo} from 'react-native' 3 + import {View} from 'react-native' 4 + import {AppBskyActorDefs, ModerationOpts} from '@atproto/api' 5 + import {GeneratorView} from '@atproto/api/dist/client/types/app/bsky/feed/defs' 6 + import {BottomSheetFlatListMethods} from '@discord/bottom-sheet' 7 + import {msg, Trans} from '@lingui/macro' 8 + import {useLingui} from '@lingui/react' 9 + 10 + import {isWeb} from 'platform/detection' 11 + import {useSession} from 'state/session' 12 + import {WizardAction, WizardState} from '#/screens/StarterPack/Wizard/State' 13 + import {atoms as a, native, useTheme, web} from '#/alf' 14 + import {Button, ButtonText} from '#/components/Button' 15 + import * as Dialog from '#/components/Dialog' 16 + import { 17 + WizardFeedCard, 18 + WizardProfileCard, 19 + } from '#/components/StarterPack/Wizard/WizardListCard' 20 + import {Text} from '#/components/Typography' 21 + 22 + function keyExtractor( 23 + item: AppBskyActorDefs.ProfileViewBasic | GeneratorView, 24 + index: number, 25 + ) { 26 + return `${item.did}-${index}` 27 + } 28 + 29 + export function WizardEditListDialog({ 30 + control, 31 + state, 32 + dispatch, 33 + moderationOpts, 34 + profile, 35 + }: { 36 + control: Dialog.DialogControlProps 37 + state: WizardState 38 + dispatch: (action: WizardAction) => void 39 + moderationOpts: ModerationOpts 40 + profile: AppBskyActorDefs.ProfileViewBasic 41 + }) { 42 + const {_} = useLingui() 43 + const t = useTheme() 44 + const {currentAccount} = useSession() 45 + 46 + const listRef = useRef<BottomSheetFlatListMethods>(null) 47 + 48 + const getData = () => { 49 + if (state.currentStep === 'Feeds') return state.feeds 50 + 51 + return [ 52 + profile, 53 + ...state.profiles.filter(p => p.did !== currentAccount?.did), 54 + ] 55 + } 56 + 57 + const renderItem = ({item}: ListRenderItemInfo<any>) => 58 + state.currentStep === 'Profiles' ? ( 59 + <WizardProfileCard 60 + profile={item} 61 + state={state} 62 + dispatch={dispatch} 63 + moderationOpts={moderationOpts} 64 + /> 65 + ) : ( 66 + <WizardFeedCard 67 + generator={item} 68 + state={state} 69 + dispatch={dispatch} 70 + moderationOpts={moderationOpts} 71 + /> 72 + ) 73 + 74 + return ( 75 + <Dialog.Outer 76 + control={control} 77 + testID="newChatDialog" 78 + nativeOptions={{sheet: {snapPoints: ['95%']}}}> 79 + <Dialog.Handle /> 80 + <Dialog.InnerFlatList 81 + ref={listRef} 82 + data={getData()} 83 + renderItem={renderItem} 84 + keyExtractor={keyExtractor} 85 + ListHeaderComponent={ 86 + <View 87 + style={[ 88 + a.flex_row, 89 + a.justify_between, 90 + a.border_b, 91 + a.px_sm, 92 + a.mb_sm, 93 + t.atoms.bg, 94 + t.atoms.border_contrast_medium, 95 + isWeb 96 + ? [ 97 + a.align_center, 98 + { 99 + height: 48, 100 + }, 101 + ] 102 + : [ 103 + a.pb_sm, 104 + a.align_end, 105 + { 106 + height: 68, 107 + }, 108 + ], 109 + ]}> 110 + <View style={{width: 60}} /> 111 + <Text style={[a.font_bold, a.text_xl]}> 112 + {state.currentStep === 'Profiles' ? ( 113 + <Trans>Edit People</Trans> 114 + ) : ( 115 + <Trans>Edit Feeds</Trans> 116 + )} 117 + </Text> 118 + <View style={{width: 60}}> 119 + {isWeb && ( 120 + <Button 121 + label={_(msg`Close`)} 122 + variant="ghost" 123 + color="primary" 124 + size="xsmall" 125 + onPress={() => control.close()}> 126 + <ButtonText> 127 + <Trans>Close</Trans> 128 + </ButtonText> 129 + </Button> 130 + )} 131 + </View> 132 + </View> 133 + } 134 + stickyHeaderIndices={[0]} 135 + style={[ 136 + web([a.py_0, {height: '100vh', maxHeight: 600}, a.px_0]), 137 + native({ 138 + height: '100%', 139 + paddingHorizontal: 0, 140 + marginTop: 0, 141 + paddingTop: 0, 142 + borderTopLeftRadius: 40, 143 + borderTopRightRadius: 40, 144 + }), 145 + ]} 146 + webInnerStyle={[a.py_0, {maxWidth: 500, minWidth: 200}]} 147 + keyboardDismissMode="on-drag" 148 + removeClippedSubviews={true} 149 + /> 150 + </Dialog.Outer> 151 + ) 152 + }
+182
src/components/StarterPack/Wizard/WizardListCard.tsx
··· 1 + import React from 'react' 2 + import {Keyboard, View} from 'react-native' 3 + import { 4 + AppBskyActorDefs, 5 + AppBskyFeedDefs, 6 + moderateFeedGenerator, 7 + moderateProfile, 8 + ModerationOpts, 9 + ModerationUI, 10 + } from '@atproto/api' 11 + import {GeneratorView} from '@atproto/api/dist/client/types/app/bsky/feed/defs' 12 + import {msg} from '@lingui/macro' 13 + import {useLingui} from '@lingui/react' 14 + 15 + import {DISCOVER_FEED_URI} from 'lib/constants' 16 + import {sanitizeDisplayName} from 'lib/strings/display-names' 17 + import {sanitizeHandle} from 'lib/strings/handles' 18 + import {useSession} from 'state/session' 19 + import {UserAvatar} from 'view/com/util/UserAvatar' 20 + import {WizardAction, WizardState} from '#/screens/StarterPack/Wizard/State' 21 + import {atoms as a, useTheme} from '#/alf' 22 + import * as Toggle from '#/components/forms/Toggle' 23 + import {Checkbox} from '#/components/forms/Toggle' 24 + import {Text} from '#/components/Typography' 25 + 26 + function WizardListCard({ 27 + type, 28 + displayName, 29 + subtitle, 30 + onPress, 31 + avatar, 32 + included, 33 + disabled, 34 + moderationUi, 35 + }: { 36 + type: 'user' | 'algo' 37 + profile?: AppBskyActorDefs.ProfileViewBasic 38 + feed?: AppBskyFeedDefs.GeneratorView 39 + displayName: string 40 + subtitle: string 41 + onPress: () => void 42 + avatar?: string 43 + included?: boolean 44 + disabled?: boolean 45 + moderationUi: ModerationUI 46 + }) { 47 + const t = useTheme() 48 + const {_} = useLingui() 49 + 50 + return ( 51 + <Toggle.Item 52 + name={type === 'user' ? _(msg`Person toggle`) : _(msg`Feed toggle`)} 53 + label={ 54 + included 55 + ? _(msg`Remove ${displayName} from starter pack`) 56 + : _(msg`Add ${displayName} to starter pack`) 57 + } 58 + value={included} 59 + disabled={disabled} 60 + onChange={onPress} 61 + style={[ 62 + a.flex_row, 63 + a.align_center, 64 + a.px_lg, 65 + a.py_md, 66 + a.gap_md, 67 + a.border_b, 68 + t.atoms.border_contrast_low, 69 + ]}> 70 + <UserAvatar 71 + size={45} 72 + avatar={avatar} 73 + moderation={moderationUi} 74 + type={type} 75 + /> 76 + <View style={[a.flex_1, a.gap_2xs]}> 77 + <Text 78 + style={[a.flex_1, a.font_bold, a.text_md, a.leading_tight]} 79 + numberOfLines={1}> 80 + {displayName} 81 + </Text> 82 + <Text 83 + style={[a.flex_1, a.leading_tight, t.atoms.text_contrast_medium]} 84 + numberOfLines={1}> 85 + {subtitle} 86 + </Text> 87 + </View> 88 + <Checkbox /> 89 + </Toggle.Item> 90 + ) 91 + } 92 + 93 + export function WizardProfileCard({ 94 + state, 95 + dispatch, 96 + profile, 97 + moderationOpts, 98 + }: { 99 + state: WizardState 100 + dispatch: (action: WizardAction) => void 101 + profile: AppBskyActorDefs.ProfileViewBasic 102 + moderationOpts: ModerationOpts 103 + }) { 104 + const {currentAccount} = useSession() 105 + 106 + const isMe = profile.did === currentAccount?.did 107 + const included = isMe || state.profiles.some(p => p.did === profile.did) 108 + const disabled = isMe || (!included && state.profiles.length >= 49) 109 + const moderationUi = moderateProfile(profile, moderationOpts).ui('avatar') 110 + const displayName = profile.displayName 111 + ? sanitizeDisplayName(profile.displayName) 112 + : `@${sanitizeHandle(profile.handle)}` 113 + 114 + const onPress = () => { 115 + if (disabled) return 116 + 117 + Keyboard.dismiss() 118 + if (profile.did === currentAccount?.did) return 119 + 120 + if (!included) { 121 + dispatch({type: 'AddProfile', profile}) 122 + } else { 123 + dispatch({type: 'RemoveProfile', profileDid: profile.did}) 124 + } 125 + } 126 + 127 + return ( 128 + <WizardListCard 129 + type="user" 130 + displayName={displayName} 131 + subtitle={`@${sanitizeHandle(profile.handle)}`} 132 + onPress={onPress} 133 + avatar={profile.avatar} 134 + included={included} 135 + disabled={disabled} 136 + moderationUi={moderationUi} 137 + /> 138 + ) 139 + } 140 + 141 + export function WizardFeedCard({ 142 + generator, 143 + state, 144 + dispatch, 145 + moderationOpts, 146 + }: { 147 + generator: GeneratorView 148 + state: WizardState 149 + dispatch: (action: WizardAction) => void 150 + moderationOpts: ModerationOpts 151 + }) { 152 + const isDiscover = generator.uri === DISCOVER_FEED_URI 153 + const included = isDiscover || state.feeds.some(f => f.uri === generator.uri) 154 + const disabled = isDiscover || (!included && state.feeds.length >= 3) 155 + const moderationUi = moderateFeedGenerator(generator, moderationOpts).ui( 156 + 'avatar', 157 + ) 158 + 159 + const onPress = () => { 160 + if (disabled) return 161 + 162 + Keyboard.dismiss() 163 + if (included) { 164 + dispatch({type: 'RemoveFeed', feedUri: generator.uri}) 165 + } else { 166 + dispatch({type: 'AddFeed', feed: generator}) 167 + } 168 + } 169 + 170 + return ( 171 + <WizardListCard 172 + type="algo" 173 + displayName={sanitizeDisplayName(generator.displayName)} 174 + subtitle={`Feed by @${sanitizeHandle(generator.creator.handle)}`} 175 + onPress={onPress} 176 + avatar={generator.avatar} 177 + included={included} 178 + disabled={disabled} 179 + moderationUi={moderationUi} 180 + /> 181 + ) 182 + }
+2
src/components/forms/TextField.tsx
··· 140 140 onChangeText, 141 141 isInvalid, 142 142 inputRef, 143 + style, 143 144 ...rest 144 145 }: InputProps) { 145 146 const t = useTheme() ··· 206 207 android({ 207 208 paddingBottom: 16, 208 209 }), 210 + style, 209 211 ]} 210 212 /> 211 213
+68
src/components/hooks/useStarterPackEntry.native.ts
··· 1 + import React from 'react' 2 + 3 + import { 4 + createStarterPackLinkFromAndroidReferrer, 5 + httpStarterPackUriToAtUri, 6 + } from 'lib/strings/starter-pack' 7 + import {isAndroid} from 'platform/detection' 8 + import {useHasCheckedForStarterPack} from 'state/preferences/used-starter-packs' 9 + import {useSetActiveStarterPack} from 'state/shell/starter-pack' 10 + import {DevicePrefs, Referrer} from '../../../modules/expo-bluesky-swiss-army' 11 + 12 + export function useStarterPackEntry() { 13 + const [ready, setReady] = React.useState(false) 14 + const setActiveStarterPack = useSetActiveStarterPack() 15 + const hasCheckedForStarterPack = useHasCheckedForStarterPack() 16 + 17 + React.useEffect(() => { 18 + if (ready) return 19 + 20 + // On Android, we cannot clear the referral link. It gets stored for 90 days and all we can do is query for it. So, 21 + // let's just ensure we never check again after the first time. 22 + if (hasCheckedForStarterPack) { 23 + setReady(true) 24 + return 25 + } 26 + 27 + // Safety for Android. Very unlike this could happen, but just in case. The response should be nearly immediate 28 + const timeout = setTimeout(() => { 29 + setReady(true) 30 + }, 500) 31 + 32 + ;(async () => { 33 + let uri: string | null | undefined 34 + 35 + if (isAndroid) { 36 + const res = await Referrer.getGooglePlayReferrerInfoAsync() 37 + 38 + if (res && res.installReferrer) { 39 + uri = createStarterPackLinkFromAndroidReferrer(res.installReferrer) 40 + } 41 + } else { 42 + const res = await DevicePrefs.getStringValueAsync( 43 + 'starterPackUri', 44 + true, 45 + ) 46 + 47 + if (res) { 48 + uri = httpStarterPackUriToAtUri(res) 49 + DevicePrefs.setStringValueAsync('starterPackUri', null, true) 50 + } 51 + } 52 + 53 + if (uri) { 54 + setActiveStarterPack({ 55 + uri, 56 + }) 57 + } 58 + 59 + setReady(true) 60 + })() 61 + 62 + return () => { 63 + clearTimeout(timeout) 64 + } 65 + }, [ready, setActiveStarterPack, hasCheckedForStarterPack]) 66 + 67 + return ready 68 + }
+29
src/components/hooks/useStarterPackEntry.ts
··· 1 + import React from 'react' 2 + 3 + import {httpStarterPackUriToAtUri} from 'lib/strings/starter-pack' 4 + import {useSetActiveStarterPack} from 'state/shell/starter-pack' 5 + 6 + export function useStarterPackEntry() { 7 + const [ready, setReady] = React.useState(false) 8 + 9 + const setActiveStarterPack = useSetActiveStarterPack() 10 + 11 + React.useEffect(() => { 12 + const href = window.location.href 13 + const atUri = httpStarterPackUriToAtUri(href) 14 + 15 + if (atUri) { 16 + const url = new URL(href) 17 + // Determines if an App Clip is loading this landing page 18 + const isClip = url.searchParams.get('clip') === 'true' 19 + setActiveStarterPack({ 20 + uri: atUri, 21 + isClip, 22 + }) 23 + } 24 + 25 + setReady(true) 26 + }, [setActiveStarterPack]) 27 + 28 + return ready 29 + }
+5
src/components/icons/QrCode.tsx
··· 1 + import {createSinglePathSVG} from './TEMPLATE' 2 + 3 + export const QrCode_Stroke2_Corner0_Rounded = createSinglePathSVG({ 4 + 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', 5 + })
+8
src/components/icons/StarterPack.tsx
··· 1 + import {createMultiPathSVG} from './TEMPLATE' 2 + 3 + export const StarterPack = createMultiPathSVG({ 4 + paths: [ 5 + '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', 6 + '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', 7 + ], 8 + })
+30 -1
src/components/icons/TEMPLATE.tsx
··· 30 30 31 31 export function createSinglePathSVG({path}: {path: string}) { 32 32 return React.forwardRef<Svg, Props>(function LogoImpl(props, ref) { 33 - const {fill, size, style, ...rest} = useCommonSVGProps(props) 33 + const {fill, size, style, gradient, ...rest} = useCommonSVGProps(props) 34 34 35 35 return ( 36 36 <Svg ··· 41 41 width={size} 42 42 height={size} 43 43 style={[style]}> 44 + {gradient} 44 45 <Path fill={fill} fillRule="evenodd" clipRule="evenodd" d={path} /> 45 46 </Svg> 46 47 ) 47 48 }) 48 49 } 50 + 51 + export function createMultiPathSVG({paths}: {paths: string[]}) { 52 + return React.forwardRef<Svg, Props>(function LogoImpl(props, ref) { 53 + const {fill, size, style, gradient, ...rest} = useCommonSVGProps(props) 54 + 55 + return ( 56 + <Svg 57 + fill="none" 58 + {...rest} 59 + ref={ref} 60 + viewBox="0 0 24 24" 61 + width={size} 62 + height={size} 63 + style={[style]}> 64 + {gradient} 65 + {paths.map((path, i) => ( 66 + <Path 67 + key={i} 68 + fill={fill} 69 + fillRule="evenodd" 70 + clipRule="evenodd" 71 + d={path} 72 + /> 73 + ))} 74 + </Svg> 75 + ) 76 + }) 77 + }
-32
src/components/icons/common.ts
··· 1 - import {StyleSheet, TextProps} from 'react-native' 2 - import type {PathProps, SvgProps} from 'react-native-svg' 3 - 4 - import {tokens} from '#/alf' 5 - 6 - export type Props = { 7 - fill?: PathProps['fill'] 8 - style?: TextProps['style'] 9 - size?: keyof typeof sizes 10 - } & Omit<SvgProps, 'style' | 'size'> 11 - 12 - export const sizes = { 13 - xs: 12, 14 - sm: 16, 15 - md: 20, 16 - lg: 24, 17 - xl: 28, 18 - } 19 - 20 - export function useCommonSVGProps(props: Props) { 21 - const {fill, size, ...rest} = props 22 - const style = StyleSheet.flatten(rest.style) 23 - const _fill = fill || style?.color || tokens.color.blue_500 24 - const _size = Number(size ? sizes[size] : rest.width || sizes.md) 25 - 26 - return { 27 - fill: _fill, 28 - size: _size, 29 - style, 30 - ...rest, 31 - } 32 - }
+59
src/components/icons/common.tsx
··· 1 + import React from 'react' 2 + import {StyleSheet, TextProps} from 'react-native' 3 + import type {PathProps, SvgProps} from 'react-native-svg' 4 + import {Defs, LinearGradient, Stop} from 'react-native-svg' 5 + import {nanoid} from 'nanoid/non-secure' 6 + 7 + import {tokens} from '#/alf' 8 + 9 + export type Props = { 10 + fill?: PathProps['fill'] 11 + style?: TextProps['style'] 12 + size?: keyof typeof sizes 13 + gradient?: keyof typeof tokens.gradients 14 + } & Omit<SvgProps, 'style' | 'size'> 15 + 16 + export const sizes = { 17 + xs: 12, 18 + sm: 16, 19 + md: 20, 20 + lg: 24, 21 + xl: 28, 22 + } 23 + 24 + export function useCommonSVGProps(props: Props) { 25 + const {fill, size, gradient, ...rest} = props 26 + const style = StyleSheet.flatten(rest.style) 27 + const _size = Number(size ? sizes[size] : rest.width || sizes.md) 28 + let _fill = fill || style?.color || tokens.color.blue_500 29 + let gradientDef = null 30 + 31 + if (gradient && tokens.gradients[gradient]) { 32 + const id = gradient + '_' + nanoid() 33 + const config = tokens.gradients[gradient] 34 + _fill = `url(#${id})` 35 + gradientDef = ( 36 + <Defs> 37 + <LinearGradient 38 + id={id} 39 + x1="0" 40 + y1="0" 41 + x2="100%" 42 + y2="0" 43 + gradientTransform="rotate(45)"> 44 + {config.values.map(([stop, fill]) => ( 45 + <Stop key={stop} offset={stop} stopColor={fill} /> 46 + ))} 47 + </LinearGradient> 48 + </Defs> 49 + ) 50 + } 51 + 52 + return { 53 + fill: _fill, 54 + size: _size, 55 + style, 56 + gradient: gradientDef, 57 + ...rest, 58 + } 59 + }
+1
src/lib/browser.native.ts
··· 1 1 export const isSafari = false 2 2 export const isFirefox = false 3 3 export const isTouchDevice = true 4 + export const isAndroidWeb = false
+2
src/lib/browser.ts
··· 5 5 export const isFirefox = /firefox|fxios/i.test(navigator.userAgent) 6 6 export const isTouchDevice = 7 7 'ontouchstart' in window || navigator.maxTouchPoints > 1 8 + export const isAndroidWeb = 9 + /android/i.test(navigator.userAgent) && isTouchDevice
+164
src/lib/generate-starterpack.ts
··· 1 + import { 2 + AppBskyActorDefs, 3 + AppBskyGraphGetStarterPack, 4 + BskyAgent, 5 + Facet, 6 + } from '@atproto/api' 7 + import {msg} from '@lingui/macro' 8 + import {useLingui} from '@lingui/react' 9 + import {useMutation} from '@tanstack/react-query' 10 + 11 + import {until} from 'lib/async/until' 12 + import {sanitizeDisplayName} from 'lib/strings/display-names' 13 + import {sanitizeHandle} from 'lib/strings/handles' 14 + import {enforceLen} from 'lib/strings/helpers' 15 + import {useAgent} from 'state/session' 16 + 17 + export const createStarterPackList = async ({ 18 + name, 19 + description, 20 + descriptionFacets, 21 + profiles, 22 + agent, 23 + }: { 24 + name: string 25 + description?: string 26 + descriptionFacets?: Facet[] 27 + profiles: AppBskyActorDefs.ProfileViewBasic[] 28 + agent: BskyAgent 29 + }): Promise<{uri: string; cid: string}> => { 30 + if (profiles.length === 0) throw new Error('No profiles given') 31 + 32 + const list = await agent.app.bsky.graph.list.create( 33 + {repo: agent.session!.did}, 34 + { 35 + name, 36 + description, 37 + descriptionFacets, 38 + avatar: undefined, 39 + createdAt: new Date().toISOString(), 40 + purpose: 'app.bsky.graph.defs#referencelist', 41 + }, 42 + ) 43 + if (!list) throw new Error('List creation failed') 44 + await agent.com.atproto.repo.applyWrites({ 45 + repo: agent.session!.did, 46 + writes: [ 47 + createListItem({did: agent.session!.did, listUri: list.uri}), 48 + ].concat( 49 + profiles 50 + // Ensure we don't have ourselves in this list twice 51 + .filter(p => p.did !== agent.session!.did) 52 + .map(p => createListItem({did: p.did, listUri: list.uri})), 53 + ), 54 + }) 55 + 56 + return list 57 + } 58 + 59 + export function useGenerateStarterPackMutation({ 60 + onSuccess, 61 + onError, 62 + }: { 63 + onSuccess: ({uri, cid}: {uri: string; cid: string}) => void 64 + onError: (e: Error) => void 65 + }) { 66 + const {_} = useLingui() 67 + const agent = useAgent() 68 + const starterPackString = _(msg`Starter Pack`) 69 + 70 + return useMutation<{uri: string; cid: string}, Error, void>({ 71 + mutationFn: async () => { 72 + let profile: AppBskyActorDefs.ProfileViewBasic | undefined 73 + let profiles: AppBskyActorDefs.ProfileViewBasic[] | undefined 74 + 75 + await Promise.all([ 76 + (async () => { 77 + profile = ( 78 + await agent.app.bsky.actor.getProfile({ 79 + actor: agent.session!.did, 80 + }) 81 + ).data 82 + })(), 83 + (async () => { 84 + profiles = ( 85 + await agent.app.bsky.actor.searchActors({ 86 + q: encodeURIComponent('*'), 87 + limit: 49, 88 + }) 89 + ).data.actors.filter(p => p.viewer?.following) 90 + })(), 91 + ]) 92 + 93 + if (!profile || !profiles) { 94 + throw new Error('ERROR_DATA') 95 + } 96 + 97 + // We include ourselves when we make the list 98 + if (profiles.length < 7) { 99 + throw new Error('NOT_ENOUGH_FOLLOWERS') 100 + } 101 + 102 + const displayName = enforceLen( 103 + profile.displayName 104 + ? sanitizeDisplayName(profile.displayName) 105 + : `@${sanitizeHandle(profile.handle)}`, 106 + 25, 107 + true, 108 + ) 109 + const starterPackName = `${displayName}'s ${starterPackString}` 110 + 111 + const list = await createStarterPackList({ 112 + name: starterPackName, 113 + profiles, 114 + agent, 115 + }) 116 + 117 + return await agent.app.bsky.graph.starterpack.create( 118 + { 119 + repo: agent.session!.did, 120 + }, 121 + { 122 + name: starterPackName, 123 + list: list.uri, 124 + createdAt: new Date().toISOString(), 125 + }, 126 + ) 127 + }, 128 + onSuccess: async data => { 129 + await whenAppViewReady(agent, data.uri, v => { 130 + return typeof v?.data.starterPack.uri === 'string' 131 + }) 132 + onSuccess(data) 133 + }, 134 + onError: error => { 135 + onError(error) 136 + }, 137 + }) 138 + } 139 + 140 + function createListItem({did, listUri}: {did: string; listUri: string}) { 141 + return { 142 + $type: 'com.atproto.repo.applyWrites#create', 143 + collection: 'app.bsky.graph.listitem', 144 + value: { 145 + $type: 'app.bsky.graph.listitem', 146 + subject: did, 147 + list: listUri, 148 + createdAt: new Date().toISOString(), 149 + }, 150 + } 151 + } 152 + 153 + async function whenAppViewReady( 154 + agent: BskyAgent, 155 + uri: string, 156 + fn: (res?: AppBskyGraphGetStarterPack.Response) => boolean, 157 + ) { 158 + await until( 159 + 5, // 5 tries 160 + 1e3, // 1s delay between tries 161 + fn, 162 + () => agent.app.bsky.graph.getStarterPack({starterPack: uri}), 163 + ) 164 + }
+14
src/lib/hooks/useBottomBarOffset.ts
··· 1 + import {useSafeAreaInsets} from 'react-native-safe-area-context' 2 + 3 + import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' 4 + import {clamp} from 'lib/numbers' 5 + import {isWeb} from 'platform/detection' 6 + 7 + export function useBottomBarOffset(modifier: number = 0) { 8 + const {isTabletOrDesktop} = useWebMediaQueries() 9 + const {bottom: bottomInset} = useSafeAreaInsets() 10 + return ( 11 + (isWeb && isTabletOrDesktop ? 0 : clamp(60 + bottomInset, 60, 75)) + 12 + modifier 13 + ) 14 + }
+2
src/lib/hooks/useNotificationHandler.ts
··· 26 26 | 'reply' 27 27 | 'quote' 28 28 | 'chat-message' 29 + | 'starterpack-joined' 29 30 30 31 type NotificationPayload = 31 32 | { ··· 142 143 case 'mention': 143 144 case 'quote': 144 145 case 'reply': 146 + case 'starterpack-joined': 145 147 resetToTab('NotificationsTab') 146 148 break 147 149 // TODO implement these after we have an idea of how to handle each individual case
+21
src/lib/moderation/create-sanitized-display-name.ts
··· 1 + import {AppBskyActorDefs} from '@atproto/api' 2 + 3 + import {sanitizeDisplayName} from 'lib/strings/display-names' 4 + import {sanitizeHandle} from 'lib/strings/handles' 5 + 6 + export function createSanitizedDisplayName( 7 + profile: 8 + | AppBskyActorDefs.ProfileViewBasic 9 + | AppBskyActorDefs.ProfileViewDetailed, 10 + noAt = false, 11 + ) { 12 + if (profile.displayName != null && profile.displayName !== '') { 13 + return sanitizeDisplayName(profile.displayName) 14 + } else { 15 + let sanitizedHandle = sanitizeHandle(profile.handle) 16 + if (!noAt) { 17 + sanitizedHandle = `@${sanitizedHandle}` 18 + } 19 + return sanitizedHandle 20 + } 21 + }
+9
src/lib/moderation/useReportOptions.ts
··· 13 13 account: ReportOption[] 14 14 post: ReportOption[] 15 15 list: ReportOption[] 16 + starterpack: ReportOption[] 16 17 feedgen: ReportOption[] 17 18 other: ReportOption[] 18 19 convoMessage: ReportOption[] ··· 87 88 ...common, 88 89 ], 89 90 list: [ 91 + { 92 + reason: ComAtprotoModerationDefs.REASONVIOLATION, 93 + title: _(msg`Name or Description Violates Community Standards`), 94 + description: _(msg`Terms used violate community standards`), 95 + }, 96 + ...common, 97 + ], 98 + starterpack: [ 90 99 { 91 100 reason: ComAtprotoModerationDefs.REASONVIOLATION, 92 101 title: _(msg`Name or Description Violates Community Standards`),
+17
src/lib/routes/links.ts
··· 1 + import {AppBskyGraphDefs, AtUri} from '@atproto/api' 2 + 1 3 import {isInvalidHandle} from 'lib/strings/handles' 2 4 3 5 export function makeProfileLink( ··· 35 37 props.query + (props.from ? ` from:${props.from}` : ''), 36 38 )}` 37 39 } 40 + 41 + export function makeStarterPackLink( 42 + starterPackOrName: 43 + | AppBskyGraphDefs.StarterPackViewBasic 44 + | AppBskyGraphDefs.StarterPackView 45 + | string, 46 + rkey?: string, 47 + ) { 48 + if (typeof starterPackOrName === 'string') { 49 + return `https://bsky.app/start/${starterPackOrName}/${rkey}` 50 + } else { 51 + const uriRkey = new AtUri(starterPackOrName.uri).rkey 52 + return `https://bsky.app/start/${starterPackOrName.creator.handle}/${uriRkey}` 53 + } 54 + }
+12
src/lib/routes/types.ts
··· 42 42 MessagesConversation: {conversation: string; embed?: string} 43 43 MessagesSettings: undefined 44 44 Feeds: undefined 45 + Start: {name: string; rkey: string} 46 + StarterPack: {name: string; rkey: string; new?: boolean} 47 + StarterPackWizard: undefined 48 + StarterPackEdit: { 49 + rkey?: string 50 + } 45 51 } 46 52 47 53 export type BottomTabNavigatorParams = CommonNavigatorParams & { ··· 93 99 Hashtag: {tag: string; author?: string} 94 100 MessagesTab: undefined 95 101 Messages: {animation?: 'push' | 'pop'} 102 + Start: {name: string; rkey: string} 103 + StarterPack: {name: string; rkey: string; new?: boolean} 104 + StarterPackWizard: undefined 105 + StarterPackEdit: { 106 + rkey?: string 107 + } 96 108 } 97 109 98 110 // NOTE
+33 -2
src/lib/statsig/events.ts
··· 53 53 } 54 54 'onboarding:moderation:nextPressed': {} 55 55 'onboarding:profile:nextPressed': {} 56 - 'onboarding:finished:nextPressed': {} 56 + 'onboarding:finished:nextPressed': { 57 + usedStarterPack: boolean 58 + starterPackName?: string 59 + starterPackCreator?: string 60 + starterPackUri?: string 61 + profilesFollowed: number 62 + feedsPinned: number 63 + } 57 64 'onboarding:finished:avatarResult': { 58 65 avatarResult: 'default' | 'created' | 'uploaded' 59 66 } ··· 61 68 feedUrl: string 62 69 feedType: string 63 70 index: number 64 - reason: 'focus' | 'tabbar-click' | 'pager-swipe' | 'desktop-sidebar-click' 71 + reason: 72 + | 'focus' 73 + | 'tabbar-click' 74 + | 'pager-swipe' 75 + | 'desktop-sidebar-click' 76 + | 'starter-pack-initial-feed' 65 77 } 66 78 'feed:endReached:sampled': { 67 79 feedUrl: string ··· 134 146 | 'ProfileMenu' 135 147 | 'ProfileHoverCard' 136 148 | 'AvatarButton' 149 + | 'StarterPackProfilesList' 137 150 } 138 151 'profile:unfollow': { 139 152 logContext: ··· 146 159 | 'ProfileHoverCard' 147 160 | 'Chat' 148 161 | 'AvatarButton' 162 + | 'StarterPackProfilesList' 149 163 } 150 164 'chat:create': { 151 165 logContext: 'ProfileHeader' | 'NewChatDialog' | 'SendViaChatDialog' ··· 156 170 | 'NewChatDialog' 157 171 | 'ChatsList' 158 172 | 'SendViaChatDialog' 173 + } 174 + 'starterPack:share': { 175 + starterPack: string 176 + shareType: 'link' | 'qrcode' 177 + qrShareType?: 'save' | 'copy' | 'share' 178 + } 179 + 'starterPack:followAll': { 180 + logContext: 'StarterPackProfilesList' | 'Onboarding' 181 + starterPack: string 182 + count: number 183 + } 184 + 'starterPack:delete': {} 185 + 'starterPack:create': { 186 + setName: boolean 187 + setDescription: boolean 188 + profilesCount: number 189 + feedsCount: number 159 190 } 160 191 161 192 'test:all:always': {}
+1
src/lib/statsig/gates.ts
··· 5 5 | 'request_notifications_permission_after_onboarding_v2' 6 6 | 'show_avi_follow_button' 7 7 | 'show_follow_back_label_v2' 8 + | 'starter_packs_enabled'
+101
src/lib/strings/starter-pack.ts
··· 1 + import {AppBskyGraphDefs, AtUri} from '@atproto/api' 2 + 3 + export function createStarterPackLinkFromAndroidReferrer( 4 + referrerQueryString: string, 5 + ): string | null { 6 + try { 7 + // The referrer string is just some URL parameters, so lets add them to a fake URL 8 + const url = new URL('http://throwaway.com/?' + referrerQueryString) 9 + const utmContent = url.searchParams.get('utm_content') 10 + const utmSource = url.searchParams.get('utm_source') 11 + 12 + if (!utmContent) return null 13 + if (utmSource !== 'bluesky') return null 14 + 15 + // This should be a string like `starterpack_haileyok.com_rkey` 16 + const contentParts = utmContent.split('_') 17 + 18 + if (contentParts[0] !== 'starterpack') return null 19 + if (contentParts.length !== 3) return null 20 + 21 + return `at://${contentParts[1]}/app.bsky.graph.starterpack/${contentParts[2]}` 22 + } catch (e) { 23 + return null 24 + } 25 + } 26 + 27 + export function parseStarterPackUri(uri?: string): { 28 + name: string 29 + rkey: string 30 + } | null { 31 + if (!uri) return null 32 + 33 + try { 34 + if (uri.startsWith('at://')) { 35 + const atUri = new AtUri(uri) 36 + if (atUri.collection !== 'app.bsky.graph.starterpack') return null 37 + if (atUri.rkey) { 38 + return { 39 + name: atUri.hostname, 40 + rkey: atUri.rkey, 41 + } 42 + } 43 + return null 44 + } else { 45 + const url = new URL(uri) 46 + const parts = url.pathname.split('/') 47 + const [_, path, name, rkey] = parts 48 + 49 + if (parts.length !== 4) return null 50 + if (path !== 'starter-pack' && path !== 'start') return null 51 + if (!name || !rkey) return null 52 + return { 53 + name, 54 + rkey, 55 + } 56 + } 57 + } catch (e) { 58 + return null 59 + } 60 + } 61 + 62 + export function createStarterPackGooglePlayUri( 63 + name: string, 64 + rkey: string, 65 + ): string | null { 66 + if (!name || !rkey) return null 67 + return `https://play.google.com/store/apps/details?id=xyz.blueskyweb.app&referrer=utm_source%3Dbluesky%26utm_medium%3Dstarterpack%26utm_content%3Dstarterpack_${name}_${rkey}` 68 + } 69 + 70 + export function httpStarterPackUriToAtUri(httpUri?: string): string | null { 71 + if (!httpUri) return null 72 + 73 + const parsed = parseStarterPackUri(httpUri) 74 + if (!parsed) return null 75 + 76 + if (httpUri.startsWith('at://')) return httpUri 77 + 78 + return `at://${parsed.name}/app.bsky.graph.starterpack/${parsed.rkey}` 79 + } 80 + 81 + export function getStarterPackOgCard( 82 + didOrStarterPack: AppBskyGraphDefs.StarterPackView | string, 83 + rkey?: string, 84 + ) { 85 + if (typeof didOrStarterPack === 'string') { 86 + return `https://ogcard.cdn.bsky.app/start/${didOrStarterPack}/${rkey}` 87 + } else { 88 + const rkey = new AtUri(didOrStarterPack.uri).rkey 89 + return `https://ogcard.cdn.bsky.app/start/${didOrStarterPack.creator.did}/${rkey}` 90 + } 91 + } 92 + 93 + export function createStarterPackUri({ 94 + did, 95 + rkey, 96 + }: { 97 + did: string 98 + rkey: string 99 + }): string | null { 100 + return new AtUri(`at://${did}/app.bsky.graph.starterpack/${rkey}`).toString() 101 + }
+4
src/routes.ts
··· 41 41 Messages: '/messages', 42 42 MessagesSettings: '/messages/settings', 43 43 MessagesConversation: '/messages/:conversation', 44 + Start: '/start/:name/:rkey', 45 + StarterPackEdit: '/starter-pack/edit/:rkey', 46 + StarterPack: '/starter-pack/:name/:rkey', 47 + StarterPackWizard: '/starter-pack/create', 44 48 })
+3
src/screens/Login/LoginForm.tsx
··· 21 21 import {useSessionApi} from '#/state/session' 22 22 import {useLoggedOutViewControls} from '#/state/shell/logged-out' 23 23 import {useRequestNotificationsPermission} from 'lib/notifications/notifications' 24 + import {useSetHasCheckedForStarterPack} from 'state/preferences/used-starter-packs' 24 25 import {atoms as a, useTheme} from '#/alf' 25 26 import {Button, ButtonIcon, ButtonText} from '#/components/Button' 26 27 import {FormError} from '#/components/forms/FormError' ··· 69 70 const {login} = useSessionApi() 70 71 const requestNotificationsPermission = useRequestNotificationsPermission() 71 72 const {setShowLoggedOut} = useLoggedOutViewControls() 73 + const setHasCheckedForStarterPack = useSetHasCheckedForStarterPack() 72 74 73 75 const onPressSelectService = React.useCallback(() => { 74 76 Keyboard.dismiss() ··· 116 118 'LoginForm', 117 119 ) 118 120 setShowLoggedOut(false) 121 + setHasCheckedForStarterPack(true) 119 122 requestNotificationsPermission('Login') 120 123 } catch (e: any) { 121 124 const errMsg = e.toString()
+9 -2
src/screens/Login/ScreenTransition.tsx
··· 1 1 import React from 'react' 2 + import {StyleProp, ViewStyle} from 'react-native' 2 3 import Animated, {FadeInRight, FadeOutLeft} from 'react-native-reanimated' 3 4 4 - export function ScreenTransition({children}: {children: React.ReactNode}) { 5 + export function ScreenTransition({ 6 + style, 7 + children, 8 + }: { 9 + style?: StyleProp<ViewStyle> 10 + children: React.ReactNode 11 + }) { 5 12 return ( 6 - <Animated.View entering={FadeInRight} exiting={FadeOutLeft}> 13 + <Animated.View style={style} entering={FadeInRight} exiting={FadeOutLeft}> 7 14 {children} 8 15 </Animated.View> 9 16 )
+111 -6
src/screens/Onboarding/StepFinished.tsx
··· 1 1 import React from 'react' 2 2 import {View} from 'react-native' 3 + import {AppBskyGraphDefs, AppBskyGraphStarterpack} from '@atproto/api' 4 + import {SavedFeed} from '@atproto/api/dist/client/types/app/bsky/actor/defs' 5 + import {TID} from '@atproto/common-web' 3 6 import {msg, Trans} from '@lingui/macro' 4 7 import {useLingui} from '@lingui/react' 5 8 import {useQueryClient} from '@tanstack/react-query' 6 9 7 10 import {useAnalytics} from '#/lib/analytics/analytics' 8 - import {BSKY_APP_ACCOUNT_DID} from '#/lib/constants' 11 + import { 12 + BSKY_APP_ACCOUNT_DID, 13 + DISCOVER_SAVED_FEED, 14 + TIMELINE_SAVED_FEED, 15 + } from '#/lib/constants' 9 16 import {logEvent} from '#/lib/statsig/statsig' 10 17 import {logger} from '#/logger' 11 18 import {preferencesQueryKey} from '#/state/queries/preferences' ··· 14 21 import {useOnboardingDispatch} from '#/state/shell' 15 22 import {uploadBlob} from 'lib/api' 16 23 import {useRequestNotificationsPermission} from 'lib/notifications/notifications' 24 + import {useSetHasCheckedForStarterPack} from 'state/preferences/used-starter-packs' 25 + import { 26 + useActiveStarterPack, 27 + useSetActiveStarterPack, 28 + } from 'state/shell/starter-pack' 17 29 import { 18 30 DescriptionText, 19 31 OnboardingControls, ··· 41 53 const queryClient = useQueryClient() 42 54 const agent = useAgent() 43 55 const requestNotificationsPermission = useRequestNotificationsPermission() 56 + const activeStarterPack = useActiveStarterPack() 57 + const setActiveStarterPack = useSetActiveStarterPack() 58 + const setHasCheckedForStarterPack = useSetHasCheckedForStarterPack() 44 59 45 60 const finishOnboarding = React.useCallback(async () => { 46 61 setSaving(true) 47 62 48 - const {interestsStepResults, profileStepResults} = state 49 - const {selectedInterests} = interestsStepResults 63 + let starterPack: AppBskyGraphDefs.StarterPackView | undefined 64 + let listItems: AppBskyGraphDefs.ListItemView[] | undefined 65 + 66 + if (activeStarterPack?.uri) { 67 + try { 68 + const spRes = await agent.app.bsky.graph.getStarterPack({ 69 + starterPack: activeStarterPack.uri, 70 + }) 71 + starterPack = spRes.data.starterPack 72 + 73 + if (starterPack.list) { 74 + const listRes = await agent.app.bsky.graph.getList({ 75 + list: starterPack.list.uri, 76 + limit: 50, 77 + }) 78 + listItems = listRes.data.items 79 + } 80 + } catch (e) { 81 + logger.error('Failed to fetch starter pack', {safeMessage: e}) 82 + // don't tell the user, just get them through onboarding. 83 + } 84 + } 85 + 50 86 try { 87 + const {interestsStepResults, profileStepResults} = state 88 + const {selectedInterests} = interestsStepResults 89 + 51 90 await Promise.all([ 52 - bulkWriteFollows(agent, [BSKY_APP_ACCOUNT_DID]), 91 + bulkWriteFollows(agent, [ 92 + BSKY_APP_ACCOUNT_DID, 93 + ...(listItems?.map(i => i.subject.did) ?? []), 94 + ]), 53 95 (async () => { 96 + // Interests need to get saved first, then we can write the feeds to prefs 54 97 await agent.setInterestsPref({tags: selectedInterests}) 98 + 99 + // Default feeds that every user should have pinned when landing in the app 100 + const feedsToSave: SavedFeed[] = [ 101 + { 102 + ...DISCOVER_SAVED_FEED, 103 + id: TID.nextStr(), 104 + }, 105 + { 106 + ...TIMELINE_SAVED_FEED, 107 + id: TID.nextStr(), 108 + }, 109 + ] 110 + 111 + // Any starter pack feeds will be pinned _after_ the defaults 112 + if (starterPack && starterPack.feeds?.length) { 113 + feedsToSave.concat( 114 + starterPack.feeds.map(f => ({ 115 + type: 'feed', 116 + value: f.uri, 117 + pinned: true, 118 + id: TID.nextStr(), 119 + })), 120 + ) 121 + } 122 + 123 + await agent.overwriteSavedFeeds(feedsToSave) 55 124 })(), 56 125 (async () => { 57 126 const {imageUri, imageMime} = profileStepResults ··· 63 132 if (res.data.blob) { 64 133 existing.avatar = res.data.blob 65 134 } 135 + 136 + if (starterPack) { 137 + existing.joinedViaStarterPack = { 138 + uri: starterPack.uri, 139 + cid: starterPack.cid, 140 + } 141 + } 142 + 143 + existing.displayName = '' 144 + // HACKFIX 145 + // creating a bunch of identical profile objects is breaking the relay 146 + // tossing this unspecced field onto it to reduce the size of the problem 147 + // -prf 148 + existing.createdAt = new Date().toISOString() 66 149 return existing 67 150 }) 68 151 } 152 + 69 153 logEvent('onboarding:finished:avatarResult', { 70 154 avatarResult: profileStepResults.isCreatedAvatar 71 155 ? 'created' ··· 96 180 }) 97 181 98 182 setSaving(false) 183 + setActiveStarterPack(undefined) 184 + setHasCheckedForStarterPack(true) 99 185 dispatch({type: 'finish'}) 100 186 onboardDispatch({type: 'finish'}) 101 187 track('OnboardingV2:StepFinished:End') 102 188 track('OnboardingV2:Complete') 103 - logEvent('onboarding:finished:nextPressed', {}) 189 + logEvent('onboarding:finished:nextPressed', { 190 + usedStarterPack: Boolean(starterPack), 191 + starterPackName: AppBskyGraphStarterpack.isRecord(starterPack?.record) 192 + ? starterPack.record.name 193 + : undefined, 194 + starterPackCreator: starterPack?.creator.did, 195 + starterPackUri: starterPack?.uri, 196 + profilesFollowed: listItems?.length ?? 0, 197 + feedsPinned: starterPack?.feeds?.length ?? 0, 198 + }) 199 + if (starterPack && listItems?.length) { 200 + logEvent('starterPack:followAll', { 201 + logContext: 'Onboarding', 202 + starterPack: starterPack.uri, 203 + count: listItems?.length, 204 + }) 205 + } 104 206 }, [ 105 - state, 106 207 queryClient, 107 208 agent, 108 209 dispatch, 109 210 onboardDispatch, 110 211 track, 212 + activeStarterPack, 213 + state, 111 214 requestNotificationsPermission, 215 + setActiveStarterPack, 216 + setHasCheckedForStarterPack, 112 217 ]) 113 218 114 219 React.useEffect(() => {
+3 -3
src/screens/Profile/Header/DisplayName.tsx
··· 1 1 import React from 'react' 2 2 import {View} from 'react-native' 3 3 import {AppBskyActorDefs, ModerationDecision} from '@atproto/api' 4 - import {sanitizeHandle} from 'lib/strings/handles' 5 - import {sanitizeDisplayName} from 'lib/strings/display-names' 6 - import {Shadow} from '#/state/cache/types' 7 4 5 + import {Shadow} from '#/state/cache/types' 6 + import {sanitizeDisplayName} from 'lib/strings/display-names' 7 + import {sanitizeHandle} from 'lib/strings/handles' 8 8 import {atoms as a, useTheme} from '#/alf' 9 9 import {Text} from '#/components/Typography' 10 10
+39 -1
src/screens/Signup/index.tsx
··· 1 1 import React from 'react' 2 2 import {View} from 'react-native' 3 - import {LayoutAnimationConfig} from 'react-native-reanimated' 3 + import Animated, { 4 + FadeIn, 5 + FadeOut, 6 + LayoutAnimationConfig, 7 + } from 'react-native-reanimated' 8 + import {AppBskyGraphStarterpack} from '@atproto/api' 4 9 import {msg, Trans} from '@lingui/macro' 5 10 import {useLingui} from '@lingui/react' 6 11 ··· 11 16 import {logger} from '#/logger' 12 17 import {useServiceQuery} from '#/state/queries/service' 13 18 import {useAgent} from '#/state/session' 19 + import {useStarterPackQuery} from 'state/queries/starter-packs' 20 + import {useActiveStarterPack} from 'state/shell/starter-pack' 14 21 import {LoggedOutLayout} from '#/view/com/util/layouts/LoggedOutLayout' 15 22 import { 16 23 initialState, ··· 26 33 import {AppLanguageDropdown} from '#/components/AppLanguageDropdown' 27 34 import {Button, ButtonText} from '#/components/Button' 28 35 import {Divider} from '#/components/Divider' 36 + import {LinearGradientBackground} from '#/components/LinearGradientBackground' 29 37 import {InlineLinkText} from '#/components/Link' 30 38 import {Text} from '#/components/Typography' 31 39 ··· 37 45 const submit = useSubmitSignup({state, dispatch}) 38 46 const {gtMobile} = useBreakpoints() 39 47 const agent = useAgent() 48 + 49 + const activeStarterPack = useActiveStarterPack() 50 + const {data: starterPack} = useStarterPackQuery({ 51 + uri: activeStarterPack?.uri, 52 + }) 40 53 41 54 const { 42 55 data: serviceInfo, ··· 142 155 description={_(msg`We're so excited to have you join us!`)} 143 156 scrollable> 144 157 <View testID="createAccount" style={a.flex_1}> 158 + {state.activeStep === SignupStep.INFO && 159 + starterPack && 160 + AppBskyGraphStarterpack.isRecord(starterPack.record) ? ( 161 + <Animated.View entering={FadeIn} exiting={FadeOut}> 162 + <LinearGradientBackground 163 + style={[a.mx_lg, a.p_lg, a.gap_sm, a.rounded_sm]}> 164 + <Text style={[a.font_bold, a.text_xl, {color: 'white'}]}> 165 + {starterPack.record.name} 166 + </Text> 167 + <Text style={[{color: 'white'}]}> 168 + {starterPack.feeds?.length ? ( 169 + <Trans> 170 + You'll follow the suggested users and feeds once you 171 + finish creating your account! 172 + </Trans> 173 + ) : ( 174 + <Trans> 175 + You'll follow the suggested users once you finish creating 176 + your account! 177 + </Trans> 178 + )} 179 + </Text> 180 + </LinearGradientBackground> 181 + </Animated.View> 182 + ) : null} 145 183 <View 146 184 style={[ 147 185 a.flex_1,
+378
src/screens/StarterPack/StarterPackLandingScreen.tsx
··· 1 + import React from 'react' 2 + import {Pressable, ScrollView, View} from 'react-native' 3 + import Animated, {FadeIn, FadeOut} from 'react-native-reanimated' 4 + import { 5 + AppBskyGraphDefs, 6 + AppBskyGraphStarterpack, 7 + AtUri, 8 + ModerationOpts, 9 + } from '@atproto/api' 10 + import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' 11 + import {msg, Trans} from '@lingui/macro' 12 + import {useLingui} from '@lingui/react' 13 + 14 + import {isAndroidWeb} from 'lib/browser' 15 + import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' 16 + import {createStarterPackGooglePlayUri} from 'lib/strings/starter-pack' 17 + import {isWeb} from 'platform/detection' 18 + import {useModerationOpts} from 'state/preferences/moderation-opts' 19 + import {useStarterPackQuery} from 'state/queries/starter-packs' 20 + import { 21 + useActiveStarterPack, 22 + useSetActiveStarterPack, 23 + } from 'state/shell/starter-pack' 24 + import {LoggedOutScreenState} from 'view/com/auth/LoggedOut' 25 + import {CenteredView} from 'view/com/util/Views' 26 + import {Logo} from 'view/icons/Logo' 27 + import {atoms as a, useTheme} from '#/alf' 28 + import {Button, ButtonText} from '#/components/Button' 29 + import {useDialogControl} from '#/components/Dialog' 30 + import * as FeedCard from '#/components/FeedCard' 31 + import {LinearGradientBackground} from '#/components/LinearGradientBackground' 32 + import {ListMaybePlaceholder} from '#/components/Lists' 33 + import {Default as ProfileCard} from '#/components/ProfileCard' 34 + import * as Prompt from '#/components/Prompt' 35 + import {Text} from '#/components/Typography' 36 + 37 + const AnimatedPressable = Animated.createAnimatedComponent(Pressable) 38 + 39 + interface AppClipMessage { 40 + action: 'present' | 'store' 41 + keyToStoreAs?: string 42 + jsonToStore?: string 43 + } 44 + 45 + function postAppClipMessage(message: AppClipMessage) { 46 + // @ts-expect-error safari webview only 47 + window.webkit.messageHandlers.onMessage.postMessage(JSON.stringify(message)) 48 + } 49 + 50 + export function LandingScreen({ 51 + setScreenState, 52 + }: { 53 + setScreenState: (state: LoggedOutScreenState) => void 54 + }) { 55 + const moderationOpts = useModerationOpts() 56 + const activeStarterPack = useActiveStarterPack() 57 + 58 + const {data: starterPack, isError: isErrorStarterPack} = useStarterPackQuery({ 59 + uri: activeStarterPack?.uri, 60 + }) 61 + 62 + const isValid = 63 + starterPack && 64 + starterPack.list && 65 + AppBskyGraphDefs.validateStarterPackView(starterPack) && 66 + AppBskyGraphStarterpack.validateRecord(starterPack.record) 67 + 68 + React.useEffect(() => { 69 + if (isErrorStarterPack || (starterPack && !isValid)) { 70 + setScreenState(LoggedOutScreenState.S_LoginOrCreateAccount) 71 + } 72 + }, [isErrorStarterPack, setScreenState, isValid, starterPack]) 73 + 74 + if (!starterPack || !isValid || !moderationOpts) { 75 + return <ListMaybePlaceholder isLoading={true} /> 76 + } 77 + 78 + return ( 79 + <LandingScreenLoaded 80 + starterPack={starterPack} 81 + setScreenState={setScreenState} 82 + moderationOpts={moderationOpts} 83 + /> 84 + ) 85 + } 86 + 87 + function LandingScreenLoaded({ 88 + starterPack, 89 + setScreenState, 90 + // TODO apply this to profile card 91 + 92 + moderationOpts, 93 + }: { 94 + starterPack: AppBskyGraphDefs.StarterPackView 95 + setScreenState: (state: LoggedOutScreenState) => void 96 + moderationOpts: ModerationOpts 97 + }) { 98 + const {record, creator, listItemsSample, feeds, joinedWeekCount} = starterPack 99 + const {_} = useLingui() 100 + const t = useTheme() 101 + const activeStarterPack = useActiveStarterPack() 102 + const setActiveStarterPack = useSetActiveStarterPack() 103 + const {isTabletOrDesktop} = useWebMediaQueries() 104 + const androidDialogControl = useDialogControl() 105 + 106 + const [appClipOverlayVisible, setAppClipOverlayVisible] = 107 + React.useState(false) 108 + 109 + const listItemsCount = starterPack.list?.listItemCount ?? 0 110 + 111 + const onContinue = () => { 112 + setActiveStarterPack({ 113 + uri: starterPack.uri, 114 + }) 115 + setScreenState(LoggedOutScreenState.S_CreateAccount) 116 + } 117 + 118 + const onJoinPress = () => { 119 + if (activeStarterPack?.isClip) { 120 + setAppClipOverlayVisible(true) 121 + postAppClipMessage({ 122 + action: 'present', 123 + }) 124 + } else if (isAndroidWeb) { 125 + androidDialogControl.open() 126 + } else { 127 + onContinue() 128 + } 129 + } 130 + 131 + const onJoinWithoutPress = () => { 132 + if (activeStarterPack?.isClip) { 133 + setAppClipOverlayVisible(true) 134 + postAppClipMessage({ 135 + action: 'present', 136 + }) 137 + } else { 138 + setActiveStarterPack(undefined) 139 + setScreenState(LoggedOutScreenState.S_CreateAccount) 140 + } 141 + } 142 + 143 + if (!AppBskyGraphStarterpack.isRecord(record)) { 144 + return null 145 + } 146 + 147 + return ( 148 + <CenteredView style={a.flex_1}> 149 + <ScrollView 150 + style={[a.flex_1, t.atoms.bg]} 151 + contentContainerStyle={{paddingBottom: 100}}> 152 + <LinearGradientBackground 153 + style={[ 154 + a.align_center, 155 + a.gap_sm, 156 + a.px_lg, 157 + a.py_2xl, 158 + isTabletOrDesktop && [a.mt_2xl, a.rounded_md], 159 + activeStarterPack?.isClip && { 160 + paddingTop: 100, 161 + }, 162 + ]}> 163 + <View style={[a.flex_row, a.gap_md, a.pb_sm]}> 164 + <Logo width={76} fill="white" /> 165 + </View> 166 + <Text 167 + style={[ 168 + a.font_bold, 169 + a.text_4xl, 170 + a.text_center, 171 + a.leading_tight, 172 + {color: 'white'}, 173 + ]}> 174 + {record.name} 175 + </Text> 176 + <Text 177 + style={[ 178 + a.text_center, 179 + a.font_semibold, 180 + a.text_md, 181 + {color: 'white'}, 182 + ]}> 183 + Starter pack by {`@${creator.handle}`} 184 + </Text> 185 + </LinearGradientBackground> 186 + <View style={[a.gap_2xl, a.mx_lg, a.my_2xl]}> 187 + {record.description ? ( 188 + <Text style={[a.text_md, t.atoms.text_contrast_medium]}> 189 + {record.description} 190 + </Text> 191 + ) : null} 192 + <View style={[a.gap_sm]}> 193 + <Button 194 + label={_(msg`Join Bluesky`)} 195 + onPress={onJoinPress} 196 + variant="solid" 197 + color="primary" 198 + size="large"> 199 + <ButtonText style={[a.text_lg]}> 200 + <Trans>Join Bluesky</Trans> 201 + </ButtonText> 202 + </Button> 203 + {joinedWeekCount && joinedWeekCount >= 25 ? ( 204 + <View style={[a.flex_row, a.align_center, a.gap_sm]}> 205 + <FontAwesomeIcon 206 + icon="arrow-trend-up" 207 + size={12} 208 + color={t.atoms.text_contrast_medium.color} 209 + /> 210 + <Text 211 + style={[ 212 + a.font_semibold, 213 + a.text_sm, 214 + t.atoms.text_contrast_medium, 215 + ]} 216 + numberOfLines={1}> 217 + 123,659 joined this week 218 + </Text> 219 + </View> 220 + ) : null} 221 + </View> 222 + <View style={[a.gap_3xl]}> 223 + {Boolean(listItemsSample?.length) && ( 224 + <View style={[a.gap_md]}> 225 + <Text style={[a.font_heavy, a.text_lg]}> 226 + {listItemsCount <= 8 ? ( 227 + <Trans>You'll follow these people right away</Trans> 228 + ) : ( 229 + <Trans> 230 + You'll follow these people and {listItemsCount - 8} others 231 + </Trans> 232 + )} 233 + </Text> 234 + <View> 235 + {starterPack.listItemsSample?.slice(0, 8).map(item => ( 236 + <View 237 + key={item.subject.did} 238 + style={[ 239 + a.py_lg, 240 + a.px_md, 241 + a.border_t, 242 + t.atoms.border_contrast_low, 243 + ]}> 244 + <ProfileCard 245 + profile={item.subject} 246 + moderationOpts={moderationOpts} 247 + /> 248 + </View> 249 + ))} 250 + </View> 251 + </View> 252 + )} 253 + {feeds?.length ? ( 254 + <View style={[a.gap_md]}> 255 + <Text style={[a.font_heavy, a.text_lg]}> 256 + <Trans>You'll stay updated with these feeds</Trans> 257 + </Text> 258 + 259 + <View style={[{pointerEvents: 'none'}]}> 260 + {feeds?.map(feed => ( 261 + <View 262 + style={[ 263 + a.py_lg, 264 + a.px_md, 265 + a.border_t, 266 + t.atoms.border_contrast_low, 267 + ]} 268 + key={feed.uri}> 269 + <FeedCard.Default type="feed" view={feed} /> 270 + </View> 271 + ))} 272 + </View> 273 + </View> 274 + ) : null} 275 + </View> 276 + <Button 277 + label={_(msg`Signup without a starter pack`)} 278 + variant="solid" 279 + color="secondary" 280 + size="medium" 281 + style={[a.py_lg]} 282 + onPress={onJoinWithoutPress}> 283 + <ButtonText> 284 + <Trans>Signup without a starter pack</Trans> 285 + </ButtonText> 286 + </Button> 287 + </View> 288 + </ScrollView> 289 + <AppClipOverlay 290 + visible={appClipOverlayVisible} 291 + setIsVisible={setAppClipOverlayVisible} 292 + /> 293 + <Prompt.Outer control={androidDialogControl}> 294 + <Prompt.TitleText> 295 + <Trans>Download Bluesky</Trans> 296 + </Prompt.TitleText> 297 + <Prompt.DescriptionText> 298 + <Trans> 299 + The experience is better in the app. Download Bluesky now and we'll 300 + pick back up where you left off. 301 + </Trans> 302 + </Prompt.DescriptionText> 303 + <Prompt.Actions> 304 + <Prompt.Action 305 + cta="Download on Google Play" 306 + color="primary" 307 + onPress={() => { 308 + const rkey = new AtUri(starterPack.uri).rkey 309 + if (!rkey) return 310 + 311 + const googlePlayUri = createStarterPackGooglePlayUri( 312 + creator.handle, 313 + rkey, 314 + ) 315 + if (!googlePlayUri) return 316 + 317 + window.location.href = googlePlayUri 318 + }} 319 + /> 320 + <Prompt.Action 321 + cta="Continue on web" 322 + color="secondary" 323 + onPress={onContinue} 324 + /> 325 + </Prompt.Actions> 326 + </Prompt.Outer> 327 + {isWeb && ( 328 + <meta 329 + name="apple-itunes-app" 330 + content="app-id=xyz.blueskyweb.app, app-clip-bundle-id=xyz.blueskyweb.app.AppClip, app-clip-display=card" 331 + /> 332 + )} 333 + </CenteredView> 334 + ) 335 + } 336 + 337 + function AppClipOverlay({ 338 + visible, 339 + setIsVisible, 340 + }: { 341 + visible: boolean 342 + setIsVisible: (visible: boolean) => void 343 + }) { 344 + if (!visible) return 345 + 346 + return ( 347 + <AnimatedPressable 348 + accessibilityRole="button" 349 + style={[ 350 + a.absolute, 351 + { 352 + top: 0, 353 + left: 0, 354 + right: 0, 355 + bottom: 0, 356 + backgroundColor: 'rgba(0, 0, 0, 0.95)', 357 + zIndex: 1, 358 + }, 359 + ]} 360 + entering={FadeIn} 361 + exiting={FadeOut} 362 + onPress={() => setIsVisible(false)}> 363 + <View style={[a.flex_1, a.px_lg, {marginTop: 250}]}> 364 + {/* Webkit needs this to have a zindex of 2? */} 365 + <View style={[a.gap_md, {zIndex: 2}]}> 366 + <Text 367 + style={[a.font_bold, a.text_4xl, {lineHeight: 40, color: 'white'}]}> 368 + Download Bluesky to get started! 369 + </Text> 370 + <Text style={[a.text_lg, {color: 'white'}]}> 371 + We'll remember the starter pack you chose and use it when you create 372 + an account in the app. 373 + </Text> 374 + </View> 375 + </View> 376 + </AnimatedPressable> 377 + ) 378 + }
+627
src/screens/StarterPack/StarterPackScreen.tsx
··· 1 + import React from 'react' 2 + import {View} from 'react-native' 3 + import {Image} from 'expo-image' 4 + import { 5 + AppBskyGraphDefs, 6 + AppBskyGraphGetList, 7 + AppBskyGraphStarterpack, 8 + AtUri, 9 + ModerationOpts, 10 + } from '@atproto/api' 11 + import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' 12 + import {msg, Trans} from '@lingui/macro' 13 + import {useLingui} from '@lingui/react' 14 + import {useNavigation} from '@react-navigation/native' 15 + import {NativeStackScreenProps} from '@react-navigation/native-stack' 16 + import { 17 + InfiniteData, 18 + UseInfiniteQueryResult, 19 + useQueryClient, 20 + } from '@tanstack/react-query' 21 + 22 + import {cleanError} from '#/lib/strings/errors' 23 + import {logger} from '#/logger' 24 + import {useDeleteStarterPackMutation} from '#/state/queries/starter-packs' 25 + import {HITSLOP_20} from 'lib/constants' 26 + import {makeProfileLink, makeStarterPackLink} from 'lib/routes/links' 27 + import {CommonNavigatorParams, NavigationProp} from 'lib/routes/types' 28 + import {logEvent} from 'lib/statsig/statsig' 29 + import {getStarterPackOgCard} from 'lib/strings/starter-pack' 30 + import {isWeb} from 'platform/detection' 31 + import {useModerationOpts} from 'state/preferences/moderation-opts' 32 + import {RQKEY, useListMembersQuery} from 'state/queries/list-members' 33 + import {useResolveDidQuery} from 'state/queries/resolve-uri' 34 + import {useShortenLink} from 'state/queries/shorten-link' 35 + import {useStarterPackQuery} from 'state/queries/starter-packs' 36 + import {useAgent, useSession} from 'state/session' 37 + import * as Toast from '#/view/com/util/Toast' 38 + import {PagerWithHeader} from 'view/com/pager/PagerWithHeader' 39 + import {ProfileSubpageHeader} from 'view/com/profile/ProfileSubpageHeader' 40 + import {CenteredView} from 'view/com/util/Views' 41 + import {bulkWriteFollows} from '#/screens/Onboarding/util' 42 + import {atoms as a, useBreakpoints, useTheme} from '#/alf' 43 + import {Button, ButtonIcon, ButtonText} from '#/components/Button' 44 + import {useDialogControl} from '#/components/Dialog' 45 + import {ArrowOutOfBox_Stroke2_Corner0_Rounded as ArrowOutOfBox} from '#/components/icons/ArrowOutOfBox' 46 + import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfo} from '#/components/icons/CircleInfo' 47 + import {DotGrid_Stroke2_Corner0_Rounded as Ellipsis} from '#/components/icons/DotGrid' 48 + import {Pencil_Stroke2_Corner0_Rounded as Pencil} from '#/components/icons/Pencil' 49 + import {Trash_Stroke2_Corner0_Rounded as Trash} from '#/components/icons/Trash' 50 + import {ListMaybePlaceholder} from '#/components/Lists' 51 + import {Loader} from '#/components/Loader' 52 + import * as Menu from '#/components/Menu' 53 + import * as Prompt from '#/components/Prompt' 54 + import {ReportDialog, useReportDialogControl} from '#/components/ReportDialog' 55 + import {FeedsList} from '#/components/StarterPack/Main/FeedsList' 56 + import {ProfilesList} from '#/components/StarterPack/Main/ProfilesList' 57 + import {QrCodeDialog} from '#/components/StarterPack/QrCodeDialog' 58 + import {ShareDialog} from '#/components/StarterPack/ShareDialog' 59 + import {Text} from '#/components/Typography' 60 + 61 + type StarterPackScreeProps = NativeStackScreenProps< 62 + CommonNavigatorParams, 63 + 'StarterPack' 64 + > 65 + 66 + export function StarterPackScreen({route}: StarterPackScreeProps) { 67 + const {_} = useLingui() 68 + const {currentAccount} = useSession() 69 + 70 + const {name, rkey} = route.params 71 + const moderationOpts = useModerationOpts() 72 + const { 73 + data: did, 74 + isLoading: isLoadingDid, 75 + isError: isErrorDid, 76 + } = useResolveDidQuery(name) 77 + const { 78 + data: starterPack, 79 + isLoading: isLoadingStarterPack, 80 + isError: isErrorStarterPack, 81 + } = useStarterPackQuery({did, rkey}) 82 + const listMembersQuery = useListMembersQuery(starterPack?.list?.uri, 50) 83 + 84 + const isValid = 85 + starterPack && 86 + (starterPack.list || starterPack?.creator?.did === currentAccount?.did) && 87 + AppBskyGraphDefs.validateStarterPackView(starterPack) && 88 + AppBskyGraphStarterpack.validateRecord(starterPack.record) 89 + 90 + if (!did || !starterPack || !isValid || !moderationOpts) { 91 + return ( 92 + <ListMaybePlaceholder 93 + isLoading={ 94 + isLoadingDid || 95 + isLoadingStarterPack || 96 + listMembersQuery.isLoading || 97 + !moderationOpts 98 + } 99 + isError={isErrorDid || isErrorStarterPack || !isValid} 100 + errorMessage={_(msg`That starter pack could not be found.`)} 101 + emptyMessage={_(msg`That starter pack could not be found.`)} 102 + /> 103 + ) 104 + } 105 + 106 + if (!starterPack.list && starterPack.creator.did === currentAccount?.did) { 107 + return <InvalidStarterPack rkey={rkey} /> 108 + } 109 + 110 + return ( 111 + <StarterPackScreenInner 112 + starterPack={starterPack} 113 + routeParams={route.params} 114 + listMembersQuery={listMembersQuery} 115 + moderationOpts={moderationOpts} 116 + /> 117 + ) 118 + } 119 + 120 + function StarterPackScreenInner({ 121 + starterPack, 122 + routeParams, 123 + listMembersQuery, 124 + moderationOpts, 125 + }: { 126 + starterPack: AppBskyGraphDefs.StarterPackView 127 + routeParams: StarterPackScreeProps['route']['params'] 128 + listMembersQuery: UseInfiniteQueryResult< 129 + InfiniteData<AppBskyGraphGetList.OutputSchema> 130 + > 131 + moderationOpts: ModerationOpts 132 + }) { 133 + const tabs = [ 134 + ...(starterPack.list ? ['People'] : []), 135 + ...(starterPack.feeds?.length ? ['Feeds'] : []), 136 + ] 137 + 138 + const qrCodeDialogControl = useDialogControl() 139 + const shareDialogControl = useDialogControl() 140 + 141 + const shortenLink = useShortenLink() 142 + const [link, setLink] = React.useState<string>() 143 + const [imageLoaded, setImageLoaded] = React.useState(false) 144 + 145 + const onOpenShareDialog = React.useCallback(() => { 146 + const rkey = new AtUri(starterPack.uri).rkey 147 + shortenLink(makeStarterPackLink(starterPack.creator.did, rkey)).then( 148 + res => { 149 + setLink(res.url) 150 + }, 151 + ) 152 + Image.prefetch(getStarterPackOgCard(starterPack)) 153 + .then(() => { 154 + setImageLoaded(true) 155 + }) 156 + .catch(() => { 157 + setImageLoaded(true) 158 + }) 159 + shareDialogControl.open() 160 + }, [shareDialogControl, shortenLink, starterPack]) 161 + 162 + React.useEffect(() => { 163 + if (routeParams.new) { 164 + onOpenShareDialog() 165 + } 166 + }, [onOpenShareDialog, routeParams.new, shareDialogControl]) 167 + 168 + return ( 169 + <CenteredView style={[a.h_full_vh]}> 170 + <View style={isWeb ? {minHeight: '100%'} : {height: '100%'}}> 171 + <PagerWithHeader 172 + items={tabs} 173 + isHeaderReady={true} 174 + renderHeader={() => ( 175 + <Header 176 + starterPack={starterPack} 177 + routeParams={routeParams} 178 + onOpenShareDialog={onOpenShareDialog} 179 + /> 180 + )}> 181 + {starterPack.list != null 182 + ? ({headerHeight, scrollElRef}) => ( 183 + <ProfilesList 184 + key={0} 185 + // Validated above 186 + listUri={starterPack!.list!.uri} 187 + headerHeight={headerHeight} 188 + // @ts-expect-error 189 + scrollElRef={scrollElRef} 190 + listMembersQuery={listMembersQuery} 191 + moderationOpts={moderationOpts} 192 + /> 193 + ) 194 + : null} 195 + {starterPack.feeds != null 196 + ? ({headerHeight, scrollElRef}) => ( 197 + <FeedsList 198 + key={1} 199 + // @ts-expect-error ? 200 + feeds={starterPack?.feeds} 201 + headerHeight={headerHeight} 202 + // @ts-expect-error 203 + scrollElRef={scrollElRef} 204 + /> 205 + ) 206 + : null} 207 + </PagerWithHeader> 208 + </View> 209 + 210 + <QrCodeDialog 211 + control={qrCodeDialogControl} 212 + starterPack={starterPack} 213 + link={link} 214 + /> 215 + <ShareDialog 216 + control={shareDialogControl} 217 + qrDialogControl={qrCodeDialogControl} 218 + starterPack={starterPack} 219 + link={link} 220 + imageLoaded={imageLoaded} 221 + /> 222 + </CenteredView> 223 + ) 224 + } 225 + 226 + function Header({ 227 + starterPack, 228 + routeParams, 229 + onOpenShareDialog, 230 + }: { 231 + starterPack: AppBskyGraphDefs.StarterPackView 232 + routeParams: StarterPackScreeProps['route']['params'] 233 + onOpenShareDialog: () => void 234 + }) { 235 + const {_} = useLingui() 236 + const t = useTheme() 237 + const {currentAccount} = useSession() 238 + const agent = useAgent() 239 + const queryClient = useQueryClient() 240 + 241 + const [isProcessing, setIsProcessing] = React.useState(false) 242 + 243 + const {record, creator} = starterPack 244 + const isOwn = creator?.did === currentAccount?.did 245 + const joinedAllTimeCount = starterPack.joinedAllTimeCount ?? 0 246 + 247 + const onFollowAll = async () => { 248 + if (!starterPack.list) return 249 + 250 + setIsProcessing(true) 251 + 252 + try { 253 + const list = await agent.app.bsky.graph.getList({ 254 + list: starterPack.list.uri, 255 + }) 256 + const dids = list.data.items 257 + .filter(li => !li.subject.viewer?.following) 258 + .map(li => li.subject.did) 259 + 260 + await bulkWriteFollows(agent, dids) 261 + 262 + await queryClient.refetchQueries({ 263 + queryKey: RQKEY(starterPack.list.uri), 264 + }) 265 + 266 + logEvent('starterPack:followAll', { 267 + logContext: 'StarterPackProfilesList', 268 + starterPack: starterPack.uri, 269 + count: dids.length, 270 + }) 271 + Toast.show(_(msg`All accounts have been followed!`)) 272 + } catch (e) { 273 + Toast.show(_(msg`An error occurred while trying to follow all`)) 274 + } finally { 275 + setIsProcessing(false) 276 + } 277 + } 278 + 279 + if (!AppBskyGraphStarterpack.isRecord(record)) { 280 + return null 281 + } 282 + 283 + return ( 284 + <> 285 + <ProfileSubpageHeader 286 + isLoading={false} 287 + href={makeProfileLink(creator)} 288 + title={record.name} 289 + isOwner={isOwn} 290 + avatar={undefined} 291 + creator={creator} 292 + avatarType="starter-pack"> 293 + <View style={[a.flex_row, a.gap_sm, a.align_center]}> 294 + {isOwn ? ( 295 + <Button 296 + label={_(msg`Share this starter pack`)} 297 + hitSlop={HITSLOP_20} 298 + variant="solid" 299 + color="primary" 300 + size="small" 301 + onPress={onOpenShareDialog}> 302 + <ButtonText> 303 + <Trans>Share</Trans> 304 + </ButtonText> 305 + </Button> 306 + ) : ( 307 + <Button 308 + label={_(msg`Follow all`)} 309 + variant="solid" 310 + color="primary" 311 + size="small" 312 + disabled={isProcessing} 313 + onPress={onFollowAll}> 314 + <ButtonText> 315 + <Trans>Follow all</Trans> 316 + {isProcessing && <Loader size="xs" />} 317 + </ButtonText> 318 + </Button> 319 + )} 320 + <OverflowMenu 321 + routeParams={routeParams} 322 + starterPack={starterPack} 323 + onOpenShareDialog={onOpenShareDialog} 324 + /> 325 + </View> 326 + </ProfileSubpageHeader> 327 + {record.description || joinedAllTimeCount >= 25 ? ( 328 + <View style={[a.px_lg, a.pt_md, a.pb_sm, a.gap_md]}> 329 + {record.description ? ( 330 + <Text style={[a.text_md, a.leading_snug]}> 331 + {record.description} 332 + </Text> 333 + ) : null} 334 + {joinedAllTimeCount >= 25 ? ( 335 + <View style={[a.flex_row, a.align_center, a.gap_sm]}> 336 + <FontAwesomeIcon 337 + icon="arrow-trend-up" 338 + size={12} 339 + color={t.atoms.text_contrast_medium.color} 340 + /> 341 + <Text 342 + style={[a.font_bold, a.text_sm, t.atoms.text_contrast_medium]}> 343 + <Trans> 344 + {starterPack.joinedAllTimeCount || 0} people have used this 345 + starter pack! 346 + </Trans> 347 + </Text> 348 + </View> 349 + ) : null} 350 + </View> 351 + ) : null} 352 + </> 353 + ) 354 + } 355 + 356 + function OverflowMenu({ 357 + starterPack, 358 + routeParams, 359 + onOpenShareDialog, 360 + }: { 361 + starterPack: AppBskyGraphDefs.StarterPackView 362 + routeParams: StarterPackScreeProps['route']['params'] 363 + onOpenShareDialog: () => void 364 + }) { 365 + const t = useTheme() 366 + const {_} = useLingui() 367 + const {gtMobile} = useBreakpoints() 368 + const {currentAccount} = useSession() 369 + const reportDialogControl = useReportDialogControl() 370 + const deleteDialogControl = useDialogControl() 371 + const navigation = useNavigation<NavigationProp>() 372 + 373 + const { 374 + mutate: deleteStarterPack, 375 + isPending: isDeletePending, 376 + error: deleteError, 377 + } = useDeleteStarterPackMutation({ 378 + onSuccess: () => { 379 + logEvent('starterPack:delete', {}) 380 + deleteDialogControl.close(() => { 381 + if (navigation.canGoBack()) { 382 + navigation.popToTop() 383 + } else { 384 + navigation.navigate('Home') 385 + } 386 + }) 387 + }, 388 + onError: e => { 389 + logger.error('Failed to delete starter pack', {safeMessage: e}) 390 + }, 391 + }) 392 + 393 + const isOwn = starterPack.creator.did === currentAccount?.did 394 + 395 + const onDeleteStarterPack = async () => { 396 + if (!starterPack.list) { 397 + logger.error(`Unable to delete starterpack because list is missing`) 398 + return 399 + } 400 + 401 + deleteStarterPack({ 402 + rkey: routeParams.rkey, 403 + listUri: starterPack.list.uri, 404 + }) 405 + logEvent('starterPack:delete', {}) 406 + } 407 + 408 + return ( 409 + <> 410 + <Menu.Root> 411 + <Menu.Trigger label={_(msg`Repost or quote post`)}> 412 + {({props}) => ( 413 + <Button 414 + {...props} 415 + testID="headerDropdownBtn" 416 + label={_(msg`Open starter pack menu`)} 417 + hitSlop={HITSLOP_20} 418 + variant="solid" 419 + color="secondary" 420 + size="small" 421 + shape="round"> 422 + <ButtonIcon icon={Ellipsis} /> 423 + </Button> 424 + )} 425 + </Menu.Trigger> 426 + <Menu.Outer style={{minWidth: 170}}> 427 + {isOwn ? ( 428 + <> 429 + <Menu.Item 430 + label={_(msg`Edit starter pack`)} 431 + testID="editStarterPackLinkBtn" 432 + onPress={() => { 433 + navigation.navigate('StarterPackEdit', { 434 + rkey: routeParams.rkey, 435 + }) 436 + }}> 437 + <Menu.ItemText> 438 + <Trans>Edit</Trans> 439 + </Menu.ItemText> 440 + <Menu.ItemIcon icon={Pencil} position="right" /> 441 + </Menu.Item> 442 + <Menu.Item 443 + label={_(msg`Delete starter pack`)} 444 + testID="deleteStarterPackBtn" 445 + onPress={() => { 446 + deleteDialogControl.open() 447 + }}> 448 + <Menu.ItemText> 449 + <Trans>Delete</Trans> 450 + </Menu.ItemText> 451 + <Menu.ItemIcon icon={Trash} position="right" /> 452 + </Menu.Item> 453 + </> 454 + ) : ( 455 + <> 456 + <Menu.Group> 457 + <Menu.Item 458 + label={_(msg`Share`)} 459 + testID="shareStarterPackLinkBtn" 460 + onPress={onOpenShareDialog}> 461 + <Menu.ItemText> 462 + <Trans>Share link</Trans> 463 + </Menu.ItemText> 464 + <Menu.ItemIcon icon={ArrowOutOfBox} position="right" /> 465 + </Menu.Item> 466 + </Menu.Group> 467 + 468 + <Menu.Item 469 + label={_(msg`Report starter pack`)} 470 + onPress={reportDialogControl.open}> 471 + <Menu.ItemText> 472 + <Trans>Report starter pack</Trans> 473 + </Menu.ItemText> 474 + <Menu.ItemIcon icon={CircleInfo} position="right" /> 475 + </Menu.Item> 476 + </> 477 + )} 478 + </Menu.Outer> 479 + </Menu.Root> 480 + 481 + {starterPack.list && ( 482 + <ReportDialog 483 + control={reportDialogControl} 484 + params={{ 485 + type: 'starterpack', 486 + uri: starterPack.uri, 487 + cid: starterPack.cid, 488 + }} 489 + /> 490 + )} 491 + 492 + <Prompt.Outer control={deleteDialogControl}> 493 + <Prompt.TitleText> 494 + <Trans>Delete starter pack?</Trans> 495 + </Prompt.TitleText> 496 + <Prompt.DescriptionText> 497 + <Trans>Are you sure you want delete this starter pack?</Trans> 498 + </Prompt.DescriptionText> 499 + {deleteError && ( 500 + <View 501 + style={[ 502 + a.flex_row, 503 + a.gap_sm, 504 + a.rounded_sm, 505 + a.p_md, 506 + a.mb_lg, 507 + a.border, 508 + t.atoms.border_contrast_medium, 509 + t.atoms.bg_contrast_25, 510 + ]}> 511 + <View style={[a.flex_1, a.gap_2xs]}> 512 + <Text style={[a.font_bold]}> 513 + <Trans>Unable to delete</Trans> 514 + </Text> 515 + <Text style={[a.leading_snug]}>{cleanError(deleteError)}</Text> 516 + </View> 517 + <CircleInfo size="sm" fill={t.palette.negative_400} /> 518 + </View> 519 + )} 520 + <Prompt.Actions> 521 + <Button 522 + variant="solid" 523 + color="negative" 524 + size={gtMobile ? 'small' : 'medium'} 525 + label={_(msg`Yes, delete this starter pack`)} 526 + onPress={onDeleteStarterPack}> 527 + <ButtonText> 528 + <Trans>Delete</Trans> 529 + </ButtonText> 530 + {isDeletePending && <ButtonIcon icon={Loader} />} 531 + </Button> 532 + <Prompt.Cancel /> 533 + </Prompt.Actions> 534 + </Prompt.Outer> 535 + </> 536 + ) 537 + } 538 + 539 + function InvalidStarterPack({rkey}: {rkey: string}) { 540 + const {_} = useLingui() 541 + const t = useTheme() 542 + const navigation = useNavigation<NavigationProp>() 543 + const {gtMobile} = useBreakpoints() 544 + const [isProcessing, setIsProcessing] = React.useState(false) 545 + 546 + const goBack = () => { 547 + if (navigation.canGoBack()) { 548 + navigation.goBack() 549 + } else { 550 + navigation.replace('Home') 551 + } 552 + } 553 + 554 + const {mutate: deleteStarterPack} = useDeleteStarterPackMutation({ 555 + onSuccess: () => { 556 + setIsProcessing(false) 557 + goBack() 558 + }, 559 + onError: e => { 560 + setIsProcessing(false) 561 + logger.error('Failed to delete invalid starter pack', {safeMessage: e}) 562 + Toast.show(_(msg`Failed to delete starter pack`)) 563 + }, 564 + }) 565 + 566 + return ( 567 + <CenteredView 568 + style={[ 569 + a.flex_1, 570 + a.align_center, 571 + a.gap_5xl, 572 + !gtMobile && a.justify_between, 573 + t.atoms.border_contrast_low, 574 + {paddingTop: 175, paddingBottom: 110}, 575 + ]} 576 + sideBorders={true}> 577 + <View style={[a.w_full, a.align_center, a.gap_lg]}> 578 + <Text style={[a.font_bold, a.text_3xl]}> 579 + <Trans>Starter pack is invalid</Trans> 580 + </Text> 581 + <Text 582 + style={[ 583 + a.text_md, 584 + a.text_center, 585 + t.atoms.text_contrast_high, 586 + {lineHeight: 1.4}, 587 + gtMobile ? {width: 450} : [a.w_full, a.px_lg], 588 + ]}> 589 + <Trans> 590 + The starter pack that you are trying to view is invalid. You may 591 + delete this starter pack instead. 592 + </Trans> 593 + </Text> 594 + </View> 595 + <View style={[a.gap_md, gtMobile ? {width: 350} : [a.w_full, a.px_lg]]}> 596 + <Button 597 + variant="solid" 598 + color="primary" 599 + label={_(msg`Delete starter pack`)} 600 + size="large" 601 + style={[a.rounded_sm, a.overflow_hidden, {paddingVertical: 10}]} 602 + disabled={isProcessing} 603 + onPress={() => { 604 + setIsProcessing(true) 605 + deleteStarterPack({rkey}) 606 + }}> 607 + <ButtonText> 608 + <Trans>Delete</Trans> 609 + </ButtonText> 610 + {isProcessing && <Loader size="xs" color="white" />} 611 + </Button> 612 + <Button 613 + variant="solid" 614 + color="secondary" 615 + label={_(msg`Return to previous page`)} 616 + size="large" 617 + style={[a.rounded_sm, a.overflow_hidden, {paddingVertical: 10}]} 618 + disabled={isProcessing} 619 + onPress={goBack}> 620 + <ButtonText> 621 + <Trans>Go Back</Trans> 622 + </ButtonText> 623 + </Button> 624 + </View> 625 + </CenteredView> 626 + ) 627 + }
+163
src/screens/StarterPack/Wizard/State.tsx
··· 1 + import React from 'react' 2 + import { 3 + AppBskyActorDefs, 4 + AppBskyGraphDefs, 5 + AppBskyGraphStarterpack, 6 + } from '@atproto/api' 7 + import {GeneratorView} from '@atproto/api/dist/client/types/app/bsky/feed/defs' 8 + import {msg} from '@lingui/macro' 9 + 10 + import {useSession} from 'state/session' 11 + import * as Toast from '#/view/com/util/Toast' 12 + 13 + const steps = ['Details', 'Profiles', 'Feeds'] as const 14 + type Step = (typeof steps)[number] 15 + 16 + type Action = 17 + | {type: 'Next'} 18 + | {type: 'Back'} 19 + | {type: 'SetCanNext'; canNext: boolean} 20 + | {type: 'SetName'; name: string} 21 + | {type: 'SetDescription'; description: string} 22 + | {type: 'AddProfile'; profile: AppBskyActorDefs.ProfileViewBasic} 23 + | {type: 'RemoveProfile'; profileDid: string} 24 + | {type: 'AddFeed'; feed: GeneratorView} 25 + | {type: 'RemoveFeed'; feedUri: string} 26 + | {type: 'SetProcessing'; processing: boolean} 27 + | {type: 'SetError'; error: string} 28 + 29 + interface State { 30 + canNext: boolean 31 + currentStep: Step 32 + name?: string 33 + description?: string 34 + profiles: AppBskyActorDefs.ProfileViewBasic[] 35 + feeds: GeneratorView[] 36 + processing: boolean 37 + error?: string 38 + transitionDirection: 'Backward' | 'Forward' 39 + } 40 + 41 + type TStateContext = [State, (action: Action) => void] 42 + 43 + const StateContext = React.createContext<TStateContext>([ 44 + {} as State, 45 + (_: Action) => {}, 46 + ]) 47 + export const useWizardState = () => React.useContext(StateContext) 48 + 49 + function reducer(state: State, action: Action): State { 50 + let updatedState = state 51 + 52 + // -- Navigation 53 + const currentIndex = steps.indexOf(state.currentStep) 54 + if (action.type === 'Next' && state.currentStep !== 'Feeds') { 55 + updatedState = { 56 + ...state, 57 + currentStep: steps[currentIndex + 1], 58 + transitionDirection: 'Forward', 59 + } 60 + } else if (action.type === 'Back' && state.currentStep !== 'Details') { 61 + updatedState = { 62 + ...state, 63 + currentStep: steps[currentIndex - 1], 64 + transitionDirection: 'Backward', 65 + } 66 + } 67 + 68 + switch (action.type) { 69 + case 'SetName': 70 + updatedState = {...state, name: action.name.slice(0, 50)} 71 + break 72 + case 'SetDescription': 73 + updatedState = {...state, description: action.description} 74 + break 75 + case 'AddProfile': 76 + if (state.profiles.length >= 51) { 77 + Toast.show(msg`You may only add up to 50 profiles`.message ?? '') 78 + } else { 79 + updatedState = {...state, profiles: [...state.profiles, action.profile]} 80 + } 81 + break 82 + case 'RemoveProfile': 83 + updatedState = { 84 + ...state, 85 + profiles: state.profiles.filter( 86 + profile => profile.did !== action.profileDid, 87 + ), 88 + } 89 + break 90 + case 'AddFeed': 91 + if (state.feeds.length >= 50) { 92 + Toast.show(msg`You may only add up to 50 feeds`.message ?? '') 93 + } else { 94 + updatedState = {...state, feeds: [...state.feeds, action.feed]} 95 + } 96 + break 97 + case 'RemoveFeed': 98 + updatedState = { 99 + ...state, 100 + feeds: state.feeds.filter(f => f.uri !== action.feedUri), 101 + } 102 + break 103 + case 'SetProcessing': 104 + updatedState = {...state, processing: action.processing} 105 + break 106 + } 107 + 108 + return updatedState 109 + } 110 + 111 + // TODO supply the initial state to this component 112 + export function Provider({ 113 + starterPack, 114 + listItems, 115 + children, 116 + }: { 117 + starterPack?: AppBskyGraphDefs.StarterPackView 118 + listItems?: AppBskyGraphDefs.ListItemView[] 119 + children: React.ReactNode 120 + }) { 121 + const {currentAccount} = useSession() 122 + 123 + const createInitialState = (): State => { 124 + if (starterPack && AppBskyGraphStarterpack.isRecord(starterPack.record)) { 125 + return { 126 + canNext: true, 127 + currentStep: 'Details', 128 + name: starterPack.record.name, 129 + description: starterPack.record.description, 130 + profiles: 131 + listItems 132 + ?.map(i => i.subject) 133 + .filter(p => p.did !== currentAccount?.did) ?? [], 134 + feeds: starterPack.feeds ?? [], 135 + processing: false, 136 + transitionDirection: 'Forward', 137 + } 138 + } 139 + 140 + return { 141 + canNext: true, 142 + currentStep: 'Details', 143 + profiles: [], 144 + feeds: [], 145 + processing: false, 146 + transitionDirection: 'Forward', 147 + } 148 + } 149 + 150 + const [state, dispatch] = React.useReducer(reducer, null, createInitialState) 151 + 152 + return ( 153 + <StateContext.Provider value={[state, dispatch]}> 154 + {children} 155 + </StateContext.Provider> 156 + ) 157 + } 158 + 159 + export { 160 + type Action as WizardAction, 161 + type State as WizardState, 162 + type Step as WizardStep, 163 + }
+84
src/screens/StarterPack/Wizard/StepDetails.tsx
··· 1 + import React from 'react' 2 + import {View} from 'react-native' 3 + import {msg, Trans} from '@lingui/macro' 4 + import {useLingui} from '@lingui/react' 5 + 6 + import {useProfileQuery} from 'state/queries/profile' 7 + import {useSession} from 'state/session' 8 + import {useWizardState} from '#/screens/StarterPack/Wizard/State' 9 + import {atoms as a, useTheme} from '#/alf' 10 + import * as TextField from '#/components/forms/TextField' 11 + import {StarterPack} from '#/components/icons/StarterPack' 12 + import {ScreenTransition} from '#/components/StarterPack/Wizard/ScreenTransition' 13 + import {Text} from '#/components/Typography' 14 + 15 + export function StepDetails() { 16 + const {_} = useLingui() 17 + const t = useTheme() 18 + const [state, dispatch] = useWizardState() 19 + 20 + const {currentAccount} = useSession() 21 + const {data: currentProfile} = useProfileQuery({ 22 + did: currentAccount?.did, 23 + staleTime: 300, 24 + }) 25 + 26 + return ( 27 + <ScreenTransition direction={state.transitionDirection}> 28 + <View style={[a.px_xl, a.gap_xl, a.mt_4xl]}> 29 + <View style={[a.gap_md, a.align_center, a.px_md, a.mb_md]}> 30 + <StarterPack width={90} gradient="sky" /> 31 + <Text style={[a.font_bold, a.text_3xl]}> 32 + <Trans>Invites, but personal</Trans> 33 + </Text> 34 + <Text style={[a.text_center, a.text_md, a.px_md]}> 35 + <Trans> 36 + Invite your friends to follow your favorite feeds and people 37 + </Trans> 38 + </Text> 39 + </View> 40 + <View> 41 + <TextField.LabelText> 42 + <Trans>What do you want to call your starter pack?</Trans> 43 + </TextField.LabelText> 44 + <TextField.Root> 45 + <TextField.Input 46 + label={_( 47 + msg`${ 48 + currentProfile?.displayName || currentProfile?.handle 49 + }'s starter pack`, 50 + )} 51 + value={state.name} 52 + onChangeText={text => dispatch({type: 'SetName', name: text})} 53 + /> 54 + <TextField.SuffixText label={_(`${state.name?.length} out of 50`)}> 55 + <Text style={[t.atoms.text_contrast_medium]}> 56 + {state.name?.length ?? 0}/50 57 + </Text> 58 + </TextField.SuffixText> 59 + </TextField.Root> 60 + </View> 61 + <View> 62 + <TextField.LabelText> 63 + <Trans>Tell us a little more</Trans> 64 + </TextField.LabelText> 65 + <TextField.Root> 66 + <TextField.Input 67 + label={_( 68 + msg`${ 69 + currentProfile?.displayName || currentProfile?.handle 70 + }'s favorite feeds and people - join me!`, 71 + )} 72 + value={state.description} 73 + onChangeText={text => 74 + dispatch({type: 'SetDescription', description: text}) 75 + } 76 + multiline 77 + style={{minHeight: 150}} 78 + /> 79 + </TextField.Root> 80 + </View> 81 + </View> 82 + </ScreenTransition> 83 + ) 84 + }
+113
src/screens/StarterPack/Wizard/StepFeeds.tsx
··· 1 + import React, {useState} from 'react' 2 + import {ListRenderItemInfo, View} from 'react-native' 3 + import {KeyboardAwareScrollView} from 'react-native-keyboard-controller' 4 + import {AppBskyFeedDefs, ModerationOpts} from '@atproto/api' 5 + import {Trans} from '@lingui/macro' 6 + 7 + import {useA11y} from '#/state/a11y' 8 + import {DISCOVER_FEED_URI} from 'lib/constants' 9 + import { 10 + useGetPopularFeedsQuery, 11 + useSavedFeeds, 12 + useSearchPopularFeedsQuery, 13 + } from 'state/queries/feed' 14 + import {SearchInput} from 'view/com/util/forms/SearchInput' 15 + import {List} from 'view/com/util/List' 16 + import {useWizardState} from '#/screens/StarterPack/Wizard/State' 17 + import {atoms as a, useTheme} from '#/alf' 18 + import {useThrottledValue} from '#/components/hooks/useThrottledValue' 19 + import {Loader} from '#/components/Loader' 20 + import {ScreenTransition} from '#/components/StarterPack/Wizard/ScreenTransition' 21 + import {WizardFeedCard} from '#/components/StarterPack/Wizard/WizardListCard' 22 + import {Text} from '#/components/Typography' 23 + 24 + function keyExtractor(item: AppBskyFeedDefs.GeneratorView) { 25 + return item.uri 26 + } 27 + 28 + export function StepFeeds({moderationOpts}: {moderationOpts: ModerationOpts}) { 29 + const t = useTheme() 30 + const [state, dispatch] = useWizardState() 31 + const [query, setQuery] = useState('') 32 + const throttledQuery = useThrottledValue(query, 500) 33 + const {screenReaderEnabled} = useA11y() 34 + 35 + const {data: savedFeedsAndLists} = useSavedFeeds() 36 + const savedFeeds = savedFeedsAndLists?.feeds 37 + .filter(f => f.type === 'feed' && f.view.uri !== DISCOVER_FEED_URI) 38 + .map(f => f.view) as AppBskyFeedDefs.GeneratorView[] 39 + 40 + const {data: popularFeedsPages, fetchNextPage} = useGetPopularFeedsQuery({ 41 + limit: 30, 42 + }) 43 + const popularFeeds = 44 + popularFeedsPages?.pages 45 + .flatMap(page => page.feeds) 46 + .filter(f => !savedFeeds?.some(sf => sf?.uri === f.uri)) ?? [] 47 + 48 + const suggestedFeeds = savedFeeds?.concat(popularFeeds) 49 + 50 + const {data: searchedFeeds, isLoading: isLoadingSearch} = 51 + useSearchPopularFeedsQuery({q: throttledQuery}) 52 + 53 + const renderItem = ({ 54 + item, 55 + }: ListRenderItemInfo<AppBskyFeedDefs.GeneratorView>) => { 56 + return ( 57 + <WizardFeedCard 58 + generator={item} 59 + state={state} 60 + dispatch={dispatch} 61 + moderationOpts={moderationOpts} 62 + /> 63 + ) 64 + } 65 + 66 + return ( 67 + <ScreenTransition style={[a.flex_1]} direction={state.transitionDirection}> 68 + <View style={[a.border_b, t.atoms.border_contrast_medium]}> 69 + <View style={[a.my_sm, a.px_md, {height: 40}]}> 70 + <SearchInput 71 + query={query} 72 + onChangeQuery={t => setQuery(t)} 73 + onPressCancelSearch={() => setQuery('')} 74 + onSubmitQuery={() => {}} 75 + /> 76 + </View> 77 + </View> 78 + <List 79 + data={query ? searchedFeeds : suggestedFeeds} 80 + renderItem={renderItem} 81 + keyExtractor={keyExtractor} 82 + contentContainerStyle={{paddingTop: 6}} 83 + onEndReached={ 84 + !query && !screenReaderEnabled ? () => fetchNextPage() : undefined 85 + } 86 + onEndReachedThreshold={2} 87 + renderScrollComponent={props => <KeyboardAwareScrollView {...props} />} 88 + keyboardShouldPersistTaps="handled" 89 + containWeb={true} 90 + sideBorders={false} 91 + style={{flex: 1}} 92 + ListEmptyComponent={ 93 + <View style={[a.flex_1, a.align_center, a.mt_lg, a.px_lg]}> 94 + {isLoadingSearch ? ( 95 + <Loader size="lg" /> 96 + ) : ( 97 + <Text 98 + style={[ 99 + a.font_bold, 100 + a.text_lg, 101 + a.text_center, 102 + a.mt_lg, 103 + a.leading_snug, 104 + ]}> 105 + <Trans>No feeds found. Try searching for something else.</Trans> 106 + </Text> 107 + )} 108 + </View> 109 + } 110 + /> 111 + </ScreenTransition> 112 + ) 113 + }
src/screens/StarterPack/Wizard/StepFinished.tsx

This is a binary file and will not be displayed.

+101
src/screens/StarterPack/Wizard/StepProfiles.tsx
··· 1 + import React, {useState} from 'react' 2 + import {ListRenderItemInfo, View} from 'react-native' 3 + import {KeyboardAwareScrollView} from 'react-native-keyboard-controller' 4 + import {AppBskyActorDefs, ModerationOpts} from '@atproto/api' 5 + import {Trans} from '@lingui/macro' 6 + 7 + import {useA11y} from '#/state/a11y' 8 + import {isNative} from 'platform/detection' 9 + import {useActorAutocompleteQuery} from 'state/queries/actor-autocomplete' 10 + import {useActorSearchPaginated} from 'state/queries/actor-search' 11 + import {SearchInput} from 'view/com/util/forms/SearchInput' 12 + import {List} from 'view/com/util/List' 13 + import {useWizardState} from '#/screens/StarterPack/Wizard/State' 14 + import {atoms as a, useTheme} from '#/alf' 15 + import {Loader} from '#/components/Loader' 16 + import {ScreenTransition} from '#/components/StarterPack/Wizard/ScreenTransition' 17 + import {WizardProfileCard} from '#/components/StarterPack/Wizard/WizardListCard' 18 + import {Text} from '#/components/Typography' 19 + 20 + function keyExtractor(item: AppBskyActorDefs.ProfileViewBasic) { 21 + return item?.did ?? '' 22 + } 23 + 24 + export function StepProfiles({ 25 + moderationOpts, 26 + }: { 27 + moderationOpts: ModerationOpts 28 + }) { 29 + const t = useTheme() 30 + const [state, dispatch] = useWizardState() 31 + const [query, setQuery] = useState('') 32 + const {screenReaderEnabled} = useA11y() 33 + 34 + const {data: topPages, fetchNextPage} = useActorSearchPaginated({ 35 + query: encodeURIComponent('*'), 36 + }) 37 + const topFollowers = topPages?.pages.flatMap(p => p.actors) 38 + 39 + const {data: results, isLoading: isLoadingResults} = 40 + useActorAutocompleteQuery(query, true, 12) 41 + 42 + const renderItem = ({ 43 + item, 44 + }: ListRenderItemInfo<AppBskyActorDefs.ProfileViewBasic>) => { 45 + return ( 46 + <WizardProfileCard 47 + profile={item} 48 + state={state} 49 + dispatch={dispatch} 50 + moderationOpts={moderationOpts} 51 + /> 52 + ) 53 + } 54 + 55 + return ( 56 + <ScreenTransition style={[a.flex_1]} direction={state.transitionDirection}> 57 + <View style={[a.border_b, t.atoms.border_contrast_medium]}> 58 + <View style={[a.my_sm, a.px_md, {height: 40}]}> 59 + <SearchInput 60 + query={query} 61 + onChangeQuery={setQuery} 62 + onPressCancelSearch={() => setQuery('')} 63 + onSubmitQuery={() => {}} 64 + /> 65 + </View> 66 + </View> 67 + <List 68 + data={query ? results : topFollowers} 69 + renderItem={renderItem} 70 + keyExtractor={keyExtractor} 71 + renderScrollComponent={props => <KeyboardAwareScrollView {...props} />} 72 + keyboardShouldPersistTaps="handled" 73 + containWeb={true} 74 + sideBorders={false} 75 + style={[a.flex_1]} 76 + onEndReached={ 77 + !query && !screenReaderEnabled ? () => fetchNextPage() : undefined 78 + } 79 + onEndReachedThreshold={isNative ? 2 : 0.25} 80 + ListEmptyComponent={ 81 + <View style={[a.flex_1, a.align_center, a.mt_lg, a.px_lg]}> 82 + {isLoadingResults ? ( 83 + <Loader size="lg" /> 84 + ) : ( 85 + <Text 86 + style={[ 87 + a.font_bold, 88 + a.text_lg, 89 + a.text_center, 90 + a.mt_lg, 91 + a.leading_snug, 92 + ]}> 93 + <Trans>Nobody was found. Try searching for someone else.</Trans> 94 + </Text> 95 + )} 96 + </View> 97 + } 98 + /> 99 + </ScreenTransition> 100 + ) 101 + }
+575
src/screens/StarterPack/Wizard/index.tsx
··· 1 + import React from 'react' 2 + import {Keyboard, TouchableOpacity, View} from 'react-native' 3 + import { 4 + KeyboardAwareScrollView, 5 + useKeyboardController, 6 + } from 'react-native-keyboard-controller' 7 + import {useSafeAreaInsets} from 'react-native-safe-area-context' 8 + import {Image} from 'expo-image' 9 + import { 10 + AppBskyActorDefs, 11 + AppBskyGraphDefs, 12 + AtUri, 13 + ModerationOpts, 14 + } from '@atproto/api' 15 + import {GeneratorView} from '@atproto/api/dist/client/types/app/bsky/feed/defs' 16 + import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' 17 + import {msg, Plural, Trans} from '@lingui/macro' 18 + import {useLingui} from '@lingui/react' 19 + import {useFocusEffect, useNavigation} from '@react-navigation/native' 20 + import {NativeStackScreenProps} from '@react-navigation/native-stack' 21 + 22 + import {logger} from '#/logger' 23 + import {HITSLOP_10} from 'lib/constants' 24 + import {CommonNavigatorParams, NavigationProp} from 'lib/routes/types' 25 + import {logEvent} from 'lib/statsig/statsig' 26 + import {sanitizeDisplayName} from 'lib/strings/display-names' 27 + import {sanitizeHandle} from 'lib/strings/handles' 28 + import {enforceLen} from 'lib/strings/helpers' 29 + import { 30 + getStarterPackOgCard, 31 + parseStarterPackUri, 32 + } from 'lib/strings/starter-pack' 33 + import {isAndroid, isNative, isWeb} from 'platform/detection' 34 + import {useModerationOpts} from 'state/preferences/moderation-opts' 35 + import {useListMembersQuery} from 'state/queries/list-members' 36 + import {useProfileQuery} from 'state/queries/profile' 37 + import { 38 + useCreateStarterPackMutation, 39 + useEditStarterPackMutation, 40 + useStarterPackQuery, 41 + } from 'state/queries/starter-packs' 42 + import {useSession} from 'state/session' 43 + import {useSetMinimalShellMode} from 'state/shell' 44 + import * as Toast from '#/view/com/util/Toast' 45 + import {UserAvatar} from 'view/com/util/UserAvatar' 46 + import {CenteredView} from 'view/com/util/Views' 47 + import {useWizardState, WizardStep} from '#/screens/StarterPack/Wizard/State' 48 + import {StepDetails} from '#/screens/StarterPack/Wizard/StepDetails' 49 + import {StepFeeds} from '#/screens/StarterPack/Wizard/StepFeeds' 50 + import {StepProfiles} from '#/screens/StarterPack/Wizard/StepProfiles' 51 + import {atoms as a, useTheme} from '#/alf' 52 + import {Button, ButtonText} from '#/components/Button' 53 + import {useDialogControl} from '#/components/Dialog' 54 + import {ListMaybePlaceholder} from '#/components/Lists' 55 + import {Loader} from '#/components/Loader' 56 + import {WizardEditListDialog} from '#/components/StarterPack/Wizard/WizardEditListDialog' 57 + import {Text} from '#/components/Typography' 58 + import {Provider} from './State' 59 + 60 + export function Wizard({ 61 + route, 62 + }: NativeStackScreenProps< 63 + CommonNavigatorParams, 64 + 'StarterPackEdit' | 'StarterPackWizard' 65 + >) { 66 + const {rkey} = route.params ?? {} 67 + const {currentAccount} = useSession() 68 + const moderationOpts = useModerationOpts() 69 + 70 + const {_} = useLingui() 71 + 72 + const { 73 + data: starterPack, 74 + isLoading: isLoadingStarterPack, 75 + isError: isErrorStarterPack, 76 + } = useStarterPackQuery({did: currentAccount!.did, rkey}) 77 + const listUri = starterPack?.list?.uri 78 + 79 + const { 80 + data: profilesData, 81 + isLoading: isLoadingProfiles, 82 + isError: isErrorProfiles, 83 + } = useListMembersQuery(listUri, 50) 84 + const listItems = profilesData?.pages.flatMap(p => p.items) 85 + 86 + const { 87 + data: profile, 88 + isLoading: isLoadingProfile, 89 + isError: isErrorProfile, 90 + } = useProfileQuery({did: currentAccount?.did}) 91 + 92 + const isEdit = Boolean(rkey) 93 + const isReady = 94 + (!isEdit || (isEdit && starterPack && listItems)) && 95 + profile && 96 + moderationOpts 97 + 98 + if (!isReady) { 99 + return ( 100 + <ListMaybePlaceholder 101 + isLoading={ 102 + isLoadingStarterPack || isLoadingProfiles || isLoadingProfile 103 + } 104 + isError={isErrorStarterPack || isErrorProfiles || isErrorProfile} 105 + errorMessage={_(msg`That starter pack could not be found.`)} 106 + /> 107 + ) 108 + } else if (isEdit && starterPack?.creator.did !== currentAccount?.did) { 109 + return ( 110 + <ListMaybePlaceholder 111 + isLoading={false} 112 + isError={true} 113 + errorMessage={_(msg`That starter pack could not be found.`)} 114 + /> 115 + ) 116 + } 117 + 118 + return ( 119 + <Provider starterPack={starterPack} listItems={listItems}> 120 + <WizardInner 121 + currentStarterPack={starterPack} 122 + currentListItems={listItems} 123 + profile={profile} 124 + moderationOpts={moderationOpts} 125 + /> 126 + </Provider> 127 + ) 128 + } 129 + 130 + function WizardInner({ 131 + currentStarterPack, 132 + currentListItems, 133 + profile, 134 + moderationOpts, 135 + }: { 136 + currentStarterPack?: AppBskyGraphDefs.StarterPackView 137 + currentListItems?: AppBskyGraphDefs.ListItemView[] 138 + profile: AppBskyActorDefs.ProfileViewBasic 139 + moderationOpts: ModerationOpts 140 + }) { 141 + const navigation = useNavigation<NavigationProp>() 142 + const {_} = useLingui() 143 + const t = useTheme() 144 + const setMinimalShellMode = useSetMinimalShellMode() 145 + const {setEnabled} = useKeyboardController() 146 + const [state, dispatch] = useWizardState() 147 + const {currentAccount} = useSession() 148 + const {data: currentProfile} = useProfileQuery({ 149 + did: currentAccount?.did, 150 + staleTime: 0, 151 + }) 152 + const parsed = parseStarterPackUri(currentStarterPack?.uri) 153 + 154 + React.useEffect(() => { 155 + navigation.setOptions({ 156 + gestureEnabled: false, 157 + }) 158 + }, [navigation]) 159 + 160 + useFocusEffect( 161 + React.useCallback(() => { 162 + setEnabled(true) 163 + setMinimalShellMode(true) 164 + 165 + return () => { 166 + setMinimalShellMode(false) 167 + setEnabled(false) 168 + } 169 + }, [setMinimalShellMode, setEnabled]), 170 + ) 171 + 172 + const getDefaultName = () => { 173 + let displayName 174 + if ( 175 + currentProfile?.displayName != null && 176 + currentProfile?.displayName !== '' 177 + ) { 178 + displayName = sanitizeDisplayName(currentProfile.displayName) 179 + } else { 180 + displayName = sanitizeHandle(currentProfile!.handle) 181 + } 182 + return _(msg`${displayName}'s Starter Pack`).slice(0, 50) 183 + } 184 + 185 + const wizardUiStrings: Record< 186 + WizardStep, 187 + {header: string; nextBtn: string; subtitle?: string} 188 + > = { 189 + Details: { 190 + header: _(msg`Starter Pack`), 191 + nextBtn: _(msg`Next`), 192 + }, 193 + Profiles: { 194 + header: _(msg`People`), 195 + nextBtn: _(msg`Next`), 196 + subtitle: _( 197 + msg`Add people to your starter pack that you think others will enjoy following`, 198 + ), 199 + }, 200 + Feeds: { 201 + header: _(msg`Feeds`), 202 + nextBtn: state.feeds.length === 0 ? _(msg`Skip`) : _(msg`Finish`), 203 + subtitle: _(msg`Some subtitle`), 204 + }, 205 + } 206 + const currUiStrings = wizardUiStrings[state.currentStep] 207 + 208 + const onSuccessCreate = (data: {uri: string; cid: string}) => { 209 + const rkey = new AtUri(data.uri).rkey 210 + logEvent('starterPack:create', { 211 + setName: state.name != null, 212 + setDescription: state.description != null, 213 + profilesCount: state.profiles.length, 214 + feedsCount: state.feeds.length, 215 + }) 216 + Image.prefetch([getStarterPackOgCard(currentProfile!.did, rkey)]) 217 + dispatch({type: 'SetProcessing', processing: false}) 218 + navigation.replace('StarterPack', { 219 + name: currentAccount!.handle, 220 + rkey, 221 + new: true, 222 + }) 223 + } 224 + 225 + const onSuccessEdit = () => { 226 + if (navigation.canGoBack()) { 227 + navigation.goBack() 228 + } else { 229 + navigation.replace('StarterPack', { 230 + name: currentAccount!.handle, 231 + rkey: parsed!.rkey, 232 + }) 233 + } 234 + } 235 + 236 + const {mutate: createStarterPack} = useCreateStarterPackMutation({ 237 + onSuccess: onSuccessCreate, 238 + onError: e => { 239 + logger.error('Failed to create starter pack', {safeMessage: e}) 240 + dispatch({type: 'SetProcessing', processing: false}) 241 + Toast.show(_(msg`Failed to create starter pack`)) 242 + }, 243 + }) 244 + const {mutate: editStarterPack} = useEditStarterPackMutation({ 245 + onSuccess: onSuccessEdit, 246 + onError: e => { 247 + logger.error('Failed to edit starter pack', {safeMessage: e}) 248 + dispatch({type: 'SetProcessing', processing: false}) 249 + Toast.show(_(msg`Failed to create starter pack`)) 250 + }, 251 + }) 252 + 253 + const submit = async () => { 254 + dispatch({type: 'SetProcessing', processing: true}) 255 + if (currentStarterPack && currentListItems) { 256 + editStarterPack({ 257 + name: state.name ?? getDefaultName(), 258 + description: state.description, 259 + descriptionFacets: [], 260 + profiles: state.profiles, 261 + feeds: state.feeds, 262 + currentStarterPack: currentStarterPack, 263 + currentListItems: currentListItems, 264 + }) 265 + } else { 266 + createStarterPack({ 267 + name: state.name ?? getDefaultName(), 268 + description: state.description, 269 + descriptionFacets: [], 270 + profiles: state.profiles, 271 + feeds: state.feeds, 272 + }) 273 + } 274 + } 275 + 276 + const onNext = () => { 277 + if (state.currentStep === 'Feeds') { 278 + submit() 279 + return 280 + } 281 + 282 + const keyboardVisible = Keyboard.isVisible() 283 + Keyboard.dismiss() 284 + setTimeout( 285 + () => { 286 + dispatch({type: 'Next'}) 287 + }, 288 + keyboardVisible ? 16 : 0, 289 + ) 290 + } 291 + 292 + return ( 293 + <CenteredView style={[a.flex_1]} sideBorders> 294 + <View 295 + style={[ 296 + a.flex_row, 297 + a.pb_sm, 298 + a.px_md, 299 + a.border_b, 300 + t.atoms.border_contrast_medium, 301 + a.gap_sm, 302 + a.justify_between, 303 + a.align_center, 304 + isAndroid && a.pt_sm, 305 + isWeb && [a.py_md], 306 + ]}> 307 + <View style={[{width: 65}]}> 308 + <TouchableOpacity 309 + testID="viewHeaderDrawerBtn" 310 + hitSlop={HITSLOP_10} 311 + accessibilityRole="button" 312 + accessibilityLabel={_(msg`Back`)} 313 + accessibilityHint={_(msg`Go back to the previous step`)} 314 + onPress={() => { 315 + if (state.currentStep === 'Details') { 316 + navigation.pop() 317 + } else { 318 + dispatch({type: 'Back'}) 319 + } 320 + }}> 321 + <FontAwesomeIcon 322 + size={18} 323 + icon="angle-left" 324 + color={t.atoms.text.color} 325 + /> 326 + </TouchableOpacity> 327 + </View> 328 + <Text style={[a.flex_1, a.font_bold, a.text_lg, a.text_center]}> 329 + {currUiStrings.header} 330 + </Text> 331 + <View style={[{width: 65}]} /> 332 + </View> 333 + 334 + <Container> 335 + {state.currentStep === 'Details' ? ( 336 + <StepDetails /> 337 + ) : state.currentStep === 'Profiles' ? ( 338 + <StepProfiles moderationOpts={moderationOpts} /> 339 + ) : state.currentStep === 'Feeds' ? ( 340 + <StepFeeds moderationOpts={moderationOpts} /> 341 + ) : null} 342 + </Container> 343 + 344 + {state.currentStep !== 'Details' && ( 345 + <Footer 346 + onNext={onNext} 347 + nextBtnText={currUiStrings.nextBtn} 348 + moderationOpts={moderationOpts} 349 + profile={profile} 350 + /> 351 + )} 352 + </CenteredView> 353 + ) 354 + } 355 + 356 + function Container({children}: {children: React.ReactNode}) { 357 + const {_} = useLingui() 358 + const [state, dispatch] = useWizardState() 359 + 360 + if (state.currentStep === 'Profiles' || state.currentStep === 'Feeds') { 361 + return <View style={[a.flex_1]}>{children}</View> 362 + } 363 + 364 + return ( 365 + <KeyboardAwareScrollView 366 + style={[a.flex_1]} 367 + keyboardShouldPersistTaps="handled"> 368 + {children} 369 + {state.currentStep === 'Details' && ( 370 + <> 371 + <Button 372 + label={_(msg`Next`)} 373 + variant="solid" 374 + color="primary" 375 + size="medium" 376 + style={[a.mx_xl, a.mb_lg, {marginTop: 35}]} 377 + onPress={() => dispatch({type: 'Next'})}> 378 + <ButtonText> 379 + <Trans>Next</Trans> 380 + </ButtonText> 381 + </Button> 382 + </> 383 + )} 384 + </KeyboardAwareScrollView> 385 + ) 386 + } 387 + 388 + function Footer({ 389 + onNext, 390 + nextBtnText, 391 + moderationOpts, 392 + profile, 393 + }: { 394 + onNext: () => void 395 + nextBtnText: string 396 + moderationOpts: ModerationOpts 397 + profile: AppBskyActorDefs.ProfileViewBasic 398 + }) { 399 + const {_} = useLingui() 400 + const t = useTheme() 401 + const [state, dispatch] = useWizardState() 402 + const editDialogControl = useDialogControl() 403 + const {bottom: bottomInset} = useSafeAreaInsets() 404 + 405 + const items = 406 + state.currentStep === 'Profiles' 407 + ? [profile, ...state.profiles] 408 + : state.feeds 409 + const initialNamesIndex = state.currentStep === 'Profiles' ? 1 : 0 410 + 411 + const isEditEnabled = 412 + (state.currentStep === 'Profiles' && items.length > 1) || 413 + (state.currentStep === 'Feeds' && items.length > 0) 414 + 415 + const minimumItems = state.currentStep === 'Profiles' ? 8 : 0 416 + 417 + const textStyles = [a.text_md] 418 + 419 + return ( 420 + <View 421 + style={[ 422 + a.border_t, 423 + a.align_center, 424 + a.px_lg, 425 + a.pt_xl, 426 + a.gap_md, 427 + t.atoms.bg, 428 + t.atoms.border_contrast_medium, 429 + { 430 + paddingBottom: a.pb_lg.paddingBottom + bottomInset, 431 + }, 432 + isNative && [ 433 + a.border_l, 434 + a.border_r, 435 + t.atoms.shadow_md, 436 + { 437 + borderTopLeftRadius: 14, 438 + borderTopRightRadius: 14, 439 + }, 440 + ], 441 + ]}> 442 + {items.length > minimumItems && ( 443 + <View style={[a.absolute, {right: 14, top: 31}]}> 444 + <Text style={[a.font_bold]}> 445 + {items.length}/{state.currentStep === 'Profiles' ? 50 : 3} 446 + </Text> 447 + </View> 448 + )} 449 + 450 + <View style={[a.flex_row, a.gap_xs]}> 451 + {items.slice(0, 6).map((p, index) => ( 452 + <UserAvatar 453 + key={index} 454 + avatar={p.avatar} 455 + size={32} 456 + type={state.currentStep === 'Profiles' ? 'user' : 'algo'} 457 + /> 458 + ))} 459 + </View> 460 + 461 + {items.length === 0 ? ( 462 + <View style={[a.gap_sm]}> 463 + <Text style={[a.font_bold, a.text_center, textStyles]}> 464 + <Trans>Add some feeds to your starter pack!</Trans> 465 + </Text> 466 + <Text style={[a.text_center, textStyles]}> 467 + <Trans>Search for feeds that you want to suggest to others.</Trans> 468 + </Text> 469 + </View> 470 + ) : ( 471 + <Text style={[a.text_center, textStyles]}> 472 + {state.currentStep === 'Profiles' && items.length === 1 ? ( 473 + <Trans> 474 + It's just you right now! Add more people to your starter pack by 475 + searching above. 476 + </Trans> 477 + ) : items.length === 1 ? ( 478 + <Trans> 479 + <Text style={[a.font_bold, textStyles]}> 480 + {getName(items[initialNamesIndex])} 481 + </Text>{' '} 482 + is included in your starter pack 483 + </Trans> 484 + ) : items.length === 2 ? ( 485 + <Trans> 486 + <Text style={[a.font_bold, textStyles]}> 487 + {getName(items[initialNamesIndex])}{' '} 488 + </Text> 489 + and 490 + <Text> </Text> 491 + <Text style={[a.font_bold, textStyles]}> 492 + {getName(items[state.currentStep === 'Profiles' ? 0 : 1])}{' '} 493 + </Text> 494 + are included in your starter pack 495 + </Trans> 496 + ) : ( 497 + <Trans> 498 + <Text style={[a.font_bold, textStyles]}> 499 + {getName(items[initialNamesIndex])},{' '} 500 + </Text> 501 + <Text style={[a.font_bold, textStyles]}> 502 + {getName(items[initialNamesIndex + 1])},{' '} 503 + </Text> 504 + and {items.length - 2}{' '} 505 + <Plural value={items.length - 2} one="other" other="others" /> are 506 + included in your starter pack 507 + </Trans> 508 + )} 509 + </Text> 510 + )} 511 + 512 + <View 513 + style={[ 514 + a.flex_row, 515 + a.w_full, 516 + a.justify_between, 517 + a.align_center, 518 + isNative ? a.mt_sm : a.mt_md, 519 + ]}> 520 + {isEditEnabled ? ( 521 + <Button 522 + label={_(msg`Edit`)} 523 + variant="solid" 524 + color="secondary" 525 + size="small" 526 + style={{width: 70}} 527 + onPress={editDialogControl.open}> 528 + <ButtonText> 529 + <Trans>Edit</Trans> 530 + </ButtonText> 531 + </Button> 532 + ) : ( 533 + <View style={{width: 70, height: 35}} /> 534 + )} 535 + {state.currentStep === 'Profiles' && items.length < 8 ? ( 536 + <> 537 + <Text 538 + style={[a.font_bold, textStyles, t.atoms.text_contrast_medium]}> 539 + <Trans>Add {8 - items.length} more to continue</Trans> 540 + </Text> 541 + <View style={{width: 70}} /> 542 + </> 543 + ) : ( 544 + <Button 545 + label={nextBtnText} 546 + variant="solid" 547 + color="primary" 548 + size="small" 549 + onPress={onNext} 550 + disabled={!state.canNext || state.processing}> 551 + <ButtonText>{nextBtnText}</ButtonText> 552 + {state.processing && <Loader size="xs" style={{color: 'white'}} />} 553 + </Button> 554 + )} 555 + </View> 556 + 557 + <WizardEditListDialog 558 + control={editDialogControl} 559 + state={state} 560 + dispatch={dispatch} 561 + moderationOpts={moderationOpts} 562 + profile={profile} 563 + /> 564 + </View> 565 + ) 566 + } 567 + 568 + function getName(item: AppBskyActorDefs.ProfileViewBasic | GeneratorView) { 569 + if (typeof item.displayName === 'string') { 570 + return enforceLen(sanitizeDisplayName(item.displayName), 16, true) 571 + } else if (typeof item.handle === 'string') { 572 + return enforceLen(sanitizeHandle(item.handle), 16, true) 573 + } 574 + return '' 575 + }
+2
src/state/persisted/schema.ts
··· 88 88 disableHaptics: z.boolean().optional(), 89 89 disableAutoplay: z.boolean().optional(), 90 90 kawaii: z.boolean().optional(), 91 + hasCheckedForStarterPack: z.boolean().optional(), 91 92 /** @deprecated */ 92 93 mutedThreads: z.array(z.string()), 93 94 }) ··· 129 130 disableHaptics: false, 130 131 disableAutoplay: prefersReducedMotion, 131 132 kawaii: false, 133 + hasCheckedForStarterPack: false, 132 134 }
+4 -1
src/state/preferences/index.tsx
··· 9 9 import {Provider as KawaiiProvider} from './kawaii' 10 10 import {Provider as LanguagesProvider} from './languages' 11 11 import {Provider as LargeAltBadgeProvider} from './large-alt-badge' 12 + import {Provider as UsedStarterPacksProvider} from './used-starter-packs' 12 13 13 14 export { 14 15 useRequireAltTextEnabled, ··· 34 35 <InAppBrowserProvider> 35 36 <DisableHapticsProvider> 36 37 <AutoplayProvider> 37 - <KawaiiProvider>{children}</KawaiiProvider> 38 + <UsedStarterPacksProvider> 39 + <KawaiiProvider>{children}</KawaiiProvider> 40 + </UsedStarterPacksProvider> 38 41 </AutoplayProvider> 39 42 </DisableHapticsProvider> 40 43 </InAppBrowserProvider>
+37
src/state/preferences/used-starter-packs.tsx
··· 1 + import React from 'react' 2 + 3 + import * as persisted from '#/state/persisted' 4 + 5 + type StateContext = boolean | undefined 6 + type SetContext = (v: boolean) => void 7 + 8 + const stateContext = React.createContext<StateContext>(false) 9 + const setContext = React.createContext<SetContext>((_: boolean) => {}) 10 + 11 + export function Provider({children}: {children: React.ReactNode}) { 12 + const [state, setState] = React.useState<StateContext>(() => 13 + persisted.get('hasCheckedForStarterPack'), 14 + ) 15 + 16 + const setStateWrapped = (v: boolean) => { 17 + setState(v) 18 + persisted.write('hasCheckedForStarterPack', v) 19 + } 20 + 21 + React.useEffect(() => { 22 + return persisted.onUpdate(() => { 23 + setState(persisted.get('hasCheckedForStarterPack')) 24 + }) 25 + }, []) 26 + 27 + return ( 28 + <stateContext.Provider value={state}> 29 + <setContext.Provider value={setStateWrapped}> 30 + {children} 31 + </setContext.Provider> 32 + </stateContext.Provider> 33 + ) 34 + } 35 + 36 + export const useHasCheckedForStarterPack = () => React.useContext(stateContext) 37 + export const useSetHasCheckedForStarterPack = () => React.useContext(setContext)
+44 -2
src/state/queries/actor-search.ts
··· 1 - import {AppBskyActorDefs} from '@atproto/api' 2 - import {QueryClient, useQuery} from '@tanstack/react-query' 1 + import {AppBskyActorDefs, AppBskyActorSearchActors} from '@atproto/api' 2 + import { 3 + InfiniteData, 4 + QueryClient, 5 + QueryKey, 6 + useInfiniteQuery, 7 + useQuery, 8 + } from '@tanstack/react-query' 3 9 4 10 import {STALE} from '#/state/queries' 5 11 import {useAgent} from '#/state/session' ··· 7 13 const RQKEY_ROOT = 'actor-search' 8 14 export const RQKEY = (query: string) => [RQKEY_ROOT, query] 9 15 16 + export const RQKEY_PAGINATED = (query: string) => [ 17 + `${RQKEY_ROOT}_paginated`, 18 + query, 19 + ] 20 + 10 21 export function useActorSearch({ 11 22 query, 12 23 enabled, ··· 25 36 return res.data.actors 26 37 }, 27 38 enabled: enabled && !!query, 39 + }) 40 + } 41 + 42 + export function useActorSearchPaginated({ 43 + query, 44 + enabled, 45 + }: { 46 + query: string 47 + enabled?: boolean 48 + }) { 49 + const agent = useAgent() 50 + return useInfiniteQuery< 51 + AppBskyActorSearchActors.OutputSchema, 52 + Error, 53 + InfiniteData<AppBskyActorSearchActors.OutputSchema>, 54 + QueryKey, 55 + string | undefined 56 + >({ 57 + staleTime: STALE.MINUTES.FIVE, 58 + queryKey: RQKEY_PAGINATED(query), 59 + queryFn: async ({pageParam}) => { 60 + const res = await agent.searchActors({ 61 + q: query, 62 + limit: 25, 63 + cursor: pageParam, 64 + }) 65 + return res.data 66 + }, 67 + enabled: enabled && !!query, 68 + initialPageParam: undefined, 69 + getNextPageParam: lastPage => lastPage.cursor, 28 70 }) 29 71 } 30 72
+47
src/state/queries/actor-starter-packs.ts
··· 1 + import {AppBskyGraphGetActorStarterPacks} from '@atproto/api' 2 + import { 3 + InfiniteData, 4 + QueryClient, 5 + QueryKey, 6 + useInfiniteQuery, 7 + } from '@tanstack/react-query' 8 + 9 + import {useAgent} from 'state/session' 10 + 11 + const RQKEY_ROOT = 'actor-starter-packs' 12 + export const RQKEY = (did?: string) => [RQKEY_ROOT, did] 13 + 14 + export function useActorStarterPacksQuery({did}: {did?: string}) { 15 + const agent = useAgent() 16 + 17 + return useInfiniteQuery< 18 + AppBskyGraphGetActorStarterPacks.OutputSchema, 19 + Error, 20 + InfiniteData<AppBskyGraphGetActorStarterPacks.OutputSchema>, 21 + QueryKey, 22 + string | undefined 23 + >({ 24 + queryKey: RQKEY(did), 25 + queryFn: async ({pageParam}: {pageParam?: string}) => { 26 + const res = await agent.app.bsky.graph.getActorStarterPacks({ 27 + actor: did!, 28 + limit: 10, 29 + cursor: pageParam, 30 + }) 31 + return res.data 32 + }, 33 + enabled: Boolean(did), 34 + initialPageParam: undefined, 35 + getNextPageParam: lastPage => lastPage.cursor, 36 + }) 37 + } 38 + 39 + export async function invalidateActorStarterPacksQuery({ 40 + queryClient, 41 + did, 42 + }: { 43 + queryClient: QueryClient 44 + did: string 45 + }) { 46 + await queryClient.invalidateQueries({queryKey: RQKEY(did)}) 47 + }
+17
src/state/queries/feed.ts
··· 9 9 } from '@atproto/api' 10 10 import { 11 11 InfiniteData, 12 + keepPreviousData, 12 13 QueryClient, 13 14 QueryKey, 14 15 useInfiniteQuery, ··· 312 313 313 314 return res.data.feeds 314 315 }, 316 + }) 317 + } 318 + 319 + export function useSearchPopularFeedsQuery({q}: {q: string}) { 320 + const agent = useAgent() 321 + return useQuery({ 322 + queryKey: ['searchPopularFeeds', q], 323 + queryFn: async () => { 324 + const res = await agent.app.bsky.unspecced.getPopularFeedGenerators({ 325 + limit: 15, 326 + query: q, 327 + }) 328 + 329 + return res.data.feeds 330 + }, 331 + placeholderData: keepPreviousData, 315 332 }) 316 333 } 317 334
+15 -4
src/state/queries/list-members.ts
··· 15 15 const RQKEY_ROOT = 'list-members' 16 16 export const RQKEY = (uri: string) => [RQKEY_ROOT, uri] 17 17 18 - export function useListMembersQuery(uri: string) { 18 + export function useListMembersQuery(uri?: string, limit: number = PAGE_SIZE) { 19 19 const agent = useAgent() 20 20 return useInfiniteQuery< 21 21 AppBskyGraphGetList.OutputSchema, ··· 25 25 RQPageParam 26 26 >({ 27 27 staleTime: STALE.MINUTES.ONE, 28 - queryKey: RQKEY(uri), 28 + queryKey: RQKEY(uri ?? ''), 29 29 async queryFn({pageParam}: {pageParam: RQPageParam}) { 30 30 const res = await agent.app.bsky.graph.getList({ 31 - list: uri, 32 - limit: PAGE_SIZE, 31 + list: uri!, // the enabled flag will prevent this from running until uri is set 32 + limit, 33 33 cursor: pageParam, 34 34 }) 35 35 return res.data 36 36 }, 37 37 initialPageParam: undefined, 38 38 getNextPageParam: lastPage => lastPage.cursor, 39 + enabled: Boolean(uri), 39 40 }) 41 + } 42 + 43 + export async function invalidateListMembersQuery({ 44 + queryClient, 45 + uri, 46 + }: { 47 + queryClient: QueryClient 48 + uri: string 49 + }) { 50 + await queryClient.invalidateQueries({queryKey: RQKEY(uri)}) 40 51 } 41 52 42 53 export function* findAllProfilesInQueryData(
+8 -3
src/state/queries/notifications/feed.ts
··· 155 155 156 156 for (const page of queryData?.pages) { 157 157 for (const item of page.items) { 158 - if (item.subject && didOrHandleUriMatches(atUri, item.subject)) { 159 - yield item.subject 158 + if (item.type !== 'starterpack-joined') { 159 + if (item.subject && didOrHandleUriMatches(atUri, item.subject)) { 160 + yield item.subject 161 + } 160 162 } 161 163 162 164 const quotedPost = getEmbeddedPost(item.subject?.embed) ··· 181 183 } 182 184 for (const page of queryData?.pages) { 183 185 for (const item of page.items) { 184 - if (item.subject?.author.did === did) { 186 + if ( 187 + item.type !== 'starterpack-joined' && 188 + item.subject?.author.did === did 189 + ) { 185 190 yield item.subject.author 186 191 } 187 192 const quotedPost = getEmbeddedPost(item.subject?.embed)
+32 -17
src/state/queries/notifications/types.ts
··· 1 1 import { 2 - AppBskyNotificationListNotifications, 3 2 AppBskyFeedDefs, 3 + AppBskyGraphDefs, 4 + AppBskyNotificationListNotifications, 4 5 } from '@atproto/api' 5 6 6 7 export type NotificationType = 7 - | 'post-like' 8 - | 'feedgen-like' 9 - | 'repost' 10 - | 'mention' 11 - | 'reply' 12 - | 'quote' 13 - | 'follow' 14 - | 'unknown' 8 + | StarterPackNotificationType 9 + | OtherNotificationType 15 10 16 - export interface FeedNotification { 17 - _reactKey: string 18 - type: NotificationType 19 - notification: AppBskyNotificationListNotifications.Notification 20 - additional?: AppBskyNotificationListNotifications.Notification[] 21 - subjectUri?: string 22 - subject?: AppBskyFeedDefs.PostView 23 - } 11 + export type FeedNotification = 12 + | (FeedNotificationBase & { 13 + type: StarterPackNotificationType 14 + subject?: AppBskyGraphDefs.StarterPackViewBasic 15 + }) 16 + | (FeedNotificationBase & { 17 + type: OtherNotificationType 18 + subject?: AppBskyFeedDefs.PostView 19 + }) 24 20 25 21 export interface FeedPage { 26 22 cursor: string | undefined ··· 37 33 data: FeedPage | undefined 38 34 unreadCount: number 39 35 } 36 + 37 + type StarterPackNotificationType = 'starterpack-joined' 38 + type OtherNotificationType = 39 + | 'post-like' 40 + | 'repost' 41 + | 'mention' 42 + | 'reply' 43 + | 'quote' 44 + | 'follow' 45 + | 'feedgen-like' 46 + | 'unknown' 47 + 48 + type FeedNotificationBase = { 49 + _reactKey: string 50 + notification: AppBskyNotificationListNotifications.Notification 51 + additional?: AppBskyNotificationListNotifications.Notification[] 52 + subjectUri?: string 53 + subject?: AppBskyFeedDefs.PostView | AppBskyGraphDefs.StarterPackViewBasic 54 + }
+65 -18
src/state/queries/notifications/util.ts
··· 3 3 AppBskyFeedLike, 4 4 AppBskyFeedPost, 5 5 AppBskyFeedRepost, 6 + AppBskyGraphDefs, 7 + AppBskyGraphStarterpack, 6 8 AppBskyNotificationListNotifications, 7 9 BskyAgent, 8 10 moderateNotification, ··· 40 42 limit, 41 43 cursor, 42 44 }) 45 + 43 46 const indexedAt = res.data.notifications[0]?.indexedAt 44 47 45 48 // filter out notifs by mod rules ··· 56 59 const subjects = await fetchSubjects(agent, notifsGrouped) 57 60 for (const notif of notifsGrouped) { 58 61 if (notif.subjectUri) { 59 - notif.subject = subjects.get(notif.subjectUri) 60 - if (notif.subject) { 61 - precacheProfile(queryClient, notif.subject.author) 62 + if ( 63 + notif.type === 'starterpack-joined' && 64 + notif.notification.reasonSubject 65 + ) { 66 + notif.subject = subjects.starterPacks.get( 67 + notif.notification.reasonSubject, 68 + ) 69 + } else { 70 + notif.subject = subjects.posts.get(notif.subjectUri) 71 + if (notif.subject) { 72 + precacheProfile(queryClient, notif.subject.author) 73 + } 62 74 } 63 75 } 64 76 } ··· 120 132 } 121 133 if (!grouped) { 122 134 const type = toKnownType(notif) 123 - groupedNotifs.push({ 124 - _reactKey: `notif-${notif.uri}`, 125 - type, 126 - notification: notif, 127 - subjectUri: getSubjectUri(type, notif), 128 - }) 135 + if (type !== 'starterpack-joined') { 136 + groupedNotifs.push({ 137 + _reactKey: `notif-${notif.uri}`, 138 + type, 139 + notification: notif, 140 + subjectUri: getSubjectUri(type, notif), 141 + }) 142 + } else { 143 + groupedNotifs.push({ 144 + _reactKey: `notif-${notif.uri}`, 145 + type: 'starterpack-joined', 146 + notification: notif, 147 + subjectUri: notif.uri, 148 + }) 149 + } 129 150 } 130 151 } 131 152 return groupedNotifs ··· 134 155 async function fetchSubjects( 135 156 agent: BskyAgent, 136 157 groupedNotifs: FeedNotification[], 137 - ): Promise<Map<string, AppBskyFeedDefs.PostView>> { 138 - const uris = new Set<string>() 158 + ): Promise<{ 159 + posts: Map<string, AppBskyFeedDefs.PostView> 160 + starterPacks: Map<string, AppBskyGraphDefs.StarterPackViewBasic> 161 + }> { 162 + const postUris = new Set<string>() 163 + const packUris = new Set<string>() 139 164 for (const notif of groupedNotifs) { 140 165 if (notif.subjectUri?.includes('app.bsky.feed.post')) { 141 - uris.add(notif.subjectUri) 166 + postUris.add(notif.subjectUri) 167 + } else if ( 168 + notif.notification.reasonSubject?.includes('app.bsky.graph.starterpack') 169 + ) { 170 + packUris.add(notif.notification.reasonSubject) 142 171 } 143 172 } 144 - const uriChunks = chunk(Array.from(uris), 25) 173 + const postUriChunks = chunk(Array.from(postUris), 25) 174 + const packUriChunks = chunk(Array.from(packUris), 25) 145 175 const postsChunks = await Promise.all( 146 - uriChunks.map(uris => 176 + postUriChunks.map(uris => 147 177 agent.app.bsky.feed.getPosts({uris}).then(res => res.data.posts), 148 178 ), 149 179 ) 150 - const map = new Map<string, AppBskyFeedDefs.PostView>() 180 + const packsChunks = await Promise.all( 181 + packUriChunks.map(uris => 182 + agent.app.bsky.graph 183 + .getStarterPacks({uris}) 184 + .then(res => res.data.starterPacks), 185 + ), 186 + ) 187 + const postsMap = new Map<string, AppBskyFeedDefs.PostView>() 188 + const packsMap = new Map<string, AppBskyGraphDefs.StarterPackView>() 151 189 for (const post of postsChunks.flat()) { 152 190 if ( 153 191 AppBskyFeedPost.isRecord(post.record) && 154 192 AppBskyFeedPost.validateRecord(post.record).success 155 193 ) { 156 - map.set(post.uri, post) 194 + postsMap.set(post.uri, post) 157 195 } 158 196 } 159 - return map 197 + for (const pack of packsChunks.flat()) { 198 + if (AppBskyGraphStarterpack.isRecord(pack.record)) { 199 + packsMap.set(pack.uri, pack) 200 + } 201 + } 202 + return { 203 + posts: postsMap, 204 + starterPacks: packsMap, 205 + } 160 206 } 161 207 162 208 function toKnownType( ··· 173 219 notif.reason === 'mention' || 174 220 notif.reason === 'reply' || 175 221 notif.reason === 'quote' || 176 - notif.reason === 'follow' 222 + notif.reason === 'follow' || 223 + notif.reason === 'starterpack-joined' 177 224 ) { 178 225 return notif.reason as NotificationType 179 226 }
+9 -1
src/state/queries/profile-lists.ts
··· 26 26 limit: PAGE_SIZE, 27 27 cursor: pageParam, 28 28 }) 29 - return res.data 29 + 30 + // Starter packs use a reference list, which we do not want to show on profiles. At some point we could probably 31 + // just filter this out on the backend instead of in the client. 32 + return { 33 + ...res.data, 34 + lists: res.data.lists.filter( 35 + l => l.purpose !== 'app.bsky.graph.defs#referencelist', 36 + ), 37 + } 30 38 }, 31 39 initialPageParam: undefined, 32 40 getNextPageParam: lastPage => lastPage.cursor,
+23
src/state/queries/shorten-link.ts
··· 1 + import {logger} from '#/logger' 2 + 3 + export function useShortenLink() { 4 + return async (inputUrl: string): Promise<{url: string}> => { 5 + const url = new URL(inputUrl) 6 + const res = await fetch('https://go.bsky.app/link', { 7 + method: 'POST', 8 + body: JSON.stringify({ 9 + path: url.pathname, 10 + }), 11 + headers: { 12 + 'Content-Type': 'application/json', 13 + }, 14 + }) 15 + 16 + if (!res.ok) { 17 + logger.error('Failed to shorten link', {safeMessage: res.status}) 18 + return {url: inputUrl} 19 + } 20 + 21 + return res.json() 22 + } 23 + }
+317
src/state/queries/starter-packs.ts
··· 1 + import { 2 + AppBskyActorDefs, 3 + AppBskyFeedDefs, 4 + AppBskyGraphDefs, 5 + AppBskyGraphGetStarterPack, 6 + AppBskyGraphStarterpack, 7 + AtUri, 8 + BskyAgent, 9 + } from '@atproto/api' 10 + import {StarterPackView} from '@atproto/api/dist/client/types/app/bsky/graph/defs' 11 + import { 12 + QueryClient, 13 + useMutation, 14 + useQuery, 15 + useQueryClient, 16 + } from '@tanstack/react-query' 17 + 18 + import {until} from 'lib/async/until' 19 + import {createStarterPackList} from 'lib/generate-starterpack' 20 + import { 21 + createStarterPackUri, 22 + httpStarterPackUriToAtUri, 23 + parseStarterPackUri, 24 + } from 'lib/strings/starter-pack' 25 + import {invalidateActorStarterPacksQuery} from 'state/queries/actor-starter-packs' 26 + import {invalidateListMembersQuery} from 'state/queries/list-members' 27 + import {useAgent} from 'state/session' 28 + 29 + const RQKEY_ROOT = 'starter-pack' 30 + const RQKEY = (did?: string, rkey?: string) => { 31 + if (did?.startsWith('https://') || did?.startsWith('at://')) { 32 + const parsed = parseStarterPackUri(did) 33 + return [RQKEY_ROOT, parsed?.name, parsed?.rkey] 34 + } else { 35 + return [RQKEY_ROOT, did, rkey] 36 + } 37 + } 38 + 39 + export function useStarterPackQuery({ 40 + uri, 41 + did, 42 + rkey, 43 + }: { 44 + uri?: string 45 + did?: string 46 + rkey?: string 47 + }) { 48 + const agent = useAgent() 49 + 50 + return useQuery<StarterPackView>({ 51 + queryKey: RQKEY(did, rkey), 52 + queryFn: async () => { 53 + if (!uri) { 54 + uri = `at://${did}/app.bsky.graph.starterpack/${rkey}` 55 + } else if (uri && !uri.startsWith('at://')) { 56 + uri = httpStarterPackUriToAtUri(uri) as string 57 + } 58 + 59 + const res = await agent.app.bsky.graph.getStarterPack({ 60 + starterPack: uri, 61 + }) 62 + return res.data.starterPack 63 + }, 64 + enabled: Boolean(uri) || Boolean(did && rkey), 65 + }) 66 + } 67 + 68 + export async function invalidateStarterPack({ 69 + queryClient, 70 + did, 71 + rkey, 72 + }: { 73 + queryClient: QueryClient 74 + did: string 75 + rkey: string 76 + }) { 77 + await queryClient.invalidateQueries({queryKey: RQKEY(did, rkey)}) 78 + } 79 + 80 + interface UseCreateStarterPackMutationParams { 81 + name: string 82 + description?: string 83 + descriptionFacets: [] 84 + profiles: AppBskyActorDefs.ProfileViewBasic[] 85 + feeds?: AppBskyFeedDefs.GeneratorView[] 86 + } 87 + 88 + export function useCreateStarterPackMutation({ 89 + onSuccess, 90 + onError, 91 + }: { 92 + onSuccess: (data: {uri: string; cid: string}) => void 93 + onError: (e: Error) => void 94 + }) { 95 + const queryClient = useQueryClient() 96 + const agent = useAgent() 97 + 98 + return useMutation< 99 + {uri: string; cid: string}, 100 + Error, 101 + UseCreateStarterPackMutationParams 102 + >({ 103 + mutationFn: async params => { 104 + let listRes 105 + listRes = await createStarterPackList({...params, agent}) 106 + return await agent.app.bsky.graph.starterpack.create( 107 + { 108 + repo: agent.session?.did, 109 + }, 110 + { 111 + ...params, 112 + list: listRes?.uri, 113 + createdAt: new Date().toISOString(), 114 + }, 115 + ) 116 + }, 117 + onSuccess: async data => { 118 + await whenAppViewReady(agent, data.uri, v => { 119 + return typeof v?.data.starterPack.uri === 'string' 120 + }) 121 + await invalidateActorStarterPacksQuery({ 122 + queryClient, 123 + did: agent.session!.did, 124 + }) 125 + onSuccess(data) 126 + }, 127 + onError: async error => { 128 + onError(error) 129 + }, 130 + }) 131 + } 132 + 133 + export function useEditStarterPackMutation({ 134 + onSuccess, 135 + onError, 136 + }: { 137 + onSuccess: () => void 138 + onError: (error: Error) => void 139 + }) { 140 + const queryClient = useQueryClient() 141 + const agent = useAgent() 142 + 143 + return useMutation< 144 + void, 145 + Error, 146 + UseCreateStarterPackMutationParams & { 147 + currentStarterPack: AppBskyGraphDefs.StarterPackView 148 + currentListItems: AppBskyGraphDefs.ListItemView[] 149 + } 150 + >({ 151 + mutationFn: async params => { 152 + const { 153 + name, 154 + description, 155 + descriptionFacets, 156 + feeds, 157 + profiles, 158 + currentStarterPack, 159 + currentListItems, 160 + } = params 161 + 162 + if (!AppBskyGraphStarterpack.isRecord(currentStarterPack.record)) { 163 + throw new Error('Invalid starter pack') 164 + } 165 + 166 + const removedItems = currentListItems.filter( 167 + i => 168 + i.subject.did !== agent.session?.did && 169 + !profiles.find(p => p.did === i.subject.did && p.did), 170 + ) 171 + 172 + if (removedItems.length !== 0) { 173 + await agent.com.atproto.repo.applyWrites({ 174 + repo: agent.session!.did, 175 + writes: removedItems.map(i => ({ 176 + $type: 'com.atproto.repo.applyWrites#delete', 177 + collection: 'app.bsky.graph.listitem', 178 + rkey: new AtUri(i.uri).rkey, 179 + })), 180 + }) 181 + } 182 + 183 + const addedProfiles = profiles.filter( 184 + p => !currentListItems.find(i => i.subject.did === p.did), 185 + ) 186 + 187 + if (addedProfiles.length > 0) { 188 + await agent.com.atproto.repo.applyWrites({ 189 + repo: agent.session!.did, 190 + writes: addedProfiles.map(p => ({ 191 + $type: 'com.atproto.repo.applyWrites#create', 192 + collection: 'app.bsky.graph.listitem', 193 + value: { 194 + $type: 'app.bsky.graph.listitem', 195 + subject: p.did, 196 + list: currentStarterPack.list?.uri, 197 + createdAt: new Date().toISOString(), 198 + }, 199 + })), 200 + }) 201 + } 202 + 203 + const rkey = parseStarterPackUri(currentStarterPack.uri)!.rkey 204 + await agent.com.atproto.repo.putRecord({ 205 + repo: agent.session!.did, 206 + collection: 'app.bsky.graph.starterpack', 207 + rkey, 208 + record: { 209 + name, 210 + description, 211 + descriptionFacets, 212 + list: currentStarterPack.list?.uri, 213 + feeds, 214 + createdAt: currentStarterPack.record.createdAt, 215 + updatedAt: new Date().toISOString(), 216 + }, 217 + }) 218 + }, 219 + onSuccess: async (_, {currentStarterPack}) => { 220 + const parsed = parseStarterPackUri(currentStarterPack.uri) 221 + await whenAppViewReady(agent, currentStarterPack.uri, v => { 222 + return currentStarterPack.cid !== v?.data.starterPack.cid 223 + }) 224 + await invalidateActorStarterPacksQuery({ 225 + queryClient, 226 + did: agent.session!.did, 227 + }) 228 + if (currentStarterPack.list) { 229 + await invalidateListMembersQuery({ 230 + queryClient, 231 + uri: currentStarterPack.list.uri, 232 + }) 233 + } 234 + await invalidateStarterPack({ 235 + queryClient, 236 + did: agent.session!.did, 237 + rkey: parsed!.rkey, 238 + }) 239 + onSuccess() 240 + }, 241 + onError: error => { 242 + onError(error) 243 + }, 244 + }) 245 + } 246 + 247 + export function useDeleteStarterPackMutation({ 248 + onSuccess, 249 + onError, 250 + }: { 251 + onSuccess: () => void 252 + onError: (error: Error) => void 253 + }) { 254 + const agent = useAgent() 255 + const queryClient = useQueryClient() 256 + 257 + return useMutation({ 258 + mutationFn: async ({listUri, rkey}: {listUri?: string; rkey: string}) => { 259 + if (!agent.session) { 260 + throw new Error(`Requires logged in user`) 261 + } 262 + 263 + if (listUri) { 264 + await agent.app.bsky.graph.list.delete({ 265 + repo: agent.session.did, 266 + rkey: new AtUri(listUri).rkey, 267 + }) 268 + } 269 + await agent.app.bsky.graph.starterpack.delete({ 270 + repo: agent.session.did, 271 + rkey, 272 + }) 273 + }, 274 + onSuccess: async (_, {listUri, rkey}) => { 275 + const uri = createStarterPackUri({ 276 + did: agent.session!.did, 277 + rkey, 278 + }) 279 + 280 + if (uri) { 281 + await whenAppViewReady(agent, uri, v => { 282 + return Boolean(v?.data?.starterPack) === false 283 + }) 284 + } 285 + 286 + if (listUri) { 287 + await invalidateListMembersQuery({queryClient, uri: listUri}) 288 + } 289 + await invalidateActorStarterPacksQuery({ 290 + queryClient, 291 + did: agent.session!.did, 292 + }) 293 + await invalidateStarterPack({ 294 + queryClient, 295 + did: agent.session!.did, 296 + rkey, 297 + }) 298 + onSuccess() 299 + }, 300 + onError: error => { 301 + onError(error) 302 + }, 303 + }) 304 + } 305 + 306 + async function whenAppViewReady( 307 + agent: BskyAgent, 308 + uri: string, 309 + fn: (res?: AppBskyGraphGetStarterPack.Response) => boolean, 310 + ) { 311 + await until( 312 + 5, // 5 tries 313 + 1e3, // 1s delay between tries 314 + fn, 315 + () => agent.app.bsky.graph.getStarterPack({starterPack: uri}), 316 + ) 317 + }
-12
src/state/session/agent.ts
··· 127 127 const account = agentToSessionAccountOrThrow(agent) 128 128 const gates = tryFetchGates(account.did, 'prefer-fresh-gates') 129 129 const moderation = configureModerationForAccount(agent, account) 130 - if (!account.signupQueued) { 131 - /*dont await*/ agent.upsertProfile(_existing => { 132 - return { 133 - displayName: '', 134 - // HACKFIX 135 - // creating a bunch of identical profile objects is breaking the relay 136 - // tossing this unspecced field onto it to reduce the size of the problem 137 - // -prf 138 - createdAt: new Date().toISOString(), 139 - } 140 - }) 141 - } 142 130 143 131 // Not awaited so that we can still get into onboarding. 144 132 // This is OK because we won't let you toggle adult stuff until you set the date.
+14 -3
src/state/shell/logged-out.tsx
··· 1 1 import React from 'react' 2 2 3 + import {isWeb} from 'platform/detection' 4 + import {useSession} from 'state/session' 5 + import {useActiveStarterPack} from 'state/shell/starter-pack' 6 + 3 7 type State = { 4 8 showLoggedOut: boolean 5 9 /** ··· 22 26 /** 23 27 * The did of the account to populate the login form with. 24 28 */ 25 - requestedAccount?: string | 'none' | 'new' 29 + requestedAccount?: string | 'none' | 'new' | 'starterpack' 26 30 }) => void 27 31 /** 28 32 * Clears the requested account so that next time the logged out view is ··· 43 47 }) 44 48 45 49 export function Provider({children}: React.PropsWithChildren<{}>) { 50 + const activeStarterPack = useActiveStarterPack() 51 + const {hasSession} = useSession() 52 + const shouldShowStarterPack = Boolean(activeStarterPack?.uri) && !hasSession 46 53 const [state, setState] = React.useState<State>({ 47 - showLoggedOut: false, 48 - requestedAccountSwitchTo: undefined, 54 + showLoggedOut: shouldShowStarterPack, 55 + requestedAccountSwitchTo: shouldShowStarterPack 56 + ? isWeb 57 + ? 'starterpack' 58 + : 'new' 59 + : undefined, 49 60 }) 50 61 51 62 const controls = React.useMemo<Controls>(
+25
src/state/shell/starter-pack.tsx
··· 1 + import React from 'react' 2 + 3 + type StateContext = 4 + | { 5 + uri: string 6 + isClip?: boolean 7 + } 8 + | undefined 9 + type SetContext = (v: StateContext) => void 10 + 11 + const stateContext = React.createContext<StateContext>(undefined) 12 + const setContext = React.createContext<SetContext>((_: StateContext) => {}) 13 + 14 + export function Provider({children}: {children: React.ReactNode}) { 15 + const [state, setState] = React.useState<StateContext>() 16 + 17 + return ( 18 + <stateContext.Provider value={state}> 19 + <setContext.Provider value={setState}>{children}</setContext.Provider> 20 + </stateContext.Provider> 21 + ) 22 + } 23 + 24 + export const useActiveStarterPack = () => React.useContext(stateContext) 25 + export const useSetActiveStarterPack = () => React.useContext(setContext)
+20 -22
src/view/com/auth/LoggedOut.tsx
··· 7 7 8 8 import {useAnalytics} from '#/lib/analytics/analytics' 9 9 import {usePalette} from '#/lib/hooks/usePalette' 10 - import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries' 11 10 import {logEvent} from '#/lib/statsig/statsig' 12 11 import {s} from '#/lib/styles' 13 12 import {isIOS, isNative} from '#/platform/detection' ··· 22 21 import {Text} from '#/view/com/util/text/Text' 23 22 import {Login} from '#/screens/Login' 24 23 import {Signup} from '#/screens/Signup' 24 + import {LandingScreen} from '#/screens/StarterPack/StarterPackLandingScreen' 25 25 import {SplashScreen} from './SplashScreen' 26 26 27 27 enum ScreenState { 28 28 S_LoginOrCreateAccount, 29 29 S_Login, 30 30 S_CreateAccount, 31 + S_StarterPack, 31 32 } 33 + export {ScreenState as LoggedOutScreenState} 32 34 33 35 export function LoggedOut({onDismiss}: {onDismiss?: () => void}) { 34 36 const {hasSession} = useSession() ··· 37 39 const setMinimalShellMode = useSetMinimalShellMode() 38 40 const {screen} = useAnalytics() 39 41 const {requestedAccountSwitchTo} = useLoggedOutView() 40 - const [screenState, setScreenState] = React.useState<ScreenState>( 41 - requestedAccountSwitchTo 42 - ? requestedAccountSwitchTo === 'new' 43 - ? ScreenState.S_CreateAccount 44 - : ScreenState.S_Login 45 - : ScreenState.S_LoginOrCreateAccount, 46 - ) 47 - const {isMobile} = useWebMediaQueries() 42 + const [screenState, setScreenState] = React.useState<ScreenState>(() => { 43 + if (requestedAccountSwitchTo === 'new') { 44 + return ScreenState.S_CreateAccount 45 + } else if (requestedAccountSwitchTo === 'starterpack') { 46 + return ScreenState.S_StarterPack 47 + } else if (requestedAccountSwitchTo != null) { 48 + return ScreenState.S_Login 49 + } else { 50 + return ScreenState.S_LoginOrCreateAccount 51 + } 52 + }) 48 53 const {clearRequestedAccount} = useLoggedOutViewControls() 49 54 const navigation = useNavigation<NavigationProp>() 50 - const isFirstScreen = screenState === ScreenState.S_LoginOrCreateAccount 51 55 56 + const isFirstScreen = screenState === ScreenState.S_LoginOrCreateAccount 52 57 React.useEffect(() => { 53 58 screen('Login') 54 59 setMinimalShellMode(true) ··· 66 71 }, [navigation]) 67 72 68 73 return ( 69 - <View 70 - testID="noSessionView" 71 - style={[ 72 - s.hContentRegion, 73 - pal.view, 74 - { 75 - // only needed if dismiss button is present 76 - paddingTop: onDismiss && isMobile ? 40 : 0, 77 - }, 78 - ]}> 74 + <View testID="noSessionView" style={[s.hContentRegion, pal.view]}> 79 75 <ErrorBoundary> 80 - {onDismiss ? ( 76 + {onDismiss && screenState === ScreenState.S_LoginOrCreateAccount ? ( 81 77 <Pressable 82 78 accessibilityHint={_(msg`Go back`)} 83 79 accessibilityLabel={_(msg`Go back`)} ··· 132 128 </Pressable> 133 129 ) : null} 134 130 135 - {screenState === ScreenState.S_LoginOrCreateAccount ? ( 131 + {screenState === ScreenState.S_StarterPack ? ( 132 + <LandingScreen setScreenState={setScreenState} /> 133 + ) : screenState === ScreenState.S_LoginOrCreateAccount ? ( 136 134 <SplashScreen 137 135 onPressSignin={() => { 138 136 setScreenState(ScreenState.S_Login)
+3
src/view/com/feeds/FeedSourceCard.tsx
··· 329 329 flex: 1, 330 330 gap: 14, 331 331 }, 332 + border: { 333 + borderTopWidth: hairlineWidth, 334 + }, 332 335 headerContainer: { 333 336 flexDirection: 'row', 334 337 },
+85 -2
src/view/com/notifications/FeedItem.tsx
··· 52 52 import {PreviewableUserAvatar, UserAvatar} from '../util/UserAvatar' 53 53 54 54 import hairlineWidth = StyleSheet.hairlineWidth 55 + import {useNavigation} from '@react-navigation/native' 56 + 55 57 import {parseTenorGif} from '#/lib/strings/embed-player' 58 + import {logger} from '#/logger' 59 + import {NavigationProp} from 'lib/routes/types' 60 + import {DM_SERVICE_HEADERS} from 'state/queries/messages/const' 61 + import {useAgent} from 'state/session' 62 + import {Button, ButtonText} from '#/components/Button' 63 + import {StarterPack} from '#/components/icons/StarterPack' 64 + import {Notification as StarterPackCard} from '#/components/StarterPack/StarterPackCard' 56 65 57 66 const MAX_AUTHORS = 5 58 67 ··· 89 98 } else if (item.type === 'reply') { 90 99 const urip = new AtUri(item.notification.uri) 91 100 return `/profile/${urip.host}/post/${urip.rkey}` 92 - } else if (item.type === 'feedgen-like') { 101 + } else if ( 102 + item.type === 'feedgen-like' || 103 + item.type === 'starterpack-joined' 104 + ) { 93 105 if (item.subjectUri) { 94 106 const urip = new AtUri(item.subjectUri) 95 107 return `/profile/${urip.host}/feed/${urip.rkey}` ··· 176 188 icon = <PersonPlusIcon size="xl" style={{color: t.palette.primary_500}} /> 177 189 } else if (item.type === 'feedgen-like') { 178 190 action = _(msg`liked your custom feed`) 191 + } else if (item.type === 'starterpack-joined') { 192 + icon = ( 193 + <View style={{height: 30, width: 30}}> 194 + <StarterPack width={30} gradient="sky" /> 195 + </View> 196 + ) 197 + action = _(msg`signed up with your starter pack`) 179 198 } else { 180 199 return null 181 200 } ··· 289 308 showLikes 290 309 /> 291 310 ) : null} 311 + {item.type === 'starterpack-joined' ? ( 312 + <View> 313 + <View 314 + style={[ 315 + a.border, 316 + a.p_sm, 317 + a.rounded_sm, 318 + a.mt_sm, 319 + t.atoms.border_contrast_low, 320 + ]}> 321 + <StarterPackCard starterPack={item.subject} /> 322 + </View> 323 + </View> 324 + ) : null} 292 325 </View> 293 326 </Link> 294 327 ) ··· 319 352 } 320 353 } 321 354 355 + function SayHelloBtn({profile}: {profile: AppBskyActorDefs.ProfileViewBasic}) { 356 + const {_} = useLingui() 357 + const agent = useAgent() 358 + const navigation = useNavigation<NavigationProp>() 359 + const [isLoading, setIsLoading] = React.useState(false) 360 + 361 + if ( 362 + profile.associated?.chat?.allowIncoming === 'none' || 363 + (profile.associated?.chat?.allowIncoming === 'following' && 364 + !profile.viewer?.followedBy) 365 + ) { 366 + return null 367 + } 368 + 369 + return ( 370 + <Button 371 + label={_(msg`Say hello!`)} 372 + variant="ghost" 373 + color="primary" 374 + size="xsmall" 375 + style={[a.self_center, {marginLeft: 'auto'}]} 376 + disabled={isLoading} 377 + onPress={async () => { 378 + try { 379 + setIsLoading(true) 380 + const res = await agent.api.chat.bsky.convo.getConvoForMembers( 381 + { 382 + members: [profile.did, agent.session!.did!], 383 + }, 384 + {headers: DM_SERVICE_HEADERS}, 385 + ) 386 + navigation.navigate('MessagesConversation', { 387 + conversation: res.data.convo.id, 388 + }) 389 + } catch (e) { 390 + logger.error('Failed to get conversation', {safeMessage: e}) 391 + } finally { 392 + setIsLoading(false) 393 + } 394 + }}> 395 + <ButtonText> 396 + <Trans>Say hello!</Trans> 397 + </ButtonText> 398 + </Button> 399 + ) 400 + } 401 + 322 402 function CondensedAuthorsList({ 323 403 visible, 324 404 authors, 325 405 onToggleAuthorsExpanded, 406 + showDmButton = true, 326 407 }: { 327 408 visible: boolean 328 409 authors: Author[] 329 410 onToggleAuthorsExpanded: () => void 411 + showDmButton?: boolean 330 412 }) { 331 413 const pal = usePalette('default') 332 414 const {_} = useLingui() ··· 355 437 } 356 438 if (authors.length === 1) { 357 439 return ( 358 - <View style={styles.avis}> 440 + <View style={[styles.avis]}> 359 441 <PreviewableUserAvatar 360 442 size={35} 361 443 profile={authors[0].profile} ··· 363 445 type={authors[0].profile.associated?.labeler ? 'labeler' : 'user'} 364 446 accessible={false} 365 447 /> 448 + {showDmButton ? <SayHelloBtn profile={authors[0].profile} /> : null} 366 449 </View> 367 450 ) 368 451 }
+6 -5
src/view/com/profile/FollowButton.tsx
··· 1 1 import React from 'react' 2 2 import {StyleProp, TextStyle, View} from 'react-native' 3 3 import {AppBskyActorDefs} from '@atproto/api' 4 + import {msg} from '@lingui/macro' 5 + import {useLingui} from '@lingui/react' 6 + 7 + import {Shadow} from '#/state/cache/types' 8 + import {useProfileFollowMutationQueue} from '#/state/queries/profile' 4 9 import {Button, ButtonType} from '../util/forms/Button' 5 10 import * as Toast from '../util/Toast' 6 - import {useProfileFollowMutationQueue} from '#/state/queries/profile' 7 - import {Shadow} from '#/state/cache/types' 8 - import {useLingui} from '@lingui/react' 9 - import {msg} from '@lingui/macro' 10 11 11 12 export function FollowButton({ 12 13 unfollowedType = 'inverted', ··· 19 20 followedType?: ButtonType 20 21 profile: Shadow<AppBskyActorDefs.ProfileViewBasic> 21 22 labelStyle?: StyleProp<TextStyle> 22 - logContext: 'ProfileCard' 23 + logContext: 'ProfileCard' | 'StarterPackProfilesList' 23 24 }) { 24 25 const [queueFollow, queueUnfollow] = useProfileFollowMutationQueue( 25 26 profile,
+4 -2
src/view/com/profile/ProfileCard.tsx
··· 251 251 noBorder, 252 252 followers, 253 253 onPress, 254 + logContext = 'ProfileCard', 254 255 }: { 255 256 profile: AppBskyActorDefs.ProfileViewBasic 256 257 noBg?: boolean 257 258 noBorder?: boolean 258 259 followers?: AppBskyActorDefs.ProfileView[] | undefined 259 260 onPress?: () => void 261 + logContext?: 'ProfileCard' | 'StarterPackProfilesList' 260 262 }) { 261 263 const {currentAccount} = useSession() 262 264 const isMe = profile.did === currentAccount?.did ··· 271 273 isMe 272 274 ? undefined 273 275 : profileShadow => ( 274 - <FollowButton profile={profileShadow} logContext="ProfileCard" /> 276 + <FollowButton profile={profileShadow} logContext={logContext} /> 275 277 ) 276 278 } 277 279 onPress={onPress} ··· 314 316 paddingRight: 10, 315 317 }, 316 318 details: { 319 + justifyContent: 'center', 317 320 paddingLeft: 54, 318 321 paddingRight: 10, 319 322 paddingBottom: 10, ··· 339 342 340 343 followedBy: { 341 344 flexDirection: 'row', 342 - alignItems: 'center', 343 345 paddingLeft: 54, 344 346 paddingRight: 20, 345 347 marginBottom: 10,
+8 -2
src/view/com/profile/ProfileSubpageHeader.tsx
··· 21 21 import {UserAvatar, UserAvatarType} from '../util/UserAvatar' 22 22 import {CenteredView} from '../util/Views' 23 23 import hairlineWidth = StyleSheet.hairlineWidth 24 + 24 25 import {Menu_Stroke2_Corner0_Rounded as Menu} from '#/components/icons/Menu' 26 + import {StarterPack} from '#/components/icons/StarterPack' 25 27 26 28 export function ProfileSubpageHeader({ 27 29 isLoading, ··· 44 46 handle: string 45 47 } 46 48 | undefined 47 - avatarType: UserAvatarType 49 + avatarType: UserAvatarType | 'starter-pack' 48 50 }>) { 49 51 const setDrawerOpen = useSetDrawerOpen() 50 52 const navigation = useNavigation<NavigationProp>() ··· 127 129 accessibilityLabel={_(msg`View the avatar`)} 128 130 accessibilityHint="" 129 131 style={{width: 58}}> 130 - <UserAvatar type={avatarType} size={58} avatar={avatar} /> 132 + {avatarType === 'starter-pack' ? ( 133 + <StarterPack width={58} gradient="sky" /> 134 + ) : ( 135 + <UserAvatar type={avatarType} size={58} avatar={avatar} /> 136 + )} 131 137 </Pressable> 132 138 <View style={{flex: 1}}> 133 139 {isLoading ? (
+1 -1
src/view/screens/Home.tsx
··· 30 30 import {NoFeedsPinned} from '#/screens/Home/NoFeedsPinned' 31 31 import {HomeHeader} from '../com/home/HomeHeader' 32 32 33 - type Props = NativeStackScreenProps<HomeTabNavigatorParams, 'Home'> 33 + type Props = NativeStackScreenProps<HomeTabNavigatorParams, 'Home' | 'Start'> 34 34 export function HomeScreen(props: Props) { 35 35 const {data: preferences} = usePreferencesQuery() 36 36 const {data: pinnedFeedInfos, isLoading: isPinnedFeedsLoading} =
+68 -47
src/view/screens/Profile.tsx
··· 1 - import React, {useMemo} from 'react' 1 + import React, {useCallback, useMemo} from 'react' 2 2 import {StyleSheet} from 'react-native' 3 3 import { 4 4 AppBskyActorDefs, 5 + AppBskyGraphGetActorStarterPacks, 5 6 moderateProfile, 6 7 ModerationOpts, 7 8 RichText as RichTextAPI, ··· 9 10 import {msg} from '@lingui/macro' 10 11 import {useLingui} from '@lingui/react' 11 12 import {useFocusEffect} from '@react-navigation/native' 12 - import {useQueryClient} from '@tanstack/react-query' 13 + import { 14 + InfiniteData, 15 + UseInfiniteQueryResult, 16 + useQueryClient, 17 + } from '@tanstack/react-query' 13 18 14 19 import {cleanError} from '#/lib/strings/errors' 15 20 import {useProfileShadow} from '#/state/cache/profile-shadow' ··· 22 27 import {useSetDrawerSwipeDisabled, useSetMinimalShellMode} from '#/state/shell' 23 28 import {useComposerControls} from '#/state/shell/composer' 24 29 import {useAnalytics} from 'lib/analytics/analytics' 30 + import {IS_DEV, IS_TESTFLIGHT} from 'lib/app-info' 25 31 import {useSetTitle} from 'lib/hooks/useSetTitle' 26 32 import {ComposeIcon2} from 'lib/icons' 27 33 import {CommonNavigatorParams, NativeStackScreenProps} from 'lib/routes/types' 34 + import {useGate} from 'lib/statsig/statsig' 28 35 import {combinedDisplayName} from 'lib/strings/display-names' 29 36 import {isInvalidHandle} from 'lib/strings/handles' 30 37 import {colors, s} from 'lib/styles' 38 + import {isWeb} from 'platform/detection' 31 39 import {listenSoftReset} from 'state/events' 40 + import {useActorStarterPacksQuery} from 'state/queries/actor-starter-packs' 32 41 import {PagerWithHeader} from 'view/com/pager/PagerWithHeader' 33 42 import {ProfileHeader, ProfileHeaderLoading} from '#/screens/Profile/Header' 34 43 import {ProfileFeedSection} from '#/screens/Profile/Sections/Feed' 35 44 import {ProfileLabelsSection} from '#/screens/Profile/Sections/Labels' 36 45 import {ScreenHider} from '#/components/moderation/ScreenHider' 46 + import {ProfileStarterPacks} from '#/components/StarterPack/ProfileStarterPacks' 37 47 import {ExpoScrollForwarderView} from '../../../modules/expo-scroll-forwarder' 38 48 import {ProfileFeedgens} from '../com/feeds/ProfileFeedgens' 39 49 import {ProfileLists} from '../com/lists/ProfileLists' ··· 69 79 } = useProfileQuery({ 70 80 did: resolvedDid, 71 81 }) 82 + const starterPacksQuery = useActorStarterPacksQuery({did: resolvedDid}) 72 83 73 84 const onPressTryAgain = React.useCallback(() => { 74 85 if (resolveError) { ··· 86 97 }, [queryClient, profile?.viewer?.blockedBy, resolvedDid]) 87 98 88 99 // Most pushes will happen here, since we will have only placeholder data 89 - if (isLoadingDid || isLoadingProfile) { 100 + if (isLoadingDid || isLoadingProfile || starterPacksQuery.isLoading) { 90 101 return ( 91 102 <CenteredView> 92 103 <ProfileHeaderLoading /> ··· 108 119 return ( 109 120 <ProfileScreenLoaded 110 121 profile={profile} 122 + starterPacksQuery={starterPacksQuery} 111 123 moderationOpts={moderationOpts} 112 124 isPlaceholderProfile={isPlaceholderProfile} 113 125 hideBackButton={!!route.params.hideBackButton} ··· 131 143 isPlaceholderProfile, 132 144 moderationOpts, 133 145 hideBackButton, 146 + starterPacksQuery, 134 147 }: { 135 148 profile: AppBskyActorDefs.ProfileViewDetailed 136 149 moderationOpts: ModerationOpts 137 150 hideBackButton: boolean 138 151 isPlaceholderProfile: boolean 152 + starterPacksQuery: UseInfiniteQueryResult< 153 + InfiniteData<AppBskyGraphGetActorStarterPacks.OutputSchema, unknown>, 154 + Error 155 + > 139 156 }) { 140 157 const profile = useProfileShadow(profileUnshadowed) 141 158 const {hasSession, currentAccount} = useSession() ··· 153 170 const [currentPage, setCurrentPage] = React.useState(0) 154 171 const {_} = useLingui() 155 172 const setDrawerSwipeDisabled = useSetDrawerSwipeDisabled() 173 + const gate = useGate() 174 + const starterPacksEnabled = 175 + IS_DEV || IS_TESTFLIGHT || (!isWeb && gate('starter_packs_enabled')) 156 176 157 177 const [scrollViewTag, setScrollViewTag] = React.useState<number | null>(null) 158 178 ··· 162 182 const likesSectionRef = React.useRef<SectionRef>(null) 163 183 const feedsSectionRef = React.useRef<SectionRef>(null) 164 184 const listsSectionRef = React.useRef<SectionRef>(null) 185 + const starterPacksSectionRef = React.useRef<SectionRef>(null) 165 186 const labelsSectionRef = React.useRef<SectionRef>(null) 166 187 167 188 useSetTitle(combinedDisplayName(profile)) ··· 183 204 const showMediaTab = !hasLabeler 184 205 const showLikesTab = isMe 185 206 const showFeedsTab = isMe || (profile.associated?.feedgens || 0) > 0 207 + const showStarterPacksTab = 208 + starterPacksEnabled && 209 + (isMe || !!starterPacksQuery.data?.pages?.[0].starterPacks.length) 186 210 const showListsTab = 187 211 hasSession && (isMe || (profile.associated?.lists || 0) > 0) 188 212 189 - const sectionTitles = useMemo<string[]>(() => { 190 - return [ 191 - showFiltersTab ? _(msg`Labels`) : undefined, 192 - showListsTab && hasLabeler ? _(msg`Lists`) : undefined, 193 - showPostsTab ? _(msg`Posts`) : undefined, 194 - showRepliesTab ? _(msg`Replies`) : undefined, 195 - showMediaTab ? _(msg`Media`) : undefined, 196 - showLikesTab ? _(msg`Likes`) : undefined, 197 - showFeedsTab ? _(msg`Feeds`) : undefined, 198 - showListsTab && !hasLabeler ? _(msg`Lists`) : undefined, 199 - ].filter(Boolean) as string[] 200 - }, [ 201 - showPostsTab, 202 - showRepliesTab, 203 - showMediaTab, 204 - showLikesTab, 205 - showFeedsTab, 206 - showListsTab, 207 - showFiltersTab, 208 - hasLabeler, 209 - _, 210 - ]) 213 + const sectionTitles = [ 214 + showFiltersTab ? _(msg`Labels`) : undefined, 215 + showListsTab && hasLabeler ? _(msg`Lists`) : undefined, 216 + showPostsTab ? _(msg`Posts`) : undefined, 217 + showRepliesTab ? _(msg`Replies`) : undefined, 218 + showMediaTab ? _(msg`Media`) : undefined, 219 + showLikesTab ? _(msg`Likes`) : undefined, 220 + showFeedsTab ? _(msg`Feeds`) : undefined, 221 + showStarterPacksTab ? _(msg`Starter Packs`) : undefined, 222 + showListsTab && !hasLabeler ? _(msg`Lists`) : undefined, 223 + ].filter(Boolean) as string[] 211 224 212 225 let nextIndex = 0 213 226 let filtersIndex: number | null = null ··· 216 229 let mediaIndex: number | null = null 217 230 let likesIndex: number | null = null 218 231 let feedsIndex: number | null = null 232 + let starterPacksIndex: number | null = null 219 233 let listsIndex: number | null = null 220 234 if (showFiltersTab) { 221 235 filtersIndex = nextIndex++ ··· 235 249 if (showFeedsTab) { 236 250 feedsIndex = nextIndex++ 237 251 } 252 + if (showStarterPacksTab) { 253 + starterPacksIndex = nextIndex++ 254 + } 238 255 if (showListsTab) { 239 256 listsIndex = nextIndex++ 240 257 } 241 258 242 - const scrollSectionToTop = React.useCallback( 259 + const scrollSectionToTop = useCallback( 243 260 (index: number) => { 244 261 if (index === filtersIndex) { 245 262 labelsSectionRef.current?.scrollToTop() ··· 253 270 likesSectionRef.current?.scrollToTop() 254 271 } else if (index === feedsIndex) { 255 272 feedsSectionRef.current?.scrollToTop() 273 + } else if (index === starterPacksIndex) { 274 + starterPacksSectionRef.current?.scrollToTop() 256 275 } else if (index === listsIndex) { 257 276 listsSectionRef.current?.scrollToTop() 258 277 } ··· 265 284 likesIndex, 266 285 feedsIndex, 267 286 listsIndex, 287 + starterPacksIndex, 268 288 ], 269 289 ) 270 290 ··· 290 310 // events 291 311 // = 292 312 293 - const onPressCompose = React.useCallback(() => { 313 + const onPressCompose = () => { 294 314 track('ProfileScreen:PressCompose') 295 315 const mention = 296 316 profile.handle === currentAccount?.handle || ··· 298 318 ? undefined 299 319 : profile.handle 300 320 openComposer({mention}) 301 - }, [openComposer, currentAccount, track, profile]) 321 + } 302 322 303 - const onPageSelected = React.useCallback((i: number) => { 323 + const onPageSelected = (i: number) => { 304 324 setCurrentPage(i) 305 - }, []) 325 + } 306 326 307 - const onCurrentPageSelected = React.useCallback( 308 - (index: number) => { 309 - scrollSectionToTop(index) 310 - }, 311 - [scrollSectionToTop], 312 - ) 327 + const onCurrentPageSelected = (index: number) => { 328 + scrollSectionToTop(index) 329 + } 313 330 314 331 // rendering 315 332 // = 316 333 317 - const renderHeader = React.useCallback(() => { 334 + const renderHeader = () => { 318 335 return ( 319 336 <ExpoScrollForwarderView scrollViewTag={scrollViewTag}> 320 337 <ProfileHeader ··· 327 344 /> 328 345 </ExpoScrollForwarderView> 329 346 ) 330 - }, [ 331 - scrollViewTag, 332 - profile, 333 - labelerInfo, 334 - hasDescription, 335 - descriptionRT, 336 - moderationOpts, 337 - hideBackButton, 338 - showPlaceholder, 339 - ]) 347 + } 340 348 341 349 return ( 342 350 <ScreenHider ··· 435 443 <ProfileFeedgens 436 444 ref={feedsSectionRef} 437 445 did={profile.did} 446 + scrollElRef={scrollElRef as ListRef} 447 + headerOffset={headerHeight} 448 + enabled={isFocused} 449 + setScrollViewTag={setScrollViewTag} 450 + /> 451 + ) 452 + : null} 453 + {showStarterPacksTab 454 + ? ({headerHeight, isFocused, scrollElRef}) => ( 455 + <ProfileStarterPacks 456 + ref={starterPacksSectionRef} 457 + isMe={isMe} 458 + starterPacksQuery={starterPacksQuery} 438 459 scrollElRef={scrollElRef as ListRef} 439 460 headerOffset={headerHeight} 440 461 enabled={isFocused}
+8
src/view/screens/Storybook/Icons.tsx
··· 45 45 <Loader size="lg" fill={t.atoms.text.color} /> 46 46 <Loader size="xl" fill={t.atoms.text.color} /> 47 47 </View> 48 + 49 + <View style={[a.flex_row, a.gap_xl]}> 50 + <Globe size="xs" gradient="sky" /> 51 + <Globe size="sm" gradient="sky" /> 52 + <Globe size="md" gradient="sky" /> 53 + <Globe size="lg" gradient="sky" /> 54 + <Globe size="xl" gradient="sky" /> 55 + </View> 48 56 </View> 49 57 ) 50 58 }
+7 -1
src/view/shell/desktop/LeftNav.tsx
··· 100 100 ) 101 101 } 102 102 103 + const HIDDEN_BACK_BNT_ROUTES = ['StarterPackWizard', 'StarterPackEdit'] 104 + 103 105 function BackBtn() { 104 106 const {isTablet} = useWebMediaQueries() 105 107 const pal = usePalette('default') 106 108 const navigation = useNavigation<NavigationProp>() 107 109 const {_} = useLingui() 108 - const shouldShow = useNavigationState(state => !isStateAtTabRoot(state)) 110 + const shouldShow = useNavigationState( 111 + state => 112 + !isStateAtTabRoot(state) && 113 + !HIDDEN_BACK_BNT_ROUTES.includes(getCurrentRoute(state).name), 114 + ) 109 115 110 116 const onPressBack = React.useCallback(() => { 111 117 if (navigation.canGoBack()) {
+52 -20
yarn.lock
··· 34 34 jsonpointer "^5.0.0" 35 35 leven "^3.1.0" 36 36 37 - "@atproto/api@^0.12.20": 38 - version "0.12.20" 39 - resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.12.20.tgz#2cada08c24bc61eb1775ee4c8010c7ed9dc5d6f3" 40 - integrity sha512-nt7ZKUQL9j2yQ3tmCCueiIuc0FwdxZYn2fXdLYqltuxlaO5DmaqqULMBKeYJLq4GbvVl/G+ikPJccoSaMWDYOg== 37 + "@atproto/api@0.12.22-next.0": 38 + version "0.12.22-next.0" 39 + resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.12.22-next.0.tgz#7996f651468e3fb151663df28a9938d92bd0660a" 40 + integrity sha512-LKmOrQvBvIlheLv+ns85bCrP23DbYfk8UQkFikLBEqPKQW10F9ZwsJ6oBUfrWv6pEI4Mn0mrn8cFQkvdZ2i2sg== 41 41 dependencies: 42 42 "@atproto/common-web" "^0.3.0" 43 43 "@atproto/lexicon" "^0.4.0" ··· 3382 3382 node-forge "^1.2.1" 3383 3383 nullthrows "^1.1.1" 3384 3384 3385 - "@expo/config-plugins@7.8.0", "@expo/config-plugins@~7.8.0": 3386 - version "7.8.0" 3387 - resolved "https://registry.yarnpkg.com/@expo/config-plugins/-/config-plugins-7.8.0.tgz#70fd87237faf6a5c3bf47277b67f7b22f9b12c05" 3388 - integrity sha512-bCJB/uTP2D520l36M0zMVzxzu25ISdEniE42SjgtFnbIzKae2s9Jd91CT/90qEoF2EXeAVlXwn2nCIiY8FTU3A== 3385 + "@expo/config-plugins@8.0.4", "@expo/config-plugins@~8.0.0", "@expo/config-plugins@~8.0.0-beta.0": 3386 + version "8.0.4" 3387 + resolved "https://registry.yarnpkg.com/@expo/config-plugins/-/config-plugins-8.0.4.tgz#1e781cd971fab27409ed2f8d621db6d29cce3036" 3388 + integrity sha512-Hi+xuyNWE2LT4LVbGttHJgl9brnsdWAhEB42gWKb5+8ae86Nr/KwUBQJsJppirBYTeLjj5ZlY0glYnAkDa2jqw== 3389 3389 dependencies: 3390 - "@expo/config-types" "^50.0.0-alpha.1" 3391 - "@expo/fingerprint" "^0.6.0" 3390 + "@expo/config-types" "^51.0.0-unreleased" 3392 3391 "@expo/json-file" "~8.3.0" 3393 3392 "@expo/plist" "^0.1.0" 3394 3393 "@expo/sdk-runtime-versions" "^1.0.0" 3395 - "@react-native/normalize-color" "^2.0.0" 3396 3394 chalk "^4.1.2" 3397 3395 debug "^4.3.1" 3398 3396 find-up "~5.0.0" 3399 3397 getenv "^1.0.0" 3400 3398 glob "7.1.6" 3401 3399 resolve-from "^5.0.0" 3402 - semver "^7.5.3" 3400 + semver "^7.5.4" 3403 3401 slash "^3.0.0" 3402 + slugify "^1.6.6" 3404 3403 xcode "^3.0.1" 3405 3404 xml2js "0.6.0" 3406 3405 3407 - "@expo/config-plugins@8.0.4", "@expo/config-plugins@~8.0.0", "@expo/config-plugins@~8.0.0-beta.0": 3408 - version "8.0.4" 3409 - resolved "https://registry.yarnpkg.com/@expo/config-plugins/-/config-plugins-8.0.4.tgz#1e781cd971fab27409ed2f8d621db6d29cce3036" 3410 - integrity sha512-Hi+xuyNWE2LT4LVbGttHJgl9brnsdWAhEB42gWKb5+8ae86Nr/KwUBQJsJppirBYTeLjj5ZlY0glYnAkDa2jqw== 3406 + "@expo/config-plugins@~7.8.0": 3407 + version "7.8.0" 3408 + resolved "https://registry.yarnpkg.com/@expo/config-plugins/-/config-plugins-7.8.0.tgz#70fd87237faf6a5c3bf47277b67f7b22f9b12c05" 3409 + integrity sha512-bCJB/uTP2D520l36M0zMVzxzu25ISdEniE42SjgtFnbIzKae2s9Jd91CT/90qEoF2EXeAVlXwn2nCIiY8FTU3A== 3411 3410 dependencies: 3412 - "@expo/config-types" "^51.0.0-unreleased" 3411 + "@expo/config-types" "^50.0.0-alpha.1" 3412 + "@expo/fingerprint" "^0.6.0" 3413 3413 "@expo/json-file" "~8.3.0" 3414 3414 "@expo/plist" "^0.1.0" 3415 3415 "@expo/sdk-runtime-versions" "^1.0.0" 3416 + "@react-native/normalize-color" "^2.0.0" 3416 3417 chalk "^4.1.2" 3417 3418 debug "^4.3.1" 3418 3419 find-up "~5.0.0" 3419 3420 getenv "^1.0.0" 3420 3421 glob "7.1.6" 3421 3422 resolve-from "^5.0.0" 3422 - semver "^7.5.4" 3423 + semver "^7.5.3" 3423 3424 slash "^3.0.0" 3424 - slugify "^1.6.6" 3425 3425 xcode "^3.0.1" 3426 3426 xml2js "0.6.0" 3427 3427 ··· 10969 10969 resolved "https://registry.yarnpkg.com/diff/-/diff-4.0.2.tgz#60f3aecb89d5fae520c11aa19efc2bb982aade7d" 10970 10970 integrity sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A== 10971 10971 10972 + dijkstrajs@^1.0.1: 10973 + version "1.0.3" 10974 + resolved "https://registry.yarnpkg.com/dijkstrajs/-/dijkstrajs-1.0.3.tgz#4c8dbdea1f0f6478bff94d9c49c784d623e4fc23" 10975 + integrity sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA== 10976 + 10972 10977 dir-glob@^3.0.1: 10973 10978 version "3.0.1" 10974 10979 resolved "https://registry.yarnpkg.com/dir-glob/-/dir-glob-3.0.1.tgz#56dbf73d992a4a93ba1584f4534063fd2e41717f" ··· 11234 11239 resolved "https://registry.yarnpkg.com/emojis-list/-/emojis-list-3.0.0.tgz#5570662046ad29e2e916e71aae260abdff4f6a78" 11235 11240 integrity sha512-/kyM18EfinwXZbno9FyUGeFh87KC8HRQBQGildHZbEuRyWFOmv1U10o9BBp8XVZDVNNuQKyIGIu5ZYAAXJ0V2Q== 11236 11241 11242 + encode-utf8@^1.0.3: 11243 + version "1.0.3" 11244 + resolved "https://registry.yarnpkg.com/encode-utf8/-/encode-utf8-1.0.3.tgz#f30fdd31da07fb596f281beb2f6b027851994cda" 11245 + integrity sha512-ucAnuBEhUK4boH2HjVYG5Q2mQyPorvv0u/ocS+zhdw0S8AlHYY+GOFhP1Gio5z4icpP2ivFSvhtFjQi8+T9ppw== 11246 + 11237 11247 encodeurl@~1.0.2: 11238 11248 version "1.0.2" 11239 11249 resolved "https://registry.yarnpkg.com/encodeurl/-/encodeurl-1.0.2.tgz#ad3ff4c86ec2d029322f5a02c3a9a606c95b3f59" ··· 17609 17619 resolved "https://registry.yarnpkg.com/pngjs/-/pngjs-3.4.0.tgz#99ca7d725965fb655814eaf65f38f12bbdbf555f" 17610 17620 integrity sha512-NCrCHhWmnQklfH4MtJMRjZ2a8c80qXeMlQMv2uVp9ISJMTt562SbGd6n2oq0PaPgKm7Z6pL9E2UlLIhC+SHL3w== 17611 17621 17622 + pngjs@^5.0.0: 17623 + version "5.0.0" 17624 + resolved "https://registry.yarnpkg.com/pngjs/-/pngjs-5.0.0.tgz#e79dd2b215767fd9c04561c01236df960bce7fbb" 17625 + integrity sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw== 17626 + 17612 17627 pofile@^1.1.4: 17613 17628 version "1.1.4" 17614 17629 resolved "https://registry.yarnpkg.com/pofile/-/pofile-1.1.4.tgz#eab7e29f5017589b2a61b2259dff608c0cad76a2" ··· 18565 18580 resolved "https://registry.yarnpkg.com/qrcode-terminal/-/qrcode-terminal-0.11.0.tgz#ffc6c28a2fc0bfb47052b47e23f4f446a5fbdb9e" 18566 18581 integrity sha512-Uu7ii+FQy4Qf82G4xu7ShHhjhGahEpCWc3x8UavY3CTcWV+ufmmCtwkr7ZKsX42jdL0kr1B5FKUeqJvAn51jzQ== 18567 18582 18583 + qrcode@^1.5.1: 18584 + version "1.5.3" 18585 + resolved "https://registry.yarnpkg.com/qrcode/-/qrcode-1.5.3.tgz#03afa80912c0dccf12bc93f615a535aad1066170" 18586 + integrity sha512-puyri6ApkEHYiVl4CFzo1tDkAZ+ATcnbJrJ6RiBM1Fhctdn/ix9MTE3hRph33omisEbC/2fcfemsseiKgBPKZg== 18587 + dependencies: 18588 + dijkstrajs "^1.0.1" 18589 + encode-utf8 "^1.0.3" 18590 + pngjs "^5.0.0" 18591 + yargs "^15.3.1" 18592 + 18568 18593 qs@6.11.0: 18569 18594 version "6.11.0" 18570 18595 resolved "https://registry.yarnpkg.com/qs/-/qs-6.11.0.tgz#fd0d963446f7a65e1367e01abd85429453f0c37a" ··· 18859 18884 resolved "https://codeload.github.com/bluesky-social/react-native-progress/tar.gz/5a372f4f2ce5feb26f4f47b6a4d187ab9b923ab4" 18860 18885 dependencies: 18861 18886 prop-types "^15.7.2" 18887 + 18888 + react-native-qrcode-styled@^0.3.1: 18889 + version "0.3.1" 18890 + resolved "https://registry.yarnpkg.com/react-native-qrcode-styled/-/react-native-qrcode-styled-0.3.1.tgz#be6a0fab173511b0d3d8d71588771c2230982dbf" 18891 + integrity sha512-Q4EqbIFV0rpCYcdmWY51+H8Vrc0fvP01hPkiSqPEmjjxhm6mqyAuTMdNHNEddLXZzCVQCJujvj6IrHjdAhKjnA== 18892 + dependencies: 18893 + qrcode "^1.5.1" 18862 18894 18863 18895 react-native-reanimated@^3.11.0: 18864 18896 version "3.11.0" ··· 22395 22427 resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-21.1.1.tgz#9096bceebf990d21bb31fa9516e0ede294a77d35" 22396 22428 integrity sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw== 22397 22429 22398 - yargs@^15.1.0: 22430 + yargs@^15.1.0, yargs@^15.3.1: 22399 22431 version "15.4.1" 22400 22432 resolved "https://registry.yarnpkg.com/yargs/-/yargs-15.4.1.tgz#0d87a16de01aee9d8bec2bfbf74f67851730f4f8" 22401 22433 integrity sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==