diff --git a/.gitignore b/.gitignore index 5ecbf803c8..3895765f2f 100644 --- a/.gitignore +++ b/.gitignore @@ -23,3 +23,4 @@ index.android.bundle *.app *.DSYM.zip **/metrics/ +package/shared-native/.sync-state/ diff --git a/examples/ExpoMessaging/package.json b/examples/ExpoMessaging/package.json index 05cdda1247..3fc4d38da1 100644 --- a/examples/ExpoMessaging/package.json +++ b/examples/ExpoMessaging/package.json @@ -3,6 +3,10 @@ "version": "1.0.0", "main": "expo-router/entry", "scripts": { + "sync-native": "bash ../../package/scripts/reconcile-shared-native.sh expo-package && bash ../../package/scripts/sync-shared-native.sh expo-package", + "prestart": "yarn sync-native", + "preandroid": "yarn sync-native", + "preios": "yarn sync-native", "start": "expo start --dev-client", "android": "expo run:android", "ios": "expo run:ios", @@ -44,6 +48,7 @@ "react-native-safe-area-context": "~5.6.0", "react-native-screens": "~4.16.0", "react-native-svg": "15.12.1", + "react-native-teleport": "^1.0.2", "react-native-worklets": "0.5.1", "stream-chat-expo": "link:../../package/expo-package", "stream-chat-react-native-core": "link:../../package" diff --git a/examples/ExpoMessaging/yarn.lock b/examples/ExpoMessaging/yarn.lock index 104da149ca..c74a42d065 100644 --- a/examples/ExpoMessaging/yarn.lock +++ b/examples/ExpoMessaging/yarn.lock @@ -1533,7 +1533,15 @@ resolved "https://registry.yarnpkg.com/@firebase/webchannel-wrapper/-/webchannel-wrapper-1.0.4.tgz#9d5b4b6f23309260a12856cb574c5e64e6c133f7" integrity sha512-6m8+P+dE/RPl4OPzjTxcTbQ0rGeRyeTvAi9KwIffBVCiAMKrfXfLZaqD1F+m8t4B5/Q5aHsMozOgirkH1F5oMQ== -"@gorhom/bottom-sheet@^5.1.6", "@gorhom/bottom-sheet@^5.1.8": +"@gorhom/bottom-sheet@5.1.8": + version "5.1.8" + resolved "https://registry.yarnpkg.com/@gorhom/bottom-sheet/-/bottom-sheet-5.1.8.tgz#65547917f5b1dae5a1291dabd4ea8bfee09feba4" + integrity sha512-QuYIVjn3K9bW20n5bgOSjvxBYoWG4YQXiLGOheEAMgISuoT6sMcA270ViSkkb0fenPxcIOwzCnFNuxmr739T9A== + dependencies: + "@gorhom/portal" "1.0.14" + invariant "^2.2.4" + +"@gorhom/bottom-sheet@^5.1.6": version "5.2.6" resolved "https://registry.yarnpkg.com/@gorhom/bottom-sheet/-/bottom-sheet-5.2.6.tgz#5f2045f6ca965383afe39f7dfa3afad1502b7467" integrity sha512-vmruJxdiUGDg+ZYcDmS30XDhq/h/+QkINOI5LY/uGjx8cPGwgJW0H6AB902gNTKtccbiKe/rr94EwdmIEz+LAQ== @@ -4256,22 +4264,6 @@ json5@^2.2.3: resolved "https://registry.yarnpkg.com/json5/-/json5-2.2.3.tgz#78cd6f1a19bdc12b73db5ad0c61efd66c1e29283" integrity sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg== -jsonwebtoken@^9.0.2: - version "9.0.2" - resolved "https://registry.yarnpkg.com/jsonwebtoken/-/jsonwebtoken-9.0.2.tgz#65ff91f4abef1784697d40952bb1998c504caaf3" - integrity sha512-PRp66vJ865SSqOlgqS8hujT5U4AOgMfhrwYIuIhfKaoSCZcirrmASQr8CX7cUg+RMih+hgznrjp99o+W4pJLHQ== - dependencies: - jws "^3.2.2" - lodash.includes "^4.3.0" - lodash.isboolean "^3.0.3" - lodash.isinteger "^4.0.4" - lodash.isnumber "^3.0.3" - lodash.isplainobject "^4.0.6" - lodash.isstring "^4.0.1" - lodash.once "^4.0.0" - ms "^2.1.1" - semver "^7.5.4" - jsonwebtoken@^9.0.3: version "9.0.3" resolved "https://registry.yarnpkg.com/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz#6cd57ab01e9b0ac07cb847d53d3c9b6ee31f7ae2" @@ -4288,15 +4280,6 @@ jsonwebtoken@^9.0.3: ms "^2.1.1" semver "^7.5.4" -jwa@^1.4.1: - version "1.4.2" - resolved "https://registry.yarnpkg.com/jwa/-/jwa-1.4.2.tgz#16011ac6db48de7b102777e57897901520eec7b9" - integrity sha512-eeH5JO+21J78qMvTIDdBXidBd6nG2kZjg5Ohz/1fpa28Z4CcsWUzJ1ZZyFq/3z3N17aZy+ZuBoHljASbL1WfOw== - dependencies: - buffer-equal-constant-time "^1.0.1" - ecdsa-sig-formatter "1.0.11" - safe-buffer "^5.0.1" - jwa@^2.0.1: version "2.0.1" resolved "https://registry.yarnpkg.com/jwa/-/jwa-2.0.1.tgz#bf8176d1ad0cd72e0f3f58338595a13e110bc804" @@ -4306,14 +4289,6 @@ jwa@^2.0.1: ecdsa-sig-formatter "1.0.11" safe-buffer "^5.0.1" -jws@^3.2.2: - version "3.2.2" - resolved "https://registry.yarnpkg.com/jws/-/jws-3.2.2.tgz#001099f3639468c9414000e99995fa52fb478304" - integrity sha512-YHlZCB6lMTllWDtSPHz/ZXTsi8S00usEV6v1tjq8tOUZzw7DpSDWVXjXDre6ed1w/pd495ODpHZYSdkRTsa0HA== - dependencies: - jwa "^1.4.1" - safe-buffer "^5.0.1" - jws@^4.0.1: version "4.0.1" resolved "https://registry.yarnpkg.com/jws/-/jws-4.0.1.tgz#07edc1be8fac20e677b283ece261498bd38f0690" @@ -4448,6 +4423,11 @@ lodash-es@4.17.21: resolved "https://registry.yarnpkg.com/lodash-es/-/lodash-es-4.17.21.tgz#43e626c46e6591b7750beb2b50117390c609e3ee" integrity sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw== +lodash-es@4.17.23: + version "4.17.23" + resolved "https://registry.yarnpkg.com/lodash-es/-/lodash-es-4.17.23.tgz#58c4360fd1b5d33afc6c0bbd3d1149349b1138e0" + integrity sha512-kVI48u3PZr38HdYz98UmfPnXl2DXrpdctLrFLCd3kOx1xUkOmpFPx7gCWWM5MPkL/fD8zb+Ph0QzjGFs4+hHWg== + lodash.camelcase@^4.3.0: version "4.3.0" resolved "https://registry.yarnpkg.com/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz#b28aa6288a2b9fc651035c7711f65ab6190331a6" @@ -5555,6 +5535,11 @@ react-native-svg@15.12.1: css-tree "^1.1.3" warn-once "0.1.1" +react-native-teleport@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/react-native-teleport/-/react-native-teleport-1.0.2.tgz#f5bed0534acba29787a6e3707513eed91cb4f8ea" + integrity sha512-+DE9N9JMxulUZwREDPBYl10Urmqocvzw++/BXzC34YzaHaDfbmgvr/KFJjGYoZhJUMcOJjBC9OxESH6+yzvxJA== + react-native-url-polyfill@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/react-native-url-polyfill/-/react-native-url-polyfill-2.0.0.tgz#db714520a2985cff1d50ab2e66279b9f91ffd589" @@ -6052,10 +6037,10 @@ stream-chat-react-native-core@8.1.0: version "0.0.0" uid "" -stream-chat@^9.27.2: - version "9.27.2" - resolved "https://registry.yarnpkg.com/stream-chat/-/stream-chat-9.27.2.tgz#5b41173e513f3606c47c93f391693b589e663968" - integrity sha512-OdALDzg8lO8CAdl8deydJ1+O4wJ7mM9dPLeCwDppq/OQ4aFIS9X38P+IdXPcOCsgSS97UoVUuxD2/excC5PEeg== +stream-chat@^9.35.1: + version "9.35.1" + resolved "https://registry.yarnpkg.com/stream-chat/-/stream-chat-9.35.1.tgz#d828854a9c27ea7e45e6642d9107966c6606f552" + integrity sha512-649sgO7+llFuW+y/Ja0K4d94aUC+EMxYUVo5mq5AFGT86vUAIXmRIMVHYHA/jw4MYoqfWAFrDK6L9Rhyn/eMkQ== dependencies: "@types/jsonwebtoken" "^9.0.8" "@types/ws" "^8.5.14" @@ -6068,9 +6053,9 @@ stream-chat@^9.27.2: ws "^8.18.1" stream-chat@^9.9.0: - version "9.20.3" - resolved "https://registry.yarnpkg.com/stream-chat/-/stream-chat-9.20.3.tgz#5f47d6f46d146202c743282f5fb7350f4a640922" - integrity sha512-206Lea0ZAVWbfYZkIwLG5m+++ELD3f8EAEL/YzbMDL++E2vU2WhQ2d1HNb1ROXURZUF0Sy845htTw1rwnahomw== + version "9.36.0" + resolved "https://registry.yarnpkg.com/stream-chat/-/stream-chat-9.36.0.tgz#154e0d6bdf8b15e97a6d9718c655d2ede34f6f25" + integrity sha512-D1b5THI4UbnvsEcJyUv1tUIgK6lCYT+aStrV+87mdrM9owX+WUpKaWFkxz/Ug+DOrJtTazvfuzvpJMyDi82NXA== dependencies: "@types/jsonwebtoken" "^9.0.8" "@types/ws" "^8.5.14" @@ -6078,7 +6063,7 @@ stream-chat@^9.9.0: base64-js "^1.5.1" form-data "^4.0.4" isomorphic-ws "^5.0.0" - jsonwebtoken "^9.0.2" + jsonwebtoken "^9.0.3" linkifyjs "^4.3.2" ws "^8.18.1" diff --git a/examples/SampleApp/fastlane/Fastfile b/examples/SampleApp/fastlane/Fastfile index 0efb03543f..365d3534ee 100644 --- a/examples/SampleApp/fastlane/Fastfile +++ b/examples/SampleApp/fastlane/Fastfile @@ -184,6 +184,7 @@ lane :frameworks_sizes do yarn_all sh('yarn build') sh('yarn minify-bundle') + js_bundle_size = file_size(path: 'package/lib/module/bundle.min.js') { js_bundle_size: js_bundle_size } end diff --git a/examples/SampleApp/ios/Podfile.lock b/examples/SampleApp/ios/Podfile.lock index 61fc07eb9c..acad889774 100644 --- a/examples/SampleApp/ios/Podfile.lock +++ b/examples/SampleApp/ios/Podfile.lock @@ -289,7 +289,6 @@ PODS: - React-RCTText (= 0.80.2) - React-RCTVibration (= 0.80.2) - React-callinvoker (0.80.2) - - React-Codegen (0.1.0) - React-Core (0.80.2): - boost - DoubleConversion @@ -1920,6 +1919,35 @@ PODS: - ReactCommon/turbomodule/core - SocketRocket - Yoga + - react-native-blur (4.4.1): + - boost + - DoubleConversion + - fast_float + - fmt + - glog + - hermes-engine + - RCT-Folly + - RCT-Folly/Fabric + - RCTRequired + - RCTTypeSafety + - React-Core + - React-debug + - React-Fabric + - React-featureflags + - React-graphics + - React-hermes + - React-ImageManager + - React-jsi + - React-NativeModulesApple + - React-RCTFabric + - React-renderercss + - React-rendererdebug + - React-utils + - ReactCodegen + - ReactCommon/turbomodule/bridging + - ReactCommon/turbomodule/core + - SocketRocket + - Yoga - react-native-cameraroll (7.10.0): - boost - DoubleConversion @@ -2722,6 +2750,35 @@ PODS: - ReactCommon/turbomodule/core - SocketRocket - Yoga + - RNCClipboard (1.16.3): + - boost + - DoubleConversion + - fast_float + - fmt + - glog + - hermes-engine + - RCT-Folly + - RCT-Folly/Fabric + - RCTRequired + - RCTTypeSafety + - React-Core + - React-debug + - React-Fabric + - React-featureflags + - React-graphics + - React-hermes + - React-ImageManager + - React-jsi + - React-NativeModulesApple + - React-RCTFabric + - React-renderercss + - React-rendererdebug + - React-utils + - ReactCodegen + - ReactCommon/turbomodule/bridging + - ReactCommon/turbomodule/core + - SocketRocket + - Yoga - RNFastImage (8.6.3): - React-Core - SDWebImage (~> 5.11.1) @@ -3146,7 +3203,6 @@ PODS: - RCT-Folly/Fabric - RCTRequired - RCTTypeSafety - - React-Codegen - React-Core - React-debug - React-Fabric @@ -3275,6 +3331,7 @@ DEPENDENCIES: - React-Mapbuffer (from `../node_modules/react-native/ReactCommon`) - React-microtasksnativemodule (from `../node_modules/react-native/ReactCommon/react/nativemodule/microtasks`) - react-native-blob-util (from `../node_modules/react-native-blob-util`) + - "react-native-blur (from `../node_modules/@react-native-community/blur`)" - "react-native-cameraroll (from `../node_modules/@react-native-camera-roll/camera-roll`)" - "react-native-document-picker (from `../node_modules/@react-native-documents/picker`)" - "react-native-geolocation (from `../node_modules/@react-native-community/geolocation`)" @@ -3315,6 +3372,7 @@ DEPENDENCIES: - ReactCodegen (from `build/generated/ios`) - ReactCommon/turbomodule/core (from `../node_modules/react-native/ReactCommon`) - "RNCAsyncStorage (from `../node_modules/@react-native-async-storage/async-storage`)" + - "RNCClipboard (from `../node_modules/@react-native-clipboard/clipboard`)" - RNFastImage (from `../node_modules/react-native-fast-image`) - "RNFBApp (from `../node_modules/@react-native-firebase/app`)" - "RNFBMessaging (from `../node_modules/@react-native-firebase/messaging`)" @@ -3351,7 +3409,6 @@ SPEC REPOS: - nanopb - PromisesObjC - PromisesSwift - - React-Codegen - SDWebImage - SDWebImageWebPCoder - SocketRocket @@ -3446,6 +3503,8 @@ EXTERNAL SOURCES: :path: "../node_modules/react-native/ReactCommon/react/nativemodule/microtasks" react-native-blob-util: :path: "../node_modules/react-native-blob-util" + react-native-blur: + :path: "../node_modules/@react-native-community/blur" react-native-cameraroll: :path: "../node_modules/@react-native-camera-roll/camera-roll" react-native-document-picker: @@ -3526,6 +3585,8 @@ EXTERNAL SOURCES: :path: "../node_modules/react-native/ReactCommon" RNCAsyncStorage: :path: "../node_modules/@react-native-async-storage/async-storage" + RNCClipboard: + :path: "../node_modules/@react-native-clipboard/clipboard" RNFastImage: :path: "../node_modules/react-native-fast-image" RNFBApp: @@ -3584,13 +3645,12 @@ SPEC CHECKSUMS: op-sqlite: a7e46cfdaebeef219fd0e939332967af9fe6d406 PromisesObjC: f5707f49cb48b9636751c5b2e7d227e43fba9f47 PromisesSwift: 9d77319bbe72ebf6d872900551f7eeba9bce2851 - RCT-Folly: 846fda9475e61ec7bcbf8a3fe81edfcaeb090669 + RCT-Folly: 59ec0ac1f2f39672a0c6e6cecdd39383b764646f RCTDeprecation: 300c5eb91114d4339b0bb39505d0f4824d7299b7 RCTRequired: e0446b01093475b7082fbeee5d1ef4ad1fe20ac4 RCTTypeSafety: cb974efcdc6695deedf7bf1eb942f2a0603a063f React: e7a4655b09d0e17e54be188cc34c2f3e2087318a React-callinvoker: 62192daaa2f30c3321fc531e4f776f7b09cf892b - React-Codegen: 4b8b4817cea7a54b83851d4c1f91f79aa73de30a React-Core: b23cdaaa9d76389d958c06af3c57aa6ad611c542 React-CoreModules: 8e0f562e5695991e455abbebe1e968af71d52553 React-cxxreact: 6ccbe0cc2c652b29409b14b23cfb3cd74e084691 @@ -3619,6 +3679,7 @@ SPEC CHECKSUMS: React-Mapbuffer: 8f620d1794c6b59a8c3862c3ae820a2e9e6c9bb0 React-microtasksnativemodule: dcf5321c9a41659a6718df8a5f202af1577c6825 react-native-blob-util: a511afccff6511544ebf56928e6afdf837b037a7 + react-native-blur: ecdc987ab8d8fba95abef14551f033376872d0a6 react-native-cameraroll: 8c3ba9b6f511cf645778de19d5039b61d922fdfb react-native-document-picker: b37cf6660ad9087b782faa78a1e67687fac15bfd react-native-geolocation: b7f68b8c04e36ee669c630dbc48dd42cf93a0a41 @@ -3659,6 +3720,7 @@ SPEC CHECKSUMS: ReactCodegen: 4928682e20747464165effacc170019a18da953c ReactCommon: ec1cdf708729338070f8c4ad746768a782fd9eb1 RNCAsyncStorage: f30b3a83064e28b0fc46f1fbd3834589ed64c7b9 + RNCClipboard: 8e5237c79dafacea5b7adf4c3ab39a4236b5ef7e RNFastImage: 462a183c4b0b6b26fdfd639e1ed6ba37536c3b87 RNFBApp: db9c2e6d36fe579ab19b82c0a4a417ff7569db7e RNFBMessaging: de62448d205095171915d622ed5fb45c2be5e075 @@ -3673,7 +3735,7 @@ SPEC CHECKSUMS: SDWebImage: a7f831e1a65eb5e285e3fb046a23fcfbf08e696d SDWebImageWebPCoder: 908b83b6adda48effe7667cd2b7f78c897e5111d SocketRocket: d4aabe649be1e368d1318fdf28a022d714d65748 - stream-chat-react-native: f42e234640869e0eafcdd354441414ad1818b9fe + stream-chat-react-native: 3a5d663e1d32afb54a3afba3691f08be65a20374 Teleport: c089481dd2bb020e3dced39b7f8849b93d1499f6 Yoga: ce248fb32065c9b00451491b06607f1c25b2f1ed diff --git a/examples/SampleApp/package.json b/examples/SampleApp/package.json index 20836e2dc4..0a3b243ecf 100644 --- a/examples/SampleApp/package.json +++ b/examples/SampleApp/package.json @@ -7,8 +7,12 @@ "url": "https://github.com/GetStream/stream-chat-react-native.git" }, "scripts": { + "sync-native": "bash ../../package/scripts/reconcile-shared-native.sh native-package && bash ../../package/scripts/sync-shared-native.sh native-package", + "preandroid": "yarn sync-native", "android": "react-native run-android", + "preios": "yarn sync-native", "ios": "react-native run-ios", + "prestart": "yarn sync-native", "start": "react-native start", "test": "jest", "lint": "eslint .", diff --git a/examples/TypeScriptMessaging/package.json b/examples/TypeScriptMessaging/package.json index 3f9e55baae..c2efffc829 100644 --- a/examples/TypeScriptMessaging/package.json +++ b/examples/TypeScriptMessaging/package.json @@ -3,8 +3,12 @@ "version": "0.0.1", "private": true, "scripts": { + "sync-native": "bash ../../package/scripts/reconcile-shared-native.sh native-package && bash ../../package/scripts/sync-shared-native.sh native-package", + "preandroid": "yarn sync-native", "android": "react-native run-android", + "preios": "yarn sync-native", "ios": "react-native run-ios", + "prestart": "yarn sync-native", "start": "react-native start", "test": "jest", "lint": "eslint .", diff --git a/package/expo-package/.gitignore b/package/expo-package/.gitignore new file mode 100644 index 0000000000..81e0235dce --- /dev/null +++ b/package/expo-package/.gitignore @@ -0,0 +1,7 @@ +# android +android/build +android/src/main/java/com/streamchatreactnative/shared + +# ios +ios/build +ios/shared diff --git a/package/expo-package/android/build.gradle b/package/expo-package/android/build.gradle new file mode 100644 index 0000000000..d64a081fba --- /dev/null +++ b/package/expo-package/android/build.gradle @@ -0,0 +1,139 @@ +def kotlinVersion = + rootProject.ext.has("kotlinVersion") + ? rootProject.ext.get("kotlinVersion") + : project.properties["StreamChatExpo_kotlinVersion"] + +buildscript { + repositories { + google() + mavenCentral() + } + + dependencies { + classpath "com.android.tools.build:gradle:7.2.1" + classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlinVersion" + } +} + +def isNewArchitectureEnabled() { + return rootProject.hasProperty("newArchEnabled") && rootProject.getProperty("newArchEnabled") == "true" +} + +apply plugin: "com.android.library" +apply plugin: "kotlin-android" + +if (isNewArchitectureEnabled()) { + apply plugin: "com.facebook.react" +} + +def getExtOrIntegerDefault(name) { + return rootProject.ext.has(name) ? rootProject.ext.get(name) : (project.properties["StreamChatExpo_" + name]).toInteger() +} +def localSharedNativeRootDir = new File(projectDir, "src/main/java/com/streamchatreactnative/shared") +def sharedNativeRootDir = new File(projectDir, "../../shared-native/android") +def hasNativeSources = { File dir -> + dir.exists() && !fileTree(dir).matching { include "**/*.kt"; include "**/*.java" }.files.isEmpty() +} + +android { + compileSdkVersion getExtOrIntegerDefault("compileSdkVersion") + def agpVersion = com.android.Version.ANDROID_GRADLE_PLUGIN_VERSION + def agpMajorVersion = agpVersion.tokenize('.')[0].toInteger() + def agpMinorVersion = agpVersion.tokenize('.')[1].toInteger() + + if (agpMajorVersion >= 7 && agpMinorVersion >= 3) { + namespace "com.streamchatexpo" + } + + defaultConfig { + minSdkVersion getExtOrIntegerDefault("minSdkVersion") + targetSdkVersion getExtOrIntegerDefault("targetSdkVersion") + buildConfigField "boolean", "IS_NEW_ARCHITECTURE_ENABLED", isNewArchitectureEnabled().toString() + } + + buildTypes { + release { + minifyEnabled false + } + } + + lintOptions { + disable "GradleCompatible" + } + + compileOptions { + sourceCompatibility JavaVersion.VERSION_1_8 + targetCompatibility JavaVersion.VERSION_1_8 + } + + kotlinOptions { + jvmTarget = "17" + } + + sourceSets { + main { + if (isNewArchitectureEnabled()) { + java.srcDirs += [ + "src/newarch", + "src/main/java/com/streamchatreactnative/shared", + "${project.buildDir}/generated/source/codegen/java" + ] + } + } + } +} + +tasks.register("syncSharedShimmerSources") { + outputs.dir(localSharedNativeRootDir) + outputs.upToDateWhen { false } + doLast { + def sourceRootDir = null + if (hasNativeSources(localSharedNativeRootDir)) { + sourceRootDir = localSharedNativeRootDir + } else if (hasNativeSources(sharedNativeRootDir)) { + sourceRootDir = sharedNativeRootDir + } + + if (sourceRootDir == null) { + throw new GradleException( + "Missing shared native Android sources. Expected either src/main/java/com/streamchatreactnative/shared/**/*.{kt,java} " + + "or ../../shared-native/android/**/*.{kt,java}." + ) + } + + if (sourceRootDir != localSharedNativeRootDir) { + project.delete(localSharedNativeRootDir) + project.copy { + from(sourceRootDir) + into(localSharedNativeRootDir) + } + } else if (!hasNativeSources(localSharedNativeRootDir)) { + throw new GradleException("Shared native source directory exists but has no Kotlin/Java files.") + } + } +} + +tasks.matching { it.name == "preBuild" }.configureEach { + dependsOn("syncSharedShimmerSources") +} + +tasks.withType(org.jetbrains.kotlin.gradle.tasks.KotlinCompile).configureEach { + dependsOn("syncSharedShimmerSources") +} + +repositories { + mavenCentral() + google() +} + +dependencies { + implementation "com.facebook.react:react-native:+" +} + +if (isNewArchitectureEnabled()) { + react { + jsRootDir = file("../src/") + libraryName = "StreamChatExpo" + codegenJavaPackageName = "com.streamchatexpo" + } +} diff --git a/package/expo-package/android/gradle.properties b/package/expo-package/android/gradle.properties new file mode 100644 index 0000000000..ff43bf1f8d --- /dev/null +++ b/package/expo-package/android/gradle.properties @@ -0,0 +1,4 @@ +StreamChatExpo_kotlinVersion=1.7.0 +StreamChatExpo_minSdkVersion=21 +StreamChatExpo_targetSdkVersion=31 +StreamChatExpo_compileSdkVersion=31 diff --git a/package/expo-package/android/src/main/AndroidManifest.xml b/package/expo-package/android/src/main/AndroidManifest.xml new file mode 100644 index 0000000000..17df9bf89c --- /dev/null +++ b/package/expo-package/android/src/main/AndroidManifest.xml @@ -0,0 +1,4 @@ + + + diff --git a/package/expo-package/android/src/main/java/com/streamchatexpo/StreamChatExpoPackage.java b/package/expo-package/android/src/main/java/com/streamchatexpo/StreamChatExpoPackage.java new file mode 100644 index 0000000000..cd42f18297 --- /dev/null +++ b/package/expo-package/android/src/main/java/com/streamchatexpo/StreamChatExpoPackage.java @@ -0,0 +1,35 @@ +package com.streamchatexpo; + +import androidx.annotation.Nullable; +import com.facebook.react.TurboReactPackage; +import com.facebook.react.bridge.NativeModule; +import com.facebook.react.bridge.ReactApplicationContext; +import com.facebook.react.module.model.ReactModuleInfo; +import com.facebook.react.module.model.ReactModuleInfoProvider; +import com.facebook.react.uimanager.ViewManager; +import com.streamchatreactnative.StreamShimmerViewManager; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +public class StreamChatExpoPackage extends TurboReactPackage { + @Nullable + @Override + public NativeModule getModule(String name, ReactApplicationContext reactContext) { + return null; + } + + @Override + public ReactModuleInfoProvider getReactModuleInfoProvider() { + return () -> { + final Map moduleInfos = new HashMap<>(); + return moduleInfos; + }; + } + + @Override + public List createViewManagers(ReactApplicationContext reactContext) { + return Collections.singletonList(new StreamShimmerViewManager()); + } +} diff --git a/package/expo-package/package.json b/package/expo-package/package.json index 1fce674308..4108730345 100644 --- a/package/expo-package/package.json +++ b/package/expo-package/package.json @@ -11,6 +11,17 @@ "url": "https://github.com/GetStream/stream-chat-react-native.git", "directory": "package/expo-package" }, + "files": [ + "src", + "types", + "android/src", + "android/build.gradle", + "android/gradle.properties", + "ios", + "*.podspec", + "react-native.config.js", + "package.json" + ], "license": "SEE LICENSE IN LICENSE", "main": "src/index.js", "types": "types/index.d.ts", @@ -20,6 +31,7 @@ }, "peerDependencies": { "expo": ">=51.0.0", + "react-native": ">=0.76.0", "expo-av": "*", "expo-clipboard": "*", "expo-document-picker": "*", @@ -70,8 +82,19 @@ "expo-audio": "~0.4.6" }, "scripts": { - "prepack": " cp ../../README.md .", - "postpack": "rm README.md" + "postinstall": "if [ -f ../scripts/sync-shared-native.sh ] && [ -d ../shared-native/ios ]; then bash ../scripts/sync-shared-native.sh expo-package; fi", + "prepack": "bash ../scripts/sync-shared-native.sh expo-package && cp ../../README.md .", + "postpack": "rm README.md && bash ../scripts/clean-shared-native-copies.sh expo-package" + }, + "codegenConfig": { + "name": "StreamChatExpoSpec", + "type": "all", + "jsSrcsDir": "src/native", + "ios": { + "componentProvider": { + "StreamShimmerView": "StreamShimmerViewComponentView" + } + } }, "resolutions": { "@types/react": "^19.0.0" diff --git a/package/expo-package/react-native.config.js b/package/expo-package/react-native.config.js new file mode 100644 index 0000000000..a94bce2790 --- /dev/null +++ b/package/expo-package/react-native.config.js @@ -0,0 +1,13 @@ +module.exports = { + dependency: { + platforms: { + android: { + packageImportPath: 'import com.streamchatexpo.StreamChatExpoPackage;', + packageInstance: 'new StreamChatExpoPackage()', + }, + ios: { + podspecPath: 'stream-chat-expo.podspec', + }, + }, + }, +}; diff --git a/package/expo-package/src/index.js b/package/expo-package/src/index.js index f9fbff91d6..53960194f9 100644 --- a/package/expo-package/src/index.js +++ b/package/expo-package/src/index.js @@ -10,6 +10,7 @@ import { getLocalAssetUri, getPhotos, iOS14RefreshGallerySelection, + NativeShimmerView, oniOS14GalleryLibrarySelectionChange, overrideAudioRecordingConfiguration, pickDocument, @@ -31,6 +32,7 @@ registerNativeHandlers({ getLocalAssetUri, getPhotos, iOS14RefreshGallerySelection, + NativeShimmerView, oniOS14GalleryLibrarySelectionChange, overrideAudioRecordingConfiguration, pickDocument, diff --git a/package/expo-package/src/native/StreamShimmerViewNativeComponent.ts b/package/expo-package/src/native/StreamShimmerViewNativeComponent.ts new file mode 100644 index 0000000000..8ba18ab123 --- /dev/null +++ b/package/expo-package/src/native/StreamShimmerViewNativeComponent.ts @@ -0,0 +1,14 @@ +import type { ColorValue, HostComponent, ViewProps } from 'react-native'; + +import type { WithDefault } from 'react-native/Libraries/Types/CodegenTypes'; +import codegenNativeComponent from 'react-native/Libraries/Utilities/codegenNativeComponent'; + +export interface NativeProps extends ViewProps { + baseColor?: ColorValue; + enabled?: WithDefault; + gradientColor?: ColorValue; +} + +export default codegenNativeComponent( + 'StreamShimmerView', +) as HostComponent; diff --git a/package/expo-package/src/optionalDependencies/NativeShimmerView.ts b/package/expo-package/src/optionalDependencies/NativeShimmerView.ts new file mode 100644 index 0000000000..fbbe898275 --- /dev/null +++ b/package/expo-package/src/optionalDependencies/NativeShimmerView.ts @@ -0,0 +1,3 @@ +import StreamShimmerViewNativeComponent from '../native/StreamShimmerViewNativeComponent'; + +export const NativeShimmerView = StreamShimmerViewNativeComponent; diff --git a/package/expo-package/src/optionalDependencies/index.ts b/package/expo-package/src/optionalDependencies/index.ts index 86122fb6f1..5cc6f86346 100644 --- a/package/expo-package/src/optionalDependencies/index.ts +++ b/package/expo-package/src/optionalDependencies/index.ts @@ -3,6 +3,7 @@ export * from './deleteFile'; export * from './getLocalAssetUri'; export * from './getPhotos'; export * from './iOS14RefreshGallerySelection'; +export * from './NativeShimmerView'; export * from './oniOS14GalleryLibrarySelectionChange'; export * from './pickDocument'; export * from './pickImage'; diff --git a/package/expo-package/stream-chat-expo.podspec b/package/expo-package/stream-chat-expo.podspec new file mode 100644 index 0000000000..0a9f977668 --- /dev/null +++ b/package/expo-package/stream-chat-expo.podspec @@ -0,0 +1,25 @@ +require "json" + +package = JSON.parse(File.read(File.join(__dir__, "package.json"))) + +Pod::Spec.new do |s| + s.name = "stream-chat-expo" + s.version = package["version"] + s.summary = package["description"] + s.homepage = "https://www.npmjs.com/package/stream-chat-expo" + s.license = package["license"] + s.authors = package["author"] + + s.platforms = { :ios => min_ios_version_supported } + s.source = { :git => "./ios", :tag => "#{s.version}" } + s.prepare_command = <<-CMD + if [ -d ../shared-native/ios ]; then + mkdir -p ios/shared + rsync -a --delete ../shared-native/ios/ ios/shared/ + fi + CMD + s.source_files = "ios/**/*.{h,m,mm,swift}" + s.private_header_files = "ios/**/*.h" + + install_modules_dependencies(s) +end diff --git a/package/native-package/.gitignore b/package/native-package/.gitignore index 2dc3e3178e..81e0235dce 100644 --- a/package/native-package/.gitignore +++ b/package/native-package/.gitignore @@ -1,2 +1,7 @@ # android android/build +android/src/main/java/com/streamchatreactnative/shared + +# ios +ios/build +ios/shared diff --git a/package/native-package/android/build.gradle b/package/native-package/android/build.gradle index 1c0d25b343..6113b5c74a 100644 --- a/package/native-package/android/build.gradle +++ b/package/native-package/android/build.gradle @@ -1,3 +1,8 @@ +def kotlinVersion = + rootProject.ext.has("kotlinVersion") + ? rootProject.ext.get("kotlinVersion") + : project.properties["ImageResizer_kotlinVersion"] + buildscript { repositories { google() @@ -6,7 +11,7 @@ buildscript { dependencies { classpath "com.android.tools.build:gradle:7.2.1" - + classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlinVersion" } } @@ -15,6 +20,7 @@ def isNewArchitectureEnabled() { } apply plugin: "com.android.library" +apply plugin: "kotlin-android" def appProject = rootProject.allprojects.find { it.plugins.hasPlugin('com.android.application') } @@ -30,6 +36,11 @@ def getExtOrDefault(name) { def getExtOrIntegerDefault(name) { return rootProject.ext.has(name) ? rootProject.ext.get(name) : (project.properties["ImageResizer_" + name]).toInteger() } +def localSharedNativeRootDir = new File(projectDir, "src/main/java/com/streamchatreactnative/shared") +def sharedNativeRootDir = new File(projectDir, "../../shared-native/android") +def hasNativeSources = { File dir -> + dir.exists() && !fileTree(dir).matching { include "**/*.kt"; include "**/*.java" }.files.isEmpty() +} android { compileSdkVersion getExtOrIntegerDefault("compileSdkVersion") @@ -65,11 +76,16 @@ android { targetCompatibility JavaVersion.VERSION_1_8 } + kotlinOptions { + jvmTarget = "17" + } + sourceSets { main { if (isNewArchitectureEnabled()) { java.srcDirs += [ "src/newarch", + "src/main/java/com/streamchatreactnative/shared", // This is needed to build Kotlin project with NewArch enabled "${project.buildDir}/generated/source/codegen/java" ] @@ -80,6 +96,44 @@ android { } } +tasks.register("syncSharedShimmerSources") { + outputs.dir(localSharedNativeRootDir) + outputs.upToDateWhen { false } + doLast { + def sourceRootDir = null + if (hasNativeSources(localSharedNativeRootDir)) { + sourceRootDir = localSharedNativeRootDir + } else if (hasNativeSources(sharedNativeRootDir)) { + sourceRootDir = sharedNativeRootDir + } + + if (sourceRootDir == null) { + throw new GradleException( + "Missing shared native Android sources. Expected either src/main/java/com/streamchatreactnative/shared/**/*.{kt,java} " + + "or ../../shared-native/android/**/*.{kt,java}." + ) + } + + if (sourceRootDir != localSharedNativeRootDir) { + project.delete(localSharedNativeRootDir) + project.copy { + from(sourceRootDir) + into(localSharedNativeRootDir) + } + } else if (!hasNativeSources(localSharedNativeRootDir)) { + throw new GradleException("Shared native source directory exists but has no Kotlin/Java files.") + } + } +} + +tasks.matching { it.name == "preBuild" }.configureEach { + dependsOn("syncSharedShimmerSources") +} + +tasks.withType(org.jetbrains.kotlin.gradle.tasks.KotlinCompile).configureEach { + dependsOn("syncSharedShimmerSources") +} + repositories { mavenCentral() google() diff --git a/package/native-package/android/src/main/java/com/streamchatreactnative/StreamChatReactNativePackage.java b/package/native-package/android/src/main/java/com/streamchatreactnative/StreamChatReactNativePackage.java index 203b72b857..9f2decab6d 100644 --- a/package/native-package/android/src/main/java/com/streamchatreactnative/StreamChatReactNativePackage.java +++ b/package/native-package/android/src/main/java/com/streamchatreactnative/StreamChatReactNativePackage.java @@ -6,9 +6,11 @@ import com.facebook.react.module.model.ReactModuleInfo; import com.facebook.react.module.model.ReactModuleInfoProvider; import com.facebook.react.TurboReactPackage; - +import com.facebook.react.uimanager.ViewManager; import java.util.HashMap; +import java.util.Collections; +import java.util.List; import java.util.Map; public class StreamChatReactNativePackage extends TurboReactPackage { @@ -42,4 +44,9 @@ public ReactModuleInfoProvider getReactModuleInfoProvider() { return moduleInfos; }; } + + @Override + public List createViewManagers(ReactApplicationContext reactContext) { + return Collections.singletonList(new StreamShimmerViewManager()); + } } diff --git a/package/native-package/package.json b/package/native-package/package.json index a29777ba10..1dcd41ad08 100644 --- a/package/native-package/package.json +++ b/package/native-package/package.json @@ -15,7 +15,10 @@ "files": [ "src", "types", - "android", + "android/src", + "android/build.gradle", + "android/gradle.properties", + "android/gradle", "ios", "*.podspec", "package.json" @@ -78,16 +81,22 @@ } }, "scripts": { - "prepack": " cp ../../README.md .", - "postpack": "rm README.md" + "postinstall": "if [ -f ../scripts/sync-shared-native.sh ] && [ -d ../shared-native/ios ]; then bash ../scripts/sync-shared-native.sh native-package; fi", + "prepack": "bash ../scripts/sync-shared-native.sh native-package && cp ../../README.md .", + "postpack": "rm README.md && bash ../scripts/clean-shared-native-copies.sh native-package" }, "devDependencies": { "react-native": "^0.79.3" }, "codegenConfig": { "name": "StreamChatReactNativeSpec", - "type": "modules", - "jsSrcsDir": "src/native" + "type": "all", + "jsSrcsDir": "src/native", + "ios": { + "componentProvider": { + "StreamShimmerView": "StreamShimmerViewComponentView" + } + } }, "resolutions": { "@types/react": "^19.0.0" diff --git a/package/native-package/src/index.js b/package/native-package/src/index.js index c782abf541..ee090a1cc6 100644 --- a/package/native-package/src/index.js +++ b/package/native-package/src/index.js @@ -11,6 +11,7 @@ import { getLocalAssetUri, getPhotos, iOS14RefreshGallerySelection, + NativeShimmerView, oniOS14GalleryLibrarySelectionChange, overrideAudioRecordingConfiguration, pickDocument, @@ -32,6 +33,7 @@ registerNativeHandlers({ getLocalAssetUri, getPhotos, iOS14RefreshGallerySelection, + NativeShimmerView, oniOS14GalleryLibrarySelectionChange, overrideAudioRecordingConfiguration, pickDocument, diff --git a/package/native-package/src/native/StreamShimmerViewNativeComponent.ts b/package/native-package/src/native/StreamShimmerViewNativeComponent.ts new file mode 100644 index 0000000000..8ba18ab123 --- /dev/null +++ b/package/native-package/src/native/StreamShimmerViewNativeComponent.ts @@ -0,0 +1,14 @@ +import type { ColorValue, HostComponent, ViewProps } from 'react-native'; + +import type { WithDefault } from 'react-native/Libraries/Types/CodegenTypes'; +import codegenNativeComponent from 'react-native/Libraries/Utilities/codegenNativeComponent'; + +export interface NativeProps extends ViewProps { + baseColor?: ColorValue; + enabled?: WithDefault; + gradientColor?: ColorValue; +} + +export default codegenNativeComponent( + 'StreamShimmerView', +) as HostComponent; diff --git a/package/native-package/src/optionalDependencies/NativeShimmerView.ts b/package/native-package/src/optionalDependencies/NativeShimmerView.ts new file mode 100644 index 0000000000..fbbe898275 --- /dev/null +++ b/package/native-package/src/optionalDependencies/NativeShimmerView.ts @@ -0,0 +1,3 @@ +import StreamShimmerViewNativeComponent from '../native/StreamShimmerViewNativeComponent'; + +export const NativeShimmerView = StreamShimmerViewNativeComponent; diff --git a/package/native-package/src/optionalDependencies/index.ts b/package/native-package/src/optionalDependencies/index.ts index 776ef08ba7..1b1ddee508 100644 --- a/package/native-package/src/optionalDependencies/index.ts +++ b/package/native-package/src/optionalDependencies/index.ts @@ -4,6 +4,7 @@ export * from './FlatList'; export * from './getLocalAssetUri'; export * from './getPhotos'; export * from './iOS14RefreshGallerySelection'; +export * from './NativeShimmerView'; export * from './oniOS14GalleryLibrarySelectionChange'; export * from './pickDocument'; export * from './pickImage'; diff --git a/package/native-package/stream-chat-react-native.podspec b/package/native-package/stream-chat-react-native.podspec index 32f974ca8a..d1c4a58a24 100644 --- a/package/native-package/stream-chat-react-native.podspec +++ b/package/native-package/stream-chat-react-native.podspec @@ -1,7 +1,6 @@ require "json" package = JSON.parse(File.read(File.join(__dir__, "package.json"))) -folly_compiler_flags = '-DFOLLY_NO_CONFIG -DFOLLY_MOBILE=1 -DFOLLY_USE_LIBCPP=1 -Wno-comma -Wno-shorten-64-to-32' Pod::Spec.new do |s| s.name = "stream-chat-react-native" @@ -11,28 +10,16 @@ Pod::Spec.new do |s| s.license = package["license"] s.authors = package["author"] - s.platforms = { :ios => "10.0" } + s.platforms = { :ios => min_ios_version_supported } s.source = { :git => "./ios", :tag => "#{s.version}" } + s.prepare_command = <<-CMD + if [ -d ../shared-native/ios ]; then + mkdir -p ios/shared + rsync -a --delete ../shared-native/ios/ ios/shared/ + fi + CMD + s.source_files = "ios/**/*.{h,m,mm,swift}" + s.private_header_files = "ios/**/*.h" - s.source_files = "ios/**/*.{h,m,mm}" - - s.dependency "React-Core" - s.ios.framework = 'AssetsLibrary', 'MobileCoreServices' - - # Don't install the dependencies when we run `pod install` in the old architecture. - if ENV['RCT_NEW_ARCH_ENABLED'] == '1' then - s.compiler_flags = folly_compiler_flags + " -DRCT_NEW_ARCH_ENABLED=1" - s.pod_target_xcconfig = { - "HEADER_SEARCH_PATHS" => "\"$(PODS_ROOT)/boost\"", - "OTHER_CPLUSPLUSFLAGS" => "-DFOLLY_NO_CONFIG -DFOLLY_MOBILE=1 -DFOLLY_USE_LIBCPP=1", - "CLANG_CXX_LANGUAGE_STANDARD" => "c++17" - } - s.dependency "React-Codegen" - s.dependency "RCT-Folly" - s.dependency "RCTRequired" - s.dependency "RCTTypeSafety" - s.dependency "ReactCommon/turbomodule/core" - install_modules_dependencies(s) - end + install_modules_dependencies(s) end - diff --git a/package/package.json b/package/package.json index ae2aa00e6b..ffea4c198b 100644 --- a/package/package.json +++ b/package/package.json @@ -24,6 +24,8 @@ ], "scripts": { "install-all": "(yarn install --force && (cd native-package && yarn install --force) && (cd expo-package && yarn install --force))", + "shared-native:sync": "bash ./scripts/sync-shared-native.sh all", + "shared-native:clean-copies": "bash ./scripts/clean-shared-native-copies.sh all", "build": "rimraf lib && yarn run --silent build-translations && bob build && yarn run --silent copy-translations", "build-translations": "i18next-cli sync", "copy-translations": "echo '\u001b[34mℹ\u001b[0m Copying translation files to \u001b[34mlib/typescript/i18n\u001b[0m' && cp -R -f ./src/i18n ./lib/typescript/i18n && echo '\u001b[32m✓\u001b[0m Done Copying Translations'", diff --git a/package/scripts/clean-shared-native-copies.sh b/package/scripts/clean-shared-native-copies.sh new file mode 100644 index 0000000000..f4eb986d4e --- /dev/null +++ b/package/scripts/clean-shared-native-copies.sh @@ -0,0 +1,35 @@ +#!/usr/bin/env bash + +set -euo pipefail + +TARGET="${1:-all}" + +ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)" + +clean_package() { + local package_name="$1" + local android_dir="$ROOT_DIR/$package_name/android/src/main/java/com/streamchatreactnative/shared" + local ios_dir="$ROOT_DIR/$package_name/ios/shared" + + rm -rf "$android_dir" "$ios_dir" +} + +case "$TARGET" in + native-package) + clean_package "native-package" + ;; + expo-package) + clean_package "expo-package" + ;; + all) + clean_package "native-package" + clean_package "expo-package" + ;; + *) + echo "Unknown target: $TARGET" + echo "Expected one of: native-package, expo-package, all" + exit 1 + ;; +esac + +echo "Cleaned generated shared native copies for target: $TARGET" diff --git a/package/scripts/reconcile-shared-native.sh b/package/scripts/reconcile-shared-native.sh new file mode 100755 index 0000000000..d66fa5b2ed --- /dev/null +++ b/package/scripts/reconcile-shared-native.sh @@ -0,0 +1,175 @@ +#!/usr/bin/env bash + +set -euo pipefail + +TARGET="${1:-all}" + +ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)" +STATE_DIR="$ROOT_DIR/shared-native/.sync-state" +SHARED_ANDROID_DIR="$ROOT_DIR/shared-native/android" +SHARED_IOS_DIR="$ROOT_DIR/shared-native/ios" + +sync_dir_contents() { + local src_dir="$1" + local dst_dir="$2" + + mkdir -p "$dst_dir" + + if command -v rsync >/dev/null 2>&1; then + rsync -a --delete "$src_dir"/ "$dst_dir"/ + else + find "$dst_dir" -mindepth 1 -maxdepth 1 -exec rm -rf {} + + cp -R "$src_dir"/. "$dst_dir"/ + fi +} + +hash_manifest_for_dir() { + local dir="$1" + local manifest_path="$2" + + mkdir -p "$(dirname "$manifest_path")" + : > "$manifest_path" + + if [ ! -d "$dir" ]; then + return + fi + + while IFS= read -r file; do + local rel_path="${file#$dir/}" + local hash + hash="$(shasum "$file" | awk '{print $1}')" + printf "%s\t%s\n" "$rel_path" "$hash" >> "$manifest_path" + done < <(find "$dir" -type f | LC_ALL=C sort) +} + +hash_for_path() { + local manifest_path="$1" + local rel_path="$2" + awk -F '\t' -v path="$rel_path" '$1 == path { print $2; found=1; exit } END { if (!found) print "-" }' "$manifest_path" +} + +sync_platform_with_conflict_detection() { + local package_name="$1" + local platform_name="$2" + local package_dir="$3" + local shared_dir="$4" + + mkdir -p "$STATE_DIR" + mkdir -p "$package_dir" "$shared_dir" + + local baseline_manifest="$STATE_DIR/${package_name}_${platform_name}.manifest" + local tmp_baseline_manifest + local tmp_shared_manifest + local tmp_package_manifest + local tmp_union_paths + local tmp_conflicts + + tmp_baseline_manifest="$(mktemp)" + tmp_shared_manifest="$(mktemp)" + tmp_package_manifest="$(mktemp)" + tmp_union_paths="$(mktemp)" + tmp_conflicts="$(mktemp)" + + if [ -f "$baseline_manifest" ]; then + cp "$baseline_manifest" "$tmp_baseline_manifest" + fi + + hash_manifest_for_dir "$shared_dir" "$tmp_shared_manifest" + hash_manifest_for_dir "$package_dir" "$tmp_package_manifest" + + cat "$tmp_baseline_manifest" "$tmp_shared_manifest" "$tmp_package_manifest" \ + | awk -F '\t' 'NF > 0 { print $1 }' \ + | LC_ALL=C sort -u > "$tmp_union_paths" + + local shared_changed=0 + local package_changed=0 + local conflict_count=0 + + while IFS= read -r rel_path; do + [ -z "$rel_path" ] && continue + + local baseline_hash + local shared_hash + local package_hash + baseline_hash="$(hash_for_path "$tmp_baseline_manifest" "$rel_path")" + shared_hash="$(hash_for_path "$tmp_shared_manifest" "$rel_path")" + package_hash="$(hash_for_path "$tmp_package_manifest" "$rel_path")" + + if [ "$shared_hash" != "$baseline_hash" ]; then + shared_changed=1 + fi + if [ "$package_hash" != "$baseline_hash" ]; then + package_changed=1 + fi + + if [ "$shared_hash" != "$package_hash" ] && [ "$shared_hash" != "$baseline_hash" ] && [ "$package_hash" != "$baseline_hash" ]; then + conflict_count=$((conflict_count + 1)) + printf "%s (shared-native=%s, package=%s, baseline=%s)\n" \ + "$rel_path" "$shared_hash" "$package_hash" "$baseline_hash" >> "$tmp_conflicts" + fi + done < "$tmp_union_paths" + + if [ "$conflict_count" -gt 0 ]; then + echo "Conflict detected for $package_name [$platform_name]." + echo "Both shared-native and package mirror changed the same file(s) differently since last sync:" + cat "$tmp_conflicts" + rm -f "$tmp_baseline_manifest" "$tmp_shared_manifest" "$tmp_package_manifest" "$tmp_union_paths" "$tmp_conflicts" + return 1 + fi + + if [ "$package_changed" -eq 1 ] && [ "$shared_changed" -eq 0 ]; then + sync_dir_contents "$package_dir" "$shared_dir" + echo "Applied $platform_name sync direction: package -> shared-native ($package_name)" + elif [ "$shared_changed" -eq 1 ] && [ "$package_changed" -eq 0 ]; then + sync_dir_contents "$shared_dir" "$package_dir" + echo "Applied $platform_name sync direction: shared-native -> package ($package_name)" + fi + + hash_manifest_for_dir "$shared_dir" "$baseline_manifest" + rm -f "$tmp_baseline_manifest" "$tmp_shared_manifest" "$tmp_package_manifest" "$tmp_union_paths" "$tmp_conflicts" +} + +sync_from_package() { + local package_name="$1" + local android_package_dir="$ROOT_DIR/$package_name/android/src/main/java/com/streamchatreactnative/shared" + local ios_package_dir="$ROOT_DIR/$package_name/ios/shared" + + sync_platform_with_conflict_detection \ + "$package_name" \ + "android" \ + "$android_package_dir" \ + "$SHARED_ANDROID_DIR" + + sync_platform_with_conflict_detection \ + "$package_name" \ + "ios" \ + "$ios_package_dir" \ + "$SHARED_IOS_DIR" +} + +case "$TARGET" in + native-package) + sync_from_package "native-package" + ;; + expo-package) + sync_from_package "expo-package" + ;; + all) + # Prefer native-package when both are present. + if [ -d "$ROOT_DIR/native-package/android/src/main/java/com/streamchatreactnative/shared" ] || [ -d "$ROOT_DIR/native-package/ios/shared" ]; then + sync_from_package "native-package" + elif [ -d "$ROOT_DIR/expo-package/android/src/main/java/com/streamchatreactnative/shared" ] || [ -d "$ROOT_DIR/expo-package/ios/shared" ]; then + sync_from_package "expo-package" + else + echo "No package shared native sources found to sync from" + exit 1 + fi + ;; + *) + echo "Unknown target: $TARGET" + echo "Expected one of: native-package, expo-package, all" + exit 1 + ;; +esac + +echo "Reconciled package/shared-native directories with conflict checks for target: $TARGET" diff --git a/package/scripts/sync-shared-native.sh b/package/scripts/sync-shared-native.sh new file mode 100644 index 0000000000..8d09ded9d5 --- /dev/null +++ b/package/scripts/sync-shared-native.sh @@ -0,0 +1,61 @@ +#!/usr/bin/env bash + +set -euo pipefail + +TARGET="${1:-all}" + +ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)" +SHARED_ANDROID_DIR="$ROOT_DIR/shared-native/android" +SHARED_IOS_DIR="$ROOT_DIR/shared-native/ios" + +sync_dir_contents() { + local src_dir="$1" + local dst_dir="$2" + + mkdir -p "$dst_dir" + + if command -v rsync >/dev/null 2>&1; then + rsync -a --delete "$src_dir"/ "$dst_dir"/ + else + find "$dst_dir" -mindepth 1 -maxdepth 1 -exec rm -rf {} + + cp -R "$src_dir"/. "$dst_dir"/ + fi +} + +copy_to_package() { + local package_name="$1" + local android_dst_dir="$ROOT_DIR/$package_name/android/src/main/java/com/streamchatreactnative/shared" + local ios_dst_dir="$ROOT_DIR/$package_name/ios/shared" + + if [ -d "$SHARED_ANDROID_DIR" ]; then + sync_dir_contents "$SHARED_ANDROID_DIR" "$android_dst_dir" + else + echo "Skipping Android sync: missing $SHARED_ANDROID_DIR" + fi + + if [ -d "$SHARED_IOS_DIR" ]; then + sync_dir_contents "$SHARED_IOS_DIR" "$ios_dst_dir" + else + echo "Skipping iOS sync: missing $SHARED_IOS_DIR" + fi +} + +case "$TARGET" in + native-package) + copy_to_package "native-package" + ;; + expo-package) + copy_to_package "expo-package" + ;; + all) + copy_to_package "native-package" + copy_to_package "expo-package" + ;; + *) + echo "Unknown target: $TARGET" + echo "Expected one of: native-package, expo-package, all" + exit 1 + ;; +esac + +echo "Synchronized shared native directories for target: $TARGET" diff --git a/package/shared-native/android/StreamShimmerFrameLayout.kt b/package/shared-native/android/StreamShimmerFrameLayout.kt new file mode 100644 index 0000000000..a82dcafbbb --- /dev/null +++ b/package/shared-native/android/StreamShimmerFrameLayout.kt @@ -0,0 +1,248 @@ +package com.streamchatreactnative + +import android.animation.ValueAnimator +import android.content.Context +import android.graphics.Canvas +import android.graphics.Color +import android.graphics.LinearGradient +import android.graphics.Matrix +import android.graphics.Paint +import android.graphics.Shader +import android.util.AttributeSet +import android.view.View +import android.view.animation.LinearInterpolator +import android.widget.FrameLayout +import kotlin.math.roundToInt + +/** + * Native shimmer container used by `StreamShimmerView`. + * + * This view draws a base color plus a moving highlight strip directly on canvas and still behaves + * like a regular container for React children. The animation runs fully on the native side so it + * does not depend on JS-driven frame updates. It automatically stops animating when the view is + * detached or not visible, rebuilds its shader when size or colors change, and waits for valid + * dimensions before starting animation to avoid invalid draw/animation states. + */ +class StreamShimmerFrameLayout @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, +) : FrameLayout(context, attrs) { + private var baseColor: Int = DEFAULT_BASE_COLOR + private var gradientColor: Int = DEFAULT_GRADIENT_COLOR + private var enabled: Boolean = true + + private val basePaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { style = Paint.Style.FILL } + private val shimmerPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { + style = Paint.Style.FILL + isDither = true + } + private val shimmerMatrix = Matrix() + + private var shimmerShader: LinearGradient? = null + private var shimmerTranslateX: Float = 0f + private var animatedViewWidth: Float = 0f + private var animator: ValueAnimator? = null + + init { + setWillNotDraw(false) + } + + fun setBaseColor(color: Int) { + if (baseColor == color) return + baseColor = color + rebuildShimmerShader() + invalidate() + } + + fun setGradientColor(color: Int) { + if (gradientColor == color) return + gradientColor = color + rebuildShimmerShader() + invalidate() + } + + fun setShimmerEnabled(enabled: Boolean) { + if (this.enabled == enabled) return + this.enabled = enabled + updateAnimatorState() + invalidate() + } + + fun updateAnimatorState() { + // Centralized lifecycle gate for animation start/stop. This keeps shimmer off for detached or + // hidden views to avoid wasting UI-thread work in long lists. + if (shouldAnimateShimmer()) { + startShimmer() + } else { + stopShimmer() + } + } + + override fun onAttachedToWindow() { + super.onAttachedToWindow() + // Reattachment (including reparenting) should recheck visibility state and restart only if + // this instance is eligible to animate. + updateAnimatorState() + } + + override fun onDetachedFromWindow() { + // Detached views are not drawable; stop and clear animator so a future attach starts cleanly. + stopShimmer() + super.onDetachedFromWindow() + } + + override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) { + super.onSizeChanged(w, h, oldw, oldh) + rebuildShimmerShader() + updateAnimatorState() + } + + override fun onWindowVisibilityChanged(visibility: Int) { + super.onWindowVisibilityChanged(visibility) + updateAnimatorState() + } + + override fun onVisibilityChanged(changedView: View, visibility: Int) { + super.onVisibilityChanged(changedView, visibility) + if (changedView === this) { + updateAnimatorState() + } + } + + override fun dispatchDraw(canvas: Canvas) { + val viewWidth = width.toFloat() + val viewHeight = height.toFloat() + if (viewWidth <= 0f || viewHeight <= 0f) { + super.dispatchDraw(canvas) + return + } + + basePaint.color = baseColor + canvas.drawRect(0f, 0f, viewWidth, viewHeight, basePaint) + + drawShimmer(canvas, viewWidth, viewHeight) + super.dispatchDraw(canvas) + } + + private fun drawShimmer(canvas: Canvas, viewWidth: Float, viewHeight: Float) { + if (!enabled) return + + val shader = shimmerShader ?: return + + shimmerMatrix.setTranslate(shimmerTranslateX, 0f) + shader.setLocalMatrix(shimmerMatrix) + shimmerPaint.shader = shader + canvas.drawRect(0f, 0f, viewWidth, viewHeight, shimmerPaint) + shimmerPaint.shader = null + } + + private fun rebuildShimmerShader() { + // Recreates the shimmer gradient for the current width/colors. This allocates shader state, + // so keep calls tied to real changes (size or color updates), not per frame execution. + val viewWidth = width.toFloat() + if (viewWidth <= 0f) { + shimmerShader = null + return + } + + // Wide multi-stop strip creates a softer "glassy" sweep and avoids the hard thin-line look. + val shimmerWidth = (viewWidth * SHIMMER_STRIP_WIDTH_RATIO).coerceAtLeast(1f) + val transparentHighlight = colorWithAlpha(gradientColor, 0f) + val edgeBase = colorWithAlpha(gradientColor, EDGE_HIGHLIGHT_ALPHA_FACTOR) + val softBase = colorWithAlpha(gradientColor, SOFT_HIGHLIGHT_ALPHA_FACTOR) + val mediumBase = colorWithAlpha(gradientColor, MID_HIGHLIGHT_ALPHA_FACTOR) + val innerBase = colorWithAlpha(gradientColor, INNER_HIGHLIGHT_ALPHA_FACTOR) + shimmerShader = LinearGradient( + 0f, + 0f, + shimmerWidth, + 0f, + intArrayOf( + transparentHighlight, + edgeBase, + softBase, + mediumBase, + innerBase, + gradientColor, + innerBase, + mediumBase, + softBase, + edgeBase, + transparentHighlight, + ), + floatArrayOf( + 0f, + 0.08f, + 0.2f, + 0.32f, + 0.4f, + 0.5f, + 0.6f, + 0.68f, + 0.8f, + 0.92f, + 1f, + ), + Shader.TileMode.CLAMP, + ) + } + + private fun startShimmer() { + val viewWidth = width.toFloat() + if (viewWidth <= 0f) return + // Keep the existing animator if the same-sized shimmer is already active. + if (animator != null && animatedViewWidth == viewWidth) return + + stopShimmer() + + // Animate from fully offscreen left to fully offscreen right so the strip enters/exits cleanly. + val shimmerWidth = (viewWidth * SHIMMER_STRIP_WIDTH_RATIO).coerceAtLeast(1f) + animatedViewWidth = viewWidth + animator = ValueAnimator.ofFloat(-shimmerWidth, viewWidth).apply { + duration = SHIMMER_DURATION_MS + repeatCount = ValueAnimator.INFINITE + interpolator = LinearInterpolator() + addUpdateListener { + shimmerTranslateX = it.animatedValue as Float + invalidate() + } + start() + } + } + + private fun stopShimmer() { + animator?.cancel() + animator = null + animatedViewWidth = 0f + } + + private fun shouldAnimateShimmer(): Boolean { + // `isShown` and explicit visibility/window checks cover different hide paths in nested + // hierarchies. Keeping them all prevents animations running when not visible to the user. + return enabled && + isAttachedToWindow && + width > 0 && + height > 0 && + visibility == View.VISIBLE && + windowVisibility == View.VISIBLE && + isShown && + alpha > 0f + } + + private fun colorWithAlpha(color: Int, alphaFactor: Float): Int { + // Preserve RGB while shaping only alpha; used for symmetric highlight falloff in gradient stops. + val alpha = (Color.alpha(color) * alphaFactor).roundToInt().coerceIn(0, 255) + return Color.argb(alpha, Color.red(color), Color.green(color), Color.blue(color)) + } + + companion object { + private const val DEFAULT_BASE_COLOR = 0x00FFFFFF + private const val DEFAULT_GRADIENT_COLOR = 0x59FFFFFF + private const val SHIMMER_DURATION_MS = 1200L + private const val SHIMMER_STRIP_WIDTH_RATIO = 1.25f + private const val EDGE_HIGHLIGHT_ALPHA_FACTOR = 0.1f + private const val SOFT_HIGHLIGHT_ALPHA_FACTOR = 0.24f + private const val MID_HIGHLIGHT_ALPHA_FACTOR = 0.48f + private const val INNER_HIGHLIGHT_ALPHA_FACTOR = 0.72f + } +} diff --git a/package/shared-native/android/StreamShimmerViewManager.kt b/package/shared-native/android/StreamShimmerViewManager.kt new file mode 100644 index 0000000000..3110055853 --- /dev/null +++ b/package/shared-native/android/StreamShimmerViewManager.kt @@ -0,0 +1,81 @@ +package com.streamchatreactnative + +import androidx.annotation.NonNull +import com.facebook.react.uimanager.ViewManagerDelegate +import com.facebook.react.uimanager.ThemedReactContext +import com.facebook.react.uimanager.ViewGroupManager +import com.facebook.react.viewmanagers.StreamShimmerViewManagerDelegate +import com.facebook.react.viewmanagers.StreamShimmerViewManagerInterface + +/** + * Fabric manager for StreamShimmerView. + * + * It creates the native shimmer layout, maps React props to native setters, and exposes child + * management methods so Fabric can mount and unmount children correctly inside this container. + * The manager rechecks animation state after prop transactions and disables shimmer when a view + * instance is dropped as a defensive cleanup step for recycled or unmounted views. Because the + * shimmer view wraps React children, this must remain a real ViewGroupManager as using a non-group + * manager can fail in Fabric mounting paths at runtime. + */ +class StreamShimmerViewManager : ViewGroupManager(), + StreamShimmerViewManagerInterface { + private val delegate = StreamShimmerViewManagerDelegate(this) + + override fun getName(): String = REACT_CLASS + + @NonNull + override fun createViewInstance(@NonNull reactContext: ThemedReactContext): StreamShimmerFrameLayout { + val layout = StreamShimmerFrameLayout(reactContext) + layout.updateAnimatorState() + return layout + } + + override fun onAfterUpdateTransaction(@NonNull view: StreamShimmerFrameLayout) { + super.onAfterUpdateTransaction(view) + // Prop batches can change visibility/enabled/colors together, so we re-evaluate the animator once + // after every transaction to keep state consistent and avoid duplicate start/stop churn. + view.updateAnimatorState() + } + + override fun addView(parent: StreamShimmerFrameLayout, child: android.view.View, index: Int) { + parent.addView(child, index) + } + + override fun getChildAt(parent: StreamShimmerFrameLayout, index: Int): android.view.View { + return parent.getChildAt(index) + } + + override fun getChildCount(parent: StreamShimmerFrameLayout): Int { + return parent.childCount + } + + override fun removeViewAt(parent: StreamShimmerFrameLayout, index: Int) { + parent.removeViewAt(index) + } + + override fun getDelegate(): ViewManagerDelegate = delegate + + override fun setEnabled(view: StreamShimmerFrameLayout, enabled: Boolean) { + view.setShimmerEnabled(enabled) + } + + override fun setBaseColor(view: StreamShimmerFrameLayout, color: Int?) { + view.setBaseColor(color ?: DEFAULT_BASE_COLOR) + } + + override fun setGradientColor(view: StreamShimmerFrameLayout, color: Int?) { + view.setGradientColor(color ?: DEFAULT_GRADIENT_COLOR) + } + + override fun onDropViewInstance(@NonNull view: StreamShimmerFrameLayout) { + super.onDropViewInstance(view) + // Defensive shutdown for recycled/unmounted views; avoids animator leaks in list-heavy screens. + view.setShimmerEnabled(false) + } + + companion object { + const val REACT_CLASS = "StreamShimmerView" + private const val DEFAULT_BASE_COLOR = 0x00FFFFFF + private const val DEFAULT_GRADIENT_COLOR = 0x59FFFFFF + } +} diff --git a/package/shared-native/ios/StreamShimmerView.swift b/package/shared-native/ios/StreamShimmerView.swift new file mode 100644 index 0000000000..f77946d9a9 --- /dev/null +++ b/package/shared-native/ios/StreamShimmerView.swift @@ -0,0 +1,235 @@ +import QuartzCore +import UIKit + +/// Native shimmer view used by the Fabric component view. +/// +/// It renders a base layer and a moving gradient highlight entirely in native code, so shimmer +/// animation stays off the JS thread. The view updates its gradient when size or colors change and +/// stops animation when it is not drawable (backgrounded, detached, hidden, or zero sized). +@objcMembers +public final class StreamShimmerView: UIView { + private static let edgeHighlightAlpha: CGFloat = 0.1 + private static let softHighlightAlpha: CGFloat = 0.24 + private static let midHighlightAlpha: CGFloat = 0.48 + private static let innerHighlightAlpha: CGFloat = 0.72 + private static let defaultHighlightAlpha: CGFloat = 0.35 + private static let shimmerStripWidthRatio: CGFloat = 1.25 + private static let shimmerDuration: CFTimeInterval = 1.2 + private static let shimmerAnimationKey = "stream_shimmer_translate_x" + + private let baseLayer = CALayer() + private let shimmerLayer = CAGradientLayer() + + private var baseColor: UIColor = UIColor(white: 1, alpha: 0) + private var gradientColor: UIColor = UIColor(white: 1, alpha: defaultHighlightAlpha) + private var enabled = false + private var lastAnimatedSize: CGSize = .zero + private var isAppActive = true + + public override init(frame: CGRect) { + super.init(frame: frame) + setupLayers() + setupLifecycleObservers() + } + + public required init?(coder: NSCoder) { + super.init(coder: coder) + setupLayers() + setupLifecycleObservers() + } + + deinit { + NotificationCenter.default.removeObserver(self) + } + + public override func layoutSubviews() { + super.layoutSubviews() + updateLayersForCurrentState() + } + + public override func didMoveToWindow() { + super.didMoveToWindow() + if window == nil { + // Detaching from window means this view is no longer drawable. Stop and clear animation so + // a later reattach starts from a clean state. + stopAnimation() + } else { + // Reattaching (including reparenting across windows) re-evaluates state and restarts only + // when needed by current bounds/visibility/enablement. + updateLayersForCurrentState() + } + } + + public override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { + super.traitCollectionDidChange(previousTraitCollection) + if let previousTraitCollection, + traitCollection.hasDifferentColorAppearance(comparedTo: previousTraitCollection) + { + // In current usage, colors are typically driven by JS props. We still refresh on trait + // changes so dynamically resolved native colors remain correct if that path is used later. + updateLayersForCurrentState() + } + } + + public func apply( + baseColor: UIColor, + gradientColor: UIColor, + enabled: Bool + ) { + self.baseColor = baseColor + self.gradientColor = gradientColor + self.enabled = enabled + updateLayersForCurrentState() + } + + public func stopAnimation() { + shimmerLayer.removeAnimation(forKey: Self.shimmerAnimationKey) + lastAnimatedSize = .zero + } + + private func setupLayers() { + isUserInteractionEnabled = false + + shimmerLayer.contentsScale = UIScreen.main.scale + shimmerLayer.allowsEdgeAntialiasing = true + shimmerLayer.startPoint = CGPoint(x: 0, y: 0.5) + shimmerLayer.endPoint = CGPoint(x: 1, y: 0.5) + shimmerLayer.locations = [0.0, 0.08, 0.2, 0.32, 0.4, 0.5, 0.6, 0.68, 0.8, 0.92, 1.0] + + layer.addSublayer(baseLayer) + layer.addSublayer(shimmerLayer) + } + + private func setupLifecycleObservers() { + NotificationCenter.default.addObserver( + self, + selector: #selector(handleWillEnterForeground), + name: UIApplication.willEnterForegroundNotification, + object: nil + ) + NotificationCenter.default.addObserver( + self, + selector: #selector(handleDidEnterBackground), + name: UIApplication.didEnterBackgroundNotification, + object: nil + ) + } + + @objc + private func handleWillEnterForeground() { + // iOS can drop active layer animations while the app is backgrounded. We explicitly rerun + // a state update on foreground so shimmer reliably restarts when returning to the app. + isAppActive = true + updateLayersForCurrentState() + } + + @objc + private func handleDidEnterBackground() { + isAppActive = false + stopAnimation() + } + + private func updateLayersForCurrentState() { + let bounds = self.bounds + guard !bounds.isEmpty else { + stopAnimation() + return + } + + baseLayer.frame = bounds + baseLayer.backgroundColor = baseColor.cgColor + + updateShimmerLayer(for: bounds) + updateShimmerAnimation(for: bounds) + } + + private func updateShimmerLayer(for bounds: CGRect) { + // Rebuild the shimmer gradient for current width/colors. Keep this tied to real state changes + // such as layout/prop updates, not continuous per frame calls. + let shimmerWidth = max(bounds.width * Self.shimmerStripWidthRatio, 1) + let transparentHighlight = color(gradientColor, alphaFactor: 0) + shimmerLayer.frame = CGRect(x: -shimmerWidth, y: 0, width: shimmerWidth, height: bounds.height) + shimmerLayer.colors = [ + transparentHighlight.cgColor, + color(gradientColor, alphaFactor: Self.edgeHighlightAlpha).cgColor, + color(gradientColor, alphaFactor: Self.softHighlightAlpha).cgColor, + color(gradientColor, alphaFactor: Self.midHighlightAlpha).cgColor, + color(gradientColor, alphaFactor: Self.innerHighlightAlpha).cgColor, + gradientColor.cgColor, + color(gradientColor, alphaFactor: Self.innerHighlightAlpha).cgColor, + color(gradientColor, alphaFactor: Self.midHighlightAlpha).cgColor, + color(gradientColor, alphaFactor: Self.softHighlightAlpha).cgColor, + color(gradientColor, alphaFactor: Self.edgeHighlightAlpha).cgColor, + transparentHighlight.cgColor, + ] + shimmerLayer.isHidden = !enabled + } + + private func updateShimmerAnimation(for bounds: CGRect) { + guard enabled, isAppActive, window != nil, bounds.width > 0, bounds.height > 0 else { + stopAnimation() + return + } + + // If an animation already exists for the same size, keep it running instead of restarting. + if shimmerLayer.animation(forKey: Self.shimmerAnimationKey) != nil, lastAnimatedSize == bounds.size { + return + } + + stopAnimation() + + // Start just outside the left edge and sweep fully past the right edge for a clean pass. + let shimmerWidth = max(bounds.width * Self.shimmerStripWidthRatio, 1) + let animation = CABasicAnimation(keyPath: "transform.translation.x") + animation.fromValue = 0 + animation.toValue = bounds.width + shimmerWidth + animation.duration = Self.shimmerDuration + animation.repeatCount = .infinity + animation.timingFunction = CAMediaTimingFunction(name: .linear) + animation.isRemovedOnCompletion = true + shimmerLayer.add(animation, forKey: Self.shimmerAnimationKey) + lastAnimatedSize = bounds.size + } + + private func color(_ color: UIColor, alphaFactor: CGFloat) -> UIColor { + // Preserve the resolved color channels and shape only alpha for smooth highlight falloff. + let resolvedColor = color.resolvedColor(with: traitCollection) + + var red: CGFloat = 0 + var green: CGFloat = 0 + var blue: CGFloat = 0 + var alpha: CGFloat = 0 + + if resolvedColor.getRed(&red, green: &green, blue: &blue, alpha: &alpha) { + return UIColor(red: red, green: green, blue: blue, alpha: alpha * alphaFactor) + } + + guard + let converted = resolvedColor.cgColor.converted( + to: CGColorSpace(name: CGColorSpace.extendedSRGB)!, + intent: .defaultIntent, + options: nil + ), + let components = converted.components + else { + return resolvedColor.withAlphaComponent(resolvedColor.cgColor.alpha * alphaFactor) + } + + switch components.count { + case 2: + return UIColor( + white: components[0], + alpha: components[1] * alphaFactor + ) + case 4: + return UIColor( + red: components[0], + green: components[1], + blue: components[2], + alpha: components[3] * alphaFactor + ) + default: + return resolvedColor.withAlphaComponent(resolvedColor.cgColor.alpha * alphaFactor) + } + } +} diff --git a/package/shared-native/ios/StreamShimmerViewComponentView.h b/package/shared-native/ios/StreamShimmerViewComponentView.h new file mode 100644 index 0000000000..5fd2863c5c --- /dev/null +++ b/package/shared-native/ios/StreamShimmerViewComponentView.h @@ -0,0 +1,22 @@ +#import + +#ifdef RCT_NEW_ARCH_ENABLED +#import +#endif + +#ifndef StreamShimmerViewComponentView_h +#define StreamShimmerViewComponentView_h + +NS_ASSUME_NONNULL_BEGIN + +@interface StreamShimmerViewComponentView : +#ifdef RCT_NEW_ARCH_ENABLED + RCTViewComponentView +#else + UIView +#endif +@end + +NS_ASSUME_NONNULL_END + +#endif /* StreamShimmerViewComponentView_h */ diff --git a/package/shared-native/ios/StreamShimmerViewComponentView.mm b/package/shared-native/ios/StreamShimmerViewComponentView.mm new file mode 100644 index 0000000000..c6161d18a8 --- /dev/null +++ b/package/shared-native/ios/StreamShimmerViewComponentView.mm @@ -0,0 +1,107 @@ +#import "StreamShimmerViewComponentView.h" + +#ifdef RCT_NEW_ARCH_ENABLED + +#if __has_include() +#import +#import +#import +#elif __has_include() +#import +#import +#import +#else +#error "Unable to find generated codegen headers for StreamShimmerView." +#endif + +#if __has_include() +#import +#elif __has_include() +#import +#elif __has_include("stream_chat_react_native-Swift.h") +#import "stream_chat_react_native-Swift.h" +#elif __has_include("stream_chat_expo-Swift.h") +#import "stream_chat_expo-Swift.h" +#else +#error "Unable to import generated Swift header for StreamShimmerView." +#endif + +#import + +using namespace facebook::react; + +@interface StreamShimmerViewComponentView () +@end + +// Fabric bridge for StreamShimmerView. This component view owns the native shimmer instance, +// applies codegen props, and keeps shimmer rendered as a background layer while Fabric manages +// React children. Keeping shimmer as a layer avoids child-order conflicts during mount/unmount. +@implementation StreamShimmerViewComponentView { + StreamShimmerView *_shimmerView; +} + ++ (ComponentDescriptorProvider)componentDescriptorProvider +{ + return concreteComponentDescriptorProvider(); +} + +- (instancetype)initWithFrame:(CGRect)frame +{ + if (self = [super initWithFrame:frame]) { + static const auto defaultProps = std::make_shared(); + _props = defaultProps; + + _shimmerView = [[StreamShimmerView alloc] initWithFrame:self.bounds]; + _shimmerView.userInteractionEnabled = NO; + [self.layer insertSublayer:_shimmerView.layer atIndex:0]; + } + + return self; +} + +- (void)layoutSubviews +{ + [super layoutSubviews]; + _shimmerView.frame = self.bounds; + + // Keep shimmer pinned as the layer furthest back. Some layer operations can reorder sublayers, and + // this guard restores expected layering without touching Fabric managed child views. + BOOL needsReinsert = _shimmerView.layer.superlayer != self.layer; + if (!needsReinsert) { + CALayer *firstLayer = self.layer.sublayers.firstObject; + needsReinsert = firstLayer != _shimmerView.layer; + } + if (needsReinsert) { + [self.layer insertSublayer:_shimmerView.layer atIndex:0]; + } +} + +- (void)updateProps:(Props::Shared const &)props oldProps:(Props::Shared const &)oldProps +{ + const auto &newProps = *std::static_pointer_cast(props); + + UIColor *baseColor = RCTUIColorFromSharedColor(newProps.baseColor) ?: [UIColor colorWithWhite:1 alpha:0]; + UIColor *gradientColor = RCTUIColorFromSharedColor(newProps.gradientColor) ?: [UIColor whiteColor]; + + [_shimmerView applyWithBaseColor:baseColor + gradientColor:gradientColor + enabled:newProps.enabled]; + + [super updateProps:props oldProps:oldProps]; +} + +- (void)prepareForRecycle +{ + [super prepareForRecycle]; + // Defensive cleanup for recycled cells/views so offscreen instances do not keep animating. + [_shimmerView stopAnimation]; +} + +- (void)dealloc +{ + [_shimmerView stopAnimation]; +} + +@end + +#endif diff --git a/package/src/components/Attachment/Gallery.tsx b/package/src/components/Attachment/Gallery.tsx index 41c4731020..e5c1b75e30 100644 --- a/package/src/components/Attachment/Gallery.tsx +++ b/package/src/components/Attachment/Gallery.tsx @@ -1,5 +1,5 @@ import React, { useMemo } from 'react'; -import { Pressable, StyleSheet, Text, View } from 'react-native'; +import { ImageErrorEvent, Pressable, StyleSheet, Text, View } from 'react-native'; import type { Attachment, LocalMessage } from 'stream-chat'; @@ -35,6 +35,7 @@ import { import { useTheme } from '../../contexts/themeContext/ThemeContext'; import { useLoadingImage } from '../../hooks/useLoadingImage'; +import { useStableCallback } from '../../hooks/useStableCallback'; import { isVideoPlayerAvailable } from '../../native'; import { primitives } from '../../theme'; import { FileTypes } from '../../types/types'; @@ -59,7 +60,6 @@ export type GalleryPropsWithContext = Pick & Pick & { @@ -74,7 +74,6 @@ const GalleryWithContext = (props: GalleryPropsWithContext) => { imageGalleryStateStore, ImageLoadingFailedIndicator, ImageLoadingIndicator, - ImageReloadIndicator, images, message, onLongPress, @@ -148,8 +147,8 @@ const GalleryWithContext = (props: GalleryPropsWithContext) => { images.length !== 1 ? { width: gridWidth, height: gridHeight } : { - height, - width, + minHeight: height, + minWidth: width, }, galleryContainer, ]} @@ -194,7 +193,6 @@ const GalleryWithContext = (props: GalleryPropsWithContext) => { imageGalleryStateStore={imageGalleryStateStore} ImageLoadingFailedIndicator={ImageLoadingFailedIndicator} ImageLoadingIndicator={ImageLoadingIndicator} - ImageReloadIndicator={ImageReloadIndicator} imagesAndVideos={imagesAndVideos} invertedDirections={invertedDirections || false} key={rowIndex} @@ -235,7 +233,6 @@ type GalleryThumbnailProps = { | 'VideoThumbnail' | 'ImageLoadingIndicator' | 'ImageLoadingFailedIndicator' - | 'ImageReloadIndicator' > & Pick & Pick & @@ -248,7 +245,6 @@ const GalleryThumbnail = ({ imageGalleryStateStore, ImageLoadingFailedIndicator, ImageLoadingIndicator, - ImageReloadIndicator, imagesAndVideos, invertedDirections, message, @@ -352,7 +348,6 @@ const GalleryThumbnail = ({ borderRadius={imageBorderRadius ?? borderRadius} ImageLoadingFailedIndicator={ImageLoadingFailedIndicator} ImageLoadingIndicator={ImageLoadingIndicator} - ImageReloadIndicator={ImageReloadIndicator} thumbnail={thumbnail} /> )} @@ -379,15 +374,10 @@ const GalleryImageThumbnail = ({ borderRadius, ImageLoadingFailedIndicator, ImageLoadingIndicator, - ImageReloadIndicator, thumbnail, }: Pick< GalleryThumbnailProps, - | 'ImageLoadingFailedIndicator' - | 'ImageLoadingIndicator' - | 'ImageReloadIndicator' - | 'thumbnail' - | 'borderRadius' + 'ImageLoadingFailedIndicator' | 'ImageLoadingIndicator' | 'thumbnail' | 'borderRadius' >) => { const { isLoadingImage, @@ -405,35 +395,39 @@ const GalleryImageThumbnail = ({ const styles = useStyles(); + const onLoadStart = useStableCallback(() => { + setLoadingImageError(false); + setLoadingImage(true); + }); + + const onLoad = useStableCallback(() => { + setTimeout(() => { + setLoadingImage(false); + setLoadingImageError(false); + }, 0); + }); + + const onError = useStableCallback(({ nativeEvent: { error } }: ImageErrorEvent) => { + console.warn(error); + setLoadingImage(false); + setLoadingImageError(true); + }); + return ( - + {isLoadingImageError ? ( - <> - - - + ) : ( <> { - console.warn(error); - setLoadingImage(false); - setLoadingImageError(true); - }} - onLoadEnd={() => setTimeout(() => setLoadingImage(false), 0)} - onLoadStart={() => setLoadingImage(true)} + onError={onError} + onLoad={onLoad} + onLoadStart={onLoadStart} resizeMode={thumbnail.resizeMode} - style={[borderRadius, gallery.image]} + style={gallery.image} uri={thumbnail.url} /> - {isLoadingImage && ( - - - - )} + {isLoadingImage ? : null} )} @@ -512,7 +506,6 @@ export const Gallery = (props: GalleryProps) => { additionalPressableProps: propAdditionalPressableProps, ImageLoadingFailedIndicator: PropImageLoadingFailedIndicator, ImageLoadingIndicator: PropImageLoadingIndicator, - ImageReloadIndicator: PropImageReloadIndicator, images: propImages, message: propMessage, myMessageTheme: propMyMessageTheme, @@ -542,7 +535,6 @@ export const Gallery = (props: GalleryProps) => { additionalPressableProps: contextAdditionalPressableProps, ImageLoadingFailedIndicator: ContextImageLoadingFailedIndicator, ImageLoadingIndicator: ContextImageLoadingIndicator, - ImageReloadIndicator: ContextImageReloadIndicator, myMessageTheme: contextMyMessageTheme, VideoThumbnail: ContextVideoThumnbnail, } = useMessagesContext(); @@ -567,7 +559,6 @@ export const Gallery = (props: GalleryProps) => { const ImageLoadingFailedIndicator = PropImageLoadingFailedIndicator || ContextImageLoadingFailedIndicator; const ImageLoadingIndicator = PropImageLoadingIndicator || ContextImageLoadingIndicator; - const ImageReloadIndicator = PropImageReloadIndicator || ContextImageReloadIndicator; const myMessageTheme = propMyMessageTheme || contextMyMessageTheme; const messageContentOrder = propMessageContentOrder || contextMessageContentOrder; @@ -585,7 +576,6 @@ export const Gallery = (props: GalleryProps) => { imageGalleryStateStore, ImageLoadingFailedIndicator, ImageLoadingIndicator, - ImageReloadIndicator, images, message, myMessageTheme, @@ -607,6 +597,7 @@ const useStyles = () => { const { theme: { semantics }, } = useTheme(); + const { isMyMessage } = useMessageContext(); return useMemo(() => { return StyleSheet.create({ errorTextSize: { @@ -626,11 +617,15 @@ const useStyles = () => { imageContainer: {}, image: { flex: 1, + backgroundColor: isMyMessage + ? semantics.chatBgAttachmentOutgoing + : semantics.chatBgAttachmentIncoming, + overflow: 'hidden', }, imageLoadingErrorIndicatorStyle: { - bottom: 4, - left: 4, - position: 'absolute', + ...StyleSheet.absoluteFillObject, + alignItems: 'center', + justifyContent: 'center', }, imageLoadingIndicatorContainer: { height: '100%', @@ -658,8 +653,17 @@ const useStyles = () => { lineHeight: primitives.typographyLineHeightRelaxed, fontWeight: primitives.typographyFontWeightSemiBold, }, + imageLoadingErrorContainer: { + ...StyleSheet.absoluteFillObject, + alignItems: 'center', + justifyContent: 'center', + }, + imageLoadingErrorWrapper: { + ...StyleSheet.absoluteFillObject, + overflow: 'hidden', + }, }); - }, [semantics]); + }, [semantics, isMyMessage]); }; Gallery.displayName = 'Gallery{messageSimple{gallery}}'; diff --git a/package/src/components/Attachment/Giphy/GiphyImage.tsx b/package/src/components/Attachment/Giphy/GiphyImage.tsx index d3f4dc551a..f9c016283f 100644 --- a/package/src/components/Attachment/Giphy/GiphyImage.tsx +++ b/package/src/components/Attachment/Giphy/GiphyImage.tsx @@ -39,8 +39,13 @@ const GiphyImageWithContext = (props: GiphyImagePropsWithContext) => { const { giphy: giphyData, image_url, thumb_url, type } = attachment; - const { isLoadingImage, isLoadingImageError, setLoadingImage, setLoadingImageError } = - useLoadingImage(); + const { + isLoadingImage, + isLoadingImageError, + setLoadingImage, + setLoadingImageError, + onReloadImage, + } = useLoadingImage(); const { theme: { @@ -93,7 +98,7 @@ const GiphyImageWithContext = (props: GiphyImagePropsWithContext) => { /> {isLoadingImageError && ( - + )} {isLoadingImage && ( diff --git a/package/src/components/Attachment/ImageLoadingFailedIndicator.tsx b/package/src/components/Attachment/ImageLoadingFailedIndicator.tsx index 2f135835e8..aef29c352d 100644 --- a/package/src/components/Attachment/ImageLoadingFailedIndicator.tsx +++ b/package/src/components/Attachment/ImageLoadingFailedIndicator.tsx @@ -1,56 +1,45 @@ -import React from 'react'; -import { StyleSheet, Text, View, ViewProps } from 'react-native'; +import React, { useMemo } from 'react'; +import { Pressable, StyleSheet, ViewProps } from 'react-native'; import { useTheme } from '../../contexts/themeContext/ThemeContext'; -import { useTranslationContext } from '../../contexts/translationContext/TranslationContext'; - -import { Warning } from '../../icons/Warning'; - -const WARNING_ICON_SIZE = 14; +import { RetryBadge } from '../ui/Badge/RetryBadge'; + +export type ImageLoadingFailedIndicatorProps = ViewProps & { + /** + * Callback to reload the image + * @returns Callback to reload the image + */ + onReloadImage: () => void; +}; -const styles = StyleSheet.create({ - container: { - alignContent: 'center', - alignItems: 'center', - borderRadius: 20, - flexDirection: 'row', - justifyContent: 'center', - }, - errorText: { - fontSize: 8, - justifyContent: 'center', - paddingHorizontal: 8, - }, - warningIconStyle: { - borderRadius: 24, - marginLeft: 4, - marginTop: 6, - }, -}); +export const ImageLoadingFailedIndicator = ({ + onReloadImage, +}: ImageLoadingFailedIndicatorProps) => { + const styles = useStyles(); -export type ImageLoadingFailedIndicatorProps = ViewProps; + return ( + + + + ); +}; -export const ImageLoadingFailedIndicator = (props: ImageLoadingFailedIndicatorProps) => { +const useStyles = () => { const { - theme: { - colors: { accent_red, overlay, white }, - }, + theme: { semantics }, } = useTheme(); - - const { t } = useTranslationContext(); - - const { style, ...rest } = props; - return ( - - - - {t('Error loading')} - - - ); + return useMemo(() => { + return StyleSheet.create({ + imageLoadingErrorIndicatorStyle: { + ...StyleSheet.absoluteFillObject, + alignItems: 'center', + justifyContent: 'center', + backgroundColor: semantics.backgroundCoreOverlayLight, + }, + }); + }, [semantics]); }; diff --git a/package/src/components/Attachment/ImageLoadingIndicator.tsx b/package/src/components/Attachment/ImageLoadingIndicator.tsx index 019aa420d4..7bb2534f1d 100644 --- a/package/src/components/Attachment/ImageLoadingIndicator.tsx +++ b/package/src/components/Attachment/ImageLoadingIndicator.tsx @@ -1,31 +1,32 @@ import React from 'react'; -import { ActivityIndicator, StyleSheet, View, ViewProps } from 'react-native'; +import { ActivityIndicator, StyleSheet, View } from 'react-native'; -import { useTheme } from '../../contexts/themeContext/ThemeContext'; +import { useTheme } from '../../contexts'; +import { NativeShimmerView } from '../UIComponents/NativeShimmerView'; -const styles = StyleSheet.create({ - container: { - alignItems: 'center', - display: 'flex', - justifyContent: 'center', - width: '100%', - }, -}); - -export type ImageLoadingIndicatorProps = ViewProps; - -export const ImageLoadingIndicator = (props: ImageLoadingIndicatorProps) => { +export const ImageLoadingIndicator = () => { const { - theme: { - messageSimple: { - loadingIndicator: { container }, - }, - }, + theme: { semantics }, } = useTheme(); - const { style, ...rest } = props; return ( - - - + + + + + ); }; + +const styles = StyleSheet.create({ + centered: { + alignItems: 'center', + flex: 1, + justifyContent: 'center', + }, +}); diff --git a/package/src/components/Attachment/ImageReloadIndicator.tsx b/package/src/components/Attachment/ImageReloadIndicator.tsx deleted file mode 100644 index 9f3b2e24a0..0000000000 --- a/package/src/components/Attachment/ImageReloadIndicator.tsx +++ /dev/null @@ -1,26 +0,0 @@ -import React from 'react'; -import { Pressable } from 'react-native'; - -import { useTheme } from '../../contexts/themeContext/ThemeContext'; -import { Refresh } from '../../icons'; - -const REFRESH_ICON_SIZE = 24; - -export type ImageReloadIndicatorProps = { - onReloadImage: () => void; - style: React.ComponentProps['style']; -}; - -export const ImageReloadIndicator = ({ onReloadImage, style }: ImageReloadIndicatorProps) => { - const { - theme: { - colors: { grey_dark }, - }, - } = useTheme(); - - return ( - - - - ); -}; diff --git a/package/src/components/Attachment/__tests__/Gallery.test.js b/package/src/components/Attachment/__tests__/Gallery.test.js index 57a06a0f3c..23a2ccdade 100644 --- a/package/src/components/Attachment/__tests__/Gallery.test.js +++ b/package/src/components/Attachment/__tests__/Gallery.test.js @@ -1,12 +1,6 @@ import React from 'react'; -import { - fireEvent, - render, - screen, - waitFor, - waitForElementToBeRemoved, -} from '@testing-library/react-native'; +import { fireEvent, render, screen, waitFor } from '@testing-library/react-native'; import { OverlayProvider } from '../../../contexts/overlayContext/OverlayProvider'; @@ -26,6 +20,15 @@ import { Chat } from '../../Chat/Chat'; import { MessageList } from '../../MessageList/MessageList'; describe('Gallery', () => { + beforeEach(() => { + jest.useFakeTimers(); + }); + + afterEach(() => { + jest.runOnlyPendingTimers(); + jest.useRealTimers(); + }); + const user1 = generateUser(); const getComponent = async (attachments = []) => { @@ -272,7 +275,7 @@ describe('Gallery', () => { fireEvent(screen.getByLabelText('Gallery Image'), 'error', { nativeEvent: { error: 'error loading image' }, }); - expect(screen.getByAccessibilityHint('image-loading-error')).toBeTruthy(); + expect(screen.getByLabelText('Image Loading Error Indicator')).toBeTruthy(); }); it('should render a loading indicator and when successful render the image', async () => { @@ -288,11 +291,15 @@ describe('Gallery', () => { expect(screen.queryAllByTestId('gallery-container').length).toBe(1); }); - fireEvent(screen.getByLabelText('Gallery Image'), 'onLoadStart'); - expect(screen.getByAccessibilityHint('image-loading')).toBeTruthy(); + fireEvent(screen.getByLabelText('Gallery Image'), 'loadStart'); + await waitFor(() => { + expect(screen.getByLabelText('Image Loading Indicator')).toBeTruthy(); + }); - fireEvent(screen.getByLabelText('Gallery Image'), 'onLoadFinish'); - waitForElementToBeRemoved(() => screen.getByAccessibilityHint('image-loading')); + fireEvent(screen.getByLabelText('Gallery Image'), 'onLoad'); + await waitFor(() => { + expect(screen.queryByLabelText('Image Loading Indicator')).toBeNull(); + }); expect(screen.getByLabelText('Gallery Image')).toBeTruthy(); - }); + }, 20000); }); diff --git a/package/src/components/Attachment/__tests__/Giphy.test.js b/package/src/components/Attachment/__tests__/Giphy.test.js index 2aed7e6385..94d3085926 100644 --- a/package/src/components/Attachment/__tests__/Giphy.test.js +++ b/package/src/components/Attachment/__tests__/Giphy.test.js @@ -8,7 +8,6 @@ import { screen, userEvent, waitFor, - waitForElementToBeRemoved, } from '@testing-library/react-native'; import { MessageProvider } from '../../../contexts/messageContext/MessageContext'; @@ -321,7 +320,7 @@ describe('Giphy', () => { }); await waitFor(() => { - expect(screen.getByAccessibilityHint('image-loading-error')).toBeTruthy(); + expect(screen.getByLabelText('Image Loading Error Indicator')).toBeTruthy(); }); }); @@ -335,23 +334,21 @@ describe('Giphy', () => { , ); - await waitFor(() => { - expect(screen.getByAccessibilityHint('image-loading')).toBeTruthy(); - }); - act(() => { - fireEvent(screen.getByLabelText('Giphy Attachment Image'), 'onLoadStart'); + fireEvent(screen.getByLabelText('Giphy Attachment Image'), 'loadStart'); }); await waitFor(() => { - expect(screen.getByAccessibilityHint('image-loading')).toBeTruthy(); + expect(screen.getByLabelText('Image Loading Indicator')).toBeTruthy(); }); act(() => { - fireEvent(screen.getByLabelText('Giphy Attachment Image'), 'onLoad'); + fireEvent(screen.getByLabelText('Giphy Attachment Image'), 'loadEnd'); }); - waitForElementToBeRemoved(() => screen.getByAccessibilityHint('image-loading')); + await waitFor(() => { + expect(screen.queryByLabelText('Image Loading Indicator')).toBeNull(); + }); await waitFor(() => { expect(screen.getByLabelText('Giphy Attachment Image')).toBeTruthy(); diff --git a/package/src/components/Channel/Channel.tsx b/package/src/components/Channel/Channel.tsx index 45091a78aa..03a2c3a0b9 100644 --- a/package/src/components/Channel/Channel.tsx +++ b/package/src/components/Channel/Channel.tsx @@ -119,7 +119,6 @@ import { Gallery as GalleryDefault } from '../Attachment/Gallery'; import { Giphy as GiphyDefault } from '../Attachment/Giphy'; import { ImageLoadingFailedIndicator as ImageLoadingFailedIndicatorDefault } from '../Attachment/ImageLoadingFailedIndicator'; import { ImageLoadingIndicator as ImageLoadingIndicatorDefault } from '../Attachment/ImageLoadingIndicator'; -import { ImageReloadIndicator as ImageReloadIndicatorDefault } from '../Attachment/ImageReloadIndicator'; import { URLPreview as URLPreviewDefault } from '../Attachment/UrlPreview'; import { VideoThumbnail as VideoThumbnailDefault } from '../Attachment/VideoThumbnail'; import { AttachmentPicker } from '../AttachmentPicker/AttachmentPicker'; @@ -351,7 +350,6 @@ export type ChannelPropsWithContext = Pick & | 'isAttachmentEqual' | 'ImageLoadingFailedIndicator' | 'ImageLoadingIndicator' - | 'ImageReloadIndicator' | 'markdownRules' | 'Message' | 'MessageActionList' @@ -647,7 +645,6 @@ const ChannelWithContext = (props: PropsWithChildren) = ImageAttachmentUploadPreview = ImageAttachmentUploadPreviewDefault, ImageLoadingFailedIndicator = ImageLoadingFailedIndicatorDefault, ImageLoadingIndicator = ImageLoadingIndicatorDefault, - ImageReloadIndicator = ImageReloadIndicatorDefault, initialScrollToFirstUnreadMessage = false, InlineDateSeparator = InlineDateSeparatorDefault, InlineUnreadIndicator = InlineUnreadIndicatorDefault, @@ -1932,7 +1929,6 @@ const ChannelWithContext = (props: PropsWithChildren) = hasCreatePoll === undefined ? pollCreationEnabled : hasCreatePoll && pollCreationEnabled, ImageLoadingFailedIndicator, ImageLoadingIndicator, - ImageReloadIndicator, initialScrollToFirstUnreadMessage: !messageId && initialScrollToFirstUnreadMessage, // when messageId is set, we scroll to the messageId instead of first unread InlineDateSeparator, InlineUnreadIndicator, diff --git a/package/src/components/Channel/hooks/useCreateMessagesContext.ts b/package/src/components/Channel/hooks/useCreateMessagesContext.ts index 3db83d4d65..178ce6a077 100644 --- a/package/src/components/Channel/hooks/useCreateMessagesContext.ts +++ b/package/src/components/Channel/hooks/useCreateMessagesContext.ts @@ -42,7 +42,6 @@ export const useCreateMessagesContext = ({ hasCreatePoll, ImageLoadingFailedIndicator, ImageLoadingIndicator, - ImageReloadIndicator, initialScrollToFirstUnreadMessage, InlineDateSeparator, InlineUnreadIndicator, @@ -159,7 +158,6 @@ export const useCreateMessagesContext = ({ hasCreatePoll, ImageLoadingFailedIndicator, ImageLoadingIndicator, - ImageReloadIndicator, initialScrollToFirstUnreadMessage, InlineDateSeparator, InlineUnreadIndicator, diff --git a/package/src/components/ChannelList/ChannelList.tsx b/package/src/components/ChannelList/ChannelList.tsx index e9570aafd0..f2a91b0cfb 100644 --- a/package/src/components/ChannelList/ChannelList.tsx +++ b/package/src/components/ChannelList/ChannelList.tsx @@ -28,7 +28,7 @@ import { import { useChatContext } from '../../contexts/chatContext/ChatContext'; import { SwipeRegistryProvider } from '../../contexts/swipeableContext/SwipeRegistryContext'; import type { ChannelListEventListenerOptions } from '../../types/types'; -import { ChannelPreview } from '../ChannelPreview/ChannelPreview'; +import { ChannelPreviewMessenger } from '../ChannelPreview/ChannelPreviewMessenger'; import { EmptyStateIndicator as EmptyStateIndicatorDefault } from '../Indicators/EmptyStateIndicator'; import { LoadingErrorIndicator as LoadingErrorIndicatorDefault } from '../Indicators/LoadingErrorIndicator'; @@ -279,7 +279,7 @@ export const ChannelList = (props: ChannelListProps) => { onRemovedFromChannel, onSelect, options = DEFAULT_OPTIONS, - Preview = ChannelPreview, + Preview = ChannelPreviewMessenger, getChannelActionItems, PreviewAvatar, PreviewMessage, diff --git a/package/src/components/Message/MessageSimple/MessageContent.tsx b/package/src/components/Message/MessageSimple/MessageContent.tsx index caf378e4cf..cbd6fa1569 100644 --- a/package/src/components/Message/MessageSimple/MessageContent.tsx +++ b/package/src/components/Message/MessageSimple/MessageContent.tsx @@ -169,7 +169,6 @@ const MessageContentWithContext = (props: MessageContentPropsWithContext) => { messageGroupedSingleOrBottomContainer, messageGroupedTopContainer, replyContainer, - textWrapper, wrapper, }, }, @@ -363,11 +362,9 @@ const MessageContentWithContext = (props: MessageContentPropsWithContext) => { } })} - - {(otherAttachments.length && otherAttachments[0].actions) || isAIGenerated ? null : ( - - )} - + {(otherAttachments.length && otherAttachments[0].actions) || isAIGenerated ? null : ( + + )} @@ -665,7 +662,5 @@ const styles = StyleSheet.create({ rightAlignItems: { alignItems: 'flex-end', }, - textWrapper: { - paddingHorizontal: primitives.spacingSm, - }, + textWrapper: {}, }); diff --git a/package/src/components/Message/MessageSimple/MessageTextContainer.tsx b/package/src/components/Message/MessageSimple/MessageTextContainer.tsx index e3af2215f1..9af8b5747d 100644 --- a/package/src/components/Message/MessageSimple/MessageTextContainer.tsx +++ b/package/src/components/Message/MessageSimple/MessageTextContainer.tsx @@ -17,9 +17,10 @@ import { useTheme } from '../../../contexts/themeContext/ThemeContext'; import type { MarkdownStyle, Theme } from '../../../contexts/themeContext/utils/theme'; import { useTranslatedMessage } from '../../../hooks/useTranslatedMessage'; +import { primitives } from '../../../theme'; const styles = StyleSheet.create({ - textContainer: { maxWidth: 256 }, + textContainer: { maxWidth: 256, paddingHorizontal: primitives.spacingSm }, }); export type MessageTextProps = MessageTextContainerProps & { diff --git a/package/src/components/Message/MessageSimple/__tests__/__snapshots__/MessageTextContainer.test.tsx.snap b/package/src/components/Message/MessageSimple/__tests__/__snapshots__/MessageTextContainer.test.tsx.snap index 95f08ee3e1..451f0b4af2 100644 --- a/package/src/components/Message/MessageSimple/__tests__/__snapshots__/MessageTextContainer.test.tsx.snap +++ b/package/src/components/Message/MessageSimple/__tests__/__snapshots__/MessageTextContainer.test.tsx.snap @@ -6,6 +6,7 @@ exports[`MessageTextContainer should render message text container 1`] = ` [ { "maxWidth": 256, + "paddingHorizontal": 12, }, {}, undefined, diff --git a/package/src/components/MessageInput/components/AttachmentPreview/AttachmentUploadProgressIndicator.tsx b/package/src/components/MessageInput/components/AttachmentPreview/AttachmentUploadProgressIndicator.tsx index 919ee1c1aa..7ea9b848fe 100644 --- a/package/src/components/MessageInput/components/AttachmentPreview/AttachmentUploadProgressIndicator.tsx +++ b/package/src/components/MessageInput/components/AttachmentPreview/AttachmentUploadProgressIndicator.tsx @@ -5,9 +5,9 @@ import { LocalAttachmentUploadMetadata } from 'stream-chat'; import { useTheme } from '../../../../contexts/themeContext/ThemeContext'; import { ExclamationCircle } from '../../../../icons/ExclamationCircle'; -import { RotateCircle } from '../../../../icons/RotateCircle'; import { Warning } from '../../../../icons/Warning'; import { primitives } from '../../../../theme'; +import { RetryBadge } from '../../../ui/Badge/RetryBadge'; export const FileUploadInProgressIndicator = () => { const { @@ -128,20 +128,9 @@ export type ImageUploadRetryIndicatorProps = { }; export const ImageUploadRetryIndicator = ({ onRetryHandler }: ImageUploadRetryIndicatorProps) => { - const styles = useImageUploadRetryIndicatorStyles(); - const { - theme: { - semantics, - messageInput: { imageUploadRetryIndicator }, - }, - } = useTheme(); return ( - - + + ); }; @@ -174,25 +163,6 @@ const useImageUploadInProgressIndicatorStyles = () => { }); }; -const useImageUploadRetryIndicatorStyles = () => { - const { - theme: { semantics }, - } = useTheme(); - return StyleSheet.create({ - container: { - backgroundColor: semantics.accentError, - alignItems: 'center', - justifyContent: 'center', - borderRadius: primitives.radiusMax, - borderWidth: 2, - borderColor: semantics.textOnAccent, - alignSelf: 'center', - width: 32, - height: 32, - }, - }); -}; - const useImageUploadNotSupportedIndicatorStyles = () => { const { theme: { semantics }, diff --git a/package/src/components/Thread/__tests__/__snapshots__/Thread.test.js.snap b/package/src/components/Thread/__tests__/__snapshots__/Thread.test.js.snap index be650c07d5..debfc46e97 100644 --- a/package/src/components/Thread/__tests__/__snapshots__/Thread.test.js.snap +++ b/package/src/components/Thread/__tests__/__snapshots__/Thread.test.js.snap @@ -559,61 +559,51 @@ exports[`Thread should match thread snapshot 1`] = ` style={ [ { + "maxWidth": 256, "paddingHorizontal": 12, }, {}, + undefined, ] } + testID="message-text-container" > - - - Message6 - + Message6 - + @@ -899,61 +889,51 @@ exports[`Thread should match thread snapshot 1`] = ` style={ [ { + "maxWidth": 256, "paddingHorizontal": 12, }, {}, + undefined, ] } + testID="message-text-container" > - - - Message5 - + Message5 - + @@ -1272,61 +1252,51 @@ exports[`Thread should match thread snapshot 1`] = ` style={ [ { + "maxWidth": 256, "paddingHorizontal": 12, }, {}, + undefined, ] } + testID="message-text-container" > - - - Message4 - + Message4 - + @@ -1603,61 +1573,51 @@ exports[`Thread should match thread snapshot 1`] = ` style={ [ { + "maxWidth": 256, "paddingHorizontal": 12, }, {}, + undefined, ] } + testID="message-text-container" > - - - Message3 - + Message3 - + diff --git a/package/src/components/UIComponents/NativeShimmerView.tsx b/package/src/components/UIComponents/NativeShimmerView.tsx new file mode 100644 index 0000000000..ea3d56431e --- /dev/null +++ b/package/src/components/UIComponents/NativeShimmerView.tsx @@ -0,0 +1,20 @@ +import React from 'react'; +import { ColorValue, View, ViewProps } from 'react-native'; + +import { NativeHandlers } from '../../native'; + +export type NativeShimmerViewProps = ViewProps & { + baseColor?: ColorValue; + enabled?: boolean; + gradientColor?: ColorValue; +}; + +export const NativeShimmerView = (props: NativeShimmerViewProps) => { + const NativeShimmerComponent = NativeHandlers.NativeShimmerView; + if (NativeShimmerComponent) { + return ; + } + + const { children, ...rest } = props; + return {children}; +}; diff --git a/package/src/components/index.ts b/package/src/components/index.ts index 9c0c493935..b5402f95a1 100644 --- a/package/src/components/index.ts +++ b/package/src/components/index.ts @@ -7,7 +7,6 @@ export * from './Attachment/FileIcon'; export * from './Attachment/Gallery'; export * from './Attachment/Giphy'; export * from './Attachment/VideoThumbnail'; -export * from './Attachment/ImageReloadIndicator'; export * from './Attachment/UrlPreview'; export * from './Attachment/utils/buildGallery/buildGallery'; diff --git a/package/src/components/ui/Badge/RetryBadge.tsx b/package/src/components/ui/Badge/RetryBadge.tsx new file mode 100644 index 0000000000..2b6876bca3 --- /dev/null +++ b/package/src/components/ui/Badge/RetryBadge.tsx @@ -0,0 +1,76 @@ +import React, { useMemo } from 'react'; +import { StyleProp, StyleSheet, View, ViewProps, ViewStyle } from 'react-native'; + +import { useTheme } from '../../../contexts/themeContext/ThemeContext'; +import { RotateCircle } from '../../../icons/RotateCircle'; +import { primitives } from '../../../theme'; + +const sizes = { + lg: { + height: 32, + width: 32, + }, + md: { + height: 24, + width: 24, + }, +}; + +const iconSizes = { + lg: { + height: 16, + width: 16, + }, + md: { + height: 12, + width: 12, + }, +}; + +export type RetryBadgeProps = ViewProps & { + /** + * The size of the badge + * @default 'md' + * @type {'lg' | 'md'} + */ + size: 'lg' | 'md'; + /** + * The style of the badge + * @default undefined + * @type {StyleProp} + */ + style?: StyleProp; +}; + +export const RetryBadge = (props: RetryBadgeProps) => { + const { size = 'md', style, ...rest } = props; + const { + theme: { semantics }, + } = useTheme(); + const styles = useStyles(); + return ( + + + + ); +}; + +const useStyles = () => { + const { + theme: { semantics }, + } = useTheme(); + return useMemo(() => { + return StyleSheet.create({ + container: { + alignItems: 'center', + justifyContent: 'center', + borderRadius: primitives.radiusMax, + backgroundColor: semantics.badgeBgError, + }, + }); + }, [semantics]); +}; diff --git a/package/src/contexts/messagesContext/MessagesContext.tsx b/package/src/contexts/messagesContext/MessagesContext.tsx index 52910b4088..53adab5444 100644 --- a/package/src/contexts/messagesContext/MessagesContext.tsx +++ b/package/src/contexts/messagesContext/MessagesContext.tsx @@ -25,8 +25,6 @@ import type { FileIconProps } from '../../components/Attachment/FileIcon'; import type { GalleryProps } from '../../components/Attachment/Gallery'; import type { GiphyProps } from '../../components/Attachment/Giphy'; import type { ImageLoadingFailedIndicatorProps } from '../../components/Attachment/ImageLoadingFailedIndicator'; -import type { ImageLoadingIndicatorProps } from '../../components/Attachment/ImageLoadingIndicator'; -import { ImageReloadIndicatorProps } from '../../components/Attachment/ImageReloadIndicator'; import type { URLPreviewProps } from '../../components/Attachment/UrlPreview'; import type { VideoThumbnailProps } from '../../components/Attachment/VideoThumbnail'; import type { @@ -162,15 +160,10 @@ export type MessagesContextValue = Pick; - /** - * The indicator rendered at the center of an image whenever its loading fails, used to trigger retries. - */ - ImageReloadIndicator: React.ComponentType; - /** * The indicator rendered when image is loading. By default renders */ - ImageLoadingIndicator: React.ComponentType; + ImageLoadingIndicator: React.ComponentType; /** * When true, messageList will be scrolled at first unread message, when opened. diff --git a/package/src/contexts/themeContext/utils/theme.ts b/package/src/contexts/themeContext/utils/theme.ts index a5b2e9a131..171694ef11 100644 --- a/package/src/contexts/themeContext/utils/theme.ts +++ b/package/src/contexts/themeContext/utils/theme.ts @@ -594,7 +594,6 @@ export type Theme = { container: ViewStyle; containerInner: ViewStyle; contentContainer: ViewStyle; - textWrapper: ViewStyle; editedTimestampContainer: ViewStyle; errorContainer: ViewStyle; errorIcon: IconProps; @@ -1482,7 +1481,6 @@ export const defaultTheme: Theme = { container: {}, containerInner: {}, contentContainer: {}, - textWrapper: {}, editedTimestampContainer: {}, errorContainer: { paddingRight: 12, diff --git a/package/src/hooks/useLoadingImage.tsx b/package/src/hooks/useLoadingImage.tsx index 98b8bdda75..bb7d44022e 100644 --- a/package/src/hooks/useLoadingImage.tsx +++ b/package/src/hooks/useLoadingImage.tsx @@ -15,14 +15,23 @@ type Action = function reducer(prevState: ImageState, action: Action) { switch (action.type) { case 'reloadImage': + if (prevState.isLoadingImage && !prevState.isLoadingImageError) { + return prevState; + } return { ...prevState, isLoadingImage: true, isLoadingImageError: false, }; case 'setLoadingImage': + if (prevState.isLoadingImage === action.isLoadingImage) { + return prevState; + } return { ...prevState, isLoadingImage: action.isLoadingImage }; case 'setLoadingImageError': + if (prevState.isLoadingImageError === action.isLoadingImageError) { + return prevState; + } return { ...prevState, isLoadingImageError: action.isLoadingImageError }; default: return prevState; @@ -30,7 +39,7 @@ function reducer(prevState: ImageState, action: Action) { } export const useLoadingImage = () => { const [imageState, dispatch] = useReducer(reducer, { - isLoadingImage: true, + isLoadingImage: false, isLoadingImageError: false, }); const { isLoadingImage, isLoadingImageError } = imageState; diff --git a/package/src/native.ts b/package/src/native.ts index c05e7f43de..ec60c149ef 100644 --- a/package/src/native.ts +++ b/package/src/native.ts @@ -1,5 +1,11 @@ import type React from 'react'; -import { FlatList as DefaultFlatList, StyleProp, ViewStyle } from 'react-native'; +import { + ColorValue, + FlatList as DefaultFlatList, + StyleProp, + ViewProps, + ViewStyle, +} from 'react-native'; import type { File } from './types/types'; const fail = () => { @@ -287,6 +293,12 @@ export type VideoType = { rate?: number; }; +export type NativeShimmerViewProps = ViewProps & { + baseColor?: ColorValue; + enabled?: boolean; + gradientColor?: ColorValue; +}; + type Handlers = { Audio?: AudioType; compressImage?: CompressImage; @@ -306,12 +318,16 @@ type Handlers = { setClipboardString?: SetClipboardString; shareImage?: ShareImage; Sound?: SoundType; + NativeShimmerView?: React.ComponentType; takePhoto?: TakePhoto; triggerHaptic?: TriggerHaptic; Video?: React.ComponentType; }; -export const NativeHandlers: Pick & +export const NativeHandlers: Pick< + Handlers, + 'Audio' | 'FlatList' | 'NativeShimmerView' | 'Video' | 'Sound' +> & Required< Pick< Handlers, @@ -346,6 +362,7 @@ export const NativeHandlers: Pick { NativeHandlers.Sound = handlers.Sound; } + if (handlers.NativeShimmerView !== undefined) { + NativeHandlers.NativeShimmerView = handlers.NativeShimmerView; + } + if (handlers.takePhoto !== undefined) { NativeHandlers.takePhoto = handlers.takePhoto; }