diff --git a/__tests__/lib/string.test.ts b/__tests__/lib/string.test.ts
index 30072ccb..0da9551e 100644
--- a/__tests__/lib/string.test.ts
+++ b/__tests__/lib/string.test.ts
@@ -1,6 +1,11 @@
import {RichText} from '@atproto/api'
import {parseEmbedPlayerFromUrl} from 'lib/strings/embed-player'
+import {
+ createStarterPackGooglePlayUri,
+ createStarterPackLinkFromAndroidReferrer,
+ parseStarterPackUri,
+} from 'lib/strings/starter-pack'
import {cleanError} from '../../src/lib/strings/errors'
import {createFullHandle, makeValidHandle} from '../../src/lib/strings/handles'
import {enforceLen} from '../../src/lib/strings/helpers'
@@ -796,3 +801,179 @@ describe('parseEmbedPlayerFromUrl', () => {
}
})
})
+
+describe('createStarterPackLinkFromAndroidReferrer', () => {
+ const validOutput = 'at://haileyok.com/app.bsky.graph.starterpack/rkey'
+
+ it('returns a link when input contains utm_source and utm_content', () => {
+ expect(
+ createStarterPackLinkFromAndroidReferrer(
+ 'utm_source=bluesky&utm_content=starterpack_haileyok.com_rkey',
+ ),
+ ).toEqual(validOutput)
+
+ expect(
+ createStarterPackLinkFromAndroidReferrer(
+ 'utm_source=bluesky&utm_content=starterpack_test-lover-9000.com_rkey',
+ ),
+ ).toEqual('at://test-lover-9000.com/app.bsky.graph.starterpack/rkey')
+ })
+
+ it('returns a link when input contains utm_source and utm_content in different order', () => {
+ expect(
+ createStarterPackLinkFromAndroidReferrer(
+ 'utm_content=starterpack_haileyok.com_rkey&utm_source=bluesky',
+ ),
+ ).toEqual(validOutput)
+ })
+
+ it('returns a link when input contains other parameters as well', () => {
+ expect(
+ createStarterPackLinkFromAndroidReferrer(
+ 'utm_source=bluesky&utm_medium=starterpack&utm_content=starterpack_haileyok.com_rkey',
+ ),
+ ).toEqual(validOutput)
+ })
+
+ it('returns null when utm_source is not present', () => {
+ expect(
+ createStarterPackLinkFromAndroidReferrer(
+ 'utm_content=starterpack_haileyok.com_rkey',
+ ),
+ ).toEqual(null)
+ })
+
+ it('returns null when utm_content is not present', () => {
+ expect(
+ createStarterPackLinkFromAndroidReferrer('utm_source=bluesky'),
+ ).toEqual(null)
+ })
+
+ it('returns null when utm_content is malformed', () => {
+ expect(
+ createStarterPackLinkFromAndroidReferrer(
+ 'utm_content=starterpack_haileyok.com',
+ ),
+ ).toEqual(null)
+
+ expect(
+ createStarterPackLinkFromAndroidReferrer('utm_content=starterpack'),
+ ).toEqual(null)
+
+ expect(
+ createStarterPackLinkFromAndroidReferrer(
+ 'utm_content=starterpack_haileyok.com_rkey_more',
+ ),
+ ).toEqual(null)
+
+ expect(
+ createStarterPackLinkFromAndroidReferrer(
+ 'utm_content=notastarterpack_haileyok.com_rkey',
+ ),
+ ).toEqual(null)
+ })
+})
+
+describe('parseStarterPackHttpUri', () => {
+ const baseUri = 'https://bsky.app/start'
+
+ it('returns a valid at uri when http uri is valid', () => {
+ const validHttpUri = `${baseUri}/haileyok.com/rkey`
+ expect(parseStarterPackUri(validHttpUri)).toEqual({
+ name: 'haileyok.com',
+ rkey: 'rkey',
+ })
+
+ const validHttpUri2 = `${baseUri}/haileyok.com/ilovetesting`
+ expect(parseStarterPackUri(validHttpUri2)).toEqual({
+ name: 'haileyok.com',
+ rkey: 'ilovetesting',
+ })
+
+ const validHttpUri3 = `${baseUri}/testlover9000.com/rkey`
+ expect(parseStarterPackUri(validHttpUri3)).toEqual({
+ name: 'testlover9000.com',
+ rkey: 'rkey',
+ })
+ })
+
+ it('returns null when there is no rkey', () => {
+ const validHttpUri = `${baseUri}/haileyok.com`
+ expect(parseStarterPackUri(validHttpUri)).toEqual(null)
+ })
+
+ it('returns null when there is an extra path', () => {
+ const validHttpUri = `${baseUri}/haileyok.com/rkey/other`
+ expect(parseStarterPackUri(validHttpUri)).toEqual(null)
+ })
+
+ it('returns null when there is no handle or rkey', () => {
+ const validHttpUri = `${baseUri}`
+ expect(parseStarterPackUri(validHttpUri)).toEqual(null)
+ })
+
+ it('returns null when the route is not /start or /starter-pack', () => {
+ const validHttpUri = 'https://bsky.app/start/haileyok.com/rkey'
+ expect(parseStarterPackUri(validHttpUri)).toEqual({
+ name: 'haileyok.com',
+ rkey: 'rkey',
+ })
+
+ const validHttpUri2 = 'https://bsky.app/starter-pack/haileyok.com/rkey'
+ expect(parseStarterPackUri(validHttpUri2)).toEqual({
+ name: 'haileyok.com',
+ rkey: 'rkey',
+ })
+
+ const invalidHttpUri = 'https://bsky.app/profile/haileyok.com/rkey'
+ expect(parseStarterPackUri(invalidHttpUri)).toEqual(null)
+ })
+
+ it('returns the at uri when the input is a valid starterpack at uri', () => {
+ const validAtUri = 'at://did:123/app.bsky.graph.starterpack/rkey'
+ expect(parseStarterPackUri(validAtUri)).toEqual({
+ name: 'did:123',
+ rkey: 'rkey',
+ })
+ })
+
+ it('returns null when the at uri has no rkey', () => {
+ const validAtUri = 'at://did:123/app.bsky.graph.starterpack'
+ expect(parseStarterPackUri(validAtUri)).toEqual(null)
+ })
+
+ it('returns null when the collection is not app.bsky.graph.starterpack', () => {
+ const validAtUri = 'at://did:123/app.bsky.graph.list/rkey'
+ expect(parseStarterPackUri(validAtUri)).toEqual(null)
+ })
+
+ it('returns null when the input is undefined', () => {
+ expect(parseStarterPackUri(undefined)).toEqual(null)
+ })
+})
+
+describe('createStarterPackGooglePlayUri', () => {
+ const base =
+ 'https://play.google.com/store/apps/details?id=xyz.blueskyweb.app&referrer=utm_source%3Dbluesky%26utm_medium%3Dstarterpack%26utm_content%3Dstarterpack_'
+
+ it('returns valid google play uri when input is valid', () => {
+ expect(createStarterPackGooglePlayUri('name', 'rkey')).toEqual(
+ `${base}name_rkey`,
+ )
+ })
+
+ it('returns null when no rkey is supplied', () => {
+ // @ts-expect-error test
+ expect(createStarterPackGooglePlayUri('name', undefined)).toEqual(null)
+ })
+
+ it('returns null when no name or rkey are supplied', () => {
+ // @ts-expect-error test
+ expect(createStarterPackGooglePlayUri(undefined, undefined)).toEqual(null)
+ })
+
+ it('returns null when rkey is supplied but no name', () => {
+ // @ts-expect-error test
+ expect(createStarterPackGooglePlayUri(undefined, 'rkey')).toEqual(null)
+ })
+})
diff --git a/app.config.js b/app.config.js
index eafacc6c..57d43058 100644
--- a/app.config.js
+++ b/app.config.js
@@ -39,6 +39,17 @@ module.exports = function (config) {
const IS_TESTFLIGHT = process.env.EXPO_PUBLIC_ENV === 'testflight'
const IS_PRODUCTION = process.env.EXPO_PUBLIC_ENV === 'production'
+ const ASSOCIATED_DOMAINS = [
+ 'applinks:bsky.app',
+ 'applinks:staging.bsky.app',
+ 'appclips:bsky.app',
+ 'appclips:go.bsky.app', // Allows App Clip to work when scanning QR codes
+ // When testing local services, enter an ngrok (et al) domain here. It must use a standard HTTP/HTTPS port.
+ ...(IS_DEV || IS_TESTFLIGHT
+ ? ['appclips:sptesting.haileyok.com', 'applinks:sptesting.haileyok.com']
+ : []),
+ ]
+
const UPDATES_CHANNEL = IS_TESTFLIGHT
? 'testflight'
: IS_PRODUCTION
@@ -83,7 +94,7 @@ module.exports = function (config) {
NSPhotoLibraryUsageDescription:
'Used for profile pictures, posts, and other kinds of content',
},
- associatedDomains: ['applinks:bsky.app', 'applinks:staging.bsky.app'],
+ associatedDomains: ASSOCIATED_DOMAINS,
splash: {
...SPLASH_CONFIG,
dark: DARK_SPLASH_CONFIG,
@@ -202,6 +213,7 @@ module.exports = function (config) {
sounds: PLATFORM === 'ios' ? ['assets/dm.aiff'] : ['assets/dm.mp3'],
},
],
+ './plugins/starterPackAppClipExtension/withStarterPackAppClip.js',
'./plugins/withAndroidManifestPlugin.js',
'./plugins/withAndroidManifestFCMIconPlugin.js',
'./plugins/withAndroidStylesWindowBackgroundPlugin.js',
@@ -234,6 +246,10 @@ module.exports = function (config) {
],
},
},
+ {
+ targetName: 'BlueskyClip',
+ bundleIdentifier: 'xyz.blueskyweb.app.AppClip',
+ },
],
},
},
diff --git a/assets/icons/qrCode_stroke2_corner0_rounded.svg b/assets/icons/qrCode_stroke2_corner0_rounded.svg
new file mode 100644
index 00000000..b17db395
--- /dev/null
+++ b/assets/icons/qrCode_stroke2_corner0_rounded.svg
@@ -0,0 +1 @@
+
diff --git a/assets/icons/starterPack.svg b/assets/icons/starterPack.svg
new file mode 100644
index 00000000..7f0df559
--- /dev/null
+++ b/assets/icons/starterPack.svg
@@ -0,0 +1 @@
+
diff --git a/assets/icons/starter_pack_icon.svg b/assets/icons/starter_pack_icon.svg
new file mode 100644
index 00000000..47a2f49b
--- /dev/null
+++ b/assets/icons/starter_pack_icon.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/assets/logo.png b/assets/logo.png
new file mode 100644
index 00000000..cc2d6603
Binary files /dev/null and b/assets/logo.png differ
diff --git a/bskyweb/cmd/bskyweb/server.go b/bskyweb/cmd/bskyweb/server.go
index 6d32e0e2..96fb07dd 100644
--- a/bskyweb/cmd/bskyweb/server.go
+++ b/bskyweb/cmd/bskyweb/server.go
@@ -223,6 +223,10 @@ func serve(cctx *cli.Context) error {
e.GET("/profile/:handleOrDID/post/:rkey/liked-by", server.WebGeneric)
e.GET("/profile/:handleOrDID/post/:rkey/reposted-by", server.WebGeneric)
+ // starter packs
+ e.GET("/starter-pack/:handleOrDID/:rkey", server.WebGeneric)
+ e.GET("/start/:handleOrDID/:rkey", server.WebGeneric)
+
if linkHost != "" {
linkUrl, err := url.Parse(linkHost)
if err != nil {
diff --git a/bskyweb/static/.well-known/apple-app-site-association b/bskyweb/static/.well-known/apple-app-site-association
index 232acdf2..0a05fa35 100644
--- a/bskyweb/static/.well-known/apple-app-site-association
+++ b/bskyweb/static/.well-known/apple-app-site-association
@@ -1,6 +1,8 @@
{
"applinks": {
- "apps": [],
+ "appclips": {
+ "apps": ["B3LX46C5HS.xyz.blueskyweb.app.AppClip"]
+ },
"details": [
{
"appID": "B3LX46C5HS.xyz.blueskyweb.app",
@@ -10,4 +12,4 @@
}
]
}
-}
\ No newline at end of file
+}
diff --git a/modules/BlueskyClip/AppDelegate.swift b/modules/BlueskyClip/AppDelegate.swift
new file mode 100644
index 00000000..68419495
--- /dev/null
+++ b/modules/BlueskyClip/AppDelegate.swift
@@ -0,0 +1,32 @@
+import UIKit
+
+@main
+class AppDelegate: UIResponder, UIApplicationDelegate {
+ var window: UIWindow?
+ var controller: ViewController?
+
+ func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
+ let window = UIWindow()
+ self.window = UIWindow()
+
+ let controller = ViewController(window: window)
+ self.controller = controller
+
+ window.rootViewController = self.controller
+ window.makeKeyAndVisible()
+
+ return true
+ }
+
+ func application(_ app: UIApplication, open url: URL, options: [UIApplication.OpenURLOptionsKey: Any] = [:]) -> Bool {
+ self.controller?.handleURL(url: url)
+ return true
+ }
+
+ func application(_ application: UIApplication, continue userActivity: NSUserActivity, restorationHandler: @escaping ([UIUserActivityRestoring]?) -> Void) -> Bool {
+ if let incomingURL = userActivity.webpageURL {
+ self.controller?.handleURL(url: incomingURL)
+ }
+ return true
+ }
+}
diff --git a/modules/BlueskyClip/Images.xcassets/AppIcon.appiconset/App-Icon-1024x1024@1x.png b/modules/BlueskyClip/Images.xcassets/AppIcon.appiconset/App-Icon-1024x1024@1x.png
new file mode 100644
index 00000000..75ce4b81
Binary files /dev/null and b/modules/BlueskyClip/Images.xcassets/AppIcon.appiconset/App-Icon-1024x1024@1x.png differ
diff --git a/modules/BlueskyClip/Images.xcassets/AppIcon.appiconset/Contents.json b/modules/BlueskyClip/Images.xcassets/AppIcon.appiconset/Contents.json
new file mode 100644
index 00000000..c3bb428d
--- /dev/null
+++ b/modules/BlueskyClip/Images.xcassets/AppIcon.appiconset/Contents.json
@@ -0,0 +1,14 @@
+{
+ "images" : [
+ {
+ "filename" : "App-Icon-1024x1024@1x.png",
+ "idiom" : "universal",
+ "platform" : "ios",
+ "size" : "1024x1024"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/modules/BlueskyClip/Images.xcassets/Contents.json b/modules/BlueskyClip/Images.xcassets/Contents.json
new file mode 100644
index 00000000..73c00596
--- /dev/null
+++ b/modules/BlueskyClip/Images.xcassets/Contents.json
@@ -0,0 +1,6 @@
+{
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/modules/BlueskyClip/ViewController.swift b/modules/BlueskyClip/ViewController.swift
new file mode 100644
index 00000000..b178644b
--- /dev/null
+++ b/modules/BlueskyClip/ViewController.swift
@@ -0,0 +1,133 @@
+import UIKit
+import WebKit
+import StoreKit
+
+class ViewController: UIViewController, WKScriptMessageHandler, WKNavigationDelegate {
+ let defaults = UserDefaults(suiteName: "group.app.bsky")
+
+ var window: UIWindow
+ var webView: WKWebView?
+
+ var prevUrl: URL?
+ var starterPackUrl: URL?
+
+ init(window: UIWindow) {
+ self.window = window
+ super.init(nibName: nil, bundle: nil)
+ }
+
+ required init?(coder: NSCoder) {
+ fatalError("init(coder:) has not been implemented")
+ }
+
+ override func viewDidLoad() {
+ super.viewDidLoad()
+
+ let contentController = WKUserContentController()
+ contentController.add(self, name: "onMessage")
+ let configuration = WKWebViewConfiguration()
+ configuration.userContentController = contentController
+
+ let webView = WKWebView(frame: self.view.bounds, configuration: configuration)
+ webView.translatesAutoresizingMaskIntoConstraints = false
+ webView.contentMode = .scaleToFill
+ webView.navigationDelegate = self
+ self.view.addSubview(webView)
+ self.webView = webView
+ }
+
+ func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) {
+ guard let response = message.body as? String,
+ let data = response.data(using: .utf8),
+ let payload = try? JSONDecoder().decode(WebViewActionPayload.self, from: data) else {
+ return
+ }
+
+ switch payload.action {
+ case .present:
+ guard let url = self.starterPackUrl else {
+ return
+ }
+
+ self.presentAppStoreOverlay()
+ defaults?.setValue(url.absoluteString, forKey: "starterPackUri")
+
+ case .store:
+ guard let keyToStoreAs = payload.keyToStoreAs, let jsonToStore = payload.jsonToStore else {
+ return
+ }
+
+ self.defaults?.setValue(jsonToStore, forKey: keyToStoreAs)
+ }
+ }
+
+ func webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction) async -> WKNavigationActionPolicy {
+ // Detect when we land on the right URL. This is incase of a short link opening the app clip
+ guard let url = navigationAction.request.url else {
+ return .allow
+ }
+
+ // Store the previous one to compare later, but only set starterPackUrl when we find the right one
+ prevUrl = url
+ // pathComponents starts with "/" as the first component, then each path name. so...
+ // ["/", "start", "name", "rkey"]
+ if url.pathComponents.count == 4,
+ url.pathComponents[1] == "start" {
+ self.starterPackUrl = url
+ }
+
+ return .allow
+ }
+
+ func handleURL(url: URL) {
+ let urlString = "\(url.absoluteString)?clip=true"
+ if let url = URL(string: urlString) {
+ self.webView?.load(URLRequest(url: url))
+ }
+ }
+
+ func presentAppStoreOverlay() {
+ guard let windowScene = self.window.windowScene else {
+ return
+ }
+
+ let configuration = SKOverlay.AppClipConfiguration(position: .bottomRaised)
+ let overlay = SKOverlay(configuration: configuration)
+
+ overlay.present(in: windowScene)
+ }
+
+ func getHost(_ url: URL?) -> String? {
+ if #available(iOS 16.0, *) {
+ return url?.host()
+ } else {
+ return url?.host
+ }
+ }
+
+ func getQuery(_ url: URL?) -> String? {
+ if #available(iOS 16.0, *) {
+ return url?.query()
+ } else {
+ return url?.query
+ }
+ }
+
+ func urlMatchesPrevious(_ url: URL?) -> Bool {
+ if #available(iOS 16.0, *) {
+ return url?.query() == prevUrl?.query() && url?.host() == prevUrl?.host() && url?.query() == prevUrl?.query()
+ } else {
+ return url?.query == prevUrl?.query && url?.host == prevUrl?.host && url?.query == prevUrl?.query
+ }
+ }
+}
+
+struct WebViewActionPayload: Decodable {
+ enum Action: String, Decodable {
+ case present, store
+ }
+
+ let action: Action
+ let keyToStoreAs: String?
+ let jsonToStore: String?
+}
diff --git a/modules/expo-bluesky-swiss-army/android/build.gradle b/modules/expo-bluesky-swiss-army/android/build.gradle
new file mode 100644
index 00000000..b031cde5
--- /dev/null
+++ b/modules/expo-bluesky-swiss-army/android/build.gradle
@@ -0,0 +1,47 @@
+apply plugin: 'com.android.library'
+
+group = 'expo.modules.blueskyswissarmy'
+version = '0.6.0'
+
+def expoModulesCorePlugin = new File(project(":expo-modules-core").projectDir.absolutePath, "ExpoModulesCorePlugin.gradle")
+apply from: expoModulesCorePlugin
+applyKotlinExpoModulesCorePlugin()
+useCoreDependencies()
+useExpoPublishing()
+
+// If you want to use the managed Android SDK versions from expo-modules-core, set this to true.
+// The Android SDK versions will be bumped from time to time in SDK releases and may introduce breaking changes in your module code.
+// Most of the time, you may like to manage the Android SDK versions yourself.
+def useManagedAndroidSdkVersions = false
+if (useManagedAndroidSdkVersions) {
+ useDefaultAndroidSdkVersions()
+} else {
+ buildscript {
+ // Simple helper that allows the root project to override versions declared by this library.
+ ext.safeExtGet = { prop, fallback ->
+ rootProject.ext.has(prop) ? rootProject.ext.get(prop) : fallback
+ }
+ }
+ project.android {
+ compileSdkVersion safeExtGet("compileSdkVersion", 34)
+ defaultConfig {
+ minSdkVersion safeExtGet("minSdkVersion", 21)
+ targetSdkVersion safeExtGet("targetSdkVersion", 34)
+ }
+ }
+}
+
+android {
+ namespace "expo.modules.blueskyswissarmy"
+ defaultConfig {
+ versionCode 1
+ versionName "0.6.0"
+ }
+ lintOptions {
+ abortOnError false
+ }
+}
+
+dependencies {
+ implementation("com.android.installreferrer:installreferrer:2.2")
+}
diff --git a/modules/expo-bluesky-swiss-army/android/src/main/AndroidManifest.xml b/modules/expo-bluesky-swiss-army/android/src/main/AndroidManifest.xml
new file mode 100644
index 00000000..bdae66c8
--- /dev/null
+++ b/modules/expo-bluesky-swiss-army/android/src/main/AndroidManifest.xml
@@ -0,0 +1,2 @@
+
+
diff --git a/modules/expo-bluesky-swiss-army/android/src/main/java/expo/modules/blueskyswissarmy/deviceprefs/ExpoBlueskyDevicePrefsModule.kt b/modules/expo-bluesky-swiss-army/android/src/main/java/expo/modules/blueskyswissarmy/deviceprefs/ExpoBlueskyDevicePrefsModule.kt
new file mode 100644
index 00000000..29017f17
--- /dev/null
+++ b/modules/expo-bluesky-swiss-army/android/src/main/java/expo/modules/blueskyswissarmy/deviceprefs/ExpoBlueskyDevicePrefsModule.kt
@@ -0,0 +1,10 @@
+package expo.modules.blueskyswissarmy.deviceprefs
+
+import expo.modules.kotlin.modules.Module
+import expo.modules.kotlin.modules.ModuleDefinition
+
+class ExpoBlueskyDevicePrefsModule : Module() {
+ override fun definition() = ModuleDefinition {
+ Name("ExpoBlueskyDevicePrefs")
+ }
+}
diff --git a/modules/expo-bluesky-swiss-army/android/src/main/java/expo/modules/blueskyswissarmy/referrer/ExpoBlueskyReferrerModule.kt b/modules/expo-bluesky-swiss-army/android/src/main/java/expo/modules/blueskyswissarmy/referrer/ExpoBlueskyReferrerModule.kt
new file mode 100644
index 00000000..3589b364
--- /dev/null
+++ b/modules/expo-bluesky-swiss-army/android/src/main/java/expo/modules/blueskyswissarmy/referrer/ExpoBlueskyReferrerModule.kt
@@ -0,0 +1,54 @@
+package expo.modules.blueskyswissarmy.referrer
+
+import android.util.Log
+import com.android.installreferrer.api.InstallReferrerClient
+import com.android.installreferrer.api.InstallReferrerStateListener
+import expo.modules.kotlin.modules.Module
+import expo.modules.kotlin.modules.ModuleDefinition
+import expo.modules.kotlin.Promise
+
+class ExpoBlueskyReferrerModule : Module() {
+ override fun definition() = ModuleDefinition {
+ Name("ExpoBlueskyReferrer")
+
+ AsyncFunction("getGooglePlayReferrerInfoAsync") { promise: Promise ->
+ val referrerClient = InstallReferrerClient.newBuilder(appContext.reactContext).build()
+ referrerClient.startConnection(object : InstallReferrerStateListener {
+ override fun onInstallReferrerSetupFinished(responseCode: Int) {
+ if (responseCode == InstallReferrerClient.InstallReferrerResponse.OK) {
+ Log.d("ExpoGooglePlayReferrer", "Successfully retrieved referrer info.")
+
+ val response = referrerClient.installReferrer
+ Log.d("ExpoGooglePlayReferrer", "Install referrer: ${response.installReferrer}")
+
+ promise.resolve(
+ mapOf(
+ "installReferrer" to response.installReferrer,
+ "clickTimestamp" to response.referrerClickTimestampSeconds,
+ "installTimestamp" to response.installBeginTimestampSeconds
+ )
+ )
+ } else {
+ Log.d("ExpoGooglePlayReferrer", "Failed to get referrer info. Unknown error.")
+ promise.reject(
+ "ERR_GOOGLE_PLAY_REFERRER_UNKNOWN",
+ "Failed to get referrer info",
+ Exception("Failed to get referrer info")
+ )
+ }
+ referrerClient.endConnection()
+ }
+
+ override fun onInstallReferrerServiceDisconnected() {
+ Log.d("ExpoGooglePlayReferrer", "Failed to get referrer info. Service disconnected.")
+ referrerClient.endConnection()
+ promise.reject(
+ "ERR_GOOGLE_PLAY_REFERRER_DISCONNECTED",
+ "Failed to get referrer info",
+ Exception("Failed to get referrer info")
+ )
+ }
+ })
+ }
+ }
+}
\ No newline at end of file
diff --git a/modules/expo-bluesky-swiss-army/expo-module.config.json b/modules/expo-bluesky-swiss-army/expo-module.config.json
new file mode 100644
index 00000000..730bc611
--- /dev/null
+++ b/modules/expo-bluesky-swiss-army/expo-module.config.json
@@ -0,0 +1,12 @@
+{
+ "platforms": ["ios", "tvos", "android", "web"],
+ "ios": {
+ "modules": ["ExpoBlueskyDevicePrefsModule", "ExpoBlueskyReferrerModule"]
+ },
+ "android": {
+ "modules": [
+ "expo.modules.blueskyswissarmy.deviceprefs.ExpoBlueskyDevicePrefsModule",
+ "expo.modules.blueskyswissarmy.referrer.ExpoBlueskyReferrerModule"
+ ]
+ }
+}
diff --git a/modules/expo-bluesky-swiss-army/index.ts b/modules/expo-bluesky-swiss-army/index.ts
new file mode 100644
index 00000000..1b2f8924
--- /dev/null
+++ b/modules/expo-bluesky-swiss-army/index.ts
@@ -0,0 +1,4 @@
+import * as DevicePrefs from './src/DevicePrefs'
+import * as Referrer from './src/Referrer'
+
+export {DevicePrefs, Referrer}
diff --git a/modules/expo-bluesky-swiss-army/ios/DevicePrefs/ExpoBlueskyDevicePrefsModule.swift b/modules/expo-bluesky-swiss-army/ios/DevicePrefs/ExpoBlueskyDevicePrefsModule.swift
new file mode 100644
index 00000000..b13a9fe3
--- /dev/null
+++ b/modules/expo-bluesky-swiss-army/ios/DevicePrefs/ExpoBlueskyDevicePrefsModule.swift
@@ -0,0 +1,23 @@
+import ExpoModulesCore
+
+public class ExpoBlueskyDevicePrefsModule: Module {
+ func getDefaults(_ useAppGroup: Bool) -> UserDefaults? {
+ if useAppGroup {
+ return UserDefaults(suiteName: "group.app.bsky")
+ } else {
+ return UserDefaults.standard
+ }
+ }
+
+ public func definition() -> ModuleDefinition {
+ Name("ExpoBlueskyDevicePrefs")
+
+ AsyncFunction("getStringValueAsync") { (key: String, useAppGroup: Bool) in
+ return self.getDefaults(useAppGroup)?.string(forKey: key)
+ }
+
+ AsyncFunction("setStringValueAsync") { (key: String, value: String?, useAppGroup: Bool) in
+ self.getDefaults(useAppGroup)?.setValue(value, forKey: key)
+ }
+ }
+}
diff --git a/modules/expo-bluesky-swiss-army/ios/ExpoBlueskySwissArmy.podspec b/modules/expo-bluesky-swiss-army/ios/ExpoBlueskySwissArmy.podspec
new file mode 100644
index 00000000..be4b0eae
--- /dev/null
+++ b/modules/expo-bluesky-swiss-army/ios/ExpoBlueskySwissArmy.podspec
@@ -0,0 +1,21 @@
+Pod::Spec.new do |s|
+ s.name = 'ExpoBlueskySwissArmy'
+ s.version = '1.0.0'
+ s.summary = 'A collection of native tools for Bluesky'
+ s.description = 'A collection of native tools for Bluesky'
+ s.author = ''
+ s.homepage = 'https://github.com/bluesky-social/social-app'
+ s.platforms = { :ios => '13.4', :tvos => '13.4' }
+ s.source = { git: '' }
+ s.static_framework = true
+
+ s.dependency 'ExpoModulesCore'
+
+ # Swift/Objective-C compatibility
+ s.pod_target_xcconfig = {
+ 'DEFINES_MODULE' => 'YES',
+ 'SWIFT_COMPILATION_MODE' => 'wholemodule'
+ }
+
+ s.source_files = "**/*.{h,m,mm,swift,hpp,cpp}"
+end
diff --git a/modules/expo-bluesky-swiss-army/ios/Referrer/ExpoBlueskyReferrerModule.swift b/modules/expo-bluesky-swiss-army/ios/Referrer/ExpoBlueskyReferrerModule.swift
new file mode 100644
index 00000000..fd28c51e
--- /dev/null
+++ b/modules/expo-bluesky-swiss-army/ios/Referrer/ExpoBlueskyReferrerModule.swift
@@ -0,0 +1,7 @@
+import ExpoModulesCore
+
+public class ExpoBlueskyReferrerModule: Module {
+ public func definition() -> ModuleDefinition {
+ Name("ExpoBlueskyReferrer")
+ }
+}
diff --git a/modules/expo-bluesky-swiss-army/src/DevicePrefs/index.ios.ts b/modules/expo-bluesky-swiss-army/src/DevicePrefs/index.ios.ts
new file mode 100644
index 00000000..42718508
--- /dev/null
+++ b/modules/expo-bluesky-swiss-army/src/DevicePrefs/index.ios.ts
@@ -0,0 +1,18 @@
+import {requireNativeModule} from 'expo-modules-core'
+
+const NativeModule = requireNativeModule('ExpoBlueskyDevicePrefs')
+
+export function getStringValueAsync(
+ key: string,
+ useAppGroup?: boolean,
+): Promise {
+ return NativeModule.getStringValueAsync(key, useAppGroup)
+}
+
+export function setStringValueAsync(
+ key: string,
+ value: string | null,
+ useAppGroup?: boolean,
+): Promise {
+ return NativeModule.setStringValueAsync(key, value, useAppGroup)
+}
diff --git a/modules/expo-bluesky-swiss-army/src/DevicePrefs/index.ts b/modules/expo-bluesky-swiss-army/src/DevicePrefs/index.ts
new file mode 100644
index 00000000..f1eee6c2
--- /dev/null
+++ b/modules/expo-bluesky-swiss-army/src/DevicePrefs/index.ts
@@ -0,0 +1,16 @@
+import {NotImplementedError} from '../NotImplemented'
+
+export function getStringValueAsync(
+ key: string,
+ useAppGroup?: boolean,
+): Promise {
+ throw new NotImplementedError({key, useAppGroup})
+}
+
+export function setStringValueAsync(
+ key: string,
+ value: string | null,
+ useAppGroup?: boolean,
+): Promise {
+ throw new NotImplementedError({key, value, useAppGroup})
+}
diff --git a/modules/expo-bluesky-swiss-army/src/NotImplemented.ts b/modules/expo-bluesky-swiss-army/src/NotImplemented.ts
new file mode 100644
index 00000000..876cd7b3
--- /dev/null
+++ b/modules/expo-bluesky-swiss-army/src/NotImplemented.ts
@@ -0,0 +1,16 @@
+import {Platform} from 'react-native'
+
+export class NotImplementedError extends Error {
+ constructor(params = {}) {
+ if (__DEV__) {
+ const caller = new Error().stack?.split('\n')[2]
+ super(
+ `Not implemented on ${Platform.OS}. Given params: ${JSON.stringify(
+ params,
+ )} ${caller}`,
+ )
+ } else {
+ super('Not implemented')
+ }
+ }
+}
diff --git a/modules/expo-bluesky-swiss-army/src/Referrer/index.android.ts b/modules/expo-bluesky-swiss-army/src/Referrer/index.android.ts
new file mode 100644
index 00000000..06dfd2d0
--- /dev/null
+++ b/modules/expo-bluesky-swiss-army/src/Referrer/index.android.ts
@@ -0,0 +1,9 @@
+import {requireNativeModule} from 'expo'
+
+import {GooglePlayReferrerInfo} from './types'
+
+export const NativeModule = requireNativeModule('ExpoBlueskyReferrer')
+
+export function getGooglePlayReferrerInfoAsync(): Promise {
+ return NativeModule.getGooglePlayReferrerInfoAsync()
+}
diff --git a/modules/expo-bluesky-swiss-army/src/Referrer/index.ts b/modules/expo-bluesky-swiss-army/src/Referrer/index.ts
new file mode 100644
index 00000000..25539855
--- /dev/null
+++ b/modules/expo-bluesky-swiss-army/src/Referrer/index.ts
@@ -0,0 +1,7 @@
+import {NotImplementedError} from '../NotImplemented'
+import {GooglePlayReferrerInfo} from './types'
+
+// @ts-ignore throws
+export function getGooglePlayReferrerInfoAsync(): Promise {
+ throw new NotImplementedError()
+}
diff --git a/modules/expo-bluesky-swiss-army/src/Referrer/types.ts b/modules/expo-bluesky-swiss-army/src/Referrer/types.ts
new file mode 100644
index 00000000..55faaff4
--- /dev/null
+++ b/modules/expo-bluesky-swiss-army/src/Referrer/types.ts
@@ -0,0 +1,7 @@
+export type GooglePlayReferrerInfo =
+ | {
+ installReferrer?: string
+ clickTimestamp?: number
+ installTimestamp?: number
+ }
+ | undefined
diff --git a/package.json b/package.json
index bcd5a1d3..65777030 100644
--- a/package.json
+++ b/package.json
@@ -49,7 +49,7 @@
"open-analyzer": "EXPO_PUBLIC_OPEN_ANALYZER=1 yarn build-web"
},
"dependencies": {
- "@atproto/api": "^0.12.20",
+ "@atproto/api": "0.12.22-next.0",
"@bam.tech/react-native-image-resizer": "^3.0.4",
"@braintree/sanitize-url": "^6.0.2",
"@discord/bottom-sheet": "bluesky-social/react-native-bottom-sheet",
@@ -177,6 +177,7 @@
"react-native-pager-view": "6.2.3",
"react-native-picker-select": "^9.1.3",
"react-native-progress": "bluesky-social/react-native-progress",
+ "react-native-qrcode-styled": "^0.3.1",
"react-native-reanimated": "^3.11.0",
"react-native-root-siblings": "^4.1.1",
"react-native-safe-area-context": "4.10.1",
@@ -205,7 +206,7 @@
"@babel/preset-env": "^7.20.0",
"@babel/runtime": "^7.20.0",
"@did-plc/server": "^0.0.1",
- "@expo/config-plugins": "7.8.0",
+ "@expo/config-plugins": "8.0.4",
"@expo/prebuild-config": "6.7.0",
"@lingui/cli": "^4.5.0",
"@lingui/macro": "^4.5.0",
diff --git a/plugins/starterPackAppClipExtension/withAppEntitlements.js b/plugins/starterPackAppClipExtension/withAppEntitlements.js
new file mode 100644
index 00000000..1bffd820
--- /dev/null
+++ b/plugins/starterPackAppClipExtension/withAppEntitlements.js
@@ -0,0 +1,16 @@
+const {withEntitlementsPlist} = require('@expo/config-plugins')
+
+const withAppEntitlements = config => {
+ // eslint-disable-next-line no-shadow
+ return withEntitlementsPlist(config, async config => {
+ config.modResults['com.apple.security.application-groups'] = [
+ `group.app.bsky`,
+ ]
+ config.modResults[
+ 'com.apple.developer.associated-appclip-app-identifiers'
+ ] = [`$(AppIdentifierPrefix)${config.ios.bundleIdentifier}.AppClip`]
+ return config
+ })
+}
+
+module.exports = {withAppEntitlements}
diff --git a/plugins/starterPackAppClipExtension/withClipEntitlements.js b/plugins/starterPackAppClipExtension/withClipEntitlements.js
new file mode 100644
index 00000000..77636b5c
--- /dev/null
+++ b/plugins/starterPackAppClipExtension/withClipEntitlements.js
@@ -0,0 +1,32 @@
+const {withInfoPlist} = require('@expo/config-plugins')
+const plist = require('@expo/plist')
+const path = require('path')
+const fs = require('fs')
+
+const withClipEntitlements = (config, {targetName}) => {
+ // eslint-disable-next-line no-shadow
+ return withInfoPlist(config, config => {
+ const entitlementsPath = path.join(
+ config.modRequest.platformProjectRoot,
+ targetName,
+ `${targetName}.entitlements`,
+ )
+
+ const appClipEntitlements = {
+ 'com.apple.security.application-groups': [`group.app.bsky`],
+ 'com.apple.developer.parent-application-identifiers': [
+ `$(AppIdentifierPrefix)${config.ios.bundleIdentifier}`,
+ ],
+ 'com.apple.developer.associated-domains': config.ios.associatedDomains,
+ }
+
+ fs.mkdirSync(path.dirname(entitlementsPath), {
+ recursive: true,
+ })
+ fs.writeFileSync(entitlementsPath, plist.default.build(appClipEntitlements))
+
+ return config
+ })
+}
+
+module.exports = {withClipEntitlements}
diff --git a/plugins/starterPackAppClipExtension/withClipInfoPlist.js b/plugins/starterPackAppClipExtension/withClipInfoPlist.js
new file mode 100644
index 00000000..59fbed1a
--- /dev/null
+++ b/plugins/starterPackAppClipExtension/withClipInfoPlist.js
@@ -0,0 +1,38 @@
+const {withInfoPlist} = require('@expo/config-plugins')
+const plist = require('@expo/plist')
+const path = require('path')
+const fs = require('fs')
+
+const withClipInfoPlist = (config, {targetName}) => {
+ // eslint-disable-next-line no-shadow
+ return withInfoPlist(config, config => {
+ const targetPath = path.join(
+ config.modRequest.platformProjectRoot,
+ targetName,
+ 'Info.plist',
+ )
+
+ const newPlist = plist.default.build({
+ NSAppClip: {
+ NSAppClipRequestEphemeralUserNotification: false,
+ NSAppClipRequestLocationConfirmation: false,
+ },
+ UILaunchScreen: {},
+ CFBundleName: '$(PRODUCT_NAME)',
+ CFBundleIdentifier: '$(PRODUCT_BUNDLE_IDENTIFIER)',
+ CFBundleVersion: '$(CURRENT_PROJECT_VERSION)',
+ CFBundleExecutable: '$(EXECUTABLE_NAME)',
+ CFBundlePackageType: '$(PRODUCT_BUNDLE_PACKAGE_TYPE)',
+ CFBundleShortVersionString: config.version,
+ CFBundleIconName: 'AppIcon',
+ UIViewControllerBasedStatusBarAppearance: 'NO',
+ })
+
+ fs.mkdirSync(path.dirname(targetPath), {recursive: true})
+ fs.writeFileSync(targetPath, newPlist)
+
+ return config
+ })
+}
+
+module.exports = {withClipInfoPlist}
diff --git a/plugins/starterPackAppClipExtension/withFiles.js b/plugins/starterPackAppClipExtension/withFiles.js
new file mode 100644
index 00000000..ad99f5ae
--- /dev/null
+++ b/plugins/starterPackAppClipExtension/withFiles.js
@@ -0,0 +1,40 @@
+const {withXcodeProject} = require('@expo/config-plugins')
+const path = require('path')
+const fs = require('fs')
+
+const FILES = ['AppDelegate.swift', 'ViewController.swift']
+
+const withFiles = (config, {targetName}) => {
+ // eslint-disable-next-line no-shadow
+ return withXcodeProject(config, config => {
+ const basePath = path.join(
+ config.modRequest.projectRoot,
+ 'modules',
+ targetName,
+ )
+
+ for (const file of FILES) {
+ const sourcePath = path.join(basePath, file)
+ const targetPath = path.join(
+ config.modRequest.platformProjectRoot,
+ targetName,
+ file,
+ )
+
+ fs.mkdirSync(path.dirname(targetPath), {recursive: true})
+ fs.copyFileSync(sourcePath, targetPath)
+ }
+
+ const imagesBasePath = path.join(basePath, 'Images.xcassets')
+ const imagesTargetPath = path.join(
+ config.modRequest.platformProjectRoot,
+ targetName,
+ 'Images.xcassets',
+ )
+ fs.cpSync(imagesBasePath, imagesTargetPath, {recursive: true})
+
+ return config
+ })
+}
+
+module.exports = {withFiles}
diff --git a/plugins/starterPackAppClipExtension/withStarterPackAppClip.js b/plugins/starterPackAppClipExtension/withStarterPackAppClip.js
new file mode 100644
index 00000000..1e3f0b70
--- /dev/null
+++ b/plugins/starterPackAppClipExtension/withStarterPackAppClip.js
@@ -0,0 +1,40 @@
+const {withPlugins} = require('@expo/config-plugins')
+const {withAppEntitlements} = require('./withAppEntitlements')
+const {withClipEntitlements} = require('./withClipEntitlements')
+const {withClipInfoPlist} = require('./withClipInfoPlist')
+const {withFiles} = require('./withFiles')
+const {withXcodeTarget} = require('./withXcodeTarget')
+
+const APP_CLIP_TARGET_NAME = 'BlueskyClip'
+
+const withStarterPackAppClip = config => {
+ return withPlugins(config, [
+ withAppEntitlements,
+ [
+ withClipEntitlements,
+ {
+ targetName: APP_CLIP_TARGET_NAME,
+ },
+ ],
+ [
+ withClipInfoPlist,
+ {
+ targetName: APP_CLIP_TARGET_NAME,
+ },
+ ],
+ [
+ withFiles,
+ {
+ targetName: APP_CLIP_TARGET_NAME,
+ },
+ ],
+ [
+ withXcodeTarget,
+ {
+ targetName: APP_CLIP_TARGET_NAME,
+ },
+ ],
+ ])
+}
+
+module.exports = withStarterPackAppClip
diff --git a/plugins/starterPackAppClipExtension/withXcodeTarget.js b/plugins/starterPackAppClipExtension/withXcodeTarget.js
new file mode 100644
index 00000000..61d5f81b
--- /dev/null
+++ b/plugins/starterPackAppClipExtension/withXcodeTarget.js
@@ -0,0 +1,91 @@
+const {withXcodeProject} = require('@expo/config-plugins')
+
+const BUILD_PHASE_FILES = ['AppDelegate.swift', 'ViewController.swift']
+
+const withXcodeTarget = (config, {targetName}) => {
+ // eslint-disable-next-line no-shadow
+ return withXcodeProject(config, config => {
+ const pbxProject = config.modResults
+
+ const target = pbxProject.addTarget(targetName, 'application', targetName)
+ target.pbxNativeTarget.productType = `"com.apple.product-type.application.on-demand-install-capable"`
+ pbxProject.addBuildPhase(
+ BUILD_PHASE_FILES.map(f => `${targetName}/${f}`),
+ 'PBXSourcesBuildPhase',
+ 'Sources',
+ target.uuid,
+ 'application',
+ '"AppClips"',
+ )
+ pbxProject.addBuildPhase(
+ [`${targetName}/Images.xcassets`],
+ 'PBXResourcesBuildPhase',
+ 'Resources',
+ target.uuid,
+ 'application',
+ '"AppClips"',
+ )
+
+ const pbxGroup = pbxProject.addPbxGroup([
+ 'AppDelegate.swift',
+ 'ViewController.swift',
+ 'Images.xcassets',
+ `${targetName}.entitlements`,
+ 'Info.plist',
+ ])
+
+ pbxProject.addFile(`${targetName}/Info.plist`, pbxGroup.uuid)
+ const configurations = pbxProject.pbxXCBuildConfigurationSection()
+ for (const key in configurations) {
+ if (typeof configurations[key].buildSettings !== 'undefined') {
+ const buildSettingsObj = configurations[key].buildSettings
+ if (
+ typeof buildSettingsObj.PRODUCT_NAME !== 'undefined' &&
+ buildSettingsObj.PRODUCT_NAME === `"${targetName}"`
+ ) {
+ buildSettingsObj.CLANG_ENABLE_MODULES = 'YES'
+ buildSettingsObj.INFOPLIST_FILE = `"${targetName}/Info.plist"`
+ buildSettingsObj.CODE_SIGN_ENTITLEMENTS = `"${targetName}/${targetName}.entitlements"`
+ buildSettingsObj.CODE_SIGN_STYLE = 'Automatic'
+ buildSettingsObj.CURRENT_PROJECT_VERSION = `"${
+ process.env.BSKY_IOS_BUILD_NUMBER ?? '1'
+ }"`
+ buildSettingsObj.GENERATE_INFOPLIST_FILE = 'YES'
+ buildSettingsObj.MARKETING_VERSION = `"${config.version}"`
+ buildSettingsObj.PRODUCT_BUNDLE_IDENTIFIER = `"${config.ios?.bundleIdentifier}.AppClip"`
+ buildSettingsObj.SWIFT_EMIT_LOC_STRINGS = 'YES'
+ buildSettingsObj.SWIFT_VERSION = '5.0'
+ buildSettingsObj.TARGETED_DEVICE_FAMILY = `"1"`
+ buildSettingsObj.DEVELOPMENT_TEAM = 'B3LX46C5HS'
+ buildSettingsObj.IPHONEOS_DEPLOYMENT_TARGET = '14.0'
+ buildSettingsObj.ASSETCATALOG_COMPILER_APPICON_NAME = 'AppIcon'
+ }
+ }
+ }
+
+ pbxProject.addTargetAttribute('DevelopmentTeam', 'B3LX46C5HS', targetName)
+
+ if (!pbxProject.hash.project.objects.PBXTargetDependency) {
+ pbxProject.hash.project.objects.PBXTargetDependency = {}
+ }
+ if (!pbxProject.hash.project.objects.PBXContainerItemProxy) {
+ pbxProject.hash.project.objects.PBXContainerItemProxy = {}
+ }
+ pbxProject.addTargetDependency(pbxProject.getFirstTarget().uuid, [
+ target.uuid,
+ ])
+
+ pbxProject.addBuildPhase(
+ [`${targetName}.app`],
+ 'PBXCopyFilesBuildPhase',
+ 'Embed App Clips',
+ pbxProject.getFirstTarget().uuid,
+ 'application',
+ '"AppClips"',
+ )
+
+ return config
+ })
+}
+
+module.exports = {withXcodeTarget}
diff --git a/scripts/updateExtensions.sh b/scripts/updateExtensions.sh
index f3e972aa..b01134ee 100755
--- a/scripts/updateExtensions.sh
+++ b/scripts/updateExtensions.sh
@@ -1,6 +1,7 @@
#!/bin/bash
IOS_SHARE_EXTENSION_DIRECTORY="./ios/Share-with-Bluesky"
IOS_NOTIFICATION_EXTENSION_DIRECTORY="./ios/BlueskyNSE"
+IOS_APP_CLIP_DIRECTORY="./ios/BlueskyClip"
MODULES_DIRECTORY="./modules"
if [ ! -d $IOS_SHARE_EXTENSION_DIRECTORY ]; then
@@ -16,3 +17,11 @@ if [ ! -d $IOS_NOTIFICATION_EXTENSION_DIRECTORY ]; then
else
cp -R $IOS_NOTIFICATION_EXTENSION_DIRECTORY $MODULES_DIRECTORY
fi
+
+
+if [ ! -d $IOS_APP_CLIP_DIRECTORY ]; then
+ echo "$IOS_APP_CLIP_DIRECTORY not found inside of your iOS project."
+ exit 1
+else
+ cp -R $IOS_APP_CLIP_DIRECTORY $MODULES_DIRECTORY
+fi
diff --git a/src/App.native.tsx b/src/App.native.tsx
index 4c73d875..639276a1 100644
--- a/src/App.native.tsx
+++ b/src/App.native.tsx
@@ -46,11 +46,13 @@ import {readLastActiveAccount} from '#/state/session/util'
import {Provider as ShellStateProvider} from '#/state/shell'
import {Provider as LoggedOutViewProvider} from '#/state/shell/logged-out'
import {Provider as SelectedFeedProvider} from '#/state/shell/selected-feed'
+import {Provider as StarterPackProvider} from '#/state/shell/starter-pack'
import {TestCtrls} from '#/view/com/testing/TestCtrls'
import * as Toast from '#/view/com/util/Toast'
import {Shell} from '#/view/shell'
import {ThemeProvider as Alf} from '#/alf'
import {useColorModeTheme} from '#/alf/util/useColorModeTheme'
+import {useStarterPackEntry} from '#/components/hooks/useStarterPackEntry'
import {Provider as PortalProvider} from '#/components/Portal'
import {Splash} from '#/Splash'
import {BackgroundNotificationPreferencesProvider} from '../modules/expo-background-notification-handler/src/BackgroundNotificationHandlerProvider'
@@ -67,6 +69,7 @@ function InnerApp() {
const {_} = useLingui()
useIntentHandler()
+ const hasCheckedReferrer = useStarterPackEntry()
// init
useEffect(() => {
@@ -98,7 +101,7 @@ function InnerApp() {
-
+
-
+
+
+
diff --git a/src/App.web.tsx b/src/App.web.tsx
index 00939c9e..31a59d97 100644
--- a/src/App.web.tsx
+++ b/src/App.web.tsx
@@ -35,11 +35,13 @@ import {readLastActiveAccount} from '#/state/session/util'
import {Provider as ShellStateProvider} from '#/state/shell'
import {Provider as LoggedOutViewProvider} from '#/state/shell/logged-out'
import {Provider as SelectedFeedProvider} from '#/state/shell/selected-feed'
+import {Provider as StarterPackProvider} from '#/state/shell/starter-pack'
import * as Toast from '#/view/com/util/Toast'
import {ToastContainer} from '#/view/com/util/Toast.web'
import {Shell} from '#/view/shell/index'
import {ThemeProvider as Alf} from '#/alf'
import {useColorModeTheme} from '#/alf/util/useColorModeTheme'
+import {useStarterPackEntry} from '#/components/hooks/useStarterPackEntry'
import {Provider as PortalProvider} from '#/components/Portal'
import {BackgroundNotificationPreferencesProvider} from '../modules/expo-background-notification-handler/src/BackgroundNotificationHandlerProvider'
import I18nProvider from './locale/i18nProvider'
@@ -52,6 +54,7 @@ function InnerApp() {
const theme = useColorModeTheme()
const {_} = useLingui()
useIntentHandler()
+ const hasCheckedReferrer = useStarterPackEntry()
// init
useEffect(() => {
@@ -77,7 +80,7 @@ function InnerApp() {
}, [_])
// wait for session to resume
- if (!isReady) return null
+ if (!isReady || !hasCheckedReferrer) return null
return (
@@ -146,7 +149,9 @@ function App() {
-
+
+
+
diff --git a/src/Navigation.tsx b/src/Navigation.tsx
index f2b7cd91..5cb4f410 100644
--- a/src/Navigation.tsx
+++ b/src/Navigation.tsx
@@ -43,6 +43,8 @@ import HashtagScreen from '#/screens/Hashtag'
import {ModerationScreen} from '#/screens/Moderation'
import {ProfileKnownFollowersScreen} from '#/screens/Profile/KnownFollowers'
import {ProfileLabelerLikedByScreen} from '#/screens/Profile/ProfileLabelerLikedBy'
+import {StarterPackScreen} from '#/screens/StarterPack/StarterPackScreen'
+import {Wizard} from '#/screens/StarterPack/Wizard'
import {init as initAnalytics} from './lib/analytics/analytics'
import {useWebScrollRestoration} from './lib/hooks/useWebScrollRestoration'
import {attachRouteToLogEvents, logEvent} from './lib/statsig/statsig'
@@ -317,6 +319,21 @@ function commonScreens(Stack: typeof HomeTab, unreadCountLabel?: string) {
getComponent={() => FeedsScreen}
options={{title: title(msg`Feeds`)}}
/>
+ StarterPackScreen}
+ options={{title: title(msg`Starter Pack`), requireAuth: true}}
+ />
+ Wizard}
+ options={{title: title(msg`Create a starter pack`), requireAuth: true}}
+ />
+ Wizard}
+ options={{title: title(msg`Edit your starter pack`), requireAuth: true}}
+ />
>
)
}
@@ -371,6 +388,7 @@ function HomeTabNavigator() {
contentStyle: pal.view,
}}>
HomeScreen} />
+ HomeScreen} />
{commonScreens(HomeTab)}
)
@@ -507,6 +525,11 @@ const FlatNavigator = () => {
getComponent={() => MessagesScreen}
options={{title: title(msg`Messages`), requireAuth: true}}
/>
+ HomeScreen}
+ options={{title: title(msg`Home`)}}
+ />
{commonScreens(Flat as typeof HomeTab, numUnread)}
)
diff --git a/src/components/LinearGradientBackground.tsx b/src/components/LinearGradientBackground.tsx
new file mode 100644
index 00000000..f516b19f
--- /dev/null
+++ b/src/components/LinearGradientBackground.tsx
@@ -0,0 +1,23 @@
+import React from 'react'
+import {StyleProp, ViewStyle} from 'react-native'
+import {LinearGradient} from 'expo-linear-gradient'
+
+import {gradients} from '#/alf/tokens'
+
+export function LinearGradientBackground({
+ style,
+ children,
+}: {
+ style: StyleProp
+ children: React.ReactNode
+}) {
+ const gradient = gradients.sky.values.map(([_, color]) => {
+ return color
+ })
+
+ return (
+
+ {children}
+
+ )
+}
diff --git a/src/components/NewskieDialog.tsx b/src/components/NewskieDialog.tsx
index 0354bfc4..6743a592 100644
--- a/src/components/NewskieDialog.tsx
+++ b/src/components/NewskieDialog.tsx
@@ -9,11 +9,13 @@ import {useGetTimeAgo} from '#/lib/hooks/useTimeAgo'
import {useModerationOpts} from '#/state/preferences/moderation-opts'
import {HITSLOP_10} from 'lib/constants'
import {sanitizeDisplayName} from 'lib/strings/display-names'
-import {atoms as a} from '#/alf'
-import {Button} from '#/components/Button'
+import {isWeb} from 'platform/detection'
+import {atoms as a, useTheme} from '#/alf'
+import {Button, ButtonText} from '#/components/Button'
import * as Dialog from '#/components/Dialog'
import {useDialogControl} from '#/components/Dialog'
import {Newskie} from '#/components/icons/Newskie'
+import * as StarterPackCard from '#/components/StarterPack/StarterPackCard'
import {Text} from '#/components/Typography'
export function NewskieDialog({
@@ -24,6 +26,7 @@ export function NewskieDialog({
disabled?: boolean
}) {
const {_} = useLingui()
+ const t = useTheme()
const moderationOpts = useModerationOpts()
const control = useDialogControl()
const profileName = React.useMemo(() => {
@@ -68,15 +71,62 @@ export function NewskieDialog({
label={_(msg`New user info dialog`)}
style={[{width: 'auto', maxWidth: 400, minWidth: 200}]}>
-
- Say hello!
-
-
-
- {profileName} joined Bluesky{' '}
- {timeAgo(createdAt, now, {format: 'long'})} ago
-
+
+
+
+ Say hello!
+
+
+
+ {profile.joinedViaStarterPack ? (
+
+ {profileName} joined Bluesky using a starter pack{' '}
+ {timeAgo(createdAt, now, {format: 'long'})} ago
+
+ ) : (
+
+ {profileName} joined Bluesky{' '}
+ {timeAgo(createdAt, now, {format: 'long'})} ago
+
+ )}
+ {profile.joinedViaStarterPack ? (
+ {
+ control.close()
+ }}>
+
+
+
+
+ ) : null}
+
diff --git a/src/components/ProfileCard.tsx b/src/components/ProfileCard.tsx
new file mode 100644
index 00000000..a0d22285
--- /dev/null
+++ b/src/components/ProfileCard.tsx
@@ -0,0 +1,91 @@
+import React from 'react'
+import {View} from 'react-native'
+import {AppBskyActorDefs, moderateProfile, ModerationOpts} from '@atproto/api'
+
+import {createSanitizedDisplayName} from 'lib/moderation/create-sanitized-display-name'
+import {sanitizeHandle} from 'lib/strings/handles'
+import {useProfileShadow} from 'state/cache/profile-shadow'
+import {useSession} from 'state/session'
+import {FollowButton} from 'view/com/profile/FollowButton'
+import {ProfileCardPills} from 'view/com/profile/ProfileCard'
+import {UserAvatar} from 'view/com/util/UserAvatar'
+import {atoms as a, useTheme} from '#/alf'
+import {Link} from '#/components/Link'
+import {Text} from '#/components/Typography'
+
+export function Default({
+ profile: profileUnshadowed,
+ moderationOpts,
+ logContext = 'ProfileCard',
+}: {
+ profile: AppBskyActorDefs.ProfileViewDetailed
+ moderationOpts: ModerationOpts
+ logContext?: 'ProfileCard' | 'StarterPackProfilesList'
+}) {
+ const t = useTheme()
+ const {currentAccount, hasSession} = useSession()
+
+ const profile = useProfileShadow(profileUnshadowed)
+ const name = createSanitizedDisplayName(profile)
+ const handle = `@${sanitizeHandle(profile.handle)}`
+ const moderation = moderateProfile(profile, moderationOpts)
+
+ return (
+
+
+
+
+
+ {name}
+
+
+ {handle}
+
+
+ {hasSession && profile.did !== currentAccount?.did && (
+
+
+
+ )}
+
+
+
+
+ {profile.description && (
+
+ {profile.description}
+
+ )}
+
+ )
+}
+
+function Wrapper({did, children}: {did: string; children: React.ReactNode}) {
+ return (
+
+ {children}
+
+ )
+}
diff --git a/src/components/ReportDialog/SelectReportOptionView.tsx b/src/components/ReportDialog/SelectReportOptionView.tsx
index 4413cbe8..169c07d7 100644
--- a/src/components/ReportDialog/SelectReportOptionView.tsx
+++ b/src/components/ReportDialog/SelectReportOptionView.tsx
@@ -55,6 +55,9 @@ export function SelectReportOptionView({
} else if (props.params.type === 'feedgen') {
title = _(msg`Report this feed`)
description = _(msg`Why should this feed be reviewed?`)
+ } else if (props.params.type === 'starterpack') {
+ title = _(msg`Report this starter pack`)
+ description = _(msg`Why should this starter pack be reviewed?`)
} else if (props.params.type === 'convoMessage') {
title = _(msg`Report this message`)
description = _(msg`Why should this message be reviewed?`)
diff --git a/src/components/ReportDialog/types.ts b/src/components/ReportDialog/types.ts
index ceabe0b9..3f43db4a 100644
--- a/src/components/ReportDialog/types.ts
+++ b/src/components/ReportDialog/types.ts
@@ -4,7 +4,7 @@ export type ReportDialogProps = {
control: Dialog.DialogOuterProps['control']
params:
| {
- type: 'post' | 'list' | 'feedgen' | 'other'
+ type: 'post' | 'list' | 'feedgen' | 'starterpack' | 'other'
uri: string
cid: string
}
diff --git a/src/components/StarterPack/Main/FeedsList.tsx b/src/components/StarterPack/Main/FeedsList.tsx
new file mode 100644
index 00000000..e350a422
--- /dev/null
+++ b/src/components/StarterPack/Main/FeedsList.tsx
@@ -0,0 +1,68 @@
+import React, {useCallback} from 'react'
+import {ListRenderItemInfo, View} from 'react-native'
+import {AppBskyFeedDefs} from '@atproto/api'
+import {GeneratorView} from '@atproto/api/dist/client/types/app/bsky/feed/defs'
+
+import {useBottomBarOffset} from 'lib/hooks/useBottomBarOffset'
+import {isNative, isWeb} from 'platform/detection'
+import {List, ListRef} from 'view/com/util/List'
+import {SectionRef} from '#/screens/Profile/Sections/types'
+import {atoms as a, useTheme} from '#/alf'
+import * as FeedCard from '#/components/FeedCard'
+
+function keyExtractor(item: AppBskyFeedDefs.GeneratorView) {
+ return item.uri
+}
+
+interface ProfilesListProps {
+ feeds: AppBskyFeedDefs.GeneratorView[]
+ headerHeight: number
+ scrollElRef: ListRef
+}
+
+export const FeedsList = React.forwardRef(
+ function FeedsListImpl({feeds, headerHeight, scrollElRef}, ref) {
+ const [initialHeaderHeight] = React.useState(headerHeight)
+ const bottomBarOffset = useBottomBarOffset(20)
+ const t = useTheme()
+
+ const onScrollToTop = useCallback(() => {
+ scrollElRef.current?.scrollToOffset({
+ animated: isNative,
+ offset: -headerHeight,
+ })
+ }, [scrollElRef, headerHeight])
+
+ React.useImperativeHandle(ref, () => ({
+ scrollToTop: onScrollToTop,
+ }))
+
+ const renderItem = ({item, index}: ListRenderItemInfo) => {
+ return (
+
+
+
+ )
+ }
+
+ return (
+
+ }
+ showsVerticalScrollIndicator={false}
+ desktopFixedHeight={true}
+ />
+ )
+ },
+)
diff --git a/src/components/StarterPack/Main/ProfilesList.tsx b/src/components/StarterPack/Main/ProfilesList.tsx
new file mode 100644
index 00000000..72d35fe2
--- /dev/null
+++ b/src/components/StarterPack/Main/ProfilesList.tsx
@@ -0,0 +1,119 @@
+import React, {useCallback} from 'react'
+import {ListRenderItemInfo, View} from 'react-native'
+import {
+ AppBskyActorDefs,
+ AppBskyGraphGetList,
+ AtUri,
+ ModerationOpts,
+} from '@atproto/api'
+import {InfiniteData, UseInfiniteQueryResult} from '@tanstack/react-query'
+
+import {useBottomBarOffset} from 'lib/hooks/useBottomBarOffset'
+import {isNative, isWeb} from 'platform/detection'
+import {useSession} from 'state/session'
+import {List, ListRef} from 'view/com/util/List'
+import {SectionRef} from '#/screens/Profile/Sections/types'
+import {atoms as a, useTheme} from '#/alf'
+import {Default as ProfileCard} from '#/components/ProfileCard'
+
+function keyExtractor(item: AppBskyActorDefs.ProfileViewBasic, index: number) {
+ return `${item.did}-${index}`
+}
+
+interface ProfilesListProps {
+ listUri: string
+ listMembersQuery: UseInfiniteQueryResult<
+ InfiniteData
+ >
+ moderationOpts: ModerationOpts
+ headerHeight: number
+ scrollElRef: ListRef
+}
+
+export const ProfilesList = React.forwardRef(
+ function ProfilesListImpl(
+ {listUri, listMembersQuery, moderationOpts, headerHeight, scrollElRef},
+ ref,
+ ) {
+ const t = useTheme()
+ const [initialHeaderHeight] = React.useState(headerHeight)
+ const bottomBarOffset = useBottomBarOffset(20)
+ const {currentAccount} = useSession()
+
+ const [isPTRing, setIsPTRing] = React.useState(false)
+
+ const {data, refetch} = listMembersQuery
+
+ // The server returns these sorted by descending creation date, so we want to invert
+ const profiles = data?.pages
+ .flatMap(p => p.items.map(i => i.subject))
+ .reverse()
+ const isOwn = new AtUri(listUri).host === currentAccount?.did
+
+ const getSortedProfiles = () => {
+ if (!profiles) return
+ if (!isOwn) return profiles
+
+ const myIndex = profiles.findIndex(p => p.did === currentAccount?.did)
+ return myIndex !== -1
+ ? [
+ profiles[myIndex],
+ ...profiles.slice(0, myIndex),
+ ...profiles.slice(myIndex + 1),
+ ]
+ : profiles
+ }
+ const onScrollToTop = useCallback(() => {
+ scrollElRef.current?.scrollToOffset({
+ animated: isNative,
+ offset: -headerHeight,
+ })
+ }, [scrollElRef, headerHeight])
+
+ React.useImperativeHandle(ref, () => ({
+ scrollToTop: onScrollToTop,
+ }))
+
+ const renderItem = ({
+ item,
+ index,
+ }: ListRenderItemInfo) => {
+ return (
+
+
+
+ )
+ }
+
+ if (listMembersQuery)
+ return (
+
+ }
+ showsVerticalScrollIndicator={false}
+ desktopFixedHeight
+ refreshing={isPTRing}
+ onRefresh={async () => {
+ setIsPTRing(true)
+ await refetch()
+ setIsPTRing(false)
+ }}
+ />
+ )
+ },
+)
diff --git a/src/components/StarterPack/ProfileStarterPacks.tsx b/src/components/StarterPack/ProfileStarterPacks.tsx
new file mode 100644
index 00000000..096f04f2
--- /dev/null
+++ b/src/components/StarterPack/ProfileStarterPacks.tsx
@@ -0,0 +1,320 @@
+import React from 'react'
+import {
+ findNodeHandle,
+ ListRenderItemInfo,
+ StyleProp,
+ View,
+ ViewStyle,
+} from 'react-native'
+import {AppBskyGraphDefs, AppBskyGraphGetActorStarterPacks} from '@atproto/api'
+import {msg, Trans} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+import {useNavigation} from '@react-navigation/native'
+import {InfiniteData, UseInfiniteQueryResult} from '@tanstack/react-query'
+
+import {logger} from '#/logger'
+import {useGenerateStarterPackMutation} from 'lib/generate-starterpack'
+import {useBottomBarOffset} from 'lib/hooks/useBottomBarOffset'
+import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
+import {NavigationProp} from 'lib/routes/types'
+import {parseStarterPackUri} from 'lib/strings/starter-pack'
+import {List, ListRef} from 'view/com/util/List'
+import {Text} from 'view/com/util/text/Text'
+import {atoms as a, useTheme} from '#/alf'
+import {Button, ButtonIcon, ButtonText} from '#/components/Button'
+import {useDialogControl} from '#/components/Dialog'
+import {LinearGradientBackground} from '#/components/LinearGradientBackground'
+import {Loader} from '#/components/Loader'
+import * as Prompt from '#/components/Prompt'
+import {Default as StarterPackCard} from '#/components/StarterPack/StarterPackCard'
+import {PlusSmall_Stroke2_Corner0_Rounded as Plus} from '../icons/Plus'
+
+interface SectionRef {
+ scrollToTop: () => void
+}
+
+interface ProfileFeedgensProps {
+ starterPacksQuery: UseInfiniteQueryResult<
+ InfiniteData,
+ Error
+ >
+ scrollElRef: ListRef
+ headerOffset: number
+ enabled?: boolean
+ style?: StyleProp
+ testID?: string
+ setScrollViewTag: (tag: number | null) => void
+ isMe: boolean
+}
+
+function keyExtractor(item: AppBskyGraphDefs.StarterPackView) {
+ return item.uri
+}
+
+export const ProfileStarterPacks = React.forwardRef<
+ SectionRef,
+ ProfileFeedgensProps
+>(function ProfileFeedgensImpl(
+ {
+ starterPacksQuery: query,
+ scrollElRef,
+ headerOffset,
+ enabled,
+ style,
+ testID,
+ setScrollViewTag,
+ isMe,
+ },
+ ref,
+) {
+ const t = useTheme()
+ const bottomBarOffset = useBottomBarOffset(100)
+ const [isPTRing, setIsPTRing] = React.useState(false)
+ const {data, refetch, isFetching, hasNextPage, fetchNextPage} = query
+ const {isTabletOrDesktop} = useWebMediaQueries()
+
+ const items = data?.pages.flatMap(page => page.starterPacks)
+
+ React.useImperativeHandle(ref, () => ({
+ scrollToTop: () => {},
+ }))
+
+ const onRefresh = React.useCallback(async () => {
+ setIsPTRing(true)
+ try {
+ await refetch()
+ } catch (err) {
+ logger.error('Failed to refresh starter packs', {message: err})
+ }
+ setIsPTRing(false)
+ }, [refetch, setIsPTRing])
+
+ const onEndReached = React.useCallback(async () => {
+ if (isFetching || !hasNextPage) return
+
+ try {
+ await fetchNextPage()
+ } catch (err) {
+ logger.error('Failed to load more starter packs', {message: err})
+ }
+ }, [isFetching, hasNextPage, fetchNextPage])
+
+ React.useEffect(() => {
+ if (enabled && scrollElRef.current) {
+ const nativeTag = findNodeHandle(scrollElRef.current)
+ setScrollViewTag(nativeTag)
+ }
+ }, [enabled, scrollElRef, setScrollViewTag])
+
+ const renderItem = ({
+ item,
+ index,
+ }: ListRenderItemInfo) => {
+ return (
+
+
+
+ )
+ }
+
+ return (
+
+
+
+ )
+})
+
+function CreateAnother() {
+ const {_} = useLingui()
+ const t = useTheme()
+ const navigation = useNavigation()
+
+ return (
+
+
+
+ )
+}
+
+function Empty() {
+ const {_} = useLingui()
+ const t = useTheme()
+ const navigation = useNavigation()
+ const confirmDialogControl = useDialogControl()
+ const followersDialogControl = useDialogControl()
+ const errorDialogControl = useDialogControl()
+
+ const [isGenerating, setIsGenerating] = React.useState(false)
+
+ const {mutate: generateStarterPack} = useGenerateStarterPackMutation({
+ onSuccess: ({uri}) => {
+ const parsed = parseStarterPackUri(uri)
+ if (parsed) {
+ navigation.push('StarterPack', {
+ name: parsed.name,
+ rkey: parsed.rkey,
+ })
+ }
+ setIsGenerating(false)
+ },
+ onError: e => {
+ logger.error('Failed to generate starter pack', {safeMessage: e})
+ setIsGenerating(false)
+ if (e.name === 'NOT_ENOUGH_FOLLOWERS') {
+ followersDialogControl.open()
+ } else {
+ errorDialogControl.open()
+ }
+ },
+ })
+
+ const generate = () => {
+ setIsGenerating(true)
+ generateStarterPack()
+ }
+
+ return (
+
+
+
+ You haven't created a starter pack yet!
+
+
+ Starter packs let you easily share your favorite feeds and people with
+ your friends.
+
+
+
+
+
+
+
+
+
+ Generate a starter pack
+
+
+
+ Bluesky will choose a set of recommended accounts from people in
+ your network.
+
+
+
+
+ {
+ navigation.navigate('StarterPackWizard')
+ }}
+ />
+
+
+ {}}
+ showCancel={false}
+ />
+
+
+ )
+}
diff --git a/src/components/StarterPack/QrCode.tsx b/src/components/StarterPack/QrCode.tsx
new file mode 100644
index 00000000..08ee03d6
--- /dev/null
+++ b/src/components/StarterPack/QrCode.tsx
@@ -0,0 +1,119 @@
+import React from 'react'
+import {View} from 'react-native'
+import QRCode from 'react-native-qrcode-styled'
+import ViewShot from 'react-native-view-shot'
+import {AppBskyGraphDefs, AppBskyGraphStarterpack} from '@atproto/api'
+import {Trans} from '@lingui/macro'
+
+import {isWeb} from 'platform/detection'
+import {Logo} from 'view/icons/Logo'
+import {Logotype} from 'view/icons/Logotype'
+import {useTheme} from '#/alf'
+import {atoms as a} from '#/alf'
+import {LinearGradientBackground} from '#/components/LinearGradientBackground'
+import {Text} from '#/components/Typography'
+
+interface Props {
+ starterPack: AppBskyGraphDefs.StarterPackView
+ link: string
+}
+
+export const QrCode = React.forwardRef(function QrCode(
+ {starterPack, link},
+ ref,
+) {
+ const {record} = starterPack
+
+ if (!AppBskyGraphStarterpack.isRecord(record)) {
+ return null
+ }
+
+ return (
+
+
+
+
+ {record.name}
+
+
+
+
+ Join the conversation
+
+
+
+
+
+
+
+ on
+
+
+
+
+
+
+
+
+
+ )
+})
+
+export function QrCodeInner({link}: {link: string}) {
+ const t = useTheme()
+
+ return (
+
+ )
+}
diff --git a/src/components/StarterPack/QrCodeDialog.tsx b/src/components/StarterPack/QrCodeDialog.tsx
new file mode 100644
index 00000000..580c6cc7
--- /dev/null
+++ b/src/components/StarterPack/QrCodeDialog.tsx
@@ -0,0 +1,201 @@
+import React from 'react'
+import {View} from 'react-native'
+import ViewShot from 'react-native-view-shot'
+import * as FS from 'expo-file-system'
+import {requestMediaLibraryPermissionsAsync} from 'expo-image-picker'
+import * as Sharing from 'expo-sharing'
+import {AppBskyGraphDefs, AppBskyGraphStarterpack} from '@atproto/api'
+import {msg, Trans} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+import {nanoid} from 'nanoid/non-secure'
+
+import {logger} from '#/logger'
+import {saveImageToMediaLibrary} from 'lib/media/manip'
+import {logEvent} from 'lib/statsig/statsig'
+import {isNative, isWeb} from 'platform/detection'
+import * as Toast from '#/view/com/util/Toast'
+import {atoms as a} from '#/alf'
+import {Button, ButtonText} from '#/components/Button'
+import * as Dialog from '#/components/Dialog'
+import {DialogControlProps} from '#/components/Dialog'
+import {Loader} from '#/components/Loader'
+import {QrCode} from '#/components/StarterPack/QrCode'
+
+export function QrCodeDialog({
+ starterPack,
+ link,
+ control,
+}: {
+ starterPack: AppBskyGraphDefs.StarterPackView
+ link?: string
+ control: DialogControlProps
+}) {
+ const {_} = useLingui()
+ const [isProcessing, setIsProcessing] = React.useState(false)
+
+ const ref = React.useRef(null)
+
+ const getCanvas = (base64: string): Promise => {
+ return new Promise(resolve => {
+ const image = new Image()
+ image.onload = () => {
+ const canvas = document.createElement('canvas')
+ canvas.width = image.width
+ canvas.height = image.height
+
+ const ctx = canvas.getContext('2d')
+ ctx?.drawImage(image, 0, 0)
+ resolve(canvas)
+ }
+ image.src = base64
+ })
+ }
+
+ const onSavePress = async () => {
+ ref.current?.capture?.().then(async (uri: string) => {
+ if (isNative) {
+ const res = await requestMediaLibraryPermissionsAsync()
+
+ if (!res) {
+ Toast.show(
+ _(
+ msg`You must grant access to your photo library to save a QR code`,
+ ),
+ )
+ return
+ }
+
+ const filename = `${FS.documentDirectory}/${nanoid(12)}.png`
+
+ // Incase of a FS failure, don't crash the app
+ try {
+ await FS.copyAsync({from: uri, to: filename})
+ await saveImageToMediaLibrary({uri: filename})
+ await FS.deleteAsync(filename)
+ } catch (e: unknown) {
+ Toast.show(_(msg`An error occurred while saving the QR code!`))
+ logger.error('Failed to save QR code', {error: e})
+ return
+ }
+ } else {
+ setIsProcessing(true)
+
+ if (!AppBskyGraphStarterpack.isRecord(starterPack.record)) {
+ return
+ }
+
+ const canvas = await getCanvas(uri)
+ const imgHref = canvas
+ .toDataURL('image/png')
+ .replace('image/png', 'image/octet-stream')
+
+ const link = document.createElement('a')
+ link.setAttribute(
+ 'download',
+ `${starterPack.record.name.replaceAll(' ', '_')}_Share_Card.png`,
+ )
+ link.setAttribute('href', imgHref)
+ link.click()
+ }
+
+ logEvent('starterPack:share', {
+ starterPack: starterPack.uri,
+ shareType: 'qrcode',
+ qrShareType: 'save',
+ })
+ setIsProcessing(false)
+ Toast.show(
+ isWeb
+ ? _(msg`QR code has been downloaded!`)
+ : _(msg`QR code saved to your camera roll!`),
+ )
+ control.close()
+ })
+ }
+
+ const onCopyPress = async () => {
+ setIsProcessing(true)
+ ref.current?.capture?.().then(async (uri: string) => {
+ const canvas = await getCanvas(uri)
+ // @ts-expect-error web only
+ canvas.toBlob((blob: Blob) => {
+ const item = new ClipboardItem({'image/png': blob})
+ navigator.clipboard.write([item])
+ })
+
+ logEvent('starterPack:share', {
+ starterPack: starterPack.uri,
+ shareType: 'qrcode',
+ qrShareType: 'copy',
+ })
+ Toast.show(_(msg`QR code copied to your clipboard!`))
+ setIsProcessing(false)
+ control.close()
+ })
+ }
+
+ const onSharePress = async () => {
+ ref.current?.capture?.().then(async (uri: string) => {
+ control.close(() => {
+ Sharing.shareAsync(uri, {mimeType: 'image/png', UTI: 'image/png'}).then(
+ () => {
+ logEvent('starterPack:share', {
+ starterPack: starterPack.uri,
+ shareType: 'qrcode',
+ qrShareType: 'share',
+ })
+ },
+ )
+ })
+ })
+ }
+
+ return (
+
+
+
+
+ {!link ? (
+
+
+
+ ) : (
+ <>
+
+ {isProcessing ? (
+
+
+
+ ) : (
+
+
+
+
+ )}
+ >
+ )}
+
+
+
+ )
+}
diff --git a/src/components/StarterPack/ShareDialog.tsx b/src/components/StarterPack/ShareDialog.tsx
new file mode 100644
index 00000000..23fa10fb
--- /dev/null
+++ b/src/components/StarterPack/ShareDialog.tsx
@@ -0,0 +1,180 @@
+import React from 'react'
+import {View} from 'react-native'
+import * as FS from 'expo-file-system'
+import {Image} from 'expo-image'
+import {requestMediaLibraryPermissionsAsync} from 'expo-image-picker'
+import {AppBskyGraphDefs} from '@atproto/api'
+import {msg, Trans} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+import {nanoid} from 'nanoid/non-secure'
+
+import {logger} from '#/logger'
+import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
+import {saveImageToMediaLibrary} from 'lib/media/manip'
+import {shareUrl} from 'lib/sharing'
+import {logEvent} from 'lib/statsig/statsig'
+import {getStarterPackOgCard} from 'lib/strings/starter-pack'
+import {isNative, isWeb} from 'platform/detection'
+import * as Toast from 'view/com/util/Toast'
+import {atoms as a, useTheme} from '#/alf'
+import {Button, ButtonText} from '#/components/Button'
+import {DialogControlProps} from '#/components/Dialog'
+import * as Dialog from '#/components/Dialog'
+import {Loader} from '#/components/Loader'
+import {Text} from '#/components/Typography'
+
+interface Props {
+ starterPack: AppBskyGraphDefs.StarterPackView
+ link?: string
+ imageLoaded?: boolean
+ qrDialogControl: DialogControlProps
+ control: DialogControlProps
+}
+
+export function ShareDialog(props: Props) {
+ return (
+
+
+
+ )
+}
+
+function ShareDialogInner({
+ starterPack,
+ link,
+ imageLoaded,
+ qrDialogControl,
+ control,
+}: Props) {
+ const {_} = useLingui()
+ const t = useTheme()
+ const {isTabletOrDesktop} = useWebMediaQueries()
+
+ const imageUrl = getStarterPackOgCard(starterPack)
+
+ const onShareLink = async () => {
+ if (!link) return
+ shareUrl(link)
+ logEvent('starterPack:share', {
+ starterPack: starterPack.uri,
+ shareType: 'link',
+ })
+ control.close()
+ }
+
+ const onSave = async () => {
+ const res = await requestMediaLibraryPermissionsAsync()
+
+ if (!res) {
+ Toast.show(
+ _(msg`You must grant access to your photo library to save the image.`),
+ )
+ return
+ }
+
+ const cachePath = await Image.getCachePathAsync(imageUrl)
+ const filename = `${FS.documentDirectory}/${nanoid(12)}.png`
+
+ if (!cachePath) {
+ Toast.show(_(msg`An error occurred while saving the image.`))
+ return
+ }
+
+ try {
+ await FS.copyAsync({from: cachePath, to: filename})
+ await saveImageToMediaLibrary({uri: filename})
+ await FS.deleteAsync(filename)
+
+ Toast.show(_(msg`Image saved to your camera roll!`))
+ control.close()
+ } catch (e: unknown) {
+ Toast.show(_(msg`An error occurred while saving the QR code!`))
+ logger.error('Failed to save QR code', {error: e})
+ return
+ }
+ }
+
+ return (
+ <>
+
+
+ {!imageLoaded || !link ? (
+
+
+
+ ) : (
+
+
+
+ Invite people to this starter pack!
+
+
+
+ Share this starter pack and help people join your community on
+ Bluesky.
+
+
+
+
+
+
+
+ {isNative && (
+
+ )}
+
+
+ )}
+
+ >
+ )
+}
diff --git a/src/components/StarterPack/StarterPackCard.tsx b/src/components/StarterPack/StarterPackCard.tsx
new file mode 100644
index 00000000..ab904d7f
--- /dev/null
+++ b/src/components/StarterPack/StarterPackCard.tsx
@@ -0,0 +1,117 @@
+import React from 'react'
+import {View} from 'react-native'
+import {AppBskyGraphStarterpack, AtUri} from '@atproto/api'
+import {StarterPackViewBasic} from '@atproto/api/dist/client/types/app/bsky/graph/defs'
+import {msg, Trans} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+
+import {sanitizeHandle} from 'lib/strings/handles'
+import {useSession} from 'state/session'
+import {atoms as a, useTheme} from '#/alf'
+import {StarterPack} from '#/components/icons/StarterPack'
+import {Link as InternalLink, LinkProps} from '#/components/Link'
+import {Text} from '#/components/Typography'
+
+export function Default({starterPack}: {starterPack?: StarterPackViewBasic}) {
+ if (!starterPack) return null
+ return (
+
+
+
+ )
+}
+
+export function Notification({
+ starterPack,
+}: {
+ starterPack?: StarterPackViewBasic
+}) {
+ if (!starterPack) return null
+ return (
+
+
+
+ )
+}
+
+export function Card({
+ starterPack,
+ noIcon,
+ noDescription,
+}: {
+ starterPack: StarterPackViewBasic
+ noIcon?: boolean
+ noDescription?: boolean
+}) {
+ const {record, creator, joinedAllTimeCount} = starterPack
+
+ const {_} = useLingui()
+ const t = useTheme()
+ const {currentAccount} = useSession()
+
+ if (!AppBskyGraphStarterpack.isRecord(record)) {
+ return null
+ }
+
+ return (
+
+
+ {!noIcon ? : null}
+
+
+ {record.name}
+
+
+
+ Starter pack by{' '}
+ {creator?.did === currentAccount?.did
+ ? _(msg`you`)
+ : `@${sanitizeHandle(creator.handle)}`}
+
+
+
+
+ {!noDescription && record.description ? (
+
+ {record.description}
+
+ ) : null}
+ {!!joinedAllTimeCount && joinedAllTimeCount >= 50 && (
+
+ {joinedAllTimeCount} users have joined!
+
+ )}
+
+ )
+}
+
+export function Link({
+ starterPack,
+ children,
+ ...rest
+}: {
+ starterPack: StarterPackViewBasic
+} & Omit) {
+ const {record} = starterPack
+ const {rkey, handleOrDid} = React.useMemo(() => {
+ const rkey = new AtUri(starterPack.uri).rkey
+ const {creator} = starterPack
+ return {rkey, handleOrDid: creator.handle || creator.did}
+ }, [starterPack])
+
+ if (!AppBskyGraphStarterpack.isRecord(record)) {
+ return null
+ }
+
+ return (
+
+ {children}
+
+ )
+}
diff --git a/src/components/StarterPack/Wizard/ScreenTransition.tsx b/src/components/StarterPack/Wizard/ScreenTransition.tsx
new file mode 100644
index 00000000..b7cd4e4c
--- /dev/null
+++ b/src/components/StarterPack/Wizard/ScreenTransition.tsx
@@ -0,0 +1,31 @@
+import React from 'react'
+import {StyleProp, ViewStyle} from 'react-native'
+import Animated, {
+ FadeIn,
+ FadeOut,
+ SlideInLeft,
+ SlideInRight,
+} from 'react-native-reanimated'
+
+import {isWeb} from 'platform/detection'
+
+export function ScreenTransition({
+ direction,
+ style,
+ children,
+}: {
+ direction: 'Backward' | 'Forward'
+ style?: StyleProp
+ children: React.ReactNode
+}) {
+ const entering = direction === 'Forward' ? SlideInRight : SlideInLeft
+
+ return (
+
+ {children}
+
+ )
+}
diff --git a/src/components/StarterPack/Wizard/WizardEditListDialog.tsx b/src/components/StarterPack/Wizard/WizardEditListDialog.tsx
new file mode 100644
index 00000000..bf250ac3
--- /dev/null
+++ b/src/components/StarterPack/Wizard/WizardEditListDialog.tsx
@@ -0,0 +1,152 @@
+import React, {useRef} from 'react'
+import type {ListRenderItemInfo} from 'react-native'
+import {View} from 'react-native'
+import {AppBskyActorDefs, ModerationOpts} from '@atproto/api'
+import {GeneratorView} from '@atproto/api/dist/client/types/app/bsky/feed/defs'
+import {BottomSheetFlatListMethods} from '@discord/bottom-sheet'
+import {msg, Trans} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+
+import {isWeb} from 'platform/detection'
+import {useSession} from 'state/session'
+import {WizardAction, WizardState} from '#/screens/StarterPack/Wizard/State'
+import {atoms as a, native, useTheme, web} from '#/alf'
+import {Button, ButtonText} from '#/components/Button'
+import * as Dialog from '#/components/Dialog'
+import {
+ WizardFeedCard,
+ WizardProfileCard,
+} from '#/components/StarterPack/Wizard/WizardListCard'
+import {Text} from '#/components/Typography'
+
+function keyExtractor(
+ item: AppBskyActorDefs.ProfileViewBasic | GeneratorView,
+ index: number,
+) {
+ return `${item.did}-${index}`
+}
+
+export function WizardEditListDialog({
+ control,
+ state,
+ dispatch,
+ moderationOpts,
+ profile,
+}: {
+ control: Dialog.DialogControlProps
+ state: WizardState
+ dispatch: (action: WizardAction) => void
+ moderationOpts: ModerationOpts
+ profile: AppBskyActorDefs.ProfileViewBasic
+}) {
+ const {_} = useLingui()
+ const t = useTheme()
+ const {currentAccount} = useSession()
+
+ const listRef = useRef(null)
+
+ const getData = () => {
+ if (state.currentStep === 'Feeds') return state.feeds
+
+ return [
+ profile,
+ ...state.profiles.filter(p => p.did !== currentAccount?.did),
+ ]
+ }
+
+ const renderItem = ({item}: ListRenderItemInfo) =>
+ state.currentStep === 'Profiles' ? (
+
+ ) : (
+
+ )
+
+ return (
+
+
+
+
+
+ {state.currentStep === 'Profiles' ? (
+ Edit People
+ ) : (
+ Edit Feeds
+ )}
+
+
+ {isWeb && (
+
+ )}
+
+
+ }
+ stickyHeaderIndices={[0]}
+ style={[
+ web([a.py_0, {height: '100vh', maxHeight: 600}, a.px_0]),
+ native({
+ height: '100%',
+ paddingHorizontal: 0,
+ marginTop: 0,
+ paddingTop: 0,
+ borderTopLeftRadius: 40,
+ borderTopRightRadius: 40,
+ }),
+ ]}
+ webInnerStyle={[a.py_0, {maxWidth: 500, minWidth: 200}]}
+ keyboardDismissMode="on-drag"
+ removeClippedSubviews={true}
+ />
+
+ )
+}
diff --git a/src/components/StarterPack/Wizard/WizardListCard.tsx b/src/components/StarterPack/Wizard/WizardListCard.tsx
new file mode 100644
index 00000000..f1332011
--- /dev/null
+++ b/src/components/StarterPack/Wizard/WizardListCard.tsx
@@ -0,0 +1,182 @@
+import React from 'react'
+import {Keyboard, View} from 'react-native'
+import {
+ AppBskyActorDefs,
+ AppBskyFeedDefs,
+ moderateFeedGenerator,
+ moderateProfile,
+ ModerationOpts,
+ ModerationUI,
+} from '@atproto/api'
+import {GeneratorView} from '@atproto/api/dist/client/types/app/bsky/feed/defs'
+import {msg} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+
+import {DISCOVER_FEED_URI} from 'lib/constants'
+import {sanitizeDisplayName} from 'lib/strings/display-names'
+import {sanitizeHandle} from 'lib/strings/handles'
+import {useSession} from 'state/session'
+import {UserAvatar} from 'view/com/util/UserAvatar'
+import {WizardAction, WizardState} from '#/screens/StarterPack/Wizard/State'
+import {atoms as a, useTheme} from '#/alf'
+import * as Toggle from '#/components/forms/Toggle'
+import {Checkbox} from '#/components/forms/Toggle'
+import {Text} from '#/components/Typography'
+
+function WizardListCard({
+ type,
+ displayName,
+ subtitle,
+ onPress,
+ avatar,
+ included,
+ disabled,
+ moderationUi,
+}: {
+ type: 'user' | 'algo'
+ profile?: AppBskyActorDefs.ProfileViewBasic
+ feed?: AppBskyFeedDefs.GeneratorView
+ displayName: string
+ subtitle: string
+ onPress: () => void
+ avatar?: string
+ included?: boolean
+ disabled?: boolean
+ moderationUi: ModerationUI
+}) {
+ const t = useTheme()
+ const {_} = useLingui()
+
+ return (
+
+
+
+
+ {displayName}
+
+
+ {subtitle}
+
+
+
+
+ )
+}
+
+export function WizardProfileCard({
+ state,
+ dispatch,
+ profile,
+ moderationOpts,
+}: {
+ state: WizardState
+ dispatch: (action: WizardAction) => void
+ profile: AppBskyActorDefs.ProfileViewBasic
+ moderationOpts: ModerationOpts
+}) {
+ const {currentAccount} = useSession()
+
+ const isMe = profile.did === currentAccount?.did
+ const included = isMe || state.profiles.some(p => p.did === profile.did)
+ const disabled = isMe || (!included && state.profiles.length >= 49)
+ const moderationUi = moderateProfile(profile, moderationOpts).ui('avatar')
+ const displayName = profile.displayName
+ ? sanitizeDisplayName(profile.displayName)
+ : `@${sanitizeHandle(profile.handle)}`
+
+ const onPress = () => {
+ if (disabled) return
+
+ Keyboard.dismiss()
+ if (profile.did === currentAccount?.did) return
+
+ if (!included) {
+ dispatch({type: 'AddProfile', profile})
+ } else {
+ dispatch({type: 'RemoveProfile', profileDid: profile.did})
+ }
+ }
+
+ return (
+
+ )
+}
+
+export function WizardFeedCard({
+ generator,
+ state,
+ dispatch,
+ moderationOpts,
+}: {
+ generator: GeneratorView
+ state: WizardState
+ dispatch: (action: WizardAction) => void
+ moderationOpts: ModerationOpts
+}) {
+ const isDiscover = generator.uri === DISCOVER_FEED_URI
+ const included = isDiscover || state.feeds.some(f => f.uri === generator.uri)
+ const disabled = isDiscover || (!included && state.feeds.length >= 3)
+ const moderationUi = moderateFeedGenerator(generator, moderationOpts).ui(
+ 'avatar',
+ )
+
+ const onPress = () => {
+ if (disabled) return
+
+ Keyboard.dismiss()
+ if (included) {
+ dispatch({type: 'RemoveFeed', feedUri: generator.uri})
+ } else {
+ dispatch({type: 'AddFeed', feed: generator})
+ }
+ }
+
+ return (
+
+ )
+}
diff --git a/src/components/forms/TextField.tsx b/src/components/forms/TextField.tsx
index f7a827b4..d513a6db 100644
--- a/src/components/forms/TextField.tsx
+++ b/src/components/forms/TextField.tsx
@@ -140,6 +140,7 @@ export function createInput(Component: typeof TextInput) {
onChangeText,
isInvalid,
inputRef,
+ style,
...rest
}: InputProps) {
const t = useTheme()
@@ -206,6 +207,7 @@ export function createInput(Component: typeof TextInput) {
android({
paddingBottom: 16,
}),
+ style,
]}
/>
diff --git a/src/components/hooks/useStarterPackEntry.native.ts b/src/components/hooks/useStarterPackEntry.native.ts
new file mode 100644
index 00000000..b6e4ab05
--- /dev/null
+++ b/src/components/hooks/useStarterPackEntry.native.ts
@@ -0,0 +1,68 @@
+import React from 'react'
+
+import {
+ createStarterPackLinkFromAndroidReferrer,
+ httpStarterPackUriToAtUri,
+} from 'lib/strings/starter-pack'
+import {isAndroid} from 'platform/detection'
+import {useHasCheckedForStarterPack} from 'state/preferences/used-starter-packs'
+import {useSetActiveStarterPack} from 'state/shell/starter-pack'
+import {DevicePrefs, Referrer} from '../../../modules/expo-bluesky-swiss-army'
+
+export function useStarterPackEntry() {
+ const [ready, setReady] = React.useState(false)
+ const setActiveStarterPack = useSetActiveStarterPack()
+ const hasCheckedForStarterPack = useHasCheckedForStarterPack()
+
+ React.useEffect(() => {
+ if (ready) return
+
+ // On Android, we cannot clear the referral link. It gets stored for 90 days and all we can do is query for it. So,
+ // let's just ensure we never check again after the first time.
+ if (hasCheckedForStarterPack) {
+ setReady(true)
+ return
+ }
+
+ // Safety for Android. Very unlike this could happen, but just in case. The response should be nearly immediate
+ const timeout = setTimeout(() => {
+ setReady(true)
+ }, 500)
+
+ ;(async () => {
+ let uri: string | null | undefined
+
+ if (isAndroid) {
+ const res = await Referrer.getGooglePlayReferrerInfoAsync()
+
+ if (res && res.installReferrer) {
+ uri = createStarterPackLinkFromAndroidReferrer(res.installReferrer)
+ }
+ } else {
+ const res = await DevicePrefs.getStringValueAsync(
+ 'starterPackUri',
+ true,
+ )
+
+ if (res) {
+ uri = httpStarterPackUriToAtUri(res)
+ DevicePrefs.setStringValueAsync('starterPackUri', null, true)
+ }
+ }
+
+ if (uri) {
+ setActiveStarterPack({
+ uri,
+ })
+ }
+
+ setReady(true)
+ })()
+
+ return () => {
+ clearTimeout(timeout)
+ }
+ }, [ready, setActiveStarterPack, hasCheckedForStarterPack])
+
+ return ready
+}
diff --git a/src/components/hooks/useStarterPackEntry.ts b/src/components/hooks/useStarterPackEntry.ts
new file mode 100644
index 00000000..dba801e0
--- /dev/null
+++ b/src/components/hooks/useStarterPackEntry.ts
@@ -0,0 +1,29 @@
+import React from 'react'
+
+import {httpStarterPackUriToAtUri} from 'lib/strings/starter-pack'
+import {useSetActiveStarterPack} from 'state/shell/starter-pack'
+
+export function useStarterPackEntry() {
+ const [ready, setReady] = React.useState(false)
+
+ const setActiveStarterPack = useSetActiveStarterPack()
+
+ React.useEffect(() => {
+ const href = window.location.href
+ const atUri = httpStarterPackUriToAtUri(href)
+
+ if (atUri) {
+ const url = new URL(href)
+ // Determines if an App Clip is loading this landing page
+ const isClip = url.searchParams.get('clip') === 'true'
+ setActiveStarterPack({
+ uri: atUri,
+ isClip,
+ })
+ }
+
+ setReady(true)
+ }, [setActiveStarterPack])
+
+ return ready
+}
diff --git a/src/components/icons/QrCode.tsx b/src/components/icons/QrCode.tsx
new file mode 100644
index 00000000..e841071f
--- /dev/null
+++ b/src/components/icons/QrCode.tsx
@@ -0,0 +1,5 @@
+import {createSinglePathSVG} from './TEMPLATE'
+
+export const QrCode_Stroke2_Corner0_Rounded = createSinglePathSVG({
+ path: 'M3 5a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5Zm6 0H5v4h4V5ZM3 15a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4Zm6 0H5v4h4v-4ZM13 5a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v4a2 2 0 0 1-2 2h-4a2 2 0 0 1-2-2V5Zm6 0h-4v4h4V5ZM14 13a1 1 0 0 1 1 1v1h1a1 1 0 1 1 0 2h-2a1 1 0 0 1-1-1v-2a1 1 0 0 1 1-1Zm3 1a1 1 0 0 1 1-1h2a1 1 0 1 1 0 2h-2a1 1 0 0 1-1-1Zm0 4a1 1 0 0 1 1-1h2a1 1 0 1 1 0 2h-1v1a1 1 0 1 1-2 0v-2Z',
+})
diff --git a/src/components/icons/StarterPack.tsx b/src/components/icons/StarterPack.tsx
new file mode 100644
index 00000000..8c678bca
--- /dev/null
+++ b/src/components/icons/StarterPack.tsx
@@ -0,0 +1,8 @@
+import {createMultiPathSVG} from './TEMPLATE'
+
+export const StarterPack = createMultiPathSVG({
+ paths: [
+ 'M11.26 5.227 5.02 6.899c-.734.197-1.17.95-.973 1.685l1.672 6.24c.197.734.951 1.17 1.685.973l6.24-1.672c.734-.197 1.17-.951.973-1.685L12.945 6.2a1.375 1.375 0 0 0-1.685-.973Zm-6.566.459a2.632 2.632 0 0 0-1.86 3.223l1.672 6.24a2.632 2.632 0 0 0 3.223 1.861l6.24-1.672a2.631 2.631 0 0 0 1.861-3.223l-1.672-6.24a2.632 2.632 0 0 0-3.223-1.861l-6.24 1.672Z',
+ 'M15.138 18.411a4.606 4.606 0 1 0 0-9.211 4.606 4.606 0 0 0 0 9.211Zm0 1.257a5.862 5.862 0 1 0 0-11.724 5.862 5.862 0 0 0 0 11.724Z',
+ ],
+})
diff --git a/src/components/icons/TEMPLATE.tsx b/src/components/icons/TEMPLATE.tsx
index f49c4280..47a5c36b 100644
--- a/src/components/icons/TEMPLATE.tsx
+++ b/src/components/icons/TEMPLATE.tsx
@@ -30,7 +30,7 @@ export const IconTemplate_Stroke2_Corner0_Rounded = React.forwardRef(
export function createSinglePathSVG({path}: {path: string}) {
return React.forwardRef