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
zio/stable
Hailey 2024-02-27 15:22:03 -08:00 committed by GitHub
parent ac726497a4
commit d451f82f54
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
27 changed files with 860 additions and 12 deletions

View File

@ -141,6 +141,7 @@ module.exports = function (config) {
}, },
], ],
'./plugins/withAndroidManifestPlugin.js', './plugins/withAndroidManifestPlugin.js',
'./plugins/shareExtension/withShareExtensions.js',
].filter(Boolean), ].filter(Boolean),
extra: { extra: {
eas: { eas: {

View File

@ -0,0 +1,41 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>NSExtension</key>
<dict>
<key>NSExtensionPrincipalClass</key>
<string>$(PRODUCT_MODULE_NAME).ShareViewController</string>
<key>NSExtensionAttributes</key>
<dict>
<key>NSExtensionActivationRule</key>
<dict>
<key>NSExtensionActivationSupportsText</key>
<true/>
<key>NSExtensionActivationSupportsWebURLWithMaxCount</key>
<integer>1</integer>
<key>NSExtensionActivationSupportsImageWithMaxCount</key>
<integer>10</integer>
</dict>
</dict>
<key>NSExtensionPointIdentifier</key>
<string>com.apple.share-services</string>
</dict>
<key>MainAppScheme</key>
<string>bluesky</string>
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundleDisplayName</key>
<string>Extension</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleVersion</key>
<string>$(CURRENT_PROJECT_VERSION)</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundlePackageType</key>
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
<key>CFBundleShortVersionString</key>
<string>$(MARKETING_VERSION)</string>
</dict>
</plist>

View File

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.application-groups</key>
<array>
<string>group.xyz.blueskyweb.app</string>
</array>
</dict>
</plist>

View File

@ -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
}
}

View File

@ -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.

View File

@ -0,0 +1,15 @@
# OSX
#
.DS_Store
# Android/IntelliJ
#
build/
.idea
.gradle
local.properties
*.iml
*.hprof
# Bundle artifacts
*.jsbundle

View File

@ -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()}"
}

View File

@ -0,0 +1,2 @@
<manifest>
</manifest>

View File

@ -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<Uri>().take(4))
}
} else {
intent.getParcelableArrayListExtra<Uri>(Intent.EXTRA_STREAM)?.let {
handleImageIntents(it.filterIsInstance<Uri>().take(4))
}
}
}
private fun handleImageIntents(uris: List<Uri>) {
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<String, Any> {
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, Any>): String {
val path = info.getValue("path")
val width = info.getValue("width")
val height = info.getValue("height")
return "file://${path}|${width}|${height}"
}
}

View File

@ -0,0 +1,6 @@
{
"platforms": ["android"],
"android": {
"modules": ["xyz.blueskyweb.app.exporeceiveandroidintents.ExpoReceiveAndroidIntentsModule"]
}
}

View File

@ -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: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:extract": "lingui extract",
"intl:compile": "lingui compile", "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": { "dependencies": {
"@atproto/api": "^0.10.0", "@atproto/api": "^0.10.0",

View File

@ -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.<identifier>
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

View File

@ -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}

View File

@ -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}

View File

@ -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}

View File

@ -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}

View File

@ -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}

View File

@ -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

View File

@ -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}

View File

@ -0,0 +1,5 @@
# Tool Scripts
## updateExtensions.sh
Updates the extensions in `/modules` with the current iOS/Android project changes.

View File

@ -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

View File

@ -6,6 +6,8 @@ import {useSession} from 'state/session'
type IntentType = 'compose' type IntentType = 'compose'
const VALID_IMAGE_REGEX = /^[\w.:\-_/]+\|\d+(\.\d+)?\|\d+(\.\d+)?$/
export function useIntentHandler() { export function useIntentHandler() {
const incomingUrl = Linking.useURL() const incomingUrl = Linking.useURL()
const composeIntent = useComposeIntent() const composeIntent = useComposeIntent()
@ -29,7 +31,7 @@ export function useIntentHandler() {
case 'compose': { case 'compose': {
composeIntent({ composeIntent({
text: params.get('text'), text: params.get('text'),
imageUris: params.get('imageUris'), imageUrisStr: params.get('imageUris'),
}) })
} }
} }
@ -45,18 +47,39 @@ function useComposeIntent() {
return React.useCallback( return React.useCallback(
({ ({
// eslint-disable-next-line @typescript-eslint/no-unused-vars
text, text,
// eslint-disable-next-line @typescript-eslint/no-unused-vars imageUrisStr,
imageUris,
}: { }: {
text: string | null 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 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(() => { setTimeout(() => {
openComposer({}) // will pass in values to the composer here in the share extension openComposer({
text: text ?? undefined,
imageUris: isNative ? imageUris : undefined,
})
}, 500) }, 500)
}, },
[openComposer, hasSession], [openComposer, hasSession],

View File

@ -4,11 +4,21 @@ import {Image as RNImage} from 'react-native-image-crop-picker'
import {openPicker} from 'lib/media/picker' import {openPicker} from 'lib/media/picker'
import {getImageDim} from 'lib/media/manip' import {getImageDim} from 'lib/media/manip'
interface InitialImageUri {
uri: string
width: number
height: number
}
export class GalleryModel { export class GalleryModel {
images: ImageModel[] = [] images: ImageModel[] = []
constructor() { constructor(uris?: {uri: string; width: number; height: number}[]) {
makeAutoObservable(this) makeAutoObservable(this)
if (uris) {
this.addFromUris(uris)
}
} }
get isEmpty() { get isEmpty() {
@ -23,7 +33,7 @@ export class GalleryModel {
return this.images.some(image => image.altText.trim() === '') return this.images.some(image => image.altText.trim() === '')
} }
async add(image_: Omit<RNImage, 'size'>) { *add(image_: Omit<RNImage, 'size'>) {
if (this.size >= 4) { if (this.size >= 4) {
return 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,
})
}
}
} }

View File

@ -38,6 +38,8 @@ export interface ComposerOpts {
quote?: ComposerOptsQuote quote?: ComposerOptsQuote
mention?: string // handle of user to mention mention?: string // handle of user to mention
openPicker?: (pos: DOMRect | undefined) => void openPicker?: (pos: DOMRect | undefined) => void
text?: string
imageUris?: {uri: string; width: number; height: number}[]
} }
type StateContext = ComposerOpts | undefined type StateContext = ComposerOpts | undefined

View File

@ -71,6 +71,8 @@ export const ComposePost = observer(function ComposePost({
quote: initQuote, quote: initQuote,
mention: initMention, mention: initMention,
openPicker, openPicker,
text: initText,
imageUris: initImageUris,
}: Props) { }: Props) {
const {currentAccount} = useSession() const {currentAccount} = useSession()
const {data: currentProfile} = useProfileQuery({did: currentAccount!.did}) const {data: currentProfile} = useProfileQuery({did: currentAccount!.did})
@ -91,7 +93,9 @@ export const ComposePost = observer(function ComposePost({
const [error, setError] = useState('') const [error, setError] = useState('')
const [richtext, setRichText] = useState( const [richtext, setRichText] = useState(
new RichText({ new RichText({
text: initMention text: initText
? initText
: initMention
? insertMentionAt( ? insertMentionAt(
`@${initMention}`, `@${initMention}`,
initMention.length + 1, initMention.length + 1,
@ -110,7 +114,10 @@ export const ComposePost = observer(function ComposePost({
const [labels, setLabels] = useState<string[]>([]) const [labels, setLabels] = useState<string[]>([])
const [threadgate, setThreadgate] = useState<ThreadgateSetting[]>([]) const [threadgate, setThreadgate] = useState<ThreadgateSetting[]>([])
const [suggestedLinks, setSuggestedLinks] = useState<Set<string>>(new Set()) const [suggestedLinks, setSuggestedLinks] = useState<Set<string>>(new Set())
const gallery = useMemo(() => new GalleryModel(), []) const gallery = useMemo(
() => new GalleryModel(initImageUris),
[initImageUris],
)
const onClose = useCallback(() => { const onClose = useCallback(() => {
closeComposer() closeComposer()
}, [closeComposer]) }, [closeComposer])

View File

@ -55,6 +55,8 @@ export const Composer = observer(function ComposerImpl({
onPost={state.onPost} onPost={state.onPost}
quote={state.quote} quote={state.quote}
mention={state.mention} mention={state.mention}
text={state.text}
imageUris={state.imageUris}
/> />
</Animated.View> </Animated.View>
) )

View File

@ -9,7 +9,7 @@ import {useWebBodyScrollLock} from '#/lib/hooks/useWebBodyScrollLock'
import { import {
EmojiPicker, EmojiPicker,
EmojiPickerState, 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 const BOTTOM_BAR_HEIGHT = 61
@ -69,6 +69,7 @@ export function Composer({}: {winHeight: number}) {
onPost={state.onPost} onPost={state.onPost}
mention={state.mention} mention={state.mention}
openPicker={onOpenPicker} openPicker={onOpenPicker}
text={state.text}
/> />
</Animated.View> </Animated.View>
<EmojiPicker state={pickerState} close={onClosePicker} /> <EmojiPicker state={pickerState} close={onClosePicker} />