diff --git a/.maestro/helpers/assert-room-title.yaml b/.maestro/helpers/assert-room-title.yaml new file mode 100644 index 00000000000..183c4e2556a --- /dev/null +++ b/.maestro/helpers/assert-room-title.yaml @@ -0,0 +1,23 @@ +appId: ${APP_ID} +name: Assert room title +tags: + - 'util' + +--- +- runFlow: + when: + platform: Android + commands: + - extendedWaitUntil: + visible: + id: 'room-view-title-${ROOM}' + timeout: 60000 + +- runFlow: + when: + platform: iOS + commands: + - extendedWaitUntil: + visible: + text: '${ROOM}' + timeout: 60000 diff --git a/.maestro/helpers/clear-search-field.yaml b/.maestro/helpers/clear-search-field.yaml new file mode 100644 index 00000000000..3ee551c0df0 --- /dev/null +++ b/.maestro/helpers/clear-search-field.yaml @@ -0,0 +1,18 @@ +appId: ${APP_ID} +name: Clear search field +tags: + - 'util' +--- +- runFlow: + when: + platform: iOS + commands: + - tapOn: + id: 'magnifyingglass' + - eraseText +- runFlow: + when: + platform: android + commands: + - tapOn: + id: clear-text-input diff --git a/.maestro/helpers/close-search.yaml b/.maestro/helpers/close-search.yaml new file mode 100644 index 00000000000..db6560964fd --- /dev/null +++ b/.maestro/helpers/close-search.yaml @@ -0,0 +1,13 @@ +appId: ${APP_ID} +name: Close search +tags: + - 'util' +--- +- runFlow: + when: + platform: iOS + visible: + text: 'Close' + commands: + - tapOn: + text: 'Close' diff --git a/.maestro/helpers/go-back.yaml b/.maestro/helpers/go-back.yaml index 667f678c475..81fd69def37 100644 --- a/.maestro/helpers/go-back.yaml +++ b/.maestro/helpers/go-back.yaml @@ -6,16 +6,15 @@ tags: --- - runFlow: when: - visible: - id: 'custom-header-back' + platform: Android commands: - tapOn: - id: 'custom-header-back' + id: '(custom-)?header-back' - runFlow: when: - visible: - id: 'header-back' + platform: iOS commands: - tapOn: - id: 'header-back' + text: 'Back(, .*)?' + retryTapIfNoChange: true diff --git a/.maestro/helpers/logout.yaml b/.maestro/helpers/logout.yaml index 70ddc19cfb7..b19b9eba4ee 100644 --- a/.maestro/helpers/logout.yaml +++ b/.maestro/helpers/logout.yaml @@ -14,12 +14,22 @@ tags: - runFlow: when: + platform: Android visible: id: 'rooms-list-view-sidebar' commands: - tapOn: id: 'rooms-list-view-sidebar' +- runFlow: + when: + platform: iOS + visible: + text: 'Menu' + commands: + - tapOn: + text: 'Menu' + - runFlow: when: platform: Android diff --git a/.maestro/helpers/navigate-to-recent-room.yaml b/.maestro/helpers/navigate-to-recent-room.yaml index fdfc1057707..5da07125d33 100644 --- a/.maestro/helpers/navigate-to-recent-room.yaml +++ b/.maestro/helpers/navigate-to-recent-room.yaml @@ -8,19 +8,23 @@ tags: visible: id: 'rooms-list-view' timeout: 60000 -- tapOn: - id: 'rooms-list-view-search' -- extendedWaitUntil: - visible: - id: 'rooms-list-view-search-input' - timeout: 60000 +- runFlow: + when: + platform: android + commands: + - tapOn: + id: 'rooms-list-view-search' + - extendedWaitUntil: + visible: + id: 'rooms-list-view-search-input' + timeout: 60000 - extendedWaitUntil: visible: id: 'rooms-list-view-item-${ROOM}' timeout: 60000 - tapOn: id: 'rooms-list-view-item-${ROOM}' -- extendedWaitUntil: - visible: - id: 'room-view-title-${ROOM}' - timeout: 60000 +- runFlow: + file: 'assert-room-title.yaml' + env: + ROOM: ${ROOM} diff --git a/.maestro/helpers/navigate-to-room-action.yaml b/.maestro/helpers/navigate-to-room-action.yaml index 08a0dfeb718..f9367ee7fd3 100644 --- a/.maestro/helpers/navigate-to-room-action.yaml +++ b/.maestro/helpers/navigate-to-room-action.yaml @@ -18,13 +18,5 @@ tags: visible: id: 'room-view' timeout: 60000 -- assertVisible: - id: 'room-header' - index: 0 -- tapOn: - id: 'room-header' - index: 0 -- extendedWaitUntil: - visible: - id: 'room-actions-view' - timeout: 60000 + +- runFlow: './open-room-actions.yaml' diff --git a/.maestro/helpers/navigate-to-room.yaml b/.maestro/helpers/navigate-to-room.yaml index e8eba0728b0..af619b5da61 100644 --- a/.maestro/helpers/navigate-to-room.yaml +++ b/.maestro/helpers/navigate-to-room.yaml @@ -4,6 +4,7 @@ tags: - 'util' --- +- runFlow: './pop-to-rooms-list.yaml' - waitForAnimationToEnd - scrollUntilVisible: element: @@ -11,7 +12,7 @@ tags: - waitForAnimationToEnd - tapOn: id: 'rooms-list-view-item-${ROOM}' -- extendedWaitUntil: - visible: - id: 'room-view-title-${ROOM}' - timeout: 60000 +- runFlow: + file: 'assert-room-title.yaml' + env: + ROOM: ${ROOM} diff --git a/.maestro/helpers/open-room-actions.yaml b/.maestro/helpers/open-room-actions.yaml new file mode 100644 index 00000000000..e351514fbfc --- /dev/null +++ b/.maestro/helpers/open-room-actions.yaml @@ -0,0 +1,18 @@ +appId: ${APP_ID} +name: Open room actions +tags: + - 'util' + +--- +- extendedWaitUntil: + visible: + id: 'room-header' + timeout: 60000 +- tapOn: + id: 'room-header' + index: 0 + retryTapIfNoChange: true +- extendedWaitUntil: + visible: + id: 'room-actions-view' + timeout: 60000 diff --git a/.maestro/helpers/open-servers-list.yaml b/.maestro/helpers/open-servers-list.yaml new file mode 100644 index 00000000000..d0fbb379500 --- /dev/null +++ b/.maestro/helpers/open-servers-list.yaml @@ -0,0 +1,12 @@ +appId: ${APP_ID} +name: Open servers list +tags: + - 'util' + +--- +- extendedWaitUntil: + visible: + id: 'rooms-list-header-servers-list-button' + timeout: 60000 +- tapOn: + id: 'rooms-list-header-servers-list-button' diff --git a/.maestro/helpers/pop-to-rooms-list.yaml b/.maestro/helpers/pop-to-rooms-list.yaml new file mode 100644 index 00000000000..1e8fb0abeb0 --- /dev/null +++ b/.maestro/helpers/pop-to-rooms-list.yaml @@ -0,0 +1,30 @@ +appId: ${APP_ID} +name: Pop to rooms list +tags: + - 'util' + +--- +- repeat: + times: 4 + while: + notVisible: + id: 'rooms-list-view' + commands: + - runFlow: + when: + platform: Android + visible: + id: '(custom-)?header-back' + commands: + - tapOn: + id: '(custom-)?header-back' + - runFlow: + when: + platform: iOS + visible: + text: 'Back(, .*)?' + commands: + - tapOn: + text: 'Back(, .*)?' + - waitForAnimationToEnd: + timeout: 5000 diff --git a/.maestro/helpers/search-room.yaml b/.maestro/helpers/search-room.yaml index 6314ba6f9af..17dfdc0668f 100644 --- a/.maestro/helpers/search-room.yaml +++ b/.maestro/helpers/search-room.yaml @@ -3,18 +3,31 @@ name: Search room tags: - 'util' --- +- runFlow: './pop-to-rooms-list.yaml' - extendedWaitUntil: visible: id: 'rooms-list-view' timeout: 60000 - waitForAnimationToEnd: timeout: 5000 -- tapOn: - id: 'rooms-list-view-search' -- tapOn: - id: 'rooms-list-view-search-input' -- inputText: - text: ${ROOM} +- runFlow: + when: + platform: iOS + commands: + - tapOn: + id: 'magnifyingglass' + - inputText: + text: ${ROOM} +- runFlow: + when: + platform: android + commands: + - tapOn: + id: 'rooms-list-view-search' + - tapOn: + id: 'rooms-list-view-search-input' + - inputText: + text: ${ROOM} - extendedWaitUntil: visible: id: 'rooms-list-view-item-${ROOM}' diff --git a/.maestro/helpers/select-header-filter.yaml b/.maestro/helpers/select-header-filter.yaml new file mode 100644 index 00000000000..7a577c1cd3d --- /dev/null +++ b/.maestro/helpers/select-header-filter.yaml @@ -0,0 +1,37 @@ +appId: ${APP_ID} +name: Select header filter +tags: + - 'util' +--- +- runFlow: + when: + platform: iOS + commands: + - extendedWaitUntil: + visible: + text: 'Filter' + timeout: 60000 + - tapOn: + text: 'Filter' + - extendedWaitUntil: + visible: + text: ${IOS_OPTION} + timeout: 60000 + - tapOn: + text: ${IOS_OPTION} +- runFlow: + when: + platform: android + commands: + - extendedWaitUntil: + visible: + id: ${ANDROID_FILTER_ID} + timeout: 60000 + - tapOn: + id: ${ANDROID_FILTER_ID} + - extendedWaitUntil: + visible: + id: ${ANDROID_OPTION_ID} + timeout: 60000 + - tapOn: + id: ${ANDROID_OPTION_ID} diff --git a/.maestro/helpers/tap-header-item.yaml b/.maestro/helpers/tap-header-item.yaml new file mode 100644 index 00000000000..963ba9f797c --- /dev/null +++ b/.maestro/helpers/tap-header-item.yaml @@ -0,0 +1,21 @@ +appId: ${APP_ID} +name: Tap header item +tags: + - 'util' + +--- +# iOS native header items (unstable_headerLeftItems/RightItems) carry no accessibilityIdentifier, +# only a label. Android keeps the JS-rendered header, matched by testID as before. +- runFlow: + when: + platform: Android + commands: + - tapOn: + id: ${ANDROID_ID} + +- runFlow: + when: + platform: iOS + commands: + - tapOn: + text: ${IOS_LABEL} diff --git a/.maestro/helpers/tap-search-field.yaml b/.maestro/helpers/tap-search-field.yaml new file mode 100644 index 00000000000..a2f14c3f691 --- /dev/null +++ b/.maestro/helpers/tap-search-field.yaml @@ -0,0 +1,33 @@ +appId: ${APP_ID} +name: Tap search field +tags: + - 'util' +--- +- runFlow: + when: + platform: iOS + commands: + - runFlow: + when: + notVisible: + id: 'magnifyingglass' + commands: + - swipe: + start: 50%, 30% + end: 50%, 60% + - extendedWaitUntil: + visible: + id: 'magnifyingglass' + timeout: 60000 + - tapOn: + id: 'magnifyingglass' +- runFlow: + when: + platform: android + commands: + - extendedWaitUntil: + visible: + id: ${SEARCH_ID} + timeout: 60000 + - tapOn: + id: ${SEARCH_ID} diff --git a/.maestro/helpers/wait-header-item.yaml b/.maestro/helpers/wait-header-item.yaml new file mode 100644 index 00000000000..7046e125b3c --- /dev/null +++ b/.maestro/helpers/wait-header-item.yaml @@ -0,0 +1,23 @@ +appId: ${APP_ID} +name: Wait for header item +tags: + - 'util' + +--- +- runFlow: + when: + platform: Android + commands: + - extendedWaitUntil: + visible: + id: ${ANDROID_ID} + timeout: 60000 + +- runFlow: + when: + platform: iOS + commands: + - extendedWaitUntil: + visible: + text: ${IOS_LABEL} + timeout: 60000 diff --git a/.maestro/tests/accessibilityAndAppearance/ToastsAndDialogs.yml b/.maestro/tests/accessibilityAndAppearance/ToastsAndDialogs.yml index a2bf50b79a6..17c2280bfdc 100644 --- a/.maestro/tests/accessibilityAndAppearance/ToastsAndDialogs.yml +++ b/.maestro/tests/accessibilityAndAppearance/ToastsAndDialogs.yml @@ -18,11 +18,27 @@ tags: # Show alerts as Toasts - tapOn: 'Menu' - tapOn: 'Accessibility & appearance' -- tapOn: '.*Show alerts as.*' -- assertVisible: 'Toasts. Dismissed automatically. Checked' -- assertVisible: 'Dialogs. Require manual dismissal. Unchecked' -- tapOn: - id: 'action-sheet-handle' +- runFlow: + when: + platform: iOS + commands: + - tapOn: 'Toasts' + - assertVisible: + text: 'Toasts' + selected: true + - assertVisible: + text: 'Dialogs' + selected: false + - tapOn: 'Toasts' +- runFlow: + when: + platform: Android + commands: + - tapOn: '.*Show alerts as.*' + - assertVisible: 'Toasts. Dismissed automatically. Checked' + - assertVisible: 'Dialogs. Require manual dismissal. Unchecked' + - tapOn: + id: 'action-sheet-handle' - waitForAnimationToEnd: timeout: 5000 - tapOn: 'Menu' @@ -37,9 +53,23 @@ tags: # Show alerts as Dialogs - tapOn: 'Accessibility & appearance' -- tapOn: '.*Show alerts as.*' -- assertVisible: 'Toasts. Dismissed automatically. Checked' -- tapOn: 'Dialogs. Require manual dismissal. Unchecked' +- runFlow: + when: + platform: iOS + commands: + - tapOn: 'Toasts' + - assertVisible: + text: 'Toasts' + selected: true + - tapOn: 'Dialogs' + - assertVisible: 'Dialogs' +- runFlow: + when: + platform: Android + commands: + - tapOn: '.*Show alerts as.*' + - assertVisible: 'Toasts. Dismissed automatically. Checked' + - tapOn: 'Dialogs. Require manual dismissal. Unchecked' - tapOn: 'Menu' - tapOn: 'Edit status' - tapOn: diff --git a/.maestro/tests/assorted/accessibility-and-appearance.yaml b/.maestro/tests/assorted/accessibility-and-appearance.yaml index 04a79a29fcd..dec998f84f7 100644 --- a/.maestro/tests/assorted/accessibility-and-appearance.yaml +++ b/.maestro/tests/assorted/accessibility-and-appearance.yaml @@ -95,8 +95,7 @@ tags: timeout: 60000 # should disable mentions with @ symbol -- tapOn: - id: header-back +- runFlow: '../../helpers/go-back.yaml' - runFlow: file: './utils/go-to-accessibility-appearance.yaml' @@ -134,8 +133,7 @@ tags: timeout: 60000 # should enable rooms with # symbol -- tapOn: - id: header-back +- runFlow: '../../helpers/go-back.yaml' - runFlow: file: './utils/go-to-accessibility-appearance.yaml' @@ -169,8 +167,7 @@ tags: timeout: 60000 # should disable mentions with # symbol -- tapOn: - id: header-back +- runFlow: '../../helpers/go-back.yaml' - runFlow: file: './utils/go-to-accessibility-appearance.yaml' diff --git a/.maestro/tests/assorted/admin-panel.yaml b/.maestro/tests/assorted/admin-panel.yaml index e783063497f..7380dfca90a 100644 --- a/.maestro/tests/assorted/admin-panel.yaml +++ b/.maestro/tests/assorted/admin-panel.yaml @@ -19,8 +19,11 @@ tags: visible: id: 'rooms-list-view' timeout: 60000 -- tapOn: - id: 'rooms-list-view-sidebar' +- runFlow: + file: '../../helpers/tap-header-item.yaml' + env: + ANDROID_ID: 'rooms-list-view-sidebar' + IOS_LABEL: 'Menu' - extendedWaitUntil: visible: id: 'sidebar-view' diff --git a/.maestro/tests/assorted/broadcast.yaml b/.maestro/tests/assorted/broadcast.yaml index 254c6b78b1b..7afd49459b2 100644 --- a/.maestro/tests/assorted/broadcast.yaml +++ b/.maestro/tests/assorted/broadcast.yaml @@ -19,12 +19,11 @@ tags: PASSWORD: ${output.user.password} # should create broadcast room -- extendedWaitUntil: - visible: - id: 'rooms-list-view-create-channel' - timeout: 60000 -- tapOn: - id: 'rooms-list-view-create-channel' +- runFlow: + file: '../../helpers/tap-header-item.yaml' + env: + ANDROID_ID: 'rooms-list-view-create-channel' + IOS_LABEL: 'Create new channel, team, direct message or discussion' - extendedWaitUntil: visible: id: 'new-message-view' @@ -39,8 +38,10 @@ tags: visible: id: 'select-users-view' timeout: 60000 -- tapOn: - id: 'select-users-view-search' +- runFlow: + file: '../../helpers/tap-search-field.yaml' + env: + SEARCH_ID: 'select-users-view-search' - inputText: ${output.otherUser.username} - extendedWaitUntil: visible: @@ -52,10 +53,17 @@ tags: visible: id: 'selected-user-${output.otherUser.username}' timeout: 60000 -- assertVisible: - id: 'selected-users-view-submit' -- tapOn: - id: 'selected-users-view-submit' +- runFlow: '../../helpers/close-search.yaml' +- runFlow: + file: '../../helpers/wait-header-item.yaml' + env: + ANDROID_ID: 'selected-users-view-submit' + IOS_LABEL: 'Next' +- runFlow: + file: '../../helpers/tap-header-item.yaml' + env: + ANDROID_ID: 'selected-users-view-submit' + IOS_LABEL: 'Next' - extendedWaitUntil: visible: id: 'create-channel-view' @@ -76,15 +84,11 @@ tags: timeout: 60000 - tapOn: id: 'create-channel-submit' -- extendedWaitUntil: - visible: - id: 'room-view-title-${output.room}' -- tapOn: - id: 'room-header' -- extendedWaitUntil: - visible: - id: 'room-actions-view' - timeout: 60000 +- runFlow: + file: '../../helpers/assert-room-title.yaml' + env: + ROOM: ${output.room} +- runFlow: '../../helpers/open-room-actions.yaml' - tapOn: id: 'room-actions-info' - extendedWaitUntil: @@ -117,10 +121,10 @@ tags: file: '../../helpers/search-and-navigate-room.yaml' env: ROOM: ${output.room} -- extendedWaitUntil: - visible: - id: 'room-view-title-${output.room}' - timeout: 60000 +- runFlow: + file: '../../helpers/assert-room-title.yaml' + env: + ROOM: ${output.room} # should not have message composer - assertNotVisible: @@ -139,10 +143,10 @@ tags: id: 'message-broadcast-reply' - tapOn: id: 'message-broadcast-reply' -- extendedWaitUntil: - visible: - id: 'room-view-title-${output.user.username}' - timeout: 60000 +- runFlow: + file: '../../helpers/assert-room-title.yaml' + env: + ROOM: ${output.user.username} # should reply broadcasted message - assertVisible: @@ -165,6 +169,7 @@ tags: timeout: 60000 - tapOn: text: 'Jump to message' -- extendedWaitUntil: - visible: - id: 'room-view-title-${output.room}' +- runFlow: + file: '../../helpers/assert-room-title.yaml' + env: + ROOM: ${output.room} diff --git a/.maestro/tests/assorted/change-avatar.yaml b/.maestro/tests/assorted/change-avatar.yaml index 72285dfc884..087c693591e 100644 --- a/.maestro/tests/assorted/change-avatar.yaml +++ b/.maestro/tests/assorted/change-avatar.yaml @@ -16,12 +16,11 @@ tags: env: USERNAME: ${output.user.username} PASSWORD: ${output.user.password} -- extendedWaitUntil: - visible: - id: 'rooms-list-view-sidebar' - timeout: 60000 -- tapOn: - id: 'rooms-list-view-sidebar' +- runFlow: + file: '../../helpers/tap-header-item.yaml' + env: + ANDROID_ID: 'rooms-list-view-sidebar' + IOS_LABEL: 'Menu' - extendedWaitUntil: visible: id: 'sidebar-view' @@ -56,8 +55,7 @@ tags: id: 'reset-avatar-suggestion' # should appear the discard alert when click the back icon -- tapOn: - id: 'header-back' +- runFlow: '../../helpers/go-back.yaml' - extendedWaitUntil: visible: text: '.*Discard changes?.*' @@ -68,8 +66,7 @@ tags: timeout: 60000 - tapOn: text: '.*Cancel.*' -- tapOn: - id: 'header-back' +- runFlow: '../../helpers/go-back.yaml' - extendedWaitUntil: visible: text: '.*Discard changes?.*' diff --git a/.maestro/tests/assorted/changeserver.yaml b/.maestro/tests/assorted/changeserver.yaml index 897eabe427d..6ae06a0747b 100644 --- a/.maestro/tests/assorted/changeserver.yaml +++ b/.maestro/tests/assorted/changeserver.yaml @@ -18,12 +18,7 @@ tags: PASSWORD: ${output.user.password} # should open the servers list, have the server add button and create workspace button -- extendedWaitUntil: - visible: - id: 'rooms-list-header-servers-list-button' - timeout: 60000 -- tapOn: - id: 'rooms-list-header-servers-list-button' +- runFlow: '../../helpers/open-servers-list.yaml' - extendedWaitUntil: visible: id: 'rooms-list-header-servers-list' @@ -57,12 +52,7 @@ tags: timeout: 60000 # should add server and create new user -- extendedWaitUntil: - visible: - id: 'rooms-list-header-servers-list-button' - timeout: 60000 -- tapOn: - id: 'rooms-list-header-servers-list-button' +- runFlow: '../../helpers/open-servers-list.yaml' - extendedWaitUntil: visible: id: 'rooms-list-header-servers-list' @@ -107,14 +97,9 @@ tags: server: ${output.data.alternateServer} # should change back to main server -- extendedWaitUntil: - visible: - id: 'rooms-list-header-servers-list-button' - timeout: 60000 - waitForAnimationToEnd: timeout: 5000 -- tapOn: - id: 'rooms-list-header-servers-list-button' +- runFlow: '../../helpers/open-servers-list.yaml' - extendedWaitUntil: visible: id: 'rooms-list-header-servers-list' @@ -154,12 +139,7 @@ tags: server: ${output.data.server} # should test swipe to delete on alternate server -- extendedWaitUntil: - visible: - id: 'rooms-list-header-servers-list-button' - timeout: 60000 -- tapOn: - id: 'rooms-list-header-servers-list-button' +- runFlow: '../../helpers/open-servers-list.yaml' - extendedWaitUntil: visible: id: 'server-item-${output.data.alternateServer}' @@ -198,12 +178,7 @@ tags: # verify alternate server is deleted - waitForAnimationToEnd: timeout: 500 -- extendedWaitUntil: - visible: - id: 'rooms-list-header-servers-list-button' - timeout: 60000 -- tapOn: - id: 'rooms-list-header-servers-list-button' +- runFlow: '../../helpers/open-servers-list.yaml' - extendedWaitUntil: notVisible: id: 'server-item-${output.data.alternateServer}' diff --git a/.maestro/tests/assorted/channel-deeplink.yaml b/.maestro/tests/assorted/channel-deeplink.yaml index 6edcc747621..5f1d4ea0110 100644 --- a/.maestro/tests/assorted/channel-deeplink.yaml +++ b/.maestro/tests/assorted/channel-deeplink.yaml @@ -24,10 +24,10 @@ tags: file: '../../helpers/open-deeplink.yaml' env: link: ${output.utils.getDeepLink('room', output.data.server, 'path=group/', output.group.name)} -- extendedWaitUntil: - visible: - id: 'room-view-title-${output.group.name}' - timeout: 60000 +- runFlow: + file: '../../helpers/assert-room-title.yaml' + env: + ROOM: ${output.group.name} # open group using id - killApp @@ -35,10 +35,10 @@ tags: file: '../../helpers/open-deeplink.yaml' env: link: ${output.utils.getDeepLink('room', output.data.server, 'path=group/', output.group._id)} -- extendedWaitUntil: - visible: - id: 'room-view-title-${output.group.name}' - timeout: 60000 +- runFlow: + file: '../../helpers/assert-room-title.yaml' + env: + ROOM: ${output.group.name} # open channel using name - killApp @@ -46,10 +46,10 @@ tags: file: '../../helpers/open-deeplink.yaml' env: link: ${output.utils.getDeepLink('room', output.data.server, 'path=channel/', output.channel.name)} -- extendedWaitUntil: - visible: - id: 'room-view-title-${output.channel.name}' - timeout: 60000 +- runFlow: + file: '../../helpers/assert-room-title.yaml' + env: + ROOM: ${output.channel.name} # open channel using id - killApp @@ -57,7 +57,7 @@ tags: file: '../../helpers/open-deeplink.yaml' env: link: ${output.utils.getDeepLink('room', output.data.server, 'path=channel/', output.channel._id)} -- extendedWaitUntil: - visible: - id: 'room-view-title-${output.channel.name}' - timeout: 60000 +- runFlow: + file: '../../helpers/assert-room-title.yaml' + env: + ROOM: ${output.channel.name} diff --git a/.maestro/tests/assorted/deeplink.yaml b/.maestro/tests/assorted/deeplink.yaml index 50cd775b78e..eef191bd36e 100644 --- a/.maestro/tests/assorted/deeplink.yaml +++ b/.maestro/tests/assorted/deeplink.yaml @@ -37,10 +37,10 @@ tags: file: '../../helpers/open-deeplink.yaml' env: link: ${output.utils.getDeepLink('auth', output.data.server, 'userId=', output.login.userId, '&token=', output.login.authToken, '&path=group/', output.room.name)} -- extendedWaitUntil: - visible: - id: 'room-view-title-${output.room.name}' - timeout: 60000 +- runFlow: + file: '../../helpers/assert-room-title.yaml' + env: + ROOM: ${output.room.name} - runFlow: '../../helpers/go-back.yaml' - extendedWaitUntil: visible: @@ -70,10 +70,10 @@ tags: file: '../../helpers/open-deeplink.yaml' env: link: ${output.utils.getDeepLink('auth', output.data.server, 'userId=', output.login.userId, '&token=', output.login.authToken, '&path=group/', output.room.name)} -- extendedWaitUntil: - visible: - id: 'room-view-title-${output.room.name}' - timeout: 60000 +- runFlow: + file: '../../helpers/assert-room-title.yaml' + env: + ROOM: ${output.room.name} - runFlow: '../../helpers/go-back.yaml' - extendedWaitUntil: visible: @@ -94,10 +94,10 @@ tags: file: '../../helpers/open-deeplink.yaml' env: link: ${output.utils.getDeepLink('room', output.data.server, 'path=group/', output.room.name)} -- extendedWaitUntil: - visible: - id: 'room-view-title-${output.room.name}' - timeout: 60000 +- runFlow: + file: '../../helpers/assert-room-title.yaml' + env: + ROOM: ${output.room.name} # should navigate to the thread using path - stopApp: ${APP_ID} @@ -105,10 +105,10 @@ tags: file: '../../helpers/open-deeplink.yaml' env: link: ${output.utils.getDeepLink('room', output.data.server, 'path=group/', output.room.name, '/thread/', output.result.message._id)} -- extendedWaitUntil: - visible: - id: 'room-view-title-${output.threadMessage}' - timeout: 60000 +- runFlow: + file: '../../helpers/assert-room-title.yaml' + env: + ROOM: ${output.threadMessage} # should navigate to the room using rid - stopApp: ${APP_ID} @@ -116,10 +116,10 @@ tags: file: '../../helpers/open-deeplink.yaml' env: link: ${output.utils.getDeepLink('room', output.data.server, 'rid=', output.room._id)} -- extendedWaitUntil: - visible: - id: 'room-view-title-${output.room.name}' - timeout: 60000 +- runFlow: + file: '../../helpers/assert-room-title.yaml' + env: + ROOM: ${output.room.name} - runFlow: '../../helpers/go-back.yaml' - extendedWaitUntil: visible: @@ -132,10 +132,10 @@ tags: file: '../../helpers/open-deeplink.yaml' env: link: ${output.utils.getDeepLink('room', output.data.server, 'path=group/', output.room.name)} -- extendedWaitUntil: - visible: - id: 'room-view-title-${output.room.name}' - timeout: 60000 +- runFlow: + file: '../../helpers/assert-room-title.yaml' + env: + ROOM: ${output.room.name} # should simulate a tap on a push notification and navigate to the room # we are skipping this because we can't send push on iOS simulator and for android we have similar test @@ -146,12 +146,7 @@ tags: visible: id: 'rooms-list-view' timeout: 60000 -- extendedWaitUntil: - visible: - id: 'rooms-list-header-servers-list-button' - timeout: 60000 -- tapOn: - id: 'rooms-list-header-servers-list-button' +- runFlow: '../../helpers/open-servers-list.yaml' - extendedWaitUntil: visible: id: 'server-item-${output.data.alternateServer}' @@ -167,10 +162,10 @@ tags: file: '../../helpers/open-deeplink.yaml' env: link: ${output.utils.getDeepLink('room', output.data.server, 'path=group/', output.room.name)} -- extendedWaitUntil: - visible: - id: 'room-view-title-${output.room.name}' - timeout: 60000 +- runFlow: + file: '../../helpers/assert-room-title.yaml' + env: + ROOM: ${output.room.name} # should add a not existing server and fallback to the previous one - stopApp: ${APP_ID} @@ -227,12 +222,7 @@ tags: visible: id: 'rooms-list-view' timeout: 60000 -- extendedWaitUntil: - visible: - id: 'rooms-list-header-servers-list-button' - timeout: 60000 -- tapOn: - id: 'rooms-list-header-servers-list-button' +- runFlow: '../../helpers/open-servers-list.yaml' - extendedWaitUntil: visible: id: 'server-item-${output.data.alternateServer}' @@ -343,8 +333,18 @@ tags: visible: id: 'new-server-view' timeout: 60000 -- assertNotVisible: - id: 'room-view-title-${output.room.name}' +- runFlow: + when: + platform: android + commands: + - assertNotVisible: + id: 'room-view-title-${output.room.name}' +- runFlow: + when: + platform: ios + commands: + - assertNotVisible: + text: '${output.room.name}' # should show a security confirmation prompt on deep link login and sign in when confirmed - runFlow: @@ -376,10 +376,10 @@ tags: commands: - tapOn: text: 'Login' -- extendedWaitUntil: - visible: - id: 'room-view-title-${output.room.name}' - timeout: 60000 +- runFlow: + file: '../../helpers/assert-room-title.yaml' + env: + ROOM: ${output.room.name} - runFlow: '../../helpers/go-back.yaml' - extendedWaitUntil: visible: diff --git a/.maestro/tests/assorted/delete-server.yaml b/.maestro/tests/assorted/delete-server.yaml index 7e63bee1ea9..f48a289776d 100644 --- a/.maestro/tests/assorted/delete-server.yaml +++ b/.maestro/tests/assorted/delete-server.yaml @@ -22,12 +22,7 @@ tags: server: ${output.data.server} # should add server -- extendedWaitUntil: - visible: - id: 'rooms-list-header-servers-list-button' - timeout: 60000 -- tapOn: - id: 'rooms-list-header-servers-list-button' +- runFlow: '../../helpers/open-servers-list.yaml' - extendedWaitUntil: visible: id: 'rooms-list-header-server-add' @@ -67,12 +62,7 @@ tags: server: ${output.data.alternateServer} # should delete server -- extendedWaitUntil: - visible: - id: 'rooms-list-header-servers-list-button' - timeout: 60000 -- tapOn: - id: 'rooms-list-header-servers-list-button' +- runFlow: '../../helpers/open-servers-list.yaml' - extendedWaitUntil: visible: id: 'server-item-${output.data.server}' @@ -109,12 +99,7 @@ tags: retryTapIfNoChange: true - waitForAnimationToEnd: timeout: 300 -- extendedWaitUntil: - visible: - id: 'rooms-list-header-servers-list-button' - timeout: 60000 -- tapOn: - id: 'rooms-list-header-servers-list-button' +- runFlow: '../../helpers/open-servers-list.yaml' - extendedWaitUntil: visible: id: 'rooms-list-header-servers-list' diff --git a/.maestro/tests/assorted/display-perf.yaml b/.maestro/tests/assorted/display-perf.yaml index 54927e65a37..bbdaf0b54d7 100644 --- a/.maestro/tests/assorted/display-perf.yaml +++ b/.maestro/tests/assorted/display-perf.yaml @@ -30,18 +30,44 @@ tags: label: 'Should have room item' # should have create channel button -- extendedWaitUntil: - visible: - id: 'rooms-list-view-create-channel' - timeout: 60000 - label: 'Should have create channel button' +- runFlow: + when: + platform: Android + commands: + - extendedWaitUntil: + visible: + id: 'rooms-list-view-create-channel' + timeout: 60000 + label: 'Should have create channel button' +- runFlow: + when: + platform: iOS + commands: + - extendedWaitUntil: + visible: + text: 'Create new channel, team, direct message or discussion' + timeout: 60000 + label: 'Should have create channel button' # should have sidebar button -- extendedWaitUntil: - visible: - id: 'rooms-list-view-sidebar' - timeout: 60000 - label: 'Should have sidebar button' +- runFlow: + when: + platform: Android + commands: + - extendedWaitUntil: + visible: + id: 'rooms-list-view-sidebar' + timeout: 60000 + label: 'Should have sidebar button' +- runFlow: + when: + platform: iOS + commands: + - extendedWaitUntil: + visible: + text: 'Menu' + timeout: 60000 + label: 'Should have sidebar button' # should go to Display Preferences - runFlow: diff --git a/.maestro/tests/assorted/i18n.yaml b/.maestro/tests/assorted/i18n.yaml index b4be402f7b2..928eed1f68a 100644 --- a/.maestro/tests/assorted/i18n.yaml +++ b/.maestro/tests/assorted/i18n.yaml @@ -25,8 +25,11 @@ tags: visible: id: 'rooms-list-view' timeout: 60000 -- tapOn: - id: 'rooms-list-view-sidebar' +- runFlow: + file: '../../helpers/tap-header-item.yaml' + env: + ANDROID_ID: 'rooms-list-view-sidebar' + IOS_LABEL: 'Menu' - extendedWaitUntil: visible: id: 'sidebar-view' @@ -54,8 +57,11 @@ tags: visible: id: 'rooms-list-view' timeout: 60000 -- tapOn: - id: 'rooms-list-view-sidebar' +- runFlow: + file: '../../helpers/tap-header-item.yaml' + env: + ANDROID_ID: 'rooms-list-view-sidebar' + IOS_LABEL: 'Menu' - extendedWaitUntil: visible: id: 'sidebar-view' diff --git a/.maestro/tests/assorted/in-app-notification.yaml b/.maestro/tests/assorted/in-app-notification.yaml index 7b60d11506f..aa0a6cfe914 100644 --- a/.maestro/tests/assorted/in-app-notification.yaml +++ b/.maestro/tests/assorted/in-app-notification.yaml @@ -28,10 +28,10 @@ tags: timeout: 60000 - tapOn: id: 'in-app-notification-Message in DM' -- extendedWaitUntil: - visible: - id: 'room-view-title-${output.sender.username}' - timeout: 60000 +- runFlow: + file: '../../helpers/assert-room-title.yaml' + env: + ROOM: ${output.sender.username} - runFlow: '../../helpers/go-back.yaml' # receive in another room @@ -46,10 +46,10 @@ tags: timeout: 60000 - tapOn: id: 'in-app-notification-Another msg' -- extendedWaitUntil: - visible: - id: 'room-view-title-${output.sender.username}' - timeout: 60000 +- runFlow: + file: '../../helpers/assert-room-title.yaml' + env: + ROOM: ${output.sender.username} # should tap back and go back to RoomsListView - runFlow: '../../helpers/go-back.yaml' diff --git a/.maestro/tests/assorted/join-from-directory.yaml b/.maestro/tests/assorted/join-from-directory.yaml index 748345c4f5d..c9673976a5b 100644 --- a/.maestro/tests/assorted/join-from-directory.yaml +++ b/.maestro/tests/assorted/join-from-directory.yaml @@ -22,26 +22,23 @@ tags: PASSWORD: ${output.user.password} # should tap directory -- extendedWaitUntil: - visible: - id: 'rooms-list-view-directory' - timeout: 60000 - waitForAnimationToEnd: timeout: 10000 -- tapOn: - id: 'rooms-list-view-directory' +- runFlow: + file: '../../helpers/tap-header-item.yaml' + env: + ANDROID_ID: 'rooms-list-view-directory' + IOS_LABEL: 'Directory' - extendedWaitUntil: visible: id: 'directory-view' timeout: 60000 # should search public channel and navigate -- extendedWaitUntil: - visible: - id: 'directory-view-search' - timeout: 60000 -- tapOn: - id: 'directory-view-search' +- runFlow: + file: '../../helpers/tap-search-field.yaml' + env: + SEARCH_ID: 'directory-view-search' - inputText: join-from-directory - extendedWaitUntil: visible: @@ -53,56 +50,60 @@ tags: visible: id: 'room-view' timeout: 60000 -- extendedWaitUntil: - visible: - id: 'room-view-title-join-from-directory' - timeout: 60000 +- runFlow: + file: '../../helpers/assert-room-title.yaml' + env: + ROOM: 'join-from-directory' # should navigate to thread messages view and load messages -- extendedWaitUntil: - visible: - id: 'room-view-header-threads' - timeout: 60000 -- tapOn: - id: 'room-view-header-threads' +- runFlow: + file: '../../helpers/tap-header-item.yaml' + env: + ANDROID_ID: 'room-view-header-threads' + IOS_LABEL: 'Threads' - extendedWaitUntil: visible: id: 'thread-messages-view-${output.thread}' timeout: 60000 -- tapOn: - id: 'custom-header-back' -- extendedWaitUntil: - visible: - id: 'room-view-header-threads' - timeout: 60000 +- runFlow: '../../helpers/go-back.yaml' +- runFlow: + when: + platform: android + commands: + - extendedWaitUntil: + visible: + id: 'room-view-header-threads' + timeout: 60000 +- runFlow: + when: + platform: iOS + commands: + - extendedWaitUntil: + visible: + text: 'Threads' + timeout: 60000 # should search user and navigate -- tapOn: - id: 'header-back' -- extendedWaitUntil: - visible: - id: 'rooms-list-view-directory' - timeout: 60000 -- tapOn: - id: 'rooms-list-view-directory' +- runFlow: '../../helpers/go-back.yaml' +- runFlow: + file: '../../helpers/tap-header-item.yaml' + env: + ANDROID_ID: 'rooms-list-view-directory' + IOS_LABEL: 'Directory' - extendedWaitUntil: visible: id: 'directory-view' timeout: 60000 -- extendedWaitUntil: - visible: - id: 'directory-view-filter' - timeout: 60000 -- tapOn: - id: 'directory-view-filter' -- extendedWaitUntil: - visible: - id: 'directory-switch-users' - timeout: 60000 -- tapOn: - id: 'directory-switch-users' -- tapOn: - id: 'directory-view-search' +- runFlow: + file: '../../helpers/select-header-filter.yaml' + env: + ANDROID_FILTER_ID: 'directory-view-filter' + ANDROID_OPTION_ID: 'directory-switch-users' + IOS_OPTION: 'Users' +- runFlow: + file: '../../helpers/tap-search-field.yaml' + env: + SEARCH_ID: 'directory-view-search' - inputText: ${output.otherUser.username} - runFlow: when: @@ -119,38 +120,32 @@ tags: visible: id: 'room-view' timeout: 60000 -- extendedWaitUntil: - visible: - id: 'room-view-title-${output.otherUser.username}' - timeout: 60000 +- runFlow: + file: '../../helpers/assert-room-title.yaml' + env: + ROOM: ${output.otherUser.username} # should search team and navigate -- tapOn: - id: 'header-back' -- extendedWaitUntil: - visible: - id: 'rooms-list-view-directory' - timeout: 60000 -- tapOn: - id: 'rooms-list-view-directory' +- runFlow: '../../helpers/go-back.yaml' +- runFlow: + file: '../../helpers/tap-header-item.yaml' + env: + ANDROID_ID: 'rooms-list-view-directory' + IOS_LABEL: 'Directory' - extendedWaitUntil: visible: id: 'directory-view' timeout: 60000 -- extendedWaitUntil: - visible: - id: 'directory-view-filter' - timeout: 60000 -- tapOn: - id: 'directory-view-filter' -- extendedWaitUntil: - visible: - id: 'directory-switch-teams' - timeout: 60000 -- tapOn: - id: 'directory-switch-teams' -- tapOn: - id: 'directory-view-search' +- runFlow: + file: '../../helpers/select-header-filter.yaml' + env: + ANDROID_FILTER_ID: 'directory-view-filter' + ANDROID_OPTION_ID: 'directory-switch-teams' + IOS_OPTION: 'Teams' +- runFlow: + file: '../../helpers/tap-search-field.yaml' + env: + SEARCH_ID: 'directory-view-search' - inputText: ${output.team} - extendedWaitUntil: visible: @@ -162,7 +157,7 @@ tags: visible: id: 'room-view' timeout: 60000 -- extendedWaitUntil: - visible: - id: 'room-view-title-${output.team}' - timeout: 60000 +- runFlow: + file: '../../helpers/assert-room-title.yaml' + env: + ROOM: ${output.team} diff --git a/.maestro/tests/assorted/join-public-room.yaml b/.maestro/tests/assorted/join-public-room.yaml index c7f82ad1bd9..c6210a77381 100644 --- a/.maestro/tests/assorted/join-public-room.yaml +++ b/.maestro/tests/assorted/join-public-room.yaml @@ -25,8 +25,18 @@ tags: id: room-view # should have actions button -- assertVisible: - id: room-header +- runFlow: + when: + platform: android + commands: + - assertVisible: + id: room-header +- runFlow: + when: + platform: iOS + commands: + - assertVisible: + text: 'More' # should have join - assertVisible: diff --git a/.maestro/tests/assorted/profile.yaml b/.maestro/tests/assorted/profile.yaml index 257cc297509..e973fcfe20a 100644 --- a/.maestro/tests/assorted/profile.yaml +++ b/.maestro/tests/assorted/profile.yaml @@ -14,12 +14,11 @@ tags: env: USERNAME: ${output.user.username} PASSWORD: ${output.user.password} -- extendedWaitUntil: - visible: - id: 'rooms-list-view-sidebar' - timeout: 60000 -- tapOn: - id: 'rooms-list-view-sidebar' +- runFlow: + file: '../../helpers/tap-header-item.yaml' + env: + ANDROID_ID: 'rooms-list-view-sidebar' + IOS_LABEL: 'Menu' - extendedWaitUntil: visible: id: 'sidebar-view' diff --git a/.maestro/tests/assorted/screen-lock.yaml b/.maestro/tests/assorted/screen-lock.yaml index 4ea169bfcb4..7fb93da90df 100644 --- a/.maestro/tests/assorted/screen-lock.yaml +++ b/.maestro/tests/assorted/screen-lock.yaml @@ -20,8 +20,11 @@ tags: visible: id: 'rooms-list-view' timeout: 60000 -- tapOn: - id: 'rooms-list-view-sidebar' +- runFlow: + file: '../../helpers/tap-header-item.yaml' + env: + ANDROID_ID: 'rooms-list-view-sidebar' + IOS_LABEL: 'Menu' - extendedWaitUntil: visible: id: 'sidebar-settings' @@ -122,8 +125,11 @@ tags: visible: id: 'rooms-list-view' timeout: 60000 -- tapOn: - id: 'rooms-list-view-sidebar' +- runFlow: + file: '../../helpers/tap-header-item.yaml' + env: + ANDROID_ID: 'rooms-list-view-sidebar' + IOS_LABEL: 'Menu' - extendedWaitUntil: visible: id: 'sidebar-settings' diff --git a/.maestro/tests/assorted/setting.yaml b/.maestro/tests/assorted/setting.yaml index 9186da79f1e..3d4a08d2143 100644 --- a/.maestro/tests/assorted/setting.yaml +++ b/.maestro/tests/assorted/setting.yaml @@ -20,8 +20,11 @@ tags: visible: id: 'rooms-list-view' timeout: 60000 -- tapOn: - id: 'rooms-list-view-sidebar' +- runFlow: + file: '../../helpers/tap-header-item.yaml' + env: + ANDROID_ID: 'rooms-list-view-sidebar' + IOS_LABEL: 'Menu' - extendedWaitUntil: visible: id: 'sidebar-view' @@ -177,10 +180,11 @@ tags: timeout: 60000 # should navigate to legalview -- assertVisible: - id: 'rooms-list-view-sidebar' -- tapOn: - id: 'rooms-list-view-sidebar' +- runFlow: + file: '../../helpers/tap-header-item.yaml' + env: + ANDROID_ID: 'rooms-list-view-sidebar' + IOS_LABEL: 'Menu' - extendedWaitUntil: visible: id: 'sidebar-view' diff --git a/.maestro/tests/assorted/status.yaml b/.maestro/tests/assorted/status.yaml index 88e5f418369..63ef6bcc4fc 100644 --- a/.maestro/tests/assorted/status.yaml +++ b/.maestro/tests/assorted/status.yaml @@ -14,12 +14,11 @@ tags: env: USERNAME: ${output.user.username} PASSWORD: ${output.user.password} -- extendedWaitUntil: - visible: - id: 'rooms-list-view-sidebar' - timeout: 60000 -- tapOn: - id: 'rooms-list-view-sidebar' +- runFlow: + file: '../../helpers/tap-header-item.yaml' + env: + ANDROID_ID: 'rooms-list-view-sidebar' + IOS_LABEL: 'Menu' - extendedWaitUntil: visible: id: 'sidebar-view' diff --git a/.maestro/tests/assorted/user-preferences.yaml b/.maestro/tests/assorted/user-preferences.yaml index 80e026160c5..5e6baa77684 100644 --- a/.maestro/tests/assorted/user-preferences.yaml +++ b/.maestro/tests/assorted/user-preferences.yaml @@ -89,8 +89,7 @@ tags: id: push-troubleshoot-view-push-gateway-connection - assertVisible: id: push-troubleshoot-view-notification-delay -- tapOn: - id: custom-header-back +- runFlow: '../../helpers/go-back.yaml' # should have alert in Email - assertVisible: @@ -105,8 +104,7 @@ tags: id: notification-preferences-emailNotificationMode-nothing - tapOn: id: action-sheet-handle -- tapOn: - id: custom-header-back +- runFlow: '../../helpers/go-back.yaml' # ASCII should not be converted to emoji (Android only) - runFlow: @@ -114,8 +112,7 @@ tags: when: platform: Android commands: - - tapOn: - id: 'custom-header-back' + - runFlow: '../../helpers/go-back.yaml' - extendedWaitUntil: visible: id: 'profile-view' @@ -162,8 +159,7 @@ tags: when: platform: Android commands: - - tapOn: - id: 'header-back' + - runFlow: '../../helpers/go-back.yaml' - runFlow: file: './utils/go-to-user-preference.yaml' - extendedWaitUntil: @@ -172,8 +168,7 @@ tags: timeout: 60000 - tapOn: id: 'preferences-view-convert-ascii-to-emoji' - - tapOn: - id: 'custom-header-back' + - runFlow: '../../helpers/go-back.yaml' - extendedWaitUntil: visible: id: 'profile-view' diff --git a/.maestro/tests/assorted/utils/check-server.yaml b/.maestro/tests/assorted/utils/check-server.yaml index 7e180616e21..486c3e14d43 100644 --- a/.maestro/tests/assorted/utils/check-server.yaml +++ b/.maestro/tests/assorted/utils/check-server.yaml @@ -4,12 +4,11 @@ tags: - 'util' --- -- extendedWaitUntil: - visible: - id: 'rooms-list-view-sidebar' - timeout: 60000 -- tapOn: - id: 'rooms-list-view-sidebar' +- runFlow: + file: '../../../helpers/tap-header-item.yaml' + env: + ANDROID_ID: 'rooms-list-view-sidebar' + IOS_LABEL: 'Menu' - extendedWaitUntil: visible: id: 'sidebar-view' diff --git a/.maestro/tests/assorted/utils/go-to-accessibility-appearance.yaml b/.maestro/tests/assorted/utils/go-to-accessibility-appearance.yaml index f54a4946e4c..42b2309f656 100644 --- a/.maestro/tests/assorted/utils/go-to-accessibility-appearance.yaml +++ b/.maestro/tests/assorted/utils/go-to-accessibility-appearance.yaml @@ -4,12 +4,11 @@ tags: - 'util' --- -- extendedWaitUntil: - visible: - id: 'rooms-list-view-sidebar' - timeout: 60000 -- tapOn: - id: 'rooms-list-view-sidebar' +- runFlow: + file: '../../../helpers/tap-header-item.yaml' + env: + ANDROID_ID: 'rooms-list-view-sidebar' + IOS_LABEL: 'Menu' - extendedWaitUntil: visible: id: 'sidebar-accessibility' diff --git a/.maestro/tests/assorted/utils/go-to-display-pref.yaml b/.maestro/tests/assorted/utils/go-to-display-pref.yaml index 4acea68390c..110ae664ff4 100644 --- a/.maestro/tests/assorted/utils/go-to-display-pref.yaml +++ b/.maestro/tests/assorted/utils/go-to-display-pref.yaml @@ -4,12 +4,11 @@ tags: - 'util' --- -- extendedWaitUntil: - visible: - id: 'rooms-list-view-sidebar' - timeout: 60000 -- tapOn: - id: 'rooms-list-view-sidebar' +- runFlow: + file: '../../../helpers/tap-header-item.yaml' + env: + ANDROID_ID: 'rooms-list-view-sidebar' + IOS_LABEL: 'Menu' - extendedWaitUntil: visible: id: 'sidebar-accessibility' diff --git a/.maestro/tests/assorted/utils/go-to-room-list.yaml b/.maestro/tests/assorted/utils/go-to-room-list.yaml index 6734380e054..5dc8737c39e 100644 --- a/.maestro/tests/assorted/utils/go-to-room-list.yaml +++ b/.maestro/tests/assorted/utils/go-to-room-list.yaml @@ -4,12 +4,9 @@ tags: - 'util' --- -- extendedWaitUntil: - visible: - id: 'custom-header-back' - timeout: 60000 -- tapOn: - id: 'custom-header-back' +- waitForAnimationToEnd: + timeout: 10000 +- runFlow: '../../../helpers/go-back.yaml' - extendedWaitUntil: visible: id: 'accessibility-view-drawer' diff --git a/.maestro/tests/assorted/utils/go-to-user-preference.yaml b/.maestro/tests/assorted/utils/go-to-user-preference.yaml index 3a735db6b03..18ac81e4df5 100644 --- a/.maestro/tests/assorted/utils/go-to-user-preference.yaml +++ b/.maestro/tests/assorted/utils/go-to-user-preference.yaml @@ -4,12 +4,11 @@ tags: - 'util' --- -- extendedWaitUntil: - visible: - id: 'rooms-list-view-sidebar' - timeout: 60000 -- tapOn: - id: 'rooms-list-view-sidebar' +- runFlow: + file: '../../../helpers/tap-header-item.yaml' + env: + ANDROID_ID: 'rooms-list-view-sidebar' + IOS_LABEL: 'Menu' - extendedWaitUntil: visible: id: 'sidebar-profile' diff --git a/.maestro/tests/assorted/utils/nav-to-language.yaml b/.maestro/tests/assorted/utils/nav-to-language.yaml index e92b341fad1..1cfd88f8d61 100644 --- a/.maestro/tests/assorted/utils/nav-to-language.yaml +++ b/.maestro/tests/assorted/utils/nav-to-language.yaml @@ -8,12 +8,11 @@ tags: visible: id: 'rooms-list-view' timeout: 60000 -- extendedWaitUntil: - visible: - id: 'rooms-list-view-sidebar' - timeout: 60000 -- tapOn: - id: 'rooms-list-view-sidebar' +- runFlow: + file: '../../../helpers/tap-header-item.yaml' + env: + ANDROID_ID: 'rooms-list-view-sidebar' + IOS_LABEL: 'Menu' - extendedWaitUntil: visible: id: 'sidebar-view' diff --git a/.maestro/tests/e2ee/flows/create-e2ee-room.yaml b/.maestro/tests/e2ee/flows/create-e2ee-room.yaml index 02219c31d1d..7d698cc833a 100644 --- a/.maestro/tests/e2ee/flows/create-e2ee-room.yaml +++ b/.maestro/tests/e2ee/flows/create-e2ee-room.yaml @@ -4,12 +4,11 @@ tags: - 'flow' --- - launchApp -- extendedWaitUntil: - visible: - id: 'rooms-list-view-create-channel' - timeout: 60000 -- tapOn: - id: 'rooms-list-view-create-channel' +- runFlow: + file: '../../../helpers/tap-header-item.yaml' + env: + ANDROID_ID: 'rooms-list-view-create-channel' + IOS_LABEL: 'Create new channel, team, direct message or discussion' - extendedWaitUntil: visible: id: 'new-message-view' @@ -23,8 +22,10 @@ tags: visible: id: 'select-users-view' timeout: 60000 -- tapOn: - id: 'select-users-view-search' +- runFlow: + file: '../../../helpers/tap-search-field.yaml' + env: + SEARCH_ID: 'select-users-view-search' - inputText: ${USER_TO_ADD} - extendedWaitUntil: visible: @@ -35,8 +36,11 @@ tags: - extendedWaitUntil: visible: id: 'selected-user-${USER_TO_ADD}' -- tapOn: - id: 'selected-users-view-submit' +- runFlow: + file: '../../../helpers/tap-header-item.yaml' + env: + ANDROID_ID: 'selected-users-view-submit' + IOS_LABEL: 'Next' - extendedWaitUntil: visible: id: 'create-channel-name' @@ -53,7 +57,7 @@ tags: id: 'create-channel-encrypted' - tapOn: id: 'create-channel-submit' -- extendedWaitUntil: - visible: - id: 'room-view-title-${ROOM}' - timeout: 60000 +- runFlow: + file: '../../../helpers/assert-room-title.yaml' + env: + ROOM: ${ROOM} diff --git a/.maestro/tests/e2ee/flows/reset-keys-and-send.yaml b/.maestro/tests/e2ee/flows/reset-keys-and-send.yaml index f607b8d3957..9a2ab1da641 100644 --- a/.maestro/tests/e2ee/flows/reset-keys-and-send.yaml +++ b/.maestro/tests/e2ee/flows/reset-keys-and-send.yaml @@ -39,12 +39,33 @@ env: visible: text: '.*The encryption keys for the room need to be updated, another room member needs to be online for this to happen.*' timeout: 60000 -- extendedWaitUntil: - visible: - id: 'room-view-header-encryption' - timeout: 60000 -- tapOn: - id: 'room-view-header-encryption' +- runFlow: + when: + platform: android + commands: + - extendedWaitUntil: + visible: + id: 'room-view-header-encryption' + timeout: 60000 + - tapOn: + id: 'room-view-header-encryption' +- runFlow: + when: + platform: iOS + commands: + - runFlow: + when: + notVisible: + text: 'Encrypted' + commands: + - tapOn: + text: 'More' + - extendedWaitUntil: + visible: + text: 'Encrypted' + timeout: 60000 + - tapOn: + text: 'Encrypted' - extendedWaitUntil: visible: id: 'e2ee-toggle-room-view' @@ -69,10 +90,10 @@ env: file: '../../../helpers/navigate-to-room.yaml' env: ROOM: ${ROOM} -- extendedWaitUntil: - visible: - id: 'room-view-title-${ROOM}' - timeout: 60000 +- runFlow: + file: '../../../helpers/assert-room-title.yaml' + env: + ROOM: ${ROOM} - runFlow: file: '../../../helpers/send-message.yaml' env: diff --git a/.maestro/tests/e2ee/utils/navigate-to-e2ee-security.yaml b/.maestro/tests/e2ee/utils/navigate-to-e2ee-security.yaml index 00c21e89233..e37a6e5bd0e 100644 --- a/.maestro/tests/e2ee/utils/navigate-to-e2ee-security.yaml +++ b/.maestro/tests/e2ee/utils/navigate-to-e2ee-security.yaml @@ -18,13 +18,11 @@ tags: visible: 'Enter E2EE password' commands: - assertVisible: Enter E2EE password -- extendedWaitUntil: - visible: - id: rooms-list-view-sidebar - enabled: true - timeout: 60000 -- tapOn: - id: rooms-list-view-sidebar +- runFlow: + file: '../../../helpers/tap-header-item.yaml' + env: + ANDROID_ID: 'rooms-list-view-sidebar' + IOS_LABEL: 'Menu' - extendedWaitUntil: visible: id: sidebar-view diff --git a/.maestro/tests/keyboardNavigation/keyboard-navigation-components.yaml b/.maestro/tests/keyboardNavigation/keyboard-navigation-components.yaml index da7b8376cad..341680e0429 100644 --- a/.maestro/tests/keyboardNavigation/keyboard-navigation-components.yaml +++ b/.maestro/tests/keyboardNavigation/keyboard-navigation-components.yaml @@ -72,8 +72,7 @@ tags: - pressKey: enter # open Toasts and Dialogs action sheet and navigate through options -- tapOn: - id: 'custom-header-back' +- runFlow: '../../helpers/go-back.yaml' - extendedWaitUntil: visible: id: 'accessibility-view-list' diff --git a/.maestro/tests/onboarding/roomslist.yaml b/.maestro/tests/onboarding/roomslist.yaml index ca236a66b74..2421a01478b 100644 --- a/.maestro/tests/onboarding/roomslist.yaml +++ b/.maestro/tests/onboarding/roomslist.yaml @@ -19,10 +19,22 @@ tags: id: 'rooms-list-view' - assertVisible: id: 'rooms-list-view-item-general' -- assertVisible: - id: 'rooms-list-view-create-channel' -- assertVisible: - id: 'rooms-list-view-sidebar' +- runFlow: + when: + platform: android + commands: + - assertVisible: + id: 'rooms-list-view-create-channel' + - assertVisible: + id: 'rooms-list-view-sidebar' +- runFlow: + when: + platform: iOS + commands: + - assertVisible: + text: 'Create new channel, team, direct message or discussion' + - assertVisible: + text: 'Menu' - runFlow: file: ../../helpers/search-room.yaml env: diff --git a/.maestro/tests/room/create-dm-group.yaml b/.maestro/tests/room/create-dm-group.yaml index 12750ea7d0d..d00f676051b 100644 --- a/.maestro/tests/room/create-dm-group.yaml +++ b/.maestro/tests/room/create-dm-group.yaml @@ -18,12 +18,11 @@ tags: env: USERNAME: ${output.user.username} PASSWORD: ${output.user.password} -- extendedWaitUntil: - visible: - id: 'rooms-list-view-create-channel' - timeout: 60000 -- tapOn: - id: 'rooms-list-view-create-channel' +- runFlow: + file: '../../helpers/tap-header-item.yaml' + env: + ANDROID_ID: 'rooms-list-view-create-channel' + IOS_LABEL: 'Create new channel, team, direct message or discussion' # should have new message screen - extendedWaitUntil: @@ -46,12 +45,10 @@ tags: id: 'new-message-view-create-direct-message' # should add users -- extendedWaitUntil: - visible: - id: 'select-users-view-search' - timeout: 60000 -- tapOn: - id: 'select-users-view-search' +- runFlow: + file: '../../helpers/tap-search-field.yaml' + env: + SEARCH_ID: 'select-users-view-search' - inputText: 'rocket.cat' - extendedWaitUntil: visible: @@ -59,8 +56,10 @@ tags: timeout: 60000 - tapOn: id: 'select-users-view-item-rocket.cat' -- tapOn: - id: 'select-users-view-search' +- runFlow: + file: '../../helpers/tap-search-field.yaml' + env: + SEARCH_ID: 'select-users-view-search' - eraseText - inputText: ${output.otherUser.username} - extendedWaitUntil: @@ -69,15 +68,19 @@ tags: timeout: 60000 - tapOn: id: 'select-users-view-item-${output.otherUser.username}' -- extendedWaitUntil: - visible: - id: 'selected-users-view-submit' - timeout: 60000 -- tapOn: - id: 'selected-users-view-submit' +- runFlow: + file: '../../helpers/wait-header-item.yaml' + env: + ANDROID_ID: 'selected-users-view-submit' + IOS_LABEL: 'Create' +- runFlow: + file: '../../helpers/tap-header-item.yaml' + env: + ANDROID_ID: 'selected-users-view-submit' + IOS_LABEL: 'Create' # check Group DM exist -- extendedWaitUntil: - visible: - id: 'room-view-title-rocket.cat, ${output.otherUser.username}' - timeout: 60000 +- runFlow: + file: '../../helpers/assert-room-title.yaml' + env: + ROOM: 'rocket.cat, ${output.otherUser.username}' diff --git a/.maestro/tests/room/create-room.yaml b/.maestro/tests/room/create-room.yaml index d7dc2f2df39..ce322467953 100644 --- a/.maestro/tests/room/create-room.yaml +++ b/.maestro/tests/room/create-room.yaml @@ -15,12 +15,11 @@ tags: USERNAME: ${output.user.username} PASSWORD: ${output.user.password} -- extendedWaitUntil: - visible: - id: 'rooms-list-view-create-channel' - timeout: 60000 -- tapOn: - id: 'rooms-list-view-create-channel' +- runFlow: + file: '../../helpers/tap-header-item.yaml' + env: + ANDROID_ID: 'rooms-list-view-create-channel' + IOS_LABEL: 'Create new channel, team, direct message or discussion' # should have new message screen - extendedWaitUntil: @@ -45,8 +44,11 @@ tags: visible: id: 'rooms-list-view' timeout: 60000 -- tapOn: - id: 'rooms-list-view-create-channel' +- runFlow: + file: '../../helpers/tap-header-item.yaml' + env: + ANDROID_ID: 'rooms-list-view-create-channel' + IOS_LABEL: 'Create new channel, team, direct message or discussion' - extendedWaitUntil: visible: id: 'new-message-view' @@ -70,10 +72,10 @@ tags: visible: id: 'room-view' timeout: 60000 -- extendedWaitUntil: - visible: - id: 'room-view-title-rocket.cat' - timeout: 60000 +- runFlow: + file: '../../helpers/assert-room-title.yaml' + env: + ROOM: 'rocket.cat' - runFlow: '../../helpers/go-back.yaml' - extendedWaitUntil: visible: @@ -81,12 +83,11 @@ tags: timeout: 60000 # should navigate to select users -- extendedWaitUntil: - visible: - id: 'rooms-list-view-create-channel' - timeout: 60000 -- tapOn: - id: 'rooms-list-view-create-channel' +- runFlow: + file: '../../helpers/tap-header-item.yaml' + env: + ANDROID_ID: 'rooms-list-view-create-channel' + IOS_LABEL: 'Create new channel, team, direct message or discussion' - extendedWaitUntil: visible: id: 'new-message-view' @@ -103,12 +104,10 @@ tags: timeout: 60000 # should search users -- extendedWaitUntil: - visible: - id: 'select-users-view-search' - timeout: 60000 -- tapOn: - id: 'select-users-view-search' +- runFlow: + file: '../../helpers/tap-search-field.yaml' + env: + SEARCH_ID: 'select-users-view-search' - inputText: 'rocket.cat' - extendedWaitUntil: visible: @@ -138,12 +137,16 @@ tags: timeout: 60000 # should navigate to create channel view -- extendedWaitUntil: - visible: - id: 'selected-users-view-submit' - timeout: 60000 -- tapOn: - id: 'selected-users-view-submit' +- runFlow: + file: '../../helpers/wait-header-item.yaml' + env: + ANDROID_ID: 'selected-users-view-submit' + IOS_LABEL: 'Next' +- runFlow: + file: '../../helpers/tap-header-item.yaml' + env: + ANDROID_ID: 'selected-users-view-submit' + IOS_LABEL: 'Next' - extendedWaitUntil: visible: id: 'create-channel-view' @@ -203,10 +206,10 @@ tags: visible: id: 'room-view' timeout: 60000 -- extendedWaitUntil: - visible: - id: ${'room-view-title-' + output.publicRoomName} - timeout: 60000 +- runFlow: + file: '../../helpers/assert-room-title.yaml' + env: + ROOM: ${output.publicRoomName} - runFlow: '../../helpers/go-back.yaml' - extendedWaitUntil: visible: @@ -223,12 +226,11 @@ tags: visible: id: 'rooms-list-view' timeout: 60000 -- extendedWaitUntil: - visible: - id: 'rooms-list-view-create-channel' - timeout: 60000 -- tapOn: - id: 'rooms-list-view-create-channel' +- runFlow: + file: '../../helpers/tap-header-item.yaml' + env: + ANDROID_ID: 'rooms-list-view-create-channel' + IOS_LABEL: 'Create new channel, team, direct message or discussion' - extendedWaitUntil: visible: id: 'new-message-view' @@ -249,8 +251,11 @@ tags: visible: id: 'selected-user-rocket.cat' timeout: 60000 -- tapOn: - id: 'selected-users-view-submit' +- runFlow: + file: '../../helpers/tap-header-item.yaml' + env: + ANDROID_ID: 'selected-users-view-submit' + IOS_LABEL: 'Next' - extendedWaitUntil: visible: id: 'create-channel-view' @@ -269,10 +274,10 @@ tags: visible: id: 'room-view' timeout: 60000 -- extendedWaitUntil: - visible: - id: ${'room-view-title-' + output.privateRoomName} - timeout: 60000 +- runFlow: + file: '../../helpers/assert-room-title.yaml' + env: + ROOM: ${output.privateRoomName} - runFlow: '../../helpers/go-back.yaml' - extendedWaitUntil: visible: @@ -289,12 +294,11 @@ tags: visible: id: 'rooms-list-view' timeout: 60000 -- extendedWaitUntil: - visible: - id: 'rooms-list-view-create-channel' - timeout: 60000 -- tapOn: - id: 'rooms-list-view-create-channel' +- runFlow: + file: '../../helpers/tap-header-item.yaml' + env: + ANDROID_ID: 'rooms-list-view-create-channel' + IOS_LABEL: 'Create new channel, team, direct message or discussion' - extendedWaitUntil: visible: id: 'new-message-view' @@ -309,8 +313,11 @@ tags: visible: id: 'select-users-view' timeout: 60000 -- tapOn: - id: 'selected-users-view-submit' +- runFlow: + file: '../../helpers/tap-header-item.yaml' + env: + ANDROID_ID: 'selected-users-view-submit' + IOS_LABEL: 'Skip' - extendedWaitUntil: visible: id: 'create-channel-view' @@ -333,10 +340,10 @@ tags: visible: id: 'room-view' timeout: 60000 -- extendedWaitUntil: - visible: - id: ${'room-view-title-' + output.emptyRoomName} - timeout: 60000 +- runFlow: + file: '../../helpers/assert-room-title.yaml' + env: + ROOM: ${output.emptyRoomName} - runFlow: '../../helpers/go-back.yaml' - extendedWaitUntil: visible: diff --git a/.maestro/tests/room/discussion.yaml b/.maestro/tests/room/discussion.yaml index 3fc7f536218..da4a072996f 100644 --- a/.maestro/tests/room/discussion.yaml +++ b/.maestro/tests/room/discussion.yaml @@ -25,10 +25,11 @@ tags: visible: id: 'rooms-list-view' timeout: 60000 -- assertVisible: - id: 'rooms-list-view-create-channel' -- tapOn: - id: 'rooms-list-view-create-channel' +- runFlow: + file: '../../helpers/tap-header-item.yaml' + env: + ANDROID_ID: 'rooms-list-view-create-channel' + IOS_LABEL: 'Create new channel, team, direct message or discussion' - extendedWaitUntil: visible: id: 'new-message-view' @@ -44,12 +45,10 @@ tags: id: 'select-users-view' timeout: 60000 # Select users in SelectedUsersView -- extendedWaitUntil: - visible: - id: 'select-users-view-search' - timeout: 60000 -- tapOn: - id: 'select-users-view-search' +- runFlow: + file: '../../helpers/tap-search-field.yaml' + env: + SEARCH_ID: 'select-users-view-search' - inputText: ${output.selectUser} - extendedWaitUntil: visible: @@ -61,8 +60,11 @@ tags: visible: id: 'selected-user-${output.selectUser}' timeout: 60000 -- tapOn: - id: 'selected-users-view-submit' +- runFlow: + file: '../../helpers/tap-header-item.yaml' + env: + ANDROID_ID: 'selected-users-view-submit' + IOS_LABEL: 'Next' - extendedWaitUntil: visible: id: 'create-discussion-view' @@ -89,18 +91,17 @@ tags: visible: id: 'room-view' timeout: 60000 -- extendedWaitUntil: - visible: - id: 'room-view-title-${output.discussionFromNewMessage}' - timeout: 60000 +- runFlow: + file: '../../helpers/assert-room-title.yaml' + env: + ROOM: ${output.discussionFromNewMessage} - runFlow: '../../helpers/hide-keyboard.yaml' - runFlow: when: visible: id: 'room-view' commands: - - tapOn: - id: 'header-back' + - runFlow: '../../helpers/go-back.yaml' - extendedWaitUntil: visible: id: 'rooms-list-view-item-${output.discussionFromNewMessage}' @@ -142,10 +143,10 @@ tags: visible: id: 'room-view' timeout: 60000 -- extendedWaitUntil: - visible: - id: 'room-view-title-${output.discussionFromMessageComposer}' - timeout: 60000 +- runFlow: + file: '../../helpers/assert-room-title.yaml' + env: + ROOM: ${output.discussionFromMessageComposer} # Create Discussion from action sheet - extendedWaitUntil: @@ -183,21 +184,13 @@ tags: visible: id: 'room-view' timeout: 60000 -- extendedWaitUntil: - visible: - id: 'room-view-title-message' - timeout: 60000 +- runFlow: + file: '../../helpers/assert-room-title.yaml' + env: + ROOM: 'message' # Check RoomActionsView render -- assertVisible: - id: room-header -- tapOn: - id: room-header - retryTapIfNoChange: true -- extendedWaitUntil: - visible: - id: 'room-actions-view' - timeout: 60000 +- runFlow: '../../helpers/open-room-actions.yaml' # should have room actions screen - assertVisible: @@ -259,20 +252,17 @@ tags: id: room-info-view-edit-button # should go back to main room -- tapOn: - id: custom-header-back +- runFlow: '../../helpers/go-back.yaml' - extendedWaitUntil: visible: id: 'room-actions-view' timeout: 60000 -- tapOn: - id: custom-header-back +- runFlow: '../../helpers/go-back.yaml' - extendedWaitUntil: visible: id: 'room-view-messages' timeout: 60000 -- tapOn: - id: header-back +- runFlow: '../../helpers/go-back.yaml' - extendedWaitUntil: visible: id: 'rooms-list-view' @@ -283,12 +273,7 @@ tags: ROOM: ${output.room.name} # should navigate to DiscussionsView -- extendedWaitUntil: - visible: - id: 'room-header' - timeout: 60000 -- tapOn: - id: 'room-header' +- runFlow: '../../helpers/open-room-actions.yaml' - extendedWaitUntil: visible: id: 'room-actions-discussions' @@ -307,7 +292,7 @@ tags: timeout: 60000 - tapOn: id: 'discussions-view-${output.discussionFromNewMessage}' -- extendedWaitUntil: - visible: - id: 'room-view-title-${output.discussionFromNewMessage}' - timeout: 60000 +- runFlow: + file: '../../helpers/assert-room-title.yaml' + env: + ROOM: ${output.discussionFromNewMessage} diff --git a/.maestro/tests/room/ignoreuser.yaml b/.maestro/tests/room/ignoreuser.yaml index f2ea05b2a37..90fe1d4b380 100644 --- a/.maestro/tests/room/ignoreuser.yaml +++ b/.maestro/tests/room/ignoreuser.yaml @@ -224,10 +224,10 @@ tags: timeout: 60000 - tapOn: id: 'report-user-view-submit' -- extendedWaitUntil: - visible: - id: 'room-view-title-${output.otherUser.username}' - timeout: 60000 +- runFlow: + file: '../../helpers/assert-room-title.yaml' + env: + ROOM: ${output.otherUser.username} # should go to user info view from a channel - runFlow: '../../helpers/go-back.yaml' @@ -281,7 +281,7 @@ tags: timeout: 60000 - tapOn: id: 'report-user-view-submit' -- extendedWaitUntil: - visible: - id: 'room-view-title-${output.room.name}' - timeout: 60000 +- runFlow: + file: '../../helpers/assert-room-title.yaml' + env: + ROOM: ${output.room.name} diff --git a/.maestro/tests/room/jump-to-message.yaml b/.maestro/tests/room/jump-to-message.yaml index e6cfb9dcd03..4c966f24015 100644 --- a/.maestro/tests/room/jump-to-message.yaml +++ b/.maestro/tests/room/jump-to-message.yaml @@ -85,12 +85,24 @@ tags: file: '../../helpers/search-and-navigate-room.yaml' env: ROOM: 'jumping' -- extendedWaitUntil: - visible: - id: 'room-view-search' - timeout: 60000 -- tapOn: - id: 'room-view-search' +- runFlow: + when: + platform: iOS + commands: + - tapOn: + text: 'More' + - tapOn: + text: 'Search messages' +- runFlow: + when: + platform: android + commands: + - extendedWaitUntil: + visible: + id: 'room-view-search' + timeout: 60000 + - tapOn: + id: 'room-view-search' - extendedWaitUntil: visible: id: 'search-messages-view' @@ -183,8 +195,7 @@ tags: visible: id: 'message-content-202' timeout: 60000 -- tapOn: - id: header-back +- runFlow: '../../helpers/go-back.yaml' # should navigate to a thread from another room - runFlow: @@ -208,14 +219,13 @@ tags: file: './utils/expect-thread-messages.yaml' env: message: Go to jumping-thread's thread -- tapOn: - id: header-back +- runFlow: '../../helpers/go-back.yaml' # should tap on thread message from main room -- extendedWaitUntil: - visible: - id: 'room-view-title-jumping-thread' - timeout: 60000 +- runFlow: + file: '../../helpers/assert-room-title.yaml' + env: + ROOM: 'jumping-thread' - extendedWaitUntil: visible: text: '.*thread message sent to main room.*' @@ -227,14 +237,13 @@ tags: file: './utils/expect-thread-messages.yaml' env: message: 'thread message sent to main room' -- tapOn: - id: header-back +- runFlow: '../../helpers/go-back.yaml' # should tap on quote -- extendedWaitUntil: - visible: - id: 'room-view-title-jumping-thread' - timeout: 60000 +- runFlow: + file: '../../helpers/assert-room-title.yaml' + env: + ROOM: 'jumping-thread' - extendedWaitUntil: visible: id: '.*reply-.*-quoted.*' @@ -252,16 +261,27 @@ tags: file: './utils/expect-thread-messages.yaml' env: message: 'quoted' -- tapOn: - id: 'header-back' +- runFlow: '../../helpers/go-back.yaml' # should jump from search message -- extendedWaitUntil: - visible: - id: 'room-view-title-jumping-thread' - timeout: 60000 -- tapOn: - id: 'room-view-search' +- runFlow: + file: '../../helpers/assert-room-title.yaml' + env: + ROOM: 'jumping-thread' +- runFlow: + when: + platform: iOS + commands: + - tapOn: + text: 'More' + - tapOn: + text: 'Search messages' +- runFlow: + when: + platform: android + commands: + - tapOn: + id: 'room-view-search' - extendedWaitUntil: visible: id: 'search-messages-view' diff --git a/.maestro/tests/room/message-markdown-click.yaml b/.maestro/tests/room/message-markdown-click.yaml index f0a746f2648..c6ea511cd20 100644 --- a/.maestro/tests/room/message-markdown-click.yaml +++ b/.maestro/tests/room/message-markdown-click.yaml @@ -57,14 +57,13 @@ tags: text: '.*detox-public.*' # Go back to room -- tapOn: - id: custom-header-back +- runFlow: '../../helpers/go-back.yaml' # Wait for room view to be visible again -- extendedWaitUntil: - visible: - id: 'room-view-title-maestro-message-clickable-test' - timeout: 60000 +- runFlow: + file: '../../helpers/assert-room-title.yaml' + env: + ROOM: maestro-message-clickable-test # Tap on the URL link to open alert - runFlow: @@ -137,61 +136,58 @@ tags: text: '.*e2e_admin.*' # Go back to room -- tapOn: - id: custom-header-back +- runFlow: '../../helpers/go-back.yaml' # Wait for room view to be visible again -- extendedWaitUntil: - visible: - id: 'room-view-title-maestro-message-clickable-test' - timeout: 60000 +- runFlow: + file: '../../helpers/assert-room-title.yaml' + env: + ROOM: maestro-message-clickable-test # Tap on message with thread to open thread room - tapOn: text: '.*Message with thread*.' # Verify thread room view is opened with the correct id -- extendedWaitUntil: - visible: - id: 'room-view-title-message with thread' - timeout: 60000 +- runFlow: + file: '../../helpers/assert-room-title.yaml' + env: + ROOM: message with thread # Go back to main room -- tapOn: - id: header-back +- runFlow: '../../helpers/go-back.yaml' # Wait for room view to be visible again -- extendedWaitUntil: - visible: - id: 'room-view-title-maestro-message-clickable-test' - timeout: 60000 +- runFlow: + file: '../../helpers/assert-room-title.yaml' + env: + ROOM: maestro-message-clickable-test # Tap on "View thread" button - tapOn: id: 'message-thread-button-message with thread' # Verify thread room view is opened -- extendedWaitUntil: - visible: - id: 'room-view-title-message with thread' - timeout: 60000 +- runFlow: + file: '../../helpers/assert-room-title.yaml' + env: + ROOM: message with thread # Go back to main room -- tapOn: - id: header-back +- runFlow: '../../helpers/go-back.yaml' # Wait for room view to be visible again -- extendedWaitUntil: - visible: - id: 'room-view-title-maestro-message-clickable-test' - timeout: 60000 +- runFlow: + file: '../../helpers/assert-room-title.yaml' + env: + ROOM: maestro-message-clickable-test # Now tap on a message within the thread - tapOn: text: '.*a message in thread*.' # Verify thread room view remains (clicking message in thread stays in thread) -- extendedWaitUntil: - visible: - id: 'room-view-title-message with thread' - timeout: 60000 +- runFlow: + file: '../../helpers/assert-room-title.yaml' + env: + ROOM: message with thread diff --git a/.maestro/tests/room/room-actions.yaml b/.maestro/tests/room/room-actions.yaml index 52a1d7332a9..cb0110a1a34 100644 --- a/.maestro/tests/room/room-actions.yaml +++ b/.maestro/tests/room/room-actions.yaml @@ -93,14 +93,12 @@ tags: visible: id: 'mentioned-messages-view' timeout: 60000 -- tapOn: - id: custom-header-back +- runFlow: '../../helpers/go-back.yaml' - extendedWaitUntil: visible: id: 'room-actions-view' timeout: 60000 -- tapOn: - id: custom-header-back +- runFlow: '../../helpers/go-back.yaml' # should star and show in starred message - runFlow: @@ -131,12 +129,7 @@ tags: notVisible: text: '.*Message Starred.*' timeout: 60000 -- tapOn: - id: 'room-header' -- extendedWaitUntil: - visible: - id: 'room-actions-view' - timeout: 60000 +- runFlow: '../../helpers/open-room-actions.yaml' - tapOn: id: 'room-actions-starred' retryTapIfNoChange: true @@ -149,14 +142,12 @@ tags: id: 'message-content-messageToStar' timeout: 60000 # should unstar -- tapOn: - id: custom-header-back +- runFlow: '../../helpers/go-back.yaml' - extendedWaitUntil: visible: id: 'room-actions-view' timeout: 60000 -- tapOn: - id: custom-header-back +- runFlow: '../../helpers/go-back.yaml' - extendedWaitUntil: visible: id: message-content-messageToStar @@ -181,12 +172,7 @@ tags: notVisible: text: '.*Message Unstarred.*' timeout: 60000 -- tapOn: - id: 'room-header' -- extendedWaitUntil: - visible: - id: 'room-actions-view' - timeout: 60000 +- runFlow: '../../helpers/open-room-actions.yaml' - tapOn: id: 'room-actions-starred' retryTapIfNoChange: true @@ -198,14 +184,12 @@ tags: notVisible: id: 'message-content-messageToStar' timeout: 60000 -- tapOn: - id: custom-header-back +- runFlow: '../../helpers/go-back.yaml' - extendedWaitUntil: visible: id: 'room-actions-view' timeout: 60000 -- tapOn: - id: custom-header-back +- runFlow: '../../helpers/go-back.yaml' # should pin a message and show in pinned - runFlow: @@ -236,12 +220,7 @@ tags: visible: text: '.*Pinned a message:.*' timeout: 60000 -- tapOn: - id: 'room-header' -- extendedWaitUntil: - visible: - id: 'room-actions-view' - timeout: 60000 +- runFlow: '../../helpers/open-room-actions.yaml' - tapOn: Pinned - extendedWaitUntil: visible: @@ -261,8 +240,7 @@ tags: notVisible: id: 'message-content-messageToPin' timeout: 60000 -- tapOn: - id: 'custom-header-back' +- runFlow: '../../helpers/go-back.yaml' - extendedWaitUntil: visible: id: 'room-actions-view' @@ -324,8 +302,7 @@ tags: id: 'notification-preference-view-email-alert' timeout: 60000 -- tapOn: - id: custom-header-back +- runFlow: '../../helpers/go-back.yaml' - extendedWaitUntil: visible: id: 'room-actions-view' @@ -387,12 +364,10 @@ tags: visible: id: 'selected-user-rocket.cat' timeout: 60000 -- extendedWaitUntil: - visible: - id: 'select-users-view-search' - timeout: 60000 -- tapOn: - id: select-users-view-search +- runFlow: + file: '../../helpers/tap-search-field.yaml' + env: + SEARCH_ID: 'select-users-view-search' - inputText: ${output.otherUser.username} - extendedWaitUntil: visible: @@ -404,10 +379,12 @@ tags: visible: id: 'selected-user-${output.otherUser.username}' timeout: 60000 -- tapOn: - id: selected-users-view-submit -- tapOn: - id: custom-header-back +- runFlow: + file: '../../helpers/tap-header-item.yaml' + env: + ANDROID_ID: 'selected-users-view-submit' + IOS_LABEL: 'Next' +- runFlow: '../../helpers/go-back.yaml' - extendedWaitUntil: visible: text: '.*3 members.*' @@ -420,18 +397,12 @@ tags: visible: id: 'room-members-view' timeout: 60000 -- extendedWaitUntil: - visible: - id: 'room-members-view-filter' - timeout: 60000 -- 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' +- runFlow: + file: '../../helpers/select-header-filter.yaml' + env: + ANDROID_FILTER_ID: 'room-members-view-filter' + ANDROID_OPTION_ID: 'room-members-view-toggle-status-all' + IOS_OPTION: 'All' - extendedWaitUntil: notVisible: id: 'room-members-view-toggle-status-all' @@ -440,8 +411,7 @@ tags: visible: id: 'room-members-view-item-${output.otherUser.username}' timeout: 60000 -- tapOn: - id: 'custom-header-back' +- runFlow: '../../helpers/go-back.yaml' # should filter user - tapOn: @@ -450,12 +420,10 @@ tags: visible: id: 'room-members-view' timeout: 60000 -- extendedWaitUntil: - visible: - id: 'room-members-view-search' - timeout: 60000 -- tapOn: - id: room-members-view-search +- runFlow: + file: '../../helpers/tap-search-field.yaml' + env: + SEARCH_ID: 'room-members-view-search' - inputText: 'rocket' - tapOn: id: room-members-view @@ -467,8 +435,7 @@ tags: notVisible: id: 'room-members-view-item-${output.otherUser.username}' timeout: 60000 -- tapOn: - id: clear-text-input +- runFlow: '../../helpers/clear-search-field.yaml' - extendedWaitUntil: visible: id: 'room-members-view-item-rocket.cat' @@ -697,14 +664,12 @@ tags: visible: text: 'User has been ignored' timeout: 60000 -- tapOn: - id: custom-header-back +- runFlow: '../../helpers/go-back.yaml' - extendedWaitUntil: visible: id: 'room-actions-view' timeout: 60000 -- tapOn: - id: custom-header-back +- runFlow: '../../helpers/go-back.yaml' - extendedWaitUntil: visible: text: '.*Message ignored. Tap to display it.*' @@ -717,12 +682,7 @@ tags: timeout: 60000 # should navigate to direct message -- tapOn: - id: 'room-header' -- extendedWaitUntil: - visible: - id: 'room-actions-view' - timeout: 60000 +- runFlow: '../../helpers/open-room-actions.yaml' - tapOn: id: 'room-actions-members' - extendedWaitUntil: @@ -745,10 +705,10 @@ tags: visible: id: 'room-view' timeout: 60000 -- extendedWaitUntil: - visible: - id: 'room-view-title-${output.otherUser.username}' - timeout: 60000 +- runFlow: + file: '../../helpers/assert-room-title.yaml' + env: + ROOM: ${output.otherUser.username} - tapOn: Back - extendedWaitUntil: visible: diff --git a/.maestro/tests/room/room-info.yaml b/.maestro/tests/room/room-info.yaml index 7859a2a7fa1..0078cc1f46f 100644 --- a/.maestro/tests/room/room-info.yaml +++ b/.maestro/tests/room/room-info.yaml @@ -141,8 +141,7 @@ tags: timeout: 60000 # should reset form -- tapOn: - id: custom-header-back +- runFlow: '../../helpers/go-back.yaml' - tapOn: id: room-info-view-edit-button - extendedWaitUntil: @@ -278,8 +277,7 @@ tags: id: 'room-info-edit-view-react-when-ro' # should change room name -- tapOn: - id: custom-header-back +- runFlow: '../../helpers/go-back.yaml' - tapOn: id: room-info-view-edit-button - evalScript: ${output.newroomname = output.room.name + 'new'} @@ -305,8 +303,7 @@ tags: visible: text: '.*Settings succesfully changed.*' timeout: 60000 -- tapOn: - id: custom-header-back +- runFlow: '../../helpers/go-back.yaml' - extendedWaitUntil: visible: id: 'room-info-view' @@ -367,8 +364,7 @@ tags: visible: text: '.*Settings succesfully changed.*' timeout: 60000 -- tapOn: - id: custom-header-back +- runFlow: '../../helpers/go-back.yaml' - extendedWaitUntil: visible: id: 'room-info-view' diff --git a/.maestro/tests/room/room.yaml b/.maestro/tests/room/room.yaml index a719d7f004c..092470c6269 100644 --- a/.maestro/tests/room/room.yaml +++ b/.maestro/tests/room/room.yaml @@ -483,10 +483,10 @@ tags: timeout: 60000 - tapOn: id: message-actions-reply-in-dm -- extendedWaitUntil: - visible: - id: room-view-title-${output.replyUser.username} - timeout: 60000 +- runFlow: + file: '../../helpers/assert-room-title.yaml' + env: + ROOM: ${output.replyUser.username} - extendedWaitUntil: visible: id: message-composer-input diff --git a/.maestro/tests/room/search-member.yaml b/.maestro/tests/room/search-member.yaml index 40c7e07a35f..9fe42658adc 100644 --- a/.maestro/tests/room/search-member.yaml +++ b/.maestro/tests/room/search-member.yaml @@ -20,22 +20,15 @@ tags: file: '../../helpers/navigate-to-room.yaml' env: ROOM: 'general' -- tapOn: - id: room-header -- extendedWaitUntil: - visible: - id: 'room-actions-view' - timeout: 60000 +- runFlow: '../../helpers/open-room-actions.yaml' - 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 +- runFlow: + file: '../../helpers/tap-search-field.yaml' + env: + SEARCH_ID: 'room-members-view-search' - inputText: rohit.bansal - extendedWaitUntil: visible: @@ -43,40 +36,37 @@ tags: 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 +- runFlow: + file: '../../helpers/select-header-filter.yaml' + env: + ANDROID_FILTER_ID: 'room-members-view-filter' + ANDROID_OPTION_ID: 'room-members-view-toggle-status-online' + IOS_OPTION: '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 +- runFlow: + file: '../../helpers/select-header-filter.yaml' + env: + ANDROID_FILTER_ID: 'room-members-view-filter' + ANDROID_OPTION_ID: 'room-members-view-toggle-status-all' + IOS_OPTION: 'All' - extendedWaitUntil: visible: id: 'room-members-view-item-rohit.bansal' timeout: 60000 -- tapOn: - id: clear-text-input +- runFlow: '../../helpers/clear-search-field.yaml' - evalScript: ${output.secondUser = output.utils.createUser()} # should search for new user in all list -- tapOn: - id: room-members-view-search +- runFlow: + file: '../../helpers/tap-search-field.yaml' + env: + SEARCH_ID: 'room-members-view-search' - inputText: ${output.secondUser.username} - extendedWaitUntil: visible: @@ -84,8 +74,10 @@ tags: timeout: 60000 # Verify "No members found" message appears correctly when search returns no results -- tapOn: - id: room-members-view-search +- runFlow: + file: '../../helpers/tap-search-field.yaml' + env: + SEARCH_ID: 'room-members-view-search' - inputText: nonexistentuser12345 - extendedWaitUntil: visible: diff --git a/.maestro/tests/room/servers-list-small-screen.yaml b/.maestro/tests/room/servers-list-small-screen.yaml index 685822b104b..ed2c7216818 100644 --- a/.maestro/tests/room/servers-list-small-screen.yaml +++ b/.maestro/tests/room/servers-list-small-screen.yaml @@ -20,12 +20,7 @@ tags: # should fully render the Workspaces action sheet on small screens # (regression for small-screen ServersList — the Add Server button was cut off on iPhone SE) -- extendedWaitUntil: - visible: - id: 'rooms-list-header-servers-list-button' - timeout: 60000 -- tapOn: - id: 'rooms-list-header-servers-list-button' +- runFlow: '../../helpers/open-servers-list.yaml' - waitForAnimationToEnd - extendedWaitUntil: visible: diff --git a/.maestro/tests/room/share-message.yaml b/.maestro/tests/room/share-message.yaml index 461818f2f37..0448192fe26 100644 --- a/.maestro/tests/room/share-message.yaml +++ b/.maestro/tests/room/share-message.yaml @@ -89,10 +89,10 @@ tags: timeout: 60000 - tapOn: id: 'forward-message-view-send' -- extendedWaitUntil: - visible: - id: 'room-view-title-${output.room.name}' - timeout: 60000 +- runFlow: + file: '../../helpers/assert-room-title.yaml' + env: + ROOM: ${output.room.name} # should go to otherUser DM and verify if exist both messages - runFlow: '../../helpers/go-back.yaml' diff --git a/.maestro/tests/room/threads.yaml b/.maestro/tests/room/threads.yaml index f45f17151b4..181e690beda 100644 --- a/.maestro/tests/room/threads.yaml +++ b/.maestro/tests/room/threads.yaml @@ -23,22 +23,46 @@ tags: ROOM: ${output.room.name} # should have room screen -- extendedWaitUntil: - visible: - id: 'room-view-title-${output.room.name}' - timeout: 60000 +- runFlow: + file: '../../helpers/assert-room-title.yaml' + env: + ROOM: ${output.room.name} # should have actions button -- extendedWaitUntil: - visible: - id: 'room-header' - timeout: 60000 +- runFlow: + when: + platform: android + commands: + - extendedWaitUntil: + visible: + id: 'room-header' + timeout: 60000 +- runFlow: + when: + platform: iOS + commands: + - extendedWaitUntil: + visible: + text: 'More' + timeout: 60000 # should have threads button -- extendedWaitUntil: - visible: - id: 'room-view-header-threads' - timeout: 60000 +- runFlow: + when: + platform: android + commands: + - extendedWaitUntil: + visible: + id: 'room-view-header-threads' + timeout: 60000 +- runFlow: + when: + platform: iOS + commands: + - extendedWaitUntil: + visible: + text: 'Threads' + timeout: 60000 # should create thread - runFlow: @@ -61,10 +85,10 @@ tags: timeout: 60000 - tapOn: text: 'Reply in thread' -- extendedWaitUntil: - visible: - id: 'room-view-title-thread' - timeout: 60000 +- runFlow: + file: '../../helpers/assert-room-title.yaml' + env: + ROOM: 'thread' - extendedWaitUntil: visible: id: 'message-composer-input-thread' @@ -78,41 +102,50 @@ tags: visible: id: 'message-content-replied' timeout: 60000 -- tapOn: - id: 'header-back' +- runFlow: '../../helpers/go-back.yaml' # should navigate to thread from button -- extendedWaitUntil: - visible: - id: 'room-view-title-${output.room.name}' - timeout: 60000 +- runFlow: + file: '../../helpers/assert-room-title.yaml' + env: + ROOM: ${output.room.name} - extendedWaitUntil: visible: id: 'message-thread-button-thread' timeout: 60000 - tapOn: id: 'message-thread-button-thread' -- extendedWaitUntil: - visible: - id: 'room-view-title-thread' - timeout: 60000 +- runFlow: + file: '../../helpers/assert-room-title.yaml' + env: + ROOM: 'thread' # should toggle follow thread -- extendedWaitUntil: - visible: - id: 'room-view-header-unfollow' - timeout: 60000 -- tapOn: - id: 'room-view-header-unfollow' -- extendedWaitUntil: - visible: - id: 'room-view-header-follow' - timeout: 60000 -- tapOn: - id: 'room-view-header-follow' -- extendedWaitUntil: - visible: - id: 'room-view-header-unfollow' - timeout: 60000 +- runFlow: + file: '../../helpers/tap-header-item.yaml' + env: + ANDROID_ID: 'room-view-header-unfollow' + IOS_LABEL: 'Unfollow thread' +- runFlow: + file: '../../helpers/tap-header-item.yaml' + env: + ANDROID_ID: 'room-view-header-follow' + IOS_LABEL: 'Follow thread' +- runFlow: + when: + platform: android + commands: + - extendedWaitUntil: + visible: + id: 'room-view-header-unfollow' + timeout: 60000 +- runFlow: + when: + platform: iOS + commands: + - extendedWaitUntil: + visible: + text: 'Unfollow thread' + timeout: 60000 # should send message in thread only - runFlow: @@ -120,16 +153,27 @@ tags: env: message: threadonly isThread: 'true' -- tapOn: - id: 'header-back' -- extendedWaitUntil: - notVisible: - id: 'room-view-title-thread' - timeout: 5000 -- extendedWaitUntil: - visible: - id: 'room-view-title-${output.room.name}' - timeout: 5000 +- runFlow: '../../helpers/go-back.yaml' +- runFlow: + when: + platform: android + commands: + - extendedWaitUntil: + notVisible: + id: 'room-view-title-thread' + timeout: 5000 +- runFlow: + when: + platform: iOS + commands: + - extendedWaitUntil: + notVisible: + text: 'thread' + timeout: 5000 +- runFlow: + file: '../../helpers/assert-room-title.yaml' + env: + ROOM: ${output.room.name} - extendedWaitUntil: notVisible: id: 'message-content-threadonly' @@ -142,10 +186,10 @@ tags: timeout: 60000 - tapOn: id: 'message-thread-button-thread' -- extendedWaitUntil: - visible: - id: 'room-view-title-thread' - timeout: 60000 +- runFlow: + file: '../../helpers/assert-room-title.yaml' + env: + ROOM: 'thread' - tapOn: id: 'message-composer-input-thread' - inputText: 'sendToChannel' @@ -153,16 +197,27 @@ tags: id: send-to-channel-unchecked - tapOn: id: message-composer-send -- tapOn: - id: header-back -- extendedWaitUntil: - notVisible: - id: 'room-view-title-thread' - timeout: 5000 -- extendedWaitUntil: - visible: - id: 'room-view-title-${output.room.name}' - timeout: 5000 +- runFlow: '../../helpers/go-back.yaml' +- runFlow: + when: + platform: android + commands: + - extendedWaitUntil: + notVisible: + id: 'room-view-title-thread' + timeout: 5000 +- runFlow: + when: + platform: iOS + commands: + - extendedWaitUntil: + notVisible: + text: 'thread' + timeout: 5000 +- runFlow: + file: '../../helpers/assert-room-title.yaml' + env: + ROOM: ${output.room.name} - extendedWaitUntil: visible: text: '.*sendToChannel.*' @@ -186,36 +241,45 @@ tags: id: 'message-composer-send-to-channel' - tapOn: id: 'message-composer-send' -- tapOn: - id: 'header-back' -- extendedWaitUntil: - notVisible: - id: 'room-view-title-thread' - timeout: 60000 -- extendedWaitUntil: - visible: - id: 'room-view-title-${output.room.name}' - timeout: 60000 +- runFlow: '../../helpers/go-back.yaml' +- runFlow: + when: + platform: android + commands: + - extendedWaitUntil: + notVisible: + id: 'room-view-title-thread' + timeout: 60000 +- runFlow: + when: + platform: iOS + commands: + - extendedWaitUntil: + notVisible: + text: 'thread' + timeout: 60000 +- runFlow: + file: '../../helpers/assert-room-title.yaml' + env: + ROOM: ${output.room.name} - extendedWaitUntil: visible: id: 'message-content-navthreadname' timeout: 60000 - tapOn: id: 'message-content-navthreadname' -- extendedWaitUntil: - visible: - id: 'room-view-title-thread' - timeout: 60000 -- tapOn: - id: 'header-back' +- runFlow: + file: '../../helpers/assert-room-title.yaml' + env: + ROOM: 'thread' +- runFlow: '../../helpers/go-back.yaml' # should navigate to thread from threads view -- extendedWaitUntil: - visible: - id: 'room-view-header-threads' - timeout: 60000 -- tapOn: - id: 'room-view-header-threads' +- runFlow: + file: '../../helpers/tap-header-item.yaml' + env: + ANDROID_ID: 'room-view-header-threads' + IOS_LABEL: 'Threads' - extendedWaitUntil: visible: id: 'thread-messages-view' @@ -226,12 +290,11 @@ tags: timeout: 60000 - tapOn: id: 'thread-messages-view-thread' -- extendedWaitUntil: - visible: - id: 'room-view-title-thread' - timeout: 60000 -- tapOn: - id: 'header-back' +- runFlow: + file: '../../helpers/assert-room-title.yaml' + env: + ROOM: 'thread' +- runFlow: '../../helpers/go-back.yaml' - extendedWaitUntil: visible: id: 'room-view-messages' @@ -244,15 +307,14 @@ tags: timeout: 60000 - tapOn: id: 'message-thread-button-thread' -- extendedWaitUntil: - visible: - id: 'room-view-title-thread' - timeout: 60000 +- runFlow: + file: '../../helpers/assert-room-title.yaml' + env: + ROOM: 'thread' - tapOn: id: 'message-composer-input-thread' - inputText: 'draftthread' -- tapOn: - id: 'header-back' +- runFlow: '../../helpers/go-back.yaml' - extendedWaitUntil: visible: @@ -260,10 +322,10 @@ tags: timeout: 60000 - tapOn: id: 'message-thread-button-thread' -- extendedWaitUntil: - visible: - id: 'room-view-title-thread' - timeout: 60000 +- runFlow: + file: '../../helpers/assert-room-title.yaml' + env: + ROOM: 'thread' - extendedWaitUntil: visible: text: '.*draftthread.*' @@ -271,8 +333,7 @@ tags: - tapOn: id: 'message-composer-input-thread' - eraseText -- tapOn: - id: 'header-back' +- runFlow: '../../helpers/go-back.yaml' - extendedWaitUntil: visible: @@ -280,10 +341,10 @@ tags: timeout: 60000 - tapOn: id: 'message-thread-button-thread' -- extendedWaitUntil: - visible: - id: 'room-view-title-thread' - timeout: 60000 +- runFlow: + file: '../../helpers/assert-room-title.yaml' + env: + ROOM: 'thread' - extendedWaitUntil: notVisible: text: '.*draftthread.*' @@ -292,16 +353,27 @@ tags: visible: text: '.*Add thread reply.*' timeout: 60000 -- tapOn: - id: 'header-back' -- extendedWaitUntil: - notVisible: - id: 'room-view-title-thread' - timeout: 60000 -- extendedWaitUntil: - visible: - id: 'room-view-title-${output.room.name}' - timeout: 60000 +- runFlow: '../../helpers/go-back.yaml' +- runFlow: + when: + platform: android + commands: + - extendedWaitUntil: + notVisible: + id: 'room-view-title-thread' + timeout: 60000 +- runFlow: + when: + platform: iOS + commands: + - extendedWaitUntil: + notVisible: + text: 'thread' + timeout: 60000 +- runFlow: + file: '../../helpers/assert-room-title.yaml' + env: + ROOM: ${output.room.name} # should create thread delete the message and the thread show the correct number of messages - runFlow: @@ -324,10 +396,10 @@ tags: timeout: 60000 - tapOn: text: 'Reply in thread' -- extendedWaitUntil: - visible: - id: 'room-view-title-thread-message-count' - timeout: 60000 +- runFlow: + file: '../../helpers/assert-room-title.yaml' + env: + ROOM: 'thread-message-count' - extendedWaitUntil: visible: id: 'message-composer-input-thread' @@ -374,12 +446,11 @@ tags: text: '.*You will not be able to recover this message.*' timeout: 60000 - tapOn: 'DELETE' -- tapOn: - id: 'header-back' -- extendedWaitUntil: - visible: - id: 'room-view-title-${output.room.name}' - timeout: 60000 +- runFlow: '../../helpers/go-back.yaml' +- runFlow: + file: '../../helpers/assert-room-title.yaml' + env: + ROOM: ${output.room.name} - extendedWaitUntil: visible: id: 'thread-count-0' diff --git a/.maestro/tests/room/unread-badge.yaml b/.maestro/tests/room/unread-badge.yaml index c52cfff178b..f0054edfa1b 100644 --- a/.maestro/tests/room/unread-badge.yaml +++ b/.maestro/tests/room/unread-badge.yaml @@ -34,9 +34,7 @@ tags: timeout: 60000 - tapOn: id: 'rooms-list-view-item-${output.otherUser.username}' -- tapOn: - id: header-back - retryTapIfNoChange: true +- runFlow: '../../helpers/go-back.yaml' - waitForAnimationToEnd: timeout: 5000 - extendedWaitUntil: @@ -62,9 +60,7 @@ tags: timeout: 60000 - tapOn: id: 'rooms-list-view-item-${output.otherUser.username}' -- tapOn: - id: header-back - retryTapIfNoChange: true +- runFlow: '../../helpers/go-back.yaml' - waitForAnimationToEnd: timeout: 5000 - extendedWaitUntil: @@ -91,9 +87,7 @@ tags: timeout: 60000 - tapOn: id: 'rooms-list-view-item-${output.otherUser.username}' -- tapOn: - id: header-back - retryTapIfNoChange: true +- runFlow: '../../helpers/go-back.yaml' - waitForAnimationToEnd: timeout: 5000 - extendedWaitUntil: @@ -120,9 +114,7 @@ tags: timeout: 60000 - tapOn: id: 'rooms-list-view-item-${output.otherUser.username}' -- tapOn: - id: header-back - retryTapIfNoChange: true +- runFlow: '../../helpers/go-back.yaml' - waitForAnimationToEnd: timeout: 5000 - extendedWaitUntil: diff --git a/.maestro/tests/room/utils/clear-cache.yaml b/.maestro/tests/room/utils/clear-cache.yaml index bf087ac3e01..d54f70f408a 100644 --- a/.maestro/tests/room/utils/clear-cache.yaml +++ b/.maestro/tests/room/utils/clear-cache.yaml @@ -8,14 +8,16 @@ tags: visible: id: 'room-view' timeout: 60000 -- tapOn: - id: 'header-back' +- runFlow: '../../../helpers/go-back.yaml' - extendedWaitUntil: visible: id: 'rooms-list-view' timeout: 60000 -- tapOn: - id: 'rooms-list-view-sidebar' +- runFlow: + file: '../../../helpers/tap-header-item.yaml' + env: + ANDROID_ID: 'rooms-list-view-sidebar' + IOS_LABEL: 'Menu' - waitForAnimationToEnd: timeout: 60000 - extendedWaitUntil: diff --git a/.maestro/tests/room/utils/expect-thread-messages.yaml b/.maestro/tests/room/utils/expect-thread-messages.yaml index 5f3fbc3ae44..ec204c19e36 100644 --- a/.maestro/tests/room/utils/expect-thread-messages.yaml +++ b/.maestro/tests/room/utils/expect-thread-messages.yaml @@ -4,10 +4,10 @@ tags: - 'util' --- -- extendedWaitUntil: - visible: - id: 'room-view-title-thread 1' - timeout: 60000 +- runFlow: + file: '../../../helpers/assert-room-title.yaml' + env: + ROOM: 'thread 1' - extendedWaitUntil: visible: id: '.*message-content-${message}.*' diff --git a/.maestro/tests/room/utils/navigate-to-info-view.yaml b/.maestro/tests/room/utils/navigate-to-info-view.yaml index c2b33f0a718..71308b4d3dd 100644 --- a/.maestro/tests/room/utils/navigate-to-info-view.yaml +++ b/.maestro/tests/room/utils/navigate-to-info-view.yaml @@ -4,16 +4,7 @@ tags: - 'util' --- -- extendedWaitUntil: - visible: - id: room-header - timeout: 60000 -- tapOn: - id: room-header -- extendedWaitUntil: - visible: - id: room-actions-view - timeout: 60000 +- runFlow: '../../../helpers/open-room-actions.yaml' - assertVisible: id: room-actions-info - tapOn: diff --git a/.maestro/tests/room/utils/navigate-to-room-info.yaml b/.maestro/tests/room/utils/navigate-to-room-info.yaml index b440852dcbe..4c419f0c9d2 100644 --- a/.maestro/tests/room/utils/navigate-to-room-info.yaml +++ b/.maestro/tests/room/utils/navigate-to-room-info.yaml @@ -8,16 +8,7 @@ tags: file: '../../../helpers/search-and-navigate-room.yaml' env: ROOM: ${ROOM} -- extendedWaitUntil: - visible: - id: 'room-header' - timeout: 60000 -- tapOn: - id: 'room-header' -- extendedWaitUntil: - visible: - id: 'room-actions-view' - timeout: 60000 +- runFlow: '../../../helpers/open-room-actions.yaml' - extendedWaitUntil: visible: id: 'room-actions-info' diff --git a/.maestro/tests/teams/convert-team.yaml b/.maestro/tests/teams/convert-team.yaml index 9820ad29154..0442b800a1b 100644 --- a/.maestro/tests/teams/convert-team.yaml +++ b/.maestro/tests/teams/convert-team.yaml @@ -42,10 +42,10 @@ tags: text: 'Convert' - tapOn: text: 'Convert' -- extendedWaitUntil: - visible: - id: 'room-view-title-${output.publicChannelToBeConverted}' - timeout: 60000 +- runFlow: + file: '../../helpers/assert-room-title.yaml' + env: + ROOM: ${output.publicChannelToBeConverted} - runFlow: '../../helpers/go-back.yaml' - extendedWaitUntil: visible: @@ -74,10 +74,10 @@ tags: text: 'Convert' - tapOn: text: 'Convert' -- extendedWaitUntil: - visible: - id: 'room-view-title-${output.toBeConverted}' - timeout: 60000 +- runFlow: + file: '../../helpers/assert-room-title.yaml' + env: + ROOM: ${output.toBeConverted} - runFlow: '../../helpers/go-back.yaml' - extendedWaitUntil: visible: @@ -124,12 +124,7 @@ tags: text: 'Yes, move it!' - tapOn: text: 'Yes, move it!' -- extendedWaitUntil: - visible: - id: 'room-header' - timeout: 60000 -- tapOn: - id: 'room-header' +- runFlow: '../../helpers/open-room-actions.yaml' - extendedWaitUntil: visible: id: 'room-actions-teams' @@ -178,10 +173,10 @@ tags: visible: id: 'room-view' timeout: 60000 -- extendedWaitUntil: - visible: - id: 'room-view-title-${output.toBeConverted}' - timeout: 60000 +- runFlow: + file: '../../helpers/assert-room-title.yaml' + env: + ROOM: ${output.toBeConverted} - runFlow: '../../helpers/go-back.yaml' - extendedWaitUntil: visible: diff --git a/.maestro/tests/teams/create-team.yaml b/.maestro/tests/teams/create-team.yaml index a587cd06daf..1567dfdbab0 100644 --- a/.maestro/tests/teams/create-team.yaml +++ b/.maestro/tests/teams/create-team.yaml @@ -17,18 +17,22 @@ tags: USERNAME: ${output.user.username} PASSWORD: ${output.user.password} -- assertVisible: - id: 'rooms-list-view-create-channel' -- tapOn: - id: 'rooms-list-view-create-channel' +- runFlow: + file: '../../helpers/tap-header-item.yaml' + env: + ANDROID_ID: 'rooms-list-view-create-channel' + IOS_LABEL: 'Create new channel, team, direct message or discussion' - assertVisible: id: 'new-message-view-create-team' - tapOn: id: 'new-message-view-create-team' - assertVisible: id: 'select-users-view' -- tapOn: - id: 'selected-users-view-submit' +- runFlow: + file: '../../helpers/tap-header-item.yaml' + env: + ANDROID_ID: 'selected-users-view-submit' + IOS_LABEL: 'Skip' - assertVisible: id: 'create-channel-view' - tapOn: @@ -39,10 +43,11 @@ tags: id: 'create-channel-submit' - assertVisible: id: 'room-view-messages' -- assertVisible: - id: 'room-view-title-${output.teamname}' -- tapOn: - id: 'room-header' +- runFlow: + file: '../../helpers/assert-room-title.yaml' + env: + ROOM: ${output.teamname} +- runFlow: '../../helpers/open-room-actions.yaml' - assertVisible: id: 'room-actions-info' - tapOn: diff --git a/.maestro/tests/teams/team.yaml b/.maestro/tests/teams/team.yaml index 91274b1131c..650b443a944 100644 --- a/.maestro/tests/teams/team.yaml +++ b/.maestro/tests/teams/team.yaml @@ -25,18 +25,39 @@ tags: ROOM: ${output.team} # Team Header -- assertVisible: - id: room-header -- assertVisible: - id: room-view-header-call -- assertVisible: - id: room-view-header-threads -- assertVisible: - id: room-view-search +- runFlow: + when: + platform: android + commands: + - assertVisible: + id: room-header + - assertVisible: + id: room-view-search + - assertVisible: + id: room-view-header-call + - assertVisible: + id: room-view-header-threads +- runFlow: + when: + platform: iOS + commands: + - assertVisible: + text: '.*Call.*' + - assertVisible: + text: 'Threads' + - tapOn: + text: 'More' + - extendedWaitUntil: + visible: + id: 'action-sheet' + timeout: 60000 + - assertVisible: + text: 'Search messages' + - tapOn: + id: 'action-sheet-handle' # Team Action Usage -- tapOn: - id: room-header +- runFlow: '../../helpers/open-room-actions.yaml' - tapOn: id: room-actions-teams - extendedWaitUntil: @@ -46,19 +67,48 @@ tags: label: Should navigate to team channels view # Team Channels Header -- assertVisible: - id: room-header - label: Should have actions button -- assertVisible: - id: team-channels-view-create - label: Should have team channels button -- assertVisible: - id: team-channels-view-search - label: Should have search button +- runFlow: + when: + platform: android + commands: + - assertVisible: + id: room-header + label: Should have actions button +- runFlow: + when: + platform: android + commands: + - assertVisible: + id: team-channels-view-create + label: Should have team channels button +- runFlow: + when: + platform: iOS + commands: + - assertVisible: + text: 'Add channel to team' + label: Should have team channels button +- runFlow: + when: + platform: iOS + commands: + - assertVisible: + id: magnifyingglass + label: Should have system search bar +- runFlow: + when: + platform: android + commands: + - assertVisible: + id: team-channels-view-search + label: Should have search button # Team Channels Header Usage -- tapOn: - id: team-channels-view-create +- runFlow: + file: '../../helpers/tap-header-item.yaml' + env: + ANDROID_ID: 'team-channels-view-create' + IOS_LABEL: 'Add channel to team' - extendedWaitUntil: visible: id: add-channel-team-view @@ -78,13 +128,11 @@ tags: # Create new channel for team - tapOn: id: add-channel-team-view-create-channel -- assertVisible: - id: select-users-view-search -- tapOn: - id: select-users-view-search -- inputText: - id: select-users-view-search - text: rocket.cat +- runFlow: + file: '../../helpers/tap-search-field.yaml' + env: + SEARCH_ID: 'select-users-view-search' +- inputText: rocket.cat - extendedWaitUntil: visible: id: select-users-view-item-rocket.cat @@ -95,8 +143,11 @@ tags: visible: id: selected-user-rocket.cat timeout: 60000 -- tapOn: - id: selected-users-view-submit +- runFlow: + file: '../../helpers/tap-header-item.yaml' + env: + ANDROID_ID: 'selected-users-view-submit' + IOS_LABEL: 'Next' - extendedWaitUntil: visible: id: create-channel-view @@ -119,10 +170,19 @@ tags: id: room-view timeout: 60000 label: should navigate to room view -- assertVisible: - id: room-header -- tapOn: - id: room-header +- runFlow: + when: + platform: android + commands: + - assertVisible: + id: room-header +- runFlow: + when: + platform: iOS + commands: + - assertVisible: + text: 'More' +- runFlow: '../../helpers/open-room-actions.yaml' - assertVisible: id: room-actions-teams - tapOn: @@ -141,18 +201,42 @@ tags: id: rooms-list-view-item-${output.privateRoomName} - tapOn: id: rooms-list-view-item-${output.privateRoomName} -- extendedWaitUntil: - visible: - id: room-view-title-${output.privateRoomName} - timeout: 60000 -- assertVisible: - id: room-view-title-${output.privateRoomName} -- assertVisible: - id: room-view-header-call -- assertVisible: - id: room-view-header-threads -- assertVisible: - id: room-view-search +- runFlow: + file: '../../helpers/assert-room-title.yaml' + env: + ROOM: ${output.privateRoomName} +- runFlow: + file: '../../helpers/assert-room-title.yaml' + env: + ROOM: ${output.privateRoomName} +- runFlow: + when: + platform: android + commands: + - assertVisible: + id: room-view-header-call + - assertVisible: + id: room-view-header-threads + - assertVisible: + id: room-view-search +- runFlow: + when: + platform: iOS + commands: + - assertVisible: + text: '.*Call.*' + - assertVisible: + text: 'Threads' + - tapOn: + text: 'More' + - extendedWaitUntil: + visible: + id: 'action-sheet' + timeout: 60000 + - assertVisible: + text: 'Search messages' + - tapOn: + id: 'action-sheet-handle' - runFlow: '../../helpers/go-back.yaml' # should add existing channel to team @@ -160,10 +244,19 @@ tags: file: '../../helpers/navigate-to-room.yaml' env: ROOM: ${output.team} -- assertVisible: - id: room-header -- tapOn: - id: room-header +- runFlow: + when: + platform: android + commands: + - assertVisible: + id: room-header +- runFlow: + when: + platform: iOS + commands: + - assertVisible: + text: 'More' +- runFlow: '../../helpers/open-room-actions.yaml' - assertVisible: id: room-actions-teams - tapOn: @@ -173,8 +266,11 @@ tags: id: team-channels-view timeout: 60000 label: Should navigate to team channels view -- tapOn: - id: team-channels-view-create +- runFlow: + file: '../../helpers/tap-header-item.yaml' + env: + ANDROID_ID: 'team-channels-view-create' + IOS_LABEL: 'Add channel to team' - extendedWaitUntil: visible: id: add-channel-team-view @@ -198,12 +294,11 @@ tags: label: should have submit button - tapOn: id: add-existing-channel-view-submit -- extendedWaitUntil: - visible: - id: room-view-title-${output.team} - timeout: 60000 -- tapOn: - id: room-header +- runFlow: + file: '../../helpers/assert-room-title.yaml' + env: + ROOM: ${output.team} +- runFlow: '../../helpers/open-room-actions.yaml' - assertVisible: id: room-actions-teams - tapOn: @@ -272,12 +367,7 @@ tags: visible: id: room-view timeout: 60000 -- tapOn: - id: room-header -- extendedWaitUntil: - visible: - id: room-actions-view - timeout: 60000 +- runFlow: '../../helpers/open-room-actions.yaml' # should add users to the team - tapOn: @@ -292,10 +382,10 @@ tags: timeout: 60000 - tapOn: id: room-actions-add-user -- assertVisible: - id: select-users-view-search -- tapOn: - id: select-users-view-search +- runFlow: + file: '../../helpers/tap-search-field.yaml' + env: + SEARCH_ID: 'select-users-view-search' - inputText: rocket.cat - runFlow: '../../helpers/hide-keyboard.yaml' - extendedWaitUntil: @@ -308,12 +398,10 @@ tags: visible: id: selected-user-rocket.cat timeout: 60000 -- extendedWaitUntil: - visible: - id: select-users-view-search - timeout: 60000 -- tapOn: - id: select-users-view-search +- runFlow: + file: '../../helpers/tap-search-field.yaml' + env: + SEARCH_ID: 'select-users-view-search' - eraseText - inputText: ${output.secondUser.username} - runFlow: '../../helpers/hide-keyboard.yaml' @@ -327,21 +415,23 @@ tags: visible: id: selected-user-${output.secondUser.username} timeout: 60000 -- assertVisible: - id: selected-users-view-submit -- tapOn: - id: selected-users-view-submit +- runFlow: + file: '../../helpers/wait-header-item.yaml' + env: + ANDROID_ID: 'selected-users-view-submit' + IOS_LABEL: 'Next' +- runFlow: + file: '../../helpers/tap-header-item.yaml' + env: + ANDROID_ID: 'selected-users-view-submit' + IOS_LABEL: 'Next' -- assertVisible: - id: room-members-view-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 +- runFlow: + file: '../../helpers/select-header-filter.yaml' + env: + ANDROID_FILTER_ID: 'room-members-view-filter' + ANDROID_OPTION_ID: 'room-members-view-toggle-status-all' + IOS_OPTION: 'All' - extendedWaitUntil: visible: id: room-members-view-item-${output.secondUser.username} diff --git a/.maestro/tests/teams/utils/create-channel.yaml b/.maestro/tests/teams/utils/create-channel.yaml index b84015412ad..18d8139ed92 100644 --- a/.maestro/tests/teams/utils/create-channel.yaml +++ b/.maestro/tests/teams/utils/create-channel.yaml @@ -3,12 +3,11 @@ tags: - 'util' --- -- extendedWaitUntil: - visible: - id: rooms-list-view-create-channel - timeout: 60000 -- tapOn: - id: rooms-list-view-create-channel +- runFlow: + file: '../../../helpers/tap-header-item.yaml' + env: + ANDROID_ID: 'rooms-list-view-create-channel' + IOS_LABEL: 'Create new channel, team, direct message or discussion' - extendedWaitUntil: visible: id: new-message-view @@ -23,10 +22,16 @@ tags: visible: id: select-users-view timeout: 60000 -- assertVisible: - id: selected-users-view-submit -- tapOn: - id: selected-users-view-submit +- runFlow: + file: '../../../helpers/wait-header-item.yaml' + env: + ANDROID_ID: 'selected-users-view-submit' + IOS_LABEL: 'Skip' +- runFlow: + file: '../../../helpers/tap-header-item.yaml' + env: + ANDROID_ID: 'selected-users-view-submit' + IOS_LABEL: 'Skip' - extendedWaitUntil: visible: id: create-channel-view @@ -54,10 +59,10 @@ tags: visible: id: room-view timeout: 60000 -- extendedWaitUntil: - visible: - id: room-view-title-${channelName} - timeout: 60000 +- runFlow: + file: '../../../helpers/assert-room-title.yaml' + env: + ROOM: ${channelName} - runFlow: ../../../helpers/go-back.yaml - extendedWaitUntil: visible: diff --git a/.sniffler/config.json b/.sniffler/config.json index f7726236c8e..0fc3c6ca471 100644 --- a/.sniffler/config.json +++ b/.sniffler/config.json @@ -28,6 +28,7 @@ "app/lib/methods/getUsersPresence.ts", "app/sagas/index.js", "app/sagas/init.js", + "app/sagas/preloadNativeHeaderIcons.js", "app/lib/methods/loggedInServer.ts", "app/sagas/state.js", "app/sagas/selectServer.ts", diff --git a/.sniffler/test-map.json b/.sniffler/test-map.json index f467214edcd..bd67980093a 100644 --- a/.sniffler/test-map.json +++ b/.sniffler/test-map.json @@ -406,7 +406,7 @@ "test": ".maestro/tests/teams/convert-team.yaml", "dependsOn": [ "app/views/RoomActionsView/**", - "app/views/SelectListView.tsx", + "app/views/SelectListView/**", "app/views/CreateChannelView/**", "app/views/NewMessageView/**", "app/views/SelectedUsersView/**", @@ -436,7 +436,7 @@ "app/views/AddExistingChannelView/**", "app/views/RoomMembersView/**", "app/views/SelectedUsersView/**", - "app/views/SelectListView.tsx", + "app/views/SelectListView/**", "app/views/CreateChannelView/**", "app/sagas/room.js", "app/sagas/createChannel.js" diff --git a/app/containers/Avatar/Avatar.tsx b/app/containers/Avatar/Avatar.tsx index 926a74d841e..15c1dddd9fc 100644 --- a/app/containers/Avatar/Avatar.tsx +++ b/app/containers/Avatar/Avatar.tsx @@ -10,6 +10,7 @@ import { SubscriptionType } from '~/definitions'; import { type IAvatar } from './interfaces'; import I18n from '~/i18n'; import Touch from '../Touch'; +import { isIOS26OrLater } from '~/lib/methods/helpers/deviceInfo'; const Avatar = memo( ({ @@ -28,7 +29,7 @@ const Avatar = memo( serverVersion, text, size = 25, - borderRadius = 4, + borderRadius = isIOS26OrLater ? 8 : 4, type = SubscriptionType.DIRECT, avatarExternalProviderUrl, roomAvatarExternalProviderUrl, diff --git a/app/containers/BackgroundContainer/__snapshots__/index.test.tsx.snap b/app/containers/BackgroundContainer/__snapshots__/index.test.tsx.snap index 0a5d30836a0..3cd43ba0c33 100644 --- a/app/containers/BackgroundContainer/__snapshots__/index.test.tsx.snap +++ b/app/containers/BackgroundContainer/__snapshots__/index.test.tsx.snap @@ -42,6 +42,16 @@ exports[`Story Snapshots: Basic should match snapshot 1`] = ` } /> + + + `; @@ -87,26 +97,37 @@ exports[`Story Snapshots: BlackThemeLoading should match snapshot 1`] = ` } /> - + contentInsetAdjustmentBehavior="automatic" + > + + + + `; @@ -152,32 +173,43 @@ exports[`Story Snapshots: BlackThemeText should match snapshot 1`] = ` } /> - - Lorem Ipsum is simply dummy text of the printing and typesetting industry. Lorem Ipsum has been the industrys standard dummy text ever since the 1500s, when an unknown printer took a galley of type and scrambled it to make a type specimen book. It has survived not only five centuries - + + + Lorem Ipsum is simply dummy text of the printing and typesetting industry. Lorem Ipsum has been the industrys standard dummy text ever since the 1500s, when an unknown printer took a galley of type and scrambled it to make a type specimen book. It has survived not only five centuries + + + `; @@ -223,26 +255,37 @@ exports[`Story Snapshots: DarkThemeLoading should match snapshot 1`] = ` } /> - + contentInsetAdjustmentBehavior="automatic" + > + + + + `; @@ -288,32 +331,43 @@ exports[`Story Snapshots: DarkThemeText should match snapshot 1`] = ` } /> - - Lorem Ipsum is simply dummy text of the printing and typesetting industry. Lorem Ipsum has been the industrys standard dummy text ever since the 1500s, when an unknown printer took a galley of type and scrambled it to make a type specimen book. It has survived not only five centuries - + + + Lorem Ipsum is simply dummy text of the printing and typesetting industry. Lorem Ipsum has been the industrys standard dummy text ever since the 1500s, when an unknown printer took a galley of type and scrambled it to make a type specimen book. It has survived not only five centuries + + + `; @@ -359,26 +413,37 @@ exports[`Story Snapshots: Loading should match snapshot 1`] = ` } /> - + contentInsetAdjustmentBehavior="automatic" + > + + + + `; @@ -424,32 +489,43 @@ exports[`Story Snapshots: LongText should match snapshot 1`] = ` } /> - - Lorem Ipsum is simply dummy text of the printing and typesetting industry. Lorem Ipsum has been the industrys standard dummy text ever since the 1500s, when an unknown printer took a galley of type and scrambled it to make a type specimen book. It has survived not only five centuries - + + + Lorem Ipsum is simply dummy text of the printing and typesetting industry. Lorem Ipsum has been the industrys standard dummy text ever since the 1500s, when an unknown printer took a galley of type and scrambled it to make a type specimen book. It has survived not only five centuries + + + `; @@ -495,31 +571,42 @@ exports[`Story Snapshots: Text should match snapshot 1`] = ` } /> - - Text here - + + + Text here + + + `; diff --git a/app/containers/BackgroundContainer/index.tsx b/app/containers/BackgroundContainer/index.tsx index 57a64dad1d5..180ff2fec18 100644 --- a/app/containers/BackgroundContainer/index.tsx +++ b/app/containers/BackgroundContainer/index.tsx @@ -1,5 +1,5 @@ import { type ReactElement } from 'react'; -import { ActivityIndicator, ImageBackground, StyleSheet, Text, View } from 'react-native'; +import { ActivityIndicator, ImageBackground, ScrollView, StyleSheet, Text, View } from 'react-native'; import { useTheme } from '~/theme'; import sharedStyles from '~/views/Styles'; @@ -36,9 +36,11 @@ const BackgroundContainer = ({ text, loading }: IBackgroundContainer): ReactElem return ( - {text && !loading ? {text} : null} - {/* @ts-ignore */} - {loading ? : null} + + {text && !loading ? {text} : null} + {/* @ts-ignore */} + {loading ? : null} + ); }; diff --git a/app/containers/DirectoryItem/__snapshots__/DirectoryItem.test.tsx.snap b/app/containers/DirectoryItem/__snapshots__/DirectoryItem.test.tsx.snap index 1bc1299cc61..e3b93fff7d4 100644 --- a/app/containers/DirectoryItem/__snapshots__/DirectoryItem.test.tsx.snap +++ b/app/containers/DirectoryItem/__snapshots__/DirectoryItem.test.tsx.snap @@ -10,288 +10,216 @@ exports[`Story Snapshots: CustomStyle should match snapshot 1`] = ` > - + + + - - - - - - - -  - - - Styled Room - - - - Custom style applied - - - + ], + ], + { + "fontFamily": "custom", + "fontStyle": "normal", + "fontWeight": "normal", + }, + {}, + ] + } + > +  + + + Styled Room + + + Custom style applied + - + @@ -307,286 +235,216 @@ exports[`Story Snapshots: Default should match snapshot 1`] = ` > - + + + - - - - - - - -  - - - General - - - - A public room - - - + ], + ], + { + "fontFamily": "custom", + "fontStyle": "normal", + "fontWeight": "normal", + }, + {}, + ] + } + > +  + + + General + + + A public room + - + @@ -602,255 +460,183 @@ exports[`Story Snapshots: DirectMessage should match snapshot 1`] = ` > - + + + - - - - - - - - Alice Johnson - - - - Hey there! - - - + Alice Johnson + + + Hey there! + - + @@ -866,308 +652,241 @@ exports[`Story Snapshots: LongRoomName should match snapshot 1`] = ` > - + + + - - - - - - - -  - - - This is a very very very very very very very long room name that should be truncated - - - - Long name test - - - - 9999 members - - + ], + ], + { + "fontFamily": "custom", + "fontStyle": "normal", + "fontWeight": "normal", + }, + {}, + ] + } + > +  + + + This is a very very very very very very very long room name that should be truncated + + + Long name test + - + + 9999 members + + @@ -1183,188 +902,144 @@ exports[`Story Snapshots: OnlyTitle should match snapshot 1`] = ` > - - - + - - -  - - - Just a title - - - - + "flexShrink": 1, + "fontSize": 17, + "fontWeight": "500", + }, + { + "color": "#2F343D", + }, + ] + } + > + Just a title + - + @@ -1380,286 +1055,216 @@ exports[`Story Snapshots: TeamMain should match snapshot 1`] = ` > - + + + - - - - - - - -  - - - Engineering Team - - - - Main team room - - - + ], + ], + { + "fontFamily": "custom", + "fontStyle": "normal", + "fontWeight": "normal", + }, + {}, + ] + } + > +  + + + Engineering Team + + + Main team room + - + @@ -1675,308 +1280,241 @@ exports[`Story Snapshots: WithRightLabel should match snapshot 1`] = ` > - + + + - - - - - - - -  - - - General - - - - Room with label - - - - 123 members - - + ], + ], + { + "fontFamily": "custom", + "fontStyle": "normal", + "fontWeight": "normal", + }, + {}, + ] + } + > +  + + + General + + + Room with label + - + + 123 members + + @@ -1992,247 +1530,201 @@ exports[`Story Snapshots: WithoutDescription should match snapshot 1`] = ` > - + + + - - - - - - - -  - - - No description room - - - - + "fontFamily": "custom", + "fontStyle": "normal", + "fontWeight": "normal", + }, + {}, + ] + } + > +  + + + No description room + - + diff --git a/app/containers/DirectoryItem/index.ios.tsx b/app/containers/DirectoryItem/index.ios.tsx new file mode 100644 index 00000000000..68dedcb848b --- /dev/null +++ b/app/containers/DirectoryItem/index.ios.tsx @@ -0,0 +1,52 @@ +import { type ReactElement } from 'react'; +import { Text } from 'react-native'; + +import Avatar from '../Avatar'; +import RoomTypeIcon from '../RoomTypeIcon'; +import NativeListRow from '../NativeListRow'; +import { AVATAR_SIZE } from '../NativeListRow/constants'; +import styles from './styles'; +import { useTheme } from '~/theme'; +import usePreviewFormatText from '~/lib/hooks/usePreviewFormatText'; +import { type IDirectoryItem } from './index'; + +export { ROW_HEIGHT } from '../NativeListRow/constants'; + +const DirectoryItem = ({ + title, + description, + avatar, + onPress, + testID, + rightLabel, + type, + rid, + teamMain, + isFirst, + isLast +}: IDirectoryItem): ReactElement => { + const { colors } = useTheme(); + const formattedDescription = usePreviewFormatText(description ?? ''); + return ( + } + titleLeading={type !== 'd' ? : undefined} + trailing={ + rightLabel ? ( + + {rightLabel} + + ) : undefined + } + /> + ); +}; + +export default DirectoryItem; diff --git a/app/containers/DirectoryItem/index.tsx b/app/containers/DirectoryItem/index.tsx index 77881bd3924..4572badd5a5 100644 --- a/app/containers/DirectoryItem/index.tsx +++ b/app/containers/DirectoryItem/index.tsx @@ -15,7 +15,7 @@ interface IDirectoryItemLabel { color: string; } -interface IDirectoryItem { +export interface IDirectoryItem { title: string; description?: string; avatar?: string; @@ -26,6 +26,8 @@ interface IDirectoryItem { rightLabel?: string; rid?: string; teamMain?: boolean; + isFirst?: boolean; + isLast?: boolean; } const DirectoryItemLabel = memo(({ text, color }: IDirectoryItemLabel) => { diff --git a/app/containers/DirectoryItem/styles.ts b/app/containers/DirectoryItem/styles.ts index 23298b6f5c8..8f7af1781c0 100644 --- a/app/containers/DirectoryItem/styles.ts +++ b/app/containers/DirectoryItem/styles.ts @@ -31,6 +31,12 @@ export default StyleSheet.create({ fontSize: 14, ...sharedStyles.textRegular }, + titleIcon: { + marginRight: 0 + }, + nativeRowLabel: { + marginTop: StyleSheet.hairlineWidth + }, directoryItemLabel: { fontSize: 14, paddingLeft: 10, diff --git a/app/containers/Header/components/HeaderButton/HeaderButtonItemBadge.tsx b/app/containers/Header/components/HeaderButton/HeaderButtonItemBadge.tsx index c11284c588f..ad5c0bf70a5 100644 --- a/app/containers/Header/components/HeaderButton/HeaderButtonItemBadge.tsx +++ b/app/containers/Header/components/HeaderButton/HeaderButtonItemBadge.tsx @@ -1,7 +1,9 @@ import { type ReactElement } from 'react'; -import { StyleSheet, View } from 'react-native'; +import { StyleSheet, Text, useWindowDimensions, View } from 'react-native'; import UnreadBadge from '~/containers/UnreadBadge'; +import { useTheme } from '~/theme'; +import sharedStyles from '~/views/Styles'; const styles = StyleSheet.create({ badgeContainer: { @@ -12,6 +14,10 @@ const styles = StyleSheet.create({ borderRadius: 10, alignItems: 'center', justifyContent: 'center' + }, + countText: { + fontSize: 10, + ...sharedStyles.textSemibold } }); @@ -20,3 +26,20 @@ export const BadgeUnread = ({ ...props }): ReactElement => ( ); + +export const BadgeCount = ({ value, color }: { value: number; color: string }): ReactElement => { + const { colors } = useTheme(); + const { fontScale } = useWindowDimensions(); + const text = value >= 100 ? '+99' : String(value); + return ( + + + {text} + + + ); +}; diff --git a/app/containers/List/ListSection.tsx b/app/containers/List/ListSection.tsx deleted file mode 100644 index a326722bec3..00000000000 --- a/app/containers/List/ListSection.tsx +++ /dev/null @@ -1,29 +0,0 @@ -import { type ReactElement } from 'react'; -import { StyleSheet, View } from 'react-native'; - -import Header from './ListHeader'; - -const styles = StyleSheet.create({ - container: { - marginBottom: 16 - } -}); - -interface IListSection { - children: (ReactElement | null)[] | ReactElement | null; - title?: string; - translateTitle?: boolean; -} - -const ListSection = ({ children, title, translateTitle }: IListSection) => { - return ( - - {title ?
: null} - {children} - - ); -}; - -ListSection.displayName = 'List.Section'; - -export default ListSection; diff --git a/app/containers/List/__snapshots__/List.test.tsx.snap b/app/containers/List/__snapshots__/List.test.tsx.snap deleted file mode 100644 index e13bf9441eb..00000000000 --- a/app/containers/List/__snapshots__/List.test.tsx.snap +++ /dev/null @@ -1,10901 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`Story Snapshots: Alert should match snapshot 1`] = ` - - - - - - - - - Chats - - -  - - - - - - - - - - - - Lorem Ipsum is simply dummy text of the printing and typesetting industry. Lorem Ipsum has been the industrys standard dummy text ever since the 1500s, when an unknown printer took a galley of type and scrambled it to make a type specimen book. It has survived not only five centuries - - -  - - - - - - - - - - - - Chats - - -  - - - - - - -  - - - - - - - - - - - - Lorem Ipsum is simply dummy text of the printing and typesetting industry. Lorem Ipsum has been the industrys standard dummy text ever since the 1500s, when an unknown printer took a galley of type and scrambled it to make a type specimen book. It has survived not only five centuries - - -  - - - - - - -  - - - - - - - - -`; - -exports[`Story Snapshots: Header should match snapshot 1`] = ` - - - - - Chats - - - - - Lorem Ipsum is simply dummy text of the printing and typesetting industry. Lorem Ipsum has been the industrys standard dummy text ever since the 1500s, when an unknown printer took a galley of type and scrambled it to make a type specimen book. It has survived not only five centuries - - - - -`; - -exports[`Story Snapshots: Icon should match snapshot 1`] = ` - - - - -  - - - - -`; - -exports[`Story Snapshots: ListItemWithRightContainerStyle should match snapshot 1`] = ` - - - - - - - -  - - - - - - - Chats - - - - All - - - - - -  - - - - - - - -`; - -exports[`Story Snapshots: Pressable should match snapshot 1`] = ` - - - - - - - - - - - Press me - - - - - - - - - - - - - - - - I'm disabled - - - - - - - - - - -`; - -exports[`Story Snapshots: Radio should match snapshot 1`] = ` - - - - - - - - - - - Option 1 - - - - - - -  - - - - - - - - - - - - - - - - Option 2 - - - - - - -  - - - - - - - - - - - - - - - - Option 3 - - - - - - -  - - - - - - - - - - - - - - - - Lorem Ipsum is simply dummy text of the printing and typesetting industry. Lorem Ipsum has been the industrys standard dummy text ever since the 1500s, when an unknown printer took a galley of type and scrambled it to make a type specimen book. It has survived not only five centuries - - - - Lorem Ipsum is simply dummy text of the printing and typesetting industry. Lorem Ipsum has been the industrys standard dummy text ever since the 1500s, when an unknown printer took a galley of type and scrambled it to make a type specimen book. It has survived not only five centuries - - - - - -  - - - - - - - - - - -`; - -exports[`Story Snapshots: SectionAndInfo should match snapshot 1`] = ` - - - - - - - - - - - Section Item - - - - - - - - - - - - Section Item - - - - - - - - - - - - - - - Section Item - - - - - - - - - - - - Section Item - - - - - - - - - - - Chats - - - - - - - - - Section Item - - - - - - - - - - - - Section Item - - - - - - - - - Chats - - - - - - - Lorem Ipsum is simply dummy text of the printing and typesetting industry. Lorem Ipsum has been the industrys standard dummy text ever since the 1500s, when an unknown printer took a galley of type and scrambled it to make a type specimen book. It has survived not only five centuries - - - - - - - - - Section Item - - - - - - - - - - - - Section Item - - - - - - - - - Lorem Ipsum is simply dummy text of the printing and typesetting industry. Lorem Ipsum has been the industrys standard dummy text ever since the 1500s, when an unknown printer took a galley of type and scrambled it to make a type specimen book. It has survived not only five centuries - - - - - - -`; - -exports[`Story Snapshots: Separator should match snapshot 1`] = ` - - - - - -`; - -exports[`Story Snapshots: TitleAndSubtitle should match snapshot 1`] = ` - - - - - - - - - Chats - - - - - - - - - - - - Chats - - - - All - - - - - - - - - - - Lorem Ipsum is simply dummy text of the printing and typesetting industry. Lorem Ipsum has been the industrys standard dummy text ever since the 1500s, when an unknown printer took a galley of type and scrambled it to make a type specimen book. It has survived not only five centuries - - - - Lorem Ipsum is simply dummy text of the printing and typesetting industry. Lorem Ipsum has been the industrys standard dummy text ever since the 1500s, when an unknown printer took a galley of type and scrambled it to make a type specimen book. It has survived not only five centuries - - - - - - - - - - - Lorem Ipsum is simply dummy text of the printing and typesetting industry. Lorem Ipsum has been the industrys standard dummy text ever since the 1500s, when an unknown printer took a galley of type and scrambled it to make a type specimen book. It has survived not only five centuries - - - - Lorem Ipsum is simply dummy text of the printing and typesetting industry. Lorem Ipsum has been the industrys standard dummy text ever since the 1500s, when an unknown printer took a galley of type and scrambled it to make a type specimen book. It has survived not only five centuries - - - - - - - -`; - -exports[`Story Snapshots: WithBiggerFont should match snapshot 1`] = ` - - - - - - - Chats - - - - - - - - - - -  - - - - - - - Chats - - - - All - - - - - -  - - - - - - - - - - - - - - - -  - - - - - - - Chats - - - - All - - - - - -  - - - - - - - - - - - Chats - - - - - - - Chats - - - - - - - - - - -  - - - - - - - Chats - - - - All - - - - - -  - - - - - - - - - - - - - - - -  - - - - - - - Chats - - - - All - - - - - -  - - - - - - - - - - - Chats - - - - - - -`; - -exports[`Story Snapshots: WithBlackTheme should match snapshot 1`] = ` - - - - - - - Chats - - - - - - - - - - -  - - - - - - - Chats - - - - All - - - - - -  - - - - - - - - - - - - - - - -  - - - - - - - Chats - - - - All - - - - - -  - - - - - - - - - - - Chats - - - - - - - Chats - - - - - - - - - - -  - - - - - - - Chats - - - - All - - - - - -  - - - - - - - - - - - - - - - -  - - - - - - - Chats - - - - All - - - - - -  - - - - - - - - - - - Chats - - - - - - -`; - -exports[`Story Snapshots: WithCustomColors should match snapshot 1`] = ` - - - - - - - - - Chats - - - - - - - - - - - - - - Press me! - - - - - - - - - - -`; - -exports[`Story Snapshots: WithDarkTheme should match snapshot 1`] = ` - - - - - - - Chats - - - - - - - - - - -  - - - - - - - Chats - - - - All - - - - - -  - - - - - - - - - - - - - - - -  - - - - - - - Chats - - - - All - - - - - -  - - - - - - - - - - - Chats - - - - - - - Chats - - - - - - - - - - -  - - - - - - - Chats - - - - All - - - - - -  - - - - - - - - - - - - - - - -  - - - - - - - Chats - - - - All - - - - - -  - - - - - - - - - - - Chats - - - - - - -`; - -exports[`Story Snapshots: WithFlatList should match snapshot 1`] = ` - - - - - - - - - - - - - 0 - - - - - - - - - - - - - - 1 - - - - - - - - - - - - - - 2 - - - - - - - - - - - - - - 3 - - - - - - - - - - - - - - 4 - - - - - - - - - - - - - - 5 - - - - - - - - - - - - - - 6 - - - - - - - - - - - - - - 7 - - - - - - - - - - - - - - 8 - - - - - - - - - - - - - - 9 - - - - - - - - - - - - - - -`; - -exports[`Story Snapshots: WithIcon should match snapshot 1`] = ` - - - - - - - - -  - - - - - - - Icon Left - - - - - - - - - - - - Icon Right - - - - - - -  - - - - - - - - - - - -  - - - - - - - Lorem Ipsum is simply dummy text of the printing and typesetting industry. Lorem Ipsum has been the industrys standard dummy text ever since the 1500s, when an unknown printer took a galley of type and scrambled it to make a type specimen book. It has survived not only five centuries - - - - Lorem Ipsum is simply dummy text of the printing and typesetting industry. Lorem Ipsum has been the industrys standard dummy text ever since the 1500s, when an unknown printer took a galley of type and scrambled it to make a type specimen book. It has survived not only five centuries - - - - - -  - - - - - - - - - - - - Show Action Indicator - - - - - - -  - - - - - - - - -`; - -exports[`Story Snapshots: WithSmallFont should match snapshot 1`] = ` - - - - - - - Chats - - - - - - - - - - -  - - - - - - - Chats - - - - All - - - - - -  - - - - - - - - - - - - - - - -  - - - - - - - Chats - - - - All - - - - - -  - - - - - - - - - - - Chats - - - - - - - Chats - - - - - - - - - - -  - - - - - - - Chats - - - - All - - - - - -  - - - - - - - - - - - - - - - -  - - - - - - - Chats - - - - All - - - - - -  - - - - - - - - - - - Chats - - - - - - -`; diff --git a/app/containers/List/components/ListCheckbox.tsx b/app/containers/List/components/ListCheckbox.tsx new file mode 100644 index 00000000000..fed58469189 --- /dev/null +++ b/app/containers/List/components/ListCheckbox.tsx @@ -0,0 +1,19 @@ +import { type ReactElement } from 'react'; + +import { useTheme } from '~/theme'; +import ListIcon from './ListIcon'; + +export interface IListCheckbox { + value: boolean; + onValueChange: (value: boolean) => void; + testID?: string; +} + +const ListCheckbox = ({ value }: IListCheckbox): ReactElement => { + const { colors } = useTheme(); + return ; +}; + +ListCheckbox.displayName = 'List.Checkbox'; + +export default ListCheckbox; diff --git a/app/containers/List/components/ListContainer.ios.tsx b/app/containers/List/components/ListContainer.ios.tsx new file mode 100644 index 00000000000..2531903567f --- /dev/null +++ b/app/containers/List/components/ListContainer.ios.tsx @@ -0,0 +1,109 @@ +import { useState, type ReactElement } from 'react'; +import { StyleSheet, View } from 'react-native'; +import { Host } from '@expo/ui'; +import { Group, List, RNHostView } from '@expo/ui/swift-ui'; +import { + alignmentGuide, + frame, + listRowInsets, + listStyle, + onGeometryChange, + scrollContentBackground, + tag +} from '@expo/ui/swift-ui/modifiers'; + +import { useTheme } from '~/theme'; +import ListSection from './ListSection'; +import ListItem from './ListItem'; +import ListRadio from './ListRadio'; +import { isNativeListRow, isNativeListSection } from '../native/utils/rowMarkers'; +import { flattenListChildren, isListSeparator } from '../utils/listChildren'; +import { NativeListContext } from '../native/context'; +import { ICON_SIZE, PADDING_HORIZONTAL } from '../constants'; + +const styles = StyleSheet.create({ + host: { + flex: 1 + } +}); + +const insetGroupedModifiers = [listStyle('insetGrouped')]; +const sidebarModifiers = [listStyle('sidebar')]; +const hiddenBackgroundModifiers = [scrollContentBackground('hidden')]; + +export interface IListSelection { + selectedTag: string | null; +} + +interface IListContainer { + children: (ReactElement | null)[] | ReactElement | null; + testID?: string; + selection?: IListSelection; + backgroundHidden?: boolean; +} + +const isSection = (element: ReactElement) => element.type === ListSection || isNativeListSection(element.type); +const isNativeRow = (element: ReactElement) => + element.type === ListItem || element.type === ListRadio || isNativeListRow(element.type); +const hasLeftIcon = (element: ReactElement) => Boolean((element.props as { left?: unknown }).left); +const rowSelectionTag = (element: ReactElement) => (element.props as { testID?: string }).testID; + +const selectedTags = ({ selectedTag }: IListSelection) => (selectedTag ? [selectedTag] : []); + +const ListContainer = ({ children, testID, selection, backgroundHidden }: IListContainer) => { + const { theme } = useTheme(); + const [rowWidth, setRowWidth] = useState(0); + + const selectionTag = (row: ReactElement) => { + const rowTag = rowSelectionTag(row); + return rowTag && selection ? [tag(rowTag)] : []; + }; + + const rowModifiers = (row: ReactElement) => [ + frame({ maxWidth: Number.MAX_SAFE_INTEGER }), + alignmentGuide('listRowSeparatorLeading', hasLeftIcon(row) ? PADDING_HORIZONTAL * 2 + ICON_SIZE : PADDING_HORIZONTAL), + onGeometryChange(({ width }) => setRowWidth(current => (current === width ? current : width))), + listRowInsets({ top: 0, leading: 0, bottom: 0, trailing: 0 }), + ...selectionTag(row) + ]; + + const renderHostedRow = (row: ReactElement) => ( + + + + {row} + + + + ); + + const renderNativeRow = (row: ReactElement) => ( + + {row} + + ); + + const renderRow = (row: ReactElement) => (isNativeRow(row) ? renderNativeRow(row) : renderHostedRow(row)); + + return ( + + + + {flattenListChildren(children) + .filter(element => !isListSeparator(element)) + .map(element => (isSection(element) ? element : renderRow(element)))} + + + + ); +}; + +ListContainer.displayName = 'List.Container'; + +export default ListContainer; diff --git a/app/containers/List/ListContainer.tsx b/app/containers/List/components/ListContainer.tsx similarity index 86% rename from app/containers/List/ListContainer.tsx rename to app/containers/List/components/ListContainer.tsx index df3c14bdc1c..60fcf2995e2 100644 --- a/app/containers/List/ListContainer.tsx +++ b/app/containers/List/components/ListContainer.tsx @@ -13,8 +13,9 @@ const styles = StyleSheet.create({ interface IListContainer extends ScrollViewProps { children: (ReactElement | null)[] | ReactElement | null; testID?: string; + backgroundHidden?: boolean; } -const ListContainer = ({ children, ...props }: IListContainer) => { +const ListContainer = ({ children, backgroundHidden: _backgroundHidden, ...props }: IListContainer) => { const { bottom } = useSafeAreaInsets(); return ( diff --git a/app/containers/List/ListHeader.tsx b/app/containers/List/components/ListHeader.tsx similarity index 94% rename from app/containers/List/ListHeader.tsx rename to app/containers/List/components/ListHeader.tsx index f8ee321aed0..2583d206cbc 100644 --- a/app/containers/List/ListHeader.tsx +++ b/app/containers/List/components/ListHeader.tsx @@ -5,7 +5,7 @@ import sharedStyles from '~/views/Styles'; import { themes } from '~/lib/constants/colors'; import I18n from '~/i18n'; import { useTheme } from '~/theme'; -import { PADDING_HORIZONTAL } from './constants'; +import { PADDING_HORIZONTAL } from '../constants'; const styles = StyleSheet.create({ container: { diff --git a/app/containers/List/ListIcon.tsx b/app/containers/List/components/ListIcon.tsx similarity index 86% rename from app/containers/List/ListIcon.tsx rename to app/containers/List/components/ListIcon.tsx index 2fa41cd25b8..46a76120beb 100644 --- a/app/containers/List/ListIcon.tsx +++ b/app/containers/List/components/ListIcon.tsx @@ -1,8 +1,8 @@ import { type ReactElement } from 'react'; import { type StyleProp, StyleSheet, View, type ViewStyle } from 'react-native'; -import { CustomIcon, type TIconsName } from '../CustomIcon'; -import { ICON_SIZE } from './constants'; +import { CustomIcon, type TIconsName } from '~/containers/CustomIcon'; +import { ICON_SIZE } from '../constants'; import { useTheme } from '~/theme'; export interface IListIcon { diff --git a/app/containers/List/ListInfo.tsx b/app/containers/List/components/ListInfo.tsx similarity index 94% rename from app/containers/List/ListInfo.tsx rename to app/containers/List/components/ListInfo.tsx index c91ee9e4814..181d98cac2b 100644 --- a/app/containers/List/ListInfo.tsx +++ b/app/containers/List/components/ListInfo.tsx @@ -4,7 +4,7 @@ import { StyleSheet, Text, View } from 'react-native'; import sharedStyles from '~/views/Styles'; import { themes } from '~/lib/constants/colors'; import { useTheme } from '~/theme'; -import { PADDING_HORIZONTAL } from './constants'; +import { PADDING_HORIZONTAL } from '../constants'; import I18n from '~/i18n'; const styles = StyleSheet.create({ diff --git a/app/containers/List/ListItem.tsx b/app/containers/List/components/ListItem.tsx similarity index 91% rename from app/containers/List/ListItem.tsx rename to app/containers/List/components/ListItem.tsx index ed783e558b0..eabe200baa5 100644 --- a/app/containers/List/ListItem.tsx +++ b/app/containers/List/components/ListItem.tsx @@ -11,16 +11,18 @@ import { type ViewStyle } from 'react-native'; -import Touch from '../Touch'; +import Touch from '~/containers/Touch'; import sharedStyles from '~/views/Styles'; import { useTheme } from '~/theme'; import I18n from '~/i18n'; import Icon from './ListIcon'; -import { BASE_HEIGHT, ICON_SIZE, PADDING_HORIZONTAL } from './constants'; -import { CustomIcon } from '../CustomIcon'; +import { BASE_HEIGHT, ICON_SIZE, PADDING_HORIZONTAL } from '../constants'; +import { useNativeListMode } from '../native/context'; +import NativeListItem from '../native/components/Item'; +import { CustomIcon } from '~/containers/CustomIcon'; import { useResponsiveLayout } from '~/lib/hooks/useResponsiveLayout/useResponsiveLayout'; import EventEmitter from '~/lib/methods/helpers/events'; -import { LISTENER } from '../Toast'; +import { LISTENER } from '~/containers/Toast'; import { isIOS } from '~/lib/methods/helpers'; // Maestro fail to click on child component when we enable accessibility in parent component on iOS @@ -256,13 +258,19 @@ export interface IListItem extends Omit, Omit { const { colors } = useTheme(); + const nativeListMode = useNativeListMode(); + const backgroundColor = props.backgroundColor || (nativeListMode ? 'transparent' : colors.surfaceRoom); + + if (nativeListMode === 'native') { + return ; + } if (props.onPress) { const { onPress } = props; - return + ); +}; + +const NativeListItem = ({ item }: INativeListItem) => { + const { colors } = useTheme(); + const subtitle = nativeListItemSubtitle(item); + const leading = describeNativeListAccessory(item.left?.()); + const trailing = describeNativeListAccessory(item.right?.()); + + return ( + + {leading ? : null} + + + {subtitle ? ( + + {subtitle} + + ) : null} + + + {trailing ? : null} + {item.showActionIndicator ? : null} + + ); +}; + +export default NativeListItem; diff --git a/app/containers/List/native/components/Item.tsx b/app/containers/List/native/components/Item.tsx new file mode 100644 index 00000000000..fa70f339efb --- /dev/null +++ b/app/containers/List/native/components/Item.tsx @@ -0,0 +1,5 @@ +import { type INativeListItem } from '../types'; + +const NativeListItem = (_: INativeListItem) => null; + +export default NativeListItem; diff --git a/app/containers/List/native/components/Picker.ios.tsx b/app/containers/List/native/components/Picker.ios.tsx new file mode 100644 index 00000000000..b45fce048c8 --- /dev/null +++ b/app/containers/List/native/components/Picker.ios.tsx @@ -0,0 +1,36 @@ +import { Picker, Text } from '@expo/ui/swift-ui'; +import { font, foregroundStyle, frame, listRowInsets, pickerStyle, tag, tint } from '@expo/ui/swift-ui/modifiers'; + +import { useTheme } from '~/theme'; +import { useResponsiveLayout } from '~/lib/hooks/useResponsiveLayout/useResponsiveLayout'; +import { BASE_HEIGHT, PADDING_HORIZONTAL } from '~/containers/List/constants'; +import { type INativeListPicker } from '../types'; + +const NativeListPicker = ({ title, testID, options, selection, onSelectionChange }: INativeListPicker) => { + const { colors } = useTheme(); + const { fontScale } = useResponsiveLayout(); + + return ( + {title} + } + modifiers={[ + pickerStyle('menu'), + tint(colors.fontSecondaryInfo), + frame({ minHeight: BASE_HEIGHT * fontScale }), + listRowInsets({ leading: PADDING_HORIZONTAL, trailing: PADDING_HORIZONTAL }) + ]}> + {options.map(option => ( + + {option.label} + + ))} + + ); +}; + +export default NativeListPicker; diff --git a/app/containers/List/native/components/Picker.tsx b/app/containers/List/native/components/Picker.tsx new file mode 100644 index 00000000000..a58d3a39071 --- /dev/null +++ b/app/containers/List/native/components/Picker.tsx @@ -0,0 +1,5 @@ +import { type INativeListPicker } from '../types'; + +const NativeListPicker = (_: INativeListPicker) => null; + +export default NativeListPicker; diff --git a/app/containers/List/native/components/Section.ios.tsx b/app/containers/List/native/components/Section.ios.tsx new file mode 100644 index 00000000000..949351955de --- /dev/null +++ b/app/containers/List/native/components/Section.ios.tsx @@ -0,0 +1,41 @@ +import { useContext, type ReactElement } from 'react'; +import { Section, Text } from '@expo/ui/swift-ui'; + +import ListInfo from '~/containers/List/components/ListInfo'; +import { flattenListChildren, isListSeparator } from '~/containers/List/utils/listChildren'; +import { NativeListContext } from '../context'; +import { translateListText } from '../utils/itemProps'; +import { type INativeListSection } from '../types'; + +interface IInfoProps { + info: string; + translateInfo?: boolean; +} + +const isInfo = (element: ReactElement): element is ReactElement => element.type === ListInfo; + +const NativeListSection = ({ children, title, translateTitle }: INativeListSection) => { + const { renderRow } = useContext(NativeListContext); + const elements = flattenListChildren(children).filter(element => !isListSeparator(element)); + const infos = elements.filter(isInfo); + const rows = elements.filter(element => !isInfo(element)); + const footer = infos.length ? ( + <> + {infos.map(info => ( + {translateListText(info.props.info, info.props.translateInfo)} + ))} + + ) : undefined; + + if (!rows.length && !infos.length) { + return null; + } + + return ( +
+ {rows.map(renderRow)} +
+ ); +}; + +export default NativeListSection; diff --git a/app/containers/List/native/components/Section.tsx b/app/containers/List/native/components/Section.tsx new file mode 100644 index 00000000000..33d2c909b34 --- /dev/null +++ b/app/containers/List/native/components/Section.tsx @@ -0,0 +1,5 @@ +import { type INativeListSection } from '../types'; + +const NativeListSection = (_: INativeListSection) => null; + +export default NativeListSection; diff --git a/app/containers/List/native/components/Status.ios.tsx b/app/containers/List/native/components/Status.ios.tsx new file mode 100644 index 00000000000..6bd2ed7570c --- /dev/null +++ b/app/containers/List/native/components/Status.ios.tsx @@ -0,0 +1,14 @@ +import { type TUserStatus } from '~/definitions'; +import { useUserStatusColor } from '~/lib/hooks/useUserStatusColor'; +import { useTheme } from '~/theme'; +import { getStatusIconName } from '~/containers/Status/getStatusIconName'; +import NativeListIcon from './Icon.ios'; + +const NativeListStatus = ({ status }: { status: TUserStatus }) => { + const { colors } = useTheme(); + const statusColor = useUserStatusColor(status); + + return ; +}; + +export default NativeListStatus; diff --git a/app/containers/List/native/context.ts b/app/containers/List/native/context.ts new file mode 100644 index 00000000000..7cd85f92865 --- /dev/null +++ b/app/containers/List/native/context.ts @@ -0,0 +1,12 @@ +import { createContext, useContext, type ReactElement } from 'react'; + +export type TNativeListMode = false | 'native' | 'hosted'; + +interface INativeListContext { + mode: TNativeListMode; + renderRow: (row: ReactElement) => ReactElement; +} + +export const NativeListContext = createContext({ mode: false, renderRow: row => row }); + +export const useNativeListMode = () => useContext(NativeListContext).mode; diff --git a/app/containers/List/native/types.ts b/app/containers/List/native/types.ts new file mode 100644 index 00000000000..0cc6ee5af21 --- /dev/null +++ b/app/containers/List/native/types.ts @@ -0,0 +1,27 @@ +import { type ReactNode } from 'react'; + +import { type IListItem } from '../components/ListItem'; + +export interface INativeListItem { + item: IListItem; +} + +export interface INativeListSection { + children: ReactNode; + title?: string; + translateTitle?: boolean; +} + +export interface INativeListPickerOption { + label: string; + value: string; + testID?: string; +} + +export interface INativeListPicker { + title: string; + testID?: string; + options: INativeListPickerOption[]; + selection: string; + onSelectionChange: (value: string) => void; +} diff --git a/app/containers/List/native/utils/__tests__/describeAccessory.test.tsx b/app/containers/List/native/utils/__tests__/describeAccessory.test.tsx new file mode 100644 index 00000000000..cecd5fe53a8 --- /dev/null +++ b/app/containers/List/native/utils/__tests__/describeAccessory.test.tsx @@ -0,0 +1,86 @@ +import { Text, View } from 'react-native'; + +import Avatar from '~/containers/Avatar'; +import { CustomIcon } from '~/containers/CustomIcon'; +import NewWindowIcon from '~/containers/NewWindowIcon'; +import Radio from '~/containers/Radio'; +import Status from '~/containers/Status/Status'; +import Switch from '~/containers/Switch'; +import ListCheckbox from '~/containers/List/components/ListCheckbox'; +import ListIcon from '~/containers/List/components/ListIcon'; +import { describeNativeListAccessory } from '../describeAccessory'; + +describe('describeNativeListAccessory', () => { + it('has nothing to render for an empty slot', () => { + expect(describeNativeListAccessory(null)).toBeNull(); + }); + + it('maps List.Icon and CustomIcon to icon font glyphs', () => { + expect(describeNativeListAccessory()).toEqual({ kind: 'icon', name: 'moon' }); + expect(describeNativeListAccessory()).toEqual({ + kind: 'icon', + name: 'warning', + color: 'red', + size: 20 + }); + }); + + it('ignores an empty icon color', () => { + expect(describeNativeListAccessory()).toEqual({ + kind: 'icon', + name: 'checkbox-unchecked' + }); + }); + + it('maps the external link icon to the external indicator', () => { + expect(describeNativeListAccessory()).toEqual({ kind: 'indicator', indicator: 'external' }); + }); + + it('maps the chevron icon to the disclosure indicator', () => { + expect(describeNativeListAccessory()).toEqual({ + kind: 'indicator', + indicator: 'disclosure' + }); + }); + + it('shows a checkmark only for a checked radio', () => { + expect(describeNativeListAccessory()).toEqual({ kind: 'check' }); + expect(describeNativeListAccessory()).toBeNull(); + }); + + it('maps the user status', () => { + expect(describeNativeListAccessory()).toEqual({ kind: 'status', status: 'away' }); + }); + + it('maps a Switch to a toggle keeping value, handler and disabled state', () => { + const onValueChange = jest.fn(); + const accessory = describeNativeListAccessory(); + expect(accessory).toEqual({ kind: 'toggle', isOn: true, onValueChange, disabled: true, testID: 'switch' }); + }); + + it('maps List.Checkbox to a checkbox keeping value, handler and testID', () => { + const onValueChange = jest.fn(); + expect(describeNativeListAccessory()).toEqual({ + kind: 'checkbox', + value: true, + onValueChange, + testID: 'checkbox' + }); + }); + + it('maps plain text, also when wrapped in a single View', () => { + expect(describeNativeListAccessory(Off)).toEqual({ kind: 'text', text: 'Off' }); + expect( + describeNativeListAccessory( + + Toasts + + ) + ).toEqual({ kind: 'text', text: 'Toasts' }); + }); + + it('hosts anything else as React Native', () => { + const avatar = ; + expect(describeNativeListAccessory(avatar)).toEqual({ kind: 'hosted', element: avatar }); + }); +}); diff --git a/app/containers/List/native/utils/__tests__/itemProps.test.ts b/app/containers/List/native/utils/__tests__/itemProps.test.ts new file mode 100644 index 00000000000..d77d6f08566 --- /dev/null +++ b/app/containers/List/native/utils/__tests__/itemProps.test.ts @@ -0,0 +1,68 @@ +import EventEmitter from '~/lib/methods/helpers/events'; +import { LISTENER } from '~/containers/Toast'; +import { type IListItem } from '~/containers/List/components/ListItem'; +import { nativeListItemAccessibilityLabel, nativeListItemTitle, pressNativeListItem } from '../itemProps'; + +jest.mock('~/i18n', () => ({ t: (key: string) => `t:${key}` })); + +describe('nativeListItemTitle', () => { + it('translates string titles', () => { + expect(nativeListItemTitle({ title: 'Theme' })).toBe('t:Theme'); + }); + + it('has no text for custom title components', () => { + expect(nativeListItemTitle({ title: () => null })).toBeUndefined(); + }); +}); + +describe('nativeListItemAccessibilityLabel', () => { + it('prefers the explicit label', () => { + expect(nativeListItemAccessibilityLabel({ title: 'Theme', accessibilityLabel: 'Custom' })).toBe('Custom'); + }); + + it('joins translated title, subtitle and switch state', () => { + expect( + nativeListItemAccessibilityLabel({ + title: 'Crash_report', + subtitle: 'Info', + translateSubtitle: false, + additionalAccessibilityLabel: true + }) + ).toBe('t:Crash_report Info t:Enabled'); + }); + + it('uses checked wording for checkboxes', () => { + expect( + nativeListItemAccessibilityLabel({ + title: 'Name', + translateTitle: false, + additionalAccessibilityLabel: false, + additionalAccessibilityLabelCheck: true + }) + ).toBe('Name t:Unchecked'); + }); +}); + +describe('pressNativeListItem', () => { + const onPress = jest.fn(); + const item: IListItem = { title: 'Theme', onPress }; + + beforeEach(() => onPress.mockClear()); + + it('calls onPress with the title', () => { + pressNativeListItem(item); + expect(onPress).toHaveBeenCalledWith('Theme'); + }); + + it('ignores presses on disabled rows', () => { + pressNativeListItem({ title: 'Theme', onPress, disabled: true }); + expect(onPress).not.toHaveBeenCalled(); + }); + + it('shows the disabled reason instead of pressing', () => { + const emit = jest.spyOn(EventEmitter, 'emit'); + pressNativeListItem({ title: 'Theme', onPress, disabled: true, disabledReason: 'Not allowed' }); + expect(emit).toHaveBeenCalledWith(LISTENER, { message: 'Not allowed' }); + expect(onPress).not.toHaveBeenCalled(); + }); +}); diff --git a/app/containers/List/native/utils/describeAccessory.ts b/app/containers/List/native/utils/describeAccessory.ts new file mode 100644 index 00000000000..a1feb7393c6 --- /dev/null +++ b/app/containers/List/native/utils/describeAccessory.ts @@ -0,0 +1,91 @@ +import { Children, isValidElement, type ReactElement, type ReactNode } from 'react'; +import { Text, View, type SwitchProps } from 'react-native'; + +import { type TUserStatus } from '~/definitions'; +import { CustomIcon, type TIconsName } from '~/containers/CustomIcon'; +import NewWindowIcon from '~/containers/NewWindowIcon'; +import Radio from '~/containers/Radio'; +import Status from '~/containers/Status/Status'; +import Switch from '~/containers/Switch'; +import ListCheckbox, { type IListCheckbox } from '~/containers/List/components/ListCheckbox'; +import ListIcon from '~/containers/List/components/ListIcon'; + +export type TNativeListAccessory = + | { kind: 'icon'; name: TIconsName; color?: string; size?: number } + | { kind: 'check' } + | { kind: 'indicator'; indicator: 'disclosure' | 'external' } + | { kind: 'status'; status: TUserStatus } + | { kind: 'toggle'; isOn: boolean; onValueChange?: (value: boolean) => void; disabled: boolean; testID?: string } + | { kind: 'checkbox'; value: boolean; onValueChange: (value: boolean) => void; testID?: string } + | { kind: 'text'; text: string } + | { kind: 'hosted'; element: ReactElement }; + +interface IIconProps { + name: TIconsName; + color?: string; + size?: number; +} + +const iconAccessory = (name: TIconsName, { color, size }: Partial): TNativeListAccessory => { + if (name === 'chevron-right') { + return { kind: 'indicator', indicator: 'disclosure' }; + } + if (name === 'new-window') { + return { kind: 'indicator', indicator: 'external' }; + } + return { kind: 'icon', name, color: color || undefined, size }; +}; + +const textContent = (children: ReactNode) => { + const parts = Children.toArray(children); + return parts.every(part => typeof part === 'string' || typeof part === 'number') ? parts.join('') : null; +}; + +const onlyChild = (children: ReactNode) => { + const parts = Children.toArray(children); + return parts.length === 1 && isValidElement(parts[0]) ? parts[0] : null; +}; + +type TProps = Record; +type TDescriber = (props: TProps) => TNativeListAccessory | null | undefined; + +const describeText: TDescriber = ({ children }) => { + const text = textContent(children); + return text === null ? undefined : { kind: 'text', text }; +}; + +const describeToggle: TDescriber = props => { + const { value, onValueChange, disabled, testID } = props as SwitchProps; + return { kind: 'toggle', isOn: Boolean(value), onValueChange: onValueChange ?? undefined, disabled: Boolean(disabled), testID }; +}; + +const describeCheckbox: TDescriber = props => { + const { value, onValueChange, testID } = props as IListCheckbox; + return { kind: 'checkbox', value, onValueChange, testID }; +}; + +const describeWrapper: TDescriber = ({ children }) => { + const child = onlyChild(children); + const described = child ? describeNativeListAccessory(child) : null; + return described?.kind === 'hosted' ? undefined : described; +}; + +const describers = new Map([ + [ListCheckbox, describeCheckbox], + [ListIcon, props => iconAccessory(props.name, props)], + [CustomIcon, props => iconAccessory(props.name, props)], + [NewWindowIcon, () => ({ kind: 'indicator', indicator: 'external' })], + [Radio, ({ check }) => (check ? { kind: 'check' } : null)], + [Status, ({ status }) => ({ kind: 'status', status: status ?? 'offline' })], + [Switch, describeToggle], + [Text, describeText], + [View, describeWrapper] +]); + +export function describeNativeListAccessory(node: ReactNode): TNativeListAccessory | null { + if (!isValidElement(node)) { + return null; + } + const described = describers.get(node.type)?.(node.props); + return described === undefined ? { kind: 'hosted', element: node } : described; +} diff --git a/app/containers/List/native/utils/itemProps.ts b/app/containers/List/native/utils/itemProps.ts new file mode 100644 index 00000000000..78dcf7a90b2 --- /dev/null +++ b/app/containers/List/native/utils/itemProps.ts @@ -0,0 +1,39 @@ +import I18n from '~/i18n'; +import EventEmitter from '~/lib/methods/helpers/events'; +import { LISTENER } from '~/containers/Toast'; +import { type IListItem } from '~/containers/List/components/ListItem'; + +export const translateListText = (text: string, shouldTranslate = true) => (shouldTranslate ? I18n.t(text) : text); + +export const nativeListItemTitle = ({ title, translateTitle }: IListItem) => + typeof title === 'string' ? translateListText(title, translateTitle) : undefined; + +export const nativeListItemSubtitle = ({ subtitle, translateSubtitle }: IListItem) => + subtitle ? translateListText(subtitle, translateSubtitle) : undefined; + +const stateLabel = ({ additionalAccessibilityLabel, additionalAccessibilityLabelCheck }: IListItem) => { + if (typeof additionalAccessibilityLabel === 'string') { + return additionalAccessibilityLabel; + } + if (typeof additionalAccessibilityLabel !== 'boolean') { + return undefined; + } + if (additionalAccessibilityLabelCheck) { + return I18n.t(additionalAccessibilityLabel ? 'Checked' : 'Unchecked'); + } + return I18n.t(additionalAccessibilityLabel ? 'Enabled' : 'Disabled'); +}; + +export const nativeListItemAccessibilityLabel = (item: IListItem) => + item.accessibilityLabel ?? + [nativeListItemTitle(item), nativeListItemSubtitle(item), stateLabel(item)].filter(Boolean).join(' '); + +export const pressNativeListItem = ({ disabled, disabledReason, onPress, title }: IListItem) => { + if (disabled && disabledReason) { + EventEmitter.emit(LISTENER, { message: disabledReason }); + return; + } + if (!disabled) { + onPress?.(title); + } +}; diff --git a/app/containers/List/native/utils/rowMarkers.ts b/app/containers/List/native/utils/rowMarkers.ts new file mode 100644 index 00000000000..14e3e2e595b --- /dev/null +++ b/app/containers/List/native/utils/rowMarkers.ts @@ -0,0 +1,16 @@ +import { type ComponentType } from 'react'; + +const createComponentMarker = () => { + const markedTypes = new WeakSet(); + const mark = >(component: Component): Component => { + markedTypes.add(component); + return component; + }; + const isMarked = (type: unknown) => + (typeof type === 'function' || (typeof type === 'object' && type !== null)) && markedTypes.has(type); + return [mark, isMarked] as const; +}; + +export const [asNativeListRow, isNativeListRow] = createComponentMarker(); + +export const [asNativeListSection, isNativeListSection] = createComponentMarker(); diff --git a/app/containers/List/List.stories.tsx b/app/containers/List/stories/List.stories.tsx similarity index 98% rename from app/containers/List/List.stories.tsx rename to app/containers/List/stories/List.stories.tsx index 0aa5c9edd3c..50f2e53c20d 100644 --- a/app/containers/List/List.stories.tsx +++ b/app/containers/List/stories/List.stories.tsx @@ -1,7 +1,7 @@ import { FlatList } from 'react-native'; -import * as List from '.'; -import SafeAreaView from '../SafeAreaView'; +import * as List from '..'; +import SafeAreaView from '~/containers/SafeAreaView'; import { longText } from '~/.rnstorybook/utils'; import { ThemeContext, type TSupportedThemes } from '~/theme'; import { ResponsiveLayoutContext } from '~/lib/hooks/useResponsiveLayout/useResponsiveLayout'; diff --git a/app/containers/List/List.test.tsx b/app/containers/List/stories/__tests__/List.test.tsx similarity index 69% rename from app/containers/List/List.test.tsx rename to app/containers/List/stories/__tests__/List.test.tsx index 446c15169bd..eb8c47a1abd 100644 --- a/app/containers/List/List.test.tsx +++ b/app/containers/List/stories/__tests__/List.test.tsx @@ -1,4 +1,4 @@ import { generateSnapshots } from '~/.rnstorybook/generateSnapshots'; -import * as stories from './List.stories'; +import * as stories from '../List.stories'; generateSnapshots(stories); diff --git a/app/containers/List/stories/__tests__/__snapshots__/List.test.tsx.snap b/app/containers/List/stories/__tests__/__snapshots__/List.test.tsx.snap new file mode 100644 index 00000000000..7cc1e692936 --- /dev/null +++ b/app/containers/List/stories/__tests__/__snapshots__/List.test.tsx.snap @@ -0,0 +1,9338 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Story Snapshots: Alert should match snapshot 1`] = ` + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +`; + +exports[`Story Snapshots: Header should match snapshot 1`] = ` + + + + + + + + Chats + + + + + + + + + + + Lorem Ipsum is simply dummy text of the printing and typesetting industry. Lorem Ipsum has been the industrys standard dummy text ever since the 1500s, when an unknown printer took a galley of type and scrambled it to make a type specimen book. It has survived not only five centuries + + + + + + + +`; + +exports[`Story Snapshots: Icon should match snapshot 1`] = ` + + + + + + + +  + + + + + + + +`; + +exports[`Story Snapshots: ListItemWithRightContainerStyle should match snapshot 1`] = ` + + + + + + + +  + + + + + + + Chats + + + + All + + + + + +  + + + + + + + +`; + +exports[`Story Snapshots: Pressable should match snapshot 1`] = ` + + + + + + + + + + + + + + + + + + + + + + + + + + + + +`; + +exports[`Story Snapshots: Radio should match snapshot 1`] = ` + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +`; + +exports[`Story Snapshots: SectionAndInfo should match snapshot 1`] = ` + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +`; + +exports[`Story Snapshots: Separator should match snapshot 1`] = ` + + + +`; + +exports[`Story Snapshots: TitleAndSubtitle should match snapshot 1`] = ` + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +`; + +exports[`Story Snapshots: WithBiggerFont should match snapshot 1`] = ` + + + + + + + + + + + + + + + + + + +  + + + + + + + Chats + + + + All + + + + + +  + + + + + + + + + + + + + + + + + + + + +  + + + + + + + Chats + + + + All + + + + + +  + + + + + + + + + + + + + + + + + + + + + + + + + + + +  + + + + + + + Chats + + + + All + + + + + +  + + + + + + + + + + + + + + + + + + + + +  + + + + + + + Chats + + + + All + + + + + +  + + + + + + + + + + + + + + + +`; + +exports[`Story Snapshots: WithBlackTheme should match snapshot 1`] = ` + + + + + + + + + + + + + + + + + + +  + + + + + + + Chats + + + + All + + + + + +  + + + + + + + + + + + + + + + + + + + + +  + + + + + + + Chats + + + + All + + + + + +  + + + + + + + + + + + + + + + + + + + + + + + + + + + +  + + + + + + + Chats + + + + All + + + + + +  + + + + + + + + + + + + + + + + + + + + +  + + + + + + + Chats + + + + All + + + + + +  + + + + + + + + + + + + + + + +`; + +exports[`Story Snapshots: WithCustomColors should match snapshot 1`] = ` + + + + + + + + + + + + + + + + + + + + + + + + + + +`; + +exports[`Story Snapshots: WithDarkTheme should match snapshot 1`] = ` + + + + + + + + + + + + + + + + + + +  + + + + + + + Chats + + + + All + + + + + +  + + + + + + + + + + + + + + + + + + + + +  + + + + + + + Chats + + + + All + + + + + +  + + + + + + + + + + + + + + + + + + + + + + + + + + + +  + + + + + + + Chats + + + + All + + + + + +  + + + + + + + + + + + + + + + + + + + + +  + + + + + + + Chats + + + + All + + + + + +  + + + + + + + + + + + + + + + +`; + +exports[`Story Snapshots: WithFlatList should match snapshot 1`] = ` + + + + + + + + + + + + + 0 + + + + + + + + + + + + + + 1 + + + + + + + + + + + + + + 2 + + + + + + + + + + + + + + 3 + + + + + + + + + + + + + + 4 + + + + + + + + + + + + + + 5 + + + + + + + + + + + + + + 6 + + + + + + + + + + + + + + 7 + + + + + + + + + + + + + + 8 + + + + + + + + + + + + + + 9 + + + + + + + + + + + + + + +`; + +exports[`Story Snapshots: WithIcon should match snapshot 1`] = ` + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +`; + +exports[`Story Snapshots: WithSmallFont should match snapshot 1`] = ` + + + + + + + + + + + + + + + + + + +  + + + + + + + Chats + + + + All + + + + + +  + + + + + + + + + + + + + + + + + + + + +  + + + + + + + Chats + + + + All + + + + + +  + + + + + + + + + + + + + + + + + + + + + + + + + + + +  + + + + + + + Chats + + + + All + + + + + +  + + + + + + + + + + + + + + + + + + + + +  + + + + + + + Chats + + + + All + + + + + +  + + + + + + + + + + + + + + + +`; diff --git a/app/containers/List/utils/listChildren.ts b/app/containers/List/utils/listChildren.ts new file mode 100644 index 00000000000..048a0c25c03 --- /dev/null +++ b/app/containers/List/utils/listChildren.ts @@ -0,0 +1,14 @@ +import { Children, cloneElement, Fragment, isValidElement, type ReactElement, type ReactNode } from 'react'; + +import ListSeparator from '../components/ListSeparator'; + +export const flattenListChildren = (children: ReactNode, keyPrefix = ''): ReactElement[] => + Children.toArray(children).flatMap(child => { + if (!isValidElement<{ children?: ReactNode }>(child)) { + return []; + } + const key = `${keyPrefix}${child.key}`; + return child.type === Fragment ? flattenListChildren(child.props.children, key) : [cloneElement(child, { key })]; + }); + +export const isListSeparator = (element: ReactElement) => element.type === ListSeparator; diff --git a/app/containers/MediaCallHeader/MediaCallHeader.tsx b/app/containers/MediaCallHeader/MediaCallHeader.tsx index 43839f75159..d24f9f6b664 100644 --- a/app/containers/MediaCallHeader/MediaCallHeader.tsx +++ b/app/containers/MediaCallHeader/MediaCallHeader.tsx @@ -9,6 +9,7 @@ import EndCall from './components/EndCall'; import { useCallStore, useControlsVisible } from '~/lib/services/voip/useCallStore'; import { Content } from './components/Content'; import { CONTROLS_ANIMATION_DURATION } from '~/views/CallView/styles'; +import { hasNativeHeaderBar } from '~/lib/methods/helpers/deviceInfo'; const styles = StyleSheet.create({ header: { @@ -49,6 +50,9 @@ const MediaCallHeader = () => { }; if (!call) { + if (hasNativeHeaderBar) { + return null; + } return ; } diff --git a/app/containers/MediaCallHeader/MediaCallHeader.test.tsx b/app/containers/MediaCallHeader/__tests__/MediaCallHeader.test.tsx similarity index 90% rename from app/containers/MediaCallHeader/MediaCallHeader.test.tsx rename to app/containers/MediaCallHeader/__tests__/MediaCallHeader.test.tsx index c9695f474ef..99b4346b057 100644 --- a/app/containers/MediaCallHeader/MediaCallHeader.test.tsx +++ b/app/containers/MediaCallHeader/__tests__/MediaCallHeader.test.tsx @@ -2,15 +2,21 @@ import { fireEvent, render } from '@testing-library/react-native'; import { Provider } from 'react-redux'; import { type ReactNode } from 'react'; -import MediaCallHeader from './MediaCallHeader'; +import MediaCallHeader from '../MediaCallHeader'; import { navigateToCallRoom } from '~/lib/services/voip/navigateToCallRoom'; import { useCallStore } from '~/lib/services/voip/useCallStore'; import { mockedStore } from '~/reducers/mockedStore'; -import * as stories from './MediaCallHeader.stories'; +import * as stories from '../MediaCallHeader.stories'; import { generateSnapshots } from '~/.rnstorybook/generateSnapshots'; const mockNavigateToCallRoom = jest.mocked(navigateToCallRoom); +let mockHasNativeHeaderBar = false; +jest.mock('~/lib/methods/helpers/deviceInfo', () => { + const actual = jest.requireActual('~/lib/methods/helpers/deviceInfo'); + return Object.defineProperty({ ...actual }, 'hasNativeHeaderBar', { get: () => mockHasNativeHeaderBar }); +}); + jest.mock('~/lib/services/voip/navigateToCallRoom', () => ({ navigateToCallRoom: jest.fn().mockResolvedValue(undefined) })); @@ -94,6 +100,19 @@ describe('MediaCallHeader', () => { expect(queryByTestId('media-call-header-end')).toBeNull(); }); + it('should render nothing on the native header bar when there is no call', () => { + mockHasNativeHeaderBar = true; + useCallStore.setState({ call: null }); + const { toJSON } = render( + + + + ); + + expect(toJSON()).toBeNull(); + mockHasNativeHeaderBar = false; + }); + it('should render empty placeholder when native accepted but call not bound yet (before answerCall completes)', () => { useCallStore.getState().setNativeAcceptedCallId('e3246c4d-d23a-412f-8a8b-37ec9f29ef1a'); const { getByTestId, queryByTestId } = render( diff --git a/app/containers/MediaCallHeader/__snapshots__/MediaCallHeader.test.tsx.snap b/app/containers/MediaCallHeader/__tests__/__snapshots__/MediaCallHeader.test.tsx.snap similarity index 99% rename from app/containers/MediaCallHeader/__snapshots__/MediaCallHeader.test.tsx.snap rename to app/containers/MediaCallHeader/__tests__/__snapshots__/MediaCallHeader.test.tsx.snap index 3c6d903cd60..e46d9eb8bce 100644 --- a/app/containers/MediaCallHeader/__snapshots__/MediaCallHeader.test.tsx.snap +++ b/app/containers/MediaCallHeader/__tests__/__snapshots__/MediaCallHeader.test.tsx.snap @@ -93,7 +93,7 @@ exports[`Story Snapshots: ActiveCall should match snapshot 1`] = ` } > ( + + + +); + +export default Disclosure; diff --git a/app/containers/NativeListRow/components/Disclosure.tsx b/app/containers/NativeListRow/components/Disclosure.tsx new file mode 100644 index 00000000000..a86000ecb0d --- /dev/null +++ b/app/containers/NativeListRow/components/Disclosure.tsx @@ -0,0 +1,9 @@ +import { I18nManager } from 'react-native'; + +import ListIcon from '~/containers/List/components/ListIcon'; + +const Disclosure = () => ( + +); + +export default Disclosure; diff --git a/app/containers/NativeListRow/components/Separator.ios.tsx b/app/containers/NativeListRow/components/Separator.ios.tsx new file mode 100644 index 00000000000..dc18aaa6bd1 --- /dev/null +++ b/app/containers/NativeListRow/components/Separator.ios.tsx @@ -0,0 +1,28 @@ +import { StyleSheet, View } from 'react-native'; + +import { useTheme } from '~/theme'; +import { AVATAR_SIZE, CONTENT_SPACING, ROW_MARGIN_HORIZONTAL, ROW_PADDING_HORIZONTAL } from '../constants'; + +const styles = StyleSheet.create({ + card: { + marginHorizontal: ROW_MARGIN_HORIZONTAL + }, + line: { + height: StyleSheet.hairlineWidth + } +}); + +const Line = ({ inset }: { inset: number }) => { + const { colors } = useTheme(); + return ( + + + + ); +}; + +export const PlainSeparator = () => ; + +const Separator = () => ; + +export default Separator; diff --git a/app/containers/NativeListRow/components/Separator.tsx b/app/containers/NativeListRow/components/Separator.tsx new file mode 100644 index 00000000000..3876b84ea4e --- /dev/null +++ b/app/containers/NativeListRow/components/Separator.tsx @@ -0,0 +1,5 @@ +import ListSeparator from '~/containers/List/components/ListSeparator'; + +export const PlainSeparator = ListSeparator; + +export default ListSeparator; diff --git a/app/containers/NativeListRow/constants.ts b/app/containers/NativeListRow/constants.ts new file mode 100644 index 00000000000..95432acad82 --- /dev/null +++ b/app/containers/NativeListRow/constants.ts @@ -0,0 +1,6 @@ +export const ROW_HEIGHT = 54; +export const ROW_RADIUS = 10; +export const ROW_MARGIN_HORIZONTAL = 16; +export const ROW_PADDING_HORIZONTAL = 16; +export const AVATAR_SIZE = 30; +export const CONTENT_SPACING = 12; diff --git a/app/containers/NativeListRow/hooks/useListBackgroundColor.ios.ts b/app/containers/NativeListRow/hooks/useListBackgroundColor.ios.ts new file mode 100644 index 00000000000..afac5d421f5 --- /dev/null +++ b/app/containers/NativeListRow/hooks/useListBackgroundColor.ios.ts @@ -0,0 +1,6 @@ +import { useTheme } from '~/theme'; + +export const useListBackgroundColor = (_backgroundColor: string) => { + const { colors } = useTheme(); + return colors.surfaceHover; +}; diff --git a/app/containers/NativeListRow/hooks/useListBackgroundColor.ts b/app/containers/NativeListRow/hooks/useListBackgroundColor.ts new file mode 100644 index 00000000000..66f1affabcd --- /dev/null +++ b/app/containers/NativeListRow/hooks/useListBackgroundColor.ts @@ -0,0 +1 @@ +export const useListBackgroundColor = (backgroundColor: string) => backgroundColor; diff --git a/app/containers/NativeListRow/index.tsx b/app/containers/NativeListRow/index.tsx new file mode 100644 index 00000000000..1b7e6a59820 --- /dev/null +++ b/app/containers/NativeListRow/index.tsx @@ -0,0 +1,161 @@ +import { type ReactElement, type ReactNode } from 'react'; +import { PixelRatio, Pressable, StyleSheet, Text, View } from 'react-native'; + +import { CustomIcon, type TIconsName } from '~/containers/CustomIcon'; +import { useTheme } from '~/theme'; +import { useResponsiveLayout } from '~/lib/hooks/useResponsiveLayout/useResponsiveLayout'; +import { CONTENT_SPACING, ROW_HEIGHT, ROW_MARGIN_HORIZONTAL, ROW_PADDING_HORIZONTAL, ROW_RADIUS } from './constants'; + +const TRAILING_ACTION_HIT_SLOP = 12; +const SPACER_MIN_LENGTH = 8; + +const styles = StyleSheet.create({ + card: { + marginHorizontal: ROW_MARGIN_HORIZONTAL, + overflow: 'hidden' + }, + firstCard: { + borderTopLeftRadius: ROW_RADIUS, + borderTopRightRadius: ROW_RADIUS + }, + lastCard: { + borderBottomLeftRadius: ROW_RADIUS, + borderBottomRightRadius: ROW_RADIUS + }, + row: { + flexDirection: 'row', + alignItems: 'center', + paddingHorizontal: ROW_PADDING_HORIZONTAL, + gap: CONTENT_SPACING + }, + content: { + flex: 1, + flexDirection: 'row', + alignItems: 'center', + alignSelf: 'stretch', + gap: CONTENT_SPACING + }, + pressed: { + opacity: 0.75 + }, + disabled: { + opacity: 0.3 + }, + texts: { + flex: 1, + marginRight: SPACER_MIN_LENGTH + CONTENT_SPACING + }, + titleRow: { + flexDirection: 'row', + alignItems: 'center', + gap: 4 + }, + title: { + flexShrink: 1, + fontSize: 17, + fontWeight: '500' + }, + subtitle: { + fontSize: 15 + } +}); + +export interface INativeListRowAction { + icon: TIconsName; + onPress: () => void; + testID: string; + accessibilityLabel: string; + disabled?: boolean; +} + +export interface INativeListRow { + title: string; + subtitle?: string; + leading?: ReactNode; + titleLeading?: ReactElement; + trailing?: ReactNode; + trailingAction?: INativeListRowAction; + onPress: () => void; + onLongPress?: () => void; + testID: string; + accessibilityLabel: string; + isSelected?: boolean; + isFirst?: boolean; + isLast?: boolean; + disabled?: boolean; +} + +const NativeListRow = ({ + title, + subtitle, + leading, + titleLeading, + trailing, + trailingAction, + onPress, + onLongPress, + testID, + accessibilityLabel, + isSelected, + isFirst, + isLast, + disabled +}: INativeListRow) => { + const { colors } = useTheme(); + const { fontScale } = useResponsiveLayout(); + const height = PixelRatio.roundToNearestPixel(ROW_HEIGHT * fontScale); + + return ( + + [styles.content, pressed && styles.pressed, disabled && styles.disabled]}> + {leading} + + + {titleLeading} + + {title} + + + {subtitle ? ( + + {subtitle} + + ) : null} + + {trailing} + + {trailingAction ? ( + + + + ) : null} + + ); +}; + +export default NativeListRow; diff --git a/app/containers/NewWindowIcon.tsx b/app/containers/NewWindowIcon.tsx index 5203794bf0f..5290c5a5691 100644 --- a/app/containers/NewWindowIcon.tsx +++ b/app/containers/NewWindowIcon.tsx @@ -1,6 +1,6 @@ import { I18nManager } from 'react-native'; -import ListIcon, { type IListIcon } from './List/ListIcon'; +import ListIcon, { type IListIcon } from './List/components/ListIcon'; const NewWindowIcon = (props: Omit) => ( diff --git a/app/containers/RoomHeader/RoomHeader.tsx b/app/containers/RoomHeader/RoomHeader.tsx index df29c682607..09b43d7c4d7 100644 --- a/app/containers/RoomHeader/RoomHeader.tsx +++ b/app/containers/RoomHeader/RoomHeader.tsx @@ -88,7 +88,6 @@ interface IRoomHeader { testID?: string; sourceType?: IOmnichannelSource; disabled?: boolean; - rightButtonsWidth?: number; abacAttributes?: ISubscription['abacAttributes']; } diff --git a/app/containers/RoomTypeIcon/OmnichannelRoomIcon.tsx b/app/containers/RoomTypeIcon/OmnichannelRoomIcon.tsx index d9b5c8288b1..88d3707d9ac 100644 --- a/app/containers/RoomTypeIcon/OmnichannelRoomIcon.tsx +++ b/app/containers/RoomTypeIcon/OmnichannelRoomIcon.tsx @@ -1,24 +1,12 @@ import { useState } from 'react'; import { Image, type ImageStyle } from 'expo-image'; -import { OmnichannelSourceType, type IOmnichannelSource, type TUserStatus } from '~/definitions'; +import { type IOmnichannelSource, type TUserStatus } from '~/definitions'; import { useAppSelector } from '~/lib/hooks/useAppSelector'; -import { CustomIcon, type TIconsName } from '../CustomIcon'; +import { CustomIcon } from '../CustomIcon'; +import { getOmnichannelIconName, getOmnichannelSidebarIconUri } from './roomTypeIconName'; import { useUserStatusColor } from '~/lib/hooks/useUserStatusColor'; -interface IIconMap { - [key: string]: TIconsName; -} - -const iconMap: IIconMap = { - widget: 'livechat-monochromatic', - email: 'mail', - sms: 'sms', - app: 'omnichannel', - api: 'omnichannel', - other: 'omnichannel' -}; - interface IOmnichannelRoomIconProps { size: number; type: string; @@ -34,15 +22,15 @@ export const OmnichannelRoomIcon = ({ size, style, sourceType, status }: IOmnich const connected = useAppSelector(state => state.meteor?.connected); const userStatusColor = useUserStatusColor(status || 'offline'); - // @ts-ignore - const customIcon = ; + const customIcon = ; + const sidebarIconUri = connected ? getOmnichannelSidebarIconUri(baseUrl, sourceType) : undefined; - if (!svgError && sourceType?.type === OmnichannelSourceType.APP && sourceType.id && sourceType.sidebarIcon && connected) { + if (!svgError && sidebarIconUri) { return ( <> setSvgError(true)} onLoad={() => setLoading(false)} diff --git a/app/containers/RoomTypeIcon/index.tsx b/app/containers/RoomTypeIcon/index.tsx index be9873fe321..e100eb6ebc6 100644 --- a/app/containers/RoomTypeIcon/index.tsx +++ b/app/containers/RoomTypeIcon/index.tsx @@ -3,7 +3,8 @@ import { StyleSheet, type ViewStyle } from 'react-native'; import { type ImageStyle } from 'expo-image'; import { OmnichannelRoomIcon } from './OmnichannelRoomIcon'; -import { CustomIcon, type TIconsName } from '../CustomIcon'; +import { CustomIcon } from '../CustomIcon'; +import { getRoomTypeIconName } from './roomTypeIconName'; import { themes } from '~/lib/constants/colors'; import Status from '../Status'; import { useTheme } from '~/theme'; @@ -48,21 +49,8 @@ const RoomTypeIcon = memo( ); } - // TODO: move this to a separate function - let icon: TIconsName = 'channel-private'; - if (abacAttributes?.length) { - icon = teamMain ? 'team-shield' : 'hash-shield'; - } else if (teamMain) { - icon = `teams${type === 'p' ? '-private' : ''}`; - } else if (type === 'discussion') { - icon = 'discussions'; - } else if (type === 'c') { - icon = 'channel-public'; - } else if (type === 'd' && isGroupChat) { - icon = 'message'; - } + const icon = getRoomTypeIconName({ type, teamMain, isDiscussion: type === 'discussion', isGroupChat, abacAttributes }); - // @ts-ignore return ; } ); diff --git a/app/containers/RoomTypeIcon/roomTypeIconName.ts b/app/containers/RoomTypeIcon/roomTypeIconName.ts new file mode 100644 index 00000000000..85e572c0460 --- /dev/null +++ b/app/containers/RoomTypeIcon/roomTypeIconName.ts @@ -0,0 +1,52 @@ +import { OmnichannelSourceType, type IOmnichannelSource, type ISubscription } from '~/definitions'; +import { type TIconsName } from '../CustomIcon'; + +const omnichannelSourceIcons: Record = { + widget: 'livechat-monochromatic', + email: 'mail', + sms: 'sms', + app: 'omnichannel', + api: 'omnichannel', + other: 'omnichannel' +}; + +export const getOmnichannelIconName = (sourceType?: IOmnichannelSource): TIconsName => + omnichannelSourceIcons[sourceType?.type || 'other'] ?? 'omnichannel'; + +export const getOmnichannelSidebarIconUri = (server: string, sourceType?: IOmnichannelSource) => + sourceType?.type === OmnichannelSourceType.APP && sourceType.id && sourceType.sidebarIcon + ? `${server}/api/apps/public/${sourceType.id}/get-sidebar-icon?icon=${sourceType.sidebarIcon}` + : undefined; + +interface IRoomTypeIconName { + type?: string; + teamMain?: boolean; + isDiscussion?: boolean; + isGroupChat?: boolean; + abacAttributes?: ISubscription['abacAttributes']; +} + +export const getRoomTypeIconName = ({ + type, + teamMain, + isDiscussion, + isGroupChat, + abacAttributes +}: IRoomTypeIconName): TIconsName => { + if (abacAttributes?.length) { + return teamMain ? 'team-shield' : 'hash-shield'; + } + if (teamMain) { + return type === 'p' ? 'teams-private' : 'teams'; + } + if (isDiscussion) { + return 'discussions'; + } + if (type === 'c') { + return 'channel-public'; + } + if (type === 'd' && isGroupChat) { + return 'message'; + } + return 'channel-private'; +}; diff --git a/app/containers/SearchBox/__snapshots__/SearchBox.test.tsx.snap b/app/containers/SearchBox/__snapshots__/SearchBox.test.tsx.snap index a72a4351e98..6cd9c5634b6 100644 --- a/app/containers/SearchBox/__snapshots__/SearchBox.test.tsx.snap +++ b/app/containers/SearchBox/__snapshots__/SearchBox.test.tsx.snap @@ -4,7 +4,7 @@ exports[`Story Snapshots: Basic should match snapshot 1`] = ` const [text, setText] = useState(''); const { colors } = useTheme(); + const backgroundColor = useListBackgroundColor(colors.surfaceRoom); const internalOnChangeText = (value: string) => { setText(value); @@ -25,7 +25,7 @@ const SearchBox = ({ onChangeText, onSubmitEditing, testID }: TextInputProps) => }; return ( - + { @@ -13,9 +14,6 @@ const Status = memo(({ style, status = 'offline', size = 32, ...props }: IStatus const { fontScale } = useWindowDimensions(); - const name: TIconsName = `status-${status}`; - const isNameValid = hasIcon(name); - const iconName = isNameValid ? name : 'status-offline'; const calculatedStyle: StyleProp = [ { width: size * fontScale, @@ -31,7 +29,7 @@ const Status = memo(({ style, status = 'offline', size = 32, ...props }: IStatus {...props} style={calculatedStyle} size={size} - name={iconName} + name={getStatusIconName(status)} color={userStatusColor ?? colors.userPresenceOffline} /> ); diff --git a/app/containers/Status/getStatusIconName.ts b/app/containers/Status/getStatusIconName.ts new file mode 100644 index 00000000000..960f48df4f4 --- /dev/null +++ b/app/containers/Status/getStatusIconName.ts @@ -0,0 +1,7 @@ +import { type TUserStatus } from '~/definitions'; +import { hasIcon, type TIconsName } from '~/containers/CustomIcon'; + +export const getStatusIconName = (status: TUserStatus): TIconsName => { + const name = `status-${status}`; + return hasIcon(name) ? (name as TIconsName) : 'status-offline'; +}; diff --git a/app/containers/UserItem.ios.tsx b/app/containers/UserItem.ios.tsx new file mode 100644 index 00000000000..bb061e88a4b --- /dev/null +++ b/app/containers/UserItem.ios.tsx @@ -0,0 +1,28 @@ +import Avatar from './Avatar'; +import { CustomIcon } from './CustomIcon'; +import NativeListRow from './NativeListRow'; +import { AVATAR_SIZE } from './NativeListRow/constants'; +import { useTheme } from '../theme'; +import i18n from '../i18n'; +import { type IUserItem } from './UserItem'; + +const UserItem = ({ name, username, onPress, testID, onLongPress, icon, iconColor, isChecked, isFirst, isLast }: IUserItem) => { + const { colors } = useTheme(); + const label = icon ? `${name} ${isChecked ? i18n.t('Selected') : i18n.t('Unselected')}` : name; + return ( + } + trailing={icon ? : undefined} + /> + ); +}; + +export default UserItem; diff --git a/app/containers/UserItem.tsx b/app/containers/UserItem.tsx index 7a6b8bffc11..b4ae5c0c456 100644 --- a/app/containers/UserItem.tsx +++ b/app/containers/UserItem.tsx @@ -34,7 +34,7 @@ const styles = StyleSheet.create({ } }); -interface IUserItem { +export interface IUserItem { name: string; username: string; onPress(): void; @@ -44,6 +44,8 @@ interface IUserItem { icon?: TIconsName | null; iconColor?: string; isChecked?: boolean; + isFirst?: boolean; + isLast?: boolean; } const UserItem = ({ name, username, onPress, testID, onLongPress, style, icon, iconColor, isChecked }: IUserItem) => { diff --git a/app/ee/omnichannel/hooks/useCanReturnQueue.ts b/app/ee/omnichannel/hooks/useCanReturnQueue.ts index 7085d1c8b0f..91603e2a586 100644 --- a/app/ee/omnichannel/hooks/useCanReturnQueue.ts +++ b/app/ee/omnichannel/hooks/useCanReturnQueue.ts @@ -4,7 +4,7 @@ import { useDispatch } from 'react-redux'; import { routingConfigRequest } from '../actions/routingConfig'; import { useAppSelector } from '~/lib/hooks/useAppSelector'; -export function useCanReturnQueue(enabled: boolean): boolean { +export function useCanReturnQueue(enabled = true): boolean { const dispatch = useDispatch(); const returnQueue = useAppSelector(state => state.routingConfig.returnQueue); diff --git a/app/i18n/locales/en.json b/app/i18n/locales/en.json index 6611881e332..48ff330b7fd 100644 --- a/app/i18n/locales/en.json +++ b/app/i18n/locales/en.json @@ -135,6 +135,7 @@ "Cancel_recording": "Cancel recording", "Cancel_upload": "Cancel upload", "Canned_Responses": "Canned responses", + "Cannot_connect": "Cannot connect", "Cannot_delete": "Cannot delete", "Cannot_leave": "Cannot leave", "Cannot_remove": "Cannot remove", @@ -581,6 +582,7 @@ "Message_was_read": "Message was read", "messages": "messages", "Microphone_access_needed_to_record_audio": "Microphone access needed to record audio", + "Minimize": "Minimize", "Missed_call": "Missed call", "missing_room_e2ee_description": "The encryption keys for the room need to be updated, another room member needs to be online for this to happen.", "missing_room_e2ee_title": "Check back in a few moments", diff --git a/app/lib/methods/helpers/deviceInfo.ts b/app/lib/methods/helpers/deviceInfo.ts index 9cb7ac727ef..c17b352ecc9 100644 --- a/app/lib/methods/helpers/deviceInfo.ts +++ b/app/lib/methods/helpers/deviceInfo.ts @@ -16,3 +16,7 @@ export const supportSystemTheme = (): boolean => { // Tablet info export const isTablet = DeviceInfo.isTablet(); + +export const isIOS26OrLater = isIOS && parseInt(DeviceInfo.getSystemVersion(), 10) >= 26; + +export const hasNativeHeaderBar = isIOS26OrLater; diff --git a/app/lib/methods/helpers/navigation/__tests__/headerActions.test.tsx b/app/lib/methods/helpers/navigation/__tests__/headerActions.test.tsx new file mode 100644 index 00000000000..dbbbc578c68 --- /dev/null +++ b/app/lib/methods/helpers/navigation/__tests__/headerActions.test.tsx @@ -0,0 +1,91 @@ +import { fireEvent, render, screen } from '@testing-library/react-native'; +import { type NativeStackHeaderItemButton, type NativeStackHeaderItemMenu } from '@react-navigation/native-stack'; + +import { showActionSheetRef, type TActionSheetOptionsItem } from '~/containers/ActionSheet'; +import { HeaderActions, type IHeaderAction, nativeHeaderItems } from '../headerActions'; + +jest.mock('../headerIcon', () => ({ headerIcon: (name: string) => ({ type: 'image', source: name }) })); +jest.mock('~/containers/ActionSheet', () => ({ showActionSheetRef: jest.fn() })); + +const filterAction: IHeaderAction = { + label: 'Filter', + icon: 'filter', + testID: 'filter', + menu: [ + { label: 'All', checked: true, onPress: jest.fn() }, + { label: 'Close', destructive: true, onPress: jest.fn() } + ] +}; +const searchAction: IHeaderAction = { + label: 'Search', + icon: 'search', + testID: 'search', + legacyHeaderOnly: true, + onPress: jest.fn() +}; + +const openedSheetOptions = () => (showActionSheetRef as jest.Mock).mock.calls[0][0].options as TActionSheetOptionsItem[]; + +describe('nativeHeaderItems', () => { + it('leaves out actions that only exist on the legacy header', () => { + const items = nativeHeaderItems([filterAction, searchAction]) as NativeStackHeaderItemMenu[]; + + expect(items.map(item => item.label)).toEqual(['Filter']); + }); + + it('turns a menu into native menu actions with radio state only on checkable items', () => { + const [menu] = nativeHeaderItems([filterAction]) as NativeStackHeaderItemMenu[]; + + expect(menu.type).toBe('menu'); + expect(menu.icon).toEqual({ type: 'image', source: 'filter' }); + expect(menu.menu.items).toEqual([ + expect.objectContaining({ type: 'action', label: 'All', state: 'on' }), + expect.objectContaining({ type: 'action', label: 'Close', state: undefined, destructive: true }) + ]); + }); + + it('renders a badge without a value as a dot in the given color', () => { + const [button] = nativeHeaderItems([{ label: 'Menu', icon: 'hamburguer', badge: { color: 'red' }, onPress: jest.fn() }]); + + expect((button as NativeStackHeaderItemButton).badge).toEqual({ value: '', style: { backgroundColor: 'red' } }); + }); +}); + +describe('HeaderActions', () => { + beforeEach(() => jest.clearAllMocks()); + + it('renders nothing without actions', () => { + render(); + + expect(screen.toJSON()).toBeNull(); + }); + + it('shows legacy-only actions and uses the label as text when there is no icon', () => { + render(); + + expect(screen.getByTestId('search')).toBeOnTheScreen(); + expect(screen.getByText('Next')).toBeOnTheScreen(); + }); + + it('opens the menu in an action sheet, marking checkable items with a radio', () => { + render(); + + fireEvent.press(screen.getByTestId('filter')); + + const [all, close] = openedSheetOptions(); + expect(all.title).toBe('All'); + expect(all.right).toBeDefined(); + expect(close.right).toBeUndefined(); + expect(close.danger).toBe(true); + }); + + it('caps a numeric badge at +99', () => { + render( + + ); + + expect(screen.getByText('+99')).toBeOnTheScreen(); + }); +}); diff --git a/app/lib/methods/helpers/navigation/__tests__/headerIcon.test.ts b/app/lib/methods/helpers/navigation/__tests__/headerIcon.test.ts new file mode 100644 index 00000000000..03ef4d0ffd4 --- /dev/null +++ b/app/lib/methods/helpers/navigation/__tests__/headerIcon.test.ts @@ -0,0 +1,21 @@ +import { headerIcon, preloadHeaderIcons } from '../headerIcon'; + +const mockGetImageSource = jest.fn(); +jest.mock('~/containers/CustomIcon', () => ({ + IconSet: { getImageSource: (...args: unknown[]) => mockGetImageSource(...args) } +})); + +describe('headerIcon', () => { + it('returns no icon before the header icons are preloaded', () => { + expect(headerIcon('kebab')).toBeUndefined(); + }); + + it('returns the rendered font glyph as an image once preloaded', async () => { + mockGetImageSource.mockImplementation(async (name: string) => ({ uri: `file://${name}.png`, scale: 3 })); + + await preloadHeaderIcons(); + + expect(headerIcon('kebab')).toEqual({ type: 'image', source: { uri: 'file://kebab.png', scale: 3, alignmentInset: 2.5 } }); + expect(mockGetImageSource).toHaveBeenCalledWith('kebab', 30, 'black'); + }); +}); diff --git a/app/lib/methods/helpers/navigation/headerActions.tsx b/app/lib/methods/helpers/navigation/headerActions.tsx new file mode 100644 index 00000000000..5034967e54e --- /dev/null +++ b/app/lib/methods/helpers/navigation/headerActions.tsx @@ -0,0 +1,129 @@ +import { type ReactElement } from 'react'; +import { + type NativeStackHeaderItem, + type NativeStackHeaderItemMenuAction, + type NativeStackNavigationOptions +} from '@react-navigation/native-stack'; + +import { showActionSheetRef } from '~/containers/ActionSheet'; +import { type TIconsName } from '~/containers/CustomIcon'; +import * as HeaderButton from '~/containers/Header/components/HeaderButton'; +import Radio from '~/containers/Radio'; +import { hasNativeHeaderBar } from '~/lib/methods/helpers/deviceInfo'; +import { headerIcon } from './headerIcon'; + +export interface IHeaderMenuItem { + label: string; + icon?: TIconsName; + checked?: boolean; + destructive?: boolean; + disabled?: boolean; + testID?: string; + onPress: () => void; +} + +export interface IHeaderActionBadge { + value?: number; + color: string; +} + +export interface IHeaderAction { + label: string; + icon?: TIconsName; + testID?: string; + disabled?: boolean; + tintColor?: string; + badge?: IHeaderActionBadge; + legacyHeaderOnly?: boolean; + onPress?: () => void; + menu?: IHeaderMenuItem[]; +} + +const toNativeMenuAction = ({ + label, + icon, + checked, + destructive, + disabled, + onPress +}: IHeaderMenuItem): NativeStackHeaderItemMenuAction => ({ + type: 'action', + label, + icon: icon && headerIcon(icon), + state: checked === undefined ? undefined : checked ? 'on' : 'off', + destructive, + disabled, + onPress +}); + +export const nativeHeaderItems = (actions: IHeaderAction[]): NativeStackHeaderItem[] => + actions + .filter(action => !action.legacyHeaderOnly) + .map(({ label, icon, disabled, tintColor, badge, onPress, menu }): NativeStackHeaderItem => { + const item = { + label, + accessibilityLabel: label, + icon: icon && headerIcon(icon), + disabled, + tintColor, + badge: badge && { value: badge.value ?? '', style: { backgroundColor: badge.color } } + }; + if (menu) { + return { ...item, type: 'menu', menu: { items: menu.map(toNativeMenuAction) } }; + } + return { ...item, type: 'button', onPress: onPress ?? (() => {}) }; + }); + +const showMenu = (menu: IHeaderMenuItem[]) => + showActionSheetRef({ + options: menu.map(({ label, icon, checked, destructive, disabled, testID, onPress }) => ({ + title: label, + icon, + danger: destructive, + enabled: !disabled, + testID, + onPress, + right: checked === undefined ? undefined : () => + })) + }); + +const renderBadge = ({ value, color }: IHeaderActionBadge) => + value === undefined ? : ; + +export const HeaderActions = ({ actions, left = false }: { actions: IHeaderAction[]; left?: boolean }): ReactElement | null => { + if (!actions.length) { + return null; + } + return ( + + {actions.map(({ label, icon, testID, disabled, tintColor, badge, onPress, menu }) => ( + renderBadge(badge) : undefined} + onPress={() => { + onPress?.(); + if (menu) { + showMenu(menu); + } + }} + /> + ))} + + ); +}; + +export const headerRightActions = (actions: IHeaderAction[]): NativeStackNavigationOptions => + hasNativeHeaderBar + ? { headerRight: undefined, unstable_headerRightItems: () => nativeHeaderItems(actions) } + : { headerRight: () => }; + +export const headerLeftActions = (actions: IHeaderAction[]): NativeStackNavigationOptions => + hasNativeHeaderBar + ? { headerLeft: undefined, unstable_headerLeftItems: () => nativeHeaderItems(actions) } + : { headerLeft: () => }; diff --git a/app/lib/methods/helpers/navigation/headerIcon.ts b/app/lib/methods/helpers/navigation/headerIcon.ts new file mode 100644 index 00000000000..df92d3f68b2 --- /dev/null +++ b/app/lib/methods/helpers/navigation/headerIcon.ts @@ -0,0 +1,54 @@ +import type { ImageURISource } from 'react-native'; + +import { IconSet, type TIconsName } from '~/containers/CustomIcon'; + +const HEADER_ICON_SIZE = 30; +const HEADER_ICON_GLYPH_PADDING = HEADER_ICON_SIZE / 12; + +const HEADER_ICONS: TIconsName[] = [ + 'chat-close', + 'chat-forward', + 'close', + 'create', + 'directory', + 'encrypted', + 'filter', + 'hamburguer', + 'kebab', + 'move-to-the-queue', + 'notification', + 'notification-disabled', + 'pause', + 'phone', + 'search', + 'threads', + 'workspaces' +]; + +const headerIconSources = new Map(); + +let preloadPromise: Promise | undefined; + +const loadHeaderIcons = async () => { + await Promise.all( + HEADER_ICONS.map(async name => { + const source = await IconSet.getImageSource(name, HEADER_ICON_SIZE, 'black'); + if (source) { + headerIconSources.set(name, { ...source, alignmentInset: HEADER_ICON_GLYPH_PADDING }); + } + }) + ); +}; + +export const preloadHeaderIcons = () => { + preloadPromise ??= loadHeaderIcons().catch(error => { + preloadPromise = undefined; + throw error; + }); + return preloadPromise; +}; + +export const headerIcon = (name: TIconsName) => { + const source = headerIconSources.get(name); + return source ? { type: 'image' as const, source } : undefined; +}; diff --git a/app/lib/methods/helpers/navigation/index.ts b/app/lib/methods/helpers/navigation/index.ts index c050358e55e..a22fa65631a 100644 --- a/app/lib/methods/helpers/navigation/index.ts +++ b/app/lib/methods/helpers/navigation/index.ts @@ -1,26 +1,66 @@ -import { createElement, type ReactElement } from 'react'; +import { createElement, type ReactElement, type RefObject } from 'react'; import { DarkTheme, DefaultTheme } from '@react-navigation/native'; import { type NativeStackHeaderProps, type NativeStackNavigationOptions } from '@react-navigation/native-stack'; +import { type SearchBarCommands, type SearchBarProps } from 'react-native-screens'; import { themes } from '~/lib/constants/colors'; import { type TSupportedThemes } from '~/theme'; import sharedStyles from '~/views/Styles'; import Header from '~/containers/Header'; +import I18n from '~/i18n'; +import { hasNativeHeaderBar } from '~/lib/methods/helpers'; +import { headerLeftActions, headerRightActions } from './headerActions'; -export const defaultHeader: NativeStackNavigationOptions = { - header: (props: NativeStackHeaderProps): ReactElement => createElement(Header, props) -}; +export const defaultHeader: NativeStackNavigationOptions = hasNativeHeaderBar + ? { + headerBackButtonDisplayMode: 'minimal' + } + : { + header: (props: NativeStackHeaderProps): ReactElement => createElement(Header, props) + }; -export const drawerStyle = { - width: 320 -}; +export const outsideHeaderRightLegal = ( + navigation: { navigate: (screen: 'LegalView') => void }, + testID: string +): NativeStackNavigationOptions => + headerRightActions([{ label: I18n.t('More'), icon: 'kebab', testID, onPress: () => navigation.navigate('LegalView') }]); + +interface IStackedSearchBarOptions { + ref?: RefObject; + onFocus?: () => void; + onChangeText: (text: string) => void; + onCancel?: () => void; + onSearch?: () => void; +} + +export const stackedSearchBarOptions = ({ + ref, + onFocus, + onChangeText, + onCancel, + onSearch +}: IStackedSearchBarOptions): SearchBarProps => ({ + ref, + placement: 'stacked', + hideWhenScrolling: false, + placeholder: I18n.t('Search'), + onFocus, + onChangeText: event => onChangeText(event.nativeEvent.text), + onSearchButtonPress: onSearch, + onCancelButtonPress: onCancel ?? (() => onChangeText('')) +}); + +export const outsideHeaderLeftClose = (onPress: () => void, testID?: string): NativeStackNavigationOptions => + headerLeftActions([{ label: I18n.t('Close'), icon: 'close', testID, onPress }]); export const themedHeader = (theme: TSupportedThemes): NativeStackNavigationOptions => ({ headerStyle: { backgroundColor: themes[theme].surfaceNeutral }, - headerTintColor: themes[theme].fontDefault, - headerTitleStyle: { ...sharedStyles.textBold, color: themes[theme].fontTitlesLabels, fontSize: 16 } + ...(!hasNativeHeaderBar && { + headerTintColor: themes[theme].fontDefault, + headerTitleStyle: { ...sharedStyles.textBold, color: themes[theme].fontTitlesLabels, fontSize: 16 } + }) }); export const navigationTheme = (theme: TSupportedThemes) => { diff --git a/app/lib/methods/helpers/theme.ts b/app/lib/methods/helpers/theme.ts index 42f045e6107..99cdbaf7806 100644 --- a/app/lib/methods/helpers/theme.ts +++ b/app/lib/methods/helpers/theme.ts @@ -57,6 +57,10 @@ export const setNativeTheme = (themePreferences: IThemePreference) => { } catch (error) { // Do nothing } + } else { + Appearance.setColorScheme( + themePreferences.currentTheme === 'automatic' ? 'unspecified' : theme === 'light' ? 'light' : 'dark' + ); } SystemUI.setBackgroundColorAsync(themes[theme].surfaceNeutral); }; diff --git a/app/sagas/deepLinking.js b/app/sagas/deepLinking.js index a55b9227e9e..d6a32ae75e9 100644 --- a/app/sagas/deepLinking.js +++ b/app/sagas/deepLinking.js @@ -28,6 +28,7 @@ import { videoConfJoin } from '../lib/methods/videoConf'; import { loginOAuthOrSso } from '../lib/services/connect'; import { notifyUser } from '../lib/services/restApi'; import sdk from '../lib/services/sdk'; +import { preloadNativeHeaderIcons } from './preloadNativeHeaderIcons'; import Navigation, { waitForNavigationReady } from '../lib/navigation/appNavigation'; import { resetVoipState } from '../lib/services/voip/resetVoipState'; @@ -325,6 +326,7 @@ const handleOpenDifferentServer = function* handleOpenDifferentServer({ params, }; const handleOpen = function* handleOpen({ params }) { + yield call(preloadNativeHeaderIcons); if (params.type === 'shareextension') { yield handleShareExtension({ params }); return; diff --git a/app/sagas/init.js b/app/sagas/init.js index 54b114387ed..4701a0723f8 100644 --- a/app/sagas/init.js +++ b/app/sagas/init.js @@ -17,6 +17,7 @@ import { RootEnum } from '../definitions'; import { getSortPreferences } from '../lib/methods/userPreferencesMethods'; import { deepLinkingClickCallPush } from '../actions/deepLinking'; import { getServerById } from '../lib/database/services/Server'; +import { preloadNativeHeaderIcons } from './preloadNativeHeaderIcons'; const PUSH_NOTIFICATION_KEY = 'pushNotification'; @@ -73,6 +74,8 @@ const deliverPendingPushNotification = function* deliverPendingPushNotification( const restore = function* restore() { yield call(runBiometricTrustMigration); + yield call(preloadNativeHeaderIcons); + const restoredServer = yield* getServerToRestore(); if (restoredServer) { diff --git a/app/sagas/preloadNativeHeaderIcons.js b/app/sagas/preloadNativeHeaderIcons.js new file mode 100644 index 00000000000..b09eb21697a --- /dev/null +++ b/app/sagas/preloadNativeHeaderIcons.js @@ -0,0 +1,16 @@ +import { call } from 'redux-saga/effects'; + +import { hasNativeHeaderBar } from '../lib/methods/helpers'; +import log from '../lib/methods/helpers/log'; +import { preloadHeaderIcons } from '../lib/methods/helpers/navigation/headerIcon'; + +export const preloadNativeHeaderIcons = function* preloadNativeHeaderIcons() { + if (!hasNativeHeaderBar) { + return; + } + try { + yield call(preloadHeaderIcons); + } catch (e) { + log(e); + } +}; diff --git a/app/stacks/InsideStack.tsx b/app/stacks/InsideStack.tsx index d497134dfb3..1f8b581893c 100644 --- a/app/stacks/InsideStack.tsx +++ b/app/stacks/InsideStack.tsx @@ -328,8 +328,10 @@ const E2EEnterYourPasswordStack = createNativeStackNavigator({ return ; }); +const fullScreenPresentation = isIOS ? 'fullScreenModal' : 'containedModal'; + const InsideStack = createNativeStackNavigator({ - screenOptions: { ...defaultHeader, presentation: 'containedModal' }, + screenOptions: { ...defaultHeader, presentation: isIOS ? 'modal' : 'containedModal' }, screens: { DrawerNavigator: createNativeStackScreen({ screen: DrawerStack, @@ -347,16 +349,22 @@ const InsideStack = createNativeStackNavigator({ screen: E2EEnterYourPasswordStack, options: { headerShown: false } }), - AttachmentView: AttachmentViewScreen, + AttachmentView: createNativeStackScreen({ + screen: AttachmentViewScreen, + options: { presentation: fullScreenPresentation } + }), StatusView: StatusViewScreen, - ShareView: ShareViewScreen, + ShareView: createNativeStackScreen({ + screen: ShareViewScreen, + options: { presentation: fullScreenPresentation } + }), ModalBlockView: createNativeStackScreen({ screen: ModalBlockViewScreen, options: (args: any): NativeStackNavigationOptions => (ModalBlockView as any).navigationOptions(args) }), CallView: createNativeStackScreen({ screen: CallViewScreen, - options: { headerShown: false } + options: { headerShown: false, presentation: fullScreenPresentation } }) } }).with(({ Navigator }) => { diff --git a/app/stacks/MasterDetailStack/SplitNavigator.tsx b/app/stacks/MasterDetailStack/SplitNavigator.tsx new file mode 100644 index 00000000000..0598b3e25bd --- /dev/null +++ b/app/stacks/MasterDetailStack/SplitNavigator.tsx @@ -0,0 +1,79 @@ +import { + createNavigatorFactory, + type DefaultNavigatorOptions, + type ParamListBase, + type TabNavigationState, + TabRouter, + type TabRouterOptions, + useNavigationBuilder +} from '@react-navigation/native'; +import { StyleSheet, View } from 'react-native'; + +import { MAX_SIDEBAR_WIDTH } from '~/lib/constants/tablet'; +import { useTheme } from '~/theme'; + +const DetailFocusedRouter = (options: TabRouterOptions): ReturnType => { + const router = TabRouter({ ...options, backBehavior: 'none' }); + return { + ...router, + getStateForAction(state, action, configOptions) { + const nextState = router.getStateForAction(state, action, configOptions); + if (nextState === null || nextState.index === state.index) { + return nextState; + } + return { ...nextState, index: state.index }; + } + }; +}; + +type SplitNavigatorProps = DefaultNavigatorOptions< + ParamListBase, + string | undefined, + TabNavigationState, + {}, + {}, + any +> & + TabRouterOptions; + +const SplitNavigator = ({ id, initialRouteName, children, layout, screenListeners, screenOptions }: SplitNavigatorProps) => { + const { colors } = useTheme(); + const { state, descriptors, NavigationContent } = useNavigationBuilder(DetailFocusedRouter, { + id, + initialRouteName, + children, + layout, + screenListeners, + screenOptions + }); + const [master, ...details] = state.routes; + + return ( + + + {descriptors[master.key].render()} + {details.map(route => ( + + {descriptors[route.key].render()} + + ))} + + + ); +}; + +const styles = StyleSheet.create({ + container: { + flex: 1, + flexDirection: 'row' + }, + master: { + width: MAX_SIDEBAR_WIDTH, + borderEndWidth: StyleSheet.hairlineWidth + }, + detail: { + flex: 1 + } +}); + +export const createSplitNavigator = createNavigatorFactory(SplitNavigator); diff --git a/app/stacks/MasterDetailStack/index.tsx b/app/stacks/MasterDetailStack/index.tsx index 87a65ff9496..fd0be5035ca 100644 --- a/app/stacks/MasterDetailStack/index.tsx +++ b/app/stacks/MasterDetailStack/index.tsx @@ -4,14 +4,14 @@ import { createNativeStackScreen, type NativeStackNavigationProp } from '@react-navigation/native-stack'; -import { createDrawerNavigator } from '@react-navigation/drawer'; import { type StaticScreenProps, useNavigation } from '@react-navigation/native'; import { ThemeContext } from '~/theme'; -import { defaultHeader, themedHeader, drawerStyle } from '~/lib/methods/helpers/navigation'; +import { defaultHeader, themedHeader } from '~/lib/methods/helpers/navigation'; import withNavigation from '~/lib/navigation/withNavigation'; -import { isIOS } from '~/lib/methods/helpers'; +import { hasNativeHeaderBar, isIOS } from '~/lib/methods/helpers'; import { ModalContainer } from './ModalContainer'; +import { createSplitNavigator } from './SplitNavigator'; import { type MasterDetailChatsStackParamList, type MasterDetailInsideStackParamList, type ModalStackParamList } from './types'; import RoomView from '~/views/RoomView'; import RoomsListView from '~/views/RoomsListView'; @@ -168,10 +168,23 @@ const ChatsStack = createNativeStackNavigator({ return ; }); -const DrawerNav = createDrawerNavigator({ - screenOptions: { drawerType: 'permanent', headerShown: false, drawerStyle: { ...drawerStyle } }, - drawerContent: () => , +const RoomsListStack = createNativeStackNavigator({ + screenOptions: defaultHeader, screens: { + RoomsListView: createNativeStackScreen({ + screen: RoomsListView, + options: { headerShown: hasNativeHeaderBar } + }) + } +}).with(({ Navigator }) => { + const { theme } = useContext(ThemeContext); + return ; +}); + +const SplitNav = createSplitNavigator({ + initialRouteName: 'ChatsStackNavigator', + screens: { + RoomsListStackNavigator: RoomsListStack, ChatsStackNavigator: ChatsStack } }); @@ -250,6 +263,9 @@ const ModalStack = createNativeStackNavigator({ }).with(({ Navigator }) => { const { theme } = useContext(ThemeContext); const navigation = useNavigation>(); + if (isIOS) { + return ; + } return ( @@ -264,12 +280,14 @@ const InsideStack = createNativeStackNavigator({ }, screens: { DrawerNavigator: createNativeStackScreen({ - screen: DrawerNav as any, + screen: SplitNav as any, options: { headerShown: false } }), ModalStackNavigator: createNativeStackScreen({ screen: ModalStack as any, - options: { headerShown: false } + options: isIOS + ? { headerShown: false, presentation: 'formSheet', sheetAllowedDetents: [1.0], sheetGrabberVisible: false } + : { headerShown: false } }), AttachmentView, ModalBlockView: createNativeStackScreen({ diff --git a/app/stacks/MasterDetailStack/types.ts b/app/stacks/MasterDetailStack/types.ts index cdd11f23bba..c4c397fe2c1 100644 --- a/app/stacks/MasterDetailStack/types.ts +++ b/app/stacks/MasterDetailStack/types.ts @@ -23,7 +23,8 @@ export type MasterDetailChatsStackParamList = { }; }; -export type MasterDetailDrawerParamList = { +export type MasterDetailSplitParamList = { + RoomsListStackNavigator: NavigatorScreenParams<{ RoomsListView: undefined }>; ChatsStackNavigator: NavigatorScreenParams; }; @@ -206,7 +207,7 @@ export type ModalStackParamList = { }; export type MasterDetailInsideStackParamList = { - DrawerNavigator: NavigatorScreenParams>; // TODO: Change + DrawerNavigator: NavigatorScreenParams>; // TODO: Change ModalStackNavigator: NavigatorScreenParams; ModalBlockView: { data: any; // TODO: Change diff --git a/app/views/AccessibilityAndAppearanceView/accessibilityAndAppearanceView.test.tsx b/app/views/AccessibilityAndAppearanceView/accessibilityAndAppearanceView.test.tsx index 5a2afdd97ac..4e6c7d41e1d 100644 --- a/app/views/AccessibilityAndAppearanceView/accessibilityAndAppearanceView.test.tsx +++ b/app/views/AccessibilityAndAppearanceView/accessibilityAndAppearanceView.test.tsx @@ -28,10 +28,10 @@ describe('Autoplay GIFs switch', () => { const { getByTestId } = render(); - const autoplaySwitch = getByTestId('accessibility-autoplay-gifs-switch').findByProps({ value: false }); - expect(autoplaySwitch.props.value).toBe(false); + const autoplaySwitch = getByTestId('accessibility-autoplay-gifs-switch').findByProps({ isOn: false }); + expect(autoplaySwitch.props.isOn).toBe(false); - fireEvent(autoplaySwitch, 'valueChange', true); + fireEvent(autoplaySwitch, 'isOnChange', { nativeEvent: { isOn: true } }); expect(mockSetAutoplayGifs).toHaveBeenCalledWith(true); }); diff --git a/app/views/AccessibilityAndAppearanceView/components/ListPicker.tsx b/app/views/AccessibilityAndAppearanceView/components/ListPicker.tsx index 495ef5d04ee..e6dc700a8f4 100644 --- a/app/views/AccessibilityAndAppearanceView/components/ListPicker.tsx +++ b/app/views/AccessibilityAndAppearanceView/components/ListPicker.tsx @@ -3,22 +3,15 @@ import { StyleSheet, Text, View } from 'react-native'; import { type TActionSheetOptionsItem, useActionSheet } from '~/containers/ActionSheet'; import { CustomIcon } from '~/containers/CustomIcon'; import * as List from '~/containers/List'; +import { asNativeListRow } from '~/containers/List/native/utils/rowMarkers'; +import { useNativeListMode } from '~/containers/List/native/context'; +import NativeListPicker from '~/containers/List/native/components/Picker'; import I18n from '~/i18n'; import { useTheme } from '~/theme'; import sharedStyles from '~/views/Styles'; import { type TAlertDisplayType } from '..'; const styles = StyleSheet.create({ - leftTitleContainer: { - flex: 1, - justifyContent: 'center', - alignItems: 'flex-start' - }, - leftTitle: { - ...sharedStyles.textMedium, - fontSize: 16, - lineHeight: 24 - }, rightContainer: { flex: 1 }, @@ -50,6 +43,7 @@ const ListPicker = ({ } & IBaseParams) => { const { showActionSheet, hideActionSheet } = useActionSheet(); const { colors } = useTheme(); + const nativeListMode = useNativeListMode(); const OPTIONS: TOPTIONS = [ { @@ -80,6 +74,17 @@ const ListPicker = ({ right: option?.value === i.value ? () => : undefined })); + if (nativeListMode === 'native') { + return ( + ({ label: i.label, value: i.value }))} + selection={option.value} + onSelectionChange={selected => onChangeValue(selected as TAlertDisplayType)} + /> + ); + } + const openOptions = () => { const options = getOptions(); showActionSheet({ options }); @@ -88,11 +93,8 @@ const ListPicker = ({ ( - - {title} - - )} + title={title} + translateTitle={false} right={() => ( {option?.label} @@ -104,4 +106,4 @@ const ListPicker = ({ ); }; -export default ListPicker; +export default asNativeListRow(ListPicker); diff --git a/app/views/AddExistingChannelView/ChannelItem.ios.tsx b/app/views/AddExistingChannelView/ChannelItem.ios.tsx new file mode 100644 index 00000000000..dd327414a64 --- /dev/null +++ b/app/views/AddExistingChannelView/ChannelItem.ios.tsx @@ -0,0 +1,25 @@ +import * as List from '~/containers/List'; +import NativeListRow from '~/containers/NativeListRow'; +import { AVATAR_SIZE } from '~/containers/NativeListRow/constants'; +import I18n from '~/i18n'; +import { useTheme } from '~/theme'; +import { type IChannelItem } from './ChannelItem'; + +const ChannelItem = ({ title, icon, isChecked, onPress, testID, isFirst, isLast }: IChannelItem) => { + const { colors } = useTheme(); + return ( + } + trailing={isChecked ? : undefined} + /> + ); +}; + +export default ChannelItem; diff --git a/app/views/AddExistingChannelView/ChannelItem.tsx b/app/views/AddExistingChannelView/ChannelItem.tsx new file mode 100644 index 00000000000..10fd6b5614f --- /dev/null +++ b/app/views/AddExistingChannelView/ChannelItem.tsx @@ -0,0 +1,31 @@ +import * as List from '~/containers/List'; +import { type TIconsName } from '~/containers/CustomIcon'; +import { useTheme } from '~/theme'; + +export interface IChannelItem { + title: string; + icon: TIconsName; + isChecked: boolean; + onPress: () => void; + testID: string; + isFirst: boolean; + isLast: boolean; +} + +const ChannelItem = ({ title, icon, isChecked, onPress, testID }: IChannelItem) => { + const { colors } = useTheme(); + return ( + } + right={() => (isChecked ? : null)} + additionalAccessibilityLabel={isChecked} + additionalAccessibilityLabelCheck + /> + ); +}; + +export default ChannelItem; diff --git a/app/views/AddExistingChannelView/index.tsx b/app/views/AddExistingChannelView/index.tsx index 8e69edbdbe9..f1f35a87dd1 100644 --- a/app/views/AddExistingChannelView/index.tsx +++ b/app/views/AddExistingChannelView/index.tsx @@ -6,7 +6,6 @@ import { Q } from '@nozbe/watermelondb'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { textInputDebounceTime } from '~/lib/constants/debounceConfig'; -import * as List from '~/containers/List'; import database from '~/lib/database'; import I18n from '~/i18n'; import log, { events, logEvent } from '~/lib/methods/helpers/log'; @@ -23,6 +22,9 @@ import { addRoomsToTeam } from '~/lib/services/restApi'; import { useAppSelector } from '~/lib/hooks/useAppSelector'; import { useMasterDetail } from '~/lib/hooks/useMasterDetail'; import Navigation from '~/lib/navigation/appNavigation'; +import RowSeparator from '~/containers/NativeListRow/components/Separator'; +import { useListBackgroundColor } from '~/containers/NativeListRow/hooks/useListBackgroundColor'; +import ChannelItem from './ChannelItem'; type TNavigation = NativeStackNavigationProp; type TRoute = RouteProp; @@ -34,6 +36,7 @@ const AddExistingChannelView = () => { const [selected, setSelected] = useState([]); const { colors } = useTheme(); + const listBackgroundColor = useListBackgroundColor(colors.surfaceRoom); const navigation = useNavigation(); const { @@ -160,24 +163,23 @@ const AddExistingChannelView = () => { ListHeaderComponent={ onSearchChangeText(text)} testID='add-existing-channel-view-search' /> } - renderItem={({ item }: { item: TSubscriptionModel }) => { + renderItem={({ item, index }: { item: TSubscriptionModel; index: number }) => { // TODO: reuse logic inside RoomTypeIcon const icon = item.t === SubscriptionType.GROUP && !item?.teamId ? 'channel-private' : 'channel-public'; return ( - toggleChannel(item.rid)} testID={`add-existing-channel-view-item-${item.name}`} - left={() => } - right={() => (isChecked(item.rid) ? : null)} - additionalAccessibilityLabel={isChecked(item.rid)} - additionalAccessibilityLabelCheck + isFirst={index === 0} + isLast={index === channels.length - 1} /> ); }} - ItemSeparatorComponent={List.Separator} - contentContainerStyle={{ backgroundColor: colors.surfaceRoom, paddingBottom: bottom }} + ItemSeparatorComponent={RowSeparator} + contentContainerStyle={{ backgroundColor: listBackgroundColor, paddingBottom: bottom }} keyboardShouldPersistTaps='always' /> diff --git a/app/views/CannedResponsesListView/index.tsx b/app/views/CannedResponsesListView/index.tsx index fe31df31ffa..6b39184b220 100644 --- a/app/views/CannedResponsesListView/index.tsx +++ b/app/views/CannedResponsesListView/index.tsx @@ -1,11 +1,14 @@ -import { useEffect, useState } from 'react'; +import { useEffect, useRef, useState } from 'react'; import { FlatList } from 'react-native'; import { type RouteProp } from '@react-navigation/native'; import { type NativeStackNavigationOptions, type NativeStackNavigationProp } from '@react-navigation/native-stack'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; +import { type SearchBarCommands } from 'react-native-screens'; import database from '~/lib/database'; import I18n from '~/i18n'; +import { stackedSearchBarOptions } from '~/lib/methods/helpers/navigation'; +import { headerRightActions } from '~/lib/methods/helpers/navigation/headerActions'; import { hideActionSheetRef, showActionSheetRef } from '~/containers/ActionSheet'; import SafeAreaView from '~/containers/SafeAreaView'; import ActivityIndicator from '~/containers/ActivityIndicator'; @@ -22,7 +25,7 @@ import DepartmentFilter from './DepartmentFilter'; import styles from './styles'; import { type ICannedResponse } from '~/definitions/ICannedResponse'; import { type ChatsStackParamList } from '~/stacks/types'; -import { useDebounce } from '~/lib/methods/helpers'; +import { hasNativeHeaderBar, useDebounce } from '~/lib/methods/helpers'; import { getListCannedResponse, getDepartments } from '~/lib/services/restApi'; import { type ILivechatDepartment } from '~/definitions/ILivechatDepartment'; import { useMasterDetail } from '~/lib/hooks/useMasterDetail'; @@ -69,6 +72,7 @@ const CannedResponsesListView = ({ navigation, route }: ICannedResponsesListView const { theme } = useTheme(); const isMasterDetail = useMasterDetail(); const { bottom } = useSafeAreaInsets(); + const searchBarRef = useRef(null); const getRoomFromDb = async () => { const { rid } = route.params; @@ -209,8 +213,21 @@ const CannedResponsesListView = ({ navigation, route }: ICannedResponsesListView await handleGetListCannedResponse({ text: searchText, department: scope, depId: departmentId, debounced: false }); }; - const getHeader = () => { - if (isSearching) { + const showFilters = () => { + showActionSheetRef({ + children: ( + + ), + enableContentPanningGesture: false + }); + }; + + const getHeader = (): NativeStackNavigationOptions => { + if (isSearching && !hasNativeHeaderBar) { return { headerLeft: () => ( @@ -228,18 +245,24 @@ const CannedResponsesListView = ({ navigation, route }: ICannedResponsesListView }; } - const options: NativeStackNavigationOptions = { + return { headerLeft: () => null, headerTitle: I18n.t('Canned_Responses'), - headerRight: () => ( - - - setIsSearching(true)} /> - - ) + ...(hasNativeHeaderBar && { + headerSearchBarOptions: stackedSearchBarOptions({ + ref: searchBarRef, + onChangeText, + onCancel: () => { + onChangeText(''); + searchBarRef.current?.clearText(); + } + }) + }), + ...headerRightActions([ + { label: I18n.t('Filter'), icon: 'filter', onPress: showFilters }, + { label: I18n.t('Search'), icon: 'search', legacyHeaderOnly: true, onPress: () => setIsSearching(true) } + ]) }; - - return options; }; const setHeader = () => { @@ -251,19 +274,6 @@ const CannedResponsesListView = ({ navigation, route }: ICannedResponsesListView setHeader(); }, [isSearching, departments, currentDepartment]); - const showFilters = () => { - showActionSheetRef({ - children: ( - - ), - enableContentPanningGesture: false - }); - }; - const renderContent = () => { if (!cannedResponsesScopeName.length && !loading) { return ; diff --git a/app/views/DefaultBrowserView/index.tsx b/app/views/DefaultBrowserView/index.tsx index 9b126ab3916..d8a6eb5809f 100644 --- a/app/views/DefaultBrowserView/index.tsx +++ b/app/views/DefaultBrowserView/index.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useLayoutEffect, useMemo, useState } from 'react'; +import { Fragment, useCallback, useEffect, useLayoutEffect, useState } from 'react'; import { FlatList, Linking } from 'react-native'; import { useNavigation } from '@react-navigation/native'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; @@ -51,7 +51,7 @@ const DefaultBrowserView = () => { const navigation = useNavigation(); const { bottom } = useSafeAreaInsets(); - const paddingBottom = useMemo(() => Math.max(16, bottom), [bottom]); + const paddingBottom = Math.max(16, bottom); useLayoutEffect(() => { navigation.setOptions({ @@ -85,32 +85,45 @@ const DefaultBrowserView = () => { logEvent(events.DB_CHANGE_DEFAULT_BROWSER_F); } }, []); + const browsers = DEFAULT_BROWSERS.concat(supported); + const renderBrowser = (item: IBrowsersValues) => ( + + ); + return ( - item.value} - contentContainerStyle={[List.styles.contentContainerStyleFlatList, { paddingBottom }]} - renderItem={({ item }) => ( - - )} - ListHeaderComponent={ - <> - - - - } - ListFooterComponent={List.Separator} - ItemSeparatorComponent={List.Separator} - /> + {isIOS ? ( + + + {browsers.map(item => ( + {renderBrowser(item)} + ))} + + + ) : ( + item.value} + contentContainerStyle={[List.styles.contentContainerStyleFlatList, { paddingBottom }]} + renderItem={({ item }) => renderBrowser(item)} + ListHeaderComponent={ + <> + + + + } + ListFooterComponent={List.Separator} + ItemSeparatorComponent={List.Separator} + /> + )} ); }; diff --git a/app/views/DirectoryView/DirectoryRow.tsx b/app/views/DirectoryView/DirectoryRow.tsx new file mode 100644 index 00000000000..179d0dc3764 --- /dev/null +++ b/app/views/DirectoryView/DirectoryRow.tsx @@ -0,0 +1,54 @@ +import { memo } from 'react'; + +import DirectoryItem from '~/containers/DirectoryItem'; +import I18n from '~/i18n'; +import { type IServerRoom } from '~/definitions'; +import { useTheme } from '~/theme'; +import sharedStyles from '../Styles'; + +interface IDirectoryRow { + item: IServerRoom; + type: string; + isFirst: boolean; + isLast: boolean; + onPressItem: (item: IServerRoom) => void; +} + +const getRowDetails = (item: IServerRoom, type: string) => { + if (type === 'users') { + return { avatar: item.username, description: item.username, rightLabel: item.federation?.peer }; + } + if (type === 'teams') { + return { avatar: item.name, description: item.name, rightLabel: I18n.t('N_channels', { n: item.roomsCount }) }; + } + return { avatar: item.name, description: item.topic, rightLabel: I18n.t('N_users', { n: item.usersCount }) }; +}; + +const DirectoryRow = ({ item, type, isFirst, isLast, onPressItem }: IDirectoryRow) => { + const { colors } = useTheme(); + const title = item.name as string; + const onPress = () => onPressItem(item); + const testID = `directory-view-item-${item.name}`; + const style = isLast ? { ...sharedStyles.separatorBottom, borderColor: colors.strokeLight } : undefined; + + const { avatar, description, rightLabel } = getRowDetails(item, type); + + return ( + + ); +}; + +export default memo(DirectoryRow); diff --git a/app/views/DirectoryView/Options.tsx b/app/views/DirectoryView/Options.tsx index 7eb5f491f89..927b1709a11 100644 --- a/app/views/DirectoryView/Options.tsx +++ b/app/views/DirectoryView/Options.tsx @@ -50,7 +50,7 @@ const DirectoryOptions = ({ }; return ( - + {renderItem('channels')} @@ -72,7 +72,7 @@ const DirectoryOptions = ({ ) : null} - + ); }; diff --git a/app/views/DirectoryView/index.tsx b/app/views/DirectoryView/index.tsx index 7c94a9ebe81..0eb6000ec1e 100644 --- a/app/views/DirectoryView/index.tsx +++ b/app/views/DirectoryView/index.tsx @@ -1,17 +1,19 @@ -import { useLayoutEffect, type ReactElement } from 'react'; -import { FlatList, type ListRenderItem } from 'react-native'; +import { useCallback, useLayoutEffect, type ReactElement } from 'react'; +import { FlashList, type ListRenderItem } from '@shopify/flash-list'; import { shallowEqual } from 'react-redux'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; -import { type NativeStackNavigationOptions, type NativeStackNavigationProp } from '@react-navigation/native-stack'; +import { + type NativeStackHeaderItemMenuAction, + type NativeStackNavigationOptions, + type NativeStackNavigationProp +} from '@react-navigation/native-stack'; import { type CompositeNavigationProp } from '@react-navigation/native'; import { useActionSheet } from '~/containers/ActionSheet'; import { type ChatsStackParamList } from '~/stacks/types'; import { type MasterDetailInsideStackParamList } from '~/stacks/MasterDetailStack/types'; -import * as List from '~/containers/List'; -import DirectoryItem from '~/containers/DirectoryItem'; -import sharedStyles from '../Styles'; import I18n from '~/i18n'; +import { stackedSearchBarOptions } from '~/lib/methods/helpers/navigation'; import SearchBox from '~/containers/SearchBox'; import ActivityIndicator from '~/containers/ActivityIndicator'; import * as HeaderButton from '~/containers/Header/components/HeaderButton'; @@ -21,12 +23,17 @@ import { goRoom as goRoomMethod, type TGoRoomItem } from '~/lib/methods/helpers/ import { type IServerRoom, SubscriptionType } from '~/definitions'; import styles from './styles'; import Options from './Options'; +import DirectoryRow from './DirectoryRow'; import { getRoomByTypeAndName } from '~/lib/services/restApi'; import { createDirectMessage } from '~/lib/methods/createDirectMessage'; import { getSubscriptionByRoomId } from '~/lib/database/services/Subscription'; import { useAppSelector } from '~/lib/hooks/useAppSelector'; import { useMasterDetail } from '~/lib/hooks/useMasterDetail'; import { useDirectorySearch } from './hooks/useDirectorySearch'; +import { hasNativeHeaderBar } from '~/lib/methods/helpers'; +import { headerIcon } from '~/lib/methods/helpers/navigation/headerIcon'; +import RowSeparator from '~/containers/NativeListRow/components/Separator'; +import { useListBackgroundColor } from '~/containers/NativeListRow/hooks/useListBackgroundColor'; interface IDirectoryViewProps { navigation: CompositeNavigationProp< @@ -37,6 +44,7 @@ interface IDirectoryViewProps { const DirectoryView = ({ navigation }: IDirectoryViewProps): ReactElement => { const { colors } = useTheme(); + const listBackgroundColor = useListBackgroundColor(colors.surfaceRoom); const { bottom } = useSafeAreaInsets(); const { showActionSheet, hideActionSheet } = useActionSheet(); @@ -71,14 +79,66 @@ const DirectoryView = ({ navigation }: IDirectoryViewProps): ReactElement => { }); }; - const options: NativeStackNavigationOptions = { - title: I18n.t('Directory'), - headerRight: () => ( - - - - ) - }; + const typeAction = ( + itemType: string, + title: string, + icon: NativeStackHeaderItemMenuAction['icon'] + ): NativeStackHeaderItemMenuAction => ({ + type: 'action', + label: I18n.t(title), + icon, + state: type === itemType ? 'on' : 'off', + onPress: () => changeType(itemType) + }); + + const options: NativeStackNavigationOptions = hasNativeHeaderBar + ? { + title: I18n.t('Directory'), + headerRight: undefined, + headerTransparent: true, + headerSearchBarOptions: stackedSearchBarOptions({ onChangeText: onSearchChangeText, onSearch: search }), + unstable_headerRightItems: () => [ + { + type: 'menu', + label: I18n.t('Filter'), + accessibilityLabel: I18n.t('Filter'), + icon: headerIcon('filter'), + menu: { + items: [ + { + type: 'submenu', + label: I18n.t('Filter'), + inline: true, + items: [ + typeAction('channels', 'Channels', { type: 'sfSymbol', name: 'number' }), + typeAction('users', 'Users', { type: 'sfSymbol', name: 'person' }), + typeAction('teams', 'Teams', { type: 'sfSymbol', name: 'person.3' }) + ] + }, + ...(isFederationEnabled + ? [ + { + type: 'action' as const, + label: I18n.t('Search_global_users'), + description: I18n.t('Search_global_users_description'), + state: globalUsers ? ('on' as const) : ('off' as const), + onPress: toggleWorkspace + } + ] + : []) + ] + } + } + ] + } + : { + title: I18n.t('Directory'), + headerRight: () => ( + + + + ) + }; if (isMasterDetail) { options.headerLeft = () => ; } @@ -93,121 +153,80 @@ const DirectoryView = ({ navigation }: IDirectoryViewProps): ReactElement => { changeType, toggleWorkspace, showActionSheet, - hideActionSheet + hideActionSheet, + onSearchChangeText, + search ]); - const goRoom = (item: TGoRoomItem) => { - goRoomMethod({ item, isMasterDetail }); - }; - - const onPressItem = async (item: IServerRoom) => { - try { - if (type === 'users') { - const result = await createDirectMessage(item.username as string); - if (result.success) { - goRoom({ rid: result.room._id, name: item.username, t: SubscriptionType.DIRECT }); + const onPressItem = useCallback( + async (item: IServerRoom) => { + const goRoom = (goRoomItem: TGoRoomItem) => { + goRoomMethod({ item: goRoomItem, isMasterDetail }); + }; + try { + if (type === 'users') { + const result = await createDirectMessage(item.username as string); + if (result.success) { + goRoom({ rid: result.room._id, name: item.username, t: SubscriptionType.DIRECT }); + } + return; } - return; - } - const subscription = await getSubscriptionByRoomId(item._id); - if (subscription) { - goRoom(subscription); - return; - } - if (['p', 'c'].includes(item.t) && !item.teamMain) { - const result = await getRoomByTypeAndName(item.t, item.name || item.fname); - if (result) { + const subscription = await getSubscriptionByRoomId(item._id); + if (subscription) { + goRoom(subscription); + return; + } + if (['p', 'c'].includes(item.t) && !item.teamMain) { + const result = await getRoomByTypeAndName(item.t, item.name || item.fname); + if (result) { + goRoom({ + rid: item._id, + name: item.name, + joinCodeRequired: result.joinCodeRequired, + t: item.t as SubscriptionType, + search: true + }); + } + } else { goRoom({ rid: item._id, name: item.name, - joinCodeRequired: result.joinCodeRequired, t: item.t as SubscriptionType, - search: true + search: true, + teamMain: item.teamMain, + teamId: item.teamId }); } - } else { - goRoom({ - rid: item._id, - name: item.name, - t: item.t as SubscriptionType, - search: true, - teamMain: item.teamMain, - teamId: item.teamId - }); + } catch { + // do nothing } - } catch { - // do nothing - } - }; - - const renderItem: ListRenderItem = ({ item, index }) => { - let style; - if (index === data.length - 1) { - style = { - ...sharedStyles.separatorBottom, - borderColor: colors.strokeLight - }; - } - - const commonProps = { - title: item.name as string, - onPress: () => onPressItem(item), - testID: `directory-view-item-${item.name}`, - style, - rid: item._id - }; - - if (type === 'users') { - return ( - - ); - } + }, + [type, isMasterDetail] + ); - if (type === 'teams') { - return ( - - ); - } - return ( - - ); - }; + const renderItem: ListRenderItem = ({ item, index }) => ( + + ); return ( - - - + + {hasNativeHeaderBar ? null : ( + + )} - item._id} renderItem={renderItem} - ItemSeparatorComponent={List.Separator} + ItemSeparatorComponent={RowSeparator} keyboardShouldPersistTaps='always' ListFooterComponent={loading ? : null} - onEndReached={() => loadMore()} + onEndReached={loadMore} + onEndReachedThreshold={2} /> ); diff --git a/app/views/DirectoryView/styles.ts b/app/views/DirectoryView/styles.ts index 32a11d852b5..0650a543726 100644 --- a/app/views/DirectoryView/styles.ts +++ b/app/views/DirectoryView/styles.ts @@ -10,6 +10,7 @@ export default StyleSheet.create({ flex: 1 }, listContainer: { + paddingTop: 16, paddingBottom: 30 }, filterItemContainer: { diff --git a/app/views/DiscussionsView/index.tsx b/app/views/DiscussionsView/index.tsx index b13c0e23754..43dc83cb381 100644 --- a/app/views/DiscussionsView/index.tsx +++ b/app/views/DiscussionsView/index.tsx @@ -3,14 +3,17 @@ import { FlatList, StyleSheet } from 'react-native'; import { type NativeStackNavigationOptions, type NativeStackNavigationProp } from '@react-navigation/native-stack'; import { type RouteProp, useNavigation, useRoute } from '@react-navigation/native'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; +import { type SearchBarCommands } from 'react-native-screens'; import { textInputDebounceTime } from '~/lib/constants/debounceConfig'; import { type IMessageFromServer, type TThreadModel } from '~/definitions'; import { type ChatsStackParamList } from '~/stacks/types'; import ActivityIndicator from '~/containers/ActivityIndicator'; import I18n from '~/i18n'; +import { outsideHeaderLeftClose, stackedSearchBarOptions } from '~/lib/methods/helpers/navigation'; +import { headerRightActions } from '~/lib/methods/helpers/navigation/headerActions'; import log from '~/lib/methods/helpers/log'; -import { isIOS, useDebounce } from '~/lib/methods/helpers'; +import { hasNativeHeaderBar, isIOS, useDebounce } from '~/lib/methods/helpers'; import SafeAreaView from '~/containers/SafeAreaView'; import * as HeaderButton from '~/containers/Header/components/HeaderButton'; import * as List from '~/containers/List'; @@ -49,6 +52,7 @@ const DiscussionsView = () => { const total = useRef(0); const searchText = useRef(''); const offset = useRef(0); + const searchBarRef = useRef(null); const { colors } = useTheme(); @@ -95,16 +99,16 @@ const DiscussionsView = () => { setSearch([]); searchText.current = ''; offset.current = 0; + searchBarRef.current?.clearText(); }; const onSearchPress = () => { setIsSearching(true); }; - const setHeader = () => { - let options: Partial; - if (isSearching) { - options = { + const setHeader = (): Partial => { + if (isSearching && !hasNativeHeaderBar) { + return { headerLeft: () => ( @@ -115,23 +119,21 @@ const DiscussionsView = () => { ), headerRight: () => null }; - return options; } - options = { - headerLeft: undefined, + return { headerTitle: I18n.t('Discussions'), - headerRight: () => ( - - - - ) + ...(hasNativeHeaderBar && { + headerSearchBarOptions: stackedSearchBarOptions({ + ref: searchBarRef, + onFocus: onSearchPress, + onChangeText: onSearchChangeText, + onCancel: onCancelSearchPress + }) + }), + ...(isMasterDetail ? outsideHeaderLeftClose(() => navigation.pop()) : { headerLeft: undefined }), + ...headerRightActions([{ label: I18n.t('Search'), icon: 'search', legacyHeaderOnly: true, onPress: onSearchPress }]) }; - - if (isMasterDetail) { - options.headerLeft = () => ; - } - return options; }; useEffect(() => { diff --git a/app/views/DisplayPrefsView.tsx b/app/views/DisplayPrefsView.tsx index 9ff29069fea..b4ab2bc552c 100644 --- a/app/views/DisplayPrefsView.tsx +++ b/app/views/DisplayPrefsView.tsx @@ -13,14 +13,12 @@ import Radio from '../containers/Radio'; import { type IPreferences } from '../definitions'; import I18n from '../i18n'; import { type SettingsStackParamList } from '../stacks/types'; -import { useTheme } from '../theme'; import { events, logEvent } from '../lib/methods/helpers/log'; import { saveSortPreference } from '../lib/methods/userPreferencesMethods'; import { useAppSelector } from '../lib/hooks/useAppSelector'; const DisplayPrefsView = (): ReactElement => { const navigation = useNavigation>(); - const { colors } = useTheme(); const { sortBy, groupByType, showFavorites, showUnread, showAvatar, displayMode } = useAppSelector( state => state.sortPreferences @@ -78,8 +76,8 @@ const DisplayPrefsView = (): ReactElement => { setSortPreference({ displayMode: DisplayMode.Condensed }); }; - const renderCheckBox = (value: boolean) => ( - + const renderCheckBox = (value: boolean, onValueChange: () => void) => ( + ); const renderAvatarSwitch = (value: boolean) => ( @@ -156,7 +154,7 @@ const DisplayPrefsView = (): ReactElement => { testID='display-pref-view-unread' left={() => } onPress={toggleUnread} - right={() => renderCheckBox(showUnread)} + right={() => renderCheckBox(showUnread, toggleUnread)} additionalAccessibilityLabel={showUnread} accessibilityRole='checkbox' /> @@ -166,7 +164,7 @@ const DisplayPrefsView = (): ReactElement => { testID='display-pref-view-favorites' left={() => } onPress={toggleGroupByFavorites} - right={() => renderCheckBox(showFavorites)} + right={() => renderCheckBox(showFavorites, toggleGroupByFavorites)} additionalAccessibilityLabel={showFavorites} accessibilityRole='checkbox' /> @@ -176,7 +174,7 @@ const DisplayPrefsView = (): ReactElement => { testID='display-pref-view-types' left={() => } onPress={toggleGroupByType} - right={() => renderCheckBox(groupByType)} + right={() => renderCheckBox(groupByType, toggleGroupByType)} additionalAccessibilityLabel={groupByType} accessibilityRole='checkbox' /> diff --git a/app/views/LanguageView/index.tsx b/app/views/LanguageView/index.tsx index 20f171eb830..e0ec3d7b101 100644 --- a/app/views/LanguageView/index.tsx +++ b/app/views/LanguageView/index.tsx @@ -1,12 +1,12 @@ import { reloadAppAsync } from 'expo'; -import { useLayoutEffect } from 'react'; -import { FlatList } from 'react-native'; +import { Fragment, useLayoutEffect } from 'react'; import { useDispatch } from 'react-redux'; +import { FlatList } from 'react-native'; import { useNavigation } from '@react-navigation/native'; import { type NativeStackNavigationProp } from '@react-navigation/native-stack'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; -import ListRadio from '~/containers/List/ListRadio'; +import ListRadio from '~/containers/List/components/ListRadio'; import { useAppSelector } from '~/lib/hooks/useAppSelector'; import { appStart } from '~/actions/app'; import { setUser } from '~/actions/login'; @@ -17,6 +17,7 @@ import I18n, { isRTL, LANGUAGES } from '~/i18n'; import database from '~/lib/database'; import { getUserSelector } from '~/selectors/login'; import { type SettingsStackParamList } from '~/stacks/types'; +import { isIOS } from '~/lib/methods/helpers'; import { showErrorAlert } from '~/lib/methods/helpers/info'; import log, { events, logEvent } from '~/lib/methods/helpers/log'; import { saveUserPreferences } from '~/lib/services/restApi'; @@ -92,26 +93,38 @@ const LanguageView = () => { } }; + const renderLanguage = (item: (typeof LANGUAGES)[number]) => ( + submit(item.value)} + /> + ); + return ( - item.value} - ListHeaderComponent={List.Separator} - ListFooterComponent={List.Separator} - contentContainerStyle={[List.styles.contentContainerStyleFlatList, { paddingBottom }]} - renderItem={({ item }) => ( - submit(item.value)} - /> - )} - ItemSeparatorComponent={List.Separator} - /> + {isIOS ? ( + + + {LANGUAGES.map(item => ( + {renderLanguage(item)} + ))} + + + ) : ( + item.value} + ListHeaderComponent={List.Separator} + ListFooterComponent={List.Separator} + contentContainerStyle={[List.styles.contentContainerStyleFlatList, { paddingBottom }]} + renderItem={({ item }) => renderLanguage(item)} + ItemSeparatorComponent={List.Separator} + /> + )} ); }; diff --git a/app/views/LoginView/index.tsx b/app/views/LoginView/index.tsx index f56401804da..e447f987e1c 100644 --- a/app/views/LoginView/index.tsx +++ b/app/views/LoginView/index.tsx @@ -4,8 +4,8 @@ import { type NativeStackNavigationProp } from '@react-navigation/native-stack'; import { useAppSelector } from '~/lib/hooks/useAppSelector'; import FormContainer, { FormContainerInner } from '~/containers/FormContainer'; -import * as HeaderButton from '~/containers/Header/components/HeaderButton'; import LoginServices from '~/containers/LoginServices'; +import { outsideHeaderRightLegal } from '~/lib/methods/helpers/navigation'; import { type OutsideParamList } from '~/stacks/types'; import UserForm from './UserForm'; @@ -25,7 +25,7 @@ const LoginView = ({ route }: LoginViewProps) => { useLayoutEffect(() => { navigation.setOptions({ title: title ?? 'Rocket.Chat', - headerRight: () => + ...outsideHeaderRightLegal(navigation, 'login-view-more') }); }, [navigation, title]); diff --git a/app/views/MediaAutoDownloadView/ListPicker.tsx b/app/views/MediaAutoDownloadView/ListPicker.tsx index ccf3f4f41cd..0cf42b8f758 100644 --- a/app/views/MediaAutoDownloadView/ListPicker.tsx +++ b/app/views/MediaAutoDownloadView/ListPicker.tsx @@ -3,22 +3,15 @@ import { StyleSheet, Text, View } from 'react-native'; import { useActionSheet } from '~/containers/ActionSheet'; import * as List from '~/containers/List'; +import { asNativeListRow } from '~/containers/List/native/utils/rowMarkers'; +import { useNativeListMode } from '~/containers/List/native/context'; +import NativeListPicker from '~/containers/List/native/components/Picker'; import I18n from '~/i18n'; import { useTheme } from '~/theme'; import sharedStyles from '../Styles'; import { type MediaDownloadOption } from '~/lib/constants/mediaAutoDownload'; const styles = StyleSheet.create({ - leftTitleContainer: { - flex: 1, - justifyContent: 'center', - alignItems: 'flex-start' - }, - leftTitle: { - ...sharedStyles.textMedium, - fontSize: 16, - lineHeight: 24 - }, rightContainer: { flex: 1 }, @@ -67,6 +60,7 @@ const ListPicker = ({ } & IBaseParams) => { const { showActionSheet, hideActionSheet } = useActionSheet(); const { colors } = useTheme(); + const nativeListMode = useNativeListMode(); const option = OPTIONS.find(option => option.value === value) || OPTIONS[2]; const getOptions = (): ReactElement => ( @@ -93,15 +87,24 @@ const ListPicker = ({ /* when picking an option the label should be Never but when showing among the other settings the label should be Off */ const label = option.label === 'Never' ? I18n.t('Off') : I18n.t(option.label); + if (nativeListMode === 'native') { + return ( + ({ label: I18n.t(i.label), value: i.value, testID: `${testID}-${i.value}` }))} + selection={option.value} + onSelectionChange={selected => onChangeValue(selected as MediaDownloadOption)} + /> + ); + } + return ( showActionSheet({ children: getOptions() })} - title={() => ( - - {title} - - )} + title={title} + translateTitle={false} right={() => ( {label} @@ -113,4 +116,4 @@ const ListPicker = ({ ); }; -export default ListPicker; +export default asNativeListRow(ListPicker); diff --git a/app/views/NewMessageView/ButtonCreate.ios.tsx b/app/views/NewMessageView/ButtonCreate.ios.tsx new file mode 100644 index 00000000000..b8718bf3341 --- /dev/null +++ b/app/views/NewMessageView/ButtonCreate.ios.tsx @@ -0,0 +1,30 @@ +import { CustomIcon } from '~/containers/CustomIcon'; +import NativeListRow from '~/containers/NativeListRow'; +import Disclosure from '~/containers/NativeListRow/components/Disclosure.ios'; +import { PlainSeparator } from '~/containers/NativeListRow/components/Separator'; +import I18n from '~/i18n'; +import { useTheme } from '~/theme'; +import { type IButton } from './ButtonCreate'; + +const ButtonCreate = ({ onPress, testID, title, icon, isFirst, isLast }: IButton) => { + const { colors } = useTheme(); + const translatedTitle = I18n.t(title); + + return ( + <> + } + trailing={} + /> + {isLast ? null : } + + ); +}; + +export default ButtonCreate; diff --git a/app/views/NewMessageView/ButtonCreate.tsx b/app/views/NewMessageView/ButtonCreate.tsx index e33d4ba54a4..d7c5ca2fee5 100644 --- a/app/views/NewMessageView/ButtonCreate.tsx +++ b/app/views/NewMessageView/ButtonCreate.tsx @@ -3,11 +3,13 @@ import { themes } from '~/lib/constants/colors'; import { CustomIcon, type TIconsName } from '~/containers/CustomIcon'; import { useTheme } from '~/theme'; -interface IButton { +export interface IButton { onPress: () => void; testID: string; title: string; icon: TIconsName; + isFirst?: boolean; + isLast?: boolean; } const ButtonCreate = ({ onPress, testID, title, icon }: IButton) => { diff --git a/app/views/NewMessageView/HeaderNewMessage.tsx b/app/views/NewMessageView/HeaderNewMessage.tsx index f5dca89ffef..50508d7cdb8 100644 --- a/app/views/NewMessageView/HeaderNewMessage.tsx +++ b/app/views/NewMessageView/HeaderNewMessage.tsx @@ -5,8 +5,8 @@ import { useDispatch } from 'react-redux'; import { useNavigation } from '@react-navigation/native'; import { createChannelRequest } from '~/actions/createChannel'; -import { themes } from '~/lib/constants/colors'; import SearchBox from '~/containers/SearchBox'; +import { type TIconsName } from '~/containers/CustomIcon'; import I18n from '~/i18n'; import Navigation from '~/lib/navigation/appNavigation'; import { useTheme } from '~/theme'; @@ -15,6 +15,7 @@ import { type NewMessageStackParamList } from '~/stacks/types'; import { compareServerVersion } from '~/lib/methods/helpers'; import { useAppSelector } from '~/lib/hooks/useAppSelector'; import { usePermissions } from '~/lib/hooks/usePermissions'; +import { useListBackgroundColor } from '~/containers/NativeListRow/hooks/useListBackgroundColor'; import ButtonCreate from './ButtonCreate'; const styles = StyleSheet.create({ @@ -23,10 +24,19 @@ const styles = StyleSheet.create({ } }); +interface IButtonConfig { + visible: boolean; + onPress: () => void; + title: string; + icon: TIconsName; + testID: string; +} + const HeaderNewMessage = ({ maxUsers, onChangeText }: { maxUsers: number; onChangeText: (text: string) => void }) => { const navigation = useNavigation>(); const dispatch = useDispatch(); - const { theme } = useTheme(); + const { colors } = useTheme(); + const listBackgroundColor = useListBackgroundColor(colors.surfaceTint); const serverVersion = useAppSelector(state => state.server.version as string); @@ -68,39 +78,52 @@ const HeaderNewMessage = ({ maxUsers, onChangeText }: { maxUsers: number; onChan }); }, [navigation]); + const buttons = [ + { + visible: createPublicChannelPermission || createPrivateChannelPermission, + onPress: createChannel, + title: 'Channel', + icon: 'channel-public', + testID: 'new-message-view-create-channel' + }, + { + visible: compareServerVersion(serverVersion, 'greaterThanOrEqualTo', '3.13.0') && createTeamPermission, + onPress: createTeam, + title: 'Team', + icon: 'teams', + testID: 'new-message-view-create-team' + }, + { + visible: maxUsers > 2 && createDirectMessagePermission, + onPress: createGroupChat, + title: 'Direct_message', + icon: 'message', + testID: 'new-message-view-create-direct-message' + }, + { + visible: createDiscussionPermission, + onPress: createDiscussion, + title: 'Discussion', + icon: 'discussions', + testID: 'new-message-view-create-discussion' + } + ].filter((button): button is IButtonConfig => Boolean(button.visible)); + return ( - <> - - {createPublicChannelPermission || createPrivateChannelPermission ? ( - - ) : null} - {compareServerVersion(serverVersion, 'greaterThanOrEqualTo', '3.13.0') && createTeamPermission ? ( - - ) : null} - {maxUsers > 2 && createDirectMessagePermission ? ( - - ) : null} - {createDiscussionPermission ? ( - - ) : null} - onChangeText(text)} testID='new-message-view-search' /> - - + + {buttons.map((button, index) => ( + + ))} + onChangeText(text)} testID='new-message-view-search' /> + ); }; diff --git a/app/views/NewMessageView/Item.ios.tsx b/app/views/NewMessageView/Item.ios.tsx new file mode 100644 index 00000000000..446db96fd25 --- /dev/null +++ b/app/views/NewMessageView/Item.ios.tsx @@ -0,0 +1,42 @@ +import Avatar from '~/containers/Avatar'; +import NativeListRow from '~/containers/NativeListRow'; +import { AVATAR_SIZE } from '~/containers/NativeListRow/constants'; +import I18n from '~/i18n'; +import { useMediaCallPermission } from '~/lib/hooks/useMediaCallPermission'; +import { useIsInActiveVoipCall } from '~/lib/services/voip/isInActiveVoipCall'; +import { isSelfUserId } from '~/lib/services/voip/isSelfUserId'; +import { useStartMediaCall } from './useStartMediaCall'; +import { type IItem } from './Item'; + +const Item = ({ userId, name, username, onPress, testID, onLongPress, isFirst, isLast }: IItem) => { + const hasMediaCallPermission = useMediaCallPermission(); + const isInActiveCall = useIsInActiveVoipCall(); + const isSelf = isSelfUserId(userId); + const startMediaCall = useStartMediaCall({ userId, name, username }); + + return ( + } + trailingAction={ + hasMediaCallPermission && !isSelf + ? { + icon: 'phone', + onPress: startMediaCall, + testID: `${testID}-call`, + accessibilityLabel: I18n.t('Voice_call'), + disabled: isInActiveCall + } + : undefined + } + /> + ); +}; + +export default Item; diff --git a/app/views/NewMessageView/Item.test.tsx b/app/views/NewMessageView/Item.test.tsx index 53d9dcf836c..87ca0096029 100644 --- a/app/views/NewMessageView/Item.test.tsx +++ b/app/views/NewMessageView/Item.test.tsx @@ -145,12 +145,12 @@ describe('NewMessageView Item', () => { }); it('should have correct accessibility label', () => { - const { getByLabelText } = render( + const { getByTestId } = render( {}} testID='new-message-view-item-john.doe' /> ); - expect(getByLabelText('John Doe')).toBeTruthy(); + expect(getByTestId('new-message-view-item-john.doe')).toHaveAccessibleName('John Doe'); }); it('should match snapshot when hasMediaCallPermission is false', () => { diff --git a/app/views/NewMessageView/Item.tsx b/app/views/NewMessageView/Item.tsx index 908dfae082d..52e3aff0393 100644 --- a/app/views/NewMessageView/Item.tsx +++ b/app/views/NewMessageView/Item.tsx @@ -7,19 +7,19 @@ import sharedStyles from '../Styles'; import { useTheme } from '~/theme'; import I18n from '~/i18n'; import { useMediaCallPermission } from '~/lib/hooks/useMediaCallPermission'; -import { usePeerAutocompleteStore } from '~/lib/services/voip/usePeerAutocompleteStore'; import { useIsInActiveVoipCall } from '~/lib/services/voip/isInActiveVoipCall'; import { isSelfUserId } from '~/lib/services/voip/isSelfUserId'; -import { showActionSheetRef } from '~/containers/ActionSheet'; -import { NewMediaCall } from '~/containers/NewMediaCall'; +import { useStartMediaCall } from './useStartMediaCall'; -interface IItem { +export interface IItem { userId: string; name: string; username: string; onPress(): void; testID: string; onLongPress?: () => void; + isFirst?: boolean; + isLast?: boolean; } const Item = ({ userId, name, username, onPress, testID, onLongPress }: IItem) => { @@ -28,17 +28,7 @@ const Item = ({ userId, name, username, onPress, testID, onLongPress }: IItem) = const isInActiveCall = useIsInActiveVoipCall(); const isSelf = isSelfUserId(userId); - const handleCallPress = () => { - if (!userId || isInActiveCall || isSelf) return; - usePeerAutocompleteStore.getState().setSelectedPeer({ type: 'user', value: userId, label: name, username }); - showActionSheetRef({ - children: , - portraitSnaps: ['60%'], - landscapeSnaps: ['90%'], - enableContentPanningGesture: false, - fullContainer: true - }); - }; + const handleCallPress = useStartMediaCall({ userId, name, username }); return ( - - John Doe - + + John Doe + + @@ -164,58 +178,73 @@ exports[`Story Snapshots: Default should match snapshot 1`] = ` } > - - John Doe - + + John Doe + + - + + - -  - - + undefined, + ], + { + "fontFamily": "custom", + "fontStyle": "normal", + "fontWeight": "normal", + }, + {}, + ] + } + > +  + @@ -396,58 +417,73 @@ exports[`Story Snapshots: LongName should match snapshot 1`] = ` } > - - Very Long Display Name That Might Get Truncated - + + Very Long Display Name That Might Get Truncated + + - + + - -  - - + undefined, + ], + { + "fontFamily": "custom", + "fontStyle": "normal", + "fontWeight": "normal", + }, + {}, + ] + } + > +  + @@ -628,58 +656,73 @@ exports[`Story Snapshots: NoVoiceCallPermission should match snapshot 1`] = ` } > - - John Doe - + + John Doe + + - + + - -  - - + undefined, + ], + { + "fontFamily": "custom", + "fontStyle": "normal", + "fontWeight": "normal", + }, + {}, + ] + } + > +  + diff --git a/app/views/NewMessageView/index.tsx b/app/views/NewMessageView/index.tsx index a61e7b7f280..6fc16d46866 100644 --- a/app/views/NewMessageView/index.tsx +++ b/app/views/NewMessageView/index.tsx @@ -20,6 +20,8 @@ import { type NewMessageStackParamList } from '~/stacks/types'; import { search as runSearch } from '~/lib/methods/search'; import { useAppSelector } from '~/lib/hooks/useAppSelector'; import { useMasterDetail } from '~/lib/hooks/useMasterDetail'; +import RowSeparator from '~/containers/NativeListRow/components/Separator'; +import { useListBackgroundColor } from '~/containers/NativeListRow/hooks/useListBackgroundColor'; import Item from './Item'; import HeaderNewMessage from './HeaderNewMessage'; import { getUidDirectMessage } from '~/lib/methods/helpers/helpers'; @@ -37,6 +39,7 @@ const NewMessageView = () => { const searchId = useRef(0); const { colors } = useTheme(); + const listBackgroundColor = useListBackgroundColor(colors.surfaceTint); const navigation = useNavigation>(); const { bottom } = useSafeAreaInsets(); @@ -110,13 +113,15 @@ const NewMessageView = () => { [isMasterDetail, navigation] ); + const data = search.length > 0 ? search : chats; + return ( 0 ? search : chats} + data={data} keyExtractor={item => item._id || item.rid} ListHeaderComponent={} - renderItem={({ item }) => { + renderItem={({ item, index }) => { const itemSearch = item as ISearch; const itemModel = item as TSubscriptionModel; const userId = itemSearch.search ? itemSearch._id : getUidDirectMessage(itemModel); @@ -128,12 +133,14 @@ const NewMessageView = () => { username={itemSearch.search ? itemSearch.username : itemModel.name} onPress={() => goRoom(itemModel)} testID={`new-message-view-item-${item.name}`} + isFirst={index === 0} + isLast={index === data.length - 1} /> ); }} - ItemSeparatorComponent={List.Separator} + ItemSeparatorComponent={RowSeparator} ListFooterComponent={searching ? () => : List.Separator} - style={{ backgroundColor: colors.surfaceTint }} + style={{ backgroundColor: listBackgroundColor }} contentContainerStyle={{ paddingBottom: bottom }} keyboardShouldPersistTaps='always' /> diff --git a/app/views/NewMessageView/useStartMediaCall.tsx b/app/views/NewMessageView/useStartMediaCall.tsx new file mode 100644 index 00000000000..9c68b7f3eae --- /dev/null +++ b/app/views/NewMessageView/useStartMediaCall.tsx @@ -0,0 +1,28 @@ +import { showActionSheetRef } from '~/containers/ActionSheet'; +import { NewMediaCall } from '~/containers/NewMediaCall'; +import { usePeerAutocompleteStore } from '~/lib/services/voip/usePeerAutocompleteStore'; +import { useIsInActiveVoipCall } from '~/lib/services/voip/isInActiveVoipCall'; +import { isSelfUserId } from '~/lib/services/voip/isSelfUserId'; + +interface IMediaCallPeer { + userId: string; + name: string; + username: string; +} + +export const useStartMediaCall = ({ userId, name, username }: IMediaCallPeer) => { + const isInActiveCall = useIsInActiveVoipCall(); + const isSelf = isSelfUserId(userId); + + return () => { + if (!userId || isInActiveCall || isSelf) return; + usePeerAutocompleteStore.getState().setSelectedPeer({ type: 'user', value: userId, label: name, username }); + showActionSheetRef({ + children: , + portraitSnaps: ['60%'], + landscapeSnaps: ['90%'], + enableContentPanningGesture: false, + fullContainer: true + }); + }; +}; diff --git a/app/views/NewServerView/index.tsx b/app/views/NewServerView/index.tsx index 99b885350f6..c4c07bf127d 100644 --- a/app/views/NewServerView/index.tsx +++ b/app/views/NewServerView/index.tsx @@ -10,11 +10,11 @@ import { inviteLinksClear } from '~/actions/inviteLinks'; import { selectServerRequest, serverFinishAdd, serverRequest } from '~/actions/server'; import Button from '~/containers/Button'; import FormContainer, { FormContainerInner } from '~/containers/FormContainer'; -import * as HeaderButton from '~/containers/Header/components/HeaderButton'; import { type TServerHistoryModel } from '~/definitions'; import I18n from '~/i18n'; import { useTheme } from '~/theme'; import { isAndroid, isTablet } from '~/lib/methods/helpers'; +import { outsideHeaderLeftClose } from '~/lib/methods/helpers/navigation'; import EventEmitter from '~/lib/methods/helpers/events'; import ServerInput from './components/ServerInput'; import { getServerById } from '~/lib/database/services/Server'; @@ -99,8 +99,9 @@ const NewServerView = () => { if (previousServer) { return navigation.setOptions({ headerTitle: I18n.t('Add_Server'), - headerLeft: () => - !connecting ? : null + ...(connecting + ? { headerLeft: () => null, unstable_headerLeftItems: () => [] } + : outsideHeaderLeftClose(close, 'new-server-view-close')) }); } return navigation.setOptions({ diff --git a/app/views/NotificationPreferencesView/index.tsx b/app/views/NotificationPreferencesView/index.tsx index 36c2f67f831..346d469bb25 100644 --- a/app/views/NotificationPreferencesView/index.tsx +++ b/app/views/NotificationPreferencesView/index.tsx @@ -6,6 +6,9 @@ import { type NativeStackNavigationProp } from '@react-navigation/native-stack'; import { type TActionSheetOptionsItem, useActionSheet } from '~/containers/ActionSheet'; import { CustomIcon } from '~/containers/CustomIcon'; import * as List from '~/containers/List'; +import { asNativeListRow } from '~/containers/List/native/utils/rowMarkers'; +import { useNativeListMode } from '~/containers/List/native/context'; +import NativeListPicker from '~/containers/List/native/components/Picker'; import SafeAreaView from '~/containers/SafeAreaView'; import { type IRoomNotifications, type TRoomNotificationsModel } from '~/definitions'; import I18n from '~/i18n'; @@ -43,6 +46,7 @@ const RenderListPicker = ({ } & IBaseParams) => { const { showActionSheet, hideActionSheet } = useActionSheet(); const { colors } = useTheme(); + const nativeListMode = useNativeListMode(); const pref = room[preference] ? OPTIONS[preference as TOptions].find(option => option.value === room[preference]) @@ -50,18 +54,43 @@ const RenderListPicker = ({ const [option, setOption] = useState(pref); + const selectOption = (selected: NonNullable) => { + const previous = option; + onChangeValue(preference, { [preference]: selected.value.toString() }, () => setOption(previous)); + setOption(selected); + }; + const options: TActionSheetOptionsItem[] = OPTIONS[preference as TOptions].map(i => ({ title: I18n.t(i.label, { defaultValue: i.label, second: i.second }), onPress: () => { hideActionSheet(); - onChangeValue(preference, { [preference]: i.value.toString() }, () => setOption(option)); - setOption(i); + selectOption(i); }, right: option?.value === i.value ? () => : undefined })); const label = option?.label ? I18n.t(option?.label, { defaultValue: option?.label, second: option?.second }) : option?.label; + if (nativeListMode === 'native') { + return ( + ({ + label: I18n.t(i.label, { defaultValue: i.label, second: i.second }), + value: i.value.toString() + }))} + selection={option?.value.toString() ?? ''} + onSelectionChange={selected => { + const selectedOption = OPTIONS[preference as TOptions].find(i => i.value.toString() === selected); + if (selectedOption) { + selectOption(selectedOption); + } + }} + /> + ); + } + return ( { const [switchValue, setSwitchValue] = useState(!room[preference]); return ( @@ -137,7 +168,7 @@ const NotificationPreferencesView = (): ReactElement => { }; return ( - + @@ -200,7 +231,7 @@ const NotificationPreferencesView = (): ReactElement => { - { onChangeValue={handleSaveNotificationSettings} /> - { - { - { + const { colors } = useTheme(); + const title = I18n.t(item.label, { defaultValue: item.label, second: item?.second }); + return ( + : undefined} + /> + ); +}; + +export default Item; diff --git a/app/views/PickerView/Item.tsx b/app/views/PickerView/Item.tsx new file mode 100644 index 00000000000..decc31504c0 --- /dev/null +++ b/app/views/PickerView/Item.tsx @@ -0,0 +1,28 @@ +import * as List from '~/containers/List'; +import I18n from '~/i18n'; +import { useTheme } from '~/theme'; +import { type IOptionsField } from '../NotificationPreferencesView/options'; + +export interface IPickerItem { + item: IOptionsField; + selected: boolean; + onItemPress: () => void; + isFirst: boolean; + isLast: boolean; +} + +const Item = ({ item, selected, onItemPress }: IPickerItem) => { + const { colors } = useTheme(); + return ( + (selected ? : null)} + onPress={onItemPress} + translateTitle={false} + additionalAccessibilityLabel={selected} + additionalAccessibilityLabelCheck + /> + ); +}; + +export default Item; diff --git a/app/views/PickerView.tsx b/app/views/PickerView/index.tsx similarity index 66% rename from app/views/PickerView.tsx rename to app/views/PickerView/index.tsx index ecd79700e98..e938bac7d55 100644 --- a/app/views/PickerView.tsx +++ b/app/views/PickerView/index.tsx @@ -2,17 +2,19 @@ import { type ReactElement, useLayoutEffect, useState } from 'react'; import { FlatList, StyleSheet, Text, type TextInputProps } from 'react-native'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; -import { textInputDebounceTime } from '../lib/constants/debounceConfig'; -import * as List from '../containers/List'; -import SafeAreaView from '../containers/SafeAreaView'; -import SearchBox from '../containers/SearchBox'; -import I18n from '../i18n'; -import { useAppNavigation, useAppRoute } from '../lib/hooks/navigation'; -import { useDebounce } from '../lib/methods/helpers'; -import { type TNavigation } from '../stacks/stackType'; -import { useTheme } from '../theme'; -import { type IOptionsField } from './NotificationPreferencesView/options'; -import sharedStyles from './Styles'; +import { textInputDebounceTime } from '~/lib/constants/debounceConfig'; +import * as List from '~/containers/List'; +import SafeAreaView from '~/containers/SafeAreaView'; +import SearchBox from '~/containers/SearchBox'; +import I18n from '~/i18n'; +import { useAppNavigation, useAppRoute } from '~/lib/hooks/navigation'; +import { useDebounce } from '~/lib/methods/helpers'; +import { type TNavigation } from '~/stacks/stackType'; +import { useTheme } from '~/theme'; +import sharedStyles from '../Styles'; +import { PlainSeparator } from '~/containers/NativeListRow/components/Separator'; +import { useListBackgroundColor } from '~/containers/NativeListRow/hooks/useListBackgroundColor'; +import Item from './Item'; const styles = StyleSheet.create({ noResult: { @@ -23,26 +25,6 @@ const styles = StyleSheet.create({ } }); -interface IItem { - item: IOptionsField; - selected: boolean; - onItemPress: () => void; -} - -const Item = ({ item, selected, onItemPress }: IItem) => { - const { colors } = useTheme(); - return ( - (selected ? : null)} - onPress={onItemPress} - translateTitle={false} - additionalAccessibilityLabel={selected} - additionalAccessibilityLabelCheck - /> - ); -}; - const RenderSearch = ({ onChangeText }: TextInputProps) => ( <> @@ -57,6 +39,7 @@ const PickerView = (): ReactElement => { } = useAppRoute(); const { colors } = useTheme(); + const listBackgroundColor = useListBackgroundColor(colors.surfaceHover); const { bottom } = useSafeAreaInsets(); const [data, setData] = useState(paramData); @@ -93,20 +76,22 @@ const PickerView = (): ReactElement => { }; return ( - + item.value as string} - renderItem={({ item }) => ( + renderItem={({ item, index }) => ( handleChangeValue(item.value)} + isFirst={index === 0} + isLast={index === data.length - 1} /> )} onEndReached={handleOnEndReached} onEndReachedThreshold={0.5} - ItemSeparatorComponent={List.Separator} + ItemSeparatorComponent={PlainSeparator} ListHeaderComponent={} contentContainerStyle={{ paddingBottom: bottom }} ListFooterComponent={List.Separator} diff --git a/app/views/ProfileView/index.tsx b/app/views/ProfileView/index.tsx index 218f781fc1b..096bdb8cf2a 100644 --- a/app/views/ProfileView/index.tsx +++ b/app/views/ProfileView/index.tsx @@ -38,7 +38,7 @@ import { useAppSelector } from '~/lib/hooks/useAppSelector'; import { useMasterDetail } from '~/lib/hooks/useMasterDetail'; import useParsedCustomFields from '~/lib/hooks/useParsedCustomFields'; import CustomFields from '~/containers/CustomFields'; -import ListSeparator from '~/containers/List/ListSeparator'; +import ListSeparator from '~/containers/List/components/ListSeparator'; import handleSaveUserProfileError from '~/lib/methods/helpers/handleSaveUserProfileError'; import logoutOtherLocations from './methods/logoutOtherLocations'; import buildProfileParams from './methods/buildProfileParams'; diff --git a/app/views/PushTroubleshootView/components/CommunityEditionPushQuota.tsx b/app/views/PushTroubleshootView/components/CommunityEditionPushQuota.tsx index e5b76c2bd36..4ca1b47b616 100644 --- a/app/views/PushTroubleshootView/components/CommunityEditionPushQuota.tsx +++ b/app/views/PushTroubleshootView/components/CommunityEditionPushQuota.tsx @@ -2,6 +2,7 @@ import { Alert, StyleSheet, Text } from 'react-native'; import { type ReactElement } from 'react'; import * as List from '~/containers/List'; +import { asNativeListSection } from '~/containers/List/native/utils/rowMarkers'; import i18n from '~/i18n'; import { useAppSelector } from '~/lib/hooks/useAppSelector'; import { useTheme } from '~/theme'; @@ -10,7 +11,7 @@ import sharedStyles from '~/views/Styles'; const WARNING_MINIMUM_VALUE = 70; const WARNING_MAXIMUM_VALUE = 90; -export default function CommunityEditionPushQuota(): ReactElement | null { +function CommunityEditionPushQuota(): ReactElement | null { const { colors } = useTheme(); const { consumptionPercentage, isCommunityEdition } = useAppSelector(state => ({ isCommunityEdition: state.troubleshootingNotification.isCommunityEdition, @@ -55,3 +56,5 @@ const styles = StyleSheet.create({ fontSize: 16 } }); + +export default asNativeListSection(CommunityEditionPushQuota); diff --git a/app/views/PushTroubleshootView/components/CustomListSection.tsx b/app/views/PushTroubleshootView/components/CustomListSection.tsx deleted file mode 100644 index 4707fbd7074..00000000000 --- a/app/views/PushTroubleshootView/components/CustomListSection.tsx +++ /dev/null @@ -1,57 +0,0 @@ -import { StyleSheet, useWindowDimensions, View } from 'react-native'; -import { type ReactElement } from 'react'; - -import { Header } from '~/containers/List'; - -const styles = StyleSheet.create({ - container: { - marginBottom: 16 - }, - headerContainer: { - flexDirection: 'row', - justifyContent: 'space-between', - alignItems: 'center' - }, - statusContainer: { - marginRight: 12 - } -}); - -interface ICustomListSection { - children: (ReactElement | null)[] | ReactElement | null; - title: string; - translateTitle?: boolean; - statusColor?: string; -} - -const CustomHeader = ({ - title, - translateTitle, - statusColor -}: { - title: string; - translateTitle?: boolean; - statusColor?: string; -}) => { - const { fontScale } = useWindowDimensions(); - const statusSize = { - width: 10 * fontScale, - height: 10 * fontScale, - borderRadius: 5 * fontScale - }; - return ( - -
- {statusColor ? : null} - - ); -}; - -const CustomListSection = ({ children, title, translateTitle, statusColor }: ICustomListSection) => ( - - {title ? : null} - {children} - -); - -export default CustomListSection; diff --git a/app/views/PushTroubleshootView/components/DeviceNotificationSettings.tsx b/app/views/PushTroubleshootView/components/DeviceNotificationSettings.tsx index c1ab52d07e1..d55625f4e25 100644 --- a/app/views/PushTroubleshootView/components/DeviceNotificationSettings.tsx +++ b/app/views/PushTroubleshootView/components/DeviceNotificationSettings.tsx @@ -6,9 +6,10 @@ import i18n from '~/i18n'; import { useAppSelector } from '~/lib/hooks/useAppSelector'; import { isIOS, showErrorAlert } from '~/lib/methods/helpers'; import { useTheme } from '~/theme'; -import CustomListSection from './CustomListSection'; +import { CustomIcon } from '~/containers/CustomIcon'; +import { asNativeListSection } from '~/containers/List/native/utils/rowMarkers'; -export default function DeviceNotificationSettings(): ReactElement { +function DeviceNotificationSettings(): ReactElement { const { colors } = useTheme(); const { deviceNotificationEnabled } = useAppSelector(state => ({ deviceNotificationEnabled: state.troubleshootingNotification.deviceNotificationEnabled @@ -32,17 +33,24 @@ export default function DeviceNotificationSettings(): ReactElement { }; return ( - + ( + + )} /> - + ); } + +export default asNativeListSection(DeviceNotificationSettings); diff --git a/app/views/PushTroubleshootView/components/NotificationDelay.tsx b/app/views/PushTroubleshootView/components/NotificationDelay.tsx index 808da1d75da..564b5f5f7e2 100644 --- a/app/views/PushTroubleshootView/components/NotificationDelay.tsx +++ b/app/views/PushTroubleshootView/components/NotificationDelay.tsx @@ -3,8 +3,9 @@ import { type ReactElement } from 'react'; import NewWindowIcon from '~/containers/NewWindowIcon'; import * as List from '~/containers/List'; +import { asNativeListSection } from '~/containers/List/native/utils/rowMarkers'; -export default function NotificationDelay(): ReactElement { +function NotificationDelay(): ReactElement { const openNotificationDocumentation = () => Linking.openURL('https://go.rocket.chat/i/push-notifications'); return ( @@ -22,3 +23,5 @@ export default function NotificationDelay(): ReactElement { ); } + +export default asNativeListSection(NotificationDelay); diff --git a/app/views/PushTroubleshootView/components/PushGatewayConnection.tsx b/app/views/PushTroubleshootView/components/PushGatewayConnection.tsx index f688fe57ff1..e0c45d1043a 100644 --- a/app/views/PushTroubleshootView/components/PushGatewayConnection.tsx +++ b/app/views/PushTroubleshootView/components/PushGatewayConnection.tsx @@ -8,9 +8,10 @@ import { usePermissions } from '~/lib/hooks/usePermissions'; import { compareServerVersion, showErrorAlertWithEMessage } from '~/lib/methods/helpers'; import { pushTest } from '~/lib/services/restApi'; import { useTheme } from '~/theme'; -import CustomListSection from './CustomListSection'; +import { CustomIcon } from '~/containers/CustomIcon'; +import { asNativeListSection } from '~/containers/List/native/utils/rowMarkers'; -export default function PushGatewayConnection(): ReactElement | null { +function PushGatewayConnection(): ReactElement | null { const [loading, setLoading] = useState(false); const { colors } = useTheme(); const [testPushNotificationsPermission] = usePermissions(['test-push-notifications']); @@ -48,18 +49,19 @@ export default function PushGatewayConnection(): ReactElement | null { } return ( - + } /> - + ); } + +export default asNativeListSection(PushGatewayConnection); diff --git a/app/views/PushTroubleshootView/index.tsx b/app/views/PushTroubleshootView/index.tsx index 6e2d38fccec..e49964b3332 100644 --- a/app/views/PushTroubleshootView/index.tsx +++ b/app/views/PushTroubleshootView/index.tsx @@ -33,7 +33,7 @@ const PushTroubleshootView = ({ navigation }: IPushTroubleshootViewProps) => { }, [navigation]); return ( - + {/* */} diff --git a/app/views/RegisterView/index.tsx b/app/views/RegisterView/index.tsx index 6697c9f1448..ea50310af83 100644 --- a/app/views/RegisterView/index.tsx +++ b/app/views/RegisterView/index.tsx @@ -11,10 +11,10 @@ import { type NativeStackNavigationProp } from '@react-navigation/native-stack'; import { loginRequest } from '~/actions/login'; import Button from '~/containers/Button'; import FormContainer, { FormContainerInner } from '~/containers/FormContainer'; -import * as HeaderButton from '~/containers/Header/components/HeaderButton'; import LoginServices from '~/containers/LoginServices'; import { ControlledFormTextInput } from '~/containers/TextInput'; import I18n from '~/i18n'; +import { outsideHeaderRightLegal } from '~/lib/methods/helpers/navigation'; import { getShowLoginButton } from '~/selectors/login'; import { type OutsideParamList } from '~/stacks/types'; import { useTheme } from '~/theme'; @@ -168,11 +168,9 @@ const RegisterView = ({ route }: RegisterViewProps) => { useLayoutEffect(() => { navigation.setOptions({ title: route?.params?.title, - headerRight: () => ( - - ) + ...outsideHeaderRightLegal(navigation, 'register-view-more') }); - }, []); + }, [navigation, route?.params?.title]); return ( diff --git a/app/views/RoomActionsView/components/CallSection.tsx b/app/views/RoomActionsView/components/CallSection.tsx index f8d8e8ec0be..c8ded098aa4 100644 --- a/app/views/RoomActionsView/components/CallSection.tsx +++ b/app/views/RoomActionsView/components/CallSection.tsx @@ -1,13 +1,14 @@ import { type ReactElement, useEffect, useState } from 'react'; import * as List from '~/containers/List'; +import { asNativeListSection } from '~/containers/List/native/utils/rowMarkers'; import { useVideoConf } from '~/lib/hooks/useVideoConf'; import type { TSubscriptionModel } from '~/definitions'; import { useNewMediaCall } from '~/lib/hooks/useNewMediaCall'; import { useIsInActiveVoipCall } from '~/lib/services/voip/isInActiveVoipCall'; import { videoConferenceGetCapabilities } from '~/lib/services/restApi'; -export default function CallSection({ +function CallSection({ room, disabled, itsMe @@ -69,3 +70,5 @@ export default function CallSection({ ); } + +export default asNativeListSection(CallSection); diff --git a/app/views/RoomActionsView/index.tsx b/app/views/RoomActionsView/index.tsx index c52c3ef18d9..277a20e5cf2 100644 --- a/app/views/RoomActionsView/index.tsx +++ b/app/views/RoomActionsView/index.tsx @@ -12,6 +12,7 @@ import { leaveRoom } from '~/actions/room'; import Avatar from '~/containers/Avatar'; import * as HeaderButton from '~/containers/Header/components/HeaderButton'; import * as List from '~/containers/List'; +import Disclosure from '~/containers/NativeListRow/components/Disclosure'; import { MarkdownPreview } from '~/containers/markdown'; import RoomTypeIcon from '~/containers/RoomTypeIcon'; import SafeAreaView from '~/containers/SafeAreaView'; @@ -49,6 +50,7 @@ import { hasPermission, isGroupChat, compareServerVersion, + isIOS, isTeamRoom } from '~/lib/methods/helpers'; import { @@ -787,7 +789,7 @@ class RoomActionsView extends Component @@ -832,7 +834,7 @@ class RoomActionsView extends Component )} - {isGroupChatHandler ? null : } + {isGroupChatHandler ? null : } diff --git a/app/views/RoomActionsView/styles.ts b/app/views/RoomActionsView/styles.ts index 983a6d5478b..7794f72a8f8 100644 --- a/app/views/RoomActionsView/styles.ts +++ b/app/views/RoomActionsView/styles.ts @@ -1,12 +1,13 @@ -import { I18nManager, StyleSheet } from 'react-native'; +import { StyleSheet } from 'react-native'; import { PADDING_HORIZONTAL } from '~/containers/List/constants'; +import { isIOS26OrLater } from '~/lib/methods/helpers/deviceInfo'; import sharedStyles from '../Styles'; export default StyleSheet.create({ roomInfoContainer: { paddingHorizontal: PADDING_HORIZONTAL, - paddingVertical: 4, + paddingVertical: isIOS26OrLater ? 12 : 4, flexDirection: 'row', alignItems: 'center' }, @@ -28,8 +29,5 @@ export default StyleSheet.create({ paddingRight: 16, flexDirection: 'row', alignItems: 'center' - }, - actionIndicator: { - ...(I18nManager.isRTL ? { transform: [{ rotate: '180deg' }] } : {}) } }); diff --git a/app/views/RoomMembersView/components/ActionsSection.ios.tsx b/app/views/RoomMembersView/components/ActionsSection.ios.tsx new file mode 100644 index 00000000000..77c8036d9f3 --- /dev/null +++ b/app/views/RoomMembersView/components/ActionsSection.ios.tsx @@ -0,0 +1,40 @@ +import { View } from 'react-native'; +import { type ReactElement } from 'react'; + +import NativeListRow from '~/containers/NativeListRow'; +import { CustomIcon } from '~/containers/CustomIcon'; +import Disclosure from '~/containers/NativeListRow/components/Disclosure.ios'; +import RowSeparator from '~/containers/NativeListRow/components/Separator'; +import i18n from '~/i18n'; +import { useTheme } from '~/theme'; +import { type IActionsSection, useMemberActions } from './useMemberActions'; + +export default function ActionsSection(props: IActionsSection): ReactElement | null { + const { colors } = useTheme(); + const actions = useMemberActions(props); + + if (!actions.length) { + return null; + } + + return ( + + {actions.map((action, index) => ( + + {index > 0 ? : null} + } + trailing={} + /> + + ))} + + ); +} diff --git a/app/views/RoomMembersView/components/ActionsSection.tsx b/app/views/RoomMembersView/components/ActionsSection.tsx index a78300c488d..db0c677c890 100644 --- a/app/views/RoomMembersView/components/ActionsSection.tsx +++ b/app/views/RoomMembersView/components/ActionsSection.tsx @@ -1,114 +1,29 @@ -import { type CompositeNavigationProp, useNavigation } from '@react-navigation/native'; -import { type NativeStackNavigationProp } from '@react-navigation/native-stack'; import { View } from 'react-native'; -import { useDispatch } from 'react-redux'; import { type ReactElement } from 'react'; -import { setLoading } from '~/actions/selectedUsers'; import * as List from '~/containers/List'; -import { type TSubscriptionModel } from '~/definitions'; -import i18n from '~/i18n'; -import { usePermissions } from '~/lib/hooks/usePermissions'; -import log, { events, logEvent } from '~/lib/methods/helpers/log'; -import { addUsersToRoom } from '~/lib/services/restApi'; -import { type MasterDetailInsideStackParamList } from '~/stacks/MasterDetailStack/types'; -import { type ChatsStackParamList } from '~/stacks/types'; +import { type IActionsSection, useMemberActions } from './useMemberActions'; -type TNavigation = CompositeNavigationProp< - NativeStackNavigationProp, - NativeStackNavigationProp ->; - -interface IActionsSection { - rid: TSubscriptionModel['rid']; - t: TSubscriptionModel['t']; - joined: boolean; - abacAttributes: TSubscriptionModel['abacAttributes']; -} - -export default function ActionsSection({ rid, t, joined, abacAttributes }: IActionsSection): ReactElement { - const { navigate, pop } = useNavigation(); - const dispatch = useDispatch(); - const [addUserToJoinedRoomPermission, addUserToAnyCRoomPermission, addUserToAnyPRoomPermission, createInviteLinksPermission] = - usePermissions(['add-user-to-joined-room', 'add-user-to-any-c-room', 'add-user-to-any-p-room', 'create-invite-links'], rid); - - const canAddUser = - (joined && addUserToJoinedRoomPermission) || - (t === 'c' && addUserToAnyCRoomPermission) || - (t === 'p' && addUserToAnyPRoomPermission) || - false; - - const canInviteUser = createInviteLinksPermission; - - const handleOnPress = ({ - route, - params - }: { - route: keyof ChatsStackParamList; - params: ChatsStackParamList[keyof ChatsStackParamList]; - }) => { - // @ts-ignore - navigate(route, params); - // @ts-ignore - logEvent(events[`RM_GO_${route.replace('View', '').toUpperCase()}`]); - }; - - const addUser = async () => { - try { - dispatch(setLoading(true)); - await addUsersToRoom(rid); - pop(); - } catch (e) { - log(e); - } finally { - dispatch(setLoading(false)); - } - }; +export default function ActionsSection(props: IActionsSection): ReactElement { + const actions = useMemberActions(props); return ( - - {['c', 'p'].includes(t) && canAddUser ? ( - <> - - - handleOnPress({ - route: 'SelectedUsersView', - params: { - title: i18n.t('Add_users'), - nextAction: addUser, - showSkipText: false - } - }) - } - testID='room-actions-add-user' - left={() => } - showActionIndicator - /> - - - ) : null} - - {['c', 'p'].includes(t) && canInviteUser ? ( - <> + + {actions.map((action, index) => ( + + {index === 0 ? : null} - handleOnPress({ - route: 'InviteUsersView', - params: { rid } - }) - } - testID='room-actions-invite-user' - left={() => } + title={action.title} + onPress={action.onPress} + testID={action.testID} + left={() => } showActionIndicator - disabled={!!abacAttributes} - disabledReason={abacAttributes ? i18n.t('ABAC_disabled_action_reason') : undefined} + disabled={action.disabled} + disabledReason={action.disabledReason} /> - - ) : null} + + ))} ); } diff --git a/app/views/RoomMembersView/components/useMemberActions.ts b/app/views/RoomMembersView/components/useMemberActions.ts new file mode 100644 index 00000000000..225074bafa1 --- /dev/null +++ b/app/views/RoomMembersView/components/useMemberActions.ts @@ -0,0 +1,101 @@ +import { type CompositeNavigationProp, useNavigation } from '@react-navigation/native'; +import { type NativeStackNavigationProp } from '@react-navigation/native-stack'; +import { useDispatch } from 'react-redux'; + +import { setLoading } from '~/actions/selectedUsers'; +import { type TIconsName } from '~/containers/CustomIcon'; +import { type TSubscriptionModel } from '~/definitions'; +import i18n from '~/i18n'; +import { usePermissions } from '~/lib/hooks/usePermissions'; +import log, { events, logEvent } from '~/lib/methods/helpers/log'; +import { addUsersToRoom } from '~/lib/services/restApi'; +import { type MasterDetailInsideStackParamList } from '~/stacks/MasterDetailStack/types'; +import { type ChatsStackParamList } from '~/stacks/types'; + +type TNavigation = CompositeNavigationProp< + NativeStackNavigationProp, + NativeStackNavigationProp +>; + +export interface IActionsSection { + rid: TSubscriptionModel['rid']; + t: TSubscriptionModel['t']; + joined: boolean; + abacAttributes: TSubscriptionModel['abacAttributes']; +} + +export interface IMemberAction { + title: string; + icon: TIconsName; + onPress: () => void; + testID: string; + disabled?: boolean; + disabledReason?: string; +} + +export const useMemberActions = ({ rid, t, joined, abacAttributes }: IActionsSection): IMemberAction[] => { + const { navigate, pop } = useNavigation(); + const dispatch = useDispatch(); + const [addUserToJoinedRoomPermission, addUserToAnyCRoomPermission, addUserToAnyPRoomPermission, createInviteLinksPermission] = + usePermissions(['add-user-to-joined-room', 'add-user-to-any-c-room', 'add-user-to-any-p-room', 'create-invite-links'], rid); + + if (!['c', 'p'].includes(t)) { + return []; + } + + const canAddUser = + (joined && addUserToJoinedRoomPermission) || + (t === 'c' && addUserToAnyCRoomPermission) || + (t === 'p' && addUserToAnyPRoomPermission) || + false; + + const handleOnPress = ({ + route, + params + }: { + route: keyof ChatsStackParamList; + params: ChatsStackParamList[keyof ChatsStackParamList]; + }) => { + // @ts-ignore + navigate(route, params); + // @ts-ignore + logEvent(events[`RM_GO_${route.replace('View', '').toUpperCase()}`]); + }; + + const addUser = async () => { + try { + dispatch(setLoading(true)); + await addUsersToRoom(rid); + pop(); + } catch (e) { + log(e); + } finally { + dispatch(setLoading(false)); + } + }; + + const actions: IMemberAction[] = []; + if (canAddUser) { + actions.push({ + title: 'Add_users', + icon: 'add', + testID: 'room-actions-add-user', + onPress: () => + handleOnPress({ + route: 'SelectedUsersView', + params: { title: i18n.t('Add_users'), nextAction: addUser, showSkipText: false } + }) + }); + } + if (createInviteLinksPermission) { + actions.push({ + title: 'Invite_users', + icon: 'user-add', + testID: 'room-actions-invite-user', + onPress: () => handleOnPress({ route: 'InviteUsersView', params: { rid } }), + disabled: !!abacAttributes, + disabledReason: abacAttributes ? i18n.t('ABAC_disabled_action_reason') : undefined + }); + } + return actions; +}; diff --git a/app/views/RoomMembersView/index.tsx b/app/views/RoomMembersView/index.tsx index 0fc57d136b1..42eebd40b0c 100644 --- a/app/views/RoomMembersView/index.tsx +++ b/app/views/RoomMembersView/index.tsx @@ -1,6 +1,6 @@ import { type NavigationProp, type RouteProp, useNavigation, useRoute } from '@react-navigation/native'; -import { type ReactElement, useCallback, useEffect, useReducer, useRef } from 'react'; -import { FlatList, Text, View } from 'react-native'; +import { type ReactElement, useCallback, useEffect, useLayoutEffect, useReducer, useRef } from 'react'; +import { FlatList, Text } from 'react-native'; import { shallowEqual } from 'react-redux'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; @@ -8,18 +8,19 @@ import { type TActionSheetOptionsItem, useActionSheet } from '~/containers/Actio import { sendLoadingEvent } from '~/containers/Loading'; import ActivityIndicator from '~/containers/ActivityIndicator'; import { CustomIcon, type TIconsName } from '~/containers/CustomIcon'; -import * as HeaderButton from '~/containers/Header/components/HeaderButton'; -import * as List from '~/containers/List'; import SafeAreaView from '~/containers/SafeAreaView'; +import RowSeparator from '~/containers/NativeListRow/components/Separator'; +import { useListBackgroundColor } from '~/containers/NativeListRow/hooks/useListBackgroundColor'; import SearchBox from '~/containers/SearchBox'; import UserItem from '~/containers/UserItem'; -import Radio from '~/containers/Radio'; import { type IGetRoomRoles, type TSubscriptionModel, type TUserModel } from '~/definitions'; import I18n from '~/i18n'; +import { stackedSearchBarOptions } from '~/lib/methods/helpers/navigation'; +import { headerRightActions } from '~/lib/methods/helpers/navigation/headerActions'; import { useAppSelector } from '~/lib/hooks/useAppSelector'; import { useMasterDetail } from '~/lib/hooks/useMasterDetail'; import { usePermissions } from '~/lib/hooks/usePermissions'; -import { compareServerVersion, getRoomTitle, isGroupChat, useDebounce } from '~/lib/methods/helpers'; +import { compareServerVersion, getRoomTitle, hasNativeHeaderBar, isGroupChat, useDebounce } from '~/lib/methods/helpers'; import { handleIgnore } from '~/lib/methods/helpers/handleIgnore'; import { showConfirmationAlert } from '~/lib/methods/helpers/info'; import log from '~/lib/methods/helpers/log'; @@ -73,6 +74,7 @@ const RightIcon = ({ check, label }: { check: boolean; label: string }) => { const RoomMembersView = (): ReactElement => { const { showActionSheet } = useActionSheet(); const { colors } = useTheme(); + const listBackgroundColor = useListBackgroundColor(colors.surfaceHover); const { params } = useRoute>(); const navigation = useNavigation>(); @@ -125,7 +127,7 @@ const RoomMembersView = (): ReactElement => { viewAllTeamsPermission ] = usePermissions(['mute-user', 'set-leader', 'set-owner', 'set-moderator', 'remove-user', ...teamPermissions], params.rid); - useEffect(() => { + useLayoutEffect(() => { const subscription = params?.room?.observe && params.room.observe().subscribe(changes => updateState({ room: changes })); setHeader(true); return () => subscription?.unsubscribe(); @@ -252,33 +254,31 @@ const RoomMembersView = (): ReactElement => { const setHeader = (allUsers: boolean) => { navigation.setOptions({ title: I18n.t('Members'), - headerRight: () => ( - - - showActionSheet({ - options: [ - { - title: I18n.t('Online'), - onPress: () => toggleStatus(false), - right: () => , - testID: 'room-members-view-toggle-status-online' - }, - { - title: I18n.t('All'), - onPress: () => toggleStatus(true), - right: () => , - testID: 'room-members-view-toggle-status-all' - } - ], - enableContentPanningGesture: false - }) + ...(hasNativeHeaderBar && { + headerTransparent: true, + headerSearchBarOptions: stackedSearchBarOptions({ onChangeText: debounceFilterChange }) + }), + ...headerRightActions([ + { + label: I18n.t('Filter'), + icon: 'filter', + testID: 'room-members-view-filter', + menu: [ + { + label: I18n.t('Online'), + checked: !allUsers, + testID: 'room-members-view-toggle-status-online', + onPress: () => toggleStatus(false) + }, + { + label: I18n.t('All'), + checked: allUsers, + testID: 'room-members-view-toggle-status-all', + onPress: () => toggleStatus(true) } - testID='room-members-view-filter' - /> - - ) + ] + } + ]) }); }; @@ -429,20 +429,22 @@ const RoomMembersView = (): ReactElement => { ( - - onPressUser(item)} - testID={`room-members-view-item-${item.username}`} - /> - + contentInsetAdjustmentBehavior={hasNativeHeaderBar ? 'automatic' : undefined} + renderItem={({ item, index }) => ( + onPressUser(item)} + testID={`room-members-view-item-${item.username}`} + style={{ backgroundColor: colors.surfaceRoom }} + isFirst={index === 0} + isLast={index === state.members.length - 1} + /> )} - style={styles.list} - contentContainerStyle={{ paddingBottom: bottom }} + style={[styles.list, { backgroundColor: listBackgroundColor }]} + contentContainerStyle={{ flexGrow: 1, paddingBottom: bottom }} keyExtractor={item => item._id} - ItemSeparatorComponent={List.Separator} + ItemSeparatorComponent={RowSeparator} ListHeaderComponent={ <> { t={state.room.t} abacAttributes={state.room.abacAttributes} /> - debounceFilterChange(text)} testID='room-members-view-search' /> + {hasNativeHeaderBar ? null : ( + debounceFilterChange(text)} testID='room-members-view-search' /> + )} } ListFooterComponent={() => (state.isLoading ? : null)} diff --git a/app/views/RoomView/RoomScreen.tsx b/app/views/RoomView/RoomScreen.tsx index 07cb4ee497a..70ddc00ae14 100644 --- a/app/views/RoomView/RoomScreen.tsx +++ b/app/views/RoomView/RoomScreen.tsx @@ -8,6 +8,7 @@ import JoinCode from './components/JoinCode'; import { type IJoinCode, type IRoomScreenProps } from './definitions'; import { RoomProviders } from './components/RoomProviders'; import { RoomAnnouncementBanner } from './components/RoomAnnouncementBanner'; +import { RoomBodyFocusAnchor } from './components/RoomBodyFocusAnchor'; import { RoomFooter } from './components/RoomFooter/RoomFooter'; import { RoomMessageActions } from './components/RoomMessageActions'; import { RoomMessageList } from './components/RoomMessageList'; @@ -87,36 +88,38 @@ const RoomScreen = ({ route, rid, t, tmid, roomStore, ready }: IRoomScreenProps) editCancel={onEditCancel} editRequest={onEditRequest} onSendMessage={sendMessage}> - - {!tmid ? : null} - - - - - - + + + {!tmid ? : null} + + + + + + + diff --git a/app/views/RoomView/__tests__/RoomView.test.tsx b/app/views/RoomView/__tests__/RoomView.test.tsx index 29631bbaae8..db38cd6b187 100644 --- a/app/views/RoomView/__tests__/RoomView.test.tsx +++ b/app/views/RoomView/__tests__/RoomView.test.tsx @@ -29,7 +29,7 @@ jest.mock('../components/EncryptedRoom', () => { const { View: RNView } = require('react-native'); return { EncryptedRoom: () => createElement(RNView, { testID: 'encrypted-screen' }) }; }); -jest.mock('../hooks/useHeader', () => ({ useHeader: jest.fn() })); +jest.mock('../components/RoomViewHeader/RoomViewHeader', () => ({ RoomViewHeader: () => null })); jest.mock('../hooks/useE2EEStatus', () => ({ useE2EEStatus: jest.fn(() => ({ showMissingE2EEKey: false, showE2EEDisabledRoom: false, hasE2EEWarning: false })) })); diff --git a/app/views/RoomView/__tests__/RoomViewRenderIsolation.test.tsx b/app/views/RoomView/__tests__/RoomViewRenderIsolation.test.tsx new file mode 100644 index 00000000000..715144516a5 --- /dev/null +++ b/app/views/RoomView/__tests__/RoomViewRenderIsolation.test.tsx @@ -0,0 +1,70 @@ +import { act, render } from '@testing-library/react-native'; +import { Provider } from 'react-redux'; +import { createStore as createReduxStore } from 'redux'; +import { createStore, type StoreApi } from 'zustand'; + +import RoomView from '../index'; +import { type IRoomViewProps, type RoomState } from '../definitions'; +import { useE2EEStatus } from '../hooks/useE2EEStatus'; + +const mockSetOptions = jest.fn(); +jest.mock('@react-navigation/native', () => ({ + useNavigation: () => ({ setOptions: mockSetOptions }) +})); +jest.mock('~/lib/methods/helpers', () => ({ + hasNativeHeaderBar: false, + getUidDirectMessage: jest.fn(), + getRoomTitle: () => 'general', + isGroupChat: () => false +})); +jest.mock('~/lib/methods/isInviteSubscription', () => ({ isInviteSubscription: () => false })); +jest.mock('../hooks/useE2EEStatus', () => ({ + useE2EEStatus: jest.fn(() => ({ showMissingE2EEKey: false, showE2EEDisabledRoom: false, hasE2EEWarning: false })) +})); +jest.mock('../hooks/useGoRoomActionsView', () => ({ useGoRoomActionsView: () => jest.fn() })); +jest.mock('../RoomScreen', () => ({ __esModule: true, default: () => null })); +jest.mock('../components/LeftButtons', () => ({ __esModule: true, default: 'LeftButtons' })); +jest.mock('../components/RightButtons/RightButtons', () => ({ __esModule: true, default: 'RightButtons' })); +jest.mock('~/containers/RoomHeader', () => ({ __esModule: true, default: 'RoomHeader' })); +jest.mock('../components/RightButtons/useRoomRightButtonsData', () => ({ useRoomRightButtonsData: jest.fn() })); +jest.mock('../components/RightButtons/useHeaderCallPress', () => ({ useHeaderCallPress: jest.fn() })); + +const mockRoomStore: { current: StoreApi> | null } = { current: null }; +jest.mock('../stores/RoomStore', () => ({ + createRoomStore: () => mockRoomStore.current, + observeRoom: (_rid: string, _store: unknown, onReady: () => void) => { + onReady(); + return jest.fn(); + } +})); + +const latestHeaderTitle = () => + mockSetOptions.mock.calls + .map(([options]) => options) + .filter(options => 'headerTitle' in options) + .at(-1) + .headerTitle(); + +it('updates the header without re-rendering the room content when only header data changes', () => { + mockRoomStore.current = createStore>(() => ({ + room: { id: 'sub-1', rid: 'rid-1', t: 'c', topic: 'Old topic' } as RoomState['room'], + roomUserId: null + })); + const route = { params: { rid: 'rid-1', t: 'c' } } as unknown as IRoomViewProps['route']; + const navigation = { setOptions: mockSetOptions } as unknown as IRoomViewProps['navigation']; + render( + ({ server: { version: '6.1.0' } }))}> + + + ); + const contentRenders = jest.mocked(useE2EEStatus).mock.calls.length; + + act(() => { + mockRoomStore.current?.setState({ + room: { id: 'sub-1', rid: 'rid-1', t: 'c', topic: 'New topic' } as RoomState['room'] + }); + }); + + expect(latestHeaderTitle().props.subtitle).toBe('New topic'); + expect(jest.mocked(useE2EEStatus).mock.calls.length).toBe(contentRenders); +}); diff --git a/app/views/RoomView/__tests__/roomAndThreadScreens.test.tsx b/app/views/RoomView/__tests__/roomAndThreadScreens.test.tsx index 7698be00c29..64a0ad7793a 100644 --- a/app/views/RoomView/__tests__/roomAndThreadScreens.test.tsx +++ b/app/views/RoomView/__tests__/roomAndThreadScreens.test.tsx @@ -74,7 +74,7 @@ jest.mock('~/containers/MessageComposer', () => ({ MessageComposerContainer: 'Me jest.mock('../components/RoomMessageActions', () => ({ RoomMessageActions: 'RoomMessageActions' })); jest.mock('../components/UploadProgress', () => ({ __esModule: true, default: 'UploadProgress' })); jest.mock('../hooks/useRoomMessageHandlers', () => ({ useRoomMessageHandlers: () => ({}) })); -jest.mock('../hooks/useHeader', () => ({ useHeader: jest.fn() })); +jest.mock('../components/RoomViewHeader/RoomViewHeader', () => ({ RoomViewHeader: () => null })); jest.mock('../hooks/useRoomSubscription', () => ({ useRoomSubscription: jest.fn() })); jest.mock('../hooks/useRoomAudioLifecycle', () => ({ useRoomAudioLifecycle: jest.fn() })); jest.mock('../hooks/useRoomRemoved', () => ({ useRoomRemoved: jest.fn() })); diff --git a/app/views/RoomView/components/RightButtons/OmnichannelRightButtons.tsx b/app/views/RoomView/components/RightButtons/OmnichannelRightButtons.tsx deleted file mode 100644 index da8d0a635b8..00000000000 --- a/app/views/RoomView/components/RightButtons/OmnichannelRightButtons.tsx +++ /dev/null @@ -1,99 +0,0 @@ -import { type ReactElement } from 'react'; -import { useStore } from 'zustand'; -import { useNavigation } from '@react-navigation/native'; - -import { type TActionSheetOptionsItem, useActionSheet } from '~/containers/ActionSheet'; -import * as HeaderButton from '~/containers/Header/components/HeaderButton'; -import i18n from '~/i18n'; -import { showConfirmationAlert, showErrorAlert } from '~/lib/methods/helpers'; -import { events, logEvent } from '~/lib/methods/helpers/log'; -import { useCanReturnQueue } from '~/ee/omnichannel/hooks/useCanReturnQueue'; -import { useMasterDetail } from '~/lib/hooks/useMasterDetail'; -import { useSetting } from '~/lib/hooks/useSetting'; -import { returnLivechat } from '~/lib/services/restApi'; -import { type RoomStore } from '~/views/RoomView/definitions'; -import { fromSubscription } from '~/views/RoomView/stores/RoomStoreContext'; -import { useCanPlaceLivechatOnHold } from '~/views/RoomView/hooks/useCanPlaceLivechatOnHold'; -import { navigateToScreen, type TRoomStackNavigation } from '~/views/RoomView/services/navigateToScreen'; -import { closeLivechat } from '~/views/RoomView/services/closeLivechat'; -import { placeLivechatOnHold } from '~/views/RoomView/services/placeLivechatOnHold'; - -interface IOmnichannelRightButtonsProps { - rid: string; - roomStore: RoomStore; -} - -export const OmnichannelRightButtons = ({ rid, roomStore }: IOmnichannelRightButtonsProps): ReactElement => { - const navigation = useNavigation(); - const isMasterDetail = useMasterDetail(); - const { showActionSheet } = useActionSheet(); - - const livechatRequestComment = useSetting('Livechat_request_comment_when_closing_conversation') as boolean; - - const departmentId = useStore( - roomStore, - fromSubscription(room => room.departmentId, undefined) - ); - const canForwardGuest = useStore(roomStore, s => s.canForwardGuest); - const canReturnQueue = useCanReturnQueue(true); - const canPlaceLivechatOnHold = useCanPlaceLivechatOnHold(roomStore); - - const handleReturnLivechat = () => { - showConfirmationAlert({ - message: i18n.t('Would_you_like_to_return_the_inquiry'), - confirmationText: i18n.t('Yes'), - onPress: async () => { - try { - await returnLivechat(rid, departmentId); - } catch (e: any) { - showErrorAlert(e.reason, i18n.t('Oops')); - } - } - }); - }; - - const showMoreActions = () => { - logEvent(events.ROOM_SHOW_MORE_ACTIONS); - const options = [] as TActionSheetOptionsItem[]; - if (canPlaceLivechatOnHold) { - options.push({ - title: i18n.t('Place_chat_on_hold'), - icon: 'pause', - onPress: () => placeLivechatOnHold({ rid, navigation }) - }); - } - - if (canForwardGuest) { - options.push({ - title: i18n.t('Forward_Chat'), - icon: 'chat-forward', - onPress: () => { - navigateToScreen({ navigation, isMasterDetail, screen: 'ForwardLivechatView', params: { rid } }); - } - }); - } - - if (canReturnQueue) { - options.push({ - title: i18n.t('Return_to_waiting_line'), - icon: 'move-to-the-queue', - onPress: () => handleReturnLivechat() - }); - } - - options.push({ - title: i18n.t('Close'), - icon: 'chat-close', - onPress: () => closeLivechat({ rid, departmentId, isMasterDetail, livechatRequestComment, navigation }), - danger: true - }); - - showActionSheet({ options }); - }; - - return ( - - - - ); -}; diff --git a/app/views/RoomView/components/RightButtons/RightButtons.tsx b/app/views/RoomView/components/RightButtons/RightButtons.tsx index d147c1a75b1..42c3350b533 100644 --- a/app/views/RoomView/components/RightButtons/RightButtons.tsx +++ b/app/views/RoomView/components/RightButtons/RightButtons.tsx @@ -1,12 +1,8 @@ import { type ReactElement } from 'react'; -import { useStore } from 'zustand'; -import { useShallow } from 'zustand/react/shallow'; +import { HeaderActions } from '~/lib/methods/helpers/navigation/headerActions'; import { type RoomStore } from '~/views/RoomView/definitions'; -import { fromSubscription } from '~/views/RoomView/stores/RoomStoreContext'; -import { OmnichannelRightButtons } from './OmnichannelRightButtons'; -import { RoomRightButtons } from './RoomRightButtons'; -import { ThreadRightButtons } from './ThreadRightButtons'; +import { RoomHeaderActions } from '~/views/RoomView/components/RoomViewHeader/RoomHeaderActions'; interface IRightButtonsProps { rid?: string; @@ -14,36 +10,8 @@ interface IRightButtonsProps { roomStore: RoomStore; } -const RightButtons = ({ rid, tmid, roomStore }: IRightButtonsProps): ReactElement | null => { - const { t, status, membership } = useStore( - roomStore, - useShallow(s => ({ - t: s.room.t, - status: fromSubscription(room => room.status, undefined)(s), - membership: s.membership - })) - ); - - if (!rid) { - return null; - } - - if (membership === 'invited') { - return null; - } - - if (t === 'l') { - if (status === 'queued' || membership !== 'subscribed') { - return null; - } - return ; - } - - if (tmid) { - return ; - } - - return ; -}; +const RightButtons = ({ rid, tmid, roomStore }: IRightButtonsProps): ReactElement => ( + +); export default RightButtons; diff --git a/app/views/RoomView/components/RightButtons/ThreadRightButtons.tsx b/app/views/RoomView/components/RightButtons/ThreadRightButtons.tsx deleted file mode 100644 index f1029284d86..00000000000 --- a/app/views/RoomView/components/RightButtons/ThreadRightButtons.tsx +++ /dev/null @@ -1,34 +0,0 @@ -import { type ReactElement } from 'react'; - -import * as HeaderButton from '~/containers/Header/components/HeaderButton'; -import i18n from '~/i18n'; -import { events, logEvent } from '~/lib/methods/helpers/log'; -import { useAppSelector } from '~/lib/hooks/useAppSelector'; -import { toggleFollowThread } from '~/lib/methods/toggleFollowThread'; -import { getUserSelector } from '~/selectors/login'; -import { useThreadFollowing } from '~/views/RoomView/hooks/useThreadFollowing'; - -interface IThreadRightButtonsProps { - tmid: string; -} - -export const ThreadRightButtons = ({ tmid }: IThreadRightButtonsProps): ReactElement => { - const userId = useAppSelector(state => getUserSelector(state).id); - const isFollowingThread = useThreadFollowing(tmid, userId); - - const onToggleFollowThread = () => { - logEvent(events.ROOM_TOGGLE_FOLLOW_THREADS); - toggleFollowThread(tmid, isFollowingThread); - }; - - return ( - - - - ); -}; diff --git a/app/views/RoomView/components/RightButtons/__tests__/HeaderCallButton.test.tsx b/app/views/RoomView/components/RightButtons/__tests__/HeaderCallButton.test.tsx deleted file mode 100644 index 9fb8b7d378d..00000000000 --- a/app/views/RoomView/components/RightButtons/__tests__/HeaderCallButton.test.tsx +++ /dev/null @@ -1,135 +0,0 @@ -import { act, fireEvent, render, screen } from '@testing-library/react-native'; - -import { HeaderCallButton } from '../HeaderCallButton'; - -const mockVideoConf = { - showInitCallActionSheet: jest.fn(), - callEnabled: false, - disabledTooltip: false -}; -jest.mock('~/lib/hooks/useVideoConf', () => ({ - useVideoConf: () => mockVideoConf -})); - -const mockMediaCall = { - openNewMediaCall: jest.fn(), - startCallImmediate: jest.fn(), - hasMediaCallPermission: false, - isInActiveCall: false -}; -jest.mock('~/lib/hooks/useNewMediaCall', () => ({ - useNewMediaCall: () => mockMediaCall -})); - -jest.mock('~/containers/Header/components/HeaderButton', () => { - const ReactActual = jest.requireActual('react'); - return { - Item: ({ - accessibilityLabel, - disabled, - iconName, - onPress, - testID - }: { - accessibilityLabel: string; - disabled: boolean; - iconName: string; - onPress: () => void; - testID: string; - }) => ReactActual.createElement('Item', { accessibilityLabel, disabled, iconName, onPress, testID }) - }; -}); - -const renderCallButton = (disabled = false) => - render(); - -describe('HeaderCallButton', () => { - beforeEach(() => { - jest.clearAllMocks(); - jest.useFakeTimers(); - mockVideoConf.callEnabled = false; - mockVideoConf.disabledTooltip = false; - mockMediaCall.hasMediaCallPermission = false; - mockMediaCall.isInActiveCall = false; - }); - - afterEach(() => { - jest.useRealTimers(); - }); - - it('renders nothing without media call permission and with calls disabled', () => { - renderCallButton(); - - expect(screen.queryByTestId('room-view-header-call')).not.toBeOnTheScreen(); - }); - - it('renders the media call button with media call permission', () => { - mockMediaCall.hasMediaCallPermission = true; - - renderCallButton(); - - const callButton = screen.getByTestId('room-view-header-call'); - expect(callButton).toHaveProp('iconName', 'phone'); - expect(callButton).toHaveProp('accessibilityLabel', 'Call Room Title'); - expect(callButton).toHaveProp('disabled', false); - }); - - it('disables the media call button during an active call', () => { - mockMediaCall.hasMediaCallPermission = true; - mockMediaCall.isInActiveCall = true; - - renderCallButton(); - - expect(screen.getByTestId('room-view-header-call')).toHaveProp('disabled', true); - }); - - it('opens the media call sheet after the double tap window on a single tap', () => { - mockMediaCall.hasMediaCallPermission = true; - - renderCallButton(); - fireEvent.press(screen.getByTestId('room-view-header-call')); - - expect(mockMediaCall.openNewMediaCall).not.toHaveBeenCalled(); - act(() => jest.advanceTimersByTime(300)); - expect(mockMediaCall.openNewMediaCall).toHaveBeenCalled(); - expect(mockMediaCall.startCallImmediate).not.toHaveBeenCalled(); - }); - - it('starts the call immediately on a double tap', () => { - mockMediaCall.hasMediaCallPermission = true; - - renderCallButton(); - fireEvent.press(screen.getByTestId('room-view-header-call')); - fireEvent.press(screen.getByTestId('room-view-header-call')); - - expect(mockMediaCall.startCallImmediate).toHaveBeenCalled(); - act(() => jest.advanceTimersByTime(300)); - expect(mockMediaCall.openNewMediaCall).not.toHaveBeenCalled(); - }); - - it('shows the video conference sheet when calls are enabled without media call permission', () => { - mockVideoConf.callEnabled = true; - - renderCallButton(); - fireEvent.press(screen.getByTestId('room-view-header-call')); - - expect(mockVideoConf.showInitCallActionSheet).toHaveBeenCalled(); - }); - - it('disables the video conference button when the tooltip is disabled', () => { - mockVideoConf.callEnabled = true; - mockVideoConf.disabledTooltip = true; - - renderCallButton(); - - expect(screen.getByTestId('room-view-header-call')).toHaveProp('disabled', true); - }); - - it('disables the video conference button when the header disables it', () => { - mockVideoConf.callEnabled = true; - - renderCallButton(true); - - expect(screen.getByTestId('room-view-header-call')).toHaveProp('disabled', true); - }); -}); diff --git a/app/views/RoomView/components/RightButtons/__tests__/OmnichannelRightButtons.test.tsx b/app/views/RoomView/components/RightButtons/__tests__/OmnichannelRightButtons.test.tsx deleted file mode 100644 index e8579d87263..00000000000 --- a/app/views/RoomView/components/RightButtons/__tests__/OmnichannelRightButtons.test.tsx +++ /dev/null @@ -1,190 +0,0 @@ -import { fireEvent, render, screen } from '@testing-library/react-native'; - -import { type TActionSheetOptionsItem } from '~/containers/ActionSheet'; -import { showConfirmationAlert } from '~/lib/methods/helpers'; -import { returnLivechat } from '~/lib/services/restApi'; -import { type RoomStore } from '~/views/RoomView/definitions'; -import { closeLivechat } from '~/views/RoomView/services/closeLivechat'; -import { placeLivechatOnHold } from '~/views/RoomView/services/placeLivechatOnHold'; -import { OmnichannelRightButtons } from '../OmnichannelRightButtons'; - -const mockNavigation = { navigate: jest.fn(), push: jest.fn() }; -jest.mock('@react-navigation/native', () => ({ - useNavigation: () => mockNavigation -})); - -const mockShowActionSheet = jest.fn(); -jest.mock('~/containers/ActionSheet', () => ({ - useActionSheet: () => ({ showActionSheet: mockShowActionSheet }) -})); - -let mockIsMasterDetail = false; -jest.mock('~/lib/hooks/useMasterDetail', () => ({ - useMasterDetail: () => mockIsMasterDetail -})); - -let mockLivechatRequestComment = false; -jest.mock('~/lib/hooks/useSetting', () => ({ - useSetting: () => mockLivechatRequestComment -})); - -let mockRoomState = { - room: { rid: 'rid-1', t: 'l', id: 'rid-1', departmentId: 'department-1' } as Record, - canForwardGuest: false -}; -jest.mock('zustand', () => ({ - useStore: (_store: unknown, selector: (state: typeof mockRoomState) => unknown) => selector(mockRoomState) -})); - -let mockCanReturnQueue = false; -jest.mock('~/ee/omnichannel/hooks/useCanReturnQueue', () => ({ - useCanReturnQueue: () => mockCanReturnQueue -})); - -let mockCanPlaceLivechatOnHold = false; -jest.mock('~/views/RoomView/hooks/useCanPlaceLivechatOnHold', () => ({ - useCanPlaceLivechatOnHold: () => mockCanPlaceLivechatOnHold -})); - -jest.mock('~/views/RoomView/services/closeLivechat', () => ({ closeLivechat: jest.fn() })); -jest.mock('~/views/RoomView/services/placeLivechatOnHold', () => ({ placeLivechatOnHold: jest.fn() })); -jest.mock('~/lib/services/restApi', () => ({ returnLivechat: jest.fn() })); -jest.mock('~/lib/methods/helpers', () => ({ - ...jest.requireActual('~/lib/methods/helpers'), - showConfirmationAlert: jest.fn(), - showErrorAlert: jest.fn() -})); - -jest.mock('~/containers/Header/components/HeaderButton', () => { - const ReactActual = jest.requireActual('react'); - return { - Container: ({ children }: { children: unknown }) => ReactActual.createElement('Container', null, children), - Item: ({ iconName, onPress, testID }: { iconName: string; onPress: () => void; testID: string }) => - ReactActual.createElement('Item', { iconName, onPress, testID }) - }; -}); - -const roomStore = {} as RoomStore; - -const openKebab = (): TActionSheetOptionsItem[] => { - render(); - fireEvent.press(screen.getByTestId('room-view-header-omnichannel-kebab')); - return mockShowActionSheet.mock.calls[0][0].options as TActionSheetOptionsItem[]; -}; - -const titlesOf = (options: TActionSheetOptionsItem[]) => options.map(option => option.title); - -describe('OmnichannelRightButtons', () => { - beforeEach(() => { - jest.clearAllMocks(); - mockIsMasterDetail = false; - mockLivechatRequestComment = false; - mockRoomState = { - room: { rid: 'rid-1', t: 'l', id: 'rid-1', departmentId: 'department-1' }, - canForwardGuest: false - }; - mockCanReturnQueue = false; - mockCanPlaceLivechatOnHold = false; - }); - - it('renders the kebab button', () => { - render(); - - expect(screen.getByTestId('room-view-header-omnichannel-kebab')).toHaveProp('iconName', 'kebab'); - }); - - it('offers only the close option when no capability is granted', () => { - const options = openKebab(); - - expect(titlesOf(options)).toEqual(['Close']); - expect(options[0].icon).toBe('chat-close'); - expect(options[0].danger).toBe(true); - }); - - it('offers the on-hold option when the chat can be placed on hold', () => { - mockCanPlaceLivechatOnHold = true; - - const options = openKebab(); - - expect(titlesOf(options)).toEqual(['Place chat on hold', 'Close']); - expect(options[0].icon).toBe('pause'); - }); - - it('offers the forward option when the agent can forward the guest', () => { - mockRoomState = { ...mockRoomState, canForwardGuest: true }; - - const options = openKebab(); - - expect(titlesOf(options)).toEqual(['Forward chat', 'Close']); - expect(options[0].icon).toBe('chat-forward'); - }); - - it('offers the return-to-queue option when the agent can return to the queue', () => { - mockCanReturnQueue = true; - - const options = openKebab(); - - expect(titlesOf(options)).toEqual(['Return to waiting line', 'Close']); - expect(options[0].icon).toBe('move-to-the-queue'); - }); - - it('offers every option in order when all capabilities are granted', () => { - mockCanPlaceLivechatOnHold = true; - mockCanReturnQueue = true; - mockRoomState = { ...mockRoomState, canForwardGuest: true }; - - expect(titlesOf(openKebab())).toEqual(['Place chat on hold', 'Forward chat', 'Return to waiting line', 'Close']); - }); - - it('places the chat on hold through the service', () => { - mockCanPlaceLivechatOnHold = true; - - openKebab()[0].onPress?.(); - - expect(placeLivechatOnHold).toHaveBeenCalledWith({ rid: 'rid-1', navigation: mockNavigation }); - }); - - it('navigates to the forward screen on stack mode', () => { - mockRoomState = { ...mockRoomState, canForwardGuest: true }; - - openKebab()[0].onPress?.(); - - expect(mockNavigation.navigate).toHaveBeenCalledWith('ForwardLivechatView', { rid: 'rid-1' }); - }); - - it('navigates to the forward screen through the modal stack on master-detail mode', () => { - mockRoomState = { ...mockRoomState, canForwardGuest: true }; - mockIsMasterDetail = true; - - openKebab()[0].onPress?.(); - - expect(mockNavigation.navigate).toHaveBeenCalledWith('ModalStackNavigator', { - screen: 'ForwardLivechatView', - params: { rid: 'rid-1' } - }); - }); - - it('returns the inquiry only after the confirmation is accepted', async () => { - mockCanReturnQueue = true; - - openKebab()[0].onPress?.(); - - expect(returnLivechat).not.toHaveBeenCalled(); - await (showConfirmationAlert as jest.Mock).mock.calls[0][0].onPress(); - expect(returnLivechat).toHaveBeenCalledWith('rid-1', 'department-1'); - }); - - it('closes the chat with the room department and the request comment setting', () => { - mockLivechatRequestComment = true; - - openKebab()[0].onPress?.(); - - expect(closeLivechat).toHaveBeenCalledWith({ - rid: 'rid-1', - departmentId: 'department-1', - isMasterDetail: false, - livechatRequestComment: true, - navigation: mockNavigation - }); - }); -}); diff --git a/app/views/RoomView/components/RightButtons/__tests__/RightButtons.test.tsx b/app/views/RoomView/components/RightButtons/__tests__/RightButtons.test.tsx index c2708e826c0..a9b5fa9e1d0 100644 --- a/app/views/RoomView/components/RightButtons/__tests__/RightButtons.test.tsx +++ b/app/views/RoomView/components/RightButtons/__tests__/RightButtons.test.tsx @@ -1,193 +1,361 @@ -import { act, render, screen } from '@testing-library/react-native'; +import { act, fireEvent, render, screen } from '@testing-library/react-native'; import { createStore } from 'zustand'; +import { showActionSheetRef, type TActionSheetOptionsItem } from '~/containers/ActionSheet'; +import { events, logEvent } from '~/lib/methods/helpers/log'; +import { toggleFollowThread } from '~/lib/methods/toggleFollowThread'; import { type RoomMembership, type RoomStore } from '~/views/RoomView/definitions'; +import { closeLivechat } from '~/views/RoomView/services/closeLivechat'; import RightButtons from '../RightButtons'; -jest.mock('../OmnichannelRightButtons', () => { - const ReactActual = jest.requireActual('react'); - const mounts = { count: 0 }; - return { - mounts, - OmnichannelRightButtons: ({ rid }: { rid: string }) => { - ReactActual.useEffect(() => { - mounts.count += 1; - }, []); - return ReactActual.createElement('OmnichannelRightButtons', { rid, testID: 'omnichannel-right-buttons-stub' }); - } - }; -}); - -jest.mock('../ThreadRightButtons', () => { - const ReactActual = jest.requireActual('react'); - const mounts = { count: 0 }; - return { - mounts, - ThreadRightButtons: ({ tmid }: { tmid: string }) => { - ReactActual.useEffect(() => { - mounts.count += 1; - }, []); - return ReactActual.createElement('ThreadRightButtons', { tmid, testID: 'thread-right-buttons-stub' }); - } - }; -}); - -jest.mock('../RoomRightButtons', () => { +const mockNavigation = { navigate: jest.fn(), push: jest.fn() }; +jest.mock('@react-navigation/native', () => ({ useNavigation: () => mockNavigation })); +jest.mock('~/containers/ActionSheet', () => ({ showActionSheetRef: jest.fn() })); + +let mockIsMasterDetail = false; +jest.mock('~/lib/hooks/useMasterDetail', () => ({ useMasterDetail: () => mockIsMasterDetail })); +jest.mock('~/theme', () => ({ useTheme: () => ({ theme: 'light', colors: { fontDanger: '#f00' } }) })); +jest.mock('~/lib/helpers/getRoomAccessibilityLabel', () => ({ __esModule: true, default: () => 'label' })); +jest.mock('~/lib/methods/helpers', () => ({ + ...jest.requireActual('~/lib/methods/helpers'), + getRoomTitle: () => 'Room Title', + isGroupChat: () => false, + hasNativeHeaderBar: false +})); +jest.mock('~/lib/methods/helpers/log', () => ({ + ...jest.requireActual('~/lib/methods/helpers/log'), + logEvent: jest.fn() +})); + +const mockVideoConf = { showInitCallActionSheet: jest.fn(), callEnabled: true, disabledTooltip: false }; +jest.mock('~/lib/hooks/useVideoConf', () => ({ useVideoConf: () => mockVideoConf })); +jest.mock('~/lib/hooks/useNewMediaCall', () => ({ + useNewMediaCall: () => ({ + openNewMediaCall: jest.fn(), + startCallImmediate: jest.fn(), + hasMediaCallPermission: false, + isInActiveCall: false + }) +})); + +let mockAppState = { + login: { user: { id: 'u1', username: 'user', token: 'tok' } }, + settings: { Threads_enabled: true, Livechat_request_comment_when_closing_conversation: false }, + troubleshootingNotification: { issuesWithNotifications: false } +}; +jest.mock('~/lib/hooks/useAppSelector', () => ({ + useAppSelector: (selector: (state: typeof mockAppState) => unknown) => selector(mockAppState) +})); +jest.mock('~/ee/omnichannel/hooks/useCanReturnQueue', () => ({ useCanReturnQueue: () => false })); +jest.mock('~/views/RoomView/hooks/useCanPlaceLivechatOnHold', () => ({ useCanPlaceLivechatOnHold: () => false })); +jest.mock('~/views/RoomView/services/closeLivechat', () => ({ closeLivechat: jest.fn() })); +jest.mock('~/lib/methods/toggleFollowThread', () => ({ toggleFollowThread: jest.fn() })); + +let mockE2EEStatus = { showMissingE2EEKey: false, showE2EEDisabledRoom: false, hasE2EEWarning: false }; +jest.mock('~/views/RoomView/hooks/useE2EEStatus', () => ({ useE2EEStatus: () => mockE2EEStatus })); + +let mockHeaderHooks = { + isFollowingThread: false, + tunread: [] as string[], + tunreadUser: [] as string[], + tunreadGroup: [] as string[], + isSelfDm: false, + canToggleEncryption: false +}; +jest.mock('~/views/RoomView/hooks/useThreadFollowing', () => ({ useThreadFollowing: () => mockHeaderHooks.isFollowingThread })); +jest.mock('~/views/RoomView/hooks/useSubscriptionUnreads', () => ({ + useSubscriptionUnreads: () => { + const { tunread, tunreadUser, tunreadGroup, isSelfDm } = mockHeaderHooks; + return { tunread, tunreadUser, tunreadGroup, isSelfDm }; + } +})); +jest.mock('~/lib/hooks/usePermissions', () => ({ usePermissions: () => [mockHeaderHooks.canToggleEncryption] })); + +jest.mock('~/containers/Header/components/HeaderButton', () => { const ReactActual = jest.requireActual('react'); - const mounts = { count: 0 }; return { - mounts, - RoomRightButtons: ({ rid }: { rid: string }) => { - ReactActual.useEffect(() => { - mounts.count += 1; - }, []); - return ReactActual.createElement('RoomRightButtons', { rid, testID: 'room-right-buttons-stub' }); - } + Container: ({ children }: { children: unknown }) => ReactActual.createElement('Container', null, children), + Item: ({ + accessibilityLabel, + badge, + color, + disabled, + iconName, + onPress, + testID + }: { + accessibilityLabel?: string; + badge?: () => unknown; + color?: string; + disabled?: boolean; + iconName?: string; + onPress: () => void; + testID?: string; + }) => ReactActual.createElement('Item', { accessibilityLabel, badge, color, disabled, iconName, onPress, testID }), + BadgeWarn: ({ color }: { color: string }) => ReactActual.createElement('BadgeWarn', { color }), + BadgeCount: ({ value, color }: { value: number; color: string }) => ReactActual.createElement('BadgeCount', { value, color }) }; }); -const omnichannelMock = jest.requireMock('../OmnichannelRightButtons') as { mounts: { count: number } }; -const threadMock = jest.requireMock('../ThreadRightButtons') as { mounts: { count: number } }; -const roomMock = jest.requireMock('../RoomRightButtons') as { mounts: { count: number } }; - -const stubIDs = ['omnichannel-right-buttons-stub', 'thread-right-buttons-stub', 'room-right-buttons-stub']; - -const expectOnlyStub = (present?: string) => { - stubIDs.forEach(id => { - if (id === present) { - expect(screen.getByTestId(id)).toBeOnTheScreen(); - } else { - expect(screen.queryByTestId(id)).not.toBeOnTheScreen(); - } - }); +const allTestIDs = [ + 'room-view-search', + 'room-view-header-threads', + 'room-view-header-call', + 'room-view-header-encryption', + 'room-view-push-troubleshoot', + 'room-view-header-omnichannel-kebab', + 'room-view-header-follow', + 'room-view-header-unfollow' +]; + +const expectOnly = (present: string[]) => { + present.forEach(id => expect(screen.getByTestId(id)).toBeOnTheScreen()); + allTestIDs.filter(id => !present.includes(id)).forEach(id => expect(screen.queryByTestId(id)).not.toBeOnTheScreen()); }; +const renderedTestIDs = () => screen.UNSAFE_queryAllByType('Item' as never).map(item => item.props.testID); + const createRoomStore = (room: Record, membership: RoomMembership = 'subscribed') => { - const store = createStore(() => ({ room: { id: 'sub-1', ...room }, membership })); + const store = createStore(() => ({ + room: { id: 'sub-1', rid: 'rid-1', name: 'general', ...room }, + membership, + canForwardGuest: false + })); return store as typeof store & RoomStore; }; -describe('RightButtons routing', () => { +const ROOM_BUTTONS = ['room-view-header-call', 'room-view-header-threads', 'room-view-search']; + +describe('RightButtons', () => { beforeEach(() => { - omnichannelMock.mounts.count = 0; - threadMock.mounts.count = 0; - roomMock.mounts.count = 0; + jest.clearAllMocks(); + mockIsMasterDetail = false; + mockVideoConf.callEnabled = true; + mockAppState = { + login: { user: { id: 'u1', username: 'user', token: 'tok' } }, + settings: { Threads_enabled: true, Livechat_request_comment_when_closing_conversation: false }, + troubleshootingNotification: { issuesWithNotifications: false } + }; + mockE2EEStatus = { showMissingE2EEKey: false, showE2EEDisabledRoom: false, hasE2EEWarning: false }; + mockHeaderHooks = { + isFollowingThread: false, + tunread: [], + tunreadUser: [], + tunreadGroup: [], + isSelfDm: false, + canToggleEncryption: false + }; }); - it('renders nothing without a rid', () => { - const { toJSON } = render(); + describe('routing', () => { + it('renders nothing without a rid', () => { + const { toJSON } = render(); - expect(toJSON()).toBeNull(); - expectOnlyStub(); - }); + expect(toJSON()).toBeNull(); + }); - it('renders nothing for an invited room', () => { - const { toJSON } = render(); + it.each([ + ['an invited room', { t: 'c' }, 'invited'], + ['a queued omnichannel room', { t: 'l', status: 'queued' }, 'subscribed'], + ['an omnichannel room still in the preview window', { t: 'l' }, 'preview'], + ['an invited omnichannel room', { t: 'l' }, 'invited'] + ])('renders nothing for %s', (_case, room, membership) => { + const { toJSON } = render(); - expect(toJSON()).toBeNull(); - expectOnlyStub(); - }); + expect(toJSON()).toBeNull(); + }); - it('renders nothing for a queued omnichannel room', () => { - const { toJSON } = render( - - ); + it('renders only the kebab for an active omnichannel room even with a tmid', () => { + render(); - expect(toJSON()).toBeNull(); - expectOnlyStub(); - }); + expectOnly(['room-view-header-omnichannel-kebab']); + }); - it('renders nothing for an omnichannel room still in the preview window', () => { - const store = createStore(() => ({ room: { rid: 'rid-1', t: 'l' }, membership: 'preview' as RoomMembership })); + it('renders the thread buttons when a tmid is given', () => { + render(); - render(); + expectOnly(['room-view-header-follow']); + }); - expectOnlyStub(); - }); + it('renders call, threads and search in order for a regular channel', () => { + render(); - it('renders nothing for an invited omnichannel room before the queued check', () => { - render(); + expect(renderedTestIDs()).toEqual(ROOM_BUTTONS); + }); - expectOnlyStub(); - }); + it('swaps the room buttons for the omnichannel kebab when the room type changes in place', () => { + const roomStore = createRoomStore({ t: 'c' }); + render(); + expectOnly(ROOM_BUTTONS); - it('renders the omnichannel buttons for an active omnichannel room even with a tmid', () => { - render(); + act(() => roomStore.setState({ room: { id: 'sub-1', rid: 'rid-1', t: 'l' } as never })); - expectOnlyStub('omnichannel-right-buttons-stub'); - expect(screen.getByTestId('omnichannel-right-buttons-stub')).toHaveProp('rid', 'rid-1'); - }); + expectOnly(['room-view-header-omnichannel-kebab']); + }); - it('renders the thread buttons when a tmid is given', () => { - render(); + it('switches to the thread buttons when a tmid appears and back when it is cleared', () => { + const roomStore = createRoomStore({ t: 'c' }); + render(); - expectOnlyStub('thread-right-buttons-stub'); - expect(screen.getByTestId('thread-right-buttons-stub')).toHaveProp('tmid', 'tmid-1'); - }); + screen.rerender(); + expectOnly(['room-view-header-follow']); - it('renders the room buttons for a regular room', () => { - render(); + screen.rerender(); + expectOnly(ROOM_BUTTONS); + }); - expectOnlyStub('room-right-buttons-stub'); - expect(screen.getByTestId('room-right-buttons-stub')).toHaveProp('rid', 'rid-1'); + it.each([ + ['c', undefined, 'c', 'INVITED', []], + ['c', 'INVITED', 'c', undefined, ROOM_BUTTONS], + ['l', undefined, 'l', 'queued', []], + ['l', 'queued', 'l', undefined, ['room-view-header-omnichannel-kebab']], + ['c', undefined, 'l', undefined, ['room-view-header-omnichannel-kebab']] + ])('updates buttons when the same Room changes from %s/%s to %s/%s', (t, status, nextType, nextStatus, expected) => { + const room = { id: 'sub-1', rid: 'rid-1', t, status }; + const roomStore = createRoomStore(room, status === 'INVITED' ? 'invited' : 'subscribed'); + render(); + + act(() => { + Object.assign(room, { t: nextType, status: nextStatus }); + roomStore.setState({ room: room as never, membership: nextStatus === 'INVITED' ? 'invited' : 'subscribed' }); + }); + + expectOnly(expected); + }); }); - it('swaps the room buttons for the omnichannel buttons when the room type changes in place', () => { - const roomStore = createRoomStore({ rid: 'rid-1', t: 'c' }); - render(); + describe('room', () => { + it('enables encryption while the e2ee warning disables the other buttons', () => { + mockE2EEStatus = { showMissingE2EEKey: true, showE2EEDisabledRoom: false, hasE2EEWarning: true }; + mockHeaderHooks = { ...mockHeaderHooks, canToggleEncryption: true }; - expectOnlyStub('room-right-buttons-stub'); - expect(roomMock.mounts.count).toBe(1); + render(); - act(() => roomStore.setState({ room: { rid: 'rid-1', t: 'l' } })); + expectOnly(['room-view-header-encryption', ...ROOM_BUTTONS]); + expect(screen.getByTestId('room-view-header-encryption')).toHaveProp('disabled', false); + expect(screen.getByTestId('room-view-search')).toHaveProp('disabled', true); + }); - expectOnlyStub('omnichannel-right-buttons-stub'); - expect(omnichannelMock.mounts.count).toBe(1); - expect(roomMock.mounts.count).toBe(1); - }); + it.each([ + ['the user cannot toggle encryption', { showMissingE2EEKey: true, showE2EEDisabledRoom: false }], + ['the room has e2ee disabled', { showMissingE2EEKey: false, showE2EEDisabledRoom: true }] + ])('disables the encryption button when %s', (_case, status) => { + mockE2EEStatus = { ...status, hasE2EEWarning: true }; - it('remounts into the thread buttons when a tmid appears', () => { - const roomStore = createRoomStore({ rid: 'rid-1', t: 'c' }); - render(); + render(); - expectOnlyStub('room-right-buttons-stub'); - expect(roomMock.mounts.count).toBe(1); + expect(screen.getByTestId('room-view-header-encryption')).toHaveProp('disabled', true); + }); - screen.rerender(); + it('navigates to the encryption toggle', () => { + mockE2EEStatus = { showMissingE2EEKey: true, showE2EEDisabledRoom: false, hasE2EEWarning: true }; + mockHeaderHooks = { ...mockHeaderHooks, canToggleEncryption: true }; + render(); - expectOnlyStub('thread-right-buttons-stub'); - expect(threadMock.mounts.count).toBe(1); - expect(roomMock.mounts.count).toBe(1); - }); + fireEvent.press(screen.getByTestId('room-view-header-encryption')); - it.each([ - ['c', undefined, 'c', 'INVITED', undefined], - ['c', 'INVITED', 'c', undefined, 'room-right-buttons-stub'], - ['l', undefined, 'l', 'queued', undefined], - ['l', 'queued', 'l', undefined, 'omnichannel-right-buttons-stub'], - ['c', undefined, 'l', undefined, 'omnichannel-right-buttons-stub'] - ])('updates buttons when the same Room changes from %s/%s to %s/%s', (t, status, nextType, nextStatus, expected) => { - const room = { id: 'sub-1', rid: 'rid-1', t, status }; - const roomStore = createRoomStore(room, status === 'INVITED' ? 'invited' : 'subscribed'); - render(); + expect(logEvent).toHaveBeenCalledTimes(1); + expect(logEvent).toHaveBeenCalledWith(events.ROOM_GO_E2EE); + expect(mockNavigation.navigate).toHaveBeenCalledWith('E2EEToggleRoomView', { rid: 'rid-1' }); + }); + + it('tints the push troubleshoot button when there are notification issues', () => { + mockAppState = { ...mockAppState, troubleshootingNotification: { issuesWithNotifications: true } }; + + render(); + + expect(renderedTestIDs()).toEqual(['room-view-push-troubleshoot', ...ROOM_BUTTONS]); + expect(screen.getByTestId('room-view-push-troubleshoot')).toHaveProp('color', '#f00'); + }); + + it('shows an untinted push troubleshoot button when notifications are disabled for the room', () => { + render(); + + expect(screen.getByTestId('room-view-push-troubleshoot')).toHaveProp('color', undefined); + }); + + it.each([false, true])('routes notification issues to push troubleshooting (master-detail: %s)', isMasterDetail => { + mockIsMasterDetail = isMasterDetail; + mockAppState = { ...mockAppState, troubleshootingNotification: { issuesWithNotifications: true } }; + render(); + + fireEvent.press(screen.getByTestId('room-view-push-troubleshoot')); + + expect(mockNavigation.navigate).toHaveBeenCalledWith( + ...(isMasterDetail + ? ['ModalStackNavigator', { screen: 'PushTroubleshootView', params: undefined }] + : ['PushTroubleshootView', undefined]) + ); + }); + + it('hides the threads button when threads are disabled', () => { + mockAppState = { ...mockAppState, settings: { ...mockAppState.settings, Threads_enabled: false } }; + + render(); + + expectOnly(['room-view-header-call', 'room-view-search']); + }); + + it('hides the call button on a self DM', () => { + mockHeaderHooks = { ...mockHeaderHooks, isSelfDm: true }; + + render(); - act(() => { - Object.assign(room, { t: nextType, status: nextStatus }); - roomStore.setState({ room, membership: nextStatus === 'INVITED' ? 'invited' : 'subscribed' }); + expectOnly(['room-view-header-threads', 'room-view-search']); }); - expectOnlyStub(expected); + it('starts a video conference from the call button', () => { + render(); + + fireEvent.press(screen.getByTestId('room-view-header-call')); + + expect(mockVideoConf.showInitCallActionSheet).toHaveBeenCalled(); + }); + + it('navigates to the threads screen', () => { + render(); + + fireEvent.press(screen.getByTestId('room-view-header-threads')); + + expect(mockNavigation.navigate).toHaveBeenCalledWith('ThreadMessagesView', { rid: 'rid-1', t: 'c' }); + }); + + it('badges the threads button with the unread thread count', () => { + mockHeaderHooks = { ...mockHeaderHooks, tunread: ['t1', 't2'] }; + render(); + + const badge = screen.getByTestId('room-view-header-threads').props.badge(); + + expect(badge.props.value).toBe(2); + }); + }); + + describe('thread', () => { + it.each([ + [false, 'room-view-header-follow', 'Follow thread'], + [true, 'room-view-header-unfollow', 'Unfollow thread'] + ])('toggles the follow state of a thread (following: %s)', (isFollowingThread, testID, label) => { + mockHeaderHooks = { ...mockHeaderHooks, isFollowingThread }; + render(); + + expect(screen.getByTestId(testID)).toHaveProp('accessibilityLabel', label); + fireEvent.press(screen.getByTestId(testID)); + + expect(toggleFollowThread).toHaveBeenCalledWith('tmid-1', isFollowingThread); + }); }); - it('remounts the room buttons after the tmid is cleared', () => { - const roomStore = createRoomStore({ rid: 'rid-1', t: 'c' }); - render(); + describe('omnichannel', () => { + it('opens the more actions sheet and closes the chat from it', () => { + render(); - screen.rerender(); - screen.rerender(); + fireEvent.press(screen.getByTestId('room-view-header-omnichannel-kebab')); - expectOnlyStub('room-right-buttons-stub'); - expect(roomMock.mounts.count).toBe(2); - expect(threadMock.mounts.count).toBe(1); + expect(logEvent).toHaveBeenCalledWith(events.ROOM_SHOW_MORE_ACTIONS); + const { options } = (showActionSheetRef as jest.Mock).mock.calls[0][0] as { options: TActionSheetOptionsItem[] }; + expect(options).toEqual([expect.objectContaining({ title: 'Close', icon: 'chat-close', danger: true })]); + options[0].onPress(); + expect(closeLivechat).toHaveBeenCalledWith(expect.objectContaining({ rid: 'rid-1', departmentId: 'department-1' })); + }); }); }); diff --git a/app/views/RoomView/components/RightButtons/__tests__/RoomRightButtons.test.tsx b/app/views/RoomView/components/RightButtons/__tests__/RoomRightButtons.test.tsx deleted file mode 100644 index db2d60dbb2e..00000000000 --- a/app/views/RoomView/components/RightButtons/__tests__/RoomRightButtons.test.tsx +++ /dev/null @@ -1,307 +0,0 @@ -import { fireEvent, render, screen } from '@testing-library/react-native'; - -import { events, logEvent } from '~/lib/methods/helpers/log'; -import { type RoomStore } from '~/views/RoomView/definitions'; -import { RoomRightButtons } from '../RoomRightButtons'; - -const mockNavigation = { navigate: jest.fn(), push: jest.fn() }; -jest.mock('~/lib/methods/helpers/log', () => ({ - ...jest.requireActual('~/lib/methods/helpers/log'), - logEvent: jest.fn() -})); -jest.mock('@react-navigation/native', () => ({ - useNavigation: () => mockNavigation -})); - -let mockIsMasterDetail = false; -jest.mock('~/lib/hooks/useMasterDetail', () => ({ - useMasterDetail: () => mockIsMasterDetail -})); - -jest.mock('~/theme', () => ({ useTheme: () => ({ colors: { fontDanger: '#f00' } }) })); -jest.mock('~/lib/helpers/getRoomAccessibilityLabel', () => ({ __esModule: true, default: () => 'channel label' })); -jest.mock('~/lib/methods/helpers', () => ({ - ...jest.requireActual('~/lib/methods/helpers'), - getRoomTitle: () => 'Room Title', - isGroupChat: () => false -})); - -let mockThreadsEnabled = true; -jest.mock('~/lib/hooks/useSetting', () => ({ - useSetting: () => mockThreadsEnabled -})); - -let mockAppState = { - login: { user: { id: 'u1', username: 'user', token: 'tok' } }, - troubleshootingNotification: { issuesWithNotifications: false } -}; -jest.mock('~/lib/hooks/useAppSelector', () => ({ - useAppSelector: (selector: (state: typeof mockAppState) => unknown) => selector(mockAppState) -})); - -let mockRoomState = { room: { rid: 'rid-1', t: 'c', name: 'general' } as Record }; -jest.mock('zustand', () => ({ - useStore: (_store: unknown, selector: (state: typeof mockRoomState) => unknown) => selector(mockRoomState) -})); - -let mockHasE2EEWarning = false; -jest.mock('~/views/RoomView/hooks/useE2EEStatus', () => ({ - useE2EEStatus: () => ({ - showMissingE2EEKey: mockHasE2EEWarning, - showE2EEDisabledRoom: false, - hasE2EEWarning: mockHasE2EEWarning - }) -})); - -let mockUnreads = { - tunread: [] as string[], - tunreadUser: [] as string[], - tunreadGroup: [] as string[], - isSelfDm: false -}; -jest.mock('~/views/RoomView/hooks/useSubscriptionUnreads', () => ({ - useSubscriptionUnreads: () => mockUnreads -})); - -let mockCanToggleEncryption = false; -jest.mock('~/lib/hooks/usePermissions', () => ({ - usePermissions: () => [mockCanToggleEncryption] -})); - -jest.mock('~/containers/Header/components/HeaderButton', () => { - const ReactActual = jest.requireActual('react'); - return { - Container: ({ children }: { children: unknown }) => ReactActual.createElement('Container', null, children), - Item: ({ - accessibilityLabel, - color, - disabled, - iconName, - onPress, - testID - }: { - accessibilityLabel?: string; - color?: string; - disabled?: boolean; - iconName: string; - onPress: () => void; - testID: string; - }) => - ReactActual.createElement('Item', { - accessibilityLabel, - color, - disabled, - iconName, - onPress, - testID - }), - BadgeUnread: () => null - }; -}); -jest.mock('../HeaderCallButton', () => { - const ReactActual = jest.requireActual('react'); - return { - HeaderCallButton: ({ rid, disabled, accessibilityLabel }: { rid: string; disabled: boolean; accessibilityLabel: string }) => - ReactActual.createElement('CallButton', { rid, disabled, accessibilityLabel, testID: 'header-call-button-stub' }) - }; -}); - -const roomStore = { getState: () => mockRoomState } as unknown as RoomStore; - -const renderRoomRightButtons = () => render(); - -describe('RoomRightButtons', () => { - beforeEach(() => { - jest.clearAllMocks(); - mockIsMasterDetail = false; - mockThreadsEnabled = true; - mockAppState = { - login: { user: { id: 'u1', username: 'user', token: 'tok' } }, - troubleshootingNotification: { issuesWithNotifications: false } - }; - mockRoomState = { room: { rid: 'rid-1', t: 'c', name: 'general' } }; - mockHasE2EEWarning = false; - mockUnreads = { tunread: [], tunreadUser: [], tunreadGroup: [], isSelfDm: false }; - mockCanToggleEncryption = false; - }); - - it('renders the call, threads and search buttons for a regular channel', () => { - renderRoomRightButtons(); - - expect(screen.getByTestId('room-view-header-threads')).toHaveProp('iconName', 'threads'); - expect(screen.getByTestId('room-view-search')).toHaveProp('accessibilityLabel', 'Search messages'); - expect(screen.queryByTestId('room-view-header-encryption')).not.toBeOnTheScreen(); - expect(screen.queryByTestId('room-view-push-troubleshoot')).not.toBeOnTheScreen(); - }); - - it('labels the call button with the room accessibility name', () => { - renderRoomRightButtons(); - - expect(screen.getByTestId('header-call-button-stub')).toHaveProp('accessibilityLabel', 'Call channel label'); - }); - - it('hides the call button on a self DM', () => { - mockRoomState = { room: { rid: 'rid-1', t: 'd', name: 'user' } }; - mockUnreads = { ...mockUnreads, isSelfDm: true }; - - renderRoomRightButtons(); - - expect(screen.queryByTestId('header-call-button-stub')).not.toBeOnTheScreen(); - expect(screen.getByTestId('room-view-search')).toBeOnTheScreen(); - }); - - it('enables the encryption button and disables the others on an e2ee warning with permission', () => { - mockHasE2EEWarning = true; - mockCanToggleEncryption = true; - mockAppState = { ...mockAppState, troubleshootingNotification: { issuesWithNotifications: true } }; - - renderRoomRightButtons(); - - expect(screen.getByTestId('room-view-header-encryption')).toHaveProp('disabled', false); - expect(screen.getByTestId('room-view-header-threads')).toHaveProp('disabled', true); - expect(screen.getByTestId('room-view-search')).toHaveProp('disabled', true); - expect(screen.getByTestId('header-call-button-stub')).toHaveProp('disabled', true); - expect(screen.getByTestId('room-view-push-troubleshoot')).toHaveProp('disabled', true); - }); - - it('disables the encryption button on an e2ee warning without permission', () => { - mockHasE2EEWarning = true; - - renderRoomRightButtons(); - - expect(screen.getByTestId('room-view-header-encryption')).toHaveProp('disabled', true); - }); - - it.each([false, true])('routes notification issues to push troubleshooting (master-detail: %s)', isMasterDetail => { - mockIsMasterDetail = isMasterDetail; - mockAppState = { ...mockAppState, troubleshootingNotification: { issuesWithNotifications: true } }; - mockRoomState = { room: { id: 'rid-1', rid: 'rid-1', t: 'c', name: 'general' } }; - - renderRoomRightButtons(); - - expect(screen.getByTestId('room-view-push-troubleshoot')).toHaveProp('color', '#f00'); - fireEvent.press(screen.getByTestId('room-view-push-troubleshoot')); - expect(mockNavigation.navigate).toHaveBeenCalledWith( - ...(isMasterDetail - ? ['ModalStackNavigator', { screen: 'PushTroubleshootView', params: undefined }] - : ['PushTroubleshootView', undefined]) - ); - }); - - it.each([false, true])('routes disabled Room notifications to preferences (master-detail: %s)', isMasterDetail => { - mockIsMasterDetail = isMasterDetail; - mockRoomState = { room: { id: 'rid-1', rid: 'rid-1', t: 'c', name: 'general', disableNotifications: true } }; - - renderRoomRightButtons(); - - expect(screen.getByTestId('room-view-push-troubleshoot')).toHaveProp('color', ''); - fireEvent.press(screen.getByTestId('room-view-push-troubleshoot')); - const params = { rid: 'rid-1', room: mockRoomState.room }; - expect(mockNavigation.navigate).toHaveBeenCalledWith( - ...(isMasterDetail ? ['ModalStackNavigator', { screen: 'NotificationPrefView', params }] : ['NotificationPrefView', params]) - ); - }); - - it('does not navigate from the notification button without a subscription', () => { - mockRoomState = { room: { rid: 'rid-1', t: 'c', name: 'general', disableNotifications: true } }; - - renderRoomRightButtons(); - - fireEvent.press(screen.getByTestId('room-view-push-troubleshoot')); - - expect(mockNavigation.navigate).not.toHaveBeenCalled(); - }); - - it('hides the threads button when threads are disabled', () => { - mockThreadsEnabled = false; - - renderRoomRightButtons(); - - expect(screen.queryByTestId('room-view-header-threads')).not.toBeOnTheScreen(); - expect(screen.getByTestId('room-view-search')).toBeOnTheScreen(); - }); - - it('labels the threads button without unreads', () => { - renderRoomRightButtons(); - - expect(screen.getByTestId('room-view-header-threads')).toHaveProp('accessibilityLabel', 'Threads'); - }); - - it('labels the threads button with the direct mention unread count', () => { - mockUnreads = { ...mockUnreads, tunread: ['tm-1'], tunreadUser: ['tm-1'] }; - - renderRoomRightButtons(); - - expect(screen.getByTestId('room-view-header-threads')).toHaveProp('accessibilityLabel', 'Threads, 1 unread, direct mention'); - }); - - it('labels the threads button with the group mention unread count', () => { - mockUnreads = { ...mockUnreads, tunread: ['tm-1'], tunreadGroup: ['tm-1'] }; - - renderRoomRightButtons(); - - expect(screen.getByTestId('room-view-header-threads')).toHaveProp('accessibilityLabel', 'Threads, 1 unread, group mention'); - }); - - it('labels the threads button with the plain unread count', () => { - mockUnreads = { ...mockUnreads, tunread: ['tm-1', 'tm-2'] }; - - renderRoomRightButtons(); - - expect(screen.getByTestId('room-view-header-threads')).toHaveProp('accessibilityLabel', 'Threads, 2 unread'); - }); - - it('navigates to the threads and search screens on stack mode', () => { - mockRoomState = { room: { id: 'rid-1', rid: 'rid-1', t: 'c', name: 'general', encrypted: true } }; - - renderRoomRightButtons(); - - fireEvent.press(screen.getByTestId('room-view-header-threads')); - expect(mockNavigation.navigate).toHaveBeenCalledWith('ThreadMessagesView', { rid: 'rid-1', t: 'c' }); - - fireEvent.press(screen.getByTestId('room-view-search')); - expect(mockNavigation.navigate).toHaveBeenCalledWith('SearchMessagesView', { rid: 'rid-1', t: 'c', encrypted: true }); - }); - - it('navigates through the modal stack on master-detail mode', () => { - mockIsMasterDetail = true; - mockRoomState = { room: { id: 'rid-1', rid: 'rid-1', t: 'c', name: 'general', encrypted: true } }; - - renderRoomRightButtons(); - - fireEvent.press(screen.getByTestId('room-view-header-threads')); - expect(mockNavigation.navigate).toHaveBeenCalledWith('ModalStackNavigator', { - screen: 'ThreadMessagesView', - params: { rid: 'rid-1', t: 'c' } - }); - - fireEvent.press(screen.getByTestId('room-view-search')); - expect(mockNavigation.navigate).toHaveBeenCalledWith('ModalStackNavigator', { - screen: 'SearchMessagesView', - params: { rid: 'rid-1', t: 'c', encrypted: true, showCloseModal: true } - }); - }); - - it.each([false, true])('offers encryption navigation while other buttons are disabled (master-detail: %s)', isMasterDetail => { - mockIsMasterDetail = isMasterDetail; - mockHasE2EEWarning = true; - mockCanToggleEncryption = true; - mockAppState = { ...mockAppState, troubleshootingNotification: { issuesWithNotifications: true } }; - mockRoomState = { room: { id: 'rid-1', rid: 'rid-1', t: 'c', name: 'general' } }; - renderRoomRightButtons(); - - expect(screen.getByTestId('room-view-header-threads')).toHaveProp('disabled', true); - expect(screen.getByTestId('room-view-search')).toHaveProp('disabled', true); - expect(screen.getByTestId('room-view-push-troubleshoot')).toHaveProp('disabled', true); - expect(screen.getByTestId('room-view-header-encryption')).toHaveProp('disabled', false); - - fireEvent.press(screen.getByTestId('room-view-header-encryption')); - expect(logEvent).toHaveBeenCalledTimes(1); - expect(logEvent).toHaveBeenCalledWith(events.ROOM_GO_E2EE); - expect(mockNavigation.navigate).toHaveBeenCalledWith( - ...(isMasterDetail - ? ['ModalStackNavigator', { screen: 'E2EEToggleRoomView', params: { rid: 'rid-1' } }] - : ['E2EEToggleRoomView', { rid: 'rid-1' }]) - ); - }); -}); diff --git a/app/views/RoomView/components/RightButtons/__tests__/ThreadRightButtons.test.tsx b/app/views/RoomView/components/RightButtons/__tests__/ThreadRightButtons.test.tsx deleted file mode 100644 index 6e16f6df164..00000000000 --- a/app/views/RoomView/components/RightButtons/__tests__/ThreadRightButtons.test.tsx +++ /dev/null @@ -1,95 +0,0 @@ -import { fireEvent, render, screen } from '@testing-library/react-native'; - -import { toggleFollowThread } from '~/lib/methods/toggleFollowThread'; -import { ThreadRightButtons } from '../ThreadRightButtons'; - -let mockAppState = { login: { user: { id: 'u1', username: 'user', token: 'tok' } } }; -jest.mock('~/lib/hooks/useAppSelector', () => ({ - useAppSelector: (selector: (state: typeof mockAppState) => unknown) => selector(mockAppState) -})); - -let mockFollowersByThread: Record = {}; -jest.mock('~/views/RoomView/hooks/useThreadFollowing', () => ({ - useThreadFollowing: (tmid: string, userId: string) => mockFollowersByThread[tmid]?.includes(userId) ?? false -})); - -jest.mock('~/lib/methods/toggleFollowThread', () => ({ toggleFollowThread: jest.fn() })); - -jest.mock('~/containers/Header/components/HeaderButton', () => { - const ReactActual = jest.requireActual('react'); - return { - Container: ({ children }: { children: unknown }) => ReactActual.createElement('Container', null, children), - Item: ({ - accessibilityLabel, - iconName, - onPress, - testID - }: { - accessibilityLabel: string; - iconName: string; - onPress: () => void; - testID: string; - }) => ReactActual.createElement('Item', { accessibilityLabel, iconName, onPress, testID }) - }; -}); - -describe('ThreadRightButtons', () => { - beforeEach(() => { - jest.clearAllMocks(); - mockFollowersByThread = {}; - mockAppState = { login: { user: { id: 'u1', username: 'user', token: 'tok' } } }; - }); - - it('renders the follow button when the thread is not followed', () => { - render(); - - const followButton = screen.getByTestId('room-view-header-follow'); - expect(followButton).toHaveProp('accessibilityLabel', 'Follow thread'); - expect(followButton).toHaveProp('iconName', 'notification-disabled'); - expect(screen.queryByTestId('room-view-header-unfollow')).not.toBeOnTheScreen(); - }); - - it('renders the unfollow button when the thread is followed', () => { - mockFollowersByThread = { 'tmid-1': ['u1'] }; - - render(); - - const unfollowButton = screen.getByTestId('room-view-header-unfollow'); - expect(unfollowButton).toHaveProp('accessibilityLabel', 'Unfollow thread'); - expect(unfollowButton).toHaveProp('iconName', 'notification'); - expect(screen.queryByTestId('room-view-header-follow')).not.toBeOnTheScreen(); - }); - - it('shows the follow state for the displayed Thread and current user', () => { - mockFollowersByThread = { 'tmid-1': ['u1'] }; - render(); - expect(screen.getByTestId('room-view-header-unfollow')).toBeOnTheScreen(); - - screen.rerender(); - expect(screen.getByTestId('room-view-header-follow')).toBeOnTheScreen(); - expect(screen.queryByTestId('room-view-header-unfollow')).not.toBeOnTheScreen(); - - mockAppState = { login: { user: { id: 'u2', username: 'other', token: 'tok' } } }; - screen.rerender(); - expect(screen.getByTestId('room-view-header-follow')).toBeOnTheScreen(); - expect(screen.queryByTestId('room-view-header-unfollow')).not.toBeOnTheScreen(); - }); - - it('follows the thread when it is not followed yet', () => { - render(); - - fireEvent.press(screen.getByTestId('room-view-header-follow')); - - expect(toggleFollowThread).toHaveBeenCalledWith('tmid-1', false); - }); - - it('unfollows the thread when it is followed', () => { - mockFollowersByThread = { 'tmid-1': ['u1'] }; - - render(); - - fireEvent.press(screen.getByTestId('room-view-header-unfollow')); - - expect(toggleFollowThread).toHaveBeenCalledWith('tmid-1', true); - }); -}); diff --git a/app/views/RoomView/components/RightButtons/HeaderCallButton.tsx b/app/views/RoomView/components/RightButtons/useHeaderCallPress.ts similarity index 57% rename from app/views/RoomView/components/RightButtons/HeaderCallButton.tsx rename to app/views/RoomView/components/RightButtons/useHeaderCallPress.ts index de7a9c68316..1a8645379ef 100644 --- a/app/views/RoomView/components/RightButtons/HeaderCallButton.tsx +++ b/app/views/RoomView/components/RightButtons/useHeaderCallPress.ts @@ -1,20 +1,11 @@ -import { type ReactElement, useEffect, useRef } from 'react'; +import { useEffect, useRef } from 'react'; -import * as HeaderButton from '~/containers/Header/components/HeaderButton'; -import { useVideoConf } from '~/lib/hooks/useVideoConf'; import { useNewMediaCall } from '~/lib/hooks/useNewMediaCall'; +import { useVideoConf } from '~/lib/hooks/useVideoConf'; const DOUBLE_TAP_WINDOW_MS = 300; -export const HeaderCallButton = ({ - rid, - disabled, - accessibilityLabel -}: { - rid: string; - disabled: boolean; - accessibilityLabel: string; -}): ReactElement | null => { +export const useHeaderCallPress = (rid: string) => { const { showInitCallActionSheet, callEnabled, disabledTooltip } = useVideoConf(rid); const { openNewMediaCall, startCallImmediate, hasMediaCallPermission, isInActiveCall } = useNewMediaCall(rid); @@ -31,7 +22,7 @@ export const HeaderCallButton = ({ [] ); - const handleVoipPress = () => { + const onPressMediaCall = () => { const now = Date.now(); if (pendingTimerRef.current && now - lastTapRef.current < DOUBLE_TAP_WINDOW_MS) { clearTimeout(pendingTimerRef.current); @@ -48,28 +39,24 @@ export const HeaderCallButton = ({ }; if (hasMediaCallPermission) { - return ( - - ); + return { + callPresent: true, + isCallDisabled: isInActiveCall, + onPressCall: onPressMediaCall + }; } if (callEnabled) { - return ( - - ); + return { + callPresent: true, + isCallDisabled: disabledTooltip || isInActiveCall, + onPressCall: showInitCallActionSheet + }; } - return null; + return { + callPresent: false, + isCallDisabled: true, + onPressCall: () => {} + }; }; diff --git a/app/views/RoomView/components/RightButtons/RoomRightButtons.tsx b/app/views/RoomView/components/RightButtons/useRoomRightButtonsData.ts similarity index 56% rename from app/views/RoomView/components/RightButtons/RoomRightButtons.tsx rename to app/views/RoomView/components/RightButtons/useRoomRightButtonsData.ts index d7dfc8f6179..1ffecb0d94e 100644 --- a/app/views/RoomView/components/RightButtons/RoomRightButtons.tsx +++ b/app/views/RoomView/components/RightButtons/useRoomRightButtonsData.ts @@ -1,37 +1,40 @@ -import { type ReactElement } from 'react'; import { useStore } from 'zustand'; import { useShallow } from 'zustand/react/shallow'; import { useNavigation } from '@react-navigation/native'; -import * as HeaderButton from '~/containers/Header/components/HeaderButton'; -import { type ISubscription, type SubscriptionType, type TSubscriptionModel, type TUserStatus } from '~/definitions'; +import { type SubscriptionType, type TUserStatus } from '~/definitions'; +import { isSubscriptionModel } from '~/definitions/TRoom'; import i18n from '~/i18n'; -import { getRoomTitle, isGroupChat } from '~/lib/methods/helpers'; -import { events, logEvent } from '~/lib/methods/helpers/log'; import getRoomAccessibilityLabel from '~/lib/helpers/getRoomAccessibilityLabel'; import { useAppSelector } from '~/lib/hooks/useAppSelector'; import { useMasterDetail } from '~/lib/hooks/useMasterDetail'; import { usePermissions } from '~/lib/hooks/usePermissions'; import { useSetting } from '~/lib/hooks/useSetting'; +import { getRoomTitle, isGroupChat } from '~/lib/methods/helpers'; +import { events, logEvent } from '~/lib/methods/helpers/log'; import { getUserSelector } from '~/selectors/login'; -import { useTheme } from '~/theme'; import { type RoomStore } from '~/views/RoomView/definitions'; -import { isSubscriptionModel } from '~/definitions/TRoom'; -import { fromSubscription } from '~/views/RoomView/stores/RoomStoreContext'; import { useE2EEStatus } from '~/views/RoomView/hooks/useE2EEStatus'; import { useSubscriptionUnreads } from '~/views/RoomView/hooks/useSubscriptionUnreads'; +import { fromSubscription } from '~/views/RoomView/stores/RoomStoreContext'; import { navigateToScreen, type TRoomStackNavigation } from '~/views/RoomView/services/navigateToScreen'; -import { HeaderCallButton } from './HeaderCallButton'; -interface IRoomRightButtonsProps { - rid: string; - roomStore: RoomStore; -} +const getThreadsAccessibilityLabel = (tunread: string[], tunreadUser?: string[], tunreadGroup?: string[]) => { + if (!tunread.length) { + return i18n.t('Threads'); + } + if (tunreadUser?.length) { + return i18n.t('Threads_dm_unread', { unread: tunreadUser.length }); + } + if (tunreadGroup?.length) { + return i18n.t('Threads_group_unread', { unread: tunreadGroup.length }); + } + return i18n.t('Threads_unread', { unread: tunread.length }); +}; -export const RoomRightButtons = ({ rid, roomStore }: IRoomRightButtonsProps): ReactElement => { +export const useRoomRightButtonsData = (rid: string, roomStore: RoomStore) => { const navigation = useNavigation(); const isMasterDetail = useMasterDetail(); - const { colors } = useTheme(); const userId = useAppSelector(state => getUserSelector(state).id); const threadsEnabled = useSetting('Threads_enabled') as boolean; @@ -45,10 +48,10 @@ export const RoomRightButtons = ({ rid, roomStore }: IRoomRightButtonsProps): Re t: room.t as SubscriptionType, status: fromSubscription(r => r.status, undefined)(s), roomName: getRoomTitle(room), - roomIsGroupChat: isGroupChat(room as ISubscription), + roomIsGroupChat: isGroupChat(room), teamMain: fromSubscription(r => !!r.teamMain, false)(s), encrypted: fromSubscription(r => r.encrypted, undefined)(s), - disableNotifications: (room as ISubscription).disableNotifications + disableNotifications: fromSubscription(r => r.disableNotifications, undefined)(s) }; }) ); @@ -62,19 +65,18 @@ export const RoomRightButtons = ({ rid, roomStore }: IRoomRightButtonsProps): Re }; const navigateToNotificationOrPushTroubleshoot = () => { - const room = roomStore.getState().room; - if (!isSubscriptionModel(room)) { + if (issuesWithNotifications) { + navigateToScreen({ navigation, isMasterDetail, screen: 'PushTroubleshootView' }); return; } - if (!issuesWithNotifications) { + const room = roomStore.getState().room; + if (isSubscriptionModel(room)) { navigateToScreen({ navigation, isMasterDetail, screen: 'NotificationPrefView', - params: { rid, room: room as TSubscriptionModel } + params: { rid, room } }); - } else { - navigateToScreen({ navigation, isMasterDetail, screen: 'PushTroubleshootView' }); } }; @@ -93,67 +95,27 @@ export const RoomRightButtons = ({ rid, roomStore }: IRoomRightButtonsProps): Re navigateToScreen({ navigation, isMasterDetail, screen: 'E2EEToggleRoomView', params: { rid } }); }; - const threadsAccessibilityLabel = () => { - if (!tunread.length) { - return i18n.t('Threads'); - } - if (tunreadUser?.length) { - return i18n.t('Threads_dm_unread', { unread: tunreadUser?.length }); - } - if (tunreadGroup?.length) { - return i18n.t('Threads_group_unread', { unread: tunreadGroup?.length }); - } - return i18n.t('Threads_unread', { unread: tunread?.length }); - }; - const accessibilityRoomName = !roomIsGroupChat && t === 'd' && !!userId ? roomName : getRoomAccessibilityLabel({ type: t, userId, isGroupChat: roomIsGroupChat, status: status as TUserStatus, teamMain }); + const callAccessibilityLabel = i18n.t('Call_room_name', { roomName: accessibilityRoomName }); - return ( - - {hasE2EEWarning ? ( - - ) : null} - {issuesWithNotifications || disableNotifications ? ( - - ) : null} - {!isSelfDm ? ( - - ) : null} - {threadsEnabled ? ( - } - disabled={hasE2EEWarning} - /> - ) : null} - - - ); + return { + threadsEnabled, + issuesWithNotifications, + disableNotifications, + hasE2EEWarning, + canToggleEncryption, + isSelfDm, + tunread, + tunreadUser, + tunreadGroup, + callAccessibilityLabel, + goThreadsView, + navigateToNotificationOrPushTroubleshoot, + goSearchView, + goE2EEToggleRoomView, + threadsAccessibilityLabel: getThreadsAccessibilityLabel(tunread, tunreadUser, tunreadGroup) + }; }; diff --git a/app/views/RoomView/components/RoomBodyFocusAnchor.tsx b/app/views/RoomView/components/RoomBodyFocusAnchor.tsx new file mode 100644 index 00000000000..13675e34b65 --- /dev/null +++ b/app/views/RoomView/components/RoomBodyFocusAnchor.tsx @@ -0,0 +1,41 @@ +import { type ReactNode } from 'react'; +import { StyleSheet } from 'react-native'; +import { KeyboardFocusView } from 'react-native-external-keyboard'; + +import { isIOS } from '~/lib/methods/helpers'; +import { isInviteSubscription } from '~/lib/methods/isInviteSubscription'; +import { useIsAccessibilityNavigationEnabled } from '~/lib/hooks/useIsAccessibilityNavigationEnabled'; +import { fromSubscription, useRoomStore } from '../stores/RoomStoreContext'; + +const styles = StyleSheet.create({ + anchor: { + width: 1, + height: 1 + } +}); + +interface IRoomBodyFocusAnchorProps { + children: ReactNode; +} + +export const RoomBodyFocusAnchor = ({ children }: IRoomBodyFocusAnchorProps) => { + const disabled = useRoomStore(fromSubscription(isInviteSubscription, false)); + const accessibilityNavigationEnabled = useIsAccessibilityNavigationEnabled(); + + if (!isIOS) { + return <>{children}; + } + + return ( + <> + + {children} + + ); +}; diff --git a/app/views/RoomView/components/RoomViewContent/RoomViewContent.tsx b/app/views/RoomView/components/RoomViewContent/RoomViewContent.tsx new file mode 100644 index 00000000000..7f4a608443c --- /dev/null +++ b/app/views/RoomView/components/RoomViewContent/RoomViewContent.tsx @@ -0,0 +1,62 @@ +import { useStore } from 'zustand'; +import { useShallow } from 'zustand/react/shallow'; + +import { getRoomTitle } from '~/lib/methods/helpers'; +import { isInviteSubscription } from '~/lib/methods/isInviteSubscription'; +import { getInvitationActions, getInvitationText } from '~/lib/methods/getInvitationData'; +import { type IInviteSubscription } from '~/definitions'; +import { isSubscriptionModel } from '~/definitions/TRoom'; +import { type IRoomViewProps, type RoomStore } from '~/views/RoomView/definitions'; +import RoomScreen from '~/views/RoomView/RoomScreen'; +import { useE2EEStatus } from '~/views/RoomView/hooks/useE2EEStatus'; +import { EncryptedRoom } from '../EncryptedRoom'; +import { InvitedRoomScreen } from '../InvitedRoomScreen'; +import { MissingRoomE2EEKey } from '../MissingRoomE2EEKey'; +import { RoomBackground } from '../RoomBackground'; + +interface IRoomViewContentProps extends IRoomViewProps { + rid?: string; + t?: string; + tmid?: string; + roomStore: RoomStore; + ready: boolean; +} + +export const RoomViewContent = ({ route, navigation, rid, t, tmid, roomStore, ready }: IRoomViewContentProps) => { + const isEncryptable = useStore(roomStore, s => isSubscriptionModel(s.room)); + const invitation = useStore( + roomStore, + useShallow(s => (isSubscriptionModel(s.room) && isInviteSubscription(s.room) ? getInvitationText(s.room) : null)) + ); + const roomTitle = useStore(roomStore, s => getRoomTitle(s.room)); + + const { showMissingE2EEKey, showE2EEDisabledRoom } = useE2EEStatus(roomStore); + + if (!rid || !t) { + return ; + } + + if (invitation) { + return ( + getInvitationActions(roomStore.getState().room as IInviteSubscription).accept()} + onReject={() => getInvitationActions(roomStore.getState().room as IInviteSubscription).reject()} + /> + ); + } + + if (isEncryptable) { + if (showMissingE2EEKey) { + return ; + } + + if (showE2EEDisabledRoom) { + return ; + } + } + + return ; +}; diff --git a/app/views/RoomView/components/RoomViewHeader/NativeHeaderItems.tsx b/app/views/RoomView/components/RoomViewHeader/NativeHeaderItems.tsx new file mode 100644 index 00000000000..12703a28def --- /dev/null +++ b/app/views/RoomView/components/RoomViewHeader/NativeHeaderItems.tsx @@ -0,0 +1,56 @@ +import { useLayoutEffect } from 'react'; +import { useNavigation } from '@react-navigation/native'; +import { useStore } from 'zustand'; + +import { type IHeaderAction, nativeHeaderItems } from '~/lib/methods/helpers/navigation/headerActions'; +import { type IRoomViewProps, type RoomStore } from '~/views/RoomView/definitions'; +import { useGoRoomActionsView } from '~/views/RoomView/hooks/useGoRoomActionsView'; +import { useHeaderFields } from '~/views/RoomView/hooks/useHeaderFields'; +import { useNativeBackButton } from '~/views/RoomView/hooks/useNativeBackButton'; +import { useNativeRoomHeader } from '~/views/RoomView/hooks/useNativeRoomHeader'; +import LeftButtons from '../LeftButtons'; + +export const NativeRoomTitle = ({ + tmid, + threadName, + roomStore +}: { + tmid?: string; + threadName?: string; + roomStore: RoomStore; +}) => { + const fields = useHeaderFields(roomStore, tmid, threadName); + const roomUserId = useStore(roomStore, s => s.roomUserId); + const goRoomActionsView = useGoRoomActionsView(roomStore); + useNativeRoomHeader(fields, tmid, roomUserId, goRoomActionsView); + return null; +}; + +export const NativeBackButton = ({ rid }: { rid: string }) => { + useNativeBackButton(rid); + return null; +}; + +export const NativeAvatarItem = ({ rid, roomStore }: { rid: string; roomStore: RoomStore }) => { + const navigation = useNavigation(); + + useLayoutEffect(() => { + navigation.setOptions({ + unstable_headerLeftItems: () => [ + { type: 'custom', element: , hidesSharedBackground: true } + ] + }); + }, [navigation, rid, roomStore]); + + return null; +}; + +export const NativeRightItems = ({ actions }: { actions: IHeaderAction[] }) => { + const navigation = useNavigation(); + + useLayoutEffect(() => { + navigation.setOptions({ unstable_headerRightItems: () => nativeHeaderItems(actions) }); + }, [navigation, actions]); + + return null; +}; diff --git a/app/views/RoomView/components/RoomViewHeader/RoomHeaderActions.tsx b/app/views/RoomView/components/RoomViewHeader/RoomHeaderActions.tsx new file mode 100644 index 00000000000..dc15c427df5 --- /dev/null +++ b/app/views/RoomView/components/RoomViewHeader/RoomHeaderActions.tsx @@ -0,0 +1,66 @@ +import { type ComponentType, type ReactElement } from 'react'; + +import { type IHeaderAction } from '~/lib/methods/helpers/navigation/headerActions'; +import { type RoomStore } from '~/views/RoomView/definitions'; +import { + EMPTY_ACTIONS, + useOmnichannelActions, + useRoomActions, + useRoomHeaderMode, + useThreadActions +} from '~/views/RoomView/hooks/useRoomHeaderActions'; + +type TActionsRenderer = ComponentType<{ actions: IHeaderAction[] }>; + +interface IRoomHeaderActionsProps { + rid?: string; + tmid?: string; + roomStore: RoomStore; + ActionsRenderer: TActionsRenderer; +} + +const OmnichannelActions = ({ + rid, + roomStore, + ActionsRenderer +}: { + rid: string; + roomStore: RoomStore; + ActionsRenderer: TActionsRenderer; +}) => { + const actions = useOmnichannelActions(rid, roomStore); + return ; +}; + +const ThreadActions = ({ tmid, ActionsRenderer }: { tmid: string; ActionsRenderer: TActionsRenderer }) => { + const actions = useThreadActions(tmid); + return ; +}; + +const RoomActions = ({ + rid, + roomStore, + ActionsRenderer +}: { + rid: string; + roomStore: RoomStore; + ActionsRenderer: TActionsRenderer; +}) => { + const actions = useRoomActions(rid, roomStore); + return ; +}; + +export const RoomHeaderActions = ({ rid, tmid, roomStore, ActionsRenderer }: IRoomHeaderActionsProps): ReactElement => { + const mode = useRoomHeaderMode(rid, tmid, roomStore); + + if (rid && mode === 'omnichannel') { + return ; + } + if (tmid && mode === 'thread') { + return ; + } + if (rid && mode === 'room') { + return ; + } + return ; +}; diff --git a/app/views/RoomView/components/RoomViewHeader/RoomViewHeader.tsx b/app/views/RoomView/components/RoomViewHeader/RoomViewHeader.tsx new file mode 100644 index 00000000000..b31e229fdb2 --- /dev/null +++ b/app/views/RoomView/components/RoomViewHeader/RoomViewHeader.tsx @@ -0,0 +1,42 @@ +import { hasNativeHeaderBar } from '~/lib/methods/helpers'; +import { useMasterDetail } from '~/lib/hooks/useMasterDetail'; +import { type RoomStore } from '~/views/RoomView/definitions'; +import { useJsRoomHeader } from '~/views/RoomView/hooks/useJsRoomHeader'; +import { NativeAvatarItem, NativeBackButton, NativeRightItems, NativeRoomTitle } from './NativeHeaderItems'; +import { RoomHeaderActions } from './RoomHeaderActions'; + +interface IRoomViewHeaderProps { + rid?: string; + tmid?: string; + name?: string; + roomStore: RoomStore; +} + +const NativeRoomHeader = ({ rid, tmid, name, roomStore }: IRoomViewHeaderProps & { rid: string }) => { + const showsAvatar = useMasterDetail() && !tmid; + + return ( + <> + + {showsAvatar ? : } + + + ); +}; + +const JsRoomHeader = ({ rid, tmid, name, roomStore }: IRoomViewHeaderProps) => { + useJsRoomHeader({ rid, tmid, name, roomStore }); + return null; +}; + +// rid/tmid/name come from the screen's mount-time snapshot: route.params can be wiped to undefined +// while this RoomView is retained below the stack top, which would break the header permanently. +export const RoomViewHeader = ({ rid, tmid, name, roomStore }: IRoomViewHeaderProps) => { + if (!hasNativeHeaderBar) { + return ; + } + if (!rid) { + return null; + } + return ; +}; diff --git a/app/views/RoomView/components/RoomViewHeader/__tests__/RoomViewHeader.test.tsx b/app/views/RoomView/components/RoomViewHeader/__tests__/RoomViewHeader.test.tsx new file mode 100644 index 00000000000..e05230bbbe3 --- /dev/null +++ b/app/views/RoomView/components/RoomViewHeader/__tests__/RoomViewHeader.test.tsx @@ -0,0 +1,202 @@ +import { act, render } from '@testing-library/react-native'; +import { createStore } from 'zustand'; + +import { type IHeaderAction } from '~/lib/methods/helpers/navigation/headerActions'; +import { type RoomState, type RoomStore } from '~/views/RoomView/definitions'; +import { useOmnichannelActions, useRoomActions, useThreadActions } from '~/views/RoomView/hooks/useRoomHeaderActions'; +import { useNativeRoomHeader } from '~/views/RoomView/hooks/useNativeRoomHeader'; +import { RoomViewHeader } from '../RoomViewHeader'; + +const mockSetOptions = jest.fn(); +jest.mock('@react-navigation/native', () => ({ + useNavigation: () => ({ setOptions: mockSetOptions, goBack: jest.fn(), canGoBack: () => true }) +})); + +let mockHasNativeHeaderBar = true; +jest.mock('~/lib/methods/helpers', () => + Object.defineProperty({ ...jest.requireActual('~/lib/methods/helpers'), getRoomTitle: () => 'general' }, 'hasNativeHeaderBar', { + get: () => mockHasNativeHeaderBar + }) +); + +let mockIsMasterDetail = false; +jest.mock('~/lib/hooks/useMasterDetail', () => ({ useMasterDetail: () => mockIsMasterDetail })); +jest.mock('~/theme', () => ({ useTheme: () => ({ colors: { fontDefault: 'default' } }) })); + +jest.mock('~/views/RoomView/hooks/useNativeRoomHeader', () => ({ useNativeRoomHeader: jest.fn() })); +jest.mock('~/views/RoomView/hooks/useGoRoomActionsView', () => ({ useGoRoomActionsView: () => jest.fn() })); +jest.mock('~/views/RoomView/hooks/useUnreadsCount', () => ({ useUnreadsCount: () => 4 })); +jest.mock('~/views/RoomView/components/LeftButtons', () => ({ __esModule: true, default: 'LeftButtons' })); +jest.mock('~/views/RoomView/components/RightButtons/RightButtons', () => ({ __esModule: true, default: 'RightButtons' })); +jest.mock('~/containers/RoomHeader', () => ({ __esModule: true, default: 'RoomHeader' })); +jest.mock('~/lib/methods/helpers/navigation/headerActions', () => ({ + ...jest.requireActual('~/lib/methods/helpers/navigation/headerActions'), + HeaderActions: 'HeaderActions' +})); + +jest.mock('~/views/RoomView/components/RightButtons/useRoomRightButtonsData', () => ({ useRoomRightButtonsData: jest.fn() })); +jest.mock('~/views/RoomView/components/RightButtons/useHeaderCallPress', () => ({ useHeaderCallPress: jest.fn() })); + +const mockActionsStore = createStore<{ roomActions: IHeaderAction[] }>(() => ({ + roomActions: [{ label: 'Threads', icon: 'threads', onPress: jest.fn() }] +})); +jest.mock('~/views/RoomView/hooks/useRoomHeaderActions', () => { + const { useStore } = jest.requireActual('zustand'); + return { + ...jest.requireActual('~/views/RoomView/hooks/useRoomHeaderActions'), + useRoomActions: jest.fn(() => useStore(mockActionsStore, (s: { roomActions: IHeaderAction[] }) => s.roomActions)), + useThreadActions: jest.fn(() => [{ label: 'Follow_thread', icon: 'notification-disabled', onPress: jest.fn() }]), + useOmnichannelActions: jest.fn(() => [{ label: 'More', icon: 'kebab', menu: [] }]) + }; +}); + +const makeRoomStore = (room: Partial = {}): RoomStore => + createStore(() => ({ + room: { id: 'sub-1', rid: 'rid-1', t: 'c', name: 'general', ...room } as RoomState['room'], + membership: 'subscribed', + member: {}, + roomUserId: null, + canAutoTranslate: false, + canForwardGuest: false, + canViewCannedResponse: false, + init: jest.fn(), + join: jest.fn(), + joinRoom: jest.fn(() => Promise.resolve()), + resumeRoom: jest.fn(() => Promise.resolve()) + })); + +const optionsWith = (key: string) => mockSetOptions.mock.calls.map(([options]) => options).filter(options => key in options); +const lastOptionsWith = (key: string) => optionsWith(key).at(-1); + +beforeEach(() => { + jest.clearAllMocks(); + mockHasNativeHeaderBar = true; + mockIsMasterDetail = false; + mockActionsStore.setState({ roomActions: [{ label: 'Threads', icon: 'threads', onPress: jest.fn() }] }); +}); + +describe('on the JS header', () => { + beforeEach(() => { + mockHasNativeHeaderBar = false; + }); + + it('renders the JS header buttons and title without native items', () => { + render(); + + expect(lastOptionsWith('headerLeft').headerLeft().type).toBe('LeftButtons'); + expect(lastOptionsWith('headerRight').headerRight().type).toBe('RightButtons'); + expect(lastOptionsWith('headerTitle').headerTitle().type).toBe('RoomHeader'); + expect(optionsWith('unstable_headerRightItems')).toHaveLength(0); + expect(optionsWith('unstable_headerLeftItems')).toHaveLength(0); + }); + + it('renders only the active mode actions in the JS header buttons', () => { + const { default: ActualRightButtons } = jest.requireActual('~/views/RoomView/components/RightButtons/RightButtons'); + render(); + expect(useThreadActions).not.toHaveBeenCalled(); + + const { props } = lastOptionsWith('headerRight').headerRight(); + const { toJSON } = render(); + + expect(useThreadActions).toHaveBeenCalledWith('tmid-1'); + expect(useRoomActions).not.toHaveBeenCalled(); + expect(useOmnichannelActions).not.toHaveBeenCalled(); + expect(toJSON()).toMatchObject({ + type: 'HeaderActions', + props: { actions: [expect.objectContaining({ label: 'Follow_thread' })] } + }); + }); +}); + +describe('on the native header bar', () => { + it('drives the title through the native header instead of a JS title', () => { + render(); + + expect(useNativeRoomHeader).toHaveBeenCalledWith( + expect.objectContaining({ title: 'general' }), + undefined, + null, + expect.any(Function) + ); + expect(optionsWith('headerTitle')).toHaveLength(0); + }); + + it('leaves the header untouched without a rid', () => { + render(); + + expect(mockSetOptions).not.toHaveBeenCalled(); + }); + + it('shows the room actions on the right and an unread back button on the left', () => { + render(); + + expect(lastOptionsWith('unstable_headerRightItems').unstable_headerRightItems()).toEqual([ + expect.objectContaining({ type: 'button', label: 'Threads' }) + ]); + const backOptions = lastOptionsWith('unstable_headerLeftItems'); + expect(backOptions.headerBackVisible).toBe(false); + expect(backOptions.unstable_headerLeftItems()).toEqual([expect.objectContaining({ label: '4' })]); + expect(optionsWith('headerRight')).toHaveLength(0); + }); + + it.each([ + ['room', undefined, {}, useRoomActions], + ['thread', 'tmid-1', {}, useThreadActions], + ['omnichannel', undefined, { t: 'l' }, useOmnichannelActions] + ])('mounts only the %s action hook', (_mode, tmid, room, activeHook) => { + render(); + + [useRoomActions, useThreadActions, useOmnichannelActions].forEach(hook => + hook === activeHook ? expect(hook).toHaveBeenCalled() : expect(hook).not.toHaveBeenCalled() + ); + }); + + it('swaps the actions when an omnichannel chat is placed back in the queue', () => { + const roomStore = makeRoomStore({ t: 'l' }); + render(); + expect(lastOptionsWith('unstable_headerRightItems').unstable_headerRightItems()).toHaveLength(1); + + act(() => { + roomStore.setState({ room: { id: 'sub-1', rid: 'rid-1', t: 'l', status: 'queued' } as RoomState['room'] }); + }); + + expect(lastOptionsWith('unstable_headerRightItems').unstable_headerRightItems()).toEqual([]); + }); + + describe('on a master-detail layout', () => { + beforeEach(() => { + mockIsMasterDetail = true; + }); + + it('shows the room avatar without the shared glass background instead of the back button', () => { + render(); + + const [avatarItem] = lastOptionsWith('unstable_headerLeftItems').unstable_headerLeftItems(); + expect(avatarItem).toMatchObject({ type: 'custom', hidesSharedBackground: true }); + expect(avatarItem.element.type).toBe('LeftButtons'); + expect(optionsWith('headerBackVisible')).toHaveLength(0); + }); + + it('updates the right items without re-sending the avatar', () => { + render(); + const leftUpdates = optionsWith('unstable_headerLeftItems').length; + + act(() => { + mockActionsStore.setState({ roomActions: [{ label: 'Call', icon: 'phone', onPress: jest.fn() }] }); + }); + + expect(lastOptionsWith('unstable_headerRightItems').unstable_headerRightItems()).toEqual([ + expect.objectContaining({ label: 'Call' }) + ]); + expect(optionsWith('unstable_headerLeftItems')).toHaveLength(leftUpdates); + }); + + it('keeps the back button on a thread', () => { + render(); + + expect(lastOptionsWith('unstable_headerLeftItems').unstable_headerLeftItems()).toEqual([ + expect.objectContaining({ label: '4', icon: { type: 'sfSymbol', name: 'chevron.backward' } }) + ]); + }); + }); +}); diff --git a/app/views/RoomView/components/__tests__/RightButtons.test.tsx b/app/views/RoomView/components/__tests__/RightButtons.test.tsx deleted file mode 100644 index 8170c55440f..00000000000 --- a/app/views/RoomView/components/__tests__/RightButtons.test.tsx +++ /dev/null @@ -1,245 +0,0 @@ -import { render } from '@testing-library/react-native'; - -import { type RoomMembership } from '~/views/RoomView/definitions'; -import RightButtons from '../RightButtons/RightButtons'; - -const mockNavigation = { navigate: jest.fn(), push: jest.fn() }; -jest.mock('@react-navigation/native', () => ({ - useNavigation: () => mockNavigation -})); -jest.mock('~/containers/ActionSheet', () => ({ - useActionSheet: () => ({ showActionSheet: jest.fn() }) -})); -jest.mock('~/lib/hooks/useMasterDetail', () => ({ - ...jest.requireActual('~/lib/hooks/useMasterDetail'), - useMasterDetail: () => false -})); -jest.mock('~/theme', () => ({ useTheme: () => ({ colors: { fontDanger: '#f00' } }) })); -jest.mock('~/lib/helpers/getRoomAccessibilityLabel', () => ({ __esModule: true, default: () => 'label' })); -jest.mock('~/lib/methods/helpers', () => ({ - ...jest.requireActual('~/lib/methods/helpers'), - getRoomTitle: () => 'Room Title', - isGroupChat: () => false -})); - -let mockAppState = { - login: { user: { id: 'u1', username: 'user', token: 'tok' } }, - settings: { Threads_enabled: true, Livechat_request_comment_when_closing_conversation: false }, - troubleshootingNotification: { issuesWithNotifications: false }, - permissions: { 'toggle-room-e2e-encryption': ['perm'] } -}; -jest.mock('~/lib/hooks/useAppSelector', () => ({ - useAppSelector: (selector: (state: typeof mockAppState) => unknown) => selector(mockAppState) -})); - -let mockRoomState = { - room: { id: 'sub-1', rid: 'rid-1', t: 'c', name: 'general' } as any, - membership: 'subscribed' as RoomMembership, - canForwardGuest: false -}; -jest.mock('zustand', () => ({ - useStore: (_store: unknown, selector: (state: typeof mockRoomState) => unknown) => selector(mockRoomState) -})); -jest.mock('~/ee/omnichannel/hooks/useCanReturnQueue', () => ({ useCanReturnQueue: () => false })); -jest.mock('~/views/RoomView/hooks/useCanPlaceLivechatOnHold', () => ({ useCanPlaceLivechatOnHold: () => false })); - -let mockE2EEStatus = { showMissingE2EEKey: false, showE2EEDisabledRoom: false, hasE2EEWarning: false }; -jest.mock('~/views/RoomView/hooks/useE2EEStatus', () => ({ useE2EEStatus: () => mockE2EEStatus })); - -let mockHeaderHooks = { - isFollowingThread: false, - tunread: [] as string[], - tunreadUser: [] as string[], - tunreadGroup: [] as string[], - isSelfDm: false, - canToggleEncryption: false -}; -jest.mock('~/views/RoomView/hooks/useThreadFollowing', () => ({ useThreadFollowing: () => mockHeaderHooks.isFollowingThread })); -jest.mock('~/views/RoomView/hooks/useSubscriptionUnreads', () => ({ - useSubscriptionUnreads: () => { - const { tunread, tunreadUser, tunreadGroup, isSelfDm } = mockHeaderHooks; - return { tunread, tunreadUser, tunreadGroup, isSelfDm }; - } -})); -jest.mock('~/lib/hooks/usePermissions', () => ({ - usePermissions: () => [mockHeaderHooks.canToggleEncryption] -})); - -jest.mock('~/containers/Header/components/HeaderButton', () => { - const ReactActual = jest.requireActual('react'); - return { - Container: ({ children }: any) => ReactActual.createElement('Container', null, children), - Item: (props: any) => ReactActual.createElement('Item', props), - BadgeUnread: () => null - }; -}); -jest.mock('../RightButtons/HeaderCallButton', () => { - const ReactActual = jest.requireActual('react'); - return { - HeaderCallButton: ({ rid, disabled, accessibilityLabel }: { rid: string; disabled: boolean; accessibilityLabel: string }) => - ReactActual.createElement('CallButton', { rid, disabled, accessibilityLabel, testID: 'header-call-button-stub' }) - }; -}); - -const allTestIDs = [ - 'room-view-search', - 'room-view-header-threads', - 'header-call-button-stub', - 'room-view-header-encryption', - 'room-view-push-troubleshoot', - 'room-view-header-omnichannel-kebab', - 'room-view-header-follow', - 'room-view-header-unfollow' -]; - -describe('RightButtons', () => { - const roomStore = { getState: () => mockRoomState } as any; - - const expectOnly = (queryByTestId: (id: string) => unknown, present: string[]) => { - present.forEach(id => expect(queryByTestId(id)).toBeTruthy()); - allTestIDs.filter(id => !present.includes(id)).forEach(id => expect(queryByTestId(id)).toBeNull()); - }; - - beforeEach(() => { - jest.clearAllMocks(); - mockAppState = { - login: { user: { id: 'u1', username: 'user', token: 'tok' } }, - settings: { Threads_enabled: true, Livechat_request_comment_when_closing_conversation: false }, - troubleshootingNotification: { issuesWithNotifications: false }, - permissions: { 'toggle-room-e2e-encryption': ['perm'] } - }; - mockRoomState = { - room: { id: 'sub-1', rid: 'rid-1', t: 'c', name: 'general' }, - membership: 'subscribed', - canForwardGuest: false - }; - mockE2EEStatus = { showMissingE2EEKey: false, showE2EEDisabledRoom: false, hasE2EEWarning: false }; - mockHeaderHooks = { - isFollowingThread: false, - tunread: [], - tunreadUser: [], - tunreadGroup: [], - isSelfDm: false, - canToggleEncryption: false - }; - }); - - it('renders only the kebab for an active omnichannel room', () => { - mockRoomState = { ...mockRoomState, room: { id: 'sub-1', rid: 'rid-1', t: 'l', name: 'chat' } }; - const { queryByTestId, toJSON } = render(); - expectOnly(queryByTestId, ['room-view-header-omnichannel-kebab']); - expect(toJSON()).toMatchSnapshot(); - }); - - it('renders the unfollow button for a followed thread', () => { - mockHeaderHooks = { ...mockHeaderHooks, isFollowingThread: true }; - const { queryByTestId, toJSON } = render(); - expectOnly(queryByTestId, ['room-view-header-unfollow']); - expect(queryByTestId('room-view-header-unfollow')).toHaveProp('accessibilityLabel', 'Unfollow thread'); - expect(toJSON()).toMatchSnapshot(); - }); - - it('renders the follow button for an unfollowed thread', () => { - const { queryByTestId, toJSON } = render(); - expectOnly(queryByTestId, ['room-view-header-follow']); - expect(queryByTestId('room-view-header-follow')).toHaveProp('accessibilityLabel', 'Follow thread'); - expect(toJSON()).toMatchSnapshot(); - }); - - it('renders call, threads and search for a regular channel', () => { - const { queryByTestId, toJSON } = render(); - expectOnly(queryByTestId, ['header-call-button-stub', 'room-view-header-threads', 'room-view-search']); - expect(toJSON()).toMatchSnapshot(); - }); - - it('enables the encryption button when the user can toggle encryption', () => { - mockRoomState = { ...mockRoomState, room: { id: 'sub-1', rid: 'rid-1', t: 'c', name: 'general', encrypted: true } }; - mockE2EEStatus = { showMissingE2EEKey: true, showE2EEDisabledRoom: false, hasE2EEWarning: true }; - mockHeaderHooks = { ...mockHeaderHooks, canToggleEncryption: true }; - const { queryByTestId, toJSON } = render(); - expectOnly(queryByTestId, [ - 'room-view-header-encryption', - 'header-call-button-stub', - 'room-view-header-threads', - 'room-view-search' - ]); - expect(queryByTestId('room-view-header-encryption')).toHaveProp('disabled', false); - expect(queryByTestId('room-view-search')).toHaveProp('disabled', true); - expect(toJSON()).toMatchSnapshot(); - }); - - it('disables the encryption button when the user cannot toggle encryption', () => { - mockRoomState = { ...mockRoomState, room: { id: 'sub-1', rid: 'rid-1', t: 'c', name: 'general', encrypted: true } }; - mockE2EEStatus = { showMissingE2EEKey: true, showE2EEDisabledRoom: false, hasE2EEWarning: true }; - const { queryByTestId, toJSON } = render(); - expectOnly(queryByTestId, [ - 'room-view-header-encryption', - 'header-call-button-stub', - 'room-view-header-threads', - 'room-view-search' - ]); - expect(queryByTestId('room-view-header-encryption')).toHaveProp('disabled', true); - expect(toJSON()).toMatchSnapshot(); - }); - - it('renders the encryption button when the room has e2ee disabled', () => { - mockRoomState = { ...mockRoomState, room: { id: 'sub-1', rid: 'rid-1', t: 'c', name: 'general', encrypted: true } }; - mockE2EEStatus = { showMissingE2EEKey: false, showE2EEDisabledRoom: true, hasE2EEWarning: true }; - const { queryByTestId, toJSON } = render(); - expectOnly(queryByTestId, [ - 'room-view-header-encryption', - 'header-call-button-stub', - 'room-view-header-threads', - 'room-view-search' - ]); - expect(queryByTestId('room-view-header-encryption')).toHaveProp('disabled', true); - expect(toJSON()).toMatchSnapshot(); - }); - - it('renders the push troubleshoot button when there are notification issues', () => { - mockAppState = { ...mockAppState, troubleshootingNotification: { issuesWithNotifications: true } }; - const { queryByTestId, toJSON } = render(); - expectOnly(queryByTestId, [ - 'room-view-push-troubleshoot', - 'header-call-button-stub', - 'room-view-header-threads', - 'room-view-search' - ]); - expect(queryByTestId('room-view-push-troubleshoot')).toHaveProp('color', '#f00'); - expect(toJSON()).toMatchSnapshot(); - }); - - it('renders the push troubleshoot button when notifications are disabled for the room', () => { - mockRoomState = { - ...mockRoomState, - room: { id: 'sub-1', rid: 'rid-1', t: 'c', name: 'general', disableNotifications: true } - }; - const { queryByTestId, toJSON } = render(); - expectOnly(queryByTestId, [ - 'room-view-push-troubleshoot', - 'header-call-button-stub', - 'room-view-header-threads', - 'room-view-search' - ]); - expect(queryByTestId('room-view-push-troubleshoot')).toHaveProp('color', ''); - expect(toJSON()).toMatchSnapshot(); - }); - - it('hides the threads button when threads are disabled', () => { - mockAppState = { - ...mockAppState, - settings: { Threads_enabled: false, Livechat_request_comment_when_closing_conversation: false } - }; - const { queryByTestId, toJSON } = render(); - expectOnly(queryByTestId, ['header-call-button-stub', 'room-view-search']); - expect(toJSON()).toMatchSnapshot(); - }); - - it('hides the call button on a self DM', () => { - mockRoomState = { ...mockRoomState, room: { id: 'sub-1', rid: 'rid-1', t: 'd', name: 'user' } }; - mockHeaderHooks = { ...mockHeaderHooks, isSelfDm: true }; - const { queryByTestId, toJSON } = render(); - expectOnly(queryByTestId, ['room-view-header-threads', 'room-view-search']); - expect(toJSON()).toMatchSnapshot(); - }); -}); diff --git a/app/views/RoomView/components/__tests__/__snapshots__/RightButtons.test.tsx.snap b/app/views/RoomView/components/__tests__/__snapshots__/RightButtons.test.tsx.snap deleted file mode 100644 index 2f2c9265c67..00000000000 --- a/app/views/RoomView/components/__tests__/__snapshots__/RightButtons.test.tsx.snap +++ /dev/null @@ -1,259 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`RightButtons disables the encryption button when the user cannot toggle encryption 1`] = ` - - - - - - -`; - -exports[`RightButtons enables the encryption button when the user can toggle encryption 1`] = ` - - - - - - -`; - -exports[`RightButtons hides the call button on a self DM 1`] = ` - - - - -`; - -exports[`RightButtons hides the threads button when threads are disabled 1`] = ` - - - - -`; - -exports[`RightButtons renders call, threads and search for a regular channel 1`] = ` - - - - - -`; - -exports[`RightButtons renders only the kebab for an active omnichannel room 1`] = ` - - - -`; - -exports[`RightButtons renders the encryption button when the room has e2ee disabled 1`] = ` - - - - - - -`; - -exports[`RightButtons renders the follow button for an unfollowed thread 1`] = ` - - - -`; - -exports[`RightButtons renders the push troubleshoot button when notifications are disabled for the room 1`] = ` - - - - - - -`; - -exports[`RightButtons renders the push troubleshoot button when there are notification issues 1`] = ` - - - - - - -`; - -exports[`RightButtons renders the unfollow button for a followed thread 1`] = ` - - - -`; diff --git a/app/views/RoomView/helpers/__tests__/roomHeaderActions.test.ts b/app/views/RoomView/helpers/__tests__/roomHeaderActions.test.ts new file mode 100644 index 00000000000..6b54d3f1e1c --- /dev/null +++ b/app/views/RoomView/helpers/__tests__/roomHeaderActions.test.ts @@ -0,0 +1,48 @@ +import { splitRoomHeaderActions } from '../roomHeaderActions'; + +describe('splitRoomHeaderActions', () => { + it('keeps every present item visible when at or under the cap', () => { + const { visibleKeys, overflowKeys } = splitRoomHeaderActions({ threads: true, call: true }); + + expect(visibleKeys).toEqual(['threads', 'call']); + expect(overflowKeys).toEqual([]); + }); + + it('ranks threads ahead of call so an unread badge always keeps a bar item', () => { + const { visibleKeys, overflowKeys } = splitRoomHeaderActions({ threads: true, call: true, encryption: true }); + + expect(visibleKeys).toEqual(['threads', 'call']); + expect(overflowKeys).toEqual(['encryption']); + }); + + it('demotes the lowest-ranked items to overflow when every warning is active at once', () => { + const { visibleKeys, overflowKeys } = splitRoomHeaderActions({ + threads: true, + call: true, + encryption: true, + notifications: true + }); + + expect(visibleKeys).toEqual(['threads', 'call']); + expect(overflowKeys).toEqual(['encryption', 'notifications']); + }); + + it('promotes call to the visible cluster once threads is absent', () => { + const { visibleKeys, overflowKeys } = splitRoomHeaderActions({ + threads: false, + call: true, + encryption: true, + notifications: true + }); + + expect(visibleKeys).toEqual(['call', 'encryption']); + expect(overflowKeys).toEqual(['notifications']); + }); + + it('returns nothing visible or overflowing when no conditional item is present', () => { + const { visibleKeys, overflowKeys } = splitRoomHeaderActions({}); + + expect(visibleKeys).toEqual([]); + expect(overflowKeys).toEqual([]); + }); +}); diff --git a/app/views/RoomView/helpers/getRoomHeaderMode.ts b/app/views/RoomView/helpers/getRoomHeaderMode.ts new file mode 100644 index 00000000000..71e90274cf9 --- /dev/null +++ b/app/views/RoomView/helpers/getRoomHeaderMode.ts @@ -0,0 +1,28 @@ +import { type RoomMembership } from '../definitions'; + +export type TRoomHeaderMode = 'none' | 'omnichannel' | 'thread' | 'room'; + +export const getRoomHeaderMode = ({ + rid, + tmid, + t, + status, + membership +}: { + rid?: string; + tmid?: string; + t?: string; + status?: string; + membership: RoomMembership; +}): TRoomHeaderMode => { + if (!rid || membership === 'invited') { + return 'none'; + } + if (t === 'l') { + return status === 'queued' || membership !== 'subscribed' ? 'none' : 'omnichannel'; + } + if (tmid) { + return 'thread'; + } + return 'room'; +}; diff --git a/app/views/RoomView/helpers/roomHeaderActions.ts b/app/views/RoomView/helpers/roomHeaderActions.ts new file mode 100644 index 00000000000..794264b553c --- /dev/null +++ b/app/views/RoomView/helpers/roomHeaderActions.ts @@ -0,0 +1,13 @@ +const ROOM_HEADER_ACTION_PRIORITY = ['threads', 'call', 'encryption', 'notifications'] as const; +export type TRoomHeaderActionKey = (typeof ROOM_HEADER_ACTION_PRIORITY)[number]; +const MAX_VISIBLE_ROOM_HEADER_ACTIONS = 2; + +export const splitRoomHeaderActions = ( + present: Partial> +): { visibleKeys: TRoomHeaderActionKey[]; overflowKeys: TRoomHeaderActionKey[] } => { + const presentKeys = ROOM_HEADER_ACTION_PRIORITY.filter(key => present[key]); + return { + visibleKeys: presentKeys.slice(0, MAX_VISIBLE_ROOM_HEADER_ACTIONS), + overflowKeys: presentKeys.slice(MAX_VISIBLE_ROOM_HEADER_ACTIONS) + }; +}; diff --git a/app/views/RoomView/hooks/__tests__/useHeader.test.tsx b/app/views/RoomView/hooks/__tests__/useJsRoomHeader.test.tsx similarity index 86% rename from app/views/RoomView/hooks/__tests__/useHeader.test.tsx rename to app/views/RoomView/hooks/__tests__/useJsRoomHeader.test.tsx index 98bb91ca015..9edc8dc1c5a 100644 --- a/app/views/RoomView/hooks/__tests__/useHeader.test.tsx +++ b/app/views/RoomView/hooks/__tests__/useJsRoomHeader.test.tsx @@ -1,8 +1,9 @@ import { act, renderHook } from '@testing-library/react-native'; + import { createStore } from 'zustand'; import { type RoomState, type RoomStore } from '~/views/RoomView/definitions'; -import { useHeader } from '../useHeader'; +import { useJsRoomHeader } from '../useJsRoomHeader'; let mockTestStore: RoomStore; @@ -41,14 +42,14 @@ const makeRoomStore = (overrides: Partial = {}): RoomStore => ...overrides })); -describe('useHeader', () => { +describe('useJsRoomHeader', () => { beforeEach(() => { jest.clearAllMocks(); mockTestStore = makeRoomStore(); }); it('sets only the headerLeft spacer and returns when rid is missing', () => { - renderHook(() => useHeader({ rid: undefined, tmid: undefined, name: 'general', roomStore: mockTestStore })); + renderHook(() => useJsRoomHeader({ rid: undefined, tmid: undefined, name: 'general', roomStore: mockTestStore })); expect(mockSetOptions).toHaveBeenCalledTimes(1); const options = mockSetOptions.mock.calls[0][0]; @@ -62,7 +63,7 @@ describe('useHeader', () => { room: { id: 'sub-1', rid: 'rid-1', t: 'c', name: 'general', topic: 'old' } as RoomState['room'] }); - renderHook(() => useHeader({ rid: 'rid-1', tmid: undefined, name: 'general', roomStore: mockTestStore })); + renderHook(() => useJsRoomHeader({ rid: 'rid-1', tmid: undefined, name: 'general', roomStore: mockTestStore })); expect(mockSetOptions).toHaveBeenCalledTimes(2); act(() => { @@ -74,7 +75,7 @@ describe('useHeader', () => { }); it('keeps the thread title from the passed name when the observed room name changes', () => { - renderHook(() => useHeader({ rid: 'rid-1', tmid: 'tmid-1', name: 'Thread name', roomStore: mockTestStore })); + renderHook(() => useJsRoomHeader({ rid: 'rid-1', tmid: 'tmid-1', name: 'Thread name', roomStore: mockTestStore })); const titleOptions = mockSetOptions.mock.calls[1][0]; expect(titleOptions.headerTitle().props.title).toBe('Thread name'); @@ -87,7 +88,7 @@ describe('useHeader', () => { }); it('renders each header callback without throwing', () => { - renderHook(() => useHeader({ rid: 'rid-1', tmid: undefined, name: 'general', roomStore: mockTestStore })); + renderHook(() => useJsRoomHeader({ rid: 'rid-1', tmid: undefined, name: 'general', roomStore: mockTestStore })); const sideOptions = mockSetOptions.mock.calls[0][0]; const titleOptions = mockSetOptions.mock.calls[1][0]; diff --git a/app/views/RoomView/hooks/__tests__/useNativeBackButton.test.ts b/app/views/RoomView/hooks/__tests__/useNativeBackButton.test.ts new file mode 100644 index 00000000000..020e9fa03d6 --- /dev/null +++ b/app/views/RoomView/hooks/__tests__/useNativeBackButton.test.ts @@ -0,0 +1,58 @@ +import { renderHook } from '@testing-library/react-native'; + +import { useNativeBackButton } from '../useNativeBackButton'; + +const mockSetOptions = jest.fn(); +const mockGoBack = jest.fn(); +let mockCanGoBack = true; +let mockUnreads: number | null = null; +const mockUseUnreadsCount = jest.fn((_rid?: string) => mockUnreads); + +jest.mock('@react-navigation/native', () => ({ + useNavigation: () => ({ setOptions: mockSetOptions, goBack: mockGoBack, canGoBack: () => mockCanGoBack }) +})); +jest.mock('../useUnreadsCount', () => ({ useUnreadsCount: (rid?: string) => mockUseUnreadsCount(rid) })); + +const renderBackItem = (unreads: number | null) => { + mockUnreads = unreads; + renderHook(() => useNativeBackButton('rid')); + const options = mockSetOptions.mock.lastCall[0]; + expect(options.headerBackVisible).toBe(false); + const [backItem] = options.unstable_headerLeftItems(); + return backItem; +}; + +beforeEach(() => { + jest.clearAllMocks(); + mockUnreads = null; + mockCanGoBack = true; +}); + +it('replaces the back button with a chevron labelled with the unread count', () => { + const backItem = renderBackItem(7); + expect(mockUseUnreadsCount).toHaveBeenCalledWith('rid'); + expect(backItem).toMatchObject({ + label: '7', + icon: { type: 'sfSymbol', name: 'chevron.backward' }, + showsLabelWithIcon: true + }); +}); + +it('caps the count at +99', () => { + expect(renderBackItem(150).label).toBe('+99'); +}); + +it('shows only the chevron without unreads', () => { + expect(renderBackItem(0).label).toBe(''); +}); + +it('goes back when pressed', () => { + renderBackItem(3).onPress(); + expect(mockGoBack).toHaveBeenCalledTimes(1); +}); + +it('clears custom left items when it cannot go back', () => { + mockCanGoBack = false; + renderHook(() => useNativeBackButton('rid')); + expect(mockSetOptions).toHaveBeenCalledWith({ unstable_headerLeftItems: undefined }); +}); diff --git a/app/views/RoomView/hooks/__tests__/useNativeRoomHeader.test.ts b/app/views/RoomView/hooks/__tests__/useNativeRoomHeader.test.ts new file mode 100644 index 00000000000..125053c17f6 --- /dev/null +++ b/app/views/RoomView/hooks/__tests__/useNativeRoomHeader.test.ts @@ -0,0 +1,117 @@ +import { act, renderHook, waitFor } from '@testing-library/react-native'; +import { Image } from 'react-native'; + +import { getUserPresence } from '~/lib/methods/getUsersPresence'; +import { type IHeaderFields } from '../useHeaderFields'; +import { useNativeRoomHeader } from '../useNativeRoomHeader'; + +const mockSetOptions = jest.fn(); +const mockNavigation = { setOptions: mockSetOptions }; +let mockState: any; + +jest.mock('@react-navigation/native', () => ({ useNavigation: () => mockNavigation })); +jest.mock('~/lib/hooks/useAppSelector', () => ({ useAppSelector: (select: (state: any) => unknown) => select(mockState) })); +jest.mock('~/containers/CustomIcon', () => ({ + IconSet: { getImageSource: jest.fn(async (name, size, color) => ({ uri: `${name}:${color}`, width: size, height: size })) }, + hasIcon: () => true +})); +jest.mock('~/lib/hooks/usePreviewFormatText', () => ({ __esModule: true, default: (text: string) => text.replaceAll('*', '') })); +jest.mock('~/lib/hooks/useUserStatusColor', () => ({ useUserStatusColor: (status: string) => status })); +jest.mock('~/lib/methods/getUsersPresence', () => ({ getUserPresence: jest.fn() })); +jest.mock('~/lib/methods/helpers/formatStatusExpiry', () => ({ formatStatusExpiry: (expiry?: string) => expiry })); +jest.mock('~/lib/methods/helpers/log', () => ({ __esModule: true, default: jest.fn() })); +jest.mock('~/theme', () => ({ useTheme: () => ({ colors: { fontTitlesLabels: 'title', fontSecondaryInfo: 'subtitle' } }) })); +jest.mock('~/i18n', () => ({ __esModule: true, default: { t: (key: string) => key } })); + +const fields: IHeaderFields = { + title: 'Room', + parentTitle: 'Parent', + teamMain: false, + subtitle: '*Topic*', + type: 'c', + isGroupChat: false, + abacAttributes: undefined, + disabled: false +}; +const latestOptions = () => mockSetOptions.mock.calls.at(-1)?.[0]; + +beforeEach(() => { + jest.clearAllMocks(); + mockState = { + meteor: { connected: true, connecting: false }, + server: { loading: false }, + settings: {}, + activeUsers: {}, + usersTyping: [] + }; +}); + +it('sets the title before the room icon is ready and adds the icon when it loads', async () => { + renderHook(() => useNativeRoomHeader({ ...fields, type: 'p', title: 'Shown' })); + expect(mockSetOptions.mock.calls[0][0]).toMatchObject({ headerTitle: 'Shown', headerSubtitle: 'Topic' }); + await waitFor(() => expect(latestOptions().headerTitleImageSource?.uri).toBe('channel-private:title')); + expect(latestOptions().headerTitle).toBe('Shown'); +}); + +it('shows plain topic text and the room icon', async () => { + renderHook(() => useNativeRoomHeader(fields)); + await waitFor(() => expect(latestOptions().headerTitleImageSource?.uri).toBe('channel-public:title')); + expect(latestOptions()).toMatchObject({ headerTitle: 'Room', headerSubtitle: 'Topic', headerSubtitleImageSource: undefined }); +}); + +it('keeps the parent subtitle and moves its icon below the Thread title while users type', async () => { + mockState.usersTyping = ['Alice']; + renderHook(() => useNativeRoomHeader({ ...fields, title: '*Thread*', parentTitle: '*Parent*' }, 'thread')); + await waitFor(() => expect(latestOptions().headerSubtitleImageSource?.uri).toBe('channel-public:title')); + expect(latestOptions()).toMatchObject({ headerTitle: 'Thread', headerSubtitle: '*Parent*', headerTitleImageSource: undefined }); +}); + +it('clears the expiry clock during typing and updates the colored presence image', async () => { + mockState.activeUsers.user = { status: 'online', statusText: 'Working', statusExpiresAt: 'future' }; + const { rerender } = renderHook(() => useNativeRoomHeader({ ...fields, type: 'd' }, undefined, 'user')); + await waitFor(() => expect(latestOptions().headerSubtitleImageSource?.uri).toBe('clock:subtitle')); + expect(latestOptions().headerTitleImageSource.uri).toBe('status-online:online'); + mockState = { ...mockState, usersTyping: ['Alice', 'Bob'], activeUsers: { user: { status: 'busy' } } }; + rerender({}); + await waitFor(() => expect(latestOptions().headerTitleImageSource?.uri).toBe('status-busy:busy')); + expect(latestOptions().headerSubtitle).toBe('Alice and Bob are_typing...'); + expect(latestOptions().headerSubtitleImageSource).toBeUndefined(); +}); + +it('requests missing direct-message presence but respects disabled broadcasting', async () => { + const { rerender } = renderHook(() => useNativeRoomHeader({ ...fields, type: 'd' }, undefined, 'user')); + await act(async () => {}); + expect(getUserPresence).toHaveBeenCalledWith('user'); + jest.mocked(getUserPresence).mockClear(); + mockState.settings.Presence_broadcast_disabled = true; + rerender({}); + await act(async () => {}); + expect(getUserPresence).not.toHaveBeenCalled(); +}); + +it('uses supported Omnichannel app images and falls back when decoding fails', async () => { + const getSize = jest.spyOn(Image, 'getSize').mockImplementation(() => Promise.resolve({ width: 24, height: 24 })); + mockState.server.server = 'https://chat.example'; + let sourceType = { type: 'app', id: 'app-id', sidebarIcon: 'icon.png' } as IHeaderFields['sourceType']; + const { rerender } = renderHook(() => useNativeRoomHeader({ ...fields, type: 'l', sourceType })); + await waitFor(() => expect(latestOptions().headerTitleImageSource?.uri).toContain('get-sidebar-icon?icon=icon.png')); + getSize.mockImplementation(() => Promise.reject(new Error('Unsupported image'))); + sourceType = { ...sourceType, sidebarIcon: 'icon.svg' } as IHeaderFields['sourceType']; + rerender({}); + await waitFor(() => expect(latestOptions().headerTitleImageSource?.uri).toBe('omnichannel:offline')); + getSize.mockRestore(); +}); + +it('opens room actions when the title is pressed', async () => { + const onTitlePress = jest.fn(); + renderHook(() => useNativeRoomHeader(fields, undefined, undefined, onTitlePress)); + await waitFor(() => expect(latestOptions().onHeaderTitlePress).toBeInstanceOf(Function)); + latestOptions().onHeaderTitlePress({ nativeEvent: {} }); + expect(onTitlePress).toHaveBeenCalledWith(); +}); + +it('does not make the title pressable for invite subscriptions', async () => { + renderHook(() => useNativeRoomHeader({ ...fields, disabled: true }, undefined, undefined, jest.fn())); + await waitFor(() => expect(latestOptions().headerTitleImageSource?.uri).toBe('channel-public:title')); + expect(latestOptions().onHeaderTitlePress).toBeUndefined(); +}); diff --git a/app/views/RoomView/hooks/__tests__/useRoomHeaderActions.test.ts b/app/views/RoomView/hooks/__tests__/useRoomHeaderActions.test.ts new file mode 100644 index 00000000000..fc490c40e89 --- /dev/null +++ b/app/views/RoomView/hooks/__tests__/useRoomHeaderActions.test.ts @@ -0,0 +1,455 @@ +import { act, renderHook } from '@testing-library/react-native'; + +import { type IHeaderAction } from '~/lib/methods/helpers/navigation/headerActions'; +import { events, logEvent } from '~/lib/methods/helpers/log'; +import { showConfirmationAlert } from '~/lib/methods/helpers'; +import { toggleFollowThread } from '~/lib/methods/toggleFollowThread'; +import { returnLivechat } from '~/lib/services/restApi'; +import { type RoomStore } from '~/views/RoomView/definitions'; +import { closeLivechat } from '~/views/RoomView/services/closeLivechat'; +import { placeLivechatOnHold } from '~/views/RoomView/services/placeLivechatOnHold'; +import { useOmnichannelActions, useRoomActions, useRoomHeaderMode, useThreadActions } from '../useRoomHeaderActions'; + +const mockNavigation = { navigate: jest.fn(), push: jest.fn() }; +jest.mock('@react-navigation/native', () => ({ useNavigation: () => mockNavigation })); + +let mockIsMasterDetail = false; +jest.mock('~/lib/hooks/useMasterDetail', () => ({ useMasterDetail: () => mockIsMasterDetail })); + +let mockLivechatRequestComment = false; +jest.mock('~/lib/hooks/useSetting', () => ({ useSetting: () => mockLivechatRequestComment })); + +let mockUserId = 'user-1'; +jest.mock('~/lib/hooks/useAppSelector', () => ({ useAppSelector: () => mockUserId })); +jest.mock('~/theme', () => ({ useTheme: () => ({ theme: 'light', colors: { fontDanger: '#f00' } }) })); +jest.mock('~/lib/methods/helpers/log', () => ({ + ...jest.requireActual('~/lib/methods/helpers/log'), + logEvent: jest.fn() +})); + +let mockHasNativeHeaderBar = true; +jest.mock('~/lib/methods/helpers', () => + Object.defineProperty( + { ...jest.requireActual('~/lib/methods/helpers'), showConfirmationAlert: jest.fn(), showErrorAlert: jest.fn() }, + 'hasNativeHeaderBar', + { get: () => mockHasNativeHeaderBar } + ) +); + +let mockRoomState: Record; +jest.mock('zustand', () => ({ + useStore: (_store: unknown, selector: (state: Record) => unknown) => selector(mockRoomState) +})); +jest.mock('zustand/react/shallow', () => ({ useShallow: (selector: unknown) => selector })); + +let mockCanReturnQueue = false; +let mockCanPlaceLivechatOnHold = false; +jest.mock('~/ee/omnichannel/hooks/useCanReturnQueue', () => ({ useCanReturnQueue: () => mockCanReturnQueue })); +jest.mock('../useCanPlaceLivechatOnHold', () => ({ useCanPlaceLivechatOnHold: () => mockCanPlaceLivechatOnHold })); + +let mockFollowersByThread: Record = {}; +jest.mock('../useThreadFollowing', () => ({ + useThreadFollowing: (tmid: string, userId: string) => mockFollowersByThread[tmid]?.includes(userId) ?? false +})); +jest.mock('~/lib/methods/toggleFollowThread', () => ({ toggleFollowThread: jest.fn() })); +jest.mock('~/views/RoomView/services/closeLivechat', () => ({ closeLivechat: jest.fn() })); +jest.mock('~/views/RoomView/services/placeLivechatOnHold', () => ({ placeLivechatOnHold: jest.fn() })); +jest.mock('~/lib/services/restApi', () => ({ returnLivechat: jest.fn() })); + +const mockGoThreadsView = jest.fn(); +const mockGoSearchView = jest.fn(); +let mockButtonsData: Record; +jest.mock('../../components/RightButtons/useRoomRightButtonsData', () => ({ + useRoomRightButtonsData: () => mockButtonsData +})); + +const mockVideoConf = { showInitCallActionSheet: jest.fn(), callEnabled: false, disabledTooltip: false }; +jest.mock('~/lib/hooks/useVideoConf', () => ({ useVideoConf: () => mockVideoConf })); + +const mockMediaCall = { + openNewMediaCall: jest.fn(), + startCallImmediate: jest.fn(), + hasMediaCallPermission: false, + isInActiveCall: false +}; +jest.mock('~/lib/hooks/useNewMediaCall', () => ({ useNewMediaCall: () => mockMediaCall })); + +const roomStore = {} as RoomStore; + +const renderRoomActions = () => renderHook(() => useRoomActions('rid-1', roomStore)).result.current; +const renderThreadActions = (tmid: string) => renderHook(() => useThreadActions(tmid)).result.current; +const renderOmnichannelActions = () => renderHook(() => useOmnichannelActions('rid-1', roomStore)).result.current; + +const labelsOf = (actions: { label: string }[]) => actions.map(action => action.label); + +const moreMenuOf = (actions: IHeaderAction[]) => { + const more = actions[actions.length - 1]; + expect(more.label).toBe('More'); + expect(more.icon).toBe('kebab'); + return more.menu ?? []; +}; + +const actionByTestID = (actions: IHeaderAction[], testID: string) => actions.find(action => action.testID === testID); + +describe('useRoomHeaderActions', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockHasNativeHeaderBar = true; + mockIsMasterDetail = false; + mockLivechatRequestComment = false; + mockUserId = 'user-1'; + mockCanReturnQueue = false; + mockCanPlaceLivechatOnHold = false; + mockFollowersByThread = {}; + mockRoomState = { + room: { id: 'sub-1', t: 'c', departmentId: 'department-1' }, + membership: 'subscribed', + canForwardGuest: false + }; + mockButtonsData = { + threadsEnabled: false, + issuesWithNotifications: false, + disableNotifications: false, + hasE2EEWarning: false, + canToggleEncryption: false, + isSelfDm: false, + tunread: [], + tunreadUser: [], + tunreadGroup: [], + threadsAccessibilityLabel: 'Threads', + callAccessibilityLabel: 'Call', + goThreadsView: mockGoThreadsView, + goSearchView: mockGoSearchView, + goE2EEToggleRoomView: jest.fn(), + navigateToNotificationOrPushTroubleshoot: jest.fn() + }; + mockVideoConf.callEnabled = false; + mockVideoConf.disabledTooltip = false; + mockMediaCall.hasMediaCallPermission = false; + mockMediaCall.isInActiveCall = false; + }); + + it.each([ + ['none without a rid', undefined, undefined, { room: { t: 'c' }, membership: 'subscribed' }, 'none'], + ['none for an invited room', 'rid-1', undefined, { room: { t: 'c' }, membership: 'invited' }, 'none'], + [ + 'none for a queued omnichannel room', + 'rid-1', + undefined, + { room: { id: 'sub-1', t: 'l', status: 'queued' }, membership: 'subscribed' }, + 'none' + ], + ['none for an omnichannel room in preview', 'rid-1', undefined, { room: { t: 'l' }, membership: 'preview' }, 'none'], + [ + 'omnichannel for an active omnichannel room, even inside a thread', + 'rid-1', + 'tmid-1', + { room: { id: 'sub-1', t: 'l' }, membership: 'subscribed' }, + 'omnichannel' + ], + ['thread when a tmid is given', 'rid-1', 'tmid-1', { room: { t: 'c' }, membership: 'subscribed' }, 'thread'], + ['room for a regular channel', 'rid-1', undefined, { room: { t: 'c' }, membership: 'subscribed' }, 'room'] + ])('picks the %s', (_case, rid, tmid, state, mode) => { + mockRoomState = { ...state, canForwardGuest: false }; + + const { result } = renderHook(() => useRoomHeaderMode(rid, tmid, roomStore)); + + expect(result.current).toBe(mode); + }); + + describe('room on the native header bar', () => { + it('lists search in the more menu and runs the chosen action', () => { + const menu = moreMenuOf(renderRoomActions()); + + expect(labelsOf(menu)).toEqual(['Search messages']); + menu[0].onPress(); + expect(mockGoSearchView).toHaveBeenCalled(); + }); + + it('keeps the two highest priority actions visible and moves the rest into the menu, keeping disabled state', () => { + mockButtonsData = { ...mockButtonsData, threadsEnabled: true, hasE2EEWarning: true, canToggleEncryption: false }; + mockMediaCall.hasMediaCallPermission = true; + + const actions = renderRoomActions(); + + expect(labelsOf(actions)).toEqual(['Call', 'Threads', 'More']); + expect(moreMenuOf(actions).map(item => [item.label, !!item.disabled])).toEqual([ + ['Encrypted', true], + ['Search messages', true] + ]); + }); + }); + + describe('room on the JS header', () => { + beforeEach(() => { + mockHasNativeHeaderBar = false; + }); + + it('shows every present action followed by search, with no more menu', () => { + mockButtonsData = { + ...mockButtonsData, + threadsEnabled: true, + hasE2EEWarning: true, + canToggleEncryption: true, + issuesWithNotifications: true + }; + mockMediaCall.hasMediaCallPermission = true; + + const actions = renderRoomActions(); + + expect(actions.map(action => action.testID)).toEqual([ + 'room-view-header-encryption', + 'room-view-push-troubleshoot', + 'room-view-header-call', + 'room-view-header-threads', + 'room-view-search' + ]); + expect(actions.some(action => action.menu)).toBe(false); + }); + + it('offers encryption while the other actions are disabled by the e2ee warning', () => { + mockButtonsData = { ...mockButtonsData, threadsEnabled: true, hasE2EEWarning: true, canToggleEncryption: true }; + + const actions = renderRoomActions(); + + expect(actionByTestID(actions, 'room-view-header-encryption')?.disabled).toBe(false); + expect(actionByTestID(actions, 'room-view-header-threads')?.disabled).toBe(true); + expect(actionByTestID(actions, 'room-view-search')?.disabled).toBe(true); + }); + + it('disables encryption when the user cannot toggle it', () => { + mockButtonsData = { ...mockButtonsData, hasE2EEWarning: true, canToggleEncryption: false }; + + expect(actionByTestID(renderRoomActions(), 'room-view-header-encryption')?.disabled).toBe(true); + }); + + it('tints the troubleshoot action only when notifications have issues', () => { + mockButtonsData = { ...mockButtonsData, issuesWithNotifications: true }; + expect(actionByTestID(renderRoomActions(), 'room-view-push-troubleshoot')?.tintColor).toBe('#f00'); + + mockButtonsData = { ...mockButtonsData, issuesWithNotifications: false, disableNotifications: true }; + const troubleshoot = actionByTestID(renderRoomActions(), 'room-view-push-troubleshoot'); + expect(troubleshoot).toBeDefined(); + expect(troubleshoot?.tintColor).toBeUndefined(); + }); + + it('hides the threads action when threads are disabled', () => { + expect(actionByTestID(renderRoomActions(), 'room-view-header-threads')).toBeUndefined(); + }); + + it('hides the call action on a self DM', () => { + mockButtonsData = { ...mockButtonsData, isSelfDm: true }; + mockMediaCall.hasMediaCallPermission = true; + + expect(actionByTestID(renderRoomActions(), 'room-view-header-call')).toBeUndefined(); + }); + + it('badges threads with the unread thread count in the unread style color', () => { + mockButtonsData = { ...mockButtonsData, threadsEnabled: true, tunread: ['t1', 't2'], tunreadUser: ['t1'] }; + + const threads = actionByTestID(renderRoomActions(), 'room-view-header-threads'); + + expect(threads?.badge?.value).toBe(2); + expect(threads?.badge?.color).toEqual(expect.any(String)); + threads?.onPress?.(); + expect(mockGoThreadsView).toHaveBeenCalled(); + }); + + it('leaves threads without a badge when nothing is unread', () => { + mockButtonsData = { ...mockButtonsData, threadsEnabled: true }; + + expect(actionByTestID(renderRoomActions(), 'room-view-header-threads')?.badge).toBeUndefined(); + }); + }); + + describe('call action', () => { + beforeEach(() => { + mockHasNativeHeaderBar = false; + jest.useFakeTimers(); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + const callAction = () => actionByTestID(renderRoomActions(), 'room-view-header-call'); + + it('is absent without media call permission and with calls disabled', () => { + expect(callAction()).toBeUndefined(); + }); + + it('is enabled with media call permission', () => { + mockMediaCall.hasMediaCallPermission = true; + + expect(callAction()).toMatchObject({ icon: 'phone', label: 'Call', disabled: false }); + }); + + it('is disabled during an active call', () => { + mockMediaCall.hasMediaCallPermission = true; + mockMediaCall.isInActiveCall = true; + + expect(callAction()?.disabled).toBe(true); + }); + + it('opens the media call sheet after the double tap window on a single tap', () => { + mockMediaCall.hasMediaCallPermission = true; + + callAction()?.onPress?.(); + + expect(mockMediaCall.openNewMediaCall).not.toHaveBeenCalled(); + act(() => jest.advanceTimersByTime(300)); + expect(mockMediaCall.openNewMediaCall).toHaveBeenCalled(); + expect(mockMediaCall.startCallImmediate).not.toHaveBeenCalled(); + }); + + it('starts the call immediately on a double tap', () => { + mockMediaCall.hasMediaCallPermission = true; + const onPress = callAction()?.onPress; + + onPress?.(); + onPress?.(); + + expect(mockMediaCall.startCallImmediate).toHaveBeenCalled(); + act(() => jest.advanceTimersByTime(300)); + expect(mockMediaCall.openNewMediaCall).not.toHaveBeenCalled(); + }); + + it('shows the video conference sheet when calls are enabled without media call permission', () => { + mockVideoConf.callEnabled = true; + + callAction()?.onPress?.(); + + expect(mockVideoConf.showInitCallActionSheet).toHaveBeenCalled(); + }); + + it('is disabled when the video conference tooltip is disabled', () => { + mockVideoConf.callEnabled = true; + mockVideoConf.disabledTooltip = true; + + expect(callAction()?.disabled).toBe(true); + }); + + it('is disabled by the e2ee warning', () => { + mockVideoConf.callEnabled = true; + mockButtonsData = { ...mockButtonsData, hasE2EEWarning: true }; + + expect(callAction()?.disabled).toBe(true); + }); + }); + + describe('thread', () => { + it('offers to follow a thread that is not followed', () => { + const [follow] = renderThreadActions('tmid-1'); + + expect(follow).toMatchObject({ + label: 'Follow thread', + icon: 'notification-disabled', + testID: 'room-view-header-follow' + }); + follow.onPress?.(); + expect(logEvent).toHaveBeenCalledWith(events.ROOM_TOGGLE_FOLLOW_THREADS); + expect(toggleFollowThread).toHaveBeenCalledWith('tmid-1', false); + }); + + it('offers to unfollow a followed thread', () => { + mockFollowersByThread = { 'tmid-1': ['user-1'] }; + + const [unfollow] = renderThreadActions('tmid-1'); + + expect(unfollow).toMatchObject({ label: 'Unfollow thread', icon: 'notification', testID: 'room-view-header-unfollow' }); + unfollow.onPress?.(); + expect(toggleFollowThread).toHaveBeenCalledWith('tmid-1', true); + }); + + it('reflects the follow state of the displayed thread and current user', () => { + mockFollowersByThread = { 'tmid-1': ['user-1'] }; + expect(renderThreadActions('tmid-1')[0].testID).toBe('room-view-header-unfollow'); + expect(renderThreadActions('tmid-2')[0].testID).toBe('room-view-header-follow'); + + mockUserId = 'user-2'; + expect(renderThreadActions('tmid-1')[0].testID).toBe('room-view-header-follow'); + }); + }); + + describe('omnichannel', () => { + beforeEach(() => { + mockRoomState = { ...mockRoomState, room: { id: 'sub-1', t: 'l', departmentId: 'department-1' } }; + }); + + const menuOf = () => { + const actions = renderOmnichannelActions(); + expect(actions).toHaveLength(1); + return actions[0].menu ?? []; + }; + + it('shows a single more menu that logs when opened', () => { + const [more] = renderOmnichannelActions(); + + expect(more).toMatchObject({ label: 'More', icon: 'kebab', testID: 'room-view-header-omnichannel-kebab' }); + more.onPress?.(); + expect(logEvent).toHaveBeenCalledWith(events.ROOM_SHOW_MORE_ACTIONS); + }); + + it('offers only the destructive close option when no capability is granted', () => { + const menu = menuOf(); + + expect(labelsOf(menu)).toEqual(['Close']); + expect(menu[0]).toMatchObject({ icon: 'chat-close', destructive: true }); + }); + + it('offers every option in order when all capabilities are granted', () => { + mockCanPlaceLivechatOnHold = true; + mockCanReturnQueue = true; + mockRoomState = { ...mockRoomState, canForwardGuest: true }; + + const menu = menuOf(); + + expect(labelsOf(menu)).toEqual(['Place chat on hold', 'Forward chat', 'Return to waiting line', 'Close']); + expect(menu.map(item => item.icon)).toEqual(['pause', 'chat-forward', 'move-to-the-queue', 'chat-close']); + }); + + it('places the chat on hold through the service', () => { + mockCanPlaceLivechatOnHold = true; + + menuOf()[0].onPress(); + + expect(placeLivechatOnHold).toHaveBeenCalledWith({ rid: 'rid-1', navigation: mockNavigation }); + }); + + it.each([ + [false, ['ForwardLivechatView', { rid: 'rid-1' }]], + [true, ['ModalStackNavigator', { screen: 'ForwardLivechatView', params: { rid: 'rid-1' } }]] + ])('navigates to the forward screen (master-detail: %s)', (isMasterDetail, expected) => { + mockIsMasterDetail = isMasterDetail; + mockRoomState = { ...mockRoomState, canForwardGuest: true }; + + menuOf()[0].onPress(); + + expect(mockNavigation.navigate).toHaveBeenCalledWith(...expected); + }); + + it('returns the inquiry only after the confirmation is accepted', async () => { + mockCanReturnQueue = true; + + menuOf()[0].onPress(); + + expect(returnLivechat).not.toHaveBeenCalled(); + await (showConfirmationAlert as jest.Mock).mock.calls[0][0].onPress(); + expect(returnLivechat).toHaveBeenCalledWith('rid-1', 'department-1'); + }); + + it('closes the chat with the room department and the request comment setting', () => { + mockLivechatRequestComment = true; + + menuOf()[0].onPress(); + + expect(closeLivechat).toHaveBeenCalledWith({ + rid: 'rid-1', + departmentId: 'department-1', + isMasterDetail: false, + livechatRequestComment: true, + navigation: mockNavigation + }); + }); + }); +}); diff --git a/app/views/RoomView/hooks/useHeader.tsx b/app/views/RoomView/hooks/useHeader.tsx deleted file mode 100644 index e11e5343274..00000000000 --- a/app/views/RoomView/hooks/useHeader.tsx +++ /dev/null @@ -1,109 +0,0 @@ -import { useLayoutEffect } from 'react'; -import { PixelRatio, View } from 'react-native'; -import { useNavigation } from '@react-navigation/native'; -import { useShallow } from 'zustand/react/shallow'; -import { useStore } from 'zustand'; - -import RoomHeader from '~/containers/RoomHeader'; -import { getRoomTitle, isGroupChat } from '~/lib/methods/helpers'; -import { isInviteSubscription } from '~/lib/methods/isInviteSubscription'; -import { type IOmnichannelSource, type ISubscription, type IVisitor } from '~/definitions'; -import LeftButtons from '../components/LeftButtons'; -import RightButtons from '../components/RightButtons/RightButtons'; -import { type IRoomViewProps } from '../definitions'; -import { type RoomStore } from '../definitions'; -import { fromSubscription } from '../stores/RoomStoreContext'; -import { useGoRoomActionsView } from './useGoRoomActionsView'; - -interface IUseHeaderParams { - rid?: string; - tmid?: string; - /** Thread name on a thread; only read when `tmid` is set, since the room title is derived from the room. */ - name?: string; - roomStore: RoomStore; -} - -interface IHeaderFields { - prid?: string; - title: string; - parentTitle: string; - teamMain: boolean; - subtitle?: string; - type: string; - visitor?: IVisitor; - isGroupChat: boolean; - sourceType?: IOmnichannelSource; - abacAttributes: ISubscription['abacAttributes']; - disabled: boolean; -} - -// rid/tmid/name come from the screen's mount-time snapshot: route.params can be wiped to undefined -// while this RoomView is retained below the stack top, which would break the header permanently. -export const useHeader = ({ rid, tmid, name: roomName, roomStore }: IUseHeaderParams): void => { - const navigation = useNavigation(); - - const headerFields = useStore( - roomStore, - useShallow((s): IHeaderFields => { - const room = s.room; - const title = tmid ? (roomName ?? '') : getRoomTitle(room); - const parentTitle = tmid ? getRoomTitle(room) : ''; - - return { - prid: room?.prid, - title, - teamMain: fromSubscription(r => !!r.teamMain, false)(s), - parentTitle, - subtitle: fromSubscription(r => r.topic, undefined)(s), - type: room?.t, - visitor: fromSubscription(r => r.visitor, undefined)(s), - isGroupChat: fromSubscription(r => isGroupChat(r), false)(s), - sourceType: fromSubscription(r => r.source, undefined)(s), - abacAttributes: fromSubscription(r => r.abacAttributes, undefined)(s), - disabled: fromSubscription(r => isInviteSubscription(r), false)(s) - }; - }) - ); - const roomUserId = useStore(roomStore, s => s.roomUserId); - const goRoomActionsView = useGoRoomActionsView(roomStore); - - useLayoutEffect(() => { - if (!rid) { - const height = 37 * PixelRatio.getFontScale(); - navigation.setOptions({ headerLeft: () => }); - return; - } - navigation.setOptions({ - headerLeft: () => , - headerRight: () => - }); - }, [rid, tmid, navigation, roomStore]); - - useLayoutEffect(() => { - if (!rid) { - return; - } - - navigation.setOptions({ - headerTitle: () => ( - - ) - }); - }, [rid, tmid, headerFields, roomUserId, navigation, goRoomActionsView]); -}; diff --git a/app/views/RoomView/hooks/useHeaderFields.ts b/app/views/RoomView/hooks/useHeaderFields.ts new file mode 100644 index 00000000000..03db365fbfe --- /dev/null +++ b/app/views/RoomView/hooks/useHeaderFields.ts @@ -0,0 +1,46 @@ +import { useShallow } from 'zustand/react/shallow'; +import { useStore } from 'zustand'; + +import { getRoomTitle, isGroupChat } from '~/lib/methods/helpers'; +import { isInviteSubscription } from '~/lib/methods/isInviteSubscription'; +import { type IOmnichannelSource, type ISubscription, type IVisitor } from '~/definitions'; +import { type RoomStore } from '../definitions'; +import { fromSubscription } from '../stores/RoomStoreContext'; + +export interface IHeaderFields { + prid?: string; + title: string; + parentTitle: string; + teamMain: boolean; + subtitle?: string; + type: string; + visitor?: IVisitor; + isGroupChat: boolean; + sourceType?: IOmnichannelSource; + abacAttributes: ISubscription['abacAttributes']; + disabled: boolean; +} + +export const useHeaderFields = (roomStore: RoomStore, tmid?: string, threadName?: string): IHeaderFields => + useStore( + roomStore, + useShallow((s): IHeaderFields => { + const room = s.room; + const title = tmid ? (threadName ?? '') : getRoomTitle(room); + const parentTitle = tmid ? getRoomTitle(room) : ''; + + return { + prid: room?.prid, + title, + teamMain: fromSubscription(r => !!r.teamMain, false)(s), + parentTitle, + subtitle: fromSubscription(r => r.topic, undefined)(s), + type: room?.t, + visitor: fromSubscription(r => r.visitor, undefined)(s), + isGroupChat: fromSubscription(r => isGroupChat(r), false)(s), + sourceType: fromSubscription(r => r.source, undefined)(s), + abacAttributes: fromSubscription(r => r.abacAttributes, undefined)(s), + disabled: fromSubscription(r => isInviteSubscription(r), false)(s) + }; + }) + ); diff --git a/app/views/RoomView/hooks/useHeaderImage.ts b/app/views/RoomView/hooks/useHeaderImage.ts new file mode 100644 index 00000000000..c8bf45f5b38 --- /dev/null +++ b/app/views/RoomView/hooks/useHeaderImage.ts @@ -0,0 +1,55 @@ +import { useEffect, useState } from 'react'; +import { Image, type ImageSourcePropType } from 'react-native'; + +import { IconSet, type TIconsName } from '~/containers/CustomIcon'; +import log from '~/lib/methods/helpers/log'; + +const iconImageCache = new Map(); + +export const useHeaderIconImage = (name: TIconsName | undefined, color: string, size: number) => { + const [image, setImage] = useState<{ name: TIconsName; color: string; source: ImageSourcePropType }>(); + const cacheKey = `${name}:${color}:${size}`; + const cachedSource = name ? iconImageCache.get(cacheKey) : undefined; + useEffect(() => { + let cancelled = false; + if (name && !iconImageCache.has(cacheKey)) { + IconSet.getImageSource(name, size, color) + .then(source => { + if (source) { + iconImageCache.set(cacheKey, source); + } + if (!cancelled && source) { + setImage({ name, color, source }); + } + }) + .catch(log); + } + return () => { + cancelled = true; + }; + }, [name, color, size, cacheKey]); + if (cachedSource) { + return cachedSource; + } + return image?.name === name && image?.color === color ? image?.source : undefined; +}; + +export const useHeaderRemoteImage = (uri?: string) => { + const [image, setImage] = useState<{ uri: string; width: number; height: number }>(); + useEffect(() => { + let cancelled = false; + if (uri) { + Image.getSize(uri) + .then(({ width, height }) => { + if (!cancelled) { + setImage({ uri, width, height }); + } + }) + .catch(log); + } + return () => { + cancelled = true; + }; + }, [uri]); + return image?.uri === uri ? image : undefined; +}; diff --git a/app/views/RoomView/hooks/useJsRoomHeader.tsx b/app/views/RoomView/hooks/useJsRoomHeader.tsx new file mode 100644 index 00000000000..0536475f009 --- /dev/null +++ b/app/views/RoomView/hooks/useJsRoomHeader.tsx @@ -0,0 +1,66 @@ +import { useLayoutEffect } from 'react'; +import { PixelRatio, View } from 'react-native'; +import { useNavigation } from '@react-navigation/native'; +import { useStore } from 'zustand'; + +import RoomHeader from '~/containers/RoomHeader'; +import LeftButtons from '../components/LeftButtons'; +import RightButtons from '../components/RightButtons/RightButtons'; +import { type IRoomViewProps, type RoomStore } from '../definitions'; +import { useGoRoomActionsView } from './useGoRoomActionsView'; +import { useHeaderFields } from './useHeaderFields'; + +interface IUseJsRoomHeaderParams { + rid?: string; + tmid?: string; + name?: string; + roomStore: RoomStore; +} + +export const useJsRoomHeader = ({ rid, tmid, name: threadName, roomStore }: IUseJsRoomHeaderParams): void => { + const navigation = useNavigation(); + const headerFields = useHeaderFields(roomStore, tmid, threadName); + const roomUserId = useStore(roomStore, s => s.roomUserId); + const goRoomActionsView = useGoRoomActionsView(roomStore); + + useLayoutEffect(() => { + if (!rid) { + const height = 37 * PixelRatio.getFontScale(); + navigation.setOptions({ headerLeft: () => }); + return; + } + + navigation.setOptions({ + headerLeft: () => , + headerRight: () => + }); + }, [rid, tmid, navigation, roomStore]); + + useLayoutEffect(() => { + if (!rid) { + return; + } + + navigation.setOptions({ + headerTitle: () => ( + + ) + }); + }, [rid, tmid, headerFields, roomUserId, navigation, goRoomActionsView]); +}; diff --git a/app/views/RoomView/hooks/useNativeBackButton.ts b/app/views/RoomView/hooks/useNativeBackButton.ts new file mode 100644 index 00000000000..5ea53b13840 --- /dev/null +++ b/app/views/RoomView/hooks/useNativeBackButton.ts @@ -0,0 +1,38 @@ +import { useLayoutEffect } from 'react'; +import { useNavigation } from '@react-navigation/native'; +import { type NativeStackHeaderItem } from '@react-navigation/native-stack'; + +import i18n from '~/i18n'; +import { useTheme } from '~/theme'; +import { type IRoomViewProps } from '../definitions'; +import { useUnreadsCount } from './useUnreadsCount'; + +const formatUnreadsCount = (unreadsCount: number | null) => { + if (!unreadsCount) { + return ''; + } + return unreadsCount > 99 ? '+99' : unreadsCount.toString(); +}; + +export const useNativeBackButton = (rid: string) => { + const navigation = useNavigation(); + const { colors } = useTheme(); + const unreadsLabel = formatUnreadsCount(useUnreadsCount(rid)); + + useLayoutEffect(() => { + if (!navigation.canGoBack()) { + navigation.setOptions({ unstable_headerLeftItems: undefined }); + return; + } + const backItem: NativeStackHeaderItem = { + type: 'button', + label: unreadsLabel, + icon: { type: 'sfSymbol', name: 'chevron.backward' }, + showsLabelWithIcon: true, + tintColor: colors.fontDefault, + accessibilityLabel: unreadsLabel ? `${i18n.t('Back')}, ${unreadsLabel} ${i18n.t('Unread')}` : i18n.t('Back'), + onPress: () => navigation.goBack() + }; + navigation.setOptions({ headerBackVisible: false, unstable_headerLeftItems: () => [backItem] }); + }, [colors.fontDefault, navigation, unreadsLabel]); +}; diff --git a/app/views/RoomView/hooks/useNativeRoomHeader.ts b/app/views/RoomView/hooks/useNativeRoomHeader.ts new file mode 100644 index 00000000000..675bce48bb1 --- /dev/null +++ b/app/views/RoomView/hooks/useNativeRoomHeader.ts @@ -0,0 +1,157 @@ +import { useEffect, useLayoutEffect } from 'react'; +import { shallowEqual } from 'react-redux'; +import { useNavigation } from '@react-navigation/native'; + +import { type TIconsName } from '~/containers/CustomIcon'; +import { getStatusIconName } from '~/containers/Status/getStatusIconName'; +import { + getOmnichannelIconName, + getOmnichannelSidebarIconUri, + getRoomTypeIconName +} from '~/containers/RoomTypeIcon/roomTypeIconName'; +import { STATUS_I18N_KEYS, type TUserStatus } from '~/definitions'; +import { type IActiveUser } from '~/reducers/activeUsers'; +import I18n from '~/i18n'; +import { useAppSelector } from '~/lib/hooks/useAppSelector'; +import usePreviewFormatText from '~/lib/hooks/usePreviewFormatText'; +import { useUserStatusColor } from '~/lib/hooks/useUserStatusColor'; +import { getUserPresence } from '~/lib/methods/getUsersPresence'; +import { formatStatusExpiry } from '~/lib/methods/helpers/formatStatusExpiry'; +import { useTheme } from '~/theme'; +import { type IRoomViewProps } from '../definitions'; +import { type IHeaderFields } from './useHeaderFields'; +import { useHeaderIconImage, useHeaderRemoteImage } from './useHeaderImage'; + +const TITLE_FONT_SIZE = 17; +const SUBTITLE_FONT_SIZE = 12; + +const getRoomIcon = (fields: IHeaderFields, isDirectMessage: boolean, status: TUserStatus): TIconsName => { + if (isDirectMessage) { + return getStatusIconName(status); + } + if (fields.type === 'l' && !fields.prid) { + return getOmnichannelIconName(fields.sourceType); + } + return getRoomTypeIconName({ + type: fields.type, + teamMain: fields.teamMain, + isDiscussion: !!fields.prid, + isGroupChat: fields.isGroupChat, + abacAttributes: fields.abacAttributes + }); +}; + +const getSubtitle = ({ + fields, + tmid, + usersTyping, + connected, + connecting, + activeUser +}: { + fields: IHeaderFields; + tmid?: string; + usersTyping: string[]; + connected: boolean; + connecting: boolean; + activeUser?: IActiveUser; +}) => { + if (tmid) { + return fields.parentTitle; + } + if (usersTyping.length) { + const names = usersTyping.join(usersTyping.length === 2 ? ` ${I18n.t('and')} ` : ', '); + return `${names} ${I18n.t(usersTyping.length > 1 ? 'are_typing' : 'is_typing')}...`; + } + if (connecting) { + return I18n.t('Connecting'); + } + if (!connected) { + return I18n.t('Waiting_for_network'); + } + if (fields.type === 'd') { + if (!activeUser) { + return undefined; + } + const presenceKey = STATUS_I18N_KEYS[activeUser.status]; + return activeUser.statusText || (presenceKey ? I18n.t(presenceKey) : undefined); + } + return fields.subtitle; +}; + +const useRoomHeaderPresence = (fields: IHeaderFields, roomUserId?: string | null) => { + const connected = useAppSelector(state => state.meteor.connected); + const presenceDisabled = useAppSelector(state => state.settings.Presence_broadcast_disabled); + const activeUser = useAppSelector(state => (roomUserId ? state.activeUsers[roomUserId] : undefined), shallowEqual); + const isDirectMessage = fields.type === 'd' && !fields.isGroupChat && !!roomUserId && !fields.prid; + const status: TUserStatus = presenceDisabled ? 'disabled' : !connected ? 'offline' : (activeUser?.status ?? 'loading'); + const visitorStatus = connected ? fields.visitor?.status : undefined; + const statusColor = useUserStatusColor(fields.type === 'l' ? (visitorStatus ?? 'offline') : status); + + useEffect(() => { + if (isDirectMessage && connected && status === 'loading' && roomUserId) { + getUserPresence(roomUserId); + } + }, [isDirectMessage, connected, status, roomUserId]); + + return { connected, activeUser, isDirectMessage, status, statusColor }; +}; + +const useRoomHeaderContent = ( + fields: IHeaderFields, + tmid: string | undefined, + activeUser: IActiveUser | undefined, + connected: boolean +) => { + const connecting = useAppSelector(state => state.meteor.connecting || state.server.loading); + const usersTyping = useAppSelector(state => state.usersTyping, shallowEqual); + const typing = !tmid && usersTyping.length > 0; + const subtitle = getSubtitle({ fields, tmid, usersTyping, connected, connecting, activeUser }); + const plainTitle = usePreviewFormatText(fields.title ?? ''); + const formattedSubtitle = usePreviewFormatText(subtitle ?? ''); + const hasStatusExpiry = fields.type === 'd' && connected && !!formatStatusExpiry(activeUser?.statusExpiresAt); + return { + title: tmid || fields.prid ? plainTitle : fields.title, + subtitle: tmid || typing ? subtitle : formattedSubtitle, + showClock: !tmid && !typing && !!subtitle && hasStatusExpiry + }; +}; + +export const useNativeRoomHeader = ( + fields: IHeaderFields, + tmid?: string, + roomUserId?: string | null, + onTitlePress?: () => void +) => { + const navigation = useNavigation(); + const { colors } = useTheme(); + const { connected, activeUser, isDirectMessage, status, statusColor } = useRoomHeaderPresence(fields, roomUserId); + + const { title, subtitle, showClock } = useRoomHeaderContent(fields, tmid, activeUser, connected); + + const roomIcon = getRoomIcon(fields, isDirectMessage, status); + const glyphImage = useHeaderIconImage( + fields.type ? roomIcon : undefined, + isDirectMessage || fields.type === 'l' ? statusColor : colors.fontTitlesLabels, + TITLE_FONT_SIZE + ); + const server = useAppSelector(state => state.server.server); + const remoteUri = connected && fields.type === 'l' ? getOmnichannelSidebarIconUri(server, fields.sourceType) : undefined; + const remoteImage = useHeaderRemoteImage(remoteUri); + const roomImage = remoteImage ?? glyphImage; + const clockImage = useHeaderIconImage(showClock ? 'clock' : undefined, colors.fontSecondaryInfo, SUBTITLE_FONT_SIZE); + const subtitleImage = tmid ? roomImage : clockImage; + + useLayoutEffect(() => { + navigation.setOptions({ + headerTitle: title, + headerSubtitle: subtitle || undefined, + headerTitleImageSource: tmid ? undefined : roomImage, + headerSubtitleImageSource: subtitleImage, + headerTitleStyle: { color: colors.fontTitlesLabels }, + headerSubtitleColor: colors.fontSecondaryInfo, + headerTitleTestID: 'room-header', + onHeaderTitlePress: onTitlePress && !fields.disabled ? () => onTitlePress() : undefined + }); + }, [navigation, title, subtitle, tmid, roomImage, subtitleImage, colors, onTitlePress, fields.disabled]); +}; diff --git a/app/views/RoomView/hooks/useRoomHeaderActions.ts b/app/views/RoomView/hooks/useRoomHeaderActions.ts new file mode 100644 index 00000000000..f7551a6dde0 --- /dev/null +++ b/app/views/RoomView/hooks/useRoomHeaderActions.ts @@ -0,0 +1,219 @@ +import { useNavigation } from '@react-navigation/native'; +import { useStore } from 'zustand'; +import { useShallow } from 'zustand/react/shallow'; + +import i18n from '~/i18n'; +import { useAppSelector } from '~/lib/hooks/useAppSelector'; +import { useMasterDetail } from '~/lib/hooks/useMasterDetail'; +import { useCanReturnQueue } from '~/ee/omnichannel/hooks/useCanReturnQueue'; +import { useSetting } from '~/lib/hooks/useSetting'; +import { hasNativeHeaderBar, showConfirmationAlert, showErrorAlert } from '~/lib/methods/helpers'; +import { events, logEvent } from '~/lib/methods/helpers/log'; +import { type IHeaderAction, type IHeaderMenuItem } from '~/lib/methods/helpers/navigation/headerActions'; +import { toggleFollowThread } from '~/lib/methods/toggleFollowThread'; +import { returnLivechat } from '~/lib/services/restApi'; +import { getUserSelector } from '~/selectors/login'; +import { useTheme } from '~/theme'; +import { getUnreadStyle } from '~/containers/UnreadBadge/getUnreadStyle'; +import { type RoomStore } from '../definitions'; +import { fromSubscription } from '../stores/RoomStoreContext'; +import { closeLivechat } from '../services/closeLivechat'; +import { placeLivechatOnHold } from '../services/placeLivechatOnHold'; +import { navigateToScreen, type TRoomStackNavigation } from '../services/navigateToScreen'; +import { getRoomHeaderMode, type TRoomHeaderMode } from '../helpers/getRoomHeaderMode'; +import { splitRoomHeaderActions, type TRoomHeaderActionKey } from '../helpers/roomHeaderActions'; +import { useCanPlaceLivechatOnHold } from './useCanPlaceLivechatOnHold'; +import { useThreadFollowing } from './useThreadFollowing'; +import { useRoomRightButtonsData } from '../components/RightButtons/useRoomRightButtonsData'; +import { useHeaderCallPress } from '../components/RightButtons/useHeaderCallPress'; + +export const EMPTY_ACTIONS: IHeaderAction[] = []; +const VISIBLE_ORDER: TRoomHeaderActionKey[] = ['encryption', 'notifications', 'call', 'threads']; + +export const useOmnichannelActions = (rid: string, roomStore: RoomStore): IHeaderAction[] => { + const navigation = useNavigation(); + const isMasterDetail = useMasterDetail(); + const livechatRequestComment = useSetting('Livechat_request_comment_when_closing_conversation') as boolean; + + const departmentId = useStore( + roomStore, + fromSubscription(room => room.departmentId, undefined) + ); + const canForwardGuest = useStore(roomStore, s => s.canForwardGuest); + const canReturnQueue = useCanReturnQueue(); + const canPlaceLivechatOnHold = useCanPlaceLivechatOnHold(roomStore); + + const returnInquiry = () => { + showConfirmationAlert({ + message: i18n.t('Would_you_like_to_return_the_inquiry'), + confirmationText: i18n.t('Yes'), + onPress: async () => { + try { + await returnLivechat(rid, departmentId); + } catch (e: any) { + showErrorAlert(e.reason, i18n.t('Oops')); + } + } + }); + }; + + const menu: IHeaderMenuItem[] = [ + ...(canPlaceLivechatOnHold + ? [{ label: i18n.t('Place_chat_on_hold'), icon: 'pause' as const, onPress: () => placeLivechatOnHold({ rid, navigation }) }] + : []), + ...(canForwardGuest + ? [ + { + label: i18n.t('Forward_Chat'), + icon: 'chat-forward' as const, + onPress: () => navigateToScreen({ navigation, isMasterDetail, screen: 'ForwardLivechatView', params: { rid } }) + } + ] + : []), + ...(canReturnQueue + ? [{ label: i18n.t('Return_to_waiting_line'), icon: 'move-to-the-queue' as const, onPress: returnInquiry }] + : []), + { + label: i18n.t('Close'), + icon: 'chat-close', + destructive: true, + onPress: () => closeLivechat({ rid, departmentId, isMasterDetail, livechatRequestComment, navigation }) + } + ]; + + return [ + { + label: i18n.t('More'), + icon: 'kebab', + testID: 'room-view-header-omnichannel-kebab', + onPress: () => logEvent(events.ROOM_SHOW_MORE_ACTIONS), + menu + } + ]; +}; + +export const useThreadActions = (tmid: string): IHeaderAction[] => { + const userId = useAppSelector(state => getUserSelector(state).id); + const isFollowingThread = useThreadFollowing(tmid, userId); + + return [ + { + label: i18n.t(isFollowingThread ? 'Unfollow_thread' : 'Follow_thread'), + icon: isFollowingThread ? 'notification' : 'notification-disabled', + testID: isFollowingThread ? 'room-view-header-unfollow' : 'room-view-header-follow', + onPress: () => { + logEvent(events.ROOM_TOGGLE_FOLLOW_THREADS); + toggleFollowThread(tmid, isFollowingThread); + } + } + ]; +}; + +export const useRoomActions = (rid: string, roomStore: RoomStore): IHeaderAction[] => { + const { theme, colors } = useTheme(); + const { + threadsEnabled, + issuesWithNotifications, + disableNotifications, + hasE2EEWarning, + canToggleEncryption, + isSelfDm, + tunread, + tunreadUser, + tunreadGroup, + callAccessibilityLabel, + goThreadsView, + navigateToNotificationOrPushTroubleshoot, + goSearchView, + goE2EEToggleRoomView, + threadsAccessibilityLabel + } = useRoomRightButtonsData(rid, roomStore); + const { callPresent, isCallDisabled, onPressCall } = useHeaderCallPress(rid); + + const present: Partial> = { + threads: threadsEnabled, + call: !isSelfDm && callPresent, + encryption: hasE2EEWarning, + notifications: issuesWithNotifications || disableNotifications + }; + + const actions: Record = { + threads: { + label: threadsAccessibilityLabel, + icon: 'threads', + testID: 'room-view-header-threads', + disabled: hasE2EEWarning, + badge: tunread.length + ? { + value: tunread.length, + color: getUnreadStyle({ tunread, tunreadUser, tunreadGroup, theme }).backgroundColor as string + } + : undefined, + onPress: goThreadsView + }, + call: { + label: callAccessibilityLabel, + icon: 'phone', + testID: 'room-view-header-call', + disabled: hasE2EEWarning || isCallDisabled, + onPress: onPressCall + }, + encryption: { + label: i18n.t('Encrypted'), + icon: 'encrypted', + testID: 'room-view-header-encryption', + disabled: !canToggleEncryption, + onPress: goE2EEToggleRoomView + }, + notifications: { + label: i18n.t('Troubleshooting'), + icon: 'notification-disabled', + testID: 'room-view-push-troubleshoot', + tintColor: issuesWithNotifications ? colors.fontDanger : undefined, + disabled: hasE2EEWarning, + onPress: navigateToNotificationOrPushTroubleshoot + } + }; + + const searchAction: IHeaderAction = { + label: i18n.t('Search_Messages'), + icon: 'search', + testID: 'room-view-search', + disabled: hasE2EEWarning, + onPress: goSearchView + }; + + if (!hasNativeHeaderBar) { + return [...VISIBLE_ORDER.filter(key => present[key]).map(key => actions[key]), searchAction]; + } + + const { visibleKeys, overflowKeys } = splitRoomHeaderActions(present); + const toMenuItem = ({ label, icon, disabled, onPress }: IHeaderAction): IHeaderMenuItem => ({ + label, + icon, + disabled, + onPress: onPress ?? (() => {}) + }); + + return [ + ...VISIBLE_ORDER.filter(key => visibleKeys.includes(key)).map(key => actions[key]), + { + label: i18n.t('More'), + icon: 'kebab', + menu: [...overflowKeys.map(key => toMenuItem(actions[key])), toMenuItem(searchAction)] + } + ]; +}; + +export const useRoomHeaderMode = (rid: string | undefined, tmid: string | undefined, roomStore: RoomStore): TRoomHeaderMode => { + const { t, status, membership } = useStore( + roomStore, + useShallow(s => ({ + t: s.room.t, + status: fromSubscription(r => r.status, undefined)(s), + membership: s.membership + })) + ); + + return getRoomHeaderMode({ rid, tmid, t, status, membership }); +}; diff --git a/app/views/RoomView/index.tsx b/app/views/RoomView/index.tsx index 314215f61c6..34326d8831e 100644 --- a/app/views/RoomView/index.tsx +++ b/app/views/RoomView/index.tsx @@ -1,23 +1,11 @@ import { useEffect, useState } from 'react'; -import { useStore } from 'zustand'; -import { useShallow } from 'zustand/react/shallow'; -import { getRoomTitle } from '~/lib/methods/helpers'; -import { isInviteSubscription } from '~/lib/methods/isInviteSubscription'; -import { getInvitationActions, getInvitationText } from '~/lib/methods/getInvitationData'; -import { type IInviteSubscription } from '~/definitions'; -import { isSubscriptionModel } from '~/definitions/TRoom'; import { type IRoomViewProps } from './definitions'; -import { EncryptedRoom } from './components/EncryptedRoom'; -import { InvitedRoomScreen } from './components/InvitedRoomScreen'; -import { MissingRoomE2EEKey } from './components/MissingRoomE2EEKey'; -import { RoomBackground } from './components/RoomBackground'; -import RoomScreen from './RoomScreen'; +import { RoomViewContent } from './components/RoomViewContent/RoomViewContent'; +import { RoomViewHeader } from './components/RoomViewHeader/RoomViewHeader'; import { parseRoomRoute } from './services/parseRoomRoute'; import { createRoomStore, observeRoom } from './stores/RoomStore'; import { type RoomStore } from './definitions'; -import { useE2EEStatus } from './hooks/useE2EEStatus'; -import { useHeader } from './hooks/useHeader'; const RoomView = ({ route, navigation }: IRoomViewProps) => { const [input] = useState(() => parseRoomRoute(route.params)); @@ -26,44 +14,13 @@ const RoomView = ({ route, navigation }: IRoomViewProps) => { const [roomStore] = useState(() => createRoomStore({ rid, initialRoom, roomUserId })); const [ready, setReady] = useState(false); useEffect(() => observeRoom(rid, roomStore, () => setReady(true)), [rid, roomStore]); - const isEncryptable = useStore(roomStore, s => isSubscriptionModel(s.room)); - const invitation = useStore( - roomStore, - useShallow(s => (isSubscriptionModel(s.room) && isInviteSubscription(s.room) ? getInvitationText(s.room) : null)) - ); - const roomTitle = useStore(roomStore, s => getRoomTitle(s.room)); - - const { showMissingE2EEKey, showE2EEDisabledRoom } = useE2EEStatus(roomStore); - - useHeader({ rid, tmid, name, roomStore }); - - if (!rid || !t) { - return ; - } - if (invitation) { - return ( - getInvitationActions(roomStore.getState().room as IInviteSubscription).accept()} - onReject={() => getInvitationActions(roomStore.getState().room as IInviteSubscription).reject()} - /> - ); - } - - if (isEncryptable) { - if (showMissingE2EEKey) { - return ; - } - - if (showE2EEDisabledRoom) { - return ; - } - } - - return ; + return ( + <> + + + + ); }; export default RoomView; diff --git a/app/views/RoomsListView/components/Header.tsx b/app/views/RoomsListView/components/Header.tsx index f5eed0fb3b6..c50a8ad75a2 100644 --- a/app/views/RoomsListView/components/Header.tsx +++ b/app/views/RoomsListView/components/Header.tsx @@ -4,11 +4,11 @@ import { Touchable } from 'react-native-gesture-handler'; import { showActionSheetRef } from '~/containers/ActionSheet'; import SearchHeader from '~/containers/SearchHeader'; -import I18n from '~/i18n'; import { useAppSelector } from '~/lib/hooks/useAppSelector'; import { useTheme } from '~/theme'; import sharedStyles from '~/views/Styles'; import ServersList from './ServersList'; +import { useRoomsListSubtitle } from '../hooks/useRoomsListSubtitle'; const styles = StyleSheet.create({ container: { @@ -32,13 +32,8 @@ const styles = StyleSheet.create({ // search and searchEnabled need to be props because Header is used on react-navigation, which does not support context const RoomsListHeaderView = ({ search, searchEnabled }: { search: (text: string) => void; searchEnabled: boolean }) => { - const connecting = useAppSelector(state => state.meteor.connecting || state.server.loading); - const connected = useAppSelector(state => state.meteor.connected); - const isLoggingIn = useAppSelector(state => state.login.isFetching); - const isFetching = useAppSelector(state => state.rooms.isFetching); const serverName = useAppSelector(state => state.settings.Site_Name as string); - const server = useAppSelector(state => state.server.server); - const { status: supportedVersionsStatus } = useAppSelector(state => state.supportedVersions); + const subtitle = useRoomsListSubtitle(); const { colors } = useTheme(); const { fontScale } = useWindowDimensions(); @@ -51,18 +46,6 @@ const RoomsListHeaderView = ({ search, searchEnabled }: { search: (text: string) const height = 37 * fontScale; return ; } - let subtitle; - if (supportedVersionsStatus === 'expired') { - subtitle = 'Cannot connect'; - } else if (connecting || isLoggingIn) { - subtitle = I18n.t('Connecting'); - } else if (isFetching) { - subtitle = I18n.t('Updating'); - } else if (!connected) { - subtitle = I18n.t('Waiting_for_network'); - } else { - subtitle = server?.replace(/(^\w+:|^)\/\//, ''); - } return ( { + const { colors } = useTheme(); + const { height } = useWindowDimensions(); + + if (!hasNativeHeaderBar) { + return null; + } + + return ; +}; + +export default LargeTitleBackdrop; diff --git a/app/views/RoomsListView/components/ListHeader.tsx b/app/views/RoomsListView/components/ListHeader.tsx index d03ae4d1ff8..a00ce033940 100644 --- a/app/views/RoomsListView/components/ListHeader.tsx +++ b/app/views/RoomsListView/components/ListHeader.tsx @@ -10,6 +10,7 @@ import { useMasterDetail } from '~/lib/hooks/useMasterDetail'; import { events, logEvent } from '~/lib/methods/helpers/log'; import { useTheme } from '~/theme'; import { RoomsSearchContext } from '../contexts/RoomsSearchProvider'; +import LargeTitleBackdrop from './LargeTitleBackdrop'; export type TEncryptionBanner = 'REQUEST_PASSWORD' | 'SAVE_PASSWORD'; @@ -38,6 +39,7 @@ const ListHeader = () => { return ( <> + {encryptionBanner ? ( <> ({ + useNavigation: () => mockNavigation +})); + +let mockIsIOS = true; +let mockIsTablet = false; +jest.mock('~/lib/methods/helpers', () => + Object.defineProperties( + { ...jest.requireActual('~/lib/methods/helpers') }, + { + hasNativeHeaderBar: { get: () => mockIsIOS, configurable: true }, + isTablet: { get: () => mockIsTablet, configurable: true } + } + ) +); + +jest.mock('~/lib/methods/helpers/deviceInfo', () => + Object.defineProperties( + { ...jest.requireActual('~/lib/methods/helpers/deviceInfo') }, + { + hasNativeHeaderBar: { get: () => mockIsIOS, configurable: true }, + isTablet: { get: () => mockIsTablet, configurable: true } + } + ) +); + +jest.mock('~/lib/methods/helpers/navigation/headerIcon', () => ({ + headerIcon: (name: string) => ({ type: 'image', source: { uri: name } }) +})); + +let mockIsMasterDetail = false; +jest.mock('~/lib/hooks/useMasterDetail', () => ({ useMasterDetail: () => mockIsMasterDetail })); +jest.mock('~/lib/hooks/useIsAccessibilityNavigationEnabled', () => ({ useIsAccessibilityNavigationEnabled: () => false })); +jest.mock('~/lib/hooks/usePermissions', () => ({ usePermissions: () => [true, false, false, false, false] })); +jest.mock('~/selectors/login', () => ({ getUserSelector: () => ({ requirePasswordChange: false }) })); +const mockColors = { fontDanger: '#f00', buttonBackgroundDangerDefault: '#f00', userPresenceDisabled: '#f00' }; +jest.mock('~/theme', () => ({ useTheme: () => ({ colors: mockColors }) })); +const mockShowActionSheetRef = jest.fn(); +jest.mock('~/containers/ActionSheet', () => ({ showActionSheetRef: (...args: unknown[]) => mockShowActionSheetRef(...args) })); +jest.mock('../../components/Header', () => ({ __esModule: true, default: 'RoomsListHeaderView' })); +jest.mock('../../components/ServersList', () => ({ __esModule: true, default: 'ServersList' })); + +jest.mock('~/containers/Header/components/HeaderButton', () => { + const ReactActual = jest.requireActual('react'); + return { + Container: ({ children }: { children: unknown }) => ReactActual.createElement('Container', null, children), + Item: (props: Record) => ReactActual.createElement('Item', props), + Drawer: (props: Record) => ReactActual.createElement('Drawer', props), + More: (props: Record) => ReactActual.createElement('More', props), + BadgeWarn: () => null + }; +}); + +let mockAppState = { + supportedVersions: { status: 'supported' }, + troubleshootingNotification: { issuesWithNotifications: false }, + app: { notificationPresenceCap: false }, + meteor: { connecting: false, connected: true }, + server: { loading: false, server: 'https://open.rocket.chat' }, + login: { isFetching: false }, + rooms: { isFetching: false }, + settings: { Site_Name: 'Rocket.Chat' } +}; +jest.mock('~/lib/hooks/useAppSelector', () => ({ + useAppSelector: (selector: (state: typeof mockAppState) => unknown) => selector(mockAppState) +})); + +const mockStartSearch = jest.fn(); +const mockStopSearch = jest.fn(); +const mockSearch = jest.fn(); +const searchContextValue = { + searching: false, + searchEnabled: false, + searchResults: [], + startSearch: mockStartSearch, + stopSearch: mockStopSearch, + search: mockSearch +}; + +const renderUseHeader = () => + renderHook(() => useHeader(), { + wrapper: ({ children }: { children: ReactElement }) => ( + {children} + ) + }); + +describe('RoomsListView useHeader', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockIsIOS = true; + mockIsTablet = false; + mockIsMasterDetail = false; + mockAppState = { + supportedVersions: { status: 'supported' }, + troubleshootingNotification: { issuesWithNotifications: false }, + app: { notificationPresenceCap: false }, + meteor: { connecting: false, connected: true }, + server: { loading: false, server: 'https://open.rocket.chat' }, + login: { isFetching: false }, + rooms: { isFetching: false }, + settings: { Site_Name: 'Rocket.Chat' } + }; + }); + + it('sets the server name as a native string title with the server url as subtitle on iPhone', () => { + renderUseHeader(); + + const options = mockSetOptions.mock.calls[0][0]; + expect(typeof options.headerTitle).toBe('string'); + expect(options.headerTitle).toBe('Rocket.Chat'); + expect(options.headerSubtitle).toBe('open.rocket.chat'); + }); + + it.each([ + [{ supportedVersions: { status: 'expired' } }, 'Cannot connect'], + [{ meteor: { connecting: true, connected: true } }, 'Connecting...'], + [{ login: { isFetching: true } }, 'Connecting...'], + [{ rooms: { isFetching: true } }, 'Updating...'], + [{ meteor: { connecting: false, connected: false } }, 'Waiting for network...'], + [{ meteor: { connecting: false, connected: false }, rooms: { isFetching: true } }, 'Updating...'] + ])('swaps the native subtitle to the connection state %j', (stateOverride, expectedSubtitle) => { + mockAppState = { ...mockAppState, ...stateOverride } as typeof mockAppState; + + renderUseHeader(); + + const options = mockSetOptions.mock.calls[0][0]; + expect(options.headerTitle).toBe('Rocket.Chat'); + expect(options.headerSubtitle).toBe(expectedSubtitle); + }); + + it('builds the right cluster in push-troubleshoot, create, directory order', () => { + mockAppState = { ...mockAppState, troubleshootingNotification: { issuesWithNotifications: true } }; + + renderUseHeader(); + + const options = mockSetOptions.mock.calls[0][0]; + const rightItems = options.unstable_headerRightItems(); + const labels = rightItems.map((item: { accessibilityLabel: string }) => item.accessibilityLabel); + expect(labels).toEqual(['Troubleshooting', 'Create new channel, team, direct message or discussion', 'Directory']); + }); + + it('renders only buttons when push troubleshooting is absent', () => { + renderUseHeader(); + + const options = mockSetOptions.mock.calls[0][0]; + const rightItems = options.unstable_headerRightItems(); + const labels = rightItems.map((item: { accessibilityLabel: string }) => item.accessibilityLabel); + expect(labels).toEqual(['Create new channel, team, direct message or discussion', 'Directory']); + expect(rightItems.every((item: { type: string }) => item.type === 'button')).toBe(true); + }); + + it('exposes only the drawer as a native left item', () => { + renderUseHeader(); + + const options = mockSetOptions.mock.calls[0][0]; + const leftItems = options.unstable_headerLeftItems(); + expect(leftItems).toHaveLength(1); + expect(leftItems[0].icon).toEqual({ type: 'image', source: { uri: 'hamburguer' } }); + }); + + it('opens the server switcher when the header title is pressed', () => { + renderUseHeader(); + + const options = mockSetOptions.mock.calls[0][0]; + options.onHeaderTitlePress(); + expect(mockShowActionSheetRef).toHaveBeenCalledWith(expect.objectContaining({ enableContentPanningGesture: false })); + }); + + it('configures a system search bar instead of a right-cluster search item', () => { + renderUseHeader(); + + const options = mockSetOptions.mock.calls[0][0]; + const rightItems = options.unstable_headerRightItems(); + expect(rightItems.some((item: { accessibilityLabel: string }) => item.accessibilityLabel === 'Search')).toBe(false); + + expect(options.headerSearchBarOptions.placement).toBe('automatic'); + expect(options.headerSearchBarOptions.ref.current).toBeNull(); + + options.headerSearchBarOptions.onFocus(); + expect(mockStartSearch).toHaveBeenCalledTimes(1); + + options.headerSearchBarOptions.onChangeText({ nativeEvent: { text: 'general' } }); + expect(mockSearch).toHaveBeenCalledWith('general'); + + options.headerSearchBarOptions.onCancelButtonPress(); + expect(mockStopSearch).toHaveBeenCalledTimes(1); + }); + + it('clears the system search bar once search stops', () => { + let setSearchEnabled: (value: boolean) => void = () => {}; + const wrapper = ({ children }: { children: ReactElement }) => { + const [searchEnabled, setter] = useState(true); + setSearchEnabled = setter; + return ( + {children} + ); + }; + + renderHook(() => useHeader(), { wrapper }); + + const clearText = jest.fn(); + mockSetOptions.mock.calls[0][0].headerSearchBarOptions.ref.current = { clearText }; + + act(() => setSearchEnabled(false)); + + expect(clearText).toHaveBeenCalledTimes(1); + }); + + it('deactivates the system search bar once search stops on tablet', () => { + mockIsMasterDetail = true; + let setSearchEnabled: (value: boolean) => void = () => {}; + const wrapper = ({ children }: { children: ReactElement }) => { + const [searchEnabled, setter] = useState(true); + setSearchEnabled = setter; + return ( + {children} + ); + }; + + renderHook(() => useHeader(), { wrapper }); + + const cancelSearch = jest.fn(); + mockSetOptions.mock.calls[0][0].headerSearchBarOptions.ref.current = { cancelSearch }; + + act(() => setSearchEnabled(false)); + + expect(cancelSearch).toHaveBeenCalledTimes(1); + }); + + it('replaces the tablet right actions with a Cancel item that stops search while searching', () => { + mockIsTablet = true; + const wrapper = ({ children }: { children: ReactElement }) => ( + {children} + ); + + renderHook(() => useHeader(), { wrapper }); + + const options = mockSetOptions.mock.calls[0][0]; + expect(options.headerSearchBarOptions.hideNavigationBar).toBe(false); + const rightItems = options.unstable_headerRightItems(); + expect(rightItems.map((item: { label: string }) => item.label)).toEqual(['Cancel']); + + rightItems[0].onPress(); + expect(mockStopSearch).toHaveBeenCalledTimes(1); + }); + + it('keeps the iPhone right actions and hides the navigation bar while searching', () => { + const wrapper = ({ children }: { children: ReactElement }) => ( + {children} + ); + + renderHook(() => useHeader(), { wrapper }); + + const options = mockSetOptions.mock.calls[0][0]; + expect(options.headerSearchBarOptions.hideNavigationBar).toBe(true); + const labels = options.unstable_headerRightItems().map((item: { label: string }) => item.label); + expect(labels).not.toContain('Cancel'); + }); + + it('falls back to the JS header on Android', () => { + mockIsIOS = false; + + renderUseHeader(); + + const options = mockSetOptions.mock.calls[0][0]; + expect(options.headerLargeTitle).toBeUndefined(); + expect(typeof options.headerTitle).toBe('function'); + }); + + it('uses the native header on iPad', () => { + mockIsTablet = true; + + renderUseHeader(); + + const options = mockSetOptions.mock.calls[0][0]; + expect(options.headerTitle).toBe('Rocket.Chat'); + }); +}); diff --git a/app/views/RoomsListView/hooks/useHeader.tsx b/app/views/RoomsListView/hooks/useHeader.tsx index cef3f006245..7717e32170d 100644 --- a/app/views/RoomsListView/hooks/useHeader.tsx +++ b/app/views/RoomsListView/hooks/useHeader.tsx @@ -1,33 +1,102 @@ -import { useFocusEffect, useNavigation } from '@react-navigation/native'; -import { useCallback, useContext, useLayoutEffect, useRef, useState } from 'react'; +import { useNavigation } from '@react-navigation/native'; +import { useCallback, useContext, useEffect, useLayoutEffect, useRef, useState } from 'react'; import { InteractionManager } from 'react-native'; import { type KeyboardFocus } from 'react-native-external-keyboard'; +import { type SearchBarCommands } from 'react-native-screens'; +import { showActionSheetRef } from '~/containers/ActionSheet'; import * as HeaderButton from '~/containers/Header/components/HeaderButton'; import i18n from '~/i18n'; import { useAppSelector } from '~/lib/hooks/useAppSelector'; import { useIsAccessibilityNavigationEnabled } from '~/lib/hooks/useIsAccessibilityNavigationEnabled'; import { useMasterDetail } from '~/lib/hooks/useMasterDetail'; import { usePermissions } from '~/lib/hooks/usePermissions'; -import { isTablet } from '~/lib/methods/helpers'; +import { hasNativeHeaderBar, isTablet } from '~/lib/methods/helpers'; import { events, logEvent } from '~/lib/methods/helpers/log'; +import { headerLeftActions, headerRightActions, type IHeaderAction } from '~/lib/methods/helpers/navigation/headerActions'; import { getUserSelector } from '~/selectors/login'; import { useTheme } from '~/theme'; import RoomsListHeaderView from '../components/Header'; +import ServersList from '../components/ServersList'; import { RoomsSearchContext } from '../contexts/RoomsSearchProvider'; +import { useRoomsListSubtitle } from './useRoomsListSubtitle'; + +interface IRightActionsParams { + issuesWithNotifications?: boolean; + canCreateRoom: boolean; + disabled?: boolean; + dangerColor: string; + onTroubleshoot: () => void; + onCreate: () => void; + onSearch: () => void; + onDirectory: () => void; +} + +const getRightActions = ({ + issuesWithNotifications, + canCreateRoom, + disabled, + dangerColor, + onTroubleshoot, + onCreate, + onSearch, + onDirectory +}: IRightActionsParams): IHeaderAction[] => { + const troubleshoot: IHeaderAction = { + label: i18n.t('Troubleshooting'), + icon: 'notification-disabled', + tintColor: dangerColor, + testID: 'rooms-list-view-push-troubleshoot', + onPress: onTroubleshoot + }; + const create: IHeaderAction = { + label: i18n.t('Create_new_channel_team_dm_discussion'), + icon: hasNativeHeaderBar ? 'create' : 'add', + testID: 'rooms-list-view-create-channel', + disabled, + onPress: onCreate + }; + const search: IHeaderAction = { + label: i18n.t('Search'), + icon: 'search', + testID: 'rooms-list-view-search', + disabled, + legacyHeaderOnly: true, + onPress: onSearch + }; + const directory: IHeaderAction = { + label: i18n.t('Directory'), + icon: 'directory', + testID: 'rooms-list-view-directory', + disabled, + onPress: onDirectory + }; + return [...(issuesWithNotifications ? [troubleshoot] : []), ...(canCreateRoom ? [create] : []), search, directory]; +}; + +const getScreenFocusNavigation = (navigation: any, isMasterDetail: boolean) => { + if (!isMasterDetail) { + return navigation; + } + return navigation.getParent()?.getParent() ?? navigation; +}; export const useHeader = () => { const { searchEnabled, search, startSearch, stopSearch } = useContext(RoomsSearchContext); const [options, setOptions] = useState(null); const isAccessibilityNavigationEnabled = useIsAccessibilityNavigationEnabled(); const drawerButtonRef = useRef(null); + const searchBarRef = useRef(null); const supportedVersionsStatus = useAppSelector(state => state.supportedVersions.status); const requirePasswordChange = useAppSelector(state => getUserSelector(state).requirePasswordChange); const isMasterDetail = useMasterDetail(); const navigation = useNavigation(); const issuesWithNotifications = useAppSelector(state => state.troubleshootingNotification.issuesWithNotifications); const notificationPresenceCap = useAppSelector(state => state.app.notificationPresenceCap); + const serverName = useAppSelector(state => state.settings.Site_Name as string); const { colors } = useTheme(); + + const nativeHeaderSubtitle = useRoomsListSubtitle(); const [ createPublicChannelPermission, createPrivateChannelPermission, @@ -46,15 +115,12 @@ export const useHeader = () => { const disabled = supportedVersionsStatus === 'expired' || requirePasswordChange; - const getBadge = useCallback(() => { - if (supportedVersionsStatus === 'warn') { - return ; - } - if (notificationPresenceCap) { - return ; - } - return null; - }, [supportedVersionsStatus, notificationPresenceCap, colors]); + const badgeColor = + supportedVersionsStatus === 'warn' + ? colors.buttonBackgroundDangerDefault + : notificationPresenceCap + ? colors.userPresenceDisabled + : undefined; const goDirectory = useCallback(() => { logEvent(events.RL_GO_DIRECTORY); @@ -84,7 +150,11 @@ export const useHeader = () => { }, [isMasterDetail, navigation]); useLayoutEffect(() => { - if (searchEnabled) { + const onDrawerPress = isMasterDetail + ? () => navigation.navigate('ModalStackNavigator', { screen: 'SettingsView' }) + : () => navigation.toggleDrawer(); + + if (searchEnabled && !hasNativeHeaderBar) { const searchOptions = { headerLeft: () => ( @@ -101,57 +171,62 @@ export const useHeader = () => { return; } + const rightActions = getRightActions({ + issuesWithNotifications, + canCreateRoom, + disabled, + dangerColor: colors.fontDanger, + onTroubleshoot: navigateToPushTroubleshootView, + onCreate: goToNewMessage, + onSearch: startSearch, + onDirectory: goDirectory + }); + + if (hasNativeHeaderBar) { + const drawerAction: IHeaderAction = { + label: i18n.t('Menu'), + icon: 'hamburguer', + disabled, + badge: badgeColor ? { color: badgeColor } : undefined, + onPress: onDrawerPress + }; + const cancelSearchAction: IHeaderAction = { label: i18n.t('Cancel'), onPress: stopSearch }; + navigation.setOptions({ + headerTransparent: true, + headerStyle: { backgroundColor: `${colors.surfaceNeutral}B3` }, + headerBlurEffect: 'regular', + headerTitle: serverName, + headerSubtitle: nativeHeaderSubtitle, + headerTitleTestID: 'rooms-list-header-servers-list-button', + onHeaderTitlePress: () => showActionSheetRef({ children: , enableContentPanningGesture: false }), + headerSearchBarOptions: { + ref: searchBarRef, + placement: isTablet ? 'stacked' : 'automatic', + placeholder: i18n.t('Search'), + hideNavigationBar: !isTablet, + onFocus: startSearch, + onChangeText: (event: { nativeEvent: { text: string } }) => search(event.nativeEvent.text), + onCancelButtonPress: stopSearch + }, + ...headerLeftActions([drawerAction]), + ...headerRightActions(isTablet && searchEnabled ? [cancelSearchAction] : rightActions) + }); + return; + } + const options = { headerLeft: () => ( navigation.navigate('ModalStackNavigator', { screen: 'SettingsView' }) - : () => navigation.toggleDrawer() - } - badge={getBadge} + onPress={onDrawerPress} + badge={() => (badgeColor ? : null)} disabled={disabled} /> ), headerTitle: () => , - headerRight: () => ( - - {issuesWithNotifications ? ( - - ) : null} - {canCreateRoom ? ( - - ) : null} - - - - ) + ...headerRightActions(rightActions) }; navigation.setOptions(options); @@ -168,28 +243,48 @@ export const useHeader = () => { searchEnabled, goDirectory, navigateToPushTroubleshootView, - getBadge, goToNewMessage, startSearch, stopSearch, - search + search, + serverName, + nativeHeaderSubtitle, + badgeColor ]); - // The rooms list header persists across native-stack navigation, so autoFocus (mount-only) - // won't re-fire on back-return or after the list/banner render asynchronously. Re-assert focus - // on the drawer button every time the screen is focused so external-keyboard/screen-reader - // navigation always starts from a known element. Regular touch users are left untouched. - useFocusEffect( - useCallback(() => { - if (!isAccessibilityNavigationEnabled) { - return; - } - const task = InteractionManager.runAfterInteractions(() => { + useEffect(() => { + if (!hasNativeHeaderBar || searchEnabled) { + return; + } + if (isMasterDetail) { + searchBarRef.current?.cancelSearch(); + return; + } + searchBarRef.current?.clearText(); + }, [searchEnabled, isMasterDetail]); + + const focusNavigation = getScreenFocusNavigation(navigation, isMasterDetail); + + useEffect(() => { + if (!isAccessibilityNavigationEnabled) { + return; + } + let task: ReturnType | undefined; + const focusDrawerButton = () => { + task?.cancel(); + task = InteractionManager.runAfterInteractions(() => { drawerButtonRef.current?.focus(); }); - return () => task.cancel(); - }, [isAccessibilityNavigationEnabled]) - ); + }; + if (focusNavigation.isFocused()) { + focusDrawerButton(); + } + const unsubscribe = focusNavigation.addListener('focus', focusDrawerButton); + return () => { + unsubscribe(); + task?.cancel(); + }; + }, [focusNavigation, isAccessibilityNavigationEnabled]); return { options }; }; diff --git a/app/views/RoomsListView/hooks/useRoomsListSubtitle.ts b/app/views/RoomsListView/hooks/useRoomsListSubtitle.ts new file mode 100644 index 00000000000..71e7302de40 --- /dev/null +++ b/app/views/RoomsListView/hooks/useRoomsListSubtitle.ts @@ -0,0 +1,24 @@ +import I18n from '~/i18n'; +import { useAppSelector } from '~/lib/hooks/useAppSelector'; + +export const useRoomsListSubtitle = (): string | undefined => { + const supportedVersionsStatus = useAppSelector(state => state.supportedVersions.status); + const connecting = useAppSelector(state => state.meteor.connecting || state.server.loading || state.login.isFetching); + const connected = useAppSelector(state => state.meteor.connected); + const isFetchingRooms = useAppSelector(state => state.rooms.isFetching); + const server = useAppSelector(state => state.server.server); + + if (supportedVersionsStatus === 'expired') { + return I18n.t('Cannot_connect'); + } + if (connecting) { + return I18n.t('Connecting'); + } + if (isFetchingRooms) { + return I18n.t('Updating'); + } + if (!connected) { + return I18n.t('Waiting_for_network'); + } + return server?.replace(/(^\w+:|^)\/\//, ''); +}; diff --git a/app/views/RoomsListView/index.tsx b/app/views/RoomsListView/index.tsx index ebe342e033d..1e48493d45c 100644 --- a/app/views/RoomsListView/index.tsx +++ b/app/views/RoomsListView/index.tsx @@ -1,6 +1,6 @@ import { useNavigation } from '@react-navigation/native'; import { memo, useContext, useEffect } from 'react'; -import { BackHandler, FlatList, RefreshControl } from 'react-native'; +import { BackHandler, FlatList, Platform, RefreshControl } from 'react-native'; import { useSafeAreaFrame, useSafeAreaInsets } from 'react-native-safe-area-context'; import { shallowEqual } from 'react-redux'; @@ -64,7 +64,7 @@ const RoomsListView = memo(function RoomsListView() { }, [searchEnabled]); const onPressItem = (item = {} as IRoomItem) => { - if (!navigation.isFocused()) { + if (!isMasterDetail && !navigation.isFocused()) { return; } if (item.rid === subscribedRoom) { @@ -131,11 +131,12 @@ const RoomsListView = memo(function RoomsListView() { extraData={searchEnabled ? searchResults : subscriptions} keyExtractor={item => `${item.rid}-${searchEnabled}`} style={[styles.list, { backgroundColor: colors.surfaceRoom }]} - contentContainerStyle={{ paddingBottom: bottom }} + contentContainerStyle={{ paddingBottom: Platform.select({ ios: 0, default: bottom }) }} renderItem={renderItem} ListHeaderComponent={ListHeader} ListFooterComponent={searching ? () => : undefined} getItemLayout={getItemLayout} + contentInsetAdjustmentBehavior={isIOS ? 'automatic' : undefined} keyboardShouldPersistTaps='always' initialNumToRender={INITIAL_NUM_TO_RENDER} refreshControl={} diff --git a/app/views/SelectListView/Item.ios.tsx b/app/views/SelectListView/Item.ios.tsx new file mode 100644 index 00000000000..0efb8925f72 --- /dev/null +++ b/app/views/SelectListView/Item.ios.tsx @@ -0,0 +1,42 @@ +import * as List from '~/containers/List'; +import { ICON_SIZE } from '~/containers/List/constants'; +import NativeListRow from '~/containers/NativeListRow'; +import RowSeparator from '~/containers/NativeListRow/components/Separator'; +import { AVATAR_SIZE } from '~/containers/NativeListRow/constants'; +import Radio from '~/containers/Radio'; +import { useTheme } from '~/theme'; +import { type ISelectListItem } from './Item'; + +const Item = ({ name, icon, alert, isRadio, isChecked, accessibilityState, onPress, isFirst, isLast }: ISelectListItem) => { + const { colors } = useTheme(); + const renderTrailing = () => { + if (isRadio) { + return ( + + ); + } + return isChecked ? : undefined; + }; + return ( + <> + {isFirst ? null : } + } + trailing={alert ? : renderTrailing()} + /> + + ); +}; + +export default Item; diff --git a/app/views/SelectListView/Item.tsx b/app/views/SelectListView/Item.tsx new file mode 100644 index 00000000000..95abd9b1590 --- /dev/null +++ b/app/views/SelectListView/Item.tsx @@ -0,0 +1,50 @@ +import * as List from '~/containers/List'; +import { ICON_SIZE } from '~/containers/List/constants'; +import { type TIconsName } from '~/containers/CustomIcon'; +import Radio from '~/containers/Radio'; +import { useTheme } from '~/theme'; + +export interface ISelectListItem { + name: string; + icon: TIconsName; + alert?: boolean; + isRadio?: boolean; + isChecked: boolean; + accessibilityState: string; + onPress: () => void; + isFirst: boolean; + isLast: boolean; +} + +const Item = ({ name, icon, alert, isRadio, isChecked, accessibilityState, onPress }: ISelectListItem) => { + const { colors } = useTheme(); + const renderRight = () => { + if (isRadio) { + return ( + + ); + } + return isChecked ? : null; + }; + return ( + <> + + } + right={renderRight} + additionalAccessibilityLabel={accessibilityState} + /> + + ); +}; + +export default Item; diff --git a/app/views/SelectListView.tsx b/app/views/SelectListView/index.tsx similarity index 65% rename from app/views/SelectListView.tsx rename to app/views/SelectListView/index.tsx index 39f193bbfca..0daf0c1efa0 100644 --- a/app/views/SelectListView.tsx +++ b/app/views/SelectListView/index.tsx @@ -4,20 +4,19 @@ import { type RouteProp } from '@react-navigation/native'; import { type EdgeInsets, withSafeAreaInsets } from 'react-native-safe-area-context'; import { Component } from 'react'; -import { type ChatsStackParamList } from '../stacks/types'; -import log from '../lib/methods/helpers/log'; -import * as List from '../containers/List'; -import I18n from '../i18n'; -import * as HeaderButton from '../containers/Header/components/HeaderButton'; -import { themes } from '../lib/constants/colors'; -import { type TSupportedThemes, withTheme } from '../theme'; -import SafeAreaView from '../containers/SafeAreaView'; -import { ICON_SIZE } from '../containers/List/constants'; -import SearchBox from '../containers/SearchBox'; -import Radio from '../containers/Radio'; -import sharedStyles from './Styles'; -import { type TDataSelect } from '../definitions/IDataSelect'; -import { withMasterDetail } from '../lib/hooks/useMasterDetail'; +import { type ChatsStackParamList } from '~/stacks/types'; +import log from '~/lib/methods/helpers/log'; +import I18n from '~/i18n'; +import * as HeaderButton from '~/containers/Header/components/HeaderButton'; +import { themes } from '~/lib/constants/colors'; +import { type TSupportedThemes, withTheme } from '~/theme'; +import SafeAreaView from '~/containers/SafeAreaView'; +import SearchBox from '~/containers/SearchBox'; +import sharedStyles from '../Styles'; +import Item from './Item'; +import { isIOS } from '~/lib/methods/helpers'; +import { type TDataSelect } from '~/definitions/IDataSelect'; +import { withMasterDetail } from '~/lib/hooks/useMasterDetail'; const styles = StyleSheet.create({ buttonText: { @@ -138,55 +137,27 @@ class SelectListView extends Component { - const { theme } = this.props; - const { selected } = this.state; - + renderItem = ({ item, index }: { item: TDataSelect; index: number }) => { + const { isSearching, data, dataFiltered } = this.state; + const rowCount = (isSearching ? dataFiltered : data)?.length ?? 0; const channelIcon = item.t === 'p' ? 'channel-private' : 'channel-public'; const teamIcon = item.t === 'p' ? 'teams-private' : 'teams'; - const icon = item.teamMain ? teamIcon : channelIcon; - const checked = this.isChecked(item.rid) ? 'check' : ''; - - const showRadio = () => ( - - ); - const showCheck = () => - checked !== '' ? ( - - ) : null; - - const handleAcessibilityLabel = (rid: string) => { - let label = ''; - if (this.isRadio) { - label = this.isChecked(rid) ? I18n.t('Selected') : I18n.t('Unselected'); - } else { - label = this.isChecked(rid) ? I18n.t('Checked') : I18n.t('Unchecked'); - } - return label; - }; + const isChecked = this.isChecked(item.rid); + const checkedLabel = isChecked ? I18n.t('Checked') : I18n.t('Unchecked'); + const radioLabel = isChecked ? I18n.t('Selected') : I18n.t('Unselected'); return ( - <> - - (item.alert ? this.showAlert() : this.toggleItem(item.rid))} - alert={item.alert} - left={() => } - right={() => (this.isRadio ? showRadio() : showCheck())} - additionalAccessibilityLabel={handleAcessibilityLabel(item.rid)} - /> - + (item.alert ? this.showAlert() : this.toggleItem(item.rid))} + isFirst={index === 0} + isLast={index === rowCount - 1} + /> ); }; @@ -201,7 +172,10 @@ class SelectListView extends Component item.rid} renderItem={this.renderItem} ListHeaderComponent={this.isSearch ? this.renderSearch : this.renderInfoText} - contentContainerStyle={{ backgroundColor: themes[theme].surfaceRoom, paddingBottom: insets.bottom }} + contentContainerStyle={{ + backgroundColor: isIOS ? themes[theme].surfaceHover : themes[theme].surfaceRoom, + paddingBottom: insets.bottom + }} keyboardShouldPersistTaps='always' /> diff --git a/app/views/SelectedUsersView/Header.tsx b/app/views/SelectedUsersView/Header.tsx index d444bd565e1..8864f76ac67 100644 --- a/app/views/SelectedUsersView/Header.tsx +++ b/app/views/SelectedUsersView/Header.tsx @@ -1,7 +1,6 @@ import { useRef } from 'react'; import { FlatList, View, Text, StyleSheet } from 'react-native'; -import { themes } from '~/lib/constants/colors'; import SearchBox from '~/containers/SearchBox'; import I18n from '~/i18n'; import { type ISelectedUser } from '~/reducers/selectedUsers'; @@ -9,6 +8,8 @@ import { useTheme } from '~/theme'; import sharedStyles from '../Styles'; import { useAppSelector } from '~/lib/hooks/useAppSelector'; import Chip from '~/containers/Chip'; +import { hasNativeHeaderBar } from '~/lib/methods/helpers'; +import { useListBackgroundColor } from '~/containers/NativeListRow/hooks/useListBackgroundColor'; const styles = StyleSheet.create({ selectedText: { @@ -33,7 +34,8 @@ const Header = ({ onPressItem: (userItem: ISelectedUser) => void; }) => { const flatlist = useRef>(null); - const { theme } = useTheme(); + const { colors } = useTheme(); + const backgroundColor = useListBackgroundColor(colors.surfaceRoom); const { users } = useAppSelector(state => ({ users: state.selectedUsers.users })); @@ -41,11 +43,13 @@ const Header = ({ const onContentSizeChange = () => flatlist?.current?.scrollToEnd({ animated: true }); return ( - - onChangeText(text)} testID='select-users-view-search' /> + + {hasNativeHeaderBar ? null : ( + onChangeText(text)} testID='select-users-view-search' /> + )} {users.length === 0 ? null : ( - + {I18n.t('N_Selected_members', { n: users.length })} { const navigation = useNavigation(); const { colors } = useTheme(); + const listBackgroundColor = useListBackgroundColor(colors.surfaceRoom); const dispatch = useDispatch(); const { bottom } = useSafeAreaInsets(); @@ -75,23 +79,52 @@ const SelectedUsersView = () => { return showSkipText ? I18n.t('Skip') : ''; }; + const handleSearch = useCallback(async (text: string) => { + searchId.current += 1; + const currentSearchId = searchId.current; + const isStale = () => currentSearchId !== searchId.current; + + setSearching(true); + + try { + // Paint local results immediately while the backend request is still in flight + const result = await runSearch({ + text, + filterRooms: false, + onLocal: localData => { + if (isStale()) return; + setSearch(localData); + } + }); + if (isStale()) return; + setSearch(result); + } catch (e) { + log(e); + } finally { + // Only the latest search clears the flag, so a stale request never hides an in-flight newer one + if (!isStale()) setSearching(false); + } + }, []); + useLayoutEffect(() => { const titleHeader = title ?? I18n.t('Select_Members'); const buttonTextHeader = buttonText || I18n.t('Next'); const nextActionHeader = nextAction || (() => {}); const buttonTitle = handleButtonTitle(buttonTextHeader); - const options = { + const showHeaderButton = (!maxUsers || showButton || (isGroupChat() && users.length > 1)) && !!buttonTitle; + navigation.setOptions({ title: titleHeader, - headerRight: () => - (!maxUsers || showButton || (isGroupChat() && users.length > 1)) && - !!buttonTitle && ( - - - - ) - }; - navigation.setOptions(options); - }, [navigation, users.length, maxUsers, buttonText, nextAction]); + ...headerRightActions( + showHeaderButton ? [{ label: buttonTitle, testID: 'selected-users-view-submit', onPress: nextActionHeader }] : [] + ), + ...(hasNativeHeaderBar + ? { + headerTransparent: true, + headerSearchBarOptions: stackedSearchBarOptions({ onChangeText: handleSearch }) + } + : {}) + }); + }, [navigation, users.length, maxUsers, buttonText, nextAction, handleSearch]); useEffect(() => { if (isGroupChat() && user.username) { @@ -124,33 +157,6 @@ const SelectedUsersView = () => { }; }, [dispatch]); - const handleSearch = useCallback(async (text: string) => { - searchId.current += 1; - const currentSearchId = searchId.current; - const isStale = () => currentSearchId !== searchId.current; - - setSearching(true); - - try { - // Paint local results immediately while the backend request is still in flight - const result = await runSearch({ - text, - filterRooms: false, - onLocal: localData => { - if (isStale()) return; - setSearch(localData); - } - }); - if (isStale()) return; - setSearch(result); - } catch (e) { - log(e); - } finally { - // Only the latest search clears the flag, so a stale request never hides an in-flight newer one - if (!isStale()) setSearching(false); - } - }, []); - const toggleUser = (userItem: ISelectedUser) => { // Disallow removing self user from the direct message group if (isGroupChat() && user.username === userItem.name) { @@ -185,8 +191,9 @@ const SelectedUsersView = () => { item._id} - renderItem={({ item }) => { + renderItem={({ item, index }) => { const name = useRealName && item.fname ? item.fname : item.name; const username = item.search ? (item.username as string) : item.name; return ( @@ -198,13 +205,19 @@ const SelectedUsersView = () => { icon={isChecked(username) ? 'checkbox-checked' : 'checkbox-unchecked'} iconColor={isChecked(username) ? colors.fontHint : colors.strokeLight} isChecked={isChecked(username)} + isFirst={index === 0} + isLast={index === data.length - 1} /> ); }} - ItemSeparatorComponent={List.Separator} - ListFooterComponent={searching ? : } + ItemSeparatorComponent={RowSeparator} + ListFooterComponent={searching ? : hasNativeHeaderBar ? null : } ListHeaderComponent={
} - contentContainerStyle={{ backgroundColor: colors.surfaceRoom, paddingBottom: bottom }} + contentContainerStyle={{ + backgroundColor: listBackgroundColor, + paddingTop: hasNativeHeaderBar ? 16 : 0, + paddingBottom: bottom + }} keyboardShouldPersistTaps='always' /> diff --git a/app/views/SettingsView/index.tsx b/app/views/SettingsView/index.tsx index 8097918658f..45897777cc3 100644 --- a/app/views/SettingsView/index.tsx +++ b/app/views/SettingsView/index.tsx @@ -30,13 +30,14 @@ import { onReviewPress } from '~/lib/methods/helpers/review'; import { type SettingsStackParamList } from '~/stacks/types'; import { useTheme } from '~/theme'; import { disconnect } from '~/lib/services/connect'; -import SidebarView from '../SidebarView'; +import { useSidebarSections } from '../SidebarView/useSidebarSections'; type TLogScreenName = 'SE_GO_LANGUAGE' | 'SE_GO_DEFAULTBROWSER' | 'SE_GO_THEME' | 'SE_GO_PROFILE' | 'SE_GO_SECURITYPRIVACY'; const SettingsView = (): ReactElement => { const { colors, theme } = useTheme(); const navigation = useNavigation>(); + const { sections: sidebarSections } = useSidebarSections(navigation as any); const dispatch = useDispatch(); const isMasterDetail = useMasterDetail(); const { server, version } = useAppSelector(state => state.server); @@ -137,9 +138,7 @@ const SettingsView = (): ReactElement => { {isMasterDetail ? ( <> - - - + {sidebarSections} { private backHandler: NativeEventSubscription | undefined; + private searchBarRef = createRef(); + constructor(props: IShareListViewProps) { super(props); this.state = { @@ -166,7 +172,7 @@ class ShareListView extends Component { if (previousProps.connecting !== connecting && connecting) { this.setState({ chats: [], searchResults: [], searching: false, searchText: '' }); } - if (previousState.searching !== searching) { + if (!hasNativeHeaderBar && previousState.searching !== searching) { this.setHeader(); } } @@ -214,7 +220,7 @@ class ShareListView extends Component { const { searching } = this.state; const { navigation } = this.props; - if (searching) { + if (searching && !hasNativeHeaderBar) { navigation.setOptions({ headerLeft: () => ( @@ -228,18 +234,23 @@ class ShareListView extends Component { } navigation.setOptions({ - headerLeft: () => ( - - - - ), + ...outsideHeaderLeftClose(this.closeShareExtension, 'share-extension-close'), headerTitle: I18n.t('Send_to'), - headerRight: () => - this.airGappedReadOnly ? null : ( - - - - ) + ...(hasNativeHeaderBar && { + headerSearchBarOptions: this.airGappedReadOnly + ? undefined + : stackedSearchBarOptions({ + ref: this.searchBarRef, + onFocus: this.initSearch, + onChangeText: this.search, + onCancel: this.cancelSearch + }) + }), + ...headerRightActions( + this.airGappedReadOnly + ? [] + : [{ label: I18n.t('Search'), icon: 'search', legacyHeaderOnly: true, onPress: this.initSearch }] + ) }); }; @@ -361,7 +372,10 @@ class ShareListView extends Component { }; cancelSearch = () => { - this.setState({ searching: false, searchResults: [], searchText: '' }, () => this.setHeader()); + this.setState({ searching: false, searchResults: [], searchText: '' }, () => { + this.setHeader(); + this.searchBarRef.current?.clearText(); + }); Keyboard.dismiss(); }; @@ -393,16 +407,22 @@ class ShareListView extends Component { return ( <> - + {I18n.t(header)} - + {hasNativeHeaderBar ? null : } ); }; - renderItem = ({ item }: { item: TSubscriptionModel }) => { - const { serverInfo } = this.state; + renderItem = ({ item, index }: { item: TSubscriptionModel; index: number }) => { + const { serverInfo, chats, searchResults, searching } = this.state; + const rowCount = searching ? searchResults.length : chats.length; let description; switch (item.t) { case 'c': @@ -427,6 +447,8 @@ class ShareListView extends Component { onPress={() => this.shareMessage(item)} testID={`share-extension-item-${item.name}`} teamMain={item.teamMain} + isFirst={index === 0} + isLast={index === rowCount - 1} /> ); }; @@ -441,7 +463,7 @@ class ShareListView extends Component { <> {this.renderSectionHeader('Select_Server')} navigation.navigate('SelectServerView')} item={serverInfo} /> - + {hasNativeHeaderBar ? null : } ); }; @@ -517,12 +539,13 @@ class ShareListView extends Component { data={searching ? searchResults : chats} keyExtractor={keyExtractor} style={[styles.flatlist, { backgroundColor: themes[theme].surfaceHover }]} - contentContainerStyle={{ paddingBottom: insets.bottom }} + contentContainerStyle={hasNativeHeaderBar ? searching && styles.nativeSearchContent : { paddingBottom: insets.bottom }} + contentInsetAdjustmentBehavior={hasNativeHeaderBar ? 'automatic' : undefined} renderItem={this.renderItem} getItemLayout={getItemLayout} - ItemSeparatorComponent={List.Separator} + ItemSeparatorComponent={RowSeparator} ListHeaderComponent={this.renderHeader} - ListFooterComponent={!searching || searchResults.length > 0 ? : null} + ListFooterComponent={!hasNativeHeaderBar && (!searching || searchResults.length > 0) ? : null} ListEmptyComponent={this.renderEmptyComponent} removeClippedSubviews keyboardShouldPersistTaps='always' diff --git a/app/views/ShareListView/styles.ts b/app/views/ShareListView/styles.ts index 89f178da561..5f651194fa9 100644 --- a/app/views/ShareListView/styles.ts +++ b/app/views/ShareListView/styles.ts @@ -36,6 +36,12 @@ export default StyleSheet.create({ paddingBottom: 10, paddingTop: 17 }, + nativeHeaderContainer: { + paddingBottom: 16 + }, + nativeSearchContent: { + paddingTop: 16 + }, headerText: { ...sharedStyles.textRegular, fontSize: 17, diff --git a/app/views/SidebarView/components/Admin.tsx b/app/views/SidebarView/components/Admin.tsx index 5d0418b5d98..a8ea1a1683a 100644 --- a/app/views/SidebarView/components/Admin.tsx +++ b/app/views/SidebarView/components/Admin.tsx @@ -6,9 +6,7 @@ import { usePermissions } from '~/lib/hooks/usePermissions'; import { useTheme } from '~/theme'; import { sidebarNavigate } from '../methods/sidebarNavigate'; -const Admin = ({ currentScreen }: { currentScreen: string | null }) => { - const isMasterDetail = useMasterDetail(); - const { colors } = useTheme(); +export const useIsAdmin = () => { const [ viewStatisticsPermission, viewRoomAdministrationPermission, @@ -32,10 +30,19 @@ const Admin = ({ currentScreen }: { currentScreen: string | null }) => { ] ); + return isAdmin; +}; + +export const useAdminRoute = () => (useMasterDetail() ? 'AdminPanelView' : 'AdminPanelStackNavigator'); + +const Admin = ({ currentScreen }: { currentScreen: string | null }) => { + const routeName = useAdminRoute(); + const { colors } = useTheme(); + const isAdmin = useIsAdmin(); + if (!isAdmin) { return null; } - const routeName = isMasterDetail ? 'AdminPanelView' : 'AdminPanelStackNavigator'; return ( <> + Boolean(useAppSelector(state => state.settings.Accounts_AllowUserStatusMessageChange)); + const CustomStatus = () => { const { colors } = useTheme(); const { status: userStatus, statusText } = useAppSelector(getUserSelector, shallowEqual); const presenceBroadcastDisabled = useAppSelector(state => state.settings.Presence_broadcast_disabled) as boolean; const notificationPresenceCap = useAppSelector(state => state.app.notificationPresenceCap); - const allowStatusMessage = useAppSelector(state => state.settings.Accounts_AllowUserStatusMessageChange); + const allowStatusMessage = useIsCustomStatusVisible(); const dispatch = useDispatch(); const onPressPresenceLearnMore = () => { @@ -77,4 +81,4 @@ const CustomStatus = () => { ); }; -export default memo(CustomStatus); +export default asNativeListRow(memo(CustomStatus)); diff --git a/app/views/SidebarView/components/Profile.ios.tsx b/app/views/SidebarView/components/Profile.ios.tsx new file mode 100644 index 00000000000..60b1c98e533 --- /dev/null +++ b/app/views/SidebarView/components/Profile.ios.tsx @@ -0,0 +1,43 @@ +import { memo } from 'react'; +import { type DrawerNavigationProp } from '@react-navigation/drawer'; +import { shallowEqual } from 'react-redux'; + +import Avatar from '~/containers/Avatar'; +import { getUserSelector } from '~/selectors/login'; +import { type DrawerParamList } from '~/stacks/types'; +import * as List from '~/containers/List'; +import { asNativeListRow } from '~/containers/List/native/utils/rowMarkers'; +import { useAppSelector } from '~/lib/hooks/useAppSelector'; +import { useMasterDetail } from '~/lib/hooks/useMasterDetail'; + +const PROFILE_ROW_HEIGHT = 68; + +const Profile = ({ navigation }: { navigation: DrawerNavigationProp }) => { + const isMasterDetail = useMasterDetail(); + const { username, name, statusText } = useAppSelector(getUserSelector, shallowEqual); + const useRealName = useAppSelector(state => state.settings.UI_Use_Real_Name); + const siteName = useAppSelector(state => state.settings.Site_Name) as string; + + const onPressUser = () => { + if (isMasterDetail) { + return; + } + navigation.closeDrawer(); + }; + + return ( + } + heightContainer={PROFILE_ROW_HEIGHT} + onPress={onPressUser} + testID='sidebar-close-drawer' + numberOfLines={1} + /> + ); +}; + +export default asNativeListRow(memo(Profile)); diff --git a/app/views/SidebarView/components/Profile.tsx b/app/views/SidebarView/components/Profile.tsx index 0520dd79f84..1914c5ffc58 100644 --- a/app/views/SidebarView/components/Profile.tsx +++ b/app/views/SidebarView/components/Profile.tsx @@ -11,10 +11,12 @@ import { type DrawerParamList } from '~/stacks/types'; import * as List from '~/containers/List'; import { useAppSelector } from '~/lib/hooks/useAppSelector'; import { useMasterDetail } from '~/lib/hooks/useMasterDetail'; +import { useNativeListMode } from '~/containers/List/native/context'; const Profile = ({ navigation }: { navigation: DrawerNavigationProp }) => { const { colors } = useTheme(); const isMasterDetail = useMasterDetail(); + const isInNativeList = useNativeListMode(); const { username, name } = useAppSelector(getUserSelector, shallowEqual); const useRealName = useAppSelector(state => state.settings.UI_Use_Real_Name); const server = useAppSelector(state => state.server.server); @@ -31,7 +33,7 @@ const Profile = ({ navigation }: { navigation: DrawerNavigationProp - + diff --git a/app/views/SidebarView/components/StackItem.tsx b/app/views/SidebarView/components/StackItem.tsx new file mode 100644 index 00000000000..fd5a4ab03e4 --- /dev/null +++ b/app/views/SidebarView/components/StackItem.tsx @@ -0,0 +1,20 @@ +import { useTheme } from '~/theme'; +import * as List from '~/containers/List'; +import { type IStackItem } from './useStackItems'; + +const StackItem = ({ item }: { item: IStackItem }) => { + const { colors } = useTheme(); + + return ( + } + onPress={item.onPress} + backgroundColor={item.selected ? colors.strokeLight : undefined} + testID={item.testID} + disabled={item.disabled} + /> + ); +}; + +export default StackItem; diff --git a/app/views/SidebarView/components/Stacks.tsx b/app/views/SidebarView/components/Stacks.tsx index 36b6beeed7e..bab9c345a9f 100644 --- a/app/views/SidebarView/components/Stacks.tsx +++ b/app/views/SidebarView/components/Stacks.tsx @@ -1,64 +1,20 @@ -import { useTheme } from '~/theme'; +import { Fragment } from 'react'; + import * as List from '~/containers/List'; -import { sidebarNavigate } from '../methods/sidebarNavigate'; -import { useMasterDetail } from '~/lib/hooks/useMasterDetail'; -import { useNewMediaCall } from '~/lib/hooks/useNewMediaCall'; +import StackItem from './StackItem'; +import { useStackItems } from './useStackItems'; const Stacks = ({ currentScreen }: { currentScreen: string | null }) => { - const { colors } = useTheme(); - const isMasterDetail = useMasterDetail(); - const { openNewMediaCall, hasMediaCallPermission, isInActiveCall } = useNewMediaCall(); - - if (isMasterDetail) { - return null; - } + const items = useStackItems(currentScreen); return ( <> - } - onPress={() => sidebarNavigate('ChatsStackNavigator')} - backgroundColor={currentScreen === 'ChatsStackNavigator' ? colors.strokeLight : undefined} - testID='sidebar-chats' - /> - - {hasMediaCallPermission ? ( - <> - } - onPress={openNewMediaCall} - testID='sidebar-media-call' - disabled={isInActiveCall} - /> + {items.map(item => ( + + - - ) : null} - } - onPress={() => sidebarNavigate('ProfileStackNavigator')} - backgroundColor={currentScreen === 'ProfileStackNavigator' ? colors.strokeLight : undefined} - testID='sidebar-profile' - /> - - } - onPress={() => sidebarNavigate('AccessibilityStackNavigator')} - backgroundColor={currentScreen === 'AccessibilityStackNavigator' ? colors.strokeLight : undefined} - testID='sidebar-accessibility' - /> - - } - onPress={() => sidebarNavigate('SettingsStackNavigator')} - backgroundColor={currentScreen === 'SettingsStackNavigator' ? colors.strokeLight : undefined} - testID='sidebar-settings' - /> - + + ))} ); }; diff --git a/app/views/SidebarView/components/SupportedVersionsWarnItem.tsx b/app/views/SidebarView/components/SupportedVersionsWarnItem.tsx index 333199ed308..00b83c863b5 100644 --- a/app/views/SidebarView/components/SupportedVersionsWarnItem.tsx +++ b/app/views/SidebarView/components/SupportedVersionsWarnItem.tsx @@ -3,15 +3,18 @@ import { memo } from 'react'; import { useTheme } from '~/theme'; import { CustomIcon } from '~/containers/CustomIcon'; import * as List from '~/containers/List'; +import { asNativeListRow } from '~/containers/List/native/utils/rowMarkers'; import { useAppSelector } from '~/lib/hooks/useAppSelector'; import { useMasterDetail } from '~/lib/hooks/useMasterDetail'; import { showActionSheetRef } from '~/containers/ActionSheet'; import Navigation from '~/lib/navigation/appNavigation'; import { SupportedVersionsWarning } from '~/containers/SupportedVersions'; +export const useIsSupportedVersionsWarnVisible = () => useAppSelector(state => state.supportedVersions.status) === 'warn'; + const SupportedVersionsWarnItem = () => { const { colors } = useTheme(); - const supportedVersionsStatus = useAppSelector(state => state.supportedVersions.status); + const isVisible = useIsSupportedVersionsWarnVisible(); const isMasterDetail = useMasterDetail(); const onPressSupportedVersionsWarning = () => { @@ -22,7 +25,7 @@ const SupportedVersionsWarnItem = () => { } }; - if (supportedVersionsStatus === 'warn') { + if (isVisible) { return ( <> { return null; }; -export default memo(SupportedVersionsWarnItem); +export default asNativeListRow(memo(SupportedVersionsWarnItem)); diff --git a/app/views/SidebarView/components/__tests__/getSidebarSelection.test.ts b/app/views/SidebarView/components/__tests__/getSidebarSelection.test.ts new file mode 100644 index 00000000000..8c555e92226 --- /dev/null +++ b/app/views/SidebarView/components/__tests__/getSidebarSelection.test.ts @@ -0,0 +1,40 @@ +import { getSidebarSelection } from '../getSidebarSelection'; +import { type IStackItem } from '../useStackItems'; + +const item = (testID: string, selected: boolean, route?: string): IStackItem => ({ + title: testID, + icon: 'message', + testID, + onPress: jest.fn(), + selected, + route +}); + +const stackItems = [ + item('sidebar-chats', false, 'ChatsStackNavigator'), + item('sidebar-media-call', false), + item('sidebar-profile', true, 'ProfileStackNavigator') +]; + +describe('getSidebarSelection', () => { + it('selects the stack item of the current route ', () => { + expect(getSidebarSelection(stackItems, null, 'ProfileStackNavigator')).toEqual({ selectedTag: 'sidebar-profile' }); + }); + + it('selects admin when the current route is the admin route', () => { + const items = stackItems.map(stackItem => ({ ...stackItem, selected: false })); + expect(getSidebarSelection(items, 'AdminPanelStackNavigator', 'AdminPanelStackNavigator')).toEqual({ + selectedTag: 'sidebar-admin' + }); + }); + + it('never selects a row without a route', () => { + const items = [item('sidebar-media-call', true)]; + expect(getSidebarSelection(items, null, null).selectedTag).toBeNull(); + }); + + it('selects nothing when the current route has no row', () => { + const items = stackItems.map(stackItem => ({ ...stackItem, selected: false })); + expect(getSidebarSelection(items, null, 'Other').selectedTag).toBeNull(); + }); +}); diff --git a/app/views/SidebarView/components/getSidebarSelection.ts b/app/views/SidebarView/components/getSidebarSelection.ts new file mode 100644 index 00000000000..3c39d22910f --- /dev/null +++ b/app/views/SidebarView/components/getSidebarSelection.ts @@ -0,0 +1,14 @@ +import { type IListSelection } from '~/containers/List/components/ListContainer.ios'; +import { type IStackItem } from './useStackItems'; + +export const ADMIN_SELECTION_TAG = 'sidebar-admin'; + +export const getSidebarSelection = ( + stackItems: IStackItem[], + adminRoute: string | null, + currentScreen: string | null +): IListSelection => { + const selectedItem = stackItems.find(item => item.route && item.selected); + const isAdminSelected = adminRoute !== null && currentScreen === adminRoute; + return { selectedTag: selectedItem?.testID ?? (isAdminSelected ? ADMIN_SELECTION_TAG : null) }; +}; diff --git a/app/views/SidebarView/components/useStackItems.ts b/app/views/SidebarView/components/useStackItems.ts new file mode 100644 index 00000000000..894b79db1fd --- /dev/null +++ b/app/views/SidebarView/components/useStackItems.ts @@ -0,0 +1,53 @@ +import { type TIconsName } from '~/containers/CustomIcon'; +import { useMasterDetail } from '~/lib/hooks/useMasterDetail'; +import { useNewMediaCall } from '~/lib/hooks/useNewMediaCall'; +import { sidebarNavigate } from '../methods/sidebarNavigate'; + +export interface IStackItem { + title: string; + icon: TIconsName; + testID: string; + onPress: () => void; + selected: boolean; + route?: string; + disabled?: boolean; +} + +export const useStackItems = (currentScreen: string | null): IStackItem[] => { + const isMasterDetail = useMasterDetail(); + const { openNewMediaCall, hasMediaCallPermission, isInActiveCall } = useNewMediaCall(); + + if (isMasterDetail) { + return []; + } + + const routeItem = (title: string, icon: TIconsName, testID: string, route: string): IStackItem => ({ + title, + icon, + testID, + onPress: () => sidebarNavigate(route), + selected: currentScreen === route, + route + }); + + const mediaCallItems: IStackItem[] = hasMediaCallPermission + ? [ + { + title: 'Voice_call', + icon: 'phone', + testID: 'sidebar-media-call', + onPress: openNewMediaCall, + selected: false, + disabled: isInActiveCall + } + ] + : []; + + return [ + routeItem('Chats', 'message', 'sidebar-chats', 'ChatsStackNavigator'), + ...mediaCallItems, + routeItem('Profile', 'user', 'sidebar-profile', 'ProfileStackNavigator'), + routeItem('Accessibility_and_Appearance', 'accessibility', 'sidebar-accessibility', 'AccessibilityStackNavigator'), + routeItem('Settings', 'administration', 'sidebar-settings', 'SettingsStackNavigator') + ]; +}; diff --git a/app/views/SidebarView/index.ios.tsx b/app/views/SidebarView/index.ios.tsx new file mode 100644 index 00000000000..991f01e2d3f --- /dev/null +++ b/app/views/SidebarView/index.ios.tsx @@ -0,0 +1,19 @@ +import { type DrawerNavigationProp } from '@react-navigation/drawer'; +import { View } from 'react-native'; + +import ListContainer from '~/containers/List/components/ListContainer.ios'; +import styles from './styles'; +import { type DrawerParamList } from '~/stacks/types'; +import { useSidebarSections } from './useSidebarSections.ios'; + +const SidebarView = ({ navigation }: { navigation: DrawerNavigationProp }) => { + const { sections, selection } = useSidebarSections(navigation); + + return ( + + {sections} + + ); +}; + +export default SidebarView; diff --git a/app/views/SidebarView/index.tsx b/app/views/SidebarView/index.tsx index c65c6ba18ef..1f00b54ce25 100644 --- a/app/views/SidebarView/index.tsx +++ b/app/views/SidebarView/index.tsx @@ -1,6 +1,7 @@ import { useEffect, useState } from 'react'; import { type DrawerNavigationProp } from '@react-navigation/drawer'; import { ScrollView, View } from 'react-native'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; import scrollPersistTaps from '~/lib/methods/helpers/scrollPersistTaps'; import styles from './styles'; @@ -12,6 +13,7 @@ import Admin from './components/Admin'; import Profile from './components/Profile'; const SidebarView = ({ navigation }: { navigation: DrawerNavigationProp }) => { + const { top } = useSafeAreaInsets(); const [currentScreen, setCurrentScreen] = useState(null); useEffect(() => { @@ -24,7 +26,11 @@ const SidebarView = ({ navigation }: { navigation: DrawerNavigationProp - + diff --git a/app/views/SidebarView/useSidebarSections.ios.tsx b/app/views/SidebarView/useSidebarSections.ios.tsx new file mode 100644 index 00000000000..b6b78bd6e5a --- /dev/null +++ b/app/views/SidebarView/useSidebarSections.ios.tsx @@ -0,0 +1,75 @@ +import { useEffect, useState } from 'react'; +import { type DrawerNavigationProp } from '@react-navigation/drawer'; + +import * as List from '~/containers/List'; +import { type DrawerParamList } from '~/stacks/types'; +import SupportedVersionsWarnItem, { useIsSupportedVersionsWarnVisible } from './components/SupportedVersionsWarnItem'; +import CustomStatus, { useIsCustomStatusVisible } from './components/CustomStatus'; +import { useStackItems } from './components/useStackItems'; +import { ADMIN_SELECTION_TAG, getSidebarSelection } from './components/getSidebarSelection'; +import { useAdminRoute, useIsAdmin } from './components/Admin'; +import { sidebarNavigate } from './methods/sidebarNavigate'; +import Profile from './components/Profile'; + +const currentRouteName = (navigation: DrawerNavigationProp) => { + const state = navigation.getState(); + return state.routes[state.index].name; +}; + +export const useSidebarSections = (navigation: DrawerNavigationProp) => { + const [currentScreen, setCurrentScreen] = useState(() => currentRouteName(navigation)); + const isSupportedVersionsWarnVisible = useIsSupportedVersionsWarnVisible(); + const isCustomStatusVisible = useIsCustomStatusVisible(); + const stackItems = useStackItems(currentScreen); + const isAdmin = useIsAdmin(); + const adminRoute = useAdminRoute(); + const selection = getSidebarSelection(stackItems, isAdmin ? adminRoute : null, currentScreen); + + useEffect(() => { + const unsubscribe = navigation.addListener('state', () => { + setCurrentScreen(currentRouteName(navigation)); + }); + + return unsubscribe; + }, [navigation]); + + const sections = ( + <> + + + + {isSupportedVersionsWarnVisible || isCustomStatusVisible ? ( + + {isSupportedVersionsWarnVisible ? : null} + {isCustomStatusVisible ? : null} + + ) : null} + {stackItems.length ? ( + + {stackItems.map(item => ( + } + onPress={item.onPress} + testID={item.testID} + disabled={item.disabled} + /> + ))} + + ) : null} + {isAdmin ? ( + + } + onPress={() => sidebarNavigate(adminRoute)} + /> + + ) : null} + + ); + + return { sections, selection }; +}; diff --git a/app/views/SidebarView/useSidebarSections.tsx b/app/views/SidebarView/useSidebarSections.tsx new file mode 100644 index 00000000000..7e4e81dcf7e --- /dev/null +++ b/app/views/SidebarView/useSidebarSections.tsx @@ -0,0 +1,13 @@ +import { type DrawerNavigationProp } from '@react-navigation/drawer'; + +import * as List from '~/containers/List'; +import { type DrawerParamList } from '~/stacks/types'; +import SidebarView from '.'; + +export const useSidebarSections = (navigation: DrawerNavigationProp) => ({ + sections: ( + + + + ) +}); diff --git a/app/views/StatusView/ClearAfterPicker/ClearAfterNativeRow.tsx b/app/views/StatusView/ClearAfterPicker/ClearAfterNativeRow.tsx new file mode 100644 index 00000000000..c00c1816d7a --- /dev/null +++ b/app/views/StatusView/ClearAfterPicker/ClearAfterNativeRow.tsx @@ -0,0 +1,50 @@ +import { useActionSheet } from '~/containers/ActionSheet'; +import { asNativeListRow } from '~/containers/List/native/utils/rowMarkers'; +import NativeListPicker from '~/containers/List/native/components/Picker'; +import I18n from '~/i18n'; +import DatePickerSheetContent from './DatePickerSheetContent'; +import { CLEAR_AFTER_OPTIONS, type ClearAfterValue } from './types'; + +interface IClearAfterNativeRowProps { + value: ClearAfterValue; + customDate: Date | null; + customDateLabel: string | null; + onChange: (value: ClearAfterValue, date: Date | null) => void; +} + +const isClearAfterValue = (value: string): value is ClearAfterValue => CLEAR_AFTER_OPTIONS.some(option => option.value === value); + +const ClearAfterNativeRow = ({ value, customDate, customDateLabel, onChange }: IClearAfterNativeRowProps) => { + const { showActionSheet } = useActionSheet(); + + const options = CLEAR_AFTER_OPTIONS.map(option => ({ + label: option.value === 'custom' && customDateLabel ? customDateLabel : I18n.t(option.labelKey), + value: option.value, + testID: `status-clear-after-${option.value || 'never'}` + })); + + const handleSelectionChange = (selected: string) => { + if (!isClearAfterValue(selected)) { + return; + } + if (selected === 'custom') { + showActionSheet({ + children: onChange('custom', date)} /> + }); + return; + } + onChange(selected, null); + }; + + return ( + + ); +}; + +export default asNativeListRow(ClearAfterNativeRow); diff --git a/app/views/StatusView/ClearAfterPicker/index.tsx b/app/views/StatusView/ClearAfterPicker/index.tsx index 01d36f5c64a..fb03f5be89f 100644 --- a/app/views/StatusView/ClearAfterPicker/index.tsx +++ b/app/views/StatusView/ClearAfterPicker/index.tsx @@ -3,9 +3,12 @@ import { Text } from 'react-native'; import { useActionSheet } from '~/containers/ActionSheet'; import * as List from '~/containers/List'; +import { asNativeListSection } from '~/containers/List/native/utils/rowMarkers'; +import { useNativeListMode } from '~/containers/List/native/context'; import I18n from '~/i18n'; import dayjs from '~/lib/dayjs'; import { useTheme } from '~/theme'; +import ClearAfterNativeRow from './ClearAfterNativeRow'; import ClearAfterSheetContent from './ClearAfterSheetContent'; import styles from './styles'; import { CLEAR_AFTER_OPTIONS, type ClearAfterValue } from './types'; @@ -22,10 +25,12 @@ interface IClearAfterPickerProps { const ClearAfterPicker = ({ value, customDate, onChange }: IClearAfterPickerProps): ReactElement => { const { showActionSheet } = useActionSheet(); const { colors } = useTheme(); + const nativeListMode = useNativeListMode(); + const customDateLabel = customDate ? dayjs(customDate).format('LL LT') : null; const getDisplayLabel = (): string => { - if (value === 'custom' && customDate) { - return dayjs(customDate).format('LL LT'); + if (value === 'custom' && customDateLabel) { + return customDateLabel; } if (value === 'custom' && !customDate) { return I18n.t('Status_dont_clear'); @@ -40,6 +45,15 @@ const ClearAfterPicker = ({ value, customDate, onChange }: IClearAfterPickerProp }); }; + if (nativeListMode === 'native') { + return ( + + + + + ); + } + return ( <> ({ useActionSheet: () => ({ showActionSheet: mockShowActionSheet, hideActionSheet: mockHideActionSheet }) })); +const mockPicker = jest.fn(); +jest.mock('~/containers/List/native/components/Picker', () => { + const { View } = require('react-native'); + return { + __esModule: true, + default: (props: INativeListPicker) => { + mockPicker(props); + return ; + } + }; +}); + +const latestPicker = (): INativeListPicker => mockPicker.mock.calls[mockPicker.mock.calls.length - 1][0]; + const Wrapper = ({ children }: { children: ReactNode }) => {children}; const renderStatusView = () => render(, { wrapper: Wrapper }); @@ -271,12 +286,7 @@ describe('StatusView', () => { renderStatusView(); - fireEvent.press(screen.getByTestId('status-view-clear-after')); - - expect(mockShowActionSheet).toHaveBeenCalled(); - const { children } = mockShowActionSheet.mock.calls[0][0] as { children: any }; - const { onConfirm } = children.props; - act(() => onConfirm('30', null)); + act(() => latestPicker().onSelectionChange('30')); await act(async () => {}); expect(screen.getByTestId('status-view-submit')).not.toBeDisabled(); @@ -287,5 +297,18 @@ describe('StatusView', () => { jest.useRealTimers(); } }); + + it('should open the date picker sheet when custom is selected', () => { + mockedStore.dispatch(setUser({ id: 'user-id', username: 'user', status: 'online', statusText: '' })); + mockedStore.dispatch(selectServerSuccess({ server: 'https://example.com', version: '8.6.0', name: 'Test' })); + mockedStore.dispatch(addSettings({ Accounts_AllowInvisibleStatusOption: true })); + + renderStatusView(); + + act(() => latestPicker().onSelectionChange('custom')); + + expect(mockShowActionSheet).toHaveBeenCalledTimes(1); + expect(latestPicker().selection).toBe(''); + }); }); }); diff --git a/app/views/StatusView/index.tsx b/app/views/StatusView/index.tsx index 5865b009077..df1a9879e9a 100644 --- a/app/views/StatusView/index.tsx +++ b/app/views/StatusView/index.tsx @@ -1,5 +1,5 @@ import { useNavigation } from '@react-navigation/native'; -import { type ReactElement, useEffect, useMemo, useRef, useState } from 'react'; +import { Fragment, type ReactElement, useEffect, useMemo, useRef, useState } from 'react'; import { FlatList, StyleSheet } from 'react-native'; import { useDispatch, useSelector } from 'react-redux'; import { useForm } from 'react-hook-form'; @@ -21,11 +21,12 @@ import I18n from '~/i18n'; import { showToast } from '~/lib/methods/helpers/showToast'; import { setUserStatus } from '~/lib/services/restApi'; import { getUserSelector } from '~/selectors/login'; -import { showErrorAlertWithEMessage, compareServerVersion } from '~/lib/methods/helpers'; +import { showErrorAlertWithEMessage, compareServerVersion, isIOS } from '~/lib/methods/helpers'; import log, { events, logEvent } from '~/lib/methods/helpers/log'; import { useTheme } from '~/theme'; import { USER_STATUS_TEXT_MAX_LENGTH } from '~/lib/constants/maxLength'; -import { type ClearAfterValue, computeExpiresAt, getInitialClearAfterState } from './ClearAfterPicker'; +import { useListBackgroundColor } from '~/containers/NativeListRow/hooks/useListBackgroundColor'; +import ClearAfterPicker, { type ClearAfterValue, computeExpiresAt, getInitialClearAfterState } from './ClearAfterPicker'; import FooterComponent from './FooterComponent'; const validationSchema = yup.object().shape({ @@ -68,41 +69,16 @@ const styles = StyleSheet.create({ borderRadius: 0, borderTopWidth: 1, borderBottomWidth: 1 + }, + rowInputContainer: { + marginBottom: 0 + }, + rowInputStyle: { + borderWidth: 0, + backgroundColor: 'transparent' } }); -const Status = ({ - statusType, - status, - setStatus -}: { - statusType: IStatus; - status: TUserStatus; - setStatus: (status: TUserStatus) => void; -}) => { - const { id, name } = statusType; - return ( - <> - { - const key = `STATUS_${id.toUpperCase()}` as keyof typeof events; - logEvent(events[key]); - if (status !== id) { - setStatus(id); - } - }} - testID={`status-view-${id}`} - value={statusType.id} - left={() => } - /> - - - ); -}; - const StatusView = (): ReactElement => { const user = useSelector((state: IApplicationState) => getUserSelector(state)); const isMasterDetail = useMasterDetail(); @@ -135,6 +111,7 @@ const StatusView = (): ReactElement => { const dispatch = useDispatch(); const { setOptions, goBack } = useNavigation(); const { colors } = useTheme(); + const listBackgroundColor = useListBackgroundColor(colors.surfaceTint); const { bottom } = useSafeAreaInsets(); const submit = async () => { @@ -160,8 +137,11 @@ const StatusView = (): ReactElement => { setHeader(); }, [isMasterDetail]); - const setStatus = (updatedStatus: TUserStatus) => { - setValue('status', updatedStatus); + const selectStatus = (id: TUserStatus) => { + logEvent(events[`STATUS_${id.toUpperCase()}` as keyof typeof events]); + if (inputValues.status !== id) { + setValue('status', id); + } }; const setCustomStatus = async (status: TUserStatus, statusText: string, expiresAt?: string | null) => { @@ -209,37 +189,92 @@ const StatusView = (): ReactElement => { if (date) setClearAfterDate(date); }; + const statusInput = ( + + ); + + const statusRowInput = ( + + ); + + const renderStatus = ({ id, name }: IStatus) => ( + selectStatus(id)} + testID={`status-view-${id}`} + value={id} + left={() => } + /> + ); + + const renderFooter = (showClearAfterPicker: boolean) => ( + + ); + + if (isIOS) { + return ( + + + {statusRowInput} + + {statusType.map(status => ( + {renderStatus(status)} + ))} + + {supportsStatusExpiry ? ( + + ) : null} + + {renderFooter(false)} + + ); + } + return ( item.id} - renderItem={({ item }) => } + renderItem={({ item }) => ( + <> + {renderStatus(item)} + + + )} ListHeaderComponent={ <> - + {statusInput} } - ListFooterComponent={ - - } + ListFooterComponent={renderFooter(supportsStatusExpiry)} style={{ backgroundColor: colors.surfaceTint }} contentContainerStyle={{ paddingBottom: bottom }} /> diff --git a/app/views/TeamChannelsView.tsx b/app/views/TeamChannelsView.tsx index 43fbdd2f148..c3c2885686b 100644 --- a/app/views/TeamChannelsView.tsx +++ b/app/views/TeamChannelsView.tsx @@ -3,7 +3,8 @@ import { type NativeStackNavigationOptions } from '@react-navigation/native-stac import { Alert, FlatList, Keyboard, PixelRatio } from 'react-native'; import { connect } from 'react-redux'; import { type EdgeInsets, withSafeAreaInsets } from 'react-native-safe-area-context'; -import { Component } from 'react'; +import { type SearchBarCommands } from 'react-native-screens'; +import { Component, createRef } from 'react'; import { deleteRoom } from '../actions/room'; import { DisplayMode } from '../lib/constants/constantDisplayMode'; @@ -22,6 +23,8 @@ import { withDimensions } from '../lib/hooks/withDimensions'; import { withMasterDetail } from '../lib/hooks/useMasterDetail'; import { BASE_ROW_HEIGHT, BASE_ROW_HEIGHT_CONDENSED } from '../lib/hooks/useResponsiveLayout/useResponsiveLayout'; import I18n from '../i18n'; +import { stackedSearchBarOptions } from '~/lib/methods/helpers/navigation'; +import { headerRightActions, type IHeaderAction } from '~/lib/methods/helpers/navigation/headerActions'; import database from '../lib/database'; import { CustomIcon } from '../containers/CustomIcon'; import RoomItem from '../containers/RoomItem'; @@ -30,7 +33,15 @@ import { withTheme } from '../theme'; import { goRoom } from '../lib/methods/helpers/goRoom'; import { showErrorAlert } from '../lib/methods/helpers/info'; import log, { events, logEvent } from '../lib/methods/helpers/log'; -import { getRoomAvatar, getRoomTitle, hasPermission, debounce, isIOS, compareServerVersion } from '../lib/methods/helpers'; +import { + getRoomAvatar, + getRoomTitle, + hasPermission, + debounce, + hasNativeHeaderBar, + isIOS, + compareServerVersion +} from '../lib/methods/helpers'; import { getRoomInfo, getTeamListRoom, updateTeamRoom, removeTeamRoom } from '../lib/services/restApi'; const API_FETCH_COUNT = 25; @@ -96,6 +107,8 @@ class TeamChannelsView extends Component(); + constructor(props: ITeamChannelsViewProps) { super(props); this.teamChannels = []; @@ -220,7 +233,7 @@ class TeamChannelsView extends Component ( @@ -235,33 +248,56 @@ class TeamChannelsView extends Component ( - - ), - headerRight: () => ( - - {showCreate ? ( - - navigation.navigate('AddChannelTeamView', { teamId: this.teamId, rid: this.team.rid, t: this.team.t as any }) - } - /> - ) : null} - - - ) + const createAction: IHeaderAction = { + label: I18n.t('Add_Channel_to_Team'), + icon: 'create', + testID: 'team-channels-view-create', + onPress: () => navigation.navigate('AddChannelTeamView', { teamId: this.teamId, rid: this.team.rid, t: this.team.t as any }) + }; + const searchAction: IHeaderAction = { + label: I18n.t('Search'), + icon: 'search', + testID: 'team-channels-view-search', + legacyHeaderOnly: true, + onPress: this.onSearchPress }; - navigation.setOptions(options); + navigation.setOptions({ + headerLeft: undefined, + ...(hasNativeHeaderBar + ? { + headerTitle: getRoomTitle(team), + headerSubtitle: team.topic, + onHeaderTitlePress: () => this.goRoomActionsView(), + headerSearchBarOptions: stackedSearchBarOptions({ + ref: this.searchBarRef, + onFocus: this.onSearchPress, + onChangeText: this.onSearchChangeText, + onCancel: this.onCancelSearchPress + }) + } + : { + headerTitle: () => ( + + ) + }), + ...headerRightActions(showCreate ? [createAction, searchAction] : [searchAction]) + }); }; onSearchPress = () => { logEvent(events.TC_SEARCH); - this.setState({ isSearching: true }, () => this.setHeader()); + this.setState({ isSearching: true }, () => { + if (!hasNativeHeaderBar) { + this.setHeader(); + } + }); }; onSearchChangeText = debounce((searchText: string) => { @@ -297,7 +333,10 @@ class TeamChannelsView extends Component { - this.setHeader(); + if (!hasNativeHeaderBar) { + this.setHeader(); + } + this.searchBarRef.current?.clearText(); } ); }; diff --git a/app/views/ThemeView.tsx b/app/views/ThemeView.tsx index a62caf2314e..e32d40d6ce9 100644 --- a/app/views/ThemeView.tsx +++ b/app/views/ThemeView.tsx @@ -1,4 +1,4 @@ -import { type ReactElement, useLayoutEffect } from 'react'; +import { Fragment, type ReactElement, useLayoutEffect } from 'react'; import { useNavigation } from '@react-navigation/native'; import * as List from '../containers/List'; @@ -56,19 +56,6 @@ interface ITheme { group: string; } -const Item = ({ onPress, item, isSelected }: { onPress: () => void; item: ITheme; isSelected: boolean }) => ( - <> - - - -); - const ThemeView = (): ReactElement => { const { themePreferences, setTheme } = useTheme(); const { setOptions } = useNavigation(); @@ -113,24 +100,33 @@ const ThemeView = (): ReactElement => { } }; + const renderThemeItems = (themes: ITheme[]) => ( + <> + {themes.map(theme => ( + + onClick(theme)} + testID={`theme-view-${theme.value}`} + /> + + + ))} + + ); + return ( - <> - {themeGroup.map(theme => ( - onClick(theme)} item={theme} isSelected={!!isSelected(theme)} key={theme.label} /> - ))} - + {renderThemeItems(themeGroup)} - <> - {darkGroup.map(theme => ( - onClick(theme)} item={theme} isSelected={!!isSelected(theme)} key={theme.label} /> - ))} - + {renderThemeItems(darkGroup)} diff --git a/app/views/ThreadMessagesView/index.tsx b/app/views/ThreadMessagesView/index.tsx index 978fc3e1941..9df5a8509a4 100644 --- a/app/views/ThreadMessagesView/index.tsx +++ b/app/views/ThreadMessagesView/index.tsx @@ -5,12 +5,13 @@ import { sanitizedRaw } from '@nozbe/watermelondb/RawRecord'; import { type NativeStackNavigationOptions } from '@react-navigation/native-stack'; import { type Observable, type Subscription } from 'rxjs'; import { type EdgeInsets, withSafeAreaInsets } from 'react-native-safe-area-context'; -import { Component } from 'react'; +import { type SearchBarCommands } from 'react-native-screens'; +import { Component, createRef } from 'react'; -import { showActionSheetRef } from '~/containers/ActionSheet'; -import { CustomIcon } from '~/containers/CustomIcon'; import ActivityIndicator from '~/containers/ActivityIndicator'; import I18n from '~/i18n'; +import { outsideHeaderLeftClose, stackedSearchBarOptions } from '~/lib/methods/helpers/navigation'; +import { headerRightActions } from '~/lib/methods/helpers/navigation/headerActions'; import database from '~/lib/database'; import { sanitizeLikeString } from '~/lib/database/utils'; import buildMessage from '~/lib/methods/helpers/buildMessage'; @@ -38,7 +39,7 @@ import { type TSubscriptionModel, type TThreadModel } from '~/definitions'; -import { getUidDirectMessage, debounce, isIOS } from '~/lib/methods/helpers'; +import { getUidDirectMessage, debounce, hasNativeHeaderBar, isIOS } from '~/lib/methods/helpers'; import { getSyncThreadsList, getThreadsList } from '~/lib/services/restApi'; import { toggleFollowThread as toggleFollowThreadService } from '~/lib/methods/toggleFollowThread'; import UserPreferences from '~/lib/methods/userPreferences'; @@ -80,6 +81,8 @@ class ThreadMessagesView extends Component; + private searchBarRef = createRef(); + constructor(props: IThreadMessagesViewProps) { super(props); this.mounted = false; @@ -119,7 +122,7 @@ class ThreadMessagesView extends Component ( @@ -133,34 +136,38 @@ class ThreadMessagesView extends Component ( - - - currentFilter !== Filter.All ? : null - } - /> - - - ) + ...(hasNativeHeaderBar && { + headerTransparent: true, + headerSearchBarOptions: stackedSearchBarOptions({ + ref: this.searchBarRef, + onFocus: this.onSearchPress, + onChangeText: this.onSearchChangeText, + onCancel: this.onCancelSearchPress + }) + }), + ...(isMasterDetail ? outsideHeaderLeftClose(() => navigation.pop()) : { headerLeft: undefined }), + ...headerRightActions([ + { + label: I18n.t('Filter'), + icon: 'filter', + badge: currentFilter !== Filter.All ? { color: colors[theme].buttonBackgroundDangerDefault } : undefined, + menu: [Filter.All, Filter.Following, Filter.Unread].map(filter => ({ + label: I18n.t(filter), + checked: currentFilter === filter, + onPress: () => this.onFilterSelected(filter) + })) + }, + { + label: I18n.t('Search'), + icon: 'search', + testID: 'thread-messages-view-search-icon', + legacyHeaderOnly: true, + onPress: this.onSearchPress + } + ]) }; - - if (isMasterDetail) { - options.headerLeft = () => ; - } - - return options; }; setHeader = () => { @@ -350,6 +357,8 @@ class ThreadMessagesView extends Component { - this.setState({ isSearching: true }, () => this.setHeader()); + this.setState({ isSearching: true }, () => { + if (!hasNativeHeaderBar) { + this.setHeader(); + } + }); }; onCancelSearchPress = () => { this.setState({ isSearching: false, searchText: '' }, () => { const { subscription } = this.state; - this.setHeader(); + if (!hasNativeHeaderBar) { + this.setHeader(); + } this.subscribeMessages(subscription); + this.searchBarRef.current?.clearText(); }); }; @@ -431,29 +447,6 @@ class ThreadMessagesView extends Component { - const { currentFilter } = this.state; - showActionSheetRef({ - options: [ - { - title: I18n.t(Filter.All), - right: currentFilter === Filter.All ? () => : undefined, - onPress: () => this.onFilterSelected(Filter.All) - }, - { - title: I18n.t(Filter.Following), - right: currentFilter === Filter.Following ? () => : undefined, - onPress: () => this.onFilterSelected(Filter.Following) - }, - { - title: I18n.t(Filter.Unread), - right: currentFilter === Filter.Unread ? () => : undefined, - onPress: () => this.onFilterSelected(Filter.Unread) - } - ] - }); - }; - onFilterSelected = (filter: Filter) => { const { messages, subscription } = this.state; const displayingThreads = this.getFilteredThreads(messages, subscription, filter); @@ -504,13 +497,14 @@ class ThreadMessagesView extends Component : null} scrollIndicatorInsets={{ right: 1 }} // https://github.com/facebook/react-native/issues/26610#issuecomment-539843444 diff --git a/app/views/ThreadMessagesView/styles.ts b/app/views/ThreadMessagesView/styles.ts index 4103e720c17..1de6a01a1ff 100644 --- a/app/views/ThreadMessagesView/styles.ts +++ b/app/views/ThreadMessagesView/styles.ts @@ -16,6 +16,7 @@ export default StyleSheet.create({ ...sharedStyles.textRegular }, contentContainer: { + flexGrow: 1, paddingBottom: 30 } }); diff --git a/app/views/UserNotificationPreferencesView/ListPicker.tsx b/app/views/UserNotificationPreferencesView/ListPicker.tsx index c1b8f0ef464..7906749fba8 100644 --- a/app/views/UserNotificationPreferencesView/ListPicker.tsx +++ b/app/views/UserNotificationPreferencesView/ListPicker.tsx @@ -2,6 +2,9 @@ import { StyleSheet, Text, View } from 'react-native'; import { Fragment, type ReactElement } from 'react'; import * as List from '~/containers/List'; +import { asNativeListRow } from '~/containers/List/native/utils/rowMarkers'; +import { useNativeListMode } from '~/containers/List/native/context'; +import NativeListPicker from '~/containers/List/native/components/Picker'; import I18n from '~/i18n'; import { useTheme } from '~/theme'; import sharedStyles from '../Styles'; @@ -35,6 +38,7 @@ const ListPicker = ({ } & IBaseParams) => { const { showActionSheet, hideActionSheet } = useActionSheet(); const { colors } = useTheme(); + const nativeListMode = useNativeListMode(); const option = value ? OPTIONS[preference].find(option => option.value === value) : OPTIONS[preference][0]; const getOptions = (): ReactElement => ( @@ -60,6 +64,22 @@ const ListPicker = ({ const label = option?.label ? I18n.t(option?.label, { defaultValue: option?.label }) : option?.label; + if (nativeListMode === 'native') { + return ( + ({ + label: I18n.t(i.label, { defaultValue: i.label }), + value: i.value.toString(), + testID: `notification-preferences-${preference}-${i.value}` + }))} + selection={option?.value.toString() ?? ''} + onSelectionChange={selected => onChangeValue({ [preference]: selected })} + /> + ); + } + return ( { const { showActionSheet, hideActionSheet } = useActionSheet(); const { colors } = useTheme(); + const nativeListMode = useNativeListMode(); const [option, setOption] = useState( value ? OPTIONS[preference].find(option => option.value === value) : OPTIONS[preference][0] ); + const selectOption = (selectedValue: string) => { + const previous = option; + onChangeValue({ [preference]: selectedValue }, () => setOption(previous)); + setOption(OPTIONS[preference].find(i => i.value === selectedValue)); + }; + const getOptions = (): TActionSheetOptionsItem[] => OPTIONS[preference].map(i => ({ title: I18n.t(i.label, { defaultValue: i.label }), onPress: () => { hideActionSheet(); - onChangeValue({ [preference]: i.value.toString() }, () => setOption(option)); - setOption(i); + selectOption(i.value); }, right: option?.value === i.value ? () => : undefined })); const label = option?.label ? I18n.t(option?.label, { defaultValue: option?.label }) : option?.label; + if (nativeListMode === 'native') { + return ( + ({ label: I18n.t(i.label, { defaultValue: i.label }), value: i.value }))} + selection={option?.value ?? ''} + onSelectionChange={selectOption} + /> + ); + } + return ( { it('keeps enableMobileRinging enabled while enabling convertAsciiEmoji', async () => { const { getByTestId } = renderWithPreferences({ convertAsciiEmoji: false, enableMobileRinging: true }); - fireEvent(getByTestId('preferences-view-convert-ascii-to-emoji'), 'valueChange', true); + fireEvent(getByTestId('preferences-view-convert-ascii-to-emoji'), 'isOnChange', { nativeEvent: { isOn: true } }); const dispatchedUser = dispatch.mock.calls[0][0].user; expect(dispatchedUser.settings.preferences).toEqual({ @@ -78,7 +78,7 @@ describe('UserPreferencesView', () => { it('keeps enableMobileRinging disabled while disabling convertAsciiEmoji', async () => { const { getByTestId } = renderWithPreferences({ convertAsciiEmoji: true, enableMobileRinging: false }); - fireEvent(getByTestId('preferences-view-convert-ascii-to-emoji'), 'valueChange', false); + fireEvent(getByTestId('preferences-view-convert-ascii-to-emoji'), 'isOnChange', { nativeEvent: { isOn: false } }); const dispatchedUser = dispatch.mock.calls[0][0].user; expect(dispatchedUser.settings.preferences).toEqual({ @@ -91,7 +91,7 @@ describe('UserPreferencesView', () => { it('keeps convertAsciiEmoji enabled while enabling enableMobileRinging', async () => { const { getByTestId } = renderWithPreferences({ convertAsciiEmoji: true, enableMobileRinging: false }); - fireEvent(getByTestId('preferences-view-enable-mobile-ringing'), 'valueChange', true); + fireEvent(getByTestId('preferences-view-enable-mobile-ringing'), 'isOnChange', { nativeEvent: { isOn: true } }); const dispatchedUser = dispatch.mock.calls[0][0].user; expect(dispatchedUser.settings.preferences).toEqual({ @@ -104,7 +104,7 @@ describe('UserPreferencesView', () => { it('keeps convertAsciiEmoji disabled while disabling enableMobileRinging', async () => { const { getByTestId } = renderWithPreferences({ convertAsciiEmoji: false, enableMobileRinging: true }); - fireEvent(getByTestId('preferences-view-enable-mobile-ringing'), 'valueChange', false); + fireEvent(getByTestId('preferences-view-enable-mobile-ringing'), 'isOnChange', { nativeEvent: { isOn: false } }); const dispatchedUser = dispatch.mock.calls[0][0].user; expect(dispatchedUser.settings.preferences).toEqual({ diff --git a/app/views/UserPreferencesView/__tests__/ListPicker.test.tsx b/app/views/UserPreferencesView/__tests__/ListPicker.test.tsx new file mode 100644 index 00000000000..f569f8b53f1 --- /dev/null +++ b/app/views/UserPreferencesView/__tests__/ListPicker.test.tsx @@ -0,0 +1,55 @@ +import { act, render } from '@testing-library/react-native'; + +import { NativeListContext } from '~/containers/List/native/context'; +import { type INativeListPicker } from '~/containers/List/native/types'; +import ListPicker from '../ListPicker'; + +const mockPicker = jest.fn(); +jest.mock('~/containers/List/native/components/Picker', () => ({ + __esModule: true, + default: (props: INativeListPicker) => { + mockPicker(props); + return null; + } +})); + +const latestPicker = (): INativeListPicker => mockPicker.mock.calls[mockPicker.mock.calls.length - 1][0]; + +const renderNative = (onChangeValue: jest.Mock) => + render( + row }}> + + + ); + +describe('UserPreferencesView ListPicker in a native list', () => { + beforeEach(() => mockPicker.mockClear()); + + it('selects the current value and saves a new selection', () => { + const onChangeValue = jest.fn(); + renderNative(onChangeValue); + expect(latestPicker().selection).toBe('always'); + expect(latestPicker().testID).toBe('preferences-view-enable-message-parser'); + + act(() => latestPicker().onSelectionChange('never')); + + expect(onChangeValue).toHaveBeenCalledWith({ alsoSendThreadToChannel: 'never' }, expect.any(Function)); + expect(latestPicker().selection).toBe('never'); + }); + + it('restores the previous value when saving fails', () => { + const onChangeValue = jest.fn(); + renderNative(onChangeValue); + + act(() => latestPicker().onSelectionChange('never')); + act(() => onChangeValue.mock.calls[0][1]()); + + expect(latestPicker().selection).toBe('always'); + }); +}); diff --git a/ios/AppIcon.icon/Assets/Union2.svg b/ios/AppIcon.icon/Assets/Union2.svg new file mode 100644 index 00000000000..d026fb0b915 --- /dev/null +++ b/ios/AppIcon.icon/Assets/Union2.svg @@ -0,0 +1,3 @@ + + + diff --git a/ios/AppIcon.icon/icon.json b/ios/AppIcon.icon/icon.json new file mode 100644 index 00000000000..af29e1e3486 --- /dev/null +++ b/ios/AppIcon.icon/icon.json @@ -0,0 +1,68 @@ +{ + "fill" : { + "linear-gradient" : [ + "display-p3:1.06272,0.30074,0.40032,1.00000", + "display-p3:0.96078,0.27059,0.36078,1.00000" + ] + }, + "groups" : [ + { + "layers" : [ + { + "blend-mode" : "plus-lighter", + "fill-specializations" : [ + { + "value" : { + "linear-gradient" : [ + "display-p3:1.00000,1.00000,1.00000,1.00000", + "srgb:1.00000,1.00000,1.00000,0.80000" + ], + "orientation" : { + "start" : { + "x" : 0.8502197265625, + "y" : 0.04061830677339003 + }, + "stop" : { + "x" : 0.3342950994318182, + "y" : 1 + } + } + } + }, + { + "appearance" : "tinted", + "value" : { + "automatic-gradient" : "extended-srgb:0.00000,0.53333,1.00000,1.00000" + } + } + ], + "glass" : true, + "image-name" : "Union2.svg", + "name" : "Union2", + "opacity" : 0.95, + "position" : { + "scale" : 2, + "translation-in-points" : [ + 5, + -3.0004841536889444 + ] + } + } + ], + "shadow" : { + "kind" : "neutral", + "opacity" : 0.5 + }, + "translucency" : { + "enabled" : true, + "value" : 0.3 + } + } + ], + "supported-platforms" : { + "circles" : [ + "watchOS" + ], + "squares" : "shared" + } +} \ No newline at end of file diff --git a/ios/Assets.xcassets/AppIcon.appiconset/100.png b/ios/Assets.xcassets/AppIcon.appiconset/100.png deleted file mode 100644 index 7f24a267978..00000000000 Binary files a/ios/Assets.xcassets/AppIcon.appiconset/100.png and /dev/null differ diff --git a/ios/Assets.xcassets/AppIcon.appiconset/1024 1.png b/ios/Assets.xcassets/AppIcon.appiconset/1024 1.png deleted file mode 100644 index e737a61ea6f..00000000000 Binary files a/ios/Assets.xcassets/AppIcon.appiconset/1024 1.png and /dev/null differ diff --git a/ios/Assets.xcassets/AppIcon.appiconset/1024.png b/ios/Assets.xcassets/AppIcon.appiconset/1024.png deleted file mode 100644 index e737a61ea6f..00000000000 Binary files a/ios/Assets.xcassets/AppIcon.appiconset/1024.png and /dev/null differ diff --git a/ios/Assets.xcassets/AppIcon.appiconset/114.png b/ios/Assets.xcassets/AppIcon.appiconset/114.png deleted file mode 100644 index 7b5b7e31f07..00000000000 Binary files a/ios/Assets.xcassets/AppIcon.appiconset/114.png and /dev/null differ diff --git a/ios/Assets.xcassets/AppIcon.appiconset/120.png b/ios/Assets.xcassets/AppIcon.appiconset/120.png deleted file mode 100644 index 2db6152265b..00000000000 Binary files a/ios/Assets.xcassets/AppIcon.appiconset/120.png and /dev/null differ diff --git a/ios/Assets.xcassets/AppIcon.appiconset/144.png b/ios/Assets.xcassets/AppIcon.appiconset/144.png deleted file mode 100644 index 9e4461b531f..00000000000 Binary files a/ios/Assets.xcassets/AppIcon.appiconset/144.png and /dev/null differ diff --git a/ios/Assets.xcassets/AppIcon.appiconset/152.png b/ios/Assets.xcassets/AppIcon.appiconset/152.png deleted file mode 100644 index ece9c43b4ed..00000000000 Binary files a/ios/Assets.xcassets/AppIcon.appiconset/152.png and /dev/null differ diff --git a/ios/Assets.xcassets/AppIcon.appiconset/167.png b/ios/Assets.xcassets/AppIcon.appiconset/167.png deleted file mode 100644 index c10c5fae9ab..00000000000 Binary files a/ios/Assets.xcassets/AppIcon.appiconset/167.png and /dev/null differ diff --git a/ios/Assets.xcassets/AppIcon.appiconset/180.png b/ios/Assets.xcassets/AppIcon.appiconset/180.png deleted file mode 100644 index e883fea3111..00000000000 Binary files a/ios/Assets.xcassets/AppIcon.appiconset/180.png and /dev/null differ diff --git a/ios/Assets.xcassets/AppIcon.appiconset/20.png b/ios/Assets.xcassets/AppIcon.appiconset/20.png deleted file mode 100644 index 580f31abf68..00000000000 Binary files a/ios/Assets.xcassets/AppIcon.appiconset/20.png and /dev/null differ diff --git a/ios/Assets.xcassets/AppIcon.appiconset/29.png b/ios/Assets.xcassets/AppIcon.appiconset/29.png deleted file mode 100644 index 075803b018d..00000000000 Binary files a/ios/Assets.xcassets/AppIcon.appiconset/29.png and /dev/null differ diff --git a/ios/Assets.xcassets/AppIcon.appiconset/40.png b/ios/Assets.xcassets/AppIcon.appiconset/40.png deleted file mode 100644 index cc301e7db69..00000000000 Binary files a/ios/Assets.xcassets/AppIcon.appiconset/40.png and /dev/null differ diff --git a/ios/Assets.xcassets/AppIcon.appiconset/50.png b/ios/Assets.xcassets/AppIcon.appiconset/50.png deleted file mode 100644 index 4b68ad4618e..00000000000 Binary files a/ios/Assets.xcassets/AppIcon.appiconset/50.png and /dev/null differ diff --git a/ios/Assets.xcassets/AppIcon.appiconset/57.png b/ios/Assets.xcassets/AppIcon.appiconset/57.png deleted file mode 100644 index 5af4cfb70a5..00000000000 Binary files a/ios/Assets.xcassets/AppIcon.appiconset/57.png and /dev/null differ diff --git a/ios/Assets.xcassets/AppIcon.appiconset/58.png b/ios/Assets.xcassets/AppIcon.appiconset/58.png deleted file mode 100644 index e9c3392d48e..00000000000 Binary files a/ios/Assets.xcassets/AppIcon.appiconset/58.png and /dev/null differ diff --git a/ios/Assets.xcassets/AppIcon.appiconset/60.png b/ios/Assets.xcassets/AppIcon.appiconset/60.png deleted file mode 100644 index 57618b80daa..00000000000 Binary files a/ios/Assets.xcassets/AppIcon.appiconset/60.png and /dev/null differ diff --git a/ios/Assets.xcassets/AppIcon.appiconset/72.png b/ios/Assets.xcassets/AppIcon.appiconset/72.png deleted file mode 100644 index b539994906f..00000000000 Binary files a/ios/Assets.xcassets/AppIcon.appiconset/72.png and /dev/null differ diff --git a/ios/Assets.xcassets/AppIcon.appiconset/76.png b/ios/Assets.xcassets/AppIcon.appiconset/76.png deleted file mode 100644 index caef08f4d24..00000000000 Binary files a/ios/Assets.xcassets/AppIcon.appiconset/76.png and /dev/null differ diff --git a/ios/Assets.xcassets/AppIcon.appiconset/80.png b/ios/Assets.xcassets/AppIcon.appiconset/80.png deleted file mode 100644 index 1b956d3da49..00000000000 Binary files a/ios/Assets.xcassets/AppIcon.appiconset/80.png and /dev/null differ diff --git a/ios/Assets.xcassets/AppIcon.appiconset/87.png b/ios/Assets.xcassets/AppIcon.appiconset/87.png deleted file mode 100644 index 17381abce12..00000000000 Binary files a/ios/Assets.xcassets/AppIcon.appiconset/87.png and /dev/null differ diff --git a/ios/Assets.xcassets/AppIcon.appiconset/Contents.json b/ios/Assets.xcassets/AppIcon.appiconset/Contents.json deleted file mode 100644 index cdcbf4fd039..00000000000 --- a/ios/Assets.xcassets/AppIcon.appiconset/Contents.json +++ /dev/null @@ -1,164 +0,0 @@ -{ - "images" : [ - { - "filename" : "40.png", - "idiom" : "iphone", - "scale" : "2x", - "size" : "20x20" - }, - { - "filename" : "60.png", - "idiom" : "iphone", - "scale" : "3x", - "size" : "20x20" - }, - { - "filename" : "29.png", - "idiom" : "iphone", - "scale" : "1x", - "size" : "29x29" - }, - { - "filename" : "58.png", - "idiom" : "iphone", - "scale" : "2x", - "size" : "29x29" - }, - { - "filename" : "87.png", - "idiom" : "iphone", - "scale" : "3x", - "size" : "29x29" - }, - { - "filename" : "80.png", - "idiom" : "iphone", - "scale" : "2x", - "size" : "40x40" - }, - { - "filename" : "120.png", - "idiom" : "iphone", - "scale" : "3x", - "size" : "40x40" - }, - { - "filename" : "57.png", - "idiom" : "iphone", - "scale" : "1x", - "size" : "57x57" - }, - { - "filename" : "114.png", - "idiom" : "iphone", - "scale" : "2x", - "size" : "57x57" - }, - { - "filename" : "120.png", - "idiom" : "iphone", - "scale" : "2x", - "size" : "60x60" - }, - { - "filename" : "180.png", - "idiom" : "iphone", - "scale" : "3x", - "size" : "60x60" - }, - { - "filename" : "20.png", - "idiom" : "ipad", - "scale" : "1x", - "size" : "20x20" - }, - { - "filename" : "40.png", - "idiom" : "ipad", - "scale" : "2x", - "size" : "20x20" - }, - { - "filename" : "29.png", - "idiom" : "ipad", - "scale" : "1x", - "size" : "29x29" - }, - { - "filename" : "58.png", - "idiom" : "ipad", - "scale" : "2x", - "size" : "29x29" - }, - { - "filename" : "40.png", - "idiom" : "ipad", - "scale" : "1x", - "size" : "40x40" - }, - { - "filename" : "80.png", - "idiom" : "ipad", - "scale" : "2x", - "size" : "40x40" - }, - { - "filename" : "50.png", - "idiom" : "ipad", - "scale" : "1x", - "size" : "50x50" - }, - { - "filename" : "100.png", - "idiom" : "ipad", - "scale" : "2x", - "size" : "50x50" - }, - { - "filename" : "72.png", - "idiom" : "ipad", - "scale" : "1x", - "size" : "72x72" - }, - { - "filename" : "144.png", - "idiom" : "ipad", - "scale" : "2x", - "size" : "72x72" - }, - { - "filename" : "76.png", - "idiom" : "ipad", - "scale" : "1x", - "size" : "76x76" - }, - { - "filename" : "152.png", - "idiom" : "ipad", - "scale" : "2x", - "size" : "76x76" - }, - { - "filename" : "167.png", - "idiom" : "ipad", - "scale" : "2x", - "size" : "83.5x83.5" - }, - { - "filename" : "1024.png", - "idiom" : "ios-marketing", - "scale" : "1x", - "size" : "1024x1024" - }, - { - "filename" : "1024 1.png", - "idiom" : "universal", - "platform" : "watchos", - "size" : "1024x1024" - } - ], - "info" : { - "author" : "xcode", - "version" : 1 - } -} diff --git a/ios/Podfile.lock b/ios/Podfile.lock index 26f8830bd9c..f64e59987bb 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -131,6 +131,10 @@ PODS: - ExpoModulesCore - ExpoSystemUI (57.0.4): - ExpoModulesCore + - ExpoUI (57.0.19): + - ExpoModulesCore + - ExpoModulesWorklets + - React-RCTFabric - ExpoVideo (57.0.4): - ExpoModulesCore - ExpoVideoThumbnails (57.0.2): @@ -3021,6 +3025,7 @@ DEPENDENCIES: - ExpoNotifications (from `../node_modules/expo-notifications/ios`) - "ExpoQuickLook (from `../node_modules/@magrinj/expo-quick-look/ios`)" - ExpoSystemUI (from `../node_modules/expo-system-ui/ios`) + - "ExpoUI (from `../node_modules/@expo/ui/ios`)" - ExpoVideo (from `../node_modules/expo-video/ios`) - ExpoVideoThumbnails (from `../node_modules/expo-video-thumbnails/ios`) - ExpoWebBrowser (from `../node_modules/expo-web-browser/ios`) @@ -3224,6 +3229,8 @@ EXTERNAL SOURCES: :path: "../node_modules/@magrinj/expo-quick-look/ios" ExpoSystemUI: :path: "../node_modules/expo-system-ui/ios" + ExpoUI: + :path: "../node_modules/@expo/ui/ios" ExpoVideo: :path: "../node_modules/expo-video/ios" ExpoVideoThumbnails: @@ -3482,6 +3489,7 @@ SPEC CHECKSUMS: ExpoNotifications: 098f80e610228da37ee98c0828931337cf5b1106 ExpoQuickLook: 7ac65cd559a74619ea41e13df20e9d7a61e79ea6 ExpoSystemUI: 05ee8d4aaba1b6af330f3621e19b39ff9f21e718 + ExpoUI: 98d4650d9d32daaf772597adc76525c645b1ff76 ExpoVideo: 56ed2863b981dd5ae946d8ef23ecfcae855863a0 ExpoVideoThumbnails: da5d471c170bf4110f681b6649524eace9c95b5b ExpoWebBrowser: a4d7c382116fbe33c1ec7f60293f02b7e97042e4 diff --git a/ios/RocketChat.xcodeproj/project.pbxproj b/ios/RocketChat.xcodeproj/project.pbxproj index e6ac5df9fba..5bcbf37df13 100644 --- a/ios/RocketChat.xcodeproj/project.pbxproj +++ b/ios/RocketChat.xcodeproj/project.pbxproj @@ -143,6 +143,7 @@ 65B9A71B2AFC24190088956F /* ringtone.mp3 in Resources */ = {isa = PBXBuildFile; fileRef = 65B9A7192AFC24190088956F /* ringtone.mp3 */; }; 66C2701C2EBBCB570062725F /* MMKVKeyManager.mm in Sources */ = {isa = PBXBuildFile; fileRef = 66C2701A2EBBCB570062725F /* MMKVKeyManager.mm */; }; 66C2701D2EBBCB570062725F /* MMKVKeyManager.mm in Sources */ = {isa = PBXBuildFile; fileRef = 66C2701A2EBBCB570062725F /* MMKVKeyManager.mm */; }; + 6F204E445C3D44699793D4F8 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 680FA18DB475479CAD48F334 /* SceneDelegate.swift */; }; 6FA7E2392F28A9D300A1D45E /* ExternalInputModule.m in Sources */ = {isa = PBXBuildFile; fileRef = 6FA7E2372F28A9D300A1D45E /* ExternalInputModule.m */; }; 782E0520ED599FF8DDC2318F /* Pods_defaults_Rocket_Chat.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 88ED4E003393D3A9256F4EB3 /* Pods_defaults_Rocket_Chat.framework */; }; 79D8C97F8CE2EC1B6882826B /* SecureStorage.m in Sources */ = {isa = PBXBuildFile; fileRef = 9B215A42CFB843397273C7EA /* SecureStorage.m */; }; @@ -215,6 +216,8 @@ 7AE10C0828A59530003593CB /* Inter.ttf in Resources */ = {isa = PBXBuildFile; fileRef = 7AE10C0528A59530003593CB /* Inter.ttf */; }; 7AVR00022F5F5900002A6BDE /* VoipRegion.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7AVR00002F5F5900002A6BDE /* VoipRegion.swift */; }; 85160EB6C143E0493FE5F014 /* ExpoModulesProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 194D9A8897F4A486C2C6F89A /* ExpoModulesProvider.swift */; }; + A1C0E0022F90000000C0FFEE /* AppIcon.icon in Resources */ = {isa = PBXBuildFile; fileRef = A1C0E0012F90000000C0FFEE /* AppIcon.icon */; }; + A1C0E0032F90000000C0FFEE /* AppIcon.icon in Resources */ = {isa = PBXBuildFile; fileRef = A1C0E0012F90000000C0FFEE /* AppIcon.icon */; }; A48B46DA2D3FFBD200945489 /* A11yFlowModule.m in Sources */ = {isa = PBXBuildFile; fileRef = A48B46D82D3FFBD200945489 /* A11yFlowModule.m */; }; B1E1F0022F03A00000EXT002 /* ExternalInputModule.m in Sources */ = {isa = PBXBuildFile; fileRef = B1E1F0042F03A00000EXT004 /* ExternalInputModule.m */; }; B1E1F0062F03A00000EXT006 /* GameController.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = B1E1F0072F03A00000EXT007 /* GameController.framework */; }; @@ -429,6 +432,7 @@ 65B9A7192AFC24190088956F /* ringtone.mp3 */ = {isa = PBXFileReference; lastKnownFileType = audio.mp3; path = ringtone.mp3; sourceTree = ""; }; 66C270192EBBCB570062725F /* MMKVKeyManager.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = MMKVKeyManager.h; sourceTree = ""; }; 66C2701A2EBBCB570062725F /* MMKVKeyManager.mm */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.cpp.objcpp; path = MMKVKeyManager.mm; sourceTree = ""; }; + 680FA18DB475479CAD48F334 /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = ""; }; 6FA7E2362F28A9D300A1D45E /* ExternalInputModule.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = ExternalInputModule.h; sourceTree = ""; }; 6FA7E2372F28A9D300A1D45E /* ExternalInputModule.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = ExternalInputModule.m; sourceTree = ""; }; 7A0000032F1BAFA700B6B4BD /* VoipService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VoipService.swift; sourceTree = ""; }; @@ -461,6 +465,7 @@ 9BE2F3DC02A264F204E3EDE3 /* DDPClient.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = DDPClient.swift; sourceTree = ""; }; 9E19E897C464DE3A91E1DAFD /* Pods-defaults-NotificationService.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-defaults-NotificationService.debug.xcconfig"; path = "Target Support Files/Pods-defaults-NotificationService/Pods-defaults-NotificationService.debug.xcconfig"; sourceTree = ""; }; 9F66BEF98C5CF2CFDD4B4C87 /* Pods-defaults-RocketChatRN.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-defaults-RocketChatRN.release.xcconfig"; path = "Target Support Files/Pods-defaults-RocketChatRN/Pods-defaults-RocketChatRN.release.xcconfig"; sourceTree = ""; }; + A1C0E0012F90000000C0FFEE /* AppIcon.icon */ = {isa = PBXFileReference; lastKnownFileType = folder.iconcomposer.icon; path = AppIcon.icon; sourceTree = ""; }; A48B46D72D3FFBD200945489 /* A11yFlowModule.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = A11yFlowModule.h; sourceTree = ""; }; A48B46D82D3FFBD200945489 /* A11yFlowModule.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = A11yFlowModule.m; sourceTree = ""; }; B179038FDD7AAF285047814B /* SecureStorage.h */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.c.h; path = SecureStorage.h; sourceTree = ""; }; @@ -530,6 +535,7 @@ 008F07F21AC5B25A0029DE68 /* main.jsbundle */, 13B07FB51A68108700A75B9A /* Images.xcassets */, 7A14FCF3257FEB59005BDCD4 /* Assets.xcassets */, + A1C0E0012F90000000C0FFEE /* AppIcon.icon */, 13B07FB61A68108700A75B9A /* Info.plist */, 7AAA749C23043B1D00F1ADE9 /* RocketChatRN-Bridging-Header.h */, 7A0D62D1242AB187006D5C06 /* LaunchScreen.storyboard */, @@ -1199,6 +1205,7 @@ buildActionMask = 2147483647; files = ( 1ED1ECEA2B869A4A00F6620C /* Assets.xcassets in Resources */, + A1C0E0032F90000000C0FFEE /* AppIcon.icon in Resources */, 1ED038982B507B4D00C007D4 /* Preview Assets.xcassets in Resources */, 1E044F992B92798E00BCA2FD /* Localizable.xcstrings in Resources */, 65AD383B2BFBDF4A00271B39 /* PrivacyInfo.xcprivacy in Resources */, @@ -1230,6 +1237,7 @@ files = ( 65B9A71B2AFC24190088956F /* ringtone.mp3 in Resources */, 7A14FCF4257FEB59005BDCD4 /* Assets.xcassets in Resources */, + A1C0E0022F90000000C0FFEE /* AppIcon.icon in Resources */, 7A610CD527ECE38100B8ABDD /* custom.ttf in Resources */, 7AAB3E42257E6A6E00707CF6 /* Images.xcassets in Resources */, 7AE10C0828A59530003593CB /* Inter.ttf in Resources */, diff --git a/ios/RocketChatRN/Info.plist b/ios/RocketChatRN/Info.plist index f10b9aacea1..40de41abf13 100644 --- a/ios/RocketChatRN/Info.plist +++ b/ios/RocketChatRN/Info.plist @@ -104,6 +104,8 @@ fetch voip + UIDesignRequiresCompatibility + UILaunchStoryboardName LaunchScreen UIRequiredDeviceCapabilities diff --git a/ios/ShareRocketChatRN/Info.plist b/ios/ShareRocketChatRN/Info.plist index d34ec27123c..da354802217 100644 --- a/ios/ShareRocketChatRN/Info.plist +++ b/ios/ShareRocketChatRN/Info.plist @@ -67,6 +67,8 @@ custom.ttf Inter.ttf + UIDesignRequiresCompatibility + bugsnag apiKey diff --git a/package.json b/package.json index 96cf9e39a58..37b40793b12 100644 --- a/package.json +++ b/package.json @@ -27,6 +27,7 @@ }, "dependencies": { "@bugsnag/react-native": "8.4.0", + "@expo/ui": "~57.0.19", "@expo/vector-icons": "^15.0.3", "@hookform/resolvers": "^2.9.10", "@lodev09/react-native-true-sheet": "~3.7.0", @@ -55,6 +56,7 @@ "@rocket.chat/mobile-crypto": "RocketChat/rocket.chat-mobile-crypto#main", "@rocket.chat/sdk": "RocketChat/Rocket.Chat.js.SDK#ecf4fedcf8ab6121e62a6b95bb48823afd805afd", "@rocket.chat/ui-kit": "^0.39.0", + "@shopify/flash-list": "2.3.2", "@zoontek/react-native-navigation-bar": "^1.1.1", "axios": "0.30.3", "bytebuffer": "~5.0.1", diff --git a/patches/@react-navigation+native-stack+7.19.2.patch b/patches/@react-navigation+native-stack+7.19.2.patch new file mode 100644 index 00000000000..cca22e78ec9 --- /dev/null +++ b/patches/@react-navigation+native-stack+7.19.2.patch @@ -0,0 +1,279 @@ +diff --git a/node_modules/@react-navigation/native-stack/lib/module/views/useHeaderConfigProps.js b/node_modules/@react-navigation/native-stack/lib/module/views/useHeaderConfigProps.js +index a56cb12..3b65c3a 100644 +--- a/node_modules/@react-navigation/native-stack/lib/module/views/useHeaderConfigProps.js ++++ b/node_modules/@react-navigation/native-stack/lib/module/views/useHeaderConfigProps.js +@@ -149,7 +149,13 @@ export function useHeaderConfigProps({ + headerStyle, + headerBlurEffect, + headerTintColor, ++ headerSubtitle, ++ headerSubtitleImageSource, ++ headerSubtitleColor, + headerTitle, ++ headerTitleImageSource, ++ onHeaderTitlePress, ++ headerTitleTestID, + headerTitleAlign, + headerTitleStyle, + headerTransparent, +@@ -230,6 +236,15 @@ export function useHeaderConfigProps({ + tintColor, + children: titleText + }) : null; ++ if (process.env.NODE_ENV !== 'production') { ++ if (headerTitleElement != null && headerSubtitle != null) { ++ console.warn(`'headerSubtitle' is ignored when 'headerTitle' renders a custom element for the screen '${route.name}'. Remove 'headerSubtitle' or use a string 'headerTitle' instead.`); ++ } ++ } ++ const subtitleText = headerTitleElement != null ? undefined : headerSubtitle; ++ const titleImageSource = headerTitleElement != null ? undefined : headerTitleImageSource; ++ const subtitleImageSource = headerTitleElement != null ? undefined : headerSubtitleImageSource; ++ const titlePress = headerTitleElement != null ? undefined : onHeaderTitlePress; + const supportsHeaderSearchBar = typeof isSearchBarAvailableForCurrentPlatform === 'boolean' ? isSearchBarAvailableForCurrentPlatform : + // Fallback for older versions of react-native-screens + Platform.OS === 'ios' && SearchBar != null; +@@ -356,7 +371,14 @@ export function useHeaderConfigProps({ + largeTitleFontSize, + largeTitleFontWeight, + largeTitleHideShadow: headerLargeTitleShadowVisible === false, ++ subtitle: subtitleText, ++ subtitleImageSource, ++ subtitleColor: headerSubtitleColor, + title: titleText, ++ titleImageSource, ++ titlePressEnabled: titlePress != null, ++ onTitlePress: titlePress, ++ titleTestID: headerTitleTestID, + titleColor, + titleFontFamily, + titleFontSize, +diff --git a/node_modules/@react-navigation/native-stack/lib/typescript/src/types.d.ts b/node_modules/@react-navigation/native-stack/lib/typescript/src/types.d.ts +index c32d1da..c011b9b 100644 +--- a/node_modules/@react-navigation/native-stack/lib/typescript/src/types.d.ts ++++ b/node_modules/@react-navigation/native-stack/lib/typescript/src/types.d.ts +@@ -321,6 +321,74 @@ export type NativeStackNavigationOptions = { + */ + tintColor?: string; + }) => React.ReactNode); ++ /** ++ * Image rendered as a leading glyph before the header title. ++ * ++ * Ignored when `headerTitle` renders a custom element. ++ * ++ * Only supported on iOS. ++ * ++ * @platform ios ++ * ++ * @supported iOS 26 or higher ++ */ ++ headerTitleImageSource?: ImageSourcePropType; ++ /** ++ * String to display in the header underneath the title. ++ * ++ * Ignored when `headerTitle` renders a custom element. ++ * ++ * Only supported on iOS. ++ * ++ * @platform ios ++ * ++ * @supported iOS 26 or higher ++ */ ++ headerSubtitle?: string; ++ /** ++ * Image rendered as a leading glyph before the header subtitle. ++ * ++ * Ignored when `headerTitle` renders a custom element. ++ * ++ * Only supported on iOS. ++ * ++ * @platform ios ++ * ++ * @supported iOS 26 or higher ++ */ ++ headerSubtitleImageSource?: ImageSourcePropType; ++ /** ++ * Color of the header subtitle text. Defaults to the title color. ++ * ++ * Only supported on iOS. ++ * ++ * @platform ios ++ * ++ * @supported iOS 26 or higher ++ */ ++ headerSubtitleColor?: ColorValue; ++ /** ++ * Called when the native header title or subtitle is tapped. ++ * ++ * Ignored when `headerTitle` renders a custom element. ++ * ++ * Only supported on iOS. ++ * ++ * @platform ios ++ * ++ * @supported iOS 26 or higher ++ */ ++ onHeaderTitlePress?: () => void; ++ /** ++ * Accessibility identifier of the native header title, for e2e tests. ++ * ++ * Only supported on iOS. ++ * ++ * @platform ios ++ * ++ * @supported iOS 26 or higher ++ */ ++ headerTitleTestID?: string; + /** + * How to align the the header title. + * Defaults to `left` on platforms other than iOS. +@@ -937,6 +1005,7 @@ export type NativeStackHeaderItemButton = SharedHeaderItem & { + * Read more: https://developer.apple.com/documentation/uikit/uibarbuttonitem/isselected + */ + selected?: boolean; ++ showsLabelWithIcon?: boolean; + }; + /** + * An action item in a menu. +diff --git a/node_modules/@react-navigation/native-stack/src/types.tsx b/node_modules/@react-navigation/native-stack/src/types.tsx +index aeb4939..3e02d47 100644 +--- a/node_modules/@react-navigation/native-stack/src/types.tsx ++++ b/node_modules/@react-navigation/native-stack/src/types.tsx +@@ -371,6 +371,74 @@ export type NativeStackNavigationOptions = { + */ + tintColor?: string; + }) => React.ReactNode); ++ /** ++ * Image rendered as a leading glyph before the header title. ++ * ++ * Ignored when `headerTitle` renders a custom element. ++ * ++ * Only supported on iOS. ++ * ++ * @platform ios ++ * ++ * @supported iOS 26 or higher ++ */ ++ headerTitleImageSource?: ImageSourcePropType; ++ /** ++ * String to display in the header underneath the title. ++ * ++ * Ignored when `headerTitle` renders a custom element. ++ * ++ * Only supported on iOS. ++ * ++ * @platform ios ++ * ++ * @supported iOS 26 or higher ++ */ ++ headerSubtitle?: string; ++ /** ++ * Image rendered as a leading glyph before the header subtitle. ++ * ++ * Ignored when `headerTitle` renders a custom element. ++ * ++ * Only supported on iOS. ++ * ++ * @platform ios ++ * ++ * @supported iOS 26 or higher ++ */ ++ headerSubtitleImageSource?: ImageSourcePropType; ++ /** ++ * Color of the header subtitle text. Defaults to the title color. ++ * ++ * Only supported on iOS. ++ * ++ * @platform ios ++ * ++ * @supported iOS 26 or higher ++ */ ++ headerSubtitleColor?: ColorValue; ++ /** ++ * Called when the native header title or subtitle is tapped. ++ * ++ * Ignored when `headerTitle` renders a custom element. ++ * ++ * Only supported on iOS. ++ * ++ * @platform ios ++ * ++ * @supported iOS 26 or higher ++ */ ++ onHeaderTitlePress?: () => void; ++ /** ++ * Accessibility identifier of the native header title, for e2e tests. ++ * ++ * Only supported on iOS. ++ * ++ * @platform ios ++ * ++ * @supported iOS 26 or higher ++ */ ++ headerTitleTestID?: string; + /** + * How to align the the header title. + * Defaults to `left` on platforms other than iOS. +@@ -994,6 +1062,7 @@ export type NativeStackHeaderItemButton = SharedHeaderItem & { + * Read more: https://developer.apple.com/documentation/uikit/uibarbuttonitem/isselected + */ + selected?: boolean; ++ showsLabelWithIcon?: boolean; + }; + + /** +diff --git a/node_modules/@react-navigation/native-stack/src/views/useHeaderConfigProps.tsx b/node_modules/@react-navigation/native-stack/src/views/useHeaderConfigProps.tsx +index a7e3004..180964c 100644 +--- a/node_modules/@react-navigation/native-stack/src/views/useHeaderConfigProps.tsx ++++ b/node_modules/@react-navigation/native-stack/src/views/useHeaderConfigProps.tsx +@@ -210,7 +210,13 @@ export function useHeaderConfigProps({ + headerStyle, + headerBlurEffect, + headerTintColor, ++ headerSubtitle, ++ headerSubtitleImageSource, ++ headerSubtitleColor, + headerTitle, ++ headerTitleImageSource, ++ onHeaderTitlePress, ++ headerTitleTestID, + headerTitleAlign, + headerTitleStyle, + headerTransparent, +@@ -330,6 +336,22 @@ export function useHeaderConfigProps({ + }) + : null; + ++ if (process.env.NODE_ENV !== 'production') { ++ if (headerTitleElement != null && headerSubtitle != null) { ++ console.warn( ++ `'headerSubtitle' is ignored when 'headerTitle' renders a custom element for the screen '${route.name}'. Remove 'headerSubtitle' or use a string 'headerTitle' instead.` ++ ); ++ } ++ } ++ ++ const subtitleText = headerTitleElement != null ? undefined : headerSubtitle; ++ const titleImageSource = ++ headerTitleElement != null ? undefined : headerTitleImageSource; ++ const subtitleImageSource = ++ headerTitleElement != null ? undefined : headerSubtitleImageSource; ++ const titlePress = ++ headerTitleElement != null ? undefined : onHeaderTitlePress; ++ + const supportsHeaderSearchBar = + typeof isSearchBarAvailableForCurrentPlatform === 'boolean' + ? isSearchBarAvailableForCurrentPlatform +@@ -522,7 +544,14 @@ export function useHeaderConfigProps({ + largeTitleFontSize, + largeTitleFontWeight, + largeTitleHideShadow: headerLargeTitleShadowVisible === false, ++ subtitle: subtitleText, ++ subtitleImageSource, ++ subtitleColor: headerSubtitleColor, + title: titleText, ++ titleImageSource, ++ titlePressEnabled: titlePress != null, ++ onTitlePress: titlePress, ++ titleTestID: headerTitleTestID, + titleColor, + titleFontFamily, + titleFontSize, diff --git a/patches/react-native-screens+4.28.0.patch b/patches/react-native-screens+4.28.0.patch new file mode 100644 index 00000000000..770f7cb0186 --- /dev/null +++ b/patches/react-native-screens+4.28.0.patch @@ -0,0 +1,1093 @@ +diff --git a/node_modules/react-native-screens/android/src/main/java/com/swmansion/rnscreens/ScreenStackHeaderConfigViewManager.kt b/node_modules/react-native-screens/android/src/main/java/com/swmansion/rnscreens/ScreenStackHeaderConfigViewManager.kt +index fa1b773..8fe75d7 100644 +--- a/node_modules/react-native-screens/android/src/main/java/com/swmansion/rnscreens/ScreenStackHeaderConfigViewManager.kt ++++ b/node_modules/react-native-screens/android/src/main/java/com/swmansion/rnscreens/ScreenStackHeaderConfigViewManager.kt +@@ -3,6 +3,7 @@ package com.swmansion.rnscreens + import android.view.View + import com.facebook.react.bridge.JSApplicationCausedNativeException + import com.facebook.react.bridge.ReadableArray ++import com.facebook.react.bridge.ReadableMap + import com.facebook.react.module.annotations.ReactModule + import com.facebook.react.uimanager.ReactStylesDiffMap + import com.facebook.react.uimanager.StateWrapper +@@ -236,6 +237,36 @@ class ScreenStackHeaderConfigViewManager : + value: String?, + ) = Unit + ++ override fun setSubtitle( ++ view: ScreenStackHeaderConfig?, ++ value: String?, ++ ) = Unit ++ ++ override fun setTitleImageSource( ++ view: ScreenStackHeaderConfig?, ++ value: ReadableMap?, ++ ) = Unit ++ ++ override fun setSubtitleImageSource( ++ view: ScreenStackHeaderConfig?, ++ value: ReadableMap?, ++ ) = Unit ++ ++ override fun setSubtitleColor( ++ view: ScreenStackHeaderConfig?, ++ value: Int?, ++ ) = Unit ++ ++ override fun setTitlePressEnabled( ++ view: ScreenStackHeaderConfig?, ++ value: Boolean, ++ ) = Unit ++ ++ override fun setTitleTestID( ++ view: ScreenStackHeaderConfig?, ++ value: String?, ++ ) = Unit ++ + override fun setBackTitleFontFamily( + view: ScreenStackHeaderConfig?, + value: String?, +diff --git a/node_modules/react-native-screens/ios/helpers/image/RNSImageLoadingHelper.mm b/node_modules/react-native-screens/ios/helpers/image/RNSImageLoadingHelper.mm +index 64beb49..14bb54a 100644 +--- a/node_modules/react-native-screens/ios/helpers/image/RNSImageLoadingHelper.mm ++++ b/node_modules/react-native-screens/ios/helpers/image/RNSImageLoadingHelper.mm +@@ -13,6 +13,15 @@ + RCTImageSource *imageSource = [RCTConvert RCTImageSource:jsonImageSource]; + RCTAssert(imageSource != nil, @"[RNScreens] Expected nonnil image source"); + ++ NSNumber *alignmentInset = jsonImageSource[@"alignmentInset"]; ++ if (alignmentInset != nil) { ++ CGFloat inset = alignmentInset.doubleValue; ++ void (^loadingCompletionBlock)(UIImage *_Nullable) = imageLoadingCompletionBlock; ++ imageLoadingCompletionBlock = ^(UIImage *_Nullable image) { ++ loadingCompletionBlock([image imageWithAlignmentRectInsets:UIEdgeInsetsMake(inset, inset, inset, inset)]); ++ }; ++ } ++ + #if !defined(NDEBUG) // We're in debug mode here + if (imageSource.packagerAsset) { + // We use `+ [RCTConvert UIImage:]` only in debug mode, because it is deprecated, however +@@ -38,6 +47,15 @@ + RCTAssert(imageSource != nil, @"[RNScreens] imageSource must not be nil"); + RCTAssert(imageLoader != nil, @"[RNScreens] imageLoader must not be nil"); + ++ if (imageSource.request.URL.isFileURL) { ++ UIImage *image = [UIImage imageWithContentsOfFile:imageSource.request.URL.path]; ++ if (image != nil) { ++ image = [UIImage imageWithCGImage:image.CGImage scale:imageSource.scale orientation:image.imageOrientation]; ++ } ++ imageLoadingCompletionBlock([RNSImageLoadingHelper handleRenderingModeForImage:image isTemplate:isTemplate]); ++ return; ++ } ++ + [imageLoader loadImageWithURLRequest:imageSource.request + size:imageSource.size + scale:imageSource.scale +diff --git a/node_modules/react-native-screens/ios/RNSBarButtonItem.mm b/node_modules/react-native-screens/ios/RNSBarButtonItem.mm +index 76a39d4..160a373 100644 +--- a/node_modules/react-native-screens/ios/RNSBarButtonItem.mm ++++ b/node_modules/react-native-screens/ios/RNSBarButtonItem.mm +@@ -126,10 +126,34 @@ static UIMenuOptions RNSMakeUIMenuOptionsFromConfig(NSDictionary *config); + self.action = @selector(handleBarButtonItemPress:); + _itemAction = action; + _buttonId = buttonId; ++ if ([dict[@"showsLabelWithIcon"] boolValue] && self.image != nil && self.title.length > 0) { ++ [self useImageAndTitleButton]; ++ } + } + return self; + } + ++- (void)useImageAndTitleButton ++{ ++ UIButtonConfiguration *configuration = [UIButtonConfiguration plainButtonConfiguration]; ++ configuration.image = self.image; ++ configuration.title = self.title; ++ configuration.imagePadding = 6; ++ if (self.tintColor != nil) { ++ configuration.baseForegroundColor = self.tintColor; ++ } ++ configuration.preferredSymbolConfigurationForImage = [[UIImageSymbolConfiguration ++ configurationWithTextStyle:UIFontTextStyleBody ++ scale:UIImageSymbolScaleLarge] ++ configurationByApplyingConfiguration:[UIImageSymbolConfiguration configurationWithWeight:UIImageSymbolWeightSemibold]]; ++ UIButton *button = [UIButton buttonWithConfiguration:configuration primaryAction:nil]; ++ button.automaticallyUpdatesConfiguration = NO; ++ [button addTarget:self action:@selector(handleBarButtonItemPress:) forControlEvents:UIControlEventPrimaryActionTriggered]; ++ button.accessibilityLabel = self.accessibilityLabel; ++ [button setContentCompressionResistancePriority:UILayoutPriorityRequired forAxis:UILayoutConstraintAxisHorizontal]; ++ self.customView = button; ++} ++ + + (void)resolveImageFromConfig:(NSDictionary *)dict + imageLoader:(RCTImageLoader *)imageLoader + completionBlock:(void (^)(UIImage *_Nullable))completionBlock +diff --git a/node_modules/react-native-screens/ios/RNSScreenStackHeaderConfig.h b/node_modules/react-native-screens/ios/RNSScreenStackHeaderConfig.h +index 8a41697..6e1b3e3 100644 +--- a/node_modules/react-native-screens/ios/RNSScreenStackHeaderConfig.h ++++ b/node_modules/react-native-screens/ios/RNSScreenStackHeaderConfig.h +@@ -8,10 +8,14 @@ + #import "RNSScreenStackHeaderSubview.h" + #import "RNSSearchBar.h" + ++@class RCTImageSource; ++ + @interface NSString (RNSStringUtil) + + + (BOOL)rnscreens_isBlankOrNull:(nullable NSString *)string; + +++ (nullable NSString *)rnscreens_stringByStrippingTitleGlyph:(nullable NSString *)string; ++ + @end + + @interface RNSScreenStackHeaderConfig : RNSReactBaseView +@@ -23,10 +27,16 @@ + NS_ASSUME_NONNULL_BEGIN + + @property (nonatomic, retain) NSString *title; ++@property (nonatomic, retain) NSString *subtitle; + @property (nonatomic, retain) NSString *titleFontFamily; + @property (nonatomic, retain) NSNumber *titleFontSize; + @property (nonatomic, retain) NSString *titleFontWeight; + @property (nonatomic, retain) UIColor *titleColor; ++@property (nonatomic, retain, nullable) UIColor *subtitleColor; ++@property (nonatomic, retain, nullable) RCTImageSource *titleImageSource; ++@property (nonatomic, retain, nullable) RCTImageSource *subtitleImageSource; ++@property (nonatomic) BOOL titlePressEnabled; ++@property (nonatomic, retain, nullable) NSString *titleTestID; + @property (nonatomic, retain) NSString *backTitle; + @property (nonatomic, retain) NSString *backTitleFontFamily; + @property (nonatomic, retain) NSNumber *backTitleFontSize; +diff --git a/node_modules/react-native-screens/ios/RNSScreenStackHeaderConfig.mm b/node_modules/react-native-screens/ios/RNSScreenStackHeaderConfig.mm +index a54cb63..366121a 100644 +--- a/node_modules/react-native-screens/ios/RNSScreenStackHeaderConfig.mm ++++ b/node_modules/react-native-screens/ios/RNSScreenStackHeaderConfig.mm +@@ -1,4 +1,5 @@ + #import "RNSScreenStackHeaderConfig.h" ++#import + #import + #import + #import +@@ -13,6 +14,7 @@ + #import + #import + #import ++#import + #import + #import + #import "RCTImageComponentView+RNSScreenStackHeaderConfig.h" +@@ -20,6 +22,7 @@ + #import "RNSBarButtonItem.h" + #import "RNSConvert.h" + #import "RNSDefines.h" ++#import "RNSImageLoadingHelper.h" + #import "RNSScreen.h" + #import "RNSSearchBar.h" + #import "UINavigationBar+RNSUtility.h" +@@ -28,11 +31,200 @@ namespace react = facebook::react; + + static const NSNumber *const DEFAULT_TITLE_FONT_SIZE = @17; + static const NSNumber *const DEFAULT_TITLE_LARGE_FONT_SIZE = @34; ++static const NSNumber *const DEFAULT_SUBTITLE_FONT_SIZE = @12; ++ ++#define RNS_TITLE_GLYPH_PLACEHOLDER @"\uFFFC" ++#define RNS_TITLE_GLYPH_SPACING_EM 0.2 + + @interface RCTImageLoader (Private) + - (id)imageCache; + @end + ++@interface RNSScreenStackHeaderConfig () ++- (void)emitTitlePress; ++@end ++ ++@interface RNSHeaderTitlePressHandler : NSObject ++@property (nonatomic, weak) UINavigationController *navigationController; ++@property (nonatomic, copy) NSArray *highlightedViews; ++@property (nonatomic) CGRect pressFrame; +++ (void)installOnNavigationController:(UINavigationController *)navigationController; ++@end ++ ++@implementation RNSHeaderTitlePressHandler ++ ++static const void *RNSHeaderTitlePressHandlerKey = &RNSHeaderTitlePressHandlerKey; ++static const CGFloat RNSHeaderTitlePressSlop = 8; ++static const CGFloat RNSHeaderTitlePressHighlightAlpha = 0.35; ++ +++ (void)installOnNavigationController:(UINavigationController *)navigationController ++{ ++ UINavigationBar *bar = navigationController.navigationBar; ++ RNSHeaderTitlePressHandler *handler = objc_getAssociatedObject(bar, RNSHeaderTitlePressHandlerKey); ++ if (handler != nil) { ++ handler.navigationController = navigationController; ++ [handler scheduleTitleIdentifier]; ++ return; ++ } ++ handler = [RNSHeaderTitlePressHandler new]; ++ handler.navigationController = navigationController; ++ UILongPressGestureRecognizer *recognizer = ++ [[UILongPressGestureRecognizer alloc] initWithTarget:handler action:@selector(handlePress:)]; ++ recognizer.minimumPressDuration = 0; ++ recognizer.allowableMovement = CGFLOAT_MAX; ++ recognizer.cancelsTouchesInView = NO; ++ recognizer.delaysTouchesEnded = NO; ++ recognizer.delegate = handler; ++ [navigationController.view addGestureRecognizer:recognizer]; ++ objc_setAssociatedObject(bar, RNSHeaderTitlePressHandlerKey, handler, OBJC_ASSOCIATION_RETAIN_NONATOMIC); ++ [handler scheduleTitleIdentifier]; ++} ++ ++- (void)scheduleTitleIdentifier ++{ ++ __weak RNSHeaderTitlePressHandler *weakSelf = self; ++ dispatch_async(dispatch_get_main_queue(), ^{ ++ [weakSelf applyTitleIdentifier]; ++ }); ++ [self.navigationController.transitionCoordinator ++ animateAlongsideTransition:nil ++ completion:^(id context) { ++ [weakSelf applyTitleIdentifier]; ++ }]; ++} ++ ++- (void)applyTitleIdentifier ++{ ++ RNSScreenStackHeaderConfig *config = [self topConfig]; ++ if (config.titleTestID == nil) { ++ return; ++ } ++ for (UIView *view in [self titleViewsInBar:self.navigationController.navigationBar forConfig:config]) { ++ if ([view isKindOfClass:[UILabel class]]) { ++ view.accessibilityIdentifier = config.titleTestID; ++ } ++ } ++} ++ ++- (nullable RNSScreenStackHeaderConfig *)topConfig ++{ ++ UIViewController *top = self.navigationController.topViewController; ++ if (![top isKindOfClass:[RNSScreen class]]) { ++ return nil; ++ } ++ return [((RNSScreen *)top).screenView findHeaderConfig]; ++} ++ ++- (NSArray *)titleViewsInBar:(UINavigationBar *)bar forConfig:(RNSScreenStackHeaderConfig *)config ++{ ++ NSMutableArray *titleViews = [NSMutableArray new]; ++ NSMutableArray *pending = [NSMutableArray arrayWithArray:bar.subviews]; ++ UIView *topView = self.navigationController.topViewController.view; ++ if (topView != nil) { ++ [pending addObject:topView]; ++ } ++ while (pending.count > 0) { ++ UIView *view = pending.lastObject; ++ [pending removeLastObject]; ++ if (view.isHidden || view.alpha <= 0 || !CGRectIntersectsRect([view convertRect:view.bounds toView:bar], bar.bounds)) { ++ continue; ++ } ++ if ([view isKindOfClass:[UILabel class]]) { ++ NSString *text = ((UILabel *)view).attributedText.string ?: ((UILabel *)view).text; ++ BOOL isTitle = config.title.length > 0 && [text containsString:config.title]; ++ BOOL isSubtitle = config.subtitle.length > 0 && [text containsString:config.subtitle]; ++ if (isTitle || isSubtitle) { ++ [titleViews addObject:view]; ++ for (UIView *sibling in view.superview.subviews) { ++ if ([sibling isKindOfClass:[UIImageView class]] && !sibling.isHidden && ![titleViews containsObject:sibling]) { ++ [titleViews addObject:sibling]; ++ } ++ } ++ } ++ continue; ++ } ++ [pending addObjectsFromArray:view.subviews]; ++ } ++ return titleViews; ++} ++ ++- (CGRect)frameOfViews:(NSArray *)views inBar:(UINavigationBar *)bar ++{ ++ CGRect frame = CGRectNull; ++ for (UIView *view in views) { ++ frame = CGRectUnion(frame, [view convertRect:view.bounds toView:bar]); ++ } ++ return CGRectIsNull(frame) ? frame : CGRectInset(frame, -RNSHeaderTitlePressSlop, -RNSHeaderTitlePressSlop); ++} ++ ++- (BOOL)gestureRecognizer:(UIGestureRecognizer *)gestureRecognizer shouldReceiveTouch:(UITouch *)touch ++{ ++ RNSScreenStackHeaderConfig *config = [self topConfig]; ++ if (config == nil || !config.titlePressEnabled) { ++ return NO; ++ } ++ UINavigationBar *bar = self.navigationController.navigationBar; ++ if (!CGRectContainsPoint(bar.bounds, [touch locationInView:bar])) { ++ return NO; ++ } ++ NSArray *titleViews = [self titleViewsInBar:bar forConfig:config]; ++ CGRect titleFrame = [self frameOfViews:titleViews inBar:bar]; ++ if (CGRectIsNull(titleFrame) || !CGRectContainsPoint(titleFrame, [touch locationInView:bar])) { ++ return NO; ++ } ++ self.highlightedViews = titleViews; ++ self.pressFrame = titleFrame; ++ return YES; ++} ++ ++- (BOOL)gestureRecognizer:(UIGestureRecognizer *)gestureRecognizer ++ shouldRecognizeSimultaneouslyWithGestureRecognizer:(UIGestureRecognizer *)otherGestureRecognizer ++{ ++ return YES; ++} ++ ++- (void)setHighlighted:(BOOL)highlighted ++{ ++ CGFloat alpha = highlighted ? RNSHeaderTitlePressHighlightAlpha : 1; ++ NSArray *views = self.highlightedViews; ++ [UIView animateWithDuration:highlighted ? 0.1 : 0.25 ++ delay:0 ++ options:UIViewAnimationOptionBeginFromCurrentState | UIViewAnimationOptionAllowUserInteraction ++ animations:^{ ++ for (UIView *view in views) { ++ view.alpha = alpha; ++ } ++ } ++ completion:nil]; ++} ++ ++- (void)handlePress:(UILongPressGestureRecognizer *)recognizer ++{ ++ BOOL isInside = CGRectContainsPoint(self.pressFrame, [recognizer locationInView:self.navigationController.navigationBar]); ++ switch (recognizer.state) { ++ case UIGestureRecognizerStateBegan: ++ case UIGestureRecognizerStateChanged: ++ [self setHighlighted:isInside]; ++ break; ++ case UIGestureRecognizerStateEnded: { ++ [self setHighlighted:NO]; ++ RNSScreenStackHeaderConfig *config = [self topConfig]; ++ if (isInside && config.titlePressEnabled) { ++ [config emitTitlePress]; ++ } ++ break; ++ } ++ case UIGestureRecognizerStateCancelled: ++ case UIGestureRecognizerStateFailed: ++ [self setHighlighted:NO]; ++ break; ++ default: ++ break; ++ } ++} ++ ++@end ++ + @implementation NSString (RNSStringUtil) + + + (BOOL)rnscreens_isBlankOrNull:(NSString *)string +@@ -43,6 +235,105 @@ static const NSNumber *const DEFAULT_TITLE_LARGE_FONT_SIZE = @34; + return [[string stringByTrimmingCharactersInSet:[NSCharacterSet whitespaceAndNewlineCharacterSet]] length] == 0; + } + +++ (nullable NSString *)rnscreens_stringByStrippingTitleGlyph:(nullable NSString *)string ++{ ++ if (string == nil) { ++ return nil; ++ } ++ NSCharacterSet *glyphCharacters = ++ [NSCharacterSet characterSetWithCharactersInString:RNS_TITLE_GLYPH_PLACEHOLDER]; ++ return [[string componentsSeparatedByCharactersInSet:glyphCharacters] componentsJoinedByString:@""]; ++} ++ ++@end ++ ++@interface RNSHeaderImageSlot : NSObject ++ ++- (nullable UIImage *)resolveImageFromSource:(nullable RCTImageSource *)source ++ withImageLoader:(nullable RCTImageLoader *)imageLoader ++ onLoaded:(nonnull dispatch_block_t)onLoaded; ++ ++- (void)reset; ++ ++@end ++ ++@implementation RNSHeaderImageSlot { ++ NSString *_Nullable _requestedUri; ++ NSUInteger _generation; ++} ++ +++ (NSCache *)sharedImageCache ++{ ++ static NSCache *cache; ++ static dispatch_once_t onceToken; ++ dispatch_once(&onceToken, ^{ ++ cache = [NSCache new]; ++ }); ++ return cache; ++} ++ ++- (nullable UIImage *)resolveImageFromSource:(nullable RCTImageSource *)source ++ withImageLoader:(nullable RCTImageLoader *)imageLoader ++ onLoaded:(nonnull dispatch_block_t)onLoaded ++{ ++ if (source == nil) { ++ [self reset]; ++ return nil; ++ } ++ ++ NSString *uri = source.request.URL.absoluteString; ++ if (uri == nil) { ++ [self reset]; ++ return nil; ++ } ++ ++ const BOOL isNewSource = ![uri isEqualToString:_requestedUri]; ++ if (isNewSource) { ++ _requestedUri = uri; ++ _generation += 1; ++ } ++ ++ UIImage *cachedImage = [[RNSHeaderImageSlot sharedImageCache] objectForKey:uri]; ++ if (cachedImage != nil) { ++ return cachedImage; ++ } ++ ++ if (!isNewSource || imageLoader == nil) { ++ return nil; ++ } ++ ++ const NSUInteger generation = _generation; ++ ++ __weak RNSHeaderImageSlot *weakSelf = self; ++ [RNSImageLoadingHelper ++ loadImageFromSource:source ++ withImageLoader:imageLoader ++ asTemplate:YES ++ completionBlock:^(UIImage *image) { ++ RNSHeaderImageSlot *strongSelf = weakSelf; ++ if (strongSelf == nil || generation != strongSelf->_generation) { ++ return; ++ } ++ if (image == nil) { ++#if !defined(NDEBUG) ++ RCTLogWarn( ++ @"[RNScreens] Failed to load header image source \"%@\", falling back to text-only header.", uri); ++#endif // !defined(NDEBUG) ++ return; ++ } ++ [[RNSHeaderImageSlot sharedImageCache] setObject:image forKey:uri]; ++ dispatch_async(dispatch_get_main_queue(), onLoaded); ++ }]; ++ ++ return [[RNSHeaderImageSlot sharedImageCache] objectForKey:uri]; ++} ++ ++- (void)reset ++{ ++ _requestedUri = nil; ++ _generation += 1; ++} ++ + @end + + @interface RNSScreenStackHeaderConfig () +@@ -59,6 +350,8 @@ static const NSNumber *const DEFAULT_TITLE_LARGE_FONT_SIZE = @34; + /// transaction via RCTMountingTransactionObserving protocol. + bool _addedReactSubviewsInCurrentTransaction; + RCTImageLoader *_imageLoader; ++ RNSHeaderImageSlot *_titleImageSlot; ++ RNSHeaderImageSlot *_subtitleImageSlot; + } + + - (instancetype)initWithFrame:(CGRect)frame +@@ -69,6 +362,8 @@ static const NSNumber *const DEFAULT_TITLE_LARGE_FONT_SIZE = @34; + _show = YES; + _translucent = NO; + _addedReactSubviewsInCurrentTransaction = false; ++ _titleImageSlot = [RNSHeaderImageSlot new]; ++ _subtitleImageSlot = [RNSHeaderImageSlot new]; + _lastSendState = react::RNSScreenStackHeaderConfigState(react::Size{}, react::EdgeInsets{}, react::Point{}); + [self initProps]; + } +@@ -131,6 +426,56 @@ RNS_IGNORE_SUPER_CALL_END + return nil; + } + ++- (nullable UIImage *)resolvedTitleImage ++{ ++ __weak RNSScreenStackHeaderConfig *weakSelf = self; ++ return [_titleImageSlot resolveImageFromSource:_titleImageSource ++ withImageLoader:_imageLoader ++ onLoaded:^{ ++ [weakSelf updateViewControllerIfNeeded]; ++ }]; ++} ++ ++- (nullable UIImage *)resolvedSubtitleImage ++{ ++ __weak RNSScreenStackHeaderConfig *weakSelf = self; ++ return [_subtitleImageSlot resolveImageFromSource:_subtitleImageSource ++ withImageLoader:_imageLoader ++ onLoaded:^{ ++ [weakSelf updateViewControllerIfNeeded]; ++ }]; ++} ++ ++- (nullable NSAttributedString *)attributedTitle ++{ ++ UIImage *glyph = [self resolvedTitleImage]; ++ if (glyph == nil || _title == nil) { ++ return nil; ++ } ++ ++ return [RNSScreenStackHeaderConfig ++ attributedStringWithLeadingGlyph:glyph ++ text:_title ++ textAttributes:[RNSScreenStackHeaderConfig titleTextAttributesForConfig:self]]; ++} ++ ++- (nullable NSAttributedString *)attributedSubtitle ++{ ++ UIImage *glyph = [self resolvedSubtitleImage]; ++ if (_subtitle == nil || (glyph == nil && _subtitleColor == nil)) { ++ return nil; ++ } ++ if (glyph == nil) { ++ return [[NSAttributedString alloc] initWithString:_subtitle ++ attributes:[RNSScreenStackHeaderConfig subtitleTextAttributesForConfig:self]]; ++ } ++ ++ return [RNSScreenStackHeaderConfig ++ attributedStringWithLeadingGlyph:glyph ++ text:_subtitle ++ textAttributes:[RNSScreenStackHeaderConfig subtitleTextAttributesForConfig:self]]; ++} ++ + - (void)updateViewControllerIfNeeded + { + UIViewController *vc = _screenView.controller; +@@ -181,10 +526,10 @@ RNS_IGNORE_SUPER_CALL_END + + if (newState != _lastSendState) { + _lastSendState = newState; +- _state->updateState(std::move(newState), +- _synchronousShadowStateUpdatesEnabled +- ? facebook::react::EventQueue::UpdateMode::unstable_Immediate +- : facebook::react::EventQueue::UpdateMode::Asynchronous); ++ _state->updateState( ++ std::move(newState), ++ _synchronousShadowStateUpdatesEnabled ? facebook::react::EventQueue::UpdateMode::unstable_Immediate ++ : facebook::react::EventQueue::UpdateMode::Asynchronous); + } + } + +@@ -293,10 +638,11 @@ RNS_IGNORE_SUPER_CALL_END + // in the image attribute not being updated. We manually set frame to the size of an image + // in order to trigger proper reload that'd update the image attribute. + RCTImageSource *imageSource = [RNSScreenStackHeaderConfig imageSourceFromImageView:imageView]; +- [imageView reactSetFrame:CGRectMake(imageView.frame.origin.x, +- imageView.frame.origin.y, +- imageSource.size.width, +- imageSource.size.height)]; ++ [imageView reactSetFrame:CGRectMake( ++ imageView.frame.origin.x, ++ imageView.frame.origin.y, ++ imageSource.size.width, ++ imageSource.size.height)]; + } + + UIImage *image = imageView.image; +@@ -365,6 +711,89 @@ RNS_IGNORE_SUPER_CALL_END + } + } + +++ (NSDictionary *)resolvedTitleTextAttributesWithFontFamily:(NSString *)fontFamily ++ fontSize:(NSNumber *)fontSize ++ fontWeight:(NSString *)fontWeight ++ color:(UIColor *)color ++ boldFallbackFont:(UIFont *)boldFallbackFont ++{ ++ NSMutableDictionary *attrs = [NSMutableDictionary new]; ++ ++ // Ignore changing header title color on visionOS ++#if !TARGET_OS_VISION ++ if (color) { ++ attrs[NSForegroundColorAttributeName] = color; ++ } ++#endif ++ ++ if (fontFamily || fontWeight) { ++ attrs[NSFontAttributeName] = [RCTFont updateFont:nil ++ withFamily:fontFamily ++ size:fontSize ++ weight:fontWeight ++ style:nil ++ variant:nil ++ scaleMultiplier:1.0]; ++ } else { ++ attrs[NSFontAttributeName] = boldFallbackFont; ++ } ++ ++ return attrs; ++} ++ +++ (NSDictionary *)titleTextAttributesForConfig:(RNSScreenStackHeaderConfig *)config ++{ ++ NSNumber *size = config.titleFontSize ?: [DEFAULT_TITLE_FONT_SIZE copy]; ++ return [self resolvedTitleTextAttributesWithFontFamily:config.titleFontFamily ++ fontSize:size ++ fontWeight:config.titleFontWeight ++ color:config.titleColor ++ boldFallbackFont:[UIFont boldSystemFontOfSize:[size floatValue]]]; ++} ++ +++ (NSDictionary *)subtitleTextAttributesForConfig:(RNSScreenStackHeaderConfig *)config ++{ ++ NSNumber *size = [DEFAULT_SUBTITLE_FONT_SIZE copy]; ++ return [self resolvedTitleTextAttributesWithFontFamily:config.titleFontFamily ++ fontSize:size ++ fontWeight:nil ++ color:config.subtitleColor ?: config.titleColor ++ boldFallbackFont:[UIFont systemFontOfSize:[size floatValue]]]; ++} ++ +++ (NSAttributedString *)attributedStringWithLeadingGlyph:(UIImage *)glyph ++ text:(NSString *)text ++ textAttributes:(NSDictionary *)textAttributes ++{ ++ UIFont *font = ++ textAttributes[NSFontAttributeName] ?: [UIFont boldSystemFontOfSize:[DEFAULT_TITLE_FONT_SIZE floatValue]]; ++ ++ CGFloat glyphHeight = font.pointSize; ++ CGSize intrinsicSize = glyph.size; ++ CGFloat aspectRatio = intrinsicSize.height > 0 ? intrinsicSize.width / intrinsicSize.height : 1; ++ ++ CGFloat glyphWidth = glyphHeight * aspectRatio; ++ CGFloat spacing = font.pointSize * RNS_TITLE_GLYPH_SPACING_EM; ++ UIGraphicsImageRendererFormat *format = [UIGraphicsImageRendererFormat preferredFormat]; ++ format.opaque = NO; ++ UIImage *paddedGlyph = [[[UIGraphicsImageRenderer alloc] initWithSize:CGSizeMake(glyphWidth + spacing, glyphHeight) ++ format:format] ++ imageWithActions:^(UIGraphicsImageRendererContext *context) { ++ [glyph drawInRect:CGRectMake(0, 0, glyphWidth, glyphHeight)]; ++ }]; ++ ++ NSTextAttachment *attachment = [NSTextAttachment new]; ++ attachment.image = [paddedGlyph imageWithRenderingMode:UIImageRenderingModeAlwaysOriginal]; ++ attachment.bounds = CGRectMake(0, (font.capHeight - glyphHeight) / 2, glyphWidth + spacing, glyphHeight); ++ ++ NSMutableAttributedString *composed = [[NSMutableAttributedString alloc] ++ initWithAttributedString:[NSAttributedString attributedStringWithAttachment:attachment]]; ++ [composed appendAttributedString:[[NSAttributedString alloc] initWithString:text]]; ++ [composed addAttributes:textAttributes range:NSMakeRange(0, composed.length)]; ++ ++ return composed; ++} ++ + + (UINavigationBarAppearance *)buildAppearance:(UIViewController *)vc withConfig:(RNSScreenStackHeaderConfig *)config + { + UINavigationBarAppearance *appearance = [UINavigationBarAppearance new]; +@@ -380,8 +809,10 @@ RNS_IGNORE_SUPER_CALL_END + appearance.shadowColor = shadowColor; + appearance.shadowImage = shadowImage; + } +- } else { ++ } else if (config.backgroundColor) { + [appearance configureWithOpaqueBackground]; ++ } else { ++ [appearance configureWithDefaultBackground]; + } + + // set background color if specified +@@ -408,59 +839,20 @@ RNS_IGNORE_SUPER_CALL_END + } + + if (config.titleFontFamily || config.titleFontSize || config.titleFontWeight || config.titleColor) { +- NSMutableDictionary *attrs = [NSMutableDictionary new]; +- +- // Ignore changing header title color on visionOS +-#if !TARGET_OS_VISION +- if (config.titleColor) { +- attrs[NSForegroundColorAttributeName] = config.titleColor; +- } +-#endif +- +- NSString *family = config.titleFontFamily ?: nil; +- NSNumber *size = config.titleFontSize ?: [DEFAULT_TITLE_FONT_SIZE copy]; +- NSString *weight = config.titleFontWeight ?: nil; +- if (family || weight) { +- attrs[NSFontAttributeName] = [RCTFont updateFont:nil +- withFamily:config.titleFontFamily +- size:size +- weight:weight +- style:nil +- variant:nil +- scaleMultiplier:1.0]; +- } else { +- attrs[NSFontAttributeName] = [UIFont boldSystemFontOfSize:[size floatValue]]; +- } +- appearance.titleTextAttributes = attrs; ++ appearance.titleTextAttributes = [self titleTextAttributesForConfig:config]; + } + + if (config.largeTitleFontFamily || config.largeTitleFontSize || config.largeTitleFontWeight || + config.largeTitleColor || config.titleColor) { +- NSMutableDictionary *largeAttrs = [NSMutableDictionary new]; +- +- // Ignore changing header title color on visionOS +-#if !TARGET_OS_VISION +- if (config.largeTitleColor || config.titleColor) { +- largeAttrs[NSForegroundColorAttributeName] = config.largeTitleColor ? config.largeTitleColor : config.titleColor; +- } +-#endif +- +- NSString *largeFamily = config.largeTitleFontFamily ?: nil; + NSNumber *largeSize = config.largeTitleFontSize ?: [DEFAULT_TITLE_LARGE_FONT_SIZE copy]; +- NSString *largeWeight = config.largeTitleFontWeight ?: nil; +- if (largeFamily || largeWeight) { +- largeAttrs[NSFontAttributeName] = [RCTFont updateFont:nil +- withFamily:largeFamily +- size:largeSize +- weight:largeWeight +- style:nil +- variant:nil +- scaleMultiplier:1.0]; +- } else { +- largeAttrs[NSFontAttributeName] = [UIFont systemFontOfSize:[largeSize floatValue] weight:UIFontWeightBold]; +- } +- +- appearance.largeTitleTextAttributes = largeAttrs; ++ UIColor *largeColor = config.largeTitleColor ? config.largeTitleColor : config.titleColor; ++ appearance.largeTitleTextAttributes = ++ [self resolvedTitleTextAttributesWithFontFamily:config.largeTitleFontFamily ++ fontSize:largeSize ++ fontWeight:config.largeTitleFontWeight ++ color:largeColor ++ boldFallbackFont:[UIFont systemFontOfSize:[largeSize floatValue] ++ weight:UIFontWeightBold]]; + } + + UIImage *backButtonImage = [config loadBackButtonImageInViewController:vc]; +@@ -507,6 +899,11 @@ RNS_IGNORE_SUPER_CALL_END + + if (shouldHide) { + navitem.title = config.title; ++#if RNS_IPHONE_OS_VERSION_AVAILABLE(26_0) ++ if (@available(iOS 26.0, *)) { ++ navitem.subtitle = config.subtitle; ++ } ++#endif // RNS_IPHONE_OS_VERSION_AVAILABLE(26_0) + + // Setting navigation bar visibility is split to mitigate iOS 26 bug with bar button items. + [navctr setNavigationBarHidden:YES animated:animated]; +@@ -514,6 +911,11 @@ RNS_IGNORE_SUPER_CALL_END + } + + navctr.navigationBar.overrideUserInterfaceStyle = config.userInterfaceStyle; ++#if RNS_IPHONE_OS_VERSION_AVAILABLE(26_0) ++ if (@available(iOS 26.0, *)) { ++ [RNSHeaderTitlePressHandler installOnNavigationController:navctr]; ++ } ++#endif // RNS_IPHONE_OS_VERSION_AVAILABLE(26_0) + + #if !TARGET_OS_TV + [config configureBackItem:prevItem withPrevVC:prevVC]; +@@ -648,6 +1050,21 @@ RNS_IGNORE_SUPER_CALL_END + // This assignment should be done after `navitem.titleView = ...` assignment (iOS 16.0 bug). + // See: https://github.com/software-mansion/react-native-screens/issues/1570 (comments) + navitem.title = config.title; ++#if RNS_IPHONE_OS_VERSION_AVAILABLE(26_0) ++ if (@available(iOS 26.0, *)) { ++ navitem.subtitle = config.subtitle; ++ ++ NSAttributedString *attributedTitle = [config attributedTitle]; ++ if (attributedTitle != nil) { ++ navitem.attributedTitle = attributedTitle; ++ } ++ ++ NSAttributedString *attributedSubtitle = [config attributedSubtitle]; ++ if (attributedSubtitle != nil) { ++ navitem.attributedSubtitle = attributedSubtitle; ++ } ++ } ++#endif // RNS_IPHONE_OS_VERSION_AVAILABLE(26_0) + navitem.leftBarButtonItems = [config barButtonItemsFromConfigs:config.headerLeftBarButtonItems + withCurrentItems:navitem.leftBarButtonItems]; + navitem.rightBarButtonItems = [config barButtonItemsFromConfigs:config.headerRightBarButtonItems +@@ -700,7 +1117,8 @@ RNS_IGNORE_SUPER_CALL_END + const auto *config = self; + + const auto isBackTitleBlank = [NSString rnscreens_isBlankOrNull:config.backTitle] == YES; +- NSString *resolvedBackTitle = isBackTitleBlank ? prevItem.title : config.backTitle; ++ NSString *resolvedBackTitle = ++ isBackTitleBlank ? [NSString rnscreens_stringByStrippingTitleGlyph:prevItem.title] : config.backTitle; + + // If previous screen controller was recreated (e.g. when you go back to tab with stack that has multiple screens), + // its navigationItem may not have any information from screen's headerConfig, including the title. +@@ -858,12 +1276,13 @@ RNS_IGNORE_SUPER_CALL_END + return; + } + +- RCTAssert(childComponentView.superview == nil, +- @"Attempt to mount already mounted component view. (parent: %@, child: %@, index: %@, existing parent: %@)", +- self, +- childComponentView, +- @(index), +- @([childComponentView.superview tag])); ++ RCTAssert( ++ childComponentView.superview == nil, ++ @"Attempt to mount already mounted component view. (parent: %@, child: %@, index: %@, existing parent: %@)", ++ self, ++ childComponentView, ++ @(index), ++ @([childComponentView.superview tag])); + + // [_reactSubviews insertObject:(RNSScreenStackHeaderSubview *)childComponentView atIndex:index]; + [self insertReactSubview:(RNSScreenStackHeaderSubview *)childComponentView atIndex:index]; +@@ -987,6 +1406,8 @@ static RCTResizeMode resizeModeFromCppEquiv(react::ImageResizeMode resizeMode) + { + [super prepareForRecycle]; + _initialPropsSet = NO; ++ [_titleImageSlot reset]; ++ [_subtitleImageSlot reset]; + + _lastSendState = react::RNSScreenStackHeaderConfigState(react::Size{}, react::EdgeInsets{}, react::Point{}); + } +@@ -1003,6 +1424,14 @@ static RCTResizeMode resizeModeFromCppEquiv(react::ImageResizeMode resizeMode) + return react::concreteComponentDescriptorProvider(); + } + ++- (void)emitTitlePress ++{ ++ auto eventEmitter = std::static_pointer_cast(_eventEmitter); ++ if (eventEmitter) { ++ eventEmitter->onTitlePress(facebook::react::RNSScreenStackHeaderConfigEventEmitter::OnTitlePress{}); ++ } ++} ++ + - (void)updateProps:(react::Props::Shared const &)props oldProps:(react::Props::Shared const &)oldProps + { + const auto &oldScreenProps = *std::static_pointer_cast(_props); +@@ -1025,6 +1454,7 @@ static RCTResizeMode resizeModeFromCppEquiv(react::ImageResizeMode resizeMode) + } + + _title = RCTNSStringFromStringNilIfEmpty(newScreenProps.title); ++ _subtitle = RCTNSStringFromStringNilIfEmpty(newScreenProps.subtitle); + if (newScreenProps.titleFontFamily != oldScreenProps.titleFontFamily) { + _titleFontFamily = RCTNSStringFromStringNilIfEmpty(newScreenProps.titleFontFamily); + } +@@ -1064,6 +1494,27 @@ static RCTResizeMode resizeModeFromCppEquiv(react::ImageResizeMode resizeMode) + // We cannot compare SharedColor because it is shared value. + // We could compare color value, but it is more performant to just assign new value + _titleColor = RCTUIColorFromSharedColor(newScreenProps.titleColor); ++ _subtitleColor = RCTUIColorFromSharedColor(newScreenProps.subtitleColor); ++ _titlePressEnabled = newScreenProps.titlePressEnabled; ++ _titleTestID = RCTNSStringFromStringNilIfEmpty(newScreenProps.titleTestID); ++ if (newScreenProps.titleImageSource != oldScreenProps.titleImageSource) { ++ const auto &titleImageSource = newScreenProps.titleImageSource; ++ _titleImageSource = titleImageSource.uri.empty() ++ ? nil ++ : [[RCTImageSource alloc] ++ initWithURLRequest:NSURLRequestFromImageSource(titleImageSource) ++ size:CGSizeMake(titleImageSource.size.width, titleImageSource.size.height) ++ scale:titleImageSource.scale]; ++ } ++ if (newScreenProps.subtitleImageSource != oldScreenProps.subtitleImageSource) { ++ const auto &subtitleImageSource = newScreenProps.subtitleImageSource; ++ _subtitleImageSource = subtitleImageSource.uri.empty() ++ ? nil ++ : [[RCTImageSource alloc] ++ initWithURLRequest:NSURLRequestFromImageSource(subtitleImageSource) ++ size:CGSizeMake(subtitleImageSource.size.width, subtitleImageSource.size.height) ++ scale:subtitleImageSource.scale]; ++ } + _largeTitleColor = RCTUIColorFromSharedColor(newScreenProps.largeTitleColor); + _color = RCTUIColorFromSharedColor(newScreenProps.color); + _backgroundColor = RCTUIColorFromSharedColor(newScreenProps.backgroundColor); +diff --git a/node_modules/react-native-screens/src/fabric/ScreenStackHeaderConfigNativeComponent.ts b/node_modules/react-native-screens/src/fabric/ScreenStackHeaderConfigNativeComponent.ts +index da28c51..017ca65 100644 +--- a/node_modules/react-native-screens/src/fabric/ScreenStackHeaderConfigNativeComponent.ts ++++ b/node_modules/react-native-screens/src/fabric/ScreenStackHeaderConfigNativeComponent.ts +@@ -4,6 +4,7 @@ import { codegenNativeComponent } from 'react-native'; + import type { + CodegenTypes as CT, + HostComponent, ++ ImageSource, + ViewProps, + ColorValue, + } from 'react-native'; +@@ -12,6 +13,7 @@ type DirectionType = 'rtl' | 'ltr'; + + // eslint-disable-next-line @typescript-eslint/ban-types + type OnAttachedEvent = Readonly<{}>; ++type OnTitlePressEvent = Readonly<{}>; + // eslint-disable-next-line @typescript-eslint/ban-types + type OnDetachedEvent = Readonly<{}>; + +@@ -66,10 +68,17 @@ export interface NativeProps extends ViewProps { + largeTitleColor?: ColorValue | undefined; + translucent?: boolean | undefined; + title?: string | undefined; ++ subtitle?: string | undefined; + titleFontFamily?: string | undefined; + titleFontSize?: CT.Int32 | undefined; + titleFontWeight?: string | undefined; + titleColor?: ColorValue | undefined; ++ subtitleColor?: ColorValue | undefined; ++ titleImageSource?: ImageSource | undefined; ++ subtitleImageSource?: ImageSource | undefined; ++ titlePressEnabled?: boolean | undefined; ++ titleTestID?: string | undefined; ++ onTitlePress?: CT.DirectEventHandler | undefined; + disableBackButtonMenu?: boolean | undefined; + backButtonDisplayMode?: CT.WithDefault; + hideBackButton?: boolean | undefined; +diff --git a/node_modules/react-native-screens/src/types.tsx b/node_modules/react-native-screens/src/types.tsx +index 8594b73..a10e423 100644 +--- a/node_modules/react-native-screens/src/types.tsx ++++ b/node_modules/react-native-screens/src/types.tsx +@@ -827,10 +827,73 @@ export interface ScreenStackHeaderConfigProps extends ViewProps { + * String that can be displayed in the header as a fallback for `headerTitle`. + */ + title?: string | undefined; ++ /** ++ * String to display in the header underneath the title. ++ * ++ * @platform ios ++ * ++ * @supported iOS 26 or higher ++ */ ++ subtitle?: string | undefined; + /** + * Allows for setting text color of the title. + */ + titleColor?: ColorValue | undefined; ++ /** ++ * Image rendered as a leading glyph before the title. The glyph is sized from ++ * the resolved title font and keeps its original colors. ++ * Requires iOS 26 or higher and is ignored on Android and earlier iOS versions. ++ * The glyph is hidden while the large title is expanded and appears on collapse. ++ * A custom header title view replaces the title and its glyph. ++ * ++ * @platform ios ++ * ++ * @supported iOS 26 or higher ++ */ ++ titleImageSource?: ImageSourcePropType | undefined; ++ /** ++ * Image rendered as a leading glyph before the subtitle. The glyph is sized ++ * from the resolved subtitle font and keeps its original colors. ++ * Requires iOS 26 or higher and is ignored on Android and earlier iOS versions. ++ * A custom header title view does not replace the subtitle or its glyph. ++ * ++ * @platform ios ++ * ++ * @supported iOS 26 or higher ++ */ ++ subtitleImageSource?: ImageSourcePropType | undefined; ++ /** ++ * Color of the subtitle text. Defaults to `titleColor`. ++ * ++ * @platform ios ++ * ++ * @supported iOS 26 or higher ++ */ ++ subtitleColor?: ColorValue | undefined; ++ /** ++ * Whether tapping the native header title calls `onTitlePress`. ++ * ++ * @platform ios ++ * ++ * @supported iOS 26 or higher ++ */ ++ titlePressEnabled?: boolean | undefined; ++ /** ++ * Accessibility identifier of the native header title, for e2e tests. ++ * ++ * @platform ios ++ * ++ * @supported iOS 26 or higher ++ */ ++ titleTestID?: string | undefined; ++ /** ++ * Called when the native header title or subtitle is tapped and `titlePressEnabled` is true. ++ * ++ * @platform ios ++ * ++ * @supported iOS 26 or higher ++ */ ++ onTitlePress?: (() => void) | undefined; + /** + * Customize font family to be used for the title. + */ +diff --git a/node_modules/react-native-screens/ios/RNSScreenStack.mm b/node_modules/react-native-screens/ios/RNSScreenStack.mm +index c233036..5d3e28d 100644 +--- a/node_modules/react-native-screens/ios/RNSScreenStack.mm ++++ b/node_modules/react-native-screens/ios/RNSScreenStack.mm +@@ -38,6 +38,58 @@ namespace react = facebook::react; + + @end + ++static UIView *RNSFindLargeTitleView(UIView *view) ++{ ++ if ([NSStringFromClass([view class]) containsString:@"LargeTitleView"]) { ++ return view; ++ } ++ for (UIView *subview in view.subviews) { ++ UIView *found = RNSFindLargeTitleView(subview); ++ if (found != nil) { ++ return found; ++ } ++ } ++ return nil; ++} ++ ++static void RNSAlignLargeTitleMargins(UINavigationController *controller) ++{ ++ UIViewController *top = controller.topViewController; ++ if (top == nil || controller.navigationBar.prefersLargeTitles == NO) { ++ return; ++ } ++ UIView *titleView = RNSFindLargeTitleView(top.view); ++ if (titleView == nil) { ++ return; ++ } ++ NSDirectionalEdgeInsets margins = titleView.directionalLayoutMargins; ++ if (margins.leading >= margins.trailing) { ++ return; ++ } ++ UINavigationItem *item = top.navigationItem; ++ UINavigationItemLargeTitleDisplayMode mode = item.largeTitleDisplayMode; ++ item.largeTitleDisplayMode = UINavigationItemLargeTitleDisplayModeNever; ++ item.largeTitleDisplayMode = mode; ++ titleView.insetsLayoutMarginsFromSafeArea = !titleView.insetsLayoutMarginsFromSafeArea; ++ titleView.insetsLayoutMarginsFromSafeArea = !titleView.insetsLayoutMarginsFromSafeArea; ++ titleView.preservesSuperviewLayoutMargins = !titleView.preservesSuperviewLayoutMargins; ++ titleView.preservesSuperviewLayoutMargins = !titleView.preservesSuperviewLayoutMargins; ++ margins = titleView.directionalLayoutMargins; ++ if (margins.leading < margins.trailing) { ++ margins.leading = margins.trailing; ++ titleView.directionalLayoutMargins = margins; ++ } ++ [titleView setNeedsLayout]; ++ [titleView layoutIfNeeded]; ++ if ([titleView.superview isKindOfClass:[UIScrollView class]]) { ++ UIScrollView *scrollView = (UIScrollView *)titleView.superview; ++ [top setContentScrollView:nil forEdge:NSDirectionalRectEdgeTop]; ++ [top setContentScrollView:scrollView forEdge:NSDirectionalRectEdgeTop]; ++ [controller.navigationBar setNeedsLayout]; ++ [controller.navigationBar layoutIfNeeded]; ++ } ++} ++ + @implementation RNSNavigationController + + #if !TARGET_OS_TV +@@ -59,6 +111,7 @@ namespace react = facebook::react; + - (void)viewDidLayoutSubviews + { + [super viewDidLayoutSubviews]; ++ RNSAlignLargeTitleMargins(self); + if ([self.topViewController isKindOfClass:[RNSScreen class]]) { + RNSScreen *screenController = (RNSScreen *)self.topViewController; + BOOL isNotDismissingModal = screenController.presentedViewController == nil || diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ef049482418..8bcc493b159 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -17,6 +17,9 @@ importers: '@bugsnag/react-native': specifier: 8.4.0 version: 8.4.0 + '@expo/ui': + specifier: ~57.0.19 + version: 57.0.19(@babel/core@7.29.7)(@types/react@19.2.18)(expo@57.0.23)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.12.2(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(react-native@0.86.3(@babel/core@7.29.7)(@react-native-community/cli@20.0.0(typescript@7.0.2))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7)(react@19.2.3))(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.3))(react@19.2.3))(react-native@0.86.3(@babel/core@7.29.7)(@react-native-community/cli@20.0.0(typescript@7.0.2))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7)(react@19.2.3))(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.3))(react@19.2.3) '@expo/vector-icons': specifier: ^15.0.3 version: 15.0.3(expo-font@57.0.4(expo@57.0.23)(react-native@0.86.3(@babel/core@7.29.7)(@react-native-community/cli@20.0.0(typescript@7.0.2))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7)(react@19.2.3))(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.3))(react@19.2.3))(react-native@0.86.3(@babel/core@7.29.7)(@react-native-community/cli@20.0.0(typescript@7.0.2))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7)(react@19.2.3))(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.3))(react@19.2.3) @@ -101,6 +104,9 @@ importers: '@rocket.chat/ui-kit': specifier: ^0.39.0 version: 0.39.0(@rocket.chat/icons@0.47.0)(@types/node@25.0.3)(typescript@7.0.2) + '@shopify/flash-list': + specifier: 2.3.2 + version: 2.3.2(@babel/runtime@7.25.9)(react-native@0.86.3(@babel/core@7.29.7)(@react-native-community/cli@20.0.0(typescript@7.0.2))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7)(react@19.2.3))(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.3))(react@19.2.3) '@zoontek/react-native-navigation-bar': specifier: ^1.1.1 version: 1.1.2(react-native@0.86.3(@babel/core@7.29.7)(@react-native-community/cli@20.0.0(typescript@7.0.2))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7)(react@19.2.3))(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.3))(react@19.2.3) @@ -1711,6 +1717,23 @@ packages: '@expo/sudo-prompt@9.3.2': resolution: {integrity: sha512-HHQigo3rQWKMDzYDLkubN5WQOYXJJE2eNqIQC2axC2iO3mHdwnIR7FgZVvHWtBwAdzBgAP0ECp8KqS8TiMKvgw==} + '@expo/ui@57.0.19': + resolution: {integrity: sha512-WB95zQPge5nb04zgY3O2NmMtxg6G+qFH3yOAHcBkTUrJXoBReQoyY3cfp/8N5Q/azcfHJcBE3Jy0Rwq9OoRrfA==} + peerDependencies: + '@babel/core': '*' + expo: '*' + react: '*' + react-dom: '*' + react-native: '*' + react-native-worklets: '*' + peerDependenciesMeta: + '@babel/core': + optional: true + react-dom: + optional: true + react-native-worklets: + optional: true + '@expo/vector-icons@15.0.3': resolution: {integrity: sha512-SBUyYKphmlfUBqxSfDdJ3jAdEVSALS2VUPOUyqn48oZmb2TL/O7t7/PQm5v4NQujYEPLPMTLn9KVw6H7twwbTA==} peerDependencies: @@ -2461,6 +2484,168 @@ packages: '@protobufjs/utf8@1.1.0': resolution: {integrity: sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==} + '@radix-ui/primitive@1.1.7': + resolution: {integrity: sha512-rqWnm76nYT8HoNNqEjpgJ7Pw/DrBj5iBTrmEPo6HTX5+VJyBNOqTdv4g89G63HuR5g0AaENoAcH7Is5fF2kZ8Q==} + + '@radix-ui/react-compose-refs@1.1.5': + resolution: {integrity: sha512-+48PbAAbq3didjJxa+OaWY2ZwgAKsNiRGyeHKszblZMQ+kcpd9pAaT11cMkGEie0vsOi3QdeTE6d5Fe3Gn61kA==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-context@1.2.2': + resolution: {integrity: sha512-RHCUGwKHDr0hDGg4X7ma4JG4/+12qxw8rkh5QKdDldlCvtja6nUx1Ef/8HVrJze81lEsgLQlqjzjGNHantgnQA==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-dialog@1.1.23': + resolution: {integrity: sha512-Ksw4WeROkO4rC9k/onilX/Ao2Cr1ku1unMNH+XSCcP4jSXYu7HDsg9n4ojMjVb22XpYjAQ9qfrFlVbru1vXDUA==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-dismissable-layer@1.1.19': + resolution: {integrity: sha512-8g4pfOL9HoKKLWGiypT+dphVqjFfmcXO5GBnhsG6zI+lxAx/8feQpr+1LSN8Re3hiZ+XkLNS4O9ztK11/LzQ6w==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-focus-guards@1.1.6': + resolution: {integrity: sha512-RNOJjfZMTyBM6xYmV3IVGXkPjIhcBAuv48POevAXwrGJhkWZ9p1rFoIS1JFooPuT193AZmRsCPhpoVJxx6OPoQ==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-focus-scope@1.1.16': + resolution: {integrity: sha512-wmRZ2WWLvmt6KHy2rNPOdPUjwq5xOHY02+m+udwJTn0aNIox/rkskAvJTyTLGhPK6KgrUjlJUJpgmx/+wFiFIQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-id@1.1.4': + resolution: {integrity: sha512-TMQp2llA+RYn7JcjnrMnz7wN4pcVttPZnRZo52PLQsoLVKzNlVwUeHmfePgTgRluXFvlD3GD5g5MOVVTJCO0qA==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-portal@1.1.17': + resolution: {integrity: sha512-vKQLcWypUnwZVvfV7UkGahH2g6ySe8M8R+zYBwPrv5byZ9QAW6cQVvNKo7GgmD+p8aYb6D9JBuvy8/WhOno2wQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-presence@1.1.10': + resolution: {integrity: sha512-3wyzCQ6+ubRA+D4uv9m95JYLXxmOHp05qjrkjeA7uKHHtjpPggQzc6DAb0URl7j67oR0K2foO4ip27TiX037Bw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-primitive@2.1.10': + resolution: {integrity: sha512-MucOnzh6hR5mid6VpkbglRAMYMjKLqRnGBbjXkzjK52fuQDd1qbkx78a5P40mkcnVXJdEVxm26E9OPAiUq7nBg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-slot@1.3.3': + resolution: {integrity: sha512-qx7oqnYbxnK9kYI9m317qmFmEgo6ywqWvbTogdj7cL9p3/yx4M48p7Rnw5z3H890cL/ow/EeWJsuTykeZVXP5Q==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-callback-ref@1.1.4': + resolution: {integrity: sha512-R6OUY2e2fA6Yn6s+VSx5KBV6Nx8LQEhu+cz7LCej18rQ1HLyg9PSC9jP/ZNx0o6FAIK9c0F1kHylzSxKsdlkrQ==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-controllable-state@1.2.6': + resolution: {integrity: sha512-uEQJGT97ZA/TgP/Hydw47lHu+/vQj6z/0jA+WeTbK1o9Rx45GImjpD0tc3W5ad3D6XTSR6e1yEO0FvGq6WQfVQ==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-effect-event@0.0.5': + resolution: {integrity: sha512-7cshFL8HGS/7HEiHH+9kL9HBwp2sa9yX18Knwek6KYWmXwM7pegMgta2AXMQKI+rq3JnfSj9x8wYqFMTdG1Jgg==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-layout-effect@1.1.4': + resolution: {integrity: sha512-K20DkRkUwDnxEYMBPcg3Y6voLkEy5p5QQmszZgLngKKiC7dzBR/aEuK3w1qlx2JWDUNH6FluahYdgR3BP+QbYw==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@react-native-async-storage/async-storage@2.2.0': resolution: {integrity: sha512-gvRvjR5JAaUZF8tv2Kcq/Gbt3JHwbKFYfmb445rhOj6NUMx3qPLixmDx5pZAyb9at1bYvJ4/eTUipU5aki45xw==} peerDependencies: @@ -2789,6 +2974,13 @@ packages: '@samchon/openapi@4.7.2': resolution: {integrity: sha512-yj6kGWHtKK85wfJXrSmWqLWjfCd98SAvCTsVDlej2s7OfXXHqA4hmgPRNrAPIQRVi00xn26qL1PChjq1MhzlRQ==} + '@shopify/flash-list@2.3.2': + resolution: {integrity: sha512-vQnd0y0Ag11yzS30llaeCrtIU3xYTMe7KEOP3dveLImnVjQEUw6BEg1+Ztja6aODlVNz1BpvxtlQ5eZGxiLwKw==} + peerDependencies: + '@babel/runtime': '*' + react: '*' + react-native: '*' + '@sideway/address@4.1.5': resolution: {integrity: sha512-IqO/DUQHUkPeixNQ8n0JA6102hT9CmaljNTPmQ1u8MEhBo/R4Q8eKLN/vGZxuebwOroDB4cbpjheD4+/sKFK4Q==} @@ -3514,6 +3706,10 @@ packages: argparse@2.0.1: resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + aria-hidden@1.2.6: + resolution: {integrity: sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==} + engines: {node: '>=10'} + aria-query@5.3.0: resolution: {integrity: sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==} @@ -4237,6 +4433,9 @@ packages: resolution: {integrity: sha512-TLz+x/vEXm/Y7P7wn1EJFNLxYpUD4TgMosxY6fAVJUnJMbupHBOncxyWUG9OpTaH9EBD7uFI5LfEgmMOc54DsA==} engines: {node: '>=8'} + detect-node-es@1.1.0: + resolution: {integrity: sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==} + diff-sequences@29.6.3: resolution: {integrity: sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} @@ -4954,6 +5153,10 @@ packages: resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} engines: {node: '>= 0.4'} + get-nonce@1.0.1: + resolution: {integrity: sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==} + engines: {node: '>=6'} + get-package-type@0.1.0: resolution: {integrity: sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q==} engines: {node: '>=8.0.0'} @@ -6890,6 +7093,36 @@ packages: resolution: {integrity: sha512-jCvmsr+1IUSMUyzOkRcvnVbX3ZYC6g9TDrDbFuFmRDq7PD4yaGbLKNQL6k2jnArV8hjYxh7hVhAZB6s9HDGpZA==} engines: {node: '>=0.10.0'} + react-remove-scroll-bar@2.3.8: + resolution: {integrity: sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + + react-remove-scroll@2.7.2: + resolution: {integrity: sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + react-style-singleton@2.2.3: + resolution: {integrity: sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + react-test-renderer@19.2.3: resolution: {integrity: sha512-TMR1LnSFiWZMJkCgNf5ATSvAheTT2NvKIwiVwdBPHxjBI7n/JbWd4gaZ16DVd9foAXdvDz+sB5yxZTwMjPRxpw==} peerDependencies: @@ -7733,6 +7966,16 @@ packages: url-parse@1.5.10: resolution: {integrity: sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==} + use-callback-ref@1.3.3: + resolution: {integrity: sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + use-debounce@9.0.4: resolution: {integrity: sha512-6X8H/mikbrt0XE8e+JXRtZ8yYVvKkdYRfmIhWZYsP8rcNs9hk3APV8Ua2mFkKRLcJKVdnX2/Vwrmg2GWKUQEaQ==} engines: {node: '>= 10.0.0'} @@ -7750,6 +7993,16 @@ packages: peerDependencies: react: '>=16.8' + use-sidecar@1.1.3: + resolution: {integrity: sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + use-sync-external-store@1.6.0: resolution: {integrity: sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==} peerDependencies: @@ -7785,6 +8038,12 @@ packages: resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} engines: {node: '>= 0.8'} + vaul@1.1.2: + resolution: {integrity: sha512-ZFkClGpWyI2WUQjdLJ/BaGuV6AVQiJ3uELGk3OYtP+B6yCO7Cmn9vPFXVJkRaGkOJu3m8bQMgtyzNHixULceQA==} + peerDependencies: + react: ^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc + vlq@1.0.1: resolution: {integrity: sha512-gQpnTgkubC6hQgdIcRdYGDSDc+SaujOdyesZQMv6JlfQee/9Mp0Qhnys6WxDWvQnL5WZdT7o2Ul187aSt0Rq+w==} @@ -9702,6 +9961,21 @@ snapshots: '@expo/sudo-prompt@9.3.2': {} + '@expo/ui@57.0.19(@babel/core@7.29.7)(@types/react@19.2.18)(expo@57.0.23)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.12.2(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(react-native@0.86.3(@babel/core@7.29.7)(@react-native-community/cli@20.0.0(typescript@7.0.2))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7)(react@19.2.3))(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.3))(react@19.2.3))(react-native@0.86.3(@babel/core@7.29.7)(@react-native-community/cli@20.0.0(typescript@7.0.2))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7)(react@19.2.3))(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.3))(react@19.2.3)': + dependencies: + expo: 57.0.23(8132ea204d39ca81423aaa5e2384dd28) + react: 19.2.3 + react-native: 0.86.3(@babel/core@7.29.7)(@react-native-community/cli@20.0.0(typescript@7.0.2))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7)(react@19.2.3))(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.3) + sf-symbols-typescript: 2.2.0 + vaul: 1.1.2(@types/react@19.2.18)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + optionalDependencies: + '@babel/core': 7.29.7 + react-dom: 19.2.3(react@19.2.3) + react-native-worklets: 0.12.2(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(react-native@0.86.3(@babel/core@7.29.7)(@react-native-community/cli@20.0.0(typescript@7.0.2))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7)(react@19.2.3))(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.3))(react@19.2.3) + transitivePeerDependencies: + - '@types/react' + - '@types/react-dom' + '@expo/vector-icons@15.0.3(expo-font@57.0.4(expo@57.0.23)(react-native@0.86.3(@babel/core@7.29.7)(@react-native-community/cli@20.0.0(typescript@7.0.2))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7)(react@19.2.3))(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.3))(react@19.2.3))(react-native@0.86.3(@babel/core@7.29.7)(@react-native-community/cli@20.0.0(typescript@7.0.2))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7)(react@19.2.3))(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.3))(react@19.2.3)': dependencies: expo-font: 57.0.4(expo@57.0.23)(react-native@0.86.3(@babel/core@7.29.7)(@react-native-community/cli@20.0.0(typescript@7.0.2))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7)(react@19.2.3))(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.3))(react@19.2.3) @@ -10514,6 +10788,137 @@ snapshots: '@protobufjs/utf8@1.1.0': {} + '@radix-ui/primitive@1.1.7': {} + + '@radix-ui/react-compose-refs@1.1.5(@types/react@19.2.18)(react@19.2.3)': + dependencies: + react: 19.2.3 + optionalDependencies: + '@types/react': 19.2.18 + + '@radix-ui/react-context@1.2.2(@types/react@19.2.18)(react@19.2.3)': + dependencies: + react: 19.2.3 + optionalDependencies: + '@types/react': 19.2.18 + + '@radix-ui/react-dialog@1.1.23(@types/react@19.2.18)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': + dependencies: + '@radix-ui/primitive': 1.1.7 + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.18)(react@19.2.3) + '@radix-ui/react-context': 1.2.2(@types/react@19.2.18)(react@19.2.3) + '@radix-ui/react-dismissable-layer': 1.1.19(@types/react@19.2.18)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-focus-guards': 1.1.6(@types/react@19.2.18)(react@19.2.3) + '@radix-ui/react-focus-scope': 1.1.16(@types/react@19.2.18)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-id': 1.1.4(@types/react@19.2.18)(react@19.2.3) + '@radix-ui/react-portal': 1.1.17(@types/react@19.2.18)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-presence': 1.1.10(@types/react@19.2.18)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-primitive': 2.1.10(@types/react@19.2.18)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-slot': 1.3.3(@types/react@19.2.18)(react@19.2.3) + '@radix-ui/react-use-controllable-state': 1.2.6(@types/react@19.2.18)(react@19.2.3) + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.2.18)(react@19.2.3) + aria-hidden: 1.2.6 + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) + react-remove-scroll: 2.7.2(@types/react@19.2.18)(react@19.2.3) + optionalDependencies: + '@types/react': 19.2.18 + + '@radix-ui/react-dismissable-layer@1.1.19(@types/react@19.2.18)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': + dependencies: + '@radix-ui/primitive': 1.1.7 + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.18)(react@19.2.3) + '@radix-ui/react-primitive': 2.1.10(@types/react@19.2.18)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-use-callback-ref': 1.1.4(@types/react@19.2.18)(react@19.2.3) + '@radix-ui/react-use-effect-event': 0.0.5(@types/react@19.2.18)(react@19.2.3) + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) + optionalDependencies: + '@types/react': 19.2.18 + + '@radix-ui/react-focus-guards@1.1.6(@types/react@19.2.18)(react@19.2.3)': + dependencies: + react: 19.2.3 + optionalDependencies: + '@types/react': 19.2.18 + + '@radix-ui/react-focus-scope@1.1.16(@types/react@19.2.18)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.18)(react@19.2.3) + '@radix-ui/react-primitive': 2.1.10(@types/react@19.2.18)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-use-callback-ref': 1.1.4(@types/react@19.2.18)(react@19.2.3) + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) + optionalDependencies: + '@types/react': 19.2.18 + + '@radix-ui/react-id@1.1.4(@types/react@19.2.18)(react@19.2.3)': + dependencies: + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.2.18)(react@19.2.3) + react: 19.2.3 + optionalDependencies: + '@types/react': 19.2.18 + + '@radix-ui/react-portal@1.1.17(@types/react@19.2.18)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': + dependencies: + '@radix-ui/react-primitive': 2.1.10(@types/react@19.2.18)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.2.18)(react@19.2.3) + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) + optionalDependencies: + '@types/react': 19.2.18 + + '@radix-ui/react-presence@1.1.10(@types/react@19.2.18)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': + dependencies: + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.2.18)(react@19.2.3) + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) + optionalDependencies: + '@types/react': 19.2.18 + + '@radix-ui/react-primitive@2.1.10(@types/react@19.2.18)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': + dependencies: + '@radix-ui/react-slot': 1.3.3(@types/react@19.2.18)(react@19.2.3) + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) + optionalDependencies: + '@types/react': 19.2.18 + + '@radix-ui/react-slot@1.3.3(@types/react@19.2.18)(react@19.2.3)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.18)(react@19.2.3) + react: 19.2.3 + optionalDependencies: + '@types/react': 19.2.18 + + '@radix-ui/react-use-callback-ref@1.1.4(@types/react@19.2.18)(react@19.2.3)': + dependencies: + react: 19.2.3 + optionalDependencies: + '@types/react': 19.2.18 + + '@radix-ui/react-use-controllable-state@1.2.6(@types/react@19.2.18)(react@19.2.3)': + dependencies: + '@radix-ui/primitive': 1.1.7 + '@radix-ui/react-use-effect-event': 0.0.5(@types/react@19.2.18)(react@19.2.3) + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.2.18)(react@19.2.3) + react: 19.2.3 + optionalDependencies: + '@types/react': 19.2.18 + + '@radix-ui/react-use-effect-event@0.0.5(@types/react@19.2.18)(react@19.2.3)': + dependencies: + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.2.18)(react@19.2.3) + react: 19.2.3 + optionalDependencies: + '@types/react': 19.2.18 + + '@radix-ui/react-use-layout-effect@1.1.4(@types/react@19.2.18)(react@19.2.3)': + dependencies: + react: 19.2.3 + optionalDependencies: + '@types/react': 19.2.18 + '@react-native-async-storage/async-storage@2.2.0(react-native@0.86.3(@babel/core@7.29.7)(@react-native-community/cli@20.0.0(typescript@7.0.2))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7)(react@19.2.3))(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.3))': dependencies: merge-options: 3.0.4 @@ -11030,6 +11435,12 @@ snapshots: '@samchon/openapi@4.7.2': {} + '@shopify/flash-list@2.3.2(@babel/runtime@7.25.9)(react-native@0.86.3(@babel/core@7.29.7)(@react-native-community/cli@20.0.0(typescript@7.0.2))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7)(react@19.2.3))(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.3))(react@19.2.3)': + dependencies: + '@babel/runtime': 7.25.9 + react: 19.2.3 + react-native: 0.86.3(@babel/core@7.29.7)(@react-native-community/cli@20.0.0(typescript@7.0.2))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7)(react@19.2.3))(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.3) + '@sideway/address@4.1.5': dependencies: '@hapi/hoek': 9.3.0 @@ -11703,6 +12114,10 @@ snapshots: argparse@2.0.1: {} + aria-hidden@1.2.6: + dependencies: + tslib: 2.8.1 + aria-query@5.3.0: dependencies: dequal: 2.0.3 @@ -12570,6 +12985,8 @@ snapshots: detect-newline@3.1.0: {} + detect-node-es@1.1.0: {} + diff-sequences@29.6.3: {} dnssd-advertise@1.1.6: {} @@ -13445,6 +13862,8 @@ snapshots: hasown: 2.0.2 math-intrinsics: 1.1.0 + get-nonce@1.0.1: {} + get-package-type@0.1.0: {} get-proto@1.0.1: @@ -15894,6 +16313,33 @@ snapshots: react-refresh@0.14.2: {} + react-remove-scroll-bar@2.3.8(@types/react@19.2.18)(react@19.2.3): + dependencies: + react: 19.2.3 + react-style-singleton: 2.2.3(@types/react@19.2.18)(react@19.2.3) + tslib: 2.8.1 + optionalDependencies: + '@types/react': 19.2.18 + + react-remove-scroll@2.7.2(@types/react@19.2.18)(react@19.2.3): + dependencies: + react: 19.2.3 + react-remove-scroll-bar: 2.3.8(@types/react@19.2.18)(react@19.2.3) + react-style-singleton: 2.2.3(@types/react@19.2.18)(react@19.2.3) + tslib: 2.8.1 + use-callback-ref: 1.3.3(@types/react@19.2.18)(react@19.2.3) + use-sidecar: 1.1.3(@types/react@19.2.18)(react@19.2.3) + optionalDependencies: + '@types/react': 19.2.18 + + react-style-singleton@2.2.3(@types/react@19.2.18)(react@19.2.3): + dependencies: + get-nonce: 1.0.1 + react: 19.2.3 + tslib: 2.8.1 + optionalDependencies: + '@types/react': 19.2.18 + react-test-renderer@19.2.3(react@19.2.3): dependencies: react: 19.2.3 @@ -16857,6 +17303,13 @@ snapshots: querystringify: 2.2.0 requires-port: 1.0.0 + use-callback-ref@1.3.3(@types/react@19.2.18)(react@19.2.3): + dependencies: + react: 19.2.3 + tslib: 2.8.1 + optionalDependencies: + '@types/react': 19.2.18 + use-debounce@9.0.4(react@19.2.3): dependencies: react: 19.2.3 @@ -16872,6 +17325,14 @@ snapshots: dependencies: react: 19.2.3 + use-sidecar@1.1.3(@types/react@19.2.18)(react@19.2.3): + dependencies: + detect-node-es: 1.1.0 + react: 19.2.3 + tslib: 2.8.1 + optionalDependencies: + '@types/react': 19.2.18 + use-sync-external-store@1.6.0(react@19.2.3): dependencies: react: 19.2.3 @@ -16905,6 +17366,15 @@ snapshots: vary@1.1.2: {} + vaul@1.1.2(@types/react@19.2.18)(react-dom@19.2.3(react@19.2.3))(react@19.2.3): + dependencies: + '@radix-ui/react-dialog': 1.1.23(@types/react@19.2.18)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) + transitivePeerDependencies: + - '@types/react' + - '@types/react-dom' + vlq@1.0.1: {} w3c-xmlserializer@4.0.0: