diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index 8093243cf0..22f9ab52c1 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -9,8 +9,8 @@ on:
# Enrich gradle.properties for CI/CD
env:
- GRADLE_OPTS: -Dorg.gradle.jvmargs="-Xmx8g -Dfile.encoding=UTF-8 -XX:+HeapDumpOnOutOfMemoryError" -Dkotlin.incremental=false -XX:+UseParallelGC
- CI_GRADLE_ARG_PROPERTIES: --stacktrace -PpreDexEnable=false --max-workers 8 --no-daemon
+ GRADLE_OPTS: -Dorg.gradle.jvmargs="-Xmx7g -Dfile.encoding=UTF-8 -XX:+HeapDumpOnOutOfMemoryError" -Dkotlin.incremental=false -XX:+UseParallelGC
+ CI_GRADLE_ARG_PROPERTIES: --stacktrace -PpreDexEnable=false --max-workers 8
jobs:
debug:
@@ -41,21 +41,28 @@ jobs:
uses: gradle/actions/setup-gradle@v3
with:
cache-read-only: ${{ github.ref != 'refs/heads/develop' }}
- - name: Assemble debug APK
+ - name: Assemble debug Gplay APK
if: ${{ matrix.variant == 'debug' }}
env:
ELEMENT_ANDROID_MAPTILER_API_KEY: ${{ secrets.MAPTILER_KEY }}
ELEMENT_ANDROID_MAPTILER_LIGHT_MAP_ID: ${{ secrets.MAPTILER_LIGHT_MAP_ID }}
ELEMENT_ANDROID_MAPTILER_DARK_MAP_ID: ${{ secrets.MAPTILER_DARK_MAP_ID }}
- run: ./gradlew :app:assembleGplayDebug :app:assembleFDroidDebug -PallWarningsAsErrors=true $CI_GRADLE_ARG_PROPERTIES
- - name: Upload APK APKs
+ run: ./gradlew :app:assembleGplayDebug -PallWarningsAsErrors=true $CI_GRADLE_ARG_PROPERTIES
+ - name: Assemble debug Fdroid APK
+ if: ${{ matrix.variant == 'debug' }}
+ env:
+ ELEMENT_ANDROID_MAPTILER_API_KEY: ${{ secrets.MAPTILER_KEY }}
+ ELEMENT_ANDROID_MAPTILER_LIGHT_MAP_ID: ${{ secrets.MAPTILER_LIGHT_MAP_ID }}
+ ELEMENT_ANDROID_MAPTILER_DARK_MAP_ID: ${{ secrets.MAPTILER_DARK_MAP_ID }}
+ run: ./gradlew app:assembleFDroidDebug -PallWarningsAsErrors=true $CI_GRADLE_ARG_PROPERTIES
+ - name: Upload debug APKs
if: ${{ matrix.variant == 'debug' }}
uses: actions/upload-artifact@v4
with:
name: elementx-debug
path: |
- app/build/outputs/apk/gplay/debug/*.apk
- app/build/outputs/apk/fdroid/debug/*.apk
+ app/build/outputs/apk/gplay/debug/*-universal-debug.apk
+ app/build/outputs/apk/fdroid/debug/*-universal-debug.apk
- uses: rnkdsh/action-upload-diawi@v1.5.5
id: diawi
# Do not fail the whole build if Diawi upload fails
diff --git a/.github/workflows/danger.yml b/.github/workflows/danger.yml
index e1c9205473..295c203107 100644
--- a/.github/workflows/danger.yml
+++ b/.github/workflows/danger.yml
@@ -11,7 +11,7 @@ jobs:
- run: |
npm install --save-dev @babel/plugin-transform-flow-strip-types
- name: Danger
- uses: danger/danger-js@11.3.1
+ uses: danger/danger-js@12.2.0
with:
args: "--dangerfile ./tools/danger/dangerfile.js"
env:
diff --git a/.github/workflows/generate_github_pages.yml b/.github/workflows/generate_github_pages.yml
index 3780c7a87d..96f64eb1f7 100644
--- a/.github/workflows/generate_github_pages.yml
+++ b/.github/workflows/generate_github_pages.yml
@@ -22,10 +22,10 @@ jobs:
uses: gradle/actions/setup-gradle@v3
with:
cache-read-only: ${{ github.ref != 'refs/heads/develop' }}
- - name: Set up Python 3.9
+ - name: Set up Python 3.12
uses: actions/setup-python@v5
with:
- python-version: 3.9
+ python-version: 3.12
- name: Run World screenshots generation script
run: |
./tools/test/generateWorldScreenshots.py
diff --git a/.github/workflows/maestro.yml b/.github/workflows/maestro.yml
index d0e57497a5..dc625aba32 100644
--- a/.github/workflows/maestro.yml
+++ b/.github/workflows/maestro.yml
@@ -12,12 +12,10 @@ env:
CI_GRADLE_ARG_PROPERTIES: --stacktrace -PpreDexEnable=false --max-workers 8 --no-daemon
jobs:
- maestro-cloud:
- name: Maestro test suite
+ build-apk:
+ name: Build APK
runs-on: ubuntu-latest
if: github.event_name == 'workflow_dispatch' || github.event.label.name == 'Run-Maestro'
- strategy:
- fail-fast: false
# Allow one per PR.
concurrency:
group: ${{ format('maestro-{0}', github.ref) }}
@@ -41,19 +39,49 @@ jobs:
distribution: 'temurin' # See 'Supported distributions' for available options
java-version: '17'
- name: Assemble debug APK
- run: ./gradlew :app:assembleDebug $CI_GRADLE_ARG_PROPERTIES
+ run: ./gradlew :app:assembleGplayDebug $CI_GRADLE_ARG_PROPERTIES
if: (github.event_name == 'pull_request' && github.event.pull_request.fork == null) || github.event_name == 'workflow_dispatch'
env:
ELEMENT_ANDROID_MAPTILER_API_KEY: ${{ secrets.MAPTILER_KEY }}
ELEMENT_ANDROID_MAPTILER_LIGHT_MAP_ID: ${{ secrets.MAPTILER_LIGHT_MAP_ID }}
ELEMENT_ANDROID_MAPTILER_DARK_MAP_ID: ${{ secrets.MAPTILER_DARK_MAP_ID }}
+ - name: Upload APK as artifact
+ uses: actions/upload-artifact@v4
+ with:
+ name: elementx-apk-maestro
+ path: |
+ app/build/outputs/apk/gplay/debug/app-gplay-x86_64-debug.apk
+ retention-days: 5
+ overwrite: true
+ if-no-files-found: error
+
+ maestro-cloud:
+ name: Maestro test suite
+ runs-on: ubuntu-latest
+ needs: build-apk
+ if: github.event_name == 'workflow_dispatch' || github.event.label.name == 'Run-Maestro'
+ # Allow one per PR.
+ concurrency:
+ group: ${{ format('maestro-{0}', github.ref) }}
+ cancel-in-progress: true
+ steps:
+ - uses: actions/checkout@v4
+ if: (github.event_name == 'pull_request' && github.event.pull_request.fork == null) || github.event_name == 'workflow_dispatch'
+ with:
+ # Ensure we are building the branch and not the branch after being merged on develop
+ # https://github.com/actions/checkout/issues/881
+ ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.ref }}
+ - name: Download APK artifact from previous job
+ uses: actions/download-artifact@v4
+ with:
+ name: elementx-apk-maestro
- uses: mobile-dev-inc/action-maestro-cloud@v1.8.1
if: (github.event_name == 'pull_request' && github.event.pull_request.fork == null) || github.event_name == 'workflow_dispatch'
with:
api-key: ${{ secrets.MAESTRO_CLOUD_API_KEY }}
# Doc says (https://github.com/mobile-dev-inc/action-maestro-cloud#android):
# app-file should point to an x86 compatible APK file, so upload the x86_64 one (much smaller than the universal APK).
- app-file: app/build/outputs/apk/gplay/debug/app-gplay-x86_64-debug.apk
+ app-file: app-gplay-x86_64-debug.apk
env: |
MAESTRO_USERNAME=maestroelement
MAESTRO_PASSWORD=${{ secrets.MATRIX_MAESTRO_ACCOUNT_PASSWORD }}
diff --git a/.github/workflows/quality.yml b/.github/workflows/quality.yml
index dfd5315c9f..b62dbb0127 100644
--- a/.github/workflows/quality.yml
+++ b/.github/workflows/quality.yml
@@ -26,10 +26,10 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- - name: Set up Python 3.9
+ - name: Set up Python 3.12
uses: actions/setup-python@v5
with:
- python-version: 3.9
+ python-version: 3.12
- name: Search for invalid screenshot files
run: ./tools/test/checkInvalidScreenshots.py
@@ -72,7 +72,7 @@ jobs:
yarn add danger-plugin-lint-report --dev
- name: Danger lint
if: always()
- uses: danger/danger-js@11.3.1
+ uses: danger/danger-js@12.2.0
with:
args: "--dangerfile ./tools/danger/dangerfile-lint.js"
env:
diff --git a/.github/workflows/sync-localazy.yml b/.github/workflows/sync-localazy.yml
index 50ebc12c95..c3f19da6ae 100644
--- a/.github/workflows/sync-localazy.yml
+++ b/.github/workflows/sync-localazy.yml
@@ -21,10 +21,10 @@ jobs:
uses: gradle/actions/setup-gradle@v3
with:
cache-read-only: ${{ github.ref != 'refs/heads/develop' }}
- - name: Set up Python 3.9
+ - name: Set up Python 3.12
uses: actions/setup-python@v5
with:
- python-version: 3.9
+ python-version: 3.12
- name: Setup Localazy
run: |
curl -sS https://dist.localazy.com/debian/pubkey.gpg | sudo gpg --dearmor -o /etc/apt/trusted.gpg.d/localazy.gpg
@@ -33,6 +33,7 @@ jobs:
- name: Run Localazy script
run: |
./tools/localazy/downloadStrings.sh --all
+ ./tools/localazy/importSupportedLocalesFromLocalazy.py
./tools/test/generateAllScreenshots.py
- name: Create Pull Request for Strings
uses: peter-evans/create-pull-request@v6
diff --git a/.github/workflows/sync-sas-strings.yml b/.github/workflows/sync-sas-strings.yml
index 79de1b7691..56336626c7 100644
--- a/.github/workflows/sync-sas-strings.yml
+++ b/.github/workflows/sync-sas-strings.yml
@@ -13,10 +13,10 @@ jobs:
# No concurrency required, runs every time on a schedule.
steps:
- uses: actions/checkout@v4
- - name: Set up Python 3.9
+ - name: Set up Python 3.12
uses: actions/setup-python@v5
with:
- python-version: 3.9
+ python-version: 3.12
- name: Install Prerequisite dependencies
run: |
pip install requests
diff --git a/.maestro/tests/roomList/createAndDeleteRoom.yaml b/.maestro/tests/roomList/createAndDeleteRoom.yaml
index 6061915493..ae6f5772c6 100644
--- a/.maestro/tests/roomList/createAndDeleteRoom.yaml
+++ b/.maestro/tests/roomList/createAndDeleteRoom.yaml
@@ -16,7 +16,7 @@ appId: ${MAESTRO_APP_ID}
- tapOn: "Create"
- takeScreenshot: build/maestro/320-createAndDeleteRoom
- tapOn: "aRoomName"
-- tapOn: "Invite people"
+- tapOn: "Invite"
# assert there's 1 member and 1 invitee
- tapOn: "Search for someone"
- inputText: ${MAESTRO_INVITEE2_MXID}
diff --git a/CHANGES.md b/CHANGES.md
index 38cb6e72cc..d091404139 100644
--- a/CHANGES.md
+++ b/CHANGES.md
@@ -1,3 +1,41 @@
+Changes in Element X v0.4.12 (2024-05-13)
+=========================================
+
+Features ✨
+----------
+- Add support for expected decryption errors due to membership (UX and analytics). ([#2754](https://github.com/element-hq/element-x-android/issues/2754))
+- Handle permalink navigation to Events. ([#2759](https://github.com/element-hq/element-x-android/issues/2759))
+- Pretty-print event JSON in debug viewer ([#2771](https://github.com/element-hq/element-x-android/issues/2771))
+- Add support for external permalinks. ([#2776](https://github.com/element-hq/element-x-android/issues/2776))
+- Enable support for Android per-app language preferences ([#2795](https://github.com/element-hq/element-x-android/issues/2795))
+
+Bugfixes 🐛
+----------
+- Fix session verification being asked again for already verified users. ([#2718](https://github.com/element-hq/element-x-android/issues/2718))
+- Instead of displaying 'create new recovery key' on the session verification screen when there is no other session active, display it always under the 'enter recovery key' screen. ([#2740](https://github.com/element-hq/element-x-android/issues/2740))
+- Adjust the typography used in the selected user component so a user's display name fits better. ([#2760](https://github.com/element-hq/element-x-android/issues/2760))
+- User display name overflows in timeline messages when it's way too long. ([#2761](https://github.com/element-hq/element-x-android/issues/2761))
+- Ensure the application open the room when a notification is clicked. ([#2778](https://github.com/element-hq/element-x-android/issues/2778))
+- Enforce mandatory session verification only for new logins. ([#2810](https://github.com/element-hq/element-x-android/issues/2810))
+- Make log less verbose, make sure we upload as many log files as possible before reaching the request size limit of the bug reporting service, discard older logs if they don't fit. ([#2825](https://github.com/element-hq/element-x-android/issues/2825))
+- Remove 'Join' button in room directory search results. ([#2827](https://github.com/element-hq/element-x-android/issues/2827))
+- Add missing `app_id` and `Version` properties to bug reports. ([#2829](https://github.com/element-hq/element-x-android/issues/2829))
+
+Other changes
+-------------
+- RoomMember screen: fallback to userProfile data, if the member is not a user of the room. ([#2721](https://github.com/element-hq/element-x-android/issues/2721))
+- Migrate application data. ([#2749](https://github.com/element-hq/element-x-android/issues/2749))
+- Let the SDK manage the file log cleanup, and keep one week of log. ([#2758](https://github.com/element-hq/element-x-android/issues/2758))
+- UX cleanup: reorder options in the main settings screen. ([#2801](https://github.com/element-hq/element-x-android/issues/2801))
+- Analytics: Add support to report current session verification and recovery state ([#2806](https://github.com/element-hq/element-x-android/issues/2806))
+- UX cleanup: room details screen, add new CTA buttons for Invite and Call actions. ([#2814](https://github.com/element-hq/element-x-android/issues/2814))
+- UX cleanup: user profile. Move send DM to a call to action button, add 'Call' CTA too. ([#2818](https://github.com/element-hq/element-x-android/issues/2818))
+- Add room badges to room details screen. ([#2822](https://github.com/element-hq/element-x-android/issues/2822))
+
+Security
+-------------
+- Bump the Rust SDK to `v0.2.18` to remediate [CVE-2024-34353 / GHSA-9ggc-845v-gcgv](https://github.com/matrix-org/matrix-rust-sdk/security/advisories/GHSA-9ggc-845v-gcgv).
+
Changes in Element X v0.4.10 (2024-04-17)
=========================================
@@ -5,14 +43,14 @@ Matrix Rust SDK 0.2.14
Features ✨
----------
- - Rework room navigation to handle unknown room and prepare work on permalink. ([#2695](https://github.com/element-hq/element-x-android/issues/2695))
+- Rework room navigation to handle unknown room and prepare work on permalink. ([#2695](https://github.com/element-hq/element-x-android/issues/2695))
Other changes
-------------
- - Encrypt new session data with a passphrase ([#2703](https://github.com/element-hq/element-x-android/issues/2703))
- - Use sdk API to build permalinks ([#2708](https://github.com/element-hq/element-x-android/issues/2708))
- - Parse permalink using parseMatrixEntityFrom from the SDK ([#2709](https://github.com/element-hq/element-x-android/issues/2709))
- - Fix compile for forks that use the `noop` analytics module ([#2698](https://github.com/element-hq/element-x-android/issues/2698))
+- Encrypt new session data with a passphrase ([#2703](https://github.com/element-hq/element-x-android/issues/2703))
+- Use sdk API to build permalinks ([#2708](https://github.com/element-hq/element-x-android/issues/2708))
+- Parse permalink using parseMatrixEntityFrom from the SDK ([#2709](https://github.com/element-hq/element-x-android/issues/2709))
+- Fix compile for forks that use the `noop` analytics module ([#2698](https://github.com/element-hq/element-x-android/issues/2698))
Changes in Element X v0.4.9 (2024-04-12)
@@ -20,31 +58,35 @@ Changes in Element X v0.4.9 (2024-04-12)
- Synchronize Localazy Strings.
+Security
+----------
+- Fix crash while processing a room message containing a malformed pill.
+
Changes in Element X v0.4.8 (2024-04-10)
========================================
Features ✨
----------
- - Move session recovery to the login flow. ([#2579](https://github.com/element-hq/element-x-android/issues/2579))
- - Move session verification to the after login flow and make it mandatory. ([#2580](https://github.com/element-hq/element-x-android/issues/2580))
- - Add a notification troubleshoot screen ([#2601](https://github.com/element-hq/element-x-android/issues/2601))
- - Add action to copy permalink ([#2650](https://github.com/element-hq/element-x-android/issues/2650))
+- Move session recovery to the login flow. ([#2579](https://github.com/element-hq/element-x-android/issues/2579))
+- Move session verification to the after login flow and make it mandatory. ([#2580](https://github.com/element-hq/element-x-android/issues/2580))
+- Add a notification troubleshoot screen ([#2601](https://github.com/element-hq/element-x-android/issues/2601))
+- Add action to copy permalink ([#2650](https://github.com/element-hq/element-x-android/issues/2650))
Bugfixes 🐛
----------
- - Fix analytics issue around room considered as space by mistake. ([#2612](https://github.com/element-hq/element-x-android/issues/2612))
- - Fix crash observed when going back to the room list. ([#2619](https://github.com/element-hq/element-x-android/issues/2619))
- - Hide Event org.matrix.msc3401.call.member on the timeline. ([#2625](https://github.com/element-hq/element-x-android/issues/2625))
- - Fall back to name-based generated avatars when image avatars don't load. ([#2667](https://github.com/element-hq/element-x-android/issues/2667))
+- Fix analytics issue around room considered as space by mistake. ([#2612](https://github.com/element-hq/element-x-android/issues/2612))
+- Fix crash observed when going back to the room list. ([#2619](https://github.com/element-hq/element-x-android/issues/2619))
+- Hide Event org.matrix.msc3401.call.member on the timeline. ([#2625](https://github.com/element-hq/element-x-android/issues/2625))
+- Fall back to name-based generated avatars when image avatars don't load. ([#2667](https://github.com/element-hq/element-x-android/issues/2667))
Other changes
-------------
- - Improve UI for notification permission screen in onboarding. ([#2581](https://github.com/element-hq/element-x-android/issues/2581))
- - Categorise members by role in change roles screen. ([#2593](https://github.com/element-hq/element-x-android/issues/2593))
- - Make completed poll more clearly visible ([#2608](https://github.com/element-hq/element-x-android/issues/2608))
- - Show users from last visited DM as suggestion when starting a Chat or when creating a Room. ([#2634](https://github.com/element-hq/element-x-android/issues/2634))
- - Enable room moderation feature. ([#2678](https://github.com/element-hq/element-x-android/issues/2678))
- - Improve analytics opt-in screen UI. ([#2684](https://github.com/element-hq/element-x-android/issues/2684))
+- Improve UI for notification permission screen in onboarding. ([#2581](https://github.com/element-hq/element-x-android/issues/2581))
+- Categorise members by role in change roles screen. ([#2593](https://github.com/element-hq/element-x-android/issues/2593))
+- Make completed poll more clearly visible ([#2608](https://github.com/element-hq/element-x-android/issues/2608))
+- Show users from last visited DM as suggestion when starting a Chat or when creating a Room. ([#2634](https://github.com/element-hq/element-x-android/issues/2634))
+- Enable room moderation feature. ([#2678](https://github.com/element-hq/element-x-android/issues/2678))
+- Improve analytics opt-in screen UI. ([#2684](https://github.com/element-hq/element-x-android/issues/2684))
Changes in Element X v0.4.7 (2024-03-26)
@@ -52,19 +94,19 @@ Changes in Element X v0.4.7 (2024-03-26)
Features ✨
----------
- - Enable the feature "RoomList filters". ([#2603](https://github.com/element-hq/element-x-android/issues/2603))
- - Enable the feature "Mark as unread" ([#2261](https://github.com/element-hq/element-x-android/issues/2261))
- - Implement MSC2530 (Body field as media caption) ([#2521](https://github.com/element-hq/element-x-android/issues/2521))
+- Enable the feature "RoomList filters". ([#2603](https://github.com/element-hq/element-x-android/issues/2603))
+- Enable the feature "Mark as unread" ([#2261](https://github.com/element-hq/element-x-android/issues/2261))
+- Implement MSC2530 (Body field as media caption) ([#2521](https://github.com/element-hq/element-x-android/issues/2521))
Bugfixes 🐛
----------
- - Use user avatar from cache if available. ([#2488](https://github.com/element-hq/element-x-android/issues/2488))
- - Update member list after changing member roles and when the room member list is opened. ([#2590](https://github.com/element-hq/element-x-android/issues/2590))
+- Use user avatar from cache if available. ([#2488](https://github.com/element-hq/element-x-android/issues/2488))
+- Update member list after changing member roles and when the room member list is opened. ([#2590](https://github.com/element-hq/element-x-android/issues/2590))
Other changes
-------------
- - Compound: add `BigIcon`, `BigCheckmark` and `PageTitle` components. ([#2574](https://github.com/element-hq/element-x-android/issues/2574))
- - Remove Welcome screen from the FTUE. ([#2584](https://github.com/element-hq/element-x-android/issues/2584))
+- Compound: add `BigIcon`, `BigCheckmark` and `PageTitle` components. ([#2574](https://github.com/element-hq/element-x-android/issues/2574))
+- Remove Welcome screen from the FTUE. ([#2584](https://github.com/element-hq/element-x-android/issues/2584))
Changes in Element X v0.4.6 (2024-03-15)
@@ -72,26 +114,26 @@ Changes in Element X v0.4.6 (2024-03-15)
Features ✨
----------
- - Admins can now change user roles in rooms. ([#2257](https://github.com/element-hq/element-x-android/issues/2257))
- - Room member moderation: remove, ban and unban users from a room. ([#2258](https://github.com/element-hq/element-x-android/issues/2258))
- - Change a room's permissions power levels. ([#2259](https://github.com/element-hq/element-x-android/issues/2259))
- - Add state timeline events and notifications for legacy call invites. ([#2485](https://github.com/element-hq/element-x-android/issues/2485))
+- Admins can now change user roles in rooms. ([#2257](https://github.com/element-hq/element-x-android/issues/2257))
+- Room member moderation: remove, ban and unban users from a room. ([#2258](https://github.com/element-hq/element-x-android/issues/2258))
+- Change a room's permissions power levels. ([#2259](https://github.com/element-hq/element-x-android/issues/2259))
+- Add state timeline events and notifications for legacy call invites. ([#2485](https://github.com/element-hq/element-x-android/issues/2485))
Bugfixes 🐛
----------
- - Added empty state to banned member list. ([#+add-empty-state-to-banned-members-list](https://github.com/element-hq/element-x-android/issues/+add-empty-state-to-banned-members-list))
- - Prevent sending empty messages. ([#995](https://github.com/element-hq/element-x-android/issues/995))
- - Use the display name only once in display name change events. The user should be referenced by `userId` instead. ([#2125](https://github.com/element-hq/element-x-android/issues/2125))
- - Hide blocked users list when there are no blocked users. ([#2198](https://github.com/element-hq/element-x-android/issues/2198))
- - Fix timeline not showing sender info when room is marked as direct but not a 1:1 room. ([#2530](https://github.com/element-hq/element-x-android/issues/2530))
+- Added empty state to banned member list. ([#+add-empty-state-to-banned-members-list](https://github.com/element-hq/element-x-android/issues/+add-empty-state-to-banned-members-list))
+- Prevent sending empty messages. ([#995](https://github.com/element-hq/element-x-android/issues/995))
+- Use the display name only once in display name change events. The user should be referenced by `userId` instead. ([#2125](https://github.com/element-hq/element-x-android/issues/2125))
+- Hide blocked users list when there are no blocked users. ([#2198](https://github.com/element-hq/element-x-android/issues/2198))
+- Fix timeline not showing sender info when room is marked as direct but not a 1:1 room. ([#2530](https://github.com/element-hq/element-x-android/issues/2530))
Other changes
-------------
- - Add `local_time`, `utc_time` and `sdk_sha` params to bug reports so they're easier to investigate. ([#+add-time-and-sdk-sha-params-to-bugreports](https://github.com/element-hq/element-x-android/issues/+add-time-and-sdk-sha-params-to-bugreports))
- - Improve room member list loading times, increase chunk size ([#2322](https://github.com/element-hq/element-x-android/issues/2322))
- - Improve room member list loading UX. ([#2452](https://github.com/element-hq/element-x-android/issues/2452))
- - Remove the special log level for the Rust SDK read receipts. ([#2511](https://github.com/element-hq/element-x-android/issues/2511))
- - Track UTD errors. ([#2544](https://github.com/element-hq/element-x-android/issues/2544))
+- Add `local_time`, `utc_time` and `sdk_sha` params to bug reports so they're easier to investigate. ([#+add-time-and-sdk-sha-params-to-bugreports](https://github.com/element-hq/element-x-android/issues/+add-time-and-sdk-sha-params-to-bugreports))
+- Improve room member list loading times, increase chunk size ([#2322](https://github.com/element-hq/element-x-android/issues/2322))
+- Improve room member list loading UX. ([#2452](https://github.com/element-hq/element-x-android/issues/2452))
+- Remove the special log level for the Rust SDK read receipts. ([#2511](https://github.com/element-hq/element-x-android/issues/2511))
+- Track UTD errors. ([#2544](https://github.com/element-hq/element-x-android/issues/2544))
Changes in Element X v0.4.5 (2024-02-28)
@@ -99,22 +141,22 @@ Changes in Element X v0.4.5 (2024-02-28)
Features ✨
----------
- - Mark a room or dm as favourite. ([#2208](https://github.com/element-hq/element-x-android/issues/2208))
- - Add moderation to rooms:
- - Sort member in room member list by powerlevel, display their roles.
- - Display banner users in room member list for users with enough power level to ban/unban. ([#2256](https://github.com/element-hq/element-x-android/issues/2256))
- - MediaViewer : introduce fullscreen and flick to dismiss behavior. ([#2390](https://github.com/element-hq/element-x-android/issues/2390))
- - Allow user-installed certificates to be used by the HTTP client ([#2992](https://github.com/element-hq/element-x-android/issues/2992))
+- Mark a room or dm as favourite. ([#2208](https://github.com/element-hq/element-x-android/issues/2208))
+- Add moderation to rooms:
+ - Sort member in room member list by powerlevel, display their roles.
+ - Display banner users in room member list for users with enough power level to ban/unban. ([#2256](https://github.com/element-hq/element-x-android/issues/2256))
+- MediaViewer : introduce fullscreen and flick to dismiss behavior. ([#2390](https://github.com/element-hq/element-x-android/issues/2390))
+- Allow user-installed certificates to be used by the HTTP client ([#2992](https://github.com/element-hq/element-x-android/issues/2992))
Bugfixes 🐛
----------
- - Do not display empty room list state before the loading one when we still don't have any items ([#+do-not-display-empty-state-before-loading-roomlist](https://github.com/element-hq/element-x-android/issues/+do-not-display-empty-state-before-loading-roomlist))
- - Improve how Talkback works with the timeline. Sadly, it's still not 100% working, but there is some issue with the `LazyColumn` using `reverseLayout` that only Google can fix. ([#+improve-accessibility-in-timeline](https://github.com/element-hq/element-x-android/issues/+improve-accessibility-in-timeline))
- - Add ability to enter a recovery key to verify the session. Also fixes some refresh issues with the verification session state. ([#2421](https://github.com/element-hq/element-x-android/issues/2421))
+- Do not display empty room list state before the loading one when we still don't have any items ([#+do-not-display-empty-state-before-loading-roomlist](https://github.com/element-hq/element-x-android/issues/+do-not-display-empty-state-before-loading-roomlist))
+- Improve how Talkback works with the timeline. Sadly, it's still not 100% working, but there is some issue with the `LazyColumn` using `reverseLayout` that only Google can fix. ([#+improve-accessibility-in-timeline](https://github.com/element-hq/element-x-android/issues/+improve-accessibility-in-timeline))
+- Add ability to enter a recovery key to verify the session. Also fixes some refresh issues with the verification session state. ([#2421](https://github.com/element-hq/element-x-android/issues/2421))
Other changes
-------------
- - Provide the current system proxy setting to the Rust SDK. ([#2420](https://github.com/element-hq/element-x-android/issues/2420))
+- Provide the current system proxy setting to the Rust SDK. ([#2420](https://github.com/element-hq/element-x-android/issues/2420))
Changes in Element X v0.4.4 (2024-02-15)
@@ -130,31 +172,31 @@ Changes in Element X v0.4.3 (2024-02-14)
Features ✨
----------
- - Change "Read receipts" advanced setting used to send private Read Receipt to "Share presence" settings. When disabled, private Read Receipts will be sent, and no typing notification will be sent. Also Read Receipts and typing notifications will not be rendered in the timeline. ([#2241](https://github.com/element-hq/element-x-android/issues/2241))
- - Render typing notifications. ([#2242](https://github.com/element-hq/element-x-android/issues/2242))
- - Manually mark a room as unread. ([#2261](https://github.com/element-hq/element-x-android/issues/2261))
- - Add empty state to the room list. ([#2330](https://github.com/element-hq/element-x-android/issues/2330))
- - Allow joining unencrypted video calls in non encrypted rooms. ([#2333](https://github.com/element-hq/element-x-android/issues/2333))
+- Change "Read receipts" advanced setting used to send private Read Receipt to "Share presence" settings. When disabled, private Read Receipts will be sent, and no typing notification will be sent. Also Read Receipts and typing notifications will not be rendered in the timeline. ([#2241](https://github.com/element-hq/element-x-android/issues/2241))
+- Render typing notifications. ([#2242](https://github.com/element-hq/element-x-android/issues/2242))
+- Manually mark a room as unread. ([#2261](https://github.com/element-hq/element-x-android/issues/2261))
+- Add empty state to the room list. ([#2330](https://github.com/element-hq/element-x-android/issues/2330))
+- Allow joining unencrypted video calls in non encrypted rooms. ([#2333](https://github.com/element-hq/element-x-android/issues/2333))
Bugfixes 🐛
----------
- - Fix crash after unregistering UnifiedPush distributor ([#2304](https://github.com/element-hq/element-x-android/issues/2304))
- - Add missing device id to settings screen. ([#2316](https://github.com/element-hq/element-x-android/issues/2316))
- - Open the keyboard (and keep it opened) when creating a poll. ([#2329](https://github.com/element-hq/element-x-android/issues/2329))
- - Fix message forwarding after SDK API change related to Timeline intitialization.
+- Fix crash after unregistering UnifiedPush distributor ([#2304](https://github.com/element-hq/element-x-android/issues/2304))
+- Add missing device id to settings screen. ([#2316](https://github.com/element-hq/element-x-android/issues/2316))
+- Open the keyboard (and keep it opened) when creating a poll. ([#2329](https://github.com/element-hq/element-x-android/issues/2329))
+- Fix message forwarding after SDK API change related to Timeline intitialization.
Other changes
-------------
- - Adjusted the login flow buttons so the continue button is always at the same height ([#825](https://github.com/element-hq/element-x-android/issues/825))
- - Move migration screen to within the room list ([#2310](https://github.com/element-hq/element-x-android/issues/2310))
- - Render correctly in reply to data when Event cannot be decrypted or has been redacted ([#2318](https://github.com/element-hq/element-x-android/issues/2318))
- - Remove Compose Foundation version pinning workaround. This was done to avoid a bug introduced in the default foundation version used by the material3 library, but that has already been fixed.
- - Remove `FilterHiddenStateEventsProcessor`, as this is already handled by the Rust SDK.
- - Remove session preferences on user log out.
+- Adjusted the login flow buttons so the continue button is always at the same height ([#825](https://github.com/element-hq/element-x-android/issues/825))
+- Move migration screen to within the room list ([#2310](https://github.com/element-hq/element-x-android/issues/2310))
+- Render correctly in reply to data when Event cannot be decrypted or has been redacted ([#2318](https://github.com/element-hq/element-x-android/issues/2318))
+- Remove Compose Foundation version pinning workaround. This was done to avoid a bug introduced in the default foundation version used by the material3 library, but that has already been fixed.
+- Remove `FilterHiddenStateEventsProcessor`, as this is already handled by the Rust SDK.
+- Remove session preferences on user log out.
Breaking changes 🚨
-------------------
- - Update Compound icons in the project. Since the icon prefix changed to `ic_compound_` and the `CompoundIcons` helper now contains the vector icons as composable functions.
+- Update Compound icons in the project. Since the icon prefix changed to `ic_compound_` and the `CompoundIcons` helper now contains the vector icons as composable functions.
Changes in Element X v0.4.2 (2024-01-31)
========================================
@@ -163,31 +205,31 @@ Matrix SDK 🦀 v0.1.95
Features ✨
----------
- - Add 'send private read receipts' option in advanced settings ([#2204](https://github.com/element-hq/element-x-android/issues/2204))
- - Send typing notification ([#2240](https://github.com/element-hq/element-x-android/issues/2240)). Disabling the sending of typing notification and rendering typing notification will come soon.
+- Add 'send private read receipts' option in advanced settings ([#2204](https://github.com/element-hq/element-x-android/issues/2204))
+- Send typing notification ([#2240](https://github.com/element-hq/element-x-android/issues/2240)). Disabling the sending of typing notification and rendering typing notification will come soon.
Bugfixes 🐛
----------
- - Make the room settings screen update automatically when new room info (name, avatar, topic) is available. ([#921](https://github.com/element-hq/element-x-android/issues/921))
- - Update timeline items' read receipts when the room members info is loaded. ([#2176](https://github.com/element-hq/element-x-android/issues/2176))
- - Edited text message bubbles should resize when edited ([#2260](https://github.com/element-hq/element-x-android/issues/2260))
- - Ensure login and password exclude `\n` ([#2263](https://github.com/element-hq/element-x-android/issues/2263))
- - Room list Ensure the indicators stay grey if the global setting is set to mention only and a regular message is received. ([#2282](https://github.com/element-hq/element-x-android/issues/2282))
+- Make the room settings screen update automatically when new room info (name, avatar, topic) is available. ([#921](https://github.com/element-hq/element-x-android/issues/921))
+- Update timeline items' read receipts when the room members info is loaded. ([#2176](https://github.com/element-hq/element-x-android/issues/2176))
+- Edited text message bubbles should resize when edited ([#2260](https://github.com/element-hq/element-x-android/issues/2260))
+- Ensure login and password exclude `\n` ([#2263](https://github.com/element-hq/element-x-android/issues/2263))
+- Room list Ensure the indicators stay grey if the global setting is set to mention only and a regular message is received. ([#2282](https://github.com/element-hq/element-x-android/issues/2282))
Other changes
-------------
- - Add a special logging configuration for nightlies so we can get more detailed info for existing issues. ([#+add-special-tracing-configuration-for-nightlies](https://github.com/element-hq/element-x-android/issues/+add-special-tracing-configuration-for-nightlies))
- - Try mitigating unexpected logouts by making getting/storing session data use a Mutex for synchronization.
+- Add a special logging configuration for nightlies so we can get more detailed info for existing issues. ([#+add-special-tracing-configuration-for-nightlies](https://github.com/element-hq/element-x-android/issues/+add-special-tracing-configuration-for-nightlies))
+- Try mitigating unexpected logouts by making getting/storing session data use a Mutex for synchronization.
Also added some more logs so we can understand exactly where it's failing. ([#+try-mitigating-unexpected-logouts](https://github.com/element-hq/element-x-android/issues/+try-mitigating-unexpected-logouts))
- - Upgrade Material3 Compose to `1.2.0-beta02`.
+- Upgrade Material3 Compose to `1.2.0-beta02`.
There is also a constraint on a transitive Compose Foundation dependency version (1.6.0-beta02) that fixes the timeline scrolling issue. ([#0-beta02](https://github.com/element-hq/element-x-android/issues/0-beta02))
- - Disambiguate display name in the timeline. ([#2215](https://github.com/element-hq/element-x-android/issues/2215))
+- Disambiguate display name in the timeline. ([#2215](https://github.com/element-hq/element-x-android/issues/2215))
- Disambiguate display name in notifications ([#2224](https://github.com/element-hq/element-x-android/issues/2224))
- - Remove room creation, self-join of room creator and 'this is the beginning of X' timeline items for DMs. ([#2217](https://github.com/element-hq/element-x-android/issues/2217))
- - Encrypt databases used by the Rust SDK on Nightly and Debug builds. ([#2219](https://github.com/element-hq/element-x-android/issues/2219))
- - Fallback to UnifiedPush (if available) if the PlayServices are not installed on the device. ([#2248](https://github.com/element-hq/element-x-android/issues/2248))
- - Add "Report a problem" button to the onboarding screen ([#2275](https://github.com/element-hq/element-x-android/issues/2275))
- - Add in app logs viewer to the "Report a problem" screen. ([#2276](https://github.com/element-hq/element-x-android/issues/2276))
+- Remove room creation, self-join of room creator and 'this is the beginning of X' timeline items for DMs. ([#2217](https://github.com/element-hq/element-x-android/issues/2217))
+- Encrypt databases used by the Rust SDK on Nightly and Debug builds. ([#2219](https://github.com/element-hq/element-x-android/issues/2219))
+- Fallback to UnifiedPush (if available) if the PlayServices are not installed on the device. ([#2248](https://github.com/element-hq/element-x-android/issues/2248))
+- Add "Report a problem" button to the onboarding screen ([#2275](https://github.com/element-hq/element-x-android/issues/2275))
+- Add in app logs viewer to the "Report a problem" screen. ([#2276](https://github.com/element-hq/element-x-android/issues/2276))
Changes in Element X v0.4.1 (2024-01-17)
@@ -195,35 +237,35 @@ Changes in Element X v0.4.1 (2024-01-17)
Features ✨
----------
- - Render m.sticker events ([#1949](https://github.com/element-hq/element-x-android/issues/1949))
- - Add support for sending images from the keyboard ([#1977](https://github.com/element-hq/element-x-android/issues/1977))
- - Added support for MSC4027 (render custom images in reactions) ([#2159](https://github.com/element-hq/element-x-android/issues/2159))
+- Render m.sticker events ([#1949](https://github.com/element-hq/element-x-android/issues/1949))
+- Add support for sending images from the keyboard ([#1977](https://github.com/element-hq/element-x-android/issues/1977))
+- Added support for MSC4027 (render custom images in reactions) ([#2159](https://github.com/element-hq/element-x-android/issues/2159))
Bugfixes 🐛
----------
- - Fix crash sending image with latest Posthog because of an usage of an internal Android method. ([#+crash-sending-image-with-latest-posthog](https://github.com/element-hq/element-x-android/issues/+crash-sending-image-with-latest-posthog))
- - Make sure the media viewer tries the main url first (if not empty) then the thumbnail url and then not open if both are missing instead of failing with an error dialog ([#1949](https://github.com/element-hq/element-x-android/issues/1949))
- - Fix room transition animation happens twice. ([#2084](https://github.com/element-hq/element-x-android/issues/2084))
- - Disable ability to send reaction if the user does not have the permission to. ([#2093](https://github.com/element-hq/element-x-android/issues/2093))
- - Trim whitespace at the end of messages to ensure we render the right content. ([#2099](https://github.com/element-hq/element-x-android/issues/2099))
- - Fix crashes in room list when the last message for a room was an extremely long one (several thousands of characters) with no line breaks. ([#2105](https://github.com/element-hq/element-x-android/issues/2105))
- - Disable rasterisation of Vector XMLs, which was causing crashes on API 23. ([#2124](https://github.com/element-hq/element-x-android/issues/2124))
- - Use `SubomposeLayout` for `ContentAvoidingLayout` to prevent wrong measurements in the layout process, leading to cut-off text messages in the timeline. ([#2155](https://github.com/element-hq/element-x-android/issues/2155))
- - Improve rendering of voice messages in the timeline in large displays ([#2156](https://github.com/element-hq/element-x-android/issues/2156))
- - Fix no indication that user list is loading when inviting to room. ([#2172](https://github.com/element-hq/element-x-android/issues/2172))
- - Hide keyboard when tapping on a message in the timeline. ([#2182](https://github.com/element-hq/element-x-android/issues/2182))
- - Mention selector gets stuck when quickly deleting the prompt. ([#2192](https://github.com/element-hq/element-x-android/issues/2192))
- - Hide verbose state events from the timeline ([#2216](https://github.com/element-hq/element-x-android/issues/2216))
+- Fix crash sending image with latest Posthog because of an usage of an internal Android method. ([#+crash-sending-image-with-latest-posthog](https://github.com/element-hq/element-x-android/issues/+crash-sending-image-with-latest-posthog))
+- Make sure the media viewer tries the main url first (if not empty) then the thumbnail url and then not open if both are missing instead of failing with an error dialog ([#1949](https://github.com/element-hq/element-x-android/issues/1949))
+- Fix room transition animation happens twice. ([#2084](https://github.com/element-hq/element-x-android/issues/2084))
+- Disable ability to send reaction if the user does not have the permission to. ([#2093](https://github.com/element-hq/element-x-android/issues/2093))
+- Trim whitespace at the end of messages to ensure we render the right content. ([#2099](https://github.com/element-hq/element-x-android/issues/2099))
+- Fix crashes in room list when the last message for a room was an extremely long one (several thousands of characters) with no line breaks. ([#2105](https://github.com/element-hq/element-x-android/issues/2105))
+- Disable rasterisation of Vector XMLs, which was causing crashes on API 23. ([#2124](https://github.com/element-hq/element-x-android/issues/2124))
+- Use `SubomposeLayout` for `ContentAvoidingLayout` to prevent wrong measurements in the layout process, leading to cut-off text messages in the timeline. ([#2155](https://github.com/element-hq/element-x-android/issues/2155))
+- Improve rendering of voice messages in the timeline in large displays ([#2156](https://github.com/element-hq/element-x-android/issues/2156))
+- Fix no indication that user list is loading when inviting to room. ([#2172](https://github.com/element-hq/element-x-android/issues/2172))
+- Hide keyboard when tapping on a message in the timeline. ([#2182](https://github.com/element-hq/element-x-android/issues/2182))
+- Mention selector gets stuck when quickly deleting the prompt. ([#2192](https://github.com/element-hq/element-x-android/issues/2192))
+- Hide verbose state events from the timeline ([#2216](https://github.com/element-hq/element-x-android/issues/2216))
Other changes
-------------
- - Only apply `com.autonomousapps.dependency-analysis` plugin in those modules that need it. ([#+only-apply-dependency-analysis-plugin-where-needed](https://github.com/element-hq/element-x-android/issues/+only-apply-dependency-analysis-plugin-where-needed))
- - Migrate to Kover 0.7.X ([#1782](https://github.com/element-hq/element-x-android/issues/1782))
- - Remove extra logout screen. ([#2072](https://github.com/element-hq/element-x-android/issues/2072))
- - Handle `MembershipChange.NONE` rendering in the timeline. ([#2102](https://github.com/element-hq/element-x-android/issues/2102))
- - Remove extra previews for timestamp view with 'document' case ([#2127](https://github.com/element-hq/element-x-android/issues/2127))
- - Bump AGP version to 8.2.0 ([#2142](https://github.com/element-hq/element-x-android/issues/2142))
- - Replace 'leave room' text with 'leave conversation' for DMs. ([#2218](https://github.com/element-hq/element-x-android/issues/2218))
+- Only apply `com.autonomousapps.dependency-analysis` plugin in those modules that need it. ([#+only-apply-dependency-analysis-plugin-where-needed](https://github.com/element-hq/element-x-android/issues/+only-apply-dependency-analysis-plugin-where-needed))
+- Migrate to Kover 0.7.X ([#1782](https://github.com/element-hq/element-x-android/issues/1782))
+- Remove extra logout screen. ([#2072](https://github.com/element-hq/element-x-android/issues/2072))
+- Handle `MembershipChange.NONE` rendering in the timeline. ([#2102](https://github.com/element-hq/element-x-android/issues/2102))
+- Remove extra previews for timestamp view with 'document' case ([#2127](https://github.com/element-hq/element-x-android/issues/2127))
+- Bump AGP version to 8.2.0 ([#2142](https://github.com/element-hq/element-x-android/issues/2142))
+- Replace 'leave room' text with 'leave conversation' for DMs. ([#2218](https://github.com/element-hq/element-x-android/issues/2218))
Changes in Element X v0.4.0 (2023-12-22)
@@ -231,75 +273,75 @@ Changes in Element X v0.4.0 (2023-12-22)
Features ✨
----------
- - Use the RTE library `TextView` to render text events in the timeline. Add support for mention pills - with no interaction yet. ([#1433](https://github.com/element-hq/element-x-android/issues/1433))
- - Tapping on a user mention pill opens their profile. ([#1448](https://github.com/element-hq/element-x-android/issues/1448))
- - Display different notifications for mentions. ([#1451](https://github.com/element-hq/element-x-android/issues/1451))
- - Reply to a poll ([#1848](https://github.com/element-hq/element-x-android/issues/1848))
- - Add plain text representation of messages ([#1850](https://github.com/element-hq/element-x-android/issues/1850))
- - Allow polls to be edited when they have not been voted on ([#1869](https://github.com/element-hq/element-x-android/issues/1869))
- - Scroll to end of timeline when sending a new message. ([#1877](https://github.com/element-hq/element-x-android/issues/1877))
- - Confirm back navigation when editing a poll only if the poll was changed ([#1886](https://github.com/element-hq/element-x-android/issues/1886))
- - Add option to delete a poll while editing the poll ([#1895](https://github.com/element-hq/element-x-android/issues/1895))
- - Open room member avatar when you click on it inside the member details screen. ([#1907](https://github.com/element-hq/element-x-android/issues/1907))
- - Poll history of a room is now accessible from the room details screen. ([#2014](https://github.com/element-hq/element-x-android/issues/2014))
- - Always close the invite list screen when there is no more invite. ([#2022](https://github.com/element-hq/element-x-android/issues/2022))
+- Use the RTE library `TextView` to render text events in the timeline. Add support for mention pills - with no interaction yet. ([#1433](https://github.com/element-hq/element-x-android/issues/1433))
+- Tapping on a user mention pill opens their profile. ([#1448](https://github.com/element-hq/element-x-android/issues/1448))
+- Display different notifications for mentions. ([#1451](https://github.com/element-hq/element-x-android/issues/1451))
+- Reply to a poll ([#1848](https://github.com/element-hq/element-x-android/issues/1848))
+- Add plain text representation of messages ([#1850](https://github.com/element-hq/element-x-android/issues/1850))
+- Allow polls to be edited when they have not been voted on ([#1869](https://github.com/element-hq/element-x-android/issues/1869))
+- Scroll to end of timeline when sending a new message. ([#1877](https://github.com/element-hq/element-x-android/issues/1877))
+- Confirm back navigation when editing a poll only if the poll was changed ([#1886](https://github.com/element-hq/element-x-android/issues/1886))
+- Add option to delete a poll while editing the poll ([#1895](https://github.com/element-hq/element-x-android/issues/1895))
+- Open room member avatar when you click on it inside the member details screen. ([#1907](https://github.com/element-hq/element-x-android/issues/1907))
+- Poll history of a room is now accessible from the room details screen. ([#2014](https://github.com/element-hq/element-x-android/issues/2014))
+- Always close the invite list screen when there is no more invite. ([#2022](https://github.com/element-hq/element-x-android/issues/2022))
Bugfixes 🐛
----------
- - Fix see room in the room list after leaving it. ([#1006](https://github.com/element-hq/element-x-android/issues/1006))
- - Adjust mention pills font weight and horizontal padding ([#1449](https://github.com/element-hq/element-x-android/issues/1449))
- - Font size in 'All Chats' header was changing mid-animation. ([#1572](https://github.com/element-hq/element-x-android/issues/1572))
- - Accessibility: do not read initial used for avatar out loud. ([#1864](https://github.com/element-hq/element-x-android/issues/1864))
- - Use the right avatar for DMs in DM rooms ([#1912](https://github.com/element-hq/element-x-android/issues/1912))
- - Fix scaling of timeline images: don't crop, don't set min/max aspect ratio values. ([#1940](https://github.com/element-hq/element-x-android/issues/1940))
- - Fix rendering of user name with vertical text by clipping the text. ([#1950](https://github.com/element-hq/element-x-android/issues/1950))
- - Do not render `roomId` if the room has no canonical alias. ([#1970](https://github.com/element-hq/element-x-android/issues/1970))
- - Fix avatar not displayed in notification when the app is not in background ([#1991](https://github.com/element-hq/element-x-android/issues/1991))
- - Fix wording in room invite members view: `Send` -> `Invite`. ([#2037](https://github.com/element-hq/element-x-android/issues/2037))
- - Timestamp positioning was broken, specially for edited messages. ([#2060](https://github.com/element-hq/element-x-android/issues/2060))
- - Emojis in custom reaction bottom sheet are too tiny. ([#2066](https://github.com/element-hq/element-x-android/issues/2066))
- - Set a default power level to join calls. Also, create new rooms taking this power level into account.
+- Fix see room in the room list after leaving it. ([#1006](https://github.com/element-hq/element-x-android/issues/1006))
+- Adjust mention pills font weight and horizontal padding ([#1449](https://github.com/element-hq/element-x-android/issues/1449))
+- Font size in 'All Chats' header was changing mid-animation. ([#1572](https://github.com/element-hq/element-x-android/issues/1572))
+- Accessibility: do not read initial used for avatar out loud. ([#1864](https://github.com/element-hq/element-x-android/issues/1864))
+- Use the right avatar for DMs in DM rooms ([#1912](https://github.com/element-hq/element-x-android/issues/1912))
+- Fix scaling of timeline images: don't crop, don't set min/max aspect ratio values. ([#1940](https://github.com/element-hq/element-x-android/issues/1940))
+- Fix rendering of user name with vertical text by clipping the text. ([#1950](https://github.com/element-hq/element-x-android/issues/1950))
+- Do not render `roomId` if the room has no canonical alias. ([#1970](https://github.com/element-hq/element-x-android/issues/1970))
+- Fix avatar not displayed in notification when the app is not in background ([#1991](https://github.com/element-hq/element-x-android/issues/1991))
+- Fix wording in room invite members view: `Send` -> `Invite`. ([#2037](https://github.com/element-hq/element-x-android/issues/2037))
+- Timestamp positioning was broken, specially for edited messages. ([#2060](https://github.com/element-hq/element-x-android/issues/2060))
+- Emojis in custom reaction bottom sheet are too tiny. ([#2066](https://github.com/element-hq/element-x-android/issues/2066))
+- Set a default power level to join calls. Also, create new rooms taking this power level into account.
Other changes
-------------
- - Add a warning for 'mentions and keywords only' notification option if your homeserver does not support it ([#1749](https://github.com/element-hq/element-x-android/issues/1749))
- - Remove `:libraries:theme` module, extract theme and tokens to [Compound Android](https://github.com/element-hq/compound-android). ([#1833](https://github.com/element-hq/element-x-android/issues/1833))
- - Update poll icons from Compound ([#1849](https://github.com/element-hq/element-x-android/issues/1849))
- - Add ability to see the room avatar in the media viewer. ([#1918](https://github.com/element-hq/element-x-android/issues/1918))
- - RoomList: introduce incremental loading to improve performances. ([#1920](https://github.com/element-hq/element-x-android/issues/1920))
- - Add toggle in the notification settings to disable notifications for room invites. ([#1944](https://github.com/element-hq/element-x-android/issues/1944))
- - Update rendering of Emojis displayed during verification. ([#1965](https://github.com/element-hq/element-x-android/issues/1965))
- - Hide sender info in direct rooms ([#1979](https://github.com/element-hq/element-x-android/issues/1979))
- - Render images in Notification ([#1991](https://github.com/element-hq/element-x-android/issues/1991))
- - Only process content.json from Localazy. ([#2031](https://github.com/element-hq/element-x-android/issues/2031))
- - Always show user avatar in message action sheet ([#2032](https://github.com/element-hq/element-x-android/issues/2032))
- - Hide room list dropdown menu. ([#2062](https://github.com/element-hq/element-x-android/issues/2062))
- - Enable Chat backup, Mentions and Read Receipt in release. ([#2087](https://github.com/element-hq/element-x-android/issues/2087))
- - Make most code used in Compose from `:libraries:matrix` and derived classes Immutable or Stable.
+- Add a warning for 'mentions and keywords only' notification option if your homeserver does not support it ([#1749](https://github.com/element-hq/element-x-android/issues/1749))
+- Remove `:libraries:theme` module, extract theme and tokens to [Compound Android](https://github.com/element-hq/compound-android). ([#1833](https://github.com/element-hq/element-x-android/issues/1833))
+- Update poll icons from Compound ([#1849](https://github.com/element-hq/element-x-android/issues/1849))
+- Add ability to see the room avatar in the media viewer. ([#1918](https://github.com/element-hq/element-x-android/issues/1918))
+- RoomList: introduce incremental loading to improve performances. ([#1920](https://github.com/element-hq/element-x-android/issues/1920))
+- Add toggle in the notification settings to disable notifications for room invites. ([#1944](https://github.com/element-hq/element-x-android/issues/1944))
+- Update rendering of Emojis displayed during verification. ([#1965](https://github.com/element-hq/element-x-android/issues/1965))
+- Hide sender info in direct rooms ([#1979](https://github.com/element-hq/element-x-android/issues/1979))
+- Render images in Notification ([#1991](https://github.com/element-hq/element-x-android/issues/1991))
+- Only process content.json from Localazy. ([#2031](https://github.com/element-hq/element-x-android/issues/2031))
+- Always show user avatar in message action sheet ([#2032](https://github.com/element-hq/element-x-android/issues/2032))
+- Hide room list dropdown menu. ([#2062](https://github.com/element-hq/element-x-android/issues/2062))
+- Enable Chat backup, Mentions and Read Receipt in release. ([#2087](https://github.com/element-hq/element-x-android/issues/2087))
+- Make most code used in Compose from `:libraries:matrix` and derived classes Immutable or Stable.
Changes in Element X v0.3.2 (2023-11-22)
========================================
Features ✨
----------
- - Add ongoing call indicator to rooms lists items. ([#1158](https://github.com/element-hq/element-x-android/issues/1158))
- - Add support for typing mentions in the message composer. ([#1453](https://github.com/element-hq/element-x-android/issues/1453))
- - Add intentional mentions to messages. This needs to be enabled in developer options since it's disabled by default. ([#1591](https://github.com/element-hq/element-x-android/issues/1591))
- - Update voice message recording behaviour. Instead of holding the record button, users can now tap the record button to start recording and tap again to stop recording. ([#1784](https://github.com/element-hq/element-x-android/issues/1784))
+- Add ongoing call indicator to rooms lists items. ([#1158](https://github.com/element-hq/element-x-android/issues/1158))
+- Add support for typing mentions in the message composer. ([#1453](https://github.com/element-hq/element-x-android/issues/1453))
+- Add intentional mentions to messages. This needs to be enabled in developer options since it's disabled by default. ([#1591](https://github.com/element-hq/element-x-android/issues/1591))
+- Update voice message recording behaviour. Instead of holding the record button, users can now tap the record button to start recording and tap again to stop recording. ([#1784](https://github.com/element-hq/element-x-android/issues/1784))
Bugfixes 🐛
----------
- - Always ensure media temp dir exists ([#1790](https://github.com/element-hq/element-x-android/issues/1790))
+- Always ensure media temp dir exists ([#1790](https://github.com/element-hq/element-x-android/issues/1790))
Other changes
-------------
- - Update icons and move away from `PreferenceText` components. ([#1718](https://github.com/element-hq/element-x-android/issues/1718))
- - Add item "This is the beginning of..." at the beginning of the timeline. ([#1801](https://github.com/element-hq/element-x-android/issues/1801))
- - LockScreen : rework LoggedInFlowNode and back management when locked. ([#1806](https://github.com/element-hq/element-x-android/issues/1806))
- - Suppress usage of removeTimeline method. ([#1824](https://github.com/element-hq/element-x-android/issues/1824))
- - Remove Element Call feature flag, it's now always enabled.
- - Reverted the EC base URL to `https://call.element.io`.
- - Moved the option to override this URL to developer settings from advanced settings.
+- Update icons and move away from `PreferenceText` components. ([#1718](https://github.com/element-hq/element-x-android/issues/1718))
+- Add item "This is the beginning of..." at the beginning of the timeline. ([#1801](https://github.com/element-hq/element-x-android/issues/1801))
+- LockScreen : rework LoggedInFlowNode and back management when locked. ([#1806](https://github.com/element-hq/element-x-android/issues/1806))
+- Suppress usage of removeTimeline method. ([#1824](https://github.com/element-hq/element-x-android/issues/1824))
+- Remove Element Call feature flag, it's now always enabled.
+- Reverted the EC base URL to `https://call.element.io`.
+- Moved the option to override this URL to developer settings from advanced settings.
Changes in Element X v0.3.1 (2023-11-09)
@@ -307,16 +349,16 @@ Changes in Element X v0.3.1 (2023-11-09)
Features ✨
----------
- - Chat backup is still under a feature flag, but when enabled, user can enter their recovery key (it's also possible to input a passphrase) to unlock the encrypted room history. ([#1770](https://github.com/element-hq/element-x-android/pull/1770))
+- Chat backup is still under a feature flag, but when enabled, user can enter their recovery key (it's also possible to input a passphrase) to unlock the encrypted room history. ([#1770](https://github.com/element-hq/element-x-android/pull/1770))
Bugfixes 🐛
----------
- - Improve confusing text in the 'ready to start verification' screen. ([#879](https://github.com/element-hq/element-x-android/issues/879))
- - Message composer wasn't resized when selecting a several lines message to reply to, then a single line one. ([#1560](https://github.com/element-hq/element-x-android/issues/1560))
+- Improve confusing text in the 'ready to start verification' screen. ([#879](https://github.com/element-hq/element-x-android/issues/879))
+- Message composer wasn't resized when selecting a several lines message to reply to, then a single line one. ([#1560](https://github.com/element-hq/element-x-android/issues/1560))
Other changes
-------------
- - PIN: Set lock grace period to 0. ([#1732](https://github.com/element-hq/element-x-android/issues/1732))
+- PIN: Set lock grace period to 0. ([#1732](https://github.com/element-hq/element-x-android/issues/1732))
Changes in Element X v0.3.0 (2023-10-31)
@@ -324,24 +366,24 @@ Changes in Element X v0.3.0 (2023-10-31)
Features ✨
----------
- - Element Call: change the 'join call' button in a chat room when there's an active call. ([#1158](https://github.com/element-hq/element-x-android/issues/1158))
- - Mentions: add mentions suggestion view in RTE ([#1452](https://github.com/element-hq/element-x-android/issues/1452))
- - Record and send voice messages ([#1596](https://github.com/element-hq/element-x-android/issues/1596))
- - Enable voice messages for all users ([#1669](https://github.com/element-hq/element-x-android/issues/1669))
- - Receive and play a voice message ([#2084](https://github.com/element-hq/element-x-android/issues/2084))
- - Enable Element Call integration in rooms by default, fix several issues when creating or joining calls.
+- Element Call: change the 'join call' button in a chat room when there's an active call. ([#1158](https://github.com/element-hq/element-x-android/issues/1158))
+- Mentions: add mentions suggestion view in RTE ([#1452](https://github.com/element-hq/element-x-android/issues/1452))
+- Record and send voice messages ([#1596](https://github.com/element-hq/element-x-android/issues/1596))
+- Enable voice messages for all users ([#1669](https://github.com/element-hq/element-x-android/issues/1669))
+- Receive and play a voice message ([#2084](https://github.com/element-hq/element-x-android/issues/2084))
+- Enable Element Call integration in rooms by default, fix several issues when creating or joining calls.
Bugfixes 🐛
----------
- - Group fallback notification to avoid having plenty of them displayed. ([#994](https://github.com/element-hq/element-x-android/issues/994))
- - Hide keyboard when exiting the chat room screen. ([#1375](https://github.com/element-hq/element-x-android/issues/1375))
- - Always register the pusher when application starts ([#1481](https://github.com/element-hq/element-x-android/issues/1481))
- - Ensure screen does not turn off when playing a video ([#1519](https://github.com/element-hq/element-x-android/issues/1519))
- - Fix issue where text is cleared when cancelling a reply ([#1617](https://github.com/element-hq/element-x-android/issues/1617))
+- Group fallback notification to avoid having plenty of them displayed. ([#994](https://github.com/element-hq/element-x-android/issues/994))
+- Hide keyboard when exiting the chat room screen. ([#1375](https://github.com/element-hq/element-x-android/issues/1375))
+- Always register the pusher when application starts ([#1481](https://github.com/element-hq/element-x-android/issues/1481))
+- Ensure screen does not turn off when playing a video ([#1519](https://github.com/element-hq/element-x-android/issues/1519))
+- Fix issue where text is cleared when cancelling a reply ([#1617](https://github.com/element-hq/element-x-android/issues/1617))
Other changes
-------------
- - Remove usage of blocking methods. ([#1563](https://github.com/element-hq/element-x-android/issues/1563))
+- Remove usage of blocking methods. ([#1563](https://github.com/element-hq/element-x-android/issues/1563))
Changes in Element X v0.2.4 (2023-10-12)
@@ -349,20 +391,20 @@ Changes in Element X v0.2.4 (2023-10-12)
Features ✨
----------
- - [Rich text editor] Add full screen mode ([#1447](https://github.com/element-hq/element-x-android/issues/1447))
- - Improve rendering of m.emote. ([#1497](https://github.com/element-hq/element-x-android/issues/1497))
- - Improve deleted session behavior. ([#1520](https://github.com/element-hq/element-x-android/issues/1520))
+- [Rich text editor] Add full screen mode ([#1447](https://github.com/element-hq/element-x-android/issues/1447))
+- Improve rendering of m.emote. ([#1497](https://github.com/element-hq/element-x-android/issues/1497))
+- Improve deleted session behavior. ([#1520](https://github.com/element-hq/element-x-android/issues/1520))
Bugfixes 🐛
----------
- - WebP images can't be sent as media. ([#1483](https://github.com/element-hq/element-x-android/issues/1483))
- - Fix back button not working in bottom sheets. ([#1517](https://github.com/element-hq/element-x-android/issues/1517))
- - Render body of unknown msgtype in the timeline and in the room list ([#1539](https://github.com/element-hq/element-x-android/issues/1539))
+- WebP images can't be sent as media. ([#1483](https://github.com/element-hq/element-x-android/issues/1483))
+- Fix back button not working in bottom sheets. ([#1517](https://github.com/element-hq/element-x-android/issues/1517))
+- Render body of unknown msgtype in the timeline and in the room list ([#1539](https://github.com/element-hq/element-x-android/issues/1539))
Other changes
-------------
- - Room : makes subscribeToSync/unsubscribeFromSync suspendable. ([#1457](https://github.com/element-hq/element-x-android/issues/1457))
- - Add some Konsist tests. ([#1526](https://github.com/element-hq/element-x-android/issues/1526))
+- Room : makes subscribeToSync/unsubscribeFromSync suspendable. ([#1457](https://github.com/element-hq/element-x-android/issues/1457))
+- Add some Konsist tests. ([#1526](https://github.com/element-hq/element-x-android/issues/1526))
Changes in Element X v0.2.3 (2023-09-27)
@@ -370,12 +412,12 @@ Changes in Element X v0.2.3 (2023-09-27)
Features ✨
----------
- - Handle installation of Apks from the media viewer. ([#1432](https://github.com/element-hq/element-x-android/pull/1432))
- - Integrate SDK 0.1.58 ([#1437](https://github.com/element-hq/element-x-android/pull/1437))
+- Handle installation of Apks from the media viewer. ([#1432](https://github.com/element-hq/element-x-android/pull/1432))
+- Integrate SDK 0.1.58 ([#1437](https://github.com/element-hq/element-x-android/pull/1437))
Other changes
-------------
- - Element call: add custom parameters to Element Call urls. ([#1434](https://github.com/element-hq/element-x-android/issues/1434))
+- Element call: add custom parameters to Element Call urls. ([#1434](https://github.com/element-hq/element-x-android/issues/1434))
Changes in Element X v0.2.2 (2023-09-21)
@@ -383,8 +425,8 @@ Changes in Element X v0.2.2 (2023-09-21)
Bugfixes 🐛
----------
- - Add animation when rendering the timeline to avoid glitches. ([#1323](https://github.com/element-hq/element-x-android/issues/1323))
- - Fix crash when trying to take a photo or record a video. ([#1395](https://github.com/element-hq/element-x-android/issues/1395))
+- Add animation when rendering the timeline to avoid glitches. ([#1323](https://github.com/element-hq/element-x-android/issues/1323))
+- Fix crash when trying to take a photo or record a video. ([#1395](https://github.com/element-hq/element-x-android/issues/1395))
Changes in Element X v0.2.1 (2023-09-20)
@@ -392,19 +434,19 @@ Changes in Element X v0.2.1 (2023-09-20)
Features ✨
----------
- - Bump Rust SDK to `v0.1.56`
- - [Rich text editor] Add link support to rich text editor ([#1309](https://github.com/element-hq/element-x-android/issues/1309))
- - Let the SDK figure the best scheme given an homeserver URL (thus allowing HTTP homeservers) ([#1382](https://github.com/element-hq/element-x-android/issues/1382))
+- Bump Rust SDK to `v0.1.56`
+- [Rich text editor] Add link support to rich text editor ([#1309](https://github.com/element-hq/element-x-android/issues/1309))
+- Let the SDK figure the best scheme given an homeserver URL (thus allowing HTTP homeservers) ([#1382](https://github.com/element-hq/element-x-android/issues/1382))
Bugfixes 🐛
----------
- - Fix ANR on RoomList when notification settings change. ([#1370](https://github.com/element-hq/element-x-android/issues/1370))
+- Fix ANR on RoomList when notification settings change. ([#1370](https://github.com/element-hq/element-x-android/issues/1370))
Other changes
-------------
- - Element Call: support scheme `io.element.call` ([#1377](https://github.com/element-hq/element-x-android/issues/1377))
- - [DI] Rework how dagger components are created and provided. ([#1378](https://github.com/element-hq/element-x-android/issues/1378))
- - Remove usage of async-uniffi as it leads to a deadlocks and memory leaks. ([#1381](https://github.com/element-hq/element-x-android/issues/1381))
+- Element Call: support scheme `io.element.call` ([#1377](https://github.com/element-hq/element-x-android/issues/1377))
+- [DI] Rework how dagger components are created and provided. ([#1378](https://github.com/element-hq/element-x-android/issues/1378))
+- Remove usage of async-uniffi as it leads to a deadlocks and memory leaks. ([#1381](https://github.com/element-hq/element-x-android/issues/1381))
Changes in Element X v0.2.0 (2023-09-18)
@@ -412,38 +454,38 @@ Changes in Element X v0.2.0 (2023-09-18)
Features ✨
----------
- - Bump Rust SDK to `v0.1.54`
- - Add a "Mute" shortcut icon and a "Notifications" section in the room details screen ([#506](https://github.com/element-hq/element-x-android/issues/506))
- - Add a notification permission screen to the initial flow. ([#897](https://github.com/element-hq/element-x-android/issues/897))
- - Integrate Element Call into EX by embedding a call in a WebView. ([#1300](https://github.com/element-hq/element-x-android/issues/1300))
- - Implement Bloom effect modifier. ([#1217](https://github.com/element-hq/element-x-android/issues/1217))
- - Set color on display name and default avatar in the timeline. ([#1224](https://github.com/element-hq/element-x-android/issues/1224))
- - Display a thread decorator in timeline so we know when a message is coming from a thread. ([#1236](https://github.com/element-hq/element-x-android/issues/1236))
- - [Rich text editor] Integrate rich text editor library. Note that markdown is now not supported and further formatting support will be introduced through the rich text editor. ([#1172](https://github.com/element-hq/element-x-android/issues/1172))
- - [Rich text editor] Add formatting menu (accessible via the '+' button) ([#1261](https://github.com/element-hq/element-x-android/issues/1261))
- - [Rich text editor] Add feature flag for rich text editor. Markdown support can now be enabled by disabling the rich text editor. ([#1289](https://github.com/element-hq/element-x-android/issues/1289))
- - [Rich text editor] Update design ([#1332](https://github.com/element-hq/element-x-android/issues/1332))
+- Bump Rust SDK to `v0.1.54`
+- Add a "Mute" shortcut icon and a "Notifications" section in the room details screen ([#506](https://github.com/element-hq/element-x-android/issues/506))
+- Add a notification permission screen to the initial flow. ([#897](https://github.com/element-hq/element-x-android/issues/897))
+- Integrate Element Call into EX by embedding a call in a WebView. ([#1300](https://github.com/element-hq/element-x-android/issues/1300))
+- Implement Bloom effect modifier. ([#1217](https://github.com/element-hq/element-x-android/issues/1217))
+- Set color on display name and default avatar in the timeline. ([#1224](https://github.com/element-hq/element-x-android/issues/1224))
+- Display a thread decorator in timeline so we know when a message is coming from a thread. ([#1236](https://github.com/element-hq/element-x-android/issues/1236))
+- [Rich text editor] Integrate rich text editor library. Note that markdown is now not supported and further formatting support will be introduced through the rich text editor. ([#1172](https://github.com/element-hq/element-x-android/issues/1172))
+- [Rich text editor] Add formatting menu (accessible via the '+' button) ([#1261](https://github.com/element-hq/element-x-android/issues/1261))
+- [Rich text editor] Add feature flag for rich text editor. Markdown support can now be enabled by disabling the rich text editor. ([#1289](https://github.com/element-hq/element-x-android/issues/1289))
+- [Rich text editor] Update design ([#1332](https://github.com/element-hq/element-x-android/issues/1332))
Bugfixes 🐛
----------
- - Make links in room topic clickable ([#612](https://github.com/element-hq/element-x-android/issues/612))
- - Reply action: harmonize conditions in bottom sheet and swipe to reply. ([#1173](https://github.com/element-hq/element-x-android/issues/1173))
- - Fix system bar color after login on light theme. ([#1222](https://github.com/element-hq/element-x-android/issues/1222))
- - Fix long click on simple formatted messages ([#1232](https://github.com/element-hq/element-x-android/issues/1232))
- - Enable polls in release build. ([#1241](https://github.com/element-hq/element-x-android/issues/1241))
- - Fix top padding in room list when app is opened in offline mode. ([#1297](https://github.com/element-hq/element-x-android/issues/1297))
- - [Rich text editor] Fix 'text formatting' option only partially visible ([#1335](https://github.com/element-hq/element-x-android/issues/1335))
- - [Rich text editor] Ensure keyboard opens for reply and text formatting modes ([#1337](https://github.com/element-hq/element-x-android/issues/1337))
- - [Rich text editor] Fix placeholder spilling onto multiple lines ([#1347](https://github.com/element-hq/element-x-android/issues/1347))
+- Make links in room topic clickable ([#612](https://github.com/element-hq/element-x-android/issues/612))
+- Reply action: harmonize conditions in bottom sheet and swipe to reply. ([#1173](https://github.com/element-hq/element-x-android/issues/1173))
+- Fix system bar color after login on light theme. ([#1222](https://github.com/element-hq/element-x-android/issues/1222))
+- Fix long click on simple formatted messages ([#1232](https://github.com/element-hq/element-x-android/issues/1232))
+- Enable polls in release build. ([#1241](https://github.com/element-hq/element-x-android/issues/1241))
+- Fix top padding in room list when app is opened in offline mode. ([#1297](https://github.com/element-hq/element-x-android/issues/1297))
+- [Rich text editor] Fix 'text formatting' option only partially visible ([#1335](https://github.com/element-hq/element-x-android/issues/1335))
+- [Rich text editor] Ensure keyboard opens for reply and text formatting modes ([#1337](https://github.com/element-hq/element-x-android/issues/1337))
+- [Rich text editor] Fix placeholder spilling onto multiple lines ([#1347](https://github.com/element-hq/element-x-android/issues/1347))
Other changes
-------------
- - Add a sub-screen "Notifications" in the existing application Settings ([#510](https://github.com/element-hq/element-x-android/issues/510))
- - Exclude some groups related to analytics to be included. ([#1191](https://github.com/element-hq/element-x-android/issues/1191))
- - Use the new SyncIndicator API. ([#1244](https://github.com/element-hq/element-x-android/issues/1244))
- - Improve RoomSummary mapping by using RoomInfo. ([#1251](https://github.com/element-hq/element-x-android/issues/1251))
- - Ensure Posthog data are sent to "https://posthog.element.io" ([#1269](https://github.com/element-hq/element-x-android/issues/1269))
- - New app icon, with monochrome support. ([#1363](https://github.com/element-hq/element-x-android/issues/1363))
+- Add a sub-screen "Notifications" in the existing application Settings ([#510](https://github.com/element-hq/element-x-android/issues/510))
+- Exclude some groups related to analytics to be included. ([#1191](https://github.com/element-hq/element-x-android/issues/1191))
+- Use the new SyncIndicator API. ([#1244](https://github.com/element-hq/element-x-android/issues/1244))
+- Improve RoomSummary mapping by using RoomInfo. ([#1251](https://github.com/element-hq/element-x-android/issues/1251))
+- Ensure Posthog data are sent to "https://posthog.element.io" ([#1269](https://github.com/element-hq/element-x-android/issues/1269))
+- New app icon, with monochrome support. ([#1363](https://github.com/element-hq/element-x-android/issues/1363))
Changes in Element X v0.1.6 (2023-09-04)
@@ -451,22 +493,22 @@ Changes in Element X v0.1.6 (2023-09-04)
Features ✨
----------
- - Enable the Polls feature. Allows to create, view, vote and end polls. ([#1196](https://github.com/element-hq/element-x-android/issues/1196))
+- Enable the Polls feature. Allows to create, view, vote and end polls. ([#1196](https://github.com/element-hq/element-x-android/issues/1196))
- Create poll. ([#1143](https://github.com/element-hq/element-x-android/issues/1143))
Bugfixes 🐛
----------
- Ensure notification for Event from encrypted room get decrypted content. ([#1178](https://github.com/element-hq/element-x-android/issues/1178))
- - Make sure Snackbars are only displayed once. ([#928](https://github.com/element-hq/element-x-android/issues/928))
- - Fix the orientation of sent images. ([#1135](https://github.com/element-hq/element-x-android/issues/1135))
- - Bug reporter crashes when 'send logs' is disabled. ([#1168](https://github.com/element-hq/element-x-android/issues/1168))
- - Add missing link to the terms on the analytics setting screen. ([#1177](https://github.com/element-hq/element-x-android/issues/1177))
- - Re-enable `SyncService.withEncryptionSync` to improve decryption of notifications. ([#1198](https://github.com/element-hq/element-x-android/issues/1198))
- - Crash with `aspectRatio` modifier when `Float.NaN` was used as input. ([#1995](https://github.com/element-hq/element-x-android/issues/1995))
+- Make sure Snackbars are only displayed once. ([#928](https://github.com/element-hq/element-x-android/issues/928))
+- Fix the orientation of sent images. ([#1135](https://github.com/element-hq/element-x-android/issues/1135))
+- Bug reporter crashes when 'send logs' is disabled. ([#1168](https://github.com/element-hq/element-x-android/issues/1168))
+- Add missing link to the terms on the analytics setting screen. ([#1177](https://github.com/element-hq/element-x-android/issues/1177))
+- Re-enable `SyncService.withEncryptionSync` to improve decryption of notifications. ([#1198](https://github.com/element-hq/element-x-android/issues/1198))
+- Crash with `aspectRatio` modifier when `Float.NaN` was used as input. ([#1995](https://github.com/element-hq/element-x-android/issues/1995))
Other changes
-------------
- - Remove unnecessary year in copyright mention. ([#1187](https://github.com/element-hq/element-x-android/issues/1187))
+- Remove unnecessary year in copyright mention. ([#1187](https://github.com/element-hq/element-x-android/issues/1187))
Changes in Element X v0.1.5 (2023-08-28)
@@ -474,7 +516,7 @@ Changes in Element X v0.1.5 (2023-08-28)
Bugfixes 🐛
----------
- - Fix crash when opening any room. ([#1160](https://github.com/element-hq/element-x-android/issues/1160))
+- Fix crash when opening any room. ([#1160](https://github.com/element-hq/element-x-android/issues/1160))
Changes in Element X v0.1.4 (2023-08-28)
@@ -482,32 +524,32 @@ Changes in Element X v0.1.4 (2023-08-28)
Features ✨
----------
- - Allow cancelling media upload ([#769](https://github.com/element-hq/element-x-android/issues/769))
- - Enable OIDC support. ([#1127](https://github.com/element-hq/element-x-android/issues/1127))
- - Add a "Setting up account" screen, displayed the first time the user logs in to the app (per account). ([#1149](https://github.com/element-hq/element-x-android/issues/1149))
+- Allow cancelling media upload ([#769](https://github.com/element-hq/element-x-android/issues/769))
+- Enable OIDC support. ([#1127](https://github.com/element-hq/element-x-android/issues/1127))
+- Add a "Setting up account" screen, displayed the first time the user logs in to the app (per account). ([#1149](https://github.com/element-hq/element-x-android/issues/1149))
Bugfixes 🐛
----------
- - Videos sent from the app were cropped in some cases. ([#862](https://github.com/element-hq/element-x-android/issues/862))
- - Timeline: sender names are now displayed in one single line. ([#1033](https://github.com/element-hq/element-x-android/issues/1033))
- - Fix `TextButtons` being displayed in black. ([#1077](https://github.com/element-hq/element-x-android/issues/1077))
- - Linkify links in HTML contents. ([#1079](https://github.com/element-hq/element-x-android/issues/1079))
- - Fix bug reporter failing after not finding some log files. ([#1082](https://github.com/element-hq/element-x-android/issues/1082))
- - Fix rendering of inline elements in list items. ([#1090](https://github.com/element-hq/element-x-android/issues/1090))
- - Fix crash RuntimeException "No matching key found for the ciphertext in the stream" ([#1101](https://github.com/element-hq/element-x-android/issues/1101))
- - Make links in messages clickable again. ([#1111](https://github.com/element-hq/element-x-android/issues/1111))
- - When event has no id, just cancel parsing the latest room message for a room. ([#1125](https://github.com/element-hq/element-x-android/issues/1125))
- - Only display verification prompt after initial sync is done. ([#1131](https://github.com/element-hq/element-x-android/issues/1131))
+- Videos sent from the app were cropped in some cases. ([#862](https://github.com/element-hq/element-x-android/issues/862))
+- Timeline: sender names are now displayed in one single line. ([#1033](https://github.com/element-hq/element-x-android/issues/1033))
+- Fix `TextButtons` being displayed in black. ([#1077](https://github.com/element-hq/element-x-android/issues/1077))
+- Linkify links in HTML contents. ([#1079](https://github.com/element-hq/element-x-android/issues/1079))
+- Fix bug reporter failing after not finding some log files. ([#1082](https://github.com/element-hq/element-x-android/issues/1082))
+- Fix rendering of inline elements in list items. ([#1090](https://github.com/element-hq/element-x-android/issues/1090))
+- Fix crash RuntimeException "No matching key found for the ciphertext in the stream" ([#1101](https://github.com/element-hq/element-x-android/issues/1101))
+- Make links in messages clickable again. ([#1111](https://github.com/element-hq/element-x-android/issues/1111))
+- When event has no id, just cancel parsing the latest room message for a room. ([#1125](https://github.com/element-hq/element-x-android/issues/1125))
+- Only display verification prompt after initial sync is done. ([#1131](https://github.com/element-hq/element-x-android/issues/1131))
In development 🚧
----------------
- - [Poll] Add feature flag in developer options ([#1064](https://github.com/element-hq/element-x-android/issues/1064))
- - [Polls] Improve UI and render ended state ([#1113](https://github.com/element-hq/element-x-android/issues/1113))
+- [Poll] Add feature flag in developer options ([#1064](https://github.com/element-hq/element-x-android/issues/1064))
+- [Polls] Improve UI and render ended state ([#1113](https://github.com/element-hq/element-x-android/issues/1113))
Other changes
-------------
- - Compound: add `ListItem` and `ListSectionHeader` components. ([#990](https://github.com/element-hq/element-x-android/issues/990))
- - Migrate `object` to `data object` in sealed interface / class #1135 ([#1135](https://github.com/element-hq/element-x-android/issues/1135))
+- Compound: add `ListItem` and `ListSectionHeader` components. ([#990](https://github.com/element-hq/element-x-android/issues/990))
+- Migrate `object` to `data object` in sealed interface / class #1135 ([#1135](https://github.com/element-hq/element-x-android/issues/1135))
Changes in Element X v0.1.2 (2023-08-16)
@@ -515,20 +557,20 @@ Changes in Element X v0.1.2 (2023-08-16)
Bugfixes 🐛
----------
- - Filter push notifications using push rules. ([#640](https://github.com/element-hq/element-x-android/issues/640))
- - Use `for` instead of `forEach` in `DefaultDiffCacheInvalidator` to improve performance. ([#1035](https://github.com/element-hq/element-x-android/issues/1035))
+- Filter push notifications using push rules. ([#640](https://github.com/element-hq/element-x-android/issues/640))
+- Use `for` instead of `forEach` in `DefaultDiffCacheInvalidator` to improve performance. ([#1035](https://github.com/element-hq/element-x-android/issues/1035))
In development 🚧
----------------
- - [Poll] Render start event in the timeline ([#1031](https://github.com/element-hq/element-x-android/issues/1031))
+- [Poll] Render start event in the timeline ([#1031](https://github.com/element-hq/element-x-android/issues/1031))
Other changes
-------------
- - Add Button component based on Compound designs ([#1021](https://github.com/element-hq/element-x-android/issues/1021))
- - Compound: implement dialogs. ([#1043](https://github.com/element-hq/element-x-android/issues/1043))
- - Compound: customise `IconButton` component. ([#1049](https://github.com/element-hq/element-x-android/issues/1049))
- - Compound: implement `DropdownMenu` customisations. ([#1050](https://github.com/element-hq/element-x-android/issues/1050))
- - Compound: implement Snackbar component. ([#1054](https://github.com/element-hq/element-x-android/issues/1054))
+- Add Button component based on Compound designs ([#1021](https://github.com/element-hq/element-x-android/issues/1021))
+- Compound: implement dialogs. ([#1043](https://github.com/element-hq/element-x-android/issues/1043))
+- Compound: customise `IconButton` component. ([#1049](https://github.com/element-hq/element-x-android/issues/1049))
+- Compound: implement `DropdownMenu` customisations. ([#1050](https://github.com/element-hq/element-x-android/issues/1050))
+- Compound: implement Snackbar component. ([#1054](https://github.com/element-hq/element-x-android/issues/1054))
Changes in Element X v0.1.0 (2023-07-19)
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index e3f34f9ea6..4c21cef589 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -23,6 +23,7 @@ import extension.allServicesImpl
import extension.gitBranchName
import extension.gitRevision
import extension.koverDependencies
+import extension.locales
import extension.setupKover
plugins {
@@ -74,6 +75,10 @@ android {
isUniversalApk = true
}
}
+
+ defaultConfig {
+ resourceConfigurations += locales
+ }
}
signingConfigs {
@@ -219,6 +224,7 @@ dependencies {
allServicesImpl()
allFeaturesImpl(rootDir, logger)
implementation(projects.features.call)
+ implementation(projects.features.migration.api)
implementation(projects.anvilannotations)
implementation(projects.appnav)
implementation(projects.appconfig)
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 10300ae00b..931c995d9a 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -29,6 +29,7 @@
android:fullBackupContent="@xml/backup_rules"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
+ android:localeConfig="@xml/locales_config"
android:networkSecurityConfig="@xml/network_security_config"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
@@ -74,6 +75,51 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
{
- override fun init(node: MainNode) {
- Timber.tag(loggerTag.value).w("onMainNodeInit")
- mainNode = node
- mainNode.handleIntent(intent)
- }
- }
- ),
- context = applicationContext
+ if (migrationState.migrationAction.isSuccess()) {
+ MainNodeHost()
+ } else {
+ appBindings.migrationEntryPoint().Render(
+ state = migrationState,
+ modifier = Modifier,
)
}
}
@@ -118,10 +112,30 @@ class MainActivity : NodeActivity() {
}
}
+ @Composable
+ private fun MainNodeHost() {
+ NodeHost(integrationPoint = appyxIntegrationPoint) {
+ MainNode(
+ it,
+ plugins = listOf(
+ object : NodeReadyObserver {
+ override fun init(node: MainNode) {
+ Timber.tag(loggerTag.value).w("onMainNodeInit")
+ mainNode = node
+ mainNode.handleIntent(intent)
+ }
+ }
+ ),
+ context = applicationContext
+ )
+ }
+ }
+
/**
* Called when:
* - the launcher icon is clicked (if the app is already running);
* - a notification is clicked.
+ * - a deep link have been clicked
* - the app is going to background (<- this is strange)
*/
override fun onNewIntent(intent: Intent) {
diff --git a/app/src/main/kotlin/io/element/android/x/di/AppBindings.kt b/app/src/main/kotlin/io/element/android/x/di/AppBindings.kt
index 0934771501..d8be841b97 100644
--- a/app/src/main/kotlin/io/element/android/x/di/AppBindings.kt
+++ b/app/src/main/kotlin/io/element/android/x/di/AppBindings.kt
@@ -17,6 +17,7 @@
package io.element.android.x.di
import com.squareup.anvil.annotations.ContributesTo
+import io.element.android.features.api.MigrationEntryPoint
import io.element.android.features.lockscreen.api.LockScreenService
import io.element.android.features.preferences.api.store.AppPreferencesStore
import io.element.android.features.rageshake.api.reporter.BugReporter
@@ -35,4 +36,6 @@ interface AppBindings {
fun lockScreenService(): LockScreenService
fun preferencesStore(): AppPreferencesStore
+
+ fun migrationEntryPoint(): MigrationEntryPoint
}
diff --git a/app/src/main/kotlin/io/element/android/x/initializer/TracingInitializer.kt b/app/src/main/kotlin/io/element/android/x/initializer/TracingInitializer.kt
index 7afcc49e6d..e037d2978f 100644
--- a/app/src/main/kotlin/io/element/android/x/initializer/TracingInitializer.kt
+++ b/app/src/main/kotlin/io/element/android/x/initializer/TracingInitializer.kt
@@ -56,11 +56,13 @@ class TracingInitializer : Initializer {
writesToLogcat = false,
writesToFilesConfiguration = WriteToFilesConfiguration.Enabled(
directory = bugReporter.logDirectory().absolutePath,
- filenamePrefix = "logs"
+ filenamePrefix = "logs",
+ filenameSuffix = null,
+ // Keep a minimum of 1 week of log files.
+ numberOfFiles = 7 * 24,
)
)
}
- bugReporter.cleanLogDirectoryIfNeeded()
bugReporter.setCurrentTracingFilter(tracingConfiguration.filterConfiguration.filter)
tracingService.setupTracing(tracingConfiguration)
// Also set env variable for rust back trace
diff --git a/app/src/main/kotlin/io/element/android/x/intent/IntentProviderImpl.kt b/app/src/main/kotlin/io/element/android/x/intent/IntentProviderImpl.kt
index 88a86b9467..bdadf1e3ef 100644
--- a/app/src/main/kotlin/io/element/android/x/intent/IntentProviderImpl.kt
+++ b/app/src/main/kotlin/io/element/android/x/intent/IntentProviderImpl.kt
@@ -45,11 +45,4 @@ class IntentProviderImpl @Inject constructor(
data = deepLinkCreator.room(sessionId, roomId, threadId).toUri()
}
}
-
- override fun getInviteListIntent(sessionId: SessionId): Intent {
- return Intent(context, MainActivity::class.java).apply {
- action = Intent.ACTION_VIEW
- data = deepLinkCreator.inviteList(sessionId).toUri()
- }
- }
}
diff --git a/app/src/main/res/resources.properties b/app/src/main/res/resources.properties
new file mode 100644
index 0000000000..c0585fd6d7
--- /dev/null
+++ b/app/src/main/res/resources.properties
@@ -0,0 +1,17 @@
+#
+# Copyright (c) 2024 New Vector Ltd
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+
+unqualifiedResLocale=en
diff --git a/app/src/main/res/xml/locales_config.xml b/app/src/main/res/xml/locales_config.xml
new file mode 100644
index 0000000000..17e0192414
--- /dev/null
+++ b/app/src/main/res/xml/locales_config.xml
@@ -0,0 +1,19 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/test/kotlin/io/element/android/x/intent/IntentProviderImplTest.kt b/app/src/test/kotlin/io/element/android/x/intent/IntentProviderImplTest.kt
index 340af6f4a8..b8b854d158 100644
--- a/app/src/test/kotlin/io/element/android/x/intent/IntentProviderImplTest.kt
+++ b/app/src/test/kotlin/io/element/android/x/intent/IntentProviderImplTest.kt
@@ -67,16 +67,6 @@ class IntentProviderImplTest {
assertThat(result.data.toString()).isEqualTo("elementx://open/@alice:server.org/!aRoomId:domain/\$aThreadId")
}
- @Test
- fun `test getInviteListIntent`() {
- val sut = createIntentProviderImpl()
- val result = sut.getInviteListIntent(
- sessionId = A_SESSION_ID,
- )
- result.commonAssertions()
- assertThat(result.data.toString()).isEqualTo("elementx://open/@alice:server.org/invites")
- }
-
private fun createIntentProviderImpl(): IntentProviderImpl {
return IntentProviderImpl(
context = RuntimeEnvironment.getApplication() as Context,
diff --git a/appconfig/src/main/kotlin/io/element/android/appconfig/ApplicationConfig.kt b/appconfig/src/main/kotlin/io/element/android/appconfig/ApplicationConfig.kt
index 21af158ad6..e8a8a2b33a 100644
--- a/appconfig/src/main/kotlin/io/element/android/appconfig/ApplicationConfig.kt
+++ b/appconfig/src/main/kotlin/io/element/android/appconfig/ApplicationConfig.kt
@@ -40,4 +40,9 @@ object ApplicationConfig {
* For Element, the value is "Element". We use the same name for desktop and mobile for now.
*/
const val DESKTOP_APPLICATION_NAME: String = "Element"
+
+ /**
+ * The maximum size of the upload request. Default value is just below CloudFlare's max request size.
+ */
+ const val MAX_LOG_UPLOAD_SIZE = 50 * 1024 * 1024L
}
diff --git a/appnav/build.gradle.kts b/appnav/build.gradle.kts
index ab215f8394..c24e544240 100644
--- a/appnav/build.gradle.kts
+++ b/appnav/build.gradle.kts
@@ -72,6 +72,7 @@ dependencies {
testImplementation(projects.features.rageshake.test)
testImplementation(projects.features.rageshake.impl)
testImplementation(projects.services.appnavstate.test)
+ testImplementation(projects.services.analytics.test)
testImplementation(libs.test.appyx.junit)
testImplementation(libs.test.arch.core)
diff --git a/appnav/src/main/kotlin/io/element/android/appnav/LoggedInFlowNode.kt b/appnav/src/main/kotlin/io/element/android/appnav/LoggedInFlowNode.kt
index 0f6da36e26..9c8b29b356 100644
--- a/appnav/src/main/kotlin/io/element/android/appnav/LoggedInFlowNode.kt
+++ b/appnav/src/main/kotlin/io/element/android/appnav/LoggedInFlowNode.kt
@@ -33,10 +33,8 @@ import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.plugin.Plugin
import com.bumble.appyx.core.plugin.plugins
import com.bumble.appyx.navmodel.backstack.BackStack
-import com.bumble.appyx.navmodel.backstack.operation.pop
import com.bumble.appyx.navmodel.backstack.operation.push
import com.bumble.appyx.navmodel.backstack.operation.replace
-import com.bumble.appyx.navmodel.backstack.operation.singleTop
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import io.element.android.anvilannotations.ContributesNode
@@ -48,7 +46,6 @@ import io.element.android.features.createroom.api.CreateRoomEntryPoint
import io.element.android.features.ftue.api.FtueEntryPoint
import io.element.android.features.ftue.api.state.FtueService
import io.element.android.features.ftue.api.state.FtueState
-import io.element.android.features.invite.api.InviteListEntryPoint
import io.element.android.features.lockscreen.api.LockScreenEntryPoint
import io.element.android.features.lockscreen.api.LockScreenLockState
import io.element.android.features.lockscreen.api.LockScreenService
@@ -59,20 +56,23 @@ import io.element.android.features.roomdirectory.api.RoomDescription
import io.element.android.features.roomdirectory.api.RoomDirectoryEntryPoint
import io.element.android.features.roomlist.api.RoomListEntryPoint
import io.element.android.features.securebackup.api.SecureBackupEntryPoint
+import io.element.android.features.userprofile.api.UserProfileEntryPoint
import io.element.android.libraries.architecture.BackstackView
import io.element.android.libraries.architecture.BaseFlowNode
import io.element.android.libraries.architecture.createNode
-import io.element.android.libraries.architecture.waitForChildAttached
-import io.element.android.libraries.deeplink.DeeplinkData
+import io.element.android.libraries.architecture.waitForNavTargetAttached
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher
import io.element.android.libraries.di.AppScope
import io.element.android.libraries.di.SessionScope
import io.element.android.libraries.matrix.api.MatrixClient
+import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.MAIN_SPACE
import io.element.android.libraries.matrix.api.core.RoomId
-import io.element.android.libraries.matrix.api.roomlist.RoomList
+import io.element.android.libraries.matrix.api.core.RoomIdOrAlias
+import io.element.android.libraries.matrix.api.core.UserId
+import io.element.android.libraries.matrix.api.core.toRoomIdOrAlias
+import io.element.android.libraries.matrix.api.permalink.PermalinkData
import io.element.android.libraries.matrix.api.sync.SyncState
-import io.element.android.libraries.push.api.notifications.NotificationDrawerManager
import io.element.android.services.appnavstate.api.AppNavigationStateService
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.FlowPreview
@@ -81,7 +81,6 @@ import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
-import kotlinx.coroutines.withContext
import kotlinx.parcelize.Parcelize
import timber.log.Timber
import java.util.Optional
@@ -95,11 +94,10 @@ class LoggedInFlowNode @AssistedInject constructor(
private val createRoomEntryPoint: CreateRoomEntryPoint,
private val appNavigationStateService: AppNavigationStateService,
private val secureBackupEntryPoint: SecureBackupEntryPoint,
- private val inviteListEntryPoint: InviteListEntryPoint,
+ private val userProfileEntryPoint: UserProfileEntryPoint,
private val ftueEntryPoint: FtueEntryPoint,
private val coroutineScope: CoroutineScope,
private val networkMonitor: NetworkMonitor,
- private val notificationDrawerManager: NotificationDrawerManager,
private val ftueService: FtueService,
private val lockScreenEntryPoint: LockScreenEntryPoint,
private val lockScreenStateService: LockScreenService,
@@ -160,23 +158,6 @@ class LoggedInFlowNode @AssistedInject constructor(
}
)
observeSyncStateAndNetworkStatus()
- observeInvitesLoadingState()
- }
-
- private fun observeInvitesLoadingState() {
- lifecycleScope.launch {
- repeatOnLifecycle(Lifecycle.State.STARTED) {
- matrixClient.roomListService.invites.loadingState
- .collect { inviteState ->
- when (inviteState) {
- is RoomList.LoadingState.Loaded -> if (inviteState.numberOfRooms == 0) {
- backstack.removeLast(NavTarget.InviteList)
- }
- RoomList.LoadingState.NotLoaded -> Unit
- }
- }
- }
- }
}
@OptIn(FlowPreview::class)
@@ -215,9 +196,14 @@ class LoggedInFlowNode @AssistedInject constructor(
@Parcelize
data class Room(
- val roomId: RoomId,
+ val roomIdOrAlias: RoomIdOrAlias,
val roomDescription: RoomDescription? = null,
- val initialElement: RoomNavigationTarget = RoomNavigationTarget.Messages
+ val initialElement: RoomNavigationTarget = RoomNavigationTarget.Messages()
+ ) : NavTarget
+
+ @Parcelize
+ data class UserProfile(
+ val userId: UserId,
) : NavTarget
@Parcelize
@@ -233,9 +219,6 @@ class LoggedInFlowNode @AssistedInject constructor(
val initialElement: SecureBackupEntryPoint.InitialTarget = SecureBackupEntryPoint.InitialTarget.Root
) : NavTarget
- @Parcelize
- data object InviteList : NavTarget
-
@Parcelize
data object Ftue : NavTarget
@@ -257,7 +240,7 @@ class LoggedInFlowNode @AssistedInject constructor(
NavTarget.RoomList -> {
val callback = object : RoomListEntryPoint.Callback {
override fun onRoomClicked(roomId: RoomId) {
- backstack.push(NavTarget.Room(roomId))
+ backstack.push(NavTarget.Room(roomId.toRoomIdOrAlias()))
}
override fun onSettingsClicked() {
@@ -272,12 +255,8 @@ class LoggedInFlowNode @AssistedInject constructor(
backstack.push(NavTarget.SecureBackup(initialElement = SecureBackupEntryPoint.InitialTarget.EnterRecoveryKey))
}
- override fun onInvitesClicked() {
- backstack.push(NavTarget.InviteList)
- }
-
override fun onRoomSettingsClicked(roomId: RoomId) {
- backstack.push(NavTarget.Room(roomId, initialElement = RoomNavigationTarget.Details))
+ backstack.push(NavTarget.Room(roomId.toRoomIdOrAlias(), initialElement = RoomNavigationTarget.Details))
}
override fun onReportBugClicked() {
@@ -296,11 +275,33 @@ class LoggedInFlowNode @AssistedInject constructor(
is NavTarget.Room -> {
val callback = object : JoinedRoomLoadedFlowNode.Callback {
override fun onOpenRoom(roomId: RoomId) {
- backstack.push(NavTarget.Room(roomId))
+ backstack.push(NavTarget.Room(roomId.toRoomIdOrAlias()))
}
override fun onForwardedToSingleRoom(roomId: RoomId) {
- coroutineScope.launch { attachRoom(roomId) }
+ coroutineScope.launch { attachRoom(roomId.toRoomIdOrAlias()) }
+ }
+
+ override fun onPermalinkClicked(data: PermalinkData) {
+ when (data) {
+ is PermalinkData.UserLink -> {
+ // Should not happen (handled by MessagesNode)
+ Timber.e("User link clicked: ${data.userId}.")
+ }
+ is PermalinkData.RoomLink -> {
+ backstack.push(
+ NavTarget.Room(
+ roomIdOrAlias = data.roomIdOrAlias,
+ initialElement = RoomNavigationTarget.Messages(data.eventId),
+ // TODO Use the viaParameters
+ )
+ )
+ }
+ is PermalinkData.FallbackLink,
+ is PermalinkData.RoomEmailInviteLink -> {
+ // Should not happen (handled by MessagesNode)
+ }
+ }
}
override fun onOpenGlobalNotificationSettings() {
@@ -308,12 +309,23 @@ class LoggedInFlowNode @AssistedInject constructor(
}
}
val inputs = RoomFlowNode.Inputs(
- roomId = navTarget.roomId,
+ roomIdOrAlias = navTarget.roomIdOrAlias,
roomDescription = Optional.ofNullable(navTarget.roomDescription),
initialElement = navTarget.initialElement
)
createNode(buildContext, plugins = listOf(inputs, callback))
}
+ is NavTarget.UserProfile -> {
+ val callback = object : UserProfileEntryPoint.Callback {
+ override fun onOpenRoom(roomId: RoomId) {
+ backstack.push(NavTarget.Room(roomId.toRoomIdOrAlias()))
+ }
+ }
+ userProfileEntryPoint.nodeBuilder(this, buildContext)
+ .params(UserProfileEntryPoint.Params(userId = navTarget.userId))
+ .callback(callback)
+ .build()
+ }
is NavTarget.Settings -> {
val callback = object : PreferencesEntryPoint.Callback {
override fun onOpenBugReport() {
@@ -325,11 +337,11 @@ class LoggedInFlowNode @AssistedInject constructor(
}
override fun onOpenRoomNotificationSettings(roomId: RoomId) {
- backstack.push(NavTarget.Room(roomId, initialElement = RoomNavigationTarget.NotificationSettings))
+ backstack.push(NavTarget.Room(roomId.toRoomIdOrAlias(), initialElement = RoomNavigationTarget.NotificationSettings))
}
}
val inputs = PreferencesEntryPoint.Params(navTarget.initialElement)
- return preferencesEntryPoint.nodeBuilder(this, buildContext)
+ preferencesEntryPoint.nodeBuilder(this, buildContext)
.params(inputs)
.callback(callback)
.build()
@@ -337,7 +349,7 @@ class LoggedInFlowNode @AssistedInject constructor(
NavTarget.CreateRoom -> {
val callback = object : CreateRoomEntryPoint.Callback {
override fun onSuccess(roomId: RoomId) {
- backstack.replace(NavTarget.Room(roomId))
+ backstack.replace(NavTarget.Room(roomId.toRoomIdOrAlias()))
}
}
@@ -351,43 +363,19 @@ class LoggedInFlowNode @AssistedInject constructor(
.params(SecureBackupEntryPoint.Params(initialElement = navTarget.initialElement))
.build()
}
- NavTarget.InviteList -> {
- val callback = object : InviteListEntryPoint.Callback {
- override fun onBackClicked() {
- backstack.pop()
- }
-
- override fun onInviteClicked(roomId: RoomId) {
- backstack.push(NavTarget.Room(roomId))
- }
-
- override fun onInviteAccepted(roomId: RoomId) {
- backstack.push(NavTarget.Room(roomId))
- }
- }
-
- inviteListEntryPoint.nodeBuilder(this, buildContext)
- .callback(callback)
- .build()
- }
NavTarget.Ftue -> {
ftueEntryPoint.nodeBuilder(this, buildContext)
- .callback(object : FtueEntryPoint.Callback {
- override fun onFtueFlowFinished() {
- lifecycleScope.launch { attachRoomList() }
- }
- })
.build()
}
NavTarget.RoomDirectorySearch -> {
roomDirectoryEntryPoint.nodeBuilder(this, buildContext)
.callback(object : RoomDirectoryEntryPoint.Callback {
override fun onRoomJoined(roomId: RoomId) {
- backstack.push(NavTarget.Room(roomId))
+ backstack.push(NavTarget.Room(roomId.toRoomIdOrAlias()))
}
override fun onResultClicked(roomDescription: RoomDescription) {
- backstack.push(NavTarget.Room(roomDescription.roomId, roomDescription))
+ backstack.push(NavTarget.Room(roomDescription.roomId.toRoomIdOrAlias(), roomDescription))
}
})
.build()
@@ -395,42 +383,42 @@ class LoggedInFlowNode @AssistedInject constructor(
}
}
- suspend fun attachRoomList() {
- if (!canShowRoomList()) return
- attachChild {
- backstack.singleTop(NavTarget.RoomList)
+ suspend fun attachRoom(roomIdOrAlias: RoomIdOrAlias, eventId: EventId? = null) {
+ waitForNavTargetAttached { navTarget ->
+ navTarget is NavTarget.RoomList
}
- }
-
- suspend fun attachRoom(roomId: RoomId) {
- if (!canShowRoomList()) return
attachChild {
- backstack.singleTop(NavTarget.RoomList)
- backstack.push(NavTarget.Room(roomId))
+ backstack.push(
+ NavTarget.Room(
+ roomIdOrAlias = roomIdOrAlias,
+ initialElement = RoomNavigationTarget.Messages(
+ focusedEventId = eventId
+ )
+ )
+ )
}
}
- internal suspend fun attachInviteList(deeplinkData: DeeplinkData.InviteList) = withContext(lifecycleScope.coroutineContext) {
- if (!canShowRoomList()) return@withContext
- notificationDrawerManager.clearMembershipNotificationForSession(deeplinkData.sessionId)
- backstack.singleTop(NavTarget.RoomList)
- backstack.push(NavTarget.InviteList)
- waitForChildAttached { navTarget ->
- navTarget is NavTarget.InviteList
+ suspend fun attachUser(userId: UserId) {
+ waitForNavTargetAttached { navTarget ->
+ navTarget is NavTarget.RoomList
+ }
+ attachChild {
+ backstack.push(
+ NavTarget.UserProfile(
+ userId = userId,
+ )
+ )
}
- }
-
- private fun canShowRoomList(): Boolean {
- return ftueService.state.value is FtueState.Complete
}
@Composable
override fun View(modifier: Modifier) {
Box(modifier = modifier) {
val lockScreenState by lockScreenStateService.lockState.collectAsState()
- val isFtueDisplayed by ftueService.state.collectAsState()
+ val ftueState by ftueService.state.collectAsState()
BackstackView()
- if (isFtueDisplayed is FtueState.Complete) {
+ if (ftueState is FtueState.Complete) {
PermanentChild(permanentNavModel = permanentNavModel, navTarget = NavTarget.LoggedInPermanent)
}
if (lockScreenState == LockScreenLockState.Locked) {
diff --git a/appnav/src/main/kotlin/io/element/android/appnav/RootFlowNode.kt b/appnav/src/main/kotlin/io/element/android/appnav/RootFlowNode.kt
index d310a02b99..db04345a4e 100644
--- a/appnav/src/main/kotlin/io/element/android/appnav/RootFlowNode.kt
+++ b/appnav/src/main/kotlin/io/element/android/appnav/RootFlowNode.kt
@@ -55,6 +55,8 @@ import io.element.android.libraries.designsystem.theme.components.CircularProgre
import io.element.android.libraries.di.AppScope
import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService
import io.element.android.libraries.matrix.api.core.SessionId
+import io.element.android.libraries.matrix.api.core.toRoomIdOrAlias
+import io.element.android.libraries.matrix.api.permalink.PermalinkData
import io.element.android.libraries.sessionstorage.api.LoggedInState
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.launchIn
@@ -279,18 +281,37 @@ class RootFlowNode @AssistedInject constructor(
when (resolvedIntent) {
is ResolvedIntent.Navigation -> navigateTo(resolvedIntent.deeplinkData)
is ResolvedIntent.Oidc -> onOidcAction(resolvedIntent.oidcAction)
+ is ResolvedIntent.Permalink -> navigateTo(resolvedIntent.permalinkData)
}
}
+ private suspend fun navigateTo(permalinkData: PermalinkData) {
+ Timber.d("Navigating to $permalinkData")
+ attachSession(null)
+ .apply {
+ when (permalinkData) {
+ is PermalinkData.FallbackLink -> Unit
+ is PermalinkData.RoomEmailInviteLink -> Unit
+ is PermalinkData.RoomLink -> {
+ attachRoom(
+ roomIdOrAlias = permalinkData.roomIdOrAlias,
+ eventId = permalinkData.eventId,
+ )
+ }
+ is PermalinkData.UserLink -> {
+ attachUser(permalinkData.userId)
+ }
+ }
+ }
+ }
+
private suspend fun navigateTo(deeplinkData: DeeplinkData) {
Timber.d("Navigating to $deeplinkData")
attachSession(deeplinkData.sessionId)
- .attachSession()
.apply {
when (deeplinkData) {
- is DeeplinkData.Root -> attachRoomList()
- is DeeplinkData.Room -> attachRoom(deeplinkData.roomId)
- is DeeplinkData.InviteList -> attachInviteList(deeplinkData)
+ is DeeplinkData.Root -> Unit // The room list will always be shown, observing FtueState
+ is DeeplinkData.Room -> attachRoom(deeplinkData.roomId.toRoomIdOrAlias())
}
}
}
@@ -299,10 +320,12 @@ class RootFlowNode @AssistedInject constructor(
oidcActionFlow.post(oidcAction)
}
- private suspend fun attachSession(sessionId: SessionId): LoggedInAppScopeFlowNode {
+ // [sessionId] will be null for permalink.
+ private suspend fun attachSession(sessionId: SessionId?): LoggedInFlowNode {
// TODO handle multi-session
- return waitForChildAttached { navTarget ->
- navTarget is NavTarget.LoggedInFlow && navTarget.sessionId == sessionId
+ return waitForChildAttached { navTarget ->
+ navTarget is NavTarget.LoggedInFlow && (sessionId == null || navTarget.sessionId == sessionId)
}
+ .attachSession()
}
}
diff --git a/appnav/src/main/kotlin/io/element/android/appnav/intent/IntentResolver.kt b/appnav/src/main/kotlin/io/element/android/appnav/intent/IntentResolver.kt
index 96febc3751..dafbf8c283 100644
--- a/appnav/src/main/kotlin/io/element/android/appnav/intent/IntentResolver.kt
+++ b/appnav/src/main/kotlin/io/element/android/appnav/intent/IntentResolver.kt
@@ -21,27 +21,41 @@ import io.element.android.features.login.api.oidc.OidcAction
import io.element.android.features.login.api.oidc.OidcIntentResolver
import io.element.android.libraries.deeplink.DeeplinkData
import io.element.android.libraries.deeplink.DeeplinkParser
+import io.element.android.libraries.matrix.api.permalink.PermalinkData
+import io.element.android.libraries.matrix.api.permalink.PermalinkParser
import timber.log.Timber
import javax.inject.Inject
sealed interface ResolvedIntent {
data class Navigation(val deeplinkData: DeeplinkData) : ResolvedIntent
data class Oidc(val oidcAction: OidcAction) : ResolvedIntent
+ data class Permalink(val permalinkData: PermalinkData) : ResolvedIntent
}
class IntentResolver @Inject constructor(
private val deeplinkParser: DeeplinkParser,
- private val oidcIntentResolver: OidcIntentResolver
+ private val oidcIntentResolver: OidcIntentResolver,
+ private val permalinkParser: PermalinkParser,
) {
fun resolve(intent: Intent): ResolvedIntent? {
if (intent.canBeIgnored()) return null
+ // Coming from a notification?
val deepLinkData = deeplinkParser.getFromIntent(intent)
if (deepLinkData != null) return ResolvedIntent.Navigation(deepLinkData)
+ // Coming during login using Oidc?
val oidcAction = oidcIntentResolver.resolve(intent)
if (oidcAction != null) return ResolvedIntent.Oidc(oidcAction)
+ // External link clicked? (matrix.to, element.io, etc.)
+ val permalinkData = intent
+ .takeIf { it.action == Intent.ACTION_VIEW }
+ ?.dataString
+ ?.let { permalinkParser.parse(it) }
+ ?.takeIf { it !is PermalinkData.FallbackLink }
+ if (permalinkData != null) return ResolvedIntent.Permalink(permalinkData)
+
// Unknown intent
Timber.w("Unknown intent")
return null
diff --git a/appnav/src/main/kotlin/io/element/android/appnav/loggedin/AnalyticsVerificationStateExt.kt b/appnav/src/main/kotlin/io/element/android/appnav/loggedin/AnalyticsVerificationStateExt.kt
new file mode 100644
index 0000000000..2afa0e40f2
--- /dev/null
+++ b/appnav/src/main/kotlin/io/element/android/appnav/loggedin/AnalyticsVerificationStateExt.kt
@@ -0,0 +1,59 @@
+/*
+ * Copyright (c) 2024 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.element.android.appnav.loggedin
+
+import im.vector.app.features.analytics.plan.CryptoSessionStateChange
+import im.vector.app.features.analytics.plan.UserProperties
+import io.element.android.libraries.matrix.api.encryption.RecoveryState
+import io.element.android.libraries.matrix.api.verification.SessionVerifiedStatus
+
+fun SessionVerifiedStatus.toAnalyticsUserPropertyValue(): UserProperties.VerificationState? {
+ return when (this) {
+ // we don't need to report transient states
+ SessionVerifiedStatus.Unknown -> null
+ SessionVerifiedStatus.NotVerified -> UserProperties.VerificationState.NotVerified
+ SessionVerifiedStatus.Verified -> UserProperties.VerificationState.Verified
+ }
+}
+
+fun RecoveryState.toAnalyticsUserPropertyValue(): UserProperties.RecoveryState? {
+ return when (this) {
+ RecoveryState.ENABLED -> UserProperties.RecoveryState.Enabled
+ RecoveryState.DISABLED -> UserProperties.RecoveryState.Disabled
+ RecoveryState.INCOMPLETE -> UserProperties.RecoveryState.Incomplete
+ // we don't need to report transient states
+ else -> null
+ }
+}
+fun SessionVerifiedStatus.toAnalyticsStateChangeValue(): CryptoSessionStateChange.VerificationState? {
+ return when (this) {
+ // we don't need to report transient states
+ SessionVerifiedStatus.Unknown -> null
+ SessionVerifiedStatus.NotVerified -> CryptoSessionStateChange.VerificationState.NotVerified
+ SessionVerifiedStatus.Verified -> CryptoSessionStateChange.VerificationState.Verified
+ }
+}
+
+fun RecoveryState.toAnalyticsStateChangeValue(): CryptoSessionStateChange.RecoveryState? {
+ return when (this) {
+ RecoveryState.ENABLED -> CryptoSessionStateChange.RecoveryState.Enabled
+ RecoveryState.DISABLED -> CryptoSessionStateChange.RecoveryState.Disabled
+ RecoveryState.INCOMPLETE -> CryptoSessionStateChange.RecoveryState.Incomplete
+ // we don't need to report transient states
+ else -> null
+ }
+}
diff --git a/appnav/src/main/kotlin/io/element/android/appnav/loggedin/LoggedInPresenter.kt b/appnav/src/main/kotlin/io/element/android/appnav/loggedin/LoggedInPresenter.kt
index 7cb1d634b8..313f4aafe0 100644
--- a/appnav/src/main/kotlin/io/element/android/appnav/loggedin/LoggedInPresenter.kt
+++ b/appnav/src/main/kotlin/io/element/android/appnav/loggedin/LoggedInPresenter.kt
@@ -22,14 +22,19 @@ import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
+import im.vector.app.features.analytics.plan.CryptoSessionStateChange
+import im.vector.app.features.analytics.plan.UserProperties
import io.element.android.features.networkmonitor.api.NetworkMonitor
import io.element.android.features.networkmonitor.api.NetworkStatus
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.matrix.api.MatrixClient
+import io.element.android.libraries.matrix.api.encryption.EncryptionService
+import io.element.android.libraries.matrix.api.encryption.RecoveryState
import io.element.android.libraries.matrix.api.roomlist.RoomListService
import io.element.android.libraries.matrix.api.verification.SessionVerificationService
import io.element.android.libraries.matrix.api.verification.SessionVerifiedStatus
import io.element.android.libraries.push.api.PushService
+import io.element.android.services.analytics.api.AnalyticsService
import kotlinx.coroutines.flow.map
import javax.inject.Inject
@@ -38,6 +43,8 @@ class LoggedInPresenter @Inject constructor(
private val networkMonitor: NetworkMonitor,
private val pushService: PushService,
private val sessionVerificationService: SessionVerificationService,
+ private val analyticsService: AnalyticsService,
+ private val encryptionService: EncryptionService,
) : Presenter {
@Composable
override fun present(): LoggedInState {
@@ -62,8 +69,36 @@ class LoggedInPresenter @Inject constructor(
networkStatus == NetworkStatus.Online && syncIndicator == RoomListService.SyncIndicator.Show
}
}
+ val verificationState by sessionVerificationService.sessionVerifiedStatus.collectAsState()
+ val recoveryState by encryptionService.recoveryStateStateFlow.collectAsState()
+ LaunchedEffect(verificationState, recoveryState) {
+ reportCryptoStatusToAnalytics(verificationState, recoveryState)
+ }
+
return LoggedInState(
showSyncSpinner = showSyncSpinner,
)
}
+
+ private fun reportCryptoStatusToAnalytics(verificationState: SessionVerifiedStatus, recoveryState: RecoveryState) {
+ // Update first the user property, to store the current status for that posthog user
+ val userVerificationState = verificationState.toAnalyticsUserPropertyValue()
+ val userRecoveryState = recoveryState.toAnalyticsUserPropertyValue()
+ if (userRecoveryState != null && userVerificationState != null) {
+ // we want to report when both value are known (if one is unknown we wait until we have them both)
+ analyticsService.updateUserProperties(
+ UserProperties(
+ verificationState = userVerificationState,
+ recoveryState = userRecoveryState
+ )
+ )
+ }
+
+ // Also report when there is a change in the state, to be able to track the changes
+ val changeVerificationState = verificationState.toAnalyticsStateChangeValue()
+ val changeRecoveryState = recoveryState.toAnalyticsStateChangeValue()
+ if (changeVerificationState != null && changeRecoveryState != null) {
+ analyticsService.capture(CryptoSessionStateChange(changeRecoveryState, changeVerificationState))
+ }
+ }
}
diff --git a/appnav/src/main/kotlin/io/element/android/appnav/room/RoomFlowNode.kt b/appnav/src/main/kotlin/io/element/android/appnav/room/RoomFlowNode.kt
index db3664e631..800b7037cf 100644
--- a/appnav/src/main/kotlin/io/element/android/appnav/room/RoomFlowNode.kt
+++ b/appnav/src/main/kotlin/io/element/android/appnav/room/RoomFlowNode.kt
@@ -17,10 +17,9 @@
package io.element.android.appnav.room
import android.os.Parcelable
-import androidx.compose.foundation.layout.Box
-import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable
-import androidx.compose.ui.Alignment
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.lifecycle.lifecycleScope
import com.bumble.appyx.core.modality.BuildContext
@@ -36,22 +35,30 @@ import dagger.assisted.AssistedInject
import io.element.android.anvilannotations.ContributesNode
import io.element.android.appnav.room.joined.JoinedRoomFlowNode
import io.element.android.appnav.room.joined.JoinedRoomLoadedFlowNode
+import io.element.android.appnav.room.joined.LoadingRoomNodeView
+import io.element.android.appnav.room.joined.LoadingRoomState
import io.element.android.features.joinroom.api.JoinRoomEntryPoint
+import io.element.android.features.networkmonitor.api.NetworkMonitor
+import io.element.android.features.networkmonitor.api.NetworkStatus
+import io.element.android.features.roomaliasesolver.api.RoomAliasResolverEntryPoint
import io.element.android.features.roomdirectory.api.RoomDescription
import io.element.android.libraries.architecture.BackstackView
import io.element.android.libraries.architecture.BaseFlowNode
import io.element.android.libraries.architecture.NodeInputs
import io.element.android.libraries.architecture.createNode
import io.element.android.libraries.architecture.inputs
-import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator
+import io.element.android.libraries.core.bool.orFalse
import io.element.android.libraries.di.SessionScope
import io.element.android.libraries.matrix.api.MatrixClient
+import io.element.android.libraries.matrix.api.core.RoomAlias
import io.element.android.libraries.matrix.api.core.RoomId
+import io.element.android.libraries.matrix.api.core.RoomIdOrAlias
import io.element.android.libraries.matrix.api.room.CurrentUserMembership
-import io.element.android.libraries.matrix.api.room.RoomMembershipObserver
-import kotlinx.coroutines.flow.filter
+import kotlinx.coroutines.flow.combine
+import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.launchIn
-import kotlinx.coroutines.flow.onEach
+import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.launch
import kotlinx.parcelize.Parcelize
import timber.log.Timber
import java.util.Optional
@@ -62,8 +69,9 @@ class RoomFlowNode @AssistedInject constructor(
@Assisted val buildContext: BuildContext,
@Assisted plugins: List,
private val client: MatrixClient,
- private val roomMembershipObserver: RoomMembershipObserver,
private val joinRoomEntryPoint: JoinRoomEntryPoint,
+ private val roomAliasResolverEntryPoint: RoomAliasResolverEntryPoint,
+ private val networkMonitor: NetworkMonitor,
) : BaseFlowNode(
backstack = BackStack(
initialElement = NavTarget.Loading,
@@ -73,9 +81,9 @@ class RoomFlowNode @AssistedInject constructor(
plugins = plugins
) {
data class Inputs(
- val roomId: RoomId,
+ val roomIdOrAlias: RoomIdOrAlias,
val roomDescription: Optional,
- val initialElement: RoomNavigationTarget = RoomNavigationTarget.Messages,
+ val initialElement: RoomNavigationTarget,
) : NodeInputs
private val inputs: Inputs = inputs()
@@ -85,54 +93,108 @@ class RoomFlowNode @AssistedInject constructor(
data object Loading : NavTarget
@Parcelize
- data object JoinRoom : NavTarget
+ data class Resolving(val roomAlias: RoomAlias) : NavTarget
@Parcelize
- data object JoinedRoom : NavTarget
+ data class JoinRoom(val roomId: RoomId) : NavTarget
+
+ @Parcelize
+ data class JoinedRoom(val roomId: RoomId) : NavTarget
}
override fun onBuilt() {
super.onBuilt()
- client.getRoomInfoFlow(
- inputs.roomId
- ).onEach { roomInfo ->
- Timber.d("Room membership: ${roomInfo.map { it.currentUserMembership }}")
- if (roomInfo.getOrNull()?.currentUserMembership == CurrentUserMembership.JOINED) {
- backstack.newRoot(NavTarget.JoinedRoom)
- } else {
- backstack.newRoot(NavTarget.JoinRoom)
+ resolveRoomId()
+ }
+
+ private fun resolveRoomId() {
+ lifecycleScope.launch {
+ when (val i = inputs.roomIdOrAlias) {
+ is RoomIdOrAlias.Alias -> {
+ backstack.newRoot(NavTarget.Resolving(i.roomAlias))
+ }
+ is RoomIdOrAlias.Id -> {
+ subscribeToRoomInfoFlow(i.roomId)
+ }
}
}
- .launchIn(lifecycleScope)
+ }
+
+ private fun subscribeToRoomInfoFlow(roomId: RoomId) {
+ val roomInfoFlow = client.getRoomInfoFlow(
+ roomId = roomId
+ ).map { it.getOrNull() }
- // When leaving the room from this session only, navigate up.
- roomMembershipObserver.updates
- .filter { update -> update.roomId == inputs.roomId && !update.isUserInRoom }
- .onEach {
- navigateUp()
+ val isSpaceFlow = roomInfoFlow.map { it?.isSpace.orFalse() }.distinctUntilChanged()
+ val currentMembershipFlow = roomInfoFlow.map { it?.currentUserMembership }.distinctUntilChanged()
+ combine(currentMembershipFlow, isSpaceFlow) { membership, isSpace ->
+ Timber.d("Room membership: $membership")
+ when (membership) {
+ CurrentUserMembership.JOINED -> {
+ if (isSpace) {
+ // It should not happen, but probably due to an issue in the sliding sync,
+ // we can have a space here in case the space has just been joined.
+ // So navigate to the JoinRoom target for now, which will
+ // handle the space not supported screen
+ backstack.newRoot(NavTarget.JoinRoom(roomId))
+ } else {
+ backstack.newRoot(NavTarget.JoinedRoom(roomId))
+ }
+ }
+ CurrentUserMembership.LEFT -> {
+ // Left the room, navigate out of this flow
+ navigateUp()
+ }
+ else -> {
+ // Was invited or the room is not known, display the join room screen
+ backstack.newRoot(NavTarget.JoinRoom(roomId))
+ }
}
- .launchIn(lifecycleScope)
+ }.launchIn(lifecycleScope)
}
override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {
return when (navTarget) {
- NavTarget.Loading -> loadingNode(buildContext)
- NavTarget.JoinRoom -> {
- val inputs = JoinRoomEntryPoint.Inputs(inputs.roomId, roomDescription = inputs.roomDescription)
+ is NavTarget.Loading -> loadingNode(buildContext)
+ is NavTarget.Resolving -> {
+ val callback = object : RoomAliasResolverEntryPoint.Callback {
+ override fun onAliasResolved(roomId: RoomId) {
+ subscribeToRoomInfoFlow(roomId)
+ }
+ }
+ val params = RoomAliasResolverEntryPoint.Params(navTarget.roomAlias)
+ roomAliasResolverEntryPoint.nodeBuilder(this, buildContext)
+ .callback(callback)
+ .params(params)
+ .build()
+ }
+ is NavTarget.JoinRoom -> {
+ val inputs = JoinRoomEntryPoint.Inputs(
+ roomId = navTarget.roomId,
+ roomIdOrAlias = inputs.roomIdOrAlias,
+ roomDescription = inputs.roomDescription,
+ )
joinRoomEntryPoint.createNode(this, buildContext, inputs)
}
- NavTarget.JoinedRoom -> {
+ is NavTarget.JoinedRoom -> {
val roomFlowNodeCallback = plugins()
- val inputs = JoinedRoomFlowNode.Inputs(inputs.roomId, initialElement = inputs.initialElement)
+ val inputs = JoinedRoomFlowNode.Inputs(
+ roomId = navTarget.roomId,
+ initialElement = inputs.initialElement
+ )
createNode(buildContext, plugins = listOf(inputs) + roomFlowNodeCallback)
}
}
}
- private fun loadingNode(buildContext: BuildContext) = node(buildContext) {
- Box(modifier = it.fillMaxSize(), contentAlignment = Alignment.Center) {
- CircularProgressIndicator()
- }
+ private fun loadingNode(buildContext: BuildContext) = node(buildContext) { modifier ->
+ val networkStatus by networkMonitor.connectivity.collectAsState()
+ LoadingRoomNodeView(
+ state = LoadingRoomState.Loading,
+ hasNetworkConnection = networkStatus == NetworkStatus.Online,
+ onBackClicked = { navigateUp() },
+ modifier = modifier,
+ )
}
@Composable
diff --git a/appnav/src/main/kotlin/io/element/android/appnav/room/RoomNavigationTarget.kt b/appnav/src/main/kotlin/io/element/android/appnav/room/RoomNavigationTarget.kt
index 901b2667be..c8d8cf9030 100644
--- a/appnav/src/main/kotlin/io/element/android/appnav/room/RoomNavigationTarget.kt
+++ b/appnav/src/main/kotlin/io/element/android/appnav/room/RoomNavigationTarget.kt
@@ -16,8 +16,17 @@
package io.element.android.appnav.room
-enum class RoomNavigationTarget {
- Messages,
- Details,
- NotificationSettings,
+import android.os.Parcelable
+import io.element.android.libraries.matrix.api.core.EventId
+import kotlinx.parcelize.Parcelize
+
+sealed interface RoomNavigationTarget : Parcelable {
+ @Parcelize
+ data class Messages(val focusedEventId: EventId? = null) : RoomNavigationTarget
+
+ @Parcelize
+ data object Details : RoomNavigationTarget
+
+ @Parcelize
+ data object NotificationSettings : RoomNavigationTarget
}
diff --git a/appnav/src/main/kotlin/io/element/android/appnav/room/joined/JoinedRoomFlowNode.kt b/appnav/src/main/kotlin/io/element/android/appnav/room/joined/JoinedRoomFlowNode.kt
index 36def888ac..49bcb53048 100644
--- a/appnav/src/main/kotlin/io/element/android/appnav/room/joined/JoinedRoomFlowNode.kt
+++ b/appnav/src/main/kotlin/io/element/android/appnav/room/joined/JoinedRoomFlowNode.kt
@@ -69,7 +69,7 @@ class JoinedRoomFlowNode @AssistedInject constructor(
) {
data class Inputs(
val roomId: RoomId,
- val initialElement: RoomNavigationTarget = RoomNavigationTarget.Messages,
+ val initialElement: RoomNavigationTarget,
) : NodeInputs
private val inputs: Inputs = inputs()
@@ -106,7 +106,10 @@ class JoinedRoomFlowNode @AssistedInject constructor(
val roomFlowNodeCallback = plugins()
val awaitRoomState = loadingRoomStateStateFlow.value
if (awaitRoomState is LoadingRoomState.Loaded) {
- val inputs = JoinedRoomLoadedFlowNode.Inputs(awaitRoomState.room, initialElement = inputs.initialElement)
+ val inputs = JoinedRoomLoadedFlowNode.Inputs(
+ room = awaitRoomState.room,
+ initialElement = inputs.initialElement
+ )
createNode(buildContext, plugins = listOf(inputs) + roomFlowNodeCallback)
} else {
loadingNode(buildContext, this::navigateUp)
diff --git a/appnav/src/main/kotlin/io/element/android/appnav/room/joined/JoinedRoomLoadedFlowNode.kt b/appnav/src/main/kotlin/io/element/android/appnav/room/joined/JoinedRoomLoadedFlowNode.kt
index a5d7893c91..142b658e5e 100644
--- a/appnav/src/main/kotlin/io/element/android/appnav/room/joined/JoinedRoomLoadedFlowNode.kt
+++ b/appnav/src/main/kotlin/io/element/android/appnav/room/joined/JoinedRoomLoadedFlowNode.kt
@@ -42,8 +42,10 @@ import io.element.android.libraries.architecture.inputs
import io.element.android.libraries.di.DaggerComponentOwner
import io.element.android.libraries.di.SessionScope
import io.element.android.libraries.matrix.api.MatrixClient
+import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.UserId
+import io.element.android.libraries.matrix.api.permalink.PermalinkData
import io.element.android.libraries.matrix.api.room.MatrixRoom
import io.element.android.services.appnavstate.api.AppNavigationStateService
import kotlinx.coroutines.CoroutineScope
@@ -63,8 +65,8 @@ class JoinedRoomLoadedFlowNode @AssistedInject constructor(
roomComponentFactory: RoomComponentFactory,
) : BaseFlowNode(
backstack = BackStack(
- initialElement = when (plugins.filterIsInstance(Inputs::class.java).first().initialElement) {
- RoomNavigationTarget.Messages -> NavTarget.Messages
+ initialElement = when (val input = plugins.filterIsInstance(Inputs::class.java).first().initialElement) {
+ is RoomNavigationTarget.Messages -> NavTarget.Messages(input.focusedEventId)
RoomNavigationTarget.Details -> NavTarget.RoomDetails
RoomNavigationTarget.NotificationSettings -> NavTarget.RoomNotificationSettings
},
@@ -75,13 +77,14 @@ class JoinedRoomLoadedFlowNode @AssistedInject constructor(
), DaggerComponentOwner {
interface Callback : Plugin {
fun onOpenRoom(roomId: RoomId)
+ fun onPermalinkClicked(data: PermalinkData)
fun onForwardedToSingleRoom(roomId: RoomId)
fun onOpenGlobalNotificationSettings()
}
data class Inputs(
val room: MatrixRoom,
- val initialElement: RoomNavigationTarget = RoomNavigationTarget.Messages,
+ val initialElement: RoomNavigationTarget,
) : NodeInputs
private val inputs: Inputs = inputs()
@@ -139,7 +142,7 @@ class JoinedRoomLoadedFlowNode @AssistedInject constructor(
override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {
return when (navTarget) {
- NavTarget.Messages -> {
+ is NavTarget.Messages -> {
val callback = object : MessagesEntryPoint.Callback {
override fun onRoomDetailsClicked() {
backstack.push(NavTarget.RoomDetails)
@@ -149,11 +152,18 @@ class JoinedRoomLoadedFlowNode @AssistedInject constructor(
backstack.push(NavTarget.RoomMemberDetails(userId))
}
+ override fun onPermalinkClicked(data: PermalinkData) {
+ callbacks.forEach { it.onPermalinkClicked(data) }
+ }
+
override fun onForwardedToSingleRoom(roomId: RoomId) {
callbacks.forEach { it.onForwardedToSingleRoom(roomId) }
}
}
- messagesEntryPoint.createNode(this, buildContext, callback)
+ messagesEntryPoint.nodeBuilder(this, buildContext)
+ .params(MessagesEntryPoint.Params(navTarget.focusedEventId))
+ .callback(callback)
+ .build()
}
NavTarget.RoomDetails -> {
createRoomDetailsNode(buildContext, RoomDetailsEntryPoint.InitialTarget.RoomDetails)
@@ -169,7 +179,7 @@ class JoinedRoomLoadedFlowNode @AssistedInject constructor(
sealed interface NavTarget : Parcelable {
@Parcelize
- data object Messages : NavTarget
+ data class Messages(val focusedEventId: EventId? = null) : NavTarget
@Parcelize
data object RoomDetails : NavTarget
diff --git a/appnav/src/main/kotlin/io/element/android/appnav/root/RootView.kt b/appnav/src/main/kotlin/io/element/android/appnav/root/RootView.kt
index 26772e56fa..1833aee1c3 100644
--- a/appnav/src/main/kotlin/io/element/android/appnav/root/RootView.kt
+++ b/appnav/src/main/kotlin/io/element/android/appnav/root/RootView.kt
@@ -68,7 +68,7 @@ fun RootView(
@PreviewsDayNight
@Composable
-internal fun RootPreview(@PreviewParameter(RootStateProvider::class) rootState: RootState) = ElementPreview {
+internal fun RootViewPreview(@PreviewParameter(RootStateProvider::class) rootState: RootState) = ElementPreview {
RootView(
state = rootState,
onOpenBugReport = {},
diff --git a/appnav/src/test/kotlin/io/element/android/appnav/JoinRoomLoadedFlowNodeTest.kt b/appnav/src/test/kotlin/io/element/android/appnav/JoinRoomLoadedFlowNodeTest.kt
index 08702eeb5a..93e727da75 100644
--- a/appnav/src/test/kotlin/io/element/android/appnav/JoinRoomLoadedFlowNodeTest.kt
+++ b/appnav/src/test/kotlin/io/element/android/appnav/JoinRoomLoadedFlowNodeTest.kt
@@ -27,6 +27,7 @@ import com.bumble.appyx.testing.junit4.util.MainDispatcherRule
import com.bumble.appyx.testing.unit.common.helper.parentNodeTestHelper
import com.google.common.truth.Truth.assertThat
import io.element.android.appnav.di.RoomComponentFactory
+import io.element.android.appnav.room.RoomNavigationTarget
import io.element.android.appnav.room.joined.JoinedRoomLoadedFlowNode
import io.element.android.features.messages.api.MessagesEntryPoint
import io.element.android.features.roomdetails.api.RoomDetailsEntryPoint
@@ -47,14 +48,30 @@ class JoinRoomLoadedFlowNodeTest {
@get:Rule
val mainDispatcherRule = MainDispatcherRule()
- private class FakeMessagesEntryPoint : MessagesEntryPoint {
+ private class FakeMessagesEntryPoint : MessagesEntryPoint, MessagesEntryPoint.NodeBuilder {
+ var buildContext: BuildContext? = null
var nodeId: String? = null
+ var parameters: MessagesEntryPoint.Params? = null
var callback: MessagesEntryPoint.Callback? = null
- override fun createNode(parentNode: Node, buildContext: BuildContext, callback: MessagesEntryPoint.Callback): Node {
- return node(buildContext) {}.also {
+ override fun nodeBuilder(parentNode: Node, buildContext: BuildContext): MessagesEntryPoint.NodeBuilder {
+ this.buildContext = buildContext
+ return this
+ }
+
+ override fun params(params: MessagesEntryPoint.Params): MessagesEntryPoint.NodeBuilder {
+ parameters = params
+ return this
+ }
+
+ override fun callback(callback: MessagesEntryPoint.Callback): MessagesEntryPoint.NodeBuilder {
+ this.callback = callback
+ return this
+ }
+
+ override fun build(): Node {
+ return node(buildContext!!) {}.also {
nodeId = it.id
- this.callback = callback
}
}
}
@@ -108,7 +125,7 @@ class JoinRoomLoadedFlowNodeTest {
// GIVEN
val room = FakeMatrixRoom()
val fakeMessagesEntryPoint = FakeMessagesEntryPoint()
- val inputs = JoinedRoomLoadedFlowNode.Inputs(room)
+ val inputs = JoinedRoomLoadedFlowNode.Inputs(room, RoomNavigationTarget.Messages())
val roomFlowNode = createJoinedRoomLoadedFlowNode(
plugins = listOf(inputs),
messagesEntryPoint = fakeMessagesEntryPoint,
@@ -118,9 +135,9 @@ class JoinRoomLoadedFlowNodeTest {
val roomFlowNodeTestHelper = roomFlowNode.parentNodeTestHelper()
// THEN
- assertThat(roomFlowNode.backstack.activeElement).isEqualTo(JoinedRoomLoadedFlowNode.NavTarget.Messages)
- roomFlowNodeTestHelper.assertChildHasLifecycle(JoinedRoomLoadedFlowNode.NavTarget.Messages, Lifecycle.State.CREATED)
- val messagesNode = roomFlowNode.childNode(JoinedRoomLoadedFlowNode.NavTarget.Messages)!!
+ assertThat(roomFlowNode.backstack.activeElement).isEqualTo(JoinedRoomLoadedFlowNode.NavTarget.Messages())
+ roomFlowNodeTestHelper.assertChildHasLifecycle(JoinedRoomLoadedFlowNode.NavTarget.Messages(), Lifecycle.State.CREATED)
+ val messagesNode = roomFlowNode.childNode(JoinedRoomLoadedFlowNode.NavTarget.Messages())!!
assertThat(messagesNode.id).isEqualTo(fakeMessagesEntryPoint.nodeId)
}
@@ -130,7 +147,7 @@ class JoinRoomLoadedFlowNodeTest {
val room = FakeMatrixRoom()
val fakeMessagesEntryPoint = FakeMessagesEntryPoint()
val fakeRoomDetailsEntryPoint = FakeRoomDetailsEntryPoint()
- val inputs = JoinedRoomLoadedFlowNode.Inputs(room)
+ val inputs = JoinedRoomLoadedFlowNode.Inputs(room, RoomNavigationTarget.Messages())
val roomFlowNode = createJoinedRoomLoadedFlowNode(
plugins = listOf(inputs),
messagesEntryPoint = fakeMessagesEntryPoint,
diff --git a/appnav/src/test/kotlin/io/element/android/appnav/intent/IntentResolverTest.kt b/appnav/src/test/kotlin/io/element/android/appnav/intent/IntentResolverTest.kt
index 3ad11787df..074009cacc 100644
--- a/appnav/src/test/kotlin/io/element/android/appnav/intent/IntentResolverTest.kt
+++ b/appnav/src/test/kotlin/io/element/android/appnav/intent/IntentResolverTest.kt
@@ -18,6 +18,7 @@ package io.element.android.appnav.intent
import android.app.Activity
import android.content.Intent
+import android.net.Uri
import androidx.core.net.toUri
import com.google.common.truth.Truth.assertThat
import io.element.android.features.login.api.oidc.OidcAction
@@ -26,9 +27,12 @@ import io.element.android.features.login.impl.oidc.OidcUrlParser
import io.element.android.libraries.deeplink.DeepLinkCreator
import io.element.android.libraries.deeplink.DeeplinkData
import io.element.android.libraries.deeplink.DeeplinkParser
+import io.element.android.libraries.matrix.api.core.UserId
+import io.element.android.libraries.matrix.api.permalink.PermalinkData
import io.element.android.libraries.matrix.test.A_ROOM_ID
import io.element.android.libraries.matrix.test.A_SESSION_ID
import io.element.android.libraries.matrix.test.A_THREAD_ID
+import io.element.android.libraries.matrix.test.permalink.FakePermalinkParser
import org.junit.Assert.assertThrows
import org.junit.Test
import org.junit.runner.RunWith
@@ -162,9 +166,60 @@ class IntentResolverTest {
}
}
+ @Test
+ fun `test resolve external permalink`() {
+ val permalinkData = PermalinkData.UserLink(
+ userId = UserId("@alice:matrix.org")
+ )
+ val sut = createIntentResolver(
+ permalinkParserResult = { permalinkData }
+ )
+ val intent = Intent(RuntimeEnvironment.getApplication(), Activity::class.java).apply {
+ action = Intent.ACTION_VIEW
+ data = "https://matrix.to/#/@alice:matrix.org".toUri()
+ }
+ val result = sut.resolve(intent)
+ assertThat(result).isEqualTo(
+ ResolvedIntent.Permalink(
+ permalinkData = permalinkData
+ )
+ )
+ }
+
+ @Test
+ fun `test resolve external permalink, FallbackLink should be ignored`() {
+ val sut = createIntentResolver(
+ permalinkParserResult = { PermalinkData.FallbackLink(Uri.parse("https://matrix.org")) }
+ )
+ val intent = Intent(RuntimeEnvironment.getApplication(), Activity::class.java).apply {
+ action = Intent.ACTION_VIEW
+ data = "https://matrix.to/#/@alice:matrix.org".toUri()
+ }
+ val result = sut.resolve(intent)
+ assertThat(result).isNull()
+ }
+
+ @Test
+ fun `test resolve external permalink, invalid action`() {
+ val permalinkData = PermalinkData.UserLink(
+ userId = UserId("@alice:matrix.org")
+ )
+ val sut = createIntentResolver(
+ permalinkParserResult = { permalinkData }
+ )
+ val intent = Intent(RuntimeEnvironment.getApplication(), Activity::class.java).apply {
+ action = Intent.ACTION_SEND
+ data = "https://matrix.to/invalid".toUri()
+ }
+ val result = sut.resolve(intent)
+ assertThat(result).isNull()
+ }
+
@Test
fun `test resolve invalid`() {
- val sut = createIntentResolver()
+ val sut = createIntentResolver(
+ permalinkParserResult = { PermalinkData.FallbackLink(Uri.parse("https://matrix.org")) }
+ )
val intent = Intent(RuntimeEnvironment.getApplication(), Activity::class.java).apply {
action = Intent.ACTION_VIEW
data = "io.element:/invalid".toUri()
@@ -173,12 +228,17 @@ class IntentResolverTest {
assertThat(result).isNull()
}
- private fun createIntentResolver(): IntentResolver {
+ private fun createIntentResolver(
+ permalinkParserResult: () -> PermalinkData = { throw NotImplementedError() }
+ ): IntentResolver {
return IntentResolver(
deeplinkParser = DeeplinkParser(),
oidcIntentResolver = DefaultOidcIntentResolver(
oidcUrlParser = OidcUrlParser()
),
+ permalinkParser = FakePermalinkParser(
+ result = permalinkParserResult
+ ),
)
}
}
diff --git a/appnav/src/test/kotlin/io/element/android/appnav/loggedin/AnalyticsVerificationStateMappingTests.kt b/appnav/src/test/kotlin/io/element/android/appnav/loggedin/AnalyticsVerificationStateMappingTests.kt
new file mode 100644
index 0000000000..cce8879d4f
--- /dev/null
+++ b/appnav/src/test/kotlin/io/element/android/appnav/loggedin/AnalyticsVerificationStateMappingTests.kt
@@ -0,0 +1,70 @@
+/*
+ * Copyright (c) 2024 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.element.android.appnav.loggedin
+
+import com.google.common.truth.Truth.assertThat
+import im.vector.app.features.analytics.plan.CryptoSessionStateChange
+import im.vector.app.features.analytics.plan.UserProperties
+import io.element.android.libraries.matrix.api.encryption.RecoveryState
+import io.element.android.libraries.matrix.api.verification.SessionVerifiedStatus
+import io.element.android.tests.testutils.WarmUpRule
+import kotlinx.coroutines.test.runTest
+import org.junit.Rule
+import org.junit.Test
+
+class AnalyticsVerificationStateMappingTests {
+ @get:Rule
+ val warmUpRule = WarmUpRule()
+
+ @Test
+ fun `Test verification Mappings`() = runTest {
+ assertThat(SessionVerifiedStatus.Verified.toAnalyticsUserPropertyValue())
+ .isEqualTo(UserProperties.VerificationState.Verified)
+ assertThat(SessionVerifiedStatus.NotVerified.toAnalyticsUserPropertyValue())
+ .isEqualTo(UserProperties.VerificationState.NotVerified)
+
+ assertThat(SessionVerifiedStatus.Verified.toAnalyticsStateChangeValue())
+ .isEqualTo(CryptoSessionStateChange.VerificationState.Verified)
+ assertThat(SessionVerifiedStatus.NotVerified.toAnalyticsStateChangeValue())
+ .isEqualTo(CryptoSessionStateChange.VerificationState.NotVerified)
+ }
+
+ @Test
+ fun `Test recovery state Mappings`() = runTest {
+ assertThat(RecoveryState.UNKNOWN.toAnalyticsUserPropertyValue())
+ .isNull()
+ assertThat(RecoveryState.WAITING_FOR_SYNC.toAnalyticsUserPropertyValue())
+ .isNull()
+ assertThat(RecoveryState.INCOMPLETE.toAnalyticsUserPropertyValue())
+ .isEqualTo(UserProperties.RecoveryState.Incomplete)
+ assertThat(RecoveryState.ENABLED.toAnalyticsUserPropertyValue())
+ .isEqualTo(UserProperties.RecoveryState.Enabled)
+ assertThat(RecoveryState.DISABLED.toAnalyticsUserPropertyValue())
+ .isEqualTo(UserProperties.RecoveryState.Disabled)
+
+ assertThat(RecoveryState.UNKNOWN.toAnalyticsStateChangeValue())
+ .isNull()
+ assertThat(RecoveryState.WAITING_FOR_SYNC.toAnalyticsStateChangeValue())
+ .isNull()
+ assertThat(RecoveryState.INCOMPLETE.toAnalyticsStateChangeValue())
+ .isEqualTo(CryptoSessionStateChange.RecoveryState.Incomplete)
+ assertThat(RecoveryState.ENABLED.toAnalyticsStateChangeValue())
+ .isEqualTo(CryptoSessionStateChange.RecoveryState.Enabled)
+ assertThat(RecoveryState.DISABLED.toAnalyticsStateChangeValue())
+ .isEqualTo(CryptoSessionStateChange.RecoveryState.Disabled)
+ }
+}
diff --git a/appnav/src/test/kotlin/io/element/android/appnav/loggedin/LoggedInPresenterTest.kt b/appnav/src/test/kotlin/io/element/android/appnav/loggedin/LoggedInPresenterTest.kt
index df17053512..f57f648599 100644
--- a/appnav/src/test/kotlin/io/element/android/appnav/loggedin/LoggedInPresenterTest.kt
+++ b/appnav/src/test/kotlin/io/element/android/appnav/loggedin/LoggedInPresenterTest.kt
@@ -20,13 +20,19 @@ import app.cash.molecule.RecompositionMode
import app.cash.molecule.moleculeFlow
import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
+import im.vector.app.features.analytics.plan.CryptoSessionStateChange
+import im.vector.app.features.analytics.plan.UserProperties
import io.element.android.features.networkmonitor.api.NetworkStatus
import io.element.android.features.networkmonitor.test.FakeNetworkMonitor
+import io.element.android.libraries.matrix.api.encryption.RecoveryState
import io.element.android.libraries.matrix.api.roomlist.RoomListService
+import io.element.android.libraries.matrix.api.verification.SessionVerifiedStatus
import io.element.android.libraries.matrix.test.FakeMatrixClient
+import io.element.android.libraries.matrix.test.encryption.FakeEncryptionService
import io.element.android.libraries.matrix.test.roomlist.FakeRoomListService
import io.element.android.libraries.matrix.test.verification.FakeSessionVerificationService
import io.element.android.libraries.push.test.FakePushService
+import io.element.android.services.analytics.test.FakeAnalyticsService
import io.element.android.tests.testutils.WarmUpRule
import io.element.android.tests.testutils.consumeItemsUntilPredicate
import kotlinx.coroutines.test.runTest
@@ -64,15 +70,56 @@ class LoggedInPresenterTest {
}
}
+ @Test
+ fun `present - report crypto status analytics`() = runTest {
+ val analyticsService = FakeAnalyticsService()
+ val roomListService = FakeRoomListService()
+ val verificationService = FakeSessionVerificationService()
+ val encryptionService = FakeEncryptionService()
+ val presenter = LoggedInPresenter(
+ matrixClient = FakeMatrixClient(roomListService = roomListService, encryptionService = encryptionService),
+ networkMonitor = FakeNetworkMonitor(NetworkStatus.Online),
+ pushService = FakePushService(),
+ sessionVerificationService = verificationService,
+ analyticsService = analyticsService,
+ encryptionService = encryptionService
+ )
+ moleculeFlow(RecompositionMode.Immediate) {
+ presenter.present()
+ }.test {
+ encryptionService.emitRecoveryState(RecoveryState.UNKNOWN)
+ encryptionService.emitRecoveryState(RecoveryState.INCOMPLETE)
+ verificationService.emitVerifiedStatus(SessionVerifiedStatus.Verified)
+
+ skipItems(4)
+
+ assertThat(analyticsService.capturedEvents.size).isEqualTo(1)
+ assertThat(analyticsService.capturedEvents[0]).isInstanceOf(CryptoSessionStateChange::class.java)
+
+ assertThat(analyticsService.capturedUserProperties.size).isEqualTo(1)
+ assertThat(analyticsService.capturedUserProperties[0].recoveryState).isEqualTo(UserProperties.RecoveryState.Incomplete)
+ assertThat(analyticsService.capturedUserProperties[0].verificationState).isEqualTo(UserProperties.VerificationState.Verified)
+
+ // ensure a sync status change does not trigger a new capture
+ roomListService.postSyncIndicator(RoomListService.SyncIndicator.Show)
+ skipItems(1)
+ assertThat(analyticsService.capturedEvents.size).isEqualTo(1)
+ }
+ }
+
private fun createLoggedInPresenter(
roomListService: RoomListService = FakeRoomListService(),
- networkStatus: NetworkStatus = NetworkStatus.Offline
+ networkStatus: NetworkStatus = NetworkStatus.Offline,
+ analyticsService: FakeAnalyticsService = FakeAnalyticsService(),
+ encryptionService: FakeEncryptionService = FakeEncryptionService(),
): LoggedInPresenter {
return LoggedInPresenter(
matrixClient = FakeMatrixClient(roomListService = roomListService),
networkMonitor = FakeNetworkMonitor(networkStatus),
pushService = FakePushService(),
sessionVerificationService = FakeSessionVerificationService(),
+ analyticsService = analyticsService,
+ encryptionService = encryptionService
)
}
}
diff --git a/build.gradle.kts b/build.gradle.kts
index 1d82d0d1a6..a0eedc8db7 100644
--- a/build.gradle.kts
+++ b/build.gradle.kts
@@ -118,6 +118,9 @@ dependencyAnalysis {
onUnusedDependencies {
exclude("com.jakewharton.timber:timber")
}
+ onUnusedAnnotationProcessors {}
+ onRedundantPlugins {}
+ onIncorrectConfiguration {}
}
}
}
diff --git a/docs/deeplink.md b/docs/deeplink.md
new file mode 100644
index 0000000000..1350b2f736
--- /dev/null
+++ b/docs/deeplink.md
@@ -0,0 +1,71 @@
+# Element X Android deeplink
+
+
+
+* [Introduction](#introduction)
+ * [Asset Links](#asset-links)
+ * [Supported links](#supported-links)
+* [Developer tools](#developer-tools)
+
+
+
+
+## Introduction
+
+Element X Android supports deep linking to specific screens in the application. This document explains how to use deep links in Element X Android.
+
+### Asset Links
+
+The asset links file is available at https://element.io/.well-known/assetlinks.json
+
+### Supported links
+
+Element Call link:
+> https://call.element.io/Example
+
+Link to a user:
+> https://app.element.io/#/user/@alice:matrix.org
+
+Link to a room by id or alias:
+> https://app.element.io/#/room/!roomid:matrix.org
+> https://app.element.io/#/room/#element-x-android:matrix.org
+
+Link to a room with a specific event:
+> https://app.element.io/#/room/!roomid:matrix.org/$eventid
+
+Note that it will also work with other domain such as:
+> https://mobile.element.io
+> https://develop.element.io
+> https://staging.element.io
+
+## Developer tools
+
+Using an Android 12 or higher emulator
+
+Ensure links verification is enabled
+```bash
+adb shell am compat enable 175408749 io.element.android.x.debug
+```
+
+Reset link verifications for the given package id
+```bash
+adb shell pm set-app-links --package io.element.android.x.debug 0 all
+```
+
+Force the package id links to be verified
+```bash
+adb shell pm verify-app-links --re-verify io.element.android.x.debug
+```
+
+Print the link verification of the package id
+```bash
+adb shell pm get-app-links io.element.android.x.debug
+```
+
+```
+ io.element.android.x.debug:
+ ID: e2ece472-c266-4bf0-829c-be79959a6270
+ Signatures: [B0:B0:51:DC:56:5C:81:2F:E1:7F:6F:3E:94:5B:4D:79:04:71:23:AB:0D:A6:12:86:76:9E:B2:94:91:97:13:0E]
+ Domain verification state:
+ *.element.io: 1024
+```
diff --git a/fastlane/metadata/android/en-US/changelogs/40004120.txt b/fastlane/metadata/android/en-US/changelogs/40004120.txt
new file mode 100644
index 0000000000..6ecb5ad718
--- /dev/null
+++ b/fastlane/metadata/android/en-US/changelogs/40004120.txt
@@ -0,0 +1,10 @@
+Main changes in this version:
+
+- Added support for opening matrix URLs inside the app and navigating to replied to messages.
+- Added per-app language support for Android 13+.
+- Session verification is no longer mandatory for already logged in users.
+- Better log handling.
+- Fixed CVE-2024-34353 / GHSA-9ggc-845v-gcgv.
+- UX improvements.
+
+Full changelog: https://github.com/element-hq/element-x-android/releases
\ No newline at end of file
diff --git a/features/analytics/api/src/main/res/values-de/translations.xml b/features/analytics/api/src/main/res/values-de/translations.xml
index e4f5952ae0..ec26ff08fc 100644
--- a/features/analytics/api/src/main/res/values-de/translations.xml
+++ b/features/analytics/api/src/main/res/values-de/translations.xml
@@ -1,7 +1,7 @@
"Teile anonyme Nutzungsdaten, um uns bei der Identifizierung von Problemen zu helfen."
- "Du kannst alle unsere Bedingungen lesen %1$s."
+ "Weitere Informationen findest du %1$s ."
"hier"
"Analysedaten teilen"
diff --git a/features/analytics/impl/src/main/kotlin/io/element/android/features/analytics/impl/AnalyticsOptInView.kt b/features/analytics/impl/src/main/kotlin/io/element/android/features/analytics/impl/AnalyticsOptInView.kt
index 08095a4e33..a2290619ae 100644
--- a/features/analytics/impl/src/main/kotlin/io/element/android/features/analytics/impl/AnalyticsOptInView.kt
+++ b/features/analytics/impl/src/main/kotlin/io/element/android/features/analytics/impl/AnalyticsOptInView.kt
@@ -43,8 +43,8 @@ import io.element.android.libraries.designsystem.atomic.molecules.ButtonColumnMo
import io.element.android.libraries.designsystem.atomic.organisms.InfoListItem
import io.element.android.libraries.designsystem.atomic.organisms.InfoListOrganism
import io.element.android.libraries.designsystem.atomic.pages.HeaderFooterPage
+import io.element.android.libraries.designsystem.background.OnboardingBackground
import io.element.android.libraries.designsystem.components.BigIcon
-import io.element.android.libraries.designsystem.components.OnboardingBackground
import io.element.android.libraries.designsystem.components.PageTitle
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
diff --git a/features/analytics/impl/src/main/res/values-de/translations.xml b/features/analytics/impl/src/main/res/values-de/translations.xml
index 74eff06c69..e2180092d9 100644
--- a/features/analytics/impl/src/main/res/values-de/translations.xml
+++ b/features/analytics/impl/src/main/res/values-de/translations.xml
@@ -2,7 +2,7 @@
"Wir zeichnen keine persönlichen Daten auf und erstellen keine Profile."
"Teile anonyme Nutzungsdaten, um uns bei der Identifizierung von Problemen zu helfen."
- "Du kannst alle unsere Bedingungen lesen %1$s."
+ "Weitere Informationen findest du %1$s ."
"hier"
"Du kannst diese Funktion jederzeit deaktivieren"
"Wir geben deine Daten nicht an Dritte weiter"
diff --git a/features/call/src/main/AndroidManifest.xml b/features/call/src/main/AndroidManifest.xml
index c7db9cc38f..532d5bc40c 100644
--- a/features/call/src/main/AndroidManifest.xml
+++ b/features/call/src/main/AndroidManifest.xml
@@ -16,8 +16,12 @@
-
-
+
+
@@ -28,24 +32,27 @@
+ android:exported="true"
+ android:label="@string/element_call"
+ android:launchMode="singleTask"
+ android:taskAffinity="io.element.android.features.call">
+
+
+
@@ -55,6 +62,7 @@
+
@@ -62,7 +70,10 @@
-
+
diff --git a/features/call/src/test/kotlin/io/element/android/features/call/utils/DefaultCallWidgetProviderTest.kt b/features/call/src/test/kotlin/io/element/android/features/call/utils/DefaultCallWidgetProviderTest.kt
index 31f6327d2f..368db19fcc 100644
--- a/features/call/src/test/kotlin/io/element/android/features/call/utils/DefaultCallWidgetProviderTest.kt
+++ b/features/call/src/test/kotlin/io/element/android/features/call/utils/DefaultCallWidgetProviderTest.kt
@@ -18,7 +18,6 @@ package io.element.android.features.call.utils
import com.google.common.truth.Truth.assertThat
import io.element.android.features.preferences.api.store.AppPreferencesStore
-import io.element.android.libraries.featureflag.test.InMemoryAppPreferencesStore
import io.element.android.libraries.matrix.api.MatrixClientProvider
import io.element.android.libraries.matrix.api.widget.CallWidgetSettingsProvider
import io.element.android.libraries.matrix.test.A_ROOM_ID
@@ -28,6 +27,7 @@ import io.element.android.libraries.matrix.test.FakeMatrixClientProvider
import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
import io.element.android.libraries.matrix.test.widget.FakeCallWidgetSettingsProvider
import io.element.android.libraries.matrix.test.widget.FakeWidgetDriver
+import io.element.android.libraries.preferences.test.InMemoryAppPreferencesStore
import kotlinx.coroutines.test.runTest
import org.junit.Test
diff --git a/features/createroom/impl/src/main/res/values-de/translations.xml b/features/createroom/impl/src/main/res/values-de/translations.xml
index 03b45ffa56..441f747e7e 100644
--- a/features/createroom/impl/src/main/res/values-de/translations.xml
+++ b/features/createroom/impl/src/main/res/values-de/translations.xml
@@ -2,8 +2,8 @@
"Neuer Raum"
"Personen einladen"
- "Beim Erstellen des Raums ist ein Fehler aufgetreten"
- "Die Nachrichten in diesem Raum sind verschlüsselt. Die Verschlüsselung kann nicht nachträglich deaktiviert werden."
+ "Beim Erstellen des Chats ist ein Fehler aufgetreten"
+ "Die Nachrichten in diesem Chat sind verschlüsselt. Die Verschlüsselung kann nicht nachträglich deaktiviert werden."
"Privater Raum (nur auf Einladung)"
"Die Nachrichten sind nicht verschlüsselt und können von jedem gelesen werden. Die Verschlüsselung kann zu einem späteren Zeitpunkt aktiviert werden."
"Öffentlicher Raum (für alle)"
diff --git a/features/ftue/api/src/main/kotlin/io/element/android/features/ftue/api/FtueEntryPoint.kt b/features/ftue/api/src/main/kotlin/io/element/android/features/ftue/api/FtueEntryPoint.kt
index 186f2fe026..f3ad0b64a9 100644
--- a/features/ftue/api/src/main/kotlin/io/element/android/features/ftue/api/FtueEntryPoint.kt
+++ b/features/ftue/api/src/main/kotlin/io/element/android/features/ftue/api/FtueEntryPoint.kt
@@ -18,18 +18,12 @@ package io.element.android.features.ftue.api
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
-import com.bumble.appyx.core.plugin.Plugin
import io.element.android.libraries.architecture.FeatureEntryPoint
interface FtueEntryPoint : FeatureEntryPoint {
fun nodeBuilder(parentNode: Node, buildContext: BuildContext): NodeBuilder
interface NodeBuilder {
- fun callback(callback: Callback): NodeBuilder
fun build(): Node
}
-
- interface Callback : Plugin {
- fun onFtueFlowFinished()
- }
}
diff --git a/features/ftue/impl/build.gradle.kts b/features/ftue/impl/build.gradle.kts
index 06251cfe5d..e42763d97c 100644
--- a/features/ftue/impl/build.gradle.kts
+++ b/features/ftue/impl/build.gradle.kts
@@ -39,6 +39,7 @@ dependencies {
implementation(projects.libraries.matrix.api)
implementation(projects.libraries.matrixui)
implementation(projects.libraries.designsystem)
+ implementation(projects.libraries.preferences.api)
implementation(projects.libraries.uiStrings)
implementation(projects.libraries.testtags)
implementation(projects.features.analytics.api)
@@ -60,6 +61,7 @@ dependencies {
testImplementation(projects.services.analytics.test)
testImplementation(projects.libraries.permissions.impl)
testImplementation(projects.libraries.permissions.test)
+ testImplementation(projects.libraries.preferences.test)
testImplementation(projects.features.lockscreen.test)
testImplementation(projects.tests.testutils)
diff --git a/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/DefaultFtueEntryPoint.kt b/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/DefaultFtueEntryPoint.kt
index c89b1840af..0b089c3df9 100644
--- a/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/DefaultFtueEntryPoint.kt
+++ b/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/DefaultFtueEntryPoint.kt
@@ -31,11 +31,6 @@ class DefaultFtueEntryPoint @Inject constructor() : FtueEntryPoint {
val plugins = ArrayList()
return object : FtueEntryPoint.NodeBuilder {
- override fun callback(callback: FtueEntryPoint.Callback): FtueEntryPoint.NodeBuilder {
- plugins += callback
- return this
- }
-
override fun build(): Node {
return parentNode.createNode(buildContext, plugins)
}
diff --git a/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/FtueFlowNode.kt b/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/FtueFlowNode.kt
index cb77100d94..772343ea58 100644
--- a/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/FtueFlowNode.kt
+++ b/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/FtueFlowNode.kt
@@ -32,7 +32,6 @@ import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import io.element.android.anvilannotations.ContributesNode
import io.element.android.features.analytics.api.AnalyticsEntryPoint
-import io.element.android.features.ftue.api.FtueEntryPoint
import io.element.android.features.ftue.impl.notifications.NotificationsOptInNode
import io.element.android.features.ftue.impl.sessionverification.FtueSessionVerificationFlowNode
import io.element.android.features.ftue.impl.state.DefaultFtueService
@@ -86,8 +85,6 @@ class FtueFlowNode @AssistedInject constructor(
data object LockScreenSetup : NavTarget
}
- private val callback = plugins.filterIsInstance().firstOrNull()
-
override fun onBuilt() {
super.onBuilt()
@@ -143,7 +140,7 @@ class FtueFlowNode @AssistedInject constructor(
}
}
- private fun moveToNextStep() {
+ private fun moveToNextStep() = lifecycleScope.launch {
when (ftueState.getNextStep()) {
FtueStep.SessionVerification -> {
backstack.newRoot(NavTarget.SessionVerification)
@@ -157,7 +154,7 @@ class FtueFlowNode @AssistedInject constructor(
FtueStep.LockscreenSetup -> {
backstack.newRoot(NavTarget.LockScreenSetup)
}
- null -> callback?.onFtueFlowFinished()
+ null -> Unit
}
}
diff --git a/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/notifications/NotificationsOptInView.kt b/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/notifications/NotificationsOptInView.kt
index b9f902202b..1384d427dc 100644
--- a/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/notifications/NotificationsOptInView.kt
+++ b/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/notifications/NotificationsOptInView.kt
@@ -41,8 +41,8 @@ import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.features.ftue.impl.R
import io.element.android.libraries.designsystem.atomic.molecules.ButtonColumnMolecule
import io.element.android.libraries.designsystem.atomic.pages.HeaderFooterPage
+import io.element.android.libraries.designsystem.background.OnboardingBackground
import io.element.android.libraries.designsystem.components.BigIcon
-import io.element.android.libraries.designsystem.components.OnboardingBackground
import io.element.android.libraries.designsystem.components.PageTitle
import io.element.android.libraries.designsystem.components.avatar.Avatar
import io.element.android.libraries.designsystem.components.avatar.AvatarData
diff --git a/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/sessionverification/FtueSessionVerificationFlowNode.kt b/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/sessionverification/FtueSessionVerificationFlowNode.kt
index f59c76199a..a5dc840267 100644
--- a/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/sessionverification/FtueSessionVerificationFlowNode.kt
+++ b/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/sessionverification/FtueSessionVerificationFlowNode.kt
@@ -58,9 +58,6 @@ class FtueSessionVerificationFlowNode @AssistedInject constructor(
@Parcelize
data object EnterRecoveryKey : NavTarget
-
- @Parcelize
- data object CreateNewRecoveryKey : NavTarget
}
interface Callback : Plugin {
@@ -68,10 +65,6 @@ class FtueSessionVerificationFlowNode @AssistedInject constructor(
}
private val secureBackupEntryPointCallback = object : SecureBackupEntryPoint.Callback {
- override fun onCreateNewRecoveryKey() {
- backstack.push(NavTarget.CreateNewRecoveryKey)
- }
-
override fun onDone() {
lifecycleScope.launch {
// Move to the completed state view in the verification flow
@@ -89,10 +82,6 @@ class FtueSessionVerificationFlowNode @AssistedInject constructor(
backstack.push(NavTarget.EnterRecoveryKey)
}
- override fun onCreateNewRecoveryKey() {
- backstack.push(NavTarget.CreateNewRecoveryKey)
- }
-
override fun onDone() {
plugins().forEach { it.onDone() }
}
@@ -105,12 +94,6 @@ class FtueSessionVerificationFlowNode @AssistedInject constructor(
.callback(secureBackupEntryPointCallback)
.build()
}
- is NavTarget.CreateNewRecoveryKey -> {
- secureBackupEntryPoint.nodeBuilder(this, buildContext)
- .params(SecureBackupEntryPoint.Params(SecureBackupEntryPoint.InitialTarget.CreateNewRecoveryKey))
- .callback(secureBackupEntryPointCallback)
- .build()
- }
}
}
diff --git a/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/state/DefaultFtueService.kt b/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/state/DefaultFtueService.kt
index 87b7c3a7ee..0e8aa54f81 100644
--- a/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/state/DefaultFtueService.kt
+++ b/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/state/DefaultFtueService.kt
@@ -23,18 +23,26 @@ import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.features.ftue.api.state.FtueService
import io.element.android.features.ftue.api.state.FtueState
import io.element.android.features.lockscreen.api.LockScreenService
+import io.element.android.features.preferences.api.store.SessionPreferencesStore
import io.element.android.libraries.di.SessionScope
import io.element.android.libraries.matrix.api.verification.SessionVerificationService
+import io.element.android.libraries.matrix.api.verification.SessionVerifiedStatus
import io.element.android.libraries.permissions.api.PermissionStateProvider
import io.element.android.services.analytics.api.AnalyticsService
import io.element.android.services.toolbox.api.sdk.BuildVersionSdkIntProvider
import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.catch
+import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
+import kotlinx.coroutines.flow.timeout
import kotlinx.coroutines.runBlocking
+import timber.log.Timber
import javax.inject.Inject
+import kotlin.time.Duration.Companion.seconds
@ContributesBinding(SessionScope::class)
class DefaultFtueService @Inject constructor(
@@ -44,6 +52,7 @@ class DefaultFtueService @Inject constructor(
private val permissionStateProvider: PermissionStateProvider,
private val lockScreenService: LockScreenService,
private val sessionVerificationService: SessionVerificationService,
+ private val sessionPreferencesStore: SessionPreferencesStore,
) : FtueService {
override val state = MutableStateFlow(FtueState.Unknown)
@@ -55,7 +64,7 @@ class DefaultFtueService @Inject constructor(
}
init {
- sessionVerificationService.needsVerificationFlow
+ sessionVerificationService.sessionVerifiedStatus
.onEach { updateState() }
.launchIn(coroutineScope)
@@ -64,7 +73,7 @@ class DefaultFtueService @Inject constructor(
.launchIn(coroutineScope)
}
- fun getNextStep(currentStep: FtueStep? = null): FtueStep? =
+ suspend fun getNextStep(currentStep: FtueStep? = null): FtueStep? =
when (currentStep) {
null -> if (isSessionNotVerified()) {
FtueStep.SessionVerification
@@ -89,8 +98,8 @@ class DefaultFtueService @Inject constructor(
FtueStep.AnalyticsOptIn -> null
}
- private fun isAnyStepIncomplete(): Boolean {
- return listOf(
+ private suspend fun isAnyStepIncomplete(): Boolean {
+ return listOf Boolean>(
{ isSessionNotVerified() },
{ shouldAskNotificationPermissions() },
{ needsAnalyticsOptIn() },
@@ -98,16 +107,28 @@ class DefaultFtueService @Inject constructor(
).any { it() }
}
- private fun isSessionNotVerified(): Boolean {
- return sessionVerificationService.needsVerificationFlow.value
+ @OptIn(FlowPreview::class)
+ private suspend fun isSessionNotVerified(): Boolean {
+ // Wait for the first known (or ready) verification status
+ val readyVerifiedSessionStatus = sessionVerificationService.sessionVerifiedStatus
+ .filter { it != SessionVerifiedStatus.Unknown }
+ // This is not ideal, but there are some very rare cases when reading the flow seems to get stuck
+ .timeout(5.seconds)
+ .catch {
+ Timber.e(it, "Failed to get session verification status, assume it's not verified")
+ emit(SessionVerifiedStatus.NotVerified)
+ }
+ .first()
+ val skipVerification = suspend { sessionPreferencesStore.isSessionVerificationSkipped().first() }
+ return readyVerifiedSessionStatus == SessionVerifiedStatus.NotVerified && !skipVerification()
}
- private fun needsAnalyticsOptIn(): Boolean {
+ private suspend fun needsAnalyticsOptIn(): Boolean {
// We need this function to not be suspend, so we need to load the value through runBlocking
- return runBlocking { analyticsService.didAskUserConsent().first().not() }
+ return analyticsService.didAskUserConsent().first().not()
}
- private fun shouldAskNotificationPermissions(): Boolean {
+ private suspend fun shouldAskNotificationPermissions(): Boolean {
return if (sdkVersionProvider.isAtLeast(Build.VERSION_CODES.TIRAMISU)) {
val permission = Manifest.permission.POST_NOTIFICATIONS
val isPermissionDenied = runBlocking { permissionStateProvider.isPermissionDenied(permission).first() }
@@ -118,14 +139,12 @@ class DefaultFtueService @Inject constructor(
}
}
- private fun shouldDisplayLockscreenSetup(): Boolean {
- return runBlocking {
- lockScreenService.isSetupRequired().first()
- }
+ private suspend fun shouldDisplayLockscreenSetup(): Boolean {
+ return lockScreenService.isSetupRequired().first()
}
@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
- internal fun updateState() {
+ internal suspend fun updateState() {
state.value = when {
isAnyStepIncomplete() -> FtueState.Incomplete
else -> FtueState.Complete
diff --git a/features/ftue/impl/src/main/res/values-be/translations.xml b/features/ftue/impl/src/main/res/values-be/translations.xml
index f53a87c797..e9666b57e3 100644
--- a/features/ftue/impl/src/main/res/values-be/translations.xml
+++ b/features/ftue/impl/src/main/res/values-be/translations.xml
@@ -2,27 +2,26 @@
"Вы можаце змяніць налады пазней."
"Дазвольце апавяшчэнні і ніколі не прапускайце іх"
- "Устанаўленне злучэння"
+ "Ўсталяванне бяспечнага злучэння"
"Не атрымалася ўсталяваць бяспечнае злучэнне з новай прыладай. Існуючыя прылады па-ранейшаму ў бяспецы, і вам не трэба турбавацца пра іх."
"Што зараз?"
"Паспрабуйце зноў увайсці ў сістэму з дапамогай QR-кода, калі гэта была сеткавая праблема"
"Калі вы сутыкнуліся з той жа праблемай, паспрабуйце іншую сетку Wi-Fi або скарыстайцеся мабільнымі дадзенымі замест Wi-Fi."
"Калі гэта не дапамагло, увайдзіце ўручную"
"Злучэнне небяспечнае"
- "Вам будзе прапанавана ўвесці дзве лічбы, паказаныя ніжэй."
- "Увядзіце нумар на прыладзе"
+ "Вам будзе прапанавана ўвесці дзве лічбы, паказаныя на гэтай прыладзе."
+ "Увядзіце наступны нумар на іншай прыладзе."
"Адкрыйце %1$s на настольнай прыладзе"
"Націсніце на свой аватар"
"Выберыце %1$s"
"“Звязаць новую прыладу”"
- "Выберыце %1$s"
- "“Паказаць QR-код”"
+ "Выконвайце паказаныя інструкцыі"
"Адкрыйце %1$s на іншай прыладзе, каб атрымаць QR-код"
"Выкарыстоўвайце QR-код, паказаны на іншай прыладзе."
"Паўтарыць спробу"
"Няправільны QR-код"
"Перайсці ў налады камеры"
- "Каб працягнуць, вам неабходна дазволіць Element выкарыстоўваць камеру вашай прылады."
+ "Каб працягнуць, вам неабходна дазволіць %1$s выкарыстоўваць камеру вашай прылады."
"Дазвольце доступ да камеры для сканавання QR-кода"
"Сканаваць QR-код"
"Пачаць спачатку"
diff --git a/features/ftue/impl/src/main/res/values-cs/translations.xml b/features/ftue/impl/src/main/res/values-cs/translations.xml
index 5f41da6de7..b6c0efa729 100644
--- a/features/ftue/impl/src/main/res/values-cs/translations.xml
+++ b/features/ftue/impl/src/main/res/values-cs/translations.xml
@@ -2,7 +2,7 @@
"Nastavení můžete později změnit."
"Povolte oznámení a nezmeškejte žádnou zprávu"
- "Navazování spojení"
+ "Navazování zabezpečeného spojení"
"K novému zařízení se nepodařilo navázat bezpečné připojení. Vaše stávající zařízení jsou stále v bezpečí a nemusíte se o ně obávat."
"Co teď?"
"Zkuste se znovu přihlásit pomocí QR kódu v případě, že se jednalo o problém se sítí"
@@ -10,19 +10,18 @@
"Pokud to nefunguje, přihlaste se ručně"
"Připojení není zabezpečené"
"Budete požádáni o zadání dvou níže uvedených číslic."
- "Zadejte číslo na svém zařízení"
+ "Zadejte níže uvedené číslo na svém dalším zařízení"
"Otevřete %1$s na stolním počítači"
"Klikněte na svůj avatar"
"Vybrat %1$s"
"\"Připojit nové zařízení\""
- "Vybrat %1$s"
- "\"Zobrazit QR kód\""
+ "Postupujte podle uvedených pokynů"
"Otevřete %1$s na jiném zařízení pro získání QR kódu"
"Použijte QR kód zobrazený na druhém zařízení."
"Zkusit znovu"
"Špatný QR kód"
"Přejděte na nastavení fotoaparátu"
- "Abyste mohli pokračovat, musíte aplikaci Element udělit povolení k použití kamery vašeho zařízení."
+ "Abyste mohli pokračovat, musíte aplikaci %1$s udělit povolení k použití kamery vašeho zařízení."
"Povolte přístup k fotoaparátu a naskenujte QR kód"
"Naskenujte QR kód"
"Začít znovu"
diff --git a/features/ftue/impl/src/main/res/values-de/translations.xml b/features/ftue/impl/src/main/res/values-de/translations.xml
index e7ea08fbfb..4640cf119c 100644
--- a/features/ftue/impl/src/main/res/values-de/translations.xml
+++ b/features/ftue/impl/src/main/res/values-de/translations.xml
@@ -2,29 +2,33 @@
"Du kannst deine Einstellungen später ändern."
"Erlaube Benachrichtigungen und verpasse keine Nachricht"
- "Verbindung aufbauen"
+ "Sichere Verbindung aufbauen"
"Es konnte keine sichere Verbindung zu dem neuen Gerät hergestellt werden."
"Und jetzt?"
"Versuche, dich erneut mit einem QR-Code anzumelden, falls dies ein Netzwerkproblem war."
"Wenn das Problem bestehen bleibt, versuche es mit einem anderen WLAN-Netzwerk oder verwende deine mobilen Daten statt WLAN."
"Wenn das nicht funktioniert, melde dich manuell an"
"Die Verbindung ist nicht sicher"
+ "Du wirst aufgefordert, die beiden unten abgebildeten Ziffern einzugeben."
+ "Trage die unten angezeigte Zahl auf einem anderen Device ein"
"%1$s auf einem Desktop-Gerät öffnen"
"Klick auf deinen Avatar"
"Wähle %1$s"
"\"Neues Gerät verknüpfen\""
- "Wähle %1$s"
- "\"QR-Code anzeigen\""
+ "Befolge die angezeigten Anweisungen"
"Öffne %1$s auf einem anderen Gerät, um den QR-Code zu erhalten"
"Verwende den QR-Code, der auf dem anderen Gerät angezeigt wird."
"Erneut versuchen"
"Falscher QR-Code"
"Gehe zu den Kameraeinstellungen"
- "Du musst Element die Erlaubnis erteilen, die Kamera deines Geräts zu verwenden, um fortzufahren."
+ "Du musst %1$s die Erlaubnis erteilen, die Kamera deines Geräts zu verwenden, um fortzufahren."
"Erlaube Zugriff auf die Kamera zum Scannen des QR-Codes"
"QR-Code scannen"
"Neu beginnen"
"Ein unerwarteter Fehler ist aufgetreten. Bitte versuche es erneut."
+ "Warten auf dein anderes Gerät"
+ "Dein Account-Provider kann nach dem folgenden Code fragen, um die Anmeldung zu bestätigen."
+ "Dein Verifizierungscode"
"Anrufe, Umfragen, Suchfunktionen und mehr werden im Laufe des Jahres hinzugefügt."
"Der Nachrichtenverlauf für verschlüsselte Räume wird in diesem Update nicht verfügbar sein."
"Wir würden uns freuen, von dir zu hören. Teile uns deine Meinung über die Einstellungsseite mit."
diff --git a/features/ftue/impl/src/main/res/values-fr/translations.xml b/features/ftue/impl/src/main/res/values-fr/translations.xml
index 8caf493584..c69b729fc7 100644
--- a/features/ftue/impl/src/main/res/values-fr/translations.xml
+++ b/features/ftue/impl/src/main/res/values-fr/translations.xml
@@ -2,18 +2,33 @@
"Vous pourrez modifier vos paramètres ultérieurement."
"Autorisez les notifications et ne manquez aucun message"
- "Établissement de la connexion"
+ "Établissement d’une connexion sécurisée"
+ "Aucune connexion sécurisée n’a pu être établie avec la nouvelle session. Vos sessions existantes sont toujours en sécurité et vous n’avez pas à vous en soucier."
+ "Et maintenant ?"
+ "Essayez de vous connecter à nouveau à l’aide du code QR au cas où il s’agirait d’un problème réseau"
+ "Si vous rencontrez le même problème, essayez un autre réseau wifi ou utilisez vos données mobiles au lieu du wifi"
+ "Si cela ne fonctionne pas, connectez-vous manuellement"
+ "La connexion n’est pas sécurisée"
+ "Il vous sera demandé de saisir les deux chiffres affichés sur cet appareil."
+ "Saisissez le nombre ci-dessous sur votre autre appareil"
"Ouvrez %1$s sur un ordinateur"
"Cliquez sur votre image de profil"
"Choisissez %1$s"
"“Associer une nouvelle session”"
- "Choisissez %1$s"
- "“Afficher le QR code”"
+ "Suivez les instructions affichées"
"Ouvrez %1$s sur un autre appareil pour obtenir le QR code"
"Scannez le QR code affiché sur l’autre appareil."
"Essayer à nouveau"
"QR code erroné"
+ "Accéder aux paramètres de l’appareil photo"
+ "Vous devez autoriser %1$s à utiliser la camera de votre appareil pour continuer."
+ "Autoriser l’usage de la caméra pour scanner le code QR"
"Scannez le QR code"
+ "Recommencer"
+ "Une erreur inattendue s’est produite. Veuillez réessayer."
+ "En attente de votre autre session"
+ "Votre fournisseur de compte peut vous demander le code suivant pour vérifier la connexion."
+ "Votre code de vérification"
"Les appels, les sondages, les recherches et plus encore seront ajoutés plus tard cette année."
"L’historique des messages pour les salons chiffrés ne sera pas disponible dans cette mise à jour."
"N’hésitez pas à nous faire part de vos commentaires via l’écran des paramètres."
diff --git a/features/ftue/impl/src/main/res/values-hu/translations.xml b/features/ftue/impl/src/main/res/values-hu/translations.xml
index f3b101517a..e5c11f0c2b 100644
--- a/features/ftue/impl/src/main/res/values-hu/translations.xml
+++ b/features/ftue/impl/src/main/res/values-hu/translations.xml
@@ -2,29 +2,33 @@
"A beállításokat később is módosíthatja."
"Értesítések engedélyezése, hogy soha ne maradjon le egyetlen üzenetről sem"
- "Kapcsolat létesítése"
+ "Biztonságos kapcsolat létesítése"
"Nem sikerült biztonságos kapcsolatot létesíteni az új eszközzel. A meglévő eszközei továbbra is biztonságban vannak, és nem kell aggódnia miattuk."
"Most mi lesz?"
"Próbáljon meg újra bejelentkezni egy QR-kóddal, ha ez hálózati probléma volt."
"Ha ugyanezzel a problémával találkozik, próbálkozzon másik Wi-Fi-hálózattal, vagy a Wi-Fi helyett használja a mobil-adatkapcsolatát"
"Ha ez nem működik, jelentkezzen be kézileg"
"A kapcsolat nem biztonságos"
+ "A rendszer kérni fogja, hogy adja meg az alábbi két számjegyet az eszközén."
+ "Adja meg az alábbi számot a másik eszközén"
"Nyissa meg az %1$set egy asztali eszközön"
"Kattintson a profilképére"
"Válassza ezt: %1$s"
"„Új eszköz összekapcsolása”"
- "Válassza ezt: %1$s"
- "„QR-kód megjelenítése”"
+ "Kövesse a látható utasításokat"
"Nyissa meg az %1$set egy másik eszközön a QR-kód lekéréséhez."
"Használja a másik eszközön látható QR-kódot."
"Próbálja újra"
"Hibás QR-kód"
"Ugrás a kamerabeállításokhoz"
- "A folytatáshoz engedélyeznie kell, hogy az Element használhassa az eszköz kameráját."
+ "A folytatáshoz engedélyeznie kell, hogy az %1$s használhassa az eszköz kameráját."
"Engedélyezze a kamera elérését a QR-kód beolvasásához"
"Olvassa be a QR-kódot"
"Újrakezdés"
"Váratlan hiba történt. Próbálja meg újra."
+ "Várakozás a másik eszközre"
+ "A fiókszolgáltatója kérheti a következő kódot a bejelentkezése ellenőrzéséhez."
+ "Az Ön ellenőrzőkódja"
"A hívások, szavazások, keresések és egyebek az év további részében kerülnek hozzáadásra."
"A titkosított szobák üzenetelőzményei nem lesznek elérhetők ebben a frissítésben."
"Szeretnénk hallani a véleményét, ossza meg velünk a beállítások oldalon."
diff --git a/features/ftue/impl/src/main/res/values-in/translations.xml b/features/ftue/impl/src/main/res/values-in/translations.xml
index bc2c07e6ea..c99839166b 100644
--- a/features/ftue/impl/src/main/res/values-in/translations.xml
+++ b/features/ftue/impl/src/main/res/values-in/translations.xml
@@ -3,17 +3,31 @@
"Anda dapat mengubah pengaturan Anda nanti."
"Izinkan pemberitahuan dan jangan pernah melewatkan pesan"
"Membuat koneksi"
+ "Koneksi aman tidak dapat dibuat ke perangkat baru. Perangkat Anda yang ada masih aman dan Anda tidak perlu khawatir tentang mereka."
+ "Apa sekarang?"
+ "Coba masuk lagi dengan kode QR jika ini adalah masalah jaringan"
+ "Jika Anda mengalami masalah yang sama, coba jaringan Wi-Fi yang berbeda atau gunakan data seluler Anda daripada Wi-Fi"
+ "Jika tidak berhasil, masuk secara manual"
+ "Koneksi tidak aman"
+ "Anda akan diminta untuk memasukkan dua digit yang ditunjukkan di bawah ini."
+ "Masukkan nomor di perangkat Anda"
"Buka %1$s di perangkat desktop"
"Klik pada avatar Anda"
"Pilih %1$s"
"“Tautkan perangkat baru”"
- "Pilih %1$s"
- "“Tampilkan kode QR”"
"Buka %1$s di perangkat lain untuk mendapatkan kode QR"
"Gunakan kode QR yang ditampilkan di perangkat lain."
"Coba lagi"
"Kode QR salah"
+ "Pergi ke pengaturan kamera"
+ "Anda perlu memberikan izin ke %1$s untuk menggunakan kamera perangkat Anda untuk melanjutkan."
+ "Izinkan akses kamera untuk memindai kode QR"
"Pindai kode QR"
+ "Mulai dari awal"
+ "Terjadi kesalahan tak terduga. Silakan coba lagi."
+ "Menunggu perangkat Anda yang lain"
+ "Penyedia akun Anda mungkin meminta kode berikut untuk memverifikasi proses masuk."
+ "Kode verifikasi Anda"
"Panggilan, pemungutan suara, pencarian, dan lainnya akan ditambahkan di tahun ini."
"Riwayat pesan untuk ruangan terenkripsi tidak akan tersedia dalam pembaruan ini."
"Kami ingin mendengar dari Anda, beri tahu kami pendapat Anda melalui halaman pengaturan."
diff --git a/features/ftue/impl/src/main/res/values-ru/translations.xml b/features/ftue/impl/src/main/res/values-ru/translations.xml
index e9eeef5329..3fe5ceefc2 100644
--- a/features/ftue/impl/src/main/res/values-ru/translations.xml
+++ b/features/ftue/impl/src/main/res/values-ru/translations.xml
@@ -9,22 +9,25 @@
"Если вы столкнулись с той же проблемой, попробуйте сменить точку доступа Wi-Fi или используйте мобильные данные"
"Если это не помогло, войдите вручную"
"Соединение не защищено"
+ "Вам будет предложено ввести две цифры, показанные ниже."
+ "Введите номер на своем устройстве"
"Откройте %1$s на настольном устройстве"
"Нажмите на свое изображение"
"Выбрать %1$s"
"\"Привязать новое устройство\""
- "Выбрать %1$s"
- "\"Показать QR-код\""
"Откройте %1$s на другом устройстве, чтобы получить QR-код"
"Используйте QR-код, показанный на другом устройстве."
"Повторить попытку"
"Неверный QR-код"
"Перейдите в настройки камеры"
- "Чтобы продолжить, вам необходимо разрешить Element использовать камеру вашего устройства."
+ "Чтобы продолжить, вам необходимо разрешить %1$s использовать камеру вашего устройства."
"Разрешите доступ к камере для сканирования QR-кода"
"Сканировать QR-код"
"Начать заново"
"Произошла непредвиденная ошибка. Пожалуйста, попробуйте еще раз."
+ "В ожидании другого устройства"
+ "Поставщик учетной записи может запросить следующий код для подтверждения входа."
+ "Ваш код подтверждения"
"Звонки, опросы, поиск и многое другое будут добавлены позже в этом году."
"История сообщений для зашифрованных комнат в этом обновлении будет недоступна."
"Мы будем рады услышать ваше мнение, сообщите нам об этом через страницу настроек."
diff --git a/features/ftue/impl/src/main/res/values-sk/translations.xml b/features/ftue/impl/src/main/res/values-sk/translations.xml
index 846d767df0..593fe78182 100644
--- a/features/ftue/impl/src/main/res/values-sk/translations.xml
+++ b/features/ftue/impl/src/main/res/values-sk/translations.xml
@@ -2,27 +2,26 @@
"Svoje nastavenia môžete neskôr zmeniť."
"Povoľte oznámenia a nikdy nezmeškajte žiadnu správu"
- "Nadväzovanie spojenia"
+ "Nadväzovanie bezpečného spojenia"
"K novému zariadeniu sa nepodarilo vytvoriť bezpečné pripojenie. Vaše existujúce zariadenia sú stále v bezpečí a nemusíte sa o ne obávať."
"Čo teraz?"
"Skúste sa znova prihlásiť pomocou QR kódu v prípade, že ide o problém so sieťou"
"Ak narazíte na rovnaký problém, vyskúšajte inú sieť Wi-Fi alebo namiesto siete Wi-Fi použite mobilné dáta"
"Ak to nefunguje, prihláste sa manuálne"
"Pripojenie nie je bezpečené"
- "Budete vyzvaní na zadanie dvoch číslic uvedených nižšie."
- "Zadajte číslo na svojom zariadení"
+ "Budete požiadaní o zadanie dvoch číslic zobrazených na tomto zariadení."
+ "Zadajte nižšie uvedené číslo na vašom druhom zariadení"
"Otvorte %1$s na stolnom zariadení"
"Kliknite na svoj obrázok"
"Vyberte %1$s"
"„Prepojiť nové zariadenie“"
- "Vyberte %1$s"
- "„Zobraziť QR kód“"
+ "Postupujte podľa zobrazených pokynov"
"Ak chcete získať QR kód, otvorte %1$s na inom zariadení"
"Použite QR kód zobrazený na druhom zariadení."
"Skúste to znova"
"Nesprávny QR kód"
"Prejsť na nastavenia fotoaparátu"
- "Ak chcete pokračovať, musíte udeliť povolenie aplikácii Element používať fotoaparát vášho zariadenia."
+ "Ak chcete pokračovať, musíte udeliť povolenie aplikácii %1$s používať fotoaparát vášho zariadenia."
"Povoľte prístup k fotoaparátu na naskenovanie QR kódu"
"Naskenovať QR kód"
"Začať odznova"
diff --git a/features/ftue/impl/src/main/res/values-sv/translations.xml b/features/ftue/impl/src/main/res/values-sv/translations.xml
index 31823cfaab..ca0f1776e8 100644
--- a/features/ftue/impl/src/main/res/values-sv/translations.xml
+++ b/features/ftue/impl/src/main/res/values-sv/translations.xml
@@ -2,6 +2,7 @@
"Du kan ändra dina inställningar senare."
"Tillåt aviseringar och missa aldrig ett meddelande"
+ "Försök igen"
"Samtal, omröstningar, sökning och mer kommer att läggas till senare i år."
"Meddelandehistorik för krypterade rum är inte tillgänglig än."
"Vi vill gärna höra från dig, låt oss veta vad du tycker via inställningssidan."
diff --git a/features/ftue/impl/src/main/res/values/localazy.xml b/features/ftue/impl/src/main/res/values/localazy.xml
index 5257271845..9264bfd4b1 100644
--- a/features/ftue/impl/src/main/res/values/localazy.xml
+++ b/features/ftue/impl/src/main/res/values/localazy.xml
@@ -2,27 +2,26 @@
"You can change your settings later."
"Allow notifications and never miss a message"
- "Establishing connection"
+ "Establishing a secure connection"
"A secure connection could not be made to the new device. Your existing devices are still safe and you don\'t need to worry about them."
"What now?"
"Try signing in again with a QR code in case this was a network problem"
"If you encounter the same problem, try a different wifi network or use your mobile data instead of wifi"
"If that doesn’t work, sign in manually"
"Connection not secure"
- "You’ll be asked to enter the two digits shown below."
- "Enter number on your device"
+ "You’ll be asked to enter the two digits shown on this device."
+ "Enter the number below on your other device"
"Open %1$s on a desktop device"
"Click on your avatar"
"Select %1$s"
"“Link new device”"
- "Select %1$s"
- "“Show QR code”"
+ "Follow the instructions shown"
"Open %1$s on another device to get the QR code"
"Use the QR code shown on the other device."
"Try again"
"Wrong QR code"
"Go to camera settings"
- "You need to give permission for Element to use your device’s camera in order to continue."
+ "You need to give permission for %1$s to use your device’s camera in order to continue."
"Allow camera access to scan the QR code"
"Scan the QR code"
"Start over"
diff --git a/features/ftue/impl/src/test/kotlin/io/element/android/features/ftue/impl/DefaultFtueServiceTests.kt b/features/ftue/impl/src/test/kotlin/io/element/android/features/ftue/impl/DefaultFtueServiceTests.kt
index d381d945a9..e346e3a1dc 100644
--- a/features/ftue/impl/src/test/kotlin/io/element/android/features/ftue/impl/DefaultFtueServiceTests.kt
+++ b/features/ftue/impl/src/test/kotlin/io/element/android/features/ftue/impl/DefaultFtueServiceTests.kt
@@ -27,6 +27,7 @@ import io.element.android.features.lockscreen.test.FakeLockScreenService
import io.element.android.libraries.matrix.api.verification.SessionVerifiedStatus
import io.element.android.libraries.matrix.test.verification.FakeSessionVerificationService
import io.element.android.libraries.permissions.impl.FakePermissionStateProvider
+import io.element.android.libraries.preferences.test.InMemorySessionPreferencesStore
import io.element.android.services.analytics.api.AnalyticsService
import io.element.android.services.analytics.test.FakeAnalyticsService
import io.element.android.services.toolbox.test.sdk.FakeBuildVersionSdkIntProvider
@@ -90,7 +91,6 @@ class DefaultFtueServiceTests {
fun `traverse flow`() = runTest {
val sessionVerificationService = FakeSessionVerificationService().apply {
givenVerifiedStatus(SessionVerifiedStatus.NotVerified)
- givenNeedsVerification(true)
}
val analyticsService = FakeAnalyticsService()
val permissionStateProvider = FakePermissionStateProvider(permissionGranted = false)
@@ -108,7 +108,7 @@ class DefaultFtueServiceTests {
// Session verification
steps.add(state.getNextStep(steps.lastOrNull()))
- sessionVerificationService.givenNeedsVerification(false)
+ sessionVerificationService.givenVerifiedStatus(SessionVerifiedStatus.NotVerified)
// Notifications opt in
steps.add(state.getNextStep(steps.lastOrNull()))
@@ -200,6 +200,7 @@ class DefaultFtueServiceTests {
analyticsService: AnalyticsService = FakeAnalyticsService(),
permissionStateProvider: FakePermissionStateProvider = FakePermissionStateProvider(permissionGranted = false),
lockScreenService: LockScreenService = FakeLockScreenService(),
+ sessionPreferencesStore: InMemorySessionPreferencesStore = InMemorySessionPreferencesStore(),
// First version where notification permission is required
sdkIntVersion: Int = Build.VERSION_CODES.TIRAMISU,
) = DefaultFtueService(
@@ -209,5 +210,6 @@ class DefaultFtueServiceTests {
analyticsService = analyticsService,
permissionStateProvider = permissionStateProvider,
lockScreenService = lockScreenService,
+ sessionPreferencesStore = sessionPreferencesStore,
)
}
diff --git a/features/invite/api/src/main/kotlin/io/element/android/features/invite/api/InviteListEntryPoint.kt b/features/invite/api/src/main/kotlin/io/element/android/features/invite/api/InviteListEntryPoint.kt
deleted file mode 100644
index c5063cdbc8..0000000000
--- a/features/invite/api/src/main/kotlin/io/element/android/features/invite/api/InviteListEntryPoint.kt
+++ /dev/null
@@ -1,38 +0,0 @@
-/*
- * Copyright (c) 2023 New Vector Ltd
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-package io.element.android.features.invite.api
-
-import com.bumble.appyx.core.modality.BuildContext
-import com.bumble.appyx.core.node.Node
-import com.bumble.appyx.core.plugin.Plugin
-import io.element.android.libraries.architecture.FeatureEntryPoint
-import io.element.android.libraries.matrix.api.core.RoomId
-
-interface InviteListEntryPoint : FeatureEntryPoint {
- fun nodeBuilder(parentNode: Node, buildContext: BuildContext): NodeBuilder
-
- interface NodeBuilder {
- fun callback(callback: Callback): NodeBuilder
- fun build(): Node
- }
-
- interface Callback : Plugin {
- fun onBackClicked()
- fun onInviteClicked(roomId: RoomId)
- fun onInviteAccepted(roomId: RoomId)
- }
-}
diff --git a/features/invite/api/src/main/kotlin/io/element/android/features/invite/api/SeenInvitesStore.kt b/features/invite/api/src/main/kotlin/io/element/android/features/invite/api/SeenInvitesStore.kt
deleted file mode 100644
index e34b17cee8..0000000000
--- a/features/invite/api/src/main/kotlin/io/element/android/features/invite/api/SeenInvitesStore.kt
+++ /dev/null
@@ -1,25 +0,0 @@
-/*
- * Copyright (c) 2023 New Vector Ltd
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-package io.element.android.features.invite.api
-
-import io.element.android.libraries.matrix.api.core.RoomId
-import kotlinx.coroutines.flow.Flow
-
-interface SeenInvitesStore {
- fun seenRoomIds(): Flow>
- suspend fun markAsSeen(roomIds: Set)
-}
diff --git a/features/invite/impl/build.gradle.kts b/features/invite/impl/build.gradle.kts
index 7a0b0db372..87e21ac562 100644
--- a/features/invite/impl/build.gradle.kts
+++ b/features/invite/impl/build.gradle.kts
@@ -50,7 +50,6 @@ dependencies {
testImplementation(libs.test.turbine)
testImplementation(projects.libraries.matrix.test)
testImplementation(projects.libraries.push.test)
- testImplementation(projects.features.invite.test)
testImplementation(projects.services.analytics.test)
testImplementation(projects.tests.testutils)
diff --git a/features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/DefaultInviteListEntryPoint.kt b/features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/DefaultInviteListEntryPoint.kt
deleted file mode 100644
index 5e464a79bc..0000000000
--- a/features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/DefaultInviteListEntryPoint.kt
+++ /dev/null
@@ -1,45 +0,0 @@
-/*
- * Copyright (c) 2023 New Vector Ltd
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-package io.element.android.features.invite.impl
-
-import com.bumble.appyx.core.modality.BuildContext
-import com.bumble.appyx.core.node.Node
-import com.bumble.appyx.core.plugin.Plugin
-import com.squareup.anvil.annotations.ContributesBinding
-import io.element.android.features.invite.api.InviteListEntryPoint
-import io.element.android.features.invite.impl.invitelist.InviteListNode
-import io.element.android.libraries.architecture.createNode
-import io.element.android.libraries.di.AppScope
-import javax.inject.Inject
-
-@ContributesBinding(AppScope::class)
-class DefaultInviteListEntryPoint @Inject constructor() : InviteListEntryPoint {
- override fun nodeBuilder(parentNode: Node, buildContext: BuildContext): InviteListEntryPoint.NodeBuilder {
- val plugins = ArrayList()
-
- return object : InviteListEntryPoint.NodeBuilder {
- override fun callback(callback: InviteListEntryPoint.Callback): InviteListEntryPoint.NodeBuilder {
- plugins += callback
- return this
- }
-
- override fun build(): Node {
- return parentNode.createNode(buildContext, plugins)
- }
- }
- }
-}
diff --git a/features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/DefaultSeenInvitesStore.kt b/features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/DefaultSeenInvitesStore.kt
deleted file mode 100644
index bf0423914e..0000000000
--- a/features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/DefaultSeenInvitesStore.kt
+++ /dev/null
@@ -1,56 +0,0 @@
-/*
- * Copyright (c) 2023 New Vector Ltd
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-package io.element.android.features.invite.impl
-
-import android.content.Context
-import androidx.datastore.core.DataStore
-import androidx.datastore.preferences.core.Preferences
-import androidx.datastore.preferences.core.edit
-import androidx.datastore.preferences.core.stringSetPreferencesKey
-import androidx.datastore.preferences.preferencesDataStore
-import com.squareup.anvil.annotations.ContributesBinding
-import io.element.android.features.invite.api.SeenInvitesStore
-import io.element.android.libraries.di.ApplicationContext
-import io.element.android.libraries.di.SessionScope
-import io.element.android.libraries.matrix.api.core.RoomId
-import kotlinx.coroutines.flow.Flow
-import kotlinx.coroutines.flow.map
-import javax.inject.Inject
-
-private val Context.dataStore: DataStore by preferencesDataStore(name = "elementx_seeninvites")
-private val seenInvitesKey = stringSetPreferencesKey("seenInvites")
-
-@ContributesBinding(SessionScope::class)
-class DefaultSeenInvitesStore @Inject constructor(
- @ApplicationContext context: Context
-) : SeenInvitesStore {
- private val store = context.dataStore
-
- override fun seenRoomIds(): Flow> =
- store.data.map { prefs ->
- prefs[seenInvitesKey]
- .orEmpty()
- .map { RoomId(it) }
- .toSet()
- }
-
- override suspend fun markAsSeen(roomIds: Set) {
- store.edit { prefs ->
- prefs[seenInvitesKey] = roomIds.map { it.value }.toSet()
- }
- }
-}
diff --git a/features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/components/InviteSummaryRow.kt b/features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/components/InviteSummaryRow.kt
deleted file mode 100644
index 0987f6cf57..0000000000
--- a/features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/components/InviteSummaryRow.kt
+++ /dev/null
@@ -1,195 +0,0 @@
-/*
- * Copyright (c) 2023 New Vector Ltd
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-package io.element.android.features.invite.impl.components
-
-import androidx.compose.foundation.layout.Arrangement
-import androidx.compose.foundation.layout.Box
-import androidx.compose.foundation.layout.Column
-import androidx.compose.foundation.layout.IntrinsicSize
-import androidx.compose.foundation.layout.Row
-import androidx.compose.foundation.layout.Spacer
-import androidx.compose.foundation.layout.fillMaxWidth
-import androidx.compose.foundation.layout.height
-import androidx.compose.foundation.layout.heightIn
-import androidx.compose.foundation.layout.padding
-import androidx.compose.foundation.layout.width
-import androidx.compose.material3.MaterialTheme
-import androidx.compose.runtime.Composable
-import androidx.compose.ui.Alignment
-import androidx.compose.ui.Modifier
-import androidx.compose.ui.platform.LocalContext
-import androidx.compose.ui.res.stringResource
-import androidx.compose.ui.text.AnnotatedString
-import androidx.compose.ui.text.SpanStyle
-import androidx.compose.ui.text.font.FontWeight
-import androidx.compose.ui.text.style.TextOverflow
-import androidx.compose.ui.tooling.preview.PreviewParameter
-import androidx.compose.ui.unit.dp
-import io.element.android.compound.theme.ElementTheme
-import io.element.android.features.invite.impl.R
-import io.element.android.features.invite.impl.model.InviteListInviteSummary
-import io.element.android.features.invite.impl.model.InviteListInviteSummaryProvider
-import io.element.android.features.invite.impl.model.InviteSender
-import io.element.android.libraries.designsystem.atomic.atoms.UnreadIndicatorAtom
-import io.element.android.libraries.designsystem.components.avatar.Avatar
-import io.element.android.libraries.designsystem.preview.ElementPreview
-import io.element.android.libraries.designsystem.preview.PreviewsDayNight
-import io.element.android.libraries.designsystem.theme.components.Button
-import io.element.android.libraries.designsystem.theme.components.ButtonSize
-import io.element.android.libraries.designsystem.theme.components.OutlinedButton
-import io.element.android.libraries.designsystem.theme.components.Text
-import io.element.android.libraries.ui.strings.CommonStrings
-
-private val minHeight = 72.dp
-
-@Composable
-internal fun InviteSummaryRow(
- invite: InviteListInviteSummary,
- onAcceptClicked: () -> Unit,
- onDeclineClicked: () -> Unit,
- modifier: Modifier = Modifier,
-) {
- Box(
- modifier = modifier
- .fillMaxWidth()
- .heightIn(min = minHeight)
- ) {
- DefaultInviteSummaryRow(
- invite = invite,
- onAcceptClicked = onAcceptClicked,
- onDeclineClicked = onDeclineClicked,
- )
- }
-}
-
-@Composable
-private fun DefaultInviteSummaryRow(
- invite: InviteListInviteSummary,
- onAcceptClicked: () -> Unit,
- onDeclineClicked: () -> Unit,
-) {
- Row(
- modifier = Modifier
- .fillMaxWidth()
- .padding(16.dp)
- .height(IntrinsicSize.Min),
- verticalAlignment = Alignment.Top
- ) {
- Avatar(
- invite.roomAvatarData,
- )
-
- Column(
- modifier = Modifier
- .padding(start = 16.dp, end = 4.dp)
- .alignByBaseline()
- .weight(1f)
- ) {
- val bonusPadding = if (invite.isNew) 12.dp else 0.dp
-
- // Name
- Text(
- text = invite.roomName,
- color = MaterialTheme.colorScheme.primary,
- maxLines = 1,
- overflow = TextOverflow.Ellipsis,
- style = ElementTheme.typography.fontBodyLgMedium,
- modifier = Modifier.padding(end = bonusPadding),
- )
-
- // ID or Alias
- invite.roomAlias?.let {
- Text(
- style = ElementTheme.typography.fontBodyMdRegular,
- text = it,
- color = MaterialTheme.colorScheme.secondary,
- maxLines = 1,
- overflow = TextOverflow.Ellipsis,
- modifier = Modifier.padding(end = bonusPadding),
- )
- }
-
- // Sender
- invite.sender?.let { sender ->
- SenderRow(sender = sender)
- }
-
- // CTAs
- Row(Modifier.padding(top = 12.dp)) {
- OutlinedButton(
- text = stringResource(CommonStrings.action_decline),
- onClick = onDeclineClicked,
- modifier = Modifier.weight(1f),
- size = ButtonSize.Medium,
- )
-
- Spacer(modifier = Modifier.width(12.dp))
-
- Button(
- text = stringResource(CommonStrings.action_accept),
- onClick = onAcceptClicked,
- modifier = Modifier.weight(1f),
- size = ButtonSize.Medium,
- )
- }
- }
-
- UnreadIndicatorAtom(isVisible = invite.isNew)
- }
-}
-
-@Composable
-private fun SenderRow(sender: InviteSender) {
- Row(
- horizontalArrangement = Arrangement.spacedBy(4.dp),
- modifier = Modifier.padding(top = 6.dp),
- ) {
- Avatar(
- avatarData = sender.avatarData,
- )
- Text(
- text = stringResource(R.string.screen_invites_invited_you, sender.displayName, sender.userId.value).let { text ->
- val senderNameStart = LocalContext.current.getString(R.string.screen_invites_invited_you).indexOf("%1\$s")
- AnnotatedString(
- text = text,
- spanStyles = listOf(
- AnnotatedString.Range(
- SpanStyle(
- fontWeight = FontWeight.Medium,
- color = MaterialTheme.colorScheme.primary
- ),
- start = senderNameStart,
- end = senderNameStart + sender.displayName.length
- )
- )
- )
- },
- style = ElementTheme.typography.fontBodyMdRegular,
- color = MaterialTheme.colorScheme.secondary,
- )
- }
-}
-
-@PreviewsDayNight
-@Composable
-internal fun InviteSummaryRowPreview(@PreviewParameter(InviteListInviteSummaryProvider::class) data: InviteListInviteSummary) = ElementPreview {
- InviteSummaryRow(
- invite = data,
- onAcceptClicked = {},
- onDeclineClicked = {},
- )
-}
diff --git a/features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/invitelist/InviteListEvents.kt b/features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/invitelist/InviteListEvents.kt
deleted file mode 100644
index f4ba30844a..0000000000
--- a/features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/invitelist/InviteListEvents.kt
+++ /dev/null
@@ -1,24 +0,0 @@
-/*
- * Copyright (c) 2024 New Vector Ltd
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-package io.element.android.features.invite.impl.invitelist
-
-import io.element.android.features.invite.impl.model.InviteListInviteSummary
-
-sealed interface InviteListEvents {
- data class AcceptInvite(val invite: InviteListInviteSummary) : InviteListEvents
- data class DeclineInvite(val invite: InviteListInviteSummary) : InviteListEvents
-}
diff --git a/features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/invitelist/InviteListNode.kt b/features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/invitelist/InviteListNode.kt
deleted file mode 100644
index fe491b157e..0000000000
--- a/features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/invitelist/InviteListNode.kt
+++ /dev/null
@@ -1,61 +0,0 @@
-/*
- * Copyright (c) 2024 New Vector Ltd
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-package io.element.android.features.invite.impl.invitelist
-
-import androidx.compose.runtime.Composable
-import androidx.compose.ui.Modifier
-import com.bumble.appyx.core.modality.BuildContext
-import com.bumble.appyx.core.node.Node
-import com.bumble.appyx.core.plugin.Plugin
-import com.bumble.appyx.core.plugin.plugins
-import dagger.assisted.Assisted
-import dagger.assisted.AssistedInject
-import io.element.android.anvilannotations.ContributesNode
-import io.element.android.features.invite.api.InviteListEntryPoint
-import io.element.android.libraries.di.SessionScope
-import io.element.android.libraries.matrix.api.core.RoomId
-
-@ContributesNode(SessionScope::class)
-class InviteListNode @AssistedInject constructor(
- @Assisted buildContext: BuildContext,
- @Assisted plugins: List,
- private val presenter: InviteListPresenter,
-) : Node(buildContext, plugins = plugins) {
- private fun onBackClicked() {
- plugins().forEach { it.onBackClicked() }
- }
-
- private fun onInviteAccepted(roomId: RoomId) {
- plugins().forEach { it.onInviteAccepted(roomId) }
- }
-
- private fun onInviteClicked(roomId: RoomId) {
- plugins().forEach { it.onInviteClicked(roomId) }
- }
-
- @Composable
- override fun View(modifier: Modifier) {
- val state = presenter.present()
- InviteListView(
- state = state,
- onBackClicked = ::onBackClicked,
- onInviteAccepted = ::onInviteAccepted,
- onInviteDeclined = {},
- onInviteClicked = ::onInviteClicked,
- )
- }
-}
diff --git a/features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/invitelist/InviteListPresenter.kt b/features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/invitelist/InviteListPresenter.kt
deleted file mode 100644
index 9d9a33ef13..0000000000
--- a/features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/invitelist/InviteListPresenter.kt
+++ /dev/null
@@ -1,157 +0,0 @@
-/*
- * Copyright (c) 2024 New Vector Ltd
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-package io.element.android.features.invite.impl.invitelist
-
-import androidx.compose.runtime.Composable
-import androidx.compose.runtime.LaunchedEffect
-import androidx.compose.runtime.collectAsState
-import androidx.compose.runtime.getValue
-import androidx.compose.runtime.mutableStateOf
-import androidx.compose.runtime.remember
-import androidx.compose.runtime.setValue
-import io.element.android.features.invite.api.SeenInvitesStore
-import io.element.android.features.invite.api.response.AcceptDeclineInviteEvents
-import io.element.android.features.invite.api.response.AcceptDeclineInviteState
-import io.element.android.features.invite.api.response.InviteData
-import io.element.android.features.invite.impl.model.InviteListInviteSummary
-import io.element.android.features.invite.impl.model.InviteSender
-import io.element.android.libraries.architecture.Presenter
-import io.element.android.libraries.designsystem.components.avatar.AvatarData
-import io.element.android.libraries.designsystem.components.avatar.AvatarSize
-import io.element.android.libraries.matrix.api.MatrixClient
-import io.element.android.libraries.matrix.api.core.RoomId
-import io.element.android.libraries.matrix.api.roomlist.RoomSummary
-import kotlinx.collections.immutable.toPersistentList
-import kotlinx.coroutines.flow.first
-import javax.inject.Inject
-
-class InviteListPresenter @Inject constructor(
- private val client: MatrixClient,
- private val store: SeenInvitesStore,
- private val acceptDeclineInvitePresenter: Presenter,
-) : Presenter {
- @Composable
- override fun present(): InviteListState {
- val invites by client
- .roomListService
- .invites
- .summaries
- .collectAsState(initial = emptyList())
-
- var seenInvites by remember { mutableStateOf>(emptySet()) }
-
- LaunchedEffect(Unit) {
- seenInvites = store.seenRoomIds().first()
- }
-
- LaunchedEffect(invites) {
- store.markAsSeen(
- invites
- .filterIsInstance()
- .map { it.details.roomId }
- .toSet()
- )
- }
-
- val acceptDeclineInviteState = acceptDeclineInvitePresenter.present()
-
- fun handleEvent(event: InviteListEvents) {
- when (event) {
- is InviteListEvents.AcceptInvite -> {
- acceptDeclineInviteState.eventSink(
- AcceptDeclineInviteEvents.AcceptInvite(event.invite.toInviteData())
- )
- }
-
- is InviteListEvents.DeclineInvite -> {
- acceptDeclineInviteState.eventSink(
- AcceptDeclineInviteEvents.DeclineInvite(event.invite.toInviteData())
- )
- }
- }
- }
-
- val inviteList = remember(seenInvites, invites) {
- invites
- .filterIsInstance()
- .map {
- it.toInviteSummary(seenInvites.contains(it.details.roomId))
- }
- .toPersistentList()
- }
-
- return InviteListState(
- inviteList = inviteList,
- acceptDeclineInviteState = acceptDeclineInviteState,
- eventSink = ::handleEvent
- )
- }
-
- private fun RoomSummary.Filled.toInviteSummary(seen: Boolean) = details.run {
- val i = inviter
- val avatarData = if (isDirect && i != null) {
- AvatarData(
- id = i.userId.value,
- name = i.displayName,
- url = i.avatarUrl,
- size = AvatarSize.RoomInviteItem,
- )
- } else {
- AvatarData(
- id = roomId.value,
- name = name,
- url = avatarUrl,
- size = AvatarSize.RoomInviteItem,
- )
- }
-
- val alias = if (isDirect) {
- inviter?.userId?.value
- } else {
- canonicalAlias
- }
-
- InviteListInviteSummary(
- roomId = roomId,
- roomName = name,
- roomAlias = alias,
- roomAvatarData = avatarData,
- isDirect = isDirect,
- isNew = !seen,
- sender = inviter
- ?.takeIf { !isDirect }
- ?.run {
- InviteSender(
- userId = userId,
- displayName = displayName ?: "",
- avatarData = AvatarData(
- id = userId.value,
- name = displayName,
- url = avatarUrl,
- size = AvatarSize.InviteSender,
- ),
- )
- },
- )
- }
-
- private fun InviteListInviteSummary.toInviteData() = InviteData(
- roomId = roomId,
- roomName = roomName,
- isDirect = isDirect,
- )
-}
diff --git a/features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/invitelist/InviteListState.kt b/features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/invitelist/InviteListState.kt
deleted file mode 100644
index 8a3cd69923..0000000000
--- a/features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/invitelist/InviteListState.kt
+++ /dev/null
@@ -1,29 +0,0 @@
-/*
- * Copyright (c) 2024 New Vector Ltd
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-package io.element.android.features.invite.impl.invitelist
-
-import androidx.compose.runtime.Immutable
-import io.element.android.features.invite.api.response.AcceptDeclineInviteState
-import io.element.android.features.invite.impl.model.InviteListInviteSummary
-import kotlinx.collections.immutable.ImmutableList
-
-@Immutable
-data class InviteListState(
- val inviteList: ImmutableList,
- val acceptDeclineInviteState: AcceptDeclineInviteState,
- val eventSink: (InviteListEvents) -> Unit
-)
diff --git a/features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/invitelist/InviteListStateProvider.kt b/features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/invitelist/InviteListStateProvider.kt
deleted file mode 100644
index 9814b1b20d..0000000000
--- a/features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/invitelist/InviteListStateProvider.kt
+++ /dev/null
@@ -1,77 +0,0 @@
-/*
- * Copyright (c) 2024 New Vector Ltd
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-package io.element.android.features.invite.impl.invitelist
-
-import androidx.compose.ui.tooling.preview.PreviewParameterProvider
-import io.element.android.features.invite.api.response.AcceptDeclineInviteState
-import io.element.android.features.invite.api.response.AcceptDeclineInviteStateProvider
-import io.element.android.features.invite.api.response.anAcceptDeclineInviteState
-import io.element.android.features.invite.impl.model.InviteListInviteSummary
-import io.element.android.features.invite.impl.model.InviteSender
-import io.element.android.libraries.matrix.api.core.RoomId
-import io.element.android.libraries.matrix.api.core.UserId
-import kotlinx.collections.immutable.ImmutableList
-import kotlinx.collections.immutable.persistentListOf
-
-open class InviteListStateProvider : PreviewParameterProvider {
- private val acceptDeclineInviteStateProvider = AcceptDeclineInviteStateProvider()
-
- override val values: Sequence
- get() = sequenceOf(
- anInviteListState(),
- anInviteListState(inviteList = persistentListOf()),
- ) + acceptDeclineInviteStateProvider.values.map { acceptDeclineInviteState ->
- anInviteListState(acceptDeclineInviteState = acceptDeclineInviteState)
- }
-}
-
-internal fun anInviteListState(
- inviteList: ImmutableList = aInviteListInviteSummaryList(),
- acceptDeclineInviteState: AcceptDeclineInviteState = anAcceptDeclineInviteState(),
- eventSink: (InviteListEvents) -> Unit = {}
-) = InviteListState(
- inviteList = inviteList,
- acceptDeclineInviteState = acceptDeclineInviteState,
- eventSink = eventSink,
-)
-
-internal fun aInviteListInviteSummaryList(): ImmutableList {
- return persistentListOf(
- InviteListInviteSummary(
- roomId = RoomId("!id1:example.com"),
- roomName = "Room 1",
- roomAlias = "#room:example.org",
- sender = InviteSender(
- userId = UserId("@alice:example.org"),
- displayName = "Alice"
- ),
- ),
- InviteListInviteSummary(
- roomId = RoomId("!id2:example.com"),
- roomName = "Room 2",
- sender = InviteSender(
- userId = UserId("@bob:example.org"),
- displayName = "Bob"
- ),
- ),
- InviteListInviteSummary(
- roomId = RoomId("!id3:example.com"),
- roomName = "Alice",
- roomAlias = "@alice:example.com"
- ),
- )
-}
diff --git a/features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/invitelist/InviteListView.kt b/features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/invitelist/InviteListView.kt
deleted file mode 100644
index 16031d558c..0000000000
--- a/features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/invitelist/InviteListView.kt
+++ /dev/null
@@ -1,148 +0,0 @@
-/*
- * Copyright (c) 2024 New Vector Ltd
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-package io.element.android.features.invite.impl.invitelist
-
-import androidx.compose.foundation.clickable
-import androidx.compose.foundation.layout.Column
-import androidx.compose.foundation.layout.Spacer
-import androidx.compose.foundation.layout.consumeWindowInsets
-import androidx.compose.foundation.layout.fillMaxWidth
-import androidx.compose.foundation.layout.padding
-import androidx.compose.foundation.layout.size
-import androidx.compose.foundation.lazy.LazyColumn
-import androidx.compose.foundation.lazy.itemsIndexed
-import androidx.compose.material3.ExperimentalMaterial3Api
-import androidx.compose.material3.MaterialTheme
-import androidx.compose.runtime.Composable
-import androidx.compose.ui.Modifier
-import androidx.compose.ui.res.stringResource
-import androidx.compose.ui.text.style.TextAlign
-import androidx.compose.ui.tooling.preview.PreviewParameter
-import androidx.compose.ui.unit.dp
-import io.element.android.compound.theme.ElementTheme
-import io.element.android.features.invite.impl.R
-import io.element.android.features.invite.impl.components.InviteSummaryRow
-import io.element.android.features.invite.impl.response.AcceptDeclineInviteView
-import io.element.android.libraries.designsystem.components.button.BackButton
-import io.element.android.libraries.designsystem.preview.ElementPreview
-import io.element.android.libraries.designsystem.preview.PreviewsDayNight
-import io.element.android.libraries.designsystem.theme.aliasScreenTitle
-import io.element.android.libraries.designsystem.theme.components.HorizontalDivider
-import io.element.android.libraries.designsystem.theme.components.Scaffold
-import io.element.android.libraries.designsystem.theme.components.Text
-import io.element.android.libraries.designsystem.theme.components.TopAppBar
-import io.element.android.libraries.matrix.api.core.RoomId
-import io.element.android.libraries.ui.strings.CommonStrings
-
-@Composable
-fun InviteListView(
- state: InviteListState,
- onBackClicked: () -> Unit,
- onInviteAccepted: (RoomId) -> Unit,
- onInviteDeclined: (RoomId) -> Unit,
- onInviteClicked: (RoomId) -> Unit,
- modifier: Modifier = Modifier,
-) {
- InviteListContent(
- state = state,
- modifier = modifier,
- onInviteClicked = onInviteClicked,
- onBackClicked = onBackClicked,
- )
- AcceptDeclineInviteView(
- state = state.acceptDeclineInviteState,
- onInviteAccepted = onInviteAccepted,
- onInviteDeclined = onInviteDeclined,
- )
-}
-
-@OptIn(ExperimentalMaterial3Api::class)
-@Composable
-private fun InviteListContent(
- state: InviteListState,
- onBackClicked: () -> Unit,
- onInviteClicked: (RoomId) -> Unit,
- modifier: Modifier = Modifier,
-) {
- Scaffold(
- modifier = modifier,
- topBar = {
- TopAppBar(
- navigationIcon = {
- BackButton(onClick = onBackClicked)
- },
- title = {
- Text(
- text = stringResource(CommonStrings.action_invites_list),
- style = ElementTheme.typography.aliasScreenTitle,
- )
- }
- )
- },
- content = { padding ->
- Column(
- modifier = Modifier
- .padding(padding)
- .consumeWindowInsets(padding)
- ) {
- if (state.inviteList.isEmpty()) {
- Spacer(Modifier.size(80.dp))
-
- Text(
- text = stringResource(R.string.screen_invites_empty_list),
- textAlign = TextAlign.Center,
- color = MaterialTheme.colorScheme.tertiary,
- modifier = Modifier.fillMaxWidth()
- )
- } else {
- LazyColumn(
- modifier = Modifier.weight(1f)
- ) {
- itemsIndexed(
- items = state.inviteList,
- ) { index, invite ->
- InviteSummaryRow(
- modifier = Modifier.clickable(
- onClick = { onInviteClicked(invite.roomId) }
- ),
- invite = invite,
- onAcceptClicked = { state.eventSink(InviteListEvents.AcceptInvite(invite)) },
- onDeclineClicked = { state.eventSink(InviteListEvents.DeclineInvite(invite)) },
- )
-
- if (index != state.inviteList.lastIndex) {
- HorizontalDivider()
- }
- }
- }
- }
- }
- }
- )
-}
-
-@PreviewsDayNight
-@Composable
-internal fun InviteListViewPreview(@PreviewParameter(InviteListStateProvider::class) state: InviteListState) = ElementPreview {
- InviteListView(
- state = state,
- onBackClicked = {},
- onInviteAccepted = {},
- onInviteDeclined = {},
- onInviteClicked = {},
- )
-}
diff --git a/features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/model/InviteListInviteSummary.kt b/features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/model/InviteListInviteSummary.kt
deleted file mode 100644
index e17dcc997c..0000000000
--- a/features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/model/InviteListInviteSummary.kt
+++ /dev/null
@@ -1,40 +0,0 @@
-/*
- * Copyright (c) 2023 New Vector Ltd
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-package io.element.android.features.invite.impl.model
-
-import androidx.compose.runtime.Immutable
-import io.element.android.libraries.designsystem.components.avatar.AvatarData
-import io.element.android.libraries.designsystem.components.avatar.AvatarSize
-import io.element.android.libraries.matrix.api.core.RoomId
-import io.element.android.libraries.matrix.api.core.UserId
-
-@Immutable
-data class InviteListInviteSummary(
- val roomId: RoomId,
- val roomName: String = "",
- val roomAlias: String? = null,
- val roomAvatarData: AvatarData = AvatarData(roomId.value, roomName, size = AvatarSize.RoomInviteItem),
- val sender: InviteSender? = null,
- val isDirect: Boolean = false,
- val isNew: Boolean = false,
-)
-
-data class InviteSender(
- val userId: UserId,
- val displayName: String,
- val avatarData: AvatarData = AvatarData(userId.value, displayName, size = AvatarSize.InviteSender),
-)
diff --git a/features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/model/InviteListInviteSummaryProvider.kt b/features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/model/InviteListInviteSummaryProvider.kt
deleted file mode 100644
index 11f6742345..0000000000
--- a/features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/model/InviteListInviteSummaryProvider.kt
+++ /dev/null
@@ -1,41 +0,0 @@
-/*
- * Copyright (c) 2023 New Vector Ltd
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-package io.element.android.features.invite.impl.model
-
-import androidx.compose.ui.tooling.preview.PreviewParameterProvider
-import io.element.android.libraries.matrix.api.core.RoomId
-import io.element.android.libraries.matrix.api.core.UserId
-
-open class InviteListInviteSummaryProvider : PreviewParameterProvider {
- override val values: Sequence
- get() = sequenceOf(
- aInviteListInviteSummary(),
- aInviteListInviteSummary().copy(roomAlias = "#someroom-with-a-long-alias:example.com"),
- aInviteListInviteSummary().copy(roomAlias = "#someroom-with-a-long-alias:example.com", isNew = true),
- aInviteListInviteSummary().copy(roomName = "Alice", sender = null),
- aInviteListInviteSummary().copy(isNew = true)
- )
-}
-
-fun aInviteListInviteSummary() = InviteListInviteSummary(
- roomId = RoomId("!room1:example.com"),
- roomName = "Some room with a long name that will truncate",
- sender = InviteSender(
- userId = UserId("@alice-with-a-long-mxid:example.org"),
- displayName = "Alice with a long name"
- ),
-)
diff --git a/features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/response/AcceptDeclineInvitePresenter.kt b/features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/response/AcceptDeclineInvitePresenter.kt
index 5f7e80b7b1..ec18aea045 100644
--- a/features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/response/AcceptDeclineInvitePresenter.kt
+++ b/features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/response/AcceptDeclineInvitePresenter.kt
@@ -102,12 +102,14 @@ class AcceptDeclineInvitePresenter @Inject constructor(
private fun CoroutineScope.acceptInvite(roomId: RoomId, acceptedAction: MutableState>) = launch {
acceptedAction.runUpdatingState {
- client.joinRoom(roomId).onSuccess {
- notificationDrawerManager.clearMembershipNotificationForRoom(client.sessionId, roomId, doRender = true)
- client.getRoom(roomId)?.use { room ->
- analyticsService.capture(room.toAnalyticsJoinedRoom(JoinedRoom.Trigger.Invite))
+ client.joinRoom(roomId)
+ .onSuccess {
+ notificationDrawerManager.clearMembershipNotificationForRoom(client.sessionId, roomId, doRender = true)
+ client.getRoom(roomId)?.use { room ->
+ analyticsService.capture(room.toAnalyticsJoinedRoom(JoinedRoom.Trigger.Invite))
+ }
}
- }
+ .map { roomId }
}
}
diff --git a/features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/response/AcceptDeclineInviteView.kt b/features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/response/AcceptDeclineInviteView.kt
index e0ad70ddc5..3f229fbe85 100644
--- a/features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/response/AcceptDeclineInviteView.kt
+++ b/features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/response/AcceptDeclineInviteView.kt
@@ -20,7 +20,6 @@ import androidx.compose.foundation.layout.Box
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
-import androidx.compose.ui.tooling.preview.PreviewLightDark
import androidx.compose.ui.tooling.preview.PreviewParameter
import io.element.android.features.invite.api.response.AcceptDeclineInviteState
import io.element.android.features.invite.api.response.AcceptDeclineInviteStateProvider
@@ -29,6 +28,7 @@ import io.element.android.features.invite.impl.R
import io.element.android.libraries.designsystem.components.async.AsyncActionView
import io.element.android.libraries.designsystem.components.dialogs.ConfirmationDialog
import io.element.android.libraries.designsystem.preview.ElementPreview
+import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.ui.strings.CommonStrings
import kotlin.jvm.optionals.getOrNull
@@ -102,9 +102,9 @@ private fun DeclineConfirmationDialog(
)
}
-@PreviewLightDark
+@PreviewsDayNight
@Composable
-internal fun AcceptDeclineInviteViewLightPreview(@PreviewParameter(AcceptDeclineInviteStateProvider::class) state: AcceptDeclineInviteState) =
+internal fun AcceptDeclineInviteViewPreview(@PreviewParameter(AcceptDeclineInviteStateProvider::class) state: AcceptDeclineInviteState) =
ElementPreview {
AcceptDeclineInviteView(
state = state,
diff --git a/features/invite/impl/src/test/kotlin/io/element/android/features/invite/impl/invitelist/InviteListPresenterTests.kt b/features/invite/impl/src/test/kotlin/io/element/android/features/invite/impl/invitelist/InviteListPresenterTests.kt
deleted file mode 100644
index c984e9c5fb..0000000000
--- a/features/invite/impl/src/test/kotlin/io/element/android/features/invite/impl/invitelist/InviteListPresenterTests.kt
+++ /dev/null
@@ -1,266 +0,0 @@
-/*
- * Copyright (c) 2023 New Vector Ltd
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-package io.element.android.features.invite.impl.invitelist
-
-import app.cash.molecule.RecompositionMode
-import app.cash.molecule.moleculeFlow
-import app.cash.turbine.TurbineTestContext
-import app.cash.turbine.test
-import com.google.common.truth.Truth.assertThat
-import io.element.android.features.invite.api.SeenInvitesStore
-import io.element.android.features.invite.api.response.AcceptDeclineInviteState
-import io.element.android.features.invite.api.response.anAcceptDeclineInviteState
-import io.element.android.features.invite.test.FakeSeenInvitesStore
-import io.element.android.libraries.architecture.Presenter
-import io.element.android.libraries.designsystem.components.avatar.AvatarData
-import io.element.android.libraries.designsystem.components.avatar.AvatarSize
-import io.element.android.libraries.matrix.api.MatrixClient
-import io.element.android.libraries.matrix.api.core.RoomId
-import io.element.android.libraries.matrix.api.room.RoomMembershipState
-import io.element.android.libraries.matrix.api.roomlist.RoomSummary
-import io.element.android.libraries.matrix.test.AN_AVATAR_URL
-import io.element.android.libraries.matrix.test.A_ROOM_ID
-import io.element.android.libraries.matrix.test.A_ROOM_ID_2
-import io.element.android.libraries.matrix.test.A_ROOM_NAME
-import io.element.android.libraries.matrix.test.A_USER_ID
-import io.element.android.libraries.matrix.test.A_USER_NAME
-import io.element.android.libraries.matrix.test.FakeMatrixClient
-import io.element.android.libraries.matrix.test.room.aRoomMember
-import io.element.android.libraries.matrix.test.room.aRoomSummaryDetails
-import io.element.android.libraries.matrix.test.roomlist.FakeRoomListService
-import io.element.android.tests.testutils.WarmUpRule
-import kotlinx.coroutines.test.runTest
-import org.junit.Rule
-import org.junit.Test
-
-class InviteListPresenterTests {
- @get:Rule
- val warmUpRule = WarmUpRule()
-
- @Test
- fun `present - starts empty, adds invites when received`() = runTest {
- val roomListService = FakeRoomListService()
- val presenter = createInviteListPresenter(
- FakeMatrixClient(roomListService = roomListService)
- )
- moleculeFlow(RecompositionMode.Immediate) {
- presenter.present()
- }.test {
- val initialState = awaitItem()
- assertThat(initialState.inviteList).isEmpty()
-
- roomListService.postInviteRooms(listOf(aRoomSummary()))
-
- val withInviteState = awaitItem()
- assertThat(withInviteState.inviteList.size).isEqualTo(1)
- assertThat(withInviteState.inviteList[0].roomId).isEqualTo(A_ROOM_ID)
- assertThat(withInviteState.inviteList[0].roomName).isEqualTo(A_ROOM_NAME)
- }
- }
-
- @Test
- fun `present - uses user ID and avatar for direct invites`() = runTest {
- val roomListService = FakeRoomListService().withDirectChatInvitation()
- val presenter = createInviteListPresenter(
- FakeMatrixClient(roomListService = roomListService)
- )
- moleculeFlow(RecompositionMode.Immediate) {
- presenter.present()
- }.test {
- val withInviteState = awaitInitialItem()
- assertThat(withInviteState.inviteList.size).isEqualTo(1)
- assertThat(withInviteState.inviteList[0].roomId).isEqualTo(A_ROOM_ID)
- assertThat(withInviteState.inviteList[0].roomAlias).isEqualTo(A_USER_ID.value)
- assertThat(withInviteState.inviteList[0].roomName).isEqualTo(A_ROOM_NAME)
- assertThat(withInviteState.inviteList[0].roomAvatarData).isEqualTo(
- AvatarData(
- id = A_USER_ID.value,
- name = A_USER_NAME,
- url = AN_AVATAR_URL,
- size = AvatarSize.RoomInviteItem,
- )
- )
- assertThat(withInviteState.inviteList[0].sender).isNull()
- }
- }
-
- @Test
- fun `present - includes sender details for room invites`() = runTest {
- val roomListService = FakeRoomListService().withRoomInvitation()
- val presenter = createInviteListPresenter(
- FakeMatrixClient(roomListService = roomListService)
- )
- moleculeFlow(RecompositionMode.Immediate) {
- presenter.present()
- }.test {
- val withInviteState = awaitInitialItem()
- assertThat(withInviteState.inviteList.size).isEqualTo(1)
- assertThat(withInviteState.inviteList[0].sender?.displayName).isEqualTo(A_USER_NAME)
- assertThat(withInviteState.inviteList[0].sender?.userId).isEqualTo(A_USER_ID)
- assertThat(withInviteState.inviteList[0].sender?.avatarData).isEqualTo(
- AvatarData(
- id = A_USER_ID.value,
- name = A_USER_NAME,
- url = AN_AVATAR_URL,
- size = AvatarSize.InviteSender,
- )
- )
- }
- }
-
- @Test
- fun `present - stores seen invites when received`() = runTest {
- val roomListService = FakeRoomListService()
- val store = FakeSeenInvitesStore()
- val presenter = createInviteListPresenter(
- FakeMatrixClient(
- roomListService = roomListService,
- ),
- store,
- )
- moleculeFlow(RecompositionMode.Immediate) {
- presenter.present()
- }.test {
- awaitItem()
-
- // When one invite is received, that ID is saved
- roomListService.postInviteRooms(listOf(aRoomSummary()))
-
- awaitItem()
- assertThat(store.getProvidedRoomIds()).isEqualTo(setOf(A_ROOM_ID))
-
- // When a second is added, both are saved
- roomListService.postInviteRooms(listOf(aRoomSummary(), aRoomSummary(A_ROOM_ID_2)))
-
- awaitItem()
- assertThat(store.getProvidedRoomIds()).isEqualTo(setOf(A_ROOM_ID, A_ROOM_ID_2))
-
- // When they're both dismissed, an empty set is saved
- roomListService.postInviteRooms(listOf())
-
- awaitItem()
- assertThat(store.getProvidedRoomIds()).isEmpty()
- }
- }
-
- @Test
- fun `present - marks invite as new if they're unseen`() = runTest {
- val roomListService = FakeRoomListService()
- val store = FakeSeenInvitesStore()
- store.publishRoomIds(setOf(A_ROOM_ID))
- val presenter = createInviteListPresenter(
- FakeMatrixClient(
- roomListService = roomListService,
- ),
- store,
- )
- moleculeFlow(RecompositionMode.Immediate) {
- presenter.present()
- }.test {
- awaitItem()
-
- roomListService.postInviteRooms(listOf(aRoomSummary(), aRoomSummary(A_ROOM_ID_2)))
- skipItems(1)
-
- val withInviteState = awaitItem()
- assertThat(withInviteState.inviteList.size).isEqualTo(2)
- assertThat(withInviteState.inviteList[0].roomId).isEqualTo(A_ROOM_ID)
- assertThat(withInviteState.inviteList[0].isNew).isFalse()
- assertThat(withInviteState.inviteList[1].roomId).isEqualTo(A_ROOM_ID_2)
- assertThat(withInviteState.inviteList[1].isNew).isTrue()
- }
- }
-
- private suspend fun FakeRoomListService.withRoomInvitation(): FakeRoomListService {
- postInviteRooms(
- listOf(
- RoomSummary.Filled(
- aRoomSummaryDetails(
- roomId = A_ROOM_ID,
- name = A_ROOM_NAME,
- avatarUrl = null,
- isDirect = false,
- lastMessage = null,
- inviter = aRoomMember(
- userId = A_USER_ID,
- displayName = A_USER_NAME,
- avatarUrl = AN_AVATAR_URL,
- membership = RoomMembershipState.JOIN,
- isNameAmbiguous = false,
- powerLevel = 0,
- normalizedPowerLevel = 0,
- isIgnored = false,
- )
- )
- )
- )
- )
- return this
- }
-
- private suspend fun FakeRoomListService.withDirectChatInvitation(): FakeRoomListService {
- postInviteRooms(
- listOf(
- RoomSummary.Filled(
- aRoomSummaryDetails(
- roomId = A_ROOM_ID,
- name = A_ROOM_NAME,
- avatarUrl = null,
- isDirect = true,
- lastMessage = null,
- inviter = aRoomMember(
- userId = A_USER_ID,
- displayName = A_USER_NAME,
- avatarUrl = AN_AVATAR_URL,
- membership = RoomMembershipState.JOIN,
- isNameAmbiguous = false,
- powerLevel = 0,
- normalizedPowerLevel = 0,
- isIgnored = false,
- )
- )
- )
- )
- )
- return this
- }
-
- private fun aRoomSummary(id: RoomId = A_ROOM_ID) = RoomSummary.Filled(
- aRoomSummaryDetails(
- roomId = id,
- name = A_ROOM_NAME,
- avatarUrl = null,
- isDirect = false,
- lastMessage = null,
- )
- )
-
- private suspend fun TurbineTestContext.awaitInitialItem(): InviteListState {
- skipItems(1)
- return awaitItem()
- }
-
- private fun createInviteListPresenter(
- client: MatrixClient,
- seenInvitesStore: SeenInvitesStore = FakeSeenInvitesStore(),
- acceptDeclineInvitePresenter: Presenter = Presenter { anAcceptDeclineInviteState() },
- ) = InviteListPresenter(
- client,
- seenInvitesStore,
- acceptDeclineInvitePresenter = acceptDeclineInvitePresenter,
- )
-}
diff --git a/features/invite/impl/src/test/kotlin/io/element/android/features/invite/impl/response/AcceptDeclineInvitePresenterTest.kt b/features/invite/impl/src/test/kotlin/io/element/android/features/invite/impl/response/AcceptDeclineInvitePresenterTest.kt
index dfb330da59..90dcc104d4 100644
--- a/features/invite/impl/src/test/kotlin/io/element/android/features/invite/impl/response/AcceptDeclineInvitePresenterTest.kt
+++ b/features/invite/impl/src/test/kotlin/io/element/android/features/invite/impl/response/AcceptDeclineInvitePresenterTest.kt
@@ -164,7 +164,7 @@ class AcceptDeclineInvitePresenterTest {
@Test
fun `present - accepting invite error flow`() = runTest {
val joinRoomFailure = lambdaRecorder { roomId: RoomId ->
- Result.failure(RuntimeException("Failed to join room $roomId"))
+ Result.failure(RuntimeException("Failed to join room $roomId"))
}
val client = FakeMatrixClient().apply {
joinRoomLambda = joinRoomFailure
@@ -197,8 +197,8 @@ class AcceptDeclineInvitePresenterTest {
@Test
fun `present - accepting invite success flow`() = runTest {
- val joinRoomSuccess = lambdaRecorder { roomId: RoomId ->
- Result.success(roomId)
+ val joinRoomSuccess = lambdaRecorder { _: RoomId ->
+ Result.success(Unit)
}
val client = FakeMatrixClient().apply {
joinRoomLambda = joinRoomSuccess
diff --git a/features/invite/test/build.gradle.kts b/features/invite/test/build.gradle.kts
deleted file mode 100644
index 44d9d3030c..0000000000
--- a/features/invite/test/build.gradle.kts
+++ /dev/null
@@ -1,29 +0,0 @@
-/*
- * Copyright (c) 2022 New Vector Ltd
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-plugins {
- id("io.element.android-library")
-}
-
-android {
- namespace = "io.element.android.features.invite.test"
-}
-
-dependencies {
- implementation(libs.coroutines.core)
- implementation(projects.libraries.matrix.api)
- api(projects.features.invite.api)
-}
diff --git a/features/invite/test/src/main/kotlin/io/element/android/features/invite/test/FakeSeenInvitesStore.kt b/features/invite/test/src/main/kotlin/io/element/android/features/invite/test/FakeSeenInvitesStore.kt
deleted file mode 100644
index f2a21ac768..0000000000
--- a/features/invite/test/src/main/kotlin/io/element/android/features/invite/test/FakeSeenInvitesStore.kt
+++ /dev/null
@@ -1,39 +0,0 @@
-/*
- * Copyright (c) 2023 New Vector Ltd
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-package io.element.android.features.invite.test
-
-import io.element.android.features.invite.api.SeenInvitesStore
-import io.element.android.libraries.matrix.api.core.RoomId
-import kotlinx.coroutines.flow.Flow
-import kotlinx.coroutines.flow.MutableStateFlow
-
-class FakeSeenInvitesStore : SeenInvitesStore {
- private val existing = MutableStateFlow(emptySet())
- private var provided: Set? = null
-
- fun publishRoomIds(invites: Set) {
- existing.value = invites
- }
-
- fun getProvidedRoomIds() = provided
-
- override fun seenRoomIds(): Flow> = existing
-
- override suspend fun markAsSeen(roomIds: Set) {
- provided = roomIds.toSet()
- }
-}
diff --git a/features/joinroom/api/src/main/kotlin/io/element/android/features/joinroom/api/JoinRoomEntryPoint.kt b/features/joinroom/api/src/main/kotlin/io/element/android/features/joinroom/api/JoinRoomEntryPoint.kt
index 60f49b9d36..d62a9819c9 100644
--- a/features/joinroom/api/src/main/kotlin/io/element/android/features/joinroom/api/JoinRoomEntryPoint.kt
+++ b/features/joinroom/api/src/main/kotlin/io/element/android/features/joinroom/api/JoinRoomEntryPoint.kt
@@ -22,6 +22,7 @@ import io.element.android.features.roomdirectory.api.RoomDescription
import io.element.android.libraries.architecture.FeatureEntryPoint
import io.element.android.libraries.architecture.NodeInputs
import io.element.android.libraries.matrix.api.core.RoomId
+import io.element.android.libraries.matrix.api.core.RoomIdOrAlias
import java.util.Optional
interface JoinRoomEntryPoint : FeatureEntryPoint {
@@ -29,6 +30,7 @@ interface JoinRoomEntryPoint : FeatureEntryPoint {
data class Inputs(
val roomId: RoomId,
+ val roomIdOrAlias: RoomIdOrAlias,
val roomDescription: Optional,
) : NodeInputs
}
diff --git a/features/joinroom/impl/build.gradle.kts b/features/joinroom/impl/build.gradle.kts
index cfdddcfee8..33fdbb6b47 100644
--- a/features/joinroom/impl/build.gradle.kts
+++ b/features/joinroom/impl/build.gradle.kts
@@ -23,6 +23,11 @@ plugins {
android {
namespace = "io.element.android.features.joinroom.impl"
+ testOptions {
+ unitTests {
+ isIncludeAndroidResources = true
+ }
+ }
}
anvil {
@@ -46,11 +51,13 @@ dependencies {
testImplementation(libs.test.junit)
testImplementation(libs.coroutines.test)
testImplementation(libs.molecule.runtime)
+ testImplementation(libs.test.robolectric)
testImplementation(libs.test.truth)
testImplementation(libs.test.turbine)
testImplementation(projects.libraries.matrix.test)
- testImplementation(projects.features.invite.test)
testImplementation(projects.tests.testutils)
+ testImplementation(libs.androidx.compose.ui.test.junit)
+ testReleaseImplementation(libs.androidx.compose.ui.test.manifest)
ksp(libs.showkase.processor)
}
diff --git a/features/joinroom/impl/src/main/kotlin/io/element/android/features/joinroom/impl/JoinRoomEvents.kt b/features/joinroom/impl/src/main/kotlin/io/element/android/features/joinroom/impl/JoinRoomEvents.kt
index 999030cd50..312efd1ad2 100644
--- a/features/joinroom/impl/src/main/kotlin/io/element/android/features/joinroom/impl/JoinRoomEvents.kt
+++ b/features/joinroom/impl/src/main/kotlin/io/element/android/features/joinroom/impl/JoinRoomEvents.kt
@@ -17,7 +17,10 @@
package io.element.android.features.joinroom.impl
sealed interface JoinRoomEvents {
+ data object RetryFetchingContent : JoinRoomEvents
data object JoinRoom : JoinRoomEvents
+ data object KnockRoom : JoinRoomEvents
+ data object ClearError : JoinRoomEvents
data object AcceptInvite : JoinRoomEvents
data object DeclineInvite : JoinRoomEvents
}
diff --git a/features/joinroom/impl/src/main/kotlin/io/element/android/features/joinroom/impl/JoinRoomNode.kt b/features/joinroom/impl/src/main/kotlin/io/element/android/features/joinroom/impl/JoinRoomNode.kt
index eaa195d88d..5d302abc8b 100644
--- a/features/joinroom/impl/src/main/kotlin/io/element/android/features/joinroom/impl/JoinRoomNode.kt
+++ b/features/joinroom/impl/src/main/kotlin/io/element/android/features/joinroom/impl/JoinRoomNode.kt
@@ -37,7 +37,11 @@ class JoinRoomNode @AssistedInject constructor(
private val acceptDeclineInviteView: AcceptDeclineInviteView,
) : Node(buildContext, plugins = plugins) {
private val inputs: JoinRoomEntryPoint.Inputs = inputs()
- private val presenter = presenterFactory.create(inputs.roomId, inputs.roomDescription)
+ private val presenter = presenterFactory.create(
+ inputs.roomId,
+ inputs.roomIdOrAlias,
+ inputs.roomDescription,
+ )
@Composable
override fun View(modifier: Modifier) {
@@ -45,6 +49,7 @@ class JoinRoomNode @AssistedInject constructor(
JoinRoomView(
state = state,
onBackPressed = ::navigateUp,
+ onKnockSuccess = ::navigateUp,
modifier = modifier
)
acceptDeclineInviteView.Render(
diff --git a/features/joinroom/impl/src/main/kotlin/io/element/android/features/joinroom/impl/JoinRoomPresenter.kt b/features/joinroom/impl/src/main/kotlin/io/element/android/features/joinroom/impl/JoinRoomPresenter.kt
index 5b3225575f..ea88150c78 100644
--- a/features/joinroom/impl/src/main/kotlin/io/element/android/features/joinroom/impl/JoinRoomPresenter.kt
+++ b/features/joinroom/impl/src/main/kotlin/io/element/android/features/joinroom/impl/JoinRoomPresenter.kt
@@ -16,47 +16,91 @@
package io.element.android.features.joinroom.impl
+import androidx.annotation.VisibleForTesting
import androidx.compose.runtime.Composable
+import androidx.compose.runtime.MutableState
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableIntStateOf
+import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.produceState
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.runtime.setValue
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import io.element.android.features.invite.api.response.AcceptDeclineInviteEvents
import io.element.android.features.invite.api.response.AcceptDeclineInviteState
import io.element.android.features.invite.api.response.InviteData
+import io.element.android.features.joinroom.impl.di.KnockRoom
import io.element.android.features.roomdirectory.api.RoomDescription
+import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.architecture.Presenter
+import io.element.android.libraries.architecture.runUpdatingState
+import io.element.android.libraries.core.meta.BuildMeta
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.core.RoomId
+import io.element.android.libraries.matrix.api.core.RoomIdOrAlias
+import io.element.android.libraries.matrix.api.core.toRoomIdOrAlias
import io.element.android.libraries.matrix.api.room.CurrentUserMembership
import io.element.android.libraries.matrix.api.room.MatrixRoomInfo
-import org.jetbrains.annotations.VisibleForTesting
+import io.element.android.libraries.matrix.api.room.RoomType
+import io.element.android.libraries.matrix.api.room.preview.RoomPreview
+import io.element.android.libraries.matrix.ui.model.toInviteSender
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.launch
import java.util.Optional
class JoinRoomPresenter @AssistedInject constructor(
@Assisted private val roomId: RoomId,
+ @Assisted private val roomIdOrAlias: RoomIdOrAlias,
@Assisted private val roomDescription: Optional,
private val matrixClient: MatrixClient,
+ private val knockRoom: KnockRoom,
private val acceptDeclineInvitePresenter: Presenter,
+ private val buildMeta: BuildMeta,
) : Presenter {
interface Factory {
- fun create(roomId: RoomId, roomDescription: Optional): JoinRoomPresenter
+ fun create(
+ roomId: RoomId,
+ roomIdOrAlias: RoomIdOrAlias,
+ roomDescription: Optional,
+ ): JoinRoomPresenter
}
@Composable
override fun present(): JoinRoomState {
+ val coroutineScope = rememberCoroutineScope()
+ var retryCount by remember { mutableIntStateOf(0) }
val roomInfo by matrixClient.getRoomInfoFlow(roomId).collectAsState(initial = Optional.empty())
- val contentState by produceState(initialValue = ContentState.Loading(roomId), key1 = roomInfo) {
- value = when {
+ val knockAction: MutableState> = remember { mutableStateOf(AsyncAction.Uninitialized) }
+ val contentState by produceState(
+ initialValue = ContentState.Loading(roomIdOrAlias),
+ key1 = roomInfo,
+ key2 = retryCount,
+ ) {
+ when {
roomInfo.isPresent -> {
- roomInfo.get().toContentState()
+ value = roomInfo.get().toContentState()
}
roomDescription.isPresent -> {
- roomDescription.get().toContentState()
+ value = roomDescription.get().toContentState()
}
else -> {
- ContentState.Loading(roomId)
+ value = ContentState.Loading(roomIdOrAlias)
+ val result = matrixClient.getRoomPreview(roomId.toRoomIdOrAlias())
+ value = result.fold(
+ onSuccess = { roomPreview ->
+ roomPreview.toContentState()
+ },
+ onFailure = { throwable ->
+ if (throwable.message?.contains("403") == true) {
+ ContentState.UnknownRoom(roomIdOrAlias)
+ } else {
+ ContentState.Failure(roomIdOrAlias, throwable)
+ }
+ }
+ )
}
}
}
@@ -64,27 +108,65 @@ class JoinRoomPresenter @AssistedInject constructor(
fun handleEvents(event: JoinRoomEvents) {
when (event) {
- JoinRoomEvents.AcceptInvite, JoinRoomEvents.JoinRoom -> {
+ JoinRoomEvents.AcceptInvite,
+ JoinRoomEvents.JoinRoom -> {
val inviteData = contentState.toInviteData() ?: return
acceptDeclineInviteState.eventSink(
AcceptDeclineInviteEvents.AcceptInvite(inviteData)
)
}
+ JoinRoomEvents.KnockRoom -> {
+ coroutineScope.knockRoom(roomId, knockAction)
+ }
JoinRoomEvents.DeclineInvite -> {
val inviteData = contentState.toInviteData() ?: return
acceptDeclineInviteState.eventSink(
AcceptDeclineInviteEvents.DeclineInvite(inviteData)
)
}
+ JoinRoomEvents.RetryFetchingContent -> {
+ retryCount++
+ }
+ JoinRoomEvents.ClearError -> {
+ knockAction.value = AsyncAction.Uninitialized
+ }
}
}
return JoinRoomState(
contentState = contentState,
acceptDeclineInviteState = acceptDeclineInviteState,
+ knockAction = knockAction.value,
+ applicationName = buildMeta.applicationName,
eventSink = ::handleEvents
)
}
+
+ private fun CoroutineScope.knockRoom(roomId: RoomId, knockAction: MutableState>) = launch {
+ knockAction.runUpdatingState {
+ knockRoom(roomId)
+ }
+ }
+}
+
+private fun RoomPreview.toContentState(): ContentState {
+ return ContentState.Loaded(
+ roomId = roomId,
+ name = name,
+ topic = topic,
+ alias = canonicalAlias,
+ numberOfMembers = numberOfJoinedMembers,
+ isDirect = false,
+ roomType = roomType,
+ roomAvatarUrl = avatarUrl,
+ joinAuthorisationStatus = when {
+ // Note when isInvited, roomInfo will be used, so if this happen, it will be temporary.
+ isInvited -> JoinAuthorisationStatus.IsInvited(null)
+ canKnock -> JoinAuthorisationStatus.CanKnock
+ isPublic -> JoinAuthorisationStatus.CanJoin
+ else -> JoinAuthorisationStatus.Unknown
+ }
+ )
}
@VisibleForTesting
@@ -96,6 +178,7 @@ internal fun RoomDescription.toContentState(): ContentState {
alias = alias,
numberOfMembers = numberOfMembers,
isDirect = false,
+ roomType = RoomType.Room,
roomAvatarUrl = avatarUrl,
joinAuthorisationStatus = when (joinRule) {
RoomDescription.JoinRule.KNOCK -> JoinAuthorisationStatus.CanKnock
@@ -108,15 +191,18 @@ internal fun RoomDescription.toContentState(): ContentState {
@VisibleForTesting
internal fun MatrixRoomInfo.toContentState(): ContentState {
return ContentState.Loaded(
- roomId = RoomId(id),
+ roomId = id,
name = name,
topic = topic,
alias = canonicalAlias,
numberOfMembers = activeMembersCount,
isDirect = isDirect,
+ roomType = if (isSpace) RoomType.Space else RoomType.Room,
roomAvatarUrl = avatarUrl,
joinAuthorisationStatus = when {
- currentUserMembership == CurrentUserMembership.INVITED -> JoinAuthorisationStatus.IsInvited
+ currentUserMembership == CurrentUserMembership.INVITED -> JoinAuthorisationStatus.IsInvited(
+ inviteSender = inviter?.toInviteSender()
+ )
isPublic -> JoinAuthorisationStatus.CanJoin
else -> JoinAuthorisationStatus.Unknown
}
@@ -128,7 +214,8 @@ internal fun ContentState.toInviteData(): InviteData? {
return when (this) {
is ContentState.Loaded -> InviteData(
roomId = roomId,
- roomName = computedTitle,
+ // Note: name should not be null at this point, but use Id just in case...
+ roomName = name ?: roomId.value,
isDirect = isDirect
)
else -> null
diff --git a/features/joinroom/impl/src/main/kotlin/io/element/android/features/joinroom/impl/JoinRoomState.kt b/features/joinroom/impl/src/main/kotlin/io/element/android/features/joinroom/impl/JoinRoomState.kt
index 08591c068e..9146b13513 100644
--- a/features/joinroom/impl/src/main/kotlin/io/element/android/features/joinroom/impl/JoinRoomState.kt
+++ b/features/joinroom/impl/src/main/kotlin/io/element/android/features/joinroom/impl/JoinRoomState.kt
@@ -18,14 +18,21 @@ package io.element.android.features.joinroom.impl
import androidx.compose.runtime.Immutable
import io.element.android.features.invite.api.response.AcceptDeclineInviteState
+import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
+import io.element.android.libraries.matrix.api.core.RoomAlias
import io.element.android.libraries.matrix.api.core.RoomId
+import io.element.android.libraries.matrix.api.core.RoomIdOrAlias
+import io.element.android.libraries.matrix.api.room.RoomType
+import io.element.android.libraries.matrix.ui.model.InviteSender
@Immutable
data class JoinRoomState(
val contentState: ContentState,
val acceptDeclineInviteState: AcceptDeclineInviteState,
+ val knockAction: AsyncAction,
+ val applicationName: String,
val eventSink: (JoinRoomEvents) -> Unit
) {
val joinAuthorisationStatus = when (contentState) {
@@ -35,26 +42,20 @@ data class JoinRoomState(
}
sealed interface ContentState {
- data class Loading(val roomId: RoomId) : ContentState
- data class UnknownRoom(val roomId: RoomId) : ContentState
+ data class Loading(val roomIdOrAlias: RoomIdOrAlias) : ContentState
+ data class Failure(val roomIdOrAlias: RoomIdOrAlias, val error: Throwable) : ContentState
+ data class UnknownRoom(val roomIdOrAlias: RoomIdOrAlias) : ContentState
data class Loaded(
val roomId: RoomId,
val name: String?,
val topic: String?,
- val alias: String?,
+ val alias: RoomAlias?,
val numberOfMembers: Long?,
val isDirect: Boolean,
+ val roomType: RoomType,
val roomAvatarUrl: String?,
val joinAuthorisationStatus: JoinAuthorisationStatus,
) : ContentState {
- val computedTitle = name ?: roomId.value
-
- val computedSubtitle = when {
- alias != null -> alias
- name == null -> ""
- else -> roomId.value
- }
-
val showMemberCount = numberOfMembers != null
fun avatarData(size: AvatarSize): AvatarData {
@@ -68,9 +69,9 @@ sealed interface ContentState {
}
}
-enum class JoinAuthorisationStatus {
- IsInvited,
- CanKnock,
- CanJoin,
- Unknown,
+sealed interface JoinAuthorisationStatus {
+ data class IsInvited(val inviteSender: InviteSender?) : JoinAuthorisationStatus
+ data object CanKnock : JoinAuthorisationStatus
+ data object CanJoin : JoinAuthorisationStatus
+ data object Unknown : JoinAuthorisationStatus
}
diff --git a/features/joinroom/impl/src/main/kotlin/io/element/android/features/joinroom/impl/JoinRoomStateProvider.kt b/features/joinroom/impl/src/main/kotlin/io/element/android/features/joinroom/impl/JoinRoomStateProvider.kt
index 82b81d8e7b..ee08ee954a 100644
--- a/features/joinroom/impl/src/main/kotlin/io/element/android/features/joinroom/impl/JoinRoomStateProvider.kt
+++ b/features/joinroom/impl/src/main/kotlin/io/element/android/features/joinroom/impl/JoinRoomStateProvider.kt
@@ -19,7 +19,16 @@ package io.element.android.features.joinroom.impl
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.features.invite.api.response.AcceptDeclineInviteState
import io.element.android.features.invite.api.response.anAcceptDeclineInviteState
+import io.element.android.libraries.architecture.AsyncAction
+import io.element.android.libraries.designsystem.components.avatar.AvatarData
+import io.element.android.libraries.designsystem.components.avatar.AvatarSize
+import io.element.android.libraries.matrix.api.core.RoomAlias
import io.element.android.libraries.matrix.api.core.RoomId
+import io.element.android.libraries.matrix.api.core.RoomIdOrAlias
+import io.element.android.libraries.matrix.api.core.UserId
+import io.element.android.libraries.matrix.api.core.toRoomIdOrAlias
+import io.element.android.libraries.matrix.api.room.RoomType
+import io.element.android.libraries.matrix.ui.model.InviteSender
open class JoinRoomStateProvider : PreviewParameterProvider {
override val values: Sequence
@@ -30,29 +39,75 @@ open class JoinRoomStateProvider : PreviewParameterProvider {
aJoinRoomState(
contentState = anUnknownContentState()
),
+ aJoinRoomState(
+ contentState = aLoadedContentState(
+ name = null,
+ alias = null,
+ topic = null,
+ )
+ ),
aJoinRoomState(
contentState = aLoadedContentState(joinAuthorisationStatus = JoinAuthorisationStatus.CanJoin)
),
aJoinRoomState(
- contentState = aLoadedContentState(joinAuthorisationStatus = JoinAuthorisationStatus.CanKnock)
+ contentState = aLoadedContentState(
+ joinAuthorisationStatus = JoinAuthorisationStatus.CanKnock,
+ topic = "lorem ipsum dolor sit amet consectetur adipiscing elit sed do eiusmod tempor incididunt" +
+ " ut labore et dolore magna aliqua ut enim ad minim veniam quis nostrud exercitation ullamco" +
+ " laboris nisi ut aliquip ex ea commodo consequat duis aute irure dolor in reprehenderit in" +
+ " voluptate velit esse cillum dolore eu fugiat nulla pariatur excepteur sint occaecat cupidatat" +
+ " non proident sunt in culpa qui officia deserunt mollit anim id est laborum",
+ numberOfMembers = 888,
+ )
+ ),
+ aJoinRoomState(
+ contentState = aLoadedContentState(joinAuthorisationStatus = JoinAuthorisationStatus.IsInvited(null))
+ ),
+ aJoinRoomState(
+ contentState = aLoadedContentState(
+ numberOfMembers = 123,
+ joinAuthorisationStatus = JoinAuthorisationStatus.IsInvited(anInviteSender()),
+ )
+ ),
+ aJoinRoomState(
+ contentState = aFailureContentState()
+ ),
+ aJoinRoomState(
+ contentState = aFailureContentState(roomIdOrAlias = A_ROOM_ALIAS.toRoomIdOrAlias())
),
aJoinRoomState(
- contentState = aLoadedContentState(joinAuthorisationStatus = JoinAuthorisationStatus.IsInvited)
+ contentState = aLoadedContentState(
+ roomId = RoomId("!aSpaceId:domain"),
+ name = "A space",
+ alias = null,
+ topic = "This is the topic of a space",
+ roomType = RoomType.Space,
+ )
),
)
}
-fun anUnknownContentState(roomId: RoomId = A_ROOM_ID) = ContentState.UnknownRoom(roomId)
+fun aFailureContentState(
+ roomIdOrAlias: RoomIdOrAlias = A_ROOM_ID.toRoomIdOrAlias()
+): ContentState {
+ return ContentState.Failure(
+ roomIdOrAlias = roomIdOrAlias,
+ error = Exception("Error"),
+ )
+}
-fun aLoadingContentState(roomId: RoomId = A_ROOM_ID) = ContentState.Loading(roomId)
+fun anUnknownContentState(roomId: RoomId = A_ROOM_ID) = ContentState.UnknownRoom(roomId.toRoomIdOrAlias())
+
+fun aLoadingContentState(roomId: RoomId = A_ROOM_ID) = ContentState.Loading(roomId.toRoomIdOrAlias())
fun aLoadedContentState(
roomId: RoomId = A_ROOM_ID,
- name: String = "Element X android",
- alias: String? = "#exa:matrix.org",
+ name: String? = "Element X android",
+ alias: RoomAlias? = RoomAlias("#exa:matrix.org"),
topic: String? = "Element X is a secure, private and decentralized messenger.",
numberOfMembers: Long? = null,
isDirect: Boolean = false,
+ roomType: RoomType = RoomType.Room,
roomAvatarUrl: String? = null,
joinAuthorisationStatus: JoinAuthorisationStatus = JoinAuthorisationStatus.Unknown
) = ContentState.Loaded(
@@ -62,6 +117,7 @@ fun aLoadedContentState(
topic = topic,
numberOfMembers = numberOfMembers,
isDirect = isDirect,
+ roomType = roomType,
roomAvatarUrl = roomAvatarUrl,
joinAuthorisationStatus = joinAuthorisationStatus
)
@@ -69,11 +125,25 @@ fun aLoadedContentState(
fun aJoinRoomState(
contentState: ContentState = aLoadedContentState(),
acceptDeclineInviteState: AcceptDeclineInviteState = anAcceptDeclineInviteState(),
+ knockAction: AsyncAction = AsyncAction.Uninitialized,
eventSink: (JoinRoomEvents) -> Unit = {}
) = JoinRoomState(
contentState = contentState,
acceptDeclineInviteState = acceptDeclineInviteState,
+ knockAction = knockAction,
+ applicationName = "AppName",
eventSink = eventSink
)
+internal fun anInviteSender(
+ userId: UserId = UserId("@bob:domain"),
+ displayName: String = "Bob",
+ avatarData: AvatarData = AvatarData(userId.value, displayName, size = AvatarSize.InviteSender),
+) = InviteSender(
+ userId = userId,
+ displayName = displayName,
+ avatarData = avatarData,
+)
+
private val A_ROOM_ID = RoomId("!exa:matrix.org")
+private val A_ROOM_ALIAS = RoomAlias("#exa:matrix.org")
diff --git a/features/joinroom/impl/src/main/kotlin/io/element/android/features/joinroom/impl/JoinRoomView.kt b/features/joinroom/impl/src/main/kotlin/io/element/android/features/joinroom/impl/JoinRoomView.kt
index 31f065c0e5..49541938d2 100644
--- a/features/joinroom/impl/src/main/kotlin/io/element/android/features/joinroom/impl/JoinRoomView.kt
+++ b/features/joinroom/impl/src/main/kotlin/io/element/android/features/joinroom/impl/JoinRoomView.kt
@@ -16,165 +16,254 @@
package io.element.android.features.joinroom.impl
-import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
-import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
-import androidx.compose.foundation.layout.padding
-import androidx.compose.foundation.layout.widthIn
-import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.style.TextAlign
-import androidx.compose.ui.text.style.TextOverflow
-import androidx.compose.ui.tooling.preview.PreviewLightDark
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme
-import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.libraries.designsystem.atomic.atoms.PlaceholderAtom
+import io.element.android.libraries.designsystem.atomic.atoms.RoomPreviewDescriptionAtom
+import io.element.android.libraries.designsystem.atomic.atoms.RoomPreviewSubtitleAtom
+import io.element.android.libraries.designsystem.atomic.atoms.RoomPreviewTitleAtom
import io.element.android.libraries.designsystem.atomic.molecules.ButtonRowMolecule
+import io.element.android.libraries.designsystem.atomic.molecules.RoomPreviewMembersCountMolecule
+import io.element.android.libraries.designsystem.atomic.organisms.RoomPreviewOrganism
import io.element.android.libraries.designsystem.atomic.pages.HeaderFooterPage
+import io.element.android.libraries.designsystem.background.LightGradientBackground
+import io.element.android.libraries.designsystem.components.async.AsyncActionView
import io.element.android.libraries.designsystem.components.avatar.Avatar
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.designsystem.components.button.BackButton
+import io.element.android.libraries.designsystem.components.button.SuperButton
import io.element.android.libraries.designsystem.preview.ElementPreview
+import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.Button
import io.element.android.libraries.designsystem.theme.components.ButtonSize
-import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.theme.components.OutlinedButton
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.theme.components.TopAppBar
+import io.element.android.libraries.matrix.api.core.RoomIdOrAlias
+import io.element.android.libraries.matrix.api.room.RoomType
+import io.element.android.libraries.matrix.ui.components.InviteSenderView
import io.element.android.libraries.ui.strings.CommonStrings
@Composable
fun JoinRoomView(
state: JoinRoomState,
onBackPressed: () -> Unit,
+ onKnockSuccess: () -> Unit,
modifier: Modifier = Modifier,
) {
- HeaderFooterPage(
- modifier = modifier,
- paddingValues = PaddingValues(16.dp),
- topBar = {
- JoinRoomTopBar(onBackClicked = onBackPressed)
- },
- content = {
- JoinRoomContent(contentState = state.contentState)
- },
- footer = {
- JoinRoomFooter(
- joinAuthorisationStatus = state.joinAuthorisationStatus,
- onAcceptInvite = {
- state.eventSink(JoinRoomEvents.AcceptInvite)
- },
- onDeclineInvite = {
- state.eventSink(JoinRoomEvents.DeclineInvite)
- },
- onJoinRoom = {
- state.eventSink(JoinRoomEvents.JoinRoom)
- },
- )
- }
+ Box(
+ modifier = modifier.fillMaxSize(),
+ ) {
+ LightGradientBackground()
+ HeaderFooterPage(
+ containerColor = Color.Transparent,
+ paddingValues = PaddingValues(16.dp),
+ topBar = {
+ JoinRoomTopBar(onBackClicked = onBackPressed)
+ },
+ content = {
+ JoinRoomContent(
+ contentState = state.contentState,
+ applicationName = state.applicationName,
+ )
+ },
+ footer = {
+ JoinRoomFooter(
+ state = state,
+ onAcceptInvite = {
+ state.eventSink(JoinRoomEvents.AcceptInvite)
+ },
+ onDeclineInvite = {
+ state.eventSink(JoinRoomEvents.DeclineInvite)
+ },
+ onJoinRoom = {
+ state.eventSink(JoinRoomEvents.JoinRoom)
+ },
+ onKnockRoom = {
+ state.eventSink(JoinRoomEvents.KnockRoom)
+ },
+ onRetry = {
+ state.eventSink(JoinRoomEvents.RetryFetchingContent)
+ },
+ onGoBack = onBackPressed,
+ )
+ }
+ )
+ }
+
+ AsyncActionView(
+ async = state.knockAction,
+ onSuccess = { onKnockSuccess() },
+ onErrorDismiss = { state.eventSink(JoinRoomEvents.ClearError) },
)
}
@Composable
private fun JoinRoomFooter(
- joinAuthorisationStatus: JoinAuthorisationStatus,
+ state: JoinRoomState,
onAcceptInvite: () -> Unit,
onDeclineInvite: () -> Unit,
onJoinRoom: () -> Unit,
+ onKnockRoom: () -> Unit,
+ onRetry: () -> Unit,
+ onGoBack: () -> Unit,
modifier: Modifier = Modifier,
) {
- when (joinAuthorisationStatus) {
- JoinAuthorisationStatus.IsInvited -> {
- ButtonRowMolecule(modifier = modifier, horizontalArrangement = Arrangement.spacedBy(20.dp)) {
- OutlinedButton(
- text = stringResource(CommonStrings.action_decline),
- onClick = onDeclineInvite,
- modifier = Modifier.weight(1f),
- size = ButtonSize.Medium,
- )
+ if (state.contentState is ContentState.Failure) {
+ Button(
+ text = stringResource(CommonStrings.action_retry),
+ onClick = onRetry,
+ modifier = modifier.fillMaxWidth(),
+ size = ButtonSize.Large,
+ )
+ } else if (state.contentState is ContentState.Loaded && state.contentState.roomType == RoomType.Space) {
+ Button(
+ text = stringResource(CommonStrings.action_go_back),
+ onClick = onGoBack,
+ modifier = modifier.fillMaxWidth(),
+ size = ButtonSize.Large,
+ )
+ } else {
+ val joinAuthorisationStatus = state.joinAuthorisationStatus
+ when (joinAuthorisationStatus) {
+ is JoinAuthorisationStatus.IsInvited -> {
+ ButtonRowMolecule(modifier = modifier, horizontalArrangement = Arrangement.spacedBy(20.dp)) {
+ OutlinedButton(
+ text = stringResource(CommonStrings.action_decline),
+ onClick = onDeclineInvite,
+ modifier = Modifier.weight(1f),
+ size = ButtonSize.Large,
+ )
+ Button(
+ text = stringResource(CommonStrings.action_accept),
+ onClick = onAcceptInvite,
+ modifier = Modifier.weight(1f),
+ size = ButtonSize.Large,
+ )
+ }
+ }
+ JoinAuthorisationStatus.CanJoin -> {
+ SuperButton(
+ onClick = onJoinRoom,
+ modifier = modifier.fillMaxWidth(),
+ buttonSize = ButtonSize.Large,
+ ) {
+ Text(
+ text = stringResource(R.string.screen_join_room_join_action),
+ )
+ }
+ }
+ JoinAuthorisationStatus.CanKnock -> {
Button(
- text = stringResource(CommonStrings.action_accept),
- onClick = onAcceptInvite,
- modifier = Modifier.weight(1f),
- size = ButtonSize.Medium,
+ text = stringResource(R.string.screen_join_room_knock_action),
+ onClick = onKnockRoom,
+ modifier = modifier.fillMaxWidth(),
+ size = ButtonSize.Large,
)
}
+ JoinAuthorisationStatus.Unknown -> Unit
}
- JoinAuthorisationStatus.CanJoin -> {
- Button(
- text = stringResource(R.string.screen_join_room_join_action),
- onClick = onJoinRoom,
- modifier = modifier.fillMaxWidth(),
- size = ButtonSize.Medium,
- )
- }
- JoinAuthorisationStatus.CanKnock -> {
- Button(
- text = stringResource(R.string.screen_join_room_knock_action),
- onClick = onJoinRoom,
- modifier = modifier.fillMaxWidth(),
- size = ButtonSize.Medium,
- )
- }
- JoinAuthorisationStatus.Unknown -> Unit
}
}
@Composable
private fun JoinRoomContent(
contentState: ContentState,
+ applicationName: String,
modifier: Modifier = Modifier,
) {
when (contentState) {
is ContentState.Loaded -> {
- ContentScaffold(
+ RoomPreviewOrganism(
modifier = modifier,
avatar = {
Avatar(contentState.avatarData(AvatarSize.RoomHeader))
},
title = {
- Title(contentState.computedTitle)
+ if (contentState.name != null) {
+ RoomPreviewTitleAtom(
+ title = contentState.name,
+ )
+ } else {
+ RoomPreviewTitleAtom(
+ title = stringResource(id = CommonStrings.common_no_room_name),
+ fontStyle = FontStyle.Italic
+ )
+ }
},
subtitle = {
- Subtitle(contentState.computedSubtitle)
+ if (contentState.alias != null) {
+ RoomPreviewSubtitleAtom(contentState.alias.value)
+ }
},
description = {
- Description(contentState.topic ?: "")
+ Column(
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.spacedBy(8.dp),
+ ) {
+ val inviteSender = (contentState.joinAuthorisationStatus as? JoinAuthorisationStatus.IsInvited)?.inviteSender
+ if (inviteSender != null) {
+ InviteSenderView(inviteSender = inviteSender)
+ }
+ RoomPreviewDescriptionAtom(contentState.topic ?: "")
+ if (contentState.roomType == RoomType.Space) {
+ Spacer(modifier = Modifier.height(24.dp))
+ Text(
+ text = stringResource(R.string.screen_join_room_space_not_supported_title),
+ textAlign = TextAlign.Center,
+ style = ElementTheme.typography.fontBodyLgMedium,
+ color = MaterialTheme.colorScheme.primary,
+ )
+ Text(
+ text = stringResource(R.string.screen_join_room_space_not_supported_description, applicationName),
+ textAlign = TextAlign.Center,
+ style = ElementTheme.typography.fontBodyMdRegular,
+ color = MaterialTheme.colorScheme.secondary,
+ )
+ }
+ }
},
memberCount = {
if (contentState.showMemberCount) {
- MembersCount(memberCount = contentState.numberOfMembers ?: 0)
+ RoomPreviewMembersCountMolecule(memberCount = contentState.numberOfMembers ?: 0)
}
}
)
}
is ContentState.UnknownRoom -> {
- ContentScaffold(
+ RoomPreviewOrganism(
modifier = modifier,
avatar = {
PlaceholderAtom(width = AvatarSize.RoomHeader.dp, height = AvatarSize.RoomHeader.dp)
},
title = {
- Title(stringResource(R.string.screen_join_room_title_no_preview))
+ RoomPreviewTitleAtom(stringResource(R.string.screen_join_room_title_no_preview))
},
subtitle = {
- Subtitle(stringResource(R.string.screen_join_room_subtitle_no_preview))
+ RoomPreviewSubtitleAtom(stringResource(R.string.screen_join_room_subtitle_no_preview))
},
)
}
is ContentState.Loading -> {
- ContentScaffold(
+ RoomPreviewOrganism(
modifier = modifier,
avatar = {
PlaceholderAtom(width = AvatarSize.RoomHeader.dp, height = AvatarSize.RoomHeader.dp)
@@ -187,94 +276,31 @@ private fun JoinRoomContent(
},
)
}
- }
-}
-
-@Composable
-private fun ContentScaffold(
- avatar: @Composable () -> Unit,
- title: @Composable () -> Unit,
- subtitle: @Composable () -> Unit,
- modifier: Modifier = Modifier,
- description: @Composable (() -> Unit)? = null,
- memberCount: @Composable (() -> Unit)? = null,
-) {
- Column(
- modifier = modifier.fillMaxWidth(),
- horizontalAlignment = Alignment.CenterHorizontally
- ) {
- avatar()
- Spacer(modifier = Modifier.height(16.dp))
- title()
- Spacer(modifier = Modifier.height(8.dp))
- subtitle()
- Spacer(modifier = Modifier.height(8.dp))
- if (memberCount != null) {
- memberCount()
- }
- Spacer(modifier = Modifier.height(8.dp))
- if (description != null) {
- description()
+ is ContentState.Failure -> {
+ RoomPreviewOrganism(
+ modifier = modifier,
+ avatar = {
+ PlaceholderAtom(width = AvatarSize.RoomHeader.dp, height = AvatarSize.RoomHeader.dp)
+ },
+ title = {
+ when (contentState.roomIdOrAlias) {
+ is RoomIdOrAlias.Alias -> {
+ RoomPreviewTitleAtom(contentState.roomIdOrAlias.identifier)
+ }
+ is RoomIdOrAlias.Id -> {
+ PlaceholderAtom(width = 200.dp, height = 22.dp)
+ }
+ }
+ },
+ subtitle = {
+ Text(
+ text = stringResource(id = CommonStrings.error_unknown),
+ textAlign = TextAlign.Center,
+ color = MaterialTheme.colorScheme.error,
+ )
+ },
+ )
}
- Spacer(modifier = Modifier.height(24.dp))
- }
-}
-
-@Composable
-private fun Title(title: String, modifier: Modifier = Modifier) {
- Text(
- modifier = modifier,
- text = title,
- style = ElementTheme.typography.fontHeadingMdBold,
- textAlign = TextAlign.Center,
- color = ElementTheme.colors.textPrimary,
- )
-}
-
-@Composable
-private fun Subtitle(subtitle: String, modifier: Modifier = Modifier) {
- Text(
- modifier = modifier,
- text = subtitle,
- style = ElementTheme.typography.fontBodyLgRegular,
- textAlign = TextAlign.Center,
- color = ElementTheme.colors.textSecondary,
- )
-}
-
-@Composable
-private fun Description(description: String, modifier: Modifier = Modifier) {
- Text(
- modifier = modifier,
- text = description,
- style = ElementTheme.typography.fontBodySmRegular,
- textAlign = TextAlign.Center,
- color = ElementTheme.colors.textSecondary,
- maxLines = 3,
- overflow = TextOverflow.Ellipsis,
- )
-}
-
-@Composable
-private fun MembersCount(memberCount: Long) {
- Row(
- modifier = Modifier
- .background(color = ElementTheme.colors.bgSubtleSecondary, shape = CircleShape)
- .widthIn(min = 48.dp)
- .padding(all = 2.dp),
- verticalAlignment = Alignment.CenterVertically,
- horizontalArrangement = Arrangement.spacedBy(4.dp)
- ) {
- Icon(
- imageVector = CompoundIcons.UserProfile(),
- contentDescription = null,
- tint = ElementTheme.colors.iconSecondary,
- )
- Text(
- text = "$memberCount",
- style = ElementTheme.typography.fontBodySmMedium,
- color = ElementTheme.colors.textSecondary,
- )
}
}
@@ -291,11 +317,12 @@ private fun JoinRoomTopBar(
)
}
-@PreviewLightDark
+@PreviewsDayNight
@Composable
internal fun JoinRoomViewPreview(@PreviewParameter(JoinRoomStateProvider::class) state: JoinRoomState) = ElementPreview {
JoinRoomView(
state = state,
- onBackPressed = { }
+ onBackPressed = { },
+ onKnockSuccess = { },
)
}
diff --git a/features/joinroom/impl/src/main/kotlin/io/element/android/features/joinroom/impl/di/JoinRoomModule.kt b/features/joinroom/impl/src/main/kotlin/io/element/android/features/joinroom/impl/di/JoinRoomModule.kt
index 6c1dfd491d..c288021cdf 100644
--- a/features/joinroom/impl/src/main/kotlin/io/element/android/features/joinroom/impl/di/JoinRoomModule.kt
+++ b/features/joinroom/impl/src/main/kotlin/io/element/android/features/joinroom/impl/di/JoinRoomModule.kt
@@ -23,9 +23,11 @@ import io.element.android.features.invite.api.response.AcceptDeclineInviteState
import io.element.android.features.joinroom.impl.JoinRoomPresenter
import io.element.android.features.roomdirectory.api.RoomDescription
import io.element.android.libraries.architecture.Presenter
+import io.element.android.libraries.core.meta.BuildMeta
import io.element.android.libraries.di.SessionScope
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.core.RoomId
+import io.element.android.libraries.matrix.api.core.RoomIdOrAlias
import java.util.Optional
@Module
@@ -34,15 +36,24 @@ object JoinRoomModule {
@Provides
fun providesJoinRoomPresenterFactory(
client: MatrixClient,
+ knockRoom: KnockRoom,
acceptDeclineInvitePresenter: Presenter,
+ buildMeta: BuildMeta,
): JoinRoomPresenter.Factory {
return object : JoinRoomPresenter.Factory {
- override fun create(roomId: RoomId, roomDescription: Optional): JoinRoomPresenter {
+ override fun create(
+ roomId: RoomId,
+ roomIdOrAlias: RoomIdOrAlias,
+ roomDescription: Optional,
+ ): JoinRoomPresenter {
return JoinRoomPresenter(
roomId = roomId,
+ roomIdOrAlias = roomIdOrAlias,
roomDescription = roomDescription,
matrixClient = client,
+ knockRoom = knockRoom,
acceptDeclineInvitePresenter = acceptDeclineInvitePresenter,
+ buildMeta = buildMeta,
)
}
}
diff --git a/features/joinroom/impl/src/main/kotlin/io/element/android/features/joinroom/impl/di/KnockRoom.kt b/features/joinroom/impl/src/main/kotlin/io/element/android/features/joinroom/impl/di/KnockRoom.kt
new file mode 100644
index 0000000000..e7bfab591b
--- /dev/null
+++ b/features/joinroom/impl/src/main/kotlin/io/element/android/features/joinroom/impl/di/KnockRoom.kt
@@ -0,0 +1,32 @@
+/*
+ * Copyright (c) 2024 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.element.android.features.joinroom.impl.di
+
+import com.squareup.anvil.annotations.ContributesBinding
+import io.element.android.libraries.di.SessionScope
+import io.element.android.libraries.matrix.api.MatrixClient
+import io.element.android.libraries.matrix.api.core.RoomId
+import javax.inject.Inject
+
+interface KnockRoom {
+ suspend operator fun invoke(roomId: RoomId): Result
+}
+
+@ContributesBinding(SessionScope::class)
+class DefaultKnockRoom @Inject constructor(private val client: MatrixClient) : KnockRoom {
+ override suspend fun invoke(roomId: RoomId) = client.knockRoom(roomId)
+}
diff --git a/features/joinroom/impl/src/main/res/values-be/translations.xml b/features/joinroom/impl/src/main/res/values-be/translations.xml
index f02d8ff94a..a7b7017cc9 100644
--- a/features/joinroom/impl/src/main/res/values-be/translations.xml
+++ b/features/joinroom/impl/src/main/res/values-be/translations.xml
@@ -2,6 +2,8 @@
"Далучыцца да пакоя"
"Націсніце, каб далучыцца"
+ "%1$s пакуль не падтрымлівае прасторы. Вы можаце атрымаць доступ да прастор праз вэб-старонку."
+ "Прасторы пакуль не падтрымліваюцца"
"Націсніце кнопку ніжэй, і адміністратар пакоя атрымае апавяшчэнне. Вы зможаце далучыцца да размовы пасля зацвярджэння."
"Вы павінны быць удзельнікам гэтага пакоя каб прагледзець гісторыю паведамленняў."
"Вы хочаце далучыцца да гэтага пакоя?"
diff --git a/features/joinroom/impl/src/main/res/values-cs/translations.xml b/features/joinroom/impl/src/main/res/values-cs/translations.xml
index 9f175974ad..a357fab76b 100644
--- a/features/joinroom/impl/src/main/res/values-cs/translations.xml
+++ b/features/joinroom/impl/src/main/res/values-cs/translations.xml
@@ -2,6 +2,8 @@
"Připojit se do místnosti"
"Zaklepejte a připojte se"
+ "%1$s zatím nepodporuje prostory. Prostory můžete používat na webu."
+ "Prostory zatím nejsou podporovány"
"Klikněte na tlačítko níže a správce místnosti bude informován. Po schválení se budete moci připojit ke konverzaci."
"Pro zobrazení historie zpráv musíte být členem této místnosti."
"Chcete se připojit k této místnosti?"
diff --git a/features/joinroom/impl/src/main/res/values-de/translations.xml b/features/joinroom/impl/src/main/res/values-de/translations.xml
index c4aefdc126..95718ce371 100644
--- a/features/joinroom/impl/src/main/res/values-de/translations.xml
+++ b/features/joinroom/impl/src/main/res/values-de/translations.xml
@@ -2,7 +2,9 @@
"Raum beitreten"
"Anklopfen"
- "Klopfe an um einen Raumadministrator zu benachrichtigen. Nach der Freigabe kannst du dich an der Unterhaltung beteiligen."
+ "%1$s unterstützt noch keine Spaces. Du kannst auf Spaces im Web zugreifen."
+ "Spaces werden noch nicht unterstützt"
+ "Klopfe an um einen Administrator zu benachrichtigen. Nach der Freigabe kannst du dich an der Unterhaltung beteiligen."
"Du musst Mitglied in diesem Raum sein, um den Nachrichtenverlauf zu sehen."
"Willst du diesem Raum beitreten?"
"Vorschau nicht verfügbar"
diff --git a/features/joinroom/impl/src/main/res/values-fr/translations.xml b/features/joinroom/impl/src/main/res/values-fr/translations.xml
index b2e56edb23..b8847cf47b 100644
--- a/features/joinroom/impl/src/main/res/values-fr/translations.xml
+++ b/features/joinroom/impl/src/main/res/values-fr/translations.xml
@@ -2,6 +2,8 @@
"Rejoindre"
"Demander à joindre"
+ "Les Spaces ne sont pas encore pris en charge par %1$s . Vous pouvez voir les Spaces sur le Web."
+ "Les Spaces ne sont pas encore pris en charge"
"Cliquez ci-dessous et un administrateur sera prévenu. Une fois votre demande approuvée, pour pourrez rejoindre la discussion."
"Vous devez être un membre du salon pour pouvoir lire l’historique des messages."
"Vous souhaitez rejoindre ce salon?"
diff --git a/features/joinroom/impl/src/main/res/values-hu/translations.xml b/features/joinroom/impl/src/main/res/values-hu/translations.xml
index 505fe1a955..df9f3e8806 100644
--- a/features/joinroom/impl/src/main/res/values-hu/translations.xml
+++ b/features/joinroom/impl/src/main/res/values-hu/translations.xml
@@ -2,6 +2,8 @@
"Csatlakozás a szobához"
"Kopogtasson a csatlakozáshoz"
+ "Az %1$s még nem támogatja a tereket. A tereket a weben érheti el."
+ "A terek még nem támogatottak"
"Kattintson az alábbi gombra, és a szoba adminisztrátora értesítést kap. A jóváhagyást követően csatlakozhat a beszélgetéshez."
"Az üzenetelőzmények megtekintéséhez a szoba tagjának kell lennie."
"Csatlakozna ehhez a szobához?"
diff --git a/features/joinroom/impl/src/main/res/values-sk/translations.xml b/features/joinroom/impl/src/main/res/values-sk/translations.xml
index 9a6e87c183..13f91469fd 100644
--- a/features/joinroom/impl/src/main/res/values-sk/translations.xml
+++ b/features/joinroom/impl/src/main/res/values-sk/translations.xml
@@ -2,6 +2,8 @@
"Pripojiť sa do miestnosti"
"Zaklopaním sa pripojíte"
+ "%1$s zatiaľ nepodporuje priestory. K priestorom môžete pristupovať na webe."
+ "Priestory zatiaľ nie sú podporované"
"Kliknite na tlačidlo nižšie a správca miestnosti bude informovaný. Po schválení sa budete môcť pripojiť ku konverzácii."
"Ak chcete zobraziť históriu správ, musíte byť členom tejto miestnosti."
"Chcete sa pripojiť do tejto miestnosti?"
diff --git a/features/joinroom/impl/src/main/res/values/localazy.xml b/features/joinroom/impl/src/main/res/values/localazy.xml
index 1c187d403d..103d512970 100644
--- a/features/joinroom/impl/src/main/res/values/localazy.xml
+++ b/features/joinroom/impl/src/main/res/values/localazy.xml
@@ -2,6 +2,8 @@
"Join room"
"Knock to join"
+ "%1$s does not support spaces yet. You can access spaces on web."
+ "Spaces are not supported yet"
"Click the button below and a room administrator will be notified. You’ll be able to join the conversation once approved."
"You must be a member of this room to view the message history."
"Want to join this room?"
diff --git a/features/joinroom/impl/src/test/kotlin/io/element/android/features/joinroom/impl/FakeKnockRoom.kt b/features/joinroom/impl/src/test/kotlin/io/element/android/features/joinroom/impl/FakeKnockRoom.kt
new file mode 100644
index 0000000000..d21369d2c3
--- /dev/null
+++ b/features/joinroom/impl/src/test/kotlin/io/element/android/features/joinroom/impl/FakeKnockRoom.kt
@@ -0,0 +1,26 @@
+/*
+ * Copyright (c) 2024 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.element.android.features.joinroom.impl
+
+import io.element.android.features.joinroom.impl.di.KnockRoom
+import io.element.android.libraries.matrix.api.core.RoomId
+
+class FakeKnockRoom(
+ var lambda: (RoomId) -> Result = { Result.success(Unit) }
+) : KnockRoom {
+ override suspend fun invoke(roomId: RoomId) = lambda(roomId)
+}
diff --git a/features/joinroom/impl/src/test/kotlin/io/element/android/features/joinroom/impl/JoinRoomPresenterTest.kt b/features/joinroom/impl/src/test/kotlin/io/element/android/features/joinroom/impl/JoinRoomPresenterTest.kt
index e61dd18aad..3928ca31e8 100644
--- a/features/joinroom/impl/src/test/kotlin/io/element/android/features/joinroom/impl/JoinRoomPresenterTest.kt
+++ b/features/joinroom/impl/src/test/kotlin/io/element/android/features/joinroom/impl/JoinRoomPresenterTest.kt
@@ -20,15 +20,27 @@ import com.google.common.truth.Truth.assertThat
import io.element.android.features.invite.api.response.AcceptDeclineInviteEvents
import io.element.android.features.invite.api.response.AcceptDeclineInviteState
import io.element.android.features.invite.api.response.anAcceptDeclineInviteState
+import io.element.android.features.joinroom.impl.di.KnockRoom
import io.element.android.features.roomdirectory.api.RoomDescription
+import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.architecture.Presenter
+import io.element.android.libraries.core.meta.BuildMeta
import io.element.android.libraries.matrix.api.MatrixClient
+import io.element.android.libraries.matrix.api.core.RoomAlias
import io.element.android.libraries.matrix.api.core.RoomId
+import io.element.android.libraries.matrix.api.core.UserId
+import io.element.android.libraries.matrix.api.core.toRoomIdOrAlias
import io.element.android.libraries.matrix.api.room.CurrentUserMembership
+import io.element.android.libraries.matrix.api.room.RoomType
+import io.element.android.libraries.matrix.api.room.preview.RoomPreview
+import io.element.android.libraries.matrix.test.AN_EXCEPTION
import io.element.android.libraries.matrix.test.A_ROOM_ID
import io.element.android.libraries.matrix.test.A_ROOM_NAME
import io.element.android.libraries.matrix.test.FakeMatrixClient
+import io.element.android.libraries.matrix.test.core.aBuildMeta
import io.element.android.libraries.matrix.test.room.aRoomInfo
+import io.element.android.libraries.matrix.test.room.aRoomMember
+import io.element.android.libraries.matrix.ui.model.toInviteSender
import io.element.android.tests.testutils.WarmUpRule
import io.element.android.tests.testutils.lambda.assert
import io.element.android.tests.testutils.lambda.lambdaRecorder
@@ -49,9 +61,11 @@ class JoinRoomPresenterTest {
val presenter = createJoinRoomPresenter()
presenter.test {
awaitItem().also { state ->
- assertThat(state.contentState).isEqualTo(ContentState.Loading(A_ROOM_ID))
+ assertThat(state.contentState).isEqualTo(ContentState.Loading(A_ROOM_ID.toRoomIdOrAlias()))
assertThat(state.joinAuthorisationStatus).isEqualTo(JoinAuthorisationStatus.Unknown)
assertThat(state.acceptDeclineInviteState).isEqualTo(anAcceptDeclineInviteState())
+ assertThat(state.applicationName).isEqualTo("AppName")
+ cancelAndIgnoreRemainingEvents()
}
}
}
@@ -96,7 +110,31 @@ class JoinRoomPresenterTest {
presenter.test {
skipItems(1)
awaitItem().also { state ->
- assertThat(state.joinAuthorisationStatus).isEqualTo(JoinAuthorisationStatus.IsInvited)
+ assertThat(state.joinAuthorisationStatus).isEqualTo(JoinAuthorisationStatus.IsInvited(null))
+ }
+ }
+ }
+
+ @Test
+ fun `present - when room is invited then join authorization is equal to invited, an inviter is provided`() = runTest {
+ val inviter = aRoomMember(userId = UserId("@bob:example.com"), displayName = "Bob")
+ val expectedInviteSender = inviter.toInviteSender()
+ val roomInfo = aRoomInfo(
+ currentUserMembership = CurrentUserMembership.INVITED,
+ inviter = inviter,
+ )
+ val matrixClient = FakeMatrixClient().apply {
+ getRoomInfoFlowLambda = { _ ->
+ flowOf(Optional.of(roomInfo))
+ }
+ }
+ val presenter = createJoinRoomPresenter(
+ matrixClient = matrixClient
+ )
+ presenter.test {
+ skipItems(1)
+ awaitItem().also { state ->
+ assertThat(state.joinAuthorisationStatus).isEqualTo(JoinAuthorisationStatus.IsInvited(expectedInviteSender))
}
}
}
@@ -237,16 +275,158 @@ class JoinRoomPresenterTest {
}
}
+ @Test
+ fun `present - emit knock room event`() = runTest {
+ val knockRoomSuccess = lambdaRecorder { _: RoomId ->
+ Result.success(Unit)
+ }
+ val knockRoomFailure = lambdaRecorder { roomId: RoomId ->
+ Result.failure(RuntimeException("Failed to knock room $roomId"))
+ }
+ val fakeKnockRoom = FakeKnockRoom(knockRoomSuccess)
+ val presenter = createJoinRoomPresenter(knockRoom = fakeKnockRoom)
+ presenter.test {
+ skipItems(1)
+ awaitItem().also { state ->
+ state.eventSink(JoinRoomEvents.KnockRoom)
+ }
+ awaitItem().also { state ->
+ assertThat(state.knockAction).isEqualTo(AsyncAction.Success(Unit))
+ fakeKnockRoom.lambda = knockRoomFailure
+ state.eventSink(JoinRoomEvents.KnockRoom)
+ }
+ awaitItem().also { state ->
+ assertThat(state.knockAction).isInstanceOf(AsyncAction.Failure::class.java)
+ }
+ }
+ assert(knockRoomSuccess)
+ .isCalledOnce()
+ .with(value(A_ROOM_ID))
+ assert(knockRoomFailure)
+ .isCalledOnce()
+ .with(value(A_ROOM_ID))
+ }
+
+ @Test
+ fun `present - when room is not known RoomPreview is loaded`() = runTest {
+ val client = FakeMatrixClient(
+ getRoomPreviewResult = {
+ Result.success(
+ RoomPreview(
+ roomId = A_ROOM_ID,
+ canonicalAlias = RoomAlias("#alias:matrix.org"),
+ name = "Room name",
+ topic = "Room topic",
+ avatarUrl = "avatarUrl",
+ numberOfJoinedMembers = 2,
+ roomType = RoomType.Room,
+ isHistoryWorldReadable = false,
+ isJoined = false,
+ isInvited = false,
+ isPublic = true,
+ canKnock = false,
+ )
+ )
+ }
+ )
+ val presenter = createJoinRoomPresenter(
+ matrixClient = client
+ )
+ presenter.test {
+ skipItems(1)
+ awaitItem().also { state ->
+ assertThat(state.contentState).isEqualTo(
+ ContentState.Loaded(
+ roomId = A_ROOM_ID,
+ name = "Room name",
+ topic = "Room topic",
+ alias = RoomAlias("#alias:matrix.org"),
+ numberOfMembers = 2,
+ isDirect = false,
+ roomType = RoomType.Room,
+ roomAvatarUrl = "avatarUrl",
+ joinAuthorisationStatus = JoinAuthorisationStatus.CanJoin
+ )
+ )
+ }
+ }
+ }
+
+ @Test
+ fun `present - when room is not known RoomPreview is loaded with error`() = runTest {
+ val client = FakeMatrixClient(
+ getRoomPreviewResult = {
+ Result.failure(AN_EXCEPTION)
+ }
+ )
+ val presenter = createJoinRoomPresenter(
+ matrixClient = client
+ )
+ presenter.test {
+ skipItems(1)
+ awaitItem().also { state ->
+ assertThat(state.contentState).isEqualTo(
+ ContentState.Failure(
+ roomIdOrAlias = A_ROOM_ID.toRoomIdOrAlias(),
+ error = AN_EXCEPTION
+ )
+ )
+ state.eventSink(JoinRoomEvents.RetryFetchingContent)
+ }
+ skipItems(1)
+ awaitItem().also { state ->
+ assertThat(state.contentState).isEqualTo(
+ ContentState.Loading(A_ROOM_ID.toRoomIdOrAlias())
+ )
+ }
+ awaitItem().also { state ->
+ assertThat(state.contentState).isEqualTo(
+ ContentState.Failure(
+ roomIdOrAlias = A_ROOM_ID.toRoomIdOrAlias(),
+ error = AN_EXCEPTION
+ )
+ )
+ }
+ }
+ }
+
+ @Test
+ fun `present - when room is not known RoomPreview is loaded with error 403`() = runTest {
+ val client = FakeMatrixClient(
+ getRoomPreviewResult = {
+ Result.failure(Exception("403"))
+ }
+ )
+ val presenter = createJoinRoomPresenter(
+ matrixClient = client
+ )
+ presenter.test {
+ skipItems(1)
+ awaitItem().also { state ->
+ assertThat(state.contentState).isEqualTo(
+ ContentState.UnknownRoom(
+ roomIdOrAlias = A_ROOM_ID.toRoomIdOrAlias(),
+ )
+ )
+ }
+ }
+ }
+
private fun createJoinRoomPresenter(
roomId: RoomId = A_ROOM_ID,
roomDescription: Optional = Optional.empty(),
matrixClient: MatrixClient = FakeMatrixClient(),
+ knockRoom: KnockRoom = FakeKnockRoom(),
+ buildMeta: BuildMeta = aBuildMeta(applicationName = "AppName"),
acceptDeclineInvitePresenter: Presenter = Presenter { anAcceptDeclineInviteState() }
): JoinRoomPresenter {
return JoinRoomPresenter(
roomId = roomId,
+ roomIdOrAlias = roomId.toRoomIdOrAlias(),
roomDescription = roomDescription,
matrixClient = matrixClient,
+ knockRoom = knockRoom,
+ buildMeta = buildMeta,
acceptDeclineInvitePresenter = acceptDeclineInvitePresenter
)
}
@@ -255,7 +435,7 @@ class JoinRoomPresenterTest {
roomId: RoomId = A_ROOM_ID,
name: String? = A_ROOM_NAME,
topic: String? = "A room about something",
- alias: String? = "#alias:matrix.org",
+ alias: RoomAlias? = RoomAlias("#alias:matrix.org"),
avatarUrl: String? = null,
joinRule: RoomDescription.JoinRule = RoomDescription.JoinRule.UNKNOWN,
numberOfMembers: Long = 2L
diff --git a/features/joinroom/impl/src/test/kotlin/io/element/android/features/joinroom/impl/JoinRoomViewTest.kt b/features/joinroom/impl/src/test/kotlin/io/element/android/features/joinroom/impl/JoinRoomViewTest.kt
new file mode 100644
index 0000000000..b4bd788286
--- /dev/null
+++ b/features/joinroom/impl/src/test/kotlin/io/element/android/features/joinroom/impl/JoinRoomViewTest.kt
@@ -0,0 +1,161 @@
+/*
+ * Copyright (c) 2024 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.element.android.features.joinroom.impl
+
+import androidx.activity.ComponentActivity
+import androidx.compose.ui.test.junit4.AndroidComposeTestRule
+import androidx.compose.ui.test.junit4.createAndroidComposeRule
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import io.element.android.libraries.architecture.AsyncAction
+import io.element.android.libraries.matrix.api.room.RoomType
+import io.element.android.libraries.ui.strings.CommonStrings
+import io.element.android.tests.testutils.EnsureNeverCalled
+import io.element.android.tests.testutils.EventsRecorder
+import io.element.android.tests.testutils.clickOn
+import io.element.android.tests.testutils.ensureCalledOnce
+import io.element.android.tests.testutils.pressBack
+import org.junit.Rule
+import org.junit.Test
+import org.junit.rules.TestRule
+import org.junit.runner.RunWith
+
+@RunWith(AndroidJUnit4::class)
+class JoinRoomViewTest {
+ @get:Rule val rule = createAndroidComposeRule()
+
+ @Test
+ fun `clicking on back invoke the expected callback`() {
+ val eventsRecorder = EventsRecorder(expectEvents = false)
+ ensureCalledOnce {
+ rule.setJoinRoomView(
+ aJoinRoomState(
+ eventSink = eventsRecorder,
+ ),
+ onBackPressed = it
+ )
+ rule.pressBack()
+ }
+ }
+
+ @Test
+ fun `clicking on Join room on CanJoin room emits the expected Event`() {
+ val eventsRecorder = EventsRecorder()
+ rule.setJoinRoomView(
+ aJoinRoomState(
+ contentState = aLoadedContentState(joinAuthorisationStatus = JoinAuthorisationStatus.CanJoin),
+ eventSink = eventsRecorder,
+ ),
+ )
+ rule.clickOn(R.string.screen_join_room_join_action)
+ eventsRecorder.assertSingle(JoinRoomEvents.JoinRoom)
+ }
+
+ @Test
+ fun `clicking on Knock room on CanKnock room emits the expected Event`() {
+ val eventsRecorder = EventsRecorder()
+ rule.setJoinRoomView(
+ aJoinRoomState(
+ contentState = aLoadedContentState(joinAuthorisationStatus = JoinAuthorisationStatus.CanKnock),
+ eventSink = eventsRecorder,
+ ),
+ )
+ rule.clickOn(R.string.screen_join_room_knock_action)
+ eventsRecorder.assertSingle(JoinRoomEvents.KnockRoom)
+ }
+
+ @Test
+ fun `clicking on closing Knock error emits the expected Event`() {
+ val eventsRecorder = EventsRecorder()
+ rule.setJoinRoomView(
+ aJoinRoomState(
+ contentState = aLoadedContentState(joinAuthorisationStatus = JoinAuthorisationStatus.CanKnock),
+ knockAction = AsyncAction.Failure(Exception("Error")),
+ eventSink = eventsRecorder,
+ ),
+ )
+ rule.clickOn(CommonStrings.action_ok)
+ eventsRecorder.assertSingle(JoinRoomEvents.ClearError)
+ }
+
+ @Test
+ fun `clicking on Accept invitation IsInvited room emits the expected Event`() {
+ val eventsRecorder = EventsRecorder()
+ rule.setJoinRoomView(
+ aJoinRoomState(
+ contentState = aLoadedContentState(joinAuthorisationStatus = JoinAuthorisationStatus.IsInvited(null)),
+ eventSink = eventsRecorder,
+ ),
+ )
+ rule.clickOn(CommonStrings.action_accept)
+ eventsRecorder.assertSingle(JoinRoomEvents.AcceptInvite)
+ }
+
+ @Test
+ fun `clicking on Decline invitation on IsInvited room emits the expected Event`() {
+ val eventsRecorder = EventsRecorder()
+ rule.setJoinRoomView(
+ aJoinRoomState(
+ contentState = aLoadedContentState(joinAuthorisationStatus = JoinAuthorisationStatus.IsInvited(null)),
+ eventSink = eventsRecorder,
+ ),
+ )
+ rule.clickOn(CommonStrings.action_decline)
+ eventsRecorder.assertSingle(JoinRoomEvents.DeclineInvite)
+ }
+
+ @Test
+ fun `clicking on Retry when an error occurs emits the expected Event`() {
+ val eventsRecorder = EventsRecorder()
+ rule.setJoinRoomView(
+ aJoinRoomState(
+ contentState = aFailureContentState(),
+ eventSink = eventsRecorder,
+ ),
+ )
+ rule.clickOn(CommonStrings.action_retry)
+ eventsRecorder.assertSingle(JoinRoomEvents.RetryFetchingContent)
+ }
+
+ @Test
+ fun `clicking on Go back when a space is displayed invokes the expected callback`() {
+ val eventsRecorder = EventsRecorder(expectEvents = false)
+ ensureCalledOnce {
+ rule.setJoinRoomView(
+ aJoinRoomState(
+ contentState = aLoadedContentState(roomType = RoomType.Space),
+ eventSink = eventsRecorder,
+ ),
+ onBackPressed = it
+ )
+ rule.clickOn(CommonStrings.action_go_back)
+ }
+ }
+}
+
+private fun AndroidComposeTestRule.setJoinRoomView(
+ state: JoinRoomState,
+ onBackPressed: () -> Unit = EnsureNeverCalled(),
+ onKnockSuccess: () -> Unit = EnsureNeverCalled(),
+) {
+ setContent {
+ JoinRoomView(
+ state = state,
+ onBackPressed = onBackPressed,
+ onKnockSuccess = onKnockSuccess,
+ )
+ }
+}
diff --git a/features/leaveroom/api/src/main/res/values-de/translations.xml b/features/leaveroom/api/src/main/res/values-de/translations.xml
index e684dd1e42..5c4928257c 100644
--- a/features/leaveroom/api/src/main/res/values-de/translations.xml
+++ b/features/leaveroom/api/src/main/res/values-de/translations.xml
@@ -1,7 +1,7 @@
"Bist du sicher, dass du diese Unterhaltung verlassen willst? Diese Unterhaltung ist nicht öffentlich und du kannst ihr ohne Einladung nicht wieder beitreten."
- "Bist du sicher, dass du diesen Raum verlassen möchtest? Du bist die einzige Person hier. Wenn du austritst, kann in Zukunft niemand mehr eintreten, auch du nicht."
+ "Bist du sicher, dass du diesen Raum verlassen möchtest? Du bist die einzige Person hier. Wenn du gehst, kann in Zukunft niemand mehr eintreten, auch du nicht."
"Bist du sicher, dass du diesen Raum verlassen möchtest? Dieser Raum ist nicht öffentlich und du kannst ihm ohne Einladung nicht erneut beitreten."
"Bist du sicher, dass du den Raum verlassen willst?"
diff --git a/features/leaveroom/api/src/main/res/values-sv/translations.xml b/features/leaveroom/api/src/main/res/values-sv/translations.xml
index c60389e24b..c80d716329 100644
--- a/features/leaveroom/api/src/main/res/values-sv/translations.xml
+++ b/features/leaveroom/api/src/main/res/values-sv/translations.xml
@@ -1,5 +1,6 @@
+ "Är du säker på att du vill lämna den här konversationen? Den här konversationen är inte offentlig och du kommer inte att kunna gå med igen utan en inbjudan."
"Är du säker på att du vill lämna det här rummet? Du är den enda personen här. Om du lämnar kommer ingen att kunna gå med i framtiden, inklusive du."
"Är du säker på att du vill lämna det här rummet? Detta rum är inte offentligt och du kommer inte att kunna gå med igen utan en inbjudan."
"Är du säker på att du vill lämna rummet?"
diff --git a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/unlock/PinUnlockView.kt b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/unlock/PinUnlockView.kt
index db12006e04..467de77d2b 100644
--- a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/unlock/PinUnlockView.kt
+++ b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/unlock/PinUnlockView.kt
@@ -358,7 +358,7 @@ private fun PinUnlockFooter(
@Composable
@PreviewsDayNight
-internal fun PinUnlockInAppViewPreview(@PreviewParameter(PinUnlockStateProvider::class) state: PinUnlockState) {
+internal fun PinUnlockViewInAppPreview(@PreviewParameter(PinUnlockStateProvider::class) state: PinUnlockState) {
ElementPreview {
PinUnlockView(
state = state,
@@ -369,7 +369,7 @@ internal fun PinUnlockInAppViewPreview(@PreviewParameter(PinUnlockStateProvider:
@Composable
@PreviewsDayNight
-internal fun PinUnlockDefaultViewPreview(@PreviewParameter(PinUnlockStateProvider::class) state: PinUnlockState) {
+internal fun PinUnlockViewPreview(@PreviewParameter(PinUnlockStateProvider::class) state: PinUnlockState) {
ElementPreview {
PinUnlockView(
state = state,
diff --git a/features/lockscreen/impl/src/main/res/values-de/translations.xml b/features/lockscreen/impl/src/main/res/values-de/translations.xml
index c24e9744c4..53df8aeca0 100644
--- a/features/lockscreen/impl/src/main/res/values-de/translations.xml
+++ b/features/lockscreen/impl/src/main/res/values-de/translations.xml
@@ -16,7 +16,7 @@
"PIN bestätigen"
"Aus Sicherheitsgründen kann dieser PIN-Code nicht verwendet werden."
"Bitte eine andere PIN verwenden."
- "Sperre %1$s mit einem PIN Code, um den Zugriff auf Deine Chats zu beschränken.
+ "Sperre %1$s mit einem PIN Code, um den Zugriff auf deine Chats zu beschränken.
Wähle etwas Einprägsames. Bei falscher Eingabe wirst du aus der App ausgeloggt."
"Bitte gib die gleiche PIN wie zuvor ein."
diff --git a/features/lockscreen/impl/src/main/res/values-fr/translations.xml b/features/lockscreen/impl/src/main/res/values-fr/translations.xml
index df95f43648..94e65cc6f0 100644
--- a/features/lockscreen/impl/src/main/res/values-fr/translations.xml
+++ b/features/lockscreen/impl/src/main/res/values-fr/translations.xml
@@ -1,7 +1,7 @@
- "Authentification biométrique"
- "Déverrouillage biométrique"
+ "authentification biométrique"
+ "déverrouillage biométrique"
"Déverrouiller avec la biométrie"
"Code PIN oublié?"
"Modifier le code PIN"
diff --git a/features/lockscreen/impl/src/main/res/values-sv/translations.xml b/features/lockscreen/impl/src/main/res/values-sv/translations.xml
index ac64f04efa..14a2faffd9 100644
--- a/features/lockscreen/impl/src/main/res/values-sv/translations.xml
+++ b/features/lockscreen/impl/src/main/res/values-sv/translations.xml
@@ -1,11 +1,27 @@
+ "biometrisk autentisering"
+ "biometrisk upplåsning"
+ "Lås upp med biometri"
"Glömt PIN-kod?"
"Byt PIN-kod"
"Tillåt biometrisk upplåsning"
"Ta bort PIN-kod"
"Är du säker på att du vill ta bort PIN-koden?"
"Ta bort PIN-koden?"
+ "Tillåt %1$s"
+ "Jag vill hellre använda PIN-kod"
+ "Bespara dig själv lite tid och använd %1$s för att låsa upp appen varje gång"
+ "Välj PIN-kod"
+ "Bekräfta PIN-kod"
+ "Du kan inte välja detta som din PIN-kod av säkerhetsskäl"
+ "Välj en annan PIN-kod"
+ "Lås %1$s för att lägga till extra säkerhet i dina chattar.
+
+Välj något minnesvärt. Om du glömmer den här PIN-koden loggas du ut från appen."
+ "Ange samma PIN-kod två gånger"
+ "PIN-koder matchar inte"
+ "Du måste logga in igen och skapa en ny PIN-kod för att fortsätta"
"Du blir utloggad"
- "Du har %1$d försök att låsa upp"
@@ -15,5 +31,7 @@
- "Fel PIN-kod. Du har %1$d försök kvar"
- "Fel PIN-kod. Du har %1$d försök kvar"
+ "Använd biometri"
+ "Använd PIN-kod"
"Loggar ut …"
diff --git a/features/lockscreen/impl/src/main/res/values-zh-rTW/translations.xml b/features/lockscreen/impl/src/main/res/values-zh-rTW/translations.xml
index 2f479b97d1..757604c4f4 100644
--- a/features/lockscreen/impl/src/main/res/values-zh-rTW/translations.xml
+++ b/features/lockscreen/impl/src/main/res/values-zh-rTW/translations.xml
@@ -15,6 +15,9 @@
"確認 PIN 碼"
"基於安全性的考量,您選的 PIN 碼無法使用"
"選擇不一樣的 PIN 碼"
+ "將 %1$s 上鎖,為你的聊天室添加一層防護。
+
+請選擇好記憶的數字。如果忘記 PIN 碼,您會被登出。"
"請輸入相同的 PIN 碼兩次"
"PIN 碼不一樣"
"您需要重新登入並建立新的 PIN 碼才能繼續"
diff --git a/features/login/impl/src/main/res/values-fr/translations.xml b/features/login/impl/src/main/res/values-fr/translations.xml
index 3a882ae965..01f11edaf6 100644
--- a/features/login/impl/src/main/res/values-fr/translations.xml
+++ b/features/login/impl/src/main/res/values-fr/translations.xml
@@ -14,7 +14,8 @@
"Utilisez un autre fournisseur de compte, tel que votre propre serveur privé ou un serveur professionnel."
"Changer de fournisseur de compte"
"Nous n’avons pas pu atteindre ce serveur d’accueil. Vérifiez que vous avez correctement saisi l’URL du serveur d’accueil. Si l’URL est correcte, contactez l’administrateur de votre serveur d’accueil pour obtenir de l’aide."
- "Sliding sync n’est pas disponible en raison d’un problème dans le well-known file : %1$s"
+ "Sliding sync n’est pas disponible en raison d’un problème dans le well-known file :
+%1$s"
"Ce serveur ne prend actuellement pas en charge la synchronisation glissante."
"URL du serveur d’accueil"
"Vous ne pouvez vous connecter qu’à un serveur existant qui prend en charge le sliding sync. L’administrateur de votre serveur d’accueil devra le configurer. %1$s"
diff --git a/features/logout/impl/src/main/res/values-de/translations.xml b/features/logout/impl/src/main/res/values-de/translations.xml
index a93dc95b54..eeec111177 100644
--- a/features/logout/impl/src/main/res/values-de/translations.xml
+++ b/features/logout/impl/src/main/res/values-de/translations.xml
@@ -5,7 +5,7 @@
"Abmelden"
"Abmelden…"
"Du bist dabei, dich von deiner letzten Sitzung abzumelden. Wenn du dich jetzt abmeldest, verlierst du den Zugriff auf deine verschlüsselten Nachrichten."
- "Du hast das Backup ausgeschaltet"
+ "Du hast das Backup deaktiviert."
"Deine Schlüssel wurden noch gesichert, als du offline gegangen bist. Stelle die Verbindung wieder her, damit deine Schlüssel gesichert werden können, bevor du dich abmeldest."
"Deine Schlüssel werden noch gesichert"
"Bitte warte, bis der Vorgang abgeschlossen ist, bevor du dich abmeldest."
diff --git a/features/logout/impl/src/main/res/values-sv/translations.xml b/features/logout/impl/src/main/res/values-sv/translations.xml
index 019939ac0a..fdf0e5102e 100644
--- a/features/logout/impl/src/main/res/values-sv/translations.xml
+++ b/features/logout/impl/src/main/res/values-sv/translations.xml
@@ -4,5 +4,15 @@
"Logga ut"
"Logga ut"
"Loggar ut …"
+ "Du är på väg att logga ut ur din senaste session. Om du loggar ut nu kommer du att förlora åtkomsten till dina krypterade meddelanden."
+ "Du har stängt av säkerhetskopiering"
+ "Dina nycklar säkerhetskopierades fortfarande när du gick offline. Anslut igen så att dina nycklar kan säkerhetskopieras innan du loggar ut."
+ "Dina nycklar säkerhetskopieras fortfarande"
+ "Vänta tills detta är klart innan du loggar ut."
+ "Dina nycklar säkerhetskopieras fortfarande"
"Logga ut"
+ "Du är på väg att logga ut ur din sista session. Om du loggar ut nu förlorar du åtkomsten till dina krypterade meddelanden."
+ "Återställning inte inställd"
+ "Du är på väg att logga ut från din senaste session. Om du loggar ut nu kan du förlora åtkomsten till dina krypterade meddelanden."
+ "Har du sparat din återställningsnyckel?"
diff --git a/features/messages/api/src/main/kotlin/io/element/android/features/messages/api/MessagesEntryPoint.kt b/features/messages/api/src/main/kotlin/io/element/android/features/messages/api/MessagesEntryPoint.kt
index 482dfad8ea..15d6e5fd95 100644
--- a/features/messages/api/src/main/kotlin/io/element/android/features/messages/api/MessagesEntryPoint.kt
+++ b/features/messages/api/src/main/kotlin/io/element/android/features/messages/api/MessagesEntryPoint.kt
@@ -20,19 +20,28 @@ import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.plugin.Plugin
import io.element.android.libraries.architecture.FeatureEntryPoint
+import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.UserId
+import io.element.android.libraries.matrix.api.permalink.PermalinkData
interface MessagesEntryPoint : FeatureEntryPoint {
- fun createNode(
- parentNode: Node,
- buildContext: BuildContext,
- callback: Callback,
- ): Node
+ fun nodeBuilder(parentNode: Node, buildContext: BuildContext): NodeBuilder
+
+ interface NodeBuilder {
+ fun params(params: Params): NodeBuilder
+ fun callback(callback: Callback): NodeBuilder
+ fun build(): Node
+ }
+
+ data class Params(
+ val focusedEventId: EventId?,
+ )
interface Callback : Plugin {
fun onRoomDetailsClicked()
fun onUserDataClicked(userId: UserId)
+ fun onPermalinkClicked(data: PermalinkData)
fun onForwardedToSingleRoom(roomId: RoomId)
}
}
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/DefaultMessagesEntryPoint.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/DefaultMessagesEntryPoint.kt
index abf451b4b6..0f6a6358d3 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/DefaultMessagesEntryPoint.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/DefaultMessagesEntryPoint.kt
@@ -18,6 +18,7 @@ package io.element.android.features.messages.impl
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
+import com.bumble.appyx.core.plugin.Plugin
import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.features.messages.api.MessagesEntryPoint
import io.element.android.libraries.architecture.createNode
@@ -26,11 +27,23 @@ import javax.inject.Inject
@ContributesBinding(AppScope::class)
class DefaultMessagesEntryPoint @Inject constructor() : MessagesEntryPoint {
- override fun createNode(
- parentNode: Node,
- buildContext: BuildContext,
- callback: MessagesEntryPoint.Callback
- ): Node {
- return parentNode.createNode(buildContext, listOf(callback))
+ override fun nodeBuilder(parentNode: Node, buildContext: BuildContext): MessagesEntryPoint.NodeBuilder {
+ val plugins = ArrayList()
+
+ return object : MessagesEntryPoint.NodeBuilder {
+ override fun params(params: MessagesEntryPoint.Params): MessagesEntryPoint.NodeBuilder {
+ plugins += MessagesFlowNode.Inputs(focusedEventId = params.focusedEventId)
+ return this
+ }
+
+ override fun callback(callback: MessagesEntryPoint.Callback): MessagesEntryPoint.NodeBuilder {
+ plugins += callback
+ return this
+ }
+
+ override fun build(): Node {
+ return parentNode.createNode(buildContext, plugins)
+ }
+ }
}
}
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesFlowNode.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesFlowNode.kt
index 3cf480f7d1..ca35ac7fee 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesFlowNode.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesFlowNode.kt
@@ -52,7 +52,9 @@ import io.element.android.features.poll.api.create.CreatePollEntryPoint
import io.element.android.features.poll.api.create.CreatePollMode
import io.element.android.libraries.architecture.BackstackWithOverlayBox
import io.element.android.libraries.architecture.BaseFlowNode
+import io.element.android.libraries.architecture.NodeInputs
import io.element.android.libraries.architecture.createNode
+import io.element.android.libraries.architecture.inputs
import io.element.android.libraries.architecture.overlay.Overlay
import io.element.android.libraries.architecture.overlay.operation.show
import io.element.android.libraries.di.ApplicationContext
@@ -62,6 +64,7 @@ import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.media.MediaSource
+import io.element.android.libraries.matrix.api.permalink.PermalinkData
import io.element.android.libraries.matrix.api.timeline.item.TimelineItemDebugInfo
import io.element.android.libraries.mediaviewer.api.local.MediaInfo
import io.element.android.libraries.mediaviewer.api.viewer.MediaViewerNode
@@ -88,6 +91,9 @@ class MessagesFlowNode @AssistedInject constructor(
buildContext = buildContext,
plugins = plugins
) {
+ data class Inputs(val focusedEventId: EventId?) : NodeInputs
+ private val inputs = inputs()
+
sealed interface NavTarget : Parcelable {
@Parcelize
data object Empty : NavTarget
@@ -149,6 +155,10 @@ class MessagesFlowNode @AssistedInject constructor(
callback?.onUserDataClicked(userId)
}
+ override fun onPermalinkClicked(data: PermalinkData) {
+ callback?.onPermalinkClicked(data)
+ }
+
override fun onShowEventDebugInfoClicked(eventId: EventId?, debugInfo: TimelineItemDebugInfo) {
backstack.push(NavTarget.EventDebugInfo(eventId, debugInfo))
}
@@ -181,7 +191,10 @@ class MessagesFlowNode @AssistedInject constructor(
ElementCallActivity.start(context, inputs)
}
}
- createNode(buildContext, listOf(callback))
+ val inputs = MessagesNode.Inputs(
+ focusedEventId = inputs.focusedEventId,
+ )
+ createNode(buildContext, listOf(callback, inputs))
}
is NavTarget.MediaViewer -> {
val inputs = MediaViewerNode.Inputs(
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesNode.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesNode.kt
index ff8c727f9c..f3c3462cd8 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesNode.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesNode.kt
@@ -19,6 +19,11 @@ package io.element.android.features.messages.impl
import android.content.Context
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.saveable.rememberSaveable
+import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import com.bumble.appyx.core.lifecycle.subscribe
@@ -30,11 +35,17 @@ import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import io.element.android.anvilannotations.ContributesNode
import io.element.android.features.messages.impl.attachments.Attachment
+import io.element.android.features.messages.impl.timeline.TimelineController
+import io.element.android.features.messages.impl.timeline.TimelineEvents
import io.element.android.features.messages.impl.timeline.di.LocalTimelineItemPresenterFactories
import io.element.android.features.messages.impl.timeline.di.TimelineItemPresenterFactories
import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.libraries.androidutils.system.openUrlInExternalApp
+import io.element.android.libraries.androidutils.system.toast
+import io.element.android.libraries.architecture.NodeInputs
+import io.element.android.libraries.architecture.inputs
import io.element.android.libraries.core.bool.orFalse
+import io.element.android.libraries.di.ApplicationContext
import io.element.android.libraries.di.RoomScope
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.RoomId
@@ -42,6 +53,7 @@ import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.permalink.PermalinkData
import io.element.android.libraries.matrix.api.permalink.PermalinkParser
import io.element.android.libraries.matrix.api.room.MatrixRoom
+import io.element.android.libraries.matrix.api.room.alias.matches
import io.element.android.libraries.matrix.api.timeline.item.TimelineItemDebugInfo
import io.element.android.libraries.mediaplayer.api.MediaPlayer
import io.element.android.services.analytics.api.AnalyticsService
@@ -58,15 +70,23 @@ class MessagesNode @AssistedInject constructor(
private val timelineItemPresenterFactories: TimelineItemPresenterFactories,
private val mediaPlayer: MediaPlayer,
private val permalinkParser: PermalinkParser,
+ @ApplicationContext
+ private val context: Context,
+ private val timelineController: TimelineController,
) : Node(buildContext, plugins = plugins), MessagesNavigator {
private val presenter = presenterFactory.create(this)
private val callback = plugins().firstOrNull()
+ data class Inputs(val focusedEventId: EventId?) : NodeInputs
+
+ private val inputs = inputs()
+
interface Callback : Plugin {
fun onRoomDetailsClicked()
fun onEventClicked(event: TimelineItem.Event): Boolean
fun onPreviewAttachments(attachments: ImmutableList)
fun onUserDataClicked(userId: UserId)
+ fun onPermalinkClicked(data: PermalinkData)
fun onShowEventDebugInfoClicked(eventId: EventId?, debugInfo: TimelineItemDebugInfo)
fun onForwardEventClicked(eventId: EventId)
fun onReportMessage(eventId: EventId, senderId: UserId)
@@ -76,12 +96,14 @@ class MessagesNode @AssistedInject constructor(
fun onJoinCallClicked(roomId: RoomId)
}
- init {
+ override fun onBuilt() {
+ super.onBuilt()
lifecycle.subscribe(
onCreate = {
analyticsService.capture(room.toAnalyticsViewRoom())
},
onDestroy = {
+ timelineController.close()
mediaPlayer.close()
}
)
@@ -106,19 +128,16 @@ class MessagesNode @AssistedInject constructor(
private fun onLinkClicked(
context: Context,
url: String,
+ eventSink: (TimelineEvents) -> Unit,
) {
when (val permalink = permalinkParser.parse(url)) {
is PermalinkData.UserLink -> {
+ // Open the room member profile, it will fallback to
+ // the user profile if the user is not in the room
callback?.onUserDataClicked(permalink.userId)
}
is PermalinkData.RoomLink -> {
- // TODO Implement room link handling
- }
- is PermalinkData.EventIdAliasLink -> {
- // TODO Implement room and Event link handling
- }
- is PermalinkData.EventIdLink -> {
- // TODO Implement room and Event link handling
+ handleRoomLinkClicked(permalink, eventSink)
}
is PermalinkData.FallbackLink,
is PermalinkData.RoomEmailInviteLink -> {
@@ -127,6 +146,20 @@ class MessagesNode @AssistedInject constructor(
}
}
+ private fun handleRoomLinkClicked(roomLink: PermalinkData.RoomLink, eventSink: (TimelineEvents) -> Unit) {
+ if (room.matches(roomLink.roomIdOrAlias)) {
+ val eventId = roomLink.eventId
+ if (eventId != null) {
+ eventSink(TimelineEvents.FocusOnEvent(eventId))
+ } else {
+ // Click on the same room, ignore
+ context.toast("Already viewing this room!")
+ }
+ } else {
+ callback?.onPermalinkClicked(roomLink)
+ }
+ }
+
override fun onShowEventDebugInfoClicked(eventId: EventId?, debugInfo: TimelineItemDebugInfo) {
callback?.onShowEventDebugInfoClicked(eventId, debugInfo)
}
@@ -169,12 +202,23 @@ class MessagesNode @AssistedInject constructor(
onEventClicked = this::onEventClicked,
onPreviewAttachments = this::onPreviewAttachments,
onUserDataClicked = this::onUserDataClicked,
- onLinkClicked = { onLinkClicked(context, it) },
+ onLinkClicked = { onLinkClicked(context, it, state.timelineState.eventSink) },
onSendLocationClicked = this::onSendLocationClicked,
onCreatePollClicked = this::onCreatePollClicked,
onJoinCallClicked = this::onJoinCallClicked,
modifier = modifier,
)
+
+ var focusedEventId by rememberSaveable {
+ mutableStateOf(inputs.focusedEventId)
+ }
+ LaunchedEffect(Unit) {
+ focusedEventId?.also { eventId ->
+ state.timelineState.eventSink(TimelineEvents.FocusOnEvent(eventId))
+ }
+ // Reset the focused event id to null to avoid refocusing when restoring node.
+ focusedEventId = null
+ }
}
}
}
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesPresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesPresenter.kt
index 4edc943a73..e5127ea865 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesPresenter.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesPresenter.kt
@@ -38,6 +38,7 @@ import io.element.android.features.messages.impl.actionlist.model.TimelineItemAc
import io.element.android.features.messages.impl.messagecomposer.MessageComposerEvents
import io.element.android.features.messages.impl.messagecomposer.MessageComposerPresenter
import io.element.android.features.messages.impl.messagecomposer.MessageComposerState
+import io.element.android.features.messages.impl.timeline.TimelineController
import io.element.android.features.messages.impl.timeline.TimelineEvents
import io.element.android.features.messages.impl.timeline.TimelinePresenter
import io.element.android.features.messages.impl.timeline.TimelineState
@@ -85,6 +86,7 @@ import io.element.android.libraries.matrix.api.room.MatrixRoomMembersState
import io.element.android.libraries.matrix.api.room.MessageEventType
import io.element.android.libraries.matrix.ui.components.AttachmentThumbnailInfo
import io.element.android.libraries.matrix.ui.components.AttachmentThumbnailType
+import io.element.android.libraries.matrix.ui.room.canCall
import io.element.android.libraries.matrix.ui.room.canRedactOtherAsState
import io.element.android.libraries.matrix.ui.room.canRedactOwnAsState
import io.element.android.libraries.matrix.ui.room.canSendMessageAsState
@@ -116,6 +118,7 @@ class MessagesPresenter @AssistedInject constructor(
private val htmlConverterProvider: HtmlConverterProvider,
@Assisted private val navigator: MessagesNavigator,
private val buildMeta: BuildMeta,
+ private val timelineController: TimelineController,
) : Presenter {
private val timelinePresenter = timelinePresenterFactory.create(navigator = navigator)
@@ -156,9 +159,7 @@ class MessagesPresenter @AssistedInject constructor(
mutableStateOf(false)
}
- var canJoinCall by rememberSaveable {
- mutableStateOf(false)
- }
+ val canJoinCall by room.canCall(updateKey = syncUpdateFlow.value)
LaunchedEffect(Unit) {
// Remove the unread flag on entering but don't send read receipts
@@ -168,12 +169,6 @@ class MessagesPresenter @AssistedInject constructor(
}
}
- LaunchedEffect(syncUpdateFlow.value) {
- withContext(dispatchers.io) {
- canJoinCall = room.canUserJoinCall(room.sessionId).getOrDefault(false)
- }
- }
-
val inviteProgress = remember { mutableStateOf>(AsyncData.Uninitialized) }
var showReinvitePrompt by remember { mutableStateOf(false) }
LaunchedEffect(hasDismissedInviteDialog, composerState.hasFocus, syncUpdateFlow.value) {
@@ -185,10 +180,6 @@ class MessagesPresenter @AssistedInject constructor(
val snackbarMessage by snackbarDispatcher.collectSnackbarMessageAsState()
- LaunchedEffect(composerState.mode.relatedEventId) {
- timelineState.eventSink(TimelineEvents.SetHighlightedEvent(composerState.mode.relatedEventId))
- }
-
val enableTextFormatting by appPreferencesStore.isRichTextEditorEnabledFlow().collectAsState(initial = true)
var enableVoiceMessages by remember { mutableStateOf(false) }
@@ -258,7 +249,7 @@ class MessagesPresenter @AssistedInject constructor(
private fun MatrixRoomInfo.avatarData(): AvatarData {
return AvatarData(
- id = id,
+ id = id.value,
name = name,
url = avatarUrl ?: room.avatarUrl,
size = AvatarSize.TimelineRoom
@@ -290,8 +281,10 @@ class MessagesPresenter @AssistedInject constructor(
emoji: String,
eventId: EventId,
) = launch(dispatchers.io) {
- room.toggleReaction(emoji, eventId)
- .onFailure { Timber.e(it) }
+ timelineController.invokeOnCurrentTimeline {
+ toggleReaction(emoji, eventId)
+ .onFailure { Timber.e(it) }
+ }
}
private fun CoroutineScope.reinviteOtherUser(inviteProgress: MutableState>) = launch(dispatchers.io) {
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesStateProvider.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesStateProvider.kt
index 00f02aed5d..acd7e86d58 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesStateProvider.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesStateProvider.kt
@@ -106,6 +106,8 @@ fun aMessagesState(
voiceMessageComposerState: VoiceMessageComposerState = aVoiceMessageComposerState(),
timelineState: TimelineState = aTimelineState(
timelineItems = aTimelineItemList(aTimelineItemTextContent()),
+ // Render a focused event for an event with sender information displayed
+ focusedEventIndex = 2,
),
retrySendMenuState: RetrySendMenuState = aRetrySendMenuState(),
readReceiptBottomSheetState: ReadReceiptBottomSheetState = aReadReceiptBottomSheetState(),
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesView.kt
index 9cf4375769..597fd4b8c9 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesView.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesView.kt
@@ -382,20 +382,19 @@ private fun MessagesViewContent(
},
content = { paddingValues ->
TimelineView(
- modifier = Modifier.padding(paddingValues),
state = state.timelineState,
- roomName = state.roomName.dataOrNull(),
typingNotificationState = state.typingNotificationState,
- onMessageClicked = onMessageClicked,
- onMessageLongClicked = onMessageLongClicked,
onUserDataClicked = onUserDataClicked,
onLinkClicked = onLinkClicked,
+ onMessageClicked = onMessageClicked,
+ onMessageLongClicked = onMessageLongClicked,
onTimestampClicked = onTimestampClicked,
+ onSwipeToReply = onSwipeToReply,
onReactionClicked = onReactionClicked,
onReactionLongClicked = onReactionLongClicked,
onMoreReactionsClicked = onMoreReactionsClicked,
onReadReceiptClick = onReadReceiptClick,
- onSwipeToReply = onSwipeToReply,
+ modifier = Modifier.padding(paddingValues),
forceJumpToBottomVisibility = forceJumpToBottomVisibility,
)
},
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/ActionListStateProvider.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/ActionListStateProvider.kt
index be78037d76..48c44e38c9 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/ActionListStateProvider.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/ActionListStateProvider.kt
@@ -48,8 +48,11 @@ open class ActionListStateProvider : PreviewParameterProvider {
),
anActionListState().copy(
target = ActionListState.Target.Success(
- event = aTimelineItemEvent(content = aTimelineItemImageContent()).copy(
- reactionsState = reactionsState
+ event = aTimelineItemEvent(
+ content = aTimelineItemImageContent(),
+ displayNameAmbiguous = true,
+ ).copy(
+ reactionsState = reactionsState,
),
displayEmojiReactions = true,
actions = aTimelineItemActionList(),
@@ -142,6 +145,7 @@ fun aTimelineItemActionList(): ImmutableList {
TimelineItemAction.ViewSource,
)
}
+
fun aTimelineItemPollActionList(): ImmutableList {
return persistentListOf(
TimelineItemAction.EndPoll,
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/ActionListView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/ActionListView.kt
index d27263f989..2fe6a0cd2b 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/ActionListView.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/ActionListView.kt
@@ -55,6 +55,8 @@ import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme
import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.features.messages.impl.actionlist.model.TimelineItemAction
+import io.element.android.features.messages.impl.sender.SenderName
+import io.element.android.features.messages.impl.sender.SenderNameMode
import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemAudioContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEncryptedContent
@@ -268,15 +270,11 @@ private fun MessageSummary(event: TimelineItem.Event, modifier: Modifier = Modif
icon()
Spacer(modifier = Modifier.width(8.dp))
Column(modifier = Modifier.weight(1f)) {
- Row {
- if (event.senderDisplayName != null) {
- Text(
- text = event.senderDisplayName,
- style = ElementTheme.typography.fontBodySmMedium,
- color = MaterialTheme.colorScheme.primary
- )
- }
- }
+ SenderName(
+ senderId = event.senderId,
+ senderProfile = event.senderProfile,
+ senderNameMode = SenderNameMode.ActionList,
+ )
content()
}
Spacer(modifier = Modifier.width(16.dp))
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/forward/ForwardMessagesPresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/forward/ForwardMessagesPresenter.kt
index 367a071084..9cfdbcd14d 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/forward/ForwardMessagesPresenter.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/forward/ForwardMessagesPresenter.kt
@@ -29,7 +29,8 @@ import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.RoomId
-import io.element.android.libraries.matrix.api.room.MatrixRoom
+import io.element.android.libraries.matrix.api.timeline.TimelineProvider
+import io.element.android.libraries.matrix.api.timeline.getActiveTimeline
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toPersistentList
import kotlinx.coroutines.CoroutineScope
@@ -37,8 +38,8 @@ import kotlinx.coroutines.launch
class ForwardMessagesPresenter @AssistedInject constructor(
@Assisted eventId: String,
- private val room: MatrixRoom,
private val matrixCoroutineScope: CoroutineScope,
+ private val timelineProvider: TimelineProvider,
) : Presenter {
private val eventId: EventId = EventId(eventId)
@@ -79,7 +80,7 @@ class ForwardMessagesPresenter @AssistedInject constructor(
isForwardMessagesState: MutableState>>,
) = launch {
isForwardMessagesState.value = AsyncData.Loading()
- room.forwardEvent(eventId, roomIds).fold(
+ timelineProvider.getActiveTimeline().forwardEvent(eventId, roomIds).fold(
{ isForwardMessagesState.value = AsyncData.Success(roomIds) },
{ isForwardMessagesState.value = AsyncData.Failure(it) }
)
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/mentions/MentionSuggestionsPickerView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/mentions/MentionSuggestionsPickerView.kt
index fd46405fe6..cf48ea1478 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/mentions/MentionSuggestionsPickerView.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/mentions/MentionSuggestionsPickerView.kt
@@ -141,7 +141,7 @@ private fun RoomMemberSuggestionItemView(
@PreviewsDayNight
@Composable
-internal fun MentionSuggestionsPickerView_Preview() {
+internal fun MentionSuggestionsPickerViewPreview() {
ElementPreview {
val roomMember = RoomMember(
userId = UserId("@alice:server.org"),
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenter.kt
index 04c182a566..30bdadcbe5 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenter.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenter.kt
@@ -38,6 +38,7 @@ import io.element.android.features.messages.impl.attachments.Attachment
import io.element.android.features.messages.impl.attachments.preview.error.sendAttachmentError
import io.element.android.features.messages.impl.mentions.MentionSuggestion
import io.element.android.features.messages.impl.mentions.MentionSuggestionsProcessor
+import io.element.android.features.messages.impl.timeline.TimelineController
import io.element.android.features.preferences.api.store.SessionPreferencesStore
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher
@@ -100,6 +101,7 @@ class MessageComposerPresenter @Inject constructor(
private val permalinkParser: PermalinkParser,
private val permalinkBuilder: PermalinkBuilder,
permissionsPresenterFactory: PermissionsPresenter.Factory,
+ private val timelineController: TimelineController,
) : Presenter {
private val cameraPermissionPresenter = permissionsPresenterFactory.create(Manifest.permission.CAMERA)
private var pendingEvent: MessageComposerEvents? = null
@@ -264,7 +266,9 @@ class MessageComposerPresenter @Inject constructor(
is MessageComposerMode.Quote -> null
}.let { relatedEventId ->
appCoroutineScope.launch {
- room.enterSpecialMode(relatedEventId)
+ timelineController.invokeOnCurrentTimeline {
+ enterSpecialMode(relatedEventId)
+ }
}
}
}
@@ -386,16 +390,17 @@ class MessageComposerPresenter @Inject constructor(
is MessageComposerMode.Edit -> {
val eventId = capturedMode.eventId
val transactionId = capturedMode.transactionId
- room.editMessage(eventId, transactionId, message.markdown, message.html, mentions)
+ timelineController.invokeOnCurrentTimeline {
+ editMessage(eventId, transactionId, message.markdown, message.html, mentions)
+ }
}
is MessageComposerMode.Quote -> TODO()
- is MessageComposerMode.Reply -> room.replyMessage(
- capturedMode.eventId,
- message.markdown,
- message.html,
- mentions
- )
+ is MessageComposerMode.Reply -> {
+ timelineController.invokeOnCurrentTimeline {
+ replyMessage(capturedMode.eventId, message.markdown, message.html, mentions)
+ }
+ }
}
analyticsService.capture(
Composer(
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/sender/SenderName.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/sender/SenderName.kt
new file mode 100644
index 0000000000..9daa95198e
--- /dev/null
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/sender/SenderName.kt
@@ -0,0 +1,136 @@
+/*
+ * Copyright (c) 2024 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.element.android.features.messages.impl.sender
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.RowScope
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clipToBounds
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.tooling.preview.PreviewParameter
+import androidx.compose.ui.unit.dp
+import io.element.android.compound.theme.ElementTheme
+import io.element.android.libraries.designsystem.preview.ElementPreview
+import io.element.android.libraries.designsystem.preview.PreviewsDayNight
+import io.element.android.libraries.designsystem.theme.components.Text
+import io.element.android.libraries.matrix.api.core.UserId
+import io.element.android.libraries.matrix.api.timeline.item.event.ProfileTimelineDetails
+
+// https://www.figma.com/file/Ni6Ii8YKtmXCKYNE90cC67/Timeline-(new)?type=design&node-id=917-80169&mode=design&t=A0CJCBbMqR8NOwUQ-0
+@Composable
+fun SenderName(
+ senderId: UserId,
+ senderProfile: ProfileTimelineDetails,
+ senderNameMode: SenderNameMode,
+ modifier: Modifier = Modifier,
+) {
+ Row(
+ modifier = modifier,
+ horizontalArrangement = Arrangement.spacedBy(4.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ when (senderProfile) {
+ is ProfileTimelineDetails.Error,
+ ProfileTimelineDetails.Pending,
+ ProfileTimelineDetails.Unavailable -> {
+ MainText(text = senderId.value, mode = senderNameMode)
+ }
+ is ProfileTimelineDetails.Ready -> {
+ val displayName = senderProfile.displayName
+ if (displayName.isNullOrEmpty()) {
+ MainText(text = senderId.value, mode = senderNameMode)
+ } else {
+ MainText(text = displayName, mode = senderNameMode)
+ if (senderProfile.displayNameAmbiguous) {
+ SecondaryText(text = senderId.value, mode = senderNameMode)
+ }
+ }
+ }
+ }
+ }
+}
+
+@Composable
+private fun RowScope.MainText(
+ text: String,
+ mode: SenderNameMode,
+) {
+ val style = when (mode) {
+ is SenderNameMode.Timeline -> ElementTheme.typography.fontBodyMdMedium
+ SenderNameMode.ActionList,
+ SenderNameMode.Reply -> ElementTheme.typography.fontBodySmMedium
+ }
+ val modifier = when (mode) {
+ is SenderNameMode.Timeline -> Modifier.alignByBaseline()
+ SenderNameMode.ActionList,
+ SenderNameMode.Reply -> Modifier
+ }
+ val color = when (mode) {
+ is SenderNameMode.Timeline -> mode.mainColor
+ SenderNameMode.ActionList,
+ SenderNameMode.Reply -> MaterialTheme.colorScheme.primary
+ }
+ Text(
+ modifier = modifier.clipToBounds(),
+ text = text,
+ style = style,
+ color = color,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ )
+}
+
+@Composable
+private fun RowScope.SecondaryText(
+ text: String,
+ mode: SenderNameMode,
+) {
+ val style = when (mode) {
+ is SenderNameMode.Timeline -> ElementTheme.typography.fontBodySmRegular
+ SenderNameMode.ActionList,
+ SenderNameMode.Reply -> ElementTheme.typography.fontBodyXsRegular
+ }
+ val modifier = when (mode) {
+ is SenderNameMode.Timeline -> Modifier.alignByBaseline()
+ SenderNameMode.ActionList,
+ SenderNameMode.Reply -> Modifier
+ }
+ Text(
+ modifier = modifier.clipToBounds(),
+ text = text,
+ style = style,
+ color = MaterialTheme.colorScheme.secondary,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ )
+}
+
+@PreviewsDayNight
+@Composable
+internal fun SenderNamePreview(
+ @PreviewParameter(SenderNameDataProvider::class) senderNameData: SenderNameData,
+) = ElementPreview {
+ SenderName(
+ senderId = senderNameData.userId,
+ senderProfile = senderNameData.profileTimelineDetails,
+ senderNameMode = senderNameData.senderNameMode,
+ )
+}
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/sender/SenderNameDataProvider.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/sender/SenderNameDataProvider.kt
new file mode 100644
index 0000000000..138038bb2f
--- /dev/null
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/sender/SenderNameDataProvider.kt
@@ -0,0 +1,66 @@
+/*
+ * Copyright (c) 2024 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.element.android.features.messages.impl.sender
+
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.tooling.preview.PreviewParameterProvider
+import io.element.android.features.messages.impl.timeline.components.aProfileTimelineDetailsReady
+import io.element.android.libraries.matrix.api.core.UserId
+import io.element.android.libraries.matrix.api.timeline.item.event.ProfileTimelineDetails
+
+data class SenderNameData(
+ val userId: UserId,
+ val profileTimelineDetails: ProfileTimelineDetails,
+ val senderNameMode: SenderNameMode,
+)
+
+open class SenderNameDataProvider : PreviewParameterProvider {
+ override val values: Sequence
+ get() = sequenceOf(
+ SenderNameMode.Timeline(mainColor = Color.Red),
+ SenderNameMode.Reply,
+ SenderNameMode.ActionList,
+ )
+ .flatMap { senderNameMode ->
+ sequenceOf(
+ aSenderNameData(
+ senderNameMode = senderNameMode,
+ ),
+ aSenderNameData(
+ senderNameMode = senderNameMode,
+ displayNameAmbiguous = true,
+ ),
+ SenderNameData(
+ senderNameMode = senderNameMode,
+ userId = UserId("@alice:${senderNameMode.javaClass.simpleName.lowercase()}"),
+ profileTimelineDetails = ProfileTimelineDetails.Unavailable,
+ ),
+ )
+ }
+}
+
+private fun aSenderNameData(
+ senderNameMode: SenderNameMode,
+ displayNameAmbiguous: Boolean = false,
+) = SenderNameData(
+ userId = UserId("@alice:${senderNameMode.javaClass.simpleName.lowercase()}"),
+ profileTimelineDetails = aProfileTimelineDetailsReady(
+ displayName = "Alice ${senderNameMode.javaClass.simpleName}",
+ displayNameAmbiguous = displayNameAmbiguous,
+ ),
+ senderNameMode = senderNameMode,
+)
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/sender/SenderNameMode.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/sender/SenderNameMode.kt
new file mode 100644
index 0000000000..6b83480650
--- /dev/null
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/sender/SenderNameMode.kt
@@ -0,0 +1,25 @@
+/*
+ * Copyright (c) 2024 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.element.android.features.messages.impl.sender
+
+import androidx.compose.ui.graphics.Color
+
+sealed interface SenderNameMode {
+ data class Timeline(val mainColor: Color) : SenderNameMode
+ data object Reply : SenderNameMode
+ data object ActionList : SenderNameMode
+}
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineController.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineController.kt
new file mode 100644
index 0000000000..1352124454
--- /dev/null
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineController.kt
@@ -0,0 +1,136 @@
+/*
+ * Copyright (c) 2024 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.element.android.features.messages.impl.timeline
+
+import com.squareup.anvil.annotations.ContributesBinding
+import io.element.android.libraries.di.RoomScope
+import io.element.android.libraries.di.SingleIn
+import io.element.android.libraries.matrix.api.core.EventId
+import io.element.android.libraries.matrix.api.room.MatrixRoom
+import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem
+import io.element.android.libraries.matrix.api.timeline.Timeline
+import io.element.android.libraries.matrix.api.timeline.TimelineProvider
+import kotlinx.coroutines.CancellationException
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.SupervisorJob
+import kotlinx.coroutines.cancel
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.SharingStarted
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.combine
+import kotlinx.coroutines.flow.flatMapLatest
+import kotlinx.coroutines.flow.flowOf
+import kotlinx.coroutines.flow.getAndUpdate
+import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.flow.stateIn
+import java.io.Closeable
+import java.util.Optional
+import javax.inject.Inject
+
+/**
+ * This controller is responsible of using the right timeline to display messages and make associated actions.
+ * It can be focused on the live timeline or on a detached timeline (focusing an unknown event).
+ */
+@SingleIn(RoomScope::class)
+@ContributesBinding(RoomScope::class, boundType = TimelineProvider::class)
+class TimelineController @Inject constructor(
+ private val room: MatrixRoom,
+) : Closeable, TimelineProvider {
+ private val coroutineScope = CoroutineScope(SupervisorJob())
+
+ private val liveTimeline = flowOf(room.liveTimeline)
+ private val detachedTimeline = MutableStateFlow>(Optional.empty())
+
+ @OptIn(ExperimentalCoroutinesApi::class)
+ fun timelineItems(): Flow> {
+ return currentTimelineFlow.flatMapLatest { it.timelineItems }
+ }
+
+ fun isLive(): Flow {
+ return detachedTimeline.map { !it.isPresent }
+ }
+
+ suspend fun invokeOnCurrentTimeline(block: suspend (Timeline.() -> Any)) {
+ currentTimelineFlow.value.run {
+ block(this)
+ }
+ }
+
+ suspend fun focusOnEvent(eventId: EventId): Result {
+ return room.timelineFocusedOnEvent(eventId)
+ .onFailure {
+ if (it is CancellationException) {
+ throw it
+ }
+ }
+ .map { newDetachedTimeline ->
+ detachedTimeline.getAndUpdate { current ->
+ if (current.isPresent) {
+ current.get().close()
+ }
+ Optional.of(newDetachedTimeline)
+ }
+ }
+ }
+
+ /**
+ * Makes sure the controller is focused on the live timeline.
+ * This does close the detached timeline if any.
+ */
+ fun focusOnLive() {
+ closeDetachedTimeline()
+ }
+
+ private fun closeDetachedTimeline() {
+ detachedTimeline.getAndUpdate {
+ when {
+ it.isPresent -> {
+ it.get().close()
+ Optional.empty()
+ }
+ else -> Optional.empty()
+ }
+ }
+ }
+
+ override fun close() {
+ coroutineScope.cancel()
+ closeDetachedTimeline()
+ }
+
+ suspend fun paginate(direction: Timeline.PaginationDirection): Result {
+ return currentTimelineFlow.value.paginate(direction)
+ .onSuccess { hasReachedEnd ->
+ if (direction == Timeline.PaginationDirection.FORWARDS && hasReachedEnd) {
+ focusOnLive()
+ }
+ }
+ }
+
+ private val currentTimelineFlow = combine(liveTimeline, detachedTimeline) { live, detached ->
+ when {
+ detached.isPresent -> detached.get()
+ else -> live
+ }
+ }.stateIn(coroutineScope, SharingStarted.Eagerly, room.liveTimeline)
+
+ override fun activeTimelineFlow(): StateFlow {
+ return currentTimelineFlow
+ }
+}
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineEvents.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineEvents.kt
index cf02664a98..8a5d3cd275 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineEvents.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineEvents.kt
@@ -17,17 +17,21 @@
package io.element.android.features.messages.impl.timeline
import io.element.android.libraries.matrix.api.core.EventId
+import io.element.android.libraries.matrix.api.timeline.Timeline
sealed interface TimelineEvents {
- data object LoadMore : TimelineEvents
- data class SetHighlightedEvent(val eventId: EventId?) : TimelineEvents
data class OnScrollFinished(val firstIndex: Int) : TimelineEvents
+ data class FocusOnEvent(val eventId: EventId) : TimelineEvents
+ data object ClearFocusRequestState : TimelineEvents
+ data object JumpToLive : TimelineEvents
/**
* Events coming from a timeline item.
*/
sealed interface EventFromTimelineItem : TimelineEvents
+ data class LoadMore(val direction: Timeline.PaginationDirection) : EventFromTimelineItem
+
/**
* Events coming from a poll item.
*/
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineItemIndexer.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineItemIndexer.kt
new file mode 100644
index 0000000000..d11de93079
--- /dev/null
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineItemIndexer.kt
@@ -0,0 +1,64 @@
+/*
+ * Copyright (c) 2024 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.element.android.features.messages.impl.timeline
+
+import io.element.android.features.messages.impl.timeline.model.TimelineItem
+import io.element.android.libraries.di.RoomScope
+import io.element.android.libraries.di.SingleIn
+import io.element.android.libraries.matrix.api.core.EventId
+import timber.log.Timber
+import javax.inject.Inject
+
+@SingleIn(RoomScope::class)
+class TimelineItemIndexer @Inject constructor() {
+ private val timelineEventsIndexes = mutableMapOf()
+
+ fun isKnown(eventId: EventId): Boolean {
+ return timelineEventsIndexes.containsKey(eventId).also {
+ Timber.d("$eventId isKnown = $it")
+ }
+ }
+
+ fun indexOf(eventId: EventId): Int {
+ return (timelineEventsIndexes[eventId] ?: -1).also {
+ Timber.d("indexOf $eventId= $it")
+ }
+ }
+
+ fun process(timelineItems: List) {
+ Timber.d("process ${timelineItems.size} items")
+ timelineEventsIndexes.clear()
+ timelineItems.forEachIndexed { index, timelineItem ->
+ when (timelineItem) {
+ is TimelineItem.Event -> {
+ processEvent(timelineItem, index)
+ }
+ is TimelineItem.GroupedEvents -> {
+ timelineItem.events.forEach { event ->
+ processEvent(event, index)
+ }
+ }
+ else -> Unit
+ }
+ }
+ }
+
+ private fun processEvent(event: TimelineItem.Event, index: Int) {
+ if (event.eventId == null) return
+ timelineEventsIndexes[event.eventId] = index
+ }
+}
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenter.kt
index 3cd525c614..d6a02959c0 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenter.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenter.kt
@@ -54,11 +54,9 @@ import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
-private const val BACK_PAGINATION_EVENT_LIMIT = 20
-private const val BACK_PAGINATION_PAGE_SIZE = 50
-
class TimelinePresenter @AssistedInject constructor(
private val timelineItemsFactory: TimelineItemsFactory,
+ private val timelineItemIndexer: TimelineItemIndexer,
private val room: MatrixRoom,
private val dispatchers: CoroutineDispatchers,
private val appScope: CoroutineScope,
@@ -67,50 +65,62 @@ class TimelinePresenter @AssistedInject constructor(
private val sendPollResponseAction: SendPollResponseAction,
private val endPollAction: EndPollAction,
private val sessionPreferencesStore: SessionPreferencesStore,
+ private val timelineController: TimelineController,
) : Presenter {
@AssistedFactory
interface Factory {
fun create(navigator: MessagesNavigator): TimelinePresenter
}
- private val timeline = room.timeline
-
@Composable
override fun present(): TimelineState {
val localScope = rememberCoroutineScope()
- val highlightedEventId: MutableState = rememberSaveable {
+ val focusedEventId: MutableState = rememberSaveable {
mutableStateOf(null)
}
+ val focusRequestState: MutableState = remember {
+ mutableStateOf(FocusRequestState.None)
+ }
val lastReadReceiptId = rememberSaveable { mutableStateOf(null) }
val timelineItems by timelineItemsFactory.collectItemsAsState()
- val paginationState by timeline.paginationState.collectAsState()
+
val syncUpdateFlow = room.syncUpdateFlow.collectAsState()
val userHasPermissionToSendMessage by room.canSendMessageAsState(type = MessageEventType.ROOM_MESSAGE, updateKey = syncUpdateFlow.value)
val userHasPermissionToSendReaction by room.canSendMessageAsState(type = MessageEventType.REACTION, updateKey = syncUpdateFlow.value)
val prevMostRecentItemId = rememberSaveable { mutableStateOf(null) }
- val newItemState = remember { mutableStateOf(NewEventState.None) }
+
+ val newEventState = remember { mutableStateOf(NewEventState.None) }
val isSendPublicReadReceiptsEnabled by sessionPreferencesStore.isSendPublicReadReceiptsEnabled().collectAsState(initial = true)
val renderReadReceipts by sessionPreferencesStore.isRenderReadReceiptsEnabled().collectAsState(initial = true)
+ val isLive by timelineController.isLive().collectAsState(initial = true)
fun handleEvents(event: TimelineEvents) {
when (event) {
- TimelineEvents.LoadMore -> localScope.paginateBackwards()
- is TimelineEvents.SetHighlightedEvent -> highlightedEventId.value = event.eventId
+ is TimelineEvents.LoadMore -> {
+ localScope.launch {
+ timelineController.paginate(direction = event.direction)
+ }
+ }
is TimelineEvents.OnScrollFinished -> {
- if (event.firstIndex == 0) {
- newItemState.value = NewEventState.None
+ if (isLive) {
+ if (event.firstIndex == 0) {
+ newEventState.value = NewEventState.None
+ }
+ println("## sendReadReceiptIfNeeded firstVisibleIndex: ${event.firstIndex}")
+ appScope.sendReadReceiptIfNeeded(
+ firstVisibleIndex = event.firstIndex,
+ timelineItems = timelineItems,
+ lastReadReceiptId = lastReadReceiptId,
+ readReceiptType = if (isSendPublicReadReceiptsEnabled) ReceiptType.READ else ReceiptType.READ_PRIVATE,
+ )
+ } else {
+ newEventState.value = NewEventState.None
}
- appScope.sendReadReceiptIfNeeded(
- firstVisibleIndex = event.firstIndex,
- timelineItems = timelineItems,
- lastReadReceiptId = lastReadReceiptId,
- readReceiptType = if (isSendPublicReadReceiptsEnabled) ReceiptType.READ else ReceiptType.READ_PRIVATE,
- )
}
is TimelineEvents.PollAnswerSelected -> appScope.launch {
sendPollResponseAction.execute(
@@ -123,28 +133,58 @@ class TimelinePresenter @AssistedInject constructor(
pollStartId = event.pollStartId,
)
}
- is TimelineEvents.PollEditClicked ->
+ is TimelineEvents.PollEditClicked -> {
navigator.onEditPollClicked(event.pollStartId)
+ }
+ is TimelineEvents.FocusOnEvent -> localScope.launch {
+ focusedEventId.value = event.eventId
+ if (timelineItemIndexer.isKnown(event.eventId)) {
+ val index = timelineItemIndexer.indexOf(event.eventId)
+ focusRequestState.value = FocusRequestState.Cached(index)
+ } else {
+ focusRequestState.value = FocusRequestState.Fetching
+ timelineController.focusOnEvent(event.eventId)
+ .fold(
+ onSuccess = {
+ focusRequestState.value = FocusRequestState.Fetched
+ },
+ onFailure = {
+ focusRequestState.value = FocusRequestState.Failure(it)
+ }
+ )
+ }
+ }
+ is TimelineEvents.ClearFocusRequestState -> {
+ focusRequestState.value = FocusRequestState.None
+ }
+ is TimelineEvents.JumpToLive -> {
+ timelineController.focusOnLive()
+ }
}
}
LaunchedEffect(timelineItems.size) {
- computeNewItemState(timelineItems, prevMostRecentItemId, newItemState)
+ computeNewItemState(timelineItems, prevMostRecentItemId, newEventState)
+ }
+
+ LaunchedEffect(timelineItems.size, focusRequestState.value, focusedEventId.value) {
+ val currentFocusedEventId = focusedEventId.value
+ if (focusRequestState.value is FocusRequestState.Fetched && currentFocusedEventId != null) {
+ if (timelineItemIndexer.isKnown(currentFocusedEventId)) {
+ val index = timelineItemIndexer.indexOf(currentFocusedEventId)
+ focusRequestState.value = FocusRequestState.Cached(index)
+ }
+ }
}
LaunchedEffect(Unit) {
- combine(timeline.timelineItems, room.membersStateFlow) { items, membersState ->
+ combine(timelineController.timelineItems(), room.membersStateFlow) { items, membersState ->
timelineItemsFactory.replaceWith(
timelineItems = items,
roomMembers = membersState.roomMembers().orEmpty()
)
items
}
- .onEach { timelineItems ->
- if (timelineItems.isEmpty()) {
- paginateBackwards()
- }
- }
.onEach(redactedVoiceMessageManager::onEachMatrixTimelineItem)
.launchIn(this)
}
@@ -152,6 +192,7 @@ class TimelinePresenter @AssistedInject constructor(
val timelineRoomInfo by remember {
derivedStateOf {
TimelineRoomInfo(
+ name = room.displayName,
isDm = room.isDm,
userHasPermissionToSendMessage = userHasPermissionToSendMessage,
userHasPermissionToSendReaction = userHasPermissionToSendReaction,
@@ -160,11 +201,12 @@ class TimelinePresenter @AssistedInject constructor(
}
return TimelineState(
timelineRoomInfo = timelineRoomInfo,
- highlightedEventId = highlightedEventId.value,
- paginationState = paginationState,
timelineItems = timelineItems,
renderReadReceipts = renderReadReceipts,
- newEventState = newItemState.value,
+ newEventState = newEventState.value,
+ isLive = isLive,
+ focusedEventId = focusedEventId.value,
+ focusRequestState = focusRequestState.value,
eventSink = { handleEvents(it) }
)
}
@@ -190,6 +232,7 @@ class TimelinePresenter @AssistedInject constructor(
newMostRecentItem is TimelineItem.Event &&
newMostRecentItem.origin != TimelineItemEventOrigin.PAGINATION &&
newMostRecentItemId != prevMostRecentItemIdValue
+
if (hasNewEvent) {
val newMostRecentEvent = newMostRecentItem as? TimelineItem.Event
// Scroll to bottom if the new event is from me, even if sent from another device
@@ -217,7 +260,7 @@ class TimelinePresenter @AssistedInject constructor(
val eventId = getLastEventIdBeforeOrAt(firstVisibleIndex, timelineItems)
if (eventId != null && eventId != lastReadReceiptId.value) {
lastReadReceiptId.value = eventId
- timeline.sendReadReceipt(eventId = eventId, receiptType = readReceiptType)
+ room.liveTimeline.sendReadReceipt(eventId = eventId, receiptType = readReceiptType)
}
}
}
@@ -231,8 +274,4 @@ class TimelinePresenter @AssistedInject constructor(
}
return null
}
-
- private fun CoroutineScope.paginateBackwards() = launch {
- timeline.paginateBackwards(BACK_PAGINATION_EVENT_LIMIT, BACK_PAGINATION_PAGE_SIZE)
- }
}
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineState.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineState.kt
index 4e2f9b8d42..c32dbad723 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineState.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineState.kt
@@ -20,7 +20,6 @@ import androidx.compose.runtime.Immutable
import io.element.android.features.messages.impl.timeline.model.NewEventState
import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.libraries.matrix.api.core.EventId
-import io.element.android.libraries.matrix.api.timeline.MatrixTimeline
import kotlinx.collections.immutable.ImmutableList
@Immutable
@@ -28,15 +27,28 @@ data class TimelineState(
val timelineItems: ImmutableList,
val timelineRoomInfo: TimelineRoomInfo,
val renderReadReceipts: Boolean,
- val highlightedEventId: EventId?,
- val paginationState: MatrixTimeline.PaginationState,
val newEventState: NewEventState,
- val eventSink: (TimelineEvents) -> Unit
-)
+ val isLive: Boolean,
+ val focusedEventId: EventId?,
+ val focusRequestState: FocusRequestState,
+ val eventSink: (TimelineEvents) -> Unit,
+) {
+ val hasAnyEvent = timelineItems.any { it is TimelineItem.Event }
+}
+
+@Immutable
+sealed interface FocusRequestState {
+ data object None : FocusRequestState
+ data class Cached(val index: Int) : FocusRequestState
+ data object Fetching : FocusRequestState
+ data object Fetched : FocusRequestState
+ data class Failure(val throwable: Throwable) : FocusRequestState
+}
@Immutable
data class TimelineRoomInfo(
val isDm: Boolean,
+ val name: String?,
val userHasPermissionToSendMessage: Boolean,
val userHasPermissionToSendReaction: Boolean,
)
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineStateProvider.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineStateProvider.kt
index ae7f62ebd7..b9e417b188 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineStateProvider.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineStateProvider.kt
@@ -16,6 +16,7 @@
package io.element.android.features.messages.impl.timeline
+import io.element.android.features.messages.impl.timeline.components.aProfileTimelineDetailsReady
import io.element.android.features.messages.impl.timeline.components.receipt.aReadReceiptData
import io.element.android.features.messages.impl.timeline.model.InReplyToDetails
import io.element.android.features.messages.impl.timeline.model.NewEventState
@@ -34,7 +35,6 @@ import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.TransactionId
import io.element.android.libraries.matrix.api.core.UserId
-import io.element.android.libraries.matrix.api.timeline.MatrixTimeline
import io.element.android.libraries.matrix.api.timeline.item.TimelineItemDebugInfo
import io.element.android.libraries.matrix.api.timeline.item.event.LocalEventSendState
import kotlinx.collections.immutable.ImmutableList
@@ -46,32 +46,22 @@ import kotlin.random.Random
fun aTimelineState(
timelineItems: ImmutableList = persistentListOf(),
- paginationState: MatrixTimeline.PaginationState = aPaginationState(),
renderReadReceipts: Boolean = false,
timelineRoomInfo: TimelineRoomInfo = aTimelineRoomInfo(),
+ focusedEventIndex: Int = -1,
+ isLive: Boolean = true,
eventSink: (TimelineEvents) -> Unit = {},
) = TimelineState(
timelineItems = timelineItems,
timelineRoomInfo = timelineRoomInfo,
- paginationState = paginationState,
renderReadReceipts = renderReadReceipts,
- highlightedEventId = null,
newEventState = NewEventState.None,
+ isLive = isLive,
+ focusedEventId = timelineItems.filterIsInstance().getOrNull(focusedEventIndex)?.eventId,
+ focusRequestState = FocusRequestState.None,
eventSink = eventSink,
)
-fun aPaginationState(
- isBackPaginating: Boolean = false,
- hasMoreToLoadBackwards: Boolean = true,
- beginningOfRoomReached: Boolean = false,
-): MatrixTimeline.PaginationState {
- return MatrixTimeline.PaginationState(
- isBackPaginating = isBackPaginating,
- hasMoreToLoadBackwards = hasMoreToLoadBackwards,
- beginningOfRoomReached = beginningOfRoomReached,
- )
-}
-
internal fun aTimelineItemList(content: TimelineItemEventContent): ImmutableList {
return persistentListOf(
// 3 items (First Middle Last) with isMine = false
@@ -131,6 +121,7 @@ internal fun aTimelineItemEvent(
isMine: Boolean = false,
isEditable: Boolean = false,
senderDisplayName: String = "Sender",
+ displayNameAmbiguous: Boolean = false,
content: TimelineItemEventContent = aTimelineItemTextContent(),
groupPosition: TimelineItemGroupPosition = TimelineItemGroupPosition.None,
sendState: LocalEventSendState? = null,
@@ -152,7 +143,10 @@ internal fun aTimelineItemEvent(
sentTime = "12:34",
isMine = isMine,
isEditable = isEditable,
- senderDisplayName = senderDisplayName,
+ senderProfile = aProfileTimelineDetailsReady(
+ displayName = senderDisplayName,
+ displayNameAmbiguous = displayNameAmbiguous,
+ ),
groupPosition = groupPosition,
localSendState = sendState,
inReplyTo = inReplyTo,
@@ -230,10 +224,12 @@ internal fun aGroupedEvents(
}
internal fun aTimelineRoomInfo(
+ name: String = "Room name",
isDm: Boolean = false,
userHasPermissionToSendMessage: Boolean = true,
) = TimelineRoomInfo(
isDm = isDm,
+ name = name,
userHasPermissionToSendMessage = userHasPermissionToSendMessage,
userHasPermissionToSendReaction = true,
)
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineView.kt
index 56a2676f43..3483c27f85 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineView.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineView.kt
@@ -55,10 +55,9 @@ import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme
import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.features.messages.impl.timeline.components.TimelineItemRow
-import io.element.android.features.messages.impl.timeline.components.virtual.TimelineItemRoomBeginningView
-import io.element.android.features.messages.impl.timeline.components.virtual.TimelineLoadingMoreIndicator
import io.element.android.features.messages.impl.timeline.di.LocalTimelineItemPresenterFactories
import io.element.android.features.messages.impl.timeline.di.aFakeTimelineItemPresenterFactories
+import io.element.android.features.messages.impl.timeline.focus.FocusRequestStateView
import io.element.android.features.messages.impl.timeline.model.NewEventState
import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEventContent
@@ -74,12 +73,12 @@ import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.ui.strings.CommonStrings
import kotlinx.coroutines.launch
+import kotlin.math.abs
@Composable
fun TimelineView(
state: TimelineState,
typingNotificationState: TypingNotificationState,
- roomName: String?,
onUserDataClicked: (UserId) -> Unit,
onLinkClicked: (String) -> Unit,
onMessageClicked: (TimelineItem.Event) -> Unit,
@@ -93,8 +92,8 @@ fun TimelineView(
modifier: Modifier = Modifier,
forceJumpToBottomVisibility: Boolean = false
) {
- fun onReachedLoadMore() {
- state.eventSink(TimelineEvents.LoadMore)
+ fun clearFocusRequestState() {
+ state.eventSink(TimelineEvents.ClearFocusRequestState)
}
fun onScrollFinishedAt(firstVisibleIndex: Int) {
@@ -109,9 +108,8 @@ fun TimelineView(
accessibilityManager.isTouchExplorationEnabled.not()
}
- @Suppress("UNUSED_PARAMETER")
fun inReplyToClicked(eventId: EventId) {
- // TODO implement this logic once we have support to 'jump to event X' in sliding sync
+ state.eventSink(TimelineEvents.FocusOnEvent(eventId))
}
// Animate alpha when timeline is first displayed, to avoid flashes or glitching when viewing rooms
@@ -123,8 +121,10 @@ fun TimelineView(
reverseLayout = useReverseLayout,
contentPadding = PaddingValues(vertical = 8.dp),
) {
- item {
- TypingNotificationView(state = typingNotificationState)
+ if (state.isLive) {
+ item {
+ TypingNotificationView(state = typingNotificationState)
+ }
}
items(
items = state.timelineItems,
@@ -137,7 +137,7 @@ fun TimelineView(
renderReadReceipts = state.renderReadReceipts,
isLastOutgoingMessage = (timelineItem as? TimelineItem.Event)?.isMine == true &&
state.timelineItems.first().identifier() == timelineItem.identifier(),
- highlightedItem = state.highlightedEventId?.value,
+ focusedEventId = state.focusedEventId,
onClick = onMessageClicked,
onLongClick = onMessageLongClicked,
onUserDataClick = onUserDataClicked,
@@ -152,28 +152,23 @@ fun TimelineView(
onSwipeToReply = onSwipeToReply,
)
}
- if (state.paginationState.hasMoreToLoadBackwards) {
- // Do not use key parameter to avoid wrong positioning
- item(contentType = "TimelineLoadingMoreIndicator") {
- TimelineLoadingMoreIndicator()
- LaunchedEffect(Unit) {
- onReachedLoadMore()
- }
- }
- }
- if (state.paginationState.beginningOfRoomReached && !state.timelineRoomInfo.isDm) {
- item(contentType = "BeginningOfRoomReached") {
- TimelineItemRoomBeginningView(roomName = roomName)
- }
- }
}
+ FocusRequestStateView(
+ focusRequestState = state.focusRequestState,
+ onClearFocusRequestState = ::clearFocusRequestState
+ )
+
TimelineScrollHelper(
- isTimelineEmpty = state.timelineItems.isEmpty(),
+ hasAnyEvent = state.hasAnyEvent,
lazyListState = lazyListState,
forceJumpToBottomVisibility = forceJumpToBottomVisibility,
newEventState = state.newEventState,
- onScrollFinishedAt = ::onScrollFinishedAt
+ isLive = state.isLive,
+ focusRequestState = state.focusRequestState,
+ onScrollFinishedAt = ::onScrollFinishedAt,
+ onClearFocusRequestState = ::clearFocusRequestState,
+ onJumpToLive = { state.eventSink(TimelineEvents.JumpToLive) },
)
}
}
@@ -181,17 +176,21 @@ fun TimelineView(
@Composable
private fun BoxScope.TimelineScrollHelper(
- isTimelineEmpty: Boolean,
+ hasAnyEvent: Boolean,
lazyListState: LazyListState,
newEventState: NewEventState,
+ isLive: Boolean,
forceJumpToBottomVisibility: Boolean,
+ focusRequestState: FocusRequestState,
+ onClearFocusRequestState: () -> Unit,
onScrollFinishedAt: (Int) -> Unit,
+ onJumpToLive: () -> Unit,
) {
val coroutineScope = rememberCoroutineScope()
val isScrollFinished by remember { derivedStateOf { !lazyListState.isScrollInProgress } }
val canAutoScroll by remember {
derivedStateOf {
- lazyListState.firstVisibleItemIndex < 3
+ lazyListState.firstVisibleItemIndex < 3 && isLive
}
}
@@ -205,16 +204,36 @@ private fun BoxScope.TimelineScrollHelper(
}
}
+ fun jumpToBottom() {
+ if (isLive) {
+ scrollToBottom()
+ } else {
+ onJumpToLive()
+ }
+ }
+
+ val latestOnClearFocusRequestState by rememberUpdatedState(onClearFocusRequestState)
+ LaunchedEffect(focusRequestState) {
+ if (focusRequestState is FocusRequestState.Cached) {
+ if (abs(lazyListState.firstVisibleItemIndex - focusRequestState.index) < 10) {
+ lazyListState.animateScrollToItem(focusRequestState.index)
+ } else {
+ lazyListState.scrollToItem(focusRequestState.index)
+ }
+ latestOnClearFocusRequestState()
+ }
+ }
+
LaunchedEffect(canAutoScroll, newEventState) {
- val shouldAutoScroll = isScrollFinished && (canAutoScroll || newEventState == NewEventState.FromMe)
- if (shouldAutoScroll) {
+ val shouldScrollToBottom = isScrollFinished && (canAutoScroll || newEventState == NewEventState.FromMe)
+ if (shouldScrollToBottom) {
scrollToBottom()
}
}
val latestOnScrollFinishedAt by rememberUpdatedState(onScrollFinishedAt)
- LaunchedEffect(isScrollFinished, isTimelineEmpty) {
- if (isScrollFinished && !isTimelineEmpty) {
+ LaunchedEffect(isScrollFinished, hasAnyEvent) {
+ if (isScrollFinished && hasAnyEvent) {
// Notify the parent composable about the first visible item index when scrolling finishes
latestOnScrollFinishedAt(lazyListState.firstVisibleItemIndex)
}
@@ -222,11 +241,11 @@ private fun BoxScope.TimelineScrollHelper(
JumpToBottomButton(
// Use inverse of canAutoScroll otherwise we might briefly see the before the scroll animation is triggered
- isVisible = !canAutoScroll || forceJumpToBottomVisibility,
+ isVisible = !canAutoScroll || forceJumpToBottomVisibility || !isLive,
modifier = Modifier
.align(Alignment.BottomEnd)
.padding(end = 24.dp, bottom = 12.dp),
- onClick = ::scrollToBottom,
+ onClick = { jumpToBottom() },
)
}
@@ -271,18 +290,20 @@ internal fun TimelineViewPreview(
LocalTimelineItemPresenterFactories provides aFakeTimelineItemPresenterFactories(),
) {
TimelineView(
- state = aTimelineState(timelineItems),
- roomName = null,
+ state = aTimelineState(
+ timelineItems = timelineItems,
+ focusedEventIndex = 0,
+ ),
typingNotificationState = aTypingNotificationState(),
- onMessageClicked = {},
- onTimestampClicked = {},
onUserDataClicked = {},
onLinkClicked = {},
+ onMessageClicked = {},
onMessageLongClicked = {},
+ onTimestampClicked = {},
+ onSwipeToReply = {},
onReactionClicked = { _, _ -> },
onReactionLongClicked = { _, _ -> },
onMoreReactionsClicked = {},
- onSwipeToReply = {},
onReadReceiptClick = {},
forceJumpToBottomVisibility = true,
)
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/MessagesReactionButton.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/MessagesReactionButton.kt
index b5e15d0d6c..7786a69db6 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/MessagesReactionButton.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/MessagesReactionButton.kt
@@ -191,7 +191,7 @@ internal fun MessagesReactionButtonPreview(@PreviewParameter(AggregatedReactionP
@PreviewsDayNight
@Composable
-internal fun MessagesAddReactionButtonPreview() = ElementPreview {
+internal fun MessagesReactionButtonAddPreview() = ElementPreview {
MessagesReactionButton(
content = MessagesReactionsButtonContent.Icon(CompoundDrawables.ic_compound_reaction_add),
onClick = {},
@@ -201,7 +201,7 @@ internal fun MessagesAddReactionButtonPreview() = ElementPreview {
@PreviewsDayNight
@Composable
-internal fun MessagesReactionExtraButtonsPreview() = ElementPreview {
+internal fun MessagesReactionButtonExtraPreview() = ElementPreview {
Row {
MessagesReactionButton(
content = MessagesReactionsButtonContent.Text("12 more"),
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventRow.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventRow.kt
index 22bce0e873..87dd60b8a7 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventRow.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventRow.kt
@@ -24,7 +24,6 @@ import androidx.compose.foundation.gestures.Orientation
import androidx.compose.foundation.gestures.draggable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
-import androidx.compose.foundation.layout.Arrangement.spacedBy
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
@@ -69,6 +68,8 @@ import androidx.constraintlayout.compose.ConstrainScope
import androidx.constraintlayout.compose.ConstraintLayout
import io.element.android.compound.theme.ElementTheme
import io.element.android.compound.tokens.generated.CompoundIcons
+import io.element.android.features.messages.impl.sender.SenderName
+import io.element.android.features.messages.impl.sender.SenderNameMode
import io.element.android.features.messages.impl.timeline.TimelineEvents
import io.element.android.features.messages.impl.timeline.TimelineRoomInfo
import io.element.android.features.messages.impl.timeline.aTimelineItemEvent
@@ -91,7 +92,9 @@ import io.element.android.features.messages.impl.timeline.model.event.TimelineIt
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemImageContent
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemTextContent
import io.element.android.features.messages.impl.timeline.model.event.canBeRepliedTo
+import io.element.android.features.messages.impl.timeline.model.eventId
import io.element.android.features.messages.impl.timeline.model.metadata
+import io.element.android.libraries.designsystem.atomic.atoms.PlaceholderAtom
import io.element.android.libraries.designsystem.colors.AvatarColorsProvider
import io.element.android.libraries.designsystem.components.EqualWidthColumn
import io.element.android.libraries.designsystem.components.avatar.Avatar
@@ -106,6 +109,8 @@ import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.UserId
+import io.element.android.libraries.matrix.api.timeline.item.event.ProfileTimelineDetails
+import io.element.android.libraries.matrix.api.timeline.item.event.getDisambiguatedDisplayName
import io.element.android.libraries.matrix.ui.components.AttachmentThumbnail
import io.element.android.libraries.testtags.TestTags
import io.element.android.libraries.ui.strings.CommonStrings
@@ -142,7 +147,7 @@ fun TimelineItemEventRow(
}
fun inReplyToClicked() {
- val inReplyToEventId = event.inReplyTo?.eventId ?: return
+ val inReplyToEventId = event.inReplyTo?.eventId() ?: return
inReplyToClick(inReplyToEventId)
}
@@ -291,7 +296,8 @@ private fun TimelineItemEventRowContent(
val avatarStrokeSize = 3.dp
if (event.showSenderInformation && !timelineRoomInfo.isDm) {
MessageSenderInformation(
- event.safeSenderName,
+ event.senderId,
+ event.senderProfile,
event.senderAvatar,
avatarStrokeSize,
Modifier
@@ -371,7 +377,8 @@ private fun TimelineItemEventRowContent(
@Composable
private fun MessageSenderInformation(
- sender: String,
+ senderId: UserId,
+ senderProfile: ProfileTimelineDetails,
senderAvatar: AvatarData,
avatarStrokeSize: Dp,
modifier: Modifier = Modifier
@@ -398,13 +405,10 @@ private fun MessageSenderInformation(
Row {
Avatar(senderAvatar)
Spacer(modifier = Modifier.width(4.dp))
- Text(
- modifier = Modifier.clipToBounds(),
- text = sender,
- maxLines = 1,
- overflow = TextOverflow.Ellipsis,
- color = avatarColors.foreground,
- style = ElementTheme.typography.fontBodyMdMedium,
+ SenderName(
+ senderId = senderId,
+ senderProfile = senderProfile,
+ senderNameMode = SenderNameMode.Timeline(avatarColors.foreground),
)
}
}
@@ -414,7 +418,6 @@ private fun MessageSenderInformation(
private fun MessageEventBubbleContent(
event: TimelineItem.Event,
onMessageLongClick: () -> Unit,
- @Suppress("UNUSED_PARAMETER")
inReplyToClick: () -> Unit,
onTimestampClicked: () -> Unit,
onLinkClicked: (String) -> Unit,
@@ -434,7 +437,7 @@ private fun MessageEventBubbleContent(
) {
Row(
modifier = modifier,
- horizontalArrangement = spacedBy(4.dp, Alignment.Start),
+ horizontalArrangement = Arrangement.spacedBy(4.dp, Alignment.Start),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
@@ -561,17 +564,31 @@ private fun MessageEventBubbleContent(
}
}
val inReplyTo = @Composable { inReplyTo: InReplyToDetails ->
- val senderName = inReplyTo.senderDisplayName ?: inReplyTo.senderId.value
val topPadding = if (showThreadDecoration) 0.dp else 8.dp
- ReplyToContent(
- senderName = senderName,
- metadata = inReplyTo.metadata(),
- modifier = Modifier
- .padding(top = topPadding, start = 8.dp, end = 8.dp)
- .clip(RoundedCornerShape(6.dp))
+ val inReplyToModifier = Modifier
+ .padding(top = topPadding, start = 8.dp, end = 8.dp)
+ .clip(RoundedCornerShape(6.dp))
// FIXME when a node is clickable, its contents won't be added to the semantics tree of its parent
-// .clickable(enabled = true, onClick = inReplyToClick)
- )
+ .clickable(onClick = inReplyToClick)
+ when (inReplyTo) {
+ is InReplyToDetails.Ready -> {
+ ReplyToContent(
+ senderId = inReplyTo.senderId,
+ senderProfile = inReplyTo.senderProfile,
+ metadata = inReplyTo.metadata(),
+ modifier = inReplyToModifier,
+ )
+ }
+ is InReplyToDetails.Error ->
+ ReplyToErrorContent(
+ data = inReplyTo,
+ modifier = inReplyToModifier,
+ )
+ is InReplyToDetails.Loading ->
+ ReplyToLoadingContent(
+ modifier = inReplyToModifier,
+ )
+ }
}
if (inReplyToDetails != null) {
// Use SubComposeLayout only if necessary as it can have consequences on the performance.
@@ -581,7 +598,7 @@ private fun MessageEventBubbleContent(
contentWithTimestamp()
}
} else {
- Column(modifier = modifier, verticalArrangement = spacedBy(8.dp)) {
+ Column(modifier = modifier, verticalArrangement = Arrangement.spacedBy(8.dp)) {
threadDecoration()
contentWithTimestamp()
}
@@ -609,7 +626,8 @@ private fun MessageEventBubbleContent(
@Composable
private fun ReplyToContent(
- senderName: String,
+ senderId: UserId,
+ senderProfile: ProfileTimelineDetails,
metadata: InReplyToMetadata?,
modifier: Modifier = Modifier,
) {
@@ -633,24 +651,59 @@ private fun ReplyToContent(
)
Spacer(modifier = Modifier.width(8.dp))
}
- val a11InReplyToText = stringResource(CommonStrings.common_in_reply_to, senderName)
+ val a11InReplyToText = stringResource(CommonStrings.common_in_reply_to, senderProfile.getDisambiguatedDisplayName(senderId))
Column(verticalArrangement = Arrangement.SpaceBetween) {
- Text(
+ SenderName(
+ senderId = senderId,
+ senderProfile = senderProfile,
+ senderNameMode = SenderNameMode.Reply,
modifier = Modifier.semantics {
contentDescription = a11InReplyToText
},
- text = senderName,
- style = ElementTheme.typography.fontBodySmMedium,
- textAlign = TextAlign.Start,
- color = ElementTheme.materialColors.primary,
- maxLines = 1,
- overflow = TextOverflow.Ellipsis,
)
ReplyToContentText(metadata)
}
}
}
+@Composable
+private fun ReplyToLoadingContent(
+ modifier: Modifier = Modifier,
+) {
+ val paddings = PaddingValues(horizontal = 12.dp, vertical = 4.dp)
+ Row(
+ modifier
+ .background(MaterialTheme.colorScheme.surface)
+ .padding(paddings)
+ ) {
+ Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
+ PlaceholderAtom(width = 80.dp, height = 12.dp)
+ PlaceholderAtom(width = 140.dp, height = 14.dp)
+ }
+ }
+}
+
+@Composable
+private fun ReplyToErrorContent(
+ data: InReplyToDetails.Error,
+ modifier: Modifier = Modifier,
+) {
+ val paddings = PaddingValues(horizontal = 12.dp, vertical = 4.dp)
+ Row(
+ modifier
+ .background(MaterialTheme.colorScheme.surface)
+ .padding(paddings)
+ ) {
+ Text(
+ text = data.message,
+ style = ElementTheme.typography.fontBodyMdRegular,
+ color = MaterialTheme.colorScheme.error,
+ maxLines = 2,
+ overflow = TextOverflow.Ellipsis,
+ )
+ }
+}
+
@Composable
private fun ReplyToContentText(metadata: InReplyToMetadata?) {
val text = when (metadata) {
@@ -700,6 +753,7 @@ internal fun TimelineItemEventRowPreview() = ElementPreview {
sequenceOf(false, true).forEach { isMine ->
ATimelineItemEventRow(
event = aTimelineItemEvent(
+ senderDisplayName = "Sender with a super long name that should ellipsize",
isMine = isMine,
content = aTimelineItemTextContent().copy(
body = "A long text which will be displayed on several lines and" +
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventRowDisambiguatedPreview.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventRowDisambiguatedPreview.kt
new file mode 100644
index 0000000000..2527072c24
--- /dev/null
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventRowDisambiguatedPreview.kt
@@ -0,0 +1,50 @@
+/*
+ * Copyright (c) 2024 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.element.android.features.messages.impl.timeline.components
+
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.tooling.preview.PreviewParameter
+import io.element.android.features.messages.impl.timeline.model.InReplyToDetails
+import io.element.android.libraries.designsystem.preview.ElementPreview
+import io.element.android.libraries.designsystem.preview.PreviewsDayNight
+import io.element.android.libraries.matrix.api.timeline.item.event.TextMessageType
+
+@PreviewsDayNight
+@Composable
+internal fun TimelineItemEventRowDisambiguatedPreview(
+ @PreviewParameter(InReplyToDetailsDisambiguatedProvider::class) inReplyToDetails: InReplyToDetails,
+) = ElementPreview {
+ TimelineItemEventRowWithReplyContentToPreview(
+ inReplyToDetails = inReplyToDetails,
+ displayNameAmbiguous = true,
+ )
+}
+
+class InReplyToDetailsDisambiguatedProvider : InReplyToDetailsProvider() {
+ override val values: Sequence
+ get() = sequenceOf(
+ aMessageContent(
+ body = "Message which are being replied.",
+ type = TextMessageType("Message which are being replied.", null)
+ ),
+ ).map {
+ aInReplyToDetails(
+ displayNameAmbiguous = true,
+ eventContent = it,
+ )
+ }
+}
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventRowTimestampPreview.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventRowTimestampPreview.kt
index e6525584b8..8ec1d0e554 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventRowTimestampPreview.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventRowTimestampPreview.kt
@@ -43,7 +43,6 @@ internal fun TimelineItemEventRowTimestampPreview(
body = str,
),
reactionsState = aTimelineItemReactions(count = 0),
- senderDisplayName = "A sender",
),
)
}
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventRowWithReplyOtherPreview.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventRowWithReplyOtherPreview.kt
new file mode 100644
index 0000000000..2231af606d
--- /dev/null
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventRowWithReplyOtherPreview.kt
@@ -0,0 +1,40 @@
+/*
+ * Copyright (c) 2024 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.element.android.features.messages.impl.timeline.components
+
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.tooling.preview.PreviewParameter
+import io.element.android.features.messages.impl.timeline.model.InReplyToDetails
+import io.element.android.libraries.designsystem.preview.ElementPreview
+import io.element.android.libraries.designsystem.preview.PreviewsDayNight
+import io.element.android.libraries.matrix.api.core.EventId
+
+@PreviewsDayNight
+@Composable
+internal fun TimelineItemEventRowWithReplyOtherPreview(
+ @PreviewParameter(InReplyToDetailsOtherProvider::class) inReplyToDetails: InReplyToDetails,
+) = ElementPreview {
+ TimelineItemEventRowWithReplyContentToPreview(inReplyToDetails)
+}
+
+class InReplyToDetailsOtherProvider : InReplyToDetailsProvider() {
+ override val values: Sequence
+ get() = sequenceOf(
+ InReplyToDetails.Loading(eventId = EventId("\$anEventId")),
+ InReplyToDetails.Error(eventId = EventId("\$anEventId"), message = "An error message."),
+ )
+}
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventRowWithReplyPreview.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventRowWithReplyPreview.kt
index 20b9a01e3f..b1500dac2c 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventRowWithReplyPreview.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventRowWithReplyPreview.kt
@@ -42,6 +42,7 @@ import io.element.android.libraries.matrix.api.timeline.item.event.MessageConten
import io.element.android.libraries.matrix.api.timeline.item.event.MessageType
import io.element.android.libraries.matrix.api.timeline.item.event.NoticeMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.PollContent
+import io.element.android.libraries.matrix.api.timeline.item.event.ProfileTimelineDetails
import io.element.android.libraries.matrix.api.timeline.item.event.StickerMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.TextMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.VideoMessageType
@@ -58,7 +59,10 @@ internal fun TimelineItemEventRowWithReplyPreview(
}
@Composable
-internal fun TimelineItemEventRowWithReplyContentToPreview(inReplyToDetails: InReplyToDetails) {
+internal fun TimelineItemEventRowWithReplyContentToPreview(
+ inReplyToDetails: InReplyToDetails,
+ displayNameAmbiguous: Boolean = false,
+) {
Column {
sequenceOf(false, true).forEach {
ATimelineItemEventRow(
@@ -69,6 +73,7 @@ internal fun TimelineItemEventRowWithReplyContentToPreview(inReplyToDetails: InR
body = "A reply."
),
inReplyTo = inReplyToDetails,
+ displayNameAmbiguous = displayNameAmbiguous,
groupPosition = TimelineItemGroupPosition.First,
),
)
@@ -80,6 +85,7 @@ internal fun TimelineItemEventRowWithReplyContentToPreview(inReplyToDetails: InR
aspectRatio = 2.5f
),
inReplyTo = inReplyToDetails,
+ displayNameAmbiguous = displayNameAmbiguous,
isThreaded = true,
groupPosition = TimelineItemGroupPosition.Last,
),
@@ -150,7 +156,7 @@ open class InReplyToDetailsProvider : PreviewParameterProvider
)
}
- private fun aMessageContent(
+ protected fun aMessageContent(
body: String,
type: MessageType,
) = MessageContent(
@@ -163,12 +169,24 @@ open class InReplyToDetailsProvider : PreviewParameterProvider
protected fun aInReplyToDetails(
eventContent: EventContent,
- ) = InReplyToDetails(
+ displayNameAmbiguous: Boolean = false,
+ ) = InReplyToDetails.Ready(
eventId = EventId("\$event"),
eventContent = eventContent,
senderId = UserId("@Sender:domain"),
- senderDisplayName = "Sender",
- senderAvatarUrl = null,
+ senderProfile = aProfileTimelineDetailsReady(
+ displayNameAmbiguous = displayNameAmbiguous,
+ ),
textContent = (eventContent as? MessageContent)?.body.orEmpty(),
)
}
+
+internal fun aProfileTimelineDetailsReady(
+ displayName: String? = "Sender",
+ displayNameAmbiguous: Boolean = false,
+ avatarUrl: String? = null,
+) = ProfileTimelineDetails.Ready(
+ displayName = displayName,
+ displayNameAmbiguous = displayNameAmbiguous,
+ avatarUrl = avatarUrl,
+)
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemGroupedEventsRow.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemGroupedEventsRow.kt
index a5d42d5dbf..2dcc0c1b55 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemGroupedEventsRow.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemGroupedEventsRow.kt
@@ -43,7 +43,7 @@ fun TimelineItemGroupedEventsRow(
timelineRoomInfo: TimelineRoomInfo,
renderReadReceipts: Boolean,
isLastOutgoingMessage: Boolean,
- highlightedItem: String?,
+ focusedEventId: EventId?,
onClick: (TimelineItem.Event) -> Unit,
onLongClick: (TimelineItem.Event) -> Unit,
inReplyToClick: (EventId) -> Unit,
@@ -68,7 +68,7 @@ fun TimelineItemGroupedEventsRow(
onExpandGroupClick = ::onExpandGroupClick,
timelineItem = timelineItem,
timelineRoomInfo = timelineRoomInfo,
- highlightedItem = highlightedItem,
+ focusedEventId = focusedEventId,
renderReadReceipts = renderReadReceipts,
isLastOutgoingMessage = isLastOutgoingMessage,
onClick = onClick,
@@ -92,7 +92,7 @@ private fun TimelineItemGroupedEventsRowContent(
onExpandGroupClick: () -> Unit,
timelineItem: TimelineItem.GroupedEvents,
timelineRoomInfo: TimelineRoomInfo,
- highlightedItem: String?,
+ focusedEventId: EventId?,
renderReadReceipts: Boolean,
isLastOutgoingMessage: Boolean,
onClick: (TimelineItem.Event) -> Unit,
@@ -116,7 +116,7 @@ private fun TimelineItemGroupedEventsRowContent(
timelineItem.events.size
),
isExpanded = isExpanded,
- isHighlighted = !isExpanded && timelineItem.events.any { it.identifier() == highlightedItem },
+ isHighlighted = !isExpanded && timelineItem.events.any { it.isEvent(focusedEventId) },
onClick = onExpandGroupClick,
)
if (isExpanded) {
@@ -127,7 +127,7 @@ private fun TimelineItemGroupedEventsRowContent(
timelineRoomInfo = timelineRoomInfo,
renderReadReceipts = renderReadReceipts,
isLastOutgoingMessage = isLastOutgoingMessage,
- highlightedItem = highlightedItem,
+ focusedEventId = focusedEventId,
onClick = onClick,
onLongClick = onLongClick,
inReplyToClick = inReplyToClick,
@@ -160,12 +160,13 @@ private fun TimelineItemGroupedEventsRowContent(
@PreviewsDayNight
@Composable
internal fun TimelineItemGroupedEventsRowContentExpandedPreview() = ElementPreview {
+ val events = aGroupedEvents(withReadReceipts = true)
TimelineItemGroupedEventsRowContent(
isExpanded = true,
onExpandGroupClick = {},
- timelineItem = aGroupedEvents(withReadReceipts = true),
+ timelineItem = events,
timelineRoomInfo = aTimelineRoomInfo(),
- highlightedItem = null,
+ focusedEventId = events.events.first().eventId,
renderReadReceipts = true,
isLastOutgoingMessage = false,
onClick = {},
@@ -190,7 +191,7 @@ internal fun TimelineItemGroupedEventsRowContentCollapsePreview() = ElementPrevi
onExpandGroupClick = {},
timelineItem = aGroupedEvents(withReadReceipts = true),
timelineRoomInfo = aTimelineRoomInfo(),
- highlightedItem = null,
+ focusedEventId = null,
renderReadReceipts = true,
isLastOutgoingMessage = false,
onClick = {},
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemRow.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemRow.kt
index 89b223dafe..4193a9f131 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemRow.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemRow.kt
@@ -16,13 +16,24 @@
package io.element.android.features.messages.impl.timeline.components
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.drawWithCache
+import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.geometry.Size
+import androidx.compose.ui.graphics.Brush
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.dp
+import io.element.android.compound.theme.ElementTheme
import io.element.android.features.messages.impl.timeline.TimelineEvents
import io.element.android.features.messages.impl.timeline.TimelineRoomInfo
import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemLegacyCallInviteContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemStateContent
+import io.element.android.libraries.designsystem.text.toPx
+import io.element.android.libraries.designsystem.theme.highlightedMessageBackgroundColor
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.UserId
@@ -32,7 +43,7 @@ internal fun TimelineItemRow(
timelineRoomInfo: TimelineRoomInfo,
renderReadReceipts: Boolean,
isLastOutgoingMessage: Boolean,
- highlightedItem: String?,
+ focusedEventId: EventId?,
onUserDataClick: (UserId) -> Unit,
onLinkClicked: (String) -> Unit,
onClick: (TimelineItem.Event) -> Unit,
@@ -47,69 +58,111 @@ internal fun TimelineItemRow(
eventSink: (TimelineEvents.EventFromTimelineItem) -> Unit,
modifier: Modifier = Modifier
) {
- when (timelineItem) {
- is TimelineItem.Virtual -> {
- TimelineItemVirtualRow(
- virtual = timelineItem,
- modifier = modifier,
- )
+ val backgroundModifier = if (timelineItem.isEvent(focusedEventId)) {
+ val focusedEventOffset = if ((timelineItem as? TimelineItem.Event)?.showSenderInformation == true) {
+ 14.dp
+ } else {
+ 2.dp
}
- is TimelineItem.Event -> {
- if (timelineItem.content is TimelineItemStateContent || timelineItem.content is TimelineItemLegacyCallInviteContent) {
- TimelineItemStateEventRow(
- event = timelineItem,
- renderReadReceipts = renderReadReceipts,
- isLastOutgoingMessage = isLastOutgoingMessage,
- isHighlighted = highlightedItem == timelineItem.identifier(),
- onClick = { onClick(timelineItem) },
- onReadReceiptsClick = onReadReceiptClick,
- onLongClick = { onLongClick(timelineItem) },
+ Modifier.focusedEvent(focusedEventOffset)
+ } else {
+ Modifier
+ }
+ Box(modifier = modifier.then(backgroundModifier)) {
+ when (timelineItem) {
+ is TimelineItem.Virtual -> {
+ TimelineItemVirtualRow(
+ virtual = timelineItem,
+ timelineRoomInfo = timelineRoomInfo,
eventSink = eventSink,
- modifier = modifier,
)
- } else {
- TimelineItemEventRow(
- event = timelineItem,
+ }
+ is TimelineItem.Event -> {
+ if (timelineItem.content is TimelineItemStateContent || timelineItem.content is TimelineItemLegacyCallInviteContent) {
+ TimelineItemStateEventRow(
+ event = timelineItem,
+ renderReadReceipts = renderReadReceipts,
+ isLastOutgoingMessage = isLastOutgoingMessage,
+ isHighlighted = timelineItem.isEvent(focusedEventId),
+ onClick = { onClick(timelineItem) },
+ onReadReceiptsClick = onReadReceiptClick,
+ onLongClick = { onLongClick(timelineItem) },
+ eventSink = eventSink,
+ )
+ } else {
+ TimelineItemEventRow(
+ event = timelineItem,
+ timelineRoomInfo = timelineRoomInfo,
+ renderReadReceipts = renderReadReceipts,
+ isLastOutgoingMessage = isLastOutgoingMessage,
+ isHighlighted = timelineItem.isEvent(focusedEventId),
+ onClick = { onClick(timelineItem) },
+ onLongClick = { onLongClick(timelineItem) },
+ onUserDataClick = onUserDataClick,
+ onLinkClicked = onLinkClicked,
+ inReplyToClick = inReplyToClick,
+ onReactionClick = onReactionClick,
+ onReactionLongClick = onReactionLongClick,
+ onMoreReactionsClick = onMoreReactionsClick,
+ onReadReceiptClick = onReadReceiptClick,
+ onTimestampClicked = onTimestampClicked,
+ onSwipeToReply = { onSwipeToReply(timelineItem) },
+ eventSink = eventSink,
+ )
+ }
+ }
+ is TimelineItem.GroupedEvents -> {
+ TimelineItemGroupedEventsRow(
+ timelineItem = timelineItem,
timelineRoomInfo = timelineRoomInfo,
renderReadReceipts = renderReadReceipts,
isLastOutgoingMessage = isLastOutgoingMessage,
- isHighlighted = highlightedItem == timelineItem.identifier(),
- onClick = { onClick(timelineItem) },
- onLongClick = { onLongClick(timelineItem) },
+ focusedEventId = focusedEventId,
+ onClick = onClick,
+ onLongClick = onLongClick,
+ inReplyToClick = inReplyToClick,
onUserDataClick = onUserDataClick,
onLinkClicked = onLinkClicked,
- inReplyToClick = inReplyToClick,
+ onTimestampClicked = onTimestampClicked,
onReactionClick = onReactionClick,
onReactionLongClick = onReactionLongClick,
onMoreReactionsClick = onMoreReactionsClick,
onReadReceiptClick = onReadReceiptClick,
- onTimestampClicked = onTimestampClicked,
- onSwipeToReply = { onSwipeToReply(timelineItem) },
eventSink = eventSink,
- modifier = modifier,
)
}
}
- is TimelineItem.GroupedEvents -> {
- TimelineItemGroupedEventsRow(
- timelineItem = timelineItem,
- timelineRoomInfo = timelineRoomInfo,
- renderReadReceipts = renderReadReceipts,
- isLastOutgoingMessage = isLastOutgoingMessage,
- highlightedItem = highlightedItem,
- onClick = onClick,
- onLongClick = onLongClick,
- inReplyToClick = inReplyToClick,
- onUserDataClick = onUserDataClick,
- onLinkClicked = onLinkClicked,
- onTimestampClicked = onTimestampClicked,
- onReactionClick = onReactionClick,
- onReactionLongClick = onReactionLongClick,
- onMoreReactionsClick = onMoreReactionsClick,
- onReadReceiptClick = onReadReceiptClick,
- eventSink = eventSink,
- modifier = modifier,
+ }
+}
+
+@Suppress("ModifierComposable")
+@Composable
+private fun Modifier.focusedEvent(
+ focusedEventOffset: Dp
+): Modifier {
+ val highlightedLineColor = ElementTheme.colors.textActionAccent
+ val gradientColors = listOf(
+ ElementTheme.colors.highlightedMessageBackgroundColor,
+ ElementTheme.materialColors.background
+ )
+ val verticalOffset = focusedEventOffset.toPx()
+ val verticalRatio = 0.7f
+ return drawWithCache {
+ val brush = Brush.verticalGradient(
+ colors = gradientColors,
+ endY = size.height * verticalRatio,
+ )
+ onDrawBehind {
+ drawRect(
+ brush,
+ topLeft = Offset(0f, verticalOffset),
+ size = Size(size.width, size.height * verticalRatio)
+ )
+ drawLine(
+ highlightedLineColor,
+ start = Offset(0f, verticalOffset),
+ end = Offset(size.width, verticalOffset)
)
}
- }
+ }.padding(top = 4.dp)
}
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemVirtualRow.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemVirtualRow.kt
index 306c11aaba..d56fe00540 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemVirtualRow.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemVirtualRow.kt
@@ -16,24 +16,51 @@
package io.element.android.features.messages.impl.timeline.components
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Spacer
import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.ui.Modifier
+import io.element.android.features.messages.impl.timeline.TimelineEvents
+import io.element.android.features.messages.impl.timeline.TimelineRoomInfo
import io.element.android.features.messages.impl.timeline.components.virtual.TimelineEncryptedHistoryBannerView
import io.element.android.features.messages.impl.timeline.components.virtual.TimelineItemDaySeparatorView
import io.element.android.features.messages.impl.timeline.components.virtual.TimelineItemReadMarkerView
+import io.element.android.features.messages.impl.timeline.components.virtual.TimelineItemRoomBeginningView
+import io.element.android.features.messages.impl.timeline.components.virtual.TimelineLoadingMoreIndicator
import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.features.messages.impl.timeline.model.virtual.TimelineItemDaySeparatorModel
import io.element.android.features.messages.impl.timeline.model.virtual.TimelineItemEncryptedHistoryBannerVirtualModel
+import io.element.android.features.messages.impl.timeline.model.virtual.TimelineItemLastForwardIndicatorModel
+import io.element.android.features.messages.impl.timeline.model.virtual.TimelineItemLoadingIndicatorModel
import io.element.android.features.messages.impl.timeline.model.virtual.TimelineItemReadMarkerModel
+import io.element.android.features.messages.impl.timeline.model.virtual.TimelineItemRoomBeginningModel
@Composable
fun TimelineItemVirtualRow(
virtual: TimelineItem.Virtual,
+ timelineRoomInfo: TimelineRoomInfo,
+ eventSink: (TimelineEvents.EventFromTimelineItem) -> Unit,
modifier: Modifier = Modifier
) {
- when (virtual.model) {
- is TimelineItemDaySeparatorModel -> TimelineItemDaySeparatorView(virtual.model, modifier)
- TimelineItemReadMarkerModel -> TimelineItemReadMarkerView()
- is TimelineItemEncryptedHistoryBannerVirtualModel -> TimelineEncryptedHistoryBannerView(modifier)
+ Box(modifier = modifier) {
+ when (virtual.model) {
+ is TimelineItemDaySeparatorModel -> TimelineItemDaySeparatorView(virtual.model)
+ TimelineItemReadMarkerModel -> TimelineItemReadMarkerView()
+ is TimelineItemEncryptedHistoryBannerVirtualModel -> TimelineEncryptedHistoryBannerView()
+ TimelineItemRoomBeginningModel -> TimelineItemRoomBeginningView(roomName = timelineRoomInfo.name)
+ is TimelineItemLoadingIndicatorModel -> {
+ TimelineLoadingMoreIndicator(virtual.model.direction)
+ val latestEventSink by rememberUpdatedState(eventSink)
+ LaunchedEffect(virtual.model.timestamp) {
+ latestEventSink(TimelineEvents.LoadMore(virtual.model.direction))
+ }
+ }
+ is TimelineItemLastForwardIndicatorModel -> {
+ Spacer(modifier = Modifier)
+ }
+ }
}
}
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemEncryptedView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemEncryptedView.kt
index 1b67fce85d..b0753be9b1 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemEncryptedView.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemEncryptedView.kt
@@ -19,24 +19,33 @@ package io.element.android.features.messages.impl.timeline.components.event
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.tooling.preview.PreviewParameter
import io.element.android.features.messages.impl.timeline.components.layout.ContentAvoidingLayoutData
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEncryptedContent
+import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEncryptedContentProvider
import io.element.android.libraries.designsystem.icons.CompoundDrawables
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.matrix.api.timeline.item.event.UnableToDecryptContent
+import io.element.android.libraries.matrix.api.timeline.item.event.UtdCause
import io.element.android.libraries.ui.strings.CommonStrings
@Composable
fun TimelineItemEncryptedView(
- @Suppress("UNUSED_PARAMETER") content: TimelineItemEncryptedContent,
+ content: TimelineItemEncryptedContent,
onContentLayoutChanged: (ContentAvoidingLayoutData) -> Unit,
modifier: Modifier = Modifier
) {
+ val isMembershipUtd = (content.data as? UnableToDecryptContent.Data.MegolmV1AesSha2)?.utdCause == UtdCause.Membership
+ val (textId, iconId) = if (isMembershipUtd) {
+ CommonStrings.common_unable_to_decrypt_no_access to CompoundDrawables.ic_compound_block
+ } else {
+ CommonStrings.common_waiting_for_decryption_key to CompoundDrawables.ic_compound_time
+ }
TimelineItemInformativeView(
- text = stringResource(id = CommonStrings.common_waiting_for_decryption_key),
+ text = stringResource(id = textId),
iconDescription = stringResource(id = CommonStrings.dialog_title_warning),
- iconResourceId = CompoundDrawables.ic_compound_time,
+ iconResourceId = iconId,
onContentLayoutChanged = onContentLayoutChanged,
modifier = modifier
)
@@ -44,11 +53,11 @@ fun TimelineItemEncryptedView(
@PreviewsDayNight
@Composable
-internal fun TimelineItemEncryptedViewPreview() = ElementPreview {
+internal fun TimelineItemEncryptedViewPreview(
+ @PreviewParameter(TimelineItemEncryptedContentProvider::class) content: TimelineItemEncryptedContent
+) = ElementPreview {
TimelineItemEncryptedView(
- content = TimelineItemEncryptedContent(
- data = UnableToDecryptContent.Data.Unknown
- ),
+ content = content,
onContentLayoutChanged = {},
)
}
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/receipt/TimelineItemReadReceiptView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/receipt/TimelineItemReadReceiptView.kt
index 047e04e187..540bff9a2b 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/receipt/TimelineItemReadReceiptView.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/receipt/TimelineItemReadReceiptView.kt
@@ -207,7 +207,7 @@ private fun computeReceiptDescription(receipts: ImmutableList):
@PreviewsDayNight
@Composable
-internal fun TimelineItemReactionsViewPreview(
+internal fun TimelineItemReadReceiptViewPreview(
@PreviewParameter(ReadReceiptViewStateProvider::class) state: ReadReceiptViewState,
) = ElementPreview {
TimelineItemReadReceiptView(
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/virtual/TimelineLoadingMoreIndicator.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/virtual/TimelineLoadingMoreIndicator.kt
index 23cc516156..1719956cdb 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/virtual/TimelineLoadingMoreIndicator.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/virtual/TimelineLoadingMoreIndicator.kt
@@ -16,10 +16,12 @@
package io.element.android.features.messages.impl.timeline.components.virtual
+import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
-import androidx.compose.foundation.layout.wrapContentHeight
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@@ -27,24 +29,45 @@ import androidx.compose.ui.unit.dp
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator
+import io.element.android.libraries.designsystem.theme.components.LinearProgressIndicator
+import io.element.android.libraries.matrix.api.timeline.Timeline
@Composable
-internal fun TimelineLoadingMoreIndicator(modifier: Modifier = Modifier) {
+internal fun TimelineLoadingMoreIndicator(
+ direction: Timeline.PaginationDirection,
+ modifier: Modifier = Modifier
+) {
Box(
- modifier
- .fillMaxWidth()
- .wrapContentHeight()
- .padding(8.dp),
+ modifier = modifier.fillMaxWidth(),
contentAlignment = Alignment.Center,
) {
- CircularProgressIndicator(
- strokeWidth = 2.dp,
- )
+ when (direction) {
+ Timeline.PaginationDirection.FORWARDS -> {
+ LinearProgressIndicator(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(top = 2.dp)
+ .height(1.dp)
+ )
+ }
+ Timeline.PaginationDirection.BACKWARDS -> {
+ CircularProgressIndicator(
+ strokeWidth = 2.dp,
+ modifier = Modifier.padding(vertical = 8.dp)
+ )
+ }
+ }
}
}
@PreviewsDayNight
@Composable
internal fun TimelineLoadingMoreIndicatorPreview() = ElementPreview {
- TimelineLoadingMoreIndicator()
+ Column(
+ modifier = Modifier.padding(vertical = 2.dp),
+ verticalArrangement = Arrangement.spacedBy(8.dp),
+ ) {
+ TimelineLoadingMoreIndicator(Timeline.PaginationDirection.BACKWARDS)
+ TimelineLoadingMoreIndicator(Timeline.PaginationDirection.FORWARDS)
+ }
}
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/debug/EventDebugInfoView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/debug/EventDebugInfoView.kt
index 5830aceb97..acff89e97c 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/debug/EventDebugInfoView.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/debug/EventDebugInfoView.kt
@@ -59,6 +59,8 @@ import io.element.android.libraries.designsystem.theme.components.Scaffold
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.theme.components.TopAppBar
import io.element.android.libraries.matrix.api.core.EventId
+import org.json.JSONException
+import org.json.JSONObject
/**
* Screen used to display debug info for events.
@@ -109,18 +111,27 @@ fun EventDebugInfoView(
}
if (originalJson != null) {
item {
- CollapsibleSection(title = "Original JSON:", text = originalJson, initiallyExpanded = sectionsInitiallyExpanded)
+ CollapsibleSection(title = "Original JSON:", text = prettyJSON(originalJson), initiallyExpanded = sectionsInitiallyExpanded)
}
}
if (latestEditedJson != null) {
item {
- CollapsibleSection(title = "Latest edited JSON:", text = latestEditedJson, initiallyExpanded = sectionsInitiallyExpanded)
+ CollapsibleSection(title = "Latest edited JSON:", text = prettyJSON(latestEditedJson), initiallyExpanded = sectionsInitiallyExpanded)
}
}
}
}
}
+private fun prettyJSON(maybeJSON: String): String {
+ return try {
+ JSONObject(maybeJSON).toString(2)
+ } catch (e: JSONException) {
+ // Prefer not pretty-printing over crashing if the data is not actually JSON
+ maybeJSON
+ }
+}
+
@Composable
private fun CollapsibleSection(
title: String,
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/TimelineItemsFactory.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/TimelineItemsFactory.kt
index 29ea3abcfc..d3e21d0042 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/TimelineItemsFactory.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/TimelineItemsFactory.kt
@@ -19,6 +19,7 @@ package io.element.android.features.messages.impl.timeline.factories
import androidx.compose.runtime.Composable
import androidx.compose.runtime.State
import androidx.compose.runtime.collectAsState
+import io.element.android.features.messages.impl.timeline.TimelineItemIndexer
import io.element.android.features.messages.impl.timeline.diff.TimelineItemsCacheInvalidator
import io.element.android.features.messages.impl.timeline.factories.event.TimelineItemEventFactory
import io.element.android.features.messages.impl.timeline.factories.virtual.TimelineItemVirtualFactory
@@ -43,9 +44,9 @@ class TimelineItemsFactory @Inject constructor(
private val eventItemFactory: TimelineItemEventFactory,
private val virtualItemFactory: TimelineItemVirtualFactory,
private val timelineItemGrouper: TimelineItemGrouper,
+ private val timelineItemIndexer: TimelineItemIndexer,
) {
private val timelineItems = MutableStateFlow(persistentListOf())
-
private val lock = Mutex()
private val diffCache = MutableListDiffCache()
private val diffCacheUpdater = DiffCacheUpdater(
@@ -100,6 +101,7 @@ class TimelineItemsFactory @Inject constructor(
}
}
val result = timelineItemGrouper.group(newTimelineItemStates).toPersistentList()
+ timelineItemIndexer.process(result)
this.timelineItems.emit(result)
}
@@ -108,13 +110,13 @@ class TimelineItemsFactory @Inject constructor(
index: Int,
roomMembers: List,
): TimelineItem? {
- val timelineItemState =
+ val timelineItem =
when (val currentTimelineItem = timelineItems[index]) {
is MatrixTimelineItem.Event -> eventItemFactory.create(currentTimelineItem, index, timelineItems, roomMembers)
is MatrixTimelineItem.Virtual -> virtualItemFactory.create(currentTimelineItem)
MatrixTimelineItem.Other -> null
}
- diffCache[index] = timelineItemState
- return timelineItemState
+ diffCache[index] = timelineItem
+ return timelineItem
}
}
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentFactory.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentFactory.kt
index 4d9cfbea42..807137a978 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentFactory.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentFactory.kt
@@ -52,8 +52,12 @@ class TimelineItemContentFactory @Inject constructor(
is FailedToParseMessageLikeContent -> failedToParseMessageFactory.create(itemContent)
is FailedToParseStateContent -> failedToParseStateFactory.create(itemContent)
is MessageContent -> {
- val senderDisplayName = eventTimelineItem.senderProfile.getDisambiguatedDisplayName(eventTimelineItem.sender)
- messageFactory.create(itemContent, senderDisplayName, eventTimelineItem.eventId)
+ val senderDisambiguatedDisplayName = eventTimelineItem.senderProfile.getDisambiguatedDisplayName(eventTimelineItem.sender)
+ messageFactory.create(
+ content = itemContent,
+ senderDisambiguatedDisplayName = senderDisambiguatedDisplayName,
+ eventId = eventTimelineItem.eventId,
+ )
}
is ProfileChangeContent -> profileChangeFactory.create(eventTimelineItem)
is RedactedContent -> redactedMessageFactory.create(itemContent)
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentMessageFactory.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentMessageFactory.kt
index acd99d3704..2aba9c3669 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentMessageFactory.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentMessageFactory.kt
@@ -70,17 +70,21 @@ class TimelineItemContentMessageFactory @Inject constructor(
private val htmlConverterProvider: HtmlConverterProvider,
private val permalinkParser: PermalinkParser,
) {
- suspend fun create(content: MessageContent, senderDisplayName: String, eventId: EventId?): TimelineItemEventContent {
+ suspend fun create(
+ content: MessageContent,
+ senderDisambiguatedDisplayName: String,
+ eventId: EventId?,
+ ): TimelineItemEventContent {
return when (val messageType = content.type) {
is EmoteMessageType -> {
- val emoteBody = "* $senderDisplayName ${messageType.body.trimEnd()}"
+ val emoteBody = "* $senderDisambiguatedDisplayName ${messageType.body.trimEnd()}"
TimelineItemEmoteContent(
body = emoteBody,
htmlDocument = messageType.formatted?.toHtmlDocument(
permalinkParser = permalinkParser,
- prefix = "* $senderDisplayName",
+ prefix = "* $senderDisambiguatedDisplayName",
),
- formattedBody = parseHtml(messageType.formatted, prefix = "* $senderDisplayName") ?: emoteBody.withLinks(),
+ formattedBody = parseHtml(messageType.formatted, prefix = "* $senderDisambiguatedDisplayName") ?: emoteBody.withLinks(),
isEdited = content.isEdited,
)
}
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemEventFactory.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemEventFactory.kt
index 0522379f72..50550bd84f 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemEventFactory.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemEventFactory.kt
@@ -33,7 +33,7 @@ import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.permalink.PermalinkParser
import io.element.android.libraries.matrix.api.room.RoomMember
import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem
-import io.element.android.libraries.matrix.api.timeline.item.event.ProfileTimelineDetails
+import io.element.android.libraries.matrix.api.timeline.item.event.getAvatarUrl
import io.element.android.libraries.matrix.api.timeline.item.event.getDisambiguatedDisplayName
import kotlinx.collections.immutable.toImmutableList
import java.text.DateFormat
@@ -55,15 +55,14 @@ class TimelineItemEventFactory @Inject constructor(
val currentSender = currentTimelineItem.event.sender
val groupPosition =
computeGroupPosition(currentTimelineItem, timelineItems, index)
- val (senderDisplayName, senderAvatarUrl) = currentTimelineItem.getSenderInfo()
-
+ val senderProfile = currentTimelineItem.event.senderProfile
val timeFormatter = DateFormat.getTimeInstance(DateFormat.SHORT)
val sentTime = timeFormatter.format(Date(currentTimelineItem.event.timestamp))
val senderAvatarData = AvatarData(
id = currentSender.value,
- name = senderDisplayName ?: currentSender.value,
- url = senderAvatarUrl,
+ name = senderProfile.getDisambiguatedDisplayName(currentSender),
+ url = senderProfile.getAvatarUrl(),
size = AvatarSize.TimelineSender
)
currentTimelineItem.event
@@ -72,7 +71,7 @@ class TimelineItemEventFactory @Inject constructor(
eventId = currentTimelineItem.eventId,
transactionId = currentTimelineItem.transactionId,
senderId = currentSender,
- senderDisplayName = senderDisplayName,
+ senderProfile = senderProfile,
senderAvatar = senderAvatarData,
content = contentFactory.create(currentTimelineItem.event),
isMine = currentTimelineItem.event.isOwn,
@@ -99,26 +98,6 @@ class TimelineItemEventFactory @Inject constructor(
)
}
- private fun MatrixTimelineItem.Event.getSenderInfo(): Pair {
- val senderDisplayName: String?
- val senderAvatarUrl: String?
-
- when (val senderProfile = event.senderProfile) {
- ProfileTimelineDetails.Unavailable,
- ProfileTimelineDetails.Pending,
- is ProfileTimelineDetails.Error -> {
- senderDisplayName = null
- senderAvatarUrl = null
- }
- is ProfileTimelineDetails.Ready -> {
- senderDisplayName = senderProfile.getDisambiguatedDisplayName(event.sender)
- senderAvatarUrl = senderProfile.avatarUrl
- }
- }
-
- return senderDisplayName to senderAvatarUrl
- }
-
private fun MatrixTimelineItem.Event.computeReactionsState(): TimelineItemReactions {
val timeFormatter = DateFormat.getTimeInstance(DateFormat.SHORT)
var aggregatedReactions = event.reactions.map { reaction ->
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/virtual/TimelineItemVirtualFactory.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/virtual/TimelineItemVirtualFactory.kt
index 64d08acacb..2fe782b960 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/virtual/TimelineItemVirtualFactory.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/virtual/TimelineItemVirtualFactory.kt
@@ -18,7 +18,10 @@ package io.element.android.features.messages.impl.timeline.factories.virtual
import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.features.messages.impl.timeline.model.virtual.TimelineItemEncryptedHistoryBannerVirtualModel
+import io.element.android.features.messages.impl.timeline.model.virtual.TimelineItemLastForwardIndicatorModel
+import io.element.android.features.messages.impl.timeline.model.virtual.TimelineItemLoadingIndicatorModel
import io.element.android.features.messages.impl.timeline.model.virtual.TimelineItemReadMarkerModel
+import io.element.android.features.messages.impl.timeline.model.virtual.TimelineItemRoomBeginningModel
import io.element.android.features.messages.impl.timeline.model.virtual.TimelineItemVirtualModel
import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem
import io.element.android.libraries.matrix.api.timeline.item.virtual.VirtualTimelineItem
@@ -41,6 +44,12 @@ class TimelineItemVirtualFactory @Inject constructor(
is VirtualTimelineItem.DayDivider -> daySeparatorFactory.create(inner)
is VirtualTimelineItem.ReadMarker -> TimelineItemReadMarkerModel
is VirtualTimelineItem.EncryptedHistoryBanner -> TimelineItemEncryptedHistoryBannerVirtualModel
+ is VirtualTimelineItem.RoomBeginning -> TimelineItemRoomBeginningModel
+ is VirtualTimelineItem.LoadingIndicator -> TimelineItemLoadingIndicatorModel(
+ direction = inner.direction,
+ timestamp = inner.timestamp
+ )
+ is VirtualTimelineItem.LastForwardIndicator -> TimelineItemLastForwardIndicatorModel
}
}
}
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/focus/FocusRequestStateProvider.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/focus/FocusRequestStateProvider.kt
new file mode 100644
index 0000000000..cb5b4a10f2
--- /dev/null
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/focus/FocusRequestStateProvider.kt
@@ -0,0 +1,45 @@
+/*
+ * Copyright (c) 2024 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.element.android.features.messages.impl.timeline.focus
+
+import androidx.compose.ui.tooling.preview.PreviewParameterProvider
+import io.element.android.features.messages.impl.timeline.FocusRequestState
+import io.element.android.libraries.matrix.api.core.EventId
+import io.element.android.libraries.matrix.api.room.errors.FocusEventException
+
+open class FocusRequestStateProvider : PreviewParameterProvider {
+ override val values: Sequence
+ get() = sequenceOf(
+ FocusRequestState.Fetching,
+ FocusRequestState.Failure(
+ FocusEventException.EventNotFound(
+ eventId = EventId("\$anEventId"),
+ )
+ ),
+ FocusRequestState.Failure(
+ FocusEventException.InvalidEventId(
+ eventId = "invalid",
+ err = "An error"
+ )
+ ),
+ FocusRequestState.Failure(
+ FocusEventException.Other(
+ msg = "An error"
+ )
+ ),
+ )
+}
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/focus/FocusRequestStateView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/focus/FocusRequestStateView.kt
new file mode 100644
index 0000000000..4a4381d269
--- /dev/null
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/focus/FocusRequestStateView.kt
@@ -0,0 +1,67 @@
+/*
+ * Copyright (c) 2024 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.element.android.features.messages.impl.timeline.focus
+
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.tooling.preview.PreviewParameter
+import io.element.android.features.messages.impl.timeline.FocusRequestState
+import io.element.android.libraries.designsystem.components.ProgressDialog
+import io.element.android.libraries.designsystem.components.dialogs.ErrorDialog
+import io.element.android.libraries.designsystem.preview.ElementPreview
+import io.element.android.libraries.designsystem.preview.PreviewsDayNight
+import io.element.android.libraries.matrix.api.room.errors.FocusEventException
+import io.element.android.libraries.ui.strings.CommonStrings
+
+@Composable
+fun FocusRequestStateView(
+ focusRequestState: FocusRequestState,
+ onClearFocusRequestState: () -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ when (focusRequestState) {
+ is FocusRequestState.Failure -> {
+ val errorMessage = when (focusRequestState.throwable) {
+ is FocusEventException.EventNotFound,
+ is FocusEventException.InvalidEventId -> stringResource(id = CommonStrings.error_message_not_found)
+ is FocusEventException.Other -> stringResource(id = CommonStrings.error_unknown)
+ else -> stringResource(id = CommonStrings.error_unknown)
+ }
+ ErrorDialog(
+ content = errorMessage,
+ onDismiss = onClearFocusRequestState,
+ modifier = modifier,
+ )
+ }
+ FocusRequestState.Fetching -> {
+ ProgressDialog(modifier = modifier, onDismissRequest = onClearFocusRequestState)
+ }
+ else -> Unit
+ }
+}
+
+@PreviewsDayNight
+@Composable
+internal fun FocusRequestStateViewPreview(
+ @PreviewParameter(FocusRequestStateProvider::class) state: FocusRequestState,
+) = ElementPreview {
+ FocusRequestStateView(
+ focusRequestState = state,
+ onClearFocusRequestState = {},
+ )
+}
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/InReplyToDetails.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/InReplyToDetails.kt
index 3c629f23fc..5317c3231e 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/InReplyToDetails.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/InReplyToDetails.kt
@@ -16,33 +16,45 @@
package io.element.android.features.messages.impl.timeline.model
+import androidx.compose.runtime.Immutable
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.permalink.PermalinkParser
import io.element.android.libraries.matrix.api.timeline.item.event.EventContent
import io.element.android.libraries.matrix.api.timeline.item.event.InReplyTo
import io.element.android.libraries.matrix.api.timeline.item.event.MessageContent
+import io.element.android.libraries.matrix.api.timeline.item.event.ProfileTimelineDetails
import io.element.android.libraries.matrix.api.timeline.item.event.StickerContent
import io.element.android.libraries.matrix.api.timeline.item.event.TextMessageType
import io.element.android.libraries.matrix.ui.messages.toPlainText
-data class InReplyToDetails(
- val eventId: EventId,
- val senderId: UserId,
- val senderDisplayName: String?,
- val senderAvatarUrl: String?,
- val eventContent: EventContent?,
- val textContent: String?,
-)
+@Immutable
+sealed interface InReplyToDetails {
+ data class Ready(
+ val eventId: EventId,
+ val senderId: UserId,
+ val senderProfile: ProfileTimelineDetails,
+ val eventContent: EventContent?,
+ val textContent: String?,
+ ) : InReplyToDetails
+
+ data class Loading(val eventId: EventId) : InReplyToDetails
+ data class Error(val eventId: EventId, val message: String) : InReplyToDetails
+}
+
+fun InReplyToDetails.eventId() = when (this) {
+ is InReplyToDetails.Ready -> eventId
+ is InReplyToDetails.Loading -> eventId
+ is InReplyToDetails.Error -> eventId
+}
fun InReplyTo.map(
permalinkParser: PermalinkParser,
) = when (this) {
- is InReplyTo.Ready -> InReplyToDetails(
+ is InReplyTo.Ready -> InReplyToDetails.Ready(
eventId = eventId,
senderId = senderId,
- senderDisplayName = senderDisplayName,
- senderAvatarUrl = senderAvatarUrl,
+ senderProfile = senderProfile,
eventContent = content,
textContent = when (content) {
is MessageContent -> {
@@ -56,5 +68,7 @@ fun InReplyTo.map(
else -> null
}
)
- else -> null
+ is InReplyTo.Error -> InReplyToDetails.Error(eventId, message)
+ is InReplyTo.NotLoaded -> InReplyToDetails.Loading(eventId)
+ is InReplyTo.Pending -> InReplyToDetails.Loading(eventId)
}
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/InReplyToMetadata.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/InReplyToMetadata.kt
index fb63912b69..ee6589f046 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/InReplyToMetadata.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/InReplyToMetadata.kt
@@ -66,7 +66,7 @@ internal sealed interface InReplyToMetadata {
* Metadata can be either a thumbnail with a text OR just a text.
*/
@Composable
-internal fun InReplyToDetails.metadata(): InReplyToMetadata? = when (eventContent) {
+internal fun InReplyToDetails.Ready.metadata(): InReplyToMetadata? = when (eventContent) {
is MessageContent -> when (val type = eventContent.type) {
is ImageMessageType -> InReplyToMetadata.Thumbnail(
AttachmentThumbnailInfo(
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/TimelineItem.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/TimelineItem.kt
index fa00c26760..e948484e5e 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/TimelineItem.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/TimelineItem.kt
@@ -27,7 +27,9 @@ import io.element.android.libraries.matrix.api.core.TransactionId
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.timeline.item.TimelineItemDebugInfo
import io.element.android.libraries.matrix.api.timeline.item.event.LocalEventSendState
+import io.element.android.libraries.matrix.api.timeline.item.event.ProfileTimelineDetails
import io.element.android.libraries.matrix.api.timeline.item.event.TimelineItemEventOrigin
+import io.element.android.libraries.matrix.api.timeline.item.event.getDisambiguatedDisplayName
import kotlinx.collections.immutable.ImmutableList
@Immutable
@@ -38,6 +40,14 @@ sealed interface TimelineItem {
is GroupedEvents -> id
}
+ fun isEvent(eventId: EventId?): Boolean {
+ if (eventId == null) return false
+ return when (this) {
+ is Event -> this.eventId == eventId
+ else -> false
+ }
+ }
+
fun contentType(): String = when (this) {
is Event -> content.type
is Virtual -> model.type
@@ -57,7 +67,7 @@ sealed interface TimelineItem {
val eventId: EventId? = null,
val transactionId: TransactionId? = null,
val senderId: UserId,
- val senderDisplayName: String?,
+ val senderProfile: ProfileTimelineDetails,
val senderAvatar: AvatarData,
val content: TimelineItemEventContent,
val sentTime: String = "",
@@ -74,7 +84,7 @@ sealed interface TimelineItem {
) : TimelineItem {
val showSenderInformation = groupPosition.isNew() && !isMine
- val safeSenderName: String = senderDisplayName ?: senderId.value
+ val safeSenderName: String = senderProfile.getDisambiguatedDisplayName(senderId)
val failedToSend: Boolean = localSendState is LocalEventSendState.SendingFailed
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemEncryptedContentProvider.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemEncryptedContentProvider.kt
new file mode 100644
index 0000000000..5c2b059ba5
--- /dev/null
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemEncryptedContentProvider.kt
@@ -0,0 +1,46 @@
+/*
+ * Copyright (c) 2024 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.element.android.features.messages.impl.timeline.model.event
+
+import androidx.compose.ui.tooling.preview.PreviewParameterProvider
+import io.element.android.libraries.matrix.api.timeline.item.event.UnableToDecryptContent
+import io.element.android.libraries.matrix.api.timeline.item.event.UtdCause
+
+open class TimelineItemEncryptedContentProvider : PreviewParameterProvider {
+ override val values: Sequence
+ get() = sequenceOf(
+ aTimelineItemEncryptedContent(),
+ aTimelineItemEncryptedContent(
+ data = UnableToDecryptContent.Data.MegolmV1AesSha2(
+ sessionId = "sessionId",
+ utdCause = UtdCause.Membership,
+ )
+ ),
+ aTimelineItemEncryptedContent(
+ data = UnableToDecryptContent.Data.MegolmV1AesSha2(
+ sessionId = "sessionId",
+ utdCause = UtdCause.Unknown,
+ )
+ ),
+ )
+}
+
+private fun aTimelineItemEncryptedContent(
+ data: UnableToDecryptContent.Data = UnableToDecryptContent.Data.Unknown
+) = TimelineItemEncryptedContent(
+ data = data
+)
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/virtual/TimelineItemLastForwardIndicatorModel.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/virtual/TimelineItemLastForwardIndicatorModel.kt
new file mode 100644
index 0000000000..b0b6376f50
--- /dev/null
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/virtual/TimelineItemLastForwardIndicatorModel.kt
@@ -0,0 +1,21 @@
+/*
+ * Copyright (c) 2024 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.element.android.features.messages.impl.timeline.model.virtual
+
+data object TimelineItemLastForwardIndicatorModel : TimelineItemVirtualModel {
+ override val type: String = "TimelineItemLastForwardIndicatorModel"
+}
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/virtual/TimelineItemLoadingIndicatorModel.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/virtual/TimelineItemLoadingIndicatorModel.kt
new file mode 100644
index 0000000000..da022d6d59
--- /dev/null
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/virtual/TimelineItemLoadingIndicatorModel.kt
@@ -0,0 +1,26 @@
+/*
+ * Copyright (c) 2024 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.element.android.features.messages.impl.timeline.model.virtual
+
+import io.element.android.libraries.matrix.api.timeline.Timeline
+
+data class TimelineItemLoadingIndicatorModel(
+ val direction: Timeline.PaginationDirection,
+ val timestamp: Long,
+) : TimelineItemVirtualModel {
+ override val type: String = "TimelineItemLoadingIndicatorModel"
+}
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/virtual/TimelineItemRoomBeginningModel.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/virtual/TimelineItemRoomBeginningModel.kt
new file mode 100644
index 0000000000..8e2abad575
--- /dev/null
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/virtual/TimelineItemRoomBeginningModel.kt
@@ -0,0 +1,21 @@
+/*
+ * Copyright (c) 2024 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.element.android.features.messages.impl.timeline.model.virtual
+
+data object TimelineItemRoomBeginningModel : TimelineItemVirtualModel {
+ override val type: String = "TimelineItemRoomBeginningModel"
+}
diff --git a/features/messages/impl/src/main/res/values-de/translations.xml b/features/messages/impl/src/main/res/values-de/translations.xml
index ddc498a6a9..785a615cab 100644
--- a/features/messages/impl/src/main/res/values-de/translations.xml
+++ b/features/messages/impl/src/main/res/values-de/translations.xml
@@ -20,11 +20,11 @@
"Standort"
"Umfrage"
"Textformatierung"
- "Der Nachrichtenverlauf ist derzeit in diesem Raum nicht verfügbar"
- "Der Nachrichtenverlauf ist in diesem Raum nicht verfügbar. Verifiziere dieses Gerät, um deinen Nachrichtenverlauf zu sehen."
+ "Der Nachrichtenverlauf ist derzeit nicht verfügbar"
+ "Der Nachrichtenverlauf ist nicht verfügbar. Verifiziere dieses Gerät, um deinen Nachrichtenverlauf zu sehen."
"Möchtest du sie wieder einladen?"
"Du bist allein in diesem Chat"
- "Den ganzen Raum benachrichtigen"
+ "Alle Mitglieder benachrichtigen"
"Alle"
"Erneut senden"
"Deine Nachricht konnte nicht gesendet werden"
@@ -33,7 +33,7 @@
"Dies ist der Anfang dieses Gesprächs."
"Weniger anzeigen"
"Nachricht wurde kopiert"
- "Du bist nicht berechtigt, in diesem Raum zu posten"
+ "Du bist nicht berechtigt, in diesem Raum zu schreiben"
"Weniger anzeigen"
"Mehr anzeigen"
"Neu"
diff --git a/features/messages/impl/src/main/res/values-sv/translations.xml b/features/messages/impl/src/main/res/values-sv/translations.xml
index fddf525181..c2ca9678e3 100644
--- a/features/messages/impl/src/main/res/values-sv/translations.xml
+++ b/features/messages/impl/src/main/res/values-sv/translations.xml
@@ -21,8 +21,10 @@
"Omröstning"
"Textformatering"
"Meddelandehistoriken är för närvarande otillgänglig."
+ "Meddelandehistorik är inte tillgänglig i det här rummet. Verifiera den här enheten för att se din meddelandehistorik."
"Vill du bjuda tillbaka dem?"
"Du är ensam i den här chatten"
+ "Meddela hela rummet"
"Alla"
"Skicka igen"
"Ditt meddelande kunde inte skickas"
@@ -39,4 +41,13 @@
- "%1$d rumsändring"
- "%1$d rumsändringar"
+
+ - "%1$s, %2$s och %3$d annan"
+ - "%1$s, %2$s och %3$d andra"
+
+
+ - "%1$s skriver"
+ - "%1$s skriver"
+
+ "%1$s och %2$s"
diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/MessagesPresenterTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/MessagesPresenterTest.kt
index 002b34f0eb..3adc7059b0 100644
--- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/MessagesPresenterTest.kt
+++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/MessagesPresenterTest.kt
@@ -31,6 +31,8 @@ import io.element.android.features.messages.impl.messagecomposer.MessageComposer
import io.element.android.features.messages.impl.messagecomposer.MessageComposerPresenter
import io.element.android.features.messages.impl.messagesummary.FakeMessageSummaryFormatter
import io.element.android.features.messages.impl.textcomposer.TestRichTextEditorStateFactory
+import io.element.android.features.messages.impl.timeline.TimelineController
+import io.element.android.features.messages.impl.timeline.TimelineItemIndexer
import io.element.android.features.messages.impl.timeline.TimelinePresenter
import io.element.android.features.messages.impl.timeline.components.customreaction.CustomReactionPresenter
import io.element.android.features.messages.impl.timeline.components.customreaction.FakeEmojibaseProvider
@@ -61,8 +63,7 @@ import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher
import io.element.android.libraries.featureflag.api.FeatureFlags
import io.element.android.libraries.featureflag.test.FakeFeatureFlagService
-import io.element.android.libraries.featureflag.test.InMemoryAppPreferencesStore
-import io.element.android.libraries.featureflag.test.InMemorySessionPreferencesStore
+import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.media.MediaSource
import io.element.android.libraries.matrix.api.room.MatrixRoom
import io.element.android.libraries.matrix.api.room.MatrixRoomMembersState
@@ -81,6 +82,7 @@ import io.element.android.libraries.matrix.test.permalink.FakePermalinkParser
import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
import io.element.android.libraries.matrix.test.room.aRoomInfo
import io.element.android.libraries.matrix.test.room.aRoomMember
+import io.element.android.libraries.matrix.test.timeline.FakeTimeline
import io.element.android.libraries.mediapickers.test.FakePickerProvider
import io.element.android.libraries.mediaplayer.test.FakeMediaPlayer
import io.element.android.libraries.mediaupload.api.MediaSender
@@ -89,12 +91,17 @@ import io.element.android.libraries.mediaviewer.test.FakeLocalMediaFactory
import io.element.android.libraries.permissions.api.PermissionsPresenter
import io.element.android.libraries.permissions.test.FakePermissionsPresenter
import io.element.android.libraries.permissions.test.FakePermissionsPresenterFactory
+import io.element.android.libraries.preferences.test.InMemoryAppPreferencesStore
+import io.element.android.libraries.preferences.test.InMemorySessionPreferencesStore
import io.element.android.libraries.textcomposer.model.MessageComposerMode
import io.element.android.libraries.voicerecorder.test.FakeVoiceRecorder
import io.element.android.services.analytics.test.FakeAnalyticsService
import io.element.android.tests.testutils.WarmUpRule
import io.element.android.tests.testutils.consumeItemsUntilPredicate
import io.element.android.tests.testutils.consumeItemsUntilTimeout
+import io.element.android.tests.testutils.lambda.assert
+import io.element.android.tests.testutils.lambda.lambdaRecorder
+import io.element.android.tests.testutils.lambda.value
import io.element.android.tests.testutils.testCoroutineDispatchers
import io.mockk.mockk
import kotlinx.collections.immutable.persistentListOf
@@ -167,7 +174,13 @@ class MessagesPresenterTest {
@Test
fun `present - handle toggling a reaction`() = runTest {
val coroutineDispatchers = testCoroutineDispatchers(useUnconfinedTestDispatcher = true)
- val room = FakeMatrixRoom()
+ val toggleReactionSuccess = lambdaRecorder { _: String, _: EventId -> Result.success(Unit) }
+ val toggleReactionFailure = lambdaRecorder { _: String, _: EventId -> Result.failure(IllegalStateException("Failed to send reaction")) }
+
+ val timeline = FakeTimeline().apply {
+ this.toggleReactionLambda = toggleReactionSuccess
+ }
+ val room = FakeMatrixRoom(liveTimeline = timeline)
val presenter = createMessagesPresenter(matrixRoom = room, coroutineDispatchers = coroutineDispatchers)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
@@ -175,29 +188,42 @@ class MessagesPresenterTest {
skipItems(1)
val initialState = awaitItem()
initialState.eventSink.invoke(MessagesEvents.ToggleReaction("👍", AN_EVENT_ID))
- assertThat(room.myReactions.count()).isEqualTo(1)
// No crashes when sending a reaction failed
- room.givenToggleReactionResult(Result.failure(IllegalStateException("Failed to send reaction")))
+ timeline.apply { toggleReactionLambda = toggleReactionFailure }
initialState.eventSink.invoke(MessagesEvents.ToggleReaction("👍", AN_EVENT_ID))
- assertThat(room.myReactions.count()).isEqualTo(1)
assertThat(awaitItem().actionListState.target).isEqualTo(ActionListState.Target.None)
+
+ assert(toggleReactionSuccess)
+ .isCalledOnce()
+ .with(value("👍"), value(AN_EVENT_ID))
+ assert(toggleReactionFailure)
+ .isCalledOnce()
+ .with(value("👍"), value(AN_EVENT_ID))
}
}
@Test
fun `present - handle toggling a reaction twice`() = runTest {
val coroutineDispatchers = testCoroutineDispatchers(useUnconfinedTestDispatcher = true)
- val room = FakeMatrixRoom()
+ val toggleReactionSuccess = lambdaRecorder { _: String, _: EventId -> Result.success(Unit) }
+
+ val timeline = FakeTimeline().apply {
+ this.toggleReactionLambda = toggleReactionSuccess
+ }
+ val room = FakeMatrixRoom(liveTimeline = timeline)
val presenter = createMessagesPresenter(matrixRoom = room, coroutineDispatchers = coroutineDispatchers)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitFirstItem()
initialState.eventSink.invoke(MessagesEvents.ToggleReaction("👍", AN_EVENT_ID))
- assertThat(room.myReactions.count()).isEqualTo(1)
-
initialState.eventSink.invoke(MessagesEvents.ToggleReaction("👍", AN_EVENT_ID))
- assertThat(room.myReactions.count()).isEqualTo(0)
+ assert(toggleReactionSuccess)
+ .isCalledExactly(2)
+ .withSequence(
+ listOf(value("👍"), value(AN_EVENT_ID)),
+ listOf(value("👍"), value(AN_EVENT_ID)),
+ )
}
}
@@ -262,7 +288,7 @@ class MessagesPresenterTest {
initialState.eventSink.invoke(MessagesEvents.HandleAction(TimelineItemAction.Reply, aMessageEvent()))
val finalState = awaitItem()
assertThat(finalState.composerState.mode).isInstanceOf(MessageComposerMode.Reply::class.java)
- assertThat(awaitItem().actionListState.target).isEqualTo(ActionListState.Target.None)
+ assertThat(finalState.actionListState.target).isEqualTo(ActionListState.Target.None)
}
}
@@ -272,10 +298,9 @@ class MessagesPresenterTest {
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
- skipItems(3)
- val initialState = awaitItem()
+ val initialState = awaitFirstItem()
initialState.eventSink.invoke(MessagesEvents.HandleAction(TimelineItemAction.Reply, aMessageEvent(eventId = null)))
- assertThat(awaitItem().actionListState.target).isEqualTo(ActionListState.Target.None)
+ assertThat(initialState.actionListState.target).isEqualTo(ActionListState.Target.None)
// Otherwise we would have some extra items here
ensureAllEventsConsumed()
}
@@ -309,7 +334,7 @@ class MessagesPresenterTest {
assertThat(finalState.composerState.mode).isInstanceOf(MessageComposerMode.Reply::class.java)
val replyMode = finalState.composerState.mode as MessageComposerMode.Reply
assertThat(replyMode.attachmentThumbnailInfo).isNotNull()
- assertThat(awaitItem().actionListState.target).isEqualTo(ActionListState.Target.None)
+ assertThat(finalState.actionListState.target).isEqualTo(ActionListState.Target.None)
}
}
@@ -342,7 +367,7 @@ class MessagesPresenterTest {
assertThat(finalState.composerState.mode).isInstanceOf(MessageComposerMode.Reply::class.java)
val replyMode = finalState.composerState.mode as MessageComposerMode.Reply
assertThat(replyMode.attachmentThumbnailInfo).isNotNull()
- assertThat(awaitItem().actionListState.target).isEqualTo(ActionListState.Target.None)
+ assertThat(finalState.actionListState.target).isEqualTo(ActionListState.Target.None)
}
}
@@ -368,7 +393,7 @@ class MessagesPresenterTest {
assertThat(finalState.composerState.mode).isInstanceOf(MessageComposerMode.Reply::class.java)
val replyMode = finalState.composerState.mode as MessageComposerMode.Reply
assertThat(replyMode.attachmentThumbnailInfo).isNotNull()
- assertThat(awaitItem().actionListState.target).isEqualTo(ActionListState.Target.None)
+ assertThat(finalState.actionListState.target).isEqualTo(ActionListState.Target.None)
}
}
@@ -382,7 +407,7 @@ class MessagesPresenterTest {
initialState.eventSink.invoke(MessagesEvents.HandleAction(TimelineItemAction.Edit, aMessageEvent()))
val finalState = awaitItem()
assertThat(finalState.composerState.mode).isInstanceOf(MessageComposerMode.Edit::class.java)
- assertThat(awaitItem().actionListState.target).isEqualTo(ActionListState.Target.None)
+ assertThat(finalState.actionListState.target).isEqualTo(ActionListState.Target.None)
}
}
@@ -428,7 +453,6 @@ class MessagesPresenterTest {
initialState.eventSink.invoke(MessagesEvents.HandleAction(TimelineItemAction.Redact, aMessageEvent()))
assertThat(matrixRoom.redactEventEventIdParam).isEqualTo(AN_EVENT_ID)
assertThat(awaitItem().actionListState.target).isEqualTo(ActionListState.Target.None)
- skipItems(1) // back paginating
}
}
@@ -707,7 +731,7 @@ class MessagesPresenterTest {
assertThat(replyMode.attachmentThumbnailInfo).isNotNull()
assertThat(replyMode.attachmentThumbnailInfo?.textContent)
.isEqualTo("What type of food should we have at the party?")
- assertThat(awaitItem().actionListState.target).isEqualTo(ActionListState.Target.None)
+ assertThat(finalState.actionListState.target).isEqualTo(ActionListState.Target.None)
}
}
@@ -720,7 +744,7 @@ class MessagesPresenterTest {
private fun TestScope.createMessagesPresenter(
coroutineDispatchers: CoroutineDispatchers = testCoroutineDispatchers(),
matrixRoom: MatrixRoom = FakeMatrixRoom().apply {
- givenRoomInfo(aRoomInfo(id = roomId.value, name = ""))
+ givenRoomInfo(aRoomInfo(id = roomId, name = ""))
},
navigator: FakeMessagesNavigator = FakeMessagesNavigator(),
clipboardHelper: FakeClipboardHelper = FakeClipboardHelper(),
@@ -748,6 +772,7 @@ class MessagesPresenterTest {
currentSessionIdHolder = CurrentSessionIdHolder(FakeMatrixClient(A_SESSION_ID)),
permalinkParser = FakePermalinkParser(),
permalinkBuilder = FakePermalinkBuilder(),
+ timelineController = TimelineController(matrixRoom),
)
val voiceMessageComposerPresenter = VoiceMessageComposerPresenter(
this,
@@ -768,6 +793,8 @@ class MessagesPresenterTest {
endPollAction = endPollAction,
sendPollResponseAction = FakeSendPollResponseAction(),
sessionPreferencesStore = sessionPreferencesStore,
+ timelineItemIndexer = TimelineItemIndexer(),
+ timelineController = TimelineController(matrixRoom),
)
val timelinePresenterFactory = object : TimelinePresenter.Factory {
override fun create(navigator: MessagesNavigator): TimelinePresenter {
@@ -804,6 +831,7 @@ class MessagesPresenterTest {
buildMeta = aBuildMeta(),
dispatchers = coroutineDispatchers,
htmlConverterProvider = FakeHtmlConverterProvider(),
+ timelineController = TimelineController(matrixRoom),
)
}
}
diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/actionlist/ActionListPresenterTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/actionlist/ActionListPresenterTest.kt
index c1062625f7..39ffda04b2 100644
--- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/actionlist/ActionListPresenterTest.kt
+++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/actionlist/ActionListPresenterTest.kt
@@ -30,8 +30,8 @@ import io.element.android.features.messages.impl.timeline.model.event.aTimelineI
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemStateEventContent
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemVoiceContent
import io.element.android.features.poll.api.pollcontent.aPollAnswerItemList
-import io.element.android.libraries.featureflag.test.InMemoryAppPreferencesStore
import io.element.android.libraries.matrix.test.A_MESSAGE
+import io.element.android.libraries.preferences.test.InMemoryAppPreferencesStore
import io.element.android.tests.testutils.WarmUpRule
import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.test.runTest
diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/fixtures/MessageEventFixtures.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/fixtures/MessageEventFixtures.kt
index f700ed34c3..b959ff151f 100644
--- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/fixtures/MessageEventFixtures.kt
+++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/fixtures/MessageEventFixtures.kt
@@ -18,6 +18,7 @@ package io.element.android.features.messages.impl.fixtures
import io.element.android.features.messages.impl.timeline.aTimelineItemDebugInfo
import io.element.android.features.messages.impl.timeline.aTimelineItemReactions
+import io.element.android.features.messages.impl.timeline.components.aProfileTimelineDetailsReady
import io.element.android.features.messages.impl.timeline.model.InReplyToDetails
import io.element.android.features.messages.impl.timeline.model.ReadReceiptData
import io.element.android.features.messages.impl.timeline.model.TimelineItem
@@ -48,7 +49,7 @@ internal fun aMessageEvent(
id = eventId?.value.orEmpty(),
eventId = eventId,
senderId = A_USER_ID,
- senderDisplayName = A_USER_NAME,
+ senderProfile = aProfileTimelineDetailsReady(displayName = A_USER_NAME),
senderAvatar = AvatarData(A_USER_ID.value, A_USER_NAME, size = AvatarSize.TimelineSender),
content = content,
sentTime = "",
diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/fixtures/TimelineItemsFactoryFixtures.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/fixtures/TimelineItemsFactoryFixtures.kt
index db056dea19..95dffe2bc0 100644
--- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/fixtures/TimelineItemsFactoryFixtures.kt
+++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/fixtures/TimelineItemsFactoryFixtures.kt
@@ -16,6 +16,7 @@
package io.element.android.features.messages.impl.fixtures
+import io.element.android.features.messages.impl.timeline.TimelineItemIndexer
import io.element.android.features.messages.impl.timeline.factories.TimelineItemsFactory
import io.element.android.features.messages.impl.timeline.factories.event.TimelineItemContentFactory
import io.element.android.features.messages.impl.timeline.factories.event.TimelineItemContentFailedToParseMessageFactory
@@ -46,7 +47,9 @@ import io.element.android.libraries.mediaviewer.api.util.FileExtensionExtractorW
import io.element.android.tests.testutils.testCoroutineDispatchers
import kotlinx.coroutines.test.TestScope
-internal fun TestScope.aTimelineItemsFactory(): TimelineItemsFactory {
+internal fun TestScope.aTimelineItemsFactory(
+ timelineItemIndexer: TimelineItemIndexer = TimelineItemIndexer()
+): TimelineItemsFactory {
val timelineEventFormatter = aTimelineEventFormatter()
val matrixClient = FakeMatrixClient()
return TimelineItemsFactory(
@@ -83,6 +86,7 @@ internal fun TestScope.aTimelineItemsFactory(): TimelineItemsFactory {
),
),
timelineItemGrouper = TimelineItemGrouper(),
+ timelineItemIndexer = timelineItemIndexer,
)
}
diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/forward/ForwardMessagesPresenterTests.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/forward/ForwardMessagesPresenterTests.kt
index c9acdba508..7715230511 100644
--- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/forward/ForwardMessagesPresenterTests.kt
+++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/forward/ForwardMessagesPresenterTests.kt
@@ -21,14 +21,20 @@ import app.cash.molecule.moleculeFlow
import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.matrix.api.core.EventId
+import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.test.AN_EVENT_ID
import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
import io.element.android.libraries.matrix.test.room.aRoomSummaryDetails
+import io.element.android.libraries.matrix.test.timeline.FakeTimeline
+import io.element.android.libraries.matrix.test.timeline.LiveTimelineProvider
import io.element.android.tests.testutils.WarmUpRule
+import io.element.android.tests.testutils.lambda.assert
+import io.element.android.tests.testutils.lambda.lambdaRecorder
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.test.runTest
import org.junit.Rule
import org.junit.Test
+import java.lang.IllegalStateException
class ForwardMessagesPresenterTests {
@get:Rule
@@ -36,7 +42,7 @@ class ForwardMessagesPresenterTests {
@Test
fun `present - initial state`() = runTest {
- val presenter = aPresenter()
+ val presenter = aForwardMessagesPresenter()
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
@@ -49,7 +55,14 @@ class ForwardMessagesPresenterTests {
@Test
fun `present - forward successful`() = runTest {
- val presenter = aPresenter()
+ val forwardEventLambda = lambdaRecorder { _: EventId, _: List ->
+ Result.success(Unit)
+ }
+ val timeline = FakeTimeline().apply {
+ this.forwardEventLambda = forwardEventLambda
+ }
+ val room = FakeMatrixRoom(liveTimeline = timeline)
+ val presenter = aForwardMessagesPresenter(fakeMatrixRoom = room)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
@@ -61,18 +74,23 @@ class ForwardMessagesPresenterTests {
val successfulForwardState = awaitItem()
assertThat(successfulForwardState.isForwarding).isFalse()
assertThat(successfulForwardState.forwardingSucceeded).isNotNull()
+ assert(forwardEventLambda).isCalledOnce()
}
}
@Test
fun `present - select a room and forward failed, then clear`() = runTest {
- val room = FakeMatrixRoom()
- val presenter = aPresenter(fakeMatrixRoom = room)
+ val forwardEventLambda = lambdaRecorder { _: EventId, _: List ->
+ Result.failure(IllegalStateException("error"))
+ }
+ val timeline = FakeTimeline().apply {
+ this.forwardEventLambda = forwardEventLambda
+ }
+ val room = FakeMatrixRoom(liveTimeline = timeline)
+ val presenter = aForwardMessagesPresenter(fakeMatrixRoom = room)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
- // Test failed forwarding
- room.givenForwardEventResult(Result.failure(Throwable("error")))
skipItems(1)
val summary = aRoomSummaryDetails()
presenter.onRoomSelected(listOf(summary.roomId))
@@ -82,16 +100,17 @@ class ForwardMessagesPresenterTests {
// Then clear error
failedForwardState.eventSink(ForwardMessagesEvents.ClearError)
assertThat(awaitItem().error).isNull()
+ assert(forwardEventLambda).isCalledOnce()
}
}
- private fun CoroutineScope.aPresenter(
+ private fun CoroutineScope.aForwardMessagesPresenter(
eventId: EventId = AN_EVENT_ID,
fakeMatrixRoom: FakeMatrixRoom = FakeMatrixRoom(),
coroutineScope: CoroutineScope = this,
) = ForwardMessagesPresenter(
eventId = eventId.value,
- room = fakeMatrixRoom,
+ timelineProvider = LiveTimelineProvider(fakeMatrixRoom),
matrixCoroutineScope = coroutineScope,
)
}
diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/textcomposer/MessageComposerPresenterTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/textcomposer/MessageComposerPresenterTest.kt
index 290f297117..7274027059 100644
--- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/textcomposer/MessageComposerPresenterTest.kt
+++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/textcomposer/MessageComposerPresenterTest.kt
@@ -32,13 +32,13 @@ import io.element.android.features.messages.impl.messagecomposer.MessageComposer
import io.element.android.features.messages.impl.messagecomposer.MessageComposerEvents
import io.element.android.features.messages.impl.messagecomposer.MessageComposerPresenter
import io.element.android.features.messages.impl.messagecomposer.MessageComposerState
+import io.element.android.features.messages.impl.timeline.TimelineController
import io.element.android.features.preferences.api.store.SessionPreferencesStore
import io.element.android.libraries.core.mimetype.MimeTypes
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher
import io.element.android.libraries.featureflag.api.FeatureFlagService
import io.element.android.libraries.featureflag.api.FeatureFlags
import io.element.android.libraries.featureflag.test.FakeFeatureFlagService
-import io.element.android.libraries.featureflag.test.InMemorySessionPreferencesStore
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.TransactionId
import io.element.android.libraries.matrix.api.media.ImageInfo
@@ -65,6 +65,7 @@ import io.element.android.libraries.matrix.test.permalink.FakePermalinkBuilder
import io.element.android.libraries.matrix.test.permalink.FakePermalinkParser
import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
import io.element.android.libraries.matrix.test.room.aRoomMember
+import io.element.android.libraries.matrix.test.timeline.FakeTimeline
import io.element.android.libraries.mediapickers.api.PickerProvider
import io.element.android.libraries.mediapickers.test.FakePickerProvider
import io.element.android.libraries.mediaupload.api.MediaPreProcessor
@@ -75,12 +76,17 @@ import io.element.android.libraries.mediaviewer.test.FakeLocalMediaFactory
import io.element.android.libraries.permissions.api.PermissionsPresenter
import io.element.android.libraries.permissions.test.FakePermissionsPresenter
import io.element.android.libraries.permissions.test.FakePermissionsPresenterFactory
+import io.element.android.libraries.preferences.test.InMemorySessionPreferencesStore
import io.element.android.libraries.textcomposer.model.Message
import io.element.android.libraries.textcomposer.model.MessageComposerMode
import io.element.android.libraries.textcomposer.model.Suggestion
import io.element.android.libraries.textcomposer.model.SuggestionType
import io.element.android.services.analytics.test.FakeAnalyticsService
import io.element.android.tests.testutils.WarmUpRule
+import io.element.android.tests.testutils.lambda.any
+import io.element.android.tests.testutils.lambda.assert
+import io.element.android.tests.testutils.lambda.lambdaRecorder
+import io.element.android.tests.testutils.lambda.value
import io.element.android.tests.testutils.waitForPredicate
import io.mockk.mockk
import kotlinx.collections.immutable.persistentListOf
@@ -259,7 +265,13 @@ class MessageComposerPresenterTest {
@Test
fun `present - edit sent message`() = runTest {
- val fakeMatrixRoom = FakeMatrixRoom()
+ val editMessageLambda = lambdaRecorder { _: EventId?, _: TransactionId?, _: String, _: String?, _: List ->
+ Result.success(Unit)
+ }
+ val timeline = FakeTimeline().apply {
+ this.editMessageLambda = editMessageLambda
+ }
+ val fakeMatrixRoom = FakeMatrixRoom(liveTimeline = timeline)
val presenter = createPresenter(
this,
fakeMatrixRoom,
@@ -283,7 +295,13 @@ class MessageComposerPresenterTest {
skipItems(1)
val messageSentState = awaitItem()
assertThat(messageSentState.richTextEditorState.messageHtml).isEqualTo("")
- assertThat(fakeMatrixRoom.editMessageCalls.first()).isEqualTo(ANOTHER_MESSAGE to ANOTHER_MESSAGE)
+
+ advanceUntilIdle()
+
+ assert(editMessageLambda)
+ .isCalledOnce()
+ .with(any(), any(), value(ANOTHER_MESSAGE), value(ANOTHER_MESSAGE), any())
+
assertThat(analyticsService.capturedEvents).containsExactly(
Composer(
inThread = false,
@@ -297,7 +315,13 @@ class MessageComposerPresenterTest {
@Test
fun `present - edit not sent message`() = runTest {
- val fakeMatrixRoom = FakeMatrixRoom()
+ val editMessageLambda = lambdaRecorder { _: EventId?, _: TransactionId?, _: String, _: String?, _: List ->
+ Result.success(Unit)
+ }
+ val timeline = FakeTimeline().apply {
+ this.editMessageLambda = editMessageLambda
+ }
+ val fakeMatrixRoom = FakeMatrixRoom(liveTimeline = timeline)
val presenter = createPresenter(
this,
fakeMatrixRoom,
@@ -321,7 +345,13 @@ class MessageComposerPresenterTest {
skipItems(1)
val messageSentState = awaitItem()
assertThat(messageSentState.richTextEditorState.messageHtml).isEqualTo("")
- assertThat(fakeMatrixRoom.editMessageCalls.first()).isEqualTo(ANOTHER_MESSAGE to ANOTHER_MESSAGE)
+
+ advanceUntilIdle()
+
+ assert(editMessageLambda)
+ .isCalledOnce()
+ .with(any(), any(), value(ANOTHER_MESSAGE), value(ANOTHER_MESSAGE), any())
+
assertThat(analyticsService.capturedEvents).containsExactly(
Composer(
inThread = false,
@@ -335,7 +365,13 @@ class MessageComposerPresenterTest {
@Test
fun `present - reply message`() = runTest {
- val fakeMatrixRoom = FakeMatrixRoom()
+ val replyMessageLambda = lambdaRecorder { _: EventId, _: String, _: String?, _: List ->
+ Result.success(Unit)
+ }
+ val timeline = FakeTimeline().apply {
+ this.replyMessageLambda = replyMessageLambda
+ }
+ val fakeMatrixRoom = FakeMatrixRoom(liveTimeline = timeline)
val presenter = createPresenter(
this,
fakeMatrixRoom,
@@ -355,7 +391,13 @@ class MessageComposerPresenterTest {
state.eventSink.invoke(MessageComposerEvents.SendMessage(A_REPLY.toMessage()))
val messageSentState = awaitItem()
assertThat(messageSentState.richTextEditorState.messageHtml).isEqualTo("")
- assertThat(fakeMatrixRoom.replyMessageParameter).isEqualTo(A_REPLY to A_REPLY)
+
+ advanceUntilIdle()
+
+ assert(replyMessageLambda)
+ .isCalledOnce()
+ .with(any(), value(A_REPLY), value(A_REPLY), any())
+
assertThat(analyticsService.capturedEvents).containsExactly(
Composer(
inThread = false,
@@ -831,7 +873,17 @@ class MessageComposerPresenterTest {
@OptIn(ExperimentalCoroutinesApi::class)
@Test
fun `present - send messages with intentional mentions`() = runTest {
- val room = FakeMatrixRoom()
+ val replyMessageLambda = lambdaRecorder { _: EventId, _: String, _: String?, _: List ->
+ Result.success(Unit)
+ }
+ val editMessageLambda = lambdaRecorder { _: EventId?, _: TransactionId?, _: String, _: String?, _: List ->
+ Result.success(Unit)
+ }
+ val timeline = FakeTimeline().apply {
+ this.replyMessageLambda = replyMessageLambda
+ this.editMessageLambda = editMessageLambda
+ }
+ val room = FakeMatrixRoom(liveTimeline = timeline)
val presenter = createPresenter(room = room, coroutineScope = this)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
@@ -866,7 +918,9 @@ class MessageComposerPresenterTest {
initialState.eventSink(MessageComposerEvents.SendMessage(A_MESSAGE.toMessage()))
advanceUntilIdle()
- assertThat(room.sendMessageMentions).isEqualTo(listOf(Mention.User(A_USER_ID_2)))
+ assert(replyMessageLambda)
+ .isCalledOnce()
+ .with(any(), any(), any(), value(listOf(Mention.User(A_USER_ID_2))))
// Check intentional mentions on edit message
skipItems(1)
@@ -882,7 +936,9 @@ class MessageComposerPresenterTest {
initialState.eventSink(MessageComposerEvents.SendMessage(A_MESSAGE.toMessage()))
advanceUntilIdle()
- assertThat(room.sendMessageMentions).isEqualTo(listOf(Mention.User(A_USER_ID_3)))
+ assert(editMessageLambda)
+ .isCalledOnce()
+ .with(any(), any(), any(), any(), value(listOf(Mention.User(A_USER_ID_3))))
skipItems(1)
}
@@ -968,6 +1024,7 @@ class MessageComposerPresenterTest {
permissionsPresenterFactory = FakePermissionsPresenterFactory(permissionPresenter),
permalinkParser = FakePermalinkParser(),
permalinkBuilder = permalinkBuilder,
+ timelineController = TimelineController(room),
)
private suspend fun ReceiveTurbine.awaitFirstItem(): T {
diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/TimelineControllerTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/TimelineControllerTest.kt
new file mode 100644
index 0000000000..5bd1145aa3
--- /dev/null
+++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/TimelineControllerTest.kt
@@ -0,0 +1,217 @@
+/*
+ * Copyright (c) 2024 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.element.android.features.messages.impl.timeline
+
+import app.cash.turbine.test
+import com.google.common.truth.Truth.assertThat
+import io.element.android.libraries.matrix.api.room.Mention
+import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem
+import io.element.android.libraries.matrix.api.timeline.Timeline
+import io.element.android.libraries.matrix.test.AN_EVENT_ID
+import io.element.android.libraries.matrix.test.A_UNIQUE_ID
+import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
+import io.element.android.libraries.matrix.test.timeline.FakeTimeline
+import io.element.android.libraries.matrix.test.timeline.anEventTimelineItem
+import io.element.android.tests.testutils.lambda.lambdaRecorder
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.flow.flowOf
+import kotlinx.coroutines.test.runTest
+import org.junit.Test
+
+class TimelineControllerTest {
+ @Test
+ fun `test switching between live and detached timeline`() = runTest {
+ val liveTimeline = FakeTimeline(name = "live")
+ val detachedTimeline = FakeTimeline(name = "detached")
+ val matrixRoom = FakeMatrixRoom(
+ liveTimeline = liveTimeline
+ )
+ matrixRoom.givenTimelineFocusedOnEventResult(Result.success(detachedTimeline))
+ val sut = TimelineController(matrixRoom)
+
+ sut.activeTimelineFlow().test {
+ awaitItem().also { state ->
+ assertThat(state).isEqualTo(liveTimeline)
+ }
+ assertThat(sut.isLive().first()).isTrue()
+ sut.focusOnEvent(AN_EVENT_ID)
+ awaitItem().also { state ->
+ assertThat(state).isEqualTo(detachedTimeline)
+ }
+ assertThat(sut.isLive().first()).isFalse()
+ assertThat(detachedTimeline.closeCounter).isEqualTo(0)
+ sut.focusOnLive()
+ assertThat(sut.isLive().first()).isTrue()
+ awaitItem().also { state ->
+ assertThat(state).isEqualTo(liveTimeline)
+ }
+ assertThat(detachedTimeline.closeCounter).isEqualTo(1)
+ }
+ }
+
+ @Test
+ fun `test switching between detached 1 and detached 2 should close detached 1`() = runTest {
+ val liveTimeline = FakeTimeline(name = "live")
+ val detachedTimeline1 = FakeTimeline(name = "detached 1")
+ val detachedTimeline2 = FakeTimeline(name = "detached 2")
+ val matrixRoom = FakeMatrixRoom(
+ liveTimeline = liveTimeline
+ )
+ val sut = TimelineController(matrixRoom)
+
+ sut.activeTimelineFlow().test {
+ awaitItem().also { state ->
+ assertThat(state).isEqualTo(liveTimeline)
+ }
+ matrixRoom.givenTimelineFocusedOnEventResult(Result.success(detachedTimeline1))
+ sut.focusOnEvent(AN_EVENT_ID)
+ awaitItem().also { state ->
+ assertThat(state).isEqualTo(detachedTimeline1)
+ }
+ assertThat(detachedTimeline1.closeCounter).isEqualTo(0)
+ assertThat(detachedTimeline2.closeCounter).isEqualTo(0)
+ // Focus on another event should close the previous detached timeline
+ matrixRoom.givenTimelineFocusedOnEventResult(Result.success(detachedTimeline2))
+ sut.focusOnEvent(AN_EVENT_ID)
+ awaitItem().also { state ->
+ assertThat(state).isEqualTo(detachedTimeline2)
+ }
+ assertThat(detachedTimeline1.closeCounter).isEqualTo(1)
+ assertThat(detachedTimeline2.closeCounter).isEqualTo(0)
+ }
+ }
+
+ @Test
+ fun `test switching to live when already in live should have no effect`() = runTest {
+ val liveTimeline = FakeTimeline(name = "live")
+ val matrixRoom = FakeMatrixRoom(
+ liveTimeline = liveTimeline
+ )
+ val sut = TimelineController(matrixRoom)
+ sut.activeTimelineFlow().test {
+ awaitItem().also { state ->
+ assertThat(state).isEqualTo(liveTimeline)
+ }
+ assertThat(sut.isLive().first()).isTrue()
+ sut.focusOnLive()
+ assertThat(sut.isLive().first()).isTrue()
+ }
+ }
+
+ @Test
+ fun `test closing the TimelineController should close the detached timeline`() = runTest {
+ val liveTimeline = FakeTimeline(name = "live")
+ val detachedTimeline = FakeTimeline(name = "detached")
+ val matrixRoom = FakeMatrixRoom(
+ liveTimeline = liveTimeline
+ )
+ matrixRoom.givenTimelineFocusedOnEventResult(Result.success(detachedTimeline))
+ val sut = TimelineController(matrixRoom)
+
+ sut.activeTimelineFlow().test {
+ awaitItem().also { state ->
+ assertThat(state).isEqualTo(liveTimeline)
+ }
+ sut.focusOnEvent(AN_EVENT_ID)
+ awaitItem().also { state ->
+ assertThat(state).isEqualTo(detachedTimeline)
+ }
+ assertThat(detachedTimeline.closeCounter).isEqualTo(0)
+ sut.close()
+ assertThat(detachedTimeline.closeCounter).isEqualTo(1)
+ }
+ }
+
+ @Test
+ fun `test getting timeline item`() = runTest {
+ val liveTimeline = FakeTimeline(
+ name = "live",
+ timelineItems = flowOf(
+ listOf(
+ MatrixTimelineItem.Event(A_UNIQUE_ID, anEventTimelineItem())
+ )
+ )
+ )
+ val matrixRoom = FakeMatrixRoom(
+ liveTimeline = liveTimeline
+ )
+ val sut = TimelineController(matrixRoom)
+ assertThat(sut.timelineItems().first()).hasSize(1)
+ }
+
+ @Test
+ fun `test invokeOnCurrentTimeline use the detached timeline and not the live timeline`() = runTest {
+ val lambdaForDetached = lambdaRecorder { _: String, _: String?, _: List ->
+ Result.success(Unit)
+ }
+ val lambdaForLive = lambdaRecorder(ensureNeverCalled = true) { _: String, _: String?, _: List ->
+ Result.success(Unit)
+ }
+ val liveTimeline = FakeTimeline(name = "live").apply {
+ sendMessageLambda = lambdaForLive
+ }
+ val detachedTimeline = FakeTimeline(name = "detached").apply {
+ sendMessageLambda = lambdaForDetached
+ }
+ val matrixRoom = FakeMatrixRoom(
+ liveTimeline = liveTimeline
+ )
+ matrixRoom.givenTimelineFocusedOnEventResult(Result.success(detachedTimeline))
+ val sut = TimelineController(matrixRoom)
+ sut.focusOnEvent(AN_EVENT_ID)
+ sut.activeTimelineFlow().test {
+ awaitItem().also { state ->
+ assertThat(state).isEqualTo(detachedTimeline)
+ }
+ sut.invokeOnCurrentTimeline {
+ sendMessage("body", "htmlBody", emptyList())
+ }
+ lambdaForDetached.assertions().isCalledOnce()
+ }
+ }
+
+ @Test
+ fun `test last forward pagination on a detached timeline should switch to live timeline`() = runTest {
+ val liveTimeline = FakeTimeline(name = "live")
+ val detachedTimeline = FakeTimeline(name = "detached")
+ val matrixRoom = FakeMatrixRoom(
+ liveTimeline = liveTimeline
+ )
+ matrixRoom.givenTimelineFocusedOnEventResult(Result.success(detachedTimeline))
+ val sut = TimelineController(matrixRoom)
+
+ sut.activeTimelineFlow().test {
+ awaitItem().also { state ->
+ assertThat(state).isEqualTo(liveTimeline)
+ }
+ sut.focusOnEvent(AN_EVENT_ID)
+ awaitItem().also { state ->
+ assertThat(state).isEqualTo(detachedTimeline)
+ }
+ val paginateLambda = lambdaRecorder { _: Timeline.PaginationDirection ->
+ Result.success(true)
+ }
+ detachedTimeline.apply {
+ this.paginateLambda = paginateLambda
+ }
+ sut.paginate(Timeline.PaginationDirection.FORWARDS)
+ awaitItem().also { state ->
+ assertThat(state).isEqualTo(liveTimeline)
+ }
+ }
+ }
+}
diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/TimelineItemIndexerTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/TimelineItemIndexerTest.kt
new file mode 100644
index 0000000000..642f07a3f9
--- /dev/null
+++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/TimelineItemIndexerTest.kt
@@ -0,0 +1,56 @@
+/*
+ * Copyright (c) 2024 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.element.android.features.messages.impl.timeline
+
+import com.google.common.truth.Truth.assertThat
+import io.element.android.features.messages.impl.timeline.model.TimelineItem
+import io.element.android.features.messages.impl.timeline.model.virtual.TimelineItemReadMarkerModel
+import io.element.android.libraries.matrix.api.core.EventId
+import io.element.android.libraries.matrix.test.AN_EVENT_ID
+import org.junit.Test
+
+class TimelineItemIndexerTest {
+ @Test
+ fun `test TimelineItemIndexer`() {
+ val eventIds = mutableListOf()
+ val data = listOf(
+ aTimelineItemEvent().also { eventIds.add(it.eventId!!) },
+ aTimelineItemEvent().also { eventIds.add(it.eventId!!) },
+ aGroupedEvents().also { groupedEvents ->
+ groupedEvents.events.forEach { eventIds.add(it.eventId!!) }
+ },
+ TimelineItem.Virtual(
+ id = "dummy",
+ model = TimelineItemReadMarkerModel
+ ),
+ )
+ assertThat(eventIds.size).isEqualTo(4)
+ val sut = TimelineItemIndexer()
+ sut.process(data)
+ eventIds.forEach {
+ assertThat(sut.isKnown(it)).isTrue()
+ }
+ assertThat(sut.indexOf(eventIds[0])).isEqualTo(0)
+ assertThat(sut.indexOf(eventIds[1])).isEqualTo(1)
+ assertThat(sut.indexOf(eventIds[2])).isEqualTo(2)
+ assertThat(sut.indexOf(eventIds[3])).isEqualTo(2)
+
+ // Unknown event
+ assertThat(sut.isKnown(AN_EVENT_ID)).isFalse()
+ assertThat(sut.indexOf(AN_EVENT_ID)).isEqualTo(-1)
+ }
+}
diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenterTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenterTest.kt
index 2485e57172..bfca79d714 100644
--- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenterTest.kt
+++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenterTest.kt
@@ -22,6 +22,7 @@ import app.cash.turbine.ReceiveTurbine
import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
import io.element.android.features.messages.impl.FakeMessagesNavigator
+import io.element.android.features.messages.impl.fixtures.aMessageEvent
import io.element.android.features.messages.impl.fixtures.aTimelineItemsFactory
import io.element.android.features.messages.impl.timeline.factories.TimelineItemsFactory
import io.element.android.features.messages.impl.timeline.model.NewEventState
@@ -33,12 +34,11 @@ import io.element.android.features.poll.api.actions.EndPollAction
import io.element.android.features.poll.api.actions.SendPollResponseAction
import io.element.android.features.poll.test.actions.FakeEndPollAction
import io.element.android.features.poll.test.actions.FakeSendPollResponseAction
-import io.element.android.libraries.featureflag.api.FeatureFlags
-import io.element.android.libraries.featureflag.test.InMemorySessionPreferencesStore
+import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.room.MatrixRoomMembersState
-import io.element.android.libraries.matrix.api.timeline.MatrixTimeline
import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem
import io.element.android.libraries.matrix.api.timeline.ReceiptType
+import io.element.android.libraries.matrix.api.timeline.Timeline
import io.element.android.libraries.matrix.api.timeline.item.event.EventReaction
import io.element.android.libraries.matrix.api.timeline.item.event.ReactionSender
import io.element.android.libraries.matrix.api.timeline.item.event.Receipt
@@ -48,20 +48,28 @@ import io.element.android.libraries.matrix.test.AN_EVENT_ID_2
import io.element.android.libraries.matrix.test.A_USER_ID
import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
import io.element.android.libraries.matrix.test.room.aRoomMember
-import io.element.android.libraries.matrix.test.timeline.FakeMatrixTimeline
+import io.element.android.libraries.matrix.test.timeline.FakeTimeline
import io.element.android.libraries.matrix.test.timeline.aMessageContent
import io.element.android.libraries.matrix.test.timeline.anEventTimelineItem
import io.element.android.libraries.matrix.ui.components.aMatrixUserList
+import io.element.android.libraries.preferences.test.InMemorySessionPreferencesStore
import io.element.android.tests.testutils.WarmUpRule
import io.element.android.tests.testutils.awaitLastSequentialItem
-import io.element.android.tests.testutils.awaitWithLatch
import io.element.android.tests.testutils.consumeItemsUntilPredicate
+import io.element.android.tests.testutils.lambda.any
+import io.element.android.tests.testutils.lambda.assert
+import io.element.android.tests.testutils.lambda.lambdaRecorder
+import io.element.android.tests.testutils.lambda.value
import io.element.android.tests.testutils.testCoroutineDispatchers
import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.delay
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.flowOf
+import kotlinx.coroutines.flow.getAndUpdate
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.TestScope
+import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runCurrent
import kotlinx.coroutines.test.runTest
import org.junit.Rule
@@ -72,7 +80,7 @@ import kotlin.time.Duration.Companion.seconds
private const val FAKE_UNIQUE_ID = "FAKE_UNIQUE_ID"
private const val FAKE_UNIQUE_ID_2 = "FAKE_UNIQUE_ID_2"
-class TimelinePresenterTest {
+@OptIn(ExperimentalCoroutinesApi::class) class TimelinePresenterTest {
@get:Rule
val warmUpRule = WarmUpRule()
@@ -84,58 +92,49 @@ class TimelinePresenterTest {
}.test {
val initialState = awaitFirstItem()
assertThat(initialState.timelineItems).isEmpty()
- val loadedNoTimelineState = awaitItem()
- assertThat(loadedNoTimelineState.timelineItems).isEmpty()
+ assertThat(initialState.isLive).isTrue()
+ assertThat(initialState.newEventState).isEqualTo(NewEventState.None)
+ assertThat(initialState.focusedEventId).isNull()
+ assertThat(initialState.focusRequestState).isEqualTo(FocusRequestState.None)
}
}
@Test
fun `present - load more`() = runTest {
- val presenter = createTimelinePresenter()
- moleculeFlow(RecompositionMode.Immediate) {
- presenter.present()
- }.test {
- val initialState = awaitItem()
- assertThat(initialState.paginationState.hasMoreToLoadBackwards).isTrue()
- assertThat(initialState.paginationState.isBackPaginating).isFalse()
- initialState.eventSink.invoke(TimelineEvents.LoadMore)
- val inPaginationState = awaitItem()
- assertThat(inPaginationState.paginationState.isBackPaginating).isTrue()
- assertThat(inPaginationState.paginationState.hasMoreToLoadBackwards).isTrue()
- val postPaginationState = awaitItem()
- assertThat(postPaginationState.paginationState.hasMoreToLoadBackwards).isTrue()
- assertThat(postPaginationState.paginationState.isBackPaginating).isFalse()
+ val paginateLambda = lambdaRecorder { _: Timeline.PaginationDirection ->
+ Result.success(false)
}
- }
-
- @Test
- fun `present - set highlighted event`() = runTest {
- val presenter = createTimelinePresenter()
+ val timeline = FakeTimeline().apply {
+ this.paginateLambda = paginateLambda
+ }
+ val presenter = createTimelinePresenter(timeline = timeline)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
- val initialState = awaitFirstItem()
- skipItems(1)
- assertThat(initialState.highlightedEventId).isNull()
- initialState.eventSink.invoke(TimelineEvents.SetHighlightedEvent(AN_EVENT_ID))
- val withHighlightedState = awaitItem()
- assertThat(withHighlightedState.highlightedEventId).isEqualTo(AN_EVENT_ID)
- initialState.eventSink.invoke(TimelineEvents.SetHighlightedEvent(null))
- val withoutHighlightedState = awaitItem()
- assertThat(withoutHighlightedState.highlightedEventId).isNull()
+ val initialState = awaitItem()
+ initialState.eventSink.invoke(TimelineEvents.LoadMore(Timeline.PaginationDirection.BACKWARDS))
+ initialState.eventSink.invoke(TimelineEvents.LoadMore(Timeline.PaginationDirection.FORWARDS))
+ assert(paginateLambda)
+ .isCalledExactly(2)
+ .withSequence(
+ listOf(value(Timeline.PaginationDirection.BACKWARDS)),
+ listOf(value(Timeline.PaginationDirection.FORWARDS))
+ )
}
}
@OptIn(ExperimentalCoroutinesApi::class)
@Test
fun `present - on scroll finished mark a room as read if the first visible index is 0`() = runTest(StandardTestDispatcher()) {
- val timeline = FakeMatrixTimeline(
- initialTimelineItems = listOf(
- MatrixTimelineItem.Event(FAKE_UNIQUE_ID, anEventTimelineItem())
+ val timeline = FakeTimeline(
+ timelineItems = flowOf(
+ listOf(
+ MatrixTimelineItem.Event(FAKE_UNIQUE_ID, anEventTimelineItem())
+ )
)
)
+ val room = FakeMatrixRoom(liveTimeline = timeline)
val sessionPreferencesStore = InMemorySessionPreferencesStore(isSendPublicReadReceiptsEnabled = false)
- val room = FakeMatrixRoom(matrixTimeline = timeline)
val presenter = createTimelinePresenter(
timeline = timeline,
room = room,
@@ -144,7 +143,6 @@ class TimelinePresenterTest {
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
- assertThat(timeline.sentReadReceipts).isEmpty()
val initialState = awaitFirstItem()
initialState.eventSink.invoke(TimelineEvents.OnScrollFinished(0))
runCurrent()
@@ -155,48 +153,62 @@ class TimelinePresenterTest {
@Test
fun `present - on scroll finished send read receipt if an event is before the index`() = runTest {
- val timeline = FakeMatrixTimeline(
- initialTimelineItems = listOf(
- MatrixTimelineItem.Event(FAKE_UNIQUE_ID, anEventTimelineItem()),
- MatrixTimelineItem.Event(
- uniqueId = FAKE_UNIQUE_ID_2,
- event = anEventTimelineItem(
- eventId = AN_EVENT_ID_2,
- content = aMessageContent("Test message")
+ val sendReadReceiptsLambda = lambdaRecorder { _: EventId, _: ReceiptType ->
+ Result.success(Unit)
+ }
+ val timeline = FakeTimeline(
+ timelineItems = flowOf(
+ listOf(
+ MatrixTimelineItem.Event(FAKE_UNIQUE_ID, anEventTimelineItem()),
+ MatrixTimelineItem.Event(
+ uniqueId = FAKE_UNIQUE_ID_2,
+ event = anEventTimelineItem(
+ eventId = AN_EVENT_ID_2,
+ content = aMessageContent("Test message")
+ )
)
)
)
- )
+ ).apply {
+ this.sendReadReceiptLambda = sendReadReceiptsLambda
+ }
val presenter = createTimelinePresenter(timeline)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
- assertThat(timeline.sentReadReceipts).isEmpty()
- val initialState = awaitFirstItem()
- awaitWithLatch { latch ->
- timeline.sendReadReceiptLatch = latch
- initialState.eventSink.invoke(TimelineEvents.OnScrollFinished(1))
+ skipItems(1)
+ awaitItem().run {
+ eventSink.invoke(TimelineEvents.OnScrollFinished(1))
}
- assertThat(timeline.sentReadReceipts).isNotEmpty()
- assertThat(timeline.sentReadReceipts.first().second).isEqualTo(ReceiptType.READ)
+ advanceUntilIdle()
+ assert(sendReadReceiptsLambda)
+ .isCalledOnce()
+ .with(any(), value(ReceiptType.READ))
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun `present - on scroll finished send a private read receipt if an event is at an index other than 0 and public read receipts are disabled`() = runTest {
- val timeline = FakeMatrixTimeline(
- initialTimelineItems = listOf(
- MatrixTimelineItem.Event(FAKE_UNIQUE_ID, anEventTimelineItem()),
- MatrixTimelineItem.Event(
- uniqueId = FAKE_UNIQUE_ID_2,
- event = anEventTimelineItem(
- eventId = AN_EVENT_ID_2,
- content = aMessageContent("Test message")
+ val sendReadReceiptsLambda = lambdaRecorder { _: EventId, _: ReceiptType ->
+ Result.success(Unit)
+ }
+ val timeline = FakeTimeline(
+ timelineItems = flowOf(
+ listOf(
+ MatrixTimelineItem.Event(FAKE_UNIQUE_ID, anEventTimelineItem()),
+ MatrixTimelineItem.Event(
+ uniqueId = FAKE_UNIQUE_ID_2,
+ event = anEventTimelineItem(
+ eventId = AN_EVENT_ID_2,
+ content = aMessageContent("Test message")
+ )
)
)
)
- )
+ ).apply {
+ this.sendReadReceiptLambda = sendReadReceiptsLambda
+ }
val sessionPreferencesStore = InMemorySessionPreferencesStore(isSendPublicReadReceiptsEnabled = false)
val presenter = createTimelinePresenter(
timeline = timeline,
@@ -205,75 +217,86 @@ class TimelinePresenterTest {
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
- assertThat(timeline.sentReadReceipts).isEmpty()
- val initialState = awaitFirstItem()
- awaitWithLatch { latch ->
- timeline.sendReadReceiptLatch = latch
- initialState.eventSink.invoke(TimelineEvents.OnScrollFinished(0))
- initialState.eventSink.invoke(TimelineEvents.OnScrollFinished(1))
+ skipItems(1)
+ awaitItem().run {
+ eventSink.invoke(TimelineEvents.OnScrollFinished(0))
+ eventSink.invoke(TimelineEvents.OnScrollFinished(1))
}
- assertThat(timeline.sentReadReceipts).isNotEmpty()
- assertThat(timeline.sentReadReceipts.first().second).isEqualTo(ReceiptType.READ_PRIVATE)
+ advanceUntilIdle()
+ assert(sendReadReceiptsLambda)
+ .isCalledOnce()
+ .with(any(), value(ReceiptType.READ_PRIVATE))
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun `present - on scroll finished will not send read receipt the first visible event is the same as before`() = runTest {
- val timeline = FakeMatrixTimeline(
- initialTimelineItems = listOf(
- MatrixTimelineItem.Event(FAKE_UNIQUE_ID, anEventTimelineItem()),
- MatrixTimelineItem.Event(
- uniqueId = FAKE_UNIQUE_ID_2,
- event = anEventTimelineItem(
- eventId = AN_EVENT_ID_2,
- content = aMessageContent("Test message")
+ val sendReadReceiptsLambda = lambdaRecorder { _: EventId, _: ReceiptType ->
+ Result.success(Unit)
+ }
+ val timeline = FakeTimeline(
+ timelineItems = flowOf(
+ listOf(
+ MatrixTimelineItem.Event(FAKE_UNIQUE_ID, anEventTimelineItem()),
+ MatrixTimelineItem.Event(
+ uniqueId = FAKE_UNIQUE_ID_2,
+ event = anEventTimelineItem(
+ eventId = AN_EVENT_ID_2,
+ content = aMessageContent("Test message")
+ )
)
)
)
- )
+ ).apply {
+ this.sendReadReceiptLambda = sendReadReceiptsLambda
+ }
val presenter = createTimelinePresenter(timeline)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
- assertThat(timeline.sentReadReceipts).isEmpty()
- val initialState = awaitFirstItem()
- awaitWithLatch { latch ->
- timeline.sendReadReceiptLatch = latch
- initialState.eventSink.invoke(TimelineEvents.OnScrollFinished(1))
- initialState.eventSink.invoke(TimelineEvents.OnScrollFinished(1))
+ skipItems(1)
+ awaitItem().run {
+ eventSink.invoke(TimelineEvents.OnScrollFinished(1))
+ eventSink.invoke(TimelineEvents.OnScrollFinished(1))
}
- assertThat(timeline.sentReadReceipts).hasSize(1)
+ advanceUntilIdle()
cancelAndIgnoreRemainingEvents()
+ assert(sendReadReceiptsLambda).isCalledOnce()
}
}
@Test
fun `present - on scroll finished will not send read receipt only virtual events exist before the index`() = runTest {
- val timeline = FakeMatrixTimeline(
- initialTimelineItems = listOf(
- MatrixTimelineItem.Virtual(FAKE_UNIQUE_ID, VirtualTimelineItem.ReadMarker),
- MatrixTimelineItem.Virtual(FAKE_UNIQUE_ID, VirtualTimelineItem.ReadMarker)
+ val sendReadReceiptsLambda = lambdaRecorder { _: EventId, _: ReceiptType ->
+ Result.success(Unit)
+ }
+ val timeline = FakeTimeline(
+ timelineItems = flowOf(
+ listOf(
+ MatrixTimelineItem.Virtual(FAKE_UNIQUE_ID, VirtualTimelineItem.ReadMarker),
+ MatrixTimelineItem.Virtual(FAKE_UNIQUE_ID, VirtualTimelineItem.ReadMarker)
+ )
)
- )
+ ).apply {
+ this.sendReadReceiptLambda = sendReadReceiptsLambda
+ }
val presenter = createTimelinePresenter(timeline)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
- assertThat(timeline.sentReadReceipts).isEmpty()
+ skipItems(1)
val initialState = awaitFirstItem()
- awaitWithLatch { latch ->
- timeline.sendReadReceiptLatch = latch
- initialState.eventSink.invoke(TimelineEvents.OnScrollFinished(1))
- }
- assertThat(timeline.sentReadReceipts).isEmpty()
+ initialState.eventSink.invoke(TimelineEvents.OnScrollFinished(1))
cancelAndIgnoreRemainingEvents()
+ assert(sendReadReceiptsLambda).isNeverCalled()
}
}
@Test
fun `present - covers newEventState scenarios`() = runTest {
- val timeline = FakeMatrixTimeline()
+ val timelineItems = MutableStateFlow(emptyList())
+ val timeline = FakeTimeline(timelineItems = timelineItems)
val presenter = createTimelinePresenter(timeline)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
@@ -281,12 +304,12 @@ class TimelinePresenterTest {
val initialState = awaitFirstItem()
assertThat(initialState.newEventState).isEqualTo(NewEventState.None)
assertThat(initialState.timelineItems.size).isEqualTo(0)
- timeline.updateTimelineItems {
+ timelineItems.emit(
listOf(MatrixTimelineItem.Event("0", anEventTimelineItem(content = aMessageContent())))
- }
+ )
consumeItemsUntilPredicate { it.timelineItems.size == 1 }
// Mimics sending a message, and assert newEventState is FromMe
- timeline.updateTimelineItems { items ->
+ timelineItems.getAndUpdate { items ->
val event = anEventTimelineItem(content = aMessageContent(), isOwn = true)
items + listOf(MatrixTimelineItem.Event("1", event))
}
@@ -295,7 +318,7 @@ class TimelinePresenterTest {
assertThat(state.newEventState).isEqualTo(NewEventState.FromMe)
}
// Mimics receiving a message without clearing the previous FromMe
- timeline.updateTimelineItems { items ->
+ timelineItems.getAndUpdate { items ->
val event = anEventTimelineItem(content = aMessageContent())
items + listOf(MatrixTimelineItem.Event("2", event))
}
@@ -307,7 +330,7 @@ class TimelinePresenterTest {
assertThat(state.newEventState).isEqualTo(NewEventState.None)
}
// Mimics receiving a message and assert newEventState is FromOther
- timeline.updateTimelineItems { items ->
+ timelineItems.getAndUpdate { items ->
val event = anEventTimelineItem(content = aMessageContent())
items + listOf(MatrixTimelineItem.Event("3", event))
}
@@ -321,7 +344,10 @@ class TimelinePresenterTest {
@Test
fun `present - reaction ordering`() = runTest {
- val timeline = FakeMatrixTimeline()
+ val timelineItems = MutableStateFlow(emptyList())
+ val timeline = FakeTimeline(
+ timelineItems = timelineItems,
+ )
val presenter = createTimelinePresenter(timeline)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
@@ -349,10 +375,9 @@ class TimelinePresenterTest {
senders = persistentListOf(charlie)
),
)
- timeline.updateTimelineItems {
+ timelineItems.emit(
listOf(MatrixTimelineItem.Event(FAKE_UNIQUE_ID, anEventTimelineItem(reactions = oneReaction)))
- }
- skipItems(1)
+ )
val item = awaitItem().timelineItems.first()
assertThat(item).isInstanceOf(TimelineItem.Event::class.java)
val event = item as TimelineItem.Event
@@ -424,8 +449,10 @@ class TimelinePresenterTest {
fun `present - side effect on redacted items is invoked`() = runTest {
val redactedVoiceMessageManager = FakeRedactedVoiceMessageManager()
val presenter = createTimelinePresenter(
- timeline = FakeMatrixTimeline(
- initialTimelineItems = aRedactedMatrixTimeline(AN_EVENT_ID),
+ timeline = FakeTimeline(
+ timelineItems = flowOf(
+ aRedactedMatrixTimeline(AN_EVENT_ID),
+ )
),
redactedVoiceMessageManager = redactedVoiceMessageManager,
)
@@ -433,32 +460,141 @@ class TimelinePresenterTest {
presenter.present()
}.test {
assertThat(redactedVoiceMessageManager.invocations.size).isEqualTo(0)
- awaitFirstItem().let {
- assertThat(it.timelineItems).isNotEmpty()
- }
+ skipItems(2)
assertThat(redactedVoiceMessageManager.invocations.size).isEqualTo(1)
}
}
+ @Test
+ fun `present - focus on event and jump to live make the presenter update the state with the correct Events`() = runTest {
+ val detachedTimeline = FakeTimeline(
+ timelineItems = flowOf(
+ listOf(
+ MatrixTimelineItem.Event(
+ uniqueId = FAKE_UNIQUE_ID,
+ event = anEventTimelineItem(),
+ )
+ )
+ )
+ )
+ val liveTimeline = FakeTimeline(
+ timelineItems = flowOf(emptyList())
+ )
+ val room = FakeMatrixRoom(
+ liveTimeline = liveTimeline,
+ ).apply {
+ givenTimelineFocusedOnEventResult(Result.success(detachedTimeline))
+ }
+ val presenter = createTimelinePresenter(
+ room = room,
+ )
+ moleculeFlow(RecompositionMode.Immediate) {
+ presenter.present()
+ }.test {
+ val initialState = awaitFirstItem()
+ initialState.eventSink.invoke(TimelineEvents.FocusOnEvent(AN_EVENT_ID))
+ awaitItem().also { state ->
+ assertThat(state.focusedEventId).isEqualTo(AN_EVENT_ID)
+ assertThat(state.focusRequestState).isEqualTo(FocusRequestState.Fetching)
+ }
+ skipItems(2)
+ awaitItem().also { state ->
+ assertThat(state.focusRequestState).isEqualTo(FocusRequestState.Fetched)
+ assertThat(state.timelineItems).isNotEmpty()
+ }
+ initialState.eventSink.invoke(TimelineEvents.JumpToLive)
+ skipItems(1)
+ awaitItem().also { state ->
+ // Event stays focused
+ assertThat(state.focusedEventId).isEqualTo(AN_EVENT_ID)
+ assertThat(state.timelineItems).isEmpty()
+ }
+ }
+ }
+
+ @Test
+ fun `present - focus on known event retrieves the event from cache`() = runTest {
+ val timelineItemIndexer = TimelineItemIndexer().apply {
+ process(listOf(aMessageEvent(eventId = AN_EVENT_ID)))
+ }
+ val presenter = createTimelinePresenter(
+ room = FakeMatrixRoom(
+ liveTimeline = FakeTimeline(
+ timelineItems = flowOf(
+ listOf(
+ MatrixTimelineItem.Event(
+ uniqueId = FAKE_UNIQUE_ID,
+ event = anEventTimelineItem(eventId = AN_EVENT_ID),
+ )
+ )
+ )
+ ),
+ ),
+ timelineItemIndexer = timelineItemIndexer,
+ )
+ moleculeFlow(RecompositionMode.Immediate) {
+ presenter.present()
+ }.test {
+ val initialState = awaitFirstItem()
+ initialState.eventSink.invoke(TimelineEvents.FocusOnEvent(AN_EVENT_ID))
+ awaitItem().also { state ->
+ assertThat(state.focusedEventId).isEqualTo(AN_EVENT_ID)
+ assertThat(state.focusRequestState).isEqualTo(FocusRequestState.Cached(0))
+ }
+ }
+ }
+
+ @Test
+ fun `present - focus on event error case`() = runTest {
+ val presenter = createTimelinePresenter(
+ room = FakeMatrixRoom(
+ liveTimeline = FakeTimeline(
+ timelineItems = flowOf(emptyList()),
+ ),
+ ).apply {
+ givenTimelineFocusedOnEventResult(Result.failure(Throwable("An error")))
+ },
+ )
+ moleculeFlow(RecompositionMode.Immediate) {
+ presenter.present()
+ }.test {
+ val initialState = awaitFirstItem()
+ initialState.eventSink(TimelineEvents.FocusOnEvent(AN_EVENT_ID))
+ awaitItem().also { state ->
+ assertThat(state.focusedEventId).isEqualTo(AN_EVENT_ID)
+ assertThat(state.focusRequestState).isEqualTo(FocusRequestState.Fetching)
+ }
+ awaitItem().also { state ->
+ assertThat(state.focusRequestState).isInstanceOf(FocusRequestState.Failure::class.java)
+ state.eventSink(TimelineEvents.ClearFocusRequestState)
+ }
+ awaitItem().also { state ->
+ assertThat(state.focusRequestState).isEqualTo(FocusRequestState.None)
+ }
+ }
+ }
+
@Test
fun `present - when room member info is loaded, read receipts info should be updated`() = runTest {
- val timeline = FakeMatrixTimeline(
- listOf(
- MatrixTimelineItem.Event(
- FAKE_UNIQUE_ID,
- anEventTimelineItem(
- sender = A_USER_ID,
- receipts = persistentListOf(
- Receipt(
- userId = A_USER_ID,
- timestamp = 0L,
+ val timeline = FakeTimeline(
+ timelineItems = flowOf(
+ listOf(
+ MatrixTimelineItem.Event(
+ FAKE_UNIQUE_ID,
+ anEventTimelineItem(
+ sender = A_USER_ID,
+ receipts = persistentListOf(
+ Receipt(
+ userId = A_USER_ID,
+ timestamp = 0L,
+ )
)
)
)
)
)
)
- val room = FakeMatrixRoom(matrixTimeline = timeline).apply {
+ val room = FakeMatrixRoom(liveTimeline = timeline).apply {
givenRoomMembersState(MatrixRoomMembersState.Unknown)
}
@@ -485,22 +621,19 @@ class TimelinePresenterTest {
}
private suspend fun ReceiveTurbine.awaitFirstItem(): T {
- // Skip 1 item if Mentions feature is enabled
- if (FeatureFlags.Mentions.defaultValue) {
- skipItems(1)
- }
return awaitItem()
}
private fun TestScope.createTimelinePresenter(
- timeline: MatrixTimeline = FakeMatrixTimeline(),
- room: FakeMatrixRoom = FakeMatrixRoom(matrixTimeline = timeline),
+ timeline: Timeline = FakeTimeline(),
+ room: FakeMatrixRoom = FakeMatrixRoom(liveTimeline = timeline),
timelineItemsFactory: TimelineItemsFactory = aTimelineItemsFactory(),
redactedVoiceMessageManager: RedactedVoiceMessageManager = FakeRedactedVoiceMessageManager(),
messagesNavigator: FakeMessagesNavigator = FakeMessagesNavigator(),
endPollAction: EndPollAction = FakeEndPollAction(),
sendPollResponseAction: SendPollResponseAction = FakeSendPollResponseAction(),
sessionPreferencesStore: InMemorySessionPreferencesStore = InMemorySessionPreferencesStore(),
+ timelineItemIndexer: TimelineItemIndexer = TimelineItemIndexer(),
): TimelinePresenter {
return TimelinePresenter(
timelineItemsFactory = timelineItemsFactory,
@@ -512,6 +645,8 @@ class TimelinePresenterTest {
endPollAction = endPollAction,
sendPollResponseAction = sendPollResponseAction,
sessionPreferencesStore = sessionPreferencesStore,
+ timelineItemIndexer = timelineItemIndexer,
+ timelineController = TimelineController(room),
)
}
}
diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/TimelineViewTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/TimelineViewTest.kt
index 44fb6270ae..29d46d2da2 100644
--- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/TimelineViewTest.kt
+++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/TimelineViewTest.kt
@@ -17,14 +17,25 @@
package io.element.android.features.messages.impl.timeline
import androidx.activity.ComponentActivity
+import androidx.compose.ui.test.junit4.AndroidComposeTestRule
import androidx.compose.ui.test.junit4.createAndroidComposeRule
+import androidx.compose.ui.test.onNodeWithContentDescription
+import androidx.compose.ui.test.performClick
import androidx.test.ext.junit.runners.AndroidJUnit4
+import io.element.android.features.messages.impl.timeline.model.TimelineItem
+import io.element.android.features.messages.impl.timeline.model.virtual.TimelineItemLoadingIndicatorModel
+import io.element.android.features.messages.impl.typing.TypingNotificationState
import io.element.android.features.messages.impl.typing.aTypingNotificationState
+import io.element.android.libraries.matrix.api.core.UserId
+import io.element.android.libraries.matrix.api.timeline.Timeline
+import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.tests.testutils.EnsureNeverCalledWithParam
import io.element.android.tests.testutils.EnsureNeverCalledWithTwoParams
import io.element.android.tests.testutils.EventsRecorder
+import kotlinx.collections.immutable.persistentListOf
import org.junit.Rule
import org.junit.Test
+import org.junit.rules.TestRule
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
@@ -34,55 +45,89 @@ class TimelineViewTest {
@Test
fun `reaching the end of the timeline with more events to load emits a LoadMore event`() {
val eventsRecorder = EventsRecorder()
- rule.setContent {
- TimelineView(
- aTimelineState(
- eventSink = eventsRecorder,
- paginationState = aPaginationState(
- hasMoreToLoadBackwards = true,
- )
+ rule.setTimelineView(
+ state = aTimelineState(
+ timelineItems = persistentListOf(
+ TimelineItem.Virtual(
+ id = "backward_pagination",
+ model = TimelineItemLoadingIndicatorModel(Timeline.PaginationDirection.BACKWARDS, 0)
+ ),
),
- typingNotificationState = aTypingNotificationState(),
- roomName = null,
- onUserDataClicked = EnsureNeverCalledWithParam(),
- onLinkClicked = EnsureNeverCalledWithParam(),
- onMessageClicked = EnsureNeverCalledWithParam(),
- onMessageLongClicked = EnsureNeverCalledWithParam(),
- onTimestampClicked = EnsureNeverCalledWithParam(),
- onSwipeToReply = EnsureNeverCalledWithParam(),
- onReactionClicked = EnsureNeverCalledWithTwoParams(),
- onReactionLongClicked = EnsureNeverCalledWithTwoParams(),
- onMoreReactionsClicked = EnsureNeverCalledWithParam(),
- onReadReceiptClick = EnsureNeverCalledWithParam(),
- )
- }
- eventsRecorder.assertSingle(TimelineEvents.LoadMore)
+ eventSink = eventsRecorder,
+ ),
+ )
+ eventsRecorder.assertSingle(TimelineEvents.LoadMore(Timeline.PaginationDirection.BACKWARDS))
}
@Test
fun `reaching the end of the timeline does not send a LoadMore event`() {
val eventsRecorder = EventsRecorder(expectEvents = false)
- rule.setContent {
- TimelineView(
- aTimelineState(
- eventSink = eventsRecorder,
- paginationState = aPaginationState(
- hasMoreToLoadBackwards = false,
- )
- ),
- typingNotificationState = aTypingNotificationState(),
- roomName = null,
- onUserDataClicked = EnsureNeverCalledWithParam(),
- onLinkClicked = EnsureNeverCalledWithParam(),
- onMessageClicked = EnsureNeverCalledWithParam(),
- onMessageLongClicked = EnsureNeverCalledWithParam(),
- onTimestampClicked = EnsureNeverCalledWithParam(),
- onSwipeToReply = EnsureNeverCalledWithParam(),
- onReactionClicked = EnsureNeverCalledWithTwoParams(),
- onReactionLongClicked = EnsureNeverCalledWithTwoParams(),
- onMoreReactionsClicked = EnsureNeverCalledWithParam(),
- onReadReceiptClick = EnsureNeverCalledWithParam(),
- )
- }
+ rule.setTimelineView(
+ state = aTimelineState(
+ eventSink = eventsRecorder,
+ ),
+ )
+ }
+
+ @Test
+ fun `scroll to bottom on live timeline does not emit the Event`() {
+ val eventsRecorder = EventsRecorder(expectEvents = false)
+ rule.setTimelineView(
+ state = aTimelineState(
+ isLive = true,
+ eventSink = eventsRecorder,
+ ),
+ forceJumpToBottomVisibility = true,
+ )
+ val contentDescription = rule.activity.getString(CommonStrings.a11y_jump_to_bottom)
+ rule.onNodeWithContentDescription(contentDescription).performClick()
+ }
+
+ @Test
+ fun `scroll to bottom on detached timeline emits the expected Event`() {
+ val eventsRecorder = EventsRecorder()
+ rule.setTimelineView(
+ state = aTimelineState(
+ isLive = false,
+ eventSink = eventsRecorder,
+ ),
+ )
+ val contentDescription = rule.activity.getString(CommonStrings.a11y_jump_to_bottom)
+ rule.onNodeWithContentDescription(contentDescription).performClick()
+ eventsRecorder.assertSingle(TimelineEvents.JumpToLive)
+ }
+}
+
+private fun AndroidComposeTestRule.setTimelineView(
+ state: TimelineState,
+ typingNotificationState: TypingNotificationState = aTypingNotificationState(),
+ onUserDataClicked: (UserId) -> Unit = EnsureNeverCalledWithParam(),
+ onLinkClicked: (String) -> Unit = EnsureNeverCalledWithParam(),
+ onMessageClicked: (TimelineItem.Event) -> Unit = EnsureNeverCalledWithParam(),
+ onMessageLongClicked: (TimelineItem.Event) -> Unit = EnsureNeverCalledWithParam(),
+ onTimestampClicked: (TimelineItem.Event) -> Unit = EnsureNeverCalledWithParam(),
+ onSwipeToReply: (TimelineItem.Event) -> Unit = EnsureNeverCalledWithParam(),
+ onReactionClicked: (emoji: String, TimelineItem.Event) -> Unit = EnsureNeverCalledWithTwoParams(),
+ onReactionLongClicked: (emoji: String, TimelineItem.Event) -> Unit = EnsureNeverCalledWithTwoParams(),
+ onMoreReactionsClicked: (TimelineItem.Event) -> Unit = EnsureNeverCalledWithParam(),
+ onReadReceiptClick: (TimelineItem.Event) -> Unit = EnsureNeverCalledWithParam(),
+ forceJumpToBottomVisibility: Boolean = false,
+) {
+ setContent {
+ TimelineView(
+ state = state,
+ typingNotificationState = typingNotificationState,
+ onUserDataClicked = onUserDataClicked,
+ onLinkClicked = onLinkClicked,
+ onMessageClicked = onMessageClicked,
+ onMessageLongClicked = onMessageLongClicked,
+ onTimestampClicked = onTimestampClicked,
+ onSwipeToReply = onSwipeToReply,
+ onReactionClicked = onReactionClicked,
+ onReactionLongClicked = onReactionLongClicked,
+ onMoreReactionsClicked = onMoreReactionsClicked,
+ onReadReceiptClick = onReadReceiptClick,
+ forceJumpToBottomVisibility = forceJumpToBottomVisibility,
+ )
}
}
diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentMessageFactoryTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentMessageFactoryTest.kt
index 6e475707ad..35de78f65b 100644
--- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentMessageFactoryTest.kt
+++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentMessageFactoryTest.kt
@@ -82,7 +82,7 @@ class TimelineItemContentMessageFactoryTest {
val sut = createTimelineItemContentMessageFactory()
val result = sut.create(
content = createMessageContent(type = OtherMessageType(msgType = "a_type", body = "body")),
- senderDisplayName = "Bob",
+ senderDisambiguatedDisplayName = "Bob",
eventId = AN_EVENT_ID,
)
val expected = TimelineItemTextContent(
@@ -100,7 +100,7 @@ class TimelineItemContentMessageFactoryTest {
val sut = createTimelineItemContentMessageFactory()
val result = sut.create(
content = createMessageContent(type = LocationMessageType("body", "geo:1,2", "description")),
- senderDisplayName = "Bob",
+ senderDisambiguatedDisplayName = "Bob",
eventId = AN_EVENT_ID,
)
val expected = TimelineItemLocationContent(
@@ -116,7 +116,7 @@ class TimelineItemContentMessageFactoryTest {
val sut = createTimelineItemContentMessageFactory()
val result = sut.create(
content = createMessageContent(type = LocationMessageType("body", "", null)),
- senderDisplayName = "Bob",
+ senderDisambiguatedDisplayName = "Bob",
eventId = AN_EVENT_ID,
)
val expected = TimelineItemTextContent(
@@ -134,7 +134,7 @@ class TimelineItemContentMessageFactoryTest {
val sut = createTimelineItemContentMessageFactory()
val result = sut.create(
content = createMessageContent(type = TextMessageType("body", null)),
- senderDisplayName = "Bob",
+ senderDisambiguatedDisplayName = "Bob",
eventId = AN_EVENT_ID,
)
val expected = TimelineItemTextContent(
@@ -152,7 +152,7 @@ class TimelineItemContentMessageFactoryTest {
val sut = createTimelineItemContentMessageFactory()
val result = sut.create(
content = createMessageContent(type = TextMessageType("https://www.example.org", null)),
- senderDisplayName = "Bob",
+ senderDisambiguatedDisplayName = "Bob",
eventId = AN_EVENT_ID,
) as TimelineItemTextContent
val expected = TimelineItemTextContent(
@@ -200,7 +200,7 @@ class TimelineItemContentMessageFactoryTest {
formatted = FormattedBody(MessageFormat.HTML, expected.toString())
)
),
- senderDisplayName = "Bob",
+ senderDisambiguatedDisplayName = "Bob",
eventId = AN_EVENT_ID,
)
assertThat((result as TimelineItemTextContent).formattedBody).isEqualTo(expected)
@@ -218,7 +218,7 @@ class TimelineItemContentMessageFactoryTest {
formatted = FormattedBody(MessageFormat.UNKNOWN, "formatted")
)
),
- senderDisplayName = "Bob",
+ senderDisambiguatedDisplayName = "Bob",
eventId = AN_EVENT_ID,
)
assertThat((result as TimelineItemTextContent).formattedBody).isNull()
@@ -229,7 +229,7 @@ class TimelineItemContentMessageFactoryTest {
val sut = createTimelineItemContentMessageFactory()
val result = sut.create(
content = createMessageContent(type = VideoMessageType("body", null, null, MediaSource("url"), null)),
- senderDisplayName = "Bob",
+ senderDisambiguatedDisplayName = "Bob",
eventId = AN_EVENT_ID,
)
val expected = TimelineItemVideoContent(
@@ -277,7 +277,7 @@ class TimelineItemContentMessageFactoryTest {
),
)
),
- senderDisplayName = "Bob",
+ senderDisambiguatedDisplayName = "Bob",
eventId = AN_EVENT_ID,
)
val expected = TimelineItemVideoContent(
@@ -303,7 +303,7 @@ class TimelineItemContentMessageFactoryTest {
val sut = createTimelineItemContentMessageFactory()
val result = sut.create(
content = createMessageContent(type = AudioMessageType("body", MediaSource("url"), null)),
- senderDisplayName = "Bob",
+ senderDisambiguatedDisplayName = "Bob",
eventId = AN_EVENT_ID,
)
val expected = TimelineItemAudioContent(
@@ -332,7 +332,7 @@ class TimelineItemContentMessageFactoryTest {
)
)
),
- senderDisplayName = "Bob",
+ senderDisambiguatedDisplayName = "Bob",
eventId = AN_EVENT_ID,
)
val expected = TimelineItemAudioContent(
@@ -351,7 +351,7 @@ class TimelineItemContentMessageFactoryTest {
val sut = createTimelineItemContentMessageFactory()
val result = sut.create(
content = createMessageContent(type = VoiceMessageType("body", MediaSource("url"), null, null)),
- senderDisplayName = "Bob",
+ senderDisambiguatedDisplayName = "Bob",
eventId = AN_EVENT_ID,
)
val expected = TimelineItemVoiceContent(
@@ -384,7 +384,7 @@ class TimelineItemContentMessageFactoryTest {
),
)
),
- senderDisplayName = "Bob",
+ senderDisambiguatedDisplayName = "Bob",
eventId = AN_EVENT_ID,
)
val expected = TimelineItemVoiceContent(
@@ -409,7 +409,7 @@ class TimelineItemContentMessageFactoryTest {
)
val result = sut.create(
content = createMessageContent(type = VoiceMessageType("body", MediaSource("url"), null, null)),
- senderDisplayName = "Bob",
+ senderDisambiguatedDisplayName = "Bob",
eventId = AN_EVENT_ID,
)
val expected = TimelineItemAudioContent(
@@ -428,7 +428,7 @@ class TimelineItemContentMessageFactoryTest {
val sut = createTimelineItemContentMessageFactory()
val result = sut.create(
content = createMessageContent(type = ImageMessageType("body", null, null, MediaSource("url"), null)),
- senderDisplayName = "Bob",
+ senderDisambiguatedDisplayName = "Bob",
eventId = AN_EVENT_ID,
)
val expected = TimelineItemImageContent(
@@ -499,7 +499,7 @@ class TimelineItemContentMessageFactoryTest {
)
)
),
- senderDisplayName = "Bob",
+ senderDisambiguatedDisplayName = "Bob",
eventId = AN_EVENT_ID,
)
val expected = TimelineItemImageContent(
@@ -524,7 +524,7 @@ class TimelineItemContentMessageFactoryTest {
val sut = createTimelineItemContentMessageFactory()
val result = sut.create(
content = createMessageContent(type = FileMessageType("body", MediaSource("url"), null)),
- senderDisplayName = "Bob",
+ senderDisambiguatedDisplayName = "Bob",
eventId = AN_EVENT_ID,
)
val expected = TimelineItemFileContent(
@@ -559,7 +559,7 @@ class TimelineItemContentMessageFactoryTest {
)
)
),
- senderDisplayName = "Bob",
+ senderDisambiguatedDisplayName = "Bob",
eventId = AN_EVENT_ID,
)
val expected = TimelineItemFileContent(
@@ -578,7 +578,7 @@ class TimelineItemContentMessageFactoryTest {
val sut = createTimelineItemContentMessageFactory()
val result = sut.create(
content = createMessageContent(type = NoticeMessageType("body", null)),
- senderDisplayName = "Bob",
+ senderDisambiguatedDisplayName = "Bob",
eventId = AN_EVENT_ID,
)
val expected = TimelineItemNoticeContent(
@@ -601,7 +601,7 @@ class TimelineItemContentMessageFactoryTest {
formatted = FormattedBody(MessageFormat.HTML, "formatted")
)
),
- senderDisplayName = "Bob",
+ senderDisambiguatedDisplayName = "Bob",
eventId = AN_EVENT_ID,
)
assertThat((result as TimelineItemNoticeContent).formattedBody).isEqualTo("formatted")
@@ -612,7 +612,7 @@ class TimelineItemContentMessageFactoryTest {
val sut = createTimelineItemContentMessageFactory()
val result = sut.create(
content = createMessageContent(type = EmoteMessageType("body", null)),
- senderDisplayName = "Bob",
+ senderDisambiguatedDisplayName = "Bob",
eventId = AN_EVENT_ID,
)
val expected = TimelineItemEmoteContent(
@@ -635,7 +635,7 @@ class TimelineItemContentMessageFactoryTest {
formatted = FormattedBody(MessageFormat.HTML, "formatted")
)
),
- senderDisplayName = "Bob",
+ senderDisambiguatedDisplayName = "Bob",
eventId = AN_EVENT_ID,
)
assertThat((result as TimelineItemEmoteContent).formattedBody).isEqualTo(SpannableString("* Bob formatted"))
diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/groups/TimelineItemGrouperTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/groups/TimelineItemGrouperTest.kt
index 42d4b3388a..3cfc34ee51 100644
--- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/groups/TimelineItemGrouperTest.kt
+++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/groups/TimelineItemGrouperTest.kt
@@ -20,6 +20,7 @@ import com.google.common.truth.Truth.assertThat
import io.element.android.features.messages.impl.fixtures.aMessageEvent
import io.element.android.features.messages.impl.timeline.aTimelineItemDebugInfo
import io.element.android.features.messages.impl.timeline.aTimelineItemReactions
+import io.element.android.features.messages.impl.timeline.components.aProfileTimelineDetailsReady
import io.element.android.features.messages.impl.timeline.model.ReadReceiptData
import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.features.messages.impl.timeline.model.TimelineItemReadReceipts
@@ -39,7 +40,7 @@ class TimelineItemGrouperTest {
id = "0",
senderId = A_USER_ID,
senderAvatar = anAvatarData(),
- senderDisplayName = "",
+ senderProfile = aProfileTimelineDetailsReady(displayName = ""),
content = TimelineItemStateEventContent(body = "a state event"),
reactionsState = aTimelineItemReactions(count = 0),
readReceiptState = TimelineItemReadReceipts(emptyList().toImmutableList()),
diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/model/InReplyToDetailTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/model/InReplyToDetailTest.kt
index bf287341ad..d73f7246c9 100644
--- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/model/InReplyToDetailTest.kt
+++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/model/InReplyToDetailTest.kt
@@ -27,26 +27,27 @@ import io.element.android.libraries.matrix.api.timeline.item.event.TextMessageTy
import io.element.android.libraries.matrix.test.AN_EVENT_ID
import io.element.android.libraries.matrix.test.A_USER_ID
import io.element.android.libraries.matrix.test.permalink.FakePermalinkParser
+import io.element.android.libraries.matrix.test.timeline.aProfileTimelineDetails
import org.junit.Test
class InReplyToDetailTest {
@Test
- fun `map - with a not ready InReplyTo does not work`() {
+ fun `map - with a not ready InReplyTo return expected object`() {
assertThat(
- InReplyTo.Pending.map(
+ InReplyTo.Pending(AN_EVENT_ID).map(
permalinkParser = FakePermalinkParser()
)
- ).isNull()
+ ).isEqualTo(InReplyToDetails.Loading(AN_EVENT_ID))
assertThat(
InReplyTo.NotLoaded(AN_EVENT_ID).map(
permalinkParser = FakePermalinkParser()
)
- ).isNull()
+ ).isEqualTo(InReplyToDetails.Loading(AN_EVENT_ID))
assertThat(
- InReplyTo.Error.map(
+ InReplyTo.Error(AN_EVENT_ID, "a message").map(
permalinkParser = FakePermalinkParser()
)
- ).isNull()
+ ).isEqualTo(InReplyToDetails.Error(AN_EVENT_ID, "a message"))
}
@Test
@@ -54,8 +55,7 @@ class InReplyToDetailTest {
val inReplyTo = InReplyTo.Ready(
eventId = AN_EVENT_ID,
senderId = A_USER_ID,
- senderDisplayName = "senderDisplayName",
- senderAvatarUrl = "senderAvatarUrl",
+ senderProfile = aProfileTimelineDetails(),
content = RoomMembershipContent(
userId = A_USER_ID,
change = MembershipChange.INVITED,
@@ -65,7 +65,7 @@ class InReplyToDetailTest {
permalinkParser = FakePermalinkParser()
)
assertThat(inReplyToDetails).isNotNull()
- assertThat(inReplyToDetails?.textContent).isNull()
+ assertThat((inReplyToDetails as InReplyToDetails.Ready).textContent).isNull()
}
@Test
@@ -73,8 +73,7 @@ class InReplyToDetailTest {
val inReplyTo = InReplyTo.Ready(
eventId = AN_EVENT_ID,
senderId = A_USER_ID,
- senderDisplayName = "senderDisplayName",
- senderAvatarUrl = "senderAvatarUrl",
+ senderProfile = aProfileTimelineDetails(),
content = MessageContent(
body = "**Hello!**",
inReplyTo = null,
@@ -90,9 +89,7 @@ class InReplyToDetailTest {
)
)
assertThat(
- inReplyTo.map(
- permalinkParser = FakePermalinkParser()
- )?.textContent
+ (inReplyTo.map(permalinkParser = FakePermalinkParser()) as InReplyToDetails.Ready).textContent
).isEqualTo("Hello!")
}
@@ -101,8 +98,7 @@ class InReplyToDetailTest {
val inReplyTo = InReplyTo.Ready(
eventId = AN_EVENT_ID,
senderId = A_USER_ID,
- senderDisplayName = "senderDisplayName",
- senderAvatarUrl = "senderAvatarUrl",
+ senderProfile = aProfileTimelineDetails(),
content = MessageContent(
body = "**Hello!**",
inReplyTo = null,
@@ -115,9 +111,7 @@ class InReplyToDetailTest {
)
)
assertThat(
- inReplyTo.map(
- permalinkParser = FakePermalinkParser()
- )?.textContent
+ (inReplyTo.map(permalinkParser = FakePermalinkParser()) as InReplyToDetails.Ready).textContent
).isEqualTo("**Hello!**")
}
}
diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/model/InReplyToMetadataKtTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/model/InReplyToMetadataKtTest.kt
index 53a0a0eec3..b56d871704 100644
--- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/model/InReplyToMetadataKtTest.kt
+++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/model/InReplyToMetadataKtTest.kt
@@ -42,6 +42,7 @@ import io.element.android.libraries.matrix.api.timeline.item.event.ImageMessageT
import io.element.android.libraries.matrix.api.timeline.item.event.LocationMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.OtherState
import io.element.android.libraries.matrix.api.timeline.item.event.ProfileChangeContent
+import io.element.android.libraries.matrix.api.timeline.item.event.ProfileTimelineDetails
import io.element.android.libraries.matrix.api.timeline.item.event.RedactedContent
import io.element.android.libraries.matrix.api.timeline.item.event.RoomMembershipContent
import io.element.android.libraries.matrix.api.timeline.item.event.StateContent
@@ -55,6 +56,7 @@ import io.element.android.libraries.matrix.test.A_USER_ID
import io.element.android.libraries.matrix.test.media.aMediaSource
import io.element.android.libraries.matrix.test.timeline.aMessageContent
import io.element.android.libraries.matrix.test.timeline.aPollContent
+import io.element.android.libraries.matrix.test.timeline.aProfileTimelineDetails
import io.element.android.libraries.matrix.ui.components.A_BLUR_HASH
import io.element.android.libraries.matrix.ui.components.AttachmentThumbnailInfo
import io.element.android.libraries.matrix.ui.components.AttachmentThumbnailType
@@ -68,7 +70,7 @@ class InReplyToMetadataKtTest {
@Test
fun `any message content`() = runTest {
moleculeFlow(RecompositionMode.Immediate) {
- anInReplyToDetails(eventContent = aMessageContent()).metadata()
+ anInReplyToDetailsReady(eventContent = aMessageContent()).metadata()
}.test {
awaitItem().let {
assertThat(it).isEqualTo(InReplyToMetadata.Text("textContent"))
@@ -79,7 +81,7 @@ class InReplyToMetadataKtTest {
@Test
fun `an image message content`() = runTest {
moleculeFlow(RecompositionMode.Immediate) {
- anInReplyToDetails(
+ anInReplyToDetailsReady(
eventContent = aMessageContent(
messageType = ImageMessageType(
body = "body",
@@ -109,7 +111,7 @@ class InReplyToMetadataKtTest {
@Test
fun `a sticker message content`() = runTest {
moleculeFlow(RecompositionMode.Immediate) {
- anInReplyToDetails(
+ anInReplyToDetailsReady(
eventContent = StickerContent(
body = "body",
info = anImageInfo(),
@@ -135,7 +137,7 @@ class InReplyToMetadataKtTest {
@Test
fun `a video message content`() = runTest {
moleculeFlow(RecompositionMode.Immediate) {
- anInReplyToDetails(
+ anInReplyToDetailsReady(
eventContent = aMessageContent(
messageType = VideoMessageType(
body = "body",
@@ -165,7 +167,7 @@ class InReplyToMetadataKtTest {
@Test
fun `a file message content`() = runTest {
moleculeFlow(RecompositionMode.Immediate) {
- anInReplyToDetails(
+ anInReplyToDetailsReady(
eventContent = aMessageContent(
messageType = FileMessageType(
body = "body",
@@ -198,7 +200,7 @@ class InReplyToMetadataKtTest {
@Test
fun `a audio message content`() = runTest {
moleculeFlow(RecompositionMode.Immediate) {
- anInReplyToDetails(
+ anInReplyToDetailsReady(
eventContent = aMessageContent(
messageType = AudioMessageType(
body = "body",
@@ -230,7 +232,7 @@ class InReplyToMetadataKtTest {
fun `a location message content`() = runTest {
moleculeFlow(RecompositionMode.Immediate) {
testEnv {
- anInReplyToDetails(
+ anInReplyToDetailsReady(
eventContent = aMessageContent(
messageType = LocationMessageType(
body = "body",
@@ -260,7 +262,7 @@ class InReplyToMetadataKtTest {
fun `a voice message content`() = runTest {
moleculeFlow(RecompositionMode.Immediate) {
testEnv {
- anInReplyToDetails(
+ anInReplyToDetailsReady(
eventContent = aMessageContent(
messageType = VoiceMessageType(
body = "body",
@@ -290,7 +292,7 @@ class InReplyToMetadataKtTest {
@Test
fun `a poll content`() = runTest {
moleculeFlow(RecompositionMode.Immediate) {
- anInReplyToDetails(
+ anInReplyToDetailsReady(
eventContent = aPollContent()
).metadata()
}.test {
@@ -312,7 +314,7 @@ class InReplyToMetadataKtTest {
@Test
fun `redacted content`() = runTest {
moleculeFlow(RecompositionMode.Immediate) {
- anInReplyToDetails(
+ anInReplyToDetailsReady(
eventContent = RedactedContent
).metadata()
}.test {
@@ -325,7 +327,7 @@ class InReplyToMetadataKtTest {
@Test
fun `unable to decrypt content`() = runTest {
moleculeFlow(RecompositionMode.Immediate) {
- anInReplyToDetails(
+ anInReplyToDetailsReady(
eventContent = UnableToDecryptContent(UnableToDecryptContent.Data.Unknown)
).metadata()
}.test {
@@ -338,7 +340,7 @@ class InReplyToMetadataKtTest {
@Test
fun `failed to parse message content`() = runTest {
moleculeFlow(RecompositionMode.Immediate) {
- anInReplyToDetails(
+ anInReplyToDetailsReady(
eventContent = FailedToParseMessageLikeContent("", "")
).metadata()
}.test {
@@ -351,7 +353,7 @@ class InReplyToMetadataKtTest {
@Test
fun `failed to parse state content`() = runTest {
moleculeFlow(RecompositionMode.Immediate) {
- anInReplyToDetails(
+ anInReplyToDetailsReady(
eventContent = FailedToParseStateContent("", "", "")
).metadata()
}.test {
@@ -364,7 +366,7 @@ class InReplyToMetadataKtTest {
@Test
fun `profile change content`() = runTest {
moleculeFlow(RecompositionMode.Immediate) {
- anInReplyToDetails(
+ anInReplyToDetailsReady(
eventContent = ProfileChangeContent("", "", "", "")
).metadata()
}.test {
@@ -377,7 +379,7 @@ class InReplyToMetadataKtTest {
@Test
fun `room membership content`() = runTest {
moleculeFlow(RecompositionMode.Immediate) {
- anInReplyToDetails(
+ anInReplyToDetailsReady(
eventContent = RoomMembershipContent(A_USER_ID, null)
).metadata()
}.test {
@@ -390,7 +392,7 @@ class InReplyToMetadataKtTest {
@Test
fun `state content`() = runTest {
moleculeFlow(RecompositionMode.Immediate) {
- anInReplyToDetails(
+ anInReplyToDetailsReady(
eventContent = StateContent("", OtherState.RoomJoinRules)
).metadata()
}.test {
@@ -403,7 +405,7 @@ class InReplyToMetadataKtTest {
@Test
fun `unknown content`() = runTest {
moleculeFlow(RecompositionMode.Immediate) {
- anInReplyToDetails(
+ anInReplyToDetailsReady(
eventContent = UnknownContent
).metadata()
}.test {
@@ -416,7 +418,7 @@ class InReplyToMetadataKtTest {
@Test
fun `null content`() = runTest {
moleculeFlow(RecompositionMode.Immediate) {
- anInReplyToDetails(
+ anInReplyToDetailsReady(
eventContent = null
).metadata()
}.test {
@@ -427,18 +429,16 @@ class InReplyToMetadataKtTest {
}
}
-fun anInReplyToDetails(
+private fun anInReplyToDetailsReady(
eventId: EventId = AN_EVENT_ID,
senderId: UserId = A_USER_ID,
- senderDisplayName: String? = "senderDisplayName",
- senderAvatarUrl: String? = "senderAvatarUrl",
+ senderProfile: ProfileTimelineDetails = aProfileTimelineDetails(),
eventContent: EventContent? = aMessageContent(),
textContent: String? = "textContent",
-) = InReplyToDetails(
+) = InReplyToDetails.Ready(
eventId = eventId,
senderId = senderId,
- senderDisplayName = senderDisplayName,
- senderAvatarUrl = senderAvatarUrl,
+ senderProfile = senderProfile,
eventContent = eventContent,
textContent = textContent,
)
diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/typing/TypingNotificationPresenterTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/typing/TypingNotificationPresenterTest.kt
index aaf0f9f774..b136053b40 100644
--- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/typing/TypingNotificationPresenterTest.kt
+++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/typing/TypingNotificationPresenterTest.kt
@@ -22,7 +22,6 @@ import app.cash.turbine.Event
import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
import io.element.android.features.preferences.api.store.SessionPreferencesStore
-import io.element.android.libraries.featureflag.test.InMemorySessionPreferencesStore
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.room.MatrixRoom
import io.element.android.libraries.matrix.api.room.MatrixRoomMembersState
@@ -32,6 +31,7 @@ import io.element.android.libraries.matrix.test.A_USER_ID_3
import io.element.android.libraries.matrix.test.A_USER_ID_4
import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
import io.element.android.libraries.matrix.test.room.aRoomInfo
+import io.element.android.libraries.preferences.test.InMemorySessionPreferencesStore
import io.element.android.tests.testutils.WarmUpRule
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.test.runTest
@@ -201,7 +201,7 @@ class TypingNotificationPresenterTest {
private fun createPresenter(
matrixRoom: MatrixRoom = FakeMatrixRoom().apply {
- givenRoomInfo(aRoomInfo(id = roomId.value, name = ""))
+ givenRoomInfo(aRoomInfo(id = roomId, name = ""))
},
sessionPreferencesStore: SessionPreferencesStore = InMemorySessionPreferencesStore(
isRenderTypingNotificationsEnabled = true
diff --git a/features/migration/api/build.gradle.kts b/features/migration/api/build.gradle.kts
new file mode 100644
index 0000000000..485635259e
--- /dev/null
+++ b/features/migration/api/build.gradle.kts
@@ -0,0 +1,27 @@
+/*
+ * Copyright (c) 2024 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+plugins {
+ id("io.element.android-compose-library")
+}
+
+android {
+ namespace = "io.element.android.features.migration.api"
+}
+
+dependencies {
+ implementation(projects.libraries.architecture)
+}
diff --git a/features/migration/api/src/main/kotlin/io/element/android/features/api/MigrationEntryPoint.kt b/features/migration/api/src/main/kotlin/io/element/android/features/api/MigrationEntryPoint.kt
new file mode 100644
index 0000000000..bd3ad4c466
--- /dev/null
+++ b/features/migration/api/src/main/kotlin/io/element/android/features/api/MigrationEntryPoint.kt
@@ -0,0 +1,31 @@
+/*
+ * Copyright (c) 2024 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.element.android.features.api
+
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+
+interface MigrationEntryPoint {
+ @Composable
+ fun present(): MigrationState
+
+ @Composable
+ fun Render(
+ state: MigrationState,
+ modifier: Modifier,
+ )
+}
diff --git a/features/migration/api/src/main/kotlin/io/element/android/features/api/MigrationState.kt b/features/migration/api/src/main/kotlin/io/element/android/features/api/MigrationState.kt
new file mode 100644
index 0000000000..b51dd82bc5
--- /dev/null
+++ b/features/migration/api/src/main/kotlin/io/element/android/features/api/MigrationState.kt
@@ -0,0 +1,23 @@
+/*
+ * Copyright (c) 2024 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.element.android.features.api
+
+import io.element.android.libraries.architecture.AsyncData
+
+data class MigrationState(
+ val migrationAction: AsyncData,
+)
diff --git a/features/migration/impl/build.gradle.kts b/features/migration/impl/build.gradle.kts
new file mode 100644
index 0000000000..2c8ffa58b3
--- /dev/null
+++ b/features/migration/impl/build.gradle.kts
@@ -0,0 +1,50 @@
+/*
+ * Copyright (c) 2024 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+plugins {
+ id("io.element.android-compose-library")
+ alias(libs.plugins.anvil)
+ alias(libs.plugins.ksp)
+}
+
+android {
+ namespace = "io.element.android.features.migration.impl"
+}
+
+dependencies {
+ implementation(projects.features.migration.api)
+ implementation(projects.libraries.architecture)
+ implementation(projects.libraries.preferences.impl)
+ implementation(libs.androidx.datastore.preferences)
+ implementation(projects.features.rageshake.api)
+ implementation(projects.libraries.designsystem)
+ implementation(projects.libraries.matrix.api)
+ implementation(projects.libraries.sessionStorage.api)
+ implementation(projects.libraries.uiStrings)
+
+ ksp(libs.showkase.processor)
+
+ testImplementation(libs.test.junit)
+ testImplementation(libs.coroutines.test)
+ testImplementation(libs.molecule.runtime)
+ testImplementation(libs.test.truth)
+ testImplementation(libs.test.turbine)
+ testImplementation(projects.libraries.sessionStorage.implMemory)
+ testImplementation(projects.libraries.sessionStorage.test)
+ testImplementation(projects.libraries.preferences.test)
+ testImplementation(projects.tests.testutils)
+ testImplementation(projects.features.rageshake.test)
+}
diff --git a/features/migration/impl/src/main/kotlin/io/element/android/features/migration/impl/DefaultMigrationEntryPoint.kt b/features/migration/impl/src/main/kotlin/io/element/android/features/migration/impl/DefaultMigrationEntryPoint.kt
new file mode 100644
index 0000000000..866e3cfd65
--- /dev/null
+++ b/features/migration/impl/src/main/kotlin/io/element/android/features/migration/impl/DefaultMigrationEntryPoint.kt
@@ -0,0 +1,42 @@
+/*
+ * Copyright (c) 2024 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.element.android.features.migration.impl
+
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import com.squareup.anvil.annotations.ContributesBinding
+import io.element.android.features.api.MigrationEntryPoint
+import io.element.android.features.api.MigrationState
+import io.element.android.libraries.di.AppScope
+import javax.inject.Inject
+
+@ContributesBinding(AppScope::class)
+class DefaultMigrationEntryPoint @Inject constructor(
+ private val migrationPresenter: MigrationPresenter,
+) : MigrationEntryPoint {
+ @Composable
+ override fun present(): MigrationState = migrationPresenter.present()
+
+ @Composable
+ override fun Render(
+ state: MigrationState,
+ modifier: Modifier,
+ ) = MigrationView(
+ migrationState = state,
+ modifier = modifier,
+ )
+}
diff --git a/features/migration/impl/src/main/kotlin/io/element/android/features/migration/impl/DefaultMigrationStore.kt b/features/migration/impl/src/main/kotlin/io/element/android/features/migration/impl/DefaultMigrationStore.kt
new file mode 100644
index 0000000000..a0158061e2
--- /dev/null
+++ b/features/migration/impl/src/main/kotlin/io/element/android/features/migration/impl/DefaultMigrationStore.kt
@@ -0,0 +1,52 @@
+/*
+ * Copyright (c) 2024 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.element.android.features.migration.impl
+
+import android.content.Context
+import androidx.datastore.core.DataStore
+import androidx.datastore.preferences.core.Preferences
+import androidx.datastore.preferences.core.edit
+import androidx.datastore.preferences.core.intPreferencesKey
+import androidx.datastore.preferences.preferencesDataStore
+import com.squareup.anvil.annotations.ContributesBinding
+import io.element.android.libraries.di.AppScope
+import io.element.android.libraries.di.ApplicationContext
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.map
+import javax.inject.Inject
+
+private val Context.dataStore: DataStore by preferencesDataStore(name = "elementx_migration")
+private val applicationMigrationVersion = intPreferencesKey("applicationMigrationVersion")
+
+@ContributesBinding(AppScope::class)
+class DefaultMigrationStore @Inject constructor(
+ @ApplicationContext context: Context,
+) : MigrationStore {
+ private val store = context.dataStore
+
+ override suspend fun setApplicationMigrationVersion(version: Int) {
+ store.edit { prefs ->
+ prefs[applicationMigrationVersion] = version
+ }
+ }
+
+ override fun applicationMigrationVersion(): Flow {
+ return store.data.map { prefs ->
+ prefs[applicationMigrationVersion] ?: 0
+ }
+ }
+}
diff --git a/features/migration/impl/src/main/kotlin/io/element/android/features/migration/impl/MigrationPresenter.kt b/features/migration/impl/src/main/kotlin/io/element/android/features/migration/impl/MigrationPresenter.kt
new file mode 100644
index 0000000000..d157566e24
--- /dev/null
+++ b/features/migration/impl/src/main/kotlin/io/element/android/features/migration/impl/MigrationPresenter.kt
@@ -0,0 +1,74 @@
+/*
+ * Copyright (c) 2024 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.element.android.features.migration.impl
+
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import io.element.android.features.api.MigrationState
+import io.element.android.features.migration.impl.migrations.AppMigration
+import io.element.android.libraries.architecture.AsyncData
+import io.element.android.libraries.architecture.Presenter
+import io.element.android.libraries.di.AppScope
+import io.element.android.libraries.di.SingleIn
+import timber.log.Timber
+import javax.inject.Inject
+
+@SingleIn(AppScope::class)
+class MigrationPresenter @Inject constructor(
+ private val migrationStore: MigrationStore,
+ migrations: Set<@JvmSuppressWildcards AppMigration>,
+) : Presenter {
+ private val orderedMigrations = migrations.sortedBy { it.order }
+ private val lastMigration: Int = orderedMigrations.lastOrNull()?.order ?: 0
+
+ @Composable
+ override fun present(): MigrationState {
+ val migrationStoreVersion by migrationStore.applicationMigrationVersion().collectAsState(initial = null)
+ var migrationAction: AsyncData by remember { mutableStateOf(AsyncData.Uninitialized) }
+
+ // Uncomment this block to run the migration everytime
+// LaunchedEffect(Unit) {
+// Timber.d("Resetting migration version to 0")
+// migrationStore.setApplicationMigrationVersion(0)
+// }
+
+ LaunchedEffect(migrationStoreVersion) {
+ val migrationValue = migrationStoreVersion ?: return@LaunchedEffect
+ if (migrationValue == lastMigration) {
+ Timber.d("Current app migration version: $migrationValue. No migration needed.")
+ migrationAction = AsyncData.Success(Unit)
+ return@LaunchedEffect
+ }
+ migrationAction = AsyncData.Loading(Unit)
+ val nextMigration = orderedMigrations.firstOrNull { it.order > migrationValue }
+ if (nextMigration != null) {
+ Timber.d("Current app migration version: $migrationValue. Applying migration: ${nextMigration.order}")
+ nextMigration.migrate()
+ migrationStore.setApplicationMigrationVersion(nextMigration.order)
+ }
+ }
+
+ return MigrationState(
+ migrationAction = migrationAction,
+ )
+ }
+}
diff --git a/features/migration/impl/src/main/kotlin/io/element/android/features/migration/impl/MigrationStateProvider.kt b/features/migration/impl/src/main/kotlin/io/element/android/features/migration/impl/MigrationStateProvider.kt
new file mode 100644
index 0000000000..a2729c93f3
--- /dev/null
+++ b/features/migration/impl/src/main/kotlin/io/element/android/features/migration/impl/MigrationStateProvider.kt
@@ -0,0 +1,35 @@
+/*
+ * Copyright (c) 2024 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.element.android.features.migration.impl
+
+import androidx.compose.ui.tooling.preview.PreviewParameterProvider
+import io.element.android.features.api.MigrationState
+import io.element.android.libraries.architecture.AsyncData
+
+internal class MigrationStateProvider : PreviewParameterProvider {
+ override val values: Sequence
+ get() = sequenceOf(
+ aMigrationState(),
+ aMigrationState(migrationAction = AsyncData.Loading(Unit)),
+ )
+}
+
+internal fun aMigrationState(
+ migrationAction: AsyncData = AsyncData.Uninitialized,
+) = MigrationState(
+ migrationAction = migrationAction,
+)
diff --git a/features/migration/impl/src/main/kotlin/io/element/android/features/migration/impl/MigrationStore.kt b/features/migration/impl/src/main/kotlin/io/element/android/features/migration/impl/MigrationStore.kt
new file mode 100644
index 0000000000..266b288fab
--- /dev/null
+++ b/features/migration/impl/src/main/kotlin/io/element/android/features/migration/impl/MigrationStore.kt
@@ -0,0 +1,34 @@
+/*
+ * Copyright (c) 2024 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.element.android.features.migration.impl
+
+import kotlinx.coroutines.flow.Flow
+
+interface MigrationStore {
+ /**
+ * Return of flow of the current value for application migration version.
+ * If the value is not set, it will emit 0.
+ * If the emitted value is lower than the current application migration version, it means
+ * that a migration should occur, and at the end [setApplicationMigrationVersion] should be called.
+ */
+ fun applicationMigrationVersion(): Flow
+
+ /**
+ * Set the application migration version, typically after a migration has been done.
+ */
+ suspend fun setApplicationMigrationVersion(version: Int)
+}
diff --git a/features/migration/impl/src/main/kotlin/io/element/android/features/migration/impl/MigrationView.kt b/features/migration/impl/src/main/kotlin/io/element/android/features/migration/impl/MigrationView.kt
new file mode 100644
index 0000000000..f912759b23
--- /dev/null
+++ b/features/migration/impl/src/main/kotlin/io/element/android/features/migration/impl/MigrationView.kt
@@ -0,0 +1,65 @@
+/*
+ * Copyright (c) 2024 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.element.android.features.migration.impl
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.tooling.preview.PreviewParameter
+import androidx.compose.ui.unit.dp
+import io.element.android.features.api.MigrationState
+import io.element.android.libraries.designsystem.preview.ElementPreview
+import io.element.android.libraries.designsystem.preview.PreviewsDayNight
+import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator
+import io.element.android.libraries.designsystem.theme.components.Text
+import io.element.android.libraries.ui.strings.CommonStrings
+
+@Composable
+fun MigrationView(
+ migrationState: MigrationState,
+ modifier: Modifier = Modifier,
+) {
+ Box(
+ modifier = modifier.fillMaxSize(),
+ contentAlignment = Alignment.Center,
+ ) {
+ Column(
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.spacedBy(8.dp),
+ ) {
+ CircularProgressIndicator()
+ if (migrationState.migrationAction.isLoading()) {
+ Text(text = stringResource(id = CommonStrings.common_please_wait))
+ }
+ }
+ }
+}
+
+@PreviewsDayNight
+@Composable
+internal fun MigrationViewPreview(
+ @PreviewParameter(MigrationStateProvider::class) state: MigrationState,
+) = ElementPreview {
+ MigrationView(
+ migrationState = state,
+ )
+}
diff --git a/features/migration/impl/src/main/kotlin/io/element/android/features/migration/impl/migrations/AppMigration.kt b/features/migration/impl/src/main/kotlin/io/element/android/features/migration/impl/migrations/AppMigration.kt
new file mode 100644
index 0000000000..4227bbc717
--- /dev/null
+++ b/features/migration/impl/src/main/kotlin/io/element/android/features/migration/impl/migrations/AppMigration.kt
@@ -0,0 +1,22 @@
+/*
+ * Copyright (c) 2024 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.element.android.features.migration.impl.migrations
+
+interface AppMigration {
+ val order: Int
+ suspend fun migrate()
+}
diff --git a/features/migration/impl/src/main/kotlin/io/element/android/features/migration/impl/migrations/AppMigration01.kt b/features/migration/impl/src/main/kotlin/io/element/android/features/migration/impl/migrations/AppMigration01.kt
new file mode 100644
index 0000000000..01514c530e
--- /dev/null
+++ b/features/migration/impl/src/main/kotlin/io/element/android/features/migration/impl/migrations/AppMigration01.kt
@@ -0,0 +1,36 @@
+/*
+ * Copyright (c) 2024 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.element.android.features.migration.impl.migrations
+
+import com.squareup.anvil.annotations.ContributesMultibinding
+import io.element.android.features.rageshake.api.logs.LogFilesRemover
+import io.element.android.libraries.di.AppScope
+import javax.inject.Inject
+
+/**
+ * Remove existing logs from the device to remove any leaks of sensitive data.
+ */
+@ContributesMultibinding(AppScope::class)
+class AppMigration01 @Inject constructor(
+ private val logFilesRemover: LogFilesRemover,
+) : AppMigration {
+ override val order: Int = 1
+
+ override suspend fun migrate() {
+ logFilesRemover.perform()
+ }
+}
diff --git a/features/migration/impl/src/main/kotlin/io/element/android/features/migration/impl/migrations/AppMigration02.kt b/features/migration/impl/src/main/kotlin/io/element/android/features/migration/impl/migrations/AppMigration02.kt
new file mode 100644
index 0000000000..c354300aa3
--- /dev/null
+++ b/features/migration/impl/src/main/kotlin/io/element/android/features/migration/impl/migrations/AppMigration02.kt
@@ -0,0 +1,49 @@
+/*
+ * Copyright (c) 2024 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.element.android.features.migration.impl.migrations
+
+import com.squareup.anvil.annotations.ContributesMultibinding
+import io.element.android.features.preferences.api.store.SessionPreferencesStoreFactory
+import io.element.android.libraries.di.AppScope
+import io.element.android.libraries.matrix.api.core.SessionId
+import io.element.android.libraries.sessionstorage.api.SessionStore
+import kotlinx.coroutines.coroutineScope
+import javax.inject.Inject
+
+/**
+ * This migration sets the skip session verification preference to true for all existing sessions.
+ * This way we don't force existing users to verify their session again.
+ */
+@ContributesMultibinding(AppScope::class)
+class AppMigration02 @Inject constructor(
+ private val sessionStore: SessionStore,
+ private val sessionPreferenceStoreFactory: SessionPreferencesStoreFactory,
+) : AppMigration {
+ override val order: Int = 2
+
+ override suspend fun migrate() {
+ coroutineScope {
+ for (session in sessionStore.getAllSessions()) {
+ val sessionId = SessionId(session.userId)
+ val preferences = sessionPreferenceStoreFactory.get(sessionId, this)
+ preferences.setSkipSessionVerification(true)
+ // This session preference store must be ephemeral since it's not created with the right coroutine scope
+ sessionPreferenceStoreFactory.remove(sessionId)
+ }
+ }
+ }
+}
diff --git a/features/migration/impl/src/main/kotlin/io/element/android/features/migration/impl/migrations/AppMigration03.kt b/features/migration/impl/src/main/kotlin/io/element/android/features/migration/impl/migrations/AppMigration03.kt
new file mode 100644
index 0000000000..37eb59874e
--- /dev/null
+++ b/features/migration/impl/src/main/kotlin/io/element/android/features/migration/impl/migrations/AppMigration03.kt
@@ -0,0 +1,35 @@
+/*
+ * Copyright (c) 2024 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.element.android.features.migration.impl.migrations
+
+import com.squareup.anvil.annotations.ContributesMultibinding
+import io.element.android.libraries.di.AppScope
+import javax.inject.Inject
+
+/**
+ * This performs the same operation as [AppMigration01], since we need to clear the local logs again.
+ */
+@ContributesMultibinding(AppScope::class)
+class AppMigration03 @Inject constructor(
+ private val migration01: AppMigration01,
+) : AppMigration {
+ override val order: Int = 3
+
+ override suspend fun migrate() {
+ migration01.migrate()
+ }
+}
diff --git a/features/migration/impl/src/test/kotlin/io/element/android/features/migration/impl/InMemoryMigrationStore.kt b/features/migration/impl/src/test/kotlin/io/element/android/features/migration/impl/InMemoryMigrationStore.kt
new file mode 100644
index 0000000000..ba5b63f3cc
--- /dev/null
+++ b/features/migration/impl/src/test/kotlin/io/element/android/features/migration/impl/InMemoryMigrationStore.kt
@@ -0,0 +1,34 @@
+/*
+ * Copyright (c) 2024 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.element.android.features.migration.impl
+
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.MutableStateFlow
+
+class InMemoryMigrationStore(
+ initialApplicationMigrationVersion: Int = 0
+) : MigrationStore {
+ private val applicationMigrationVersion = MutableStateFlow(initialApplicationMigrationVersion)
+
+ override suspend fun setApplicationMigrationVersion(version: Int) {
+ applicationMigrationVersion.value = version
+ }
+
+ override fun applicationMigrationVersion(): Flow {
+ return applicationMigrationVersion
+ }
+}
diff --git a/features/migration/impl/src/test/kotlin/io/element/android/features/migration/impl/MigrationPresenterTest.kt b/features/migration/impl/src/test/kotlin/io/element/android/features/migration/impl/MigrationPresenterTest.kt
new file mode 100644
index 0000000000..3ea0625f76
--- /dev/null
+++ b/features/migration/impl/src/test/kotlin/io/element/android/features/migration/impl/MigrationPresenterTest.kt
@@ -0,0 +1,97 @@
+/*
+ * Copyright (c) 2024 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.element.android.features.migration.impl
+
+import app.cash.molecule.RecompositionMode
+import app.cash.molecule.moleculeFlow
+import app.cash.turbine.test
+import com.google.common.truth.Truth.assertThat
+import io.element.android.features.migration.impl.migrations.AppMigration
+import io.element.android.libraries.architecture.AsyncData
+import io.element.android.tests.testutils.WarmUpRule
+import io.element.android.tests.testutils.consumeItemsUntilPredicate
+import io.element.android.tests.testutils.lambda.LambdaNoParamRecorder
+import io.element.android.tests.testutils.lambda.lambdaRecorder
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.test.runTest
+import org.junit.Rule
+import org.junit.Test
+
+class MigrationPresenterTest {
+ @get:Rule
+ val warmUpRule = WarmUpRule()
+
+ @Test
+ fun `present - no migration should occurs if ApplicationMigrationVersion is the last one`() = runTest {
+ val migrations = (1..10).map { FakeMigration(it) }
+ val store = InMemoryMigrationStore(migrations.maxOf { it.order })
+ val presenter = createPresenter(
+ migrationStore = store,
+ migrations = migrations.toSet(),
+ )
+ moleculeFlow(RecompositionMode.Immediate) {
+ presenter.present()
+ }.test {
+ val initialState = awaitItem()
+ assertThat(initialState.migrationAction).isEqualTo(AsyncData.Uninitialized)
+ awaitItem().also { state ->
+ assertThat(state.migrationAction).isEqualTo(AsyncData.Success(Unit))
+ }
+ }
+ }
+
+ @Test
+ fun `present - testing all migrations`() = runTest {
+ val store = InMemoryMigrationStore(0)
+ val migrations = (1..10).map { FakeMigration(it) }
+ val presenter = createPresenter(
+ migrationStore = store,
+ migrations = migrations.toSet(),
+ )
+ moleculeFlow(RecompositionMode.Immediate) {
+ presenter.present()
+ }.test {
+ val initialState = awaitItem()
+ assertThat(initialState.migrationAction).isEqualTo(AsyncData.Uninitialized)
+ awaitItem().also { state ->
+ assertThat(state.migrationAction).isEqualTo(AsyncData.Loading(Unit))
+ }
+ consumeItemsUntilPredicate { it.migrationAction is AsyncData.Success }
+ assertThat(store.applicationMigrationVersion().first()).isEqualTo(migrations.maxOf { it.order })
+ for (migration in migrations) {
+ migration.migrateLambda.assertions().isCalledOnce()
+ }
+ }
+ }
+}
+
+private fun createPresenter(
+ migrationStore: MigrationStore = InMemoryMigrationStore(0),
+ migrations: Set = setOf(FakeMigration(1)),
+) = MigrationPresenter(
+ migrationStore = migrationStore,
+ migrations = migrations,
+)
+
+private class FakeMigration(
+ override val order: Int,
+ var migrateLambda: LambdaNoParamRecorder = lambdaRecorder { -> },
+) : AppMigration {
+ override suspend fun migrate() {
+ migrateLambda()
+ }
+}
diff --git a/features/migration/impl/src/test/kotlin/io/element/android/features/migration/impl/migrations/AppMigration01Test.kt b/features/migration/impl/src/test/kotlin/io/element/android/features/migration/impl/migrations/AppMigration01Test.kt
new file mode 100644
index 0000000000..91f50a81b3
--- /dev/null
+++ b/features/migration/impl/src/test/kotlin/io/element/android/features/migration/impl/migrations/AppMigration01Test.kt
@@ -0,0 +1,33 @@
+/*
+ * Copyright (c) 2024 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.element.android.features.migration.impl.migrations
+
+import io.element.android.features.rageshake.test.logs.FakeLogFilesRemover
+import kotlinx.coroutines.test.runTest
+import org.junit.Test
+
+class AppMigration01Test {
+ @Test
+ fun `test migration`() = runTest {
+ val logsFileRemover = FakeLogFilesRemover()
+ val migration = AppMigration01(logsFileRemover)
+
+ migration.migrate()
+
+ logsFileRemover.performLambda.assertions().isCalledOnce()
+ }
+}
diff --git a/features/migration/impl/src/test/kotlin/io/element/android/features/migration/impl/migrations/AppMigration02Test.kt b/features/migration/impl/src/test/kotlin/io/element/android/features/migration/impl/migrations/AppMigration02Test.kt
new file mode 100644
index 0000000000..1a077fda2e
--- /dev/null
+++ b/features/migration/impl/src/test/kotlin/io/element/android/features/migration/impl/migrations/AppMigration02Test.kt
@@ -0,0 +1,50 @@
+/*
+ * Copyright (c) 2024 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.element.android.features.migration.impl.migrations
+
+import com.google.common.truth.Truth.assertThat
+import io.element.android.libraries.preferences.test.FakeSessionPreferenceStoreFactory
+import io.element.android.libraries.preferences.test.InMemorySessionPreferencesStore
+import io.element.android.libraries.sessionstorage.impl.memory.InMemorySessionStore
+import io.element.android.libraries.sessionstorage.test.aSessionData
+import io.element.android.tests.testutils.lambda.lambdaRecorder
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.test.runTest
+import org.junit.Test
+
+class AppMigration02Test {
+ @Test
+ fun `test migration`() = runTest {
+ val sessionStore = InMemorySessionStore().apply {
+ updateData(aSessionData())
+ }
+ val sessionPreferencesStore = InMemorySessionPreferencesStore(isSessionVerificationSkipped = false)
+ val sessionPreferencesStoreFactory = FakeSessionPreferenceStoreFactory(
+ getLambda = lambdaRecorder { _, _, -> sessionPreferencesStore },
+ )
+ val migration = AppMigration02(sessionStore = sessionStore, sessionPreferenceStoreFactory = sessionPreferencesStoreFactory)
+
+ migration.migrate()
+
+ // We got the session preferences store
+ sessionPreferencesStoreFactory.getLambda.assertions().isCalledOnce()
+ // We changed the settings for the skipping the session verification
+ assertThat(sessionPreferencesStore.isSessionVerificationSkipped().first()).isTrue()
+ // We removed the session preferences store from cache
+ sessionPreferencesStoreFactory.removeLambda.assertions().isCalledOnce()
+ }
+}
diff --git a/features/migration/impl/src/test/kotlin/io/element/android/features/migration/impl/migrations/AppMigration03Test.kt b/features/migration/impl/src/test/kotlin/io/element/android/features/migration/impl/migrations/AppMigration03Test.kt
new file mode 100644
index 0000000000..c9251bfe76
--- /dev/null
+++ b/features/migration/impl/src/test/kotlin/io/element/android/features/migration/impl/migrations/AppMigration03Test.kt
@@ -0,0 +1,33 @@
+/*
+ * Copyright (c) 2024 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.element.android.features.migration.impl.migrations
+
+import io.element.android.features.rageshake.test.logs.FakeLogFilesRemover
+import kotlinx.coroutines.test.runTest
+import org.junit.Test
+
+class AppMigration03Test {
+ @Test
+ fun `test migration`() = runTest {
+ val logsFileRemover = FakeLogFilesRemover()
+ val migration = AppMigration03(migration01 = AppMigration01(logsFileRemover))
+
+ migration.migrate()
+
+ logsFileRemover.performLambda.assertions().isCalledOnce()
+ }
+}
diff --git a/features/onboarding/impl/src/main/kotlin/io/element/android/features/onboarding/impl/OnBoardingView.kt b/features/onboarding/impl/src/main/kotlin/io/element/android/features/onboarding/impl/OnBoardingView.kt
index d87fb36c03..09553d6c42 100644
--- a/features/onboarding/impl/src/main/kotlin/io/element/android/features/onboarding/impl/OnBoardingView.kt
+++ b/features/onboarding/impl/src/main/kotlin/io/element/android/features/onboarding/impl/OnBoardingView.kt
@@ -202,7 +202,7 @@ private fun OnBoardingButtons(
@PreviewsDayNight
@Composable
-internal fun OnBoardingScreenPreview(
+internal fun OnBoardingViewPreview(
@PreviewParameter(OnBoardingStateProvider::class) state: OnBoardingState
) = ElementPreview {
OnBoardingView(
diff --git a/features/poll/api/src/main/kotlin/io/element/android/features/poll/api/pollcontent/PollAnswerView.kt b/features/poll/api/src/main/kotlin/io/element/android/features/poll/api/pollcontent/PollAnswerView.kt
index 70658e65a8..946f81f71a 100644
--- a/features/poll/api/src/main/kotlin/io/element/android/features/poll/api/pollcontent/PollAnswerView.kt
+++ b/features/poll/api/src/main/kotlin/io/element/android/features/poll/api/pollcontent/PollAnswerView.kt
@@ -132,7 +132,7 @@ internal fun PollAnswerView(
@PreviewsDayNight
@Composable
-internal fun PollAnswerDisclosedNotSelectedPreview() = ElementPreview {
+internal fun PollAnswerViewDisclosedNotSelectedPreview() = ElementPreview {
PollAnswerView(
answerItem = aPollAnswerItem(showVotes = true, isSelected = false),
)
@@ -140,7 +140,7 @@ internal fun PollAnswerDisclosedNotSelectedPreview() = ElementPreview {
@PreviewsDayNight
@Composable
-internal fun PollAnswerDisclosedSelectedPreview() = ElementPreview {
+internal fun PollAnswerViewDisclosedSelectedPreview() = ElementPreview {
PollAnswerView(
answerItem = aPollAnswerItem(showVotes = true, isSelected = true),
)
@@ -148,7 +148,7 @@ internal fun PollAnswerDisclosedSelectedPreview() = ElementPreview {
@PreviewsDayNight
@Composable
-internal fun PollAnswerUndisclosedNotSelectedPreview() = ElementPreview {
+internal fun PollAnswerViewUndisclosedNotSelectedPreview() = ElementPreview {
PollAnswerView(
answerItem = aPollAnswerItem(showVotes = false, isSelected = false),
)
@@ -156,7 +156,7 @@ internal fun PollAnswerUndisclosedNotSelectedPreview() = ElementPreview {
@PreviewsDayNight
@Composable
-internal fun PollAnswerUndisclosedSelectedPreview() = ElementPreview {
+internal fun PollAnswerViewUndisclosedSelectedPreview() = ElementPreview {
PollAnswerView(
answerItem = aPollAnswerItem(showVotes = false, isSelected = true),
)
@@ -164,7 +164,7 @@ internal fun PollAnswerUndisclosedSelectedPreview() = ElementPreview {
@PreviewsDayNight
@Composable
-internal fun PollAnswerEndedWinnerNotSelectedPreview() = ElementPreview {
+internal fun PollAnswerViewEndedWinnerNotSelectedPreview() = ElementPreview {
PollAnswerView(
answerItem = aPollAnswerItem(showVotes = true, isSelected = false, isEnabled = false, isWinner = true),
)
@@ -172,7 +172,7 @@ internal fun PollAnswerEndedWinnerNotSelectedPreview() = ElementPreview {
@PreviewsDayNight
@Composable
-internal fun PollAnswerEndedWinnerSelectedPreview() = ElementPreview {
+internal fun PollAnswerViewEndedWinnerSelectedPreview() = ElementPreview {
PollAnswerView(
answerItem = aPollAnswerItem(showVotes = true, isSelected = true, isEnabled = false, isWinner = true),
)
@@ -180,7 +180,7 @@ internal fun PollAnswerEndedWinnerSelectedPreview() = ElementPreview {
@PreviewsDayNight
@Composable
-internal fun PollAnswerEndedSelectedPreview() = ElementPreview {
+internal fun PollAnswerViewEndedSelectedPreview() = ElementPreview {
PollAnswerView(
answerItem = aPollAnswerItem(showVotes = true, isSelected = true, isEnabled = false, isWinner = false),
)
diff --git a/features/poll/api/src/main/kotlin/io/element/android/features/poll/api/pollcontent/PollContentView.kt b/features/poll/api/src/main/kotlin/io/element/android/features/poll/api/pollcontent/PollContentView.kt
index a77753fc4c..b8f77ce3af 100644
--- a/features/poll/api/src/main/kotlin/io/element/android/features/poll/api/pollcontent/PollContentView.kt
+++ b/features/poll/api/src/main/kotlin/io/element/android/features/poll/api/pollcontent/PollContentView.kt
@@ -241,7 +241,7 @@ private fun CreatorView(
@PreviewsDayNight
@Composable
-internal fun PollContentUndisclosedPreview() = ElementPreview {
+internal fun PollContentViewUndisclosedPreview() = ElementPreview {
PollContentView(
eventId = EventId("\$anEventId"),
question = "What type of food should we have at the party?",
@@ -258,7 +258,7 @@ internal fun PollContentUndisclosedPreview() = ElementPreview {
@PreviewsDayNight
@Composable
-internal fun PollContentDisclosedPreview() = ElementPreview {
+internal fun PollContentViewDisclosedPreview() = ElementPreview {
PollContentView(
eventId = EventId("\$anEventId"),
question = "What type of food should we have at the party?",
@@ -275,7 +275,7 @@ internal fun PollContentDisclosedPreview() = ElementPreview {
@PreviewsDayNight
@Composable
-internal fun PollContentEndedPreview() = ElementPreview {
+internal fun PollContentViewEndedPreview() = ElementPreview {
PollContentView(
eventId = EventId("\$anEventId"),
question = "What type of food should we have at the party?",
@@ -292,7 +292,7 @@ internal fun PollContentEndedPreview() = ElementPreview {
@PreviewsDayNight
@Composable
-internal fun PollContentCreatorEditablePreview() = ElementPreview {
+internal fun PollContentViewCreatorEditablePreview() = ElementPreview {
PollContentView(
eventId = EventId("\$anEventId"),
question = "What type of food should we have at the party?",
@@ -309,7 +309,7 @@ internal fun PollContentCreatorEditablePreview() = ElementPreview {
@PreviewsDayNight
@Composable
-internal fun PollContentCreatorPreview() = ElementPreview {
+internal fun PollContentViewCreatorPreview() = ElementPreview {
PollContentView(
eventId = EventId("\$anEventId"),
question = "What type of food should we have at the party?",
@@ -326,7 +326,7 @@ internal fun PollContentCreatorPreview() = ElementPreview {
@PreviewsDayNight
@Composable
-internal fun PollContentCreatorEndedPreview() = ElementPreview {
+internal fun PollContentViewCreatorEndedPreview() = ElementPreview {
PollContentView(
eventId = EventId("\$anEventId"),
question = "What type of food should we have at the party?",
diff --git a/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/data/PollRepository.kt b/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/data/PollRepository.kt
index 0b9aa5aee0..e8d2506408 100644
--- a/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/data/PollRepository.kt
+++ b/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/data/PollRepository.kt
@@ -20,15 +20,19 @@ import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.poll.PollKind
import io.element.android.libraries.matrix.api.room.MatrixRoom
import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem
+import io.element.android.libraries.matrix.api.timeline.TimelineProvider
+import io.element.android.libraries.matrix.api.timeline.getActiveTimeline
import io.element.android.libraries.matrix.api.timeline.item.event.PollContent
import kotlinx.coroutines.flow.first
import javax.inject.Inject
class PollRepository @Inject constructor(
private val room: MatrixRoom,
+ private val timelineProvider: TimelineProvider,
) {
suspend fun getPoll(eventId: EventId): Result = runCatching {
- room.timeline
+ timelineProvider
+ .getActiveTimeline()
.timelineItems
.first()
.asSequence()
@@ -51,13 +55,15 @@ class PollRepository @Inject constructor(
maxSelections = maxSelections,
pollKind = pollKind,
)
- else -> room.editPoll(
- pollStartId = existingPollId,
- question = question,
- answers = answers,
- maxSelections = maxSelections,
- pollKind = pollKind,
- )
+ else -> timelineProvider
+ .getActiveTimeline()
+ .editPoll(
+ pollStartId = existingPollId,
+ question = question,
+ answers = answers,
+ maxSelections = maxSelections,
+ pollKind = pollKind,
+ )
}
suspend fun deletePoll(
diff --git a/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/history/PollHistoryPresenter.kt b/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/history/PollHistoryPresenter.kt
index da37891d91..981fe00c32 100644
--- a/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/history/PollHistoryPresenter.kt
+++ b/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/history/PollHistoryPresenter.kt
@@ -32,25 +32,24 @@ import io.element.android.features.poll.impl.history.model.PollHistoryFilter
import io.element.android.features.poll.impl.history.model.PollHistoryItems
import io.element.android.features.poll.impl.history.model.PollHistoryItemsFactory
import io.element.android.libraries.architecture.Presenter
-import io.element.android.libraries.matrix.api.room.MatrixRoom
-import io.element.android.libraries.matrix.api.timeline.MatrixTimeline
+import io.element.android.libraries.matrix.api.timeline.Timeline
+import io.element.android.libraries.matrix.api.timeline.TimelineProvider
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import javax.inject.Inject
class PollHistoryPresenter @Inject constructor(
- private val room: MatrixRoom,
private val appCoroutineScope: CoroutineScope,
private val sendPollResponseAction: SendPollResponseAction,
private val endPollAction: EndPollAction,
private val pollHistoryItemFactory: PollHistoryItemsFactory,
+ private val timelineProvider: TimelineProvider,
) : Presenter {
@Composable
override fun present(): PollHistoryState {
- // TODO use room.rememberPollHistory() when working properly?
- val timeline = room.timeline
- val paginationState by timeline.paginationState.collectAsState()
+ val timeline by timelineProvider.activeTimelineFlow().collectAsState()
+ val paginationState by timeline.paginationStatus(Timeline.PaginationDirection.BACKWARDS).collectAsState()
val pollHistoryItemsFlow = remember {
timeline.timelineItems.map { items ->
pollHistoryItemFactory.create(items)
@@ -61,11 +60,11 @@ class PollHistoryPresenter @Inject constructor(
}
val pollHistoryItems by pollHistoryItemsFlow.collectAsState(initial = PollHistoryItems())
LaunchedEffect(paginationState, pollHistoryItems.size) {
- if (pollHistoryItems.size == 0 && paginationState.canBackPaginate) loadMore(timeline)
+ if (pollHistoryItems.size == 0 && paginationState.canPaginate) loadMore(timeline)
}
val isLoading by remember {
derivedStateOf {
- pollHistoryItems.size == 0 || paginationState.isBackPaginating
+ pollHistoryItems.size == 0 || paginationState.isPaginating
}
}
val coroutineScope = rememberCoroutineScope()
@@ -88,14 +87,14 @@ class PollHistoryPresenter @Inject constructor(
return PollHistoryState(
isLoading = isLoading,
- hasMoreToLoad = paginationState.hasMoreToLoadBackwards,
+ hasMoreToLoad = paginationState.hasMoreToLoad,
pollHistoryItems = pollHistoryItems,
activeFilter = activeFilter,
eventSink = ::handleEvents,
)
}
- private fun CoroutineScope.loadMore(pollHistory: MatrixTimeline) = launch {
- pollHistory.paginateBackwards(200)
+ private fun CoroutineScope.loadMore(pollHistory: Timeline) = launch {
+ pollHistory.paginate(Timeline.PaginationDirection.BACKWARDS)
}
}
diff --git a/features/poll/impl/src/main/res/values-sv/translations.xml b/features/poll/impl/src/main/res/values-sv/translations.xml
index 80f8a23ca9..f2116d1eef 100644
--- a/features/poll/impl/src/main/res/values-sv/translations.xml
+++ b/features/poll/impl/src/main/res/values-sv/translations.xml
@@ -4,8 +4,16 @@
"Visa resultat först efter att omröstningen avslutats"
"Dölj röster"
"Alternativ %1$d"
+ "Dina ändringar har inte sparats. Är du säker på att du vill gå tillbaka?"
"Fråga eller ämne"
"Vad handlar omröstningen om?"
"Skapa omröstning"
+ "Är du säker på att du vill radera den här omröstningen?"
+ "Radera omröstning"
"Redigera omröstning"
+ "Kan inte hitta några pågående omröstningar."
+ "Kan inte hitta några tidigare omröstningar."
+ "Pågående"
+ "Tidigare"
+ "Omröstningar"
diff --git a/features/poll/impl/src/test/kotlin/io/element/android/features/poll/impl/PollFixtures.kt b/features/poll/impl/src/test/kotlin/io/element/android/features/poll/impl/PollFixtures.kt
index b54b18b8c0..c0a5b4f7f7 100644
--- a/features/poll/impl/src/test/kotlin/io/element/android/features/poll/impl/PollFixtures.kt
+++ b/features/poll/impl/src/test/kotlin/io/element/android/features/poll/impl/PollFixtures.kt
@@ -20,16 +20,17 @@ import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.poll.PollAnswer
import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem
import io.element.android.libraries.matrix.api.timeline.item.event.PollContent
-import io.element.android.libraries.matrix.test.timeline.FakeMatrixTimeline
import io.element.android.libraries.matrix.test.timeline.aPollContent
import io.element.android.libraries.matrix.test.timeline.anEventTimelineItem
import kotlinx.collections.immutable.persistentListOf
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.flowOf
-fun aPollTimeline(
+fun aPollTimelineItems(
polls: Map = emptyMap(),
-): FakeMatrixTimeline {
- return FakeMatrixTimeline(
- initialTimelineItems = polls.map { entry ->
+): Flow> {
+ return flowOf(
+ polls.map { entry ->
MatrixTimelineItem.Event(
entry.key.value,
anEventTimelineItem(
diff --git a/features/poll/impl/src/test/kotlin/io/element/android/features/poll/impl/create/CreatePollPresenterTest.kt b/features/poll/impl/src/test/kotlin/io/element/android/features/poll/impl/create/CreatePollPresenterTest.kt
index d5be6c2bb6..2dbc612a0b 100644
--- a/features/poll/impl/src/test/kotlin/io/element/android/features/poll/impl/create/CreatePollPresenterTest.kt
+++ b/features/poll/impl/src/test/kotlin/io/element/android/features/poll/impl/create/CreatePollPresenterTest.kt
@@ -25,33 +25,42 @@ import im.vector.app.features.analytics.plan.Composer
import im.vector.app.features.analytics.plan.PollCreation
import io.element.android.features.messages.test.FakeMessageComposerContext
import io.element.android.features.poll.api.create.CreatePollMode
-import io.element.android.features.poll.impl.aPollTimeline
+import io.element.android.features.poll.impl.aPollTimelineItems
import io.element.android.features.poll.impl.anOngoingPollContent
import io.element.android.features.poll.impl.data.PollRepository
+import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.poll.PollKind
import io.element.android.libraries.matrix.api.room.MatrixRoom
import io.element.android.libraries.matrix.api.timeline.item.event.PollContent
import io.element.android.libraries.matrix.test.AN_EVENT_ID
import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
import io.element.android.libraries.matrix.test.room.SavePollInvocation
+import io.element.android.libraries.matrix.test.timeline.FakeTimeline
+import io.element.android.libraries.matrix.test.timeline.LiveTimelineProvider
import io.element.android.services.analytics.test.FakeAnalyticsService
import io.element.android.tests.testutils.WarmUpRule
+import io.element.android.tests.testutils.lambda.assert
+import io.element.android.tests.testutils.lambda.lambdaRecorder
+import io.element.android.tests.testutils.lambda.value
+import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.delay
+import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runTest
import org.junit.Rule
import org.junit.Test
-class CreatePollPresenterTest {
+@OptIn(ExperimentalCoroutinesApi::class) class CreatePollPresenterTest {
@get:Rule
val warmUpRule = WarmUpRule()
private val pollEventId = AN_EVENT_ID
private var navUpInvocationsCount = 0
private val existingPoll = anOngoingPollContent()
+ private val timeline = FakeTimeline(
+ timelineItems = aPollTimelineItems(mapOf(pollEventId to existingPoll))
+ )
private val fakeMatrixRoom = FakeMatrixRoom(
- matrixTimeline = aPollTimeline(
- mapOf(pollEventId to existingPoll)
- )
+ liveTimeline = timeline
)
private val fakeAnalyticsService = FakeAnalyticsService()
private val fakeMessageComposerContext = FakeMessageComposerContext()
@@ -80,7 +89,7 @@ class CreatePollPresenterTest {
@Test
fun `in edit mode, if poll doesn't exist, error is tracked and screen is closed`() = runTest {
val room = FakeMatrixRoom(
- matrixTimeline = aPollTimeline()
+ liveTimeline = FakeTimeline()
)
val presenter = createCreatePollPresenter(mode = CreatePollMode.EditPoll(AN_EVENT_ID), room = room)
moleculeFlow(RecompositionMode.Immediate) {
@@ -180,6 +189,12 @@ class CreatePollPresenterTest {
@Test
fun `edit poll sends a poll edit event`() = runTest {
+ val editPollLambda = lambdaRecorder { _: EventId, _: String, _: List, _: Int, _: PollKind ->
+ Result.success(Unit)
+ }
+ timeline.apply {
+ this.editPollLambda = editPollLambda
+ }
val presenter = createCreatePollPresenter(mode = CreatePollMode.EditPoll(pollEventId))
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
@@ -201,16 +216,18 @@ class CreatePollPresenterTest {
).apply {
eventSink(CreatePollEvents.Save)
}
- delay(1) // Wait for the coroutine to finish
- assertThat(fakeMatrixRoom.editPollInvocations.size).isEqualTo(1)
- assertThat(fakeMatrixRoom.editPollInvocations.last()).isEqualTo(
- SavePollInvocation(
- question = "Changed question",
- answers = listOf("Changed answer 1", "Changed answer 2", "Maybe"),
- maxSelections = 1,
- pollKind = PollKind.Disclosed
+ advanceUntilIdle() // Wait for the coroutine to finish
+
+ assert(editPollLambda)
+ .isCalledOnce()
+ .with(
+ value(pollEventId),
+ value("Changed question"),
+ value(listOf("Changed answer 1", "Changed answer 2", "Maybe")),
+ value(1),
+ value(PollKind.Disclosed)
)
- )
+
assertThat(fakeAnalyticsService.capturedEvents.size).isEqualTo(2)
assertThat(fakeAnalyticsService.capturedEvents[0]).isEqualTo(
Composer(
@@ -233,6 +250,12 @@ class CreatePollPresenterTest {
@Test
fun `when edit poll fails, error is tracked`() = runTest {
val error = Exception("cause")
+ val editPollLambda = lambdaRecorder { _: EventId, _: String, _: List, _: Int, _: PollKind ->
+ Result.failure(error)
+ }
+ timeline.apply {
+ this.editPollLambda = editPollLambda
+ }
fakeMatrixRoom.givenEditPollResult(Result.failure(error))
val presenter = createCreatePollPresenter(mode = CreatePollMode.EditPoll(pollEventId))
moleculeFlow(RecompositionMode.Immediate) {
@@ -241,8 +264,8 @@ class CreatePollPresenterTest {
awaitDefaultItem()
awaitPollLoaded().eventSink(CreatePollEvents.SetAnswer(0, "A"))
awaitPollLoaded(newAnswer1 = "A").eventSink(CreatePollEvents.Save)
- delay(1) // Wait for the coroutine to finish
- assertThat(fakeMatrixRoom.editPollInvocations).hasSize(1)
+ advanceUntilIdle() // Wait for the coroutine to finish
+ assert(editPollLambda).isCalledOnce()
assertThat(fakeAnalyticsService.capturedEvents).isEmpty()
assertThat(fakeAnalyticsService.trackedErrors).hasSize(1)
assertThat(fakeAnalyticsService.trackedErrors).containsExactly(
@@ -497,22 +520,22 @@ class CreatePollPresenterTest {
newAnswer1: String? = null,
newAnswer2: String? = null,
) =
- awaitItem().apply {
- assertThat(canSave).isTrue()
- assertThat(canAddAnswer).isTrue()
- assertThat(question).isEqualTo(newQuestion ?: existingPoll.question)
- assertThat(answers).isEqualTo(existingPoll.expectedAnswersState().toMutableList().apply {
+ awaitItem().also { state ->
+ assertThat(state.canSave).isTrue()
+ assertThat(state.canAddAnswer).isTrue()
+ assertThat(state.question).isEqualTo(newQuestion ?: existingPoll.question)
+ assertThat(state.answers).isEqualTo(existingPoll.expectedAnswersState().toMutableList().apply {
newAnswer1?.let { this[0] = Answer(it, true) }
newAnswer2?.let { this[1] = Answer(it, true) }
})
- assertThat(pollKind).isEqualTo(existingPoll.kind)
+ assertThat(state.pollKind).isEqualTo(existingPoll.kind)
}
private fun createCreatePollPresenter(
mode: CreatePollMode = CreatePollMode.NewPoll,
room: MatrixRoom = fakeMatrixRoom,
): CreatePollPresenter = CreatePollPresenter(
- repository = PollRepository(room),
+ repository = PollRepository(room, LiveTimelineProvider(room)),
analyticsService = fakeAnalyticsService,
messageComposerContext = fakeMessageComposerContext,
navigateUp = { navUpInvocationsCount++ },
diff --git a/features/poll/impl/src/test/kotlin/io/element/android/features/poll/impl/history/PollHistoryPresenterTest.kt b/features/poll/impl/src/test/kotlin/io/element/android/features/poll/impl/history/PollHistoryPresenterTest.kt
index f7948a951c..638f9e6ff0 100644
--- a/features/poll/impl/src/test/kotlin/io/element/android/features/poll/impl/history/PollHistoryPresenterTest.kt
+++ b/features/poll/impl/src/test/kotlin/io/element/android/features/poll/impl/history/PollHistoryPresenterTest.kt
@@ -22,7 +22,7 @@ import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
import io.element.android.features.poll.api.actions.EndPollAction
import io.element.android.features.poll.api.actions.SendPollResponseAction
-import io.element.android.features.poll.impl.aPollTimeline
+import io.element.android.features.poll.impl.aPollTimelineItems
import io.element.android.features.poll.impl.anEndedPollContent
import io.element.android.features.poll.impl.anOngoingPollContent
import io.element.android.features.poll.impl.history.model.PollHistoryFilter
@@ -32,14 +32,21 @@ import io.element.android.features.poll.test.actions.FakeEndPollAction
import io.element.android.features.poll.test.actions.FakeSendPollResponseAction
import io.element.android.libraries.dateformatter.test.FakeDaySeparatorFormatter
import io.element.android.libraries.matrix.api.room.MatrixRoom
+import io.element.android.libraries.matrix.api.timeline.Timeline
import io.element.android.libraries.matrix.test.AN_EVENT_ID
import io.element.android.libraries.matrix.test.AN_EVENT_ID_2
import io.element.android.libraries.matrix.test.FakeMatrixClient
import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
+import io.element.android.libraries.matrix.test.timeline.FakeTimeline
+import io.element.android.libraries.matrix.test.timeline.LiveTimelineProvider
import io.element.android.tests.testutils.WarmUpRule
+import io.element.android.tests.testutils.lambda.assert
+import io.element.android.tests.testutils.lambda.lambdaRecorder
import io.element.android.tests.testutils.testCoroutineDispatchers
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.getAndUpdate
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.runCurrent
import kotlinx.coroutines.test.runTest
@@ -50,14 +57,18 @@ class PollHistoryPresenterTest {
@get:Rule
val warmUpRule = WarmUpRule()
- private val timeline = aPollTimeline(
- polls = mapOf(
- AN_EVENT_ID to anOngoingPollContent(),
- AN_EVENT_ID_2 to anEndedPollContent()
- )
+ private val backwardPaginationStatus = MutableStateFlow(Timeline.PaginationStatus(isPaginating = false, hasMoreToLoad = true))
+ private val timeline = FakeTimeline(
+ timelineItems = aPollTimelineItems(
+ mapOf(
+ AN_EVENT_ID to anOngoingPollContent(),
+ AN_EVENT_ID_2 to anEndedPollContent()
+ )
+ ),
+ backwardPaginationStatus = backwardPaginationStatus
)
private val room = FakeMatrixRoom(
- matrixTimeline = timeline
+ liveTimeline = timeline
)
@Test
@@ -66,7 +77,6 @@ class PollHistoryPresenterTest {
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
- skipItems(1)
awaitItem().also { state ->
assertThat(state.activeFilter).isEqualTo(PollHistoryFilter.ONGOING)
assertThat(state.pollHistoryItems.size).isEqualTo(0)
@@ -127,26 +137,30 @@ class PollHistoryPresenterTest {
@Test
fun `present - load more scenario`() = runTest {
+ val paginateLambda = lambdaRecorder { _: Timeline.PaginationDirection ->
+ Result.success(false)
+ }
+ timeline.apply {
+ this.paginateLambda = paginateLambda
+ }
val presenter = createPollHistoryPresenter(room = room)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
- skipItems(2)
- awaitItem().also { state ->
- assertThat(state.pollHistoryItems.size).isEqualTo(2)
- }
- timeline.updatePaginationState {
- copy(isBackPaginating = false)
- }
+ skipItems(1)
val loadedState = awaitItem()
assertThat(loadedState.isLoading).isFalse()
loadedState.eventSink(PollHistoryEvents.LoadMore)
+ backwardPaginationStatus.getAndUpdate { it.copy(isPaginating = true) }
awaitItem().also { state ->
assertThat(state.isLoading).isTrue()
}
+ backwardPaginationStatus.getAndUpdate { it.copy(isPaginating = false) }
awaitItem().also { state ->
assertThat(state.isLoading).isFalse()
}
+ // Called once by the initial load and once by the load more event
+ assert(paginateLambda).isCalledExactly(2)
}
}
@@ -162,11 +176,11 @@ class PollHistoryPresenterTest {
),
): PollHistoryPresenter {
return PollHistoryPresenter(
- room = room,
appCoroutineScope = appCoroutineScope,
sendPollResponseAction = sendPollResponseAction,
endPollAction = endPollAction,
pollHistoryItemFactory = pollHistoryItemFactory,
+ timelineProvider = LiveTimelineProvider(room),
)
}
}
diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsStateProvider.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsStateProvider.kt
index e4643182f4..fb93a63ffc 100644
--- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsStateProvider.kt
+++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsStateProvider.kt
@@ -25,22 +25,36 @@ open class DeveloperSettingsStateProvider : PreviewParameterProvider
get() = sequenceOf(
aDeveloperSettingsState(),
- aDeveloperSettingsState().copy(clearCacheAction = AsyncData.Loading()),
- aDeveloperSettingsState().copy(
- customElementCallBaseUrlState = CustomElementCallBaseUrlState(
+ aDeveloperSettingsState(
+ clearCacheAction = AsyncData.Loading()
+ ),
+ aDeveloperSettingsState(
+ customElementCallBaseUrlState = aCustomElementCallBaseUrlState(
baseUrl = "https://call.element.ahoy",
- defaultUrl = "https://call.element.io",
- validator = { true }
)
),
)
}
-fun aDeveloperSettingsState() = DeveloperSettingsState(
+fun aDeveloperSettingsState(
+ clearCacheAction: AsyncData = AsyncData.Uninitialized,
+ customElementCallBaseUrlState: CustomElementCallBaseUrlState = aCustomElementCallBaseUrlState(),
+ eventSink: (DeveloperSettingsEvents) -> Unit = {},
+) = DeveloperSettingsState(
features = aFeatureUiModelList(),
rageshakeState = aRageshakePreferencesState(),
cacheSize = AsyncData.Success("1.2 MB"),
- clearCacheAction = AsyncData.Uninitialized,
- customElementCallBaseUrlState = CustomElementCallBaseUrlState(baseUrl = null, defaultUrl = "https://call.element.io", validator = { true }),
- eventSink = {}
+ clearCacheAction = clearCacheAction,
+ customElementCallBaseUrlState = customElementCallBaseUrlState,
+ eventSink = eventSink,
+)
+
+fun aCustomElementCallBaseUrlState(
+ baseUrl: String? = null,
+ defaultUrl: String = "https://call.element.io",
+ validator: (String?) -> Boolean = { true },
+) = CustomElementCallBaseUrlState(
+ baseUrl = baseUrl,
+ defaultUrl = defaultUrl,
+ validator = validator,
)
diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/edit/EditDefaultNotificationSettingStateProvider.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/edit/EditDefaultNotificationSettingStateProvider.kt
index 1eb7c0389e..703050227e 100644
--- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/edit/EditDefaultNotificationSettingStateProvider.kt
+++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/edit/EditDefaultNotificationSettingStateProvider.kt
@@ -42,16 +42,21 @@ private fun anEditDefaultNotificationSettingsState(
) = EditDefaultNotificationSettingState(
isOneToOne = isOneToOne,
mode = RoomNotificationMode.MENTIONS_AND_KEYWORDS_ONLY,
- roomsWithUserDefinedMode = persistentListOf(aRoomSummary()),
+ roomsWithUserDefinedMode = persistentListOf(
+ aRoomSummary("Room"),
+ aRoomSummary(null),
+ ),
changeNotificationSettingAction = changeNotificationSettingAction,
displayMentionsOnlyDisclaimer = displayMentionsOnlyDisclaimer,
eventSink = {}
)
-private fun aRoomSummary() = RoomSummary.Filled(
+private fun aRoomSummary(
+ name: String?,
+) = RoomSummary.Filled(
aRoomSummaryDetails(
roomId = RoomId("!roomId:domain"),
- name = "Room",
+ name = name,
avatarUrl = null,
isDirect = false,
lastMessage = null,
diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/edit/EditDefaultNotificationSettingView.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/edit/EditDefaultNotificationSettingView.kt
index 9eca90fa54..1938a4b4f0 100644
--- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/edit/EditDefaultNotificationSettingView.kt
+++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/edit/EditDefaultNotificationSettingView.kt
@@ -21,6 +21,7 @@ import androidx.compose.foundation.selection.selectableGroup
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.tooling.preview.PreviewParameter
import io.element.android.features.preferences.impl.R
import io.element.android.libraries.designsystem.components.async.AsyncActionView
@@ -100,7 +101,11 @@ fun EditDefaultNotificationSettingView(
)
ListItem(
headlineContent = {
- Text(text = summary.details.name)
+ val roomName = summary.details.name
+ Text(
+ text = roomName ?: stringResource(id = CommonStrings.common_no_room_name),
+ fontStyle = FontStyle.Italic.takeIf { roomName == null }
+ )
},
supportingContent = {
Text(text = subtitle)
diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootView.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootView.kt
index 21132df5c9..a70f73fa6e 100644
--- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootView.kt
+++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootView.kt
@@ -17,6 +17,7 @@
package io.element.android.features.preferences.impl.root
import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
@@ -81,92 +82,152 @@ fun PreferencesRootView(
},
user = state.myUser,
)
- if (state.showSecureBackup) {
- ListItem(
- headlineContent = { Text(stringResource(id = CommonStrings.common_chat_backup)) },
- leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.KeySolid())),
- trailingContent = ListItemContent.Badge.takeIf { state.showSecureBackupBadge },
- onClick = onSecureBackupClicked,
- )
- HorizontalDivider()
- }
- if (state.accountManagementUrl != null) {
- ListItem(
- headlineContent = { Text(stringResource(id = CommonStrings.action_manage_account)) },
- leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.UserProfile())),
- trailingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.PopOut())),
- onClick = { onManageAccountClicked(state.accountManagementUrl) },
- )
- HorizontalDivider()
- }
- if (state.showAnalyticsSettings) {
- ListItem(
- headlineContent = { Text(stringResource(id = CommonStrings.common_analytics)) },
- leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Chart())),
- onClick = onOpenAnalytics,
- )
- }
- if (state.showNotificationSettings) {
- ListItem(
- headlineContent = { Text(stringResource(id = R.string.screen_notification_settings_title)) },
- leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Notifications())),
- onClick = onOpenNotificationSettings,
- )
- }
- if (state.showBlockedUsersItem) {
- ListItem(
- headlineContent = { Text(stringResource(id = CommonStrings.common_blocked_users)) },
- leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Block())),
- onClick = onOpenBlockedUsers,
- )
- }
+
+ // 'Manage my app' section
+ ManageAppSection(
+ state = state,
+ onOpenNotificationSettings = onOpenNotificationSettings,
+ onOpenLockScreenSettings = onOpenLockScreenSettings,
+ onSecureBackupClicked = onSecureBackupClicked,
+ )
+
+ // 'Account' section
+ ManageAccountSection(
+ state = state,
+ onManageAccountClicked = onManageAccountClicked,
+ onOpenBlockedUsers = onOpenBlockedUsers
+ )
+
+ // General section
+ GeneralSection(
+ state = state,
+ onOpenAbout = onOpenAbout,
+ onOpenAnalytics = onOpenAnalytics,
+ onOpenRageShake = onOpenRageShake,
+ onOpenAdvancedSettings = onOpenAdvancedSettings,
+ onOpenDeveloperSettings = onOpenDeveloperSettings,
+ onSignOutClicked = onSignOutClicked,
+ )
+
+ Footer(
+ version = state.version,
+ deviceId = state.deviceId,
+ )
+ }
+}
+
+@Composable
+private fun ColumnScope.ManageAppSection(
+ state: PreferencesRootState,
+ onOpenNotificationSettings: () -> Unit,
+ onOpenLockScreenSettings: () -> Unit,
+ onSecureBackupClicked: () -> Unit,
+) {
+ if (state.showNotificationSettings) {
ListItem(
- headlineContent = { Text(stringResource(id = CommonStrings.common_report_a_problem)) },
- leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.ChatProblem())),
- onClick = onOpenRageShake
+ headlineContent = { Text(stringResource(id = R.string.screen_notification_settings_title)) },
+ leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Notifications())),
+ onClick = onOpenNotificationSettings,
)
+ }
+ if (state.showLockScreenSettings) {
ListItem(
- headlineContent = { Text(stringResource(id = CommonStrings.common_about)) },
- leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Info())),
- onClick = onOpenAbout,
+ headlineContent = { Text(stringResource(id = CommonStrings.common_screen_lock)) },
+ leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Lock())),
+ onClick = onOpenLockScreenSettings,
)
- if (state.showLockScreenSettings) {
- ListItem(
- headlineContent = { Text(stringResource(id = CommonStrings.common_screen_lock)) },
- leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Lock())),
- onClick = onOpenLockScreenSettings,
- )
- }
- HorizontalDivider()
- if (state.devicesManagementUrl != null) {
- ListItem(
- headlineContent = { Text(stringResource(id = CommonStrings.action_manage_devices)) },
- leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Devices())),
- trailingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.PopOut())),
- onClick = { onManageAccountClicked(state.devicesManagementUrl) },
- )
- HorizontalDivider()
- }
+ }
+ if (state.showSecureBackup) {
ListItem(
- headlineContent = { Text(stringResource(id = CommonStrings.common_advanced_settings)) },
- leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Settings())),
- onClick = onOpenAdvancedSettings,
+ headlineContent = { Text(stringResource(id = CommonStrings.common_chat_backup)) },
+ leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.KeySolid())),
+ trailingContent = ListItemContent.Badge.takeIf { state.showSecureBackupBadge },
+ onClick = onSecureBackupClicked,
)
- if (state.showDeveloperSettings) {
- DeveloperPreferencesView(onOpenDeveloperSettings)
- }
+ }
+ if (state.showNotificationSettings || state.showLockScreenSettings || state.showSecureBackup) {
HorizontalDivider()
+ }
+}
+
+@Composable
+private fun ColumnScope.ManageAccountSection(
+ state: PreferencesRootState,
+ onManageAccountClicked: (url: String) -> Unit,
+ onOpenBlockedUsers: () -> Unit,
+) {
+ state.accountManagementUrl?.let { url ->
ListItem(
- headlineContent = { Text(stringResource(id = CommonStrings.action_signout)) },
- leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.SignOut())),
- style = ListItemStyle.Destructive,
- onClick = onSignOutClicked,
+ headlineContent = { Text(stringResource(id = CommonStrings.action_manage_account)) },
+ leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.UserProfile())),
+ trailingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.PopOut())),
+ onClick = { onManageAccountClicked(url) },
)
- Footer(
- version = state.version,
- deviceId = state.deviceId,
+ }
+
+ state.devicesManagementUrl?.let { url ->
+ ListItem(
+ headlineContent = { Text(stringResource(id = CommonStrings.action_manage_devices)) },
+ leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Devices())),
+ trailingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.PopOut())),
+ onClick = { onManageAccountClicked(url) },
+ )
+ }
+
+ if (state.showBlockedUsersItem) {
+ ListItem(
+ headlineContent = { Text(stringResource(id = CommonStrings.common_blocked_users)) },
+ leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Block())),
+ onClick = onOpenBlockedUsers,
+ )
+ }
+
+ if (state.accountManagementUrl != null || state.devicesManagementUrl != null || state.showBlockedUsersItem) {
+ HorizontalDivider()
+ }
+}
+
+@Composable
+private fun ColumnScope.GeneralSection(
+ state: PreferencesRootState,
+ onOpenAbout: () -> Unit,
+ onOpenAnalytics: () -> Unit,
+ onOpenRageShake: () -> Unit,
+ onOpenAdvancedSettings: () -> Unit,
+ onOpenDeveloperSettings: () -> Unit,
+ onSignOutClicked: () -> Unit,
+) {
+ ListItem(
+ headlineContent = { Text(stringResource(id = CommonStrings.common_about)) },
+ leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Info())),
+ onClick = onOpenAbout,
+ )
+ ListItem(
+ headlineContent = { Text(stringResource(id = CommonStrings.common_report_a_problem)) },
+ leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.ChatProblem())),
+ onClick = onOpenRageShake
+ )
+ if (state.showAnalyticsSettings) {
+ ListItem(
+ headlineContent = { Text(stringResource(id = CommonStrings.common_analytics)) },
+ leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Chart())),
+ onClick = onOpenAnalytics,
)
}
+ ListItem(
+ headlineContent = { Text(stringResource(id = CommonStrings.common_advanced_settings)) },
+ leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Settings())),
+ onClick = onOpenAdvancedSettings,
+ )
+ if (state.showDeveloperSettings) {
+ DeveloperPreferencesView(onOpenDeveloperSettings)
+ }
+ ListItem(
+ headlineContent = { Text(stringResource(id = CommonStrings.action_signout)) },
+ leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.SignOut())),
+ style = ListItemStyle.Destructive,
+ onClick = onSignOutClicked,
+ )
}
@Composable
@@ -186,7 +247,7 @@ private fun Footer(
Text(
modifier = Modifier
.fillMaxWidth()
- .padding(top = 40.dp, bottom = 24.dp),
+ .padding(start = 16.dp, end = 16.dp, top = 40.dp, bottom = 24.dp),
textAlign = TextAlign.Center,
text = text,
style = ElementTheme.typography.fontBodySmRegular,
diff --git a/features/preferences/impl/src/main/res/values-de/translations.xml b/features/preferences/impl/src/main/res/values-de/translations.xml
index 65b4923452..1faac8c4eb 100644
--- a/features/preferences/impl/src/main/res/values-de/translations.xml
+++ b/features/preferences/impl/src/main/res/values-de/translations.xml
@@ -39,7 +39,7 @@ Wenn du fortfährst, können sich einige deiner Einstellungen ändern."
"Die Konfiguration wurde nicht korrigiert, bitte versuche es erneut."
"Gruppenchats"
"Einladungen"
- "Dein Homeserver unterstützt diese Option in verschlüsselten Räumen nicht. In einigen Räumen wirst du möglicherweise nicht benachrichtigt."
+ "Dein Homeserver unterstützt diese Option in verschlüsselten Chat nicht. In einigen Chats wirst du möglicherweise nicht benachrichtigt."
"Erwähnungen"
"Alle"
"Erwähnungen"
diff --git a/features/preferences/impl/src/main/res/values-it/translations.xml b/features/preferences/impl/src/main/res/values-it/translations.xml
index 8b0c6baa64..e15213218c 100644
--- a/features/preferences/impl/src/main/res/values-it/translations.xml
+++ b/features/preferences/impl/src/main/res/values-it/translations.xml
@@ -49,4 +49,6 @@ Se procedi, alcune delle tue impostazioni potrebbero cambiare."
"impostazioni di sistema"
"Notifiche di sistema disattivate"
"Notifiche"
+ "Risoluzione dei problemi"
+ "Risoluzione di problemi delle notifiche"
diff --git a/features/preferences/impl/src/main/res/values-sv/translations.xml b/features/preferences/impl/src/main/res/values-sv/translations.xml
index aa4eea5a2e..adcb60f9d5 100644
--- a/features/preferences/impl/src/main/res/values-sv/translations.xml
+++ b/features/preferences/impl/src/main/res/values-sv/translations.xml
@@ -6,6 +6,11 @@
"Ange en anpassad bas-URL för Element Call."
"Ogiltig URL, se till att du inkluderar protokollet (http/https) och rätt adress."
"Inaktivera rik-text-redigeraren för att skriva Markdown manuellt."
+ "Läskvitton"
+ "Om det är avstängt kommer dina läskvitton inte att skickas till någon. Du kommer fortfarande att få läskvitton från andra användare."
+ "Dela närvaro"
+ "Om det är avstängt kan du inte skicka eller ta emot läskvitton eller skrivnotiser"
+ "Aktivera alternativet för att visa meddelandekälla i tidslinjen."
"Avblockera"
"Du kommer att kunna se alla meddelanden från dem igen."
"Avblockera användare"
@@ -31,6 +36,8 @@ Om du fortsätter kan vissa av dina inställningar ändras."
"Aktivera aviseringar på den här enheten"
"Konfigurationen har inte korrigerats, vänligen pröva igen."
"Gruppchattar"
+ "Inbjudningar"
+ "Din hemserver stöder inte det här alternativet i krypterade rum, du kanske inte aviseras i vissa rum."
"Omnämnanden"
"Alla"
"Omnämnanden"
diff --git a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsPresenterTest.kt b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsPresenterTest.kt
index 1745d57396..8f6c45c51d 100644
--- a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsPresenterTest.kt
+++ b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsPresenterTest.kt
@@ -21,8 +21,8 @@ import app.cash.molecule.moleculeFlow
import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
import io.element.android.compound.theme.Theme
-import io.element.android.libraries.featureflag.test.InMemoryAppPreferencesStore
-import io.element.android.libraries.featureflag.test.InMemorySessionPreferencesStore
+import io.element.android.libraries.preferences.test.InMemoryAppPreferencesStore
+import io.element.android.libraries.preferences.test.InMemorySessionPreferencesStore
import io.element.android.tests.testutils.WarmUpRule
import io.element.android.tests.testutils.awaitLastSequentialItem
import kotlinx.coroutines.test.runTest
diff --git a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsPresenterTest.kt b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsPresenterTest.kt
index 19b1614500..6908ee3c9d 100644
--- a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsPresenterTest.kt
+++ b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsPresenterTest.kt
@@ -29,7 +29,7 @@ import io.element.android.features.rageshake.test.rageshake.FakeRageshakeDataSto
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.featureflag.api.FeatureFlags
import io.element.android.libraries.featureflag.test.FakeFeatureFlagService
-import io.element.android.libraries.featureflag.test.InMemoryAppPreferencesStore
+import io.element.android.libraries.preferences.test.InMemoryAppPreferencesStore
import io.element.android.tests.testutils.WarmUpRule
import io.element.android.tests.testutils.awaitLastSequentialItem
import kotlinx.coroutines.test.runTest
diff --git a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsViewTest.kt b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsViewTest.kt
new file mode 100644
index 0000000000..ea120a27c1
--- /dev/null
+++ b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsViewTest.kt
@@ -0,0 +1,127 @@
+/*
+ * Copyright (c) 2024 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.element.android.features.preferences.impl.developer
+
+import androidx.activity.ComponentActivity
+import androidx.compose.ui.test.junit4.AndroidComposeTestRule
+import androidx.compose.ui.test.junit4.createAndroidComposeRule
+import androidx.compose.ui.test.onNodeWithText
+import androidx.compose.ui.test.performClick
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import io.element.android.features.preferences.impl.R
+import io.element.android.libraries.ui.strings.CommonStrings
+import io.element.android.tests.testutils.EnsureNeverCalled
+import io.element.android.tests.testutils.EventsRecorder
+import io.element.android.tests.testutils.clickOn
+import io.element.android.tests.testutils.ensureCalledOnce
+import io.element.android.tests.testutils.pressBack
+import org.junit.Rule
+import org.junit.Test
+import org.junit.rules.TestRule
+import org.junit.runner.RunWith
+import org.robolectric.annotation.Config
+
+@RunWith(AndroidJUnit4::class)
+class DeveloperSettingsViewTest {
+ @get:Rule
+ val rule = createAndroidComposeRule()
+
+ @Test
+ fun `clicking on back invokes the expected callback`() {
+ val eventsRecorder = EventsRecorder(expectEvents = false)
+ ensureCalledOnce {
+ rule.setDeveloperSettingsView(
+ state = aDeveloperSettingsState(
+ eventSink = eventsRecorder
+ ),
+ onBackPressed = it
+ )
+ rule.pressBack()
+ }
+ }
+
+ @Test
+ fun `clicking on element call url open the dialogs and submit emits the expected event`() {
+ val eventsRecorder = EventsRecorder()
+ rule.setDeveloperSettingsView(
+ state = aDeveloperSettingsState(
+ eventSink = eventsRecorder
+ ),
+ )
+ rule.clickOn(R.string.screen_advanced_settings_element_call_base_url)
+ rule.clickOn(CommonStrings.action_ok)
+ eventsRecorder.assertSingle(DeveloperSettingsEvents.SetCustomElementCallBaseUrl("https://call.element.io"))
+ }
+
+ @Config(qualifiers = "h1024dp")
+ @Test
+ fun `clicking on open showkase invokes the expected callback`() {
+ val eventsRecorder = EventsRecorder(expectEvents = false)
+ ensureCalledOnce {
+ rule.setDeveloperSettingsView(
+ state = aDeveloperSettingsState(
+ eventSink = eventsRecorder
+ ),
+ onOpenShowkase = it
+ )
+ rule.onNodeWithText("Open Showkase browser").performClick()
+ }
+ }
+
+ @Test
+ fun `clicking on configure tracing invokes the expected callback`() {
+ val eventsRecorder = EventsRecorder(expectEvents = false)
+ ensureCalledOnce {
+ rule.setDeveloperSettingsView(
+ state = aDeveloperSettingsState(
+ eventSink = eventsRecorder
+ ),
+ onOpenConfigureTracing = it
+ )
+ rule.onNodeWithText("Configure tracing").performClick()
+ }
+ }
+
+ @Config(qualifiers = "h1024dp")
+ @Test
+ fun `clicking on clear cache emits the expected event`() {
+ val eventsRecorder = EventsRecorder()
+ rule.setDeveloperSettingsView(
+ state = aDeveloperSettingsState(
+ eventSink = eventsRecorder
+ ),
+ )
+ rule.onNodeWithText("Clear cache").performClick()
+ eventsRecorder.assertSingle(DeveloperSettingsEvents.ClearCache)
+ }
+}
+
+private fun AndroidComposeTestRule.setDeveloperSettingsView(
+ state: DeveloperSettingsState,
+ onOpenShowkase: () -> Unit = EnsureNeverCalled(),
+ onOpenConfigureTracing: () -> Unit = EnsureNeverCalled(),
+ onBackPressed: () -> Unit = EnsureNeverCalled()
+) {
+ setContent {
+ DeveloperSettingsView(
+ state = state,
+ onOpenShowkase = onOpenShowkase,
+ onOpenConfigureTracing = onOpenConfigureTracing,
+ onBackPressed = onBackPressed,
+ )
+ }
+}
diff --git a/features/rageshake/api/src/main/kotlin/io/element/android/features/rageshake/api/logs/LogFilesRemover.kt b/features/rageshake/api/src/main/kotlin/io/element/android/features/rageshake/api/logs/LogFilesRemover.kt
new file mode 100644
index 0000000000..dd73133060
--- /dev/null
+++ b/features/rageshake/api/src/main/kotlin/io/element/android/features/rageshake/api/logs/LogFilesRemover.kt
@@ -0,0 +1,21 @@
+/*
+ * Copyright (c) 2024 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.element.android.features.rageshake.api.logs
+
+interface LogFilesRemover {
+ suspend fun perform()
+}
diff --git a/features/rageshake/api/src/main/kotlin/io/element/android/features/rageshake/api/reporter/BugReporter.kt b/features/rageshake/api/src/main/kotlin/io/element/android/features/rageshake/api/reporter/BugReporter.kt
index d8e05f947b..4f48871666 100644
--- a/features/rageshake/api/src/main/kotlin/io/element/android/features/rageshake/api/reporter/BugReporter.kt
+++ b/features/rageshake/api/src/main/kotlin/io/element/android/features/rageshake/api/reporter/BugReporter.kt
@@ -38,11 +38,6 @@ interface BugReporter {
listener: BugReporterListener?
)
- /**
- * Clean the log files if needed to avoid wasting disk space.
- */
- fun cleanLogDirectoryIfNeeded()
-
/**
* Provide the log directory.
*/
diff --git a/features/rageshake/impl/build.gradle.kts b/features/rageshake/impl/build.gradle.kts
index fdc8e803f7..6ac0776d0a 100644
--- a/features/rageshake/impl/build.gradle.kts
+++ b/features/rageshake/impl/build.gradle.kts
@@ -38,6 +38,7 @@ anvil {
dependencies {
implementation(projects.anvilannotations)
anvil(projects.anvilcodegen)
+ implementation(projects.appconfig)
implementation(projects.services.toolbox.api)
implementation(projects.libraries.androidutils)
implementation(projects.libraries.core)
diff --git a/features/rageshake/impl/src/main/kotlin/io/element/android/features/rageshake/impl/bugreport/BugReportPresenter.kt b/features/rageshake/impl/src/main/kotlin/io/element/android/features/rageshake/impl/bugreport/BugReportPresenter.kt
index 5e3b3cfeb1..5eeb078275 100644
--- a/features/rageshake/impl/src/main/kotlin/io/element/android/features/rageshake/impl/bugreport/BugReportPresenter.kt
+++ b/features/rageshake/impl/src/main/kotlin/io/element/android/features/rageshake/impl/bugreport/BugReportPresenter.kt
@@ -26,10 +26,10 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import io.element.android.features.rageshake.api.crash.CrashDataStore
+import io.element.android.features.rageshake.api.logs.LogFilesRemover
import io.element.android.features.rageshake.api.reporter.BugReporter
import io.element.android.features.rageshake.api.reporter.BugReporterListener
import io.element.android.features.rageshake.api.screenshot.ScreenshotHolder
-import io.element.android.features.rageshake.impl.logs.VectorFileLogger
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.architecture.Presenter
import kotlinx.coroutines.CoroutineScope
@@ -40,6 +40,7 @@ class BugReportPresenter @Inject constructor(
private val bugReporter: BugReporter,
private val crashDataStore: CrashDataStore,
private val screenshotHolder: ScreenshotHolder,
+ private val logFilesRemover: LogFilesRemover,
private val appCoroutineScope: CoroutineScope,
) : Presenter {
private class BugReporterUploadListener(
@@ -95,6 +96,7 @@ class BugReportPresenter @Inject constructor(
if (formState.value.description.length < 10) {
sendingAction.value = AsyncAction.Failure(BugReportFormError.DescriptionTooShort)
} else {
+ sendingAction.value = AsyncAction.Loading
appCoroutineScope.sendBugReport(formState.value, crashInfo.isNotEmpty(), uploadListener)
}
}
@@ -150,6 +152,6 @@ class BugReportPresenter @Inject constructor(
private fun CoroutineScope.resetAll() = launch {
screenshotHolder.reset()
crashDataStore.reset()
- VectorFileLogger.getFromTimber()?.reset()
+ logFilesRemover.perform()
}
}
diff --git a/features/rageshake/impl/src/main/kotlin/io/element/android/features/rageshake/impl/logs/DefaultLogFilesRemover.kt b/features/rageshake/impl/src/main/kotlin/io/element/android/features/rageshake/impl/logs/DefaultLogFilesRemover.kt
new file mode 100644
index 0000000000..c0c7c8c346
--- /dev/null
+++ b/features/rageshake/impl/src/main/kotlin/io/element/android/features/rageshake/impl/logs/DefaultLogFilesRemover.kt
@@ -0,0 +1,32 @@
+/*
+ * Copyright (c) 2024 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.element.android.features.rageshake.impl.logs
+
+import com.squareup.anvil.annotations.ContributesBinding
+import io.element.android.features.rageshake.api.logs.LogFilesRemover
+import io.element.android.features.rageshake.impl.reporter.DefaultBugReporter
+import io.element.android.libraries.di.AppScope
+import javax.inject.Inject
+
+@ContributesBinding(AppScope::class)
+class DefaultLogFilesRemover @Inject constructor(
+ private val bugReporter: DefaultBugReporter,
+) : LogFilesRemover {
+ override suspend fun perform() {
+ bugReporter.deleteAllFiles()
+ }
+}
diff --git a/features/rageshake/impl/src/main/kotlin/io/element/android/features/rageshake/impl/logs/LogFormatter.kt b/features/rageshake/impl/src/main/kotlin/io/element/android/features/rageshake/impl/logs/LogFormatter.kt
deleted file mode 100644
index e7381c1c58..0000000000
--- a/features/rageshake/impl/src/main/kotlin/io/element/android/features/rageshake/impl/logs/LogFormatter.kt
+++ /dev/null
@@ -1,63 +0,0 @@
-/*
- * Copyright (c) 2022 New Vector Ltd
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-package io.element.android.features.rageshake.impl.logs
-
-import java.io.PrintWriter
-import java.io.StringWriter
-import java.text.SimpleDateFormat
-import java.util.Date
-import java.util.Locale
-import java.util.TimeZone
-import java.util.logging.Formatter
-import java.util.logging.LogRecord
-
-internal class LogFormatter : Formatter() {
- override fun format(r: LogRecord): String {
- if (!isTimeZoneSet) {
- DATE_FORMAT.timeZone = TimeZone.getTimeZone("UTC")
- isTimeZoneSet = true
- }
-
- val thrown = r.thrown
- if (thrown != null) {
- val sw = StringWriter()
- val pw = PrintWriter(sw)
- sw.write(r.message)
- sw.write(LINE_SEPARATOR)
- thrown.printStackTrace(pw)
- pw.flush()
- return sw.toString()
- } else {
- val b = StringBuilder()
- val date = DATE_FORMAT.format(Date(r.millis))
- b.append(date)
- b.append("Z ")
- b.append(r.message)
- b.append(LINE_SEPARATOR)
- return b.toString()
- }
- }
-
- companion object {
- private val LINE_SEPARATOR = System.getProperty("line.separator") ?: "\n"
-
- // private val DATE_FORMAT = SimpleDateFormat("MM-dd HH:mm:ss.SSS", Locale.US)
- private val DATE_FORMAT = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss*SSSZZZZ", Locale.US)
-
- private var isTimeZoneSet = false
- }
-}
diff --git a/features/rageshake/impl/src/main/kotlin/io/element/android/features/rageshake/impl/logs/VectorFileLogger.kt b/features/rageshake/impl/src/main/kotlin/io/element/android/features/rageshake/impl/logs/VectorFileLogger.kt
deleted file mode 100644
index 859f1c961a..0000000000
--- a/features/rageshake/impl/src/main/kotlin/io/element/android/features/rageshake/impl/logs/VectorFileLogger.kt
+++ /dev/null
@@ -1,176 +0,0 @@
-/*
- * Copyright (c) 2022 New Vector Ltd
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-package io.element.android.features.rageshake.impl.logs
-
-import android.content.Context
-import android.util.Log
-import io.element.android.libraries.androidutils.file.safeDelete
-import io.element.android.libraries.core.data.tryOrNull
-import kotlinx.coroutines.CoroutineDispatcher
-import kotlinx.coroutines.DelicateCoroutinesApi
-import kotlinx.coroutines.Dispatchers
-import kotlinx.coroutines.GlobalScope
-import kotlinx.coroutines.launch
-import timber.log.Timber
-import java.io.File
-import java.io.PrintWriter
-import java.io.StringWriter
-import java.util.logging.FileHandler
-import java.util.logging.Level
-import java.util.logging.Logger
-
-/**
- * Will be planted in Timber.
- */
-class VectorFileLogger(
- private val context: Context,
- // private val vectorPreferences: VectorPreferences
- private val dispatcher: CoroutineDispatcher = Dispatchers.IO,
-) : Timber.Tree() {
- companion object {
- fun getFromTimber(): VectorFileLogger? {
- return Timber.forest().filterIsInstance().firstOrNull()
- }
-
- private const val SIZE_20MB = 20 * 1024 * 1024
- // private const val SIZE_50MB = 50 * 1024 * 1024
- }
-
- /*
- private val maxLogSizeByte = if (vectorPreferences.labAllowedExtendedLogging()) SIZE_50MB else SIZE_20MB
- private val logRotationCount = if (vectorPreferences.labAllowedExtendedLogging()) 15 else 7
- */
- private val maxLogSizeByte = SIZE_20MB
- private val logRotationCount = 7
-
- private val logger = Logger.getLogger(context.packageName).apply {
- tryOrNull {
- useParentHandlers = false
- level = Level.ALL
- }
- }
-
- private val fileHandler: FileHandler?
- private val cacheDirectory get() = File(context.cacheDir, "logs").apply {
- if (!exists()) mkdirs()
- }
- private var fileNamePrefix = "logs"
-
- private val prioPrefixes = mapOf(
- Log.VERBOSE to "V/ ",
- Log.DEBUG to "D/ ",
- Log.INFO to "I/ ",
- Log.WARN to "W/ ",
- Log.ERROR to "E/ ",
- Log.ASSERT to "WTF/ "
- )
-
- init {
- for (i in 0..15) {
- val file = File(cacheDirectory, "elementLogs.$i.txt")
- file.safeDelete()
- }
-
- fileHandler = tryOrNull(
- onError = { Timber.e(it, "Failed to initialize FileLogger") }
- ) {
- FileHandler(
- cacheDirectory.absolutePath + "/" + fileNamePrefix + ".%g.txt",
- maxLogSizeByte,
- logRotationCount
- )
- .also { it.formatter = LogFormatter() }
- .also { logger.addHandler(it) }
- }
- }
-
- fun reset() {
- // Delete all files
- getLogFiles().map {
- it.safeDelete()
- }
- }
-
- @OptIn(DelicateCoroutinesApi::class)
- override fun log(priority: Int, tag: String?, message: String, t: Throwable?) {
- fileHandler ?: return
- GlobalScope.launch(dispatcher) {
- if (skipLog(priority)) return@launch
- if (t != null) {
- logToFile(t)
- }
- logToFile(prioPrefixes[priority] ?: "$priority ", tag ?: "Tag", message)
- }
- }
-
- private fun skipLog(priority: Int): Boolean {
- // return if (vectorPreferences.labAllowedExtendedLogging()) {
- // false
- // } else {
- // // Exclude verbose logs
- // priority < Log.DEBUG
- // }
- // Exclude verbose logs
- return priority < Log.DEBUG
- }
-
- /**
- * Adds our own log files to the provided list of files.
- *
- * @return The list of files with logs.
- */
- private fun getLogFiles(): List {
- return tryOrNull(
- onError = { Timber.e(it, "## getLogFiles() failed") }
- ) {
- fileHandler
- ?.flush()
- ?.let { 0 until logRotationCount }
- ?.mapNotNull { index ->
- File(cacheDirectory, "$fileNamePrefix.$index.txt")
- .takeIf { it.exists() }
- }
- }
- .orEmpty()
- }
-
- /**
- * Log an Throwable.
- *
- * @param throwable the throwable to log
- */
- private fun logToFile(throwable: Throwable?) {
- throwable ?: return
-
- val errors = StringWriter()
- throwable.printStackTrace(PrintWriter(errors))
-
- logger.info(errors.toString())
- }
-
- private fun logToFile(level: String, tag: String, content: String) {
- val b = StringBuilder()
- b.append(Thread.currentThread().id)
- b.append(" ")
- b.append(level)
- b.append("/")
- b.append(tag)
- b.append(": ")
- b.append(content)
- logger.info(b.toString())
- }
-}
diff --git a/features/rageshake/impl/src/main/kotlin/io/element/android/features/rageshake/impl/reporter/DefaultBugReporter.kt b/features/rageshake/impl/src/main/kotlin/io/element/android/features/rageshake/impl/reporter/DefaultBugReporter.kt
index aa0633c273..295fa17563 100755
--- a/features/rageshake/impl/src/main/kotlin/io/element/android/features/rageshake/impl/reporter/DefaultBugReporter.kt
+++ b/features/rageshake/impl/src/main/kotlin/io/element/android/features/rageshake/impl/reporter/DefaultBugReporter.kt
@@ -18,10 +18,10 @@ package io.element.android.features.rageshake.impl.reporter
import android.content.Context
import android.os.Build
-import android.text.format.DateUtils.DAY_IN_MILLIS
import androidx.core.net.toFile
import androidx.core.net.toUri
import com.squareup.anvil.annotations.ContributesBinding
+import io.element.android.appconfig.ApplicationConfig
import io.element.android.features.rageshake.api.crash.CrashDataStore
import io.element.android.features.rageshake.api.reporter.BugReporter
import io.element.android.features.rageshake.api.reporter.BugReporterListener
@@ -39,11 +39,8 @@ import io.element.android.libraries.di.SingleIn
import io.element.android.libraries.matrix.api.SdkMetadata
import io.element.android.libraries.network.useragent.UserAgentProvider
import io.element.android.libraries.sessionstorage.api.SessionStore
-import io.element.android.services.toolbox.api.systemclock.SystemClock
import kotlinx.coroutines.CancellationException
-import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.first
-import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import okhttp3.Call
import okhttp3.MediaType.Companion.toMediaTypeOrNull
@@ -75,8 +72,6 @@ class DefaultBugReporter @Inject constructor(
@ApplicationContext private val context: Context,
private val screenshotHolder: ScreenshotHolder,
private val crashDataStore: CrashDataStore,
- private val coroutineScope: CoroutineScope,
- private val systemClock: SystemClock,
private val coroutineDispatchers: CoroutineDispatchers,
private val okHttpClient: Provider,
private val userAgentProvider: UserAgentProvider,
@@ -89,7 +84,6 @@ class DefaultBugReporter @Inject constructor(
// filenames
private const val LOG_CAT_FILENAME = "logcat.log"
private const val LOG_DIRECTORY_NAME = "logs"
- private const val BUFFER_SIZE = 1024 * 1024 * 50
}
// the pending bug report call
@@ -126,7 +120,7 @@ class DefaultBugReporter @Inject constructor(
val gzippedFiles = ArrayList()
if (withDevicesLogs) {
- val files = getLogFiles()
+ val files = getLogFiles().sortedByDescending { it.lastModified() }
files.mapNotNullTo(gzippedFiles) { f ->
when {
isCancelled -> null
@@ -141,7 +135,7 @@ class DefaultBugReporter @Inject constructor(
saveLogCat()
val gzippedLogcat = compressFile(logCatErrFile)
if (null != gzippedLogcat) {
- if (gzippedFiles.size == 0) {
+ if (gzippedFiles.isEmpty()) {
gzippedFiles.add(gzippedLogcat)
} else {
gzippedFiles.add(0, gzippedLogcat)
@@ -167,15 +161,26 @@ class DefaultBugReporter @Inject constructor(
.addFormDataPart("sdk_sha", sdkMetadata.sdkGitSha)
.addFormDataPart("local_time", LocalDateTime.now().format(DateTimeFormatter.ISO_DATE_TIME))
.addFormDataPart("utc_time", LocalDateTime.ofInstant(Instant.now(), ZoneOffset.UTC).format(DateTimeFormatter.ISO_DATE_TIME))
+ .addFormDataPart("app_id", buildMeta.applicationId)
+ // Nightly versions have a custom version name suffix that we should remove for the bug report
+ .addFormDataPart("Version", buildMeta.versionName.replace("-nightly", ""))
currentTracingFilter?.let {
builder.addFormDataPart("tracing_filter", it)
}
// add the gzipped files, don't cancel the whole upload if only some file failed to upload
+ var totalUploadedSize = 0L
var uploadedSomeLogs = false
for (file in gzippedFiles) {
try {
- builder.addFormDataPart("compressed-log", file.name, file.asRequestBody(MimeTypes.OctetStream.toMediaTypeOrNull()))
+ val requestBody = file.asRequestBody(MimeTypes.OctetStream.toMediaTypeOrNull())
+ totalUploadedSize += requestBody.contentLength()
+ // If we are about to upload more than the max request size, stop here
+ if (totalUploadedSize > ApplicationConfig.MAX_LOG_UPLOAD_SIZE) {
+ Timber.e("Could not upload file ${file.name} because it would exceed the max request size")
+ break
+ }
+ builder.addFormDataPart("compressed-log", file.name, requestBody)
uploadedSomeLogs = true
} catch (e: CancellationException) {
throw e
@@ -339,10 +344,9 @@ class DefaultBugReporter @Inject constructor(
}
}
- override fun cleanLogDirectoryIfNeeded() {
- coroutineScope.launch(coroutineDispatchers.io) {
- // delete the log files older than 1 day, except the most recent one
- deleteOldLogFiles(systemClock.epochMillis() - DAY_IN_MILLIS)
+ suspend fun deleteAllFiles() {
+ withContext(coroutineDispatchers.io) {
+ getLogFiles().forEach { it.safeDelete() }
}
}
@@ -362,19 +366,8 @@ class DefaultBugReporter @Inject constructor(
}.orEmpty()
}
- /**
- * Delete the log files older than the given time except the most recent one.
- * @param time the time in ms
- */
- private fun deleteOldLogFiles(time: Long) {
- val logFiles = getLogFiles()
- val oldLogFiles = logFiles.filter { it.lastModified() < time }
- oldLogFiles.deleteAllExceptMostRecent()
- }
-
/**
* Delete all the log files except the most recent one.
- *
*/
private fun List.deleteAllExceptMostRecent() {
if (size > 1) {
@@ -429,7 +422,7 @@ class DefaultBugReporter @Inject constructor(
val separator = System.getProperty("line.separator")
logcatProc.inputStream
.reader()
- .buffered(BUFFER_SIZE)
+ .buffered(ApplicationConfig.MAX_LOG_UPLOAD_SIZE.toInt())
.forEachLine { line ->
streamWriter.append(line)
streamWriter.append(separator)
diff --git a/features/rageshake/impl/src/main/res/values-sv/translations.xml b/features/rageshake/impl/src/main/res/values-sv/translations.xml
index dc307d4f30..0058b6519a 100644
--- a/features/rageshake/impl/src/main/res/values-sv/translations.xml
+++ b/features/rageshake/impl/src/main/res/values-sv/translations.xml
@@ -12,4 +12,5 @@
"Skicka skärmdump"
"Loggar kommer att inkluderas i ditt meddelande för att se till att allt fungerar korrekt. Om du vill skicka ditt meddelande utan loggar stänger du av den här inställningen."
"%1$s kraschade senast den användes. Vill du dela en kraschrapport med oss?"
+ "Se loggar"
diff --git a/features/rageshake/impl/src/test/kotlin/io/element/android/features/rageshake/impl/bugreport/BugReportPresenterTest.kt b/features/rageshake/impl/src/test/kotlin/io/element/android/features/rageshake/impl/bugreport/BugReportPresenterTest.kt
index 7d6f16d412..e0c033afc7 100644
--- a/features/rageshake/impl/src/test/kotlin/io/element/android/features/rageshake/impl/bugreport/BugReportPresenterTest.kt
+++ b/features/rageshake/impl/src/test/kotlin/io/element/android/features/rageshake/impl/bugreport/BugReportPresenterTest.kt
@@ -21,15 +21,18 @@ import app.cash.molecule.moleculeFlow
import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
import io.element.android.features.rageshake.api.crash.CrashDataStore
+import io.element.android.features.rageshake.api.logs.LogFilesRemover
import io.element.android.features.rageshake.api.reporter.BugReporter
import io.element.android.features.rageshake.api.screenshot.ScreenshotHolder
import io.element.android.features.rageshake.test.crash.A_CRASH_DATA
import io.element.android.features.rageshake.test.crash.FakeCrashDataStore
+import io.element.android.features.rageshake.test.logs.FakeLogFilesRemover
import io.element.android.features.rageshake.test.screenshot.A_SCREENSHOT_URI
import io.element.android.features.rageshake.test.screenshot.FakeScreenshotHolder
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.matrix.test.A_FAILURE_REASON
import io.element.android.tests.testutils.WarmUpRule
+import io.element.android.tests.testutils.lambda.lambdaRecorder
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.runTest
import org.junit.Rule
@@ -117,9 +120,11 @@ class BugReportPresenterTest {
@Test
fun `present - reset all`() = runTest {
+ val logFilesRemoverLambda = lambdaRecorder { -> }
val presenter = createPresenter(
crashDataStore = FakeCrashDataStore(crashData = A_CRASH_DATA, appHasCrashed = true),
screenshotHolder = FakeScreenshotHolder(screenshotUri = A_SCREENSHOT_URI),
+ logFilesRemover = FakeLogFilesRemover(logFilesRemoverLambda),
)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
@@ -131,6 +136,7 @@ class BugReportPresenterTest {
initialState.eventSink.invoke(BugReportEvents.ResetAll)
val resetState = awaitItem()
assertThat(resetState.hasCrashLogs).isFalse()
+ logFilesRemoverLambda.assertions().isCalledExactly(1)
// TODO Make it live assertThat(resetState.screenshotUri).isNull()
}
}
@@ -239,10 +245,12 @@ class BugReportPresenterTest {
bugReporter: BugReporter = FakeBugReporter(),
crashDataStore: CrashDataStore = FakeCrashDataStore(),
screenshotHolder: ScreenshotHolder = FakeScreenshotHolder(),
+ logFilesRemover: LogFilesRemover = FakeLogFilesRemover(lambdaRecorder(ensureNeverCalled = true) { -> }),
) = BugReportPresenter(
- bugReporter,
- crashDataStore,
- screenshotHolder,
- this,
+ bugReporter = bugReporter,
+ crashDataStore = crashDataStore,
+ screenshotHolder = screenshotHolder,
+ logFilesRemover = logFilesRemover,
+ appCoroutineScope = this,
)
}
diff --git a/features/rageshake/impl/src/test/kotlin/io/element/android/features/rageshake/impl/bugreport/FakeBugReporter.kt b/features/rageshake/impl/src/test/kotlin/io/element/android/features/rageshake/impl/bugreport/FakeBugReporter.kt
index 0a67a79f57..cce2d5d144 100644
--- a/features/rageshake/impl/src/test/kotlin/io/element/android/features/rageshake/impl/bugreport/FakeBugReporter.kt
+++ b/features/rageshake/impl/src/test/kotlin/io/element/android/features/rageshake/impl/bugreport/FakeBugReporter.kt
@@ -52,10 +52,6 @@ class FakeBugReporter(val mode: FakeBugReporterMode = FakeBugReporterMode.Succes
listener?.onUploadSucceed()
}
- override fun cleanLogDirectoryIfNeeded() {
- // No op
- }
-
override fun logDirectory(): File {
return File("fake")
}
diff --git a/features/rageshake/impl/src/test/kotlin/io/element/android/features/rageshake/impl/logs/VectorFileLoggerTest.kt b/features/rageshake/impl/src/test/kotlin/io/element/android/features/rageshake/impl/logs/VectorFileLoggerTest.kt
deleted file mode 100644
index 26e29e1786..0000000000
--- a/features/rageshake/impl/src/test/kotlin/io/element/android/features/rageshake/impl/logs/VectorFileLoggerTest.kt
+++ /dev/null
@@ -1,58 +0,0 @@
-/*
- * Copyright (c) 2023 New Vector Ltd
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-package io.element.android.features.rageshake.impl.logs
-
-import com.google.common.truth.Truth.assertThat
-import io.element.android.libraries.matrix.test.A_THROWABLE
-import io.element.android.tests.testutils.testCoroutineDispatchers
-import kotlinx.coroutines.test.TestScope
-import kotlinx.coroutines.test.runTest
-import org.junit.Test
-import org.junit.runner.RunWith
-import org.robolectric.RobolectricTestRunner
-import org.robolectric.RuntimeEnvironment
-
-@RunWith(RobolectricTestRunner::class)
-class VectorFileLoggerTest {
- @Test
- fun `init VectorFileLogger log debug`() = runTest {
- val sut = createVectorFileLogger()
- sut.d("A debug log")
- }
-
- @Test
- fun `init VectorFileLogger log error`() = runTest {
- val sut = createVectorFileLogger()
- sut.e(A_THROWABLE, "A debug log")
- }
-
- @Test
- fun `reset VectorFileLogger`() = runTest {
- val sut = createVectorFileLogger()
- sut.reset()
- }
-
- @Test
- fun `check getFromTimber`() {
- assertThat(VectorFileLogger.getFromTimber()).isNull()
- }
-
- private fun TestScope.createVectorFileLogger() = VectorFileLogger(
- context = RuntimeEnvironment.getApplication(),
- dispatcher = testCoroutineDispatchers().io,
- )
-}
diff --git a/features/rageshake/impl/src/test/kotlin/io/element/android/features/rageshake/impl/reporter/DefaultBugReporterTest.kt b/features/rageshake/impl/src/test/kotlin/io/element/android/features/rageshake/impl/reporter/DefaultBugReporterTest.kt
index 8d48220ab5..f2c8d0d0a8 100755
--- a/features/rageshake/impl/src/test/kotlin/io/element/android/features/rageshake/impl/reporter/DefaultBugReporterTest.kt
+++ b/features/rageshake/impl/src/test/kotlin/io/element/android/features/rageshake/impl/reporter/DefaultBugReporterTest.kt
@@ -24,7 +24,6 @@ import io.element.android.libraries.matrix.test.FakeSdkMetadata
import io.element.android.libraries.matrix.test.core.aBuildMeta
import io.element.android.libraries.network.useragent.DefaultUserAgentProvider
import io.element.android.libraries.sessionstorage.impl.memory.InMemorySessionStore
-import io.element.android.services.toolbox.test.systemclock.FakeSystemClock
import io.element.android.tests.testutils.testCoroutineDispatchers
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.runTest
@@ -144,8 +143,6 @@ class DefaultBugReporterTest {
context = RuntimeEnvironment.getApplication(),
screenshotHolder = FakeScreenshotHolder(),
crashDataStore = FakeCrashDataStore(),
- coroutineScope = this,
- systemClock = FakeSystemClock(),
coroutineDispatchers = testCoroutineDispatchers(),
okHttpClient = { OkHttpClient.Builder().build() },
userAgentProvider = DefaultUserAgentProvider(buildMeta, FakeSdkMetadata("123456789")),
@@ -157,6 +154,6 @@ class DefaultBugReporterTest {
}
companion object {
- private const val EXPECTED_NUMBER_OF_PROGRESS_VALUE = 15
+ private const val EXPECTED_NUMBER_OF_PROGRESS_VALUE = 17
}
}
diff --git a/features/rageshake/test/build.gradle.kts b/features/rageshake/test/build.gradle.kts
index 31d0377f35..c22d2bd205 100644
--- a/features/rageshake/test/build.gradle.kts
+++ b/features/rageshake/test/build.gradle.kts
@@ -24,4 +24,5 @@ android {
dependencies {
implementation(projects.features.rageshake.api)
implementation(libs.coroutines.core)
+ implementation(projects.tests.testutils)
}
diff --git a/features/rageshake/test/src/main/kotlin/io/element/android/features/rageshake/test/logs/FakeLogFilesRemover.kt b/features/rageshake/test/src/main/kotlin/io/element/android/features/rageshake/test/logs/FakeLogFilesRemover.kt
new file mode 100644
index 0000000000..a416f2eeb4
--- /dev/null
+++ b/features/rageshake/test/src/main/kotlin/io/element/android/features/rageshake/test/logs/FakeLogFilesRemover.kt
@@ -0,0 +1,29 @@
+/*
+ * Copyright (c) 2024 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.element.android.features.rageshake.test.logs
+
+import io.element.android.features.rageshake.api.logs.LogFilesRemover
+import io.element.android.tests.testutils.lambda.LambdaNoParamRecorder
+import io.element.android.tests.testutils.lambda.lambdaRecorder
+
+class FakeLogFilesRemover(
+ var performLambda: LambdaNoParamRecorder = lambdaRecorder { -> },
+) : LogFilesRemover {
+ override suspend fun perform() {
+ performLambda()
+ }
+}
diff --git a/features/roomaliasresolver/api/build.gradle.kts b/features/roomaliasresolver/api/build.gradle.kts
new file mode 100644
index 0000000000..631c9e2dbf
--- /dev/null
+++ b/features/roomaliasresolver/api/build.gradle.kts
@@ -0,0 +1,28 @@
+/*
+ * Copyright (c) 2024 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+plugins {
+ id("io.element.android-library")
+}
+
+android {
+ namespace = "io.element.android.features.roomaliasresolver.api"
+}
+
+dependencies {
+ implementation(projects.libraries.architecture)
+ implementation(projects.libraries.matrix.api)
+}
diff --git a/features/roomaliasresolver/api/src/main/kotlin/io/element/android/features/roomaliasesolver/api/RoomAliasResolverEntryPoint.kt b/features/roomaliasresolver/api/src/main/kotlin/io/element/android/features/roomaliasesolver/api/RoomAliasResolverEntryPoint.kt
new file mode 100644
index 0000000000..8c0cc10f64
--- /dev/null
+++ b/features/roomaliasresolver/api/src/main/kotlin/io/element/android/features/roomaliasesolver/api/RoomAliasResolverEntryPoint.kt
@@ -0,0 +1,43 @@
+/*
+ * Copyright (c) 2024 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.element.android.features.roomaliasesolver.api
+
+import com.bumble.appyx.core.modality.BuildContext
+import com.bumble.appyx.core.node.Node
+import com.bumble.appyx.core.plugin.Plugin
+import io.element.android.libraries.architecture.FeatureEntryPoint
+import io.element.android.libraries.architecture.NodeInputs
+import io.element.android.libraries.matrix.api.core.RoomAlias
+import io.element.android.libraries.matrix.api.core.RoomId
+
+interface RoomAliasResolverEntryPoint : FeatureEntryPoint {
+ fun nodeBuilder(parentNode: Node, buildContext: BuildContext): NodeBuilder
+
+ interface NodeBuilder {
+ fun callback(callback: Callback): NodeBuilder
+ fun params(params: Params): NodeBuilder
+ fun build(): Node
+ }
+
+ interface Callback : Plugin {
+ fun onAliasResolved(roomId: RoomId)
+ }
+
+ data class Params(
+ val roomAlias: RoomAlias
+ ) : NodeInputs
+}
diff --git a/features/roomaliasresolver/impl/build.gradle.kts b/features/roomaliasresolver/impl/build.gradle.kts
new file mode 100644
index 0000000000..00be3b0076
--- /dev/null
+++ b/features/roomaliasresolver/impl/build.gradle.kts
@@ -0,0 +1,61 @@
+/*
+ * Copyright (c) 2024 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+plugins {
+ id("io.element.android-compose-library")
+ alias(libs.plugins.anvil)
+ alias(libs.plugins.ksp)
+ id("kotlin-parcelize")
+}
+
+android {
+ namespace = "io.element.android.features.roomaliasresolver.impl"
+ testOptions {
+ unitTests {
+ isIncludeAndroidResources = true
+ }
+ }
+}
+
+anvil {
+ generateDaggerFactories.set(true)
+}
+
+dependencies {
+ implementation(projects.anvilannotations)
+ anvil(projects.anvilcodegen)
+ api(projects.features.roomaliasresolver.api)
+ implementation(projects.libraries.core)
+ implementation(projects.libraries.architecture)
+ implementation(projects.libraries.androidutils)
+ implementation(projects.libraries.matrix.api)
+ implementation(projects.libraries.matrixui)
+ implementation(projects.libraries.designsystem)
+ implementation(projects.libraries.uiStrings)
+
+ testImplementation(libs.test.junit)
+ testImplementation(libs.coroutines.test)
+ testImplementation(libs.molecule.runtime)
+ testImplementation(libs.test.robolectric)
+ testImplementation(libs.test.truth)
+ testImplementation(libs.test.turbine)
+ testImplementation(projects.libraries.matrix.test)
+ testImplementation(projects.tests.testutils)
+ testImplementation(libs.androidx.compose.ui.test.junit)
+ testReleaseImplementation(libs.androidx.compose.ui.test.manifest)
+
+ ksp(libs.showkase.processor)
+}
diff --git a/features/roomaliasresolver/impl/src/main/kotlin/io/element/android/features/roomaliasresolver/impl/DefaultRoomAliasResolverEntryPoint.kt b/features/roomaliasresolver/impl/src/main/kotlin/io/element/android/features/roomaliasresolver/impl/DefaultRoomAliasResolverEntryPoint.kt
new file mode 100644
index 0000000000..46bb6cf6c1
--- /dev/null
+++ b/features/roomaliasresolver/impl/src/main/kotlin/io/element/android/features/roomaliasresolver/impl/DefaultRoomAliasResolverEntryPoint.kt
@@ -0,0 +1,49 @@
+/*
+ * Copyright (c) 2024 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.element.android.features.roomaliasresolver.impl
+
+import com.bumble.appyx.core.modality.BuildContext
+import com.bumble.appyx.core.node.Node
+import com.bumble.appyx.core.plugin.Plugin
+import com.squareup.anvil.annotations.ContributesBinding
+import io.element.android.features.roomaliasesolver.api.RoomAliasResolverEntryPoint
+import io.element.android.libraries.architecture.createNode
+import io.element.android.libraries.di.AppScope
+import javax.inject.Inject
+
+@ContributesBinding(AppScope::class)
+class DefaultRoomAliasResolverEntryPoint @Inject constructor() : RoomAliasResolverEntryPoint {
+ override fun nodeBuilder(parentNode: Node, buildContext: BuildContext): RoomAliasResolverEntryPoint.NodeBuilder {
+ val plugins = ArrayList()
+
+ return object : RoomAliasResolverEntryPoint.NodeBuilder {
+ override fun callback(callback: RoomAliasResolverEntryPoint.Callback): RoomAliasResolverEntryPoint.NodeBuilder {
+ plugins += callback
+ return this
+ }
+
+ override fun params(params: RoomAliasResolverEntryPoint.Params): RoomAliasResolverEntryPoint.NodeBuilder {
+ plugins += params
+ return this
+ }
+
+ override fun build(): Node {
+ return parentNode.createNode(buildContext, plugins)
+ }
+ }
+ }
+}
diff --git a/features/roomaliasresolver/impl/src/main/kotlin/io/element/android/features/roomaliasresolver/impl/RoomAliasResolverEvents.kt b/features/roomaliasresolver/impl/src/main/kotlin/io/element/android/features/roomaliasresolver/impl/RoomAliasResolverEvents.kt
new file mode 100644
index 0000000000..60ac90d762
--- /dev/null
+++ b/features/roomaliasresolver/impl/src/main/kotlin/io/element/android/features/roomaliasresolver/impl/RoomAliasResolverEvents.kt
@@ -0,0 +1,21 @@
+/*
+ * Copyright (c) 2024 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.element.android.features.roomaliasresolver.impl
+
+sealed interface RoomAliasResolverEvents {
+ data object Retry : RoomAliasResolverEvents
+}
diff --git a/features/roomaliasresolver/impl/src/main/kotlin/io/element/android/features/roomaliasresolver/impl/RoomAliasResolverNode.kt b/features/roomaliasresolver/impl/src/main/kotlin/io/element/android/features/roomaliasresolver/impl/RoomAliasResolverNode.kt
new file mode 100644
index 0000000000..a4dbcc40b5
--- /dev/null
+++ b/features/roomaliasresolver/impl/src/main/kotlin/io/element/android/features/roomaliasresolver/impl/RoomAliasResolverNode.kt
@@ -0,0 +1,59 @@
+/*
+ * Copyright (c) 2024 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.element.android.features.roomaliasresolver.impl
+
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import com.bumble.appyx.core.modality.BuildContext
+import com.bumble.appyx.core.node.Node
+import com.bumble.appyx.core.plugin.Plugin
+import com.bumble.appyx.core.plugin.plugins
+import dagger.assisted.Assisted
+import dagger.assisted.AssistedInject
+import io.element.android.anvilannotations.ContributesNode
+import io.element.android.features.roomaliasesolver.api.RoomAliasResolverEntryPoint
+import io.element.android.libraries.architecture.inputs
+import io.element.android.libraries.di.SessionScope
+import io.element.android.libraries.matrix.api.core.RoomId
+
+@ContributesNode(SessionScope::class)
+class RoomAliasResolverNode @AssistedInject constructor(
+ @Assisted buildContext: BuildContext,
+ @Assisted plugins: List,
+ presenterFactory: RoomAliasResolverPresenter.Factory,
+) : Node(buildContext, plugins = plugins) {
+ private val inputs = inputs()
+
+ private val presenter = presenterFactory.create(
+ inputs.roomAlias
+ )
+
+ private fun onAliasResolved(roomId: RoomId) {
+ plugins().forEach { it.onAliasResolved(roomId) }
+ }
+
+ @Composable
+ override fun View(modifier: Modifier) {
+ val state = presenter.present()
+ RoomAliasResolverView(
+ state = state,
+ onAliasResolved = ::onAliasResolved,
+ onBackPressed = ::navigateUp,
+ modifier = modifier
+ )
+ }
+}
diff --git a/features/roomaliasresolver/impl/src/main/kotlin/io/element/android/features/roomaliasresolver/impl/RoomAliasResolverPresenter.kt b/features/roomaliasresolver/impl/src/main/kotlin/io/element/android/features/roomaliasresolver/impl/RoomAliasResolverPresenter.kt
new file mode 100644
index 0000000000..775be7d6ff
--- /dev/null
+++ b/features/roomaliasresolver/impl/src/main/kotlin/io/element/android/features/roomaliasresolver/impl/RoomAliasResolverPresenter.kt
@@ -0,0 +1,72 @@
+/*
+ * Copyright (c) 2024 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.element.android.features.roomaliasresolver.impl
+
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.MutableState
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+import dagger.assisted.Assisted
+import dagger.assisted.AssistedInject
+import io.element.android.libraries.architecture.AsyncData
+import io.element.android.libraries.architecture.Presenter
+import io.element.android.libraries.architecture.runCatchingUpdatingState
+import io.element.android.libraries.matrix.api.MatrixClient
+import io.element.android.libraries.matrix.api.core.RoomAlias
+import io.element.android.libraries.matrix.api.core.RoomId
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.launch
+
+class RoomAliasResolverPresenter @AssistedInject constructor(
+ @Assisted private val roomAlias: RoomAlias,
+ private val matrixClient: MatrixClient,
+) : Presenter {
+ interface Factory {
+ fun create(
+ roomAlias: RoomAlias,
+ ): RoomAliasResolverPresenter
+ }
+
+ @Composable
+ override fun present(): RoomAliasResolverState {
+ val coroutineScope = rememberCoroutineScope()
+ val resolveState: MutableState> = remember { mutableStateOf(AsyncData.Uninitialized) }
+ LaunchedEffect(Unit) {
+ resolveAlias(resolveState)
+ }
+
+ fun handleEvents(event: RoomAliasResolverEvents) {
+ when (event) {
+ RoomAliasResolverEvents.Retry -> coroutineScope.resolveAlias(resolveState)
+ }
+ }
+
+ return RoomAliasResolverState(
+ roomAlias = roomAlias,
+ resolveState = resolveState.value,
+ eventSink = ::handleEvents
+ )
+ }
+
+ private fun CoroutineScope.resolveAlias(resolveState: MutableState>) = launch {
+ suspend {
+ matrixClient.resolveRoomAlias(roomAlias).getOrThrow()
+ }.runCatchingUpdatingState(resolveState)
+ }
+}
diff --git a/features/roomaliasresolver/impl/src/main/kotlin/io/element/android/features/roomaliasresolver/impl/RoomAliasResolverState.kt b/features/roomaliasresolver/impl/src/main/kotlin/io/element/android/features/roomaliasresolver/impl/RoomAliasResolverState.kt
new file mode 100644
index 0000000000..638214da3f
--- /dev/null
+++ b/features/roomaliasresolver/impl/src/main/kotlin/io/element/android/features/roomaliasresolver/impl/RoomAliasResolverState.kt
@@ -0,0 +1,29 @@
+/*
+ * Copyright (c) 2024 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.element.android.features.roomaliasresolver.impl
+
+import androidx.compose.runtime.Immutable
+import io.element.android.libraries.architecture.AsyncData
+import io.element.android.libraries.matrix.api.core.RoomAlias
+import io.element.android.libraries.matrix.api.core.RoomId
+
+@Immutable
+data class RoomAliasResolverState(
+ val roomAlias: RoomAlias,
+ val resolveState: AsyncData,
+ val eventSink: (RoomAliasResolverEvents) -> Unit
+)
diff --git a/features/roomaliasresolver/impl/src/main/kotlin/io/element/android/features/roomaliasresolver/impl/RoomAliasResolverStateProvider.kt b/features/roomaliasresolver/impl/src/main/kotlin/io/element/android/features/roomaliasresolver/impl/RoomAliasResolverStateProvider.kt
new file mode 100644
index 0000000000..3c5599628c
--- /dev/null
+++ b/features/roomaliasresolver/impl/src/main/kotlin/io/element/android/features/roomaliasresolver/impl/RoomAliasResolverStateProvider.kt
@@ -0,0 +1,47 @@
+/*
+ * Copyright (c) 2024 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.element.android.features.roomaliasresolver.impl
+
+import androidx.compose.ui.tooling.preview.PreviewParameterProvider
+import io.element.android.libraries.architecture.AsyncData
+import io.element.android.libraries.matrix.api.core.RoomAlias
+import io.element.android.libraries.matrix.api.core.RoomId
+
+open class RoomAliasResolverStateProvider : PreviewParameterProvider {
+ override val values: Sequence
+ get() = sequenceOf(
+ aRoomAliasResolverState(),
+ aRoomAliasResolverState(
+ resolveState = AsyncData.Loading(),
+ ),
+ aRoomAliasResolverState(
+ resolveState = AsyncData.Failure(Exception("Error")),
+ ),
+ )
+}
+
+fun aRoomAliasResolverState(
+ roomAlias: RoomAlias = A_ROOM_ALIAS,
+ resolveState: AsyncData = AsyncData.Uninitialized,
+ eventSink: (RoomAliasResolverEvents) -> Unit = {}
+) = RoomAliasResolverState(
+ roomAlias = roomAlias,
+ resolveState = resolveState,
+ eventSink = eventSink,
+)
+
+private val A_ROOM_ALIAS = RoomAlias("#exa:matrix.org")
diff --git a/features/roomaliasresolver/impl/src/main/kotlin/io/element/android/features/roomaliasresolver/impl/RoomAliasResolverView.kt b/features/roomaliasresolver/impl/src/main/kotlin/io/element/android/features/roomaliasresolver/impl/RoomAliasResolverView.kt
new file mode 100644
index 0000000000..cd5bd042c8
--- /dev/null
+++ b/features/roomaliasresolver/impl/src/main/kotlin/io/element/android/features/roomaliasresolver/impl/RoomAliasResolverView.kt
@@ -0,0 +1,169 @@
+/*
+ * Copyright (c) 2024 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.element.android.features.roomaliasresolver.impl
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.rememberUpdatedState
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.tooling.preview.PreviewParameter
+import androidx.compose.ui.unit.dp
+import io.element.android.libraries.architecture.AsyncData
+import io.element.android.libraries.designsystem.atomic.atoms.PlaceholderAtom
+import io.element.android.libraries.designsystem.atomic.atoms.RoomPreviewTitleAtom
+import io.element.android.libraries.designsystem.atomic.organisms.RoomPreviewOrganism
+import io.element.android.libraries.designsystem.atomic.pages.HeaderFooterPage
+import io.element.android.libraries.designsystem.background.LightGradientBackground
+import io.element.android.libraries.designsystem.components.avatar.AvatarSize
+import io.element.android.libraries.designsystem.components.button.BackButton
+import io.element.android.libraries.designsystem.preview.ElementPreview
+import io.element.android.libraries.designsystem.preview.PreviewsDayNight
+import io.element.android.libraries.designsystem.theme.components.Button
+import io.element.android.libraries.designsystem.theme.components.ButtonSize
+import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator
+import io.element.android.libraries.designsystem.theme.components.Text
+import io.element.android.libraries.designsystem.theme.components.TopAppBar
+import io.element.android.libraries.matrix.api.core.RoomId
+import io.element.android.libraries.ui.strings.CommonStrings
+
+@Composable
+fun RoomAliasResolverView(
+ state: RoomAliasResolverState,
+ onBackPressed: () -> Unit,
+ onAliasResolved: (RoomId) -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ val latestOnAliasResolved by rememberUpdatedState(onAliasResolved)
+ LaunchedEffect(state.resolveState) {
+ if (state.resolveState is AsyncData.Success) {
+ latestOnAliasResolved(state.resolveState.data)
+ }
+ }
+ Box(
+ modifier = modifier.fillMaxSize(),
+ ) {
+ LightGradientBackground()
+ HeaderFooterPage(
+ containerColor = Color.Transparent,
+ paddingValues = PaddingValues(16.dp),
+ topBar = {
+ RoomAliasResolverTopBar(onBackClicked = onBackPressed)
+ },
+ content = {
+ RoomAliasResolverContent(state = state)
+ },
+ footer = {
+ RoomAliasResolverFooter(
+ state = state,
+ )
+ }
+ )
+ }
+}
+
+@Composable
+private fun RoomAliasResolverFooter(
+ state: RoomAliasResolverState,
+ modifier: Modifier = Modifier,
+) {
+ when (state.resolveState) {
+ is AsyncData.Failure -> {
+ Button(
+ text = stringResource(CommonStrings.action_retry),
+ onClick = {
+ state.eventSink(RoomAliasResolverEvents.Retry)
+ },
+ modifier = modifier.fillMaxWidth(),
+ size = ButtonSize.Large,
+ )
+ }
+ is AsyncData.Loading -> {
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.Center,
+ ) {
+ CircularProgressIndicator()
+ }
+ }
+ AsyncData.Uninitialized,
+ is AsyncData.Success -> Unit
+ }
+}
+
+@Composable
+private fun RoomAliasResolverContent(
+ state: RoomAliasResolverState,
+ modifier: Modifier = Modifier,
+) {
+ RoomPreviewOrganism(
+ modifier = modifier,
+ avatar = {
+ PlaceholderAtom(width = AvatarSize.RoomHeader.dp, height = AvatarSize.RoomHeader.dp)
+ },
+ title = {
+ RoomPreviewTitleAtom(state.roomAlias.value)
+ },
+ subtitle = {
+ },
+ description = {
+ if (state.resolveState.isFailure()) {
+ Text(
+ text = stringResource(id = R.string.screen_room_alias_resolver_resolve_alias_failure),
+ textAlign = TextAlign.Center,
+ color = MaterialTheme.colorScheme.error,
+ )
+ }
+ },
+ memberCount = {
+ }
+ )
+}
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+private fun RoomAliasResolverTopBar(
+ onBackClicked: () -> Unit,
+) {
+ TopAppBar(
+ navigationIcon = {
+ BackButton(onClick = onBackClicked)
+ },
+ title = {},
+ )
+}
+
+@PreviewsDayNight
+@Composable
+internal fun RoomAliasResolverViewPreview(@PreviewParameter(RoomAliasResolverStateProvider::class) state: RoomAliasResolverState) = ElementPreview {
+ RoomAliasResolverView(
+ state = state,
+ onAliasResolved = { },
+ onBackPressed = { }
+ )
+}
diff --git a/features/roomaliasresolver/impl/src/main/kotlin/io/element/android/features/roomaliasresolver/impl/di/RoomAliasResolverModule.kt b/features/roomaliasresolver/impl/src/main/kotlin/io/element/android/features/roomaliasresolver/impl/di/RoomAliasResolverModule.kt
new file mode 100644
index 0000000000..538cc57f32
--- /dev/null
+++ b/features/roomaliasresolver/impl/src/main/kotlin/io/element/android/features/roomaliasresolver/impl/di/RoomAliasResolverModule.kt
@@ -0,0 +1,43 @@
+/*
+ * Copyright (c) 2024 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.element.android.features.roomaliasresolver.impl.di
+
+import com.squareup.anvil.annotations.ContributesTo
+import dagger.Module
+import dagger.Provides
+import io.element.android.features.roomaliasresolver.impl.RoomAliasResolverPresenter
+import io.element.android.libraries.di.SessionScope
+import io.element.android.libraries.matrix.api.MatrixClient
+import io.element.android.libraries.matrix.api.core.RoomAlias
+
+@Module
+@ContributesTo(SessionScope::class)
+object RoomAliasResolverModule {
+ @Provides
+ fun providesJoinRoomPresenterFactory(
+ client: MatrixClient,
+ ): RoomAliasResolverPresenter.Factory {
+ return object : RoomAliasResolverPresenter.Factory {
+ override fun create(roomAlias: RoomAlias): RoomAliasResolverPresenter {
+ return RoomAliasResolverPresenter(
+ roomAlias = roomAlias,
+ matrixClient = client,
+ )
+ }
+ }
+ }
+}
diff --git a/features/roomaliasresolver/impl/src/main/res/values-be/translations.xml b/features/roomaliasresolver/impl/src/main/res/values-be/translations.xml
new file mode 100644
index 0000000000..e9465f639f
--- /dev/null
+++ b/features/roomaliasresolver/impl/src/main/res/values-be/translations.xml
@@ -0,0 +1,4 @@
+
+
+ "Не ўдалося разабрацца з псеўданімам пакоя."
+
diff --git a/features/roomaliasresolver/impl/src/main/res/values-cs/translations.xml b/features/roomaliasresolver/impl/src/main/res/values-cs/translations.xml
new file mode 100644
index 0000000000..234a9f7198
--- /dev/null
+++ b/features/roomaliasresolver/impl/src/main/res/values-cs/translations.xml
@@ -0,0 +1,4 @@
+
+
+ "Nepodařilo se přeložit alias místnosti."
+
diff --git a/features/roomaliasresolver/impl/src/main/res/values-de/translations.xml b/features/roomaliasresolver/impl/src/main/res/values-de/translations.xml
new file mode 100644
index 0000000000..0ed5be402b
--- /dev/null
+++ b/features/roomaliasresolver/impl/src/main/res/values-de/translations.xml
@@ -0,0 +1,4 @@
+
+
+ "Der Raum-Alias konnte nicht ermittelt werden."
+
diff --git a/features/roomaliasresolver/impl/src/main/res/values-fr/translations.xml b/features/roomaliasresolver/impl/src/main/res/values-fr/translations.xml
new file mode 100644
index 0000000000..72395bbb06
--- /dev/null
+++ b/features/roomaliasresolver/impl/src/main/res/values-fr/translations.xml
@@ -0,0 +1,4 @@
+
+
+ "Impossible de trouver un salon avec cet alias."
+
diff --git a/features/roomaliasresolver/impl/src/main/res/values-hu/translations.xml b/features/roomaliasresolver/impl/src/main/res/values-hu/translations.xml
new file mode 100644
index 0000000000..d1d6d05c98
--- /dev/null
+++ b/features/roomaliasresolver/impl/src/main/res/values-hu/translations.xml
@@ -0,0 +1,4 @@
+
+
+ "Nem sikerült a szoba álnevének feloldása."
+
diff --git a/features/roomaliasresolver/impl/src/main/res/values-ru/translations.xml b/features/roomaliasresolver/impl/src/main/res/values-ru/translations.xml
new file mode 100644
index 0000000000..53e8da81ca
--- /dev/null
+++ b/features/roomaliasresolver/impl/src/main/res/values-ru/translations.xml
@@ -0,0 +1,4 @@
+
+
+ "Не удалось определить псевдоним комнаты."
+
diff --git a/features/roomaliasresolver/impl/src/main/res/values-sk/translations.xml b/features/roomaliasresolver/impl/src/main/res/values-sk/translations.xml
new file mode 100644
index 0000000000..059788804f
--- /dev/null
+++ b/features/roomaliasresolver/impl/src/main/res/values-sk/translations.xml
@@ -0,0 +1,4 @@
+
+
+ "Nepodarilo sa nájsť alias miestnosti."
+
diff --git a/features/roomaliasresolver/impl/src/main/res/values/localazy.xml b/features/roomaliasresolver/impl/src/main/res/values/localazy.xml
new file mode 100644
index 0000000000..21d5c17135
--- /dev/null
+++ b/features/roomaliasresolver/impl/src/main/res/values/localazy.xml
@@ -0,0 +1,4 @@
+
+
+ "Failed to resolve room alias."
+
diff --git a/features/roomaliasresolver/impl/src/test/kotlin/io/element/android/features/roomaliasresolver/impl/RoomAliasResolverPresenterTest.kt b/features/roomaliasresolver/impl/src/test/kotlin/io/element/android/features/roomaliasresolver/impl/RoomAliasResolverPresenterTest.kt
new file mode 100644
index 0000000000..2c64690600
--- /dev/null
+++ b/features/roomaliasresolver/impl/src/test/kotlin/io/element/android/features/roomaliasresolver/impl/RoomAliasResolverPresenterTest.kt
@@ -0,0 +1,94 @@
+/*
+ * Copyright (c) 2024 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.element.android.features.roomaliasresolver.impl
+
+import app.cash.molecule.RecompositionMode
+import app.cash.molecule.moleculeFlow
+import app.cash.turbine.test
+import com.google.common.truth.Truth.assertThat
+import io.element.android.libraries.matrix.api.MatrixClient
+import io.element.android.libraries.matrix.api.core.RoomAlias
+import io.element.android.libraries.matrix.test.AN_EXCEPTION
+import io.element.android.libraries.matrix.test.A_ROOM_ALIAS
+import io.element.android.libraries.matrix.test.A_ROOM_ID
+import io.element.android.libraries.matrix.test.FakeMatrixClient
+import io.element.android.tests.testutils.WarmUpRule
+import kotlinx.coroutines.test.runTest
+import org.junit.Rule
+import org.junit.Test
+
+class RoomAliasResolverPresenterTest {
+ @get:Rule
+ val warmUpRule = WarmUpRule()
+
+ @Test
+ fun `present - initial state`() = runTest {
+ val presenter = createPresenter()
+ moleculeFlow(RecompositionMode.Immediate) {
+ presenter.present()
+ }.test {
+ assertThat(awaitItem().resolveState.isUninitialized()).isTrue()
+ cancelAndIgnoreRemainingEvents()
+ }
+ }
+
+ @Test
+ fun `present - resolve alias to roomId`() = runTest {
+ val client = FakeMatrixClient(
+ resolveRoomAliasResult = { Result.success(A_ROOM_ID) }
+ )
+ val presenter = createPresenter(matrixClient = client)
+ moleculeFlow(RecompositionMode.Immediate) {
+ presenter.present()
+ }.test {
+ assertThat(awaitItem().resolveState.isUninitialized()).isTrue()
+ assertThat(awaitItem().resolveState.isLoading()).isTrue()
+ val resultState = awaitItem()
+ assertThat(resultState.roomAlias).isEqualTo(A_ROOM_ALIAS)
+ assertThat(resultState.resolveState.dataOrNull()).isEqualTo(A_ROOM_ID)
+ }
+ }
+
+ @Test
+ fun `present - resolve alias error and retry`() = runTest {
+ val client = FakeMatrixClient(
+ resolveRoomAliasResult = { Result.failure(AN_EXCEPTION) }
+ )
+ val presenter = createPresenter(matrixClient = client)
+ moleculeFlow(RecompositionMode.Immediate) {
+ presenter.present()
+ }.test {
+ assertThat(awaitItem().resolveState.isUninitialized()).isTrue()
+ assertThat(awaitItem().resolveState.isLoading()).isTrue()
+ val resultState = awaitItem()
+ assertThat(resultState.resolveState.errorOrNull()).isEqualTo(AN_EXCEPTION)
+ resultState.eventSink(RoomAliasResolverEvents.Retry)
+ val retryLoadingState = awaitItem()
+ assertThat(retryLoadingState.resolveState.isLoading()).isTrue()
+ val retryState = awaitItem()
+ assertThat(retryState.resolveState.errorOrNull()).isEqualTo(AN_EXCEPTION)
+ }
+ }
+
+ private fun createPresenter(
+ roomAlias: RoomAlias = A_ROOM_ALIAS,
+ matrixClient: MatrixClient = FakeMatrixClient(),
+ ) = RoomAliasResolverPresenter(
+ roomAlias = roomAlias,
+ matrixClient = matrixClient,
+ )
+}
diff --git a/features/roomaliasresolver/impl/src/test/kotlin/io/element/android/features/roomaliasresolver/impl/RoomAliasResolverViewTest.kt b/features/roomaliasresolver/impl/src/test/kotlin/io/element/android/features/roomaliasresolver/impl/RoomAliasResolverViewTest.kt
new file mode 100644
index 0000000000..6df8a7849e
--- /dev/null
+++ b/features/roomaliasresolver/impl/src/test/kotlin/io/element/android/features/roomaliasresolver/impl/RoomAliasResolverViewTest.kt
@@ -0,0 +1,97 @@
+/*
+ * Copyright (c) 2024 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.element.android.features.roomaliasresolver.impl
+
+import androidx.activity.ComponentActivity
+import androidx.compose.ui.test.junit4.AndroidComposeTestRule
+import androidx.compose.ui.test.junit4.createAndroidComposeRule
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import io.element.android.libraries.architecture.AsyncData
+import io.element.android.libraries.matrix.api.core.RoomId
+import io.element.android.libraries.matrix.test.A_ROOM_ID
+import io.element.android.libraries.ui.strings.CommonStrings
+import io.element.android.tests.testutils.EnsureNeverCalled
+import io.element.android.tests.testutils.EnsureNeverCalledWithParam
+import io.element.android.tests.testutils.EventsRecorder
+import io.element.android.tests.testutils.clickOn
+import io.element.android.tests.testutils.ensureCalledOnce
+import io.element.android.tests.testutils.ensureCalledOnceWithParam
+import io.element.android.tests.testutils.pressBack
+import org.junit.Rule
+import org.junit.Test
+import org.junit.rules.TestRule
+import org.junit.runner.RunWith
+
+@RunWith(AndroidJUnit4::class)
+class RoomAliasResolverViewTest {
+ @get:Rule val rule = createAndroidComposeRule()
+
+ @Test
+ fun `clicking on back invokes the expected callback`() {
+ val eventsRecorder = EventsRecorder(expectEvents = false)
+ ensureCalledOnce {
+ rule.setRoomAliasResolverView(
+ aRoomAliasResolverState(
+ eventSink = eventsRecorder,
+ ),
+ onBackPressed = it
+ )
+ rule.pressBack()
+ }
+ }
+
+ @Test
+ fun `clicking on Retry emits the expected Event`() {
+ val eventsRecorder = EventsRecorder()
+ rule.setRoomAliasResolverView(
+ aRoomAliasResolverState(
+ resolveState = AsyncData.Failure(Exception("Error")),
+ eventSink = eventsRecorder,
+ ),
+ )
+ rule.clickOn(CommonStrings.action_retry)
+ eventsRecorder.assertSingle(RoomAliasResolverEvents.Retry)
+ }
+
+ @Test
+ fun `success state invokes the expected Callback`() {
+ val eventsRecorder = EventsRecorder(expectEvents = false)
+ ensureCalledOnceWithParam(A_ROOM_ID) {
+ rule.setRoomAliasResolverView(
+ aRoomAliasResolverState(
+ resolveState = AsyncData.Success(A_ROOM_ID),
+ eventSink = eventsRecorder,
+ ),
+ onAliasResolved = it,
+ )
+ }
+ }
+}
+
+private fun AndroidComposeTestRule.setRoomAliasResolverView(
+ state: RoomAliasResolverState,
+ onBackPressed: () -> Unit = EnsureNeverCalled(),
+ onAliasResolved: (RoomId) -> Unit = EnsureNeverCalledWithParam(),
+) {
+ setContent {
+ RoomAliasResolverView(
+ state = state,
+ onBackPressed = onBackPressed,
+ onAliasResolved = onAliasResolved,
+ )
+ }
+}
diff --git a/features/roomdetails/impl/build.gradle.kts b/features/roomdetails/impl/build.gradle.kts
index ce2aed6e70..037165e4ee 100644
--- a/features/roomdetails/impl/build.gradle.kts
+++ b/features/roomdetails/impl/build.gradle.kts
@@ -56,8 +56,10 @@ dependencies {
api(projects.libraries.usersearch.api)
api(projects.services.apperror.api)
implementation(libs.coil.compose)
- implementation(projects.features.leaveroom.api)
+ implementation(projects.features.call)
implementation(projects.features.createroom.api)
+ implementation(projects.features.leaveroom.api)
+ implementation(projects.features.userprofile.shared)
implementation(projects.services.analytics.api)
implementation(projects.features.poll.api)
diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsFlowNode.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsFlowNode.kt
index db41748747..798a903274 100644
--- a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsFlowNode.kt
+++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsFlowNode.kt
@@ -16,6 +16,7 @@
package io.element.android.features.roomdetails.impl
+import android.content.Context
import android.os.Parcelable
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
@@ -28,23 +29,28 @@ import com.bumble.appyx.navmodel.backstack.operation.push
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import io.element.android.anvilannotations.ContributesNode
+import io.element.android.features.call.CallType
+import io.element.android.features.call.ui.ElementCallActivity
import io.element.android.features.poll.api.history.PollHistoryEntryPoint
import io.element.android.features.roomdetails.api.RoomDetailsEntryPoint
import io.element.android.features.roomdetails.impl.edit.RoomDetailsEditNode
import io.element.android.features.roomdetails.impl.invite.RoomInviteMembersNode
import io.element.android.features.roomdetails.impl.members.RoomMemberListNode
import io.element.android.features.roomdetails.impl.members.details.RoomMemberDetailsNode
-import io.element.android.features.roomdetails.impl.members.details.avatar.AvatarPreviewNode
import io.element.android.features.roomdetails.impl.notificationsettings.RoomNotificationSettingsNode
import io.element.android.features.roomdetails.impl.rolesandpermissions.RolesAndPermissionsFlowNode
+import io.element.android.features.userprofile.shared.UserProfileNodeHelper
+import io.element.android.features.userprofile.shared.avatar.AvatarPreviewNode
import io.element.android.libraries.architecture.BackstackView
import io.element.android.libraries.architecture.BaseFlowNode
import io.element.android.libraries.architecture.createNode
import io.element.android.libraries.core.mimetype.MimeTypes
+import io.element.android.libraries.di.ApplicationContext
import io.element.android.libraries.di.RoomScope
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.media.MediaSource
+import io.element.android.libraries.matrix.api.room.MatrixRoom
import io.element.android.libraries.mediaviewer.api.local.MediaInfo
import io.element.android.libraries.mediaviewer.api.viewer.MediaViewerNode
import kotlinx.parcelize.Parcelize
@@ -53,7 +59,9 @@ import kotlinx.parcelize.Parcelize
class RoomDetailsFlowNode @AssistedInject constructor(
@Assisted buildContext: BuildContext,
@Assisted plugins: List,
+ @ApplicationContext private val context: Context,
private val pollHistoryEntryPoint: PollHistoryEntryPoint,
+ private val room: MatrixRoom,
) : BaseFlowNode(
backstack = BackStack(
initialElement = plugins.filterIsInstance().first().initialElement.toNavTarget(),
@@ -78,7 +86,7 @@ class RoomDetailsFlowNode @AssistedInject constructor(
@Parcelize
data class RoomNotificationSettings(
/**
- * When presented from outsite the context of the room, the rooms settings UI is different.
+ * When presented from outside the context of the room, the rooms settings UI is different.
* Figma designs: https://www.figma.com/file/0MMNu7cTOzLOlWb7ctTkv3/Element-X?type=design&node-id=5199-198932&mode=design&t=fTTvpuxYFjewYQOe-0
*/
val showUserDefinedSettingStyle: Boolean
@@ -128,6 +136,14 @@ class RoomDetailsFlowNode @AssistedInject constructor(
override fun openAdminSettings() {
backstack.push(NavTarget.AdminSettings)
}
+
+ override fun onJoinCall() {
+ val inputs = CallType.RoomCall(
+ sessionId = room.sessionId,
+ roomId = room.roomId,
+ )
+ ElementCallActivity.start(context, inputs)
+ }
}
createNode(buildContext, listOf(roomDetailsCallback))
}
@@ -164,7 +180,7 @@ class RoomDetailsFlowNode @AssistedInject constructor(
}
is NavTarget.RoomMemberDetails -> {
- val callback = object : RoomMemberDetailsNode.Callback {
+ val callback = object : UserProfileNodeHelper.Callback {
override fun openAvatarPreview(username: String, avatarUrl: String) {
backstack.push(NavTarget.AvatarPreview(username, avatarUrl))
}
@@ -172,6 +188,10 @@ class RoomDetailsFlowNode @AssistedInject constructor(
override fun onStartDM(roomId: RoomId) {
plugins().forEach { it.onOpenRoom(roomId) }
}
+
+ override fun onStartCall(roomId: RoomId) {
+ ElementCallActivity.start(context, CallType.RoomCall(roomId = roomId, sessionId = room.sessionId))
+ }
}
val plugins = listOf(RoomMemberDetailsNode.RoomMemberDetailsInput(navTarget.roomMemberId), callback)
createNode(buildContext, plugins)
diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsNode.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsNode.kt
index 84658ccc83..83eb19dc37 100644
--- a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsNode.kt
+++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsNode.kt
@@ -32,9 +32,7 @@ import im.vector.app.features.analytics.plan.MobileScreen
import io.element.android.anvilannotations.ContributesNode
import io.element.android.libraries.androidutils.system.startSharePlainTextIntent
import io.element.android.libraries.di.RoomScope
-import io.element.android.libraries.matrix.api.permalink.PermalinkBuilder
import io.element.android.libraries.matrix.api.room.MatrixRoom
-import io.element.android.libraries.matrix.api.room.RoomMember
import io.element.android.services.analytics.api.AnalyticsService
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
@@ -48,7 +46,6 @@ class RoomDetailsNode @AssistedInject constructor(
private val presenter: RoomDetailsPresenter,
private val room: MatrixRoom,
private val analyticsService: AnalyticsService,
- private val permalinkBuilder: PermalinkBuilder,
) : Node(buildContext, plugins = plugins) {
interface Callback : Plugin {
fun openRoomMemberList()
@@ -58,6 +55,7 @@ class RoomDetailsNode @AssistedInject constructor(
fun openAvatarPreview(name: String, url: String)
fun openPollHistory()
fun openAdminSettings()
+ fun onJoinCall()
}
private val callbacks = plugins()
@@ -86,24 +84,12 @@ class RoomDetailsNode @AssistedInject constructor(
callbacks.forEach { it.openPollHistory() }
}
- private fun CoroutineScope.onShareRoom(context: Context) = launch {
- room.getPermalink()
- .onSuccess { permalink ->
- context.startSharePlainTextIntent(
- activityResultLauncher = null,
- chooserTitle = context.getString(R.string.screen_room_details_share_room_title),
- text = permalink,
- noActivityFoundMessage = context.getString(AndroidUtilsR.string.error_no_compatible_app_found)
- )
- }
- .onFailure {
- Timber.e(it)
- }
+ private fun onJoinCall() {
+ callbacks.forEach { it.onJoinCall() }
}
- private fun onShareMember(context: Context, member: RoomMember) {
- val permalinkResult = permalinkBuilder.permalinkForUser(member.userId)
- permalinkResult
+ private fun CoroutineScope.onShareRoom(context: Context) = launch {
+ room.getPermalink()
.onSuccess { permalink ->
context.startSharePlainTextIntent(
activityResultLauncher = null,
@@ -138,10 +124,6 @@ class RoomDetailsNode @AssistedInject constructor(
lifecycleScope.onShareRoom(context)
}
- fun onShareMember(roomMember: RoomMember) {
- this.onShareMember(context, roomMember)
- }
-
fun onActionClicked(action: RoomDetailsAction) {
when (action) {
RoomDetailsAction.Edit -> onEditRoomDetails()
@@ -155,13 +137,13 @@ class RoomDetailsNode @AssistedInject constructor(
goBack = this::navigateUp,
onActionClicked = ::onActionClicked,
onShareRoom = ::onShareRoom,
- onShareMember = ::onShareMember,
openRoomMemberList = ::openRoomMemberList,
openRoomNotificationSettings = ::openRoomNotificationSettings,
invitePeople = ::invitePeople,
openAvatarPreview = ::openAvatarPreview,
openPollHistory = ::openPollHistory,
openAdminSettings = this::openAdminSettings,
+ onJoinCallClicked = ::onJoinCall,
)
}
}
diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsPresenter.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsPresenter.kt
index f0c96e74e2..966dade2c3 100644
--- a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsPresenter.kt
+++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsPresenter.kt
@@ -44,6 +44,7 @@ import io.element.android.libraries.matrix.api.room.StateEventType
import io.element.android.libraries.matrix.api.room.powerlevels.canInvite
import io.element.android.libraries.matrix.api.room.powerlevels.canSendState
import io.element.android.libraries.matrix.api.room.roomNotificationSettings
+import io.element.android.libraries.matrix.ui.room.canCall
import io.element.android.libraries.matrix.ui.room.getDirectRoomMember
import io.element.android.libraries.matrix.ui.room.isOwnUserAdmin
import io.element.android.services.analytics.api.AnalyticsService
@@ -74,9 +75,10 @@ class RoomDetailsPresenter @Inject constructor(
val roomAvatar by remember { derivedStateOf { roomInfo?.avatarUrl ?: room.avatarUrl } }
- val roomName by remember { derivedStateOf { (roomInfo?.name ?: room.name ?: room.displayName).trim() } }
+ val roomName by remember { derivedStateOf { (roomInfo?.name ?: room.displayName).trim() } }
val roomTopic by remember { derivedStateOf { roomInfo?.topic ?: room.topic } }
val isFavorite by remember { derivedStateOf { roomInfo?.isFavorite.orFalse() } }
+ val isPublic by remember { derivedStateOf { roomInfo?.isPublic.orFalse() } }
LaunchedEffect(Unit) {
canShowNotificationSettings.value = featureFlagService.isFeatureEnabled(FeatureFlags.NotificationSettings)
@@ -86,11 +88,14 @@ class RoomDetailsPresenter @Inject constructor(
}
}
+ val syncUpdateTimestamp by room.syncUpdateFlow.collectAsState()
+
val membersState by room.membersStateFlow.collectAsState()
val canInvite by getCanInvite(membersState)
val canEditName by getCanSendState(membersState, StateEventType.ROOM_NAME)
val canEditAvatar by getCanSendState(membersState, StateEventType.ROOM_AVATAR)
val canEditTopic by getCanSendState(membersState, StateEventType.ROOM_TOPIC)
+ val canJoinCall by room.canCall(updateKey = syncUpdateTimestamp)
val dmMember by room.getDirectRoomMember(membersState)
val roomMemberDetailsPresenter = roomMemberDetailsPresenter(dmMember)
val roomType by getRoomType(dmMember)
@@ -128,7 +133,7 @@ class RoomDetailsPresenter @Inject constructor(
val roomMemberDetailsState = roomMemberDetailsPresenter?.present()
return RoomDetailsState(
- roomId = room.roomId.value,
+ roomId = room.roomId,
roomName = roomName,
roomAlias = room.alias,
roomAvatarUrl = roomAvatar,
@@ -138,12 +143,14 @@ class RoomDetailsPresenter @Inject constructor(
canInvite = canInvite,
canEdit = (canEditAvatar || canEditName || canEditTopic) && roomType == RoomDetailsType.Room,
canShowNotificationSettings = canShowNotificationSettings.value,
+ canCall = canJoinCall,
roomType = roomType,
roomMemberDetailsState = roomMemberDetailsState,
leaveRoomState = leaveRoomState,
roomNotificationSettings = roomNotificationSettingsState.roomNotificationSettings(),
isFavorite = isFavorite,
displayRolesAndPermissionsSettings = !room.isDm && isUserAdmin,
+ isPublic = isPublic,
eventSink = ::handleEvents,
)
}
diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsState.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsState.kt
index f5abc5bcce..43605eb7b6 100644
--- a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsState.kt
+++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsState.kt
@@ -17,27 +17,31 @@
package io.element.android.features.roomdetails.impl
import io.element.android.features.leaveroom.api.LeaveRoomState
-import io.element.android.features.roomdetails.impl.members.details.RoomMemberDetailsState
+import io.element.android.features.userprofile.shared.UserProfileState
+import io.element.android.libraries.matrix.api.core.RoomAlias
+import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.room.RoomMember
import io.element.android.libraries.matrix.api.room.RoomNotificationSettings
data class RoomDetailsState(
- val roomId: String,
+ val roomId: RoomId,
val roomName: String,
- val roomAlias: String?,
+ val roomAlias: RoomAlias?,
val roomAvatarUrl: String?,
val roomTopic: RoomTopicState,
val memberCount: Long,
val isEncrypted: Boolean,
val roomType: RoomDetailsType,
- val roomMemberDetailsState: RoomMemberDetailsState?,
+ val roomMemberDetailsState: UserProfileState?,
val canEdit: Boolean,
val canInvite: Boolean,
val canShowNotificationSettings: Boolean,
+ val canCall: Boolean,
val leaveRoomState: LeaveRoomState,
val roomNotificationSettings: RoomNotificationSettings?,
val isFavorite: Boolean,
val displayRolesAndPermissionsSettings: Boolean,
+ val isPublic: Boolean,
val eventSink: (RoomDetailsEvent) -> Unit
)
diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsStateProvider.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsStateProvider.kt
index 06b7ea7be7..f970b80d82 100644
--- a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsStateProvider.kt
+++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsStateProvider.kt
@@ -19,8 +19,10 @@ package io.element.android.features.roomdetails.impl
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.features.leaveroom.api.LeaveRoomState
import io.element.android.features.leaveroom.api.aLeaveRoomState
-import io.element.android.features.roomdetails.impl.members.details.RoomMemberDetailsState
-import io.element.android.features.roomdetails.impl.members.details.aRoomMemberDetailsState
+import io.element.android.features.userprofile.shared.UserProfileState
+import io.element.android.features.userprofile.shared.aUserProfileState
+import io.element.android.libraries.matrix.api.core.RoomAlias
+import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.room.RoomMember
import io.element.android.libraries.matrix.api.room.RoomMembershipState
@@ -44,6 +46,8 @@ open class RoomDetailsStateProvider : PreviewParameterProvider
// Also test the roomNotificationSettings ALL_MESSAGES in the same screenshot. Icon 'Mute' should be displayed
roomNotificationSettings = aRoomNotificationSettings(mode = RoomNotificationMode.ALL_MESSAGES, isDefault = true)
),
+ aRoomDetailsState(canCall = false, canInvite = false),
+ aRoomDetailsState(isPublic = false),
// Add other state here
)
}
@@ -71,9 +75,9 @@ fun aDmRoomMember(
)
fun aRoomDetailsState(
- roomId: String = "a room id",
+ roomId: RoomId = RoomId("!aRoomId:domain.com"),
roomName: String = "Marketing",
- roomAlias: String? = "#marketing:domain.com",
+ roomAlias: RoomAlias? = RoomAlias("#marketing:domain.com"),
roomAvatarUrl: String? = null,
roomTopic: RoomTopicState = RoomTopicState.ExistingTopic(
"Welcome to #marketing, home of the Marketing team " +
@@ -87,12 +91,14 @@ fun aRoomDetailsState(
canInvite: Boolean = false,
canEdit: Boolean = false,
canShowNotificationSettings: Boolean = true,
+ canCall: Boolean = true,
roomType: RoomDetailsType = RoomDetailsType.Room,
- roomMemberDetailsState: RoomMemberDetailsState? = null,
+ roomMemberDetailsState: UserProfileState? = null,
leaveRoomState: LeaveRoomState = aLeaveRoomState(),
roomNotificationSettings: RoomNotificationSettings = aRoomNotificationSettings(),
isFavorite: Boolean = false,
displayAdminSettings: Boolean = false,
+ isPublic: Boolean = true,
eventSink: (RoomDetailsEvent) -> Unit = {},
) = RoomDetailsState(
roomId = roomId,
@@ -105,12 +111,14 @@ fun aRoomDetailsState(
canInvite = canInvite,
canEdit = canEdit,
canShowNotificationSettings = canShowNotificationSettings,
+ canCall = canCall,
roomType = roomType,
roomMemberDetailsState = roomMemberDetailsState,
leaveRoomState = leaveRoomState,
roomNotificationSettings = roomNotificationSettings,
isFavorite = isFavorite,
displayRolesAndPermissionsSettings = displayAdminSettings,
+ isPublic = isPublic,
eventSink = eventSink
)
@@ -128,5 +136,5 @@ fun aDmRoomDetailsState(
) = aRoomDetailsState(
roomName = roomName,
roomType = RoomDetailsType.Dm(aDmRoomMember(isIgnored = isDmMemberIgnored)),
- roomMemberDetailsState = aRoomMemberDetailsState()
+ roomMemberDetailsState = aUserProfileState()
)
diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsView.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsView.kt
index ff4ece7bde..76807c07e6 100644
--- a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsView.kt
+++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsView.kt
@@ -27,7 +27,6 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
-import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
@@ -49,10 +48,10 @@ import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme
import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.features.leaveroom.api.LeaveRoomView
-import io.element.android.features.roomdetails.impl.blockuser.BlockUserDialogs
-import io.element.android.features.roomdetails.impl.blockuser.BlockUserSection
-import io.element.android.features.roomdetails.impl.members.details.RoomMemberHeaderSection
-import io.element.android.features.roomdetails.impl.members.details.RoomMemberMainActionsSection
+import io.element.android.features.roomdetails.impl.components.RoomBadge
+import io.element.android.features.userprofile.shared.UserProfileHeaderSection
+import io.element.android.features.userprofile.shared.blockuser.BlockUserDialogs
+import io.element.android.features.userprofile.shared.blockuser.BlockUserSection
import io.element.android.libraries.architecture.coverage.ExcludeFromCoverage
import io.element.android.libraries.designsystem.components.ClickableLinkText
import io.element.android.libraries.designsystem.components.avatar.Avatar
@@ -78,7 +77,8 @@ import io.element.android.libraries.designsystem.theme.components.Scaffold
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.theme.components.TopAppBar
import io.element.android.libraries.designsystem.utils.CommonDrawables
-import io.element.android.libraries.matrix.api.room.RoomMember
+import io.element.android.libraries.matrix.api.core.RoomAlias
+import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.room.RoomNotificationMode
import io.element.android.libraries.matrix.api.room.getBestName
import io.element.android.libraries.testtags.TestTags
@@ -91,19 +91,15 @@ fun RoomDetailsView(
goBack: () -> Unit,
onActionClicked: (RoomDetailsAction) -> Unit,
onShareRoom: () -> Unit,
- onShareMember: (RoomMember) -> Unit,
openRoomMemberList: () -> Unit,
openRoomNotificationSettings: () -> Unit,
invitePeople: () -> Unit,
openAvatarPreview: (name: String, url: String) -> Unit,
openPollHistory: () -> Unit,
openAdminSettings: () -> Unit,
+ onJoinCallClicked: () -> Unit,
modifier: Modifier = Modifier,
) {
- fun onShareMember() {
- onShareMember((state.roomType as RoomDetailsType.Dm).roomMember)
- }
-
Scaffold(
modifier = modifier,
topBar = {
@@ -129,30 +125,39 @@ fun RoomDetailsView(
roomId = state.roomId,
roomName = state.roomName,
roomAlias = state.roomAlias,
+ isEncrypted = state.isEncrypted,
+ isPublic = state.isPublic,
openAvatarPreview = { avatarUrl ->
openAvatarPreview(state.roomName, avatarUrl)
},
)
MainActionsSection(
state = state,
- onShareRoom = onShareRoom
+ onShareRoom = onShareRoom,
+ onInvitePeople = invitePeople,
+ onCall = onJoinCallClicked,
)
}
is RoomDetailsType.Dm -> {
val member = state.roomType.roomMember
- RoomMemberHeaderSection(
+ UserProfileHeaderSection(
avatarUrl = state.roomAvatarUrl ?: member.avatarUrl,
- userId = member.userId.value,
+ userId = member.userId,
userName = state.roomName,
openAvatarPreview = { avatarUrl ->
openAvatarPreview(member.getBestName(), avatarUrl)
},
)
- RoomMemberMainActionsSection(onShareUser = ::onShareMember)
+ MainActionsSection(
+ state = state,
+ onShareRoom = onShareRoom,
+ onInvitePeople = invitePeople,
+ onCall = onJoinCallClicked,
+ )
}
}
- Spacer(Modifier.height(18.dp))
+ Spacer(Modifier.height(12.dp))
if (state.roomTopic !is RoomTopicState.Hidden) {
TopicSection(
@@ -186,20 +191,12 @@ fun RoomDetailsView(
}
val displayMemberListItem = state.roomType is RoomDetailsType.Room
- val displayInviteMembersItem = state.canInvite
- if (displayMemberListItem || displayInviteMembersItem) {
+ if (displayMemberListItem) {
PreferenceCategory {
- if (displayMemberListItem) {
- MembersItem(
- memberCount = state.memberCount,
- openRoomMemberList = openRoomMemberList,
- )
- }
- if (displayInviteMembersItem) {
- InviteItem(
- invitePeople = invitePeople
- )
- }
+ MembersItem(
+ memberCount = state.memberCount,
+ openRoomMemberList = openRoomMemberList,
+ )
}
}
@@ -265,10 +262,14 @@ private fun RoomDetailsTopBar(
private fun MainActionsSection(
state: RoomDetailsState,
onShareRoom: () -> Unit,
+ onInvitePeople: () -> Unit,
+ onCall: () -> Unit,
) {
Row(
- modifier = Modifier.fillMaxWidth(),
- horizontalArrangement = Arrangement.Center,
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 16.dp),
+ horizontalArrangement = Arrangement.SpaceEvenly,
) {
val roomNotificationSettings = state.roomNotificationSettings
if (state.canShowNotificationSettings && roomNotificationSettings != null) {
@@ -290,21 +291,39 @@ private fun MainActionsSection(
)
}
}
- Spacer(modifier = Modifier.width(20.dp))
- MainActionButton(
- title = stringResource(R.string.screen_room_details_share_room_title),
- imageVector = CompoundIcons.ShareAndroid(),
- onClick = onShareRoom
- )
+ if (state.canCall) {
+ MainActionButton(
+ title = stringResource(CommonStrings.action_call),
+ imageVector = CompoundIcons.VideoCall(),
+ onClick = onCall,
+ )
+ }
+ if (state.roomType is RoomDetailsType.Room) {
+ if (state.canInvite) {
+ MainActionButton(
+ title = stringResource(CommonStrings.action_invite),
+ imageVector = CompoundIcons.UserAdd(),
+ onClick = onInvitePeople,
+ )
+ }
+ // Share CTA should be hidden for DMs
+ MainActionButton(
+ title = stringResource(CommonStrings.action_share),
+ imageVector = CompoundIcons.ShareAndroid(),
+ onClick = onShareRoom
+ )
+ }
}
}
@Composable
private fun RoomHeaderSection(
avatarUrl: String?,
- roomId: String,
+ roomId: RoomId,
roomName: String,
- roomAlias: String?,
+ roomAlias: RoomAlias?,
+ isEncrypted: Boolean,
+ isPublic: Boolean,
openAvatarPreview: (url: String) -> Unit,
) {
Column(
@@ -314,7 +333,7 @@ private fun RoomHeaderSection(
horizontalAlignment = Alignment.CenterHorizontally,
) {
Avatar(
- avatarData = AvatarData(roomId, roomName, avatarUrl, AvatarSize.RoomHeader),
+ avatarData = AvatarData(roomId.value, roomName, avatarUrl, AvatarSize.RoomHeader),
modifier = Modifier
.size(70.dp)
.clickable(enabled = avatarUrl != null) { openAvatarPreview(avatarUrl!!) }
@@ -329,16 +348,52 @@ private fun RoomHeaderSection(
if (roomAlias != null) {
Spacer(modifier = Modifier.height(6.dp))
Text(
- text = roomAlias,
+ text = roomAlias.value,
style = ElementTheme.typography.fontBodyLgRegular,
color = MaterialTheme.colorScheme.secondary,
textAlign = TextAlign.Center,
)
}
+ BadgeList(isEncrypted = isEncrypted, isPublic = isPublic)
Spacer(Modifier.height(32.dp))
}
}
+@Composable
+private fun BadgeList(
+ isEncrypted: Boolean,
+ isPublic: Boolean,
+) {
+ if (isEncrypted || isPublic) {
+ Spacer(modifier = Modifier.height(8.dp))
+ Row(
+ modifier = Modifier.padding(horizontal = 16.dp),
+ horizontalArrangement = Arrangement.spacedBy(8.dp),
+ ) {
+ if (isEncrypted) {
+ RoomBadge.View(
+ text = stringResource(R.string.screen_room_details_badge_encrypted),
+ icon = CompoundIcons.LockSolid(),
+ type = RoomBadge.Type.Positive,
+ )
+ } else {
+ RoomBadge.View(
+ text = stringResource(R.string.screen_room_details_badge_not_encrypted),
+ icon = CompoundIcons.LockOff(),
+ type = RoomBadge.Type.Neutral,
+ )
+ }
+ if (isPublic) {
+ RoomBadge.View(
+ text = stringResource(R.string.screen_room_details_badge_public),
+ icon = CompoundIcons.Public(),
+ type = RoomBadge.Type.Neutral,
+ )
+ }
+ }
+ }
+}
+
@Composable
private fun TopicSection(
roomTopic: RoomTopicState,
@@ -408,17 +463,6 @@ private fun MembersItem(
)
}
-@Composable
-private fun InviteItem(
- invitePeople: () -> Unit,
-) {
- ListItem(
- headlineContent = { Text(stringResource(R.string.screen_room_details_invite_people_title)) },
- leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.UserAdd())),
- onClick = invitePeople,
- )
-}
-
@Composable
private fun PollsSection(
openPollHistory: () -> Unit,
@@ -482,12 +526,12 @@ private fun ContentToPreview(state: RoomDetailsState) {
goBack = {},
onActionClicked = {},
onShareRoom = {},
- onShareMember = {},
openRoomMemberList = {},
openRoomNotificationSettings = {},
invitePeople = {},
openAvatarPreview = { _, _ -> },
openPollHistory = {},
openAdminSettings = {},
+ onJoinCallClicked = {},
)
}
diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/blockuser/BlockUserDialogs.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/blockuser/BlockUserDialogs.kt
deleted file mode 100644
index be25a6228f..0000000000
--- a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/blockuser/BlockUserDialogs.kt
+++ /dev/null
@@ -1,83 +0,0 @@
-/*
- * Copyright (c) 2023 New Vector Ltd
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-package io.element.android.features.roomdetails.impl.blockuser
-
-import androidx.compose.runtime.Composable
-import androidx.compose.ui.res.stringResource
-import io.element.android.features.roomdetails.impl.R
-import io.element.android.features.roomdetails.impl.members.details.RoomMemberDetailsEvents
-import io.element.android.features.roomdetails.impl.members.details.RoomMemberDetailsState
-import io.element.android.libraries.designsystem.components.dialogs.ConfirmationDialog
-
-@Composable
-fun BlockUserDialogs(state: RoomMemberDetailsState) {
- when (state.displayConfirmationDialog) {
- null -> Unit
- RoomMemberDetailsState.ConfirmationDialog.Block -> {
- BlockConfirmationDialog(
- onBlockAction = {
- state.eventSink(
- RoomMemberDetailsEvents.BlockUser(
- needsConfirmation = false
- )
- )
- },
- onDismiss = { state.eventSink(RoomMemberDetailsEvents.ClearConfirmationDialog) }
- )
- }
- RoomMemberDetailsState.ConfirmationDialog.Unblock -> {
- UnblockConfirmationDialog(
- onUnblockAction = {
- state.eventSink(
- RoomMemberDetailsEvents.UnblockUser(
- needsConfirmation = false
- )
- )
- },
- onDismiss = { state.eventSink(RoomMemberDetailsEvents.ClearConfirmationDialog) }
- )
- }
- }
-}
-
-@Composable
-private fun BlockConfirmationDialog(
- onBlockAction: () -> Unit,
- onDismiss: () -> Unit,
-) {
- ConfirmationDialog(
- title = stringResource(R.string.screen_dm_details_block_user),
- content = stringResource(R.string.screen_dm_details_block_alert_description),
- submitText = stringResource(R.string.screen_dm_details_block_alert_action),
- onSubmitClicked = onBlockAction,
- onDismiss = onDismiss
- )
-}
-
-@Composable
-private fun UnblockConfirmationDialog(
- onUnblockAction: () -> Unit,
- onDismiss: () -> Unit,
-) {
- ConfirmationDialog(
- title = stringResource(R.string.screen_dm_details_unblock_user),
- content = stringResource(R.string.screen_dm_details_unblock_alert_description),
- submitText = stringResource(R.string.screen_dm_details_unblock_alert_action),
- onSubmitClicked = onUnblockAction,
- onDismiss = onDismiss
- )
-}
diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/components/RoomBadge.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/components/RoomBadge.kt
new file mode 100644
index 0000000000..5a1b4eb978
--- /dev/null
+++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/components/RoomBadge.kt
@@ -0,0 +1,104 @@
+/*
+ * Copyright (c) 2024 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.element.android.features.roomdetails.impl.components
+
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.graphics.vector.ImageVector
+import io.element.android.compound.theme.ElementTheme
+import io.element.android.compound.tokens.generated.CompoundIcons
+import io.element.android.libraries.designsystem.components.Badge
+import io.element.android.libraries.designsystem.preview.ElementPreview
+import io.element.android.libraries.designsystem.preview.PreviewsDayNight
+import io.element.android.libraries.designsystem.theme.badgeNegativeBackgroundColor
+import io.element.android.libraries.designsystem.theme.badgeNegativeContentColor
+import io.element.android.libraries.designsystem.theme.badgeNeutralBackgroundColor
+import io.element.android.libraries.designsystem.theme.badgeNeutralContentColor
+import io.element.android.libraries.designsystem.theme.badgePositiveBackgroundColor
+import io.element.android.libraries.designsystem.theme.badgePositiveContentColor
+
+object RoomBadge {
+ enum class Type {
+ Positive,
+ Neutral,
+ Negative
+ }
+
+ @Composable fun View(
+ text: String,
+ icon: ImageVector,
+ type: Type,
+ ) {
+ val backgroundColor = when (type) {
+ Type.Positive -> ElementTheme.colors.badgePositiveBackgroundColor
+ Type.Neutral -> ElementTheme.colors.badgeNeutralBackgroundColor
+ Type.Negative -> ElementTheme.colors.badgeNegativeBackgroundColor
+ }
+ val textColor = when (type) {
+ Type.Positive -> ElementTheme.colors.badgePositiveContentColor
+ Type.Neutral -> ElementTheme.colors.badgeNeutralContentColor
+ Type.Negative -> ElementTheme.colors.badgeNegativeContentColor
+ }
+ val iconColor = when (type) {
+ Type.Positive -> ElementTheme.colors.iconSuccessPrimary
+ Type.Neutral -> ElementTheme.colors.iconSecondary
+ Type.Negative -> ElementTheme.colors.iconCriticalPrimary
+ }
+ Badge(
+ text = text,
+ icon = icon,
+ backgroundColor = backgroundColor,
+ iconColor = iconColor,
+ textColor = textColor,
+ )
+ }
+}
+
+@PreviewsDayNight
+@Composable
+internal fun RoomBadgePositivePreview() {
+ ElementPreview {
+ RoomBadge.View(
+ text = "Trusted",
+ icon = CompoundIcons.Verified(),
+ type = RoomBadge.Type.Positive,
+ )
+ }
+}
+
+@PreviewsDayNight
+@Composable
+internal fun RoomBadgeNeutralPreview() {
+ ElementPreview {
+ RoomBadge.View(
+ text = "Public room",
+ icon = CompoundIcons.Public(),
+ type = RoomBadge.Type.Neutral,
+ )
+ }
+}
+
+@PreviewsDayNight
+@Composable
+internal fun RoomBadgeNegativePreview() {
+ ElementPreview {
+ RoomBadge.View(
+ text = "Not trusted",
+ icon = CompoundIcons.Error(),
+ type = RoomBadge.Type.Negative,
+ )
+ }
+}
diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/edit/RoomDetailsEditPresenter.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/edit/RoomDetailsEditPresenter.kt
index 32f36a891b..6213f2cf1a 100644
--- a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/edit/RoomDetailsEditPresenter.kt
+++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/edit/RoomDetailsEditPresenter.kt
@@ -65,7 +65,7 @@ class RoomDetailsEditPresenter @Inject constructor(
// just erase the local value when the room field has changed
var roomAvatarUri by rememberSaveable(room.avatarUrl) { mutableStateOf(room.avatarUrl?.toUri()) }
- var roomName by rememberSaveable { mutableStateOf((room.name ?: room.displayName).trim()) }
+ var roomName by rememberSaveable { mutableStateOf(room.displayName.trim()) }
var roomTopic by rememberSaveable { mutableStateOf(room.topic?.trim()) }
val saveButtonEnabled by remember(
@@ -76,7 +76,7 @@ class RoomDetailsEditPresenter @Inject constructor(
) {
derivedStateOf {
roomAvatarUri?.toString()?.trim() != room.avatarUrl?.toUri()?.toString()?.trim() ||
- roomName.trim() != (room.name ?: room.displayName).trim() ||
+ roomName.trim() != room.displayName.trim() ||
roomTopic.orEmpty().trim() != room.topic.orEmpty().trim()
}
}
@@ -168,7 +168,7 @@ class RoomDetailsEditPresenter @Inject constructor(
Timber.e(it, "Failed to set room topic")
})
}
- if (name.isNotEmpty() && name.trim() != room.name.orEmpty().trim()) {
+ if (name.isNotEmpty() && name.trim() != room.displayName.trim()) {
results.add(room.setName(name).onFailure {
Timber.e(it, "Failed to set room name")
})
diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/invite/RoomInviteMembersView.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/invite/RoomInviteMembersView.kt
index 39c2118580..f6e8f07cce 100644
--- a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/invite/RoomInviteMembersView.kt
+++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/invite/RoomInviteMembersView.kt
@@ -225,7 +225,7 @@ private fun RoomInviteMembersSearchBar(
@PreviewsDayNight
@Composable
-internal fun RoomInviteMembersPreview(@PreviewParameter(RoomInviteMembersStateProvider::class) state: RoomInviteMembersState) = ElementPreview {
+internal fun RoomInviteMembersViewPreview(@PreviewParameter(RoomInviteMembersStateProvider::class) state: RoomInviteMembersState) = ElementPreview {
RoomInviteMembersView(
state = state,
onBackPressed = {},
diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/RoomMemberListView.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/RoomMemberListView.kt
index 029d541039..c111245ee0 100644
--- a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/RoomMemberListView.kt
+++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/RoomMemberListView.kt
@@ -357,7 +357,7 @@ private fun RoomMemberSearchBar(
@PreviewsDayNight
@Composable
-internal fun RoomMemberListPreview(@PreviewParameter(RoomMemberListStateProvider::class) state: RoomMemberListState) = ElementPreview {
+internal fun RoomMemberListViewPreview(@PreviewParameter(RoomMemberListStateProvider::class) state: RoomMemberListState) = ElementPreview {
RoomMemberListView(
state = state,
navigator = object : RoomMemberListNavigator {},
@@ -366,7 +366,7 @@ internal fun RoomMemberListPreview(@PreviewParameter(RoomMemberListStateProvider
@PreviewsDayNight
@Composable
-internal fun RoomMemberBannedListPreview(@PreviewParameter(RoomMemberListStateBannedProvider::class) state: RoomMemberListState) = ElementPreview {
+internal fun RoomMemberListViewBannedPreview(@PreviewParameter(RoomMemberListStateBannedProvider::class) state: RoomMemberListState) = ElementPreview {
RoomMemberListView(
initialSelectedSectionIndex = 1,
state = state,
diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/details/RoomMemberDetailsEvents.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/details/RoomMemberDetailsEvents.kt
deleted file mode 100644
index 75c66f26e2..0000000000
--- a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/details/RoomMemberDetailsEvents.kt
+++ /dev/null
@@ -1,26 +0,0 @@
-/*
- * Copyright (c) 2023 New Vector Ltd
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-package io.element.android.features.roomdetails.impl.members.details
-
-sealed interface RoomMemberDetailsEvents {
- data object StartDM : RoomMemberDetailsEvents
- data object ClearStartDMState : RoomMemberDetailsEvents
- data class BlockUser(val needsConfirmation: Boolean = false) : RoomMemberDetailsEvents
- data class UnblockUser(val needsConfirmation: Boolean = false) : RoomMemberDetailsEvents
- data object ClearBlockUserError : RoomMemberDetailsEvents
- data object ClearConfirmationDialog : RoomMemberDetailsEvents
-}
diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/details/RoomMemberDetailsNode.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/details/RoomMemberDetailsNode.kt
index 71cd975e18..caccbc97be 100644
--- a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/details/RoomMemberDetailsNode.kt
+++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/details/RoomMemberDetailsNode.kt
@@ -28,8 +28,8 @@ import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import im.vector.app.features.analytics.plan.MobileScreen
import io.element.android.anvilannotations.ContributesNode
-import io.element.android.features.roomdetails.impl.R
-import io.element.android.libraries.androidutils.system.startSharePlainTextIntent
+import io.element.android.features.userprofile.shared.UserProfileNodeHelper
+import io.element.android.features.userprofile.shared.UserProfileView
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.architecture.NodeInputs
import io.element.android.libraries.architecture.inputs
@@ -38,8 +38,6 @@ import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.permalink.PermalinkBuilder
import io.element.android.services.analytics.api.AnalyticsService
-import timber.log.Timber
-import io.element.android.libraries.androidutils.R as AndroidUtilsR
@ContributesNode(RoomScope::class)
class RoomMemberDetailsNode @AssistedInject constructor(
@@ -49,18 +47,14 @@ class RoomMemberDetailsNode @AssistedInject constructor(
private val permalinkBuilder: PermalinkBuilder,
presenterFactory: RoomMemberDetailsPresenter.Factory,
) : Node(buildContext, plugins = plugins) {
- interface Callback : NodeInputs {
- fun openAvatarPreview(username: String, avatarUrl: String)
- fun onStartDM(roomId: RoomId)
- }
-
data class RoomMemberDetailsInput(
val roomMemberId: UserId
) : NodeInputs
private val inputs = inputs()
- private val callback = inputs()
+ private val callback = inputs()
private val presenter = presenterFactory.create(inputs.roomMemberId)
+ private val userProfileNodeHelper = UserProfileNodeHelper(inputs.roomMemberId)
init {
lifecycle.subscribe(
@@ -75,36 +69,32 @@ class RoomMemberDetailsNode @AssistedInject constructor(
val context = LocalContext.current
fun onShareUser() {
- val permalinkResult = permalinkBuilder.permalinkForUser(inputs.roomMemberId)
- permalinkResult.onSuccess { permalink ->
- context.startSharePlainTextIntent(
- activityResultLauncher = null,
- chooserTitle = context.getString(R.string.screen_room_details_share_room_title),
- text = permalink,
- noActivityFoundMessage = context.getString(AndroidUtilsR.string.error_no_compatible_app_found)
- )
- }.onFailure {
- Timber.e(it)
- }
+ userProfileNodeHelper.onShareUser(context, permalinkBuilder)
}
fun onStartDM(roomId: RoomId) {
callback.onStartDM(roomId)
}
+ fun onStartCall(roomId: RoomId) {
+ callback.onStartCall(roomId)
+ }
+
val state = presenter.present()
LaunchedEffect(state.startDmActionState) {
- if (state.startDmActionState is AsyncAction.Success) {
- onStartDM(state.startDmActionState.data)
+ val result = state.startDmActionState
+ if (result is AsyncAction.Success) {
+ onStartDM(result.data)
}
}
- RoomMemberDetailsView(
+ UserProfileView(
state = state,
modifier = modifier,
goBack = this::navigateUp,
onShareUser = ::onShareUser,
- onDMStarted = ::onStartDM,
+ onDmStarted = ::onStartDM,
+ onStartCall = ::onStartCall,
openAvatarPreview = callback::openAvatarPreview,
)
}
diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/details/RoomMemberDetailsPresenter.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/details/RoomMemberDetailsPresenter.kt
index cb749d2c80..78dcea4c6d 100644
--- a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/details/RoomMemberDetailsPresenter.kt
+++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/details/RoomMemberDetailsPresenter.kt
@@ -28,7 +28,10 @@ import androidx.compose.runtime.setValue
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import io.element.android.features.createroom.api.StartDMAction
-import io.element.android.features.roomdetails.impl.members.details.RoomMemberDetailsState.ConfirmationDialog
+import io.element.android.features.userprofile.shared.UserProfileEvents
+import io.element.android.features.userprofile.shared.UserProfilePresenterHelper
+import io.element.android.features.userprofile.shared.UserProfileState
+import io.element.android.features.userprofile.shared.UserProfileState.ConfirmationDialog
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.architecture.Presenter
@@ -37,8 +40,12 @@ import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.room.MatrixRoom
+import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.matrix.ui.room.getRoomMemberAsState
-import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.flow.distinctUntilChanged
+import kotlinx.coroutines.flow.launchIn
+import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
class RoomMemberDetailsPresenter @AssistedInject constructor(
@@ -46,114 +53,118 @@ class RoomMemberDetailsPresenter @AssistedInject constructor(
private val client: MatrixClient,
private val room: MatrixRoom,
private val startDMAction: StartDMAction,
-) : Presenter {
+) : Presenter {
interface Factory {
fun create(roomMemberId: UserId): RoomMemberDetailsPresenter
}
+ private val userProfilePresenterHelper = UserProfilePresenterHelper(
+ userId = roomMemberId,
+ client = client,
+ )
+
@Composable
- override fun present(): RoomMemberDetailsState {
+ override fun present(): UserProfileState {
val coroutineScope = rememberCoroutineScope()
var confirmationDialog by remember { mutableStateOf(null) }
val roomMember by room.getRoomMemberAsState(roomMemberId)
+ var userProfile by remember { mutableStateOf(null) }
val startDmActionState: MutableState> = remember { mutableStateOf(AsyncAction.Uninitialized) }
- // the room member is not really live...
- val isBlocked: MutableState> = remember(roomMember) {
- val isIgnored = roomMember?.isIgnored
- if (isIgnored == null) {
- mutableStateOf(AsyncData.Uninitialized)
- } else {
- mutableStateOf(AsyncData.Success(isIgnored))
- }
+ val isBlocked: MutableState> = remember { mutableStateOf(AsyncData.Uninitialized) }
+ val isCurrentUser = remember { client.isMe(roomMemberId) }
+ val dmRoomId by userProfilePresenterHelper.getDmRoomId()
+ val canCall by userProfilePresenterHelper.getCanCall(dmRoomId)
+ LaunchedEffect(Unit) {
+ client.ignoredUsersFlow
+ .map { ignoredUsers -> roomMemberId in ignoredUsers }
+ .distinctUntilChanged()
+ .onEach { isBlocked.value = AsyncData.Success(it) }
+ .launchIn(this)
}
LaunchedEffect(Unit) {
// Update room member info when opening this screen
// We don't need to assign the result as it will be automatically propagated by `room.getRoomMemberAsState`
room.getUpdatedMember(roomMemberId)
+ .onFailure {
+ // Not a member of the room, try to get the user profile
+ userProfile = client.getProfile(roomMemberId).getOrNull()
+ }
}
- fun handleEvents(event: RoomMemberDetailsEvents) {
+ fun handleEvents(event: UserProfileEvents) {
when (event) {
- is RoomMemberDetailsEvents.BlockUser -> {
+ is UserProfileEvents.BlockUser -> {
if (event.needsConfirmation) {
confirmationDialog = ConfirmationDialog.Block
} else {
confirmationDialog = null
- coroutineScope.blockUser(roomMemberId, isBlocked)
+ userProfilePresenterHelper.blockUser(coroutineScope, isBlocked)
}
}
- is RoomMemberDetailsEvents.UnblockUser -> {
+ is UserProfileEvents.UnblockUser -> {
if (event.needsConfirmation) {
confirmationDialog = ConfirmationDialog.Unblock
} else {
confirmationDialog = null
- coroutineScope.unblockUser(roomMemberId, isBlocked)
+ userProfilePresenterHelper.unblockUser(coroutineScope, isBlocked)
}
}
- RoomMemberDetailsEvents.ClearConfirmationDialog -> confirmationDialog = null
- RoomMemberDetailsEvents.ClearBlockUserError -> {
+ UserProfileEvents.ClearConfirmationDialog -> confirmationDialog = null
+ UserProfileEvents.ClearBlockUserError -> {
isBlocked.value = AsyncData.Success(isBlocked.value.dataOrNull().orFalse())
}
- RoomMemberDetailsEvents.StartDM -> {
+ UserProfileEvents.StartDM -> {
coroutineScope.launch {
startDMAction.execute(roomMemberId, startDmActionState)
}
}
- RoomMemberDetailsEvents.ClearStartDMState -> {
+ UserProfileEvents.ClearStartDMState -> {
startDmActionState.value = AsyncAction.Uninitialized
}
}
}
- val userName by produceState(initialValue = roomMember?.displayName) {
- room.userDisplayName(roomMemberId).onSuccess { displayName ->
- if (displayName != null) value = displayName
- }
+ val userName: String? by produceState(
+ initialValue = roomMember?.displayName ?: userProfile?.displayName,
+ key1 = roomMember,
+ key2 = userProfile,
+ ) {
+ value = room.userDisplayName(roomMemberId)
+ .fold(
+ onSuccess = { it },
+ onFailure = {
+ // Fallback to user profile
+ userProfile?.displayName
+ }
+ )
}
- val userAvatar by produceState(initialValue = roomMember?.avatarUrl) {
- room.userAvatarUrl(roomMemberId).onSuccess { avatarUrl ->
- if (avatarUrl != null) value = avatarUrl
- }
+ val userAvatar: String? by produceState(
+ initialValue = roomMember?.avatarUrl ?: userProfile?.avatarUrl,
+ key1 = roomMember,
+ key2 = userProfile,
+ ) {
+ value = room.userAvatarUrl(roomMemberId)
+ .fold(
+ onSuccess = { it },
+ onFailure = {
+ // Fallback to user profile
+ userProfile?.avatarUrl
+ }
+ )
}
- return RoomMemberDetailsState(
- userId = roomMemberId.value,
+ return UserProfileState(
+ userId = roomMemberId,
userName = userName,
avatarUrl = userAvatar,
isBlocked = isBlocked.value,
startDmActionState = startDmActionState.value,
displayConfirmationDialog = confirmationDialog,
- isCurrentUser = client.isMe(roomMember?.userId),
+ isCurrentUser = isCurrentUser,
+ dmRoomId = dmRoomId,
+ canCall = canCall,
eventSink = ::handleEvents
)
}
-
- private fun CoroutineScope.blockUser(userId: UserId, isBlockedState: MutableState>) = launch {
- isBlockedState.value = AsyncData.Loading(false)
- client.ignoreUser(userId)
- .fold(
- onSuccess = {
- isBlockedState.value = AsyncData.Success(true)
- room.getUpdatedMember(userId)
- },
- onFailure = {
- isBlockedState.value = AsyncData.Failure(it, false)
- }
- )
- }
-
- private fun CoroutineScope.unblockUser(userId: UserId, isBlockedState: MutableState>) = launch {
- isBlockedState.value = AsyncData.Loading(true)
- client.unignoreUser(userId)
- .fold(
- onSuccess = {
- isBlockedState.value = AsyncData.Success(false)
- room.getUpdatedMember(userId)
- },
- onFailure = {
- isBlockedState.value = AsyncData.Failure(it, true)
- }
- )
- }
}
diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/details/RoomMemberDetailsState.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/details/RoomMemberDetailsState.kt
deleted file mode 100644
index 535ab79280..0000000000
--- a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/details/RoomMemberDetailsState.kt
+++ /dev/null
@@ -1,37 +0,0 @@
-/*
- * Copyright (c) 2023 New Vector Ltd
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-package io.element.android.features.roomdetails.impl.members.details
-
-import io.element.android.libraries.architecture.AsyncAction
-import io.element.android.libraries.architecture.AsyncData
-import io.element.android.libraries.matrix.api.core.RoomId
-
-data class RoomMemberDetailsState(
- val userId: String,
- val userName: String?,
- val avatarUrl: String?,
- val isBlocked: AsyncData,
- val startDmActionState: AsyncAction,
- val displayConfirmationDialog: ConfirmationDialog?,
- val isCurrentUser: Boolean,
- val eventSink: (RoomMemberDetailsEvents) -> Unit
-) {
- enum class ConfirmationDialog {
- Block,
- Unblock
- }
-}
diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/details/RoomMemberDetailsStateProvider.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/details/RoomMemberDetailsStateProvider.kt
deleted file mode 100644
index dfc208e047..0000000000
--- a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/details/RoomMemberDetailsStateProvider.kt
+++ /dev/null
@@ -1,56 +0,0 @@
-/*
- * Copyright (c) 2023 New Vector Ltd
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-package io.element.android.features.roomdetails.impl.members.details
-
-import androidx.compose.ui.tooling.preview.PreviewParameterProvider
-import io.element.android.libraries.architecture.AsyncAction
-import io.element.android.libraries.architecture.AsyncData
-import io.element.android.libraries.matrix.api.core.RoomId
-
-open class RoomMemberDetailsStateProvider : PreviewParameterProvider {
- override val values: Sequence
- get() = sequenceOf(
- aRoomMemberDetailsState(),
- aRoomMemberDetailsState(userName = null),
- aRoomMemberDetailsState(isBlocked = AsyncData.Success(true)),
- aRoomMemberDetailsState(displayConfirmationDialog = RoomMemberDetailsState.ConfirmationDialog.Block),
- aRoomMemberDetailsState(displayConfirmationDialog = RoomMemberDetailsState.ConfirmationDialog.Unblock),
- aRoomMemberDetailsState(isBlocked = AsyncData.Loading(true)),
- aRoomMemberDetailsState(startDmActionState = AsyncAction.Loading),
- // Add other states here
- )
-}
-
-fun aRoomMemberDetailsState(
- userId: String = "@daniel:domain.com",
- userName: String? = "Daniel",
- avatarUrl: String? = null,
- isBlocked: AsyncData = AsyncData.Success(false),
- startDmActionState: AsyncAction = AsyncAction.Uninitialized,
- displayConfirmationDialog: RoomMemberDetailsState.ConfirmationDialog? = null,
- isCurrentUser: Boolean = false,
- eventSink: (RoomMemberDetailsEvents) -> Unit = {},
-) = RoomMemberDetailsState(
- userId = userId,
- userName = userName,
- avatarUrl = avatarUrl,
- isBlocked = isBlocked,
- startDmActionState = startDmActionState,
- displayConfirmationDialog = displayConfirmationDialog,
- isCurrentUser = isCurrentUser,
- eventSink = eventSink,
-)
diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/details/RoomMemberDetailsView.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/details/RoomMemberDetailsView.kt
deleted file mode 100644
index 01f3ca2ea0..0000000000
--- a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/details/RoomMemberDetailsView.kt
+++ /dev/null
@@ -1,141 +0,0 @@
-/*
- * Copyright (c) 2023 New Vector Ltd
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-package io.element.android.features.roomdetails.impl.members.details
-
-import androidx.compose.foundation.layout.Column
-import androidx.compose.foundation.layout.Spacer
-import androidx.compose.foundation.layout.consumeWindowInsets
-import androidx.compose.foundation.layout.height
-import androidx.compose.foundation.layout.padding
-import androidx.compose.foundation.rememberScrollState
-import androidx.compose.foundation.verticalScroll
-import androidx.compose.material3.ExperimentalMaterial3Api
-import androidx.compose.runtime.Composable
-import androidx.compose.ui.Modifier
-import androidx.compose.ui.res.stringResource
-import androidx.compose.ui.tooling.preview.PreviewParameter
-import androidx.compose.ui.unit.dp
-import io.element.android.compound.tokens.generated.CompoundIcons
-import io.element.android.features.roomdetails.impl.R
-import io.element.android.features.roomdetails.impl.blockuser.BlockUserDialogs
-import io.element.android.features.roomdetails.impl.blockuser.BlockUserSection
-import io.element.android.libraries.architecture.coverage.ExcludeFromCoverage
-import io.element.android.libraries.designsystem.components.async.AsyncActionView
-import io.element.android.libraries.designsystem.components.async.AsyncActionViewDefaults
-import io.element.android.libraries.designsystem.components.button.BackButton
-import io.element.android.libraries.designsystem.components.list.ListItemContent
-import io.element.android.libraries.designsystem.preview.ElementPreviewDark
-import io.element.android.libraries.designsystem.preview.ElementPreviewLight
-import io.element.android.libraries.designsystem.preview.PreviewWithLargeHeight
-import io.element.android.libraries.designsystem.theme.components.IconSource
-import io.element.android.libraries.designsystem.theme.components.ListItem
-import io.element.android.libraries.designsystem.theme.components.ListItemStyle
-import io.element.android.libraries.designsystem.theme.components.Scaffold
-import io.element.android.libraries.designsystem.theme.components.Text
-import io.element.android.libraries.designsystem.theme.components.TopAppBar
-import io.element.android.libraries.matrix.api.core.RoomId
-import io.element.android.libraries.ui.strings.CommonStrings
-
-@OptIn(ExperimentalMaterial3Api::class)
-@Composable
-fun RoomMemberDetailsView(
- state: RoomMemberDetailsState,
- onShareUser: () -> Unit,
- onDMStarted: (RoomId) -> Unit,
- goBack: () -> Unit,
- openAvatarPreview: (username: String, url: String) -> Unit,
- modifier: Modifier = Modifier,
-) {
- Scaffold(
- modifier = modifier,
- topBar = {
- TopAppBar(title = { }, navigationIcon = { BackButton(onClick = goBack) })
- },
- ) { padding ->
- Column(
- modifier = Modifier
- .padding(padding)
- .consumeWindowInsets(padding)
- .verticalScroll(rememberScrollState())
- ) {
- RoomMemberHeaderSection(
- avatarUrl = state.avatarUrl,
- userId = state.userId,
- userName = state.userName,
- openAvatarPreview = { avatarUrl ->
- openAvatarPreview(state.userName ?: state.userId, avatarUrl)
- },
- )
-
- RoomMemberMainActionsSection(onShareUser = onShareUser)
-
- Spacer(modifier = Modifier.height(26.dp))
-
- if (!state.isCurrentUser) {
- StartDMSection(onStartDMClicked = { state.eventSink(RoomMemberDetailsEvents.StartDM) })
- BlockUserSection(state)
- BlockUserDialogs(state)
- }
- AsyncActionView(
- async = state.startDmActionState,
- progressDialog = {
- AsyncActionViewDefaults.ProgressDialog(
- progressText = stringResource(CommonStrings.common_starting_chat),
- )
- },
- onSuccess = onDMStarted,
- errorMessage = { stringResource(R.string.screen_start_chat_error_starting_chat) },
- onRetry = { state.eventSink(RoomMemberDetailsEvents.StartDM) },
- onErrorDismiss = { state.eventSink(RoomMemberDetailsEvents.ClearStartDMState) },
- )
- }
- }
-}
-
-@Composable
-private fun StartDMSection(
- onStartDMClicked: () -> Unit,
-) {
- ListItem(
- headlineContent = { Text(stringResource(CommonStrings.common_direct_chat)) },
- leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Chat())),
- style = ListItemStyle.Primary,
- onClick = onStartDMClicked,
- )
-}
-
-@PreviewWithLargeHeight
-@Composable
-internal fun RoomMemberDetailsViewLightPreview(@PreviewParameter(RoomMemberDetailsStateProvider::class) state: RoomMemberDetailsState) =
- ElementPreviewLight { ContentToPreview(state) }
-
-@PreviewWithLargeHeight
-@Composable
-internal fun RoomMemberDetailsViewDarkPreview(@PreviewParameter(RoomMemberDetailsStateProvider::class) state: RoomMemberDetailsState) =
- ElementPreviewDark { ContentToPreview(state) }
-
-@ExcludeFromCoverage
-@Composable
-private fun ContentToPreview(state: RoomMemberDetailsState) {
- RoomMemberDetailsView(
- state = state,
- onShareUser = {},
- goBack = {},
- onDMStarted = {},
- openAvatarPreview = { _, _ -> }
- )
-}
diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/details/RoomMemberHeaderSection.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/details/RoomMemberHeaderSection.kt
deleted file mode 100644
index 5e6ed6a7e7..0000000000
--- a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/details/RoomMemberHeaderSection.kt
+++ /dev/null
@@ -1,81 +0,0 @@
-/*
- * Copyright (c) 2023 New Vector Ltd
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-package io.element.android.features.roomdetails.impl.members.details
-
-import androidx.compose.foundation.clickable
-import androidx.compose.foundation.layout.Box
-import androidx.compose.foundation.layout.Column
-import androidx.compose.foundation.layout.Spacer
-import androidx.compose.foundation.layout.fillMaxSize
-import androidx.compose.foundation.layout.fillMaxWidth
-import androidx.compose.foundation.layout.height
-import androidx.compose.foundation.layout.padding
-import androidx.compose.foundation.layout.size
-import androidx.compose.material3.MaterialTheme
-import androidx.compose.runtime.Composable
-import androidx.compose.ui.Alignment
-import androidx.compose.ui.Modifier
-import androidx.compose.ui.draw.clipToBounds
-import androidx.compose.ui.text.style.TextAlign
-import androidx.compose.ui.unit.dp
-import io.element.android.compound.theme.ElementTheme
-import io.element.android.libraries.designsystem.components.avatar.Avatar
-import io.element.android.libraries.designsystem.components.avatar.AvatarData
-import io.element.android.libraries.designsystem.components.avatar.AvatarSize
-import io.element.android.libraries.designsystem.theme.components.Text
-import io.element.android.libraries.testtags.TestTags
-import io.element.android.libraries.testtags.testTag
-
-@Composable
-fun RoomMemberHeaderSection(
- avatarUrl: String?,
- userId: String,
- userName: String?,
- openAvatarPreview: (url: String) -> Unit,
- modifier: Modifier = Modifier
-) {
- Column(modifier.fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally) {
- Box(modifier = Modifier.size(70.dp)) {
- Avatar(
- avatarData = AvatarData(userId, userName, avatarUrl, AvatarSize.UserHeader),
- modifier = Modifier
- .clickable(enabled = avatarUrl != null) { openAvatarPreview(avatarUrl!!) }
- .fillMaxSize()
- .testTag(TestTags.memberDetailAvatar)
- )
- }
- Spacer(modifier = Modifier.height(24.dp))
- if (userName != null) {
- Text(
- modifier = Modifier.clipToBounds(),
- text = userName,
- style = ElementTheme.typography.fontHeadingLgBold,
- )
- Spacer(modifier = Modifier.height(6.dp))
- }
- Text(
- text = userId,
- style = ElementTheme.typography.fontBodyLgRegular,
- color = MaterialTheme.colorScheme.secondary,
- modifier = Modifier
- .fillMaxWidth()
- .padding(horizontal = 16.dp),
- textAlign = TextAlign.Center,
- )
- Spacer(Modifier.height(40.dp))
- }
-}
diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/details/RoomMemberMainActionsSection.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/details/RoomMemberMainActionsSection.kt
deleted file mode 100644
index afa4651c66..0000000000
--- a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/details/RoomMemberMainActionsSection.kt
+++ /dev/null
@@ -1,38 +0,0 @@
-/*
- * Copyright (c) 2023 New Vector Ltd
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-package io.element.android.features.roomdetails.impl.members.details
-
-import androidx.compose.foundation.layout.Arrangement
-import androidx.compose.foundation.layout.Row
-import androidx.compose.foundation.layout.fillMaxWidth
-import androidx.compose.runtime.Composable
-import androidx.compose.ui.Modifier
-import androidx.compose.ui.res.stringResource
-import io.element.android.compound.tokens.generated.CompoundIcons
-import io.element.android.libraries.designsystem.components.button.MainActionButton
-import io.element.android.libraries.ui.strings.CommonStrings
-
-@Composable
-fun RoomMemberMainActionsSection(onShareUser: () -> Unit, modifier: Modifier = Modifier) {
- Row(modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Center) {
- MainActionButton(
- title = stringResource(CommonStrings.action_share),
- imageVector = CompoundIcons.ShareAndroid(),
- onClick = onShareUser
- )
- }
-}
diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/notificationsettings/RoomNotificationSettingsOption.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/notificationsettings/RoomNotificationSettingsOption.kt
index eaea25d202..15e450502a 100644
--- a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/notificationsettings/RoomNotificationSettingsOption.kt
+++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/notificationsettings/RoomNotificationSettingsOption.kt
@@ -57,7 +57,7 @@ fun RoomNotificationSettingsOption(
@PreviewsDayNight
@Composable
-internal fun RoomPrivacyOptionPreview() = ElementPreview {
+internal fun RoomNotificationSettingsOptionPreview() = ElementPreview {
Column {
for ((index, item) in roomNotificationSettingsItems().withIndex()) {
RoomNotificationSettingsOption(
diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/notificationsettings/RoomNotificationSettingsView.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/notificationsettings/RoomNotificationSettingsView.kt
index b34ee3a620..ec3d436752 100644
--- a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/notificationsettings/RoomNotificationSettingsView.kt
+++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/notificationsettings/RoomNotificationSettingsView.kt
@@ -190,7 +190,7 @@ private fun RoomNotificationSettingsTopBar(
@PreviewsDayNight
@Composable
-internal fun RoomNotificationSettingsPreview(
+internal fun RoomNotificationSettingsViewPreview(
@PreviewParameter(RoomNotificationSettingsStateProvider::class) state: RoomNotificationSettingsState
) = ElementPreview {
RoomNotificationSettingsView(
diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/notificationsettings/UserDefinedRoomNotificationSettingsView.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/notificationsettings/UserDefinedRoomNotificationSettingsView.kt
index 99903b7466..925ea23401 100644
--- a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/notificationsettings/UserDefinedRoomNotificationSettingsView.kt
+++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/notificationsettings/UserDefinedRoomNotificationSettingsView.kt
@@ -116,7 +116,7 @@ private fun UserDefinedRoomNotificationSettingsTopBar(
@PreviewsDayNight
@Composable
-internal fun UserDefinedRoomNotificationSettingsPreview(
+internal fun UserDefinedRoomNotificationSettingsViewPreview(
@PreviewParameter(UserDefinedRoomNotificationSettingsStateProvider::class) state: RoomNotificationSettingsState
) = ElementPreview {
UserDefinedRoomNotificationSettingsView(
diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/rolesandpermissions/RolesAndPermissionsView.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/rolesandpermissions/RolesAndPermissionsView.kt
index f97b069215..268e7ca478 100644
--- a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/rolesandpermissions/RolesAndPermissionsView.kt
+++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/rolesandpermissions/RolesAndPermissionsView.kt
@@ -203,7 +203,7 @@ private fun ChangeOwnRoleBottomSheet(
@PreviewsDayNight
@Composable
-internal fun RolesAndPermissionViewPreview(@PreviewParameter(RolesAndPermissionsStateProvider::class) state: RolesAndPermissionsState) {
+internal fun RolesAndPermissionsViewPreview(@PreviewParameter(RolesAndPermissionsStateProvider::class) state: RolesAndPermissionsState) {
ElementPreview {
RolesAndPermissionsView(
state = state,
diff --git a/features/roomdetails/impl/src/main/res/values-be/translations.xml b/features/roomdetails/impl/src/main/res/values-be/translations.xml
index 3761d90ac4..cb2dc4ce7c 100644
--- a/features/roomdetails/impl/src/main/res/values-be/translations.xml
+++ b/features/roomdetails/impl/src/main/res/values-be/translations.xml
@@ -1,11 +1,5 @@
- "Заблакіраваць"
- "Заблакіраваныя карыстальнікі не змогуць адпраўляць вам паведамленні, і ўсе іх паведамленні будуць схаваны. Вы можаце разблакіраваць іх у любы час."
- "Заблакіраваць карыстальніка"
- "Разблакіраваць"
- "Вы зноў зможаце ўбачыць усе паведамленні."
- "Разблакіраваць карыстальніка"
"Пры абнаўленні налад апавяшчэнняў адбылася памылка."
"Ваш хатні сервер не падтрымлівае гэтую опцыю ў зашыфраваных пакоях, вы можаце не атрымаць апавяшчэнне ў некаторых пакоях."
"Апытанні"
@@ -117,5 +111,4 @@
"Ролі"
"Дэталі пакоя"
"Ролі і дазволы"
- "Пры спробе пачаць чат адбылася памылка"
diff --git a/features/roomdetails/impl/src/main/res/values-bg/translations.xml b/features/roomdetails/impl/src/main/res/values-bg/translations.xml
index 5cc105f657..e764baee3c 100644
--- a/features/roomdetails/impl/src/main/res/values-bg/translations.xml
+++ b/features/roomdetails/impl/src/main/res/values-bg/translations.xml
@@ -1,9 +1,5 @@
- "Блокиране"
- "Блокиране на потребителя"
- "Отблокиране"
- "Отблокиране на потребителя"
"Анкети"
"Членове"
"Добавяне на тема"
diff --git a/features/roomdetails/impl/src/main/res/values-cs/translations.xml b/features/roomdetails/impl/src/main/res/values-cs/translations.xml
index f44cdd210a..a8bbc85973 100644
--- a/features/roomdetails/impl/src/main/res/values-cs/translations.xml
+++ b/features/roomdetails/impl/src/main/res/values-cs/translations.xml
@@ -1,11 +1,5 @@
- "Zablokovat"
- "Blokovaní uživatelé vám nebudou moci posílat zprávy a všechny jejich zprávy budou skryty. Můžete je kdykoli odblokovat."
- "Zablokovat uživatele"
- "Odblokovat"
- "Znovu uvidíte všechny zprávy od nich."
- "Odblokovat uživatele"
"Při aktualizaci nastavení oznámení došlo k chybě."
"Váš domovský server tuto možnost v zašifrovaných místnostech nepodporuje, v některých místnostech nemusíte být upozorněni."
"Hlasování"
@@ -117,5 +111,4 @@
"Role"
"Podrobnosti místnosti"
"Role a oprávnění"
- "Při pokusu o zahájení chatu došlo k chybě"
diff --git a/features/roomdetails/impl/src/main/res/values-de/translations.xml b/features/roomdetails/impl/src/main/res/values-de/translations.xml
index 5a403643c6..ae0366a6b1 100644
--- a/features/roomdetails/impl/src/main/res/values-de/translations.xml
+++ b/features/roomdetails/impl/src/main/res/values-de/translations.xml
@@ -1,13 +1,7 @@
- "Blockieren"
- "Blockierte Benutzer können Dir keine Nachrichten senden und alle ihre alten Nachrichten werden ausgeblendet. Die Blockierung kann jederzeit aufgehoben werden."
- "Benutzer blockieren"
- "Blockierung aufheben"
- "Der Nutzer kann dir wieder Nachrichten senden & alle Nachrichten des Nutzers werden wieder angezeigt."
- "Blockierung aufheben"
"Beim Aktualisieren der Benachrichtigungseinstellungen ist ein Fehler aufgetreten."
- "Dein Homeserver unterstützt diese Option in verschlüsselten Räumen nicht. In einigen Räumen wirst du möglicherweise nicht benachrichtigt."
+ "Dein Homeserver unterstützt diese Option in verschlüsselten Chat nicht. In einigen Chats wirst du möglicherweise nicht benachrichtigt."
"Umfragen"
"Nur Administratoren"
"Mitglieder sperren"
@@ -18,17 +12,17 @@
"Nachrichten senden & löschen"
"Administratoren und Moderatoren"
"Personen entfernen"
- "Raum-Avatar ändern"
- "Raumdetails anpassen"
+ "Avatar ändern"
+ "Raum-Details anpassen"
"Raumname ändern"
"Raumthema ändern"
"Nachrichten senden"
"Admins bearbeiten"
- "Du kannst diese Aktion nicht mehr rückgängig machen. Du vergibst dieselbe Rolle, wie auch Du sie hast."
+ "Du kannst diese Aktion nicht mehr rückgängig machen. Du vergibst dieselbe Rolle, die du auch hast."
"Als Administrator hinzufügen?"
"Zurückstufen"
"Du stufst dich selbst herab. Diese Änderung kann nicht rückgängig gemacht werden. Wenn du der letzte Benutzer mit dieser Rolle bist, ist es nicht möglich, diese Rolle wiederzuerlangen."
- "Möchtest Du Dich selbst herabstufen?"
+ "Möchtest du dich selbst herabstufen?"
"%1$s (Ausstehend)"
"(Ausstehend)"
"Administratoren haben automatisch Moderatorenrechte"
@@ -47,19 +41,19 @@
"Nachrichten und Anrufe sind Ende-zu-Ende verschlüsselt. Nur du und die Empfänger haben die eindeutigen Schlüssel, um sie zu entsperren."
"Nachrichtenverschlüsselung aktiviert"
"Beim Laden der Benachrichtigungseinstellungen ist ein Fehler aufgetreten."
- "Die Stummschaltung dieses Raums ist fehlgeschlagen, bitte versuche es erneut."
- "Die Deaktivierung der Stummschaltung dieses Raums ist fehlgeschlagen, bitte versuche es erneut."
+ "Die Stummschaltung ist fehlgeschlagen, bitte versuche es erneut."
+ "Die Deaktivierung der Stummschaltung ist fehlgeschlagen, bitte versuche es erneut."
"Personen einladen"
"Unterhaltung verlassen"
- "Raum verlassen"
+ "Verlassen"
"Benutzerdefiniert"
"Standard"
"Benachrichtigungen"
"Rollen und Berechtigungen"
"Raumname"
"Sicherheit"
- "Raum teilen"
- "Raum Informationen"
+ "Teilen"
+ "Informationen"
"Thema"
"Raum wird aktualisiert…"
"Sperren"
@@ -86,7 +80,7 @@
"%1$s wird entfernt."
"Administrator"
"Moderator"
- "Raummitglieder"
+ "Mitglieder"
"%1$s wird entsperrt."
"Benutzerdefinierte Einstellungen verwenden"
"Wenn du diese Option aktivierst, wird deine Standardeinstellung außer Kraft gesetzt."
@@ -98,10 +92,10 @@
"Beim Laden der Benachrichtigungseinstellungen ist ein Fehler aufgetreten."
"Fehler beim Wiederherstellen des Standardmodus. Bitte versuche es erneut."
"Fehler beim Einstellen des Modus. Bitte versuche es erneut."
- "Dein Homeserver unterstützt diese Option in verschlüsselten Räumen nicht. Du wirst in diesem Raum nicht benachrichtigt."
+ "Dein Homeserver unterstützt diese Option in verschlüsselten Chats nicht. Du wirst in diesem Chat nicht benachrichtigt."
"Alle Nachrichten"
"Nur Erwähnungen und Schlüsselwörter"
- "Benachrichtige mich in diesem Raum bei"
+ "Benachrichtige mich bei"
"Administratoren"
"Ändere meine Rolle"
"Zum Mitglied herabstufen"
@@ -114,7 +108,6 @@
"Sobald Sie die Berechtigungen zurücksetzen, verlieren Sie die aktuellen Einstellungen."
"Berechtigungen zurücksetzen?"
"Rollen"
- "Raumdetails anpassen"
+ "Raum-Details anpassen"
"Rollen und Berechtigungen"
- "Beim Versuch, einen Chat zu starten, ist ein Fehler aufgetreten"
diff --git a/features/roomdetails/impl/src/main/res/values-es/translations.xml b/features/roomdetails/impl/src/main/res/values-es/translations.xml
index 5ae61bd4df..16e6a55cfd 100644
--- a/features/roomdetails/impl/src/main/res/values-es/translations.xml
+++ b/features/roomdetails/impl/src/main/res/values-es/translations.xml
@@ -1,11 +1,5 @@
- "Bloquear"
- "Los usuarios bloqueados no podrán enviarte mensajes y todos sus mensajes se ocultarán. Puedes desbloquearlos cuando quieras."
- "Bloquear usuario"
- "Desbloquear"
- "Podrás ver todos sus mensajes de nuevo."
- "Desbloquear usuario"
"Se ha producido un error al actualizar la configuración de notificaciones."
"Tu servidor principal no admite esta opción en salas cifradas, puede que no recibas notificaciones en algunas salas."
"Encuestas"
@@ -51,5 +45,4 @@
"Todos los mensajes"
"Únicamente Menciones y Palabras clave"
"En esta sala, notificarme por"
- "Se ha producido un error al intentar iniciar un chat"
diff --git a/features/roomdetails/impl/src/main/res/values-fr/translations.xml b/features/roomdetails/impl/src/main/res/values-fr/translations.xml
index d4f4cab64c..71bc11c618 100644
--- a/features/roomdetails/impl/src/main/res/values-fr/translations.xml
+++ b/features/roomdetails/impl/src/main/res/values-fr/translations.xml
@@ -1,11 +1,5 @@
- "Bloquer"
- "Les utilisateurs bloqués ne pourront pas vous envoyer de messages et tous leurs messages seront masqués. Vous pouvez les débloquer à tout moment."
- "Bloquer l’utilisateur"
- "Débloquer"
- "Vous pourrez à nouveau voir tous ses messages."
- "Débloquer l’utilisateur"
"Une erreur s’est produite lors de la mise à jour du paramètre de notification."
"Votre serveur d’accueil ne supporte pas cette option pour les salons chiffrés, vous pourriez ne pas être notifié(e) dans certains salons."
"Sondages"
@@ -116,5 +110,4 @@
"Rôles"
"Détails du salon"
"Rôles et autorisations"
- "Une erreur s’est produite lors de la tentative de création de la discussion"
diff --git a/features/roomdetails/impl/src/main/res/values-hu/translations.xml b/features/roomdetails/impl/src/main/res/values-hu/translations.xml
index 4fa200ee58..883a67e014 100644
--- a/features/roomdetails/impl/src/main/res/values-hu/translations.xml
+++ b/features/roomdetails/impl/src/main/res/values-hu/translations.xml
@@ -1,11 +1,5 @@
- "Letiltás"
- "A letiltott felhasználók nem fognak tudni üzeneteket küldeni, és az összes üzenetük rejtve lesz. Bármikor feloldhatja a letiltásukat."
- "Felhasználó letiltása"
- "Letiltás feloldása"
- "Újra láthatja az összes üzenetét."
- "Felhasználó kitiltásának feloldása"
"Hiba történt az értesítési beállítás frissítésekor."
"A Matrix-kiszolgálója nem támogatja ezt a beállítást a titkosított szobákban, előfordulhat, hogy egyes szobákban nem kap értesítést."
"Szavazások"
@@ -116,5 +110,4 @@
"Szerepkörök"
"Szoba részletei"
"Szerepkörök és jogosultságok"
- "Hiba történt a csevegés indításakor"
diff --git a/features/roomdetails/impl/src/main/res/values-in/translations.xml b/features/roomdetails/impl/src/main/res/values-in/translations.xml
index c9aedeefd1..022126bb1c 100644
--- a/features/roomdetails/impl/src/main/res/values-in/translations.xml
+++ b/features/roomdetails/impl/src/main/res/values-in/translations.xml
@@ -1,11 +1,5 @@
- "Blokir"
- "Pengguna yang diblokir tidak akan dapat mengirim Anda pesan dan semua pesan mereka akan disembunyikan. Anda dapat membuka blokirnya kapan saja."
- "Blokir pengguna"
- "Buka blokir"
- "Anda akan dapat melihat semua pesan dari mereka lagi."
- "Buka blokir pengguna"
"Terjadi kesalahan saat memperbarui pengaturan pemberitahuan."
"Homeserver Anda tidak mendukung opsi ini dalam ruangan terenkripsi, Anda mungkin tidak diberi tahu dalam beberapa ruangan."
"Pemungutan suara"
@@ -115,5 +109,4 @@
"Peran"
"Detail ruangan"
"Peran dan perizinan"
- "Terjadi kesalahan saat mencoba memulai obrolan"
diff --git a/features/roomdetails/impl/src/main/res/values-it/translations.xml b/features/roomdetails/impl/src/main/res/values-it/translations.xml
index 8326d592e8..ab3ec27fcc 100644
--- a/features/roomdetails/impl/src/main/res/values-it/translations.xml
+++ b/features/roomdetails/impl/src/main/res/values-it/translations.xml
@@ -1,11 +1,5 @@
- "Blocca"
- "Gli utenti bloccati non saranno in grado di inviarti messaggi e tutti quelli già ricevuti saranno nascosti. Puoi sbloccarli in qualsiasi momento."
- "Blocca utente"
- "Sblocca"
- "Potrai vedere di nuovo tutti i suoi messaggi."
- "Sblocca utente"
"Si è verificato un errore durante l\'aggiornamento delle impostazioni di notifica."
"Il tuo homeserver non supporta questa opzione nelle stanze crifrate, quindi potresti non ricevere notifiche in alcune stanze."
"Sondaggi"
@@ -29,6 +23,7 @@
"Declassa"
"Non potrai annullare questa modifica perché ti stai declassando, se sei l\'ultimo utente privilegiato nella stanza, sarà impossibile riottenere i privilegi."
"Declassare te stesso?"
+ "%1$s (In attesa)"
"Modifica moderatori"
"Amministratori"
"Moderatori"
@@ -112,5 +107,4 @@
"Ruoli"
"Dettagli della stanza"
"Ruoli e autorizzazioni"
- "Si è verificato un errore durante il tentativo di avviare una chat"
diff --git a/features/roomdetails/impl/src/main/res/values-ro/translations.xml b/features/roomdetails/impl/src/main/res/values-ro/translations.xml
index 0f4c19b4d8..ef8edf43be 100644
--- a/features/roomdetails/impl/src/main/res/values-ro/translations.xml
+++ b/features/roomdetails/impl/src/main/res/values-ro/translations.xml
@@ -1,11 +1,5 @@
- "Blocați"
- "Utilizatorii blocați nu vă vor putea trimite mesaje și toate mesajele lor vor fi ascunse. Puteți anula această acțiune oricând."
- "Blocați utilizatorul"
- "Deblocați"
- "La deblocarea utilizatorului, veți putea vedea din nou toate mesajele de la acesta."
- "Deblocați utilizatorul"
"A apărut o eroare în timpul actualizării setărilor pentru notificari."
"Serverul dumneavoastră nu acceptă această opțiune în camerele criptate, este posibil să nu primiți notificări în unele camere."
"Sondaje"
@@ -63,5 +57,4 @@
"Toate mesajele"
"Numai mențiuni și cuvinte cheie"
"În această cameră, anunțați-mă pentru"
- "A apărut o eroare la încercarea începerii conversației"
diff --git a/features/roomdetails/impl/src/main/res/values-ru/translations.xml b/features/roomdetails/impl/src/main/res/values-ru/translations.xml
index 2cfa24a003..1b0bc8d193 100644
--- a/features/roomdetails/impl/src/main/res/values-ru/translations.xml
+++ b/features/roomdetails/impl/src/main/res/values-ru/translations.xml
@@ -1,11 +1,5 @@
- "Заблокировать"
- "Заблокированные пользователи не смогут отправлять вам сообщения, а все их сообщения будут скрыты. Вы можете разблокировать их в любое время."
- "Заблокировать пользователя"
- "Разблокировать"
- "Вы снова сможете увидеть все сообщения."
- "Разблокировать пользователя"
"При обновлении настроек уведомления произошла ошибка."
"Ваш домашний сервер не поддерживает эту опцию в зашифрованных комнатах, в некоторых комнатах вы можете не получать уведомления."
"Опросы"
@@ -117,5 +111,4 @@
"Роли"
"Информация о комнате"
"Роли и разрешения"
- "Произошла ошибка при попытке открытия комнаты"
diff --git a/features/roomdetails/impl/src/main/res/values-sk/translations.xml b/features/roomdetails/impl/src/main/res/values-sk/translations.xml
index 7feb1d4e41..467ba5578a 100644
--- a/features/roomdetails/impl/src/main/res/values-sk/translations.xml
+++ b/features/roomdetails/impl/src/main/res/values-sk/translations.xml
@@ -1,11 +1,5 @@
- "Zablokovať"
- "Blokovaní používatelia vám nebudú môcť posielať správy a všetky ich správy budú skryté. Môžete ich kedykoľvek odblokovať."
- "Zablokovať používateľa"
- "Odblokovať"
- "Všetky správy od nich budete môcť opäť vidieť."
- "Odblokovať používateľa"
"Pri aktualizácii nastavenia oznámenia došlo k chybe."
"Váš domovský server nepodporuje túto možnosť v šifrovaných miestnostiach, v niektorých miestnostiach nemusíte dostať upozornenie."
"Ankety"
@@ -117,5 +111,4 @@
"Roly"
"Podrobnosti o miestnosti"
"Roly a povolenia"
- "Pri pokuse o spustenie konverzácie sa vyskytla chyba"
diff --git a/features/roomdetails/impl/src/main/res/values-sv/translations.xml b/features/roomdetails/impl/src/main/res/values-sv/translations.xml
index 770101de00..b8161c4108 100644
--- a/features/roomdetails/impl/src/main/res/values-sv/translations.xml
+++ b/features/roomdetails/impl/src/main/res/values-sv/translations.xml
@@ -1,12 +1,8 @@
- "Blockera"
- "Blockerade användare kommer inte att kunna skicka meddelanden till dig och alla deras meddelanden kommer att döljas. Du kan avblockera dem när som helst."
- "Blockera användare"
- "Avblockera"
- "Du kommer att kunna se alla meddelanden från dem igen."
- "Avblockera användare"
"Ett fel uppstod vid uppdatering av aviseringsinställningen."
+ "Din hemserver stöder inte det här alternativet i krypterade rum, du kanske inte aviseras i vissa rum."
+ "Omröstningar"
"Alla"
"Lägg till ämne"
"Redan medlem"
@@ -20,6 +16,7 @@
"Misslyckades att tysta det här rummet, vänligen pröva igen."
"Misslyckades att avtysta det här rummet, vänligen pröva igen."
"Bjud in personer"
+ "Lämna konversation"
"Lämna rum"
"Anpassad"
"Förval"
@@ -45,8 +42,8 @@
"Ett fel uppstod vid laddning av aviseringsinställningarna."
"Misslyckades att återställa standardläget, vänligen försök igen."
"Misslyckades att ställa in läget, vänligen pröva igen."
+ "Din hemserver stöder inte det här alternativet i krypterade rum, du blir inte aviserad i det här rummet."
"Alla meddelanden"
"Endast omnämnanden och nyckelord"
"I det här rummet, meddela mig för"
- "Ett fel uppstod när du försökte starta en chatt"
diff --git a/features/roomdetails/impl/src/main/res/values-uk/translations.xml b/features/roomdetails/impl/src/main/res/values-uk/translations.xml
index 415334df5a..e61a7052a3 100644
--- a/features/roomdetails/impl/src/main/res/values-uk/translations.xml
+++ b/features/roomdetails/impl/src/main/res/values-uk/translations.xml
@@ -1,11 +1,5 @@
- "Заблокувати"
- "Заблоковані користувачі не зможуть надсилати Вам повідомлення, і всі їхні повідомлення будуть приховані. Ви можете розблокувати їх у будь-який час."
- "Заблокувати користувача"
- "Розблокувати"
- "Ви знову зможете бачити всі повідомлення від них."
- "Розблокувати користувача"
"Під час оновлення налаштувань сповіщень сталася помилка."
"Ваш домашній сервер не підтримує цю опцію в зашифрованих кімнатах, ви можете не отримати сповіщення в деяких кімнатах."
"Опитування"
@@ -113,5 +107,4 @@
"Ролі"
"Деталі кімнати"
"Ролі та дозволи"
- "Під час спроби почати чат сталася помилка"
diff --git a/features/roomdetails/impl/src/main/res/values-zh-rTW/translations.xml b/features/roomdetails/impl/src/main/res/values-zh-rTW/translations.xml
index c1701ce69b..11769b3176 100644
--- a/features/roomdetails/impl/src/main/res/values-zh-rTW/translations.xml
+++ b/features/roomdetails/impl/src/main/res/values-zh-rTW/translations.xml
@@ -1,9 +1,5 @@
- "封鎖"
- "封鎖使用者"
- "解除封鎖"
- "解除封鎖使用者"
"更新通知設定時發生錯誤。"
"所有投票"
"所有人"
diff --git a/features/roomdetails/impl/src/main/res/values/localazy.xml b/features/roomdetails/impl/src/main/res/values/localazy.xml
index 8067676f93..19400c85b3 100644
--- a/features/roomdetails/impl/src/main/res/values/localazy.xml
+++ b/features/roomdetails/impl/src/main/res/values/localazy.xml
@@ -1,11 +1,5 @@
- "Block"
- "Blocked users won\'t be able to send you messages and all their messages will be hidden. You can unblock them anytime."
- "Block user"
- "Unblock"
- "You\'ll be able to see all messages from them again."
- "Unblock user"
"An error occurred while updating the notification setting."
"Your homeserver does not support this option in encrypted rooms, you may not get notified in some rooms."
"Polls"
@@ -41,6 +35,9 @@
"Add topic"
"Already a member"
"Already invited"
+ "Encrypted"
+ "Not encrypted"
+ "Public room"
"Edit Room"
"There was an unknown error and the information couldn\'t be changed."
"Unable to update room"
@@ -116,5 +113,4 @@
"Roles"
"Room details"
"Roles and permissions"
- "An error occurred when trying to start a chat"
diff --git a/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/RoomDetailsPresenterTests.kt b/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/RoomDetailsPresenterTests.kt
index 895ce759f4..998193690e 100644
--- a/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/RoomDetailsPresenterTests.kt
+++ b/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/RoomDetailsPresenterTests.kt
@@ -117,8 +117,8 @@ class RoomDetailsPresenterTests {
val presenter = createRoomDetailsPresenter(room)
presenter.test {
val initialState = awaitItem()
- assertThat(initialState.roomId).isEqualTo(room.roomId.value)
- assertThat(initialState.roomName).isEqualTo(room.name)
+ assertThat(initialState.roomId).isEqualTo(room.roomId)
+ assertThat(initialState.roomName).isEqualTo(room.displayName)
assertThat(initialState.roomAvatarUrl).isEqualTo(room.avatarUrl)
assertThat(initialState.roomTopic).isEqualTo(RoomTopicState.ExistingTopic(room.topic!!))
assertThat(initialState.memberCount).isEqualTo(room.joinedMemberCount)
@@ -148,7 +148,7 @@ class RoomDetailsPresenterTests {
@Test
fun `present - initial state with no room name`() = runTest {
- val room = aMatrixRoom(name = null)
+ val room = aMatrixRoom(displayName = "")
val presenter = createRoomDetailsPresenter(room)
presenter.test {
val initialState = awaitItem()
@@ -476,8 +476,7 @@ class RoomDetailsPresenterTests {
fun aMatrixRoom(
roomId: RoomId = A_ROOM_ID,
- name: String? = A_ROOM_NAME,
- displayName: String = "A fallback display name",
+ displayName: String = A_ROOM_NAME,
topic: String? = "A topic",
avatarUrl: String? = "https://matrix.org/avatar.jpg",
isEncrypted: Boolean = true,
@@ -486,7 +485,6 @@ fun aMatrixRoom(
notificationSettingsService: FakeNotificationSettingsService = FakeNotificationSettingsService()
) = FakeMatrixRoom(
roomId = roomId,
- name = name,
displayName = displayName,
topic = topic,
avatarUrl = avatarUrl,
diff --git a/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/edit/RoomDetailsEditPresenterTest.kt b/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/edit/RoomDetailsEditPresenterTest.kt
index f2f6f9b1b4..8b0dcd5b0a 100644
--- a/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/edit/RoomDetailsEditPresenterTest.kt
+++ b/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/edit/RoomDetailsEditPresenterTest.kt
@@ -98,7 +98,7 @@ class RoomDetailsEditPresenterTest {
}.test {
val initialState = awaitItem()
assertThat(initialState.roomId).isEqualTo(room.roomId.value)
- assertThat(initialState.roomName).isEqualTo(room.name)
+ assertThat(initialState.roomName).isEqualTo(room.displayName)
assertThat(initialState.roomAvatarUrl).isEqualTo(roomAvatarUri)
assertThat(initialState.roomTopic).isEqualTo(room.topic.orEmpty())
assertThat(initialState.avatarActions).containsExactly(
@@ -191,7 +191,7 @@ class RoomDetailsEditPresenterTest {
@Test
fun `present - updates state in response to changes`() = runTest {
- val room = aMatrixRoom(topic = "My topic", name = "Name", avatarUrl = AN_AVATAR_URL)
+ val room = aMatrixRoom(topic = "My topic", displayName = "Name", avatarUrl = AN_AVATAR_URL)
val presenter = createRoomDetailsEditPresenter(room)
moleculeFlow(RecompositionMode.Immediate) {
@@ -234,7 +234,7 @@ class RoomDetailsEditPresenterTest {
@Test
fun `present - obtains avatar uris from gallery`() = runTest {
- val room = aMatrixRoom(topic = "My topic", name = "Name", avatarUrl = AN_AVATAR_URL)
+ val room = aMatrixRoom(topic = "My topic", displayName = "Name", avatarUrl = AN_AVATAR_URL)
fakePickerProvider.givenResult(anotherAvatarUri)
@@ -255,7 +255,7 @@ class RoomDetailsEditPresenterTest {
@Test
fun `present - obtains avatar uris from camera`() = runTest {
- val room = aMatrixRoom(topic = "My topic", name = "Name", avatarUrl = AN_AVATAR_URL)
+ val room = aMatrixRoom(topic = "My topic", displayName = "Name", avatarUrl = AN_AVATAR_URL)
fakePickerProvider.givenResult(anotherAvatarUri)
val fakePermissionsPresenter = FakePermissionsPresenter()
@@ -288,7 +288,7 @@ class RoomDetailsEditPresenterTest {
@Test
fun `present - updates save button state`() = runTest {
- val room = aMatrixRoom(topic = "My topic", name = "Name", avatarUrl = AN_AVATAR_URL)
+ val room = aMatrixRoom(topic = "My topic", displayName = "Name", avatarUrl = AN_AVATAR_URL)
fakePickerProvider.givenResult(roomAvatarUri)
@@ -340,7 +340,7 @@ class RoomDetailsEditPresenterTest {
@Test
fun `present - updates save button state when initial values are null`() = runTest {
- val room = aMatrixRoom(topic = null, name = null, displayName = "fallback", avatarUrl = null)
+ val room = aMatrixRoom(topic = null, displayName = "fallback", avatarUrl = null)
fakePickerProvider.givenResult(roomAvatarUri)
@@ -392,7 +392,7 @@ class RoomDetailsEditPresenterTest {
@Test
fun `present - save changes room details if different`() = runTest {
- val room = aMatrixRoom(topic = "My topic", name = "Name", avatarUrl = AN_AVATAR_URL)
+ val room = aMatrixRoom(topic = "My topic", displayName = "Name", avatarUrl = AN_AVATAR_URL)
val presenter = createRoomDetailsEditPresenter(room)
@@ -417,7 +417,7 @@ class RoomDetailsEditPresenterTest {
@Test
fun `present - save doesn't change room details if they're the same trimmed`() = runTest {
- val room = aMatrixRoom(topic = "My topic", name = "Name", avatarUrl = AN_AVATAR_URL)
+ val room = aMatrixRoom(topic = "My topic", displayName = "Name", avatarUrl = AN_AVATAR_URL)
val presenter = createRoomDetailsEditPresenter(room)
@@ -441,7 +441,7 @@ class RoomDetailsEditPresenterTest {
@Test
fun `present - save doesn't change topic if it was unset and is now blank`() = runTest {
- val room = aMatrixRoom(topic = null, name = "Name", avatarUrl = AN_AVATAR_URL)
+ val room = aMatrixRoom(topic = null, displayName = "Name", avatarUrl = AN_AVATAR_URL)
val presenter = createRoomDetailsEditPresenter(room)
@@ -464,7 +464,7 @@ class RoomDetailsEditPresenterTest {
@Test
fun `present - save doesn't change name if it's now empty`() = runTest {
- val room = aMatrixRoom(topic = "My topic", name = "Name", avatarUrl = AN_AVATAR_URL)
+ val room = aMatrixRoom(topic = "My topic", displayName = "Name", avatarUrl = AN_AVATAR_URL)
val presenter = createRoomDetailsEditPresenter(room)
@@ -487,7 +487,7 @@ class RoomDetailsEditPresenterTest {
@Test
fun `present - save processes and sets avatar when processor returns successfully`() = runTest {
- val room = aMatrixRoom(topic = "My topic", name = "Name", avatarUrl = AN_AVATAR_URL)
+ val room = aMatrixRoom(topic = "My topic", displayName = "Name", avatarUrl = AN_AVATAR_URL)
givenPickerReturnsFile()
@@ -511,7 +511,7 @@ class RoomDetailsEditPresenterTest {
@Test
fun `present - save does not set avatar data if processor fails`() = runTest {
- val room = aMatrixRoom(topic = "My topic", name = "Name", avatarUrl = AN_AVATAR_URL)
+ val room = aMatrixRoom(topic = "My topic", displayName = "Name", avatarUrl = AN_AVATAR_URL)
fakePickerProvider.givenResult(anotherAvatarUri)
fakeMediaPreProcessor.givenResult(Result.failure(Throwable("Oh no")))
@@ -538,7 +538,7 @@ class RoomDetailsEditPresenterTest {
@Test
fun `present - sets save action to failure if name update fails`() = runTest {
- val room = aMatrixRoom(topic = "My topic", name = "Name", avatarUrl = AN_AVATAR_URL).apply {
+ val room = aMatrixRoom(topic = "My topic", displayName = "Name", avatarUrl = AN_AVATAR_URL).apply {
givenSetNameResult(Result.failure(Throwable("!")))
}
@@ -547,7 +547,7 @@ class RoomDetailsEditPresenterTest {
@Test
fun `present - sets save action to failure if topic update fails`() = runTest {
- val room = aMatrixRoom(topic = "My topic", name = "Name", avatarUrl = AN_AVATAR_URL).apply {
+ val room = aMatrixRoom(topic = "My topic", displayName = "Name", avatarUrl = AN_AVATAR_URL).apply {
givenSetTopicResult(Result.failure(Throwable("!")))
}
@@ -556,7 +556,7 @@ class RoomDetailsEditPresenterTest {
@Test
fun `present - sets save action to failure if removing avatar fails`() = runTest {
- val room = aMatrixRoom(topic = "My topic", name = "Name", avatarUrl = AN_AVATAR_URL).apply {
+ val room = aMatrixRoom(topic = "My topic", displayName = "Name", avatarUrl = AN_AVATAR_URL).apply {
givenRemoveAvatarResult(Result.failure(Throwable("!")))
}
@@ -567,7 +567,7 @@ class RoomDetailsEditPresenterTest {
fun `present - sets save action to failure if setting avatar fails`() = runTest {
givenPickerReturnsFile()
- val room = aMatrixRoom(topic = "My topic", name = "Name", avatarUrl = AN_AVATAR_URL).apply {
+ val room = aMatrixRoom(topic = "My topic", displayName = "Name", avatarUrl = AN_AVATAR_URL).apply {
givenUpdateAvatarResult(Result.failure(Throwable("!")))
}
@@ -578,7 +578,7 @@ class RoomDetailsEditPresenterTest {
fun `present - CancelSaveChanges resets save action state`() = runTest {
givenPickerReturnsFile()
- val room = aMatrixRoom(topic = "My topic", name = "Name", avatarUrl = AN_AVATAR_URL).apply {
+ val room = aMatrixRoom(topic = "My topic", displayName = "Name", avatarUrl = AN_AVATAR_URL).apply {
givenSetTopicResult(Result.failure(Throwable("!")))
}
diff --git a/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsViewTest.kt b/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsViewTest.kt
index f4e7ef1d60..febd213e0c 100644
--- a/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsViewTest.kt
+++ b/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsViewTest.kt
@@ -23,7 +23,6 @@ import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.performClick
import androidx.test.ext.junit.runners.AndroidJUnit4
-import io.element.android.libraries.matrix.api.room.RoomMember
import io.element.android.libraries.matrix.api.room.RoomNotificationMode
import io.element.android.libraries.testtags.TestTags
import io.element.android.libraries.ui.strings.CommonStrings
@@ -62,19 +61,6 @@ class RoomDetailsViewTest {
rule.setRoomDetailView(
onShareRoom = callback,
)
- rule.clickOn(R.string.screen_room_details_share_room_title)
- }
- }
-
- @Test
- fun `click on share member invokes expected callback`() {
- val state = aDmRoomDetailsState()
- val roomMember = (state.roomType as RoomDetailsType.Dm).roomMember
- ensureCalledOnceWithParam(roomMember) { callback ->
- rule.setRoomDetailView(
- state = aDmRoomDetailsState(),
- onShareMember = callback,
- )
rule.clickOn(CommonStrings.action_share)
}
}
@@ -112,9 +98,8 @@ class RoomDetailsViewTest {
}
}
- @Config(qualifiers = "h1024dp")
@Test
- fun `click on invite people invokes expected callback`() {
+ fun `click on invite invokes expected callback`() {
ensureCalledOnce { callback ->
rule.setRoomDetailView(
state = aRoomDetailsState(
@@ -123,7 +108,21 @@ class RoomDetailsViewTest {
),
invitePeople = callback,
)
- rule.clickOn(R.string.screen_room_details_invite_people_title)
+ rule.clickOn(CommonStrings.action_invite)
+ }
+ }
+
+ @Test
+ fun `click on call invokes expected callback`() {
+ ensureCalledOnce { callback ->
+ rule.setRoomDetailView(
+ state = aRoomDetailsState(
+ eventSink = EventsRecorder(expectEvents = false),
+ canInvite = true,
+ ),
+ onJoinCallClicked = callback,
+ )
+ rule.clickOn(CommonStrings.action_call)
}
}
@@ -251,13 +250,13 @@ private fun AndroidComposeTestRule.setRoomD
goBack: () -> Unit = EnsureNeverCalled(),
onActionClicked: (RoomDetailsAction) -> Unit = EnsureNeverCalledWithParam(),
onShareRoom: () -> Unit = EnsureNeverCalled(),
- onShareMember: (RoomMember) -> Unit = EnsureNeverCalledWithParam(),
openRoomMemberList: () -> Unit = EnsureNeverCalled(),
openRoomNotificationSettings: () -> Unit = EnsureNeverCalled(),
invitePeople: () -> Unit = EnsureNeverCalled(),
openAvatarPreview: (name: String, url: String) -> Unit = EnsureNeverCalledWithTwoParams(),
openPollHistory: () -> Unit = EnsureNeverCalled(),
openAdminSettings: () -> Unit = EnsureNeverCalled(),
+ onJoinCallClicked: () -> Unit = EnsureNeverCalled(),
) {
setContent {
RoomDetailsView(
@@ -265,13 +264,13 @@ private fun AndroidComposeTestRule.setRoomD
goBack = goBack,
onActionClicked = onActionClicked,
onShareRoom = onShareRoom,
- onShareMember = onShareMember,
openRoomMemberList = openRoomMemberList,
openRoomNotificationSettings = openRoomNotificationSettings,
invitePeople = invitePeople,
openAvatarPreview = openAvatarPreview,
openPollHistory = openPollHistory,
openAdminSettings = openAdminSettings,
+ onJoinCallClicked = onJoinCallClicked,
)
}
}
diff --git a/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/impl/blockuser/BlockUserDialogsTest.kt b/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/impl/blockuser/BlockUserDialogsTest.kt
deleted file mode 100644
index 5683b88c3c..0000000000
--- a/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/impl/blockuser/BlockUserDialogsTest.kt
+++ /dev/null
@@ -1,96 +0,0 @@
-/*
- * Copyright (c) 2024 New Vector Ltd
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-package io.element.android.features.roomdetails.impl.blockuser
-
-import androidx.activity.ComponentActivity
-import androidx.compose.ui.test.junit4.createAndroidComposeRule
-import androidx.test.ext.junit.runners.AndroidJUnit4
-import io.element.android.features.roomdetails.impl.R
-import io.element.android.features.roomdetails.impl.members.details.RoomMemberDetailsEvents
-import io.element.android.features.roomdetails.impl.members.details.RoomMemberDetailsState
-import io.element.android.features.roomdetails.impl.members.details.aRoomMemberDetailsState
-import io.element.android.libraries.ui.strings.CommonStrings
-import io.element.android.tests.testutils.EventsRecorder
-import io.element.android.tests.testutils.clickOn
-import org.junit.Rule
-import org.junit.Test
-import org.junit.runner.RunWith
-
-@RunWith(AndroidJUnit4::class)
-class BlockUserDialogsTest {
- @get:Rule val rule = createAndroidComposeRule()
-
- @Test
- fun `confirm block user emit expected Event`() {
- val eventsRecorder = EventsRecorder()
- rule.setContent {
- BlockUserDialogs(
- state = aRoomMemberDetailsState(
- displayConfirmationDialog = RoomMemberDetailsState.ConfirmationDialog.Block,
- eventSink = eventsRecorder,
- )
- )
- }
- rule.clickOn(R.string.screen_dm_details_block_alert_action)
- eventsRecorder.assertSingle(RoomMemberDetailsEvents.BlockUser(false))
- }
-
- @Test
- fun `cancel block user emit expected Event`() {
- val eventsRecorder = EventsRecorder()
- rule.setContent {
- BlockUserDialogs(
- state = aRoomMemberDetailsState(
- displayConfirmationDialog = RoomMemberDetailsState.ConfirmationDialog.Block,
- eventSink = eventsRecorder,
- )
- )
- }
- rule.clickOn(CommonStrings.action_cancel)
- eventsRecorder.assertSingle(RoomMemberDetailsEvents.ClearConfirmationDialog)
- }
-
- @Test
- fun `confirm unblock user emit expected Event`() {
- val eventsRecorder = EventsRecorder()
- rule.setContent {
- BlockUserDialogs(
- state = aRoomMemberDetailsState(
- displayConfirmationDialog = RoomMemberDetailsState.ConfirmationDialog.Unblock,
- eventSink = eventsRecorder,
- )
- )
- }
- rule.clickOn(R.string.screen_dm_details_unblock_alert_action)
- eventsRecorder.assertSingle(RoomMemberDetailsEvents.UnblockUser(false))
- }
-
- @Test
- fun `cancel unblock user emit expected Event`() {
- val eventsRecorder = EventsRecorder()
- rule.setContent {
- BlockUserDialogs(
- state = aRoomMemberDetailsState(
- displayConfirmationDialog = RoomMemberDetailsState.ConfirmationDialog.Unblock,
- eventSink = eventsRecorder,
- )
- )
- }
- rule.clickOn(CommonStrings.action_cancel)
- eventsRecorder.assertSingle(RoomMemberDetailsEvents.ClearConfirmationDialog)
- }
-}
diff --git a/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/members/details/RoomMemberDetailsPresenterTests.kt b/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/members/details/RoomMemberDetailsPresenterTests.kt
index e1f0d76ec0..89b7335fef 100644
--- a/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/members/details/RoomMemberDetailsPresenterTests.kt
+++ b/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/members/details/RoomMemberDetailsPresenterTests.kt
@@ -18,24 +18,26 @@ package io.element.android.features.roomdetails.members.details
import app.cash.molecule.RecompositionMode
import app.cash.molecule.moleculeFlow
+import app.cash.turbine.ReceiveTurbine
import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
import io.element.android.features.createroom.api.StartDMAction
import io.element.android.features.createroom.test.FakeStartDMAction
import io.element.android.features.roomdetails.aMatrixRoom
import io.element.android.features.roomdetails.impl.members.aRoomMember
-import io.element.android.features.roomdetails.impl.members.details.RoomMemberDetailsEvents
import io.element.android.features.roomdetails.impl.members.details.RoomMemberDetailsPresenter
-import io.element.android.features.roomdetails.impl.members.details.RoomMemberDetailsState
+import io.element.android.features.userprofile.shared.UserProfileEvents
+import io.element.android.features.userprofile.shared.UserProfileState
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.matrix.api.MatrixClient
+import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.room.MatrixRoom
import io.element.android.libraries.matrix.api.room.MatrixRoomMembersState
-import io.element.android.libraries.matrix.api.room.RoomMember
import io.element.android.libraries.matrix.test.A_ROOM_ID
import io.element.android.libraries.matrix.test.A_THROWABLE
import io.element.android.libraries.matrix.test.FakeMatrixClient
+import io.element.android.libraries.matrix.ui.components.aMatrixUser
import io.element.android.tests.testutils.WarmUpRule
import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.ExperimentalCoroutinesApi
@@ -58,16 +60,18 @@ class RoomMemberDetailsPresenterTests {
}
val presenter = createRoomMemberDetailsPresenter(
room = room,
- roomMember = roomMember
+ roomMemberId = roomMember.userId
)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
- val initialState = awaitItem()
- assertThat(initialState.userId).isEqualTo(roomMember.userId.value)
+ val initialState = awaitFirstItem()
+ assertThat(initialState.userId).isEqualTo(roomMember.userId)
assertThat(initialState.userName).isEqualTo(roomMember.displayName)
assertThat(initialState.avatarUrl).isEqualTo(roomMember.avatarUrl)
assertThat(initialState.isBlocked).isEqualTo(AsyncData.Success(roomMember.isIgnored))
+ assertThat(initialState.dmRoomId).isEqualTo(A_ROOM_ID)
+ assertThat(initialState.canCall).isFalse()
skipItems(1)
val loadedState = awaitItem()
assertThat(loadedState.userName).isEqualTo("A custom name")
@@ -85,12 +89,12 @@ class RoomMemberDetailsPresenterTests {
}
val presenter = createRoomMemberDetailsPresenter(
room = room,
- roomMember = roomMember
+ roomMemberId = roomMember.userId
)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
- val initialState = awaitItem()
+ val initialState = awaitFirstItem()
assertThat(initialState.userName).isEqualTo(roomMember.displayName)
assertThat(initialState.avatarUrl).isEqualTo(roomMember.avatarUrl)
@@ -108,12 +112,12 @@ class RoomMemberDetailsPresenterTests {
}
val presenter = createRoomMemberDetailsPresenter(
room = room,
- roomMember = roomMember
+ roomMemberId = roomMember.userId
)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
- val initialState = awaitItem()
+ val initialState = awaitFirstItem()
assertThat(initialState.userName).isEqualTo(roomMember.displayName)
assertThat(initialState.avatarUrl).isEqualTo(roomMember.avatarUrl)
@@ -121,19 +125,46 @@ class RoomMemberDetailsPresenterTests {
}
}
+ @Test
+ fun `present - will fallback to user profile if user is not a member of the room`() = runTest {
+ val bobProfile = aMatrixUser("@bob:server.org", "Bob", avatarUrl = "anAvatarUrl")
+ val room = aMatrixRoom().apply {
+ givenUserDisplayNameResult(Result.failure(Exception("Not a member!")))
+ givenUserAvatarUrlResult(Result.failure(Exception("Not a member!")))
+ }
+ val client = FakeMatrixClient().apply {
+ givenGetProfileResult(bobProfile.userId, Result.success(bobProfile))
+ }
+ val presenter = createRoomMemberDetailsPresenter(
+ client = client,
+ room = room,
+ roomMemberId = UserId("@bob:server.org")
+ )
+ moleculeFlow(RecompositionMode.Immediate) {
+ presenter.present()
+ }.test {
+ skipItems(2)
+ val initialState = awaitFirstItem()
+ assertThat(initialState.userName).isEqualTo("Bob")
+ assertThat(initialState.avatarUrl).isEqualTo("anAvatarUrl")
+
+ ensureAllEventsConsumed()
+ }
+ }
+
@Test
fun `present - BlockUser needing confirmation displays confirmation dialog`() = runTest {
val presenter = createRoomMemberDetailsPresenter()
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
- val initialState = awaitItem()
- initialState.eventSink(RoomMemberDetailsEvents.BlockUser(needsConfirmation = true))
+ val initialState = awaitFirstItem()
+ initialState.eventSink(UserProfileEvents.BlockUser(needsConfirmation = true))
val dialogState = awaitItem()
- assertThat(dialogState.displayConfirmationDialog).isEqualTo(RoomMemberDetailsState.ConfirmationDialog.Block)
+ assertThat(dialogState.displayConfirmationDialog).isEqualTo(UserProfileState.ConfirmationDialog.Block)
- dialogState.eventSink(RoomMemberDetailsEvents.ClearConfirmationDialog)
+ dialogState.eventSink(UserProfileEvents.ClearConfirmationDialog)
assertThat(awaitItem().displayConfirmationDialog).isNull()
ensureAllEventsConsumed()
@@ -142,17 +173,24 @@ class RoomMemberDetailsPresenterTests {
@Test
fun `present - BlockUser and UnblockUser without confirmation change the 'blocked' state`() = runTest {
- val presenter = createRoomMemberDetailsPresenter()
+ val client = FakeMatrixClient()
+ val roomMember = aRoomMember()
+ val presenter = createRoomMemberDetailsPresenter(
+ client = client,
+ roomMemberId = roomMember.userId
+ )
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
- val initialState = awaitItem()
- initialState.eventSink(RoomMemberDetailsEvents.BlockUser(needsConfirmation = false))
+ val initialState = awaitFirstItem()
+ initialState.eventSink(UserProfileEvents.BlockUser(needsConfirmation = false))
assertThat(awaitItem().isBlocked.isLoading()).isTrue()
+ client.emitIgnoreUserList(listOf(roomMember.userId))
assertThat(awaitItem().isBlocked.dataOrNull()).isTrue()
- initialState.eventSink(RoomMemberDetailsEvents.UnblockUser(needsConfirmation = false))
+ initialState.eventSink(UserProfileEvents.UnblockUser(needsConfirmation = false))
assertThat(awaitItem().isBlocked.isLoading()).isTrue()
+ client.emitIgnoreUserList(listOf())
assertThat(awaitItem().isBlocked.dataOrNull()).isFalse()
}
}
@@ -165,30 +203,49 @@ class RoomMemberDetailsPresenterTests {
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
- val initialState = awaitItem()
- initialState.eventSink(RoomMemberDetailsEvents.BlockUser(needsConfirmation = false))
+ val initialState = awaitFirstItem()
+ initialState.eventSink(UserProfileEvents.BlockUser(needsConfirmation = false))
assertThat(awaitItem().isBlocked.isLoading()).isTrue()
val errorState = awaitItem()
assertThat(errorState.isBlocked.errorOrNull()).isEqualTo(A_THROWABLE)
// Clear error
- initialState.eventSink(RoomMemberDetailsEvents.ClearBlockUserError)
+ initialState.eventSink(UserProfileEvents.ClearBlockUserError)
assertThat(awaitItem().isBlocked).isEqualTo(AsyncData.Success(false))
}
}
+ @Test
+ fun `present - UnblockUser with error`() = runTest {
+ val matrixClient = FakeMatrixClient()
+ matrixClient.givenUnignoreUserResult(Result.failure(A_THROWABLE))
+ val presenter = createRoomMemberDetailsPresenter(client = matrixClient)
+ moleculeFlow(RecompositionMode.Immediate) {
+ presenter.present()
+ }.test {
+ val initialState = awaitFirstItem()
+ initialState.eventSink(UserProfileEvents.UnblockUser(needsConfirmation = false))
+ assertThat(awaitItem().isBlocked.isLoading()).isTrue()
+ val errorState = awaitItem()
+ assertThat(errorState.isBlocked.errorOrNull()).isEqualTo(A_THROWABLE)
+ // Clear error
+ initialState.eventSink(UserProfileEvents.ClearBlockUserError)
+ assertThat(awaitItem().isBlocked).isEqualTo(AsyncData.Success(true))
+ }
+ }
+
@Test
fun `present - UnblockUser needing confirmation displays confirmation dialog`() = runTest {
val presenter = createRoomMemberDetailsPresenter()
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
- val initialState = awaitItem()
- initialState.eventSink(RoomMemberDetailsEvents.UnblockUser(needsConfirmation = true))
+ val initialState = awaitFirstItem()
+ initialState.eventSink(UserProfileEvents.UnblockUser(needsConfirmation = true))
val dialogState = awaitItem()
- assertThat(dialogState.displayConfirmationDialog).isEqualTo(RoomMemberDetailsState.ConfirmationDialog.Unblock)
+ assertThat(dialogState.displayConfirmationDialog).isEqualTo(UserProfileState.ConfirmationDialog.Unblock)
- dialogState.eventSink(RoomMemberDetailsEvents.ClearConfirmationDialog)
+ dialogState.eventSink(UserProfileEvents.ClearConfirmationDialog)
assertThat(awaitItem().displayConfirmationDialog).isNull()
ensureAllEventsConsumed()
@@ -202,25 +259,25 @@ class RoomMemberDetailsPresenterTests {
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
- val initialState = awaitItem()
+ val initialState = awaitFirstItem()
assertThat(initialState.startDmActionState).isInstanceOf(AsyncAction.Uninitialized::class.java)
val startDMSuccessResult = AsyncAction.Success(A_ROOM_ID)
val startDMFailureResult = AsyncAction.Failure(A_THROWABLE)
// Failure
startDMAction.givenExecuteResult(startDMFailureResult)
- initialState.eventSink(RoomMemberDetailsEvents.StartDM)
+ initialState.eventSink(UserProfileEvents.StartDM)
assertThat(awaitItem().startDmActionState).isInstanceOf(AsyncAction.Loading::class.java)
awaitItem().also { state ->
assertThat(state.startDmActionState).isEqualTo(startDMFailureResult)
- state.eventSink(RoomMemberDetailsEvents.ClearStartDMState)
+ state.eventSink(UserProfileEvents.ClearStartDMState)
}
// Success
startDMAction.givenExecuteResult(startDMSuccessResult)
awaitItem().also { state ->
assertThat(state.startDmActionState).isEqualTo(AsyncAction.Uninitialized)
- state.eventSink(RoomMemberDetailsEvents.StartDM)
+ state.eventSink(UserProfileEvents.StartDM)
}
assertThat(awaitItem().startDmActionState).isInstanceOf(AsyncAction.Loading::class.java)
awaitItem().also { state ->
@@ -229,14 +286,19 @@ class RoomMemberDetailsPresenterTests {
}
}
+ private suspend fun ReceiveTurbine.awaitFirstItem(): T {
+ skipItems(1)
+ return awaitItem()
+ }
+
private fun createRoomMemberDetailsPresenter(
client: MatrixClient = FakeMatrixClient(),
room: MatrixRoom = aMatrixRoom(),
- roomMember: RoomMember = aRoomMember(),
+ roomMemberId: UserId = UserId("@alice:server.org"),
startDMAction: StartDMAction = FakeStartDMAction()
): RoomMemberDetailsPresenter {
return RoomMemberDetailsPresenter(
- roomMemberId = roomMember.userId,
+ roomMemberId = roomMemberId,
client = client,
room = room,
startDMAction = startDMAction
diff --git a/features/roomdirectory/api/src/main/kotlin/io/element/android/features/roomdirectory/api/RoomDescription.kt b/features/roomdirectory/api/src/main/kotlin/io/element/android/features/roomdirectory/api/RoomDescription.kt
index a27f413e9b..909c34f959 100644
--- a/features/roomdirectory/api/src/main/kotlin/io/element/android/features/roomdirectory/api/RoomDescription.kt
+++ b/features/roomdirectory/api/src/main/kotlin/io/element/android/features/roomdirectory/api/RoomDescription.kt
@@ -20,6 +20,7 @@ import android.os.Parcelable
import androidx.compose.runtime.Immutable
import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
+import io.element.android.libraries.matrix.api.core.RoomAlias
import io.element.android.libraries.matrix.api.core.RoomId
import kotlinx.parcelize.IgnoredOnParcel
import kotlinx.parcelize.Parcelize
@@ -29,7 +30,7 @@ import kotlinx.parcelize.Parcelize
data class RoomDescription(
val roomId: RoomId,
val name: String?,
- val alias: String?,
+ val alias: RoomAlias?,
val topic: String?,
val avatarUrl: String?,
val joinRule: JoinRule,
@@ -42,14 +43,14 @@ data class RoomDescription(
}
@IgnoredOnParcel
- val computedName = name ?: alias ?: roomId.value
+ val computedName = name ?: alias?.value ?: roomId.value
@IgnoredOnParcel
val computedDescription: String
get() {
return when {
topic != null -> topic
- name != null && alias != null -> alias
+ name != null && alias != null -> alias.value
name == null && alias == null -> ""
else -> roomId.value
}
diff --git a/features/roomdirectory/impl/src/main/kotlin/io/element/android/features/roomdirectory/impl/root/RoomDirectoryPresenter.kt b/features/roomdirectory/impl/src/main/kotlin/io/element/android/features/roomdirectory/impl/root/RoomDirectoryPresenter.kt
index 5d4cef55cb..4f9130613f 100644
--- a/features/roomdirectory/impl/src/main/kotlin/io/element/android/features/roomdirectory/impl/root/RoomDirectoryPresenter.kt
+++ b/features/roomdirectory/impl/src/main/kotlin/io/element/android/features/roomdirectory/impl/root/RoomDirectoryPresenter.kt
@@ -108,6 +108,7 @@ class RoomDirectoryPresenter @Inject constructor(
private fun CoroutineScope.joinRoom(state: MutableState>, roomId: RoomId) = launch {
state.runUpdatingState {
joinRoom(roomId)
+ .map { roomId }
}
}
diff --git a/features/roomdirectory/impl/src/main/kotlin/io/element/android/features/roomdirectory/impl/root/RoomDirectoryStateProvider.kt b/features/roomdirectory/impl/src/main/kotlin/io/element/android/features/roomdirectory/impl/root/RoomDirectoryStateProvider.kt
index e94271cfb8..bf682fc15b 100644
--- a/features/roomdirectory/impl/src/main/kotlin/io/element/android/features/roomdirectory/impl/root/RoomDirectoryStateProvider.kt
+++ b/features/roomdirectory/impl/src/main/kotlin/io/element/android/features/roomdirectory/impl/root/RoomDirectoryStateProvider.kt
@@ -19,6 +19,7 @@ package io.element.android.features.roomdirectory.impl.root
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.features.roomdirectory.api.RoomDescription
import io.element.android.libraries.architecture.AsyncAction
+import io.element.android.libraries.matrix.api.core.RoomAlias
import io.element.android.libraries.matrix.api.core.RoomId
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
@@ -69,7 +70,7 @@ fun aRoomDescriptionList(): ImmutableList {
roomId = RoomId("!exa:matrix.org"),
name = "Element X Android",
topic = "Element X is a secure, private and decentralized messenger.",
- alias = "#element-x-android:matrix.org",
+ alias = RoomAlias("#element-x-android:matrix.org"),
avatarUrl = null,
joinRule = RoomDescription.JoinRule.PUBLIC,
numberOfMembers = 2765,
@@ -78,7 +79,7 @@ fun aRoomDescriptionList(): ImmutableList {
roomId = RoomId("!exi:matrix.org"),
name = "Element X iOS",
topic = "Element X is a secure, private and decentralized messenger.",
- alias = "#element-x-ios:matrix.org",
+ alias = RoomAlias("#element-x-ios:matrix.org"),
avatarUrl = null,
joinRule = RoomDescription.JoinRule.UNKNOWN,
numberOfMembers = 356,
diff --git a/features/roomdirectory/impl/src/main/kotlin/io/element/android/features/roomdirectory/impl/root/RoomDirectoryView.kt b/features/roomdirectory/impl/src/main/kotlin/io/element/android/features/roomdirectory/impl/root/RoomDirectoryView.kt
index d6eeb65d7c..a79f0d6e70 100644
--- a/features/roomdirectory/impl/src/main/kotlin/io/element/android/features/roomdirectory/impl/root/RoomDirectoryView.kt
+++ b/features/roomdirectory/impl/src/main/kotlin/io/element/android/features/roomdirectory/impl/root/RoomDirectoryView.kt
@@ -21,12 +21,10 @@ import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.IntrinsicSize
import androidx.compose.foundation.layout.Row
-import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.consumeWindowInsets
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
-import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.wrapContentHeight
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
@@ -76,10 +74,6 @@ fun RoomDirectoryView(
onBackPressed: () -> Unit,
modifier: Modifier = Modifier,
) {
- fun joinRoom(roomDescription: RoomDescription) {
- state.eventSink(RoomDirectoryEvents.JoinRoom(roomDescription.roomId))
- }
-
Scaffold(
modifier = modifier,
topBar = {
@@ -89,7 +83,6 @@ fun RoomDirectoryView(
RoomDirectoryContent(
state = state,
onResultClicked = onResultClicked,
- onJoinClicked = ::joinRoom,
modifier = Modifier
.padding(padding)
.consumeWindowInsets(padding)
@@ -132,7 +125,6 @@ private fun RoomDirectoryTopBar(
private fun RoomDirectoryContent(
state: RoomDirectoryState,
onResultClicked: (RoomDescription) -> Unit,
- onJoinClicked: (RoomDescription) -> Unit,
modifier: Modifier = Modifier,
) {
Column(modifier = modifier) {
@@ -147,7 +139,6 @@ private fun RoomDirectoryContent(
displayLoadMoreIndicator = state.displayLoadMoreIndicator,
displayEmptyState = state.displayEmptyState,
onResultClicked = onResultClicked,
- onJoinClicked = onJoinClicked,
onReachedLoadMore = { state.eventSink(RoomDirectoryEvents.LoadMore) },
)
}
@@ -159,7 +150,6 @@ private fun RoomDirectoryRoomList(
displayLoadMoreIndicator: Boolean,
displayEmptyState: Boolean,
onResultClicked: (RoomDescription) -> Unit,
- onJoinClicked: (RoomDescription) -> Unit,
onReachedLoadMore: () -> Unit,
modifier: Modifier = Modifier,
) {
@@ -170,9 +160,6 @@ private fun RoomDirectoryRoomList(
onClick = {
onResultClicked(roomDescription)
},
- onJoinClick = {
- onJoinClicked(roomDescription)
- },
)
}
if (displayEmptyState) {
@@ -199,10 +186,10 @@ private fun RoomDirectoryRoomList(
@Composable
private fun LoadMoreIndicator(modifier: Modifier = Modifier) {
Box(
- modifier
- .fillMaxWidth()
- .wrapContentHeight()
- .padding(24.dp),
+ modifier
+ .fillMaxWidth()
+ .wrapContentHeight()
+ .padding(24.dp),
contentAlignment = Alignment.Center,
) {
CircularProgressIndicator(
@@ -268,7 +255,6 @@ private fun SearchTextField(
private fun RoomDirectoryRoomRow(
roomDescription: RoomDescription,
onClick: () -> Unit,
- onJoinClick: () -> Unit,
modifier: Modifier = Modifier,
) {
Row(
@@ -289,7 +275,7 @@ private fun RoomDirectoryRoomRow(
Column(
modifier = Modifier
.weight(1f)
- .padding(start = 16.dp)
+ .padding(horizontal = 16.dp)
) {
Text(
text = roomDescription.computedName,
@@ -306,19 +292,6 @@ private fun RoomDirectoryRoomRow(
overflow = TextOverflow.Ellipsis,
)
}
- if (roomDescription.canJoinOrKnock) {
- Text(
- text = stringResource(id = CommonStrings.action_join),
- color = ElementTheme.colors.textSuccessPrimary,
- modifier = Modifier
- .align(Alignment.CenterVertically)
- .clickable(onClick = onJoinClick)
- .padding(start = 4.dp, end = 12.dp)
- .testTag(TestTags.callToAction.value)
- )
- } else {
- Spacer(modifier = Modifier.width(24.dp))
- }
}
}
diff --git a/features/roomdirectory/impl/src/main/kotlin/io/element/android/features/roomdirectory/impl/root/di/JoinRoom.kt b/features/roomdirectory/impl/src/main/kotlin/io/element/android/features/roomdirectory/impl/root/di/JoinRoom.kt
index 983d2a1dd2..477b49e4f6 100644
--- a/features/roomdirectory/impl/src/main/kotlin/io/element/android/features/roomdirectory/impl/root/di/JoinRoom.kt
+++ b/features/roomdirectory/impl/src/main/kotlin/io/element/android/features/roomdirectory/impl/root/di/JoinRoom.kt
@@ -23,7 +23,7 @@ import io.element.android.libraries.matrix.api.core.RoomId
import javax.inject.Inject
interface JoinRoom {
- suspend operator fun invoke(roomId: RoomId): Result
+ suspend operator fun invoke(roomId: RoomId): Result
}
@ContributesBinding(SessionScope::class)
diff --git a/features/roomdirectory/impl/src/main/res/values-de/translations.xml b/features/roomdirectory/impl/src/main/res/values-de/translations.xml
index 8c60d845b2..ea81cdb862 100644
--- a/features/roomdirectory/impl/src/main/res/values-de/translations.xml
+++ b/features/roomdirectory/impl/src/main/res/values-de/translations.xml
@@ -1,5 +1,5 @@
"Fehler beim Laden"
- "Raumverzeichnis"
+ "Raum-Verzeichnis"
diff --git a/features/roomdirectory/impl/src/test/kotlin/io/element/android/features/roomdirectory/impl/root/FakeJoinRoom.kt b/features/roomdirectory/impl/src/test/kotlin/io/element/android/features/roomdirectory/impl/root/FakeJoinRoom.kt
index 3f4d17aefd..6251bcaefa 100644
--- a/features/roomdirectory/impl/src/test/kotlin/io/element/android/features/roomdirectory/impl/root/FakeJoinRoom.kt
+++ b/features/roomdirectory/impl/src/test/kotlin/io/element/android/features/roomdirectory/impl/root/FakeJoinRoom.kt
@@ -20,7 +20,7 @@ import io.element.android.features.roomdirectory.impl.root.di.JoinRoom
import io.element.android.libraries.matrix.api.core.RoomId
class FakeJoinRoom(
- var lambda: (RoomId) -> Result = { Result.success(it) }
+ var lambda: (RoomId) -> Result = { Result.success(Unit) }
) : JoinRoom {
override suspend fun invoke(roomId: RoomId) = lambda(roomId)
}
diff --git a/features/roomdirectory/impl/src/test/kotlin/io/element/android/features/roomdirectory/impl/root/RoomDirectoryPresenterTest.kt b/features/roomdirectory/impl/src/test/kotlin/io/element/android/features/roomdirectory/impl/root/RoomDirectoryPresenterTest.kt
index eefafc86e1..3af102146b 100644
--- a/features/roomdirectory/impl/src/test/kotlin/io/element/android/features/roomdirectory/impl/root/RoomDirectoryPresenterTest.kt
+++ b/features/roomdirectory/impl/src/test/kotlin/io/element/android/features/roomdirectory/impl/root/RoomDirectoryPresenterTest.kt
@@ -138,11 +138,11 @@ import org.junit.Test
@Test
fun `present - emit join room event`() = runTest {
- val joinRoomSuccess = lambdaRecorder { roomId: RoomId ->
- Result.success(roomId)
+ val joinRoomSuccess = lambdaRecorder { _: RoomId ->
+ Result.success(Unit)
}
val joinRoomFailure = lambdaRecorder { roomId: RoomId ->
- Result.failure(RuntimeException("Failed to join room $roomId"))
+ Result.failure(RuntimeException("Failed to join room $roomId"))
}
val fakeJoinRoom = FakeJoinRoom(joinRoomSuccess)
val presenter = createRoomDirectoryPresenter(joinRoom = fakeJoinRoom)
@@ -171,7 +171,7 @@ import org.junit.Test
roomDirectoryService: RoomDirectoryService = FakeRoomDirectoryService(
createRoomDirectoryListFactory = { FakeRoomDirectoryList() }
),
- joinRoom: JoinRoom = FakeJoinRoom { Result.success(it) },
+ joinRoom: JoinRoom = FakeJoinRoom { Result.success(Unit) },
): RoomDirectoryPresenter {
return RoomDirectoryPresenter(
dispatchers = testCoroutineDispatchers(),
diff --git a/features/roomdirectory/impl/src/test/kotlin/io/element/android/features/roomdirectory/impl/root/RoomDirectoryViewTest.kt b/features/roomdirectory/impl/src/test/kotlin/io/element/android/features/roomdirectory/impl/root/RoomDirectoryViewTest.kt
index ce949250f0..2535f6f421 100644
--- a/features/roomdirectory/impl/src/test/kotlin/io/element/android/features/roomdirectory/impl/root/RoomDirectoryViewTest.kt
+++ b/features/roomdirectory/impl/src/test/kotlin/io/element/android/features/roomdirectory/impl/root/RoomDirectoryViewTest.kt
@@ -19,8 +19,6 @@ package io.element.android.features.roomdirectory.impl.root
import androidx.activity.ComponentActivity
import androidx.compose.ui.test.junit4.AndroidComposeTestRule
import androidx.compose.ui.test.junit4.createAndroidComposeRule
-import androidx.compose.ui.test.onAllNodesWithTag
-import androidx.compose.ui.test.onFirst
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
@@ -74,19 +72,6 @@ class RoomDirectoryViewTest {
}
}
- @Test
- fun `clicking on room item join cta emits the expected Event`() {
- val eventsRecorder = EventsRecorder()
- val state = aRoomDirectoryState(
- roomDescriptions = aRoomDescriptionList(),
- eventSink = eventsRecorder,
- )
- rule.setRoomDirectoryView(state = state)
- val clickedRoom = state.roomDescriptions.first()
- rule.onAllNodesWithTag(TestTags.callToAction.value).onFirst().performClick()
- eventsRecorder.assertSingle(RoomDirectoryEvents.JoinRoom(clickedRoom.roomId))
- }
-
@Test
fun `composing load more indicator emits expected Event`() {
val eventsRecorder = EventsRecorder()
diff --git a/features/roomlist/api/src/main/kotlin/io/element/android/features/roomlist/api/RoomListEntryPoint.kt b/features/roomlist/api/src/main/kotlin/io/element/android/features/roomlist/api/RoomListEntryPoint.kt
index b5d1c1299f..f4e2ca7b01 100644
--- a/features/roomlist/api/src/main/kotlin/io/element/android/features/roomlist/api/RoomListEntryPoint.kt
+++ b/features/roomlist/api/src/main/kotlin/io/element/android/features/roomlist/api/RoomListEntryPoint.kt
@@ -34,7 +34,6 @@ interface RoomListEntryPoint : FeatureEntryPoint {
fun onCreateRoomClicked()
fun onSettingsClicked()
fun onSessionConfirmRecoveryKeyClicked()
- fun onInvitesClicked()
fun onRoomSettingsClicked(roomId: RoomId)
fun onReportBugClicked()
fun onRoomDirectorySearchClicked()
diff --git a/features/roomlist/impl/build.gradle.kts b/features/roomlist/impl/build.gradle.kts
index ff67ebe42a..e4eaf9b20e 100644
--- a/features/roomlist/impl/build.gradle.kts
+++ b/features/roomlist/impl/build.gradle.kts
@@ -75,7 +75,6 @@ dependencies {
testImplementation(projects.libraries.indicator.impl)
testImplementation(projects.libraries.permissions.noop)
testImplementation(projects.libraries.preferences.test)
- testImplementation(projects.features.invite.test)
testImplementation(projects.services.analytics.test)
testImplementation(projects.features.networkmonitor.test)
testImplementation(projects.tests.testutils)
diff --git a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/InvitesEntryPointView.kt b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/InvitesEntryPointView.kt
deleted file mode 100644
index ea2b5d71a3..0000000000
--- a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/InvitesEntryPointView.kt
+++ /dev/null
@@ -1,81 +0,0 @@
-/*
- * Copyright (c) 2023 New Vector Ltd
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-package io.element.android.features.roomlist.impl
-
-import androidx.compose.foundation.clickable
-import androidx.compose.foundation.layout.Box
-import androidx.compose.foundation.layout.Row
-import androidx.compose.foundation.layout.Spacer
-import androidx.compose.foundation.layout.fillMaxWidth
-import androidx.compose.foundation.layout.heightIn
-import androidx.compose.foundation.layout.padding
-import androidx.compose.foundation.layout.width
-import androidx.compose.foundation.shape.RoundedCornerShape
-import androidx.compose.runtime.Composable
-import androidx.compose.ui.Alignment
-import androidx.compose.ui.Modifier
-import androidx.compose.ui.draw.clip
-import androidx.compose.ui.res.stringResource
-import androidx.compose.ui.semantics.Role
-import androidx.compose.ui.tooling.preview.PreviewParameter
-import androidx.compose.ui.unit.dp
-import io.element.android.compound.theme.ElementTheme
-import io.element.android.libraries.designsystem.atomic.atoms.UnreadIndicatorAtom
-import io.element.android.libraries.designsystem.preview.ElementPreview
-import io.element.android.libraries.designsystem.preview.PreviewsDayNight
-import io.element.android.libraries.designsystem.theme.components.Text
-import io.element.android.libraries.ui.strings.CommonStrings
-
-@Composable
-fun InvitesEntryPointView(
- onInvitesClicked: () -> Unit,
- state: InvitesState,
- modifier: Modifier = Modifier,
-) {
- Box(
- modifier = modifier.fillMaxWidth(),
- ) {
- Row(
- modifier = Modifier
- .clip(RoundedCornerShape(8.dp))
- .clickable(role = Role.Button, onClick = onInvitesClicked)
- .padding(start = 24.dp, end = 16.dp)
- .align(Alignment.CenterEnd)
- .heightIn(min = 40.dp),
- verticalAlignment = Alignment.CenterVertically,
- ) {
- Text(
- text = stringResource(CommonStrings.action_invites_list),
- style = ElementTheme.typography.fontBodyMdMedium,
- )
-
- if (state == InvitesState.NewInvites) {
- Spacer(Modifier.width(8.dp))
- UnreadIndicatorAtom()
- }
- }
- }
-}
-
-@PreviewsDayNight
-@Composable
-internal fun InvitesEntryPointViewPreview(@PreviewParameter(InvitesStateProvider::class) state: InvitesState) = ElementPreview {
- InvitesEntryPointView(
- onInvitesClicked = {},
- state = state,
- )
-}
diff --git a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListContentStateProvider.kt b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListContentStateProvider.kt
index 284abb5d0f..5581615a2e 100644
--- a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListContentStateProvider.kt
+++ b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListContentStateProvider.kt
@@ -33,11 +33,9 @@ open class RoomListContentStateProvider : PreviewParameterProvider = aRoomListRoomSummaryList(),
) = RoomListContentState.Rooms(
- invitesState = invitesState,
securityBannerState = securityBannerState,
summaries = summaries,
)
@@ -46,6 +44,4 @@ internal fun aMigrationContentState() = RoomListContentState.Migration
internal fun aSkeletonContentState() = RoomListContentState.Skeleton(16)
-internal fun anEmptyContentState(
- invitesState: InvitesState = InvitesState.NoInvites,
-) = RoomListContentState.Empty(invitesState)
+internal fun anEmptyContentState() = RoomListContentState.Empty
diff --git a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListContextMenu.kt b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListContextMenu.kt
index 1ab51a5219..4d7a6b3855 100644
--- a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListContextMenu.kt
+++ b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListContextMenu.kt
@@ -24,6 +24,8 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.font.FontStyle
+import androidx.compose.ui.tooling.preview.PreviewParameter
import io.element.android.compound.theme.ElementTheme
import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.libraries.designsystem.components.list.ListItemContent
@@ -87,8 +89,9 @@ private fun RoomListModalBottomSheetContent(
ListItem(
headlineContent = {
Text(
- text = contextMenu.roomName,
+ text = contextMenu.roomName ?: stringResource(id = CommonStrings.common_no_room_name),
style = ElementTheme.typography.fontBodyLgMedium,
+ fontStyle = FontStyle.Italic.takeIf { contextMenu.roomName == null }
)
}
)
@@ -192,22 +195,11 @@ private fun RoomListModalBottomSheetContent(
// Remove this preview when the issue is fixed.
@PreviewsDayNight
@Composable
-internal fun RoomListModalBottomSheetContentPreview() = ElementPreview {
+internal fun RoomListModalBottomSheetContentPreview(
+ @PreviewParameter(RoomListStateContextMenuShownProvider::class) contextMenu: RoomListState.ContextMenu.Shown
+) = ElementPreview {
RoomListModalBottomSheetContent(
- contextMenu = aContextMenuShown(hasNewContent = true),
- onRoomMarkReadClicked = {},
- onRoomMarkUnreadClicked = {},
- onRoomSettingsClicked = {},
- onLeaveRoomClicked = {},
- onFavoriteChanged = {},
- )
-}
-
-@PreviewsDayNight
-@Composable
-internal fun RoomListModalBottomSheetContentForDmPreview() = ElementPreview {
- RoomListModalBottomSheetContent(
- contextMenu = aContextMenuShown(isDm = true),
+ contextMenu = contextMenu,
onRoomMarkReadClicked = {},
onRoomMarkUnreadClicked = {},
onRoomSettingsClicked = {},
diff --git a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListEvents.kt b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListEvents.kt
index cad5dd3311..aa1e8e2832 100644
--- a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListEvents.kt
+++ b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListEvents.kt
@@ -24,6 +24,8 @@ sealed interface RoomListEvents {
data object DismissRequestVerificationPrompt : RoomListEvents
data object DismissRecoveryKeyPrompt : RoomListEvents
data object ToggleSearchResults : RoomListEvents
+ data class AcceptInvite(val roomListRoomSummary: RoomListRoomSummary) : RoomListEvents
+ data class DeclineInvite(val roomListRoomSummary: RoomListRoomSummary) : RoomListEvents
data class ShowContextMenu(val roomListRoomSummary: RoomListRoomSummary) : RoomListEvents
sealed interface ContextMenuEvents : RoomListEvents
diff --git a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListNode.kt b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListNode.kt
index e9af66d331..912c45eb30 100644
--- a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListNode.kt
+++ b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListNode.kt
@@ -29,6 +29,7 @@ import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import im.vector.app.features.analytics.plan.MobileScreen
import io.element.android.anvilannotations.ContributesNode
+import io.element.android.features.invite.api.response.AcceptDeclineInviteView
import io.element.android.features.roomlist.api.RoomListEntryPoint
import io.element.android.features.roomlist.impl.components.RoomListMenuAction
import io.element.android.libraries.deeplink.usecase.InviteFriendsUseCase
@@ -43,6 +44,7 @@ class RoomListNode @AssistedInject constructor(
private val presenter: RoomListPresenter,
private val inviteFriendsUseCase: InviteFriendsUseCase,
private val analyticsService: AnalyticsService,
+ private val acceptDeclineInviteView: AcceptDeclineInviteView,
) : Node(buildContext, plugins = plugins) {
init {
lifecycle.subscribe(
@@ -68,10 +70,6 @@ class RoomListNode @AssistedInject constructor(
plugins().forEach { it.onSessionConfirmRecoveryKeyClicked() }
}
- private fun onInvitesClicked() {
- plugins().forEach { it.onInvitesClicked() }
- }
-
private fun onRoomSettingsClicked(roomId: RoomId) {
plugins().forEach { it.onRoomSettingsClicked(roomId) }
}
@@ -101,11 +99,17 @@ class RoomListNode @AssistedInject constructor(
onSettingsClicked = this::onOpenSettings,
onCreateRoomClicked = this::onCreateRoomClicked,
onConfirmRecoveryKeyClicked = this::onSessionConfirmRecoveryKeyClicked,
- onInvitesClicked = this::onInvitesClicked,
onRoomSettingsClicked = this::onRoomSettingsClicked,
onMenuActionClicked = { onMenuActionClicked(activity, it) },
onRoomDirectorySearchClicked = this::onRoomDirectorySearchClicked,
modifier = modifier,
- )
+ ) {
+ acceptDeclineInviteView.Render(
+ state = state.acceptDeclineInviteState,
+ onInviteAccepted = this::onRoomClicked,
+ onInviteDeclined = { },
+ modifier = Modifier
+ )
+ }
}
}
diff --git a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListPresenter.kt b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListPresenter.kt
index 0b5d07dc69..e610c33571 100644
--- a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListPresenter.kt
+++ b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListPresenter.kt
@@ -16,6 +16,7 @@
package io.element.android.features.roomlist.impl
+import androidx.annotation.VisibleForTesting
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState
@@ -32,15 +33,18 @@ import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
import im.vector.app.features.analytics.plan.Interaction
+import io.element.android.features.invite.api.response.AcceptDeclineInviteEvents
+import io.element.android.features.invite.api.response.AcceptDeclineInviteState
+import io.element.android.features.invite.api.response.InviteData
import io.element.android.features.leaveroom.api.LeaveRoomEvent
import io.element.android.features.leaveroom.api.LeaveRoomPresenter
import io.element.android.features.networkmonitor.api.NetworkMonitor
import io.element.android.features.networkmonitor.api.NetworkStatus
import io.element.android.features.preferences.api.store.SessionPreferencesStore
-import io.element.android.features.roomlist.impl.datasource.InviteStateDataSource
import io.element.android.features.roomlist.impl.datasource.RoomListDataSource
import io.element.android.features.roomlist.impl.filters.RoomListFiltersState
import io.element.android.features.roomlist.impl.migration.MigrationScreenState
+import io.element.android.features.roomlist.impl.model.RoomListRoomSummary
import io.element.android.features.roomlist.impl.search.RoomListSearchEvents
import io.element.android.features.roomlist.impl.search.RoomListSearchState
import io.element.android.libraries.architecture.AsyncData
@@ -79,7 +83,6 @@ class RoomListPresenter @Inject constructor(
private val client: MatrixClient,
private val networkMonitor: NetworkMonitor,
private val snackbarDispatcher: SnackbarDispatcher,
- private val inviteStateDataSource: InviteStateDataSource,
private val leaveRoomPresenter: LeaveRoomPresenter,
private val roomListDataSource: RoomListDataSource,
private val featureFlagService: FeatureFlagService,
@@ -89,6 +92,7 @@ class RoomListPresenter @Inject constructor(
private val migrationScreenPresenter: Presenter,
private val sessionPreferencesStore: SessionPreferencesStore,
private val analyticsService: AnalyticsService,
+ private val acceptDeclineInvitePresenter: Presenter,
) : Presenter {
private val encryptionService: EncryptionService = client.encryptionService()
private val syncService: SyncService = client.syncService()
@@ -101,6 +105,7 @@ class RoomListPresenter @Inject constructor(
val networkConnectionStatus by networkMonitor.connectivity.collectAsState()
val filtersState = filtersPresenter.present()
val searchState = searchPresenter.present()
+ val acceptDeclineInviteState = acceptDeclineInvitePresenter.present()
LaunchedEffect(Unit) {
roomListDataSource.launchIn(this)
@@ -131,6 +136,16 @@ class RoomListPresenter @Inject constructor(
is RoomListEvents.SetRoomIsFavorite -> coroutineScope.setRoomIsFavorite(event.roomId, event.isFavorite)
is RoomListEvents.MarkAsRead -> coroutineScope.markAsRead(event.roomId)
is RoomListEvents.MarkAsUnread -> coroutineScope.markAsUnread(event.roomId)
+ is RoomListEvents.AcceptInvite -> {
+ acceptDeclineInviteState.eventSink(
+ AcceptDeclineInviteEvents.AcceptInvite(event.roomListRoomSummary.toInviteData())
+ )
+ }
+ is RoomListEvents.DeclineInvite -> {
+ acceptDeclineInviteState.eventSink(
+ AcceptDeclineInviteEvents.DeclineInvite(event.roomListRoomSummary.toInviteData())
+ )
+ }
}
}
@@ -148,6 +163,7 @@ class RoomListPresenter @Inject constructor(
filtersState = filtersState,
searchState = searchState,
contentState = contentState,
+ acceptDeclineInviteState = acceptDeclineInviteState,
eventSink = ::handleEvents,
)
}
@@ -192,16 +208,11 @@ class RoomListPresenter @Inject constructor(
}
return when {
showMigration -> RoomListContentState.Migration
- showEmpty -> {
- val invitesState = inviteStateDataSource.inviteState()
- RoomListContentState.Empty(invitesState)
- }
+ showEmpty -> RoomListContentState.Empty
showSkeleton -> RoomListContentState.Skeleton(count = 16)
else -> {
- val invitesState = inviteStateDataSource.inviteState()
val securityBannerState by securityBannerState(securityBannerDismissed)
RoomListContentState.Rooms(
- invitesState = invitesState,
securityBannerState = securityBannerState,
summaries = roomSummaries.dataOrNull().orEmpty().toPersistentList()
)
@@ -283,3 +294,11 @@ class RoomListPresenter @Inject constructor(
client.roomListService.updateAllRoomsVisibleRange(extendedRange)
}
}
+
+@VisibleForTesting
+internal fun RoomListRoomSummary.toInviteData() = InviteData(
+ roomId = roomId,
+ // Note: `name` should not be null at this point, but just in case, fallback to the roomId
+ roomName = name ?: roomId.value,
+ isDirect = isDirect,
+)
diff --git a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListState.kt b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListState.kt
index 62f59b4eaa..c127dbdd2a 100644
--- a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListState.kt
+++ b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListState.kt
@@ -17,6 +17,7 @@
package io.element.android.features.roomlist.impl
import androidx.compose.runtime.Immutable
+import io.element.android.features.invite.api.response.AcceptDeclineInviteState
import io.element.android.features.leaveroom.api.LeaveRoomState
import io.element.android.features.roomlist.impl.filters.RoomListFiltersState
import io.element.android.features.roomlist.impl.model.RoomListRoomSummary
@@ -37,16 +38,17 @@ data class RoomListState(
val filtersState: RoomListFiltersState,
val searchState: RoomListSearchState,
val contentState: RoomListContentState,
+ val acceptDeclineInviteState: AcceptDeclineInviteState,
val eventSink: (RoomListEvents) -> Unit,
) {
- val displayFilters = filtersState.isFeatureEnabled && contentState is RoomListContentState.Rooms
+ val displayFilters = contentState is RoomListContentState.Rooms
val displayActions = contentState !is RoomListContentState.Migration
sealed interface ContextMenu {
data object Hidden : ContextMenu
data class Shown(
val roomId: RoomId,
- val roomName: String,
+ val roomName: String?,
val isDm: Boolean,
val isFavorite: Boolean,
val markAsUnreadFeatureFlagEnabled: Boolean,
@@ -70,9 +72,8 @@ enum class SecurityBannerState {
sealed interface RoomListContentState {
data object Migration : RoomListContentState
data class Skeleton(val count: Int) : RoomListContentState
- data class Empty(val invitesState: InvitesState) : RoomListContentState
+ data object Empty : RoomListContentState
data class Rooms(
- val invitesState: InvitesState,
val securityBannerState: SecurityBannerState,
val summaries: ImmutableList,
) : RoomListContentState
diff --git a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListStateContextMenuShownProvider.kt b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListStateContextMenuShownProvider.kt
new file mode 100644
index 0000000000..309dd9b5a0
--- /dev/null
+++ b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListStateContextMenuShownProvider.kt
@@ -0,0 +1,43 @@
+/*
+ * Copyright (c) 2023 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.element.android.features.roomlist.impl
+
+import androidx.compose.ui.tooling.preview.PreviewParameterProvider
+import io.element.android.libraries.matrix.api.core.RoomId
+
+open class RoomListStateContextMenuShownProvider : PreviewParameterProvider {
+ override val values: Sequence
+ get() = sequenceOf(
+ aContextMenuShown(hasNewContent = true),
+ aContextMenuShown(isDm = true),
+ aContextMenuShown(roomName = null)
+ )
+}
+
+internal fun aContextMenuShown(
+ roomName: String? = "aRoom",
+ isDm: Boolean = false,
+ hasNewContent: Boolean = false,
+ isFavorite: Boolean = false,
+) = RoomListState.ContextMenu.Shown(
+ roomId = RoomId("!aRoom:aDomain"),
+ roomName = roomName,
+ isDm = isDm,
+ markAsUnreadFeatureFlagEnabled = true,
+ hasNewContent = hasNewContent,
+ isFavorite = isFavorite,
+)
diff --git a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListStateProvider.kt b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListStateProvider.kt
index c80430f9fe..fe000c948e 100644
--- a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListStateProvider.kt
+++ b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListStateProvider.kt
@@ -17,18 +17,21 @@
package io.element.android.features.roomlist.impl
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
+import io.element.android.features.invite.api.response.AcceptDeclineInviteState
+import io.element.android.features.invite.api.response.anAcceptDeclineInviteState
import io.element.android.features.leaveroom.api.LeaveRoomState
import io.element.android.features.leaveroom.api.aLeaveRoomState
import io.element.android.features.roomlist.impl.filters.RoomListFiltersState
import io.element.android.features.roomlist.impl.filters.aRoomListFiltersState
import io.element.android.features.roomlist.impl.model.RoomListRoomSummary
+import io.element.android.features.roomlist.impl.model.RoomSummaryDisplayType
import io.element.android.features.roomlist.impl.model.aRoomListRoomSummary
+import io.element.android.features.roomlist.impl.model.anInviteSender
import io.element.android.features.roomlist.impl.search.RoomListSearchState
import io.element.android.features.roomlist.impl.search.aRoomListSearchState
import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarMessage
-import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.ui.strings.CommonStrings
@@ -41,8 +44,7 @@ open class RoomListStateProvider : PreviewParameterProvider {
aRoomListState(),
aRoomListState(snackbarMessage = SnackbarMessage(CommonStrings.common_verification_complete)),
aRoomListState(hasNetworkConnection = false),
- aRoomListState(contentState = aRoomsContentState(invitesState = InvitesState.SeenInvites)),
- aRoomListState(contentState = aRoomsContentState(invitesState = InvitesState.NewInvites)),
+ aRoomListState(contextMenu = aContextMenuShown(roomName = null)),
aRoomListState(contextMenu = aContextMenuShown(roomName = "A nice room name")),
aRoomListState(contextMenu = aContextMenuShown(isFavorite = true)),
aRoomListState(contentState = aRoomsContentState(securityBannerState = SecurityBannerState.RecoveryKeyConfirmation)),
@@ -50,7 +52,6 @@ open class RoomListStateProvider : PreviewParameterProvider {
aRoomListState(contentState = aSkeletonContentState()),
aRoomListState(matrixUser = MatrixUser(userId = UserId("@id:domain")), contentState = aMigrationContentState()),
aRoomListState(searchState = aRoomListSearchState(isSearchActive = true, query = "Test")),
- aRoomListState(filtersState = aRoomListFiltersState(isFeatureEnabled = true)),
)
}
@@ -62,8 +63,9 @@ internal fun aRoomListState(
contextMenu: RoomListState.ContextMenu = RoomListState.ContextMenu.Hidden,
leaveRoomState: LeaveRoomState = aLeaveRoomState(),
searchState: RoomListSearchState = aRoomListSearchState(),
- filtersState: RoomListFiltersState = aRoomListFiltersState(isFeatureEnabled = false),
+ filtersState: RoomListFiltersState = aRoomListFiltersState(),
contentState: RoomListContentState = aRoomsContentState(),
+ acceptDeclineInviteState: AcceptDeclineInviteState = anAcceptDeclineInviteState(),
eventSink: (RoomListEvents) -> Unit = {}
) = RoomListState(
matrixUser = matrixUser,
@@ -75,11 +77,19 @@ internal fun aRoomListState(
filtersState = filtersState,
searchState = searchState,
contentState = contentState,
+ acceptDeclineInviteState = acceptDeclineInviteState,
eventSink = eventSink,
)
internal fun aRoomListRoomSummaryList(): ImmutableList {
return persistentListOf(
+ aRoomListRoomSummary(
+ name = "Room Invited",
+ avatarData = AvatarData("!roomId", "Room with Alice and Bob", size = AvatarSize.RoomListItem),
+ id = "!roomId:domain",
+ inviteSender = anInviteSender(),
+ displayType = RoomSummaryDisplayType.INVITE,
+ ),
aRoomListRoomSummary(
name = "Room",
numberOfUnreadMessages = 1,
@@ -98,25 +108,11 @@ internal fun aRoomListRoomSummaryList(): ImmutableList {
),
aRoomListRoomSummary(
id = "!roomId3:domain",
- isPlaceholder = true,
+ displayType = RoomSummaryDisplayType.PLACEHOLDER,
),
aRoomListRoomSummary(
id = "!roomId4:domain",
- isPlaceholder = true,
+ displayType = RoomSummaryDisplayType.PLACEHOLDER,
),
)
}
-
-internal fun aContextMenuShown(
- roomName: String = "aRoom",
- isDm: Boolean = false,
- hasNewContent: Boolean = false,
- isFavorite: Boolean = false,
-) = RoomListState.ContextMenu.Shown(
- roomId = RoomId("!aRoom:aDomain"),
- roomName = roomName,
- isDm = isDm,
- markAsUnreadFeatureFlagEnabled = true,
- hasNewContent = hasNewContent,
- isFavorite = isFavorite,
-)
diff --git a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListView.kt b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListView.kt
index 881d06411e..22506fe49b 100644
--- a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListView.kt
+++ b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListView.kt
@@ -55,23 +55,17 @@ fun RoomListView(
onSettingsClicked: () -> Unit,
onConfirmRecoveryKeyClicked: () -> Unit,
onCreateRoomClicked: () -> Unit,
- onInvitesClicked: () -> Unit,
onRoomSettingsClicked: (roomId: RoomId) -> Unit,
onMenuActionClicked: (RoomListMenuAction) -> Unit,
onRoomDirectorySearchClicked: () -> Unit,
modifier: Modifier = Modifier,
+ acceptDeclineInviteView: @Composable () -> Unit,
) {
ConnectivityIndicatorContainer(
modifier = modifier,
isOnline = state.hasNetworkConnection,
) { topPadding ->
Box {
- fun onRoomLongClicked(
- roomListRoomSummary: RoomListRoomSummary
- ) {
- state.eventSink(RoomListEvents.ShowContextMenu(roomListRoomSummary))
- }
-
if (state.contextMenu is RoomListState.ContextMenu.Shown) {
RoomListContextMenu(
contextMenu = state.contextMenu,
@@ -83,21 +77,19 @@ fun RoomListView(
LeaveRoomView(state = state.leaveRoomState)
RoomListScaffold(
- modifier = Modifier.padding(top = topPadding),
state = state,
onConfirmRecoveryKeyClicked = onConfirmRecoveryKeyClicked,
onRoomClicked = onRoomClicked,
- onRoomLongClicked = { onRoomLongClicked(it) },
onOpenSettings = onSettingsClicked,
onCreateRoomClicked = onCreateRoomClicked,
- onInvitesClicked = onInvitesClicked,
onMenuActionClicked = onMenuActionClicked,
+ modifier = Modifier.padding(top = topPadding),
)
// This overlaid view will only be visible when state.displaySearchResults is true
RoomListSearchView(
state = state.searchState,
+ eventSink = state.eventSink,
onRoomClicked = onRoomClicked,
- onRoomLongClicked = { onRoomLongClicked(it) },
onRoomDirectorySearchClicked = onRoomDirectorySearchClicked,
modifier = Modifier
.statusBarsPadding()
@@ -105,6 +97,7 @@ fun RoomListView(
.fillMaxSize()
.background(MaterialTheme.colorScheme.background)
)
+ acceptDeclineInviteView()
}
}
}
@@ -115,10 +108,8 @@ private fun RoomListScaffold(
state: RoomListState,
onConfirmRecoveryKeyClicked: () -> Unit,
onRoomClicked: (RoomId) -> Unit,
- onRoomLongClicked: (RoomListRoomSummary) -> Unit,
onOpenSettings: () -> Unit,
onCreateRoomClicked: () -> Unit,
- onInvitesClicked: () -> Unit,
onMenuActionClicked: (RoomListMenuAction) -> Unit,
modifier: Modifier = Modifier,
) {
@@ -153,9 +144,7 @@ private fun RoomListScaffold(
eventSink = state.eventSink,
onConfirmRecoveryKeyClicked = onConfirmRecoveryKeyClicked,
onRoomClicked = ::onRoomClicked,
- onRoomLongClicked = onRoomLongClicked,
onCreateRoomClicked = onCreateRoomClicked,
- onInvitesClicked = onInvitesClicked,
modifier = Modifier
.padding(padding)
.consumeWindowInsets(padding)
@@ -180,7 +169,7 @@ private fun RoomListScaffold(
)
}
-internal fun RoomListRoomSummary.contentType() = isPlaceholder
+internal fun RoomListRoomSummary.contentType() = displayType.ordinal
@PreviewsDayNight
@Composable
@@ -191,9 +180,9 @@ internal fun RoomListViewPreview(@PreviewParameter(RoomListStateProvider::class)
onSettingsClicked = {},
onConfirmRecoveryKeyClicked = {},
onCreateRoomClicked = {},
- onInvitesClicked = {},
onRoomSettingsClicked = {},
onMenuActionClicked = {},
onRoomDirectorySearchClicked = {},
+ acceptDeclineInviteView = {},
)
}
diff --git a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/components/RoomListContentView.kt b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/components/RoomListContentView.kt
index 552ff008a0..1b1c760889 100644
--- a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/components/RoomListContentView.kt
+++ b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/components/RoomListContentView.kt
@@ -44,8 +44,6 @@ import androidx.compose.ui.unit.Velocity
import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme
import io.element.android.compound.tokens.generated.CompoundIcons
-import io.element.android.features.roomlist.impl.InvitesEntryPointView
-import io.element.android.features.roomlist.impl.InvitesState
import io.element.android.features.roomlist.impl.R
import io.element.android.features.roomlist.impl.RoomListContentState
import io.element.android.features.roomlist.impl.RoomListContentStateProvider
@@ -75,9 +73,7 @@ fun RoomListContentView(
eventSink: (RoomListEvents) -> Unit,
onConfirmRecoveryKeyClicked: () -> Unit,
onRoomClicked: (RoomListRoomSummary) -> Unit,
- onRoomLongClicked: (RoomListRoomSummary) -> Unit,
onCreateRoomClicked: () -> Unit,
- onInvitesClicked: () -> Unit,
modifier: Modifier = Modifier,
) {
Box(modifier = modifier) {
@@ -92,8 +88,6 @@ fun RoomListContentView(
}
is RoomListContentState.Empty -> {
EmptyView(
- state = contentState,
- onInvitesClicked = onInvitesClicked,
onCreateRoomClicked = onCreateRoomClicked,
)
}
@@ -104,8 +98,6 @@ fun RoomListContentView(
eventSink = eventSink,
onConfirmRecoveryKeyClicked = onConfirmRecoveryKeyClicked,
onRoomClicked = onRoomClicked,
- onRoomLongClicked = onRoomLongClicked,
- onInvitesClicked = onInvitesClicked,
)
}
}
@@ -128,30 +120,21 @@ private fun SkeletonView(count: Int, modifier: Modifier = Modifier) {
@Composable
private fun EmptyView(
- state: RoomListContentState.Empty,
onCreateRoomClicked: () -> Unit,
- onInvitesClicked: () -> Unit,
modifier: Modifier = Modifier
) {
- Box(
+ EmptyScaffold(
+ title = R.string.screen_roomlist_empty_title,
+ subtitle = R.string.screen_roomlist_empty_message,
+ action = {
+ Button(
+ text = stringResource(CommonStrings.action_start_chat),
+ leadingIcon = IconSource.Vector(CompoundIcons.Compose()),
+ onClick = onCreateRoomClicked,
+ )
+ },
modifier = modifier.fillMaxSize(),
- ) {
- if (state.invitesState != InvitesState.NoInvites) {
- InvitesEntryPointView(onInvitesClicked, state.invitesState)
- }
- EmptyScaffold(
- title = R.string.screen_roomlist_empty_title,
- subtitle = R.string.screen_roomlist_empty_message,
- action = {
- Button(
- text = stringResource(CommonStrings.action_start_chat),
- leadingIcon = IconSource.Vector(CompoundIcons.Compose()),
- onClick = onCreateRoomClicked,
- )
- },
- modifier = Modifier.fillMaxSize(),
- )
- }
+ )
}
@Composable
@@ -161,8 +144,6 @@ private fun RoomsView(
eventSink: (RoomListEvents) -> Unit,
onConfirmRecoveryKeyClicked: () -> Unit,
onRoomClicked: (RoomListRoomSummary) -> Unit,
- onRoomLongClicked: (RoomListRoomSummary) -> Unit,
- onInvitesClicked: () -> Unit,
modifier: Modifier = Modifier,
) {
if (state.summaries.isEmpty() && filtersState.hasAnyFilterSelected) {
@@ -176,8 +157,6 @@ private fun RoomsView(
eventSink = eventSink,
onConfirmRecoveryKeyClicked = onConfirmRecoveryKeyClicked,
onRoomClicked = onRoomClicked,
- onRoomLongClicked = onRoomLongClicked,
- onInvitesClicked = onInvitesClicked,
modifier = modifier.fillMaxSize(),
)
}
@@ -189,8 +168,6 @@ private fun RoomsViewList(
eventSink: (RoomListEvents) -> Unit,
onConfirmRecoveryKeyClicked: () -> Unit,
onRoomClicked: (RoomListRoomSummary) -> Unit,
- onRoomLongClicked: (RoomListRoomSummary) -> Unit,
- onInvitesClicked: () -> Unit,
modifier: Modifier = Modifier,
) {
val lazyListState = rememberLazyListState()
@@ -228,11 +205,6 @@ private fun RoomsViewList(
else -> Unit
}
- if (state.invitesState != InvitesState.NoInvites) {
- item {
- InvitesEntryPointView(onInvitesClicked, state.invitesState)
- }
- }
// Note: do not use a key for the LazyColumn, or the scroll will not behave as expected if a room
// is moved to the top of the list.
itemsIndexed(
@@ -242,7 +214,7 @@ private fun RoomsViewList(
RoomSummaryRow(
room = room,
onClick = onRoomClicked,
- onLongClick = onRoomLongClicked,
+ eventSink = eventSink,
)
if (index != state.summaries.lastIndex) {
HorizontalDivider()
@@ -305,8 +277,6 @@ internal fun RoomListContentViewPreview(@PreviewParameter(RoomListContentStatePr
eventSink = {},
onConfirmRecoveryKeyClicked = {},
onRoomClicked = {},
- onRoomLongClicked = {},
onCreateRoomClicked = {},
- onInvitesClicked = {}
)
}
diff --git a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/components/RoomSummaryRow.kt b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/components/RoomSummaryRow.kt
index 78174f2acf..a14140297c 100644
--- a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/components/RoomSummaryRow.kt
+++ b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/components/RoomSummaryRow.kt
@@ -20,15 +20,18 @@ import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Arrangement.Absolute.spacedBy
import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.IntrinsicSize
import androidx.compose.foundation.layout.Row
-import androidx.compose.foundation.layout.RowScope
+import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
import androidx.compose.material.ripple.rememberRipple
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
@@ -36,26 +39,38 @@ import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.AnnotatedString
+import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme
import io.element.android.compound.tokens.generated.CompoundIcons
+import io.element.android.features.roomlist.impl.RoomListEvents
import io.element.android.features.roomlist.impl.model.RoomListRoomSummary
import io.element.android.features.roomlist.impl.model.RoomListRoomSummaryProvider
+import io.element.android.features.roomlist.impl.model.RoomSummaryDisplayType
import io.element.android.libraries.core.extensions.orEmpty
import io.element.android.libraries.designsystem.atomic.atoms.UnreadIndicatorAtom
import io.element.android.libraries.designsystem.components.avatar.Avatar
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
+import io.element.android.libraries.designsystem.theme.components.Button
+import io.element.android.libraries.designsystem.theme.components.ButtonSize
import io.element.android.libraries.designsystem.theme.components.Icon
+import io.element.android.libraries.designsystem.theme.components.OutlinedButton
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.theme.roomListRoomMessage
import io.element.android.libraries.designsystem.theme.roomListRoomMessageDate
import io.element.android.libraries.designsystem.theme.roomListRoomName
import io.element.android.libraries.designsystem.theme.unreadIndicator
+import io.element.android.libraries.matrix.api.core.RoomAlias
import io.element.android.libraries.matrix.api.room.RoomNotificationMode
+import io.element.android.libraries.matrix.ui.components.InviteSenderView
+import io.element.android.libraries.matrix.ui.model.InviteSender
+import io.element.android.libraries.ui.strings.CommonStrings
+import timber.log.Timber
internal val minHeight = 84.dp
@@ -63,30 +78,70 @@ internal val minHeight = 84.dp
internal fun RoomSummaryRow(
room: RoomListRoomSummary,
onClick: (RoomListRoomSummary) -> Unit,
- onLongClick: (RoomListRoomSummary) -> Unit,
+ eventSink: (RoomListEvents) -> Unit,
modifier: Modifier = Modifier,
) {
- if (room.isPlaceholder) {
- RoomSummaryPlaceholderRow(
- modifier = modifier,
- )
- } else {
- RoomSummaryRealRow(
- room = room,
- onClick = onClick,
- onLongClick = onLongClick,
- modifier = modifier
- )
+ when (room.displayType) {
+ RoomSummaryDisplayType.PLACEHOLDER -> {
+ RoomSummaryPlaceholderRow(modifier = modifier)
+ }
+ RoomSummaryDisplayType.INVITE -> {
+ RoomSummaryScaffoldRow(
+ room = room,
+ onClick = onClick,
+ onLongClick = {
+ Timber.d("Long click on invite room")
+ },
+ modifier = modifier
+ ) {
+ InviteNameAndIndicatorRow(name = room.name)
+ InviteSubtitle(isDirect = room.isDirect, inviteSender = room.inviteSender, canonicalAlias = room.canonicalAlias)
+ if (!room.isDirect && room.inviteSender != null) {
+ Spacer(modifier = Modifier.height(4.dp))
+ InviteSenderView(
+ modifier = Modifier.fillMaxWidth(),
+ inviteSender = room.inviteSender,
+ )
+ }
+ Spacer(modifier = Modifier.height(12.dp))
+ InviteButtonsRow(
+ onAcceptClicked = {
+ eventSink(RoomListEvents.AcceptInvite(room))
+ },
+ onDeclineClicked = {
+ eventSink(RoomListEvents.DeclineInvite(room))
+ }
+ )
+ }
+ }
+ RoomSummaryDisplayType.ROOM -> {
+ RoomSummaryScaffoldRow(
+ room = room,
+ onClick = onClick,
+ onLongClick = {
+ eventSink(RoomListEvents.ShowContextMenu(room))
+ },
+ modifier = modifier
+ ) {
+ NameAndTimestampRow(
+ name = room.name,
+ timestamp = room.timestamp,
+ isHighlighted = room.isHighlighted
+ )
+ LastMessageAndIndicatorRow(room = room)
+ }
+ }
}
}
@OptIn(ExperimentalFoundationApi::class)
@Composable
-private fun RoomSummaryRealRow(
+private fun RoomSummaryScaffoldRow(
room: RoomListRoomSummary,
onClick: (RoomListRoomSummary) -> Unit,
onLongClick: (RoomListRoomSummary) -> Unit,
modifier: Modifier = Modifier,
+ content: @Composable ColumnScope.() -> Unit
) {
val clickModifier = Modifier.combinedClickable(
onClick = { onClick(room) },
@@ -100,94 +155,170 @@ private fun RoomSummaryRealRow(
.fillMaxWidth()
.heightIn(min = minHeight)
.then(clickModifier)
- .fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 11.dp)
.height(IntrinsicSize.Min),
) {
- Avatar(
- room
- .avatarData,
- modifier = Modifier
- .align(Alignment.CenterVertically)
- )
+ Avatar(room.avatarData)
+ Spacer(modifier = Modifier.width(16.dp))
Column(
- modifier = Modifier
- .fillMaxWidth()
- .padding(start = 16.dp)
+ modifier = Modifier.fillMaxWidth(),
+ content = content,
+ )
+ }
+}
+
+@Composable
+private fun NameAndTimestampRow(
+ name: String?,
+ timestamp: String?,
+ isHighlighted: Boolean,
+ modifier: Modifier = Modifier
+) {
+ Row(
+ modifier = modifier.fillMaxWidth(),
+ horizontalArrangement = spacedBy(16.dp)
+ ) {
+ // Name
+ Text(
+ modifier = Modifier.weight(1f),
+ style = ElementTheme.typography.fontBodyLgMedium,
+ text = name ?: stringResource(id = CommonStrings.common_no_room_name),
+ fontStyle = FontStyle.Italic.takeIf { name == null },
+ color = MaterialTheme.roomListRoomName(),
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis
+ )
+ // Timestamp
+ Text(
+ text = timestamp ?: "",
+ style = ElementTheme.typography.fontBodySmMedium,
+ color = if (isHighlighted) {
+ ElementTheme.colors.unreadIndicator
+ } else {
+ MaterialTheme.roomListRoomMessageDate()
+ },
+ )
+ }
+}
+
+@Composable
+private fun InviteSubtitle(
+ isDirect: Boolean,
+ inviteSender: InviteSender?,
+ canonicalAlias: RoomAlias?,
+ modifier: Modifier = Modifier
+) {
+ val subtitle = if (isDirect) {
+ inviteSender?.userId?.value
+ } else {
+ canonicalAlias?.value
+ }
+ if (subtitle != null) {
+ Text(
+ text = subtitle,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ style = ElementTheme.typography.fontBodyMdRegular,
+ color = MaterialTheme.roomListRoomMessage(),
+ modifier = modifier,
+ )
+ }
+}
+
+@Composable
+private fun LastMessageAndIndicatorRow(
+ room: RoomListRoomSummary,
+ modifier: Modifier = Modifier,
+) {
+ Row(
+ modifier = modifier.fillMaxWidth(),
+ horizontalArrangement = spacedBy(28.dp)
+ ) {
+ // Last Message
+ val attributedLastMessage = room.lastMessage as? AnnotatedString
+ ?: AnnotatedString(room.lastMessage.orEmpty().toString())
+ Text(
+ modifier = Modifier.weight(1f),
+ text = attributedLastMessage,
+ color = MaterialTheme.roomListRoomMessage(),
+ style = ElementTheme.typography.fontBodyMdRegular,
+ minLines = 2,
+ maxLines = 2,
+ overflow = TextOverflow.Ellipsis
+ )
+ // Call and unread
+ Row(
+ modifier = Modifier.height(16.dp),
+ horizontalArrangement = Arrangement.spacedBy(8.dp),
+ verticalAlignment = Alignment.CenterVertically,
) {
- Row(modifier = Modifier.fillMaxWidth()) {
- NameAndTimestampRow(room = room)
+ val tint = if (room.isHighlighted) ElementTheme.colors.unreadIndicator else ElementTheme.colors.iconQuaternary
+ if (room.hasRoomCall) {
+ OnGoingCallIcon(
+ color = tint,
+ )
}
- Row(modifier = Modifier.fillMaxWidth()) {
- LastMessageAndIndicatorRow(room = room)
+ if (room.userDefinedNotificationMode == RoomNotificationMode.MUTE) {
+ NotificationOffIndicatorAtom()
+ } else if (room.numberOfUnreadMentions > 0) {
+ MentionIndicatorAtom()
+ }
+ if (room.hasNewContent) {
+ UnreadIndicatorAtom(
+ color = tint
+ )
}
}
}
}
@Composable
-private fun RowScope.NameAndTimestampRow(room: RoomListRoomSummary) {
- // Name
- Text(
- modifier = Modifier
- .weight(1f)
- .padding(end = 16.dp),
- style = ElementTheme.typography.fontBodyLgMedium,
- text = room.name,
- color = MaterialTheme.roomListRoomName(),
- maxLines = 1,
- overflow = TextOverflow.Ellipsis
- )
- // Timestamp
- Text(
- text = room.timestamp ?: "",
- style = ElementTheme.typography.fontBodySmMedium,
- color = if (room.isHighlighted) {
- ElementTheme.colors.unreadIndicator
- } else {
- MaterialTheme.roomListRoomMessageDate()
- },
- )
+private fun InviteNameAndIndicatorRow(
+ name: String?,
+ modifier: Modifier = Modifier,
+) {
+ Row(
+ modifier = modifier.fillMaxWidth(),
+ horizontalArrangement = spacedBy(16.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Text(
+ modifier = Modifier.weight(1f),
+ style = ElementTheme.typography.fontBodyLgMedium,
+ text = name ?: stringResource(id = CommonStrings.common_no_room_name),
+ fontStyle = FontStyle.Italic.takeIf { name == null },
+ color = MaterialTheme.roomListRoomName(),
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis
+ )
+ UnreadIndicatorAtom(
+ color = ElementTheme.colors.unreadIndicator
+ )
+ }
}
@Composable
-private fun RowScope.LastMessageAndIndicatorRow(room: RoomListRoomSummary) {
- // Last Message
- val attributedLastMessage = room.lastMessage as? AnnotatedString
- ?: AnnotatedString(room.lastMessage.orEmpty().toString())
- Text(
- modifier = Modifier
- .weight(1f)
- .padding(end = 28.dp),
- text = attributedLastMessage,
- color = MaterialTheme.roomListRoomMessage(),
- style = ElementTheme.typography.fontBodyMdRegular,
- minLines = 2,
- maxLines = 2,
- overflow = TextOverflow.Ellipsis
- )
- // Call and unread
+private fun InviteButtonsRow(
+ onAcceptClicked: () -> Unit,
+ onDeclineClicked: () -> Unit,
+ modifier: Modifier = Modifier
+) {
Row(
- modifier = Modifier.height(16.dp),
- horizontalArrangement = Arrangement.spacedBy(8.dp),
- verticalAlignment = Alignment.CenterVertically,
+ modifier = modifier.padding(),
+ horizontalArrangement = spacedBy(12.dp)
) {
- val tint = if (room.isHighlighted) ElementTheme.colors.unreadIndicator else ElementTheme.colors.iconQuaternary
- if (room.hasRoomCall) {
- OnGoingCallIcon(
- color = tint,
- )
- }
- if (room.userDefinedNotificationMode == RoomNotificationMode.MUTE) {
- NotificationOffIndicatorAtom()
- } else if (room.numberOfUnreadMentions > 0) {
- MentionIndicatorAtom()
- }
- if (room.hasNewContent) {
- UnreadIndicatorAtom(
- color = tint
- )
- }
+ OutlinedButton(
+ text = stringResource(CommonStrings.action_decline),
+ onClick = onDeclineClicked,
+ size = ButtonSize.Medium,
+ modifier = Modifier.weight(1f),
+ )
+ Button(
+ text = stringResource(CommonStrings.action_accept),
+ onClick = onAcceptClicked,
+ size = ButtonSize.Medium,
+ modifier = Modifier.weight(1f),
+ )
}
}
@@ -229,6 +360,6 @@ internal fun RoomSummaryRowPreview(@PreviewParameter(RoomListRoomSummaryProvider
RoomSummaryRow(
room = data,
onClick = {},
- onLongClick = {}
+ eventSink = {},
)
}
diff --git a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/datasource/DefaultInviteStateDataSource.kt b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/datasource/DefaultInviteStateDataSource.kt
deleted file mode 100644
index 16baf4e41b..0000000000
--- a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/datasource/DefaultInviteStateDataSource.kt
+++ /dev/null
@@ -1,72 +0,0 @@
-/*
- * Copyright (c) 2023 New Vector Ltd
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-package io.element.android.features.roomlist.impl.datasource
-
-import androidx.compose.runtime.Composable
-import androidx.compose.runtime.LaunchedEffect
-import androidx.compose.runtime.collectAsState
-import androidx.compose.runtime.getValue
-import androidx.compose.runtime.mutableStateOf
-import androidx.compose.runtime.remember
-import androidx.compose.runtime.setValue
-import com.squareup.anvil.annotations.ContributesBinding
-import io.element.android.features.invite.api.SeenInvitesStore
-import io.element.android.features.roomlist.impl.InvitesState
-import io.element.android.libraries.core.coroutine.CoroutineDispatchers
-import io.element.android.libraries.di.SessionScope
-import io.element.android.libraries.matrix.api.MatrixClient
-import io.element.android.libraries.matrix.api.core.RoomId
-import io.element.android.libraries.matrix.api.roomlist.RoomSummary
-import kotlinx.coroutines.withContext
-import javax.inject.Inject
-
-@ContributesBinding(SessionScope::class)
-class DefaultInviteStateDataSource @Inject constructor(
- private val client: MatrixClient,
- private val seenInvitesStore: SeenInvitesStore,
- private val coroutineDispatchers: CoroutineDispatchers,
-) : InviteStateDataSource {
- @Composable
- override fun inviteState(): InvitesState {
- val invites by client
- .roomListService
- .invites
- .summaries
- .collectAsState(initial = emptyList())
-
- val seenInvites by seenInvitesStore
- .seenRoomIds()
- .collectAsState(initial = emptySet())
-
- var state by remember { mutableStateOf(InvitesState.NoInvites) }
-
- LaunchedEffect(invites, seenInvites) {
- withContext(coroutineDispatchers.computation) {
- state = when {
- invites.isEmpty() -> InvitesState.NoInvites
- seenInvites.containsAll(invites.roomIds) -> InvitesState.SeenInvites
- else -> InvitesState.NewInvites
- }
- }
- }
-
- return state
- }
-}
-
-private val List.roomIds: Collection
- get() = filterIsInstance().map { it.details.roomId }
diff --git a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/datasource/InviteStateDataSource.kt b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/datasource/InviteStateDataSource.kt
deleted file mode 100644
index 866bed1efd..0000000000
--- a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/datasource/InviteStateDataSource.kt
+++ /dev/null
@@ -1,25 +0,0 @@
-/*
- * Copyright (c) 2023 New Vector Ltd
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-package io.element.android.features.roomlist.impl.datasource
-
-import androidx.compose.runtime.Composable
-import io.element.android.features.roomlist.impl.InvitesState
-
-interface InviteStateDataSource {
- @Composable
- fun inviteState(): InvitesState
-}
diff --git a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/datasource/RoomListRoomSummaryFactory.kt b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/datasource/RoomListRoomSummaryFactory.kt
index 07cdabea8e..80232563ed 100644
--- a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/datasource/RoomListRoomSummaryFactory.kt
+++ b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/datasource/RoomListRoomSummaryFactory.kt
@@ -17,13 +17,16 @@
package io.element.android.features.roomlist.impl.datasource
import io.element.android.features.roomlist.impl.model.RoomListRoomSummary
+import io.element.android.features.roomlist.impl.model.RoomSummaryDisplayType
import io.element.android.libraries.core.extensions.orEmpty
import io.element.android.libraries.dateformatter.api.LastMessageTimestampFormatter
import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.eventformatter.api.RoomLastMessageFormatter
import io.element.android.libraries.matrix.api.core.RoomId
+import io.element.android.libraries.matrix.api.room.CurrentUserMembership
import io.element.android.libraries.matrix.api.roomlist.RoomSummary
+import io.element.android.libraries.matrix.ui.model.toInviteSender
import javax.inject.Inject
class RoomListRoomSummaryFactory @Inject constructor(
@@ -35,7 +38,7 @@ class RoomListRoomSummaryFactory @Inject constructor(
return RoomListRoomSummary(
id = id,
roomId = RoomId(id),
- isPlaceholder = true,
+ displayType = RoomSummaryDisplayType.PLACEHOLDER,
name = "Short name",
timestamp = "hh:mm",
lastMessage = "Last message for placeholder",
@@ -46,8 +49,11 @@ class RoomListRoomSummaryFactory @Inject constructor(
isMarkedUnread = false,
userDefinedNotificationMode = null,
hasRoomCall = false,
- isDm = false,
+ isDirect = false,
isFavorite = false,
+ inviteSender = null,
+ isDm = false,
+ canonicalAlias = null,
)
}
}
@@ -73,11 +79,18 @@ class RoomListRoomSummaryFactory @Inject constructor(
roomLastMessageFormatter.format(message.event, roomSummary.details.isDirect)
}.orEmpty(),
avatarData = avatarData,
- isPlaceholder = false,
userDefinedNotificationMode = roomSummary.details.userDefinedNotificationMode,
hasRoomCall = roomSummary.details.hasRoomCall,
- isDm = roomSummary.details.isDm,
+ isDirect = roomSummary.details.isDirect,
isFavorite = roomSummary.details.isFavorite,
+ inviteSender = roomSummary.details.inviter?.toInviteSender(),
+ isDm = roomSummary.details.isDm,
+ canonicalAlias = roomSummary.details.canonicalAlias,
+ displayType = if (roomSummary.details.currentUserMembership == CurrentUserMembership.INVITED) {
+ RoomSummaryDisplayType.INVITE
+ } else {
+ RoomSummaryDisplayType.ROOM
+ }
)
}
}
diff --git a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/filters/RoomListFilter.kt b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/filters/RoomListFilter.kt
index 51b9570c6d..1af6979508 100644
--- a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/filters/RoomListFilter.kt
+++ b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/filters/RoomListFilter.kt
@@ -26,13 +26,15 @@ enum class RoomListFilter(val stringResource: Int) {
Unread(R.string.screen_roomlist_filter_unreads),
People(R.string.screen_roomlist_filter_people),
Rooms(R.string.screen_roomlist_filter_rooms),
- Favourites(R.string.screen_roomlist_filter_favourites);
+ Favourites(R.string.screen_roomlist_filter_favourites),
+ Invites(R.string.screen_roomlist_filter_invites);
- val oppositeFilter: RoomListFilter?
+ val incompatibleFilters: Set
get() = when (this) {
- Rooms -> People
- People -> Rooms
- Unread -> null
- Favourites -> null
+ Rooms -> setOf(People, Invites)
+ People -> setOf(Rooms, Invites)
+ Unread -> setOf(Invites)
+ Favourites -> setOf(Invites)
+ Invites -> setOf(Rooms, People, Unread, Favourites)
}
}
diff --git a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/filters/RoomListFiltersEmptyStateResources.kt b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/filters/RoomListFiltersEmptyStateResources.kt
index 53763abd0d..5bfc274067 100644
--- a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/filters/RoomListFiltersEmptyStateResources.kt
+++ b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/filters/RoomListFiltersEmptyStateResources.kt
@@ -53,6 +53,10 @@ data class RoomListFiltersEmptyStateResources(
title = R.string.screen_roomlist_filter_favourites_empty_state_title,
subtitle = R.string.screen_roomlist_filter_favourites_empty_state_subtitle
)
+ RoomListFilter.Invites -> RoomListFiltersEmptyStateResources(
+ title = R.string.screen_roomlist_filter_invites_empty_state_title,
+ subtitle = R.string.screen_roomlist_filter_mixed_empty_state_subtitle
+ )
}
}
else -> RoomListFiltersEmptyStateResources(
diff --git a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/filters/RoomListFiltersPresenter.kt b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/filters/RoomListFiltersPresenter.kt
index 27edc91627..4292492af5 100644
--- a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/filters/RoomListFiltersPresenter.kt
+++ b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/filters/RoomListFiltersPresenter.kt
@@ -22,8 +22,6 @@ import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import io.element.android.features.roomlist.impl.filters.selection.FilterSelectionStrategy
import io.element.android.libraries.architecture.Presenter
-import io.element.android.libraries.featureflag.api.FeatureFlagService
-import io.element.android.libraries.featureflag.api.FeatureFlags
import io.element.android.libraries.matrix.api.roomlist.RoomListService
import kotlinx.collections.immutable.toPersistentList
import javax.inject.Inject
@@ -31,12 +29,10 @@ import io.element.android.libraries.matrix.api.roomlist.RoomListFilter as Matrix
class RoomListFiltersPresenter @Inject constructor(
private val roomListService: RoomListService,
- private val featureFlagService: FeatureFlagService,
private val filterSelectionStrategy: FilterSelectionStrategy,
) : Presenter {
@Composable
override fun present(): RoomListFiltersState {
- val isFeatureEnabled by featureFlagService.isFeatureEnabledFlow(FeatureFlags.RoomListFilters).collectAsState(false)
val filters by filterSelectionStrategy.filterSelectionStates.collectAsState()
fun handleEvents(event: RoomListFiltersEvents) {
@@ -50,31 +46,25 @@ class RoomListFiltersPresenter @Inject constructor(
}
}
- LaunchedEffect(isFeatureEnabled) {
- if (!isFeatureEnabled) {
- filterSelectionStrategy.clear()
- }
- }
-
LaunchedEffect(filters) {
val allRoomsFilter = MatrixRoomListFilter.All(
filters
.filter { it.isSelected }
.map { roomListFilter ->
- when (roomListFilter.filter) {
- RoomListFilter.Rooms -> MatrixRoomListFilter.Category.Group
- RoomListFilter.People -> MatrixRoomListFilter.Category.People
- RoomListFilter.Unread -> MatrixRoomListFilter.Unread
- RoomListFilter.Favourites -> MatrixRoomListFilter.Favorite
+ when (roomListFilter.filter) {
+ RoomListFilter.Rooms -> MatrixRoomListFilter.Category.Group
+ RoomListFilter.People -> MatrixRoomListFilter.Category.People
+ RoomListFilter.Unread -> MatrixRoomListFilter.Unread
+ RoomListFilter.Favourites -> MatrixRoomListFilter.Favorite
+ RoomListFilter.Invites -> MatrixRoomListFilter.Invite
+ }
}
- }
)
roomListService.allRooms.updateFilter(allRoomsFilter)
}
return RoomListFiltersState(
filterSelectionStates = filters.toPersistentList(),
- isFeatureEnabled = isFeatureEnabled,
eventSink = ::handleEvents
)
}
diff --git a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/filters/RoomListFiltersState.kt b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/filters/RoomListFiltersState.kt
index 14850ef82e..af89e84091 100644
--- a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/filters/RoomListFiltersState.kt
+++ b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/filters/RoomListFiltersState.kt
@@ -22,7 +22,6 @@ import kotlinx.collections.immutable.toPersistentList
data class RoomListFiltersState(
val filterSelectionStates: ImmutableList,
- val isFeatureEnabled: Boolean,
val eventSink: (RoomListFiltersEvents) -> Unit,
) {
val hasAnyFilterSelected = filterSelectionStates.any { it.isSelected }
diff --git a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/filters/RoomListFiltersStateProvider.kt b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/filters/RoomListFiltersStateProvider.kt
index 00d1352728..298c493800 100644
--- a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/filters/RoomListFiltersStateProvider.kt
+++ b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/filters/RoomListFiltersStateProvider.kt
@@ -32,10 +32,8 @@ class RoomListFiltersStateProvider : PreviewParameterProvider = RoomListFilter.entries.map { FilterSelectionState(it, isSelected = false) },
- isFeatureEnabled: Boolean = true,
eventSink: (RoomListFiltersEvents) -> Unit = {},
) = RoomListFiltersState(
filterSelectionStates = filterSelectionStates.toImmutableList(),
- isFeatureEnabled = isFeatureEnabled,
eventSink = eventSink,
)
diff --git a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/filters/selection/DefaultFilterSelectionStrategy.kt b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/filters/selection/DefaultFilterSelectionStrategy.kt
index d56c84a572..91d6a6d09b 100644
--- a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/filters/selection/DefaultFilterSelectionStrategy.kt
+++ b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/filters/selection/DefaultFilterSelectionStrategy.kt
@@ -54,7 +54,7 @@ class DefaultFilterSelectionStrategy @Inject constructor() : FilterSelectionStra
isSelected = true
)
}
- val unselectedFilters = RoomListFilter.entries - selectedFilters - selectedFilters.mapNotNull { it.oppositeFilter }.toSet()
+ val unselectedFilters = RoomListFilter.entries - selectedFilters - selectedFilters.flatMap { it.incompatibleFilters }.toSet()
val unselectedFilterStates = unselectedFilters.map {
FilterSelectionState(
filter = it,
diff --git a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/migration/MigrationScreenView.kt b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/migration/MigrationScreenView.kt
index 67e1999fc9..ec012e82ba 100644
--- a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/migration/MigrationScreenView.kt
+++ b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/migration/MigrationScreenView.kt
@@ -50,6 +50,6 @@ fun MigrationScreenView(
@Composable
@PreviewsDayNight
-internal fun MigrationViewPreview() = ElementPreview {
+internal fun MigrationScreenViewPreview() = ElementPreview {
MigrationScreenView(isMigrating = true)
}
diff --git a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/model/RoomListRoomSummary.kt b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/model/RoomListRoomSummary.kt
index cc9f94aa52..9afa6cd749 100644
--- a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/model/RoomListRoomSummary.kt
+++ b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/model/RoomListRoomSummary.kt
@@ -18,14 +18,18 @@ package io.element.android.features.roomlist.impl.model
import androidx.compose.runtime.Immutable
import io.element.android.libraries.designsystem.components.avatar.AvatarData
+import io.element.android.libraries.matrix.api.core.RoomAlias
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.room.RoomNotificationMode
+import io.element.android.libraries.matrix.ui.model.InviteSender
@Immutable
data class RoomListRoomSummary(
val id: String,
+ val displayType: RoomSummaryDisplayType,
val roomId: RoomId,
- val name: String,
+ val name: String?,
+ val canonicalAlias: RoomAlias?,
val numberOfUnreadMessages: Int,
val numberOfUnreadMentions: Int,
val numberOfUnreadNotifications: Int,
@@ -33,18 +37,21 @@ data class RoomListRoomSummary(
val timestamp: String?,
val lastMessage: CharSequence?,
val avatarData: AvatarData,
- val isPlaceholder: Boolean,
val userDefinedNotificationMode: RoomNotificationMode?,
val hasRoomCall: Boolean,
+ val isDirect: Boolean,
val isDm: Boolean,
val isFavorite: Boolean,
+ val inviteSender: InviteSender?,
) {
val isHighlighted = userDefinedNotificationMode != RoomNotificationMode.MUTE &&
(numberOfUnreadNotifications > 0 || numberOfUnreadMentions > 0) ||
- isMarkedUnread
+ isMarkedUnread ||
+ displayType == RoomSummaryDisplayType.INVITE
val hasNewContent = numberOfUnreadMessages > 0 ||
numberOfUnreadMentions > 0 ||
numberOfUnreadNotifications > 0 ||
- isMarkedUnread
+ isMarkedUnread ||
+ displayType == RoomSummaryDisplayType.INVITE
}
diff --git a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/model/RoomListRoomSummaryProvider.kt b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/model/RoomListRoomSummaryProvider.kt
index feec962d85..a39d50eac0 100644
--- a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/model/RoomListRoomSummaryProvider.kt
+++ b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/model/RoomListRoomSummaryProvider.kt
@@ -19,15 +19,19 @@ package io.element.android.features.roomlist.impl.model
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
+import io.element.android.libraries.matrix.api.core.RoomAlias
import io.element.android.libraries.matrix.api.core.RoomId
+import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.room.RoomNotificationMode
+import io.element.android.libraries.matrix.ui.model.InviteSender
open class RoomListRoomSummaryProvider : PreviewParameterProvider {
override val values: Sequence
get() = sequenceOf(
listOf(
- aRoomListRoomSummary(isPlaceholder = true),
+ aRoomListRoomSummary(displayType = RoomSummaryDisplayType.PLACEHOLDER),
aRoomListRoomSummary(),
+ aRoomListRoomSummary(name = null),
aRoomListRoomSummary(lastMessage = null),
aRoomListRoomSummary(
name = "A very long room name that should be truncated",
@@ -80,24 +84,64 @@ open class RoomListRoomSummaryProvider : PreviewParameterProvider Unit,
onRoomClicked: (RoomId) -> Unit,
- onRoomLongClicked: (RoomListRoomSummary) -> Unit,
onRoomDirectorySearchClicked: () -> Unit,
modifier: Modifier = Modifier,
) {
@@ -90,7 +96,7 @@ internal fun RoomListSearchView(
RoomListSearchContent(
state = state,
onRoomClicked = onRoomClicked,
- onRoomLongClicked = onRoomLongClicked,
+ eventSink = eventSink,
onRoomDirectorySearchClicked = onRoomDirectorySearchClicked,
)
}
@@ -102,8 +108,8 @@ internal fun RoomListSearchView(
@Composable
private fun RoomListSearchContent(
state: RoomListSearchState,
+ eventSink: (RoomListEvents) -> Unit,
onRoomClicked: (RoomId) -> Unit,
- onRoomLongClicked: (RoomListRoomSummary) -> Unit,
onRoomDirectorySearchClicked: () -> Unit,
) {
val borderColor = MaterialTheme.colorScheme.tertiary
@@ -178,8 +184,8 @@ private fun RoomListSearchContent(
if (state.displayRoomDirectorySearch) {
RoomDirectorySearchButton(
modifier = Modifier
- .fillMaxWidth()
- .padding(vertical = 24.dp, horizontal = 16.dp),
+ .fillMaxWidth()
+ .padding(vertical = 24.dp, horizontal = 16.dp),
onClick = onRoomDirectorySearchClicked
)
}
@@ -193,7 +199,7 @@ private fun RoomListSearchContent(
RoomSummaryRow(
room = room,
onClick = ::onRoomClicked,
- onLongClick = onRoomLongClicked,
+ eventSink = eventSink,
)
}
}
@@ -206,21 +212,33 @@ private fun RoomDirectorySearchButton(
onClick: () -> Unit,
modifier: Modifier = Modifier
) {
- Button(
- text = stringResource(id = R.string.screen_roomlist_room_directory_button_title),
- leadingIcon = IconSource.Vector(CompoundIcons.ListBulleted()),
+ SuperButton(
onClick = onClick,
modifier = modifier,
- )
+ buttonSize = ButtonSize.Large,
+ ) {
+ Row(
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Icon(
+ imageVector = CompoundIcons.ListBulleted(),
+ contentDescription = null,
+ )
+ Spacer(modifier = Modifier.width(8.dp))
+ Text(
+ text = stringResource(R.string.screen_roomlist_room_directory_button_title),
+ )
+ }
+ }
}
@PreviewsDayNight
@Composable
-internal fun RoomListSearchResultContentPreview(@PreviewParameter(RoomListSearchStateProvider::class) state: RoomListSearchState) = ElementPreview {
+internal fun RoomListSearchContentPreview(@PreviewParameter(RoomListSearchStateProvider::class) state: RoomListSearchState) = ElementPreview {
RoomListSearchContent(
state = state,
onRoomClicked = {},
- onRoomLongClicked = {},
+ eventSink = {},
onRoomDirectorySearchClicked = {},
)
}
diff --git a/features/roomlist/impl/src/main/res/values-be/translations.xml b/features/roomlist/impl/src/main/res/values-be/translations.xml
index 8da1365931..ac54eb1d84 100644
--- a/features/roomlist/impl/src/main/res/values-be/translations.xml
+++ b/features/roomlist/impl/src/main/res/values-be/translations.xml
@@ -2,6 +2,12 @@
"Ваша рэзервовая копія чата зараз не сінхранізавана. Вам трэба пацвердзіць ключ аднаўлення, каб захаваць доступ да рэзервовай копіі чата."
"Увядзіце ключ аднаўлення"
+ "Вы ўпэўненыя, што хочаце адхіліць запрашэнне ў %1$s?"
+ "Адхіліць запрашэнне"
+ "Вы ўпэўненыя, што хочаце адмовіцца ад прыватных зносін з %1$s?"
+ "Адхіліць чат"
+ "Няма запрашэнняў"
+ "%1$s (%2$s) запрасіў вас"
"Гэта аднаразовы працэс, дзякуем за чаканне."
"Налада ўліковага запісу."
"Стварыце новую размову або пакой"
@@ -16,7 +22,7 @@
"Нізкі прыярытэт"
"Вы можаце прыбраць фільтры, каб убачыць іншыя вашыя чаты."
"У вас няма чатаў для гэтай катэгорыі"
- "Удзельнікі"
+ "Людзі"
"У вас пакуль няма асабістых паведамленняў"
"Пакоі"
"Вас пакуль няма ў ніводным пакоі"
diff --git a/features/roomlist/impl/src/main/res/values-bg/translations.xml b/features/roomlist/impl/src/main/res/values-bg/translations.xml
index dfb4f42e8b..8012e4283b 100644
--- a/features/roomlist/impl/src/main/res/values-bg/translations.xml
+++ b/features/roomlist/impl/src/main/res/values-bg/translations.xml
@@ -1,6 +1,11 @@
+ "Резервното копие на чатовете ви в момента не е синхронизирано. Въведете ключа си за възстановяване, за да потвърдите достъпа до резервното копие на чатовете си."
"Потвърдете ключа си за възстановяване"
+ "Сигурни ли сте, че искате да отхвърлите поканата за присъединяване в %1$s?"
+ "Отказване на покана"
+ "Няма покани"
+ "%1$s (%2$s) ви покани"
"Създаване на нов разговор или стая"
"Започнете, като изпратите съобщение на някого."
"Все още няма чатове."
diff --git a/features/roomlist/impl/src/main/res/values-cs/translations.xml b/features/roomlist/impl/src/main/res/values-cs/translations.xml
index 3a91eb04dc..4168a5224c 100644
--- a/features/roomlist/impl/src/main/res/values-cs/translations.xml
+++ b/features/roomlist/impl/src/main/res/values-cs/translations.xml
@@ -2,6 +2,12 @@
"Vaše záloha chatu není aktuálně synchronizována. Abyste si zachovali přístup k záloze chatu, musíte potvrdit klíč pro obnovení."
"Potvrďte klíč pro obnovení"
+ "Opravdu chcete odmítnout pozvánku do %1$s?"
+ "Odmítnout pozvání"
+ "Opravdu chcete odmítnout tuto soukromou konverzaci s %1$s?"
+ "Odmítnout chat"
+ "Žádné pozvánky"
+ "%1$s (%2$s) vás pozval(a)"
"Jedná se o jednorázový proces, prosíme o strpení."
"Nastavení vašeho účtu"
"Vytvořte novou konverzaci nebo místnost"
diff --git a/features/roomlist/impl/src/main/res/values-de/translations.xml b/features/roomlist/impl/src/main/res/values-de/translations.xml
index d02870ccd1..36442d6f7e 100644
--- a/features/roomlist/impl/src/main/res/values-de/translations.xml
+++ b/features/roomlist/impl/src/main/res/values-de/translations.xml
@@ -2,9 +2,15 @@
"Dein Chat-Backup ist derzeit nicht synchronisiert. Du musst deinen Wiederherstellungsschlüssel bestätigen, um Zugriff auf dein Chat-Backup zu erhalten."
"Wiederherstellungsschlüssel bestätigen."
+ "Möchtest du die Einladung zum Betreten von %1$s wirklich ablehnen?"
+ "Einladung ablehnen"
+ "Bist du sicher, dass du diese Direktnachricht von %1$s ablehnen möchtest?"
+ "Einladung ablehnen"
+ "Keine Einladungen"
+ "%1$s (%2$s) hat dich eingeladen"
"Dies ist ein einmaliger Vorgang, danke fürs Warten."
"Dein Konto wird eingerichtet."
- "Eine neue Unterhaltung oder einen neuen Raum erstellen"
+ "Eine Unterthaltung oder Raum erstellen"
"Beginne, indem du jemandem eine Nachricht sendest."
"Noch keine Chats."
"Favoriten"
@@ -19,7 +25,7 @@ Um deine anderen Chats zu sehen wähle diesen Filter ab."
"Personen"
"Du hast noch keine Direktnachrichten"
"Räume"
- "Du bist noch in keinem Raum."
+ "Du hast noch keine Chats."
"Ungelesen"
"Glückwunsch!
Du hast keine ungelesenen Nachrichten!"
diff --git a/features/roomlist/impl/src/main/res/values-es/translations.xml b/features/roomlist/impl/src/main/res/values-es/translations.xml
index 645651fc9e..3873c335c5 100644
--- a/features/roomlist/impl/src/main/res/values-es/translations.xml
+++ b/features/roomlist/impl/src/main/res/values-es/translations.xml
@@ -2,6 +2,12 @@
"La copia de seguridad del chat no está sincronizada en este momento. Debes confirmar tu clave de recuperación para mantener el acceso a la copia de seguridad del chat."
"Confirma tu clave de recuperación"
+ "¿Estás seguro de que quieres rechazar la invitación a unirte a %1$s?"
+ "Rechazar la invitación"
+ "¿Estás seguro de que quieres rechazar este chat privado con%1$s?"
+ "Rechazar el chat"
+ "Sin invitaciones"
+ "%1$s (%2$s) te invitó"
"Este proceso solo se hace una vez, gracias por esperar."
"Configura tu cuenta"
"Crear una nueva conversación o sala"
diff --git a/features/roomlist/impl/src/main/res/values-fr/translations.xml b/features/roomlist/impl/src/main/res/values-fr/translations.xml
index fd8606c96b..55b82935d3 100644
--- a/features/roomlist/impl/src/main/res/values-fr/translations.xml
+++ b/features/roomlist/impl/src/main/res/values-fr/translations.xml
@@ -2,6 +2,12 @@
"La sauvegarde des conversations est désynchronisée. Vous devez confirmer la clé de récupération pour accéder à votre historique."
"Confirmer votre clé de récupération"
+ "Êtes-vous sûr de vouloir décliner l’invitation à rejoindre %1$s ?"
+ "Refuser l’invitation"
+ "Êtes-vous sûr de vouloir refuser cette discussion privée avec %1$s ?"
+ "Refuser l’invitation"
+ "Aucune invitation"
+ "%1$s (%2$s) vous a invité(e)"
"Il s’agit d’une opération ponctuelle, merci d’attendre quelques instants."
"Configuration de votre compte."
"Créer une nouvelle discussion ou un nouveau salon"
diff --git a/features/roomlist/impl/src/main/res/values-hu/translations.xml b/features/roomlist/impl/src/main/res/values-hu/translations.xml
index e25c493b13..47695444d5 100644
--- a/features/roomlist/impl/src/main/res/values-hu/translations.xml
+++ b/features/roomlist/impl/src/main/res/values-hu/translations.xml
@@ -2,6 +2,12 @@
"A csevegés biztonsági mentése nincs szinkronban. Meg kell erősítenie a helyreállítási kulcsát, hogy továbbra is hozzáférjen a csevegés biztonsági mentéséhez."
"Helyreállítási kulcs megerősítése"
+ "Biztos, hogy elutasítja a meghívást, hogy csatlakozzon ehhez: %1$s?"
+ "Meghívás elutasítása"
+ "Biztos, hogy elutasítja ezt a privát csevegést vele: %1$s?"
+ "Csevegés elutasítása"
+ "Nincsenek meghívások"
+ "%1$s (%2$s) meghívta"
"Ez egy egyszeri folyamat, köszönjük a türelmét."
"A fiók beállítása."
"Új beszélgetés vagy szoba létrehozása"
diff --git a/features/roomlist/impl/src/main/res/values-in/translations.xml b/features/roomlist/impl/src/main/res/values-in/translations.xml
index 8f194e1e34..7f5fd69eee 100644
--- a/features/roomlist/impl/src/main/res/values-in/translations.xml
+++ b/features/roomlist/impl/src/main/res/values-in/translations.xml
@@ -2,6 +2,12 @@
"Cadangan percakapan Anda saat ini tidak tersinkron. Anda perlu mengonfirmasi kunci pemulihan Anda untuk tetap memiliki akses ke cadangan percakapan Anda."
"Konfirmasi kunci pemulihan Anda"
+ "Apakah Anda yakin ingin menolak undangan untuk bergabung ke %1$s?"
+ "Tolak undangan"
+ "Apakah Anda yakin ingin menolak obrolan pribadi dengan %1$s?"
+ "Tolak obrolan"
+ "Tidak ada undangan"
+ "%1$s (%2$s) mengundang Anda"
"Ini adalah proses satu kali, terima kasih telah menunggu."
"Menyiapkan akun Anda."
"Buat percakapan atau ruangan baru"
diff --git a/features/roomlist/impl/src/main/res/values-it/translations.xml b/features/roomlist/impl/src/main/res/values-it/translations.xml
index 05e41594a2..349057e7c4 100644
--- a/features/roomlist/impl/src/main/res/values-it/translations.xml
+++ b/features/roomlist/impl/src/main/res/values-it/translations.xml
@@ -2,6 +2,12 @@
"Il backup della chat non è attualmente sincronizzato. Devi confermare la chiave di recupero per mantenere l\'accesso al backup della chat."
"Inserisci la chiave di recupero"
+ "Vuoi davvero rifiutare l\'invito ad entrare in %1$s?"
+ "Rifiuta l\'invito"
+ "Vuoi davvero rifiutare questa conversazione privata con %1$s?"
+ "Rifiuta l\'invito alla conversazione"
+ "Nessun invito"
+ "%1$s (%2$s) ti ha invitato"
"Si tratta di una procedura che si effettua una sola volta, grazie per l\'attesa."
"Configurazione del tuo account."
"Crea una nuova conversazione o stanza"
diff --git a/features/roomlist/impl/src/main/res/values-ro/translations.xml b/features/roomlist/impl/src/main/res/values-ro/translations.xml
index df30bc8239..b766caf8f0 100644
--- a/features/roomlist/impl/src/main/res/values-ro/translations.xml
+++ b/features/roomlist/impl/src/main/res/values-ro/translations.xml
@@ -2,6 +2,12 @@
"Backup-ul pentru chat nu este sincronizat în prezent. Trebuie să confirmați cheia de recuperare pentru a menține accesul la backup."
"Confirmați cheia de recuperare"
+ "Sigur doriți să refuzați alăturarea la %1$s?"
+ "Refuzați invitația"
+ "Sigur doriți să refuzați conversațiile cu %1$s?"
+ "Refuzați conversația"
+ "Nicio invitație"
+ "%1$s (%2$s) v-a invitat."
"Acesta este un proces care se desfășoară o singură dată, vă mulțumim pentru așteptare."
"Contul dumneavoastră se configurează"
"Creați o conversație sau o cameră nouă"
diff --git a/features/roomlist/impl/src/main/res/values-ru/translations.xml b/features/roomlist/impl/src/main/res/values-ru/translations.xml
index 68cb98436d..bf66490a73 100644
--- a/features/roomlist/impl/src/main/res/values-ru/translations.xml
+++ b/features/roomlist/impl/src/main/res/values-ru/translations.xml
@@ -5,6 +5,12 @@
"Введите "
"ключ восстановления"
+ "Вы уверены, что хотите отклонить приглашение в %1$s?"
+ "Отклонить приглашение"
+ "Вы уверены, что хотите отказаться от личного общения с %1$s?"
+ "Отклонить чат"
+ "Нет приглашений"
+ "%1$s (%2$s) пригласил вас"
"Это одноразовый процесс, спасибо, что подождали."
"Настройка учетной записи."
"Создайте новую беседу или комнату"
diff --git a/features/roomlist/impl/src/main/res/values-sk/translations.xml b/features/roomlist/impl/src/main/res/values-sk/translations.xml
index 101df1792d..4b610c28b4 100644
--- a/features/roomlist/impl/src/main/res/values-sk/translations.xml
+++ b/features/roomlist/impl/src/main/res/values-sk/translations.xml
@@ -2,6 +2,12 @@
"Vaša záloha konverzácie nie je momentálne synchronizovaná. Na zachovanie prístupu k zálohe konverzácie musíte potvrdiť svoj kľúč na obnovu."
"Potvrďte svoj kľúč na obnovenie"
+ "Naozaj chcete odmietnuť pozvánku na pripojenie do %1$s?"
+ "Odmietnuť pozvanie"
+ "Naozaj chcete odmietnuť túto súkromnú konverzáciu s %1$s?"
+ "Odmietnuť konverzáciu"
+ "Žiadne pozvánky"
+ "%1$s (%2$s) vás pozval/a"
"Ide o jednorazový proces, ďakujeme za trpezlivosť."
"Nastavenie vášho účtu."
"Vytvorte novú konverzáciu alebo miestnosť"
diff --git a/features/roomlist/impl/src/main/res/values-sv/translations.xml b/features/roomlist/impl/src/main/res/values-sv/translations.xml
index 144a05115c..cc483e81da 100644
--- a/features/roomlist/impl/src/main/res/values-sv/translations.xml
+++ b/features/roomlist/impl/src/main/res/values-sv/translations.xml
@@ -1,12 +1,26 @@
+ "Din chattsäkerhetskopia är för närvarande inte synkroniserad. Du måste ange din återställningsnyckel för att behålla åtkomsten till din chattsäkerhetskopia."
+ "Ange din återställningsnyckel"
+ "Är du säker på att du vill tacka nej till inbjudan att gå med%1$s?"
+ "Avböj inbjudan"
+ "Är du säker på att du vill avböja denna privata chatt med %1$s?"
+ "Avböj chatt"
+ "Inga inbjudningar"
+ "%1$s (%2$s) bjöd in dig"
"Detta är en engångsprocess, tack för att du väntar."
"Konfigurerar ditt konto"
"Skapa en ny konversation eller ett nytt rum"
"Kom igång genom att skicka meddelanden till någon."
"Inga chattar än."
+ "Favoriter"
+ "Låg prioritet"
"Personer"
+ "Rum"
+ "Olästa"
"Alla chattar"
+ "Markera som läst"
+ "Markera som oläst"
"Det verkar som om du använder en ny enhet. Verifiera med en annan enhet för att komma åt dina krypterade meddelanden."
"Verifiera att det är du"
diff --git a/features/roomlist/impl/src/main/res/values-uk/translations.xml b/features/roomlist/impl/src/main/res/values-uk/translations.xml
index d7ebacd5f7..a7f1c26db0 100644
--- a/features/roomlist/impl/src/main/res/values-uk/translations.xml
+++ b/features/roomlist/impl/src/main/res/values-uk/translations.xml
@@ -2,6 +2,12 @@
"Ваша резервна копія чату наразі не синхронізована. Вам потрібно підтвердити ключ відновлення, щоб зберегти доступ до резервної копії чату."
"Підтвердіть ключ відновлення"
+ "Ви впевнені, що хочете відхилити запрошення приєднатися до %1$s?"
+ "Відхилити запрошення"
+ "Ви дійсно хочете відмовитися від приватного чату з %1$s?"
+ "Відхилити чат"
+ "Немає запрошень"
+ "%1$s (%2$s) запросив (-ла) Вас"
"Це одноразовий процес, дякую за очікування."
"Налаштування облікового запису."
"Створити нову розмову або кімнату"
diff --git a/features/roomlist/impl/src/main/res/values-zh-rTW/translations.xml b/features/roomlist/impl/src/main/res/values-zh-rTW/translations.xml
index 73b9ea36d1..8d0ec972f7 100644
--- a/features/roomlist/impl/src/main/res/values-zh-rTW/translations.xml
+++ b/features/roomlist/impl/src/main/res/values-zh-rTW/translations.xml
@@ -1,11 +1,15 @@
"輸入您的復原金鑰"
+ "沒有邀請"
+ "%1$s(%2$s)邀請您"
"這是一次性的程序,感謝您耐心等候。"
"正在設定您的帳號。"
"建立新的對話或聊天室"
"我的最愛"
"夥伴"
+ "聊天室"
+ "未讀"
"所有聊天室"
"您似乎正在使用新的裝置。請使用另一個裝置進行驗證,以存取您的加密訊息。"
"驗證這是您本人"
diff --git a/features/roomlist/impl/src/main/res/values/localazy.xml b/features/roomlist/impl/src/main/res/values/localazy.xml
index deeead48c2..04db0e8cde 100644
--- a/features/roomlist/impl/src/main/res/values/localazy.xml
+++ b/features/roomlist/impl/src/main/res/values/localazy.xml
@@ -2,6 +2,12 @@
"Your chat backup is currently out of sync. You need to enter your recovery key to maintain access to your chat backup."
"Enter your recovery key"
+ "Are you sure you want to decline the invitation to join %1$s?"
+ "Decline invite"
+ "Are you sure you want to decline this private chat with %1$s?"
+ "Decline chat"
+ "No Invites"
+ "%1$s (%2$s) invited you"
"This is a one time process, thanks for waiting."
"Setting up your account."
"Create a new conversation or room"
diff --git a/features/roomlist/impl/src/test/kotlin/io/element/android/features/roomlist/impl/RoomListPresenterTests.kt b/features/roomlist/impl/src/test/kotlin/io/element/android/features/roomlist/impl/RoomListPresenterTests.kt
index 0f2c288a63..053b4e51a8 100644
--- a/features/roomlist/impl/src/test/kotlin/io/element/android/features/roomlist/impl/RoomListPresenterTests.kt
+++ b/features/roomlist/impl/src/test/kotlin/io/element/android/features/roomlist/impl/RoomListPresenterTests.kt
@@ -21,14 +21,15 @@ import app.cash.molecule.moleculeFlow
import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
import im.vector.app.features.analytics.plan.Interaction
+import io.element.android.features.invite.api.response.AcceptDeclineInviteEvents
+import io.element.android.features.invite.api.response.AcceptDeclineInviteState
+import io.element.android.features.invite.api.response.anAcceptDeclineInviteState
import io.element.android.features.leaveroom.api.LeaveRoomEvent
import io.element.android.features.leaveroom.api.LeaveRoomPresenter
import io.element.android.features.leaveroom.fake.FakeLeaveRoomPresenter
import io.element.android.features.networkmonitor.api.NetworkMonitor
import io.element.android.features.networkmonitor.test.FakeNetworkMonitor
import io.element.android.features.preferences.api.store.SessionPreferencesStore
-import io.element.android.features.roomlist.impl.datasource.FakeInviteDataSource
-import io.element.android.features.roomlist.impl.datasource.InviteStateDataSource
import io.element.android.features.roomlist.impl.datasource.RoomListDataSource
import io.element.android.features.roomlist.impl.datasource.RoomListRoomSummaryFactory
import io.element.android.features.roomlist.impl.filters.RoomListFiltersState
@@ -47,11 +48,11 @@ import io.element.android.libraries.eventformatter.api.RoomLastMessageFormatter
import io.element.android.libraries.eventformatter.test.FakeRoomLastMessageFormatter
import io.element.android.libraries.featureflag.api.FeatureFlagService
import io.element.android.libraries.featureflag.test.FakeFeatureFlagService
-import io.element.android.libraries.featureflag.test.InMemorySessionPreferencesStore
import io.element.android.libraries.indicator.impl.DefaultIndicatorService
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.encryption.BackupState
import io.element.android.libraries.matrix.api.encryption.RecoveryState
+import io.element.android.libraries.matrix.api.room.CurrentUserMembership
import io.element.android.libraries.matrix.api.room.RoomNotificationMode
import io.element.android.libraries.matrix.api.roomlist.RoomList
import io.element.android.libraries.matrix.api.sync.SyncState
@@ -71,17 +72,21 @@ import io.element.android.libraries.matrix.test.room.aRoomSummaryFilled
import io.element.android.libraries.matrix.test.roomlist.FakeRoomListService
import io.element.android.libraries.matrix.test.sync.FakeSyncService
import io.element.android.libraries.matrix.test.verification.FakeSessionVerificationService
+import io.element.android.libraries.preferences.test.InMemorySessionPreferencesStore
import io.element.android.services.analytics.api.AnalyticsService
import io.element.android.services.analytics.test.FakeAnalyticsService
import io.element.android.tests.testutils.EventsRecorder
import io.element.android.tests.testutils.MutablePresenter
import io.element.android.tests.testutils.WarmUpRule
import io.element.android.tests.testutils.consumeItemsUntilPredicate
+import io.element.android.tests.testutils.lambda.assert
+import io.element.android.tests.testutils.lambda.lambdaRecorder
+import io.element.android.tests.testutils.lambda.value
+import io.element.android.tests.testutils.test
import io.element.android.tests.testutils.testCoroutineDispatchers
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
-import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.runTest
import org.junit.Rule
@@ -303,38 +308,6 @@ class RoomListPresenterTests {
}
}
- @Test
- fun `present - sets invite state`() = runTest {
- val inviteStateFlow = MutableStateFlow(InvitesState.NoInvites)
- val inviteStateDataSource = FakeInviteDataSource(inviteStateFlow)
- val roomListService = FakeRoomListService()
- val scope = CoroutineScope(coroutineContext + SupervisorJob())
- val presenter = createRoomListPresenter(
- inviteStateDataSource = inviteStateDataSource,
- coroutineScope = scope,
- client = FakeMatrixClient(roomListService = roomListService),
- )
- roomListService.postAllRoomsLoadingState(RoomList.LoadingState.Loaded(1))
- moleculeFlow(RecompositionMode.Immediate) {
- presenter.present()
- }.test {
- val firstItem = consumeItemsUntilPredicate {
- it.contentState is RoomListContentState.Rooms
- }.last()
- assertThat(firstItem.contentAsRooms().invitesState).isEqualTo(InvitesState.NoInvites)
-
- inviteStateFlow.value = InvitesState.SeenInvites
- assertThat(awaitItem().contentAsRooms().invitesState).isEqualTo(InvitesState.SeenInvites)
-
- inviteStateFlow.value = InvitesState.NewInvites
- assertThat(awaitItem().contentAsRooms().invitesState).isEqualTo(InvitesState.NewInvites)
-
- inviteStateFlow.value = InvitesState.NoInvites
- assertThat(awaitItem().contentAsRooms().invitesState).isEqualTo(InvitesState.NoInvites)
- scope.cancel()
- }
- }
-
@Test
fun `present - show context menu`() = runTest {
val scope = CoroutineScope(coroutineContext + SupervisorJob())
@@ -609,11 +582,53 @@ class RoomListPresenterTests {
}
}
+ @Test
+ fun `present - when a room is invited then accept and decline events are sent to acceptDeclinePresenter`() = runTest {
+ val eventSinkRecorder = lambdaRecorder { _: AcceptDeclineInviteEvents -> }
+ val acceptDeclinePresenter = Presenter {
+ anAcceptDeclineInviteState(eventSink = eventSinkRecorder)
+ }
+ val roomListService = FakeRoomListService()
+ val scope = CoroutineScope(coroutineContext + SupervisorJob())
+ val matrixClient = FakeMatrixClient(
+ roomListService = roomListService,
+ )
+ val roomSummary = aRoomSummaryFilled(
+ currentUserMembership = CurrentUserMembership.INVITED
+ )
+ roomListService.postAllRoomsLoadingState(RoomList.LoadingState.Loaded(1))
+ roomListService.postAllRooms(listOf(roomSummary))
+ val presenter = createRoomListPresenter(
+ coroutineScope = scope,
+ client = matrixClient,
+ acceptDeclineInvitePresenter = acceptDeclinePresenter
+ )
+ presenter.test {
+ val state = consumeItemsUntilPredicate {
+ it.contentState is RoomListContentState.Rooms
+ }.last()
+
+ val roomListRoomSummary = state.contentAsRooms().summaries.first {
+ it.id == roomSummary.identifier()
+ }
+ state.eventSink(RoomListEvents.AcceptInvite(roomListRoomSummary))
+ state.eventSink(RoomListEvents.DeclineInvite(roomListRoomSummary))
+
+ val inviteData = roomListRoomSummary.toInviteData()
+
+ assert(eventSinkRecorder)
+ .isCalledExactly(2)
+ .withSequence(
+ listOf(value(AcceptDeclineInviteEvents.AcceptInvite(inviteData))),
+ listOf(value(AcceptDeclineInviteEvents.DeclineInvite(inviteData))),
+ )
+ }
+ }
+
private fun TestScope.createRoomListPresenter(
client: MatrixClient = FakeMatrixClient(),
networkMonitor: NetworkMonitor = FakeNetworkMonitor(),
snackbarDispatcher: SnackbarDispatcher = SnackbarDispatcher(),
- inviteStateDataSource: InviteStateDataSource = FakeInviteDataSource(),
leaveRoomPresenter: LeaveRoomPresenter = FakeLeaveRoomPresenter(),
lastMessageTimestampFormatter: LastMessageTimestampFormatter = FakeLastMessageTimestampFormatter().apply {
givenFormat(A_FORMATTED_DATE)
@@ -626,11 +641,11 @@ class RoomListPresenterTests {
analyticsService: AnalyticsService = FakeAnalyticsService(),
filtersPresenter: Presenter = Presenter { aRoomListFiltersState() },
searchPresenter: Presenter = Presenter { aRoomListSearchState() },
+ acceptDeclineInvitePresenter: Presenter = Presenter { anAcceptDeclineInviteState() },
) = RoomListPresenter(
client = client,
networkMonitor = networkMonitor,
snackbarDispatcher = snackbarDispatcher,
- inviteStateDataSource = inviteStateDataSource,
leaveRoomPresenter = leaveRoomPresenter,
roomListDataSource = RoomListDataSource(
roomListService = client.roomListService,
@@ -652,5 +667,6 @@ class RoomListPresenterTests {
sessionPreferencesStore = sessionPreferencesStore,
filtersPresenter = filtersPresenter,
analyticsService = analyticsService,
+ acceptDeclineInvitePresenter = acceptDeclineInvitePresenter,
)
}
diff --git a/features/roomlist/impl/src/test/kotlin/io/element/android/features/roomlist/impl/RoomListViewTest.kt b/features/roomlist/impl/src/test/kotlin/io/element/android/features/roomlist/impl/RoomListViewTest.kt
index 2dbec36395..7507ab466b 100644
--- a/features/roomlist/impl/src/test/kotlin/io/element/android/features/roomlist/impl/RoomListViewTest.kt
+++ b/features/roomlist/impl/src/test/kotlin/io/element/android/features/roomlist/impl/RoomListViewTest.kt
@@ -26,6 +26,7 @@ import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performTouchInput
import androidx.test.ext.junit.runners.AndroidJUnit4
import io.element.android.features.roomlist.impl.components.RoomListMenuAction
+import io.element.android.features.roomlist.impl.model.RoomSummaryDisplayType
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.tests.testutils.EnsureNeverCalled
@@ -93,7 +94,9 @@ class RoomListViewTest {
val state = aRoomListState(
eventSink = eventsRecorder,
)
- val room0 = state.contentAsRooms().summaries.first()
+ val room0 = state.contentAsRooms().summaries.first {
+ it.displayType == RoomSummaryDisplayType.ROOM
+ }
ensureCalledOnceWithParam(room0.roomId) { callback ->
rule.setRoomListView(
state = state,
@@ -109,7 +112,9 @@ class RoomListViewTest {
val state = aRoomListState(
eventSink = eventsRecorder,
)
- val room0 = state.contentAsRooms().summaries.first()
+ val room0 = state.contentAsRooms().summaries.first {
+ it.displayType == RoomSummaryDisplayType.ROOM
+ }
rule.setRoomListView(
state = state,
)
@@ -136,19 +141,20 @@ class RoomListViewTest {
}
@Test
- fun `clicking on invites invokes the expected callback`() {
+ fun `clicking on accept and decline invite emits the expected Events`() {
val eventsRecorder = EventsRecorder()
val state = aRoomListState(
- contentState = aRoomsContentState(invitesState = InvitesState.NewInvites),
eventSink = eventsRecorder,
)
- ensureCalledOnce { callback ->
- rule.setRoomListView(
- state = state,
- onInvitesClicked = callback,
- )
- rule.clickOn(CommonStrings.action_invites_list)
+ val invitedRoom = state.contentAsRooms().summaries.first {
+ it.displayType == RoomSummaryDisplayType.INVITE
}
+ rule.setRoomListView(state = state)
+ rule.clickOn(CommonStrings.action_accept)
+ rule.clickOn(CommonStrings.action_decline)
+ eventsRecorder.assertList(
+ listOf(RoomListEvents.AcceptInvite(invitedRoom), RoomListEvents.DeclineInvite(invitedRoom)),
+ )
}
}
@@ -158,7 +164,6 @@ private fun AndroidComposeTestRule.setRoomL
onSettingsClicked: () -> Unit = EnsureNeverCalled(),
onConfirmRecoveryKeyClicked: () -> Unit = EnsureNeverCalled(),
onCreateRoomClicked: () -> Unit = EnsureNeverCalled(),
- onInvitesClicked: () -> Unit = EnsureNeverCalled(),
onRoomSettingsClicked: (RoomId) -> Unit = EnsureNeverCalledWithParam(),
onMenuActionClicked: (RoomListMenuAction) -> Unit = EnsureNeverCalledWithParam(),
onRoomDirectorySearchClicked: () -> Unit = EnsureNeverCalled(),
@@ -170,10 +175,10 @@ private fun AndroidComposeTestRule.setRoomL
onSettingsClicked = onSettingsClicked,
onConfirmRecoveryKeyClicked = onConfirmRecoveryKeyClicked,
onCreateRoomClicked = onCreateRoomClicked,
- onInvitesClicked = onInvitesClicked,
onRoomSettingsClicked = onRoomSettingsClicked,
onMenuActionClicked = onMenuActionClicked,
onRoomDirectorySearchClicked = onRoomDirectorySearchClicked,
+ acceptDeclineInviteView = { },
)
}
}
diff --git a/features/roomlist/impl/src/test/kotlin/io/element/android/features/roomlist/impl/datasource/DefaultInviteStateDataSourceTest.kt b/features/roomlist/impl/src/test/kotlin/io/element/android/features/roomlist/impl/datasource/DefaultInviteStateDataSourceTest.kt
deleted file mode 100644
index a1e08cd93b..0000000000
--- a/features/roomlist/impl/src/test/kotlin/io/element/android/features/roomlist/impl/datasource/DefaultInviteStateDataSourceTest.kt
+++ /dev/null
@@ -1,134 +0,0 @@
-/*
- * Copyright (c) 2023 New Vector Ltd
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-package io.element.android.features.roomlist.impl.datasource
-
-import app.cash.molecule.RecompositionMode
-import app.cash.molecule.moleculeFlow
-import app.cash.turbine.test
-import com.google.common.truth.Truth.assertThat
-import io.element.android.features.invite.test.FakeSeenInvitesStore
-import io.element.android.features.roomlist.impl.InvitesState
-import io.element.android.libraries.matrix.test.A_ROOM_ID
-import io.element.android.libraries.matrix.test.A_ROOM_ID_2
-import io.element.android.libraries.matrix.test.FakeMatrixClient
-import io.element.android.libraries.matrix.test.room.aRoomSummaryFilled
-import io.element.android.libraries.matrix.test.roomlist.FakeRoomListService
-import io.element.android.tests.testutils.testCoroutineDispatchers
-import kotlinx.coroutines.test.runTest
-import org.junit.Test
-
-internal class DefaultInviteStateDataSourceTest {
- @Test
- fun `emits NoInvites state if invites list is empty`() = runTest {
- val roomListService = FakeRoomListService()
- val client = FakeMatrixClient(roomListService = roomListService)
- val seenStore = FakeSeenInvitesStore()
- val dataSource = DefaultInviteStateDataSource(client, seenStore, testCoroutineDispatchers())
-
- moleculeFlow(RecompositionMode.Immediate) {
- dataSource.inviteState()
- }.test {
- assertThat(awaitItem()).isEqualTo(InvitesState.NoInvites)
- }
- }
-
- @Test
- fun `emits NewInvites state if unseen invite exists`() = runTest {
- val roomListService = FakeRoomListService()
- roomListService.postInviteRooms(listOf(aRoomSummaryFilled(roomId = A_ROOM_ID)))
- val client = FakeMatrixClient(roomListService = roomListService)
- val seenStore = FakeSeenInvitesStore()
- val dataSource = DefaultInviteStateDataSource(client, seenStore, testCoroutineDispatchers())
-
- moleculeFlow(RecompositionMode.Immediate) {
- dataSource.inviteState()
- }.test {
- skipItems(2)
- assertThat(awaitItem()).isEqualTo(InvitesState.NewInvites)
- }
- }
-
- @Test
- fun `emits NewInvites state if multiple invites exist and at least one is unseen`() = runTest {
- val roomListService = FakeRoomListService()
- roomListService.postInviteRooms(listOf(aRoomSummaryFilled(roomId = A_ROOM_ID), aRoomSummaryFilled(roomId = A_ROOM_ID_2)))
- val client = FakeMatrixClient(roomListService = roomListService)
- val seenStore = FakeSeenInvitesStore()
- seenStore.publishRoomIds(setOf(A_ROOM_ID))
- val dataSource = DefaultInviteStateDataSource(client, seenStore, testCoroutineDispatchers(useUnconfinedTestDispatcher = true))
-
- moleculeFlow(RecompositionMode.Immediate) {
- dataSource.inviteState()
- }.test {
- skipItems(1)
- assertThat(awaitItem()).isEqualTo(InvitesState.NewInvites)
- }
- }
-
- @Test
- fun `emits SeenInvites state if invite exists in seen store`() = runTest {
- val roomListService = FakeRoomListService()
- roomListService.postInviteRooms(listOf(aRoomSummaryFilled(roomId = A_ROOM_ID)))
- val client = FakeMatrixClient(roomListService = roomListService)
- val seenStore = FakeSeenInvitesStore()
- seenStore.publishRoomIds(setOf(A_ROOM_ID))
- val dataSource = DefaultInviteStateDataSource(client, seenStore, testCoroutineDispatchers(useUnconfinedTestDispatcher = true))
-
- moleculeFlow(RecompositionMode.Immediate) {
- dataSource.inviteState()
- }.test {
- skipItems(1)
-
- assertThat(awaitItem()).isEqualTo(InvitesState.SeenInvites)
- }
- }
-
- @Test
- fun `emits new state in response to upstream events`() = runTest {
- val roomListService = FakeRoomListService()
- val client = FakeMatrixClient(roomListService = roomListService)
- val seenStore = FakeSeenInvitesStore()
- val dataSource = DefaultInviteStateDataSource(client, seenStore, testCoroutineDispatchers())
-
- moleculeFlow(RecompositionMode.Immediate) {
- dataSource.inviteState()
- }.test {
- // Initially there are no invites
- assertThat(awaitItem()).isEqualTo(InvitesState.NoInvites)
-
- // When a single invite is received, state should be NewInvites
- roomListService.postInviteRooms(listOf(aRoomSummaryFilled(roomId = A_ROOM_ID)))
- skipItems(1)
- assertThat(awaitItem()).isEqualTo(InvitesState.NewInvites)
-
- // If that invite is marked as seen, then the state becomes SeenInvites
- seenStore.publishRoomIds(setOf(A_ROOM_ID))
- skipItems(1)
- assertThat(awaitItem()).isEqualTo(InvitesState.SeenInvites)
-
- // Another new invite resets it to NewInvites
- roomListService.postInviteRooms(listOf(aRoomSummaryFilled(roomId = A_ROOM_ID), aRoomSummaryFilled(roomId = A_ROOM_ID_2)))
- skipItems(1)
- assertThat(awaitItem()).isEqualTo(InvitesState.NewInvites)
-
- // All of the invites going away reverts to NoInvites
- roomListService.postInviteRooms(emptyList())
- skipItems(1)
- assertThat(awaitItem()).isEqualTo(InvitesState.NoInvites)
- }
- }
-}
diff --git a/features/roomlist/impl/src/test/kotlin/io/element/android/features/roomlist/impl/datasource/FakeInviteDataSource.kt b/features/roomlist/impl/src/test/kotlin/io/element/android/features/roomlist/impl/datasource/FakeInviteDataSource.kt
deleted file mode 100644
index 49f4a65f7f..0000000000
--- a/features/roomlist/impl/src/test/kotlin/io/element/android/features/roomlist/impl/datasource/FakeInviteDataSource.kt
+++ /dev/null
@@ -1,33 +0,0 @@
-/*
- * Copyright (c) 2023 New Vector Ltd
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-package io.element.android.features.roomlist.impl.datasource
-
-import androidx.compose.runtime.Composable
-import androidx.compose.runtime.collectAsState
-import io.element.android.features.roomlist.impl.InvitesState
-import kotlinx.coroutines.flow.Flow
-import kotlinx.coroutines.flow.flowOf
-
-class FakeInviteDataSource(
- private val flow: Flow = flowOf()
-) : InviteStateDataSource {
- @Composable
- override fun inviteState(): InvitesState {
- val state = flow.collectAsState(initial = InvitesState.NoInvites)
- return state.value
- }
-}
diff --git a/features/roomlist/impl/src/test/kotlin/io/element/android/features/roomlist/impl/filters/RoomListFiltersEmptyStateResourcesTest.kt b/features/roomlist/impl/src/test/kotlin/io/element/android/features/roomlist/impl/filters/RoomListFiltersEmptyStateResourcesTest.kt
index b9cdf5a03f..42a8f0e8da 100644
--- a/features/roomlist/impl/src/test/kotlin/io/element/android/features/roomlist/impl/filters/RoomListFiltersEmptyStateResourcesTest.kt
+++ b/features/roomlist/impl/src/test/kotlin/io/element/android/features/roomlist/impl/filters/RoomListFiltersEmptyStateResourcesTest.kt
@@ -64,6 +64,15 @@ class RoomListFiltersEmptyStateResourcesTest {
assertThat(result?.subtitle).isEqualTo(R.string.screen_roomlist_filter_favourites_empty_state_subtitle)
}
+ @Test
+ fun `fromSelectedFilters should return exact RoomListFiltersEmptyStateResources when selectedFilters has only invites filter`() {
+ val selectedFilters = listOf(RoomListFilter.Invites)
+ val result = RoomListFiltersEmptyStateResources.fromSelectedFilters(selectedFilters)
+ assertThat(result).isNotNull()
+ assertThat(result?.title).isEqualTo(R.string.screen_roomlist_filter_invites_empty_state_title)
+ assertThat(result?.subtitle).isEqualTo(R.string.screen_roomlist_filter_mixed_empty_state_subtitle)
+ }
+
@Test
fun `fromSelectedFilters should return exact RoomListFiltersEmptyStateResources when selectedFilters has multiple filters`() {
val selectedFilters = listOf(RoomListFilter.Unread, RoomListFilter.People, RoomListFilter.Rooms, RoomListFilter.Favourites)
diff --git a/features/roomlist/impl/src/test/kotlin/io/element/android/features/roomlist/impl/filters/RoomListFiltersPresenterTests.kt b/features/roomlist/impl/src/test/kotlin/io/element/android/features/roomlist/impl/filters/RoomListFiltersPresenterTests.kt
index 756fe1aa0e..8d1fa69276 100644
--- a/features/roomlist/impl/src/test/kotlin/io/element/android/features/roomlist/impl/filters/RoomListFiltersPresenterTests.kt
+++ b/features/roomlist/impl/src/test/kotlin/io/element/android/features/roomlist/impl/filters/RoomListFiltersPresenterTests.kt
@@ -22,8 +22,6 @@ import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
import io.element.android.features.roomlist.impl.filters.selection.DefaultFilterSelectionStrategy
import io.element.android.features.roomlist.impl.filters.selection.FilterSelectionState
-import io.element.android.libraries.featureflag.api.FeatureFlagService
-import io.element.android.libraries.featureflag.test.FakeFeatureFlagService
import io.element.android.libraries.matrix.api.roomlist.RoomListService
import io.element.android.libraries.matrix.test.roomlist.FakeRoomListService
import io.element.android.tests.testutils.awaitLastSequentialItem
@@ -45,6 +43,7 @@ class RoomListFiltersPresenterTests {
filterSelectionState(RoomListFilter.People, false),
filterSelectionState(RoomListFilter.Rooms, false),
filterSelectionState(RoomListFilter.Favourites, false),
+ filterSelectionState(RoomListFilter.Invites, false),
)
}
cancelAndIgnoreRemainingEvents()
@@ -84,6 +83,7 @@ class RoomListFiltersPresenterTests {
filterSelectionState(RoomListFilter.People, false),
filterSelectionState(RoomListFilter.Rooms, false),
filterSelectionState(RoomListFilter.Favourites, false),
+ filterSelectionState(RoomListFilter.Invites, false),
).inOrder()
assertThat(state.selectedFilters()).isEmpty()
val roomListCurrentFilter = roomListService.allRooms.currentFilter.value as MatrixRoomListFilter.All
@@ -118,11 +118,9 @@ private fun filterSelectionState(filter: RoomListFilter, selected: Boolean) = Fi
private fun createRoomListFiltersPresenter(
roomListService: RoomListService = FakeRoomListService(),
- featureFlagService: FeatureFlagService = FakeFeatureFlagService(),
): RoomListFiltersPresenter {
return RoomListFiltersPresenter(
roomListService = roomListService,
- featureFlagService = featureFlagService,
filterSelectionStrategy = DefaultFilterSelectionStrategy(),
)
}
diff --git a/features/roomlist/impl/src/test/kotlin/io/element/android/features/roomlist/impl/model/RoomListRoomSummaryTest.kt b/features/roomlist/impl/src/test/kotlin/io/element/android/features/roomlist/impl/model/RoomListRoomSummaryTest.kt
index 5cdd8ef39c..6ad7c07960 100644
--- a/features/roomlist/impl/src/test/kotlin/io/element/android/features/roomlist/impl/model/RoomListRoomSummaryTest.kt
+++ b/features/roomlist/impl/src/test/kotlin/io/element/android/features/roomlist/impl/model/RoomListRoomSummaryTest.kt
@@ -72,6 +72,15 @@ class RoomListRoomSummaryTest {
assertThat(sut.isHighlighted).isTrue()
assertThat(sut.hasNewContent).isTrue()
}
+
+ @Test
+ fun `when display type is invite then isHighlighted and hasNewContent are true`() {
+ val sut = createRoomListRoomSummary(
+ displayType = RoomSummaryDisplayType.INVITE,
+ )
+ assertThat(sut.isHighlighted).isTrue()
+ assertThat(sut.hasNewContent).isTrue()
+ }
}
internal fun createRoomListRoomSummary(
@@ -81,6 +90,7 @@ internal fun createRoomListRoomSummary(
isMarkedUnread: Boolean = false,
userDefinedNotificationMode: RoomNotificationMode? = null,
isFavorite: Boolean = false,
+ displayType: RoomSummaryDisplayType = RoomSummaryDisplayType.ROOM,
) = RoomListRoomSummary(
id = A_ROOM_ID.value,
roomId = A_ROOM_ID,
@@ -92,9 +102,12 @@ internal fun createRoomListRoomSummary(
timestamp = A_FORMATTED_DATE,
lastMessage = "",
avatarData = AvatarData(id = A_ROOM_ID.value, name = A_ROOM_NAME, size = AvatarSize.RoomListItem),
- isPlaceholder = false,
+ displayType = displayType,
userDefinedNotificationMode = userDefinedNotificationMode,
hasRoomCall = false,
- isDm = false,
+ isDirect = false,
isFavorite = isFavorite,
+ canonicalAlias = null,
+ inviteSender = null,
+ isDm = false,
)
diff --git a/features/roomlist/impl/src/test/kotlin/io/element/android/features/roomlist/impl/search/RoomListSearchViewTest.kt b/features/roomlist/impl/src/test/kotlin/io/element/android/features/roomlist/impl/search/RoomListSearchViewTest.kt
new file mode 100644
index 0000000000..b3f0755f11
--- /dev/null
+++ b/features/roomlist/impl/src/test/kotlin/io/element/android/features/roomlist/impl/search/RoomListSearchViewTest.kt
@@ -0,0 +1,71 @@
+/*
+ * Copyright (c) 2024 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.element.android.features.roomlist.impl.search
+
+import androidx.activity.ComponentActivity
+import androidx.compose.ui.test.junit4.AndroidComposeTestRule
+import androidx.compose.ui.test.junit4.createAndroidComposeRule
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import io.element.android.features.roomlist.impl.R
+import io.element.android.features.roomlist.impl.RoomListEvents
+import io.element.android.libraries.matrix.api.core.RoomId
+import io.element.android.tests.testutils.EnsureNeverCalled
+import io.element.android.tests.testutils.EnsureNeverCalledWithParam
+import io.element.android.tests.testutils.EventsRecorder
+import io.element.android.tests.testutils.clickOn
+import io.element.android.tests.testutils.ensureCalledOnce
+import org.junit.Rule
+import org.junit.Test
+import org.junit.rules.TestRule
+import org.junit.runner.RunWith
+
+@RunWith(AndroidJUnit4::class)
+class RoomListSearchViewTest {
+ @get:Rule val rule = createAndroidComposeRule()
+
+ @Test
+ fun `clicking on 'Browse all rooms' invokes the expected callback`() {
+ val eventsRecorder = EventsRecorder(expectEvents = false)
+ ensureCalledOnce {
+ rule.setRoomListSearchView(
+ aRoomListSearchState(
+ isSearchActive = true,
+ isRoomDirectorySearchEnabled = true,
+ eventSink = eventsRecorder,
+ ),
+ onRoomDirectorySearchClicked = it,
+ )
+ rule.clickOn(R.string.screen_roomlist_room_directory_button_title)
+ }
+ }
+}
+
+private fun AndroidComposeTestRule.setRoomListSearchView(
+ state: RoomListSearchState,
+ eventSink: (RoomListEvents) -> Unit = EventsRecorder(expectEvents = false),
+ onRoomClicked: (RoomId) -> Unit = EnsureNeverCalledWithParam(),
+ onRoomDirectorySearchClicked: () -> Unit = EnsureNeverCalled(),
+) {
+ setContent {
+ RoomListSearchView(
+ state = state,
+ eventSink = eventSink,
+ onRoomClicked = onRoomClicked,
+ onRoomDirectorySearchClicked = onRoomDirectorySearchClicked,
+ )
+ }
+}
diff --git a/features/securebackup/api/src/main/kotlin/io/element/android/features/securebackup/api/SecureBackupEntryPoint.kt b/features/securebackup/api/src/main/kotlin/io/element/android/features/securebackup/api/SecureBackupEntryPoint.kt
index 1904ceb2ff..45e3a75738 100644
--- a/features/securebackup/api/src/main/kotlin/io/element/android/features/securebackup/api/SecureBackupEntryPoint.kt
+++ b/features/securebackup/api/src/main/kotlin/io/element/android/features/securebackup/api/SecureBackupEntryPoint.kt
@@ -41,7 +41,6 @@ interface SecureBackupEntryPoint : FeatureEntryPoint {
fun nodeBuilder(parentNode: Node, buildContext: BuildContext): NodeBuilder
interface Callback : Plugin {
- fun onCreateNewRecoveryKey()
fun onDone()
}
diff --git a/features/securebackup/impl/build.gradle.kts b/features/securebackup/impl/build.gradle.kts
index 1d7a97b344..b1060dcd0e 100644
--- a/features/securebackup/impl/build.gradle.kts
+++ b/features/securebackup/impl/build.gradle.kts
@@ -23,6 +23,12 @@ plugins {
android {
namespace = "io.element.android.features.securebackup.impl"
+
+ testOptions {
+ unitTests {
+ isIncludeAndroidResources = true
+ }
+ }
}
anvil {
@@ -51,8 +57,11 @@ dependencies {
testImplementation(libs.molecule.runtime)
testImplementation(libs.test.truth)
testImplementation(libs.test.turbine)
+ testImplementation(libs.test.robolectric)
+ testImplementation(libs.androidx.compose.ui.test.junit)
testImplementation(projects.libraries.matrix.test)
testImplementation(projects.tests.testutils)
+ testReleaseImplementation(libs.androidx.compose.ui.test.manifest)
ksp(libs.showkase.processor)
}
diff --git a/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/SecureBackupFlowNode.kt b/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/SecureBackupFlowNode.kt
index 1f22e57c03..f696066181 100644
--- a/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/SecureBackupFlowNode.kt
+++ b/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/SecureBackupFlowNode.kt
@@ -136,6 +136,10 @@ class SecureBackupFlowNode @AssistedInject constructor(
backstack.pop()
}
}
+
+ override fun onCreateNewRecoveryKey() {
+ backstack.push(NavTarget.CreateNewRecoveryKey)
+ }
}
createNode(buildContext, plugins = listOf(callback))
}
diff --git a/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/enter/SecureBackupEnterRecoveryKeyNode.kt b/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/enter/SecureBackupEnterRecoveryKeyNode.kt
index 597ab4c14e..c80becb88a 100644
--- a/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/enter/SecureBackupEnterRecoveryKeyNode.kt
+++ b/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/enter/SecureBackupEnterRecoveryKeyNode.kt
@@ -35,6 +35,7 @@ class SecureBackupEnterRecoveryKeyNode @AssistedInject constructor(
) : Node(buildContext, plugins = plugins) {
interface Callback : Plugin {
fun onEnterRecoveryKeySuccess()
+ fun onCreateNewRecoveryKey()
}
private val callback = plugins().first()
@@ -47,6 +48,7 @@ class SecureBackupEnterRecoveryKeyNode @AssistedInject constructor(
modifier = modifier,
onDone = callback::onEnterRecoveryKeySuccess,
onBackClicked = ::navigateUp,
+ onCreateNewRecoveryKey = callback::onCreateNewRecoveryKey
)
}
}
diff --git a/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/enter/SecureBackupEnterRecoveryKeyStateProvider.kt b/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/enter/SecureBackupEnterRecoveryKeyStateProvider.kt
index 2869b9a281..7052c043c7 100644
--- a/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/enter/SecureBackupEnterRecoveryKeyStateProvider.kt
+++ b/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/enter/SecureBackupEnterRecoveryKeyStateProvider.kt
@@ -36,6 +36,7 @@ fun aSecureBackupEnterRecoveryKeyState(
recoveryKey: String = aFormattedRecoveryKey(),
isSubmitEnabled: Boolean = recoveryKey.isNotEmpty(),
submitAction: AsyncAction = AsyncAction.Uninitialized,
+ eventSink: (SecureBackupEnterRecoveryKeyEvents) -> Unit = {},
) = SecureBackupEnterRecoveryKeyState(
recoveryKeyViewState = RecoveryKeyViewState(
recoveryKeyUserStory = RecoveryKeyUserStory.Enter,
@@ -44,5 +45,5 @@ fun aSecureBackupEnterRecoveryKeyState(
),
isSubmitEnabled = isSubmitEnabled,
submitAction = submitAction,
- eventSink = {}
+ eventSink = eventSink,
)
diff --git a/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/enter/SecureBackupEnterRecoveryKeyView.kt b/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/enter/SecureBackupEnterRecoveryKeyView.kt
index b74078c7f2..db23018c6f 100644
--- a/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/enter/SecureBackupEnterRecoveryKeyView.kt
+++ b/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/enter/SecureBackupEnterRecoveryKeyView.kt
@@ -32,6 +32,7 @@ import io.element.android.libraries.designsystem.components.async.AsyncActionVie
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.Button
+import io.element.android.libraries.designsystem.theme.components.TextButton
import io.element.android.libraries.ui.strings.CommonStrings
@Composable
@@ -39,6 +40,7 @@ fun SecureBackupEnterRecoveryKeyView(
state: SecureBackupEnterRecoveryKeyState,
onDone: () -> Unit,
onBackClicked: () -> Unit,
+ onCreateNewRecoveryKey: () -> Unit,
modifier: Modifier = Modifier,
) {
AsyncActionView(
@@ -57,7 +59,7 @@ fun SecureBackupEnterRecoveryKeyView(
title = stringResource(id = R.string.screen_recovery_key_confirm_title),
subTitle = stringResource(id = R.string.screen_recovery_key_confirm_description),
content = { Content(state = state) },
- buttons = { Buttons(state = state) }
+ buttons = { Buttons(state = state, onCreateRecoveryKey = onCreateNewRecoveryKey) }
)
}
@@ -81,6 +83,7 @@ private fun Content(
@Composable
private fun ColumnScope.Buttons(
state: SecureBackupEnterRecoveryKeyState,
+ onCreateRecoveryKey: () -> Unit,
) {
Button(
text = stringResource(id = CommonStrings.action_continue),
@@ -91,6 +94,12 @@ private fun ColumnScope.Buttons(
state.eventSink.invoke(SecureBackupEnterRecoveryKeyEvents.Submit)
}
)
+ TextButton(
+ text = stringResource(id = R.string.screen_recovery_key_confirm_lost_recovery_key),
+ enabled = !state.submitAction.isLoading(),
+ modifier = Modifier.fillMaxWidth(),
+ onClick = onCreateRecoveryKey,
+ )
}
@PreviewsDayNight
@@ -102,5 +111,6 @@ internal fun SecureBackupEnterRecoveryKeyViewPreview(
state = state,
onDone = {},
onBackClicked = {},
+ onCreateNewRecoveryKey = {},
)
}
diff --git a/features/securebackup/impl/src/main/res/values-be/translations.xml b/features/securebackup/impl/src/main/res/values-be/translations.xml
index 9710ac77b1..b4a71d766f 100644
--- a/features/securebackup/impl/src/main/res/values-be/translations.xml
+++ b/features/securebackup/impl/src/main/res/values-be/translations.xml
@@ -21,7 +21,7 @@
"Вы ўпэўнены, што хочаце адключыць рэзервовае капіраванне?"
"Адключэнне рэзервовага капіравання прывядзе да выдалення бягучай рэзервовай копіі ключа шыфравання і адключэння іншых функцый бяспекі. У гэтым выпадку вы:"
"Не будзеце мець зашыфраванай гісторыі паведамленняў на новых прыладах"
- "Страціце доступ да зашыфраваных паведамленняў, калі вы выйдзеце з усіх %1$s сеансаў"
+ "Страціце доступ да зашыфраваных паведамленняў, калі вы выйдзеце з усіх сеансаў %1$s"
"Вы ўпэўнены, што хочаце адключыць рэзервовае капіраванне?"
"Атрымайце новы ключ аднаўлення, калі вы страцілі існуючы. Пасля змены ключа аднаўлення ваш стары больш не будзе працаваць."
"Стварыць новы ключ аднаўлення"
@@ -35,8 +35,9 @@
"Калі ў вас ёсць ключ аднаўлення або парольная фраза, гэта таксама будзе працаваць."
"Ключ аднаўлення або код доступу"
"Увесці…"
+ "Страцілі ключ аднаўлення?"
"Ключ аднаўлення пацверджаны"
- "Увядзіце ключ аднаўлення або код доступу"
+ "Увядзіце ключ аднаўлення"
"Ключ аднаўлення скапіраваны"
"Стварэнне…"
"Захаваць ключ аднаўлення"
diff --git a/features/securebackup/impl/src/main/res/values-bg/translations.xml b/features/securebackup/impl/src/main/res/values-bg/translations.xml
index bc74654eed..1b9eca4c09 100644
--- a/features/securebackup/impl/src/main/res/values-bg/translations.xml
+++ b/features/securebackup/impl/src/main/res/values-bg/translations.xml
@@ -10,7 +10,7 @@
"Изключване"
"Генериране на нов ключ за възстановяване"
"Промяна на ключа за възстановяване?"
- "Въведете ключа си за възстановяване, за да потвърдите достъпа до резервното копие на чатовете си."
+ "Уверете се, че никой не може да види този екран!"
"Неправилен ключ за възстановяване"
"Въведете 48-символния код."
"Въведете…"
diff --git a/features/securebackup/impl/src/main/res/values-cs/translations.xml b/features/securebackup/impl/src/main/res/values-cs/translations.xml
index 5f9ef80e54..18255155d2 100644
--- a/features/securebackup/impl/src/main/res/values-cs/translations.xml
+++ b/features/securebackup/impl/src/main/res/values-cs/translations.xml
@@ -35,8 +35,9 @@
"Pokud máte bezpečnostní klíč nebo bezpečnostní frázi, bude to fungovat také."
"Klíč pro obnovení nebo přístupový kód"
"Zadejte…"
+ "Ztratili jste klíč pro obnovení?"
"Klíč pro obnovení potvrzen"
- "Potvrďte klíč pro obnovení"
+ "Zadejte klíč pro obnovení"
"Klíč pro obnovení zkopírován"
"Generování…"
"Uložit klíč pro obnovení"
diff --git a/features/securebackup/impl/src/main/res/values-de/translations.xml b/features/securebackup/impl/src/main/res/values-de/translations.xml
index f78f0cb3e3..c95e51db7e 100644
--- a/features/securebackup/impl/src/main/res/values-de/translations.xml
+++ b/features/securebackup/impl/src/main/res/values-de/translations.xml
@@ -31,13 +31,14 @@
"Ausschalten"
"Du verlierst deine verschlüsselten Nachrichten, wenn du auf allen Geräten abgemeldet bist."
- "Bist du sicher, dass du das Backup ausschalten willst?"
- "Wenn du das Backup ausschaltest, wird dein aktuelles Backup des Verschlüsselungsschlüssels entfernt und andere Sicherheitsfunktionen werden deaktiviert. In diesem Fall wirst du:"
- "Kein verschlüsselter Nachrichtenverlauf auf neuen Geräten"
+ "Bist du sicher, dass du das Backup deaktivieren willst?"
+ "Wenn du das Backup deaktivierst, wird dein aktuelles Backup des Verschlüsselungsschlüssels entfernt und andere Sicherheitsfunktionen werden deaktiviert.
+Das bedeutet:"
+ "Keine Historie für verschlüsselte Nachrichten auf neuen Geräten ."
"Du verlierst den Zugriff auf deine verschlüsselten Nachrichten, wenn du dich überall von %1$s abmeldest"
- "Bist du sicher, dass du das Backup ausschalten willst?"
- "Besorge dir einen neuen Wiederherstellungsschlüssel, wenn du deinen alten verloren hast. Nachdem du deinen Wiederherstellungsschlüssel geändert hast, funktioniert dein alter Schlüssel nicht mehr."
- "Erstelle einen neuen Wiederherstellungsschlüssel"
+ "Bist du sicher, dass du das Backup deaktivieren willst?"
+ "Hier kannst Du einen neuen Wiederherstellungsschlüssel erstellen. Nachdem Du einen neuen Wiederherstellungsschlüssel erstellt hast, funktioniert dein alter Schlüssel nicht mehr."
+ "Wiederherstellungsschlüssel erstellen"
"Stelle sicher, dass du deinen Wiederherstellungsschlüssel an einem sicheren Ort aufbewahren kannst"
"Wiederherstellungsschlüssel geändert"
"Wiederherstellungsschlüssel ändern?"
@@ -55,8 +56,9 @@
" oder Passcode"
"Eingeben…"
+ "Hast du deinen Wiederherstellungschlüssel vergessen?"
"Wiederherstellungsschlüssel bestätigt"
- "Wiederherstellungsschlüssel oder Passcode bestätigen"
+ "Bitte Wiederherstellungsschlüssel eingeben"
"Wiederherstellungsschlüssel kopiert"
"Generieren…"
"Wiederherstellungsschlüssel speichern"
diff --git a/features/securebackup/impl/src/main/res/values-fr/translations.xml b/features/securebackup/impl/src/main/res/values-fr/translations.xml
index 5cedec5328..824e786cc4 100644
--- a/features/securebackup/impl/src/main/res/values-fr/translations.xml
+++ b/features/securebackup/impl/src/main/res/values-fr/translations.xml
@@ -19,13 +19,13 @@
"Désactiver"
"Vous perdrez vos messages chiffrés si vous vous déconnectez de toutes vos sessions."
"Êtes-vous certain de vouloir désactiver la sauvegarde?"
- "Désactiver la sauvegarde supprimera votre clé de récupération actuelle et désactivera d’autres mesures de sécurité. Dans ce cas, vous:"
+ "Désactiver la sauvegarde supprimera votre clé de récupération actuelle et désactivera d’autres mesures de sécurité. Dans ce cas:"
"Pas d’accès à l’historique des discussions chiffrées sur vos nouveaux appareils"
"Perte de l’accès à vos messages chiffrés si vous êtes déconnectés de %1$s partout"
"Êtes-vous certain de vouloir désactiver la sauvegarde?"
"Obtenez une nouvelle clé de récupération dans le cas où vous avez oublié l’ancienne. Après le changement, l’ancienne clé ne sera plus utilisable."
"Générer une nouvelle clé"
- "Assurez-vous de conserver la clé dans un endroit sûr."
+ "Assurez-vous de conserver la clé dans un endroit sûr"
"Clé de récupération modifée"
"Changer la clé de récupération?"
"Créer une nouvelle clé de récupération"
@@ -35,6 +35,7 @@
"Si vous avez une clé de sécurité ou une phrase de sécurité, cela fonctionnera également."
"Clé de récupération"
"Saisissez la clé ici…"
+ "Clé de récupération perdue?"
"Clé de récupération confirmée"
"Saisissez votre clé de récupération"
"Clé de récupération copiée"
@@ -45,9 +46,9 @@
"Sauvegarder la clé"
"La clé ne pourra plus être affichée après cette étape."
"Avez-vous sauvegardé votre clé de récupération?"
- "Votre sauvegarde est protégée par votre clé de récupération. Si vous avez besoin d’une nouvelle clé après la configuration, vous pourrez en créer une nouvelle en cliquant sur \"Changer la clé de récupération\""
+ "Votre sauvegarde est protégée par votre clé de récupération. Si vous avez besoin d’une nouvelle clé après la configuration, vous pourrez en créer une nouvelle en cliquant sur \"Changer la clé de récupération\"."
"Générer la clé de récupération"
- "Assurez-vous de pouvoir enregistrer votre clé dans un endroit sécurisé."
+ "Assurez-vous de pouvoir enregistrer votre clé dans un endroit sécurisé"
"Sauvegarde mise en place avec succès"
"Configurer la sauvegarde"
diff --git a/features/securebackup/impl/src/main/res/values-hu/translations.xml b/features/securebackup/impl/src/main/res/values-hu/translations.xml
index 944174f434..ffa1c31be3 100644
--- a/features/securebackup/impl/src/main/res/values-hu/translations.xml
+++ b/features/securebackup/impl/src/main/res/values-hu/translations.xml
@@ -20,7 +20,7 @@
"Ha kijelentkezik az összes eszközéről, akkor elveszti a titkosított üzeneteit."
"Biztos, hogy kikapcsolja a biztonsági mentéseket?"
"A biztonsági mentés kikapcsolása eltávolítja a jelenlegi titkosítási kulcsának mentését, és kikapcsol más biztonsági funkciókat is. Ebben az esetben:"
- "Nem lesznek meg a titkosított üzenetelőzményei az új eszközein"
+ "Nem lesznek meg a titkosított üzenetek előzményei az új eszközein"
"Elveszti a titkosított üzenetei hozzáférését, ha mindenhol kilép az %1$sből"
"Biztos, hogy kikapcsolja a biztonsági mentéseket?"
"Szerezzen új helyreállítási kulcsot, ha elvesztette a meglévőt. A helyreállítása kulcsa módosítása után a régi már nem fog működni."
@@ -35,8 +35,9 @@
"Ha van biztonsági kulcsa vagy biztonsági jelmondata, akkor ez is fog működni."
"Helyreállítási kulcs vagy jelkód"
"Megadás…"
+ "Elvesztette a helyreállítási kulcsát?"
"Helyreállítási kulcs megerősítve"
- "Adja meg a helyreállítási kulcsát vagy a jelkódját"
+ "Adja meg a helyreállítási kulcsát"
"Helyreállítási kulcs másolva"
"Előállítás…"
"Helyreállítási kulcs mentése"
diff --git a/features/securebackup/impl/src/main/res/values-it/translations.xml b/features/securebackup/impl/src/main/res/values-it/translations.xml
index 60211a4347..ba2948e8a5 100644
--- a/features/securebackup/impl/src/main/res/values-it/translations.xml
+++ b/features/securebackup/impl/src/main/res/values-it/translations.xml
@@ -21,13 +21,13 @@
"Assicurati di conservare la chiave di recupero in un posto sicuro"
"Chiave di recupero cambiata"
"Cambiare la chiave di recupero?"
- "Inserisci la tua chiave di recupero per confermare l\'accesso al backup della chat."
+ "Assicurati che nessuno possa vedere questa schermata!"
"Riprova per confermare l\'accesso al backup della chat."
"Chiave di recupero errata"
- "Inserisci il codice di 48 caratteri."
+ "Se hai una chiave di sicurezza o una password, andrà bene anche questo."
"Inserisci…"
"Chiave di recupero confermata"
- "Conferma la chiave di recupero"
+ "Inserisci la chiave o password di recupero"
"Chiave di recupero copiata"
"Generazione…"
"Salva la chiave di recupero"
diff --git a/features/securebackup/impl/src/main/res/values-sk/translations.xml b/features/securebackup/impl/src/main/res/values-sk/translations.xml
index 8b6be92219..64ec3c7fac 100644
--- a/features/securebackup/impl/src/main/res/values-sk/translations.xml
+++ b/features/securebackup/impl/src/main/res/values-sk/translations.xml
@@ -21,7 +21,7 @@
"Ste si istí, že chcete vypnúť zálohovanie?"
"Vypnutím zálohovania sa odstráni aktuálna záloha šifrovacích kľúčov a vypnú sa ďalšie bezpečnostné funkcie. V tomto prípade:"
"Na nových zariadeniach nebudete mať zašifrovanú históriu správ"
- "Stratíte prístup k svojim zašifrovaným správam, ak sa odhlásite zo všetkých %1$s zariadení"
+ "Stratíte prístup k svojim zašifrovaným správam, ak sa odhlásite z aplikácie %1$s na všetkých zariadeniach"
"Ste si istí, že chcete vypnúť zálohovanie?"
"Získajte nový kľúč na obnovenie, ak ste stratili svoj existujúci. Po zmene kľúča na obnovenie už starý kľúč nebude fungovať."
"Vygenerovať nový kľúč na obnovenie"
@@ -35,8 +35,9 @@
"Ak máte bezpečnostný kľúč alebo bezpečnostnú frázu, bude to fungovať tiež."
"Kľúč na obnovenie alebo prístupový kód"
"Zadať…"
+ "Stratili ste kľúč na obnovenie?"
"Kľúč na obnovu potvrdený"
- "Zadajte kľúč na obnovenie alebo prístupový kód"
+ "Zadajte kľúč na obnovenie"
"Skopírovaný kľúč na obnovenie"
"Generovanie…"
"Uložiť kľúč na obnovenie"
diff --git a/features/securebackup/impl/src/main/res/values-sv/translations.xml b/features/securebackup/impl/src/main/res/values-sv/translations.xml
index 99c9c97b8a..a1b1512031 100644
--- a/features/securebackup/impl/src/main/res/values-sv/translations.xml
+++ b/features/securebackup/impl/src/main/res/values-sv/translations.xml
@@ -21,11 +21,15 @@
"Se till att du kan lagra din återställningsnyckel någonstans säkert"
"Återställningsnyckel ändrad"
"Byt återställningsnyckel?"
- "Ange din återställningsnyckel för att bekräfta åtkomst till din chattsäkerhetskopia."
- "Ange koden på 48 tecken."
+ "Se till att ingen kan se den här skärmen"
+ "Vänligen pröva igen för att bekräfta åtkomsten till din chattsäkerhetskopia."
+ "Felaktig återställningsnyckel"
+ "Om du har en säkerhetsnyckel eller säkerhetsfras så funkar den också."
"Ange …"
"Återställningsnyckel bekräftad"
"Ange din återställningsnyckel"
+ "Kopierade återställningsnyckel"
+ "Genererar …"
"Spara återställningsnyckeln"
"Skriv ner din återställningsnyckel någonstans säkert eller spara den i en lösenordshanterare."
"Tryck för att kopiera återställningsnyckeln"
diff --git a/features/securebackup/impl/src/main/res/values/localazy.xml b/features/securebackup/impl/src/main/res/values/localazy.xml
index d3fb41857e..e1159031a2 100644
--- a/features/securebackup/impl/src/main/res/values/localazy.xml
+++ b/features/securebackup/impl/src/main/res/values/localazy.xml
@@ -35,8 +35,9 @@
"If you have a security key or security phrase, this will work too."
"Recovery key or passcode"
"Enter…"
+ "Lost your recovery key?"
"Recovery key confirmed"
- "Enter your recovery key or passcode"
+ "Enter your recovery key"
"Copied recovery key"
"Generating…"
"Save recovery key"
diff --git a/features/securebackup/impl/src/test/kotlin/io/element/android/features/securebackup/impl/enter/SecureBackupEnterRecoveryKeyViewTest.kt b/features/securebackup/impl/src/test/kotlin/io/element/android/features/securebackup/impl/enter/SecureBackupEnterRecoveryKeyViewTest.kt
new file mode 100644
index 0000000000..f074116af1
--- /dev/null
+++ b/features/securebackup/impl/src/test/kotlin/io/element/android/features/securebackup/impl/enter/SecureBackupEnterRecoveryKeyViewTest.kt
@@ -0,0 +1,110 @@
+/*
+ * Copyright (c) 2024 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.element.android.features.securebackup.impl.enter
+
+import androidx.activity.ComponentActivity
+import androidx.compose.ui.test.junit4.AndroidComposeTestRule
+import androidx.compose.ui.test.junit4.createAndroidComposeRule
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import io.element.android.features.securebackup.impl.R
+import io.element.android.libraries.architecture.AsyncAction
+import io.element.android.libraries.ui.strings.CommonStrings
+import io.element.android.tests.testutils.EnsureNeverCalled
+import io.element.android.tests.testutils.EventsRecorder
+import io.element.android.tests.testutils.clickOn
+import io.element.android.tests.testutils.ensureCalledOnce
+import io.element.android.tests.testutils.pressBack
+import io.element.android.tests.testutils.pressBackKey
+import org.junit.Rule
+import org.junit.Test
+import org.junit.rules.TestRule
+import org.junit.runner.RunWith
+
+@RunWith(AndroidJUnit4::class)
+class SecureBackupEnterRecoveryKeyViewTest {
+ @get:Rule val rule = createAndroidComposeRule()
+
+ @Test
+ fun `back key pressed - calls onBackClicked`() {
+ ensureCalledOnce { callback ->
+ rule.setSecureBackupEnterRecoveryKeyView(
+ aSecureBackupEnterRecoveryKeyState(),
+ onBackClicked = callback,
+ )
+ rule.pressBackKey()
+ }
+ }
+
+ @Test
+ fun `back button clicked - calls onBackClicked`() {
+ ensureCalledOnce { callback ->
+ rule.setSecureBackupEnterRecoveryKeyView(
+ aSecureBackupEnterRecoveryKeyState(),
+ onBackClicked = callback,
+ )
+ rule.pressBack()
+ }
+ }
+
+ @Test
+ fun `tapping on Continue when key is valid - calls expected action`() {
+ val recorder = EventsRecorder()
+ rule.setSecureBackupEnterRecoveryKeyView(
+ aSecureBackupEnterRecoveryKeyState(isSubmitEnabled = true, eventSink = recorder),
+ )
+ rule.clickOn(CommonStrings.action_continue)
+
+ recorder.assertSingle(SecureBackupEnterRecoveryKeyEvents.Submit)
+ }
+
+ @Test
+ fun `tapping on Lost your recovery key - calls onCreateNewRecoveryKey`() {
+ ensureCalledOnce { callback ->
+ rule.setSecureBackupEnterRecoveryKeyView(
+ aSecureBackupEnterRecoveryKeyState(),
+ onCreateNewRecoveryKey = callback,
+ )
+ rule.clickOn(R.string.screen_recovery_key_confirm_lost_recovery_key)
+ }
+ }
+
+ @Test
+ fun `when submit action succeeds - calls onDone`() {
+ ensureCalledOnce { callback ->
+ rule.setSecureBackupEnterRecoveryKeyView(
+ aSecureBackupEnterRecoveryKeyState(submitAction = AsyncAction.Success(Unit)),
+ onDone = callback,
+ )
+ }
+ }
+
+ private fun AndroidComposeTestRule.setSecureBackupEnterRecoveryKeyView(
+ state: SecureBackupEnterRecoveryKeyState,
+ onDone: () -> Unit = EnsureNeverCalled(),
+ onBackClicked: () -> Unit = EnsureNeverCalled(),
+ onCreateNewRecoveryKey: () -> Unit = EnsureNeverCalled(),
+ ) {
+ rule.setContent {
+ SecureBackupEnterRecoveryKeyView(
+ state = state,
+ onDone = onDone,
+ onBackClicked = onBackClicked,
+ onCreateNewRecoveryKey = onCreateNewRecoveryKey
+ )
+ }
+ }
+}
diff --git a/features/signedout/impl/src/main/kotlin/io/element/android/features/signedout/impl/SignedOutStateProvider.kt b/features/signedout/impl/src/main/kotlin/io/element/android/features/signedout/impl/SignedOutStateProvider.kt
index 9827d8d720..df3549b0f8 100644
--- a/features/signedout/impl/src/main/kotlin/io/element/android/features/signedout/impl/SignedOutStateProvider.kt
+++ b/features/signedout/impl/src/main/kotlin/io/element/android/features/signedout/impl/SignedOutStateProvider.kt
@@ -38,7 +38,6 @@ fun aSignedOutState() = SignedOutState(
fun aSessionData(
sessionId: SessionId = SessionId("@alice:server.org"),
isTokenValid: Boolean = false,
- needsVerification: Boolean = false,
): SessionData {
return SessionData(
userId = sessionId.value,
@@ -52,6 +51,5 @@ fun aSessionData(
isTokenValid = isTokenValid,
loginType = LoginType.UNKNOWN,
passphrase = null,
- needsVerification = needsVerification,
)
}
diff --git a/features/signedout/impl/src/main/res/values-fr/translations.xml b/features/signedout/impl/src/main/res/values-fr/translations.xml
index 8b4c9b7461..e093df8517 100644
--- a/features/signedout/impl/src/main/res/values-fr/translations.xml
+++ b/features/signedout/impl/src/main/res/values-fr/translations.xml
@@ -3,6 +3,6 @@
"Le mot de passe de votre compte a été modifié sur un autre appareil"
"Cette session a été supprimée depuis un autre appareil"
"L’administrateur de votre serveur a révoqué votre accès."
- "La déconnexion peut être due à une des raisons ci-dessous. Veuillez vous connecter à nouveau pour continuer à utiliser %1$s."
+ "La déconnexion peut être due à une des raisons ci-dessous. Veuillez vous connecter à nouveau pour continuer à utiliser %s."
"Vous avez été déconnecté"
diff --git a/features/userprofile/api/build.gradle.kts b/features/userprofile/api/build.gradle.kts
new file mode 100644
index 0000000000..95e22ffb29
--- /dev/null
+++ b/features/userprofile/api/build.gradle.kts
@@ -0,0 +1,29 @@
+/*
+ * Copyright (c) 2024 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+plugins {
+ id("io.element.android-library")
+ id("kotlin-parcelize")
+}
+
+android {
+ namespace = "io.element.android.features.userprofile.api"
+}
+
+dependencies {
+ implementation(projects.libraries.architecture)
+ implementation(projects.libraries.matrix.api)
+}
diff --git a/features/userprofile/api/src/main/kotlin/io/element/android/features/userprofile/api/UserProfileEntryPoint.kt b/features/userprofile/api/src/main/kotlin/io/element/android/features/userprofile/api/UserProfileEntryPoint.kt
new file mode 100644
index 0000000000..ca77008c3c
--- /dev/null
+++ b/features/userprofile/api/src/main/kotlin/io/element/android/features/userprofile/api/UserProfileEntryPoint.kt
@@ -0,0 +1,41 @@
+/*
+ * Copyright (c) 2023 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.element.android.features.userprofile.api
+
+import com.bumble.appyx.core.modality.BuildContext
+import com.bumble.appyx.core.node.Node
+import com.bumble.appyx.core.plugin.Plugin
+import io.element.android.libraries.architecture.FeatureEntryPoint
+import io.element.android.libraries.architecture.NodeInputs
+import io.element.android.libraries.matrix.api.core.RoomId
+import io.element.android.libraries.matrix.api.core.UserId
+
+interface UserProfileEntryPoint : FeatureEntryPoint {
+ data class Params(val userId: UserId) : NodeInputs
+
+ interface Callback : Plugin {
+ fun onOpenRoom(roomId: RoomId)
+ }
+
+ interface NodeBuilder {
+ fun params(params: Params): NodeBuilder
+ fun callback(callback: Callback): NodeBuilder
+ fun build(): Node
+ }
+
+ fun nodeBuilder(parentNode: Node, buildContext: BuildContext): NodeBuilder
+}
diff --git a/features/userprofile/impl/build.gradle.kts b/features/userprofile/impl/build.gradle.kts
new file mode 100644
index 0000000000..e41524abb2
--- /dev/null
+++ b/features/userprofile/impl/build.gradle.kts
@@ -0,0 +1,70 @@
+/*
+ * Copyright (c) 2024 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+plugins {
+ id("io.element.android-compose-library")
+ alias(libs.plugins.anvil)
+ alias(libs.plugins.ksp)
+ id("kotlin-parcelize")
+}
+
+android {
+ namespace = "io.element.android.features.userprofile.impl"
+ testOptions {
+ unitTests {
+ isIncludeAndroidResources = true
+ }
+ }
+}
+
+anvil {
+ generateDaggerFactories.set(true)
+}
+
+dependencies {
+ anvil(projects.anvilcodegen)
+ implementation(projects.anvilannotations)
+
+ implementation(projects.libraries.core)
+ implementation(projects.libraries.architecture)
+ implementation(projects.libraries.matrix.api)
+ implementation(projects.libraries.matrixui)
+ implementation(projects.libraries.designsystem)
+ implementation(projects.libraries.uiStrings)
+ implementation(projects.libraries.androidutils)
+ implementation(projects.libraries.mediaviewer.api)
+ implementation(projects.features.call)
+ api(projects.features.userprofile.api)
+ api(projects.features.userprofile.shared)
+ implementation(libs.coil.compose)
+ implementation(projects.features.createroom.api)
+ implementation(projects.services.analytics.api)
+
+ testImplementation(libs.test.junit)
+ testImplementation(libs.coroutines.test)
+ testImplementation(libs.molecule.runtime)
+ testImplementation(libs.test.truth)
+ testImplementation(libs.test.turbine)
+ testImplementation(libs.test.mockk)
+ testImplementation(libs.test.robolectric)
+ testImplementation(projects.libraries.matrix.test)
+ testImplementation(projects.features.createroom.test)
+ testImplementation(projects.tests.testutils)
+ testImplementation(libs.androidx.compose.ui.test.junit)
+ testReleaseImplementation(libs.androidx.compose.ui.test.manifest)
+
+ ksp(libs.showkase.processor)
+}
diff --git a/features/userprofile/impl/src/main/kotlin/io/element/android/features/userprofile/impl/DefaultUserProfileEntryPoint.kt b/features/userprofile/impl/src/main/kotlin/io/element/android/features/userprofile/impl/DefaultUserProfileEntryPoint.kt
new file mode 100644
index 0000000000..0858b39deb
--- /dev/null
+++ b/features/userprofile/impl/src/main/kotlin/io/element/android/features/userprofile/impl/DefaultUserProfileEntryPoint.kt
@@ -0,0 +1,49 @@
+/*
+ * Copyright (c) 2024 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.element.android.features.userprofile.impl
+
+import com.bumble.appyx.core.modality.BuildContext
+import com.bumble.appyx.core.node.Node
+import com.bumble.appyx.core.plugin.Plugin
+import com.squareup.anvil.annotations.ContributesBinding
+import io.element.android.features.userprofile.api.UserProfileEntryPoint
+import io.element.android.libraries.architecture.createNode
+import io.element.android.libraries.di.AppScope
+import javax.inject.Inject
+
+@ContributesBinding(AppScope::class)
+class DefaultUserProfileEntryPoint @Inject constructor() : UserProfileEntryPoint {
+ override fun nodeBuilder(parentNode: Node, buildContext: BuildContext): UserProfileEntryPoint.NodeBuilder {
+ return object : UserProfileEntryPoint.NodeBuilder {
+ val plugins = ArrayList()
+
+ override fun params(params: UserProfileEntryPoint.Params): UserProfileEntryPoint.NodeBuilder {
+ plugins += params
+ return this
+ }
+
+ override fun callback(callback: UserProfileEntryPoint.Callback): UserProfileEntryPoint.NodeBuilder {
+ plugins += callback
+ return this
+ }
+
+ override fun build(): Node {
+ return parentNode.createNode(buildContext, plugins)
+ }
+ }
+ }
+}
diff --git a/features/userprofile/impl/src/main/kotlin/io/element/android/features/userprofile/impl/UserProfileFlowNode.kt b/features/userprofile/impl/src/main/kotlin/io/element/android/features/userprofile/impl/UserProfileFlowNode.kt
new file mode 100644
index 0000000000..402e07dbba
--- /dev/null
+++ b/features/userprofile/impl/src/main/kotlin/io/element/android/features/userprofile/impl/UserProfileFlowNode.kt
@@ -0,0 +1,117 @@
+/*
+ * Copyright (c) 2024 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.element.android.features.userprofile.impl
+
+import android.content.Context
+import android.os.Parcelable
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import com.bumble.appyx.core.modality.BuildContext
+import com.bumble.appyx.core.node.Node
+import com.bumble.appyx.core.plugin.Plugin
+import com.bumble.appyx.core.plugin.plugins
+import com.bumble.appyx.navmodel.backstack.BackStack
+import com.bumble.appyx.navmodel.backstack.operation.push
+import dagger.assisted.Assisted
+import dagger.assisted.AssistedInject
+import io.element.android.anvilannotations.ContributesNode
+import io.element.android.features.call.CallType
+import io.element.android.features.call.ui.ElementCallActivity
+import io.element.android.features.userprofile.api.UserProfileEntryPoint
+import io.element.android.features.userprofile.impl.root.UserProfileNode
+import io.element.android.features.userprofile.shared.UserProfileNodeHelper
+import io.element.android.features.userprofile.shared.avatar.AvatarPreviewNode
+import io.element.android.libraries.architecture.BackstackView
+import io.element.android.libraries.architecture.BaseFlowNode
+import io.element.android.libraries.architecture.createNode
+import io.element.android.libraries.architecture.inputs
+import io.element.android.libraries.core.mimetype.MimeTypes
+import io.element.android.libraries.di.ApplicationContext
+import io.element.android.libraries.di.SessionScope
+import io.element.android.libraries.matrix.api.core.RoomId
+import io.element.android.libraries.matrix.api.media.MediaSource
+import io.element.android.libraries.matrix.api.user.CurrentSessionIdHolder
+import io.element.android.libraries.mediaviewer.api.local.MediaInfo
+import io.element.android.libraries.mediaviewer.api.viewer.MediaViewerNode
+import kotlinx.parcelize.Parcelize
+
+@ContributesNode(SessionScope::class)
+class UserProfileFlowNode @AssistedInject constructor(
+ @Assisted buildContext: BuildContext,
+ @Assisted plugins: List,
+ @ApplicationContext private val context: Context,
+ private val sessionIdHolder: CurrentSessionIdHolder,
+) : BaseFlowNode(
+ backstack = BackStack(
+ initialElement = NavTarget.Root,
+ savedStateMap = buildContext.savedStateMap,
+ ),
+ buildContext = buildContext,
+ plugins = plugins,
+) {
+ sealed interface NavTarget : Parcelable {
+ @Parcelize
+ data object Root : NavTarget
+
+ @Parcelize
+ data class AvatarPreview(val name: String, val avatarUrl: String) : NavTarget
+ }
+
+ override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {
+ return when (navTarget) {
+ NavTarget.Root -> {
+ val callback = object : UserProfileNodeHelper.Callback {
+ override fun openAvatarPreview(username: String, avatarUrl: String) {
+ backstack.push(NavTarget.AvatarPreview(username, avatarUrl))
+ }
+
+ override fun onStartDM(roomId: RoomId) {
+ plugins().forEach { it.onOpenRoom(roomId) }
+ }
+
+ override fun onStartCall(roomId: RoomId) {
+ ElementCallActivity.start(context, CallType.RoomCall(sessionId = sessionIdHolder.current, roomId = roomId))
+ }
+ }
+ val params = UserProfileNode.UserProfileInputs(userId = inputs().userId)
+ createNode(buildContext, listOf(callback, params))
+ }
+ is NavTarget.AvatarPreview -> {
+ // We need to fake the MimeType here for the viewer to work.
+ val mimeType = MimeTypes.Images
+ val input = MediaViewerNode.Inputs(
+ mediaInfo = MediaInfo(
+ name = navTarget.name,
+ mimeType = mimeType,
+ formattedFileSize = "",
+ fileExtension = ""
+ ),
+ mediaSource = MediaSource(url = navTarget.avatarUrl),
+ thumbnailSource = null,
+ canDownload = false,
+ canShare = false,
+ )
+ createNode(buildContext, listOf(input))
+ }
+ }
+ }
+
+ @Composable
+ override fun View(modifier: Modifier) {
+ BackstackView()
+ }
+}
diff --git a/features/userprofile/impl/src/main/kotlin/io/element/android/features/userprofile/impl/di/UserProfileModule.kt b/features/userprofile/impl/src/main/kotlin/io/element/android/features/userprofile/impl/di/UserProfileModule.kt
new file mode 100644
index 0000000000..47f0bc94cd
--- /dev/null
+++ b/features/userprofile/impl/src/main/kotlin/io/element/android/features/userprofile/impl/di/UserProfileModule.kt
@@ -0,0 +1,42 @@
+/*
+ * Copyright (c) 2024 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.element.android.features.userprofile.impl.di
+
+import com.squareup.anvil.annotations.ContributesTo
+import dagger.Module
+import dagger.Provides
+import io.element.android.features.createroom.api.StartDMAction
+import io.element.android.features.userprofile.impl.root.UserProfilePresenter
+import io.element.android.libraries.di.SessionScope
+import io.element.android.libraries.matrix.api.MatrixClient
+import io.element.android.libraries.matrix.api.core.UserId
+
+@Module
+@ContributesTo(SessionScope::class)
+object UserProfileModule {
+ @Provides
+ fun provideUserProfilePresenterFactory(
+ matrixClient: MatrixClient,
+ startDMAction: StartDMAction,
+ ): UserProfilePresenter.Factory {
+ return object : UserProfilePresenter.Factory {
+ override fun create(userId: UserId): UserProfilePresenter {
+ return UserProfilePresenter(userId, matrixClient, startDMAction)
+ }
+ }
+ }
+}
diff --git a/features/userprofile/impl/src/main/kotlin/io/element/android/features/userprofile/impl/root/UserProfileNode.kt b/features/userprofile/impl/src/main/kotlin/io/element/android/features/userprofile/impl/root/UserProfileNode.kt
new file mode 100644
index 0000000000..4d4ea993c4
--- /dev/null
+++ b/features/userprofile/impl/src/main/kotlin/io/element/android/features/userprofile/impl/root/UserProfileNode.kt
@@ -0,0 +1,97 @@
+/*
+ * Copyright (c) 2024 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.element.android.features.userprofile.impl.root
+
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalContext
+import com.bumble.appyx.core.lifecycle.subscribe
+import com.bumble.appyx.core.modality.BuildContext
+import com.bumble.appyx.core.node.Node
+import com.bumble.appyx.core.plugin.Plugin
+import dagger.assisted.Assisted
+import dagger.assisted.AssistedInject
+import im.vector.app.features.analytics.plan.MobileScreen
+import io.element.android.anvilannotations.ContributesNode
+import io.element.android.features.userprofile.shared.UserProfileNodeHelper
+import io.element.android.features.userprofile.shared.UserProfileView
+import io.element.android.libraries.architecture.AsyncAction
+import io.element.android.libraries.architecture.NodeInputs
+import io.element.android.libraries.architecture.inputs
+import io.element.android.libraries.di.SessionScope
+import io.element.android.libraries.matrix.api.core.RoomId
+import io.element.android.libraries.matrix.api.core.UserId
+import io.element.android.libraries.matrix.api.permalink.PermalinkBuilder
+import io.element.android.services.analytics.api.AnalyticsService
+
+@ContributesNode(SessionScope::class)
+class UserProfileNode @AssistedInject constructor(
+ @Assisted buildContext: BuildContext,
+ @Assisted plugins: List,
+ private val analyticsService: AnalyticsService,
+ private val permalinkBuilder: PermalinkBuilder,
+ presenterFactory: UserProfilePresenter.Factory,
+) : Node(buildContext, plugins = plugins) {
+ data class UserProfileInputs(
+ val userId: UserId
+ ) : NodeInputs
+
+ private val inputs = inputs()
+ private val callback = inputs()
+ private val presenter = presenterFactory.create(inputs.userId)
+ private val userProfileNodeHelper = UserProfileNodeHelper(inputs.userId)
+
+ init {
+ lifecycle.subscribe(
+ onResume = {
+ analyticsService.screen(MobileScreen(screenName = MobileScreen.ScreenName.User))
+ }
+ )
+ }
+
+ @Composable
+ override fun View(modifier: Modifier) {
+ val context = LocalContext.current
+
+ fun onShareUser() {
+ userProfileNodeHelper.onShareUser(context, permalinkBuilder)
+ }
+
+ fun onStartDM(roomId: RoomId) {
+ callback.onStartDM(roomId)
+ }
+
+ val state = presenter.present()
+
+ LaunchedEffect(state.startDmActionState) {
+ val result = state.startDmActionState
+ if (result is AsyncAction.Success) {
+ onStartDM(result.data)
+ }
+ }
+ UserProfileView(
+ state = state,
+ modifier = modifier,
+ goBack = this::navigateUp,
+ onShareUser = ::onShareUser,
+ onDmStarted = ::onStartDM,
+ onStartCall = callback::onStartCall,
+ openAvatarPreview = callback::openAvatarPreview,
+ )
+ }
+}
diff --git a/features/userprofile/impl/src/main/kotlin/io/element/android/features/userprofile/impl/root/UserProfilePresenter.kt b/features/userprofile/impl/src/main/kotlin/io/element/android/features/userprofile/impl/root/UserProfilePresenter.kt
new file mode 100644
index 0000000000..753891156e
--- /dev/null
+++ b/features/userprofile/impl/src/main/kotlin/io/element/android/features/userprofile/impl/root/UserProfilePresenter.kt
@@ -0,0 +1,128 @@
+/*
+ * Copyright (c) 2024 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.element.android.features.userprofile.impl.root
+
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.MutableState
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.runtime.setValue
+import dagger.assisted.Assisted
+import dagger.assisted.AssistedInject
+import io.element.android.features.createroom.api.StartDMAction
+import io.element.android.features.userprofile.shared.UserProfileEvents
+import io.element.android.features.userprofile.shared.UserProfilePresenterHelper
+import io.element.android.features.userprofile.shared.UserProfileState
+import io.element.android.features.userprofile.shared.UserProfileState.ConfirmationDialog
+import io.element.android.libraries.architecture.AsyncAction
+import io.element.android.libraries.architecture.AsyncData
+import io.element.android.libraries.architecture.Presenter
+import io.element.android.libraries.core.bool.orFalse
+import io.element.android.libraries.matrix.api.MatrixClient
+import io.element.android.libraries.matrix.api.core.RoomId
+import io.element.android.libraries.matrix.api.core.UserId
+import io.element.android.libraries.matrix.api.user.MatrixUser
+import kotlinx.coroutines.flow.distinctUntilChanged
+import kotlinx.coroutines.flow.launchIn
+import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.flow.onEach
+import kotlinx.coroutines.launch
+
+class UserProfilePresenter @AssistedInject constructor(
+ @Assisted private val userId: UserId,
+ private val client: MatrixClient,
+ private val startDMAction: StartDMAction,
+) : Presenter {
+ interface Factory {
+ fun create(userId: UserId): UserProfilePresenter
+ }
+
+ private val userProfilePresenterHelper = UserProfilePresenterHelper(
+ userId = userId,
+ client = client,
+ )
+
+ @Composable
+ override fun present(): UserProfileState {
+ val coroutineScope = rememberCoroutineScope()
+ var confirmationDialog by remember { mutableStateOf(null) }
+ var userProfile by remember { mutableStateOf(null) }
+ val startDmActionState: MutableState> = remember { mutableStateOf(AsyncAction.Uninitialized) }
+ val isBlocked: MutableState> = remember { mutableStateOf(AsyncData.Uninitialized) }
+ val dmRoomId by userProfilePresenterHelper.getDmRoomId()
+ val canCall by userProfilePresenterHelper.getCanCall(dmRoomId)
+ LaunchedEffect(Unit) {
+ client.ignoredUsersFlow
+ .map { ignoredUsers -> userId in ignoredUsers }
+ .distinctUntilChanged()
+ .onEach { isBlocked.value = AsyncData.Success(it) }
+ .launchIn(this)
+ }
+ LaunchedEffect(Unit) {
+ userProfile = client.getProfile(userId).getOrNull()
+ }
+
+ fun handleEvents(event: UserProfileEvents) {
+ when (event) {
+ is UserProfileEvents.BlockUser -> {
+ if (event.needsConfirmation) {
+ confirmationDialog = ConfirmationDialog.Block
+ } else {
+ confirmationDialog = null
+ userProfilePresenterHelper.blockUser(coroutineScope, isBlocked)
+ }
+ }
+ is UserProfileEvents.UnblockUser -> {
+ if (event.needsConfirmation) {
+ confirmationDialog = ConfirmationDialog.Unblock
+ } else {
+ confirmationDialog = null
+ userProfilePresenterHelper.unblockUser(coroutineScope, isBlocked)
+ }
+ }
+ UserProfileEvents.ClearConfirmationDialog -> confirmationDialog = null
+ UserProfileEvents.ClearBlockUserError -> {
+ isBlocked.value = AsyncData.Success(isBlocked.value.dataOrNull().orFalse())
+ }
+ UserProfileEvents.StartDM -> {
+ coroutineScope.launch {
+ startDMAction.execute(userId, startDmActionState)
+ }
+ }
+ UserProfileEvents.ClearStartDMState -> {
+ startDmActionState.value = AsyncAction.Uninitialized
+ }
+ }
+ }
+
+ return UserProfileState(
+ userId = userId,
+ userName = userProfile?.displayName,
+ avatarUrl = userProfile?.avatarUrl,
+ isBlocked = isBlocked.value,
+ startDmActionState = startDmActionState.value,
+ displayConfirmationDialog = confirmationDialog,
+ isCurrentUser = client.isMe(userId),
+ dmRoomId = dmRoomId,
+ canCall = canCall,
+ eventSink = ::handleEvents
+ )
+ }
+}
diff --git a/features/userprofile/impl/src/test/kotlin/io/element/android/features/userprofile/impl/UserProfilePresenterTests.kt b/features/userprofile/impl/src/test/kotlin/io/element/android/features/userprofile/impl/UserProfilePresenterTests.kt
new file mode 100644
index 0000000000..20b63ef702
--- /dev/null
+++ b/features/userprofile/impl/src/test/kotlin/io/element/android/features/userprofile/impl/UserProfilePresenterTests.kt
@@ -0,0 +1,240 @@
+/*
+ * Copyright (c) 2024 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.element.android.features.userprofile.impl
+
+import app.cash.molecule.RecompositionMode
+import app.cash.molecule.moleculeFlow
+import app.cash.turbine.ReceiveTurbine
+import app.cash.turbine.test
+import com.google.common.truth.Truth.assertThat
+import io.element.android.features.createroom.api.StartDMAction
+import io.element.android.features.createroom.test.FakeStartDMAction
+import io.element.android.features.userprofile.impl.root.UserProfilePresenter
+import io.element.android.features.userprofile.shared.UserProfileEvents
+import io.element.android.features.userprofile.shared.UserProfileState
+import io.element.android.libraries.architecture.AsyncAction
+import io.element.android.libraries.architecture.AsyncData
+import io.element.android.libraries.matrix.api.MatrixClient
+import io.element.android.libraries.matrix.api.core.UserId
+import io.element.android.libraries.matrix.test.AN_EXCEPTION
+import io.element.android.libraries.matrix.test.A_ROOM_ID
+import io.element.android.libraries.matrix.test.A_THROWABLE
+import io.element.android.libraries.matrix.test.A_USER_ID
+import io.element.android.libraries.matrix.test.FakeMatrixClient
+import io.element.android.libraries.matrix.ui.components.aMatrixUser
+import io.element.android.tests.testutils.WarmUpRule
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.test.runTest
+import org.junit.Rule
+import org.junit.Test
+
+@ExperimentalCoroutinesApi
+class UserProfilePresenterTests {
+ @get:Rule
+ val warmUpRule = WarmUpRule()
+
+ @Test
+ fun `present - returns the user profile data`() = runTest {
+ val matrixUser = aMatrixUser(A_USER_ID.value, "Alice", "anAvatarUrl")
+ val client = FakeMatrixClient().apply {
+ givenGetProfileResult(A_USER_ID, Result.success(matrixUser))
+ }
+ val presenter = createUserProfilePresenter(
+ client = client,
+ )
+ moleculeFlow(RecompositionMode.Immediate) {
+ presenter.present()
+ }.test {
+ val initialState = awaitFirstItem()
+ assertThat(initialState.userId).isEqualTo(matrixUser.userId)
+ assertThat(initialState.userName).isEqualTo(matrixUser.displayName)
+ assertThat(initialState.avatarUrl).isEqualTo(matrixUser.avatarUrl)
+ assertThat(initialState.isBlocked).isEqualTo(AsyncData.Success(false))
+ assertThat(initialState.dmRoomId).isEqualTo(A_ROOM_ID)
+ assertThat(initialState.canCall).isFalse()
+ }
+ }
+
+ @Test
+ fun `present - returns empty data in case of failure`() = runTest {
+ val client = FakeMatrixClient().apply {
+ givenGetProfileResult(A_USER_ID, Result.failure(AN_EXCEPTION))
+ }
+ val presenter = createUserProfilePresenter(
+ client = client,
+ )
+ moleculeFlow(RecompositionMode.Immediate) {
+ presenter.present()
+ }.test {
+ val initialState = awaitFirstItem()
+ assertThat(initialState.userId).isEqualTo(A_USER_ID)
+ assertThat(initialState.userName).isNull()
+ assertThat(initialState.avatarUrl).isNull()
+ assertThat(initialState.isBlocked).isEqualTo(AsyncData.Success(false))
+ }
+ }
+
+ @Test
+ fun `present - BlockUser needing confirmation displays confirmation dialog`() = runTest {
+ val presenter = createUserProfilePresenter()
+ moleculeFlow(RecompositionMode.Immediate) {
+ presenter.present()
+ }.test {
+ val initialState = awaitFirstItem()
+ initialState.eventSink(UserProfileEvents.BlockUser(needsConfirmation = true))
+
+ val dialogState = awaitItem()
+ assertThat(dialogState.displayConfirmationDialog).isEqualTo(UserProfileState.ConfirmationDialog.Block)
+
+ dialogState.eventSink(UserProfileEvents.ClearConfirmationDialog)
+ assertThat(awaitItem().displayConfirmationDialog).isNull()
+
+ ensureAllEventsConsumed()
+ }
+ }
+
+ @Test
+ fun `present - BlockUser and UnblockUser without confirmation change the 'blocked' state`() = runTest {
+ val client = FakeMatrixClient()
+ val presenter = createUserProfilePresenter(
+ client = client,
+ userId = A_USER_ID
+ )
+ moleculeFlow(RecompositionMode.Immediate) {
+ presenter.present()
+ }.test {
+ val initialState = awaitFirstItem()
+ initialState.eventSink(UserProfileEvents.BlockUser(needsConfirmation = false))
+ assertThat(awaitItem().isBlocked.isLoading()).isTrue()
+ client.emitIgnoreUserList(listOf(A_USER_ID))
+ assertThat(awaitItem().isBlocked.dataOrNull()).isTrue()
+
+ initialState.eventSink(UserProfileEvents.UnblockUser(needsConfirmation = false))
+ assertThat(awaitItem().isBlocked.isLoading()).isTrue()
+ client.emitIgnoreUserList(listOf())
+ assertThat(awaitItem().isBlocked.dataOrNull()).isFalse()
+ }
+ }
+
+ @Test
+ fun `present - BlockUser with error`() = runTest {
+ val matrixClient = FakeMatrixClient()
+ matrixClient.givenIgnoreUserResult(Result.failure(A_THROWABLE))
+ val presenter = createUserProfilePresenter(client = matrixClient)
+ moleculeFlow(RecompositionMode.Immediate) {
+ presenter.present()
+ }.test {
+ val initialState = awaitFirstItem()
+ initialState.eventSink(UserProfileEvents.BlockUser(needsConfirmation = false))
+ assertThat(awaitItem().isBlocked.isLoading()).isTrue()
+ val errorState = awaitItem()
+ assertThat(errorState.isBlocked.errorOrNull()).isEqualTo(A_THROWABLE)
+ // Clear error
+ initialState.eventSink(UserProfileEvents.ClearBlockUserError)
+ assertThat(awaitItem().isBlocked).isEqualTo(AsyncData.Success(false))
+ }
+ }
+
+ @Test
+ fun `present - UnblockUser with error`() = runTest {
+ val matrixClient = FakeMatrixClient()
+ matrixClient.givenUnignoreUserResult(Result.failure(A_THROWABLE))
+ val presenter = createUserProfilePresenter(client = matrixClient)
+ moleculeFlow(RecompositionMode.Immediate) {
+ presenter.present()
+ }.test {
+ val initialState = awaitFirstItem()
+ initialState.eventSink(UserProfileEvents.UnblockUser(needsConfirmation = false))
+ assertThat(awaitItem().isBlocked.isLoading()).isTrue()
+ val errorState = awaitItem()
+ assertThat(errorState.isBlocked.errorOrNull()).isEqualTo(A_THROWABLE)
+ // Clear error
+ initialState.eventSink(UserProfileEvents.ClearBlockUserError)
+ assertThat(awaitItem().isBlocked).isEqualTo(AsyncData.Success(true))
+ }
+ }
+
+ @Test
+ fun `present - UnblockUser needing confirmation displays confirmation dialog`() = runTest {
+ val presenter = createUserProfilePresenter()
+ moleculeFlow(RecompositionMode.Immediate) {
+ presenter.present()
+ }.test {
+ val initialState = awaitFirstItem()
+ initialState.eventSink(UserProfileEvents.UnblockUser(needsConfirmation = true))
+
+ val dialogState = awaitItem()
+ assertThat(dialogState.displayConfirmationDialog).isEqualTo(UserProfileState.ConfirmationDialog.Unblock)
+
+ dialogState.eventSink(UserProfileEvents.ClearConfirmationDialog)
+ assertThat(awaitItem().displayConfirmationDialog).isNull()
+
+ ensureAllEventsConsumed()
+ }
+ }
+
+ @Test
+ fun `present - start DM action complete scenario`() = runTest {
+ val startDMAction = FakeStartDMAction()
+ val presenter = createUserProfilePresenter(startDMAction = startDMAction)
+ moleculeFlow(RecompositionMode.Immediate) {
+ presenter.present()
+ }.test {
+ val initialState = awaitFirstItem()
+ assertThat(initialState.startDmActionState).isInstanceOf(AsyncAction.Uninitialized::class.java)
+ val startDMSuccessResult = AsyncAction.Success(A_ROOM_ID)
+ val startDMFailureResult = AsyncAction.Failure(A_THROWABLE)
+
+ // Failure
+ startDMAction.givenExecuteResult(startDMFailureResult)
+ initialState.eventSink(UserProfileEvents.StartDM)
+ assertThat(awaitItem().startDmActionState).isInstanceOf(AsyncAction.Loading::class.java)
+ awaitItem().also { state ->
+ assertThat(state.startDmActionState).isEqualTo(startDMFailureResult)
+ state.eventSink(UserProfileEvents.ClearStartDMState)
+ }
+
+ // Success
+ startDMAction.givenExecuteResult(startDMSuccessResult)
+ awaitItem().also { state ->
+ assertThat(state.startDmActionState).isEqualTo(AsyncAction.Uninitialized)
+ state.eventSink(UserProfileEvents.StartDM)
+ }
+ assertThat(awaitItem().startDmActionState).isInstanceOf(AsyncAction.Loading::class.java)
+ awaitItem().also { state ->
+ assertThat(state.startDmActionState).isEqualTo(startDMSuccessResult)
+ }
+ }
+ }
+
+ private suspend fun ReceiveTurbine.awaitFirstItem(): T {
+ skipItems(1)
+ return awaitItem()
+ }
+
+ private fun createUserProfilePresenter(
+ client: MatrixClient = FakeMatrixClient(),
+ userId: UserId = UserId("@alice:server.org"),
+ startDMAction: StartDMAction = FakeStartDMAction()
+ ): UserProfilePresenter {
+ return UserProfilePresenter(
+ userId = userId,
+ client = client,
+ startDMAction = startDMAction
+ )
+ }
+}
diff --git a/features/userprofile/shared/build.gradle.kts b/features/userprofile/shared/build.gradle.kts
new file mode 100644
index 0000000000..2407770ed4
--- /dev/null
+++ b/features/userprofile/shared/build.gradle.kts
@@ -0,0 +1,71 @@
+/*
+ * Copyright (c) 2024 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+plugins {
+ id("io.element.android-compose-library")
+ alias(libs.plugins.anvil)
+ alias(libs.plugins.ksp)
+ id("kotlin-parcelize")
+}
+
+android {
+ namespace = "io.element.android.features.userprofile.shared"
+ testOptions {
+ unitTests {
+ isIncludeAndroidResources = true
+ }
+ }
+}
+
+anvil {
+ generateDaggerFactories.set(true)
+}
+
+dependencies {
+ anvil(projects.anvilcodegen)
+ implementation(projects.anvilannotations)
+
+ implementation(projects.libraries.core)
+ implementation(projects.libraries.architecture)
+ implementation(projects.libraries.matrix.api)
+ implementation(projects.libraries.matrixui)
+ implementation(projects.libraries.designsystem)
+ implementation(projects.libraries.uiStrings)
+ implementation(projects.libraries.androidutils)
+ implementation(projects.libraries.mediaviewer.api)
+ implementation(projects.libraries.featureflag.api)
+ implementation(projects.libraries.permissions.api)
+ implementation(projects.libraries.preferences.api)
+ implementation(projects.libraries.testtags)
+ api(projects.features.userprofile.api)
+ api(projects.services.apperror.api)
+ implementation(libs.coil.compose)
+ implementation(projects.features.createroom.api)
+ implementation(projects.services.analytics.api)
+
+ testImplementation(libs.test.junit)
+ testImplementation(libs.coroutines.test)
+ testImplementation(libs.molecule.runtime)
+ testImplementation(libs.test.truth)
+ testImplementation(libs.test.turbine)
+ testImplementation(libs.test.robolectric)
+ testImplementation(projects.libraries.matrix.test)
+ testImplementation(projects.tests.testutils)
+ testImplementation(libs.androidx.compose.ui.test.junit)
+ testReleaseImplementation(libs.androidx.compose.ui.test.manifest)
+
+ ksp(libs.showkase.processor)
+}
diff --git a/features/userprofile/shared/src/main/kotlin/io/element/android/features/userprofile/shared/UserProfileEvents.kt b/features/userprofile/shared/src/main/kotlin/io/element/android/features/userprofile/shared/UserProfileEvents.kt
new file mode 100644
index 0000000000..447b04c9c1
--- /dev/null
+++ b/features/userprofile/shared/src/main/kotlin/io/element/android/features/userprofile/shared/UserProfileEvents.kt
@@ -0,0 +1,26 @@
+/*
+ * Copyright (c) 2024 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.element.android.features.userprofile.shared
+
+sealed interface UserProfileEvents {
+ data object StartDM : UserProfileEvents
+ data object ClearStartDMState : UserProfileEvents
+ data class BlockUser(val needsConfirmation: Boolean = false) : UserProfileEvents
+ data class UnblockUser(val needsConfirmation: Boolean = false) : UserProfileEvents
+ data object ClearBlockUserError : UserProfileEvents
+ data object ClearConfirmationDialog : UserProfileEvents
+}
diff --git a/features/userprofile/shared/src/main/kotlin/io/element/android/features/userprofile/shared/UserProfileHeaderSection.kt b/features/userprofile/shared/src/main/kotlin/io/element/android/features/userprofile/shared/UserProfileHeaderSection.kt
new file mode 100644
index 0000000000..9db6aee2b9
--- /dev/null
+++ b/features/userprofile/shared/src/main/kotlin/io/element/android/features/userprofile/shared/UserProfileHeaderSection.kt
@@ -0,0 +1,88 @@
+/*
+ * Copyright (c) 2024 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.element.android.features.userprofile.shared
+
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clipToBounds
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.unit.dp
+import io.element.android.compound.theme.ElementTheme
+import io.element.android.libraries.designsystem.components.avatar.Avatar
+import io.element.android.libraries.designsystem.components.avatar.AvatarData
+import io.element.android.libraries.designsystem.components.avatar.AvatarSize
+import io.element.android.libraries.designsystem.theme.components.Text
+import io.element.android.libraries.matrix.api.core.UserId
+import io.element.android.libraries.testtags.TestTags
+import io.element.android.libraries.testtags.testTag
+
+@Composable
+fun UserProfileHeaderSection(
+ avatarUrl: String?,
+ userId: UserId,
+ userName: String?,
+ openAvatarPreview: (url: String) -> Unit,
+ modifier: Modifier = Modifier
+) {
+ Column(
+ modifier = modifier
+ .fillMaxWidth()
+ .padding(horizontal = 16.dp),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ ) {
+ Box(modifier = Modifier.size(70.dp)) {
+ Avatar(
+ avatarData = AvatarData(userId.value, userName, avatarUrl, AvatarSize.UserHeader),
+ modifier = Modifier
+ .clickable(enabled = avatarUrl != null) { openAvatarPreview(avatarUrl!!) }
+ .fillMaxSize()
+ .testTag(TestTags.memberDetailAvatar)
+ )
+ }
+ Spacer(modifier = Modifier.height(24.dp))
+ if (userName != null) {
+ Text(
+ modifier = Modifier.clipToBounds(),
+ text = userName,
+ style = ElementTheme.typography.fontHeadingLgBold,
+ textAlign = TextAlign.Center,
+ )
+ Spacer(modifier = Modifier.height(6.dp))
+ }
+ Text(
+ text = userId.value,
+ style = ElementTheme.typography.fontBodyLgRegular,
+ color = MaterialTheme.colorScheme.secondary,
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 16.dp),
+ textAlign = TextAlign.Center,
+ )
+ Spacer(Modifier.height(40.dp))
+ }
+}
diff --git a/features/userprofile/shared/src/main/kotlin/io/element/android/features/userprofile/shared/UserProfileMainActionsSection.kt b/features/userprofile/shared/src/main/kotlin/io/element/android/features/userprofile/shared/UserProfileMainActionsSection.kt
new file mode 100644
index 0000000000..d194a5662a
--- /dev/null
+++ b/features/userprofile/shared/src/main/kotlin/io/element/android/features/userprofile/shared/UserProfileMainActionsSection.kt
@@ -0,0 +1,64 @@
+/*
+ * Copyright (c) 2024 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.element.android.features.userprofile.shared
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.unit.dp
+import io.element.android.compound.tokens.generated.CompoundIcons
+import io.element.android.libraries.designsystem.components.button.MainActionButton
+import io.element.android.libraries.ui.strings.CommonStrings
+
+@Composable
+fun UserProfileMainActionsSection(
+ isCurrentUser: Boolean,
+ canCall: Boolean,
+ onShareUser: () -> Unit,
+ onStartDM: () -> Unit,
+ onCall: () -> Unit,
+ modifier: Modifier = Modifier
+) {
+ Row(
+ modifier.fillMaxWidth().padding(horizontal = 16.dp),
+ horizontalArrangement = Arrangement.SpaceEvenly,
+ ) {
+ if (!isCurrentUser) {
+ MainActionButton(
+ title = stringResource(CommonStrings.action_message),
+ imageVector = CompoundIcons.Chat(),
+ onClick = onStartDM,
+ )
+ }
+ if (canCall) {
+ MainActionButton(
+ title = stringResource(CommonStrings.action_call),
+ imageVector = CompoundIcons.VideoCall(),
+ onClick = onCall,
+ )
+ }
+ MainActionButton(
+ title = stringResource(CommonStrings.action_share),
+ imageVector = CompoundIcons.ShareAndroid(),
+ onClick = onShareUser
+ )
+ }
+}
diff --git a/features/userprofile/shared/src/main/kotlin/io/element/android/features/userprofile/shared/UserProfileNodeHelper.kt b/features/userprofile/shared/src/main/kotlin/io/element/android/features/userprofile/shared/UserProfileNodeHelper.kt
new file mode 100644
index 0000000000..7a669772f7
--- /dev/null
+++ b/features/userprofile/shared/src/main/kotlin/io/element/android/features/userprofile/shared/UserProfileNodeHelper.kt
@@ -0,0 +1,54 @@
+/*
+ * Copyright (c) 2024 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.element.android.features.userprofile.shared
+
+import android.content.Context
+import io.element.android.libraries.androidutils.R
+import io.element.android.libraries.androidutils.system.startSharePlainTextIntent
+import io.element.android.libraries.architecture.NodeInputs
+import io.element.android.libraries.matrix.api.core.RoomId
+import io.element.android.libraries.matrix.api.core.UserId
+import io.element.android.libraries.matrix.api.permalink.PermalinkBuilder
+import io.element.android.libraries.ui.strings.CommonStrings
+import timber.log.Timber
+
+class UserProfileNodeHelper(
+ private val userId: UserId,
+) {
+ interface Callback : NodeInputs {
+ fun openAvatarPreview(username: String, avatarUrl: String)
+ fun onStartDM(roomId: RoomId)
+ fun onStartCall(roomId: RoomId)
+ }
+
+ fun onShareUser(
+ context: Context,
+ permalinkBuilder: PermalinkBuilder,
+ ) {
+ val permalinkResult = permalinkBuilder.permalinkForUser(userId)
+ permalinkResult.onSuccess { permalink ->
+ context.startSharePlainTextIntent(
+ activityResultLauncher = null,
+ chooserTitle = context.getString(CommonStrings.action_share),
+ text = permalink,
+ noActivityFoundMessage = context.getString(R.string.error_no_compatible_app_found)
+ )
+ }.onFailure {
+ Timber.e(it)
+ }
+ }
+}
diff --git a/features/userprofile/shared/src/main/kotlin/io/element/android/features/userprofile/shared/UserProfilePresenterHelper.kt b/features/userprofile/shared/src/main/kotlin/io/element/android/features/userprofile/shared/UserProfilePresenterHelper.kt
new file mode 100644
index 0000000000..af2311aab7
--- /dev/null
+++ b/features/userprofile/shared/src/main/kotlin/io/element/android/features/userprofile/shared/UserProfilePresenterHelper.kt
@@ -0,0 +1,76 @@
+/*
+ * Copyright (c) 2024 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.element.android.features.userprofile.shared
+
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.MutableState
+import androidx.compose.runtime.State
+import androidx.compose.runtime.produceState
+import io.element.android.libraries.architecture.AsyncData
+import io.element.android.libraries.core.bool.orFalse
+import io.element.android.libraries.matrix.api.MatrixClient
+import io.element.android.libraries.matrix.api.core.RoomId
+import io.element.android.libraries.matrix.api.core.UserId
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.launch
+
+class UserProfilePresenterHelper(
+ private val userId: UserId,
+ private val client: MatrixClient,
+) {
+ @Composable
+ fun getDmRoomId(): State {
+ return produceState(initialValue = null) {
+ value = client.findDM(userId)
+ }
+ }
+
+ @Composable
+ fun getCanCall(roomId: RoomId?): State {
+ return produceState(initialValue = false, roomId) {
+ value = if (client.isMe(userId)) {
+ false
+ } else {
+ roomId?.let { client.getRoom(it)?.canUserJoinCall(client.sessionId)?.getOrNull() == true }.orFalse()
+ }
+ }
+ }
+
+ fun blockUser(
+ scope: CoroutineScope,
+ isBlockedState: MutableState>,
+ ) = scope.launch {
+ isBlockedState.value = AsyncData.Loading(false)
+ client.ignoreUser(userId)
+ .onFailure {
+ isBlockedState.value = AsyncData.Failure(it, false)
+ }
+ // Note: on success, ignoredUserList will be updated.
+ }
+
+ fun unblockUser(
+ scope: CoroutineScope,
+ isBlockedState: MutableState>,
+ ) = scope.launch {
+ isBlockedState.value = AsyncData.Loading(true)
+ client.unignoreUser(userId)
+ .onFailure {
+ isBlockedState.value = AsyncData.Failure(it, true)
+ }
+ // Note: on success, ignoredUserList will be updated.
+ }
+}
diff --git a/features/userprofile/shared/src/main/kotlin/io/element/android/features/userprofile/shared/UserProfileState.kt b/features/userprofile/shared/src/main/kotlin/io/element/android/features/userprofile/shared/UserProfileState.kt
new file mode 100644
index 0000000000..bdf4578172
--- /dev/null
+++ b/features/userprofile/shared/src/main/kotlin/io/element/android/features/userprofile/shared/UserProfileState.kt
@@ -0,0 +1,40 @@
+/*
+ * Copyright (c) 2024 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.element.android.features.userprofile.shared
+
+import io.element.android.libraries.architecture.AsyncAction
+import io.element.android.libraries.architecture.AsyncData
+import io.element.android.libraries.matrix.api.core.RoomId
+import io.element.android.libraries.matrix.api.core.UserId
+
+data class UserProfileState(
+ val userId: UserId,
+ val userName: String?,
+ val avatarUrl: String?,
+ val isBlocked: AsyncData,
+ val startDmActionState: AsyncAction,
+ val displayConfirmationDialog: ConfirmationDialog?,
+ val isCurrentUser: Boolean,
+ val dmRoomId: RoomId?,
+ val canCall: Boolean,
+ val eventSink: (UserProfileEvents) -> Unit
+) {
+ enum class ConfirmationDialog {
+ Block,
+ Unblock
+ }
+}
diff --git a/features/userprofile/shared/src/main/kotlin/io/element/android/features/userprofile/shared/UserProfileStateProvider.kt b/features/userprofile/shared/src/main/kotlin/io/element/android/features/userprofile/shared/UserProfileStateProvider.kt
new file mode 100644
index 0000000000..14b5d29878
--- /dev/null
+++ b/features/userprofile/shared/src/main/kotlin/io/element/android/features/userprofile/shared/UserProfileStateProvider.kt
@@ -0,0 +1,63 @@
+/*
+ * Copyright (c) 2024 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.element.android.features.userprofile.shared
+
+import androidx.compose.ui.tooling.preview.PreviewParameterProvider
+import io.element.android.libraries.architecture.AsyncAction
+import io.element.android.libraries.architecture.AsyncData
+import io.element.android.libraries.matrix.api.core.RoomId
+import io.element.android.libraries.matrix.api.core.UserId
+
+open class UserProfileStateProvider : PreviewParameterProvider {
+ override val values: Sequence
+ get() = sequenceOf(
+ aUserProfileState(),
+ aUserProfileState(userName = null),
+ aUserProfileState(isBlocked = AsyncData.Success(true)),
+ aUserProfileState(displayConfirmationDialog = UserProfileState.ConfirmationDialog.Block),
+ aUserProfileState(displayConfirmationDialog = UserProfileState.ConfirmationDialog.Unblock),
+ aUserProfileState(isBlocked = AsyncData.Loading(true)),
+ aUserProfileState(startDmActionState = AsyncAction.Loading),
+ aUserProfileState(canCall = true),
+ aUserProfileState(dmRoomId = null),
+ // Add other states here
+ )
+}
+
+fun aUserProfileState(
+ userId: UserId = UserId("@daniel:domain.com"),
+ userName: String? = "Daniel",
+ avatarUrl: String? = null,
+ isBlocked: AsyncData = AsyncData.Success(false),
+ startDmActionState: AsyncAction = AsyncAction.Uninitialized,
+ displayConfirmationDialog: UserProfileState.ConfirmationDialog? = null,
+ isCurrentUser: Boolean = false,
+ dmRoomId: RoomId? = null,
+ canCall: Boolean = false,
+ eventSink: (UserProfileEvents) -> Unit = {},
+) = UserProfileState(
+ userId = userId,
+ userName = userName,
+ avatarUrl = avatarUrl,
+ isBlocked = isBlocked,
+ startDmActionState = startDmActionState,
+ displayConfirmationDialog = displayConfirmationDialog,
+ isCurrentUser = isCurrentUser,
+ dmRoomId = dmRoomId,
+ canCall = canCall,
+ eventSink = eventSink,
+)
diff --git a/features/userprofile/shared/src/main/kotlin/io/element/android/features/userprofile/shared/UserProfileView.kt b/features/userprofile/shared/src/main/kotlin/io/element/android/features/userprofile/shared/UserProfileView.kt
new file mode 100644
index 0000000000..3d5ae4a66c
--- /dev/null
+++ b/features/userprofile/shared/src/main/kotlin/io/element/android/features/userprofile/shared/UserProfileView.kt
@@ -0,0 +1,121 @@
+/*
+ * Copyright (c) 2024 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.element.android.features.userprofile.shared
+
+import androidx.activity.compose.BackHandler
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.consumeWindowInsets
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.tooling.preview.PreviewParameter
+import androidx.compose.ui.unit.dp
+import io.element.android.features.userprofile.shared.blockuser.BlockUserDialogs
+import io.element.android.features.userprofile.shared.blockuser.BlockUserSection
+import io.element.android.libraries.designsystem.components.async.AsyncActionView
+import io.element.android.libraries.designsystem.components.async.AsyncActionViewDefaults
+import io.element.android.libraries.designsystem.components.button.BackButton
+import io.element.android.libraries.designsystem.preview.ElementPreview
+import io.element.android.libraries.designsystem.preview.PreviewsDayNight
+import io.element.android.libraries.designsystem.theme.components.Scaffold
+import io.element.android.libraries.designsystem.theme.components.TopAppBar
+import io.element.android.libraries.matrix.api.core.RoomId
+import io.element.android.libraries.ui.strings.CommonStrings
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun UserProfileView(
+ state: UserProfileState,
+ onShareUser: () -> Unit,
+ onDmStarted: (RoomId) -> Unit,
+ onStartCall: (RoomId) -> Unit,
+ goBack: () -> Unit,
+ openAvatarPreview: (username: String, url: String) -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ BackHandler { goBack() }
+ Scaffold(
+ modifier = modifier,
+ topBar = {
+ TopAppBar(title = { }, navigationIcon = { BackButton(onClick = goBack) })
+ },
+ ) { padding ->
+ Column(
+ modifier = Modifier
+ .padding(padding)
+ .consumeWindowInsets(padding)
+ .verticalScroll(rememberScrollState())
+ ) {
+ UserProfileHeaderSection(
+ avatarUrl = state.avatarUrl,
+ userId = state.userId,
+ userName = state.userName,
+ openAvatarPreview = { avatarUrl ->
+ openAvatarPreview(state.userName ?: state.userId.value, avatarUrl)
+ },
+ )
+
+ UserProfileMainActionsSection(
+ isCurrentUser = state.isCurrentUser,
+ canCall = state.canCall,
+ onShareUser = onShareUser,
+ onStartDM = { state.eventSink(UserProfileEvents.StartDM) },
+ onCall = { state.dmRoomId?.let { onStartCall(it) } }
+ )
+
+ Spacer(modifier = Modifier.height(26.dp))
+
+ if (!state.isCurrentUser) {
+ BlockUserSection(state)
+ BlockUserDialogs(state)
+ }
+ AsyncActionView(
+ async = state.startDmActionState,
+ progressDialog = {
+ AsyncActionViewDefaults.ProgressDialog(
+ progressText = stringResource(CommonStrings.common_starting_chat),
+ )
+ },
+ onSuccess = onDmStarted,
+ errorMessage = { stringResource(R.string.screen_start_chat_error_starting_chat) },
+ onRetry = { state.eventSink(UserProfileEvents.StartDM) },
+ onErrorDismiss = { state.eventSink(UserProfileEvents.ClearStartDMState) },
+ )
+ }
+ }
+}
+
+@PreviewsDayNight
+@Composable
+internal fun UserProfileViewPreview(
+ @PreviewParameter(UserProfileStateProvider::class) state: UserProfileState
+) = ElementPreview {
+ UserProfileView(
+ state = state,
+ onShareUser = {},
+ goBack = {},
+ onDmStarted = {},
+ onStartCall = {},
+ openAvatarPreview = { _, _ -> }
+ )
+}
diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/details/avatar/AvatarPreviewNode.kt b/features/userprofile/shared/src/main/kotlin/io/element/android/features/userprofile/shared/avatar/AvatarPreviewNode.kt
similarity index 85%
rename from features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/details/avatar/AvatarPreviewNode.kt
rename to features/userprofile/shared/src/main/kotlin/io/element/android/features/userprofile/shared/avatar/AvatarPreviewNode.kt
index ecd2806b88..fc736dc425 100644
--- a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/details/avatar/AvatarPreviewNode.kt
+++ b/features/userprofile/shared/src/main/kotlin/io/element/android/features/userprofile/shared/avatar/AvatarPreviewNode.kt
@@ -1,5 +1,5 @@
/*
- * Copyright (c) 2023 New Vector Ltd
+ * Copyright (c) 2024 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@@ -14,18 +14,18 @@
* limitations under the License.
*/
-package io.element.android.features.roomdetails.impl.members.details.avatar
+package io.element.android.features.userprofile.shared.avatar
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.plugin.Plugin
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import io.element.android.anvilannotations.ContributesNode
-import io.element.android.libraries.di.RoomScope
+import io.element.android.libraries.di.SessionScope
import io.element.android.libraries.mediaviewer.api.viewer.MediaViewerNode
import io.element.android.libraries.mediaviewer.api.viewer.MediaViewerPresenter
-@ContributesNode(RoomScope::class)
+@ContributesNode(SessionScope::class)
class AvatarPreviewNode @AssistedInject constructor(
@Assisted buildContext: BuildContext,
@Assisted plugins: List,
diff --git a/features/userprofile/shared/src/main/kotlin/io/element/android/features/userprofile/shared/blockuser/BlockUserDialogs.kt b/features/userprofile/shared/src/main/kotlin/io/element/android/features/userprofile/shared/blockuser/BlockUserDialogs.kt
new file mode 100644
index 0000000000..3e7aeff512
--- /dev/null
+++ b/features/userprofile/shared/src/main/kotlin/io/element/android/features/userprofile/shared/blockuser/BlockUserDialogs.kt
@@ -0,0 +1,83 @@
+/*
+ * Copyright (c) 2024 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.element.android.features.userprofile.shared.blockuser
+
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.res.stringResource
+import io.element.android.features.userprofile.shared.R
+import io.element.android.features.userprofile.shared.UserProfileEvents
+import io.element.android.features.userprofile.shared.UserProfileState
+import io.element.android.libraries.designsystem.components.dialogs.ConfirmationDialog
+
+@Composable
+fun BlockUserDialogs(state: UserProfileState) {
+ when (state.displayConfirmationDialog) {
+ null -> Unit
+ UserProfileState.ConfirmationDialog.Block -> {
+ BlockConfirmationDialog(
+ onBlockAction = {
+ state.eventSink(
+ UserProfileEvents.BlockUser(
+ needsConfirmation = false
+ )
+ )
+ },
+ onDismiss = { state.eventSink(UserProfileEvents.ClearConfirmationDialog) }
+ )
+ }
+ UserProfileState.ConfirmationDialog.Unblock -> {
+ UnblockConfirmationDialog(
+ onUnblockAction = {
+ state.eventSink(
+ UserProfileEvents.UnblockUser(
+ needsConfirmation = false
+ )
+ )
+ },
+ onDismiss = { state.eventSink(UserProfileEvents.ClearConfirmationDialog) }
+ )
+ }
+ }
+}
+
+@Composable
+private fun BlockConfirmationDialog(
+ onBlockAction: () -> Unit,
+ onDismiss: () -> Unit,
+) {
+ ConfirmationDialog(
+ title = stringResource(R.string.screen_dm_details_block_user),
+ content = stringResource(R.string.screen_dm_details_block_alert_description),
+ submitText = stringResource(R.string.screen_dm_details_block_alert_action),
+ onSubmitClicked = onBlockAction,
+ onDismiss = onDismiss
+ )
+}
+
+@Composable
+private fun UnblockConfirmationDialog(
+ onUnblockAction: () -> Unit,
+ onDismiss: () -> Unit,
+) {
+ ConfirmationDialog(
+ title = stringResource(R.string.screen_dm_details_unblock_user),
+ content = stringResource(R.string.screen_dm_details_unblock_alert_description),
+ submitText = stringResource(R.string.screen_dm_details_unblock_alert_action),
+ onSubmitClicked = onUnblockAction,
+ onDismiss = onDismiss
+ )
+}
diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/blockuser/BlockUserSection.kt b/features/userprofile/shared/src/main/kotlin/io/element/android/features/userprofile/shared/blockuser/BlockUserSection.kt
similarity index 77%
rename from features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/blockuser/BlockUserSection.kt
rename to features/userprofile/shared/src/main/kotlin/io/element/android/features/userprofile/shared/blockuser/BlockUserSection.kt
index a35df8cdcf..6ad1bf8484 100644
--- a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/blockuser/BlockUserSection.kt
+++ b/features/userprofile/shared/src/main/kotlin/io/element/android/features/userprofile/shared/blockuser/BlockUserSection.kt
@@ -1,5 +1,5 @@
/*
- * Copyright (c) 2023 New Vector Ltd
+ * Copyright (c) 2024 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@@ -14,7 +14,7 @@
* limitations under the License.
*/
-package io.element.android.features.roomdetails.impl.blockuser
+package io.element.android.features.userprofile.shared.blockuser
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.progressSemantics
@@ -23,9 +23,9 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import io.element.android.compound.tokens.generated.CompoundIcons
-import io.element.android.features.roomdetails.impl.R
-import io.element.android.features.roomdetails.impl.members.details.RoomMemberDetailsEvents
-import io.element.android.features.roomdetails.impl.members.details.RoomMemberDetailsState
+import io.element.android.features.userprofile.shared.R
+import io.element.android.features.userprofile.shared.UserProfileEvents
+import io.element.android.features.userprofile.shared.UserProfileState
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.core.bool.orFalse
import io.element.android.libraries.designsystem.components.dialogs.RetryDialog
@@ -39,8 +39,14 @@ import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.ui.strings.CommonStrings
@Composable
-internal fun BlockUserSection(state: RoomMemberDetailsState) {
- PreferenceCategory(showDivider = false) {
+fun BlockUserSection(
+ state: UserProfileState,
+ modifier: Modifier = Modifier,
+) {
+ PreferenceCategory(
+ modifier = modifier,
+ showDivider = false,
+ ) {
when (state.isBlocked) {
is AsyncData.Failure -> PreferenceBlockUser(isBlocked = state.isBlocked.prevData, isLoading = false, eventSink = state.eventSink)
is AsyncData.Loading -> PreferenceBlockUser(isBlocked = state.isBlocked.prevData, isLoading = true, eventSink = state.eventSink)
@@ -51,13 +57,13 @@ internal fun BlockUserSection(state: RoomMemberDetailsState) {
if (state.isBlocked is AsyncData.Failure) {
RetryDialog(
content = stringResource(CommonStrings.error_unknown),
- onDismiss = { state.eventSink(RoomMemberDetailsEvents.ClearBlockUserError) },
+ onDismiss = { state.eventSink(UserProfileEvents.ClearBlockUserError) },
onRetry = {
val event = when (state.isBlocked.prevData) {
- true -> RoomMemberDetailsEvents.UnblockUser(needsConfirmation = false)
- false -> RoomMemberDetailsEvents.BlockUser(needsConfirmation = false)
+ true -> UserProfileEvents.UnblockUser(needsConfirmation = false)
+ false -> UserProfileEvents.BlockUser(needsConfirmation = false)
// null case Should not happen
- null -> RoomMemberDetailsEvents.ClearBlockUserError
+ null -> UserProfileEvents.ClearBlockUserError
}
state.eventSink(event)
},
@@ -69,7 +75,7 @@ internal fun BlockUserSection(state: RoomMemberDetailsState) {
private fun PreferenceBlockUser(
isBlocked: Boolean?,
isLoading: Boolean,
- eventSink: (RoomMemberDetailsEvents) -> Unit,
+ eventSink: (UserProfileEvents) -> Unit,
) {
val loadingCurrentValue = @Composable {
CircularProgressIndicator(
@@ -83,7 +89,7 @@ private fun PreferenceBlockUser(
ListItem(
headlineContent = { Text(stringResource(R.string.screen_dm_details_unblock_user)) },
leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Block())),
- onClick = { if (!isLoading) eventSink(RoomMemberDetailsEvents.UnblockUser(needsConfirmation = true)) },
+ onClick = { if (!isLoading) eventSink(UserProfileEvents.UnblockUser(needsConfirmation = true)) },
trailingContent = if (isLoading) ListItemContent.Custom(loadingCurrentValue) else null,
style = ListItemStyle.Primary,
)
@@ -92,7 +98,7 @@ private fun PreferenceBlockUser(
headlineContent = { Text(stringResource(R.string.screen_dm_details_block_user)) },
leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Block())),
style = ListItemStyle.Destructive,
- onClick = { if (!isLoading) eventSink(RoomMemberDetailsEvents.BlockUser(needsConfirmation = true)) },
+ onClick = { if (!isLoading) eventSink(UserProfileEvents.BlockUser(needsConfirmation = true)) },
trailingContent = if (isLoading) ListItemContent.Custom(loadingCurrentValue) else null,
)
}
diff --git a/features/userprofile/shared/src/main/res/values-be/translations.xml b/features/userprofile/shared/src/main/res/values-be/translations.xml
new file mode 100644
index 0000000000..3e5b95d74c
--- /dev/null
+++ b/features/userprofile/shared/src/main/res/values-be/translations.xml
@@ -0,0 +1,10 @@
+
+
+ "Заблакіраваць"
+ "Заблакіраваныя карыстальнікі не змогуць адпраўляць вам паведамленні, і ўсе іх паведамленні будуць схаваны. Вы можаце разблакіраваць іх у любы час."
+ "Заблакіраваць карыстальніка"
+ "Разблакіраваць"
+ "Вы зноў зможаце ўбачыць усе паведамленні."
+ "Разблакіраваць карыстальніка"
+ "Пры спробе пачаць чат адбылася памылка"
+
diff --git a/features/userprofile/shared/src/main/res/values-bg/translations.xml b/features/userprofile/shared/src/main/res/values-bg/translations.xml
new file mode 100644
index 0000000000..96b7bf7f56
--- /dev/null
+++ b/features/userprofile/shared/src/main/res/values-bg/translations.xml
@@ -0,0 +1,7 @@
+
+
+ "Блокиране"
+ "Блокиране на потребителя"
+ "Отблокиране"
+ "Отблокиране на потребителя"
+
diff --git a/features/userprofile/shared/src/main/res/values-cs/translations.xml b/features/userprofile/shared/src/main/res/values-cs/translations.xml
new file mode 100644
index 0000000000..541954b8b8
--- /dev/null
+++ b/features/userprofile/shared/src/main/res/values-cs/translations.xml
@@ -0,0 +1,10 @@
+
+
+ "Zablokovat"
+ "Blokovaní uživatelé vám nebudou moci posílat zprávy a všechny jejich zprávy budou skryty. Můžete je kdykoli odblokovat."
+ "Zablokovat uživatele"
+ "Odblokovat"
+ "Znovu uvidíte všechny zprávy od nich."
+ "Odblokovat uživatele"
+ "Při pokusu o zahájení chatu došlo k chybě"
+
diff --git a/features/userprofile/shared/src/main/res/values-de/translations.xml b/features/userprofile/shared/src/main/res/values-de/translations.xml
new file mode 100644
index 0000000000..02f7517401
--- /dev/null
+++ b/features/userprofile/shared/src/main/res/values-de/translations.xml
@@ -0,0 +1,10 @@
+
+
+ "Blockieren"
+ "Blockierte Benutzer können Dir keine Nachrichten senden und alle ihre alten Nachrichten werden ausgeblendet. Die Blockierung kann jederzeit aufgehoben werden."
+ "Benutzer blockieren"
+ "Blockierung aufheben"
+ "Der Nutzer kann dir wieder Nachrichten senden & alle Nachrichten des Nutzers werden wieder angezeigt."
+ "Blockierung aufheben"
+ "Beim Versuch, einen Chat zu starten, ist ein Fehler aufgetreten"
+
diff --git a/features/userprofile/shared/src/main/res/values-es/translations.xml b/features/userprofile/shared/src/main/res/values-es/translations.xml
new file mode 100644
index 0000000000..ffe33a333a
--- /dev/null
+++ b/features/userprofile/shared/src/main/res/values-es/translations.xml
@@ -0,0 +1,10 @@
+
+
+ "Bloquear"
+ "Los usuarios bloqueados no podrán enviarte mensajes y todos sus mensajes se ocultarán. Puedes desbloquearlos cuando quieras."
+ "Bloquear usuario"
+ "Desbloquear"
+ "Podrás ver todos sus mensajes de nuevo."
+ "Desbloquear usuario"
+ "Se ha producido un error al intentar iniciar un chat"
+
diff --git a/features/userprofile/shared/src/main/res/values-fr/translations.xml b/features/userprofile/shared/src/main/res/values-fr/translations.xml
new file mode 100644
index 0000000000..0238cacbc4
--- /dev/null
+++ b/features/userprofile/shared/src/main/res/values-fr/translations.xml
@@ -0,0 +1,10 @@
+
+
+ "Bloquer"
+ "Les utilisateurs bloqués ne pourront pas vous envoyer de messages et tous leurs messages seront masqués. Vous pouvez les débloquer à tout moment."
+ "Bloquer l’utilisateur"
+ "Débloquer"
+ "Vous pourrez à nouveau voir tous ses messages."
+ "Débloquer l’utilisateur"
+ "Une erreur s’est produite lors de la tentative de création de la discussion"
+
diff --git a/features/userprofile/shared/src/main/res/values-hu/translations.xml b/features/userprofile/shared/src/main/res/values-hu/translations.xml
new file mode 100644
index 0000000000..9b491d557b
--- /dev/null
+++ b/features/userprofile/shared/src/main/res/values-hu/translations.xml
@@ -0,0 +1,10 @@
+
+
+ "Letiltás"
+ "A letiltott felhasználók nem fognak tudni üzeneteket küldeni, és az összes üzenetük rejtve lesz. Bármikor feloldhatja a letiltásukat."
+ "Felhasználó letiltása"
+ "Letiltás feloldása"
+ "Újra láthatja az összes üzenetét."
+ "Felhasználó kitiltásának feloldása"
+ "Hiba történt a csevegés indításakor"
+
diff --git a/features/userprofile/shared/src/main/res/values-it/translations.xml b/features/userprofile/shared/src/main/res/values-it/translations.xml
new file mode 100644
index 0000000000..e123113da6
--- /dev/null
+++ b/features/userprofile/shared/src/main/res/values-it/translations.xml
@@ -0,0 +1,10 @@
+
+
+ "Blocca"
+ "Gli utenti bloccati non saranno in grado di inviarti messaggi e tutti quelli già ricevuti saranno nascosti. Puoi sbloccarli in qualsiasi momento."
+ "Blocca utente"
+ "Sblocca"
+ "Potrai vedere di nuovo tutti i suoi messaggi."
+ "Sblocca utente"
+ "Si è verificato un errore durante il tentativo di avviare una chat"
+
diff --git a/features/userprofile/shared/src/main/res/values-ro/translations.xml b/features/userprofile/shared/src/main/res/values-ro/translations.xml
new file mode 100644
index 0000000000..0922bbebd6
--- /dev/null
+++ b/features/userprofile/shared/src/main/res/values-ro/translations.xml
@@ -0,0 +1,10 @@
+
+
+ "Blocați"
+ "Utilizatorii blocați nu vă vor putea trimite mesaje și toate mesajele lor vor fi ascunse. Puteți anula această acțiune oricând."
+ "Blocați utilizatorul"
+ "Deblocați"
+ "La deblocarea utilizatorului, veți putea vedea din nou toate mesajele de la acesta."
+ "Deblocați utilizatorul"
+ "A apărut o eroare la încercarea începerii conversației"
+
diff --git a/features/userprofile/shared/src/main/res/values-ru/translations.xml b/features/userprofile/shared/src/main/res/values-ru/translations.xml
new file mode 100644
index 0000000000..665ee4207f
--- /dev/null
+++ b/features/userprofile/shared/src/main/res/values-ru/translations.xml
@@ -0,0 +1,10 @@
+
+
+ "Заблокировать"
+ "Заблокированные пользователи не смогут отправлять вам сообщения, а все их сообщения будут скрыты. Вы можете разблокировать их в любое время."
+ "Заблокировать пользователя"
+ "Разблокировать"
+ "Вы снова сможете увидеть все сообщения."
+ "Разблокировать пользователя"
+ "Произошла ошибка при попытке открытия комнаты"
+
diff --git a/features/userprofile/shared/src/main/res/values-sk/translations.xml b/features/userprofile/shared/src/main/res/values-sk/translations.xml
new file mode 100644
index 0000000000..01c2dffa42
--- /dev/null
+++ b/features/userprofile/shared/src/main/res/values-sk/translations.xml
@@ -0,0 +1,10 @@
+
+
+ "Zablokovať"
+ "Blokovaní používatelia vám nebudú môcť posielať správy a všetky ich správy budú skryté. Môžete ich kedykoľvek odblokovať."
+ "Zablokovať používateľa"
+ "Odblokovať"
+ "Všetky správy od nich budete môcť opäť vidieť."
+ "Odblokovať používateľa"
+ "Pri pokuse o spustenie konverzácie sa vyskytla chyba"
+
diff --git a/features/userprofile/shared/src/main/res/values-sv/translations.xml b/features/userprofile/shared/src/main/res/values-sv/translations.xml
new file mode 100644
index 0000000000..6f2c9568c1
--- /dev/null
+++ b/features/userprofile/shared/src/main/res/values-sv/translations.xml
@@ -0,0 +1,10 @@
+
+
+ "Blockera"
+ "Blockerade användare kommer inte att kunna skicka meddelanden till dig och alla deras meddelanden kommer att döljas. Du kan avblockera dem när som helst."
+ "Blockera användare"
+ "Avblockera"
+ "Du kommer att kunna se alla meddelanden från dem igen."
+ "Avblockera användare"
+ "Ett fel uppstod när du försökte starta en chatt"
+
diff --git a/features/userprofile/shared/src/main/res/values-uk/translations.xml b/features/userprofile/shared/src/main/res/values-uk/translations.xml
new file mode 100644
index 0000000000..63bc66bb0f
--- /dev/null
+++ b/features/userprofile/shared/src/main/res/values-uk/translations.xml
@@ -0,0 +1,10 @@
+
+
+ "Заблокувати"
+ "Заблоковані користувачі не зможуть надсилати Вам повідомлення, і всі їхні повідомлення будуть приховані. Ви можете розблокувати їх у будь-який час."
+ "Заблокувати користувача"
+ "Розблокувати"
+ "Ви знову зможете бачити всі повідомлення від них."
+ "Розблокувати користувача"
+ "Під час спроби почати чат сталася помилка"
+
diff --git a/features/userprofile/shared/src/main/res/values-zh-rTW/translations.xml b/features/userprofile/shared/src/main/res/values-zh-rTW/translations.xml
new file mode 100644
index 0000000000..ee42766639
--- /dev/null
+++ b/features/userprofile/shared/src/main/res/values-zh-rTW/translations.xml
@@ -0,0 +1,7 @@
+
+
+ "封鎖"
+ "封鎖使用者"
+ "解除封鎖"
+ "解除封鎖使用者"
+
diff --git a/features/userprofile/shared/src/main/res/values/localazy.xml b/features/userprofile/shared/src/main/res/values/localazy.xml
new file mode 100644
index 0000000000..e73c16fe4e
--- /dev/null
+++ b/features/userprofile/shared/src/main/res/values/localazy.xml
@@ -0,0 +1,10 @@
+
+
+ "Block"
+ "Blocked users won\'t be able to send you messages and all their messages will be hidden. You can unblock them anytime."
+ "Block user"
+ "Unblock"
+ "You\'ll be able to see all messages from them again."
+ "Unblock user"
+ "An error occurred when trying to start a chat"
+
diff --git a/features/userprofile/shared/src/test/kotlin/io/element/android/features/userprofile/UserProfileViewTest.kt b/features/userprofile/shared/src/test/kotlin/io/element/android/features/userprofile/UserProfileViewTest.kt
new file mode 100644
index 0000000000..6cc5e229e5
--- /dev/null
+++ b/features/userprofile/shared/src/test/kotlin/io/element/android/features/userprofile/UserProfileViewTest.kt
@@ -0,0 +1,235 @@
+/*
+ * Copyright (c) 2024 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.element.android.features.userprofile
+
+import androidx.activity.ComponentActivity
+import androidx.compose.ui.test.hasTestTag
+import androidx.compose.ui.test.junit4.AndroidComposeTestRule
+import androidx.compose.ui.test.junit4.createAndroidComposeRule
+import androidx.compose.ui.test.performClick
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import io.element.android.features.userprofile.shared.R
+import io.element.android.features.userprofile.shared.UserProfileEvents
+import io.element.android.features.userprofile.shared.UserProfileState
+import io.element.android.features.userprofile.shared.UserProfileView
+import io.element.android.features.userprofile.shared.aUserProfileState
+import io.element.android.libraries.architecture.AsyncData
+import io.element.android.libraries.matrix.api.core.RoomId
+import io.element.android.libraries.matrix.test.AN_AVATAR_URL
+import io.element.android.libraries.matrix.test.A_ROOM_ID
+import io.element.android.libraries.matrix.test.A_USER_NAME
+import io.element.android.libraries.testtags.TestTags
+import io.element.android.libraries.ui.strings.CommonStrings
+import io.element.android.tests.testutils.EnsureNeverCalled
+import io.element.android.tests.testutils.EnsureNeverCalledWithParam
+import io.element.android.tests.testutils.EnsureNeverCalledWithTwoParams
+import io.element.android.tests.testutils.EventsRecorder
+import io.element.android.tests.testutils.clickOn
+import io.element.android.tests.testutils.ensureCalledOnce
+import io.element.android.tests.testutils.ensureCalledOnceWithParam
+import io.element.android.tests.testutils.ensureCalledOnceWithTwoParams
+import io.element.android.tests.testutils.pressBack
+import io.element.android.tests.testutils.pressBackKey
+import kotlinx.coroutines.test.runTest
+import org.junit.Rule
+import org.junit.Test
+import org.junit.rules.TestRule
+import org.junit.runner.RunWith
+
+@RunWith(AndroidJUnit4::class)
+class UserProfileViewTest {
+ @get:Rule val rule = createAndroidComposeRule()
+
+ @Test
+ fun `on back key press - the expected callback is called`() = runTest {
+ ensureCalledOnce { callback ->
+ rule.setUserProfileView(
+ goBack = callback,
+ )
+ rule.pressBackKey()
+ }
+ }
+
+ @Test
+ fun `on back button click - the expected callback is called`() = runTest {
+ ensureCalledOnce { callback ->
+ rule.setUserProfileView(
+ goBack = callback,
+ )
+ rule.pressBack()
+ }
+ }
+
+ @Test
+ fun `on avatar clicked - the expected callback is called`() = runTest {
+ ensureCalledOnceWithTwoParams(A_USER_NAME, AN_AVATAR_URL) { callback ->
+ rule.setUserProfileView(
+ state = aUserProfileState(userName = A_USER_NAME, avatarUrl = AN_AVATAR_URL),
+ openAvatarPreview = callback,
+ )
+ rule.onNode(hasTestTag(TestTags.memberDetailAvatar.value)).performClick()
+ }
+ }
+
+ @Test
+ fun `on avatar clicked with no avatar - nothing happens`() = runTest {
+ val callback = EnsureNeverCalledWithTwoParams()
+ rule.setUserProfileView(
+ state = aUserProfileState(userName = A_USER_NAME, avatarUrl = null),
+ openAvatarPreview = callback,
+ )
+ rule.onNode(hasTestTag(TestTags.memberDetailAvatar.value)).performClick()
+ }
+
+ @Test
+ fun `on Share clicked - the expected callback is called`() = runTest {
+ ensureCalledOnce { callback ->
+ rule.setUserProfileView(
+ onShareUser = callback,
+ )
+ rule.clickOn(CommonStrings.action_share)
+ }
+ }
+
+ @Test
+ fun `on Message clicked - the StartDm event is emitted`() = runTest {
+ val eventsRecorder = EventsRecorder()
+ rule.setUserProfileView(
+ state = aUserProfileState(
+ dmRoomId = A_ROOM_ID,
+ eventSink = eventsRecorder,
+ ),
+ )
+ rule.clickOn(CommonStrings.action_message)
+ eventsRecorder.assertSingle(UserProfileEvents.StartDM)
+ }
+
+ @Test
+ fun `on Call clicked - the expected callback is called`() = runTest {
+ ensureCalledOnceWithParam(A_ROOM_ID) { callback ->
+ rule.setUserProfileView(
+ state = aUserProfileState(
+ dmRoomId = A_ROOM_ID,
+ canCall = true,
+ ),
+ onStartCall = callback,
+ )
+ rule.clickOn(CommonStrings.action_call)
+ }
+ }
+
+ @Test
+ fun `on Block user clicked - a BlockUser event is emitted with needsConfirmation`() = runTest {
+ val eventsRecorder = EventsRecorder()
+ rule.setUserProfileView(
+ state = aUserProfileState(
+ eventSink = eventsRecorder,
+ ),
+ )
+ rule.clickOn(R.string.screen_dm_details_block_user)
+ eventsRecorder.assertSingle(UserProfileEvents.BlockUser(needsConfirmation = true))
+ }
+
+ @Test
+ fun `on confirming block user - a BlockUser event is emitted without needsConfirmation`() = runTest {
+ val eventsRecorder = EventsRecorder()
+ rule.setUserProfileView(
+ state = aUserProfileState(
+ displayConfirmationDialog = UserProfileState.ConfirmationDialog.Block,
+ eventSink = eventsRecorder,
+ ),
+ )
+ rule.clickOn(R.string.screen_dm_details_block_alert_action)
+ eventsRecorder.assertSingle(UserProfileEvents.BlockUser(needsConfirmation = false))
+ }
+
+ @Test
+ fun `on canceling blocking a user - a ClearConfirmationDialog event is emitted`() = runTest {
+ val eventsRecorder = EventsRecorder()
+ rule.setUserProfileView(
+ state = aUserProfileState(
+ displayConfirmationDialog = UserProfileState.ConfirmationDialog.Block,
+ eventSink = eventsRecorder,
+ ),
+ )
+ rule.clickOn(CommonStrings.action_cancel)
+ eventsRecorder.assertSingle(UserProfileEvents.ClearConfirmationDialog)
+ }
+
+ @Test
+ fun `on Unblock user clicked - an UnblockUser event is emitted with needsConfirmation`() = runTest {
+ val eventsRecorder = EventsRecorder()
+ rule.setUserProfileView(
+ state = aUserProfileState(
+ isBlocked = AsyncData.Success(true),
+ eventSink = eventsRecorder,
+ ),
+ )
+ rule.clickOn(R.string.screen_dm_details_unblock_user)
+ eventsRecorder.assertSingle(UserProfileEvents.UnblockUser(needsConfirmation = true))
+ }
+
+ @Test
+ fun `on confirming Unblock user - an UnblockUser event is emitted without needsConfirmation`() = runTest {
+ val eventsRecorder = EventsRecorder()
+ rule.setUserProfileView(
+ state = aUserProfileState(
+ isBlocked = AsyncData.Success(true),
+ displayConfirmationDialog = UserProfileState.ConfirmationDialog.Unblock,
+ eventSink = eventsRecorder,
+ ),
+ )
+ rule.clickOn(R.string.screen_dm_details_unblock_alert_action)
+ eventsRecorder.assertSingle(UserProfileEvents.UnblockUser(needsConfirmation = false))
+ }
+
+ @Test
+ fun `on canceling unblocking a user - a ClearConfirmationDialog event is emitted`() = runTest {
+ val eventsRecorder = EventsRecorder()
+ rule.setUserProfileView(
+ state = aUserProfileState(
+ isBlocked = AsyncData.Success(true),
+ displayConfirmationDialog = UserProfileState.ConfirmationDialog.Unblock,
+ eventSink = eventsRecorder,
+ ),
+ )
+ rule.clickOn(CommonStrings.action_cancel)
+ eventsRecorder.assertSingle(UserProfileEvents.ClearConfirmationDialog)
+ }
+}
+
+private fun AndroidComposeTestRule.setUserProfileView(
+ state: UserProfileState = aUserProfileState(
+ eventSink = EventsRecorder(expectEvents = false),
+ ),
+ onShareUser: () -> Unit = EnsureNeverCalled(),
+ onDmStarted: (RoomId) -> Unit = EnsureNeverCalledWithParam(),
+ onStartCall: (RoomId) -> Unit = EnsureNeverCalledWithParam(),
+ goBack: () -> Unit = EnsureNeverCalled(),
+ openAvatarPreview: (String, String) -> Unit = EnsureNeverCalledWithTwoParams(),
+) {
+ setContent {
+ UserProfileView(
+ state = state,
+ onShareUser = onShareUser,
+ onDmStarted = onDmStarted,
+ onStartCall = onStartCall,
+ goBack = goBack,
+ openAvatarPreview = openAvatarPreview,
+ )
+ }
+}
diff --git a/features/userprofile/shared/src/test/kotlin/io/element/android/features/userprofile/shared/blockuser/BlockUserDialogsTest.kt b/features/userprofile/shared/src/test/kotlin/io/element/android/features/userprofile/shared/blockuser/BlockUserDialogsTest.kt
new file mode 100644
index 0000000000..66f129fe99
--- /dev/null
+++ b/features/userprofile/shared/src/test/kotlin/io/element/android/features/userprofile/shared/blockuser/BlockUserDialogsTest.kt
@@ -0,0 +1,96 @@
+/*
+ * Copyright (c) 2024 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.element.android.features.userprofile.shared.blockuser
+
+import androidx.activity.ComponentActivity
+import androidx.compose.ui.test.junit4.createAndroidComposeRule
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import io.element.android.features.userprofile.shared.R
+import io.element.android.features.userprofile.shared.UserProfileEvents
+import io.element.android.features.userprofile.shared.UserProfileState
+import io.element.android.features.userprofile.shared.aUserProfileState
+import io.element.android.libraries.ui.strings.CommonStrings
+import io.element.android.tests.testutils.EventsRecorder
+import io.element.android.tests.testutils.clickOn
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+
+@RunWith(AndroidJUnit4::class)
+class BlockUserDialogsTest {
+ @get:Rule val rule = createAndroidComposeRule()
+
+ @Test
+ fun `confirm block user emit expected Event`() {
+ val eventsRecorder = EventsRecorder()
+ rule.setContent {
+ BlockUserDialogs(
+ state = aUserProfileState(
+ displayConfirmationDialog = UserProfileState.ConfirmationDialog.Block,
+ eventSink = eventsRecorder,
+ )
+ )
+ }
+ rule.clickOn(R.string.screen_dm_details_block_alert_action)
+ eventsRecorder.assertSingle(UserProfileEvents.BlockUser(false))
+ }
+
+ @Test
+ fun `cancel block user emit expected Event`() {
+ val eventsRecorder = EventsRecorder()
+ rule.setContent {
+ BlockUserDialogs(
+ state = aUserProfileState(
+ displayConfirmationDialog = UserProfileState.ConfirmationDialog.Block,
+ eventSink = eventsRecorder,
+ )
+ )
+ }
+ rule.clickOn(CommonStrings.action_cancel)
+ eventsRecorder.assertSingle(UserProfileEvents.ClearConfirmationDialog)
+ }
+
+ @Test
+ fun `confirm unblock user emit expected Event`() {
+ val eventsRecorder = EventsRecorder()
+ rule.setContent {
+ BlockUserDialogs(
+ state = aUserProfileState(
+ displayConfirmationDialog = UserProfileState.ConfirmationDialog.Unblock,
+ eventSink = eventsRecorder,
+ )
+ )
+ }
+ rule.clickOn(R.string.screen_dm_details_unblock_alert_action)
+ eventsRecorder.assertSingle(UserProfileEvents.UnblockUser(false))
+ }
+
+ @Test
+ fun `cancel unblock user emit expected Event`() {
+ val eventsRecorder = EventsRecorder()
+ rule.setContent {
+ BlockUserDialogs(
+ state = aUserProfileState(
+ displayConfirmationDialog = UserProfileState.ConfirmationDialog.Unblock,
+ eventSink = eventsRecorder,
+ )
+ )
+ }
+ rule.clickOn(CommonStrings.action_cancel)
+ eventsRecorder.assertSingle(UserProfileEvents.ClearConfirmationDialog)
+ }
+}
diff --git a/features/verifysession/api/src/main/kotlin/io/element/android/features/verifysession/api/VerifySessionEntryPoint.kt b/features/verifysession/api/src/main/kotlin/io/element/android/features/verifysession/api/VerifySessionEntryPoint.kt
index 70600f7f7c..8d19ca5698 100644
--- a/features/verifysession/api/src/main/kotlin/io/element/android/features/verifysession/api/VerifySessionEntryPoint.kt
+++ b/features/verifysession/api/src/main/kotlin/io/element/android/features/verifysession/api/VerifySessionEntryPoint.kt
@@ -31,7 +31,6 @@ interface VerifySessionEntryPoint : FeatureEntryPoint {
interface Callback : Plugin {
fun onEnterRecoveryKey()
- fun onCreateNewRecoveryKey()
fun onDone()
}
}
diff --git a/features/verifysession/impl/build.gradle.kts b/features/verifysession/impl/build.gradle.kts
index 3f62c6898e..38cb453a05 100644
--- a/features/verifysession/impl/build.gradle.kts
+++ b/features/verifysession/impl/build.gradle.kts
@@ -42,6 +42,7 @@ dependencies {
implementation(projects.libraries.matrix.api)
implementation(projects.libraries.matrixui)
implementation(projects.libraries.designsystem)
+ implementation(projects.libraries.preferences.api)
implementation(projects.libraries.uiStrings)
api(libs.statemachine)
api(projects.features.verifysession.api)
@@ -53,6 +54,7 @@ dependencies {
testImplementation(libs.test.truth)
testImplementation(libs.test.turbine)
testImplementation(projects.libraries.matrix.test)
+ testImplementation(projects.libraries.preferences.test)
testImplementation(projects.tests.testutils)
testImplementation(libs.androidx.compose.ui.test.junit)
testReleaseImplementation(libs.androidx.compose.ui.test.manifest)
diff --git a/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/VerifySelfSessionNode.kt b/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/VerifySelfSessionNode.kt
index 222683156a..6fbde66faf 100644
--- a/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/VerifySelfSessionNode.kt
+++ b/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/VerifySelfSessionNode.kt
@@ -43,7 +43,6 @@ class VerifySelfSessionNode @AssistedInject constructor(
state = state,
modifier = modifier,
onEnterRecoveryKey = callback::onEnterRecoveryKey,
- onCreateNewRecoveryKey = callback::onCreateNewRecoveryKey,
onFinished = callback::onDone,
)
}
diff --git a/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/VerifySelfSessionPresenter.kt b/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/VerifySelfSessionPresenter.kt
index c09b48946d..b31cbd0162 100644
--- a/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/VerifySelfSessionPresenter.kt
+++ b/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/VerifySelfSessionPresenter.kt
@@ -23,11 +23,11 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
-import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import com.freeletics.flowredux.compose.rememberStateAndDispatch
+import io.element.android.features.preferences.api.store.SessionPreferencesStore
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.core.meta.BuildMeta
@@ -49,6 +49,7 @@ class VerifySelfSessionPresenter @Inject constructor(
private val encryptionService: EncryptionService,
private val stateMachine: VerifySelfSessionStateMachine,
private val buildMeta: BuildMeta,
+ private val sessionPreferencesStore: SessionPreferencesStore,
) : Presenter {
@Composable
override fun present(): VerifySelfSessionState {
@@ -59,8 +60,8 @@ class VerifySelfSessionPresenter @Inject constructor(
}
val recoveryState by encryptionService.recoveryStateStateFlow.collectAsState()
val stateAndDispatch = stateMachine.rememberStateAndDispatch()
- var skipVerification by remember { mutableStateOf(false) }
- val needsVerification by sessionVerificationService.needsVerificationFlow.collectAsState()
+ val skipVerification by sessionPreferencesStore.isSessionVerificationSkipped().collectAsState(initial = false)
+ val needsVerification by sessionVerificationService.canVerifySessionFlow.collectAsState(initial = true)
val verificationFlowStep by remember {
derivedStateOf {
when {
@@ -86,8 +87,7 @@ class VerifySelfSessionPresenter @Inject constructor(
VerifySelfSessionViewEvents.Cancel -> stateAndDispatch.dispatchAction(StateMachineEvent.Cancel)
VerifySelfSessionViewEvents.Reset -> stateAndDispatch.dispatchAction(StateMachineEvent.Reset)
VerifySelfSessionViewEvents.SkipVerification -> coroutineScope.launch {
- sessionVerificationService.saveVerifiedState(true)
- skipVerification = true
+ sessionPreferencesStore.setSkipSessionVerification(true)
}
}
}
@@ -103,7 +103,10 @@ class VerifySelfSessionPresenter @Inject constructor(
): VerifySelfSessionState.VerificationStep =
when (val machineState = this) {
StateMachineState.Initial, null -> {
- VerifySelfSessionState.VerificationStep.Initial(canEnterRecoveryKey = canEnterRecoveryKey, isLastDevice = encryptionService.isLastDevice.value)
+ VerifySelfSessionState.VerificationStep.Initial(
+ canEnterRecoveryKey = canEnterRecoveryKey,
+ isLastDevice = encryptionService.isLastDevice.value
+ )
}
StateMachineState.RequestingVerification,
StateMachineState.StartingSasVerification,
diff --git a/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/VerifySelfSessionState.kt b/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/VerifySelfSessionState.kt
index 30d91b8fa4..4db21d88b8 100644
--- a/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/VerifySelfSessionState.kt
+++ b/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/VerifySelfSessionState.kt
@@ -29,7 +29,7 @@ data class VerifySelfSessionState(
) {
@Stable
sealed interface VerificationStep {
- data class Initial(val canEnterRecoveryKey: Boolean, val isLastDevice: Boolean) : VerificationStep
+ data class Initial(val canEnterRecoveryKey: Boolean, val isLastDevice: Boolean = false) : VerificationStep
data object Canceled : VerificationStep
data object AwaitingOtherDeviceResponse : VerificationStep
data object Ready : VerificationStep
diff --git a/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/VerifySelfSessionStateProvider.kt b/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/VerifySelfSessionStateProvider.kt
index c066d48613..f6db1bc65f 100644
--- a/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/VerifySelfSessionStateProvider.kt
+++ b/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/VerifySelfSessionStateProvider.kt
@@ -45,7 +45,7 @@ open class VerifySelfSessionStateProvider : PreviewParameterProvider Unit = {},
) = VerifySelfSessionState(
diff --git a/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/VerifySelfSessionView.kt b/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/VerifySelfSessionView.kt
index e54ba31872..42d752d8f5 100644
--- a/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/VerifySelfSessionView.kt
+++ b/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/VerifySelfSessionView.kt
@@ -66,7 +66,6 @@ import io.element.android.features.verifysession.impl.VerifySelfSessionState.Ver
fun VerifySelfSessionView(
state: VerifySelfSessionState,
onEnterRecoveryKey: () -> Unit,
- onCreateNewRecoveryKey: () -> Unit,
onFinished: () -> Unit,
modifier: Modifier = Modifier,
) {
@@ -115,7 +114,6 @@ fun VerifySelfSessionView(
screenState = state,
goBack = ::resetFlow,
onEnterRecoveryKey = onEnterRecoveryKey,
- onCreateNewRecoveryKey = onCreateNewRecoveryKey,
onFinished = onFinished,
)
}
@@ -228,7 +226,6 @@ private fun EmojiItemView(emoji: VerificationEmoji, modifier: Modifier = Modifie
private fun BottomMenu(
screenState: VerifySelfSessionState,
onEnterRecoveryKey: () -> Unit,
- onCreateNewRecoveryKey: () -> Unit,
goBack: () -> Unit,
onFinished: () -> Unit,
) {
@@ -243,8 +240,6 @@ private fun BottomMenu(
BottomMenu(
positiveButtonTitle = stringResource(R.string.screen_session_verification_enter_recovery_key),
onPositiveButtonClicked = onEnterRecoveryKey,
- negativeButtonTitle = stringResource(R.string.screen_identity_confirmation_create_new_recovery_key),
- onNegativeButtonClicked = onCreateNewRecoveryKey,
)
} else {
BottomMenu(
@@ -346,7 +341,6 @@ internal fun VerifySelfSessionViewPreview(@PreviewParameter(VerifySelfSessionSta
VerifySelfSessionView(
state = state,
onEnterRecoveryKey = {},
- onCreateNewRecoveryKey = {},
onFinished = {},
)
}
diff --git a/features/verifysession/impl/src/main/res/values-sv/translations.xml b/features/verifysession/impl/src/main/res/values-sv/translations.xml
index e1c4572085..f1b2fcc6e5 100644
--- a/features/verifysession/impl/src/main/res/values-sv/translations.xml
+++ b/features/verifysession/impl/src/main/res/values-sv/translations.xml
@@ -3,12 +3,15 @@
"Något verkar inte stämma. Antingen gick tidsgränsen för begäran ut eller så avvisades begäran."
"Bekräfta att emojierna nedan matchar de som visas på din andra session."
"Jämför emojis"
+ "Bekräfta att siffrorna nedan matchar de som visas på din andra session."
+ "Jämför siffror"
"Din nya session är nu verifierad. Den har tillgång till dina krypterade meddelanden, och andra användare kommer att se den som betrodd."
"Bevisa att det är du för att komma åt din krypterade meddelandehistorik."
"Öppna en befintlig session"
"Försök att verifiera igen"
"Jag är redo"
"Väntar på att matcha"
+ "Jämför en unik uppsättning emojis."
"Jämför de unika emojierna och se till att de visas i samma ordning."
"De matchar inte"
"De matchar"
diff --git a/features/verifysession/impl/src/main/res/values-zh-rTW/translations.xml b/features/verifysession/impl/src/main/res/values-zh-rTW/translations.xml
index d73ea8e2c4..e98480316a 100644
--- a/features/verifysession/impl/src/main/res/values-zh-rTW/translations.xml
+++ b/features/verifysession/impl/src/main/res/values-zh-rTW/translations.xml
@@ -1,7 +1,11 @@
- "裝置已認證"
- "使用另一個裝置"
+ "建立新的復原金鑰"
+ "驗證這部裝置以設定安全通訊。"
+ "確認這是你本人"
+ "您可以安全地讀取和發送訊息了,與您聊天的人也可以信任這部裝置。"
+ "裝置已驗證"
+ "使用另一部裝置"
"正在等待其他裝置……"
"似乎出了一點問題。有可能是因為等候逾時,或是請求被拒絕。"
"確認顯示在其他工作階段上的表情符號是否和下方的相同。"
diff --git a/features/verifysession/impl/src/test/kotlin/io/element/android/features/verifysession/impl/VerifySelfSessionPresenterTests.kt b/features/verifysession/impl/src/test/kotlin/io/element/android/features/verifysession/impl/VerifySelfSessionPresenterTests.kt
index 06f69e1628..3dd391da16 100644
--- a/features/verifysession/impl/src/test/kotlin/io/element/android/features/verifysession/impl/VerifySelfSessionPresenterTests.kt
+++ b/features/verifysession/impl/src/test/kotlin/io/element/android/features/verifysession/impl/VerifySelfSessionPresenterTests.kt
@@ -34,8 +34,8 @@ import io.element.android.libraries.matrix.api.verification.VerificationFlowStat
import io.element.android.libraries.matrix.test.core.aBuildMeta
import io.element.android.libraries.matrix.test.encryption.FakeEncryptionService
import io.element.android.libraries.matrix.test.verification.FakeSessionVerificationService
+import io.element.android.libraries.preferences.test.InMemorySessionPreferencesStore
import io.element.android.tests.testutils.WarmUpRule
-import io.element.android.tests.testutils.lambda.value
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.runTest
import org.junit.Rule
@@ -53,7 +53,7 @@ class VerifySelfSessionPresenterTests {
presenter.present()
}.test {
awaitItem().run {
- assertThat(verificationFlowStep).isEqualTo(VerificationStep.Initial(false, false))
+ assertThat(verificationFlowStep).isEqualTo(VerificationStep.Initial(false))
assertThat(displaySkipButton).isTrue()
}
}
@@ -80,7 +80,7 @@ class VerifySelfSessionPresenterTests {
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
- assertThat(awaitItem().verificationFlowStep).isEqualTo(VerificationStep.Initial(true, false))
+ assertThat(awaitItem().verificationFlowStep).isEqualTo(VerificationStep.Initial(true))
}
}
@@ -95,7 +95,7 @@ class VerifySelfSessionPresenterTests {
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
- assertThat(awaitItem().verificationFlowStep).isEqualTo(VerificationStep.Initial(true, true))
+ assertThat(awaitItem().verificationFlowStep).isEqualTo(VerificationStep.Initial(canEnterRecoveryKey = true, isLastDevice = true))
}
}
@@ -118,7 +118,7 @@ class VerifySelfSessionPresenterTests {
presenter.present()
}.test {
val initialState = awaitItem()
- assertThat(initialState.verificationFlowStep).isEqualTo(VerificationStep.Initial(false, false))
+ assertThat(initialState.verificationFlowStep).isEqualTo(VerificationStep.Initial(false))
val eventSink = initialState.eventSink
eventSink(VerifySelfSessionViewEvents.StartSasVerification)
// Await for other device response:
@@ -137,7 +137,7 @@ class VerifySelfSessionPresenterTests {
presenter.present()
}.test {
val initialState = awaitItem()
- assertThat(initialState.verificationFlowStep).isEqualTo(VerificationStep.Initial(false, false))
+ assertThat(initialState.verificationFlowStep).isEqualTo(VerificationStep.Initial(false))
val eventSink = initialState.eventSink
eventSink(VerifySelfSessionViewEvents.Cancel)
expectNoEvents()
@@ -172,7 +172,7 @@ class VerifySelfSessionPresenterTests {
awaitItem().eventSink(VerifySelfSessionViewEvents.RequestVerification)
service.shouldFail = false
assertThat(awaitItem().verificationFlowStep).isInstanceOf(VerificationStep.AwaitingOtherDeviceResponse::class.java)
- assertThat(awaitItem().verificationFlowStep).isEqualTo(VerificationStep.Initial(false, false))
+ assertThat(awaitItem().verificationFlowStep).isEqualTo(VerificationStep.Initial(false))
}
}
@@ -231,7 +231,7 @@ class VerifySelfSessionPresenterTests {
assertThat(awaitItem().verificationFlowStep).isEqualTo(VerificationStep.Canceled)
state.eventSink(VerifySelfSessionViewEvents.Reset)
// Went back to initial state
- assertThat(awaitItem().verificationFlowStep).isEqualTo(VerificationStep.Initial(false, false))
+ assertThat(awaitItem().verificationFlowStep).isEqualTo(VerificationStep.Initial(false))
cancelAndIgnoreRemainingEvents()
}
}
@@ -289,7 +289,6 @@ class VerifySelfSessionPresenterTests {
}.test {
val state = requestVerificationAndAwaitVerifyingState(service)
state.eventSink(VerifySelfSessionViewEvents.SkipVerification)
- service.saveVerifiedStateResult.assertions().isCalledOnce().with(value(true))
assertThat(awaitItem().verificationFlowStep).isEqualTo(VerificationStep.Skipped)
}
}
@@ -297,12 +296,16 @@ class VerifySelfSessionPresenterTests {
@Test
fun `present - When verification is not needed, the flow is completed`() = runTest {
val service = FakeSessionVerificationService().apply {
- givenNeedsVerification(false)
+ givenCanVerifySession(false)
+ givenIsReady(true)
+ givenVerifiedStatus(SessionVerifiedStatus.Verified)
+ givenVerificationFlowState(VerificationFlowState.Finished)
}
val presenter = createVerifySelfSessionPresenter(service)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
+ skipItems(1)
assertThat(awaitItem().verificationFlowStep).isEqualTo(VerificationStep.Completed)
}
}
@@ -312,7 +315,7 @@ class VerifySelfSessionPresenterTests {
sessionVerificationData: SessionVerificationData = SessionVerificationData.Emojis(emptyList()),
): VerifySelfSessionState {
var state = awaitItem()
- assertThat(state.verificationFlowStep).isEqualTo(VerificationStep.Initial(false, false))
+ assertThat(state.verificationFlowStep).isEqualTo(VerificationStep.Initial(false))
state.eventSink(VerifySelfSessionViewEvents.RequestVerification)
// Await for other device response:
state = awaitItem()
@@ -334,7 +337,6 @@ class VerifySelfSessionPresenterTests {
private fun unverifiedSessionService(): FakeSessionVerificationService {
return FakeSessionVerificationService().apply {
givenVerifiedStatus(SessionVerifiedStatus.NotVerified)
- givenNeedsVerification(true)
}
}
@@ -342,12 +344,14 @@ class VerifySelfSessionPresenterTests {
service: SessionVerificationService = unverifiedSessionService(),
encryptionService: EncryptionService = FakeEncryptionService(),
buildMeta: BuildMeta = aBuildMeta(),
+ sessionPreferencesStore: InMemorySessionPreferencesStore = InMemorySessionPreferencesStore(),
): VerifySelfSessionPresenter {
return VerifySelfSessionPresenter(
sessionVerificationService = service,
encryptionService = encryptionService,
stateMachine = VerifySelfSessionStateMachine(service, encryptionService),
buildMeta = buildMeta,
+ sessionPreferencesStore = sessionPreferencesStore,
)
}
}
diff --git a/features/verifysession/impl/src/test/kotlin/io/element/android/features/verifysession/impl/VerifySelfSessionViewTest.kt b/features/verifysession/impl/src/test/kotlin/io/element/android/features/verifysession/impl/VerifySelfSessionViewTest.kt
index 4d5f67f0b1..52158c2d7f 100644
--- a/features/verifysession/impl/src/test/kotlin/io/element/android/features/verifysession/impl/VerifySelfSessionViewTest.kt
+++ b/features/verifysession/impl/src/test/kotlin/io/element/android/features/verifysession/impl/VerifySelfSessionViewTest.kt
@@ -144,7 +144,7 @@ class VerifySelfSessionViewTest {
ensureCalledOnce { callback ->
rule.setVerifySelfSessionView(
aVerifySelfSessionState(
- verificationFlowStep = VerifySelfSessionState.VerificationStep.Initial(true, false),
+ verificationFlowStep = VerifySelfSessionState.VerificationStep.Initial(true),
eventSink = eventsRecorder
),
onEnterRecoveryKey = callback,
@@ -153,22 +153,6 @@ class VerifySelfSessionViewTest {
}
}
- @Config(qualifiers = "h1024dp")
- @Test
- fun `clicking on create new recovery key calls the expected callback`() {
- val eventsRecorder = EventsRecorder(expectEvents = false)
- ensureCalledOnce { callback ->
- rule.setVerifySelfSessionView(
- aVerifySelfSessionState(
- verificationFlowStep = VerifySelfSessionState.VerificationStep.Initial(true, true),
- eventSink = eventsRecorder
- ),
- onCreateNewRecoveryKey = callback,
- )
- rule.clickOn(R.string.screen_identity_confirmation_create_new_recovery_key)
- }
- }
-
@Test
fun `clicking on they match emits the expected event`() {
val eventsRecorder = EventsRecorder()
@@ -206,7 +190,7 @@ class VerifySelfSessionViewTest {
val eventsRecorder = EventsRecorder()
rule.setVerifySelfSessionView(
aVerifySelfSessionState(
- verificationFlowStep = VerifySelfSessionState.VerificationStep.Initial(canEnterRecoveryKey = true, isLastDevice = false),
+ verificationFlowStep = VerifySelfSessionState.VerificationStep.Initial(canEnterRecoveryKey = true),
displaySkipButton = true,
eventSink = eventsRecorder
),
@@ -232,14 +216,12 @@ class VerifySelfSessionViewTest {
private fun AndroidComposeTestRule.setVerifySelfSessionView(
state: VerifySelfSessionState,
onEnterRecoveryKey: () -> Unit = EnsureNeverCalled(),
- onCreateNewRecoveryKey: () -> Unit = EnsureNeverCalled(),
onFinished: () -> Unit = EnsureNeverCalled(),
) {
rule.setContent {
VerifySelfSessionView(
state = state,
onEnterRecoveryKey = onEnterRecoveryKey,
- onCreateNewRecoveryKey = onCreateNewRecoveryKey,
onFinished = onFinished,
)
}
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index 6ab79bb7aa..27dfaa42f1 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -6,10 +6,15 @@
android_gradle_plugin = "8.3.2"
kotlin = "1.9.23"
ksp = "1.9.23-1.0.20"
-firebaseAppDistribution = "4.2.0"
+firebaseAppDistribution = "5.0.0"
# AndroidX
-core = "1.12.0"
+core = "1.13.0"
+# Warning: there is an issue with 1.1.0, that I cannot repro on unit test.
+# To repro with the application:
+# Clear the cache of the application and run the app. Nearly each time, there is an infinite loading
+# Due to the DefaultMigrationStore not bahaving as expected.
+# Stick to 1.0.0 for now, and ensure that this scenario cannot be reproduced when upgrading the version.
datastore = "1.0.0"
constraintlayout = "2.1.4"
constraintlayout_compose = "1.0.1"
@@ -18,8 +23,8 @@ activity = "1.8.2"
media3 = "1.3.1"
# Compose
-compose_bom = "2024.04.00"
-composecompiler = "1.5.11"
+compose_bom = "2024.05.00"
+composecompiler = "1.5.13"
# Coroutines
coroutines = "1.8.0"
@@ -38,8 +43,8 @@ serialization_json = "1.6.3"
showkase = "1.0.2"
appyx = "1.4.0"
sqldelight = "2.0.2"
-wysiwyg = "2.37.0"
-telephoto = "0.10.0"
+wysiwyg = "2.37.2"
+telephoto = "0.11.2"
# DI
dagger = "2.51.1"
@@ -63,7 +68,7 @@ kotlin_gradle_plugin = { module = "org.jetbrains.kotlin:kotlin-gradle-plugin", v
kover_gradle_plugin = { module = "org.jetbrains.kotlinx:kover-gradle-plugin", version.ref = "kover" }
gms_google_services = "com.google.gms:google-services:4.4.1"
# https://firebase.google.com/docs/android/setup#available-libraries
-google_firebase_bom = "com.google.firebase:firebase-bom:32.8.1"
+google_firebase_bom = "com.google.firebase:firebase-bom:33.0.0"
firebase_appdistribution_gradle = { module = "com.google.firebase:firebase-appdistribution-gradle", version.ref = "firebaseAppDistribution" }
autonomousapps_dependencyanalysis_plugin = { module = "com.autonomousapps:dependency-analysis-gradle-plugin", version.ref = "dependencyAnalysis" }
@@ -91,7 +96,7 @@ androidx_activity_activity = { module = "androidx.activity:activity", version.re
androidx_activity_compose = { module = "androidx.activity:activity-compose", version.ref = "activity" }
androidx_startup = "androidx.startup:startup-runtime:1.1.1"
androidx_preference = "androidx.preference:preference:1.2.1"
-androidx_webkit = "androidx.webkit:webkit:1.10.0"
+androidx_webkit = "androidx.webkit:webkit:1.11.0"
androidx_compose_bom = { module = "androidx.compose:compose-bom", version.ref = "compose_bom" }
androidx_compose_material3 = "androidx.compose.material3:material3:1.2.1"
@@ -135,7 +140,7 @@ test_mockk = "io.mockk:mockk:1.13.10"
test_konsist = "com.lemonappdev:konsist:0.13.0"
test_turbine = "app.cash.turbine:turbine:1.1.0"
test_truth = "com.google.truth:truth:1.4.2"
-test_parameter_injector = "com.google.testparameterinjector:test-parameter-injector:1.15"
+test_parameter_injector = "com.google.testparameterinjector:test-parameter-injector:1.16"
test_robolectric = "org.robolectric:robolectric:4.12.1"
test_appyx_junit = { module = "com.bumble.appyx:testing-junit4", version.ref = "appyx" }
@@ -154,7 +159,7 @@ jsoup = "org.jsoup:jsoup:1.17.2"
appyx_core = { module = "com.bumble.appyx:core", version.ref = "appyx" }
molecule-runtime = "app.cash.molecule:molecule-runtime:1.4.2"
timber = "com.jakewharton.timber:timber:5.0.1"
-matrix_sdk = "org.matrix.rustcomponents:sdk-android:0.2.14"
+matrix_sdk = "org.matrix.rustcomponents:sdk-android:0.2.18"
matrix_richtexteditor = { module = "io.element.android:wysiwyg", version.ref = "wysiwyg" }
matrix_richtexteditor_compose = { module = "io.element.android:wysiwyg-compose", version.ref = "wysiwyg" }
sqldelight-driver-android = { module = "app.cash.sqldelight:android-driver", version.ref = "sqldelight" }
@@ -168,18 +173,17 @@ vanniktech_blurhash = "com.vanniktech:blurhash:0.3.0"
telephoto_zoomableimage = { module = "me.saket.telephoto:zoomable-image-coil", version.ref = "telephoto" }
telephoto_flick = { module = "me.saket.telephoto:flick-android", version.ref = "telephoto" }
statemachine = "com.freeletics.flowredux:compose:1.2.1"
-maplibre = "org.maplibre.gl:android-sdk:10.3.0"
+maplibre = "org.maplibre.gl:android-sdk:10.3.1"
maplibre_ktx = "org.maplibre.gl:android-sdk-ktx-v7:2.0.2"
maplibre_annotation = "org.maplibre.gl:android-plugin-annotation-v9:2.0.2"
opusencoder = "io.element.android:opusencoder:1.1.0"
kotlinpoet = "com.squareup:kotlinpoet:1.16.0"
# Analytics
-posthog = "com.posthog:posthog-android:3.1.17"
+posthog = "com.posthog:posthog-android:3.2.1"
sentry = "io.sentry:sentry-android:7.8.0"
-# Note: only 0.19.0 will compile properly
# main branch can be tested replacing the version with main-SNAPSHOT
-matrix_analytics_events = "com.github.matrix-org:matrix-analytics-events:0.15.0"
+matrix_analytics_events = "com.github.matrix-org:matrix-analytics-events:0.21.0"
# Emojibase
matrix_emojibase_bindings = "io.element.android:emojibase-bindings:1.1.3"
@@ -217,7 +221,7 @@ kapt = { id = "org.jetbrains.kotlin.kapt", version.ref = "kotlin" }
ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" }
anvil = { id = "com.squareup.anvil", version.ref = "anvil" }
detekt = "io.gitlab.arturbosch.detekt:1.23.6"
-ktlint = "org.jlleitschuh.gradle.ktlint:12.1.0"
+ktlint = "org.jlleitschuh.gradle.ktlint:12.1.1"
dependencygraph = "com.savvasdalkitsis.module-dependency-graph:0.12"
dependencycheck = "org.owasp.dependencycheck:9.1.0"
dependencyanalysis = { id = "com.autonomousapps.dependency-analysis", version.ref = "dependencyAnalysis" }
diff --git a/libraries/architecture/src/main/kotlin/io/element/android/libraries/architecture/ParentNodeExt.kt b/libraries/architecture/src/main/kotlin/io/element/android/libraries/architecture/ParentNodeExt.kt
index c03ff64f6c..be68c4671e 100644
--- a/libraries/architecture/src/main/kotlin/io/element/android/libraries/architecture/ParentNodeExt.kt
+++ b/libraries/architecture/src/main/kotlin/io/element/android/libraries/architecture/ParentNodeExt.kt
@@ -20,6 +20,7 @@ import androidx.lifecycle.lifecycleScope
import com.bumble.appyx.core.children.nodeOrNull
import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.node.ParentNode
+import kotlinx.coroutines.cancel
import kotlinx.coroutines.launch
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlin.coroutines.resume
@@ -48,3 +49,23 @@ suspend inline fun ParentNode.wai
continuation.cancel()
}
}
+
+/**
+ * Wait for a child to be attached to the parent node, only using the NavTarget.
+ */
+suspend inline fun ParentNode.waitForNavTargetAttached(crossinline predicate: (NavTarget) -> Boolean) =
+ suspendCancellableCoroutine { continuation ->
+ lifecycleScope.launch {
+ children.collect { childMap ->
+ val node = childMap.entries
+ .map { it.key.navTarget }
+ .lastOrNull(predicate)
+ if (node != null && !continuation.isCompleted) {
+ continuation.resume(Unit)
+ cancel()
+ }
+ }
+ }.invokeOnCompletion {
+ continuation.cancel()
+ }
+ }
diff --git a/libraries/core/src/main/kotlin/io/element/android/libraries/core/extensions/BasicExtensions.kt b/libraries/core/src/main/kotlin/io/element/android/libraries/core/extensions/BasicExtensions.kt
index d839be9dee..71c4da1a04 100644
--- a/libraries/core/src/main/kotlin/io/element/android/libraries/core/extensions/BasicExtensions.kt
+++ b/libraries/core/src/main/kotlin/io/element/android/libraries/core/extensions/BasicExtensions.kt
@@ -58,3 +58,15 @@ fun String.ellipsize(length: Int): String {
return "${this.take(length)}…"
}
+
+/**
+ * Replace the old prefix with the new prefix.
+ * If the string does not start with the old prefix, the string is returned as is.
+ */
+fun String.replacePrefix(oldPrefix: String, newPrefix: String): String {
+ return if (startsWith(oldPrefix)) {
+ newPrefix + substring(oldPrefix.length)
+ } else {
+ this
+ }
+}
diff --git a/libraries/deeplink/src/main/kotlin/io/element/android/libraries/deeplink/Constants.kt b/libraries/deeplink/src/main/kotlin/io/element/android/libraries/deeplink/Constants.kt
index d16d31fb82..df26ef2fa0 100644
--- a/libraries/deeplink/src/main/kotlin/io/element/android/libraries/deeplink/Constants.kt
+++ b/libraries/deeplink/src/main/kotlin/io/element/android/libraries/deeplink/Constants.kt
@@ -18,7 +18,3 @@ package io.element.android.libraries.deeplink
internal const val SCHEME = "elementx"
internal const val HOST = "open"
-
-object DeepLinkPaths {
- const val INVITE_LIST = "invites"
-}
diff --git a/libraries/deeplink/src/main/kotlin/io/element/android/libraries/deeplink/DeepLinkCreator.kt b/libraries/deeplink/src/main/kotlin/io/element/android/libraries/deeplink/DeepLinkCreator.kt
index 0cf2a7fca8..5f7dd339e4 100644
--- a/libraries/deeplink/src/main/kotlin/io/element/android/libraries/deeplink/DeepLinkCreator.kt
+++ b/libraries/deeplink/src/main/kotlin/io/element/android/libraries/deeplink/DeepLinkCreator.kt
@@ -36,13 +36,4 @@ class DeepLinkCreator @Inject constructor() {
}
}
}
-
- fun inviteList(sessionId: SessionId): String {
- return buildString {
- append("$SCHEME://$HOST/")
- append(sessionId.value)
- append("/")
- append(DeepLinkPaths.INVITE_LIST)
- }
- }
}
diff --git a/libraries/deeplink/src/main/kotlin/io/element/android/libraries/deeplink/DeeplinkData.kt b/libraries/deeplink/src/main/kotlin/io/element/android/libraries/deeplink/DeeplinkData.kt
index aa373411c7..4f12cd4e3e 100644
--- a/libraries/deeplink/src/main/kotlin/io/element/android/libraries/deeplink/DeeplinkData.kt
+++ b/libraries/deeplink/src/main/kotlin/io/element/android/libraries/deeplink/DeeplinkData.kt
@@ -29,7 +29,4 @@ sealed interface DeeplinkData {
/** The target is a room, with the given [sessionId], [roomId] and optionally a [threadId]. */
data class Room(override val sessionId: SessionId, val roomId: RoomId, val threadId: ThreadId?) : DeeplinkData
-
- /** The target is the invites list, with the given [sessionId]. */
- data class InviteList(override val sessionId: SessionId) : DeeplinkData
}
diff --git a/libraries/deeplink/src/main/kotlin/io/element/android/libraries/deeplink/DeeplinkParser.kt b/libraries/deeplink/src/main/kotlin/io/element/android/libraries/deeplink/DeeplinkParser.kt
index 7a5f9d5772..93548b8248 100644
--- a/libraries/deeplink/src/main/kotlin/io/element/android/libraries/deeplink/DeeplinkParser.kt
+++ b/libraries/deeplink/src/main/kotlin/io/element/android/libraries/deeplink/DeeplinkParser.kt
@@ -39,7 +39,6 @@ class DeeplinkParser @Inject constructor() {
return when (val screenPathComponent = pathBits.elementAtOrNull(1)) {
null -> DeeplinkData.Root(sessionId)
- DeepLinkPaths.INVITE_LIST -> DeeplinkData.InviteList(sessionId)
else -> {
val roomId = screenPathComponent.let(::RoomId)
val threadId = pathBits.elementAtOrNull(2)?.let(::ThreadId)
diff --git a/libraries/deeplink/src/test/kotlin/io/element/android/libraries/deeplink/DeepLinkCreatorTest.kt b/libraries/deeplink/src/test/kotlin/io/element/android/libraries/deeplink/DeepLinkCreatorTest.kt
index 5c43624655..1c603713ab 100644
--- a/libraries/deeplink/src/test/kotlin/io/element/android/libraries/deeplink/DeepLinkCreatorTest.kt
+++ b/libraries/deeplink/src/test/kotlin/io/element/android/libraries/deeplink/DeepLinkCreatorTest.kt
@@ -33,11 +33,4 @@ class DeepLinkCreatorTest {
assertThat(sut.room(A_SESSION_ID, A_ROOM_ID, A_THREAD_ID))
.isEqualTo("elementx://open/@alice:server.org/!aRoomId:domain/\$aThreadId")
}
-
- @Test
- fun inviteList() {
- val sut = DeepLinkCreator()
- assertThat(sut.inviteList(A_SESSION_ID))
- .isEqualTo("elementx://open/@alice:server.org/invites")
- }
}
diff --git a/libraries/deeplink/src/test/kotlin/io/element/android/libraries/deeplink/DeeplinkParserTest.kt b/libraries/deeplink/src/test/kotlin/io/element/android/libraries/deeplink/DeeplinkParserTest.kt
index 553850a4d6..b11b2f620c 100644
--- a/libraries/deeplink/src/test/kotlin/io/element/android/libraries/deeplink/DeeplinkParserTest.kt
+++ b/libraries/deeplink/src/test/kotlin/io/element/android/libraries/deeplink/DeeplinkParserTest.kt
@@ -36,8 +36,6 @@ class DeeplinkParserTest {
"elementx://open/@alice:server.org/!aRoomId:domain"
const val A_URI_WITH_ROOM_WITH_THREAD =
"elementx://open/@alice:server.org/!aRoomId:domain/\$aThreadId"
- const val A_URI_FOR_INVITE_LIST =
- "elementx://open/@alice:server.org/invites"
}
private val sut = DeeplinkParser()
@@ -50,8 +48,6 @@ class DeeplinkParserTest {
.isEqualTo(DeeplinkData.Room(A_SESSION_ID, A_ROOM_ID, null))
assertThat(sut.getFromIntent(createIntent(A_URI_WITH_ROOM_WITH_THREAD)))
.isEqualTo(DeeplinkData.Room(A_SESSION_ID, A_ROOM_ID, A_THREAD_ID))
- assertThat(sut.getFromIntent(createIntent(A_URI_FOR_INVITE_LIST)))
- .isEqualTo(DeeplinkData.InviteList(A_SESSION_ID))
}
@Test
diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/atoms/RoomPreviewDescriptionAtom.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/atoms/RoomPreviewDescriptionAtom.kt
new file mode 100644
index 0000000000..13bc48ea3a
--- /dev/null
+++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/atoms/RoomPreviewDescriptionAtom.kt
@@ -0,0 +1,37 @@
+/*
+ * Copyright (c) 2024 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.element.android.libraries.designsystem.atomic.atoms
+
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.text.style.TextOverflow
+import io.element.android.compound.theme.ElementTheme
+import io.element.android.libraries.designsystem.theme.components.Text
+
+@Composable
+fun RoomPreviewDescriptionAtom(description: String, modifier: Modifier = Modifier) {
+ Text(
+ modifier = modifier,
+ text = description,
+ style = ElementTheme.typography.fontBodySmRegular,
+ textAlign = TextAlign.Center,
+ color = ElementTheme.colors.textSecondary,
+ maxLines = 3,
+ overflow = TextOverflow.Ellipsis,
+ )
+}
diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/atoms/RoomPreviewSubtitleAtom.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/atoms/RoomPreviewSubtitleAtom.kt
new file mode 100644
index 0000000000..8915b0fff4
--- /dev/null
+++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/atoms/RoomPreviewSubtitleAtom.kt
@@ -0,0 +1,34 @@
+/*
+ * Copyright (c) 2024 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.element.android.libraries.designsystem.atomic.atoms
+
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.text.style.TextAlign
+import io.element.android.compound.theme.ElementTheme
+import io.element.android.libraries.designsystem.theme.components.Text
+
+@Composable
+fun RoomPreviewSubtitleAtom(subtitle: String, modifier: Modifier = Modifier) {
+ Text(
+ modifier = modifier,
+ text = subtitle,
+ style = ElementTheme.typography.fontBodyMdRegular,
+ textAlign = TextAlign.Center,
+ color = ElementTheme.colors.textSecondary,
+ )
+}
diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/atoms/RoomPreviewTitleAtom.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/atoms/RoomPreviewTitleAtom.kt
new file mode 100644
index 0000000000..bccadf8198
--- /dev/null
+++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/atoms/RoomPreviewTitleAtom.kt
@@ -0,0 +1,40 @@
+/*
+ * Copyright (c) 2024 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.element.android.libraries.designsystem.atomic.atoms
+
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.text.font.FontStyle
+import androidx.compose.ui.text.style.TextAlign
+import io.element.android.compound.theme.ElementTheme
+import io.element.android.libraries.designsystem.theme.components.Text
+
+@Composable
+fun RoomPreviewTitleAtom(
+ title: String,
+ modifier: Modifier = Modifier,
+ fontStyle: FontStyle? = null,
+) {
+ Text(
+ modifier = modifier,
+ text = title,
+ style = ElementTheme.typography.fontHeadingMdBold,
+ textAlign = TextAlign.Center,
+ fontStyle = fontStyle,
+ color = ElementTheme.colors.textPrimary,
+ )
+}
diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/molecules/RoomPreviewMembersCountMolecule.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/molecules/RoomPreviewMembersCountMolecule.kt
new file mode 100644
index 0000000000..f39e3b19dc
--- /dev/null
+++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/molecules/RoomPreviewMembersCountMolecule.kt
@@ -0,0 +1,72 @@
+/*
+ * Copyright (c) 2024 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.element.android.libraries.designsystem.atomic.molecules
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.unit.dp
+import io.element.android.compound.theme.ElementTheme
+import io.element.android.compound.tokens.generated.CompoundIcons
+import io.element.android.libraries.designsystem.preview.ElementPreview
+import io.element.android.libraries.designsystem.preview.PreviewsDayNight
+import io.element.android.libraries.designsystem.theme.components.Icon
+import io.element.android.libraries.designsystem.theme.components.Text
+
+@Composable
+fun RoomPreviewMembersCountMolecule(
+ memberCount: Long,
+ modifier: Modifier = Modifier,
+) {
+ Row(
+ modifier = modifier
+ .background(color = ElementTheme.colors.bgSubtleSecondary, shape = CircleShape)
+ .padding(start = 2.dp, end = 8.dp, top = 2.dp, bottom = 2.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(4.dp)
+ ) {
+ Icon(
+ imageVector = CompoundIcons.UserProfile(),
+ contentDescription = null,
+ tint = ElementTheme.colors.iconSecondary,
+ )
+ Text(
+ text = "$memberCount",
+ style = ElementTheme.typography.fontBodySmMedium,
+ color = ElementTheme.colors.textSecondary,
+ )
+ }
+}
+
+@PreviewsDayNight
+@Composable
+internal fun RoomPreviewMembersCountMoleculePreview() = ElementPreview {
+ Column(
+ modifier = Modifier.padding(8.dp),
+ verticalArrangement = Arrangement.spacedBy(8.dp),
+ ) {
+ RoomPreviewMembersCountMolecule(memberCount = 1)
+ RoomPreviewMembersCountMolecule(memberCount = 888)
+ RoomPreviewMembersCountMolecule(memberCount = 123_456)
+ }
+}
diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/organisms/RoomPreviewOrganism.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/organisms/RoomPreviewOrganism.kt
new file mode 100644
index 0000000000..ede1cff787
--- /dev/null
+++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/organisms/RoomPreviewOrganism.kt
@@ -0,0 +1,56 @@
+/*
+ * Copyright (c) 2024 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.element.android.libraries.designsystem.atomic.organisms
+
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.unit.dp
+
+@Composable
+fun RoomPreviewOrganism(
+ avatar: @Composable () -> Unit,
+ title: @Composable () -> Unit,
+ subtitle: @Composable () -> Unit,
+ modifier: Modifier = Modifier,
+ description: @Composable (() -> Unit)? = null,
+ memberCount: @Composable (() -> Unit)? = null,
+) {
+ Column(
+ modifier = modifier.fillMaxWidth(),
+ horizontalAlignment = Alignment.CenterHorizontally
+ ) {
+ avatar()
+ Spacer(modifier = Modifier.height(16.dp))
+ title()
+ Spacer(modifier = Modifier.height(8.dp))
+ subtitle()
+ Spacer(modifier = Modifier.height(8.dp))
+ if (memberCount != null) {
+ memberCount()
+ }
+ Spacer(modifier = Modifier.height(8.dp))
+ if (description != null) {
+ description()
+ }
+ Spacer(modifier = Modifier.height(24.dp))
+ }
+}
diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/pages/HeaderFooterPage.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/pages/HeaderFooterPage.kt
index 3cae67cb1b..4dd00a67d9 100644
--- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/pages/HeaderFooterPage.kt
+++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/pages/HeaderFooterPage.kt
@@ -23,9 +23,11 @@ import androidx.compose.foundation.layout.consumeWindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
+import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme
import io.element.android.libraries.designsystem.preview.ElementPreview
@@ -36,6 +38,7 @@ import io.element.android.libraries.designsystem.theme.components.Text
/**
* @param modifier Classical modifier.
* @param paddingValues padding values to apply to the content.
+ * @param containerColor color of the container. Set to [Color.Transparent] if you provide a background in the [modifier].
* @param background optional background component.
* @param topBar optional topBar.
* @param header optional header.
@@ -46,6 +49,7 @@ import io.element.android.libraries.designsystem.theme.components.Text
fun HeaderFooterPage(
modifier: Modifier = Modifier,
paddingValues: PaddingValues = PaddingValues(20.dp),
+ containerColor: Color = MaterialTheme.colorScheme.background,
background: @Composable () -> Unit = {},
topBar: @Composable () -> Unit = {},
header: @Composable () -> Unit = {},
@@ -55,6 +59,7 @@ fun HeaderFooterPage(
Scaffold(
modifier = modifier,
topBar = topBar,
+ containerColor = containerColor,
) { padding ->
Box {
background()
diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/background/LightGradientBackground.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/background/LightGradientBackground.kt
new file mode 100644
index 0000000000..793f4891f5
--- /dev/null
+++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/background/LightGradientBackground.kt
@@ -0,0 +1,63 @@
+/*
+ * Copyright (c) 2024 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.element.android.libraries.designsystem.background
+
+import androidx.compose.foundation.Canvas
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.geometry.center
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.RadialGradientShader
+import androidx.compose.ui.graphics.ShaderBrush
+import io.element.android.libraries.designsystem.preview.ElementPreview
+import io.element.android.libraries.designsystem.preview.PreviewsDayNight
+
+/**
+ * Light gradient background for Join room screens.
+ */
+@Composable
+fun LightGradientBackground(
+ modifier: Modifier = Modifier,
+ backgroundColor: Color = MaterialTheme.colorScheme.background,
+ firstColor: Color = Color(0x1E0DBD8B),
+ secondColor: Color = Color(0x001273EB),
+ ratio: Float = 642 / 775f,
+) {
+ Canvas(
+ modifier = modifier.fillMaxSize()
+ ) {
+ val biggerDimension = size.width * 1.98f
+ val gradientShaderBrush = ShaderBrush(
+ RadialGradientShader(
+ colors = listOf(firstColor, secondColor),
+ center = size.center.copy(x = size.width * ratio, y = size.height * ratio),
+ radius = biggerDimension / 2f,
+ colorStops = listOf(0f, 0.95f)
+ )
+ )
+ drawRect(backgroundColor, size = size)
+ drawRect(brush = gradientShaderBrush, size = size)
+ }
+}
+
+@PreviewsDayNight
+@Composable
+internal fun LightGradientBackgroundPreview() = ElementPreview {
+ LightGradientBackground()
+}
diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/OnboardingBackground.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/background/OnboardingBackground.kt
similarity index 95%
rename from libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/OnboardingBackground.kt
rename to libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/background/OnboardingBackground.kt
index 5dbced7417..c8c703e48c 100644
--- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/OnboardingBackground.kt
+++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/background/OnboardingBackground.kt
@@ -14,7 +14,7 @@
* limitations under the License.
*/
-package io.element.android.libraries.designsystem.components
+package io.element.android.libraries.designsystem.background
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.layout.Box
@@ -31,6 +31,7 @@ import androidx.compose.ui.graphics.LinearGradientShader
import androidx.compose.ui.graphics.ShaderBrush
import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme
+import io.element.android.libraries.designsystem.components.drawWithLayer
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/Badge.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/Badge.kt
new file mode 100644
index 0000000000..1bf06644c6
--- /dev/null
+++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/Badge.kt
@@ -0,0 +1,93 @@
+/*
+ * Copyright (c) 2024 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.element.android.libraries.designsystem.components
+
+import androidx.compose.foundation.BorderStroke
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material3.LocalContentColor
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.Shape
+import androidx.compose.ui.graphics.vector.ImageVector
+import androidx.compose.ui.unit.dp
+import io.element.android.compound.theme.ElementTheme
+import io.element.android.compound.tokens.generated.CompoundIcons
+import io.element.android.libraries.designsystem.preview.ElementPreview
+import io.element.android.libraries.designsystem.preview.PreviewsDayNight
+import io.element.android.libraries.designsystem.theme.badgePositiveBackgroundColor
+import io.element.android.libraries.designsystem.theme.badgePositiveContentColor
+import io.element.android.libraries.designsystem.theme.components.Icon
+import io.element.android.libraries.designsystem.theme.components.Surface
+import io.element.android.libraries.designsystem.theme.components.Text
+
+@Suppress("ModifierMissing")
+@Composable
+fun Badge(
+ text: String,
+ icon: ImageVector,
+ backgroundColor: Color,
+ textColor: Color,
+ iconColor: Color,
+ shape: Shape = RoundedCornerShape(50),
+ borderStroke: BorderStroke? = null,
+ tintIcon: Boolean = true,
+) {
+ Surface(
+ color = backgroundColor,
+ contentColor = textColor,
+ border = borderStroke,
+ shape = shape,
+ ) {
+ Row(
+ modifier = Modifier.padding(start = 8.dp, end = 12.dp, top = 4.5.dp, bottom = 4.5.dp),
+ horizontalArrangement = Arrangement.spacedBy(5.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Icon(
+ modifier = Modifier.size(16.dp),
+ imageVector = icon,
+ contentDescription = null,
+ tint = if (tintIcon) iconColor else LocalContentColor.current,
+ )
+ Text(
+ text = text,
+ style = ElementTheme.typography.fontBodySmRegular,
+ color = textColor,
+ )
+ }
+ }
+}
+
+@PreviewsDayNight
+@Composable
+internal fun BadgePreview() {
+ ElementPreview {
+ Badge(
+ text = "Trusted",
+ icon = CompoundIcons.Verified(),
+ backgroundColor = ElementTheme.colors.badgePositiveBackgroundColor,
+ textColor = ElementTheme.colors.badgePositiveContentColor,
+ iconColor = ElementTheme.colors.iconSuccessPrimary,
+ )
+ }
+}
diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/PageTitle.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/PageTitle.kt
index de1e25f5f3..833d822260 100644
--- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/PageTitle.kt
+++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/PageTitle.kt
@@ -108,7 +108,7 @@ fun PageTitle(
@PreviewsDayNight
@Composable
-internal fun TitleWithIconFullPreview(@PreviewParameter(BigIconStylePreviewProvider::class) style: BigIcon.Style) {
+internal fun PageTitleWithIconFullPreview(@PreviewParameter(BigIconStylePreviewProvider::class) style: BigIcon.Style) {
ElementPreview {
PageTitle(
modifier = Modifier.padding(top = 24.dp),
@@ -124,7 +124,7 @@ internal fun TitleWithIconFullPreview(@PreviewParameter(BigIconStylePreviewProvi
@PreviewsDayNight
@Composable
-internal fun TitleWithIconMinimalPreview() {
+internal fun PageTitleWithIconMinimalPreview() {
ElementPreview {
PageTitle(
modifier = Modifier.padding(top = 24.dp),
diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/async/AsyncIndicatorView.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/async/AsyncIndicatorView.kt
index 897d9ffc9a..c9223b2360 100644
--- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/async/AsyncIndicatorView.kt
+++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/async/AsyncIndicatorView.kt
@@ -75,7 +75,7 @@ internal fun AsyncIndicatorView(
@PreviewsDayNight
@Composable
-internal fun AsyncIndicatorView_Loading_Preview() {
+internal fun AsyncIndicatorLoadingPreview() {
ElementPreview {
AsyncIndicator.Loading(text = "Loading")
}
@@ -83,7 +83,7 @@ internal fun AsyncIndicatorView_Loading_Preview() {
@PreviewsDayNight
@Composable
-internal fun AsyncIndicatorView_Failed_Preview() {
+internal fun AsyncIndicatorFailurePreview() {
ElementPreview {
AsyncIndicator.Failure(text = "Failed")
}
diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/avatar/AvatarData.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/avatar/AvatarData.kt
index 810e0cae10..331c0fad4a 100644
--- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/avatar/AvatarData.kt
+++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/avatar/AvatarData.kt
@@ -26,7 +26,8 @@ data class AvatarData(
val size: AvatarSize,
) {
val initial by lazy {
- (name?.takeIf { it.isNotBlank() } ?: id)
+ // For roomIds, use "#" as initial
+ (name?.takeIf { it.isNotBlank() } ?: id.takeIf { !it.startsWith("!") } ?: "#")
.let { dn ->
var startIndex = 0
val initial = dn[startIndex]
diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/avatar/UserAvatarPreview.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/avatar/UserAvatarPreview.kt
index 0fa4ca35f8..5a71e30d80 100644
--- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/avatar/UserAvatarPreview.kt
+++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/avatar/UserAvatarPreview.kt
@@ -31,7 +31,7 @@ import io.element.android.libraries.designsystem.theme.components.Text
@PreviewsDayNight
@Composable
-internal fun UserAvatarPreview() = ElementPreview {
+internal fun UserAvatarColorsPreview() = ElementPreview {
Column(
modifier = Modifier.padding(8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/button/GradientFloatingActionButton.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/button/GradientFloatingActionButton.kt
index dcce83644c..fc37cee47a 100644
--- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/button/GradientFloatingActionButton.kt
+++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/button/GradientFloatingActionButton.kt
@@ -128,7 +128,7 @@ internal fun GradientFloatingActionButtonPreview() {
@PreviewsDayNight
@Composable
-internal fun GradientSendButtonPreview() {
+internal fun GradientFloatingActionButtonCircleShapePreview() {
ElementPreview {
Box(modifier = Modifier.padding(20.dp)) {
GradientFloatingActionButton(
diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/button/MainActionButton.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/button/MainActionButton.kt
index bc530b8fd3..a1172b47fa 100644
--- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/button/MainActionButton.kt
+++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/button/MainActionButton.kt
@@ -24,6 +24,7 @@ import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.layout.widthIn
import androidx.compose.material.ripple.rememberRipple
import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.MaterialTheme
@@ -53,12 +54,14 @@ fun MainActionButton(
val ripple = rememberRipple(bounded = false)
val interactionSource = remember { MutableInteractionSource() }
Column(
- modifier.clickable(
- enabled = enabled,
- interactionSource = interactionSource,
- onClick = onClick,
- indication = ripple
- ),
+ modifier
+ .clickable(
+ enabled = enabled,
+ interactionSource = interactionSource,
+ onClick = onClick,
+ indication = ripple
+ )
+ .widthIn(min = 76.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
val tintColor = if (enabled) LocalContentColor.current else MaterialTheme.colorScheme.secondary
diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/preferences/PreferencePage.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/preferences/PreferencePage.kt
index 1aa245c945..b02cf157d8 100644
--- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/preferences/PreferencePage.kt
+++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/preferences/PreferencePage.kt
@@ -98,7 +98,7 @@ private fun PreferenceTopAppBar(
@PreviewsDayNight
@Composable
-internal fun PreferenceViewPreview() = ElementPreview {
+internal fun PreferencePagePreview() = ElementPreview {
PreferencePage(
title = "Preference screen",
onBackPressed = {},
diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/preferences/PreferenceTextField.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/preferences/PreferenceTextField.kt
index f616c20a72..5b319e41d8 100644
--- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/preferences/PreferenceTextField.kt
+++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/preferences/PreferenceTextField.kt
@@ -98,11 +98,11 @@ private fun TextFieldDialog(
keyboardOptions: KeyboardOptions = KeyboardOptions.Default,
) {
val focusRequester = remember { FocusRequester() }
-
var textFieldContents by rememberSaveable(stateSaver = TextFieldValue.Saver) {
mutableStateOf(TextFieldValue(value.orEmpty(), selection = TextRange(value.orEmpty().length)))
}
var error by rememberSaveable { mutableStateOf(null) }
+ var canRequestFocus by rememberSaveable { mutableStateOf(false) }
val canSubmit by remember { derivedStateOf { validation(textFieldContents.text) } }
ListDialog(
title = title,
@@ -128,10 +128,11 @@ private fun TextFieldDialog(
maxLines = maxLines,
modifier = Modifier.focusRequester(focusRequester),
)
+ canRequestFocus = true
}
}
- if (autoSelectOnDisplay) {
+ if (autoSelectOnDisplay && canRequestFocus) {
LaunchedEffect(Unit) {
focusRequester.requestFocus()
}
diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/ruler/WithRulers.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/ruler/WithRulers.kt
index c24b34271f..66e1333d01 100644
--- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/ruler/WithRulers.kt
+++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/ruler/WithRulers.kt
@@ -64,7 +64,7 @@ fun WithRulers(
@PreviewsDayNight
@Composable
-internal fun WithRulerPreview() = ElementPreview {
+internal fun WithRulersPreview() = ElementPreview {
WithRulers(xRulersOffset = 20.dp, yRulersOffset = 15.dp) {
OutlinedButton(
text = "A Button with rulers on it!",
diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/ColorAliases.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/ColorAliases.kt
index 5039d10467..5fc6fd2a23 100644
--- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/ColorAliases.kt
+++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/ColorAliases.kt
@@ -149,6 +149,36 @@ val SemanticColors.bigIconDefaultBackgroundColor
val SemanticColors.bigCheckmarkBorderColor
get() = if (isLight) LightColorTokens.colorGray400 else DarkColorTokens.colorGray400
+@OptIn(CoreColorToken::class)
+val SemanticColors.highlightedMessageBackgroundColor
+ get() = if (isLight) LightColorTokens.colorGreen300 else DarkColorTokens.colorGreen300
+
+// Badge colors
+
+@OptIn(CoreColorToken::class)
+val SemanticColors.badgePositiveBackgroundColor
+ get() = if (isLight) LightColorTokens.colorAlphaGreen300 else DarkColorTokens.colorAlphaGreen300
+
+@OptIn(CoreColorToken::class)
+val SemanticColors.badgePositiveContentColor
+ get() = if (isLight) LightColorTokens.colorGreen1100 else DarkColorTokens.colorGreen1100
+
+@OptIn(CoreColorToken::class)
+val SemanticColors.badgeNeutralBackgroundColor
+ get() = if (isLight) LightColorTokens.colorAlphaGray300 else DarkColorTokens.colorAlphaGray300
+
+@OptIn(CoreColorToken::class)
+val SemanticColors.badgeNeutralContentColor
+ get() = if (isLight) LightColorTokens.colorGray1100 else DarkColorTokens.colorGray1100
+
+@OptIn(CoreColorToken::class)
+val SemanticColors.badgeNegativeBackgroundColor
+ get() = if (isLight) LightColorTokens.colorAlphaRed300 else DarkColorTokens.colorAlphaRed300
+
+@OptIn(CoreColorToken::class)
+val SemanticColors.badgeNegativeContentColor
+ get() = if (isLight) LightColorTokens.colorRed1100 else DarkColorTokens.colorRed1100
+
@PreviewsDayNight
@Composable
internal fun ColorAliasesPreview() = ElementPreview {
@@ -167,6 +197,8 @@ internal fun ColorAliasesPreview() = ElementPreview {
"temporaryColorBgSpecial" to ElementTheme.colors.temporaryColorBgSpecial,
"iconSuccessPrimaryBackground" to ElementTheme.colors.iconSuccessPrimaryBackground,
"bigIconBackgroundColor" to ElementTheme.colors.bigIconDefaultBackgroundColor,
+ "bigCheckmarkBorderColor" to ElementTheme.colors.bigCheckmarkBorderColor,
+ "highlightedMessageBackgroundColor" to ElementTheme.colors.highlightedMessageBackgroundColor,
)
)
}
diff --git a/libraries/eventformatter/impl/src/main/kotlin/io/element/android/libraries/eventformatter/impl/DefaultRoomLastMessageFormatter.kt b/libraries/eventformatter/impl/src/main/kotlin/io/element/android/libraries/eventformatter/impl/DefaultRoomLastMessageFormatter.kt
index ffaabb45ea..f541941818 100644
--- a/libraries/eventformatter/impl/src/main/kotlin/io/element/android/libraries/eventformatter/impl/DefaultRoomLastMessageFormatter.kt
+++ b/libraries/eventformatter/impl/src/main/kotlin/io/element/android/libraries/eventformatter/impl/DefaultRoomLastMessageFormatter.kt
@@ -41,7 +41,6 @@ import io.element.android.libraries.matrix.api.timeline.item.event.NoticeMessage
import io.element.android.libraries.matrix.api.timeline.item.event.OtherMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.PollContent
import io.element.android.libraries.matrix.api.timeline.item.event.ProfileChangeContent
-import io.element.android.libraries.matrix.api.timeline.item.event.ProfileTimelineDetails
import io.element.android.libraries.matrix.api.timeline.item.event.RedactedContent
import io.element.android.libraries.matrix.api.timeline.item.event.RoomMembershipContent
import io.element.android.libraries.matrix.api.timeline.item.event.StateContent
@@ -52,6 +51,7 @@ import io.element.android.libraries.matrix.api.timeline.item.event.UnableToDecry
import io.element.android.libraries.matrix.api.timeline.item.event.UnknownContent
import io.element.android.libraries.matrix.api.timeline.item.event.VideoMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.VoiceMessageType
+import io.element.android.libraries.matrix.api.timeline.item.event.getDisambiguatedDisplayName
import io.element.android.libraries.matrix.ui.messages.toPlainText
import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.services.toolbox.api.strings.StringProvider
@@ -72,15 +72,13 @@ class DefaultRoomLastMessageFormatter @Inject constructor(
override fun format(event: EventTimelineItem, isDmRoom: Boolean): CharSequence? {
val isOutgoing = event.isOwn
- // Note: we do not use disambiguated display name here, see
- // https://github.com/element-hq/element-x-ios/issues/1845#issuecomment-1888707428
- val senderDisplayName = (event.senderProfile as? ProfileTimelineDetails.Ready)?.displayName ?: event.sender.value
+ val senderDisambiguatedDisplayName = event.senderProfile.getDisambiguatedDisplayName(event.sender)
return when (val content = event.content) {
- is MessageContent -> processMessageContents(content, senderDisplayName, isDmRoom)
+ is MessageContent -> processMessageContents(content, senderDisambiguatedDisplayName, isDmRoom)
RedactedContent -> {
val message = sp.getString(CommonStrings.common_message_removed)
if (!isDmRoom) {
- prefix(message, senderDisplayName)
+ prefix(message, senderDisambiguatedDisplayName)
} else {
message
}
@@ -91,36 +89,40 @@ class DefaultRoomLastMessageFormatter @Inject constructor(
is UnableToDecryptContent -> {
val message = sp.getString(CommonStrings.common_waiting_for_decryption_key)
if (!isDmRoom) {
- prefix(message, senderDisplayName)
+ prefix(message, senderDisambiguatedDisplayName)
} else {
message
}
}
is RoomMembershipContent -> {
- roomMembershipContentFormatter.format(content, senderDisplayName, isOutgoing)
+ roomMembershipContentFormatter.format(content, senderDisambiguatedDisplayName, isOutgoing)
}
is ProfileChangeContent -> {
- profileChangeContentFormatter.format(content, event.sender, senderDisplayName, isOutgoing)
+ profileChangeContentFormatter.format(content, event.sender, senderDisambiguatedDisplayName, isOutgoing)
}
is StateContent -> {
- stateContentFormatter.format(content, senderDisplayName, isOutgoing, RenderingMode.RoomList)
+ stateContentFormatter.format(content, senderDisambiguatedDisplayName, isOutgoing, RenderingMode.RoomList)
}
is PollContent -> {
val message = sp.getString(CommonStrings.common_poll_summary, content.question)
- prefixIfNeeded(message, senderDisplayName, isDmRoom)
+ prefixIfNeeded(message, senderDisambiguatedDisplayName, isDmRoom)
}
is FailedToParseMessageLikeContent, is FailedToParseStateContent, is UnknownContent -> {
- prefixIfNeeded(sp.getString(CommonStrings.common_unsupported_event), senderDisplayName, isDmRoom)
+ prefixIfNeeded(sp.getString(CommonStrings.common_unsupported_event), senderDisambiguatedDisplayName, isDmRoom)
}
is LegacyCallInviteContent -> sp.getString(CommonStrings.common_call_invite)
}?.take(MAX_SAFE_LENGTH)
}
- private fun processMessageContents(messageContent: MessageContent, senderDisplayName: String, isDmRoom: Boolean): CharSequence? {
+ private fun processMessageContents(
+ messageContent: MessageContent,
+ senderDisambiguatedDisplayName: String,
+ isDmRoom: Boolean,
+ ): CharSequence {
val internalMessage = when (val messageType: MessageType = messageContent.type) {
// Doesn't need a prefix
is EmoteMessageType -> {
- return "* $senderDisplayName ${messageType.body}"
+ return "* $senderDisambiguatedDisplayName ${messageType.body}"
}
is TextMessageType -> {
messageType.toPlainText(permalinkParser)
@@ -153,19 +155,23 @@ class DefaultRoomLastMessageFormatter @Inject constructor(
messageType.body
}
}
- return prefixIfNeeded(internalMessage, senderDisplayName, isDmRoom)
+ return prefixIfNeeded(internalMessage, senderDisambiguatedDisplayName, isDmRoom)
}
- private fun prefixIfNeeded(message: String, senderDisplayName: String, isDmRoom: Boolean): CharSequence = if (isDmRoom) {
+ private fun prefixIfNeeded(
+ message: String,
+ senderDisambiguatedDisplayName: String,
+ isDmRoom: Boolean,
+ ): CharSequence = if (isDmRoom) {
message
} else {
- prefix(message, senderDisplayName)
+ prefix(message, senderDisambiguatedDisplayName)
}
- private fun prefix(message: String, senderDisplayName: String): AnnotatedString {
+ private fun prefix(message: String, senderDisambiguatedDisplayName: String): AnnotatedString {
return buildAnnotatedString {
withStyle(SpanStyle(fontWeight = FontWeight.Bold)) {
- append(senderDisplayName)
+ append(senderDisambiguatedDisplayName)
}
append(": ")
append(message)
diff --git a/libraries/eventformatter/impl/src/main/kotlin/io/element/android/libraries/eventformatter/impl/DefaultTimelineEventFormatter.kt b/libraries/eventformatter/impl/src/main/kotlin/io/element/android/libraries/eventformatter/impl/DefaultTimelineEventFormatter.kt
index a4f0d25af0..e77659a99e 100644
--- a/libraries/eventformatter/impl/src/main/kotlin/io/element/android/libraries/eventformatter/impl/DefaultTimelineEventFormatter.kt
+++ b/libraries/eventformatter/impl/src/main/kotlin/io/element/android/libraries/eventformatter/impl/DefaultTimelineEventFormatter.kt
@@ -49,16 +49,16 @@ class DefaultTimelineEventFormatter @Inject constructor(
) : TimelineEventFormatter {
override fun format(event: EventTimelineItem): CharSequence? {
val isOutgoing = event.isOwn
- val senderDisplayName = event.senderProfile.getDisambiguatedDisplayName(event.sender)
+ val senderDisambiguatedDisplayName = event.senderProfile.getDisambiguatedDisplayName(event.sender)
return when (val content = event.content) {
is RoomMembershipContent -> {
- roomMembershipContentFormatter.format(content, senderDisplayName, isOutgoing)
+ roomMembershipContentFormatter.format(content, senderDisambiguatedDisplayName, isOutgoing)
}
is ProfileChangeContent -> {
- profileChangeContentFormatter.format(content, event.sender, senderDisplayName, isOutgoing)
+ profileChangeContentFormatter.format(content, event.sender, senderDisambiguatedDisplayName, isOutgoing)
}
is StateContent -> {
- stateContentFormatter.format(content, senderDisplayName, isOutgoing, RenderingMode.Timeline)
+ stateContentFormatter.format(content, senderDisambiguatedDisplayName, isOutgoing, RenderingMode.Timeline)
}
is LegacyCallInviteContent -> {
sp.getString(CommonStrings.common_call_invite)
diff --git a/libraries/eventformatter/impl/src/main/kotlin/io/element/android/libraries/eventformatter/impl/ProfileChangeContentFormatter.kt b/libraries/eventformatter/impl/src/main/kotlin/io/element/android/libraries/eventformatter/impl/ProfileChangeContentFormatter.kt
index 1b27c4ec15..c5d8a5fab6 100644
--- a/libraries/eventformatter/impl/src/main/kotlin/io/element/android/libraries/eventformatter/impl/ProfileChangeContentFormatter.kt
+++ b/libraries/eventformatter/impl/src/main/kotlin/io/element/android/libraries/eventformatter/impl/ProfileChangeContentFormatter.kt
@@ -27,14 +27,19 @@ class ProfileChangeContentFormatter @Inject constructor(
fun format(
profileChangeContent: ProfileChangeContent,
senderId: UserId,
- senderDisplayName: String,
+ senderDisambiguatedDisplayName: String,
senderIsYou: Boolean,
): String? = profileChangeContent.run {
val displayNameChanged = displayName != prevDisplayName
val avatarChanged = avatarUrl != prevAvatarUrl
return when {
avatarChanged && displayNameChanged -> {
- val message = format(profileChangeContent.copy(avatarUrl = null, prevAvatarUrl = null), senderId, senderDisplayName, senderIsYou)
+ val message = format(
+ profileChangeContent = profileChangeContent.copy(avatarUrl = null, prevAvatarUrl = null),
+ senderId = senderId,
+ senderDisambiguatedDisplayName = senderDisambiguatedDisplayName,
+ senderIsYou = senderIsYou,
+ )
val avatarChangedToo = sp.getString(R.string.state_event_avatar_changed_too)
"$message\n$avatarChangedToo"
}
@@ -63,7 +68,7 @@ class ProfileChangeContentFormatter @Inject constructor(
if (senderIsYou) {
sp.getString(R.string.state_event_avatar_url_changed_by_you)
} else {
- sp.getString(R.string.state_event_avatar_url_changed, senderDisplayName)
+ sp.getString(R.string.state_event_avatar_url_changed, senderDisambiguatedDisplayName)
}
}
else -> null
diff --git a/libraries/eventformatter/impl/src/main/kotlin/io/element/android/libraries/eventformatter/impl/RoomMembershipContentFormatter.kt b/libraries/eventformatter/impl/src/main/kotlin/io/element/android/libraries/eventformatter/impl/RoomMembershipContentFormatter.kt
index 926648458f..1c58cffd43 100644
--- a/libraries/eventformatter/impl/src/main/kotlin/io/element/android/libraries/eventformatter/impl/RoomMembershipContentFormatter.kt
+++ b/libraries/eventformatter/impl/src/main/kotlin/io/element/android/libraries/eventformatter/impl/RoomMembershipContentFormatter.kt
@@ -29,7 +29,7 @@ class RoomMembershipContentFormatter @Inject constructor(
) {
fun format(
membershipContent: RoomMembershipContent,
- senderDisplayName: String,
+ senderDisambiguatedDisplayName: String,
senderIsYou: Boolean,
): CharSequence? {
val userId = membershipContent.userId
@@ -38,34 +38,34 @@ class RoomMembershipContentFormatter @Inject constructor(
MembershipChange.JOINED -> if (memberIsYou) {
sp.getString(R.string.state_event_room_join_by_you)
} else {
- sp.getString(R.string.state_event_room_join, userId.value)
+ sp.getString(R.string.state_event_room_join, senderDisambiguatedDisplayName)
}
MembershipChange.LEFT -> if (memberIsYou) {
sp.getString(R.string.state_event_room_leave_by_you)
} else {
- sp.getString(R.string.state_event_room_leave, userId.value)
+ sp.getString(R.string.state_event_room_leave, senderDisambiguatedDisplayName)
}
MembershipChange.BANNED, MembershipChange.KICKED_AND_BANNED -> if (senderIsYou) {
sp.getString(R.string.state_event_room_ban_by_you, userId.value)
} else {
- sp.getString(R.string.state_event_room_ban, senderDisplayName, userId.value)
+ sp.getString(R.string.state_event_room_ban, senderDisambiguatedDisplayName, userId.value)
}
MembershipChange.UNBANNED -> if (senderIsYou) {
sp.getString(R.string.state_event_room_unban_by_you, userId.value)
} else {
- sp.getString(R.string.state_event_room_unban, senderDisplayName, userId.value)
+ sp.getString(R.string.state_event_room_unban, senderDisambiguatedDisplayName, userId.value)
}
MembershipChange.KICKED -> if (senderIsYou) {
sp.getString(R.string.state_event_room_remove_by_you, userId.value)
} else {
- sp.getString(R.string.state_event_room_remove, senderDisplayName, userId.value)
+ sp.getString(R.string.state_event_room_remove, senderDisambiguatedDisplayName, userId.value)
}
MembershipChange.INVITED -> if (senderIsYou) {
sp.getString(R.string.state_event_room_invite_by_you, userId.value)
} else if (memberIsYou) {
- sp.getString(R.string.state_event_room_invite_you, senderDisplayName)
+ sp.getString(R.string.state_event_room_invite_you, senderDisambiguatedDisplayName)
} else {
- sp.getString(R.string.state_event_room_invite, senderDisplayName, userId.value)
+ sp.getString(R.string.state_event_room_invite, senderDisambiguatedDisplayName, userId.value)
}
MembershipChange.INVITATION_ACCEPTED -> if (memberIsYou) {
sp.getString(R.string.state_event_room_invite_accepted_by_you)
@@ -80,34 +80,34 @@ class RoomMembershipContentFormatter @Inject constructor(
MembershipChange.INVITATION_REVOKED -> if (senderIsYou) {
sp.getString(R.string.state_event_room_third_party_revoked_invite_by_you, userId.value)
} else {
- sp.getString(R.string.state_event_room_third_party_revoked_invite, senderDisplayName, userId.value)
+ sp.getString(R.string.state_event_room_third_party_revoked_invite, senderDisambiguatedDisplayName, userId.value)
}
MembershipChange.KNOCKED -> if (memberIsYou) {
sp.getString(R.string.state_event_room_knock_by_you)
} else {
- sp.getString(R.string.state_event_room_knock, userId.value)
+ sp.getString(R.string.state_event_room_knock, senderDisambiguatedDisplayName)
}
MembershipChange.KNOCK_ACCEPTED -> if (senderIsYou) {
sp.getString(R.string.state_event_room_knock_accepted_by_you, userId.value)
} else {
- sp.getString(R.string.state_event_room_knock_accepted, senderDisplayName, userId.value)
+ sp.getString(R.string.state_event_room_knock_accepted, senderDisambiguatedDisplayName, userId.value)
}
MembershipChange.KNOCK_RETRACTED -> if (memberIsYou) {
sp.getString(R.string.state_event_room_knock_retracted_by_you)
} else {
- sp.getString(R.string.state_event_room_knock_retracted, userId.value)
+ sp.getString(R.string.state_event_room_knock_retracted, senderDisambiguatedDisplayName)
}
MembershipChange.KNOCK_DENIED -> if (senderIsYou) {
sp.getString(R.string.state_event_room_knock_denied_by_you, userId.value)
} else if (memberIsYou) {
- sp.getString(R.string.state_event_room_knock_denied_you, senderDisplayName)
+ sp.getString(R.string.state_event_room_knock_denied_you, senderDisambiguatedDisplayName)
} else {
- sp.getString(R.string.state_event_room_knock_denied, senderDisplayName, userId.value)
+ sp.getString(R.string.state_event_room_knock_denied, senderDisambiguatedDisplayName, userId.value)
}
MembershipChange.NONE -> if (senderIsYou) {
sp.getString(R.string.state_event_room_none_by_you)
} else {
- sp.getString(R.string.state_event_room_none, senderDisplayName)
+ sp.getString(R.string.state_event_room_none, senderDisambiguatedDisplayName)
}
MembershipChange.ERROR -> {
Timber.v("Filtering timeline item for room membership: $membershipContent")
diff --git a/libraries/eventformatter/impl/src/main/kotlin/io/element/android/libraries/eventformatter/impl/StateContentFormatter.kt b/libraries/eventformatter/impl/src/main/kotlin/io/element/android/libraries/eventformatter/impl/StateContentFormatter.kt
index 2e86648f7b..ef15216a66 100644
--- a/libraries/eventformatter/impl/src/main/kotlin/io/element/android/libraries/eventformatter/impl/StateContentFormatter.kt
+++ b/libraries/eventformatter/impl/src/main/kotlin/io/element/android/libraries/eventformatter/impl/StateContentFormatter.kt
@@ -29,7 +29,7 @@ class StateContentFormatter @Inject constructor(
) {
fun format(
stateContent: StateContent,
- senderDisplayName: String,
+ senderDisambiguatedDisplayName: String,
senderIsYou: Boolean,
renderingMode: RenderingMode,
): CharSequence? {
@@ -39,15 +39,15 @@ class StateContentFormatter @Inject constructor(
when {
senderIsYou && hasAvatarUrl -> sp.getString(R.string.state_event_room_avatar_changed_by_you)
senderIsYou && !hasAvatarUrl -> sp.getString(R.string.state_event_room_avatar_removed_by_you)
- !senderIsYou && hasAvatarUrl -> sp.getString(R.string.state_event_room_avatar_changed, senderDisplayName)
- else -> sp.getString(R.string.state_event_room_avatar_removed, senderDisplayName)
+ !senderIsYou && hasAvatarUrl -> sp.getString(R.string.state_event_room_avatar_changed, senderDisambiguatedDisplayName)
+ else -> sp.getString(R.string.state_event_room_avatar_removed, senderDisambiguatedDisplayName)
}
}
is OtherState.RoomCreate -> {
if (senderIsYou) {
sp.getString(R.string.state_event_room_created_by_you)
} else {
- sp.getString(R.string.state_event_room_created, senderDisplayName)
+ sp.getString(R.string.state_event_room_created, senderDisambiguatedDisplayName)
}
}
is OtherState.RoomEncryption -> sp.getString(CommonStrings.common_encryption_enabled)
@@ -56,8 +56,8 @@ class StateContentFormatter @Inject constructor(
when {
senderIsYou && hasRoomName -> sp.getString(R.string.state_event_room_name_changed_by_you, content.name)
senderIsYou && !hasRoomName -> sp.getString(R.string.state_event_room_name_removed_by_you)
- !senderIsYou && hasRoomName -> sp.getString(R.string.state_event_room_name_changed, senderDisplayName, content.name)
- else -> sp.getString(R.string.state_event_room_name_removed, senderDisplayName)
+ !senderIsYou && hasRoomName -> sp.getString(R.string.state_event_room_name_changed, senderDisambiguatedDisplayName, content.name)
+ else -> sp.getString(R.string.state_event_room_name_removed, senderDisambiguatedDisplayName)
}
}
is OtherState.RoomThirdPartyInvite -> {
@@ -68,7 +68,7 @@ class StateContentFormatter @Inject constructor(
if (senderIsYou) {
sp.getString(R.string.state_event_room_third_party_invite_by_you, content.displayName)
} else {
- sp.getString(R.string.state_event_room_third_party_invite, senderDisplayName, content.displayName)
+ sp.getString(R.string.state_event_room_third_party_invite, senderDisambiguatedDisplayName, content.displayName)
}
}
is OtherState.RoomTopic -> {
@@ -76,8 +76,8 @@ class StateContentFormatter @Inject constructor(
when {
senderIsYou && hasRoomTopic -> sp.getString(R.string.state_event_room_topic_changed_by_you, content.topic)
senderIsYou && !hasRoomTopic -> sp.getString(R.string.state_event_room_topic_removed_by_you)
- !senderIsYou && hasRoomTopic -> sp.getString(R.string.state_event_room_topic_changed, senderDisplayName, content.topic)
- else -> sp.getString(R.string.state_event_room_topic_removed, senderDisplayName)
+ !senderIsYou && hasRoomTopic -> sp.getString(R.string.state_event_room_topic_changed, senderDisambiguatedDisplayName, content.topic)
+ else -> sp.getString(R.string.state_event_room_topic_removed, senderDisambiguatedDisplayName)
}
}
is OtherState.Custom -> when (renderingMode) {
diff --git a/libraries/eventformatter/impl/src/main/res/values-be/translations.xml b/libraries/eventformatter/impl/src/main/res/values-be/translations.xml
index 00e6e1665e..dcc942e4fc 100644
--- a/libraries/eventformatter/impl/src/main/res/values-be/translations.xml
+++ b/libraries/eventformatter/impl/src/main/res/values-be/translations.xml
@@ -5,12 +5,12 @@