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 typoszio/stable
parent
ac726497a4
commit
d451f82f54
|
@ -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: {
|
||||||
|
|
|
@ -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>
|
|
@ -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>
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
|
@ -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.
|
|
@ -0,0 +1,15 @@
|
||||||
|
# OSX
|
||||||
|
#
|
||||||
|
.DS_Store
|
||||||
|
|
||||||
|
# Android/IntelliJ
|
||||||
|
#
|
||||||
|
build/
|
||||||
|
.idea
|
||||||
|
.gradle
|
||||||
|
local.properties
|
||||||
|
*.iml
|
||||||
|
*.hprof
|
||||||
|
|
||||||
|
# Bundle artifacts
|
||||||
|
*.jsbundle
|
|
@ -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()}"
|
||||||
|
}
|
|
@ -0,0 +1,2 @@
|
||||||
|
<manifest>
|
||||||
|
</manifest>
|
|
@ -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}"
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,6 @@
|
||||||
|
{
|
||||||
|
"platforms": ["android"],
|
||||||
|
"android": {
|
||||||
|
"modules": ["xyz.blueskyweb.app.exporeceiveandroidintents.ExpoReceiveAndroidIntentsModule"]
|
||||||
|
}
|
||||||
|
}
|
|
@ -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",
|
||||||
|
|
|
@ -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
|
|
@ -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}
|
|
@ -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}
|
|
@ -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}
|
|
@ -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}
|
|
@ -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}
|
|
@ -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
|
|
@ -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}
|
|
@ -0,0 +1,5 @@
|
||||||
|
# Tool Scripts
|
||||||
|
|
||||||
|
## updateExtensions.sh
|
||||||
|
|
||||||
|
Updates the extensions in `/modules` with the current iOS/Android project changes.
|
|
@ -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
|
|
@ -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],
|
||||||
|
|
|
@ -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,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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])
|
||||||
|
|
|
@ -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>
|
||||||
)
|
)
|
||||||
|
|
|
@ -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} />
|
||||||
|
|
Loading…
Reference in New Issue