From 465714aa63e67b273965f5f314b646efe99ef1ba Mon Sep 17 00:00:00 2001 From: Daniel McCartney Date: Wed, 16 Aug 2023 15:21:07 +0200 Subject: [PATCH 1/2] feat: support remote attachments --- android/build.gradle | 2 +- .../modules/xmtpreactnativesdk/XMTPModule.kt | 64 ++ .../wrappers/ContentJson.kt | 34 +- .../wrappers/DecryptedLocalAttachment.kt | 32 + .../wrappers/EncryptedAttachmentMetadata.kt | 66 ++ .../wrappers/EncryptedLocalAttachment.kt | 48 ++ example/app.json | 9 + example/dev/local/docker-compose.yml | 27 +- example/dev/local/upload-service/.gitignore | 1 + example/dev/local/upload-service/Caddyfile | 4 + example/dev/local/upload-service/Dockerfile | 7 + example/dev/local/upload-service/app.rb | 21 + example/ios/Podfile.lock | 25 + .../ios/xmtpreactnativesdkexample/Info.plist | 6 + example/package-lock.json | 108 ++++ example/package.json | 4 + example/src/ConversationScreen.tsx | 588 +++++++++++++++--- example/src/hooks.tsx | 93 ++- example/src/storage.ts | 35 ++ example/src/tests.ts | 80 ++- ios/Wrappers/DecodedMessageWrapper.swift | 173 +++++- ios/XMTPModule.swift | 52 +- ios/XMTPReactNative.podspec | 2 +- src/XMTP.types.ts | 25 +- src/index.ts | 33 +- src/lib/Client.ts | 28 +- 26 files changed, 1458 insertions(+), 109 deletions(-) create mode 100644 android/src/main/java/expo/modules/xmtpreactnativesdk/wrappers/DecryptedLocalAttachment.kt create mode 100644 android/src/main/java/expo/modules/xmtpreactnativesdk/wrappers/EncryptedAttachmentMetadata.kt create mode 100644 android/src/main/java/expo/modules/xmtpreactnativesdk/wrappers/EncryptedLocalAttachment.kt create mode 100644 example/dev/local/upload-service/.gitignore create mode 100644 example/dev/local/upload-service/Caddyfile create mode 100644 example/dev/local/upload-service/Dockerfile create mode 100644 example/dev/local/upload-service/app.rb create mode 100644 example/src/storage.ts diff --git a/android/build.gradle b/android/build.gradle index ac7552bac..e1a26c939 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -88,7 +88,7 @@ repositories { dependencies { implementation project(':expo-modules-core') implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:${getKotlinVersion()}" - implementation "org.xmtp:android:0.3.5" + implementation "org.xmtp:android:0.4.0" implementation 'com.google.code.gson:gson:2.10.1' implementation 'com.facebook.react:react-native:0.71.3' implementation "com.daveanthonythomas.moshipack:moshipack:1.0.1" diff --git a/android/src/main/java/expo/modules/xmtpreactnativesdk/XMTPModule.kt b/android/src/main/java/expo/modules/xmtpreactnativesdk/XMTPModule.kt index 08fec1581..29a98e2ad 100644 --- a/android/src/main/java/expo/modules/xmtpreactnativesdk/XMTPModule.kt +++ b/android/src/main/java/expo/modules/xmtpreactnativesdk/XMTPModule.kt @@ -1,8 +1,10 @@ package expo.modules.xmtpreactnativesdk +import android.net.Uri import android.util.Base64 import android.util.Base64.NO_WRAP import android.util.Log +import androidx.core.net.toUri import com.google.gson.JsonParser import com.google.protobuf.kotlin.toByteString import expo.modules.kotlin.modules.Module @@ -10,6 +12,8 @@ import expo.modules.kotlin.modules.ModuleDefinition import expo.modules.xmtpreactnativesdk.wrappers.ContentJson import expo.modules.xmtpreactnativesdk.wrappers.ConversationWrapper import expo.modules.xmtpreactnativesdk.wrappers.DecodedMessageWrapper +import expo.modules.xmtpreactnativesdk.wrappers.DecryptedLocalAttachment +import expo.modules.xmtpreactnativesdk.wrappers.EncryptedLocalAttachment import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job @@ -23,6 +27,12 @@ import org.xmtp.android.library.SendOptions import org.xmtp.android.library.SigningKey import org.xmtp.android.library.XMTPEnvironment import org.xmtp.android.library.XMTPException +import org.xmtp.android.library.codecs.Attachment +import org.xmtp.android.library.codecs.AttachmentCodec +import org.xmtp.android.library.codecs.EncodedContent +import org.xmtp.android.library.codecs.EncryptedEncodedContent +import org.xmtp.android.library.codecs.RemoteAttachment +import org.xmtp.android.library.codecs.decoded import org.xmtp.android.library.messages.EnvelopeBuilder import org.xmtp.android.library.messages.InvitationV1ContextBuilder import org.xmtp.android.library.messages.Pagination @@ -31,6 +41,7 @@ import org.xmtp.android.library.messages.Signature import org.xmtp.android.library.push.XMTPPush import org.xmtp.proto.keystore.api.v1.Keystore.TopicMap.TopicData import org.xmtp.proto.message.contents.PrivateKeyOuterClass +import java.io.File import java.util.Date import java.util.UUID import kotlin.coroutines.Continuation @@ -199,6 +210,59 @@ class XMTPModule : Module() { client.canMessage(peerAddress) } + AsyncFunction("encryptAttachment") { clientAddress: String, fileJson: String -> + logV("encryptAttachment") + val client = clients[clientAddress] ?: throw XMTPException("No client") + val file = DecryptedLocalAttachment.fromJson(fileJson) + val uri = Uri.parse(file.fileUri) + val data = appContext.reactContext?.contentResolver + ?.openInputStream(uri) + ?.use { it.buffered().readBytes() }!! + val attachment = Attachment( + filename = uri.lastPathSegment ?: "", + mimeType = file.mimeType, + data.toByteString(), + ) + val encrypted = RemoteAttachment.encodeEncrypted( + attachment, + AttachmentCodec() + ) + val encryptedFile = File.createTempFile(UUID.randomUUID().toString(), null) + encryptedFile.writeBytes(encrypted.payload.toByteArray()) + + EncryptedLocalAttachment.from( + attachment, + encrypted, + encryptedFile.toUri() + ).toJson() + } + + AsyncFunction("decryptAttachment") { clientAddress: String, encryptedFileJson: String -> + logV("decryptAttachment") + val client = clients[clientAddress] ?: throw XMTPException("No client") + val encryptedFile = EncryptedLocalAttachment.fromJson(encryptedFileJson) + val encryptedData = appContext.reactContext?.contentResolver + ?.openInputStream(Uri.parse(encryptedFile.encryptedLocalFileUri)) + ?.use { it.buffered().readBytes() }!! + val encrypted = EncryptedEncodedContent( + encryptedFile.metadata.contentDigest, + encryptedFile.metadata.secret, + encryptedFile.metadata.salt, + encryptedFile.metadata.nonce, + encryptedData.toByteString(), + encryptedData.size, + encryptedFile.metadata.filename, + ) + val encoded: EncodedContent = RemoteAttachment.decryptEncoded(encrypted) + val attachment = encoded.decoded()!! + val file = File.createTempFile(UUID.randomUUID().toString(), null) + file.writeBytes(attachment.data.toByteArray()) + DecryptedLocalAttachment( + fileUri = file.toURI().toString(), + mimeType = attachment.mimeType, + ).toJson() + } + AsyncFunction("listConversations") { clientAddress: String -> logV("listConversations") val client = clients[clientAddress] ?: throw XMTPException("No client") diff --git a/android/src/main/java/expo/modules/xmtpreactnativesdk/wrappers/ContentJson.kt b/android/src/main/java/expo/modules/xmtpreactnativesdk/wrappers/ContentJson.kt index 2779c0e54..235fdf81d 100644 --- a/android/src/main/java/expo/modules/xmtpreactnativesdk/wrappers/ContentJson.kt +++ b/android/src/main/java/expo/modules/xmtpreactnativesdk/wrappers/ContentJson.kt @@ -1,12 +1,10 @@ package expo.modules.xmtpreactnativesdk.wrappers import android.util.Base64 -import com.google.gson.GsonBuilder import com.google.gson.JsonObject import com.google.gson.JsonParser import com.google.protobuf.ByteString import org.xmtp.android.library.Client -import org.xmtp.android.library.DecodedMessage import org.xmtp.proto.message.contents.Content.EncodedContent import org.xmtp.android.library.codecs.decoded import org.xmtp.android.library.codecs.ContentTypeAttachment @@ -15,17 +13,21 @@ import org.xmtp.android.library.codecs.ContentTypeReaction import org.xmtp.android.library.codecs.ContentTypeText import org.xmtp.android.library.codecs.AttachmentCodec import org.xmtp.android.library.codecs.Attachment +import org.xmtp.android.library.codecs.ContentTypeRemoteAttachment import org.xmtp.android.library.codecs.ContentTypeReply import org.xmtp.android.library.codecs.ReactionAction import org.xmtp.android.library.codecs.ReactionSchema import org.xmtp.android.library.codecs.ReactionCodec import org.xmtp.android.library.codecs.Reaction +import org.xmtp.android.library.codecs.RemoteAttachment +import org.xmtp.android.library.codecs.RemoteAttachmentCodec import org.xmtp.android.library.codecs.Reply import org.xmtp.android.library.codecs.ReplyCodec import org.xmtp.android.library.codecs.TextCodec import org.xmtp.android.library.codecs.id import java.lang.Exception +import java.net.URL class ContentJson( val type: ContentTypeId, @@ -41,12 +43,13 @@ class ContentJson( Client.register(TextCodec()) Client.register(AttachmentCodec()) Client.register(ReactionCodec()) + Client.register(RemoteAttachmentCodec()) Client.register(ReplyCodec()) // TODO: //Client.register(CompositeCodec()) //Client.register(GroupChatMemberAddedCodec()) //Client.register(GroupChatTitleChangedCodec()) - //Client.register(RemoteAttachmentCodec()) + } fun fromJsonObject(obj: JsonObject): ContentJson { @@ -59,6 +62,22 @@ class ContentJson( mimeType = attachment.get("mimeType").asString, data = ByteString.copyFrom(bytesFrom64(attachment.get("data").asString)), )) + } else if (obj.has("remoteAttachment")) { + val remoteAttachment = obj.get("remoteAttachment").asJsonObject + val metadata = EncryptedAttachmentMetadata.fromJsonObj(remoteAttachment) + val url = URL(remoteAttachment.get("url").asString) + return ContentJson( + ContentTypeRemoteAttachment, RemoteAttachment( + url = url, + contentDigest = metadata.contentDigest, + secret = metadata.secret, + salt = metadata.salt, + nonce = metadata.nonce, + scheme = "https://", + contentLength = metadata.contentLength, + filename = metadata.filename, + ) + ) } else if (obj.has("reaction")) { val reaction = obj.get("reaction").asJsonObject return ContentJson(ContentTypeReaction, Reaction( @@ -109,6 +128,15 @@ class ContentJson( ) ) + ContentTypeRemoteAttachment.id -> mapOf( + "remoteAttachment" to mapOf( + "scheme" to "https://", + "url" to (content as RemoteAttachment).url.toString(), + ) + EncryptedAttachmentMetadata + .fromRemoteAttachment(content) + .toJsonMap() + ) + ContentTypeReaction.id -> mapOf( "reaction" to mapOf( "reference" to (content as Reaction).reference, diff --git a/android/src/main/java/expo/modules/xmtpreactnativesdk/wrappers/DecryptedLocalAttachment.kt b/android/src/main/java/expo/modules/xmtpreactnativesdk/wrappers/DecryptedLocalAttachment.kt new file mode 100644 index 000000000..e2099fe2f --- /dev/null +++ b/android/src/main/java/expo/modules/xmtpreactnativesdk/wrappers/DecryptedLocalAttachment.kt @@ -0,0 +1,32 @@ +package expo.modules.xmtpreactnativesdk.wrappers + +import com.google.gson.GsonBuilder +import com.google.gson.JsonObject +import com.google.gson.JsonParser + +/** + * Refers to a decrypted attachment that is stored locally on the device. + */ +class DecryptedLocalAttachment( + val fileUri: String, + val mimeType: String, +) { + companion object { + fun fromJsonObject(obj: JsonObject) = DecryptedLocalAttachment( + obj.get("fileUri").asString, + obj.get("mimeType").asString, + ) + + fun fromJson(json: String): DecryptedLocalAttachment { + val obj = JsonParser.parseString(json).asJsonObject + return fromJsonObject(obj); + } + } + + fun toJsonMap(): Map = mapOf( + "fileUri" to fileUri, + "mimeType" to mimeType, + ) + + fun toJson(): String = GsonBuilder().create().toJson(toJsonMap()) +} \ No newline at end of file diff --git a/android/src/main/java/expo/modules/xmtpreactnativesdk/wrappers/EncryptedAttachmentMetadata.kt b/android/src/main/java/expo/modules/xmtpreactnativesdk/wrappers/EncryptedAttachmentMetadata.kt new file mode 100644 index 000000000..01d97cab3 --- /dev/null +++ b/android/src/main/java/expo/modules/xmtpreactnativesdk/wrappers/EncryptedAttachmentMetadata.kt @@ -0,0 +1,66 @@ +package expo.modules.xmtpreactnativesdk.wrappers + +import com.facebook.common.util.Hex +import com.google.gson.JsonObject +import com.google.protobuf.ByteString +import com.google.protobuf.kotlin.toByteString +import org.xmtp.android.library.codecs.Attachment +import org.xmtp.android.library.codecs.EncryptedEncodedContent +import org.xmtp.android.library.codecs.RemoteAttachment + +/** + * Describes the metadata for an encrypted attachment used to encrypt/decrypt the payload. + */ +class EncryptedAttachmentMetadata( + val filename: String, + val secret: ByteString, + val salt: ByteString, + val nonce: ByteString, + val contentDigest: String, + val contentLength: Int, +) { + companion object { + fun fromAttachment( + attachment: Attachment, + encrypted: EncryptedEncodedContent + ) = EncryptedAttachmentMetadata( + attachment.filename, + encrypted.secret, + encrypted.salt, + encrypted.nonce, + encrypted.contentDigest, + attachment.data.size(), + ) + + fun fromRemoteAttachment( + remoteAttachment: RemoteAttachment, + ) = EncryptedAttachmentMetadata( + remoteAttachment.filename ?: "", + remoteAttachment.secret, + remoteAttachment.salt, + remoteAttachment.nonce, + remoteAttachment.contentDigest, + remoteAttachment.contentLength ?: 0, + ) + + fun fromJsonObj( + obj: JsonObject, + ) = EncryptedAttachmentMetadata( + obj.get("filename").asString, + Hex.hexStringToByteArray(obj.get("secret").asString).toByteString(), + Hex.hexStringToByteArray(obj.get("salt").asString).toByteString(), + Hex.hexStringToByteArray(obj.get("nonce").asString).toByteString(), + obj.get("contentDigest").asString, + Integer.parseInt(obj.get("contentLength").asString), + ) + } + + fun toJsonMap(): Map = mapOf( + "filename" to filename, + "secret" to Hex.encodeHex(secret.toByteArray(), false), + "salt" to Hex.encodeHex(salt.toByteArray(), false), + "nonce" to Hex.encodeHex(nonce.toByteArray(), false), + "contentDigest" to contentDigest, + "contentLength" to contentLength.toString(), + ) +} \ No newline at end of file diff --git a/android/src/main/java/expo/modules/xmtpreactnativesdk/wrappers/EncryptedLocalAttachment.kt b/android/src/main/java/expo/modules/xmtpreactnativesdk/wrappers/EncryptedLocalAttachment.kt new file mode 100644 index 000000000..87a92805b --- /dev/null +++ b/android/src/main/java/expo/modules/xmtpreactnativesdk/wrappers/EncryptedLocalAttachment.kt @@ -0,0 +1,48 @@ +package expo.modules.xmtpreactnativesdk.wrappers + +import android.net.Uri +import com.google.gson.GsonBuilder +import com.google.gson.JsonObject +import com.google.gson.JsonParser +import org.xmtp.android.library.codecs.Attachment +import org.xmtp.android.library.codecs.EncryptedEncodedContent + +/** + * Refers to an encrypted attachment that is stored locally on the device + * alongside the metadata that can be used to encrypt/decrypt it. + */ +class EncryptedLocalAttachment( + val encryptedLocalFileUri: String, + val metadata: EncryptedAttachmentMetadata, +) { + companion object { + fun from( + attachment: Attachment, + encrypted: EncryptedEncodedContent, + encryptedFile: Uri, + ) = EncryptedLocalAttachment( + encryptedFile.toString(), + EncryptedAttachmentMetadata.fromAttachment( + attachment, + encrypted + ) + ) + + fun fromJsonObject(obj: JsonObject) = EncryptedLocalAttachment( + obj.get("encryptedLocalFileUri").asString, + EncryptedAttachmentMetadata.fromJsonObj(obj.get("metadata").asJsonObject), + ) + + fun fromJson(json: String): EncryptedLocalAttachment { + val obj = JsonParser.parseString(json).asJsonObject + return fromJsonObject(obj); + } + } + + fun toJsonMap(): Map = mapOf( + "encryptedLocalFileUri" to encryptedLocalFileUri, + "metadata" to metadata.toJsonMap() + ) + + fun toJson(): String = GsonBuilder().create().toJson(toJsonMap()) +} diff --git a/example/app.json b/example/app.json index 0444d0fe6..49759b798 100644 --- a/example/app.json +++ b/example/app.json @@ -14,6 +14,15 @@ "assetBundlePatterns": [ "**/*" ], + "plugins": [ + [ + "expo-image-picker", + { + "photosPermission": "The app accesses your photos to let you attach them as messages.", + "cameraPermission": "The app accesses your camera to let you attach photos as messages." + } + ] + ], "ios": { "supportsTablet": true, "bundleIdentifier": "expo.modules.xmtpreactnativesdk.example" diff --git a/example/dev/local/docker-compose.yml b/example/dev/local/docker-compose.yml index 02561f164..d217ce884 100644 --- a/example/dev/local/docker-compose.yml +++ b/example/dev/local/docker-compose.yml @@ -29,10 +29,23 @@ services: image: postgres:13 environment: POSTGRES_PASSWORD: xmtp - js: - restart: always - depends_on: - wakunode: - condition: service_healthy - build: ./test - \ No newline at end of file + + upload-service: + build: ./upload-service + ports: + - 4567:4567 + + caddy: + image: caddy:latest + ports: + - "443:443" + volumes: + - ./upload-service/Caddyfile:/etc/caddy/Caddyfile + - ./upload-service/data/data:/data + - ./upload-service/data/config:/config +# js: +# restart: always +# depends_on: +# wakunode: +# condition: service_healthy +# build: ./test diff --git a/example/dev/local/upload-service/.gitignore b/example/dev/local/upload-service/.gitignore new file mode 100644 index 000000000..8fce60300 --- /dev/null +++ b/example/dev/local/upload-service/.gitignore @@ -0,0 +1 @@ +data/ diff --git a/example/dev/local/upload-service/Caddyfile b/example/dev/local/upload-service/Caddyfile new file mode 100644 index 000000000..351a28346 --- /dev/null +++ b/example/dev/local/upload-service/Caddyfile @@ -0,0 +1,4 @@ +localhost { + tls internal + reverse_proxy upload-service:4567 +} diff --git a/example/dev/local/upload-service/Dockerfile b/example/dev/local/upload-service/Dockerfile new file mode 100644 index 000000000..f5f790868 --- /dev/null +++ b/example/dev/local/upload-service/Dockerfile @@ -0,0 +1,7 @@ +FROM ruby:3.2 + +RUN gem install sinatra puma +WORKDIR /usr/src/app +ADD app.rb /usr/src/app/ + +ENTRYPOINT ["ruby", "app.rb", "-o", "0.0.0.0"] diff --git a/example/dev/local/upload-service/app.rb b/example/dev/local/upload-service/app.rb new file mode 100644 index 000000000..e21c6036a --- /dev/null +++ b/example/dev/local/upload-service/app.rb @@ -0,0 +1,21 @@ +require 'rubygems' +require 'puma' +require 'sinatra' + +UPLOADS = {} + +post '/:path' do + logger.info "POST #{params[:path]}" + UPLOADS[params[:path]] = request.body.read + "OK" +end + +get '/:path' do + logger.info "GET #{params[:path]}" + if data = UPLOADS[params[:path]] + content_type 'application/octet-stream' + data + else + raise Sinatra::NotFound + end +end diff --git a/example/ios/Podfile.lock b/example/ios/Podfile.lock index f5704d8f5..c3329ebab 100644 --- a/example/ios/Podfile.lock +++ b/example/ios/Podfile.lock @@ -12,8 +12,15 @@ PODS: - ExpoModulesCore - EXFont (11.1.1): - ExpoModulesCore + - EXImageLoader (4.3.0): + - ExpoModulesCore + - React-Core - Expo (48.0.20): - ExpoModulesCore + - ExpoDocumentPicker (11.5.4): + - ExpoModulesCore + - ExpoImagePicker (14.3.2): + - ExpoModulesCore - ExpoKeepAwake (12.0.1): - ExpoModulesCore - ExpoModulesCore (1.2.7): @@ -295,6 +302,8 @@ PODS: - React-jsinspector (0.71.8) - React-logger (0.71.8): - glog + - react-native-blob-util (0.19.0): + - React-Core - react-native-encrypted-storage (4.0.3): - React-Core - react-native-get-random-values (1.8.0): @@ -425,7 +434,10 @@ DEPENDENCIES: - EXConstants (from `../node_modules/expo-constants/ios`) - EXFileSystem (from `../node_modules/expo-file-system/ios`) - EXFont (from `../node_modules/expo-font/ios`) + - EXImageLoader (from `../node_modules/expo-image-loader/ios`) - Expo (from `../node_modules/expo`) + - ExpoDocumentPicker (from `../node_modules/expo-document-picker/ios`) + - ExpoImagePicker (from `../node_modules/expo-image-picker/ios`) - ExpoKeepAwake (from `../node_modules/expo-keep-awake/ios`) - ExpoModulesCore (from `../node_modules/expo-modules-core`) - EXSplashScreen (from `../node_modules/expo-splash-screen/ios`) @@ -449,6 +461,7 @@ DEPENDENCIES: - React-jsiexecutor (from `../node_modules/react-native/ReactCommon/jsiexecutor`) - React-jsinspector (from `../node_modules/react-native/ReactCommon/jsinspector`) - React-logger (from `../node_modules/react-native/ReactCommon/logger`) + - react-native-blob-util (from `../node_modules/react-native-blob-util`) - react-native-encrypted-storage (from `../node_modules/react-native-encrypted-storage`) - react-native-get-random-values (from `../node_modules/react-native-get-random-values`) - react-native-mmkv (from `../node_modules/react-native-mmkv`) @@ -504,8 +517,14 @@ EXTERNAL SOURCES: :path: "../node_modules/expo-file-system/ios" EXFont: :path: "../node_modules/expo-font/ios" + EXImageLoader: + :path: "../node_modules/expo-image-loader/ios" Expo: :path: "../node_modules/expo" + ExpoDocumentPicker: + :path: "../node_modules/expo-document-picker/ios" + ExpoImagePicker: + :path: "../node_modules/expo-image-picker/ios" ExpoKeepAwake: :path: "../node_modules/expo-keep-awake/ios" ExpoModulesCore: @@ -548,6 +567,8 @@ EXTERNAL SOURCES: :path: "../node_modules/react-native/ReactCommon/jsinspector" React-logger: :path: "../node_modules/react-native/ReactCommon/logger" + react-native-blob-util: + :path: "../node_modules/react-native-blob-util" react-native-encrypted-storage: :path: "../node_modules/react-native-encrypted-storage" react-native-get-random-values: @@ -602,7 +623,10 @@ SPEC CHECKSUMS: EXConstants: f348da07e21b23d2b085e270d7b74f282df1a7d9 EXFileSystem: 844e86ca9b5375486ecc4ef06d3838d5597d895d EXFont: 6ea3800df746be7233208d80fe379b8ed74f4272 + EXImageLoader: 34b214f9387e98f3c73989f15d8d5b399c9ab3f7 Expo: b7d2843b0a0027d0ce76121a63085764355a16ed + ExpoDocumentPicker: 5cb7389ff935b4addefdd466a606de51a512e922 + ExpoImagePicker: c58fdf28be551681a8edc550bcec76c397a8dfcd ExpoKeepAwake: 69f5f627670d62318410392d03e0b5db0f85759a ExpoModulesCore: 653958063a301098b541ae4dfed1ac0b98db607b EXSplashScreen: 0e0a9ba0cf7553094e93213099bd7b42e6e237e9 @@ -632,6 +656,7 @@ SPEC CHECKSUMS: React-jsiexecutor: 747911ab5921641b4ed7e4900065896597142125 React-jsinspector: c712f9e3bb9ba4122d6b82b4f906448b8a281580 React-logger: 342f358b8decfbf8f272367f4eacf4b6154061be + react-native-blob-util: 2b6627b288e3bd9874704ea9a153f0a179e4f3f5 react-native-encrypted-storage: db300a3f2f0aba1e818417c1c0a6be549038deb7 react-native-get-random-values: a6ea6a8a65dc93e96e24a11105b1a9c8cfe1d72a react-native-mmkv: 7da5e18e55c04a9af9a7e0ab9792a1e8d33765a1 diff --git a/example/ios/xmtpreactnativesdkexample/Info.plist b/example/ios/xmtpreactnativesdkexample/Info.plist index 495c9b0a8..ce646d17c 100644 --- a/example/ios/xmtpreactnativesdkexample/Info.plist +++ b/example/ios/xmtpreactnativesdkexample/Info.plist @@ -46,6 +46,12 @@ + NSCameraUsageDescription + The app accesses your camera to let you attach photos as messages. + NSMicrophoneUsageDescription + Allow $(PRODUCT_NAME) to access your microphone + NSPhotoLibraryUsageDescription + The app accesses your photos to let you attach them as messages. UILaunchStoryboardName SplashScreen UIRequiredDeviceCapabilities diff --git a/example/package-lock.json b/example/package-lock.json index 35ffe4b44..65915212c 100644 --- a/example/package-lock.json +++ b/example/package-lock.json @@ -12,12 +12,15 @@ "@react-navigation/native-stack": "^6.9.12", "ethers": "^5.7.2", "expo": "~48.0.18", + "expo-document-picker": "^11.5.4", + "expo-image-picker": "^14.3.2", "expo-splash-screen": "~0.18.2", "expo-status-bar": "~1.4.4", "moment": "^2.29.4", "node-libs-browser": "^2.2.1", "react": "18.2.0", "react-native": "0.71.8", + "react-native-blob-util": "^0.19.0", "react-native-crypto": "^2.2.0", "react-native-encrypted-storage": "^4.0.3", "react-native-get-random-values": "^1.8.0", @@ -26,6 +29,7 @@ "react-native-safe-area-context": "4.5.0", "react-native-screens": "~3.20.0", "react-native-svg": "^13.9.0", + "react-native-url-polyfill": "^2.0.0", "react-query": "^3.39.3" }, "devDependencies": { @@ -6079,6 +6083,11 @@ "node": ">=0.10.0" } }, + "node_modules/base-64": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/base-64/-/base-64-0.1.0.tgz", + "integrity": "sha512-Y5gU45svrR5tI2Vt/X9GPd3L0HNIKzGu202EjxrXMpuc2V2CiKgemAbUUsqYmZJvPtCXoUKjNZwBJzsNScUbXA==" + }, "node_modules/base/node_modules/define-property": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/define-property/-/define-property-1.0.0.tgz", @@ -8050,6 +8059,14 @@ "expo": "*" } }, + "node_modules/expo-document-picker": { + "version": "11.5.4", + "resolved": "https://registry.npmjs.org/expo-document-picker/-/expo-document-picker-11.5.4.tgz", + "integrity": "sha512-4lpRixi33kjoQQy9lfrGs48yFRMOgBgdnqjYWVFe85WFy/WkEoFC2E8usxcFdrIigtfy9Z+HY3/je4lHEYqyLg==", + "peerDependencies": { + "expo": "*" + } + }, "node_modules/expo-file-system": { "version": "15.2.2", "resolved": "https://registry.npmjs.org/expo-file-system/-/expo-file-system-15.2.2.tgz", @@ -8072,6 +8089,25 @@ "expo": "*" } }, + "node_modules/expo-image-loader": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/expo-image-loader/-/expo-image-loader-4.3.0.tgz", + "integrity": "sha512-2kqJIO+oYM8J3GbvTUHLqTSpt1dLpOn/X0eB4U4RTuzz/faj8l/TyQELsMBLlGAkweNUuG9LqznbaBz+WuSFEw==", + "peerDependencies": { + "expo": "*" + } + }, + "node_modules/expo-image-picker": { + "version": "14.3.2", + "resolved": "https://registry.npmjs.org/expo-image-picker/-/expo-image-picker-14.3.2.tgz", + "integrity": "sha512-xr/YeQMIYheXecWP033F2SPwpBlBR5xVCx7YSfSCTH8Y9pw7Z886agqKGbS9QBVGlzJ5qecJktZ6ASSzeslDVg==", + "dependencies": { + "expo-image-loader": "~4.3.0" + }, + "peerDependencies": { + "expo": "*" + } + }, "node_modules/expo-keep-awake": { "version": "12.0.1", "resolved": "https://registry.npmjs.org/expo-keep-awake/-/expo-keep-awake-12.0.1.tgz", @@ -12870,6 +12906,38 @@ "react": "18.2.0" } }, + "node_modules/react-native-blob-util": { + "version": "0.19.0", + "resolved": "https://registry.npmjs.org/react-native-blob-util/-/react-native-blob-util-0.19.0.tgz", + "integrity": "sha512-l4HIGDS7Sfio7lq6fQLjTlxDEeOmPiRb8Lj5V9AidGpyrt2ZOWoquXeGjSNncugF3zC+Wq+cIrY8rQtLfRsogA==", + "dependencies": { + "base-64": "0.1.0", + "glob": "^7.2.3" + }, + "peerDependencies": { + "react": "*", + "react-native": "*" + } + }, + "node_modules/react-native-blob-util/node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/react-native-codegen": { "version": "0.71.5", "resolved": "https://registry.npmjs.org/react-native-codegen/-/react-native-codegen-0.71.5.tgz", @@ -13003,6 +13071,17 @@ "react-native": "*" } }, + "node_modules/react-native-url-polyfill": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/react-native-url-polyfill/-/react-native-url-polyfill-2.0.0.tgz", + "integrity": "sha512-My330Do7/DvKnEvwQc0WdcBnFPploYKp9CYlefDXzIdEaA+PAhDYllkvGeEroEzvc4Kzzj2O4yVdz8v6fjRvhA==", + "dependencies": { + "whatwg-url-without-unicode": "8.0.0-3" + }, + "peerDependencies": { + "react-native": "*" + } + }, "node_modules/react-native/node_modules/promise": { "version": "8.3.0", "resolved": "https://registry.npmjs.org/promise/-/promise-8.3.0.tgz", @@ -15060,6 +15139,35 @@ "webidl-conversions": "^3.0.0" } }, + "node_modules/whatwg-url-without-unicode": { + "version": "8.0.0-3", + "resolved": "https://registry.npmjs.org/whatwg-url-without-unicode/-/whatwg-url-without-unicode-8.0.0-3.tgz", + "integrity": "sha512-HoKuzZrUlgpz35YO27XgD28uh/WJH4B0+3ttFqRo//lmq+9T/mIOJ6kqmINI9HpUpz1imRC/nR/lxKpJiv0uig==", + "dependencies": { + "buffer": "^5.4.3", + "punycode": "^2.1.1", + "webidl-conversions": "^5.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/whatwg-url-without-unicode/node_modules/punycode": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.0.tgz", + "integrity": "sha512-rRV+zQD8tVFys26lAGR9WUuS4iUAngJScM+ZRSKtvl5tKeZ2t5bvdNFdNHBW9FWR4guGHlgmsZ1G7BSm2wTbuA==", + "engines": { + "node": ">=6" + } + }, + "node_modules/whatwg-url-without-unicode/node_modules/webidl-conversions": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-5.0.0.tgz", + "integrity": "sha512-VlZwKPCkYKxQgeSbH5EyngOmRp7Ww7I9rQLERETtf5ofd9pGeswWiOtogpEO850jziPRarreGxn5QIiTqpb2wA==", + "engines": { + "node": ">=8" + } + }, "node_modules/which": { "version": "1.3.1", "resolved": "https://registry.npmjs.org/which/-/which-1.3.1.tgz", diff --git a/example/package.json b/example/package.json index 77f89f2a7..880a68555 100644 --- a/example/package.json +++ b/example/package.json @@ -12,12 +12,15 @@ "@react-navigation/native-stack": "^6.9.12", "ethers": "^5.7.2", "expo": "~48.0.18", + "expo-document-picker": "^11.5.4", + "expo-image-picker": "^14.3.2", "expo-splash-screen": "~0.18.2", "expo-status-bar": "~1.4.4", "moment": "^2.29.4", "node-libs-browser": "^2.2.1", "react": "18.2.0", "react-native": "0.71.8", + "react-native-blob-util": "^0.19.0", "react-native-crypto": "^2.2.0", "react-native-encrypted-storage": "^4.0.3", "react-native-get-random-values": "^1.8.0", @@ -26,6 +29,7 @@ "react-native-safe-area-context": "4.5.0", "react-native-screens": "~3.20.0", "react-native-svg": "^13.9.0", + "react-native-url-polyfill": "^2.0.0", "react-query": "^3.39.3" }, "devDependencies": { diff --git a/example/src/ConversationScreen.tsx b/example/src/ConversationScreen.tsx index 2b729b080..1b79f967b 100644 --- a/example/src/ConversationScreen.tsx +++ b/example/src/ConversationScreen.tsx @@ -4,6 +4,7 @@ import { Button, FlatList, KeyboardAvoidingView, + Image, Modal, Platform, SafeAreaView, @@ -11,19 +12,29 @@ import { Text, TextInput, TouchableHighlight, + TouchableOpacity, TouchableWithoutFeedback, View, } from "react-native"; +import "react-native-url-polyfill/auto"; import { Buffer } from "buffer"; +import { FontAwesome } from "@expo/vector-icons"; +import * as ImagePicker from "expo-image-picker"; +import * as DocumentPicker from "expo-document-picker"; import React, { useRef, useState } from "react"; import { useConversation, useMessage, useMessageReactions, useMessages, + useLoadRemoteAttachment, + usePrepareRemoteAttachment, } from "./hooks"; -import { MessageContent } from "xmtp-react-native-sdk"; +import { MessageContent, RemoteAttachmentContent } from "xmtp-react-native-sdk"; import moment from "moment"; +import { PermissionStatus } from "expo-modules-core"; +import type { DocumentPickerAsset } from "expo-document-picker"; +import type { ImagePickerAsset } from "expo-image-picker"; /// Show the messages in a conversation. export default function ConversationScreen({ @@ -40,36 +51,42 @@ export default function ConversationScreen({ let { data: conversation } = useConversation({ topic }); let [replyingTo, setReplyingTo] = useState(null); let [text, setText] = useState(""); + let [isShowingAttachmentModal, setShowingAttachmentModal] = useState(false); + let [attachment, setAttachment] = useState< + { image: ImagePickerAsset } | { file: DocumentPickerAsset } | null + >(null); + let [isAttachmentPreviewing, setAttachmentPreviewing] = useState(false); let [isSending, setSending] = useState(false); - + let { remoteAttachment } = usePrepareRemoteAttachment({ + fileUri: attachment?.image?.uri || attachment?.file?.uri, + mimeType: attachment?.file?.mimeType, + }); messages = (messages || []).filter(({ content }) => !content.reaction); // console.log("messages", JSON.stringify(messages, null, 2)); const sendMessage = async (content: MessageContent) => { setSending(true); console.log("Sending message", content); try { + content = replyingTo + ? { + reply: { + reference: replyingTo, + content, + }, + } + : content; await conversation!.send(content); await refreshMessages(); + setReplyingTo(null); } catch (e) { console.log("Error sending message", e); } finally { setSending(false); } }; - const sendTextMessage = () => - sendMessage( - replyingTo - ? { - reply: { - reference: replyingTo, - content: { text }, - }, - } - : { text }, - ).then(() => { - setText(""); - setReplyingTo(null); - }); + const sendRemoteAttachmentMessage = () => + sendMessage({ remoteAttachment }).then(() => setAttachment(null)); + const sendTextMessage = () => sendMessage({ text }).then(() => setText("")); const scrollToMessageId = (messageId: string) => { let index = (messages || []).findIndex((m) => m.id === messageId); if (index == -1) { @@ -80,13 +97,6 @@ export default function ConversationScreen({ animated: true, }); }; - // const sendAttachment = () => sendMessage({ - // attachment: { - // mimeType: "text/plain", - // filename: "hello.txt", - // data: new Buffer("Hello Hello Hello Hello Hello Hello").toString("base64"), - // } - // }); return ( + + console.log("from camera", image) + } + onAttachedImageFromLibrary={(image) => { + setAttachment({ image }); + setShowingAttachmentModal(false); + }} + onAttachedFile={(file) => { + console.log("from file", file); + setAttachment({ file }); + setShowingAttachmentModal(false); + }} + onRequestClose={() => setShowingAttachmentModal(false)} + /> + {replyingTo && ( scrollToMessageId(replyingTo!)} /> )} - - -