diff --git a/.github/renovate.json5 b/.github/renovate.json5 index 85377d905c..cdf47a6b1a 100644 --- a/.github/renovate.json5 +++ b/.github/renovate.json5 @@ -13,8 +13,8 @@ "hostRules": [ { "matchHost": "https://repo1.maven.org/maven2/", - "concurrentRequestLimit": 10, - "maxRequestsPerSecond": 10, + "concurrentRequestLimit": 5, + "maxRequestsPerSecond": 5, "timeout": 10000, } ], diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 2bc1f10978..aa9be929ea 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -51,7 +51,7 @@ jobs: ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.ref }} persist-credentials: false - name: Use JDK 21 - uses: actions/setup-java@dd06d9cba3e5552c54d9f8ea23572deb30010f7c # v6.0.0 + uses: actions/setup-java@de7274f081f381c8f8158605e0321c36c376e2e6 # v6.0.1 with: distribution: 'temurin' # See 'Supported distributions' for available options java-version: '21' diff --git a/.github/workflows/build_enterprise.yml b/.github/workflows/build_enterprise.yml index 00d5bb7fb5..b1d7204168 100644 --- a/.github/workflows/build_enterprise.yml +++ b/.github/workflows/build_enterprise.yml @@ -56,7 +56,7 @@ jobs: - name: Clone submodules run: git submodule update --init --recursive - name: Use JDK 21 - uses: actions/setup-java@dd06d9cba3e5552c54d9f8ea23572deb30010f7c # v6.0.0 + uses: actions/setup-java@de7274f081f381c8f8158605e0321c36c376e2e6 # v6.0.1 with: distribution: 'temurin' # See 'Supported distributions' for available options java-version: '21' diff --git a/.github/workflows/generate_github_pages.yml b/.github/workflows/generate_github_pages.yml index 3c9b6f9dd8..763936e3d6 100644 --- a/.github/workflows/generate_github_pages.yml +++ b/.github/workflows/generate_github_pages.yml @@ -19,7 +19,7 @@ jobs: - name: ⏬ Checkout with LFS uses: nschloe/action-cached-lfs-checkout@385a8ecc719e50b8c71af6ab01a624b486b7c3bc # v1.2.5 - name: Use JDK 21 - uses: actions/setup-java@dd06d9cba3e5552c54d9f8ea23572deb30010f7c # v6.0.0 + uses: actions/setup-java@de7274f081f381c8f8158605e0321c36c376e2e6 # v6.0.1 with: distribution: 'temurin' # See 'Supported distributions' for available options java-version: '21' diff --git a/.github/workflows/gradle-wrapper-update.yml b/.github/workflows/gradle-wrapper-update.yml index a122135f37..777e9f1173 100644 --- a/.github/workflows/gradle-wrapper-update.yml +++ b/.github/workflows/gradle-wrapper-update.yml @@ -16,7 +16,7 @@ jobs: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - - uses: actions/setup-java@dd06d9cba3e5552c54d9f8ea23572deb30010f7c # v6.0.0 + - uses: actions/setup-java@de7274f081f381c8f8158605e0321c36c376e2e6 # v6.0.1 name: Use JDK 21 with: distribution: 'temurin' # See 'Supported distributions' for available options diff --git a/.github/workflows/maestro-local.yml b/.github/workflows/maestro-local.yml index c06dc55fa0..1195c7b53d 100644 --- a/.github/workflows/maestro-local.yml +++ b/.github/workflows/maestro-local.yml @@ -47,7 +47,7 @@ jobs: # https://github.com/actions/checkout/issues/881 ref: ${{ github.ref }} persist-credentials: false - - uses: actions/setup-java@dd06d9cba3e5552c54d9f8ea23572deb30010f7c # v6.0.0 + - uses: actions/setup-java@de7274f081f381c8f8158605e0321c36c376e2e6 # v6.0.1 name: Use JDK 21 with: distribution: 'temurin' # See 'Supported distributions' for available options diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index 250f736f65..25e7d174e0 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -36,7 +36,7 @@ jobs: with: persist-credentials: false - name: Use JDK 21 - uses: actions/setup-java@dd06d9cba3e5552c54d9f8ea23572deb30010f7c # v6.0.0 + uses: actions/setup-java@de7274f081f381c8f8158605e0321c36c376e2e6 # v6.0.1 with: distribution: 'temurin' # See 'Supported distributions' for available options java-version: '21' diff --git a/.github/workflows/nightlyReports.yml b/.github/workflows/nightlyReports.yml index c4e70e84ec..fe38c0138f 100644 --- a/.github/workflows/nightlyReports.yml +++ b/.github/workflows/nightlyReports.yml @@ -37,7 +37,7 @@ jobs: uses: nschloe/action-cached-lfs-checkout@385a8ecc719e50b8c71af6ab01a624b486b7c3bc # v1.2.5 - name: Use JDK 21 - uses: actions/setup-java@dd06d9cba3e5552c54d9f8ea23572deb30010f7c # v6.0.0 + uses: actions/setup-java@de7274f081f381c8f8158605e0321c36c376e2e6 # v6.0.1 with: distribution: 'temurin' # See 'Supported distributions' for available options java-version: '21' @@ -80,7 +80,7 @@ jobs: with: persist-credentials: false - name: Use JDK 21 - uses: actions/setup-java@dd06d9cba3e5552c54d9f8ea23572deb30010f7c # v6.0.0 + uses: actions/setup-java@de7274f081f381c8f8158605e0321c36c376e2e6 # v6.0.1 with: distribution: 'temurin' # See 'Supported distributions' for available options java-version: '21' diff --git a/.github/workflows/quality.yml b/.github/workflows/quality.yml index 5f0c2b1b3a..1f6289a8ea 100644 --- a/.github/workflows/quality.yml +++ b/.github/workflows/quality.yml @@ -69,7 +69,7 @@ jobs: with: persist-credentials: false - name: Use JDK 21 - uses: actions/setup-java@dd06d9cba3e5552c54d9f8ea23572deb30010f7c # v6.0.0 + uses: actions/setup-java@de7274f081f381c8f8158605e0321c36c376e2e6 # v6.0.1 with: distribution: 'temurin' # See 'Supported distributions' for available options java-version: '21' @@ -108,7 +108,7 @@ jobs: if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'element-hq/element-x-android' }} run: git submodule update --init --recursive - name: Use JDK 21 - uses: actions/setup-java@dd06d9cba3e5552c54d9f8ea23572deb30010f7c # v6.0.0 + uses: actions/setup-java@de7274f081f381c8f8158605e0321c36c376e2e6 # v6.0.1 with: distribution: 'temurin' # See 'Supported distributions' for available options java-version: '21' @@ -149,7 +149,7 @@ jobs: if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'element-hq/element-x-android' }} run: git submodule update --init --recursive - name: Use JDK 21 - uses: actions/setup-java@dd06d9cba3e5552c54d9f8ea23572deb30010f7c # v6.0.0 + uses: actions/setup-java@de7274f081f381c8f8158605e0321c36c376e2e6 # v6.0.1 with: distribution: 'temurin' # See 'Supported distributions' for available options java-version: '21' @@ -183,7 +183,7 @@ jobs: if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'element-hq/element-x-android' }} run: git submodule update --init --recursive - name: Use JDK 21 - uses: actions/setup-java@dd06d9cba3e5552c54d9f8ea23572deb30010f7c # v6.0.0 + uses: actions/setup-java@de7274f081f381c8f8158605e0321c36c376e2e6 # v6.0.1 with: distribution: 'temurin' # See 'Supported distributions' for available options java-version: '21' @@ -228,7 +228,7 @@ jobs: if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'element-hq/element-x-android' }} run: git submodule update --init --recursive - name: Use JDK 21 - uses: actions/setup-java@dd06d9cba3e5552c54d9f8ea23572deb30010f7c # v6.0.0 + uses: actions/setup-java@de7274f081f381c8f8158605e0321c36c376e2e6 # v6.0.1 with: distribution: 'temurin' # See 'Supported distributions' for available options java-version: '21' @@ -269,7 +269,7 @@ jobs: if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'element-hq/element-x-android' }} run: git submodule update --init --recursive - name: Use JDK 21 - uses: actions/setup-java@dd06d9cba3e5552c54d9f8ea23572deb30010f7c # v6.0.0 + uses: actions/setup-java@de7274f081f381c8f8158605e0321c36c376e2e6 # v6.0.1 with: distribution: 'temurin' # See 'Supported distributions' for available options java-version: '21' diff --git a/.github/workflows/recordScreenshots.yml b/.github/workflows/recordScreenshots.yml index b445301596..efac08a99f 100644 --- a/.github/workflows/recordScreenshots.yml +++ b/.github/workflows/recordScreenshots.yml @@ -54,7 +54,7 @@ jobs: with: persist-credentials: false - name: ☕️ Use JDK 21 - uses: actions/setup-java@dd06d9cba3e5552c54d9f8ea23572deb30010f7c # v6.0.0 + uses: actions/setup-java@de7274f081f381c8f8158605e0321c36c376e2e6 # v6.0.1 with: distribution: 'temurin' # See 'Supported distributions' for available options java-version: '21' diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f761e00438..8f264aa6de 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -38,7 +38,7 @@ jobs: with: persist-credentials: false - name: Use JDK 21 - uses: actions/setup-java@dd06d9cba3e5552c54d9f8ea23572deb30010f7c # v6.0.0 + uses: actions/setup-java@de7274f081f381c8f8158605e0321c36c376e2e6 # v6.0.1 with: distribution: 'temurin' # See 'Supported distributions' for available options java-version: '21' @@ -88,7 +88,7 @@ jobs: with: persist-credentials: false - name: Use JDK 21 - uses: actions/setup-java@dd06d9cba3e5552c54d9f8ea23572deb30010f7c # v6.0.0 + uses: actions/setup-java@de7274f081f381c8f8158605e0321c36c376e2e6 # v6.0.1 with: distribution: 'temurin' # See 'Supported distributions' for available options java-version: '21' diff --git a/.github/workflows/scripts/recordScreenshots.sh b/.github/workflows/scripts/recordScreenshots.sh index d04ee46f72..d4122c22bf 100755 --- a/.github/workflows/scripts/recordScreenshots.sh +++ b/.github/workflows/scripts/recordScreenshots.sh @@ -57,12 +57,6 @@ echo "Deleting previous screenshots" echo "Record screenshots" ./gradlew recordPaparazziDebug --stacktrace $GRADLE_ARGS -echo "Deleting previous screenshots" -./gradlew removeOldScreenshots --stacktrace --warn $GRADLE_ARGS - -echo "Record screenshots (Compound)" -./gradlew :libraries:compound:recordRoborazziDebug --stacktrace -PpreDexEnable=false --max-workers 4 --warn $GRADLE_ARGS - echo "Committing changes" git config http.sslVerify false diff --git a/.github/workflows/sonar.yml b/.github/workflows/sonar.yml index 93a5e371b6..4febf272a9 100644 --- a/.github/workflows/sonar.yml +++ b/.github/workflows/sonar.yml @@ -45,7 +45,7 @@ jobs: ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.ref }} persist-credentials: false - name: Use JDK 21 - uses: actions/setup-java@dd06d9cba3e5552c54d9f8ea23572deb30010f7c # v6.0.0 + uses: actions/setup-java@de7274f081f381c8f8158605e0321c36c376e2e6 # v6.0.1 with: distribution: 'temurin' # See 'Supported distributions' for available options java-version: '21' diff --git a/.github/workflows/sync-localazy.yml b/.github/workflows/sync-localazy.yml index 7445bcc45b..9065e6d4e9 100644 --- a/.github/workflows/sync-localazy.yml +++ b/.github/workflows/sync-localazy.yml @@ -20,7 +20,7 @@ jobs: with: persist-credentials: false - name: Use JDK 21 - uses: actions/setup-java@dd06d9cba3e5552c54d9f8ea23572deb30010f7c # v6.0.0 + uses: actions/setup-java@de7274f081f381c8f8158605e0321c36c376e2e6 # v6.0.1 with: distribution: 'temurin' # See 'Supported distributions' for available options java-version: '21' diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 4bb11596fa..fe09a37ae2 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -63,7 +63,7 @@ jobs: if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'element-hq/element-x-android' }} run: git submodule update --init --recursive - name: ☕️ Use JDK 21 - uses: actions/setup-java@dd06d9cba3e5552c54d9f8ea23572deb30010f7c # v6.0.0 + uses: actions/setup-java@de7274f081f381c8f8158605e0321c36c376e2e6 # v6.0.1 with: distribution: 'temurin' # See 'Supported distributions' for available options java-version: '21' @@ -76,10 +76,7 @@ jobs: run: ./gradlew :app:testGplayDebugUnitTest testDebugUnitTest -x :tests:konsist:testDebugUnitTest $CI_GRADLE_ARG_PROPERTIES - name: ⚙️ Run screenshot tests - run: ./gradlew :tests:uitests:verifyPaparazziDebug $CI_GRADLE_ARG_PROPERTIES - - - name: ⚙️ Run Compound screenshot tests - run: ./gradlew :libraries:compound:verifyRoborazziDebug $CI_GRADLE_ARG_PROPERTIES + run: ./gradlew verifyPaparazziDebug $CI_GRADLE_ARG_PROPERTIES - name: ⚙️ Generate kover coverage report run: ./gradlew :koverXmlReportMerged :koverHtmlReportMerged :koverVerifyAll -x testDebugUnitTest $CI_GRADLE_ARG_PROPERTIES @@ -103,7 +100,6 @@ jobs: name: tests-and-screenshot-tests-results path: | **/build/paparazzi/failures/ - **/build/roborazzi/failures/ **/build/reports/tests/*UnitTest/ - name: 🚫 Modify summary on error @@ -117,7 +113,7 @@ jobs: # https://github.com/codecov/codecov-action - name: ☂️ Upload coverage reports to codecov - uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0 + uses: codecov/codecov-action@303a32d7a59b442fa8d48b6a1cc6825c09c847a5 # v7.1.1 with: fail_ci_if_error: true token: ${{ secrets.CODECOV_TOKEN }} diff --git a/.maestro/tests/account/login.yaml b/.maestro/tests/account/login.yaml index 81922c7f38..db105dc31d 100644 --- a/.maestro/tests/account/login.yaml +++ b/.maestro/tests/account/login.yaml @@ -7,6 +7,13 @@ appId: ${MAESTRO_APP_ID} - tapOn: id: "login-continue" ## MAS page +# Click on "Use another account" if MAS is already logged in: +- runFlow: + when: + visible: "Use another account" + commands: + - tapOn: + text: "Use another account" - retry: maxRetries: 3 commands: diff --git a/.maestro/tests/account/logout.yaml b/.maestro/tests/account/logout.yaml index a276182825..83f834da04 100644 --- a/.maestro/tests/account/logout.yaml +++ b/.maestro/tests/account/logout.yaml @@ -2,6 +2,9 @@ appId: ${MAESTRO_APP_ID} --- - tapOn: id: "home_screen-settings" +- tapOn: + id: "settings-user_profile" +- assertVisible: "Your account" - tapOn: "Remove this device" - takeScreenshot: build/maestro/900-SignOutScreen - back diff --git a/.maestro/tests/roomList/createAndDeleteRoom.yaml b/.maestro/tests/roomList/createAndDeleteRoom.yaml index 6b4d745fae..bee23613b9 100644 --- a/.maestro/tests/roomList/createAndDeleteRoom.yaml +++ b/.maestro/tests/roomList/createAndDeleteRoom.yaml @@ -8,6 +8,12 @@ appId: ${MAESTRO_APP_ID} - tapOn: "Add description…" - inputText: "aRoomTopic" - tapOn: "Create" + +# It can take ages to create room on matrix.org +- extendedWaitUntil: + timeout: 30000 + visible: "Invite people" + - takeScreenshot: build/maestro/320-createAndDeleteRoom - tapOn: "Search for someone" - inputText: ${MAESTRO_INVITEE1_MXID} diff --git a/.maestro/tests/roomList/roomList.yaml b/.maestro/tests/roomList/roomList.yaml index 704d983b7d..bb3d426675 100644 --- a/.maestro/tests/roomList/roomList.yaml +++ b/.maestro/tests/roomList/roomList.yaml @@ -1,7 +1,7 @@ appId: ${MAESTRO_APP_ID} --- - runFlow: cleanUpRoomList.yaml -- runFlow: searchRoomList.yaml +#- runFlow: searchRoomList.yaml - takeScreenshot: build/maestro/300-RoomList - runFlow: timeline/timeline.yaml - runFlow: roomContextMenu.yaml diff --git a/.maestro/tests/roomList/timeline/messages/location.yaml b/.maestro/tests/roomList/timeline/messages/location.yaml index 863a699fd9..7dda9c8a02 100644 --- a/.maestro/tests/roomList/timeline/messages/location.yaml +++ b/.maestro/tests/roomList/timeline/messages/location.yaml @@ -3,5 +3,5 @@ appId: ${MAESTRO_APP_ID} - takeScreenshot: build/maestro/520-Timeline - tapOn: "Add attachment" - tapOn: "Share location" -- tapOn: "Share selected location" +- tapOn: "Share my location" - takeScreenshot: build/maestro/521-Timeline diff --git a/.maestro/tests/settings/settings.yaml b/.maestro/tests/settings/settings.yaml index 15181a458b..e016bd468c 100644 --- a/.maestro/tests/settings/settings.yaml +++ b/.maestro/tests/settings/settings.yaml @@ -4,9 +4,12 @@ appId: ${MAESTRO_APP_ID} id: "home_screen-settings" - assertVisible: "Settings" - takeScreenshot: build/maestro/600-Settings + +## Account settings - tapOn: - text: "Analytics" -- assertVisible: "Share analytics data" + id: "settings-user_profile" +- assertVisible: "Edit profile" +- takeScreenshot: build/maestro/610-AccountEditProfile - back - tapOn: @@ -14,16 +17,27 @@ appId: ${MAESTRO_APP_ID} - assertVisible: "Enable notifications on this device" - back +- tapOn: + text: "Encryption" +- assertVisible: "Key storage" +- back + +- tapOn: + text: "Moderation & safety" +- assertVisible: "Your appearance" +- back + - tapOn: text: "Report a problem" - assertVisible: "Report a problem" - back +## App settings section +- assertVisible: "Appearance" + - tapOn: - text: "About" -- assertVisible: "Copyright" -- assertVisible: "Acceptable use policy" -- assertVisible: "Privacy policy" + text: "Media upload quality" +- assertVisible: "Media upload quality" - back - tapOn: @@ -32,14 +46,53 @@ appId: ${MAESTRO_APP_ID} - hideKeyboard - back +- scrollUntilVisible: + direction: DOWN + element: + text: "Location sharing" - tapOn: - text: "Advanced settings" -- assertVisible: "View source" + text: "Location sharing" +- assertVisible: "Live location updates" - back +## General section +- scrollUntilVisible: + direction: DOWN + element: + text: "Analytics" +- tapOn: + text: "Analytics" +- assertVisible: "Share analytics data" +- back + +- scrollUntilVisible: + direction: DOWN + element: + text: "Labs" +- tapOn: + text: "Labs" +- assertVisible: "Feeling experimental?" +- back + +- scrollUntilVisible: + direction: DOWN + element: + text: "About" +- tapOn: + text: "About" +- assertVisible: "Copyright" +- assertVisible: "Acceptable use policy" +- assertVisible: "Privacy policy" +- back + +- scrollUntilVisible: + direction: DOWN + element: + text: "Developer options" - tapOn: text: "Developer options" - assertVisible: "Feature flags" +- assertVisible: "View source" - back - back diff --git a/CHANGES.md b/CHANGES.md index e0833dbe36..cbf9bc0652 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,3 +1,66 @@ +Changes in Element X v26.09.3 +============================= + + + +## What's Changed +### ✨ Features +* Integrate the new timeline renderer behind a feature flag by @jmartinesp in https://github.com/element-hq/element-x-android/pull/7722 +* Add headers (`h1-h6`) tag support for the new timeline renderer by @jmartinesp in https://github.com/element-hq/element-x-android/pull/7754 +### 🙌 Improvements +* Add smooth animation to voice messages play progress by @bxdxnn in https://github.com/element-hq/element-x-android/pull/7698 +* Add linkification to the new timeline renderer by @jmartinesp in https://github.com/element-hq/element-x-android/pull/7731 +### 🐛 Bugfixes +* Make global search use a `Joined` filter for room results by @jmartinesp in https://github.com/element-hq/element-x-android/pull/7695 +* Fix flaky test in `TimelinePresenterTest` by @jmartinesp in https://github.com/element-hq/element-x-android/pull/7702 +* Close the threaded timelines created to create a poll, share a location or send media by @bmarty in https://github.com/element-hq/element-x-android/pull/7705 +* Start sync manually after completing the auth flow by @jmartinesp in https://github.com/element-hq/element-x-android/pull/7734 +* Fix forwarding an event not working in threads by @jmartinesp in https://github.com/element-hq/element-x-android/pull/7743 +* Fix blank screen at launch when the homeserver expires the sliding sync session by @bmarty in https://github.com/element-hq/element-x-android/pull/7692 +* Fix unread notification count in room list by @jmartinesp in https://github.com/element-hq/element-x-android/pull/7749 +### 🗣 Translations +* Sync Strings by @ElementBot in https://github.com/element-hq/element-x-android/pull/7699 +* Sync Strings by @ElementBot in https://github.com/element-hq/element-x-android/pull/7736 +### 🧱 Build +* Reduce parallel JVMs spawned for tests by @jmartinesp in https://github.com/element-hq/element-x-android/pull/7701 +* Replace git flow with regular git commands in the release script V2 by @bmarty in https://github.com/element-hq/element-x-android/pull/7707 +* Remove Roborazzi, use Paparazzi for Compound by @jmartinesp in https://github.com/element-hq/element-x-android/pull/7735 +* Fix regex for invalid placeholders by @jmartinesp in https://github.com/element-hq/element-x-android/pull/7738 +* Add a script to install and launch an APK given a PR URL by @jmartinesp in https://github.com/element-hq/element-x-android/pull/7752 +### 🚧 In development 🚧 +* New timeline event renderer: add new modules by @jmartinesp in https://github.com/element-hq/element-x-android/pull/7721 +* Add recent search results to global search by @jmartinesp in https://github.com/element-hq/element-x-android/pull/7742 +### Dependency upgrades +* Update dependency io.sentry:sentry-android to v8.55.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7685 +* Update coil to v3.6.2 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7693 +* Update kotlinpoet to v2.4.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7709 +* Update plugin sonarqube to v7.5.0.8588 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7671 +* Update roborazzi to v1.74.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7712 +* Update dependency io.element.android:element-call-embedded to v0.26.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7715 +* Use patched Konsist version (forked, 0.18.0) by @jmartinesp in https://github.com/element-hq/element-x-android/pull/7719 +* Update dependency net.zetetic:sqlcipher-android to v4.19.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7716 +* Update metro to v1.4.3 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7711 +* Update actions/setup-java action to v6.0.1 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7728 +* Update dependency androidx.compose:compose-bom to v2026.09.00 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7730 +* Update dependency androidx.sqlite:sqlite-ktx to v2.7.1 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7729 +* Update dependencyAnalysis to v3.19.2 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7755 +* Update media3 to v1.11.1 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7756 +* Upgrade SDK bindings to `v26.09.26` by @jmartinesp in https://github.com/element-hq/element-x-android/pull/7780 +### Others +* Let the user give a reason when removing a message by @hayaksi1 in https://github.com/element-hq/element-x-android/pull/7538 +* Iterate on client creation by @bmarty in https://github.com/element-hq/element-x-android/pull/7591 +* Iteration on Acount provider by @bmarty in https://github.com/element-hq/element-x-android/pull/7703 +* Do not report detekt LargeClass issues on test classes by @bmarty in https://github.com/element-hq/element-x-android/pull/7713 +* Let the user give an optional reason when removing a message by @bmarty in https://github.com/element-hq/element-x-android/pull/7679 +* Make NotificationResultProcessor.emit wait until the batch is processed by @Johennes in https://github.com/element-hq/element-x-android/pull/7640 +* Settings iteration: move user status and update multi-account section by @bmarty in https://github.com/element-hq/element-x-android/pull/7741 +* Update remove message confirmation dialog. by @bmarty in https://github.com/element-hq/element-x-android/pull/7757 +* Add a script to end all the sessions of a Matrix account using MAS by @bmarty in https://github.com/element-hq/element-x-android/pull/7762 +* Settings iteration: split account settings and application settings. by @bmarty in https://github.com/element-hq/element-x-android/pull/7750 + + +**Full Changelog**: https://github.com/element-hq/element-x-android/compare/v26.09.2...v26.09.3 + Changes in Element X v26.09.2 ============================= diff --git a/app/build.gradle.kts b/app/build.gradle.kts index a297ea7702..64bc93cb6f 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -21,6 +21,7 @@ import extension.allFeaturesImpl import extension.allLibrariesImpl import extension.allServicesImpl import extension.buildConfigFieldStr +import extension.environmentOrGradleValue import extension.locales import extension.setupDependencyInjection import extension.testCommonDependencies @@ -88,13 +89,14 @@ android { storePassword = "android" } register("nightly") { - keyAlias = System.getenv("ELEMENT_ANDROID_NIGHTLY_KEYID") - ?: project.property("signing.element.nightly.keyId") as? String? - keyPassword = System.getenv("ELEMENT_ANDROID_NIGHTLY_KEYPASSWORD") - ?: project.property("signing.element.nightly.keyPassword") as? String? - storeFile = file("./signature/nightly.keystore") - storePassword = System.getenv("ELEMENT_ANDROID_NIGHTLY_STOREPASSWORD") - ?: project.property("signing.element.nightly.storePassword") as? String? + keyAlias = environmentOrGradleValue("ELEMENT_ANDROID_NIGHTLY_KEYID", "signing.element.nightly.keyId") + keyPassword = environmentOrGradleValue("ELEMENT_ANDROID_NIGHTLY_KEYPASSWORD", "signing.element.nightly.keyPassword") + storeFile = file( + environmentOrGradleValue("ELEMENT_ANDROID_NIGHTLY_STOREFILE", "signing.element.nightly.storeFile") + ?.ifBlank { null } + ?: "./signature/nightly.keystore" + ) + storePassword = environmentOrGradleValue("ELEMENT_ANDROID_NIGHTLY_STOREPASSWORD", "signing.element.nightly.storePassword") } } 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 dab9b7828c..a0bcf3b489 100644 --- a/appnav/src/main/kotlin/io/element/android/appnav/LoggedInFlowNode.kt +++ b/appnav/src/main/kotlin/io/element/android/appnav/LoggedInFlowNode.kt @@ -96,6 +96,7 @@ import io.element.android.libraries.matrix.api.verification.SessionVerificationS import io.element.android.libraries.matrix.api.verification.VerificationRequest import io.element.android.libraries.preferences.api.store.AppPreferencesStore import io.element.android.libraries.push.api.notifications.conversations.NotificationConversationService +import io.element.android.libraries.ui.common.nodes.EmptyState import io.element.android.libraries.ui.common.nodes.emptyNode import io.element.android.services.analytics.api.AnalyticsLongRunningTransaction import io.element.android.services.analytics.api.AnalyticsService @@ -113,6 +114,7 @@ import java.time.Duration import java.time.Instant import java.util.Optional import java.util.UUID +import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.minutes import kotlin.time.Duration.Companion.seconds import kotlin.time.toKotlinDuration @@ -324,7 +326,9 @@ class LoggedInFlowNode( override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node { return when (navTarget) { - NavTarget.Placeholder -> emptyNode(buildContext) + // Rendered while the FtueState is Unknown. Render a loading state rather than nothing at all, + // since there is no guarantee that this state will be left. + NavTarget.Placeholder -> emptyNode(buildContext, state = EmptyState(delayBeforeShowingContent = 500.milliseconds)) NavTarget.LoggedInPermanent -> { val callback = object : LoggedInNode.Callback { override fun navigateToNotificationTroubleshoot() { diff --git a/appnav/src/main/kotlin/io/element/android/appnav/session/SyncOrchestrator.kt b/appnav/src/main/kotlin/io/element/android/appnav/session/SyncOrchestrator.kt index 3a72c10ab7..9863f75410 100644 --- a/appnav/src/main/kotlin/io/element/android/appnav/session/SyncOrchestrator.kt +++ b/appnav/src/main/kotlin/io/element/android/appnav/session/SyncOrchestrator.kt @@ -28,10 +28,14 @@ import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onCompletion +import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch import timber.log.Timber import java.util.concurrent.atomic.AtomicBoolean +import java.util.concurrent.atomic.AtomicInteger +import kotlin.time.Duration import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.seconds @@ -60,6 +64,12 @@ class SyncOrchestrator( private val started = AtomicBoolean(false) + /** + * Number of consecutive attempts at restarting a sync service which stopped on its own. + * Reset once the sync service has been running for [RESTART_BACKOFF_RESET_DELAY]. + */ + private val restartAttempt = AtomicInteger(0) + /** * Starting observing the app state and network state to start/stop the sync service. * @@ -91,6 +101,17 @@ class SyncOrchestrator( @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) internal fun observeStates() = coroutineScope.launch { Timber.tag(tag).d("start observing the app and network state") + // Reset the restart backoff once the sync service has been steadily running for a while. Note that + // SyncService.startSync() moves the state to Running right away, so resetting on any Running value + // would defeat the backoff when the sync service dies again immediately after each restart. + syncService.syncState + .debounce(RESTART_BACKOFF_RESET_DELAY) + .onEach { syncState -> + if (syncState == SyncState.Running) { + restartAttempt.set(0) + } + } + .launchIn(this) val isAppActiveFlows = listOf( appForegroundStateService.isInForeground, appForegroundStateService.isInCall, @@ -117,14 +138,26 @@ class SyncOrchestrator( SyncStateAction.StopSync } else if (syncState == SyncState.Idle && isAppActive && isNetworkAvailable) { SyncStateAction.StartSync + } else if (syncState == SyncState.Error && isAppActive && isNetworkAvailable) { + // The sync service has given up, for instance because the homeserver has expired our sliding sync + // session and answered 400 M_UNKNOWN_POS. Nothing else will ever restart it, so do it from here, + // else the application is stuck until its data is cleared. + SyncStateAction.RestartSync } else { SyncStateAction.NoOp } } .distinctUntilChanged() .debounce { action -> - // Don't stop the sync immediately, wait a bit to avoid starting/stopping the sync too often - if (action == SyncStateAction.StopSync) 3.seconds else 0.seconds + when (action) { + // Don't stop the sync immediately, wait a bit to avoid starting/stopping the sync too often + SyncStateAction.StopSync -> 3.seconds + // Back off before restarting a dead sync service, to avoid hammering a failing homeserver. + // A state change in the meantime cancels the pending restart. + SyncStateAction.RestartSync -> restartDelay(restartAttempt.get()) + SyncStateAction.StartSync, + SyncStateAction.NoOp -> 0.seconds + } } .onCompletion { Timber.tag(tag).d("has been stopped") @@ -134,6 +167,11 @@ class SyncOrchestrator( SyncStateAction.StartSync -> { syncService.startSync() } + SyncStateAction.RestartSync -> { + val attempt = restartAttempt.incrementAndGet() + Timber.tag(tag).w("sync service is dead, restarting it (attempt $attempt)") + syncService.startSync() + } SyncStateAction.StopSync -> { syncService.stopSync() } @@ -143,8 +181,35 @@ class SyncOrchestrator( } } +@VisibleForTesting +internal val FIRST_RESTART_DELAY = 1.seconds + +/** + * Exponential backoff, from [FIRST_RESTART_DELAY], doubling on every attempt, up to [FIRST_RESTART_DELAY * 32]. + */ +@VisibleForTesting +internal val RESTART_BACKOFF_DELAYS = listOf( + FIRST_RESTART_DELAY, + FIRST_RESTART_DELAY * 2, + FIRST_RESTART_DELAY * 4, + FIRST_RESTART_DELAY * 8, + FIRST_RESTART_DELAY * 16, + FIRST_RESTART_DELAY * 32, +) + +/** + * Duration during which the sync service has to stay running before the restart backoff is reset. + */ +@VisibleForTesting +internal val RESTART_BACKOFF_RESET_DELAY = 30.seconds + +private fun restartDelay(attempt: Int): Duration { + return RESTART_BACKOFF_DELAYS[attempt.coerceIn(0, RESTART_BACKOFF_DELAYS.lastIndex)] +} + private enum class SyncStateAction { StartSync, + RestartSync, StopSync, NoOp, } diff --git a/appnav/src/main/res/values-cs/translations.xml b/appnav/src/main/res/values-cs/translations.xml index 3652bda56f..7b4a58d394 100644 --- a/appnav/src/main/res/values-cs/translations.xml +++ b/appnav/src/main/res/values-cs/translations.xml @@ -2,5 +2,5 @@ "Odhlásit se a upgradovat" "%1$s již nepodporuje starý protokol. Odhlaste se a znovu přihlaste, abyste mohli pokračovat v používání aplikace." - "Váš domovský server již nepodporuje starý protokol. Chcete-li pokračovat v používání aplikace, odhlaste se a znovu se přihlaste." + "Váš poskytovatel účtu již nepodporuje starý protokol. Chcete-li aplikaci nadále používat, odhlaste se a znovu se přihlaste." diff --git a/appnav/src/main/res/values-fi/translations.xml b/appnav/src/main/res/values-fi/translations.xml index 14605ea6e1..7b0f1f56ab 100644 --- a/appnav/src/main/res/values-fi/translations.xml +++ b/appnav/src/main/res/values-fi/translations.xml @@ -2,5 +2,5 @@ "Kirjaudu Ulos & Päivitä" "%1$s ei enää tue vanhaa protokollaa. Kirjaudu ulos ja takaisin sisään jatkaaksesi sovelluksen käyttöä." - "Kotipalvelimesi ei enää tue vanhaa protokollaa. Kirjaudu ulos ja takaisin sisään jatkaaksesi sovelluksen käyttöä." + "Palveluntarjoajasi ei enää tue vanhaa protokollaa. Kirjaudu ulos ja takaisin sisään jatkaaksesi sovelluksen käyttöä." diff --git a/appnav/src/main/res/values-ja/translations.xml b/appnav/src/main/res/values-ja/translations.xml index 3405cc1d68..45acb54410 100644 --- a/appnav/src/main/res/values-ja/translations.xml +++ b/appnav/src/main/res/values-ja/translations.xml @@ -2,5 +2,5 @@ "ログアウトしてアップグレード" "%1$s は古いプロトコルに非対応になりました。アプリを引き続き使用するには、ログアウトしてから再度ログインしてください。" - "使用しているホームサーバーは古いプロトコルに非対応になりました。アプリケーションを引き続き使用するには、ログアウトしてから再度ログインしてください。" + "使用しているアカウント提供元は古いプロトコルに非対応になりました。アプリケーションを引き続き使用するには、ログアウトしてから再度ログインしてください。" diff --git a/appnav/src/main/res/values-zh-rTW/translations.xml b/appnav/src/main/res/values-zh-rTW/translations.xml index ea69a33df8..067de9b1c1 100644 --- a/appnav/src/main/res/values-zh-rTW/translations.xml +++ b/appnav/src/main/res/values-zh-rTW/translations.xml @@ -2,5 +2,5 @@ "登出並升級" "%1$s 不再支援舊版通訊協定。請登出並重新登入以繼續使用應用程式。" - "您的家伺服器不再支援舊協定。請登出並重新登入以繼續使用應用程式。" + "您的帳號服務提供者已不再支援舊版通訊協定。請先登出,然後重新登入,以繼續使用此應用程式。" diff --git a/appnav/src/test/kotlin/io/element/android/appnav/SyncOrchestratorTest.kt b/appnav/src/test/kotlin/io/element/android/appnav/SyncOrchestratorTest.kt index 32d0ec8f56..af53c68b3d 100644 --- a/appnav/src/test/kotlin/io/element/android/appnav/SyncOrchestratorTest.kt +++ b/appnav/src/test/kotlin/io/element/android/appnav/SyncOrchestratorTest.kt @@ -8,6 +8,9 @@ package io.element.android.appnav +import io.element.android.appnav.session.FIRST_RESTART_DELAY +import io.element.android.appnav.session.RESTART_BACKOFF_DELAYS +import io.element.android.appnav.session.RESTART_BACKOFF_RESET_DELAY import io.element.android.appnav.session.SyncOrchestrator import io.element.android.features.networkmonitor.api.NetworkStatus import io.element.android.features.networkmonitor.test.FakeNetworkMonitor @@ -414,6 +417,131 @@ class SyncOrchestratorTest { startSyncRecorder.assertions().isNeverCalled() } + @Test + fun `when the sync service is in error, it will be restarted after a delay`() = runTest { + val startSyncRecorder = lambdaRecorder> { Result.success(Unit) } + val syncService = FakeSyncService(initialSyncState = SyncState.Error).apply { + startSyncLambda = startSyncRecorder + } + val syncOrchestrator = createSyncOrchestrator( + matrixClient = FakeMatrixClient(syncService = syncService), + networkMonitor = FakeNetworkMonitor(initialStatus = NetworkStatus.Connected), + appForegroundStateService = FakeAppForegroundStateService(initialForegroundValue = true), + ) + + // We start observing + syncOrchestrator.observeStates() + + // The restart does not happen right away, we back off first + advanceTimeBy(FIRST_RESTART_DELAY / 2) + startSyncRecorder.assertions().isNeverCalled() + + // But it does happen once the backoff has elapsed + advanceTimeBy(FIRST_RESTART_DELAY) + startSyncRecorder.assertions().isCalledOnce() + } + + @Test + fun `when the sync service keeps dying, the delay between the restarts increases`() = runTest { + val startSyncRecorder = lambdaRecorder> { Result.success(Unit) } + val syncService = FakeSyncService(initialSyncState = SyncState.Error).apply { + startSyncLambda = startSyncRecorder + } + val syncOrchestrator = createSyncOrchestrator( + matrixClient = FakeMatrixClient(syncService = syncService), + networkMonitor = FakeNetworkMonitor(initialStatus = NetworkStatus.Connected), + appForegroundStateService = FakeAppForegroundStateService(initialForegroundValue = true), + ) + + // We start observing + syncOrchestrator.observeStates() + + advanceTimeBy(FIRST_RESTART_DELAY * 2) + startSyncRecorder.assertions().isCalledOnce() + + // The sync service starts, but dies again straight away + syncService.emitSyncState(SyncState.Running) + advanceTimeBy(200.milliseconds) + syncService.emitSyncState(SyncState.Error) + + // The second restart is not attempted after the first delay anymore + advanceTimeBy(FIRST_RESTART_DELAY + 200.milliseconds) + startSyncRecorder.assertions().isCalledOnce() + + // But after twice that delay + advanceTimeBy(FIRST_RESTART_DELAY) + startSyncRecorder.assertions().isCalledExactly(2) + } + + @Test + fun `when the sync service recovers, the delay between the restarts is reset`() = runTest { + val startSyncRecorder = lambdaRecorder> { Result.success(Unit) } + val syncService = FakeSyncService(initialSyncState = SyncState.Error).apply { + startSyncLambda = startSyncRecorder + } + val syncOrchestrator = createSyncOrchestrator( + matrixClient = FakeMatrixClient(syncService = syncService), + networkMonitor = FakeNetworkMonitor(initialStatus = NetworkStatus.Connected), + appForegroundStateService = FakeAppForegroundStateService(initialForegroundValue = true), + ) + + // We start observing + syncOrchestrator.observeStates() + + advanceTimeBy(FIRST_RESTART_DELAY * 2) + startSyncRecorder.assertions().isCalledOnce() + + // The sync service is now running, and keeps running for a while + syncService.emitSyncState(SyncState.Running) + advanceTimeBy(RESTART_BACKOFF_RESET_DELAY + 1.seconds) + startSyncRecorder.assertions().isCalledOnce() + + // It dies again, and is restarted after the first delay, not a longer one + syncService.emitSyncState(SyncState.Error) + advanceTimeBy(FIRST_RESTART_DELAY / 2) + startSyncRecorder.assertions().isCalledOnce() + advanceTimeBy(FIRST_RESTART_DELAY) + startSyncRecorder.assertions().isCalledExactly(2) + } + + @Test + fun `when the sync service is in error but the app is in background, it is not restarted`() = runTest { + val startSyncRecorder = lambdaRecorder> { Result.success(Unit) } + val syncService = FakeSyncService(initialSyncState = SyncState.Error).apply { + startSyncLambda = startSyncRecorder + } + val syncOrchestrator = createSyncOrchestrator( + matrixClient = FakeMatrixClient(syncService = syncService), + networkMonitor = FakeNetworkMonitor(initialStatus = NetworkStatus.Connected), + appForegroundStateService = FakeAppForegroundStateService(initialForegroundValue = false), + ) + + // We start observing + syncOrchestrator.observeStates() + + advanceTimeBy(RESTART_BACKOFF_DELAYS.last() * 2) + startSyncRecorder.assertions().isNeverCalled() + } + + @Test + fun `when the sync service is in error but there is no network, it is not restarted`() = runTest { + val startSyncRecorder = lambdaRecorder> { Result.success(Unit) } + val syncService = FakeSyncService(initialSyncState = SyncState.Error).apply { + startSyncLambda = startSyncRecorder + } + val syncOrchestrator = createSyncOrchestrator( + matrixClient = FakeMatrixClient(syncService = syncService), + networkMonitor = FakeNetworkMonitor(initialStatus = NetworkStatus.Disconnected), + appForegroundStateService = FakeAppForegroundStateService(initialForegroundValue = true), + ) + + // We start observing + syncOrchestrator.observeStates() + + advanceTimeBy(RESTART_BACKOFF_DELAYS.last() * 2) + startSyncRecorder.assertions().isNeverCalled() + } + private fun TestScope.createSyncOrchestrator( matrixClient: FakeMatrixClient = FakeMatrixClient(), networkMonitor: FakeNetworkMonitor = FakeNetworkMonitor(), diff --git a/build.gradle.kts b/build.gradle.kts index 725033a6ea..e7a03b403d 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -18,7 +18,6 @@ plugins { alias(libs.plugins.compose.compiler) apply false alias(libs.plugins.ksp) apply false alias(libs.plugins.dependencycheck) apply false - alias(libs.plugins.roborazzi) apply false alias(libs.plugins.dependencyanalysis) alias(libs.plugins.detekt) alias(libs.plugins.ktlint) @@ -153,7 +152,6 @@ allprojects { maxParallelForks = (Runtime.getRuntime().availableProcessors() / 2).coerceAtLeast(1) val isScreenshotTest = project.gradle.startParameter.taskNames.any { it.contains("paparazzi", ignoreCase = true) } - val isRoborazziTest = project.gradle.startParameter.taskNames.any { it.contains("roborazzi", ignoreCase = true) } if (isScreenshotTest) { // Paparazzi tests benefit from parallelisation, so we can use half the available cores to run them in parallel. maxParallelForks = (Runtime.getRuntime().availableProcessors() / 2).coerceAtLeast(1) @@ -178,12 +176,10 @@ allprojects { // Disable screenshot tests by default exclude("ui/*.class") exclude("translations/*.class") - if (isRoborazziTest.not()) { - // Roborazzi screenshot tests (:libraries:compound) live in a `screenshot` package, which - // the two patterns above do not match, so they used to run on every plain unit test run. - // They are verified by the dedicated `verifyRoborazziDebug` task instead. - exclude("**/screenshot/**") - } + // The Compound screenshot tests (:libraries:compound) live in a `screenshot` package, which + // the two patterns above do not match, so they would otherwise run on every plain unit test run. + // They are verified by the dedicated `verifyPaparazziDebug` task instead. + exclude("**/screenshot/**") } } } @@ -230,21 +226,6 @@ subprojects { tasks.findByName("recordPaparazziRelease")?.dependsOn(removeOldScreenshotsTask) } -// Make sure to delete old snapshot before recording new ones -subprojects { - val screenshotsDir = File("${project.projectDir}/screenshots") - val removeOldScreenshotsTask = tasks.register("removeOldScreenshots") { - onlyIf { screenshotsDir.exists() } - doFirst { - println("Delete previous screenshots located at $screenshotsDir\n") - screenshotsDir.deleteRecursively() - } - } - tasks.findByName("recordRoborazzi")?.dependsOn(removeOldScreenshotsTask) - tasks.findByName("recordRoborazziDebug")?.dependsOn(removeOldScreenshotsTask) - tasks.findByName("recordRoborazziRelease")?.dependsOn(removeOldScreenshotsTask) -} - subprojects { tasks.withType().configureEach { compilerOptions { diff --git a/docs/nightly_build.md b/docs/nightly_build.md index 3a3345d662..76fdfeb0ef 100644 --- a/docs/nightly_build.md +++ b/docs/nightly_build.md @@ -27,6 +27,7 @@ Click on this link and follow the instruction: [https://appdistribution.firebase Nightly build can be built manually from your computer. You will need to retrieved some secrets from Passbolt and add them to your file `~/.gradle/gradle.properties`: ``` +signing.element.nightly.storeFile=LOCATION_OF_THE_KEYSTORE_FILE signing.element.nightly.storePassword=VALUE_FROM_PASSBOLT signing.element.nightly.keyId=VALUE_FROM_PASSBOLT signing.element.nightly.keyPassword=VALUE_FROM_PASSBOLT diff --git a/fastlane/metadata/android/en-US/changelogs/202609030.txt b/fastlane/metadata/android/en-US/changelogs/202609030.txt new file mode 100644 index 0000000000..a4b397f1bb --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/202609030.txt @@ -0,0 +1,2 @@ +Main changes in this version: bug fixes and improvements. +Full changelog: https://github.com/element-hq/element-x-android/releases \ No newline at end of file diff --git a/features/createroom/impl/src/main/res/values-cs/translations.xml b/features/createroom/impl/src/main/res/values-cs/translations.xml index 10b29b7b17..bfa84cb116 100644 --- a/features/createroom/impl/src/main/res/values-cs/translations.xml +++ b/features/createroom/impl/src/main/res/values-cs/translations.xml @@ -11,7 +11,7 @@ "Soukromé" "Tuto místnost může najít kdokoli. To můžete kdykoli změnit v nastavení místnosti." - "Vstoupit může kdokoli." + "Připojit se může kdokoli, včetně osob mimo vaši organizaci." "Veřejné" "Kdokoli může požádat o vstup do místnosti, ale správce nebo moderátor bude muset žádost přijmout." "Povolit žádost o vstup" @@ -19,7 +19,7 @@ To můžete kdykoli změnit v nastavení místnosti." "Požádat o vstup" "Vstoupit mohou pouze pozvaní lidé." "Soukromé" - "Vstoupit může kdokoli." + "Připojit se může kdokoli, včetně osob mimo vaši organizaci." "Veřejné" "Kdokoli může vstoupit do %1$s." "Standard" 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 982f32e2a4..03626a3da0 100644 --- a/features/createroom/impl/src/main/res/values-de/translations.xml +++ b/features/createroom/impl/src/main/res/values-de/translations.xml @@ -11,7 +11,7 @@ "Privat" "Jeder kann diesen Chat finden. Du kannst dies jederzeit in den Einstellungen des Chats ändern." - "Alle können beitreten." + "Alle können beitreten, auch Personen außerhalb deiner Organisation." "Öffentlich" "Jeder kann den Beitritt zum Chat erbitten, aber ein Admin oder Moderator muss die Anfrage akzeptieren." "Anfrage zum Beitritt zulassen" @@ -19,7 +19,7 @@ Du kannst dies jederzeit in den Einstellungen des Chats ändern." "Beitritt anfragen" "Nur eingeladene Personen können beitreten." "Privat" - "Alle können beitreten." + "Alle können beitreten, auch Personen außerhalb deiner Organisation." "Öffentlich" "Jeder in %1$s kann beitreten." "Standard" diff --git a/features/createroom/impl/src/main/res/values-el/translations.xml b/features/createroom/impl/src/main/res/values-el/translations.xml index 1e6d7841ec..8e27520ccc 100644 --- a/features/createroom/impl/src/main/res/values-el/translations.xml +++ b/features/createroom/impl/src/main/res/values-el/translations.xml @@ -11,7 +11,7 @@ "Ιδιωτικό" "Ο καθένας μπορεί να βρει αυτή την αίθουσα. Αυτό μπορείτε να το αλλάξετε ανά πάσα στιγμή στις ρυθμίσεις της αίθουσας." - "Οποιοσδήποτε μπορεί να συμμετάσχει." + "Οποιοσδήποτε μπορεί να συμμετάσχει, συμπεριλαμβανομένων ατόμων εκτός του οργανισμού σας." "Δημόσιο" "Οποιοσδήποτε μπορεί να ζητήσει να συμμετάσχει στην αίθουσα, αλλά ένας διαχειριστής ή ένας συντονιστής θα πρέπει να αποδεχτεί το αίτημα." "Επιτρέψτε την αίτηση συμμετοχής" @@ -19,7 +19,7 @@ "Αίτημα συμμετοχής" "Μόνο άτομα που έχουν προσκληθεί μπορούν να συμμετάσχουν." "Ιδιωτικό" - "Οποιοσδήποτε μπορεί να συμμετάσχει." + "Οποιοσδήποτε μπορεί να συμμετάσχει, συμπεριλαμβανομένων και ατόμων εκτός του οργανισμού σας." "Δημόσιο" "Οποιοσδήποτε στο %1$s μπορεί να συμμετάσχει." "Πρότυπο" diff --git a/features/createroom/impl/src/main/res/values-fi/translations.xml b/features/createroom/impl/src/main/res/values-fi/translations.xml index c912a7ffe9..f71017594f 100644 --- a/features/createroom/impl/src/main/res/values-fi/translations.xml +++ b/features/createroom/impl/src/main/res/values-fi/translations.xml @@ -11,7 +11,7 @@ "Yksityinen" "Kuka tahansa voi löytää tämän huoneen. Voit muuttaa tämän milloin tahansa huoneen asetuksista." - "Kuka tahansa voi liittyä." + "Kuka tahansa voi liittyä, myös organisaatiosi ulkopuoliset henkilöt." "Julkinen" "Kuka tahansa voi pyytää saada liittyä huoneeseen, mutta ylläpitäjän tai valvojan on hyväksyttävä pyyntö." "Salli liittymispyynnöt" @@ -19,7 +19,7 @@ Voit muuttaa tämän milloin tahansa huoneen asetuksista." "Pyydä liittymistä" "Vain kutsutut henkilöt voivat liittyä." "Yksityinen" - "Kuka tahansa voi liittyä." + "Kuka tahansa voi liittyä, myös organisaatiosi ulkopuoliset henkilöt." "Julkinen" "Kuka tahansa tilassa %1$s voi liittyä." "Vakio" diff --git a/features/createroom/impl/src/main/res/values-hr/translations.xml b/features/createroom/impl/src/main/res/values-hr/translations.xml index 17336bebf2..11b83aa4db 100644 --- a/features/createroom/impl/src/main/res/values-hr/translations.xml +++ b/features/createroom/impl/src/main/res/values-hr/translations.xml @@ -11,7 +11,7 @@ "Privatno" "Svatko može pronaći ovu sobu. To možete u svakom trenutku promijeniti u postavkama sobe." - "Svatko se može pridružiti." + "Svatko se može pridružiti, uključujući i osobe izvan vaše organizacije." "Javno" "Svatko može zatražiti pridruživanje sobi, ali administrator ili moderator morat će prihvatiti zahtjev." "Dopusti traženje pridruživanja" @@ -19,7 +19,7 @@ To možete u svakom trenutku promijeniti u postavkama sobe." "Zatraži pridruživanje" "Samo pozvane osobe mogu pristupiti ovoj sobi. Sve su poruke sveobuhvatno šifrirane." "Privatno" - "Svatko se može pridružiti." + "Svatko se može pridružiti, uključujući i osobe izvan vaše organizacije." "Javno" "Svatko u %1$s može se pridružiti." "Standard" diff --git a/features/createroom/impl/src/main/res/values-hu/translations.xml b/features/createroom/impl/src/main/res/values-hu/translations.xml index c6d654c8de..64d6ce1638 100644 --- a/features/createroom/impl/src/main/res/values-hu/translations.xml +++ b/features/createroom/impl/src/main/res/values-hu/translations.xml @@ -11,7 +11,7 @@ "Privát" "Bárki megtalálhatja ezt a szobát. Ezt bármikor módosíthatja a szobabeállításokban." - "Bárki csatlakozhat." + "Bárki csatlakozhat, beleértve a szervezeten kívülieket is." "Nyilvános" "Bárki kérheti, hogy csatlakozhasson a szobához, de egy adminisztrátornak vagy moderátornak el kell fogadnia a kérést." "Csatlakozás kérése" @@ -19,7 +19,7 @@ Ezt bármikor módosíthatja a szobabeállításokban." "Csatlakozás kérése" "Csak a meghívottak léphetnek be." "Privát" - "Bárki csatlakozhat." + "Bárki csatlakozhat, beleértve a szervezeten kívülieket is." "Nyilvános" "Bárki csatlakozhat innen: %1$s." "Szokásos" diff --git a/features/createroom/impl/src/main/res/values-ja/translations.xml b/features/createroom/impl/src/main/res/values-ja/translations.xml index 3a51c63d61..f225faac87 100644 --- a/features/createroom/impl/src/main/res/values-ja/translations.xml +++ b/features/createroom/impl/src/main/res/values-ja/translations.xml @@ -11,7 +11,7 @@ "非公開" "ルームは全世界に公開されます。 ルーム設定でいつでも変更できます。" - "誰でも参加できます。" + "あなたの組織外のユーザーを含め誰でも参加できます。" "公開" "誰でも参加できますが、管理者またはモデレーターの承認が必要です。" "参加の要求を許可" @@ -19,7 +19,7 @@ "参加を要求" "招待されたユーザーのみが参加できます。" "非公開" - "誰でも参加できます。" + "あなたの組織外のユーザーを含め誰でも参加できます。" "公開" "%1$s にいる全員が参加することができます。" "スタンダード" diff --git a/features/createroom/impl/src/main/res/values-uk/translations.xml b/features/createroom/impl/src/main/res/values-uk/translations.xml index 6d43472217..96b3ada5a4 100644 --- a/features/createroom/impl/src/main/res/values-uk/translations.xml +++ b/features/createroom/impl/src/main/res/values-uk/translations.xml @@ -11,7 +11,7 @@ "Приватний" "Будь-хто може знайти цю кімнату. Ви можете змінити це в будь-який час у налаштуваннях кімнати." - "Приєднатися може будь-хто." + "Приєднатися може будь-хто, у тому числі й особи, які не належать до вашої організації." "Публічний" "Будь-хто може подати запит на приєднання, але адміністратор або модератор повинен схвалити запит." "Дозволити запит на приєднання" @@ -19,7 +19,7 @@ "Запит на приєднання" "Приєднатися можуть лише запрошені особи." "Приватний" - "Приєднатися може будь-хто." + "Приєднатися може будь-хто, у тому числі й особи, які не належать до вашої організації." "Публічний" "Приєднатися може будь-хто з %1$s." "Стандартний" diff --git a/features/createroom/impl/src/main/res/values-zh-rTW/translations.xml b/features/createroom/impl/src/main/res/values-zh-rTW/translations.xml index 0899f065d7..54301a4d44 100644 --- a/features/createroom/impl/src/main/res/values-zh-rTW/translations.xml +++ b/features/createroom/impl/src/main/res/values-zh-rTW/translations.xml @@ -11,7 +11,7 @@ "私人" "任何人都可以找到此聊天室。 您隨時都可以在聊天室設定中變更此設定。" - "任何人都可以加入。" + "任何人都可以加入,包括貴組織以外的人士。" "公開" "任何人都可以要求加入,但管理員或版主必須接受該請求" "允許要求加入" @@ -19,7 +19,7 @@ "要求加入" "僅被邀請的人才可以加入。" "私人" - "任何人都可以加入" + "任何人都可以加入,包括貴組織以外的人士。" "公開" "在 %1$s 中的任何人都可以加入。" "標準" 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 8d66258e12..461fc4ea80 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,11 +32,13 @@ 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.di.SessionScope +import io.element.android.libraries.ui.common.nodes.EmptyState import io.element.android.libraries.ui.common.nodes.emptyNode import kotlinx.coroutines.flow.filterIsInstance import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach import kotlinx.parcelize.Parcelize +import kotlin.time.Duration.Companion.milliseconds @ContributesNode(SessionScope::class) @AssistedInject @@ -84,7 +86,10 @@ class FtueFlowNode( override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node { return when (navTarget) { NavTarget.Placeholder -> { - emptyNode(buildContext) + // Rendered while waiting for the initial state (see FtueStep.WaitingForInitialState). + // This can last for a while, or forever if the sync service never manages to run, so + // render a loading state rather than nothing at all. + emptyNode(buildContext, EmptyState(delayBeforeShowingContent = 500.milliseconds)) } is NavTarget.SessionVerification -> { val callback = object : FtueSessionVerificationFlowNode.Callback { diff --git a/features/home/impl/build.gradle.kts b/features/home/impl/build.gradle.kts index efdc545e61..c66a4fd0e6 100644 --- a/features/home/impl/build.gradle.kts +++ b/features/home/impl/build.gradle.kts @@ -12,6 +12,7 @@ import extension.testCommonDependencies plugins { id("io.element.android-compose-library") id("kotlin-parcelize") + alias(libs.plugins.kotlin.serialization) } android { @@ -31,6 +32,8 @@ dependencies { implementation(projects.libraries.core) implementation(projects.libraries.androidutils) implementation(projects.libraries.architecture) + implementation(projects.libraries.encryptedDb) + implementation(libs.serialization.json) implementation(projects.libraries.featureflag.api) implementation(projects.libraries.matrix.api) implementation(projects.libraries.matrixui) @@ -55,8 +58,6 @@ dependencies { implementation(projects.features.rageshake.api) implementation(projects.services.analytics.api) implementation(libs.androidx.datastore.preferences) - implementation(libs.haze) - implementation(libs.haze.materials) implementation(projects.features.preferences.impl) implementation(projects.features.reportroom.api) implementation(projects.features.rolesandpermissions.api) diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/CurrentUserWithNeighborsBuilder.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/CurrentUserWithNeighborsBuilder.kt index b29dc788a7..f9c5fc3bc0 100644 --- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/CurrentUserWithNeighborsBuilder.kt +++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/CurrentUserWithNeighborsBuilder.kt @@ -36,6 +36,7 @@ class CurrentUserWithNeighborsBuilder { userId = UserId(it.userId), displayName = it.userDisplayName, avatarUrl = it.userAvatarUrl, + avatarThumbnail = it.userAvatarData, ) } } diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeView.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeView.kt index 024d013871..1df5496ef5 100644 --- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeView.kt +++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeView.kt @@ -6,8 +6,6 @@ * Please see LICENSE files in the repository root for full details. */ -@file:OptIn(ExperimentalHazeMaterialsApi::class) - package io.element.android.features.home.impl import androidx.activity.compose.BackHandler @@ -40,11 +38,6 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.unit.dp import androidx.compose.ui.zIndex -import dev.chrisbanes.haze.hazeEffect -import dev.chrisbanes.haze.hazeSource -import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi -import dev.chrisbanes.haze.materials.HazeMaterials -import dev.chrisbanes.haze.rememberHazeState import io.element.android.compound.theme.ElementTheme import io.element.android.compound.tokens.generated.CompoundIcons import io.element.android.features.home.impl.components.HomeTopBar @@ -190,7 +183,6 @@ private fun HomeScaffold( } } - val hazeState = rememberHazeState() val roomsLazyListState = rememberLazyListState() val spacesLazyListState = rememberLazyListState() @@ -223,10 +215,6 @@ private fun HomeScaffold( filtersState = roomListState.filtersState, spaceFiltersState = roomListState.spaceFiltersState, canReportBug = state.canReportBug, - modifier = Modifier.hazeEffect( - state = hazeState, - style = HazeMaterials.thick(), - ) ) }, floatingActionButton = { @@ -297,7 +285,6 @@ private fun HomeScaffold( modifier = Modifier .padding(outerPadding) .consumeWindowInsets(outerPadding) - .hazeSource(state = hazeState) ) SpaceFiltersView(roomListState.spaceFiltersState) } @@ -306,8 +293,7 @@ private fun HomeScaffold( modifier = Modifier .fillMaxSize() .padding(outerPadding) - .consumeWindowInsets(outerPadding) - .hazeSource(state = hazeState), + .consumeWindowInsets(outerPadding), contentPadding = lazyColumnContentPadding + contentPadding, state = state.homeSpacesState, lazyListState = spacesLazyListState, diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/components/RoomSummaryRow.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/components/RoomSummaryRow.kt index 883d8aff76..4b60e9e684 100644 --- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/components/RoomSummaryRow.kt +++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/components/RoomSummaryRow.kt @@ -345,7 +345,7 @@ private fun MessagePreviewAndIndicatorRow( // Call and unread Row( modifier = Modifier - .height(16.dp) + .heightIn(min = 16.dp) // Used to force this line to be read aloud earlier than the latest event when using Talkback .zIndex(-1f), horizontalArrangement = Arrangement.spacedBy(8.dp), @@ -358,21 +358,18 @@ private fun MessagePreviewAndIndicatorRow( isAudio = room.activeCallIntent == CallIntent.AUDIO ) } - if (room.userDefinedNotificationMode == RoomNotificationMode.MUTE) { + val isMuted = room.userDefinedNotificationMode == RoomNotificationMode.MUTE + if (isMuted) { NotificationOffIndicatorAtom() } else if (room.numberOfUnreadMentions > 0) { MentionIndicatorAtom() } if (room.hasNewContent) { val contentDescription = stringResource(CommonStrings.a11y_notifications_new_messages) - val count = if (showUnreadCount) { - if (room.userDefinedNotificationMode == RoomNotificationMode.MUTE) { - room.numberOfUnreadMessages - } else { - room.numberOfUnreadNotifications - } - } else { - null + val count = when { + showUnreadCount && !isMuted && room.numberOfUnreadNotifications > 0 -> room.numberOfUnreadNotifications + !showUnreadCount -> 0 + else -> null } UnreadIndicatorAtom( color = tint, @@ -457,5 +454,6 @@ internal fun RoomSummaryRowPreview(@PreviewParameter(RoomListRoomSummaryPreviewP isInviteSeen = data.name == "Bob", onClick = {}, eventSink = {}, + showUnreadCount = data.numberOfUnreadNotifications > 0, ) } diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/model/RoomListRoomSummaryPreviewParam.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/model/RoomListRoomSummaryPreviewParam.kt index c04c3e446b..17b35c3b42 100644 --- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/model/RoomListRoomSummaryPreviewParam.kt +++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/model/RoomListRoomSummaryPreviewParam.kt @@ -82,6 +82,24 @@ open class RoomListRoomSummaryPreviewParam : PreviewParameterProvider { @Composable override fun present(): GlobalSearchState { @@ -85,6 +87,21 @@ class GlobalSearchPresenter( var currentTarget: GlobalSearchTarget by remember { mutableStateOf(GlobalSearchTarget.ROOMS) } val currentMessageSearch: MessageSearch = remember { messageSearchService.createMessageSearch(scope = coroutineScope) } + val searchHistory by produceState>>(initialValue = AsyncData.Uninitialized) { + searchHistoryStore.history.collectLatest { history -> + val mappedHistoryItems = history.mapNotNull { result -> + when (result) { + is SearchHistoryResult.Query -> SearchHistoryResultItem.Query(term = result.term) + is SearchHistoryResult.Room -> { + val roomInfo = matrixClient.getRoomInfo(result.roomId).getOrNull() ?: return@mapNotNull null + SearchHistoryResultItem.Room(roomId = result.roomId, roomInfo = roomInfo) + } + } + } + value = AsyncData.Success(mappedHistoryItems.toImmutableList()) + } + } + LaunchedEffect(queryState.text) { // Add a delay to avoid performing too many searches in a short period of time, which can be expensive delay(200.milliseconds) @@ -141,7 +158,7 @@ class GlobalSearchPresenter( // result, which can be slow if we have a lot of results. The original order is kept by using `awaitAll()`. val mappedResults = results.map { result -> async(coroutineDispatchers.computation) { - val roomInfo = matrixClient.getRoomInfoFlow(result.roomId).first().getOrElse { return@async null } + val roomInfo = matrixClient.getRoomInfo(result.roomId).getOrNull() ?: return@async null val formattedTimestamp = dateFormatter.format( timestamp = result.timestamp, mode = DateFormatterMode.TimeOrDate, @@ -180,7 +197,8 @@ class GlobalSearchPresenter( fun handleEvent(event: GlobalSearchEvent) { when (event) { - GlobalSearchEvent.ClearQuery -> { + GlobalSearchEvent.ClearQuery -> coroutineScope.launch { + searchResults = AsyncData.Uninitialized queryState.clearText() } GlobalSearchEvent.ToggleSearchVisibility -> { @@ -205,6 +223,30 @@ class GlobalSearchPresenter( } } is GlobalSearchEvent.UpdateTarget -> currentTarget = event.target + GlobalSearchEvent.SaveQueryToHistory -> { + val term = queryState.text.toString() + if (term.isNotBlank()) { + coroutineScope.launch { + searchHistoryStore.add(SearchHistoryResult.Query(term)) + } + } + } + is GlobalSearchEvent.SaveRoomToHistory -> coroutineScope.launch { + searchHistoryStore.add(SearchHistoryResult.Room(event.roomId)) + } + is GlobalSearchEvent.SearchHistoryResultSelected -> { + when (event.resultItem) { + is SearchHistoryResultItem.Query -> coroutineScope.launch { + delay(300.milliseconds) + queryState.edit { + replace(0, length, event.resultItem.term) + } + searchResults = AsyncData.Loading() + currentTarget = GlobalSearchTarget.ROOMS + } + is SearchHistoryResultItem.Room -> Unit + } + } } } @@ -214,6 +256,7 @@ class GlobalSearchPresenter( queryState = queryState, currentTarget = currentTarget, results = searchResults, + history = searchHistory, eventSink = ::handleEvent, ) } diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/GlobalSearchState.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/GlobalSearchState.kt index d623fd5594..44b94bd512 100644 --- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/GlobalSearchState.kt +++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/GlobalSearchState.kt @@ -28,6 +28,7 @@ data class GlobalSearchState( val queryState: TextFieldState, val currentTarget: GlobalSearchTarget, val results: AsyncData, + val history: AsyncData>, val eventSink: (GlobalSearchEvent) -> Unit ) @@ -81,6 +82,20 @@ sealed interface MessageSearchResultItem { } } +sealed interface SearchHistoryResultItem { + val id: String + + data class Query(val term: String) : SearchHistoryResultItem { + override val id: String = "query:$term" + } + data class Room( + val roomId: RoomId, + val roomInfo: RoomInfo, + ) : SearchHistoryResultItem { + override val id: String = "room:${roomId.value}" + } +} + data class MediaSearchResultContent( val filename: String, val extension: String?, diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/GlobalSearchStatePreviewParam.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/GlobalSearchStatePreviewParam.kt index 73487c5069..f9cc105a77 100644 --- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/GlobalSearchStatePreviewParam.kt +++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/GlobalSearchStatePreviewParam.kt @@ -36,77 +36,139 @@ import io.element.android.libraries.matrix.api.timeline.item.event.TextMessageTy import io.element.android.libraries.matrix.api.user.MatrixUser import io.element.android.libraries.matrix.ui.components.AttachmentThumbnailType import io.element.android.libraries.matrix.ui.messages.reply.aProfileDetailsReady +import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.persistentMapOf import kotlinx.collections.immutable.toImmutableList import kotlinx.collections.immutable.toPersistentList +private val tombstonedRoomId = RoomId("!tombstone:server.org") +private val roomId1 = RoomId("!roomId:server.org") +private val roomId2 = RoomId("!roomId2:server.org") +private val dmRoomId = RoomId("!dm:server.org") +private const val AN_AVATAR_URL = "https://example.com/avatar.png" +private val aCanonicalAlias = RoomAlias("#alias:server.org") + class GlobalSearchStatePreviewParam : PreviewParameterProvider { override val values: Sequence - get() = sequenceOf( - // Not enabled, no preview - aGlobalSearchState(isEnabled = false), - aGlobalSearchState(isSearchActive = true, queryState = TextFieldState("Query")), - aGlobalSearchState( - isSearchActive = true, - currentTarget = GlobalSearchTarget.ROOMS, - queryState = TextFieldState("Query"), - results = AsyncData.Loading(), - ), - aGlobalSearchState( - isSearchActive = true, - currentTarget = GlobalSearchTarget.ROOMS, - queryState = TextFieldState("Query"), - results = AsyncData.Success( - GlobalSearchResults.RoomListResults( - results = aRoomListRoomSummaryList().mapIndexed { index, summary -> - summary.copy(name = "Room with Query #${index + 1}") - }.toPersistentList() + get() { + return sequenceOf( + // Not enabled, no preview + aGlobalSearchState(isEnabled = false), + aGlobalSearchState(isSearchActive = true, queryState = TextFieldState("Query")), + aGlobalSearchState( + isSearchActive = true, + currentTarget = GlobalSearchTarget.ROOMS, + queryState = TextFieldState("Query"), + results = AsyncData.Loading(), + ), + aGlobalSearchState( + isSearchActive = true, + currentTarget = GlobalSearchTarget.ROOMS, + queryState = TextFieldState("Query"), + results = AsyncData.Success( + GlobalSearchResults.RoomListResults( + results = aRoomListRoomSummaryList().mapIndexed { index, summary -> + summary.copy(name = "Room with Query #${index + 1}") + }.toPersistentList() + ) + ), + ), + aGlobalSearchState( + isSearchActive = true, + currentTarget = GlobalSearchTarget.MESSAGES, + queryState = TextFieldState("Query"), + results = AsyncData.Success(GlobalSearchResults.MessageSearchResults(persistentListOf( + MessageSearchResultItem.Message( + messageSearchResult = aMessageSearchResult(eventId = EventId("\$eventId1:server.org")), + body = "A message with Query", + roomInfo = aRoomInfo(), + formattedTimestamp = "12:00", + ), + MessageSearchResultItem.Media( + messageSearchResult = aMessageSearchResult(eventId = EventId("\$eventId2:server.org")), + mediaContent = MediaSearchResultContent( + filename = "file.png", + extension = "PNG", + caption = "A caption containing Query", + formattedSize = "1 MB", + thumbnailSource = MediaSource("https://example.com/thumbnail.png"), + thumbnailType = AttachmentThumbnailType.Image, + blurhash = null, + ), + roomInfo = aRoomInfo(), + formattedTimestamp = "12:00", + ), + ))), + ), + aGlobalSearchState( + isSearchActive = true, + currentTarget = GlobalSearchTarget.MESSAGES, + queryState = TextFieldState("Query"), + results = AsyncData.Success(GlobalSearchResults.MessageSearchResults(persistentListOf())), + ), + aGlobalSearchState( + isSearchActive = true, + currentTarget = GlobalSearchTarget.ROOMS, + queryState = TextFieldState(), + results = AsyncData.Uninitialized, + history = AsyncData.Success( + persistentListOf( + SearchHistoryResultItem.Query("Query 1"), + SearchHistoryResultItem.Query("Query 2"), + SearchHistoryResultItem.Room( + roomId = roomId1, + roomInfo = aRoomInfo( + id = roomId1, + name = "A room with alias", + avatarUrl = AN_AVATAR_URL, + canonicalAlias = aCanonicalAlias, + ) + ), + SearchHistoryResultItem.Room( + roomId = roomId2, + roomInfo = aRoomInfo( + id = roomId2, + name = "A room", + avatarUrl = AN_AVATAR_URL, + ) + ), + SearchHistoryResultItem.Room( + roomId = dmRoomId, + roomInfo = aRoomInfo( + id = dmRoomId, + name = "A DM", + isDm = true, + heroes = listOf(MatrixUser(UserId("@user:server.org"), "User", AN_AVATAR_URL)), + avatarUrl = AN_AVATAR_URL, + canonicalAlias = aCanonicalAlias, + ) + ), + SearchHistoryResultItem.Room( + roomId = tombstonedRoomId, + roomInfo = aRoomInfo( + id = tombstonedRoomId, + name = "A tombstoned room", + avatarUrl = AN_AVATAR_URL, + successorRoom = SuccessorRoom( + roomId = RoomId("!successorRoom:server.org"), + reason = null, + ), + ), + ), + ) ) ), - ), - aGlobalSearchState( - isSearchActive = true, - currentTarget = GlobalSearchTarget.MESSAGES, - queryState = TextFieldState("Query"), - results = AsyncData.Success(GlobalSearchResults.MessageSearchResults(persistentListOf( - MessageSearchResultItem.Message( - messageSearchResult = aMessageSearchResult(eventId = EventId("\$eventId1:server.org")), - body = "A message with Query", - roomInfo = aRoomInfo(), - formattedTimestamp = "12:00", - ), - MessageSearchResultItem.Media( - messageSearchResult = aMessageSearchResult(eventId = EventId("\$eventId2:server.org")), - mediaContent = MediaSearchResultContent( - filename = "file.png", - extension = "PNG", - caption = "A caption containing Query", - formattedSize = "1 MB", - thumbnailSource = MediaSource("https://example.com/thumbnail.png"), - thumbnailType = AttachmentThumbnailType.Image, - blurhash = null, - ), - roomInfo = aRoomInfo(), - formattedTimestamp = "12:00", - ), - ))), - ), - aGlobalSearchState( - isSearchActive = true, - currentTarget = GlobalSearchTarget.MESSAGES, - queryState = TextFieldState("Query"), - results = AsyncData.Success(GlobalSearchResults.MessageSearchResults(persistentListOf())), - ), - ) + ) + } } fun aRoomInfo( - id: RoomId = RoomId("!roomId:server.org"), + id: RoomId = roomId1, name: String? = "A room", rawName: String? = "A room raw name", topic: String? = "A room topic", - avatarUrl: String? = "https://example.com/avatar.png", + avatarUrl: String? = AN_AVATAR_URL, isPublic: Boolean = true, isDirect: Boolean = false, isEncrypted: Boolean = false, @@ -217,7 +279,7 @@ fun aMessageContent( ) internal fun aMessageSearchResult( - roomId: RoomId = RoomId("!roomId:server.org"), + roomId: RoomId = roomId1, eventId: EventId = EventId("\$eventId:server.org"), senderId: UserId = UserId("@user:server.org"), senderProfile: ProfileDetails = aProfileDetailsReady(), @@ -238,6 +300,7 @@ internal fun aGlobalSearchState( queryState: TextFieldState = TextFieldState(), currentTarget: GlobalSearchTarget = GlobalSearchTarget.ROOMS, results: AsyncData = AsyncData.Uninitialized, + history: AsyncData> = AsyncData.Uninitialized, eventSink: (GlobalSearchEvent) -> Unit = {}, ) = GlobalSearchState( isEnabled = isEnabled, @@ -245,5 +308,6 @@ internal fun aGlobalSearchState( queryState = queryState, currentTarget = currentTarget, results = results, + history = history, eventSink = eventSink ) diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/GlobalSearchView.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/GlobalSearchView.kt index d5a0460f06..cce0620f3f 100644 --- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/GlobalSearchView.kt +++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/GlobalSearchView.kt @@ -23,11 +23,15 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.text.input.KeyboardActionHandler import androidx.compose.foundation.text.input.TextFieldLineLimits -import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.IconButtonDefaults import androidx.compose.material3.SingleChoiceSegmentedButtonRow import androidx.compose.material3.TextFieldDefaults import androidx.compose.runtime.Composable @@ -44,6 +48,7 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.unit.dp @@ -63,6 +68,7 @@ import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.designsystem.text.roundToPx import io.element.android.libraries.designsystem.theme.components.FilledTextField +import io.element.android.libraries.designsystem.theme.components.HorizontalDivider import io.element.android.libraries.designsystem.theme.components.Icon import io.element.android.libraries.designsystem.theme.components.IconButton import io.element.android.libraries.designsystem.theme.components.LinearProgressIndicator @@ -80,6 +86,7 @@ import io.element.android.libraries.matrix.ui.components.AttachmentThumbnailInfo import io.element.android.libraries.matrix.ui.model.getAvatarData import io.element.android.libraries.ui.strings.CommonStrings import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList @Composable @@ -103,7 +110,10 @@ fun GlobalSearchView( onBackButtonClick = { state.eventSink(GlobalSearchEvent.ToggleSearchVisibility) }, - onSelectSearchResult = onSelectSearchResult, + onSelectSearchResult = { roomId, eventId -> + onSelectSearchResult(roomId, eventId) + state.eventSink(GlobalSearchEvent.SaveRoomToHistory(roomId)) + }, ) } } @@ -115,63 +125,11 @@ private fun GlobalSearchContent( onBackButtonClick: () -> Unit, onSelectSearchResult: (RoomId, EventId?) -> Unit, ) { - val borderColor = MaterialTheme.colorScheme.tertiary - val strokeWidth = 1.dp - - val drawBottomLineModifier = Modifier.drawBehind { - drawLine( - color = borderColor, - start = Offset(0f, size.height), - end = Offset(size.width, size.height), - strokeWidth = strokeWidth.value - ) - } - Scaffold( topBar = { TopAppBar( navigationIcon = { BackButton(onClick = onBackButtonClick) }, - title = { - // The stateSaver will keep the selection state when returning to this UI - val focusRequester = remember { FocusRequester() } - val searchLabel = stringResource(CommonStrings.action_search) - FilledTextField( - modifier = Modifier - .fillMaxWidth() - .focusRequester(focusRequester) - .semantics { contentDescription = searchLabel }, - state = state.queryState, - lineLimits = TextFieldLineLimits.SingleLine, - colors = TextFieldDefaults.colors( - focusedContainerColor = Color.Transparent, - unfocusedContainerColor = Color.Transparent, - disabledContainerColor = Color.Transparent, - focusedIndicatorColor = Color.Transparent, - unfocusedIndicatorColor = Color.Transparent, - disabledIndicatorColor = Color.Transparent, - errorIndicatorColor = Color.Transparent, - ), - trailingIcon = if (state.queryState.text.isNotEmpty()) { - @Composable { - IconButton(onClick = { state.eventSink(GlobalSearchEvent.ClearQuery) }) { - Icon( - imageVector = CompoundIcons.Close(), - contentDescription = stringResource(CommonStrings.a11y_clear_search_field) - ) - } - } - } else { - null - }, - ) - - LaunchedEffect(Unit) { - if (!focusRequester.restoreFocusedChild()) { - focusRequester.requestFocus() - } - focusRequester.saveFocusedChild() - } - } + title = { SearchTopAppBarContent(state = state) } ) }, contentWindowInsets = scaffoldScrollableContentInsets, @@ -179,27 +137,9 @@ private fun GlobalSearchContent( Column( modifier = Modifier.padding(padding), ) { - SingleChoiceSegmentedButtonRow( - modifier = drawBottomLineModifier - .fillMaxWidth() - .padding(start = 16.dp, end = 16.dp, top = 8.dp, bottom = 8.dp) - ) { - SegmentedButton( - index = 0, - count = 2, - selected = state.currentTarget == GlobalSearchTarget.ROOMS, - onClick = { state.eventSink(GlobalSearchEvent.UpdateTarget(GlobalSearchTarget.ROOMS)) }, - text = stringResource(R.string.search_section_chats), - ) + HorizontalDivider(color = ElementTheme.colors.borderInteractivePrimary) - SegmentedButton( - index = 1, - count = 2, - selected = state.currentTarget == GlobalSearchTarget.MESSAGES, - onClick = { state.eventSink(GlobalSearchEvent.UpdateTarget(GlobalSearchTarget.MESSAGES)) }, - text = stringResource(R.string.search_section_messages), - ) - } + SearchTargetSelector(state = state) val lazyListState = rememberLazyListState() OnVisibleRangeChangeEffect(lazyListState) { visibleRange -> @@ -212,9 +152,21 @@ private fun GlobalSearchContent( state = lazyListState, ) { val results = state.results.dataOrNull() + val historyResults = state.history.dataOrNull()?.toImmutableList() ?: persistentListOf() when { - state.results.isUninitialized() -> startSearching() - state.results.isLoading() -> loading() + state.results.isUninitialized() -> if (state.queryState.text.isBlank() && historyResults.isNotEmpty()) { + searchHistory(historyResults) { result -> + when (result) { + is SearchHistoryResultItem.Query -> Unit + is SearchHistoryResultItem.Room -> onSelectSearchResult(result.roomInfo.id, null) + } + + state.eventSink(GlobalSearchEvent.SearchHistoryResultSelected(result)) + } + } else { + startSearching() + } + state.results.isLoading() -> loadingResults() results?.isEmpty() == true -> emptySearchResults(query = state.queryState.text.toString()) results is GlobalSearchResults.RoomListResults -> roomListResults( results = results.results, @@ -230,6 +182,89 @@ private fun GlobalSearchContent( } } +@Composable +private fun SearchTopAppBarContent(state: GlobalSearchState) { + // The stateSaver will keep the selection state when returning to this UI + val focusRequester = remember { FocusRequester() } + val searchLabel = stringResource(CommonStrings.action_search) + FilledTextField( + modifier = Modifier + .fillMaxWidth() + .focusRequester(focusRequester) + .semantics { contentDescription = searchLabel }, + state = state.queryState, + lineLimits = TextFieldLineLimits.SingleLine, + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search), + keyboardActions = KeyboardActionHandler { performDefault -> + state.eventSink(GlobalSearchEvent.SaveQueryToHistory) + performDefault() + }, + colors = TextFieldDefaults.colors( + focusedContainerColor = Color.Transparent, + unfocusedContainerColor = Color.Transparent, + disabledContainerColor = Color.Transparent, + focusedIndicatorColor = Color.Transparent, + unfocusedIndicatorColor = Color.Transparent, + disabledIndicatorColor = Color.Transparent, + errorIndicatorColor = Color.Transparent, + ), + trailingIcon = if (state.queryState.text.isNotEmpty()) { + @Composable { + IconButton( + modifier = Modifier.clip(CircleShape).size(20.dp), + onClick = { state.eventSink(GlobalSearchEvent.ClearQuery) }, + colors = IconButtonDefaults.iconButtonColors( + containerColor = ElementTheme.colors.borderInteractivePrimary, + contentColor = ElementTheme.colors.iconOnSolidPrimary + ) + ) { + Icon( + modifier = Modifier.size(16.dp), + imageVector = CompoundIcons.Close(), + contentDescription = stringResource(CommonStrings.a11y_clear_search_field), + ) + } + } + } else { + null + }, + ) + + LaunchedEffect(Unit) { + if (!focusRequester.restoreFocusedChild()) { + focusRequester.requestFocus() + } + focusRequester.saveFocusedChild() + } +} + +@Composable +private fun SearchTargetSelector(state: GlobalSearchState) { + if (state.queryState.text.isEmpty()) return + + SingleChoiceSegmentedButtonRow( + modifier = Modifier + .fillMaxWidth() + .padding(start = 16.dp, end = 16.dp, top = 16.dp, bottom = 8.dp) + ) { + SegmentedButton( + index = 0, + count = 2, + selected = state.currentTarget == GlobalSearchTarget.ROOMS, + onClick = { state.eventSink(GlobalSearchEvent.UpdateTarget(GlobalSearchTarget.ROOMS)) }, + text = stringResource(R.string.search_section_chats), + ) + + SegmentedButton( + index = 1, + count = 2, + selected = state.currentTarget == GlobalSearchTarget.MESSAGES, + onClick = { state.eventSink(GlobalSearchEvent.UpdateTarget(GlobalSearchTarget.MESSAGES)) }, + text = stringResource(R.string.search_section_messages), + ) + } +} + private fun LazyListScope.startSearching() { item { IconTitleSubtitleMolecule( @@ -243,7 +278,98 @@ private fun LazyListScope.startSearching() { } } -private fun LazyListScope.loading() { +private fun LazyListScope.searchHistory( + historyResults: ImmutableList, + onSelectSearchHistoryResult: (SearchHistoryResultItem) -> Unit, +) { + item { + Text( + modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 16.dp, bottom = 8.dp), + text = stringResource(R.string.screen_search_recent_searches_section_title), + style = ElementTheme.typography.fontBodyLgMedium, + color = ElementTheme.colors.textPrimary, + ) + } + items( + items = historyResults, + key = { it.id }, + ) { result -> + Row( + modifier = Modifier + .niceClickable(onClick = { onSelectSearchHistoryResult(result) }) + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 12.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + when (result) { + is SearchHistoryResultItem.Query -> { + Icon( + modifier = Modifier.size(32.dp) + .clip(RoundedCornerShape(8.dp)) + .background(ElementTheme.colors.bgSubtleSecondary) + .padding(6.dp), + imageVector = CompoundIcons.History(), + contentDescription = null, + tint = ElementTheme.colors.iconPrimary, + ) + + Text( + text = result.term, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + style = ElementTheme.typography.fontBodyLgRegular, + ) + } + is SearchHistoryResultItem.Room -> { + val heroAvatars = remember(result.roomInfo.heroes) { + result.roomInfo.heroes.map { it.getAvatarData(AvatarSize.SearchRoomListItem) }.toImmutableList() + } + Avatar( + avatarData = result.roomInfo.getAvatarData(AvatarSize.SearchRoomListItem), + avatarType = AvatarType.Room( + heroes = heroAvatars, + isTombstoned = result.roomInfo.successorRoom != null, + ), + ) + + Column(modifier = Modifier.fillMaxWidth()) { + Text( + text = result.roomInfo.name.orEmpty(), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + style = ElementTheme.typography.fontBodyLgRegular, + color = ElementTheme.colors.textPrimary, + ) + + if (result.roomInfo.isDm) { + val dmUserId = result.roomInfo.heroes.firstOrNull()?.userId + dmUserId?.let { userId -> + Text( + text = userId.value, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + style = ElementTheme.typography.fontBodyMdRegular, + color = ElementTheme.colors.textSecondary, + ) + } + } else if (result.roomInfo.canonicalAlias != null) { + Text( + text = result.roomInfo.canonicalAlias?.value!!, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + style = ElementTheme.typography.fontBodyMdRegular, + color = ElementTheme.colors.textSecondary, + ) + } + } + } + } + } + } +} + +private fun LazyListScope.loadingResults() { item { LinearProgressIndicator(modifier = Modifier.fillMaxWidth()) } diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/history/DefaultSearchHistoryFileStore.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/history/DefaultSearchHistoryFileStore.kt new file mode 100644 index 0000000000..0d7931b2df --- /dev/null +++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/history/DefaultSearchHistoryFileStore.kt @@ -0,0 +1,47 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.home.impl.search.history + +import android.content.Context +import dev.zacsweers.metro.ContributesBinding +import io.element.android.libraries.di.SessionScope +import io.element.android.libraries.di.annotations.ApplicationContext +import io.element.android.libraries.matrix.api.MatrixClient +import io.element.encrypteddb.crypto.EncryptedFile +import java.io.File + +private const val SEARCH_HISTORY_FILE_NAME = "search_history" + +/** + * Persists the search history in an encrypted file inside the current session file directory, using + * [EncryptedFile] (Tink StreamingAead + a master key stored in the Android Keystore). + */ +@ContributesBinding(SessionScope::class) +class DefaultSearchHistoryFileStore( + @ApplicationContext private val context: Context, + matrixClient: MatrixClient, +) : SearchHistoryFileStore { + private val file: File by lazy { + File(matrixClient.sessionPaths.fileDirectory, SEARCH_HISTORY_FILE_NAME) + } + + override fun read(): ByteArray? { + if (!file.exists()) return null + return EncryptedFile(context, file).openFileInput().use { it.readBytes() } + } + + override fun write(bytes: ByteArray) { + file.parentFile?.takeIf { !it.exists() }?.mkdirs() + // Opening a FileOutputStream truncates any previous content, so this overwrites the file. + EncryptedFile(context, file).openFileOutput().use { it.write(bytes) } + } + + override fun delete(): Boolean { + return file.delete() + } +} diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/history/DefaultSearchHistoryStore.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/history/DefaultSearchHistoryStore.kt new file mode 100644 index 0000000000..4da3456043 --- /dev/null +++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/history/DefaultSearchHistoryStore.kt @@ -0,0 +1,96 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.home.impl.search.history + +import dev.zacsweers.metro.ContributesBinding +import io.element.android.libraries.androidutils.json.JsonProvider +import io.element.android.libraries.core.coroutine.CoroutineDispatchers +import io.element.android.libraries.core.extensions.runCatchingExceptions +import io.element.android.libraries.di.SessionScope +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.emitAll +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext +import timber.log.Timber + +@ContributesBinding(SessionScope::class) +class DefaultSearchHistoryStore( + private val fileStore: SearchHistoryFileStore, + private val dispatchers: CoroutineDispatchers, + private val jsonProvider: JsonProvider, +) : SearchHistoryStore { + private val json by lazy { jsonProvider() } + + // Guards [fileStore] access and the lazy initialization of [cache]. + private val mutex = Mutex() + private val cache = MutableStateFlow>(emptyList()) + private var isLoaded = false + + override val history: Flow> = flow { + ensureLoaded() + emitAll(cache) + } + + override suspend fun add(result: SearchHistoryResult) = withContext(dispatchers.io) { + mutex.withLock { + ensureLoadedLocked() + val updated = buildList { + add(result) + // Deduplicate: an equal entry is moved to the front rather than being duplicated. + addAll(cache.value.filterNot { it == result }) + } + .distinct() + .take(MAX_SEARCH_HISTORY_SIZE) + cache.value = updated + persist(updated) + } + } + + override suspend fun clear() = withContext(dispatchers.io) { + mutex.withLock { + isLoaded = true + cache.value = emptyList() + runCatchingExceptions { fileStore.delete() } + .onFailure { Timber.e(it, "Failed to delete search history") } + } + } + + private suspend fun ensureLoaded() = withContext(dispatchers.io) { + mutex.withLock { + ensureLoadedLocked() + } + } + + /** + * Must be called while holding [mutex]. + */ + private fun ensureLoadedLocked() { + if (isLoaded) return + cache.value = readFromDisk() + isLoaded = true + } + + private fun readFromDisk(): List { + return runCatchingExceptions { + val bytes = fileStore.read() ?: return@runCatchingExceptions emptyList() + json.decodeFromString>(bytes.decodeToString()) + }.getOrElse { + Timber.e(it, "Failed to read search history, resetting it") + emptyList() + } + } + + private fun persist(items: List) { + runCatchingExceptions { + fileStore.write(json.encodeToString(items).encodeToByteArray()) + }.onFailure { Timber.e(it, "Failed to persist search history") } + } +} diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/history/SearchHistoryFileStore.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/history/SearchHistoryFileStore.kt new file mode 100644 index 0000000000..9ba1fc6d03 --- /dev/null +++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/history/SearchHistoryFileStore.kt @@ -0,0 +1,34 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.home.impl.search.history + +/** + * Low level, raw byte read/write of the persisted search history. + * + * This exists as a seam so that [DefaultSearchHistoryStore] can be unit tested without relying on + * the Android Keystore. The production implementation ([DefaultSearchHistoryFileStore]) stores the + * bytes in an encrypted file. + */ +interface SearchHistoryFileStore { + /** + * Return the persisted bytes, or `null` if nothing has been persisted yet. + */ + fun read(): ByteArray? + + /** + * Persist [bytes], replacing any previously persisted content. + */ + fun write(bytes: ByteArray) + + /** + * Delete any persisted content. + * + * @return `true` if the persisted content was deleted, `false` if there was no persisted content to delete. + */ + fun delete(): Boolean +} diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/history/SearchHistoryResult.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/history/SearchHistoryResult.kt new file mode 100644 index 0000000000..1b883fa24e --- /dev/null +++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/history/SearchHistoryResult.kt @@ -0,0 +1,26 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.home.impl.search.history + +import io.element.android.libraries.matrix.api.core.RoomId +import kotlinx.serialization.Serializable + +/** + * A single entry in the global search history. + * + * The history stores either the term used to perform a search (when the keyboard search action is + * triggered) or the id of a room opened from the search results. + */ +@Serializable +sealed interface SearchHistoryResult { + @Serializable + data class Query(val term: String) : SearchHistoryResult + + @Serializable + data class Room(val roomId: RoomId) : SearchHistoryResult +} diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/history/SearchHistoryStore.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/history/SearchHistoryStore.kt new file mode 100644 index 0000000000..7718d32f77 --- /dev/null +++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/history/SearchHistoryStore.kt @@ -0,0 +1,42 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.home.impl.search.history + +import kotlinx.coroutines.flow.Flow + +/** + * Stores the global search history for the current session. + * + * The history is persisted in an encrypted file and capped to [MAX_SEARCH_HISTORY_SIZE] entries. + * The most recent entry is always at the front of the list; when the cap is reached the oldest + * entries are dropped. + */ +interface SearchHistoryStore { + /** + * Emits the current search history, most recent entry first, and re-emits whenever it changes. + */ + val history: Flow> + + /** + * Add a new [result] at the front of the history. + * + * If an equal entry already exists it is moved to the front instead of being duplicated. When + * the history exceeds [MAX_SEARCH_HISTORY_SIZE] entries the oldest ones are dropped. + */ + suspend fun add(result: SearchHistoryResult) + + /** + * Remove every entry from the history. + */ + suspend fun clear() +} + +/** + * Maximum number of entries kept in the search history. + */ +const val MAX_SEARCH_HISTORY_SIZE = 25 diff --git a/features/home/impl/src/main/res/values-cs/translations.xml b/features/home/impl/src/main/res/values-cs/translations.xml index 0276db1fbe..5577f82ef2 100644 --- a/features/home/impl/src/main/res/values-cs/translations.xml +++ b/features/home/impl/src/main/res/values-cs/translations.xml @@ -14,7 +14,7 @@ "Vaše úložiště klíčů není synchronizováno" "Abyste nikdy nezmeškali důležitý hovor, změňte nastavení tak, abyste povolili oznámení na celé obrazovce, když je telefon uzamčen." "Vylepšete si zážitek z volání" - "Všechny chaty" + "Chaty" "Prostory" "Opravdu chcete odmítnout pozvánku do %1$s?" "Odmítnout pozvání" @@ -46,7 +46,7 @@ Prozatím můžete zrušit výběr filtrů, abyste viděli své další chaty""Gratulujeme! Nemáte žádné nepřečtené zprávy!" "Žádost o vstup odeslána" - "Všechny chaty" + "Chaty" "Označit jako přečtené" "Označit jako nepřečtené" "Tato místnost byla aktualizována" diff --git a/features/home/impl/src/main/res/values-de/translations.xml b/features/home/impl/src/main/res/values-de/translations.xml index 1f0d21d6f0..f285e8d060 100644 --- a/features/home/impl/src/main/res/values-de/translations.xml +++ b/features/home/impl/src/main/res/values-de/translations.xml @@ -36,6 +36,8 @@ Deaktiviere den entsprechenden Filter, um deine anderen Chats zu sehen" "Du hast keine ausstehenden Einladungen." "Niedrige Priorität" "Du hast noch keine Chats mit niedriger Priorität" + "Erwähnungen" + "Du hast keine ungelesenen Erwähnungen" "Entferne Filter, um Chats zu sehen" "Für diese Auswahl hast du keinen Chat" "Personen" diff --git a/features/home/impl/src/main/res/values-fi/translations.xml b/features/home/impl/src/main/res/values-fi/translations.xml index fbc818898b..6643782548 100644 --- a/features/home/impl/src/main/res/values-fi/translations.xml +++ b/features/home/impl/src/main/res/values-fi/translations.xml @@ -36,6 +36,8 @@ Toistaiseksi voit poistaa suodattimien valinnan, jotta näet muut keskustelut."< "Sinulla ei ole yhtään odottavaa kutsua." "Matala prioriteetti" "Sinulla ei ole vielä yhtään matalan prioriteetin keskustelua" + "Maininnat" + "Sinulla ei ole lukemattomia mainintoja" "Voit poistaa suodattimien valinnan nähdäksesi muut keskustelusi." "Sinulla ei ole sopivia keskusteluja tähän valintaan" "Ihmiset" diff --git a/features/home/impl/src/main/res/values-hu/translations.xml b/features/home/impl/src/main/res/values-hu/translations.xml index ac5ff05ea5..36f34aff0f 100644 --- a/features/home/impl/src/main/res/values-hu/translations.xml +++ b/features/home/impl/src/main/res/values-hu/translations.xml @@ -14,7 +14,7 @@ "A kulcstároló nincs szinkronizálva" "Hogy sose maradjon le egyetlen fontos hívásról sem, a beállításokban engedélyezze a teljes képernyős értesítéseket, amikor a telefon zárolva van." "Fokozza a hívásélményét" - "Összes csevegés" + "Csevegések" "Terek" "Biztos, hogy elutasítja a meghívást, hogy csatlakozzon ehhez: %1$s?" "Meghívás elutasítása" @@ -36,6 +36,8 @@ Egyelőre törölheti a szűrőket a többi csevegés megtekintéséhez.""Nincsenek függőben lévő meghívásai." "Alacsony prioritás" "Még nincsenek alacsony prioritású csevegései" + "Megemlítés" + "Nincs olvasatlan említése" "Kikapcsolhatja a szűrőket a többi csevegés megtekintéséhez" "Ehhez a kiválasztáshoz nem tartoznak csevegések" "Emberek" @@ -46,7 +48,7 @@ Egyelőre törölheti a szűrőket a többi csevegés megtekintéséhez.""Gratulálunk! Nincs olvasatlan üzenete!" "Csatlakozási kérés elküldve" - "Összes csevegés" + "Csevegések" "Megjelölés olvasottként" "Megjelölés olvasatlanként" "A szoba verzióját frissítették" diff --git a/features/home/impl/src/main/res/values-ja/translations.xml b/features/home/impl/src/main/res/values-ja/translations.xml index 855bb14533..6b1124a713 100644 --- a/features/home/impl/src/main/res/values-ja/translations.xml +++ b/features/home/impl/src/main/res/values-ja/translations.xml @@ -36,6 +36,8 @@ "承認待ちの招待はありません" "低い優先度" "低い優先度のチャットはまだありません" + "メンション" + "未読のメンションはありません" "フィルターを解除して他のチャットを表示できます" "この選択中にチャットがありません" "ユーザー" diff --git a/features/home/impl/src/main/res/values-sv/translations.xml b/features/home/impl/src/main/res/values-sv/translations.xml index d92b351118..b02183f57d 100644 --- a/features/home/impl/src/main/res/values-sv/translations.xml +++ b/features/home/impl/src/main/res/values-sv/translations.xml @@ -12,7 +12,7 @@ "Din nyckellagring är inte synkroniserad" "För att säkerställa att du aldrig missar ett viktigt samtal, ändra dina inställningar för att tillåta helskärmsmeddelanden när telefonen är låst." "Förbättra din samtalsupplevelse" - "Alla chattar" + "Chattar" "Utrymmen" "Är du säker på att du vill tacka nej till inbjudan att gå med%1$s?" "Avböj inbjudan" @@ -44,7 +44,7 @@ För tillfället kan du avmarkera filter för att se dina andra chattar""Grattis! Du har inga olästa meddelanden!" "Begäran om att gå med skickad" - "Alla chattar" + "Chattar" "Markera som läst" "Markera som oläst" "Det här rummet har uppgraderats" diff --git a/features/home/impl/src/main/res/values-zh-rTW/translations.xml b/features/home/impl/src/main/res/values-zh-rTW/translations.xml index b52ac9ad8b..af9653f4f5 100644 --- a/features/home/impl/src/main/res/values-zh-rTW/translations.xml +++ b/features/home/impl/src/main/res/values-zh-rTW/translations.xml @@ -14,7 +14,7 @@ "您的金鑰儲存空間並未同步" "為確保您永遠不會錯過重要通話,請變更設定以允許在手機鎖定時允許全螢幕通知。" "提升您的通話體驗" - "所有聊天室" + "聊天" "空間" "您確定您想要拒絕加入 %1$s 的邀請嗎?" "拒絕邀請" @@ -36,6 +36,8 @@ "您沒有任何擱置中的邀請。" "低優先度" "您尚無任何低優先程度聊天" + "提及" + "您沒有任何未讀提及" "您可以取消選取篩選條件以檢視其他聊天" "您並無此選擇的聊天" "夥伴" @@ -46,7 +48,7 @@ "恭喜! 您沒有任何未讀的訊息!" "已傳送加入請求" - "所有聊天室" + "聊天" "標為已讀" "標為未讀" "此聊天室已升級" diff --git a/features/home/impl/src/main/res/values-zh/translations.xml b/features/home/impl/src/main/res/values-zh/translations.xml index c746f9572f..91d08766ed 100644 --- a/features/home/impl/src/main/res/values-zh/translations.xml +++ b/features/home/impl/src/main/res/values-zh/translations.xml @@ -37,7 +37,7 @@ "低优先级" "你暂无任何低优先级聊天" "提及" - "你没有任何未读提及" + "你暂无任何未读提及" "你可以取消选择筛选器以查看其它对话" "你暂无适用于此选项的聊天" "人员" @@ -46,7 +46,7 @@ "你尚未进入任何房间" "未读" "恭喜! -没有任何未读消息!" +你暂无任何未读消息!" "加入申请已发送" "聊天" "设为已读" diff --git a/features/home/impl/src/main/res/values/temporary.xml b/features/home/impl/src/main/res/values/temporary.xml index c44001e5a6..556d8a050f 100644 --- a/features/home/impl/src/main/res/values/temporary.xml +++ b/features/home/impl/src/main/res/values/temporary.xml @@ -14,4 +14,5 @@ Messages No results There are no results for “%1$s”. Try a new search term. + Recent searches diff --git a/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/CurrentUserWithNeighborsBuilderTest.kt b/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/CurrentUserWithNeighborsBuilderTest.kt index 13c9585386..e8d041bca3 100644 --- a/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/CurrentUserWithNeighborsBuilderTest.kt +++ b/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/CurrentUserWithNeighborsBuilderTest.kt @@ -220,4 +220,30 @@ class CurrentUserWithNeighborsBuilderTest { ) ) } + + @Test + fun `other accounts use the data from db, including the avatar thumbnail`() { + val sut = CurrentUserWithNeighborsBuilder() + val matrixUser = aMatrixUser(id = A_USER_ID.value) + val list = listOf( + aSessionData( + sessionId = A_USER_ID.value, + ), + aSessionData( + sessionId = A_USER_ID_2.value, + userDisplayName = "Carol", + userAvatarUrl = "avatarUrl", + userAvatarData = "avatarData", + ), + ) + val result = sut.build(matrixUser, list) + assertThat(result[0]).isEqualTo( + MatrixUser( + userId = A_USER_ID_2, + displayName = "Carol", + avatarUrl = "avatarUrl", + avatarThumbnail = "avatarData", + ) + ) + } } diff --git a/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/HomePresenterTest.kt b/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/HomePresenterTest.kt index 371a718523..58ad4eb100 100644 --- a/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/HomePresenterTest.kt +++ b/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/HomePresenterTest.kt @@ -81,7 +81,7 @@ class HomePresenterTest { val presenter = createHomePresenter( rageshakeFeatureAvailability = { flowOf(true) }, sessionStore = InMemorySessionStore( - updateUserProfileResult = { _, _, _ -> }, + updateUserProfileResult = { _, _, _, _ -> }, ), ) presenter.test { @@ -98,7 +98,7 @@ class HomePresenterTest { val presenter = createHomePresenter( indicatorService = indicatorService, sessionStore = InMemorySessionStore( - updateUserProfileResult = { _, _, _ -> }, + updateUserProfileResult = { _, _, _, _ -> }, ), ) presenter.test { @@ -120,7 +120,7 @@ class HomePresenterTest { val presenter = createHomePresenter( client = matrixClient, sessionStore = InMemorySessionStore( - updateUserProfileResult = { _, _, _ -> }, + updateUserProfileResult = { _, _, _, _ -> }, ), ) presenter.test { @@ -134,7 +134,7 @@ class HomePresenterTest { fun `present - NavigationBar change`() = runTest { val presenter = createHomePresenter( sessionStore = InMemorySessionStore( - updateUserProfileResult = { _, _, _ -> }, + updateUserProfileResult = { _, _, _, _ -> }, ), ) presenter.test { diff --git a/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/search/GlobalSearchPresenterTest.kt b/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/search/GlobalSearchPresenterTest.kt index 0c6b063705..6d54c59912 100644 --- a/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/search/GlobalSearchPresenterTest.kt +++ b/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/search/GlobalSearchPresenterTest.kt @@ -11,6 +11,9 @@ package io.element.android.features.home.impl.search import com.google.common.truth.Truth.assertThat import io.element.android.features.home.impl.datasource.aRoomListRoomSummaryFactory +import io.element.android.features.home.impl.search.history.FakeSearchHistoryStore +import io.element.android.features.home.impl.search.history.SearchHistoryResult +import io.element.android.features.home.impl.search.history.SearchHistoryStore import io.element.android.libraries.androidutils.filesize.FakeFileSizeFormatter import io.element.android.libraries.androidutils.filesize.FileSizeFormatter import io.element.android.libraries.architecture.AsyncData @@ -28,6 +31,7 @@ import io.element.android.libraries.matrix.api.roomlist.RoomListFilter.Joined import io.element.android.libraries.matrix.api.roomlist.RoomListFilter.NormalizedMatchRoomName import io.element.android.libraries.matrix.api.roomlist.RoomListService import io.element.android.libraries.matrix.api.search.MessageSearchService +import io.element.android.libraries.matrix.test.A_ROOM_ID import io.element.android.libraries.matrix.test.FakeMatrixClient import io.element.android.libraries.matrix.test.permalink.FakePermalinkParser import io.element.android.libraries.matrix.test.room.aRoomSummary @@ -41,12 +45,12 @@ import io.element.android.tests.testutils.testCoroutineDispatchers import kotlinx.collections.immutable.persistentListOf import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.first import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.runCurrent import kotlinx.coroutines.test.runTest import org.junit.Test -import java.util.Optional class GlobalSearchPresenterTest { @Test @@ -60,6 +64,8 @@ class GlobalSearchPresenterTest { assertThat(state.currentTarget).isEqualTo(GlobalSearchTarget.ROOMS) assertThat(state.results).isEqualTo(AsyncData.Uninitialized) } + + cancelAndConsumeRemainingEvents() } } @@ -85,6 +91,9 @@ class GlobalSearchPresenterTest { fun `present - toggle search visibility`() = runTest { val presenter = createGlobalSearchPresenter() presenter.test { + // Skip the initial state + skipItems(1) + awaitItem().let { state -> assertThat(state.isSearchActive).isFalse() state.eventSink(GlobalSearchEvent.ToggleSearchVisibility) @@ -103,6 +112,9 @@ class GlobalSearchPresenterTest { fun `present - update target`() = runTest { val presenter = createGlobalSearchPresenter() presenter.test { + // Skip initial item + skipItems(1) + awaitItem().let { state -> assertThat(state.currentTarget).isEqualTo(GlobalSearchTarget.ROOMS) state.eventSink(GlobalSearchEvent.UpdateTarget(GlobalSearchTarget.MESSAGES)) @@ -194,7 +206,7 @@ class GlobalSearchPresenterTest { fun `present - message search results are mapped when the message search emits`() = runTest { val messageSearch = FakeMessageSearch() val matrixClient = FakeMatrixClient().apply { - getRoomInfoFlowLambda = { flowOf(Optional.of(aRoomInfo())) } + getRoomInfoLambda = { Result.success(aRoomInfo()) } } val presenter = createGlobalSearchPresenter( messageSearchService = FakeMessageSearchService(messageSearch), @@ -221,7 +233,7 @@ class GlobalSearchPresenterTest { fun `present - message search displays the uninitialized state after removing the query`() = runTest { val messageSearch = FakeMessageSearch() val matrixClient = FakeMatrixClient().apply { - getRoomInfoFlowLambda = { flowOf(Optional.of(aRoomInfo())) } + getRoomInfoLambda = { Result.success(aRoomInfo()) } } val presenter = createGlobalSearchPresenter( messageSearchService = FakeMessageSearchService(messageSearch), @@ -256,7 +268,7 @@ class GlobalSearchPresenterTest { fun `present - UpdateVisibleRange triggers pagination for messages when near the end`() = runTest { val messageSearch = FakeMessageSearch() val matrixClient = FakeMatrixClient().apply { - getRoomInfoFlowLambda = { flowOf(Optional.of(aRoomInfo())) } + getRoomInfoLambda = { Result.success(aRoomInfo()) } } val presenter = createGlobalSearchPresenter( messageSearchService = FakeMessageSearchService(messageSearch), @@ -279,6 +291,82 @@ class GlobalSearchPresenterTest { cancelAndIgnoreRemainingEvents() } } + + @Test + fun `present - SaveQueryToHistory stores the current query`() = runTest { + val searchHistoryStore = FakeSearchHistoryStore() + val presenter = createGlobalSearchPresenter(searchHistoryStore = searchHistoryStore) + presenter.test { + val initialState = awaitItem() + initialState.queryState.edit { append("A query") } + testScheduler.advanceUntilIdle() + + initialState.eventSink(GlobalSearchEvent.SaveQueryToHistory) + testScheduler.advanceUntilIdle() + + assertThat(searchHistoryStore.history.first()).containsExactly(SearchHistoryResult.Query("A query")) + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `present - SaveQueryToHistory ignores a blank query`() = runTest { + val searchHistoryStore = FakeSearchHistoryStore() + val presenter = createGlobalSearchPresenter(searchHistoryStore = searchHistoryStore) + presenter.test { + val initialState = awaitItem() + initialState.eventSink(GlobalSearchEvent.SaveQueryToHistory) + testScheduler.advanceUntilIdle() + + assertThat(searchHistoryStore.history.first()).isEmpty() + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `present - SaveRoomToHistory stores the room`() = runTest { + val searchHistoryStore = FakeSearchHistoryStore() + val presenter = createGlobalSearchPresenter(searchHistoryStore = searchHistoryStore) + presenter.test { + val initialState = awaitItem() + initialState.eventSink(GlobalSearchEvent.SaveRoomToHistory(A_ROOM_ID)) + testScheduler.advanceUntilIdle() + + assertThat(searchHistoryStore.history.first()).containsExactly(SearchHistoryResult.Room(A_ROOM_ID)) + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `present - SearchHistoryResultSelected does nothing for a room result`() = runTest { + val searchHistoryStore = FakeSearchHistoryStore() + val presenter = createGlobalSearchPresenter(searchHistoryStore = searchHistoryStore) + presenter.test { + val initialState = awaitItem() + skipItems(1) + + initialState.eventSink(GlobalSearchEvent.SearchHistoryResultSelected(SearchHistoryResultItem.Room(A_ROOM_ID, aRoomInfo()))) + testScheduler.advanceUntilIdle() + + ensureAllEventsConsumed() + } + } + + @Test + fun `present - SearchHistoryResultSelected changes the search query when a previous search is selected`() = runTest { + val searchHistoryStore = FakeSearchHistoryStore() + val presenter = createGlobalSearchPresenter(searchHistoryStore = searchHistoryStore) + presenter.test { + val initialState = awaitItem() + // Skip loading search results state + skipItems(1) + + initialState.eventSink(GlobalSearchEvent.SearchHistoryResultSelected(SearchHistoryResultItem.Query("Test"))) + + assertThat(awaitItem().queryState.text.toString()).isEqualTo("Test") + assertThat(awaitItem().results.isLoading()).isTrue() + } + } } private fun TestScope.createGlobalSearchPresenter( @@ -290,6 +378,7 @@ private fun TestScope.createGlobalSearchPresenter( fileSizeFormatter: FileSizeFormatter = FakeFileSizeFormatter(), permalinkParser: PermalinkParser = FakePermalinkParser(), matrixClient: MatrixClient = FakeMatrixClient(), + searchHistoryStore: SearchHistoryStore = FakeSearchHistoryStore(), ): GlobalSearchPresenter { return GlobalSearchPresenter( roomListSearchDataSourceFactory = object : RoomListSearchDataSource.Factory { @@ -310,5 +399,6 @@ private fun TestScope.createGlobalSearchPresenter( permalinkParser = permalinkParser, coroutineDispatchers = testCoroutineDispatchers(), matrixClient = matrixClient, + searchHistoryStore = searchHistoryStore, ) } diff --git a/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/search/GlobalSearchViewTest.kt b/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/search/GlobalSearchViewTest.kt new file mode 100644 index 0000000000..f63e21496e --- /dev/null +++ b/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/search/GlobalSearchViewTest.kt @@ -0,0 +1,275 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +@file:OptIn(ExperimentalTestApi::class) + +package io.element.android.features.home.impl.search + +import androidx.activity.ComponentActivity +import androidx.compose.foundation.text.input.TextFieldState +import androidx.compose.ui.test.AndroidComposeUiTest +import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.onNodeWithContentDescription +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.v2.runAndroidComposeUiTest +import io.element.android.features.home.impl.R +import io.element.android.features.home.impl.model.aRoomListRoomSummary +import io.element.android.libraries.architecture.AsyncData +import io.element.android.libraries.matrix.api.core.EventId +import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.ui.strings.CommonStrings +import io.element.android.tests.testutils.EnsureCalledOnceWithTwoParams +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.pressBack +import io.element.android.tests.testutils.pressBackKey +import io.element.android.tests.testutils.robolectric.RobolectricTest +import kotlinx.collections.immutable.persistentListOf +import org.junit.Test + +class GlobalSearchViewTest : RobolectricTest() { + @Test + fun `back button - emits the expected Event`() = runAndroidComposeUiTest { + val eventsRecorder = EventsRecorder() + setGlobalSearchView( + state = aGlobalSearchState( + isSearchActive = true, + eventSink = eventsRecorder, + ), + ) + // Remove automatic initial events + eventsRecorder.clear() + pressBack() + eventsRecorder.assertSingle(GlobalSearchEvent.ToggleSearchVisibility) + } + + @Test + fun `back key - with search active toggles the search`() = runAndroidComposeUiTest { + val eventsRecorder = EventsRecorder() + setGlobalSearchView( + state = aGlobalSearchState( + isSearchActive = true, + eventSink = eventsRecorder, + ), + ) + // Remove automatic initial events + eventsRecorder.clear() + pressBackKey() + + // Advance time to let the event be processed, as the search toggle might have some delay (e.g. for the animation) + mainClock.advanceTimeBy(1) + + eventsRecorder.assertSingle(GlobalSearchEvent.ToggleSearchVisibility) + } + + @Test + fun `clear query button - emits the expected Event`() = runAndroidComposeUiTest { + val eventsRecorder = EventsRecorder() + setGlobalSearchView( + state = aGlobalSearchState( + isSearchActive = true, + queryState = TextFieldState("Query"), + eventSink = eventsRecorder, + ), + ) + // Remove automatic initial events + eventsRecorder.clear() + val clearSearch = activity!!.getString(CommonStrings.a11y_clear_search_field) + onNodeWithContentDescription(clearSearch).performClick() + eventsRecorder.assertSingle(GlobalSearchEvent.ClearQuery) + } + + @Test + fun `search target selector - clicking on 'Chats' emits the expected Event`() = runAndroidComposeUiTest { + val eventsRecorder = EventsRecorder() + setGlobalSearchView( + state = aGlobalSearchState( + isSearchActive = true, + currentTarget = GlobalSearchTarget.MESSAGES, + queryState = TextFieldState("Query"), + eventSink = eventsRecorder, + ), + ) + // Remove automatic initial events + eventsRecorder.clear() + clickOn(R.string.search_section_chats) + eventsRecorder.assertSingle(GlobalSearchEvent.UpdateTarget(GlobalSearchTarget.ROOMS)) + } + + @Test + fun `search target selector - clicking on 'Messages' emits the expected Event`() = runAndroidComposeUiTest { + val eventsRecorder = EventsRecorder() + setGlobalSearchView( + state = aGlobalSearchState( + isSearchActive = true, + currentTarget = GlobalSearchTarget.ROOMS, + queryState = TextFieldState("Query"), + eventSink = eventsRecorder, + ), + ) + // Remove automatic initial events + eventsRecorder.clear() + clickOn(R.string.search_section_messages) + eventsRecorder.assertSingle(GlobalSearchEvent.UpdateTarget(GlobalSearchTarget.MESSAGES)) + } + + @Test + fun `clicking on a room result - invokes the callback and saves it to the history`() = runAndroidComposeUiTest { + val roomId = RoomId("!aRoom:server.org") + val eventsRecorder = EventsRecorder() + val onSelectSearchResult = EnsureCalledOnceWithTwoParams(roomId, null) + setGlobalSearchView( + state = aGlobalSearchState( + isSearchActive = true, + currentTarget = GlobalSearchTarget.ROOMS, + queryState = TextFieldState("Query"), + results = AsyncData.Success( + GlobalSearchResults.RoomListResults( + persistentListOf( + aRoomListRoomSummary(id = roomId.value, name = "A room result"), + ) + ) + ), + eventSink = eventsRecorder, + ), + onSelectSearchResult = onSelectSearchResult, + ) + // Remove automatic initial events + eventsRecorder.clear() + onNodeWithText("A room result").performClick() + onSelectSearchResult.assertSuccess() + eventsRecorder.assertSingle(GlobalSearchEvent.SaveRoomToHistory(roomId)) + } + + @Test + fun `clicking on a message result - invokes the callback and saves it to the history`() = runAndroidComposeUiTest { + val roomId = RoomId("!aMessageRoom:server.org") + val eventId = EventId("\$anEvent:server.org") + val eventsRecorder = EventsRecorder() + val onSelectSearchResult = EnsureCalledOnceWithTwoParams(roomId, eventId) + setGlobalSearchView( + state = aGlobalSearchState( + isSearchActive = true, + currentTarget = GlobalSearchTarget.MESSAGES, + queryState = TextFieldState("Query"), + results = AsyncData.Success( + GlobalSearchResults.MessageSearchResults( + persistentListOf( + MessageSearchResultItem.Message( + messageSearchResult = aMessageSearchResult(roomId = roomId, eventId = eventId), + body = "A message body", + roomInfo = aRoomInfo(id = roomId, name = "A message room"), + formattedTimestamp = "12:00", + ), + ) + ) + ), + eventSink = eventsRecorder, + ), + onSelectSearchResult = onSelectSearchResult, + ) + // Remove automatic initial events + eventsRecorder.clear() + onNodeWithText("A message room").performClick() + onSelectSearchResult.assertSuccess() + eventsRecorder.assertSingle(GlobalSearchEvent.SaveRoomToHistory(roomId)) + } + + @Test + fun `clicking on a history query result - emits the expected Event`() = runAndroidComposeUiTest { + val queryResult = SearchHistoryResultItem.Query("Recent term") + val eventsRecorder = EventsRecorder() + setGlobalSearchView( + state = aGlobalSearchState( + isSearchActive = true, + queryState = TextFieldState(), + results = AsyncData.Uninitialized, + history = AsyncData.Success(persistentListOf(queryResult)), + eventSink = eventsRecorder, + ), + // A query history result must not trigger the search result callback + onSelectSearchResult = EnsureNeverCalledWithTwoParams(), + ) + // Remove automatic initial events + eventsRecorder.clear() + onNodeWithText("Recent term").performClick() + eventsRecorder.assertSingle(GlobalSearchEvent.SearchHistoryResultSelected(queryResult)) + } + + @Test + fun `clicking on a history room result - invokes the callback and emits the expected Events`() = runAndroidComposeUiTest { + val roomId = RoomId("!aHistoryRoom:server.org") + val roomResult = SearchHistoryResultItem.Room( + roomId = roomId, + roomInfo = aRoomInfo(id = roomId, name = "A history room"), + ) + val eventsRecorder = EventsRecorder() + val onSelectSearchResult = EnsureCalledOnceWithTwoParams(roomId, null) + setGlobalSearchView( + state = aGlobalSearchState( + isSearchActive = true, + queryState = TextFieldState(), + results = AsyncData.Uninitialized, + history = AsyncData.Success(persistentListOf(roomResult)), + eventSink = eventsRecorder, + ), + onSelectSearchResult = onSelectSearchResult, + ) + // Remove automatic initial events + eventsRecorder.clear() + onNodeWithText("A history room").performClick() + onSelectSearchResult.assertSuccess() + eventsRecorder.assertList( + listOf( + GlobalSearchEvent.SaveRoomToHistory(roomId), + GlobalSearchEvent.SearchHistoryResultSelected(roomResult), + ) + ) + } + + @Test + fun `with a query and uninitialized results - displays the start searching placeholder`() = runAndroidComposeUiTest { + setGlobalSearchView( + state = aGlobalSearchState( + isSearchActive = true, + queryState = TextFieldState("Query"), + results = AsyncData.Uninitialized, + ), + ) + val startSearching = activity!!.getString(R.string.start_searching_title) + onNodeWithText(startSearching).assertIsDisplayed() + } + + @Test + fun `with empty results - displays the no results placeholder`() = runAndroidComposeUiTest { + setGlobalSearchView( + state = aGlobalSearchState( + isSearchActive = true, + currentTarget = GlobalSearchTarget.ROOMS, + queryState = TextFieldState("Query"), + results = AsyncData.Success(GlobalSearchResults.RoomListResults(persistentListOf())), + ), + ) + val noResults = activity!!.getString(R.string.search_no_results_title) + onNodeWithText(noResults).assertIsDisplayed() + } + + private fun AndroidComposeUiTest.setGlobalSearchView( + state: GlobalSearchState, + onSelectSearchResult: (RoomId, EventId?) -> Unit = EnsureNeverCalledWithTwoParams(), + ) { + setContent { + GlobalSearchView( + state = state, + onSelectSearchResult = onSelectSearchResult, + ) + } + } +} diff --git a/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/search/history/DefaultSearchHistoryStoreTest.kt b/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/search/history/DefaultSearchHistoryStoreTest.kt new file mode 100644 index 0000000000..a0a63755dc --- /dev/null +++ b/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/search/history/DefaultSearchHistoryStoreTest.kt @@ -0,0 +1,121 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.home.impl.search.history + +import com.google.common.truth.Truth.assertThat +import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.test.A_ROOM_ID +import io.element.android.tests.testutils.testCoroutineDispatchers +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runTest +import kotlinx.serialization.json.Json +import org.junit.Test + +class DefaultSearchHistoryStoreTest { + @Test + fun `history is empty when nothing has been persisted`() = runTest { + val store = createStore() + assertThat(store.history.first()).isEmpty() + } + + @Test + fun `add prepends new entries so the most recent is first`() = runTest { + val store = createStore() + store.add(SearchHistoryResult.Query("first")) + store.add(SearchHistoryResult.Query("second")) + assertThat(store.history.first()).containsExactly( + SearchHistoryResult.Query("second"), + SearchHistoryResult.Query("first"), + ).inOrder() + } + + @Test + fun `add supports both queries and rooms`() = runTest { + val store = createStore() + store.add(SearchHistoryResult.Query("term")) + store.add(SearchHistoryResult.Room(A_ROOM_ID)) + assertThat(store.history.first()).containsExactly( + SearchHistoryResult.Room(A_ROOM_ID), + SearchHistoryResult.Query("term"), + ).inOrder() + } + + @Test + fun `adding an existing entry moves it to the front without duplicating`() = runTest { + val store = createStore() + store.add(SearchHistoryResult.Query("a")) + store.add(SearchHistoryResult.Query("b")) + store.add(SearchHistoryResult.Query("a")) + assertThat(store.history.first()).containsExactly( + SearchHistoryResult.Query("a"), + SearchHistoryResult.Query("b"), + ).inOrder() + } + + @Test + fun `history is capped to the maximum size, dropping the oldest entries`() = runTest { + val store = createStore() + repeat(MAX_SEARCH_HISTORY_SIZE + 10) { index -> + store.add(SearchHistoryResult.Query("query$index")) + } + val history = store.history.first() + assertThat(history).hasSize(MAX_SEARCH_HISTORY_SIZE) + // Newest entry is first... + assertThat(history.first()).isEqualTo(SearchHistoryResult.Query("query${MAX_SEARCH_HISTORY_SIZE + 9}")) + // ...and the oldest ones have been dropped. + assertThat(history).doesNotContain(SearchHistoryResult.Query("query0")) + assertThat(history.last()).isEqualTo(SearchHistoryResult.Query("query10")) + } + + @Test + fun `persisted history is deserialized back into SearchHistoryResult items`() = runTest { + val fileStore = FakeSearchHistoryFileStore() + createStore(fileStore).apply { + add(SearchHistoryResult.Query("persisted term")) + add(SearchHistoryResult.Room(A_ROOM_ID)) + } + + // A brand new store backed by the same file reads the same content back. + val reloaded = createStore(fileStore) + assertThat(reloaded.history.first()).containsExactly( + SearchHistoryResult.Room(A_ROOM_ID), + SearchHistoryResult.Query("persisted term"), + ).inOrder() + } + + @Test + fun `clear removes every entry and deletes the persisted content`() = runTest { + val fileStore = FakeSearchHistoryFileStore() + val store = createStore(fileStore) + store.add(SearchHistoryResult.Query("term")) + + store.clear() + + assertThat(store.history.first()).isEmpty() + assertThat(fileStore.content).isNull() + } + + @Test + fun `corrupted content is ignored and results in an empty history`() = runTest { + val fileStore = FakeSearchHistoryFileStore(initialContent = "not valid json".toByteArray()) + val store = createStore(fileStore) + assertThat(store.history.first()).isEmpty() + // The store recovers and can still add new entries. + store.add(SearchHistoryResult.Room(RoomId("!recovered:domain"))) + assertThat(store.history.first()).containsExactly(SearchHistoryResult.Room(RoomId("!recovered:domain"))) + } + + private fun TestScope.createStore( + fileStore: SearchHistoryFileStore = FakeSearchHistoryFileStore(), + ) = DefaultSearchHistoryStore( + fileStore = fileStore, + dispatchers = testCoroutineDispatchers(), + jsonProvider = { Json { ignoreUnknownKeys = true } } + ) +} diff --git a/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/search/history/FakeSearchHistoryFileStore.kt b/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/search/history/FakeSearchHistoryFileStore.kt new file mode 100644 index 0000000000..ef8ce19b1f --- /dev/null +++ b/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/search/history/FakeSearchHistoryFileStore.kt @@ -0,0 +1,33 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.home.impl.search.history + +/** + * In-memory [SearchHistoryFileStore] used to test [DefaultSearchHistoryStore] without the Android Keystore. + */ +class FakeSearchHistoryFileStore( + initialContent: ByteArray? = null, +) : SearchHistoryFileStore { + var content: ByteArray? = initialContent + private set + + var writeCount = 0 + private set + + override fun read(): ByteArray? = content + + override fun write(bytes: ByteArray) { + content = bytes + writeCount++ + } + + override fun delete(): Boolean { + content = null + return true + } +} diff --git a/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/search/history/FakeSearchHistoryStore.kt b/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/search/history/FakeSearchHistoryStore.kt new file mode 100644 index 0000000000..14d6a7ce65 --- /dev/null +++ b/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/search/history/FakeSearchHistoryStore.kt @@ -0,0 +1,28 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.home.impl.search.history + +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow + +class FakeSearchHistoryStore : SearchHistoryStore { + private val state = MutableStateFlow>(emptyList()) + + override val history: Flow> = state + + override suspend fun add(result: SearchHistoryResult) { + state.value = buildList { + add(result) + addAll(state.value.filterNot { it == result }) + }.take(MAX_SEARCH_HISTORY_SIZE) + } + + override suspend fun clear() { + state.value = emptyList() + } +} diff --git a/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/search/history/SearchHistoryResultSerializationTest.kt b/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/search/history/SearchHistoryResultSerializationTest.kt new file mode 100644 index 0000000000..95a649d88b --- /dev/null +++ b/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/search/history/SearchHistoryResultSerializationTest.kt @@ -0,0 +1,50 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.home.impl.search.history + +import com.google.common.truth.Truth.assertThat +import io.element.android.libraries.matrix.test.A_ROOM_ID +import io.element.android.libraries.matrix.test.A_ROOM_ID_2 +import kotlinx.serialization.json.Json +import org.junit.Test + +class SearchHistoryResultSerializationTest { + private val json = Json + + @Test + fun `Query round-trips through serialization`() { + val original: SearchHistoryResult = SearchHistoryResult.Query("hello world") + val decoded = json.decodeFromString(json.encodeToString(original)) + assertThat(decoded).isEqualTo(original) + } + + @Test + fun `Room round-trips through serialization`() { + val original: SearchHistoryResult = SearchHistoryResult.Room(A_ROOM_ID) + val decoded = json.decodeFromString(json.encodeToString(original)) + assertThat(decoded).isEqualTo(original) + } + + @Test + fun `RoomId is serialized as its raw string value`() { + val encoded = json.encodeToString(SearchHistoryResult.Room(A_ROOM_ID)) + assertThat(encoded).contains(A_ROOM_ID.value) + } + + @Test + fun `a mixed list round-trips preserving order`() { + val original = listOf( + SearchHistoryResult.Query("first"), + SearchHistoryResult.Room(A_ROOM_ID), + SearchHistoryResult.Query("second"), + SearchHistoryResult.Room(A_ROOM_ID_2), + ) + val decoded = json.decodeFromString>(json.encodeToString(original)) + assertThat(decoded).containsExactlyElementsIn(original).inOrder() + } +} 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 25f7765a7c..a1ab73486d 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 @@ -107,6 +107,7 @@ sealed interface LoadedDetails { ) : LoadedDetails } +@Immutable sealed interface JoinAuthorisationStatus { data object None : JoinAuthorisationStatus data class IsInvited(val inviteData: InviteData, val inviteSender: InviteSender?) : JoinAuthorisationStatus 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 3dd964610a..663b4e440f 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 @@ -40,8 +40,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.invite.api.InviteData +import io.element.android.libraries.designsystem.atomic.atoms.CopiableTextAtom import io.element.android.libraries.designsystem.atomic.atoms.PlaceholderAtom -import io.element.android.libraries.designsystem.atomic.atoms.RoomPreviewAliasAtom import io.element.android.libraries.designsystem.atomic.atoms.RoomPreviewDescriptionAtom import io.element.android.libraries.designsystem.atomic.atoms.RoomPreviewTitleAtom import io.element.android.libraries.designsystem.atomic.molecules.ButtonRowMolecule @@ -514,7 +514,7 @@ private fun IncompleteContent( title = { when (roomIdOrAlias) { is RoomIdOrAlias.Alias -> { - RoomPreviewAliasAtom(roomIdOrAlias.identifier) + CopiableTextAtom(roomIdOrAlias.identifier) } is RoomIdOrAlias.Id -> { PlaceholderAtom(width = 200.dp, height = 22.dp) @@ -567,7 +567,7 @@ private fun DefaultLoadedContent( }, subtitle = { if (contentState.alias != null) { - RoomPreviewAliasAtom(contentState.alias.value) + CopiableTextAtom(contentState.alias.value) } if (contentState.details is LoadedDetails.Space) { Spacer(Modifier.height(8.dp)) 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 85403339e8..a3f9ce09d0 100644 --- a/features/joinroom/impl/src/main/res/values-cs/translations.xml +++ b/features/joinroom/impl/src/main/res/values-cs/translations.xml @@ -27,7 +27,7 @@ "Náhled této místnosti jsme nemohli zobrazit" "%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." + "Klepně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?" "Náhled není k dispozici" diff --git a/features/joinroom/impl/src/main/res/values-fi/translations.xml b/features/joinroom/impl/src/main/res/values-fi/translations.xml index 38ba043955..025b3de97b 100644 --- a/features/joinroom/impl/src/main/res/values-fi/translations.xml +++ b/features/joinroom/impl/src/main/res/values-fi/translations.xml @@ -27,7 +27,7 @@ "Emme voineet näyttää tämän huoneen esikatselua" "%1$s ei tue vielä tiloja. Voit käyttää tiloja selainversiolla." "Tiloja ei vielä tueta" - "Paina alla olevaa nappia ja huoneen ylläpitäjä saa ilmoituksen. Voit liittyä keskusteluun kun pyyntösi on hyväksytty." + "Paina alla olevaa nappia ja keskustelun ylläpitäjä saa ilmoituksen. Voit liittyä keskusteluun kun pyyntösi on hyväksytty." "Sinun on oltava tämän huoneen jäsen, jotta voit nähdä viestihistorian." "Haluatko liittyä tähän huoneeseen?" "Esikatselu ei ole saatavilla" diff --git a/features/joinroom/impl/src/main/res/values-zh-rTW/translations.xml b/features/joinroom/impl/src/main/res/values-zh-rTW/translations.xml index 00887ad12a..0e0858c4cb 100644 --- a/features/joinroom/impl/src/main/res/values-zh-rTW/translations.xml +++ b/features/joinroom/impl/src/main/res/values-zh-rTW/translations.xml @@ -27,7 +27,7 @@ "我們無法顯示此聊天室的預覽" "%1$s 尚未支援空間。您可以在網頁上存取空間。" "尚未支援空間" - "點選下方按鈕將會通知聊天試管里員。一旦核准,您就可以加入對話。" + "輕點下方的按鈕,系統將通知聊天室管理員。經核准後,您即可加入對話。" "您必須是此聊天室的成員才能檢視訊息歷史紀錄。" "想要加入此聊天室?" "無法使用預覽" diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationPresenter.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationPresenter.kt index ccdf598e93..05af96fe5e 100644 --- a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationPresenter.kt +++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationPresenter.kt @@ -217,8 +217,8 @@ class ShareLocationPresenter( val replyMode = messageComposerContext.composerMode as? MessageComposerMode.Reply val inReplyToEventId = replyMode?.eventId val geoUri = event.location.toGeoUri() - getTimeline().flatMap { - it.sendLocation( + withTimeline { timeline -> + timeline.sendLocation( body = generateBody(geoUri), geoUri = geoUri, description = null, @@ -237,10 +237,22 @@ class ShareLocationPresenter( ) } - private suspend fun getTimeline(): Result { + /** + * Invokes [block] with the [Timeline] matching [timelineMode]. + * + * For a thread, a dedicated timeline is created and closed once [block] returns, since such a + * timeline owns SDK resources. For the other modes, the live timeline is used, and is not + * closed here since it is owned by its room. + */ + private suspend fun withTimeline(block: suspend (Timeline) -> Result): Result { return when (timelineMode) { - is Timeline.Mode.Thread -> room.createTimeline(CreateTimelineParams.Threaded(timelineMode.threadRootId)) - else -> Result.success(room.liveTimeline) + is Timeline.Mode.Thread -> { + room.createTimeline(CreateTimelineParams.Threaded(timelineMode.threadRootId)) + .flatMap { threadedTimeline -> + threadedTimeline.use { block(it) } + } + } + else -> block(room.liveTimeline) } } } diff --git a/features/location/impl/src/main/res/values-el/translations.xml b/features/location/impl/src/main/res/values-el/translations.xml index 8ae7c58c74..8dedb9a1eb 100644 --- a/features/location/impl/src/main/res/values-el/translations.xml +++ b/features/location/impl/src/main/res/values-el/translations.xml @@ -2,4 +2,5 @@ "Το ιστορικό ζωντανής τοποθεσίας σας θα αποθηκευτεί στην αίθουσα και θα είναι ορατό στα μέλη μετά το τέλος της συνεδρίας." "Επιλέξτε για πόσο χρονικό διάστημα θα κοινοποιείτε την τρέχουσα τοποθεσία σας." + "Δεν έχετε δικαιώματα για να μοιραστείτε την τρέχουσα τοποθεσία σας σε αυτήν την αίθουσα" diff --git a/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/share/ShareLocationPresenterTest.kt b/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/share/ShareLocationPresenterTest.kt index 7500d29616..129ea1e4cd 100644 --- a/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/share/ShareLocationPresenterTest.kt +++ b/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/share/ShareLocationPresenterTest.kt @@ -708,6 +708,45 @@ class ShareLocationPresenterTest { } } + @Test + fun `ShareStaticLocation in a thread closes the created timeline`() = runTest { + val threadedTimeline = FakeTimeline(mode = Timeline.Mode.Thread(A_THREAD_ID)).apply { + sendLocationLambda = { _, _, _, _, _, _ -> Result.success(Unit) } + } + val liveTimeline = FakeTimeline() + val joinedRoom = FakeJoinedRoom( + liveTimeline = liveTimeline, + createTimelineResult = { Result.success(threadedTimeline) }, + ) + val shareLocationPresenter = createShareLocationPresenter( + joinedRoom = joinedRoom, + timelineMode = Timeline.Mode.Thread(A_THREAD_ID), + ) + fakePermissionsPresenter.givenState( + aPermissionsState( + permissions = PermissionsState.Permissions.AllGranted, + shouldShowRationale = false, + ) + ) + + shareLocationPresenter.test { + val initialState = awaitFirstItem() + + initialState.eventSink( + ShareLocationEvent.ShareStaticLocation( + location = Location(lat = 3.0, lon = 4.0, accuracy = 5.0f), + isPinned = false, + ) + ) + + advanceUntilIdle() + + assertThat(threadedTimeline.closeCounter).isEqualTo(1) + assertThat(liveTimeline.closeCounter).isEqualTo(0) + cancelAndIgnoreRemainingEvents() + } + } + private suspend fun ReceiveTurbine.awaitFirstItem(): T { skipItems(1) return awaitItem() diff --git a/features/login/impl/src/main/res/values-cs/translations.xml b/features/login/impl/src/main/res/values-cs/translations.xml index 3181e823bb..020ccd04d1 100644 --- a/features/login/impl/src/main/res/values-cs/translations.xml +++ b/features/login/impl/src/main/res/values-cs/translations.xml @@ -16,7 +16,7 @@ "Google Play" "Na %1$s je vyžadována aplikace Element Pro. Stáhněte si ji prosím z obchodu." "Vyžadován Element Pro" - "Nepodařilo se nám připojit k tomuto domovskému serveru. Zkontrolujte prosím, zda jste správně zadali adresu URL domovského serveru. Pokud je adresa URL správná, obraťte se na správce domovského serveru, který vám poskytne další pomoc." + "Nepodařilo se nám spojit s tímto poskytovatelem účtu. Zkontrolujte prosím, zda jste správně zadali URL adresu poskytovatele účtu. Pokud je URL adresa správná, obraťte se na administrátora poskytovatele účtu, který vám poskytne další pomoc." "Server není k dispozici kvůli problému se souborem well-known: %1$s" "Vybraný poskytovatel účtu nepodporuje klouzavou synchronizaci. Pro použití %1$s je nutná aktualizace serveru." @@ -32,7 +32,7 @@ "Nesprávné uživatelské jméno nebo heslo" "Toto není platný identifikátor uživatele. Očekávaný formát: \'@user:homeserver.org\'" "Tento server je nakonfigurován tak, aby používal obnovovací tokeny. Ty nejsou podporovány při použití přihlašovacích údajů založených na hesle." - "Vybraný domovský server nepodporuje přihlášení pomocí hesla nebo OAuth. Kontaktujte prosím svého správce nebo vyberte jiný domovský server." + "Vybraný poskytovatel účtu nepodporuje přihlášení pomocí hesla ani OAuth. Kontaktujte prosím svého poskytovatele účtu nebo zvolte jiného." "Zadejte své údaje" "Matrix je otevřená síť pro bezpečnou a decentralizovanou komunikaci." "Vítejte zpět!" diff --git a/features/login/impl/src/main/res/values-fi/translations.xml b/features/login/impl/src/main/res/values-fi/translations.xml index 493ddee2f8..2fa571f48e 100644 --- a/features/login/impl/src/main/res/values-fi/translations.xml +++ b/features/login/impl/src/main/res/values-fi/translations.xml @@ -16,7 +16,7 @@ "Google Play" "Element Pro -sovellus on pakollinen %1$s -palvelimella. Lataa se sovelluskaupasta." "Element Pro vaaditaan" - "Kotipalvelimeen ei saatu yhteyttä. Varmista, että olet syöttänyt osoitteen oikein. Jos osoite on oikein, ota yhteyttä palvelimesi ylläpitäjään." + "Palveluntarjoajaan ei saatu yhteyttä. Varmista, että olet syöttänyt osoitteen oikein. Jos osoite on oikein, ota yhteyttä palveluntarjoajasi ylläpitäjään." "Palvelin ei ole saatavilla .well-known tiedostossa olevan ongelman vuoksi: %1$s" "Valitsemasi palveluntarjoaja ei tue sliding syncia. Palvelimen päivitys tarvitaan %1$s -sovelluksen käyttämiseen." @@ -40,7 +40,7 @@ "Väärä käyttäjänimi ja/tai salasana" "Tämä ei ole kelvollinen käyttäjätunnus. Odotettu muoto: \'@käyttäjä:kotipalvelin.fi\'" "Tämä palvelin on määritetty käyttämään refresh tokeneja. Näitä ei tueta salasanapohjaisen kirjautumisen kanssa." - "Valitsemasi kotipalvelin ei tue salasana- tai OAuth-kirjautumista. Ota yhteyttä palvelimesi ylläpitäjään tai valitse toinen kotipalvelin." + "Valitsemasi palveluntarjoaja ei tue salasana- tai OAuth-kirjautumista. Ota yhteyttä palveluntarjoajaasi tai valitse toinen." "Anna tietosi" "Matrix on avoin verkko turvallista, hajautettua viestintää varten." "Tervetuloa takaisin!" @@ -89,7 +89,7 @@ Yritä kirjautua sisään manuaalisesti tai skannaa QR-koodi toisella laitteella "%1$s -sovellusta ei tueta" "Valmis skannaamaan" "Avaa %1$s tietokoneella" - "Napsauta avatariasi" + "Klikkaa avatariasi" "Valitse %1$s" "“Yhdistä uusi laite”" "Skannaa QR-koodi tällä laitteella" diff --git a/features/login/impl/src/main/res/values-hu/translations.xml b/features/login/impl/src/main/res/values-hu/translations.xml index 26534cc523..799a922198 100644 --- a/features/login/impl/src/main/res/values-hu/translations.xml +++ b/features/login/impl/src/main/res/values-hu/translations.xml @@ -25,8 +25,16 @@ "A(z) %1$s fiókszolgáltató nem engedélyezett." "Matrix-kiszolgáló webcíme" "Adjon meg egy domaincímet." + "Bejelentkezés" + "Fiók létrehozása" "Mi a kiszolgálója címe?" + "Ha fiókját a vállalata vagy egy közösség biztosítja, akkor módosítsa a fiók szolgáltatóját (például: companyname.com)." + "Hozzon létre ingyenes fiókot a matrix.org oldalon. Ha más szolgáltatónál szeretne fiókot létrehozni, akkor módosítsa a nevet ennek megfelelően (például: communityname.org)." + "Fiókszolgáltató" + "example.com" "Válassza ki a kiszolgálóját" + "Adja meg a fiókszolgáltatóját" + "Válasszon fiókszolgáltatót" "Fiók létrehozása" "Ez a fiók törölve lett." "Helytelen felhasználónév vagy jelszó" diff --git a/features/login/impl/src/main/res/values-ja/translations.xml b/features/login/impl/src/main/res/values-ja/translations.xml index b4d6f16721..256a4439bc 100644 --- a/features/login/impl/src/main/res/values-ja/translations.xml +++ b/features/login/impl/src/main/res/values-ja/translations.xml @@ -16,7 +16,7 @@ "Google Play" "%1$s では Element Pro を使用する必要があります。アプリストアよりダウンロードしてください。" "Element Pro が必要です" - "このホームサーバーに接続できませんでした。正しいURLを入力したことを確認し、問題が継続する場合は、ホームサーバーの管理者に問い合わせてください。" + "このアカウント提供元に接続できませんでした。正しいURLを入力したことを確認し、問題が継続する場合は、アカウント提供元の管理者に問い合わせてください。" ".well-knownファイルに問題があるためサーバーを使用できません: %1$s" "このアカウント提供元は、スライド同期に対応していません。%1$s を使用するにはサーバーのアップグレードが必要です。" "%2$s は %1$s からの接続を許可していません。" @@ -39,7 +39,7 @@ "ユーザー名またはパスワードが違います" "無効なユーザーIDです。正しい形式は \"@ユーザー:ホームサーバー\" です。" "このサーバーはリフレッシュトークンを使用します。パスワードを使用したログインとは併用できません。" - "指定したホームサーバはパスワードまたはOAuthによるログインに対応していません。管理者に問い合わせるか、異なるホームサーバーを使用してください。" + "指定したアカウント提供元はパスワードまたはOAuthによるログインに対応していません。管理者に問い合わせるか、異なるアカウント提供元を使用してください。" "詳細を入力" "Matrix は安全で分散型のオープンなネットワークです。" "お待ちしておりました。" diff --git a/features/login/impl/src/main/res/values-sv/translations.xml b/features/login/impl/src/main/res/values-sv/translations.xml index fe2d443ce3..b287cb9a49 100644 --- a/features/login/impl/src/main/res/values-sv/translations.xml +++ b/features/login/impl/src/main/res/values-sv/translations.xml @@ -16,7 +16,7 @@ "Google Play" "Element Pro-appen krävs på %1$s. Ladda ner den från butiken." "Element Pro krävs" - "Vi kunde inte nå den här hemservern. Kontrollera att du har angett hemserverns URL korrekt. Om URL:en är korrekt kontaktar du administratören för hemservern för ytterligare hjälp." + "Vi kunde inte nå den här kontoleverantören. Kontrollera att du har angett kontoleverantörens URL korrekt. Om URL:en är korrekt kontaktar du administratören för kontoleverantören för ytterligare hjälp." "Sliding Sync är inte tillgängligt på grund av ett problem i .well-known-filen: %1$s" "Den valda kontoleverantören stöder inte sliding sync. En uppgradering till servern behövs för att använda %1$s." @@ -32,7 +32,7 @@ "Felaktigt användarnamn och/eller lösenord" "Detta är inte en giltig användaridentifierare. Förväntat format: \'@användare:hemserver.org\'" "Den här servern är konfigurerad för att använda uppdateringstokens. Dessa stöds inte när du använder lösenordsbaserad inloggning." - "Den valda hemservern stöder inte lösenord eller OAuth-inloggning. Kontakta administratören eller välj en annan hemserver." + "Den valda kontoleverantören stöder inte lösenords- eller OAuth-inloggning. Kontakta kontoleverantören eller välj en annan." "Ange dina uppgifter" "Matrix är ett öppet nätverk för säker, decentraliserad kommunikation." "Välkommen tillbaka!" diff --git a/features/login/impl/src/main/res/values-zh-rTW/translations.xml b/features/login/impl/src/main/res/values-zh-rTW/translations.xml index 3b329c2000..4c0d88ddaa 100644 --- a/features/login/impl/src/main/res/values-zh-rTW/translations.xml +++ b/features/login/impl/src/main/res/values-zh-rTW/translations.xml @@ -16,7 +16,7 @@ "Google Play" "%1$s 需要 Element Pro 應用程式。請從商店下載。" "需要 Element Pro" - "我們無法連線至此家伺服器。請檢查您是否已正確輸入家伺服器 URL。若 URL 正確,請聯絡您家伺服器的管理員以取得進一步協助。" + "我們無法連線至此帳號提供者。請確認您輸入的帳號提供者網址是否正確。如果網址正確,請聯絡您的帳號提供者管理員以取得進一步協助。" "因為 well-known 檔案的問題,伺服器不可用: %1$s" "選定的帳號提供者不支援 sliding sync。必須升級伺服器才能使用 %1$s。" @@ -40,7 +40,7 @@ "不正確的使用者名稱或密碼" "此非有效的使用者識別字串。預期的格式:‘@user:homeserver.org’" "此伺服器已設定為使用重新整理權杖。使用以密碼為基礎的登入方式時,不支援這些功能。" - "選定的家伺服器不支援密碼或 OAuth 登入。請聯絡您的管理員或選擇其他家伺服器。" + "您選擇的帳號提供者不支援密碼或 OAuth 登入。請聯絡您的帳號提供者,或選擇其他帳號提供者。" "輸入您的詳細資料" "Matrix 是一個開放網路,為了安全且去中心化的通訊而生。" "歡迎回來!" diff --git a/features/login/impl/src/main/res/values-zh/translations.xml b/features/login/impl/src/main/res/values-zh/translations.xml index aca4962922..aaf371f748 100644 --- a/features/login/impl/src/main/res/values-zh/translations.xml +++ b/features/login/impl/src/main/res/values-zh/translations.xml @@ -3,7 +3,7 @@ "更改账户提供者" "主服务器地址" "输入搜索词或域名地址。" - "搜索公司、社区或私人服务器。" + "搜索公司、社区或私有服务器。" "查找账户提供者" "这是你的对话将存在的地方,就像你使用邮件提供者来存储电子邮件那样。" "你即将登录到 %s" @@ -108,7 +108,7 @@ "你的账户提供者可能会要求你提供以下代码以验证登录。" "你的验证码" "更改账户提供者" - "专为 Element 员工提供的私人服务器。" + "专为 Element 员工提供的私有服务器。" "Matrix 是一个用于安全、去中心化通信的开放网络。" "这是你的对话将存在的地方,就像你使用邮件提供者来存储电子邮件那样。" "即将登录 %1$s" diff --git a/features/messages/api/src/main/kotlin/io/element/android/features/messages/api/timeline/voicemessages/composer/VoiceMessageComposerStatePreviewParam.kt b/features/messages/api/src/main/kotlin/io/element/android/features/messages/api/timeline/voicemessages/composer/VoiceMessageComposerStatePreviewParam.kt index 2cf698dd84..94365ca01d 100644 --- a/features/messages/api/src/main/kotlin/io/element/android/features/messages/api/timeline/voicemessages/composer/VoiceMessageComposerStatePreviewParam.kt +++ b/features/messages/api/src/main/kotlin/io/element/android/features/messages/api/timeline/voicemessages/composer/VoiceMessageComposerStatePreviewParam.kt @@ -39,5 +39,6 @@ fun aVoiceMessagePreviewState() = VoiceMessageState.Preview( showCursor = false, playbackProgress = 0f, time = 10.seconds, + duration = 10.seconds, waveform = WaveFormSamples.realisticWaveForm, ) diff --git a/features/messages/impl/build.gradle.kts b/features/messages/impl/build.gradle.kts index 0b51a2b80d..882d9f1f97 100644 --- a/features/messages/impl/build.gradle.kts +++ b/features/messages/impl/build.gradle.kts @@ -45,6 +45,7 @@ dependencies { implementation(projects.libraries.uiStrings) implementation(projects.libraries.dateformatter.api) implementation(projects.libraries.eventformatter.api) + implementation(projects.libraries.htmlrenderer.api) implementation(projects.libraries.mediapickers.api) implementation(projects.libraries.mediaviewer.api) implementation(projects.libraries.featureflag.api) @@ -59,6 +60,7 @@ dependencies { implementation(projects.libraries.voicerecorder.api) implementation(projects.libraries.mediaplayer.api) implementation(projects.libraries.push.api) + implementation(projects.libraries.uiCommon) implementation(projects.libraries.uiUtils) implementation(projects.libraries.testtags) implementation(projects.libraries.wellknown.api) @@ -110,4 +112,5 @@ dependencies { testImplementation(projects.libraries.emoji.impl) testImplementation(projects.libraries.emoji.test) testImplementation(projects.libraries.slashcommands.test) + testImplementation(projects.libraries.htmlrenderer.test) } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesEvent.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesEvent.kt index 4d621e417f..38aa5b75ce 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesEvent.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesEvent.kt @@ -18,6 +18,8 @@ sealed interface MessagesEvent { data class ToggleReaction(val emoji: String, val eventOrTransactionId: EventOrTransactionId) : MessagesEvent data class InviteDialogDismissed(val action: InviteDialogAction) : MessagesEvent data class OnUserClicked(val user: MatrixUser) : MessagesEvent + data class ConfirmRedact(val reason: String?) : MessagesEvent + data object CancelRedact : MessagesEvent data object StopLiveLocationShare : MessagesEvent data object ShowLiveLocationShare : MessagesEvent data object MarkAsFullyReadAndExit : MessagesEvent 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 88a90f6c7d..40d62313ec 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 @@ -87,6 +87,7 @@ import io.element.android.libraries.matrix.api.room.alias.matches import io.element.android.libraries.matrix.api.room.joinedRoomMembers import io.element.android.libraries.matrix.api.roomlist.RoomListService import io.element.android.libraries.matrix.api.timeline.Timeline +import io.element.android.libraries.matrix.api.timeline.TimelineProvider import io.element.android.libraries.matrix.api.timeline.item.TimelineItemDebugInfo import io.element.android.libraries.matrix.ui.messages.RoomMemberProfilesCache import io.element.android.libraries.matrix.ui.messages.RoomNamesCache @@ -104,6 +105,7 @@ import kotlinx.coroutines.delay import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.withContext +import kotlinx.parcelize.IgnoredOnParcel import kotlinx.parcelize.Parcelize import kotlin.time.Duration.Companion.milliseconds @@ -180,7 +182,8 @@ class MessagesFlowNode( @Parcelize data class ForwardEvent( val eventId: EventId, - val fromPinnedEvents: Boolean, + @IgnoredOnParcel + val timelineProvider: TimelineProvider? = null, ) : NavTarget @Parcelize @@ -303,8 +306,8 @@ class MessagesFlowNode( backstack.push(NavTarget.EventDebugInfo(eventId, debugInfo)) } - override fun forwardEvent(eventId: EventId) { - backstack.push(NavTarget.ForwardEvent(eventId, fromPinnedEvents = false)) + override fun forwardEvent(eventId: EventId, timelineProvider: TimelineProvider) { + backstack.push(NavTarget.ForwardEvent(eventId, timelineProvider)) } override fun navigateToReportMessage(eventId: EventId, senderId: UserId) { @@ -459,11 +462,8 @@ class MessagesFlowNode( createNode(buildContext, listOf(inputs)) } is NavTarget.ForwardEvent -> { - val timelineProvider = if (navTarget.fromPinnedEvents) { - pinnedEventsTimelineProvider - } else { - timelineController - } + // If no timeline provider is received, assume the live timeline should be used + val timelineProvider = navTarget.timelineProvider ?: timelineController val params = ForwardEntryPoint.Params(navTarget.eventId, timelineProvider) val callback = object : ForwardEntryPoint.Callback { override fun onDone(roomIds: List) { @@ -550,8 +550,8 @@ class MessagesFlowNode( backstack.push(NavTarget.EventDebugInfo(eventId, debugInfo)) } - override fun handleForwardEventClick(eventId: EventId) { - backstack.push(NavTarget.ForwardEvent(eventId = eventId, fromPinnedEvents = true)) + override fun handleForwardEventClick(eventId: EventId, timelineProvider: TimelineProvider) { + backstack.push(NavTarget.ForwardEvent(eventId = eventId, timelineProvider = timelineProvider)) } override fun navigateToThread(threadRootId: ThreadId) { @@ -617,8 +617,8 @@ class MessagesFlowNode( backstack.push(NavTarget.EventDebugInfo(eventId, debugInfo)) } - override fun handleForwardEventClick(eventId: EventId) { - backstack.push(NavTarget.ForwardEvent(eventId, fromPinnedEvents = false)) + override fun handleForwardEventClick(eventId: EventId, timelineProvider: TimelineProvider) { + backstack.push(NavTarget.ForwardEvent(eventId, timelineProvider)) } override fun navigateToReportMessage(eventId: EventId, senderId: UserId) { diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesNavigator.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesNavigator.kt index 6113b68aab..424d8bcd21 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesNavigator.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesNavigator.kt @@ -13,12 +13,13 @@ 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.ThreadId import io.element.android.libraries.matrix.api.core.UserId +import io.element.android.libraries.matrix.api.timeline.TimelineProvider import io.element.android.libraries.matrix.api.timeline.item.TimelineItemDebugInfo import kotlinx.collections.immutable.ImmutableList interface MessagesNavigator { fun navigateToEventDebugInfo(eventId: EventId?, debugInfo: TimelineItemDebugInfo) - fun forwardEvent(eventId: EventId) + fun forwardEvent(eventId: EventId, timelineProvider: TimelineProvider) fun navigateToReportMessage(eventId: EventId, senderId: UserId) fun navigateToEditPoll(eventId: EventId) fun navigateToPreviewAttachments(attachments: ImmutableList, inReplyToEventId: EventId?) 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 e0213f2fb5..62830ef1ba 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 @@ -68,6 +68,7 @@ import io.element.android.libraries.matrix.api.permalink.PermalinkParser import io.element.android.libraries.matrix.api.room.JoinedRoom import io.element.android.libraries.matrix.api.room.alias.matches import io.element.android.libraries.matrix.api.timeline.Timeline +import io.element.android.libraries.matrix.api.timeline.TimelineProvider import io.element.android.libraries.matrix.api.timeline.item.TimelineItemDebugInfo import io.element.android.libraries.matrix.ui.media.contentvalidation.EventContentValidationCache import io.element.android.libraries.matrix.ui.media.contentvalidation.LocalEventContentValidationState @@ -137,7 +138,7 @@ class MessagesNode( fun navigateToRoomMemberDetails(userId: UserId) fun handlePermalinkClick(data: PermalinkData) fun navigateToEventDebugInfo(eventId: EventId?, debugInfo: TimelineItemDebugInfo) - fun forwardEvent(eventId: EventId) + fun forwardEvent(eventId: EventId, timelineProvider: TimelineProvider) fun navigateToReportMessage(eventId: EventId, senderId: UserId) fun navigateToSendLocation() fun navigateToCreatePoll() @@ -220,8 +221,8 @@ class MessagesNode( callback.navigateToEventDebugInfo(eventId, debugInfo) } - override fun forwardEvent(eventId: EventId) { - callback.forwardEvent(eventId) + override fun forwardEvent(eventId: EventId, timelineProvider: TimelineProvider) { + callback.forwardEvent(eventId, timelineProvider) } override fun navigateToReportMessage(eventId: EventId, senderId: UserId) { 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 6a019d865b..98e595c110 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 @@ -58,6 +58,7 @@ import io.element.android.features.roomcall.api.RoomCallState import io.element.android.features.roommembermoderation.api.RoomMemberModerationEvent import io.element.android.features.roommembermoderation.api.RoomMemberModerationState import io.element.android.libraries.androidutils.clipboard.ClipboardHelper +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.coroutine.CoroutineDispatchers @@ -84,6 +85,7 @@ import io.element.android.libraries.matrix.api.room.history.RoomHistoryVisibilit import io.element.android.libraries.matrix.api.room.powerlevels.permissionsAsState import io.element.android.libraries.matrix.api.timeline.Timeline import io.element.android.libraries.matrix.api.timeline.item.event.EventOrTransactionId +import io.element.android.libraries.matrix.api.timeline.item.event.toEventOrTransactionId import io.element.android.libraries.matrix.ui.messages.reply.map import io.element.android.libraries.matrix.ui.model.dmUserStatus import io.element.android.libraries.matrix.ui.model.getAvatarData @@ -176,7 +178,7 @@ class MessagesPresenter( .collectLatest { value = it.toImmutableList() } } - val canOpenThreadList by featureFlagService.isFeatureEnabledFlow(FeatureFlags.RoomThreadList).collectAsState(initial = false) + val canOpenThreadList by featureFlagService.isFeatureEnabledFlow(FeatureFlags.Threads).collectAsState(initial = false) val isCurrentlySharingLiveLocationInRoom by remember { liveLocationShareManager.isCurrentlySharing(room.roomId) }.collectAsState() val userEventPermissions by room.permissionsAsState(UserEventPermissions.DEFAULT) { perms -> @@ -241,6 +243,8 @@ class MessagesPresenter( onPauseOrDispose {} } + val redactEventAction = remember { mutableStateOf>(AsyncAction.Uninitialized) } + fun handleEvent(event: MessagesEvent) { when (event) { is MessagesEvent.HandleAction -> { @@ -251,8 +255,21 @@ class MessagesPresenter( enableTextFormatting = composerState.showTextFormatting, timelineState = timelineState, timelineProtectionState = timelineProtectionState, + redactEventAction = redactEventAction, ) } + is MessagesEvent.ConfirmRedact -> { + val confirming = redactEventAction.value as? MessagesState.ConfirmingRedaction + redactEventAction.value = AsyncAction.Uninitialized + if (confirming != null) { + localCoroutineScope.launch { + redact(confirming.eventId.toEventOrTransactionId(), event.reason?.takeIf { it.isNotBlank() }) + } + } + } + MessagesEvent.CancelRedact -> { + redactEventAction.value = AsyncAction.Uninitialized + } is MessagesEvent.ToggleReaction -> { localCoroutineScope.toggleReaction(event.emoji, event.eventOrTransactionId) } @@ -331,6 +348,7 @@ class MessagesPresenter( hasUnreadThreads = false, ), showLiveLocationShareBanner = isCurrentlySharingLiveLocationInRoom && timelineState.timelineMode !is Timeline.Mode.Thread, + redactEventAction = redactEventAction.value, eventSink = ::handleEvent, ) } @@ -369,12 +387,13 @@ class MessagesPresenter( timelineProtectionState: TimelineProtectionState, enableTextFormatting: Boolean, timelineState: TimelineState, + redactEventAction: MutableState>, ) = launch { when (action) { TimelineItemAction.CopyText -> handleCopyContents(targetEvent) TimelineItemAction.CopyCaption -> handleCopyCaption(targetEvent) TimelineItemAction.CopyLink -> handleCopyLink(targetEvent) - TimelineItemAction.Redact -> handleActionRedact(targetEvent) + TimelineItemAction.Redact -> handleActionRedact(targetEvent, redactEventAction) TimelineItemAction.Edit, TimelineItemAction.EditPoll -> handleActionEdit(targetEvent, composerState, enableTextFormatting) TimelineItemAction.AddCaption -> handleActionAddCaption(targetEvent, composerState) @@ -496,9 +515,19 @@ class MessagesPresenter( ) } - private suspend fun handleActionRedact(event: TimelineItem.Event) { + private suspend fun handleActionRedact(event: TimelineItem.Event, redactEventAction: MutableState>) { + val eventId = event.eventId + if (eventId == null) { + // The message was never sent, so there is nobody to give a reason to. + redact(event.eventOrTransactionId, reason = null) + } else { + redactEventAction.value = MessagesState.ConfirmingRedaction(eventId) + } + } + + private suspend fun redact(eventOrTransactionId: EventOrTransactionId, reason: String?) { timelineController.invokeOnCurrentTimeline { - redactEvent(eventOrTransactionId = event.eventOrTransactionId, reason = null) + redactEvent(eventOrTransactionId = eventOrTransactionId, reason = reason) .onFailure { Timber.e(it) } } } @@ -586,7 +615,7 @@ class MessagesPresenter( private fun handleForwardAction(event: TimelineItem.Event) { if (event.eventId == null) return - navigator.forwardEvent(event.eventId) + navigator.forwardEvent(eventId = event.eventId, timelineProvider = timelineController) } private fun handleReportAction(event: TimelineItem.Event) { diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesState.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesState.kt index d9ec875320..2073b7f847 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesState.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesState.kt @@ -21,9 +21,11 @@ import io.element.android.features.messages.impl.timeline.components.receipt.bot import io.element.android.features.messages.impl.timeline.protection.TimelineProtectionState import io.element.android.features.roomcall.api.RoomCallState import io.element.android.features.roommembermoderation.api.RoomMemberModerationState +import io.element.android.libraries.architecture.AsyncAction import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.designsystem.components.avatar.AvatarData import io.element.android.libraries.designsystem.utils.snackbar.SnackbarMessage +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.encryption.identity.IdentityState import io.element.android.libraries.matrix.api.room.tombstone.SuccessorRoom @@ -61,6 +63,7 @@ data class MessagesState( val successorRoom: SuccessorRoom?, val threads: Threads, val showLiveLocationShareBanner: Boolean, + val redactEventAction: AsyncAction, val eventSink: (MessagesEvent) -> Unit ) { val isTombstoned = successorRoom != null @@ -69,6 +72,11 @@ data class MessagesState( val hasThreads: Boolean, val hasUnreadThreads: Boolean, ) + + /** The user asked to remove [eventId] and has to confirm, optionally giving a reason. */ + data class ConfirmingRedaction( + val eventId: EventId, + ) : AsyncAction.Confirming } /** Type of "shared history" icon to show in the top bar. */ diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesStatePreviewParam.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesStatePreviewParam.kt index d792ab5ed4..53f90ba7d8 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesStatePreviewParam.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesStatePreviewParam.kt @@ -41,10 +41,12 @@ import io.element.android.features.roomcall.api.aStandByCallState import io.element.android.features.roommembermoderation.api.RoomMemberModerationEvent import io.element.android.features.roommembermoderation.api.RoomMemberModerationPermissions import io.element.android.features.roommembermoderation.api.RoomMemberModerationState +import io.element.android.libraries.architecture.AsyncAction import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.designsystem.components.avatar.AvatarData import io.element.android.libraries.designsystem.components.avatar.AvatarSize import io.element.android.libraries.designsystem.preview.ROOM_NAME +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.ThreadId import io.element.android.libraries.matrix.api.encryption.identity.IdentityState @@ -92,6 +94,7 @@ open class MessagesStatePreviewParam : PreviewParameterProvider { composerState = aMessageComposerState(textEditorState = aTextEditorStateMarkdown()), identityChangeState = anIdentityChangeState(listOf(aRoomMemberIdentityStateChange())) ), + aMessagesState(redactEventAction = MessagesState.ConfirmingRedaction(EventId("\$anEventId"))), ) } @@ -131,6 +134,7 @@ fun aMessagesState( isCurrentlySharingLiveLocationInRoom: Boolean = false, dmUserStatus: DisplayedStatus? = null, eventSink: (MessagesEvent) -> Unit = {}, + redactEventAction: AsyncAction = AsyncAction.Uninitialized, ) = MessagesState( roomId = RoomId("!id:domain"), roomName = roomName, @@ -161,6 +165,7 @@ fun aMessagesState( threads = threads, showLiveLocationShareBanner = isCurrentlySharingLiveLocationInRoom, dmUserStatus = dmUserStatus, + redactEventAction = redactEventAction, eventSink = eventSink, ) 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 16ca023d50..f3ece96c2e 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 @@ -104,6 +104,7 @@ import io.element.android.libraries.designsystem.atomic.molecules.ComposerAlertM import io.element.android.libraries.designsystem.components.ExpandableBottomSheetLayout import io.element.android.libraries.designsystem.components.ExpandableBottomSheetLayoutState import io.element.android.libraries.designsystem.components.dialogs.ConfirmationDialog +import io.element.android.libraries.designsystem.components.dialogs.TextFieldDialog import io.element.android.libraries.designsystem.components.rememberExpandableBottomSheetLayoutState import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.PreviewsDayNight @@ -455,6 +456,19 @@ fun MessagesView( state.timelineState.eventSink(TimelineEvent.HideSendFailureDialog) }, ) + + if (state.redactEventAction is MessagesState.ConfirmingRedaction) { + TextFieldDialog( + title = stringResource(R.string.screen_room_confirm_removal_title), + placeholder = stringResource(R.string.screen_room_confirm_removal_reason_placeholder), + supportingText = stringResource(R.string.screen_room_confirm_removal_reason_supporting_text), + value = null, + submitText = stringResource(CommonStrings.action_remove), + destructiveSubmit = true, + onSubmit = { reason -> state.eventSink(MessagesEvent.ConfirmRedact(reason)) }, + onDismissRequest = { state.eventSink(MessagesEvent.CancelRedact) }, + ) + } } @Composable diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListNavigator.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListNavigator.kt index 9633802f77..02c2bf7fa9 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListNavigator.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListNavigator.kt @@ -10,11 +10,12 @@ package io.element.android.features.messages.impl.pinned.list import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.core.ThreadId +import io.element.android.libraries.matrix.api.timeline.TimelineProvider import io.element.android.libraries.matrix.api.timeline.item.TimelineItemDebugInfo interface PinnedMessagesListNavigator { fun viewInTimeline(eventId: EventId) fun navigateToEventDebugInfo(eventId: EventId?, debugInfo: TimelineItemDebugInfo) - fun forwardEvent(eventId: EventId) + fun forwardEvent(eventId: EventId, timelineProvider: TimelineProvider) fun navigateToThread(threadRootId: ThreadId) } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListNode.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListNode.kt index 36ae68de9b..5c6980c8bb 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListNode.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListNode.kt @@ -37,6 +37,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.timeline.Timeline +import io.element.android.libraries.matrix.api.timeline.TimelineProvider import io.element.android.libraries.matrix.api.timeline.item.TimelineItemDebugInfo import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.libraries.ui.utils.a11y.hasExternalKeyboard @@ -60,7 +61,7 @@ class PinnedMessagesListNode( fun viewInTimeline(eventId: EventId) fun handlePermalinkClick(data: PermalinkData.RoomLink) fun navigateToEventDebugInfo(eventId: EventId?, debugInfo: TimelineItemDebugInfo) - fun handleForwardEventClick(eventId: EventId) + fun handleForwardEventClick(eventId: EventId, timelineProvider: TimelineProvider) fun navigateToThread(threadRootId: ThreadId) } @@ -98,8 +99,8 @@ class PinnedMessagesListNode( callback.navigateToEventDebugInfo(eventId, debugInfo) } - override fun forwardEvent(eventId: EventId) { - callback.handleForwardEventClick(eventId) + override fun forwardEvent(eventId: EventId, timelineProvider: TimelineProvider) { + callback.handleForwardEventClick(eventId, timelineProvider) } override fun navigateToThread(threadRootId: ThreadId) { diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListPresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListPresenter.kt index 748fa084f8..2ee4705838 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListPresenter.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListPresenter.kt @@ -107,6 +107,7 @@ class PinnedMessagesListPresenter( val timelineRoomInfo by remember { derivedStateOf { TimelineRoomInfo( + currentUserId = room.sessionId, isDm = roomInfo.isDm, name = roomInfo.name, // We don't need to compute those values @@ -180,7 +181,7 @@ class PinnedMessagesListPresenter( } TimelineItemAction.Forward -> { targetEvent.eventId?.let { eventId -> - navigator.forwardEvent(eventId) + navigator.forwardEvent(eventId, timelineProvider) } } TimelineItemAction.Unpin -> handleUnpinAction(targetEvent) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListView.kt index 5daec3e275..298a783e97 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListView.kt @@ -36,7 +36,6 @@ import io.element.android.features.messages.impl.timeline.components.TimelineIte import io.element.android.features.messages.impl.timeline.components.customreaction.CustomReactionBottomSheet import io.element.android.features.messages.impl.timeline.components.customreaction.CustomReactionEvent import io.element.android.features.messages.impl.timeline.components.event.TimelineItemEventContentView -import io.element.android.features.messages.impl.timeline.components.layout.ContentAvoidingLayoutData import io.element.android.features.messages.impl.timeline.components.reactionsummary.ReactionSummaryEvent import io.element.android.features.messages.impl.timeline.components.reactionsummary.ReactionSummaryView import io.element.android.features.messages.impl.timeline.model.TimelineItem @@ -58,6 +57,7 @@ import io.element.android.libraries.emoji.api.picker.EmojiPickerRenderer import io.element.android.libraries.emoji.api.picker.NoOpEmojiPickerRenderer import io.element.android.libraries.matrix.api.timeline.Timeline import io.element.android.libraries.matrix.api.user.MatrixUser +import io.element.android.libraries.ui.common.layout.ContentAvoidingLayoutData import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.services.analytics.compose.LocalAnalyticsService import io.element.android.services.analyticsproviders.api.trackers.captureInteraction diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/threads/ThreadedMessagesNode.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/threads/ThreadedMessagesNode.kt index b5542ec4be..34f2b1fc74 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/threads/ThreadedMessagesNode.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/threads/ThreadedMessagesNode.kt @@ -70,6 +70,7 @@ import io.element.android.libraries.matrix.api.room.CreateTimelineParams import io.element.android.libraries.matrix.api.room.JoinedRoom import io.element.android.libraries.matrix.api.room.alias.matches import io.element.android.libraries.matrix.api.timeline.Timeline +import io.element.android.libraries.matrix.api.timeline.TimelineProvider import io.element.android.libraries.matrix.api.timeline.item.TimelineItemDebugInfo import io.element.android.libraries.matrix.ui.model.getBestName import io.element.android.libraries.ui.utils.a11y.hasExternalKeyboard @@ -142,7 +143,7 @@ class ThreadedMessagesNode( fun navigateToRoomMemberDetails(userId: UserId) fun handlePermalinkClick(data: PermalinkData) fun navigateToEventDebugInfo(eventId: EventId?, debugInfo: TimelineItemDebugInfo) - fun handleForwardEventClick(eventId: EventId) + fun handleForwardEventClick(eventId: EventId, timelineProvider: TimelineProvider) fun navigateToReportMessage(eventId: EventId, senderId: UserId) fun navigateToSendLocation() fun navigateToCreatePoll() @@ -226,8 +227,8 @@ class ThreadedMessagesNode( callback.navigateToEventDebugInfo(eventId, debugInfo) } - override fun forwardEvent(eventId: EventId) { - callback.handleForwardEventClick(eventId) + override fun forwardEvent(eventId: EventId, timelineProvider: TimelineProvider) { + callback.handleForwardEventClick(eventId, timelineProvider) } override fun navigateToReportMessage(eventId: EventId, senderId: UserId) { diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/LocalComposeTimelineEventRenderer.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/LocalComposeTimelineEventRenderer.kt new file mode 100644 index 0000000000..840ee8c5c7 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/LocalComposeTimelineEventRenderer.kt @@ -0,0 +1,31 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.messages.impl.timeline + +import androidx.compose.runtime.staticCompositionLocalOf +import io.element.android.libraries.matrix.api.core.UserId + +/** + * Local configuration for the timeline event renderer, so it can be accessed from UI components without having to pass it down through parameters. + */ +val LocalTimelineEventRendererConfig = staticCompositionLocalOf { + ComposeLocalTimelineEventRendererConfig( + isComposeRendererEnabled = false, + currentUserId = null, + ) +} + +/** + * Configuration for the timeline event renderer. + */ +data class ComposeLocalTimelineEventRendererConfig( + /** Whether the compose renderer is enabled or not. If `false` (the default option), the view-based renderer will be used instead. */ + val isComposeRendererEnabled: Boolean, + /** The current user ID, if known. This can be used to display UI sent by/owned by the current user differently. */ + val currentUserId: UserId?, +) 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 2fc4351cea..4580f77d59 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 @@ -163,6 +163,9 @@ class TimelinePresenter( val displayJumpToUnread by produceState(false) { value = featureFlagService.isFeatureEnabled(FeatureFlags.JumpToUnread) } + val useNewTimelineEventRenderer by produceState(false) { + value = featureFlagService.isFeatureEnabled(FeatureFlags.NewTimelineEventRenderer) + } val timelineProtectionState = timelineProtectionPresenter.present() @@ -434,6 +437,7 @@ class TimelinePresenter( val timelineRoomInfo by remember(typingNotificationState, roomCallState, roomInfo) { derivedStateOf { TimelineRoomInfo( + currentUserId = room.sessionId, name = roomInfo.name, isDm = roomInfo.isDm, userHasPermissionToSendMessage = userEventPermissions.canSendMessage, @@ -463,6 +467,7 @@ class TimelinePresenter( displayThreadSummaries = displayThreadSummaries, displayJumpToUnread = displayJumpToUnread, jumpToUnread = jumpToUnread.value, + useNewTimelineEventRenderer = useNewTimelineEventRenderer, eventSink = ::handleEvent, ) } 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 1ebc96f15f..9376747715 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 @@ -18,6 +18,7 @@ import io.element.android.features.messages.impl.typing.TypingNotificationState import io.element.android.features.roomcall.api.RoomCallState import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.core.UniqueId +import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.room.tombstone.PredecessorRoom import io.element.android.libraries.matrix.api.timeline.Timeline import kotlinx.collections.immutable.ImmutableList @@ -37,6 +38,8 @@ data class TimelineState( val displayThreadSummaries: Boolean, val displayJumpToUnread: Boolean, val jumpToUnread: JumpToUnreadState, + // When true, formatted message bodies are rendered with the native Compose renderer. + val useNewTimelineEventRenderer: Boolean, val eventSink: (TimelineEvent) -> Unit, ) { private val lastTimelineEvent = timelineItems.firstOrNull { it is TimelineItem.Event } as? TimelineItem.Event @@ -76,6 +79,7 @@ sealed interface FocusRequestState { } data class TimelineRoomInfo( + val currentUserId: UserId, val isDm: Boolean, val name: String?, val userHasPermissionToSendMessage: 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 89545b5d0b..af5bca7b2d 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 @@ -63,6 +63,7 @@ fun aTimelineState( displayJumpToUnread: Boolean = false, jumpToUnread: JumpToUnreadState = JumpToUnreadState.Hidden, newEventState: NewEventState = NewEventState.None, + useNewTimelineEventRenderer: Boolean = false, eventSink: (TimelineEvent) -> Unit = {}, ): TimelineState { val focusedEventId = timelineItems.filterIsInstance().getOrNull(focusedEventIndex)?.eventId @@ -84,6 +85,7 @@ fun aTimelineState( displayThreadSummaries = displayThreadSummaries, displayJumpToUnread = displayJumpToUnread, jumpToUnread = jumpToUnread, + useNewTimelineEventRenderer = useNewTimelineEventRenderer, eventSink = eventSink, ) } @@ -277,6 +279,7 @@ internal fun aRedactedMessagesGroupedEvents( } internal fun aTimelineRoomInfo( + currentUserId: UserId = UserId("@user:domain"), name: String = ROOM_NAME, isDm: Boolean = false, userHasPermissionToSendMessage: Boolean = true, @@ -284,6 +287,7 @@ internal fun aTimelineRoomInfo( typingNotificationState: TypingNotificationState = aTypingNotificationState(), predecessorRoom: PredecessorRoom? = null, ) = TimelineRoomInfo( + currentUserId = currentUserId, isDm = isDm, name = name, userHasPermissionToSendMessage = userHasPermissionToSendMessage, 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 5e7fd34723..ed53a9f885 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 @@ -98,7 +98,6 @@ import io.element.android.libraries.designsystem.atomic.atoms.UnreadIndicatorAto import io.element.android.libraries.designsystem.components.dialogs.AlertDialog import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.PreviewsDayNight -import io.element.android.libraries.designsystem.text.roundToPx import io.element.android.libraries.designsystem.theme.components.Icon import io.element.android.libraries.designsystem.theme.components.Text import io.element.android.libraries.designsystem.utils.animateScrollToItemCenter @@ -191,80 +190,88 @@ fun TimelineView( // Animate alpha when timeline is first displayed, to avoid flashes or glitching when viewing rooms AnimatedVisibility(visible = true, enter = fadeIn()) { - Box(modifier) { - LazyColumn( - modifier = Modifier - .fillMaxSize() - .nestedScroll(nestedScrollConnection) - .testTag(TestTags.timeline), - state = lazyListState, - reverseLayout = true, - contentPadding = WindowInsets.safeDrawing.only(WindowInsetsSides.Horizontal).asPaddingValues() + PaddingValues(top = 64.dp, bottom = 8.dp), - ) { - items( - items = state.timelineItems, - contentType = { timelineItem -> timelineItem.contentType() }, - key = { timelineItem -> timelineItem.identifier() }, - ) { timelineItem -> - TimelineItemRow( - timelineItem = timelineItem, - timelineMode = state.timelineMode, - timelineRoomInfo = state.timelineRoomInfo, - timelineProtectionState = timelineProtectionState, - isLastOutgoingMessage = state.isLastOutgoingMessage(timelineItem.identifier()), - focusedEventId = state.focusedEventId, - displayThreadSummaries = state.displayThreadSummaries, - onUserDataClick = onUserDataClick, - onLinkClick = onLinkClick, - onLinkLongClick = ::onLinkLongClick, - onContentClick = onContentClick, - onGalleryItemClick = onGalleryItemClick, - onLongClick = onMessageLongClick, - inReplyToClick = ::inReplyToClick, - onReactionClick = onReactionClick, - onReactionLongClick = onReactionLongClick, - onMoreReactionsClick = onMoreReactionsClick, - onReadReceiptClick = onReadReceiptClick, - onSwipeToReply = onSwipeToReply, - onJoinCallClick = onJoinCallClick, - eventSink = state.eventSink, - ) + val composeLocalTimelineEventRendererConfig = remember(state.useNewTimelineEventRenderer, state.timelineRoomInfo.currentUserId) { + ComposeLocalTimelineEventRendererConfig( + isComposeRendererEnabled = state.useNewTimelineEventRenderer, + currentUserId = state.timelineRoomInfo.currentUserId, + ) + } + CompositionLocalProvider(LocalTimelineEventRendererConfig provides composeLocalTimelineEventRendererConfig) { + Box(modifier) { + LazyColumn( + modifier = Modifier + .fillMaxSize() + .nestedScroll(nestedScrollConnection) + .testTag(TestTags.timeline), + state = lazyListState, + reverseLayout = true, + contentPadding = WindowInsets.safeDrawing.only(WindowInsetsSides.Horizontal).asPaddingValues() + PaddingValues(top = 64.dp, bottom = 8.dp), + ) { + items( + items = state.timelineItems, + contentType = { timelineItem -> timelineItem.contentType() }, + key = { timelineItem -> timelineItem.identifier() }, + ) { timelineItem -> + TimelineItemRow( + timelineItem = timelineItem, + timelineMode = state.timelineMode, + timelineRoomInfo = state.timelineRoomInfo, + timelineProtectionState = timelineProtectionState, + isLastOutgoingMessage = state.isLastOutgoingMessage(timelineItem.identifier()), + focusedEventId = state.focusedEventId, + displayThreadSummaries = state.displayThreadSummaries, + onUserDataClick = onUserDataClick, + onLinkClick = onLinkClick, + onLinkLongClick = ::onLinkLongClick, + onContentClick = onContentClick, + onGalleryItemClick = onGalleryItemClick, + onLongClick = onMessageLongClick, + inReplyToClick = ::inReplyToClick, + onReactionClick = onReactionClick, + onReactionLongClick = onReactionLongClick, + onMoreReactionsClick = onMoreReactionsClick, + onReadReceiptClick = onReadReceiptClick, + onSwipeToReply = onSwipeToReply, + onJoinCallClick = onJoinCallClick, + eventSink = state.eventSink, + ) + } } + + FocusRequestStateView( + focusRequestState = state.focusRequestState, + onClearFocusRequestState = ::clearFocusRequestState + ) + + TimelinePrefetchingHelper( + lazyListState = lazyListState, + prefetch = ::prefetchMoreItems + ) + + TimelineScrollHelper( + hasAnyEvent = state.hasAnyEvent, + lazyListState = lazyListState, + forceJumpToBottomVisibility = forceJumpToBottomVisibility, + forceJumpToReadMarkerVisibility = forceJumpToReadMarkerVisibility, + newEventState = state.newEventState, + isLive = state.isLive, + focusRequestState = state.focusRequestState, + displayJumpToUnread = state.displayJumpToUnread, + jumpToUnread = state.jumpToUnread, + onScrollFinishAt = ::onScrollFinishAt, + onJumpToLive = ::onJumpToLive, + onFocusEventRender = ::onFocusEventRender, + onMarkAllAsRead = ::onMarkAllAsRead, + onFocusOnEvent = ::onFocusOnEvent, + ) + + FloatingDateBadgeOverlay( + lazyListState = lazyListState, + timelineItems = state.timelineItems, + isLive = state.isLive, + topOffset = floatingDateTopOffset, + ) } - - FocusRequestStateView( - focusRequestState = state.focusRequestState, - onClearFocusRequestState = ::clearFocusRequestState - ) - - TimelinePrefetchingHelper( - lazyListState = lazyListState, - prefetch = ::prefetchMoreItems - ) - - TimelineScrollHelper( - hasAnyEvent = state.hasAnyEvent, - lazyListState = lazyListState, - forceJumpToBottomVisibility = forceJumpToBottomVisibility, - forceJumpToReadMarkerVisibility = forceJumpToReadMarkerVisibility, - newEventState = state.newEventState, - isLive = state.isLive, - focusRequestState = state.focusRequestState, - displayJumpToUnread = state.displayJumpToUnread, - jumpToUnread = state.jumpToUnread, - onScrollFinishAt = ::onScrollFinishAt, - onJumpToLive = ::onJumpToLive, - onFocusEventRender = ::onFocusEventRender, - onMarkAllAsRead = ::onMarkAllAsRead, - onFocusOnEvent = ::onFocusOnEvent, - ) - - FloatingDateBadgeOverlay( - lazyListState = lazyListState, - timelineItems = state.timelineItems, - isLive = state.isLive, - topOffset = floatingDateTopOffset, - ) } } 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 99e72f4973..97ec71173a 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 @@ -72,8 +72,6 @@ import io.element.android.features.messages.impl.timeline.TimelineEvent import io.element.android.features.messages.impl.timeline.TimelineRoomInfo import io.element.android.features.messages.impl.timeline.aTimelineItemEvent import io.element.android.features.messages.impl.timeline.components.event.TimelineItemEventContentView -import io.element.android.features.messages.impl.timeline.components.layout.ContentAvoidingLayout -import io.element.android.features.messages.impl.timeline.components.layout.ContentAvoidingLayoutData import io.element.android.features.messages.impl.timeline.components.receipt.ReadReceiptViewState import io.element.android.features.messages.impl.timeline.components.receipt.TimelineItemReadReceiptView import io.element.android.features.messages.impl.timeline.model.TimelineItem @@ -139,6 +137,8 @@ import io.element.android.libraries.matrix.ui.messages.sender.SenderName import io.element.android.libraries.matrix.ui.messages.sender.SenderNameMode import io.element.android.libraries.testtags.TestTags import io.element.android.libraries.testtags.testTag +import io.element.android.libraries.ui.common.layout.ContentAvoidingLayout +import io.element.android.libraries.ui.common.layout.ContentAvoidingLayoutData import io.element.android.libraries.ui.strings.CommonPlurals import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.libraries.ui.utils.a11y.isTalkbackActive 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 5b620a7dc3..0128e80b79 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 @@ -23,7 +23,6 @@ import io.element.android.features.messages.impl.timeline.aRedactedMessagesGroup import io.element.android.features.messages.impl.timeline.aTimelineRoomInfo import io.element.android.features.messages.impl.timeline.components.event.TimelineItemEventContentView import io.element.android.features.messages.impl.timeline.components.group.GroupHeaderView -import io.element.android.features.messages.impl.timeline.components.layout.ContentAvoidingLayoutData import io.element.android.features.messages.impl.timeline.components.receipt.ReadReceiptViewState import io.element.android.features.messages.impl.timeline.components.receipt.TimelineItemReadReceiptView import io.element.android.features.messages.impl.timeline.groups.isRedactedMessagesGroup @@ -35,6 +34,7 @@ import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.timeline.Timeline import io.element.android.libraries.matrix.api.user.MatrixUser +import io.element.android.libraries.ui.common.layout.ContentAvoidingLayoutData import io.element.android.wysiwyg.link.Link @Composable 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 fb9659ff70..aa66c262b5 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 @@ -29,7 +29,6 @@ import io.element.android.compound.theme.ElementTheme import io.element.android.features.messages.impl.timeline.TimelineEvent import io.element.android.features.messages.impl.timeline.TimelineRoomInfo import io.element.android.features.messages.impl.timeline.components.event.TimelineItemEventContentView -import io.element.android.features.messages.impl.timeline.components.layout.ContentAvoidingLayoutData import io.element.android.features.messages.impl.timeline.model.TimelineItem import io.element.android.features.messages.impl.timeline.model.event.RtcNotificationState import io.element.android.features.messages.impl.timeline.model.event.TimelineItemLegacyCallInviteContent @@ -46,6 +45,7 @@ import io.element.android.libraries.designsystem.text.toPx import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.timeline.Timeline import io.element.android.libraries.matrix.api.user.MatrixUser +import io.element.android.libraries.ui.common.layout.ContentAvoidingLayoutData import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.libraries.ui.utils.a11y.isTalkbackActive import io.element.android.wysiwyg.link.Link diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemAttachmentView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemAttachmentView.kt index 38d575718f..586ff6cfe5 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemAttachmentView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemAttachmentView.kt @@ -22,12 +22,12 @@ import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import io.element.android.compound.theme.ElementTheme -import io.element.android.features.messages.impl.timeline.components.layout.ContentAvoidingLayout -import io.element.android.features.messages.impl.timeline.components.layout.ContentAvoidingLayoutData import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator import io.element.android.libraries.designsystem.theme.components.Icon import io.element.android.libraries.designsystem.theme.components.Text import io.element.android.libraries.matrix.ui.media.contentvalidation.ContentValidationValue +import io.element.android.libraries.ui.common.layout.ContentAvoidingLayout +import io.element.android.libraries.ui.common.layout.ContentAvoidingLayoutData /** * package-private, you should only use TimelineItemFileView and TimelineItemAudioView. diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemAudioView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemAudioView.kt index d47ca552b8..a6c6afcbc9 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemAudioView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemAudioView.kt @@ -12,11 +12,11 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.tooling.preview.PreviewParameter import io.element.android.compound.tokens.generated.CompoundIcons -import io.element.android.features.messages.impl.timeline.components.layout.ContentAvoidingLayoutData import io.element.android.features.messages.impl.timeline.model.event.TimelineItemAudioContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemAudioContentPreviewParam import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.matrix.ui.media.contentvalidation.ContentValidationValue +import io.element.android.libraries.ui.common.layout.ContentAvoidingLayoutData @Composable fun TimelineItemAudioView( 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 6b9022d3cf..38920af287 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 @@ -12,7 +12,6 @@ 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.TimelineItemEncryptedContentPreviewParam import io.element.android.libraries.designsystem.icons.CompoundDrawables @@ -20,6 +19,7 @@ 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.common.layout.ContentAvoidingLayoutData import io.element.android.libraries.ui.strings.CommonStrings @Composable diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemEventContentView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemEventContentView.kt index 4b7ab9372d..839df1d320 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemEventContentView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemEventContentView.kt @@ -24,8 +24,6 @@ import androidx.compose.ui.platform.LocalInspectionMode import androidx.compose.ui.unit.dp import io.element.android.compound.theme.ElementTheme import io.element.android.features.messages.impl.timeline.TimelineEvent -import io.element.android.features.messages.impl.timeline.components.layout.ContentAvoidingLayout -import io.element.android.features.messages.impl.timeline.components.layout.ContentAvoidingLayoutData import io.element.android.features.messages.impl.timeline.di.LocalTimelineItemPresenterFactories import io.element.android.features.messages.impl.timeline.di.rememberPresenter import io.element.android.features.messages.impl.timeline.model.event.TimelineItemAttachmentsContent @@ -60,6 +58,8 @@ import io.element.android.libraries.matrix.ui.media.contentvalidation.NotFoundCo import io.element.android.libraries.matrix.ui.media.contentvalidation.collectOverallState import io.element.android.libraries.matrix.ui.media.contentvalidation.rememberEventContentValidationState import io.element.android.libraries.textcomposer.ElementRichTextEditorStyle +import io.element.android.libraries.ui.common.layout.ContentAvoidingLayout +import io.element.android.libraries.ui.common.layout.ContentAvoidingLayoutData import io.element.android.libraries.voiceplayer.api.VoiceMessageState import io.element.android.wysiwyg.compose.EditorStyledText import io.element.android.wysiwyg.link.Link diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemFileView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemFileView.kt index 77df9ba923..bc3fe91eca 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemFileView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemFileView.kt @@ -13,11 +13,11 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.PreviewParameter import io.element.android.compound.tokens.generated.CompoundIcons -import io.element.android.features.messages.impl.timeline.components.layout.ContentAvoidingLayoutData import io.element.android.features.messages.impl.timeline.model.event.TimelineItemFileContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemFileContentPreviewParam import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.matrix.ui.media.contentvalidation.ContentValidationValue +import io.element.android.libraries.ui.common.layout.ContentAvoidingLayoutData import io.element.android.libraries.ui.strings.CommonStrings /** diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemInformativeView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemInformativeView.kt index 41a950ee5e..34f22d9d48 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemInformativeView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemInformativeView.kt @@ -22,12 +22,12 @@ import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.unit.dp import io.element.android.compound.theme.ElementTheme -import io.element.android.features.messages.impl.timeline.components.layout.ContentAvoidingLayoutData 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.designsystem.theme.components.Icon import io.element.android.libraries.designsystem.theme.components.Text +import io.element.android.libraries.ui.common.layout.ContentAvoidingLayoutData @Composable fun TimelineItemInformativeView( diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemRedactedView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemRedactedView.kt index e32de7f4ae..70ac84eb31 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemRedactedView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemRedactedView.kt @@ -11,11 +11,11 @@ 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 io.element.android.features.messages.impl.timeline.components.layout.ContentAvoidingLayoutData import io.element.android.features.messages.impl.timeline.model.event.TimelineItemRedactedContent 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.ui.common.layout.ContentAvoidingLayoutData import io.element.android.libraries.ui.strings.CommonStrings @Composable diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemTextView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemTextView.kt index 86b50f3015..8a4f2635a1 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemTextView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemTextView.kt @@ -23,8 +23,7 @@ import androidx.compose.ui.semantics.semantics import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.PreviewParameter import io.element.android.compound.theme.ElementTheme -import io.element.android.features.messages.impl.timeline.components.layout.ContentAvoidingLayout -import io.element.android.features.messages.impl.timeline.components.layout.ContentAvoidingLayoutData +import io.element.android.features.messages.impl.timeline.LocalTimelineEventRendererConfig import io.element.android.features.messages.impl.timeline.model.event.AN_EMOJI_ONLY_TEXT import io.element.android.features.messages.impl.timeline.model.event.TimelineItemTextBasedContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemTextBasedContentPreviewParam @@ -34,8 +33,11 @@ import io.element.android.libraries.androidutils.text.LinkifyHelper import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.designsystem.utils.LocalUiTestMode +import io.element.android.libraries.htmlrenderer.api.ui.HtmlMessageContent import io.element.android.libraries.textcomposer.ElementRichTextEditorStyle import io.element.android.libraries.textcomposer.mentions.LocalMentionSpanUpdater +import io.element.android.libraries.ui.common.layout.ContentAvoidingLayout +import io.element.android.libraries.ui.common.layout.ContentAvoidingLayoutData import io.element.android.wysiwyg.compose.EditorStyledText import io.element.android.wysiwyg.link.Link @@ -50,6 +52,31 @@ fun TimelineItemTextView( // The View <-> Compose interop is not working well with Compose UI tests (it loops indefinitely), so we skip it in the UI test mode. if (LocalUiTestMode.current) return + // When the new timeline event renderer is enabled and the message has a parsed tree, render it + // natively with Compose. Otherwise fall back to the legacy rich text editor view below. + val messageTree = content.messageTree + if (LocalTimelineEventRendererConfig.current.isComposeRendererEnabled && messageTree != null) { + Box(modifier.semantics { contentDescription = content.plainText }) { + HtmlMessageContent( + node = messageTree, + currentUserId = LocalTimelineEventRendererConfig.current.currentUserId, + onLinkClick = { url, text -> onLinkClick(Link(url = url, text = text)) }, + onLinkLongClick = { url, text -> onLinkLongClick(Link(url = url, text = text)) }, + onContentLayoutChange = { data -> + onContentLayoutChange( + ContentAvoidingLayoutData( + contentWidth = data.contentWidth, + contentHeight = data.contentHeight, + nonOverlappingContentWidth = data.nonOverlappingContentWidth, + nonOverlappingContentHeight = data.nonOverlappingContentHeight, + ) + ) + }, + ) + } + return + } + val isInPreview = LocalInspectionMode.current val emojiOnly = remember(content.body, content.formattedBody, isInPreview) { content.formattedBody.toString() == content.body && diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemUnknownView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemUnknownView.kt index fbbefca207..8f375ddb5b 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemUnknownView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemUnknownView.kt @@ -11,11 +11,11 @@ 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 io.element.android.features.messages.impl.timeline.components.layout.ContentAvoidingLayoutData import io.element.android.features.messages.impl.timeline.model.event.TimelineItemUnknownContent 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.ui.common.layout.ContentAvoidingLayoutData import io.element.android.libraries.ui.strings.CommonStrings @Composable diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemVoiceView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemVoiceView.kt index a05ac78f84..77e29c7a90 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemVoiceView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemVoiceView.kt @@ -40,7 +40,6 @@ import androidx.compose.ui.tooling.preview.PreviewParameterProvider 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.layout.ContentAvoidingLayoutData import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVoiceContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVoiceContentPreviewParam import io.element.android.libraries.designsystem.atomic.atoms.PlaybackSpeedButton @@ -52,6 +51,7 @@ import io.element.android.libraries.designsystem.theme.components.Icon import io.element.android.libraries.designsystem.theme.components.IconButton import io.element.android.libraries.designsystem.theme.components.Text import io.element.android.libraries.matrix.ui.media.contentvalidation.ContentValidationValue +import io.element.android.libraries.ui.common.layout.ContentAvoidingLayoutData import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.libraries.ui.utils.a11y.isTalkbackActive import io.element.android.libraries.voiceplayer.api.VoiceMessageEvent @@ -142,6 +142,9 @@ fun TimelineItemVoiceView( modifier = Modifier .weight(1f) .height(34.dp), + isPlaying = state.isPlaying, + durationMs = state.durationMs, + playbackSpeed = state.playbackSpeed, seekEnabled = !isTalkbackActive(), onSeek = { state.eventSink(VoiceMessageEvent.Seek(it)) }, ) 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 75966db1a3..c3586ba225 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 @@ -33,6 +33,9 @@ import io.element.android.features.messages.impl.utils.TextPillificationHelper import io.element.android.libraries.androidutils.filesize.FileSizeFormatter import io.element.android.libraries.androidutils.text.safeLinkify import io.element.android.libraries.core.mimetype.MimeTypes +import io.element.android.libraries.featureflag.api.FeatureFlagService +import io.element.android.libraries.featureflag.api.FeatureFlags +import io.element.android.libraries.htmlrenderer.api.HtmlMessageParser 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 @@ -71,8 +74,10 @@ class TimelineItemContentMessageFactory( private val htmlConverterProvider: HtmlConverterProvider, private val permalinkParser: PermalinkParser, private val textPillificationHelper: TextPillificationHelper, + private val htmlMessageParser: HtmlMessageParser, + private val featureFlagService: FeatureFlagService, ) { - fun create( + suspend fun create( content: MessageContent, senderId: UserId, senderProfile: ProfileDetails, @@ -93,6 +98,28 @@ class TimelineItemContentMessageFactory( htmlDocument = dom, formattedBody = formattedBody, isEdited = content.isEdited, + messageTree = if (featureFlagService.isFeatureEnabled(FeatureFlags.NewTimelineEventRenderer)) { + dom?.let(htmlMessageParser::parse) + } else { + null + }, + ) + } + is TextMessageType -> { + val body = messageType.body.trimEnd() + val htmlDocument = messageType.formatted?.toHtmlDocument(permalinkParser = permalinkParser) + val formattedBody = htmlDocument?.let(::parseHtml) + ?: textPillificationHelper.pillify(body).safeLinkify() + TimelineItemTextContent( + body = body, + htmlDocument = htmlDocument, + formattedBody = formattedBody, + isEdited = content.isEdited, + messageTree = if (featureFlagService.isFeatureEnabled(FeatureFlags.NewTimelineEventRenderer)) { + htmlDocument?.let(htmlMessageParser::parse) + } else { + null + }, ) } is ImageMessageType -> { @@ -251,28 +278,15 @@ class TimelineItemContentMessageFactory( } is NoticeMessageType -> { val body = messageType.body.trimEnd() - val dom = messageType.formatted?.toHtmlDocument(permalinkParser = permalinkParser) - val formattedBody = dom?.let(::parseHtml) - ?: textPillificationHelper.pillify(body).safeLinkify() val htmlDocument = messageType.formatted?.toHtmlDocument(permalinkParser = permalinkParser) + val formattedBody = htmlDocument?.let(::parseHtml) + ?: textPillificationHelper.pillify(body).safeLinkify() TimelineItemNoticeContent( body = body, htmlDocument = htmlDocument, formattedBody = formattedBody, isEdited = content.isEdited, - ) - } - is TextMessageType -> { - val body = messageType.body.trimEnd() - val dom = messageType.formatted?.toHtmlDocument(permalinkParser = permalinkParser) - val formattedBody = dom?.let(::parseHtml) - ?: textPillificationHelper.pillify(body).safeLinkify() - val htmlDocument = messageType.formatted?.toHtmlDocument(permalinkParser = permalinkParser) - TimelineItemTextContent( - body = body, - htmlDocument = htmlDocument, - formattedBody = formattedBody, - isEdited = content.isEdited, + messageTree = htmlDocument?.let(htmlMessageParser::parse), ) } is GalleryMessageType -> { diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemEmoteContent.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemEmoteContent.kt index 28946e8eea..c73c3a3480 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemEmoteContent.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemEmoteContent.kt @@ -8,6 +8,7 @@ package io.element.android.features.messages.impl.timeline.model.event +import io.element.android.libraries.htmlrenderer.api.DocumentNode import io.element.android.libraries.matrix.ui.messages.toPlainText import org.jsoup.nodes.Document @@ -16,6 +17,7 @@ data class TimelineItemEmoteContent( override val htmlDocument: Document?, override val formattedBody: CharSequence, override val isEdited: Boolean, + override val messageTree: DocumentNode? = null, ) : TimelineItemTextBasedContent { override val type: String = "TimelineItemEmoteContent" override val plainText: String = htmlDocument?.toPlainText() ?: body diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemNoticeContent.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemNoticeContent.kt index bb6f6db685..a88ac11e82 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemNoticeContent.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemNoticeContent.kt @@ -8,6 +8,7 @@ package io.element.android.features.messages.impl.timeline.model.event +import io.element.android.libraries.htmlrenderer.api.DocumentNode import io.element.android.libraries.matrix.ui.messages.toPlainText import org.jsoup.nodes.Document @@ -16,6 +17,7 @@ data class TimelineItemNoticeContent( override val htmlDocument: Document?, override val formattedBody: CharSequence, override val isEdited: Boolean, + override val messageTree: DocumentNode? = null, ) : TimelineItemTextBasedContent { override val type: String = "TimelineItemNoticeContent" override val plainText: String = htmlDocument?.toPlainText() ?: body diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemTextBasedContent.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemTextBasedContent.kt index 4d941e73db..fc6d1da4df 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemTextBasedContent.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemTextBasedContent.kt @@ -9,6 +9,7 @@ package io.element.android.features.messages.impl.timeline.model.event import androidx.compose.runtime.Immutable +import io.element.android.libraries.htmlrenderer.api.DocumentNode import org.jsoup.nodes.Document /** @@ -24,6 +25,12 @@ sealed interface TimelineItemTextBasedContent : /** The parsed HTML DOM of the formatted event body. */ val htmlDocument: Document? + /** + * The formatted body parsed into a tree of [DocumentNode] for the native Compose renderer. + * Null when there is no formatted body. Only used when the new timeline event renderer is enabled. + */ + val messageTree: DocumentNode? + /** The formatted body of the event, already parsed and with the DOM translated to Android spans. * This can also includes mention spans from permalink parsing */ val formattedBody: CharSequence diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemTextContent.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemTextContent.kt index 2de0d6cead..a7701b044d 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemTextContent.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemTextContent.kt @@ -8,6 +8,7 @@ package io.element.android.features.messages.impl.timeline.model.event +import io.element.android.libraries.htmlrenderer.api.DocumentNode import io.element.android.libraries.matrix.ui.messages.toPlainText import org.jsoup.nodes.Document @@ -16,6 +17,7 @@ data class TimelineItemTextContent( override val htmlDocument: Document?, override val formattedBody: CharSequence, override val isEdited: Boolean, + override val messageTree: DocumentNode? = null, ) : TimelineItemTextBasedContent { override val type: String = "TimelineItemTextContent" override val plainText: String = htmlDocument?.toPlainText() ?: body diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/composer/DefaultVoiceMessageComposerPresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/composer/DefaultVoiceMessageComposerPresenter.kt index 96d1787085..ca207757b9 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/composer/DefaultVoiceMessageComposerPresenter.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/composer/DefaultVoiceMessageComposerPresenter.kt @@ -245,12 +245,16 @@ class DefaultVoiceMessageComposerPresenter( val playerTime by remember(playerState, recorderState) { derivedStateOf { displayTime(playerState, recorderState) } } val waveform by remember(recorderState) { derivedStateOf { recorderState.finishedWaveform() } } + val recorderDuration = (recorderState as VoiceRecorderState.Finished).duration + val duration = playerState.durationMs?.milliseconds ?: recorderDuration + return VoiceMessageState.Preview( isSending = isSending, isPlaying = playerState.isPlaying, showCursor = showCursor, playbackProgress = playerState.progress, time = playerTime, + duration = duration, waveform = waveform, ) } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/composer/VoiceMessageComposerPlayer.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/composer/VoiceMessageComposerPlayer.kt index 1e55c45542..81f8c8e7fb 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/composer/VoiceMessageComposerPlayer.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/composer/VoiceMessageComposerPlayer.kt @@ -63,6 +63,7 @@ class VoiceMessageComposerPlayer( playState = it.playState, currentPosition = it.currentPosition, progress = calcProgress(it), + durationMs = if (it.playState != PlayState.Stopped) it.duration else null, ) }.distinctUntilChanged() @@ -183,17 +184,20 @@ class VoiceMessageComposerPlayer( * @property playState Whether this player is currently playing. See [PlayState]. * @property currentPosition The elapsed time of this player in milliseconds. * @property progress The progress of this player between 0 and 1. + * @property durationMs The player-reported duration in milliseconds, when known and the player is not stopped. */ data class State( val playState: PlayState, val currentPosition: Long, val progress: Float, + val durationMs: Long?, ) { companion object { val Initial = State( playState = PlayState.Stopped, currentPosition = 0L, progress = 0f, + durationMs = null, ) } diff --git a/features/messages/impl/src/main/res/values-cs/translations.xml b/features/messages/impl/src/main/res/values-cs/translations.xml index 6c39d67583..86add66f6e 100644 --- a/features/messages/impl/src/main/res/values-cs/translations.xml +++ b/features/messages/impl/src/main/res/values-cs/translations.xml @@ -7,6 +7,19 @@ "Šifrováno neznámým nebo smazaným zařízením." "Šifrováno zařízením, které nebylo ověřeno jeho vlastníkem." "Šifrováno neověřeným uživatelem." + "Otočit obrázek vodorovně" + "Otočeno vodorovně" + "Originál" + "Otočit obrázek svisle" + "Otočeno svisle " + "Originál" + "Otočit obrázek doleva" + + "%1$d stupeň" + "%1$d stupně" + "%1$d stupňů" + + "Upravit fotku" "Titulky nemusí být viditelné pro lidi, kteří používají starší aplikace." "Klepnutím změníte kvalitu nahrávání videa" "Soubor nelze nahrát." @@ -17,9 +30,10 @@ "Položka %1$d z %2$d" "Optimalizace kvality obrazu" "Probíhá zpracování…" + "Přidat média" "Zablokovat uživatele" "Zaškrtněte, pokud chcete skrýt všechny aktuální a budoucí zprávy od tohoto uživatele" - "Tato zpráva bude nahlášena správci vašeho domovského serveru. Nebude si moci přečíst žádné šifrované zprávy." + "Tato zpráva bude nahlášena vašemu poskytovateli účtu. Nebude moci číst žádné šifrované zprávy." "Důvod nahlášení tohoto obsahu" "Fotoaparát" "Vyfotit" 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 389e868b5a..1363d2d017 100644 --- a/features/messages/impl/src/main/res/values-de/translations.xml +++ b/features/messages/impl/src/main/res/values-de/translations.xml @@ -47,6 +47,10 @@ "Standort teilen" "Umfrage" "Textformatierung" + "Grund (optional)" + "Eingeben …" + "(Optional)" + "Entfernen bestätigen" "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?" diff --git a/features/messages/impl/src/main/res/values-el/translations.xml b/features/messages/impl/src/main/res/values-el/translations.xml index 8a4e935b9e..72c752ba66 100644 --- a/features/messages/impl/src/main/res/values-el/translations.xml +++ b/features/messages/impl/src/main/res/values-el/translations.xml @@ -7,6 +7,18 @@ "Κρυπτογραφημένο από άγνωστη ή διαγεγραμμένη συσκευή." "Κρυπτογραφημένο από μια συσκευή που δεν έχει επαληθευτεί από τον ιδιοκτήτη της." "Κρυπτογραφημένο από μη επαληθευμένο χρήστη." + "Αναστροφή εικόνας οριζόντια" + "Αναποδογυρισμένο οριζόντια" + "Πρωτότυπο" + "Αναστροφή εικόνας κάθετα" + "Αναποδογυρισμένο κάθετα" + "Πρωτότυπο" + "Περιστρέψτε την εικόνα προς τα αριστερά" + + "%1$d μοίρα" + "%1$d μοίρες" + + "Επεξεργασία φωτογραφίας" "Οι λεζάντες ενδέχεται να μην είναι ορατές σε άτομα που χρησιμοποιούν παλαιότερες εφαρμογές." "Πατήστε για να αλλάξετε την ποιότητα μεταφόρτωσης βίντεο" "Δεν ήταν δυνατή η μεταφόρτωση του αρχείου." @@ -17,6 +29,7 @@ "Στοιχείο %1$d από %2$d" "Βελτιστοποίηση ποιότητας εικόνας" "Επεξεργασία…" + "Προσθήκη πολυμέσων" "Αποκλεισμός χρήστη" "Επέλεξε εάν θες να αποκρύψεις όλα τα τρέχοντα και μελλοντικά μηνύματα από αυτόν τον χρήστη" "Αυτό το μήνυμα θα αναφερθεί στον πάροχο του λογαριασμού σας. Δεν θα μπορεί να διαβάσει κανένα κρυπτογραφημένο μήνυμα." @@ -57,6 +70,10 @@ "Εμφάνιση περισσότερων" "Εμφάνιση περίληψης αντιδράσεων" "Νέο" + + "%1$d μήνυμα αφαιρέθηκε" + "%1$d μηνύματα αφαιρέθηκαν" + "%1$d αλλαγή αίθουσας" "%1$d αλλαγές αίθουσας" diff --git a/features/messages/impl/src/main/res/values-fi/translations.xml b/features/messages/impl/src/main/res/values-fi/translations.xml index 308ae57f2b..3bbc68c356 100644 --- a/features/messages/impl/src/main/res/values-fi/translations.xml +++ b/features/messages/impl/src/main/res/values-fi/translations.xml @@ -7,6 +7,11 @@ "Tuntemattoman tai poistetun laitteen salaama." "Salattu laitteella, jota sen omistaja ei ole vahvistanut." "Vahvistamattoman käyttäjän salaama." + "Virheellinen MIME-tyyppi: %s" + "Mediasisältö puuttuu" + "Virheen tiedot: %s" + "Lähetetään vahvistamattomasta laitteesta" + "Tuntematon virhe" "Käännä kuva vaakasuunnassa" "Käännetty vaakasuunnassa" "Alkuperäinen" @@ -32,7 +37,7 @@ "Lisää mediaa" "Estä käyttäjä" "Valitse tämä, jos haluat piilottaa kaikki nykyiset ja tulevat viestit tältä käyttäjältä" - "Tämä viesti ilmoitetaan kotipalvelimesi ylläpitäjälle. Ylläpitäjä ei pysty lukemaan salattuja viestejä." + "Tämä viesti ilmoitetaan palveluntarjoajallesi. He eivät pysty lukemaan salattuja viestejä." "Syy tämän sisällön ilmoittamiseen" "Kamera" "Ota kuva" @@ -42,6 +47,10 @@ "Jaa sijainti" "Kysely" "Tekstin muotoilu" + "Syy (valinnainen)" + "Kirjoita…" + "(Valinnainen)" + "Vahvista poistaminen" "Viestihistoria ei ole tällä hetkellä saatavilla" "Viestihistoria ei ole käytettävissä tässä huoneessa. Vahvista tämä laite nähdäksesi viestihistoriasi." "Haluatko kutsua heidät takaisin?" diff --git a/features/messages/impl/src/main/res/values-hr/translations.xml b/features/messages/impl/src/main/res/values-hr/translations.xml index 41bf751738..33daf3b00f 100644 --- a/features/messages/impl/src/main/res/values-hr/translations.xml +++ b/features/messages/impl/src/main/res/values-hr/translations.xml @@ -47,6 +47,10 @@ "Dijeli lokaciju" "Anketa" "Oblikovanje teksta" + "Razlog (neobavezno)" + "Unesite…" + "(Neobavezno)" + "Potvrdite uklanjanje" "Povijest poruka trenutačno nije dostupna." "Povijest poruka nije dostupna u ovoj sobi. Potvrdite ovaj uređaj kako biste vidjeli povijest poruka." "Želite li ih pozvati natrag?" diff --git a/features/messages/impl/src/main/res/values-hu/translations.xml b/features/messages/impl/src/main/res/values-hu/translations.xml index 98841bf995..4befd393b6 100644 --- a/features/messages/impl/src/main/res/values-hu/translations.xml +++ b/features/messages/impl/src/main/res/values-hu/translations.xml @@ -7,6 +7,11 @@ "Ismeretlen vagy törölt eszköz által titkosítva." "A tulajdonos által nem ellenőrzött eszköz által titkosítva." "Nem ellenőrzött felhasználó által titkosítva." + "Érvénytelen MIME-típus: %s" + "Hiányzó médiatartalom" + "Hiba részletei: %s" + "Küldés ellenőrizetlen eszközről" + "Ismeretlen hiba" "Kép vízszintes tükrözése" "Vízszintesen tükrözve" "Eredeti" @@ -42,6 +47,10 @@ "Hely megosztása" "Szavazás" "Szövegformázás" + "Ok (nem kötelező)" + "Írja be…" + "(Választható)" + "Eltávolítás megerősítése" "Az üzenetelőzmények jelenleg nem érhetők el." "Az üzenetelőzmények nem érhetők el ebben a szobában. Ellenőrizze ezt az eszközt, hogy lássa az előzményeket." "Visszahívja?" diff --git a/features/messages/impl/src/main/res/values-ja/translations.xml b/features/messages/impl/src/main/res/values-ja/translations.xml index ae571e2751..9d2408d051 100644 --- a/features/messages/impl/src/main/res/values-ja/translations.xml +++ b/features/messages/impl/src/main/res/values-ja/translations.xml @@ -7,6 +7,11 @@ "削除されたまたは不明な端末により暗号化されています。" "所有者に検証されていない端末により暗号化されています。" "未検証のユーザーにより暗号化されています。" + "無効なMIMEタイプ: %s" + "メディアの内容が見つかりません" + "エラーの詳細: %s" + "未検証の端末から送信" + "不明なエラー" "水平に反転" "垂直に反転" "元画像" @@ -31,7 +36,7 @@ "メディアを追加" "ユーザーをブロック" "このユーザーからのメッセージをすべて非表示にする場合はチェックしてください。" - "このメッセージはホームサーバーの管理者に報告されます。暗号化されたメッセージを確認することはできません。" + "このメッセージはアカウント提供元の管理者に報告されます。暗号化されたメッセージは確認することができません。" "このコンテンツを通報する理由" "カメラ" "写真を撮影" @@ -41,6 +46,10 @@ "場所を共有" "投票" "書式設定" + "理由 (任意)" + "入力…" + "(任意)" + "削除の確認" "過去のメッセージを現在表示できません。" "このルームの過去のメッセージを表示できません。確認するには、この端末を検証してください。" "招待し直しますか?" diff --git a/features/messages/impl/src/main/res/values-pl/translations.xml b/features/messages/impl/src/main/res/values-pl/translations.xml index 915eedf61e..150922e999 100644 --- a/features/messages/impl/src/main/res/values-pl/translations.xml +++ b/features/messages/impl/src/main/res/values-pl/translations.xml @@ -48,6 +48,10 @@ "Udostępnij lokalizację" "Ankieta" "Formatowanie tekstu" + "Powód (opcjonalne)" + "Wprowadź powód…" + "(Opcjonalne)" + "Usunąć wiadomość?" "Historia wiadomości jest obecnie niedostępna." "Historia wiadomości jest niedostępna w tym pokoju. Zweryfikuj to urządzenie, aby zobaczyć historię wiadomości." "Czy chcesz zaprosić ich z powrotem?" diff --git a/features/messages/impl/src/main/res/values-ru/translations.xml b/features/messages/impl/src/main/res/values-ru/translations.xml index d1dfceb6a3..1fab6ed5b3 100644 --- a/features/messages/impl/src/main/res/values-ru/translations.xml +++ b/features/messages/impl/src/main/res/values-ru/translations.xml @@ -48,6 +48,10 @@ "Поделиться местоположением" "Опрос" "Форматирование текста" + "Причина (необязательно)" + "Введите…" + "(Необязательно)" + "Подтвердите удаление" "В настоящее время история сообщений недоступна в этой комнате." "История сообщений в этой комнате недоступна. Проверьте это устройство, чтобы увидеть историю сообщений." "Хотите пригласить их снова?" 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 6de0b1ebcc..2388b8c172 100644 --- a/features/messages/impl/src/main/res/values-sv/translations.xml +++ b/features/messages/impl/src/main/res/values-sv/translations.xml @@ -18,7 +18,7 @@ "Bearbetar …" "Blockera användare" "Markera om du vill dölja alla nuvarande och framtida meddelanden från denna användare" - "Det här meddelandet kommer att rapporteras till din hemservers administratör. Denne kommer inte att kunna läsa några krypterade meddelanden." + "Det här meddelandet kommer att rapporteras till din kontoleverantör. Denne kommer inte att kunna läsa några krypterade meddelanden." "Anledning till att rapportera detta innehåll" "Kamera" "Ta ett foto" diff --git a/features/messages/impl/src/main/res/values-uk/translations.xml b/features/messages/impl/src/main/res/values-uk/translations.xml index bcac792fc3..530cef6919 100644 --- a/features/messages/impl/src/main/res/values-uk/translations.xml +++ b/features/messages/impl/src/main/res/values-uk/translations.xml @@ -48,6 +48,10 @@ "Поділитися місцеперебуванням" "Опитування" "Форматування тексту" + "Причина (необов\'язково)" + "Ввести причину…" + "(Необов\'язково)" + "Вилучити повідомлення?" "Історія повідомлень наразі недоступна." "Історія повідомлень недоступна в цій кімнаті. Звірте цей пристрій, щоб побачити історію повідомлень." "Чи хотіли б ви запросити їх знову?" diff --git a/features/messages/impl/src/main/res/values-zh-rTW/translations.xml b/features/messages/impl/src/main/res/values-zh-rTW/translations.xml index db7d96cd97..ce8c7a86e2 100644 --- a/features/messages/impl/src/main/res/values-zh-rTW/translations.xml +++ b/features/messages/impl/src/main/res/values-zh-rTW/translations.xml @@ -7,6 +7,11 @@ "由未知或已刪除的裝置加密。" "由未經其擁有者驗證的裝置加密。" "由未經驗證的使用者加密。" + "無效的 MIME 類型:%s" + "缺少媒體內容" + "錯誤詳細資訊:%s" + "從未經驗證的裝置傳送" + "未知錯誤" "水平翻轉圖片" "水平翻轉" "原始狀態" @@ -31,7 +36,7 @@ "新增媒體" "封鎖使用者" "檢查您是否要隱藏所有來自此使用者的目前及未來的訊息" - "此訊息將會回報給您的家伺服器管理員。他們將無法讀取任何已加密的訊息。" + "這則訊息將會通報給您的帳號服務提供者。他們將無法閱讀任何加密訊息。" "檢舉這個內容的原因" "照相機" "拍照" @@ -41,6 +46,10 @@ "分享位置" "投票" "格式化文字" + "理由(選擇性)" + "輸入⋯⋯" + "(選擇性)" + "確認移除" "目前無法檢視訊息歷史紀錄。" "無法檢視此聊天室的訊息歷史紀錄。驗證此裝置以檢視您的訊息紀錄。" "您想要邀請他們回來嗎?" diff --git a/features/messages/impl/src/main/res/values-zh/translations.xml b/features/messages/impl/src/main/res/values-zh/translations.xml index 609db9f40b..20e275a959 100644 --- a/features/messages/impl/src/main/res/values-zh/translations.xml +++ b/features/messages/impl/src/main/res/values-zh/translations.xml @@ -46,6 +46,10 @@ "共享位置" "投票" "文本格式化" + "理由(可选)" + "输入理由…" + "(可选)" + "移除消息?" "消息历史记录当前不可用。" "消息历史在此房间不可用。请验证此设备以查看。" "你想邀请他们回来吗?" @@ -73,7 +77,7 @@ "显示反应摘要" "新消息" - "%1$d 个已移除的消息" + "%1$d 条已移除的消息" "%1$d 个房间变化" diff --git a/features/messages/impl/src/main/res/values/localazy.xml b/features/messages/impl/src/main/res/values/localazy.xml index 0c45fc01e1..5930725632 100644 --- a/features/messages/impl/src/main/res/values/localazy.xml +++ b/features/messages/impl/src/main/res/values/localazy.xml @@ -47,6 +47,10 @@ "Share location" "Poll" "Text Formatting" + "Reason (optional)" + "Enter reason…" + "(Optional)" + "Remove message?" "Message history is currently unavailable." "Message history is unavailable in this room. Verify this device to see your message history." "Would you like to invite them back?" diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/FakeMessagesNavigator.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/FakeMessagesNavigator.kt index 68f9a8d17a..31f6e4604e 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/FakeMessagesNavigator.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/FakeMessagesNavigator.kt @@ -13,13 +13,14 @@ 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.ThreadId import io.element.android.libraries.matrix.api.core.UserId +import io.element.android.libraries.matrix.api.timeline.TimelineProvider import io.element.android.libraries.matrix.api.timeline.item.TimelineItemDebugInfo import io.element.android.tests.testutils.lambda.lambdaError import kotlinx.collections.immutable.ImmutableList class FakeMessagesNavigator( private val onShowEventDebugInfoClickLambda: (eventId: EventId?, debugInfo: TimelineItemDebugInfo) -> Unit = { _, _ -> lambdaError() }, - private val onForwardEventClickLambda: (eventId: EventId) -> Unit = { _ -> lambdaError() }, + private val onForwardEventClickLambda: (eventId: EventId, timelineProvider: TimelineProvider) -> Unit = { _, _ -> lambdaError() }, private val onReportContentClickLambda: (eventId: EventId, senderId: UserId) -> Unit = { _, _ -> lambdaError() }, private val onEditPollClickLambda: (eventId: EventId) -> Unit = { _ -> lambdaError() }, private val onPreviewAttachmentLambda: (attachments: ImmutableList, inReplyToEventId: EventId?) -> Unit = { _, _ -> lambdaError() }, @@ -34,8 +35,8 @@ class FakeMessagesNavigator( onShowEventDebugInfoClickLambda(eventId, debugInfo) } - override fun forwardEvent(eventId: EventId) { - onForwardEventClickLambda(eventId) + override fun forwardEvent(eventId: EventId, timelineProvider: TimelineProvider) { + onForwardEventClickLambda(eventId, timelineProvider) } override fun navigateToReportMessage(eventId: EventId, senderId: UserId) { 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 78e9aba2d0..858a1f345f 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 @@ -45,6 +45,7 @@ import io.element.android.features.messages.test.timeline.voicemessages.composer import io.element.android.features.roomcall.api.aStandByCallState import io.element.android.features.roommembermoderation.api.RoomMemberModerationState import io.element.android.libraries.androidutils.clipboard.FakeClipboardHelper +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.coroutine.CoroutineDispatchers @@ -70,6 +71,7 @@ import io.element.android.libraries.matrix.api.room.StateEventType import io.element.android.libraries.matrix.api.room.history.RoomHistoryVisibility import io.element.android.libraries.matrix.api.room.tombstone.SuccessorRoom import io.element.android.libraries.matrix.api.timeline.Timeline +import io.element.android.libraries.matrix.api.timeline.TimelineProvider import io.element.android.libraries.matrix.api.timeline.item.TimelineItemDebugInfo import io.element.android.libraries.matrix.api.timeline.item.event.EventOrTransactionId import io.element.android.libraries.matrix.api.timeline.item.event.LocalEventSendState @@ -78,10 +80,12 @@ import io.element.android.libraries.matrix.test.AN_AVATAR_URL import io.element.android.libraries.matrix.test.AN_EVENT_ID import io.element.android.libraries.matrix.test.AN_EXCEPTION import io.element.android.libraries.matrix.test.A_CAPTION +import io.element.android.libraries.matrix.test.A_REASON 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_SESSION_ID_2 import io.element.android.libraries.matrix.test.A_THREAD_ID +import io.element.android.libraries.matrix.test.A_TRANSACTION_ID import io.element.android.libraries.matrix.test.A_USER_ID import io.element.android.libraries.matrix.test.A_USER_ID_2 import io.element.android.libraries.matrix.test.core.FakeSendHandle @@ -106,6 +110,7 @@ import io.element.android.tests.testutils.FakeLifecycleOwner 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.any import io.element.android.tests.testutils.lambda.assert import io.element.android.tests.testutils.lambda.lambdaError import io.element.android.tests.testutils.lambda.lambdaRecorder @@ -285,7 +290,7 @@ class MessagesPresenterTest { @Test fun `present - handle action forward`() = runTest { - val onForwardEventClickLambda = lambdaRecorder { } + val onForwardEventClickLambda = lambdaRecorder { _, _ -> } val navigator = FakeMessagesNavigator( onForwardEventClickLambda = onForwardEventClickLambda, ) @@ -294,7 +299,7 @@ class MessagesPresenterTest { val initialState = awaitItem() initialState.eventSink(MessagesEvent.HandleAction(TimelineItemAction.Forward, aMessageEvent())) assertThat(awaitItem().actionListState.target).isEqualTo(ActionListState.Target.None) - onForwardEventClickLambda.assertions().isCalledOnce().with(value(AN_EVENT_ID)) + onForwardEventClickLambda.assertions().isCalledOnce().with(value(AN_EVENT_ID), any()) } } @@ -556,13 +561,86 @@ class MessagesPresenterTest { val initialState = awaitItem() val messageEvent = aMessageEvent() initialState.eventSink(MessagesEvent.HandleAction(TimelineItemAction.Redact, messageEvent)) - awaitItem() + advanceUntilIdle() + val confirmingState = expectMostRecentItem() + assertThat(confirmingState.redactEventAction).isEqualTo(MessagesState.ConfirmingRedaction(messageEvent.eventId!!)) + assert(redactEventLambda).isNeverCalled() + confirmingState.eventSink(MessagesEvent.ConfirmRedact(A_REASON)) + advanceUntilIdle() + assertThat(expectMostRecentItem().redactEventAction).isEqualTo(AsyncAction.Uninitialized) + assert(redactEventLambda) + .isCalledOnce() + .with(value(messageEvent.eventOrTransactionId), value(A_REASON)) + } + } + + @Test + fun `present - handle action redact - a blank reason is not sent`() = runTest { + val coroutineDispatchers = testCoroutineDispatchers(useUnconfinedTestDispatcher = true) + val liveTimeline = FakeTimeline() + val redactEventLambda = lambdaRecorder { _: EventOrTransactionId, _: String? -> Result.success(Unit) } + liveTimeline.redactEventLambda = redactEventLambda + val presenter = createMessagesPresenter( + timeline = liveTimeline, + coroutineDispatchers = coroutineDispatchers, + ) + presenter.testWithLifecycleOwner { + val initialState = awaitItem() + val messageEvent = aMessageEvent() + initialState.eventSink(MessagesEvent.HandleAction(TimelineItemAction.Redact, messageEvent)) + advanceUntilIdle() + expectMostRecentItem().eventSink(MessagesEvent.ConfirmRedact(" ")) + advanceUntilIdle() + assertThat(expectMostRecentItem().redactEventAction).isEqualTo(AsyncAction.Uninitialized) assert(redactEventLambda) .isCalledOnce() .with(value(messageEvent.eventOrTransactionId), value(null)) } } + @Test + fun `present - handle action redact - cancelling does not redact`() = runTest { + val coroutineDispatchers = testCoroutineDispatchers(useUnconfinedTestDispatcher = true) + val liveTimeline = FakeTimeline() + val redactEventLambda = lambdaRecorder { _: EventOrTransactionId, _: String? -> Result.success(Unit) } + liveTimeline.redactEventLambda = redactEventLambda + val presenter = createMessagesPresenter( + timeline = liveTimeline, + coroutineDispatchers = coroutineDispatchers, + ) + presenter.testWithLifecycleOwner { + val initialState = awaitItem() + initialState.eventSink(MessagesEvent.HandleAction(TimelineItemAction.Redact, aMessageEvent())) + advanceUntilIdle() + expectMostRecentItem().eventSink(MessagesEvent.CancelRedact) + advanceUntilIdle() + assertThat(expectMostRecentItem().redactEventAction).isEqualTo(AsyncAction.Uninitialized) + assert(redactEventLambda).isNeverCalled() + } + } + + @Test + fun `present - handle action redact - a message which was never sent is redacted without asking`() = runTest { + val coroutineDispatchers = testCoroutineDispatchers(useUnconfinedTestDispatcher = true) + val liveTimeline = FakeTimeline() + val redactEventLambda = lambdaRecorder { _: EventOrTransactionId, _: String? -> Result.success(Unit) } + liveTimeline.redactEventLambda = redactEventLambda + val presenter = createMessagesPresenter( + timeline = liveTimeline, + coroutineDispatchers = coroutineDispatchers, + ) + presenter.testWithLifecycleOwner { + val initialState = awaitItem() + val localEcho = aMessageEvent(eventId = null, transactionId = A_TRANSACTION_ID) + initialState.eventSink(MessagesEvent.HandleAction(TimelineItemAction.Redact, localEcho)) + advanceUntilIdle() + assertThat(expectMostRecentItem().redactEventAction).isEqualTo(AsyncAction.Uninitialized) + assert(redactEventLambda) + .isCalledOnce() + .with(value(localEcho.eventOrTransactionId), value(null)) + } + } + @Test fun `present - handle action report content`() = runTest { val onReportContentClickLambda = lambdaRecorder { _: EventId, _: UserId -> } @@ -1387,7 +1465,7 @@ class MessagesPresenterTest { assertThat(initialState.threads.hasThreads).isFalse() // Enable the feature flag, now it should reflect the thread list state - featureFlagService.setFeatureEnabled(FeatureFlags.RoomThreadList, true) + featureFlagService.setFeatureEnabled(FeatureFlags.Threads, true) skipItems(1) assertThat(awaitItem().threads.hasThreads).isTrue() 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 fb39e67fc5..45d88ad765 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 @@ -32,6 +32,8 @@ import io.element.android.features.poll.test.pollcontent.FakePollContentStateFac import io.element.android.libraries.androidutils.filesize.FakeFileSizeFormatter import io.element.android.libraries.dateformatter.test.FakeDateFormatter import io.element.android.libraries.eventformatter.api.TimelineEventFormatter +import io.element.android.libraries.featureflag.test.FakeFeatureFlagService +import io.element.android.libraries.htmlrenderer.test.FakeHtmlMessageParser import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.timeline.item.event.EventContent import io.element.android.libraries.matrix.test.FakeMatrixClient @@ -59,6 +61,8 @@ internal fun aTimelineItemContentFactory( htmlConverterProvider = FakeHtmlConverterProvider(), permalinkParser = FakePermalinkParser(), textPillificationHelper = FakeTextPillificationHelper(), + htmlMessageParser = FakeHtmlMessageParser(), + featureFlagService = FakeFeatureFlagService(), ), redactedMessageFactory = TimelineItemContentRedactedFactory(), stickerFactory = TimelineItemContentStickerFactory( diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/pinned/list/FakePinnedMessagesListNavigator.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/pinned/list/FakePinnedMessagesListNavigator.kt index 69eaae3699..377ed0dc52 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/pinned/list/FakePinnedMessagesListNavigator.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/pinned/list/FakePinnedMessagesListNavigator.kt @@ -10,6 +10,7 @@ package io.element.android.features.messages.impl.pinned.list import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.core.ThreadId +import io.element.android.libraries.matrix.api.timeline.TimelineProvider import io.element.android.libraries.matrix.api.timeline.item.TimelineItemDebugInfo class FakePinnedMessagesListNavigator : PinnedMessagesListNavigator { @@ -23,9 +24,9 @@ class FakePinnedMessagesListNavigator : PinnedMessagesListNavigator { onShowEventDebugInfoClickLambda?.invoke(eventId, debugInfo) } - var onForwardEventClickLambda: ((EventId) -> Unit)? = null - override fun forwardEvent(eventId: EventId) { - onForwardEventClickLambda?.invoke(eventId) + var onForwardEventClickLambda: ((EventId, TimelineProvider) -> Unit)? = null + override fun forwardEvent(eventId: EventId, timelineProvider: TimelineProvider) { + onForwardEventClickLambda?.invoke(eventId, timelineProvider) } var onOpenThreadLambda: ((ThreadId) -> Unit)? = null diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListPresenterTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListPresenterTest.kt index cafb16c41f..fc00b2f12b 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListPresenterTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListPresenterTest.kt @@ -27,6 +27,7 @@ import io.element.android.libraries.matrix.api.core.UniqueId import io.element.android.libraries.matrix.api.room.JoinedRoom import io.element.android.libraries.matrix.api.sync.SyncService 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.item.TimelineItemDebugInfo import io.element.android.libraries.matrix.api.timeline.item.event.FailedToParseMessageLikeContent import io.element.android.libraries.matrix.api.timeline.item.event.UnknownContent @@ -47,6 +48,7 @@ import io.element.android.libraries.matrix.test.timeline.anEventTimelineItem import io.element.android.services.analytics.api.AnalyticsService import io.element.android.services.analytics.test.FakeAnalyticsService 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 @@ -329,7 +331,7 @@ class PinnedMessagesListPresenterTest { @Test fun `present - forward event`() = runTest { - val onForwardEventClickLambda = lambdaRecorder { _: EventId -> } + val onForwardEventClickLambda = lambdaRecorder { _: EventId, _: TimelineProvider -> } val navigator = FakePinnedMessagesListNavigator().apply { this.onForwardEventClickLambda = onForwardEventClickLambda } @@ -352,7 +354,7 @@ class PinnedMessagesListPresenterTest { cancelAndIgnoreRemainingEvents() assert(onForwardEventClickLambda) .isCalledOnce() - .with(value(AN_EVENT_ID)) + .with(value(AN_EVENT_ID), any()) } } 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 15b624aeff..cf80e028e7 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 @@ -30,6 +30,7 @@ import io.element.android.features.messages.impl.timeline.model.event.TimelineIt import io.element.android.features.messages.impl.timeline.model.event.TimelineItemLocationContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemNoticeContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemStickerContent +import io.element.android.features.messages.impl.timeline.model.event.TimelineItemTextBasedContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemTextContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVideoContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVoiceContent @@ -37,6 +38,10 @@ import io.element.android.features.messages.impl.utils.FakeTextPillificationHelp import io.element.android.features.messages.test.timeline.FakeHtmlConverterProvider import io.element.android.libraries.androidutils.filesize.FakeFileSizeFormatter import io.element.android.libraries.core.mimetype.MimeTypes +import io.element.android.libraries.featureflag.api.FeatureFlags +import io.element.android.libraries.featureflag.test.FakeFeatureFlagService +import io.element.android.libraries.htmlrenderer.api.DocumentNode +import io.element.android.libraries.htmlrenderer.test.FakeHtmlMessageParser import io.element.android.libraries.matrix.api.media.AudioDetails import io.element.android.libraries.matrix.api.media.AudioInfo import io.element.android.libraries.matrix.api.media.FileInfo @@ -80,9 +85,11 @@ import kotlinx.coroutines.test.runTest import org.jsoup.nodes.Document import org.junit.Assert.fail import org.junit.Test +import kotlin.jvm.java import kotlin.time.Duration import kotlin.time.Duration.Companion.minutes +@Suppress("LargeClass") class TimelineItemContentMessageFactoryTest : RobolectricTest() { @Test fun `test create OtherMessageType`() = runTest { @@ -1215,6 +1222,40 @@ class TimelineItemContentMessageFactoryTest : RobolectricTest() { assertThat(galleryContent.formattedCaption).isNull() } + @Test + fun `test create will only use the new parser if the feature flag is enabled`() = runTest { + val featureFlagService = FakeFeatureFlagService( + initialState = mapOf( + FeatureFlags.NewTimelineEventRenderer.key to true + ) + ) + val sut = createTimelineItemContentMessageFactory( + featureFlagService = featureFlagService + ) + val resultWithFeatureFlag = sut.create( + content = createMessageContent(type = TextMessageType("body", FormattedBody(MessageFormat.HTML, "html body"))), + senderId = A_USER_ID, + senderProfile = aProfileDetails(), + eventId = AN_EVENT_ID, + ) + + // The message tree is present when the feature flag is enabled + assertThat(resultWithFeatureFlag).isInstanceOf(TimelineItemTextBasedContent::class.java) + assertThat((resultWithFeatureFlag as TimelineItemTextBasedContent).messageTree).isNotNull() + + featureFlagService.setFeatureEnabled(FeatureFlags.NewTimelineEventRenderer, false) + val resultWithoutFeatureFlag = sut.create( + content = createMessageContent(type = TextMessageType("body", FormattedBody(MessageFormat.HTML, "html body"))), + senderId = A_USER_ID, + senderProfile = aProfileDetails(), + eventId = AN_EVENT_ID, + ) + + // The message tree is null when the feature flag is disabled + assertThat(resultWithoutFeatureFlag).isInstanceOf(TimelineItemTextBasedContent::class.java) + assertThat((resultWithoutFeatureFlag as TimelineItemTextBasedContent).messageTree).isNull() + } + private fun createMessageContent( body: String = "Body", inReplyTo: InReplyTo? = null, @@ -1235,12 +1276,15 @@ class TimelineItemContentMessageFactoryTest : RobolectricTest() { htmlConverterTransform: (String) -> CharSequence = { it }, domConverterTransform: (Document) -> CharSequence = { it.body().html() }, permalinkParser: FakePermalinkParser = FakePermalinkParser(), + featureFlagService: FakeFeatureFlagService = FakeFeatureFlagService(), ) = TimelineItemContentMessageFactory( fileSizeFormatter = FakeFileSizeFormatter(), fileExtensionExtractor = FileExtensionExtractorWithoutValidation(), htmlConverterProvider = FakeHtmlConverterProvider(htmlConverterTransform, domConverterTransform), permalinkParser = permalinkParser, textPillificationHelper = FakeTextPillificationHelper(), + htmlMessageParser = FakeHtmlMessageParser(parseResult = { DocumentNode(persistentListOf()) }), + featureFlagService = featureFlagService, ) private fun createStickerContent( diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/voicemessages/composer/DefaultVoiceMessageComposerPresenterTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/voicemessages/composer/DefaultVoiceMessageComposerPresenterTest.kt index baedfa33b6..e4c3310089 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/voicemessages/composer/DefaultVoiceMessageComposerPresenterTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/voicemessages/composer/DefaultVoiceMessageComposerPresenterTest.kt @@ -103,6 +103,7 @@ class DefaultVoiceMessageComposerPresenterTest { companion object { private val RECORDING_DURATION = 1.seconds + private val PLAYER_DURATION = 10.seconds private val FIRST_LEVEL_DURATION = 500.milliseconds private val RECORDING_STATE = VoiceMessageState.Recording(RECORDING_DURATION, listOf(0.1f, 0.2f).toImmutableList()) private val FIRST_RECORDING_STATE = VoiceMessageState.Recording(FIRST_LEVEL_DURATION, listOf(0.1f).toImmutableList()) @@ -266,6 +267,7 @@ class DefaultVoiceMessageComposerPresenterTest { val finalState = awaitItem() assertThat(finalState.voiceMessageState).isEqualTo(aPreviewState()) + assertThat((finalState.voiceMessageState as VoiceMessageState.Preview).duration).isEqualTo(RECORDING_DURATION) startRecordResult.assertions().isCalledOnce() stopRecordResult.assertions().isCalledOnce().with(value(false)) deleteRecordingResult.assertions().isNeverCalled() @@ -302,6 +304,7 @@ class DefaultVoiceMessageComposerPresenterTest { awaitItem().eventSink(VoiceMessageComposerEvent.PlayerEvent(VoiceMessagePlayerEvent.Play)) val finalState = awaitItem().also { assertThat(it.voiceMessageState).isEqualTo(aPlayingState()) + assertThat((it.voiceMessageState as VoiceMessageState.Preview).duration).isEqualTo(PLAYER_DURATION) } startRecordResult.assertions().isCalledOnce() stopRecordResult.assertions().isCalledOnce().with(value(false)) @@ -338,17 +341,30 @@ class DefaultVoiceMessageComposerPresenterTest { awaitItem().eventSink(VoiceMessageComposerEvent.RecorderEvent(VoiceMessageRecorderEvent.Stop)) awaitItem().eventSink(VoiceMessageComposerEvent.PlayerEvent(VoiceMessagePlayerEvent.Seek(0.5f))) awaitItem().apply { - assertThat(voiceMessageState).isEqualTo(aPreviewState(playbackProgress = 0.5f, time = 0.seconds, showCursor = true)) + assertThat(voiceMessageState).isEqualTo( + aPreviewState(playbackProgress = 0.5f, time = 0.seconds, showCursor = true) + ) } awaitItem().apply { - assertThat(voiceMessageState).isEqualTo(aPreviewState(playbackProgress = 0.5f, time = 5.seconds, showCursor = true)) + assertThat(voiceMessageState).isEqualTo( + aPreviewState(playbackProgress = 0.5f, time = 0.seconds, showCursor = true, duration = PLAYER_DURATION) + ) + } + awaitItem().apply { + assertThat(voiceMessageState).isEqualTo( + aPreviewState(playbackProgress = 0.5f, time = 5.seconds, showCursor = true, duration = PLAYER_DURATION) + ) eventSink(VoiceMessageComposerEvent.PlayerEvent(VoiceMessagePlayerEvent.Seek(0.2f))) } awaitItem().apply { - assertThat(voiceMessageState).isEqualTo(aPreviewState(playbackProgress = 0.2f, time = 5.seconds, showCursor = true)) + assertThat(voiceMessageState).isEqualTo( + aPreviewState(playbackProgress = 0.2f, time = 5.seconds, showCursor = true, duration = PLAYER_DURATION) + ) } val finalState = awaitItem().apply { - assertThat(voiceMessageState).isEqualTo(aPreviewState(playbackProgress = 0.2f, time = 2.seconds, showCursor = true)) + assertThat(voiceMessageState).isEqualTo( + aPreviewState(playbackProgress = 0.2f, time = 2.seconds, showCursor = true, duration = PLAYER_DURATION) + ) } testPauseAndDestroy(finalState) @@ -827,6 +843,7 @@ class DefaultVoiceMessageComposerPresenterTest { playbackProgress: Float = 0f, isSending: Boolean = false, time: Duration = RECORDING_DURATION, + duration: Duration = RECORDING_DURATION, showCursor: Boolean = false, waveform: List = voiceRecorder.waveform, ) = VoiceMessageState.Preview( @@ -834,6 +851,7 @@ class DefaultVoiceMessageComposerPresenterTest { playbackProgress = playbackProgress, isSending = isSending, time = time, + duration = duration, showCursor = showCursor, waveform = waveform.toImmutableList(), ) @@ -844,6 +862,7 @@ class DefaultVoiceMessageComposerPresenterTest { playbackProgress = 0.1f, showCursor = true, time = RECORDING_DURATION, + duration = PLAYER_DURATION, ) private fun aPausedState() = 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 d3f177736a..0e98a71d23 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 @@ -23,8 +23,6 @@ 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 io.element.android.libraries.matrix.api.timeline.item.event.toEventOrTransactionId -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.first @AssistedInject @@ -40,17 +38,16 @@ class PollRepository( ): PollRepository } - suspend fun getPoll(eventId: EventId): Result = runCatchingExceptions { - getTimelineProvider() - .getOrThrow() - .getActiveTimeline() - .timelineItems - .first() - .asSequence() - .filterIsInstance() - .first { it.eventId == eventId } - .event - .content as PollContent + suspend fun getPoll(eventId: EventId): Result = withTimeline { timeline -> + runCatchingExceptions { + timeline.timelineItems + .first() + .asSequence() + .filterIsInstance() + .first { it.eventId == eventId } + .event + .content as PollContent + } } suspend fun savePoll( @@ -59,52 +56,49 @@ class PollRepository( answers: List, pollKind: PollKind, maxSelections: Int, - ): Result = when (existingPollId) { - null -> getTimelineProvider().flatMap { timelineProvider -> - timelineProvider - .getActiveTimeline() - .createPoll( - question = question, - answers = answers, - maxSelections = maxSelections, - pollKind = pollKind, - ) - } - else -> getTimelineProvider().flatMap { timelineProvider -> - timelineProvider.getActiveTimeline() - .editPoll( - pollStartId = existingPollId, - question = question, - answers = answers, - maxSelections = maxSelections, - pollKind = pollKind, - ) + ): Result = withTimeline { timeline -> + when (existingPollId) { + null -> timeline.createPoll( + question = question, + answers = answers, + maxSelections = maxSelections, + pollKind = pollKind, + ) + else -> timeline.editPoll( + pollStartId = existingPollId, + question = question, + answers = answers, + maxSelections = maxSelections, + pollKind = pollKind, + ) } } suspend fun deletePoll( pollStartId: EventId, - ): Result = - getTimelineProvider().flatMap { timelineProvider -> - timelineProvider.getActiveTimeline() - .redactEvent( - eventOrTransactionId = pollStartId.toEventOrTransactionId(), - reason = null, - ) - } + ): Result = withTimeline { timeline -> + timeline.redactEvent( + eventOrTransactionId = pollStartId.toEventOrTransactionId(), + reason = null, + ) + } - private suspend fun getTimelineProvider(): Result { + /** + * Invokes [block] with the [Timeline] matching [timelineMode]. + * + * For a thread, a dedicated timeline is created and closed once [block] returns, since such a + * timeline owns SDK resources. For the other modes, the active timeline of + * [defaultTimelineProvider] is used, and is not closed here since it is owned elsewhere. + */ + private suspend fun withTimeline(block: suspend (Timeline) -> Result): Result { return when (timelineMode) { is Timeline.Mode.Thread -> { - val threadedTimelineResult = room.createTimeline(CreateTimelineParams.Threaded(timelineMode.threadRootId)) - threadedTimelineResult.map { threadedTimeline -> - object : TimelineProvider { - private val flow = MutableStateFlow(threadedTimeline) - override fun activeTimelineFlow(): StateFlow = flow + room.createTimeline(CreateTimelineParams.Threaded(timelineMode.threadRootId)) + .flatMap { threadedTimeline -> + threadedTimeline.use { block(it) } } - } } - else -> Result.success(defaultTimelineProvider) + else -> block(defaultTimelineProvider.getActiveTimeline()) } } } diff --git a/features/poll/impl/src/test/kotlin/io/element/android/features/poll/impl/data/PollRepositoryTest.kt b/features/poll/impl/src/test/kotlin/io/element/android/features/poll/impl/data/PollRepositoryTest.kt new file mode 100644 index 0000000000..519643d008 --- /dev/null +++ b/features/poll/impl/src/test/kotlin/io/element/android/features/poll/impl/data/PollRepositoryTest.kt @@ -0,0 +1,168 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.poll.impl.data + +import com.google.common.truth.Truth.assertThat +import io.element.android.features.poll.impl.aPollTimelineItems +import io.element.android.features.poll.impl.anOngoingPollContent +import io.element.android.libraries.matrix.api.poll.PollKind +import io.element.android.libraries.matrix.api.room.CreateTimelineParams +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_THREAD_ID +import io.element.android.libraries.matrix.test.room.FakeJoinedRoom +import io.element.android.libraries.matrix.test.timeline.FakeTimeline +import io.element.android.libraries.matrix.test.timeline.LiveTimelineProvider +import kotlinx.coroutines.test.runTest +import org.junit.Test + +class PollRepositoryTest { + private val pollContent = anOngoingPollContent() + + @Test + fun `given a thread, getPoll closes the created timeline`() = runTest { + val threadedTimeline = aThreadedTimeline() + val room = aRoomCreating(threadedTimeline) + val sut = createPollRepository(room, Timeline.Mode.Thread(A_THREAD_ID)) + + val result = sut.getPoll(AN_EVENT_ID) + + assertThat(result.getOrNull()).isEqualTo(pollContent) + assertThat(threadedTimeline.closeCounter).isEqualTo(1) + assertThat((room.liveTimeline as FakeTimeline).closeCounter).isEqualTo(0) + } + + @Test + fun `given a thread, savePoll closes the created timeline`() = runTest { + val threadedTimeline = aThreadedTimeline().apply { + createPollLambda = { _, _, _, _ -> Result.success(Unit) } + } + val room = aRoomCreating(threadedTimeline) + val sut = createPollRepository(room, Timeline.Mode.Thread(A_THREAD_ID)) + + val result = sut.savePoll( + existingPollId = null, + question = "A question", + answers = listOf("Yes", "No"), + pollKind = PollKind.Disclosed, + maxSelections = 1, + ) + + assertThat(result.isSuccess).isTrue() + assertThat(threadedTimeline.closeCounter).isEqualTo(1) + } + + @Test + fun `given a thread, savePoll of an existing poll closes the created timeline`() = runTest { + val threadedTimeline = aThreadedTimeline().apply { + editPollLambda = { _, _, _, _, _ -> Result.success(Unit) } + } + val room = aRoomCreating(threadedTimeline) + val sut = createPollRepository(room, Timeline.Mode.Thread(A_THREAD_ID)) + + val result = sut.savePoll( + existingPollId = AN_EVENT_ID, + question = "A question", + answers = listOf("Yes", "No"), + pollKind = PollKind.Disclosed, + maxSelections = 1, + ) + + assertThat(result.isSuccess).isTrue() + assertThat(threadedTimeline.closeCounter).isEqualTo(1) + } + + @Test + fun `given a thread, deletePoll closes the created timeline`() = runTest { + val threadedTimeline = aThreadedTimeline().apply { + redactEventLambda = { _, _ -> Result.success(Unit) } + } + val room = aRoomCreating(threadedTimeline) + val sut = createPollRepository(room, Timeline.Mode.Thread(A_THREAD_ID)) + + val result = sut.deletePoll(AN_EVENT_ID) + + assertThat(result.isSuccess).isTrue() + assertThat(threadedTimeline.closeCounter).isEqualTo(1) + } + + @Test + fun `given a thread, the created timeline is closed even if the operation fails`() = runTest { + val error = RuntimeException("Failed to send the poll") + val threadedTimeline = aThreadedTimeline().apply { + createPollLambda = { _, _, _, _ -> Result.failure(error) } + } + val room = aRoomCreating(threadedTimeline) + val sut = createPollRepository(room, Timeline.Mode.Thread(A_THREAD_ID)) + + val result = sut.savePoll( + existingPollId = null, + question = "A question", + answers = listOf("Yes", "No"), + pollKind = PollKind.Disclosed, + maxSelections = 1, + ) + + assertThat(result.exceptionOrNull()).isEqualTo(error) + assertThat(threadedTimeline.closeCounter).isEqualTo(1) + } + + @Test + fun `given a thread, if the timeline cannot be created the error is returned`() = runTest { + val error = RuntimeException("Failed to create the timeline") + val room = FakeJoinedRoom( + liveTimeline = aLiveTimeline(), + createTimelineResult = { Result.failure(error) }, + ) + val sut = createPollRepository(room, Timeline.Mode.Thread(A_THREAD_ID)) + + val result = sut.deletePoll(AN_EVENT_ID) + + assertThat(result.exceptionOrNull()).isEqualTo(error) + } + + @Test + fun `given a live timeline, the timeline is not closed`() = runTest { + val liveTimeline = aLiveTimeline().apply { + redactEventLambda = { _, _ -> Result.success(Unit) } + } + val room = FakeJoinedRoom(liveTimeline = liveTimeline) + val sut = createPollRepository(room, Timeline.Mode.Live) + + val result = sut.deletePoll(AN_EVENT_ID) + + assertThat(result.isSuccess).isTrue() + assertThat(liveTimeline.closeCounter).isEqualTo(0) + } + + private fun aLiveTimeline() = FakeTimeline( + timelineItems = aPollTimelineItems(mapOf(AN_EVENT_ID to pollContent)), + ) + + private fun aThreadedTimeline() = FakeTimeline( + timelineItems = aPollTimelineItems(mapOf(AN_EVENT_ID to pollContent)), + mode = Timeline.Mode.Thread(A_THREAD_ID), + ) + + private fun aRoomCreating(threadedTimeline: FakeTimeline) = FakeJoinedRoom( + liveTimeline = aLiveTimeline(), + createTimelineResult = { params -> + assertThat(params).isEqualTo(CreateTimelineParams.Threaded(A_THREAD_ID)) + Result.success(threadedTimeline) + }, + ) + + private fun createPollRepository( + room: FakeJoinedRoom, + timelineMode: Timeline.Mode, + ) = PollRepository( + room = room, + defaultTimelineProvider = LiveTimelineProvider(room), + timelineMode = timelineMode, + ) +} diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/PreferencesFlowNode.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/PreferencesFlowNode.kt index c85eed5b9d..d9db0219d4 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/PreferencesFlowNode.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/PreferencesFlowNode.kt @@ -26,11 +26,13 @@ import io.element.android.features.lockscreen.api.LockScreenEntryPoint import io.element.android.features.logout.api.LogoutEntryPoint import io.element.android.features.preferences.api.PreferencesEntryPoint import io.element.android.features.preferences.impl.about.AboutNode -import io.element.android.features.preferences.impl.advanced.AdvancedSettingsNode import io.element.android.features.preferences.impl.analytics.AnalyticsSettingsNode import io.element.android.features.preferences.impl.blockedusers.BlockedUsersNode import io.element.android.features.preferences.impl.developer.DeveloperSettingsNode import io.element.android.features.preferences.impl.labs.LabsNode +import io.element.android.features.preferences.impl.location.LocationSettingsNode +import io.element.android.features.preferences.impl.media.MediaSettingsNode +import io.element.android.features.preferences.impl.moderation.ModerationAndSafetyNode import io.element.android.features.preferences.impl.notifications.NotificationSettingsNode import io.element.android.features.preferences.impl.notifications.edit.EditDefaultNotificationSettingNode import io.element.android.features.preferences.impl.root.PreferencesRootNode @@ -78,7 +80,10 @@ class PreferencesFlowNode( data object DeveloperSettings : NavTarget @Parcelize - data object AdvancedSettings : NavTarget + data object MediaSettings : NavTarget + + @Parcelize + data object LocationSettings : NavTarget @Parcelize data object Labs : NavTarget @@ -92,6 +97,9 @@ class PreferencesFlowNode( @Parcelize data object NotificationSettings : NavTarget + @Parcelize + data object ModerationAndSafety : NavTarget + @Parcelize data object TroubleshootNotifications : NavTarget @@ -133,14 +141,6 @@ class PreferencesFlowNode( callback.navigateToAddAccount() } - override fun navigateToBugReport() { - callback.navigateToBugReport() - } - - override fun navigateToSecureBackup() { - callback.navigateToSecureBackup() - } - override fun navigateToAnalyticsSettings() { backstack.push(NavTarget.AnalyticsSettings) } @@ -153,22 +153,38 @@ class PreferencesFlowNode( backstack.push(NavTarget.DeveloperSettings) } - override fun navigateToNotificationSettings() { - backstack.push(NavTarget.NotificationSettings) - } - override fun navigateToLockScreenSettings() { backstack.push(NavTarget.LockScreenSettings) } - override fun navigateToAdvancedSettings() { - backstack.push(NavTarget.AdvancedSettings) + override fun navigateToMediaSettings() { + backstack.push(NavTarget.MediaSettings) + } + + override fun navigateToLocationSettings() { + backstack.push(NavTarget.LocationSettings) } override fun navigateToLabs() { backstack.push(NavTarget.Labs) } + override fun navigateToBugReport() { + callback.navigateToBugReport() + } + + override fun navigateToSecureBackup() { + callback.navigateToSecureBackup() + } + + override fun navigateToModerationAndSafety() { + backstack.push(NavTarget.ModerationAndSafety) + } + + override fun navigateToNotificationSettings() { + backstack.push(NavTarget.NotificationSettings) + } + override fun navigateToLinkNewDevice() { callback.navigateToLinkNewDevice() } @@ -177,10 +193,6 @@ class PreferencesFlowNode( backstack.push(NavTarget.UserProfile(matrixUser)) } - override fun navigateToBlockedUsers() { - backstack.push(NavTarget.BlockedUsers) - } - override fun startSignOutFlow() { backstack.push(NavTarget.SignOut) } @@ -219,6 +231,17 @@ class PreferencesFlowNode( } createNode(buildContext, listOf(callback)) } + NavTarget.ModerationAndSafety -> { + val callback = object : ModerationAndSafetyNode.Callback { + override fun navigateToBlockedUsers() { + backstack.push(NavTarget.BlockedUsers) + } + } + createNode(buildContext, listOf(callback)) + } + NavTarget.LocationSettings -> { + createNode(buildContext) + } NavTarget.About -> { val callback = object : AboutNode.Callback { override fun navigateToOssLicenses() { @@ -309,8 +332,8 @@ class PreferencesFlowNode( val input = EditDefaultNotificationSettingNode.Inputs(navTarget.isOneToOne) createNode(buildContext, plugins = listOf(input, callback)) } - NavTarget.AdvancedSettings -> { - createNode(buildContext) + NavTarget.MediaSettings -> { + createNode(buildContext) } is NavTarget.UserProfile -> { val inputs = EditUserProfileNode.Inputs(navTarget.matrixUser) diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/account/PreferencesAccountCallback.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/account/PreferencesAccountCallback.kt new file mode 100644 index 0000000000..90504aab2c --- /dev/null +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/account/PreferencesAccountCallback.kt @@ -0,0 +1,21 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.preferences.impl.account + +import io.element.android.libraries.matrix.api.user.MatrixUser + +interface PreferencesAccountCallback { + fun navigateToBugReport() + fun navigateToSecureBackup() + fun navigateToModerationAndSafety() + fun navigateToNotificationSettings() + fun navigateToLinkNewDevice() + fun navigateToUserProfile(matrixUser: MatrixUser) + fun startSignOutFlow() + fun startAccountDeactivationFlow() +} diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/account/PreferencesAccountPresenter.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/account/PreferencesAccountPresenter.kt new file mode 100644 index 0000000000..ad9686be9c --- /dev/null +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/account/PreferencesAccountPresenter.kt @@ -0,0 +1,90 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.preferences.impl.account + +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.produceState +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import dev.zacsweers.metro.Inject +import io.element.android.features.enterprise.api.SessionEnterpriseService +import io.element.android.features.logout.api.direct.DirectLogoutState +import io.element.android.features.rageshake.api.RageshakeFeatureAvailability +import io.element.android.libraries.architecture.Presenter +import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher +import io.element.android.libraries.designsystem.utils.snackbar.collectSnackbarMessageAsState +import io.element.android.libraries.featureflag.api.FeatureFlagService +import io.element.android.libraries.featureflag.api.FeatureFlags +import io.element.android.libraries.indicator.api.IndicatorService +import io.element.android.libraries.matrix.api.MatrixClient +import io.element.android.libraries.matrix.api.verification.SessionVerificationService + +@Inject +class PreferencesAccountPresenter( + private val matrixClient: MatrixClient, + private val sessionVerificationService: SessionVerificationService, + private val snackbarDispatcher: SnackbarDispatcher, + private val indicatorService: IndicatorService, + private val directLogoutPresenter: Presenter, + private val rageshakeFeatureAvailability: RageshakeFeatureAvailability, + private val featureFlagService: FeatureFlagService, + private val sessionEnterpriseService: SessionEnterpriseService, +) : Presenter { + @Composable + override fun present(): PreferencesAccountState { + val matrixUser = matrixClient.userProfile.collectAsState() + LaunchedEffect(Unit) { + // Force a refresh of the profile + matrixClient.getUserProfile() + } + val showLinkNewDevice by remember { + featureFlagService.isFeatureEnabledFlow(FeatureFlags.QrCodeLogin) + }.collectAsState(initial = false) + + val snackbarMessage by snackbarDispatcher.collectSnackbarMessageAsState() + + // We should display the 'complete verification' option if the current session can be verified + val canVerifyUserSession by sessionVerificationService.needsSessionVerification.collectAsState(false) + + val showSecureBackupIndicator by indicatorService.showSettingChatBackupIndicator() + + var canDeactivateAccount by remember { + mutableStateOf(false) + } + val canReportBug by remember { rageshakeFeatureAvailability.isAvailable() }.collectAsState(false) + LaunchedEffect(Unit) { + canDeactivateAccount = matrixClient.canDeactivateAccount() + } + + val directLogoutState = directLogoutPresenter.present() + + val accountManagementUrl by produceState(initialValue = null) { + value = matrixClient.getAccountManagementUrl(null) + .getOrNull() + ?.let { + sessionEnterpriseService.tweakMasUrl(it) + } + } + + return PreferencesAccountState( + myUser = matrixUser.value, + showSecureBackup = !canVerifyUserSession, + showSecureBackupBadge = showSecureBackupIndicator, + accountManagementUrl = accountManagementUrl, + canReportBug = canReportBug, + showLinkNewDevice = showLinkNewDevice, + canDeactivateAccount = canDeactivateAccount, + directLogoutState = directLogoutState, + snackbarMessage = snackbarMessage, + ) + } +} diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/account/PreferencesAccountState.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/account/PreferencesAccountState.kt new file mode 100644 index 0000000000..00abbf2ad4 --- /dev/null +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/account/PreferencesAccountState.kt @@ -0,0 +1,24 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.preferences.impl.account + +import io.element.android.features.logout.api.direct.DirectLogoutState +import io.element.android.libraries.designsystem.utils.snackbar.SnackbarMessage +import io.element.android.libraries.matrix.api.user.MatrixUser + +data class PreferencesAccountState( + val myUser: MatrixUser, + val showSecureBackup: Boolean, + val showSecureBackupBadge: Boolean, + val accountManagementUrl: String?, + val canReportBug: Boolean, + val showLinkNewDevice: Boolean, + val canDeactivateAccount: Boolean, + val directLogoutState: DirectLogoutState, + val snackbarMessage: SnackbarMessage?, +) diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/account/PreferencesAccountStatePreviewParam.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/account/PreferencesAccountStatePreviewParam.kt new file mode 100644 index 0000000000..0cf42104ef --- /dev/null +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/account/PreferencesAccountStatePreviewParam.kt @@ -0,0 +1,71 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.preferences.impl.account + +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import io.element.android.features.logout.api.direct.DirectLogoutState +import io.element.android.features.logout.api.direct.aDirectLogoutState +import io.element.android.libraries.designsystem.utils.snackbar.SnackbarMessage +import io.element.android.libraries.matrix.api.user.MatrixUser +import io.element.android.libraries.matrix.ui.components.aMatrixUser +import io.element.android.libraries.ui.strings.CommonStrings + +open class PreferencesAccountStatePreviewParam : PreviewParameterProvider { + override val values: Sequence + get() = sequenceOf( + // Nominal state, that a regular user will see if multi account is enabled + aPreferencesAccountState( + myUser = aMatrixUser(avatarUrl = "anAvatarUrl"), + showSecureBackup = true, + accountManagementUrl = "aUrl", + canReportBug = true, + showLinkNewDevice = true, + canDeactivateAccount = false, + ), + aPreferencesAccountState( + myUser = aMatrixUser(displayName = null), + showSecureBackup = true, + canDeactivateAccount = true, + ), + aPreferencesAccountState( + accountManagementUrl = "aUrl", + showSecureBackup = true, + showSecureBackupBadge = true, + ), + aPreferencesAccountState( + canReportBug = true, + snackbarMessage = SnackbarMessage(CommonStrings.common_verification_complete), + ), + aPreferencesAccountState( + showLinkNewDevice = true, + canDeactivateAccount = true, + ), + ) +} + +fun aPreferencesAccountState( + myUser: MatrixUser = aMatrixUser(), + showSecureBackup: Boolean = false, + showSecureBackupBadge: Boolean = false, + accountManagementUrl: String? = null, + canReportBug: Boolean = false, + showLinkNewDevice: Boolean = false, + canDeactivateAccount: Boolean = false, + directLogoutState: DirectLogoutState = aDirectLogoutState(), + snackbarMessage: SnackbarMessage? = null, +) = PreferencesAccountState( + myUser = myUser, + showSecureBackup = showSecureBackup, + showSecureBackupBadge = showSecureBackupBadge, + accountManagementUrl = accountManagementUrl, + canReportBug = canReportBug, + showLinkNewDevice = showLinkNewDevice, + canDeactivateAccount = canDeactivateAccount, + directLogoutState = directLogoutState, + snackbarMessage = snackbarMessage, +) diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/account/PreferencesAccountView.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/account/PreferencesAccountView.kt new file mode 100644 index 0000000000..1d99914737 --- /dev/null +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/account/PreferencesAccountView.kt @@ -0,0 +1,168 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.preferences.impl.account + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +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.compound.tokens.generated.CompoundIcons +import io.element.android.features.preferences.impl.R +import io.element.android.libraries.designsystem.components.list.ListItemContent +import io.element.android.libraries.designsystem.components.preferences.PreferenceCategory +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +import io.element.android.libraries.designsystem.theme.components.HorizontalDivider +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.Text +import io.element.android.libraries.ui.strings.CommonStrings + +@Composable +fun PreferencesAccountView( + state: PreferencesAccountState, + onSecureBackupClick: () -> Unit, + onManageAccountClick: (url: String) -> Unit, + onLinkNewDeviceClick: () -> Unit, + onOpenRageShake: () -> Unit, + onModerationAndSafetyClick: () -> Unit, + onOpenNotificationSettings: () -> Unit, + onSignOutClick: () -> Unit, + onDeactivateClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Column(modifier = modifier) { + // 'Account settings' section + ManageAccountSection( + state = state, + onManageAccountClick = onManageAccountClick, + onLinkNewDeviceClick = onLinkNewDeviceClick, + ) + OtherSettingsSection( + state = state, + onOpenNotificationSettings = onOpenNotificationSettings, + onSecureBackupClick = onSecureBackupClick, + ) + // General section + GeneralSection( + state = state, + onModerationAndSafetyClick = onModerationAndSafetyClick, + onOpenRageShake = onOpenRageShake, + onSignOutClick = onSignOutClick, + onDeactivateClick = onDeactivateClick, + ) + } +} + +@Composable +private fun ManageAccountSection( + state: PreferencesAccountState, + onManageAccountClick: (url: String) -> Unit, + onLinkNewDeviceClick: () -> Unit, +) { + PreferenceCategory( + title = stringResource(CommonStrings.common_account_settings), + showTopDivider = false, + ) { + state.accountManagementUrl?.let { url -> + ListItem( + content = { Text(stringResource(id = CommonStrings.action_manage_account_and_devices)) }, + leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.UserProfile())), + trailingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.PopOut())), + onClick = { onManageAccountClick(url) }, + ) + } + if (state.showLinkNewDevice) { + ListItem( + content = { Text(stringResource(id = CommonStrings.common_link_new_device)) }, + leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Devices())), + onClick = onLinkNewDeviceClick, + ) + } + } +} + +@Composable +private fun ColumnScope.OtherSettingsSection( + state: PreferencesAccountState, + onOpenNotificationSettings: () -> Unit, + onSecureBackupClick: () -> Unit, +) { + ListItem( + content = { Text(stringResource(id = R.string.screen_notification_settings_title)) }, + leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Notifications())), + onClick = onOpenNotificationSettings, + ) + if (state.showSecureBackup) { + ListItem( + content = { Text(stringResource(id = CommonStrings.common_encryption)) }, + leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Key())), + trailingContent = ListItemContent.Badge.takeIf { state.showSecureBackupBadge }, + onClick = onSecureBackupClick, + ) + } +} + +@Composable +private fun ColumnScope.GeneralSection( + state: PreferencesAccountState, + onModerationAndSafetyClick: () -> Unit, + onOpenRageShake: () -> Unit, + onSignOutClick: () -> Unit, + onDeactivateClick: () -> Unit, +) { + HorizontalDivider() + ListItem( + content = { Text(stringResource(id = CommonStrings.common_moderation_and_safety)) }, + leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Admin())), + onClick = onModerationAndSafetyClick, + ) + + if (state.canReportBug) { + ListItem( + content = { Text(stringResource(id = CommonStrings.common_report_a_problem)) }, + leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.ChatProblem())), + onClick = onOpenRageShake, + ) + } + HorizontalDivider() + ListItem( + content = { Text(stringResource(id = CommonStrings.action_signout)) }, + leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Close())), + style = ListItemStyle.Destructive, + onClick = onSignOutClick, + ) + if (state.canDeactivateAccount) { + ListItem( + content = { Text(stringResource(id = CommonStrings.action_delete_account)) }, + leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Delete())), + style = ListItemStyle.Destructive, + onClick = onDeactivateClick, + ) + } +} + +@PreviewsDayNight +@Composable +internal fun PreferencesAccountViewPreview(@PreviewParameter(PreferencesAccountStatePreviewParam::class) state: PreferencesAccountState) = + ElementPreview { + PreferencesAccountView( + state = state, + onModerationAndSafetyClick = {}, + onOpenRageShake = {}, + onSecureBackupClick = {}, + onManageAccountClick = {}, + onLinkNewDeviceClick = {}, + onOpenNotificationSettings = {}, + onSignOutClick = {}, + onDeactivateClick = {}, + ) + } diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsEvent.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsEvent.kt deleted file mode 100644 index f2e6537d9d..0000000000 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsEvent.kt +++ /dev/null @@ -1,24 +0,0 @@ -/* - * Copyright (c) 2025 Element Creations Ltd. - * Copyright 2023-2025 New Vector Ltd. - * - * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. - * Please see LICENSE files in the repository root for full details. - */ - -package io.element.android.features.preferences.impl.advanced - -import io.element.android.libraries.matrix.api.media.MediaPreviewValue -import io.element.android.libraries.preferences.api.store.VideoCompressionPreset - -sealed interface AdvancedSettingsEvent { - data class SetDeveloperModeEnabled(val enabled: Boolean) : AdvancedSettingsEvent - data class SetSharePresenceEnabled(val enabled: Boolean) : AdvancedSettingsEvent - data class SetCompressMedia(val compress: Boolean) : AdvancedSettingsEvent - data class SetCompressImages(val compress: Boolean) : AdvancedSettingsEvent - data class SetVideoUploadQuality(val videoPreset: VideoCompressionPreset) : AdvancedSettingsEvent - data class SetTheme(val theme: ThemeOption) : AdvancedSettingsEvent - data class SetTimelineMediaPreviewValue(val value: MediaPreviewValue) : AdvancedSettingsEvent - data class SetHideInviteAvatars(val value: Boolean) : AdvancedSettingsEvent - data class SetLiveLocationMinimumDistanceUpdate(val value: Int) : AdvancedSettingsEvent -} diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsPresenter.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsPresenter.kt deleted file mode 100644 index 3ebf1f5140..0000000000 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsPresenter.kt +++ /dev/null @@ -1,147 +0,0 @@ -/* - * Copyright (c) 2025 Element Creations Ltd. - * Copyright 2023-2025 New Vector Ltd. - * - * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. - * Please see LICENSE files in the repository root for full details. - */ - -package io.element.android.features.preferences.impl.advanced - -import androidx.compose.runtime.Composable -import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.derivedStateOf -import androidx.compose.runtime.getValue -import androidx.compose.runtime.produceState -import androidx.compose.runtime.remember -import dev.zacsweers.metro.Inject -import io.element.android.compound.theme.Theme -import io.element.android.compound.theme.mapToTheme -import io.element.android.libraries.architecture.Presenter -import io.element.android.libraries.di.annotations.SessionCoroutineScope -import io.element.android.libraries.featureflag.api.FeatureFlagService -import io.element.android.libraries.featureflag.api.FeatureFlags -import io.element.android.libraries.preferences.api.store.AppPreferencesStore -import io.element.android.libraries.preferences.api.store.SessionPreferencesStore -import kotlinx.collections.immutable.toImmutableList -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.flow.collect -import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.launch - -@Inject -class AdvancedSettingsPresenter( - private val appPreferencesStore: AppPreferencesStore, - private val sessionPreferencesStore: SessionPreferencesStore, - private val mediaPreviewConfigStateStore: MediaPreviewConfigStateStore, - @SessionCoroutineScope - private val sessionCoroutineScope: CoroutineScope, - private val featureFlagService: FeatureFlagService, -) : Presenter { - @Composable - override fun present(): AdvancedSettingsState { - val isDeveloperModeEnabled by remember { - appPreferencesStore.isDeveloperModeEnabledFlow() - }.collectAsState(initial = false) - val isSharePresenceEnabled by remember { - sessionPreferencesStore.isSharePresenceEnabled() - }.collectAsState(initial = true) - val isBlackThemeAllowed by remember { - featureFlagService.isFeatureEnabledFlow(FeatureFlags.AllowBlackTheme) - }.collectAsState(initial = false) - val theme = remember(isBlackThemeAllowed) { - appPreferencesStore.getThemeFlow().mapToTheme(isBlackThemeAllowed) - }.collectAsState(initial = Theme.System) - - val liveLocationMinimumDistanceUpdate by produceState(null) { - appPreferencesStore.getLiveLocationMinimumDistanceInMetersUpdateFlow().collect { value = it } - } - - val mediaPreviewConfigState = mediaPreviewConfigStateStore.state() - - val themeOption by remember { - derivedStateOf { - when (theme.value) { - Theme.System -> ThemeOption.System - Theme.Dark -> ThemeOption.Dark - Theme.Black -> ThemeOption.Black - Theme.Light -> ThemeOption.Light - } - } - } - - val hasSplitMediaQualityOptions by produceState(null) { - value = featureFlagService.isFeatureEnabled(FeatureFlags.SelectableMediaQuality) - } - - val availableThemeOptions = remember(isBlackThemeAllowed) { - if (isBlackThemeAllowed) { - ThemeOption.entries - } else { - ThemeOption.entries.filterNot { it == ThemeOption.Black } - }.toImmutableList() - } - - val mediaOptimizationState by produceState(null) { - val hasSplitMediaQualityOptionsFlow = featureFlagService.isFeatureEnabledFlow(FeatureFlags.SelectableMediaQuality) - combine( - hasSplitMediaQualityOptionsFlow, - sessionPreferencesStore.doesOptimizeImages(), - sessionPreferencesStore.getVideoCompressionPreset() - ) { hasSplitOptions, compressImages, videoPreset -> - if (hasSplitMediaQualityOptions == true) { - value = MediaOptimizationState.Split( - compressImages = compressImages, - videoPreset = videoPreset, - ) - } else if (hasSplitMediaQualityOptions == false) { - value = MediaOptimizationState.AllMedia(isEnabled = compressImages) - } - }.collect() - } - - fun handleEvent(event: AdvancedSettingsEvent) { - when (event) { - is AdvancedSettingsEvent.SetDeveloperModeEnabled -> sessionCoroutineScope.launch { - appPreferencesStore.setDeveloperModeEnabled(event.enabled) - } - is AdvancedSettingsEvent.SetSharePresenceEnabled -> sessionCoroutineScope.launch { - sessionPreferencesStore.setSharePresence(event.enabled) - } - is AdvancedSettingsEvent.SetCompressMedia -> sessionCoroutineScope.launch { - sessionPreferencesStore.setOptimizeImages(event.compress) - } - is AdvancedSettingsEvent.SetTheme -> sessionCoroutineScope.launch { - when (event.theme) { - ThemeOption.System -> appPreferencesStore.setTheme(Theme.System.name) - ThemeOption.Dark -> appPreferencesStore.setTheme(Theme.Dark.name) - ThemeOption.Black -> appPreferencesStore.setTheme(Theme.Black.name) - ThemeOption.Light -> appPreferencesStore.setTheme(Theme.Light.name) - } - } - is AdvancedSettingsEvent.SetHideInviteAvatars -> mediaPreviewConfigStateStore.setHideInviteAvatars(event.value) - is AdvancedSettingsEvent.SetTimelineMediaPreviewValue -> mediaPreviewConfigStateStore.setTimelineMediaPreviewValue(event.value) - is AdvancedSettingsEvent.SetLiveLocationMinimumDistanceUpdate -> sessionCoroutineScope.launch { - appPreferencesStore.setLiveLocationMinimumDistanceInMetersUpdate(event.value) - } - is AdvancedSettingsEvent.SetCompressImages -> sessionCoroutineScope.launch { - sessionPreferencesStore.setOptimizeImages(event.compress) - } - is AdvancedSettingsEvent.SetVideoUploadQuality -> sessionCoroutineScope.launch { - sessionPreferencesStore.setVideoCompressionPreset(event.videoPreset) - } - } - } - - return AdvancedSettingsState( - isDeveloperModeEnabled = isDeveloperModeEnabled, - isSharePresenceEnabled = isSharePresenceEnabled, - mediaOptimizationState = mediaOptimizationState, - theme = themeOption, - availableThemeOptions = availableThemeOptions, - mediaPreviewConfigState = mediaPreviewConfigState, - liveLocationMinimumDistanceUpdate = liveLocationMinimumDistanceUpdate, - eventSink = ::handleEvent, - ) - } -} diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsState.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsState.kt deleted file mode 100644 index 689075759d..0000000000 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsState.kt +++ /dev/null @@ -1,67 +0,0 @@ -/* - * Copyright (c) 2025 Element Creations Ltd. - * Copyright 2023-2025 New Vector Ltd. - * - * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. - * Please see LICENSE files in the repository root for full details. - */ - -package io.element.android.features.preferences.impl.advanced - -import androidx.compose.runtime.Composable -import androidx.compose.runtime.ReadOnlyComposable -import androidx.compose.ui.res.stringResource -import io.element.android.features.preferences.impl.R -import io.element.android.libraries.designsystem.components.preferences.DropdownOption -import io.element.android.libraries.preferences.api.store.VideoCompressionPreset -import kotlinx.collections.immutable.ImmutableList - -data class AdvancedSettingsState( - val isDeveloperModeEnabled: Boolean, - val isSharePresenceEnabled: Boolean, - val mediaOptimizationState: MediaOptimizationState?, - val theme: ThemeOption, - val availableThemeOptions: ImmutableList, - val mediaPreviewConfigState: MediaPreviewConfigState, - val liveLocationMinimumDistanceUpdate: Int?, - val eventSink: (AdvancedSettingsEvent) -> Unit -) - -sealed interface MediaOptimizationState { - data class AllMedia(val isEnabled: Boolean) : MediaOptimizationState - data class Split( - val compressImages: Boolean, - val videoPreset: VideoCompressionPreset, - ) : MediaOptimizationState - - val shouldCompressImages: Boolean get() = when (this) { - is AllMedia -> isEnabled - is Split -> compressImages - } -} - -enum class ThemeOption : DropdownOption { - System { - @Composable - @ReadOnlyComposable - override fun getText(): String = stringResource(R.string.theme_system) - }, - - Light { - @Composable - @ReadOnlyComposable - override fun getText(): String = stringResource(R.string.theme_light) - }, - - Dark { - @Composable - @ReadOnlyComposable - override fun getText(): String = stringResource(R.string.theme_dark) - }, - - Black { - @Composable - @ReadOnlyComposable - override fun getText(): String = stringResource(R.string.theme_black) - } -} diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsStatePreviewParam.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsStatePreviewParam.kt deleted file mode 100644 index 3b6a323419..0000000000 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsStatePreviewParam.kt +++ /dev/null @@ -1,62 +0,0 @@ -/* - * Copyright (c) 2025 Element Creations Ltd. - * Copyright 2023-2025 New Vector Ltd. - * - * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. - * Please see LICENSE files in the repository root for full details. - */ - -package io.element.android.features.preferences.impl.advanced - -import androidx.compose.ui.tooling.preview.PreviewParameterProvider -import io.element.android.libraries.architecture.AsyncAction -import io.element.android.libraries.matrix.api.media.MediaPreviewValue -import io.element.android.libraries.preferences.api.store.VideoCompressionPreset -import kotlinx.collections.immutable.ImmutableList -import kotlinx.collections.immutable.toImmutableList - -open class AdvancedSettingsStatePreviewParam : PreviewParameterProvider { - override val values: Sequence - get() = sequenceOf( - aAdvancedSettingsState(), - aAdvancedSettingsState(isDeveloperModeEnabled = true), - aAdvancedSettingsState(isSharePresenceEnabled = true), - aAdvancedSettingsState(mediaOptimizationState = MediaOptimizationState.AllMedia(isEnabled = true)), - aAdvancedSettingsState(hideInviteAvatars = true), - aAdvancedSettingsState(timelineMediaPreviewValue = MediaPreviewValue.Off), - aAdvancedSettingsState(setHideInviteAvatarsAction = AsyncAction.Loading), - aAdvancedSettingsState(setTimelineMediaPreviewAction = AsyncAction.Loading), - aAdvancedSettingsState(mediaOptimizationState = MediaOptimizationState.Split( - compressImages = true, - videoPreset = VideoCompressionPreset.HIGH, - )), - ) -} - -fun aAdvancedSettingsState( - isDeveloperModeEnabled: Boolean = false, - isSharePresenceEnabled: Boolean = false, - mediaOptimizationState: MediaOptimizationState = MediaOptimizationState.AllMedia(isEnabled = false), - theme: ThemeOption = ThemeOption.System, - availableThemeOptions: ImmutableList = ThemeOption.entries.toImmutableList(), - hideInviteAvatars: Boolean = false, - timelineMediaPreviewValue: MediaPreviewValue = MediaPreviewValue.On, - liveLocationMinimumDistanceUpdate: Int? = 50, - setTimelineMediaPreviewAction: AsyncAction = AsyncAction.Uninitialized, - setHideInviteAvatarsAction: AsyncAction = AsyncAction.Uninitialized, - eventSink: (AdvancedSettingsEvent) -> Unit = {}, -) = AdvancedSettingsState( - isDeveloperModeEnabled = isDeveloperModeEnabled, - isSharePresenceEnabled = isSharePresenceEnabled, - mediaOptimizationState = mediaOptimizationState, - theme = theme, - availableThemeOptions = availableThemeOptions, - mediaPreviewConfigState = MediaPreviewConfigState( - hideInviteAvatars = hideInviteAvatars, - timelineMediaPreviewValue = timelineMediaPreviewValue, - setTimelineMediaPreviewAction = setTimelineMediaPreviewAction, - setHideInviteAvatarsAction = setHideInviteAvatarsAction - ), - liveLocationMinimumDistanceUpdate = liveLocationMinimumDistanceUpdate, - eventSink = eventSink -) diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsView.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsView.kt deleted file mode 100644 index d7b1cfeb17..0000000000 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsView.kt +++ /dev/null @@ -1,446 +0,0 @@ -/* - * Copyright (c) 2025 Element Creations Ltd. - * Copyright 2023-2025 New Vector Ltd. - * - * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. - * Please see LICENSE files in the repository root for full details. - */ - -package io.element.android.features.preferences.impl.advanced - -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.navigationBarsPadding -import androidx.compose.foundation.layout.padding -import androidx.compose.material3.SliderDefaults -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableIntStateOf -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.res.pluralStringResource -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.tooling.preview.PreviewParameter -import androidx.compose.ui.unit.dp -import im.vector.app.features.analytics.plan.Interaction -import io.element.android.compound.theme.ElementTheme -import io.element.android.features.preferences.impl.R -import io.element.android.libraries.architecture.coverage.ExcludeFromCoverage -import io.element.android.libraries.designsystem.components.dialogs.ListDialog -import io.element.android.libraries.designsystem.components.list.ListItemContent -import io.element.android.libraries.designsystem.components.preferences.PreferenceCategory -import io.element.android.libraries.designsystem.components.preferences.PreferenceDropdown -import io.element.android.libraries.designsystem.components.preferences.PreferencePage -import io.element.android.libraries.designsystem.components.preferences.PreferenceSwitch -import io.element.android.libraries.designsystem.preview.ElementPreview -import io.element.android.libraries.designsystem.preview.ElementPreviewBlack -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.preview.PreviewsDayNight -import io.element.android.libraries.designsystem.text.stringWithLink -import io.element.android.libraries.designsystem.theme.components.ListItem -import io.element.android.libraries.designsystem.theme.components.ListSectionHeader -import io.element.android.libraries.designsystem.theme.components.ListSupportingText -import io.element.android.libraries.designsystem.theme.components.ListSupportingTextDefaults -import io.element.android.libraries.designsystem.theme.components.Slider -import io.element.android.libraries.designsystem.theme.components.Text -import io.element.android.libraries.designsystem.utils.snackbar.LocalSnackbarDispatcher -import io.element.android.libraries.designsystem.utils.snackbar.SnackbarHost -import io.element.android.libraries.designsystem.utils.snackbar.collectSnackbarMessageAsState -import io.element.android.libraries.designsystem.utils.snackbar.rememberSnackbarHostState -import io.element.android.libraries.matrix.api.media.MediaPreviewValue -import io.element.android.libraries.preferences.api.store.VideoCompressionPreset -import io.element.android.libraries.ui.strings.CommonStrings -import io.element.android.services.analytics.compose.LocalAnalyticsService -import io.element.android.services.analyticsproviders.api.trackers.captureInteraction -import kotlin.math.roundToInt - -@Composable -fun AdvancedSettingsView( - state: AdvancedSettingsState, - onBackClick: () -> Unit, - onOpenAppSettingsClick: () -> Unit, - modifier: Modifier = Modifier, -) { - val analyticsService = LocalAnalyticsService.current - - val snackbarDispatcher = LocalSnackbarDispatcher.current - val snackbarMessage by snackbarDispatcher.collectSnackbarMessageAsState() - val snackbarHostState = rememberSnackbarHostState(snackbarMessage = snackbarMessage) - - PreferencePage( - modifier = modifier, - onBackClick = onBackClick, - title = stringResource(id = CommonStrings.common_advanced_settings), - snackbarHost = { - SnackbarHost( - snackbarHostState, - modifier = Modifier.navigationBarsPadding() - ) - } - ) { - PreferenceDropdown( - title = stringResource(id = CommonStrings.common_appearance), - selectedOption = state.theme, - options = state.availableThemeOptions, - onSelectOption = { themeOption -> - state.eventSink(AdvancedSettingsEvent.SetTheme(themeOption)) - } - ) - ListItem( - content = { - Text(text = stringResource(id = CommonStrings.action_view_source)) - }, - supportingContent = { - Text(text = stringResource(id = R.string.screen_advanced_settings_view_source_description)) - }, - trailingContent = ListItemContent.Switch( - checked = state.isDeveloperModeEnabled, - ), - onClick = { state.eventSink(AdvancedSettingsEvent.SetDeveloperModeEnabled(!state.isDeveloperModeEnabled)) } - ) - ListItem( - content = { - Text(text = stringResource(id = R.string.screen_advanced_settings_share_presence)) - }, - supportingContent = { - Text(text = stringResource(id = R.string.screen_advanced_settings_share_presence_description)) - }, - trailingContent = ListItemContent.Switch( - checked = state.isSharePresenceEnabled, - ), - onClick = { state.eventSink(AdvancedSettingsEvent.SetSharePresenceEnabled(!state.isSharePresenceEnabled)) } - ) - val compressImages = state.mediaOptimizationState?.shouldCompressImages - - when (state.mediaOptimizationState) { - null -> Unit - is MediaOptimizationState.AllMedia -> { - ListItem( - content = { - Text(text = stringResource(id = R.string.screen_advanced_settings_media_compression_title)) - }, - supportingContent = { - Text(text = stringResource(id = R.string.screen_advanced_settings_media_compression_description)) - }, - trailingContent = ListItemContent.Switch( - checked = compressImages ?: false, - ), - onClick = { - val newValue = !(compressImages ?: false) - analyticsService.captureInteraction( - if (newValue) { - Interaction.Name.MobileSettingsOptimizeMediaUploadsEnabled - } else { - Interaction.Name.MobileSettingsOptimizeMediaUploadsDisabled - } - ) - state.eventSink(AdvancedSettingsEvent.SetCompressMedia(newValue)) - } - ) - } - is MediaOptimizationState.Split -> { - ListItem( - content = { - Text(text = stringResource(id = R.string.screen_advanced_settings_optimise_image_upload_quality_title)) - }, - supportingContent = { - Text(text = stringResource(id = R.string.screen_advanced_settings_optimise_image_upload_quality_description)) - }, - trailingContent = ListItemContent.Switch( - checked = compressImages ?: false, - ), - onClick = { - val newValue = !(compressImages ?: false) - analyticsService.captureInteraction( - if (newValue) { - Interaction.Name.MobileSettingsOptimizeMediaUploadsEnabled - } else { - Interaction.Name.MobileSettingsOptimizeMediaUploadsDisabled - } - ) - state.eventSink(AdvancedSettingsEvent.SetCompressMedia(newValue)) - } - ) - - var displaySelectorDialog by remember { mutableStateOf(false) } - - ListItem( - content = { - Text(text = stringResource(id = R.string.screen_advanced_settings_optimise_video_upload_quality_title)) - }, - supportingContent = { - val description = stringResource(id = R.string.screen_advanced_settings_optimise_video_upload_quality_description) - val quality = when (state.mediaOptimizationState.videoPreset) { - VideoCompressionPreset.LOW -> stringResource(id = R.string.screen_advanced_settings_optimise_video_upload_quality_low) - VideoCompressionPreset.STANDARD -> stringResource(id = R.string.screen_advanced_settings_optimise_video_upload_quality_standard) - VideoCompressionPreset.HIGH -> stringResource(id = R.string.screen_advanced_settings_optimise_video_upload_quality_high) - } - val descriptionWithValue = remember(quality) { - String.format(description, quality) - } - Text(text = descriptionWithValue) - }, - onClick = { displaySelectorDialog = true }, - ) - - if (displaySelectorDialog) { - VideoQualitySelectorDialog( - selectedPreset = state.mediaOptimizationState.videoPreset, - onSubmit = { preset -> - state.eventSink(AdvancedSettingsEvent.SetVideoUploadQuality(preset)) - displaySelectorDialog = false - }, - onDismiss = { displaySelectorDialog = false }, - ) - } - } - } - - ModerationAndSafety(state) - if (state.liveLocationMinimumDistanceUpdate != null) { - LiveLocationUpdatesSection( - value = state.liveLocationMinimumDistanceUpdate, - onSaveValue = { value -> - state.eventSink(AdvancedSettingsEvent.SetLiveLocationMinimumDistanceUpdate(value)) - }, - onOpenAppPermissionsClick = onOpenAppSettingsClick, - ) - } - } -} - -@Composable -private fun VideoQualitySelectorDialog( - selectedPreset: VideoCompressionPreset, - onSubmit: (VideoCompressionPreset) -> Unit, - onDismiss: () -> Unit -) { - val videoPresets = VideoCompressionPreset.entries - var localSelectedPreset by remember { mutableStateOf(selectedPreset) } - ListDialog( - title = stringResource(CommonStrings.dialog_video_quality_selector_title), - subtitle = stringResource(CommonStrings.dialog_default_video_quality_selector_subtitle), - onSubmit = { onSubmit(localSelectedPreset) }, - onDismissRequest = onDismiss, - applyPaddingToContents = false, - ) { - for (preset in videoPresets) { - val isSelected = preset == localSelectedPreset - item( - key = preset, - contentType = preset, - ) { - val title = when (preset) { - VideoCompressionPreset.LOW -> stringResource(R.string.screen_advanced_settings_optimise_video_upload_quality_low) - VideoCompressionPreset.STANDARD -> stringResource(R.string.screen_advanced_settings_optimise_video_upload_quality_standard) - VideoCompressionPreset.HIGH -> stringResource(R.string.screen_advanced_settings_optimise_video_upload_quality_high) - } - val subtitle = when (preset) { - VideoCompressionPreset.LOW -> stringResource(CommonStrings.common_video_quality_low_description) - VideoCompressionPreset.STANDARD -> stringResource(CommonStrings.common_video_quality_standard_description) - VideoCompressionPreset.HIGH -> stringResource(CommonStrings.common_video_quality_high_description) - } - ListItem( - content = { - Text( - text = title, - style = ElementTheme.typography.fontBodyLgMedium, - ) - }, - supportingContent = { - Text( - text = subtitle, - style = ElementTheme.typography.fontBodyMdRegular, - color = ElementTheme.colors.textSecondary, - ) - }, - leadingContent = ListItemContent.RadioButton( - selected = isSelected, - ), - onClick = { - localSelectedPreset = preset - }, - ) - } - } - } -} - -@Composable -private fun ModerationAndSafety( - state: AdvancedSettingsState, - modifier: Modifier = Modifier, -) { - PreferenceCategory( - modifier = modifier, - title = stringResource(R.string.screen_advanced_settings_moderation_and_safety_section_title), - showTopDivider = true - ) { - PreferenceSwitch( - title = stringResource(R.string.screen_advanced_settings_hide_invite_avatars_toggle_title), - isChecked = state.mediaPreviewConfigState.hideInviteAvatars, - onCheckedChange = { - state.eventSink(AdvancedSettingsEvent.SetHideInviteAvatars(it)) - }, - enabled = !state.mediaPreviewConfigState.setHideInviteAvatarsAction.isLoading() - ) - ListSectionHeader( - title = stringResource(R.string.screen_advanced_settings_show_media_timeline_title), - hasDivider = false, - description = { - ListSupportingText( - text = stringResource(R.string.screen_advanced_settings_show_media_timeline_subtitle), - contentPadding = ListSupportingTextDefaults.Padding.None, - ) - } - ) - ListItem( - content = { Text(text = stringResource(R.string.screen_advanced_settings_show_media_timeline_always_hide)) }, - leadingContent = ListItemContent.RadioButton( - selected = state.mediaPreviewConfigState.timelineMediaPreviewValue == MediaPreviewValue.Off, - compact = true - ), - onClick = { - state.eventSink(AdvancedSettingsEvent.SetTimelineMediaPreviewValue(MediaPreviewValue.Off)) - }, - enabled = !state.mediaPreviewConfigState.setTimelineMediaPreviewAction.isLoading() - ) - ListItem( - content = { Text(text = stringResource(R.string.screen_advanced_settings_show_media_timeline_private_rooms)) }, - leadingContent = ListItemContent.RadioButton( - selected = state.mediaPreviewConfigState.timelineMediaPreviewValue == MediaPreviewValue.Private, - compact = true - ), - onClick = { - state.eventSink(AdvancedSettingsEvent.SetTimelineMediaPreviewValue(MediaPreviewValue.Private)) - }, - enabled = !state.mediaPreviewConfigState.setTimelineMediaPreviewAction.isLoading() - ) - ListItem( - content = { Text(text = stringResource(R.string.screen_advanced_settings_show_media_timeline_always_show)) }, - leadingContent = ListItemContent.RadioButton( - selected = state.mediaPreviewConfigState.timelineMediaPreviewValue == MediaPreviewValue.On, - compact = true - ), - onClick = { - state.eventSink(AdvancedSettingsEvent.SetTimelineMediaPreviewValue(MediaPreviewValue.On)) - }, - enabled = !state.mediaPreviewConfigState.setTimelineMediaPreviewAction.isLoading() - ) - } -} - -@Composable -private fun LiveLocationUpdatesSection( - value: Int, - onSaveValue: (Int) -> Unit, - onOpenAppPermissionsClick: () -> Unit, - modifier: Modifier = Modifier, -) { - PreferenceCategory( - modifier = modifier, - showTopDivider = true, - ) { - ListSectionHeader( - title = stringResource(R.string.screen_advanced_settings_live_location_section_title), - description = { - ListSupportingText( - text = stringResource(R.string.screen_advanced_settings_live_location_section_description), - contentPadding = ListSupportingTextDefaults.Padding.None, - ) - } - ) - var sliderValue by remember(value) { mutableIntStateOf(value) } - Column( - modifier = Modifier.padding(vertical = 12.dp, horizontal = 16.dp), - verticalArrangement = Arrangement.spacedBy(16.dp) - ) { - Text( - text = pluralStringResource( - R.plurals.screen_advanced_settings_live_location_update_distance, - sliderValue, - sliderValue, - ), - style = ElementTheme.typography.fontBodyLgRegular, - color = ElementTheme.colors.textPrimary, - ) - val valueRange = 1f..100f - val start = valueRange.start.toInt() - val end = valueRange.endInclusive.toInt() - Row(verticalAlignment = Alignment.CenterVertically) { - Text("${start}m", color = ElementTheme.colors.textSecondary, style = ElementTheme.typography.fontBodyMdRegular) - Slider( - modifier = Modifier - .weight(1f) - .padding(horizontal = 12.dp), - value = sliderValue.toFloat(), - onValueChange = { sliderValue = it.roundToInt() }, - onValueChangeFinish = { - onSaveValue(sliderValue) - }, - valueRange = valueRange, - colors = SliderDefaults.colors( - thumbColor = ElementTheme.colors.iconAccentPrimary, - activeTrackColor = ElementTheme.colors.iconAccentPrimary, - inactiveTrackColor = ElementTheme.colors.bgBadgeAccent, - inactiveTickColor = ElementTheme.colors.iconAccentPrimary, - ) - ) - Text("${end}m", color = ElementTheme.colors.textSecondary, style = ElementTheme.typography.fontBodyMdRegular) - } - } - val footerText = stringWithLink( - textRes = R.string.screen_advanced_settings_live_location_section_footer, - url = "", - linkTextRes = R.string.screen_advanced_settings_live_location_section_footer_link, - onLinkClick = { onOpenAppPermissionsClick() }, - ) - ListSupportingText( - annotatedString = footerText, - contentPadding = ListSupportingTextDefaults.Padding.Default, - ) - } -} - -@PreviewWithLargeHeight -@Composable -internal fun AdvancedSettingsViewLightPreview(@PreviewParameter(AdvancedSettingsStatePreviewParam::class) state: AdvancedSettingsState) = - ElementPreviewLight { ContentToPreview(state) } - -@PreviewWithLargeHeight -@Composable -internal fun AdvancedSettingsViewDarkPreview(@PreviewParameter(AdvancedSettingsStatePreviewParam::class) state: AdvancedSettingsState) = - ElementPreviewDark { ContentToPreview(state) } - -@PreviewWithLargeHeight -@Composable -internal fun AdvancedSettingsViewBlackPreview(@PreviewParameter(AdvancedSettingsStatePreviewParam::class) state: AdvancedSettingsState) = - ElementPreviewBlack { ContentToPreview(state) } - -@ExcludeFromCoverage -@Composable -private fun ContentToPreview(state: AdvancedSettingsState) { - AdvancedSettingsView( - state = state, - onBackClick = { }, - onOpenAppSettingsClick = {} - ) -} - -@Composable -@PreviewsDayNight -internal fun VideoQualitySelectorDialogPreview() { - ElementPreview { - VideoQualitySelectorDialog( - selectedPreset = VideoCompressionPreset.STANDARD, - onSubmit = { /* no-op */ }, - onDismiss = { /* no-op */ } - ) - } -} diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/appsettings/AppDeveloperSettingsEvent.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/appsettings/AppDeveloperSettingsEvent.kt index d9641a2810..9f328fdd9b 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/appsettings/AppDeveloperSettingsEvent.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/appsettings/AppDeveloperSettingsEvent.kt @@ -12,6 +12,7 @@ import io.element.android.libraries.featureflag.ui.model.FeatureUiModel import io.element.android.libraries.matrix.api.tracing.TraceLogPack sealed interface AppDeveloperSettingsEvent { + data class SetDeveloperModeEnabled(val enabled: Boolean) : AppDeveloperSettingsEvent data class UpdateEnabledFeature(val feature: FeatureUiModel, val isEnabled: Boolean) : AppDeveloperSettingsEvent data class SetCustomElementCallBaseUrl(val baseUrl: String?) : AppDeveloperSettingsEvent data class SetTracingLogLevel(val logLevel: LogLevelItem) : AppDeveloperSettingsEvent diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/appsettings/AppDeveloperSettingsPresenter.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/appsettings/AppDeveloperSettingsPresenter.kt index 9c1127dd2a..1bf162d3ea 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/appsettings/AppDeveloperSettingsPresenter.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/appsettings/AppDeveloperSettingsPresenter.kt @@ -47,6 +47,9 @@ class AppDeveloperSettingsPresenter( ) : Presenter { @Composable override fun present(): AppDeveloperSettingsState { + val isDeveloperModeEnabled by remember { + appPreferencesStore.isDeveloperModeEnabledFlow() + }.collectAsState(initial = false) val rageshakeState = rageshakePresenter.present() val enabledFeatures = remember { mutableStateListOf() @@ -79,6 +82,9 @@ class AppDeveloperSettingsPresenter( fun handleEvent(event: AppDeveloperSettingsEvent) { when (event) { + is AppDeveloperSettingsEvent.SetDeveloperModeEnabled -> coroutineScope.launch { + appPreferencesStore.setDeveloperModeEnabled(event.enabled) + } is AppDeveloperSettingsEvent.UpdateEnabledFeature -> coroutineScope.updateEnabledFeature( enabledFeatures = enabledFeatures, featureKey = event.feature.key, @@ -104,6 +110,7 @@ class AppDeveloperSettingsPresenter( } return AppDeveloperSettingsState( + isDeveloperModeEnabled = isDeveloperModeEnabled, features = featureUiModels, rageshakeState = rageshakeState, customElementCallBaseUrlState = CustomElementCallBaseUrlState( diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/appsettings/AppDeveloperSettingsState.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/appsettings/AppDeveloperSettingsState.kt index 071670644c..07067da9b5 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/appsettings/AppDeveloperSettingsState.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/appsettings/AppDeveloperSettingsState.kt @@ -15,6 +15,7 @@ import io.element.android.libraries.matrix.api.tracing.TraceLogPack import kotlinx.collections.immutable.ImmutableList data class AppDeveloperSettingsState( + val isDeveloperModeEnabled: Boolean, val features: ImmutableList, val rageshakeState: RageshakePreferencesState, val customElementCallBaseUrlState: CustomElementCallBaseUrlState, diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/appsettings/AppDeveloperSettingsStatePreviewParam.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/appsettings/AppDeveloperSettingsStatePreviewParam.kt index 480b8426d7..272ecb6a91 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/appsettings/AppDeveloperSettingsStatePreviewParam.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/appsettings/AppDeveloperSettingsStatePreviewParam.kt @@ -24,14 +24,19 @@ open class AppDeveloperSettingsStatePreviewParam : PreviewParameterProvider = emptyList(), eventSink: (AppDeveloperSettingsEvent) -> Unit = {}, ) = AppDeveloperSettingsState( + isDeveloperModeEnabled = isDeveloperModeEnabled, features = aFeatureUiModelList(), rageshakeState = aRageshakePreferencesState(), customElementCallBaseUrlState = customElementCallBaseUrlState, diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/appsettings/AppDeveloperSettingsView.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/appsettings/AppDeveloperSettingsView.kt index 1285269ca9..7f10745872 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/appsettings/AppDeveloperSettingsView.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/appsettings/AppDeveloperSettingsView.kt @@ -24,6 +24,7 @@ import io.element.android.features.preferences.impl.R import io.element.android.features.preferences.impl.developer.tracing.LogLevelItem import io.element.android.features.rageshake.api.preferences.RageshakePreferencesView import io.element.android.libraries.androidutils.system.copyToClipboard +import io.element.android.libraries.designsystem.components.list.ListItemContent import io.element.android.libraries.designsystem.components.preferences.PreferenceCategory import io.element.android.libraries.designsystem.components.preferences.PreferenceDropdown import io.element.android.libraries.designsystem.components.preferences.PreferenceSwitch @@ -48,6 +49,18 @@ fun AppDeveloperSettingsView( modifier = modifier.fillMaxWidth(), ) { // Note: this is OK to hardcode strings in this debug screen. + ListItem( + content = { + Text(text = stringResource(id = CommonStrings.action_view_source)) + }, + supportingContent = { + Text(text = stringResource(id = R.string.screen_advanced_settings_view_source_description)) + }, + trailingContent = ListItemContent.Switch( + checked = state.isDeveloperModeEnabled, + ), + onClick = { state.eventSink(AppDeveloperSettingsEvent.SetDeveloperModeEnabled(!state.isDeveloperModeEnabled)) } + ) PreferenceCategory( title = "Feature flags", showTopDivider = false, diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/di/PreferencesBindingContainer.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/di/PreferencesBindingContainer.kt new file mode 100644 index 0000000000..96572f1d39 --- /dev/null +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/di/PreferencesBindingContainer.kt @@ -0,0 +1,23 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.preferences.impl.di + +import dev.zacsweers.metro.BindingContainer +import dev.zacsweers.metro.Binds +import dev.zacsweers.metro.ContributesTo +import io.element.android.features.preferences.impl.account.PreferencesAccountPresenter +import io.element.android.features.preferences.impl.account.PreferencesAccountState +import io.element.android.libraries.architecture.Presenter +import io.element.android.libraries.di.SessionScope + +@ContributesTo(SessionScope::class) +@BindingContainer +interface PreferencesBindingContainer { + @Binds + fun bindPreferencesAccountPresenter(presenter: PreferencesAccountPresenter): Presenter +} diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/location/LocationSettingsEvent.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/location/LocationSettingsEvent.kt new file mode 100644 index 0000000000..8461621637 --- /dev/null +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/location/LocationSettingsEvent.kt @@ -0,0 +1,12 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.preferences.impl.location + +sealed interface LocationSettingsEvent { + data class SetLiveLocationMinimumDistanceUpdate(val value: Int) : LocationSettingsEvent +} diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsNode.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/location/LocationSettingsNode.kt similarity index 81% rename from features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsNode.kt rename to features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/location/LocationSettingsNode.kt index 96b1cddc94..629dfeb3d2 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsNode.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/location/LocationSettingsNode.kt @@ -1,12 +1,11 @@ /* - * Copyright (c) 2025 Element Creations Ltd. - * Copyright 2023-2025 New Vector Ltd. + * Copyright (c) 2026 Element Creations Ltd. * * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. * Please see LICENSE files in the repository root for full details. */ -package io.element.android.features.preferences.impl.advanced +package io.element.android.features.preferences.impl.location import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier @@ -22,20 +21,20 @@ import io.element.android.libraries.di.SessionScope @ContributesNode(SessionScope::class) @AssistedInject -class AdvancedSettingsNode( +class LocationSettingsNode( @Assisted buildContext: BuildContext, @Assisted plugins: List, - private val presenter: AdvancedSettingsPresenter, + private val presenter: LocationSettingsPresenter, ) : Node(buildContext, plugins = plugins) { @Composable override fun View(modifier: Modifier) { val state = presenter.present() val context = LocalContext.current - AdvancedSettingsView( + LocationSettingsView( state = state, modifier = modifier, onBackClick = ::navigateUp, - onOpenAppSettingsClick = context::openAppSettingsPage + onOpenAppSettingsClick = context::openAppSettingsPage, ) } } diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/location/LocationSettingsPresenter.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/location/LocationSettingsPresenter.kt new file mode 100644 index 0000000000..9461b82dad --- /dev/null +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/location/LocationSettingsPresenter.kt @@ -0,0 +1,45 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.preferences.impl.location + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.produceState +import dev.zacsweers.metro.Inject +import io.element.android.libraries.architecture.Presenter +import io.element.android.libraries.di.annotations.SessionCoroutineScope +import io.element.android.libraries.preferences.api.store.AppPreferencesStore +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch + +@Inject +class LocationSettingsPresenter( + private val appPreferencesStore: AppPreferencesStore, + @SessionCoroutineScope + private val sessionCoroutineScope: CoroutineScope, +) : Presenter { + @Composable + override fun present(): LocationSettingsState { + val liveLocationMinimumDistanceUpdate by produceState(10) { + appPreferencesStore.getLiveLocationMinimumDistanceInMetersUpdateFlow().collect { value = it } + } + + fun handleEvent(event: LocationSettingsEvent) { + when (event) { + is LocationSettingsEvent.SetLiveLocationMinimumDistanceUpdate -> sessionCoroutineScope.launch { + appPreferencesStore.setLiveLocationMinimumDistanceInMetersUpdate(event.value) + } + } + } + + return LocationSettingsState( + liveLocationMinimumDistanceUpdate = liveLocationMinimumDistanceUpdate, + eventSink = ::handleEvent, + ) + } +} diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/location/LocationSettingsState.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/location/LocationSettingsState.kt new file mode 100644 index 0000000000..1328b7c194 --- /dev/null +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/location/LocationSettingsState.kt @@ -0,0 +1,13 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.preferences.impl.location + +data class LocationSettingsState( + val liveLocationMinimumDistanceUpdate: Int, + val eventSink: (LocationSettingsEvent) -> Unit +) diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/location/LocationSettingsStatePreviewParam.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/location/LocationSettingsStatePreviewParam.kt new file mode 100644 index 0000000000..cede867a1e --- /dev/null +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/location/LocationSettingsStatePreviewParam.kt @@ -0,0 +1,26 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.preferences.impl.location + +import androidx.compose.ui.tooling.preview.PreviewParameterProvider + +open class LocationSettingsStatePreviewParam : PreviewParameterProvider { + override val values: Sequence + get() = sequenceOf( + aLocationSettingsState(), + aLocationSettingsState(liveLocationMinimumDistanceUpdate = 80), + ) +} + +fun aLocationSettingsState( + liveLocationMinimumDistanceUpdate: Int = 50, + eventSink: (LocationSettingsEvent) -> Unit = {}, +) = LocationSettingsState( + liveLocationMinimumDistanceUpdate = liveLocationMinimumDistanceUpdate, + eventSink = eventSink +) diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/location/LocationSettingsView.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/location/LocationSettingsView.kt new file mode 100644 index 0000000000..9fb6719978 --- /dev/null +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/location/LocationSettingsView.kt @@ -0,0 +1,152 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.preferences.impl.location + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.SliderDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +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.preferences.impl.R +import io.element.android.libraries.designsystem.components.preferences.PreferencePage +import io.element.android.libraries.designsystem.text.stringWithLink +import io.element.android.libraries.designsystem.theme.components.ListSectionHeader +import io.element.android.libraries.designsystem.theme.components.ListSupportingText +import io.element.android.libraries.designsystem.theme.components.ListSupportingTextDefaults +import io.element.android.libraries.designsystem.theme.components.Slider +import io.element.android.libraries.designsystem.theme.components.Text +import io.element.android.libraries.designsystem.utils.snackbar.LocalSnackbarDispatcher +import io.element.android.libraries.designsystem.utils.snackbar.SnackbarHost +import io.element.android.libraries.designsystem.utils.snackbar.collectSnackbarMessageAsState +import io.element.android.libraries.designsystem.utils.snackbar.rememberSnackbarHostState +import io.element.android.libraries.ui.strings.CommonStrings +import kotlin.math.roundToInt + +@Composable +fun LocationSettingsView( + state: LocationSettingsState, + onBackClick: () -> Unit, + onOpenAppSettingsClick: () -> Unit, + modifier: Modifier = Modifier, +) { + val snackbarDispatcher = LocalSnackbarDispatcher.current + val snackbarMessage by snackbarDispatcher.collectSnackbarMessageAsState() + val snackbarHostState = rememberSnackbarHostState(snackbarMessage = snackbarMessage) + + PreferencePage( + modifier = modifier, + onBackClick = onBackClick, + title = stringResource(id = CommonStrings.common_location_sharing), + snackbarHost = { + SnackbarHost( + snackbarHostState, + modifier = Modifier.navigationBarsPadding() + ) + } + ) { + LiveLocationUpdatesSection( + value = state.liveLocationMinimumDistanceUpdate, + onSaveValue = { value -> + state.eventSink(LocationSettingsEvent.SetLiveLocationMinimumDistanceUpdate(value)) + }, + onOpenAppPermissionsClick = onOpenAppSettingsClick, + ) + } +} + +@Composable +private fun ColumnScope.LiveLocationUpdatesSection( + value: Int, + onSaveValue: (Int) -> Unit, + onOpenAppPermissionsClick: () -> Unit, +) { + ListSectionHeader( + title = stringResource(R.string.screen_advanced_settings_live_location_section_title), + description = { + ListSupportingText( + text = stringResource(R.string.screen_advanced_settings_live_location_section_description), + contentPadding = ListSupportingTextDefaults.Padding.None, + ) + }, + hasDivider = false, + ) + var sliderValue by remember(value) { mutableIntStateOf(value) } + Column( + modifier = Modifier.padding(vertical = 12.dp, horizontal = 16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + Text( + text = pluralStringResource( + R.plurals.screen_advanced_settings_live_location_update_distance, + sliderValue, + sliderValue, + ), + style = ElementTheme.typography.fontBodyLgRegular, + color = ElementTheme.colors.textPrimary, + ) + val valueRange = 1f..100f + val start = valueRange.start.toInt() + val end = valueRange.endInclusive.toInt() + Row(verticalAlignment = Alignment.CenterVertically) { + Text("${start}m", color = ElementTheme.colors.textSecondary, style = ElementTheme.typography.fontBodyMdRegular) + Slider( + modifier = Modifier + .weight(1f) + .padding(horizontal = 12.dp), + value = sliderValue.toFloat(), + onValueChange = { sliderValue = it.roundToInt() }, + onValueChangeFinish = { + onSaveValue(sliderValue) + }, + valueRange = valueRange, + colors = SliderDefaults.colors( + thumbColor = ElementTheme.colors.iconAccentPrimary, + activeTrackColor = ElementTheme.colors.iconAccentPrimary, + inactiveTrackColor = ElementTheme.colors.bgBadgeAccent, + inactiveTickColor = ElementTheme.colors.iconAccentPrimary, + ) + ) + Text("${end}m", color = ElementTheme.colors.textSecondary, style = ElementTheme.typography.fontBodyMdRegular) + } + } + val footerText = stringWithLink( + textRes = R.string.screen_advanced_settings_live_location_section_footer, + url = "", + linkTextRes = R.string.screen_advanced_settings_live_location_section_footer_link, + onLinkClick = { onOpenAppPermissionsClick() }, + ) + ListSupportingText( + annotatedString = footerText, + contentPadding = ListSupportingTextDefaults.Padding.Default, + ) +} + +@Preview +@Composable +internal fun LocationSettingsViewPreview(@PreviewParameter(LocationSettingsStatePreviewParam::class) state: LocationSettingsState) { + LocationSettingsView( + state = state, + onBackClick = { }, + onOpenAppSettingsClick = {} + ) +} diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/media/MediaSettingsEvent.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/media/MediaSettingsEvent.kt new file mode 100644 index 0000000000..7f8af796ad --- /dev/null +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/media/MediaSettingsEvent.kt @@ -0,0 +1,16 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.preferences.impl.media + +import io.element.android.libraries.preferences.api.store.VideoCompressionPreset + +sealed interface MediaSettingsEvent { + data class SetCompressMedia(val compress: Boolean) : MediaSettingsEvent + data class SetVideoUploadQuality(val videoPreset: VideoCompressionPreset) : MediaSettingsEvent +} diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/media/MediaSettingsNode.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/media/MediaSettingsNode.kt new file mode 100644 index 0000000000..826341f0f0 --- /dev/null +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/media/MediaSettingsNode.kt @@ -0,0 +1,37 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.preferences.impl.media + +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 dev.zacsweers.metro.Assisted +import dev.zacsweers.metro.AssistedInject +import io.element.android.annotations.ContributesNode +import io.element.android.libraries.di.SessionScope + +@ContributesNode(SessionScope::class) +@AssistedInject +class MediaSettingsNode( + @Assisted buildContext: BuildContext, + @Assisted plugins: List, + private val presenter: MediaSettingsPresenter, +) : Node(buildContext, plugins = plugins) { + @Composable + override fun View(modifier: Modifier) { + val state = presenter.present() + MediaSettingsView( + state = state, + modifier = modifier, + onBackClick = ::navigateUp, + ) + } +} diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/media/MediaSettingsPresenter.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/media/MediaSettingsPresenter.kt new file mode 100644 index 0000000000..35a1ed63f9 --- /dev/null +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/media/MediaSettingsPresenter.kt @@ -0,0 +1,67 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.preferences.impl.media + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.produceState +import dev.zacsweers.metro.Inject +import io.element.android.libraries.architecture.Presenter +import io.element.android.libraries.di.annotations.SessionCoroutineScope +import io.element.android.libraries.featureflag.api.FeatureFlagService +import io.element.android.libraries.featureflag.api.FeatureFlags +import io.element.android.libraries.preferences.api.store.SessionPreferencesStore +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.launch + +@Inject +class MediaSettingsPresenter( + private val sessionPreferencesStore: SessionPreferencesStore, + @SessionCoroutineScope + private val sessionCoroutineScope: CoroutineScope, + private val featureFlagService: FeatureFlagService, +) : Presenter { + @Composable + override fun present(): MediaSettingsState { + val mediaOptimizationState by produceState(null) { + val hasSplitMediaQualityOptionsFlow = featureFlagService.isFeatureEnabledFlow(FeatureFlags.SelectableMediaQuality) + combine( + hasSplitMediaQualityOptionsFlow, + sessionPreferencesStore.doesOptimizeImages(), + sessionPreferencesStore.getVideoCompressionPreset() + ) { hasSplitOptions, compressImages, videoPreset -> + if (hasSplitOptions) { + MediaOptimizationState.Split( + compressImages = compressImages, + videoPreset = videoPreset, + ) + } else { + MediaOptimizationState.AllMedia(isEnabled = compressImages) + } + }.collect { value = it } + } + + fun handleEvent(event: MediaSettingsEvent) { + when (event) { + is MediaSettingsEvent.SetCompressMedia -> sessionCoroutineScope.launch { + sessionPreferencesStore.setOptimizeImages(event.compress) + } + is MediaSettingsEvent.SetVideoUploadQuality -> sessionCoroutineScope.launch { + sessionPreferencesStore.setVideoCompressionPreset(event.videoPreset) + } + } + } + + return MediaSettingsState( + mediaOptimizationState = mediaOptimizationState, + eventSink = ::handleEvent, + ) + } +} diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/media/MediaSettingsState.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/media/MediaSettingsState.kt new file mode 100644 index 0000000000..d6796e9719 --- /dev/null +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/media/MediaSettingsState.kt @@ -0,0 +1,30 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.preferences.impl.media + +import io.element.android.libraries.preferences.api.store.VideoCompressionPreset + +data class MediaSettingsState( + val mediaOptimizationState: MediaOptimizationState?, + val eventSink: (MediaSettingsEvent) -> Unit +) + +sealed interface MediaOptimizationState { + data class AllMedia(val isEnabled: Boolean) : MediaOptimizationState + data class Split( + val compressImages: Boolean, + val videoPreset: VideoCompressionPreset, + ) : MediaOptimizationState + + val shouldCompressImages: Boolean + get() = when (this) { + is AllMedia -> isEnabled + is Split -> compressImages + } +} diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/media/MediaSettingsStatePreviewParam.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/media/MediaSettingsStatePreviewParam.kt new file mode 100644 index 0000000000..d12743e4aa --- /dev/null +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/media/MediaSettingsStatePreviewParam.kt @@ -0,0 +1,34 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.preferences.impl.media + +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import io.element.android.libraries.preferences.api.store.VideoCompressionPreset + +open class MediaSettingsStatePreviewParam : PreviewParameterProvider { + override val values: Sequence + get() = sequenceOf( + aMediaSettingsState(), + aMediaSettingsState(mediaOptimizationState = MediaOptimizationState.AllMedia(isEnabled = true)), + aMediaSettingsState( + mediaOptimizationState = MediaOptimizationState.Split( + compressImages = true, + videoPreset = VideoCompressionPreset.HIGH, + ) + ), + ) +} + +fun aMediaSettingsState( + mediaOptimizationState: MediaOptimizationState? = MediaOptimizationState.AllMedia(isEnabled = false), + eventSink: (MediaSettingsEvent) -> Unit = {}, +) = MediaSettingsState( + mediaOptimizationState = mediaOptimizationState, + eventSink = eventSink +) diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/media/MediaSettingsView.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/media/MediaSettingsView.kt new file mode 100644 index 0000000000..88c6cd3702 --- /dev/null +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/media/MediaSettingsView.kt @@ -0,0 +1,230 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.preferences.impl.media + +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.PreviewParameter +import im.vector.app.features.analytics.plan.Interaction +import io.element.android.compound.theme.ElementTheme +import io.element.android.features.preferences.impl.R +import io.element.android.libraries.designsystem.components.dialogs.ListDialog +import io.element.android.libraries.designsystem.components.list.ListItemContent +import io.element.android.libraries.designsystem.components.preferences.PreferencePage +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +import io.element.android.libraries.designsystem.theme.components.ListItem +import io.element.android.libraries.designsystem.theme.components.Text +import io.element.android.libraries.designsystem.utils.snackbar.LocalSnackbarDispatcher +import io.element.android.libraries.designsystem.utils.snackbar.SnackbarHost +import io.element.android.libraries.designsystem.utils.snackbar.collectSnackbarMessageAsState +import io.element.android.libraries.designsystem.utils.snackbar.rememberSnackbarHostState +import io.element.android.libraries.preferences.api.store.VideoCompressionPreset +import io.element.android.libraries.ui.strings.CommonStrings +import io.element.android.services.analytics.compose.LocalAnalyticsService +import io.element.android.services.analyticsproviders.api.trackers.captureInteraction + +@Composable +fun MediaSettingsView( + state: MediaSettingsState, + onBackClick: () -> Unit, + modifier: Modifier = Modifier, +) { + val analyticsService = LocalAnalyticsService.current + + val snackbarDispatcher = LocalSnackbarDispatcher.current + val snackbarMessage by snackbarDispatcher.collectSnackbarMessageAsState() + val snackbarHostState = rememberSnackbarHostState(snackbarMessage = snackbarMessage) + + PreferencePage( + modifier = modifier, + onBackClick = onBackClick, + title = stringResource(id = CommonStrings.common_media_upload_quality), + snackbarHost = { + SnackbarHost( + snackbarHostState, + modifier = Modifier.navigationBarsPadding() + ) + } + ) { + val compressImages = state.mediaOptimizationState?.shouldCompressImages + + when (state.mediaOptimizationState) { + null -> Unit + is MediaOptimizationState.AllMedia -> { + ListItem( + content = { + Text(text = stringResource(id = R.string.screen_advanced_settings_media_compression_title)) + }, + supportingContent = { + Text(text = stringResource(id = R.string.screen_advanced_settings_media_compression_description)) + }, + trailingContent = ListItemContent.Switch( + checked = compressImages ?: false, + ), + onClick = { + val newValue = !(compressImages ?: false) + analyticsService.captureInteraction( + if (newValue) { + Interaction.Name.MobileSettingsOptimizeMediaUploadsEnabled + } else { + Interaction.Name.MobileSettingsOptimizeMediaUploadsDisabled + } + ) + state.eventSink(MediaSettingsEvent.SetCompressMedia(newValue)) + } + ) + } + is MediaOptimizationState.Split -> { + ListItem( + content = { + Text(text = stringResource(id = R.string.screen_advanced_settings_optimise_image_upload_quality_title)) + }, + supportingContent = { + Text(text = stringResource(id = R.string.screen_advanced_settings_optimise_image_upload_quality_description)) + }, + trailingContent = ListItemContent.Switch( + checked = compressImages ?: false, + ), + onClick = { + val newValue = !(compressImages ?: false) + analyticsService.captureInteraction( + if (newValue) { + Interaction.Name.MobileSettingsOptimizeMediaUploadsEnabled + } else { + Interaction.Name.MobileSettingsOptimizeMediaUploadsDisabled + } + ) + state.eventSink(MediaSettingsEvent.SetCompressMedia(newValue)) + } + ) + + var displaySelectorDialog by remember { mutableStateOf(false) } + + ListItem( + content = { + Text(text = stringResource(id = R.string.screen_advanced_settings_optimise_video_upload_quality_title)) + }, + supportingContent = { + val description = stringResource(id = R.string.screen_advanced_settings_optimise_video_upload_quality_description) + val quality = when (state.mediaOptimizationState.videoPreset) { + VideoCompressionPreset.LOW -> stringResource(id = R.string.screen_advanced_settings_optimise_video_upload_quality_low) + VideoCompressionPreset.STANDARD -> stringResource(id = R.string.screen_advanced_settings_optimise_video_upload_quality_standard) + VideoCompressionPreset.HIGH -> stringResource(id = R.string.screen_advanced_settings_optimise_video_upload_quality_high) + } + val descriptionWithValue = remember(quality) { + String.format(description, quality) + } + Text(text = descriptionWithValue) + }, + onClick = { displaySelectorDialog = true }, + ) + + if (displaySelectorDialog) { + VideoQualitySelectorDialog( + selectedPreset = state.mediaOptimizationState.videoPreset, + onSubmit = { preset -> + state.eventSink(MediaSettingsEvent.SetVideoUploadQuality(preset)) + displaySelectorDialog = false + }, + onDismiss = { displaySelectorDialog = false }, + ) + } + } + } + } +} + +@Composable +private fun VideoQualitySelectorDialog( + selectedPreset: VideoCompressionPreset, + onSubmit: (VideoCompressionPreset) -> Unit, + onDismiss: () -> Unit +) { + val videoPresets = VideoCompressionPreset.entries + var localSelectedPreset by remember { mutableStateOf(selectedPreset) } + ListDialog( + title = stringResource(CommonStrings.dialog_video_quality_selector_title), + subtitle = stringResource(CommonStrings.dialog_default_video_quality_selector_subtitle), + onSubmit = { onSubmit(localSelectedPreset) }, + onDismissRequest = onDismiss, + applyPaddingToContents = false, + ) { + for (preset in videoPresets) { + val isSelected = preset == localSelectedPreset + item( + key = preset, + contentType = preset, + ) { + val title = when (preset) { + VideoCompressionPreset.LOW -> stringResource(R.string.screen_advanced_settings_optimise_video_upload_quality_low) + VideoCompressionPreset.STANDARD -> stringResource(R.string.screen_advanced_settings_optimise_video_upload_quality_standard) + VideoCompressionPreset.HIGH -> stringResource(R.string.screen_advanced_settings_optimise_video_upload_quality_high) + } + val subtitle = when (preset) { + VideoCompressionPreset.LOW -> stringResource(CommonStrings.common_video_quality_low_description) + VideoCompressionPreset.STANDARD -> stringResource(CommonStrings.common_video_quality_standard_description) + VideoCompressionPreset.HIGH -> stringResource(CommonStrings.common_video_quality_high_description) + } + ListItem( + content = { + Text( + text = title, + style = ElementTheme.typography.fontBodyLgMedium, + ) + }, + supportingContent = { + Text( + text = subtitle, + style = ElementTheme.typography.fontBodyMdRegular, + color = ElementTheme.colors.textSecondary, + ) + }, + leadingContent = ListItemContent.RadioButton( + selected = isSelected, + ), + onClick = { + localSelectedPreset = preset + }, + ) + } + } + } +} + +@Composable +@PreviewsDayNight +internal fun MediaSettingsViewPreview( + @PreviewParameter(MediaSettingsStatePreviewParam::class) state: MediaSettingsState, +) { + ElementPreview { + MediaSettingsView( + state = state, + onBackClick = { }, + ) + } +} + +@Composable +@PreviewsDayNight +internal fun VideoQualitySelectorDialogPreview() { + ElementPreview { + VideoQualitySelectorDialog( + selectedPreset = VideoCompressionPreset.STANDARD, + onSubmit = { /* no-op */ }, + onDismiss = { /* no-op */ } + ) + } +} diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/advanced/MediaPreviewConfigStateStore.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/moderation/MediaPreviewConfigStateStore.kt similarity index 98% rename from features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/advanced/MediaPreviewConfigStateStore.kt rename to features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/moderation/MediaPreviewConfigStateStore.kt index d23168e29a..e13bbf84f8 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/advanced/MediaPreviewConfigStateStore.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/moderation/MediaPreviewConfigStateStore.kt @@ -6,7 +6,7 @@ * Please see LICENSE files in the repository root for full details. */ -package io.element.android.features.preferences.impl.advanced +package io.element.android.features.preferences.impl.moderation import androidx.compose.runtime.Composable import androidx.compose.runtime.mutableStateOf diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/moderation/ModerationAndSafetyEvent.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/moderation/ModerationAndSafetyEvent.kt new file mode 100644 index 0000000000..8bcc07ad9a --- /dev/null +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/moderation/ModerationAndSafetyEvent.kt @@ -0,0 +1,16 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.preferences.impl.moderation + +import io.element.android.libraries.matrix.api.media.MediaPreviewValue + +sealed interface ModerationAndSafetyEvent { + data class SetSharePresenceEnabled(val enabled: Boolean) : ModerationAndSafetyEvent + data class SetHideInviteAvatars(val value: Boolean) : ModerationAndSafetyEvent + data class SetTimelineMediaPreviewValue(val value: MediaPreviewValue) : ModerationAndSafetyEvent +} diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/moderation/ModerationAndSafetyNode.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/moderation/ModerationAndSafetyNode.kt new file mode 100644 index 0000000000..9ea6fe4bf5 --- /dev/null +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/moderation/ModerationAndSafetyNode.kt @@ -0,0 +1,44 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.preferences.impl.moderation + +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 dev.zacsweers.metro.Assisted +import dev.zacsweers.metro.AssistedInject +import io.element.android.annotations.ContributesNode +import io.element.android.libraries.architecture.callback +import io.element.android.libraries.di.SessionScope + +@ContributesNode(SessionScope::class) +@AssistedInject +class ModerationAndSafetyNode( + @Assisted buildContext: BuildContext, + @Assisted plugins: List, + private val presenter: ModerationAndSafetyPresenter, +) : Node(buildContext, plugins = plugins) { + interface Callback : Plugin { + fun navigateToBlockedUsers() + } + + private val callback: Callback = callback() + + @Composable + override fun View(modifier: Modifier) { + val state = presenter.present() + ModerationAndSafetyView( + state = state, + modifier = modifier, + onBackClick = ::navigateUp, + onOpenBlockedUsers = callback::navigateToBlockedUsers, + ) + } +} diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/moderation/ModerationAndSafetyPresenter.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/moderation/ModerationAndSafetyPresenter.kt new file mode 100644 index 0000000000..dbfbce2fdf --- /dev/null +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/moderation/ModerationAndSafetyPresenter.kt @@ -0,0 +1,64 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.preferences.impl.moderation + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.produceState +import androidx.compose.runtime.remember +import dev.zacsweers.metro.Inject +import io.element.android.libraries.architecture.Presenter +import io.element.android.libraries.di.annotations.SessionCoroutineScope +import io.element.android.libraries.matrix.api.MatrixClient +import io.element.android.libraries.preferences.api.store.SessionPreferencesStore +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.launch + +@Inject +class ModerationAndSafetyPresenter( + private val matrixClient: MatrixClient, + private val sessionPreferencesStore: SessionPreferencesStore, + private val mediaPreviewConfigStateStore: MediaPreviewConfigStateStore, + @SessionCoroutineScope + private val sessionCoroutineScope: CoroutineScope, +) : Presenter { + @Composable + override fun present(): ModerationAndSafetyState { + val isSharePresenceEnabled by remember { + sessionPreferencesStore.isSharePresenceEnabled() + }.collectAsState(initial = true) + + val mediaPreviewConfigState = mediaPreviewConfigStateStore.state() + + val numberOfBlockedUsers by produceState(initialValue = 0) { + matrixClient.ignoredUsersFlow + .onEach { value = it.size } + .launchIn(this) + } + + fun handleEvent(event: ModerationAndSafetyEvent) { + when (event) { + is ModerationAndSafetyEvent.SetSharePresenceEnabled -> sessionCoroutineScope.launch { + sessionPreferencesStore.setSharePresence(event.enabled) + } + is ModerationAndSafetyEvent.SetHideInviteAvatars -> mediaPreviewConfigStateStore.setHideInviteAvatars(event.value) + is ModerationAndSafetyEvent.SetTimelineMediaPreviewValue -> mediaPreviewConfigStateStore.setTimelineMediaPreviewValue(event.value) + } + } + + return ModerationAndSafetyState( + isSharePresenceEnabled = isSharePresenceEnabled, + mediaPreviewConfigState = mediaPreviewConfigState, + numberOfBlockedUsers = numberOfBlockedUsers, + eventSink = ::handleEvent, + ) + } +} diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/moderation/ModerationAndSafetyState.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/moderation/ModerationAndSafetyState.kt new file mode 100644 index 0000000000..4ff0513294 --- /dev/null +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/moderation/ModerationAndSafetyState.kt @@ -0,0 +1,17 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.preferences.impl.moderation + +data class ModerationAndSafetyState( + val isSharePresenceEnabled: Boolean, + val mediaPreviewConfigState: MediaPreviewConfigState, + val numberOfBlockedUsers: Int, + val eventSink: (ModerationAndSafetyEvent) -> Unit +) { + val showBlockedUsersItem = numberOfBlockedUsers > 0 +} diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/moderation/ModerationAndSafetyStatePreviewParam.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/moderation/ModerationAndSafetyStatePreviewParam.kt new file mode 100644 index 0000000000..27612216d3 --- /dev/null +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/moderation/ModerationAndSafetyStatePreviewParam.kt @@ -0,0 +1,45 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.preferences.impl.moderation + +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import io.element.android.libraries.architecture.AsyncAction +import io.element.android.libraries.matrix.api.media.MediaPreviewValue + +open class ModerationAndSafetyStatePreviewParam : PreviewParameterProvider { + override val values: Sequence + get() = sequenceOf( + aModerationAndSafetyState(), + aModerationAndSafetyState(isSharePresenceEnabled = true), + aModerationAndSafetyState(hideInviteAvatars = true), + aModerationAndSafetyState(timelineMediaPreviewValue = MediaPreviewValue.Off), + aModerationAndSafetyState(setHideInviteAvatarsAction = AsyncAction.Loading), + aModerationAndSafetyState(setTimelineMediaPreviewAction = AsyncAction.Loading), + aModerationAndSafetyState(numberOfBlockedUsers = 3), + ) +} + +fun aModerationAndSafetyState( + isSharePresenceEnabled: Boolean = false, + hideInviteAvatars: Boolean = false, + timelineMediaPreviewValue: MediaPreviewValue = MediaPreviewValue.On, + setTimelineMediaPreviewAction: AsyncAction = AsyncAction.Uninitialized, + setHideInviteAvatarsAction: AsyncAction = AsyncAction.Uninitialized, + numberOfBlockedUsers: Int = 0, + eventSink: (ModerationAndSafetyEvent) -> Unit = {}, +) = ModerationAndSafetyState( + isSharePresenceEnabled = isSharePresenceEnabled, + mediaPreviewConfigState = MediaPreviewConfigState( + hideInviteAvatars = hideInviteAvatars, + timelineMediaPreviewValue = timelineMediaPreviewValue, + setTimelineMediaPreviewAction = setTimelineMediaPreviewAction, + setHideInviteAvatarsAction = setHideInviteAvatarsAction + ), + numberOfBlockedUsers = numberOfBlockedUsers, + eventSink = eventSink +) diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/moderation/ModerationAndSafetyView.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/moderation/ModerationAndSafetyView.kt new file mode 100644 index 0000000000..3322f28413 --- /dev/null +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/moderation/ModerationAndSafetyView.kt @@ -0,0 +1,207 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.preferences.impl.moderation + +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.PreviewParameter +import io.element.android.compound.tokens.generated.CompoundIcons +import io.element.android.features.preferences.impl.R +import io.element.android.libraries.architecture.coverage.ExcludeFromCoverage +import io.element.android.libraries.designsystem.components.list.ListItemContent +import io.element.android.libraries.designsystem.components.preferences.PreferenceCategory +import io.element.android.libraries.designsystem.components.preferences.PreferencePage +import io.element.android.libraries.designsystem.components.preferences.PreferenceSwitch +import io.element.android.libraries.designsystem.preview.ElementPreviewBlack +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.ListSectionHeader +import io.element.android.libraries.designsystem.theme.components.ListSupportingText +import io.element.android.libraries.designsystem.theme.components.ListSupportingTextDefaults +import io.element.android.libraries.designsystem.theme.components.Text +import io.element.android.libraries.designsystem.utils.snackbar.LocalSnackbarDispatcher +import io.element.android.libraries.designsystem.utils.snackbar.SnackbarHost +import io.element.android.libraries.designsystem.utils.snackbar.collectSnackbarMessageAsState +import io.element.android.libraries.designsystem.utils.snackbar.rememberSnackbarHostState +import io.element.android.libraries.matrix.api.media.MediaPreviewValue +import io.element.android.libraries.ui.strings.CommonStrings + +/** + * Ref: https://www.figma.com/design/kEAcfun9iSpszeUDvdKZ6b/ER-351--Multi-account-in-EX?node-id=606-56426 + */ +@Composable +fun ModerationAndSafetyView( + state: ModerationAndSafetyState, + onBackClick: () -> Unit, + onOpenBlockedUsers: () -> Unit, + modifier: Modifier = Modifier, +) { + val snackbarDispatcher = LocalSnackbarDispatcher.current + val snackbarMessage by snackbarDispatcher.collectSnackbarMessageAsState() + val snackbarHostState = rememberSnackbarHostState(snackbarMessage = snackbarMessage) + + PreferencePage( + modifier = modifier, + onBackClick = onBackClick, + title = stringResource(id = CommonStrings.common_moderation_and_safety), + snackbarHost = { + SnackbarHost( + snackbarHostState, + modifier = Modifier.navigationBarsPadding() + ) + } + ) { + PresenceSection(state) + InvitesSection(state) + MediaSection(state) + if (state.showBlockedUsersItem) { + BlockedUsersSection( + numberOfBlockedUsers = state.numberOfBlockedUsers, + onOpenBlockedUsers = onOpenBlockedUsers, + ) + } + } +} + +@Composable +private fun BlockedUsersSection( + numberOfBlockedUsers: Int, + onOpenBlockedUsers: () -> Unit, +) { + PreferenceCategory( + title = stringResource(R.string.screen_moderation_and_safety_other_users_heading), + ) { + ListItem( + content = { Text(stringResource(id = CommonStrings.common_blocked_users)) }, + leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Block())), + onClick = onOpenBlockedUsers, + trailingContent = ListItemContent.Text(numberOfBlockedUsers.toString()), + ) + } +} + +@Composable +private fun PresenceSection(state: ModerationAndSafetyState) { + PreferenceCategory( + title = stringResource(R.string.screen_moderation_and_safety_share_presence_heading), + ) { + ListItem( + content = { + Text(text = stringResource(id = R.string.screen_advanced_settings_share_presence)) + }, + supportingContent = { + Text(text = stringResource(id = R.string.screen_advanced_settings_share_presence_description)) + }, + trailingContent = ListItemContent.Switch( + checked = state.isSharePresenceEnabled, + ), + onClick = { state.eventSink(ModerationAndSafetyEvent.SetSharePresenceEnabled(!state.isSharePresenceEnabled)) } + ) + } +} + +@Composable +private fun InvitesSection( + state: ModerationAndSafetyState, + modifier: Modifier = Modifier, +) { + PreferenceCategory( + modifier = modifier, + title = stringResource(R.string.screen_moderation_and_safety_invites_heading), + ) { + PreferenceSwitch( + title = stringResource(R.string.screen_advanced_settings_hide_invite_avatars_toggle_title), + isChecked = state.mediaPreviewConfigState.hideInviteAvatars, + onCheckedChange = { + state.eventSink(ModerationAndSafetyEvent.SetHideInviteAvatars(it)) + }, + enabled = !state.mediaPreviewConfigState.setHideInviteAvatarsAction.isLoading() + ) + } +} + +@Composable +private fun ColumnScope.MediaSection( + state: ModerationAndSafetyState, +) { + ListSectionHeader( + title = stringResource(R.string.screen_advanced_settings_show_media_timeline_title), + hasDivider = true, + description = { + ListSupportingText( + text = stringResource(R.string.screen_advanced_settings_show_media_timeline_subtitle), + contentPadding = ListSupportingTextDefaults.Padding.None, + ) + } + ) + ListItem( + content = { Text(text = stringResource(R.string.screen_advanced_settings_show_media_timeline_always_show)) }, + trailingContent = ListItemContent.RadioButton( + selected = state.mediaPreviewConfigState.timelineMediaPreviewValue == MediaPreviewValue.On, + compact = true + ), + onClick = { + state.eventSink(ModerationAndSafetyEvent.SetTimelineMediaPreviewValue(MediaPreviewValue.On)) + }, + enabled = !state.mediaPreviewConfigState.setTimelineMediaPreviewAction.isLoading() + ) + ListItem( + content = { Text(text = stringResource(R.string.screen_advanced_settings_show_media_timeline_private_rooms)) }, + trailingContent = ListItemContent.RadioButton( + selected = state.mediaPreviewConfigState.timelineMediaPreviewValue == MediaPreviewValue.Private, + compact = true + ), + onClick = { + state.eventSink(ModerationAndSafetyEvent.SetTimelineMediaPreviewValue(MediaPreviewValue.Private)) + }, + enabled = !state.mediaPreviewConfigState.setTimelineMediaPreviewAction.isLoading() + ) + ListItem( + content = { Text(text = stringResource(R.string.screen_advanced_settings_show_media_timeline_always_hide)) }, + trailingContent = ListItemContent.RadioButton( + selected = state.mediaPreviewConfigState.timelineMediaPreviewValue == MediaPreviewValue.Off, + compact = true + ), + onClick = { + state.eventSink(ModerationAndSafetyEvent.SetTimelineMediaPreviewValue(MediaPreviewValue.Off)) + }, + enabled = !state.mediaPreviewConfigState.setTimelineMediaPreviewAction.isLoading() + ) +} + +@PreviewWithLargeHeight +@Composable +internal fun ModerationAndSafetyViewLightPreview(@PreviewParameter(ModerationAndSafetyStatePreviewParam::class) state: ModerationAndSafetyState) = + ElementPreviewLight { ContentToPreview(state) } + +@PreviewWithLargeHeight +@Composable +internal fun ModerationAndSafetyViewDarkPreview(@PreviewParameter(ModerationAndSafetyStatePreviewParam::class) state: ModerationAndSafetyState) = + ElementPreviewDark { ContentToPreview(state) } + +@PreviewWithLargeHeight +@Composable +internal fun ModerationAndSafetyViewBlackPreview(@PreviewParameter(ModerationAndSafetyStatePreviewParam::class) state: ModerationAndSafetyState) = + ElementPreviewBlack { ContentToPreview(state) } + +@ExcludeFromCoverage +@Composable +private fun ContentToPreview(state: ModerationAndSafetyState) { + ModerationAndSafetyView( + state = state, + onBackClick = { }, + onOpenBlockedUsers = { }, + ) +} diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootEvent.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootEvent.kt index 5a10a50ba6..be2c873a3b 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootEvent.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootEvent.kt @@ -13,4 +13,6 @@ import io.element.android.libraries.matrix.api.core.SessionId sealed interface PreferencesRootEvent { data object OnVersionInfoClick : PreferencesRootEvent data class SwitchToSession(val sessionId: SessionId) : PreferencesRootEvent + data object ToggleOtherAccountsExpanded : PreferencesRootEvent + data class SetTheme(val theme: ThemeOption) : PreferencesRootEvent } diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootNode.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootNode.kt index 4fdac07550..57ed6294fa 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootNode.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootNode.kt @@ -21,11 +21,11 @@ import io.element.android.annotations.ContributesNode import io.element.android.compound.theme.ElementTheme import io.element.android.features.logout.api.direct.DirectLogoutEvent import io.element.android.features.logout.api.direct.DirectLogoutView +import io.element.android.features.preferences.impl.account.PreferencesAccountCallback import io.element.android.libraries.androidutils.browser.openUrlInChromeCustomTab import io.element.android.libraries.architecture.callback import io.element.android.libraries.di.SessionScope import io.element.android.libraries.emoji.api.picker.EmojiPickerRenderer -import io.element.android.libraries.matrix.api.user.MatrixUser @ContributesNode(SessionScope::class) @AssistedInject @@ -33,25 +33,18 @@ class PreferencesRootNode( @Assisted buildContext: BuildContext, @Assisted plugins: List, private val presenter: PreferencesRootPresenter, - private val directLogoutView: DirectLogoutView, private val emojiPickerRenderer: EmojiPickerRenderer, + private val directLogoutView: DirectLogoutView, ) : Node(buildContext, plugins = plugins) { - interface Callback : Plugin { + interface Callback : PreferencesAccountCallback, Plugin { fun navigateToAddAccount() - fun navigateToBugReport() - fun navigateToSecureBackup() fun navigateToAnalyticsSettings() fun navigateToAbout() fun navigateToDeveloperSettings() - fun navigateToNotificationSettings() fun navigateToLockScreenSettings() - fun navigateToAdvancedSettings() + fun navigateToMediaSettings() + fun navigateToLocationSettings() fun navigateToLabs() - fun navigateToLinkNewDevice() - fun navigateToUserProfile(matrixUser: MatrixUser) - fun navigateToBlockedUsers() - fun startSignOutFlow() - fun startAccountDeactivationFlow() } private val callback: Callback = callback() @@ -75,28 +68,30 @@ class PreferencesRootNode( val state = presenter.present() val activity = requireNotNull(LocalActivity.current) val isDark = ElementTheme.isLightTheme.not() + PreferencesRootView( state = state, emojiPickerRenderer = emojiPickerRenderer, modifier = modifier, onBackClick = this::navigateUp, onAddAccountClick = callback::navigateToAddAccount, - onOpenRageShake = callback::navigateToBugReport, onOpenAnalytics = callback::navigateToAnalyticsSettings, onOpenAbout = callback::navigateToAbout, - onSecureBackupClick = callback::navigateToSecureBackup, onOpenDeveloperSettings = callback::navigateToDeveloperSettings, - onOpenAdvancedSettings = callback::navigateToAdvancedSettings, + onOpenMediaSettings = callback::navigateToMediaSettings, + onOpenLocationSettings = callback::navigateToLocationSettings, onOpenLabs = callback::navigateToLabs, + onOpenLockScreenSettings = callback::navigateToLockScreenSettings, + onOpenRageShake = callback::navigateToBugReport, + onSecureBackupClick = callback::navigateToSecureBackup, + onModerationAndSafetyClick = callback::navigateToModerationAndSafety, onLinkNewDeviceClick = callback::navigateToLinkNewDevice, onManageAccountClick = { onManageAccountClick(activity, it, isDark) }, onOpenNotificationSettings = callback::navigateToNotificationSettings, - onOpenLockScreenSettings = callback::navigateToLockScreenSettings, - onOpenUserProfile = callback::navigateToUserProfile, - onOpenBlockedUsers = callback::navigateToBlockedUsers, + onEditProfileClick = callback::navigateToUserProfile, onSignOutClick = { - if (state.directLogoutState.canDoDirectSignOut) { - state.directLogoutState.eventSink(DirectLogoutEvent.Logout(ignoreSdkError = false)) + if (state.preferencesAccountState.directLogoutState.canDoDirectSignOut) { + state.preferencesAccountState.directLogoutState.eventSink(DirectLogoutEvent.Logout(ignoreSdkError = false)) } else { callback.startSignOutFlow() } @@ -104,6 +99,6 @@ class PreferencesRootNode( onDeactivateClick = callback::startAccountDeactivationFlow ) - directLogoutView.Render(state = state.directLogoutState) + directLogoutView.Render(state = state.preferencesAccountState.directLogoutState) } } diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootPresenter.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootPresenter.kt index 3a720fc7a8..4bbe2eeaf8 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootPresenter.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootPresenter.kt @@ -10,55 +10,50 @@ package io.element.android.features.preferences.impl.root import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.MutableState import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue -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 dev.zacsweers.metro.Inject -import io.element.android.features.enterprise.api.SessionEnterpriseService -import io.element.android.features.logout.api.direct.DirectLogoutState +import io.element.android.compound.theme.Theme +import io.element.android.compound.theme.mapToTheme +import io.element.android.features.preferences.impl.account.PreferencesAccountState import io.element.android.features.preferences.impl.userstatus.UserStatusState import io.element.android.features.preferences.impl.utils.ShowDeveloperSettingsProvider -import io.element.android.features.rageshake.api.RageshakeFeatureAvailability import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher import io.element.android.libraries.designsystem.utils.snackbar.collectSnackbarMessageAsState +import io.element.android.libraries.di.annotations.SessionCoroutineScope import io.element.android.libraries.featureflag.api.FeatureFlagService import io.element.android.libraries.featureflag.api.FeatureFlags -import io.element.android.libraries.indicator.api.IndicatorService import io.element.android.libraries.matrix.api.MatrixClient import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.user.MatrixUser -import io.element.android.libraries.matrix.api.verification.SessionVerificationService +import io.element.android.libraries.preferences.api.store.AppPreferencesStore import io.element.android.libraries.sessionstorage.api.SessionStore import io.element.android.services.analytics.api.AnalyticsService import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch @Inject class PreferencesRootPresenter( private val matrixClient: MatrixClient, - private val sessionVerificationService: SessionVerificationService, private val analyticsService: AnalyticsService, private val versionFormatter: VersionFormatter, private val snackbarDispatcher: SnackbarDispatcher, - private val indicatorService: IndicatorService, - private val directLogoutPresenter: Presenter, private val showDeveloperSettingsProvider: ShowDeveloperSettingsProvider, - private val rageshakeFeatureAvailability: RageshakeFeatureAvailability, private val featureFlagService: FeatureFlagService, private val sessionStore: SessionStore, - private val sessionEnterpriseService: SessionEnterpriseService, + private val appPreferencesStore: AppPreferencesStore, private val userStatusPresenter: Presenter, + private val preferencesAccountPresenter: Presenter, + @SessionCoroutineScope + private val sessionCoroutineScope: CoroutineScope, ) : Presenter { @Composable override fun present(): PreferencesRootState { @@ -68,17 +63,23 @@ class PreferencesRootPresenter( // Force a refresh of the profile matrixClient.getUserProfile() } + val preferencesAccountState = preferencesAccountPresenter.present() val isMultiAccountEnabled by remember { featureFlagService.isFeatureEnabledFlow(FeatureFlags.MultiAccount) }.collectAsState(initial = false) - val showLinkNewDevice by remember { - featureFlagService.isFeatureEnabledFlow(FeatureFlags.QrCodeLogin) - }.collectAsState(initial = false) - + val isOtherAccountsSectionExpanded by remember { + appPreferencesStore.isOtherAccountsExpandedFlow() + }.collectAsState(initial = true) val isUserStatusSupported by produceState(false) { value = matrixClient.isUserStatusSupported().getOrDefault(false) } val userStatusState = if (isUserStatusSupported) userStatusPresenter.present() else null + val isBlackThemeAllowed by remember { + featureFlagService.isFeatureEnabledFlow(FeatureFlags.AllowBlackTheme) + }.collectAsState(initial = false) + val theme = remember(isBlackThemeAllowed) { + appPreferencesStore.getThemeFlow().mapToTheme(isBlackThemeAllowed) + }.collectAsState(initial = Theme.System) val otherSessions by remember { sessionStore.sessionsFlow().map { list -> @@ -89,45 +90,37 @@ class PreferencesRootPresenter( userId = UserId(it.userId), displayName = it.userDisplayName, avatarUrl = it.userAvatarUrl, + avatarThumbnail = it.userAvatarData, ) } .toImmutableList() } }.collectAsState(initial = persistentListOf()) + val themeOption by remember { + derivedStateOf { + when (theme.value) { + Theme.System -> ThemeOption.System + Theme.Dark -> ThemeOption.Dark + Theme.Black -> ThemeOption.Black + Theme.Light -> ThemeOption.Light + } + } + } + + val availableThemeOptions = remember(isBlackThemeAllowed) { + if (isBlackThemeAllowed) { + ThemeOption.entries + } else { + ThemeOption.entries.filterNot { it == ThemeOption.Black } + }.toImmutableList() + } + val snackbarMessage by snackbarDispatcher.collectSnackbarMessageAsState() val hasAnalyticsProviders = remember { analyticsService.getAvailableAnalyticsProviders().isNotEmpty() } - // We should display the 'complete verification' option if the current session can be verified - val canVerifyUserSession by sessionVerificationService.needsSessionVerification.collectAsState(false) - - val showSecureBackupIndicator by indicatorService.showSettingChatBackupIndicator() - - val accountManagementUrl: MutableState = remember { - mutableStateOf(null) - } - var canDeactivateAccount by remember { - mutableStateOf(false) - } - val canReportBug by remember { rageshakeFeatureAvailability.isAvailable() }.collectAsState(false) - LaunchedEffect(Unit) { - canDeactivateAccount = matrixClient.canDeactivateAccount() - } - - val nbOfBlockedUsers by produceState(initialValue = 0) { - matrixClient.ignoredUsersFlow - .onEach { value = it.size } - .launchIn(this) - } - val showLabsItem = remember { featureFlagService.getAvailableFeatures(isInLabs = true).isNotEmpty() } - val directLogoutState = directLogoutPresenter.present() - - LaunchedEffect(Unit) { - initAccountManagementUrl(accountManagementUrl) - } - val showDeveloperSettings by showDeveloperSettingsProvider.showDeveloperSettings.collectAsState() fun handleEvent(event: PreferencesRootEvent) { @@ -138,38 +131,35 @@ class PreferencesRootPresenter( is PreferencesRootEvent.SwitchToSession -> coroutineScope.launch { sessionStore.setLatestSession(event.sessionId.value) } + PreferencesRootEvent.ToggleOtherAccountsExpanded -> coroutineScope.launch { + appPreferencesStore.setOtherAccountsExpanded(!isOtherAccountsSectionExpanded) + } + is PreferencesRootEvent.SetTheme -> sessionCoroutineScope.launch { + when (event.theme) { + ThemeOption.System -> appPreferencesStore.setTheme(Theme.System.name) + ThemeOption.Dark -> appPreferencesStore.setTheme(Theme.Dark.name) + ThemeOption.Black -> appPreferencesStore.setTheme(Theme.Black.name) + ThemeOption.Light -> appPreferencesStore.setTheme(Theme.Light.name) + } + } } } return PreferencesRootState( myUser = matrixUser.value, userStatusState = userStatusState, + preferencesAccountState = preferencesAccountState, + theme = themeOption, + availableThemeOptions = availableThemeOptions, version = remember { versionFormatter.get() }, isMultiAccountEnabled = isMultiAccountEnabled, + isOtherAccountsSectionExpanded = isOtherAccountsSectionExpanded, otherSessions = otherSessions, - showSecureBackup = !canVerifyUserSession, - showSecureBackupBadge = showSecureBackupIndicator, - accountManagementUrl = accountManagementUrl.value, showAnalyticsSettings = hasAnalyticsProviders, - canReportBug = canReportBug, - showLinkNewDevice = showLinkNewDevice, showDeveloperSettings = showDeveloperSettings, - canDeactivateAccount = canDeactivateAccount, - nbOfBlockedUsers = nbOfBlockedUsers, showLabsItem = showLabsItem, - directLogoutState = directLogoutState, snackbarMessage = snackbarMessage, eventSink = ::handleEvent, ) } - - private fun CoroutineScope.initAccountManagementUrl( - accountManagementUrl: MutableState, - ) = launch { - accountManagementUrl.value = matrixClient.getAccountManagementUrl(null) - .getOrNull() - ?.let { - sessionEnterpriseService.tweakMasUrl(it) - } - } } diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootState.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootState.kt index 72d7240f2e..67b75a84a8 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootState.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootState.kt @@ -8,31 +8,56 @@ package io.element.android.features.preferences.impl.root -import io.element.android.features.logout.api.direct.DirectLogoutState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.ReadOnlyComposable +import androidx.compose.ui.res.stringResource +import io.element.android.features.preferences.impl.R +import io.element.android.features.preferences.impl.account.PreferencesAccountState import io.element.android.features.preferences.impl.userstatus.UserStatusState +import io.element.android.libraries.designsystem.components.preferences.DropdownOption import io.element.android.libraries.designsystem.utils.snackbar.SnackbarMessage import io.element.android.libraries.matrix.api.user.MatrixUser import kotlinx.collections.immutable.ImmutableList data class PreferencesRootState( val myUser: MatrixUser, + val theme: ThemeOption, + val preferencesAccountState: PreferencesAccountState, + val availableThemeOptions: ImmutableList, val userStatusState: UserStatusState?, val version: String, val isMultiAccountEnabled: Boolean, + val isOtherAccountsSectionExpanded: Boolean, val otherSessions: ImmutableList, - val showSecureBackup: Boolean, - val showSecureBackupBadge: Boolean, - val accountManagementUrl: String?, - val canReportBug: Boolean, - val showLinkNewDevice: Boolean, val showAnalyticsSettings: Boolean, val showDeveloperSettings: Boolean, - val canDeactivateAccount: Boolean, - val nbOfBlockedUsers: Int, val showLabsItem: Boolean, - val directLogoutState: DirectLogoutState, val snackbarMessage: SnackbarMessage?, val eventSink: (PreferencesRootEvent) -> Unit, -) { - val showBlockedUsersItem = nbOfBlockedUsers > 0 +) + +enum class ThemeOption : DropdownOption { + System { + @Composable + @ReadOnlyComposable + override fun getText(): String = stringResource(R.string.theme_system) + }, + + Light { + @Composable + @ReadOnlyComposable + override fun getText(): String = stringResource(R.string.theme_light) + }, + + Dark { + @Composable + @ReadOnlyComposable + override fun getText(): String = stringResource(R.string.theme_dark) + }, + + Black { + @Composable + @ReadOnlyComposable + override fun getText(): String = stringResource(R.string.theme_black) + } } diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootStatePreviewParam.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootStatePreviewParam.kt index 80c47a33b2..57b7493dd5 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootStatePreviewParam.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootStatePreviewParam.kt @@ -9,8 +9,8 @@ package io.element.android.features.preferences.impl.root import androidx.compose.ui.tooling.preview.PreviewParameterProvider -import io.element.android.features.logout.api.direct.DirectLogoutState -import io.element.android.features.logout.api.direct.aDirectLogoutState +import io.element.android.features.preferences.impl.account.PreferencesAccountState +import io.element.android.features.preferences.impl.account.aPreferencesAccountState import io.element.android.features.preferences.impl.userstatus.UserStatusPickerState import io.element.android.features.preferences.impl.userstatus.UserStatusState import io.element.android.features.preferences.impl.userstatus.aUserStatusState @@ -30,39 +30,25 @@ open class PreferencesRootStatePreviewParam : PreviewParameterProvider = ThemeOption.entries, otherSessions: List = emptyList(), - showSecureBackup: Boolean = false, - showSecureBackupBadge: Boolean = false, - accountManagementUrl: String? = null, - canReportBug: Boolean = false, - showLinkNewDevice: Boolean = false, showAnalyticsSettings: Boolean = false, showDeveloperSettings: Boolean = false, - canDeactivateAccount: Boolean = false, - nbOfBlockedUsers: Int = 0, showLabsItem: Boolean = false, - directLogoutState: DirectLogoutState = aDirectLogoutState(), userStatusState: UserStatusState? = aUserStatusState(), snackbarMessage: SnackbarMessage? = null, eventSink: (PreferencesRootEvent) -> Unit = {}, ) = PreferencesRootState( myUser = myUser, + preferencesAccountState = preferencesAccountState, version = version, isMultiAccountEnabled = isMultiAccountEnabled, + theme = theme, + availableThemeOptions = availableThemeOptions.toImmutableList(), + isOtherAccountsSectionExpanded = isOtherAccountsSectionExpanded, otherSessions = otherSessions.toImmutableList(), - showSecureBackup = showSecureBackup, - showSecureBackupBadge = showSecureBackupBadge, - accountManagementUrl = accountManagementUrl, - canReportBug = canReportBug, - showLinkNewDevice = showLinkNewDevice, showAnalyticsSettings = showAnalyticsSettings, showDeveloperSettings = showDeveloperSettings, - canDeactivateAccount = canDeactivateAccount, - nbOfBlockedUsers = nbOfBlockedUsers, showLabsItem = showLabsItem, - directLogoutState = directLogoutState, userStatusState = userStatusState, snackbarMessage = snackbarMessage, eventSink = eventSink, 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 82c88b16fa..1449ece700 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 @@ -9,23 +9,35 @@ package io.element.android.features.preferences.impl.root import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.statusBarsPadding import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.rotate import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.stateDescription 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.compound.tokens.generated.CompoundIcons import io.element.android.features.preferences.impl.R +import io.element.android.features.preferences.impl.account.PreferencesAccountView import io.element.android.features.preferences.impl.user.UserPreferences import io.element.android.features.preferences.impl.userstatus.UserStatusState import io.element.android.features.preferences.impl.userstatus.UserStatusView @@ -33,16 +45,20 @@ import io.element.android.libraries.architecture.AsyncAction import io.element.android.libraries.architecture.coverage.ExcludeFromCoverage import io.element.android.libraries.designsystem.components.async.AsyncActionIndicator import io.element.android.libraries.designsystem.components.async.AsyncIndicator +import io.element.android.libraries.designsystem.components.avatar.AvatarRow import io.element.android.libraries.designsystem.components.avatar.AvatarSize +import io.element.android.libraries.designsystem.components.avatar.AvatarType import io.element.android.libraries.designsystem.components.list.ListItemContent +import io.element.android.libraries.designsystem.components.preferences.PreferenceCategory +import io.element.android.libraries.designsystem.components.preferences.PreferenceDropdown import io.element.android.libraries.designsystem.components.preferences.PreferencePage 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.HorizontalDivider +import io.element.android.libraries.designsystem.theme.components.Icon 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.Text import io.element.android.libraries.designsystem.utils.CommonDrawables import io.element.android.libraries.designsystem.utils.snackbar.SnackbarHost @@ -51,7 +67,11 @@ import io.element.android.libraries.emoji.api.picker.EmojiPickerRenderer import io.element.android.libraries.emoji.api.picker.NoOpEmojiPickerRenderer import io.element.android.libraries.matrix.api.user.MatrixUser import io.element.android.libraries.matrix.ui.components.MatrixUserRow +import io.element.android.libraries.matrix.ui.model.getAvatarData +import io.element.android.libraries.testtags.TestTags +import io.element.android.libraries.testtags.testTag import io.element.android.libraries.ui.strings.CommonStrings +import kotlinx.collections.immutable.toImmutableList @Composable fun PreferencesRootView( @@ -59,19 +79,20 @@ fun PreferencesRootView( emojiPickerRenderer: EmojiPickerRenderer, onBackClick: () -> Unit, onAddAccountClick: () -> Unit, - onSecureBackupClick: () -> Unit, - onManageAccountClick: (url: String) -> Unit, - onLinkNewDeviceClick: () -> Unit, onOpenAnalytics: () -> Unit, - onOpenRageShake: () -> Unit, onOpenLockScreenSettings: () -> Unit, onOpenAbout: () -> Unit, onOpenDeveloperSettings: () -> Unit, - onOpenAdvancedSettings: () -> Unit, + onOpenMediaSettings: () -> Unit, + onOpenLocationSettings: () -> Unit, onOpenLabs: () -> Unit, + onEditProfileClick: (MatrixUser) -> Unit, + onSecureBackupClick: () -> Unit, + onManageAccountClick: (url: String) -> Unit, + onLinkNewDeviceClick: () -> Unit, + onOpenRageShake: () -> Unit, + onModerationAndSafetyClick: () -> Unit, onOpenNotificationSettings: () -> Unit, - onOpenUserProfile: (MatrixUser) -> Unit, - onOpenBlockedUsers: () -> Unit, onSignOutClick: () -> Unit, onDeactivateClick: () -> Unit, modifier: Modifier = Modifier, @@ -86,50 +107,55 @@ fun PreferencesRootView( snackbarHost = { SnackbarHost(snackbarHostState) } ) { UserPreferences( - modifier = Modifier.clickable { - onOpenUserProfile(state.myUser) - }, + modifier = Modifier + .testTag(TestTags.settingsUserProfile) + .clickable { + onEditProfileClick(state.myUser) + }, matrixUser = state.myUser, ) + if (state.userStatusState != null) { + UserStatusSection( + userStatusState = state.userStatusState, + emojiPickerRenderer = emojiPickerRenderer, + ) + } if (state.isMultiAccountEnabled) { MultiAccountSection( state = state, onAddAccountClick = onAddAccountClick, ) } - if (state.userStatusState != null) { - UserStatusSection( - userStatusState = state.userStatusState, - emojiPickerRenderer = emojiPickerRenderer, - showTopDivider = !state.isMultiAccountEnabled, - ) - } - // 'Account' section - ManageAccountSection( - state = state, + HorizontalDivider( + thickness = 8.dp, + color = ElementTheme.colors.bgSubtleSecondary, + ) + // Account settings section + PreferencesAccountView( + state = state.preferencesAccountState, + onSecureBackupClick = onSecureBackupClick, onManageAccountClick = onManageAccountClick, onLinkNewDeviceClick = onLinkNewDeviceClick, - onOpenBlockedUsers = onOpenBlockedUsers - ) - // 'Manage my app' section - ManageAppSection( - state = state, + onOpenRageShake = onOpenRageShake, + onModerationAndSafetyClick = onModerationAndSafetyClick, onOpenNotificationSettings = onOpenNotificationSettings, - onOpenLockScreenSettings = onOpenLockScreenSettings, - onSecureBackupClick = onSecureBackupClick, + onSignOutClick = onSignOutClick, + onDeactivateClick = onDeactivateClick, + ) + // 'App settings' section + AppSettingsSection( + state = state, + onOpenLockScreenSettings = onOpenLockScreenSettings, + onOpenMediaSettings = onOpenMediaSettings, + onOpenLocationSettings = onOpenLocationSettings, ) - // General section GeneralSection( state = state, onOpenAbout = onOpenAbout, onOpenAnalytics = onOpenAnalytics, - onOpenRageShake = onOpenRageShake, - onOpenAdvancedSettings = onOpenAdvancedSettings, onOpenDeveloperSettings = onOpenDeveloperSettings, onOpenLabs = onOpenLabs, - onSignOutClick = onSignOutClick, - onDeactivateClick = onDeactivateClick, ) // Version Footer( @@ -163,25 +189,21 @@ private fun BoxScope.UserStatusUpdateIndicator(updateStatusAction: AsyncAction - MatrixUserRow( - modifier = Modifier - .clickable { - state.eventSink(PreferencesRootEvent.SwitchToSession(matrixUser.userId)) + if (state.otherSessions.isEmpty()) { + AddAccountItem(onAddAccountClick) + } else { + val expandedStateDescription = if (state.isOtherAccountsSectionExpanded) { + stringResource(CommonStrings.a11y_state_expanded) + } else { + stringResource(CommonStrings.a11y_state_collapsed) + } + ListItem( + modifier = Modifier.semantics { + stateDescription = expandedStateDescription + }, + content = { Text(stringResource(CommonStrings.common_switch_account)) }, + onClick = { state.eventSink(PreferencesRootEvent.ToggleOtherAccountsExpanded) }, + trailingContent = ListItemContent.Custom { _ -> + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + AnimatedVisibility( + visible = !state.isOtherAccountsSectionExpanded, + enter = fadeIn(), + exit = fadeOut(), + ) { + AvatarRow( + avatarDataList = state.otherSessions + .take(3) + .map { it.getAvatarData(AvatarSize.OtherAccountItem) } + .toImmutableList(), + avatarType = AvatarType.User, + lastOnTop = true, + ) + } + // Animate the chevron icon to rotate when the section is expanded/collapsed + val rotation: Float by animateFloatAsState( + targetValue = if (state.isOtherAccountsSectionExpanded) -180f else 0f, + animationSpec = tween( + delayMillis = 0, + durationMillis = 300, + ), + label = "chevron" + ) + Icon( + modifier = Modifier.rotate(rotation), + imageVector = CompoundIcons.ChevronDown(), + contentDescription = null, + ) } - .padding(top = 2.dp, bottom = 2.dp, end = 8.dp), - matrixUser = matrixUser, - avatarSize = AvatarSize.AccountItem, - verticalSpaceWidth = 16.dp, + }, ) + AnimatedVisibility( + visible = state.isOtherAccountsSectionExpanded, + ) { + Column { + HorizontalDivider( + thickness = 1.dp, + color = ElementTheme.colors.bgSubtleSecondary, + ) + state.otherSessions.forEach { matrixUser -> + MatrixUserRow( + modifier = Modifier + .clickable { + state.eventSink(PreferencesRootEvent.SwitchToSession(matrixUser.userId)) + } + .padding(top = 2.dp, bottom = 2.dp, end = 8.dp), + matrixUser = matrixUser, + avatarSize = AvatarSize.AccountItem, + verticalSpaceWidth = 16.dp, + ) + } + AddAccountItem(onAddAccountClick) + } + } } +} + +@Composable +private fun AddAccountItem(onAddAccountClick: () -> Unit) { ListItem( leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Plus())), content = { @@ -210,73 +298,44 @@ private fun ColumnScope.MultiAccountSection( }, onClick = onAddAccountClick, ) - HorizontalDivider( - thickness = 8.dp, - color = ElementTheme.colors.bgSubtleSecondary, - ) } @Composable -private fun ColumnScope.ManageAppSection( +private fun AppSettingsSection( state: PreferencesRootState, - onOpenNotificationSettings: () -> Unit, onOpenLockScreenSettings: () -> Unit, - onSecureBackupClick: () -> Unit, + onOpenLocationSettings: () -> Unit, + onOpenMediaSettings: () -> Unit, ) { - ListItem( - content = { Text(stringResource(id = R.string.screen_notification_settings_title)) }, - leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Notifications())), - onClick = onOpenNotificationSettings, - ) - ListItem( - content = { Text(stringResource(id = CommonStrings.common_screen_lock)) }, - leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Lock())), - onClick = onOpenLockScreenSettings, - ) - if (state.showSecureBackup) { - ListItem( - content = { Text(stringResource(id = CommonStrings.common_encryption)) }, - leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Key())), - trailingContent = ListItemContent.Badge.takeIf { state.showSecureBackupBadge }, - onClick = onSecureBackupClick, + PreferenceCategory( + title = stringResource(CommonStrings.common_app_settings), + ) { + PreferenceDropdown( + icon = CompoundIcons.DarkMode(), + dropDownIcon = CompoundIcons.ChevronUpDown(), + title = stringResource(id = CommonStrings.common_appearance), + selectedOption = state.theme, + options = state.availableThemeOptions, + onSelectOption = { themeOption -> + state.eventSink(PreferencesRootEvent.SetTheme(themeOption)) + } ) - } - HorizontalDivider() -} - -@Composable -private fun ColumnScope.ManageAccountSection( - state: PreferencesRootState, - onManageAccountClick: (url: String) -> Unit, - onLinkNewDeviceClick: () -> Unit, - onOpenBlockedUsers: () -> Unit, -) { - state.accountManagementUrl?.let { url -> ListItem( - content = { Text(stringResource(id = CommonStrings.action_manage_account_and_devices)) }, - leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.UserProfile())), - trailingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.PopOut())), - onClick = { onManageAccountClick(url) }, + content = { Text(stringResource(id = CommonStrings.common_media_upload_quality)) }, + leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Image())), + onClick = onOpenMediaSettings, ) - } - if (state.showLinkNewDevice) { ListItem( - content = { Text(stringResource(id = CommonStrings.common_link_new_device)) }, - leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Devices())), - onClick = onLinkNewDeviceClick, + content = { Text(stringResource(id = CommonStrings.common_screen_lock)) }, + leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Lock())), + onClick = onOpenLockScreenSettings, ) - } - if (state.showBlockedUsersItem) { ListItem( - content = { Text(stringResource(id = CommonStrings.common_blocked_users)) }, - leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Block())), - onClick = onOpenBlockedUsers, - trailingContent = ListItemContent.Text(state.nbOfBlockedUsers.toString()), + content = { Text(stringResource(id = CommonStrings.common_location_sharing)) }, + leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.LocationPin())), + onClick = onOpenLocationSettings, ) } - if (state.accountManagementUrl != null || state.showLinkNewDevice || state.showBlockedUsersItem) { - HorizontalDivider() - } } @Composable @@ -284,18 +343,20 @@ private fun ColumnScope.GeneralSection( state: PreferencesRootState, onOpenAbout: () -> Unit, onOpenAnalytics: () -> Unit, - onOpenRageShake: () -> Unit, - onOpenAdvancedSettings: () -> Unit, onOpenLabs: () -> Unit, onOpenDeveloperSettings: () -> Unit, - onSignOutClick: () -> Unit, - onDeactivateClick: () -> Unit, ) { - ListItem( - content = { Text(stringResource(id = CommonStrings.common_advanced_settings)) }, - leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Settings())), - onClick = onOpenAdvancedSettings, + HorizontalDivider( + thickness = 1.dp, + color = ElementTheme.colors.bgSubtleSecondary, ) + if (state.showAnalyticsSettings) { + ListItem( + content = { Text(stringResource(id = CommonStrings.common_analytics)) }, + leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Chart())), + onClick = onOpenAnalytics, + ) + } if (state.showLabsItem) { ListItem( content = { Text(stringResource(id = R.string.screen_labs_title)) }, @@ -308,35 +369,6 @@ private fun ColumnScope.GeneralSection( leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Info())), onClick = onOpenAbout, ) - if (state.canReportBug) { - ListItem( - content = { Text(stringResource(id = CommonStrings.common_report_a_problem)) }, - leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.ChatProblem())), - onClick = onOpenRageShake - ) - } - if (state.showAnalyticsSettings) { - ListItem( - content = { Text(stringResource(id = CommonStrings.common_analytics)) }, - leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Chart())), - onClick = onOpenAnalytics, - ) - } - HorizontalDivider() - ListItem( - content = { Text(stringResource(id = CommonStrings.action_signout)) }, - leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Close())), - style = ListItemStyle.Destructive, - onClick = onSignOutClick, - ) - if (state.canDeactivateAccount) { - ListItem( - content = { Text(stringResource(id = CommonStrings.action_delete_account)) }, - leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Delete())), - style = ListItemStyle.Destructive, - onClick = onDeactivateClick, - ) - } // Put developer settings at the end, so nothing bad happens if the user clicks 8 times to enable the entry AnimatedVisibility( visible = state.showDeveloperSettings, @@ -394,18 +426,19 @@ private fun ContentToPreview(state: PreferencesRootState) { onBackClick = {}, onAddAccountClick = {}, onOpenAnalytics = {}, - onOpenRageShake = {}, onOpenDeveloperSettings = {}, - onOpenAdvancedSettings = {}, + onOpenLocationSettings = {}, + onOpenMediaSettings = {}, onOpenLabs = {}, onOpenAbout = {}, + onOpenLockScreenSettings = {}, + onEditProfileClick = {}, onSecureBackupClick = {}, onManageAccountClick = {}, onLinkNewDeviceClick = {}, + onOpenRageShake = {}, + onModerationAndSafetyClick = {}, onOpenNotificationSettings = {}, - onOpenLockScreenSettings = {}, - onOpenUserProfile = {}, - onOpenBlockedUsers = {}, onSignOutClick = {}, onDeactivateClick = {}, ) diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/userstatus/UserStatusView.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/userstatus/UserStatusView.kt index c25c159f33..aab06499a4 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/userstatus/UserStatusView.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/userstatus/UserStatusView.kt @@ -7,12 +7,12 @@ package io.element.android.features.preferences.impl.userstatus +import androidx.compose.animation.AnimatedContent import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.offset -import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.sizeIn import androidx.compose.foundation.shape.CircleShape @@ -72,47 +72,65 @@ fun UserStatusView( emojiPickerRenderer: EmojiPickerRenderer, modifier: Modifier = Modifier, ) { - when (val pickerState = state.pickerState) { - UserStatusPickerState.Hidden, UserStatusPickerState.ShowingPicker -> { - val displayedStatus = state.displayedStatus - if (displayedStatus != null) { - CurrentStatusRow( - displayedStatus = displayedStatus, - onClick = { state.eventSink(UserStatusEvent.OpenPicker) }, - onClear = { state.eventSink(UserStatusEvent.ClearStatus) }, - modifier = modifier, - ) - } else { - EmptyStatusRow( - onClick = { state.eventSink(UserStatusEvent.OpenPicker) }, - modifier = modifier, - ) + AnimatedContent( + targetState = state, + contentKey = { + when (it.pickerState) { + UserStatusPickerState.Hidden, + UserStatusPickerState.ShowingPicker -> if (state.displayedStatus != null) 0 else 1 + is UserStatusPickerState.CustomInput -> 2 } - if (pickerState == UserStatusPickerState.ShowingPicker) { - UserStatusPickerBottomSheet( - currentRawStatus = state.rawStatus, - onDismiss = { state.eventSink(UserStatusEvent.DismissPicker) }, - onSelectPredefinedStatus = { status -> state.eventSink(UserStatusEvent.SetStatus(status)) }, - onSelectCustomStatus = { state.eventSink(UserStatusEvent.OpenCustomInput) }, + }, + ) { userStatusState -> + when (val pickerState = userStatusState.pickerState) { + UserStatusPickerState.Hidden, + UserStatusPickerState.ShowingPicker -> { + val displayedStatus = state.displayedStatus + if (displayedStatus != null) { + CurrentStatusRow( + displayedStatus = displayedStatus, + onClick = { state.eventSink(UserStatusEvent.OpenPicker) }, + onClear = { state.eventSink(UserStatusEvent.ClearStatus) }, + modifier = modifier, + ) + } else { + EmptyStatusRow( + onClick = { state.eventSink(UserStatusEvent.OpenPicker) }, + modifier = modifier, + ) + } + } + is UserStatusPickerState.CustomInput -> { + CustomStatusInputRow( + emoji = pickerState.emoji, + textFieldState = pickerState.textFieldState, + rawStatus = state.rawStatus, + onConfirm = { + state.eventSink( + UserStatusEvent.SetStatus(UserStatus(pickerState.emoji, pickerState.textFieldState.text.toString())) + ) + }, + onCancel = { state.eventSink(UserStatusEvent.CancelCustomInput) }, + onEmojiClick = { state.eventSink(UserStatusEvent.OpenEmojiPicker) }, + modifier = modifier, ) } } - is UserStatusPickerState.CustomInput -> { - CustomStatusInputRow( - emoji = pickerState.emoji, - textFieldState = pickerState.textFieldState, - rawStatus = state.rawStatus, - onConfirm = { - state.eventSink( - UserStatusEvent.SetStatus(UserStatus(pickerState.emoji, pickerState.textFieldState.text.toString())) - ) - }, - onCancel = { state.eventSink(UserStatusEvent.CancelCustomInput) }, - onEmojiClick = { state.eventSink(UserStatusEvent.OpenEmojiPicker) }, - modifier = modifier, + } + when (val pickerState = state.pickerState) { + UserStatusPickerState.Hidden -> Unit + UserStatusPickerState.ShowingPicker -> { + UserStatusPickerBottomSheet( + currentRawStatus = state.rawStatus, + onDismiss = { state.eventSink(UserStatusEvent.DismissPicker) }, + onSelectPredefinedStatus = { status -> state.eventSink(UserStatusEvent.SetStatus(status)) }, + onSelectCustomStatus = { state.eventSink(UserStatusEvent.OpenCustomInput) }, ) + } + is UserStatusPickerState.CustomInput -> { when (val sheet = pickerState.emojiPickerSheetState) { - EmojiPickerSheetState.Hidden, EmojiPickerSheetState.Loading -> Unit + EmojiPickerSheetState.Hidden, + EmojiPickerSheetState.Loading -> Unit is EmojiPickerSheetState.Shown -> EmojiPickerBottomSheet( pickerState = sheet.state, emojiPickerRenderer = emojiPickerRenderer, @@ -133,7 +151,6 @@ private fun EmptyStatusRow( content = { Text( text = stringResource(R.string.screen_settings_user_status_placeholder), - modifier = Modifier.padding(vertical = 16.dp), ) }, leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Reaction())), @@ -153,7 +170,7 @@ private fun CurrentStatusRow( is DisplayedStatus.InCall -> "🎧" to stringResource(CommonStrings.common_on_a_call) } ListItem( - content = { Text(text = text, modifier = Modifier.padding(vertical = 16.dp)) }, + content = { Text(text = text) }, leadingContent = ListItemContent.Custom { EmojiText(emoji) }, trailingContent = ListItemContent.Custom({ IconButton( diff --git a/features/preferences/impl/src/main/res/values-cs/translations.xml b/features/preferences/impl/src/main/res/values-cs/translations.xml index 5c690dd824..6ea3fc774b 100644 --- a/features/preferences/impl/src/main/res/values-cs/translations.xml +++ b/features/preferences/impl/src/main/res/values-cs/translations.xml @@ -38,8 +38,8 @@ "Pokud je tato funkce vypnutá, nebudete moci odesílat ani přijímat potvrzení o přečtení ani upozornění o psaní." "Vždy skrýt" "Vždy zobrazit" - "V soukromých místnostech" - "Skryté médium lze vždy zobrazit klepnutím na něj" + "Zobrazit v soukromých místnostech" + "Skrytá média lze vždy zobrazit klepnutím na ně." "Zobrazit média na časové ose" "Povolit možnost zobrazení zdroje zprávy na časové ose." "Nemáte žádné blokované uživatele" @@ -59,6 +59,7 @@ "Máte chuť experimentovat?" "Experimentální funkce" "Další nastavení" + "Vyzváněcí tón hovoru" "Halsové a video hovory" "Neshoda konfigurace" "Zjednodušili jsme nastavení oznámení, abychom usnadnili hledání možností. @@ -77,12 +78,28 @@ Pokud budete pokračovat, některá nastavení se mohou změnit." "Konfigurace nebyla opravena, zkuste to prosím znovu." "Skupinové chaty" "Pozvánky" - "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." + "Váš poskytovatel účtu tuto možnost v encrypted chatech nepodporuje, v některých chatech se vám proto nemusí zobrazovat oznámení." "Zmínky" + "Vyberte jiný zvuk…" + "Aktuálně používáte %1$s" + "Zvuk zprávy" + "Zvuk zprávy" "Vše" "Zmínky" "Upozornit mě na" - "Upozornit mě na @room" + "Zmínky o @room" + "Vlastní" + "Vlastní zvuk…" + "Chyba při mazání souboru" + "Výchozí nastavení Elementu" + "Element Fade" + "Chyba při importu souboru" + "Problém s náhledem zvuku upozornění" + "Zvuk" + "Zrušit chybu zvukového upozornění" + "Problém s nastavením zvuku upozornění" + "Tichý" + "Výchozí nastavení systému" "Chcete-li dostávat oznámení, změňte prosím svůj %1$s." "systémová nastavení" "Systémová oznámení byla vypnuta" 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 e33bd743eb..55230ed5de 100644 --- a/features/preferences/impl/src/main/res/values-de/translations.xml +++ b/features/preferences/impl/src/main/res/values-de/translations.xml @@ -52,6 +52,10 @@ "Profil kann nicht aktualisiert werden" "Profil bearbeiten" "Profil wird aktualisiert …" + "Galerie-Nachrichten" + "Sende mehrere Bilder oder Videos auf einmal. Auf Mobilgeräten stabil, von der Web-Version aber noch nicht unterstützt." + "Beitritt anfragen" + "Nutzer können Zugang zu einem Chat anfragen, der nicht öffentlich zugänglich ist. Auf Mobilgeräten stabil, die Web-Implementierung existiert, ist aber experimentell." "Thread-Antworten aktivieren" "Die App wird neu gestartet, um diese Änderung zu übernehmen." "Probier unsere neuesten Ideen in der Entwicklung aus. Diese Funktionen sind noch nicht fertiggestellt; sie können instabil sein und sich noch ändern." diff --git a/features/preferences/impl/src/main/res/values-el/translations.xml b/features/preferences/impl/src/main/res/values-el/translations.xml index fd684eea53..13b167c172 100644 --- a/features/preferences/impl/src/main/res/values-el/translations.xml +++ b/features/preferences/impl/src/main/res/values-el/translations.xml @@ -37,7 +37,7 @@ "Εάν απενεργοποιηθεί, δεν θα μπορείς να στέλνεις ή να λαμβάνεις αποδεικτικά ανάγνωσης ή ειδοποιήσεις πληκτρολόγησης." "Πάντα απόκρυψη" "Πάντα εμφάνιση" - "Σε ιδιωτικές αίθουσες" + "Εμφάνιση σε ιδιωτικές αίθουσες" "Ένα κρυφό πολυμέσο μπορεί πάντα να εμφανιστεί πατώντας το" "Εμφάνιση πολυμέσων στο timeline" "Ενεργοποίησε την επιλογή για προβολή πηγής μηνυμάτων στη ροή." @@ -58,6 +58,7 @@ "Έχετε διάθεση για πειραματισμούς;" "Εργαστήρια" "Πρόσθετες ρυθμίσεις" + "Ήχος κλήσης" "Κλήσεις ήχου και βίντεο" "Αναντιστοιχία διαμόρφωσης" "Απλοποιήσαμε τις Ρυθμίσεις Ειδοποιήσεων για να διευκολύνουμε την εύρεση επιλογών. Ορισμένες προσαρμοσμένες ρυθμίσεις που έχετε επιλέξει στο παρελθόν δεν εμφανίζονται εδώ, αλλά εξακολουθούν να είναι ενεργές. @@ -76,14 +77,39 @@ "Προσκλήσεις" "Ο πάροχος του λογαριασμού σας δεν υποστηρίζει αυτήν την επιλογή σε κρυπτογραφημένες συνομιλίες. Ενδέχεται να μην λαμβάνετε ειδοποιήσεις σε ορισμένες συνομιλίες." "Αναφορές" + "Επιλέξτε έναν άλλο ήχο…" + "Αυτήν τη στιγμή χρησιμοποιείται το %1$s" + "Ήχος μηνύματος" + "Ήχος μηνύματος" "Όλα" "Αναφορές" "Ειδοποίησέ με για" "Αναφορές στην @αίθουσα" + "Προσαρμοσμένο" + "Προσαρμοσμένος ήχος…" + "Σφάλμα κατά τη διαγραφή αρχείου" + "Προεπιλογή του Element" + "Σβήσιμο του Element" + "Σφάλμα κατά την εισαγωγή αρχείου" + "Πρόβλημα με την προεπισκόπηση του ήχου ειδοποίησης" + "Ήχος" + "Απόρριψη σφάλματος ήχου ειδοποίησης" + "Πρόβλημα με τη ρύθμιση του ήχου ειδοποίησης" + "Σιωπηλό" + "Προεπιλογή συστήματος" "Για να λαμβάνεις ειδοποιήσεις, άλλαξε το %1$s." "ρυθμίσεις συστήματος" "Ειδοποιήσεις συστήματος ανενεργές" "Ειδοποιήσεις" + "Λείπει" + "Επιστρέφω αμέσως" + "Προσαρμοσμένη…" + "Προσαρμοσμένη κατάσταση…" + "Χρόνος εστίασης" + "Σε μια συνάντηση" + "Εν κινήσει" + "Ποια είναι η κατάστασή σας;" + "Μαύρο" "Σκοτεινό" "Φωτεινό" "Σύστημα" diff --git a/features/preferences/impl/src/main/res/values-fi/translations.xml b/features/preferences/impl/src/main/res/values-fi/translations.xml index c50989e526..3aeeefe2c4 100644 --- a/features/preferences/impl/src/main/res/values-fi/translations.xml +++ b/features/preferences/impl/src/main/res/values-fi/translations.xml @@ -52,6 +52,10 @@ "Profiilin muokkaaminen ei onnistunut" "Muokkaa profiilia" "Muokataan profiilia…" + "Galleriaviestit" + "Lähetä useita kuvia tai videoita kerralla. Toimii mobiililaitteilla, mutta ei vielä tuettu selainversiossa." + "Pyydä liittymistä" + "Käyttäjät voivat pyytää pääsyä huoneeseen, johon kuka tahansa ei voi liittyä. Toimii mobiililaitteilla. Selainversio on olemassa, mutta se on vielä kokeiluvaiheessa." "Ota käyttöön viestiketjuvastaukset" "Sovellus käynnistyy uudelleen muutoksen käyttöönottamiseksi." "Kokeile uusimpia kehitteillä olevia ideoitamme. Nämä ominaisuudet eivät ole vielä valmiita; ne voivat olla epävakaita ja muuttua." @@ -75,7 +79,7 @@ Jos jatkat, jotkin asetukset saattavat muuttua." "Määritystä ei ole korjattu, yritä uudelleen." "Ryhmäkeskusteluissa" "Kutsut" - "Kotipalvelimesi ei tue tätä vaihtoehtoa salatuissa huoneissa, joten et ehkä saa ilmoitusta joissakin huoneissa." + "Palveluntarjoajasi ei tue tätä vaihtoehtoa salatuissa huoneissa, joten et ehkä saa ilmoitusta joissakin huoneissa." "Maininnat" "Valitse toinen ääni…" "Käytössä tällä hetkellä %1$s" @@ -84,7 +88,7 @@ Jos jatkat, jotkin asetukset saattavat muuttua." "Kaikki" "Maininnat" "Ilmoita minulle" - "Ilmoita minulle @room-maininnoista" + @room-maininnat "Mukautettu" "Mukautettu ääni…" "Virhe tiedoston poistamisessa" diff --git a/features/preferences/impl/src/main/res/values-fr/translations.xml b/features/preferences/impl/src/main/res/values-fr/translations.xml index 98854a6ba5..159d340ace 100644 --- a/features/preferences/impl/src/main/res/values-fr/translations.xml +++ b/features/preferences/impl/src/main/res/values-fr/translations.xml @@ -37,8 +37,6 @@ "Si cette option est désactivée, vous ne pourrez ni envoyer ni recevoir de confirmations de lecture ni de notifications de saisie." "Toujours cacher" "Toujours montrer" - "Dans les salons privés" - "Un média caché peut toujours être affiché en cliquant dessus" "Afficher les médias dans les discussions." "Activer cette option pour pouvoir voir la source des messages dans la discussion." "Vous n’avez bloqué personne" diff --git a/features/preferences/impl/src/main/res/values-hu/translations.xml b/features/preferences/impl/src/main/res/values-hu/translations.xml index 67afac50a1..193e28cff6 100644 --- a/features/preferences/impl/src/main/res/values-hu/translations.xml +++ b/features/preferences/impl/src/main/res/values-hu/translations.xml @@ -52,6 +52,10 @@ "Nem sikerült frissíteni a profilt" "Profil szerkesztése" "Profil frissítése…" + "Galéria üzenetek" + "Több kép vagy videó küldése egyszerre. Mobilon stabil, de weben még nem támogatott." + "Csatlakozási kérés" + "A felhasználók hozzáférést kérhetnek olyan szobákhoz, amelyek nem állnak mindenki számára nyitva. Mobil eszközökön stabilan működik, webes megvalósítás is létezik, de még kísérleti stádiumban van." "Üzenetszál válaszok engedélyezése" "Az alkalmazás újraindul, hogy a változás érvénybe lépjen." "Próbálja ki legújabb fejlesztéseinket. Ezek a funkciók még nem véglegesek, instabilak lehetnek és változhatnak." @@ -84,7 +88,7 @@ Ha folytatja, egyes beállítások megváltozhatnak." "Összes" "Említések" "Értesítés ezekről:" - "Értesítés a @room említésekor" + @szoba említések "Egyedi" "Egyedi hang…" "Hiba a fájl törlésekor" diff --git a/features/preferences/impl/src/main/res/values-ja/translations.xml b/features/preferences/impl/src/main/res/values-ja/translations.xml index 5010402532..9d4eb51e0e 100644 --- a/features/preferences/impl/src/main/res/values-ja/translations.xml +++ b/features/preferences/impl/src/main/res/values-ja/translations.xml @@ -51,6 +51,10 @@ "プロフィールを更新できません" "プロフィールを編集" "プロフィールを更新中…" + "ギャラリーメッセージ" + "複数の画像や動画を一度に送信できます。モバイルアプリでは安定して動作しますが、web版では対応していません。" + "参加の申請" + "招待制のルームにおいて、参加したいユーザーが申請を送信できるようになります。モバイルアプリでは安定して動作しますが、web版では試験的にのみ実装されています。" "スレッドへの返信を有効化" "変更を適用するためにアプリケーションは再起動します。" "開発段階の最新機能を試すことができます。未完成のため、変更や不安定な挙動が生じる可能性があります。" @@ -74,7 +78,7 @@ "設定が修正されていません。再試行してください。" "グループチャット" "招待" - "暗号化されたルームでは、この機能にホームサーバーが対応しないため、一部のルームから通知が届かない可能性があります。" + "暗号化されたルームにおいてこの機能にアカウント提供元が対応しないため、このルームからの一部の通知を受信できない可能性があります。" "メンション" "他の音を選択…" "%1$s を使用中" @@ -83,7 +87,7 @@ "すべて" "メンション" "以下を通知" - @ルームで通知を受け取る + @ルームのメンション "カスタム" "カスタム着信音" "ファイルの削除に失敗" diff --git a/features/preferences/impl/src/main/res/values-pl/translations.xml b/features/preferences/impl/src/main/res/values-pl/translations.xml index 0e24010dba..3142639d94 100644 --- a/features/preferences/impl/src/main/res/values-pl/translations.xml +++ b/features/preferences/impl/src/main/res/values-pl/translations.xml @@ -38,8 +38,8 @@ "Po wyłączeniu nie będzie można wysyłać ani odbierać potwierdzeń odczytu i powiadomień o pisaniu." "Zawsze ukrywaj" "Zawsze pokazuj" - "W pokojach prywatnych" - "Ukryte media można zawsze wyświetlić, dotykając ich" + "Pokaż w pokojach prywatnych" + "Ukryte media można zawsze wyświetlić, dotykając ich." "Pokaż media na osi czasu" "Włącz, aby móc wyświetlać źródło wiadomości na osi czasu." "Nie blokujesz żadnych użytkowników" @@ -62,6 +62,9 @@ "Wypróbuj nasze najnowsze pomysły w fazie rozwoju. Funkcje te nie są jeszcze sfinalizowane; mogą być niestabilne i ulec zmianie." "Chcesz poeksperymentować?" "Laboratoria" + "Zaproszenia" + "Pozostali użytkownicy" + "Twój wygląd" "Dodatkowe ustawienia" "Dzwonek połączenia" "Połączenia audio i wideo" 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 109000440f..aa1da78239 100644 --- a/features/preferences/impl/src/main/res/values-sv/translations.xml +++ b/features/preferences/impl/src/main/res/values-sv/translations.xml @@ -65,7 +65,7 @@ Om du fortsätter kan vissa av dina inställningar ändras." "Alla" "Omnämnanden" "Meddela mig för" - "Meddela mig på @room" + "Omnämnanden av @rum" "För att få aviseringar, vänligen ändra dina %1$s." "systeminställningar" "Systemaviseringar avstängda" diff --git a/features/preferences/impl/src/main/res/values-uk/translations.xml b/features/preferences/impl/src/main/res/values-uk/translations.xml index bcd90a3934..293f9749f2 100644 --- a/features/preferences/impl/src/main/res/values-uk/translations.xml +++ b/features/preferences/impl/src/main/res/values-uk/translations.xml @@ -38,8 +38,8 @@ "Якщо вимкнути цю функцію, ви не зможете надсилати чи отримувати підтвердження прочитання або сповіщення про набір тексту." "Завжди ховати" "Завжди показувати" - "У приватних кімнатах" - "Сховані медіа завжди можна переглянути, натиснувши на нього" + "Показувати в приватних кімнатах" + "Приховані медіафайли завжди можна переглянути, натиснувши на них." "Показувати медіа у стрічці" "Увімкнення цього параметра уможливить перегляд джерела повідомлень зі стрічки." "У вас немає заблокованих користувачів." @@ -53,11 +53,18 @@ "Неможливо оновити профіль" "Редагувати профіль" "Оновлення профілю…" + "Галерея повідомлень" + "Одночасне надсилання кількох зображень або відео. Функція працює стабільно на мобільних пристроях, але поки що не підтримується у вебверсії." + "Запит на приєднання" + "Користувачі можуть подати запит на доступ до кімнати, яка не відкрита для загального доступу. Стабільна робота на мобільних пристроях; вебверсія існує, але перебуває на експериментальній стадії." "Увімкнути відповіді в гілках" "Застосунок перезапуститься, щоб застосувати цю зміну." "Випробуйте найновіші ідеї наших розробників. Ці функції ще не завершені, вони можуть бути нестабільними та змінюватись." "Хочете поекспериментувати?" "Лабораторія" + "Запрошення" + "Інші користувачі" + "Як вас бачать інші" "Додаткові налаштування" "Мелодія виклику" "Аудіо та відеовиклики" @@ -86,6 +93,7 @@ "Згадки" "Сповіщати мене про" "Згадки @room" + "Показати всю діяльність у списку кімнат (крапки або кількість непрочитаних повідомлень)" "Власний" "Користувацький звук…" "Помилка видалення файлу" diff --git a/features/preferences/impl/src/main/res/values-zh-rTW/translations.xml b/features/preferences/impl/src/main/res/values-zh-rTW/translations.xml index e9e973fde8..0d57db9195 100644 --- a/features/preferences/impl/src/main/res/values-zh-rTW/translations.xml +++ b/features/preferences/impl/src/main/res/values-zh-rTW/translations.xml @@ -51,6 +51,10 @@ "無法更新個人檔案" "編輯個人檔案" "正在更新個人檔案…" + "圖庫訊息" + "一次傳送多張圖片或影片。在行動裝置上可穩定運作,但網頁端尚不支援。" + "要求加入" + "使用者可以申請加入尚未對所有人開放的聊天室。此功能在行動裝置上已穩定運作,網頁版雖已實作,但仍處於實驗階段。" "啟用討論串回覆" "應用程式將會重新啟動以套用此變更。" "試試我們正在開發中的最新構想。這些功能可能尚未完成,可能不夠穩定,也可能隨時變動。" @@ -74,7 +78,7 @@ "組態尚未更正,請再試一次。" "群組聊天" "邀請" - "您的家伺服器在加密聊天室中不支援此選項,可能無法收到部份聊天室的通知。" + "您的帳號服務提供者不支援加密聊天中的此項功能,因此在某些聊天中您可能不會收到通知。" "提及" "選擇其他音效⋯⋯" "目前正在使用 %1$s" @@ -83,7 +87,7 @@ "全部" "提及" "通知我" - "於 @room 通知我" + "提及 @room" "自訂" "自訂音效⋯⋯" "刪除檔案時發生錯誤" diff --git a/features/preferences/impl/src/main/res/values-zh/translations.xml b/features/preferences/impl/src/main/res/values-zh/translations.xml index a286c4b832..f1d1cbb786 100644 --- a/features/preferences/impl/src/main/res/values-zh/translations.xml +++ b/features/preferences/impl/src/main/res/values-zh/translations.xml @@ -36,8 +36,8 @@ "关闭后将无法发送或接收已读回执、输入通知" "始终隐藏" "始终显示" - "在私人房间" - "点击隐藏的媒体即可将其恢复显示" + "在私有房间中显示" + "被隐藏的媒体可始终通过点触将其恢复显示" "在时间线上显示媒体" "启用在时间线上查看消息源码的选项。" "暂无已屏蔽的用户" @@ -60,6 +60,9 @@ "尝试我们最新的开发理念。这些功能尚未最终确定,可能不稳定,也可能会发生变化。" "想尝试新功能?" "实验室" + "邀请" + "其他用户" + "你的形象" "更多设置" "来电铃声" "音视频通话" @@ -88,6 +91,7 @@ "提及" "通知我以下类型" "含有 @room 的提及" + "在房间列表上显示所有活动(用于未读消息的圆点或数字)" "自定义" "自定义提示音…" "删除文件时出错" diff --git a/features/preferences/impl/src/main/res/values/localazy.xml b/features/preferences/impl/src/main/res/values/localazy.xml index 9da592170e..c6350f7a1e 100644 --- a/features/preferences/impl/src/main/res/values/localazy.xml +++ b/features/preferences/impl/src/main/res/values/localazy.xml @@ -37,8 +37,8 @@ "If turned off, you won’t be able to send or receive read receipts or typing notifications." "Always hide" "Always show" - "In private rooms" - "A hidden media can always be shown by tapping on it" + "Show in private rooms" + "Hidden media can always be shown by tapping on it." "Show media in timeline" "Enable option to view message source in the timeline." "You have no blocked users" @@ -61,6 +61,9 @@ "Try out our latest ideas in development. These features are not finalised; they may be unstable, may change." "Feeling experimental?" "Labs" + "Invites" + "Other users" + "Your appearance" "Additional settings" "Call ringtone" "Audio and video calls" @@ -89,6 +92,7 @@ If you proceed, some of your settings may change." "Mentions" "Notify me for" "Mentions of @room" + "Show all activity in the room list (dots or number of unread messages)" "Custom" "Custom sound…" "Error deleting file" diff --git a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/account/PreferencesAccountPresenterTest.kt b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/account/PreferencesAccountPresenterTest.kt new file mode 100644 index 0000000000..cd1bd186f4 --- /dev/null +++ b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/account/PreferencesAccountPresenterTest.kt @@ -0,0 +1,177 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.preferences.impl.account + +import com.google.common.truth.Truth.assertThat +import io.element.android.features.enterprise.api.SessionEnterpriseService +import io.element.android.features.enterprise.test.FakeSessionEnterpriseService +import io.element.android.features.logout.api.direct.aDirectLogoutState +import io.element.android.features.rageshake.api.RageshakeFeatureAvailability +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.indicator.api.IndicatorService +import io.element.android.libraries.indicator.test.FakeIndicatorService +import io.element.android.libraries.matrix.api.oauth.AccountManagementAction +import io.element.android.libraries.matrix.api.user.MatrixUser +import io.element.android.libraries.matrix.test.AN_AVATAR_URL +import io.element.android.libraries.matrix.test.A_SESSION_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.verification.FakeSessionVerificationService +import io.element.android.tests.testutils.WarmUpRule +import io.element.android.tests.testutils.consumeItemsUntilPredicate +import io.element.android.tests.testutils.lambda.lambdaRecorder +import io.element.android.tests.testutils.lambda.value +import io.element.android.tests.testutils.test +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.runTest +import org.junit.Rule +import org.junit.Test + +class PreferencesAccountPresenterTest { + @get:Rule + val warmUpRule = WarmUpRule() + + @Test + fun `present - initial state`() = runTest { + val accountManagementUrlResult = lambdaRecorder> { action -> + Result.success("$action url") + } + val matrixClient = FakeMatrixClient( + canDeactivateAccountResult = { true }, + accountManagementUrlResult = accountManagementUrlResult, + ) + createPreferencesAccountPresenter( + matrixClient = matrixClient, + sessionEnterpriseService = FakeSessionEnterpriseService( + tweakMasUrlResult = { "tweaked $it" }, + ), + ).test { + val initialState = awaitItem() + assertThat(initialState.myUser).isEqualTo( + MatrixUser( + userId = matrixClient.sessionId, + displayName = A_USER_NAME, + avatarUrl = AN_AVATAR_URL + ) + ) + assertThat(initialState.showSecureBackupBadge).isFalse() + assertThat(initialState.accountManagementUrl).isNull() + assertThat(initialState.showLinkNewDevice).isFalse() + assertThat(initialState.directLogoutState).isEqualTo(aDirectLogoutState()) + assertThat(initialState.snackbarMessage).isNull() + val loadedState = consumeItemsUntilPredicate { it.accountManagementUrl != null }.last() + assertThat(loadedState.showSecureBackup).isFalse() + assertThat(loadedState.canDeactivateAccount).isTrue() + assertThat(loadedState.canReportBug).isTrue() + assertThat(loadedState.accountManagementUrl).isEqualTo("tweaked null url") + accountManagementUrlResult.assertions().isCalledOnce() + .with(value(null)) + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `present - cannot report bug`() = runTest { + createPreferencesAccountPresenter( + matrixClient = FakeMatrixClient( + canDeactivateAccountResult = { true }, + accountManagementUrlResult = { Result.success("") }, + ), + rageshakeFeatureAvailability = { flowOf(false) }, + ).test { + assertThat(awaitItem().canReportBug).isFalse() + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `present - secure backup badge`() = runTest { + val indicatorService = FakeIndicatorService() + createPreferencesAccountPresenter( + matrixClient = FakeMatrixClient( + canDeactivateAccountResult = { true }, + accountManagementUrlResult = { Result.success("") }, + ), + indicatorService = indicatorService, + ).test { + assertThat(awaitItem().showSecureBackupBadge).isFalse() + indicatorService.setShowSettingChatBackupIndicator(true) + consumeItemsUntilPredicate { it.showSecureBackupBadge } + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `present - secure backup is shown when the session does not need to be verified`() = runTest { + val sessionVerificationService = FakeSessionVerificationService() + createPreferencesAccountPresenter( + matrixClient = FakeMatrixClient( + canDeactivateAccountResult = { true }, + accountManagementUrlResult = { Result.success("") }, + ), + sessionVerificationService = sessionVerificationService, + ).test { + // The session needs to be verified, so the secure backup entry is hidden. + consumeItemsUntilPredicate { !it.showSecureBackup } + sessionVerificationService.emitNeedsSessionVerification(false) + consumeItemsUntilPredicate { it.showSecureBackup } + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `present - can deactivate account is false if the Matrix client say so`() = runTest { + createPreferencesAccountPresenter( + matrixClient = FakeMatrixClient( + canDeactivateAccountResult = { false }, + accountManagementUrlResult = { Result.success(null) }, + ), + ).test { + assertThat(awaitItem().canDeactivateAccount).isFalse() + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `present - link new device`() = runTest { + createPreferencesAccountPresenter( + matrixClient = FakeMatrixClient( + sessionId = A_SESSION_ID, + canDeactivateAccountResult = { true }, + accountManagementUrlResult = { Result.success(null) }, + ), + featureFlagService = FakeFeatureFlagService( + initialState = mapOf(FeatureFlags.QrCodeLogin.key to true) + ), + ).test { + consumeItemsUntilPredicate { it.showLinkNewDevice } + cancelAndIgnoreRemainingEvents() + } + } + + private fun createPreferencesAccountPresenter( + matrixClient: FakeMatrixClient = FakeMatrixClient(), + sessionVerificationService: FakeSessionVerificationService = FakeSessionVerificationService(), + rageshakeFeatureAvailability: RageshakeFeatureAvailability = RageshakeFeatureAvailability { flowOf(true) }, + indicatorService: IndicatorService = FakeIndicatorService(), + featureFlagService: FeatureFlagService = FakeFeatureFlagService(), + sessionEnterpriseService: SessionEnterpriseService = FakeSessionEnterpriseService(), + ) = PreferencesAccountPresenter( + matrixClient = matrixClient, + sessionVerificationService = sessionVerificationService, + snackbarDispatcher = SnackbarDispatcher(), + indicatorService = indicatorService, + directLogoutPresenter = { aDirectLogoutState() }, + rageshakeFeatureAvailability = rageshakeFeatureAvailability, + featureFlagService = featureFlagService, + sessionEnterpriseService = sessionEnterpriseService, + ) +} diff --git a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/account/PreferencesAccountViewTest.kt b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/account/PreferencesAccountViewTest.kt new file mode 100644 index 0000000000..f3ac4d1898 --- /dev/null +++ b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/account/PreferencesAccountViewTest.kt @@ -0,0 +1,179 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +@file:OptIn(ExperimentalTestApi::class) + +package io.element.android.features.preferences.impl.account + +import androidx.activity.ComponentActivity +import androidx.compose.ui.test.AndroidComposeUiTest +import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.hasClickAction +import androidx.compose.ui.test.hasText +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.v2.runAndroidComposeUiTest +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.EnsureNeverCalledWithParam +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.robolectric.RobolectricTest +import org.junit.Test + +class PreferencesAccountViewTest : RobolectricTest() { + @Test + fun `click on Manage account invokes the expected callback`() = runAndroidComposeUiTest { + ensureCalledOnceWithParam("aUrl") { callback -> + setView( + aPreferencesAccountState(accountManagementUrl = "aUrl"), + onManageAccountClick = callback, + ) + clickOn(CommonStrings.action_manage_account_and_devices) + } + } + + @Test + fun `when accountManagementUrl is null, item is not shown`() = runAndroidComposeUiTest { + setView(aPreferencesAccountState(accountManagementUrl = null)) + onNodeWithText(activity!!.getString(CommonStrings.action_manage_account_and_devices)).assertDoesNotExist() + } + + @Test + fun `click on Link new devices invokes the expected callback`() = runAndroidComposeUiTest { + ensureCalledOnce { callback -> + setView( + aPreferencesAccountState(showLinkNewDevice = true), + onLinkNewDeviceClick = callback, + ) + clickOn(CommonStrings.common_link_new_device) + } + } + + @Test + fun `when showLinkNewDevice is false, item is not shown`() = runAndroidComposeUiTest { + setView(aPreferencesAccountState(showLinkNewDevice = false)) + onNodeWithText(activity!!.getString(CommonStrings.common_link_new_device)).assertDoesNotExist() + } + + @Test + fun `click on Notification invokes the expected callback`() = runAndroidComposeUiTest { + ensureCalledOnce { callback -> + setView( + aPreferencesAccountState(), + onOpenNotificationSettings = callback, + ) + clickOn(R.string.screen_notification_settings_title) + } + } + + @Test + fun `click on Encryption invokes the expected callback`() = runAndroidComposeUiTest { + ensureCalledOnce { callback -> + setView( + aPreferencesAccountState(showSecureBackup = true), + onSecureBackupClick = callback, + ) + val text = activity!!.getString(CommonStrings.common_encryption) + onNode(hasText(text) and hasClickAction()).performClick() + } + } + + @Test + fun `when showSecureBackup is false, item is not shown`() = runAndroidComposeUiTest { + setView(aPreferencesAccountState(showSecureBackup = false)) + onNodeWithText(activity!!.getString(CommonStrings.common_encryption)).assertDoesNotExist() + } + + @Test + fun `click on Moderation and safety invokes the expected callback`() = runAndroidComposeUiTest { + ensureCalledOnce { callback -> + setView( + aPreferencesAccountState(), + onModerationAndSafetyClick = callback, + ) + val text = activity!!.getString(CommonStrings.common_moderation_and_safety) + onNode(hasText(text) and hasClickAction()).performClick() + } + } + + @Test + fun `click on Report a problem invokes the expected callback`() = runAndroidComposeUiTest { + ensureCalledOnce { callback -> + setView( + aPreferencesAccountState(canReportBug = true), + onOpenRageShake = callback, + ) + val text = activity!!.getString(CommonStrings.common_report_a_problem) + onNode(hasText(text) and hasClickAction()).performClick() + } + } + + @Test + fun `when canReportBug is false, item is not shown`() = runAndroidComposeUiTest { + setView(aPreferencesAccountState(canReportBug = false)) + onNodeWithText(activity!!.getString(CommonStrings.common_report_a_problem)).assertDoesNotExist() + } + + @Test + fun `click on Remove this device invokes the expected callback`() = runAndroidComposeUiTest { + ensureCalledOnce { callback -> + setView( + aPreferencesAccountState(), + onSignOutClick = callback, + ) + val text = activity!!.getString(CommonStrings.action_signout) + onNode(hasText(text) and hasClickAction()).performClick() + } + } + + @Test + fun `click on Deactivate invokes the expected callback`() = runAndroidComposeUiTest { + ensureCalledOnce { callback -> + setView( + aPreferencesAccountState(canDeactivateAccount = true), + onDeactivateClick = callback, + ) + val text = activity!!.getString(CommonStrings.action_delete_account) + onNode(hasText(text) and hasClickAction()).performClick() + } + } + + @Test + fun `when canDeactivateAccount is false, item is not shown`() = runAndroidComposeUiTest { + setView(aPreferencesAccountState(canDeactivateAccount = false)) + onNodeWithText(activity!!.getString(CommonStrings.action_delete_account)).assertDoesNotExist() + } +} + +private fun AndroidComposeUiTest.setView( + state: PreferencesAccountState, + onSecureBackupClick: () -> Unit = EnsureNeverCalled(), + onManageAccountClick: (url: String) -> Unit = EnsureNeverCalledWithParam(), + onLinkNewDeviceClick: () -> Unit = EnsureNeverCalled(), + onOpenRageShake: () -> Unit = EnsureNeverCalled(), + onModerationAndSafetyClick: () -> Unit = EnsureNeverCalled(), + onOpenNotificationSettings: () -> Unit = EnsureNeverCalled(), + onSignOutClick: () -> Unit = EnsureNeverCalled(), + onDeactivateClick: () -> Unit = EnsureNeverCalled(), +) { + setContent { + PreferencesAccountView( + state = state, + onSecureBackupClick = onSecureBackupClick, + onManageAccountClick = onManageAccountClick, + onLinkNewDeviceClick = onLinkNewDeviceClick, + onOpenRageShake = onOpenRageShake, + onModerationAndSafetyClick = onModerationAndSafetyClick, + onOpenNotificationSettings = onOpenNotificationSettings, + onSignOutClick = onSignOutClick, + onDeactivateClick = onDeactivateClick, + ) + } +} 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 deleted file mode 100644 index 2ee594e8b9..0000000000 --- a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsPresenterTest.kt +++ /dev/null @@ -1,393 +0,0 @@ -/* - * Copyright (c) 2025 Element Creations Ltd. - * Copyright 2023-2025 New Vector Ltd. - * - * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. - * Please see LICENSE files in the repository root for full details. - */ - -package io.element.android.features.preferences.impl.advanced - -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.compound.theme.Theme -import io.element.android.libraries.architecture.AsyncAction -import io.element.android.libraries.featureflag.api.FeatureFlags -import io.element.android.libraries.featureflag.test.FakeFeatureFlagService -import io.element.android.libraries.matrix.api.media.MediaPreviewValue -import io.element.android.libraries.preferences.api.store.AppPreferencesStore -import io.element.android.libraries.preferences.api.store.VideoCompressionPreset -import io.element.android.libraries.preferences.test.InMemoryAppPreferencesStore -import io.element.android.libraries.preferences.test.InMemorySessionPreferencesStore -import io.element.android.tests.testutils.WarmUpRule -import kotlinx.collections.immutable.toImmutableList -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.test.runTest -import org.junit.Rule -import org.junit.Test - -class AdvancedSettingsPresenterTest { - @get:Rule - val warmUpRule = WarmUpRule() - - @Test - fun `present - initial state`() = runTest { - val presenter = createAdvancedSettingsPresenter() - moleculeFlow(RecompositionMode.Immediate) { - presenter.present() - }.test { - with(awaitItem()) { - assertThat(isDeveloperModeEnabled).isFalse() - assertThat(isSharePresenceEnabled).isTrue() - assertThat(mediaOptimizationState).isNull() - assertThat(theme).isEqualTo(ThemeOption.System) - assertThat(availableThemeOptions).isEqualTo( - listOf(ThemeOption.System, ThemeOption.Light, ThemeOption.Dark).toImmutableList() - ) - assertThat(mediaPreviewConfigState.hideInviteAvatars).isFalse() - assertThat(mediaPreviewConfigState.timelineMediaPreviewValue).isEqualTo(MediaPreviewValue.On) - assertThat(mediaPreviewConfigState.setHideInviteAvatarsAction).isEqualTo(AsyncAction.Uninitialized) - assertThat(mediaPreviewConfigState.setTimelineMediaPreviewAction).isEqualTo(AsyncAction.Uninitialized) - } - - // After the initial state, we expect the media optimization state to be set - with(awaitItem()) { - assertThat(mediaOptimizationState).isInstanceOf(MediaOptimizationState.AllMedia::class.java) - assertThat((mediaOptimizationState as MediaOptimizationState.AllMedia).isEnabled).isTrue() - } - } - } - - @Test - fun `present - developer mode on off`() = runTest { - val presenter = createAdvancedSettingsPresenter() - moleculeFlow(RecompositionMode.Immediate) { - presenter.present() - }.test { - // Skip until the initial data it loaded - skipItems(1) - - with(awaitItem()) { - assertThat(isDeveloperModeEnabled).isFalse() - eventSink(AdvancedSettingsEvent.SetDeveloperModeEnabled(true)) - } - with(awaitItem()) { - assertThat(isDeveloperModeEnabled).isTrue() - eventSink(AdvancedSettingsEvent.SetDeveloperModeEnabled(false)) - } - with(awaitItem()) { - assertThat(isDeveloperModeEnabled).isFalse() - } - } - } - - @Test - fun `present - share presence off on`() = runTest { - val presenter = createAdvancedSettingsPresenter() - moleculeFlow(RecompositionMode.Immediate) { - presenter.present() - }.test { - // Skip until the initial data it loaded - skipItems(1) - - with(awaitItem()) { - assertThat(isSharePresenceEnabled).isTrue() - eventSink(AdvancedSettingsEvent.SetSharePresenceEnabled(false)) - } - with(awaitItem()) { - assertThat(isSharePresenceEnabled).isFalse() - eventSink(AdvancedSettingsEvent.SetSharePresenceEnabled(true)) - } - with(awaitItem()) { - assertThat(isSharePresenceEnabled).isTrue() - } - } - } - - @Test - fun `present - compress media off on`() = runTest { - val presenter = createAdvancedSettingsPresenter() - moleculeFlow(RecompositionMode.Immediate) { - presenter.present() - }.test { - // Skip until the initial data it loaded - skipItems(1) - - with(awaitItem()) { - assertThat((mediaOptimizationState as MediaOptimizationState.AllMedia).isEnabled).isTrue() - eventSink(AdvancedSettingsEvent.SetCompressMedia(false)) - } - with(awaitItem()) { - assertThat((mediaOptimizationState as MediaOptimizationState.AllMedia).isEnabled).isFalse() - eventSink(AdvancedSettingsEvent.SetCompressMedia(true)) - } - with(awaitItem()) { - assertThat((mediaOptimizationState as MediaOptimizationState.AllMedia).isEnabled).isTrue() - } - } - } - - @Test - fun `present - compress images off on`() = runTest { - val presenter = createAdvancedSettingsPresenter( - featureFlagService = FakeFeatureFlagService().apply { - setFeatureEnabled(FeatureFlags.SelectableMediaQuality, true) - } - ) - moleculeFlow(RecompositionMode.Immediate) { - presenter.present() - }.test { - // Skip until the initial data it loaded - skipItems(1) - - with(awaitItem()) { - assertThat((mediaOptimizationState as MediaOptimizationState.Split).compressImages).isTrue() - eventSink(AdvancedSettingsEvent.SetCompressImages(false)) - } - with(awaitItem()) { - assertThat((mediaOptimizationState as MediaOptimizationState.Split).compressImages).isFalse() - eventSink(AdvancedSettingsEvent.SetCompressImages(true)) - } - with(awaitItem()) { - assertThat((mediaOptimizationState as MediaOptimizationState.Split).compressImages).isTrue() - } - } - } - - @Test - fun `present - video upload quality selector`() = runTest { - val presenter = createAdvancedSettingsPresenter( - featureFlagService = FakeFeatureFlagService().apply { - setFeatureEnabled(FeatureFlags.SelectableMediaQuality, true) - } - ) - moleculeFlow(RecompositionMode.Immediate) { - presenter.present() - }.test { - // Skip until the initial data it loaded - skipItems(1) - - with(awaitItem()) { - assertThat((mediaOptimizationState as MediaOptimizationState.Split).videoPreset).isEqualTo(VideoCompressionPreset.STANDARD) - eventSink(AdvancedSettingsEvent.SetVideoUploadQuality(VideoCompressionPreset.LOW)) - } - with(awaitItem()) { - assertThat((mediaOptimizationState as MediaOptimizationState.Split).videoPreset).isEqualTo(VideoCompressionPreset.LOW) - eventSink(AdvancedSettingsEvent.SetVideoUploadQuality(VideoCompressionPreset.HIGH)) - } - with(awaitItem()) { - assertThat((mediaOptimizationState as MediaOptimizationState.Split).videoPreset).isEqualTo(VideoCompressionPreset.HIGH) - } - } - } - - @Test - fun `present - change theme`() = runTest { - val presenter = createAdvancedSettingsPresenter() - moleculeFlow(RecompositionMode.Immediate) { - presenter.present() - }.test { - // Skip until the initial data it loaded - skipItems(1) - - with(awaitItem()) { - assertThat(theme).isEqualTo(ThemeOption.System) - eventSink(AdvancedSettingsEvent.SetTheme(ThemeOption.Dark)) - } - with(awaitItem()) { - assertThat(theme).isEqualTo(ThemeOption.Dark) - eventSink(AdvancedSettingsEvent.SetTheme(ThemeOption.Light)) - } - with(awaitItem()) { - assertThat(theme).isEqualTo(ThemeOption.Light) - eventSink(AdvancedSettingsEvent.SetTheme(ThemeOption.System)) - } - with(awaitItem()) { - assertThat(theme).isEqualTo(ThemeOption.System) - } - } - } - - @Test - fun `present - exposes live location minimum distance from app preferences`() = runTest { - val appPreferencesStore = InMemoryAppPreferencesStore( - liveLocationMinimumDistanceUpdate = 50, - ) - val presenter = createAdvancedSettingsPresenter(appPreferencesStore = appPreferencesStore) - - moleculeFlow(RecompositionMode.Immediate) { - presenter.present() - }.test { - skipItems(1) - - with(awaitItem()) { - assertThat(liveLocationMinimumDistanceUpdate).isEqualTo(50) - } - } - } - - @Test - fun `present - saving live location minimum distance updates app preferences`() = runTest { - val appPreferencesStore = InMemoryAppPreferencesStore( - liveLocationMinimumDistanceUpdate = 10, - ) - val presenter = createAdvancedSettingsPresenter(appPreferencesStore = appPreferencesStore) - - moleculeFlow(RecompositionMode.Immediate) { - presenter.present() - }.test { - skipItems(1) - - with(awaitItem()) { - assertThat(liveLocationMinimumDistanceUpdate).isEqualTo(10) - eventSink(AdvancedSettingsEvent.SetLiveLocationMinimumDistanceUpdate(42)) - } - with(awaitItem()) { - assertThat(liveLocationMinimumDistanceUpdate).isEqualTo(42) - } - } - } - - @Test - fun `present - black theme option shown when feature flag enabled`() = runTest { - val presenter = createAdvancedSettingsPresenter( - featureFlagService = FakeFeatureFlagService().apply { - setFeatureEnabled(FeatureFlags.AllowBlackTheme, true) - } - ) - moleculeFlow(RecompositionMode.Immediate) { - presenter.present() - }.test { - skipItems(1) - - with(awaitItem()) { - assertThat(availableThemeOptions).contains(ThemeOption.Black) - assertThat(availableThemeOptions).isEqualTo(ThemeOption.entries.toImmutableList()) - } - } - } - - @Test - fun `present - stored black theme falls back to dark when feature flag disabled`() = runTest { - val appPreferencesStore = InMemoryAppPreferencesStore().apply { - setTheme(Theme.Black.name) - } - val presenter = createAdvancedSettingsPresenter(appPreferencesStore = appPreferencesStore) - moleculeFlow(RecompositionMode.Immediate) { - presenter.present() - }.test { - skipItems(1) - - with(awaitItem()) { - assertThat(theme).isEqualTo(ThemeOption.Dark) - } - } - } - - @Test - fun `present - hide invite avatars`() = runTest { - val mediaPreviewStore = FakeMediaPreviewConfigStateStore() - val presenter = createAdvancedSettingsPresenter(mediaPreviewConfigStateStore = mediaPreviewStore) - moleculeFlow(RecompositionMode.Immediate) { - presenter.present() - }.test { - // Skip until the initial data it loaded - skipItems(1) - - with(awaitItem()) { - assertThat(mediaPreviewConfigState.hideInviteAvatars).isFalse() - eventSink(AdvancedSettingsEvent.SetHideInviteAvatars(true)) - } - with(awaitItem()) { - assertThat(mediaPreviewConfigState.hideInviteAvatars).isTrue() - eventSink(AdvancedSettingsEvent.SetHideInviteAvatars(false)) - } - with(awaitItem()) { - assertThat(mediaPreviewConfigState.hideInviteAvatars).isFalse() - } - } - assertThat(mediaPreviewStore.getSetHideInviteAvatarsEvents()).isEqualTo(listOf(true, false)) - } - - @Test - fun `present - timeline media preview value`() = runTest { - val mediaPreviewStore = FakeMediaPreviewConfigStateStore() - val presenter = createAdvancedSettingsPresenter(mediaPreviewConfigStateStore = mediaPreviewStore) - moleculeFlow(RecompositionMode.Immediate) { - presenter.present() - }.test { - // Skip until the initial data it loaded - skipItems(1) - - with(awaitItem()) { - assertThat(mediaPreviewConfigState.timelineMediaPreviewValue).isEqualTo(MediaPreviewValue.On) - eventSink(AdvancedSettingsEvent.SetTimelineMediaPreviewValue(MediaPreviewValue.Off)) - } - with(awaitItem()) { - assertThat(mediaPreviewConfigState.timelineMediaPreviewValue).isEqualTo(MediaPreviewValue.Off) - eventSink(AdvancedSettingsEvent.SetTimelineMediaPreviewValue(MediaPreviewValue.Private)) - } - with(awaitItem()) { - assertThat(mediaPreviewConfigState.timelineMediaPreviewValue).isEqualTo(MediaPreviewValue.Private) - } - } - assertThat(mediaPreviewStore.getSetTimelineMediaPreviewValueEvents()).isEqualTo( - listOf(MediaPreviewValue.Off, MediaPreviewValue.Private) - ) - } - - @Test - fun `present - media preview state with custom initial values`() = runTest { - val mediaPreviewStore = FakeMediaPreviewConfigStateStore( - hideInviteAvatarsValue = true, - timelineMediaPreviewValue = MediaPreviewValue.Private - ) - val presenter = createAdvancedSettingsPresenter(mediaPreviewConfigStateStore = mediaPreviewStore) - moleculeFlow(RecompositionMode.Immediate) { - presenter.present() - }.test { - // Skip until the initial data it loaded - skipItems(1) - - with(awaitItem()) { - assertThat(mediaPreviewConfigState.hideInviteAvatars).isTrue() - assertThat(mediaPreviewConfigState.timelineMediaPreviewValue).isEqualTo(MediaPreviewValue.Private) - } - } - } - - @Test - fun `present - async actions state`() = runTest { - val mediaPreviewStore = FakeMediaPreviewConfigStateStore( - setHideInviteAvatarsActionValue = AsyncAction.Loading, - setTimelineMediaPreviewActionValue = AsyncAction.Success(Unit) - ) - val presenter = createAdvancedSettingsPresenter(mediaPreviewConfigStateStore = mediaPreviewStore) - moleculeFlow(RecompositionMode.Immediate) { - presenter.present() - }.test { - // Skip until the initial data it loaded - skipItems(1) - - with(awaitItem()) { - assertThat(mediaPreviewConfigState.setHideInviteAvatarsAction).isEqualTo(AsyncAction.Loading) - assertThat(mediaPreviewConfigState.setTimelineMediaPreviewAction).isEqualTo(AsyncAction.Success(Unit)) - } - } - } - - private fun CoroutineScope.createAdvancedSettingsPresenter( - appPreferencesStore: AppPreferencesStore = InMemoryAppPreferencesStore(), - sessionPreferencesStore: InMemorySessionPreferencesStore = InMemorySessionPreferencesStore(), - mediaPreviewConfigStateStore: MediaPreviewConfigStateStore = FakeMediaPreviewConfigStateStore(), - featureFlagService: FakeFeatureFlagService = FakeFeatureFlagService(), - ) = AdvancedSettingsPresenter( - appPreferencesStore = appPreferencesStore, - sessionPreferencesStore = sessionPreferencesStore, - mediaPreviewConfigStateStore = mediaPreviewConfigStateStore, - featureFlagService = featureFlagService, - sessionCoroutineScope = this, - ) -} diff --git a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsViewTest.kt b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsViewTest.kt deleted file mode 100644 index 52af7d5927..0000000000 --- a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsViewTest.kt +++ /dev/null @@ -1,264 +0,0 @@ -/* - * Copyright (c) 2025 Element Creations Ltd. - * Copyright 2024, 2025 New Vector Ltd. - * - * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. - * Please see LICENSE files in the repository root for full details. - */ - -@file:OptIn(ExperimentalTestApi::class) - -package io.element.android.features.preferences.impl.advanced - -import androidx.activity.ComponentActivity -import androidx.compose.runtime.CompositionLocalProvider -import androidx.compose.ui.test.AndroidComposeUiTest -import androidx.compose.ui.test.ExperimentalTestApi -import androidx.compose.ui.test.onNodeWithText -import androidx.compose.ui.test.v2.runAndroidComposeUiTest -import com.google.common.truth.Truth.assertThat -import im.vector.app.features.analytics.plan.Interaction -import io.element.android.features.preferences.impl.R -import io.element.android.libraries.architecture.AsyncAction -import io.element.android.libraries.matrix.api.media.MediaPreviewValue -import io.element.android.libraries.ui.strings.CommonStrings -import io.element.android.services.analytics.api.AnalyticsService -import io.element.android.services.analytics.compose.LocalAnalyticsService -import io.element.android.services.analytics.test.FakeAnalyticsService -import io.element.android.tests.testutils.EnsureNeverCalled -import io.element.android.tests.testutils.EventsRecorder -import io.element.android.tests.testutils.assertNoNodeWithText -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.robolectric.RobolectricTest -import kotlinx.collections.immutable.toImmutableList -import org.junit.Test -import org.robolectric.annotation.Config - -class AdvancedSettingsViewTest : RobolectricTest() { - @Test - fun `clicking on back invokes the expected callback`() = runAndroidComposeUiTest { - val eventsRecorder = EventsRecorder(expectEvents = false) - ensureCalledOnce { - setAdvancedSettingsView( - state = aAdvancedSettingsState( - eventSink = eventsRecorder - ), - onBackClick = it - ) - pressBack() - } - } - - @Test - fun `clicking on other theme emits the expected event`() = runAndroidComposeUiTest { - val eventsRecorder = EventsRecorder() - setAdvancedSettingsView( - state = aAdvancedSettingsState( - eventSink = eventsRecorder, - ), - ) - clickOn(CommonStrings.common_appearance) - clickOn(R.string.theme_dark) - eventsRecorder.assertSingle(AdvancedSettingsEvent.SetTheme(ThemeOption.Dark)) - } - - @Test - fun `black theme is shown when available`() = runAndroidComposeUiTest { - setAdvancedSettingsView( - state = aAdvancedSettingsState( - availableThemeOptions = ThemeOption.entries.toImmutableList(), - ), - ) - clickOn(CommonStrings.common_appearance) - run { - val text = activity!!.getString(R.string.theme_black) - onNodeWithText(text).assertExists() - } - } - - @Test - fun `black theme is hidden when unavailable`() = runAndroidComposeUiTest { - setAdvancedSettingsView( - state = aAdvancedSettingsState( - availableThemeOptions = ThemeOption.entries.filterNot { it == ThemeOption.Black }.toImmutableList(), - ), - ) - clickOn(CommonStrings.common_appearance) - assertNoNodeWithText(R.string.theme_black) - } - - @Test - fun `clicking on View source emits the expected event`() = runAndroidComposeUiTest { - val eventsRecorder = EventsRecorder() - setAdvancedSettingsView( - state = aAdvancedSettingsState( - eventSink = eventsRecorder, - ), - ) - clickOn(CommonStrings.action_view_source) - eventsRecorder.assertSingle(AdvancedSettingsEvent.SetDeveloperModeEnabled(true)) - } - - @Test - fun `clicking on Share presence emits the expected event`() = runAndroidComposeUiTest { - val eventsRecorder = EventsRecorder() - setAdvancedSettingsView( - state = aAdvancedSettingsState( - eventSink = eventsRecorder, - ), - ) - clickOn(R.string.screen_advanced_settings_share_presence) - eventsRecorder.assertSingle(AdvancedSettingsEvent.SetSharePresenceEnabled(true)) - } - - @Test - fun `clicking on media to enable compression emits the expected event`() = runAndroidComposeUiTest { - val eventsRecorder = EventsRecorder() - val analyticsService = FakeAnalyticsService() - setAdvancedSettingsView( - state = aAdvancedSettingsState( - eventSink = eventsRecorder, - ), - analyticsService = analyticsService - ) - clickOn(R.string.screen_advanced_settings_media_compression_description) - eventsRecorder.assertSingle(AdvancedSettingsEvent.SetCompressMedia(true)) - assertThat(analyticsService.capturedEvents).isEqualTo( - listOf( - Interaction( - name = Interaction.Name.MobileSettingsOptimizeMediaUploadsEnabled - ) - ) - ) - } - - @Test - fun `clicking on media to disable compression emits the expected event`() = runAndroidComposeUiTest { - val eventsRecorder = EventsRecorder() - val analyticsService = FakeAnalyticsService() - setAdvancedSettingsView( - state = aAdvancedSettingsState( - mediaOptimizationState = MediaOptimizationState.AllMedia(isEnabled = true), - eventSink = eventsRecorder, - ), - analyticsService = analyticsService - ) - clickOn(R.string.screen_advanced_settings_media_compression_description) - eventsRecorder.assertSingle(AdvancedSettingsEvent.SetCompressMedia(false)) - assertThat(analyticsService.capturedEvents).isEqualTo( - listOf( - Interaction( - name = Interaction.Name.MobileSettingsOptimizeMediaUploadsDisabled - ) - ) - ) - } - - @Test - @Config(qualifiers = "h1080dp") - fun `clicking on hide invite avatars emits the expected event`() = runAndroidComposeUiTest { - val eventsRecorder = EventsRecorder() - setAdvancedSettingsView( - state = aAdvancedSettingsState( - eventSink = eventsRecorder, - hideInviteAvatars = false - ), - ) - clickOn(R.string.screen_advanced_settings_hide_invite_avatars_toggle_title) - eventsRecorder.assertSingle(AdvancedSettingsEvent.SetHideInviteAvatars(true)) - } - - @Test - @Config(qualifiers = "h1080dp") - fun `clicking on timeline media preview always hide emits the expected event`() = runAndroidComposeUiTest { - val eventsRecorder = EventsRecorder() - setAdvancedSettingsView( - state = aAdvancedSettingsState( - eventSink = eventsRecorder, - timelineMediaPreviewValue = MediaPreviewValue.On - ), - ) - clickOn(R.string.screen_advanced_settings_show_media_timeline_always_hide) - eventsRecorder.assertSingle(AdvancedSettingsEvent.SetTimelineMediaPreviewValue(MediaPreviewValue.Off)) - } - - @Test - @Config(qualifiers = "h1080dp") - fun `clicking on timeline media preview private rooms emits the expected event`() = runAndroidComposeUiTest { - val eventsRecorder = EventsRecorder() - setAdvancedSettingsView( - state = aAdvancedSettingsState( - eventSink = eventsRecorder, - timelineMediaPreviewValue = MediaPreviewValue.On - ), - ) - clickOn(R.string.screen_advanced_settings_show_media_timeline_private_rooms) - eventsRecorder.assertSingle(AdvancedSettingsEvent.SetTimelineMediaPreviewValue(MediaPreviewValue.Private)) - } - - @Test - @Config(qualifiers = "h1080dp") - fun `clicking on timeline media preview always show emits the expected event`() = runAndroidComposeUiTest { - val eventsRecorder = EventsRecorder() - setAdvancedSettingsView( - state = aAdvancedSettingsState( - eventSink = eventsRecorder, - timelineMediaPreviewValue = MediaPreviewValue.Off - ), - ) - clickOn(R.string.screen_advanced_settings_show_media_timeline_always_show) - eventsRecorder.assertSingle(AdvancedSettingsEvent.SetTimelineMediaPreviewValue(MediaPreviewValue.On)) - } - - @Test - @Config(qualifiers = "h1080dp") - fun `hide invite avatars toggle is disabled when action is loading`() = runAndroidComposeUiTest { - val eventsRecorder = EventsRecorder(expectEvents = false) - setAdvancedSettingsView( - state = aAdvancedSettingsState( - eventSink = eventsRecorder, - hideInviteAvatars = false, - setHideInviteAvatarsAction = AsyncAction.Loading - ), - ) - // The toggle should be disabled, so clicking should not emit any events - clickOn(R.string.screen_advanced_settings_hide_invite_avatars_toggle_title) - } - - @Test - @Config(qualifiers = "h1080dp") - fun `timeline media preview options are disabled when action is loading`() = runAndroidComposeUiTest { - val eventsRecorder = EventsRecorder(expectEvents = false) - setAdvancedSettingsView( - state = aAdvancedSettingsState( - eventSink = eventsRecorder, - timelineMediaPreviewValue = MediaPreviewValue.On, - setTimelineMediaPreviewAction = AsyncAction.Loading - ), - ) - // The options should be disabled, so clicking should not emit any events - clickOn(R.string.screen_advanced_settings_show_media_timeline_always_hide) - clickOn(R.string.screen_advanced_settings_show_media_timeline_private_rooms) - } -} - -private fun AndroidComposeUiTest.setAdvancedSettingsView( - state: AdvancedSettingsState, - analyticsService: AnalyticsService = FakeAnalyticsService(), - onBackClick: () -> Unit = EnsureNeverCalled(), - onOpenAppSettings: () -> Unit = EnsureNeverCalled(), -) { - setContent { - CompositionLocalProvider( - LocalAnalyticsService provides analyticsService, - ) { - AdvancedSettingsView( - state = state, - onBackClick = onBackClick, - onOpenAppSettingsClick = onOpenAppSettings - ) - } - } -} diff --git a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/developer/appsettings/AppDeveloperSettingsPageTest.kt b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/developer/appsettings/AppDeveloperSettingsPageTest.kt index 891cf44b96..8a012d29c7 100644 --- a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/developer/appsettings/AppDeveloperSettingsPageTest.kt +++ b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/developer/appsettings/AppDeveloperSettingsPageTest.kt @@ -48,6 +48,18 @@ class AppDeveloperSettingsPageTest : RobolectricTest() { } } + @Test + fun `clicking on View source emits the expected event`() = runAndroidComposeUiTest { + val eventsRecorder = EventsRecorder() + setAppDeveloperSettingsView( + state = anAppDeveloperSettingsState( + eventSink = eventsRecorder + ), + ) + clickOn(CommonStrings.action_view_source) + eventsRecorder.assertSingle(AppDeveloperSettingsEvent.SetDeveloperModeEnabled(true)) + } + @Config(qualifiers = "h1500dp") @Test fun `clicking on element call url open the dialogs and submit emits the expected event`() = runAndroidComposeUiTest { diff --git a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/developer/appsettings/AppDeveloperSettingsPresenterTest.kt b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/developer/appsettings/AppDeveloperSettingsPresenterTest.kt index f4dad80e8b..9300236293 100644 --- a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/developer/appsettings/AppDeveloperSettingsPresenterTest.kt +++ b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/developer/appsettings/AppDeveloperSettingsPresenterTest.kt @@ -47,6 +47,7 @@ class AppDeveloperSettingsPresenterTest { ) presenter.test { awaitItem().also { state -> + assertThat(state.isDeveloperModeEnabled).isFalse() assertThat(state.features).isEmpty() assertThat(state.customElementCallBaseUrlState).isNotNull() assertThat(state.customElementCallBaseUrlState.baseUrl).isNull() @@ -130,6 +131,26 @@ class AppDeveloperSettingsPresenterTest { } } + @Test + fun `present - developer mode on off`() = runTest { + val preferences = InMemoryAppPreferencesStore() + val presenter = createAppDeveloperSettingsPresenter(preferencesStore = preferences) + presenter.test { + skipItems(1) + awaitItem().also { state -> + assertThat(state.isDeveloperModeEnabled).isFalse() + state.eventSink(AppDeveloperSettingsEvent.SetDeveloperModeEnabled(true)) + } + awaitItem().also { state -> + assertThat(state.isDeveloperModeEnabled).isTrue() + state.eventSink(AppDeveloperSettingsEvent.SetDeveloperModeEnabled(false)) + } + awaitItem().also { state -> + assertThat(state.isDeveloperModeEnabled).isFalse() + } + } + } + private fun createAppDeveloperSettingsPresenter( featureFlagService: FakeFeatureFlagService = FakeFeatureFlagService( getAvailableFeaturesResult = { _, _ -> diff --git a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/location/LocationSettingsPresenterTest.kt b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/location/LocationSettingsPresenterTest.kt new file mode 100644 index 0000000000..f0090c3700 --- /dev/null +++ b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/location/LocationSettingsPresenterTest.kt @@ -0,0 +1,71 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.preferences.impl.location + +import com.google.common.truth.Truth.assertThat +import io.element.android.libraries.preferences.api.store.AppPreferencesStore +import io.element.android.libraries.preferences.test.InMemoryAppPreferencesStore +import io.element.android.tests.testutils.WarmUpRule +import io.element.android.tests.testutils.test +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.runTest +import org.junit.Rule +import org.junit.Test + +class LocationSettingsPresenterTest { + @get:Rule + val warmUpRule = WarmUpRule() + + @Test + fun `present - initial state`() = runTest { + createLocationSettingsPresenter().test { + with(awaitItem()) { + assertThat(liveLocationMinimumDistanceUpdate).isEqualTo(10) + } + } + } + + @Test + fun `present - exposes live location minimum distance from app preferences`() = runTest { + val appPreferencesStore = InMemoryAppPreferencesStore( + liveLocationMinimumDistanceUpdate = 50, + ) + createLocationSettingsPresenter(appPreferencesStore = appPreferencesStore).test { + skipItems(1) + + with(awaitItem()) { + assertThat(liveLocationMinimumDistanceUpdate).isEqualTo(50) + } + } + } + + @Test + fun `present - saving live location minimum distance updates app preferences`() = runTest { + val appPreferencesStore = InMemoryAppPreferencesStore( + liveLocationMinimumDistanceUpdate = 10, + ) + createLocationSettingsPresenter(appPreferencesStore = appPreferencesStore).test { + with(awaitItem()) { + assertThat(liveLocationMinimumDistanceUpdate).isEqualTo(10) + eventSink(LocationSettingsEvent.SetLiveLocationMinimumDistanceUpdate(42)) + } + with(awaitItem()) { + assertThat(liveLocationMinimumDistanceUpdate).isEqualTo(42) + } + } + assertThat(appPreferencesStore.getLiveLocationMinimumDistanceInMetersUpdateFlow().first()).isEqualTo(42) + } + + private fun CoroutineScope.createLocationSettingsPresenter( + appPreferencesStore: AppPreferencesStore = InMemoryAppPreferencesStore(), + ) = LocationSettingsPresenter( + appPreferencesStore = appPreferencesStore, + sessionCoroutineScope = this, + ) +} diff --git a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/location/LocationSettingsViewTest.kt b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/location/LocationSettingsViewTest.kt new file mode 100644 index 0000000000..afbef3f97b --- /dev/null +++ b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/location/LocationSettingsViewTest.kt @@ -0,0 +1,88 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +@file:OptIn(ExperimentalTestApi::class) + +package io.element.android.features.preferences.impl.location + +import androidx.activity.ComponentActivity +import androidx.compose.ui.semantics.SemanticsActions +import androidx.compose.ui.test.AndroidComposeUiTest +import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.SemanticsMatcher +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performSemanticsAction +import androidx.compose.ui.test.v2.runAndroidComposeUiTest +import io.element.android.features.preferences.impl.R +import io.element.android.tests.testutils.EnsureNeverCalled +import io.element.android.tests.testutils.EventsRecorder +import io.element.android.tests.testutils.ensureCalledOnce +import io.element.android.tests.testutils.pressBack +import io.element.android.tests.testutils.robolectric.RobolectricTest +import org.junit.Test + +class LocationSettingsViewTest : RobolectricTest() { + @Test + fun `clicking on back invokes the expected callback`() = runAndroidComposeUiTest { + val eventsRecorder = EventsRecorder(expectEvents = false) + ensureCalledOnce { + setLocationSettingsView( + state = aLocationSettingsState( + eventSink = eventsRecorder + ), + onBackClick = it + ) + pressBack() + } + } + + @Test + fun `the current minimum distance is displayed`() = runAndroidComposeUiTest { + val eventsRecorder = EventsRecorder(expectEvents = false) + setLocationSettingsView( + state = aLocationSettingsState( + liveLocationMinimumDistanceUpdate = 50, + eventSink = eventsRecorder, + ), + ) + val text = activity!!.resources.getQuantityString( + R.plurals.screen_advanced_settings_live_location_update_distance, + 50, + 50, + ) + onNodeWithText(text).assertExists() + } + + @Test + fun `changing the minimum distance emits the expected event`() = runAndroidComposeUiTest { + val eventsRecorder = EventsRecorder() + setLocationSettingsView( + state = aLocationSettingsState( + liveLocationMinimumDistanceUpdate = 50, + eventSink = eventsRecorder, + ), + ) + // `setProgress` invokes `onValueChange` and then `onValueChangeFinish`, which is what the view listens to. + onNode(SemanticsMatcher.keyIsDefined(SemanticsActions.SetProgress)) + .performSemanticsAction(SemanticsActions.SetProgress) { it(42f) } + eventsRecorder.assertSingle(LocationSettingsEvent.SetLiveLocationMinimumDistanceUpdate(42)) + } +} + +private fun AndroidComposeUiTest.setLocationSettingsView( + state: LocationSettingsState, + onBackClick: () -> Unit = EnsureNeverCalled(), + onOpenAppSettingsClick: () -> Unit = EnsureNeverCalled(), +) { + setContent { + LocationSettingsView( + state = state, + onBackClick = onBackClick, + onOpenAppSettingsClick = onOpenAppSettingsClick, + ) + } +} diff --git a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/media/MediaSettingsPresenterTest.kt b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/media/MediaSettingsPresenterTest.kt new file mode 100644 index 0000000000..a138c42e04 --- /dev/null +++ b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/media/MediaSettingsPresenterTest.kt @@ -0,0 +1,114 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.preferences.impl.media + +import com.google.common.truth.Truth.assertThat +import io.element.android.libraries.featureflag.api.FeatureFlags +import io.element.android.libraries.featureflag.test.FakeFeatureFlagService +import io.element.android.libraries.preferences.api.store.VideoCompressionPreset +import io.element.android.libraries.preferences.test.InMemorySessionPreferencesStore +import io.element.android.tests.testutils.WarmUpRule +import io.element.android.tests.testutils.test +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.test.runTest +import org.junit.Rule +import org.junit.Test + +class MediaSettingsPresenterTest { + @get:Rule + val warmUpRule = WarmUpRule() + + @Test + fun `present - initial state`() = runTest { + createMediaSettingsPresenter().test { + assertThat(awaitItem().mediaOptimizationState).isNull() + // After the initial state, we expect the media optimization state to be set + with(awaitItem()) { + assertThat(mediaOptimizationState).isEqualTo(MediaOptimizationState.AllMedia(isEnabled = true)) + } + } + } + + @Test + fun `present - compress media off on`() = runTest { + createMediaSettingsPresenter().test { + // Skip until the initial data is loaded + skipItems(1) + + with(awaitItem()) { + assertThat((mediaOptimizationState as MediaOptimizationState.AllMedia).isEnabled).isTrue() + eventSink(MediaSettingsEvent.SetCompressMedia(false)) + } + with(awaitItem()) { + assertThat((mediaOptimizationState as MediaOptimizationState.AllMedia).isEnabled).isFalse() + eventSink(MediaSettingsEvent.SetCompressMedia(true)) + } + with(awaitItem()) { + assertThat((mediaOptimizationState as MediaOptimizationState.AllMedia).isEnabled).isTrue() + } + } + } + + @Test + fun `present - split media optimization state when the feature flag is enabled`() = runTest { + createMediaSettingsPresenter( + featureFlagService = FakeFeatureFlagService().apply { + setFeatureEnabled(FeatureFlags.SelectableMediaQuality, true) + } + ).test { + // Skip until the initial data is loaded + skipItems(1) + + with(awaitItem()) { + assertThat((mediaOptimizationState as MediaOptimizationState.Split).compressImages).isTrue() + eventSink(MediaSettingsEvent.SetCompressMedia(false)) + } + with(awaitItem()) { + assertThat((mediaOptimizationState as MediaOptimizationState.Split).compressImages).isFalse() + eventSink(MediaSettingsEvent.SetCompressMedia(true)) + } + with(awaitItem()) { + assertThat((mediaOptimizationState as MediaOptimizationState.Split).compressImages).isTrue() + } + } + } + + @Test + fun `present - video upload quality selector`() = runTest { + createMediaSettingsPresenter( + featureFlagService = FakeFeatureFlagService().apply { + setFeatureEnabled(FeatureFlags.SelectableMediaQuality, true) + } + ).test { + // Skip until the initial data is loaded + skipItems(1) + + with(awaitItem()) { + assertThat((mediaOptimizationState as MediaOptimizationState.Split).videoPreset).isEqualTo(VideoCompressionPreset.STANDARD) + eventSink(MediaSettingsEvent.SetVideoUploadQuality(VideoCompressionPreset.LOW)) + } + with(awaitItem()) { + assertThat((mediaOptimizationState as MediaOptimizationState.Split).videoPreset).isEqualTo(VideoCompressionPreset.LOW) + eventSink(MediaSettingsEvent.SetVideoUploadQuality(VideoCompressionPreset.HIGH)) + } + with(awaitItem()) { + assertThat((mediaOptimizationState as MediaOptimizationState.Split).videoPreset).isEqualTo(VideoCompressionPreset.HIGH) + } + } + } + + private fun CoroutineScope.createMediaSettingsPresenter( + sessionPreferencesStore: InMemorySessionPreferencesStore = InMemorySessionPreferencesStore(), + featureFlagService: FakeFeatureFlagService = FakeFeatureFlagService(), + ) = MediaSettingsPresenter( + sessionPreferencesStore = sessionPreferencesStore, + featureFlagService = featureFlagService, + sessionCoroutineScope = this, + ) +} diff --git a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/media/MediaSettingsViewTest.kt b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/media/MediaSettingsViewTest.kt new file mode 100644 index 0000000000..1ef879d1ca --- /dev/null +++ b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/media/MediaSettingsViewTest.kt @@ -0,0 +1,173 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +@file:OptIn(ExperimentalTestApi::class) + +package io.element.android.features.preferences.impl.media + +import androidx.activity.ComponentActivity +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.ui.test.AndroidComposeUiTest +import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.v2.runAndroidComposeUiTest +import com.google.common.truth.Truth.assertThat +import im.vector.app.features.analytics.plan.Interaction +import io.element.android.features.preferences.impl.R +import io.element.android.libraries.preferences.api.store.VideoCompressionPreset +import io.element.android.libraries.ui.strings.CommonStrings +import io.element.android.services.analytics.api.AnalyticsService +import io.element.android.services.analytics.compose.LocalAnalyticsService +import io.element.android.services.analytics.test.FakeAnalyticsService +import io.element.android.tests.testutils.EnsureNeverCalled +import io.element.android.tests.testutils.EventsRecorder +import io.element.android.tests.testutils.assertNoNodeWithText +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.robolectric.RobolectricTest +import org.junit.Test + +class MediaSettingsViewTest : RobolectricTest() { + @Test + fun `clicking on back invokes the expected callback`() = runAndroidComposeUiTest { + val eventsRecorder = EventsRecorder(expectEvents = false) + ensureCalledOnce { + setMediaSettingsView( + state = aMediaSettingsState( + eventSink = eventsRecorder + ), + onBackClick = it + ) + pressBack() + } + } + + @Test + fun `when the media optimization state is not loaded, nothing is rendered`() = runAndroidComposeUiTest { + val eventsRecorder = EventsRecorder(expectEvents = false) + setMediaSettingsView( + state = aMediaSettingsState( + mediaOptimizationState = null, + eventSink = eventsRecorder, + ), + ) + assertNoNodeWithText(R.string.screen_advanced_settings_media_compression_title) + assertNoNodeWithText(R.string.screen_advanced_settings_optimise_image_upload_quality_title) + } + + @Test + fun `clicking on media to enable compression emits the expected event`() = runAndroidComposeUiTest { + val eventsRecorder = EventsRecorder() + val analyticsService = FakeAnalyticsService() + setMediaSettingsView( + state = aMediaSettingsState( + eventSink = eventsRecorder, + ), + analyticsService = analyticsService + ) + clickOn(R.string.screen_advanced_settings_media_compression_description) + eventsRecorder.assertSingle(MediaSettingsEvent.SetCompressMedia(true)) + assertThat(analyticsService.capturedEvents).isEqualTo( + listOf( + Interaction( + name = Interaction.Name.MobileSettingsOptimizeMediaUploadsEnabled + ) + ) + ) + } + + @Test + fun `clicking on media to disable compression emits the expected event`() = runAndroidComposeUiTest { + val eventsRecorder = EventsRecorder() + val analyticsService = FakeAnalyticsService() + setMediaSettingsView( + state = aMediaSettingsState( + mediaOptimizationState = MediaOptimizationState.AllMedia(isEnabled = true), + eventSink = eventsRecorder, + ), + analyticsService = analyticsService + ) + clickOn(R.string.screen_advanced_settings_media_compression_description) + eventsRecorder.assertSingle(MediaSettingsEvent.SetCompressMedia(false)) + assertThat(analyticsService.capturedEvents).isEqualTo( + listOf( + Interaction( + name = Interaction.Name.MobileSettingsOptimizeMediaUploadsDisabled + ) + ) + ) + } + + @Test + fun `clicking on image upload quality in split mode emits the expected event`() = runAndroidComposeUiTest { + val eventsRecorder = EventsRecorder() + setMediaSettingsView( + state = aMediaSettingsState( + mediaOptimizationState = MediaOptimizationState.Split( + compressImages = false, + videoPreset = VideoCompressionPreset.STANDARD, + ), + eventSink = eventsRecorder, + ), + ) + clickOn(R.string.screen_advanced_settings_optimise_image_upload_quality_title) + eventsRecorder.assertSingle(MediaSettingsEvent.SetCompressMedia(true)) + } + + @Test + fun `selecting a video upload quality emits the expected event`() = runAndroidComposeUiTest { + val eventsRecorder = EventsRecorder() + setMediaSettingsView( + state = aMediaSettingsState( + mediaOptimizationState = MediaOptimizationState.Split( + compressImages = true, + videoPreset = VideoCompressionPreset.STANDARD, + ), + eventSink = eventsRecorder, + ), + ) + clickOn(R.string.screen_advanced_settings_optimise_video_upload_quality_title) + clickOn(R.string.screen_advanced_settings_optimise_video_upload_quality_low, inDialog = true) + clickOn(CommonStrings.action_ok, inDialog = true) + eventsRecorder.assertSingle(MediaSettingsEvent.SetVideoUploadQuality(VideoCompressionPreset.LOW)) + } + + @Test + fun `cancelling the video upload quality dialog emits no event`() = runAndroidComposeUiTest { + val eventsRecorder = EventsRecorder(expectEvents = false) + setMediaSettingsView( + state = aMediaSettingsState( + mediaOptimizationState = MediaOptimizationState.Split( + compressImages = true, + videoPreset = VideoCompressionPreset.STANDARD, + ), + eventSink = eventsRecorder, + ), + ) + clickOn(R.string.screen_advanced_settings_optimise_video_upload_quality_title) + clickOn(R.string.screen_advanced_settings_optimise_video_upload_quality_low, inDialog = true) + clickOn(CommonStrings.action_cancel, inDialog = true) + } +} + +private fun AndroidComposeUiTest.setMediaSettingsView( + state: MediaSettingsState, + analyticsService: AnalyticsService = FakeAnalyticsService(), + onBackClick: () -> Unit = EnsureNeverCalled(), +) { + setContent { + CompositionLocalProvider( + LocalAnalyticsService provides analyticsService, + ) { + MediaSettingsView( + state = state, + onBackClick = onBackClick, + ) + } + } +} diff --git a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/advanced/FakeMediaPreviewConfigStateStore.kt b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/moderation/FakeMediaPreviewConfigStateStore.kt similarity index 97% rename from features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/advanced/FakeMediaPreviewConfigStateStore.kt rename to features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/moderation/FakeMediaPreviewConfigStateStore.kt index b07e8b7a55..b45aaab3d2 100644 --- a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/advanced/FakeMediaPreviewConfigStateStore.kt +++ b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/moderation/FakeMediaPreviewConfigStateStore.kt @@ -6,7 +6,7 @@ * Please see LICENSE files in the repository root for full details. */ -package io.element.android.features.preferences.impl.advanced +package io.element.android.features.preferences.impl.moderation import androidx.compose.runtime.Composable import androidx.compose.runtime.mutableStateOf diff --git a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/advanced/MediaPreviewConfigStateStoreTest.kt b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/moderation/MediaPreviewConfigStateStoreTest.kt similarity index 99% rename from features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/advanced/MediaPreviewConfigStateStoreTest.kt rename to features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/moderation/MediaPreviewConfigStateStoreTest.kt index becd6b1a2f..b15e7c356b 100644 --- a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/advanced/MediaPreviewConfigStateStoreTest.kt +++ b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/moderation/MediaPreviewConfigStateStoreTest.kt @@ -6,7 +6,7 @@ * Please see LICENSE files in the repository root for full details. */ -package io.element.android.features.preferences.impl.advanced +package io.element.android.features.preferences.impl.moderation import app.cash.molecule.RecompositionMode import app.cash.molecule.moleculeFlow diff --git a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/moderation/ModerationAndSafetyPresenterTest.kt b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/moderation/ModerationAndSafetyPresenterTest.kt new file mode 100644 index 0000000000..0517a1b601 --- /dev/null +++ b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/moderation/ModerationAndSafetyPresenterTest.kt @@ -0,0 +1,154 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.preferences.impl.moderation + +import com.google.common.truth.Truth.assertThat +import io.element.android.libraries.architecture.AsyncAction +import io.element.android.libraries.matrix.api.media.MediaPreviewValue +import io.element.android.libraries.matrix.test.A_USER_ID +import io.element.android.libraries.matrix.test.A_USER_ID_2 +import io.element.android.libraries.matrix.test.FakeMatrixClient +import io.element.android.libraries.preferences.test.InMemorySessionPreferencesStore +import io.element.android.tests.testutils.WarmUpRule +import io.element.android.tests.testutils.consumeItemsUntilPredicate +import io.element.android.tests.testutils.test +import kotlinx.collections.immutable.persistentListOf +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.runTest +import org.junit.Rule +import org.junit.Test + +class ModerationAndSafetyPresenterTest { + @get:Rule + val warmUpRule = WarmUpRule() + + @Test + fun `present - initial state`() = runTest { + createModerationAndSafetyPresenter().test { + with(awaitItem()) { + assertThat(isSharePresenceEnabled).isTrue() + assertThat(mediaPreviewConfigState.hideInviteAvatars).isFalse() + assertThat(mediaPreviewConfigState.timelineMediaPreviewValue).isEqualTo(MediaPreviewValue.On) + assertThat(mediaPreviewConfigState.setHideInviteAvatarsAction).isEqualTo(AsyncAction.Uninitialized) + assertThat(mediaPreviewConfigState.setTimelineMediaPreviewAction).isEqualTo(AsyncAction.Uninitialized) + assertThat(numberOfBlockedUsers).isEqualTo(0) + assertThat(showBlockedUsersItem).isFalse() + } + } + } + + @Test + fun `present - number of blocked users`() = runTest { + createModerationAndSafetyPresenter( + matrixClient = FakeMatrixClient( + ignoredUsersFlow = MutableStateFlow(persistentListOf(A_USER_ID, A_USER_ID_2)), + ), + ).test { + val state = consumeItemsUntilPredicate { it.numberOfBlockedUsers == 2 }.last() + assertThat(state.showBlockedUsersItem).isTrue() + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `present - share presence off on`() = runTest { + createModerationAndSafetyPresenter().test { + with(awaitItem()) { + assertThat(isSharePresenceEnabled).isTrue() + eventSink(ModerationAndSafetyEvent.SetSharePresenceEnabled(false)) + } + with(awaitItem()) { + assertThat(isSharePresenceEnabled).isFalse() + eventSink(ModerationAndSafetyEvent.SetSharePresenceEnabled(true)) + } + with(awaitItem()) { + assertThat(isSharePresenceEnabled).isTrue() + } + } + } + + @Test + fun `present - hide invite avatars`() = runTest { + val mediaPreviewStore = FakeMediaPreviewConfigStateStore() + createModerationAndSafetyPresenter(mediaPreviewConfigStateStore = mediaPreviewStore).test { + with(awaitItem()) { + assertThat(mediaPreviewConfigState.hideInviteAvatars).isFalse() + eventSink(ModerationAndSafetyEvent.SetHideInviteAvatars(true)) + } + with(awaitItem()) { + assertThat(mediaPreviewConfigState.hideInviteAvatars).isTrue() + eventSink(ModerationAndSafetyEvent.SetHideInviteAvatars(false)) + } + with(awaitItem()) { + assertThat(mediaPreviewConfigState.hideInviteAvatars).isFalse() + } + } + assertThat(mediaPreviewStore.getSetHideInviteAvatarsEvents()).isEqualTo(listOf(true, false)) + } + + @Test + fun `present - timeline media preview value`() = runTest { + val mediaPreviewStore = FakeMediaPreviewConfigStateStore() + createModerationAndSafetyPresenter(mediaPreviewConfigStateStore = mediaPreviewStore).test { + with(awaitItem()) { + assertThat(mediaPreviewConfigState.timelineMediaPreviewValue).isEqualTo(MediaPreviewValue.On) + eventSink(ModerationAndSafetyEvent.SetTimelineMediaPreviewValue(MediaPreviewValue.Off)) + } + with(awaitItem()) { + assertThat(mediaPreviewConfigState.timelineMediaPreviewValue).isEqualTo(MediaPreviewValue.Off) + eventSink(ModerationAndSafetyEvent.SetTimelineMediaPreviewValue(MediaPreviewValue.Private)) + } + with(awaitItem()) { + assertThat(mediaPreviewConfigState.timelineMediaPreviewValue).isEqualTo(MediaPreviewValue.Private) + } + } + assertThat(mediaPreviewStore.getSetTimelineMediaPreviewValueEvents()).isEqualTo( + listOf(MediaPreviewValue.Off, MediaPreviewValue.Private) + ) + } + + @Test + fun `present - media preview state with custom initial values`() = runTest { + val mediaPreviewStore = FakeMediaPreviewConfigStateStore( + hideInviteAvatarsValue = true, + timelineMediaPreviewValue = MediaPreviewValue.Private + ) + createModerationAndSafetyPresenter(mediaPreviewConfigStateStore = mediaPreviewStore).test { + with(awaitItem()) { + assertThat(mediaPreviewConfigState.hideInviteAvatars).isTrue() + assertThat(mediaPreviewConfigState.timelineMediaPreviewValue).isEqualTo(MediaPreviewValue.Private) + } + } + } + + @Test + fun `present - async actions state`() = runTest { + val mediaPreviewStore = FakeMediaPreviewConfigStateStore( + setHideInviteAvatarsActionValue = AsyncAction.Loading, + setTimelineMediaPreviewActionValue = AsyncAction.Success(Unit) + ) + createModerationAndSafetyPresenter(mediaPreviewConfigStateStore = mediaPreviewStore).test { + with(awaitItem()) { + assertThat(mediaPreviewConfigState.setHideInviteAvatarsAction).isEqualTo(AsyncAction.Loading) + assertThat(mediaPreviewConfigState.setTimelineMediaPreviewAction).isEqualTo(AsyncAction.Success(Unit)) + } + } + } + + private fun CoroutineScope.createModerationAndSafetyPresenter( + matrixClient: FakeMatrixClient = FakeMatrixClient(), + sessionPreferencesStore: InMemorySessionPreferencesStore = InMemorySessionPreferencesStore(), + mediaPreviewConfigStateStore: MediaPreviewConfigStateStore = FakeMediaPreviewConfigStateStore(), + ) = ModerationAndSafetyPresenter( + matrixClient = matrixClient, + sessionPreferencesStore = sessionPreferencesStore, + mediaPreviewConfigStateStore = mediaPreviewConfigStateStore, + sessionCoroutineScope = this, + ) +} diff --git a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/moderation/ModerationAndSafetyViewTest.kt b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/moderation/ModerationAndSafetyViewTest.kt new file mode 100644 index 0000000000..6d72486846 --- /dev/null +++ b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/moderation/ModerationAndSafetyViewTest.kt @@ -0,0 +1,189 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +@file:OptIn(ExperimentalTestApi::class) + +package io.element.android.features.preferences.impl.moderation + +import androidx.activity.ComponentActivity +import androidx.compose.ui.test.AndroidComposeUiTest +import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.hasClickAction +import androidx.compose.ui.test.hasText +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performScrollTo +import androidx.compose.ui.test.v2.runAndroidComposeUiTest +import io.element.android.features.preferences.impl.R +import io.element.android.libraries.architecture.AsyncAction +import io.element.android.libraries.matrix.api.media.MediaPreviewValue +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.robolectric.RobolectricTest +import org.junit.Test +import org.robolectric.annotation.Config + +class ModerationAndSafetyViewTest : RobolectricTest() { + @Test + fun `clicking on back invokes the expected callback`() = runAndroidComposeUiTest { + val eventsRecorder = EventsRecorder(expectEvents = false) + ensureCalledOnce { + setModerationAndSafetyView( + state = aModerationAndSafetyState( + eventSink = eventsRecorder + ), + onBackClick = it + ) + pressBack() + } + } + + @Test + fun `clicking on Share presence emits the expected event`() = runAndroidComposeUiTest { + val eventsRecorder = EventsRecorder() + setModerationAndSafetyView( + state = aModerationAndSafetyState( + eventSink = eventsRecorder, + ), + ) + clickOn(R.string.screen_advanced_settings_share_presence) + eventsRecorder.assertSingle(ModerationAndSafetyEvent.SetSharePresenceEnabled(true)) + } + + @Test + @Config(qualifiers = "h1080dp") + fun `clicking on hide invite avatars emits the expected event`() = runAndroidComposeUiTest { + val eventsRecorder = EventsRecorder() + setModerationAndSafetyView( + state = aModerationAndSafetyState( + eventSink = eventsRecorder, + hideInviteAvatars = false + ), + ) + clickOn(R.string.screen_advanced_settings_hide_invite_avatars_toggle_title) + eventsRecorder.assertSingle(ModerationAndSafetyEvent.SetHideInviteAvatars(true)) + } + + @Test + @Config(qualifiers = "h1080dp") + fun `clicking on timeline media preview always hide emits the expected event`() = runAndroidComposeUiTest { + val eventsRecorder = EventsRecorder() + setModerationAndSafetyView( + state = aModerationAndSafetyState( + eventSink = eventsRecorder, + timelineMediaPreviewValue = MediaPreviewValue.On + ), + ) + clickOn(R.string.screen_advanced_settings_show_media_timeline_always_hide) + eventsRecorder.assertSingle(ModerationAndSafetyEvent.SetTimelineMediaPreviewValue(MediaPreviewValue.Off)) + } + + @Test + @Config(qualifiers = "h1080dp") + fun `clicking on timeline media preview private rooms emits the expected event`() = runAndroidComposeUiTest { + val eventsRecorder = EventsRecorder() + setModerationAndSafetyView( + state = aModerationAndSafetyState( + eventSink = eventsRecorder, + timelineMediaPreviewValue = MediaPreviewValue.On + ), + ) + clickOn(R.string.screen_advanced_settings_show_media_timeline_private_rooms) + eventsRecorder.assertSingle(ModerationAndSafetyEvent.SetTimelineMediaPreviewValue(MediaPreviewValue.Private)) + } + + @Test + @Config(qualifiers = "h1080dp") + fun `clicking on timeline media preview always show emits the expected event`() = runAndroidComposeUiTest { + val eventsRecorder = EventsRecorder() + setModerationAndSafetyView( + state = aModerationAndSafetyState( + eventSink = eventsRecorder, + timelineMediaPreviewValue = MediaPreviewValue.Off + ), + ) + clickOn(R.string.screen_advanced_settings_show_media_timeline_always_show) + eventsRecorder.assertSingle(ModerationAndSafetyEvent.SetTimelineMediaPreviewValue(MediaPreviewValue.On)) + } + + @Test + @Config(qualifiers = "h1080dp") + fun `hide invite avatars toggle is disabled when action is loading`() = runAndroidComposeUiTest { + val eventsRecorder = EventsRecorder(expectEvents = false) + setModerationAndSafetyView( + state = aModerationAndSafetyState( + eventSink = eventsRecorder, + hideInviteAvatars = false, + setHideInviteAvatarsAction = AsyncAction.Loading + ), + ) + // The toggle should be disabled, so clicking should not emit any events + clickOn(R.string.screen_advanced_settings_hide_invite_avatars_toggle_title) + } + + @Test + @Config(qualifiers = "h1080dp") + fun `timeline media preview options are disabled when action is loading`() = runAndroidComposeUiTest { + val eventsRecorder = EventsRecorder(expectEvents = false) + setModerationAndSafetyView( + state = aModerationAndSafetyState( + eventSink = eventsRecorder, + timelineMediaPreviewValue = MediaPreviewValue.On, + setTimelineMediaPreviewAction = AsyncAction.Loading + ), + ) + // The options should be disabled, so clicking should not emit any events + clickOn(R.string.screen_advanced_settings_show_media_timeline_always_hide) + clickOn(R.string.screen_advanced_settings_show_media_timeline_private_rooms) + } + + @Test + fun `click on Blocked users invokes the expected callback`() = runAndroidComposeUiTest { + val eventsRecorder = EventsRecorder(expectEvents = false) + ensureCalledOnce { callback -> + setModerationAndSafetyView( + state = aModerationAndSafetyState( + numberOfBlockedUsers = 1, + eventSink = eventsRecorder, + ), + onOpenBlockedUsers = callback, + ) + val text = activity!!.getString(CommonStrings.common_blocked_users) + onNode(hasText(text) and hasClickAction()).performScrollTo().performClick() + } + } + + @Test + fun `when numberOfBlockedUsers is 0, Blocked users item is not shown`() = runAndroidComposeUiTest { + val eventsRecorder = EventsRecorder(expectEvents = false) + setModerationAndSafetyView( + state = aModerationAndSafetyState( + numberOfBlockedUsers = 0, + eventSink = eventsRecorder, + ), + ) + onNodeWithText(activity!!.getString(CommonStrings.common_blocked_users)).assertDoesNotExist() + } +} + +private fun AndroidComposeUiTest.setModerationAndSafetyView( + state: ModerationAndSafetyState, + onBackClick: () -> Unit = EnsureNeverCalled(), + onOpenBlockedUsers: () -> Unit = EnsureNeverCalled(), +) { + setContent { + ModerationAndSafetyView( + state = state, + onBackClick = onBackClick, + onOpenBlockedUsers = onOpenBlockedUsers, + ) + } +} diff --git a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootPresenterTest.kt b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootPresenterTest.kt index 0bda80fe0c..085b74e323 100644 --- a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootPresenterTest.kt +++ b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootPresenterTest.kt @@ -12,43 +12,38 @@ package io.element.android.features.preferences.impl.root import app.cash.turbine.ReceiveTurbine import com.google.common.truth.Truth.assertThat -import io.element.android.features.enterprise.api.SessionEnterpriseService -import io.element.android.features.enterprise.test.FakeSessionEnterpriseService -import io.element.android.features.logout.api.direct.aDirectLogoutState +import io.element.android.compound.theme.Theme +import io.element.android.features.preferences.impl.account.aPreferencesAccountState import io.element.android.features.preferences.impl.userstatus.aUserStatusState import io.element.android.features.preferences.impl.utils.ShowDeveloperSettingsProvider -import io.element.android.features.rageshake.api.RageshakeFeatureAvailability import io.element.android.libraries.core.meta.BuildType 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.FakeFeature import io.element.android.libraries.featureflag.test.FakeFeatureFlagService -import io.element.android.libraries.indicator.api.IndicatorService -import io.element.android.libraries.indicator.test.FakeIndicatorService -import io.element.android.libraries.matrix.api.oauth.AccountManagementAction import io.element.android.libraries.matrix.api.user.MatrixUser import io.element.android.libraries.matrix.test.AN_AVATAR_URL import io.element.android.libraries.matrix.test.A_SESSION_ID import io.element.android.libraries.matrix.test.A_SESSION_ID_2 -import io.element.android.libraries.matrix.test.A_USER_ID -import io.element.android.libraries.matrix.test.A_USER_ID_2 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.core.aBuildMeta -import io.element.android.libraries.matrix.test.verification.FakeSessionVerificationService +import io.element.android.libraries.preferences.api.store.AppPreferencesStore +import io.element.android.libraries.preferences.test.InMemoryAppPreferencesStore import io.element.android.libraries.sessionstorage.api.SessionStore import io.element.android.libraries.sessionstorage.test.InMemorySessionStore import io.element.android.libraries.sessionstorage.test.aSessionData 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.lambda.lambdaRecorder import io.element.android.tests.testutils.lambda.value import io.element.android.tests.testutils.test -import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.first import kotlinx.coroutines.test.runTest import org.junit.Rule import org.junit.Test @@ -59,19 +54,8 @@ class PreferencesRootPresenterTest { @Test fun `present - initial state`() = runTest { - val accountManagementUrlResult = lambdaRecorder> { action -> - Result.success("$action url") - } - val matrixClient = FakeMatrixClient( - canDeactivateAccountResult = { true }, - accountManagementUrlResult = accountManagementUrlResult, - ) - createPresenter( - matrixClient = matrixClient, - sessionEnterpriseService = FakeSessionEnterpriseService( - tweakMasUrlResult = { "tweaked $it" }, - ), - ).test { + val matrixClient = FakeMatrixClient() + createPreferencesRootPresenter(matrixClient = matrixClient).test { val initialState = awaitItem() assertThat(initialState.myUser).isEqualTo( MatrixUser( @@ -82,107 +66,85 @@ class PreferencesRootPresenterTest { ) assertThat(initialState.version).isEqualTo("A Version") assertThat(initialState.isMultiAccountEnabled).isFalse() + assertThat(initialState.isOtherAccountsSectionExpanded).isTrue() assertThat(initialState.otherSessions).isEmpty() - assertThat(initialState.version).isEqualTo("A Version") + assertThat(initialState.preferencesAccountState).isEqualTo(aPreferencesAccountState()) val loadedState = awaitItem() - assertThat(loadedState.myUser).isEqualTo( - MatrixUser( - userId = matrixClient.sessionId, - displayName = A_USER_NAME, - avatarUrl = AN_AVATAR_URL - ) + assertThat(loadedState.theme).isEqualTo(ThemeOption.System) + assertThat(loadedState.availableThemeOptions).isEqualTo( + listOf(ThemeOption.System, ThemeOption.Light, ThemeOption.Dark).toImmutableList() ) - assertThat(loadedState.showSecureBackup).isFalse() - assertThat(loadedState.showSecureBackupBadge).isFalse() - assertThat(loadedState.accountManagementUrl).isNull() + assertThat(loadedState.userStatusState).isNotNull() assertThat(loadedState.showAnalyticsSettings).isFalse() - assertThat(loadedState.showLinkNewDevice).isFalse() assertThat(loadedState.showDeveloperSettings).isTrue() - assertThat(loadedState.canDeactivateAccount).isTrue() - assertThat(loadedState.canReportBug).isTrue() - assertThat(loadedState.nbOfBlockedUsers).isEqualTo(0) - assertThat(loadedState.directLogoutState).isEqualTo(aDirectLogoutState()) + assertThat(loadedState.showLabsItem).isFalse() assertThat(loadedState.snackbarMessage).isNull() - val finalState = awaitItem() - accountManagementUrlResult.assertions().isCalledOnce() - .with(value(null)) - assertThat(finalState.accountManagementUrl).isEqualTo("tweaked null url") } } @Test - fun `present - cannot report bug`() = runTest { - val matrixClient = FakeMatrixClient( - canDeactivateAccountResult = { true }, - accountManagementUrlResult = { Result.success("") }, - ) - createPresenter( - matrixClient = matrixClient, - rageshakeFeatureAvailability = { flowOf(false) }, + fun `present - user status state is null when the server does not support it`() = runTest { + createPreferencesRootPresenter( + matrixClient = FakeMatrixClient().apply { + isUserStatusSupportedResult = Result.success(false) + }, ).test { - val initialState = awaitItem() - assertThat(initialState.canReportBug).isFalse() + consumeItemsUntilPredicate { it.userStatusState == null } + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `present - change theme`() = runTest { + createPreferencesRootPresenter().test { + // Skip until the initial data is loaded skipItems(1) + + with(awaitItem()) { + assertThat(theme).isEqualTo(ThemeOption.System) + eventSink(PreferencesRootEvent.SetTheme(ThemeOption.Dark)) + } + with(awaitItem()) { + assertThat(theme).isEqualTo(ThemeOption.Dark) + eventSink(PreferencesRootEvent.SetTheme(ThemeOption.Light)) + } + with(awaitItem()) { + assertThat(theme).isEqualTo(ThemeOption.Light) + eventSink(PreferencesRootEvent.SetTheme(ThemeOption.System)) + } + with(awaitItem()) { + assertThat(theme).isEqualTo(ThemeOption.System) + } } } @Test - fun `present - number of blocked users`() = runTest { - val matrixClient = FakeMatrixClient( - canDeactivateAccountResult = { true }, - accountManagementUrlResult = { Result.success("") }, - ignoredUsersFlow = MutableStateFlow(persistentListOf(A_USER_ID, A_USER_ID_2)), - ) - createPresenter( - matrixClient = matrixClient, + fun `present - black theme option shown when feature flag enabled`() = runTest { + createPreferencesRootPresenter( + featureFlagService = FakeFeatureFlagService().apply { + setFeatureEnabled(FeatureFlags.AllowBlackTheme, true) + } ).test { - skipItems(1) - val initialState = awaitItem() - assertThat(initialState.nbOfBlockedUsers).isEqualTo(2) + val state = consumeItemsUntilPredicate { it.availableThemeOptions.contains(ThemeOption.Black) }.last() + assertThat(state.availableThemeOptions).isEqualTo(ThemeOption.entries.toImmutableList()) + cancelAndIgnoreRemainingEvents() } } @Test - fun `present - secure backup badge`() = runTest { - val matrixClient = FakeMatrixClient( - canDeactivateAccountResult = { true }, - accountManagementUrlResult = { Result.success("") }, - ) - val indicatorService = FakeIndicatorService() - createPresenter( - matrixClient = matrixClient, - rageshakeFeatureAvailability = { flowOf(false) }, - indicatorService = indicatorService, - ).test { - skipItems(1) - val initialState = awaitItem() - assertThat(initialState.showSecureBackupBadge).isFalse() - indicatorService.setShowSettingChatBackupIndicator(true) - val finalState = awaitItem() - assertThat(finalState.showSecureBackupBadge).isTrue() + fun `present - stored black theme falls back to dark when feature flag disabled`() = runTest { + val appPreferencesStore = InMemoryAppPreferencesStore().apply { + setTheme(Theme.Black.name) } - } - - @Test - fun `present - can deactivate account is false if the Matrix client say so`() = runTest { - createPresenter( - matrixClient = FakeMatrixClient( - canDeactivateAccountResult = { false }, - accountManagementUrlResult = { Result.success(null) }, - ), - ).test { - val loadedState = awaitFirstItem() - assertThat(loadedState.canDeactivateAccount).isFalse() + createPreferencesRootPresenter(appPreferencesStore = appPreferencesStore).test { + consumeItemsUntilPredicate { it.theme == ThemeOption.Dark } + cancelAndIgnoreRemainingEvents() } } @Test fun `present - developer settings is hidden by default in release builds`() = runTest { - createPresenter( - matrixClient = FakeMatrixClient( - canDeactivateAccountResult = { true }, - accountManagementUrlResult = { Result.success(null) }, - ), + createPreferencesRootPresenter( showDeveloperSettingsProvider = ShowDeveloperSettingsProvider(aBuildMeta(BuildType.RELEASE)) ).test { val loadedState = awaitFirstItem() @@ -192,11 +154,7 @@ class PreferencesRootPresenterTest { @Test fun `present - developer settings can be enabled in release builds`() = runTest { - createPresenter( - matrixClient = FakeMatrixClient( - canDeactivateAccountResult = { true }, - accountManagementUrlResult = { Result.success(null) }, - ), + createPreferencesRootPresenter( showDeveloperSettingsProvider = ShowDeveloperSettingsProvider(aBuildMeta(BuildType.RELEASE)) ).test { val loadedState = awaitFirstItem() @@ -218,13 +176,7 @@ class PreferencesRootPresenterTest { ), setLatestSessionResult = setLatestSessionResult, ) - createPresenter( - matrixClient = FakeMatrixClient( - canDeactivateAccountResult = { true }, - accountManagementUrlResult = { Result.success(null) }, - ), - sessionStore = sessionStore, - ).test { + createPreferencesRootPresenter(sessionStore = sessionStore).test { val loadedState = awaitFirstItem() loadedState.eventSink(PreferencesRootEvent.SwitchToSession(A_SESSION_ID_2)) setLatestSessionResult.assertions().isCalledOnce() @@ -234,7 +186,7 @@ class PreferencesRootPresenterTest { @Test fun `present - labs can be shown if any feature flag is in labs and not finished`() = runTest { - createPresenter( + createPreferencesRootPresenter( featureFlagService = FakeFeatureFlagService( getAvailableFeaturesResult = { _, _ -> listOf( @@ -247,10 +199,6 @@ class PreferencesRootPresenterTest { ) } ), - matrixClient = FakeMatrixClient( - canDeactivateAccountResult = { true }, - accountManagementUrlResult = { Result.success(null) }, - ), ).test { assertThat(awaitItem().showLabsItem).isTrue() cancelAndIgnoreRemainingEvents() @@ -259,16 +207,12 @@ class PreferencesRootPresenterTest { @Test fun `present - labs can't be shown if all feature flags in labs are finished`() = runTest { - createPresenter( + createPreferencesRootPresenter( featureFlagService = FakeFeatureFlagService( getAvailableFeaturesResult = { _, _ -> emptyList() } ), - matrixClient = FakeMatrixClient( - canDeactivateAccountResult = { true }, - accountManagementUrlResult = { Result.success(null) }, - ), ).test { skipItems(1) assertThat(awaitItem().showLabsItem).isFalse() @@ -278,11 +222,8 @@ class PreferencesRootPresenterTest { @Test fun `present - multiple accounts`() = runTest { - createPresenter( - matrixClient = FakeMatrixClient( - sessionId = A_SESSION_ID, - canDeactivateAccountResult = { true }, - ), + createPreferencesRootPresenter( + matrixClient = FakeMatrixClient(sessionId = A_SESSION_ID), featureFlagService = FakeFeatureFlagService( initialState = mapOf(FeatureFlags.MultiAccount.key to true) ), @@ -293,6 +234,7 @@ class PreferencesRootPresenterTest { sessionId = A_SESSION_ID_2.value, userDisplayName = "Bob", userAvatarUrl = "avatarUrl", + userAvatarData = "avatarData", ), ) ) @@ -300,23 +242,41 @@ class PreferencesRootPresenterTest { val state = awaitFirstItem() assertThat(state.isMultiAccountEnabled).isTrue() assertThat(state.otherSessions).hasSize(1) - assertThat(state.otherSessions[0]).isEqualTo(MatrixUser(userId = A_SESSION_ID_2, displayName = "Bob", avatarUrl = "avatarUrl")) + assertThat(state.otherSessions[0]).isEqualTo( + MatrixUser( + userId = A_SESSION_ID_2, + displayName = "Bob", + avatarUrl = "avatarUrl", + avatarThumbnail = "avatarData", + ) + ) } } @Test - fun `present - link new device`() = runTest { - createPresenter( - matrixClient = FakeMatrixClient( - sessionId = A_SESSION_ID, - canDeactivateAccountResult = { true }, - ), - featureFlagService = FakeFeatureFlagService( - initialState = mapOf(FeatureFlags.QrCodeLogin.key to true) - ), + fun `present - other accounts section is collapsed if the preference says so`() = runTest { + createPreferencesRootPresenter( + appPreferencesStore = InMemoryAppPreferencesStore(isOtherAccountsExpanded = false), ).test { - val state = awaitFirstItem() - assertThat(state.showLinkNewDevice).isTrue() + consumeItemsUntilPredicate { !it.isOtherAccountsSectionExpanded } + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `present - toggle other accounts expanded stores the new value`() = runTest { + val appPreferencesStore = InMemoryAppPreferencesStore() + createPreferencesRootPresenter(appPreferencesStore = appPreferencesStore).test { + val initialState = awaitFirstItem() + assertThat(initialState.isOtherAccountsSectionExpanded).isTrue() + initialState.eventSink(PreferencesRootEvent.ToggleOtherAccountsExpanded) + val collapsedState = consumeItemsUntilPredicate { !it.isOtherAccountsSectionExpanded }.last() + assertThat(appPreferencesStore.isOtherAccountsExpandedFlow().first()).isFalse() + // Toggling again must expand the section back + collapsedState.eventSink(PreferencesRootEvent.ToggleOtherAccountsExpanded) + consumeItemsUntilPredicate { it.isOtherAccountsSectionExpanded } + assertThat(appPreferencesStore.isOtherAccountsExpandedFlow().first()).isTrue() + cancelAndIgnoreRemainingEvents() } } @@ -325,28 +285,23 @@ class PreferencesRootPresenterTest { return awaitItem() } - private fun createPresenter( + private fun CoroutineScope.createPreferencesRootPresenter( matrixClient: FakeMatrixClient = FakeMatrixClient(), - sessionVerificationService: FakeSessionVerificationService = FakeSessionVerificationService(), showDeveloperSettingsProvider: ShowDeveloperSettingsProvider = ShowDeveloperSettingsProvider(aBuildMeta(BuildType.DEBUG)), - rageshakeFeatureAvailability: RageshakeFeatureAvailability = RageshakeFeatureAvailability { flowOf(true) }, - indicatorService: IndicatorService = FakeIndicatorService(), featureFlagService: FeatureFlagService = FakeFeatureFlagService(), sessionStore: SessionStore = InMemorySessionStore(), - sessionEnterpriseService: SessionEnterpriseService = FakeSessionEnterpriseService(), + appPreferencesStore: AppPreferencesStore = InMemoryAppPreferencesStore(), ) = PreferencesRootPresenter( matrixClient = matrixClient, - sessionVerificationService = sessionVerificationService, analyticsService = FakeAnalyticsService(), versionFormatter = FakeVersionFormatter(), snackbarDispatcher = SnackbarDispatcher(), - indicatorService = indicatorService, - directLogoutPresenter = { aDirectLogoutState() }, showDeveloperSettingsProvider = showDeveloperSettingsProvider, - rageshakeFeatureAvailability = rageshakeFeatureAvailability, featureFlagService = featureFlagService, sessionStore = sessionStore, - sessionEnterpriseService = sessionEnterpriseService, + appPreferencesStore = appPreferencesStore, userStatusPresenter = { aUserStatusState() }, + preferencesAccountPresenter = { aPreferencesAccountState() }, + sessionCoroutineScope = this, ) } diff --git a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootViewTest.kt b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootViewTest.kt index c6578cb494..3afd6cde9d 100644 --- a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootViewTest.kt +++ b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootViewTest.kt @@ -27,11 +27,14 @@ 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.assertNoNodeWithText +import io.element.android.tests.testutils.assertNodeWithTextIsDisplayed 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 io.element.android.tests.testutils.robolectric.RobolectricTest +import kotlinx.collections.immutable.toImmutableList import org.junit.Test class PreferencesRootViewTest : RobolectricTest() { @@ -50,7 +53,7 @@ class PreferencesRootViewTest : RobolectricTest() { } @Test - fun `click on User profile invokes the expected callback`() = runAndroidComposeUiTest { + fun `click on the user row invokes the expected callback`() = runAndroidComposeUiTest { val eventsRecorder = EventsRecorder(expectEvents = false) val user = aMatrixUser() ensureCalledOnceWithParam(user) { callback -> @@ -59,7 +62,7 @@ class PreferencesRootViewTest : RobolectricTest() { myUser = user, eventSink = eventsRecorder, ), - onOpenUserProfile = callback, + onEditProfileClick = callback, ) onNodeWithText("Alice").performClick() } @@ -71,6 +74,7 @@ class PreferencesRootViewTest : RobolectricTest() { setView( aPreferencesRootState( isMultiAccountEnabled = true, + isOtherAccountsSectionExpanded = true, otherSessions = listOf( aMatrixUser( id = A_USER_ID_2.value, @@ -84,6 +88,69 @@ class PreferencesRootViewTest : RobolectricTest() { eventsRecorder.assertSingle(PreferencesRootEvent.SwitchToSession(A_USER_ID_2)) } + @Test + fun `clicking on Switch accounts sends a ToggleOtherAccountsExpanded`() = runAndroidComposeUiTest { + val eventsRecorder = EventsRecorder() + setView( + aPreferencesRootState( + isMultiAccountEnabled = true, + isOtherAccountsSectionExpanded = false, + otherSessions = listOf( + aMatrixUser( + id = A_USER_ID_2.value, + displayName = "Bob", + ) + ), + eventSink = eventsRecorder, + ), + ) + clickOn(CommonStrings.common_switch_account) + eventsRecorder.assertSingle(PreferencesRootEvent.ToggleOtherAccountsExpanded) + } + + @Test + fun `when the other accounts section is collapsed, other sessions and add account are not shown`() = runAndroidComposeUiTest { + val eventsRecorder = EventsRecorder(expectEvents = false) + setView( + aPreferencesRootState( + isMultiAccountEnabled = true, + isOtherAccountsSectionExpanded = false, + otherSessions = listOf( + aMatrixUser( + id = A_USER_ID_2.value, + displayName = "Bob", + ) + ), + eventSink = eventsRecorder, + ), + ) + assertNodeWithTextIsDisplayed(CommonStrings.common_switch_account) + onNodeWithText("Bob").assertDoesNotExist() + assertNoNodeWithText(CommonStrings.common_add_another_account) + } + + @Test + fun `click on Add account of the expanded section invokes the expected callback`() = runAndroidComposeUiTest { + val eventsRecorder = EventsRecorder(expectEvents = false) + ensureCalledOnce { callback -> + setView( + aPreferencesRootState( + isMultiAccountEnabled = true, + isOtherAccountsSectionExpanded = true, + otherSessions = listOf( + aMatrixUser( + id = A_USER_ID_2.value, + displayName = "Bob", + ) + ), + eventSink = eventsRecorder, + ), + onAddAccountClick = callback, + ) + clickOn(CommonStrings.common_add_another_account) + } + } + @Test fun `click on Add account invokes the expected callback`() = runAndroidComposeUiTest { val eventsRecorder = EventsRecorder(expectEvents = false) @@ -112,84 +179,90 @@ class PreferencesRootViewTest : RobolectricTest() { } @Test - fun `click on Encryption invokes the expected callback`() = runAndroidComposeUiTest { + fun `clicking on another theme emits the expected event`() = runAndroidComposeUiTest { + val eventsRecorder = EventsRecorder() + setView( + aPreferencesRootState( + eventSink = eventsRecorder, + ), + ) + val appearance = activity!!.getString(CommonStrings.common_appearance) + onNode(hasText(appearance) and hasClickAction()).performScrollTo().performClick() + clickOn(R.string.theme_dark) + eventsRecorder.assertSingle(PreferencesRootEvent.SetTheme(ThemeOption.Dark)) + } + + @Test + fun `black theme is shown when available`() = runAndroidComposeUiTest { + val eventsRecorder = EventsRecorder(expectEvents = false) + setView( + aPreferencesRootState( + availableThemeOptions = ThemeOption.entries.toImmutableList(), + eventSink = eventsRecorder, + ), + ) + val appearance = activity!!.getString(CommonStrings.common_appearance) + onNode(hasText(appearance) and hasClickAction()).performScrollTo().performClick() + assertNodeWithTextIsDisplayed(R.string.theme_black) + } + + @Test + fun `black theme is hidden when unavailable`() = runAndroidComposeUiTest { + val eventsRecorder = EventsRecorder(expectEvents = false) + setView( + aPreferencesRootState( + availableThemeOptions = ThemeOption.entries.filterNot { it == ThemeOption.Black }.toImmutableList(), + eventSink = eventsRecorder, + ), + ) + val appearance = activity!!.getString(CommonStrings.common_appearance) + onNode(hasText(appearance) and hasClickAction()).performScrollTo().performClick() + assertNoNodeWithText(R.string.theme_black) + } + + @Test + fun `click on Media upload quality invokes the expected callback`() = runAndroidComposeUiTest { val eventsRecorder = EventsRecorder(expectEvents = false) ensureCalledOnce { callback -> setView( aPreferencesRootState( - showSecureBackup = true, eventSink = eventsRecorder, ), - onSecureBackupClick = callback, + onOpenMediaSettings = callback, ) - clickOn(CommonStrings.common_encryption) + val text = activity!!.getString(CommonStrings.common_media_upload_quality) + onNode(hasText(text) and hasClickAction()).performScrollTo().performClick() } } @Test - fun `when showSecureBackup is false, item is not shown`() = runAndroidComposeUiTest { - val eventsRecorder = EventsRecorder(expectEvents = false) - setView( - aPreferencesRootState( - showSecureBackup = false, - eventSink = eventsRecorder, - ), - ) - onNodeWithText(activity!!.getString(CommonStrings.common_encryption)).assertDoesNotExist() - } - - @Test - fun `click on Manage account invokes the expected callback`() = runAndroidComposeUiTest { - val eventsRecorder = EventsRecorder(expectEvents = false) - ensureCalledOnceWithParam("aUrl") { callback -> - setView( - aPreferencesRootState( - accountManagementUrl = "aUrl", - eventSink = eventsRecorder, - ), - onManageAccountClick = callback, - ) - clickOn(CommonStrings.action_manage_account_and_devices) - } - } - - @Test - fun `when accountManagementUrl is null, item is not shown`() = runAndroidComposeUiTest { - val eventsRecorder = EventsRecorder(expectEvents = false) - setView( - aPreferencesRootState( - accountManagementUrl = null, - eventSink = eventsRecorder, - ), - ) - onNodeWithText(activity!!.getString(CommonStrings.action_manage_account_and_devices)).assertDoesNotExist() - } - - @Test - fun `click on Link new devices invokes the expected callback`() = runAndroidComposeUiTest { + fun `click on Screen lock invokes the expected callback`() = runAndroidComposeUiTest { val eventsRecorder = EventsRecorder(expectEvents = false) ensureCalledOnce { callback -> setView( aPreferencesRootState( - showLinkNewDevice = true, eventSink = eventsRecorder, ), - onLinkNewDeviceClick = callback, + onOpenLockScreenSettings = callback, ) - clickOn(CommonStrings.common_link_new_device) + val text = activity!!.getString(CommonStrings.common_screen_lock) + onNode(hasText(text) and hasClickAction()).performScrollTo().performClick() } } @Test - fun `when showLinkNewDevice is false, item is not shown`() = runAndroidComposeUiTest { + fun `click on Location sharing invokes the expected callback`() = runAndroidComposeUiTest { val eventsRecorder = EventsRecorder(expectEvents = false) - setView( - aPreferencesRootState( - showLinkNewDevice = false, - eventSink = eventsRecorder, - ), - ) - onNodeWithText(activity!!.getString(CommonStrings.common_link_new_device)).assertDoesNotExist() + ensureCalledOnce { callback -> + setView( + aPreferencesRootState( + eventSink = eventsRecorder, + ), + onOpenLocationSettings = callback, + ) + val text = activity!!.getString(CommonStrings.common_location_sharing) + onNode(hasText(text) and hasClickAction()).performScrollTo().performClick() + } } @Test @@ -221,45 +294,31 @@ class PreferencesRootViewTest : RobolectricTest() { } @Test - fun `click on Report a problem invokes the expected callback`() = runAndroidComposeUiTest { + fun `click on Labs invokes the expected callback`() = runAndroidComposeUiTest { val eventsRecorder = EventsRecorder(expectEvents = false) ensureCalledOnce { callback -> setView( aPreferencesRootState( - canReportBug = true, + showLabsItem = true, eventSink = eventsRecorder, ), - onOpenRageShake = callback, + onOpenLabs = callback, ) - val text = activity!!.getString(CommonStrings.common_report_a_problem) + val text = activity!!.getString(R.string.screen_labs_title) onNode(hasText(text) and hasClickAction()).performScrollTo().performClick() } } @Test - fun `when canReportBug is false, item is not shown`() = runAndroidComposeUiTest { + fun `when showLabsItem is false, item is not shown`() = runAndroidComposeUiTest { val eventsRecorder = EventsRecorder(expectEvents = false) setView( aPreferencesRootState( - canReportBug = false, + showLabsItem = false, eventSink = eventsRecorder, ), ) - onNodeWithText(activity!!.getString(CommonStrings.common_report_a_problem)).assertDoesNotExist() - } - - @Test - fun `click on Screen lock invokes the expected callback`() = runAndroidComposeUiTest { - val eventsRecorder = EventsRecorder(expectEvents = false) - ensureCalledOnce { callback -> - setView( - aPreferencesRootState( - eventSink = eventsRecorder, - ), - onOpenLockScreenSettings = callback, - ) - clickOn(CommonStrings.common_screen_lock) - } + onNodeWithText(activity!!.getString(R.string.screen_labs_title)).assertDoesNotExist() } @Test @@ -272,7 +331,8 @@ class PreferencesRootViewTest : RobolectricTest() { ), onOpenAbout = callback, ) - clickOn(CommonStrings.common_about) + val text = activity!!.getString(CommonStrings.common_about) + onNode(hasText(text) and hasClickAction()).performScrollTo().performClick() } } @@ -305,128 +365,15 @@ class PreferencesRootViewTest : RobolectricTest() { } @Test - fun `click on Advanced settings invokes the expected callback`() = runAndroidComposeUiTest { - val eventsRecorder = EventsRecorder(expectEvents = false) - ensureCalledOnce { callback -> - setView( - aPreferencesRootState( - eventSink = eventsRecorder, - ), - onOpenAdvancedSettings = callback, - ) - clickOn(CommonStrings.common_advanced_settings) - } - } - - @Test - fun `click on Labs invokes the expected callback`() = runAndroidComposeUiTest { - val eventsRecorder = EventsRecorder(expectEvents = false) - ensureCalledOnce { callback -> - setView( - aPreferencesRootState( - showLabsItem = true, - eventSink = eventsRecorder, - ), - onOpenLabs = callback, - ) - clickOn(R.string.screen_labs_title) - } - } - - @Test - fun `when showLabsItem is false, item is not shown`() = runAndroidComposeUiTest { + fun `when userStatusState is null, the status section is not shown`() = runAndroidComposeUiTest { val eventsRecorder = EventsRecorder(expectEvents = false) setView( aPreferencesRootState( - showLabsItem = false, + userStatusState = null, eventSink = eventsRecorder, ), ) - onNodeWithText(activity!!.getString(R.string.screen_labs_title)).assertDoesNotExist() - } - - @Test - fun `click on Notification invokes the expected callback`() = runAndroidComposeUiTest { - val eventsRecorder = EventsRecorder(expectEvents = false) - ensureCalledOnce { callback -> - setView( - aPreferencesRootState( - eventSink = eventsRecorder, - ), - onOpenNotificationSettings = callback, - ) - clickOn(R.string.screen_notification_settings_title) - } - } - - @Test - fun `click on Blocked users invokes the expected callback`() = runAndroidComposeUiTest { - val eventsRecorder = EventsRecorder(expectEvents = false) - ensureCalledOnce { callback -> - setView( - aPreferencesRootState( - nbOfBlockedUsers = 1, - eventSink = eventsRecorder, - ), - onOpenBlockedUsers = callback, - ) - clickOn(CommonStrings.common_blocked_users) - } - } - - @Test - fun `when nbOfBlockedUsers is 0, item is not shown`() = runAndroidComposeUiTest { - val eventsRecorder = EventsRecorder(expectEvents = false) - setView( - aPreferencesRootState( - nbOfBlockedUsers = 0, - eventSink = eventsRecorder, - ), - ) - onNodeWithText(activity!!.getString(CommonStrings.common_blocked_users)).assertDoesNotExist() - } - - @Test - fun `click on Remove this device invokes the expected callback`() = runAndroidComposeUiTest { - val eventsRecorder = EventsRecorder(expectEvents = false) - ensureCalledOnce { callback -> - setView( - aPreferencesRootState( - eventSink = eventsRecorder, - ), - onSignOutClick = callback, - ) - val text = activity!!.getString(CommonStrings.action_signout) - onNode(hasText(text) and hasClickAction()).performScrollTo().performClick() - } - } - - @Test - fun `click on Deactivate invokes the expected callback`() = runAndroidComposeUiTest { - val eventsRecorder = EventsRecorder(expectEvents = false) - ensureCalledOnce { callback -> - setView( - aPreferencesRootState( - canDeactivateAccount = true, - eventSink = eventsRecorder, - ), - onDeactivateClick = callback, - ) - val text = activity!!.getString(CommonStrings.action_delete_account) - onNode(hasText(text) and hasClickAction()).performScrollTo().performClick() - } - } - - @Test - fun `when canDeactivateAccount is false, item is not shown`() = runAndroidComposeUiTest { - val eventsRecorder = EventsRecorder(expectEvents = false) - setView( - aPreferencesRootState( - canDeactivateAccount = false, - eventSink = eventsRecorder, - ), - ) - onNodeWithText(activity!!.getString(CommonStrings.action_delete_account)).assertDoesNotExist() + assertNoNodeWithText(R.string.screen_settings_user_status_placeholder) } @Test @@ -448,19 +395,20 @@ private fun AndroidComposeUiTest.setView( state: PreferencesRootState, onBackClick: () -> Unit = EnsureNeverCalled(), onAddAccountClick: () -> Unit = EnsureNeverCalled(), - onSecureBackupClick: () -> Unit = EnsureNeverCalled(), - onManageAccountClick: (url: String) -> Unit = EnsureNeverCalledWithParam(), - onLinkNewDeviceClick: () -> Unit = EnsureNeverCalled(), onOpenAnalytics: () -> Unit = EnsureNeverCalled(), - onOpenRageShake: () -> Unit = EnsureNeverCalled(), onOpenLockScreenSettings: () -> Unit = EnsureNeverCalled(), onOpenAbout: () -> Unit = EnsureNeverCalled(), onOpenDeveloperSettings: () -> Unit = EnsureNeverCalled(), - onOpenAdvancedSettings: () -> Unit = EnsureNeverCalled(), + onOpenMediaSettings: () -> Unit = EnsureNeverCalled(), + onOpenLocationSettings: () -> Unit = EnsureNeverCalled(), onOpenLabs: () -> Unit = EnsureNeverCalled(), + onEditProfileClick: (MatrixUser) -> Unit = EnsureNeverCalledWithParam(), + onSecureBackupClick: () -> Unit = EnsureNeverCalled(), + onManageAccountClick: (url: String) -> Unit = EnsureNeverCalledWithParam(), + onLinkNewDeviceClick: () -> Unit = EnsureNeverCalled(), + onOpenRageShake: () -> Unit = EnsureNeverCalled(), + onModerationAndSafetyClick: () -> Unit = EnsureNeverCalled(), onOpenNotificationSettings: () -> Unit = EnsureNeverCalled(), - onOpenUserProfile: (MatrixUser) -> Unit = EnsureNeverCalledWithParam(), - onOpenBlockedUsers: () -> Unit = EnsureNeverCalled(), onSignOutClick: () -> Unit = EnsureNeverCalled(), onDeactivateClick: () -> Unit = EnsureNeverCalled(), ) { @@ -470,19 +418,20 @@ private fun AndroidComposeUiTest.setView( emojiPickerRenderer = NoOpEmojiPickerRenderer, onBackClick = onBackClick, onAddAccountClick = onAddAccountClick, - onSecureBackupClick = onSecureBackupClick, - onManageAccountClick = onManageAccountClick, - onLinkNewDeviceClick = onLinkNewDeviceClick, onOpenAnalytics = onOpenAnalytics, - onOpenRageShake = onOpenRageShake, onOpenLockScreenSettings = onOpenLockScreenSettings, onOpenAbout = onOpenAbout, onOpenDeveloperSettings = onOpenDeveloperSettings, - onOpenAdvancedSettings = onOpenAdvancedSettings, + onOpenMediaSettings = onOpenMediaSettings, + onOpenLocationSettings = onOpenLocationSettings, onOpenLabs = onOpenLabs, + onEditProfileClick = onEditProfileClick, + onSecureBackupClick = onSecureBackupClick, + onManageAccountClick = onManageAccountClick, + onLinkNewDeviceClick = onLinkNewDeviceClick, + onOpenRageShake = onOpenRageShake, + onModerationAndSafetyClick = onModerationAndSafetyClick, onOpenNotificationSettings = onOpenNotificationSettings, - onOpenUserProfile = onOpenUserProfile, - onOpenBlockedUsers = onOpenBlockedUsers, onSignOutClick = onSignOutClick, onDeactivateClick = onDeactivateClick, ) diff --git a/features/rageshake/impl/src/main/res/values-cs/translations.xml b/features/rageshake/impl/src/main/res/values-cs/translations.xml index 93db5427d5..9b8bbdeb94 100644 --- a/features/rageshake/impl/src/main/res/values-cs/translations.xml +++ b/features/rageshake/impl/src/main/res/values-cs/translations.xml @@ -8,6 +8,8 @@ "Popište chybu…" "Pokud je to možné, prosím, napište popis anglicky." "Popis je příliš krátký, uveďte prosím více podrobností o tom, co se stalo. Děkujeme!" + "Můžete zadat číslo souvisejícího problému na GitHubu, pokud existuje." + "Issue na GitHubu" "Odeslat záznamy o selhání" "Povolit protokoly" "Vaše protokoly jsou příliš velké, proto je nelze zahrnout do této zprávy. Zašlete nám je prosím jiným způsobem." diff --git a/features/rageshake/impl/src/main/res/values-el/translations.xml b/features/rageshake/impl/src/main/res/values-el/translations.xml index 62c794afed..e62de350b0 100644 --- a/features/rageshake/impl/src/main/res/values-el/translations.xml +++ b/features/rageshake/impl/src/main/res/values-el/translations.xml @@ -8,6 +8,8 @@ "Περιέγραψε το πρόβλημα…" "Εάν είναι δυνατόν, γράψε την περιγραφή στα αγγλικά." "Η περιγραφή είναι πολύ σύντομη, δώσε περισσότερες λεπτομέρειες σχετικά με το τί συνέβη. Ευχαριστώ!" + "Μπορείτε να εισαγάγετε τον αριθμό ενός σχετικού προβλήματος GitHub, εάν υπάρχει." + "Ζήτημα GitHub" "Αποστολή αρχείων καταγραφής σφαλμάτων" "Να επιτρέπονται τα αρχεία καταγραφής" "Τα αρχεία καταγραφής σας είναι υπερβολικά μεγάλα και δεν μπορούν να συμπεριληφθούν σε αυτήν την αναφορά. Παρακαλούμε να μας τα στείλετε με άλλο τρόπο." diff --git a/features/reportroom/impl/src/main/res/values-cs/translations.xml b/features/reportroom/impl/src/main/res/values-cs/translations.xml index 809cfb68a5..d4492397d2 100644 --- a/features/reportroom/impl/src/main/res/values-cs/translations.xml +++ b/features/reportroom/impl/src/main/res/values-cs/translations.xml @@ -2,7 +2,7 @@ "Vaše hlášení bylo úspěšně odesláno, ale při pokusu o opuštění místnosti jsme narazili na problém. Zkuste to prosím znovu." "Nelze opustit místnost" - "Nahlaste tuto místnost svému administrátorovi. Pokud jsou zprávy zašifrované, váš administrátor je nebude moci číst." + "Nahlaste tento chat poskytovateli vašeho účtu. Pokud jsou zprávy šifrované, poskytovatel vašeho účtu si je nebude moci přečíst." "Popište důvod…" "Nahlásit místnost" diff --git a/features/reportroom/impl/src/main/res/values-fi/translations.xml b/features/reportroom/impl/src/main/res/values-fi/translations.xml index cf23910f8b..30b06fc047 100644 --- a/features/reportroom/impl/src/main/res/values-fi/translations.xml +++ b/features/reportroom/impl/src/main/res/values-fi/translations.xml @@ -2,7 +2,7 @@ "Ilmoituksesi lähetettiin onnistuneesti, mutta kohtasimme ongelman yrittäessämme poistua huoneesta. Yritä uudelleen." "Huoneesta poistuminen epäonnistui" - "Ilmoita tästä huoneesta palvelimesi ylläpitäjälle. Jos viestit on salattu, ylläpitäjäsi ei voi lukea niitä." + "Ilmoita tästä keskustelusta palveluntarjoajallesi. Jos viestit on salattu, palveluntarjoajasi ei voi lukea niitä." "Kuvaile syytä…" "Ilmoita huoneesta" diff --git a/features/reportroom/impl/src/main/res/values-hu/translations.xml b/features/reportroom/impl/src/main/res/values-hu/translations.xml index 9e2a3899a3..8e37082717 100644 --- a/features/reportroom/impl/src/main/res/values-hu/translations.xml +++ b/features/reportroom/impl/src/main/res/values-hu/translations.xml @@ -2,7 +2,7 @@ "A jelentése sikeresen el lett küldve, de hibát találtunk a szoba elhagyása során. Próbálja újra." "Nem tudja elhagyni a szobát" - "A szoba jelentése az adminisztrátoroknak. Ha az üzenetek titkosítva vannak, akkor az adminisztrátor nem fogja tudni elolvasni őket." + "A csevegést jelentése a fiókszolgáltatódnak. Ha az üzenetek titkosítva vannak, a fiókszolgáltatód nem fogja tudni elolvasni őket." "Írja le az okot…" "Szoba jelentése" diff --git a/features/reportroom/impl/src/main/res/values-ja/translations.xml b/features/reportroom/impl/src/main/res/values-ja/translations.xml index 9718ca432f..d2e2b70104 100644 --- a/features/reportroom/impl/src/main/res/values-ja/translations.xml +++ b/features/reportroom/impl/src/main/res/values-ja/translations.xml @@ -2,7 +2,7 @@ "報告は正常に送信されましたが、ルームの退出中に問題が発生しました。もう一度試してください。" "ルームの退出に失敗" - "管理者にこのルームを報告します。メッセージが暗号化されている場合、管理者は内容を確認することができません。" + "アカウント提供元にこのルームを報告します。メッセージが暗号化されている場合、アカウント提供元はその内容を確認することができません。" "報告の理由を説明してください…" "ルームを通報" diff --git a/features/reportroom/impl/src/main/res/values-zh-rTW/translations.xml b/features/reportroom/impl/src/main/res/values-zh-rTW/translations.xml index 1118a72c97..ede706e856 100644 --- a/features/reportroom/impl/src/main/res/values-zh-rTW/translations.xml +++ b/features/reportroom/impl/src/main/res/values-zh-rTW/translations.xml @@ -2,7 +2,7 @@ "您的回報已成功遞交,但我們嘗試離開聊天室時遇到了問題。請再試一次。" "無法離開聊天室" - "將此聊天室回報給您的管理員。若訊息已加密,您的管理員將無法讀取它們。" + "請向您的帳號服務提供者回報這則聊天紀錄。如果訊息經過加密,您的帳號服務提供者將無法閱讀這些訊息。" "說明回報的原因……" "回報聊天室" diff --git a/features/rolesandpermissions/impl/src/main/res/values-el/translations.xml b/features/rolesandpermissions/impl/src/main/res/values-el/translations.xml index fcc1abe283..4585d1988e 100644 --- a/features/rolesandpermissions/impl/src/main/res/values-el/translations.xml +++ b/features/rolesandpermissions/impl/src/main/res/values-el/translations.xml @@ -6,6 +6,7 @@ "Αφαίρεση μηνυμάτων" "Μέλος" "Πρόσκληση ατόμων" + "Μοιραστείτε τη τοποθεσία σας σε πραγματικό χρόνο" "Διαχείριση χώρου" "Διαχείριση αίθουσών" "Διαχείριση μελών" 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 index dd002814b1..bf339d7592 100644 --- 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 @@ -24,8 +24,8 @@ import androidx.compose.ui.res.stringResource 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.CopiableTextAtom import io.element.android.libraries.designsystem.atomic.atoms.PlaceholderAtom -import io.element.android.libraries.designsystem.atomic.atoms.RoomPreviewAliasAtom import io.element.android.libraries.designsystem.atomic.organisms.RoomPreviewOrganism import io.element.android.libraries.designsystem.atomic.pages.HeaderFooterPage import io.element.android.libraries.designsystem.components.avatar.AvatarSize @@ -121,7 +121,7 @@ private fun RoomAliasResolverContent( PlaceholderAtom(width = AvatarSize.RoomPreviewHeader.dp, height = AvatarSize.RoomPreviewHeader.dp) }, title = { - RoomPreviewAliasAtom(roomAlias.value) + CopiableTextAtom(roomAlias.value) }, subtitle = { if (isLoading) { 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 190e1ae5de..137bd2c8b8 100644 --- a/features/roomdetails/impl/src/main/res/values-cs/translations.xml +++ b/features/roomdetails/impl/src/main/res/values-cs/translations.xml @@ -6,7 +6,7 @@ "Budete potřebovat adresu místnosti, aby byla viditelná v adresáři místností." "Upravit adresu" "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." + "Váš poskytovatel účtu tuto možnost v encrypted chatech nepodporuje, v některých chatech se vám proto nemusí zobrazovat oznámení." "Hlasování" "Správce" "Vykázat lidi" @@ -115,9 +115,9 @@ "Při načítání nastavení oznámení došlo k chybě." "Obnovení výchozího režimu se nezdařilo, zkuste to prosím znovu." "Nastavení režimu se nezdařilo, zkuste to prosím znovu." - "Váš domovský server tuto možnost nepodporuje v šifrovaných místnostech, v této místnosti nebudete dostávat upozornění." + "Váš poskytovatel účtu tuto možnost v encrypted chatech nepodporuje, v tomto chatu tedy nebudete dostávat oznámení." "Všechny zprávy" - "Pouze zmínky a klíčová slova" + "Pouze zmínky a odpovědi" "V této místnosti mě upozornit na" "Správci" "Správci a vlastníci" @@ -150,7 +150,7 @@ Nedoporučujeme povolovat šifrování pro místnosti, které může kdokoli naj "Jakmile je povoleno, šifrování nelze zakázat." "Šifrování" "Povolit koncové šifrování" - "Vstoupit může kdokoli." + "Připojit se může kdokoli, včetně osob mimo vaši organizaci." "Kdokoli" "Vyberte, kteří členové prostorů mohou vstoupit do této místnosti bez pozvánky. %1$s" "Spravovat prostory" 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 351284bf60..caaa615021 100644 --- a/features/roomdetails/impl/src/main/res/values-de/translations.xml +++ b/features/roomdetails/impl/src/main/res/values-de/translations.xml @@ -148,7 +148,7 @@ Wir empfehlen keine Verschlüsselung für Chats zu aktivieren, die jeder finden "Einmal angeschaltet kann die Verschlüsselung nicht mehr deaktiviert werden." "Verschlüsselung" "Ende-zu-Ende-Verschlüsselung aktivieren" - "Alle können beitreten." + "Alle können beitreten, auch Personen außerhalb deiner Organisation." "Jeder" "Wähle aus, welche Spaces ihren Mitgliedern ermöglichen sollen, dieser Gruppe ohne Einladung beitreten zu können. %1$s" "Spaces verwalten" diff --git a/features/roomdetails/impl/src/main/res/values-el/translations.xml b/features/roomdetails/impl/src/main/res/values-el/translations.xml index 8d89308c51..0573590d3d 100644 --- a/features/roomdetails/impl/src/main/res/values-el/translations.xml +++ b/features/roomdetails/impl/src/main/res/values-el/translations.xml @@ -147,7 +147,7 @@ "Μόλις ενεργοποιηθεί, η κρυπτογράφηση δεν μπορεί να απενεργοποιηθεί." "Κρυπτογράφηση" "Ενεργοποίηση κρυπτογράφησης από άκρο σε άκρο" - "Οποιοσδήποτε μπορεί να συμμετάσχει." + "Οποιοσδήποτε μπορεί να συμμετάσχει, συμπεριλαμβανομένων ατόμων εκτός του οργανισμού σας." "Οποιοσδήποτε" "Επιλέξτε ποια μέλη των χώρων μπορούν να συμμετάσχουν σε αυτήν την αίθουσα χωρίς πρόσκληση. %1$s" "Διαχείριση χώρων" diff --git a/features/roomdetails/impl/src/main/res/values-fi/translations.xml b/features/roomdetails/impl/src/main/res/values-fi/translations.xml index b6586d8884..fbc9dbecb4 100644 --- a/features/roomdetails/impl/src/main/res/values-fi/translations.xml +++ b/features/roomdetails/impl/src/main/res/values-fi/translations.xml @@ -6,7 +6,7 @@ "Tarvitset osoitteen, jotta se näkyy julkisessa hakemistossa." "Muokkaa osoitetta" "Ilmoitusasetusten muokkaamisessa tapahtui virhe." - "Kotipalvelimesi ei tue tätä vaihtoehtoa salatuissa huoneissa, joten et ehkä saa ilmoitusta joissakin huoneissa." + "Palveluntarjoajasi ei tue tätä vaihtoehtoa salatuissa huoneissa, joten et ehkä saa ilmoitusta joissakin huoneissa." "Kyselyt" "Ylläpitäjä" "Porttikieltojen antaminen" @@ -112,9 +112,9 @@ "Ilmoitusasetusten lataamisessa tapahtui virhe." "Oletustilan palauttaminen epäonnistui, yritä uudelleen." "Tilan asettaminen epäonnistui, yritä uudelleen." - "Kotipalvelimesi ei tue tätä vaihtoehtoa salatuissa huoneissa, joten et saa ilmoituksia tästä huoneesta." + "Palveluntarjoajasi ei tue tätä vaihtoehtoa salatuissa huoneissa, joten et saa ilmoituksia tästä huoneesta." "Kaikista viesteistä" - "Vain maininnoista ja avainsanoista" + "Vain maininnoista ja vastauksista" "Mykistä" "Ilmoita minulle tässä huoneessa" "Ylläpitäjät" @@ -148,7 +148,7 @@ Emme suosittele salauksen ottamista käyttöön huoneissa, jotka kuka tahansa vo "Kun salaus on kerran otettu käyttöön, sitä ei voi poistaa käytöstä." "Salaus" "Ota päästä päähän -salaus käyttöön" - "Kuka tahansa voi liittyä." + "Kuka tahansa voi liittyä, myös organisaatiosi ulkopuoliset henkilöt." "Kuka tahansa" "Valitse, minkä tilojen jäsenet voivat liittyä tähän huoneeseen ilman kutsua. %1$s" "Hallitse tiloja" diff --git a/features/roomdetails/impl/src/main/res/values-hr/translations.xml b/features/roomdetails/impl/src/main/res/values-hr/translations.xml index f5daf33a24..514ac58a61 100644 --- a/features/roomdetails/impl/src/main/res/values-hr/translations.xml +++ b/features/roomdetails/impl/src/main/res/values-hr/translations.xml @@ -151,7 +151,7 @@ Ne preporučujemo omogućavanje šifriranja za sobe koje svatko može pronaći i "Nakon što se šifriranje omogući, više se neće moći onemogućiti." "Šifriranje" "Omogući sveobuhvatno šifriranje" - "Svatko se može pridružiti." + "Svatko se može pridružiti, uključujući i osobe izvan vaše organizacije." "Bilo tko" "Odaberite iz kojih se prostora članovi mogu pridružiti ovoj sobi bez pozivnice. %1$s" "Upravljaj prostorima" 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 fed765fb45..d5b4ca79d8 100644 --- a/features/roomdetails/impl/src/main/res/values-hu/translations.xml +++ b/features/roomdetails/impl/src/main/res/values-hu/translations.xml @@ -112,9 +112,10 @@ "Hiba történt az értesítési beállítások betöltésekor." "Nem sikerült visszaállítani az alapértelmezett módot, próbálja újra." "Nem sikerült a mód beállítása, próbálja újra." - "A Matrix-kiszolgálója nem támogatja ezt a beállítást a titkosított szobákban, egyes szobákban nem fog értesítéseket kapni." + "A Matrix-kiszolgálója nem támogatja ezt a beállítást a titkosított szobákban, ebből a csevegésből nem fog értesítést kapni." "Összes üzenet" - "Csak említések és kulcsszavak" + "Csak említések és válaszok" + "Némítás" "Ebben a szobában, értesítés ezekről:" "Adminisztrátorok" "Adminisztrátorok és tulajdonosok" @@ -147,7 +148,7 @@ Nem javasoljuk a titkosítás engedélyezését az olyan szobákban, amelyeket b "Engedélyezés után a titkosítás nem tiltható le." "Titkosítás" "Végpontok közötti titkosítás engedélyezése" - "Bárki csatlakozhat." + "Bárki csatlakozhat, beleértve a szervezeten kívülieket is." "Bárki" "Válassza ki, hogy mely terek tagjai csatlakozhatnak ehhez a szobához meghívás nélkül.%1$s" "Terek kezelése" diff --git a/features/roomdetails/impl/src/main/res/values-ja/translations.xml b/features/roomdetails/impl/src/main/res/values-ja/translations.xml index cc8390a1d0..cc31070404 100644 --- a/features/roomdetails/impl/src/main/res/values-ja/translations.xml +++ b/features/roomdetails/impl/src/main/res/values-ja/translations.xml @@ -6,7 +6,7 @@ "公開ディレクトリで自分を見つけられるようにするには、アドレスが必要です。" "アドレスの編集" "通知設定の更新中に問題が発生しました。" - "暗号化されたルームでは、この機能にホームサーバーが対応しないため、一部のルームから通知が届かない可能性があります。" + "暗号化されたルームにおいてこの機能にアカウント提供元が対応しないため、このルームからの一部の通知を受信できない可能性があります。" "投票" "管理者" "ユーザーの追放" @@ -109,7 +109,7 @@ "通知設定の読み込み中に問題が発生しました。" "デフォルトの復元に失敗しました。再試行してください。" "設定に失敗しました。再試行してください。" - "暗号化されたルームでは、この機能にホームサーバーが対応しないため、このルームからの通知を受信できません。" + "暗号化されたルームにおいてこの機能にアカウント提供元が対応しないため、このルームからの通知は受信することができません。" "すべてのメッセージ" "メンションと返信のみ" "ミュート" @@ -145,7 +145,7 @@ "一度有効にすると元に戻すことはできません。" "暗号化" "エンドツーエンド暗号化を有効にする" - "誰でも参加できます" + "あなたの組織外のユーザーを含め誰でも参加できます。" "全員" "招待無しで参加できるユーザーが所属するルームを選択してください。%1$s" "スペースを管理" 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 764b18de76..aa9f244498 100644 --- a/features/roomdetails/impl/src/main/res/values-sv/translations.xml +++ b/features/roomdetails/impl/src/main/res/values-sv/translations.xml @@ -99,6 +99,7 @@ "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" "Administratörer" "Administratörer och ägare" 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 6b8a235741..9094e032a9 100644 --- a/features/roomdetails/impl/src/main/res/values-uk/translations.xml +++ b/features/roomdetails/impl/src/main/res/values-uk/translations.xml @@ -67,6 +67,9 @@ "Профіль" "Запити на приєднання" "Ролі та дозволи" + "Натисніть, щоб говорити" + "Аудіо в кімнаті" + "Відеовиклики" "Назва" "Безпека й приватність" "Безпека" @@ -151,7 +154,7 @@ "Після ввімкнення шифрування неможливо вимкнути." "Шифрування" "Увімкнути наскрізне шифрування" - "Будь-хто може приєднатися." + "Приєднатися може будь-хто, у тому числі й особи, які не належать до вашої організації." "Будь-хто" "Виберіть, учасники яких просторів можуть приєднатися до цієї кімнати без запрошення. %1$s" "Керувати просторами" 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 c466688db1..8cd89a6ee2 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 @@ -6,7 +6,7 @@ "您需要地址才能在公開目錄中顯示。" "編輯地址" "更新通知設定時發生錯誤。" - "您的家伺服器在加密聊天室中不支援此選項,可能無法收到部份聊天室的通知。" + "您的帳號服務提供者不支援加密聊天中的此項功能,因此在某些聊天中您可能不會收到通知。" "所有投票" "管理員" "管理黑名單" @@ -109,7 +109,7 @@ "載入通知設定時發生錯誤。" "無法重設為預設模式,請再試一次。" "無法設定模式,請再試一次。" - "您的家伺服器在加密聊天室中不支援此選項,您將不會收到此聊天室的通知。" + "您的帳號服務提供者不支援加密聊天中的此項功能,因此您將不會在此聊天中收到通知。" "所有訊息" "僅提及與回覆" "靜音" @@ -145,7 +145,7 @@ "一旦啟用就無法停用加密。" "加密" "啟用端到端加密" - "任何人都可以加入。" + "任何人都可以加入,包括貴組織以外的人士。" "任何人" "選擇哪些空間的成員不需要邀請就可以加入此聊天室。%1$s" "管理空間" diff --git a/features/roomdetails/impl/src/main/res/values-zh/translations.xml b/features/roomdetails/impl/src/main/res/values-zh/translations.xml index dd204b4b55..dea8350422 100644 --- a/features/roomdetails/impl/src/main/res/values-zh/translations.xml +++ b/features/roomdetails/impl/src/main/res/values-zh/translations.xml @@ -67,6 +67,9 @@ "个人资料" "申请加入" "角色与权限" + "按下发言" + "房间音频" + "视频通话" "名称" "安全与隐私" "安全" diff --git a/features/roomdetails/impl/src/main/res/values/localazy.xml b/features/roomdetails/impl/src/main/res/values/localazy.xml index b08e2570c0..25473238fd 100644 --- a/features/roomdetails/impl/src/main/res/values/localazy.xml +++ b/features/roomdetails/impl/src/main/res/values/localazy.xml @@ -67,6 +67,9 @@ "Profile" "Requests to join" "Roles & permissions" + "Push to talk" + "Room Audio" + "Video calls" "Name" "Security & privacy" "Security" 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 171900cede..2f43668287 100644 --- a/features/securebackup/impl/src/main/res/values-cs/translations.xml +++ b/features/securebackup/impl/src/main/res/values-cs/translations.xml @@ -20,6 +20,12 @@ "Postupujte podle pokynů k vytvoření nového obnovovacího klíče" "Uložte nový klíč pro obnovení do správce hesel nebo do zašifrované poznámky" "Obnovte šifrování účtu pomocí jiného zařízení" + "Zadejte znovu svůj klíč pro obnovení." + "Potvrďte svůj klíč pro obnovení" + "Vyberte si klíč pro obnovení, který si budete moci zapamatovat." + "Zadejte klíč pro obnovení" + "Pomocí svého klíče pro obnovení můžete potvrdit nová zařízení nebo obnovit šifrované chaty, pokud ztratíte přístup ke všem svým zařízením." + "Vaše záloha je nyní plně nastavena" "Pokračovat v resetování" "Podrobnosti o vašem účtu, kontaktech, preferencích a seznamu chatu budou zachovány" "Ztratíte svou stávající historii zpráv" diff --git a/features/securebackup/impl/src/main/res/values-el/translations.xml b/features/securebackup/impl/src/main/res/values-el/translations.xml index 0f4584271c..96fecb2895 100644 --- a/features/securebackup/impl/src/main/res/values-el/translations.xml +++ b/features/securebackup/impl/src/main/res/values-el/translations.xml @@ -20,6 +20,26 @@ "Ακολούθησε τις οδηγίες για να δημιουργήσεις ένα νέο κλειδί ανάκτησης" "Αποθήκευσε το νέο κλειδί ανάκτησης σε έναν διαχειριστή κωδικών πρόσβασης ή σε κρυπτογραφημένη σημείωση" "Επανάφερε την κρυπτογράφηση για το λογαριασμό σου χρησιμοποιώντας άλλη συσκευή" + "Το κλειδί ανάκτησης που εισαγάγατε δεν ταιριάζει" + "Ολοκλήρωση της ρύθμισης" + "Εισαγάγετε ξανά το κλειδί ανάκτησης." + "Επιβεβαιώστε το κλειδί ανάκτησης" + + "Ελάχιστος αριθμός χαρακτήρων: %1$d. Μην χρησιμοποιείτε τον κωδικό πρόσβασης του λογαριασμού σας" + "Ελάχιστος αριθμός χαρακτήρων: %1$d. Μην χρησιμοποιείτε τον κωδικό πρόσβασης του λογαριασμού σας" + + "Δύναμη" + "Ισχύς φράσης πρόσβασης: %1$s" + "Μέτριο" + "Ισχυρό" + "Πολύ ισχυρό" + "Πολύ αδύναμο" + "Αδύναμο" + "Επιλέξτε ένα κλειδί ανάκτησης που μπορείτε να απομνημονεύσετε." + "Εισαγάγετε ένα κλειδί ανάκτησης" + "Φόρτωση απαιτήσεων κλειδιού ανάκτησης" + "Για να αλλάξετε το κλειδί ανάκτησης, μεταβείτε στις Ρυθμίσεις → Κρυπτογράφηση → Αντίγραφα ασφαλείας" + "Το αντίγραφο ασφαλείας σας έχει πλέον ρυθμιστεί πλήρως" "Συνέχιση επαναφοράς" "Τα στοιχεία του λογαριασμού σου, οι επαφές, οι προτιμήσεις και η λίστα συνομιλιών θα διατηρηθούν" "Θα χάσεις το υπάρχον ιστορικό μηνυμάτων σου" diff --git a/features/securityandprivacy/impl/src/main/res/values-cs/translations.xml b/features/securityandprivacy/impl/src/main/res/values-cs/translations.xml index d0d747f086..e5a42c26a5 100644 --- a/features/securityandprivacy/impl/src/main/res/values-cs/translations.xml +++ b/features/securityandprivacy/impl/src/main/res/values-cs/translations.xml @@ -20,7 +20,7 @@ Nedoporučujeme povolovat šifrování pro místnosti, které může kdokoli naj "Jakmile je povoleno, šifrování nelze zakázat." "Šifrování" "Povolit koncové šifrování" - "Vstoupit může kdokoli." + "Připojit se může kdokoli, včetně osob mimo vaši organizaci." "Kdokoli" "Vyberte, kteří členové prostorů mohou vstoupit do této místnosti bez pozvánky. %1$s" "Spravovat prostory" diff --git a/features/securityandprivacy/impl/src/main/res/values-de/translations.xml b/features/securityandprivacy/impl/src/main/res/values-de/translations.xml index 64da81c2a6..62844ba5a2 100644 --- a/features/securityandprivacy/impl/src/main/res/values-de/translations.xml +++ b/features/securityandprivacy/impl/src/main/res/values-de/translations.xml @@ -20,7 +20,7 @@ Wir empfehlen keine Verschlüsselung für Chats zu aktivieren, die jeder finden "Einmal angeschaltet kann die Verschlüsselung nicht mehr deaktiviert werden." "Verschlüsselung" "Ende-zu-Ende-Verschlüsselung aktivieren" - "Alle können beitreten." + "Alle können beitreten, auch Personen außerhalb deiner Organisation." "Jeder" "Wähle aus, welche Spaces ihren Mitgliedern ermöglichen sollen, dieser Gruppe ohne Einladung beitreten zu können. %1$s" "Spaces verwalten" diff --git a/features/securityandprivacy/impl/src/main/res/values-el/translations.xml b/features/securityandprivacy/impl/src/main/res/values-el/translations.xml index dfbf4e075c..ab031c9f1d 100644 --- a/features/securityandprivacy/impl/src/main/res/values-el/translations.xml +++ b/features/securityandprivacy/impl/src/main/res/values-el/translations.xml @@ -20,7 +20,7 @@ "Μόλις ενεργοποιηθεί, η κρυπτογράφηση δεν μπορεί να απενεργοποιηθεί." "Κρυπτογράφηση" "Ενεργοποίηση κρυπτογράφησης από άκρο σε άκρο" - "Οποιοσδήποτε μπορεί να συμμετάσχει." + "Οποιοσδήποτε μπορεί να συμμετάσχει, συμπεριλαμβανομένων ατόμων εκτός του οργανισμού σας." "Οποιοσδήποτε" "Επιλέξτε ποια μέλη των χώρων μπορούν να συμμετάσχουν σε αυτήν την αίθουσα χωρίς πρόσκληση. %1$s" "Διαχείριση χώρων" diff --git a/features/securityandprivacy/impl/src/main/res/values-fi/translations.xml b/features/securityandprivacy/impl/src/main/res/values-fi/translations.xml index fcf8a90ee3..cf60e50b07 100644 --- a/features/securityandprivacy/impl/src/main/res/values-fi/translations.xml +++ b/features/securityandprivacy/impl/src/main/res/values-fi/translations.xml @@ -20,7 +20,7 @@ Emme suosittele salauksen ottamista käyttöön huoneissa, jotka kuka tahansa vo "Kun salaus on kerran otettu käyttöön, sitä ei voi poistaa käytöstä." "Salaus" "Ota päästä päähän -salaus käyttöön" - "Kuka tahansa voi liittyä." + "Kuka tahansa voi liittyä, myös organisaatiosi ulkopuoliset henkilöt." "Kuka tahansa" "Valitse, minkä tilojen jäsenet voivat liittyä tähän huoneeseen ilman kutsua. %1$s" "Hallitse tiloja" diff --git a/features/securityandprivacy/impl/src/main/res/values-hr/translations.xml b/features/securityandprivacy/impl/src/main/res/values-hr/translations.xml index 4038a7a412..075d5a7325 100644 --- a/features/securityandprivacy/impl/src/main/res/values-hr/translations.xml +++ b/features/securityandprivacy/impl/src/main/res/values-hr/translations.xml @@ -20,7 +20,7 @@ Ne preporučujemo omogućavanje šifriranja za sobe koje svatko može pronaći i "Nakon što se šifriranje omogući, više se neće moći onemogućiti." "Šifriranje" "Omogući sveobuhvatno šifriranje" - "Svatko se može pridružiti." + "Svatko se može pridružiti, uključujući i osobe izvan vaše organizacije." "Bilo tko" "Odaberite iz kojih se prostora članovi mogu pridružiti ovoj sobi bez pozivnice. %1$s" "Upravljaj prostorima" diff --git a/features/securityandprivacy/impl/src/main/res/values-hu/translations.xml b/features/securityandprivacy/impl/src/main/res/values-hu/translations.xml index 60e2190e84..9f3cd08a6a 100644 --- a/features/securityandprivacy/impl/src/main/res/values-hu/translations.xml +++ b/features/securityandprivacy/impl/src/main/res/values-hu/translations.xml @@ -20,7 +20,7 @@ Nem javasoljuk a titkosítás engedélyezését az olyan szobákban, amelyeket b "Engedélyezés után a titkosítás nem tiltható le." "Titkosítás" "Végpontok közötti titkosítás engedélyezése" - "Bárki csatlakozhat." + "Bárki csatlakozhat, beleértve a szervezeten kívülieket is." "Bárki" "Válassza ki, hogy mely terek tagjai csatlakozhatnak ehhez a szobához meghívás nélkül.%1$s" "Terek kezelése" diff --git a/features/securityandprivacy/impl/src/main/res/values-ja/translations.xml b/features/securityandprivacy/impl/src/main/res/values-ja/translations.xml index 110d19a325..5b0f93c22a 100644 --- a/features/securityandprivacy/impl/src/main/res/values-ja/translations.xml +++ b/features/securityandprivacy/impl/src/main/res/values-ja/translations.xml @@ -20,7 +20,7 @@ "一度有効にすると元に戻すことはできません。" "暗号化" "エンドツーエンド暗号化を有効にする" - "誰でも参加できます" + "あなたの組織外のユーザーを含め誰でも参加できます。" "全員" "招待無しで参加できるユーザーが所属するルームを選択してください。%1$s" "スペースを管理" diff --git a/features/securityandprivacy/impl/src/main/res/values-uk/translations.xml b/features/securityandprivacy/impl/src/main/res/values-uk/translations.xml index 0921887ccb..8a6f3c3367 100644 --- a/features/securityandprivacy/impl/src/main/res/values-uk/translations.xml +++ b/features/securityandprivacy/impl/src/main/res/values-uk/translations.xml @@ -20,7 +20,7 @@ "Після ввімкнення шифрування неможливо вимкнути." "Шифрування" "Увімкнути наскрізне шифрування" - "Будь-хто може приєднатися." + "Приєднатися може будь-хто, у тому числі й особи, які не належать до вашої організації." "Будь-хто" "Виберіть, учасники яких просторів можуть приєднатися до цієї кімнати без запрошення. %1$s" "Керувати просторами" diff --git a/features/securityandprivacy/impl/src/main/res/values-zh-rTW/translations.xml b/features/securityandprivacy/impl/src/main/res/values-zh-rTW/translations.xml index 5239585201..3cd2b8f274 100644 --- a/features/securityandprivacy/impl/src/main/res/values-zh-rTW/translations.xml +++ b/features/securityandprivacy/impl/src/main/res/values-zh-rTW/translations.xml @@ -20,7 +20,7 @@ "一旦啟用就無法停用加密。" "加密" "啟用端到端加密" - "任何人都可以加入。" + "任何人都可以加入,包括貴組織以外的人士。" "任何人" "選擇哪些空間的成員不需要邀請就可以加入此聊天室。%1$s" "管理空間" diff --git a/features/signedout/impl/src/main/kotlin/io/element/android/features/signedout/impl/SignedOutStatePreviewParam.kt b/features/signedout/impl/src/main/kotlin/io/element/android/features/signedout/impl/SignedOutStatePreviewParam.kt index ae60b7aacb..e0396cba97 100644 --- a/features/signedout/impl/src/main/kotlin/io/element/android/features/signedout/impl/SignedOutStatePreviewParam.kt +++ b/features/signedout/impl/src/main/kotlin/io/element/android/features/signedout/impl/SignedOutStatePreviewParam.kt @@ -47,5 +47,6 @@ private fun aSessionData( lastUsageIndex = 0, userDisplayName = null, userAvatarUrl = null, + userAvatarData = null, ) } diff --git a/features/space/impl/src/main/res/values-ja/translations.xml b/features/space/impl/src/main/res/values-ja/translations.xml index e00d045038..1851438b68 100644 --- a/features/space/impl/src/main/res/values-ja/translations.xml +++ b/features/space/impl/src/main/res/values-ja/translations.xml @@ -16,7 +16,7 @@ "ルームの追加はルームへのアクセスに影響しません。アクセスの設定は、ルームの設定 > セキュリティーとプライバシー から変更できます。" "最初のルームを追加しましょう" "メンバーを表示" - "ルームの削除はルームへのアクセスに影響しません。アクセスの設定は、ルームの設定 > セキュリティーとプライバシー から変更できます。" + "ルームを削除によって、ルームへのアクセスは変更されません。アクセスの設定は、ルームの設定 > セキュリティーとプライバシー から変更できます。" "%2$s から%1$d 個のルームを削除" diff --git a/features/space/impl/src/main/res/values-zh-rTW/translations.xml b/features/space/impl/src/main/res/values-zh-rTW/translations.xml index 623f30923e..66e41bdb07 100644 --- a/features/space/impl/src/main/res/values-zh-rTW/translations.xml +++ b/features/space/impl/src/main/res/values-zh-rTW/translations.xml @@ -16,7 +16,7 @@ "新增聊天室不會影響聊天室存取權。要變更存取權,請前往「聊天室設定」→「安全性與隱私權」" "新增您的第一個聊天室" "檢視成員" - "移除聊天室不會影響聊天室存取權。要變更存取權,請前往「聊天室資訊」→「隱私權與安全性」。" + "移除聊天室不會影響聊天室存取權。要變更存取權,請前往「聊天室資訊」→「安全性與隱私權」。" "從 %2$s 移除 %1$d 個聊天室" diff --git a/features/startchat/impl/src/main/res/values-cs/translations.xml b/features/startchat/impl/src/main/res/values-cs/translations.xml index b89f27fb9e..82b7399c41 100644 --- a/features/startchat/impl/src/main/res/values-cs/translations.xml +++ b/features/startchat/impl/src/main/res/values-cs/translations.xml @@ -5,7 +5,7 @@ "Při pokusu o zahájení chatu došlo k chybě" "Vstoupit do místnosti pomocí adresy" "Neplatná adresa" - "Zadejte…" + "Zadejte adresu…" "Odpovídající místnost nalezena" "Místnost nebyla nalezena" "např. #room-name:matrix.org" diff --git a/features/verifysession/impl/src/main/res/values-de/translations.xml b/features/verifysession/impl/src/main/res/values-de/translations.xml index 2e46202dd4..51d132dc8a 100644 --- a/features/verifysession/impl/src/main/res/values-de/translations.xml +++ b/features/verifysession/impl/src/main/res/values-de/translations.xml @@ -38,6 +38,8 @@ "Verifizierung angefordert" "Sie stimmen nicht überein" "Sie stimmen überein" + "Bitte verwende ein anderes Gerät oder deinen Wiederherstellungsschlüssel." + "Verifizierung mit diesem Gerät ist derzeit nicht möglich" "Öffne die App auf dem anderen Gerät, bevor du die Verifizierung auf diesem Gerät startest." "Öffne die App auf einem anderen verifizierten Gerät" "Verifiziere diesen Nutzer für zusätzliche Sicherheit durch den Vergleich einer Reihe von Emojis auf den Geräten. Verwende dazu einen vertraulichen Kommunikationskanal." diff --git a/features/verifysession/impl/src/main/res/values-fi/translations.xml b/features/verifysession/impl/src/main/res/values-fi/translations.xml index cca016ac3a..b234072190 100644 --- a/features/verifysession/impl/src/main/res/values-fi/translations.xml +++ b/features/verifysession/impl/src/main/res/values-fi/translations.xml @@ -38,6 +38,8 @@ "Vahvistus pyydetty" "Ne eivät täsmää" "Ne täsmäävät" + "Käytä toista laitetta tai palautusavainta." + "Vahvistus tällä laitteella ei ole tällä hetkellä käytettävissä" "Varmista, että sovellus on avoinna toisessa laitteessa, ennen kuin aloitat vahvistuksen tästä." "Avaa sovellus toisella vahvistetulla laitteella" "Vahvista tämä käyttäjä turvallisuuden lisäämiseksi vertaamalla emojeja laitteillanne. Tee tämä käyttämällä luotettavaa viestintätapaa." diff --git a/features/verifysession/impl/src/main/res/values-hr/translations.xml b/features/verifysession/impl/src/main/res/values-hr/translations.xml index 91fecd1744..7b92ada6d6 100644 --- a/features/verifysession/impl/src/main/res/values-hr/translations.xml +++ b/features/verifysession/impl/src/main/res/values-hr/translations.xml @@ -38,6 +38,8 @@ "Zatražena je provjera" "Ne podudaraju se" "Podudaraju se" + "Molimo koristite drugi uređaj ili ključ za oporavak." + "Verifikacija s ovim uređajem trenutno nije dostupna" "Prije nego što odavde započnete provjeru, provjerite jeste li otvorili aplikaciju na drugom uređaju." "Otvorite aplikaciju na drugom potvrđenom uređaju" "Radi dodatne sigurnosti potvrdite ovog korisnika tako da usporedite skup emotikona na svojim uređajima. Učinite to koristeći se pouzdanim načinom komunikacije." diff --git a/features/verifysession/impl/src/main/res/values-hu/translations.xml b/features/verifysession/impl/src/main/res/values-hu/translations.xml index 666ff26af1..3eedb6730b 100644 --- a/features/verifysession/impl/src/main/res/values-hu/translations.xml +++ b/features/verifysession/impl/src/main/res/values-hu/translations.xml @@ -38,6 +38,8 @@ "Ellenőrzés kérve" "Nem egyeznek" "Megegyeznek" + "Használjon másik eszközt vagy helyreállítási kulcsot." + "Jelenleg nem lehetséges az azonosítás ezzel az eszközzel" "Győződjön meg róla, hogy az alkalmazás nyitva van a másik eszközön, mielőtt innen elindítja az ellenőrzést." "Nyissa meg az alkalmazást egy másik ellenőrzött eszközön" "A fokozott biztonság érdekében ellenőrizze ezt a felhasználót az eszközökön megjelenő emodzsik összehasonlításával. Ehhez használjon megbízható kommunikációs csatornát." diff --git a/features/verifysession/impl/src/main/res/values-ja/translations.xml b/features/verifysession/impl/src/main/res/values-ja/translations.xml index 72360fda21..3fd95df4cb 100644 --- a/features/verifysession/impl/src/main/res/values-ja/translations.xml +++ b/features/verifysession/impl/src/main/res/values-ja/translations.xml @@ -38,6 +38,8 @@ "検証をリクエスト済み" "一致しません" "一致します" + "他の端末または回復鍵を使用してください。" + "現在この端末で検証をすることはできません" "検証を開始する前に、他の端末でアプリケーションを開いてください。" "検証済みの他の端末でアプリケーションを開いてください" "安全性を高めるために、絵文字の組み合わせを使用してこのユーザーを検証してください。これにより安全にやり取りを行うことができるようになります。" diff --git a/features/verifysession/impl/src/main/res/values-uk/translations.xml b/features/verifysession/impl/src/main/res/values-uk/translations.xml index a81fdd3395..803e57fa39 100644 --- a/features/verifysession/impl/src/main/res/values-uk/translations.xml +++ b/features/verifysession/impl/src/main/res/values-uk/translations.xml @@ -38,6 +38,8 @@ "Запит на звіряння" "Вони не збігаються" "Вони збігаються" + "Скористайтеся іншим пристроєм або ключем відновлення." + "Звірення за допомогою цього пристрою наразі недоступне" "Перш ніж розпочати звіряння звідси, переконайтеся, що ви відкрили застосунок на іншому пристрої." "Відкрийте застосунок на іншому звіреному пристрої" "Для додаткової безпеки звірте цього користувача, порівнявши набір емоджі на ваших пристроях. Зробіть це, використовуючи надійний спосіб спілкування." 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 5472fba56e..d16e1aefe4 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 @@ -38,6 +38,8 @@ "已請求驗證" "不一樣" "一樣" + "請使用其他裝置或復原金鑰。" + "目前無法使用此裝置進行驗證" "從這裡開始驗證之前,請確保您在其他裝置中開啟了應用程式。" "在另外一個已驗證的裝置上開啟應用程式" "為了提昇安全性,請透過比較您裝置上的一組表情符號來驗證此使用者。請透過可信的通訊方式來執行此動作。" diff --git a/gradle.properties b/gradle.properties index 4323d20107..26ebbabf4d 100644 --- a/gradle.properties +++ b/gradle.properties @@ -39,6 +39,7 @@ org.gradle.configuration-cache.parallel=true kotlin.incremental=true # Dummy values for signing secrets / nightly +signing.element.nightly.storeFile= signing.element.nightly.storePassword=Secret signing.element.nightly.keyId=Secret signing.element.nightly.keyPassword=Secret @@ -59,10 +60,6 @@ ksp.allow.all.target.configuration=false # Used to prevent detekt from reusing invalid cached rules detekt.use.worker.api=true -# Let test include roborazzi verification -# https://github.com/takahirom/roborazzi?tab=readme-ov-file#roborazzitest -roborazzi.test.verify=true - # Needed after enabling proguard on AGP 8.13.1 android.r8.gradual.support=true diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 4d7bacc7f6..297662551d 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -17,12 +17,12 @@ constraintlayout = "2.2.2" constraintlayout_compose = "1.1.2" lifecycle = "2.11.0" activity = "1.13.0" -media3 = "1.11.0" +media3 = "1.11.1" camera = "1.6.2" work = "2.11.2" # Compose -compose_bom = "2026.08.00" +compose_bom = "2026.09.00" # Coroutines coroutines = "1.11.0" @@ -32,28 +32,26 @@ accompanist = "0.37.3" # Test test_core = "1.7.0" -roborazzi = "1.74.0" # Jetbrain datetime = "0.8.0" serialization_json = "1.11.0" #other -coil = "3.6.2" +coil = "3.6.3" # There is some custom logic in `RootFlowNode` that may break because it reuses some Appyx internal APIs. # When upgrading this version, check state restoration still works fine. appyx = "1.7.1" sqldelight = "2.3.2" wysiwyg = "2.42.1" telephoto = "0.19.0" -haze = "1.7.2" maplibre-compose = "0.17.0" # Dependency analysis -dependencyAnalysis = "3.19.1" +dependencyAnalysis = "3.19.2" # DI -metro = "1.4.2" +metro = "1.4.4" # Auto service autoservice = "1.1.1" @@ -168,7 +166,7 @@ test_arch_core = "androidx.arch.core:core-testing:2.2.0" test_junit = "junit:junit:4.13.2" test_runner = "androidx.test:runner:1.7.0" test_mockk = "io.mockk:mockk:1.14.11" -test_konsist = "com.lemonappdev:konsist:0.17.3" +test_konsist = "com.github.jmartinesp:konsist:0.18.0" test_turbine = "app.cash.turbine:turbine:1.2.1" test_truth = "com.google.truth:truth:1.4.5" test_parameter_injector = "com.google.testparameterinjector:test-parameter-injector:1.22" @@ -184,7 +182,7 @@ test_detekt_test = { module = "io.gitlab.arturbosch.detekt:detekt-test", version # https://github.com/matrix-org/matrix-rust-components-kotlin/commits/main/sdk/sdk-android/src/main/kotlin/org/matrix/rustcomponents/sdk/matrix_sdk_ffi.kt # All new features should not be implemented in the pull request that upgrades the version, developers should # only fix API breaks and may add some TODOs. -matrix_sdk = "org.matrix.rustcomponents:sdk-android:26.09.9" +matrix_sdk = "org.matrix.rustcomponents:sdk-android:26.09.26" # Others coil = { module = "io.coil-kt.coil3:coil", version.ref = "coil" } @@ -205,8 +203,8 @@ matrix_richtexteditor_compose = { module = "io.element.android:wysiwyg-compose", sqldelight-driver-android = { module = "app.cash.sqldelight:android-driver", version.ref = "sqldelight" } sqldelight-driver-jvm = { module = "app.cash.sqldelight:sqlite-driver", version.ref = "sqldelight" } sqldelight-coroutines = { module = "app.cash.sqldelight:coroutines-extensions", version.ref = "sqldelight" } -sqlcipher = "net.zetetic:sqlcipher-android:4.18.0" -sqlite = "androidx.sqlite:sqlite-ktx:2.7.0" +sqlcipher = "net.zetetic:sqlcipher-android:4.19.0" +sqlite = "androidx.sqlite:sqlite-ktx:2.7.1" unifiedpush = "org.unifiedpush.android:connector:3.3.5" vanniktech_blurhash = "com.vanniktech:blurhash:0.3.0" telephoto_zoomableimage = { module = "me.saket.telephoto:zoomable-image-coil3", version.ref = "telephoto" } @@ -220,8 +218,6 @@ zxing_cpp = "io.github.zxing-cpp:android:3.1.1" google_zxing = "com.google.zxing:core:3.5.4" google_guava = "com.google.guava:guava:33.7.1-android" -haze = { module = "dev.chrisbanes.haze:haze", version.ref = "haze" } -haze_materials = { module = "dev.chrisbanes.haze:haze-materials", version.ref = "haze" } color_picker = "io.mhssn:colorpicker:1.0.0" # Analytics @@ -247,11 +243,6 @@ google_autoservice_annotations = { module = "com.google.auto.service:auto-servic # Miscellaneous androidx-test-ext-junit = { group = "androidx.test.ext", name = "junit", version.ref = "androidx-test-ext-junit" } -# Test -test_roborazzi = { module = "io.github.takahirom.roborazzi:roborazzi", version.ref = "roborazzi" } -test_roborazzi_compose = { module = "io.github.takahirom.roborazzi:roborazzi-compose", version.ref = "roborazzi" } -test_roborazzi_junit = { module = "io.github.takahirom.roborazzi:roborazzi-junit-rule", version.ref = "roborazzi" } - [bundles] [plugins] @@ -269,7 +260,6 @@ dependencygraph = "com.savvasdalkitsis.module-dependency-graph:0.12" dependencycheck = "org.owasp.dependencycheck:13.0.0" dependencyanalysis = { id = "com.autonomousapps.dependency-analysis", version.ref = "dependencyAnalysis" } paparazzi = "app.cash.paparazzi:2.0.0-alpha05" -roborazzi = { id = "io.github.takahirom.roborazzi", version.ref = "roborazzi" } sqldelight = { id = "app.cash.sqldelight", version.ref = "sqldelight" } firebaseAppDistribution = { id = "com.google.firebase.appdistribution", version.ref = "firebaseAppDistribution" } sonarqube = "org.sonarqube:7.5.0.8588" diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar index eddabd2eef..5097068a8d 100644 Binary files a/gradle/wrapper/gradle-wrapper.jar and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 760c45969c..1eca32e7b4 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,7 +1,7 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionSha256Sum=acd53f1edaf02f1a8ff99879f8a34b302661a057d9b063ae9e35b552f804d20a -distributionUrl=https\://services.gradle.org/distributions/gradle-9.7.1-bin.zip +distributionSha256Sum=bafd5ce9cfaea0fbccfdc8439a1ac42fbd4cd9c89dc9a988228d8a2639a58e6c +distributionUrl=https\://services.gradle.org/distributions/gradle-9.8.0-bin.zip networkTimeout=10000 retries=0 retryBackOffMs=500 diff --git a/gradlew.bat b/gradlew.bat index 8508ef684d..03754c4c3d 100644 --- a/gradlew.bat +++ b/gradlew.bat @@ -26,6 +26,33 @@ @rem Set local scope for the variables, and ensure extensions are enabled setlocal EnableExtensions +@rem Catch executions from older scripts and ensure they exit cleanly. +@rem This can be removed once we can be reasonably confident that few people +@rem will be migrating directly to this new wrapper. +goto afterSafetyNet +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +goto exitWithErrorLevel +:afterSafetyNet + set DIRNAME=%~dp0 if "%DIRNAME%"=="" set DIRNAME=. @rem This is normally unused @@ -45,13 +72,14 @@ set JAVA_EXE=java.exe %JAVA_EXE% -version >NUL 2>&1 if %ERRORLEVEL% equ 0 goto execute -echo. 1>&2 -echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 -echo. 1>&2 -echo Please set the JAVA_HOME variable in your environment to match the 1>&2 -echo location of your Java installation. 1>&2 +1>&2 echo. +1>&2 echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +1>&2 echo. +1>&2 echo Please set the JAVA_HOME variable in your environment to match the +1>&2 echo location of your Java installation. "%COMSPEC%" /c exit 1 +goto exitWithErrorLevel :findJavaFromJavaHome set JAVA_HOME=%JAVA_HOME:"=% @@ -59,13 +87,14 @@ set JAVA_EXE=%JAVA_HOME%/bin/java.exe if exist "%JAVA_EXE%" goto execute -echo. 1>&2 -echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 -echo. 1>&2 -echo Please set the JAVA_HOME variable in your environment to match the 1>&2 -echo location of your Java installation. 1>&2 +1>&2 echo. +1>&2 echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +1>&2 echo. +1>&2 echo Please set the JAVA_HOME variable in your environment to match the +1>&2 echo location of your Java installation. "%COMSPEC%" /c exit 1 +goto exitWithErrorLevel :execute @rem Setup the command line @@ -75,8 +104,9 @@ echo location of your Java installation. 1>&2 @rem Execute gradlew @rem endlocal doesn't take effect until after the line is parsed and variables are expanded @rem which allows us to clear the local environment before executing the java command -endlocal & "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* & call :exitWithErrorLevel +endlocal & "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* & call :exitWithErrorLevel & goto exitWithErrorLevel +@rem This label must not be changed. We rely on old scripts being able to jump to this point. :exitWithErrorLevel @rem Use "%COMSPEC%" /c exit to allow operators to work properly in scripts "%COMSPEC%" /c exit %ERRORLEVEL% diff --git a/libraries/accountselect/impl/src/main/kotlin/io/element/android/libraries/accountselect/impl/AccountSelectPresenter.kt b/libraries/accountselect/impl/src/main/kotlin/io/element/android/libraries/accountselect/impl/AccountSelectPresenter.kt index be6a8bea6f..e1d6b2800e 100644 --- a/libraries/accountselect/impl/src/main/kotlin/io/element/android/libraries/accountselect/impl/AccountSelectPresenter.kt +++ b/libraries/accountselect/impl/src/main/kotlin/io/element/android/libraries/accountselect/impl/AccountSelectPresenter.kt @@ -34,6 +34,7 @@ class AccountSelectPresenter( userId = UserId(it.userId), displayName = it.userDisplayName, avatarUrl = it.userAvatarUrl, + avatarThumbnail = it.userAvatarData, ) } .toImmutableList() diff --git a/libraries/accountselect/impl/src/test/kotlin/io/element/android/libraries/accountselect/impl/AccountSelectPresenterTest.kt b/libraries/accountselect/impl/src/test/kotlin/io/element/android/libraries/accountselect/impl/AccountSelectPresenterTest.kt index 8b0f581f6d..f9b9aeebcd 100644 --- a/libraries/accountselect/impl/src/test/kotlin/io/element/android/libraries/accountselect/impl/AccountSelectPresenterTest.kt +++ b/libraries/accountselect/impl/src/test/kotlin/io/element/android/libraries/accountselect/impl/AccountSelectPresenterTest.kt @@ -44,6 +44,7 @@ class AccountSelectPresenterTest { sessionId = A_SESSION_ID_2.value, userDisplayName = "Bob", userAvatarUrl = "avatarUrl", + userAvatarData = "avatarData", ), ) ) @@ -66,6 +67,7 @@ class AccountSelectPresenterTest { userId = A_SESSION_ID_2, displayName = "Bob", avatarUrl = "avatarUrl", + avatarThumbnail = "avatarData", ) ) } diff --git a/libraries/compound/build.gradle.kts b/libraries/compound/build.gradle.kts index 04d55081ee..7fe065eb54 100644 --- a/libraries/compound/build.gradle.kts +++ b/libraries/compound/build.gradle.kts @@ -10,7 +10,7 @@ import extension.testCommonDependencies plugins { id("io.element.android-compose-library") - alias(libs.plugins.roborazzi) + alias(libs.plugins.paparazzi) } android { @@ -21,9 +21,30 @@ android { } } +// Paparazzi's layoutlib runtime conflicts with Robolectric, so during Paparazzi record/verify runs +// only execute the screenshot tests (which use Paparazzi) and leave the Robolectric-based unit tests +// out. On plain unit test runs the screenshot package is excluded in the root build.gradle.kts. +tasks.withType(Test::class).configureEach { + val isScreenshotTest = gradle.startParameter.taskNames.any { it.contains("paparazzi", ignoreCase = true) } + if (isScreenshotTest) { + include("**/screenshot/**") + } +} + dependencies { testCommonDependencies(libs) - testImplementation(libs.test.roborazzi) - testImplementation(libs.test.roborazzi.compose) - testImplementation(libs.test.roborazzi.junit) + + // Paparazzi 1.3.2 workaround (see https://github.com/cashapp/paparazzi/blob/master/CHANGELOG.md#132---2024-01-13) + constraints.add("testImplementation", "com.google.guava:guava") { + attributes { + attribute( + TargetJvmEnvironment.TARGET_JVM_ENVIRONMENT_ATTRIBUTE, + objects.named(TargetJvmEnvironment::class.java, TargetJvmEnvironment.STANDARD_JVM) + ) + } + because( + "LayoutLib and sdk-common depend on Guava's -jre published variant." + + "See https://github.com/cashapp/paparazzi/issues/906." + ) + } } diff --git a/libraries/compound/screenshots/Avatar Colors - Dark.png b/libraries/compound/screenshots/Avatar Colors - Dark.png deleted file mode 100644 index 5cf6cbda89..0000000000 --- a/libraries/compound/screenshots/Avatar Colors - Dark.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:caf2de32caf0fa5368da899297e2eabd5a0c6891dd94f81295ef8a933d79ce16 -size 10751 diff --git a/libraries/compound/screenshots/Avatar Colors - Light.png b/libraries/compound/screenshots/Avatar Colors - Light.png deleted file mode 100644 index 7069762082..0000000000 --- a/libraries/compound/screenshots/Avatar Colors - Light.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:80e44e94d7b23af2ec4fd1c5a871851ae2567b40e478b30145de199076f20e95 -size 11296 diff --git a/libraries/compound/screenshots/Compound Icons - Dark.png b/libraries/compound/screenshots/Compound Icons - Dark.png deleted file mode 100644 index 6d5057eaac..0000000000 --- a/libraries/compound/screenshots/Compound Icons - Dark.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:78386a58e91be3213485f60909ec8d83cd535aabcec775aadf7b36e77d8542f0 -size 245853 diff --git a/libraries/compound/screenshots/Compound Icons - Light.png b/libraries/compound/screenshots/Compound Icons - Light.png deleted file mode 100644 index f4f66317c3..0000000000 --- a/libraries/compound/screenshots/Compound Icons - Light.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:477ab18ffa1ca81ca5e4c9f30f4466b19e14fcaddf641fc860c6be19a0e33d4f -size 259185 diff --git a/libraries/compound/screenshots/Compound Icons - Rtl.png b/libraries/compound/screenshots/Compound Icons - Rtl.png deleted file mode 100644 index d0374a65fb..0000000000 --- a/libraries/compound/screenshots/Compound Icons - Rtl.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:7edeebf9876aecd262fceb7d55a4bfd30a859a69b708af8dd4538ffc0f9be419 -size 260400 diff --git a/libraries/compound/screenshots/Compound Semantic Colors - Dark HC.png b/libraries/compound/screenshots/Compound Semantic Colors - Dark HC.png deleted file mode 100644 index c70e44cacd..0000000000 --- a/libraries/compound/screenshots/Compound Semantic Colors - Dark HC.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:a9334d37f010d4e520b11dbd16d664fbb4413497d371dc8b0af0157faf870451 -size 323086 diff --git a/libraries/compound/screenshots/Compound Semantic Colors - Dark.png b/libraries/compound/screenshots/Compound Semantic Colors - Dark.png deleted file mode 100644 index 45be3e910d..0000000000 --- a/libraries/compound/screenshots/Compound Semantic Colors - Dark.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:9e016ef5e07de6f6e86e5e6104d78502f5ee15ecb39d1533f020cf94ac087603 -size 320821 diff --git a/libraries/compound/screenshots/Compound Semantic Colors - Light HC.png b/libraries/compound/screenshots/Compound Semantic Colors - Light HC.png deleted file mode 100644 index bfcd41eb92..0000000000 --- a/libraries/compound/screenshots/Compound Semantic Colors - Light HC.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:93df69ddd7a1571abcb868495edb9914b5d832c1e55f1520a1c04a71de59577f -size 302213 diff --git a/libraries/compound/screenshots/Compound Semantic Colors - Light.png b/libraries/compound/screenshots/Compound Semantic Colors - Light.png deleted file mode 100644 index 4d5e171d7d..0000000000 --- a/libraries/compound/screenshots/Compound Semantic Colors - Light.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:b1eb3a0283e42d2e2d1083c95fd2bbd2e338fcc5f318c07386f04cfb97e6fed7 -size 301963 diff --git a/libraries/compound/screenshots/Compound Typography.png b/libraries/compound/screenshots/Compound Typography.png deleted file mode 100644 index 22e2d92ebb..0000000000 --- a/libraries/compound/screenshots/Compound Typography.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:32b12d0b26cd016a632a4cb87b71d5efcb2c0d816bf565bc90aee9963ce2d5df -size 134117 diff --git a/libraries/compound/screenshots/Compound Vector Icons - Dark.png b/libraries/compound/screenshots/Compound Vector Icons - Dark.png deleted file mode 100644 index 0d8e07d9fa..0000000000 --- a/libraries/compound/screenshots/Compound Vector Icons - Dark.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:cc6cc2d2e89c7460217c2075046e41bef727407ee422dcdea23f161f48441904 -size 97346 diff --git a/libraries/compound/screenshots/Compound Vector Icons - Light.png b/libraries/compound/screenshots/Compound Vector Icons - Light.png deleted file mode 100644 index 74b4c859c7..0000000000 --- a/libraries/compound/screenshots/Compound Vector Icons - Light.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:4df16aee4a3a8b10c33e9724904c72b8dc502725204d2f86cf62cc381b35f4a1 -size 103870 diff --git a/libraries/compound/screenshots/ForcedDarkElementTheme.png b/libraries/compound/screenshots/ForcedDarkElementTheme.png deleted file mode 100644 index d7182aa47c..0000000000 --- a/libraries/compound/screenshots/ForcedDarkElementTheme.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:03bdb2f3de01d40b8f85ba87b395cdab2e5225aded2f40a0892077798bca6066 -size 22328 diff --git a/libraries/compound/screenshots/Legacy Colors.png b/libraries/compound/screenshots/Legacy Colors.png deleted file mode 100644 index 2ecdda2ed7..0000000000 --- a/libraries/compound/screenshots/Legacy Colors.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:3e9d676a1ef20a7228e985f62d346265ff9c31d1860a219540f012c063c9345e -size 33652 diff --git a/libraries/compound/screenshots/Material Typography.png b/libraries/compound/screenshots/Material Typography.png deleted file mode 100644 index 6c22c364b9..0000000000 --- a/libraries/compound/screenshots/Material Typography.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:d27f813acc9e8dc3960f5205809c8c0d2ba0fa51fd9e3ca07964b866b125e87d -size 110171 diff --git a/libraries/compound/screenshots/Material3 Colors - Dark HC.png b/libraries/compound/screenshots/Material3 Colors - Dark HC.png deleted file mode 100644 index 4cc7609c89..0000000000 --- a/libraries/compound/screenshots/Material3 Colors - Dark HC.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:85969829577e158bdd1d0f21c8b3a2334dcde79cb50d5e2331d06d5423332be2 -size 160754 diff --git a/libraries/compound/screenshots/Material3 Colors - Dark.png b/libraries/compound/screenshots/Material3 Colors - Dark.png deleted file mode 100644 index 3738cf6a79..0000000000 --- a/libraries/compound/screenshots/Material3 Colors - Dark.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:ca64da1dc373dc49503ee525ae3331e73d0ab12053993a1ef19dcab1e67b08c4 -size 159123 diff --git a/libraries/compound/screenshots/Material3 Colors - Light HC.png b/libraries/compound/screenshots/Material3 Colors - Light HC.png deleted file mode 100644 index 8d64857d13..0000000000 --- a/libraries/compound/screenshots/Material3 Colors - Light HC.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:01622bca20a132ec5a874fc1a2d0ffd45e7ce6d7849c4d607d79c7bc51d6c6a9 -size 163322 diff --git a/libraries/compound/screenshots/Material3 Colors - Light.png b/libraries/compound/screenshots/Material3 Colors - Light.png deleted file mode 100644 index a1d5d1f2ce..0000000000 --- a/libraries/compound/screenshots/Material3 Colors - Light.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:87c0c4ff42d17137d554708ce33f40f214ec608eca4ca87af0b2adab63de6bb7 -size 162891 diff --git a/libraries/compound/screenshots/MaterialText Colors.png b/libraries/compound/screenshots/MaterialText Colors.png deleted file mode 100644 index 1273aee01e..0000000000 --- a/libraries/compound/screenshots/MaterialText Colors.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:1f1a277e76d351f48ae0041e082525422604fbf41d77fe078112349855dd3d2e -size 453512 diff --git a/libraries/compound/screenshots/MaterialYou Theme - Dark.png b/libraries/compound/screenshots/MaterialYou Theme - Dark.png deleted file mode 100644 index 8c5bc9d1ef..0000000000 --- a/libraries/compound/screenshots/MaterialYou Theme - Dark.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:c166e5371bb1922a9c016438a3cdfd0d68197237969d53a04f92baf6d53c4ac0 -size 164925 diff --git a/libraries/compound/screenshots/MaterialYou Theme - Light.png b/libraries/compound/screenshots/MaterialYou Theme - Light.png deleted file mode 100644 index 70cccac69b..0000000000 --- a/libraries/compound/screenshots/MaterialYou Theme - Light.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:d936948cbad69d6935f2d2738d33682a55f044dfb1af8b5c9b8323c5f4318971 -size 163558 diff --git a/libraries/compound/src/main/kotlin/io/element/android/compound/previews/SemanticColorsPreview.kt b/libraries/compound/src/main/kotlin/io/element/android/compound/previews/SemanticColorsPreview.kt index 6b1e77676c..8d43591194 100644 --- a/libraries/compound/src/main/kotlin/io/element/android/compound/previews/SemanticColorsPreview.kt +++ b/libraries/compound/src/main/kotlin/io/element/android/compound/previews/SemanticColorsPreview.kt @@ -11,6 +11,7 @@ package io.element.android.compound.previews import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -27,7 +28,7 @@ import kotlinx.collections.immutable.persistentMapOf @Preview(heightDp = 2000) @Composable internal fun CompoundSemanticColorsLight() = ElementTheme { - Surface { + Surface(Modifier.width(300.dp)) { Column( modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(10.dp) @@ -48,7 +49,7 @@ internal fun CompoundSemanticColorsLight() = ElementTheme { internal fun CompoundSemanticColorsLightHc() = ElementTheme( compoundDark = compoundColorsHcDark, ) { - Surface { + Surface(Modifier.width(300.dp)) { Column( modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(10.dp) @@ -67,7 +68,7 @@ internal fun CompoundSemanticColorsLightHc() = ElementTheme( @Preview(heightDp = 2000) @Composable internal fun CompoundSemanticColorsDark() = ElementTheme(theme = Theme.Dark) { - Surface { + Surface(Modifier.width(300.dp)) { Column( modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(10.dp) @@ -89,7 +90,7 @@ internal fun CompoundSemanticColorsDarkHc() = ElementTheme( theme = Theme.Dark, compoundDark = compoundColorsHcDark, ) { - Surface { + Surface(Modifier.width(300.dp)) { Column( modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(10.dp) diff --git a/libraries/compound/src/test/kotlin/io/element/android/compound/screenshot/AvatarColorsTest.kt b/libraries/compound/src/test/kotlin/io/element/android/compound/screenshot/AvatarColorsTest.kt index 895247f12f..0603ffe76d 100644 --- a/libraries/compound/src/test/kotlin/io/element/android/compound/screenshot/AvatarColorsTest.kt +++ b/libraries/compound/src/test/kotlin/io/element/android/compound/screenshot/AvatarColorsTest.kt @@ -8,24 +8,22 @@ package io.element.android.compound.screenshot -import com.github.takahirom.roborazzi.captureRoboImage -import io.element.android.compound.screenshot.utils.screenshotFile +import io.element.android.compound.screenshot.utils.createPaparazziRule import io.element.android.compound.theme.AvatarColorsPreviewDark import io.element.android.compound.theme.AvatarColorsPreviewLight -import io.element.android.tests.testutils.robolectric.RobolectricTest +import org.junit.Rule import org.junit.Test -import org.robolectric.annotation.Config -import org.robolectric.annotation.GraphicsMode -@GraphicsMode(GraphicsMode.Mode.NATIVE) -class AvatarColorsTest : RobolectricTest() { +class AvatarColorsTest { + @get:Rule + val paparazzi = createPaparazziRule() + @Test - @Config(sdk = [35], qualifiers = "xxhdpi") fun screenshots() { - captureRoboImage(file = screenshotFile("Avatar Colors - Light.png")) { + paparazzi.snapshot(name = "Avatar Colors - Light") { AvatarColorsPreviewLight() } - captureRoboImage(file = screenshotFile("Avatar Colors - Dark.png")) { + paparazzi.snapshot(name = "Avatar Colors - Dark") { AvatarColorsPreviewDark() } } diff --git a/libraries/compound/src/test/kotlin/io/element/android/compound/screenshot/CompoundIconTest.kt b/libraries/compound/src/test/kotlin/io/element/android/compound/screenshot/CompoundIconTest.kt index 97926fc291..16648476af 100644 --- a/libraries/compound/src/test/kotlin/io/element/android/compound/screenshot/CompoundIconTest.kt +++ b/libraries/compound/src/test/kotlin/io/element/android/compound/screenshot/CompoundIconTest.kt @@ -11,36 +11,34 @@ package io.element.android.compound.screenshot import androidx.compose.foundation.layout.ColumnScope import androidx.compose.material3.Icon import androidx.compose.runtime.Composable -import com.github.takahirom.roborazzi.captureRoboImage import io.element.android.compound.previews.IconsCompoundPreviewDark import io.element.android.compound.previews.IconsCompoundPreviewLight import io.element.android.compound.previews.IconsCompoundPreviewRtl import io.element.android.compound.previews.IconsPreview -import io.element.android.compound.screenshot.utils.screenshotFile +import io.element.android.compound.screenshot.utils.createPaparazziRule import io.element.android.compound.theme.ElementTheme import io.element.android.compound.theme.Theme import io.element.android.compound.tokens.generated.CompoundIcons -import io.element.android.tests.testutils.robolectric.RobolectricTest import kotlinx.collections.immutable.toImmutableList +import org.junit.Rule import org.junit.Test -import org.robolectric.annotation.Config -import org.robolectric.annotation.GraphicsMode -@GraphicsMode(GraphicsMode.Mode.NATIVE) -class CompoundIconTest : RobolectricTest() { +class CompoundIconTest { + @get:Rule + val paparazzi = createPaparazziRule() + @Test - @Config(sdk = [35], qualifiers = "w1024dp-h2048dp") fun screenshots() { - captureRoboImage(file = screenshotFile("Compound Icons - Light.png")) { + paparazzi.snapshot(name = "Compound Icons - Light") { IconsCompoundPreviewLight() } - captureRoboImage(file = screenshotFile("Compound Icons - Rtl.png")) { + paparazzi.snapshot(name = "Compound Icons - Rtl") { IconsCompoundPreviewRtl() } - captureRoboImage(file = screenshotFile("Compound Icons - Dark.png")) { + paparazzi.snapshot(name = "Compound Icons - Dark") { IconsCompoundPreviewDark() } - captureRoboImage(file = screenshotFile("Compound Vector Icons - Light.png")) { + paparazzi.snapshot(name = "Compound Vector Icons - Light") { val content: List<@Composable ColumnScope.() -> Unit> = CompoundIcons.all.map { @Composable { Icon(imageVector = it, contentDescription = null) } } @@ -51,7 +49,7 @@ class CompoundIconTest : RobolectricTest() { ) } } - captureRoboImage(file = screenshotFile("Compound Vector Icons - Dark.png")) { + paparazzi.snapshot(name = "Compound Vector Icons - Dark") { val content: List<@Composable ColumnScope.() -> Unit> = CompoundIcons.all.map { @Composable { Icon(imageVector = it, contentDescription = null) } } diff --git a/libraries/compound/src/test/kotlin/io/element/android/compound/screenshot/CompoundTypographyTest.kt b/libraries/compound/src/test/kotlin/io/element/android/compound/screenshot/CompoundTypographyTest.kt index ec27acd836..2825c1ce5c 100644 --- a/libraries/compound/src/test/kotlin/io/element/android/compound/screenshot/CompoundTypographyTest.kt +++ b/libraries/compound/src/test/kotlin/io/element/android/compound/screenshot/CompoundTypographyTest.kt @@ -8,20 +8,18 @@ package io.element.android.compound.screenshot -import com.github.takahirom.roborazzi.captureRoboImage import io.element.android.compound.previews.CompoundTypographyPreview -import io.element.android.compound.screenshot.utils.screenshotFile -import io.element.android.tests.testutils.robolectric.RobolectricTest +import io.element.android.compound.screenshot.utils.createPaparazziRule +import org.junit.Rule import org.junit.Test -import org.robolectric.annotation.Config -import org.robolectric.annotation.GraphicsMode -@GraphicsMode(GraphicsMode.Mode.NATIVE) -class CompoundTypographyTest : RobolectricTest() { +class CompoundTypographyTest { + @get:Rule + val paparazzi = createPaparazziRule() + @Test - @Config(sdk = [35], qualifiers = "h2048dp-xxhdpi") fun screenshots() { - captureRoboImage(file = screenshotFile("Compound Typography.png")) { + paparazzi.snapshot(name = "Compound Typography") { CompoundTypographyPreview() } } diff --git a/libraries/compound/src/test/kotlin/io/element/android/compound/screenshot/ForcedDarkElementThemeTest.kt b/libraries/compound/src/test/kotlin/io/element/android/compound/screenshot/ForcedDarkElementThemeTest.kt index 5f858b8622..c19c8ef8e9 100644 --- a/libraries/compound/src/test/kotlin/io/element/android/compound/screenshot/ForcedDarkElementThemeTest.kt +++ b/libraries/compound/src/test/kotlin/io/element/android/compound/screenshot/ForcedDarkElementThemeTest.kt @@ -18,22 +18,21 @@ import androidx.compose.material3.Text import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp -import com.github.takahirom.roborazzi.captureRoboImage import io.element.android.compound.colors.SemanticColorsLightDark -import io.element.android.compound.screenshot.utils.screenshotFile +import io.element.android.compound.screenshot.utils.createPaparazziRule import io.element.android.compound.theme.ElementTheme import io.element.android.compound.theme.ForcedDarkElementTheme -import io.element.android.tests.testutils.robolectric.RobolectricTest +import org.junit.Rule import org.junit.Test -import org.robolectric.annotation.Config -import org.robolectric.annotation.GraphicsMode -@GraphicsMode(GraphicsMode.Mode.NATIVE) -class ForcedDarkElementThemeTest : RobolectricTest() { +class ForcedDarkElementThemeTest { + // The preview uses `fillMaxSize` to fill the inner Surface, so it needs a bounded frame. + @get:Rule + val paparazzi = createPaparazziRule(widthDp = 420, heightDp = 640) + @Test - @Config(sdk = [35], qualifiers = "xxhdpi") fun screenshots() { - captureRoboImage(file = screenshotFile("ForcedDarkElementTheme.png")) { + paparazzi.snapshot(name = "ForcedDarkElementTheme") { ElementTheme { Surface { Column( diff --git a/libraries/compound/src/test/kotlin/io/element/android/compound/screenshot/LegacyColorsTest.kt b/libraries/compound/src/test/kotlin/io/element/android/compound/screenshot/LegacyColorsTest.kt index c0c3d11555..b269aaa146 100644 --- a/libraries/compound/src/test/kotlin/io/element/android/compound/screenshot/LegacyColorsTest.kt +++ b/libraries/compound/src/test/kotlin/io/element/android/compound/screenshot/LegacyColorsTest.kt @@ -12,32 +12,31 @@ import androidx.compose.foundation.layout.Column 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.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.dp -import com.github.takahirom.roborazzi.captureRoboImage import io.element.android.compound.previews.ColorPreview -import io.element.android.compound.screenshot.utils.screenshotFile +import io.element.android.compound.screenshot.utils.createPaparazziRule import io.element.android.compound.theme.ElementTheme import io.element.android.compound.theme.LinkColor import io.element.android.compound.theme.SnackBarLabelColorDark import io.element.android.compound.theme.SnackBarLabelColorLight -import io.element.android.tests.testutils.robolectric.RobolectricTest +import org.junit.Rule import org.junit.Test -import org.robolectric.annotation.Config -import org.robolectric.annotation.GraphicsMode -@GraphicsMode(GraphicsMode.Mode.NATIVE) -class LegacyColorsTest : RobolectricTest() { +class LegacyColorsTest { + @get:Rule + val paparazzi = createPaparazziRule() + @Test - @Config(sdk = [35], qualifiers = "xxhdpi") fun screenshots() { - captureRoboImage(file = screenshotFile("Legacy Colors.png")) { + paparazzi.snapshot(name = "Legacy Colors") { ElementTheme { - Surface { + Surface(Modifier.width(300.dp)) { Column(modifier = Modifier.padding(16.dp)) { Text(text = "Legacy Colors") Spacer(modifier = Modifier.height(10.dp)) diff --git a/libraries/compound/src/test/kotlin/io/element/android/compound/screenshot/MaterialColorSchemeTest.kt b/libraries/compound/src/test/kotlin/io/element/android/compound/screenshot/MaterialColorSchemeTest.kt index 2e1368ff54..cf934f9baf 100644 --- a/libraries/compound/src/test/kotlin/io/element/android/compound/screenshot/MaterialColorSchemeTest.kt +++ b/libraries/compound/src/test/kotlin/io/element/android/compound/screenshot/MaterialColorSchemeTest.kt @@ -12,32 +12,31 @@ import androidx.compose.foundation.layout.Column 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.material3.Surface import androidx.compose.material3.Text import androidx.compose.ui.Modifier import androidx.compose.ui.text.TextStyle import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import com.github.takahirom.roborazzi.captureRoboImage -import io.element.android.compound.screenshot.utils.screenshotFile +import io.element.android.compound.screenshot.utils.createPaparazziRule import io.element.android.compound.theme.ColorsSchemeDarkHcPreview import io.element.android.compound.theme.ColorsSchemeDarkPreview import io.element.android.compound.theme.ColorsSchemeLightHcPreview import io.element.android.compound.theme.ColorsSchemeLightPreview import io.element.android.compound.theme.ElementTheme -import io.element.android.tests.testutils.robolectric.RobolectricTest +import org.junit.Rule import org.junit.Test -import org.robolectric.annotation.Config -import org.robolectric.annotation.GraphicsMode -@GraphicsMode(GraphicsMode.Mode.NATIVE) -class MaterialColorSchemeTest : RobolectricTest() { +class MaterialColorSchemeTest { + @get:Rule + val paparazzi = createPaparazziRule() + @Test - @Config(sdk = [35], qualifiers = "h2048dp-xhdpi") fun screenshots() { - captureRoboImage(file = screenshotFile("Material3 Colors - Light.png")) { + paparazzi.snapshot(name = "Material3 Colors - Light") { ElementTheme { - Surface { + Surface(Modifier.width(300.dp)) { Column(modifier = Modifier.padding(16.dp)) { Text( text = "M3 Light colors", @@ -49,9 +48,9 @@ class MaterialColorSchemeTest : RobolectricTest() { } } } - captureRoboImage(file = screenshotFile("Material3 Colors - Light HC.png")) { + paparazzi.snapshot(name = "Material3 Colors - Light HC") { ElementTheme { - Surface { + Surface(Modifier.width(300.dp)) { Column(modifier = Modifier.padding(16.dp)) { Text( text = "M3 Light HC colors", @@ -63,9 +62,9 @@ class MaterialColorSchemeTest : RobolectricTest() { } } } - captureRoboImage(file = screenshotFile("Material3 Colors - Dark.png")) { + paparazzi.snapshot(name = "Material3 Colors - Dark") { ElementTheme { - Surface { + Surface(Modifier.width(300.dp)) { Column(modifier = Modifier.padding(16.dp)) { Text( text = "M3 Dark colors", @@ -77,9 +76,9 @@ class MaterialColorSchemeTest : RobolectricTest() { } } } - captureRoboImage(file = screenshotFile("Material3 Colors - Dark HC.png")) { + paparazzi.snapshot(name = "Material3 Colors - Dark HC") { ElementTheme { - Surface { + Surface(Modifier.width(300.dp)) { Column(modifier = Modifier.padding(16.dp)) { Text( text = "M3 Dark HC colors", diff --git a/libraries/compound/src/test/kotlin/io/element/android/compound/screenshot/MaterialTextTest.kt b/libraries/compound/src/test/kotlin/io/element/android/compound/screenshot/MaterialTextTest.kt index 4d21e77aed..d57351b530 100644 --- a/libraries/compound/src/test/kotlin/io/element/android/compound/screenshot/MaterialTextTest.kt +++ b/libraries/compound/src/test/kotlin/io/element/android/compound/screenshot/MaterialTextTest.kt @@ -8,20 +8,19 @@ package io.element.android.compound.screenshot -import com.github.takahirom.roborazzi.captureRoboImage -import io.element.android.compound.screenshot.utils.screenshotFile +import io.element.android.compound.screenshot.utils.createPaparazziRule import io.element.android.compound.theme.MaterialTextPreview -import io.element.android.tests.testutils.robolectric.RobolectricTest +import org.junit.Rule import org.junit.Test -import org.robolectric.annotation.Config -import org.robolectric.annotation.GraphicsMode -@GraphicsMode(GraphicsMode.Mode.NATIVE) -class MaterialTextTest : RobolectricTest() { +class MaterialTextTest { + // MaterialTextPreview relies on `weight`/`fillMaxSize`, so it needs a bounded frame to lay out. + @get:Rule + val paparazzi = createPaparazziRule(widthDp = 420, heightDp = 1200) + @Test - @Config(sdk = [35], qualifiers = "w480dp-h1200dp-xxhdpi") fun screenshots() { - captureRoboImage(file = screenshotFile("MaterialText Colors.png")) { + paparazzi.snapshot(name = "MaterialText Colors") { MaterialTextPreview() } } diff --git a/libraries/compound/src/test/kotlin/io/element/android/compound/screenshot/MaterialTypographyTest.kt b/libraries/compound/src/test/kotlin/io/element/android/compound/screenshot/MaterialTypographyTest.kt index 3c07ff48f5..a3b2c6fda3 100644 --- a/libraries/compound/src/test/kotlin/io/element/android/compound/screenshot/MaterialTypographyTest.kt +++ b/libraries/compound/src/test/kotlin/io/element/android/compound/screenshot/MaterialTypographyTest.kt @@ -8,20 +8,18 @@ package io.element.android.compound.screenshot -import com.github.takahirom.roborazzi.captureRoboImage import io.element.android.compound.previews.TypographyPreview -import io.element.android.compound.screenshot.utils.screenshotFile -import io.element.android.tests.testutils.robolectric.RobolectricTest +import io.element.android.compound.screenshot.utils.createPaparazziRule +import org.junit.Rule import org.junit.Test -import org.robolectric.annotation.Config -import org.robolectric.annotation.GraphicsMode -@GraphicsMode(GraphicsMode.Mode.NATIVE) -class MaterialTypographyTest : RobolectricTest() { +class MaterialTypographyTest { + @get:Rule + val paparazzi = createPaparazziRule() + @Test - @Config(sdk = [35], qualifiers = "h2048dp-xxhdpi") fun screenshots() { - captureRoboImage(file = screenshotFile("Material Typography.png")) { + paparazzi.snapshot(name = "Material Typography") { TypographyPreview() } } diff --git a/libraries/compound/src/test/kotlin/io/element/android/compound/screenshot/MaterialYouThemeTest.kt b/libraries/compound/src/test/kotlin/io/element/android/compound/screenshot/MaterialYouThemeTest.kt index 2f1abc55f5..6f74852251 100644 --- a/libraries/compound/src/test/kotlin/io/element/android/compound/screenshot/MaterialYouThemeTest.kt +++ b/libraries/compound/src/test/kotlin/io/element/android/compound/screenshot/MaterialYouThemeTest.kt @@ -19,22 +19,20 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.dp -import com.github.takahirom.roborazzi.captureRoboImage import io.element.android.compound.previews.ColorsSchemePreview -import io.element.android.compound.screenshot.utils.screenshotFile +import io.element.android.compound.screenshot.utils.createPaparazziRule import io.element.android.compound.theme.ElementTheme import io.element.android.compound.theme.Theme -import io.element.android.tests.testutils.robolectric.RobolectricTest +import org.junit.Rule import org.junit.Test -import org.robolectric.annotation.Config -import org.robolectric.annotation.GraphicsMode -@GraphicsMode(GraphicsMode.Mode.NATIVE) -class MaterialYouThemeTest : RobolectricTest() { +class MaterialYouThemeTest { + @get:Rule + val paparazzi = createPaparazziRule() + @Test - @Config(sdk = [35], qualifiers = "h2048dp-xhdpi") fun screenshots() { - captureRoboImage(file = screenshotFile("MaterialYou Theme - Light.png")) { + paparazzi.snapshot(name = "MaterialYou Theme - Light") { ElementTheme(dynamicColor = true) { Surface { Column( @@ -49,7 +47,7 @@ class MaterialYouThemeTest : RobolectricTest() { } } } - captureRoboImage(file = screenshotFile("MaterialYou Theme - Dark.png")) { + paparazzi.snapshot(name = "MaterialYou Theme - Dark") { ElementTheme(dynamicColor = true, theme = Theme.Dark) { Surface { Column( diff --git a/libraries/compound/src/test/kotlin/io/element/android/compound/screenshot/SemanticColorsTest.kt b/libraries/compound/src/test/kotlin/io/element/android/compound/screenshot/SemanticColorsTest.kt index 95e34fb3d9..a3430ce70d 100644 --- a/libraries/compound/src/test/kotlin/io/element/android/compound/screenshot/SemanticColorsTest.kt +++ b/libraries/compound/src/test/kotlin/io/element/android/compound/screenshot/SemanticColorsTest.kt @@ -8,35 +8,33 @@ package io.element.android.compound.screenshot -import com.github.takahirom.roborazzi.captureRoboImage import io.element.android.compound.previews.CompoundSemanticColorsDark import io.element.android.compound.previews.CompoundSemanticColorsDarkHc import io.element.android.compound.previews.CompoundSemanticColorsLight import io.element.android.compound.previews.CompoundSemanticColorsLightHc -import io.element.android.compound.screenshot.utils.screenshotFile -import io.element.android.tests.testutils.robolectric.RobolectricTest +import io.element.android.compound.screenshot.utils.createPaparazziRule +import org.junit.Rule import org.junit.Test -import org.robolectric.annotation.Config -import org.robolectric.annotation.GraphicsMode -@GraphicsMode(GraphicsMode.Mode.NATIVE) -class SemanticColorsTest : RobolectricTest() { - @Config(sdk = [35], qualifiers = "h2000dp-xhdpi") +class SemanticColorsTest { + @get:Rule + val paparazzi = createPaparazziRule() + @Test fun screenshots() { - captureRoboImage(file = screenshotFile("Compound Semantic Colors - Light.png")) { + paparazzi.snapshot(name = "Compound Semantic Colors - Light") { CompoundSemanticColorsLight() } - captureRoboImage(file = screenshotFile("Compound Semantic Colors - Light HC.png")) { + paparazzi.snapshot(name = "Compound Semantic Colors - Light HC") { CompoundSemanticColorsLightHc() } - captureRoboImage(file = screenshotFile("Compound Semantic Colors - Dark.png")) { + paparazzi.snapshot(name = "Compound Semantic Colors - Dark") { CompoundSemanticColorsDark() } - captureRoboImage(file = screenshotFile("Compound Semantic Colors - Dark HC.png")) { + paparazzi.snapshot(name = "Compound Semantic Colors - Dark HC") { CompoundSemanticColorsDarkHc() } } diff --git a/libraries/compound/src/test/kotlin/io/element/android/compound/screenshot/utils/ScreenshotTest.kt b/libraries/compound/src/test/kotlin/io/element/android/compound/screenshot/utils/ScreenshotTest.kt new file mode 100644 index 0000000000..ba48e2ab04 --- /dev/null +++ b/libraries/compound/src/test/kotlin/io/element/android/compound/screenshot/utils/ScreenshotTest.kt @@ -0,0 +1,57 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.compound.screenshot.utils + +import app.cash.paparazzi.DeviceConfig +import app.cash.paparazzi.Paparazzi +import com.android.ide.common.rendering.api.SessionParams.RenderingMode +import com.android.resources.Density + +// Screenshots are captured at xxhdpi so the icon grids stay sharp. This is the density scale factor +// (480dpi / 160dpi), used to convert the dp bounds below into the pixel values [DeviceConfig] expects. +private const val DENSITY_SCALE = 2 + +// Upper bounds for self-sizing screenshots, in dp. Large enough to fit the tallest and widest +// Compound preview; `RenderingMode.SHRINK` then crops each image down to its actual content. +private const val MAX_WIDTH_DP = 2048 +private const val MAX_HEIGHT_DP = 4800 + +/** + * Creates a [Paparazzi] rule for the Compound screenshot tests. + * + * By default each screenshot sizes itself to its content ([RenderingMode.SHRINK]) instead of being + * rendered at a fixed device frame size, and the [DeviceConfig] only acts as an upper bound. + * + * Pass [widthDp] and [heightDp] for previews that rely on `fillMaxSize`/`weight` and therefore need + * a bounded frame to lay out correctly: they are then rendered at that fixed size instead. + * + * [useDeviceResolution] is enabled so screenshots are captured at full device resolution. Without + * it, Paparazzi downscales every image to a 1000px max dimension, which makes large previews (such + * as the icon grids) look pixelated. + */ +fun createPaparazziRule( + widthDp: Int? = null, + heightDp: Int? = null, +): Paparazzi { + val selfSizing = widthDp == null || heightDp == null + return Paparazzi( + deviceConfig = DeviceConfig( + screenWidth = (widthDp ?: MAX_WIDTH_DP) * DENSITY_SCALE, + screenHeight = (heightDp ?: MAX_HEIGHT_DP) * DENSITY_SCALE, + xdpi = 480, + ydpi = 480, + density = Density.XXHIGH, + softButtons = false, + ), + renderingMode = if (selfSizing) RenderingMode.SHRINK else RenderingMode.NORMAL, + supportsRtl = true, + useDeviceResolution = true, + maxPercentDifference = 0.01, + ) +} diff --git a/libraries/compound/src/test/kotlin/io/element/android/compound/screenshot/utils/ScreenshotUtils.kt b/libraries/compound/src/test/kotlin/io/element/android/compound/screenshot/utils/ScreenshotUtils.kt deleted file mode 100644 index 082a9b248a..0000000000 --- a/libraries/compound/src/test/kotlin/io/element/android/compound/screenshot/utils/ScreenshotUtils.kt +++ /dev/null @@ -1,19 +0,0 @@ -/* - * Copyright (c) 2025 Element Creations Ltd. - * Copyright 2023-2025 New Vector Ltd. - * - * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. - * Please see LICENSE files in the repository root for full details. - */ - -package io.element.android.compound.screenshot.utils - -import java.io.File - -/** - * Returns a [File] object for a screenshot with the given [filename]. - * This is to ensure we have a consistent location for all screenshots. - */ -fun screenshotFile(filename: String): File { - return File("screenshots", filename) -} diff --git a/libraries/compound/src/test/snapshots/images/io.element.android.compound.screenshot_AvatarColorsTest_screenshots_avatar_colors_-_dark.png b/libraries/compound/src/test/snapshots/images/io.element.android.compound.screenshot_AvatarColorsTest_screenshots_avatar_colors_-_dark.png new file mode 100644 index 0000000000..adde0951a6 --- /dev/null +++ b/libraries/compound/src/test/snapshots/images/io.element.android.compound.screenshot_AvatarColorsTest_screenshots_avatar_colors_-_dark.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:440dfad8b5540b7599dd3186c99008e6f20abecf4c3b51f6950b4fa5f091151a +size 7034 diff --git a/libraries/compound/src/test/snapshots/images/io.element.android.compound.screenshot_AvatarColorsTest_screenshots_avatar_colors_-_light.png b/libraries/compound/src/test/snapshots/images/io.element.android.compound.screenshot_AvatarColorsTest_screenshots_avatar_colors_-_light.png new file mode 100644 index 0000000000..e24a1f94f3 --- /dev/null +++ b/libraries/compound/src/test/snapshots/images/io.element.android.compound.screenshot_AvatarColorsTest_screenshots_avatar_colors_-_light.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:f05f09503c21ab8d85b39e9db27cc4d9991824e78f8f0cad63f349e9364e9865 +size 7043 diff --git a/libraries/compound/src/test/snapshots/images/io.element.android.compound.screenshot_CompoundIconTest_screenshots_compound_icons_-_dark.png b/libraries/compound/src/test/snapshots/images/io.element.android.compound.screenshot_CompoundIconTest_screenshots_compound_icons_-_dark.png new file mode 100644 index 0000000000..73346443a8 --- /dev/null +++ b/libraries/compound/src/test/snapshots/images/io.element.android.compound.screenshot_CompoundIconTest_screenshots_compound_icons_-_dark.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:97bab2190821cbd55b5ee583fbb18afdf03d419e9046150ad01b53de90808ab3 +size 672799 diff --git a/libraries/compound/src/test/snapshots/images/io.element.android.compound.screenshot_CompoundIconTest_screenshots_compound_icons_-_light.png b/libraries/compound/src/test/snapshots/images/io.element.android.compound.screenshot_CompoundIconTest_screenshots_compound_icons_-_light.png new file mode 100644 index 0000000000..6eced2b227 --- /dev/null +++ b/libraries/compound/src/test/snapshots/images/io.element.android.compound.screenshot_CompoundIconTest_screenshots_compound_icons_-_light.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:13ea9611b21c96a2c18b1a47128e70120bdcf7e4d8df36b790a7b89004a9250e +size 723134 diff --git a/libraries/compound/src/test/snapshots/images/io.element.android.compound.screenshot_CompoundIconTest_screenshots_compound_icons_-_rtl.png b/libraries/compound/src/test/snapshots/images/io.element.android.compound.screenshot_CompoundIconTest_screenshots_compound_icons_-_rtl.png new file mode 100644 index 0000000000..1c57d142ea --- /dev/null +++ b/libraries/compound/src/test/snapshots/images/io.element.android.compound.screenshot_CompoundIconTest_screenshots_compound_icons_-_rtl.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:c3f7f4772952b914e1674da24d88d5dafc7b4198ab2366b302a4d744c8b4c4b3 +size 727300 diff --git a/libraries/compound/src/test/snapshots/images/io.element.android.compound.screenshot_CompoundIconTest_screenshots_compound_vector_icons_-_dark.png b/libraries/compound/src/test/snapshots/images/io.element.android.compound.screenshot_CompoundIconTest_screenshots_compound_vector_icons_-_dark.png new file mode 100644 index 0000000000..bebb1bfd40 --- /dev/null +++ b/libraries/compound/src/test/snapshots/images/io.element.android.compound.screenshot_CompoundIconTest_screenshots_compound_vector_icons_-_dark.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:15af412c707dbea977b74209117472ba9cdfc9d2f51fc4cd8f80fb9baa8d327e +size 276092 diff --git a/libraries/compound/src/test/snapshots/images/io.element.android.compound.screenshot_CompoundIconTest_screenshots_compound_vector_icons_-_light.png b/libraries/compound/src/test/snapshots/images/io.element.android.compound.screenshot_CompoundIconTest_screenshots_compound_vector_icons_-_light.png new file mode 100644 index 0000000000..a72bd146a4 --- /dev/null +++ b/libraries/compound/src/test/snapshots/images/io.element.android.compound.screenshot_CompoundIconTest_screenshots_compound_vector_icons_-_light.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:363449e3c0baaa827ad0c85dd9ba2ace13922bd7beddf5fe43f6ab3e43ffd873 +size 304177 diff --git a/libraries/compound/src/test/snapshots/images/io.element.android.compound.screenshot_CompoundTypographyTest_screenshots_compound_typography.png b/libraries/compound/src/test/snapshots/images/io.element.android.compound.screenshot_CompoundTypographyTest_screenshots_compound_typography.png new file mode 100644 index 0000000000..0cd652253e --- /dev/null +++ b/libraries/compound/src/test/snapshots/images/io.element.android.compound.screenshot_CompoundTypographyTest_screenshots_compound_typography.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:a31a9b9a5fdc647a3d77c4e3958d91e937c1bddab668bfd110ad1363b936ca44 +size 110963 diff --git a/libraries/compound/src/test/snapshots/images/io.element.android.compound.screenshot_ForcedDarkElementThemeTest_screenshots_forceddarkelementtheme.png b/libraries/compound/src/test/snapshots/images/io.element.android.compound.screenshot_ForcedDarkElementThemeTest_screenshots_forceddarkelementtheme.png new file mode 100644 index 0000000000..1bdaca057c --- /dev/null +++ b/libraries/compound/src/test/snapshots/images/io.element.android.compound.screenshot_ForcedDarkElementThemeTest_screenshots_forceddarkelementtheme.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:39362743f2aa286b0d85933b67b08ea3617fed7ea89060d22135168dc6aaf470 +size 18081 diff --git a/libraries/compound/src/test/snapshots/images/io.element.android.compound.screenshot_LegacyColorsTest_screenshots_legacy_colors.png b/libraries/compound/src/test/snapshots/images/io.element.android.compound.screenshot_LegacyColorsTest_screenshots_legacy_colors.png new file mode 100644 index 0000000000..ca0aff60a3 --- /dev/null +++ b/libraries/compound/src/test/snapshots/images/io.element.android.compound.screenshot_LegacyColorsTest_screenshots_legacy_colors.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:f33880533b4ec7104d5d85b9a36fc125f98717e53682d5076f9173802763bcb9 +size 30792 diff --git a/libraries/compound/src/test/snapshots/images/io.element.android.compound.screenshot_MaterialColorSchemeTest_screenshots_material3_colors_-_dark.png b/libraries/compound/src/test/snapshots/images/io.element.android.compound.screenshot_MaterialColorSchemeTest_screenshots_material3_colors_-_dark.png new file mode 100644 index 0000000000..48ab924319 --- /dev/null +++ b/libraries/compound/src/test/snapshots/images/io.element.android.compound.screenshot_MaterialColorSchemeTest_screenshots_material3_colors_-_dark.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:1995dd2ee81dab6bbb7f2dfe24ca47429e51469686aa3379e0b0953dab5ef1aa +size 235932 diff --git a/libraries/compound/src/test/snapshots/images/io.element.android.compound.screenshot_MaterialColorSchemeTest_screenshots_material3_colors_-_dark_hc.png b/libraries/compound/src/test/snapshots/images/io.element.android.compound.screenshot_MaterialColorSchemeTest_screenshots_material3_colors_-_dark_hc.png new file mode 100644 index 0000000000..fef1566fc5 --- /dev/null +++ b/libraries/compound/src/test/snapshots/images/io.element.android.compound.screenshot_MaterialColorSchemeTest_screenshots_material3_colors_-_dark_hc.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:59dedf6d3cc29d8a3d534e4d705e8a2533de71fd277e01c549f2bb5a1d30ff8c +size 238452 diff --git a/libraries/compound/src/test/snapshots/images/io.element.android.compound.screenshot_MaterialColorSchemeTest_screenshots_material3_colors_-_light.png b/libraries/compound/src/test/snapshots/images/io.element.android.compound.screenshot_MaterialColorSchemeTest_screenshots_material3_colors_-_light.png new file mode 100644 index 0000000000..2ab8d3043f --- /dev/null +++ b/libraries/compound/src/test/snapshots/images/io.element.android.compound.screenshot_MaterialColorSchemeTest_screenshots_material3_colors_-_light.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:143f25e342c507c2f93e37cd3068c5832be0e37aa38e45236e60314224ba6f12 +size 230591 diff --git a/libraries/compound/src/test/snapshots/images/io.element.android.compound.screenshot_MaterialColorSchemeTest_screenshots_material3_colors_-_light_hc.png b/libraries/compound/src/test/snapshots/images/io.element.android.compound.screenshot_MaterialColorSchemeTest_screenshots_material3_colors_-_light_hc.png new file mode 100644 index 0000000000..11a663fd30 --- /dev/null +++ b/libraries/compound/src/test/snapshots/images/io.element.android.compound.screenshot_MaterialColorSchemeTest_screenshots_material3_colors_-_light_hc.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:be176b260b79b90ab7354d14550df388b5629f1de46ea68e7d124912f8ef56ba +size 230779 diff --git a/libraries/compound/src/test/snapshots/images/io.element.android.compound.screenshot_MaterialTextTest_screenshots_materialtext_colors.png b/libraries/compound/src/test/snapshots/images/io.element.android.compound.screenshot_MaterialTextTest_screenshots_materialtext_colors.png new file mode 100644 index 0000000000..51cd990103 --- /dev/null +++ b/libraries/compound/src/test/snapshots/images/io.element.android.compound.screenshot_MaterialTextTest_screenshots_materialtext_colors.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:2572ec54b2ce8819c99fa935b532794e04f43eb08fef52a84b179ee959fac062 +size 160422 diff --git a/libraries/compound/src/test/snapshots/images/io.element.android.compound.screenshot_MaterialTypographyTest_screenshots_material_typography.png b/libraries/compound/src/test/snapshots/images/io.element.android.compound.screenshot_MaterialTypographyTest_screenshots_material_typography.png new file mode 100644 index 0000000000..05c99c79c6 --- /dev/null +++ b/libraries/compound/src/test/snapshots/images/io.element.android.compound.screenshot_MaterialTypographyTest_screenshots_material_typography.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:ea3070f9f648170f16ace884fce7aeb35e57455375b698b9bbad2ce0904290ea +size 85995 diff --git a/libraries/compound/src/test/snapshots/images/io.element.android.compound.screenshot_MaterialYouThemeTest_screenshots_materialyou_theme_-_dark.png b/libraries/compound/src/test/snapshots/images/io.element.android.compound.screenshot_MaterialYouThemeTest_screenshots_materialyou_theme_-_dark.png new file mode 100644 index 0000000000..ea4856ea85 --- /dev/null +++ b/libraries/compound/src/test/snapshots/images/io.element.android.compound.screenshot_MaterialYouThemeTest_screenshots_materialyou_theme_-_dark.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:3b4fedcc0203eb9371764263c516c58c7cf87ac058884ea99b1f5dbf58ff18a2 +size 435677 diff --git a/libraries/compound/src/test/snapshots/images/io.element.android.compound.screenshot_MaterialYouThemeTest_screenshots_materialyou_theme_-_light.png b/libraries/compound/src/test/snapshots/images/io.element.android.compound.screenshot_MaterialYouThemeTest_screenshots_materialyou_theme_-_light.png new file mode 100644 index 0000000000..4c878a53ac --- /dev/null +++ b/libraries/compound/src/test/snapshots/images/io.element.android.compound.screenshot_MaterialYouThemeTest_screenshots_materialyou_theme_-_light.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:6da500a8d937143569f3979ec6f9d446d1d71042f3c2222af2dc97687079fb01 +size 434112 diff --git a/libraries/compound/src/test/snapshots/images/io.element.android.compound.screenshot_SemanticColorsTest_screenshots_compound_semantic_colors_-_dark.png b/libraries/compound/src/test/snapshots/images/io.element.android.compound.screenshot_SemanticColorsTest_screenshots_compound_semantic_colors_-_dark.png new file mode 100644 index 0000000000..3e94cfb1ee --- /dev/null +++ b/libraries/compound/src/test/snapshots/images/io.element.android.compound.screenshot_SemanticColorsTest_screenshots_compound_semantic_colors_-_dark.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:4d7c27f5dcefdda3f923d8cd6309a0a424d3d476384374f24755c63f3ba3ae74 +size 551644 diff --git a/libraries/compound/src/test/snapshots/images/io.element.android.compound.screenshot_SemanticColorsTest_screenshots_compound_semantic_colors_-_dark_hc.png b/libraries/compound/src/test/snapshots/images/io.element.android.compound.screenshot_SemanticColorsTest_screenshots_compound_semantic_colors_-_dark_hc.png new file mode 100644 index 0000000000..d6a675109b --- /dev/null +++ b/libraries/compound/src/test/snapshots/images/io.element.android.compound.screenshot_SemanticColorsTest_screenshots_compound_semantic_colors_-_dark_hc.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:366b9283fb49be9d072570dbe9cb935d7c61af7f19f1440c722aa9e20ab32826 +size 557930 diff --git a/libraries/compound/src/test/snapshots/images/io.element.android.compound.screenshot_SemanticColorsTest_screenshots_compound_semantic_colors_-_light.png b/libraries/compound/src/test/snapshots/images/io.element.android.compound.screenshot_SemanticColorsTest_screenshots_compound_semantic_colors_-_light.png new file mode 100644 index 0000000000..ccd73cc937 --- /dev/null +++ b/libraries/compound/src/test/snapshots/images/io.element.android.compound.screenshot_SemanticColorsTest_screenshots_compound_semantic_colors_-_light.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:7f1deef19927a5a5844ce887711efe64092dc56d630b3abfa867ec39e6b1fe2c +size 542256 diff --git a/libraries/compound/src/test/snapshots/images/io.element.android.compound.screenshot_SemanticColorsTest_screenshots_compound_semantic_colors_-_light_hc.png b/libraries/compound/src/test/snapshots/images/io.element.android.compound.screenshot_SemanticColorsTest_screenshots_compound_semantic_colors_-_light_hc.png new file mode 100644 index 0000000000..73b6011125 --- /dev/null +++ b/libraries/compound/src/test/snapshots/images/io.element.android.compound.screenshot_SemanticColorsTest_screenshots_compound_semantic_colors_-_light_hc.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:5da9d4bb6b00dfdd8e9d53b78659b9e73330ce084c1e5e910db926864de40475 +size 543810 diff --git a/libraries/designsystem/build.gradle.kts b/libraries/designsystem/build.gradle.kts index 7020b5dba6..7c1f7cd3c4 100644 --- a/libraries/designsystem/build.gradle.kts +++ b/libraries/designsystem/build.gradle.kts @@ -19,6 +19,12 @@ android { buildFeatures { buildConfig = true } + + testOptions { + unitTests { + isIncludeAndroidResources = true + } + } } dependencies { @@ -36,5 +42,5 @@ dependencies { implementation(projects.libraries.testtags) implementation(projects.libraries.uiStrings) - testCommonDependencies(libs) + testCommonDependencies(libs, true) } diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/atoms/RoomPreviewAliasAtom.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/atoms/CopiableTextAtom.kt similarity index 91% rename from libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/atoms/RoomPreviewAliasAtom.kt rename to libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/atoms/CopiableTextAtom.kt index ad70fc3ba8..2bc1f17f1d 100644 --- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/atoms/RoomPreviewAliasAtom.kt +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/atoms/CopiableTextAtom.kt @@ -35,8 +35,8 @@ import io.element.android.libraries.ui.strings.CommonStrings import kotlinx.coroutines.launch @Composable -fun RoomPreviewAliasAtom( - alias: String, +fun CopiableTextAtom( + text: String, modifier: Modifier = Modifier, copiable: Boolean = true ) { @@ -46,7 +46,7 @@ fun RoomPreviewAliasAtom( modifier = modifier .clickable(enabled = copiable) { coroutineScope.launch { - val clipData = ClipData.newPlainText(alias, alias) + val clipData = ClipData.newPlainText(text, text) clipboard.setClipEntry(clipData.toClipEntry()) } }, @@ -55,7 +55,7 @@ fun RoomPreviewAliasAtom( ) { Text( modifier = Modifier.weight(weight = 1f, fill = false), - text = alias, + text = text, style = ElementTheme.typography.fontBodyLgRegular, textAlign = TextAlign.Center, maxLines = 1, @@ -75,9 +75,9 @@ fun RoomPreviewAliasAtom( @PreviewsDayNight @Composable -internal fun RoomPreviewAliasAtomPreview() = ElementPreview { - RoomPreviewAliasAtom( - alias = "#room-alias:matrix.org", +internal fun CopiableTextAtomPreview() = ElementPreview { + CopiableTextAtom( + text = "#room-alias:matrix.org", copiable = true ) } diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/atoms/CounterAtom.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/atoms/CounterAtom.kt index 3bbd3c12a7..ef6a71cc90 100644 --- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/atoms/CounterAtom.kt +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/atoms/CounterAtom.kt @@ -8,39 +8,43 @@ package io.element.android.libraries.designsystem.atomic.atoms -import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement.spacedBy -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.sizeIn 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.graphics.Color import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.rememberTextMeasurer +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.coerceAtLeast import androidx.compose.ui.unit.dp import io.element.android.compound.theme.ElementTheme +import io.element.android.compound.tokens.generated.SemanticColors import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.designsystem.text.toDp import io.element.android.libraries.designsystem.theme.components.Text private const val MAX_COUNT = 99 -private const val MAX_COUNT_STRING = "+$MAX_COUNT" +private const val MAX_COUNT_STRING = "$MAX_COUNT+" /** * A counter atom that displays a number in a circle. * Figma link : https://www.figma.com/design/G1xy0HDZKJf5TCRFmKb5d5/Compound-Android-Components?node-id=2805-2649&m=dev * * @param count The number to display. If the number is greater than [MAX_COUNT], the counter will display [MAX_COUNT_STRING]. - * If the number is less than 1, the counter will not be displayed. + * If the number is less than 1, just a dot will be displayed instead. * @param modifier The modifier to apply to this layout. * @param containerColor The background color of the counter. When null, uses [isCritical] to pick a default. - * @param contentColor The text color inside the counter. When null, uses [textOnSolidPrimary]. + * @param contentColor The text color inside the counter. When null, uses [SemanticColors.textOnSolidPrimary]. + * @param contentPadding The padding to apply to the text inside the counter. * @param textStyle The style to apply to the text inside the counter. + * @param dotSize The size of the dot when [count] is less than 1. It defaults to `12.0.dp`. * @param isCritical If true, the counter will use a critical color scheme, otherwise it will use an accent color scheme. * Only used when [containerColor] is null. */ @@ -50,41 +54,51 @@ fun CounterAtom( modifier: Modifier = Modifier, containerColor: Color? = null, contentColor: Color? = null, + contentPadding: PaddingValues = PaddingValues.Zero, textStyle: TextStyle = CounterAtomDefaults.textStyle, + dotSize: Dp = 12.0.dp, isCritical: Boolean = false, ) { - if (count < 1) return val countAsText = when (count) { - in 0..MAX_COUNT -> count.toString() + in Int.MIN_VALUE..0 -> null + in 1..MAX_COUNT -> count.toString() else -> MAX_COUNT_STRING } - val textMeasurer = rememberTextMeasurer() - // Measure the maximum count string size - val textLayoutResult = textMeasurer.measure( - text = MAX_COUNT_STRING, - style = textStyle - ) - val textSize = textLayoutResult.size - val squareSize = maxOf(textSize.width, textSize.height) - Box( - modifier = modifier - .size(squareSize.toDp() + 1.dp) - .clip(CircleShape) - .background( - containerColor ?: if (isCritical) { - ElementTheme.colors.iconCriticalPrimary - } else { - ElementTheme.colors.iconAccentPrimary - } - ) - ) { - Text( - modifier = Modifier.align(Alignment.Center), + + val minSingleDigitSize = if (countAsText?.length == 1) { + val textMeasurer = rememberTextMeasurer() + // Measure the maximum count string size + val textLayoutResult = textMeasurer.measure( text = countAsText, - style = textStyle, - color = contentColor ?: ElementTheme.colors.textOnSolidPrimary, + style = textStyle ) + val textSize = textLayoutResult.size + maxOf(textSize.width, textSize.height) + } else { + 0 } + + androidx.compose.material3.Badge( + modifier = modifier.then( + if (countAsText == null) Modifier.sizeIn(minWidth = dotSize, minHeight = dotSize) else Modifier + ), + containerColor = containerColor ?: if (isCritical) ElementTheme.colors.iconCriticalPrimary else ElementTheme.colors.iconAccentPrimary, + contentColor = contentColor ?: ElementTheme.colors.textOnSolidPrimary, + content = countAsText?.let { + @Composable { + Text( + modifier = Modifier.sizeIn( + minWidth = (minSingleDigitSize.toDp() - 8.dp).coerceAtLeast(0.dp), + minHeight = minSingleDigitSize.toDp() + ).padding(contentPadding), + text = countAsText, + style = textStyle, + color = contentColor ?: ElementTheme.colors.textOnSolidPrimary, + textAlign = TextAlign.Center, + ) + } + } + ) } object CounterAtomDefaults { @@ -95,7 +109,7 @@ object CounterAtomDefaults { @PreviewsDayNight @Composable internal fun CounterAtomPreview() = ElementPreview { - Column(verticalArrangement = spacedBy(2.dp)) { + Row(horizontalArrangement = spacedBy(2.dp), verticalAlignment = Alignment.CenterVertically) { CounterAtom(count = 0) CounterAtom(count = 4) CounterAtom(count = 99) diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/atoms/UnreadIndicatorAtom.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/atoms/UnreadIndicatorAtom.kt index bdf039edb8..332621bba5 100644 --- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/atoms/UnreadIndicatorAtom.kt +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/atoms/UnreadIndicatorAtom.kt @@ -9,15 +9,16 @@ package io.element.android.libraries.designsystem.atomic.atoms import androidx.compose.foundation.BorderStroke -import androidx.compose.foundation.background import androidx.compose.foundation.border -import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.size -import androidx.compose.foundation.shape.CircleShape +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.graphics.Color import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.semantics @@ -37,35 +38,36 @@ fun UnreadIndicatorAtom( isVisible: Boolean = true, contentDescription: String? = null, border: BorderStroke? = null, + contentPadding: PaddingValues = PaddingValues.Zero, ) { when { !isVisible -> Spacer(modifier = modifier.size(size)) - count != null && count >= 1 -> CounterAtom( - count = count.toInt(), - modifier = modifier - .semantics { - contentDescription?.let { this.contentDescription = it } - } - .then(if (border != null) Modifier.border(border, CircleShape) else Modifier), - containerColor = color, - contentColor = ElementTheme.colors.bgCanvasDefault, - textStyle = ElementTheme.typography.fontBodySmMedium, - ) - else -> Box( - modifier = modifier - .semantics { - contentDescription?.let { this.contentDescription = it } - } - .size(size) - .clip(CircleShape) - .background(color) - .then(if (border != null) Modifier.border(border, CircleShape) else Modifier), - ) + else -> count?.let { + CounterAtom( + count = count.toInt(), + modifier = modifier + .semantics { + contentDescription?.let { this.contentDescription = it } + } + .then(if (border != null) Modifier.border(border, RoundedCornerShape(percent = 50)) else Modifier), + containerColor = color, + contentColor = ElementTheme.colors.bgCanvasDefault, + textStyle = ElementTheme.typography.fontBodySmMedium, + contentPadding = contentPadding, + ) + } } } @PreviewsDayNight @Composable internal fun UnreadIndicatorAtomPreview() = ElementPreview { - UnreadIndicatorAtom() + Row(horizontalArrangement = Arrangement.spacedBy(2.dp), verticalAlignment = Alignment.CenterVertically) { + UnreadIndicatorAtom(count = null) + UnreadIndicatorAtom(count = 0) + UnreadIndicatorAtom(count = 1) + UnreadIndicatorAtom(count = 10) + UnreadIndicatorAtom(count = 99) + UnreadIndicatorAtom(count = 999) + } } diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/ClickableLinkText.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/ClickableLinkText.kt index a70aafff36..f2b2a47f16 100644 --- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/ClickableLinkText.kt +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/ClickableLinkText.kt @@ -32,8 +32,8 @@ import androidx.compose.ui.text.TextLayoutResult import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.tooling.preview.Preview -import androidx.core.text.util.LinkifyCompat import io.element.android.compound.theme.LinkColor +import io.element.android.libraries.androidutils.text.LinkifyHelper import io.element.android.libraries.designsystem.preview.ElementThemedPreview import io.element.android.libraries.designsystem.preview.PreviewGroup import io.element.android.libraries.designsystem.theme.components.Text @@ -142,7 +142,7 @@ fun ClickableLinkText( fun AnnotatedString.linkify(linkStyle: SpanStyle): AnnotatedString { val original = this val spannable = SpannableString.valueOf(this.text) - LinkifyCompat.addLinks(spannable, Linkify.WEB_URLS or Linkify.PHONE_NUMBERS or Linkify.EMAIL_ADDRESSES) + LinkifyHelper.linkify(spannable, Linkify.WEB_URLS or Linkify.PHONE_NUMBERS or Linkify.EMAIL_ADDRESSES) val spans = spannable.getSpans(0, spannable.length, URLSpan::class.java) return buildAnnotatedString { @@ -150,7 +150,9 @@ fun AnnotatedString.linkify(linkStyle: SpanStyle): AnnotatedString { for (span in spans) { val start = spannable.getSpanStart(span) val end = spannable.getSpanEnd(span) - if (original.getLinkAnnotations(start, end).isEmpty() && original.getStringAnnotations("URL", start, end).isEmpty()) { + if (original.getLinkAnnotations(start, end).isEmpty() && + original.getStringAnnotations(LINK_TAG, start, end).isEmpty() && + original.getStringAnnotations("inline_code", start, end).isEmpty()) { // Prevent linkifying domains in user or room handles (@user:domain.com, #room:domain.com) if (start > 0 && !spannable[start - 1].isWhitespace()) continue 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 99c45c67d9..88f8f3abb5 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 @@ -18,6 +18,8 @@ data class AvatarData( val name: String?, val url: String? = null, val size: AvatarSize, + /** Base64 encoded thumbnail of [url], stored locally, used as a fallback when the avatar cannot be loaded. */ + val thumbnail: String? = null, ) { val initialLetter by lazy { // For roomIds, use "#" as initial diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/avatar/AvatarDataProvider.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/avatar/AvatarDataProvider.kt index 53ef5593ed..ee069f71fd 100644 --- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/avatar/AvatarDataProvider.kt +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/avatar/AvatarDataProvider.kt @@ -16,9 +16,11 @@ fun anAvatarData( name: String? = USER_NAME_ALICE, url: String? = null, size: AvatarSize = AvatarSize.RoomListItem, + thumbnail: String? = null, ) = AvatarData( id = id, name = name, url = url, size = size, + thumbnail = thumbnail, ) diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/avatar/AvatarSize.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/avatar/AvatarSize.kt index b91f8c405c..e4c8ebc641 100644 --- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/avatar/AvatarSize.kt +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/avatar/AvatarSize.kt @@ -82,6 +82,7 @@ enum class AvatarSize(val dp: Dp) { LeaveSpaceRoom(32.dp), SelectParentSpace(32.dp), AccountItem(32.dp), + OtherAccountItem(20.dp), LocationPin(32.dp), ActiveCallItem(20.dp) } diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/avatar/internal/ImageAvatar.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/avatar/internal/ImageAvatar.kt index da57fbcbe1..e8dcd94a77 100644 --- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/avatar/internal/ImageAvatar.kt +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/avatar/internal/ImageAvatar.kt @@ -13,6 +13,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.SideEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Shape @@ -21,8 +22,10 @@ import androidx.compose.ui.unit.Dp import coil3.compose.AsyncImagePainter import coil3.compose.SubcomposeAsyncImage import coil3.compose.SubcomposeAsyncImageContent +import io.element.android.libraries.core.extensions.runCatchingExceptions import io.element.android.libraries.designsystem.components.avatar.AvatarData import timber.log.Timber +import kotlin.io.encoding.Base64 @Composable internal fun ImageAvatar( @@ -33,6 +36,9 @@ internal fun ImageAvatar( contentDescription: String? = null, ) { val size = forcedAvatarSize ?: avatarData.size.dp + val thumbnailBytes = remember(avatarData.thumbnail) { + avatarData.thumbnail?.let { runCatchingExceptions { Base64.decode(it) }.getOrNull() } + } SubcomposeAsyncImage( model = avatarData, contentDescription = contentDescription, @@ -51,15 +57,17 @@ internal fun ImageAvatar( "Error loading avatar $state\n${state.result}" ) } - InitialLetterAvatar( + ThumbnailOrInitialLetterAvatar( avatarData = avatarData, + thumbnailBytes = thumbnailBytes, avatarShape = avatarShape, forcedAvatarSize = forcedAvatarSize, contentDescription = contentDescription, ) } - else -> InitialLetterAvatar( + else -> ThumbnailOrInitialLetterAvatar( avatarData = avatarData, + thumbnailBytes = thumbnailBytes, avatarShape = avatarShape, forcedAvatarSize = forcedAvatarSize, contentDescription = contentDescription, @@ -67,3 +75,44 @@ internal fun ImageAvatar( } } } + +/** + * Render the locally stored thumbnail of the avatar if any, else the initial letter. + */ +@Composable +private fun ThumbnailOrInitialLetterAvatar( + avatarData: AvatarData, + thumbnailBytes: ByteArray?, + avatarShape: Shape, + forcedAvatarSize: Dp?, + contentDescription: String?, +) { + if (thumbnailBytes == null) { + InitialLetterAvatar( + avatarData = avatarData, + avatarShape = avatarShape, + forcedAvatarSize = forcedAvatarSize, + contentDescription = contentDescription, + ) + } else { + SubcomposeAsyncImage( + model = thumbnailBytes, + contentDescription = contentDescription, + contentScale = ContentScale.Crop, + modifier = Modifier + .size(forcedAvatarSize ?: avatarData.size.dp) + .clip(avatarShape) + ) { + val collectedState by painter.state.collectAsState() + when (collectedState) { + is AsyncImagePainter.State.Success -> SubcomposeAsyncImageContent() + else -> InitialLetterAvatar( + avatarData = avatarData, + avatarShape = avatarShape, + forcedAvatarSize = forcedAvatarSize, + contentDescription = contentDescription, + ) + } + } + } +} diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/dialogs/TextFieldDialog.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/dialogs/TextFieldDialog.kt index 1ce241d5d9..493ad87bc3 100644 --- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/dialogs/TextFieldDialog.kt +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/dialogs/TextFieldDialog.kt @@ -47,6 +47,7 @@ fun TextFieldDialog( maxLines: Int = minLines, content: String? = null, label: String? = null, + supportingText: String? = null, keyboardOptions: KeyboardOptions = KeyboardOptions.Default, submitText: String = stringResource(CommonStrings.action_ok), destructiveSubmit: Boolean = false, @@ -84,6 +85,7 @@ fun TextFieldDialog( TextFieldListItem( placeholder = placeholder.orEmpty(), label = label, + supportingText = supportingText, text = textFieldContents, onTextChange = { error = if (!validation(it.text)) onValidationErrorMessage else null diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/list/TextFieldListItem.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/list/TextFieldListItem.kt index e149af5890..f2cace5eaf 100644 --- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/list/TextFieldListItem.kt +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/list/TextFieldListItem.kt @@ -57,6 +57,7 @@ fun TextFieldListItem( minLines: Int = 1, maxLines: Int = minLines, label: String? = null, + supportingText: String? = null, keyboardOptions: KeyboardOptions = KeyboardOptions.Default, keyboardActions: KeyboardActions = KeyboardActions.Default, ) { @@ -66,7 +67,7 @@ fun TextFieldListItem( placeholder = placeholder, label = label, validity = if (error != null) TextFieldValidity.Invalid else TextFieldValidity.None, - supportingText = error, + supportingText = error ?: supportingText, keyboardOptions = keyboardOptions, keyboardActions = keyboardActions, maxLines = maxLines, diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/media/WaveformPlaybackSeek.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/media/WaveformPlaybackSeek.kt new file mode 100644 index 0000000000..797fee0470 --- /dev/null +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/media/WaveformPlaybackSeek.kt @@ -0,0 +1,72 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.designsystem.components.media + +import android.view.MotionEvent + +/** + * Interprets a waveform pointer event and reports whether it was consumed. + * + * @param action [MotionEvent] action, such as [MotionEvent.ACTION_DOWN]. + * @param x Horizontal position of the pointer, in pixels, relative to the waveform canvas. + * @param waveformWidthPx Width of the drawn waveform, in pixels. Touches past this are ignored. + * @param currentSeekProgress In-progress drag progress (`0` to `1`), or `null` if no drag is active. + * @param onDisallowParentIntercept Called with `true` when a drag starts, and `false` when it ends, + * so a parent scroller does not steal the gesture. + * @param onUpdateSeekProgress Called with the current drag progress, or `null` when the drag ends. + * @param onCommitSeek Called with the final progress when the user lifts their finger after a drag. + * @return `true` when the event was consumed by the waveform. + */ +internal fun handleWaveformPointerEvent( + action: Int, + x: Float, + waveformWidthPx: Float, + currentSeekProgress: Float?, + onDisallowParentIntercept: (Boolean) -> Unit, + onUpdateSeekProgress: (Float?) -> Unit, + onCommitSeek: (Float) -> Unit, +): Boolean { + return when (action) { + MotionEvent.ACTION_DOWN -> { + val progress = seekProgressFromPointerX(x, waveformWidthPx) + if (progress != null) { + onDisallowParentIntercept(true) + onUpdateSeekProgress(progress) + true + } else { + false + } + } + MotionEvent.ACTION_MOVE -> { + val progress = seekProgressFromPointerX(x, waveformWidthPx) + if (progress != null) { + onUpdateSeekProgress(progress) + } + true + } + MotionEvent.ACTION_UP -> { + onDisallowParentIntercept(false) + currentSeekProgress?.let(onCommitSeek) + onUpdateSeekProgress(null) + true + } + MotionEvent.ACTION_CANCEL -> { + onDisallowParentIntercept(false) + onUpdateSeekProgress(null) + true + } + else -> false + } +} + +internal fun seekProgressFromPointerX(x: Float, waveformWidthPx: Float): Float? { + if (x in 0F..waveformWidthPx) { + return x / waveformWidthPx + } + return null +} diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/media/WaveformPlaybackSync.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/media/WaveformPlaybackSync.kt new file mode 100644 index 0000000000..aacc8ccc1c --- /dev/null +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/media/WaveformPlaybackSync.kt @@ -0,0 +1,159 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.designsystem.components.media + +import kotlin.math.abs +import kotlin.math.roundToInt + +/** + * Maximum gap, in wall-clock milliseconds, between two progress values that we still treat as + * "the same place" while audio is playing. + * + * Player progress is sampled on a timer, while the waveform cursor is animated every frame. + * A gap smaller than this is expected sampling lag and should be ignored. A larger gap means a + * real jump (seek acknowledged, pause, or the animation has drifted) and the cursor should snap. + * + * The comparison uses wall-clock time, so faster playback makes the same progress-fraction + * delta count as a smaller time gap. + */ +internal const val PLAYING_SYNC_THRESHOLD_MS = 200L + +/** + * When media duration is unknown (`durationMs <= 0`), progress is compared as a 0–1 fraction + * instead of milliseconds. Values this close are treated as equal. + */ +private const val ZERO_DURATION_PROGRESS_EPSILON = 0.01f + +/** + * Whether a player progress sample should update the cursor, or be ignored because a user seek + * has not been acknowledged yet. + * + * After a seek, the player can keep emitting samples from the old position. Those are dropped + * until a sample is close enough to [pendingSeek]. + * + * @param playbackProgress Latest player progress, from 0 to 1. + * @param pendingSeek Target progress of an in-flight user seek, or `null` if none. + * @param durationMs Total media duration in milliseconds. Used to convert a progress delta into time. + * @param playbackSpeed Current playback rate (`1` = realtime). Used when converting the delta to wall-clock time. + * @param syncThresholdMs How close the sample must be to [pendingSeek], in milliseconds. See [PLAYING_SYNC_THRESHOLD_MS]. + */ +internal fun shouldApplyPlayerProgress( + playbackProgress: Float, + pendingSeek: Float?, + durationMs: Long, + playbackSpeed: Float, + syncThresholdMs: Long = PLAYING_SYNC_THRESHOLD_MS, +): Boolean { + if (pendingSeek == null) return true + return hasCaughtUpToSeek( + playbackProgress = playbackProgress, + pendingSeek = pendingSeek, + durationMs = durationMs, + playbackSpeed = playbackSpeed, + syncThresholdMs = syncThresholdMs, + ) +} + +/** + * Whether [playbackProgress] is close enough to [pendingSeek] that the player has likely applied + * the seek. + * + * @param playbackProgress Latest player progress, from 0 to 1. + * @param pendingSeek Target progress of the in-flight user seek, from 0 to 1. + * @param durationMs Total media duration in milliseconds. If `<= 0`, [ZERO_DURATION_PROGRESS_EPSILON] is used instead. + * @param playbackSpeed Current playback rate (`1` = realtime). + * @param syncThresholdMs Allowed wall-clock gap in milliseconds. See [PLAYING_SYNC_THRESHOLD_MS]. + */ +internal fun hasCaughtUpToSeek( + playbackProgress: Float, + pendingSeek: Float, + durationMs: Long, + playbackSpeed: Float, + syncThresholdMs: Long = PLAYING_SYNC_THRESHOLD_MS, +): Boolean { + val delta = abs(playbackProgress - pendingSeek) + if (durationMs <= 0L) { + return delta <= ZERO_DURATION_PROGRESS_EPSILON + } + val deltaMs = delta * durationMs / playbackSpeed.coerceAtLeast(0.01f) + return deltaMs <= syncThresholdMs +} + +/** Player progress at or below this is treated as "at the start" when detecting a loop/restart. */ +private const val RESTART_PROGRESS_EPSILON = 0.01f + +/** Animated cursor at or above this is treated as "at the end" when detecting a loop/restart. */ +private const val RESTART_ANIMATED_PROGRESS = 0.9f + +/** + * Whether the animated cursor should jump to the latest player sample instead of continuing its + * linear animation. + * + * Returns `true` when: + * - playback is paused or duration is unknown (there is no animation to follow) + * - the player is ahead of the cursor by more than [syncThresholdMs] + * - playback restarted from the beginning (cursor near the end, player near the start) + * + * Returns `false` when the values already match, or when the player is only slightly ahead + * (or slightly behind) so the in-flight animation can catch up without a visible jump. + * + * @param playbackProgress Latest player progress, from 0 to 1. + * @param animatedProgress Current animated cursor progress, from 0 to 1. + * @param isPlaying Whether media is currently playing. + * @param durationMs Total media duration in milliseconds. Used to convert a progress delta into time. + * @param playbackSpeed Current playback rate (`1` = realtime). Used when converting the delta to wall-clock time. + * @param syncThresholdMs Drift that is still ignored while playing, in milliseconds. See [PLAYING_SYNC_THRESHOLD_MS]. + */ +internal fun shouldSnapToPlayer( + playbackProgress: Float, + animatedProgress: Float, + isPlaying: Boolean, + durationMs: Long, + playbackSpeed: Float, + syncThresholdMs: Long = PLAYING_SYNC_THRESHOLD_MS, +): Boolean { + val delta = abs(playbackProgress - animatedProgress) + if (delta == 0f) return false + if (!isPlaying || durationMs <= 0L) return true + val deltaMs = delta * durationMs / playbackSpeed.coerceAtLeast(0.01f) + if (playbackProgress < animatedProgress) { + return isPlaybackRestart(playbackProgress, animatedProgress) + } + return deltaMs > syncThresholdMs +} + +/** + * Detects a loop: the player has wrapped to the start while the cursor is still near the end. + */ +private fun isPlaybackRestart( + playbackProgress: Float, + animatedProgress: Float, +): Boolean { + return playbackProgress <= RESTART_PROGRESS_EPSILON && animatedProgress >= RESTART_ANIMATED_PROGRESS +} + +/** + * Remaining wall-clock milliseconds to tween the cursor from [progress] to the end of the media. + * + * Used to time the linear playback animation between infrequent player samples. A result of `0` + * means the cursor is already at (or past) the end and should snap rather than tween. + * + * @param progress Current cursor progress, from 0 to 1. + * @param durationMs Total media duration in milliseconds. + * @param playbackSpeed Current playback rate (`1` = realtime). Faster speeds shorten the result. + */ +internal fun remainingPlaybackAnimationMs( + progress: Float, + durationMs: Long, + playbackSpeed: Float, +): Int { + val remainingProgress = (1f - progress).coerceAtLeast(0f) + return (remainingProgress * durationMs / playbackSpeed.coerceAtLeast(0.01f)) + .roundToInt() + .coerceAtLeast(0) +} diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/media/WaveformPlaybackView.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/media/WaveformPlaybackView.kt index fe9507cb45..3c4f51777c 100644 --- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/media/WaveformPlaybackView.kt +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/media/WaveformPlaybackView.kt @@ -8,17 +8,24 @@ package io.element.android.libraries.designsystem.components.media -import android.view.MotionEvent -import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.AnimationVector1D +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.tween import androidx.compose.foundation.Canvas import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.State import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.geometry.CornerRadius @@ -41,6 +48,7 @@ import io.element.android.libraries.designsystem.preview.PreviewsDayNight import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.launch import kotlin.math.roundToInt private const val DEFAULT_GRAPHICS_LAYER_ALPHA: Float = 0.99F @@ -53,6 +61,9 @@ private const val DEFAULT_GRAPHICS_LAYER_ALPHA: Float = 0.99F * @param waveform The waveform to display. * @param onSeek Callback when the user seeks the waveform. Called with a value between 0 and 1. * @param modifier The modifier to be applied to the view. + * @param isPlaying Whether playback is currently in progress. Used to drive a linear progress animation. + * @param durationMs The total duration of the media in milliseconds. Used to time the linear animation. + * @param playbackSpeed The current playback speed. Used to time the linear animation. * @param seekEnabled Whether the user can seek the waveform or not. * @param brush The brush to use to draw the waveform. * @param progressBrush The brush to use to draw the progress. @@ -67,6 +78,9 @@ fun WaveformPlaybackView( waveform: ImmutableList, onSeek: (progress: Float) -> Unit, modifier: Modifier = Modifier, + isPlaying: Boolean = false, + durationMs: Long = 0L, + playbackSpeed: Float = 1f, seekEnabled: Boolean = true, brush: Brush = SolidColor(ElementTheme.colors.iconQuaternary), progressBrush: Brush = SolidColor(ElementTheme.colors.iconSecondary), @@ -75,14 +89,36 @@ fun WaveformPlaybackView( linePadding: Dp = 2.dp, ) { val seekProgress = remember { mutableStateOf(null) } + // Last user-seek target, kept until the player reports a nearby sample. + var pendingSeek by remember { mutableStateOf(null) } var canvasSize by remember { mutableStateOf(DpSize(0.dp, 0.dp)) } var canvasSizePx by remember { mutableStateOf(Size(0f, 0f)) } - val progress by remember(playbackProgress, seekProgress.value) { - derivedStateOf { - seekProgress.value ?: playbackProgress - } - } - val progressAnimated = animateFloatAsState(targetValue = progress, label = "progressAnimation") + val progressAnimated = remember { Animatable(playbackProgress) } + // Bumped after a snap or user seek so AnimateCursorWhilePlaying restarts its tween. + var seekGeneration by remember { mutableIntStateOf(0) } + val coroutineScope = rememberCoroutineScope() + + ApplyPlayerProgressToCursor( + playbackProgress = playbackProgress, + isPlaying = isPlaying, + durationMs = durationMs, + playbackSpeed = playbackSpeed, + pendingSeek = pendingSeek, + seekProgress = seekProgress, + progressAnimated = progressAnimated, + onAcknowledgePendingSeek = { pendingSeek = null }, + onCursorSnap = { seekGeneration++ }, + ) + + AnimateCursorWhilePlaying( + isPlaying = isPlaying, + durationMs = durationMs, + playbackSpeed = playbackSpeed, + seekGeneration = seekGeneration, + progressAnimated = progressAnimated, + ) + + val progress = seekProgress.value ?: progressAnimated.value val amplitudeDisplayCount by remember(canvasSize, lineWidth, linePadding) { derivedStateOf { (canvasSize.width.value / (lineWidth.value + linePadding.value)).toInt() @@ -107,30 +143,22 @@ fun WaveformPlaybackView( .let { if (!seekEnabled) return@let it it.pointerInteropFilter(requestDisallowInterceptTouchEvent = requestDisallowInterceptTouchEvent) { e -> - return@pointerInteropFilter when (e.action) { - MotionEvent.ACTION_DOWN -> { - if (e.x in 0F..waveformWidthPx) { - requestDisallowInterceptTouchEvent.invoke(true) - seekProgress.value = e.x / waveformWidthPx - true - } else { - false + handleWaveformPointerEvent( + action = e.action, + x = e.x, + waveformWidthPx = waveformWidthPx, + currentSeekProgress = seekProgress.value, + onDisallowParentIntercept = { requestDisallowInterceptTouchEvent.invoke(it) }, + onUpdateSeekProgress = { seekProgress.value = it }, + onCommitSeek = { seek -> + pendingSeek = seek + onSeek(seek) + coroutineScope.launch { + progressAnimated.snapTo(seek) + seekGeneration++ } - } - MotionEvent.ACTION_MOVE -> { - if (e.x in 0F..waveformWidthPx) { - seekProgress.value = e.x / waveformWidthPx - } - true - } - MotionEvent.ACTION_UP -> { - requestDisallowInterceptTouchEvent.invoke(false) - seekProgress.value?.let(onSeek) - seekProgress.value = null - true - } - else -> false - } + }, + ) } } .then(modifier) @@ -149,7 +177,7 @@ fun WaveformPlaybackView( drawRect( brush = progressBrush, size = Size( - width = progressAnimated.value * waveformWidthPx, + width = progress * waveformWidthPx, height = canvasSizePx.height ), blendMode = BlendMode.SrcAtop @@ -158,7 +186,7 @@ fun WaveformPlaybackView( drawRoundRect( brush = cursorBrush, topLeft = Offset( - x = progressAnimated.value * waveformWidthPx, + x = progress * waveformWidthPx, y = 1f ), size = Size( @@ -172,6 +200,103 @@ fun WaveformPlaybackView( } } +/** + * Applies discrete player progress samples to the animated cursor. + * + * Samples that belong to a position before an in-flight user seek are ignored. Once a sample + * is close enough to [pendingSeek], that seek is considered acknowledged. The cursor then + * either snaps to the sample (pause, large jump, or playback restart) or keeps interpolating. + * + * @param playbackProgress Latest player progress, from 0 to 1. + * @param isPlaying Whether media is currently playing. + * @param durationMs Total media duration in milliseconds. + * @param playbackSpeed Current playback rate (`1` = realtime). + * @param pendingSeek Target progress of an in-flight user seek, or `null` if none. + * @param seekProgress Progress while the user is dragging the cursor; non-null means a drag is in progress. + * @param progressAnimated Animated cursor progress. + * @param onAcknowledgePendingSeek Called when a player sample has caught up to [pendingSeek]. + * @param onCursorSnap Called after the cursor jumps, so the playing animation can restart from the new position. + */ +@Composable +private fun ApplyPlayerProgressToCursor( + playbackProgress: Float, + isPlaying: Boolean, + durationMs: Long, + playbackSpeed: Float, + pendingSeek: Float?, + seekProgress: State, + progressAnimated: Animatable, + onAcknowledgePendingSeek: () -> Unit, + onCursorSnap: () -> Unit, +) { + val latestOnAcknowledgePendingSeek by rememberUpdatedState(onAcknowledgePendingSeek) + val latestOnCursorSnap by rememberUpdatedState(onCursorSnap) + LaunchedEffect(playbackProgress, isPlaying, durationMs, playbackSpeed, pendingSeek) { + if (seekProgress.value != null) return@LaunchedEffect + if (!shouldApplyPlayerProgress( + playbackProgress = playbackProgress, + pendingSeek = pendingSeek, + durationMs = durationMs, + playbackSpeed = playbackSpeed, + ) + ) { + return@LaunchedEffect + } + latestOnAcknowledgePendingSeek() + if (shouldSnapToPlayer( + playbackProgress = playbackProgress, + animatedProgress = progressAnimated.value, + isPlaying = isPlaying, + durationMs = durationMs, + playbackSpeed = playbackSpeed, + ) + ) { + progressAnimated.snapTo(playbackProgress) + latestOnCursorSnap() + } + } +} + +/** + * Animates the cursor linearly from its current position to the end of the media. + * + * Player samples are relatively infrequent, so between them we interpolate using the remaining + * duration and [playbackSpeed]. Changing [seekGeneration] (after a snap or user seek) cancels + * the current tween and starts a new one from the updated cursor position. + * + * @param isPlaying Whether media is currently playing. Animation only runs while this is true. + * @param durationMs Total media duration in milliseconds. Must be `> 0` to animate. + * @param playbackSpeed Current playback rate (`1` = realtime). Faster speeds shorten the tween. + * @param seekGeneration Counter incremented when the cursor jumps, used to restart this effect. + * @param progressAnimated Animated cursor progress, tweened toward `1`. + */ +@Composable +private fun AnimateCursorWhilePlaying( + isPlaying: Boolean, + durationMs: Long, + playbackSpeed: Float, + seekGeneration: Int, + progressAnimated: Animatable, +) { + LaunchedEffect(isPlaying, durationMs, playbackSpeed, seekGeneration) { + if (isPlaying && durationMs > 0L) { + val remainingMs = remainingPlaybackAnimationMs( + progress = progressAnimated.value, + durationMs = durationMs, + playbackSpeed = playbackSpeed, + ) + if (remainingMs == 0) { + progressAnimated.snapTo(1f) + } else { + progressAnimated.animateTo( + targetValue = 1f, + animationSpec = tween(durationMillis = remainingMs, easing = LinearEasing), + ) + } + } + } +} + @PreviewsDayNight @Composable internal fun WaveformPlaybackViewPreview() = ElementPreview { diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/preferences/PreferenceDropdown.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/preferences/PreferenceDropdown.kt index 1072dd41d5..093791a71f 100644 --- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/preferences/PreferenceDropdown.kt +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/preferences/PreferenceDropdown.kt @@ -55,6 +55,7 @@ fun PreferenceDropdown( supportingText: String? = null, enabled: Boolean = true, icon: ImageVector? = null, + dropDownIcon: ImageVector = CompoundIcons.ChevronDown(), @DrawableRes iconResourceId: Int? = null, showIconAreaIfNoIcon: Boolean = false, ) { @@ -84,13 +85,14 @@ fun PreferenceDropdown( trailingContent = ListItemContent.Custom( content = { enabled -> DropdownTrailingContent( + dropDownIcon = dropDownIcon, selectedOption = selectedOption, options = options, onSelectOption = onSelectOption, expanded = isDropdownExpanded, onExpandedChange = { isDropdownExpanded = it }, enabled = enabled, - modifier = Modifier.fillMaxSize(0.3f) + modifier = Modifier.fillMaxSize(0.3f), ) } ), @@ -119,6 +121,7 @@ private fun DropdownTrailingContent( onSelectOption: (T) -> Unit, enabled: Boolean, modifier: Modifier = Modifier, + dropDownIcon: ImageVector = CompoundIcons.ChevronDown(), ) { Row( modifier = modifier, @@ -135,7 +138,7 @@ private fun DropdownTrailingContent( modifier = Modifier.weight(1f), ) Icon( - imageVector = CompoundIcons.ChevronDown(), + imageVector = dropDownIcon, contentDescription = null, tint = enabled.toIconSecondaryEnabledColor(), ) diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/HorizontalFloatingToolbar.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/HorizontalFloatingToolbar.kt index 8cdb320d30..74cdc33f17 100644 --- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/HorizontalFloatingToolbar.kt +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/HorizontalFloatingToolbar.kt @@ -8,17 +8,18 @@ package io.element.android.libraries.designsystem.theme.components import androidx.compose.animation.core.FiniteAnimationSpec -import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.RowScope import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.widthIn +import androidx.compose.material3.BadgedBox import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi -import androidx.compose.material3.FilledIconButton import androidx.compose.material3.FloatingToolbarColors import androidx.compose.material3.FloatingToolbarDefaults import androidx.compose.material3.FloatingToolbarHorizontalFabPosition @@ -28,7 +29,6 @@ import androidx.compose.material3.TooltipAnchorPosition import androidx.compose.material3.TooltipDefaults import androidx.compose.material3.rememberTooltipState 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 @@ -139,8 +139,20 @@ fun HorizontalFloatingToolbarItem( contentColor = ElementTheme.colors.iconSecondary, ) } - Box { - FilledIconButton( + BadgedBox( + badge = { + counter?.let { + CounterAtom( + count = it, + // Tweak the offset so it's centered on the icon + modifier = Modifier.offset(x = (-16).dp, y = 10.dp), + contentPadding = PaddingValues(vertical = 0.5.dp), + textStyle = ElementTheme.typography.fontBodySmMedium, + ) + } + } + ) { + IconButton( modifier = Modifier.widthIn(min = 56.dp), colors = colors, onClick = onClick, @@ -151,15 +163,6 @@ fun HorizontalFloatingToolbarItem( contentDescription = tooltipLabel, ) } - if (counter != null) { - CounterAtom( - modifier = Modifier - .align(Alignment.TopEnd) - .padding(top = 6.dp, end = 3.dp), - count = counter, - textStyle = ElementTheme.typography.fontBodyXsMedium, - ) - } } } } @@ -172,18 +175,47 @@ fun HorizontalFloatingToolbarSeparator(modifier: Modifier = Modifier) { @PreviewsDayNight @Composable internal fun HorizontalFloatingToolbarPreview() = ElementPreview { - ContentToPreview( - floatingActionButton = { - FloatingActionButton( - onClick = {}, - ) { - Icon( - imageVector = CompoundIcons.Plus(), - contentDescription = null, - ) + Column { + ContentToPreview( + count = 1, + floatingActionButton = { + FloatingActionButton( + onClick = {}, + ) { + Icon( + imageVector = CompoundIcons.Plus(), + contentDescription = null, + ) + } } - } - ) + ) + ContentToPreview( + count = 99, + floatingActionButton = { + FloatingActionButton( + onClick = {}, + ) { + Icon( + imageVector = CompoundIcons.Plus(), + contentDescription = null, + ) + } + } + ) + ContentToPreview( + count = 999, + floatingActionButton = { + FloatingActionButton( + onClick = {}, + ) { + Icon( + imageVector = CompoundIcons.Plus(), + contentDescription = null, + ) + } + } + ) + } } @PreviewsDayNight @@ -197,10 +229,11 @@ internal fun HorizontalFloatingToolbarNoFabPreview() = ElementPreview { @OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class) @Composable private fun ContentToPreview( + count: Int? = 6, floatingActionButton: (@Composable () -> Unit)?, ) { HorizontalFloatingToolbar( - modifier = Modifier.padding(28.dp), + modifier = Modifier.padding(horizontal = 28.dp, vertical = 16.dp), floatingActionButton = floatingActionButton, ) { listOf( @@ -214,7 +247,7 @@ private fun ContentToPreview( icon = icon, tooltipLabel = "Label", isSelected = index == 0, - counter = if (index == 0) 6 else null, + counter = if (index == 0) count else null, forceRenderingTooltip = true, onClick = { }, ) diff --git a/libraries/designsystem/src/test/kotlin/io/element/android/libraries/designsystem/components/media/WaveformPlaybackSeekTest.kt b/libraries/designsystem/src/test/kotlin/io/element/android/libraries/designsystem/components/media/WaveformPlaybackSeekTest.kt new file mode 100644 index 0000000000..fdb1611b75 --- /dev/null +++ b/libraries/designsystem/src/test/kotlin/io/element/android/libraries/designsystem/components/media/WaveformPlaybackSeekTest.kt @@ -0,0 +1,134 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.designsystem.components.media + +import android.view.MotionEvent +import com.google.common.truth.Truth.assertThat +import org.junit.Test + +class WaveformPlaybackSeekTest { + @Test + fun `a down event inside the waveform starts a seek`() { + val result = handleEvent(action = MotionEvent.ACTION_DOWN, x = 40f) + assertThat(result.consumed).isTrue() + assertThat(result.disallowParentIntercept).isTrue() + assertThat(result.seekProgress).isEqualTo(0.4f) + assertThat(result.committedSeek).isNull() + } + + @Test + fun `a down event outside the waveform is ignored`() { + val result = handleEvent(action = MotionEvent.ACTION_DOWN, x = 150f) + assertThat(result.consumed).isFalse() + assertThat(result.seekProgress).isNull() + assertThat(result.disallowParentIntercept).isNull() + } + + @Test + fun `a move event inside the waveform updates the seek progress`() { + val result = handleEvent(action = MotionEvent.ACTION_MOVE, x = 80f) + assertThat(result.consumed).isTrue() + assertThat(result.seekProgress).isEqualTo(0.8f) + assertThat(result.disallowParentIntercept).isNull() + } + + @Test + fun `a move event outside the waveform is still consumed without updating progress`() { + val result = handleEvent(action = MotionEvent.ACTION_MOVE, x = 150f) + assertThat(result.consumed).isTrue() + assertThat(result.seekProgress).isNull() + } + + @Test + fun `an up event commits the in-progress seek`() { + val result = handleEvent( + action = MotionEvent.ACTION_UP, + currentSeekProgress = 0.6f, + ) + assertThat(result.consumed).isTrue() + assertThat(result.disallowParentIntercept).isFalse() + assertThat(result.committedSeek).isEqualTo(0.6f) + assertThat(result.seekProgress).isNull() + assertThat(result.clearedSeekProgress).isTrue() + } + + @Test + fun `an up event without an in-progress seek does not commit`() { + val result = handleEvent(action = MotionEvent.ACTION_UP, currentSeekProgress = null) + assertThat(result.consumed).isTrue() + assertThat(result.committedSeek).isNull() + assertThat(result.clearedSeekProgress).isTrue() + } + + @Test + fun `a cancel event clears the in-progress seek without committing`() { + val result = handleEvent( + action = MotionEvent.ACTION_CANCEL, + currentSeekProgress = 0.6f, + ) + assertThat(result.consumed).isTrue() + assertThat(result.disallowParentIntercept).isFalse() + assertThat(result.committedSeek).isNull() + assertThat(result.clearedSeekProgress).isTrue() + } + + @Test + fun `unknown motion actions are ignored`() { + val result = handleEvent(action = MotionEvent.ACTION_POINTER_DOWN) + assertThat(result.consumed).isFalse() + assertThat(result.seekProgress).isNull() + assertThat(result.committedSeek).isNull() + } + + @Test + fun `seek progress is null when the pointer is outside the waveform`() { + assertThat(seekProgressFromPointerX(x = -1f, waveformWidthPx = 100f)).isNull() + assertThat(seekProgressFromPointerX(x = 101f, waveformWidthPx = 100f)).isNull() + assertThat(seekProgressFromPointerX(x = 0f, waveformWidthPx = 100f)).isEqualTo(0f) + assertThat(seekProgressFromPointerX(x = 100f, waveformWidthPx = 100f)).isEqualTo(1f) + } + + private fun handleEvent( + action: Int, + x: Float = 0f, + waveformWidthPx: Float = 100f, + currentSeekProgress: Float? = null, + ): HandleResult { + var disallowParentIntercept: Boolean? = null + var seekProgress: Float? = currentSeekProgress + var clearedSeekProgress = false + var committedSeek: Float? = null + val consumed = handleWaveformPointerEvent( + action = action, + x = x, + waveformWidthPx = waveformWidthPx, + currentSeekProgress = currentSeekProgress, + onDisallowParentIntercept = { disallowParentIntercept = it }, + onUpdateSeekProgress = { + seekProgress = it + if (it == null) clearedSeekProgress = true + }, + onCommitSeek = { committedSeek = it }, + ) + return HandleResult( + consumed = consumed, + disallowParentIntercept = disallowParentIntercept, + seekProgress = seekProgress, + clearedSeekProgress = clearedSeekProgress, + committedSeek = committedSeek, + ) + } + + private data class HandleResult( + val consumed: Boolean, + val disallowParentIntercept: Boolean?, + val seekProgress: Float?, + val clearedSeekProgress: Boolean, + val committedSeek: Float?, + ) +} diff --git a/libraries/designsystem/src/test/kotlin/io/element/android/libraries/designsystem/components/media/WaveformPlaybackSyncTest.kt b/libraries/designsystem/src/test/kotlin/io/element/android/libraries/designsystem/components/media/WaveformPlaybackSyncTest.kt new file mode 100644 index 0000000000..3066f179bd --- /dev/null +++ b/libraries/designsystem/src/test/kotlin/io/element/android/libraries/designsystem/components/media/WaveformPlaybackSyncTest.kt @@ -0,0 +1,280 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.designsystem.components.media + +import com.google.common.truth.Truth.assertThat +import org.junit.Test + +class WaveformPlaybackSyncTest { + @Test + fun `paused cursor always snaps to a different player sample`() { + assertThat( + shouldSnapToPlayer( + playbackProgress = 0.5f, + animatedProgress = 0.2f, + isPlaying = false, + durationMs = 10_000, + playbackSpeed = 1f, + ) + ).isTrue() + } + + @Test + fun `paused cursor does not snap when it already matches the player`() { + assertThat( + shouldSnapToPlayer( + playbackProgress = 0.5f, + animatedProgress = 0.5f, + isPlaying = false, + durationMs = 10_000, + playbackSpeed = 1f, + ) + ).isFalse() + } + + @Test + fun `playing cursor ignores drift under the sync threshold`() { + assertThat( + shouldSnapToPlayer( + playbackProgress = 0.21f, + animatedProgress = 0.2f, + isPlaying = true, + durationMs = 10_000, + playbackSpeed = 1f, + ) + ).isFalse() + } + + @Test + fun `playing cursor snaps when the player is ahead beyond the sync threshold`() { + assertThat( + shouldSnapToPlayer( + playbackProgress = 0.3f, + animatedProgress = 0.2f, + isPlaying = true, + durationMs = 10_000, + playbackSpeed = 1f, + ) + ).isTrue() + } + + @Test + fun `playing cursor does not snap back when the player lags behind the animation`() { + assertThat( + shouldSnapToPlayer( + playbackProgress = 0f, + animatedProgress = 0.05f, + isPlaying = true, + durationMs = 10_000, + playbackSpeed = 1f, + ) + ).isFalse() + } + + @Test + fun `playing cursor snaps back when playback restarts from the beginning`() { + assertThat( + shouldSnapToPlayer( + playbackProgress = 0f, + animatedProgress = 1f, + isPlaying = true, + durationMs = 10_000, + playbackSpeed = 1f, + ) + ).isTrue() + } + + @Test + fun `stale player samples are ignored until they catch up to a pending seek`() { + assertThat( + shouldApplyPlayerProgress( + playbackProgress = 0.2f, + pendingSeek = 0.8f, + durationMs = 10_000, + playbackSpeed = 1f, + ) + ).isFalse() + assertThat( + shouldApplyPlayerProgress( + playbackProgress = 0.8f, + pendingSeek = 0.8f, + durationMs = 10_000, + playbackSpeed = 1f, + ) + ).isTrue() + } + + @Test + fun `player samples apply immediately when there is no pending seek`() { + assertThat( + shouldApplyPlayerProgress( + playbackProgress = 0.2f, + pendingSeek = null, + durationMs = 10_000, + playbackSpeed = 1f, + ) + ).isTrue() + } + + @Test + fun `playing cursor snaps when duration is unknown`() { + assertThat( + shouldSnapToPlayer( + playbackProgress = 0.5f, + animatedProgress = 0.2f, + isPlaying = true, + durationMs = 0, + playbackSpeed = 1f, + ) + ).isTrue() + } + + @Test + fun `playing cursor does not snap when drift is exactly the sync threshold`() { + assertThat( + shouldSnapToPlayer( + playbackProgress = 0.22f, + animatedProgress = 0.2f, + isPlaying = true, + durationMs = 10_000, + playbackSpeed = 1f, + ) + ).isFalse() + } + + @Test + fun `playing cursor snaps back at the restart progress boundary`() { + assertThat( + shouldSnapToPlayer( + playbackProgress = 0.01f, + animatedProgress = 0.9f, + isPlaying = true, + durationMs = 10_000, + playbackSpeed = 1f, + ) + ).isTrue() + assertThat( + shouldSnapToPlayer( + playbackProgress = 0.011f, + animatedProgress = 0.9f, + isPlaying = true, + durationMs = 10_000, + playbackSpeed = 1f, + ) + ).isFalse() + assertThat( + shouldSnapToPlayer( + playbackProgress = 0f, + animatedProgress = 0.89f, + isPlaying = true, + durationMs = 10_000, + playbackSpeed = 1f, + ) + ).isFalse() + } + + @Test + fun `faster playback treats the same progress delta as a smaller time gap`() { + assertThat( + shouldSnapToPlayer( + playbackProgress = 0.3f, + animatedProgress = 0.2f, + isPlaying = true, + durationMs = 10_000, + playbackSpeed = 8f, + ) + ).isFalse() + } + + @Test + fun `a pending seek is acknowledged when duration is unknown and the sample is close`() { + assertThat( + hasCaughtUpToSeek( + playbackProgress = 0.8f, + pendingSeek = 0.8f, + durationMs = 0, + playbackSpeed = 1f, + ) + ).isTrue() + assertThat( + hasCaughtUpToSeek( + playbackProgress = 0.82f, + pendingSeek = 0.8f, + durationMs = 0, + playbackSpeed = 1f, + ) + ).isFalse() + assertThat( + shouldApplyPlayerProgress( + playbackProgress = 0.805f, + pendingSeek = 0.8f, + durationMs = 0, + playbackSpeed = 1f, + ) + ).isTrue() + } + + @Test + fun `a pending seek is acknowledged at the exact sync threshold`() { + assertThat( + hasCaughtUpToSeek( + playbackProgress = 0.82f, + pendingSeek = 0.8f, + durationMs = 10_000, + playbackSpeed = 1f, + ) + ).isTrue() + } + + @Test + fun `zero playback speed still produces a finite remaining animation duration`() { + assertThat( + remainingPlaybackAnimationMs( + progress = 0.5f, + durationMs = 10_000, + playbackSpeed = 0f, + ) + ).isEqualTo(500_000) + } + + @Test + fun `remaining animation duration is zero when the cursor is already at the end`() { + assertThat( + remainingPlaybackAnimationMs( + progress = 1f, + durationMs = 10_000, + playbackSpeed = 1f, + ) + ).isEqualTo(0) + assertThat( + remainingPlaybackAnimationMs( + progress = 1.2f, + durationMs = 10_000, + playbackSpeed = 1f, + ) + ).isEqualTo(0) + } + + @Test + fun `remaining animation duration scales with leftover progress and speed`() { + assertThat( + remainingPlaybackAnimationMs( + progress = 0.25f, + durationMs = 10_000, + playbackSpeed = 1f, + ) + ).isEqualTo(7_500) + assertThat( + remainingPlaybackAnimationMs( + progress = 0.25f, + durationMs = 10_000, + playbackSpeed = 2f, + ) + ).isEqualTo(3_750) + } +} diff --git a/libraries/designsystem/src/test/kotlin/io/element/android/libraries/designsystem/components/media/WaveformPlaybackViewTest.kt b/libraries/designsystem/src/test/kotlin/io/element/android/libraries/designsystem/components/media/WaveformPlaybackViewTest.kt new file mode 100644 index 0000000000..5ded20522c --- /dev/null +++ b/libraries/designsystem/src/test/kotlin/io/element/android/libraries/designsystem/components/media/WaveformPlaybackViewTest.kt @@ -0,0 +1,127 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +@file:OptIn(ExperimentalTestApi::class) + +package io.element.android.libraries.designsystem.components.media + +import androidx.activity.ComponentActivity +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.ui.Modifier +import androidx.compose.ui.test.AndroidComposeUiTest +import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.v2.runAndroidComposeUiTest +import androidx.compose.ui.unit.dp +import io.element.android.compound.theme.ElementTheme +import io.element.android.tests.testutils.robolectric.RobolectricTest +import io.element.android.tests.testutils.setSafeContent +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf +import org.junit.Test + +class WaveformPlaybackViewTest : RobolectricTest() { + @Test + fun `seeking can be disabled`() = runAndroidComposeUiTest { + setWaveformPlaybackView(seekEnabled = false) + waitForIdle() + } + + @Test + fun `a paused cursor follows a new player sample`() = runAndroidComposeUiTest { + val playbackProgress = mutableFloatStateOf(0.2f) + setWaveformPlaybackView(playbackProgress = { playbackProgress.value }) + playbackProgress.value = 0.8f + waitForIdle() + } + + @Test + fun `playing with a positive duration animates the cursor`() = runAndroidComposeUiTest { + setWaveformPlaybackView( + playbackProgress = { 0f }, + isPlaying = true, + durationMs = 50L, + ) + waitForIdle() + } + + @Test + fun `playing at the end of the media snaps the cursor without tweening`() = runAndroidComposeUiTest { + setWaveformPlaybackView( + playbackProgress = { 1f }, + isPlaying = true, + durationMs = 1_000L, + ) + waitForIdle() + } + + @Test + fun `playing with an unknown duration does not animate`() = runAndroidComposeUiTest { + setWaveformPlaybackView( + playbackProgress = { 0.3f }, + isPlaying = true, + durationMs = 0L, + ) + waitForIdle() + } + + @Test + fun `playing cursor keeps interpolating when the player is only slightly ahead`() = runAndroidComposeUiTest { + val playbackProgress = mutableFloatStateOf(0.2f) + setWaveformPlaybackView( + playbackProgress = { playbackProgress.value }, + isPlaying = true, + durationMs = 10_000L, + ) + playbackProgress.value = 0.21f + waitForIdle() + } + + @Test + fun `an empty waveform still renders with a cursor`() = runAndroidComposeUiTest { + setWaveformPlaybackView( + waveform = persistentListOf(), + showCursor = true, + ) + waitForIdle() + } + + @Test + fun `a short waveform still renders`() = runAndroidComposeUiTest { + setWaveformPlaybackView(waveform = persistentListOf(0.2f, 0.4f, 0.6f)) + waitForIdle() + } +} + +private fun AndroidComposeUiTest.setWaveformPlaybackView( + playbackProgress: () -> Float = { 0.5f }, + showCursor: Boolean = false, + waveform: ImmutableList = WaveFormSamples.realisticWaveForm, + isPlaying: Boolean = false, + durationMs: Long = 0L, + playbackSpeed: Float = 1f, + seekEnabled: Boolean = true, +) { + setSafeContent { + ElementTheme { + WaveformPlaybackView( + playbackProgress = playbackProgress(), + showCursor = showCursor, + waveform = waveform, + onSeek = {}, + modifier = Modifier + .fillMaxWidth() + .height(34.dp), + isPlaying = isPlaying, + durationMs = durationMs, + playbackSpeed = playbackSpeed, + seekEnabled = seekEnabled, + ) + } + } +} diff --git a/libraries/encrypted-db/src/main/kotlin/io/element/encrypteddb/crypto/EncryptedFile.kt b/libraries/encrypted-db/src/main/kotlin/io/element/encrypteddb/crypto/EncryptedFile.kt index ee6b381e08..488928782f 100644 --- a/libraries/encrypted-db/src/main/kotlin/io/element/encrypteddb/crypto/EncryptedFile.kt +++ b/libraries/encrypted-db/src/main/kotlin/io/element/encrypteddb/crypto/EncryptedFile.kt @@ -25,7 +25,7 @@ import java.io.FileOutputStream * * It uses hardcoded constants that are used in that library, for backwards compatibility reasons. */ -internal class EncryptedFile( +class EncryptedFile( private val context: Context, private val file: File ) { diff --git a/libraries/eventformatter/impl/src/main/res/values-zh/translations.xml b/libraries/eventformatter/impl/src/main/res/values-zh/translations.xml index 3b8811ba37..9be5fb54d4 100644 --- a/libraries/eventformatter/impl/src/main/res/values-zh/translations.xml +++ b/libraries/eventformatter/impl/src/main/res/values-zh/translations.xml @@ -49,10 +49,10 @@ "你未产生任何更改" "%1$s 更改了已置顶的消息" "你更改了已置顶的消息" - "%1$s 已置顶 1 个消息" - "你已置顶 1 个消息" - "%1$s 已取消置顶 1 个消息" - "你取消置顶了 1 个消息" + "%1$s 已置顶 1 条消息" + "你已置顶 1 条消息" + "%1$s 已取消置顶 1 条消息" + "你已取消置顶 1 条消息" "%1$s 拒绝了邀请" "你拒绝了邀请" "%1$s 已移除 %2$s" diff --git a/libraries/featureflag/api/src/main/kotlin/io/element/android/libraries/featureflag/api/FeatureFlags.kt b/libraries/featureflag/api/src/main/kotlin/io/element/android/libraries/featureflag/api/FeatureFlags.kt index af7ccef97f..98bb55fecf 100644 --- a/libraries/featureflag/api/src/main/kotlin/io/element/android/libraries/featureflag/api/FeatureFlags.kt +++ b/libraries/featureflag/api/src/main/kotlin/io/element/android/libraries/featureflag/api/FeatureFlags.kt @@ -116,13 +116,6 @@ enum class FeatureFlags( defaultValue = { false }, isFinished = false, ), - RoomThreadList( - key = "feature.room_thread_list", - title = "Add a list of threads in a room", - description = "Add a new screen with a list of threads in a room.", - defaultValue = { false }, - isFinished = false, - ), AutomaticBackPagination( key = "feature.automatic_back_pagination", title = "Automatic back pagination of rooms", @@ -161,4 +154,12 @@ enum class FeatureFlags( defaultValue = { false }, isFinished = false, ), + NewTimelineEventRenderer( + key = "feature.new_timeline_event_renderer", + title = "New timeline event renderer", + description = "Render formatted message bodies natively with Jetpack Compose instead of the rich text editor view.", + defaultValue = { false }, + isFinished = false, + isInLabs = false, + ), } diff --git a/libraries/htmlrenderer/api/build.gradle.kts b/libraries/htmlrenderer/api/build.gradle.kts new file mode 100644 index 0000000000..0a111fec36 --- /dev/null +++ b/libraries/htmlrenderer/api/build.gradle.kts @@ -0,0 +1,23 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +plugins { + id("io.element.android-compose-library") +} + +android { + namespace = "io.element.android.libraries.htmlrenderer.api" +} + +dependencies { + api(projects.libraries.matrix.api) + implementation(projects.libraries.designsystem) + implementation(projects.libraries.uiCommon) + implementation(projects.libraries.uiStrings) + + api(libs.jsoup) +} diff --git a/libraries/htmlrenderer/api/src/main/kotlin/io/element/android/libraries/htmlrenderer/api/HtmlMessageParser.kt b/libraries/htmlrenderer/api/src/main/kotlin/io/element/android/libraries/htmlrenderer/api/HtmlMessageParser.kt new file mode 100644 index 0000000000..95343313db --- /dev/null +++ b/libraries/htmlrenderer/api/src/main/kotlin/io/element/android/libraries/htmlrenderer/api/HtmlMessageParser.kt @@ -0,0 +1,41 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.htmlrenderer.api + +import io.element.android.libraries.designsystem.components.LINK_TAG +import org.jsoup.nodes.Document + +/** + * Parses the HTML [Document] of a formatted message body into a tree of [MessageNode]s + * that a Compose renderer can turn into composables. + * + * The [Document] is expected to have already been sanitized (e.g. via + * `FormattedBody.toHtmlDocument`) so that only supported tags remain. + */ +interface HtmlMessageParser { + /** + * Converts [document] into a [DocumentNode]. Never throws: unknown or empty content + * yields a [DocumentNode] with no children. + */ + fun parse(document: Document): DocumentNode + + companion object { + /** + * Tag used for the string annotation attached to link (``) text ranges in a + * [ParagraphNode.text]. The annotation value is the link target URL. + */ + const val LINK_ANNOTATION_TAG = LINK_TAG + + /** + * Tag used for the string annotation attached to inline code (``) text ranges in a + * [ParagraphNode.text]. The annotation value is empty; only the range matters. A `` + * nested inside a `
` is part of the [CodeBlockNode] and is never annotated this way.
+         */
+        const val INLINE_CODE_ANNOTATION_TAG = "inline_code"
+    }
+}
diff --git a/libraries/htmlrenderer/api/src/main/kotlin/io/element/android/libraries/htmlrenderer/api/MentionNodeContent.kt b/libraries/htmlrenderer/api/src/main/kotlin/io/element/android/libraries/htmlrenderer/api/MentionNodeContent.kt
new file mode 100644
index 0000000000..4886dcef23
--- /dev/null
+++ b/libraries/htmlrenderer/api/src/main/kotlin/io/element/android/libraries/htmlrenderer/api/MentionNodeContent.kt
@@ -0,0 +1,42 @@
+/*
+ * Copyright (c) 2026 Element Creations Ltd.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
+ * Please see LICENSE files in the repository root for full details.
+ */
+
+package io.element.android.libraries.htmlrenderer.api
+
+import androidx.compose.runtime.Immutable
+import io.element.android.libraries.matrix.api.core.RoomIdOrAlias
+import io.element.android.libraries.matrix.api.core.UserId
+
+/**
+ * Describes a mention "pill" referenced from a [ParagraphNode].
+ *
+ * Each mention occupies an inline-content placeholder inside the paragraph's
+ * [ParagraphNode.text]; the renderer looks the id up in [ParagraphNode.inlineContent]
+ * and draws the corresponding pill.
+ */
+@Immutable
+sealed interface MentionNodeContent {
+    /** The text shown inside the pill (e.g. a display name, prefixed with `@` for users). */
+    val displayText: String
+
+    /** A mention of a specific user (``). */
+    data class User(
+        override val displayText: String,
+        val userId: UserId,
+    ) : MentionNodeContent
+
+    /** A mention of a room (``). */
+    data class Room(
+        override val displayText: String,
+        val roomIdOrAlias: RoomIdOrAlias,
+    ) : MentionNodeContent
+
+    /** An `@room` mention notifying everyone in the room. */
+    data class Everyone(
+        override val displayText: String,
+    ) : MentionNodeContent
+}
diff --git a/libraries/htmlrenderer/api/src/main/kotlin/io/element/android/libraries/htmlrenderer/api/MessageNode.kt b/libraries/htmlrenderer/api/src/main/kotlin/io/element/android/libraries/htmlrenderer/api/MessageNode.kt
new file mode 100644
index 0000000000..0b28b0cd1a
--- /dev/null
+++ b/libraries/htmlrenderer/api/src/main/kotlin/io/element/android/libraries/htmlrenderer/api/MessageNode.kt
@@ -0,0 +1,107 @@
+/*
+ * Copyright (c) 2026 Element Creations Ltd.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
+ * Please see LICENSE files in the repository root for full details.
+ */
+
+package io.element.android.libraries.htmlrenderer.api
+
+import androidx.compose.runtime.Immutable
+import androidx.compose.ui.text.AnnotatedString
+import kotlinx.collections.immutable.ImmutableList
+import kotlinx.collections.immutable.ImmutableMap
+import kotlinx.collections.immutable.persistentMapOf
+
+/**
+ * A node in the parsed representation of a formatted (HTML) message body.
+ *
+ * The tree is produced by [HtmlMessageParser] and consumed by a Compose renderer.
+ * Block-level nodes ([BlockNode]) either contain other block nodes (nested lists, a
+ * quote containing paragraphs, ...) or terminate in a [ParagraphNode] / [CodeBlockNode]
+ * that carries the actual text.
+ */
+@Immutable
+sealed interface MessageNode
+
+/** A block-level node, laid out vertically by the renderer. */
+@Immutable
+sealed interface BlockNode : MessageNode
+
+/**
+ * The root of a parsed message: an ordered list of block-level children.
+ */
+@Immutable
+data class DocumentNode(
+    val children: ImmutableList,
+) : BlockNode
+
+/**
+ * A run of inline content (text with inline formatting) already flattened to an
+ * [AnnotatedString]. This is the only leaf that carries styled text.
+ *
+ * Inline formatting (``, ``, ``, ``, inline ``) is encoded as
+ * [androidx.compose.ui.text.SpanStyle] ranges, and links (``) as string annotations
+ * tagged [HtmlMessageParser.LINK_ANNOTATION_TAG].
+ *
+ * Mentions (pills) are encoded as inline-content placeholders inside [text] (see
+ * [androidx.compose.foundation.text.appendInlineContent]); [inlineContent] maps each
+ * placeholder id to the [MentionNodeContent] the renderer needs to draw the pill.
+ */
+@Immutable
+data class ParagraphNode(
+    val text: AnnotatedString,
+    val inlineContent: ImmutableMap = persistentMapOf(),
+) : BlockNode
+
+/**
+ * A heading (`

`–`

`). Like a [ParagraphNode] it carries inline content ([text] and + * [inlineContent]), plus the heading [level] from 1 (largest) to 6 (smallest), used by the renderer + * to pick the typography. + */ +@Immutable +data class HeaderNode( + val level: Int, + val text: AnnotatedString, + val inlineContent: ImmutableMap = persistentMapOf(), +) : BlockNode + +/** + * A preformatted code block (`
`). Rendered in a monospaced, horizontally
+ * scrollable container. The text is kept raw: no inline styling is applied inside it.
+ */
+@Immutable
+data class CodeBlockNode(
+    val code: String,
+) : BlockNode
+
+/**
+ * A block quote (`
`). Quotes can nest and can contain any block content. + */ +@Immutable +data class QuoteNode( + val children: ImmutableList, +) : BlockNode + +/** + * An ordered (`
    `) or unordered (`
      `) list. + * + * @param ordered whether the list is numbered (`
        `) or bulleted (`
          `). + * @param startIndex the number the first item of an ordered list starts at (honors `ol[start]`, default 1). + * @param items the list items, in document order. + */ +@Immutable +data class ListNode( + val ordered: Boolean, + val startIndex: Int, + val items: ImmutableList, +) : BlockNode + +/** + * A single list item (`
        • `). Being a block container, an item can itself hold + * paragraphs, nested lists or quotes. + */ +@Immutable +data class ListItemNode( + val children: ImmutableList, +) : MessageNode diff --git a/libraries/htmlrenderer/api/src/main/kotlin/io/element/android/libraries/htmlrenderer/api/spans/InlineCodeSpanStyle.kt b/libraries/htmlrenderer/api/src/main/kotlin/io/element/android/libraries/htmlrenderer/api/spans/InlineCodeSpanStyle.kt new file mode 100644 index 0000000000..1f3010660c --- /dev/null +++ b/libraries/htmlrenderer/api/src/main/kotlin/io/element/android/libraries/htmlrenderer/api/spans/InlineCodeSpanStyle.kt @@ -0,0 +1,20 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.htmlrenderer.api.spans + +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.font.FontFamily +import io.element.android.compound.theme.ElementTheme + +/** + * The style for inline code spans. + */ +val InlineCodeSpanStyle: SpanStyle = SpanStyle( + fontFamily = FontFamily.Monospace, + fontSize = ElementTheme.typography.fontBodySmRegular.fontSize, +) diff --git a/libraries/htmlrenderer/api/src/main/kotlin/io/element/android/libraries/htmlrenderer/api/ui/ContentAvoidingHtmlMessageContentPreview.kt b/libraries/htmlrenderer/api/src/main/kotlin/io/element/android/libraries/htmlrenderer/api/ui/ContentAvoidingHtmlMessageContentPreview.kt new file mode 100644 index 0000000000..9bba8807af --- /dev/null +++ b/libraries/htmlrenderer/api/src/main/kotlin/io/element/android/libraries/htmlrenderer/api/ui/ContentAvoidingHtmlMessageContentPreview.kt @@ -0,0 +1,70 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.htmlrenderer.api.ui + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.safeContent +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import io.element.android.libraries.designsystem.theme.components.Text +import io.element.android.libraries.htmlrenderer.api.CodeBlockNode +import io.element.android.libraries.htmlrenderer.api.DocumentNode +import io.element.android.libraries.htmlrenderer.api.ListItemNode +import io.element.android.libraries.htmlrenderer.api.ListNode +import io.element.android.libraries.htmlrenderer.api.ParagraphNode +import io.element.android.libraries.htmlrenderer.api.QuoteNode +import io.element.android.libraries.ui.common.layout.ContentAvoidingLayout +import kotlinx.collections.immutable.persistentListOf + +private val documentNode = DocumentNode( + persistentListOf( + ParagraphNode(AnnotatedString("Hello World, this is a")), + QuoteNode(persistentListOf(ParagraphNode(AnnotatedString("Quote with a very long text that wraps")))), + CodeBlockNode("fun main() {\n println(\"Hello, world!\")\n}"), + ListNode( + ordered = true, + startIndex = 0, + items = persistentListOf( + ListItemNode(persistentListOf(ParagraphNode(AnnotatedString("First item")))), + ListItemNode(persistentListOf(ParagraphNode(AnnotatedString("Second item")))), + ListItemNode(persistentListOf(ParagraphNode(AnnotatedString("Third item")))), + ) + ) + ) +) + +@Composable +private fun FakeTimestampView() { + Text(text = "12:34", modifier = Modifier.background(Color.LightGray).padding(start = 12.dp)) +} + +@Suppress("UnusedPrivateMember") +@Preview(showBackground = true) +@Composable +private fun ContentAvoidingLayoutHtmlMessageContentPreview() { + Box(modifier = Modifier.padding(WindowInsets.safeContent.asPaddingValues())) { + ContentAvoidingLayout( + overlay = { FakeTimestampView() }, + content = { + HtmlMessageContent( + modifier = Modifier.background(Color.Red), + node = documentNode, + onContentLayoutChange = this::onContentLayoutChange, + ) + } + ) + } +} diff --git a/libraries/htmlrenderer/api/src/main/kotlin/io/element/android/libraries/htmlrenderer/api/ui/HtmlMessageContent.kt b/libraries/htmlrenderer/api/src/main/kotlin/io/element/android/libraries/htmlrenderer/api/ui/HtmlMessageContent.kt new file mode 100644 index 0000000000..e113856cf7 --- /dev/null +++ b/libraries/htmlrenderer/api/src/main/kotlin/io/element/android/libraries/htmlrenderer/api/ui/HtmlMessageContent.kt @@ -0,0 +1,606 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.htmlrenderer.api.ui + +import android.content.ClipData +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.gestures.awaitEachGesture +import androidx.compose.foundation.gestures.awaitFirstDown +import androidx.compose.foundation.horizontalScroll +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.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +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.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.InlineTextContent +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.SideEffect +import androidx.compose.runtime.State +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.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.drawOutline +import androidx.compose.ui.graphics.drawscope.Fill +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.graphics.drawscope.translate +import androidx.compose.ui.input.pointer.PointerEventTimeoutCancellationException +import androidx.compose.ui.input.pointer.changedToUp +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.platform.ClipEntry +import androidx.compose.ui.platform.LocalClipboard +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalInspectionMode +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.Placeholder +import androidx.compose.ui.text.PlaceholderVerticalAlign +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.TextLayoutResult +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.rememberTextMeasurer +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.Density +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import io.element.android.compound.theme.ElementTheme +import io.element.android.libraries.designsystem.components.linkify +import io.element.android.libraries.designsystem.text.toPx +import io.element.android.libraries.designsystem.theme.components.Text +import io.element.android.libraries.htmlrenderer.api.BlockNode +import io.element.android.libraries.htmlrenderer.api.CodeBlockNode +import io.element.android.libraries.htmlrenderer.api.DocumentNode +import io.element.android.libraries.htmlrenderer.api.HeaderNode +import io.element.android.libraries.htmlrenderer.api.HtmlMessageParser.Companion.INLINE_CODE_ANNOTATION_TAG +import io.element.android.libraries.htmlrenderer.api.HtmlMessageParser.Companion.LINK_ANNOTATION_TAG +import io.element.android.libraries.htmlrenderer.api.ListNode +import io.element.android.libraries.htmlrenderer.api.MentionNodeContent +import io.element.android.libraries.htmlrenderer.api.ParagraphNode +import io.element.android.libraries.htmlrenderer.api.QuoteNode +import io.element.android.libraries.matrix.api.core.UserId +import io.element.android.libraries.ui.common.layout.ContentAvoidingLayout +import io.element.android.libraries.ui.common.layout.ContentAvoidingLayoutData +import io.element.android.libraries.ui.strings.CommonStrings +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.ImmutableMap +import kotlinx.collections.immutable.persistentMapOf +import kotlinx.collections.immutable.toImmutableMap +import kotlinx.coroutines.launch + +/** + * Renders a parsed message tree ([DocumentNode]) as native Compose content. + * + * The renderer is stateless: it is driven entirely by [node] and reports interactions + * through the callbacks. It performs no HTML parsing itself. + * + * @param node the root HTML node of the parsed message tree to render. + * @param modifier the [Modifier] to be applied to the layout. + * @param currentUserId the current user ID, if known. This can be used to display UI sent by/owned by the current user differently. + * @param onLinkClick invoked with the target URL when a link is tapped. + * @param onLinkLongClick invoked with the target URL when a link is long-pressed. + * @param onMentionClick invoked when a mention pill is tapped. + * @param onContentLayoutChange invoked with the measured layout of the very last rendered + * [ParagraphNode], so the timeline can lay out the timestamp around it. It is only wired to that + * last paragraph, and only when it is not nested inside a [CodeBlockNode] or [QuoteNode] — in which + * case no measurement is reported. + */ +@Composable +fun HtmlMessageContent( + node: DocumentNode, + modifier: Modifier = Modifier, + currentUserId: UserId? = null, + onLinkClick: (url: String, text: String) -> Unit = { _, _ -> }, + onLinkLongClick: (url: String, text: String) -> Unit = { _, _ -> }, + onMentionClick: (MentionNodeContent) -> Unit = {}, + onContentLayoutChange: (ContentAvoidingLayoutData) -> Unit = {}, +) { + val measuredParagraph = remember(node) { node.lastBlockNode() } + val context = remember( + currentUserId, + onLinkClick, + onLinkLongClick, + onMentionClick, + measuredParagraph, + onContentLayoutChange + ) { + RenderContext( + currentUserId = currentUserId, + onLinkClick = onLinkClick, + onLinkLongClick = onLinkLongClick, + onMentionClick = onMentionClick, + lastBlockNode = measuredParagraph, + onContentLayoutChange = onContentLayoutChange, + ) + } + BlockNodes(nodes = node.children, context = context, modifier = modifier) +} + +/** Interaction callbacks and state threaded through the render tree. */ +@Immutable +private data class RenderContext( + val currentUserId: UserId?, + val onLinkClick: (url: String, text: String) -> Unit, + val onLinkLongClick: (url: String, text: String) -> Unit, + val onMentionClick: (MentionNodeContent) -> Unit, + // The latest descendant BlockNode of the root DocumentNode + val lastBlockNode: BlockNode?, + val onContentLayoutChange: (ContentAvoidingLayoutData) -> Unit, +) + +internal fun BlockNode.lastBlockNode(): BlockNode? = when (this) { + is ParagraphNode -> this + is DocumentNode -> children.lastOrNull()?.lastBlockNode() + is ListNode -> this + is QuoteNode -> this + is CodeBlockNode -> this + is HeaderNode -> this +} + +@Composable +private fun BlockNodes( + nodes: ImmutableList, + context: RenderContext, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier, + verticalArrangement = Arrangement.spacedBy(BlockSpacing), + ) { + nodes.forEach { BlockNodeView(node = it, context = context) } + } +} + +@Composable +private fun BlockNodeView( + node: BlockNode, + context: RenderContext, + modifier: Modifier = Modifier, +) { + if (node === context.lastBlockNode && node !is ParagraphNode && node !is HeaderNode) { + SideEffect { context.onContentLayoutChange(ContentAvoidingLayoutData.NotOverlapping) } + } + + when (node) { + is DocumentNode -> BlockNodes(nodes = node.children, context = context, modifier = modifier) + is ParagraphNode -> { + ParagraphView(node = node, context = context, modifier = modifier) + } + is HeaderNode -> HeaderView(node = node, context = context, modifier = modifier) + is CodeBlockNode -> CodeBlockView(node = node, modifier = modifier) + is QuoteNode -> QuoteView(node = node, context = context, modifier = modifier) + is ListNode -> ListView(node = node, context = context, modifier = modifier) + } +} + +@Composable +private fun ParagraphView( + node: ParagraphNode, + context: RenderContext, + modifier: Modifier = Modifier, +) { + // Only the last rendered paragraph (and only when not inside a code block / quote) reports its + // measured last line to the timeline. + val measureLastLine = if (node === context.lastBlockNode) { + ContentAvoidingLayout.measureLastTextLine(onContentLayoutChange = context.onContentLayoutChange) + } else { + null + } + TextBlock( + text = node.text, + inlineContent = node.inlineContent, + textStyle = ElementTheme.typography.fontBodyMdRegular, + context = context, + modifier = modifier, + onTextLayout = { measureLastLine?.invoke(it) }, + ) +} + +@Composable +private fun HeaderView( + node: HeaderNode, + context: RenderContext, + modifier: Modifier = Modifier, +) { + // Only the last rendered paragraph (and only when not inside a code block / quote) reports its + // measured last line to the timeline. + val measureLastLine = if (node === context.lastBlockNode) { + ContentAvoidingLayout.measureLastTextLine(onContentLayoutChange = context.onContentLayoutChange) + } else { + null + } + TextBlock( + text = node.text, + inlineContent = node.inlineContent, + textStyle = headerTextStyle(node.level), + context = context, + modifier = modifier, + onTextLayout = { measureLastLine?.invoke(it) } + ) +} + +/** + * Renders an inline-content block (a paragraph or a header) as a [Text] with [textStyle], applying + * link styling, the inline-code background boxes, mention/image inline content and link taps. + */ +@Composable +private fun TextBlock( + text: AnnotatedString, + inlineContent: ImmutableMap, + textStyle: TextStyle, + context: RenderContext, + modifier: Modifier = Modifier, + onTextLayout: (TextLayoutResult) -> Unit = {}, +) { + val linkColor = ElementTheme.colors.textLinkExternal + val codeBackgroundColor = ElementTheme.colors.bgSubtleSecondary + val codeBorderColor = ElementTheme.colors.borderInteractiveSecondary + val styledText = remember(text, linkColor) { + text.linkify(SpanStyle(color = linkColor)) + .applyLinkStyles(linkColor = linkColor) + } + val resolvedInlineContent = rememberMentionInlineContent(inlineContent, context) + val layoutResult = remember { mutableStateOf(null) } + Text( + text = styledText, + modifier = modifier + .drawInlineCodeBackgrounds(styledText, layoutResult, codeBackgroundColor, codeBorderColor, LocalDensity.current) + .linkTapHandler(styledText, layoutResult, context), + style = textStyle, + color = ElementTheme.colors.textPrimary, + inlineContent = resolvedInlineContent, + onTextLayout = { result -> + layoutResult.value = result + onTextLayout(result) + }, + ) +} + +/** Maps an `

          `–`

          ` [level] to a Compound typography style, largest first. */ +@Composable +private fun headerTextStyle(level: Int): TextStyle = when (level) { + // For safety reasons, use the same style for H1 and H2, to avoid people using H1 for 'shouting' spam that fills the timeline with huge text + 1, 2 -> ElementTheme.typography.fontHeadingLgBold + 3 -> ElementTheme.typography.fontHeadingMdBold + 4 -> ElementTheme.typography.fontHeadingSmMedium + 5 -> ElementTheme.typography.fontBodyLgMedium + else -> ElementTheme.typography.fontBodyMdMedium +} + +@Composable +private fun CodeBlockView( + node: CodeBlockNode, + modifier: Modifier = Modifier, +) { + val coroutineScope = rememberCoroutineScope() + val scrollState = rememberScrollState() + val localClipboard = LocalClipboard.current + val clipDataLabel = stringResource(CommonStrings.common_code_block) + Box( + modifier = modifier + .clip(RoundedCornerShape(8.dp)) + .border(width = 1.dp, color = ElementTheme.colors.borderInteractiveSecondary, shape = RoundedCornerShape(8.dp)) + .background(ElementTheme.colors.bgSubtleSecondary) + .height(IntrinsicSize.Min) + .combinedClickable( + onClick = {}, + onLongClick = { + coroutineScope.launch { + localClipboard.setClipEntry(ClipEntry(ClipData.newPlainText(clipDataLabel, node.code))) + } + }, + onLongClickLabel = stringResource(CommonStrings.action_copy_to_clipboard), + ), + ) { + Text( + modifier = Modifier.horizontalScroll(scrollState).padding(horizontal = 12.dp, vertical = 8.dp), + text = node.code, + style = ElementTheme.typography.fontBodySmRegular.copy(fontFamily = FontFamily.Monospace), + color = ElementTheme.colors.textPrimary, + softWrap = false, + ) + + // Previews and screenshots can't render the fading edge gradients properly, everything is obscured by them, so we skip them in the preview mode. + if (LocalInspectionMode.current.not() && (scrollState.canScrollForward || scrollState.canScrollBackward)) { + val progress by remember { + derivedStateOf { + scrollState.value.toFloat() / scrollState.maxValue + } + } + + val alpha by animateFloatAsState(targetValue = progress, label = "CodeBlockViewScrollAlpha") + + // Draw a fading edge gradient at the left and right of the code block, to hint that it is horizontally scrollable. + Box( + modifier = Modifier + .matchParentSize() + .background( + brush = Brush.horizontalGradient( + colors = listOf(ElementTheme.colors.bgSubtleTertiary.copy(alpha = alpha), Color.Transparent), + startX = 0f, + endX = CodeBlockFadingEdgeWidth.toPx(), + ) + ) + ) + + Box( + modifier = Modifier + .matchParentSize() + .background( + brush = Brush.horizontalGradient( + colors = listOf(Color.Transparent, ElementTheme.colors.bgSubtleTertiary.copy(alpha = 1f - alpha)), + startX = scrollState.viewportSize - CodeBlockFadingEdgeWidth.toPx(), + endX = scrollState.viewportSize.toFloat(), + ) + ) + ) + } + } +} + +@Composable +private fun QuoteView( + node: QuoteNode, + context: RenderContext, + modifier: Modifier = Modifier, +) { + Row(modifier = modifier.height(IntrinsicSize.Min)) { + Box( + modifier = Modifier + .width(2.dp) + .fillMaxHeight() + .clip(RoundedCornerShape(1.dp)) + .background(ElementTheme.colors.borderInteractiveSecondary), + ) + Spacer(modifier = Modifier.width(8.dp)) + BlockNodes(nodes = node.children, context = context) + } +} + +@Composable +private fun ListView( + node: ListNode, + context: RenderContext, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier, + verticalArrangement = Arrangement.spacedBy(ListItemSpacing), + ) { + node.items.forEachIndexed { index, item -> + Row { + Text( + text = if (node.ordered) "${node.startIndex + index}." else "•", + style = ElementTheme.typography.fontBodyMdRegular, + color = ElementTheme.colors.textPrimary, + modifier = Modifier.widthIn(min = ListMarkerWidth), + textAlign = if (node.ordered) TextAlign.Start else TextAlign.Center, + ) + BlockNodes(nodes = item.children, context = context) + } + } + } +} + +@Composable +private fun MentionPill( + mention: MentionNodeContent, + isCurrentUser: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + val backgroundColor = if (isCurrentUser) ElementTheme.colors.bgBadgeAccent else ElementTheme.colors.bgBadgePrimary + val textColor = if (isCurrentUser) ElementTheme.colors.textBadgeAccent else ElementTheme.colors.textOnSolidPrimary + Box( + modifier = modifier + .fillMaxSize() + .clip(RoundedCornerShape(percent = 50)) + .background(backgroundColor) + .clickable(onClick = onClick) + .padding(horizontal = PillPaddingHorizontal, vertical = PillPaddingVertical), + contentAlignment = Alignment.Center, + ) { + Text( + text = mention.displayText, + style = ElementTheme.typography.fontBodyLgMedium, + color = textColor, + maxLines = 1, + ) + } +} + +/** + * Builds an [InlineTextContent] entry for each mention placeholder. Each pill is measured so + * the placeholder reserves exactly the space the pill needs. + */ +@Composable +private fun rememberMentionInlineContent( + mentions: ImmutableMap, + context: RenderContext, +): ImmutableMap { + if (mentions.isEmpty()) return persistentMapOf() + val textMeasurer = rememberTextMeasurer() + val pillTextStyle = ElementTheme.typography.fontBodyLgMedium + val density = LocalDensity.current + return mentions.mapValues { (_, mention) -> + val measured = textMeasurer.measure(mention.displayText, pillTextStyle) + val width = with(density) { measured.size.width.toDp() + PillPaddingHorizontal * 2 } + val height = with(density) { measured.size.height.toDp() + PillPaddingVertical * 2 } + InlineTextContent( + placeholder = Placeholder( + width = with(density) { width.toSp() }, + height = with(density) { height.toSp() }, + placeholderVerticalAlign = PlaceholderVerticalAlign.Center, + ), + ) { + MentionPill( + mention = mention, + isCurrentUser = mention is MentionNodeContent.User && mention.userId == context.currentUserId, + onClick = { context.onMentionClick(mention) }, + ) + } + }.toImmutableMap() +} + +/** Overlays the theme-dependent link color onto the link-annotated ranges. */ +private fun AnnotatedString.applyLinkStyles(linkColor: Color): AnnotatedString { + val linkRanges = getStringAnnotations(LINK_ANNOTATION_TAG, 0, length) + if (linkRanges.isEmpty()) return this + return buildAnnotatedString { + append(this@applyLinkStyles) + linkRanges.forEach { linkRange -> + val containsColorSpan = + spanStyles.any { colorRange -> colorRange.start == linkRange.start && colorRange.end == linkRange.end && colorRange.item.color == linkColor } + if (!containsColorSpan) { + addStyle(SpanStyle(color = linkColor), linkRange.start, linkRange.end) + } + } + } +} + +/** + * Draws a rounded, bordered box behind each inline code ([INLINE_CODE_ANNOTATION_TAG]) range, so + * inline code matches the look of [CodeBlockView] but only covers the annotated text. A range that + * wraps across several lines gets one box per line. + */ +private fun Modifier.drawInlineCodeBackgrounds( + text: AnnotatedString, + layoutResult: State, + backgroundColor: Color, + borderColor: Color, + density: Density, +): Modifier = drawBehind { + val layout = layoutResult.value ?: return@drawBehind + val codeRanges = text.getStringAnnotations(INLINE_CODE_ANNOTATION_TAG, 0, text.length) + if (codeRanges.isEmpty()) return@drawBehind + val strokeWidth = InlineCodeBorderWidth.toPx() + val horizontalPadding = InlineCodeHorizontalPadding.toPx() + codeRanges.forEach { range -> + val firstLine = layout.getLineForOffset(range.start) + val lastLine = layout.getLineForOffset(range.end) + for (line in firstLine..lastLine) { + val lineStart = maxOf(range.start, layout.getLineStart(line)) + val lineEnd = minOf(range.end, layout.getLineEnd(line, visibleEnd = true)) + if (lineEnd <= lineStart) continue + val startX = layout.getHorizontalPosition(lineStart, usePrimaryDirection = true) + val endX = layout.getHorizontalPosition(lineEnd, usePrimaryDirection = true) + val left = minOf(startX, endX) - horizontalPadding + val right = maxOf(startX, endX) + horizontalPadding + val topLeft = Offset(left, layout.getLineTop(line)) + val size = Size(right - left, layout.getLineBottom(line) - layout.getLineTop(line)) + + val shape = RoundedCornerShape( + topStart = if (line == firstLine) InlineCodeCornerRadius else 0.dp, + topEnd = if (line == lastLine) InlineCodeCornerRadius else 0.dp, + bottomStart = if (line == firstLine) InlineCodeCornerRadius else 0.dp, + bottomEnd = if (line == lastLine) InlineCodeCornerRadius else 0.dp, + ) + + val outline = shape.createOutline( + size = size, + layoutDirection = layoutDirection, + density = density, + ) + + translate(topLeft.x, topLeft.y) { + drawOutline(outline = outline, color = backgroundColor, style = Fill) + drawOutline(outline = outline, color = borderColor, style = Stroke(width = strokeWidth)) + } + } + } +} + +/** + * Detects taps and long presses on link-annotated ranges ([LINK_ANNOTATION_TAG]) without consuming the touch events if none are detected, and invokes the + * appropriate callbacks in [context]. + */ +private fun Modifier.linkTapHandler( + text: AnnotatedString, + layoutResult: State, + context: RenderContext, +): Modifier = pointerInput(text) { + awaitEachGesture { + val down = awaitFirstDown(requireUnconsumed = true) + val (url, urlText) = layoutResult.value?.urlAt(down.position, text) ?: return@awaitEachGesture + down.consume() + + val longPressTimeout = viewConfiguration.longPressTimeoutMillis + + // Try detecting a long press by waiting for the long press timeout and checking if we received an up event before that. + // If we did, it's a tap, otherwise it's a long press. + // This warning is a false positive. + @Suppress("KotlinConstantConditions") + val isLongPress = try { + withTimeout(longPressTimeout) { + var receivedUp = false + while (!receivedUp) { + val event = awaitPointerEvent() + val up = event.changes.firstOrNull { it.id == down.id && it.changedToUp() } + if (up != null) { + receivedUp = true + up.consume() + } + } + + !receivedUp + } + } catch (_: PointerEventTimeoutCancellationException) { + true + } + + if (isLongPress) { + context.onLinkLongClick(url, urlText) + } else { + context.onLinkClick(url, urlText) + } + } +} + +private fun TextLayoutResult.urlAt(offset: Offset, text: AnnotatedString): Pair? { + val position = getOffsetForPosition(offset) + val urlAnnotation = text.getStringAnnotations(LINK_ANNOTATION_TAG, position, position).firstOrNull() ?: return null + return urlAnnotation.item to text.substring(urlAnnotation.start, urlAnnotation.end) +} + +private val BlockSpacing: Dp = 8.dp +private val ListItemSpacing: Dp = 4.dp +private val ListMarkerWidth: Dp = 24.dp +private val PillPaddingHorizontal: Dp = 6.dp +private val PillPaddingVertical: Dp = 0.dp + +/** Rounded, bordered box drawn behind inline code. */ +private val InlineCodeCornerRadius: Dp = 6.dp +private val InlineCodeBorderWidth: Dp = 1.dp +private val InlineCodeHorizontalPadding: Dp = 2.dp + +/** The width of the fading edge gradient drawn at the left and right of a [CodeBlockView]. */ +private val CodeBlockFadingEdgeWidth: Dp = 32.dp diff --git a/libraries/htmlrenderer/api/src/main/kotlin/io/element/android/libraries/htmlrenderer/api/ui/HtmlMessageContentPreview.kt b/libraries/htmlrenderer/api/src/main/kotlin/io/element/android/libraries/htmlrenderer/api/ui/HtmlMessageContentPreview.kt new file mode 100644 index 0000000000..4187acd070 --- /dev/null +++ b/libraries/htmlrenderer/api/src/main/kotlin/io/element/android/libraries/htmlrenderer/api/ui/HtmlMessageContentPreview.kt @@ -0,0 +1,262 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.htmlrenderer.api.ui + +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.text.appendInlineContent +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.withAnnotation +import androidx.compose.ui.text.withStyle +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +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.htmlrenderer.api.BlockNode +import io.element.android.libraries.htmlrenderer.api.CodeBlockNode +import io.element.android.libraries.htmlrenderer.api.DocumentNode +import io.element.android.libraries.htmlrenderer.api.HeaderNode +import io.element.android.libraries.htmlrenderer.api.HtmlMessageParser.Companion.INLINE_CODE_ANNOTATION_TAG +import io.element.android.libraries.htmlrenderer.api.HtmlMessageParser.Companion.LINK_ANNOTATION_TAG +import io.element.android.libraries.htmlrenderer.api.ListItemNode +import io.element.android.libraries.htmlrenderer.api.ListNode +import io.element.android.libraries.htmlrenderer.api.MentionNodeContent +import io.element.android.libraries.htmlrenderer.api.ParagraphNode +import io.element.android.libraries.htmlrenderer.api.QuoteNode +import io.element.android.libraries.htmlrenderer.api.spans.InlineCodeSpanStyle +import io.element.android.libraries.matrix.api.core.UserId +import kotlinx.collections.immutable.ImmutableMap +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.persistentMapOf +import kotlinx.collections.immutable.toImmutableList + +@PreviewsDayNight +@Composable +internal fun HtmlMessageContentPreview( + @PreviewParameter(HtmlMessageContentPreviewParam::class) node: DocumentNode, +) = ElementPreview { + HtmlMessageContent( + node = node, + currentUserId = UserId("@me:example.org"), + modifier = Modifier.padding(16.dp), + ) +} + +internal class HtmlMessageContentPreviewParam : PreviewParameterProvider { + override val values: Sequence + get() = sequenceOf( + headers(), + richParagraph(), + inlineCode(), + multilineInlineCode(), + orderedList(), + quote(), + nestedQuotes(), + codeBlock(), + mention(), + mentionOwn(), + nestedList(), + nestedListWithComplexContents(), + linkifiedParagraph(), + ) + + private fun richParagraph() = document( + paragraph( + buildAnnotatedString { + append("Hello ") + withStyle(SpanStyle(fontWeight = FontWeight.Bold)) { append("bold") } + append(", ") + withStyle(SpanStyle(fontStyle = FontStyle.Italic)) { append("italic") } + append(" and a ") + val start = length + append("link") + addStringAnnotation(LINK_ANNOTATION_TAG, "https://element.io", start, length) + } + ), + ) + + private fun linkifiedParagraph() = document( + paragraph( + buildAnnotatedString { + append("Hello me@matrix.org, check out element.io and and call +1 234 567 8900, then get in touch with ") + appendInlineContent(MENTION_ID, "@alice:example.org") + append(".\n\n") + append("Also, ignore this link: ") + val start = length + append("https://matrix.org") + addStringAnnotation(LINK_ANNOTATION_TAG, "https://matrix.org", start, length) + append(" and this one: ") + withAnnotation(INLINE_CODE_ANNOTATION_TAG, "") { + append("https://element.io") + } + append(".") + }, + inlineContent = persistentMapOf( + MENTION_ID to MentionNodeContent.User(displayText = "@alice", userId = UserId("@alice:example.org")), + ), + ), + ) + + private fun inlineCode() = document( + paragraph( + buildAnnotatedString { + append("Run ") + val start = length + withStyle(InlineCodeSpanStyle) { append("git status") } + addStringAnnotation(INLINE_CODE_ANNOTATION_TAG, "", start, length) + append(" to see changes") + } + ), + ) + + private fun multilineInlineCode() = document( + QuoteNode(persistentListOf( + paragraph( + buildAnnotatedString { + append("Trying ") + val start = length + withStyle(InlineCodeSpanStyle) { + append("inline code when it needs to be wrapped in several lines, just to check what it looks like") + } + addStringAnnotation(INLINE_CODE_ANNOTATION_TAG, "", start, length) + append(", is it good?") + } + ), + )) + ) + + private fun orderedList() = document( + ListNode( + ordered = true, + startIndex = 1, + items = persistentListOf( + ListItemNode(persistentListOf(paragraph(AnnotatedString("First item")))), + ListItemNode(persistentListOf(paragraph(AnnotatedString("Second item")))), + ), + ), + ) + + private fun quote() = document( + paragraph(AnnotatedString("Someone said:")), + QuoteNode(persistentListOf(paragraph(AnnotatedString("This is a quoted message.")))), + ) + + private fun nestedQuotes() = document( + paragraph(AnnotatedString("Someone said:")), + QuoteNode(persistentListOf( + paragraph(AnnotatedString("This is a quoted message.")), + QuoteNode(persistentListOf( + paragraph(AnnotatedString("This is a nested quote.")), + codeBlock(), + )), + )), + ) + + private fun codeBlock() = document( + CodeBlockNode("fun main() {\n println(\"Hello, world!\")\n}"), + ) + + private fun mention() = document( + paragraph( + text = buildAnnotatedString { + append("Hey ") + appendInlineContent(MENTION_ID, "@alice") + append(", welcome!") + }, + inlineContent = persistentMapOf( + MENTION_ID to MentionNodeContent.User(displayText = "@alice", userId = UserId("@alice:example.org")), + ), + ), + ) + + private fun mentionOwn() = document( + paragraph( + text = buildAnnotatedString { + append("Hey ") + appendInlineContent(MENTION_ID, "@me") + append(", welcome!") + }, + inlineContent = persistentMapOf( + MENTION_ID to MentionNodeContent.User(displayText = "@me", userId = UserId("@me:example.org")), + ), + ), + ) + + private fun nestedList() = document( + ListNode( + ordered = false, + startIndex = 1, + items = persistentListOf( + ListItemNode( + persistentListOf( + paragraph(AnnotatedString("Parent item")), + ListNode( + ordered = false, + startIndex = 1, + items = persistentListOf( + ListItemNode(persistentListOf(paragraph(AnnotatedString("Nested item")))), + ), + ), + ), + ), + ), + ), + ) + + private fun nestedListWithComplexContents() = document( + ListNode( + ordered = false, + startIndex = 1, + items = persistentListOf( + ListItemNode( + persistentListOf( + paragraph(AnnotatedString("Parent item")), + ListNode( + ordered = false, + startIndex = 1, + items = persistentListOf( + ListItemNode(persistentListOf( + paragraph(AnnotatedString("Nested item")), + codeBlock(), + quote(), + )), + ), + ), + ), + ), + ), + ), + ) + + private fun headers() = document( + HeaderNode(level = 1, text = AnnotatedString("Heading 1")), + HeaderNode(level = 2, text = AnnotatedString("Heading 2")), + HeaderNode(level = 3, text = AnnotatedString("Heading 3")), + HeaderNode(level = 4, text = AnnotatedString("Heading 4")), + HeaderNode(level = 5, text = AnnotatedString("Heading 5")), + HeaderNode(level = 6, text = AnnotatedString("Heading 6")), + paragraph(AnnotatedString("Body text under the headings.")), + ) + + private fun document(vararg blocks: BlockNode) = DocumentNode(blocks.toList().toImmutableList()) + + private fun paragraph( + text: AnnotatedString, + inlineContent: ImmutableMap = persistentMapOf(), + ) = ParagraphNode(text = text, inlineContent = inlineContent) + + private companion object { + const val MENTION_ID = "mention_0" + } +} diff --git a/libraries/htmlrenderer/impl/build.gradle.kts b/libraries/htmlrenderer/impl/build.gradle.kts new file mode 100644 index 0000000000..ba17d855f1 --- /dev/null +++ b/libraries/htmlrenderer/impl/build.gradle.kts @@ -0,0 +1,35 @@ +import extension.setupDependencyInjection +import extension.testCommonDependencies + +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +plugins { + id("io.element.android-compose-library") +} + +android { + namespace = "io.element.android.libraries.htmlrenderer.impl" + + testOptions { + unitTests { + isIncludeAndroidResources = true + } + } +} + +setupDependencyInjection() + +dependencies { + api(projects.libraries.htmlrenderer.api) + implementation(projects.libraries.matrix.api) + implementation(projects.libraries.uiCommon) + implementation(libs.jsoup) + + testCommonDependencies(libs) + testImplementation(projects.libraries.matrix.test) +} diff --git a/libraries/htmlrenderer/impl/src/main/kotlin/io/element/android/libraries/htmlrenderer/impl/DefaultHtmlMessageParser.kt b/libraries/htmlrenderer/impl/src/main/kotlin/io/element/android/libraries/htmlrenderer/impl/DefaultHtmlMessageParser.kt new file mode 100644 index 0000000000..5d46920763 --- /dev/null +++ b/libraries/htmlrenderer/impl/src/main/kotlin/io/element/android/libraries/htmlrenderer/impl/DefaultHtmlMessageParser.kt @@ -0,0 +1,259 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.htmlrenderer.impl + +import androidx.compose.foundation.text.appendInlineContent +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.font.FontStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextDecoration +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.ContributesBinding +import io.element.android.libraries.htmlrenderer.api.BlockNode +import io.element.android.libraries.htmlrenderer.api.CodeBlockNode +import io.element.android.libraries.htmlrenderer.api.DocumentNode +import io.element.android.libraries.htmlrenderer.api.HeaderNode +import io.element.android.libraries.htmlrenderer.api.HtmlMessageParser +import io.element.android.libraries.htmlrenderer.api.HtmlMessageParser.Companion.INLINE_CODE_ANNOTATION_TAG +import io.element.android.libraries.htmlrenderer.api.HtmlMessageParser.Companion.LINK_ANNOTATION_TAG +import io.element.android.libraries.htmlrenderer.api.ListItemNode +import io.element.android.libraries.htmlrenderer.api.ListNode +import io.element.android.libraries.htmlrenderer.api.MentionNodeContent +import io.element.android.libraries.htmlrenderer.api.ParagraphNode +import io.element.android.libraries.htmlrenderer.api.QuoteNode +import io.element.android.libraries.htmlrenderer.api.spans.InlineCodeSpanStyle +import io.element.android.libraries.matrix.api.permalink.PermalinkData +import io.element.android.libraries.matrix.api.permalink.PermalinkParser +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.ImmutableMap +import kotlinx.collections.immutable.toImmutableList +import kotlinx.collections.immutable.toImmutableMap +import org.jsoup.nodes.Document +import org.jsoup.nodes.Element +import org.jsoup.nodes.Node +import org.jsoup.nodes.TextNode + +/** + * Recursive-descent [HtmlMessageParser] over a (sanitized) jsoup [Document]. + * + * Block-level elements become their matching [BlockNode]; consecutive inline nodes are + * coalesced into a single [ParagraphNode] whose text is an [AnnotatedString]. + */ +@ContributesBinding(AppScope::class) +class DefaultHtmlMessageParser( + private val permalinkParser: PermalinkParser, +) : HtmlMessageParser { + override fun parse(document: Document): DocumentNode { + return DocumentNode(children = parseBlocks(document.body())) + } + + /** + * Walks the children of [parent], emitting a block node for each block-level element and + * buffering runs of inline content into [ParagraphNode]s in between. + */ + private fun parseBlocks(parent: Element): ImmutableList { + val blocks = mutableListOf() + val inlineBuffer = mutableListOf() + + fun flushInline() { + if (inlineBuffer.isNotEmpty()) { + buildParagraph(inlineBuffer)?.let(blocks::add) + inlineBuffer.clear() + } + } + + for (node in parent.childNodes()) { + when (node) { + // The fallback reply content is never rendered. + is Element if node.tagName() == TAG_MX_REPLY -> Unit + is Element if node.isBlockLevel() -> { + flushInline() + parseBlock(node)?.let(blocks::add) + } + else -> inlineBuffer.add(node) + } + } + flushInline() + return blocks.toImmutableList() + } + + private fun parseBlock(element: Element): BlockNode? = when (val tag = element.tagName()) { + TAG_P -> buildParagraph(element.childNodes()) + TAG_PRE -> CodeBlockNode(code = element.wholeText().trimEnd('\n')) + TAG_BLOCKQUOTE -> QuoteNode(children = parseBlocks(element)) + TAG_UL -> buildList(element, ordered = false) + TAG_OL -> buildList(element, ordered = true) + in HEADER_TAGS -> buildHeader(element, level = tag.removePrefix("h").toInt()) + else -> null + } + + private fun buildList(element: Element, ordered: Boolean): ListNode { + val startIndex = if (ordered) element.attr("start").toIntOrNull() ?: 1 else 1 + val items = element.childNodes() + .filterIsInstance() + .filter { it.tagName() == TAG_LI } + .map { ListItemNode(children = parseBlocks(it)) } + .toImmutableList() + return ListNode(ordered = ordered, startIndex = startIndex, items = items) + } + + /** Builds a [ParagraphNode] from a run of inline [nodes], or `null` if it holds no content. */ + private fun buildParagraph(nodes: List): ParagraphNode? { + return buildInlineContent(nodes)?.let { ParagraphNode(text = it.text, inlineContent = it.inlineContent) } + } + + /** Builds a [HeaderNode] of the given [level] from the inline content of [element], or `null` if empty. */ + private fun buildHeader(element: Element, level: Int): HeaderNode? { + return buildInlineContent(element.childNodes())?.let { + HeaderNode(level = level, text = it.text, inlineContent = it.inlineContent) + } + } + + private fun buildInlineContent(nodes: List): InlineContentBuilder.Result? { + val builder = InlineContentBuilder() + nodes.forEach { builder.append(it) } + return builder.build() + } + + /** + * Accumulates inline nodes into an [AnnotatedString] plus a map of mention placeholders. + * A fresh instance is used for each paragraph so placeholder ids stay local to it. + */ + private inner class InlineContentBuilder { + private val builder = AnnotatedString.Builder() + private val mentions = mutableMapOf() + private var mentionCount = 0 + + fun append(node: Node) { + when (node) { + is TextNode -> builder.append(node.text()) + is Element -> appendElement(node) + else -> Unit + } + } + + private fun appendElement(element: Element) { + when (element.tagName()) { + TAG_BR -> builder.append("\n") + TAG_B, TAG_STRONG -> withStyle(SpanStyle(fontWeight = FontWeight.Bold), element) + TAG_I, TAG_EM -> withStyle(SpanStyle(fontStyle = FontStyle.Italic), element) + TAG_U -> withStyle(SpanStyle(textDecoration = TextDecoration.Underline), element) + TAG_DEL, TAG_STRIKE_SHORT, TAG_STRIKE -> withStyle(SpanStyle(textDecoration = TextDecoration.LineThrough), element) + TAG_CODE -> appendInlineCode(element) + TAG_A -> appendLink(element) + TAG_MX_REPLY -> Unit + // Unknown inline wrapper: keep its text so nothing is dropped. + else -> appendChildren(element) + } + } + + private fun appendChildren(element: Element) { + element.childNodes().forEach { append(it) } + } + + private fun withStyle(style: SpanStyle, element: Element) { + val start = builder.length + appendChildren(element) + builder.addStyle(style, start, builder.length) + } + + /** + * Renders an inline `` as monospaced text and annotates the range so the renderer can + * apply a code background. A `` inside a `
          ` never reaches here: the `
          ` is
          +         * turned into a [CodeBlockNode] from its raw text.
          +         */
          +        private fun appendInlineCode(element: Element) {
          +            val start = builder.length
          +            appendChildren(element)
          +            if (builder.length > start) {
          +                builder.addStyle(InlineCodeSpanStyle, start, builder.length)
          +                builder.addStringAnnotation(INLINE_CODE_ANNOTATION_TAG, "", start, builder.length)
          +            }
          +        }
          +
          +        private fun appendLink(element: Element) {
          +            val href = element.attr("href")
          +            val mention = href.takeIf { it.isNotEmpty() }?.let { resolveMention(element, it) }
          +            if (mention != null) {
          +                val id = "$MENTION_ID_PREFIX${mentionCount++}"
          +                mentions[id] = mention
          +                builder.appendInlineContent(id, mention.displayText)
          +                return
          +            }
          +            // Only the link range is annotated; the visual style (color, ...) is applied by the renderer.
          +            val start = builder.length
          +            appendChildren(element)
          +            if (href.isNotEmpty() && builder.length > start) {
          +                builder.addStringAnnotation(LINK_ANNOTATION_TAG, href, start, builder.length)
          +            }
          +        }
          +
          +        private fun resolveMention(element: Element, href: String): MentionNodeContent? {
          +            return when (val permalink = permalinkParser.parse(href)) {
          +                is PermalinkData.UserLink -> MentionNodeContent.User(
          +                    displayText = element.text(),
          +                    userId = permalink.userId,
          +                )
          +                is PermalinkData.RoomLink -> if (permalink.eventId == null) {
          +                    MentionNodeContent.Room(
          +                        displayText = element.text(),
          +                        roomIdOrAlias = permalink.roomIdOrAlias,
          +                    )
          +                } else {
          +                    // A link to a specific event is a regular link, not a mention pill.
          +                    null
          +                }
          +                else -> null
          +            }
          +        }
          +
          +        fun build(): Result? {
          +            val text = builder.toAnnotatedString()
          +            if (text.isBlank() && mentions.isEmpty()) return null
          +            return Result(
          +                text = text,
          +                inlineContent = mentions.toImmutableMap(),
          +            )
          +        }
          +
          +        /** The flattened inline content of a block: styled text plus its inline-content placeholders. */
          +        inner class Result(
          +            val text: AnnotatedString,
          +            val inlineContent: ImmutableMap,
          +        )
          +    }
          +}
          +
          +/** Tags that start a new block-level node. Anything else is treated as inline content. */
          +private fun Element.isBlockLevel(): Boolean = tagName() in BLOCK_TAGS
          +
          +private val HEADER_TAGS = setOf("h1", "h2", "h3", "h4", "h5", "h6")
          +
          +private val BLOCK_TAGS = setOf(TAG_P, TAG_PRE, TAG_BLOCKQUOTE, TAG_UL, TAG_OL) + HEADER_TAGS
          +
          +private const val TAG_P = "p"
          +private const val TAG_PRE = "pre"
          +private const val TAG_BLOCKQUOTE = "blockquote"
          +private const val TAG_UL = "ul"
          +private const val TAG_OL = "ol"
          +private const val TAG_LI = "li"
          +private const val TAG_BR = "br"
          +private const val TAG_B = "b"
          +private const val TAG_STRONG = "strong"
          +private const val TAG_I = "i"
          +private const val TAG_EM = "em"
          +private const val TAG_U = "u"
          +private const val TAG_DEL = "del"
          +private const val TAG_STRIKE_SHORT = "s"
          +private const val TAG_STRIKE = "strike"
          +private const val TAG_CODE = "code"
          +private const val TAG_A = "a"
          +private const val TAG_MX_REPLY = "mx-reply"
          +
          +private const val MENTION_ID_PREFIX = "mention_"
          diff --git a/libraries/htmlrenderer/impl/src/test/kotlin/io/element/android/libraries/htmlrenderer/impl/DefaultHtmlMessageParserTest.kt b/libraries/htmlrenderer/impl/src/test/kotlin/io/element/android/libraries/htmlrenderer/impl/DefaultHtmlMessageParserTest.kt
          new file mode 100644
          index 0000000000..4c746524c1
          --- /dev/null
          +++ b/libraries/htmlrenderer/impl/src/test/kotlin/io/element/android/libraries/htmlrenderer/impl/DefaultHtmlMessageParserTest.kt
          @@ -0,0 +1,223 @@
          +/*
          + * Copyright (c) 2026 Element Creations Ltd.
          + *
          + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
          + * Please see LICENSE files in the repository root for full details.
          + */
          +
          +package io.element.android.libraries.htmlrenderer.impl
          +
          +import androidx.compose.ui.text.font.FontFamily
          +import androidx.compose.ui.text.font.FontStyle
          +import androidx.compose.ui.text.font.FontWeight
          +import com.google.common.truth.Truth.assertThat
          +import io.element.android.libraries.htmlrenderer.api.CodeBlockNode
          +import io.element.android.libraries.htmlrenderer.api.HeaderNode
          +import io.element.android.libraries.htmlrenderer.api.HtmlMessageParser
          +import io.element.android.libraries.htmlrenderer.api.ListNode
          +import io.element.android.libraries.htmlrenderer.api.MentionNodeContent
          +import io.element.android.libraries.htmlrenderer.api.ParagraphNode
          +import io.element.android.libraries.htmlrenderer.api.QuoteNode
          +import io.element.android.libraries.matrix.api.core.EventId
          +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.permalink.PermalinkData
          +import io.element.android.libraries.matrix.test.permalink.FakePermalinkParser
          +import io.element.android.tests.testutils.robolectric.RobolectricTest
          +import org.jsoup.Jsoup
          +import org.junit.Test
          +
          +class DefaultHtmlMessageParserTest : RobolectricTest() {
          +    @Test
          +    fun `plain text becomes a single paragraph`() {
          +        val document = parse("Hello world")
          +        val paragraph = document.children.single() as ParagraphNode
          +        assertThat(paragraph.text.text).isEqualTo("Hello world")
          +        assertThat(paragraph.inlineContent).isEmpty()
          +    }
          +
          +    @Test
          +    fun `inline formatting is encoded as span styles`() {
          +        val document = parse("Hello bold and italic")
          +        val paragraph = document.children.single() as ParagraphNode
          +        assertThat(paragraph.text.text).isEqualTo("Hello bold and italic")
          +
          +        val boldStart = paragraph.text.text.indexOf("bold")
          +        val bold = paragraph.text.spanStyles.single { it.item.fontWeight == FontWeight.Bold }
          +        assertThat(bold.start).isEqualTo(boldStart)
          +        assertThat(bold.end).isEqualTo(boldStart + "bold".length)
          +
          +        val italicStart = paragraph.text.text.indexOf("italic")
          +        val italic = paragraph.text.spanStyles.single { it.item.fontStyle == FontStyle.Italic }
          +        assertThat(italic.start).isEqualTo(italicStart)
          +        assertThat(italic.end).isEqualTo(italicStart + "italic".length)
          +    }
          +
          +    @Test
          +    fun `unordered list produces a bulleted ListNode`() {
          +        val document = parse("
          • First
          • Second
          ") + val list = document.children.single() as ListNode + assertThat(list.ordered).isFalse() + assertThat(list.startIndex).isEqualTo(1) + assertThat(list.items).hasSize(2) + val firstItem = list.items[0].children.single() as ParagraphNode + assertThat(firstItem.text.text).isEqualTo("First") + } + + @Test + fun `ordered list honors the start attribute`() { + val document = parse("""
          1. Fifth
          """) + val list = document.children.single() as ListNode + assertThat(list.ordered).isTrue() + assertThat(list.startIndex).isEqualTo(5) + } + + @Test + fun `a list nested inside a list item is preserved`() { + val document = parse("
          • Parent
            • Child
          ") + val outerList = document.children.single() as ListNode + val outerItem = outerList.items.single() + // The item holds a paragraph ("Parent") followed by the nested list. + val paragraph = outerItem.children[0] as ParagraphNode + assertThat(paragraph.text.text).isEqualTo("Parent") + val nestedList = outerItem.children[1] as ListNode + val nestedItem = nestedList.items.single().children.single() as ParagraphNode + assertThat(nestedItem.text.text).isEqualTo("Child") + } + + @Test + fun `blockquote wraps its block children`() { + val document = parse("

          Quoted

          ") + val quote = document.children.single() as QuoteNode + val paragraph = quote.children.single() as ParagraphNode + assertThat(paragraph.text.text).isEqualTo("Quoted") + } + + @Test + fun `blockquotes can nest`() { + val document = parse("

          Deep

          ") + val outer = document.children.single() as QuoteNode + val inner = outer.children.single() as QuoteNode + val paragraph = inner.children.single() as ParagraphNode + assertThat(paragraph.text.text).isEqualTo("Deep") + } + + @Test + fun `inline code is monospaced and annotated`() { + val document = parse("Run git status now") + val paragraph = document.children.single() as ParagraphNode + assertThat(paragraph.text.text).isEqualTo("Run git status now") + + val start = paragraph.text.text.indexOf("git status") + val end = start + "git status".length + val monospace = paragraph.text.spanStyles.single { it.item.fontFamily == FontFamily.Monospace } + assertThat(monospace.start).isEqualTo(start) + assertThat(monospace.end).isEqualTo(end) + + val codeAnnotations = paragraph.text.getStringAnnotations(HtmlMessageParser.INLINE_CODE_ANNOTATION_TAG, start, end) + assertThat(codeAnnotations).hasSize(1) + } + + @Test + fun `pre becomes a code block with raw text, and its inner code is not treated as inline code`() { + val document = parse("
          val x = 1\nval y = 2
          ") + val codeBlock = document.children.single() as CodeBlockNode + assertThat(codeBlock.code).isEqualTo("val x = 1\nval y = 2") + } + + @Test + fun `a regular link is styled and annotated with its url`() { + val permalinkParser = FakePermalinkParser { PermalinkData.FallbackLink(android.net.Uri.parse(it)) } + val document = parse("""Visit the site""", permalinkParser) + val paragraph = document.children.single() as ParagraphNode + assertThat(paragraph.inlineContent).isEmpty() + + val linkStart = paragraph.text.text.indexOf("the site") + val annotations = paragraph.text.getStringAnnotations( + tag = HtmlMessageParser.LINK_ANNOTATION_TAG, + start = linkStart, + end = linkStart + "the site".length, + ) + assertThat(annotations.single().item).isEqualTo("https://element.io") + } + + @Test + fun `a user link becomes a mention pill`() { + val userId = UserId("@alice:example.org") + val permalinkParser = FakePermalinkParser { PermalinkData.UserLink(userId) } + val document = parse("""Hi
          Alice""", permalinkParser) + val paragraph = document.children.single() as ParagraphNode + + val mention = paragraph.inlineContent.values.single() + assertThat(mention).isEqualTo(MentionNodeContent.User(displayText = "Alice", userId = userId)) + // The placeholder id in the text matches the map key. + val placeholderId = paragraph.inlineContent.keys.single() + val annotations = paragraph.text.getStringAnnotations(0, paragraph.text.length) + assertThat(annotations.any { it.item == placeholderId }).isTrue() + } + + @Test + fun `a room link becomes a room mention pill`() { + val roomIdOrAlias = RoomAlias("#room:example.org").toRoomIdOrAlias() + val permalinkParser = FakePermalinkParser { + PermalinkData.RoomLink(roomIdOrAlias = roomIdOrAlias) + } + val document = parse("""See the room""", permalinkParser) + val paragraph = document.children.single() as ParagraphNode + val mention = paragraph.inlineContent.values.single() + assertThat(mention).isEqualTo( + MentionNodeContent.Room(displayText = "the room", roomIdOrAlias = roomIdOrAlias) + ) + } + + @Test + fun `a link to an event is a regular link, not a mention`() { + val permalinkParser = FakePermalinkParser { + PermalinkData.RoomLink( + roomIdOrAlias = RoomId("!room:example.org").toRoomIdOrAlias(), + eventId = EventId("\$Rqnc-F-dvnEYJTyHq_iKxU2bZ1CI92-kuZq3a5lr5Zg"), + ) + } + val document = parse("""Look here""", permalinkParser) + val paragraph = document.children.single() as ParagraphNode + assertThat(paragraph.inlineContent).isEmpty() + } + + @Test + fun `mx-reply content is stripped`() { + val document = parse("In reply to somethingActual message") + val paragraph = document.children.single() as ParagraphNode + assertThat(paragraph.text.text).isEqualTo("Actual message") + } + + @Test + fun `headers become HeaderNodes with their level and inline content`() { + val document = parse("

          Title

          Subtitle with bold

          Small
          ") + assertThat(document.children).hasSize(3) + + val h1 = document.children[0] as HeaderNode + assertThat(h1.level).isEqualTo(1) + assertThat(h1.text.text).isEqualTo("Title") + + val h3 = document.children[1] as HeaderNode + assertThat(h3.level).isEqualTo(3) + assertThat(h3.text.text).isEqualTo("Subtitle with bold") + + val h6 = document.children[2] as HeaderNode + assertThat(h6.level).isEqualTo(6) + assertThat(h6.text.text).isEqualTo("Small") + } + + @Test + fun `blank input produces an empty document`() { + val document = parse(" ") + assertThat(document.children).isEmpty() + } + + private fun parse( + html: String, + permalinkParser: FakePermalinkParser = FakePermalinkParser(), + ) = DefaultHtmlMessageParser(permalinkParser).parse(Jsoup.parse(html)) +} diff --git a/libraries/htmlrenderer/test/build.gradle.kts b/libraries/htmlrenderer/test/build.gradle.kts new file mode 100644 index 0000000000..b44014bbca --- /dev/null +++ b/libraries/htmlrenderer/test/build.gradle.kts @@ -0,0 +1,18 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ +plugins { + id("io.element.android-library") +} + +android { + namespace = "io.element.android.libraries.htmlrenderer.test" +} + +dependencies { + implementation(projects.libraries.htmlrenderer.api) + implementation(projects.tests.testutils) +} diff --git a/libraries/htmlrenderer/test/src/main/kotlin/io/element/android/libraries/htmlrenderer/test/FakeHtmlMessageParser.kt b/libraries/htmlrenderer/test/src/main/kotlin/io/element/android/libraries/htmlrenderer/test/FakeHtmlMessageParser.kt new file mode 100644 index 0000000000..5f17517301 --- /dev/null +++ b/libraries/htmlrenderer/test/src/main/kotlin/io/element/android/libraries/htmlrenderer/test/FakeHtmlMessageParser.kt @@ -0,0 +1,19 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.htmlrenderer.test + +import io.element.android.libraries.htmlrenderer.api.DocumentNode +import io.element.android.libraries.htmlrenderer.api.HtmlMessageParser +import io.element.android.tests.testutils.lambda.lambdaError +import org.jsoup.nodes.Document + +class FakeHtmlMessageParser( + private val parseResult: (Document) -> DocumentNode = { lambdaError() }, +) : HtmlMessageParser { + override fun parse(document: Document): DocumentNode = parseResult(document) +} diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/MatrixClient.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/MatrixClient.kt index 3f98dcc509..e43f362e7c 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/MatrixClient.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/MatrixClient.kt @@ -326,6 +326,11 @@ interface MatrixClient : ClientUrlContentFetcher { */ suspend fun uploadMedia(mimeType: String, data: ByteArray): Result + /** + * Returns the latest [RoomInfo] of a room, if known to the client, or `null` if the room is unknown. + */ + suspend fun getRoomInfo(roomId: RoomId): Result + /** * Get a room info flow for a given room ID. * The flow will emit a new value whenever the room info is updated, skipping duplicates, and an empty [Optional] if the room is not found. diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/core/RoomId.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/core/RoomId.kt index 1758563f55..f4597de640 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/core/RoomId.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/core/RoomId.kt @@ -11,6 +11,7 @@ package io.element.android.libraries.matrix.api.core import io.element.android.libraries.androidutils.metadata.isInDebug import java.io.Serializable +@kotlinx.serialization.Serializable @JvmInline value class RoomId(val value: String) : Serializable { init { diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/user/MatrixUser.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/user/MatrixUser.kt index 7a8ec739a5..f27557bda2 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/user/MatrixUser.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/user/MatrixUser.kt @@ -19,4 +19,6 @@ data class MatrixUser( val avatarUrl: String? = null, val displayedStatus: DisplayedStatus? = null, val rawStatus: UserStatus? = null, + /** Base64 encoded thumbnail of [avatarUrl], stored locally, used as a fallback when the avatar cannot be loaded. */ + val avatarThumbnail: String? = null, ) : Parcelable diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClient.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClient.kt index a36184d6bd..d92509b262 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClient.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClient.kt @@ -31,6 +31,7 @@ import io.element.android.libraries.matrix.api.createroom.RoomPreset import io.element.android.libraries.matrix.api.linknewdevice.LinkDesktopHandler import io.element.android.libraries.matrix.api.linknewdevice.LinkMobileHandler import io.element.android.libraries.matrix.api.media.MatrixMediaLoader +import io.element.android.libraries.matrix.api.media.MediaSource import io.element.android.libraries.matrix.api.oauth.AccountManagementAction import io.element.android.libraries.matrix.api.paths.SessionPaths import io.element.android.libraries.matrix.api.room.BaseRoom @@ -136,6 +137,7 @@ import timber.log.Timber import java.io.File import java.util.Optional import java.util.concurrent.atomic.AtomicBoolean +import kotlin.io.encoding.Base64 import kotlin.jvm.optionals.getOrNull import kotlin.time.Duration import kotlin.time.Duration.Companion.seconds @@ -143,6 +145,9 @@ import org.matrix.rustcomponents.sdk.CreateRoomParameters as RustCreateRoomParam import org.matrix.rustcomponents.sdk.RoomPreset as RustRoomPreset import org.matrix.rustcomponents.sdk.SyncService as ClientSyncService +// Same value as AVATAR_THUMBNAIL_SIZE_IN_PIXEL in the matrixmedia module +private const val AVATAR_THUMBNAIL_SIZE_IN_PIXEL = 240L + @Suppress("LargeClass") class RustMatrixClient( override val sessionPaths: SessionPaths, @@ -289,7 +294,11 @@ class RustMatrixClient( private val ownProfileListener = object : ProfileListener { override fun onUpdate(profile: UserProfile) { - _userProfile.tryEmit(profile.map()) + val matrixUser = profile.map() + _userProfile.tryEmit(matrixUser) + sessionCoroutineScope.launch { + storeUserProfile(matrixUser) + } } } @@ -485,13 +494,37 @@ class RustMatrixClient( .onSuccess { matrixUser -> // Also update our session storage _userProfile.emit(matrixUser) - sessionStore.updateUserProfile( - sessionId = sessionId.value, - displayName = matrixUser.displayName, - avatarUrl = matrixUser.avatarUrl, - ) + storeUserProfile(matrixUser) } + /** + * Store the user profile in the session storage, including a base64 encoded thumbnail of the avatar, + * so that the avatar can be rendered without having to use this client. + */ + private suspend fun storeUserProfile(matrixUser: MatrixUser) { + val avatarUrl = matrixUser.avatarUrl + val storedSession = sessionStore.getSession(sessionId.value) + val avatarData = when { + avatarUrl == null -> null + // Avatar has not changed, no need to download it again + avatarUrl == storedSession?.userAvatarUrl && storedSession.userAvatarData != null -> storedSession.userAvatarData + else -> matrixMediaLoader.loadMediaThumbnail( + source = MediaSource(avatarUrl), + width = AVATAR_THUMBNAIL_SIZE_IN_PIXEL, + height = AVATAR_THUMBNAIL_SIZE_IN_PIXEL, + ) + .map { Base64.encode(it) } + .onFailure { Timber.w(it, "Unable to load the avatar thumbnail of the user") } + .getOrNull() + } + sessionStore.updateUserProfile( + sessionId = sessionId.value, + displayName = matrixUser.displayName, + avatarUrl = avatarUrl, + avatarData = avatarData, + ) + } + override suspend fun searchUsers(searchTerm: String, limit: Long): Result = withContext(sessionDispatcher) { runCatchingExceptions { @@ -779,6 +812,14 @@ class RustMatrixClient( } } + override suspend fun getRoomInfo(roomId: RoomId): Result = withContext(sessionDispatcher) { + runCatchingExceptions { + innerRoomListService.roomOrNull(roomId.value)?.use { room -> + roomInfoMapper.map(room.roomInfo()) + } + } + } + override fun getRoomInfoFlow(roomId: RoomId): Flow> { return mxCallbackFlow { val roomNotFound = innerRoomListService.roomOrNull(roomId.value).use { it == null } diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/auth/AuthenticationException.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/auth/AuthenticationException.kt index 6a06ac0599..5a3bb67567 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/auth/AuthenticationException.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/auth/AuthenticationException.kt @@ -28,7 +28,6 @@ fun Throwable.mapAuthenticationException(): AuthenticationException { } is ClientBuildException.WellKnownLookupFailed -> AuthenticationException.Generic(message) is ClientBuildException.EventCache -> AuthenticationException.Generic(message) - is ClientBuildException.InvalidRawKey -> AuthenticationException.Generic(message) is ClientBuildException.WellKnownLookupDisabled -> AuthenticationException.Generic(message) } is OAuthException -> when (this) { diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/auth/RustMatrixAuthenticationService.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/auth/RustMatrixAuthenticationService.kt index 681aa6ba64..1ae2a16d38 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/auth/RustMatrixAuthenticationService.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/auth/RustMatrixAuthenticationService.kt @@ -381,6 +381,9 @@ class RustMatrixAuthenticationService( // Apply enterprise hooks to the newly created client as soon as possible clientEnterpriseHook(matrixClient) + // Start the sync service to ensure that the client receives the encryption updates we'll wait for next. + matrixClient.syncService.startSync() + matrixClient.waitForKnownVerificationState() newMatrixClientObservers.forEach { it.invoke(matrixClient) } diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/mapper/Session.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/mapper/Session.kt index 69a76f2825..57f2b8c597 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/mapper/Session.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/mapper/Session.kt @@ -38,4 +38,5 @@ internal fun Session.toSessionData( lastUsageIndex = 0, userDisplayName = null, userAvatarUrl = null, + userAvatarData = null, ) diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/media/MediaSource.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/media/MediaSource.kt index 08bef24722..c42e9c1f02 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/media/MediaSource.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/media/MediaSource.kt @@ -8,10 +8,27 @@ package io.element.android.libraries.matrix.impl.media +import io.element.android.libraries.core.extensions.runCatchingExceptions import io.element.android.libraries.matrix.api.media.MediaSource +import org.matrix.rustcomponents.sdk.NoHandle import org.matrix.rustcomponents.sdk.use import org.matrix.rustcomponents.sdk.MediaSource as RustMediaSource fun RustMediaSource.map(): MediaSource = use { MediaSource(it.url(), it.toJson()) } + +internal fun MediaSource.toRustMediaSource(): RustMediaSource { + val json = this.json + return try { + if (json != null) { + RustMediaSource.fromJson(json) + } else { + RustMediaSource.fromUrl(safeUrl) + } + } catch (e: LinkageError) { + // Used for tests, since we can't instantiate an actual `RustMediaSource` because the native library can't be loaded + val isTest = runCatchingExceptions { Class.forName("org.junit.Test") }.isSuccess + if (isTest) RustMediaSource(NoHandle) else throw e + } +} diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/media/RustMediaLoader.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/media/RustMediaLoader.kt index 7b3a1d9f10..0b8229cb6b 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/media/RustMediaLoader.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/media/RustMediaLoader.kt @@ -20,7 +20,6 @@ import kotlinx.coroutines.withContext import org.matrix.rustcomponents.sdk.Client import org.matrix.rustcomponents.sdk.use import java.io.File -import org.matrix.rustcomponents.sdk.MediaSource as RustMediaSource class RustMediaLoader( private val baseCacheDirectory: File, @@ -80,13 +79,4 @@ class RustMediaLoader( } } } - - private fun MediaSource.toRustMediaSource(): RustMediaSource { - val json = this.json - return if (json != null) { - RustMediaSource.fromJson(json) - } else { - RustMediaSource.fromUrl(safeUrl) - } - } } diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/JoinedRustRoom.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/JoinedRustRoom.kt index e892c62e84..17a3910c98 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/JoinedRustRoom.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/JoinedRustRoom.kt @@ -478,7 +478,7 @@ class JoinedRustRoom( widgetSettings = widgetSettings, room = innerRoom, widgetCapabilitiesProvider = object : WidgetCapabilitiesProvider { - override fun acquireCapabilities(capabilities: WidgetCapabilities): WidgetCapabilities { + override suspend fun acquireCapabilities(capabilities: WidgetCapabilities): WidgetCapabilities { return getElementCallRequiredPermissions(sessionId.value, baseRoom.deviceId.value) } }, diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/scanner/RustContentScanner.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/scanner/RustContentScanner.kt index c07ca94f26..60ae423f98 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/scanner/RustContentScanner.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/scanner/RustContentScanner.kt @@ -12,10 +12,9 @@ import io.element.android.libraries.core.extensions.runCatchingExceptions import io.element.android.libraries.matrix.api.media.MediaSource import io.element.android.libraries.matrix.api.scanner.ContentScanner import io.element.android.libraries.matrix.impl.exception.mapClientException +import io.element.android.libraries.matrix.impl.media.toRustMediaSource import org.matrix.rustcomponents.sdk.Client -import org.matrix.rustcomponents.sdk.NoHandle import org.matrix.rustcomponents.sdk.ContentScanner as RustScanner -import org.matrix.rustcomponents.sdk.MediaSource as RustMediaSource class RustContentScanner( private val client: Client, @@ -29,18 +28,3 @@ class RustContentScanner( } } } - -private fun MediaSource.toRustMediaSource(): RustMediaSource { - val json = this.json - return try { - if (json != null) { - RustMediaSource.fromJson(json) - } else { - RustMediaSource.fromUrl(safeUrl) - } - } catch (e: LinkageError) { - // Used for tests, since we can't instantiate an actual `RustMediaSource` because the native library can't be loaded - val isTest = runCatchingExceptions { Class.forName("org.junit.Test") }.isSuccess - if (isTest) RustMediaSource(NoHandle) else throw e - } -} diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/item/event/EventTimelineItemMapper.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/item/event/EventTimelineItemMapper.kt index 3a568761ce..db8f4753ec 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/item/event/EventTimelineItemMapper.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/item/event/EventTimelineItemMapper.kt @@ -31,7 +31,6 @@ import org.matrix.rustcomponents.sdk.EventOrTransactionId import org.matrix.rustcomponents.sdk.QueueWedgeError import org.matrix.rustcomponents.sdk.Reaction import org.matrix.rustcomponents.sdk.ShieldState -import org.matrix.rustcomponents.sdk.TimelineItemContent import uniffi.matrix_sdk_ui.TimelineEventShieldStateCode import org.matrix.rustcomponents.sdk.EventSendState as RustEventSendState import org.matrix.rustcomponents.sdk.EventTimelineItem as RustEventTimelineItem @@ -52,7 +51,7 @@ class EventTimelineItemMapper( isOwn = isOwn, isRemote = isRemote, localSendState = localSendState?.map(), - reactions = (content as? TimelineItemContent.MsgLike)?.content?.reactions.map(), + reactions = reactions.map(), receipts = readReceipts.map(), sender = UserId(sender), senderProfile = senderProfile.map(), diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClientFactoryTest.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClientFactoryTest.kt index b559b7d4d0..4dcb8b1df8 100644 --- a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClientFactoryTest.kt +++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClientFactoryTest.kt @@ -49,7 +49,7 @@ class RustMatrixClientFactoryTest { fun TestScope.createRustMatrixClientFactory( cacheDirectory: File = File("/cache"), sessionStore: SessionStore = InMemorySessionStore( - updateUserProfileResult = { _, _, _ -> }, + updateUserProfileResult = { _, _, _, _ -> }, ), clientBuilderProvider: ClientBuilderProvider = FakeClientBuilderProvider(), workManagerScheduler: FakeWorkManagerScheduler = FakeWorkManagerScheduler(), diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClientTest.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClientTest.kt index ea399b39df..4fe61e4d14 100644 --- a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClientTest.kt +++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClientTest.kt @@ -14,6 +14,7 @@ import com.google.common.truth.Truth.assertThat import io.element.android.libraries.core.data.bytes import io.element.android.libraries.featureflag.test.FakeFeatureFlagService import io.element.android.libraries.matrix.api.paths.SessionPaths +import io.element.android.libraries.matrix.impl.fixtures.factories.aRustUserProfile import io.element.android.libraries.matrix.impl.fixtures.fakes.FakeFfiClient import io.element.android.libraries.matrix.impl.fixtures.fakes.FakeFfiSyncService import io.element.android.libraries.matrix.impl.room.FakeTimelineEventFilterFactory @@ -29,6 +30,7 @@ import io.element.android.libraries.sessionstorage.test.aSessionData import io.element.android.libraries.workmanager.test.FakeWorkManagerScheduler import io.element.android.services.analytics.test.FakeAnalyticsService import io.element.android.services.toolbox.test.systemclock.FakeSystemClock +import io.element.android.tests.testutils.lambda.any import io.element.android.tests.testutils.lambda.lambdaRecorder import io.element.android.tests.testutils.lambda.value import io.element.android.tests.testutils.testCoroutineDispatchers @@ -39,9 +41,9 @@ import kotlinx.coroutines.test.runTest import org.junit.Test import org.matrix.rustcomponents.sdk.Client import org.matrix.rustcomponents.sdk.CreateRoomParameters +import org.matrix.rustcomponents.sdk.MediaSource import org.matrix.rustcomponents.sdk.RoomHistoryVisibility import org.matrix.rustcomponents.sdk.StoreSizes -import org.matrix.rustcomponents.sdk.UserProfile import java.io.File private const val AN_ACCOUNT_DATA_EVENT_TYPE = "org.example.custom" @@ -76,7 +78,8 @@ class RustMatrixClientTest { @Test fun `retrieving the UserProfile updates the database`() = runTest { val profilePersisted = CompletableDeferred() - val updateUserProfileResult = lambdaRecorder { _, _, _ -> profilePersisted.complete(Unit) } + val updateUserProfileResult = lambdaRecorder { _, _, _, _ -> profilePersisted.complete(Unit) } + val getMediaThumbnailResult = lambdaRecorder { _, _, _ -> byteArrayOf(1, 2, 3) } val sessionStore = InMemorySessionStore( initialList = listOf( aSessionData( @@ -89,24 +92,160 @@ class RustMatrixClientTest { ) val client = createRustMatrixClient( client = FakeFfiClient( - getProfileResult = { userId -> - UserProfile( - userId = userId, - displayName = A_USER_NAME, - avatarUrl = AN_AVATAR_URL, - status = null, - call = null, - ) - }, + getProfileResult = { userId -> aRustUserProfile(userId = userId, displayName = A_USER_NAME, avatarUrl = AN_AVATAR_URL) }, + getMediaThumbnailResult = getMediaThumbnailResult, ), sessionStore = sessionStore, ) profilePersisted.await() + getMediaThumbnailResult.assertions().isCalledOnce() + .with(any(), value(240uL), value(240uL)) updateUserProfileResult.assertions().isCalledOnce() .with( value(A_USER_ID.value), value(A_USER_NAME), value(AN_AVATAR_URL), + value("AQID"), + ) + client.destroy() + } + + @Test + fun `retrieving the UserProfile does not download the avatar again if it has not changed`() = runTest { + val profilePersisted = CompletableDeferred() + val updateUserProfileResult = lambdaRecorder { _, _, _, _ -> profilePersisted.complete(Unit) } + val getMediaThumbnailResult = lambdaRecorder { _, _, _ -> byteArrayOf(1, 2, 3) } + val sessionStore = InMemorySessionStore( + initialList = listOf( + aSessionData( + sessionId = A_USER_ID.value, + userDisplayName = A_USER_NAME, + userAvatarUrl = AN_AVATAR_URL, + userAvatarData = "storedData", + ) + ), + updateUserProfileResult = updateUserProfileResult, + ) + val client = createRustMatrixClient( + client = FakeFfiClient( + getProfileResult = { userId -> aRustUserProfile(userId = userId, displayName = A_USER_NAME, avatarUrl = AN_AVATAR_URL) }, + getMediaThumbnailResult = getMediaThumbnailResult, + ), + sessionStore = sessionStore, + ) + profilePersisted.await() + getMediaThumbnailResult.assertions().isNeverCalled() + updateUserProfileResult.assertions().isCalledOnce() + .with( + value(A_USER_ID.value), + value(A_USER_NAME), + value(AN_AVATAR_URL), + value("storedData"), + ) + client.destroy() + } + + @Test + fun `retrieving the UserProfile downloads the avatar again if the previous download failed`() = runTest { + val profilePersisted = CompletableDeferred() + val updateUserProfileResult = lambdaRecorder { _, _, _, _ -> profilePersisted.complete(Unit) } + val getMediaThumbnailResult = lambdaRecorder { _, _, _ -> byteArrayOf(1, 2, 3) } + val sessionStore = InMemorySessionStore( + initialList = listOf( + aSessionData( + sessionId = A_USER_ID.value, + userDisplayName = A_USER_NAME, + userAvatarUrl = AN_AVATAR_URL, + userAvatarData = null, + ) + ), + updateUserProfileResult = updateUserProfileResult, + ) + val client = createRustMatrixClient( + client = FakeFfiClient( + getProfileResult = { userId -> aRustUserProfile(userId = userId, displayName = A_USER_NAME, avatarUrl = AN_AVATAR_URL) }, + getMediaThumbnailResult = getMediaThumbnailResult, + ), + sessionStore = sessionStore, + ) + profilePersisted.await() + getMediaThumbnailResult.assertions().isCalledOnce() + updateUserProfileResult.assertions().isCalledOnce() + .with( + value(A_USER_ID.value), + value(A_USER_NAME), + value(AN_AVATAR_URL), + value("AQID"), + ) + client.destroy() + } + + @Test + fun `retrieving the UserProfile clears the avatar data if the avatar download fails`() = runTest { + val profilePersisted = CompletableDeferred() + val updateUserProfileResult = lambdaRecorder { _, _, _, _ -> profilePersisted.complete(Unit) } + val getMediaThumbnailResult = lambdaRecorder { _, _, _ -> error("Download failed") } + val sessionStore = InMemorySessionStore( + initialList = listOf( + aSessionData( + sessionId = A_USER_ID.value, + userDisplayName = A_USER_NAME, + userAvatarUrl = "mxc://server.org/previousAvatar", + userAvatarData = "previousData", + ) + ), + updateUserProfileResult = updateUserProfileResult, + ) + val client = createRustMatrixClient( + client = FakeFfiClient( + getProfileResult = { userId -> aRustUserProfile(userId = userId, displayName = A_USER_NAME, avatarUrl = AN_AVATAR_URL) }, + getMediaThumbnailResult = getMediaThumbnailResult, + ), + sessionStore = sessionStore, + ) + profilePersisted.await() + getMediaThumbnailResult.assertions().isCalledOnce() + updateUserProfileResult.assertions().isCalledOnce() + .with( + value(A_USER_ID.value), + value(A_USER_NAME), + value(AN_AVATAR_URL), + value(null), + ) + client.destroy() + } + + @Test + fun `retrieving the UserProfile without avatar clears the avatar data`() = runTest { + val profilePersisted = CompletableDeferred() + val updateUserProfileResult = lambdaRecorder { _, _, _, _ -> profilePersisted.complete(Unit) } + val getMediaThumbnailResult = lambdaRecorder { _, _, _ -> byteArrayOf(1, 2, 3) } + val sessionStore = InMemorySessionStore( + initialList = listOf( + aSessionData( + sessionId = A_USER_ID.value, + userDisplayName = A_USER_NAME, + userAvatarUrl = AN_AVATAR_URL, + userAvatarData = "storedData", + ) + ), + updateUserProfileResult = updateUserProfileResult, + ) + val client = createRustMatrixClient( + client = FakeFfiClient( + getProfileResult = { userId -> aRustUserProfile(userId = userId, displayName = A_USER_NAME, avatarUrl = null) }, + getMediaThumbnailResult = getMediaThumbnailResult, + ), + sessionStore = sessionStore, + ) + profilePersisted.await() + getMediaThumbnailResult.assertions().isNeverCalled() + updateUserProfileResult.assertions().isCalledOnce() + .with( + value(A_USER_ID.value), + value(A_USER_NAME), + value(null), + value(null), ) client.destroy() } @@ -169,7 +308,7 @@ class RustMatrixClientTest { private fun TestScope.createRustMatrixClient( client: Client = FakeFfiClient(), sessionStore: SessionStore = InMemorySessionStore( - updateUserProfileResult = { _, _, _ -> }, + updateUserProfileResult = { _, _, _, _ -> }, ), ) = RustMatrixClient( innerClient = client, diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/RustTemporaryMatrixClientFactoryTest.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/RustTemporaryMatrixClientFactoryTest.kt index f18f633889..f4a229b30b 100644 --- a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/RustTemporaryMatrixClientFactoryTest.kt +++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/RustTemporaryMatrixClientFactoryTest.kt @@ -60,7 +60,7 @@ class RustTemporaryMatrixClientFactoryTest { private fun TestScope.createRustMatrixClientFactory( cacheDirectory: File = File("/cache"), sessionStore: SessionStore = InMemorySessionStore( - updateUserProfileResult = { _, _, _ -> }, + updateUserProfileResult = { _, _, _, _ -> }, ), clientBuilderProvider: ClientBuilderProvider = FakeClientBuilderProvider(), workManagerScheduler: FakeWorkManagerScheduler = FakeWorkManagerScheduler(), diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/auth/RustMatrixAuthenticationServiceTest.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/auth/RustMatrixAuthenticationServiceTest.kt index d81c6021da..9b589b2281 100644 --- a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/auth/RustMatrixAuthenticationServiceTest.kt +++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/auth/RustMatrixAuthenticationServiceTest.kt @@ -255,7 +255,7 @@ class RustMatrixAuthenticationServiceTest { } private fun TestScope.createRustMatrixAuthenticationService( - sessionStore: SessionStore = InMemorySessionStore(updateUserProfileResult = { _, _, _ -> }), + sessionStore: SessionStore = InMemorySessionStore(updateUserProfileResult = { _, _, _, _ -> }), clientBuilderProvider: ClientBuilderProvider = FakeClientBuilderProvider(), enterpriseService: EnterpriseService = FakeEnterpriseService(), sessionPathsFactory: SessionPathsFactory = SessionPathsFactory(File("/base"), File("/cache")), diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/factories/EventTimelineItem.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/factories/EventTimelineItem.kt index 8689de3c9b..68990560ce 100644 --- a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/factories/EventTimelineItem.kt +++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/factories/EventTimelineItem.kt @@ -16,6 +16,7 @@ import org.matrix.rustcomponents.sdk.EventSendState import org.matrix.rustcomponents.sdk.EventTimelineItem import org.matrix.rustcomponents.sdk.LazyTimelineItemProvider import org.matrix.rustcomponents.sdk.ProfileDetails +import org.matrix.rustcomponents.sdk.Reaction import org.matrix.rustcomponents.sdk.Receipt import org.matrix.rustcomponents.sdk.TimelineItemContent import uniffi.matrix_sdk_ui.EventItemOrigin @@ -37,6 +38,7 @@ internal fun aRustEventTimelineItem( readReceipts: Map = emptyMap(), origin: EventItemOrigin? = EventItemOrigin.SYNC, canBeRepliedTo: Boolean = true, + reactions: List = emptyList(), lazyProvider: LazyTimelineItemProvider = FakeFfiLazyTimelineItemProvider(), ) = EventTimelineItem( isRemote = isRemote, @@ -55,5 +57,6 @@ internal fun aRustEventTimelineItem( readReceipts = readReceipts, origin = origin, canBeRepliedTo = canBeRepliedTo, + reactions = reactions, lazyProvider = lazyProvider, ) diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/factories/TimelineItemContentMsgLike.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/factories/TimelineItemContentMsgLike.kt index 0db7590a5c..157adde323 100644 --- a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/factories/TimelineItemContentMsgLike.kt +++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/factories/TimelineItemContentMsgLike.kt @@ -27,7 +27,6 @@ internal fun aRustTimelineItemContentMsgLike( mentions = null, ) ), - reactions = emptyList(), threadRoot = null, inReplyTo = null, threadSummary = null, diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/factories/UserProfile.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/factories/UserProfile.kt index 947e6e7aa1..07997e9109 100644 --- a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/factories/UserProfile.kt +++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/factories/UserProfile.kt @@ -16,7 +16,7 @@ import org.matrix.rustcomponents.sdk.UserStatus internal fun aRustUserProfile( userId: String = A_USER_ID.value, displayName: String = "displayName", - avatarUrl: String = "avatarUrl", + avatarUrl: String? = "avatarUrl", status: UserStatus? = null, call: UserCall? = null, ) = UserProfile( diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/fakes/FakeFfiClient.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/fakes/FakeFfiClient.kt index 8c6411197a..c2080747d5 100644 --- a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/fakes/FakeFfiClient.kt +++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/fakes/FakeFfiClient.kt @@ -26,6 +26,7 @@ import org.matrix.rustcomponents.sdk.HomeserverCapabilities import org.matrix.rustcomponents.sdk.HomeserverLoginDetails import org.matrix.rustcomponents.sdk.IgnoredUsersListener import org.matrix.rustcomponents.sdk.LoginWithQrCodeHandler +import org.matrix.rustcomponents.sdk.MediaSource import org.matrix.rustcomponents.sdk.NoHandle import org.matrix.rustcomponents.sdk.NotificationClient import org.matrix.rustcomponents.sdk.NotificationProcessSetup @@ -70,6 +71,7 @@ class FakeFfiClient( private val isProfilesSlidingSyncExtensionSupportedResult: () -> Boolean = { false }, private val subscribeToOwnProfileResult: (ProfileListener) -> Unit = {}, private val getUrlResult: (String) -> ByteArray = { lambdaError() }, + private val getMediaThumbnailResult: (MediaSource, ULong, ULong) -> ByteArray = { _, _, _ -> byteArrayOf() }, private val contentScannerResult: () -> ContentScanner = { FakeFfiContentScanner() }, private val closeResult: () -> Unit = {}, ) : Client(NoHandle) { @@ -122,6 +124,10 @@ class FakeFfiClient( return getProfileResult(userId) } + override suspend fun getMediaThumbnail(mediaSource: MediaSource, width: ULong, height: ULong): ByteArray { + return getMediaThumbnailResult(mediaSource, width, height) + } + override suspend fun homeserverLoginDetails(): HomeserverLoginDetails { return homeserverLoginDetailsResult() } diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/FakeMatrixClient.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/FakeMatrixClient.kt index 6ba42270fe..f176d3b006 100644 --- a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/FakeMatrixClient.kt +++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/FakeMatrixClient.kt @@ -74,6 +74,7 @@ import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.test.TestScope import java.io.File import java.util.Optional +import kotlin.jvm.optionals.getOrNull class FakeMatrixClient( override val sessionId: SessionId = A_SESSION_ID, @@ -173,6 +174,7 @@ class FakeMatrixClient( var knockRoomLambda: (RoomIdOrAlias, String, List) -> Result = { _, _, _ -> Result.success(null) } + var getRoomInfoLambda = { _: RoomId -> Result.success(null) } var getRoomInfoFlowLambda = { _: RoomId -> flowOf>(Optional.empty()) } @@ -379,6 +381,8 @@ class FakeMatrixClient( return Result.success(visitedRoomsId) } + override suspend fun getRoomInfo(roomId: RoomId): Result = getRoomInfoLambda(roomId) + override fun getRoomInfoFlow(roomId: RoomId) = getRoomInfoFlowLambda(roomId) var setAllSendQueuesEnabledLambda = lambdaRecorder(ensureNeverCalled = true) { _: Boolean -> diff --git a/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/SpaceHeaderView.kt b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/SpaceHeaderView.kt index 8a833cee44..68c53c0685 100644 --- a/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/SpaceHeaderView.kt +++ b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/SpaceHeaderView.kt @@ -20,7 +20,7 @@ import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.tooling.preview.datasource.LoremIpsum import androidx.compose.ui.unit.dp import io.element.android.compound.theme.ElementTheme -import io.element.android.libraries.designsystem.atomic.atoms.RoomPreviewAliasAtom +import io.element.android.libraries.designsystem.atomic.atoms.CopiableTextAtom import io.element.android.libraries.designsystem.atomic.atoms.RoomPreviewDescriptionAtom import io.element.android.libraries.designsystem.atomic.atoms.RoomPreviewTitleAtom import io.element.android.libraries.designsystem.atomic.organisms.RoomPreviewOrganism @@ -87,7 +87,7 @@ fun SpaceHeaderView( verticalArrangement = Arrangement.spacedBy(8.dp), ) { if (alias != null) { - RoomPreviewAliasAtom(alias = alias.value) + CopiableTextAtom(text = alias.value) } SpaceInfoRow(visibility = visibility) } diff --git a/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/messages/ToHtmlDocument.kt b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/messages/ToHtmlDocument.kt index 2f52320b0d..5cb3535b06 100644 --- a/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/messages/ToHtmlDocument.kt +++ b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/messages/ToHtmlDocument.kt @@ -89,6 +89,12 @@ private object CustomHtmlToDomParser { "blockquote", "p", "br", + "h1", + "h2", + "h3", + "h4", + "h5", + "h6", // Add custom `mx-reply` tag, even if it's just to remove its contents from the plain text version of the message "mx-reply" ) diff --git a/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/model/InviteSender.kt b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/model/InviteSender.kt index dd8655af26..a6798a377b 100644 --- a/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/model/InviteSender.kt +++ b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/model/InviteSender.kt @@ -9,6 +9,7 @@ package io.element.android.libraries.matrix.ui.model import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.SpanStyle @@ -20,6 +21,7 @@ import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.room.RoomMember import io.element.android.libraries.matrix.ui.R +@Immutable data class InviteSender( val userId: UserId, val displayName: String, diff --git a/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/model/MatrixUserExtensions.kt b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/model/MatrixUserExtensions.kt index 83a707347d..ac3cdf0e3b 100644 --- a/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/model/MatrixUserExtensions.kt +++ b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/model/MatrixUserExtensions.kt @@ -20,6 +20,7 @@ fun MatrixUser.getAvatarData(size: AvatarSize) = AvatarData( name = displayName, url = avatarUrl, size = size, + thumbnail = avatarThumbnail, ) fun MatrixUser.getBestName(): String { diff --git a/libraries/matrixui/src/test/kotlin/io/element/android/libraries/matrix/ui/model/MatrixUserExtensionsTest.kt b/libraries/matrixui/src/test/kotlin/io/element/android/libraries/matrix/ui/model/MatrixUserExtensionsTest.kt index 035548a4ec..ae8a04b7cd 100644 --- a/libraries/matrixui/src/test/kotlin/io/element/android/libraries/matrix/ui/model/MatrixUserExtensionsTest.kt +++ b/libraries/matrixui/src/test/kotlin/io/element/android/libraries/matrix/ui/model/MatrixUserExtensionsTest.kt @@ -33,12 +33,14 @@ class MatrixUserExtensionsTest : RobolectricTest() { userId = A_USER_ID, displayName = "displayName", avatarUrl = "avatarUrl", + avatarThumbnail = "avatarThumbnail", ) val expected = AvatarData( id = A_USER_ID.value, name = "displayName", url = "avatarUrl", size = AvatarSize.UserHeader, + thumbnail = "avatarThumbnail", ) assertThat(matrixUser.getAvatarData(AvatarSize.UserHeader)).isEqualTo(expected) } diff --git a/libraries/mediaupload/impl/src/main/kotlin/io/element/android/libraries/mediaupload/impl/DefaultMediaSender.kt b/libraries/mediaupload/impl/src/main/kotlin/io/element/android/libraries/mediaupload/impl/DefaultMediaSender.kt index 37f77d11f8..36c3ba99fb 100644 --- a/libraries/mediaupload/impl/src/main/kotlin/io/element/android/libraries/mediaupload/impl/DefaultMediaSender.kt +++ b/libraries/mediaupload/impl/src/main/kotlin/io/element/android/libraries/mediaupload/impl/DefaultMediaSender.kt @@ -100,9 +100,9 @@ class DefaultMediaSender( inReplyToEventId: EventId?, ): Result { val mediaLogId = mediaId(mediaUploadInfo.file) - return getTimeline().flatMap { - Timber.d("Started sending media $mediaLogId using timeline: ${it.mode}") - it.sendMedia( + return withTimeline { timeline -> + Timber.d("Started sending media $mediaLogId using timeline: ${timeline.mode}") + timeline.sendMedia( uploadInfo = mediaUploadInfo, caption = caption, formattedCaption = formattedCaption, @@ -128,12 +128,14 @@ class DefaultMediaSender( mediaOptimizationConfig = mediaOptimizationConfig, ) .flatMapCatching { info -> - getTimeline().getOrThrow().sendMedia( - uploadInfo = info, - caption = caption, - formattedCaption = formattedCaption, - inReplyToEventId = inReplyToEventId, - ) + withTimeline { timeline -> + timeline.sendMedia( + uploadInfo = info, + caption = caption, + formattedCaption = formattedCaption, + inReplyToEventId = inReplyToEventId, + ) + } } .handleSendResult(mediaId(uri)) } @@ -158,12 +160,14 @@ class DefaultMediaSender( audioInfo = audioInfo, waveform = waveForm, ) - getTimeline().getOrThrow().sendMedia( - uploadInfo = newInfo, - caption = null, - formattedCaption = null, - inReplyToEventId = inReplyToEventId, - ) + withTimeline { timeline -> + timeline.sendMedia( + uploadInfo = newInfo, + caption = null, + formattedCaption = null, + inReplyToEventId = inReplyToEventId, + ) + } } .handleSendResult(mediaId(uri)) } @@ -176,7 +180,7 @@ class DefaultMediaSender( ): Result { val galleryLogId = "gallery[${mediaUploadInfos.size} items]" Timber.d("Sending $galleryLogId") - return getTimeline().flatMap { timeline -> + return withTimeline { timeline -> val galleryItems = mediaUploadInfos.map { it.toGalleryItemInfo() } timeline.sendGallery( items = galleryItems, @@ -184,10 +188,10 @@ class DefaultMediaSender( formattedCaption = formattedCaption, inReplyToEventId = inReplyToEventId, ) + .flatMapCatching { uploadHandler -> + uploadHandler.await() + } } - .flatMapCatching { uploadHandler -> - uploadHandler.await() - } .handleSendResult(galleryLogId) } @@ -269,12 +273,23 @@ class DefaultMediaSender( } } - private suspend fun getTimeline(): Result { + /** + * Invokes [block] with the [Timeline] matching [timelineMode]. + * + * For a thread, a dedicated timeline is created and closed once [block] returns, since such a + * timeline owns SDK resources. [block] must then perform the whole sending, including waiting + * for the media upload to complete. For the other modes, the live timeline is used, and is not + * closed here since it is owned by its room. + */ + private suspend fun withTimeline(block: suspend (Timeline) -> Result): Result { return when (timelineMode) { is Timeline.Mode.Thread -> { room.createTimeline(CreateTimelineParams.Threaded(threadRootEventId = timelineMode.threadRootId)) + .flatMap { threadedTimeline -> + threadedTimeline.use { block(it) } + } } - else -> Result.success(room.liveTimeline) + else -> block(room.liveTimeline) } } diff --git a/libraries/mediaupload/impl/src/test/kotlin/io/element/android/libraries/mediaupload/impl/DefaultMediaSenderTest.kt b/libraries/mediaupload/impl/src/test/kotlin/io/element/android/libraries/mediaupload/impl/DefaultMediaSenderTest.kt index cfa3585629..97a9b95c51 100644 --- a/libraries/mediaupload/impl/src/test/kotlin/io/element/android/libraries/mediaupload/impl/DefaultMediaSenderTest.kt +++ b/libraries/mediaupload/impl/src/test/kotlin/io/element/android/libraries/mediaupload/impl/DefaultMediaSenderTest.kt @@ -16,6 +16,7 @@ import io.element.android.libraries.matrix.api.media.FileInfo import io.element.android.libraries.matrix.api.media.ImageInfo import io.element.android.libraries.matrix.api.room.JoinedRoom import io.element.android.libraries.matrix.api.timeline.Timeline +import io.element.android.libraries.matrix.test.A_THREAD_ID import io.element.android.libraries.matrix.test.media.FakeMediaUploadHandler import io.element.android.libraries.matrix.test.room.FakeJoinedRoom import io.element.android.libraries.matrix.test.timeline.FakeTimeline @@ -156,14 +157,52 @@ class DefaultMediaSenderTest : RobolectricTest() { sendFileResult.assertions().isCalledOnce() } + @Test + fun `given a thread, the created timeline is closed once the media is sent`() = runTest { + val threadedTimeline = FakeTimeline(mode = Timeline.Mode.Thread(A_THREAD_ID)).apply { + sendFileLambda = { _, _, _, _, _ -> Result.success(FakeMediaUploadHandler()) } + } + val liveTimeline = FakeTimeline() + val room = FakeJoinedRoom( + liveTimeline = liveTimeline, + createTimelineResult = { Result.success(threadedTimeline) }, + ) + val sender = createDefaultMediaSender( + room = room, + timelineMode = Timeline.Mode.Thread(A_THREAD_ID), + ) + + val uri = Uri.parse("content://image.jpg") + val result = sender.sendMedia(uri = uri, mimeType = MimeTypes.Jpeg, mediaOptimizationConfig = mediaOptimizationConfig) + + assertThat(result.isSuccess).isTrue() + assertThat(threadedTimeline.closeCounter).isEqualTo(1) + assertThat(liveTimeline.closeCounter).isEqualTo(0) + } + + @Test + fun `given a live timeline, the timeline is not closed once the media is sent`() = runTest { + val liveTimeline = FakeTimeline().apply { + sendFileLambda = { _, _, _, _, _ -> Result.success(FakeMediaUploadHandler()) } + } + val sender = createDefaultMediaSender(room = FakeJoinedRoom(liveTimeline = liveTimeline)) + + val uri = Uri.parse("content://image.jpg") + val result = sender.sendMedia(uri = uri, mimeType = MimeTypes.Jpeg, mediaOptimizationConfig = mediaOptimizationConfig) + + assertThat(result.isSuccess).isTrue() + assertThat(liveTimeline.closeCounter).isEqualTo(0) + } + private fun createDefaultMediaSender( preProcessor: MediaPreProcessor = FakeMediaPreProcessor(), room: JoinedRoom = FakeJoinedRoom(), + timelineMode: Timeline.Mode = Timeline.Mode.Live, mediaOptimizationConfigProvider: MediaOptimizationConfigProvider = MediaOptimizationConfigProvider { mediaOptimizationConfig }, ) = DefaultMediaSender( preProcessor = preProcessor, room = room, - timelineMode = Timeline.Mode.Live, + timelineMode = timelineMode, mediaOptimizationConfigProvider = mediaOptimizationConfigProvider, ) } diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/ui/VoiceItemView.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/ui/VoiceItemView.kt index 2ffaed7af0..4ad4d76383 100644 --- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/ui/VoiceItemView.kt +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/ui/VoiceItemView.kt @@ -153,6 +153,9 @@ private fun VoiceInfoRow( onSeek = { state.eventSink(VoiceMessageEvent.Seek(it)) }, + isPlaying = state.isPlaying, + durationMs = state.durationMs, + playbackSpeed = state.playbackSpeed, seekEnabled = !isValidating, ) } diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/local/audio/MediaAudioView.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/local/audio/MediaAudioView.kt index 4ef746cb89..9f68212304 100644 --- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/local/audio/MediaAudioView.kt +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/local/audio/MediaAudioView.kt @@ -47,6 +47,7 @@ import androidx.core.view.isVisible import androidx.lifecycle.Lifecycle import androidx.media3.common.MediaItem import androidx.media3.common.MediaMetadata +import androidx.media3.common.PlaybackParameters import androidx.media3.common.Player import androidx.media3.common.Timeline import androidx.media3.exoplayer.ExoPlayer @@ -124,6 +125,7 @@ private fun ExoPlayerMediaAudioView( canMute = false, isMuted = false, seekingToMillis = null, + playbackSpeed = exoPlayer.playbackParameters.speed, ) ) } @@ -156,6 +158,12 @@ private fun ExoPlayerMediaAudioView( ) } + override fun onPlaybackParametersChanged(playbackParameters: PlaybackParameters) { + mediaPlayerControllerState = mediaPlayerControllerState.copy( + playbackSpeed = playbackParameters.speed, + ) + } + override fun onTimelineChanged(timeline: Timeline, reason: Int) { if (reason == Player.TIMELINE_CHANGE_REASON_SOURCE_UPDATE) { exoPlayer.duration.takeIf { it >= 0 } @@ -276,6 +284,9 @@ private fun ExoPlayerMediaAudioView( onSeek = { exoPlayer.seekToEnsurePlaying((it * exoPlayer.duration).toLong()) }, + isPlaying = mediaPlayerControllerState.isPlaying, + durationMs = mediaPlayerControllerState.durationInMillis, + playbackSpeed = mediaPlayerControllerState.playbackSpeed, seekEnabled = true, ) } else { diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/local/player/ExoPlayerForPreview.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/local/player/ExoPlayerForPreview.kt index e23cb790db..6b08fb9d04 100644 --- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/local/player/ExoPlayerForPreview.kt +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/local/player/ExoPlayerForPreview.kt @@ -120,7 +120,9 @@ class ExoPlayerForPreview( override fun seekToNext() {} override fun setPlaybackParameters(playbackParameters: PlaybackParameters) {} override fun setPlaybackSpeed(speed: Float) {} - override fun getPlaybackParameters(): PlaybackParameters = throw NotImplementedError() + + // Cannot throw, this method is invoked when recording Paparazzi screenshots. + override fun getPlaybackParameters(): PlaybackParameters = PlaybackParameters.DEFAULT override fun stop() {} override fun release() {} override fun getCurrentTracks(): Tracks = throw NotImplementedError() diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/local/player/MediaPlayerControllerState.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/local/player/MediaPlayerControllerState.kt index 79bd7be503..275d001ad9 100644 --- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/local/player/MediaPlayerControllerState.kt +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/local/player/MediaPlayerControllerState.kt @@ -19,6 +19,7 @@ data class MediaPlayerControllerState( val canMute: Boolean, val isMuted: Boolean, val seekingToMillis: Long?, + val playbackSpeed: Float, ) { /** * The progress in milliseconds to display. When [seekingToMillis] is non-null (during a seek operation), diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/local/player/MediaPlayerControllerStatePreviewParam.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/local/player/MediaPlayerControllerStatePreviewParam.kt index b229d11283..f60a81d504 100644 --- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/local/player/MediaPlayerControllerStatePreviewParam.kt +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/local/player/MediaPlayerControllerStatePreviewParam.kt @@ -35,6 +35,7 @@ private fun aMediaPlayerControllerState( canMute: Boolean = true, isMuted: Boolean = false, seekingToMillis: Long? = null, + playbackSpeed: Float = 1f, ) = MediaPlayerControllerState( isVisible = isVisible, isPlaying = isPlaying, @@ -44,4 +45,5 @@ private fun aMediaPlayerControllerState( canMute = canMute, isMuted = isMuted, seekingToMillis = seekingToMillis, + playbackSpeed = playbackSpeed, ) diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/local/video/MediaVideoView.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/local/video/MediaVideoView.kt index b5c65fa609..0a06695485 100644 --- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/local/video/MediaVideoView.kt +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/local/video/MediaVideoView.kt @@ -118,6 +118,7 @@ private fun ExoPlayerMediaVideoView( canMute = true, isMuted = false, seekingToMillis = null, + playbackSpeed = exoPlayer.playbackParameters.speed, ) ) } diff --git a/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/local/audio/MediaAudioViewTest.kt b/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/local/audio/MediaAudioViewTest.kt new file mode 100644 index 0000000000..283fa57f9e --- /dev/null +++ b/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/local/audio/MediaAudioViewTest.kt @@ -0,0 +1,98 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +@file:OptIn(ExperimentalTestApi::class) + +package io.element.android.libraries.mediaviewer.impl.local.audio + +import android.net.Uri +import androidx.activity.ComponentActivity +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.ui.platform.LocalInspectionMode +import androidx.compose.ui.test.AndroidComposeUiTest +import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.v2.runAndroidComposeUiTest +import com.bumble.appyx.core.node.LocalNodeTargetVisibility +import io.element.android.compound.theme.ElementTheme +import io.element.android.libraries.designsystem.components.media.WaveFormSamples +import io.element.android.libraries.mediaviewer.api.MediaInfo +import io.element.android.libraries.mediaviewer.api.anAudioMediaInfo +import io.element.android.libraries.mediaviewer.impl.local.rememberLocalMediaViewState +import io.element.android.libraries.mediaviewer.test.viewer.aLocalMedia +import io.element.android.tests.testutils.robolectric.RobolectricTest +import io.element.android.tests.testutils.setSafeContent +import org.junit.Test + +class MediaAudioViewTest : RobolectricTest() { + @Test + fun `an audio file without a waveform shows the filename`() = runAndroidComposeUiTest { + val info = anAudioMediaInfo(filename = "track.mp3") + setMediaAudioView(info = info) + onNodeWithText("track.mp3").assertIsDisplayed() + } + + @Test + fun `an audio file with a waveform hides the filename in favour of the waveform`() = runAndroidComposeUiTest { + val info = anAudioMediaInfo( + filename = "voice.ogg", + waveForm = WaveFormSamples.realisticWaveForm, + ) + setMediaAudioView(info = info) + onNodeWithText("voice.ogg").assertDoesNotExist() + } + + @Test + fun `audio is paused when the view is not displayed`() = runAndroidComposeUiTest { + setMediaAudioView(isDisplayed = false) + waitForIdle() + } + + @Test + fun `audio is paused when the host node is not visible`() = runAndroidComposeUiTest { + setMediaAudioView(isTargetVisible = false) + waitForIdle() + } + + @Test + fun `a local media uri is prepared on the player`() = runAndroidComposeUiTest { + setMediaAudioView( + info = anAudioMediaInfo( + filename = "voice.ogg", + waveForm = WaveFormSamples.realisticWaveForm, + ), + localMediaUri = Uri.parse("file://voice.ogg"), + ) + waitForIdle() + } +} + +private fun AndroidComposeUiTest.setMediaAudioView( + info: MediaInfo = anAudioMediaInfo(), + isDisplayed: Boolean = true, + isTargetVisible: Boolean = true, + localMediaUri: Uri? = null, +) { + setSafeContent { + CompositionLocalProvider( + LocalInspectionMode provides true, + LocalNodeTargetVisibility provides isTargetVisible, + ) { + ElementTheme { + MediaAudioView( + localMediaViewState = rememberLocalMediaViewState(), + bottomPaddingInPixels = 0, + localMedia = localMediaUri?.let { aLocalMedia(uri = it, mediaInfo = info) }, + info = info, + audioFocus = null, + isDisplayed = isDisplayed, + ) + } + } + } +} diff --git a/libraries/preferences/api/src/main/kotlin/io/element/android/libraries/preferences/api/store/AppPreferencesStore.kt b/libraries/preferences/api/src/main/kotlin/io/element/android/libraries/preferences/api/store/AppPreferencesStore.kt index 6285c46389..0a774443a0 100644 --- a/libraries/preferences/api/src/main/kotlin/io/element/android/libraries/preferences/api/store/AppPreferencesStore.kt +++ b/libraries/preferences/api/src/main/kotlin/io/element/android/libraries/preferences/api/store/AppPreferencesStore.kt @@ -44,6 +44,14 @@ interface AppPreferencesStore { /** The name of the chosen theme, or `null` when the user has not picked one and the system setting should be followed. */ fun getThemeFlow(): Flow + /** + * @param expanded true to expand the other accounts section. + */ + suspend fun setOtherAccountsExpanded(expanded: Boolean) + + /** Whether the other accounts section is expanded; defaults to `true`. */ + fun isOtherAccountsExpandedFlow(): Flow + /** * @param value the distance in metres the user must move before a new live location is published. */ diff --git a/libraries/preferences/impl/src/main/kotlin/io/element/android/libraries/preferences/impl/store/DefaultAppPreferencesStore.kt b/libraries/preferences/impl/src/main/kotlin/io/element/android/libraries/preferences/impl/store/DefaultAppPreferencesStore.kt index 1a14a0ea8f..66a6eaf61f 100644 --- a/libraries/preferences/impl/src/main/kotlin/io/element/android/libraries/preferences/impl/store/DefaultAppPreferencesStore.kt +++ b/libraries/preferences/impl/src/main/kotlin/io/element/android/libraries/preferences/impl/store/DefaultAppPreferencesStore.kt @@ -33,6 +33,7 @@ private val developerModeKey = booleanPreferencesKey("developerMode") private val customElementCallBaseUrlKey = stringPreferencesKey("elementCallBaseUrl") private val themeKey = stringPreferencesKey("theme") private val hideInviteAvatarsKey = booleanPreferencesKey("hideInviteAvatars") +private val otherAccountsExpandedKey = booleanPreferencesKey("otherAccountsExpanded") private val timelineMediaPreviewValueKey = stringPreferencesKey("timelineMediaPreviewValue") private val liveLocationMinimumDistanceUpdateKey = intPreferencesKey("liveLocationMinimumDistanceUpdate") private val logLevelKey = stringPreferencesKey("logLevel") @@ -97,6 +98,18 @@ class DefaultAppPreferencesStore( } } + override suspend fun setOtherAccountsExpanded(expanded: Boolean) { + store.edit { prefs -> + prefs[otherAccountsExpandedKey] = expanded + } + } + + override fun isOtherAccountsExpandedFlow(): Flow { + return store.data.map { prefs -> + prefs[otherAccountsExpandedKey] ?: true + } + } + override suspend fun setLiveLocationMinimumDistanceInMetersUpdate(value: Int) { store.edit { prefs -> prefs[liveLocationMinimumDistanceUpdateKey] = value diff --git a/libraries/preferences/impl/src/test/kotlin/io/element/android/libraries/preferences/impl/store/DefaultAppPreferencesStoreTest.kt b/libraries/preferences/impl/src/test/kotlin/io/element/android/libraries/preferences/impl/store/DefaultAppPreferencesStoreTest.kt index 4cf018371a..1d30278bd1 100644 --- a/libraries/preferences/impl/src/test/kotlin/io/element/android/libraries/preferences/impl/store/DefaultAppPreferencesStoreTest.kt +++ b/libraries/preferences/impl/src/test/kotlin/io/element/android/libraries/preferences/impl/store/DefaultAppPreferencesStoreTest.kt @@ -120,6 +120,24 @@ class DefaultAppPreferencesStoreTest { assertThat(history.last()).isEqualTo("https://server5.org") } + @Test + fun `other accounts section is expanded by default`() = runTest { + val store = createStore() + + assertThat(store.isOtherAccountsExpandedFlow().first()).isTrue() + } + + @Test + fun `other accounts expanded state persists updates`() = runTest { + val store = createStore() + + store.setOtherAccountsExpanded(false) + assertThat(store.isOtherAccountsExpandedFlow().first()).isFalse() + + store.setOtherAccountsExpanded(true) + assertThat(store.isOtherAccountsExpandedFlow().first()).isTrue() + } + private fun createStore() = DefaultAppPreferencesStore( buildMeta = buildMeta, preferenceDataStoreFactory = FakePreferenceDataStoreFactory(), diff --git a/libraries/preferences/test/src/main/kotlin/io/element/android/libraries/preferences/test/InMemoryAppPreferencesStore.kt b/libraries/preferences/test/src/main/kotlin/io/element/android/libraries/preferences/test/InMemoryAppPreferencesStore.kt index e8d0b0bb8e..458be27ac9 100644 --- a/libraries/preferences/test/src/main/kotlin/io/element/android/libraries/preferences/test/InMemoryAppPreferencesStore.kt +++ b/libraries/preferences/test/src/main/kotlin/io/element/android/libraries/preferences/test/InMemoryAppPreferencesStore.kt @@ -21,6 +21,7 @@ import kotlinx.coroutines.flow.updateAndGet class InMemoryAppPreferencesStore( isDeveloperModeEnabled: Boolean = false, + isOtherAccountsExpanded: Boolean = true, customElementCallBaseUrl: String? = null, hideInviteAvatars: Boolean? = null, timelineMediaPreviewValue: MediaPreviewValue? = null, @@ -37,6 +38,7 @@ class InMemoryAppPreferencesStore( callRingtoneDisplayName: String? = null, ) : AppPreferencesStore { private val isDeveloperModeEnabled = MutableStateFlow(isDeveloperModeEnabled) + private val isOtherAccountsExpanded = MutableStateFlow(isOtherAccountsExpanded) private val customElementCallBaseUrl = MutableStateFlow(customElementCallBaseUrl) private val theme = MutableStateFlow(theme) private val liveLocationMinimumDistanceUpdate = MutableStateFlow(liveLocationMinimumDistanceUpdate) @@ -60,6 +62,14 @@ class InMemoryAppPreferencesStore( return isDeveloperModeEnabled } + override suspend fun setOtherAccountsExpanded(expanded: Boolean) { + isOtherAccountsExpanded.value = expanded + } + + override fun isOtherAccountsExpandedFlow(): Flow { + return isOtherAccountsExpanded + } + override suspend fun setCustomElementCallBaseUrl(string: String?) { customElementCallBaseUrl.tryEmit(string) } diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationResultProcessor.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationResultProcessor.kt index 88ba6c5ff9..e1b6687e14 100644 --- a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationResultProcessor.kt +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationResultProcessor.kt @@ -12,6 +12,7 @@ import dev.zacsweers.metro.ContributesBinding import dev.zacsweers.metro.SingleIn import io.element.android.features.call.api.CallData import io.element.android.features.call.api.ElementCallEntryPoint +import io.element.android.libraries.core.extensions.runCatchingExceptions import io.element.android.libraries.di.annotations.AppCoroutineScope import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.core.RoomId @@ -31,18 +32,25 @@ import io.element.android.libraries.push.impl.push.MutableBatteryOptimizationSto import io.element.android.libraries.push.impl.push.OnNotifiableEventReceived import io.element.android.libraries.push.impl.push.OnRedactedEventReceived import io.element.android.libraries.pushstore.api.UserPushStoreFactory +import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job -import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.launch +import kotlinx.coroutines.selects.select import timber.log.Timber private const val TAG = "NotifResultProcessor" interface NotificationResultProcessor { + /** + * Queue a batch of resolved push events for processing and suspend until it has been processed, so that the caller can keep the process + * alive (e.g. through a foreground service) until any resulting notification or ringing call has been set up. + */ suspend fun emit(results: Map>) fun start() fun stop() @@ -61,23 +69,50 @@ class DefaultNotificationResultProcessor( private val notificationChannels: NotificationChannels, @AppCoroutineScope private val coroutineScope: CoroutineScope, ) : NotificationResultProcessor { - private val resultFlow = MutableSharedFlow>>(extraBufferCapacity = Int.MAX_VALUE) + private data class PendingResults( + val results: Map>, + val processed: CompletableDeferred = CompletableDeferred(), + ) + + // A channel rather than a shared flow: it keeps the results until the collector takes them, so nothing is dropped if the collector isn't + // running yet (e.g. the worker runs in a process where no push has been received, after a WorkManager retry) or hasn't subscribed yet. + private val pendingResults = Channel(Channel.UNLIMITED) private var processJob: Job? = null override suspend fun emit(results: Map>) { - resultFlow.emit(results) + val collector = ensureStarted() + val pending = PendingResults(results) + pendingResults.send(pending) + // Suspend until the batch has been processed, or give up if the collector was stopped in the meantime. + select { + pending.processed.onAwait {} + collector.onJoin { Timber.tag(TAG).w("Processing stopped before the results were processed") } + } } override fun start() { - if (processJob?.isActive == true) { - Timber.tag(TAG).w("Is already processing, not starting again") - return - } - processJob = resultFlow - .onEach(::processResults) - .launchIn(coroutineScope) + ensureStarted() } + @Synchronized + private fun ensureStarted(): Job { + processJob?.takeIf { it.isActive }?.let { return it } + return pendingResults + .receiveAsFlow() + .onEach { pending -> + try { + // Don't let a failing batch cancel the collector, or subsequent batches would never be processed. + runCatchingExceptions { processResults(pending.results) } + .onFailure { Timber.tag(TAG).e(it, "Failed to process notification results") } + } finally { + pending.processed.complete(Unit) + } + } + .launchIn(coroutineScope) + .also { processJob = it } + } + + @Synchronized override fun stop() { if (processJob?.isActive != true) { Timber.tag(TAG).w("Is not processing, not stopping") diff --git a/libraries/push/impl/src/main/res/values-cs/translations.xml b/libraries/push/impl/src/main/res/values-cs/translations.xml index 03fac59852..b371f68a65 100644 --- a/libraries/push/impl/src/main/res/values-cs/translations.xml +++ b/libraries/push/impl/src/main/res/values-cs/translations.xml @@ -50,7 +50,7 @@ "%1$s zmínil(a) nebo odpověděl(a)" "Pozvali vás do prostoru" "%1$s vás pozvali do prostoru" - "Prohlížíte si oznámení! Klikněte na mě!" + "Prohlížíte si oznámení! Klepněte na mě!" "Vlákno v %1$s" "%1$s: %2$s" "%1$s: %2$s %3$s" @@ -100,7 +100,7 @@ "Oznámení nelze zobrazit." "Na oznámení bylo kliknuto!" "Zobrazit oznámení" - "Kliknutím na oznámení pokračujte v testu." + "Klepnutím na oznámení pokračujte v testu." "Ujistěte se, že aplikace přijímá push." "Chyba: pusher odmítl požadavek." "Chyba: %1$s." diff --git a/libraries/push/impl/src/main/res/values-fi/translations.xml b/libraries/push/impl/src/main/res/values-fi/translations.xml index 0d8f486663..9561140d05 100644 --- a/libraries/push/impl/src/main/res/values-fi/translations.xml +++ b/libraries/push/impl/src/main/res/values-fi/translations.xml @@ -34,6 +34,7 @@ "Kutsui sinut keskustelemaan" "%1$s kutsui sinut keskustelemaan" + "Pyytää liittymistä" "Aloitti reaaliaikaisen sijainnin jakamisen" "Mainitsi sinut: %1$s" "Uusia viestejä" @@ -50,7 +51,7 @@ "%1$s mainitsi tai vastasi" "Kutsui sinut liittymään tilaan" "%1$s kutsui sinut liittymään tilaan" - "Katselet ilmoitusta! Klikkaa minua!" + "Katselet ilmoitusta! Napauta minua!" "Viestiketju huoneessa %1$s" "%1$s: %2$s" "%1$s: %2$s %3$s" @@ -96,12 +97,13 @@ "Ilmoitusta ei voida näyttää." "Ilmoitusta on klikattu!" "Ilmoituksen näyttäminen" - "Klikkaa ilmoitusta jatkaaksesi testiä." + "Napauta ilmoitusta jatkaaksesi testiä." "Varmistaa, että sovellus vastaanottaa push-ilmoituksen." "Virhe: pusher on hylännyt pyynnön." "Virhe: %1$s." "Virhe, push-ilmoitusta ei voi testata." "Virhe, aikakatkaisu push-ilmoitusta odotellessa." + "Virhe: push-yhdyskäytävä rajoittaa tätä laittetta, joten ilmoitukset eivät ole luotettavia. Määritä toinen push-yhdyskäytävä." "Push-ilmoituksella kesti %1$d ms palata takaisin." "Testaa push-ilmoituksen paluu" diff --git a/libraries/push/impl/src/main/res/values-hu/translations.xml b/libraries/push/impl/src/main/res/values-hu/translations.xml index 4ee957bc23..bcfa1fd94f 100644 --- a/libraries/push/impl/src/main/res/values-hu/translations.xml +++ b/libraries/push/impl/src/main/res/values-hu/translations.xml @@ -19,6 +19,10 @@ "Van %d új üzenete." "Van %d új üzenete." + + "Megosztott %1$d melléklet" + "Megosztott %1$d melléklet" + "📞 Bejövő hívás" "📹 Bejövő hívás" "** Nem sikerült elküldeni – nyissa meg a szobát" @@ -30,6 +34,7 @@ "Meghívta, hogy csevegjen" "%1$s meghívta egy csevegésre" + "Csatlakozási kérelem benyújtva" "Elkezdték megosztani az élő helyzetüket" "Megemlítette Önt: %1$s" "Új üzenetek" @@ -92,12 +97,13 @@ "Az értesítés nem jeleníthető meg." "Az értesítésre rákattintottak!" "Értesítés megjelenítése" - "A teszt folytatásához kattintson az értesítésre." + "A teszt folytatásához koppintson az értesítésre." "Győződjön meg arról, hogy az alkalmazás megkapja-e a leküldéses értesítést." "Hiba: a leküldő elutasította a kérést." "Hiba: %1$s." "Hiba, nem lehet tesztelni a leküldéses értesítést." "Hiba, időtúllépés a leküldéses értesítésre való várakozás során." + "Hiba: a push-átjáró korlátozza ennek az eszköznek a forgalmát, ezért az értesítések nem lesznek megbízhatóak. Kérjük, állítson be egy másik push-átjárót." "A leküldéses értesítés folyamata %1$d ezredmásodpercig tartott." "Leküldéses értesítés folyamatának tesztelése" diff --git a/libraries/push/impl/src/main/res/values-ja/translations.xml b/libraries/push/impl/src/main/res/values-ja/translations.xml index 6b03bcc674..a80846e066 100644 --- a/libraries/push/impl/src/main/res/values-ja/translations.xml +++ b/libraries/push/impl/src/main/res/values-ja/translations.xml @@ -45,7 +45,7 @@ "%1$s がメンションまたは返信しました" "スペースに招待されました" "%1$s があなたをスペースに招待しました" - "通知を表示しています。タップしてください。" + "通知を確認できています。この通知をタップしてください。" "%1$sのスレッド" "%1$s: %2$s" "%1$s: %2$s %3$s" diff --git a/libraries/push/impl/src/main/res/values-sv/translations.xml b/libraries/push/impl/src/main/res/values-sv/translations.xml index 9f978c253f..9f6edfa499 100644 --- a/libraries/push/impl/src/main/res/values-sv/translations.xml +++ b/libraries/push/impl/src/main/res/values-sv/translations.xml @@ -37,7 +37,7 @@ "%1$s bjöd in dig att gå med i rummet" "Jag" "%1$s nämnde eller svarade" - "Du tittar på aviseringen! Klicka på mig!" + "Du tittar på aviseringen! Tryck på mig!" "%1$s: %2$s" "%1$s: %2$s %3$s" diff --git a/libraries/push/impl/src/main/res/values-zh-rTW/translations.xml b/libraries/push/impl/src/main/res/values-zh-rTW/translations.xml index 2860bc187c..f14787e90a 100644 --- a/libraries/push/impl/src/main/res/values-zh-rTW/translations.xml +++ b/libraries/push/impl/src/main/res/values-zh-rTW/translations.xml @@ -45,7 +45,7 @@ "%1$s 提及或回覆" "已邀請您加入空間" "%1$s 已邀請您的加入此空間" - "您正在查看通知!點我!" + "您正在檢視通知!點我!" "在 %1$s 的討論串" "%1$s:%2$s" "%1$s:%2$s %3$s" @@ -87,7 +87,7 @@ "無法顯示通知。" "已點選通知!" "顯示通知" - "請點選通知以繼續測試。" + "請輕點通知以繼續測試。" "確保應用程式正在接收推播。" "錯誤:推播程式拒絕請求。" "錯誤:%1$s。" diff --git a/libraries/push/impl/src/main/res/values-zh/translations.xml b/libraries/push/impl/src/main/res/values-zh/translations.xml index f976bc2dff..b50cc36418 100644 --- a/libraries/push/impl/src/main/res/values-zh/translations.xml +++ b/libraries/push/impl/src/main/res/values-zh/translations.xml @@ -6,15 +6,15 @@ "响铃通话" "静默通知" - "%1$s:%2$d 个消息" + "%1$s:%2$d 条消息" - "%d 个通知" + "%d 条通知" "UnifiedPush 分发器注册失败,你将无法再接收通知。请检查 app 的通知设置及推送分发器的状态。" "你有新消息。" - "你有 %d 个新消息。" + "你有 %d 条新消息。" "已共享 %1$d 个附件" @@ -34,7 +34,7 @@ "提到了你:%1$s" "新消息" - "%d 个新消息" + "%d 条新消息" "已使用 %1$s 反应" "设为已读" @@ -50,7 +50,7 @@ "%1$s:%2$s" "%1$s: %2$s %3$s" - "%d 个未读消息" + "%d 条未读消息" "%1$s 和 %2$s" "%2$s 中的 %1$s" diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/DefaultNotificationResultProcessorTest.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/DefaultNotificationResultProcessorTest.kt index 21a2c6cb91..a27e1cacc4 100644 --- a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/DefaultNotificationResultProcessorTest.kt +++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/DefaultNotificationResultProcessorTest.kt @@ -303,6 +303,48 @@ class DefaultNotificationResultProcessorTest { ) } + @Test + fun `emit starts the processing if needed and only returns once the results have been processed`() = runTest { + val onNotifiableEventsReceived = lambdaRecorder, Unit> {} + val processor = createDefaultNotificationResultProcessor( + pushHistoryService = FakePushHistoryService(onPushReceivedResult = { _, _, _, _, _, _, _ -> }), + onNotifiableEventsReceived = onNotifiableEventsReceived, + ) + + // No call to start(): the worker may run in a process where no push has been received yet + processor.emit(mapOf(aPushRequest() to Result.success(ResolvedPushEvent.Event(aNotifiableMessageEvent())))) + + // No runCurrent(): the results must already have been processed when emit returns + onNotifiableEventsReceived.assertions().isCalledOnce() + + processor.stop() + } + + @Test + fun `a failing batch does not prevent later batches from being processed`() = runTest { + var pushHistoryCalls = 0 + val pushHistoryService = FakePushHistoryService( + onPushReceivedResult = { _, _, _, _, _, _, _ -> + pushHistoryCalls++ + if (pushHistoryCalls == 1) error("boom") + }, + ) + val onNotifiableEventsReceived = lambdaRecorder, Unit> {} + val processor = createDefaultNotificationResultProcessor( + pushHistoryService = pushHistoryService, + onNotifiableEventsReceived = onNotifiableEventsReceived, + ) + + runningProcessor(processor) { + // Processing the first batch throws, but emit must still return and the collector must survive + emit(mapOf(aPushRequest() to Result.success(ResolvedPushEvent.Event(aNotifiableMessageEvent())))) + emit(mapOf(aPushRequest() to Result.success(ResolvedPushEvent.Event(aNotifiableMessageEvent())))) + } + + // Only the second batch made it to the notification drawer + onNotifiableEventsReceived.assertions().isCalledOnce() + } + private suspend fun TestScope.runningProcessor(processor: NotificationResultProcessor, block: suspend NotificationResultProcessor.() -> Unit) { processor.start() diff --git a/libraries/session-storage/api/src/main/kotlin/io/element/android/libraries/sessionstorage/api/SessionData.kt b/libraries/session-storage/api/src/main/kotlin/io/element/android/libraries/sessionstorage/api/SessionData.kt index 88cf9558b3..fa4e22232d 100644 --- a/libraries/session-storage/api/src/main/kotlin/io/element/android/libraries/sessionstorage/api/SessionData.kt +++ b/libraries/session-storage/api/src/main/kotlin/io/element/android/libraries/sessionstorage/api/SessionData.kt @@ -46,4 +46,6 @@ data class SessionData( val userDisplayName: String?, /** The optional avatar URL of the user. */ val userAvatarUrl: String?, + /** The optional thumbnail of the avatar of the user ([userAvatarUrl]), base64 encoded. */ + val userAvatarData: String?, ) diff --git a/libraries/session-storage/api/src/main/kotlin/io/element/android/libraries/sessionstorage/api/SessionStore.kt b/libraries/session-storage/api/src/main/kotlin/io/element/android/libraries/sessionstorage/api/SessionStore.kt index 641a2afdf2..a84a1f00dc 100644 --- a/libraries/session-storage/api/src/main/kotlin/io/element/android/libraries/sessionstorage/api/SessionStore.kt +++ b/libraries/session-storage/api/src/main/kotlin/io/element/android/libraries/sessionstorage/api/SessionStore.kt @@ -51,8 +51,9 @@ interface SessionStore { * @param sessionId the session to update. * @param displayName the new display name, or `null` when the user has none. * @param avatarUrl the new avatar, or `null` when the user has none. + * @param avatarData the thumbnail of the new avatar, base64 encoded, or `null` when the user has none or when it could not be loaded. */ - suspend fun updateUserProfile(sessionId: String, displayName: String?, avatarUrl: String?) + suspend fun updateUserProfile(sessionId: String, displayName: String?, avatarUrl: String?, avatarData: String?) /** * Get the session data matching the userId, or null if not found. diff --git a/libraries/session-storage/impl/src/main/kotlin/io/element/android/libraries/sessionstorage/impl/DatabaseSessionStore.kt b/libraries/session-storage/impl/src/main/kotlin/io/element/android/libraries/sessionstorage/impl/DatabaseSessionStore.kt index 9be59e4bae..f183c42225 100644 --- a/libraries/session-storage/impl/src/main/kotlin/io/element/android/libraries/sessionstorage/impl/DatabaseSessionStore.kt +++ b/libraries/session-storage/impl/src/main/kotlin/io/element/android/libraries/sessionstorage/impl/DatabaseSessionStore.kt @@ -84,12 +84,13 @@ class DatabaseSessionStore( lastUsageIndex = result.lastUsageIndex, userDisplayName = result.userDisplayName, userAvatarUrl = result.userAvatarUrl, + userAvatarData = result.userAvatarData, ).toDbModel() ) } } - override suspend fun updateUserProfile(sessionId: String, displayName: String?, avatarUrl: String?) { + override suspend fun updateUserProfile(sessionId: String, displayName: String?, avatarUrl: String?, avatarData: String?) { sessionDataMutex.withLock { val result = database.sessionDataQueries.selectByUserId(sessionId) .executeAsOneOrNull() @@ -102,6 +103,7 @@ class DatabaseSessionStore( result.copy( userDisplayName = displayName, userAvatarUrl = avatarUrl, + userAvatarData = avatarData, ).toDbModel() ) } diff --git a/libraries/session-storage/impl/src/main/kotlin/io/element/android/libraries/sessionstorage/impl/SessionDataMapper.kt b/libraries/session-storage/impl/src/main/kotlin/io/element/android/libraries/sessionstorage/impl/SessionDataMapper.kt index 316bf86c1c..0c55d46f66 100644 --- a/libraries/session-storage/impl/src/main/kotlin/io/element/android/libraries/sessionstorage/impl/SessionDataMapper.kt +++ b/libraries/session-storage/impl/src/main/kotlin/io/element/android/libraries/sessionstorage/impl/SessionDataMapper.kt @@ -31,6 +31,7 @@ internal fun SessionData.toDbModel(): DbSessionData { lastUsageIndex = lastUsageIndex, userDisplayName = userDisplayName, userAvatarUrl = userAvatarUrl, + userAvatarData = userAvatarData, ) } @@ -52,5 +53,6 @@ internal fun DbSessionData.toApiModel(): SessionData { lastUsageIndex = lastUsageIndex, userDisplayName = userDisplayName, userAvatarUrl = userAvatarUrl, + userAvatarData = userAvatarData, ) } diff --git a/libraries/session-storage/impl/src/main/sqldelight/databases/13.db b/libraries/session-storage/impl/src/main/sqldelight/databases/13.db new file mode 100644 index 0000000000..e2d2bebe6a Binary files /dev/null and b/libraries/session-storage/impl/src/main/sqldelight/databases/13.db differ diff --git a/libraries/session-storage/impl/src/main/sqldelight/io/element/android/libraries/matrix/session/SessionData.sq b/libraries/session-storage/impl/src/main/sqldelight/io/element/android/libraries/matrix/session/SessionData.sq index 8e5fb7a1f8..869bca34ed 100644 --- a/libraries/session-storage/impl/src/main/sqldelight/io/element/android/libraries/matrix/session/SessionData.sq +++ b/libraries/session-storage/impl/src/main/sqldelight/io/element/android/libraries/matrix/session/SessionData.sq @@ -36,7 +36,10 @@ CREATE TABLE SessionData ( -- user display name userDisplayName TEXT, -- user avatar url - userAvatarUrl TEXT + userAvatarUrl TEXT, + -- added in version 13 + -- user avatar thumbnail data, base64 encoded + userAvatarData TEXT ); diff --git a/libraries/session-storage/impl/src/main/sqldelight/migrations/12.sqm b/libraries/session-storage/impl/src/main/sqldelight/migrations/12.sqm new file mode 100644 index 0000000000..f6ba7fb28a --- /dev/null +++ b/libraries/session-storage/impl/src/main/sqldelight/migrations/12.sqm @@ -0,0 +1,4 @@ +-- Migrate DB from version 12 +-- Add the thumbnail of the user avatar, base64 encoded, so that the avatar can be rendered without a Matrix client. + +ALTER TABLE SessionData ADD COLUMN userAvatarData TEXT; diff --git a/libraries/session-storage/impl/src/test/kotlin/io/element/android/libraries/sessionstorage/impl/DatabaseSessionStoreTest.kt b/libraries/session-storage/impl/src/test/kotlin/io/element/android/libraries/sessionstorage/impl/DatabaseSessionStoreTest.kt index e40000986b..cc91ee8039 100644 --- a/libraries/session-storage/impl/src/test/kotlin/io/element/android/libraries/sessionstorage/impl/DatabaseSessionStoreTest.kt +++ b/libraries/session-storage/impl/src/test/kotlin/io/element/android/libraries/sessionstorage/impl/DatabaseSessionStoreTest.kt @@ -134,17 +134,18 @@ class DatabaseSessionStoreTest { @Test fun `updateUserProfile does nothing if the session is not found`() = runTest { - databaseSessionStore.updateUserProfile(aSessionData.userId, "userDisplayName", "userAvatarUrl") + databaseSessionStore.updateUserProfile(aSessionData.userId, "userDisplayName", "userAvatarUrl", "userAvatarData") assertThat(database.sessionDataQueries.selectByUserId(aSessionData.userId).executeAsOneOrNull()).isNull() } @Test fun `updateUserProfile update the data`() = runTest { database.sessionDataQueries.insertSessionData(aSessionData) - databaseSessionStore.updateUserProfile(aSessionData.userId, "userDisplayName", "userAvatarUrl") + databaseSessionStore.updateUserProfile(aSessionData.userId, "userDisplayName", "userAvatarUrl", "userAvatarData") val updatedSession = database.sessionDataQueries.selectByUserId(aSessionData.userId).executeAsOne() assertThat(updatedSession.userDisplayName).isEqualTo("userDisplayName") assertThat(updatedSession.userAvatarUrl).isEqualTo("userAvatarUrl") + assertThat(updatedSession.userAvatarData).isEqualTo("userAvatarData") } @Test @@ -231,6 +232,7 @@ class DatabaseSessionStoreTest { lastUsageIndex = 0, userDisplayName = "userDisplayName", userAvatarUrl = "userAvatarUrl", + userAvatarData = "userAvatarData", ) val secondSessionData = SessionData( userId = "userId", @@ -249,6 +251,7 @@ class DatabaseSessionStoreTest { lastUsageIndex = 1, userDisplayName = "userDisplayNameAltered", userAvatarUrl = "userAvatarUrlAltered", + userAvatarData = "userAvatarDataAltered", ) assertThat(firstSessionData.userId).isEqualTo(secondSessionData.userId) assertThat(firstSessionData.loginTimestamp).isNotEqualTo(secondSessionData.loginTimestamp) @@ -273,6 +276,7 @@ class DatabaseSessionStoreTest { assertThat(alteredSession.lastUsageIndex).isEqualTo(firstSessionData.lastUsageIndex) assertThat(alteredSession.userDisplayName).isEqualTo(firstSessionData.userDisplayName) assertThat(alteredSession.userAvatarUrl).isEqualTo(firstSessionData.userAvatarUrl) + assertThat(alteredSession.userAvatarData).isEqualTo(firstSessionData.userAvatarData) } @Test @@ -294,6 +298,7 @@ class DatabaseSessionStoreTest { lastUsageIndex = 0, userDisplayName = "userDisplayName", userAvatarUrl = "userAvatarUrl", + userAvatarData = "userAvatarData", ) val secondSessionData = SessionData( userId = "userIdUnknown", @@ -312,6 +317,7 @@ class DatabaseSessionStoreTest { lastUsageIndex = 1, userDisplayName = "userDisplayNameAltered", userAvatarUrl = "userAvatarUrlAltered", + userAvatarData = "userAvatarDataAltered", ) assertThat(firstSessionData.userId).isNotEqualTo(secondSessionData.userId) diff --git a/libraries/session-storage/impl/src/test/kotlin/io/element/android/libraries/sessionstorage/impl/Fixtures.kt b/libraries/session-storage/impl/src/test/kotlin/io/element/android/libraries/sessionstorage/impl/Fixtures.kt index 4a488588ff..a6292631e9 100644 --- a/libraries/session-storage/impl/src/test/kotlin/io/element/android/libraries/sessionstorage/impl/Fixtures.kt +++ b/libraries/session-storage/impl/src/test/kotlin/io/element/android/libraries/sessionstorage/impl/Fixtures.kt @@ -30,4 +30,5 @@ internal fun aDbSessionData( lastUsageIndex = 0, userDisplayName = null, userAvatarUrl = null, + userAvatarData = null, ) diff --git a/libraries/session-storage/test/src/main/kotlin/io/element/android/libraries/sessionstorage/test/InMemorySessionStore.kt b/libraries/session-storage/test/src/main/kotlin/io/element/android/libraries/sessionstorage/test/InMemorySessionStore.kt index 05e58ac623..ac54c1f1d4 100644 --- a/libraries/session-storage/test/src/main/kotlin/io/element/android/libraries/sessionstorage/test/InMemorySessionStore.kt +++ b/libraries/session-storage/test/src/main/kotlin/io/element/android/libraries/sessionstorage/test/InMemorySessionStore.kt @@ -18,7 +18,7 @@ import kotlinx.coroutines.flow.map class InMemorySessionStore( initialList: List = emptyList(), - private val updateUserProfileResult: (String, String?, String?) -> Unit = { _, _, _ -> error("Not implemented") }, + private val updateUserProfileResult: (String, String?, String?, String?) -> Unit = { _, _, _, _ -> error("Not implemented") }, private val setLatestSessionResult: (String) -> Unit = { error("Not implemented") }, ) : SessionStore { private val sessionDataListFlow = MutableStateFlow(initialList) @@ -56,8 +56,8 @@ class InMemorySessionStore( } } - override suspend fun updateUserProfile(sessionId: String, displayName: String?, avatarUrl: String?) { - updateUserProfileResult(sessionId, displayName, avatarUrl) + override suspend fun updateUserProfile(sessionId: String, displayName: String?, avatarUrl: String?, avatarData: String?) { + updateUserProfileResult(sessionId, displayName, avatarUrl, avatarData) } override suspend fun getSession(sessionId: String): SessionData? { diff --git a/libraries/session-storage/test/src/main/kotlin/io/element/android/libraries/sessionstorage/test/SessionData.kt b/libraries/session-storage/test/src/main/kotlin/io/element/android/libraries/sessionstorage/test/SessionData.kt index c5acd77755..c7c1856f8c 100644 --- a/libraries/session-storage/test/src/main/kotlin/io/element/android/libraries/sessionstorage/test/SessionData.kt +++ b/libraries/session-storage/test/src/main/kotlin/io/element/android/libraries/sessionstorage/test/SessionData.kt @@ -23,6 +23,7 @@ fun aSessionData( lastUsageIndex: Long = 0, userDisplayName: String? = null, userAvatarUrl: String? = null, + userAvatarData: String? = null, ): SessionData { return SessionData( userId = sessionId, @@ -41,5 +42,6 @@ fun aSessionData( lastUsageIndex = lastUsageIndex, userDisplayName = userDisplayName, userAvatarUrl = userAvatarUrl, + userAvatarData = userAvatarData, ) } diff --git a/libraries/testtags/src/main/kotlin/io/element/android/libraries/testtags/TestTags.kt b/libraries/testtags/src/main/kotlin/io/element/android/libraries/testtags/TestTags.kt index 7fed08b3e9..942d20de91 100644 --- a/libraries/testtags/src/main/kotlin/io/element/android/libraries/testtags/TestTags.kt +++ b/libraries/testtags/src/main/kotlin/io/element/android/libraries/testtags/TestTags.kt @@ -48,6 +48,11 @@ object TestTags { val homeScreenSettings = TestTag("home_screen-settings") val homeScreenClearFilters = TestTag("home_screen-clear_filters") + /** + * Settings screen. + */ + val settingsUserProfile = TestTag("settings-user_profile") + /** * Room detail screen. */ diff --git a/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/TextComposer.kt b/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/TextComposer.kt index fcb89247b5..020ac6c371 100644 --- a/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/TextComposer.kt +++ b/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/TextComposer.kt @@ -370,6 +370,7 @@ fun TextComposer( waveform = voiceMessageState.waveform, playbackProgress = voiceMessageState.playbackProgress, time = voiceMessageState.time, + duration = voiceMessageState.duration, onPlayClick = onPlayVoiceMessageClick, onPauseClick = onPauseVoiceMessageClick, onSeek = onSeekVoiceMessage, @@ -929,6 +930,7 @@ internal fun TextComposerVoicePreview() = ElementPreview { showCursor = false, waveform = WaveFormSamples.realisticWaveForm, time = 0.seconds, + duration = 61.seconds, playbackProgress = 0.0f, ), VoiceMessageState.Preview( @@ -937,6 +939,7 @@ internal fun TextComposerVoicePreview() = ElementPreview { showCursor = true, waveform = WaveFormSamples.realisticWaveForm, time = 3.seconds, + duration = 0.seconds, playbackProgress = 0.2f, ), VoiceMessageState.Preview( @@ -945,6 +948,7 @@ internal fun TextComposerVoicePreview() = ElementPreview { showCursor = false, waveform = WaveFormSamples.realisticWaveForm, time = 61.seconds, + duration = 61.seconds, playbackProgress = 0.0f, ), ) @@ -972,6 +976,7 @@ internal fun TextComposerVoiceNotEncryptedPreview() = ElementPreview { showCursor = false, waveform = WaveFormSamples.realisticWaveForm, time = 0.seconds, + duration = 61.seconds, playbackProgress = 0.0f ), VoiceMessageState.Preview( @@ -980,6 +985,7 @@ internal fun TextComposerVoiceNotEncryptedPreview() = ElementPreview { showCursor = true, waveform = WaveFormSamples.realisticWaveForm, time = 3.seconds, + duration = 0.seconds, playbackProgress = 0.2f ), VoiceMessageState.Preview( @@ -988,6 +994,7 @@ internal fun TextComposerVoiceNotEncryptedPreview() = ElementPreview { showCursor = false, waveform = WaveFormSamples.realisticWaveForm, time = 61.seconds, + duration = 61.seconds, playbackProgress = 0.0f ), ) diff --git a/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/components/VoiceMessagePreview.kt b/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/components/VoiceMessagePreview.kt index 8ca90843a4..6e81eea08f 100644 --- a/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/components/VoiceMessagePreview.kt +++ b/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/components/VoiceMessagePreview.kt @@ -50,6 +50,7 @@ internal fun VoiceMessagePreview( showCursor: Boolean, waveform: ImmutableList, time: Duration, + duration: Duration, onPlayClick: () -> Unit, onPauseClick: () -> Unit, onSeek: (Float) -> Unit, @@ -88,6 +89,8 @@ internal fun VoiceMessagePreview( playbackProgress = playbackProgress, showCursor = showCursor, waveform = waveform, + isPlaying = isPlaying, + durationMs = duration.inWholeMilliseconds, seekEnabled = true, onSeek = onSeek, ) @@ -151,6 +154,8 @@ internal fun VoiceMessagePreviewPreview() = ElementPreview { isInteractive = true, isPlaying = true, time = 2.seconds, + // Keep previews static: a non-zero duration would start a long linear tween. + duration = 0.seconds, playbackProgress = 0.2f, showCursor = true, waveform = WaveFormSamples.longRealisticWaveForm, @@ -159,6 +164,7 @@ internal fun VoiceMessagePreviewPreview() = ElementPreview { isInteractive = true, isPlaying = false, time = 0.seconds, + duration = 10.seconds, playbackProgress = 0.0f, showCursor = true, waveform = WaveFormSamples.longRealisticWaveForm, @@ -167,6 +173,7 @@ internal fun VoiceMessagePreviewPreview() = ElementPreview { isInteractive = false, isPlaying = false, time = 789.seconds, + duration = 789.seconds, playbackProgress = 0.0f, showCursor = false, waveform = WaveFormSamples.longRealisticWaveForm, @@ -179,6 +186,7 @@ private fun AVoiceMessagePreview( isInteractive: Boolean, isPlaying: Boolean, time: Duration, + duration: Duration, playbackProgress: Float, showCursor: Boolean, waveform: ImmutableList, @@ -187,6 +195,7 @@ private fun AVoiceMessagePreview( isInteractive = isInteractive, isPlaying = isPlaying, time = time, + duration = duration, playbackProgress = playbackProgress, showCursor = showCursor, waveform = waveform, diff --git a/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/model/VoiceMessageState.kt b/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/model/VoiceMessageState.kt index 54fa2bcae7..de8c85d3a6 100644 --- a/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/model/VoiceMessageState.kt +++ b/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/model/VoiceMessageState.kt @@ -22,6 +22,7 @@ sealed interface VoiceMessageState { val showCursor: Boolean, val playbackProgress: Float, val time: Duration, + val duration: Duration, // Values are between 0 and 1 val waveform: ImmutableList, ) : VoiceMessageState diff --git a/libraries/ui-common/build.gradle.kts b/libraries/ui-common/build.gradle.kts index 76c772f9fc..9487adbcf1 100644 --- a/libraries/ui-common/build.gradle.kts +++ b/libraries/ui-common/build.gradle.kts @@ -16,5 +16,7 @@ android { dependencies { implementation(libs.appyx.core) + implementation(projects.libraries.core) implementation(projects.libraries.designsystem) + implementation(projects.libraries.uiStrings) } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/layout/ContentAvoidingLayout.kt b/libraries/ui-common/src/main/kotlin/io/element/android/libraries/ui/common/layout/ContentAvoidingLayout.kt similarity index 95% rename from features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/layout/ContentAvoidingLayout.kt rename to libraries/ui-common/src/main/kotlin/io/element/android/libraries/ui/common/layout/ContentAvoidingLayout.kt index cebe7f93b2..3ba0ad5942 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/layout/ContentAvoidingLayout.kt +++ b/libraries/ui-common/src/main/kotlin/io/element/android/libraries/ui/common/layout/ContentAvoidingLayout.kt @@ -6,7 +6,7 @@ * Please see LICENSE files in the repository root for full details. */ -package io.element.android.features.messages.impl.timeline.components.layout +package io.element.android.libraries.ui.common.layout import android.text.Layout import androidx.compose.foundation.layout.Column @@ -25,7 +25,6 @@ import androidx.compose.ui.unit.dp import io.element.android.libraries.core.data.tryOrNull import io.element.android.libraries.designsystem.text.roundToPx import io.element.android.libraries.designsystem.utils.LocalUiTestMode -import io.element.android.wysiwyg.compose.EditorStyledText import kotlin.math.max import kotlin.math.min import kotlin.math.roundToInt @@ -91,7 +90,7 @@ fun ContentAvoidingLayout( when { // When the content + the overlay don't fit in the available max width, we need to move the overlay to a new row - !shrinkContent && data.nonOverlappingContentWidth + overlayPlaceable.width > constraints.maxWidth -> { + !data.canOverlay() || !shrinkContent && data.nonOverlappingContentWidth + overlayPlaceable.width > constraints.maxWidth -> { layoutHeight += overlayPlaceable.height + overlayOffset.y.roundToPx() } // If the content is smaller than the available max width, we can move the overlay to the right of the content @@ -130,7 +129,13 @@ data class ContentAvoidingLayoutData( val contentHeight: Int = 0, val nonOverlappingContentWidth: Int = contentWidth, val nonOverlappingContentHeight: Int = contentHeight, -) +) { + fun canOverlay(): Boolean = contentWidth != Int.MAX_VALUE + + companion object { + val NotOverlapping = ContentAvoidingLayoutData(contentWidth = Int.MAX_VALUE, contentHeight = Int.MAX_VALUE) + } +} /** * A scope for the [ContentAvoidingLayout]. @@ -157,7 +162,7 @@ object ContentAvoidingLayout { * This is supposed to be used in the `onTextLayout` parameter of a Text based component. */ @Composable - internal fun measureLastTextLine( + fun measureLastTextLine( onContentLayoutChange: (ContentAvoidingLayoutData) -> Unit, extraWidth: Dp = 0.dp, ): ((TextLayoutResult) -> Unit) { @@ -187,7 +192,7 @@ object ContentAvoidingLayout { * This is supposed to be used in the `onTextLayout` parameter of an [EditorStyledText] component. */ @Composable - internal fun measureLegacyLastTextLine( + fun measureLegacyLastTextLine( onContentLayoutChange: (ContentAvoidingLayoutData) -> Unit, extraWidth: Dp = 0.dp, ): ((Layout) -> Unit) { diff --git a/libraries/ui-common/src/main/kotlin/io/element/android/libraries/ui/common/nodes/EmptyNode.kt b/libraries/ui-common/src/main/kotlin/io/element/android/libraries/ui/common/nodes/EmptyNode.kt index 53cc8c8443..1e7e1b8d21 100644 --- a/libraries/ui-common/src/main/kotlin/io/element/android/libraries/ui/common/nodes/EmptyNode.kt +++ b/libraries/ui-common/src/main/kotlin/io/element/android/libraries/ui/common/nodes/EmptyNode.kt @@ -8,38 +8,23 @@ package io.element.android.libraries.ui.common.nodes -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.fillMaxSize -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.node.node -import io.element.android.compound.theme.ElementTheme -import io.element.android.libraries.designsystem.preview.ElementPreview -import io.element.android.libraries.designsystem.preview.PreviewsDayNight +import kotlin.time.Duration /** * Ref: https://www.figma.com/design/0MMNu7cTOzLOlWb7ctTkv3/Element-X?node-id=1518-85323 + * + * @param buildContext the [BuildContext] of the Node. + * @param state the [EmptyState] of the Node. Default value will render an empty node forever. */ fun emptyNode( buildContext: BuildContext, + state: EmptyState = EmptyState(Duration.INFINITE), ): Node = node(buildContext) { modifier -> - EmptyView(modifier) -} - -@Composable -private fun EmptyView( - modifier: Modifier = Modifier, -) = Box( - modifier = modifier - .fillMaxSize() - .background(ElementTheme.colors.bgCanvasDefault), -) - -@PreviewsDayNight -@Composable -internal fun EmptyViewPreview() = ElementPreview { - EmptyView(Modifier) + EmptyView( + state = state, + modifier = modifier, + ) } diff --git a/libraries/ui-common/src/main/kotlin/io/element/android/libraries/ui/common/nodes/EmptyState.kt b/libraries/ui-common/src/main/kotlin/io/element/android/libraries/ui/common/nodes/EmptyState.kt new file mode 100644 index 0000000000..0930523881 --- /dev/null +++ b/libraries/ui-common/src/main/kotlin/io/element/android/libraries/ui/common/nodes/EmptyState.kt @@ -0,0 +1,18 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.ui.common.nodes + +import kotlin.time.Duration + +/** + * @property delayBeforeShowingContent when finite, a loading indicator fades in after this delay. Use it for + * placeholders which can stay on screen for a long time, or forever, else the application looks frozen. + */ +data class EmptyState( + val delayBeforeShowingContent: Duration, +) diff --git a/libraries/ui-common/src/main/kotlin/io/element/android/libraries/ui/common/nodes/EmptyStatePreviewParam.kt b/libraries/ui-common/src/main/kotlin/io/element/android/libraries/ui/common/nodes/EmptyStatePreviewParam.kt new file mode 100644 index 0000000000..c0f1705276 --- /dev/null +++ b/libraries/ui-common/src/main/kotlin/io/element/android/libraries/ui/common/nodes/EmptyStatePreviewParam.kt @@ -0,0 +1,20 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.ui.common.nodes + +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import kotlin.time.Duration +import kotlin.time.Duration.Companion.seconds + +open class EmptyStatePreviewParam : PreviewParameterProvider { + override val values: Sequence + get() = sequenceOf( + EmptyState(Duration.INFINITE), + EmptyState(1.seconds), + ) +} diff --git a/libraries/ui-common/src/main/kotlin/io/element/android/libraries/ui/common/nodes/EmptyView.kt b/libraries/ui-common/src/main/kotlin/io/element/android/libraries/ui/common/nodes/EmptyView.kt new file mode 100644 index 0000000000..77ff6555a5 --- /dev/null +++ b/libraries/ui-common/src/main/kotlin/io/element/android/libraries/ui/common/nodes/EmptyView.kt @@ -0,0 +1,67 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.ui.common.nodes + +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.fillMaxSize +import androidx.compose.foundation.layout.padding +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.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.CircularProgressIndicator +import io.element.android.libraries.designsystem.theme.components.Text +import io.element.android.libraries.designsystem.utils.DelayedVisibility +import io.element.android.libraries.ui.strings.CommonStrings + +@Composable +fun EmptyView( + state: EmptyState, + modifier: Modifier = Modifier, +) = Box( + modifier = modifier + .fillMaxSize() + .background(ElementTheme.colors.bgCanvasDefault), + contentAlignment = Alignment.Center, +) { + if (state.delayBeforeShowingContent.isFinite()) { + DelayedVisibility(duration = state.delayBeforeShowingContent) { + LoadingContent() + } + } +} + +@Composable +private fun LoadingContent() = Column( + modifier = Modifier.padding(16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(16.dp), +) { + CircularProgressIndicator() + Text( + text = stringResource(CommonStrings.common_loading), + color = ElementTheme.colors.textSecondary, + style = ElementTheme.typography.fontBodyMdRegular, + ) +} + +@PreviewsDayNight +@Composable +internal fun EmptyViewPreview( + @PreviewParameter(EmptyStatePreviewParam::class) state: EmptyState +) = ElementPreview { + EmptyView(state = state) +} diff --git a/libraries/ui-strings/src/main/res/values-cs/translations.xml b/libraries/ui-strings/src/main/res/values-cs/translations.xml index 11ded645d7..97bbef824d 100644 --- a/libraries/ui-strings/src/main/res/values-cs/translations.xml +++ b/libraries/ui-strings/src/main/res/values-cs/translations.xml @@ -1,5 +1,6 @@ + "Přidat avatar" "Přidat reakci: %1$s" "Adresa" "Profilový obrázek" @@ -10,6 +11,7 @@ "zadány %1$d číslice" "zadáno %1$d číslic" + "Doba trvání: %1$s" "Upravit avatar" "Úplná adresa bude %1$s" "Podrobnosti o šifrování" @@ -18,6 +20,7 @@ "Informace" "Připojit se k hovoru" "Přejít dolů" + "Přejít na nepřečtené" "Přesunout mapu na mou polohu" "Pouze zmínky" "Ztišeno" @@ -34,16 +37,16 @@ "Rychlost přehrávání" "Hlasování" "Hlasování ukončeno" + "Pozice: %1$s" "QR kód" "Reagovat s %1$s" "Reagovat s dalšími emoji" - "%1$s a %2$s přečetli" + "Předchozí zprávu přečetli %1$s a %2$s" - "Přečetl(a) %1$s a %2$d další" - "Přečetl(a) %1$s a %2$d další" - "Přečetl(a) %1$s a %2$d dalších" + "Předchozí zprávu přečetl(a) %1$s a %2$d další" + "Předchozí zprávu přečetl(a) %1$s a %2$d další" - "%1$s přečetl(a)" + "Předchozí zprávu přečetl(a) %1$s" "Odstraňit reakci s %1$s" "Odstranit reakci pomocí %1$s" "Avatar místnosti" @@ -97,6 +100,7 @@ "Odmítnout a zablokovat" "Smazat" "Smazat účet" + "Smazat soubor" "Odstranit hlasování" "Odznačit vše" "Zakázat" @@ -304,6 +308,7 @@ Důvod: %1$s." "Počkejte prosím…" "Opravdu chcete ukončit toto hlasování?" "Hlasování: %1$s" + "Hlasování" "Celkový počet hlasů: %1$s" "Výsledky se zobrazí po skončení hlasování" @@ -453,7 +458,7 @@ Opravdu chcete pokračovat?" "Vyberte kvalitu videa, které chcete nahrát." "Vyberte kvalitu nahrávání videa" "Na tomto zařízení jste již přihlášeni jako %1$s." - "Váš domovský server je třeba upgradovat, aby podporoval službu Matrix Authentication Service a vytváření účtu." + "Váš poskytovatel účtu musí být upgradeován, aby podporoval Matrix Authentication Service a vytváření účtů." "Vytvoření trvalého odkazu se nezdařilo" "%1$s nemohl načíst mapu. Zkuste to prosím později." "Načítání zpráv se nezdařilo" @@ -547,4 +552,6 @@ Opravdu chcete pokračovat?" "Nemáte přístup k této zprávě" "Nelze dešifrovat zprávu" "Tato zpráva byla zablokována buď proto, že jste neověřili své zařízení, nebo proto, že odesílatel musí ověřit vaši digitální identitu." + "Vaše zařízení je příliš staré, je vyžadováno zařízení s Androidem 8 nebo novějším." + "Vykreslování map není podporováno" diff --git a/libraries/ui-strings/src/main/res/values-de/translations.xml b/libraries/ui-strings/src/main/res/values-de/translations.xml index efd3af694a..cefeb75720 100644 --- a/libraries/ui-strings/src/main/res/values-de/translations.xml +++ b/libraries/ui-strings/src/main/res/values-de/translations.xml @@ -555,11 +555,16 @@ Möchtest du wirklich fortfahren?" "Erweitern" "Verkleinern" "Live-Standort wird geteilt" + "Maximale Anzahl an Nachrichten ausgewählt" "Du siehst diesen Chat bereits!" "%1$s von %2$s" "%1$s fixierte Nachrichten" "Nachricht wird geladen…" "Alle anzeigen" + + "%1$d Nachricht ausgewählt" + "%1$d Nachrichten ausgewählt" + "Chat" "Standort teilen" "Meinen Standort teilen" diff --git a/libraries/ui-strings/src/main/res/values-el/translations.xml b/libraries/ui-strings/src/main/res/values-el/translations.xml index 62306c1985..97223c8d3f 100644 --- a/libraries/ui-strings/src/main/res/values-el/translations.xml +++ b/libraries/ui-strings/src/main/res/values-el/translations.xml @@ -1,14 +1,20 @@ + "Προσθήκη avatar" "Προσθήκη αντίδρασης: %1$s" "Διεύθυνση" "Εικόνα Προφίλ" + "Εκκαθάριση πεδίου αναζήτησης" "Ελαχιστοποίηση πεδίου κειμένου μηνύματος" + "Μείωση επιτρεπόμενων ψήφων ανά άτομο" + "Αύξηση επιτρεπόμενων ψήφων ανά άτομο" + "Προσαρμοσμένο emoji" "Διαγραφή" "%1$d ψηφίο εισήχθη" "%1$d ψηφία εισήχθησαν" + "Διάρκεια: %1$s" "Επεξεργασία Άβαταρ" "Η πλήρης διεύθυνση θα είναι %1$s" "Λεπτομέρειες κρυπτογράφησης" @@ -17,6 +23,8 @@ "Πληροφορίες" "Συμμετοχή στην κλήση" "Άλμα προς τα κάτω" + "Μετάβαση σε μη αναγνωσμένα" + "Μετάβαση στο πρώτο μη αναγνωσμένο μήνυμα" "Μετακινήστε τον χάρτη στην τοποθεσία μου" "Αναφορές μόνο" "Σε σίγαση" @@ -27,12 +35,14 @@ "Σελίδα %1$d" "Παύση" "Φωνητικό μήνυμα, διάρκεια:%1$s, τρέχουσα θέση: %2$s" + "Προεπισκόπηση φωτογραφίας" "Πεδίο PIN" "Καρφιτσωμένη τοποθεσία" "Αναπαραγωγή" "Ταχύτητα αναπαραγωγής" "Δημοσκόπηση" "Ολοκληρωμένη δημοσκόπηση" + "Θέση: %1$s" "Κωδικός QR" "Αντέδρασε με %1$s" "Αντέδρασε με άλλα emoji" @@ -42,20 +52,26 @@ "Διαβάστηκε από %1$s και %2$d ακόμη" "Το προηγούμενο μήνυμα διαβάστηκε από %1$s" + "εμφάνιση όλων των αποδεικτικών ανάγνωσης" "Αφαίρεση αντίδρασης με %1$s" "Αφαιρέστε την αντίδραση με %1$s" "Άβαταρ αίθουσας" "Αποστολή αρχείων" "Τοποθεσία αποστολέα" + "Απεστάλη από %1$s στις %2$s" "Απαιτείται ενέργεια περιορισμένης χρονικής διάρκειας, έχετε ένα λεπτό για επαλήθευση." "Ρυθμίσεις, απαιτείται ενέργεια" "Εμφάνιση κωδικού πρόσβασης" "Ξεκίνησε μια κλήση" "Ξεκινήστε μια βιντεοκλήση" "Έναρξη φωνητικής κλήσης" + "Κατάσταση" + "Νήμα στο %1$s" + "Νήματα στο %1$s" "Θαμένη αίθουσα" "Άβαταρ χρήστη" "Μενού χρήστη" + "Προεπισκόπηση βίντεο" "Προβολή avatar" "Προβολή λεπτομερειών" "Φωνητικό μήνυμα, διάρκεια: %1$s" @@ -92,6 +108,7 @@ "Απόρριψη και αποκλεισμός" "Διαγραφή" "Διαγραφή λογαριασμού" + "Διαγραφή αρχείου" "Διαγραφή Δημοσκόπησης" "Αποεπιλογή όλων" "Απενεργοποίηση" @@ -129,6 +146,7 @@ "Διαχείριση λογαριασμού και συσκευών" "Διαχείριση συσκευών" "Διαχείριση αίθουσών" + "Σήμανση ως αναγνωσμένου" "Στείλε" "Ελαχιστοποίηση" "Επόμενο" @@ -183,9 +201,11 @@ "Μετάφραση" "Προσπάθησε ξανά" "Ξεκαρφίτσωμα" + "Βιντεοκλήση" "Προβολή" "Προβολή στο χρονοδιάγραμμα" "Προβολή πηγής" + "Φωνητική κλήση" "Ναι" "Ναι, δοκιμή ξανά" "Ο διακομιστής σου υποστηρίζει τώρα ένα νέο, ταχύτερο πρωτόκολλο. Αποσυνδέσου και συνδέσου ξανά για αναβάθμιση τώρα. Κάνοντας αυτό τώρα θα σε βοηθήσει να αποφύγεις μια αναγκαστική αποσύνδεση όταν το παλιό πρωτόκολλο καταργηθεί αργότερα." @@ -206,6 +226,7 @@ "Αποκλεισμένοι χρήστες" "Φυσαλίδες" "Η κλήση απορρίφθηκε" + "Κλήση σε εξέλιξη" "Η κλήση ξεκίνησε" "Απορρίψατε μια κλήση" "Αντίγραφο ασφαλείας συνομιλίας" @@ -249,7 +270,17 @@ "Αρχείο αποθηκευμένο σε Λήψεις" "Προώθηση μηνύματος" "Χρησιμοποιείται συχνά" + "Γκαλερί" + + "%1$d συνημμένο…" + "%1$d συνημμένα…" + + + "%1$d στοιχείο πολυμέσων…" + "%1$d στοιχεία πολυμέσων…" + "GIF" + "Ομαδική κλήση σε εξέλιξη" "Εικόνα" "Σε απάντηση στον χρήστη %1$s" "Εγκατάσταση APK" @@ -285,6 +316,7 @@ "Δεν υπάρχει όνομα χώρου" "Χωρίς κρυπτογράφηση" "Εκτός σύνδεσης" + "Σε κλήση" "Άδειες ανοιχτού κώδικα" "ή" "Άλλες επιλογές" @@ -297,6 +329,7 @@ "Παρακαλώ περίμενε…" "Θες σίγουρα να τερματίσεις αυτή τη δημοσκόπηση;" "Δημοσκόπηση: %1$s" + "Δημοσκόπηση" "Σύνολο ψήφων: %1$s" "Τα αποτελέσματα θα εμφανιστούν μετά τη λήξη της ψηφοφορίας" @@ -391,6 +424,7 @@ "Άρση σίγασης" "Μη υποστηριζόμενη κλήση" "Μη υποστηριζόμενο συμβάν" + "Ο χρήστης %1$s ξεκίνησε μια κλήση" "Όνομα χρήστη" "Η επαλήθευση ακυρώθηκε" "Η επαλήθευση ολοκληρώθηκε" @@ -412,6 +446,11 @@ "Αναμονή για ζωντανή τοποθεσία…" "Οποιοσδήποτε μπορεί να δει το ιστορικό" "Εσύ" + "Η λήψη δεν είναι διαθέσιμη." + "Κάτι πήγε στραβά" + "Σάρωση…" + "Η προεπισκόπηση και η λήψη έχουν απενεργοποιηθεί." + "Το αρχείο δεν είναι ασφαλές" "%1$s (%2$s) μοιράστηκε αυτό το μήνυμα, καθώς δεν ήσασταν στην αίθουσα όταν στάλθηκε." "%1$s μοιράστηκε αυτό το μήνυμα, ενόσω δεν ήσασταν στην αίθουσα όταν στάλθηκε." "Αυτή η αίθουσα έχει διαμορφωθεί έτσι ώστε τα νέα μέλη να μπορούν να διαβάσουν το ιστορικό. %1$s" @@ -462,6 +501,9 @@ "Λυπούμαστε, παρουσιάστηκε σφάλμα" "🔐️ Έλα μαζί μου στο %1$s" "Γεια, μίλα μου στην εφαρμογή %1$s :%2$s" + "Κοινή χρήση τρέχουσας τοποθεσίας" + "Κοινοποίηση τοποθεσίας σε εξέλιξη" + "%1$s Ζωντανή τοποθεσία" "%1$s Android" "Κούνησε δυνατά τη συσκευή σου για να αναφέρεις κάποιο σφάλμα" "Στιγμιότυπο οθόνης" @@ -469,6 +511,7 @@ "Επιλογές" "Αφαίρεση %1$s" "Ρυθμίσεις" + "Επιτρεπόμενες ψήφοι ανά άτομο" "Κανείς δεν μοιράζεται την τοποθεσία του" "Κοινοποιείς την τοποθεσία σε πραγματικό χρόνο" @@ -476,6 +519,8 @@ "%1$d άτομα" "Στον χάρτη" + "Ο αρχικός διακομιστής βρίσκεται στο τοπικό σας δίκτυο. Για να συνδεθεί, το %1$s χρειάζεται άδεια πρόσβασης σε συσκευές σε αυτό το δίκτυο." + "Επιτρέψτε την πρόσβαση στο τοπικό δίκτυο" "Αποτυχία επιλογής πολυμέσου, δοκίμασε ξανά." "Πάτα σε ένα μήνυμα και επέλεξε «%1$s» για να συμπεριληφθεί εδώ." "Καρφίτσωσε σημαντικά μηνύματα, ώστε να μπορούν να εντοπιστούν εύκολα" @@ -521,6 +566,10 @@ "Χώροι" "Κοινοποιήθηκε %1$s" "Στον χάρτη" + + "%1$d συμμετέχον" + "%1$d συμμετέχοντες" + "Το μήνυμα δεν αποστάλη επειδή η επαληθευμένη ψηφιακή ταυτότητα του %1$s επαναφέρθηκε." "Το μήνυμα δεν στάλθηκε επειδή ο χρήστης %1$s δεν έχει επαληθεύσει όλες τις συσκευές." "Το μήνυμα δεν στάλθηκε επειδή δεν έχεις επαληθεύσει τουλάχιστον μία από τις συσκευές σου." @@ -532,4 +581,6 @@ "Δεν έχεις πρόσβαση σε αυτό το μήνυμα" "Δεν είναι δυνατή η αποκρυπτογράφηση μηνύματος" "Αυτό το μήνυμα αποκλείστηκε είτε επειδή δεν επαληθεύσατε τη συσκευή σας είτε επειδή ο αποστολέας πρέπει να επαληθεύσει την ψηφιακή σας ταυτότητα." + "Η συσκευή σας είναι πολύ παλιά, απαιτείται συσκευή με Android 8 ή νεότερη έκδοση." + "Η απόδοση χαρτών δεν υποστηρίζεται" diff --git a/libraries/ui-strings/src/main/res/values-fi/translations.xml b/libraries/ui-strings/src/main/res/values-fi/translations.xml index 11ca38edf0..027c060007 100644 --- a/libraries/ui-strings/src/main/res/values-fi/translations.xml +++ b/libraries/ui-strings/src/main/res/values-fi/translations.xml @@ -491,7 +491,7 @@ Haluatko varmasti jatkaa?" "Valitse lähetettävän videon laatu." "Valitse videon lähetyslaatu" "Olet jo kirjautuneena tälle laitteelle käyttäjällä %1$s." - "Kotipalvelimesi on päivitettävä tukemaan Matrix Authentication Serviceä ja tilin luomista." + "Palveluntarjoajasi on päivitettävä tukemaan Matrix Authentication Serviceä ja tilin luomista." "Pysyvän linkin luominen epäonnistui" "%1$s ei pystynyt lataamaan karttaa. Yritä myöhemmin uudelleen." "Viestien lataaminen epäonnistui" @@ -555,11 +555,16 @@ Haluatko varmasti jatkaa?" "Laajenna" "Pienennä" "Jaetaan reaaliaikaista sijaintia" + "Enimmäismäärä viestejä valittu" "Katselet jo tätä huonetta!" "%1$s / %2$s" "Kiinnitetty viesti %1$s" "Viestiä ladataan…" "Näytä kaikki" + + "%1$d viesti valittu" + "%1$d viestiä valittu" + "Keskustelu" "Jaa sijainti" "Jaa sijaintini" diff --git a/libraries/ui-strings/src/main/res/values-hr/translations.xml b/libraries/ui-strings/src/main/res/values-hr/translations.xml index 93e9556163..26db3881d6 100644 --- a/libraries/ui-strings/src/main/res/values-hr/translations.xml +++ b/libraries/ui-strings/src/main/res/values-hr/translations.xml @@ -564,11 +564,16 @@ Jeste li sigurni da želite nastaviti?" "Proširi" "Smanji" "Dijeljenje lokacije uživo" + "Odabran je maksimalan broj poruka" "Već gledam ovu sobu!" "%1$s od %2$s" "%1$s Prikvačene poruke" "Učitavanje poruke…" "Prikaži sve" + + "Odabrana je poruka %1$d" + "Odabrano je %1$d poruka" + "Razgovor" "Podijeli lokaciju" "Podijeli moju lokaciju" diff --git a/libraries/ui-strings/src/main/res/values-hu/translations.xml b/libraries/ui-strings/src/main/res/values-hu/translations.xml index f198cc0510..651292a29a 100644 --- a/libraries/ui-strings/src/main/res/values-hu/translations.xml +++ b/libraries/ui-strings/src/main/res/values-hu/translations.xml @@ -19,6 +19,15 @@ "A teljes cím ez lesz: %1$s" "Titkosítás részletei" "Üzenet szövegmezőjének kibontása" + "Galéria, melléklet %1$d" + "Galéria, sikertelen szkennelés %1$d" + "Galéria, kép %1$d" + + "Galéria, %1$d további médiatartalom" + "Galéria, %1$d további médiatartalom" + + "Galéria, szkennelés %1$d" + "Galéria, videó %1$d" "Jelszó elrejtése" "Információ" "Csatlakozás a híváshoz" @@ -406,6 +415,7 @@ Ok: %1$s." "Sikeres" "Javaslat" "Javaslatok" + "Fiókváltás" "Szinkronizálás" "Szöveg" "Harmadik felek nyilatkozatai" @@ -545,11 +555,16 @@ Biztos, hogy folytatja?" "Kibontás" "Csökkentés" "Élő helymegosztás" + "Maximális üzenet kiválasztva" "Már ezt a szobát nézi!" "%1$s. / %2$s" "%1$s kitűzött üzenet" "Üzenet betöltése…" "Összes megtekintése" + + "%1$d üzenet kijelölve" + "%1$d üzenet kijelölve" + "Csevegés" "Hely megosztása" "Saját hely megosztása" diff --git a/libraries/ui-strings/src/main/res/values-ja/translations.xml b/libraries/ui-strings/src/main/res/values-ja/translations.xml index 1a14cd75d8..c99e866d7b 100644 --- a/libraries/ui-strings/src/main/res/values-ja/translations.xml +++ b/libraries/ui-strings/src/main/res/values-ja/translations.xml @@ -395,7 +395,7 @@ "問題が発生しました。再度お試しください。" "スペース" "スペースのメンバー" - "このスペースは何についてのものですか?" + "このスペースは何についてのものですか?" "%1$d 個のスペース" @@ -441,7 +441,7 @@ "待機中…" "メッセージを待機中" "ライブ位置情報を待機中…" - "誰でも履歴を閲覧できます" + "誰でも過去のメッセージを閲覧できます" "あなた" "ダウンロードは使用できません。" "問題が発生しました" @@ -479,7 +479,7 @@ "アップロードする画質を選択してください。" "動画のアップロード品質を選択" "この端末では既に %1$s としてログインしています。" - "Matrix Authentication Service とアカウント作成に対応するため、このホームサーバーはアップグレードが必要です。" + "Matrix Authentication Service とアカウント作成に対応させるため、アカウント提供元のアップグレードが必要です。" "固定リンクの作成に失敗しました" "%1$s で地図を読み込めません。時間を置いて再度お試しください。" "メッセージの読み込みに失敗しました" @@ -541,11 +541,15 @@ "展開" "縮小" "ライブ位置情報を共有中" + "最大数のメッセージを選択中" "すでにこのルームを表示しています" "%2$s 個のうち %1$s" "%1$s 個のピン留めされたメッセージ" "メッセージを読み込み中…" "すべて表示" + + "%1$d件のメッセージを選択中" + "チャット" "場所を共有" "現在地を共有する" diff --git a/libraries/ui-strings/src/main/res/values-pl/translations.xml b/libraries/ui-strings/src/main/res/values-pl/translations.xml index 5aaae1f5d1..0555716c57 100644 --- a/libraries/ui-strings/src/main/res/values-pl/translations.xml +++ b/libraries/ui-strings/src/main/res/values-pl/translations.xml @@ -111,6 +111,7 @@ "Kopiuj link" "Kopiuj link do wiadomości" "Kopiuj tekst" + "Kopiuj do schowka" "Utwórz" "Utwórz pokój" "Utwórz przestrzeń" @@ -189,7 +190,6 @@ "Ponów próbę odszyfrowania" "Zapisz" "Szukaj" - "Zaznacz" "Zaznacz wszystko" "Wyślij" "Wyślij edytowaną wiadomość" @@ -225,6 +225,7 @@ "Dostępna aktualizacja" "O programie" "Polityka użytkowania" + "Ustawienia konta" "Dodaj konto" "Dodaj kolejne konto" "Dodawanie opisu" @@ -233,6 +234,7 @@ "Dane analityczne" "Opuściłeś pokój" "Zostałeś wylogowany z sesji" + "Ustawienia aplikacji" "Wygląd" "Dźwięk" "Beta" @@ -243,6 +245,7 @@ "Rozpoczęto rozmowę" "Odrzuciłeś połączenie" "Backup czatu" + "Blok kodu" "Skopiowano do schowka" "Prawa autorskie" "Tworzenie pokoju…" @@ -309,11 +312,13 @@ Powód: %1$s." "Zakończono udostępnianie lokalizacji na żywo" "Ładowanie…" "Ładuję więcej…" + "Udostępnianie lokalizacji" "%d inny" "%d innych" "%d innych" + "Jakość przesyłanych mediów" "%1$d członek" "%1$d członków" @@ -324,6 +329,7 @@ Powód: %1$s." "Nie udało się wysłać wiadomości" "Układ wiadomości" "Wiadomość usunięta" + "Moderacja i bezpieczeństwo" "Nowoczesny" "Wycisz" "Nazwa" @@ -429,6 +435,7 @@ Powód: %1$s." "Sukces" "Polecane" "Sugestie" + "Zmień konto" "Synchronizuję" "Tekst" "Informacje stron trzecich" @@ -468,6 +475,7 @@ Powód: %1$s." "Czekam na lokalizację na żywo…" "Każdy może zobaczyć historię" "Ty" + "Twoje konto" "Pobieranie niedostępne." "Nie znaleziono pliku" "Skanuję…" @@ -564,10 +572,6 @@ Czy na pewno chcesz kontynuować?" "Jedno lub więcej z Twoich urządzeń jest niezweryfikowanych. Wyślij wiadomość mimo to lub anuluj i spróbuj ponownie po zweryfikowaniu wszystkich swoich urządzeń." "Twoja wiadomość nie została wysłana, ponieważ nie zweryfikowałeś jednego lub więcej swoich urządzeń." "Edytuj administratorów lub właścicieli" - "Czy na pewno chcesz usunąć wiadomość?" - "Powód (opcjonalne)" - "Powód…" - "Potwierdź usunięcie" "Przetwarzanie multimediów do przesłania nie powiodło się, spróbuj ponownie." "Nie można pobrać danych użytkownika" "Wiadomość w %1$s" diff --git a/libraries/ui-strings/src/main/res/values-ru/translations.xml b/libraries/ui-strings/src/main/res/values-ru/translations.xml index d2fc35c7b3..c80403468f 100644 --- a/libraries/ui-strings/src/main/res/values-ru/translations.xml +++ b/libraries/ui-strings/src/main/res/values-ru/translations.xml @@ -109,7 +109,7 @@ "Копировать" "Скопировать подпись" "Скопировать ссылку" - "Скопировать ссылку в сообщение" + "Скопировать ссылку на сообщение" "Копировать текст" "Создать" "Создать комнату" @@ -189,7 +189,6 @@ "Повторите расшифровку" "Сохранить" "Поиск" - "Выбрать" "Выбрать все" "Отправить" "Отправить отредактированное сообщение" @@ -564,10 +563,6 @@ "Одно или несколько ваших устройств не подтверждены. Вы можете отправить сообщение в любом случае или отменить его пока и повторить попытку позже, подтвердив все свои устройства." "Ваше сообщение не было отправлено, поскольку вы не подтвердили одно или несколько своих устройств." "Редактировать владельцев и администраторов" - "Вы уверены, что хотите удалить это сообщение?" - "Причина (необязательно)" - "Введите…" - "Подтвердите удаление" "Не удалось обработать медиафайл для загрузки, попробуйте ещё раз." "Не удалось получить данные о пользователе" "Сообщение в %1$s" diff --git a/libraries/ui-strings/src/main/res/values-uk/translations.xml b/libraries/ui-strings/src/main/res/values-uk/translations.xml index 47cb0c5366..e791c4b5be 100644 --- a/libraries/ui-strings/src/main/res/values-uk/translations.xml +++ b/libraries/ui-strings/src/main/res/values-uk/translations.xml @@ -111,6 +111,7 @@ "Скопіювати посилання" "Скопіювати посилання на повідомлення" "Скопіювати текст" + "Копіювати в буфер обміну" "Створити" "Створити кімнату" "Створити простір" @@ -138,6 +139,11 @@ "Завершити" "Забули пароль?" "Переслати" + + "Переслати %1$d повідомлення" + "Переслати %1$d повідомлення" + "Переслати %1$d повідомлень" + "Повернутися" "Перейти до ролей і дозволів" "Перейти до налаштувань" @@ -224,6 +230,7 @@ "Доступне оновлення" "Відомості" "Політика прийнятного використання" + "Налаштування облікового запису" "Додати обліковий запис" "Додати ще один обліковий запис" "Додавання підпису" @@ -232,6 +239,7 @@ "Аналітика" "Ви вийшли з кімнати" "Ви вийшли з сеансу" + "Налаштування застосунку" "Вигляд" "Аудіо" "Бета-версія" @@ -242,6 +250,7 @@ "Виклик розпочато" "Ви відхилили виклик" "Резервне копіювання бесіди" + "Блок коду" "Скопійовано до буферу обміну" "Авторське право" "Створення кімнати…" @@ -308,11 +317,13 @@ "Показ місцеперебування наживо завершено" "Завантаження" "Завантаження наступних…" + "Обмін даними про розташування" "%d інший" "%d інші" "%d інші" + "Якість вивантажуваних медіа" "%1$d учасник" "%1$d учасники" @@ -323,6 +334,7 @@ "Не вдалося надіслати повідомлення" "Макет повідомлень" "Повідомлення вилучено" + "Модерування та безпека" "Модерн" "Вимкнути звук" "Назва" @@ -428,6 +440,7 @@ "Успіх" "Запропоновано" "Пропозиції" + "Змінити обліковий запис" "Синхронізація" "Текст" "Повідомлення третіх сторін" @@ -467,6 +480,7 @@ "Очікування геолокації…" "Будь-хто може переглянути історію" "Ви" + "Ваш обліковий запис" "Завантаження недоступне." "Файл не знайдено" "Сканування…" @@ -569,11 +583,22 @@ "Розгорнути" "Згорнути" "Спільний доступ до місцезнаходження у реальному часі" + "Вибрано максимальну кількість повідомлень" + + "Переслано %1$d повідомлення" + "Переслано %1$d повідомлення" + "Переслано %1$d повідомлень" + "Уже переглядаєте цю кімнату!" "%1$s із %2$s" "%1$s закріплених повідомлень" "Завантаження повідомлення…" "Переглянути всі" + + "Вибрано %1$d повідомлення" + "Вибрано %1$d повідомлення" + "Вибрано %1$d повідомлень" + "Бесіда" "Поділитися розташуванням" "Поділитися моїм розташуванням" diff --git a/libraries/ui-strings/src/main/res/values-zh-rTW/translations.xml b/libraries/ui-strings/src/main/res/values-zh-rTW/translations.xml index 302407080d..8a71ad7241 100644 --- a/libraries/ui-strings/src/main/res/values-zh-rTW/translations.xml +++ b/libraries/ui-strings/src/main/res/values-zh-rTW/translations.xml @@ -105,6 +105,7 @@ "複製連結" "複製訊息連結" "複製文字" + "複製到剪貼簿" "建立" "建立聊天室" "建立空間" @@ -218,6 +219,7 @@ "可升級" "關於" "可接受使用政策" + "帳號設定" "新增帳號" "新增其他帳號" "新增標題" @@ -226,6 +228,7 @@ "分析" "您離開聊天室" "您已登出工作階段" + "應用程式設定" "外觀" "音訊" "測試版" @@ -236,6 +239,7 @@ "開始通話" "您拒絕了通話" "聊天室備份" + "程式碼區塊" "已複製到剪貼簿" "著作權" "正在建立聊天室…" @@ -298,9 +302,11 @@ "即時位置已結束" "載入中…" "載入更多……" + "位置分享" "其他 %d 個人" + "媒體上傳品質" "%1$d 位成員" @@ -309,6 +315,7 @@ "訊息傳送失敗" "訊息佈局" "訊息已移除" + "內容審核與安全" "現代" "關閉通知" "名稱" @@ -404,6 +411,7 @@ "成功" "已建議" "建議" + "切換帳號" "同步中" "文字" "第三方通知" @@ -443,6 +451,7 @@ "正在等待即時位置……" "任何人都可以檢視歷史" "您" + "您的帳號" "無法下載。" "發生錯誤" "正在掃描⋯⋯" @@ -479,7 +488,7 @@ "選取您要上傳的視訊的品質。" "選取視訊上傳品質" "您已在此裝置上以 %1$s 的身份登入。" - "您的家伺服器需要升級才能支援 Matrix Authentication Service 與帳號建立。" + "您的帳號服務提供者需要進行升級,才能支援 Matrix Authentication Service 及帳號建立功能。" "無法建立永久連結" "%1$s無法載入地圖。請稍後再試。" "無法載入訊息" @@ -541,11 +550,15 @@ "展開" "減少" "分享即時位置" + "已選取最多訊息" "已檢視此聊天室!" "第 %1$s 個,共 %2$s 個" "%1$s 個釘選訊息" "正在載入訊息……" "檢視全部" + + "已選取 %1$d 則訊息" + "聊天" "分享位置" "分享我的位置" diff --git a/libraries/ui-strings/src/main/res/values-zh/translations.xml b/libraries/ui-strings/src/main/res/values-zh/translations.xml index 7f5cb8d120..36331e302b 100644 --- a/libraries/ui-strings/src/main/res/values-zh/translations.xml +++ b/libraries/ui-strings/src/main/res/values-zh/translations.xml @@ -31,7 +31,7 @@ "加入通话" "跳转到底部" "跳转到未读" - "跳转到首个未读消息" + "跳转到首条未读消息" "将地图移动到我的位置" "仅提及" "通知已关闭" @@ -53,11 +53,11 @@ "二维码" "使用 %1$s 反应" "使用其它 Emoji 做出反应" - "上一个消息已被 %1$s 与 %2$s 阅读" + "上一条消息已被 %1$s 与 %2$s 阅读" - "上一个消息已被 %1$s 与其余 %2$d 人阅读" + "上一条消息已被 %1$s 与其余 %2$d 人阅读" - "上一个消息已被 %1$s 阅读" + "上一条消息已被 %1$s 阅读" "显示所有已读回执" "移除反应:%1$s" "移除反应:%1$s" @@ -105,6 +105,7 @@ "复制链接" "复制消息链接" "复制文本" + "复制到剪贴板" "创建" "创建房间" "创建空间" @@ -132,6 +133,9 @@ "完成" "忘记密码?" "转发" + + "转发 %1$d 条消息" + "返回" "前往角色与权限" "前往设置" @@ -183,7 +187,6 @@ "重试解密" "保存" "搜索" - "选择" "全选" "发送" "发送编辑后的消息" @@ -219,6 +222,7 @@ "有可用升级" "关于" "可接受的使用政策" + "账户设置" "添加账户" "添加账户" "正在添加标题" @@ -227,6 +231,7 @@ "分析" "你离开了房间" "你已注销会话" + "应用设置" "外观" "音频" "Beta" @@ -237,6 +242,7 @@ "通话已开始" "你已拒接来电" "聊天记录备份" + "代码块" "已复制到剪贴板" "版权" "正在创建房间…" @@ -299,9 +305,11 @@ "实时位置已结束" "正在加载…" "正在加载更多…" + "位置共享" "其他 %d 人" + "媒体上传质量" "%1$d 个成员" @@ -310,6 +318,7 @@ "消息发送失败" "消息布局" "消息已移除" + "尺度与安全" "现代" "静音" "名称" @@ -405,6 +414,7 @@ "成功" "建议" "建议" + "切换账户" "正在同步" "文本" "第三方通知" @@ -444,6 +454,7 @@ "正在等待实时位置…" "任何人都可以看到历史" "你" + "你的账户" "无法下载。" "文件未找到" "正在扫描…" @@ -522,7 +533,7 @@ "按下消息并选择 “%1$s” 将其包含在此处。" "置顶重要的消息以便于发现" - "%1$d 个已置顶的消息" + "%1$d 条已置顶的消息" "已置顶的消息" "你即将被重定向到你在 %1$s 上的账户以重置数字身份。之后将被带回 app。" @@ -536,10 +547,6 @@ "你有至少 1 个未经验证的设备。你可以照常发送消息,也可以暂时取消,并在验证所有设备后重试。" "你的消息未能发送,因为你有尚未验证的设备。" "编辑管理员或所有者" - "确定要移除此消息?" - "理由(可选)" - "输入…" - "确认移除" "处理要上传的媒体失败,请重试。" "无法获取用户信息" "%1$s 中的消息" @@ -547,13 +554,16 @@ "折叠" "正在共享实时位置" "选择的消息已达到最大数量" + + "已转发 %1$d 条消息" + "已经位于此房间!" "%1$s / %2$s" - "%1$s 个已置顶的消息" + "%1$s 条已置顶的消息" "正在加载消息…" "查看全部" - "已选择 %1$d 个消息" + "已选择 %1$d 条消息" "聊天" "共享位置" diff --git a/libraries/ui-strings/src/main/res/values/localazy.xml b/libraries/ui-strings/src/main/res/values/localazy.xml index 0493817f61..7a0ce8fd4e 100644 --- a/libraries/ui-strings/src/main/res/values/localazy.xml +++ b/libraries/ui-strings/src/main/res/values/localazy.xml @@ -108,6 +108,7 @@ "Copy link" "Copy link to message" "Copy text" + "Copy to clipboard" "Create" "Create room" "Create space" @@ -135,6 +136,10 @@ "Finish" "Forgot password?" "Forward" + + "Forward %1$d message" + "Forward %1$d messages" + "Go back" "Go to roles & permissions" "Go to settings" @@ -186,7 +191,6 @@ "Retry decryption" "Save" "Search" - "Select" "Select all" "Send" "Send edited message" @@ -222,6 +226,7 @@ "Upgrade available" "About" "Acceptable use policy" + "Account settings" "Add an account" "Add account" "Adding caption" @@ -230,6 +235,7 @@ "Analytics" "You left the room" "You were logged out of the session" + "App settings" "Appearance" "Audio" "Beta" @@ -240,6 +246,7 @@ "Call started" "You declined a call" "Chat backup" + "Code block" "Copied to clipboard" "Copyright" "Creating room…" @@ -304,10 +311,12 @@ Reason: %1$s." "Live location ended" "Loading…" "Loading more…" + "Location sharing" "%d other" "%d others" + "Media upload quality" "%1$d Member" "%1$d Members" @@ -317,6 +326,7 @@ Reason: %1$s." "Message failed to send" "Message layout" "Message removed" + "Moderation & safety" "Modern" "Mute" "Name" @@ -417,6 +427,7 @@ Reason: %1$s." "Success" "Suggested" "Suggestions" + "Switch accounts" "Syncing" "Text" "Third-party notices" @@ -456,6 +467,7 @@ Reason: %1$s." "Waiting for live location…" "Anyone can see history" "You" + "Your account" "Download not available." "Something went wrong" "Scanning…" @@ -550,10 +562,6 @@ Are you sure you want to continue?" "One or more of your devices are unverified. You can send the message anyway, or you can cancel for now and try again later after you have verified all of your devices." "Your message was not sent because you have not verified one or more of your devices" "Edit Admins or Owners" - "Are you sure you wish to remove this message?" - "Reason (optional)" - "Enter…" - "Confirm removal" "Failed processing media to upload, please try again." "Could not retrieve user details" "Message in %1$s" @@ -561,6 +569,10 @@ Are you sure you want to continue?" "Reduce" "Sharing live location" "Maximum messages selected" + + "%1$d message forwarded" + "%1$d messages forwarded" + "Already viewing this room!" "%1$s of %2$s" "%1$s Pinned messages" diff --git a/libraries/ui-strings/src/main/res/values/temporary.xml b/libraries/ui-strings/src/main/res/values/temporary.xml index c704e811ff..6821b63d98 100644 --- a/libraries/ui-strings/src/main/res/values/temporary.xml +++ b/libraries/ui-strings/src/main/res/values/temporary.xml @@ -10,4 +10,5 @@ "Collapsed" Active Inactive + Switch accounts diff --git a/libraries/voiceplayer/api/src/main/kotlin/io/element/android/libraries/voiceplayer/api/VoiceMessageState.kt b/libraries/voiceplayer/api/src/main/kotlin/io/element/android/libraries/voiceplayer/api/VoiceMessageState.kt index 2faf35f91f..17d2146045 100644 --- a/libraries/voiceplayer/api/src/main/kotlin/io/element/android/libraries/voiceplayer/api/VoiceMessageState.kt +++ b/libraries/voiceplayer/api/src/main/kotlin/io/element/android/libraries/voiceplayer/api/VoiceMessageState.kt @@ -13,7 +13,9 @@ data class VoiceMessageState( val progress: Float, val time: String, val showCursor: Boolean, + val isPlaying: Boolean, val playbackSpeed: Float, + val durationMs: Long, val eventSink: (event: VoiceMessageEvent) -> Unit, ) { enum class ButtonType { diff --git a/libraries/voiceplayer/api/src/main/kotlin/io/element/android/libraries/voiceplayer/api/VoiceMessageStatePreviewParam.kt b/libraries/voiceplayer/api/src/main/kotlin/io/element/android/libraries/voiceplayer/api/VoiceMessageStatePreviewParam.kt index f417947cb6..2f126e1a7f 100644 --- a/libraries/voiceplayer/api/src/main/kotlin/io/element/android/libraries/voiceplayer/api/VoiceMessageStatePreviewParam.kt +++ b/libraries/voiceplayer/api/src/main/kotlin/io/element/android/libraries/voiceplayer/api/VoiceMessageStatePreviewParam.kt @@ -34,6 +34,8 @@ open class VoiceMessageStatePreviewParam : PreviewParameterProvider