Bluesky app fork with some witchin' additions 💫

Share Extension/Intents (#2587)

* add native ios code outside of ios project

* helper script

* going to be a lot of these commits to squash...backing up

* save

* start of an expo plugin

* create info.plist

* copy the view controller

* maybe working

* working

* wait working now

* working plugin

* use current scheme

* update intent path

* use better params

* support text in uri

* build

* use better encoding

* handle images

* cleanup ios plugin

* android

* move bash script to /scripts

* handle cases where loaded data is uiimage rather than uri

* remove unnecessary logic, allow more than 4 images and just take first 4

* android build plugin

* limit images to four on android

* use js for plugins, no need to build

* revert changes to app config

* use correct scheme on android

* android readme

* move ios extension to /modules

* remove unnecessary event

* revert typo

* plugin readme

* scripts readme

* add configurable scheme to .env, default to `bluesky`

* remove debug

* revert .gitignore change

* add comment about updating .env to app.config.js for those modifying scheme

* modify .env

* update android module to use the proper url

* update ios extension

* remove comment

* parse and validate incoming image uris

* fix types

* rm oops

* fix a few typos

authored by hailey.at and committed by GitHub d451f82f ac726497

+1
app.config.js
··· 141 141 }, 142 142 ], 143 143 './plugins/withAndroidManifestPlugin.js', 144 + './plugins/shareExtension/withShareExtensions.js', 144 145 ].filter(Boolean), 145 146 extra: { 146 147 eas: {
+41
modules/Share-with-Bluesky/Info.plist
··· 1 + <?xml version="1.0" encoding="UTF-8"?> 2 + <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> 3 + <plist version="1.0"> 4 + <dict> 5 + <key>NSExtension</key> 6 + <dict> 7 + <key>NSExtensionPrincipalClass</key> 8 + <string>$(PRODUCT_MODULE_NAME).ShareViewController</string> 9 + <key>NSExtensionAttributes</key> 10 + <dict> 11 + <key>NSExtensionActivationRule</key> 12 + <dict> 13 + <key>NSExtensionActivationSupportsText</key> 14 + <true/> 15 + <key>NSExtensionActivationSupportsWebURLWithMaxCount</key> 16 + <integer>1</integer> 17 + <key>NSExtensionActivationSupportsImageWithMaxCount</key> 18 + <integer>10</integer> 19 + </dict> 20 + </dict> 21 + <key>NSExtensionPointIdentifier</key> 22 + <string>com.apple.share-services</string> 23 + </dict> 24 + <key>MainAppScheme</key> 25 + <string>bluesky</string> 26 + <key>CFBundleName</key> 27 + <string>$(PRODUCT_NAME)</string> 28 + <key>CFBundleDisplayName</key> 29 + <string>Extension</string> 30 + <key>CFBundleIdentifier</key> 31 + <string>$(PRODUCT_BUNDLE_IDENTIFIER)</string> 32 + <key>CFBundleVersion</key> 33 + <string>$(CURRENT_PROJECT_VERSION)</string> 34 + <key>CFBundleExecutable</key> 35 + <string>$(EXECUTABLE_NAME)</string> 36 + <key>CFBundlePackageType</key> 37 + <string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string> 38 + <key>CFBundleShortVersionString</key> 39 + <string>$(MARKETING_VERSION)</string> 40 + </dict> 41 + </plist>
+10
modules/Share-with-Bluesky/Share-with-Bluesky.entitlements
··· 1 + <?xml version="1.0" encoding="UTF-8"?> 2 + <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> 3 + <plist version="1.0"> 4 + <dict> 5 + <key>com.apple.security.application-groups</key> 6 + <array> 7 + <string>group.xyz.blueskyweb.app</string> 8 + </array> 9 + </dict> 10 + </plist>
+153
modules/Share-with-Bluesky/ShareViewController.swift
··· 1 + import UIKit 2 + 3 + class ShareViewController: UIViewController { 4 + // This allows other forks to use this extension while also changing their 5 + // scheme. 6 + let appScheme = Bundle.main.object(forInfoDictionaryKey: "MainAppScheme") as? String ?? "bluesky" 7 + 8 + // 9 + override func viewDidAppear(_ animated: Bool) { 10 + super.viewDidAppear(animated) 11 + 12 + guard let extensionItem = extensionContext?.inputItems.first as? NSExtensionItem, 13 + let attachments = extensionItem.attachments, 14 + let firstAttachment = extensionItem.attachments?.first 15 + else { 16 + self.completeRequest() 17 + return 18 + } 19 + 20 + Task { 21 + if firstAttachment.hasItemConformingToTypeIdentifier("public.text") { 22 + await self.handleText(item: firstAttachment) 23 + } else if firstAttachment.hasItemConformingToTypeIdentifier("public.url") { 24 + await self.handleUrl(item: firstAttachment) 25 + } else if firstAttachment.hasItemConformingToTypeIdentifier("public.image") { 26 + await self.handleImages(items: attachments) 27 + } else { 28 + self.completeRequest() 29 + } 30 + } 31 + } 32 + 33 + private func handleText(item: NSItemProvider) async -> Void { 34 + do { 35 + if let data = try await item.loadItem(forTypeIdentifier: "public.text") as? String { 36 + if let encoded = data.addingPercentEncoding(withAllowedCharacters: .urlHostAllowed), 37 + let url = URL(string: "\(self.appScheme)://intent/compose?text=\(encoded)") 38 + { 39 + _ = self.openURL(url) 40 + } 41 + } 42 + self.completeRequest() 43 + } catch { 44 + self.completeRequest() 45 + } 46 + } 47 + 48 + private func handleUrl(item: NSItemProvider) async -> Void { 49 + do { 50 + if let data = try await item.loadItem(forTypeIdentifier: "public.url") as? URL { 51 + if let encoded = data.absoluteString.addingPercentEncoding(withAllowedCharacters: .urlHostAllowed), 52 + let url = URL(string: "\(self.appScheme)://intent/compose?text=\(encoded)") 53 + { 54 + _ = self.openURL(url) 55 + } 56 + } 57 + self.completeRequest() 58 + } catch { 59 + self.completeRequest() 60 + } 61 + } 62 + 63 + private func handleImages(items: [NSItemProvider]) async -> Void { 64 + let firstFourItems: [NSItemProvider] 65 + if items.count < 4 { 66 + firstFourItems = items 67 + } else { 68 + firstFourItems = Array(items[0...3]) 69 + } 70 + 71 + var valid = true 72 + var imageUris = "" 73 + 74 + for (index, item) in firstFourItems.enumerated() { 75 + var imageUriInfo: String? = nil 76 + 77 + do { 78 + if let dataUri = try await item.loadItem(forTypeIdentifier: "public.image") as? URL { 79 + // We need to duplicate this image, since we don't have access to the outgoing temp directory 80 + // We also will get the image dimensions here, sinze RN makes it difficult to get those dimensions for local files 81 + let data = try Data(contentsOf: dataUri) 82 + let image = UIImage(data: data) 83 + imageUriInfo = self.saveImageWithInfo(image) 84 + } else if let image = try await item.loadItem(forTypeIdentifier: "public.image") as? UIImage { 85 + imageUriInfo = self.saveImageWithInfo(image) 86 + } 87 + } catch { 88 + valid = false 89 + } 90 + 91 + if let imageUriInfo = imageUriInfo { 92 + imageUris.append(imageUriInfo) 93 + if index < items.count - 1 { 94 + imageUris.append(",") 95 + } 96 + } else { 97 + valid = false 98 + } 99 + } 100 + 101 + if valid, 102 + let encoded = imageUris.addingPercentEncoding(withAllowedCharacters: .urlHostAllowed), 103 + let url = URL(string: "\(self.appScheme)://intent/compose?imageUris=\(encoded)") 104 + { 105 + _ = self.openURL(url) 106 + } 107 + 108 + self.completeRequest() 109 + } 110 + 111 + private func saveImageWithInfo(_ image: UIImage?) -> String? { 112 + guard let image = image else { 113 + return nil 114 + } 115 + 116 + do { 117 + // Saving this file to the bundle group's directory lets us access it from 118 + // inside of the app. Otherwise, we wouldn't have access even though the 119 + // extension does. 120 + if let dir = FileManager() 121 + .containerURL( 122 + forSecurityApplicationGroupIdentifier: "group.\(Bundle.main.bundleIdentifier?.replacingOccurrences(of: ".Share-with-Bluesky", with: "") ?? "")") 123 + { 124 + let filePath = "\(dir.absoluteString)\(ProcessInfo.processInfo.globallyUniqueString).jpeg" 125 + 126 + if let newUri = URL(string: filePath), 127 + let jpegData = image.jpegData(compressionQuality: 1) 128 + { 129 + try jpegData.write(to: newUri) 130 + return "\(newUri.absoluteString)|\(image.size.width)|\(image.size.height)" 131 + } 132 + } 133 + return nil 134 + } catch { 135 + return nil 136 + } 137 + } 138 + 139 + private func completeRequest() -> Void { 140 + self.extensionContext?.completeRequest(returningItems: nil) 141 + } 142 + 143 + @objc func openURL(_ url: URL) -> Bool { 144 + var responder: UIResponder? = self 145 + while responder != nil { 146 + if let application = responder as? UIApplication { 147 + return application.perform(#selector(openURL(_:)), with: url) != nil 148 + } 149 + responder = responder?.next 150 + } 151 + return false 152 + } 153 + }
+8
modules/expo-receive-android-intents/README.md
··· 1 + # Expo Receive Android Intents 2 + 3 + This module handles incoming intents on Android. Handled intents are `text/plain` and `image/*` (single or multiple). 4 + The module handles saving images to the app's filesystem for access within the app, limiting the selection of images 5 + 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 6 + platforms. 7 + 8 + No installation is required. Gradle will automatically add this module on build.
+15
modules/expo-receive-android-intents/android/.gitignore
··· 1 + # OSX 2 + # 3 + .DS_Store 4 + 5 + # Android/IntelliJ 6 + # 7 + build/ 8 + .idea 9 + .gradle 10 + local.properties 11 + *.iml 12 + *.hprof 13 + 14 + # Bundle artifacts 15 + *.jsbundle
+92
modules/expo-receive-android-intents/android/build.gradle
··· 1 + apply plugin: 'com.android.library' 2 + apply plugin: 'kotlin-android' 3 + apply plugin: 'maven-publish' 4 + 5 + group = 'xyz.blueskyweb.app.exporeceiveandroidintents' 6 + version = '0.4.1' 7 + 8 + buildscript { 9 + def expoModulesCorePlugin = new File(project(":expo-modules-core").projectDir.absolutePath, "ExpoModulesCorePlugin.gradle") 10 + if (expoModulesCorePlugin.exists()) { 11 + apply from: expoModulesCorePlugin 12 + applyKotlinExpoModulesCorePlugin() 13 + } 14 + 15 + // Simple helper that allows the root project to override versions declared by this library. 16 + ext.safeExtGet = { prop, fallback -> 17 + rootProject.ext.has(prop) ? rootProject.ext.get(prop) : fallback 18 + } 19 + 20 + // Ensures backward compatibility 21 + ext.getKotlinVersion = { 22 + if (ext.has("kotlinVersion")) { 23 + ext.kotlinVersion() 24 + } else { 25 + ext.safeExtGet("kotlinVersion", "1.8.10") 26 + } 27 + } 28 + 29 + repositories { 30 + mavenCentral() 31 + } 32 + 33 + dependencies { 34 + classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:${getKotlinVersion()}") 35 + } 36 + } 37 + 38 + afterEvaluate { 39 + publishing { 40 + publications { 41 + release(MavenPublication) { 42 + from components.release 43 + } 44 + } 45 + repositories { 46 + maven { 47 + url = mavenLocal().url 48 + } 49 + } 50 + } 51 + } 52 + 53 + android { 54 + compileSdkVersion safeExtGet("compileSdkVersion", 33) 55 + 56 + def agpVersion = com.android.Version.ANDROID_GRADLE_PLUGIN_VERSION 57 + if (agpVersion.tokenize('.')[0].toInteger() < 8) { 58 + compileOptions { 59 + sourceCompatibility JavaVersion.VERSION_11 60 + targetCompatibility JavaVersion.VERSION_11 61 + } 62 + 63 + kotlinOptions { 64 + jvmTarget = JavaVersion.VERSION_11.majorVersion 65 + } 66 + } 67 + 68 + namespace "xyz.blueskyweb.app.exporeceiveandroidintents" 69 + defaultConfig { 70 + minSdkVersion safeExtGet("minSdkVersion", 21) 71 + targetSdkVersion safeExtGet("targetSdkVersion", 34) 72 + versionCode 1 73 + versionName "0.4.1" 74 + } 75 + lintOptions { 76 + abortOnError false 77 + } 78 + publishing { 79 + singleVariant("release") { 80 + withSourcesJar() 81 + } 82 + } 83 + } 84 + 85 + repositories { 86 + mavenCentral() 87 + } 88 + 89 + dependencies { 90 + implementation project(':expo-modules-core') 91 + implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:${getKotlinVersion()}" 92 + }
+2
modules/expo-receive-android-intents/android/src/main/AndroidManifest.xml
··· 1 + <manifest> 2 + </manifest>
+119
modules/expo-receive-android-intents/android/src/main/java/xyz/blueskyweb/app/exporeceiveandroidintents/ExpoReceiveAndroidIntentsModule.kt
··· 1 + package xyz.blueskyweb.app.exporeceiveandroidintents 2 + 3 + import android.content.Intent 4 + import android.graphics.Bitmap 5 + import android.net.Uri 6 + import android.os.Build 7 + import android.provider.MediaStore 8 + import androidx.core.net.toUri 9 + import expo.modules.kotlin.modules.Module 10 + import expo.modules.kotlin.modules.ModuleDefinition 11 + import java.io.File 12 + import java.io.FileOutputStream 13 + import java.net.URLEncoder 14 + 15 + class ExpoReceiveAndroidIntentsModule : Module() { 16 + override fun definition() = ModuleDefinition { 17 + Name("ExpoReceiveAndroidIntents") 18 + 19 + OnNewIntent { 20 + handleIntent(it) 21 + } 22 + } 23 + 24 + private fun handleIntent(intent: Intent?) { 25 + if(appContext.currentActivity == null || intent == null) return 26 + 27 + if (intent.action == Intent.ACTION_SEND) { 28 + if (intent.type == "text/plain") { 29 + handleTextIntent(intent) 30 + } else if (intent.type.toString().startsWith("image/")) { 31 + handleImageIntent(intent) 32 + } 33 + } else if (intent.action == Intent.ACTION_SEND_MULTIPLE) { 34 + if (intent.type.toString().startsWith("image/")) { 35 + handleImagesIntent(intent) 36 + } 37 + } 38 + } 39 + 40 + private fun handleTextIntent(intent: Intent) { 41 + intent.getStringExtra(Intent.EXTRA_TEXT)?.let { 42 + val encoded = URLEncoder.encode(it, "UTF-8") 43 + "bluesky://intent/compose?text=${encoded}".toUri().let { uri -> 44 + val newIntent = Intent(Intent.ACTION_VIEW, uri) 45 + appContext.currentActivity?.startActivity(newIntent) 46 + } 47 + } 48 + } 49 + 50 + private fun handleImageIntent(intent: Intent) { 51 + val uri = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { 52 + intent.getParcelableExtra(Intent.EXTRA_STREAM, Uri::class.java) 53 + } else { 54 + intent.getParcelableExtra(Intent.EXTRA_STREAM) 55 + } 56 + if (uri == null) return 57 + 58 + handleImageIntents(listOf(uri)) 59 + } 60 + 61 + private fun handleImagesIntent(intent: Intent) { 62 + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { 63 + intent.getParcelableArrayListExtra(Intent.EXTRA_STREAM, Uri::class.java)?.let { 64 + handleImageIntents(it.filterIsInstance<Uri>().take(4)) 65 + } 66 + } else { 67 + intent.getParcelableArrayListExtra<Uri>(Intent.EXTRA_STREAM)?.let { 68 + handleImageIntents(it.filterIsInstance<Uri>().take(4)) 69 + } 70 + } 71 + } 72 + 73 + private fun handleImageIntents(uris: List<Uri>) { 74 + var allParams = "" 75 + 76 + uris.forEachIndexed { index, uri -> 77 + val info = getImageInfo(uri) 78 + val params = buildUriData(info) 79 + allParams = "${allParams}${params}" 80 + 81 + if (index < uris.count() - 1) { 82 + allParams = "${allParams}," 83 + } 84 + } 85 + 86 + val encoded = URLEncoder.encode(allParams, "UTF-8") 87 + 88 + "bluesky://intent/compose?imageUris=${encoded}".toUri().let { 89 + val newIntent = Intent(Intent.ACTION_VIEW, it) 90 + appContext.currentActivity?.startActivity(newIntent) 91 + } 92 + } 93 + 94 + private fun getImageInfo(uri: Uri): Map<String, Any> { 95 + val bitmap = MediaStore.Images.Media.getBitmap(appContext.currentActivity?.contentResolver, uri) 96 + // We have to save this so that we can access it later when uploading the image. 97 + // createTempFile will automatically place a unique string between "img" and "temp.jpeg" 98 + val file = File.createTempFile("img", "temp.jpeg", appContext.currentActivity?.cacheDir) 99 + val out = FileOutputStream(file) 100 + bitmap.compress(Bitmap.CompressFormat.JPEG, 100, out) 101 + out.flush() 102 + out.close() 103 + 104 + return mapOf( 105 + "width" to bitmap.width, 106 + "height" to bitmap.height, 107 + "path" to file.path.toString() 108 + ) 109 + } 110 + 111 + // We will pas the width and height to the app here, since getting measurements 112 + // on the RN side is a bit more involved, and we already have them here anyway. 113 + private fun buildUriData(info: Map<String, Any>): String { 114 + val path = info.getValue("path") 115 + val width = info.getValue("width") 116 + val height = info.getValue("height") 117 + return "file://${path}|${width}|${height}" 118 + } 119 + }
+6
modules/expo-receive-android-intents/expo-module.config.json
··· 1 + { 2 + "platforms": ["android"], 3 + "android": { 4 + "modules": ["xyz.blueskyweb.app.exporeceiveandroidintents.ExpoReceiveAndroidIntentsModule"] 5 + } 6 + }
+2 -1
package.json
··· 40 40 "intl:check": "yarn intl:extract && git diff-index -G'(^[^\\*# /])|(^#\\w)|(^\\s+[^\\*#/])' HEAD || (echo '\n⚠️ i18n detected un-extracted translations\n' && exit 1)", 41 41 "intl:extract": "lingui extract", 42 42 "intl:compile": "lingui compile", 43 - "nuke": "rm -rf ./node_modules && rm -rf ./ios && rm -rf ./android" 43 + "nuke": "rm -rf ./node_modules && rm -rf ./ios && rm -rf ./android", 44 + "update-extensions": "scripts/updateExtensions.sh" 44 45 }, 45 46 "dependencies": { 46 47 "@atproto/api": "^0.10.0",
+22
plugins/shareExtension/README.md
··· 1 + # Share extension plugin for Expo 2 + 3 + This plugin handles moving the necessary files into their respective iOS and Android targets and updating the build 4 + phases, plists, manifests, etc. 5 + 6 + ## Steps 7 + 8 + ### ios 9 + 10 + 1. Update entitlements 11 + 2. Set the app group to group.<identifier> 12 + 3. Add the extension plist 13 + 4. Add the view controller 14 + 5. Update the xcode project's build phases 15 + 16 + ### android 17 + 18 + 1. Update the manifest with the intents the app can receive 19 + 20 + ## Credits 21 + 22 + 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
+13
plugins/shareExtension/withAppEntitlements.js
··· 1 + const {withEntitlementsPlist} = require('@expo/config-plugins') 2 + 3 + const withAppEntitlements = config => { 4 + // eslint-disable-next-line no-shadow 5 + return withEntitlementsPlist(config, async config => { 6 + config.modResults['com.apple.security.application-groups'] = [ 7 + `group.${config.ios.bundleIdentifier}`, 8 + ] 9 + return config 10 + }) 11 + } 12 + 13 + module.exports = {withAppEntitlements}
+33
plugins/shareExtension/withExtensionEntitlements.js
··· 1 + const {withInfoPlist} = require('@expo/config-plugins') 2 + const plist = require('@expo/plist') 3 + const path = require('path') 4 + const fs = require('fs') 5 + 6 + const withExtensionEntitlements = (config, {extensionName}) => { 7 + // eslint-disable-next-line no-shadow 8 + return withInfoPlist(config, config => { 9 + const extensionEntitlementsPath = path.join( 10 + config.modRequest.platformProjectRoot, 11 + extensionName, 12 + `${extensionName}.entitlements`, 13 + ) 14 + 15 + const shareExtensionEntitlements = { 16 + 'com.apple.security.application-groups': [ 17 + `group.${config.ios?.bundleIdentifier}`, 18 + ], 19 + } 20 + 21 + fs.mkdirSync(path.dirname(extensionEntitlementsPath), { 22 + recursive: true, 23 + }) 24 + fs.writeFileSync( 25 + extensionEntitlementsPath, 26 + plist.default.build(shareExtensionEntitlements), 27 + ) 28 + 29 + return config 30 + }) 31 + } 32 + 33 + module.exports = {withExtensionEntitlements}
+39
plugins/shareExtension/withExtensionInfoPlist.js
··· 1 + const {withInfoPlist} = require('@expo/config-plugins') 2 + const plist = require('@expo/plist') 3 + const path = require('path') 4 + const fs = require('fs') 5 + 6 + const withExtensionInfoPlist = (config, {extensionName}) => { 7 + // eslint-disable-next-line no-shadow 8 + return withInfoPlist(config, config => { 9 + const plistPath = path.join( 10 + config.modRequest.projectRoot, 11 + 'modules', 12 + extensionName, 13 + 'Info.plist', 14 + ) 15 + const targetPath = path.join( 16 + config.modRequest.platformProjectRoot, 17 + extensionName, 18 + 'Info.plist', 19 + ) 20 + 21 + const extPlist = plist.default.parse(fs.readFileSync(plistPath).toString()) 22 + 23 + extPlist.MainAppScheme = config.scheme 24 + extPlist.CFBundleName = '$(PRODUCT_NAME)' 25 + extPlist.CFBundleDisplayName = 'Extension' 26 + extPlist.CFBundleIdentifier = '$(PRODUCT_BUNDLE_IDENTIFIER)' 27 + extPlist.CFBundleVersion = '$(CURRENT_PROJECT_VERSION)' 28 + extPlist.CFBundleExecutable = '$(EXECUTABLE_NAME)' 29 + extPlist.CFBundlePackageType = '$(PRODUCT_BUNDLE_PACKAGE_TYPE)' 30 + extPlist.CFBundleShortVersionString = '$(MARKETING_VERSION)' 31 + 32 + fs.mkdirSync(path.dirname(targetPath), {recursive: true}) 33 + fs.writeFileSync(targetPath, plist.default.build(extPlist)) 34 + 35 + return config 36 + }) 37 + } 38 + 39 + module.exports = {withExtensionInfoPlist}
+31
plugins/shareExtension/withExtensionViewController.js
··· 1 + const {withXcodeProject} = require('@expo/config-plugins') 2 + const path = require('path') 3 + const fs = require('fs') 4 + 5 + const withExtensionViewController = ( 6 + config, 7 + {controllerName, extensionName}, 8 + ) => { 9 + // eslint-disable-next-line no-shadow 10 + return withXcodeProject(config, config => { 11 + const controllerPath = path.join( 12 + config.modRequest.projectRoot, 13 + 'modules', 14 + extensionName, 15 + `${controllerName}.swift`, 16 + ) 17 + 18 + const targetPath = path.join( 19 + config.modRequest.platformProjectRoot, 20 + extensionName, 21 + `${controllerName}.swift`, 22 + ) 23 + 24 + fs.mkdirSync(path.dirname(targetPath), {recursive: true}) 25 + fs.copyFileSync(controllerPath, targetPath) 26 + 27 + return config 28 + }) 29 + } 30 + 31 + module.exports = {withExtensionViewController}
+89
plugins/shareExtension/withIntentFilters.js
··· 1 + const {withAndroidManifest} = require('@expo/config-plugins') 2 + 3 + const withIntentFilters = config => { 4 + // eslint-disable-next-line no-shadow 5 + return withAndroidManifest(config, config => { 6 + const intents = [ 7 + { 8 + action: [ 9 + { 10 + $: { 11 + 'android:name': 'android.intent.action.SEND', 12 + }, 13 + }, 14 + ], 15 + category: [ 16 + { 17 + $: { 18 + 'android:name': 'android.intent.category.DEFAULT', 19 + }, 20 + }, 21 + ], 22 + data: [ 23 + { 24 + $: { 25 + 'android:mimeType': 'image/*', 26 + }, 27 + }, 28 + ], 29 + }, 30 + { 31 + action: [ 32 + { 33 + $: { 34 + 'android:name': 'android.intent.action.SEND', 35 + }, 36 + }, 37 + ], 38 + category: [ 39 + { 40 + $: { 41 + 'android:name': 'android.intent.category.DEFAULT', 42 + }, 43 + }, 44 + ], 45 + data: [ 46 + { 47 + $: { 48 + 'android:mimeType': 'text/plain', 49 + }, 50 + }, 51 + ], 52 + }, 53 + { 54 + action: [ 55 + { 56 + $: { 57 + 'android:name': 'android.intent.action.SEND_MULTIPLE', 58 + }, 59 + }, 60 + ], 61 + category: [ 62 + { 63 + $: { 64 + 'android:name': 'android.intent.category.DEFAULT', 65 + }, 66 + }, 67 + ], 68 + data: [ 69 + { 70 + $: { 71 + 'android:mimeType': 'image/*', 72 + }, 73 + }, 74 + ], 75 + }, 76 + ] 77 + 78 + const intentFilter = 79 + config.modResults.manifest.application?.[0].activity?.[0]['intent-filter'] 80 + 81 + if (intentFilter) { 82 + intentFilter.push(...intents) 83 + } 84 + 85 + return config 86 + }) 87 + } 88 + 89 + module.exports = {withIntentFilters}
+47
plugins/shareExtension/withShareExtensions.js
··· 1 + const {withPlugins} = require('@expo/config-plugins') 2 + const {withAppEntitlements} = require('./withAppEntitlements') 3 + const {withXcodeTarget} = require('./withXcodeTarget') 4 + const {withExtensionEntitlements} = require('./withExtensionEntitlements') 5 + const {withExtensionInfoPlist} = require('./withExtensionInfoPlist') 6 + const {withExtensionViewController} = require('./withExtensionViewController') 7 + const {withIntentFilters} = require('./withIntentFilters') 8 + 9 + const SHARE_EXTENSION_NAME = 'Share-with-Bluesky' 10 + const SHARE_EXTENSION_CONTROLLER_NAME = 'ShareViewController' 11 + 12 + const withShareExtensions = config => { 13 + return withPlugins(config, [ 14 + // IOS 15 + withAppEntitlements, 16 + [ 17 + withExtensionEntitlements, 18 + { 19 + extensionName: SHARE_EXTENSION_NAME, 20 + }, 21 + ], 22 + [ 23 + withExtensionInfoPlist, 24 + { 25 + extensionName: SHARE_EXTENSION_NAME, 26 + }, 27 + ], 28 + [ 29 + withExtensionViewController, 30 + { 31 + extensionName: SHARE_EXTENSION_NAME, 32 + controllerName: SHARE_EXTENSION_CONTROLLER_NAME, 33 + }, 34 + ], 35 + [ 36 + withXcodeTarget, 37 + { 38 + extensionName: SHARE_EXTENSION_NAME, 39 + controllerName: SHARE_EXTENSION_CONTROLLER_NAME, 40 + }, 41 + ], 42 + // Android 43 + withIntentFilters, 44 + ]) 45 + } 46 + 47 + module.exports = withShareExtensions
+55
plugins/shareExtension/withXcodeTarget.js
··· 1 + const {withXcodeProject} = require('@expo/config-plugins') 2 + 3 + const withXcodeTarget = (config, {extensionName, controllerName}) => { 4 + // eslint-disable-next-line no-shadow 5 + return withXcodeProject(config, config => { 6 + const pbxProject = config.modResults 7 + 8 + const target = pbxProject.addTarget( 9 + extensionName, 10 + 'app_extension', 11 + extensionName, 12 + ) 13 + pbxProject.addBuildPhase([], 'PBXSourcesBuildPhase', 'Sources', target.uuid) 14 + pbxProject.addBuildPhase( 15 + [], 16 + 'PBXResourcesBuildPhase', 17 + 'Resources', 18 + target.uuid, 19 + ) 20 + const pbxGroupKey = pbxProject.pbxCreateGroup(extensionName, extensionName) 21 + pbxProject.addFile(`${extensionName}/Info.plist`, pbxGroupKey) 22 + pbxProject.addSourceFile( 23 + `${extensionName}/${controllerName}.swift`, 24 + {target: target.uuid}, 25 + pbxGroupKey, 26 + ) 27 + 28 + var configurations = pbxProject.pbxXCBuildConfigurationSection() 29 + for (var key in configurations) { 30 + if (typeof configurations[key].buildSettings !== 'undefined') { 31 + var buildSettingsObj = configurations[key].buildSettings 32 + if ( 33 + typeof buildSettingsObj.PRODUCT_NAME !== 'undefined' && 34 + buildSettingsObj.PRODUCT_NAME === `"${extensionName}"` 35 + ) { 36 + buildSettingsObj.CLANG_ENABLE_MODULES = 'YES' 37 + buildSettingsObj.INFOPLIST_FILE = `"${extensionName}/Info.plist"` 38 + buildSettingsObj.CODE_SIGN_ENTITLEMENTS = `"${extensionName}/${extensionName}.entitlements"` 39 + buildSettingsObj.CODE_SIGN_STYLE = 'Automatic' 40 + buildSettingsObj.CURRENT_PROJECT_VERSION = `"${config.ios?.buildNumber}"` 41 + buildSettingsObj.GENERATE_INFOPLIST_FILE = 'YES' 42 + buildSettingsObj.MARKETING_VERSION = `"${config.version}"` 43 + buildSettingsObj.PRODUCT_BUNDLE_IDENTIFIER = `"${config.ios?.bundleIdentifier}.${extensionName}"` 44 + buildSettingsObj.SWIFT_EMIT_LOC_STRINGS = 'YES' 45 + buildSettingsObj.SWIFT_VERSION = '5.0' 46 + buildSettingsObj.TARGETED_DEVICE_FAMILY = `"1,2"` 47 + } 48 + } 49 + } 50 + 51 + return config 52 + }) 53 + } 54 + 55 + module.exports = {withXcodeTarget}
+5
scripts/README.md
··· 1 + # Tool Scripts 2 + 3 + ## updateExtensions.sh 4 + 5 + Updates the extensions in `/modules` with the current iOS/Android project changes.
+10
scripts/updateExtensions.sh
··· 1 + #!/bin/bash 2 + IOS_SHARE_EXTENSION_DIRECTORY="./ios/Share-with-Bluesky" 3 + MODULES_DIRECTORY="./modules" 4 + 5 + if [ ! -d $IOS_SHARE_EXTENSION_DIRECTORY ]; then 6 + echo "$IOS_SHARE_EXTENSION_DIRECTORY not found inside of your iOS project." 7 + exit 1 8 + else 9 + cp -R $IOS_SHARE_EXTENSION_DIRECTORY $MODULES_DIRECTORY 10 + fi
+29 -6
src/lib/hooks/useIntentHandler.ts
··· 6 6 7 7 type IntentType = 'compose' 8 8 9 + const VALID_IMAGE_REGEX = /^[\w.:\-_/]+\|\d+(\.\d+)?\|\d+(\.\d+)?$/ 10 + 9 11 export function useIntentHandler() { 10 12 const incomingUrl = Linking.useURL() 11 13 const composeIntent = useComposeIntent() ··· 29 31 case 'compose': { 30 32 composeIntent({ 31 33 text: params.get('text'), 32 - imageUris: params.get('imageUris'), 34 + imageUrisStr: params.get('imageUris'), 33 35 }) 34 36 } 35 37 } ··· 45 47 46 48 return React.useCallback( 47 49 ({ 48 - // eslint-disable-next-line @typescript-eslint/no-unused-vars 49 50 text, 50 - // eslint-disable-next-line @typescript-eslint/no-unused-vars 51 - imageUris, 51 + imageUrisStr, 52 52 }: { 53 53 text: string | null 54 - imageUris: string | null // unused for right now, will be used later with intents 54 + imageUrisStr: string | null // unused for right now, will be used later with intents 55 55 }) => { 56 56 if (!hasSession) return 57 57 58 + const imageUris = imageUrisStr 59 + ?.split(',') 60 + .filter(part => { 61 + // For some security, we're going to filter out any image uri that is external. We don't want someone to 62 + // be able to provide some link like "bluesky://intent/compose?imageUris=https://IHaveYourIpNow.com/image.jpeg 63 + // and we load that image 64 + if (part.includes('https://') || part.includes('http://')) { 65 + return false 66 + } 67 + // We also should just filter out cases that don't have all the info we need 68 + if (!VALID_IMAGE_REGEX.test(part)) { 69 + return false 70 + } 71 + return true 72 + }) 73 + .map(part => { 74 + const [uri, width, height] = part.split('|') 75 + return {uri, width: Number(width), height: Number(height)} 76 + }) 77 + 58 78 setTimeout(() => { 59 - openComposer({}) // will pass in values to the composer here in the share extension 79 + openComposer({ 80 + text: text ?? undefined, 81 + imageUris: isNative ? imageUris : undefined, 82 + }) 60 83 }, 500) 61 84 }, 62 85 [openComposer, hasSession],
+23 -2
src/state/models/media/gallery.ts
··· 4 4 import {openPicker} from 'lib/media/picker' 5 5 import {getImageDim} from 'lib/media/manip' 6 6 7 + interface InitialImageUri { 8 + uri: string 9 + width: number 10 + height: number 11 + } 12 + 7 13 export class GalleryModel { 8 14 images: ImageModel[] = [] 9 15 10 - constructor() { 16 + constructor(uris?: {uri: string; width: number; height: number}[]) { 11 17 makeAutoObservable(this) 18 + 19 + if (uris) { 20 + this.addFromUris(uris) 21 + } 12 22 } 13 23 14 24 get isEmpty() { ··· 23 33 return this.images.some(image => image.altText.trim() === '') 24 34 } 25 35 26 - async add(image_: Omit<RNImage, 'size'>) { 36 + *add(image_: Omit<RNImage, 'size'>) { 27 37 if (this.size >= 4) { 28 38 return 29 39 } ··· 85 95 this.add(image) 86 96 }), 87 97 ) 98 + } 99 + 100 + async addFromUris(uris: InitialImageUri[]) { 101 + for (const uriObj of uris) { 102 + this.add({ 103 + mime: 'image/jpeg', 104 + height: uriObj.height, 105 + width: uriObj.width, 106 + path: uriObj.uri, 107 + }) 108 + } 88 109 } 89 110 }
+2
src/state/shell/composer.tsx
··· 38 38 quote?: ComposerOptsQuote 39 39 mention?: string // handle of user to mention 40 40 openPicker?: (pos: DOMRect | undefined) => void 41 + text?: string 42 + imageUris?: {uri: string; width: number; height: number}[] 41 43 } 42 44 43 45 type StateContext = ComposerOpts | undefined
+9 -2
src/view/com/composer/Composer.tsx
··· 71 71 quote: initQuote, 72 72 mention: initMention, 73 73 openPicker, 74 + text: initText, 75 + imageUris: initImageUris, 74 76 }: Props) { 75 77 const {currentAccount} = useSession() 76 78 const {data: currentProfile} = useProfileQuery({did: currentAccount!.did}) ··· 91 93 const [error, setError] = useState('') 92 94 const [richtext, setRichText] = useState( 93 95 new RichText({ 94 - text: initMention 96 + text: initText 97 + ? initText 98 + : initMention 95 99 ? insertMentionAt( 96 100 `@${initMention}`, 97 101 initMention.length + 1, ··· 110 114 const [labels, setLabels] = useState<string[]>([]) 111 115 const [threadgate, setThreadgate] = useState<ThreadgateSetting[]>([]) 112 116 const [suggestedLinks, setSuggestedLinks] = useState<Set<string>>(new Set()) 113 - const gallery = useMemo(() => new GalleryModel(), []) 117 + const gallery = useMemo( 118 + () => new GalleryModel(initImageUris), 119 + [initImageUris], 120 + ) 114 121 const onClose = useCallback(() => { 115 122 closeComposer() 116 123 }, [closeComposer])
+2
src/view/shell/Composer.tsx
··· 55 55 onPost={state.onPost} 56 56 quote={state.quote} 57 57 mention={state.mention} 58 + text={state.text} 59 + imageUris={state.imageUris} 58 60 /> 59 61 </Animated.View> 60 62 )
+2 -1
src/view/shell/Composer.web.tsx
··· 9 9 import { 10 10 EmojiPicker, 11 11 EmojiPickerState, 12 - } from 'view/com/composer/text-input/web/EmojiPicker.web.tsx' 12 + } from 'view/com/composer/text-input/web/EmojiPicker.web' 13 13 14 14 const BOTTOM_BAR_HEIGHT = 61 15 15 ··· 69 69 onPost={state.onPost} 70 70 mention={state.mention} 71 71 openPicker={onOpenPicker} 72 + text={state.text} 72 73 /> 73 74 </Animated.View> 74 75 <EmojiPicker state={pickerState} close={onClosePicker} />