diff --git a/app.config.js b/app.config.js
index 5bbe864a3a..fa9735dc2b 100644
--- a/app.config.js
+++ b/app.config.js
@@ -141,6 +141,7 @@ module.exports = function (config) {
},
],
'./plugins/withAndroidManifestPlugin.js',
+ './plugins/shareExtension/withShareExtensions.js',
].filter(Boolean),
extra: {
eas: {
diff --git a/modules/Share-with-Bluesky/Info.plist b/modules/Share-with-Bluesky/Info.plist
new file mode 100644
index 0000000000..90fe923455
--- /dev/null
+++ b/modules/Share-with-Bluesky/Info.plist
@@ -0,0 +1,41 @@
+
+
+
+
+ NSExtension
+
+ NSExtensionPrincipalClass
+ $(PRODUCT_MODULE_NAME).ShareViewController
+ NSExtensionAttributes
+
+ NSExtensionActivationRule
+
+ NSExtensionActivationSupportsText
+
+ NSExtensionActivationSupportsWebURLWithMaxCount
+ 1
+ NSExtensionActivationSupportsImageWithMaxCount
+ 10
+
+
+ NSExtensionPointIdentifier
+ com.apple.share-services
+
+ MainAppScheme
+ bluesky
+ CFBundleName
+ $(PRODUCT_NAME)
+ CFBundleDisplayName
+ Extension
+ CFBundleIdentifier
+ $(PRODUCT_BUNDLE_IDENTIFIER)
+ CFBundleVersion
+ $(CURRENT_PROJECT_VERSION)
+ CFBundleExecutable
+ $(EXECUTABLE_NAME)
+ CFBundlePackageType
+ $(PRODUCT_BUNDLE_PACKAGE_TYPE)
+ CFBundleShortVersionString
+ $(MARKETING_VERSION)
+
+
diff --git a/modules/Share-with-Bluesky/Share-with-Bluesky.entitlements b/modules/Share-with-Bluesky/Share-with-Bluesky.entitlements
new file mode 100644
index 0000000000..22ca9157f3
--- /dev/null
+++ b/modules/Share-with-Bluesky/Share-with-Bluesky.entitlements
@@ -0,0 +1,10 @@
+
+
+
+
+ com.apple.security.application-groups
+
+ group.xyz.blueskyweb.app
+
+
+
diff --git a/modules/Share-with-Bluesky/ShareViewController.swift b/modules/Share-with-Bluesky/ShareViewController.swift
new file mode 100644
index 0000000000..a16a290bf6
--- /dev/null
+++ b/modules/Share-with-Bluesky/ShareViewController.swift
@@ -0,0 +1,153 @@
+import UIKit
+
+class ShareViewController: UIViewController {
+ // This allows other forks to use this extension while also changing their
+ // scheme.
+ let appScheme = Bundle.main.object(forInfoDictionaryKey: "MainAppScheme") as? String ?? "bluesky"
+
+ //
+ override func viewDidAppear(_ animated: Bool) {
+ super.viewDidAppear(animated)
+
+ guard let extensionItem = extensionContext?.inputItems.first as? NSExtensionItem,
+ let attachments = extensionItem.attachments,
+ let firstAttachment = extensionItem.attachments?.first
+ else {
+ self.completeRequest()
+ return
+ }
+
+ Task {
+ if firstAttachment.hasItemConformingToTypeIdentifier("public.text") {
+ await self.handleText(item: firstAttachment)
+ } else if firstAttachment.hasItemConformingToTypeIdentifier("public.url") {
+ await self.handleUrl(item: firstAttachment)
+ } else if firstAttachment.hasItemConformingToTypeIdentifier("public.image") {
+ await self.handleImages(items: attachments)
+ } else {
+ self.completeRequest()
+ }
+ }
+ }
+
+ private func handleText(item: NSItemProvider) async -> Void {
+ do {
+ if let data = try await item.loadItem(forTypeIdentifier: "public.text") as? String {
+ if let encoded = data.addingPercentEncoding(withAllowedCharacters: .urlHostAllowed),
+ let url = URL(string: "\(self.appScheme)://intent/compose?text=\(encoded)")
+ {
+ _ = self.openURL(url)
+ }
+ }
+ self.completeRequest()
+ } catch {
+ self.completeRequest()
+ }
+ }
+
+ private func handleUrl(item: NSItemProvider) async -> Void {
+ do {
+ if let data = try await item.loadItem(forTypeIdentifier: "public.url") as? URL {
+ if let encoded = data.absoluteString.addingPercentEncoding(withAllowedCharacters: .urlHostAllowed),
+ let url = URL(string: "\(self.appScheme)://intent/compose?text=\(encoded)")
+ {
+ _ = self.openURL(url)
+ }
+ }
+ self.completeRequest()
+ } catch {
+ self.completeRequest()
+ }
+ }
+
+ private func handleImages(items: [NSItemProvider]) async -> Void {
+ let firstFourItems: [NSItemProvider]
+ if items.count < 4 {
+ firstFourItems = items
+ } else {
+ firstFourItems = Array(items[0...3])
+ }
+
+ var valid = true
+ var imageUris = ""
+
+ for (index, item) in firstFourItems.enumerated() {
+ var imageUriInfo: String? = nil
+
+ do {
+ if let dataUri = try await item.loadItem(forTypeIdentifier: "public.image") as? URL {
+ // We need to duplicate this image, since we don't have access to the outgoing temp directory
+ // We also will get the image dimensions here, sinze RN makes it difficult to get those dimensions for local files
+ let data = try Data(contentsOf: dataUri)
+ let image = UIImage(data: data)
+ imageUriInfo = self.saveImageWithInfo(image)
+ } else if let image = try await item.loadItem(forTypeIdentifier: "public.image") as? UIImage {
+ imageUriInfo = self.saveImageWithInfo(image)
+ }
+ } catch {
+ valid = false
+ }
+
+ if let imageUriInfo = imageUriInfo {
+ imageUris.append(imageUriInfo)
+ if index < items.count - 1 {
+ imageUris.append(",")
+ }
+ } else {
+ valid = false
+ }
+ }
+
+ if valid,
+ let encoded = imageUris.addingPercentEncoding(withAllowedCharacters: .urlHostAllowed),
+ let url = URL(string: "\(self.appScheme)://intent/compose?imageUris=\(encoded)")
+ {
+ _ = self.openURL(url)
+ }
+
+ self.completeRequest()
+ }
+
+ private func saveImageWithInfo(_ image: UIImage?) -> String? {
+ guard let image = image else {
+ return nil
+ }
+
+ do {
+ // Saving this file to the bundle group's directory lets us access it from
+ // inside of the app. Otherwise, we wouldn't have access even though the
+ // extension does.
+ if let dir = FileManager()
+ .containerURL(
+ forSecurityApplicationGroupIdentifier: "group.\(Bundle.main.bundleIdentifier?.replacingOccurrences(of: ".Share-with-Bluesky", with: "") ?? "")")
+ {
+ let filePath = "\(dir.absoluteString)\(ProcessInfo.processInfo.globallyUniqueString).jpeg"
+
+ if let newUri = URL(string: filePath),
+ let jpegData = image.jpegData(compressionQuality: 1)
+ {
+ try jpegData.write(to: newUri)
+ return "\(newUri.absoluteString)|\(image.size.width)|\(image.size.height)"
+ }
+ }
+ return nil
+ } catch {
+ return nil
+ }
+ }
+
+ private func completeRequest() -> Void {
+ self.extensionContext?.completeRequest(returningItems: nil)
+ }
+
+ @objc func openURL(_ url: URL) -> Bool {
+ var responder: UIResponder? = self
+ while responder != nil {
+ if let application = responder as? UIApplication {
+ return application.perform(#selector(openURL(_:)), with: url) != nil
+ }
+ responder = responder?.next
+ }
+ return false
+ }
+}
diff --git a/modules/expo-receive-android-intents/README.md b/modules/expo-receive-android-intents/README.md
new file mode 100644
index 0000000000..7e8506860c
--- /dev/null
+++ b/modules/expo-receive-android-intents/README.md
@@ -0,0 +1,8 @@
+# Expo Receive Android Intents
+
+This module handles incoming intents on Android. Handled intents are `text/plain` and `image/*` (single or multiple).
+The module handles saving images to the app's filesystem for access within the app, limiting the selection of images
+to a max of four, and handling intent types. No JS code is required for this module, and it is no-op on non-android
+platforms.
+
+No installation is required. Gradle will automatically add this module on build.
diff --git a/modules/expo-receive-android-intents/android/.gitignore b/modules/expo-receive-android-intents/android/.gitignore
new file mode 100644
index 0000000000..877b87e9a5
--- /dev/null
+++ b/modules/expo-receive-android-intents/android/.gitignore
@@ -0,0 +1,15 @@
+# OSX
+#
+.DS_Store
+
+# Android/IntelliJ
+#
+build/
+.idea
+.gradle
+local.properties
+*.iml
+*.hprof
+
+# Bundle artifacts
+*.jsbundle
diff --git a/modules/expo-receive-android-intents/android/build.gradle b/modules/expo-receive-android-intents/android/build.gradle
new file mode 100644
index 0000000000..3712dda40f
--- /dev/null
+++ b/modules/expo-receive-android-intents/android/build.gradle
@@ -0,0 +1,92 @@
+apply plugin: 'com.android.library'
+apply plugin: 'kotlin-android'
+apply plugin: 'maven-publish'
+
+group = 'xyz.blueskyweb.app.exporeceiveandroidintents'
+version = '0.4.1'
+
+buildscript {
+ def expoModulesCorePlugin = new File(project(":expo-modules-core").projectDir.absolutePath, "ExpoModulesCorePlugin.gradle")
+ if (expoModulesCorePlugin.exists()) {
+ apply from: expoModulesCorePlugin
+ applyKotlinExpoModulesCorePlugin()
+ }
+
+ // 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
+ }
+
+ // Ensures backward compatibility
+ ext.getKotlinVersion = {
+ if (ext.has("kotlinVersion")) {
+ ext.kotlinVersion()
+ } else {
+ ext.safeExtGet("kotlinVersion", "1.8.10")
+ }
+ }
+
+ repositories {
+ mavenCentral()
+ }
+
+ dependencies {
+ classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:${getKotlinVersion()}")
+ }
+}
+
+afterEvaluate {
+ publishing {
+ publications {
+ release(MavenPublication) {
+ from components.release
+ }
+ }
+ repositories {
+ maven {
+ url = mavenLocal().url
+ }
+ }
+ }
+}
+
+android {
+ compileSdkVersion safeExtGet("compileSdkVersion", 33)
+
+ def agpVersion = com.android.Version.ANDROID_GRADLE_PLUGIN_VERSION
+ if (agpVersion.tokenize('.')[0].toInteger() < 8) {
+ compileOptions {
+ sourceCompatibility JavaVersion.VERSION_11
+ targetCompatibility JavaVersion.VERSION_11
+ }
+
+ kotlinOptions {
+ jvmTarget = JavaVersion.VERSION_11.majorVersion
+ }
+ }
+
+ namespace "xyz.blueskyweb.app.exporeceiveandroidintents"
+ defaultConfig {
+ minSdkVersion safeExtGet("minSdkVersion", 21)
+ targetSdkVersion safeExtGet("targetSdkVersion", 34)
+ versionCode 1
+ versionName "0.4.1"
+ }
+ lintOptions {
+ abortOnError false
+ }
+ publishing {
+ singleVariant("release") {
+ withSourcesJar()
+ }
+ }
+}
+
+repositories {
+ mavenCentral()
+}
+
+dependencies {
+ implementation project(':expo-modules-core')
+ implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:${getKotlinVersion()}"
+}
diff --git a/modules/expo-receive-android-intents/android/src/main/AndroidManifest.xml b/modules/expo-receive-android-intents/android/src/main/AndroidManifest.xml
new file mode 100644
index 0000000000..bdae66c8f5
--- /dev/null
+++ b/modules/expo-receive-android-intents/android/src/main/AndroidManifest.xml
@@ -0,0 +1,2 @@
+
+
diff --git a/modules/expo-receive-android-intents/android/src/main/java/xyz/blueskyweb/app/exporeceiveandroidintents/ExpoReceiveAndroidIntentsModule.kt b/modules/expo-receive-android-intents/android/src/main/java/xyz/blueskyweb/app/exporeceiveandroidintents/ExpoReceiveAndroidIntentsModule.kt
new file mode 100644
index 0000000000..c2e17fb80a
--- /dev/null
+++ b/modules/expo-receive-android-intents/android/src/main/java/xyz/blueskyweb/app/exporeceiveandroidintents/ExpoReceiveAndroidIntentsModule.kt
@@ -0,0 +1,119 @@
+package xyz.blueskyweb.app.exporeceiveandroidintents
+
+import android.content.Intent
+import android.graphics.Bitmap
+import android.net.Uri
+import android.os.Build
+import android.provider.MediaStore
+import androidx.core.net.toUri
+import expo.modules.kotlin.modules.Module
+import expo.modules.kotlin.modules.ModuleDefinition
+import java.io.File
+import java.io.FileOutputStream
+import java.net.URLEncoder
+
+class ExpoReceiveAndroidIntentsModule : Module() {
+ override fun definition() = ModuleDefinition {
+ Name("ExpoReceiveAndroidIntents")
+
+ OnNewIntent {
+ handleIntent(it)
+ }
+ }
+
+ private fun handleIntent(intent: Intent?) {
+ if(appContext.currentActivity == null || intent == null) return
+
+ if (intent.action == Intent.ACTION_SEND) {
+ if (intent.type == "text/plain") {
+ handleTextIntent(intent)
+ } else if (intent.type.toString().startsWith("image/")) {
+ handleImageIntent(intent)
+ }
+ } else if (intent.action == Intent.ACTION_SEND_MULTIPLE) {
+ if (intent.type.toString().startsWith("image/")) {
+ handleImagesIntent(intent)
+ }
+ }
+ }
+
+ private fun handleTextIntent(intent: Intent) {
+ intent.getStringExtra(Intent.EXTRA_TEXT)?.let {
+ val encoded = URLEncoder.encode(it, "UTF-8")
+ "bluesky://intent/compose?text=${encoded}".toUri().let { uri ->
+ val newIntent = Intent(Intent.ACTION_VIEW, uri)
+ appContext.currentActivity?.startActivity(newIntent)
+ }
+ }
+ }
+
+ private fun handleImageIntent(intent: Intent) {
+ val uri = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
+ intent.getParcelableExtra(Intent.EXTRA_STREAM, Uri::class.java)
+ } else {
+ intent.getParcelableExtra(Intent.EXTRA_STREAM)
+ }
+ if (uri == null) return
+
+ handleImageIntents(listOf(uri))
+ }
+
+ private fun handleImagesIntent(intent: Intent) {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
+ intent.getParcelableArrayListExtra(Intent.EXTRA_STREAM, Uri::class.java)?.let {
+ handleImageIntents(it.filterIsInstance().take(4))
+ }
+ } else {
+ intent.getParcelableArrayListExtra(Intent.EXTRA_STREAM)?.let {
+ handleImageIntents(it.filterIsInstance().take(4))
+ }
+ }
+ }
+
+ private fun handleImageIntents(uris: List) {
+ var allParams = ""
+
+ uris.forEachIndexed { index, uri ->
+ val info = getImageInfo(uri)
+ val params = buildUriData(info)
+ allParams = "${allParams}${params}"
+
+ if (index < uris.count() - 1) {
+ allParams = "${allParams},"
+ }
+ }
+
+ val encoded = URLEncoder.encode(allParams, "UTF-8")
+
+ "bluesky://intent/compose?imageUris=${encoded}".toUri().let {
+ val newIntent = Intent(Intent.ACTION_VIEW, it)
+ appContext.currentActivity?.startActivity(newIntent)
+ }
+ }
+
+ private fun getImageInfo(uri: Uri): Map {
+ val bitmap = MediaStore.Images.Media.getBitmap(appContext.currentActivity?.contentResolver, uri)
+ // We have to save this so that we can access it later when uploading the image.
+ // createTempFile will automatically place a unique string between "img" and "temp.jpeg"
+ val file = File.createTempFile("img", "temp.jpeg", appContext.currentActivity?.cacheDir)
+ val out = FileOutputStream(file)
+ bitmap.compress(Bitmap.CompressFormat.JPEG, 100, out)
+ out.flush()
+ out.close()
+
+ return mapOf(
+ "width" to bitmap.width,
+ "height" to bitmap.height,
+ "path" to file.path.toString()
+ )
+ }
+
+ // We will pas the width and height to the app here, since getting measurements
+ // on the RN side is a bit more involved, and we already have them here anyway.
+ private fun buildUriData(info: Map): String {
+ val path = info.getValue("path")
+ val width = info.getValue("width")
+ val height = info.getValue("height")
+ return "file://${path}|${width}|${height}"
+ }
+}
diff --git a/modules/expo-receive-android-intents/expo-module.config.json b/modules/expo-receive-android-intents/expo-module.config.json
new file mode 100644
index 0000000000..8f01fb6c9f
--- /dev/null
+++ b/modules/expo-receive-android-intents/expo-module.config.json
@@ -0,0 +1,6 @@
+{
+ "platforms": ["android"],
+ "android": {
+ "modules": ["xyz.blueskyweb.app.exporeceiveandroidintents.ExpoReceiveAndroidIntentsModule"]
+ }
+}
diff --git a/package.json b/package.json
index 2d520b4be8..e9dd9202df 100644
--- a/package.json
+++ b/package.json
@@ -40,7 +40,8 @@
"intl:check": "yarn intl:extract && git diff-index -G'(^[^\\*# /])|(^#\\w)|(^\\s+[^\\*#/])' HEAD || (echo '\n⚠️ i18n detected un-extracted translations\n' && exit 1)",
"intl:extract": "lingui extract",
"intl:compile": "lingui compile",
- "nuke": "rm -rf ./node_modules && rm -rf ./ios && rm -rf ./android"
+ "nuke": "rm -rf ./node_modules && rm -rf ./ios && rm -rf ./android",
+ "update-extensions": "scripts/updateExtensions.sh"
},
"dependencies": {
"@atproto/api": "^0.10.0",
diff --git a/plugins/shareExtension/README.md b/plugins/shareExtension/README.md
new file mode 100644
index 0000000000..2b57e624ae
--- /dev/null
+++ b/plugins/shareExtension/README.md
@@ -0,0 +1,22 @@
+# Share extension plugin for Expo
+
+This plugin handles moving the necessary files into their respective iOS and Android targets and updating the build
+phases, plists, manifests, etc.
+
+## Steps
+
+### ios
+
+1. Update entitlements
+2. Set the app group to group.
+3. Add the extension plist
+4. Add the view controller
+5. Update the xcode project's build phases
+
+### android
+
+1. Update the manifest with the intents the app can receive
+
+## Credits
+
+Adapted from https://github.com/andrew-levy/react-native-safari-extension and https://github.com/timedtext/expo-config-plugin-ios-share-extension/blob/master/src/withShareExtensionXcodeTarget.ts
diff --git a/plugins/shareExtension/withAppEntitlements.js b/plugins/shareExtension/withAppEntitlements.js
new file mode 100644
index 0000000000..6f9136c374
--- /dev/null
+++ b/plugins/shareExtension/withAppEntitlements.js
@@ -0,0 +1,13 @@
+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.${config.ios.bundleIdentifier}`,
+ ]
+ return config
+ })
+}
+
+module.exports = {withAppEntitlements}
diff --git a/plugins/shareExtension/withExtensionEntitlements.js b/plugins/shareExtension/withExtensionEntitlements.js
new file mode 100644
index 0000000000..e6bbf9d234
--- /dev/null
+++ b/plugins/shareExtension/withExtensionEntitlements.js
@@ -0,0 +1,33 @@
+const {withInfoPlist} = require('@expo/config-plugins')
+const plist = require('@expo/plist')
+const path = require('path')
+const fs = require('fs')
+
+const withExtensionEntitlements = (config, {extensionName}) => {
+ // eslint-disable-next-line no-shadow
+ return withInfoPlist(config, config => {
+ const extensionEntitlementsPath = path.join(
+ config.modRequest.platformProjectRoot,
+ extensionName,
+ `${extensionName}.entitlements`,
+ )
+
+ const shareExtensionEntitlements = {
+ 'com.apple.security.application-groups': [
+ `group.${config.ios?.bundleIdentifier}`,
+ ],
+ }
+
+ fs.mkdirSync(path.dirname(extensionEntitlementsPath), {
+ recursive: true,
+ })
+ fs.writeFileSync(
+ extensionEntitlementsPath,
+ plist.default.build(shareExtensionEntitlements),
+ )
+
+ return config
+ })
+}
+
+module.exports = {withExtensionEntitlements}
diff --git a/plugins/shareExtension/withExtensionInfoPlist.js b/plugins/shareExtension/withExtensionInfoPlist.js
new file mode 100644
index 0000000000..9afc4d5f92
--- /dev/null
+++ b/plugins/shareExtension/withExtensionInfoPlist.js
@@ -0,0 +1,39 @@
+const {withInfoPlist} = require('@expo/config-plugins')
+const plist = require('@expo/plist')
+const path = require('path')
+const fs = require('fs')
+
+const withExtensionInfoPlist = (config, {extensionName}) => {
+ // eslint-disable-next-line no-shadow
+ return withInfoPlist(config, config => {
+ const plistPath = path.join(
+ config.modRequest.projectRoot,
+ 'modules',
+ extensionName,
+ 'Info.plist',
+ )
+ const targetPath = path.join(
+ config.modRequest.platformProjectRoot,
+ extensionName,
+ 'Info.plist',
+ )
+
+ const extPlist = plist.default.parse(fs.readFileSync(plistPath).toString())
+
+ extPlist.MainAppScheme = config.scheme
+ extPlist.CFBundleName = '$(PRODUCT_NAME)'
+ extPlist.CFBundleDisplayName = 'Extension'
+ extPlist.CFBundleIdentifier = '$(PRODUCT_BUNDLE_IDENTIFIER)'
+ extPlist.CFBundleVersion = '$(CURRENT_PROJECT_VERSION)'
+ extPlist.CFBundleExecutable = '$(EXECUTABLE_NAME)'
+ extPlist.CFBundlePackageType = '$(PRODUCT_BUNDLE_PACKAGE_TYPE)'
+ extPlist.CFBundleShortVersionString = '$(MARKETING_VERSION)'
+
+ fs.mkdirSync(path.dirname(targetPath), {recursive: true})
+ fs.writeFileSync(targetPath, plist.default.build(extPlist))
+
+ return config
+ })
+}
+
+module.exports = {withExtensionInfoPlist}
diff --git a/plugins/shareExtension/withExtensionViewController.js b/plugins/shareExtension/withExtensionViewController.js
new file mode 100644
index 0000000000..cd29bea7da
--- /dev/null
+++ b/plugins/shareExtension/withExtensionViewController.js
@@ -0,0 +1,31 @@
+const {withXcodeProject} = require('@expo/config-plugins')
+const path = require('path')
+const fs = require('fs')
+
+const withExtensionViewController = (
+ config,
+ {controllerName, extensionName},
+) => {
+ // eslint-disable-next-line no-shadow
+ return withXcodeProject(config, config => {
+ const controllerPath = path.join(
+ config.modRequest.projectRoot,
+ 'modules',
+ extensionName,
+ `${controllerName}.swift`,
+ )
+
+ const targetPath = path.join(
+ config.modRequest.platformProjectRoot,
+ extensionName,
+ `${controllerName}.swift`,
+ )
+
+ fs.mkdirSync(path.dirname(targetPath), {recursive: true})
+ fs.copyFileSync(controllerPath, targetPath)
+
+ return config
+ })
+}
+
+module.exports = {withExtensionViewController}
diff --git a/plugins/shareExtension/withIntentFilters.js b/plugins/shareExtension/withIntentFilters.js
new file mode 100644
index 0000000000..605fcfd052
--- /dev/null
+++ b/plugins/shareExtension/withIntentFilters.js
@@ -0,0 +1,89 @@
+const {withAndroidManifest} = require('@expo/config-plugins')
+
+const withIntentFilters = config => {
+ // eslint-disable-next-line no-shadow
+ return withAndroidManifest(config, config => {
+ const intents = [
+ {
+ action: [
+ {
+ $: {
+ 'android:name': 'android.intent.action.SEND',
+ },
+ },
+ ],
+ category: [
+ {
+ $: {
+ 'android:name': 'android.intent.category.DEFAULT',
+ },
+ },
+ ],
+ data: [
+ {
+ $: {
+ 'android:mimeType': 'image/*',
+ },
+ },
+ ],
+ },
+ {
+ action: [
+ {
+ $: {
+ 'android:name': 'android.intent.action.SEND',
+ },
+ },
+ ],
+ category: [
+ {
+ $: {
+ 'android:name': 'android.intent.category.DEFAULT',
+ },
+ },
+ ],
+ data: [
+ {
+ $: {
+ 'android:mimeType': 'text/plain',
+ },
+ },
+ ],
+ },
+ {
+ action: [
+ {
+ $: {
+ 'android:name': 'android.intent.action.SEND_MULTIPLE',
+ },
+ },
+ ],
+ category: [
+ {
+ $: {
+ 'android:name': 'android.intent.category.DEFAULT',
+ },
+ },
+ ],
+ data: [
+ {
+ $: {
+ 'android:mimeType': 'image/*',
+ },
+ },
+ ],
+ },
+ ]
+
+ const intentFilter =
+ config.modResults.manifest.application?.[0].activity?.[0]['intent-filter']
+
+ if (intentFilter) {
+ intentFilter.push(...intents)
+ }
+
+ return config
+ })
+}
+
+module.exports = {withIntentFilters}
diff --git a/plugins/shareExtension/withShareExtensions.js b/plugins/shareExtension/withShareExtensions.js
new file mode 100644
index 0000000000..55a26c75eb
--- /dev/null
+++ b/plugins/shareExtension/withShareExtensions.js
@@ -0,0 +1,47 @@
+const {withPlugins} = require('@expo/config-plugins')
+const {withAppEntitlements} = require('./withAppEntitlements')
+const {withXcodeTarget} = require('./withXcodeTarget')
+const {withExtensionEntitlements} = require('./withExtensionEntitlements')
+const {withExtensionInfoPlist} = require('./withExtensionInfoPlist')
+const {withExtensionViewController} = require('./withExtensionViewController')
+const {withIntentFilters} = require('./withIntentFilters')
+
+const SHARE_EXTENSION_NAME = 'Share-with-Bluesky'
+const SHARE_EXTENSION_CONTROLLER_NAME = 'ShareViewController'
+
+const withShareExtensions = config => {
+ return withPlugins(config, [
+ // IOS
+ withAppEntitlements,
+ [
+ withExtensionEntitlements,
+ {
+ extensionName: SHARE_EXTENSION_NAME,
+ },
+ ],
+ [
+ withExtensionInfoPlist,
+ {
+ extensionName: SHARE_EXTENSION_NAME,
+ },
+ ],
+ [
+ withExtensionViewController,
+ {
+ extensionName: SHARE_EXTENSION_NAME,
+ controllerName: SHARE_EXTENSION_CONTROLLER_NAME,
+ },
+ ],
+ [
+ withXcodeTarget,
+ {
+ extensionName: SHARE_EXTENSION_NAME,
+ controllerName: SHARE_EXTENSION_CONTROLLER_NAME,
+ },
+ ],
+ // Android
+ withIntentFilters,
+ ])
+}
+
+module.exports = withShareExtensions
diff --git a/plugins/shareExtension/withXcodeTarget.js b/plugins/shareExtension/withXcodeTarget.js
new file mode 100644
index 0000000000..4f43c09262
--- /dev/null
+++ b/plugins/shareExtension/withXcodeTarget.js
@@ -0,0 +1,55 @@
+const {withXcodeProject} = require('@expo/config-plugins')
+
+const withXcodeTarget = (config, {extensionName, controllerName}) => {
+ // eslint-disable-next-line no-shadow
+ return withXcodeProject(config, config => {
+ const pbxProject = config.modResults
+
+ const target = pbxProject.addTarget(
+ extensionName,
+ 'app_extension',
+ extensionName,
+ )
+ pbxProject.addBuildPhase([], 'PBXSourcesBuildPhase', 'Sources', target.uuid)
+ pbxProject.addBuildPhase(
+ [],
+ 'PBXResourcesBuildPhase',
+ 'Resources',
+ target.uuid,
+ )
+ const pbxGroupKey = pbxProject.pbxCreateGroup(extensionName, extensionName)
+ pbxProject.addFile(`${extensionName}/Info.plist`, pbxGroupKey)
+ pbxProject.addSourceFile(
+ `${extensionName}/${controllerName}.swift`,
+ {target: target.uuid},
+ pbxGroupKey,
+ )
+
+ var configurations = pbxProject.pbxXCBuildConfigurationSection()
+ for (var key in configurations) {
+ if (typeof configurations[key].buildSettings !== 'undefined') {
+ var buildSettingsObj = configurations[key].buildSettings
+ if (
+ typeof buildSettingsObj.PRODUCT_NAME !== 'undefined' &&
+ buildSettingsObj.PRODUCT_NAME === `"${extensionName}"`
+ ) {
+ buildSettingsObj.CLANG_ENABLE_MODULES = 'YES'
+ buildSettingsObj.INFOPLIST_FILE = `"${extensionName}/Info.plist"`
+ buildSettingsObj.CODE_SIGN_ENTITLEMENTS = `"${extensionName}/${extensionName}.entitlements"`
+ buildSettingsObj.CODE_SIGN_STYLE = 'Automatic'
+ buildSettingsObj.CURRENT_PROJECT_VERSION = `"${config.ios?.buildNumber}"`
+ buildSettingsObj.GENERATE_INFOPLIST_FILE = 'YES'
+ buildSettingsObj.MARKETING_VERSION = `"${config.version}"`
+ buildSettingsObj.PRODUCT_BUNDLE_IDENTIFIER = `"${config.ios?.bundleIdentifier}.${extensionName}"`
+ buildSettingsObj.SWIFT_EMIT_LOC_STRINGS = 'YES'
+ buildSettingsObj.SWIFT_VERSION = '5.0'
+ buildSettingsObj.TARGETED_DEVICE_FAMILY = `"1,2"`
+ }
+ }
+ }
+
+ return config
+ })
+}
+
+module.exports = {withXcodeTarget}
diff --git a/scripts/README.md b/scripts/README.md
new file mode 100644
index 0000000000..99d6236f90
--- /dev/null
+++ b/scripts/README.md
@@ -0,0 +1,5 @@
+# Tool Scripts
+
+## updateExtensions.sh
+
+Updates the extensions in `/modules` with the current iOS/Android project changes.
diff --git a/scripts/updateExtensions.sh b/scripts/updateExtensions.sh
new file mode 100755
index 0000000000..f4e462b744
--- /dev/null
+++ b/scripts/updateExtensions.sh
@@ -0,0 +1,10 @@
+#!/bin/bash
+IOS_SHARE_EXTENSION_DIRECTORY="./ios/Share-with-Bluesky"
+MODULES_DIRECTORY="./modules"
+
+if [ ! -d $IOS_SHARE_EXTENSION_DIRECTORY ]; then
+ echo "$IOS_SHARE_EXTENSION_DIRECTORY not found inside of your iOS project."
+ exit 1
+else
+ cp -R $IOS_SHARE_EXTENSION_DIRECTORY $MODULES_DIRECTORY
+fi
diff --git a/src/lib/hooks/useIntentHandler.ts b/src/lib/hooks/useIntentHandler.ts
index 249e6898e4..de9a96da9b 100644
--- a/src/lib/hooks/useIntentHandler.ts
+++ b/src/lib/hooks/useIntentHandler.ts
@@ -6,6 +6,8 @@ import {useSession} from 'state/session'
type IntentType = 'compose'
+const VALID_IMAGE_REGEX = /^[\w.:\-_/]+\|\d+(\.\d+)?\|\d+(\.\d+)?$/
+
export function useIntentHandler() {
const incomingUrl = Linking.useURL()
const composeIntent = useComposeIntent()
@@ -29,7 +31,7 @@ export function useIntentHandler() {
case 'compose': {
composeIntent({
text: params.get('text'),
- imageUris: params.get('imageUris'),
+ imageUrisStr: params.get('imageUris'),
})
}
}
@@ -45,18 +47,39 @@ function useComposeIntent() {
return React.useCallback(
({
- // eslint-disable-next-line @typescript-eslint/no-unused-vars
text,
- // eslint-disable-next-line @typescript-eslint/no-unused-vars
- imageUris,
+ imageUrisStr,
}: {
text: string | null
- imageUris: string | null // unused for right now, will be used later with intents
+ imageUrisStr: string | null // unused for right now, will be used later with intents
}) => {
if (!hasSession) return
+ const imageUris = imageUrisStr
+ ?.split(',')
+ .filter(part => {
+ // For some security, we're going to filter out any image uri that is external. We don't want someone to
+ // be able to provide some link like "bluesky://intent/compose?imageUris=https://IHaveYourIpNow.com/image.jpeg
+ // and we load that image
+ if (part.includes('https://') || part.includes('http://')) {
+ return false
+ }
+ // We also should just filter out cases that don't have all the info we need
+ if (!VALID_IMAGE_REGEX.test(part)) {
+ return false
+ }
+ return true
+ })
+ .map(part => {
+ const [uri, width, height] = part.split('|')
+ return {uri, width: Number(width), height: Number(height)}
+ })
+
setTimeout(() => {
- openComposer({}) // will pass in values to the composer here in the share extension
+ openComposer({
+ text: text ?? undefined,
+ imageUris: isNative ? imageUris : undefined,
+ })
}, 500)
},
[openComposer, hasSession],
diff --git a/src/state/models/media/gallery.ts b/src/state/models/media/gallery.ts
index 04023bf820..9c8c13010d 100644
--- a/src/state/models/media/gallery.ts
+++ b/src/state/models/media/gallery.ts
@@ -4,11 +4,21 @@ import {Image as RNImage} from 'react-native-image-crop-picker'
import {openPicker} from 'lib/media/picker'
import {getImageDim} from 'lib/media/manip'
+interface InitialImageUri {
+ uri: string
+ width: number
+ height: number
+}
+
export class GalleryModel {
images: ImageModel[] = []
- constructor() {
+ constructor(uris?: {uri: string; width: number; height: number}[]) {
makeAutoObservable(this)
+
+ if (uris) {
+ this.addFromUris(uris)
+ }
}
get isEmpty() {
@@ -23,7 +33,7 @@ export class GalleryModel {
return this.images.some(image => image.altText.trim() === '')
}
- async add(image_: Omit) {
+ *add(image_: Omit) {
if (this.size >= 4) {
return
}
@@ -86,4 +96,15 @@ export class GalleryModel {
}),
)
}
+
+ async addFromUris(uris: InitialImageUri[]) {
+ for (const uriObj of uris) {
+ this.add({
+ mime: 'image/jpeg',
+ height: uriObj.height,
+ width: uriObj.width,
+ path: uriObj.uri,
+ })
+ }
+ }
}
diff --git a/src/state/shell/composer.tsx b/src/state/shell/composer.tsx
index 696a3c5ba1..c9dbfbeac4 100644
--- a/src/state/shell/composer.tsx
+++ b/src/state/shell/composer.tsx
@@ -38,6 +38,8 @@ export interface ComposerOpts {
quote?: ComposerOptsQuote
mention?: string // handle of user to mention
openPicker?: (pos: DOMRect | undefined) => void
+ text?: string
+ imageUris?: {uri: string; width: number; height: number}[]
}
type StateContext = ComposerOpts | undefined
diff --git a/src/view/com/composer/Composer.tsx b/src/view/com/composer/Composer.tsx
index 1ed6b98a55..2855d4232c 100644
--- a/src/view/com/composer/Composer.tsx
+++ b/src/view/com/composer/Composer.tsx
@@ -71,6 +71,8 @@ export const ComposePost = observer(function ComposePost({
quote: initQuote,
mention: initMention,
openPicker,
+ text: initText,
+ imageUris: initImageUris,
}: Props) {
const {currentAccount} = useSession()
const {data: currentProfile} = useProfileQuery({did: currentAccount!.did})
@@ -91,7 +93,9 @@ export const ComposePost = observer(function ComposePost({
const [error, setError] = useState('')
const [richtext, setRichText] = useState(
new RichText({
- text: initMention
+ text: initText
+ ? initText
+ : initMention
? insertMentionAt(
`@${initMention}`,
initMention.length + 1,
@@ -110,7 +114,10 @@ export const ComposePost = observer(function ComposePost({
const [labels, setLabels] = useState([])
const [threadgate, setThreadgate] = useState([])
const [suggestedLinks, setSuggestedLinks] = useState>(new Set())
- const gallery = useMemo(() => new GalleryModel(), [])
+ const gallery = useMemo(
+ () => new GalleryModel(initImageUris),
+ [initImageUris],
+ )
const onClose = useCallback(() => {
closeComposer()
}, [closeComposer])
diff --git a/src/view/shell/Composer.tsx b/src/view/shell/Composer.tsx
index d37ff4fb7c..1937fcb6ea 100644
--- a/src/view/shell/Composer.tsx
+++ b/src/view/shell/Composer.tsx
@@ -55,6 +55,8 @@ export const Composer = observer(function ComposerImpl({
onPost={state.onPost}
quote={state.quote}
mention={state.mention}
+ text={state.text}
+ imageUris={state.imageUris}
/>
)
diff --git a/src/view/shell/Composer.web.tsx b/src/view/shell/Composer.web.tsx
index 99e659d62d..00233f66af 100644
--- a/src/view/shell/Composer.web.tsx
+++ b/src/view/shell/Composer.web.tsx
@@ -9,7 +9,7 @@ import {useWebBodyScrollLock} from '#/lib/hooks/useWebBodyScrollLock'
import {
EmojiPicker,
EmojiPickerState,
-} from 'view/com/composer/text-input/web/EmojiPicker.web.tsx'
+} from 'view/com/composer/text-input/web/EmojiPicker.web'
const BOTTOM_BAR_HEIGHT = 61
@@ -69,6 +69,7 @@ export function Composer({}: {winHeight: number}) {
onPost={state.onPost}
mention={state.mention}
openPicker={onOpenPicker}
+ text={state.text}
/>