diff --git a/.github/actions/build-ios/action.yml b/.github/actions/build-ios/action.yml index ff9983a9ca4..153ba71c6d3 100644 --- a/.github/actions/build-ios/action.yml +++ b/.github/actions/build-ios/action.yml @@ -53,7 +53,7 @@ runs: - name: Set up Xcode uses: maxim-lobanov/setup-xcode@v1 with: - xcode-version: '16.4' + xcode-version: '26.2.0' - name: Set up Ruby uses: ruby/setup-ruby@v1 diff --git a/.github/actions/upload-ios/action.yml b/.github/actions/upload-ios/action.yml index 4deefc781a2..b7324541194 100644 --- a/.github/actions/upload-ios/action.yml +++ b/.github/actions/upload-ios/action.yml @@ -91,7 +91,7 @@ runs: - name: Set up Xcode uses: maxim-lobanov/setup-xcode@v1 with: - xcode-version: '16.2' + xcode-version: '26.2.0' - name: Set up Ruby uses: ruby/setup-ruby@v1 diff --git a/.github/workflows/build-ios.yml b/.github/workflows/build-ios.yml index 40c8bc5702c..7cc9c4b2624 100644 --- a/.github/workflows/build-ios.yml +++ b/.github/workflows/build-ios.yml @@ -22,7 +22,7 @@ jobs: build-ios: name: Build - runs-on: macos-15 + runs-on: macos-26 needs: [build-hold] if: ${{ inputs.type == 'experimental' && (always() && (needs.build-hold.result == 'success' || needs.build-hold.result == 'skipped')) }} steps: @@ -62,7 +62,7 @@ jobs: upload-ios: name: Upload - runs-on: macos-15 + runs-on: macos-26 needs: [build-ios] if: ${{ inputs.type == 'experimental' && (always() && (needs.build-ios.result == 'success')) }} steps: diff --git a/.github/workflows/build-official-ios.yml b/.github/workflows/build-official-ios.yml index 75d76015a1b..bf8f31df792 100644 --- a/.github/workflows/build-official-ios.yml +++ b/.github/workflows/build-official-ios.yml @@ -22,7 +22,7 @@ jobs: build-ios: name: Build - runs-on: macos-15 + runs-on: macos-26 needs: [build-hold] if: ${{ inputs.type == 'official' && (always() && (needs.build-hold.result == 'success' || needs.build-hold.result == 'skipped')) }} steps: @@ -72,7 +72,7 @@ jobs: upload-ios: name: Upload - runs-on: macos-15 + runs-on: macos-26 needs: [upload-hold] if: ${{ inputs.type == 'official' && (always() && (needs.upload-hold.result == 'success' || needs.upload-hold.result == 'skipped')) }} steps: diff --git a/.github/workflows/e2e-build-ios.yml b/.github/workflows/e2e-build-ios.yml index 0228a9d310e..1952085df2a 100644 --- a/.github/workflows/e2e-build-ios.yml +++ b/.github/workflows/e2e-build-ios.yml @@ -22,7 +22,7 @@ on: jobs: ios-build: - runs-on: macos-15 + runs-on: macos-26 steps: - name: Checkout repository @@ -34,7 +34,7 @@ jobs: - name: Set up Xcode uses: maxim-lobanov/setup-xcode@v1 with: - xcode-version: '16.4' + xcode-version: '26.2.0' - name: Set up Ruby uses: ruby/setup-ruby@v1 diff --git a/.github/workflows/prettier.yml b/.github/workflows/prettier.yml index 3e5a93039c8..c49c60afe60 100644 --- a/.github/workflows/prettier.yml +++ b/.github/workflows/prettier.yml @@ -46,5 +46,5 @@ jobs: git config user.name "${{ github.actor }}" git config user.email "${{ github.actor }}@users.noreply.github.com" git add . - git commit -m "chore: format code and fix lint issues [skip ci]" + git commit -m "chore: format code and fix lint issues" git push origin ${{ github.ref_name }} \ No newline at end of file diff --git a/.maestro/helpers/login-with-deeplink.yaml b/.maestro/helpers/login-with-deeplink.yaml index 114974a59e5..d9b1ee77ae5 100644 --- a/.maestro/helpers/login-with-deeplink.yaml +++ b/.maestro/helpers/login-with-deeplink.yaml @@ -10,6 +10,9 @@ tags: true: CLEAR_STATE commands: - clearState: chat.rocket.reactnative +- setPermissions: + permissions: + all: allow - evalScript: ${output.login = output.utils.login(USERNAME, PASSWORD)} - runFlow: file: 'open-deeplink.yaml' @@ -24,24 +27,6 @@ tags: visible: '.*Pixel Launcher.*' commands: - tapOn: 'Close App' - - extendedWaitUntil: - visible: - text: '.*Allow.*' - timeout: 30000 - optional: true - - tapOn: - text: '.*Allow.*' - optional: true - - assertNotVisible: - text: '.*Allow.*' - optional: true -- runFlow: - when: - visible: '.*Would like to send you notifications.*' - platform: iOS - commands: - - tapOn: - point: 65%,60% - extendedWaitUntil: visible: id: 'rooms-list-view' diff --git a/.maestro/tests/room/ignoreuser.yaml b/.maestro/tests/room/ignoreuser.yaml index 9eaff66e266..f545cead62f 100644 --- a/.maestro/tests/room/ignoreuser.yaml +++ b/.maestro/tests/room/ignoreuser.yaml @@ -89,7 +89,8 @@ tags: id: 'username-header-${output.otherUser.username}' timeout: 60000 - tapOn: - id: 'username-header-${output.otherUser.username}' + text: ${output.otherUser.username} + index: 1 retryTapIfNoChange: true - extendedWaitUntil: visible: @@ -210,7 +211,9 @@ tags: visible: text: ${output.otherUser.username} timeout: 60000 -- tapOn: ${output.otherUser.username} +- tapOn: + text: ${output.otherUser.username} + index: 1 - extendedWaitUntil: visible: id: 'room-info-view' diff --git a/.maestro/tests/room/search-member.yaml b/.maestro/tests/room/search-member.yaml new file mode 100644 index 00000000000..6d6bf982b37 --- /dev/null +++ b/.maestro/tests/room/search-member.yaml @@ -0,0 +1,91 @@ +appId: chat.rocket.reactnative +name: Search Member +onFlowStart: + - runFlow: '../../helpers/setup.yaml' +tags: + - test-13 + +--- +- evalScript: ${output.user = output.utils.createUser()} + +- runFlow: + file: '../../helpers/login-with-deeplink.yaml' + env: + USERNAME: ${output.user.username} + PASSWORD: ${output.user.password} + +- runFlow: + file: '../../helpers/navigate-to-room.yaml' + env: + ROOM: 'general' +- tapOn: + id: room-header +- extendedWaitUntil: + visible: + id: 'room-actions-view' + timeout: 60000 +- tapOn: + id: 'room-actions-members' +- extendedWaitUntil: + visible: + id: 'room-members-view-search' + timeout: 60000 + +# should search in all users +- tapOn: + id: room-members-view-search +- inputText: rohit.bansal +- extendedWaitUntil: + visible: + id: 'room-members-view-item-rohit.bansal' + timeout: 60000 + +# use online status and it should use the text filter +- tapOn: + id: room-members-view-filter +- extendedWaitUntil: + visible: + id: 'room-members-view-toggle-status-online' + timeout: 60000 +- tapOn: + id: room-members-view-toggle-status-online +- extendedWaitUntil: + visible: + text: 'No members found' + timeout: 60000 + +# use all status again and it should use text filter +- tapOn: + id: room-members-view-filter +- extendedWaitUntil: + visible: + id: 'room-members-view-toggle-status-all' + timeout: 60000 +- tapOn: + id: room-members-view-toggle-status-all +- extendedWaitUntil: + visible: + id: 'room-members-view-item-rohit.bansal' + timeout: 60000 +- tapOn: + id: clear-text-input + +- evalScript: ${output.secondUser = output.utils.createUser()} + +# should search for new user in all list +- tapOn: + id: room-members-view-search +- inputText: ${output.secondUser.username} +- extendedWaitUntil: + visible: + id: 'room-members-view-item-${output.secondUser.username}' + timeout: 60000 + +# Verify "No members found" message appears correctly when search returns no results +- tapOn: + id: room-members-view-search +- inputText: nonexistentuser12345 +- extendedWaitUntil: + visible: + text: 'No members found' + timeout: 60000 diff --git a/android/app/build.gradle b/android/app/build.gradle index f0f06675f4c..951a4c11312 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -90,7 +90,7 @@ android { minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion versionCode VERSIONCODE as Integer - versionName "4.69.0" + versionName "4.70.0" vectorDrawables.useSupportLibrary = true manifestPlaceholders = [BugsnagAPIKey: BugsnagAPIKey as String] resValue "string", "rn_config_reader_custom_package", "chat.rocket.reactnative" diff --git a/android/app/src/main/java/chat/rocket/reactnative/MainApplication.kt b/android/app/src/main/java/chat/rocket/reactnative/MainApplication.kt index 5ac2e25f483..811e72f97ce 100644 --- a/android/app/src/main/java/chat/rocket/reactnative/MainApplication.kt +++ b/android/app/src/main/java/chat/rocket/reactnative/MainApplication.kt @@ -20,6 +20,7 @@ import chat.rocket.reactnative.storage.MMKVKeyManager; import chat.rocket.reactnative.storage.SecureStoragePackage; import chat.rocket.reactnative.notification.VideoConfTurboPackage import chat.rocket.reactnative.notification.PushNotificationTurboPackage +import chat.rocket.reactnative.scroll.InvertedScrollPackage /** * Main Application class. @@ -44,6 +45,7 @@ open class MainApplication : Application(), ReactApplication { add(VideoConfTurboPackage()) add(PushNotificationTurboPackage()) add(SecureStoragePackage()) + add(InvertedScrollPackage()) } override fun getJSMainModuleName(): String = "index" diff --git a/android/app/src/main/java/chat/rocket/reactnative/notification/NotificationIntentHandler.kt b/android/app/src/main/java/chat/rocket/reactnative/notification/NotificationIntentHandler.kt index 322677141c5..3c51b834276 100644 --- a/android/app/src/main/java/chat/rocket/reactnative/notification/NotificationIntentHandler.kt +++ b/android/app/src/main/java/chat/rocket/reactnative/notification/NotificationIntentHandler.kt @@ -93,6 +93,20 @@ class NotificationIntentHandler { } try { + val notId = extras.getString("notId") + + // Clear the notification messages from the static map to prevent stacking + if (!notId.isNullOrEmpty()) { + try { + val notIdInt = notId.toIntOrNull() + if (notIdInt != null) { + CustomPushNotification.clearMessages(notIdInt) + } + } catch (e: Exception) { + Log.e(TAG, "Error clearing notification messages for ID $notId: ${e.message}", e) + } + } + // Extract all notification data from Intent extras // Only include serializable types to avoid JSON serialization errors val notificationData = mutableMapOf() diff --git a/android/app/src/main/java/chat/rocket/reactnative/scroll/InvertedScrollContentView.java b/android/app/src/main/java/chat/rocket/reactnative/scroll/InvertedScrollContentView.java new file mode 100644 index 00000000000..a4acb0c1e13 --- /dev/null +++ b/android/app/src/main/java/chat/rocket/reactnative/scroll/InvertedScrollContentView.java @@ -0,0 +1,23 @@ +package chat.rocket.reactnative.scroll; + +import android.view.View; +import com.facebook.react.views.view.ReactViewGroup; +import java.util.ArrayList; +import java.util.Collections; + +/** + * Content view for inverted FlatLists. Reports its children to accessibility in reversed order so + * TalkBack traversal matches the visual order (newest-first) when used inside InvertedScrollView. + */ +public class InvertedScrollContentView extends ReactViewGroup { + + public InvertedScrollContentView(android.content.Context context) { + super(context); + } + + @Override + public void addChildrenForAccessibility(ArrayList outChildren) { + super.addChildrenForAccessibility(outChildren); + Collections.reverse(outChildren); + } +} diff --git a/android/app/src/main/java/chat/rocket/reactnative/scroll/InvertedScrollContentViewManager.java b/android/app/src/main/java/chat/rocket/reactnative/scroll/InvertedScrollContentViewManager.java new file mode 100644 index 00000000000..d30f9fc84c2 --- /dev/null +++ b/android/app/src/main/java/chat/rocket/reactnative/scroll/InvertedScrollContentViewManager.java @@ -0,0 +1,25 @@ +package chat.rocket.reactnative.scroll; + +import com.facebook.react.module.annotations.ReactModule; +import com.facebook.react.uimanager.ThemedReactContext; +import com.facebook.react.views.view.ReactViewManager; + +/** + * View manager for InvertedScrollContentView. Behaves like a View but reports children in reversed + * order for accessibility so TalkBack matches the visual order in inverted lists. + */ +@ReactModule(name = InvertedScrollContentViewManager.REACT_CLASS) +public class InvertedScrollContentViewManager extends ReactViewManager { + + public static final String REACT_CLASS = "InvertedScrollContentView"; + + @Override + public String getName() { + return REACT_CLASS; + } + + @Override + public InvertedScrollContentView createViewInstance(ThemedReactContext context) { + return new InvertedScrollContentView(context); + } +} diff --git a/android/app/src/main/java/chat/rocket/reactnative/scroll/InvertedScrollPackage.java b/android/app/src/main/java/chat/rocket/reactnative/scroll/InvertedScrollPackage.java new file mode 100644 index 00000000000..05e6a7be0d5 --- /dev/null +++ b/android/app/src/main/java/chat/rocket/reactnative/scroll/InvertedScrollPackage.java @@ -0,0 +1,24 @@ +package chat.rocket.reactnative.scroll; + +import com.facebook.react.ReactPackage; +import com.facebook.react.bridge.NativeModule; +import com.facebook.react.bridge.ReactApplicationContext; +import com.facebook.react.uimanager.ViewManager; +import java.util.Collections; +import java.util.List; + +public class InvertedScrollPackage implements ReactPackage { + + @Override + public List createNativeModules(ReactApplicationContext reactContext) { + return Collections.emptyList(); + } + + @Override + public List createViewManagers(ReactApplicationContext reactContext) { + List managers = new java.util.ArrayList<>(); + managers.add(new InvertedScrollViewManager()); + managers.add(new InvertedScrollContentViewManager()); + return managers; + } +} diff --git a/android/app/src/main/java/chat/rocket/reactnative/scroll/InvertedScrollView.java b/android/app/src/main/java/chat/rocket/reactnative/scroll/InvertedScrollView.java new file mode 100644 index 00000000000..def585a7511 --- /dev/null +++ b/android/app/src/main/java/chat/rocket/reactnative/scroll/InvertedScrollView.java @@ -0,0 +1,36 @@ +package chat.rocket.reactnative.scroll; + +import android.view.View; +import com.facebook.react.bridge.ReactContext; +import com.facebook.react.views.scroll.ReactScrollView; +import java.util.ArrayList; +import java.util.Collections; + +// When a FlatList is inverted (inverted={true}), React Native uses scaleY: -1 transform which +// visually inverts the list but Android still reports children in array order. This view overrides +// addChildrenForAccessibility to reverse the order so TalkBack matches the visual order. + +public class InvertedScrollView extends ReactScrollView { + + private boolean mIsInvertedVirtualizedList = false; + + public InvertedScrollView(ReactContext context) { + super(context); + } + + + // Set whether this ScrollView is used for an inverted virtualized list. When true, we reverse the + // accessibility traversal order to match the visual order. + + public void setIsInvertedVirtualizedList(boolean isInverted) { + mIsInvertedVirtualizedList = isInverted; + } + + @Override + public void addChildrenForAccessibility(ArrayList outChildren) { + super.addChildrenForAccessibility(outChildren); + if (mIsInvertedVirtualizedList) { + Collections.reverse(outChildren); + } + } +} diff --git a/android/app/src/main/java/chat/rocket/reactnative/scroll/InvertedScrollViewManager.java b/android/app/src/main/java/chat/rocket/reactnative/scroll/InvertedScrollViewManager.java new file mode 100644 index 00000000000..453dd009ec0 --- /dev/null +++ b/android/app/src/main/java/chat/rocket/reactnative/scroll/InvertedScrollViewManager.java @@ -0,0 +1,26 @@ +package chat.rocket.reactnative.scroll; + +import com.facebook.react.module.annotations.ReactModule; +import com.facebook.react.uimanager.ThemedReactContext; +import com.facebook.react.views.scroll.ReactScrollViewManager; + +/** + * View manager for {@link InvertedScrollView}. Registers as "InvertedScrollView" to avoid + * collision with core RCTScrollView. Inherits all ScrollView props from ReactScrollViewManager; + * FlatList passes isInvertedVirtualizedList when inverted, which is applied by the parent setter. + */ +@ReactModule(name = InvertedScrollViewManager.REACT_CLASS) +public class InvertedScrollViewManager extends ReactScrollViewManager { + + public static final String REACT_CLASS = "InvertedScrollView"; + + @Override + public String getName() { + return REACT_CLASS; + } + + @Override + public InvertedScrollView createViewInstance(ThemedReactContext context) { + return new InvertedScrollView(context); + } +} diff --git a/app/containers/Chip/__snapshots__/Chip.test.tsx.snap b/app/containers/Chip/__snapshots__/Chip.test.tsx.snap index 58de1a6452d..e9fc7988de9 100644 --- a/app/containers/Chip/__snapshots__/Chip.test.tsx.snap +++ b/app/containers/Chip/__snapshots__/Chip.test.tsx.snap @@ -69,6 +69,7 @@ exports[`Story Snapshots: ChipFullWidth should match snapshot 1`] = ` style={ [ { + "flexShrink": 1, "marginRight": 8, "maxWidth": 110, }, @@ -227,6 +228,7 @@ exports[`Story Snapshots: ChipText should match snapshot 1`] = ` style={ [ { + "flexShrink": 1, "marginRight": 8, "maxWidth": 110, }, @@ -409,6 +411,7 @@ exports[`Story Snapshots: ChipWithShortText should match snapshot 1`] = ` style={ [ { + "flexShrink": 1, "marginRight": 8, "maxWidth": 110, }, @@ -536,6 +539,7 @@ exports[`Story Snapshots: ChipWithoutAvatar should match snapshot 1`] = ` style={ [ { + "flexShrink": 1, "marginRight": 8, "maxWidth": 110, }, @@ -663,6 +667,7 @@ exports[`Story Snapshots: ChipWithoutAvatarAndIcon should match snapshot 1`] = ` style={ [ { + "flexShrink": 1, "marginRight": 8, "maxWidth": 110, }, @@ -819,6 +824,7 @@ exports[`Story Snapshots: ChipWithoutIcon should match snapshot 1`] = ` style={ [ { + "flexShrink": 1, "marginRight": 8, "maxWidth": 110, }, diff --git a/app/containers/Chip/index.tsx b/app/containers/Chip/index.tsx index b45d61e94ae..42c58162f90 100644 --- a/app/containers/Chip/index.tsx +++ b/app/containers/Chip/index.tsx @@ -23,6 +23,7 @@ const styles = StyleSheet.create({ marginVertical: 8 }, textContainer: { + flexShrink: 1, marginRight: 8, maxWidth: 110 }, diff --git a/app/containers/RoomHeader/RoomHeader.tsx b/app/containers/RoomHeader/RoomHeader.tsx index bd2a95d89d5..35a44f5181f 100644 --- a/app/containers/RoomHeader/RoomHeader.tsx +++ b/app/containers/RoomHeader/RoomHeader.tsx @@ -75,7 +75,7 @@ interface IRoomHeader { usersTyping: IUsersTyping; isGroupChat?: boolean; parentTitle?: string; - onPress: Function; + onPress: () => void; testID?: string; sourceType?: IOmnichannelSource; disabled?: boolean; diff --git a/app/containers/RoomHeader/index.tsx b/app/containers/RoomHeader/index.tsx index 50a3df02f9d..e343bb2d704 100644 --- a/app/containers/RoomHeader/index.tsx +++ b/app/containers/RoomHeader/index.tsx @@ -14,7 +14,7 @@ interface IRoomHeaderContainerProps { tmid?: string; teamMain?: boolean; roomUserId?: string | null; - onPress: Function; + onPress: () => void; parentTitle?: string; isGroupChat?: boolean; testID?: string; diff --git a/app/containers/TwoFactor/styles.ts b/app/containers/TwoFactor/styles.ts index c1a401ad7b0..5cc0e84fb36 100644 --- a/app/containers/TwoFactor/styles.ts +++ b/app/containers/TwoFactor/styles.ts @@ -6,6 +6,7 @@ export default StyleSheet.create({ container: { flex: 1, justifyContent: 'center', + alignItems: 'center', padding: 16 }, content: { diff --git a/app/containers/UIKit/MessageBlock.tsx b/app/containers/UIKit/MessageBlock.tsx index 7ccc01db0b6..3039d580539 100644 --- a/app/containers/UIKit/MessageBlock.tsx +++ b/app/containers/UIKit/MessageBlock.tsx @@ -12,11 +12,10 @@ export const messageBlockWithContext = (context: any) => (props: any) => const MessageBlock = ({ blocks }: any) => UiKitMessage(blocks); -export const modalBlockWithContext = (context: any) => (data: any) => - ( - - - - ); +export const ModalBlockWithContext = (props: any) => ( + + + +); const ModalBlock = ({ blocks }: any) => UiKitModal(blocks); diff --git a/app/containers/UIKit/UiKitModal.stories.tsx b/app/containers/UIKit/UiKitModal.stories.tsx index 678ef999889..5f47ad19786 100644 --- a/app/containers/UIKit/UiKitModal.stories.tsx +++ b/app/containers/UIKit/UiKitModal.stories.tsx @@ -1,3 +1,6 @@ +import React, { useState } from 'react'; +import { Button, View } from 'react-native'; + import { UiKitComponent, UiKitModal } from '.'; import { KitContext, defaultContext } from './utils'; import MessageContext from '../message/Context'; @@ -759,3 +762,53 @@ export const ModalDatePickerWithError = () => ( ); ModalDatePickerWithError.storyName = 'Modal - DatePicker with error'; + +const initialInputBlocks = [ + { + type: 'input', + element: { type: 'plain_text_input', actionId: 'input-1' }, + label: { type: 'plain_text', text: 'First field', emoji: true }, + placeholder: { type: 'plain_text', text: 'Type here…', emoji: true } + }, + { + type: 'input', + element: { type: 'plain_text_input', actionId: 'input-2' }, + label: { type: 'plain_text', text: 'Second field', emoji: true }, + placeholder: { type: 'plain_text', text: 'Type here…', emoji: true } + } +]; + +export const ModalInputWithAddField = () => { + const [values, setValues] = useState>({}); + const [blocks, setBlocks] = useState(initialInputBlocks); + const changeState = ({ actionId, value, blockId = 'default' }: { actionId: string; value: string; blockId?: string }) => { + setValues(prev => ({ ...prev, [actionId]: { blockId, value } })); + }; + const addField = () => { + const nextId = `input-${blocks.length + 1}`; + setBlocks(prev => [ + ...prev, + { + type: 'input', + element: { type: 'plain_text_input', actionId: nextId }, + label: { type: 'plain_text', text: `Field ${blocks.length + 1}`, emoji: true }, + placeholder: { type: 'plain_text', text: 'Type here…', emoji: true } + } + ]); + }; + const modalKey = `${blocks.length}-${blocks + .map((b: any, index: number) => `${b.element?.actionId || b.type}-${index}`) + .join('-')}`; + + return ( + + + + + + +