diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index 1df2119bf9..724060aa40 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -27,7 +27,7 @@ jobs:
group: ${{ github.ref == 'refs/heads/develop' && format('build-develop-{0}-{1}', matrix.variant, github.sha) || format('build-{0}-{1}', matrix.variant, github.ref) }}
cancel-in-progress: true
steps:
- - uses: actions/checkout@v3
+ - uses: actions/checkout@v4
with:
# Ensure we are building the branch and not the branch after being merged on develop
# https://github.com/actions/checkout/issues/881
diff --git a/.github/workflows/danger.yml b/.github/workflows/danger.yml
index 8551cb44c0..f05db3c791 100644
--- a/.github/workflows/danger.yml
+++ b/.github/workflows/danger.yml
@@ -7,7 +7,7 @@ jobs:
runs-on: ubuntu-latest
name: Danger main check
steps:
- - uses: actions/checkout@v3
+ - uses: actions/checkout@v4
- run: |
npm install --save-dev @babel/plugin-transform-flow-strip-types
- name: Danger
diff --git a/.github/workflows/gradle-wrapper-update.yml b/.github/workflows/gradle-wrapper-update.yml
index 33a12d3c54..351057fc89 100644
--- a/.github/workflows/gradle-wrapper-update.yml
+++ b/.github/workflows/gradle-wrapper-update.yml
@@ -8,7 +8,7 @@ jobs:
update-gradle-wrapper:
runs-on: ubuntu-latest
steps:
- - uses: actions/checkout@v3
+ - uses: actions/checkout@v4
- name: Update Gradle Wrapper
uses: gradle-update/update-gradle-wrapper-action@v1
# Skip in forks
diff --git a/.github/workflows/gradle-wrapper-validation.yml b/.github/workflows/gradle-wrapper-validation.yml
index 271c5399f6..4746aa3885 100644
--- a/.github/workflows/gradle-wrapper-validation.yml
+++ b/.github/workflows/gradle-wrapper-validation.yml
@@ -11,5 +11,5 @@ jobs:
runs-on: ubuntu-latest
# No concurrency required, this is a prerequisite to other actions and should run every time.
steps:
- - uses: actions/checkout@v3
+ - uses: actions/checkout@v4
- uses: gradle/wrapper-validation-action@v1
diff --git a/.github/workflows/maestro.yml b/.github/workflows/maestro.yml
index 46ee84896f..8b78a3c447 100644
--- a/.github/workflows/maestro.yml
+++ b/.github/workflows/maestro.yml
@@ -24,7 +24,7 @@ jobs:
group: ${{ format('maestro-{0}', github.ref) }}
cancel-in-progress: true
steps:
- - uses: actions/checkout@v3
+ - uses: actions/checkout@v4
with:
# Ensure we are building the branch and not the branch after being merged on develop
# https://github.com/actions/checkout/issues/881
diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml
index 85c9ed1422..179b001131 100644
--- a/.github/workflows/nightly.yml
+++ b/.github/workflows/nightly.yml
@@ -16,7 +16,7 @@ jobs:
runs-on: ubuntu-latest
if: ${{ github.repository == 'vector-im/element-x-android' }}
steps:
- - uses: actions/checkout@v3
+ - uses: actions/checkout@v4
- name: Use JDK 17
uses: actions/setup-java@v3
with:
diff --git a/.github/workflows/nightlyReports.yml b/.github/workflows/nightlyReports.yml
index 65f3cbb53c..124afbc98f 100644
--- a/.github/workflows/nightlyReports.yml
+++ b/.github/workflows/nightlyReports.yml
@@ -55,7 +55,7 @@ jobs:
name: Dependency analysis
runs-on: ubuntu-latest
steps:
- - uses: actions/checkout@v3
+ - uses: actions/checkout@v4
- name: Use JDK 17
uses: actions/setup-java@v3
with:
diff --git a/.github/workflows/quality.yml b/.github/workflows/quality.yml
index 9c0aac7aef..417acc9341 100644
--- a/.github/workflows/quality.yml
+++ b/.github/workflows/quality.yml
@@ -17,7 +17,7 @@ jobs:
name: Search for forbidden patterns
runs-on: ubuntu-latest
steps:
- - uses: actions/checkout@v3
+ - uses: actions/checkout@v4
- name: Run code quality check suite
run: ./tools/check/check_code_quality.sh
@@ -29,7 +29,7 @@ jobs:
group: ${{ github.ref == 'refs/heads/main' && format('check-main-{0}', github.sha) || github.ref == 'refs/heads/develop' && format('check-develop-{0}', github.sha) || format('check-{0}', github.ref) }}
cancel-in-progress: true
steps:
- - uses: actions/checkout@v3
+ - uses: actions/checkout@v4
with:
# Ensure we are building the branch and not the branch after being merged on develop
# https://github.com/actions/checkout/issues/881
@@ -52,12 +52,6 @@ jobs:
name: linting-report
path: |
*/build/reports/**/*.*
- - name: 🔊 Publish results to Sonar
- env:
- SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
- ORG_GRADLE_PROJECT_SONAR_LOGIN: ${{ secrets.SONAR_TOKEN }}
- if: ${{ always() && env.SONAR_TOKEN != '' && env.ORG_GRADLE_PROJECT_SONAR_LOGIN != '' }}
- run: ./gradlew sonar $CI_GRADLE_ARG_PROPERTIES
- name: Prepare Danger
if: always()
run: |
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 7e535a1467..cc8fc9055c 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -18,7 +18,7 @@ jobs:
group: ${{ github.ref == 'refs/head/main' && format('build-release-main-{0}', github.sha) }}
cancel-in-progress: true
steps:
- - uses: actions/checkout@v3
+ - uses: actions/checkout@v4
- name: Use JDK 17
uses: actions/setup-java@v3
with:
diff --git a/.github/workflows/sonar.yml b/.github/workflows/sonar.yml
index 42846c2cd5..b511835a60 100644
--- a/.github/workflows/sonar.yml
+++ b/.github/workflows/sonar.yml
@@ -1,4 +1,4 @@
-name: Code Quality Checks
+name: Sonar
on:
workflow_dispatch:
@@ -10,18 +10,18 @@ on:
# Enrich gradle.properties for CI/CD
env:
GRADLE_OPTS: -Dorg.gradle.jvmargs="-Xmx3072m -Dfile.encoding=UTF-8 -XX:+HeapDumpOnOutOfMemoryError" -XX:MaxMetaspaceSize=512m -Dkotlin.daemon.jvm.options="-Xmx2g" -Dkotlin.incremental=false
- CI_GRADLE_ARG_PROPERTIES: --stacktrace -PpreDexEnable=false --max-workers 2 --no-daemon --warn
+ CI_GRADLE_ARG_PROPERTIES: --stacktrace -PpreDexEnable=false --max-workers 4 --no-daemon --warn
jobs:
sonar:
- name: Project Check Suite
+ name: Sonar Quality Checks
runs-on: ubuntu-latest
# Allow all jobs on main and develop. Just one per PR.
concurrency:
group: ${{ github.ref == 'refs/heads/main' && format('sonar-main-{0}', github.sha) || github.ref == 'refs/heads/develop' && format('sonar-develop-{0}', github.sha) || format('sonar-{0}', github.ref) }}
cancel-in-progress: true
steps:
- - uses: actions/checkout@v3
+ - uses: actions/checkout@v4
with:
# Ensure we are building the branch and not the branch after being merged on develop
# https://github.com/actions/checkout/issues/881
@@ -41,9 +41,3 @@ jobs:
ORG_GRADLE_PROJECT_SONAR_LOGIN: ${{ secrets.SONAR_TOKEN }}
if: ${{ always() && env.SONAR_TOKEN != '' && env.ORG_GRADLE_PROJECT_SONAR_LOGIN != '' }}
run: ./gradlew sonar $CI_GRADLE_ARG_PROPERTIES
- - name: Prepare Danger
- if: always()
- run: |
- npm install --save-dev @babel/core
- npm install --save-dev @babel/plugin-transform-flow-strip-types
- yarn add danger-plugin-lint-report --dev
diff --git a/.github/workflows/sync-localazy.yml b/.github/workflows/sync-localazy.yml
index 3d63f8b542..f3acb4675b 100644
--- a/.github/workflows/sync-localazy.yml
+++ b/.github/workflows/sync-localazy.yml
@@ -11,7 +11,7 @@ jobs:
# Skip in forks
if: github.repository == 'vector-im/element-x-android'
steps:
- - uses: actions/checkout@v3
+ - uses: actions/checkout@v4
- name: Set up Python 3.9
uses: actions/setup-python@v4
with:
diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml
index 460e57b4e3..f662e5352d 100644
--- a/.github/workflows/tests.yml
+++ b/.github/workflows/tests.yml
@@ -22,6 +22,16 @@ jobs:
group: ${{ github.ref == 'refs/heads/main' && format('unit-tests-main-{0}', github.sha) || github.ref == 'refs/heads/develop' && format('unit-tests-develop-{0}', github.sha) || format('unit-tests-{0}', github.ref) }}
cancel-in-progress: true
steps:
+ # Increase swapfile size to prevent screenshot tests getting terminated
+ # https://github.com/actions/runner-images/discussions/7188#discussioncomment-6750749
+ - name: 💽 Increase swapfile size
+ run: |
+ sudo swapoff -a
+ sudo fallocate -l 8G /mnt/swapfile
+ sudo chmod 600 /mnt/swapfile
+ sudo mkswap /mnt/swapfile
+ sudo swapon /mnt/swapfile
+ sudo swapon --show
- name: ⏬ Checkout with LFS
uses: nschloe/action-cached-lfs-checkout@v1.2.2
with:
diff --git a/.maestro/tests/account/changeServer.yaml b/.maestro/tests/account/changeServer.yaml
index e503589f5c..c6b092e018 100644
--- a/.maestro/tests/account/changeServer.yaml
+++ b/.maestro/tests/account/changeServer.yaml
@@ -15,7 +15,7 @@ appId: ${APP_ID}
- tapOn: "gnuradio.org"
- extendedWaitUntil:
visible: "This server currently doesn’t support sliding sync."
- timeout: 10_000
+ timeout: 10000
- tapOn: "Cancel"
- back
- back
diff --git a/.maestro/tests/assertions/assertAnalyticsDisplayed.yaml b/.maestro/tests/assertions/assertAnalyticsDisplayed.yaml
index 96a91a24af..9c63c99ffc 100644
--- a/.maestro/tests/assertions/assertAnalyticsDisplayed.yaml
+++ b/.maestro/tests/assertions/assertAnalyticsDisplayed.yaml
@@ -2,4 +2,4 @@ appId: ${APP_ID}
---
- extendedWaitUntil:
visible: "Help improve Element X dbg"
- timeout: 10_000
+ timeout: 10000
diff --git a/.maestro/tests/assertions/assertHomeDisplayed.yaml b/.maestro/tests/assertions/assertHomeDisplayed.yaml
index 6e9eec50db..ca409705e1 100644
--- a/.maestro/tests/assertions/assertHomeDisplayed.yaml
+++ b/.maestro/tests/assertions/assertHomeDisplayed.yaml
@@ -2,4 +2,4 @@ appId: ${APP_ID}
---
- extendedWaitUntil:
visible: "All Chats"
- timeout: 10_000
+ timeout: 10000
diff --git a/.maestro/tests/assertions/assertInitDisplayed.yaml b/.maestro/tests/assertions/assertInitDisplayed.yaml
index 417ac87711..9424f382c1 100644
--- a/.maestro/tests/assertions/assertInitDisplayed.yaml
+++ b/.maestro/tests/assertions/assertInitDisplayed.yaml
@@ -2,4 +2,4 @@ appId: ${APP_ID}
---
- extendedWaitUntil:
visible: "Be in your element"
- timeout: 10_000
+ timeout: 10000
diff --git a/.maestro/tests/assertions/assertLoginDisplayed.yaml b/.maestro/tests/assertions/assertLoginDisplayed.yaml
index 3abd86ceef..b18078f916 100644
--- a/.maestro/tests/assertions/assertLoginDisplayed.yaml
+++ b/.maestro/tests/assertions/assertLoginDisplayed.yaml
@@ -2,4 +2,4 @@ appId: ${APP_ID}
---
- extendedWaitUntil:
visible: "Change account provider"
- timeout: 10_000
+ timeout: 10000
diff --git a/.maestro/tests/assertions/assertRoomListSynced.yaml b/.maestro/tests/assertions/assertRoomListSynced.yaml
index 2d13c17df9..5fcd6e093e 100644
--- a/.maestro/tests/assertions/assertRoomListSynced.yaml
+++ b/.maestro/tests/assertions/assertRoomListSynced.yaml
@@ -2,4 +2,4 @@ appId: ${APP_ID}
---
- extendedWaitUntil:
visible: ${ROOM_NAME}
- timeout: 10_000
+ timeout: 10000
diff --git a/.maestro/tests/assertions/assertWelcomeScreenDisplayed.yaml b/.maestro/tests/assertions/assertWelcomeScreenDisplayed.yaml
index 73e8e78ef5..3fbd9d2513 100644
--- a/.maestro/tests/assertions/assertWelcomeScreenDisplayed.yaml
+++ b/.maestro/tests/assertions/assertWelcomeScreenDisplayed.yaml
@@ -3,4 +3,4 @@ appId: ${APP_ID}
- extendedWaitUntil:
visible:
id: "welcome_screen-title"
- timeout: 10_000
+ timeout: 10000
diff --git a/.maestro/tests/roomList/timeline/messages/text.yaml b/.maestro/tests/roomList/timeline/messages/text.yaml
index 4e3b7bbd45..963b2cf9e9 100644
--- a/.maestro/tests/roomList/timeline/messages/text.yaml
+++ b/.maestro/tests/roomList/timeline/messages/text.yaml
@@ -1,7 +1,8 @@
appId: ${APP_ID}
---
- takeScreenshot: build/maestro/510-Timeline
-- tapOn: "Message"
+- tapOn:
+ id: "rich_text_editor"
- inputText: "Hello world!"
- tapOn: "Send"
- hideKeyboard
diff --git a/CHANGES.md b/CHANGES.md
index 4cca4c7212..05e070f678 100644
--- a/CHANGES.md
+++ b/CHANGES.md
@@ -1,3 +1,42 @@
+Changes in Element X v0.2.0 (2023-09-18)
+========================================
+
+Features ✨
+----------
+ - Bump Rust SDK to `v0.1.54`
+ - Add a "Mute" shortcut icon and a "Notifications" section in the room details screen ([#506](https://github.com/vector-im/element-x-android/issues/506))
+ - Add a notification permission screen to the initial flow. ([#897](https://github.com/vector-im/element-x-android/issues/897))
+ - Integrate Element Call into EX by embedding a call in a WebView. ([#1300](https://github.com/vector-im/element-x-android/issues/1300))
+ - Implement Bloom effect modifier. ([#1217](https://github.com/vector-im/element-x-android/issues/1217))
+ - Set color on display name and default avatar in the timeline. ([#1224](https://github.com/vector-im/element-x-android/issues/1224))
+ - Display a thread decorator in timeline so we know when a message is coming from a thread. ([#1236](https://github.com/vector-im/element-x-android/issues/1236))
+ - [Rich text editor] Integrate rich text editor library. Note that markdown is now not supported and further formatting support will be introduced through the rich text editor. ([#1172](https://github.com/vector-im/element-x-android/issues/1172))
+ - [Rich text editor] Add formatting menu (accessible via the '+' button) ([#1261](https://github.com/vector-im/element-x-android/issues/1261))
+ - [Rich text editor] Add feature flag for rich text editor. Markdown support can now be enabled by disabling the rich text editor. ([#1289](https://github.com/vector-im/element-x-android/issues/1289))
+ - [Rich text editor] Update design ([#1332](https://github.com/vector-im/element-x-android/issues/1332))
+
+Bugfixes 🐛
+----------
+ - Make links in room topic clickable ([#612](https://github.com/vector-im/element-x-android/issues/612))
+ - Reply action: harmonize conditions in bottom sheet and swipe to reply. ([#1173](https://github.com/vector-im/element-x-android/issues/1173))
+ - Fix system bar color after login on light theme. ([#1222](https://github.com/vector-im/element-x-android/issues/1222))
+ - Fix long click on simple formatted messages ([#1232](https://github.com/vector-im/element-x-android/issues/1232))
+ - Enable polls in release build. ([#1241](https://github.com/vector-im/element-x-android/issues/1241))
+ - Fix top padding in room list when app is opened in offline mode. ([#1297](https://github.com/vector-im/element-x-android/issues/1297))
+ - [Rich text editor] Fix 'text formatting' option only partially visible ([#1335](https://github.com/vector-im/element-x-android/issues/1335))
+ - [Rich text editor] Ensure keyboard opens for reply and text formatting modes ([#1337](https://github.com/vector-im/element-x-android/issues/1337))
+ - [Rich text editor] Fix placeholder spilling onto multiple lines ([#1347](https://github.com/vector-im/element-x-android/issues/1347))
+
+Other changes
+-------------
+ - Add a sub-screen "Notifications" in the existing application Settings ([#510](https://github.com/vector-im/element-x-android/issues/510))
+ - Exclude some groups related to analytics to be included. ([#1191](https://github.com/vector-im/element-x-android/issues/1191))
+ - Use the new SyncIndicator API. ([#1244](https://github.com/vector-im/element-x-android/issues/1244))
+ - Improve RoomSummary mapping by using RoomInfo. ([#1251](https://github.com/vector-im/element-x-android/issues/1251))
+ - Ensure Posthog data are sent to "https://posthog.element.io" ([#1269](https://github.com/vector-im/element-x-android/issues/1269))
+ - New app icon, with monochrome support. ([#1363](https://github.com/vector-im/element-x-android/issues/1363))
+
+
Changes in Element X v0.1.6 (2023-09-04)
========================================
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index 2821fcbd04..839a5095dd 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -198,6 +198,7 @@ dependencies {
allLibrariesImpl()
allServicesImpl()
allFeaturesImpl(rootDir, logger)
+ implementation(projects.features.call)
implementation(projects.anvilannotations)
implementation(projects.appnav)
anvil(projects.anvilcodegen)
diff --git a/app/src/debug/res/drawable/ic_launcher_background.xml b/app/src/debug/res/drawable/ic_launcher_background.xml
new file mode 100644
index 0000000000..d41447906a
--- /dev/null
+++ b/app/src/debug/res/drawable/ic_launcher_background.xml
@@ -0,0 +1,10 @@
+
+
+
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index ffd0265584..2b77c7076e 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -22,8 +22,9 @@
{
}
bugReporter.cleanLogDirectoryIfNeeded()
tracingService.setupTracing(tracingConfiguration)
+ // Also set env variable for rust back trace
+ Os.setenv("RUST_BACKTRACE", "1", true)
}
override fun dependencies(): List>> = mutableListOf()
diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
index 82724deb96..804149846b 100644
--- a/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
+++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
@@ -1,6 +1,22 @@
+
+
-
+
-
+
diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml
index 82724deb96..804149846b 100644
--- a/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml
+++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml
@@ -1,6 +1,22 @@
+
+
-
+
-
+
diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher.png b/app/src/main/res/mipmap-hdpi/ic_launcher.png
deleted file mode 100644
index 4bbfe30ed2..0000000000
Binary files a/app/src/main/res/mipmap-hdpi/ic_launcher.png and /dev/null differ
diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher.webp b/app/src/main/res/mipmap-hdpi/ic_launcher.webp
new file mode 100644
index 0000000000..f08c673055
Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher.webp differ
diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher_background.png b/app/src/main/res/mipmap-hdpi/ic_launcher_background.png
deleted file mode 100644
index 3510298288..0000000000
Binary files a/app/src/main/res/mipmap-hdpi/ic_launcher_background.png and /dev/null differ
diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher_background.webp b/app/src/main/res/mipmap-hdpi/ic_launcher_background.webp
new file mode 100644
index 0000000000..f051ae3c81
Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher_background.webp differ
diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png b/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png
deleted file mode 100644
index 20b221702c..0000000000
Binary files a/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png and /dev/null differ
diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.webp b/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.webp
new file mode 100644
index 0000000000..fff7bbbfd7
Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.webp differ
diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher_monochrome.webp b/app/src/main/res/mipmap-hdpi/ic_launcher_monochrome.webp
new file mode 100644
index 0000000000..bcc8059674
Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher_monochrome.webp differ
diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher_round.png b/app/src/main/res/mipmap-hdpi/ic_launcher_round.png
deleted file mode 100644
index 9dd38fd073..0000000000
Binary files a/app/src/main/res/mipmap-hdpi/ic_launcher_round.png and /dev/null differ
diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp
new file mode 100644
index 0000000000..00b5a99258
Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp differ
diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher.png b/app/src/main/res/mipmap-mdpi/ic_launcher.png
deleted file mode 100644
index 001f74e9ba..0000000000
Binary files a/app/src/main/res/mipmap-mdpi/ic_launcher.png and /dev/null differ
diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher.webp b/app/src/main/res/mipmap-mdpi/ic_launcher.webp
new file mode 100644
index 0000000000..9965e382c1
Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher.webp differ
diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher_background.png b/app/src/main/res/mipmap-mdpi/ic_launcher_background.png
deleted file mode 100644
index d3ce4fb227..0000000000
Binary files a/app/src/main/res/mipmap-mdpi/ic_launcher_background.png and /dev/null differ
diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher_background.webp b/app/src/main/res/mipmap-mdpi/ic_launcher_background.webp
new file mode 100644
index 0000000000..27d9d1db19
Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher_background.webp differ
diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png b/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png
deleted file mode 100644
index 2d4bab337c..0000000000
Binary files a/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png and /dev/null differ
diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.webp b/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.webp
new file mode 100644
index 0000000000..f248938976
Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.webp differ
diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher_monochrome.webp b/app/src/main/res/mipmap-mdpi/ic_launcher_monochrome.webp
new file mode 100644
index 0000000000..62e0fb81a7
Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher_monochrome.webp differ
diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher_round.png b/app/src/main/res/mipmap-mdpi/ic_launcher_round.png
deleted file mode 100644
index 42a631def3..0000000000
Binary files a/app/src/main/res/mipmap-mdpi/ic_launcher_round.png and /dev/null differ
diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp
new file mode 100644
index 0000000000..347a31e2ea
Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp differ
diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/app/src/main/res/mipmap-xhdpi/ic_launcher.png
deleted file mode 100644
index 0adb2f3b52..0000000000
Binary files a/app/src/main/res/mipmap-xhdpi/ic_launcher.png and /dev/null differ
diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher.webp b/app/src/main/res/mipmap-xhdpi/ic_launcher.webp
new file mode 100644
index 0000000000..b1d83c1244
Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher.webp differ
diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher_background.png b/app/src/main/res/mipmap-xhdpi/ic_launcher_background.png
deleted file mode 100644
index e73012b493..0000000000
Binary files a/app/src/main/res/mipmap-xhdpi/ic_launcher_background.png and /dev/null differ
diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher_background.webp b/app/src/main/res/mipmap-xhdpi/ic_launcher_background.webp
new file mode 100644
index 0000000000..4dbc6db066
Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher_background.webp differ
diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png b/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png
deleted file mode 100644
index 33fd9ab681..0000000000
Binary files a/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png and /dev/null differ
diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.webp b/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.webp
new file mode 100644
index 0000000000..b4090e12b5
Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.webp differ
diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher_monochrome.webp b/app/src/main/res/mipmap-xhdpi/ic_launcher_monochrome.webp
new file mode 100644
index 0000000000..d7c1ffcc72
Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher_monochrome.webp differ
diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png b/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png
deleted file mode 100644
index 05f718cf3b..0000000000
Binary files a/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png and /dev/null differ
diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp
new file mode 100644
index 0000000000..0537ce0a8b
Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp differ
diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/app/src/main/res/mipmap-xxhdpi/ic_launcher.png
deleted file mode 100644
index 6dd6aa3ee6..0000000000
Binary files a/app/src/main/res/mipmap-xxhdpi/ic_launcher.png and /dev/null differ
diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp b/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp
new file mode 100644
index 0000000000..60ada6a758
Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp differ
diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher_background.png b/app/src/main/res/mipmap-xxhdpi/ic_launcher_background.png
deleted file mode 100644
index 1a6c540c52..0000000000
Binary files a/app/src/main/res/mipmap-xxhdpi/ic_launcher_background.png and /dev/null differ
diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher_background.webp b/app/src/main/res/mipmap-xxhdpi/ic_launcher_background.webp
new file mode 100644
index 0000000000..b635d5cbb5
Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher_background.webp differ
diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png b/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png
deleted file mode 100644
index 7c4cf9729b..0000000000
Binary files a/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png and /dev/null differ
diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.webp b/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.webp
new file mode 100644
index 0000000000..7311a98da8
Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.webp differ
diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher_monochrome.webp b/app/src/main/res/mipmap-xxhdpi/ic_launcher_monochrome.webp
new file mode 100644
index 0000000000..4f5c924f1f
Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher_monochrome.webp differ
diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png b/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png
deleted file mode 100644
index 448fa261cc..0000000000
Binary files a/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png and /dev/null differ
diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp
new file mode 100644
index 0000000000..dfae045e0c
Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp differ
diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png
deleted file mode 100644
index adb2a0c794..0000000000
Binary files a/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png and /dev/null differ
diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp
new file mode 100644
index 0000000000..8282551ecf
Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp differ
diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_background.png b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_background.png
deleted file mode 100644
index 576bdfc52d..0000000000
Binary files a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_background.png and /dev/null differ
diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_background.webp b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_background.webp
new file mode 100644
index 0000000000..b5cb68c7bb
Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_background.webp differ
diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png
deleted file mode 100644
index 89f421aafc..0000000000
Binary files a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png and /dev/null differ
diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.webp b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.webp
new file mode 100644
index 0000000000..4efec9cdb2
Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.webp differ
diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_monochrome.webp b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_monochrome.webp
new file mode 100644
index 0000000000..c3490dc0b4
Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_monochrome.webp differ
diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png
deleted file mode 100644
index d5c5e2af7d..0000000000
Binary files a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png and /dev/null differ
diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp
new file mode 100644
index 0000000000..ecf5481f94
Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp differ
diff --git a/app/src/main/res/xml/backup_rules.xml b/app/src/main/res/xml/backup_rules.xml
index 37fe0011dc..973e5911ae 100644
--- a/app/src/main/res/xml/backup_rules.xml
+++ b/app/src/main/res/xml/backup_rules.xml
@@ -15,15 +15,8 @@
-->
-
+
diff --git a/app/src/main/res/xml/data_extraction_rules.xml b/app/src/main/res/xml/data_extraction_rules.xml
index a6ecda4638..9b4bbfff1c 100644
--- a/app/src/main/res/xml/data_extraction_rules.xml
+++ b/app/src/main/res/xml/data_extraction_rules.xml
@@ -1,5 +1,5 @@
-
+
-
diff --git a/app/src/nightly/res/drawable/ic_launcher_background.xml b/app/src/nightly/res/drawable/ic_launcher_background.xml
new file mode 100644
index 0000000000..dc27f37adf
--- /dev/null
+++ b/app/src/nightly/res/drawable/ic_launcher_background.xml
@@ -0,0 +1,10 @@
+
+
+
diff --git a/app/src/release/res/drawable/ic_launcher_background.xml b/app/src/release/res/drawable/ic_launcher_background.xml
new file mode 100644
index 0000000000..6ff3e59543
--- /dev/null
+++ b/app/src/release/res/drawable/ic_launcher_background.xml
@@ -0,0 +1,2 @@
+
diff --git a/appnav/build.gradle.kts b/appnav/build.gradle.kts
index cffd318fb1..88f0741ebe 100644
--- a/appnav/build.gradle.kts
+++ b/appnav/build.gradle.kts
@@ -48,8 +48,6 @@ dependencies {
implementation(projects.libraries.designsystem)
implementation(projects.libraries.matrixui)
implementation(projects.libraries.uiStrings)
- implementation(projects.libraries.permissions.api)
- implementation(projects.libraries.permissions.noop)
implementation(libs.coil)
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 c3a79acb81..5006218bda 100644
--- a/appnav/src/main/kotlin/io/element/android/appnav/LoggedInFlowNode.kt
+++ b/appnav/src/main/kotlin/io/element/android/appnav/LoggedInFlowNode.kt
@@ -60,7 +60,6 @@ import io.element.android.libraries.di.SessionScope
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.core.MAIN_SPACE
import io.element.android.libraries.matrix.api.core.RoomId
-import io.element.android.libraries.matrix.api.sync.StartSyncReason
import io.element.android.libraries.matrix.api.sync.SyncState
import io.element.android.libraries.push.api.notifications.NotificationDrawerManager
import io.element.android.services.appnavstate.api.AppNavigationStateService
@@ -125,7 +124,7 @@ class LoggedInFlowNode @AssistedInject constructor(
onStop = {
//Counterpart startSync is done in observeSyncStateAndNetworkStatus method.
coroutineScope.launch {
- syncService.stopSync(StartSyncReason.AppInForeground)
+ syncService.stopSync()
}
},
onDestroy = {
@@ -151,7 +150,7 @@ class LoggedInFlowNode @AssistedInject constructor(
.collect { (syncState, networkStatus) ->
Timber.d("Sync state: $syncState, network status: $networkStatus")
if (syncState != SyncState.Running && networkStatus == NetworkStatus.Online) {
- syncService.startSync(StartSyncReason.AppInForeground)
+ syncService.startSync()
}
}
}
diff --git a/appnav/src/main/kotlin/io/element/android/appnav/NotLoggedInFlowNode.kt b/appnav/src/main/kotlin/io/element/android/appnav/NotLoggedInFlowNode.kt
index 17f3a44eb8..6c22644658 100644
--- a/appnav/src/main/kotlin/io/element/android/appnav/NotLoggedInFlowNode.kt
+++ b/appnav/src/main/kotlin/io/element/android/appnav/NotLoggedInFlowNode.kt
@@ -32,6 +32,7 @@ import dagger.assisted.AssistedInject
import io.element.android.anvilannotations.ContributesNode
import io.element.android.features.login.api.LoginEntryPoint
import io.element.android.features.onboarding.api.OnBoardingEntryPoint
+import io.element.android.features.preferences.api.ConfigureTracingEntryPoint
import io.element.android.libraries.architecture.BackstackNode
import io.element.android.libraries.architecture.animation.rememberDefaultTransitionHandler
import io.element.android.libraries.di.AppScope
@@ -43,6 +44,7 @@ class NotLoggedInFlowNode @AssistedInject constructor(
@Assisted buildContext: BuildContext,
@Assisted plugins: List,
private val onBoardingEntryPoint: OnBoardingEntryPoint,
+ private val configureTracingEntryPoint: ConfigureTracingEntryPoint,
private val loginEntryPoint: LoginEntryPoint,
private val notLoggedInImageLoaderFactory: NotLoggedInImageLoaderFactory,
) : BackstackNode(
@@ -70,6 +72,9 @@ class NotLoggedInFlowNode @AssistedInject constructor(
data class LoginFlow(
val isAccountCreation: Boolean,
) : NavTarget
+
+ @Parcelize
+ data object ConfigureTracing : NavTarget
}
override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {
@@ -83,6 +88,10 @@ class NotLoggedInFlowNode @AssistedInject constructor(
override fun onSignIn() {
backstack.push(NavTarget.LoginFlow(isAccountCreation = false))
}
+
+ override fun onOpenDeveloperSettings() {
+ backstack.push(NavTarget.ConfigureTracing)
+ }
}
onBoardingEntryPoint
.nodeBuilder(this, buildContext)
@@ -94,6 +103,9 @@ class NotLoggedInFlowNode @AssistedInject constructor(
.params(LoginEntryPoint.Params(isAccountCreation = navTarget.isAccountCreation))
.build()
}
+ NavTarget.ConfigureTracing -> {
+ configureTracingEntryPoint.createNode(this, buildContext)
+ }
}
}
diff --git a/appnav/src/main/kotlin/io/element/android/appnav/loggedin/LoggedInPresenter.kt b/appnav/src/main/kotlin/io/element/android/appnav/loggedin/LoggedInPresenter.kt
index 6d386a17e5..7683b7278f 100644
--- a/appnav/src/main/kotlin/io/element/android/appnav/loggedin/LoggedInPresenter.kt
+++ b/appnav/src/main/kotlin/io/element/android/appnav/loggedin/LoggedInPresenter.kt
@@ -16,44 +16,26 @@
package io.element.android.appnav.loggedin
-import android.Manifest
-import android.os.Build
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
-import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
-import androidx.compose.runtime.setValue
import io.element.android.features.networkmonitor.api.NetworkMonitor
import io.element.android.features.networkmonitor.api.NetworkStatus
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.roomlist.RoomListService
-import io.element.android.libraries.permissions.api.PermissionsPresenter
-import io.element.android.libraries.permissions.noop.NoopPermissionsPresenter
import io.element.android.libraries.push.api.PushService
-import kotlinx.coroutines.delay
import javax.inject.Inject
-private const val DELAY_BEFORE_SHOWING_SYNC_SPINNER_IN_MILLIS = 1500L
-
class LoggedInPresenter @Inject constructor(
private val matrixClient: MatrixClient,
- private val permissionsPresenterFactory: PermissionsPresenter.Factory,
private val networkMonitor: NetworkMonitor,
private val pushService: PushService,
) : Presenter {
- private val postNotificationPermissionsPresenter by lazy {
- // Ask for POST_NOTIFICATION PERMISSION on Android 13+
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
- permissionsPresenterFactory.create(Manifest.permission.POST_NOTIFICATIONS)
- } else {
- NoopPermissionsPresenter()
- }
- }
-
@Composable
override fun present(): LoggedInState {
LaunchedEffect(Unit) {
@@ -64,25 +46,15 @@ class LoggedInPresenter @Inject constructor(
pushService.registerWith(matrixClient, pushProvider, distributor)
}
- val roomListState by matrixClient.roomListService.state.collectAsState()
+ val syncIndicator by matrixClient.roomListService.syncIndicator.collectAsState()
val networkStatus by networkMonitor.connectivity.collectAsState()
- val permissionsState = postNotificationPermissionsPresenter.present()
- var showSyncSpinner by remember {
- mutableStateOf(false)
- }
- LaunchedEffect(roomListState, networkStatus) {
- showSyncSpinner = when {
- networkStatus == NetworkStatus.Offline -> false
- roomListState == RoomListService.State.Running -> false
- else -> {
- delay(DELAY_BEFORE_SHOWING_SYNC_SPINNER_IN_MILLIS)
- true
- }
+ val showSyncSpinner by remember {
+ derivedStateOf {
+ networkStatus == NetworkStatus.Online && syncIndicator == RoomListService.SyncIndicator.Show
}
}
return LoggedInState(
showSyncSpinner = showSyncSpinner,
- permissionsState = permissionsState,
)
}
}
diff --git a/appnav/src/main/kotlin/io/element/android/appnav/loggedin/LoggedInState.kt b/appnav/src/main/kotlin/io/element/android/appnav/loggedin/LoggedInState.kt
index bb06952a50..4196277698 100644
--- a/appnav/src/main/kotlin/io/element/android/appnav/loggedin/LoggedInState.kt
+++ b/appnav/src/main/kotlin/io/element/android/appnav/loggedin/LoggedInState.kt
@@ -16,9 +16,6 @@
package io.element.android.appnav.loggedin
-import io.element.android.libraries.permissions.api.PermissionsState
-
data class LoggedInState(
val showSyncSpinner: Boolean,
- val permissionsState: PermissionsState,
)
diff --git a/appnav/src/main/kotlin/io/element/android/appnav/loggedin/LoggedInStateProvider.kt b/appnav/src/main/kotlin/io/element/android/appnav/loggedin/LoggedInStateProvider.kt
index 3cfb03f123..0e8fdef8d8 100644
--- a/appnav/src/main/kotlin/io/element/android/appnav/loggedin/LoggedInStateProvider.kt
+++ b/appnav/src/main/kotlin/io/element/android/appnav/loggedin/LoggedInStateProvider.kt
@@ -17,7 +17,6 @@
package io.element.android.appnav.loggedin
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
-import io.element.android.libraries.permissions.api.createDummyPostNotificationPermissionsState
open class LoggedInStateProvider : PreviewParameterProvider {
override val values: Sequence
@@ -32,5 +31,4 @@ fun aLoggedInState(
showSyncSpinner: Boolean = true,
) = LoggedInState(
showSyncSpinner = showSyncSpinner,
- permissionsState = createDummyPostNotificationPermissionsState(),
)
diff --git a/appnav/src/main/kotlin/io/element/android/appnav/loggedin/LoggedInView.kt b/appnav/src/main/kotlin/io/element/android/appnav/loggedin/LoggedInView.kt
index 0ade93a795..37e6e9591d 100644
--- a/appnav/src/main/kotlin/io/element/android/appnav/loggedin/LoggedInView.kt
+++ b/appnav/src/main/kotlin/io/element/android/appnav/loggedin/LoggedInView.kt
@@ -23,21 +23,16 @@ import androidx.compose.foundation.layout.systemBarsPadding
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
-import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
-import io.element.android.libraries.androidutils.system.openAppSettingsPage
import io.element.android.libraries.designsystem.preview.DayNightPreviews
import io.element.android.libraries.designsystem.preview.ElementPreview
-import io.element.android.libraries.permissions.api.PermissionsView
@Composable
fun LoggedInView(
state: LoggedInState,
modifier: Modifier = Modifier
) {
- val context = LocalContext.current
-
Box(
modifier = modifier
.fillMaxSize()
@@ -49,10 +44,6 @@ fun LoggedInView(
.align(Alignment.TopCenter),
isVisible = state.showSyncSpinner,
)
- PermissionsView(
- state = state.permissionsState,
- openSystemSettings = context::openAppSettingsPage
- )
}
}
diff --git a/appnav/src/test/kotlin/io/element/android/appnav/RootPresenterTest.kt b/appnav/src/test/kotlin/io/element/android/appnav/RootPresenterTest.kt
index dad9365921..6771718d81 100644
--- a/appnav/src/test/kotlin/io/element/android/appnav/RootPresenterTest.kt
+++ b/appnav/src/test/kotlin/io/element/android/appnav/RootPresenterTest.kt
@@ -31,10 +31,15 @@ import io.element.android.features.rageshake.test.screenshot.FakeScreenshotHolde
import io.element.android.services.apperror.api.AppErrorState
import io.element.android.services.apperror.api.AppErrorStateService
import io.element.android.services.apperror.impl.DefaultAppErrorStateService
+import io.element.android.tests.testutils.WarmUpRule
import kotlinx.coroutines.test.runTest
+import org.junit.Rule
import org.junit.Test
class RootPresenterTest {
+ @get:Rule
+ val warmUpRule = WarmUpRule()
+
@Test
fun `present - initial state`() = runTest {
val presenter = createPresenter()
diff --git a/appnav/src/test/kotlin/io/element/android/appnav/loggedin/LoggedInPresenterTest.kt b/appnav/src/test/kotlin/io/element/android/appnav/loggedin/LoggedInPresenterTest.kt
index 4abc89e7ee..efada670b0 100644
--- a/appnav/src/test/kotlin/io/element/android/appnav/loggedin/LoggedInPresenterTest.kt
+++ b/appnav/src/test/kotlin/io/element/android/appnav/loggedin/LoggedInPresenterTest.kt
@@ -26,16 +26,20 @@ import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.roomlist.RoomListService
import io.element.android.libraries.matrix.test.FakeMatrixClient
import io.element.android.libraries.matrix.test.roomlist.FakeRoomListService
-import io.element.android.libraries.permissions.api.PermissionsPresenter
-import io.element.android.libraries.permissions.noop.NoopPermissionsPresenter
import io.element.android.libraries.push.api.PushService
import io.element.android.libraries.pushproviders.api.Distributor
import io.element.android.libraries.pushproviders.api.PushProvider
+import io.element.android.tests.testutils.WarmUpRule
import io.element.android.tests.testutils.consumeItemsUntilPredicate
import kotlinx.coroutines.test.runTest
+import org.junit.Rule
import org.junit.Test
class LoggedInPresenterTest {
+
+ @get:Rule
+ val warmUpRule = WarmUpRule()
+
@Test
fun `present - initial state`() = runTest {
val presenter = createPresenter()
@@ -43,7 +47,7 @@ class LoggedInPresenterTest {
presenter.present()
}.test {
val initialState = awaitItem()
- assertThat(initialState.permissionsState.permission).isEmpty()
+ assertThat(initialState.showSyncSpinner).isFalse()
}
}
@@ -68,11 +72,6 @@ class LoggedInPresenterTest {
): LoggedInPresenter {
return LoggedInPresenter(
matrixClient = FakeMatrixClient(roomListService = roomListService),
- permissionsPresenterFactory = object : PermissionsPresenter.Factory {
- override fun create(permission: String): PermissionsPresenter {
- return NoopPermissionsPresenter()
- }
- },
networkMonitor = FakeNetworkMonitor(networkStatus),
pushService = object : PushService {
override fun notificationStyleChanged() {
diff --git a/build.gradle.kts b/build.gradle.kts
index b48ef70ce7..7e7c1c1c9e 100644
--- a/build.gradle.kts
+++ b/build.gradle.kts
@@ -6,7 +6,7 @@ import org.jetbrains.kotlin.cli.common.toBooleanLenient
buildscript {
dependencies {
classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:1.9.10")
- classpath("com.google.gms:google-services:4.3.15")
+ classpath("com.google.gms:google-services:4.4.0")
}
}
@@ -62,7 +62,7 @@ allprojects {
config.from(files("$rootDir/tools/detekt/detekt.yml"))
}
dependencies {
- detektPlugins("io.nlopez.compose.rules:detekt:0.2.1")
+ detektPlugins("io.nlopez.compose.rules:detekt:0.2.2")
}
// KtLint
@@ -143,22 +143,6 @@ sonar {
}
}
-allprojects {
- val projectDir = projectDir.toString()
- sonar {
- properties {
- // Note: folders `kotlin` are not supported (yet), I asked on their side: https://community.sonarsource.com/t/82824
- // As a workaround provide the path in `sonar.sources` property.
- if (File("$projectDir/src/main/kotlin").exists()) {
- property("sonar.sources", "src/main/kotlin")
- }
- if (File("$projectDir/src/test/kotlin").exists()) {
- property("sonar.tests", "src/test/kotlin")
- }
- }
- }
-}
-
allprojects {
tasks.withType {
maxParallelForks = (Runtime.getRuntime().availableProcessors() / 2).coerceAtLeast(1)
@@ -261,6 +245,8 @@ koverMerged {
includes += "*Presenter"
excludes += "*Fake*Presenter"
excludes += "io.element.android.appnav.loggedin.LoggedInPresenter$*"
+ // Some options can't be tested at the moment
+ excludes += "io.element.android.features.preferences.impl.developer.DeveloperSettingsPresenter$*"
}
bound {
minValue = 85
diff --git a/docs/continuous_integration.md b/docs/continuous_integration.md
new file mode 100644
index 0000000000..4ff0ebb983
--- /dev/null
+++ b/docs/continuous_integration.md
@@ -0,0 +1,69 @@
+# Continuous integration strategy
+
+
+
+* [Introduction](#introduction)
+* [CI tools](#ci-tools)
+* [Rules](#rules)
+* [What is the CI checking](#what-is-the-ci-checking)
+* [What is the CI reporting](#what-is-the-ci-reporting)
+* [Current choices](#current-choices)
+ * [R8 task](#r8-task)
+ * [Android test (connected test)](#android-test-connected-test)
+
+
+
+## Introduction
+
+This document gives some information about how we take advantage of the continuous integration (CI).
+
+## CI tools
+
+We use GitHub Actions to configure and perform the CI.
+
+## Rules
+
+We want:
+
+1. The CI to detect as soon as possible any issue in the code
+2. The CI to be fast - it's run on all the Pull Requests, and developers do not like to wait too long
+3. The CI to be reliable - it should not fail randomly
+4. The CI to generate artifacts which can be used by the team and the community
+5. The CI to generate useful logs and reports, not too verbose, not too short
+6. The developer to be able to run the CI locally - to help with this we have [a script](../tools/check/check_code_quality.sh) the can be run locally and which does more checks that just building and deploying the app.
+7. The CI to be used as a common environment for the team: generate the screenshots image for the screenshot test, build the release build (unsigned)
+8. The CI to run repeated tasks, like building the nightly builds, integrating data from external tools (translations, etc.)
+9. The CI to upgrade our dependencies (Renovate)
+10. The CI to do some issue triaging
+
+## What is the CI checking
+
+The CI checks that:
+
+1. The code is compiling, without any warnings, for all the app build types and variants and for the minimal app
+2. The tests are passing
+3. The code quality is good (detekt, ktlint, lint)
+4. The code is running and smoke tests are passing (maestro)
+5. The PullRequest itself is good (with danger)
+6. Files that must be added with git-lfs are added with git-lfs
+
+## What is the CI reporting
+
+The CI reports:
+
+1. Code coverage reports
+2. Sonar reports
+
+## Current choices
+
+### R8 task
+
+The CI does not run R8 because it's too slow, and it breaks rule 2.
+
+The drawback is that the nightly build can fail, as well as the release build.
+
+Since the nightly build is failing, the team can detect the failure quite fast and react to it.
+
+### Android test (connected test)
+
+We limit the number of connected tests (tests under folder `androidTest`), because it often break rule 2 and 3.
diff --git a/fastlane/metadata/android/en-US/changelogs/40002000.txt b/fastlane/metadata/android/en-US/changelogs/40002000.txt
new file mode 100644
index 0000000000..97ceaa8d5a
--- /dev/null
+++ b/fastlane/metadata/android/en-US/changelogs/40002000.txt
@@ -0,0 +1,2 @@
+Main changes in this version: Element Call, design update, bugfixes
+Full changelog: https://github.com/vector-im/element-x-android/releases
diff --git a/features/analytics/impl/build.gradle.kts b/features/analytics/impl/build.gradle.kts
index 3ce2bab507..3deb202ebc 100644
--- a/features/analytics/impl/build.gradle.kts
+++ b/features/analytics/impl/build.gradle.kts
@@ -51,4 +51,5 @@ dependencies {
testImplementation(libs.test.mockk)
testImplementation(projects.libraries.matrix.test)
testImplementation(projects.services.analytics.test)
+ testImplementation(projects.tests.testutils)
}
diff --git a/features/analytics/impl/src/main/res/values-de/translations.xml b/features/analytics/impl/src/main/res/values-de/translations.xml
index 7ef2ff2500..ac9149a4b6 100644
--- a/features/analytics/impl/src/main/res/values-de/translations.xml
+++ b/features/analytics/impl/src/main/res/values-de/translations.xml
@@ -1,10 +1,10 @@
- "Wir werden keine personenbezogenen Daten aufzeichnen oder auswerten"
+ "Wir zeichnen keine persönlichen Daten auf und erstellen keine Profile."
"Teile anonyme Nutzungsdaten, um uns bei der Identifizierung von Problemen zu helfen."
- "Du kannst alle unsere Nutzerbedingungen %1$s lesen."
+ "Du kannst alle unsere Bedingungen lesen %1$s."
"hier"
- "Du kannst dies jederzeit deaktivieren"
- "Wir geben deine Daten nicht an Dritte weiter"
- "Hilf uns, %1$s zu verbessern"
+ "Du kannst diese Funktion jederzeit deaktivieren"
+ "Wir geben Ihre Daten nicht an Dritte weiter"
+ "Hilf uns %1$s zu verbessern"
diff --git a/features/analytics/impl/src/main/res/values-fr/translations.xml b/features/analytics/impl/src/main/res/values-fr/translations.xml
index 55231f7b6c..e18657cf98 100644
--- a/features/analytics/impl/src/main/res/values-fr/translations.xml
+++ b/features/analytics/impl/src/main/res/values-fr/translations.xml
@@ -1,10 +1,10 @@
- "Nous n\'enregistrerons ni ne traiterons aucune donnée personnelle"
- "Partagez des données d\'utilisation anonymes pour nous aider à identifier les problèmes."
- "Consultez nos conditions d\'utilisation %1$s."
+ "Nous n’enregistrerons ni ne profilerons aucune donnée personnelle"
+ "Partagez des données d’utilisation anonymes pour nous aider à identifier les problèmes."
+ "Vous pouvez lire toutes nos conditions %1$s."
"ici"
- "Vous pouvez désactiver cette fonction à tout moment"
+ "Vous pouvez le désactiver à tout moment"
"Nous ne partagerons pas vos données avec des tiers"
- "Aidez-nous à améliorer %1$s"
+ "Aidez à améliorer %1$s"
diff --git a/features/analytics/impl/src/main/res/values-zh-rTW/translations.xml b/features/analytics/impl/src/main/res/values-zh-rTW/translations.xml
index 35a607c95e..81349674b7 100644
--- a/features/analytics/impl/src/main/res/values-zh-rTW/translations.xml
+++ b/features/analytics/impl/src/main/res/values-zh-rTW/translations.xml
@@ -1,5 +1,10 @@
+ "我們不會紀錄或剖繪您的個人資料"
+ "分享匿名的使用數據以協助我們釐清問題"
+ "您可以到 %1$s 閱讀我們的條款。"
+ "這裡"
"您可以在任何時候關閉它"
"我們不會和第三方分享您的資料"
+ "讓 %1$s 變得更好"
diff --git a/features/analytics/impl/src/test/kotlin/io/element/android/features/analytics/impl/AnalyticsOptInPresenterTest.kt b/features/analytics/impl/src/test/kotlin/io/element/android/features/analytics/impl/AnalyticsOptInPresenterTest.kt
index b9cb4d95ff..3dcb4674df 100644
--- a/features/analytics/impl/src/test/kotlin/io/element/android/features/analytics/impl/AnalyticsOptInPresenterTest.kt
+++ b/features/analytics/impl/src/test/kotlin/io/element/android/features/analytics/impl/AnalyticsOptInPresenterTest.kt
@@ -23,11 +23,17 @@ import com.google.common.truth.Truth.assertThat
import io.element.android.features.analytics.api.AnalyticsOptInEvents
import io.element.android.libraries.matrix.test.core.aBuildMeta
import io.element.android.services.analytics.test.FakeAnalyticsService
+import io.element.android.tests.testutils.WarmUpRule
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.runTest
+import org.junit.Rule
import org.junit.Test
class AnalyticsOptInPresenterTest {
+
+ @get:Rule
+ val warmUpRule = WarmUpRule()
+
@Test
fun `present - enable`() = runTest {
val analyticsService = FakeAnalyticsService(isEnabled = false)
diff --git a/features/analytics/impl/src/test/kotlin/io/element/android/features/analytics/impl/preferences/AnalyticsPreferencesPresenterTest.kt b/features/analytics/impl/src/test/kotlin/io/element/android/features/analytics/impl/preferences/AnalyticsPreferencesPresenterTest.kt
index 29c4579d8f..494468c530 100644
--- a/features/analytics/impl/src/test/kotlin/io/element/android/features/analytics/impl/preferences/AnalyticsPreferencesPresenterTest.kt
+++ b/features/analytics/impl/src/test/kotlin/io/element/android/features/analytics/impl/preferences/AnalyticsPreferencesPresenterTest.kt
@@ -23,10 +23,16 @@ import com.google.common.truth.Truth.assertThat
import io.element.android.features.analytics.api.AnalyticsOptInEvents
import io.element.android.libraries.matrix.test.core.aBuildMeta
import io.element.android.services.analytics.test.FakeAnalyticsService
+import io.element.android.tests.testutils.WarmUpRule
import kotlinx.coroutines.test.runTest
+import org.junit.Rule
import org.junit.Test
class AnalyticsPreferencesPresenterTest {
+
+ @get:Rule
+ val warmUpRule = WarmUpRule()
+
@Test
fun `present - initial state available`() = runTest {
val presenter = DefaultAnalyticsPreferencesPresenter(
diff --git a/features/call/build.gradle.kts b/features/call/build.gradle.kts
new file mode 100644
index 0000000000..69046e33b4
--- /dev/null
+++ b/features/call/build.gradle.kts
@@ -0,0 +1,37 @@
+/*
+ * Copyright (c) 2023 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+plugins {
+ id("io.element.android-compose-library")
+ alias(libs.plugins.anvil)
+ alias(libs.plugins.ksp)
+}
+
+android {
+ namespace = "io.element.android.features.call"
+}
+
+dependencies {
+ implementation(projects.libraries.architecture)
+ implementation(projects.libraries.designsystem)
+ implementation(projects.libraries.network)
+ implementation(libs.androidx.webkit)
+ ksp(libs.showkase.processor)
+
+ testImplementation(libs.test.junit)
+ testImplementation(libs.test.truth)
+ testImplementation(libs.test.robolectric)
+}
diff --git a/features/call/src/main/AndroidManifest.xml b/features/call/src/main/AndroidManifest.xml
new file mode 100644
index 0000000000..1aed77cd95
--- /dev/null
+++ b/features/call/src/main/AndroidManifest.xml
@@ -0,0 +1,61 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/features/call/src/main/kotlin/io/element/android/features/call/CallForegroundService.kt b/features/call/src/main/kotlin/io/element/android/features/call/CallForegroundService.kt
new file mode 100644
index 0000000000..12355290e3
--- /dev/null
+++ b/features/call/src/main/kotlin/io/element/android/features/call/CallForegroundService.kt
@@ -0,0 +1,89 @@
+/*
+ * Copyright (c) 2023 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.element.android.features.call
+
+import android.app.Service
+import android.content.Context
+import android.content.Intent
+import android.os.Build
+import android.os.IBinder
+import androidx.core.app.NotificationChannelCompat
+import androidx.core.app.NotificationCompat
+import androidx.core.app.NotificationManagerCompat
+import androidx.core.app.PendingIntentCompat
+import androidx.core.graphics.drawable.IconCompat
+import io.element.android.libraries.designsystem.utils.CommonDrawables
+
+class CallForegroundService : Service() {
+
+ companion object {
+ fun start(context: Context) {
+ val intent = Intent(context, CallForegroundService::class.java)
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+ context.startForegroundService(intent)
+ } else {
+ context.startService(intent)
+ }
+ }
+
+ fun stop(context: Context) {
+ val intent = Intent(context, CallForegroundService::class.java)
+ context.stopService(intent)
+ }
+ }
+
+ private lateinit var notificationManagerCompat: NotificationManagerCompat
+
+ override fun onCreate() {
+ super.onCreate()
+
+ notificationManagerCompat = NotificationManagerCompat.from(this)
+
+ val foregroundServiceChannel = NotificationChannelCompat.Builder(
+ "call_foreground_service_channel",
+ NotificationManagerCompat.IMPORTANCE_LOW,
+ ).setName(
+ getString(R.string.call_foreground_service_channel_title_android).ifEmpty { "Ongoing call" }
+ ).build()
+ notificationManagerCompat.createNotificationChannel(foregroundServiceChannel)
+
+ val callActivityIntent = Intent(this, ElementCallActivity::class.java)
+ val pendingIntent = PendingIntentCompat.getActivity(this, 0, callActivityIntent, 0, false)
+ val notification = NotificationCompat.Builder(this, foregroundServiceChannel.id)
+ .setSmallIcon(IconCompat.createWithResource(this, CommonDrawables.ic_notification_small))
+ .setContentTitle(getString(R.string.call_foreground_service_title_android))
+ .setContentText(getString(R.string.call_foreground_service_message_android))
+ .setContentIntent(pendingIntent)
+ .build()
+ startForeground(1, notification)
+ }
+
+ @Suppress("DEPRECATION")
+ override fun onDestroy() {
+ super.onDestroy()
+
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
+ stopForeground(STOP_FOREGROUND_REMOVE)
+ } else {
+ stopForeground(true)
+ }
+ }
+
+ override fun onBind(intent: Intent?): IBinder? {
+ return null
+ }
+}
diff --git a/features/call/src/main/kotlin/io/element/android/features/call/CallIntentDataParser.kt b/features/call/src/main/kotlin/io/element/android/features/call/CallIntentDataParser.kt
new file mode 100644
index 0000000000..a664e562f3
--- /dev/null
+++ b/features/call/src/main/kotlin/io/element/android/features/call/CallIntentDataParser.kt
@@ -0,0 +1,45 @@
+/*
+ * Copyright (c) 2023 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.element.android.features.call
+
+import android.net.Uri
+import java.net.URLDecoder
+
+object CallIntentDataParser {
+
+ private val validHttpSchemes = sequenceOf("http", "https")
+
+ fun parse(data: String?): String? {
+ val parsedUrl = data?.let { Uri.parse(data) } ?: return null
+ val scheme = parsedUrl.scheme
+ return when {
+ scheme in validHttpSchemes && parsedUrl.host == "call.element.io" -> data
+ scheme == "element" && parsedUrl.host == "call" -> {
+ // We use this custom scheme to load arbitrary URLs for other instances of Element Call,
+ // so we can only verify it's an HTTP/HTTPs URL with a non-empty host
+ parsedUrl.getQueryParameter("url")
+ ?.let { URLDecoder.decode(it, "utf-8") }
+ ?.takeIf {
+ val internalUri = Uri.parse(it)
+ internalUri.scheme in validHttpSchemes && !internalUri.host.isNullOrBlank()
+ }
+ }
+ // This should never be possible, but we still need to take into account the possibility
+ else -> null
+ }
+ }
+}
diff --git a/features/call/src/main/kotlin/io/element/android/features/call/CallScreenView.kt b/features/call/src/main/kotlin/io/element/android/features/call/CallScreenView.kt
new file mode 100644
index 0000000000..08ad687f91
--- /dev/null
+++ b/features/call/src/main/kotlin/io/element/android/features/call/CallScreenView.kt
@@ -0,0 +1,152 @@
+/*
+ * Copyright (c) 2023 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.element.android.features.call
+
+import android.annotation.SuppressLint
+import android.view.ViewGroup
+import android.webkit.PermissionRequest
+import android.webkit.WebChromeClient
+import android.webkit.WebView
+import androidx.compose.foundation.layout.consumeWindowInsets
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Close
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalInspectionMode
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.viewinterop.AndroidView
+import io.element.android.libraries.designsystem.components.button.BackButton
+import io.element.android.libraries.designsystem.preview.DayNightPreviews
+import io.element.android.libraries.designsystem.theme.components.Scaffold
+import io.element.android.libraries.designsystem.theme.components.Text
+import io.element.android.libraries.designsystem.theme.components.TopAppBar
+import io.element.android.libraries.theme.ElementTheme
+
+typealias RequestPermissionCallback = (Array) -> Unit
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+internal fun CallScreenView(
+ url: String?,
+ userAgent: String,
+ requestPermissions: (Array, RequestPermissionCallback) -> Unit,
+ onClose: () -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ ElementTheme {
+ Scaffold(
+ modifier = modifier,
+ topBar = {
+ TopAppBar(
+ title = { Text(stringResource(R.string.element_call)) },
+ navigationIcon = {
+ BackButton(
+ imageVector = Icons.Default.Close,
+ onClick = onClose
+ )
+ }
+ )
+ }
+ ) { padding ->
+ CallWebView(
+ modifier = Modifier
+ .padding(padding)
+ .consumeWindowInsets(padding)
+ .fillMaxSize(),
+ url = url,
+ userAgent = userAgent,
+ onPermissionsRequested = { request ->
+ val androidPermissions = mapWebkitPermissions(request.resources)
+ val callback: RequestPermissionCallback = { request.grant(it) }
+ requestPermissions(androidPermissions.toTypedArray(), callback)
+ }
+ )
+ }
+ }
+}
+
+@Composable
+private fun CallWebView(
+ url: String?,
+ userAgent: String,
+ onPermissionsRequested: (PermissionRequest) -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ val isInpectionMode = LocalInspectionMode.current
+ AndroidView(
+ modifier = modifier,
+ factory = { context ->
+ WebView(context).apply {
+ if (!isInpectionMode) {
+ setup(userAgent, onPermissionsRequested)
+ if (url != null) {
+ loadUrl(url)
+ }
+ }
+ }
+ },
+ update = { webView ->
+ if (!isInpectionMode && url != null) {
+ webView.loadUrl(url)
+ }
+ },
+ onRelease = { webView ->
+ webView.destroy()
+ }
+ )
+}
+
+@SuppressLint("SetJavaScriptEnabled")
+private fun WebView.setup(userAgent: String, onPermissionsRequested: (PermissionRequest) -> Unit) {
+ layoutParams = ViewGroup.LayoutParams(
+ ViewGroup.LayoutParams.MATCH_PARENT,
+ ViewGroup.LayoutParams.MATCH_PARENT
+ )
+
+ with(settings) {
+ javaScriptEnabled = true
+ allowContentAccess = true
+ allowFileAccess = true
+ domStorageEnabled = true
+ mediaPlaybackRequiresUserGesture = false
+ databaseEnabled = true
+ loadsImagesAutomatically = true
+ userAgentString = userAgent
+ }
+
+ webChromeClient = object : WebChromeClient() {
+ override fun onPermissionRequest(request: PermissionRequest) {
+ onPermissionsRequested(request)
+ }
+ }
+}
+
+@DayNightPreviews
+@Composable
+internal fun CallScreenViewPreview() {
+ ElementTheme {
+ CallScreenView(
+ url = "https://call.element.io/some-actual-call?with=parameters",
+ userAgent = "",
+ requestPermissions = { _, _ -> },
+ onClose = { },
+ )
+ }
+}
diff --git a/features/call/src/main/kotlin/io/element/android/features/call/ElementCallActivity.kt b/features/call/src/main/kotlin/io/element/android/features/call/ElementCallActivity.kt
new file mode 100644
index 0000000000..69ef3963cb
--- /dev/null
+++ b/features/call/src/main/kotlin/io/element/android/features/call/ElementCallActivity.kt
@@ -0,0 +1,208 @@
+/*
+ * Copyright (c) 2023 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.element.android.features.call
+
+import android.Manifest
+import android.content.Intent
+import android.content.res.Configuration
+import android.media.AudioAttributes
+import android.media.AudioFocusRequest
+import android.media.AudioManager
+import android.os.Build
+import android.os.Bundle
+import android.view.WindowManager
+import android.webkit.PermissionRequest
+import androidx.activity.ComponentActivity
+import androidx.activity.compose.setContent
+import androidx.activity.result.ActivityResultLauncher
+import androidx.activity.result.contract.ActivityResultContracts
+import androidx.compose.runtime.mutableStateOf
+import io.element.android.features.call.di.CallBindings
+import io.element.android.libraries.architecture.bindings
+import io.element.android.libraries.network.useragent.UserAgentProvider
+import javax.inject.Inject
+
+class ElementCallActivity : ComponentActivity() {
+
+ @Inject lateinit var userAgentProvider: UserAgentProvider
+
+ private lateinit var audioManager: AudioManager
+
+ private var requestPermissionCallback: RequestPermissionCallback? = null
+
+ private var audiofocusRequest: AudioFocusRequest? = null
+ private var audioFocusChangeListener: AudioManager.OnAudioFocusChangeListener? = null
+
+ private val requestPermissionsLauncher = registerPermissionResultLauncher()
+
+ private var isDarkMode = false
+ private val urlState = mutableStateOf(null)
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+
+ applicationContext.bindings().inject(this)
+
+ window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
+
+ urlState.value = intent?.dataString?.let(::parseUrl) ?: run {
+ finish()
+ return
+ }
+
+ if (savedInstanceState == null) {
+ updateUiMode(resources.configuration)
+ }
+
+ audioManager = getSystemService(AUDIO_SERVICE) as AudioManager
+ requestAudioFocus()
+
+ val userAgent = userAgentProvider.provide()
+
+ setContent {
+ CallScreenView(
+ url = urlState.value,
+ userAgent = userAgent,
+ onClose = this::finish,
+ requestPermissions = { permissions, callback ->
+ requestPermissionCallback = callback
+ requestPermissionsLauncher.launch(permissions)
+ }
+ )
+ }
+ }
+
+ override fun onConfigurationChanged(newConfig: Configuration) {
+ super.onConfigurationChanged(newConfig)
+
+ updateUiMode(newConfig)
+ }
+
+ override fun onNewIntent(intent: Intent?) {
+ super.onNewIntent(intent)
+
+ val intentUrl = intent?.dataString?.let(::parseUrl)
+ when {
+ // New URL, update it and reload the webview
+ intentUrl != null -> urlState.value = intentUrl
+ // Re-opened the activity but we have no url to load or a cached one, finish the activity
+ intent?.dataString == null && urlState.value == null -> finish()
+ // Coming back from notification, do nothing
+ else -> return
+ }
+ }
+
+ override fun onStart() {
+ super.onStart()
+ CallForegroundService.stop(this)
+ }
+
+ override fun onStop() {
+ super.onStop()
+ if (!isFinishing && !isChangingConfigurations) {
+ CallForegroundService.start(this)
+ }
+ }
+
+ override fun onDestroy() {
+ super.onDestroy()
+ releaseAudioFocus()
+ CallForegroundService.stop(this)
+ }
+
+ override fun finish() {
+ // Also remove the task from recents
+ finishAndRemoveTask()
+ }
+
+ private fun parseUrl(url: String?): String? = CallIntentDataParser.parse(url)
+
+ private fun registerPermissionResultLauncher(): ActivityResultLauncher> {
+ return registerForActivityResult(
+ ActivityResultContracts.RequestMultiplePermissions()
+ ) { permissions ->
+ val callback = requestPermissionCallback ?: return@registerForActivityResult
+ val permissionsToGrant = mutableListOf()
+ permissions.forEach { (permission, granted) ->
+ if (granted) {
+ val webKitPermission = when (permission) {
+ Manifest.permission.CAMERA -> PermissionRequest.RESOURCE_VIDEO_CAPTURE
+ Manifest.permission.RECORD_AUDIO -> PermissionRequest.RESOURCE_AUDIO_CAPTURE
+ else -> return@forEach
+ }
+ permissionsToGrant.add(webKitPermission)
+ }
+ }
+ callback(permissionsToGrant.toTypedArray())
+ }
+ }
+
+ @Suppress("DEPRECATION")
+ private fun requestAudioFocus() {
+ val audioAttributes = AudioAttributes.Builder()
+ .setUsage(AudioAttributes.USAGE_VOICE_COMMUNICATION)
+ .build()
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+ val request = AudioFocusRequest.Builder(AudioManager.AUDIOFOCUS_GAIN)
+ .setAudioAttributes(audioAttributes)
+ .build()
+ audioManager.requestAudioFocus(request)
+ audiofocusRequest = request
+ } else {
+ val listener = AudioManager.OnAudioFocusChangeListener { }
+ audioManager.requestAudioFocus(
+ listener,
+ AudioManager.STREAM_VOICE_CALL,
+ AudioManager.AUDIOFOCUS_GAIN_TRANSIENT_EXCLUSIVE,
+ )
+
+ audioFocusChangeListener = listener
+ }
+ }
+
+ @Suppress("DEPRECATION")
+ private fun releaseAudioFocus() {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+ audiofocusRequest?.let { audioManager.abandonAudioFocusRequest(it) }
+ } else {
+ audioFocusChangeListener?.let { audioManager.abandonAudioFocus(it) }
+ }
+ }
+
+ private fun updateUiMode(configuration: Configuration) {
+ val prevDarkMode = isDarkMode
+ val currentNightMode = configuration.uiMode and Configuration.UI_MODE_NIGHT_YES
+ isDarkMode = currentNightMode != 0
+ if (prevDarkMode != isDarkMode) {
+ if (isDarkMode) {
+ window.setBackgroundDrawableResource(android.R.drawable.screen_background_dark)
+ } else {
+ window.setBackgroundDrawableResource(android.R.drawable.screen_background_light)
+ }
+ }
+ }
+}
+
+internal fun mapWebkitPermissions(permissions: Array): List {
+ return permissions.mapNotNull { permission ->
+ when (permission) {
+ PermissionRequest.RESOURCE_AUDIO_CAPTURE -> Manifest.permission.RECORD_AUDIO
+ PermissionRequest.RESOURCE_VIDEO_CAPTURE -> Manifest.permission.CAMERA
+ else -> null
+ }
+ }
+}
diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/sync/StartSyncReason.kt b/features/call/src/main/kotlin/io/element/android/features/call/di/CallBindings.kt
similarity index 63%
rename from libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/sync/StartSyncReason.kt
rename to features/call/src/main/kotlin/io/element/android/features/call/di/CallBindings.kt
index 4a6b44949e..1e261cc225 100644
--- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/sync/StartSyncReason.kt
+++ b/features/call/src/main/kotlin/io/element/android/features/call/di/CallBindings.kt
@@ -14,12 +14,13 @@
* limitations under the License.
*/
-package io.element.android.libraries.matrix.api.sync
+package io.element.android.features.call.di
-import io.element.android.libraries.matrix.api.core.EventId
-import io.element.android.libraries.matrix.api.core.RoomId
+import com.squareup.anvil.annotations.ContributesTo
+import io.element.android.features.call.ElementCallActivity
+import io.element.android.libraries.di.AppScope
-sealed interface StartSyncReason {
- data object AppInForeground : StartSyncReason
- data class Notification(val roomId: RoomId, val eventId: EventId) : StartSyncReason
+@ContributesTo(AppScope::class)
+interface CallBindings {
+ fun inject(callActivity: ElementCallActivity)
}
diff --git a/features/call/src/main/res/values-de/translations.xml b/features/call/src/main/res/values-de/translations.xml
new file mode 100644
index 0000000000..d58a616780
--- /dev/null
+++ b/features/call/src/main/res/values-de/translations.xml
@@ -0,0 +1,6 @@
+
+
+ "Laufender Anruf"
+ "Tippen, um zum Anruf zurückzukehren"
+ "☎️ Anruf läuft"
+
diff --git a/features/call/src/main/res/values-fr/translations.xml b/features/call/src/main/res/values-fr/translations.xml
new file mode 100644
index 0000000000..eecf771031
--- /dev/null
+++ b/features/call/src/main/res/values-fr/translations.xml
@@ -0,0 +1,6 @@
+
+
+ "Appel en cours"
+ "Cliquez pour retourner à l’appel."
+ "☎️ Appel en cours"
+
diff --git a/features/call/src/main/res/values-sk/translations.xml b/features/call/src/main/res/values-sk/translations.xml
new file mode 100644
index 0000000000..ec7d693f1d
--- /dev/null
+++ b/features/call/src/main/res/values-sk/translations.xml
@@ -0,0 +1,6 @@
+
+
+ "Prebiehajúci hovor"
+ "Ťuknutím sa vrátite k hovoru"
+ "☎️ Prebieha hovor"
+
diff --git a/features/call/src/main/res/values/do_not_translate.xml b/features/call/src/main/res/values/do_not_translate.xml
new file mode 100644
index 0000000000..c1fe10cdfb
--- /dev/null
+++ b/features/call/src/main/res/values/do_not_translate.xml
@@ -0,0 +1,20 @@
+
+
+
+
+ Element Call
+
diff --git a/features/call/src/main/res/values/localazy.xml b/features/call/src/main/res/values/localazy.xml
new file mode 100644
index 0000000000..cfe40526f4
--- /dev/null
+++ b/features/call/src/main/res/values/localazy.xml
@@ -0,0 +1,6 @@
+
+
+ "Ongoing call"
+ "Tap to return to the call"
+ "☎️ Call in progress"
+
diff --git a/features/call/src/test/kotlin/io/element/android/features/call/CallIntentDataParserTests.kt b/features/call/src/test/kotlin/io/element/android/features/call/CallIntentDataParserTests.kt
new file mode 100644
index 0000000000..da41692b40
--- /dev/null
+++ b/features/call/src/test/kotlin/io/element/android/features/call/CallIntentDataParserTests.kt
@@ -0,0 +1,105 @@
+/*
+ * Copyright (c) 2023 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.element.android.features.call
+
+import com.google.common.truth.Truth.assertThat
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.robolectric.RobolectricTestRunner
+import java.net.URLEncoder
+
+@RunWith(RobolectricTestRunner::class)
+class CallIntentDataParserTests {
+
+ @Test
+ fun `a null data returns null`() {
+ val url: String? = null
+ assertThat(CallIntentDataParser.parse(url)).isNull()
+ }
+
+ @Test
+ fun `empty data returns null`() {
+ val url = ""
+ assertThat(CallIntentDataParser.parse(url)).isNull()
+ }
+
+ @Test
+ fun `invalid data returns null`() {
+ val url = "!"
+ assertThat(CallIntentDataParser.parse(url)).isNull()
+ }
+
+ @Test
+ fun `data with no scheme returns null`() {
+ val url = "test"
+ assertThat(CallIntentDataParser.parse(url)).isNull()
+ }
+
+ @Test
+ fun `Element Call urls will be returned as is`() {
+ val httpBaseUrl = "http://call.element.io"
+ val httpCallUrl = "http://call.element.io/some-actual-call?with=parameters"
+ val httpsBaseUrl = "https://call.element.io"
+ val httpsCallUrl = "https://call.element.io/some-actual-call?with=parameters"
+ assertThat(CallIntentDataParser.parse(httpBaseUrl)).isEqualTo(httpBaseUrl)
+ assertThat(CallIntentDataParser.parse(httpCallUrl)).isEqualTo(httpCallUrl)
+ assertThat(CallIntentDataParser.parse(httpsBaseUrl)).isEqualTo(httpsBaseUrl)
+ assertThat(CallIntentDataParser.parse(httpsCallUrl)).isEqualTo(httpsCallUrl)
+ }
+
+ @Test
+ fun `HTTP and HTTPS urls that don't come from EC return null`() {
+ val httpBaseUrl = "http://app.element.io"
+ val httpsBaseUrl = "https://app.element.io"
+ val httpInvalidUrl = "http://"
+ val httpsInvalidUrl = "http://"
+ assertThat(CallIntentDataParser.parse(httpBaseUrl)).isNull()
+ assertThat(CallIntentDataParser.parse(httpsBaseUrl)).isNull()
+ assertThat(CallIntentDataParser.parse(httpInvalidUrl)).isNull()
+ assertThat(CallIntentDataParser.parse(httpsInvalidUrl)).isNull()
+ }
+
+ @Test
+ fun `element scheme with call host and url param gets url extracted`() {
+ val embeddedUrl = "http://call.element.io/some-actual-call?with=parameters"
+ val encodedUrl = URLEncoder.encode(embeddedUrl, "utf-8")
+ val url = "element://call?url=$encodedUrl"
+ assertThat(CallIntentDataParser.parse(url)).isEqualTo(embeddedUrl)
+ }
+
+ @Test
+ fun `element scheme with call host and no url param returns null`() {
+ val embeddedUrl = "http://call.element.io/some-actual-call?with=parameters"
+ val encodedUrl = URLEncoder.encode(embeddedUrl, "utf-8")
+ val url = "element://call?no-url=$encodedUrl"
+ assertThat(CallIntentDataParser.parse(url)).isNull()
+ }
+
+ @Test
+ fun `element scheme with no call host returns null`() {
+ val embeddedUrl = "http://call.element.io/some-actual-call?with=parameters"
+ val encodedUrl = URLEncoder.encode(embeddedUrl, "utf-8")
+ val url = "element://no-call?url=$encodedUrl"
+ assertThat(CallIntentDataParser.parse(url)).isNull()
+ }
+
+ @Test
+ fun `element scheme with no data returns null`() {
+ val url = "element://call?url="
+ assertThat(CallIntentDataParser.parse(url)).isNull()
+ }
+}
diff --git a/features/call/src/test/kotlin/io/element/android/features/call/MapWebkitPermissionsTest.kt b/features/call/src/test/kotlin/io/element/android/features/call/MapWebkitPermissionsTest.kt
new file mode 100644
index 0000000000..f82e31c068
--- /dev/null
+++ b/features/call/src/test/kotlin/io/element/android/features/call/MapWebkitPermissionsTest.kt
@@ -0,0 +1,44 @@
+/*
+ * Copyright (c) 2023 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.element.android.features.call
+
+import android.Manifest
+import android.webkit.PermissionRequest
+import com.google.common.truth.Truth.assertThat
+import org.junit.Test
+
+class MapWebkitPermissionsTest {
+
+ @Test
+ fun `given Webkit's RESOURCE_AUDIO_CAPTURE returns Android's RECORD_AUDIO permission`() {
+ val permission = mapWebkitPermissions(arrayOf(PermissionRequest.RESOURCE_AUDIO_CAPTURE))
+ assertThat(permission).isEqualTo(listOf(Manifest.permission.RECORD_AUDIO))
+ }
+
+ @Test
+ fun `given Webkit's RESOURCE_VIDEO_CAPTURE returns Android's CAMERA permission`() {
+ val permission = mapWebkitPermissions(arrayOf(PermissionRequest.RESOURCE_VIDEO_CAPTURE))
+ assertThat(permission).isEqualTo(listOf(Manifest.permission.CAMERA))
+ }
+
+ @Test
+ fun `given any other permission, it returns nothing`() {
+ val permission = mapWebkitPermissions(arrayOf(PermissionRequest.RESOURCE_PROTECTED_MEDIA_ID))
+ assertThat(permission).isEqualTo(emptyList())
+ }
+
+}
diff --git a/features/createroom/impl/build.gradle.kts b/features/createroom/impl/build.gradle.kts
index 0ef46a57ec..3ea54ca4ea 100644
--- a/features/createroom/impl/build.gradle.kts
+++ b/features/createroom/impl/build.gradle.kts
@@ -65,6 +65,7 @@ dependencies {
testImplementation(projects.libraries.mediapickers.test)
testImplementation(projects.libraries.mediaupload.test)
testImplementation(projects.libraries.usersearch.test)
+ testImplementation(projects.tests.testutils)
ksp(libs.showkase.processor)
}
diff --git a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/addpeople/AddPeopleUserListStateProvider.kt b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/addpeople/AddPeopleUserListStateProvider.kt
index 48ad56caf4..79969eeac7 100644
--- a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/addpeople/AddPeopleUserListStateProvider.kt
+++ b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/addpeople/AddPeopleUserListStateProvider.kt
@@ -23,6 +23,7 @@ import io.element.android.features.createroom.impl.userlist.aListOfSelectedUsers
import io.element.android.features.createroom.impl.userlist.aUserListState
import io.element.android.libraries.designsystem.theme.components.SearchBarResultState
import io.element.android.libraries.matrix.ui.components.aMatrixUserList
+import io.element.android.libraries.usersearch.api.UserSearchResult
import kotlinx.collections.immutable.toImmutableList
open class AddPeopleUserListStateProvider : PreviewParameterProvider {
@@ -36,7 +37,11 @@ open class AddPeopleUserListStateProvider : PreviewParameterProvider
+ UserSearchResult(matrixUser, index % 2 == 0)
+ }
+ .toImmutableList()),
selectedUsers = aListOfSelectedUsers(),
isSearchActive = true,
selectionMode = SelectionMode.Multiple,
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 abc2ef9d71..1a027c9d2d 100644
--- a/features/createroom/impl/src/main/res/values-de/translations.xml
+++ b/features/createroom/impl/src/main/res/values-de/translations.xml
@@ -2,12 +2,12 @@
"Neuer Raum"
"Freunde zu Element einladen"
- "Personen hinzufügen"
+ "Personen einladen"
"Beim Erstellen des Raums ist ein Fehler aufgetreten"
"Die Nachrichten in diesem Raum sind verschlüsselt. Die Verschlüsselung kann nicht nachträglich deaktiviert werden."
"Privater Raum (nur auf Einladung)"
- "Nachrichten sind nicht verschlüsselt und jeder kann sie lesen. Du kannst die Verschlüsselung zu einem späteren Zeitpunkt aktivieren."
- "Öffentlicher Raum (jeder)"
+ "Die Nachrichten sind nicht verschlüsselt und können von jedem gelesen werden. Die Verschlüsselung kann zu einem späteren Zeitpunkt aktiviert werden."
+ "Öffentlicher Raum (für alle)"
"Raumname"
"Thema (optional)"
"Beim Versuch, einen Chat zu starten, ist ein Fehler aufgetreten"
diff --git a/features/createroom/impl/src/main/res/values-fr/translations.xml b/features/createroom/impl/src/main/res/values-fr/translations.xml
index e37a30c457..e060b99ed8 100644
--- a/features/createroom/impl/src/main/res/values-fr/translations.xml
+++ b/features/createroom/impl/src/main/res/values-fr/translations.xml
@@ -3,13 +3,13 @@
"Nouveau salon"
"Inviter des amis sur Element"
"Inviter des personnes"
- "Une erreur s\'est produite lors de la création du salon"
- "Les messages dans ce salon sont chiffrés. Une fois activé, le chiffrement ne peut pas être désactivé."
- "Salon privé (sur invitation uniquement)"
- "Les messages ne sont pas chiffrés et n\'importe qui peut les lire. Vous pouvez activer le chiffrement ultérieurement."
- "Salon public (n’importe qui)"
+ "Une erreur s’est produite lors de la création du salon"
+ "Les messages dans ce salon sont chiffrés. Le chiffrement ne pourra pas être désactivé par la suite."
+ "Salon privé (sur invitation seulement)"
+ "Les messages ne sont pas chiffrés et n’importe qui peut les lire. Vous pouvez activer le chiffrement ultérieurement."
+ "Salon public (tout le monde)"
"Nom du salon"
- "Sujet (optionnel)"
- "Une erreur s\'est produite lors de la tentative de démarrage d\'une discussion"
+ "Sujet (facultatif)"
+ "Une erreur s’est produite lors de la tentative de création de la discussion"
"Créer un salon"
diff --git a/features/createroom/impl/src/test/kotlin/io/element/android/features/createroom/impl/addpeople/AddPeoplePresenterTests.kt b/features/createroom/impl/src/test/kotlin/io/element/android/features/createroom/impl/addpeople/AddPeoplePresenterTests.kt
index 43cf6cff4d..968b2a1b57 100644
--- a/features/createroom/impl/src/test/kotlin/io/element/android/features/createroom/impl/addpeople/AddPeoplePresenterTests.kt
+++ b/features/createroom/impl/src/test/kotlin/io/element/android/features/createroom/impl/addpeople/AddPeoplePresenterTests.kt
@@ -24,12 +24,17 @@ import io.element.android.features.createroom.impl.CreateRoomDataStore
import io.element.android.features.createroom.impl.userlist.FakeUserListPresenterFactory
import io.element.android.features.createroom.impl.userlist.UserListDataStore
import io.element.android.libraries.usersearch.test.FakeUserRepository
+import io.element.android.tests.testutils.WarmUpRule
import kotlinx.coroutines.test.runTest
import org.junit.Before
+import org.junit.Rule
import org.junit.Test
class AddPeoplePresenterTests {
+ @get:Rule
+ val warmUpRule = WarmUpRule()
+
private lateinit var presenter: AddPeoplePresenter
@Before
diff --git a/features/createroom/impl/src/test/kotlin/io/element/android/features/createroom/impl/configureroom/ConfigureRoomPresenterTests.kt b/features/createroom/impl/src/test/kotlin/io/element/android/features/createroom/impl/configureroom/ConfigureRoomPresenterTests.kt
index 864d85423d..a330384f60 100644
--- a/features/createroom/impl/src/test/kotlin/io/element/android/features/createroom/impl/configureroom/ConfigureRoomPresenterTests.kt
+++ b/features/createroom/impl/src/test/kotlin/io/element/android/features/createroom/impl/configureroom/ConfigureRoomPresenterTests.kt
@@ -38,6 +38,7 @@ import io.element.android.libraries.mediapickers.test.FakePickerProvider
import io.element.android.libraries.mediaupload.api.MediaUploadInfo
import io.element.android.libraries.mediaupload.test.FakeMediaPreProcessor
import io.element.android.services.analytics.test.FakeAnalyticsService
+import io.element.android.tests.testutils.WarmUpRule
import io.mockk.every
import io.mockk.mockk
import io.mockk.mockkStatic
@@ -47,6 +48,7 @@ import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.test.runTest
import org.junit.After
import org.junit.Before
+import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
@@ -58,6 +60,9 @@ private const val AN_URI_FROM_GALLERY = "content://uri_from_gallery"
@RunWith(RobolectricTestRunner::class)
class ConfigureRoomPresenterTests {
+ @get:Rule
+ val warmUpRule = WarmUpRule()
+
private lateinit var presenter: ConfigureRoomPresenter
private lateinit var userListDataStore: UserListDataStore
private lateinit var createRoomDataStore: CreateRoomDataStore
diff --git a/features/createroom/impl/src/test/kotlin/io/element/android/features/createroom/impl/root/CreateRoomRootPresenterTests.kt b/features/createroom/impl/src/test/kotlin/io/element/android/features/createroom/impl/root/CreateRoomRootPresenterTests.kt
index 0f88280c84..bf4593bdbb 100644
--- a/features/createroom/impl/src/test/kotlin/io/element/android/features/createroom/impl/root/CreateRoomRootPresenterTests.kt
+++ b/features/createroom/impl/src/test/kotlin/io/element/android/features/createroom/impl/root/CreateRoomRootPresenterTests.kt
@@ -35,13 +35,18 @@ import io.element.android.libraries.matrix.test.core.aBuildMeta
import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
import io.element.android.libraries.usersearch.test.FakeUserRepository
import io.element.android.services.analytics.test.FakeAnalyticsService
+import io.element.android.tests.testutils.WarmUpRule
import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.test.runTest
import org.junit.Before
+import org.junit.Rule
import org.junit.Test
class CreateRoomRootPresenterTests {
+ @get:Rule
+ val warmUpRule = WarmUpRule()
+
private lateinit var userRepository: FakeUserRepository
private lateinit var presenter: CreateRoomRootPresenter
private lateinit var fakeUserListPresenter: FakeUserListPresenter
diff --git a/features/createroom/impl/src/test/kotlin/io/element/android/features/createroom/impl/userlist/DefaultUserListPresenterTests.kt b/features/createroom/impl/src/test/kotlin/io/element/android/features/createroom/impl/userlist/DefaultUserListPresenterTests.kt
index 3561387dcf..a591a7a8e9 100644
--- a/features/createroom/impl/src/test/kotlin/io/element/android/features/createroom/impl/userlist/DefaultUserListPresenterTests.kt
+++ b/features/createroom/impl/src/test/kotlin/io/element/android/features/createroom/impl/userlist/DefaultUserListPresenterTests.kt
@@ -25,12 +25,17 @@ import io.element.android.libraries.matrix.ui.components.aMatrixUser
import io.element.android.libraries.matrix.ui.components.aMatrixUserList
import io.element.android.libraries.usersearch.api.UserSearchResult
import io.element.android.libraries.usersearch.test.FakeUserRepository
+import io.element.android.tests.testutils.WarmUpRule
import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.test.runTest
+import org.junit.Rule
import org.junit.Test
class DefaultUserListPresenterTests {
+ @get:Rule
+ val warmUpRule = WarmUpRule()
+
private val userRepository = FakeUserRepository()
@Test
diff --git a/features/ftue/impl/build.gradle.kts b/features/ftue/impl/build.gradle.kts
index 8b12767b20..42fe8dade5 100644
--- a/features/ftue/impl/build.gradle.kts
+++ b/features/ftue/impl/build.gradle.kts
@@ -43,6 +43,10 @@ dependencies {
implementation(projects.libraries.testtags)
implementation(projects.features.analytics.api)
implementation(projects.services.analytics.api)
+ implementation(projects.libraries.permissions.api)
+ implementation(projects.libraries.permissions.noop)
+ implementation(projects.services.toolbox.api)
+ implementation(projects.services.toolbox.test)
testImplementation(libs.test.junit)
testImplementation(libs.coroutines.test)
@@ -51,6 +55,9 @@ dependencies {
testImplementation(libs.test.turbine)
testImplementation(projects.libraries.matrix.test)
testImplementation(projects.services.analytics.test)
+ testImplementation(projects.libraries.permissions.impl)
+ testImplementation(projects.libraries.permissions.test)
+ testImplementation(projects.tests.testutils)
ksp(libs.showkase.processor)
}
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 2b515c18a6..ab6bf94a69 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
@@ -35,6 +35,7 @@ import io.element.android.anvilannotations.ContributesNode
import io.element.android.features.analytics.api.AnalyticsEntryPoint
import io.element.android.features.ftue.api.FtueEntryPoint
import io.element.android.features.ftue.impl.migration.MigrationScreenNode
+import io.element.android.features.ftue.impl.notifications.NotificationsOptInNode
import io.element.android.features.ftue.impl.state.DefaultFtueState
import io.element.android.features.ftue.impl.state.FtueStep
import io.element.android.features.ftue.impl.welcome.WelcomeNode
@@ -79,6 +80,9 @@ class FtueFlowNode @AssistedInject constructor(
@Parcelize
data object WelcomeScreen : NavTarget
+ @Parcelize
+ data object NotificationsOptIn : NavTarget
+
@Parcelize
data object AnalyticsOptIn : NavTarget
}
@@ -124,6 +128,14 @@ class FtueFlowNode @AssistedInject constructor(
}
createNode(buildContext, listOf(callback))
}
+ NavTarget.NotificationsOptIn -> {
+ val callback = object : NotificationsOptInNode.Callback {
+ override fun onNotificationsOptInFinished() {
+ lifecycleScope.launch { moveToNextStep() }
+ }
+ }
+ createNode(buildContext, listOf(callback))
+ }
NavTarget.AnalyticsOptIn -> {
analyticsEntryPoint.createNode(this, buildContext)
}
@@ -138,6 +150,9 @@ class FtueFlowNode @AssistedInject constructor(
FtueStep.WelcomeScreen -> {
backstack.newRoot(NavTarget.WelcomeScreen)
}
+ FtueStep.NotificationsOptIn -> {
+ backstack.newRoot(NavTarget.NotificationsOptIn)
+ }
FtueStep.AnalyticsOptIn -> {
backstack.replace(NavTarget.AnalyticsOptIn)
}
diff --git a/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/notifications/NotificationsOptInEvents.kt b/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/notifications/NotificationsOptInEvents.kt
new file mode 100644
index 0000000000..55b6748c72
--- /dev/null
+++ b/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/notifications/NotificationsOptInEvents.kt
@@ -0,0 +1,22 @@
+/*
+ * Copyright (c) 2023 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.element.android.features.ftue.impl.notifications
+
+sealed interface NotificationsOptInEvents {
+ data object ContinueClicked : NotificationsOptInEvents
+ data object NotNowClicked : NotificationsOptInEvents
+}
diff --git a/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/notifications/NotificationsOptInNode.kt b/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/notifications/NotificationsOptInNode.kt
new file mode 100644
index 0000000000..00fbb10b1f
--- /dev/null
+++ b/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/notifications/NotificationsOptInNode.kt
@@ -0,0 +1,57 @@
+/*
+ * Copyright (c) 2023 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.element.android.features.ftue.impl.notifications
+
+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 dagger.assisted.Assisted
+import dagger.assisted.AssistedInject
+import io.element.android.anvilannotations.ContributesNode
+import io.element.android.libraries.architecture.NodeInputs
+import io.element.android.libraries.architecture.inputs
+import io.element.android.libraries.di.AppScope
+
+@ContributesNode(AppScope::class)
+class NotificationsOptInNode @AssistedInject constructor(
+ @Assisted buildContext: BuildContext,
+ @Assisted plugins: List,
+ private val presenterFactory: NotificationsOptInPresenter.Factory,
+) : Node(buildContext, plugins = plugins) {
+
+ interface Callback: NodeInputs {
+ fun onNotificationsOptInFinished()
+ }
+
+ private val callback = inputs()
+
+ private val presenter: NotificationsOptInPresenter by lazy {
+ presenterFactory.create(callback)
+ }
+
+ @Composable
+ override fun View(modifier: Modifier) {
+ val state = presenter.present()
+ NotificationsOptInView(
+ state = state,
+ onBack = { callback.onNotificationsOptInFinished() },
+ modifier = modifier
+ )
+ }
+}
diff --git a/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/notifications/NotificationsOptInPresenter.kt b/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/notifications/NotificationsOptInPresenter.kt
new file mode 100644
index 0000000000..f3bffca590
--- /dev/null
+++ b/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/notifications/NotificationsOptInPresenter.kt
@@ -0,0 +1,97 @@
+/*
+ * Copyright (c) 2023 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.element.android.features.ftue.impl.notifications
+
+import android.Manifest
+import android.os.Build
+import androidx.annotation.RequiresApi
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import dagger.assisted.Assisted
+import dagger.assisted.AssistedFactory
+import dagger.assisted.AssistedInject
+import io.element.android.libraries.architecture.Presenter
+import io.element.android.libraries.permissions.api.PermissionStateProvider
+import io.element.android.libraries.permissions.api.PermissionsEvents
+import io.element.android.libraries.permissions.api.PermissionsPresenter
+import io.element.android.libraries.permissions.noop.NoopPermissionsPresenter
+import io.element.android.services.toolbox.api.sdk.BuildVersionSdkIntProvider
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.launch
+
+class NotificationsOptInPresenter @AssistedInject constructor(
+ private val permissionsPresenterFactory: PermissionsPresenter.Factory,
+ @Assisted private val callback: NotificationsOptInNode.Callback,
+ private val appCoroutineScope: CoroutineScope,
+ private val permissionStateProvider: PermissionStateProvider,
+ private val buildVersionSdkIntProvider: BuildVersionSdkIntProvider,
+) : Presenter {
+
+ @AssistedFactory
+ interface Factory {
+ fun create(callback: NotificationsOptInNode.Callback): NotificationsOptInPresenter
+ }
+
+ private val postNotificationPermissionsPresenter by lazy {
+ // Ask for POST_NOTIFICATION PERMISSION on Android 13+
+ if (buildVersionSdkIntProvider.isAtLeast(Build.VERSION_CODES.TIRAMISU)) {
+ permissionsPresenterFactory.create(Manifest.permission.POST_NOTIFICATIONS)
+ } else {
+ NoopPermissionsPresenter()
+ }
+ }
+
+ @Composable
+ override fun present(): NotificationsOptInState {
+ val notificationsPermissionsState = postNotificationPermissionsPresenter.present()
+
+ fun handleEvents(event: NotificationsOptInEvents) {
+ when (event) {
+ NotificationsOptInEvents.ContinueClicked -> {
+ if (notificationsPermissionsState.permissionGranted) {
+ callback.onNotificationsOptInFinished()
+ } else {
+ notificationsPermissionsState.eventSink(PermissionsEvents.OpenSystemDialog)
+ }
+ }
+ NotificationsOptInEvents.NotNowClicked -> {
+ if (buildVersionSdkIntProvider.isAtLeast(Build.VERSION_CODES.TIRAMISU)) {
+ appCoroutineScope.setPermissionDenied()
+ }
+ callback.onNotificationsOptInFinished()
+ }
+ }
+ }
+
+ LaunchedEffect(notificationsPermissionsState) {
+ if (notificationsPermissionsState.permissionGranted
+ || notificationsPermissionsState.permissionAlreadyDenied) {
+ callback.onNotificationsOptInFinished()
+ }
+ }
+
+ return NotificationsOptInState(
+ notificationsPermissionState = notificationsPermissionsState,
+ eventSink = ::handleEvents
+ )
+ }
+
+ @RequiresApi(Build.VERSION_CODES.TIRAMISU)
+ private fun CoroutineScope.setPermissionDenied() = launch {
+ permissionStateProvider.setPermissionDenied(Manifest.permission.POST_NOTIFICATIONS, true)
+ }
+}
diff --git a/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/notifications/NotificationsOptInState.kt b/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/notifications/NotificationsOptInState.kt
new file mode 100644
index 0000000000..a64fb7ad4a
--- /dev/null
+++ b/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/notifications/NotificationsOptInState.kt
@@ -0,0 +1,24 @@
+/*
+ * Copyright (c) 2023 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.element.android.features.ftue.impl.notifications
+
+import io.element.android.libraries.permissions.api.PermissionsState
+
+data class NotificationsOptInState(
+ val notificationsPermissionState: PermissionsState,
+ val eventSink: (NotificationsOptInEvents) -> Unit
+)
diff --git a/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/notifications/NotificationsOptInStateProvider.kt b/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/notifications/NotificationsOptInStateProvider.kt
new file mode 100644
index 0000000000..230e125c1b
--- /dev/null
+++ b/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/notifications/NotificationsOptInStateProvider.kt
@@ -0,0 +1,33 @@
+/*
+ * Copyright (c) 2023 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.element.android.features.ftue.impl.notifications
+
+import androidx.compose.ui.tooling.preview.PreviewParameterProvider
+import io.element.android.libraries.permissions.api.aPermissionsState
+
+open class NotificationsOptInStateProvider : PreviewParameterProvider {
+ override val values: Sequence
+ get() = sequenceOf(
+ aNotificationsOptInState(),
+ // Add other states here
+ )
+}
+
+fun aNotificationsOptInState() = NotificationsOptInState(
+ notificationsPermissionState = aPermissionsState(),
+ eventSink = {}
+)
diff --git a/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/notifications/NotificationsOptInView.kt b/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/notifications/NotificationsOptInView.kt
new file mode 100644
index 0000000000..d22d6ab6ff
--- /dev/null
+++ b/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/notifications/NotificationsOptInView.kt
@@ -0,0 +1,197 @@
+/*
+ * Copyright (c) 2023 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.element.android.features.ftue.impl.notifications
+
+import androidx.activity.compose.BackHandler
+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.Row
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.systemBarsPadding
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Notifications
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.tooling.preview.PreviewParameter
+import androidx.compose.ui.unit.dp
+import io.element.android.features.ftue.impl.R
+import io.element.android.libraries.designsystem.atomic.molecules.ButtonColumnMolecule
+import io.element.android.libraries.designsystem.atomic.molecules.IconTitleSubtitleMolecule
+import io.element.android.libraries.designsystem.atomic.pages.HeaderFooterPage
+import io.element.android.libraries.designsystem.components.avatar.Avatar
+import io.element.android.libraries.designsystem.components.avatar.AvatarData
+import io.element.android.libraries.designsystem.components.avatar.AvatarSize
+import io.element.android.libraries.designsystem.preview.DayNightPreviews
+import io.element.android.libraries.designsystem.preview.ElementPreview
+import io.element.android.libraries.designsystem.theme.components.Button
+import io.element.android.libraries.designsystem.theme.components.Surface
+import io.element.android.libraries.designsystem.theme.components.TextButton
+import io.element.android.libraries.theme.ElementTheme
+import io.element.android.libraries.ui.strings.CommonStrings
+
+@Composable
+fun NotificationsOptInView(
+ state: NotificationsOptInState,
+ onBack: () -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ BackHandler(onBack = onBack)
+
+ HeaderFooterPage(
+ modifier = modifier
+ .systemBarsPadding()
+ .fillMaxSize(),
+ header = { NotificationsOptInHeader(modifier = Modifier.padding(top = 60.dp, bottom = 12.dp)) },
+ footer = { NotificationsOptInFooter(state) },
+ ) {
+ NotificationsOptInContent(modifier = Modifier.fillMaxWidth())
+ }
+}
+
+@Composable
+private fun NotificationsOptInHeader(
+ modifier: Modifier = Modifier,
+) {
+ IconTitleSubtitleMolecule(
+ modifier = modifier,
+ title = stringResource(R.string.screen_notification_optin_title),
+ subTitle = stringResource(R.string.screen_notification_optin_subtitle),
+ iconImageVector = Icons.Default.Notifications,
+ )
+}
+
+@Composable
+private fun NotificationsOptInFooter(state: NotificationsOptInState) {
+ ButtonColumnMolecule {
+ Button(
+ modifier = Modifier.fillMaxWidth(),
+ text = stringResource(CommonStrings.action_ok),
+ onClick = {
+ state.eventSink(NotificationsOptInEvents.ContinueClicked)
+ }
+ )
+ TextButton(
+ modifier = Modifier.fillMaxWidth(),
+ text = stringResource(CommonStrings.action_not_now),
+ onClick = {
+ state.eventSink(NotificationsOptInEvents.NotNowClicked)
+ }
+ )
+ }
+}
+
+@Composable
+private fun NotificationsOptInContent(
+ modifier: Modifier = Modifier,
+) {
+ Box(modifier = modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
+ Column(
+ verticalArrangement = Arrangement.spacedBy(
+ 16.dp,
+ alignment = Alignment.CenterVertically
+ )
+ ) {
+ NotificationRow(
+ avatarLetter = "M",
+ avatarColorsId = "5",
+ firstRowPercent = 1f,
+ secondRowPercent = 0.4f
+ )
+
+ NotificationRow(
+ avatarLetter = "A",
+ avatarColorsId = "1",
+ firstRowPercent = 1f,
+ secondRowPercent = 1f
+ )
+
+ NotificationRow(
+ avatarLetter = "T",
+ avatarColorsId = "4",
+ firstRowPercent = 0.65f,
+ secondRowPercent = 0f
+ )
+ }
+ }
+}
+
+@Composable
+private fun NotificationRow(
+ avatarLetter: String,
+ avatarColorsId: String,
+ firstRowPercent: Float,
+ secondRowPercent: Float,
+ modifier: Modifier = Modifier
+) {
+ Surface(
+ modifier = modifier,
+ color = ElementTheme.colors.bgCanvasDisabled,
+ shape = RoundedCornerShape(14.dp),
+ shadowElevation = 2.dp,
+ ) {
+ Row(
+ modifier = Modifier.padding(16.dp),
+ horizontalArrangement = Arrangement.spacedBy(16.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Avatar(
+ avatarData = AvatarData(id = avatarColorsId, name = avatarLetter, size = AvatarSize.NotificationsOptIn),
+ )
+ Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(12.dp)) {
+ Box(
+ modifier = Modifier
+ .clip(CircleShape)
+ .fillMaxWidth(firstRowPercent)
+ .height(10.dp)
+ .background(ElementTheme.colors.borderInteractiveSecondary)
+ )
+ if (secondRowPercent > 0f) {
+ Box(
+ modifier = Modifier
+ .clip(CircleShape)
+ .fillMaxWidth(secondRowPercent)
+ .height(10.dp)
+ .background(ElementTheme.colors.borderInteractiveSecondary)
+ )
+ }
+ }
+ }
+ }
+}
+
+@DayNightPreviews
+@Composable
+internal fun NotificationsOptInViewPreview(
+ @PreviewParameter(NotificationsOptInStateProvider::class) state: NotificationsOptInState
+) {
+ ElementPreview {
+ NotificationsOptInView(
+ onBack = {},
+ state = state,
+ )
+ }
+}
diff --git a/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/state/DefaultFtueState.kt b/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/state/DefaultFtueState.kt
index 108072cba9..3247d7faf8 100644
--- a/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/state/DefaultFtueState.kt
+++ b/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/state/DefaultFtueState.kt
@@ -16,6 +16,8 @@
package io.element.android.features.ftue.impl.state
+import android.Manifest
+import android.os.Build
import androidx.annotation.VisibleForTesting
import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.features.ftue.api.state.FtueState
@@ -23,7 +25,9 @@ import io.element.android.features.ftue.impl.migration.MigrationScreenStore
import io.element.android.features.ftue.impl.welcome.state.WelcomeScreenState
import io.element.android.libraries.di.SessionScope
import io.element.android.libraries.matrix.api.MatrixClient
+import io.element.android.libraries.permissions.api.PermissionStateProvider
import io.element.android.services.analytics.api.AnalyticsService
+import io.element.android.services.toolbox.api.sdk.BuildVersionSdkIntProvider
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.first
@@ -34,10 +38,12 @@ import javax.inject.Inject
@ContributesBinding(SessionScope::class)
class DefaultFtueState @Inject constructor(
+ private val sdkVersionProvider: BuildVersionSdkIntProvider,
private val coroutineScope: CoroutineScope,
private val analyticsService: AnalyticsService,
private val welcomeScreenState: WelcomeScreenState,
private val migrationScreenStore: MigrationScreenStore,
+ private val permissionStateProvider: PermissionStateProvider,
private val matrixClient: MatrixClient,
) : FtueState {
@@ -47,6 +53,9 @@ class DefaultFtueState @Inject constructor(
welcomeScreenState.reset()
analyticsService.reset()
migrationScreenStore.reset()
+ if (sdkVersionProvider.isAtLeast(Build.VERSION_CODES.TIRAMISU)) {
+ permissionStateProvider.resetPermission(Manifest.permission.POST_NOTIFICATIONS)
+ }
}
init {
@@ -63,7 +72,10 @@ class DefaultFtueState @Inject constructor(
FtueStep.MigrationScreen -> if (shouldDisplayWelcomeScreen()) FtueStep.WelcomeScreen else getNextStep(
FtueStep.WelcomeScreen
)
- FtueStep.WelcomeScreen -> if (needsAnalyticsOptIn()) FtueStep.AnalyticsOptIn else getNextStep(
+ FtueStep.WelcomeScreen -> if (shouldAskNotificationPermissions()) FtueStep.NotificationsOptIn else getNextStep(
+ FtueStep.NotificationsOptIn
+ )
+ FtueStep.NotificationsOptIn -> if (needsAnalyticsOptIn()) FtueStep.AnalyticsOptIn else getNextStep(
FtueStep.AnalyticsOptIn
)
FtueStep.AnalyticsOptIn -> null
@@ -73,6 +85,7 @@ class DefaultFtueState @Inject constructor(
return listOf(
shouldDisplayMigrationScreen(),
shouldDisplayWelcomeScreen(),
+ shouldAskNotificationPermissions(),
needsAnalyticsOptIn()
).any { it }
}
@@ -90,6 +103,15 @@ class DefaultFtueState @Inject constructor(
return welcomeScreenState.isWelcomeScreenNeeded()
}
+ private fun shouldAskNotificationPermissions(): Boolean {
+ return if (sdkVersionProvider.isAtLeast(Build.VERSION_CODES.TIRAMISU)) {
+ val permission = Manifest.permission.POST_NOTIFICATIONS
+ val isPermissionDenied = runBlocking { permissionStateProvider.isPermissionDenied(permission).first() }
+ val isPermissionGranted = permissionStateProvider.isPermissionGranted(permission)
+ !isPermissionGranted && !isPermissionDenied
+ } else false
+ }
+
fun setWelcomeScreenShown() {
welcomeScreenState.setWelcomeScreenShown()
updateState()
@@ -104,5 +126,6 @@ class DefaultFtueState @Inject constructor(
sealed interface FtueStep {
data object MigrationScreen : FtueStep
data object WelcomeScreen : FtueStep
+ data object NotificationsOptIn : FtueStep
data object AnalyticsOptIn : FtueStep
}
diff --git a/features/ftue/impl/src/main/res/values-cs/translations.xml b/features/ftue/impl/src/main/res/values-cs/translations.xml
index f1734c9c75..724f3793cd 100644
--- a/features/ftue/impl/src/main/res/values-cs/translations.xml
+++ b/features/ftue/impl/src/main/res/values-cs/translations.xml
@@ -1,6 +1,6 @@
- "Toto je jednorázový proces, děkujeme za čekání."
+ "Jedná se o jednorázový proces, prosíme o strpení."
"Nastavení vašeho účtu"
"Hovory, hlasování, vyhledávání a další budou přidány koncem tohoto roku."
"Historie zpráv šifrovaných místností nebude v této aktualizaci k dispozici."
diff --git a/features/ftue/impl/src/main/res/values-de/translations.xml b/features/ftue/impl/src/main/res/values-de/translations.xml
index 19b445bd50..9ee1113fdb 100644
--- a/features/ftue/impl/src/main/res/values-de/translations.xml
+++ b/features/ftue/impl/src/main/res/values-de/translations.xml
@@ -1,11 +1,13 @@
"Dies ist ein einmaliger Vorgang, danke fürs Warten."
- "Dein Konto einrichten"
- "Anrufe, Umfragen, Suche und mehr werden später in diesem Jahr hinzugefügt."
+ "Richte dein Konto ein."
+ "Du kannst deine Einstellungen später ändern."
+ "Erlaube Benachrichtigungen und verpasse keine Nachricht"
+ "Anrufe, Umfragen, Suchfunktionen und mehr werden im Laufe des Jahres hinzugefügt."
"Der Nachrichtenverlauf für verschlüsselte Räume wird in diesem Update nicht verfügbar sein."
- "Wir würden uns freuen, wenn du uns über die Einstellungsseite deine Meinung mitteilst."
+ "Wir würden uns freuen, von Ihnen zu hören. Teilen Sie uns Ihre Meinung über die Einstellungsseite mit."
"Los geht\'s!"
- "Folgendes musst du wissen:"
+ "Folgendes müssen Sie wissen:"
"Willkommen bei %1$s!"
diff --git a/features/ftue/impl/src/main/res/values-fr/translations.xml b/features/ftue/impl/src/main/res/values-fr/translations.xml
index 9f431f545d..864a887f20 100644
--- a/features/ftue/impl/src/main/res/values-fr/translations.xml
+++ b/features/ftue/impl/src/main/res/values-fr/translations.xml
@@ -1,10 +1,13 @@
- "Ce processus n’a besoin d’être fait qu’une seule fois, merci de patienter."
+ "Il s’agit d’une opération ponctuelle, merci d’attendre quelques instants."
"Configuration de votre compte."
+ "Vous pourrez modifier vos paramètres ultérieurement."
+ "Autorisez les notifications et ne manquez aucun message"
+ "Les appels, les sondages, les recherches et plus encore seront ajoutés plus tard cette année."
"L’historique des messages pour les salons chiffrés ne sera pas disponible dans cette mise à jour."
- "Nous serions ravis d’avoir votre avis, n’hésitez pas à nous le partager via la page des paramètres."
+ "N’hésitez pas à nous faire part de vos commentaires via l’écran des paramètres."
"C’est parti !"
- "Voici ce qu’il faut savoir :"
- "Bienvenue sur %1$s !"
+ "Voici ce que vous devez savoir :"
+ "Bienvenue dans %1$s !"
diff --git a/features/ftue/impl/src/main/res/values-ro/translations.xml b/features/ftue/impl/src/main/res/values-ro/translations.xml
new file mode 100644
index 0000000000..f932256318
--- /dev/null
+++ b/features/ftue/impl/src/main/res/values-ro/translations.xml
@@ -0,0 +1,11 @@
+
+
+ "Acesta este un proces care se desfășoară o singură dată, vă mulțumim pentru așteptare."
+ "Contul dumneavoastră se configurează"
+ "Apelurile, sondajele, căutare și multe altele vor fi adăugate în cursul acestui an."
+ "Istoricul mesajelor pentru camerele criptate nu va fi disponibil în această actualizare."
+ "Ne-ar plăcea să auzim de la dumneavoastră, spuneți-ne ce părere aveți prin intermediul paginii de setări."
+ "Să începem!"
+ "Iată ce trebuie să știți:"
+ "Bun venit la%1$s!"
+
diff --git a/features/ftue/impl/src/main/res/values-sk/translations.xml b/features/ftue/impl/src/main/res/values-sk/translations.xml
index 5bbd2d386d..aa76053cea 100644
--- a/features/ftue/impl/src/main/res/values-sk/translations.xml
+++ b/features/ftue/impl/src/main/res/values-sk/translations.xml
@@ -2,6 +2,8 @@
"Ide o jednorazový proces, ďakujeme za trpezlivosť."
"Nastavenie vášho účtu."
+ "Svoje nastavenia môžete neskôr zmeniť."
+ "Povoľte oznámenia a nikdy nezmeškajte žiadnu správu"
"Hovory, ankety, vyhľadávanie a ďalšie funkcie pribudnú neskôr v tomto roku."
"História správ pre zašifrované miestnosti nebude v tejto aktualizácii k dispozícii."
"Radi by sme od vás počuli, dajte nám vedieť, čo si myslíte, prostredníctvom stránky nastavení."
diff --git a/features/ftue/impl/src/main/res/values/localazy.xml b/features/ftue/impl/src/main/res/values/localazy.xml
index aee8470751..b398ba37d0 100644
--- a/features/ftue/impl/src/main/res/values/localazy.xml
+++ b/features/ftue/impl/src/main/res/values/localazy.xml
@@ -2,6 +2,8 @@
"This is a one time process, thanks for waiting."
"Setting up your account."
+ "You can change your settings later."
+ "Allow notifications and never miss a message"
"Calls, polls, search and more will be added later this year."
"Message history for encrypted rooms won’t be available in this update."
"We’d love to hear from you, let us know what you think via the settings page."
diff --git a/features/ftue/impl/src/test/kotlin/io/element/android/features/ftue/impl/DefaultFtueStateTests.kt b/features/ftue/impl/src/test/kotlin/io/element/android/features/ftue/impl/DefaultFtueStateTests.kt
index f93f761994..1388eb8fc1 100644
--- a/features/ftue/impl/src/test/kotlin/io/element/android/features/ftue/impl/DefaultFtueStateTests.kt
+++ b/features/ftue/impl/src/test/kotlin/io/element/android/features/ftue/impl/DefaultFtueStateTests.kt
@@ -16,6 +16,7 @@
package io.element.android.features.ftue.impl
+import android.os.Build
import com.google.common.truth.Truth.assertThat
import io.element.android.features.ftue.impl.migration.InMemoryMigrationScreenStore
import io.element.android.features.ftue.impl.migration.MigrationScreenStore
@@ -25,8 +26,10 @@ import io.element.android.features.ftue.impl.welcome.state.FakeWelcomeState
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.test.A_SESSION_ID
import io.element.android.libraries.matrix.test.FakeMatrixClient
+import io.element.android.libraries.permissions.impl.FakePermissionStateProvider
import io.element.android.services.analytics.api.AnalyticsService
import io.element.android.services.analytics.test.FakeAnalyticsService
+import io.element.android.services.toolbox.test.sdk.FakeBuildVersionSdkIntProvider
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
@@ -51,13 +54,21 @@ class DefaultFtueStateTests {
val welcomeState = FakeWelcomeState()
val analyticsService = FakeAnalyticsService()
val migrationScreenStore = InMemoryMigrationScreenStore()
+ val permissionStateProvider = FakePermissionStateProvider(permissionGranted = true)
val coroutineScope = CoroutineScope(coroutineContext + SupervisorJob())
- val state = createState(coroutineScope, welcomeState, analyticsService, migrationScreenStore)
+ val state = createState(
+ coroutineScope = coroutineScope,
+ welcomeState = welcomeState,
+ analyticsService = analyticsService,
+ migrationScreenStore = migrationScreenStore,
+ permissionStateProvider = permissionStateProvider
+ )
welcomeState.setWelcomeScreenShown()
analyticsService.setDidAskUserConsent()
migrationScreenStore.setMigrationScreenShown(A_SESSION_ID)
+ permissionStateProvider.setPermissionGranted()
state.updateState()
assertThat(state.shouldDisplayFlow.value).isFalse()
@@ -71,9 +82,16 @@ class DefaultFtueStateTests {
val welcomeState = FakeWelcomeState()
val analyticsService = FakeAnalyticsService()
val migrationScreenStore = InMemoryMigrationScreenStore()
+ val permissionStateProvider = FakePermissionStateProvider(permissionGranted = false)
val coroutineScope = CoroutineScope(coroutineContext + SupervisorJob())
- val state = createState(coroutineScope, welcomeState, analyticsService, migrationScreenStore)
+ val state = createState(
+ coroutineScope = coroutineScope,
+ welcomeState = welcomeState,
+ analyticsService = analyticsService,
+ migrationScreenStore = migrationScreenStore,
+ permissionStateProvider = permissionStateProvider
+ )
val steps = mutableListOf()
// First step, migration screen
@@ -84,7 +102,11 @@ class DefaultFtueStateTests {
steps.add(state.getNextStep(steps.lastOrNull()))
welcomeState.setWelcomeScreenShown()
- // Third step, analytics opt in
+ // Third step, notifications opt in
+ steps.add(state.getNextStep(steps.lastOrNull()))
+ permissionStateProvider.setPermissionGranted()
+
+ // Fourth step, analytics opt in
steps.add(state.getNextStep(steps.lastOrNull()))
analyticsService.setDidAskUserConsent()
@@ -94,6 +116,7 @@ class DefaultFtueStateTests {
assertThat(steps).containsExactly(
FtueStep.MigrationScreen,
FtueStep.WelcomeScreen,
+ FtueStep.NotificationsOptIn,
FtueStep.AnalyticsOptIn,
null, // Final state
)
@@ -107,8 +130,37 @@ class DefaultFtueStateTests {
val coroutineScope = CoroutineScope(coroutineContext + SupervisorJob())
val analyticsService = FakeAnalyticsService()
val migrationScreenStore = InMemoryMigrationScreenStore()
+ val permissionStateProvider = FakePermissionStateProvider(permissionGranted = false)
+
+ val state = createState(
+ coroutineScope = coroutineScope,
+ analyticsService = analyticsService,
+ migrationScreenStore = migrationScreenStore,
+ permissionStateProvider = permissionStateProvider,
+ )
+
+ // Skip first 3 steps
+ migrationScreenStore.setMigrationScreenShown(A_SESSION_ID)
+ state.setWelcomeScreenShown()
+ permissionStateProvider.setPermissionGranted()
+
+ assertThat(state.getNextStep()).isEqualTo(FtueStep.AnalyticsOptIn)
+
+ analyticsService.setDidAskUserConsent()
+ assertThat(state.getNextStep(FtueStep.WelcomeScreen)).isNull()
+
+ // Cleanup
+ coroutineScope.cancel()
+ }
+
+ @Test
+ fun `if version is older than 13 we don't display the notification opt in screen`() = runTest {
+ val coroutineScope = CoroutineScope(coroutineContext + SupervisorJob())
+ val analyticsService = FakeAnalyticsService()
+ val migrationScreenStore = InMemoryMigrationScreenStore()
val state = createState(
+ sdkIntVersion = Build.VERSION_CODES.M,
coroutineScope = coroutineScope,
analyticsService = analyticsService,
migrationScreenStore = migrationScreenStore,
@@ -132,12 +184,16 @@ class DefaultFtueStateTests {
welcomeState: FakeWelcomeState = FakeWelcomeState(),
analyticsService: AnalyticsService = FakeAnalyticsService(),
migrationScreenStore: MigrationScreenStore = InMemoryMigrationScreenStore(),
+ permissionStateProvider: FakePermissionStateProvider = FakePermissionStateProvider(permissionGranted = false),
matrixClient: MatrixClient = FakeMatrixClient(),
+ sdkIntVersion: Int = Build.VERSION_CODES.TIRAMISU, // First version where notification permission is required
) = DefaultFtueState(
+ sdkVersionProvider = FakeBuildVersionSdkIntProvider(sdkIntVersion),
coroutineScope = coroutineScope,
analyticsService = analyticsService,
welcomeScreenState = welcomeState,
migrationScreenStore = migrationScreenStore,
+ permissionStateProvider = permissionStateProvider,
matrixClient = matrixClient,
)
}
diff --git a/features/ftue/impl/src/test/kotlin/io/element/android/features/ftue/impl/migration/MigrationScreenPresenterTest.kt b/features/ftue/impl/src/test/kotlin/io/element/android/features/ftue/impl/migration/MigrationScreenPresenterTest.kt
index 6e19879b86..ac56718e06 100644
--- a/features/ftue/impl/src/test/kotlin/io/element/android/features/ftue/impl/migration/MigrationScreenPresenterTest.kt
+++ b/features/ftue/impl/src/test/kotlin/io/element/android/features/ftue/impl/migration/MigrationScreenPresenterTest.kt
@@ -25,10 +25,16 @@ import io.element.android.libraries.matrix.api.roomlist.RoomListService
import io.element.android.libraries.matrix.test.A_SESSION_ID
import io.element.android.libraries.matrix.test.FakeMatrixClient
import io.element.android.libraries.matrix.test.roomlist.FakeRoomListService
+import io.element.android.tests.testutils.WarmUpRule
import kotlinx.coroutines.test.runTest
+import org.junit.Rule
import org.junit.Test
class MigrationScreenPresenterTest {
+
+ @get:Rule
+ val warmUpRule = WarmUpRule()
+
@Test
fun `present - initial`() = runTest {
val presenter = createPresenter()
diff --git a/features/ftue/impl/src/test/kotlin/io/element/android/features/ftue/impl/notifications/NotificationsOptInPresenterTests.kt b/features/ftue/impl/src/test/kotlin/io/element/android/features/ftue/impl/notifications/NotificationsOptInPresenterTests.kt
new file mode 100644
index 0000000000..9bfa1cf33c
--- /dev/null
+++ b/features/ftue/impl/src/test/kotlin/io/element/android/features/ftue/impl/notifications/NotificationsOptInPresenterTests.kt
@@ -0,0 +1,147 @@
+/*
+ * Copyright (c) 2023 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.element.android.features.ftue.impl.notifications
+
+import android.os.Build
+import app.cash.molecule.RecompositionMode
+import app.cash.molecule.moleculeFlow
+import app.cash.turbine.test
+import com.google.common.truth.Truth
+import io.element.android.libraries.permissions.api.PermissionStateProvider
+import io.element.android.libraries.permissions.api.PermissionsPresenter
+import io.element.android.libraries.permissions.impl.FakePermissionStateProvider
+import io.element.android.libraries.permissions.test.FakePermissionsPresenter
+import io.element.android.services.toolbox.test.sdk.FakeBuildVersionSdkIntProvider
+import io.element.android.tests.testutils.WarmUpRule
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.runBlocking
+import kotlinx.coroutines.test.StandardTestDispatcher
+import kotlinx.coroutines.test.TestScope
+import kotlinx.coroutines.test.runCurrent
+import kotlinx.coroutines.test.runTest
+import org.junit.Rule
+import org.junit.Test
+
+class NotificationsOptInPresenterTests {
+
+ @get:Rule
+ val warmUpRule = WarmUpRule()
+
+ private var isFinished = false
+
+ @Test
+ fun `initial state`() = runTest {
+ val presenter = createPresenter()
+ moleculeFlow(RecompositionMode.Immediate) {
+ presenter.present()
+ }.test {
+ val initialState = awaitItem()
+ Truth.assertThat(initialState.notificationsPermissionState.showDialog).isFalse()
+ }
+ }
+
+ @Test
+ fun `show dialog on continue clicked`() = runTest {
+ val permissionPresenter = FakePermissionsPresenter()
+ val presenter = createPresenter(permissionPresenter)
+ moleculeFlow(RecompositionMode.Immediate) {
+ presenter.present()
+ }.test {
+ val initialState = awaitItem()
+ initialState.eventSink(NotificationsOptInEvents.ContinueClicked)
+ Truth.assertThat(awaitItem().notificationsPermissionState.showDialog).isTrue()
+ }
+ }
+
+ @Test
+ fun `finish flow on continue clicked with permission already granted`() = runTest {
+ val permissionPresenter = FakePermissionsPresenter().apply {
+ setPermissionGranted()
+ }
+ val presenter = createPresenter(permissionPresenter)
+ moleculeFlow(RecompositionMode.Immediate) {
+ presenter.present()
+ }.test {
+ val initialState = awaitItem()
+ initialState.eventSink(NotificationsOptInEvents.ContinueClicked)
+ Truth.assertThat(isFinished).isTrue()
+ }
+ }
+
+ @Test
+ fun `finish flow on not now clicked`() = runTest {
+ val permissionPresenter = FakePermissionsPresenter()
+ val presenter = createPresenter(
+ permissionsPresenter = permissionPresenter,
+ sdkIntVersion = Build.VERSION_CODES.M
+ )
+ moleculeFlow(RecompositionMode.Immediate) {
+ presenter.present()
+ }.test {
+ val initialState = awaitItem()
+ initialState.eventSink(NotificationsOptInEvents.NotNowClicked)
+ Truth.assertThat(isFinished).isTrue()
+ }
+ }
+
+ @OptIn(ExperimentalCoroutinesApi::class)
+ @Test
+ fun `set permission denied on not now clicked in API 33`() = runTest(StandardTestDispatcher()) {
+ val permissionPresenter = FakePermissionsPresenter()
+ val permissionStateProvider = FakePermissionStateProvider()
+ val presenter = createPresenter(
+ permissionsPresenter = permissionPresenter,
+ permissionStateProvider = permissionStateProvider,
+ sdkIntVersion = Build.VERSION_CODES.TIRAMISU
+ )
+ moleculeFlow(RecompositionMode.Immediate) {
+ presenter.present()
+ }.test {
+ val initialState = awaitItem()
+ initialState.eventSink(NotificationsOptInEvents.NotNowClicked)
+
+ // Allow background coroutines to run
+ runCurrent()
+
+ val isPermissionDenied = runBlocking {
+ permissionStateProvider.isPermissionDenied("notifications").first()
+ }
+ Truth.assertThat(isPermissionDenied).isTrue()
+ }
+ }
+
+ private fun TestScope.createPresenter(
+ permissionsPresenter: PermissionsPresenter = FakePermissionsPresenter(),
+ permissionStateProvider: PermissionStateProvider = FakePermissionStateProvider(),
+ sdkIntVersion: Int = Build.VERSION_CODES.TIRAMISU,
+ ) = NotificationsOptInPresenter(
+ permissionsPresenterFactory = object : PermissionsPresenter.Factory {
+ override fun create(permission: String): PermissionsPresenter {
+ return permissionsPresenter
+ }
+ },
+ callback = object : NotificationsOptInNode.Callback {
+ override fun onNotificationsOptInFinished() {
+ isFinished = true
+ }
+ },
+ appCoroutineScope = this,
+ permissionStateProvider = permissionStateProvider,
+ buildVersionSdkIntProvider = FakeBuildVersionSdkIntProvider(sdkIntVersion),
+ )
+}
diff --git a/features/invitelist/impl/build.gradle.kts b/features/invitelist/impl/build.gradle.kts
index cd008472b5..d8fb25585f 100644
--- a/features/invitelist/impl/build.gradle.kts
+++ b/features/invitelist/impl/build.gradle.kts
@@ -54,6 +54,7 @@ dependencies {
testImplementation(projects.libraries.push.test)
testImplementation(projects.features.invitelist.test)
testImplementation(projects.services.analytics.test)
+ testImplementation(projects.tests.testutils)
ksp(libs.showkase.processor)
}
diff --git a/features/invitelist/impl/src/main/kotlin/io/element/android/features/invitelist/impl/InviteListPresenter.kt b/features/invitelist/impl/src/main/kotlin/io/element/android/features/invitelist/impl/InviteListPresenter.kt
index 8c43b815ae..648eb5094f 100644
--- a/features/invitelist/impl/src/main/kotlin/io/element/android/features/invitelist/impl/InviteListPresenter.kt
+++ b/features/invitelist/impl/src/main/kotlin/io/element/android/features/invitelist/impl/InviteListPresenter.kt
@@ -141,7 +141,7 @@ class InviteListPresenter @Inject constructor(
suspend {
client.getRoom(roomId)?.use {
it.join().getOrThrow()
- notificationDrawerManager.clearMembershipNotificationForRoom(client.sessionId, roomId)
+ notificationDrawerManager.clearMembershipNotificationForRoom(client.sessionId, roomId, doRender = true)
analyticsService.capture(it.toAnalyticsJoinedRoom(JoinedRoom.Trigger.Invite))
}
roomId
@@ -152,7 +152,7 @@ class InviteListPresenter @Inject constructor(
suspend {
client.getRoom(roomId)?.use {
it.leave().getOrThrow()
- notificationDrawerManager.clearMembershipNotificationForRoom(client.sessionId, roomId)
+ notificationDrawerManager.clearMembershipNotificationForRoom(client.sessionId, roomId, doRender = true)
}.let { }
}.runCatchingUpdatingState(declinedAction)
}
diff --git a/features/invitelist/impl/src/main/res/values-de/translations.xml b/features/invitelist/impl/src/main/res/values-de/translations.xml
index 2cec59d6a0..97f4aa91f7 100644
--- a/features/invitelist/impl/src/main/res/values-de/translations.xml
+++ b/features/invitelist/impl/src/main/res/values-de/translations.xml
@@ -1,8 +1,8 @@
- "Möchtest du den Beitritt zu %1$s wirklich ablehnen?"
+ "Möchtest du die Einladung zum Betreten von %1$s wirklich ablehnen?"
"Einladung ablehnen"
- "Möchtest du den privaten Chat mit %1$s wirklich ablehnen?"
+ "Bist du sicher, dass du diesen privaten Chat mit %1$s ablehnen möchtest?"
"Chat ablehnen"
"Keine Einladungen"
"%1$s (%2$s) hat dich eingeladen"
diff --git a/features/invitelist/impl/src/main/res/values-fr/translations.xml b/features/invitelist/impl/src/main/res/values-fr/translations.xml
index 677fadd539..45c29b3572 100644
--- a/features/invitelist/impl/src/main/res/values-fr/translations.xml
+++ b/features/invitelist/impl/src/main/res/values-fr/translations.xml
@@ -1,9 +1,9 @@
- "Voulez-vous vraiment refuser l‘invitation à rejoindre %1$s ?"
- "Refuser l\'invitation"
- "Voulez-vous vraiment refuser ce chat privé avec %1$s ?"
- "Refuser le chat"
+ "Êtes-vous sûr de vouloir décliner l’invitation à rejoindre %1$s ?"
+ "Refuser l’invitation"
+ "Êtes-vous sûr de vouloir refuser cette discussion privée avec %1$s ?"
+ "Refuser l’invitation"
"Aucune invitation"
- "%1$s (%2$s) vous a invité"
+ "%1$s (%2$s) vous a invité(e)"
diff --git a/features/invitelist/impl/src/test/kotlin/io/element/android/features/invitelist/impl/InviteListPresenterTests.kt b/features/invitelist/impl/src/test/kotlin/io/element/android/features/invitelist/impl/InviteListPresenterTests.kt
index adb2042b62..ca0c73d53e 100644
--- a/features/invitelist/impl/src/test/kotlin/io/element/android/features/invitelist/impl/InviteListPresenterTests.kt
+++ b/features/invitelist/impl/src/test/kotlin/io/element/android/features/invitelist/impl/InviteListPresenterTests.kt
@@ -44,10 +44,14 @@ import io.element.android.libraries.push.api.notifications.NotificationDrawerMan
import io.element.android.libraries.push.test.notifications.FakeNotificationDrawerManager
import io.element.android.services.analytics.api.AnalyticsService
import io.element.android.services.analytics.test.FakeAnalyticsService
+import io.element.android.tests.testutils.WarmUpRule
import kotlinx.coroutines.test.runTest
+import org.junit.Rule
import org.junit.Test
class InviteListPresenterTests {
+ @get:Rule
+ val warmUpRule = WarmUpRule()
@Test
fun `present - starts empty, adds invites when received`() = runTest {
diff --git a/features/leaveroom/impl/src/test/kotlin/io/element/android/features/leaveroom/impl/LeaveRoomPresenterImplTest.kt b/features/leaveroom/impl/src/test/kotlin/io/element/android/features/leaveroom/impl/LeaveRoomPresenterImplTest.kt
index 3075d3c686..fff7c00410 100644
--- a/features/leaveroom/impl/src/test/kotlin/io/element/android/features/leaveroom/impl/LeaveRoomPresenterImplTest.kt
+++ b/features/leaveroom/impl/src/test/kotlin/io/element/android/features/leaveroom/impl/LeaveRoomPresenterImplTest.kt
@@ -29,14 +29,19 @@ import io.element.android.libraries.matrix.api.timeline.item.event.MembershipCha
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.room.FakeMatrixRoom
+import io.element.android.tests.testutils.WarmUpRule
import io.element.android.tests.testutils.testCoroutineDispatchers
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.runTest
+import org.junit.Rule
import org.junit.Test
class LeaveRoomPresenterImplTest {
+ @get:Rule
+ val warmUpRule = WarmUpRule()
+
@Test
fun `present - initial state hides all dialogs`() = runTest {
val presenter = createPresenter()
diff --git a/features/location/impl/build.gradle.kts b/features/location/impl/build.gradle.kts
index 325003b110..dd265ee7f0 100644
--- a/features/location/impl/build.gradle.kts
+++ b/features/location/impl/build.gradle.kts
@@ -57,4 +57,5 @@ dependencies {
testImplementation(projects.libraries.matrix.test)
testImplementation(projects.services.analytics.test)
testImplementation(projects.features.messages.test)
+ testImplementation(projects.tests.testutils)
}
diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/send/SendLocationPresenter.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/send/SendLocationPresenter.kt
index 2ea7dc20e1..94af55c01c 100644
--- a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/send/SendLocationPresenter.kt
+++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/send/SendLocationPresenter.kt
@@ -26,10 +26,10 @@ import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import im.vector.app.features.analytics.plan.Composer
import io.element.android.features.location.impl.common.MapDefaults
+import io.element.android.features.location.impl.common.actions.LocationActions
import io.element.android.features.location.impl.common.permissions.PermissionsEvents
import io.element.android.features.location.impl.common.permissions.PermissionsPresenter
import io.element.android.features.location.impl.common.permissions.PermissionsState
-import io.element.android.features.location.impl.common.actions.LocationActions
import io.element.android.features.messages.api.MessageComposerContext
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.core.meta.BuildMeta
@@ -119,9 +119,8 @@ class SendLocationPresenter @Inject constructor(
Composer(
inThread = messageComposerContext.composerMode.inThread,
isEditing = messageComposerContext.composerMode.isEditing,
- isLocation = true,
isReply = messageComposerContext.composerMode.isReply,
- locationType = Composer.LocationType.PinDrop,
+ messageType = Composer.MessageType.LocationPin,
)
)
}
@@ -138,9 +137,8 @@ class SendLocationPresenter @Inject constructor(
Composer(
inThread = messageComposerContext.composerMode.inThread,
isEditing = messageComposerContext.composerMode.isEditing,
- isLocation = true,
isReply = messageComposerContext.composerMode.isReply,
- locationType = Composer.LocationType.MyLocation,
+ messageType = Composer.MessageType.LocationUser,
)
)
}
diff --git a/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/send/SendLocationPresenterTest.kt b/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/send/SendLocationPresenterTest.kt
index 21766a2cf0..c0b4cb7d35 100644
--- a/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/send/SendLocationPresenterTest.kt
+++ b/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/send/SendLocationPresenterTest.kt
@@ -34,12 +34,17 @@ import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
import io.element.android.libraries.matrix.test.room.SendLocationInvocation
import io.element.android.libraries.textcomposer.MessageComposerMode
import io.element.android.services.analytics.test.FakeAnalyticsService
+import io.element.android.tests.testutils.WarmUpRule
import kotlinx.coroutines.delay
import kotlinx.coroutines.test.runTest
+import org.junit.Rule
import org.junit.Test
class SendLocationPresenterTest {
+ @get:Rule
+ val warmUpRule = WarmUpRule()
+
private val permissionsPresenterFake = PermissionsPresenterFake()
private val fakeMatrixRoom = FakeMatrixRoom()
private val fakeAnalyticsService = FakeAnalyticsService()
@@ -302,9 +307,8 @@ class SendLocationPresenterTest {
Composer(
inThread = false,
isEditing = false,
- isLocation = true,
isReply = false,
- locationType = Composer.LocationType.MyLocation,
+ messageType = Composer.MessageType.LocationUser,
)
)
}
@@ -359,9 +363,8 @@ class SendLocationPresenterTest {
Composer(
inThread = false,
isEditing = false,
- isLocation = true,
isReply = false,
- locationType = Composer.LocationType.PinDrop,
+ messageType = Composer.MessageType.LocationPin,
)
)
}
@@ -406,9 +409,8 @@ class SendLocationPresenterTest {
Composer(
inThread = false,
isEditing = true,
- isLocation = true,
isReply = false,
- locationType = Composer.LocationType.PinDrop,
+ messageType = Composer.MessageType.LocationPin,
)
)
}
diff --git a/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/show/ShowLocationPresenterTest.kt b/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/show/ShowLocationPresenterTest.kt
index 12ccdc16a5..9eb0c3e1e2 100644
--- a/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/show/ShowLocationPresenterTest.kt
+++ b/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/show/ShowLocationPresenterTest.kt
@@ -27,12 +27,17 @@ import io.element.android.features.location.impl.common.permissions.PermissionsP
import io.element.android.features.location.impl.common.permissions.PermissionsPresenterFake
import io.element.android.features.location.impl.common.permissions.PermissionsState
import io.element.android.libraries.matrix.test.core.aBuildMeta
+import io.element.android.tests.testutils.WarmUpRule
import kotlinx.coroutines.delay
import kotlinx.coroutines.test.runTest
+import org.junit.Rule
import org.junit.Test
class ShowLocationPresenterTest {
+ @get:Rule
+ val warmUpRule = WarmUpRule()
+
private val permissionsPresenterFake = PermissionsPresenterFake()
private val fakeLocationActions = FakeLocationActions()
private val fakeBuildMeta = aBuildMeta(applicationName = "app name")
diff --git a/features/login/impl/src/main/res/values-de/translations.xml b/features/login/impl/src/main/res/values-de/translations.xml
index 7464e17f8d..cf247877fe 100644
--- a/features/login/impl/src/main/res/values-de/translations.xml
+++ b/features/login/impl/src/main/res/values-de/translations.xml
@@ -1,47 +1,47 @@
- "Kontoanbieter wechseln"
- "Adresse des Homeservers"
+ "Kontoanbieter ändern"
+ "Homeserver-Adresse"
"Gib einen Suchbegriff oder eine Domainadresse ein."
"Suche nach einem Unternehmen, einer Community oder einem privaten Server."
- "Finde einen Kontoanbieter"
- "Hier werden deine Konversationen stattfinden — genauso wie du einen E-Mail-Anbieter verwenden würdest, um deine E-Mails aufzubewahren."
- "Du bist dabei dich bei %s anzumelden"
- "Hier werden deine Konversationen stattfinden — genauso wie du einen E-Mail-Anbieter verwenden würdest, um deine E-Mails aufzubewahren."
- "Du bist dabei ein Konto auf %s zu erstellen"
- "Matrix.org ist ein großer, kostenloser Server im öffentlichen Matrix-Netzwerk für sichere, dezentrale Kommunikation, der von der Matrix.org Foundation betrieben wird."
- "Andere"
- "Verwende einen anderen Kontoanbieter, z. B. deinen eigenen privaten Server oder ein Arbeitskonto."
- "Kontoanbieter ändern"
- "Wir konnten diesen Homeserver nicht erreichen. Bitte überprüfe, dass du die Homeserver-URL korrekt eingegeben hast. Wenn die URL korrekt ist, wende dich an deinen Homeserver-Administrator für weitere Hilfe."
- "Dieser Server unterstützt derzeit keine Sliding Sync."
+ "Kontoanbieter finden"
+ "Hier werden Ihre Gespräche gespeichert – genau so, wie du einen E-Mail-Anbieter nutzen würdest, um deine E-Mails aufzubewahren."
+ "Du bist dabei, dich bei %s anzumelden"
+ "Hier werden deine Gespräche gespeichert – genau so, wie du einen E-Mail-Anbieter nutzen würdest, um deine E-Mails aufzubewahren."
+ "Du bist dabei, ein Konto bei %s zu erstellen"
+ "Matrix.org ist ein großer, kostenloser Server im öffentlichen Matrix-Netzwerk für eine sichere, dezentralisierte Kommunikation, der von der Matrix.org Foundation betrieben wird."
+ "Sonstige"
+ "Verwende einen anderen Kontoanbieter, z. B. deinen eigenen privaten Server oder ein Geschäftskonto."
+ "Kontoanbieter wechseln"
+ "Wir konnten diesen Homeserver nicht erreichen. Bitte überprüfe, ob du die Homeserver-URL korrekt eingegeben hast. Wenn die URL korrekt ist, wende dich an deinen Homeserver-Administrator, um weitere Hilfe zu erhalten."
+ "Dieser Server unterstützt derzeit kein Sliding Sync."
"Homeserver-URL"
- "Du kannst dich nur mit einem existierenden Server verbinden, der Sliding Sync unterstützt. Dein Homeserver-Administrator muss es konfigurieren. %1$s"
- "Wie lautet die Adresse deines Servers?"
+ "Du kannst nur eine Verbindung zu einem vorhandenen Server herstellen, der Sliding Sync unterstützt. Dein Homeserver-Administrator muss das konfigurieren. %1$s"
+ "Wie lautet die Adresse Ihres Servers?"
"Dieses Konto wurde deaktiviert."
"Falscher Benutzername und/oder Passwort"
- "Dies ist kein gültiger Benutzeridentifikator. Erwartetes Format: \'@user:homeserver.org\'"
- "Der ausgewählte Homeserver unterstützt kein Passwort- oder OIDC-Login. Bitte kontaktiere deinen Admin oder wähle einen anderen Homeserver."
- "Gib deine Daten ein"
+ "Dies ist keine gültige Benutzerkennung. Erwartetes Format: \'@user:homeserver.org\'"
+ "Der ausgewählte Homeserver unterstützt weder den Login per Passwort noch per OIDC. Bitte kontaktiere deinen Administrator oder wähle einen anderen Homeserver."
+ "Gebe deine Daten ein"
"Willkommen zurück!"
- "Bei %1$s anmelden"
+ "Anmelden bei %1$s"
"Kontoanbieter wechseln"
- "Ein privater Server für Element-Mitarbeiter."
- "Matrix ist ein offenes Netzwerk für sichere, dezentrale Kommunikation"
- "Hier werden deine Konversationen stattfinden — genau so wie du einen E-Mail-Anbieter verwenden würdest, um deine E-Mails aufzubewahren."
- "Du bist dabei dich bei %1$s anzumelden"
- "Du bist dabei ein Konto auf %1$s zu erstellen"
- "Im Moment besteht eine hohe Nachfrage nach %1$s auf %2$s. Besuche die App in ein paar Tagen wieder und versuche es erneut.
+ "Ein privater Server für die Mitarbeiter von Element."
+ "Matrix ist ein offenes Netzwerk für eine sichere, dezentrale Kommunikation."
+ "Hier werden Ihre Gespräche gespeichert - so wie Sie Ihre E-Mails bei einem E-Mail-Anbieter aufbewahren würden."
+ "Sie sind dabei, sich bei %1$s anzumelden"
+ "Sie sind dabei, ein Konto auf %1$s zu erstellen"
+ "Derzeit besteht eine hohe Nachfrage nach %1$s auf %2$s. Kehren Sie in ein paar Tagen zur App zurück und versuchen Sie es erneut.
-Vielen Dank für deine Geduld!"
+Danke für Ihre Geduld!"
"Willkommen bei %1$s!"
- "Du hast es fast geschafft!"
- "Du bist dabei."
+ "Sie sind fast am Ziel."
+ "Sie sind dabei."
"Weiter"
"Weiter"
- "Wählen deinen Server"
+ "Wähle deinen Server aus"
"Passwort"
"Weiter"
- "Matrix ist ein offenes Netzwerk für sichere, dezentrale Kommunikation"
+ "Matrix ist ein offenes Netzwerk für eine sichere, dezentrale Kommunikation."
"Benutzername"
diff --git a/features/login/impl/src/main/res/values-fr/translations.xml b/features/login/impl/src/main/res/values-fr/translations.xml
index 74f23cc5c9..a566198a45 100644
--- a/features/login/impl/src/main/res/values-fr/translations.xml
+++ b/features/login/impl/src/main/res/values-fr/translations.xml
@@ -1,39 +1,40 @@
- "Changer de fournisseur"
- "Adresse du serveur d\'accueil"
- "Entrez un mot clé de recherche ou un nom de domaine."
- "Rechercher une entreprise, une communauté ou un serveur privé."
- "Trouver un fournisseur de services"
- "C\'est ici que vos conversations seront stockées - tout comme vous utiliseriez un fournisseur de messagerie pour conserver vos e-mails."
+ "Changer de fournisseur de compte"
+ "Adresse du serveur d’accueil"
+ "Entrez un terme de recherche ou une adresse de domaine."
+ "Recherchez une entreprise, une communauté ou un serveur privé."
+ "Trouver un fournisseur de comptes"
+ "C’est ici que vos conversations seront enregistrées, comme vous le feriez avec un fournisseur de messagerie pour conserver vos e-mails."
"Vous êtes sur le point de vous connecter à %s"
- "C\'est ici que vos conversations seront stockées - tout comme vous utiliseriez un fournisseur de messagerie pour conserver vos e-mails."
+ "C’est ici que vos conversations seront enregistrées, comme vous le feriez avec un fournisseur de messagerie pour conserver vos e-mails."
"Vous êtes sur le point de créer un compte sur %s"
- "Autre"
- "Utilisez un autre fournisseur de compte, tel que votre propre serveur ou un compte professionnel."
- "Changer de fournisseur"
- "Nous n\'avons pas pu atteindre ce serveur domestique. Vérifiez que vous avez correctement saisi l\'URL du serveur d\'accueil. Si l\'URL est correcte, contactez l\'administrateur de votre serveur domestique pour obtenir de l\'aide."
+ "Matrix.org est un grand serveur gratuit sur le réseau public Matrix pour une communication sécurisée et décentralisée, géré par la Fondation Matrix.org."
+ "Autres"
+ "Utilisez un autre fournisseur de compte, tel que votre propre serveur privé ou un serveur professionnel."
+ "Changer de fournisseur de compte"
+ "Nous n’avons pas pu atteindre ce serveur d’accueil. Vérifiez que vous avez correctement saisi l’URL du serveur d’accueil. Si l’URL est correcte, contactez l’administrateur de votre serveur d’accueil pour obtenir de l’aide."
"Ce serveur ne prend actuellement pas en charge la synchronisation glissante."
- "URL du serveur d\'accueil"
- "Vous ne pouvez vous connecter qu\'à un serveur existant qui prend en charge la synchronisation glissante. L\'administrateur de votre serveur domestique devra la configurer. %1$s"
- "Quelle est l\'adresse de votre serveur ?"
+ "URL du serveur d’accueil"
+ "Vous ne pouvez vous connecter qu’à un serveur existant qui prend en charge le sliding sync. L’administrateur de votre serveur d’accueil devra le configurer. %1$s"
+ "Quelle est l’adresse de votre serveur ?"
"Ce compte a été désactivé."
- "Nom d\'utilisateur et/ou mot de passe incorrect"
- "Il ne s\'agit pas d\'un identifiant utilisateur valide. Format attendu : « @user:homeserver.org »"
- "Le serveur domestique sélectionné ne prend pas en charge le mot de passe ou la connexion OIDC. Contactez votre administrateur ou choisissez un autre serveur domestique."
- "Saisir vos informations personnelles"
- "Heureux de vous revoir!"
- "Se connecter à %1$s"
+ "Nom d’utilisateur et/ou mot de passe incorrects"
+ "Il ne s’agit pas d’un identifiant utilisateur valide. Format attendu : « @user:homeserver.org »"
+ "Le serveur d’accueil sélectionné ne prend pas en charge le mot de passe ou la connexion OIDC. Contactez votre administrateur ou choisissez un autre serveur d’accueil."
+ "Saisissez vos identifiants"
+ "Content de vous revoir !"
+ "Connectez-vous à %1$s"
"Changer de fournisseur de compte"
"Un serveur privé pour les employés d’Element."
- "Matrix est un réseau ouvert de communication sécurisée et décentralisée."
- "C\'est là que vos conversations seront conservées — de la même manière que votre service d’e-mail habituel conserverait vos e-mails."
- "Vous allez vous connecter à %1$s"
- "Vous allez créer un compte sur %1$s"
- "Il y a une forte demande pour %1$s sur %2$s en ce moment. Rouvrez l’app dans quelques jours et réessayez.
+ "Matrix est un réseau ouvert pour une communication sécurisée et décentralisée."
+ "C’est ici que vos conversations seront enregistrées, comme vous le feriez avec un fournisseur de messagerie pour conserver vos e-mails."
+ "Vous êtes sur le point de vous connecter à %1$s"
+ "Vous êtes sur le point de créer un compte sur %1$s"
+ "Il y a une forte demande pour %1$s sur %2$s à l’heure actuelle. Revenez sur l’application dans quelques jours et réessayez.
-Merci de votre patience !"
- "Bienvenue sur %1$s !"
+Merci pour votre patience !"
+ "Bienvenue dans %1$s !"
"Vous y êtes presque."
"Vous y êtes."
"Continuer"
@@ -41,6 +42,6 @@ Merci de votre patience !"
"Sélectionnez votre serveur"
"Mot de passe"
"Continuer"
- "Matrix est un réseau ouvert de communication sécurisée et décentralisée."
- "Nom d\'utilisateur"
+ "Matrix est un réseau ouvert pour une communication sécurisée et décentralisée."
+ "Nom d’utilisateur"
diff --git a/features/login/impl/src/main/res/values-ro/translations.xml b/features/login/impl/src/main/res/values-ro/translations.xml
index 0c58c45d03..e780269a39 100644
--- a/features/login/impl/src/main/res/values-ro/translations.xml
+++ b/features/login/impl/src/main/res/values-ro/translations.xml
@@ -5,10 +5,11 @@
"Introduceţi un termen de căutare sau o adresă de domeniu."
"Căutați o companie, o comunitate sau un server privat."
"Găsiți un furnizor de cont"
- "Aici vor trăi conversațiile - la fel cum ați folosi un furnizor de e-mail pentru a vă păstra e-mailurile."
+ "Aici vor trăi conversațiile dumneavoastră - la fel cum ați folosi un furnizor de e-mail pentru a vă păstra e-mailurile."
"Sunteți pe cale să vă conectați la %s"
- "Aici vor trăi conversațiile - la fel cum ați folosi un furnizor de e-mail pentru a vă păstra e-mailurile."
+ "Aici vor trăi conversațiile dumneavoastră - la fel cum ați folosi un furnizor de e-mail pentru a vă păstra e-mailurile."
"Sunteți pe cale să creați un cont pe %s"
+ "Matrix.org este un server mare și gratuit din rețeaua publică Matrix pentru comunicații sigure și descentralizate, administrat de Fundația Matrix.org."
"Altul"
"Utilizați un alt furnizor de cont, cum ar fi propriul server privat sau un cont de serviciu."
"Schimbați furnizorul contului"
diff --git a/features/login/impl/src/main/res/values-ru/translations.xml b/features/login/impl/src/main/res/values-ru/translations.xml
index 242ec4bb2c..3ecb8a52d0 100644
--- a/features/login/impl/src/main/res/values-ru/translations.xml
+++ b/features/login/impl/src/main/res/values-ru/translations.xml
@@ -9,6 +9,7 @@
"Вы собираетесь войти в %s"
"Здесь будут храниться ваши разговоры - точно так же, как вы используете почтового провайдера для хранения своих писем."
"Вы собираетесь создать учетную запись на %s"
+ "Matrix.org — это большой бесплатный сервер в общедоступной сети Matrix для безопасной децентрализованной связи, управляемый Matrix.org Foundation."
"Другое"
"Используйте другого поставщика учетных записей, например, собственный частный сервер или рабочую учетную запись."
"Сменить поставщика учетной записи"
diff --git a/features/login/impl/src/main/res/values-sk/translations.xml b/features/login/impl/src/main/res/values-sk/translations.xml
index 14a5407055..e94990554f 100644
--- a/features/login/impl/src/main/res/values-sk/translations.xml
+++ b/features/login/impl/src/main/res/values-sk/translations.xml
@@ -9,6 +9,7 @@
"Chystáte sa prihlásiť do %s"
"Tu budú žiť vaše konverzácie — podobne ako používate poskytovateľa e-mailových služieb na uchovávanie e-mailov."
"Chystáte sa vytvoriť účet na %s"
+ "Matrix.org je veľký bezplatný server vo verejnej sieti Matrix na bezpečnú, decentralizovanú komunikáciu, ktorý prevádzkuje nadácia Matrix.org."
"Iný"
"Použite iného poskytovateľa účtu, ako napríklad vlastný súkromný server alebo pracovný účet."
"Zmeniť poskytovateľa účtu"
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 d3dca0942a..e4bfd61b35 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
@@ -1,10 +1,11 @@
- "您即將登入%s"
+ "您即將登入 %s"
"您即將在 %s 建立帳號"
"其他"
"此伺服器目前不支援 sliding sync。"
"家伺服器 URL"
+ "輸入您的詳細資料"
"歡迎回來!"
"登入 %1$s"
"您即將登入 %1$s"
diff --git a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/changeserver/ChangeServerPresenterTest.kt b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/changeserver/ChangeServerPresenterTest.kt
index 009ab31dcd..f94ce4e65d 100644
--- a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/changeserver/ChangeServerPresenterTest.kt
+++ b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/changeserver/ChangeServerPresenterTest.kt
@@ -26,10 +26,16 @@ import io.element.android.libraries.architecture.Async
import io.element.android.libraries.matrix.test.A_HOMESERVER
import io.element.android.libraries.matrix.test.A_HOMESERVER_URL
import io.element.android.libraries.matrix.test.auth.FakeAuthenticationService
+import io.element.android.tests.testutils.WarmUpRule
import kotlinx.coroutines.test.runTest
+import org.junit.Rule
import org.junit.Test
class ChangeServerPresenterTest {
+
+ @get:Rule
+ val warmUpRule = WarmUpRule()
+
@Test
fun `present - initial state`() = runTest {
val presenter = ChangeServerPresenter(
diff --git a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/oidc/webview/OidcPresenterTest.kt b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/oidc/webview/OidcPresenterTest.kt
index 32d1c6918a..70d6da7783 100644
--- a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/oidc/webview/OidcPresenterTest.kt
+++ b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/oidc/webview/OidcPresenterTest.kt
@@ -27,11 +27,17 @@ import io.element.android.libraries.architecture.Async
import io.element.android.libraries.matrix.test.A_THROWABLE
import io.element.android.libraries.matrix.test.auth.A_OIDC_DATA
import io.element.android.libraries.matrix.test.auth.FakeAuthenticationService
+import io.element.android.tests.testutils.WarmUpRule
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.runTest
+import org.junit.Rule
import org.junit.Test
class OidcPresenterTest {
+
+ @get:Rule
+ val warmUpRule = WarmUpRule()
+
@Test
fun `present - initial state`() = runTest {
val presenter = OidcPresenter(
diff --git a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/changeaccountprovider/ChangeAccountProviderPresenterTest.kt b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/changeaccountprovider/ChangeAccountProviderPresenterTest.kt
index f807355cb1..edf198fe35 100644
--- a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/changeaccountprovider/ChangeAccountProviderPresenterTest.kt
+++ b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/changeaccountprovider/ChangeAccountProviderPresenterTest.kt
@@ -24,10 +24,16 @@ import io.element.android.features.login.impl.accountprovider.AccountProvider
import io.element.android.features.login.impl.accountprovider.AccountProviderDataSource
import io.element.android.features.login.impl.changeserver.ChangeServerPresenter
import io.element.android.libraries.matrix.test.auth.FakeAuthenticationService
+import io.element.android.tests.testutils.WarmUpRule
import kotlinx.coroutines.test.runTest
+import org.junit.Rule
import org.junit.Test
class ChangeAccountProviderPresenterTest {
+
+ @get:Rule
+ val warmUpRule = WarmUpRule()
+
@Test
fun `present - initial state`() = runTest {
val changeServerPresenter = ChangeServerPresenter(
diff --git a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderPresenterTest.kt b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderPresenterTest.kt
index 95cd9bf053..f348839c97 100644
--- a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderPresenterTest.kt
+++ b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderPresenterTest.kt
@@ -31,11 +31,17 @@ import io.element.android.libraries.matrix.test.A_HOMESERVER
import io.element.android.libraries.matrix.test.A_HOMESERVER_OIDC
import io.element.android.libraries.matrix.test.A_THROWABLE
import io.element.android.libraries.matrix.test.auth.FakeAuthenticationService
+import io.element.android.tests.testutils.WarmUpRule
import io.element.android.tests.testutils.waitForPredicate
import kotlinx.coroutines.test.runTest
+import org.junit.Rule
import org.junit.Test
class ConfirmAccountProviderPresenterTest {
+
+ @get:Rule
+ val warmUpRule = WarmUpRule()
+
@Test
fun `present - initial test`() = runTest {
val presenter = createConfirmAccountProviderPresenter()
diff --git a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/loginpassword/LoginPasswordPresenterTest.kt b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/loginpassword/LoginPasswordPresenterTest.kt
index 421eb869b0..a2b0fae449 100644
--- a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/loginpassword/LoginPasswordPresenterTest.kt
+++ b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/loginpassword/LoginPasswordPresenterTest.kt
@@ -31,10 +31,16 @@ import io.element.android.libraries.matrix.test.A_SESSION_ID
import io.element.android.libraries.matrix.test.A_THROWABLE
import io.element.android.libraries.matrix.test.A_USER_NAME
import io.element.android.libraries.matrix.test.auth.FakeAuthenticationService
+import io.element.android.tests.testutils.WarmUpRule
import kotlinx.coroutines.test.runTest
+import org.junit.Rule
import org.junit.Test
class LoginPasswordPresenterTest {
+
+ @get:Rule
+ val warmUpRule = WarmUpRule()
+
@Test
fun `present - initial state`() = runTest {
val authenticationService = FakeAuthenticationService()
diff --git a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/searchaccountprovider/SearchAccountProviderPresenterTest.kt b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/searchaccountprovider/SearchAccountProviderPresenterTest.kt
index ae6ae4d344..84bd6925db 100644
--- a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/searchaccountprovider/SearchAccountProviderPresenterTest.kt
+++ b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/searchaccountprovider/SearchAccountProviderPresenterTest.kt
@@ -30,11 +30,17 @@ import io.element.android.features.login.impl.resolver.network.WellKnownSlidingS
import io.element.android.libraries.architecture.Async
import io.element.android.libraries.matrix.test.A_HOMESERVER_URL
import io.element.android.libraries.matrix.test.auth.FakeAuthenticationService
+import io.element.android.tests.testutils.WarmUpRule
import io.element.android.tests.testutils.testCoroutineDispatchers
import kotlinx.coroutines.test.runTest
+import org.junit.Rule
import org.junit.Test
class SearchAccountProviderPresenterTest {
+
+ @get:Rule
+ val warmUpRule = WarmUpRule()
+
@Test
fun `present - initial state`() = runTest {
val fakeWellknownRequest = FakeWellknownRequest()
diff --git a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/waitlistscreen/WaitListPresenterTest.kt b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/waitlistscreen/WaitListPresenterTest.kt
index 507e8cec8b..590fa8efc0 100644
--- a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/waitlistscreen/WaitListPresenterTest.kt
+++ b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/waitlistscreen/WaitListPresenterTest.kt
@@ -30,10 +30,16 @@ import io.element.android.libraries.matrix.test.A_THROWABLE
import io.element.android.libraries.matrix.test.A_USER_ID
import io.element.android.libraries.matrix.test.auth.FakeAuthenticationService
import io.element.android.libraries.matrix.test.core.aBuildMeta
+import io.element.android.tests.testutils.WarmUpRule
import kotlinx.coroutines.test.runTest
+import org.junit.Rule
import org.junit.Test
class WaitListPresenterTest {
+
+ @get:Rule
+ val warmUpRule = WarmUpRule()
+
@Test
fun `present - initial state`() = runTest {
val authenticationService = FakeAuthenticationService().apply {
diff --git a/features/logout/api/src/main/res/values-de/translations.xml b/features/logout/api/src/main/res/values-de/translations.xml
index 0cd8ac389a..893cc983b0 100644
--- a/features/logout/api/src/main/res/values-de/translations.xml
+++ b/features/logout/api/src/main/res/values-de/translations.xml
@@ -1,8 +1,8 @@
- "Möchtest du Dich wirklich abmelden?"
+ "Sind Sie sicher, dass Sie sich abmelden wollen?"
"Abmelden"
- "Abmeldung läuft…"
+ "Abmelden…"
"Abmelden"
"Abmelden"
diff --git a/features/logout/api/src/main/res/values-fr/translations.xml b/features/logout/api/src/main/res/values-fr/translations.xml
index b6d5137072..16c9d3717e 100644
--- a/features/logout/api/src/main/res/values-fr/translations.xml
+++ b/features/logout/api/src/main/res/values-fr/translations.xml
@@ -1,8 +1,8 @@
- "Êtes-vous sûr de vouloir vous déconnecter?"
+ "Êtes-vous sûr de vouloir vous déconnecter ?"
"Se déconnecter"
- "Déconnexion en cours…"
+ "Déconnexion…"
"Se déconnecter"
"Se déconnecter"
diff --git a/features/logout/impl/build.gradle.kts b/features/logout/impl/build.gradle.kts
index 464695e169..52a9f76b41 100644
--- a/features/logout/impl/build.gradle.kts
+++ b/features/logout/impl/build.gradle.kts
@@ -48,4 +48,5 @@ dependencies {
testImplementation(libs.test.truth)
testImplementation(libs.test.turbine)
testImplementation(projects.libraries.matrix.test)
+ testImplementation(projects.tests.testutils)
}
diff --git a/features/logout/impl/src/test/kotlin/io/element/android/features/logout/impl/LogoutPreferencePresenterTest.kt b/features/logout/impl/src/test/kotlin/io/element/android/features/logout/impl/LogoutPreferencePresenterTest.kt
index 29df521cb1..52e673cba6 100644
--- a/features/logout/impl/src/test/kotlin/io/element/android/features/logout/impl/LogoutPreferencePresenterTest.kt
+++ b/features/logout/impl/src/test/kotlin/io/element/android/features/logout/impl/LogoutPreferencePresenterTest.kt
@@ -25,10 +25,16 @@ import io.element.android.features.logout.api.LogoutPreferenceState
import io.element.android.libraries.architecture.Async
import io.element.android.libraries.matrix.test.A_THROWABLE
import io.element.android.libraries.matrix.test.FakeMatrixClient
+import io.element.android.tests.testutils.WarmUpRule
import kotlinx.coroutines.test.runTest
+import org.junit.Rule
import org.junit.Test
class LogoutPreferencePresenterTest {
+
+ @get:Rule
+ val warmUpRule = WarmUpRule()
+
@Test
fun `present - initial state`() = runTest {
val presenter = DefaultLogoutPreferencePresenter(
diff --git a/features/messages/api/build.gradle.kts b/features/messages/api/build.gradle.kts
index 756014e97d..9e890265ec 100644
--- a/features/messages/api/build.gradle.kts
+++ b/features/messages/api/build.gradle.kts
@@ -25,5 +25,5 @@ android {
dependencies {
implementation(projects.libraries.architecture)
implementation(projects.libraries.matrix.api)
- api(projects.libraries.textcomposer)
+ api(projects.libraries.textcomposer.impl)
}
diff --git a/features/messages/impl/build.gradle.kts b/features/messages/impl/build.gradle.kts
index 1a61a2d8b6..cd0b6dda2d 100644
--- a/features/messages/impl/build.gradle.kts
+++ b/features/messages/impl/build.gradle.kts
@@ -41,13 +41,14 @@ dependencies {
implementation(projects.libraries.matrix.api)
implementation(projects.libraries.matrixui)
implementation(projects.libraries.designsystem)
- implementation(projects.libraries.textcomposer)
+ implementation(projects.libraries.textcomposer.impl)
implementation(projects.libraries.uiStrings)
implementation(projects.libraries.dateformatter.api)
implementation(projects.libraries.eventformatter.api)
implementation(projects.libraries.mediapickers.api)
implementation(projects.libraries.featureflag.api)
implementation(projects.libraries.mediaupload.api)
+ implementation(projects.libraries.preferences.api)
implementation(projects.features.networkmonitor.api)
implementation(projects.services.analytics.api)
implementation(libs.coil.compose)
@@ -76,6 +77,8 @@ dependencies {
testImplementation(projects.libraries.featureflag.test)
testImplementation(projects.libraries.mediaupload.test)
testImplementation(projects.libraries.mediapickers.test)
+ testImplementation(projects.libraries.preferences.test)
+ testImplementation(projects.libraries.textcomposer.test)
testImplementation(libs.test.mockk)
ksp(libs.showkase.processor)
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 5f67fd1f7c..1dc46aa2bd 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
@@ -30,6 +30,7 @@ import androidx.compose.runtime.setValue
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
+import im.vector.app.features.analytics.plan.PollEnd
import io.element.android.features.messages.impl.actionlist.ActionListEvents
import io.element.android.features.messages.impl.actionlist.ActionListPresenter
import io.element.android.features.messages.impl.actionlist.model.TimelineItemAction
@@ -56,10 +57,10 @@ import io.element.android.features.messages.impl.timeline.model.event.TimelineIt
import io.element.android.features.messages.impl.utils.messagesummary.MessageSummaryFormatter
import io.element.android.features.networkmonitor.api.NetworkMonitor
import io.element.android.features.networkmonitor.api.NetworkStatus
+import io.element.android.features.preferences.api.store.PreferencesStore
import io.element.android.libraries.androidutils.clipboard.ClipboardHelper
import io.element.android.libraries.architecture.Async
import io.element.android.libraries.architecture.Presenter
-import io.element.android.libraries.architecture.runCatchingUpdatingState
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
@@ -75,6 +76,7 @@ import io.element.android.libraries.matrix.ui.components.AttachmentThumbnailType
import io.element.android.libraries.matrix.ui.room.canRedactAsState
import io.element.android.libraries.matrix.ui.room.canSendMessageAsState
import io.element.android.libraries.textcomposer.MessageComposerMode
+import io.element.android.services.analytics.api.AnalyticsService
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
@@ -93,6 +95,8 @@ class MessagesPresenter @AssistedInject constructor(
private val messageSummaryFormatter: MessageSummaryFormatter,
private val dispatchers: CoroutineDispatchers,
private val clipboardHelper: ClipboardHelper,
+ private val analyticsService: AnalyticsService,
+ private val preferencesStore: PreferencesStore,
@Assisted private val navigator: MessagesNavigator,
) : Presenter {
@@ -141,10 +145,17 @@ class MessagesPresenter @AssistedInject constructor(
timelineState.eventSink(TimelineEvents.SetHighlightedEvent(composerState.mode.relatedEventId))
}
+ val enableTextFormatting by preferencesStore.isRichTextEditorEnabledFlow().collectAsState(initial = true)
+
fun handleEvents(event: MessagesEvents) {
when (event) {
is MessagesEvents.HandleAction -> {
- localCoroutineScope.handleTimelineAction(event.action, event.event, composerState)
+ localCoroutineScope.handleTimelineAction(
+ action = event.action,
+ targetEvent = event.event,
+ composerState = composerState,
+ enableTextFormatting = enableTextFormatting,
+ )
}
is MessagesEvents.ToggleReaction -> {
localCoroutineScope.toggleReaction(event.emoji, event.eventId)
@@ -176,7 +187,8 @@ class MessagesPresenter @AssistedInject constructor(
snackbarMessage = snackbarMessage,
showReinvitePrompt = showReinvitePrompt,
inviteProgress = inviteProgress.value,
- eventSink = ::handleEvents
+ enableTextFormatting = enableTextFormatting,
+ eventSink = { handleEvents(it) }
)
}
@@ -193,13 +205,15 @@ class MessagesPresenter @AssistedInject constructor(
action: TimelineItemAction,
targetEvent: TimelineItem.Event,
composerState: MessageComposerState,
+ enableTextFormatting: Boolean,
) = launch {
when (action) {
TimelineItemAction.Copy -> handleCopyContents(targetEvent)
TimelineItemAction.Redact -> handleActionRedact(targetEvent)
- TimelineItemAction.Edit -> handleActionEdit(targetEvent, composerState)
- TimelineItemAction.Reply -> handleActionReply(targetEvent, composerState)
- TimelineItemAction.Developer -> handleShowDebugInfoAction(targetEvent)
+ TimelineItemAction.Edit -> handleActionEdit(targetEvent, composerState, enableTextFormatting)
+ TimelineItemAction.Reply,
+ TimelineItemAction.ReplyInThread -> handleActionReply(targetEvent, composerState)
+ TimelineItemAction.ViewSource -> handleShowDebugInfoAction(targetEvent)
TimelineItemAction.Forward -> handleForwardAction(targetEvent)
TimelineItemAction.ReportContent -> handleReportAction(targetEvent)
TimelineItemAction.EndPoll -> handleEndPollAction(targetEvent)
@@ -215,7 +229,8 @@ class MessagesPresenter @AssistedInject constructor(
}
private fun CoroutineScope.reinviteOtherUser(inviteProgress: MutableState>) = launch(dispatchers.io) {
- suspend {
+ inviteProgress.value = Async.Loading()
+ runCatching {
room.updateMembers()
val memberList = when (val memberState = room.membersStateFlow.value) {
@@ -228,7 +243,14 @@ class MessagesPresenter @AssistedInject constructor(
room.inviteUserById(member.userId).onFailure { t ->
Timber.e(t, "Failed to reinvite DM partner")
}.getOrThrow()
- }.runCatchingUpdatingState(inviteProgress)
+ }.fold(
+ onSuccess = {
+ inviteProgress.value = Async.Success(Unit)
+ },
+ onFailure = {
+ inviteProgress.value = Async.Failure(it)
+ }
+ )
}
private suspend fun handleActionRedact(event: TimelineItem.Event) {
@@ -240,10 +262,20 @@ class MessagesPresenter @AssistedInject constructor(
}
}
- private fun handleActionEdit(targetEvent: TimelineItem.Event, composerState: MessageComposerState) {
+ private suspend fun handleActionEdit(
+ targetEvent: TimelineItem.Event,
+ composerState: MessageComposerState,
+ enableTextFormatting: Boolean,
+ ) {
val composerMode = MessageComposerMode.Edit(
targetEvent.eventId,
- (targetEvent.content as? TimelineItemTextBasedContent)?.body.orEmpty(),
+ (targetEvent.content as? TimelineItemTextBasedContent)?.let {
+ if (enableTextFormatting) {
+ it.htmlBody ?: it.body
+ } else {
+ it.body
+ }
+ }.orEmpty(),
targetEvent.transactionId,
)
composerState.eventSink(
@@ -287,6 +319,7 @@ class MessagesPresenter @AssistedInject constructor(
is TimelineItemUnknownContent -> null
}
val composerMode = MessageComposerMode.Reply(
+ isThreaded = targetEvent.isThreaded,
senderName = targetEvent.safeSenderName,
eventId = targetEvent.eventId,
attachmentThumbnailInfo = attachmentThumbnailInfo,
@@ -312,8 +345,10 @@ class MessagesPresenter @AssistedInject constructor(
}
private suspend fun handleEndPollAction(event: TimelineItem.Event) {
- event.eventId?.let { room.endPoll(it, "The poll with event id: $it has ended.") }
- // TODO Polls: Send poll end analytic
+ event.eventId?.let {
+ room.endPoll(it, "The poll with event id: $it has ended.")
+ analyticsService.capture(PollEnd())
+ }
}
private suspend fun handleCopyContents(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 d22d54e7f3..46aad1e191 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
@@ -45,5 +45,6 @@ data class MessagesState(
val snackbarMessage: SnackbarMessage?,
val inviteProgress: Async,
val showReinvitePrompt: Boolean,
+ val enableTextFormatting: Boolean,
val eventSink: (MessagesEvents) -> Unit
)
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesStateProvider.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesStateProvider.kt
index 7eb1a0984e..a88ebcbcd8 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesStateProvider.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesStateProvider.kt
@@ -30,6 +30,7 @@ import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.textcomposer.MessageComposerMode
+import io.element.android.wysiwyg.compose.RichTextEditorState
import kotlinx.collections.immutable.persistentSetOf
open class MessagesStateProvider : PreviewParameterProvider {
@@ -54,7 +55,9 @@ fun aMessagesState() = MessagesState(
userHasPermissionToSendMessage = true,
userHasPermissionToRedact = false,
composerState = aMessageComposerState().copy(
- text = "Hello",
+ richTextEditorState = RichTextEditorState("Hello", fake = true).apply {
+ requestFocus()
+ },
isFullScreen = false,
mode = MessageComposerMode.Normal("Hello"),
),
@@ -79,5 +82,6 @@ fun aMessagesState() = MessagesState(
snackbarMessage = null,
inviteProgress = Async.Uninitialized,
showReinvitePrompt = false,
+ enableTextFormatting = true,
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 9810845228..1b4b8f7e19 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
@@ -123,7 +123,13 @@ fun MessagesView(
fun onMessageLongClicked(event: TimelineItem.Event) {
Timber.v("OnMessageLongClicked= ${event.id}")
localView.hideKeyboard()
- state.actionListState.eventSink(ActionListEvents.ComputeForMessage(event, state.userHasPermissionToRedact))
+ state.actionListState.eventSink(
+ ActionListEvents.ComputeForMessage(
+ event = event,
+ canRedact = state.userHasPermissionToRedact,
+ canSendMessage = state.userHasPermissionToSendMessage,
+ )
+ )
}
fun onActionSelected(action: TimelineItemAction, event: TimelineItem.Event) {
@@ -203,8 +209,8 @@ fun MessagesView(
CustomReactionBottomSheet(
state = state.customReactionState,
onEmojiSelected = { eventId, emoji ->
- state.eventSink(MessagesEvents.ToggleReaction(emoji.unicode, eventId))
- state.customReactionState.eventSink(CustomReactionEvents.DismissCustomReactionSheet)
+ state.eventSink(MessagesEvents.ToggleReaction(emoji.unicode, eventId))
+ state.customReactionState.eventSink(CustomReactionEvents.DismissCustomReactionSheet)
}
)
@@ -298,6 +304,7 @@ private fun MessagesViewContent(
state = state.composerState,
onSendLocationClicked = onSendLocationClicked,
onCreatePollClicked = onCreatePollClicked,
+ enableTextFormatting = state.enableTextFormatting,
modifier = Modifier
.fillMaxWidth()
.wrapContentHeight(Alignment.Bottom)
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/ActionListEvents.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/ActionListEvents.kt
index c5e6618736..7c8fad6c7c 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/ActionListEvents.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/ActionListEvents.kt
@@ -20,5 +20,9 @@ import io.element.android.features.messages.impl.timeline.model.TimelineItem
sealed interface ActionListEvents {
data object Clear : ActionListEvents
- data class ComputeForMessage(val event: TimelineItem.Event, val canRedact: Boolean) : ActionListEvents
+ data class ComputeForMessage(
+ val event: TimelineItem.Event,
+ val canRedact: Boolean,
+ val canSendMessage: Boolean,
+ ) : ActionListEvents
}
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/ActionListPresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/ActionListPresenter.kt
index f5e28818c9..588adb1020 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/ActionListPresenter.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/ActionListPresenter.kt
@@ -18,6 +18,7 @@ package io.element.android.features.messages.impl.actionlist
import androidx.compose.runtime.Composable
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
@@ -30,15 +31,15 @@ import io.element.android.features.messages.impl.timeline.model.event.TimelineIt
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemStateContent
import io.element.android.features.messages.impl.timeline.model.event.canBeCopied
import io.element.android.features.messages.impl.timeline.model.event.canReact
+import io.element.android.features.preferences.api.store.PreferencesStore
import io.element.android.libraries.architecture.Presenter
-import io.element.android.libraries.core.meta.BuildMeta
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import javax.inject.Inject
class ActionListPresenter @Inject constructor(
- private val buildMeta: BuildMeta,
+ private val preferencesStore: PreferencesStore,
) : Presenter {
@Composable
@@ -49,6 +50,8 @@ class ActionListPresenter @Inject constructor(
mutableStateOf(ActionListState.Target.None)
}
+ val isDeveloperModeEnabled by preferencesStore.isDeveloperModeEnabledFlow().collectAsState(initial = false)
+
val displayEmojiReactions by remember {
derivedStateOf {
val event = (target.value as? ActionListState.Target.Success)?.event
@@ -62,6 +65,8 @@ class ActionListPresenter @Inject constructor(
is ActionListEvents.ComputeForMessage -> localCoroutineScope.computeForMessage(
timelineItem = event.event,
userCanRedact = event.canRedact,
+ userCanSendMessage = event.canSendMessage,
+ isDeveloperModeEnabled = isDeveloperModeEnabled,
target = target,
)
}
@@ -70,21 +75,23 @@ class ActionListPresenter @Inject constructor(
return ActionListState(
target = target.value,
displayEmojiReactions = displayEmojiReactions,
- eventSink = ::handleEvents
+ eventSink = { handleEvents(it) }
)
}
private fun CoroutineScope.computeForMessage(
timelineItem: TimelineItem.Event,
userCanRedact: Boolean,
+ userCanSendMessage: Boolean,
+ isDeveloperModeEnabled: Boolean,
target: MutableState
) = launch {
target.value = ActionListState.Target.Loading(timelineItem)
val actions =
when (timelineItem.content) {
is TimelineItemRedactedContent -> {
- if (buildMeta.isDebuggable) {
- listOf(TimelineItemAction.Developer)
+ if (isDeveloperModeEnabled) {
+ listOf(TimelineItemAction.ViewSource)
} else {
emptyList()
}
@@ -92,8 +99,8 @@ class ActionListPresenter @Inject constructor(
is TimelineItemStateContent -> {
buildList {
add(TimelineItemAction.Copy)
- if (buildMeta.isDebuggable) {
- add(TimelineItemAction.Developer)
+ if (isDeveloperModeEnabled) {
+ add(TimelineItemAction.ViewSource)
}
}
}
@@ -101,7 +108,8 @@ class ActionListPresenter @Inject constructor(
buildList {
val isMineOrCanRedact = timelineItem.isMine || userCanRedact
- // TODO Poll: Reply to poll
+ // TODO Poll: Reply to poll. Ensure to update `fun TimelineItemEventContent.canBeReplied()`
+ // when touching this
// if (timelineItem.isRemote) {
// // Can only reply or forward messages already uploaded to the server
// add(TimelineItemAction.Reply)
@@ -112,8 +120,8 @@ class ActionListPresenter @Inject constructor(
if (timelineItem.content.canBeCopied()) {
add(TimelineItemAction.Copy)
}
- if (buildMeta.isDebuggable) {
- add(TimelineItemAction.Developer)
+ if (isDeveloperModeEnabled) {
+ add(TimelineItemAction.ViewSource)
}
if (!timelineItem.isMine) {
add(TimelineItemAction.ReportContent)
@@ -126,7 +134,13 @@ class ActionListPresenter @Inject constructor(
else -> buildList {
if (timelineItem.isRemote) {
// Can only reply or forward messages already uploaded to the server
- add(TimelineItemAction.Reply)
+ if (userCanSendMessage) {
+ if (timelineItem.isThreaded) {
+ add(TimelineItemAction.ReplyInThread)
+ } else {
+ add(TimelineItemAction.Reply)
+ }
+ }
add(TimelineItemAction.Forward)
}
if (timelineItem.isMine && timelineItem.isTextMessage) {
@@ -135,8 +149,8 @@ class ActionListPresenter @Inject constructor(
if (timelineItem.content.canBeCopied()) {
add(TimelineItemAction.Copy)
}
- if (buildMeta.isDebuggable) {
- add(TimelineItemAction.Developer)
+ if (isDeveloperModeEnabled) {
+ add(TimelineItemAction.ViewSource)
}
if (!timelineItem.isMine) {
add(TimelineItemAction.ReportContent)
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/ActionListStateProvider.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/ActionListStateProvider.kt
index bb9c851288..44736bc076 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/ActionListStateProvider.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/ActionListStateProvider.kt
@@ -111,7 +111,7 @@ fun aTimelineItemActionList(): ImmutableList {
TimelineItemAction.Edit,
TimelineItemAction.Redact,
TimelineItemAction.ReportContent,
- TimelineItemAction.Developer,
+ TimelineItemAction.ViewSource,
)
}
fun aTimelineItemPollActionList(): ImmutableList {
@@ -119,7 +119,7 @@ fun aTimelineItemPollActionList(): ImmutableList {
TimelineItemAction.EndPoll,
TimelineItemAction.Reply,
TimelineItemAction.Copy,
- TimelineItemAction.Developer,
+ TimelineItemAction.ViewSource,
TimelineItemAction.ReportContent,
TimelineItemAction.Redact,
)
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/model/TimelineItemAction.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/model/TimelineItemAction.kt
index 8dc48f892e..948c0b0cb1 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/model/TimelineItemAction.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/model/TimelineItemAction.kt
@@ -32,8 +32,9 @@ sealed class TimelineItemAction(
data object Copy : TimelineItemAction(CommonStrings.action_copy, VectorIcons.Copy)
data object Redact : TimelineItemAction(CommonStrings.action_remove, VectorIcons.Delete, destructive = true)
data object Reply : TimelineItemAction(CommonStrings.action_reply, VectorIcons.Reply)
+ data object ReplyInThread : TimelineItemAction(CommonStrings.action_reply_in_thread, VectorIcons.Reply)
data object Edit : TimelineItemAction(CommonStrings.action_edit, VectorIcons.Edit)
- data object Developer : TimelineItemAction(CommonStrings.action_view_source, VectorIcons.DeveloperMode)
+ data object ViewSource : TimelineItemAction(CommonStrings.action_view_source, VectorIcons.DeveloperMode)
data object ReportContent : TimelineItemAction(CommonStrings.action_report_content, VectorIcons.ReportContent, destructive = true)
- data object EndPoll : TimelineItemAction(CommonStrings.action_end_poll, VectorIcons.EndPoll)
+ data object EndPoll : TimelineItemAction(CommonStrings.action_end_poll, VectorIcons.PollEnd)
}
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/AttachmentsBottomSheet.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/AttachmentsBottomSheet.kt
index 43805bb5c0..f7108fe951 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/AttachmentsBottomSheet.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/AttachmentsBottomSheet.kt
@@ -26,10 +26,12 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.AttachFile
import androidx.compose.material.icons.filled.BarChart
import androidx.compose.material.icons.filled.Collections
+import androidx.compose.material.icons.filled.FormatColorText
import androidx.compose.material.icons.filled.LocationOn
import androidx.compose.material.icons.filled.PhotoCamera
import androidx.compose.material.icons.filled.Videocam
import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
@@ -54,6 +56,7 @@ internal fun AttachmentsBottomSheet(
state: MessageComposerState,
onSendLocationClicked: () -> Unit,
onCreatePollClicked: () -> Unit,
+ enableTextFormatting: Boolean,
modifier: Modifier = Modifier,
) {
val localView = LocalView.current
@@ -82,10 +85,14 @@ internal fun AttachmentsBottomSheet(
if (isVisible) {
ModalBottomSheet(
modifier = modifier,
+ sheetState = rememberModalBottomSheetState(
+ skipPartiallyExpanded = true
+ ),
onDismissRequest = { isVisible = false }
) {
AttachmentSourcePickerMenu(
state = state,
+ enableTextFormatting = enableTextFormatting,
onSendLocationClicked = onSendLocationClicked,
onCreatePollClicked = onCreatePollClicked,
)
@@ -99,6 +106,7 @@ internal fun AttachmentSourcePickerMenu(
state: MessageComposerState,
onSendLocationClicked: () -> Unit,
onCreatePollClicked: () -> Unit,
+ enableTextFormatting: Boolean,
modifier: Modifier = Modifier,
) {
Column(
@@ -145,6 +153,13 @@ internal fun AttachmentSourcePickerMenu(
text = { Text(stringResource(R.string.screen_room_attachment_source_poll)) },
)
}
+ if (enableTextFormatting) {
+ ListItem(
+ modifier = Modifier.clickable { state.eventSink(MessageComposerEvents.ToggleTextFormatting(enabled = true)) },
+ icon = { Icon(Icons.Default.FormatColorText, null) },
+ text = { Text(stringResource(R.string.screen_room_attachment_text_formatting)) },
+ )
+ }
}
}
@@ -157,5 +172,6 @@ internal fun AttachmentSourcePickerMenuPreview() = ElementPreview {
),
onSendLocationClicked = {},
onCreatePollClicked = {},
+ enableTextFormatting = true,
)
}
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerEvents.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerEvents.kt
index d99eb3c158..92b180f326 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerEvents.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerEvents.kt
@@ -17,16 +17,15 @@
package io.element.android.features.messages.impl.messagecomposer
import androidx.compose.runtime.Immutable
+import io.element.android.libraries.textcomposer.Message
import io.element.android.libraries.textcomposer.MessageComposerMode
@Immutable
sealed interface MessageComposerEvents {
data object ToggleFullScreenState : MessageComposerEvents
- data class FocusChanged(val hasFocus: Boolean) : MessageComposerEvents
- data class SendMessage(val message: String) : MessageComposerEvents
+ data class SendMessage(val message: Message) : MessageComposerEvents
data object CloseSpecialMode : MessageComposerEvents
data class SetMode(val composerMode: MessageComposerMode) : MessageComposerEvents
- data class UpdateText(val text: String) : MessageComposerEvents
data object AddAttachment : MessageComposerEvents
data object DismissAttachmentMenu : MessageComposerEvents
sealed interface PickAttachmentSource : MessageComposerEvents {
@@ -37,5 +36,7 @@ sealed interface MessageComposerEvents {
data object Location : PickAttachmentSource
data object Poll : PickAttachmentSource
}
+ data class ToggleTextFormatting(val enabled: Boolean) : MessageComposerEvents
data object CancelSendAttachment : MessageComposerEvents
+ data class Error(val error: Throwable) : MessageComposerEvents
}
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenter.kt
index cc735dc008..a7728c2a00 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenter.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenter.kt
@@ -44,8 +44,10 @@ import io.element.android.libraries.matrix.api.core.ProgressCallback
import io.element.android.libraries.matrix.api.room.MatrixRoom
import io.element.android.libraries.mediapickers.api.PickerProvider
import io.element.android.libraries.mediaupload.api.MediaSender
+import io.element.android.libraries.textcomposer.Message
import io.element.android.libraries.textcomposer.MessageComposerMode
import io.element.android.services.analytics.api.AnalyticsService
+import io.element.android.wysiwyg.compose.RichTextEditorState
import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
@@ -67,6 +69,7 @@ class MessageComposerPresenter @Inject constructor(
private val snackbarDispatcher: SnackbarDispatcher,
private val analyticsService: AnalyticsService,
private val messageComposerContext: MessageComposerContextImpl,
+ private val richTextEditorStateFactory: RichTextEditorStateFactory,
) : Presenter {
@SuppressLint("UnsafeOptInUsageError")
@@ -103,19 +106,16 @@ class MessageComposerPresenter @Inject constructor(
val isFullScreen = rememberSaveable {
mutableStateOf(false)
}
- val hasFocus = remember {
- mutableStateOf(false)
- }
- val text: MutableState = rememberSaveable {
- mutableStateOf("")
- }
+ val richTextEditorState = richTextEditorStateFactory.create()
val ongoingSendAttachmentJob = remember { mutableStateOf(null) }
var showAttachmentSourcePicker: Boolean by remember { mutableStateOf(false) }
+ var showTextFormatting: Boolean by remember { mutableStateOf(false) }
LaunchedEffect(messageComposerContext.composerMode) {
when (val modeValue = messageComposerContext.composerMode) {
- is MessageComposerMode.Edit -> text.value = modeValue.defaultContent
+ is MessageComposerMode.Edit ->
+ richTextEditorState.setHtml(modeValue.defaultContent)
else -> Unit
}
}
@@ -136,29 +136,18 @@ class MessageComposerPresenter @Inject constructor(
when (event) {
MessageComposerEvents.ToggleFullScreenState -> isFullScreen.value = !isFullScreen.value
- is MessageComposerEvents.FocusChanged -> hasFocus.value = event.hasFocus
-
- is MessageComposerEvents.UpdateText -> text.value = event.text
MessageComposerEvents.CloseSpecialMode -> {
- text.value = ""
+ richTextEditorState.setHtml("")
messageComposerContext.composerMode = MessageComposerMode.Normal("")
}
is MessageComposerEvents.SendMessage -> appCoroutineScope.sendMessage(
- text = event.message,
+ message = event.message,
updateComposerMode = { messageComposerContext.composerMode = it },
- textState = text
+ richTextEditorState = richTextEditorState,
)
is MessageComposerEvents.SetMode -> {
messageComposerContext.composerMode = event.composerMode
- analyticsService.capture(
- Composer(
- inThread = messageComposerContext.composerMode.inThread,
- isEditing = messageComposerContext.composerMode.isEditing,
- isReply = messageComposerContext.composerMode.isReply,
- isLocation = false,
- )
- )
}
MessageComposerEvents.AddAttachment -> localCoroutineScope.launch {
showAttachmentSourcePicker = true
@@ -194,45 +183,61 @@ class MessageComposerPresenter @Inject constructor(
ongoingSendAttachmentJob.value == null
}
}
+ is MessageComposerEvents.ToggleTextFormatting -> {
+ showAttachmentSourcePicker = false
+ showTextFormatting = event.enabled
+ }
+ is MessageComposerEvents.Error -> {
+ analyticsService.trackError(event.error)
+ }
}
}
return MessageComposerState(
- text = text.value,
+ richTextEditorState = richTextEditorState,
isFullScreen = isFullScreen.value,
- hasFocus = hasFocus.value,
mode = messageComposerContext.composerMode,
showAttachmentSourcePicker = showAttachmentSourcePicker,
+ showTextFormatting = showTextFormatting,
canShareLocation = canShareLocation.value,
canCreatePoll = canCreatePoll.value,
attachmentsState = attachmentsState.value,
- eventSink = ::handleEvents
+ eventSink = { handleEvents(it) }
)
}
private fun CoroutineScope.sendMessage(
- text: String,
+ message: Message,
updateComposerMode: (newComposerMode: MessageComposerMode) -> Unit,
- textState: MutableState
+ richTextEditorState: RichTextEditorState,
) = launch {
val capturedMode = messageComposerContext.composerMode
// Reset composer right away
- textState.value = ""
+ richTextEditorState.setHtml("")
updateComposerMode(MessageComposerMode.Normal(""))
when (capturedMode) {
- is MessageComposerMode.Normal -> room.sendMessage(text)
+ is MessageComposerMode.Normal -> room.sendMessage(body = message.markdown, htmlBody = message.html)
is MessageComposerMode.Edit -> {
val eventId = capturedMode.eventId
val transactionId = capturedMode.transactionId
- room.editMessage(eventId, transactionId, text)
+ room.editMessage(eventId, transactionId, message.markdown, message.html)
}
is MessageComposerMode.Quote -> TODO()
is MessageComposerMode.Reply -> room.replyMessage(
capturedMode.eventId,
- text
+ message.markdown,
+ message.html,
)
}
+ analyticsService.capture(
+ Composer(
+ inThread = capturedMode.inThread,
+ isEditing = capturedMode.isEditing,
+ isReply = capturedMode.isReply,
+ messageType = Composer.MessageType.Text, // Set proper type when we'll be sending other types of messages.
+ )
+ )
}
private fun CoroutineScope.sendAttachment(
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerState.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerState.kt
index dbbc62ca47..65fac53fdc 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerState.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerState.kt
@@ -19,21 +19,23 @@ package io.element.android.features.messages.impl.messagecomposer
import androidx.compose.runtime.Immutable
import io.element.android.features.messages.impl.attachments.Attachment
import io.element.android.libraries.textcomposer.MessageComposerMode
+import io.element.android.wysiwyg.compose.RichTextEditorState
import kotlinx.collections.immutable.ImmutableList
@Immutable
data class MessageComposerState(
- val text: String?,
+ val richTextEditorState: RichTextEditorState,
val isFullScreen: Boolean,
- val hasFocus: Boolean,
val mode: MessageComposerMode,
val showAttachmentSourcePicker: Boolean,
+ val showTextFormatting: Boolean,
val canShareLocation: Boolean,
val canCreatePoll: Boolean,
val attachmentsState: AttachmentsState,
- val eventSink: (MessageComposerEvents) -> Unit
+ val eventSink: (MessageComposerEvents) -> Unit,
) {
- val isSendButtonVisible: Boolean = text.isNullOrEmpty().not()
+ val canSendMessage: Boolean = richTextEditorState.messageHtml.isNotEmpty()
+ val hasFocus: Boolean = richTextEditorState.hasFocus
}
@Immutable
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerStateProvider.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerStateProvider.kt
index 2217b574b4..d86969fc19 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerStateProvider.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerStateProvider.kt
@@ -18,6 +18,7 @@ package io.element.android.features.messages.impl.messagecomposer
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.libraries.textcomposer.MessageComposerMode
+import io.element.android.wysiwyg.compose.RichTextEditorState
open class MessageComposerStateProvider : PreviewParameterProvider {
override val values: Sequence
@@ -27,19 +28,20 @@ open class MessageComposerStateProvider : PreviewParameterProvider Unit,
onCreatePollClicked: () -> Unit,
+ enableTextFormatting: Boolean,
modifier: Modifier = Modifier,
) {
fun onFullscreenToggle() {
state.eventSink(MessageComposerEvents.ToggleFullScreenState)
}
- fun sendMessage(message: String) {
+ fun sendMessage(message: Message) {
state.eventSink(MessageComposerEvents.SendMessage(message))
}
@@ -48,12 +50,12 @@ fun MessageComposerView(
state.eventSink(MessageComposerEvents.CloseSpecialMode)
}
- fun onComposerTextChange(text: String) {
- state.eventSink(MessageComposerEvents.UpdateText(text))
+ fun onDismissTextFormatting() {
+ state.eventSink(MessageComposerEvents.ToggleTextFormatting(enabled = false))
}
- fun onFocusChanged(hasFocus: Boolean) {
- state.eventSink(MessageComposerEvents.FocusChanged(hasFocus))
+ fun onError(error: Throwable) {
+ state.eventSink(MessageComposerEvents.Error(error))
}
Box(modifier = modifier) {
@@ -61,17 +63,21 @@ fun MessageComposerView(
state = state,
onSendLocationClicked = onSendLocationClicked,
onCreatePollClicked = onCreatePollClicked,
+ enableTextFormatting = enableTextFormatting,
)
TextComposer(
+ state = state.richTextEditorState,
+ canSendMessage = state.canSendMessage,
+ onRequestFocus = { state.richTextEditorState.requestFocus() },
onSendMessage = ::sendMessage,
composerMode = state.mode,
+ showTextFormatting = state.showTextFormatting,
onResetComposerMode = ::onCloseSpecialMode,
- onComposerTextChange = ::onComposerTextChange,
onAddAttachment = ::onAddAttachment,
- onFocusChanged = ::onFocusChanged,
- composerCanSendMessage = state.isSendButtonVisible,
- composerText = state.text
+ onDismissTextFormatting = ::onDismissTextFormatting,
+ enableTextFormatting = enableTextFormatting,
+ onError = ::onError,
)
}
}
@@ -92,5 +98,6 @@ private fun ContentToPreview(state: MessageComposerState) {
state = state,
onSendLocationClicked = {},
onCreatePollClicked = {},
+ enableTextFormatting = true,
)
}
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/RichTextEditorStateFactory.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/RichTextEditorStateFactory.kt
new file mode 100644
index 0000000000..00b39c47e2
--- /dev/null
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/RichTextEditorStateFactory.kt
@@ -0,0 +1,38 @@
+/*
+ * Copyright (c) 2023 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.element.android.features.messages.impl.messagecomposer
+
+import androidx.compose.runtime.Composable
+import com.squareup.anvil.annotations.ContributesBinding
+import io.element.android.libraries.di.AppScope
+import io.element.android.wysiwyg.compose.RichTextEditorState
+import io.element.android.wysiwyg.compose.rememberRichTextEditorState
+import javax.inject.Inject
+
+interface RichTextEditorStateFactory {
+ @Composable
+ fun create(): RichTextEditorState
+}
+
+@ContributesBinding(AppScope::class)
+class DefaultRichTextEditorStateFactory @Inject constructor() : RichTextEditorStateFactory {
+ @Composable
+ override fun create(): RichTextEditorState {
+ return rememberRichTextEditorState()
+ }
+}
+
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 402c332855..a7e86d341f 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
@@ -21,10 +21,12 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
+import im.vector.app.features.analytics.plan.PollVote
import io.element.android.features.messages.impl.timeline.factories.TimelineItemsFactory
import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.libraries.architecture.Presenter
@@ -34,6 +36,7 @@ import io.element.android.libraries.matrix.api.room.MatrixRoom
import io.element.android.libraries.matrix.api.room.MessageEventType
import io.element.android.libraries.matrix.api.timeline.item.event.TimelineItemEventOrigin
import io.element.android.libraries.matrix.ui.room.canSendMessageAsState
+import io.element.android.services.analytics.api.AnalyticsService
import kotlinx.collections.immutable.ImmutableList
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.launchIn
@@ -50,6 +53,7 @@ class TimelinePresenter @Inject constructor(
private val room: MatrixRoom,
private val dispatchers: CoroutineDispatchers,
private val appScope: CoroutineScope,
+ private val analyticsService: AnalyticsService,
) : Presenter {
private val timeline = room.timeline
@@ -61,7 +65,7 @@ class TimelinePresenter @Inject constructor(
mutableStateOf(null)
}
- val lastReadReceiptIndex = rememberSaveable { mutableStateOf(Int.MAX_VALUE) }
+ val lastReadReceiptIndex = rememberSaveable { mutableIntStateOf(Int.MAX_VALUE) }
val lastReadReceiptId = rememberSaveable { mutableStateOf(null) }
val timelineItems by timelineItemsFactory.collectItemsAsState()
@@ -92,7 +96,7 @@ class TimelinePresenter @Inject constructor(
pollStartId = event.pollStartId,
answers = listOf(event.answerId),
)
- // TODO Polls: Send poll vote analytic
+ analyticsService.capture(PollVote())
}
}
}
@@ -115,11 +119,11 @@ class TimelinePresenter @Inject constructor(
return TimelineState(
highlightedEventId = highlightedEventId.value,
- canReply = userHasPermissionToSendMessage,
+ userHasPermissionToSendMessage = userHasPermissionToSendMessage,
paginationState = paginationState,
timelineItems = timelineItems,
hasNewItems = hasNewItems.value,
- eventSink = ::handleEvents
+ eventSink = { handleEvents(it) }
)
}
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 ab5874d39c..1c7ff1b87c 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
@@ -26,7 +26,7 @@ import kotlinx.collections.immutable.ImmutableList
data class TimelineState(
val timelineItems: ImmutableList,
val highlightedEventId: EventId?,
- val canReply: Boolean,
+ val userHasPermissionToSendMessage: Boolean,
val paginationState: MatrixTimeline.PaginationState,
val hasNewItems: Boolean,
val eventSink: (TimelineEvents) -> Unit
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 6172da0469..1374f4aef4 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
@@ -44,7 +44,7 @@ fun aTimelineState(timelineItems: ImmutableList = persistentListOf
timelineItems = timelineItems,
paginationState = MatrixTimeline.PaginationState(isBackPaginating = false, hasMoreToLoadBackwards = true),
highlightedEventId = null,
- canReply = true,
+ userHasPermissionToSendMessage = true,
hasNewItems = false,
eventSink = {},
)
@@ -111,6 +111,7 @@ internal fun aTimelineItemEvent(
groupPosition: TimelineItemGroupPosition = TimelineItemGroupPosition.None,
sendState: LocalEventSendState = LocalEventSendState.Sent(eventId),
inReplyTo: InReplyTo? = null,
+ isThreaded: Boolean = false,
debugInfo: TimelineItemDebugInfo = aTimelineItemDebugInfo(),
timelineItemReactions: TimelineItemReactions = aTimelineItemReactions(),
): TimelineItem.Event {
@@ -129,6 +130,7 @@ internal fun aTimelineItemEvent(
localSendState = sendState,
inReplyTo = inReplyTo,
debugInfo = debugInfo,
+ isThreaded = isThreaded,
origin = null
)
}
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 ff90e8d29a..e48eb4f72b 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
@@ -63,6 +63,7 @@ import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEventContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEventContentProvider
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemStateContent
+import io.element.android.features.messages.impl.timeline.model.event.canBeRepliedTo
import io.element.android.libraries.designsystem.preview.DayNightPreviews
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.theme.components.FloatingActionButton
@@ -119,7 +120,7 @@ fun TimelineView(
TimelineItemRow(
timelineItem = timelineItem,
highlightedItem = state.highlightedEventId?.value,
- canReply = state.canReply,
+ userHasPermissionToSendMessage = state.userHasPermissionToSendMessage,
onClick = onMessageClicked,
onLongClick = onMessageLongClicked,
onUserDataClick = onUserDataClicked,
@@ -156,7 +157,7 @@ fun TimelineView(
fun TimelineItemRow(
timelineItem: TimelineItem,
highlightedItem: String?,
- canReply: Boolean,
+ userHasPermissionToSendMessage: Boolean,
onUserDataClick: (UserId) -> Unit,
onClick: (TimelineItem.Event) -> Unit,
onLongClick: (TimelineItem.Event) -> Unit,
@@ -189,7 +190,7 @@ fun TimelineItemRow(
TimelineItemEventRow(
event = timelineItem,
isHighlighted = highlightedItem == timelineItem.identifier(),
- canReply = canReply,
+ canReply = userHasPermissionToSendMessage && timelineItem.content.canBeRepliedTo(),
onClick = { onClick(timelineItem) },
onLongClick = { onLongClick(timelineItem) },
onUserDataClick = onUserDataClick,
@@ -228,7 +229,7 @@ fun TimelineItemRow(
TimelineItemRow(
timelineItem = subGroupEvent,
highlightedItem = highlightedItem,
- canReply = false,
+ userHasPermissionToSendMessage = false,
onClick = onClick,
onLongClick = onLongClick,
inReplyToClick = inReplyToClick,
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 10671ee00f..f492407690 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventRow.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventRow.kt
@@ -24,6 +24,7 @@ import androidx.compose.foundation.gestures.Orientation
import androidx.compose.foundation.gestures.draggable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Arrangement.spacedBy
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
@@ -75,6 +76,8 @@ import io.element.android.features.messages.impl.timeline.model.event.TimelineIt
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemImageContent
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemPollContent
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemTextContent
+import io.element.android.libraries.designsystem.VectorIcons
+import io.element.android.libraries.designsystem.colors.AvatarColorsProvider
import io.element.android.libraries.designsystem.components.EqualWidthColumn
import io.element.android.libraries.designsystem.components.avatar.Avatar
import io.element.android.libraries.designsystem.components.avatar.AvatarData
@@ -83,6 +86,7 @@ import io.element.android.libraries.designsystem.preview.ElementPreviewLight
import io.element.android.libraries.designsystem.swipe.SwipeableActionsState
import io.element.android.libraries.designsystem.swipe.rememberSwipeableActionsState
import io.element.android.libraries.designsystem.text.toPx
+import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.UserId
@@ -327,6 +331,7 @@ private fun MessageSenderInformation(
) {
val avatarStrokeColor = MaterialTheme.colorScheme.background
val avatarSize = senderAvatar.size.dp
+ val avatarColors = AvatarColorsProvider.provide(senderAvatar.id, ElementTheme.isLightTheme)
Box(
modifier = modifier
) {
@@ -350,7 +355,7 @@ private fun MessageSenderInformation(
text = sender,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
- color = MaterialTheme.colorScheme.primary,
+ color = avatarColors.foreground,
style = ElementTheme.typography.fontBodyMdMedium,
)
}
@@ -368,14 +373,6 @@ private fun MessageEventBubbleContent(
onPollAnswerSelected: (pollStartId: EventId, answerId: String) -> Unit,
@SuppressLint("ModifierParameter") bubbleModifier: Modifier = Modifier, // need to rename this modifier to distinguish it from the following ones
) {
- val timestampPosition = when (event.content) {
- is TimelineItemImageContent,
- is TimelineItemVideoContent,
- is TimelineItemLocationContent -> TimestampPosition.Overlay
- is TimelineItemPollContent -> TimestampPosition.Below
- else -> TimestampPosition.Default
- }
- val replyToDetails = event.inReplyTo as? InReplyTo.Ready
// Long clicks are not not automatically propagated from a `clickable`
// to its `combinedClickable` parent so we do it manually
@@ -396,6 +393,24 @@ private fun MessageEventBubbleContent(
)
}
+ @Composable
+ fun ThreadDecoration(
+ modifier: Modifier = Modifier
+ ) {
+ Row(
+ modifier = modifier,
+ horizontalArrangement = spacedBy(4.dp, Alignment.Start),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Icon(resourceId = VectorIcons.ThreadDecoration, contentDescription = null, tint = ElementTheme.colors.iconSecondary)
+ Text(
+ text = stringResource(CommonStrings.common_thread),
+ style = ElementTheme.typography.fontBodyXsRegular,
+ color = ElementTheme.colors.textPrimary,
+ )
+ }
+ }
+
@Composable
fun ContentAndTimestampView(
timestampPosition: TimestampPosition,
@@ -448,47 +463,74 @@ private fun MessageEventBubbleContent(
/** Groups the different components in a Column with some space between them. */
@Composable
fun CommonLayout(
+ timestampPosition: TimestampPosition,
+ showThreadDecoration: Boolean,
inReplyToDetails: InReplyTo.Ready?,
modifier: Modifier = Modifier
) {
- var modifierWithPadding: Modifier = Modifier
- var contentModifier: Modifier = Modifier
- EqualWidthColumn(modifier = modifier, spacing = 8.dp) {
- when {
- inReplyToDetails != null -> {
- val senderName = inReplyToDetails.senderDisplayName ?: inReplyToDetails.senderId.value
- val attachmentThumbnailInfo = attachmentThumbnailInfoForInReplyTo(inReplyToDetails)
- val text = textForInReplyTo(inReplyToDetails)
- ReplyToContent(
- senderName = senderName,
- text = text,
- attachmentThumbnailInfo = attachmentThumbnailInfo,
- modifier = Modifier
- .padding(top = 8.dp, start = 8.dp, end = 8.dp)
- .clip(RoundedCornerShape(6.dp))
- .clickable(enabled = true, onClick = inReplyToClick),
- )
- if (timestampPosition == TimestampPosition.Overlay) {
- modifierWithPadding = Modifier.padding(start = 8.dp, end = 8.dp, bottom = 8.dp)
- contentModifier = Modifier.clip(RoundedCornerShape(12.dp))
- } else {
- contentModifier = Modifier.padding(start = 12.dp, end = 12.dp, top = 0.dp, bottom = 8.dp)
- }
- }
- timestampPosition != TimestampPosition.Overlay -> {
- contentModifier = Modifier.padding(start = 12.dp, end = 12.dp, top = 8.dp, bottom = 8.dp)
+ val modifierWithPadding: Modifier
+ val contentModifier: Modifier
+ when {
+ inReplyToDetails != null -> {
+ if (timestampPosition == TimestampPosition.Overlay) {
+ modifierWithPadding = Modifier.padding(start = 8.dp, end = 8.dp, bottom = 8.dp)
+ contentModifier = Modifier.clip(RoundedCornerShape(12.dp))
+ } else {
+ contentModifier = Modifier.padding(start = 12.dp, end = 12.dp, top = 0.dp, bottom = 8.dp)
+ modifierWithPadding = Modifier
}
}
+ timestampPosition != TimestampPosition.Overlay -> {
+ modifierWithPadding = Modifier
+ contentModifier = Modifier.padding(start = 12.dp, end = 12.dp, top = 8.dp, bottom = 8.dp)
+ }
+ else -> {
+ modifierWithPadding = Modifier
+ contentModifier = Modifier
+ }
+ }
+ EqualWidthColumn(modifier = modifier, spacing = 8.dp) {
+ if (showThreadDecoration) {
+ ThreadDecoration(modifier = Modifier.padding(top = 8.dp, start = 12.dp, end = 12.dp))
+ }
+ if (inReplyToDetails != null) {
+ val senderName = inReplyToDetails.senderDisplayName ?: inReplyToDetails.senderId.value
+ val attachmentThumbnailInfo = attachmentThumbnailInfoForInReplyTo(inReplyToDetails)
+ val text = textForInReplyTo(inReplyToDetails)
+ val topPadding = if (showThreadDecoration) 0.dp else 8.dp
+ ReplyToContent(
+ senderName = senderName,
+ text = text,
+ attachmentThumbnailInfo = attachmentThumbnailInfo,
+ modifier = Modifier
+ .padding(top = topPadding, start = 8.dp, end = 8.dp)
+ .clip(RoundedCornerShape(6.dp))
+ .clickable(enabled = true, onClick = inReplyToClick),
+ )
+ }
ContentAndTimestampView(
timestampPosition = timestampPosition,
- contentModifier = contentModifier,
modifier = modifierWithPadding,
+ contentModifier = contentModifier,
)
}
}
- CommonLayout(inReplyToDetails = replyToDetails, modifier = bubbleModifier)
+ val timestampPosition = when (event.content) {
+ is TimelineItemImageContent,
+ is TimelineItemVideoContent,
+ is TimelineItemLocationContent -> TimestampPosition.Overlay
+ is TimelineItemPollContent -> TimestampPosition.Below
+ else -> TimestampPosition.Default
+ }
+ val replyToDetails = event.inReplyTo as? InReplyTo.Ready
+ CommonLayout(
+ showThreadDecoration = event.isThreaded,
+ timestampPosition = timestampPosition,
+ inReplyToDetails = replyToDetails,
+ modifier = bubbleModifier
+ )
}
@Composable
@@ -692,6 +734,7 @@ private fun ContentToPreviewWithReply() {
aspectRatio = 5f
),
inReplyTo = aInReplyToReady(replyContent),
+ isThreaded = true,
groupPosition = TimelineItemGroupPosition.Last,
),
isHighlighted = false,
@@ -712,11 +755,11 @@ private fun ContentToPreviewWithReply() {
}
private fun aInReplyToReady(
- replyContent: String
+ replyContent: String,
): InReplyTo.Ready {
return InReplyTo.Ready(
eventId = EventId("\$event"),
- content = MessageContent(replyContent, null, false, TextMessageType(replyContent, null)),
+ content = MessageContent(replyContent, null, false, false, TextMessageType(replyContent, null)),
senderId = UserId("@Sender:domain"),
senderDisplayName = "Sender",
senderAvatarUrl = null,
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/customreaction/CustomReactionPresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/customreaction/CustomReactionPresenter.kt
index b048383b1f..8bbd6cbff7 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/customreaction/CustomReactionPresenter.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/customreaction/CustomReactionPresenter.kt
@@ -63,7 +63,7 @@ class CustomReactionPresenter @Inject constructor(
return CustomReactionState(
target = target.value,
selectedEmoji = selectedEmoji,
- eventSink = ::handleEvents
+ eventSink = { handleEvents(it) }
)
}
}
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/ExtraPadding.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/ExtraPadding.kt
index 65101183d9..e5851c993b 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/ExtraPadding.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/ExtraPadding.kt
@@ -18,10 +18,14 @@ package io.element.android.features.messages.impl.timeline.components.event
import androidx.compose.runtime.Composable
import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.TextUnit
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.times
import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemTextBasedContent
import io.element.android.libraries.core.bool.orFalse
+import io.element.android.libraries.designsystem.text.toDp
import io.element.android.libraries.matrix.api.timeline.item.event.LocalEventSendState
import io.element.android.libraries.theme.ElementTheme
import io.element.android.libraries.ui.strings.CommonStrings
@@ -69,3 +73,10 @@ fun ExtraPadding.getStr(fontSize: TextUnit): String {
// A space and some unbreakable spaces
return " " + "\u00A0".repeat(nbOfSpaces)
}
+
+@Composable
+fun ExtraPadding.getDpSize(): Dp {
+ if (nbChars == 0) return 0.dp
+ val timestampFontSize = ElementTheme.typography.fontBodyXsRegular.fontSize // 11.sp
+ return nbChars * timestampFontSize.toDp() / 3
+}
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 c6b0218bba..b2dcc477a0 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
@@ -18,9 +18,6 @@ package io.element.android.features.messages.impl.timeline.components.event
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Box
-import androidx.compose.foundation.layout.Column
-import androidx.compose.foundation.layout.Spacer
-import androidx.compose.foundation.layout.height
import androidx.compose.material3.LocalContentColor
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
@@ -28,7 +25,6 @@ import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
-import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import io.element.android.features.messages.impl.timeline.components.html.HtmlDocument
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemTextBasedContent
@@ -51,18 +47,14 @@ fun TimelineItemTextView(
CompositionLocalProvider(LocalContentColor provides ElementTheme.colors.textPrimary) {
val htmlDocument = content.htmlDocument
if (htmlDocument != null) {
- // For now we ignore the extra padding for html content, so add some spacing
- // below the content (as previous behavior)
- Column(modifier = modifier) {
- HtmlDocument(
- document = htmlDocument,
- modifier = Modifier,
- onTextClicked = onTextClicked,
- onTextLongClicked = onTextLongClicked,
- interactionSource = interactionSource
- )
- Spacer(Modifier.height(16.dp))
- }
+ HtmlDocument(
+ document = htmlDocument,
+ extraPadding = extraPadding,
+ modifier = modifier,
+ onTextClicked = onTextClicked,
+ onTextLongClicked = onTextLongClicked,
+ interactionSource = interactionSource
+ )
} else {
Box(modifier) {
val textWithPadding = remember(content.body) {
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/html/HtmlDocument.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/html/HtmlDocument.kt
index 9c2798a638..1febdd6092 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/html/HtmlDocument.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/html/HtmlDocument.kt
@@ -25,8 +25,10 @@ import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
+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.shape.RoundedCornerShape
import androidx.compose.foundation.text.InlineTextContent
import androidx.compose.foundation.text.appendInlineContent
@@ -53,13 +55,18 @@ import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
+import io.element.android.features.messages.impl.timeline.components.event.ExtraPadding
+import io.element.android.features.messages.impl.timeline.components.event.getDpSize
+import io.element.android.features.messages.impl.timeline.components.event.noExtraPadding
import io.element.android.libraries.designsystem.components.ClickableLinkText
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
+import io.element.android.libraries.designsystem.text.toDp
import io.element.android.libraries.designsystem.theme.components.Surface
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.matrix.api.permalink.PermalinkData
import io.element.android.libraries.matrix.api.permalink.PermalinkParser
+import io.element.android.libraries.theme.ElementTheme
import io.element.android.libraries.theme.LinkColor
import kotlinx.collections.immutable.persistentMapOf
import org.jsoup.nodes.Document
@@ -72,18 +79,28 @@ private const val CHIP_ID = "chip"
@Composable
fun HtmlDocument(
document: Document,
+ extraPadding: ExtraPadding,
interactionSource: MutableInteractionSource,
modifier: Modifier = Modifier,
onTextClicked: () -> Unit = {},
onTextLongClicked: () -> Unit = {},
) {
- HtmlBody(
- body = document.body(),
- interactionSource = interactionSource,
+ FlowRow(
modifier = modifier,
- onTextClicked = onTextClicked,
- onTextLongClicked = onTextLongClicked,
- )
+ ) {
+ HtmlBody(
+ body = document.body(),
+ interactionSource = interactionSource,
+ onTextClicked = onTextClicked,
+ onTextLongClicked = onTextLongClicked,
+ )
+ Spacer(
+ modifier = Modifier.size(
+ width = extraPadding.getDpSize(),
+ height = ElementTheme.typography.fontBodyXsRegular.fontSize.toDp() * 1.25f
+ )
+ )
+ }
}
@Composable
@@ -109,7 +126,12 @@ private fun HtmlBody(
when (val node = nodes.next()) {
is TextNode -> {
if (!node.isBlank) {
- ClickableLinkText(text = node.text(), interactionSource = interactionSource)
+ ClickableLinkText(
+ text = node.text(),
+ interactionSource = interactionSource,
+ onClick = onTextClicked,
+ onLongClick = onTextLongClicked,
+ )
}
}
is Element -> {
@@ -603,5 +625,9 @@ internal fun HtmlDocumentDarkPreview(@PreviewParameter(DocumentProvider::class)
@Composable
private fun ContentToPreview(document: Document) {
- HtmlDocument(document, remember { MutableInteractionSource() })
+ HtmlDocument(
+ document = document,
+ extraPadding = noExtraPadding,
+ interactionSource = remember { MutableInteractionSource() }
+ )
}
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/reactionsummary/ReactionSummaryPresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/reactionsummary/ReactionSummaryPresenter.kt
index 456ac5f548..e75e49c1e3 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/reactionsummary/ReactionSummaryPresenter.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/reactionsummary/ReactionSummaryPresenter.kt
@@ -61,7 +61,7 @@ class ReactionSummaryPresenter @Inject constructor(
}
return ReactionSummaryState(
target = targetWithAvatars.value,
- eventSink = ::handleEvents
+ eventSink = { handleEvents(it) }
)
}
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/retrysendmenu/RetrySendMenuPresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/retrysendmenu/RetrySendMenuPresenter.kt
index 237dc5683d..c9ebd9be8c 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/retrysendmenu/RetrySendMenuPresenter.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/retrysendmenu/RetrySendMenuPresenter.kt
@@ -66,7 +66,7 @@ class RetrySendMenuPresenter @Inject constructor(
return RetrySendMenuState(
selectedEvent = selectedEvent,
- eventSink = ::handleEvent,
+ eventSink = { handleEvent(it) },
)
}
}
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemEventFactory.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemEventFactory.kt
index 4cb249af72..14f8429c85 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemEventFactory.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemEventFactory.kt
@@ -71,6 +71,7 @@ class TimelineItemEventFactory @Inject constructor(
url = senderAvatarUrl,
size = AvatarSize.TimelineSender
)
+ currentTimelineItem.event
return TimelineItem.Event(
id = currentTimelineItem.uniqueId.toString(),
eventId = currentTimelineItem.eventId,
@@ -85,6 +86,7 @@ class TimelineItemEventFactory @Inject constructor(
reactionsState = currentTimelineItem.computeReactionsState(),
localSendState = currentTimelineItem.event.localSendState,
inReplyTo = currentTimelineItem.event.inReplyTo(),
+ isThreaded = currentTimelineItem.event.isThreaded(),
debugInfo = currentTimelineItem.event.debugInfo,
origin = currentTimelineItem.event.origin,
)
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/TimelineItem.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/TimelineItem.kt
index b1a5c245b9..8f00dfb0dd 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/TimelineItem.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/TimelineItem.kt
@@ -66,6 +66,7 @@ sealed interface TimelineItem {
val reactionsState: TimelineItemReactions,
val localSendState: LocalEventSendState?,
val inReplyTo: InReplyTo?,
+ val isThreaded: Boolean,
val debugInfo: TimelineItemDebugInfo,
val origin: TimelineItemEventOrigin?,
) : TimelineItem {
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemEventContent.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemEventContent.kt
index 02837bd6b4..ef31d6249c 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemEventContent.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemEventContent.kt
@@ -34,6 +34,18 @@ fun TimelineItemEventContent.canBeCopied(): Boolean =
else -> false
}
+/**
+ * Determine if the event content can be replied to.
+ * Note: it should match the logic in [io.element.android.features.messages.impl.actionlist.ActionListPresenter].
+ */
+fun TimelineItemEventContent.canBeRepliedTo(): Boolean =
+ when (this) {
+ is TimelineItemRedactedContent,
+ is TimelineItemStateContent,
+ is TimelineItemPollContent -> false
+ else -> true
+ }
+
/**
* Return true if user can react (i.e. send a reaction) on the event content.
*/
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 ec6ee16675..10fca53261 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
@@ -22,4 +22,6 @@ sealed interface TimelineItemTextBasedContent : TimelineItemEventContent {
val body: String
val htmlDocument: Document?
val isEdited: Boolean
+ val htmlBody: String?
+ get() = htmlDocument?.body()?.html()
}
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 7ca02ef21c..1543101f6d 100644
--- a/features/messages/impl/src/main/res/values-cs/translations.xml
+++ b/features/messages/impl/src/main/res/values-cs/translations.xml
@@ -5,11 +5,6 @@
- "%1$d změny místnosti"
- "%1$d změn místnosti"
-
- - "%1$d další"
- - "%1$d další"
- - "%1$d dalších"
-
"Fotoaparát"
"Vyfotit"
"Natočit video"
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 482065c887..7127196056 100644
--- a/features/messages/impl/src/main/res/values-de/translations.xml
+++ b/features/messages/impl/src/main/res/values-de/translations.xml
@@ -5,37 +5,38 @@
- "%1$d Raumänderungen"
"Kamera"
- "Foto aufnehmen"
+ "Foto machen"
"Video aufnehmen"
"Anhang"
- "Foto- & Video-Bibliothek"
+ "Foto- und Videobibliothek"
"Standort"
"Umfrage"
- "Der Nachrichtenverlauf ist in diesem Raum derzeit nicht verfügbar"
+ "Textformatierung"
+ "Der Nachrichtenverlauf ist derzeit in diesem Raum nicht verfügbar"
"Benutzerdetails konnten nicht abgerufen werden"
"Möchtest du sie wieder einladen?"
"Du bist allein in diesem Chat"
- "Nachricht kopiert"
- "Du bist keine Berechtigung, um in diesem Raum zu posten"
+ "Nachricht wurde kopiert"
+ "Du bist nicht berechtigt, in diesem Raum zu posten"
"Benutzerdefinierte Einstellung zulassen"
- "Das Aktivieren dieser Option wird die Standardeinstellungen überschreiben."
- "Benachrichtige mich in diesem Chat für"
- "Du kannst es in deinem %1$s ändern."
+ "Wenn du diese Option aktivierst, wird deine Standardeinstellung außer Kraft gesetzt."
+ "Benachrichtige mich in diesem Chat bei"
+ "Du kannst das in deinem %1$s ändern."
"Globale Einstellungen"
"Standardeinstellung"
"Benutzerdefinierte Einstellung entfernen"
"Beim Laden der Benachrichtigungseinstellungen ist ein Fehler aufgetreten."
- "Wiederherstellung des Standardmodus fehlgeschlagen. Bitte versuche es erneut."
+ "Fehler beim Wiederherstellen des Standardmodus. Bitte versuche es erneut."
"Fehler beim Einstellen des Modus. Bitte versuche es erneut."
"Alle Nachrichten"
"Nur Erwähnungen und Schlüsselwörter"
- "In diesem Raum, benachrichtige mich für"
+ "Benachrichtige mich in diesem Raum bei"
"Weniger anzeigen"
"Mehr anzeigen"
"Erneut senden"
"Ihre Nachricht konnte nicht gesendet werden"
"Emoji hinzufügen"
"Weniger anzeigen"
- "Fehler bei der Verarbeitung von Medien zum Hochladen, bitte versuche es erneut."
+ "Fehler beim Verarbeiten des hochgeladenen Mediums. Bitte versuche es erneut."
"Entfernen"
diff --git a/features/messages/impl/src/main/res/values-fr/translations.xml b/features/messages/impl/src/main/res/values-fr/translations.xml
index 5f9f0223aa..5ae43b98d7 100644
--- a/features/messages/impl/src/main/res/values-fr/translations.xml
+++ b/features/messages/impl/src/main/res/values-fr/translations.xml
@@ -1,40 +1,42 @@
- - "%1$d changement dans la conversation"
- - "%1$d changements dans la conversation"
-
-
- - "%1$d de plus"
- - "%1$d de plus"
+ - "%1$d changement dans le salon"
+ - "%1$d changements dans le salon"
"Appareil photo"
"Prendre une photo"
"Enregistrer une vidéo"
- "Pièce-jointe"
- "Gallerie photo et vidéo"
- "L’historique des messages n’est pas disponible actuellement dans ce salon"
+ "Pièce jointe"
+ "Gallerie Photo et Vidéo"
+ "Position"
+ "Sondage"
+ "Formatage du texte"
+ "L’historique des messages n’est actuellement pas disponible dans ce salon"
"Impossible de récupérer les détails de l’utilisateur"
- "Souhaitez-vous les inviter à revenir ?"
- "Vous êtes seul dans ce chat"
+ "Souaitez-vous inviter l\'ancien membre à revenir ?"
+ "Vous êtes seul dans ce salon"
"Message copié"
- "Vous n‘avez pas le droit de poster dans ce salon"
+ "Vous n’êtes pas autorisé à publier dans ce salon"
"Autoriser les paramètres personnalisés"
- "Activer cette option remplacera votre paramètre par défaut"
- "Me notifier dans ce chat pour"
- "paramètres généraux"
+ "L’activation de cette option annulera votre paramètre par défaut"
+ "Prévenez-moi dans ce salon pour"
+ "Vous pouvez le modifier dans votre %1$s."
+ "paramètres globaux"
"Paramètre par défaut"
+ "Supprimer le paramètre personnalisé"
"Une erreur s’est produite lors du chargement des paramètres de notification."
- "Impossible de restaurer le mode par défaut, veuillez réessayer."
- "Impossible de régler le mode, veuillez réessayer."
+ "Échec de la restauration du mode par défaut, veuillez réessayer."
+ "Échec de la configuration du mode, veuillez réessayer."
"Tous les messages"
- "Mentions et mots-clés uniquement"
+ "Mentions et mots clés uniquement"
+ "Dans ce salon, prévenez-moi pour"
"Afficher moins"
"Afficher plus"
- "Renvoyer"
- "Votre message n\'a pas pu être envoyé"
- "Ajouter un emoji"
- "Montrer moins"
- "Échec du traitement du média avant son envoi, veuillez réessayer."
+ "Envoyer à nouveau"
+ "Votre message n’a pas pu être envoyé"
+ "Ajouter un émoji"
+ "Afficher moins"
+ "Échec du traitement des médias à télécharger, veuillez réessayer."
"Supprimer"
diff --git a/features/messages/impl/src/main/res/values-ro/translations.xml b/features/messages/impl/src/main/res/values-ro/translations.xml
index 54774ca172..c351eb29cb 100644
--- a/features/messages/impl/src/main/res/values-ro/translations.xml
+++ b/features/messages/impl/src/main/res/values-ro/translations.xml
@@ -11,13 +11,33 @@
"Atașament"
"Bibliotecă foto și video"
"Locație"
+ "Sondaj"
+ "Formatarea textului"
+ "Istoricul mesajelor este momentan indisponibil în această cameră"
"Nu am putut găsi detaliile utilizatorului"
"Doriți să îi invitați înapoi?"
"Sunteți singur în această cameră"
"Mesaj copiat"
"Nu aveți permisiunea de a posta în această cameră"
+ "Permiteți setări personalizate"
+ "Activarea acestei opțiuni va anula setările implicite."
+ "Anunțați-mă în acestă cameră pentru"
+ "Îl puteți schimba în %1$s."
+ "Setări generale"
+ "Setare implicită"
+ "Stergeți setarea personalizată"
+ "A apărut o eroare la încărcarea setărilor pentry notificari."
+ "Nu s-a reușit restaurarea modului implicit, vă rugăm să încercați din nou."
+ "Nu s-a reușit setarea modului, vă rugăm să încercați din nou."
+ "Toate mesajele"
+ "Numai mențiuni și cuvinte cheie"
+ "În această cameră, anunțați-mă pentru"
+ "Afișați mai puțin"
+ "Afișați mai mult"
"Trimiteți din nou"
"Mesajul dvs. nu a putut fi trimis"
+ "Adăugați emoji"
+ "Afișați mai puțin"
"Procesarea datelor media a eșuat, vă rugăm să încercați din nou."
"Ștergeți"
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 33147f8ea2..8c96074f1b 100644
--- a/features/messages/impl/src/main/res/values-ru/translations.xml
+++ b/features/messages/impl/src/main/res/values-ru/translations.xml
@@ -5,11 +5,6 @@
- "%1$d изменения в комнате"
- "%1$d изменений в комнате"
-
- - "И ещё %1$d"
- - "И ещё %1$d"
- - "И ещё %1$d"
-
"Камера"
"Сделать фото"
"Записать видео"
diff --git a/features/messages/impl/src/main/res/values-sk/translations.xml b/features/messages/impl/src/main/res/values-sk/translations.xml
index 03a32f847f..2c98aab148 100644
--- a/features/messages/impl/src/main/res/values-sk/translations.xml
+++ b/features/messages/impl/src/main/res/values-sk/translations.xml
@@ -5,11 +5,6 @@
- "%1$d zmeny miestnosti"
- "%1$d zmien miestnosti"
-
- - "%1$d ďalší"
- - "%1$d ďalšie"
- - "%1$d ďalších"
-
"Kamera"
"Odfotiť"
"Nahrať video"
@@ -17,6 +12,7 @@
"Knižnica fotografií a videí"
"Poloha"
"Anketa"
+ "Formátovanie textu"
"História správ v tejto miestnosti nie je momentálne k dispozícii"
"Nepodarilo sa získať údaje o používateľovi"
"Chceli by ste ich pozvať späť?"
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 88f97cd2bf..ac4725896e 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
@@ -3,9 +3,6 @@
- "%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 105cb1fc7b..81cd4933e4 100644
--- a/features/messages/impl/src/main/res/values/localazy.xml
+++ b/features/messages/impl/src/main/res/values/localazy.xml
@@ -4,9 +4,6 @@
- "%1$d room change"
- "%1$d room changes"
-
- - "%1$d more"
-
"Camera"
"Take photo"
"Record a video"
@@ -14,6 +11,7 @@
"Photo & Video Library"
"Location"
"Poll"
+ "Text Formatting"
"Message history is currently unavailable in this room"
"Could not retrieve user details"
"Would you like to invite them back?"
diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/MessagesPresenterTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/MessagesPresenterTest.kt
index 992ea60869..db2abcc1dd 100644
--- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/MessagesPresenterTest.kt
+++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/MessagesPresenterTest.kt
@@ -21,6 +21,7 @@ import app.cash.molecule.RecompositionMode
import app.cash.molecule.moleculeFlow
import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
+import im.vector.app.features.analytics.plan.PollEnd
import io.element.android.features.messages.fixtures.aMessageEvent
import io.element.android.features.messages.fixtures.aTimelineItemsFactory
import io.element.android.features.messages.impl.InviteDialogAction
@@ -30,7 +31,6 @@ import io.element.android.features.messages.impl.actionlist.ActionListPresenter
import io.element.android.features.messages.impl.actionlist.ActionListState
import io.element.android.features.messages.impl.actionlist.model.TimelineItemAction
import io.element.android.features.messages.impl.messagecomposer.MessageComposerContextImpl
-import io.element.android.features.messages.impl.messagecomposer.MessageComposerEvents
import io.element.android.features.messages.impl.messagecomposer.MessageComposerPresenter
import io.element.android.features.messages.impl.timeline.TimelinePresenter
import io.element.android.features.messages.impl.timeline.components.customreaction.CustomReactionPresenter
@@ -41,6 +41,7 @@ import io.element.android.features.messages.impl.timeline.model.event.TimelineIt
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.media.FakeLocalMediaFactory
+import io.element.android.features.messages.textcomposer.TestRichTextEditorStateFactory
import io.element.android.features.messages.timeline.components.customreaction.FakeEmojibaseProvider
import io.element.android.features.messages.utils.messagesummary.FakeMessageSummaryFormatter
import io.element.android.features.networkmonitor.test.FakeNetworkMonitor
@@ -51,7 +52,9 @@ import io.element.android.libraries.core.mimetype.MimeTypes
import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.designsystem.utils.SnackbarDispatcher
+import io.element.android.libraries.featureflag.api.FeatureFlags
import io.element.android.libraries.featureflag.test.FakeFeatureFlagService
+import io.element.android.libraries.featureflag.test.InMemoryPreferencesStore
import io.element.android.libraries.matrix.api.media.MediaSource
import io.element.android.libraries.matrix.api.room.MatrixRoom
import io.element.android.libraries.matrix.api.room.MatrixRoomMembersState
@@ -62,7 +65,6 @@ import io.element.android.libraries.matrix.test.AN_EVENT_ID
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.core.aBuildMeta
import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
import io.element.android.libraries.matrix.test.room.aRoomMember
import io.element.android.libraries.mediapickers.test.FakePickerProvider
@@ -70,6 +72,7 @@ import io.element.android.libraries.mediaupload.api.MediaSender
import io.element.android.libraries.mediaupload.test.FakeMediaPreProcessor
import io.element.android.libraries.textcomposer.MessageComposerMode
import io.element.android.services.analytics.test.FakeAnalyticsService
+import io.element.android.tests.testutils.WarmUpRule
import io.element.android.tests.testutils.consumeItemsUntilPredicate
import io.element.android.tests.testutils.consumeItemsUntilTimeout
import io.element.android.tests.testutils.testCoroutineDispatchers
@@ -77,10 +80,15 @@ import io.element.android.tests.testutils.waitForPredicate
import io.mockk.mockk
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.runTest
+import org.junit.Rule
import org.junit.Test
+import kotlin.time.Duration.Companion.milliseconds
class MessagesPresenterTest {
+ @get:Rule
+ val warmUpRule = WarmUpRule()
+
private val mockMediaUrl: Uri = mockk("localMediaUri")
@Test
@@ -317,6 +325,7 @@ class MessagesPresenterTest {
initialState.eventSink.invoke(MessagesEvents.HandleAction(TimelineItemAction.Redact, aMessageEvent()))
assertThat(matrixRoom.redactEventEventIdParam).isEqualTo(AN_EVENT_ID)
assertThat(awaitItem().actionListState.target).isEqualTo(ActionListState.Target.None)
+ skipItems(1) // back paginating
}
}
@@ -355,7 +364,7 @@ class MessagesPresenterTest {
presenter.present()
}.test {
val initialState = awaitItem()
- initialState.eventSink.invoke(MessagesEvents.HandleAction(TimelineItemAction.Developer, aMessageEvent()))
+ initialState.eventSink.invoke(MessagesEvents.HandleAction(TimelineItemAction.ViewSource, aMessageEvent()))
assertThat(awaitItem().actionListState.target).isEqualTo(ActionListState.Target.None)
assertThat(navigator.onShowEventDebugInfoClickedCount).isEqualTo(1)
}
@@ -373,14 +382,14 @@ class MessagesPresenterTest {
// Initially the composer doesn't have focus, so we don't show the alert
assertThat(initialState.showReinvitePrompt).isFalse()
// When the input field is focused we show the alert
- initialState.composerState.eventSink(MessageComposerEvents.FocusChanged(true))
- val focusedState = consumeItemsUntilPredicate { state ->
+ initialState.composerState.richTextEditorState.requestFocus()
+ val focusedState = consumeItemsUntilPredicate(timeout = 250.milliseconds) { state ->
state.showReinvitePrompt
}.last()
assertThat(focusedState.showReinvitePrompt).isTrue()
// If it's dismissed then we stop showing the alert
initialState.eventSink(MessagesEvents.InviteDialogDismissed(InviteDialogAction.Cancel))
- val dismissedState = consumeItemsUntilPredicate { state ->
+ val dismissedState = consumeItemsUntilPredicate(timeout = 250.milliseconds) { state ->
!state.showReinvitePrompt
}.last()
assertThat(dismissedState.showReinvitePrompt).isFalse()
@@ -397,7 +406,7 @@ class MessagesPresenterTest {
skipItems(1)
val initialState = awaitItem()
assertThat(initialState.showReinvitePrompt).isFalse()
- initialState.composerState.eventSink(MessageComposerEvents.FocusChanged(true))
+ initialState.composerState.richTextEditorState.requestFocus()
val focusedState = awaitItem()
assertThat(focusedState.showReinvitePrompt).isFalse()
}
@@ -413,7 +422,7 @@ class MessagesPresenterTest {
skipItems(1)
val initialState = awaitItem()
assertThat(initialState.showReinvitePrompt).isFalse()
- initialState.composerState.eventSink(MessageComposerEvents.FocusChanged(true))
+ initialState.composerState.richTextEditorState.requestFocus()
val focusedState = awaitItem()
assertThat(focusedState.showReinvitePrompt).isFalse()
}
@@ -464,7 +473,9 @@ class MessagesPresenterTest {
val initialState = consumeItemsUntilTimeout().last()
initialState.eventSink(MessagesEvents.InviteDialogDismissed(InviteDialogAction.Invite))
skipItems(1)
- val loadingState = awaitItem()
+ val loadingState = consumeItemsUntilPredicate { state ->
+ state.inviteProgress.isLoading()
+ }.last()
assertThat(loadingState.inviteProgress.isLoading()).isTrue()
val newState = awaitItem()
assertThat(newState.inviteProgress.isSuccess()).isTrue()
@@ -564,7 +575,11 @@ class MessagesPresenterTest {
@Test
fun `present - handle poll end`() = runTest {
val room = FakeMatrixRoom()
- val presenter = createMessagePresenter(matrixRoom = room)
+ val analyticsService = FakeAnalyticsService()
+ val presenter = createMessagePresenter(
+ matrixRoom = room,
+ analyticsService = analyticsService,
+ )
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
@@ -575,7 +590,8 @@ class MessagesPresenterTest {
assertThat(room.endPollInvocations.size).isEqualTo(1)
assertThat(room.endPollInvocations.first().pollStartId).isEqualTo(AN_EVENT_ID)
assertThat(room.endPollInvocations.first().text).isEqualTo("The poll with event id: \$anEventId has ended.")
- // TODO Polls: Test poll end analytic
+ assertThat(analyticsService.capturedEvents.size).isEqualTo(1)
+ assertThat(analyticsService.capturedEvents.last()).isEqualTo(PollEnd())
}
}
@@ -584,26 +600,30 @@ class MessagesPresenterTest {
matrixRoom: MatrixRoom = FakeMatrixRoom(),
navigator: FakeMessagesNavigator = FakeMessagesNavigator(),
clipboardHelper: FakeClipboardHelper = FakeClipboardHelper(),
+ analyticsService: FakeAnalyticsService = FakeAnalyticsService(),
): MessagesPresenter {
val messageComposerPresenter = MessageComposerPresenter(
appCoroutineScope = this,
room = matrixRoom,
mediaPickerProvider = FakePickerProvider(),
- featureFlagService = FakeFeatureFlagService(),
+ featureFlagService = FakeFeatureFlagService(mapOf(FeatureFlags.NotificationSettings.key to true)),
localMediaFactory = FakeLocalMediaFactory(mockMediaUrl),
mediaSender = MediaSender(FakeMediaPreProcessor(), matrixRoom),
snackbarDispatcher = SnackbarDispatcher(),
- analyticsService = FakeAnalyticsService(),
+ analyticsService = analyticsService,
messageComposerContext = MessageComposerContextImpl(),
- )
+ richTextEditorStateFactory = TestRichTextEditorStateFactory(),
+
+ )
val timelinePresenter = TimelinePresenter(
timelineItemsFactory = aTimelineItemsFactory(),
room = matrixRoom,
dispatchers = coroutineDispatchers,
- appScope = this
+ appScope = this,
+ analyticsService = analyticsService,
)
- val buildMeta = aBuildMeta()
- val actionListPresenter = ActionListPresenter(buildMeta = buildMeta)
+ val preferencesStore = InMemoryPreferencesStore(isRichTextEditorEnabled = true)
+ val actionListPresenter = ActionListPresenter(preferencesStore = preferencesStore)
val customReactionPresenter = CustomReactionPresenter(emojibaseProvider = FakeEmojibaseProvider())
val reactionSummaryPresenter = ReactionSummaryPresenter(room = matrixRoom)
val retrySendMenuPresenter = RetrySendMenuPresenter(room = matrixRoom)
@@ -620,6 +640,8 @@ class MessagesPresenterTest {
messageSummaryFormatter = FakeMessageSummaryFormatter(),
navigator = navigator,
clipboardHelper = clipboardHelper,
+ analyticsService = analyticsService,
+ preferencesStore = preferencesStore,
dispatchers = coroutineDispatchers,
)
}
diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/actionlist/ActionListPresenterTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/actionlist/ActionListPresenterTest.kt
index a4ff484ff3..316e39317e 100644
--- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/actionlist/ActionListPresenterTest.kt
+++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/actionlist/ActionListPresenterTest.kt
@@ -31,16 +31,22 @@ import io.element.android.features.messages.impl.timeline.model.event.TimelineIt
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemImageContent
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemPollContent
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemStateEventContent
+import io.element.android.libraries.featureflag.test.InMemoryPreferencesStore
import io.element.android.libraries.matrix.test.A_MESSAGE
-import io.element.android.libraries.matrix.test.core.aBuildMeta
+import io.element.android.tests.testutils.WarmUpRule
import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.test.runTest
+import org.junit.Rule
import org.junit.Test
class ActionListPresenterTest {
+
+ @get:Rule
+ val warmUpRule = WarmUpRule()
+
@Test
fun `present - initial state`() = runTest {
- val presenter = anActionListPresenter(isBuildDebuggable = true)
+ val presenter = anActionListPresenter(isDeveloperModeEnabled = true)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
@@ -51,13 +57,13 @@ class ActionListPresenterTest {
@Test
fun `present - compute for message from me redacted`() = runTest {
- val presenter = anActionListPresenter(isBuildDebuggable = true)
+ val presenter = anActionListPresenter(isDeveloperModeEnabled = true)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
val messageEvent = aMessageEvent(isMine = true, content = TimelineItemRedactedContent)
- initialState.eventSink.invoke(ActionListEvents.ComputeForMessage(messageEvent, false))
+ initialState.eventSink.invoke(ActionListEvents.ComputeForMessage(messageEvent, canRedact = false, canSendMessage = true))
// val loadingState = awaitItem()
// assertThat(loadingState.target).isEqualTo(ActionListState.Target.Loading(messageEvent))
val successState = awaitItem()
@@ -65,7 +71,7 @@ class ActionListPresenterTest {
ActionListState.Target.Success(
messageEvent,
persistentListOf(
- TimelineItemAction.Developer,
+ TimelineItemAction.ViewSource,
)
)
)
@@ -76,13 +82,13 @@ class ActionListPresenterTest {
@Test
fun `present - compute for message from others redacted`() = runTest {
- val presenter = anActionListPresenter(isBuildDebuggable = true)
+ val presenter = anActionListPresenter(isDeveloperModeEnabled = true)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
val messageEvent = aMessageEvent(isMine = false, content = TimelineItemRedactedContent)
- initialState.eventSink.invoke(ActionListEvents.ComputeForMessage(messageEvent, false))
+ initialState.eventSink.invoke(ActionListEvents.ComputeForMessage(messageEvent, canRedact = false, canSendMessage = true))
// val loadingState = awaitItem()
// assertThat(loadingState.target).isEqualTo(ActionListState.Target.Loading(messageEvent))
val successState = awaitItem()
@@ -90,7 +96,7 @@ class ActionListPresenterTest {
ActionListState.Target.Success(
messageEvent,
persistentListOf(
- TimelineItemAction.Developer,
+ TimelineItemAction.ViewSource,
)
)
)
@@ -101,7 +107,7 @@ class ActionListPresenterTest {
@Test
fun `present - compute for others message`() = runTest {
- val presenter = anActionListPresenter(isBuildDebuggable = true)
+ val presenter = anActionListPresenter(isDeveloperModeEnabled = true)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
@@ -110,7 +116,7 @@ class ActionListPresenterTest {
isMine = false,
content = TimelineItemTextContent(body = A_MESSAGE, htmlDocument = null, isEdited = false)
)
- initialState.eventSink.invoke(ActionListEvents.ComputeForMessage(messageEvent, false))
+ initialState.eventSink.invoke(ActionListEvents.ComputeForMessage(messageEvent, canRedact = false, canSendMessage = true))
// val loadingState = awaitItem()
// assertThat(loadingState.target).isEqualTo(ActionListState.Target.Loading(messageEvent))
val successState = awaitItem()
@@ -121,7 +127,38 @@ class ActionListPresenterTest {
TimelineItemAction.Reply,
TimelineItemAction.Forward,
TimelineItemAction.Copy,
- TimelineItemAction.Developer,
+ TimelineItemAction.ViewSource,
+ TimelineItemAction.ReportContent,
+ )
+ )
+ )
+ initialState.eventSink.invoke(ActionListEvents.Clear)
+ assertThat(awaitItem().target).isEqualTo(ActionListState.Target.None)
+ }
+ }
+
+ @Test
+ fun `present - compute for others message cannot sent message`() = runTest {
+ val presenter = anActionListPresenter(isDeveloperModeEnabled = true)
+ moleculeFlow(RecompositionMode.Immediate) {
+ presenter.present()
+ }.test {
+ val initialState = awaitItem()
+ val messageEvent = aMessageEvent(
+ isMine = false,
+ content = TimelineItemTextContent(body = A_MESSAGE, htmlDocument = null, isEdited = false)
+ )
+ initialState.eventSink.invoke(ActionListEvents.ComputeForMessage(messageEvent, canRedact = false, canSendMessage = false))
+ // val loadingState = awaitItem()
+ // assertThat(loadingState.target).isEqualTo(ActionListState.Target.Loading(messageEvent))
+ val successState = awaitItem()
+ assertThat(successState.target).isEqualTo(
+ ActionListState.Target.Success(
+ messageEvent,
+ persistentListOf(
+ TimelineItemAction.Forward,
+ TimelineItemAction.Copy,
+ TimelineItemAction.ViewSource,
TimelineItemAction.ReportContent,
)
)
@@ -133,7 +170,7 @@ class ActionListPresenterTest {
@Test
fun `present - compute for others message and can redact`() = runTest {
- val presenter = anActionListPresenter(isBuildDebuggable = true)
+ val presenter = anActionListPresenter(isDeveloperModeEnabled = true)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
@@ -142,7 +179,7 @@ class ActionListPresenterTest {
isMine = false,
content = TimelineItemTextContent(body = A_MESSAGE, htmlDocument = null, isEdited = false)
)
- initialState.eventSink.invoke(ActionListEvents.ComputeForMessage(messageEvent, true))
+ initialState.eventSink.invoke(ActionListEvents.ComputeForMessage(messageEvent, canRedact = true, canSendMessage = true))
val successState = awaitItem()
assertThat(successState.target).isEqualTo(
ActionListState.Target.Success(
@@ -151,7 +188,7 @@ class ActionListPresenterTest {
TimelineItemAction.Reply,
TimelineItemAction.Forward,
TimelineItemAction.Copy,
- TimelineItemAction.Developer,
+ TimelineItemAction.ViewSource,
TimelineItemAction.ReportContent,
TimelineItemAction.Redact,
)
@@ -164,7 +201,7 @@ class ActionListPresenterTest {
@Test
fun `present - compute for my message`() = runTest {
- val presenter = anActionListPresenter(isBuildDebuggable = true)
+ val presenter = anActionListPresenter(isDeveloperModeEnabled = true)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
@@ -173,7 +210,7 @@ class ActionListPresenterTest {
isMine = true,
content = TimelineItemTextContent(body = A_MESSAGE, htmlDocument = null, isEdited = false)
)
- initialState.eventSink.invoke(ActionListEvents.ComputeForMessage(messageEvent, false))
+ initialState.eventSink.invoke(ActionListEvents.ComputeForMessage(messageEvent, canRedact = false, canSendMessage = true))
// val loadingState = awaitItem()
// assertThat(loadingState.target).isEqualTo(ActionListState.Target.Loading(messageEvent))
val successState = awaitItem()
@@ -185,7 +222,7 @@ class ActionListPresenterTest {
TimelineItemAction.Forward,
TimelineItemAction.Edit,
TimelineItemAction.Copy,
- TimelineItemAction.Developer,
+ TimelineItemAction.ViewSource,
TimelineItemAction.Redact,
)
)
@@ -197,7 +234,7 @@ class ActionListPresenterTest {
@Test
fun `present - compute for a media item`() = runTest {
- val presenter = anActionListPresenter(isBuildDebuggable = true)
+ val presenter = anActionListPresenter(isDeveloperModeEnabled = true)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
@@ -206,7 +243,7 @@ class ActionListPresenterTest {
isMine = true,
content = aTimelineItemImageContent(),
)
- initialState.eventSink.invoke(ActionListEvents.ComputeForMessage(messageEvent, false))
+ initialState.eventSink.invoke(ActionListEvents.ComputeForMessage(messageEvent, canRedact = false, canSendMessage = true))
// val loadingState = awaitItem()
// assertThat(loadingState.target).isEqualTo(ActionListState.Target.Loading(messageEvent))
val successState = awaitItem()
@@ -216,7 +253,7 @@ class ActionListPresenterTest {
persistentListOf(
TimelineItemAction.Reply,
TimelineItemAction.Forward,
- TimelineItemAction.Developer,
+ TimelineItemAction.ViewSource,
TimelineItemAction.Redact,
)
)
@@ -228,7 +265,7 @@ class ActionListPresenterTest {
@Test
fun `present - compute for a state item in debug build`() = runTest {
- val presenter = anActionListPresenter(isBuildDebuggable = true)
+ val presenter = anActionListPresenter(isDeveloperModeEnabled = true)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
@@ -237,7 +274,7 @@ class ActionListPresenterTest {
isMine = true,
content = aTimelineItemStateEventContent(),
)
- initialState.eventSink.invoke(ActionListEvents.ComputeForMessage(stateEvent, false))
+ initialState.eventSink.invoke(ActionListEvents.ComputeForMessage(stateEvent, canRedact = false, canSendMessage = true))
// val loadingState = awaitItem()
// assertThat(loadingState.target).isEqualTo(ActionListState.Target.Loading(messageEvent))
val successState = awaitItem()
@@ -246,7 +283,7 @@ class ActionListPresenterTest {
stateEvent,
persistentListOf(
TimelineItemAction.Copy,
- TimelineItemAction.Developer,
+ TimelineItemAction.ViewSource,
)
)
)
@@ -257,7 +294,7 @@ class ActionListPresenterTest {
@Test
fun `present - compute for a state item in non-debuggable build`() = runTest {
- val presenter = anActionListPresenter(isBuildDebuggable = false)
+ val presenter = anActionListPresenter(isDeveloperModeEnabled = false)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
@@ -266,7 +303,7 @@ class ActionListPresenterTest {
isMine = true,
content = aTimelineItemStateEventContent(),
)
- initialState.eventSink.invoke(ActionListEvents.ComputeForMessage(stateEvent, false))
+ initialState.eventSink.invoke(ActionListEvents.ComputeForMessage(stateEvent, canRedact = false, canSendMessage = true))
// val loadingState = awaitItem()
// assertThat(loadingState.target).isEqualTo(ActionListState.Target.Loading(messageEvent))
val successState = awaitItem()
@@ -285,7 +322,7 @@ class ActionListPresenterTest {
@Test
fun `present - compute message in non-debuggable build`() = runTest {
- val presenter = anActionListPresenter(isBuildDebuggable = false)
+ val presenter = anActionListPresenter(isDeveloperModeEnabled = false)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
@@ -294,7 +331,7 @@ class ActionListPresenterTest {
isMine = true,
content = TimelineItemTextContent(body = A_MESSAGE, htmlDocument = null, isEdited = false)
)
- initialState.eventSink.invoke(ActionListEvents.ComputeForMessage(messageEvent, false))
+ initialState.eventSink.invoke(ActionListEvents.ComputeForMessage(messageEvent, canRedact = false, canSendMessage = true))
// val loadingState = awaitItem()
// assertThat(loadingState.target).isEqualTo(ActionListState.Target.Loading(messageEvent))
val successState = awaitItem()
@@ -317,7 +354,7 @@ class ActionListPresenterTest {
@Test
fun `present - compute message with no actions`() = runTest {
- val presenter = anActionListPresenter(isBuildDebuggable = false)
+ val presenter = anActionListPresenter(isDeveloperModeEnabled = false)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
@@ -331,10 +368,10 @@ class ActionListPresenterTest {
content = TimelineItemRedactedContent,
)
- initialState.eventSink.invoke(ActionListEvents.ComputeForMessage(messageEvent, false))
+ initialState.eventSink.invoke(ActionListEvents.ComputeForMessage(messageEvent, canRedact = false, canSendMessage = true))
assertThat(awaitItem().target).isInstanceOf(ActionListState.Target.Success::class.java)
- initialState.eventSink.invoke(ActionListEvents.ComputeForMessage(redactedEvent, false))
+ initialState.eventSink.invoke(ActionListEvents.ComputeForMessage(redactedEvent, canRedact = false, canSendMessage = true))
awaitItem().run {
assertThat(target).isEqualTo(ActionListState.Target.None)
assertThat(displayEmojiReactions).isFalse()
@@ -344,7 +381,7 @@ class ActionListPresenterTest {
@Test
fun `present - compute not sent message`() = runTest {
- val presenter = anActionListPresenter(isBuildDebuggable = false)
+ val presenter = anActionListPresenter(isDeveloperModeEnabled = false)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
@@ -355,7 +392,7 @@ class ActionListPresenterTest {
content = TimelineItemTextContent(body = A_MESSAGE, htmlDocument = null, isEdited = false),
)
- initialState.eventSink.invoke(ActionListEvents.ComputeForMessage(messageEvent, false))
+ initialState.eventSink.invoke(ActionListEvents.ComputeForMessage(messageEvent, canRedact = false, canSendMessage = true))
val successState = awaitItem()
assertThat(successState.target).isEqualTo(
ActionListState.Target.Success(
@@ -373,7 +410,7 @@ class ActionListPresenterTest {
@Test
fun `present - compute for poll message`() = runTest {
- val presenter = anActionListPresenter(isBuildDebuggable = false)
+ val presenter = anActionListPresenter(isDeveloperModeEnabled = false)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
@@ -382,7 +419,7 @@ class ActionListPresenterTest {
isMine = true,
content = aTimelineItemPollContent(),
)
- initialState.eventSink.invoke(ActionListEvents.ComputeForMessage(messageEvent, false))
+ initialState.eventSink.invoke(ActionListEvents.ComputeForMessage(messageEvent, canRedact = false, canSendMessage = true))
val successState = awaitItem()
assertThat(successState.target).isEqualTo(
ActionListState.Target.Success(
@@ -399,7 +436,7 @@ class ActionListPresenterTest {
@Test
fun `present - compute for ended poll message`() = runTest {
- val presenter = anActionListPresenter(isBuildDebuggable = false)
+ val presenter = anActionListPresenter(isDeveloperModeEnabled = false)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
@@ -408,7 +445,7 @@ class ActionListPresenterTest {
isMine = true,
content = aTimelineItemPollContent(isEnded = true),
)
- initialState.eventSink.invoke(ActionListEvents.ComputeForMessage(messageEvent, false))
+ initialState.eventSink.invoke(ActionListEvents.ComputeForMessage(messageEvent, canRedact = false, canSendMessage = true))
val successState = awaitItem()
assertThat(successState.target).isEqualTo(
ActionListState.Target.Success(
@@ -423,5 +460,8 @@ class ActionListPresenterTest {
}
}
-private fun anActionListPresenter(isBuildDebuggable: Boolean) = ActionListPresenter(buildMeta = aBuildMeta(isDebuggable = isBuildDebuggable))
+private fun anActionListPresenter(isDeveloperModeEnabled: Boolean): ActionListPresenter {
+ val preferencesStore = InMemoryPreferencesStore(isDeveloperModeEnabled = isDeveloperModeEnabled)
+ return ActionListPresenter(preferencesStore = preferencesStore)
+}
diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/attachments/AttachmentsPreviewPresenterTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/attachments/AttachmentsPreviewPresenterTest.kt
index 3608d9e80e..30c8d761b3 100644
--- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/attachments/AttachmentsPreviewPresenterTest.kt
+++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/attachments/AttachmentsPreviewPresenterTest.kt
@@ -34,13 +34,18 @@ import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
import io.element.android.libraries.mediaupload.api.MediaPreProcessor
import io.element.android.libraries.mediaupload.api.MediaSender
import io.element.android.libraries.mediaupload.test.FakeMediaPreProcessor
+import io.element.android.tests.testutils.WarmUpRule
import io.mockk.mockk
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.runTest
+import org.junit.Rule
import org.junit.Test
class AttachmentsPreviewPresenterTest {
+ @get:Rule
+ val warmUpRule = WarmUpRule()
+
private val mediaPreProcessor = FakeMediaPreProcessor()
private val mockMediaUrl: Uri = mockk("localMediaUri")
diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/fixtures/aMessageEvent.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/fixtures/aMessageEvent.kt
index 4f1edcb64f..6d944075d1 100644
--- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/fixtures/aMessageEvent.kt
+++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/fixtures/aMessageEvent.kt
@@ -37,6 +37,7 @@ internal fun aMessageEvent(
isMine: Boolean = true,
content: TimelineItemEventContent = TimelineItemTextContent(body = A_MESSAGE, htmlDocument = null, isEdited = false),
inReplyTo: InReplyTo? = null,
+ isThreaded: Boolean = false,
debugInfo: TimelineItemDebugInfo = aTimelineItemDebugInfo(),
sendState: LocalEventSendState = LocalEventSendState.Sent(AN_EVENT_ID),
) = TimelineItem.Event(
@@ -52,5 +53,6 @@ internal fun aMessageEvent(
localSendState = sendState,
inReplyTo = inReplyTo,
debugInfo = debugInfo,
+ isThreaded = isThreaded,
origin = null
)
diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/forward/ForwardMessagesPresenterTests.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/forward/ForwardMessagesPresenterTests.kt
index 983b251df7..c3a70deac6 100644
--- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/forward/ForwardMessagesPresenterTests.kt
+++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/forward/ForwardMessagesPresenterTests.kt
@@ -30,13 +30,19 @@ import io.element.android.libraries.matrix.test.FakeMatrixClient
import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
import io.element.android.libraries.matrix.test.room.aRoomSummaryDetail
import io.element.android.libraries.matrix.test.roomlist.FakeRoomListService
+import io.element.android.tests.testutils.WarmUpRule
import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.test.runTest
+import org.junit.Rule
import org.junit.Test
class ForwardMessagesPresenterTests {
+ @get:Rule
+ val warmUpRule = WarmUpRule()
+
+
@Test
fun `present - initial state`() = runTest {
val presenter = aPresenter()
diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/media/viewer/MediaViewerPresenterTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/media/viewer/MediaViewerPresenterTest.kt
index 3c31fa49d3..6401f60f47 100644
--- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/media/viewer/MediaViewerPresenterTest.kt
+++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/media/viewer/MediaViewerPresenterTest.kt
@@ -33,15 +33,21 @@ import io.element.android.libraries.architecture.Async
import io.element.android.libraries.designsystem.utils.SnackbarDispatcher
import io.element.android.libraries.matrix.test.media.FakeMediaLoader
import io.element.android.libraries.matrix.test.media.aMediaSource
+import io.element.android.tests.testutils.WarmUpRule
import io.mockk.mockk
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.runTest
+import org.junit.Rule
import org.junit.Test
private val TESTED_MEDIA_INFO = aFileInfo()
class MediaViewerPresenterTest {
+ @get:Rule
+ val warmUpRule = WarmUpRule()
+
+
private val mockMediaUri: Uri = mockk("localMediaUri")
private val localMediaFactory = FakeLocalMediaFactory(mockMediaUri)
diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/report/ReportMessagePresenterTests.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/report/ReportMessagePresenterTests.kt
index e6b0dee0c9..8244562622 100644
--- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/report/ReportMessagePresenterTests.kt
+++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/report/ReportMessagePresenterTests.kt
@@ -28,11 +28,16 @@ import io.element.android.libraries.matrix.api.room.MatrixRoom
import io.element.android.libraries.matrix.test.AN_EVENT_ID
import io.element.android.libraries.matrix.test.A_USER_ID
import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
+import io.element.android.tests.testutils.WarmUpRule
import kotlinx.coroutines.test.runTest
+import org.junit.Rule
import org.junit.Test
class ReportMessagePresenterTests {
+ @get:Rule
+ val warmUpRule = WarmUpRule()
+
@Test
fun `presenter - initial state`() = runTest {
val presenter = aPresenter()
diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/textcomposer/MessageComposerPresenterTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/textcomposer/MessageComposerPresenterTest.kt
index 799db7f274..3f66269fe1 100644
--- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/textcomposer/MessageComposerPresenterTest.kt
+++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/textcomposer/MessageComposerPresenterTest.kt
@@ -24,6 +24,7 @@ import app.cash.molecule.moleculeFlow
import app.cash.turbine.ReceiveTurbine
import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
+import im.vector.app.features.analytics.plan.Composer
import io.element.android.features.messages.impl.messagecomposer.AttachmentsState
import io.element.android.features.messages.impl.messagecomposer.MessageComposerContextImpl
import io.element.android.features.messages.impl.messagecomposer.MessageComposerEvents
@@ -53,17 +54,24 @@ import io.element.android.libraries.mediaupload.api.MediaPreProcessor
import io.element.android.libraries.mediaupload.api.MediaSender
import io.element.android.libraries.mediaupload.api.MediaUploadInfo
import io.element.android.libraries.mediaupload.test.FakeMediaPreProcessor
+import io.element.android.libraries.textcomposer.Message
import io.element.android.libraries.textcomposer.MessageComposerMode
import io.element.android.services.analytics.test.FakeAnalyticsService
+import io.element.android.tests.testutils.WarmUpRule
+import io.element.android.tests.testutils.waitForPredicate
import io.mockk.mockk
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.runTest
+import org.junit.Rule
import org.junit.Test
import java.io.File
class MessageComposerPresenterTest {
+ @get:Rule
+ val warmUpRule = WarmUpRule()
+
private val pickerProvider = FakePickerProvider().apply {
givenResult(mockk()) // Uri is not available in JVM, so the only way to have a non-null Uri is using Mockk
}
@@ -74,6 +82,7 @@ class MessageComposerPresenterTest {
private val snackbarDispatcher = SnackbarDispatcher()
private val mockMediaUrl: Uri = mockk("localMediaUri")
private val localMediaFactory = FakeLocalMediaFactory(mockMediaUrl)
+ private val analyticsService = FakeAnalyticsService()
@Test
fun `present - initial state`() = runTest {
@@ -84,12 +93,12 @@ class MessageComposerPresenterTest {
skipItems(1)
val initialState = awaitItem()
assertThat(initialState.isFullScreen).isFalse()
- assertThat(initialState.text).isEqualTo("")
+ assertThat(initialState.richTextEditorState.messageHtml).isEqualTo("")
assertThat(initialState.mode).isEqualTo(MessageComposerMode.Normal(""))
assertThat(initialState.showAttachmentSourcePicker).isFalse()
assertThat(initialState.canShareLocation).isTrue()
assertThat(initialState.attachmentsState).isEqualTo(AttachmentsState.None)
- assertThat(initialState.isSendButtonVisible).isFalse()
+ assertThat(initialState.canSendMessage).isFalse()
}
}
@@ -118,14 +127,14 @@ class MessageComposerPresenterTest {
}.test {
skipItems(1)
val initialState = awaitItem()
- initialState.eventSink.invoke(MessageComposerEvents.UpdateText(A_MESSAGE))
+ initialState.richTextEditorState.setHtml(A_MESSAGE)
val withMessageState = awaitItem()
- assertThat(withMessageState.text).isEqualTo(A_MESSAGE)
- assertThat(withMessageState.isSendButtonVisible).isTrue()
- withMessageState.eventSink.invoke(MessageComposerEvents.UpdateText(""))
+ assertThat(withMessageState.richTextEditorState.messageHtml).isEqualTo(A_MESSAGE)
+ assertThat(withMessageState.canSendMessage).isTrue()
+ withMessageState.richTextEditorState.setHtml("")
val withEmptyMessageState = awaitItem()
- assertThat(withEmptyMessageState.text).isEqualTo("")
- assertThat(withEmptyMessageState.isSendButtonVisible).isFalse()
+ assertThat(withEmptyMessageState.richTextEditorState.messageHtml).isEqualTo("")
+ assertThat(withEmptyMessageState.canSendMessage).isFalse()
}
}
@@ -142,8 +151,8 @@ class MessageComposerPresenterTest {
state = awaitItem()
assertThat(state.mode).isEqualTo(mode)
state = awaitItem()
- assertThat(state.text).isEqualTo(A_MESSAGE)
- assertThat(state.isSendButtonVisible).isTrue()
+ assertThat(state.richTextEditorState.messageHtml).isEqualTo(A_MESSAGE)
+ assertThat(state.canSendMessage).isTrue()
backToNormalMode(state, skipCount = 1)
}
}
@@ -160,8 +169,8 @@ class MessageComposerPresenterTest {
state.eventSink.invoke(MessageComposerEvents.SetMode(mode))
state = awaitItem()
assertThat(state.mode).isEqualTo(mode)
- assertThat(state.text).isEqualTo("")
- assertThat(state.isSendButtonVisible).isFalse()
+ assertThat(state.richTextEditorState.messageHtml).isEqualTo("")
+ assertThat(state.canSendMessage).isFalse()
backToNormalMode(state)
}
}
@@ -178,8 +187,8 @@ class MessageComposerPresenterTest {
state.eventSink.invoke(MessageComposerEvents.SetMode(mode))
state = awaitItem()
assertThat(state.mode).isEqualTo(mode)
- assertThat(state.text).isEqualTo("")
- assertThat(state.isSendButtonVisible).isFalse()
+ assertThat(state.richTextEditorState.messageHtml).isEqualTo("")
+ assertThat(state.canSendMessage).isFalse()
backToNormalMode(state)
}
}
@@ -192,14 +201,23 @@ class MessageComposerPresenterTest {
}.test {
skipItems(1)
val initialState = awaitItem()
- initialState.eventSink.invoke(MessageComposerEvents.UpdateText(A_MESSAGE))
+ initialState.richTextEditorState.setHtml(A_MESSAGE)
val withMessageState = awaitItem()
- assertThat(withMessageState.text).isEqualTo(A_MESSAGE)
- assertThat(withMessageState.isSendButtonVisible).isTrue()
- withMessageState.eventSink.invoke(MessageComposerEvents.SendMessage(A_MESSAGE))
+ assertThat(withMessageState.richTextEditorState.messageHtml).isEqualTo(A_MESSAGE)
+ assertThat(withMessageState.canSendMessage).isTrue()
+ withMessageState.eventSink.invoke(MessageComposerEvents.SendMessage(A_MESSAGE.toMessage()))
val messageSentState = awaitItem()
- assertThat(messageSentState.text).isEqualTo("")
- assertThat(messageSentState.isSendButtonVisible).isFalse()
+ assertThat(messageSentState.richTextEditorState.messageHtml).isEqualTo("")
+ assertThat(messageSentState.canSendMessage).isFalse()
+ waitForPredicate { analyticsService.capturedEvents.size == 1 }
+ assertThat(analyticsService.capturedEvents).containsExactly(
+ Composer(
+ inThread = false,
+ isEditing = false,
+ isReply = false,
+ messageType = Composer.MessageType.Text,
+ )
+ )
}
}
@@ -215,23 +233,31 @@ class MessageComposerPresenterTest {
}.test {
skipItems(1)
val initialState = awaitItem()
- assertThat(initialState.text).isEqualTo("")
+ assertThat(initialState.richTextEditorState.messageHtml).isEqualTo("")
val mode = anEditMode()
initialState.eventSink.invoke(MessageComposerEvents.SetMode(mode))
skipItems(1)
val withMessageState = awaitItem()
assertThat(withMessageState.mode).isEqualTo(mode)
- assertThat(withMessageState.text).isEqualTo(A_MESSAGE)
- assertThat(withMessageState.isSendButtonVisible).isTrue()
- withMessageState.eventSink.invoke(MessageComposerEvents.UpdateText(ANOTHER_MESSAGE))
+ assertThat(withMessageState.richTextEditorState.messageHtml).isEqualTo(A_MESSAGE)
+ assertThat(withMessageState.canSendMessage).isTrue()
+ withMessageState.richTextEditorState.setHtml(ANOTHER_MESSAGE)
val withEditedMessageState = awaitItem()
- assertThat(withEditedMessageState.text).isEqualTo(ANOTHER_MESSAGE)
- withEditedMessageState.eventSink.invoke(MessageComposerEvents.SendMessage(ANOTHER_MESSAGE))
+ assertThat(withEditedMessageState.richTextEditorState.messageHtml).isEqualTo(ANOTHER_MESSAGE)
+ withEditedMessageState.eventSink.invoke(MessageComposerEvents.SendMessage(ANOTHER_MESSAGE.toMessage()))
skipItems(1)
val messageSentState = awaitItem()
- assertThat(messageSentState.text).isEqualTo("")
- assertThat(messageSentState.isSendButtonVisible).isFalse()
- assertThat(fakeMatrixRoom.editMessageCalls.first()).isEqualTo(ANOTHER_MESSAGE)
+ assertThat(messageSentState.richTextEditorState.messageHtml).isEqualTo("")
+ assertThat(messageSentState.canSendMessage).isFalse()
+ assertThat(fakeMatrixRoom.editMessageCalls.first()).isEqualTo(ANOTHER_MESSAGE to ANOTHER_MESSAGE)
+ assertThat(analyticsService.capturedEvents).containsExactly(
+ Composer(
+ inThread = false,
+ isEditing = true,
+ isReply = false,
+ messageType = Composer.MessageType.Text,
+ )
+ )
}
}
@@ -247,23 +273,31 @@ class MessageComposerPresenterTest {
}.test {
skipItems(1)
val initialState = awaitItem()
- assertThat(initialState.text).isEqualTo("")
+ assertThat(initialState.richTextEditorState.messageHtml).isEqualTo("")
val mode = anEditMode(eventId = null, transactionId = A_TRANSACTION_ID)
initialState.eventSink.invoke(MessageComposerEvents.SetMode(mode))
skipItems(1)
val withMessageState = awaitItem()
assertThat(withMessageState.mode).isEqualTo(mode)
- assertThat(withMessageState.text).isEqualTo(A_MESSAGE)
- assertThat(withMessageState.isSendButtonVisible).isTrue()
- withMessageState.eventSink.invoke(MessageComposerEvents.UpdateText(ANOTHER_MESSAGE))
+ assertThat(withMessageState.richTextEditorState.messageHtml).isEqualTo(A_MESSAGE)
+ assertThat(withMessageState.canSendMessage).isTrue()
+ withMessageState.richTextEditorState.setHtml(ANOTHER_MESSAGE)
val withEditedMessageState = awaitItem()
- assertThat(withEditedMessageState.text).isEqualTo(ANOTHER_MESSAGE)
- withEditedMessageState.eventSink.invoke(MessageComposerEvents.SendMessage(ANOTHER_MESSAGE))
+ assertThat(withEditedMessageState.richTextEditorState.messageHtml).isEqualTo(ANOTHER_MESSAGE)
+ withEditedMessageState.eventSink.invoke(MessageComposerEvents.SendMessage(ANOTHER_MESSAGE.toMessage()))
skipItems(1)
val messageSentState = awaitItem()
- assertThat(messageSentState.text).isEqualTo("")
- assertThat(messageSentState.isSendButtonVisible).isFalse()
- assertThat(fakeMatrixRoom.editMessageCalls.first()).isEqualTo(ANOTHER_MESSAGE)
+ assertThat(messageSentState.richTextEditorState.messageHtml).isEqualTo("")
+ assertThat(messageSentState.canSendMessage).isFalse()
+ assertThat(fakeMatrixRoom.editMessageCalls.first()).isEqualTo(ANOTHER_MESSAGE to ANOTHER_MESSAGE)
+ assertThat(analyticsService.capturedEvents).containsExactly(
+ Composer(
+ inThread = false,
+ isEditing = true,
+ isReply = false,
+ messageType = Composer.MessageType.Text,
+ )
+ )
}
}
@@ -279,23 +313,31 @@ class MessageComposerPresenterTest {
}.test {
skipItems(1)
val initialState = awaitItem()
- assertThat(initialState.text).isEqualTo("")
+ assertThat(initialState.richTextEditorState.messageHtml).isEqualTo("")
val mode = aReplyMode()
initialState.eventSink.invoke(MessageComposerEvents.SetMode(mode))
val state = awaitItem()
assertThat(state.mode).isEqualTo(mode)
- assertThat(state.text).isEqualTo("")
- assertThat(state.isSendButtonVisible).isFalse()
- initialState.eventSink.invoke(MessageComposerEvents.UpdateText(A_REPLY))
+ assertThat(state.richTextEditorState.messageHtml).isEqualTo("")
+ assertThat(state.canSendMessage).isFalse()
+ state.richTextEditorState.setHtml(A_REPLY)
val withMessageState = awaitItem()
- assertThat(withMessageState.text).isEqualTo(A_REPLY)
- assertThat(withMessageState.isSendButtonVisible).isTrue()
- withMessageState.eventSink.invoke(MessageComposerEvents.SendMessage(A_REPLY))
+ assertThat(withMessageState.richTextEditorState.messageHtml).isEqualTo(A_REPLY)
+ assertThat(withMessageState.canSendMessage).isTrue()
+ withMessageState.eventSink.invoke(MessageComposerEvents.SendMessage(A_REPLY.toMessage()))
skipItems(1)
val messageSentState = awaitItem()
- assertThat(messageSentState.text).isEqualTo("")
- assertThat(messageSentState.isSendButtonVisible).isFalse()
- assertThat(fakeMatrixRoom.replyMessageParameter).isEqualTo(A_REPLY)
+ assertThat(messageSentState.richTextEditorState.messageHtml).isEqualTo("")
+ assertThat(messageSentState.canSendMessage).isFalse()
+ assertThat(fakeMatrixRoom.replyMessageParameter).isEqualTo(A_REPLY to A_REPLY)
+ assertThat(analyticsService.capturedEvents).containsExactly(
+ Composer(
+ inThread = false,
+ isEditing = false,
+ isReply = true,
+ messageType = Composer.MessageType.Text,
+ )
+ )
}
}
@@ -517,13 +559,50 @@ class MessageComposerPresenterTest {
}
}
+ @Test
+ fun `present - errors are tracked`() = runTest {
+ val testException = Exception("Test error")
+ val presenter = createPresenter(this)
+ moleculeFlow(RecompositionMode.Immediate) {
+ presenter.present()
+ }.test {
+ skipItems(1)
+ val initialState = awaitItem()
+ initialState.eventSink(MessageComposerEvents.Error(testException))
+ assertThat(analyticsService.trackedErrors).containsExactly(testException)
+ }
+ }
+
+ @Test
+ fun `present - ToggleTextFormatting toggles text formatting`() = runTest {
+ val presenter = createPresenter(this)
+ moleculeFlow(RecompositionMode.Immediate) {
+ presenter.present()
+ }.test {
+ skipItems(1)
+ val initialState = awaitItem()
+ assertThat(initialState.showTextFormatting).isFalse()
+ initialState.eventSink(MessageComposerEvents.AddAttachment)
+ val composerOptions = awaitItem()
+ assertThat(composerOptions.showAttachmentSourcePicker).isTrue()
+ composerOptions.eventSink(MessageComposerEvents.ToggleTextFormatting(true))
+ awaitItem() // composer options closed
+ val showTextFormatting = awaitItem()
+ assertThat(showTextFormatting.showAttachmentSourcePicker).isFalse()
+ assertThat(showTextFormatting.showTextFormatting).isTrue()
+ showTextFormatting.eventSink(MessageComposerEvents.ToggleTextFormatting(false))
+ val finished = awaitItem()
+ assertThat(finished.showTextFormatting).isFalse()
+ }
+ }
+
private suspend fun ReceiveTurbine.backToNormalMode(state: MessageComposerState, skipCount: Int = 0) {
state.eventSink.invoke(MessageComposerEvents.CloseSpecialMode)
skipItems(skipCount)
val normalState = awaitItem()
assertThat(normalState.mode).isEqualTo(MessageComposerMode.Normal(""))
- assertThat(normalState.text).isEqualTo("")
- assertThat(normalState.isSendButtonVisible).isFalse()
+ assertThat(normalState.richTextEditorState.messageHtml).isEqualTo("")
+ assertThat(normalState.canSendMessage).isFalse()
}
private fun createPresenter(
@@ -541,8 +620,9 @@ class MessageComposerPresenterTest {
localMediaFactory,
MediaSender(mediaPreProcessor, room),
snackbarDispatcher,
- FakeAnalyticsService(),
+ analyticsService,
MessageComposerContextImpl(),
+ TestRichTextEditorStateFactory(),
)
}
@@ -552,5 +632,10 @@ fun anEditMode(
transactionId: TransactionId? = null,
) = MessageComposerMode.Edit(eventId, message, transactionId)
-fun aReplyMode() = MessageComposerMode.Reply(A_USER_NAME, null, AN_EVENT_ID, A_MESSAGE)
+fun aReplyMode() = MessageComposerMode.Reply(A_USER_NAME, null, false, AN_EVENT_ID, A_MESSAGE)
fun aQuoteMode() = MessageComposerMode.Quote(AN_EVENT_ID, A_MESSAGE)
+
+private fun String.toMessage() = Message(
+ html = this,
+ markdown = this,
+)
diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/textcomposer/TestRichTextEditorStateFactory.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/textcomposer/TestRichTextEditorStateFactory.kt
new file mode 100644
index 0000000000..762d144cd6
--- /dev/null
+++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/textcomposer/TestRichTextEditorStateFactory.kt
@@ -0,0 +1,29 @@
+/*
+ * Copyright (c) 2023 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.element.android.features.messages.textcomposer
+
+import androidx.compose.runtime.Composable
+import io.element.android.features.messages.impl.messagecomposer.RichTextEditorStateFactory
+import io.element.android.wysiwyg.compose.RichTextEditorState
+import io.element.android.wysiwyg.compose.rememberRichTextEditorState
+
+class TestRichTextEditorStateFactory : RichTextEditorStateFactory {
+ @Composable
+ override fun create(): RichTextEditorState {
+ return rememberRichTextEditorState("", fake = true)
+ }
+}
diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/timeline/TimelinePresenterTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/timeline/TimelinePresenterTest.kt
index 860f8def37..0435be3cb1 100644
--- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/timeline/TimelinePresenterTest.kt
+++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/timeline/TimelinePresenterTest.kt
@@ -20,6 +20,7 @@ import app.cash.molecule.RecompositionMode
import app.cash.molecule.moleculeFlow
import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
+import im.vector.app.features.analytics.plan.PollVote
import io.element.android.features.messages.fixtures.aTimelineItemsFactory
import io.element.android.features.messages.impl.timeline.TimelineEvents
import io.element.android.features.messages.impl.timeline.TimelinePresenter
@@ -37,15 +38,22 @@ import io.element.android.libraries.matrix.test.room.aMessageContent
import io.element.android.libraries.matrix.test.room.anEventTimelineItem
import io.element.android.libraries.matrix.test.timeline.FakeMatrixTimeline
import io.element.android.libraries.matrix.ui.components.aMatrixUserList
+import io.element.android.services.analytics.test.FakeAnalyticsService
+import io.element.android.tests.testutils.WarmUpRule
import io.element.android.tests.testutils.awaitWithLatch
import io.element.android.tests.testutils.testCoroutineDispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.runTest
+import org.junit.Rule
import org.junit.Test
import java.util.Date
class TimelinePresenterTest {
+
+ @get:Rule
+ val warmUpRule = WarmUpRule()
+
@Test
fun `present - initial state`() = runTest {
val presenter = createTimelinePresenter()
@@ -253,7 +261,11 @@ class TimelinePresenterTest {
@Test
fun `present - PollAnswerSelected event calls into rust room api and analytics`() = runTest {
val room = FakeMatrixRoom()
- val presenter = createTimelinePresenter(room)
+ val analyticsService = FakeAnalyticsService()
+ val presenter = createTimelinePresenter(
+ room = room,
+ analyticsService = analyticsService,
+ )
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
@@ -264,7 +276,8 @@ class TimelinePresenterTest {
assertThat(room.sendPollResponseInvocations.size).isEqualTo(1)
assertThat(room.sendPollResponseInvocations.first().answers).isEqualTo(listOf("anAnswerId"))
assertThat(room.sendPollResponseInvocations.first().pollStartId).isEqualTo(AN_EVENT_ID)
- // TODO Polls: Test poll vote analytic
+ assertThat(analyticsService.capturedEvents.size).isEqualTo(1)
+ assertThat(analyticsService.capturedEvents.last()).isEqualTo(PollVote())
}
private fun TestScope.createTimelinePresenter(
@@ -275,18 +288,21 @@ class TimelinePresenterTest {
timelineItemsFactory = timelineItemsFactory,
room = FakeMatrixRoom(matrixTimeline = timeline),
dispatchers = testCoroutineDispatchers(),
- appScope = this
+ appScope = this,
+ analyticsService = FakeAnalyticsService(),
)
}
private fun TestScope.createTimelinePresenter(
room: MatrixRoom,
+ analyticsService: FakeAnalyticsService = FakeAnalyticsService(),
): TimelinePresenter {
return TimelinePresenter(
timelineItemsFactory = aTimelineItemsFactory(),
room = room,
dispatchers = testCoroutineDispatchers(),
- appScope = this
+ appScope = this,
+ analyticsService = analyticsService,
)
}
}
diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/timeline/components/customreaction/CustomReactionPresenterTests.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/timeline/components/customreaction/CustomReactionPresenterTests.kt
index bb77605132..40912c0cc0 100644
--- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/timeline/components/customreaction/CustomReactionPresenterTests.kt
+++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/timeline/components/customreaction/CustomReactionPresenterTests.kt
@@ -26,11 +26,16 @@ import io.element.android.features.messages.impl.timeline.components.customreact
import io.element.android.features.messages.impl.timeline.components.customreaction.CustomReactionPresenter
import io.element.android.features.messages.impl.timeline.components.customreaction.CustomReactionState
import io.element.android.libraries.matrix.test.AN_EVENT_ID
+import io.element.android.tests.testutils.WarmUpRule
import kotlinx.coroutines.test.runTest
+import org.junit.Rule
import org.junit.Test
class CustomReactionPresenterTests {
+ @get:Rule
+ val warmUpRule = WarmUpRule()
+
private val presenter = CustomReactionPresenter(emojibaseProvider = FakeEmojibaseProvider())
@Test
diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/timeline/components/reactionsummary/ReactionSummaryPresenterTests.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/timeline/components/reactionsummary/ReactionSummaryPresenterTests.kt
index 0170878cb5..7b18d890df 100644
--- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/timeline/components/reactionsummary/ReactionSummaryPresenterTests.kt
+++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/timeline/components/reactionsummary/ReactionSummaryPresenterTests.kt
@@ -30,10 +30,16 @@ import io.element.android.libraries.matrix.test.A_USER_ID
import io.element.android.libraries.matrix.test.A_USER_NAME
import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
import io.element.android.libraries.matrix.test.room.aRoomMember
+import io.element.android.tests.testutils.WarmUpRule
import kotlinx.coroutines.test.runTest
+import org.junit.Rule
import org.junit.Test
class ReactionSummaryPresenterTests {
+
+ @get:Rule
+ val warmUpRule = WarmUpRule()
+
private val aggregatedReaction = anAggregatedReaction(userId = A_USER_ID, key = "👍", isHighlighted = true)
private val roomMember = aRoomMember(userId = A_USER_ID, avatarUrl = AN_AVATAR_URL, displayName = A_USER_NAME)
private val summaryEvent = ReactionSummaryEvents.ShowReactionSummary(AN_EVENT_ID, listOf(aggregatedReaction), aggregatedReaction.key)
diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/timeline/components/retrysendmenu/RetrySendMenuPresenterTests.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/timeline/components/retrysendmenu/RetrySendMenuPresenterTests.kt
index 4f4f0a0ee4..011db1f038 100644
--- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/timeline/components/retrysendmenu/RetrySendMenuPresenterTests.kt
+++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/timeline/components/retrysendmenu/RetrySendMenuPresenterTests.kt
@@ -25,11 +25,16 @@ import io.element.android.features.messages.impl.timeline.components.retrysendme
import io.element.android.features.messages.impl.timeline.components.retrysendmenu.RetrySendMenuPresenter
import io.element.android.libraries.matrix.test.A_TRANSACTION_ID
import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
+import io.element.android.tests.testutils.WarmUpRule
import kotlinx.coroutines.test.runTest
+import org.junit.Rule
import org.junit.Test
class RetrySendMenuPresenterTests {
+ @get:Rule
+ val warmUpRule = WarmUpRule()
+
private val room = FakeMatrixRoom()
private val presenter = RetrySendMenuPresenter(room)
diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/timeline/groups/TimelineItemGrouperTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/timeline/groups/TimelineItemGrouperTest.kt
index d5ce31f87a..4c43a8552f 100644
--- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/timeline/groups/TimelineItemGrouperTest.kt
+++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/timeline/groups/TimelineItemGrouperTest.kt
@@ -44,6 +44,7 @@ class TimelineItemGrouperTest {
reactionsState = aTimelineItemReactions(count = 0),
localSendState = LocalEventSendState.Sent(AN_EVENT_ID),
inReplyTo = null,
+ isThreaded = false,
debugInfo = aTimelineItemDebugInfo(),
origin = null
)
diff --git a/features/networkmonitor/api/src/main/kotlin/io/element/android/features/networkmonitor/api/ui/ConnectivityIndicatorView.kt b/features/networkmonitor/api/src/main/kotlin/io/element/android/features/networkmonitor/api/ui/ConnectivityIndicatorView.kt
index 855bf067dd..dd586d4576 100644
--- a/features/networkmonitor/api/src/main/kotlin/io/element/android/features/networkmonitor/api/ui/ConnectivityIndicatorView.kt
+++ b/features/networkmonitor/api/src/main/kotlin/io/element/android/features/networkmonitor/api/ui/ConnectivityIndicatorView.kt
@@ -18,6 +18,9 @@ package io.element.android.features.networkmonitor.api.ui
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.MutableTransitionState
+import androidx.compose.animation.core.Spring
+import androidx.compose.animation.core.animateDpAsState
+import androidx.compose.animation.core.spring
import androidx.compose.animation.expandVertically
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
@@ -27,34 +30,44 @@ import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.WindowInsets
+import androidx.compose.foundation.layout.asPaddingValues
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.statusBars
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.layout.width
-import androidx.compose.material.Text
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.WifiOff
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.ColorFilter
+import androidx.compose.ui.platform.LocalInspectionMode
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
import io.element.android.libraries.designsystem.text.toDp
+import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.theme.ElementTheme
import io.element.android.libraries.ui.strings.CommonStrings
+/**
+ * A view that displays a connectivity indicator when the device is offline, adding a default
+ * padding to make sure the status bar is not overlapped.
+ */
@Composable
fun ConnectivityIndicatorView(
isOnline: Boolean,
- modifier: Modifier = Modifier
+ modifier: Modifier = Modifier,
) {
val isIndicatorVisible = remember { MutableTransitionState(!isOnline) }.apply { targetState = !isOnline }
val isStatusBarPaddingVisible = remember { MutableTransitionState(isOnline) }.apply { targetState = isOnline }
@@ -78,6 +91,48 @@ fun ConnectivityIndicatorView(
}
}
+/**
+ * A view that displays a connectivity indicator when the device is offline, passing the padding
+ * needed to make sure the status bar is not overlapped to its content views.
+ */
+@Composable
+fun ConnectivityIndicatorContainer(
+ isOnline: Boolean,
+ modifier: Modifier = Modifier,
+ content: @Composable (topPadding: Dp) -> Unit,
+) {
+ val isIndicatorVisible = remember { MutableTransitionState(!isOnline) }.apply { targetState = !isOnline }
+
+ val statusBarTopPadding = if (LocalInspectionMode.current) {
+ // Needed to get valid UI previews
+ 24.dp
+ } else {
+ WindowInsets.statusBars.asPaddingValues().calculateTopPadding() + 6.dp
+ }
+ val target = remember(isIndicatorVisible.targetState, statusBarTopPadding) {
+ if (!isIndicatorVisible.targetState) 0.dp else statusBarTopPadding
+ }
+ val animationStateOffset by animateDpAsState(
+ targetValue = target,
+ animationSpec = spring(
+ stiffness = Spring.StiffnessMediumLow,
+ visibilityThreshold = 1.dp,
+ ),
+ label = "insets-animation",
+ )
+
+ content(animationStateOffset)
+
+ // Display the network indicator with an animation
+ AnimatedVisibility(
+ visibleState = isIndicatorVisible,
+ enter = fadeIn() + expandVertically(),
+ exit = fadeOut() + shrinkVertically(),
+ ) {
+ Indicator(modifier)
+ }
+}
+
@Composable
private fun Indicator(modifier: Modifier = Modifier) {
Row(
diff --git a/features/onboarding/api/src/main/kotlin/io/element/android/features/onboarding/api/OnBoardingEntryPoint.kt b/features/onboarding/api/src/main/kotlin/io/element/android/features/onboarding/api/OnBoardingEntryPoint.kt
index 7be45ce236..d183b05386 100644
--- a/features/onboarding/api/src/main/kotlin/io/element/android/features/onboarding/api/OnBoardingEntryPoint.kt
+++ b/features/onboarding/api/src/main/kotlin/io/element/android/features/onboarding/api/OnBoardingEntryPoint.kt
@@ -33,5 +33,6 @@ interface OnBoardingEntryPoint : FeatureEntryPoint {
interface Callback : Plugin {
fun onSignUp()
fun onSignIn()
+ fun onOpenDeveloperSettings()
}
}
diff --git a/features/onboarding/impl/build.gradle.kts b/features/onboarding/impl/build.gradle.kts
index 0f97a0ffeb..9994eacf81 100644
--- a/features/onboarding/impl/build.gradle.kts
+++ b/features/onboarding/impl/build.gradle.kts
@@ -48,4 +48,5 @@ dependencies {
testImplementation(libs.test.truth)
testImplementation(libs.test.turbine)
testImplementation(projects.libraries.matrix.test)
+ testImplementation(projects.tests.testutils)
}
diff --git a/features/onboarding/impl/src/main/kotlin/io/element/android/features/onboarding/impl/OnBoardingNode.kt b/features/onboarding/impl/src/main/kotlin/io/element/android/features/onboarding/impl/OnBoardingNode.kt
index d86623cae2..21322657c1 100644
--- a/features/onboarding/impl/src/main/kotlin/io/element/android/features/onboarding/impl/OnBoardingNode.kt
+++ b/features/onboarding/impl/src/main/kotlin/io/element/android/features/onboarding/impl/OnBoardingNode.kt
@@ -46,6 +46,10 @@ class OnBoardingNode @AssistedInject constructor(
plugins().forEach { it.onSignUp() }
}
+ private fun onOpenDeveloperSettings() {
+ plugins().forEach { it.onOpenDeveloperSettings() }
+ }
+
@Composable
override fun View(modifier: Modifier) {
val state = presenter.present()
@@ -54,6 +58,8 @@ class OnBoardingNode @AssistedInject constructor(
modifier = modifier,
onSignIn = ::onSignIn,
onCreateAccount = ::onSignUp,
+ onSignInWithQrCode = { /* Not supported yet */ },
+ onOpenDeveloperSettings = ::onOpenDeveloperSettings,
)
}
}
diff --git a/features/onboarding/impl/src/main/kotlin/io/element/android/features/onboarding/impl/OnBoardingPresenter.kt b/features/onboarding/impl/src/main/kotlin/io/element/android/features/onboarding/impl/OnBoardingPresenter.kt
index 48a360e6c9..b26752fdbe 100644
--- a/features/onboarding/impl/src/main/kotlin/io/element/android/features/onboarding/impl/OnBoardingPresenter.kt
+++ b/features/onboarding/impl/src/main/kotlin/io/element/android/features/onboarding/impl/OnBoardingPresenter.kt
@@ -18,6 +18,8 @@ package io.element.android.features.onboarding.impl
import androidx.compose.runtime.Composable
import io.element.android.libraries.architecture.Presenter
+import io.element.android.libraries.core.meta.BuildMeta
+import io.element.android.libraries.core.meta.BuildType
import javax.inject.Inject
/**
@@ -25,10 +27,12 @@ import javax.inject.Inject
* When this presenter get more code in it, please remove the ignore rule in the kover configuration.
*/
class OnBoardingPresenter @Inject constructor(
+ private val buildMeta: BuildMeta,
) : Presenter {
@Composable
override fun present(): OnBoardingState {
return OnBoardingState(
+ isDebugBuild = buildMeta.buildType != BuildType.RELEASE,
canLoginWithQrCode = OnBoardingConfig.canLoginWithQrCode,
canCreateAccount = OnBoardingConfig.canCreateAccount,
)
diff --git a/features/onboarding/impl/src/main/kotlin/io/element/android/features/onboarding/impl/OnBoardingState.kt b/features/onboarding/impl/src/main/kotlin/io/element/android/features/onboarding/impl/OnBoardingState.kt
index 88215c0c1e..5bd7718033 100644
--- a/features/onboarding/impl/src/main/kotlin/io/element/android/features/onboarding/impl/OnBoardingState.kt
+++ b/features/onboarding/impl/src/main/kotlin/io/element/android/features/onboarding/impl/OnBoardingState.kt
@@ -17,6 +17,7 @@
package io.element.android.features.onboarding.impl
data class OnBoardingState(
+ val isDebugBuild: Boolean,
val canLoginWithQrCode: Boolean,
val canCreateAccount: Boolean,
)
diff --git a/features/onboarding/impl/src/main/kotlin/io/element/android/features/onboarding/impl/OnBoardingStateProvider.kt b/features/onboarding/impl/src/main/kotlin/io/element/android/features/onboarding/impl/OnBoardingStateProvider.kt
index 1c60a56018..926d2a2303 100644
--- a/features/onboarding/impl/src/main/kotlin/io/element/android/features/onboarding/impl/OnBoardingStateProvider.kt
+++ b/features/onboarding/impl/src/main/kotlin/io/element/android/features/onboarding/impl/OnBoardingStateProvider.kt
@@ -25,13 +25,16 @@ open class OnBoardingStateProvider : PreviewParameterProvider {
anOnBoardingState(canLoginWithQrCode = true),
anOnBoardingState(canCreateAccount = true),
anOnBoardingState(canLoginWithQrCode = true, canCreateAccount = true),
+ anOnBoardingState(isDebugBuild = true),
)
}
fun anOnBoardingState(
+ isDebugBuild: Boolean = false,
canLoginWithQrCode: Boolean = false,
canCreateAccount: Boolean = false
) = OnBoardingState(
+ isDebugBuild = isDebugBuild,
canLoginWithQrCode = canLoginWithQrCode,
canCreateAccount = canCreateAccount
)
diff --git a/features/onboarding/impl/src/main/kotlin/io/element/android/features/onboarding/impl/OnBoardingView.kt b/features/onboarding/impl/src/main/kotlin/io/element/android/features/onboarding/impl/OnBoardingView.kt
index 1adfe6bd93..424c24839a 100644
--- a/features/onboarding/impl/src/main/kotlin/io/element/android/features/onboarding/impl/OnBoardingView.kt
+++ b/features/onboarding/impl/src/main/kotlin/io/element/android/features/onboarding/impl/OnBoardingView.kt
@@ -25,7 +25,9 @@ import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.QrCode
+import androidx.compose.material.icons.filled.Settings
import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
import androidx.compose.ui.Alignment.Companion.CenterHorizontally
import androidx.compose.ui.BiasAlignment
import androidx.compose.ui.Modifier
@@ -41,6 +43,8 @@ import io.element.android.libraries.designsystem.atomic.pages.OnBoardingPage
import io.element.android.libraries.designsystem.preview.DayNightPreviews
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.theme.components.Button
+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.IconSource
import io.element.android.libraries.designsystem.theme.components.OutlinedButton
import io.element.android.libraries.designsystem.theme.components.Text
@@ -57,15 +61,19 @@ import io.element.android.libraries.ui.strings.CommonStrings
@Composable
fun OnBoardingView(
state: OnBoardingState,
+ onSignInWithQrCode: () -> Unit,
+ onSignIn: () -> Unit,
+ onCreateAccount: () -> Unit,
+ onOpenDeveloperSettings: () -> Unit,
modifier: Modifier = Modifier,
- onSignInWithQrCode: () -> Unit = {},
- onSignIn: () -> Unit = {},
- onCreateAccount: () -> Unit = {},
) {
OnBoardingPage(
modifier = modifier,
content = {
- OnBoardingContent()
+ OnBoardingContent(
+ state = state,
+ onOpenDeveloperSettings = onOpenDeveloperSettings
+ )
},
footer = {
OnBoardingButtons(
@@ -79,7 +87,11 @@ fun OnBoardingView(
}
@Composable
-private fun OnBoardingContent(modifier: Modifier = Modifier) {
+private fun OnBoardingContent(
+ state: OnBoardingState,
+ onOpenDeveloperSettings: () -> Unit,
+ modifier: Modifier = Modifier
+) {
Box(
modifier = modifier.fillMaxSize(),
) {
@@ -122,6 +134,17 @@ private fun OnBoardingContent(modifier: Modifier = Modifier) {
)
}
}
+ if (state.isDebugBuild) {
+ IconButton(
+ modifier = Modifier.align(Alignment.TopEnd),
+ onClick = onOpenDeveloperSettings,
+ ) {
+ Icon(
+ imageVector = Icons.Filled.Settings,
+ contentDescription = stringResource(CommonStrings.common_settings)
+ )
+ }
+ }
}
}
@@ -172,5 +195,11 @@ private fun OnBoardingButtons(
internal fun OnBoardingScreenPreview(
@PreviewParameter(OnBoardingStateProvider::class) state: OnBoardingState
) = ElementPreview {
- OnBoardingView(state)
+ OnBoardingView(
+ state = state,
+ onSignInWithQrCode = {},
+ onSignIn = {},
+ onCreateAccount = {},
+ onOpenDeveloperSettings = {}
+ )
}
diff --git a/features/onboarding/impl/src/main/res/values-de/translations.xml b/features/onboarding/impl/src/main/res/values-de/translations.xml
index 82e20c3509..b7f231a32a 100644
--- a/features/onboarding/impl/src/main/res/values-de/translations.xml
+++ b/features/onboarding/impl/src/main/res/values-de/translations.xml
@@ -5,6 +5,6 @@
"Konto erstellen"
"Sicher kommunizieren und zusammenarbeiten"
"Willkommen beim schnellsten Element aller Zeiten. Optimiert für Geschwindigkeit und Einfachheit."
- "Willkommen zur %1$s. Verbessert, für Geschwindigkeit und Einfachheit."
+ "Willkommen zu %1$s. Aufgeladen, für Geschwindigkeit und Einfachheit."
"Sei in deinem Element"
diff --git a/features/onboarding/impl/src/main/res/values-fr/translations.xml b/features/onboarding/impl/src/main/res/values-fr/translations.xml
index 5ed96ebf60..789b29c5b8 100644
--- a/features/onboarding/impl/src/main/res/values-fr/translations.xml
+++ b/features/onboarding/impl/src/main/res/values-fr/translations.xml
@@ -1,10 +1,10 @@
"Se connecter manuellement"
- "Se connecter avec un code QR"
+ "Se connecter avec un QR code"
"Créer un compte"
- "Communiquer et collaborer en toute sécurité"
- "Bienvenue dans l’Element le plus rapide de tous les temps. Surpuissant pour plus de vitesse et de simplicité."
- "Bienvenue dans %1$s. Affiné pour plus de rapidité et de simplicité."
+ "Communiquez et collaborez en toute sécurité"
+ "Bienvenue dans l’Element le plus rapide de tous les temps. Boosté pour plus de rapidité et de simplicité."
+ "Bienvenue sur %1$s. Boosté, pour plus de rapidité et de simplicité."
"Soyez dans votre Element"
diff --git a/features/onboarding/impl/src/main/res/values-zh-rTW/translations.xml b/features/onboarding/impl/src/main/res/values-zh-rTW/translations.xml
index 5150b50c9a..22c9d70004 100644
--- a/features/onboarding/impl/src/main/res/values-zh-rTW/translations.xml
+++ b/features/onboarding/impl/src/main/res/values-zh-rTW/translations.xml
@@ -4,5 +4,5 @@
"使用 QR code 登入"
"建立帳號"
"歡迎使用有史以來最快的 Element。速度超快,操作簡便。"
- "得心應手"
+ "Be in your element"
diff --git a/features/onboarding/impl/src/test/kotlin/io/element/android/features/onboarding/impl/OnBoardingPresenterTest.kt b/features/onboarding/impl/src/test/kotlin/io/element/android/features/onboarding/impl/OnBoardingPresenterTest.kt
index d336e5b466..9a356aa488 100644
--- a/features/onboarding/impl/src/test/kotlin/io/element/android/features/onboarding/impl/OnBoardingPresenterTest.kt
+++ b/features/onboarding/impl/src/test/kotlin/io/element/android/features/onboarding/impl/OnBoardingPresenterTest.kt
@@ -20,19 +20,39 @@ import app.cash.molecule.RecompositionMode
import app.cash.molecule.moleculeFlow
import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
+import io.element.android.libraries.core.meta.BuildType
+import io.element.android.libraries.matrix.test.core.aBuildMeta
+import io.element.android.tests.testutils.WarmUpRule
import kotlinx.coroutines.test.runTest
+import org.junit.Rule
import org.junit.Test
class OnBoardingPresenterTest {
+
+ @get:Rule
+ val warmUpRule = WarmUpRule()
+
@Test
fun `present - initial state`() = runTest {
- val presenter = OnBoardingPresenter()
+ val presenter = OnBoardingPresenter(aBuildMeta())
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
+ assertThat(initialState.isDebugBuild).isTrue()
assertThat(initialState.canLoginWithQrCode).isFalse()
assertThat(initialState.canCreateAccount).isFalse()
}
}
+
+ @Test
+ fun `present - initial state release`() = runTest {
+ val presenter = OnBoardingPresenter(aBuildMeta(buildType = BuildType.RELEASE))
+ moleculeFlow(RecompositionMode.Immediate) {
+ presenter.present()
+ }.test {
+ val initialState = awaitItem()
+ assertThat(initialState.isDebugBuild).isFalse()
+ }
+ }
}
diff --git a/features/poll/api/src/main/kotlin/io/element/android/features/poll/api/PollAnswerView.kt b/features/poll/api/src/main/kotlin/io/element/android/features/poll/api/PollAnswerView.kt
index 3e52cc5fc7..0e4b168866 100644
--- a/features/poll/api/src/main/kotlin/io/element/android/features/poll/api/PollAnswerView.kt
+++ b/features/poll/api/src/main/kotlin/io/element/android/features/poll/api/PollAnswerView.kt
@@ -21,66 +21,56 @@ import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
-import androidx.compose.foundation.selection.selectable
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.CheckCircle
import androidx.compose.material.icons.filled.RadioButtonUnchecked
-import androidx.compose.material3.IconButtonDefaults
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.res.pluralStringResource
-import androidx.compose.ui.semantics.Role
-import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
-import io.element.android.libraries.designsystem.preview.ElementThemedPreview
+import io.element.android.libraries.designsystem.preview.DayNightPreviews
+import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.theme.components.Icon
-import io.element.android.libraries.designsystem.theme.components.IconToggleButton
import io.element.android.libraries.designsystem.theme.components.LinearProgressIndicator
import io.element.android.libraries.designsystem.theme.components.Text
+import io.element.android.libraries.designsystem.theme.progressIndicatorTrackColor
import io.element.android.libraries.designsystem.toEnabledColor
import io.element.android.libraries.theme.ElementTheme
import io.element.android.libraries.ui.strings.CommonPlurals
@Composable
-fun PollAnswerView(
+internal fun PollAnswerView(
answerItem: PollAnswerItem,
- onClick: () -> Unit,
modifier: Modifier = Modifier,
) {
Row(
- modifier
- .fillMaxWidth()
- .selectable(
- selected = answerItem.isSelected,
- enabled = answerItem.isEnabled,
- onClick = onClick,
- role = Role.RadioButton,
- )
+ modifier = modifier.fillMaxWidth(),
) {
- IconToggleButton(
- modifier = Modifier.size(22.dp),
- checked = answerItem.isSelected,
- enabled = answerItem.isEnabled,
- colors = IconButtonDefaults.iconToggleButtonColors(
- contentColor = ElementTheme.colors.iconSecondary,
- checkedContentColor = ElementTheme.colors.iconPrimary,
- disabledContentColor = ElementTheme.colors.iconDisabled,
- ),
- onCheckedChange = { onClick() },
- ) {
- Icon(
- imageVector = if (answerItem.isSelected) {
- Icons.Default.CheckCircle
+ Icon(
+ imageVector = if (answerItem.isSelected) {
+ Icons.Default.CheckCircle
+ } else {
+ Icons.Default.RadioButtonUnchecked
+ },
+ contentDescription = null,
+ modifier = Modifier
+ .padding(0.5.dp)
+ .size(22.dp),
+ tint = if (answerItem.isEnabled) {
+ if (answerItem.isSelected) {
+ ElementTheme.colors.iconPrimary
} else {
- Icons.Default.RadioButtonUnchecked
- },
- contentDescription = null,
- )
- }
+ ElementTheme.colors.iconSecondary
+ }
+ } else {
+ ElementTheme.colors.iconDisabled
+ },
+ )
Spacer(modifier = Modifier.width(12.dp))
Column {
Row {
@@ -111,71 +101,65 @@ fun PollAnswerView(
answerItem.isSelected -> 1f
else -> 0f
},
+ trackColor = ElementTheme.colors.progressIndicatorTrackColor,
strokeCap = StrokeCap.Round,
)
}
}
}
-@Preview
+@DayNightPreviews
@Composable
-internal fun PollAnswerDisclosedNotSelectedPreview() = ElementThemedPreview {
+internal fun PollAnswerDisclosedNotSelectedPreview() = ElementPreview {
PollAnswerView(
answerItem = aPollAnswerItem(isDisclosed = true, isSelected = false),
- onClick = { },
)
}
-@Preview
+@DayNightPreviews
@Composable
-internal fun PollAnswerDisclosedSelectedPreview() = ElementThemedPreview {
+internal fun PollAnswerDisclosedSelectedPreview() = ElementPreview {
PollAnswerView(
answerItem = aPollAnswerItem(isDisclosed = true, isSelected = true),
- onClick = { }
)
}
-@Preview
+@DayNightPreviews
@Composable
-internal fun PollAnswerUndisclosedNotSelectedPreview() = ElementThemedPreview {
+internal fun PollAnswerUndisclosedNotSelectedPreview() = ElementPreview {
PollAnswerView(
answerItem = aPollAnswerItem(isDisclosed = false, isSelected = false),
- onClick = { },
)
}
-@Preview
+@DayNightPreviews
@Composable
-internal fun PollAnswerUndisclosedSelectedPreview() = ElementThemedPreview {
+internal fun PollAnswerUndisclosedSelectedPreview() = ElementPreview {
PollAnswerView(
answerItem = aPollAnswerItem(isDisclosed = false, isSelected = true),
- onClick = { }
)
}
-@Preview
+@DayNightPreviews
@Composable
-internal fun PollAnswerEndedWinnerNotSelectedPreview() = ElementThemedPreview {
+internal fun PollAnswerEndedWinnerNotSelectedPreview() = ElementPreview {
PollAnswerView(
answerItem = aPollAnswerItem(isDisclosed = true, isSelected = false, isEnabled = false, isWinner = true),
- onClick = { }
)
}
-@Preview
+@DayNightPreviews
@Composable
-internal fun PollAnswerEndedWinnerSelectedPreview() = ElementThemedPreview {
+internal fun PollAnswerEndedWinnerSelectedPreview() = ElementPreview {
PollAnswerView(
answerItem = aPollAnswerItem(isDisclosed = true, isSelected = true, isEnabled = false, isWinner = true),
- onClick = { }
)
}
-@Preview
+@DayNightPreviews
@Composable
-internal fun PollAnswerEndedSelectedPreview() = ElementThemedPreview {
+internal fun PollAnswerEndedSelectedPreview() = ElementPreview {
PollAnswerView(
answerItem = aPollAnswerItem(isDisclosed = true, isSelected = true, isEnabled = false, isWinner = false),
- onClick = { }
)
}
diff --git a/features/poll/api/src/main/kotlin/io/element/android/features/poll/api/PollContentView.kt b/features/poll/api/src/main/kotlin/io/element/android/features/poll/api/PollContentView.kt
index 9301630b73..a47d69c780 100644
--- a/features/poll/api/src/main/kotlin/io/element/android/features/poll/api/PollContentView.kt
+++ b/features/poll/api/src/main/kotlin/io/element/android/features/poll/api/PollContentView.kt
@@ -23,13 +23,14 @@ import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.selection.selectable
import androidx.compose.foundation.selection.selectableGroup
-import androidx.compose.material.icons.Icons
-import androidx.compose.material.icons.outlined.Poll
import androidx.compose.runtime.Composable
+import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.semantics.Role
import androidx.compose.ui.unit.dp
import io.element.android.libraries.designsystem.VectorIcons
import io.element.android.libraries.designsystem.preview.DayNightPreviews
@@ -58,24 +59,24 @@ fun PollContentView(
}
Column(
- modifier = modifier
- .selectableGroup()
- .fillMaxWidth(),
+ modifier = modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
PollTitle(title = question, isPollEnded = isPollEnded)
PollAnswers(answerItems = answerItems, onAnswerSelected = ::onAnswerSelected)
- when {
- isPollEnded || pollKind == PollKind.Disclosed -> DisclosedPollBottomNotice(answerItems)
- pollKind == PollKind.Undisclosed -> UndisclosedPollBottomNotice()
+ if (isPollEnded || pollKind == PollKind.Disclosed) {
+ val votesCount = remember(answerItems) { answerItems.sumOf { it.votesCount } }
+ DisclosedPollBottomNotice(votesCount = votesCount)
+ } else {
+ UndisclosedPollBottomNotice()
}
}
}
@Composable
-internal fun PollTitle(
+private fun PollTitle(
title: String,
isPollEnded: Boolean,
modifier: Modifier = Modifier
@@ -86,14 +87,14 @@ internal fun PollTitle(
) {
if (isPollEnded) {
Icon(
- resourceId = VectorIcons.EndPoll,
- contentDescription = null,
+ resourceId = VectorIcons.PollEnd,
+ contentDescription = stringResource(id = CommonStrings.a11y_poll_end),
modifier = Modifier.size(22.dp)
)
} else {
Icon(
- imageVector = Icons.Outlined.Poll,
- contentDescription = null,
+ resourceId = VectorIcons.Poll,
+ contentDescription = stringResource(id = CommonStrings.a11y_poll),
modifier = Modifier.size(22.dp)
)
}
@@ -105,27 +106,35 @@ internal fun PollTitle(
}
@Composable
-internal fun PollAnswers(
+private fun PollAnswers(
answerItems: ImmutableList,
onAnswerSelected: (PollAnswer) -> Unit,
modifier: Modifier = Modifier,
) {
-
- answerItems.forEach { answerItem ->
- PollAnswerView(
- modifier = modifier,
- answerItem = answerItem,
- onClick = { onAnswerSelected(answerItem.answer) }
- )
+ Column(
+ modifier = modifier.selectableGroup(),
+ verticalArrangement = Arrangement.spacedBy(16.dp),
+ ) {
+ answerItems.forEach {
+ PollAnswerView(
+ answerItem = it,
+ modifier = Modifier
+ .selectable(
+ selected = it.isSelected,
+ enabled = it.isEnabled,
+ onClick = { onAnswerSelected(it.answer) },
+ role = Role.RadioButton,
+ ),
+ )
+ }
}
}
@Composable
-internal fun ColumnScope.DisclosedPollBottomNotice(
- answerItems: ImmutableList,
+private fun ColumnScope.DisclosedPollBottomNotice(
+ votesCount: Int,
modifier: Modifier = Modifier
) {
- val votesCount = answerItems.sumOf { it.votesCount }
Text(
modifier = modifier.align(Alignment.End),
style = ElementTheme.typography.fontBodyXsRegular,
@@ -135,7 +144,9 @@ internal fun ColumnScope.DisclosedPollBottomNotice(
}
@Composable
-fun ColumnScope.UndisclosedPollBottomNotice(modifier: Modifier = Modifier) {
+private fun ColumnScope.UndisclosedPollBottomNotice(
+ modifier: Modifier = Modifier
+) {
Text(
modifier = modifier
.align(Alignment.Start)
diff --git a/features/poll/impl/build.gradle.kts b/features/poll/impl/build.gradle.kts
index 4e8d36966a..5ff9025aae 100644
--- a/features/poll/impl/build.gradle.kts
+++ b/features/poll/impl/build.gradle.kts
@@ -39,6 +39,7 @@ dependencies {
implementation(projects.libraries.matrixui)
implementation(projects.libraries.designsystem)
implementation(projects.services.analytics.api)
+ implementation(projects.features.messages.api)
implementation(projects.libraries.uiStrings)
testImplementation(libs.test.junit)
@@ -48,6 +49,8 @@ dependencies {
testImplementation(libs.test.turbine)
testImplementation(projects.libraries.matrix.test)
testImplementation(projects.services.analytics.test)
+ testImplementation(projects.features.messages.test)
+ testImplementation(projects.tests.testutils)
ksp(libs.showkase.processor)
}
diff --git a/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/create/CreatePollNode.kt b/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/create/CreatePollNode.kt
index 387f57a597..506c39c177 100644
--- a/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/create/CreatePollNode.kt
+++ b/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/create/CreatePollNode.kt
@@ -24,15 +24,17 @@ import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.plugin.Plugin
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
+import im.vector.app.features.analytics.plan.MobileScreen
import io.element.android.anvilannotations.ContributesNode
import io.element.android.libraries.di.RoomScope
+import io.element.android.services.analytics.api.AnalyticsService
@ContributesNode(RoomScope::class)
class CreatePollNode @AssistedInject constructor(
@Assisted buildContext: BuildContext,
@Assisted plugins: List,
presenterFactory: CreatePollPresenter.Factory,
- // analyticsService: AnalyticsService, // TODO Polls: add analytics
+ analyticsService: AnalyticsService,
) : Node(buildContext, plugins = plugins) {
private val presenter = presenterFactory.create(backNavigator = ::navigateUp)
@@ -40,8 +42,7 @@ class CreatePollNode @AssistedInject constructor(
init {
lifecycle.subscribe(
onResume = {
- // TODO Polls: add analytics
- // analyticsService.screen(MobileScreen(screenName = MobileScreen.ScreenName.PollView))
+ analyticsService.screen(MobileScreen(screenName = MobileScreen.ScreenName.CreatePollView))
}
)
}
diff --git a/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/create/CreatePollPresenter.kt b/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/create/CreatePollPresenter.kt
index b44afae9d1..44cc54a100 100644
--- a/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/create/CreatePollPresenter.kt
+++ b/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/create/CreatePollPresenter.kt
@@ -29,9 +29,13 @@ import androidx.compose.runtime.setValue
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
+import im.vector.app.features.analytics.plan.Composer
+import im.vector.app.features.analytics.plan.PollCreation
+import io.element.android.features.messages.api.MessageComposerContext
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.matrix.api.poll.PollKind
import io.element.android.libraries.matrix.api.room.MatrixRoom
+import io.element.android.services.analytics.api.AnalyticsService
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.launch
@@ -44,9 +48,9 @@ private const val MAX_SELECTIONS = 1
class CreatePollPresenter @AssistedInject constructor(
private val room: MatrixRoom,
- // private val analyticsService: AnalyticsService, // TODO Polls: add analytics
+ private val analyticsService: AnalyticsService,
+ private val messageComposerContext: MessageComposerContext,
@Assisted private val navigateUp: () -> Unit,
- // private val messageComposerContext: MessageComposerContext, // TODO Polls: add analytics
) : Presenter {
@AssistedFactory
@@ -78,7 +82,21 @@ class CreatePollPresenter @AssistedInject constructor(
maxSelections = MAX_SELECTIONS,
pollKind = pollKind,
)
- // analyticsService.capture(PollCreate()) // TODO Polls: add analytics
+ analyticsService.capture(
+ Composer(
+ inThread = messageComposerContext.composerMode.inThread,
+ isEditing = messageComposerContext.composerMode.isEditing,
+ isReply = messageComposerContext.composerMode.isReply,
+ messageType = Composer.MessageType.Poll,
+ )
+ )
+ analyticsService.capture(
+ PollCreation(
+ action = PollCreation.Action.Create,
+ isUndisclosed = pollKind == PollKind.Undisclosed,
+ numberOfAnswers = answers.size,
+ )
+ )
navigateUp()
} else {
Timber.d("Cannot create poll")
@@ -153,7 +171,7 @@ private val pollKindSaver: Saver, Boolean> = Saver(
},
restore = {
mutableStateOf(
- when(it) {
+ when (it) {
true -> PollKind.Undisclosed
else -> PollKind.Disclosed
}
diff --git a/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/create/CreatePollView.kt b/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/create/CreatePollView.kt
index 8e3de07574..0355b34375 100644
--- a/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/create/CreatePollView.kt
+++ b/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/create/CreatePollView.kt
@@ -18,6 +18,7 @@ package io.element.android.features.poll.impl.create
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.consumeWindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
@@ -25,6 +26,7 @@ import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
+import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
@@ -32,6 +34,7 @@ import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
@@ -61,6 +64,8 @@ import io.element.android.libraries.designsystem.theme.components.TopAppBar
import io.element.android.libraries.matrix.api.poll.PollKind
import io.element.android.libraries.theme.ElementTheme
import io.element.android.libraries.ui.strings.CommonStrings
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.launch
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@@ -68,6 +73,8 @@ fun CreatePollView(
state: CreatePollState,
modifier: Modifier = Modifier,
) {
+ val coroutineScope = rememberCoroutineScope()
+
val navBack = { state.eventSink(CreatePollEvents.ConfirmNavBack) }
BackHandler(onBack = navBack)
if (state.showConfirmation) ConfirmationDialog(
@@ -76,6 +83,7 @@ fun CreatePollView(
onDismiss = { state.eventSink(CreatePollEvents.HideConfirmation) }
)
val questionFocusRequester = remember { FocusRequester() }
+ val answerFocusRequester = remember { FocusRequester() }
LaunchedEffect(Unit) {
questionFocusRequester.requestFocus()
}
@@ -102,40 +110,43 @@ fun CreatePollView(
)
},
) { paddingValues ->
+ val lazyListState = rememberLazyListState()
LazyColumn(
modifier = Modifier
.padding(paddingValues)
.consumeWindowInsets(paddingValues)
.imePadding()
.fillMaxSize(),
+ state = lazyListState,
) {
item {
- Text(
- text = stringResource(id = R.string.screen_create_poll_question_desc),
- modifier = Modifier.padding(start = 32.dp),
- style = ElementTheme.typography.fontBodyMdRegular,
- )
- }
- item {
- ListItem(
- headlineContent = {
- OutlinedTextField(
- value = state.question,
- onValueChange = {
- state.eventSink(CreatePollEvents.SetQuestion(it))
- },
- modifier = Modifier
- .focusRequester(questionFocusRequester)
- .fillMaxWidth(),
- placeholder = {
- Text(text = stringResource(id = R.string.screen_create_poll_question_hint))
- },
- keyboardOptions = keyboardOptions,
- )
- }
- )
+ Column {
+ Text(
+ text = stringResource(id = R.string.screen_create_poll_question_desc),
+ modifier = Modifier.padding(start = 32.dp),
+ style = ElementTheme.typography.fontBodyMdRegular,
+ )
+ ListItem(
+ headlineContent = {
+ OutlinedTextField(
+ value = state.question,
+ onValueChange = {
+ state.eventSink(CreatePollEvents.SetQuestion(it))
+ },
+ modifier = Modifier
+ .focusRequester(questionFocusRequester)
+ .fillMaxWidth(),
+ placeholder = {
+ Text(text = stringResource(id = R.string.screen_create_poll_question_hint))
+ },
+ keyboardOptions = keyboardOptions,
+ )
+ }
+ )
+ }
}
itemsIndexed(state.answers) { index, answer ->
+ val isLastItem = index == state.answers.size - 1
ListItem(
headlineContent = {
OutlinedTextField(
@@ -143,7 +154,9 @@ fun CreatePollView(
onValueChange = {
state.eventSink(CreatePollEvents.SetAnswer(index, it))
},
- modifier = Modifier.fillMaxWidth(),
+ modifier = Modifier
+ .then(if (isLastItem) Modifier.focusRequester(answerFocusRequester) else Modifier)
+ .fillMaxWidth(),
placeholder = {
Text(text = stringResource(id = R.string.screen_create_poll_answer_hint, index + 1))
},
@@ -170,22 +183,28 @@ fun CreatePollView(
iconSource = IconSource.Vector(Icons.Default.Add),
),
style = ListItemStyle.Primary,
- onClick = { state.eventSink(CreatePollEvents.AddAnswer) },
+ onClick = {
+ state.eventSink(CreatePollEvents.AddAnswer)
+ coroutineScope.launch(Dispatchers.Main) {
+ lazyListState.animateScrollToItem(state.answers.size + 1)
+ answerFocusRequester.requestFocus()
+ }
+ },
)
}
}
item {
- HorizontalDivider()
- }
- item {
- ListItem(
- headlineContent = { Text(text = stringResource(id = R.string.screen_create_poll_anonymous_headline)) },
- supportingContent = { Text(text = stringResource(id = R.string.screen_create_poll_anonymous_desc)) },
- trailingContent = ListItemContent.Switch(
- checked = state.pollKind == PollKind.Undisclosed,
- onChange = { state.eventSink(CreatePollEvents.SetPollKind(if (it) PollKind.Undisclosed else PollKind.Disclosed)) },
- ),
- )
+ Column {
+ HorizontalDivider()
+ ListItem(
+ headlineContent = { Text(text = stringResource(id = R.string.screen_create_poll_anonymous_headline)) },
+ supportingContent = { Text(text = stringResource(id = R.string.screen_create_poll_anonymous_desc)) },
+ trailingContent = ListItemContent.Switch(
+ checked = state.pollKind == PollKind.Undisclosed,
+ onChange = { state.eventSink(CreatePollEvents.SetPollKind(if (it) PollKind.Undisclosed else PollKind.Disclosed)) },
+ ),
+ )
+ }
}
}
}
diff --git a/features/poll/impl/src/main/res/values-cs/translations.xml b/features/poll/impl/src/main/res/values-cs/translations.xml
new file mode 100644
index 0000000000..d199df993f
--- /dev/null
+++ b/features/poll/impl/src/main/res/values-cs/translations.xml
@@ -0,0 +1,10 @@
+
+
+ "Přidat volbu"
+ "Zobrazit výsledky až po skončení hlasování"
+ "Anonymní hlasování"
+ "Volba %1$d"
+ "Otázka nebo téma"
+ "Čeho se hlasování týká?"
+ "Vytvořit hlasování"
+
diff --git a/features/poll/impl/src/main/res/values-de/translations.xml b/features/poll/impl/src/main/res/values-de/translations.xml
new file mode 100644
index 0000000000..bd43eb8337
--- /dev/null
+++ b/features/poll/impl/src/main/res/values-de/translations.xml
@@ -0,0 +1,12 @@
+
+
+ "Option hinzufügen"
+ "Ergebnisse erst nach Ende der Umfrage anzeigen"
+ "Anonyme Umfrage"
+ "Option %1$d"
+ "Bist du sicher, dass du diese Umfrage verwerfen willst?"
+ "Umfrage verwerfen"
+ "Frage oder Thema"
+ "Worum geht es bei der Umfrage?"
+ "Umfrage erstellen"
+
diff --git a/features/poll/impl/src/main/res/values-fr/translations.xml b/features/poll/impl/src/main/res/values-fr/translations.xml
new file mode 100644
index 0000000000..adfcfd274d
--- /dev/null
+++ b/features/poll/impl/src/main/res/values-fr/translations.xml
@@ -0,0 +1,12 @@
+
+
+ "Ajouter une option"
+ "Afficher les résultats uniquement après la fin du sondage"
+ "Masquer les votes"
+ "Option %1$d"
+ "Êtes-vous sûr de vouloir supprimer ce sondage ?"
+ "Supprimer le sondage"
+ "Question ou sujet"
+ "Quel est le sujet du sondage ?"
+ "Créer un sondage"
+
diff --git a/features/poll/impl/src/main/res/values-ro/translations.xml b/features/poll/impl/src/main/res/values-ro/translations.xml
new file mode 100644
index 0000000000..b552a68024
--- /dev/null
+++ b/features/poll/impl/src/main/res/values-ro/translations.xml
@@ -0,0 +1,12 @@
+
+
+ "Adăugați o opțiune"
+ "Afișați rezultatele numai după încheierea sondajului"
+ "Sondaj anonim"
+ "Opțiune %1$d"
+ "Sunteți sigur că doriți să renunțați la acest sondaj?"
+ "Renunțați la sondaj"
+ "Întrebare sau subiect"
+ "Despre ce este sondajul?"
+ "Creați un sondaj"
+
diff --git a/features/poll/impl/src/main/res/values-ru/translations.xml b/features/poll/impl/src/main/res/values-ru/translations.xml
new file mode 100644
index 0000000000..f1a518b0e7
--- /dev/null
+++ b/features/poll/impl/src/main/res/values-ru/translations.xml
@@ -0,0 +1,10 @@
+
+
+ "Добавить опцию"
+ "Показывать результаты только после окончания опроса"
+ "Анонимный опрос"
+ "Настройка %1$d"
+ "Вопрос или тема"
+ "Тема опроса?"
+ "Создать опрос"
+
diff --git a/features/poll/impl/src/main/res/values-sk/translations.xml b/features/poll/impl/src/main/res/values-sk/translations.xml
new file mode 100644
index 0000000000..5e2d9ee7bc
--- /dev/null
+++ b/features/poll/impl/src/main/res/values-sk/translations.xml
@@ -0,0 +1,12 @@
+
+
+ "Pridať možnosť"
+ "Zobraziť výsledky až po skončení ankety"
+ "Anonymná anketa"
+ "Možnosť %1$d"
+ "Ste si istí, že chcete túto anketu zahodiť?"
+ "Odstrániť anketu"
+ "Otázka alebo téma"
+ "O čom je anketa?"
+ "Vytvoriť anketu"
+
diff --git a/features/poll/impl/src/main/res/values/localazy.xml b/features/poll/impl/src/main/res/values/localazy.xml
index 876dd0ee44..3c5006d0af 100644
--- a/features/poll/impl/src/main/res/values/localazy.xml
+++ b/features/poll/impl/src/main/res/values/localazy.xml
@@ -2,9 +2,10 @@
"Add option"
"Show results only after poll ends"
- "Anonymous Poll"
+ "Hide votes"
"Option %1$d"
- "Are you sure you would like to go back?"
+ "Are you sure you want to discard this poll?"
+ "Discard Poll"
"Question or topic"
"What is the poll about?"
"Create Poll"
diff --git a/features/poll/impl/src/test/kotlin/io/element/android/features/poll/impl/create/CreatePollPresenterTest.kt b/features/poll/impl/src/test/kotlin/io/element/android/features/poll/impl/create/CreatePollPresenterTest.kt
index 9dacc6062d..8e16084a59 100644
--- a/features/poll/impl/src/test/kotlin/io/element/android/features/poll/impl/create/CreatePollPresenterTest.kt
+++ b/features/poll/impl/src/test/kotlin/io/element/android/features/poll/impl/create/CreatePollPresenterTest.kt
@@ -20,22 +20,33 @@ import app.cash.molecule.RecompositionMode
import app.cash.molecule.moleculeFlow
import app.cash.turbine.test
import com.google.common.truth.Truth
+import im.vector.app.features.analytics.plan.Composer
+import im.vector.app.features.analytics.plan.PollCreation
+import io.element.android.features.messages.test.MessageComposerContextFake
import io.element.android.libraries.matrix.api.poll.PollKind
import io.element.android.libraries.matrix.test.room.CreatePollInvocation
import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
+import io.element.android.services.analytics.test.FakeAnalyticsService
+import io.element.android.tests.testutils.WarmUpRule
import kotlinx.coroutines.delay
import kotlinx.coroutines.test.runTest
+import org.junit.Rule
import org.junit.Test
class CreatePollPresenterTest {
+ @get:Rule
+ val warmUpRule = WarmUpRule()
+
private var navUpInvocationsCount = 0
private val fakeMatrixRoom = FakeMatrixRoom()
- // private val fakeAnalyticsService = FakeAnalyticsService() // TODO Polls: add analytics
+ private val fakeAnalyticsService = FakeAnalyticsService()
+ private val messageComposerContextFake = MessageComposerContextFake()
private val presenter = CreatePollPresenter(
room = fakeMatrixRoom,
- // analyticsService = fakeAnalyticsService, // TODO Polls: add analytics
+ analyticsService = fakeAnalyticsService,
+ messageComposerContext = messageComposerContextFake,
navigateUp = { navUpInvocationsCount++ },
)
@@ -98,6 +109,22 @@ class CreatePollPresenterTest {
pollKind = PollKind.Disclosed
)
)
+ Truth.assertThat(fakeAnalyticsService.capturedEvents.size).isEqualTo(2)
+ Truth.assertThat(fakeAnalyticsService.capturedEvents[0]).isEqualTo(
+ Composer(
+ inThread = false,
+ isEditing = false,
+ isReply = false,
+ messageType = Composer.MessageType.Poll,
+ )
+ )
+ Truth.assertThat(fakeAnalyticsService.capturedEvents[1]).isEqualTo(
+ PollCreation(
+ action = PollCreation.Action.Create,
+ isUndisclosed = false,
+ numberOfAnswers = 2,
+ )
+ )
}
}
diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/log/LoggerTag.kt b/features/preferences/api/src/main/kotlin/io/element/android/features/preferences/api/ConfigureTracingEntryPoint.kt
similarity index 71%
rename from libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/log/LoggerTag.kt
rename to features/preferences/api/src/main/kotlin/io/element/android/features/preferences/api/ConfigureTracingEntryPoint.kt
index 3fa613d097..803c0c9232 100644
--- a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/log/LoggerTag.kt
+++ b/features/preferences/api/src/main/kotlin/io/element/android/features/preferences/api/ConfigureTracingEntryPoint.kt
@@ -14,9 +14,8 @@
* limitations under the License.
*/
-package io.element.android.libraries.push.impl.log
+package io.element.android.features.preferences.api
-import io.element.android.libraries.core.log.logger.LoggerTag
+import io.element.android.libraries.architecture.SimpleFeatureEntryPoint
-internal val pushLoggerTag = LoggerTag("Push")
-internal val notificationLoggerTag = LoggerTag("Notification", pushLoggerTag)
+interface ConfigureTracingEntryPoint : SimpleFeatureEntryPoint
diff --git a/features/preferences/impl/build.gradle.kts b/features/preferences/impl/build.gradle.kts
index 2773379ccc..b28a068708 100644
--- a/features/preferences/impl/build.gradle.kts
+++ b/features/preferences/impl/build.gradle.kts
@@ -40,12 +40,16 @@ dependencies {
implementation(projects.libraries.featureflag.api)
implementation(projects.libraries.featureflag.ui)
implementation(projects.libraries.network)
+ implementation(projects.libraries.pushstore.api)
+ implementation(projects.libraries.preferences.api)
implementation(projects.libraries.testtags)
implementation(projects.libraries.uiStrings)
+ implementation(projects.libraries.matrixui)
+ implementation(projects.libraries.mediapickers.api)
+ implementation(projects.libraries.mediaupload.api)
implementation(projects.features.rageshake.api)
implementation(projects.features.analytics.api)
implementation(projects.features.ftue.api)
- implementation(projects.libraries.matrixui)
implementation(projects.features.logout.api)
implementation(projects.services.analytics.api)
implementation(projects.services.toolbox.api)
@@ -53,6 +57,7 @@ dependencies {
implementation(libs.accompanist.placeholder)
implementation(libs.coil.compose)
implementation(libs.androidx.browser)
+ implementation(libs.androidx.datastore.preferences)
api(projects.features.preferences.api)
ksp(libs.showkase.processor)
@@ -61,8 +66,13 @@ dependencies {
testImplementation(libs.molecule.runtime)
testImplementation(libs.test.truth)
testImplementation(libs.test.turbine)
+ testImplementation(libs.test.mockk)
testImplementation(projects.libraries.matrix.test)
testImplementation(projects.libraries.featureflag.test)
+ testImplementation(projects.libraries.mediapickers.test)
+ testImplementation(projects.libraries.mediaupload.test)
+ testImplementation(projects.libraries.preferences.test)
+ testImplementation(projects.libraries.pushstore.test)
testImplementation(projects.features.rageshake.test)
testImplementation(projects.features.rageshake.impl)
testImplementation(projects.features.logout.impl)
diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/DefaultConfigureTracingEntryPoint.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/DefaultConfigureTracingEntryPoint.kt
new file mode 100644
index 0000000000..372acbd1f4
--- /dev/null
+++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/DefaultConfigureTracingEntryPoint.kt
@@ -0,0 +1,33 @@
+/*
+ * Copyright (c) 2023 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.element.android.features.preferences.impl
+
+import com.bumble.appyx.core.modality.BuildContext
+import com.bumble.appyx.core.node.Node
+import com.squareup.anvil.annotations.ContributesBinding
+import io.element.android.features.preferences.api.ConfigureTracingEntryPoint
+import io.element.android.features.preferences.impl.developer.tracing.ConfigureTracingNode
+import io.element.android.libraries.architecture.createNode
+import io.element.android.libraries.di.AppScope
+import javax.inject.Inject
+
+@ContributesBinding(AppScope::class)
+class DefaultConfigureTracingEntryPoint @Inject constructor() : ConfigureTracingEntryPoint {
+ override fun createNode(parentNode: Node, buildContext: BuildContext): Node {
+ return parentNode.createNode(buildContext)
+ }
+}
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 872520105f..6cf0390db2 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
@@ -31,14 +31,19 @@ import dagger.assisted.AssistedInject
import io.element.android.anvilannotations.ContributesNode
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.developer.DeveloperSettingsNode
import io.element.android.features.preferences.impl.developer.tracing.ConfigureTracingNode
+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
+import io.element.android.features.preferences.impl.user.editprofile.EditUserProfileNode
import io.element.android.libraries.architecture.BackstackNode
import io.element.android.libraries.architecture.animation.rememberDefaultTransitionHandler
import io.element.android.libraries.architecture.createNode
import io.element.android.libraries.di.SessionScope
+import io.element.android.libraries.matrix.api.user.MatrixUser
import kotlinx.parcelize.Parcelize
@ContributesNode(SessionScope::class)
@@ -61,6 +66,9 @@ class PreferencesFlowNode @AssistedInject constructor(
@Parcelize
data object DeveloperSettings : NavTarget
+ @Parcelize
+ data object AdvancedSettings : NavTarget
+
@Parcelize
data object ConfigureTracing : NavTarget
@@ -69,6 +77,15 @@ class PreferencesFlowNode @AssistedInject constructor(
@Parcelize
data object About : NavTarget
+
+ @Parcelize
+ data object NotificationSettings : NavTarget
+
+ @Parcelize
+ data class EditDefaultNotificationSetting(val isOneToOne: Boolean) : NavTarget
+
+ @Parcelize
+ data class UserProfile(val matrixUser: MatrixUser) : NavTarget
}
override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {
@@ -94,6 +111,18 @@ class PreferencesFlowNode @AssistedInject constructor(
override fun onOpenDeveloperSettings() {
backstack.push(NavTarget.DeveloperSettings)
}
+
+ override fun onOpenNotificationSettings() {
+ backstack.push(NavTarget.NotificationSettings)
+ }
+
+ override fun onOpenAdvancedSettings() {
+ backstack.push(NavTarget.AdvancedSettings)
+ }
+
+ override fun onOpenUserProfile(matrixUser: MatrixUser) {
+ backstack.push(NavTarget.UserProfile(matrixUser))
+ }
}
createNode(buildContext, plugins = listOf(callback))
}
@@ -114,6 +143,25 @@ class PreferencesFlowNode @AssistedInject constructor(
NavTarget.AnalyticsSettings -> {
createNode(buildContext)
}
+ NavTarget.NotificationSettings -> {
+ val notificationSettingsCallback = object : NotificationSettingsNode.Callback {
+ override fun editDefaultNotificationMode(isOneToOne: Boolean) {
+ backstack.push(NavTarget.EditDefaultNotificationSetting(isOneToOne))
+ }
+ }
+ createNode(buildContext, listOf(notificationSettingsCallback))
+ }
+ is NavTarget.EditDefaultNotificationSetting -> {
+ val input = EditDefaultNotificationSettingNode.Inputs(navTarget.isOneToOne)
+ createNode(buildContext, plugins = listOf(input))
+ }
+ NavTarget.AdvancedSettings -> {
+ createNode(buildContext)
+ }
+ is NavTarget.UserProfile -> {
+ val inputs = EditUserProfileNode.Inputs(navTarget.matrixUser)
+ createNode(buildContext, listOf(inputs))
+ }
}
}
diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsEvents.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsEvents.kt
new file mode 100644
index 0000000000..37641d684c
--- /dev/null
+++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsEvents.kt
@@ -0,0 +1,22 @@
+/*
+ * Copyright (c) 2023 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.element.android.features.preferences.impl.advanced
+
+sealed interface AdvancedSettingsEvents {
+ data class SetRichTextEditorEnabled(val enabled: Boolean) : AdvancedSettingsEvents
+ data class SetDeveloperModeEnabled(val enabled: Boolean) : AdvancedSettingsEvents
+}
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/advanced/AdvancedSettingsNode.kt
new file mode 100644
index 0000000000..f7f0fd2eb8
--- /dev/null
+++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsNode.kt
@@ -0,0 +1,45 @@
+/*
+ * Copyright (c) 2023 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.element.android.features.preferences.impl.advanced
+
+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 dagger.assisted.Assisted
+import dagger.assisted.AssistedInject
+import io.element.android.anvilannotations.ContributesNode
+import io.element.android.libraries.di.SessionScope
+
+@ContributesNode(SessionScope::class)
+class AdvancedSettingsNode @AssistedInject constructor(
+ @Assisted buildContext: BuildContext,
+ @Assisted plugins: List,
+ private val presenter: AdvancedSettingsPresenter,
+) : Node(buildContext, plugins = plugins) {
+
+ @Composable
+ override fun View(modifier: Modifier) {
+ val state = presenter.present()
+ AdvancedSettingsView(
+ state = state,
+ modifier = modifier,
+ onBackPressed = ::navigateUp
+ )
+ }
+}
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
new file mode 100644
index 0000000000..5738fe43c8
--- /dev/null
+++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsPresenter.kt
@@ -0,0 +1,59 @@
+/*
+ * Copyright (c) 2023 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.element.android.features.preferences.impl.advanced
+
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.rememberCoroutineScope
+import io.element.android.features.preferences.api.store.PreferencesStore
+import io.element.android.libraries.architecture.Presenter
+import kotlinx.coroutines.launch
+import javax.inject.Inject
+
+class AdvancedSettingsPresenter @Inject constructor(
+ private val preferencesStore: PreferencesStore,
+) : Presenter {
+
+ @Composable
+ override fun present(): AdvancedSettingsState {
+ val localCoroutineScope = rememberCoroutineScope()
+ val isRichTextEditorEnabled by preferencesStore
+ .isRichTextEditorEnabledFlow()
+ .collectAsState(initial = false)
+ val isDeveloperModeEnabled by preferencesStore
+ .isDeveloperModeEnabledFlow()
+ .collectAsState(initial = false)
+
+ fun handleEvents(event: AdvancedSettingsEvents) {
+ when (event) {
+ is AdvancedSettingsEvents.SetRichTextEditorEnabled -> localCoroutineScope.launch {
+ preferencesStore.setRichTextEditorEnabled(event.enabled)
+ }
+ is AdvancedSettingsEvents.SetDeveloperModeEnabled -> localCoroutineScope.launch {
+ preferencesStore.setDeveloperModeEnabled(event.enabled)
+ }
+ }
+ }
+
+ return AdvancedSettingsState(
+ isRichTextEditorEnabled = isRichTextEditorEnabled,
+ isDeveloperModeEnabled = isDeveloperModeEnabled,
+ eventSink = ::handleEvents
+ )
+ }
+}
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
new file mode 100644
index 0000000000..19625b9ebc
--- /dev/null
+++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsState.kt
@@ -0,0 +1,23 @@
+/*
+ * Copyright (c) 2023 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.element.android.features.preferences.impl.advanced
+
+data class AdvancedSettingsState constructor(
+ val isRichTextEditorEnabled: Boolean,
+ val isDeveloperModeEnabled: Boolean,
+ val eventSink: (AdvancedSettingsEvents) -> Unit
+)
diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsStateProvider.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsStateProvider.kt
new file mode 100644
index 0000000000..5ab50c8a16
--- /dev/null
+++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsStateProvider.kt
@@ -0,0 +1,37 @@
+/*
+ * Copyright (c) 2023 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.element.android.features.preferences.impl.advanced
+
+import androidx.compose.ui.tooling.preview.PreviewParameterProvider
+
+open class AdvancedSettingsStateProvider : PreviewParameterProvider {
+ override val values: Sequence
+ get() = sequenceOf(
+ aAdvancedSettingsState(),
+ aAdvancedSettingsState(isRichTextEditorEnabled = true),
+ aAdvancedSettingsState(isDeveloperModeEnabled = true),
+ )
+}
+
+fun aAdvancedSettingsState(
+ isRichTextEditorEnabled: Boolean = false,
+ isDeveloperModeEnabled: Boolean = false,
+) = AdvancedSettingsState(
+ isRichTextEditorEnabled = isRichTextEditorEnabled,
+ isDeveloperModeEnabled = isDeveloperModeEnabled,
+ 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
new file mode 100644
index 0000000000..cff1454594
--- /dev/null
+++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsView.kt
@@ -0,0 +1,63 @@
+/*
+ * Copyright (c) 2023 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.element.android.features.preferences.impl.advanced
+
+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.libraries.designsystem.components.preferences.PreferenceSwitch
+import io.element.android.libraries.designsystem.components.preferences.PreferenceView
+import io.element.android.libraries.designsystem.preview.DayNightPreviews
+import io.element.android.libraries.designsystem.preview.ElementPreview
+import io.element.android.libraries.ui.strings.CommonStrings
+
+@Composable
+fun AdvancedSettingsView(
+ state: AdvancedSettingsState,
+ onBackPressed: () -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ PreferenceView(
+ modifier = modifier,
+ onBackPressed = onBackPressed,
+ title = stringResource(id = CommonStrings.common_advanced_settings)
+ ) {
+ PreferenceSwitch(
+ title = stringResource(id = CommonStrings.common_rich_text_editor),
+ // TODO i18n
+ subtitle = "Disable the rich text editor to type Markdown manually",
+ isChecked = state.isRichTextEditorEnabled,
+ onCheckedChange = { state.eventSink(AdvancedSettingsEvents.SetRichTextEditorEnabled(it)) },
+ )
+ PreferenceSwitch(
+ // TODO i18n
+ title = "Developer mode",
+ // TODO i18n
+ subtitle = "The developer mode activates hidden features. For developers only!",
+ isChecked = state.isDeveloperModeEnabled,
+ onCheckedChange = { state.eventSink(AdvancedSettingsEvents.SetDeveloperModeEnabled(it)) },
+ )
+ }
+}
+
+@DayNightPreviews
+@Composable
+internal fun AdvancedSettingsViewPreview(@PreviewParameter(AdvancedSettingsStateProvider::class) state: AdvancedSettingsState) =
+ ElementPreview {
+ AdvancedSettingsView(state = state, onBackPressed = { })
+ }
diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsPresenter.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsPresenter.kt
index 5f50fde309..ebcd44b4db 100644
--- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsPresenter.kt
+++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsPresenter.kt
@@ -124,16 +124,12 @@ class DeveloperSettingsPresenter @Inject constructor(
enabledFeatures: SnapshotStateMap,
featureUiModel: FeatureUiModel,
enabled: Boolean,
- triggerClearCache: () -> Unit,
+ @Suppress("UNUSED_PARAMETER") triggerClearCache: () -> Unit,
) = launch {
val feature = features[featureUiModel.key] ?: return@launch
if (featureFlagService.setFeatureEnabled(feature, enabled)) {
enabledFeatures[featureUiModel.key] = enabled
}
-
- if (featureUiModel.key == FeatureFlags.UseEncryptionSync.key) {
- triggerClearCache()
- }
}
private fun CoroutineScope.computeCacheSize(cacheSize: MutableState>) = launch {
diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/tracing/TargetLogLevelMapBuilder.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/tracing/TargetLogLevelMapBuilder.kt
index c70d573430..b851c15279 100644
--- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/tracing/TargetLogLevelMapBuilder.kt
+++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/tracing/TargetLogLevelMapBuilder.kt
@@ -29,7 +29,6 @@ class TargetLogLevelMapBuilder @Inject constructor(
fun getDefaultMap(): Map {
return Target.entries.associateWith { target ->
defaultConfig.getLogLevel(target)
- ?: LogLevel.INFO
}
}
@@ -37,7 +36,6 @@ class TargetLogLevelMapBuilder @Inject constructor(
return Target.entries.associateWith { target ->
tracingConfigurationStore.getLogLevel(target)
?: defaultConfig.getLogLevel(target)
- ?: LogLevel.INFO
}
}
}
diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/NotificationSettingsEvents.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/NotificationSettingsEvents.kt
new file mode 100644
index 0000000000..374b8078ca
--- /dev/null
+++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/NotificationSettingsEvents.kt
@@ -0,0 +1,27 @@
+/*
+ * Copyright (c) 2023 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.element.android.features.preferences.impl.notifications
+
+sealed interface NotificationSettingsEvents {
+
+ data object RefreshSystemNotificationsEnabled : NotificationSettingsEvents
+ data class SetNotificationsEnabled(val enabled: Boolean) : NotificationSettingsEvents
+ data class SetAtRoomNotificationsEnabled(val enabled: Boolean) : NotificationSettingsEvents
+ data class SetCallNotificationsEnabled(val enabled: Boolean) : NotificationSettingsEvents
+ data object FixConfigurationMismatch : NotificationSettingsEvents
+ data object ClearConfigurationMismatchError : NotificationSettingsEvents
+}
diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/NotificationSettingsNode.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/NotificationSettingsNode.kt
new file mode 100644
index 0000000000..0e3861c5ec
--- /dev/null
+++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/NotificationSettingsNode.kt
@@ -0,0 +1,57 @@
+/*
+ * Copyright (c) 2023 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.element.android.features.preferences.impl.notifications
+
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import com.bumble.appyx.core.modality.BuildContext
+import com.bumble.appyx.core.node.Node
+import com.bumble.appyx.core.plugin.Plugin
+import com.bumble.appyx.core.plugin.plugins
+import dagger.assisted.Assisted
+import dagger.assisted.AssistedInject
+import io.element.android.anvilannotations.ContributesNode
+import io.element.android.libraries.di.SessionScope
+
+@ContributesNode(SessionScope::class)
+class NotificationSettingsNode @AssistedInject constructor(
+ @Assisted buildContext: BuildContext,
+ @Assisted plugins: List,
+ private val presenter: NotificationSettingsPresenter,
+) : Node(buildContext, plugins = plugins) {
+
+ interface Callback : Plugin {
+ fun editDefaultNotificationMode(isOneToOne: Boolean)
+ }
+
+ private val callbacks = plugins()
+
+ private fun openEditDefault(isOneToOne: Boolean) {
+ callbacks.forEach { it.editDefaultNotificationMode(isOneToOne) }
+ }
+
+ @Composable
+ override fun View(modifier: Modifier) {
+ val state = presenter.present()
+ NotificationSettingsView(
+ state = state,
+ onOpenEditDefault = { openEditDefault(isOneToOne = it) },
+ onBackPressed = ::navigateUp,
+ modifier = modifier,
+ )
+ }
+}
diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/NotificationSettingsPresenter.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/NotificationSettingsPresenter.kt
new file mode 100644
index 0000000000..697d5887f0
--- /dev/null
+++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/NotificationSettingsPresenter.kt
@@ -0,0 +1,168 @@
+/*
+ * Copyright (c) 2023 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.element.android.features.preferences.impl.notifications
+
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.MutableState
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+import io.element.android.libraries.architecture.Presenter
+import io.element.android.libraries.matrix.api.MatrixClient
+import io.element.android.libraries.matrix.api.notificationsettings.NotificationSettingsService
+import io.element.android.libraries.matrix.api.room.RoomNotificationMode
+import io.element.android.libraries.pushstore.api.UserPushStore
+import io.element.android.libraries.pushstore.api.UserPushStoreFactory
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.FlowPreview
+import kotlinx.coroutines.flow.debounce
+import kotlinx.coroutines.flow.launchIn
+import kotlinx.coroutines.flow.onEach
+import kotlinx.coroutines.launch
+import javax.inject.Inject
+import kotlin.time.Duration.Companion.seconds
+
+class NotificationSettingsPresenter @Inject constructor(
+ private val notificationSettingsService: NotificationSettingsService,
+ private val userPushStoreFactory: UserPushStoreFactory,
+ private val matrixClient: MatrixClient,
+ private val systemNotificationsEnabledProvider: SystemNotificationsEnabledProvider
+) : Presenter {
+ @Composable
+ override fun present(): NotificationSettingsState {
+ val userPushStore = remember { userPushStoreFactory.create(matrixClient.sessionId) }
+ val systemNotificationsEnabled: MutableState = remember {
+ mutableStateOf(systemNotificationsEnabledProvider.notificationsEnabled())
+ }
+
+ val localCoroutineScope = rememberCoroutineScope()
+ val appNotificationsEnabled = userPushStore
+ .getNotificationEnabledForDevice()
+ .collectAsState(initial = false)
+
+ val matrixSettings: MutableState = remember {
+ mutableStateOf(NotificationSettingsState.MatrixSettings.Uninitialized)
+ }
+
+ LaunchedEffect(Unit) {
+ fetchSettings(matrixSettings)
+ observeNotificationSettings(matrixSettings)
+ }
+
+ fun handleEvents(event: NotificationSettingsEvents) {
+ when (event) {
+ is NotificationSettingsEvents.SetAtRoomNotificationsEnabled -> localCoroutineScope.setAtRoomNotificationsEnabled(event.enabled)
+ is NotificationSettingsEvents.SetCallNotificationsEnabled -> localCoroutineScope.setCallNotificationsEnabled(event.enabled)
+ is NotificationSettingsEvents.SetNotificationsEnabled -> localCoroutineScope.setNotificationsEnabled(userPushStore, event.enabled)
+ NotificationSettingsEvents.ClearConfigurationMismatchError -> {
+ matrixSettings.value = NotificationSettingsState.MatrixSettings.Invalid(fixFailed = false)
+ }
+ NotificationSettingsEvents.FixConfigurationMismatch -> localCoroutineScope.fixConfigurationMismatch(matrixSettings)
+ NotificationSettingsEvents.RefreshSystemNotificationsEnabled -> {
+ systemNotificationsEnabled.value = systemNotificationsEnabledProvider.notificationsEnabled()
+ }
+ }
+ }
+
+ return NotificationSettingsState(
+ matrixSettings = matrixSettings.value,
+ appSettings = NotificationSettingsState.AppSettings(
+ systemNotificationsEnabled = systemNotificationsEnabled.value,
+ appNotificationsEnabled = appNotificationsEnabled.value
+ ),
+ eventSink = ::handleEvents
+ )
+ }
+
+ @OptIn(FlowPreview::class)
+ private fun CoroutineScope.observeNotificationSettings(target: MutableState) {
+ notificationSettingsService.notificationSettingsChangeFlow
+ .debounce(0.5.seconds)
+ .onEach {
+ fetchSettings(target)
+ }
+ .launchIn(this)
+ }
+
+ private fun CoroutineScope.fetchSettings(target: MutableState) = launch {
+ val groupDefaultMode = notificationSettingsService.getDefaultRoomNotificationMode(isEncrypted = false, isOneToOne = false).getOrThrow()
+ val encryptedGroupDefaultMode = notificationSettingsService.getDefaultRoomNotificationMode(isEncrypted = true, isOneToOne = false).getOrThrow()
+
+ val oneToOneDefaultMode = notificationSettingsService.getDefaultRoomNotificationMode(isEncrypted = false, isOneToOne = true).getOrThrow()
+ val encryptedOneToOneDefaultMode = notificationSettingsService.getDefaultRoomNotificationMode(isEncrypted = true, isOneToOne = true).getOrThrow()
+
+ if(groupDefaultMode != encryptedGroupDefaultMode || oneToOneDefaultMode != encryptedOneToOneDefaultMode) {
+ target.value = NotificationSettingsState.MatrixSettings.Invalid(fixFailed = false)
+ return@launch
+ }
+
+ val callNotificationsEnabled = notificationSettingsService.isCallEnabled().getOrThrow()
+ val atRoomNotificationsEnabled = notificationSettingsService.isRoomMentionEnabled().getOrThrow()
+
+ target.value = NotificationSettingsState.MatrixSettings.Valid(
+ atRoomNotificationsEnabled = atRoomNotificationsEnabled,
+ callNotificationsEnabled = callNotificationsEnabled,
+ defaultGroupNotificationMode = encryptedGroupDefaultMode,
+ defaultOneToOneNotificationMode = encryptedOneToOneDefaultMode,
+ )
+ }
+
+ private fun CoroutineScope.fixConfigurationMismatch(target: MutableState) = launch {
+ runCatching {
+ val groupDefaultMode = notificationSettingsService.getDefaultRoomNotificationMode(isEncrypted = false, isOneToOne = false).getOrThrow()
+ val encryptedGroupDefaultMode = notificationSettingsService.getDefaultRoomNotificationMode(isEncrypted = true, isOneToOne = false).getOrThrow()
+
+ if (groupDefaultMode != encryptedGroupDefaultMode) {
+ notificationSettingsService.setDefaultRoomNotificationMode(
+ isEncrypted = encryptedGroupDefaultMode != RoomNotificationMode.ALL_MESSAGES,
+ mode = RoomNotificationMode.ALL_MESSAGES,
+ isOneToOne = false,
+ )
+ }
+
+ val oneToOneDefaultMode = notificationSettingsService.getDefaultRoomNotificationMode(isEncrypted = false, isOneToOne = true).getOrThrow()
+ val encryptedOneToOneDefaultMode = notificationSettingsService.getDefaultRoomNotificationMode(isEncrypted = true, isOneToOne = true).getOrThrow()
+
+ if (oneToOneDefaultMode != encryptedOneToOneDefaultMode) {
+ notificationSettingsService.setDefaultRoomNotificationMode(
+ isEncrypted = encryptedOneToOneDefaultMode != RoomNotificationMode.ALL_MESSAGES,
+ mode = RoomNotificationMode.ALL_MESSAGES,
+ isOneToOne = true,
+ )
+ }
+ }.fold(
+ onSuccess = {},
+ onFailure = {
+ target.value = NotificationSettingsState.MatrixSettings.Invalid(fixFailed = true)
+ }
+ )
+ }
+
+ private fun CoroutineScope.setAtRoomNotificationsEnabled(enabled: Boolean) = launch {
+ notificationSettingsService.setRoomMentionEnabled(enabled)
+ }
+
+ private fun CoroutineScope.setCallNotificationsEnabled(enabled: Boolean) = launch {
+ notificationSettingsService.setCallEnabled(enabled)
+ }
+
+ private fun CoroutineScope.setNotificationsEnabled(userPushStore: UserPushStore, enabled: Boolean) = launch {
+ userPushStore.setNotificationEnabledForDevice(enabled)
+ }
+}
diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/NotificationSettingsState.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/NotificationSettingsState.kt
new file mode 100644
index 0000000000..cf3cf6e3d0
--- /dev/null
+++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/NotificationSettingsState.kt
@@ -0,0 +1,50 @@
+/*
+ * Copyright (c) 2023 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.element.android.features.preferences.impl.notifications
+
+import androidx.compose.runtime.Immutable
+import io.element.android.libraries.matrix.api.room.RoomNotificationMode
+
+@Immutable
+data class NotificationSettingsState(
+ val matrixSettings: MatrixSettings,
+ val appSettings: AppSettings,
+ val eventSink: (NotificationSettingsEvents) -> Unit,
+) {
+ sealed interface MatrixSettings {
+ data object Uninitialized : MatrixSettings
+ data class Valid(
+ val atRoomNotificationsEnabled: Boolean,
+ val callNotificationsEnabled: Boolean,
+ val defaultGroupNotificationMode: RoomNotificationMode?,
+ val defaultOneToOneNotificationMode: RoomNotificationMode?,
+ ) : MatrixSettings
+
+ data class Invalid(
+ val fixFailed: Boolean
+ ) : MatrixSettings
+ }
+
+ data class AppSettings(
+ val systemNotificationsEnabled: Boolean,
+ val appNotificationsEnabled: Boolean,
+ )
+}
+
+
+
+
diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/NotificationSettingsStateProvider.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/NotificationSettingsStateProvider.kt
new file mode 100644
index 0000000000..1e653c47e0
--- /dev/null
+++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/NotificationSettingsStateProvider.kt
@@ -0,0 +1,41 @@
+/*
+ * Copyright (c) 2023 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.element.android.features.preferences.impl.notifications
+
+import androidx.compose.ui.tooling.preview.PreviewParameterProvider
+import io.element.android.libraries.matrix.api.room.RoomNotificationMode
+
+open class NotificationSettingsStateProvider : PreviewParameterProvider {
+ override val values: Sequence
+ get() = sequenceOf(
+ aNotificationSettingsState(),
+ )
+}
+
+fun aNotificationSettingsState() = NotificationSettingsState(
+ matrixSettings = NotificationSettingsState.MatrixSettings.Valid(
+ atRoomNotificationsEnabled = true,
+ callNotificationsEnabled = true,
+ defaultGroupNotificationMode = RoomNotificationMode.MENTIONS_AND_KEYWORDS_ONLY,
+ defaultOneToOneNotificationMode = RoomNotificationMode.ALL_MESSAGES,
+ ),
+ appSettings = NotificationSettingsState.AppSettings(
+ systemNotificationsEnabled = false,
+ appNotificationsEnabled = true,
+ ),
+ eventSink = {}
+)
diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/NotificationSettingsView.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/NotificationSettingsView.kt
new file mode 100644
index 0000000000..4b17eba4ca
--- /dev/null
+++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/NotificationSettingsView.kt
@@ -0,0 +1,263 @@
+/*
+ * Copyright (c) 2023 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.element.android.features.preferences.impl.notifications
+
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.NotificationsOff
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.tooling.preview.PreviewParameter
+import androidx.compose.ui.unit.dp
+import androidx.lifecycle.Lifecycle
+import io.element.android.libraries.androidutils.system.startNotificationSettingsIntent
+import io.element.android.libraries.designsystem.components.dialogs.ErrorDialog
+import io.element.android.libraries.designsystem.components.preferences.PreferenceCategory
+import io.element.android.libraries.designsystem.components.preferences.PreferenceSwitch
+import io.element.android.libraries.designsystem.components.preferences.PreferenceText
+import io.element.android.libraries.designsystem.components.preferences.PreferenceView
+import io.element.android.libraries.designsystem.preview.ElementPreviewDark
+import io.element.android.libraries.designsystem.preview.ElementPreviewLight
+import io.element.android.libraries.designsystem.theme.components.Button
+import io.element.android.libraries.designsystem.theme.components.ButtonSize
+import io.element.android.libraries.designsystem.theme.components.Surface
+import io.element.android.libraries.designsystem.theme.components.Text
+import io.element.android.libraries.designsystem.utils.OnLifecycleEvent
+import io.element.android.libraries.matrix.api.room.RoomNotificationMode
+import io.element.android.libraries.theme.ElementTheme
+import io.element.android.libraries.ui.strings.CommonStrings
+
+/**
+ * A view that allows a user edit their global notification settings.
+ */
+@Composable
+fun NotificationSettingsView(
+ state: NotificationSettingsState,
+ onOpenEditDefault: (isOneToOne: Boolean) -> Unit,
+ onBackPressed: () -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ OnLifecycleEvent { _, event ->
+ when (event) {
+ Lifecycle.Event.ON_RESUME -> state.eventSink.invoke(NotificationSettingsEvents.RefreshSystemNotificationsEnabled)
+ else -> Unit
+ }
+ }
+ PreferenceView(
+ modifier = modifier,
+ onBackPressed = onBackPressed,
+ title = stringResource(id = CommonStrings.screen_notification_settings_title)
+ ) {
+
+ when (state.matrixSettings) {
+ is NotificationSettingsState.MatrixSettings.Invalid -> InvalidNotificationSettingsView(
+ showError = state.matrixSettings.fixFailed,
+ onContinueClicked = { state.eventSink(NotificationSettingsEvents.FixConfigurationMismatch) },
+ onDismissError = { state.eventSink(NotificationSettingsEvents.ClearConfigurationMismatchError) },
+ )
+ NotificationSettingsState.MatrixSettings.Uninitialized -> return@PreferenceView
+ is NotificationSettingsState.MatrixSettings.Valid -> NotificationSettingsContentView(
+ matrixSettings = state.matrixSettings,
+ systemSettings = state.appSettings,
+ onNotificationsEnabledChanged = { state.eventSink(NotificationSettingsEvents.SetNotificationsEnabled(it))},
+ onGroupChatsClicked = { onOpenEditDefault(false) },
+ onDirectChatsClicked = { onOpenEditDefault(true) },
+ onMentionNotificationsChanged = { state.eventSink(NotificationSettingsEvents.SetAtRoomNotificationsEnabled(it)) },
+// onCallsNotificationsChanged = { state.eventSink(NotificationSettingsEvents.SetCallNotificationsEnabled(it)) },
+ )
+ }
+ }
+}
+
+@Composable
+private fun NotificationSettingsContentView(
+ matrixSettings: NotificationSettingsState.MatrixSettings.Valid,
+ systemSettings: NotificationSettingsState.AppSettings,
+ onNotificationsEnabledChanged: (Boolean) -> Unit,
+ onGroupChatsClicked: () -> Unit,
+ onDirectChatsClicked: () -> Unit,
+ onMentionNotificationsChanged: (Boolean) -> Unit,
+// onCallsNotificationsChanged: (Boolean) -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ val context = LocalContext.current
+ if (systemSettings.appNotificationsEnabled && !systemSettings.systemNotificationsEnabled) {
+ PreferenceText(
+ icon = Icons.Filled.NotificationsOff,
+ title = stringResource(id = CommonStrings.screen_notification_settings_system_notifications_turned_off),
+ subtitle = stringResource(id = CommonStrings.screen_notification_settings_system_notifications_action_required,
+ stringResource(id = CommonStrings.screen_notification_settings_system_notifications_action_required_content_link)),
+ onClick = {
+ context.startNotificationSettingsIntent()
+ }
+ )
+ }
+
+ PreferenceSwitch(
+ modifier = modifier,
+ title = stringResource(id = CommonStrings.screen_notification_settings_enable_notifications),
+ isChecked = systemSettings.appNotificationsEnabled,
+ switchAlignment = Alignment.Top,
+ onCheckedChange = onNotificationsEnabledChanged
+ )
+
+ if (systemSettings.appNotificationsEnabled) {
+ PreferenceCategory(title = stringResource(id = CommonStrings.screen_notification_settings_notification_section_title)) {
+ PreferenceText(
+ title = stringResource(id = CommonStrings.screen_notification_settings_group_chats),
+ subtitle = getTitleForRoomNotificationMode(mode = matrixSettings.defaultGroupNotificationMode),
+ onClick = onGroupChatsClicked
+ )
+
+ PreferenceText(
+ title = stringResource(id = CommonStrings.screen_notification_settings_direct_chats),
+ subtitle = getTitleForRoomNotificationMode(mode = matrixSettings.defaultOneToOneNotificationMode),
+ onClick = onDirectChatsClicked
+ )
+ }
+
+ PreferenceCategory(title = stringResource(id = CommonStrings.screen_notification_settings_mode_mentions)) {
+ PreferenceSwitch(
+ modifier = Modifier,
+ title = stringResource(id = CommonStrings.screen_notification_settings_room_mention_label),
+ isChecked = matrixSettings.atRoomNotificationsEnabled,
+ switchAlignment = Alignment.Top,
+ onCheckedChange = onMentionNotificationsChanged
+ )
+ }
+ // We are removing the call notification toggle until call support has been added
+// PreferenceCategory(title = stringResource(id = CommonStrings.screen_notification_settings_additional_settings_section_title)) {
+// PreferenceSwitch(
+// modifier = Modifier,
+// title = stringResource(id = CommonStrings.screen_notification_settings_calls_label),
+// isChecked = matrixSettings.callNotificationsEnabled,
+// switchAlignment = Alignment.Top,
+// onCheckedChange = onCallsNotificationsChanged
+// )
+// }
+ }
+}
+
+@Composable
+private fun getTitleForRoomNotificationMode(mode: RoomNotificationMode?) =
+when(mode) {
+ RoomNotificationMode.ALL_MESSAGES -> stringResource(id = CommonStrings.screen_notification_settings_edit_mode_all_messages)
+ RoomNotificationMode.MENTIONS_AND_KEYWORDS_ONLY -> stringResource(id = CommonStrings.screen_notification_settings_edit_mode_mentions_and_keywords)
+ RoomNotificationMode.MUTE -> stringResource(id = CommonStrings.common_mute)
+ null -> ""
+}
+
+@Composable
+private fun InvalidNotificationSettingsView(
+ showError: Boolean,
+ onContinueClicked: () -> Unit,
+ onDismissError: () -> Unit,
+ modifier: Modifier = Modifier
+) {
+ Box(modifier = modifier.padding(horizontal = 16.dp, vertical = 8.dp)) {
+ Surface(
+ Modifier.fillMaxWidth(),
+ shape = MaterialTheme.shapes.small,
+ color = MaterialTheme.colorScheme.surfaceVariant
+ ) {
+ Column(
+ Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 16.dp, vertical = 12.dp)
+ ) {
+ Row {
+ Text(
+ stringResource(CommonStrings.screen_notification_settings_configuration_mismatch),
+ modifier = Modifier.weight(1f),
+ style = ElementTheme.typography.fontBodyLgMedium,
+ color = MaterialTheme.colorScheme.primary,
+ textAlign = TextAlign.Start,
+ )
+ }
+ Spacer(modifier = Modifier.height(4.dp))
+ Text(
+ stringResource(CommonStrings.screen_notification_settings_configuration_mismatch_description),
+ style = ElementTheme.typography.fontBodyMdRegular,
+ )
+ Spacer(modifier = Modifier.height(12.dp))
+ Button(
+ text = stringResource(CommonStrings.action_continue),
+ size = ButtonSize.Medium,
+ modifier = Modifier.fillMaxWidth(),
+ onClick = onContinueClicked,
+ )
+ }
+ }
+ }
+ if(showError) {
+ ErrorDialog(
+ title = stringResource(id = CommonStrings.dialog_title_error),
+ content = stringResource(id = CommonStrings.screen_notification_settings_failed_fixing_configuration),
+ onDismiss = onDismissError
+ )
+ }
+}
+
+@Preview
+@Composable
+internal fun NotificationSettingsViewLightPreview(@PreviewParameter(NotificationSettingsStateProvider::class) state: NotificationSettingsState) =
+ ElementPreviewLight { ContentToPreview(state) }
+
+@Preview
+@Composable
+internal fun NotificationSettingsViewDarkPreview(@PreviewParameter(NotificationSettingsStateProvider::class) state: NotificationSettingsState) =
+ ElementPreviewDark { ContentToPreview(state) }
+
+@Composable
+private fun ContentToPreview(state: NotificationSettingsState) {
+ NotificationSettingsView(
+ state = state,
+ onBackPressed = {},
+ onOpenEditDefault = {},
+ )
+}
+
+@Preview
+@Composable
+internal fun InvalidNotificationSettingsViewightPreview() =
+ ElementPreviewLight { InvalidNotificationSettingsContentToPreview() }
+
+@Preview
+@Composable
+internal fun InvalidNotificationSettingsViewDarkPreview() =
+ ElementPreviewDark { InvalidNotificationSettingsContentToPreview() }
+
+@Composable
+private fun InvalidNotificationSettingsContentToPreview() {
+ InvalidNotificationSettingsView(
+ showError = false,
+ onContinueClicked = {},
+ onDismissError = {},
+ )
+}
diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/SystemNotificationsEnabledProvider.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/SystemNotificationsEnabledProvider.kt
new file mode 100644
index 0000000000..f33b1cb773
--- /dev/null
+++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/SystemNotificationsEnabledProvider.kt
@@ -0,0 +1,39 @@
+/*
+ * Copyright (c) 2023 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.element.android.features.preferences.impl.notifications
+
+import android.content.Context
+import androidx.core.app.NotificationManagerCompat
+import com.squareup.anvil.annotations.ContributesBinding
+import io.element.android.libraries.di.AppScope
+import io.element.android.libraries.di.ApplicationContext
+import io.element.android.libraries.di.SingleIn
+import javax.inject.Inject
+
+interface SystemNotificationsEnabledProvider {
+ fun notificationsEnabled(): Boolean
+}
+@SingleIn(AppScope::class)
+@ContributesBinding(AppScope::class, boundType = SystemNotificationsEnabledProvider::class)
+class DefaultSystemNotificationsEnabledProvider @Inject constructor(
+ @ApplicationContext private val context: Context,
+): SystemNotificationsEnabledProvider {
+ override fun notificationsEnabled(): Boolean {
+ return NotificationManagerCompat.from(context).areNotificationsEnabled()
+ }
+}
+
diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/edit/DefaultNotificationSettingOption.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/edit/DefaultNotificationSettingOption.kt
new file mode 100644
index 0000000000..e60b2bc8dc
--- /dev/null
+++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/edit/DefaultNotificationSettingOption.kt
@@ -0,0 +1,98 @@
+/*
+ * Copyright (c) 2023 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.element.android.features.preferences.impl.notifications.edit
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.selection.selectable
+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.semantics.Role
+import androidx.compose.ui.unit.dp
+import io.element.android.libraries.designsystem.preview.DayNightPreviews
+import io.element.android.libraries.designsystem.preview.ElementPreview
+import io.element.android.libraries.designsystem.theme.components.RadioButton
+import io.element.android.libraries.designsystem.theme.components.Text
+import io.element.android.libraries.matrix.api.room.RoomNotificationMode
+import io.element.android.libraries.theme.ElementTheme
+import io.element.android.libraries.ui.strings.CommonStrings
+
+@Composable
+fun DefaultNotificationSettingOption(
+ mode: RoomNotificationMode,
+ modifier: Modifier = Modifier,
+ isSelected: Boolean = false,
+ onOptionSelected: (RoomNotificationMode) -> Unit = {},
+) {
+ val subtitle = when(mode) {
+ RoomNotificationMode.ALL_MESSAGES -> stringResource(id = CommonStrings.screen_notification_settings_edit_mode_all_messages)
+ RoomNotificationMode.MENTIONS_AND_KEYWORDS_ONLY -> stringResource(id = CommonStrings.screen_notification_settings_edit_mode_mentions_and_keywords)
+ else -> ""
+ }
+ Row(
+ modifier
+ .fillMaxWidth()
+ .selectable(
+ selected = isSelected,
+ onClick = { onOptionSelected(mode) },
+ role = Role.RadioButton,
+ )
+ .padding(8.dp),
+ ) {
+ Column(
+ Modifier
+ .weight(1f)
+ .padding(horizontal = 8.dp)
+ .align(Alignment.CenterVertically)
+ ) {
+ Text(
+ text = subtitle,
+ style = ElementTheme.typography.fontBodyLgRegular,
+ )
+ }
+
+ RadioButton(
+ modifier = Modifier
+ .align(Alignment.CenterVertically)
+ .size(48.dp),
+ selected = isSelected,
+ onClick = null // null recommended for accessibility with screenreaders
+ )
+ }
+}
+@DayNightPreviews
+@Composable
+internal fun DefaultNotificationSettingOptionPreview() = ElementPreview { ContentToPreview() }
+
+@Composable
+private fun ContentToPreview() {
+ Column {
+ DefaultNotificationSettingOption(
+ mode = RoomNotificationMode.ALL_MESSAGES,
+ isSelected = true,
+ )
+ DefaultNotificationSettingOption(
+ mode = RoomNotificationMode.MENTIONS_AND_KEYWORDS_ONLY,
+ isSelected = false,
+ )
+ }
+}
+
diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/edit/EditDefaultNotificationSettingNode.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/edit/EditDefaultNotificationSettingNode.kt
new file mode 100644
index 0000000000..6c4fd646f4
--- /dev/null
+++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/edit/EditDefaultNotificationSettingNode.kt
@@ -0,0 +1,54 @@
+/*
+ * Copyright (c) 2023 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.element.android.features.preferences.impl.notifications.edit
+
+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 dagger.assisted.Assisted
+import dagger.assisted.AssistedInject
+import io.element.android.anvilannotations.ContributesNode
+import io.element.android.libraries.architecture.NodeInputs
+import io.element.android.libraries.architecture.inputs
+import io.element.android.libraries.di.SessionScope
+
+@ContributesNode(SessionScope::class)
+class EditDefaultNotificationSettingNode @AssistedInject constructor(
+ @Assisted buildContext: BuildContext,
+ @Assisted plugins: List,
+ presenterFactory: EditDefaultNotificationSettingPresenter.Factory
+) : Node(buildContext, plugins = plugins) {
+
+ data class Inputs(
+ val isOneToOne: Boolean
+ ) : NodeInputs
+
+ private val inputs = inputs()
+ private val presenter = presenterFactory.create(inputs.isOneToOne)
+
+ @Composable
+ override fun View(modifier: Modifier) {
+ val state = presenter.present()
+ EditDefaultNotificationSettingView(
+ state = state,
+ onBackPressed = ::navigateUp,
+ modifier = modifier
+ )
+ }
+}
diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/edit/EditDefaultNotificationSettingPresenter.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/edit/EditDefaultNotificationSettingPresenter.kt
new file mode 100644
index 0000000000..764b37c52d
--- /dev/null
+++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/edit/EditDefaultNotificationSettingPresenter.kt
@@ -0,0 +1,92 @@
+/*
+ * Copyright (c) 2023 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.element.android.features.preferences.impl.notifications.edit
+
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.MutableState
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+import dagger.assisted.Assisted
+import dagger.assisted.AssistedFactory
+import dagger.assisted.AssistedInject
+import io.element.android.libraries.architecture.Presenter
+import io.element.android.libraries.matrix.api.notificationsettings.NotificationSettingsService
+import io.element.android.libraries.matrix.api.room.RoomNotificationMode
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.FlowPreview
+import kotlinx.coroutines.flow.debounce
+import kotlinx.coroutines.flow.launchIn
+import kotlinx.coroutines.flow.onEach
+import kotlinx.coroutines.launch
+import kotlin.time.Duration.Companion.seconds
+
+class EditDefaultNotificationSettingPresenter @AssistedInject constructor(
+ private val notificationSettingsService: NotificationSettingsService,
+ @Assisted private val isOneToOne: Boolean,
+) : Presenter {
+ @AssistedFactory
+ interface Factory {
+ fun create(oneToOne: Boolean): EditDefaultNotificationSettingPresenter
+ }
+ @Composable
+ override fun present(): EditDefaultNotificationSettingState {
+
+ val mode: MutableState = remember {
+ mutableStateOf(null)
+ }
+ val localCoroutineScope = rememberCoroutineScope()
+ LaunchedEffect(Unit) {
+ fetchSettings(mode)
+ observeNotificationSettings(mode)
+ }
+
+ fun handleEvents(event: EditDefaultNotificationSettingStateEvents) {
+ when (event) {
+ is EditDefaultNotificationSettingStateEvents.SetNotificationMode -> localCoroutineScope.setDefaultNotificationMode(event.mode)
+ }
+ }
+
+ return EditDefaultNotificationSettingState(
+ isOneToOne = isOneToOne,
+ mode = mode.value,
+ eventSink = ::handleEvents
+ )
+ }
+
+ private fun CoroutineScope.fetchSettings(mode: MutableState) = launch {
+ mode.value = notificationSettingsService.getDefaultRoomNotificationMode(isEncrypted = true, isOneToOne = isOneToOne).getOrThrow()
+ }
+
+ @OptIn(FlowPreview::class)
+ private fun CoroutineScope.observeNotificationSettings(mode: MutableState) {
+ notificationSettingsService.notificationSettingsChangeFlow
+ .debounce(0.5.seconds)
+ .onEach {
+ fetchSettings(mode)
+ }
+ .launchIn(this)
+ }
+
+ private fun CoroutineScope.setDefaultNotificationMode(mode: RoomNotificationMode) = launch {
+ // On modern clients, we don't have different settings for encrypted and non-encrypted rooms (Legacy clients did).
+ notificationSettingsService.setDefaultRoomNotificationMode(isEncrypted = true, mode = mode, isOneToOne = isOneToOne)
+ notificationSettingsService.setDefaultRoomNotificationMode(isEncrypted = false, mode = mode, isOneToOne = isOneToOne)
+ }
+
+}
diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/edit/EditDefaultNotificationSettingState.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/edit/EditDefaultNotificationSettingState.kt
new file mode 100644
index 0000000000..62c708d988
--- /dev/null
+++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/edit/EditDefaultNotificationSettingState.kt
@@ -0,0 +1,25 @@
+/*
+ * Copyright (c) 2023 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.element.android.features.preferences.impl.notifications.edit
+
+import io.element.android.libraries.matrix.api.room.RoomNotificationMode
+
+data class EditDefaultNotificationSettingState(
+ val isOneToOne: Boolean,
+ val mode: RoomNotificationMode?,
+ val eventSink: (EditDefaultNotificationSettingStateEvents) -> Unit,
+)
diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/edit/EditDefaultNotificationSettingStateEvents.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/edit/EditDefaultNotificationSettingStateEvents.kt
new file mode 100644
index 0000000000..75c9b6c1a4
--- /dev/null
+++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/edit/EditDefaultNotificationSettingStateEvents.kt
@@ -0,0 +1,23 @@
+/*
+ * Copyright (c) 2023 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.element.android.features.preferences.impl.notifications.edit
+
+import io.element.android.libraries.matrix.api.room.RoomNotificationMode
+
+sealed interface EditDefaultNotificationSettingStateEvents {
+ data class SetNotificationMode(val mode: RoomNotificationMode): EditDefaultNotificationSettingStateEvents
+}
diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/edit/EditDefaultNotificationSettingView.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/edit/EditDefaultNotificationSettingView.kt
new file mode 100644
index 0000000000..4cc95af71f
--- /dev/null
+++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/edit/EditDefaultNotificationSettingView.kt
@@ -0,0 +1,75 @@
+/*
+ * Copyright (c) 2023 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.element.android.features.preferences.impl.notifications.edit
+
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.selection.selectableGroup
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.stringResource
+import io.element.android.libraries.designsystem.components.preferences.PreferenceCategory
+import io.element.android.libraries.designsystem.components.preferences.PreferenceView
+import io.element.android.libraries.matrix.api.room.RoomNotificationMode
+import io.element.android.libraries.ui.strings.CommonStrings
+
+/**
+ * A view that allows a user to edit the default notification setting for rooms. This can be set separately
+ * for one-to-one and group rooms, indicated by [EditDefaultNotificationSettingState.isOneToOne].
+ */
+@Composable
+fun EditDefaultNotificationSettingView(
+ state: EditDefaultNotificationSettingState,
+ onBackPressed: () -> Unit,
+ modifier: Modifier = Modifier,
+) {
+
+ val title = if(state.isOneToOne) {
+ CommonStrings.screen_notification_settings_direct_chats
+ } else {
+ CommonStrings.screen_notification_settings_group_chats
+ }
+ PreferenceView(
+ modifier = modifier,
+ onBackPressed = onBackPressed,
+ title = stringResource(id = title)
+ ) {
+
+ // Only ALL_MESSAGES and MENTIONS_AND_KEYWORDS_ONLY are valid global defaults.
+ val validModes = listOf(RoomNotificationMode.ALL_MESSAGES, RoomNotificationMode.MENTIONS_AND_KEYWORDS_ONLY)
+
+ val categoryTitle = if(state.isOneToOne) {
+ CommonStrings.screen_notification_settings_edit_screen_direct_section_header
+ } else {
+ CommonStrings.screen_notification_settings_edit_screen_group_section_header
+ }
+ PreferenceCategory(title = stringResource(id = categoryTitle)) {
+
+ if (state.mode != null) {
+ Column(modifier = Modifier.selectableGroup()) {
+ validModes.forEach { item ->
+ DefaultNotificationSettingOption(
+ mode = item,
+ isSelected = state.mode == item,
+ onOptionSelected = { state.eventSink(EditDefaultNotificationSettingStateEvents.SetNotificationMode(it)) }
+ )
+ }
+ }
+ }
+ }
+ }
+}
+
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 e90569b40e..407832627b 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
@@ -29,6 +29,8 @@ import dagger.assisted.AssistedInject
import io.element.android.anvilannotations.ContributesNode
import io.element.android.libraries.androidutils.browser.openUrlInChromeCustomTab
import io.element.android.libraries.di.SessionScope
+import io.element.android.libraries.matrix.api.user.MatrixUser
+import io.element.android.libraries.theme.ElementTheme
import timber.log.Timber
@ContributesNode(SessionScope::class)
@@ -44,6 +46,9 @@ class PreferencesRootNode @AssistedInject constructor(
fun onOpenAnalytics()
fun onOpenAbout()
fun onOpenDeveloperSettings()
+ fun onOpenNotificationSettings()
+ fun onOpenAdvancedSettings()
+ fun onOpenUserProfile(matrixUser: MatrixUser)
}
private fun onOpenBugReport() {
@@ -58,6 +63,10 @@ class PreferencesRootNode @AssistedInject constructor(
plugins().forEach { it.onOpenDeveloperSettings() }
}
+ private fun onOpenAdvancedSettings() {
+ plugins().forEach { it.onOpenAdvancedSettings() }
+ }
+
private fun onOpenAnalytics() {
plugins().forEach { it.onOpenAnalytics() }
}
@@ -66,16 +75,33 @@ class PreferencesRootNode @AssistedInject constructor(
plugins().forEach { it.onOpenAbout() }
}
- private fun onManageAccountClicked(activity: Activity, accountManagementUrl: String?) {
- accountManagementUrl?.let {
- activity.openUrlInChromeCustomTab(null, false, it)
+ private fun onManageAccountClicked(
+ activity: Activity,
+ url: String?,
+ isDark: Boolean,
+ ) {
+ url?.let {
+ activity.openUrlInChromeCustomTab(
+ null,
+ darkTheme = isDark,
+ url = it
+ )
}
}
+ private fun onOpenNotificationSettings() {
+ plugins().forEach { it.onOpenNotificationSettings() }
+ }
+
+ private fun onOpenUserProfile(matrixUser: MatrixUser) {
+ plugins().forEach { it.onOpenUserProfile(matrixUser) }
+ }
+
@Composable
override fun View(modifier: Modifier) {
val state = presenter.present()
val activity = LocalContext.current as Activity
+ val isDark = ElementTheme.isLightTheme.not()
PreferencesRootView(
state = state,
modifier = modifier,
@@ -85,8 +111,11 @@ class PreferencesRootNode @AssistedInject constructor(
onOpenAbout = this::onOpenAbout,
onVerifyClicked = this::onVerifyClicked,
onOpenDeveloperSettings = this::onOpenDeveloperSettings,
+ onOpenAdvancedSettings = this::onOpenAdvancedSettings,
onSuccessLogout = { onSuccessLogout(activity, it) },
- onManageAccountClicked = { onManageAccountClicked(activity, state.accountManagementUrl) },
+ onManageAccountClicked = { onManageAccountClicked(activity, it, isDark) },
+ onOpenNotificationSettings = this::onOpenNotificationSettings,
+ onOpenUserProfile = this::onOpenUserProfile,
)
}
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 af82a3ab2a..dac7ae3204 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
@@ -29,7 +29,10 @@ import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.core.meta.BuildType
import io.element.android.libraries.designsystem.utils.SnackbarDispatcher
import io.element.android.libraries.designsystem.utils.collectSnackbarMessageAsState
+import io.element.android.libraries.featureflag.api.FeatureFlagService
+import io.element.android.libraries.featureflag.api.FeatureFlags
import io.element.android.libraries.matrix.api.MatrixClient
+import io.element.android.libraries.matrix.api.oidc.AccountManagementAction
import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.matrix.api.user.getCurrentUser
import io.element.android.libraries.matrix.api.verification.SessionVerificationService
@@ -46,6 +49,7 @@ class PreferencesRootPresenter @Inject constructor(
private val buildType: BuildType,
private val versionFormatter: VersionFormatter,
private val snackbarDispatcher: SnackbarDispatcher,
+ private val featureFlagService: FeatureFlagService,
) : Presenter {
@Composable
@@ -60,15 +64,23 @@ class PreferencesRootPresenter @Inject constructor(
val snackbarMessage by snackbarDispatcher.collectSnackbarMessageAsState()
val hasAnalyticsProviders = remember { analyticsService.getAvailableAnalyticsProviders().isNotEmpty() }
+ val showNotificationSettings = remember { mutableStateOf(false) }
+ LaunchedEffect(Unit) {
+ showNotificationSettings.value = featureFlagService.isFeatureEnabled(FeatureFlags.NotificationSettings)
+ }
+
// We should display the 'complete verification' option if the current session can be verified
val showCompleteVerification by sessionVerificationService.canVerifySessionFlow.collectAsState(false)
val accountManagementUrl: MutableState = remember {
mutableStateOf(null)
}
+ val devicesManagementUrl: MutableState = remember {
+ mutableStateOf(null)
+ }
LaunchedEffect(Unit) {
- initAccountManagementUrl(accountManagementUrl)
+ initAccountManagementUrl(accountManagementUrl, devicesManagementUrl)
}
val logoutState = logoutPresenter.present()
@@ -79,8 +91,10 @@ class PreferencesRootPresenter @Inject constructor(
version = versionFormatter.get(),
showCompleteVerification = showCompleteVerification,
accountManagementUrl = accountManagementUrl.value,
+ devicesManagementUrl = devicesManagementUrl.value,
showAnalyticsSettings = hasAnalyticsProviders,
showDeveloperSettings = showDeveloperSettings,
+ showNotificationSettings = showNotificationSettings.value,
snackbarMessage = snackbarMessage,
)
}
@@ -89,7 +103,11 @@ class PreferencesRootPresenter @Inject constructor(
matrixUser.value = matrixClient.getCurrentUser()
}
- private fun CoroutineScope.initAccountManagementUrl(accountManagementUrl: MutableState) = launch {
- accountManagementUrl.value = matrixClient.getAccountManagementUrl().getOrNull()
+ private fun CoroutineScope.initAccountManagementUrl(
+ accountManagementUrl: MutableState,
+ devicesManagementUrl: MutableState,
+ ) = launch {
+ accountManagementUrl.value = matrixClient.getAccountManagementUrl(AccountManagementAction.Profile).getOrNull()
+ devicesManagementUrl.value = matrixClient.getAccountManagementUrl(AccountManagementAction.SessionsList).getOrNull()
}
}
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 af3a090630..accede5d6d 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
@@ -26,7 +26,9 @@ data class PreferencesRootState(
val version: String,
val showCompleteVerification: Boolean,
val accountManagementUrl: String?,
+ val devicesManagementUrl: String?,
val showAnalyticsSettings: Boolean,
val showDeveloperSettings: Boolean,
+ val showNotificationSettings: Boolean,
val snackbarMessage: SnackbarMessage?,
)
diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootStateProvider.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootStateProvider.kt
index 931a560c1d..860c738687 100644
--- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootStateProvider.kt
+++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootStateProvider.kt
@@ -26,7 +26,9 @@ fun aPreferencesRootState() = PreferencesRootState(
version = "Version 1.1 (1)",
showCompleteVerification = true,
accountManagementUrl = "aUrl",
+ devicesManagementUrl = "anOtherUrl",
showAnalyticsSettings = true,
showDeveloperSettings = true,
+ showNotificationSettings = true,
snackbarMessage = SnackbarMessage(CommonStrings.common_verification_complete),
)
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 df108c03a3..933f22bc13 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
@@ -16,6 +16,7 @@
package io.element.android.features.preferences.impl.root
+import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
@@ -23,7 +24,9 @@ import androidx.compose.material.icons.outlined.BugReport
import androidx.compose.material.icons.outlined.DeveloperMode
import androidx.compose.material.icons.outlined.Help
import androidx.compose.material.icons.outlined.InsertChart
-import androidx.compose.material.icons.outlined.ManageAccounts
+import androidx.compose.material.icons.outlined.Notifications
+import androidx.compose.material.icons.outlined.OpenInNew
+import androidx.compose.material.icons.outlined.Settings
import androidx.compose.material.icons.outlined.VerifiedUser
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
@@ -52,12 +55,15 @@ fun PreferencesRootView(
state: PreferencesRootState,
onBackPressed: () -> Unit,
onVerifyClicked: () -> Unit,
- onManageAccountClicked: () -> Unit,
+ onManageAccountClicked: (url: String) -> Unit,
onOpenAnalytics: () -> Unit,
onOpenRageShake: () -> Unit,
onOpenAbout: () -> Unit,
onOpenDeveloperSettings: () -> Unit,
+ onOpenAdvancedSettings: () -> Unit,
onSuccessLogout: (logoutUrlResult: String?) -> Unit,
+ onOpenNotificationSettings: () -> Unit,
+ onOpenUserProfile: (MatrixUser) -> Unit,
modifier: Modifier = Modifier,
) {
val snackbarHostState = rememberSnackbarHostState(snackbarMessage = state.snackbarMessage)
@@ -69,7 +75,12 @@ fun PreferencesRootView(
title = stringResource(id = CommonStrings.common_settings),
snackbarHost = { SnackbarHost(snackbarHostState) }
) {
- UserPreferences(state.myUser)
+ UserPreferences(
+ modifier = Modifier.clickable {
+ state.myUser?.let(onOpenUserProfile)
+ },
+ user = state.myUser,
+ )
if (state.showCompleteVerification) {
PreferenceText(
title = stringResource(id = CommonStrings.action_complete_verification),
@@ -80,10 +91,11 @@ fun PreferencesRootView(
}
if (state.accountManagementUrl != null) {
PreferenceText(
- title = stringResource(id = CommonStrings.screen_settings_oidc_account),
- icon = Icons.Outlined.ManageAccounts,
- onClick = onManageAccountClicked,
+ title = stringResource(id = CommonStrings.action_manage_account),
+ icon = Icons.Outlined.OpenInNew,
+ onClick = { onManageAccountClicked(state.accountManagementUrl) },
)
+ HorizontalDivider()
}
if (state.showAnalyticsSettings) {
PreferenceText(
@@ -92,6 +104,13 @@ fun PreferencesRootView(
onClick = onOpenAnalytics,
)
}
+ if (state.showNotificationSettings) {
+ PreferenceText(
+ title = stringResource(id = CommonStrings.screen_notification_settings_title),
+ icon = Icons.Outlined.Notifications,
+ onClick = onOpenNotificationSettings,
+ )
+ }
PreferenceText(
title = stringResource(id = CommonStrings.action_report_bug),
icon = Icons.Outlined.BugReport,
@@ -102,6 +121,20 @@ fun PreferencesRootView(
icon = Icons.Outlined.Help,
onClick = onOpenAbout,
)
+ HorizontalDivider()
+ if (state.devicesManagementUrl != null) {
+ PreferenceText(
+ title = stringResource(id = CommonStrings.action_manage_devices),
+ icon = Icons.Outlined.OpenInNew,
+ onClick = { onManageAccountClicked(state.devicesManagementUrl) },
+ )
+ HorizontalDivider()
+ }
+ PreferenceText(
+ title = stringResource(id = CommonStrings.common_advanced_settings),
+ icon = Icons.Outlined.Settings,
+ onClick = onOpenAdvancedSettings,
+ )
if (state.showDeveloperSettings) {
DeveloperPreferencesView(onOpenDeveloperSettings)
}
@@ -149,9 +182,12 @@ private fun ContentToPreview(matrixUser: MatrixUser) {
onOpenAnalytics = {},
onOpenRageShake = {},
onOpenDeveloperSettings = {},
+ onOpenAdvancedSettings = {},
onOpenAbout = {},
onVerifyClicked = {},
onSuccessLogout = {},
onManageAccountClicked = {},
+ onOpenNotificationSettings = {},
+ onOpenUserProfile = {},
)
}
diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/tasks/ComputeCacheSizeUseCase.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/tasks/ComputeCacheSizeUseCase.kt
index 661f6493ec..0eb6e5b613 100644
--- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/tasks/ComputeCacheSizeUseCase.kt
+++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/tasks/ComputeCacheSizeUseCase.kt
@@ -18,8 +18,8 @@ package io.element.android.features.preferences.impl.tasks
import android.content.Context
import com.squareup.anvil.annotations.ContributesBinding
-import io.element.android.libraries.androidutils.filesize.FileSizeFormatter
import io.element.android.libraries.androidutils.file.getSizeOfFiles
+import io.element.android.libraries.androidutils.filesize.FileSizeFormatter
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.di.ApplicationContext
import io.element.android.libraries.di.SessionScope
diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/user/editprofile/EditUserProfileEvents.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/user/editprofile/EditUserProfileEvents.kt
new file mode 100644
index 0000000000..5c53ec23c4
--- /dev/null
+++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/user/editprofile/EditUserProfileEvents.kt
@@ -0,0 +1,26 @@
+/*
+ * Copyright (c) 2023 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.element.android.features.preferences.impl.user.editprofile
+
+import io.element.android.libraries.matrix.ui.media.AvatarAction
+
+sealed interface EditUserProfileEvents {
+ data class HandleAvatarAction(val action: AvatarAction) : EditUserProfileEvents
+ data class UpdateDisplayName(val name: String) : EditUserProfileEvents
+ data object Save : EditUserProfileEvents
+ data object CancelSaveChanges : EditUserProfileEvents
+}
diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/user/editprofile/EditUserProfileNode.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/user/editprofile/EditUserProfileNode.kt
new file mode 100644
index 0000000000..738ae4ec6d
--- /dev/null
+++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/user/editprofile/EditUserProfileNode.kt
@@ -0,0 +1,56 @@
+/*
+ * Copyright (c) 2023 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.element.android.features.preferences.impl.user.editprofile
+
+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 dagger.assisted.Assisted
+import dagger.assisted.AssistedInject
+import io.element.android.anvilannotations.ContributesNode
+import io.element.android.libraries.architecture.NodeInputs
+import io.element.android.libraries.architecture.inputs
+import io.element.android.libraries.di.SessionScope
+import io.element.android.libraries.matrix.api.user.MatrixUser
+
+@ContributesNode(SessionScope::class)
+class EditUserProfileNode @AssistedInject constructor(
+ @Assisted buildContext: BuildContext,
+ @Assisted plugins: List,
+ presenterFactory: EditUserProfilePresenter.Factory,
+) : Node(buildContext, plugins = plugins) {
+
+ data class Inputs(
+ val matrixUser: MatrixUser
+ ) : NodeInputs
+
+ val matrixUser = inputs().matrixUser
+ val presenter = presenterFactory.create(matrixUser)
+
+ @Composable
+ override fun View(modifier: Modifier) {
+ val state = presenter.present()
+ EditUserProfileView(
+ state = state,
+ onBackPressed = ::navigateUp,
+ onProfileEdited = ::navigateUp,
+ modifier = modifier
+ )
+ }
+}
diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/user/editprofile/EditUserProfilePresenter.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/user/editprofile/EditUserProfilePresenter.kt
new file mode 100644
index 0000000000..793c4840d7
--- /dev/null
+++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/user/editprofile/EditUserProfilePresenter.kt
@@ -0,0 +1,149 @@
+/*
+ * Copyright (c) 2023 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.element.android.features.preferences.impl.user.editprofile
+
+import android.net.Uri
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.MutableState
+import androidx.compose.runtime.derivedStateOf
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.runtime.saveable.rememberSaveable
+import androidx.compose.runtime.setValue
+import androidx.core.net.toUri
+import dagger.assisted.Assisted
+import dagger.assisted.AssistedFactory
+import dagger.assisted.AssistedInject
+import io.element.android.libraries.architecture.Async
+import io.element.android.libraries.architecture.Presenter
+import io.element.android.libraries.architecture.runCatchingUpdatingState
+import io.element.android.libraries.core.mimetype.MimeTypes
+import io.element.android.libraries.matrix.api.MatrixClient
+import io.element.android.libraries.matrix.api.user.MatrixUser
+import io.element.android.libraries.matrix.ui.media.AvatarAction
+import io.element.android.libraries.mediapickers.api.PickerProvider
+import io.element.android.libraries.mediaupload.api.MediaPreProcessor
+import kotlinx.collections.immutable.toImmutableList
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.launch
+import timber.log.Timber
+
+class EditUserProfilePresenter @AssistedInject constructor(
+ @Assisted private val matrixUser: MatrixUser,
+ private val matrixClient: MatrixClient,
+ private val mediaPickerProvider: PickerProvider,
+ private val mediaPreProcessor: MediaPreProcessor,
+) : Presenter {
+
+ @AssistedFactory
+ interface Factory {
+ fun create(matrixUser: MatrixUser): EditUserProfilePresenter
+ }
+
+ @Composable
+ override fun present(): EditUserProfileState {
+ var userAvatarUri by rememberSaveable { mutableStateOf(matrixUser.avatarUrl?.let { Uri.parse(it) }) }
+ var userDisplayName by rememberSaveable { mutableStateOf(matrixUser.displayName) }
+ val cameraPhotoPicker = mediaPickerProvider.registerCameraPhotoPicker(
+ onResult = { uri -> if (uri != null) userAvatarUri = uri }
+ )
+ val galleryImagePicker = mediaPickerProvider.registerGalleryImagePicker(
+ onResult = { uri -> if (uri != null) userAvatarUri = uri }
+ )
+
+ val avatarActions by remember(userAvatarUri) {
+ derivedStateOf {
+ listOfNotNull(
+ AvatarAction.TakePhoto,
+ AvatarAction.ChoosePhoto,
+ AvatarAction.Remove.takeIf { userAvatarUri != null },
+ ).toImmutableList()
+ }
+ }
+
+ val saveAction: MutableState> = remember { mutableStateOf(Async.Uninitialized) }
+ val localCoroutineScope = rememberCoroutineScope()
+ fun handleEvents(event: EditUserProfileEvents) {
+ when (event) {
+ is EditUserProfileEvents.Save -> localCoroutineScope.saveChanges(userDisplayName, userAvatarUri, matrixUser, saveAction)
+ is EditUserProfileEvents.HandleAvatarAction -> {
+ when (event.action) {
+ AvatarAction.ChoosePhoto -> galleryImagePicker.launch()
+ AvatarAction.TakePhoto -> cameraPhotoPicker.launch()
+ AvatarAction.Remove -> userAvatarUri = null
+ }
+ }
+
+ is EditUserProfileEvents.UpdateDisplayName -> userDisplayName = event.name
+ EditUserProfileEvents.CancelSaveChanges -> saveAction.value = Async.Uninitialized
+ }
+ }
+
+ val canSave = remember(userDisplayName, userAvatarUri) {
+ val hasProfileChanged = hasDisplayNameChanged(userDisplayName, matrixUser) ||
+ hasAvatarUrlChanged(userAvatarUri, matrixUser)
+ !userDisplayName.isNullOrBlank() && hasProfileChanged
+ }
+
+ return EditUserProfileState(
+ userId = matrixUser.userId,
+ displayName = userDisplayName.orEmpty(),
+ userAvatarUrl = userAvatarUri,
+ avatarActions = avatarActions,
+ saveButtonEnabled = canSave && saveAction.value !is Async.Loading,
+ saveAction = saveAction.value,
+ eventSink = { handleEvents(it) },
+ )
+ }
+
+ private fun hasDisplayNameChanged(name: String?, currentUser: MatrixUser) =
+ name?.trim() != currentUser.displayName?.trim()
+
+ private fun hasAvatarUrlChanged(avatarUri: Uri?, currentUser: MatrixUser) =
+ // Need to call `toUri()?.toString()` to make the test pass (we mockk Uri)
+ avatarUri?.toString()?.trim() != currentUser.avatarUrl?.toUri()?.toString()?.trim()
+
+ private fun CoroutineScope.saveChanges(name: String?, avatarUri: Uri?, currentUser: MatrixUser, action: MutableState>) = launch {
+ val results = mutableListOf>()
+ suspend {
+ if (!name.isNullOrEmpty() && name.trim() != currentUser.displayName.orEmpty().trim()) {
+ results.add(matrixClient.setDisplayName(name).onFailure {
+ Timber.e(it, "Failed to set user's display name")
+ })
+ }
+ if (avatarUri?.toString()?.trim() != currentUser.avatarUrl?.trim()) {
+ results.add(updateAvatar(avatarUri).onFailure {
+ Timber.e(it, "Failed to update user's avatar")
+ })
+ }
+ if (results.all { it.isSuccess }) Unit else results.first { it.isFailure }.getOrThrow()
+ }.runCatchingUpdatingState(action)
+ }
+
+ private suspend fun updateAvatar(avatarUri: Uri?): Result {
+ return runCatching {
+ if (avatarUri != null) {
+ val preprocessed = mediaPreProcessor.process(avatarUri, MimeTypes.Jpeg, compressIfPossible = false).getOrThrow()
+ matrixClient.uploadAvatar(MimeTypes.Jpeg, preprocessed.file.readBytes()).getOrThrow()
+ } else {
+ matrixClient.removeAvatar().getOrThrow()
+ }
+ }.onFailure { Timber.e(it, "Unable to update avatar") }
+ }
+}
diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/user/editprofile/EditUserProfileState.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/user/editprofile/EditUserProfileState.kt
new file mode 100644
index 0000000000..87668e6f45
--- /dev/null
+++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/user/editprofile/EditUserProfileState.kt
@@ -0,0 +1,33 @@
+/*
+ * Copyright (c) 2023 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.element.android.features.preferences.impl.user.editprofile
+
+import android.net.Uri
+import io.element.android.libraries.architecture.Async
+import io.element.android.libraries.matrix.api.core.UserId
+import io.element.android.libraries.matrix.ui.media.AvatarAction
+import kotlinx.collections.immutable.ImmutableList
+
+data class EditUserProfileState(
+ val userId: UserId?,
+ val displayName: String,
+ val userAvatarUrl: Uri?,
+ val avatarActions: ImmutableList,
+ val saveButtonEnabled: Boolean,
+ val saveAction: Async,
+ val eventSink: (EditUserProfileEvents) -> Unit
+)
diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/user/editprofile/EditUserProfileStateProvider.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/user/editprofile/EditUserProfileStateProvider.kt
new file mode 100644
index 0000000000..5e4ccb95cb
--- /dev/null
+++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/user/editprofile/EditUserProfileStateProvider.kt
@@ -0,0 +1,40 @@
+/*
+ * Copyright (c) 2023 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.element.android.features.preferences.impl.user.editprofile
+
+import androidx.compose.ui.tooling.preview.PreviewParameterProvider
+import io.element.android.libraries.architecture.Async
+import io.element.android.libraries.matrix.api.core.UserId
+import kotlinx.collections.immutable.persistentListOf
+
+open class EditUserProfileStateProvider : PreviewParameterProvider {
+ override val values: Sequence
+ get() = sequenceOf(
+ aEditUserProfileState(),
+ // Add other states here
+ )
+}
+
+fun aEditUserProfileState() = EditUserProfileState(
+ userId = UserId("@john.doe:matrix.org"),
+ displayName = "John Doe",
+ userAvatarUrl = null,
+ avatarActions = persistentListOf(),
+ saveAction = Async.Uninitialized,
+ saveButtonEnabled = true,
+ eventSink = {}
+)
diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/user/editprofile/EditUserProfileView.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/user/editprofile/EditUserProfileView.kt
new file mode 100644
index 0000000000..5b921c047c
--- /dev/null
+++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/user/editprofile/EditUserProfileView.kt
@@ -0,0 +1,190 @@
+/*
+ * Copyright (c) 2023 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.element.android.features.preferences.impl.user.editprofile
+
+import androidx.compose.foundation.gestures.detectTapGestures
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.imePadding
+import androidx.compose.foundation.layout.navigationBarsPadding
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material.ExperimentalMaterialApi
+import androidx.compose.material.ModalBottomSheetValue
+import androidx.compose.material.rememberModalBottomSheetState
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.focus.FocusManager
+import androidx.compose.ui.input.pointer.pointerInput
+import androidx.compose.ui.platform.LocalFocusManager
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.tooling.preview.PreviewParameter
+import androidx.compose.ui.unit.dp
+import io.element.android.features.preferences.impl.R
+import io.element.android.libraries.architecture.Async
+import io.element.android.libraries.designsystem.components.LabelledOutlinedTextField
+import io.element.android.libraries.designsystem.components.ProgressDialog
+import io.element.android.libraries.designsystem.components.avatar.AvatarSize
+import io.element.android.libraries.designsystem.components.button.BackButton
+import io.element.android.libraries.designsystem.components.dialogs.ErrorDialog
+import io.element.android.libraries.designsystem.preview.DayNightPreviews
+import io.element.android.libraries.designsystem.preview.ElementPreview
+import io.element.android.libraries.designsystem.theme.aliasScreenTitle
+import io.element.android.libraries.designsystem.theme.components.Scaffold
+import io.element.android.libraries.designsystem.theme.components.Text
+import io.element.android.libraries.designsystem.theme.components.TextButton
+import io.element.android.libraries.designsystem.theme.components.TopAppBar
+import io.element.android.libraries.matrix.ui.components.AvatarActionBottomSheet
+import io.element.android.libraries.matrix.ui.components.EditableAvatarView
+import io.element.android.libraries.theme.ElementTheme
+import io.element.android.libraries.ui.strings.CommonStrings
+import kotlinx.coroutines.launch
+
+@OptIn(ExperimentalMaterialApi::class, ExperimentalMaterial3Api::class)
+@Composable
+fun EditUserProfileView(
+ state: EditUserProfileState,
+ onBackPressed: () -> Unit,
+ onProfileEdited: () -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ val coroutineScope = rememberCoroutineScope()
+ val focusManager = LocalFocusManager.current
+ val itemActionsBottomSheetState = rememberModalBottomSheetState(
+ initialValue = ModalBottomSheetValue.Hidden,
+ )
+
+ fun onAvatarClicked() {
+ focusManager.clearFocus()
+ coroutineScope.launch {
+ itemActionsBottomSheetState.show()
+ }
+ }
+
+ Scaffold(
+ modifier = modifier.clearFocusOnTap(focusManager),
+ topBar = {
+ TopAppBar(
+ title = {
+ Text(
+ text = stringResource(R.string.screen_edit_profile_title),
+ style = ElementTheme.typography.aliasScreenTitle,
+ )
+ },
+ navigationIcon = { BackButton(onClick = onBackPressed) },
+ actions = {
+ TextButton(
+ text = stringResource(CommonStrings.action_save),
+ enabled = state.saveButtonEnabled,
+ onClick = {
+ focusManager.clearFocus()
+ state.eventSink(EditUserProfileEvents.Save)
+ },
+ )
+ }
+ )
+ },
+ ) { padding ->
+ Column(
+ modifier = Modifier
+ .padding(padding)
+ .padding(horizontal = 16.dp)
+ .navigationBarsPadding()
+ .imePadding()
+ .verticalScroll(rememberScrollState())
+ ) {
+ Spacer(modifier = Modifier.height(24.dp))
+ EditableAvatarView(
+ userId = state.userId?.value,
+ displayName = state.displayName,
+ avatarUrl = state.userAvatarUrl,
+ avatarSize = AvatarSize.RoomHeader,
+ onAvatarClicked = { onAvatarClicked() },
+ modifier = Modifier.align(Alignment.CenterHorizontally),
+ )
+ Spacer(modifier = Modifier.height(16.dp))
+ state.userId?.let {
+ Text(
+ modifier = Modifier.fillMaxWidth(),
+ text = it.value,
+ style = ElementTheme.typography.fontBodyLgRegular,
+ textAlign = TextAlign.Center,
+ )
+ }
+ Spacer(modifier = Modifier.height(40.dp))
+ LabelledOutlinedTextField(
+ label = stringResource(R.string.screen_edit_profile_display_name),
+ value = state.displayName,
+ placeholder = stringResource(CommonStrings.common_room_name_placeholder),
+ singleLine = true,
+ onValueChange = { state.eventSink(EditUserProfileEvents.UpdateDisplayName(it)) },
+ )
+ }
+
+ AvatarActionBottomSheet(
+ actions = state.avatarActions,
+ modalBottomSheetState = itemActionsBottomSheetState,
+ onActionSelected = { state.eventSink(EditUserProfileEvents.HandleAvatarAction(it)) }
+ )
+
+ when (state.saveAction) {
+ is Async.Loading -> {
+ ProgressDialog(text = stringResource(R.string.screen_edit_profile_updating_details))
+ }
+ is Async.Failure -> {
+ ErrorDialog(
+ title = stringResource(R.string.screen_edit_profile_error_title),
+ content = stringResource(R.string.screen_edit_profile_error),
+ onDismiss = { state.eventSink(EditUserProfileEvents.CancelSaveChanges) },
+ )
+ }
+ is Async.Success -> {
+ LaunchedEffect(state.saveAction) {
+ onProfileEdited()
+ }
+ }
+ else -> Unit
+ }
+ }
+}
+
+private fun Modifier.clearFocusOnTap(focusManager: FocusManager): Modifier =
+ pointerInput(Unit) {
+ detectTapGestures(onTap = {
+ focusManager.clearFocus()
+ })
+ }
+
+@DayNightPreviews
+@Composable
+internal fun EditUserProfileViewPreview(@PreviewParameter(EditUserProfileStateProvider::class) state: EditUserProfileState) =
+ ElementPreview {
+ EditUserProfileView(
+ onBackPressed = {},
+ onProfileEdited = {},
+ state = state,
+ )
+ }
+
diff --git a/features/preferences/impl/src/main/res/values-de/translations.xml b/features/preferences/impl/src/main/res/values-de/translations.xml
new file mode 100644
index 0000000000..787bba6087
--- /dev/null
+++ b/features/preferences/impl/src/main/res/values-de/translations.xml
@@ -0,0 +1,9 @@
+
+
+ "Anzeigename"
+ "Ihr Anzeigename"
+ "Ein unbekannter Fehler ist aufgetreten und die Informationen konnten nicht geändert werden."
+ "Profil kann nicht aktualisiert werden"
+ "Profil bearbeiten"
+ "Profil wird aktualisiert…"
+
diff --git a/features/preferences/impl/src/main/res/values-fr/translations.xml b/features/preferences/impl/src/main/res/values-fr/translations.xml
new file mode 100644
index 0000000000..392b28c785
--- /dev/null
+++ b/features/preferences/impl/src/main/res/values-fr/translations.xml
@@ -0,0 +1,9 @@
+
+
+ "Pseudonyme"
+ "Votre pseudonyme"
+ "Une erreur inconnue s’est produite et les informations n’ont pas pu être modifiées."
+ "Impossible de mettre à jour le profil"
+ "Modifier le profil"
+ "Mise à jour du profil…"
+
diff --git a/features/preferences/impl/src/main/res/values-sk/translations.xml b/features/preferences/impl/src/main/res/values-sk/translations.xml
new file mode 100644
index 0000000000..46631c4eb3
--- /dev/null
+++ b/features/preferences/impl/src/main/res/values-sk/translations.xml
@@ -0,0 +1,9 @@
+
+
+ "Zobrazované meno"
+ "Vaše zobrazované meno"
+ "Vyskytla sa neznáma chyba a informácie nebolo možné zmeniť."
+ "Nepodarilo sa aktualizovať profil"
+ "Upraviť profil"
+ "Aktualizácia profilu…"
+
diff --git a/features/preferences/impl/src/main/res/values/localazy.xml b/features/preferences/impl/src/main/res/values/localazy.xml
new file mode 100644
index 0000000000..f01ae2b5e1
--- /dev/null
+++ b/features/preferences/impl/src/main/res/values/localazy.xml
@@ -0,0 +1,9 @@
+
+
+ "Display name"
+ "Your display name"
+ "An unknown error was encountered and the information couldn\'t be changed."
+ "Unable to update profile"
+ "Edit profile"
+ "Updating profile…"
+
diff --git a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/about/AboutPresenterTest.kt b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/about/AboutPresenterTest.kt
index 4b025c10ad..2cfd73b614 100644
--- a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/about/AboutPresenterTest.kt
+++ b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/about/AboutPresenterTest.kt
@@ -20,10 +20,16 @@ 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.tests.testutils.WarmUpRule
import kotlinx.coroutines.test.runTest
+import org.junit.Rule
import org.junit.Test
class AboutPresenterTest {
+
+ @get:Rule
+ val warmUpRule = WarmUpRule()
+
@Test
fun `present - initial state`() = runTest {
val presenter = AboutPresenter()
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
new file mode 100644
index 0000000000..76808ee5f9
--- /dev/null
+++ b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsPresenterTest.kt
@@ -0,0 +1,78 @@
+/*
+ * Copyright (c) 2023 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.element.android.features.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.libraries.featureflag.test.InMemoryPreferencesStore
+import io.element.android.tests.testutils.WarmUpRule
+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 store = InMemoryPreferencesStore()
+ val presenter = AdvancedSettingsPresenter(store)
+ moleculeFlow(RecompositionMode.Immediate) {
+ presenter.present()
+ }.test {
+ val initialState = awaitItem()
+ assertThat(initialState.isDeveloperModeEnabled).isFalse()
+ assertThat(initialState.isRichTextEditorEnabled).isFalse()
+ }
+ }
+
+ @Test
+ fun `present - developer mode on off`() = runTest {
+ val store = InMemoryPreferencesStore()
+ val presenter = AdvancedSettingsPresenter(store)
+ moleculeFlow(RecompositionMode.Immediate) {
+ presenter.present()
+ }.test {
+ val initialState = awaitItem()
+ assertThat(initialState.isDeveloperModeEnabled).isFalse()
+ initialState.eventSink.invoke(AdvancedSettingsEvents.SetDeveloperModeEnabled(true))
+ assertThat(awaitItem().isDeveloperModeEnabled).isTrue()
+ initialState.eventSink.invoke(AdvancedSettingsEvents.SetDeveloperModeEnabled(false))
+ assertThat(awaitItem().isDeveloperModeEnabled).isFalse()
+ }
+ }
+
+ @Test
+ fun `present - rich text editor on off`() = runTest {
+ val store = InMemoryPreferencesStore()
+ val presenter = AdvancedSettingsPresenter(store)
+ moleculeFlow(RecompositionMode.Immediate) {
+ presenter.present()
+ }.test {
+ val initialState = awaitItem()
+ assertThat(initialState.isRichTextEditorEnabled).isFalse()
+ initialState.eventSink.invoke(AdvancedSettingsEvents.SetRichTextEditorEnabled(true))
+ assertThat(awaitItem().isRichTextEditorEnabled).isTrue()
+ initialState.eventSink.invoke(AdvancedSettingsEvents.SetRichTextEditorEnabled(false))
+ assertThat(awaitItem().isRichTextEditorEnabled).isFalse()
+ }
+ }
+}
diff --git a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/analytics/AnalyticsSettingsPresenterTest.kt b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/analytics/AnalyticsSettingsPresenterTest.kt
index 2a7fdba258..9570258092 100644
--- a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/analytics/AnalyticsSettingsPresenterTest.kt
+++ b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/analytics/AnalyticsSettingsPresenterTest.kt
@@ -23,10 +23,16 @@ import com.google.common.truth.Truth.assertThat
import io.element.android.features.analytics.impl.preferences.DefaultAnalyticsPreferencesPresenter
import io.element.android.libraries.matrix.test.core.aBuildMeta
import io.element.android.services.analytics.test.FakeAnalyticsService
+import io.element.android.tests.testutils.WarmUpRule
import kotlinx.coroutines.test.runTest
+import org.junit.Rule
import org.junit.Test
class AnalyticsSettingsPresenterTest {
+
+ @get:Rule
+ val warmUpRule = WarmUpRule()
+
@Test
fun `present - initial state`() = runTest {
val analyticsPresenter = DefaultAnalyticsPreferencesPresenter(FakeAnalyticsService(), aBuildMeta())
diff --git a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsPresenterTest.kt b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsPresenterTest.kt
index 9b1bda3631..88778d4227 100644
--- a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsPresenterTest.kt
+++ b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsPresenterTest.kt
@@ -28,10 +28,16 @@ import io.element.android.features.rageshake.test.rageshake.FakeRageshakeDataSto
import io.element.android.libraries.architecture.Async
import io.element.android.libraries.featureflag.api.FeatureFlags
import io.element.android.libraries.featureflag.test.FakeFeatureFlagService
+import io.element.android.tests.testutils.WarmUpRule
import kotlinx.coroutines.test.runTest
+import org.junit.Rule
import org.junit.Test
class DeveloperSettingsPresenterTest {
+
+ @get:Rule
+ val warmUpRule = WarmUpRule()
+
@Test
fun `present - ensures initial state is correct`() = runTest {
val rageshakePresenter = DefaultRageshakePreferencesPresenter(FakeRageShake(), FakeRageshakeDataStore())
diff --git a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/developer/tracing/ConfigureTracingPresenterTest.kt b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/developer/tracing/ConfigureTracingPresenterTest.kt
index 979713427e..5ca874b6d6 100644
--- a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/developer/tracing/ConfigureTracingPresenterTest.kt
+++ b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/developer/tracing/ConfigureTracingPresenterTest.kt
@@ -22,11 +22,16 @@ import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.matrix.api.tracing.LogLevel
import io.element.android.libraries.matrix.api.tracing.Target
+import io.element.android.tests.testutils.WarmUpRule
import io.element.android.tests.testutils.waitForPredicate
import kotlinx.coroutines.test.runTest
+import org.junit.Rule
import org.junit.Test
class ConfigureTracingPresenterTest {
+
+ @get:Rule
+ val warmUpRule = WarmUpRule()
@Test
fun `present - initial state`() = runTest {
val store = InMemoryTracingConfigurationStore()
diff --git a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/notifications/EditDefaultNotificationSettingsPresenterTests.kt b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/notifications/EditDefaultNotificationSettingsPresenterTests.kt
new file mode 100644
index 0000000000..e8c9ff4fe5
--- /dev/null
+++ b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/notifications/EditDefaultNotificationSettingsPresenterTests.kt
@@ -0,0 +1,64 @@
+/*
+ * Copyright (c) 2023 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.element.android.features.preferences.impl.notifications
+
+import app.cash.molecule.RecompositionMode
+import app.cash.molecule.moleculeFlow
+import app.cash.turbine.test
+import com.google.common.truth.Truth
+import io.element.android.features.preferences.impl.notifications.edit.EditDefaultNotificationSettingPresenter
+import io.element.android.features.preferences.impl.notifications.edit.EditDefaultNotificationSettingStateEvents
+import io.element.android.libraries.matrix.api.room.RoomNotificationMode
+import io.element.android.libraries.matrix.test.notificationsettings.FakeNotificationSettingsService
+import io.element.android.tests.testutils.consumeItemsUntilPredicate
+import kotlinx.coroutines.test.runTest
+import org.junit.Test
+
+class EditDefaultNotificationSettingsPresenterTests {
+ @Test
+ fun `present - ensures initial state is correct`() = runTest {
+ val notificationSettingsService = FakeNotificationSettingsService()
+ val presenter = EditDefaultNotificationSettingPresenter(notificationSettingsService = notificationSettingsService, isOneToOne = false)
+ moleculeFlow(RecompositionMode.Immediate) {
+ presenter.present()
+ }.test {
+ val initialState = awaitItem()
+ Truth.assertThat(initialState.mode).isNull()
+ Truth.assertThat(initialState.isOneToOne).isFalse()
+
+ val loadedState = consumeItemsUntilPredicate {
+ it.mode == RoomNotificationMode.MENTIONS_AND_KEYWORDS_ONLY
+ }.last()
+ Truth.assertThat(loadedState.mode).isEqualTo(RoomNotificationMode.MENTIONS_AND_KEYWORDS_ONLY)
+ }
+ }
+
+ @Test
+ fun `present - edit default notification setting`() = runTest {
+ val notificationSettingsService = FakeNotificationSettingsService()
+ val presenter = EditDefaultNotificationSettingPresenter(notificationSettingsService = notificationSettingsService, isOneToOne = false)
+ moleculeFlow(RecompositionMode.Immediate) {
+ presenter.present()
+ }.test {
+ awaitItem().eventSink(EditDefaultNotificationSettingStateEvents.SetNotificationMode(RoomNotificationMode.ALL_MESSAGES))
+ val loadedState = consumeItemsUntilPredicate {
+ it.mode == RoomNotificationMode.ALL_MESSAGES
+ }.last()
+ Truth.assertThat(loadedState.mode).isEqualTo(RoomNotificationMode.ALL_MESSAGES)
+ }
+ }
+}
diff --git a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/notifications/FakeSystemNotificationsEnabledProvider.kt b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/notifications/FakeSystemNotificationsEnabledProvider.kt
new file mode 100644
index 0000000000..1a7c1b5004
--- /dev/null
+++ b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/notifications/FakeSystemNotificationsEnabledProvider.kt
@@ -0,0 +1,23 @@
+/*
+ * Copyright (c) 2023 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.element.android.features.preferences.impl.notifications
+
+class FakeSystemNotificationsEnabledProvider: SystemNotificationsEnabledProvider {
+ override fun notificationsEnabled(): Boolean {
+ return true
+ }
+}
diff --git a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/notifications/NotificationSettingsPresenterTests.kt b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/notifications/NotificationSettingsPresenterTests.kt
new file mode 100644
index 0000000000..70d15c7a71
--- /dev/null
+++ b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/notifications/NotificationSettingsPresenterTests.kt
@@ -0,0 +1,201 @@
+/*
+ * Copyright (c) 2023 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.element.android.features.preferences.impl.notifications
+
+import app.cash.molecule.RecompositionMode
+import app.cash.molecule.moleculeFlow
+import app.cash.turbine.test
+import com.element.android.libraries.pushstore.test.userpushstore.FakeUserPushStoreFactory
+import com.google.common.truth.Truth
+import io.element.android.libraries.matrix.api.room.RoomNotificationMode
+import io.element.android.libraries.matrix.test.FakeMatrixClient
+import io.element.android.libraries.matrix.test.notificationsettings.FakeNotificationSettingsService
+import io.element.android.tests.testutils.consumeItemsUntilPredicate
+import kotlinx.coroutines.test.runTest
+import org.junit.Test
+import kotlin.time.Duration.Companion.milliseconds
+
+class NotificationSettingsPresenterTests {
+ @Test
+ fun `present - ensures initial state is correct`() = runTest {
+ val presenter = aNotificationPresenter()
+ moleculeFlow(RecompositionMode.Immediate) {
+ presenter.present()
+ }.test {
+ val initialState = awaitItem()
+ Truth.assertThat(initialState.appSettings.appNotificationsEnabled).isFalse()
+ Truth.assertThat(initialState.appSettings.systemNotificationsEnabled).isTrue()
+ Truth.assertThat(initialState.matrixSettings).isEqualTo(NotificationSettingsState.MatrixSettings.Uninitialized)
+
+ val loadedState = consumeItemsUntilPredicate {
+ it.matrixSettings is NotificationSettingsState.MatrixSettings.Valid
+ }.last()
+ Truth.assertThat(loadedState.appSettings.appNotificationsEnabled).isTrue()
+ Truth.assertThat(loadedState.appSettings.systemNotificationsEnabled).isTrue()
+ val valid = loadedState.matrixSettings as? NotificationSettingsState.MatrixSettings.Valid
+ Truth.assertThat(valid?.atRoomNotificationsEnabled).isFalse()
+ Truth.assertThat(valid?.callNotificationsEnabled).isFalse()
+ Truth.assertThat(valid?.defaultGroupNotificationMode).isEqualTo(RoomNotificationMode.MENTIONS_AND_KEYWORDS_ONLY)
+ Truth.assertThat(valid?.defaultOneToOneNotificationMode).isEqualTo(RoomNotificationMode.ALL_MESSAGES)
+ cancelAndIgnoreRemainingEvents()
+ }
+ }
+
+ @Test
+ fun `present - default group notification mode changed`() = runTest {
+ val notificationSettingsService = FakeNotificationSettingsService()
+ val presenter = aNotificationPresenter(notificationSettingsService)
+ moleculeFlow(RecompositionMode.Immediate) {
+ presenter.present()
+ }.test {
+
+ notificationSettingsService.setDefaultRoomNotificationMode(isEncrypted = true, isOneToOne = false, mode = RoomNotificationMode.ALL_MESSAGES)
+ notificationSettingsService.setDefaultRoomNotificationMode(isEncrypted = false, isOneToOne = false, mode = RoomNotificationMode.ALL_MESSAGES)
+ val updatedState = consumeItemsUntilPredicate {
+ (it.matrixSettings as? NotificationSettingsState.MatrixSettings.Valid)
+ ?.defaultGroupNotificationMode == RoomNotificationMode.ALL_MESSAGES
+ }.last()
+ val valid = updatedState.matrixSettings as? NotificationSettingsState.MatrixSettings.Valid
+ Truth.assertThat(valid?.defaultGroupNotificationMode).isEqualTo(RoomNotificationMode.ALL_MESSAGES)
+ }
+ }
+
+ @Test
+ fun `present - notification settings mismatched`() = runTest {
+ val notificationSettingsService = FakeNotificationSettingsService()
+ val presenter = aNotificationPresenter(notificationSettingsService)
+ moleculeFlow(RecompositionMode.Immediate) {
+ presenter.present()
+ }.test {
+
+ notificationSettingsService.setDefaultRoomNotificationMode(
+ isEncrypted = true,
+ isOneToOne = false,
+ mode = RoomNotificationMode.ALL_MESSAGES
+ )
+ notificationSettingsService.setDefaultRoomNotificationMode(
+ isEncrypted = false,
+ isOneToOne = false,
+ mode = RoomNotificationMode.MENTIONS_AND_KEYWORDS_ONLY
+ )
+ val updatedState = consumeItemsUntilPredicate {
+ it.matrixSettings is NotificationSettingsState.MatrixSettings.Invalid
+ }.last()
+ Truth.assertThat(updatedState.matrixSettings).isEqualTo(NotificationSettingsState.MatrixSettings.Invalid(fixFailed = false))
+ }
+ }
+
+ @Test
+ fun `present - fix notification settings mismatched`() = runTest {
+ // Start with a mismatched configuration
+ val notificationSettingsService = FakeNotificationSettingsService(
+ initialEncryptedGroupDefaultMode = RoomNotificationMode.ALL_MESSAGES,
+ initialGroupDefaultMode = RoomNotificationMode.MENTIONS_AND_KEYWORDS_ONLY,
+ initialEncryptedOneToOneDefaultMode = RoomNotificationMode.ALL_MESSAGES,
+ initialOneToOneDefaultMode = RoomNotificationMode.MENTIONS_AND_KEYWORDS_ONLY
+ )
+ val presenter = aNotificationPresenter(notificationSettingsService)
+ moleculeFlow(RecompositionMode.Immediate) {
+ presenter.present()
+ }.test {
+ val initialState = awaitItem()
+ initialState.eventSink(NotificationSettingsEvents.FixConfigurationMismatch)
+ val fixedState = consumeItemsUntilPredicate(timeout = 2000.milliseconds) {
+ it.matrixSettings is NotificationSettingsState.MatrixSettings.Valid
+ }.last()
+
+ val fixedMatrixState = fixedState.matrixSettings as? NotificationSettingsState.MatrixSettings.Valid
+ Truth.assertThat(fixedMatrixState?.defaultGroupNotificationMode).isEqualTo(RoomNotificationMode.ALL_MESSAGES)
+ }
+ }
+
+ @Test
+ fun `present - set notifications enabled`() = runTest {
+ val presenter = aNotificationPresenter()
+ moleculeFlow(RecompositionMode.Immediate) {
+ presenter.present()
+ }.test {
+ val loadedState = consumeItemsUntilPredicate {
+ it.matrixSettings is NotificationSettingsState.MatrixSettings.Valid
+ }.last()
+ Truth.assertThat(loadedState.appSettings.appNotificationsEnabled).isTrue()
+
+ loadedState.eventSink(NotificationSettingsEvents.SetNotificationsEnabled(false))
+ val updatedState = consumeItemsUntilPredicate {
+ !it.appSettings.appNotificationsEnabled
+ }.last()
+ Truth.assertThat(updatedState.appSettings.appNotificationsEnabled).isFalse()
+ cancelAndIgnoreRemainingEvents()
+ }
+ }
+
+ @Test
+ fun `present - set call notifications enabled`() = runTest {
+ val presenter = aNotificationPresenter()
+ moleculeFlow(RecompositionMode.Immediate) {
+ presenter.present()
+ }.test {
+ val loadedState = consumeItemsUntilPredicate {
+ (it.matrixSettings as? NotificationSettingsState.MatrixSettings.Valid)?.callNotificationsEnabled == false
+ }.last()
+ val validMatrixState = loadedState.matrixSettings as? NotificationSettingsState.MatrixSettings.Valid
+ Truth.assertThat(validMatrixState?.callNotificationsEnabled).isFalse()
+
+ loadedState.eventSink(NotificationSettingsEvents.SetCallNotificationsEnabled(true))
+ val updatedState = consumeItemsUntilPredicate {
+ (it.matrixSettings as? NotificationSettingsState.MatrixSettings.Valid)?.callNotificationsEnabled == true
+ }.last()
+ val updatedMatrixState = updatedState.matrixSettings as? NotificationSettingsState.MatrixSettings.Valid
+ Truth.assertThat(updatedMatrixState?.callNotificationsEnabled).isTrue()
+ cancelAndIgnoreRemainingEvents()
+ }
+ }
+
+ @Test
+ fun `present - set atRoom notifications enabled`() = runTest {
+ val presenter = aNotificationPresenter()
+ moleculeFlow(RecompositionMode.Immediate) {
+ presenter.present()
+ }.test {
+ val loadedState = consumeItemsUntilPredicate {
+ (it.matrixSettings as? NotificationSettingsState.MatrixSettings.Valid)?.atRoomNotificationsEnabled == false
+ }.last()
+ val validMatrixState = loadedState.matrixSettings as? NotificationSettingsState.MatrixSettings.Valid
+ Truth.assertThat(validMatrixState?.atRoomNotificationsEnabled).isFalse()
+
+ loadedState.eventSink(NotificationSettingsEvents.SetAtRoomNotificationsEnabled(true))
+ val updatedState = consumeItemsUntilPredicate {
+ (it.matrixSettings as? NotificationSettingsState.MatrixSettings.Valid)?.atRoomNotificationsEnabled == true
+ }.last()
+ val updatedMatrixState = updatedState.matrixSettings as? NotificationSettingsState.MatrixSettings.Valid
+ Truth.assertThat(updatedMatrixState?.atRoomNotificationsEnabled).isTrue()
+ cancelAndIgnoreRemainingEvents()
+ }
+ }
+
+ private fun aNotificationPresenter(
+ notificationSettingsService: FakeNotificationSettingsService = FakeNotificationSettingsService()
+ ) : NotificationSettingsPresenter {
+ val matrixClient = FakeMatrixClient(notificationSettingsService = notificationSettingsService)
+ return NotificationSettingsPresenter(
+ notificationSettingsService = notificationSettingsService,
+ userPushStoreFactory = FakeUserPushStoreFactory(),
+ matrixClient = matrixClient,
+ systemNotificationsEnabledProvider = FakeSystemNotificationsEnabledProvider(),
+ )
+ }
+}
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 9ad5db3d1b..2237f717bd 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
@@ -24,16 +24,23 @@ import io.element.android.features.logout.impl.DefaultLogoutPreferencePresenter
import io.element.android.libraries.architecture.Async
import io.element.android.libraries.core.meta.BuildType
import io.element.android.libraries.designsystem.utils.SnackbarDispatcher
+import io.element.android.libraries.featureflag.test.FakeFeatureFlagService
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_USER_NAME
import io.element.android.libraries.matrix.test.FakeMatrixClient
import io.element.android.libraries.matrix.test.verification.FakeSessionVerificationService
import io.element.android.services.analytics.test.FakeAnalyticsService
+import io.element.android.tests.testutils.WarmUpRule
import kotlinx.coroutines.test.runTest
+import org.junit.Rule
import org.junit.Test
class PreferencesRootPresenterTest {
+
+ @get:Rule
+ val warmUpRule = WarmUpRule()
+
@Test
fun `present - initial state`() = runTest {
val matrixClient = FakeMatrixClient()
@@ -46,6 +53,7 @@ class PreferencesRootPresenterTest {
BuildType.DEBUG,
FakeVersionFormatter(),
SnackbarDispatcher(),
+ FakeFeatureFlagService()
)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
@@ -65,6 +73,7 @@ class PreferencesRootPresenterTest {
assertThat(loadedState.showDeveloperSettings).isEqualTo(true)
assertThat(loadedState.showAnalyticsSettings).isEqualTo(false)
assertThat(loadedState.accountManagementUrl).isNull()
+ assertThat(loadedState.devicesManagementUrl).isNull()
}
}
}
diff --git a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/user/editprofile/EditUserProfilePresenterTest.kt b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/user/editprofile/EditUserProfilePresenterTest.kt
new file mode 100644
index 0000000000..beece60c9a
--- /dev/null
+++ b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/user/editprofile/EditUserProfilePresenterTest.kt
@@ -0,0 +1,429 @@
+/*
+ * Copyright (c) 2023 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.element.android.features.preferences.impl.user.editprofile
+
+import android.net.Uri
+import app.cash.molecule.RecompositionMode
+import app.cash.molecule.moleculeFlow
+import app.cash.turbine.test
+import com.google.common.truth.Truth.assertThat
+import io.element.android.libraries.architecture.Async
+import io.element.android.libraries.matrix.api.MatrixClient
+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_USER_ID
+import io.element.android.libraries.matrix.test.FakeMatrixClient
+import io.element.android.libraries.matrix.ui.components.aMatrixUser
+import io.element.android.libraries.matrix.ui.media.AvatarAction
+import io.element.android.libraries.mediapickers.test.FakePickerProvider
+import io.element.android.libraries.mediaupload.api.MediaUploadInfo
+import io.element.android.libraries.mediaupload.test.FakeMediaPreProcessor
+import io.element.android.tests.testutils.WarmUpRule
+import io.element.android.tests.testutils.consumeItemsUntilPredicate
+import io.mockk.every
+import io.mockk.mockk
+import io.mockk.mockkStatic
+import io.mockk.unmockkAll
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.test.runTest
+import org.junit.After
+import org.junit.Before
+import org.junit.Rule
+import org.junit.Test
+import java.io.File
+
+@ExperimentalCoroutinesApi
+class EditUserProfilePresenterTest {
+
+ @get:Rule
+ val warmUpRule = WarmUpRule()
+
+ private lateinit var fakePickerProvider: FakePickerProvider
+ private lateinit var fakeMediaPreProcessor: FakeMediaPreProcessor
+
+ private val userAvatarUri: Uri = mockk()
+ private val anotherAvatarUri: Uri = mockk()
+
+ private val fakeFileContents = ByteArray(2)
+
+ @Before
+ fun setup() {
+ fakePickerProvider = FakePickerProvider()
+ fakeMediaPreProcessor = FakeMediaPreProcessor()
+ mockkStatic(Uri::class)
+
+ every { Uri.parse(AN_AVATAR_URL) } returns userAvatarUri
+ every { Uri.parse(ANOTHER_AVATAR_URL) } returns anotherAvatarUri
+ }
+
+ @After
+ fun tearDown() {
+ unmockkAll()
+ }
+
+ private fun createEditUserProfilePresenter(
+ matrixClient: MatrixClient = FakeMatrixClient(),
+ matrixUser: MatrixUser = aMatrixUser(),
+ ): EditUserProfilePresenter {
+ return EditUserProfilePresenter(
+ matrixClient = matrixClient,
+ matrixUser = matrixUser,
+ mediaPickerProvider = fakePickerProvider,
+ mediaPreProcessor = fakeMediaPreProcessor,
+ )
+ }
+
+ @Test
+ fun `present - initial state is created from user info`() = runTest {
+ val user = aMatrixUser(avatarUrl = AN_AVATAR_URL)
+ val presenter = createEditUserProfilePresenter(matrixUser = user)
+ moleculeFlow(RecompositionMode.Immediate) {
+ presenter.present()
+ }.test {
+ val initialState = awaitItem()
+ assertThat(initialState.userId).isEqualTo(user.userId)
+ assertThat(initialState.displayName).isEqualTo(user.displayName)
+ assertThat(initialState.userAvatarUrl).isEqualTo(userAvatarUri)
+ assertThat(initialState.avatarActions).containsExactly(
+ AvatarAction.ChoosePhoto,
+ AvatarAction.TakePhoto,
+ AvatarAction.Remove
+ )
+ assertThat(initialState.saveButtonEnabled).isFalse()
+ assertThat(initialState.saveAction).isInstanceOf(Async.Uninitialized::class.java)
+ }
+ }
+
+ @Test
+ fun `present - updates state in response to changes`() = runTest {
+ val user = aMatrixUser(id = A_USER_ID.value, displayName = "Name", avatarUrl = AN_AVATAR_URL)
+ val presenter = createEditUserProfilePresenter(matrixUser = user)
+ moleculeFlow(RecompositionMode.Immediate) {
+ presenter.present()
+ }.test {
+ val initialState = awaitItem()
+ assertThat(initialState.displayName).isEqualTo("Name")
+ assertThat(initialState.userAvatarUrl).isEqualTo(userAvatarUri)
+ initialState.eventSink(EditUserProfileEvents.UpdateDisplayName("Name II"))
+ awaitItem().apply {
+ assertThat(displayName).isEqualTo("Name II")
+ assertThat(userAvatarUrl).isEqualTo(userAvatarUri)
+ }
+ initialState.eventSink(EditUserProfileEvents.UpdateDisplayName("Name III"))
+ awaitItem().apply {
+ assertThat(displayName).isEqualTo("Name III")
+ assertThat(userAvatarUrl).isEqualTo(userAvatarUri)
+ }
+ initialState.eventSink(EditUserProfileEvents.HandleAvatarAction(AvatarAction.Remove))
+ awaitItem().apply {
+ assertThat(displayName).isEqualTo("Name III")
+ assertThat(userAvatarUrl).isNull()
+ }
+ }
+ }
+
+ @Test
+ fun `present - obtains avatar uris from gallery`() = runTest {
+ val user = aMatrixUser(id = A_USER_ID.value, displayName = "Name", avatarUrl = AN_AVATAR_URL)
+ fakePickerProvider.givenResult(anotherAvatarUri)
+ val presenter = createEditUserProfilePresenter(matrixUser = user)
+ moleculeFlow(RecompositionMode.Immediate) {
+ presenter.present()
+ }.test {
+ val initialState = awaitItem()
+ assertThat(initialState.userAvatarUrl).isEqualTo(userAvatarUri)
+ initialState.eventSink(EditUserProfileEvents.HandleAvatarAction(AvatarAction.ChoosePhoto))
+ awaitItem().apply {
+ assertThat(userAvatarUrl).isEqualTo(anotherAvatarUri)
+ }
+ }
+ }
+
+ @Test
+ fun `present - obtains avatar uris from camera`() = runTest {
+ val user = aMatrixUser(id = A_USER_ID.value, displayName = "Name", avatarUrl = AN_AVATAR_URL)
+ fakePickerProvider.givenResult(anotherAvatarUri)
+ val presenter = createEditUserProfilePresenter(matrixUser = user)
+ moleculeFlow(RecompositionMode.Immediate) {
+ presenter.present()
+ }.test {
+ val initialState = awaitItem()
+ assertThat(initialState.userAvatarUrl).isEqualTo(userAvatarUri)
+ initialState.eventSink(EditUserProfileEvents.HandleAvatarAction(AvatarAction.TakePhoto))
+ awaitItem().apply {
+ assertThat(userAvatarUrl).isEqualTo(anotherAvatarUri)
+ }
+ }
+ }
+
+ @Test
+ fun `present - updates save button state`() = runTest {
+ val user = aMatrixUser(id = A_USER_ID.value, displayName = "Name", avatarUrl = AN_AVATAR_URL)
+ fakePickerProvider.givenResult(userAvatarUri)
+ val presenter = createEditUserProfilePresenter(matrixUser = user)
+ moleculeFlow(RecompositionMode.Immediate) {
+ presenter.present()
+ }.test {
+ val initialState = awaitItem()
+ assertThat(initialState.saveButtonEnabled).isFalse()
+ // Once a change is made, the save button is enabled
+ initialState.eventSink(EditUserProfileEvents.UpdateDisplayName("Name II"))
+ awaitItem().apply {
+ assertThat(saveButtonEnabled).isTrue()
+ }
+ // If it's reverted then the save disables again
+ initialState.eventSink(EditUserProfileEvents.UpdateDisplayName("Name"))
+ awaitItem().apply {
+ assertThat(saveButtonEnabled).isFalse()
+ }
+ // Make a change...
+ initialState.eventSink(EditUserProfileEvents.HandleAvatarAction(AvatarAction.Remove))
+ awaitItem().apply {
+ assertThat(saveButtonEnabled).isTrue()
+ }
+ // Revert it...
+ initialState.eventSink(EditUserProfileEvents.HandleAvatarAction(AvatarAction.ChoosePhoto))
+ awaitItem().apply {
+ assertThat(saveButtonEnabled).isFalse()
+ }
+ }
+ }
+
+ @Test
+ fun `present - updates save button state when initial values are null`() = runTest {
+ val user = aMatrixUser(id = A_USER_ID.value, displayName = "Name", avatarUrl = null)
+ fakePickerProvider.givenResult(userAvatarUri)
+ val presenter = createEditUserProfilePresenter(matrixUser = user)
+ moleculeFlow(RecompositionMode.Immediate) {
+ presenter.present()
+ }.test {
+ val initialState = awaitItem()
+ assertThat(initialState.saveButtonEnabled).isFalse()
+ // Once a change is made, the save button is enabled
+ initialState.eventSink(EditUserProfileEvents.UpdateDisplayName("Name II"))
+ awaitItem().apply {
+ assertThat(saveButtonEnabled).isTrue()
+ }
+ // If it's reverted then the save disables again
+ initialState.eventSink(EditUserProfileEvents.UpdateDisplayName("Name"))
+ awaitItem().apply {
+ assertThat(saveButtonEnabled).isFalse()
+ }
+ // Make a change...
+ initialState.eventSink(EditUserProfileEvents.HandleAvatarAction(AvatarAction.ChoosePhoto))
+ awaitItem().apply {
+ assertThat(saveButtonEnabled).isTrue()
+ }
+ // Revert it...
+ initialState.eventSink(EditUserProfileEvents.HandleAvatarAction(AvatarAction.Remove))
+ awaitItem().apply {
+ assertThat(saveButtonEnabled).isFalse()
+ }
+ }
+ }
+
+ @Test
+ fun `present - save changes room details if different`() = runTest {
+ val matrixClient = FakeMatrixClient()
+ val user = aMatrixUser(id = A_USER_ID.value, displayName = "Name", avatarUrl = AN_AVATAR_URL)
+ val presenter = createEditUserProfilePresenter(
+ matrixClient = matrixClient,
+ matrixUser = user
+ )
+ moleculeFlow(RecompositionMode.Immediate) {
+ presenter.present()
+ }.test {
+ val initialState = awaitItem()
+ initialState.eventSink(EditUserProfileEvents.UpdateDisplayName("New name"))
+ initialState.eventSink(EditUserProfileEvents.HandleAvatarAction(AvatarAction.Remove))
+ initialState.eventSink(EditUserProfileEvents.Save)
+ consumeItemsUntilPredicate { matrixClient.setDisplayNameCalled && matrixClient.removeAvatarCalled && !matrixClient.uploadAvatarCalled }
+ assertThat(matrixClient.setDisplayNameCalled).isTrue()
+ assertThat(matrixClient.removeAvatarCalled).isTrue()
+ assertThat(matrixClient.uploadAvatarCalled).isFalse()
+ cancelAndIgnoreRemainingEvents()
+ }
+ }
+
+ @Test
+ fun `present - save does not change room details if they're the same trimmed`() = runTest {
+ val matrixClient = FakeMatrixClient()
+ val user = aMatrixUser(id = A_USER_ID.value, displayName = "Name", avatarUrl = AN_AVATAR_URL)
+ val presenter = createEditUserProfilePresenter(
+ matrixClient = matrixClient,
+ matrixUser = user
+ )
+ moleculeFlow(RecompositionMode.Immediate) {
+ presenter.present()
+ }.test {
+ val initialState = awaitItem()
+ initialState.eventSink(EditUserProfileEvents.UpdateDisplayName(" Name "))
+ initialState.eventSink(EditUserProfileEvents.Save)
+ consumeItemsUntilPredicate { matrixClient.setDisplayNameCalled && !matrixClient.removeAvatarCalled && !matrixClient.uploadAvatarCalled }
+ assertThat(matrixClient.setDisplayNameCalled).isFalse()
+ assertThat(matrixClient.uploadAvatarCalled).isFalse()
+ assertThat(matrixClient.removeAvatarCalled).isFalse()
+ cancelAndIgnoreRemainingEvents()
+ }
+ }
+
+ @Test
+ fun `present - save does not change name if it's now empty`() = runTest {
+ val matrixClient = FakeMatrixClient()
+ val user = aMatrixUser(id = A_USER_ID.value, displayName = "Name", avatarUrl = AN_AVATAR_URL)
+ val presenter = createEditUserProfilePresenter(
+ matrixClient = matrixClient,
+ matrixUser = user
+ )
+ moleculeFlow(RecompositionMode.Immediate) {
+ presenter.present()
+ }.test {
+ val initialState = awaitItem()
+ initialState.eventSink(EditUserProfileEvents.UpdateDisplayName(""))
+ initialState.eventSink(EditUserProfileEvents.Save)
+ assertThat(matrixClient.setDisplayNameCalled).isFalse()
+ assertThat(matrixClient.uploadAvatarCalled).isFalse()
+ assertThat(matrixClient.removeAvatarCalled).isFalse()
+ cancelAndIgnoreRemainingEvents()
+ }
+ }
+
+ @Test
+ fun `present - save processes and sets avatar when processor returns successfully`() = runTest {
+ val matrixClient = FakeMatrixClient()
+ val user = aMatrixUser(id = A_USER_ID.value, displayName = "Name", avatarUrl = AN_AVATAR_URL)
+ givenPickerReturnsFile()
+ val presenter = createEditUserProfilePresenter(
+ matrixClient = matrixClient,
+ matrixUser = user
+ )
+ moleculeFlow(RecompositionMode.Immediate) {
+ presenter.present()
+ }.test {
+ val initialState = awaitItem()
+ initialState.eventSink(EditUserProfileEvents.HandleAvatarAction(AvatarAction.ChoosePhoto))
+ initialState.eventSink(EditUserProfileEvents.Save)
+ consumeItemsUntilPredicate { matrixClient.uploadAvatarCalled }
+ assertThat(matrixClient.uploadAvatarCalled).isTrue()
+ }
+ }
+
+ @Test
+ fun `present - save does not set avatar data if processor fails`() = runTest {
+ val matrixClient = FakeMatrixClient()
+ val user = aMatrixUser(id = A_USER_ID.value, displayName = "Name", avatarUrl = AN_AVATAR_URL)
+ val presenter = createEditUserProfilePresenter(
+ matrixClient = matrixClient,
+ matrixUser = user
+ )
+ fakePickerProvider.givenResult(anotherAvatarUri)
+ fakeMediaPreProcessor.givenResult(Result.failure(Throwable("Oh no")))
+ moleculeFlow(RecompositionMode.Immediate) {
+ presenter.present()
+ }.test {
+ val initialState = awaitItem()
+ initialState.eventSink(EditUserProfileEvents.HandleAvatarAction(AvatarAction.ChoosePhoto))
+ initialState.eventSink(EditUserProfileEvents.Save)
+ skipItems(2)
+ assertThat(matrixClient.uploadAvatarCalled).isFalse()
+ assertThat(awaitItem().saveAction).isInstanceOf(Async.Failure::class.java)
+ }
+ }
+
+ @Test
+ fun `present - sets save action to failure if name update fails`() = runTest {
+ val user = aMatrixUser(id = A_USER_ID.value, displayName = "Name", avatarUrl = AN_AVATAR_URL)
+ val matrixClient = FakeMatrixClient().apply {
+ givenSetDisplayNameResult(Result.failure(Throwable("!")))
+ }
+ saveAndAssertFailure(user, matrixClient, EditUserProfileEvents.UpdateDisplayName("New name"))
+ }
+
+ @Test
+ fun `present - sets save action to failure if removing avatar fails`() = runTest {
+ val user = aMatrixUser(id = A_USER_ID.value, displayName = "Name", avatarUrl = AN_AVATAR_URL)
+ val matrixClient = FakeMatrixClient().apply {
+ givenRemoveAvatarResult(Result.failure(Throwable("!")))
+ }
+ saveAndAssertFailure(user, matrixClient, EditUserProfileEvents.HandleAvatarAction(AvatarAction.Remove))
+ }
+
+ @Test
+ fun `present - sets save action to failure if setting avatar fails`() = runTest {
+ givenPickerReturnsFile()
+ val user = aMatrixUser(id = A_USER_ID.value, displayName = "Name", avatarUrl = AN_AVATAR_URL)
+ val matrixClient = FakeMatrixClient().apply {
+ givenUploadAvatarResult(Result.failure(Throwable("!")))
+ }
+ saveAndAssertFailure(user, matrixClient, EditUserProfileEvents.HandleAvatarAction(AvatarAction.ChoosePhoto))
+ }
+
+ @Test
+ fun `present - CancelSaveChanges resets save action state`() = runTest {
+ givenPickerReturnsFile()
+ val user = aMatrixUser(id = A_USER_ID.value, displayName = "Name", avatarUrl = AN_AVATAR_URL)
+ val matrixClient = FakeMatrixClient().apply {
+ givenSetDisplayNameResult(Result.failure(Throwable("!")))
+ }
+ val presenter = createEditUserProfilePresenter(matrixUser = user, matrixClient = matrixClient)
+ moleculeFlow(RecompositionMode.Immediate) {
+ presenter.present()
+ }.test {
+ val initialState = awaitItem()
+ initialState.eventSink(EditUserProfileEvents.UpdateDisplayName("foo"))
+ initialState.eventSink(EditUserProfileEvents.Save)
+ skipItems(2)
+ assertThat(awaitItem().saveAction).isInstanceOf(Async.Failure::class.java)
+ initialState.eventSink(EditUserProfileEvents.CancelSaveChanges)
+ assertThat(awaitItem().saveAction).isInstanceOf(Async.Uninitialized::class.java)
+ }
+ }
+
+ private suspend fun saveAndAssertFailure(matrixUser: MatrixUser, matrixClient: MatrixClient, event: EditUserProfileEvents) {
+ val presenter = createEditUserProfilePresenter(matrixUser = matrixUser, matrixClient = matrixClient)
+ moleculeFlow(RecompositionMode.Immediate) {
+ presenter.present()
+ }.test {
+ val initialState = awaitItem()
+ initialState.eventSink(event)
+ initialState.eventSink(EditUserProfileEvents.Save)
+ skipItems(1)
+ assertThat(awaitItem().saveAction).isInstanceOf(Async.Loading::class.java)
+ assertThat(awaitItem().saveAction).isInstanceOf(Async.Failure::class.java)
+ }
+ }
+
+ private fun givenPickerReturnsFile() {
+ mockkStatic(File::readBytes)
+ val processedFile: File = mockk {
+ every { readBytes() } returns fakeFileContents
+ }
+ fakePickerProvider.givenResult(anotherAvatarUri)
+ fakeMediaPreProcessor.givenResult(
+ Result.success(
+ MediaUploadInfo.AnyFile(
+ file = processedFile,
+ fileInfo = mockk(),
+ )
+ )
+ )
+ }
+
+ companion object {
+ private const val ANOTHER_AVATAR_URL = "example://camera/foo.jpg"
+ }
+}
diff --git a/features/rageshake/api/src/main/res/values-de/translations.xml b/features/rageshake/api/src/main/res/values-de/translations.xml
index f2446a4028..468ac44491 100644
--- a/features/rageshake/api/src/main/res/values-de/translations.xml
+++ b/features/rageshake/api/src/main/res/values-de/translations.xml
@@ -1,5 +1,5 @@
- "%1$s ist bei der letzten Verwendung abgestürzt. Möchtest du uns einen Absturzbericht senden?"
- "Du scheinst frustriert das Telefon zu schütteln. Möchtest du den Fehlerberichtsbildschirm öffnen?"
+ "%1$s ist bei der letzten Nutzung abgestürzt. Möchtest du einen Absturzbericht mit uns teilen?"
+ "Du scheinst das Telefon aus Frustration zu schütteln. Möchtest du den Bildschirm für den Fehlerbericht öffnen?"
diff --git a/features/rageshake/api/src/main/res/values-fr/translations.xml b/features/rageshake/api/src/main/res/values-fr/translations.xml
index 455ab1daef..5c3571e443 100644
--- a/features/rageshake/api/src/main/res/values-fr/translations.xml
+++ b/features/rageshake/api/src/main/res/values-fr/translations.xml
@@ -1,5 +1,5 @@
- "%1$s a planté la dernière fois qu\'il a été utilisé. Souhaitez-vous partager un rapport de crash avec nous ?"
- "Vous semblez secouer le téléphone de frustration. Voulez-vous ouvrir le formulaire de rapport de problème ?"
+ "%1$s s’est arrêté la dernière fois qu’il a été utilisé. Souhaitez-vous partager un rapport d’incident avec nous ?"
+ "Vous semblez secouer le téléphone avec frustration. Voulez-vous ouvrir le formulaire de rapport de problème ?"
diff --git a/features/rageshake/impl/build.gradle.kts b/features/rageshake/impl/build.gradle.kts
index 464d521689..eced5c78b8 100644
--- a/features/rageshake/impl/build.gradle.kts
+++ b/features/rageshake/impl/build.gradle.kts
@@ -57,4 +57,5 @@ dependencies {
testImplementation(libs.test.mockk)
testImplementation(projects.libraries.matrix.test)
testImplementation(projects.features.rageshake.test)
+ testImplementation(projects.tests.testutils)
}
diff --git a/features/rageshake/impl/src/main/res/values-de/translations.xml b/features/rageshake/impl/src/main/res/values-de/translations.xml
index 51b331a86f..63564615b8 100644
--- a/features/rageshake/impl/src/main/res/values-de/translations.xml
+++ b/features/rageshake/impl/src/main/res/values-de/translations.xml
@@ -1,15 +1,15 @@
"Bildschirmfoto anhängen"
- "Ihr könnt mich kontaktieren, wenn ihr weitere Fragen habt"
- "Kontaktiere mich"
+ "Sie können mich kontaktieren, wenn Sie weitere Fragen haben."
+ "Kontaktieren Sie mich"
"Bildschirmfoto bearbeiten"
- "Beschreibe bitte den Fehler. Was hast du gemacht? Was hätte passieren sollen? Was ist passiert? Bitte beschreibe alles mit so vielen Details wie möglich."
+ "Bitte beschreibe den Fehler. Was hast du getan? Was hast du erwartet, was passiert? Was ist tatsächlich passiert. Bitte gehe so detailliert wie möglich vor."
"Beschreibe den Fehler…"
"Wenn möglich, verfasse die Beschreibung bitte auf Englisch."
"Absturzprotokolle senden"
- "Logs zulassen"
+ "Protokolle zulassen"
"Bildschirmfoto senden"
- "Deiner Nachricht werden Protokolle beigefügt, um sicherzustellen, dass alles ordnungsgemäß funktioniert. Um deine Nachricht ohne Logs zu senden, deaktiviere diese Einstellung."
- "%1$s ist bei der letzten Verwendung abgestürzt. Möchtest du uns einen Absturzbericht senden?"
+ "Die Protokolle werden Ihrer Nachricht beigefügt, um sicherzustellen, dass alles ordnungsgemäß funktioniert. Um deine Nachricht ohne Protokolle zu senden, deaktiviere diese Einstellung."
+ "%1$s ist bei der letzten Nutzung abgestürzt. Möchtest du einen Absturzbericht mit uns teilen?"
diff --git a/features/rageshake/impl/src/main/res/values-fr/translations.xml b/features/rageshake/impl/src/main/res/values-fr/translations.xml
index 53b95af6be..ed2d9f7e96 100644
--- a/features/rageshake/impl/src/main/res/values-fr/translations.xml
+++ b/features/rageshake/impl/src/main/res/values-fr/translations.xml
@@ -1,15 +1,15 @@
- "Joindre une capture d\'écran"
+ "Joindre une capture d’écran"
"Vous pouvez me contacter si vous avez des questions complémentaires."
- "Me contacter"
- "Modifier la capture d\'écran"
- "S\'il vous plait, veuillez décrire le bogue. Qu\'avez-vous fait ? À quoi vous attendiez-vous ? Que s\'est-il réellement passé. Veuillez ajouter le plus de détails possible."
- "Décrire le bogue"
+ "Contactez-moi"
+ "Modifier la capture d’écran"
+ "S’il vous plait, veuillez décrire le problème. Qu’avez-vous fait ? À quoi vous attendiez-vous ? Que s’est-il réellement passé ? Veuillez ajouter le plus de détails possible."
+ "Décrire le problème"
"Si possible, veuillez rédiger la description en anglais."
"Envoyer des journaux d’incident"
"Autoriser à inclure les journaux techniques"
"Envoyer une capture d’écran"
- "Pour vérifier que les choses fonctionnent comme prévu, des journaux techniques seront envoyés avec votre message. Pour l’envoyer sans ces journaux, désactivez ce paramètre."
- "%1$s a planté la dernière fois qu\'il a été utilisé. Souhaitez-vous partager un rapport de crash avec nous ?"
+ "Pour vérifier que les choses fonctionnent comme prévu, des journaux techniques seront envoyés avec votre message. Pour ne pas envoyer ces journaux, désactivez ce paramètre."
+ "%1$s s’est arrêté la dernière fois qu’il a été utilisé. Souhaitez-vous partager un rapport d’incident avec nous ?"
diff --git a/features/rageshake/impl/src/main/res/values-ro/translations.xml b/features/rageshake/impl/src/main/res/values-ro/translations.xml
index db0398c0db..e82c06826e 100644
--- a/features/rageshake/impl/src/main/res/values-ro/translations.xml
+++ b/features/rageshake/impl/src/main/res/values-ro/translations.xml
@@ -2,12 +2,13 @@
"Atașați o captură de ecran"
"Puteți să mă contactați dacă aveți întrebări suplimentare"
+ "Contactați-mă"
"Editați captura de ecran"
"Vă rugăm să descrieți eroarea. Ce ați făcut? Ce vă aşteptați să se întâmple? Ce s-a întâmplat de fapt. Vă rugam să intrați în cât mai multe detalii cu putință."
"Descrieți eroarea…"
"Dacă posibil, vă rugăm să scrieți descrierea în engleză."
"Trimiteți log-uri"
- "Trimiteți log-uri pentru a ajuta"
+ "Permiteți log-uri"
"Trimiteți captură de ecran"
"Pentru a verifica că lucrurile funcționează conform așteptărilor, log-uri vor fi trimise împreună cu mesajul. Acestea vor fi private. Pentru a trimite doar mesajul, dezactivați această setare."
"%1$s s-a blocat ultima dată când a fost folosit. Doriți să ne trimiteți un raport?"
diff --git a/features/rageshake/impl/src/test/kotlin/io/element/android/features/rageshake/impl/bugreport/BugReportPresenterTest.kt b/features/rageshake/impl/src/test/kotlin/io/element/android/features/rageshake/impl/bugreport/BugReportPresenterTest.kt
index c0418783dd..3360ce3dd3 100644
--- a/features/rageshake/impl/src/test/kotlin/io/element/android/features/rageshake/impl/bugreport/BugReportPresenterTest.kt
+++ b/features/rageshake/impl/src/test/kotlin/io/element/android/features/rageshake/impl/bugreport/BugReportPresenterTest.kt
@@ -26,13 +26,19 @@ import io.element.android.features.rageshake.test.screenshot.A_SCREENSHOT_URI
import io.element.android.features.rageshake.test.screenshot.FakeScreenshotHolder
import io.element.android.libraries.architecture.Async
import io.element.android.libraries.matrix.test.A_FAILURE_REASON
+import io.element.android.tests.testutils.WarmUpRule
import kotlinx.coroutines.test.runTest
+import org.junit.Rule
import org.junit.Test
const val A_SHORT_DESCRIPTION = "bug!"
const val A_LONG_DESCRIPTION = "I have seen a bug!"
class BugReportPresenterTest {
+
+ @get:Rule
+ val warmUpRule = WarmUpRule()
+
@Test
fun `present - initial state`() = runTest {
val presenter = BugReportPresenter(
diff --git a/features/rageshake/impl/src/test/kotlin/io/element/android/features/rageshake/impl/crash/ui/CrashDetectionPresenterTest.kt b/features/rageshake/impl/src/test/kotlin/io/element/android/features/rageshake/impl/crash/ui/CrashDetectionPresenterTest.kt
index b8b8c4b6d0..716f424ce3 100644
--- a/features/rageshake/impl/src/test/kotlin/io/element/android/features/rageshake/impl/crash/ui/CrashDetectionPresenterTest.kt
+++ b/features/rageshake/impl/src/test/kotlin/io/element/android/features/rageshake/impl/crash/ui/CrashDetectionPresenterTest.kt
@@ -24,10 +24,16 @@ import io.element.android.features.rageshake.api.crash.CrashDetectionEvents
import io.element.android.features.rageshake.impl.crash.DefaultCrashDetectionPresenter
import io.element.android.features.rageshake.test.crash.A_CRASH_DATA
import io.element.android.features.rageshake.test.crash.FakeCrashDataStore
+import io.element.android.tests.testutils.WarmUpRule
import kotlinx.coroutines.test.runTest
+import org.junit.Rule
import org.junit.Test
class CrashDetectionPresenterTest {
+
+ @get:Rule
+ val warmUpRule = WarmUpRule()
+
@Test
fun `present - initial state no crash`() = runTest {
val presenter = DefaultCrashDetectionPresenter(
diff --git a/features/rageshake/impl/src/test/kotlin/io/element/android/features/rageshake/impl/detection/RageshakeDetectionPresenterTest.kt b/features/rageshake/impl/src/test/kotlin/io/element/android/features/rageshake/impl/detection/RageshakeDetectionPresenterTest.kt
index 02a0fc0794..a8a80fdc25 100644
--- a/features/rageshake/impl/src/test/kotlin/io/element/android/features/rageshake/impl/detection/RageshakeDetectionPresenterTest.kt
+++ b/features/rageshake/impl/src/test/kotlin/io/element/android/features/rageshake/impl/detection/RageshakeDetectionPresenterTest.kt
@@ -28,14 +28,19 @@ import io.element.android.features.rageshake.test.rageshake.FakeRageShake
import io.element.android.features.rageshake.test.rageshake.FakeRageshakeDataStore
import io.element.android.features.rageshake.test.screenshot.FakeScreenshotHolder
import io.element.android.libraries.matrix.test.AN_EXCEPTION
+import io.element.android.tests.testutils.WarmUpRule
import io.mockk.mockk
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.runTest
import org.junit.BeforeClass
+import org.junit.Rule
import org.junit.Test
class RageshakeDetectionPresenterTest {
+ @get:Rule
+ val warmUpRule = WarmUpRule()
+
companion object {
private lateinit var aBitmap: Bitmap
diff --git a/features/rageshake/impl/src/test/kotlin/io/element/android/features/rageshake/impl/preferences/RageshakePreferencesPresenterTest.kt b/features/rageshake/impl/src/test/kotlin/io/element/android/features/rageshake/impl/preferences/RageshakePreferencesPresenterTest.kt
index 56759c360c..c639adb98f 100644
--- a/features/rageshake/impl/src/test/kotlin/io/element/android/features/rageshake/impl/preferences/RageshakePreferencesPresenterTest.kt
+++ b/features/rageshake/impl/src/test/kotlin/io/element/android/features/rageshake/impl/preferences/RageshakePreferencesPresenterTest.kt
@@ -24,10 +24,16 @@ import io.element.android.features.rageshake.api.preferences.RageshakePreference
import io.element.android.features.rageshake.test.rageshake.A_SENSITIVITY
import io.element.android.features.rageshake.test.rageshake.FakeRageShake
import io.element.android.features.rageshake.test.rageshake.FakeRageshakeDataStore
+import io.element.android.tests.testutils.WarmUpRule
import kotlinx.coroutines.test.runTest
+import org.junit.Rule
import org.junit.Test
class RageshakePreferencesPresenterTest {
+
+ @get:Rule
+ val warmUpRule = WarmUpRule()
+
@Test
fun `present - initial state available`() = runTest {
val presenter = DefaultRageshakePreferencesPresenter(
diff --git a/features/roomdetails/impl/build.gradle.kts b/features/roomdetails/impl/build.gradle.kts
index 0965cf484a..2137b1401d 100644
--- a/features/roomdetails/impl/build.gradle.kts
+++ b/features/roomdetails/impl/build.gradle.kts
@@ -42,6 +42,7 @@ dependencies {
implementation(projects.libraries.androidutils)
implementation(projects.libraries.mediapickers.api)
implementation(projects.libraries.mediaupload.api)
+ implementation(projects.libraries.featureflag.api)
api(projects.features.roomdetails.api)
api(projects.libraries.usersearch.api)
api(projects.services.apperror.api)
@@ -59,6 +60,7 @@ dependencies {
testImplementation(projects.libraries.mediaupload.test)
testImplementation(projects.libraries.mediapickers.test)
testImplementation(projects.libraries.usersearch.test)
+ testImplementation(projects.libraries.featureflag.test)
testImplementation(projects.tests.testutils)
testImplementation(projects.features.leaveroom.fake)
diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsEvent.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsEvent.kt
index 7b18d398df..fdad01d83c 100644
--- a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsEvent.kt
+++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsEvent.kt
@@ -18,4 +18,6 @@ package io.element.android.features.roomdetails.impl
sealed interface RoomDetailsEvent {
data object LeaveRoom : RoomDetailsEvent
+ data object MuteNotification : RoomDetailsEvent
+ data object UnmuteNotification : RoomDetailsEvent
}
diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsFlowNode.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsFlowNode.kt
index b456df9f02..675ef7de60 100644
--- a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsFlowNode.kt
+++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsFlowNode.kt
@@ -33,6 +33,7 @@ import io.element.android.features.roomdetails.impl.edit.RoomDetailsEditNode
import io.element.android.features.roomdetails.impl.invite.RoomInviteMembersNode
import io.element.android.features.roomdetails.impl.members.RoomMemberListNode
import io.element.android.features.roomdetails.impl.members.details.RoomMemberDetailsNode
+import io.element.android.features.roomdetails.impl.notificationsettings.RoomNotificationSettingsNode
import io.element.android.libraries.architecture.BackstackNode
import io.element.android.libraries.architecture.animation.rememberDefaultTransitionHandler
import io.element.android.libraries.architecture.createNode
@@ -66,6 +67,9 @@ class RoomDetailsFlowNode @AssistedInject constructor(
@Parcelize
data object InviteMembers : NavTarget
+ @Parcelize
+ object RoomNotificationSettings : NavTarget
+
@Parcelize
data class RoomMemberDetails(val roomMemberId: UserId) : NavTarget
}
@@ -85,6 +89,10 @@ class RoomDetailsFlowNode @AssistedInject constructor(
override fun openInviteMembers() {
backstack.push(NavTarget.InviteMembers)
}
+
+ override fun openRoomNotificationSettings() {
+ backstack.push(NavTarget.RoomNotificationSettings)
+ }
}
createNode(buildContext, listOf(roomDetailsCallback))
}
@@ -110,6 +118,10 @@ class RoomDetailsFlowNode @AssistedInject constructor(
createNode(buildContext)
}
+ NavTarget.RoomNotificationSettings -> {
+ createNode(buildContext)
+ }
+
is NavTarget.RoomMemberDetails -> {
val plugins = listOf(RoomMemberDetailsNode.RoomMemberDetailsInput(navTarget.roomMemberId))
createNode(buildContext, plugins)
diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsNode.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsNode.kt
index b74cf7aaf1..65c2ed0f89 100644
--- a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsNode.kt
+++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsNode.kt
@@ -51,6 +51,7 @@ class RoomDetailsNode @AssistedInject constructor(
fun openRoomMemberList()
fun openInviteMembers()
fun editRoomDetails()
+ fun openRoomNotificationSettings()
}
private val callbacks = plugins()
@@ -67,6 +68,10 @@ class RoomDetailsNode @AssistedInject constructor(
callbacks.forEach { it.openRoomMemberList() }
}
+ private fun openRoomNotificationSettings() {
+ callbacks.forEach { it.openRoomNotificationSettings() }
+ }
+
private fun invitePeople() {
callbacks.forEach { it.openInviteMembers() }
}
@@ -133,6 +138,7 @@ class RoomDetailsNode @AssistedInject constructor(
onShareRoom = ::onShareRoom,
onShareMember = ::onShareMember,
openRoomMemberList = ::openRoomMemberList,
+ openRoomNotificationSettings = ::openRoomNotificationSettings,
invitePeople = ::invitePeople,
)
}
diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsPresenter.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsPresenter.kt
index 2612c24365..34a0b2b2e1 100644
--- a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsPresenter.kt
+++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsPresenter.kt
@@ -22,31 +22,55 @@ import androidx.compose.runtime.State
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 io.element.android.features.leaveroom.api.LeaveRoomEvent
import io.element.android.features.leaveroom.api.LeaveRoomPresenter
import io.element.android.features.roomdetails.impl.members.details.RoomMemberDetailsPresenter
import io.element.android.libraries.architecture.Presenter
+import io.element.android.libraries.core.coroutine.CoroutineDispatchers
+import io.element.android.libraries.featureflag.api.FeatureFlagService
+import io.element.android.libraries.featureflag.api.FeatureFlags
+import io.element.android.libraries.matrix.api.MatrixClient
+import io.element.android.libraries.matrix.api.notificationsettings.NotificationSettingsService
import io.element.android.libraries.matrix.api.room.MatrixRoom
import io.element.android.libraries.matrix.api.room.MatrixRoomMembersState
import io.element.android.libraries.matrix.api.room.RoomMember
import io.element.android.libraries.matrix.api.room.StateEventType
import io.element.android.libraries.matrix.api.room.powerlevels.canInvite
import io.element.android.libraries.matrix.api.room.powerlevels.canSendState
+import io.element.android.libraries.matrix.api.room.roomNotificationSettings
import io.element.android.libraries.matrix.ui.room.getDirectRoomMember
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.flow.launchIn
+import kotlinx.coroutines.flow.onEach
+import kotlinx.coroutines.launch
import javax.inject.Inject
class RoomDetailsPresenter @Inject constructor(
+ private val client: MatrixClient,
private val room: MatrixRoom,
+ private val featureFlagService: FeatureFlagService,
+ private val notificationSettingsService: NotificationSettingsService,
private val roomMembersDetailsPresenterFactory: RoomMemberDetailsPresenter.Factory,
private val leaveRoomPresenter: LeaveRoomPresenter,
+ private val dispatchers: CoroutineDispatchers,
) : Presenter {
@Composable
override fun present(): RoomDetailsState {
+ val scope = rememberCoroutineScope()
val leaveRoomState = leaveRoomPresenter.present()
+ val canShowNotificationSettings = remember { mutableStateOf(false) }
+
LaunchedEffect(Unit) {
+ canShowNotificationSettings.value = featureFlagService.isFeatureEnabled(FeatureFlags.NotificationSettings)
+ if (canShowNotificationSettings.value) {
+ room.updateRoomNotificationSettings()
+ observeNotificationSettings()
+ }
room.updateMembers()
}
@@ -69,10 +93,22 @@ class RoomDetailsPresenter @Inject constructor(
}
}
+ val roomNotificationSettingsState by room.roomNotificationSettingsStateFlow.collectAsState()
+
fun handleEvents(event: RoomDetailsEvent) {
when (event) {
- is RoomDetailsEvent.LeaveRoom ->
+ RoomDetailsEvent.LeaveRoom ->
leaveRoomState.eventSink(LeaveRoomEvent.ShowConfirmation(room.roomId))
+ RoomDetailsEvent.MuteNotification -> {
+ scope.launch(dispatchers.io) {
+ client.notificationSettingsService().muteRoom(room.roomId)
+ }
+ }
+ RoomDetailsEvent.UnmuteNotification -> {
+ scope.launch(dispatchers.io) {
+ client.notificationSettingsService().unmuteRoom(room.roomId, room.isEncrypted, room.isOneToOne)
+ }
+ }
}
}
@@ -88,9 +124,11 @@ class RoomDetailsPresenter @Inject constructor(
isEncrypted = room.isEncrypted,
canInvite = canInvite,
canEdit = (canEditAvatar || canEditName || canEditTopic) && roomType == RoomDetailsType.Room,
+ canShowNotificationSettings = canShowNotificationSettings.value,
roomType = roomType,
roomMemberDetailsState = roomMemberDetailsState,
leaveRoomState = leaveRoomState,
+ roomNotificationSettings = roomNotificationSettingsState.roomNotificationSettings(),
eventSink = ::handleEvents,
)
}
@@ -122,4 +160,10 @@ class RoomDetailsPresenter @Inject constructor(
private fun getCanSendState(membersState: MatrixRoomMembersState, type: StateEventType) = produceState(false, membersState) {
value = room.canSendState(type).getOrElse { false }
}
+
+ private fun CoroutineScope.observeNotificationSettings() {
+ notificationSettingsService.notificationSettingsChangeFlow.onEach {
+ room.updateRoomNotificationSettings()
+ }.launchIn(this)
+ }
}
diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsState.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsState.kt
index 49aa7f6ce0..8dc6f81bf1 100644
--- a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsState.kt
+++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsState.kt
@@ -19,6 +19,7 @@ package io.element.android.features.roomdetails.impl
import io.element.android.features.leaveroom.api.LeaveRoomState
import io.element.android.features.roomdetails.impl.members.details.RoomMemberDetailsState
import io.element.android.libraries.matrix.api.room.RoomMember
+import io.element.android.libraries.matrix.api.room.RoomNotificationSettings
data class RoomDetailsState(
val roomId: String,
@@ -32,7 +33,9 @@ data class RoomDetailsState(
val roomMemberDetailsState: RoomMemberDetailsState?,
val canEdit: Boolean,
val canInvite: Boolean,
+ val canShowNotificationSettings: Boolean,
val leaveRoomState: LeaveRoomState,
+ val roomNotificationSettings: RoomNotificationSettings?,
val eventSink: (RoomDetailsEvent) -> Unit
)
diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsStateProvider.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsStateProvider.kt
index cc4c4a6b1b..c580e6d677 100644
--- a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsStateProvider.kt
+++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsStateProvider.kt
@@ -22,6 +22,8 @@ import io.element.android.features.roomdetails.impl.members.details.aRoomMemberD
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.room.RoomMember
import io.element.android.libraries.matrix.api.room.RoomMembershipState
+import io.element.android.libraries.matrix.api.room.RoomNotificationMode
+import io.element.android.libraries.matrix.api.room.RoomNotificationSettings
open class RoomDetailsStateProvider : PreviewParameterProvider {
override val values: Sequence
@@ -75,9 +77,11 @@ fun aRoomDetailsState() = RoomDetailsState(
isEncrypted = true,
canInvite = false,
canEdit = false,
+ canShowNotificationSettings = true,
roomType = RoomDetailsType.Room,
roomMemberDetailsState = null,
leaveRoomState = LeaveRoomState(),
+ roomNotificationSettings = RoomNotificationSettings(mode = RoomNotificationMode.MUTE, isDefault = false),
eventSink = {}
)
diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsView.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsView.kt
index 51754ca6de..19aeb7ba0f 100644
--- a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsView.kt
+++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsView.kt
@@ -16,6 +16,7 @@
package io.element.android.features.roomdetails.impl
+import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
@@ -26,12 +27,15 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material.icons.outlined.Add
import androidx.compose.material.icons.outlined.Lock
+import androidx.compose.material.icons.outlined.Notifications
+import androidx.compose.material.icons.outlined.NotificationsOff
import androidx.compose.material.icons.outlined.Person
import androidx.compose.material.icons.outlined.PersonAddAlt
import androidx.compose.material.icons.outlined.Share
@@ -55,6 +59,7 @@ import io.element.android.features.roomdetails.impl.blockuser.BlockUserDialogs
import io.element.android.features.roomdetails.impl.blockuser.BlockUserSection
import io.element.android.features.roomdetails.impl.members.details.RoomMemberHeaderSection
import io.element.android.features.roomdetails.impl.members.details.RoomMemberMainActionsSection
+import io.element.android.libraries.designsystem.components.ClickableLinkText
import io.element.android.libraries.designsystem.components.avatar.Avatar
import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
@@ -73,6 +78,7 @@ import io.element.android.libraries.designsystem.theme.components.Scaffold
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.theme.components.TopAppBar
import io.element.android.libraries.matrix.api.room.RoomMember
+import io.element.android.libraries.matrix.api.room.RoomNotificationMode
import io.element.android.libraries.theme.ElementTheme
import io.element.android.libraries.ui.strings.CommonStrings
@@ -85,6 +91,7 @@ fun RoomDetailsView(
onShareRoom: () -> Unit,
onShareMember: (RoomMember) -> Unit,
openRoomMemberList: () -> Unit,
+ openRoomNotificationSettings: () -> Unit,
invitePeople: () -> Unit,
modifier: Modifier = Modifier,
) {
@@ -118,7 +125,10 @@ fun RoomDetailsView(
roomName = state.roomName,
roomAlias = state.roomAlias
)
- MainActionsSection(onShareRoom = onShareRoom)
+ MainActionsSection(
+ state = state,
+ onShareRoom = onShareRoom
+ )
}
is RoomDetailsType.Dm -> {
@@ -140,6 +150,12 @@ fun RoomDetailsView(
)
}
+ if (state.canShowNotificationSettings && state.roomNotificationSettings != null) {
+ NotificationSection(
+ isDefaultMode = state.roomNotificationSettings.isDefault,
+ openRoomNotificationSettings = openRoomNotificationSettings)
+ }
+
if (state.roomType is RoomDetailsType.Room) {
MembersSection(
memberCount = state.memberCount,
@@ -209,8 +225,21 @@ internal fun RoomDetailsTopBar(
}
@Composable
-internal fun MainActionsSection(onShareRoom: () -> Unit, modifier: Modifier = Modifier) {
+internal fun MainActionsSection(state: RoomDetailsState, onShareRoom: () -> Unit, modifier: Modifier = Modifier) {
Row(modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Center) {
+ val roomNotificationSettings = state.roomNotificationSettings
+ if (state.canShowNotificationSettings && roomNotificationSettings != null) {
+ if (roomNotificationSettings.mode == RoomNotificationMode.MUTE) {
+ MainActionButton(title = stringResource(CommonStrings.common_unmute), icon = Icons.Outlined.NotificationsOff, onClick = {
+ state.eventSink(RoomDetailsEvent.UnmuteNotification)
+ })
+ } else {
+ MainActionButton(title = stringResource(CommonStrings.common_mute), icon = Icons.Outlined.Notifications, onClick = {
+ state.eventSink(RoomDetailsEvent.MuteNotification)
+ })
+ }
+ }
+ Spacer(modifier = Modifier.width(20.dp))
MainActionButton(title = stringResource(R.string.screen_room_details_share_room_title), icon = Icons.Outlined.Share, onClick = onShareRoom)
}
}
@@ -266,16 +295,39 @@ internal fun TopicSection(
onClick = { onActionClicked(RoomDetailsAction.AddTopic) },
)
} else if (roomTopic is RoomTopicState.ExistingTopic) {
- Text(
- roomTopic.topic,
+ ClickableLinkText(
+ text = roomTopic.topic,
modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 8.dp, bottom = 12.dp),
- style = MaterialTheme.typography.bodyMedium,
- color = MaterialTheme.colorScheme.tertiary
+ interactionSource = remember { MutableInteractionSource() },
+ style = MaterialTheme.typography.bodyMedium.copy(
+ color = MaterialTheme.colorScheme.tertiary,
+ ),
)
}
}
}
+@Composable
+internal fun NotificationSection(
+ isDefaultMode: Boolean,
+ openRoomNotificationSettings: () -> Unit,
+ modifier: Modifier = Modifier
+) {
+ val subtitle = if (isDefaultMode) {
+ stringResource(R.string.screen_room_details_notification_mode_default)
+ } else {
+ stringResource(R.string.screen_room_details_notification_mode_custom)
+ }
+ PreferenceCategory(modifier = modifier) {
+ PreferenceText(
+ title = stringResource(R.string.screen_room_details_notification_title),
+ subtitle = subtitle,
+ icon = Icons.Outlined.Notifications,
+ onClick = openRoomNotificationSettings,
+ )
+ }
+}
+
@Composable
internal fun MembersSection(
memberCount: Long,
@@ -348,6 +400,7 @@ private fun ContentToPreview(state: RoomDetailsState) {
onShareRoom = {},
onShareMember = {},
openRoomMemberList = {},
+ openRoomNotificationSettings = {},
invitePeople = {},
)
}
diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/edit/RoomDetailsEditView.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/edit/RoomDetailsEditView.kt
index cd0cbf878e..ac9853a387 100644
--- a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/edit/RoomDetailsEditView.kt
+++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/edit/RoomDetailsEditView.kt
@@ -18,37 +18,27 @@
package io.element.android.features.roomdetails.impl.edit
-import androidx.compose.foundation.background
-import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.layout.Arrangement
-import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
-import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
-import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
-import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.ExperimentalMaterialApi
import androidx.compose.material.ModalBottomSheetValue
-import androidx.compose.material.icons.Icons
-import androidx.compose.material.icons.outlined.AddAPhoto
import androidx.compose.material.rememberModalBottomSheetState
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
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.focus.FocusManager
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalFocusManager
@@ -61,21 +51,18 @@ import io.element.android.features.roomdetails.impl.R
import io.element.android.libraries.architecture.Async
import io.element.android.libraries.designsystem.components.LabelledTextField
import io.element.android.libraries.designsystem.components.ProgressDialog
-import io.element.android.libraries.designsystem.components.avatar.Avatar
-import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.designsystem.components.button.BackButton
import io.element.android.libraries.designsystem.components.dialogs.ErrorDialog
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
import io.element.android.libraries.designsystem.theme.aliasScreenTitle
-import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.theme.components.Scaffold
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.theme.components.TextButton
import io.element.android.libraries.designsystem.theme.components.TopAppBar
import io.element.android.libraries.matrix.ui.components.AvatarActionBottomSheet
-import io.element.android.libraries.matrix.ui.components.UnsavedAvatar
+import io.element.android.libraries.matrix.ui.components.EditableAvatarView
import io.element.android.libraries.theme.ElementTheme
import io.element.android.libraries.ui.strings.CommonStrings
import kotlinx.coroutines.launch
@@ -134,7 +121,14 @@ fun RoomDetailsEditView(
.verticalScroll(rememberScrollState())
) {
Spacer(modifier = Modifier.height(24.dp))
- EditableAvatarView(state, ::onAvatarClicked)
+ EditableAvatarView(
+ userId = state.roomId,
+ displayName = state.roomName,
+ avatarUrl = state.roomAvatarUrl,
+ avatarSize = AvatarSize.EditRoomDetails,
+ onAvatarClicked = ::onAvatarClicked,
+ modifier = Modifier.fillMaxWidth(),
+ )
Spacer(modifier = Modifier.height(60.dp))
if (state.canChangeName) {
@@ -202,56 +196,6 @@ fun RoomDetailsEditView(
}
}
-@Composable
-private fun EditableAvatarView(
- state: RoomDetailsEditState,
- onAvatarClicked: () -> Unit,
- modifier: Modifier = Modifier,
-) {
- Column(modifier = modifier.fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally) {
- Box(
- modifier = Modifier
- .size(70.dp)
- .clickable(onClick = onAvatarClicked, enabled = state.canChangeAvatar)
- ) {
- // TODO this might be able to be simplified into a single component once send/receive media is done
- when (state.roomAvatarUrl?.scheme) {
- null, "mxc" -> {
- Avatar(
- avatarData = AvatarData(state.roomId, state.roomName, state.roomAvatarUrl?.toString(), size = AvatarSize.RoomHeader),
- modifier = Modifier.fillMaxSize(),
- )
- }
-
- else -> {
- UnsavedAvatar(
- avatarUri = state.roomAvatarUrl,
- modifier = Modifier.fillMaxSize(),
- )
- }
- }
-
- if (state.canChangeAvatar) {
- Box(
- modifier = Modifier
- .align(Alignment.BottomEnd)
- .clip(CircleShape)
- .background(MaterialTheme.colorScheme.primary)
- .size(24.dp),
- contentAlignment = Alignment.Center,
- ) {
- Icon(
- modifier = Modifier.size(16.dp),
- imageVector = Icons.Outlined.AddAPhoto,
- contentDescription = "",
- tint = MaterialTheme.colorScheme.onPrimary,
- )
- }
- }
- }
- }
-}
-
@Composable
private fun LabelledReadOnlyField(
title: String,
diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/notificationsettings/RoomNotificationSettingsEvents.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/notificationsettings/RoomNotificationSettingsEvents.kt
new file mode 100644
index 0000000000..bbe756b154
--- /dev/null
+++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/notificationsettings/RoomNotificationSettingsEvents.kt
@@ -0,0 +1,24 @@
+/*
+ * Copyright (c) 2023 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.element.android.features.roomdetails.impl.notificationsettings
+
+import io.element.android.libraries.matrix.api.room.RoomNotificationMode
+
+sealed interface RoomNotificationSettingsEvents {
+ data class RoomNotificationModeChanged(val mode: RoomNotificationMode) : RoomNotificationSettingsEvents
+ data class SetNotificationMode(val isDefault: Boolean): RoomNotificationSettingsEvents
+}
diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/notificationsettings/RoomNotificationSettingsItem.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/notificationsettings/RoomNotificationSettingsItem.kt
new file mode 100644
index 0000000000..182944c70e
--- /dev/null
+++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/notificationsettings/RoomNotificationSettingsItem.kt
@@ -0,0 +1,52 @@
+/*
+ * Copyright (c) 2023 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.element.android.features.roomdetails.impl.notificationsettings
+
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.res.stringResource
+import io.element.android.features.roomdetails.impl.R
+import io.element.android.libraries.matrix.api.room.RoomNotificationMode
+import io.element.android.libraries.ui.strings.CommonStrings
+import kotlinx.collections.immutable.ImmutableList
+import kotlinx.collections.immutable.toImmutableList
+
+data class RoomNotificationSettingsItem(
+ val mode: RoomNotificationMode,
+ val title: String,
+)
+
+@Composable
+fun roomNotificationSettingsItems(): ImmutableList {
+ return RoomNotificationMode.values()
+ .map {
+ when (it) {
+ RoomNotificationMode.ALL_MESSAGES -> RoomNotificationSettingsItem(
+ mode = it,
+ title = stringResource(R.string.screen_room_notification_settings_mode_all_messages),
+ )
+ RoomNotificationMode.MENTIONS_AND_KEYWORDS_ONLY -> RoomNotificationSettingsItem(
+ mode = it,
+ title = stringResource(R.string.screen_room_notification_settings_mode_mentions_and_keywords),
+ )
+ RoomNotificationMode.MUTE -> RoomNotificationSettingsItem(
+ mode = it,
+ title = stringResource(CommonStrings.common_mute),
+ )
+ }
+ }
+ .toImmutableList()
+}
diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/notificationsettings/RoomNotificationSettingsNode.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/notificationsettings/RoomNotificationSettingsNode.kt
new file mode 100644
index 0000000000..224f850e28
--- /dev/null
+++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/notificationsettings/RoomNotificationSettingsNode.kt
@@ -0,0 +1,57 @@
+/*
+ * Copyright (c) 2023 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.element.android.features.roomdetails.impl.notificationsettings
+
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import com.bumble.appyx.core.lifecycle.subscribe
+import com.bumble.appyx.core.modality.BuildContext
+import com.bumble.appyx.core.node.Node
+import com.bumble.appyx.core.plugin.Plugin
+import dagger.assisted.Assisted
+import dagger.assisted.AssistedInject
+import im.vector.app.features.analytics.plan.MobileScreen
+import io.element.android.anvilannotations.ContributesNode
+import io.element.android.libraries.di.RoomScope
+import io.element.android.services.analytics.api.AnalyticsService
+
+@ContributesNode(RoomScope::class)
+class RoomNotificationSettingsNode @AssistedInject constructor(
+ @Assisted buildContext: BuildContext,
+ @Assisted plugins: List,
+ private val presenter: RoomNotificationSettingsPresenter,
+ private val analyticsService: AnalyticsService,
+) : Node(buildContext, plugins = plugins) {
+
+ init {
+ lifecycle.subscribe(
+ onResume = {
+ analyticsService.screen(MobileScreen(screenName = MobileScreen.ScreenName.RoomNotifications))
+ }
+ )
+ }
+
+ @Composable
+ override fun View(modifier: Modifier) {
+ val state = presenter.present()
+ RoomNotificationSettingsView(
+ state = state,
+ modifier = modifier,
+ onBackPressed = this::navigateUp,
+ )
+ }
+}
diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/notificationsettings/RoomNotificationSettingsOption.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/notificationsettings/RoomNotificationSettingsOption.kt
new file mode 100644
index 0000000000..28592b3b7e
--- /dev/null
+++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/notificationsettings/RoomNotificationSettingsOption.kt
@@ -0,0 +1,97 @@
+/*
+ * Copyright (c) 2023 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.element.android.features.roomdetails.impl.notificationsettings
+
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.selection.selectable
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.semantics.Role
+import androidx.compose.ui.unit.dp
+import io.element.android.libraries.designsystem.preview.DayNightPreviews
+import io.element.android.libraries.designsystem.preview.ElementPreview
+import io.element.android.libraries.designsystem.theme.components.RadioButton
+import io.element.android.libraries.designsystem.theme.components.Text
+import io.element.android.libraries.designsystem.toEnabledColor
+import io.element.android.libraries.theme.ElementTheme
+
+@Composable
+fun RoomNotificationSettingsOption(
+ roomNotificationSettingsItem: RoomNotificationSettingsItem,
+ modifier: Modifier = Modifier,
+ enabled: Boolean = true,
+ isSelected: Boolean = false,
+ onOptionSelected: (RoomNotificationSettingsItem) -> Unit = {},
+) {
+ Row(
+ modifier
+ .fillMaxWidth()
+ .selectable(
+ selected = isSelected,
+ enabled = enabled,
+ onClick = { onOptionSelected(roomNotificationSettingsItem) },
+ role = Role.RadioButton,
+ )
+ .padding(8.dp),
+ ) {
+ Column(
+ Modifier
+ .weight(1f)
+ .padding(horizontal = 8.dp)
+ .align(Alignment.CenterVertically)
+ ) {
+ Text(
+ text = roomNotificationSettingsItem.title,
+ style = ElementTheme.typography.fontBodyLgRegular,
+ color = enabled.toEnabledColor(),
+ )
+ }
+
+ RadioButton(
+ modifier = Modifier
+ .align(Alignment.CenterVertically)
+ .size(48.dp),
+ selected = isSelected,
+ enabled = enabled,
+ onClick = null // null recommended for accessibility with screenreaders
+ )
+ }
+}
+
+@DayNightPreviews
+@Composable
+internal fun RoomPrivacyOptionLightPreview() = ElementPreview { ContentToPreview() }
+
+@Composable
+private fun ContentToPreview() {
+ Column {
+ RoomNotificationSettingsOption(
+ roomNotificationSettingsItem = roomNotificationSettingsItems().first(),
+ isSelected = true,
+ )
+ RoomNotificationSettingsOption(
+ roomNotificationSettingsItem = roomNotificationSettingsItems().last(),
+ isSelected = false,
+ enabled = false,
+ )
+ }
+}
diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/notificationsettings/RoomNotificationSettingsPresenter.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/notificationsettings/RoomNotificationSettingsPresenter.kt
new file mode 100644
index 0000000000..a6e2477bcc
--- /dev/null
+++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/notificationsettings/RoomNotificationSettingsPresenter.kt
@@ -0,0 +1,108 @@
+/*
+ * Copyright (c) 2023 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.element.android.features.roomdetails.impl.notificationsettings
+
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.MutableState
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.runtime.saveable.rememberSaveable
+import io.element.android.libraries.architecture.Presenter
+import io.element.android.libraries.matrix.api.notificationsettings.NotificationSettingsService
+import io.element.android.libraries.matrix.api.room.MatrixRoom
+import io.element.android.libraries.matrix.api.room.RoomNotificationMode
+import io.element.android.libraries.matrix.api.room.roomNotificationSettings
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.FlowPreview
+import kotlinx.coroutines.flow.debounce
+import kotlinx.coroutines.flow.launchIn
+import kotlinx.coroutines.flow.onEach
+import kotlinx.coroutines.launch
+import javax.inject.Inject
+import kotlin.time.Duration.Companion.seconds
+
+class RoomNotificationSettingsPresenter @Inject constructor(
+ private val room: MatrixRoom,
+ private val notificationSettingsService: NotificationSettingsService,
+) : Presenter {
+
+ @Composable
+ override fun present(): RoomNotificationSettingsState {
+ val defaultRoomNotificationMode: MutableState = rememberSaveable {
+ mutableStateOf(null)
+ }
+ val localCoroutineScope = rememberCoroutineScope()
+
+ LaunchedEffect(Unit) {
+ getDefaultRoomNotificationMode(defaultRoomNotificationMode)
+ observeNotificationSettings()
+ }
+
+ val roomNotificationSettingsState by room.roomNotificationSettingsStateFlow.collectAsState()
+
+ fun handleEvents(event: RoomNotificationSettingsEvents) {
+ when (event) {
+ is RoomNotificationSettingsEvents.RoomNotificationModeChanged -> {
+ localCoroutineScope.setRoomNotificationMode(event.mode)
+ }
+ is RoomNotificationSettingsEvents.SetNotificationMode -> {
+ if (event.isDefault) {
+ localCoroutineScope.restoreDefaultRoomNotificationMode()
+ } else {
+ defaultRoomNotificationMode.value?.let {
+ localCoroutineScope.setRoomNotificationMode(it)
+ }
+ }
+ }
+ }
+ }
+
+ return RoomNotificationSettingsState(
+ roomNotificationSettings = roomNotificationSettingsState.roomNotificationSettings(),
+ defaultRoomNotificationMode = defaultRoomNotificationMode.value,
+ eventSink = ::handleEvents,
+ )
+ }
+
+ @OptIn(FlowPreview::class)
+ private fun CoroutineScope.observeNotificationSettings() {
+ notificationSettingsService.notificationSettingsChangeFlow
+ .debounce(0.5.seconds)
+ .onEach {
+ room.updateRoomNotificationSettings()
+ }
+ .launchIn(this)
+ }
+
+ private fun CoroutineScope.getDefaultRoomNotificationMode(defaultRoomNotificationMode: MutableState) = launch {
+ defaultRoomNotificationMode.value = notificationSettingsService.getDefaultRoomNotificationMode(
+ room.isEncrypted,
+ room.isOneToOne
+ ).getOrThrow()
+ }
+
+ private fun CoroutineScope.setRoomNotificationMode(mode: RoomNotificationMode) = launch {
+ notificationSettingsService.setRoomNotificationMode(room.roomId, mode)
+ }
+
+ private fun CoroutineScope.restoreDefaultRoomNotificationMode() = launch {
+ notificationSettingsService.restoreDefaultRoomNotificationMode(room.roomId)
+ }
+}
diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/notificationsettings/RoomNotificationSettingsState.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/notificationsettings/RoomNotificationSettingsState.kt
new file mode 100644
index 0000000000..04742781b5
--- /dev/null
+++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/notificationsettings/RoomNotificationSettingsState.kt
@@ -0,0 +1,26 @@
+/*
+ * Copyright (c) 2023 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.element.android.features.roomdetails.impl.notificationsettings
+
+import io.element.android.libraries.matrix.api.room.RoomNotificationMode
+import io.element.android.libraries.matrix.api.room.RoomNotificationSettings
+
+data class RoomNotificationSettingsState(
+ val roomNotificationSettings: RoomNotificationSettings?,
+ val defaultRoomNotificationMode: RoomNotificationMode?,
+ val eventSink: (RoomNotificationSettingsEvents) -> Unit
+)
diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/notificationsettings/RoomNotificationSettingsStateProvider.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/notificationsettings/RoomNotificationSettingsStateProvider.kt
new file mode 100644
index 0000000000..df1dd7977b
--- /dev/null
+++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/notificationsettings/RoomNotificationSettingsStateProvider.kt
@@ -0,0 +1,34 @@
+/*
+ * Copyright (c) 2023 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.element.android.features.roomdetails.impl.notificationsettings
+
+import androidx.compose.ui.tooling.preview.PreviewParameterProvider
+import io.element.android.libraries.matrix.api.room.RoomNotificationMode
+import io.element.android.libraries.matrix.api.room.RoomNotificationSettings
+
+internal class RoomNotificationSettingsStateProvider : PreviewParameterProvider {
+ override val values: Sequence
+ get() = sequenceOf(
+ RoomNotificationSettingsState(
+ RoomNotificationSettings(
+ mode = RoomNotificationMode.MUTE,
+ isDefault = true),
+ RoomNotificationMode.ALL_MESSAGES,
+ eventSink = { },
+ ),
+ )
+}
diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/notificationsettings/RoomNotificationSettingsView.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/notificationsettings/RoomNotificationSettingsView.kt
new file mode 100644
index 0000000000..3709280477
--- /dev/null
+++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/notificationsettings/RoomNotificationSettingsView.kt
@@ -0,0 +1,161 @@
+/*
+ * Copyright (c) 2023 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.element.android.features.roomdetails.impl.notificationsettings
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.ExperimentalLayoutApi
+import androidx.compose.foundation.layout.consumeWindowInsets
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.selection.selectableGroup
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.tooling.preview.PreviewParameter
+import androidx.compose.ui.unit.dp
+import io.element.android.features.roomdetails.impl.R
+import io.element.android.libraries.core.bool.orTrue
+import io.element.android.libraries.designsystem.components.button.BackButton
+import io.element.android.libraries.designsystem.components.preferences.PreferenceCategory
+import io.element.android.libraries.designsystem.components.preferences.PreferenceSwitch
+import io.element.android.libraries.designsystem.components.preferences.PreferenceText
+import io.element.android.libraries.designsystem.preview.ElementPreviewDark
+import io.element.android.libraries.designsystem.preview.ElementPreviewLight
+import io.element.android.libraries.designsystem.theme.aliasScreenTitle
+import io.element.android.libraries.designsystem.theme.components.Scaffold
+import io.element.android.libraries.designsystem.theme.components.Text
+import io.element.android.libraries.designsystem.theme.components.TopAppBar
+import io.element.android.libraries.matrix.api.room.RoomNotificationMode
+import io.element.android.libraries.theme.ElementTheme
+import io.element.android.libraries.ui.strings.CommonStrings
+
+@OptIn(ExperimentalLayoutApi::class)
+@Composable
+fun RoomNotificationSettingsView(
+ state: RoomNotificationSettingsState,
+ modifier: Modifier = Modifier,
+ onBackPressed: () -> Unit = {},
+) {
+ Scaffold(
+ modifier = modifier,
+ topBar = {
+ RoomNotificationSettingsTopBar(
+ onBackPressed = { onBackPressed() }
+ )
+ }
+ ) { padding ->
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(padding)
+ .consumeWindowInsets(padding),
+ verticalArrangement = Arrangement.spacedBy(16.dp),
+ ) {
+ val subtitle = when(state.defaultRoomNotificationMode) {
+ RoomNotificationMode.ALL_MESSAGES -> stringResource(id = R.string.screen_room_notification_settings_mode_all_messages)
+ RoomNotificationMode.MENTIONS_AND_KEYWORDS_ONLY -> stringResource(id = R.string.screen_room_notification_settings_mode_mentions_and_keywords)
+ RoomNotificationMode.MUTE -> stringResource(id = CommonStrings.common_mute)
+ null -> ""
+ }
+
+
+ PreferenceCategory(title = stringResource(id = R.string.screen_room_notification_settings_custom_settings_title)) {
+ PreferenceSwitch(
+ isChecked = state.roomNotificationSettings?.isDefault.orTrue(),
+ onCheckedChange = {
+ state.eventSink(RoomNotificationSettingsEvents.SetNotificationMode(it))
+ },
+ title = "Match default setting",
+ subtitle = subtitle,
+ enabled = state.roomNotificationSettings != null
+ )
+
+ PreferenceText(
+ title = stringResource(id = R.string.screen_room_notification_settings_allow_custom),
+ subtitle = stringResource(id = R.string.screen_room_notification_settings_allow_custom_footnote),
+ enabled = state.roomNotificationSettings != null && !state.roomNotificationSettings.isDefault,
+ )
+
+ if (state.roomNotificationSettings != null) {
+ RoomNotificationSettingsOptions(
+ selected = state.roomNotificationSettings.mode,
+ enabled = !state.roomNotificationSettings.isDefault,
+ onOptionSelected = {
+ state.eventSink(RoomNotificationSettingsEvents.RoomNotificationModeChanged(it.mode))
+ },
+ )
+ }
+ }
+ }
+ }
+}
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun RoomNotificationSettingsTopBar(
+ modifier: Modifier = Modifier,
+ onBackPressed: () -> Unit = {},
+) {
+ TopAppBar(
+ modifier = modifier,
+ title = {
+ Text(
+ text = stringResource(R.string.screen_room_details_notification_title),
+ style = ElementTheme.typography.aliasScreenTitle,
+ )
+ },
+ navigationIcon = { BackButton(onClick = onBackPressed) },
+ )
+}
+
+@Composable
+fun RoomNotificationSettingsOptions(
+ selected: RoomNotificationMode?,
+ enabled: Boolean,
+ modifier: Modifier = Modifier,
+ onOptionSelected: (RoomNotificationSettingsItem) -> Unit = {},
+) {
+ val items = roomNotificationSettingsItems()
+ Column(modifier = modifier.selectableGroup()) {
+ items.forEach { item ->
+ RoomNotificationSettingsOption(
+ roomNotificationSettingsItem = item,
+ isSelected = selected == item.mode,
+ onOptionSelected = onOptionSelected,
+ enabled = enabled
+ )
+ }
+ }
+}
+
+@Preview
+@Composable
+internal fun RoomNotificationSettingsLightPreview(@PreviewParameter(RoomNotificationSettingsStateProvider::class) state: RoomNotificationSettingsState) =
+ ElementPreviewLight { ContentToPreview(state) }
+
+@Preview
+@Composable
+internal fun RoomNotificationSettingsDarkPreview(@PreviewParameter(RoomNotificationSettingsStateProvider::class) state: RoomNotificationSettingsState) =
+ ElementPreviewDark { ContentToPreview(state) }
+
+@Composable
+private fun ContentToPreview(state: RoomNotificationSettingsState) {
+ RoomNotificationSettingsView(state)
+}
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 90a9bb0190..defedd3bfb 100644
--- a/features/roomdetails/impl/src/main/res/values-cs/translations.xml
+++ b/features/roomdetails/impl/src/main/res/values-cs/translations.xml
@@ -25,6 +25,19 @@
"Aktualizace místnosti…"
"Nevyřízeno"
"Členové místnosti"
+ "Povolit vlastní nastavení"
+ "Zapnutím této funkce přepíšete výchozí nastavení"
+ "Upozornit mě v tomto chatu na"
+ "Můžete změnit ve vašem %1$s."
+ "globální nastavení"
+ "Výchozí nastavení"
+ "Odebrat vlastní nastavení"
+ "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šechny zprávy"
+ "Pouze zmínky a klíčová slova"
+ "V této místnosti mě upozornit na"
"Zablokovat"
"Blokovaní uživatelé vám nebudou moci posílat zprávy a všechny jejich zprávy budou skryty. Můžete je kdykoli odblokovat."
"Zablokovat uživatele"
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 8ae23e8152..1495840415 100644
--- a/features/roomdetails/impl/src/main/res/values-de/translations.xml
+++ b/features/roomdetails/impl/src/main/res/values-de/translations.xml
@@ -8,28 +8,41 @@
"Bereits Mitglied"
"Bereits eingeladen"
"Raum bearbeiten"
- "Es gab einen unbekannten Fehler und die Informationen konnten nicht geändert werden."
- "Raum konnte nicht aktualisiert werden"
- "Nachrichten sind mit Schlössern gesichert. Nur du und der Empfänger haben die eindeutigen Schlüssel, um sie zu entsperren."
+ "Es ist ein unbekannter Fehler aufgetreten und die Informationen konnten nicht geändert werden."
+ "Raum kann nicht aktualisiert werden"
+ "Nachrichten sind mit Schlössern gesichert. Nur du und die Empfänger haben die eindeutigen Schlüssel, um sie zu entsperren."
"Nachrichtenverschlüsselung aktiviert"
"Beim Laden der Benachrichtigungseinstellungen ist ein Fehler aufgetreten."
- "Das Stummschalten dieses Raums ist fehlgeschlagen. Bitte versuche es erneut."
- "Die Stummschaltung dieses Raums konnte nicht aufgehoben werden. Bitte versuchen Sie es erneut."
+ "Die Stummschaltung dieses Raums ist fehlgeschlagen, bitte versuche es erneut."
+ "Die Deaktivierung der Stummschaltung dieses Raums ist fehlgeschlagen, bitte versuche es erneut."
"Personen einladen"
"Benutzerdefiniert"
"Standard"
"Benachrichtigungen"
"Raumname"
"Raum teilen"
- "Aktualisiere Raum…"
+ "Raum wird aktualisiert…"
"Ausstehend"
"Raummitglieder"
- "Blockieren"
- "Blockierte Benutzer können dir keine Nachrichten senden und alle ihre Nachrichten werden ausgeblendet. Du kannst sie jederzeit entsperren."
- "Nutzer blockieren"
- "Blockierung aufheben"
- "Du wirst alle ihre Nachrichten wieder sehen."
- "Nutzer entblockieren"
+ "Benutzerdefinierte Einstellung zulassen"
+ "Wenn du diese Option aktivierst, wird deine Standardeinstellung außer Kraft gesetzt."
+ "Benachrichtige mich in diesem Chat bei"
+ "Du kannst das in deinem %1$s ändern."
+ "Globale Einstellungen"
+ "Standardeinstellung"
+ "Benutzerdefinierte Einstellung entfernen"
+ "Beim Laden der Benachrichtigungseinstellungen ist ein Fehler aufgetreten."
+ "Fehler beim Wiederherstellen des Standardmodus. Bitte versuche es erneut."
+ "Fehler beim Einstellen des Modus. Bitte versuche es erneut."
+ "Alle Nachrichten"
+ "Nur Erwähnungen und Schlüsselwörter"
+ "Benachrichtige mich in diesem Raum bei"
+ "Sperren"
+ "Gesperrte Benutzer können dir keine Nachrichten senden und alle ihre Nachrichten werden ausgeblendet. Du kannst sie jederzeit entsperren."
+ "Benutzer sperren"
+ "Entsperren"
+ "Du kannst dann wieder alle Nachrichten von ihnen sehen."
+ "Benutzer entsperren"
"Raum verlassen"
"Personen"
"Sicherheit"
diff --git a/features/roomdetails/impl/src/main/res/values-fr/translations.xml b/features/roomdetails/impl/src/main/res/values-fr/translations.xml
index 2696cc99ea..7efbe4c1f7 100644
--- a/features/roomdetails/impl/src/main/res/values-fr/translations.xml
+++ b/features/roomdetails/impl/src/main/res/values-fr/translations.xml
@@ -1,34 +1,48 @@
- - "%1$d membre"
- - "%1$d membres"
+ - "%1$d personne"
+ - "%1$d personnes"
- "Définir un sujet"
+ "Ajouter un sujet"
"Déjà membre"
"Déjà invité(e)"
"Modifier le salon"
"Une erreur inconnue s’est produite et les informations n’ont pas pu être modifiées."
"Impossible de mettre à jour le salon"
- "Les messages sont sécurisés par des cadenas numériques. Seuls vous et les destinataires possédez les clés uniques pour les déverrouiller."
+ "Les messages sont sécurisés par des clés de chiffrement. Seuls vous et les destinataires possédez les clés uniques pour les déverrouiller."
"Chiffrement des messages activé"
"Une erreur s’est produite lors du chargement des paramètres de notification."
- "Impossible de désactiver les notifications de cette salle, veuillez réessayer."
- "Impossible de réactiver les notifications de cette salle, veuillez réessayer."
+ "Échec de la mise en sourdine de ce salon, veuillez réessayer."
+ "Échec de la désactivation de la mise en sourdine de ce salon, veuillez réessayer."
"Inviter des personnes"
"Personnalisé"
- "Par défaut"
+ "Défaut"
"Notifications"
"Nom du salon"
"Partager le salon"
"Mise à jour du salon…"
"En attente"
+ "Membres du salon"
+ "Autoriser les paramètres personnalisés"
+ "L’activation de cette option annulera votre paramètre par défaut"
+ "Prévenez-moi dans ce salon pour"
+ "Vous pouvez le modifier dans votre %1$s."
+ "paramètres globaux"
+ "Paramètre par défaut"
+ "Supprimer le paramètre personnalisé"
+ "Une erreur s’est produite lors du chargement des paramètres de notification."
+ "Échec de la restauration du mode par défaut, veuillez réessayer."
+ "Échec de la configuration du mode, veuillez réessayer."
+ "Tous les messages"
+ "Mentions et mots clés uniquement"
+ "Dans ce salon, prévenez-moi pour"
"Bloquer"
"Les utilisateurs bloqués ne pourront pas vous envoyer de messages et tous leurs messages seront masqués. Vous pouvez les débloquer à tout moment."
- "Bloquer l\'utilisateur"
+ "Bloquer l’utilisateur"
"Débloquer"
- "Vous pourrez à nouveau voir tous leurs messages."
- "Débloquer l\'utilisateur"
+ "Vous pourrez à nouveau voir tous ses messages."
+ "Débloquer l’utilisateur"
"Quitter le salon"
"Personnes"
"Sécurité"
diff --git a/features/roomdetails/impl/src/main/res/values-ro/translations.xml b/features/roomdetails/impl/src/main/res/values-ro/translations.xml
index c20cdab3f9..f86fbb8bb5 100644
--- a/features/roomdetails/impl/src/main/res/values-ro/translations.xml
+++ b/features/roomdetails/impl/src/main/res/values-ro/translations.xml
@@ -12,13 +12,31 @@
"Nu s-a putut actualiza camera"
"Mesajele sunt securizate cu încuietori. Doar dumneavoastră și destinatarii aveți cheile unice pentru a le debloca."
"Criptarea mesajelor este activată"
+ "A apărut o eroare la încărcarea setărilor pentru notificari."
+ "Dezactivarea notificarilor pentru această cameră a eșuat, încercați din nou."
+ "Activarea notificarilor pentru această cameră a eșuat, încercați din nou."
"Invitați persoane"
- "Notificare"
+ "Personalizat"
+ "Implicit"
+ "Notificări"
"Numele camerei"
"Partajați camera"
"Se actualizează camera…"
"În așteptare"
"Membrii camerei"
+ "Permiteți setări personalizate"
+ "Activarea acestei opțiuni va anula setările implicite."
+ "Anunțați-mă în acestă cameră pentru"
+ "Îl puteți schimba în %1$s."
+ "Setări generale"
+ "Setare implicită"
+ "Stergeți setarea personalizată"
+ "A apărut o eroare la încărcarea setărilor pentry notificari."
+ "Nu s-a reușit restaurarea modului implicit, vă rugăm să încercați din nou."
+ "Nu s-a reușit setarea modului, vă rugăm să încercați din nou."
+ "Toate mesajele"
+ "Numai mențiuni și cuvinte cheie"
+ "În această cameră, anunțați-mă pentru"
"Blocați"
"Utilizatorii blocați nu vă vor putea trimite mesaje și toate mesajele lor vor fi ascunse. Puteți anula această acțiune oricând."
"Blocați utilizatorul"
diff --git a/features/roomdetails/impl/src/main/res/values-ru/translations.xml b/features/roomdetails/impl/src/main/res/values-ru/translations.xml
index 4d2664ab30..cff08460c1 100644
--- a/features/roomdetails/impl/src/main/res/values-ru/translations.xml
+++ b/features/roomdetails/impl/src/main/res/values-ru/translations.xml
@@ -25,6 +25,19 @@
"Обновление комнаты…"
"В ожидании"
"Участники комнаты"
+ "Разрешить пользовательские настройки"
+ "Включение этого параметра отменяет настройки по умолчанию"
+ "Уведомить меня в этом чате"
+ "Вы можете изменить его в своем %1$s."
+ "Основные Настройки"
+ "Настройка по умолчанию"
+ "Удалить пользовательскую настройку"
+ "Произошла ошибка при загрузке настроек уведомлений."
+ "Не удалось восстановить режим по умолчанию, попробуйте еще раз."
+ "Не удалось настроить режим, попробуйте еще раз."
+ "Все сообщения"
+ "Только упоминания и ключевые слова"
+ "В этой комнате уведомить меня о"
"Заблокировать"
"Заблокированные пользователи не смогут отправлять вам сообщения, а все их сообщения будут скрыты. Вы можете разблокировать их в любое время."
"Заблокировать пользователя"
diff --git a/features/roomdetails/impl/src/main/res/values-sk/translations.xml b/features/roomdetails/impl/src/main/res/values-sk/translations.xml
index 1d744fba30..01e0f717e1 100644
--- a/features/roomdetails/impl/src/main/res/values-sk/translations.xml
+++ b/features/roomdetails/impl/src/main/res/values-sk/translations.xml
@@ -25,6 +25,19 @@
"Aktualizácia miestnosti…"
"Čaká sa"
"Členovia miestnosti"
+ "Povoliť vlastné nastavenie"
+ "Zapnutím tohto nastavenia sa prepíše vaše predvolené nastavenie"
+ "Upozorniť ma v tejto konverzácii na"
+ "Môžete to zmeniť vo svojich %1$s."
+ "všeobecných nastaveniach"
+ "Predvolené nastavenie"
+ "Odstrániť vlastné nastavenie"
+ "Pri načítavaní nastavení oznámení došlo k chybe."
+ "Nepodarilo sa obnoviť predvolený režim, skúste to prosím znova."
+ "Nepodarilo sa nastaviť režim, skúste to prosím znova."
+ "Všetky správy"
+ "Iba zmienky a kľúčové slová"
+ "V tejto miestnosti ma upozorniť na"
"Zablokovať"
"Blokovaní používatelia vám nebudú môcť posielať správy a všetky ich správy budú skryté. Môžete ich kedykoľvek odblokovať."
"Zablokovať používateľa"
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 fb7872844a..7b4eb895ea 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
@@ -18,6 +18,12 @@
"正在更新聊天室…"
"待定"
"聊天室成員"
+ "全域設定"
+ "預設"
+ "無法重設為預設模式,請再試一次。"
+ "無法設定模式,請再試一次。"
+ "所有訊息"
+ "只限提及與關鍵字"
"封鎖"
"封鎖使用者"
"解除封鎖"
diff --git a/features/roomdetails/impl/src/main/res/values/localazy.xml b/features/roomdetails/impl/src/main/res/values/localazy.xml
index b1f67dab1e..7f0f4ddc30 100644
--- a/features/roomdetails/impl/src/main/res/values/localazy.xml
+++ b/features/roomdetails/impl/src/main/res/values/localazy.xml
@@ -24,6 +24,19 @@
"Updating room…"
"Pending"
"Room members"
+ "Allow custom setting"
+ "Turning this on will override your default setting"
+ "Notify me in this chat for"
+ "You can change it in your %1$s."
+ "global settings"
+ "Default setting"
+ "Remove custom setting"
+ "An error occurred while loading notification settings."
+ "Failed restoring the default mode, please try again."
+ "Failed setting the mode, please try again."
+ "All messages"
+ "Mentions and Keywords only"
+ "In this room, notify me for"
"Block"
"Blocked users won\'t be able to send you messages and all their messages will be hidden. You can unblock them anytime."
"Block user"
diff --git a/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/RoomDetailsPresenterTests.kt b/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/RoomDetailsPresenterTests.kt
index 08d6a58535..a6ffa0a289 100644
--- a/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/RoomDetailsPresenterTests.kt
+++ b/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/RoomDetailsPresenterTests.kt
@@ -29,37 +29,66 @@ import io.element.android.features.roomdetails.impl.RoomDetailsType
import io.element.android.features.roomdetails.impl.RoomTopicState
import io.element.android.features.roomdetails.impl.members.aRoomMember
import io.element.android.features.roomdetails.impl.members.details.RoomMemberDetailsPresenter
+import io.element.android.libraries.core.coroutine.CoroutineDispatchers
+import io.element.android.libraries.featureflag.api.FeatureFlags
+import io.element.android.libraries.featureflag.test.FakeFeatureFlagService
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.room.MatrixRoom
import io.element.android.libraries.matrix.api.room.MatrixRoomMembersState
+import io.element.android.libraries.matrix.api.room.RoomNotificationMode
import io.element.android.libraries.matrix.api.room.StateEventType
import io.element.android.libraries.matrix.test.A_ROOM_ID
import io.element.android.libraries.matrix.test.A_ROOM_NAME
import io.element.android.libraries.matrix.test.A_SESSION_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.matrix.test.notificationsettings.FakeNotificationSettingsService
import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
+import io.element.android.tests.testutils.consumeItemsUntilPredicate
+import io.element.android.tests.testutils.testCoroutineDispatchers
+import io.element.android.tests.testutils.WarmUpRule
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.runTest
+import org.junit.Rule
import org.junit.Test
+import kotlin.time.Duration.Companion.milliseconds
@ExperimentalCoroutinesApi
class RoomDetailsPresenterTests {
- private fun aRoomDetailsPresenter(room: MatrixRoom, leaveRoomPresenter: LeaveRoomPresenter = LeaveRoomPresenterFake()): RoomDetailsPresenter {
+ @get:Rule
+ val warmUpRule = WarmUpRule()
+ private fun aRoomDetailsPresenter(
+ room: MatrixRoom,
+ leaveRoomPresenter: LeaveRoomPresenter = LeaveRoomPresenterFake(),
+ dispatchers: CoroutineDispatchers,
+ notificationSettingsService: FakeNotificationSettingsService = FakeNotificationSettingsService()
+ ): RoomDetailsPresenter {
+ val matrixClient = FakeMatrixClient(notificationSettingsService = notificationSettingsService)
val roomMemberDetailsPresenterFactory = object : RoomMemberDetailsPresenter.Factory {
override fun create(roomMemberId: UserId): RoomMemberDetailsPresenter {
- return RoomMemberDetailsPresenter(FakeMatrixClient(), room, roomMemberId)
+ return RoomMemberDetailsPresenter(matrixClient, room, roomMemberId)
}
}
- return RoomDetailsPresenter(room, roomMemberDetailsPresenterFactory, leaveRoomPresenter)
+ val featureFlagService = FakeFeatureFlagService(
+ mapOf(FeatureFlags.NotificationSettings.key to true)
+ )
+ return RoomDetailsPresenter(
+ matrixClient,
+ room,
+ featureFlagService,
+ matrixClient.notificationSettingsService(),
+ roomMemberDetailsPresenterFactory,
+ leaveRoomPresenter,
+ dispatchers
+ )
}
@Test
fun `present - initial state is created from room info`() = runTest {
val room = aMatrixRoom()
- val presenter = aRoomDetailsPresenter(room)
+ val presenter = aRoomDetailsPresenter(room, dispatchers = testCoroutineDispatchers())
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
@@ -78,7 +107,7 @@ class RoomDetailsPresenterTests {
@Test
fun `present - initial state with no room name`() = runTest {
val room = aMatrixRoom(name = null)
- val presenter = aRoomDetailsPresenter(room)
+ val presenter = aRoomDetailsPresenter(room, dispatchers = testCoroutineDispatchers())
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
@@ -100,7 +129,7 @@ class RoomDetailsPresenterTests {
val roomMembers = listOf(myRoomMember, otherRoomMember)
givenRoomMembersState(MatrixRoomMembersState.Ready(roomMembers))
}
- val presenter = aRoomDetailsPresenter(room)
+ val presenter = aRoomDetailsPresenter(room, dispatchers = testCoroutineDispatchers())
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
@@ -116,7 +145,7 @@ class RoomDetailsPresenterTests {
val room = aMatrixRoom().apply {
givenCanInviteResult(Result.success(true))
}
- val presenter = aRoomDetailsPresenter(room)
+ val presenter = aRoomDetailsPresenter(room, dispatchers = testCoroutineDispatchers())
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
@@ -134,11 +163,13 @@ class RoomDetailsPresenterTests {
val room = aMatrixRoom().apply {
givenCanInviteResult(Result.success(false))
}
- val presenter = aRoomDetailsPresenter(room)
+ val presenter = aRoomDetailsPresenter(room, dispatchers = testCoroutineDispatchers())
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
assertThat(awaitItem().canInvite).isFalse()
+
+ cancelAndIgnoreRemainingEvents()
}
}
@@ -147,11 +178,13 @@ class RoomDetailsPresenterTests {
val room = aMatrixRoom().apply {
givenCanInviteResult(Result.failure(Throwable("Whoops")))
}
- val presenter = aRoomDetailsPresenter(room)
+ val presenter = aRoomDetailsPresenter(room, dispatchers = testCoroutineDispatchers())
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
assertThat(awaitItem().canInvite).isFalse()
+
+ cancelAndIgnoreRemainingEvents()
}
}
@@ -163,7 +196,7 @@ class RoomDetailsPresenterTests {
givenCanSendStateResult(StateEventType.ROOM_AVATAR, Result.failure(Throwable("Whelp")))
givenCanInviteResult(Result.success(false))
}
- val presenter = aRoomDetailsPresenter(room)
+ val presenter = aRoomDetailsPresenter(room, dispatchers = testCoroutineDispatchers())
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
@@ -192,7 +225,7 @@ class RoomDetailsPresenterTests {
givenCanSendStateResult(StateEventType.ROOM_AVATAR, Result.success(true))
givenCanInviteResult(Result.success(false))
}
- val presenter = aRoomDetailsPresenter(room)
+ val presenter = aRoomDetailsPresenter(room, dispatchers = testCoroutineDispatchers())
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
@@ -221,7 +254,7 @@ class RoomDetailsPresenterTests {
givenCanSendStateResult(StateEventType.ROOM_TOPIC, Result.success(true))
}
- val presenter = aRoomDetailsPresenter(room)
+ val presenter = aRoomDetailsPresenter(room, dispatchers = testCoroutineDispatchers())
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
@@ -242,7 +275,7 @@ class RoomDetailsPresenterTests {
givenCanSendStateResult(StateEventType.ROOM_AVATAR, Result.success(true))
givenCanInviteResult(Result.success(false))
}
- val presenter = aRoomDetailsPresenter(room)
+ val presenter = aRoomDetailsPresenter(room, dispatchers = testCoroutineDispatchers())
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
@@ -263,12 +296,14 @@ class RoomDetailsPresenterTests {
givenCanSendStateResult(StateEventType.ROOM_AVATAR, Result.success(false))
givenCanInviteResult(Result.success(false))
}
- val presenter = aRoomDetailsPresenter(room)
+ val presenter = aRoomDetailsPresenter(room, dispatchers = testCoroutineDispatchers())
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
// Initially false, and no further events
assertThat(awaitItem().canEdit).isFalse()
+
+ cancelAndIgnoreRemainingEvents()
}
}
@@ -279,12 +314,14 @@ class RoomDetailsPresenterTests {
givenCanInviteResult(Result.success(false))
}
- val presenter = aRoomDetailsPresenter(room)
+ val presenter = aRoomDetailsPresenter(room, dispatchers = testCoroutineDispatchers())
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
// The initial state is "hidden" and no further state changes happen
assertThat(awaitItem().roomTopic).isEqualTo(RoomTopicState.Hidden)
+
+ cancelAndIgnoreRemainingEvents()
}
}
@@ -295,7 +332,7 @@ class RoomDetailsPresenterTests {
givenCanInviteResult(Result.success(false))
}
- val presenter = aRoomDetailsPresenter(room)
+ val presenter = aRoomDetailsPresenter(room, dispatchers = testCoroutineDispatchers())
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
@@ -313,7 +350,7 @@ class RoomDetailsPresenterTests {
fun `present - leave room event is passed on to leave room presenter`() = runTest {
val leaveRoomPresenter = LeaveRoomPresenterFake()
val room = aMatrixRoom()
- val presenter = aRoomDetailsPresenter(room, leaveRoomPresenter)
+ val presenter = aRoomDetailsPresenter(room, leaveRoomPresenter, testCoroutineDispatchers())
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
@@ -324,6 +361,64 @@ class RoomDetailsPresenterTests {
cancelAndIgnoreRemainingEvents()
}
}
+
+ @Test
+ fun `present - notification mode changes`() = runTest {
+ val leaveRoomPresenter = LeaveRoomPresenterFake()
+ val notificationSettingsService = FakeNotificationSettingsService()
+ val room = aMatrixRoom(notificationSettingsService = notificationSettingsService)
+ val presenter = aRoomDetailsPresenter(room, leaveRoomPresenter, testCoroutineDispatchers(), notificationSettingsService)
+ moleculeFlow(RecompositionMode.Immediate) {
+ presenter.present()
+ }.test {
+
+ notificationSettingsService.setRoomNotificationMode(room.roomId, RoomNotificationMode.MENTIONS_AND_KEYWORDS_ONLY)
+ val updatedState = consumeItemsUntilPredicate {
+ it.roomNotificationSettings?.mode == RoomNotificationMode.MENTIONS_AND_KEYWORDS_ONLY
+ }.last()
+ assertThat(updatedState.roomNotificationSettings?.mode).isEqualTo(RoomNotificationMode.MENTIONS_AND_KEYWORDS_ONLY)
+ cancelAndIgnoreRemainingEvents()
+ }
+ }
+
+ @Test
+ fun `present - mute room notifications`() = runTest {
+ val leaveRoomPresenter = LeaveRoomPresenterFake()
+ val notificationSettingsService = FakeNotificationSettingsService(initialRoomMode = RoomNotificationMode.MENTIONS_AND_KEYWORDS_ONLY)
+ val room = aMatrixRoom(notificationSettingsService = notificationSettingsService)
+ val presenter = aRoomDetailsPresenter(room, leaveRoomPresenter, testCoroutineDispatchers(), notificationSettingsService)
+ moleculeFlow(RecompositionMode.Immediate) {
+ presenter.present()
+ }.test {
+ awaitItem().eventSink(RoomDetailsEvent.MuteNotification)
+ val updatedState = consumeItemsUntilPredicate(timeout = 250.milliseconds) {
+ it.roomNotificationSettings?.mode == RoomNotificationMode.MUTE
+ }.last()
+ assertThat(updatedState.roomNotificationSettings?.mode).isEqualTo(RoomNotificationMode.MUTE)
+ cancelAndIgnoreRemainingEvents()
+ }
+ }
+
+ @Test
+ fun `present - unmute room notifications`() = runTest {
+ val leaveRoomPresenter = LeaveRoomPresenterFake()
+ val notificationSettingsService = FakeNotificationSettingsService(
+ initialRoomMode = RoomNotificationMode.MENTIONS_AND_KEYWORDS_ONLY,
+ initialEncryptedGroupDefaultMode = RoomNotificationMode.ALL_MESSAGES
+ )
+ val room = aMatrixRoom(notificationSettingsService = notificationSettingsService)
+ val presenter = aRoomDetailsPresenter(room, leaveRoomPresenter, testCoroutineDispatchers(), notificationSettingsService)
+ moleculeFlow(RecompositionMode.Immediate) {
+ presenter.present()
+ }.test {
+ awaitItem().eventSink(RoomDetailsEvent.UnmuteNotification)
+ val updatedState = consumeItemsUntilPredicate {
+ it.roomNotificationSettings?.mode == RoomNotificationMode.ALL_MESSAGES
+ }.last()
+ assertThat(updatedState.roomNotificationSettings?.mode).isEqualTo(RoomNotificationMode.ALL_MESSAGES)
+ cancelAndIgnoreRemainingEvents()
+ }
+ }
}
fun aMatrixRoom(
@@ -335,6 +430,7 @@ fun aMatrixRoom(
isEncrypted: Boolean = true,
isPublic: Boolean = true,
isDirect: Boolean = false,
+ notificationSettingsService: FakeNotificationSettingsService = FakeNotificationSettingsService()
) = FakeMatrixRoom(
roomId = roomId,
name = name,
@@ -344,5 +440,6 @@ fun aMatrixRoom(
isEncrypted = isEncrypted,
isPublic = isPublic,
isDirect = isDirect,
+ notificationSettingsService = notificationSettingsService
)
diff --git a/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/edit/RoomDetailsEditPresenterTest.kt b/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/edit/RoomDetailsEditPresenterTest.kt
index e43703e235..aeaefeab6e 100644
--- a/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/edit/RoomDetailsEditPresenterTest.kt
+++ b/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/edit/RoomDetailsEditPresenterTest.kt
@@ -32,6 +32,7 @@ import io.element.android.libraries.matrix.ui.media.AvatarAction
import io.element.android.libraries.mediapickers.test.FakePickerProvider
import io.element.android.libraries.mediaupload.api.MediaUploadInfo
import io.element.android.libraries.mediaupload.test.FakeMediaPreProcessor
+import io.element.android.tests.testutils.WarmUpRule
import io.mockk.every
import io.mockk.mockk
import io.mockk.mockkStatic
@@ -40,12 +41,16 @@ import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.runTest
import org.junit.After
import org.junit.Before
+import org.junit.Rule
import org.junit.Test
import java.io.File
@ExperimentalCoroutinesApi
class RoomDetailsEditPresenterTest {
+ @get:Rule
+ val warmUpRule = WarmUpRule()
+
private lateinit var fakePickerProvider: FakePickerProvider
private lateinit var fakeMediaPreProcessor: FakeMediaPreProcessor
diff --git a/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/impl/invite/RoomInviteMembersPresenterTest.kt b/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/impl/invite/RoomInviteMembersPresenterTest.kt
index ede7342882..4033038b69 100644
--- a/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/impl/invite/RoomInviteMembersPresenterTest.kt
+++ b/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/impl/invite/RoomInviteMembersPresenterTest.kt
@@ -36,14 +36,19 @@ import io.element.android.libraries.matrix.ui.components.aMatrixUser
import io.element.android.libraries.matrix.ui.components.aMatrixUserList
import io.element.android.libraries.usersearch.api.UserSearchResult
import io.element.android.libraries.usersearch.test.FakeUserRepository
+import io.element.android.tests.testutils.WarmUpRule
import io.element.android.tests.testutils.testCoroutineDispatchers
import kotlinx.collections.immutable.ImmutableList
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.runTest
+import org.junit.Rule
import org.junit.Test
internal class RoomInviteMembersPresenterTest {
+ @get:Rule
+ val warmUpRule = WarmUpRule()
+
@Test
fun `present - initial state has no results and no search`() = runTest {
val presenter = RoomInviteMembersPresenter(
diff --git a/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/members/RoomMemberListPresenterTests.kt b/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/members/RoomMemberListPresenterTests.kt
index 9de035d017..c7e4c6d8b5 100644
--- a/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/members/RoomMemberListPresenterTests.kt
+++ b/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/members/RoomMemberListPresenterTests.kt
@@ -32,15 +32,20 @@ import io.element.android.libraries.designsystem.theme.components.SearchBarResul
import io.element.android.libraries.matrix.api.room.MatrixRoom
import io.element.android.libraries.matrix.api.room.MatrixRoomMembersState
import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
+import io.element.android.tests.testutils.WarmUpRule
import io.element.android.tests.testutils.testCoroutineDispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.runTest
+import org.junit.Rule
import org.junit.Test
@ExperimentalCoroutinesApi
class RoomMemberListPresenterTests {
+ @get:Rule
+ val warmUpRule = WarmUpRule()
+
@Test
fun `search is done automatically on start, but is async`() = runTest {
val presenter = createPresenter()
diff --git a/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/members/details/RoomMemberDetailsPresenterTests.kt b/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/members/details/RoomMemberDetailsPresenterTests.kt
index 71df3ad633..a249e38823 100644
--- a/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/members/details/RoomMemberDetailsPresenterTests.kt
+++ b/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/members/details/RoomMemberDetailsPresenterTests.kt
@@ -29,13 +29,18 @@ import io.element.android.libraries.architecture.Async
import io.element.android.libraries.matrix.api.room.MatrixRoomMembersState
import io.element.android.libraries.matrix.test.A_THROWABLE
import io.element.android.libraries.matrix.test.FakeMatrixClient
+import io.element.android.tests.testutils.WarmUpRule
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.runTest
+import org.junit.Rule
import org.junit.Test
@ExperimentalCoroutinesApi
class RoomMemberDetailsPresenterTests {
+ @get:Rule
+ val warmUpRule = WarmUpRule()
+
@Test
fun `present - returns the room member's data, then updates it if needed`() = runTest {
val roomMember = aRoomMember(displayName = "Alice")
diff --git a/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/notificationsettings/RoomNotificationSettingsPresenterTests.kt b/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/notificationsettings/RoomNotificationSettingsPresenterTests.kt
new file mode 100644
index 0000000000..a1b7831ce2
--- /dev/null
+++ b/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/notificationsettings/RoomNotificationSettingsPresenterTests.kt
@@ -0,0 +1,82 @@
+/*
+ * Copyright (c) 2023 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.element.android.features.roomdetails.notificationsettings
+
+import app.cash.molecule.RecompositionMode
+import app.cash.molecule.moleculeFlow
+import app.cash.turbine.test
+import com.google.common.truth.Truth
+import io.element.android.features.roomdetails.aMatrixRoom
+import io.element.android.features.roomdetails.impl.notificationsettings.RoomNotificationSettingsEvents
+import io.element.android.features.roomdetails.impl.notificationsettings.RoomNotificationSettingsPresenter
+import io.element.android.libraries.matrix.api.room.RoomNotificationMode
+import io.element.android.tests.testutils.consumeItemsUntilPredicate
+import kotlinx.coroutines.test.runTest
+import org.junit.Test
+
+class RoomNotificationSettingsPresenterTests {
+ @Test
+ fun `present - initial state is created from room info`() = runTest {
+ val presenter = aNotificationPresenter
+ moleculeFlow(RecompositionMode.Immediate) {
+ presenter.present()
+ }.test {
+ val initialState = awaitItem()
+ Truth.assertThat(initialState.roomNotificationSettings).isNull()
+ Truth.assertThat(initialState.defaultRoomNotificationMode).isNull()
+ cancelAndIgnoreRemainingEvents()
+ }
+ }
+
+ @Test
+ fun `present - notification mode changed`() = runTest {
+ val presenter = aNotificationPresenter
+ moleculeFlow(RecompositionMode.Immediate) {
+ presenter.present()
+ }.test {
+ awaitItem().eventSink(RoomNotificationSettingsEvents.RoomNotificationModeChanged(RoomNotificationMode.MENTIONS_AND_KEYWORDS_ONLY))
+ val updatedState = consumeItemsUntilPredicate {
+ it.roomNotificationSettings?.mode == RoomNotificationMode.MENTIONS_AND_KEYWORDS_ONLY
+ }.last()
+ Truth.assertThat(updatedState.roomNotificationSettings?.mode).isEqualTo(RoomNotificationMode.MENTIONS_AND_KEYWORDS_ONLY)
+ }
+ }
+
+ @Test
+ fun `present - notification settings restore default`() = runTest {
+ val presenter = aNotificationPresenter
+ moleculeFlow(RecompositionMode.Immediate) {
+ presenter.present()
+ }.test {
+ val initialState = awaitItem()
+ initialState.eventSink(RoomNotificationSettingsEvents.RoomNotificationModeChanged(RoomNotificationMode.MENTIONS_AND_KEYWORDS_ONLY))
+ initialState.eventSink(RoomNotificationSettingsEvents.SetNotificationMode(true))
+ val defaultState = consumeItemsUntilPredicate {
+ it.roomNotificationSettings?.mode == RoomNotificationMode.MENTIONS_AND_KEYWORDS_ONLY
+ }.last()
+ Truth.assertThat(defaultState.roomNotificationSettings?.mode).isEqualTo(RoomNotificationMode.MENTIONS_AND_KEYWORDS_ONLY)
+ }
+ }
+
+ private val aNotificationPresenter: RoomNotificationSettingsPresenter get() {
+ val room = aMatrixRoom()
+ return RoomNotificationSettingsPresenter(
+ room = room,
+ notificationSettingsService = room.notificationSettingsService
+ )
+ }
+}
diff --git a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListView.kt b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListView.kt
index 176962ca2a..80d183a91b 100644
--- a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListView.kt
+++ b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListView.kt
@@ -18,11 +18,13 @@ package io.element.android.features.roomlist.impl
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
-import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
+import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.consumeWindowInsets
import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
@@ -43,7 +45,7 @@ import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.Velocity
import androidx.compose.ui.unit.dp
import io.element.android.features.leaveroom.api.LeaveRoomView
-import io.element.android.features.networkmonitor.api.ui.ConnectivityIndicatorView
+import io.element.android.features.networkmonitor.api.ui.ConnectivityIndicatorContainer
import io.element.android.features.roomlist.impl.components.RequestVerificationHeader
import io.element.android.features.roomlist.impl.components.RoomListMenuAction
import io.element.android.features.roomlist.impl.components.RoomListTopBar
@@ -52,8 +54,8 @@ import io.element.android.features.roomlist.impl.model.RoomListRoomSummary
import io.element.android.features.roomlist.impl.search.RoomListSearchResultView
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
-import io.element.android.libraries.designsystem.theme.components.HorizontalDivider
import io.element.android.libraries.designsystem.theme.components.FloatingActionButton
+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.Scaffold
import io.element.android.libraries.designsystem.utils.LogCompositions
@@ -74,8 +76,10 @@ fun RoomListView(
onMenuActionClicked: (RoomListMenuAction) -> Unit,
modifier: Modifier = Modifier,
) {
- Column(modifier = modifier) {
- ConnectivityIndicatorView(isOnline = state.hasNetworkConnection)
+ ConnectivityIndicatorContainer(
+ modifier = modifier,
+ isOnline = state.hasNetworkConnection,
+ ) { topPadding ->
Box {
fun onRoomLongClicked(
roomListRoomSummary: RoomListRoomSummary
@@ -94,6 +98,7 @@ fun RoomListView(
LeaveRoomView(state = state.leaveRoomState)
RoomListContent(
+ modifier = Modifier.padding(top = topPadding),
state = state,
onVerifyClicked = onVerifyClicked,
onRoomClicked = onRoomClicked,
@@ -109,6 +114,8 @@ fun RoomListView(
onRoomClicked = onRoomClicked,
onRoomLongClicked = { onRoomLongClicked(it) },
modifier = Modifier
+ .statusBarsPadding()
+ .padding(top = topPadding)
.fillMaxSize()
.background(MaterialTheme.colorScheme.background)
)
@@ -210,6 +217,11 @@ fun RoomListContent(
HorizontalDivider()
}
}
+ // Add a last Spacer item to ensure that the FAB does not hide the last room item
+ // FAB height is 56dp, bottom padding is 16dp, we add 8dp as extra margin -> 56+16+8 = 80
+ item {
+ Spacer(modifier = Modifier.height(80.dp))
+ }
}
},
floatingActionButton = {
@@ -219,8 +231,7 @@ fun RoomListContent(
onClick = onCreateRoomClicked
) {
Icon(
- // Correct icon alignment for better rendering.
- modifier = Modifier.padding(start = 1.dp, bottom = 1.dp),
+ // Note cannot use Icons.Outlined.EditSquare, it does not exist :/
resourceId = DrawableR.drawable.ic_edit_square,
contentDescription = stringResource(id = R.string.screen_roomlist_a11y_create_message)
)
diff --git a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/components/RoomListTopBar.kt b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/components/RoomListTopBar.kt
index 001c048e4e..508385dd84 100644
--- a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/components/RoomListTopBar.kt
+++ b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/components/RoomListTopBar.kt
@@ -17,10 +17,13 @@
package io.element.android.features.roomlist.impl.components
import androidx.activity.compose.BackHandler
+import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.WindowInsets
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.statusBars
+import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.MoreVert
-import androidx.compose.material.icons.filled.Search
import androidx.compose.material.icons.outlined.BugReport
import androidx.compose.material.icons.outlined.Share
import androidx.compose.material3.ExperimentalMaterial3Api
@@ -30,24 +33,37 @@ import androidx.compose.material3.rememberTopAppBarState
import androidx.compose.runtime.Composable
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.setValue
+import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.alpha
+import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.nestedscroll.nestedScroll
+import androidx.compose.ui.layout.onSizeChanged
+import androidx.compose.ui.platform.LocalDensity
+import androidx.compose.ui.platform.LocalInspectionMode
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.DpOffset
+import androidx.compose.ui.unit.DpSize
import androidx.compose.ui.unit.dp
import io.element.android.features.roomlist.impl.R
import io.element.android.libraries.designsystem.components.avatar.Avatar
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
+import io.element.android.libraries.designsystem.components.avatarBloom
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
import io.element.android.libraries.designsystem.text.applyScaleDown
+import io.element.android.libraries.designsystem.text.roundToPx
+import io.element.android.libraries.designsystem.text.toDp
import io.element.android.libraries.designsystem.text.toSp
import io.element.android.libraries.designsystem.theme.aliasScreenTitle
import io.element.android.libraries.designsystem.theme.components.DropdownMenu
import io.element.android.libraries.designsystem.theme.components.DropdownMenuItem
+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.MediumTopAppBar
@@ -60,6 +76,9 @@ import io.element.android.libraries.testtags.TestTags
import io.element.android.libraries.testtags.testTag
import io.element.android.libraries.theme.ElementTheme
import io.element.android.libraries.ui.strings.CommonStrings
+import io.element.android.libraries.designsystem.R as CommonR
+
+private val avatarBloomSize = 430.dp
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@@ -89,6 +108,7 @@ fun RoomListTopBar(
DefaultRoomListTopBar(
matrixUser = matrixUser,
+ areSearchResultsDisplayed = areSearchResultsDisplayed,
onOpenSettings = onOpenSettings,
onSearchClicked = onToggleSearch,
onMenuActionClicked = onMenuActionClicked,
@@ -101,6 +121,7 @@ fun RoomListTopBar(
@Composable
private fun DefaultRoomListTopBar(
matrixUser: MatrixUser?,
+ areSearchResultsDisplayed: Boolean,
scrollBehavior: TopAppBarScrollBehavior,
onOpenSettings: () -> Unit,
onSearchClicked: () -> Unit,
@@ -108,94 +129,145 @@ private fun DefaultRoomListTopBar(
modifier: Modifier = Modifier,
) {
var showMenu by remember { mutableStateOf(false) }
- MediumTopAppBar(
- modifier = modifier
- .nestedScroll(scrollBehavior.nestedScrollConnection),
- title = {
- val fontStyle = if (scrollBehavior.state.collapsedFraction > 0.5)
- ElementTheme.typography.aliasScreenTitle
- else
- ElementTheme.typography.fontHeadingLgBold.copy(
- // Due to a limitation of MediumTopAppBar, and to avoid the text to be truncated,
- // ensure that the font size will never be bigger than 28.dp.
- fontSize = 28.dp.applyScaleDown().toSp()
- )
- Text(
- style = fontStyle,
- text = stringResource(id = R.string.screen_roomlist_main_space_title)
- )
- },
- navigationIcon = {
- if (matrixUser != null) {
- IconButton(
- modifier = Modifier.testTag(TestTags.homeScreenSettings),
- onClick = onOpenSettings
- ) {
- val avatarData by remember {
- derivedStateOf {
- matrixUser.getAvatarData(size = AvatarSize.CurrentUserTopBar)
- }
- }
- Avatar(avatarData, contentDescription = stringResource(CommonStrings.common_settings))
+
+ // We need this to manually clip the top app bar in preview mode
+ val previewAppBarHeight = if (LocalInspectionMode.current) {
+ 112.dp.roundToPx()
+ } else {
+ null
+ }
+ val collapsedFraction = scrollBehavior.state.collapsedFraction
+ var appBarHeight by remember {
+ mutableIntStateOf(previewAppBarHeight ?: 0)
+ }
+
+ val avatarData by remember(matrixUser) {
+ derivedStateOf {
+ matrixUser?.getAvatarData(size = AvatarSize.CurrentUserTopBar)
+ }
+ }
+
+ val statusBarPadding = with (LocalDensity.current) { WindowInsets.statusBars.getTop(this).toDp() }
+
+ Box(modifier = modifier) {
+ MediumTopAppBar(
+ modifier = Modifier
+ .onSizeChanged {
+ appBarHeight = it.height
}
- }
- },
- actions = {
- IconButton(
- onClick = onSearchClicked,
- ) {
- Icon(
- imageVector = Icons.Default.Search,
- tint = ElementTheme.materialColors.secondary,
- contentDescription = stringResource(CommonStrings.action_search),
- )
- }
- IconButton(
- onClick = { showMenu = !showMenu }
- ) {
- Icon(
- imageVector = Icons.Default.MoreVert,
- tint = ElementTheme.materialColors.secondary,
- contentDescription = null,
- )
- }
- DropdownMenu(
- expanded = showMenu,
- onDismissRequest = { showMenu = false }
- ) {
- DropdownMenuItem(
- onClick = {
- showMenu = false
- onMenuActionClicked(RoomListMenuAction.InviteFriends)
+ .nestedScroll(scrollBehavior.nestedScrollConnection)
+ .avatarBloom(
+ avatarData = avatarData,
+ background = if (ElementTheme.isLightTheme) {
+ // Workaround to display a very subtle bloom for avatars with very soft colors
+ Color(0xFFF9F9F9)
+ } else {
+ ElementTheme.materialColors.background
},
- text = { Text(stringResource(id = CommonStrings.action_invite)) },
- leadingIcon = {
- Icon(
- Icons.Outlined.Share,
- tint = ElementTheme.materialColors.secondary,
- contentDescription = null,
- )
- }
+ blurSize = DpSize(avatarBloomSize, avatarBloomSize),
+ offset = DpOffset(24.dp, 24.dp + statusBarPadding),
+ clipToSize = if (appBarHeight > 0) DpSize(
+ avatarBloomSize,
+ appBarHeight.toDp()
+ ) else DpSize.Unspecified,
+ bottomSoftEdgeColor = ElementTheme.materialColors.background,
+ bottomSoftEdgeAlpha = 1f - collapsedFraction,
+ alpha = if (areSearchResultsDisplayed) 0f else 1f,
)
- DropdownMenuItem(
- onClick = {
- showMenu = false
- onMenuActionClicked(RoomListMenuAction.ReportBug)
- },
- text = { Text(stringResource(id = CommonStrings.common_report_a_bug)) },
- leadingIcon = {
- Icon(
- Icons.Outlined.BugReport,
- tint = ElementTheme.materialColors.secondary,
- contentDescription = null,
+ .statusBarsPadding(),
+ colors = TopAppBarDefaults.mediumTopAppBarColors(
+ containerColor = Color.Transparent,
+ scrolledContainerColor = Color.Transparent,
+ ),
+ title = {
+ val fontStyle = if (scrollBehavior.state.collapsedFraction > 0.5)
+ ElementTheme.typography.aliasScreenTitle
+ else
+ ElementTheme.typography.fontHeadingLgBold.copy(
+ // Due to a limitation of MediumTopAppBar, and to avoid the text to be truncated,
+ // ensure that the font size will never be bigger than 28.dp.
+ fontSize = 28.dp.applyScaleDown().toSp()
+ )
+ Text(
+ style = fontStyle,
+ text = stringResource(id = R.string.screen_roomlist_main_space_title)
+ )
+ },
+ navigationIcon = {
+ avatarData?.let {
+ IconButton(
+ modifier = Modifier.testTag(TestTags.homeScreenSettings),
+ onClick = onOpenSettings
+ ) {
+ Avatar(
+ avatarData = it,
+ contentDescription = stringResource(CommonStrings.common_settings),
)
}
- )
- }
- },
- scrollBehavior = scrollBehavior,
- windowInsets = WindowInsets(0.dp),
- )
+ }
+ },
+ actions = {
+ IconButton(
+ onClick = onSearchClicked,
+ ) {
+ Icon(
+ resourceId = CommonR.drawable.ic_search,
+ contentDescription = stringResource(CommonStrings.action_search),
+ )
+ }
+ IconButton(
+ onClick = { showMenu = !showMenu }
+ ) {
+ Icon(
+ imageVector = Icons.Default.MoreVert,
+ contentDescription = null,
+ )
+ }
+ DropdownMenu(
+ expanded = showMenu,
+ onDismissRequest = { showMenu = false }
+ ) {
+ DropdownMenuItem(
+ onClick = {
+ showMenu = false
+ onMenuActionClicked(RoomListMenuAction.InviteFriends)
+ },
+ text = { Text(stringResource(id = CommonStrings.action_invite)) },
+ leadingIcon = {
+ Icon(
+ Icons.Outlined.Share,
+ tint = ElementTheme.materialColors.secondary,
+ contentDescription = null,
+ )
+ }
+ )
+ DropdownMenuItem(
+ onClick = {
+ showMenu = false
+ onMenuActionClicked(RoomListMenuAction.ReportBug)
+ },
+ text = { Text(stringResource(id = CommonStrings.common_report_a_bug)) },
+ leadingIcon = {
+ Icon(
+ Icons.Outlined.BugReport,
+ tint = ElementTheme.materialColors.secondary,
+ contentDescription = null,
+ )
+ }
+ )
+ }
+ },
+ scrollBehavior = scrollBehavior,
+ windowInsets = WindowInsets(0.dp),
+ )
+
+ HorizontalDivider(modifier =
+ Modifier.fillMaxWidth()
+ .alpha(collapsedFraction)
+ .align(Alignment.BottomCenter),
+ color = ElementTheme.materialColors.outlineVariant,
+ )
+ }
}
@Preview
@@ -211,6 +283,7 @@ internal fun DefaultRoomListTopBarDarkPreview() = ElementPreviewDark { DefaultRo
private fun DefaultRoomListTopBarPreview() {
DefaultRoomListTopBar(
matrixUser = MatrixUser(UserId("@id:domain"), "Alice"),
+ areSearchResultsDisplayed = false,
scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior(rememberTopAppBarState()),
onOpenSettings = {},
onSearchClicked = {},
diff --git a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/components/RoomSummaryRow.kt b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/components/RoomSummaryRow.kt
index aab174c6a4..251b5608b8 100644
--- a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/components/RoomSummaryRow.kt
+++ b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/components/RoomSummaryRow.kt
@@ -19,6 +19,7 @@ package io.element.android.features.roomlist.impl.components
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.interaction.MutableInteractionSource
+import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.IntrinsicSize
import androidx.compose.foundation.layout.Row
@@ -33,31 +34,31 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
-import androidx.compose.ui.geometry.Rect
-import androidx.compose.ui.geometry.Size
-import androidx.compose.ui.graphics.Outline
-import androidx.compose.ui.graphics.Path
-import androidx.compose.ui.graphics.Shape
+import androidx.compose.ui.graphics.vector.ImageVector
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
-import androidx.compose.ui.unit.Density
-import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp
import io.element.android.features.roomlist.impl.model.RoomListRoomSummary
import io.element.android.features.roomlist.impl.model.RoomListRoomSummaryProvider
import io.element.android.libraries.core.extensions.orEmpty
+import io.element.android.libraries.designsystem.VectorIcons
import io.element.android.libraries.designsystem.atomic.atoms.UnreadIndicatorAtom
import io.element.android.libraries.designsystem.components.avatar.Avatar
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
+import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.theme.roomListRoomMessage
import io.element.android.libraries.designsystem.theme.roomListRoomMessageDate
import io.element.android.libraries.designsystem.theme.roomListRoomName
import io.element.android.libraries.designsystem.theme.unreadIndicator
+import io.element.android.libraries.matrix.api.room.RoomNotificationMode
import io.element.android.libraries.theme.ElementTheme
+import io.element.android.libraries.ui.strings.CommonStrings
internal val minHeight = 84.dp
@@ -168,33 +169,38 @@ private fun RowScope.LastMessageAndIndicatorRow(room: RoomListRoomSummary) {
maxLines = 2,
overflow = TextOverflow.Ellipsis
)
+
// Unread
- UnreadIndicatorAtom(
- modifier = Modifier.padding(top = 3.dp),
- isVisible = room.hasUnread,
- )
-}
+ Row(
+ horizontalArrangement = Arrangement.spacedBy(8.dp)
+ ) {
+ NotificationIcon(room)
+ if (room.hasUnread) {
+ UnreadIndicatorAtom(
+ modifier = Modifier.padding(top = 3.dp),
+ )
+ }
+ }
-val TextPlaceholderShape = PercentRectangleSizeShape(0.5f)
+}
-class PercentRectangleSizeShape(private val percent: Float) : Shape {
- override fun createOutline(
- size: Size,
- layoutDirection: LayoutDirection,
- density: Density
- ): Outline {
- val halfPercent = percent / 2f
- val path = Path().apply {
- val rect = Rect(
- left = 0f,
- top = size.height * halfPercent,
- right = size.width,
- bottom = size.height * (1 - halfPercent)
+@Composable
+private fun NotificationIcon(room: RoomListRoomSummary) {
+ val tint = if(room.hasUnread) ElementTheme.colors.unreadIndicator else ElementTheme.colors.iconQuaternary
+ when(room.notificationMode) {
+ null, RoomNotificationMode.ALL_MESSAGES -> return
+ RoomNotificationMode.MENTIONS_AND_KEYWORDS_ONLY ->
+ Icon(
+ contentDescription = stringResource(CommonStrings.screen_notification_settings_mode_mentions),
+ imageVector = ImageVector.vectorResource(VectorIcons.Mention),
+ tint = tint,
+ )
+ RoomNotificationMode.MUTE ->
+ Icon(
+ contentDescription = stringResource(CommonStrings.common_mute),
+ imageVector = ImageVector.vectorResource(VectorIcons.Mute),
+ tint = tint,
)
- addRect(rect)
- close()
- }
- return Outline.Generic(path)
}
}
diff --git a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/datasource/RoomListDataSource.kt b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/datasource/RoomListDataSource.kt
index e44bcd6b6b..6a9c152af6 100644
--- a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/datasource/RoomListDataSource.kt
+++ b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/datasource/RoomListDataSource.kt
@@ -27,28 +27,37 @@ import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.eventformatter.api.RoomLastMessageFormatter
import io.element.android.libraries.matrix.api.core.RoomId
+import io.element.android.libraries.matrix.api.notificationsettings.NotificationSettingsService
import io.element.android.libraries.matrix.api.roomlist.RoomListService
import io.element.android.libraries.matrix.api.roomlist.RoomSummary
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
+import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import javax.inject.Inject
+import kotlin.time.Duration.Companion.seconds
class RoomListDataSource @Inject constructor(
private val roomListService: RoomListService,
private val lastMessageTimestampFormatter: LastMessageTimestampFormatter,
private val roomLastMessageFormatter: RoomLastMessageFormatter,
private val coroutineDispatchers: CoroutineDispatchers,
+ private val notificationSettingsService: NotificationSettingsService,
+ private val appScope: CoroutineScope,
) {
+ init {
+ observeNotificationSettings()
+ }
private val _filter = MutableStateFlow("")
private val _allRooms = MutableStateFlow>(persistentListOf())
@@ -92,6 +101,16 @@ class RoomListDataSource @Inject constructor(
val allRooms: StateFlow> = _allRooms
val filteredRooms: StateFlow> = _filteredRooms
+ @OptIn(FlowPreview::class)
+ private fun observeNotificationSettings() {
+ notificationSettingsService.notificationSettingsChangeFlow
+ .debounce(0.5.seconds)
+ .onEach {
+ roomListService.rebuildRoomSummaries()
+ }
+ .launchIn(appScope)
+ }
+
private suspend fun replaceWith(roomSummaries: List) = withContext(coroutineDispatchers.computation) {
lock.withLock {
diffCacheUpdater.updateWith(roomSummaries)
@@ -120,10 +139,7 @@ class RoomListDataSource @Inject constructor(
}
}
- private fun buildAndCacheItem(
- roomSummaries: List,
- index: Int
- ): RoomListRoomSummary? {
+ private fun buildAndCacheItem(roomSummaries: List, index: Int): RoomListRoomSummary? {
val roomListRoomSummary = when (val roomSummary = roomSummaries.getOrNull(index)) {
is RoomSummary.Empty -> RoomListRoomSummaryPlaceholders.create(roomSummary.identifier)
is RoomSummary.Filled -> {
@@ -144,10 +160,12 @@ class RoomListDataSource @Inject constructor(
roomLastMessageFormatter.format(message.event, roomSummary.details.isDirect)
}.orEmpty(),
avatarData = avatarData,
+ notificationMode = roomSummary.details.notificationMode,
)
}
null -> null
}
+
diffCache[index] = roomListRoomSummary
return roomListRoomSummary
}
diff --git a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/model/RoomListRoomSummary.kt b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/model/RoomListRoomSummary.kt
index 8ba6c26c0f..bb3405c2d3 100644
--- a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/model/RoomListRoomSummary.kt
+++ b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/model/RoomListRoomSummary.kt
@@ -20,6 +20,7 @@ import androidx.compose.runtime.Immutable
import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.matrix.api.core.RoomId
+import io.element.android.libraries.matrix.api.room.RoomNotificationMode
@Immutable
data class RoomListRoomSummary constructor(
@@ -31,4 +32,5 @@ data class RoomListRoomSummary constructor(
val lastMessage: CharSequence? = null,
val avatarData: AvatarData = AvatarData(id, name, size = AvatarSize.RoomListItem),
val isPlaceholder: Boolean = false,
+ val notificationMode: RoomNotificationMode? = null,
)
diff --git a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/model/RoomListRoomSummaryProvider.kt b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/model/RoomListRoomSummaryProvider.kt
index cd6ca21106..f2db8a376c 100644
--- a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/model/RoomListRoomSummaryProvider.kt
+++ b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/model/RoomListRoomSummaryProvider.kt
@@ -20,14 +20,16 @@ import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.matrix.api.core.RoomId
+import io.element.android.libraries.matrix.api.room.RoomNotificationMode
open class RoomListRoomSummaryProvider : PreviewParameterProvider {
override val values: Sequence
get() = sequenceOf(
aRoomListRoomSummary(),
aRoomListRoomSummary().copy(lastMessage = null),
- aRoomListRoomSummary().copy(hasUnread = true),
- aRoomListRoomSummary().copy(timestamp = "88:88"),
+ aRoomListRoomSummary().copy(hasUnread = true, notificationMode = RoomNotificationMode.MENTIONS_AND_KEYWORDS_ONLY),
+ aRoomListRoomSummary().copy(timestamp = "88:88", notificationMode = RoomNotificationMode.MENTIONS_AND_KEYWORDS_ONLY),
+ aRoomListRoomSummary().copy(timestamp = "88:88", notificationMode = RoomNotificationMode.MUTE),
aRoomListRoomSummary().copy(timestamp = "88:88", hasUnread = true),
aRoomListRoomSummary().copy(isPlaceholder = true, timestamp = "88:88"),
aRoomListRoomSummary().copy(
diff --git a/features/roomlist/impl/src/main/res/values-de/translations.xml b/features/roomlist/impl/src/main/res/values-de/translations.xml
index 49a400b138..c24d2146a4 100644
--- a/features/roomlist/impl/src/main/res/values-de/translations.xml
+++ b/features/roomlist/impl/src/main/res/values-de/translations.xml
@@ -1,9 +1,9 @@
- "Ein neues Gespräch oder einen neuen Raum erstellen"
- "Beginnen, indem du jemandem eine Nachricht sendest."
+ "Eine neue Unterhaltung oder einen neuen Raum erstellen"
+ "Beginne, indem du jemandem eine Nachricht sendest."
"Noch keine Chats."
"Alle Chats"
- "Es sieht so aus, als ob du ein neues Gerät verwendest. Verifiziere, dass du es bist, um auf deine verschlüsselten Nachrichten zuzugreifen."
- "Verifiziere, dass du es bist"
+ "Es sieht aus, als würden Sie ein neues Gerät verwenden. Verifizieren Sie es mit einem anderen Gerät, damit Sie auf Ihre verschlüsselten Nachrichten zugreifen können."
+ "Bestätigen Sie Ihre Identität"
diff --git a/features/roomlist/impl/src/main/res/values-fr/translations.xml b/features/roomlist/impl/src/main/res/values-fr/translations.xml
index a5217e1ad5..2a14d1f4f4 100644
--- a/features/roomlist/impl/src/main/res/values-fr/translations.xml
+++ b/features/roomlist/impl/src/main/res/values-fr/translations.xml
@@ -1,7 +1,9 @@
- "Créer une nouvelle conversation ou un nouveau salon"
- "Tous les chats"
- "Il semblerait que vous utilisiez un nouvel appareil. Lancez la vérification avec un autre appareil pour accéder à vos messages chiffrés à l’avenir."
+ "Créer une nouvelle discussion ou un nouveau salon"
+ "Commencez par envoyer un message à quelqu’un."
+ "Aucune discussion pour le moment."
+ "Conversations"
+ "Il semblerait que vous utilisiez un nouvel appareil. Vérifiez la session avec un autre de vos appareils pour accéder à vos messages chiffrés."
"Vérifier que c’est bien vous"
diff --git a/features/roomlist/impl/src/main/res/values-ro/translations.xml b/features/roomlist/impl/src/main/res/values-ro/translations.xml
index b8ffc57090..e1f43a02ca 100644
--- a/features/roomlist/impl/src/main/res/values-ro/translations.xml
+++ b/features/roomlist/impl/src/main/res/values-ro/translations.xml
@@ -1,7 +1,9 @@
"Creați o conversație sau o cameră nouă"
+ "Începeți prin a trimite mesaje cuiva."
+ "Nu există încă discuții."
"Toate conversatiile"
- "Se pare că folosiți un dispozitiv nou. Verificați-vă identitatea pentru acces la mesajele dumneavoastră criptate."
- "Accesați istoricul mesajelor"
+ "Se pare că folosiți un dispozitiv nou. Verificați-vă identitatea cu un alt dispozitiv pentru a accesa mesajele dumneavoastră criptate."
+ "Verificați că sunteți dumneavoastră"
diff --git a/features/roomlist/impl/src/test/kotlin/io/element/android/features/roomlist/impl/RoomListPresenterTests.kt b/features/roomlist/impl/src/test/kotlin/io/element/android/features/roomlist/impl/RoomListPresenterTests.kt
index d8524d5834..db2f7027c4 100644
--- a/features/roomlist/impl/src/test/kotlin/io/element/android/features/roomlist/impl/RoomListPresenterTests.kt
+++ b/features/roomlist/impl/src/test/kotlin/io/element/android/features/roomlist/impl/RoomListPresenterTests.kt
@@ -38,6 +38,7 @@ import io.element.android.libraries.designsystem.utils.SnackbarDispatcher
import io.element.android.libraries.eventformatter.api.RoomLastMessageFormatter
import io.element.android.libraries.eventformatter.test.FakeRoomLastMessageFormatter
import io.element.android.libraries.matrix.api.MatrixClient
+import io.element.android.libraries.matrix.api.room.RoomNotificationMode
import io.element.android.libraries.matrix.api.verification.SessionVerificationService
import io.element.android.libraries.matrix.api.verification.SessionVerifiedStatus
import io.element.android.libraries.matrix.test.AN_AVATAR_URL
@@ -47,21 +48,31 @@ import io.element.android.libraries.matrix.test.A_ROOM_NAME
import io.element.android.libraries.matrix.test.A_USER_ID
import io.element.android.libraries.matrix.test.A_USER_NAME
import io.element.android.libraries.matrix.test.FakeMatrixClient
+import io.element.android.libraries.matrix.test.notificationsettings.FakeNotificationSettingsService
import io.element.android.libraries.matrix.test.room.aRoomSummaryFilled
import io.element.android.libraries.matrix.test.roomlist.FakeRoomListService
import io.element.android.libraries.matrix.test.verification.FakeSessionVerificationService
+import io.element.android.tests.testutils.WarmUpRule
import io.element.android.tests.testutils.consumeItemsUntilPredicate
import io.element.android.tests.testutils.testCoroutineDispatchers
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.SupervisorJob
+import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.runTest
+import org.junit.Rule
import org.junit.Test
class RoomListPresenterTests {
+ @get:Rule
+ val warmUpRule = WarmUpRule()
+
@Test
fun `present - should start with no user and then load user with success`() = runTest {
- val presenter = createRoomListPresenter()
+ val scope = CoroutineScope(coroutineContext + SupervisorJob())
+ val presenter = createRoomListPresenter(coroutineScope = scope)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
@@ -72,6 +83,7 @@ class RoomListPresenterTests {
Truth.assertThat(withUserState.matrixUser!!.userId).isEqualTo(A_USER_ID)
Truth.assertThat(withUserState.matrixUser!!.displayName).isEqualTo(A_USER_NAME)
Truth.assertThat(withUserState.matrixUser!!.avatarUrl).isEqualTo(AN_AVATAR_URL)
+ scope.cancel()
}
}
@@ -81,7 +93,8 @@ class RoomListPresenterTests {
userDisplayName = Result.failure(AN_EXCEPTION),
userAvatarURLString = Result.failure(AN_EXCEPTION),
)
- val presenter = createRoomListPresenter(matrixClient)
+ val scope = CoroutineScope(coroutineContext + SupervisorJob())
+ val presenter = createRoomListPresenter(client = matrixClient, coroutineScope = scope)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
@@ -89,12 +102,14 @@ class RoomListPresenterTests {
Truth.assertThat(initialState.matrixUser).isNull()
val withUserState = awaitItem()
Truth.assertThat(withUserState.matrixUser).isNotNull()
+ scope.cancel()
}
}
@Test
fun `present - should filter room with success`() = runTest {
- val presenter = createRoomListPresenter()
+ val scope = CoroutineScope(coroutineContext + SupervisorJob())
+ val presenter = createRoomListPresenter(coroutineScope = scope)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
@@ -104,8 +119,8 @@ class RoomListPresenterTests {
withUserState.eventSink.invoke(RoomListEvents.UpdateFilter("t"))
val withFilterState = awaitItem()
Truth.assertThat(withFilterState.filter).isEqualTo("t")
-
cancelAndIgnoreRemainingEvents()
+ scope.cancel()
}
}
@@ -115,7 +130,8 @@ class RoomListPresenterTests {
val matrixClient = FakeMatrixClient(
roomListService = roomListService
)
- val presenter = createRoomListPresenter(matrixClient)
+ val scope = CoroutineScope(coroutineContext + SupervisorJob())
+ val presenter = createRoomListPresenter(client = matrixClient, coroutineScope = scope)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
@@ -128,6 +144,7 @@ class RoomListPresenterTests {
Truth.assertThat(withRoomState.roomList.size).isEqualTo(1)
Truth.assertThat(withRoomState.roomList.first())
.isEqualTo(aRoomListRoomSummary)
+ scope.cancel()
}
}
@@ -137,7 +154,8 @@ class RoomListPresenterTests {
val matrixClient = FakeMatrixClient(
roomListService = roomListService
)
- val presenter = createRoomListPresenter(matrixClient)
+ val scope = CoroutineScope(coroutineContext + SupervisorJob())
+ val presenter = createRoomListPresenter(client = matrixClient, coroutineScope = scope)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
@@ -155,6 +173,7 @@ class RoomListPresenterTests {
val withNotFilteredRoomState = consumeItemsUntilPredicate { state -> state.filteredRoomList.size == 0 }.last()
Truth.assertThat(withNotFilteredRoomState.filter).isEqualTo("tada")
Truth.assertThat(withNotFilteredRoomState.filteredRoomList).isEmpty()
+ scope.cancel()
}
}
@@ -164,7 +183,8 @@ class RoomListPresenterTests {
val matrixClient = FakeMatrixClient(
roomListService = roomListService
)
- val presenter = createRoomListPresenter(matrixClient)
+ val scope = CoroutineScope(coroutineContext + SupervisorJob())
+ val presenter = createRoomListPresenter(client = matrixClient, coroutineScope = scope)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
@@ -195,6 +215,7 @@ class RoomListPresenterTests {
Truth.assertThat(roomListService.latestSlidingSyncRange)
.isEqualTo(IntRange(129, 279))
cancelAndIgnoreRemainingEvents()
+ scope.cancel()
}
}
@@ -204,12 +225,14 @@ class RoomListPresenterTests {
val matrixClient = FakeMatrixClient(
roomListService = roomListService,
)
+ val scope = CoroutineScope(context = coroutineContext + SupervisorJob())
val presenter = createRoomListPresenter(
client = matrixClient,
sessionVerificationService = FakeSessionVerificationService().apply {
givenIsReady(true)
givenVerifiedStatus(SessionVerifiedStatus.NotVerified)
},
+ coroutineScope = scope,
)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
@@ -219,6 +242,7 @@ class RoomListPresenterTests {
eventSink(RoomListEvents.DismissRequestVerificationPrompt)
Truth.assertThat(awaitItem().displayVerificationPrompt).isFalse()
+ scope.cancel()
}
}
@@ -226,7 +250,8 @@ class RoomListPresenterTests {
fun `present - sets invite state`() = runTest {
val inviteStateFlow = MutableStateFlow(InvitesState.NoInvites)
val inviteStateDataSource = FakeInviteDataSource(inviteStateFlow)
- val presenter = createRoomListPresenter(inviteStateDataSource = inviteStateDataSource)
+ val scope = CoroutineScope(coroutineContext + SupervisorJob())
+ val presenter = createRoomListPresenter(inviteStateDataSource = inviteStateDataSource, coroutineScope = scope)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
@@ -241,12 +266,14 @@ class RoomListPresenterTests {
inviteStateFlow.value = InvitesState.NoInvites
Truth.assertThat(awaitItem().invitesState).isEqualTo(InvitesState.NoInvites)
+ scope.cancel()
}
}
@Test
fun `present - show context menu`() = runTest {
- val presenter = createRoomListPresenter()
+ val scope = CoroutineScope(coroutineContext + SupervisorJob())
+ val presenter = createRoomListPresenter(coroutineScope = scope)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
@@ -259,12 +286,14 @@ class RoomListPresenterTests {
val shownState = awaitItem()
Truth.assertThat(shownState.contextMenu)
.isEqualTo(RoomListState.ContextMenu.Shown(summary.roomId, summary.name))
+ scope.cancel()
}
}
@Test
fun `present - hide context menu`() = runTest {
- val presenter = createRoomListPresenter()
+ val scope = CoroutineScope(coroutineContext + SupervisorJob())
+ val presenter = createRoomListPresenter(coroutineScope = scope)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
@@ -281,13 +310,15 @@ class RoomListPresenterTests {
val hiddenState = awaitItem()
Truth.assertThat(hiddenState.contextMenu).isEqualTo(RoomListState.ContextMenu.Hidden)
+ scope.cancel()
}
}
@Test
fun `present - leave room calls into leave room presenter`() = runTest {
val leaveRoomPresenter = LeaveRoomPresenterFake()
- val presenter = createRoomListPresenter(leaveRoomPresenter = leaveRoomPresenter)
+ val scope = CoroutineScope(coroutineContext + SupervisorJob())
+ val presenter = createRoomListPresenter(leaveRoomPresenter = leaveRoomPresenter, coroutineScope = scope)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
@@ -295,6 +326,35 @@ class RoomListPresenterTests {
initialState.eventSink(RoomListEvents.LeaveRoom(A_ROOM_ID))
Truth.assertThat(leaveRoomPresenter.events).containsExactly(LeaveRoomEvent.ShowConfirmation(A_ROOM_ID))
cancelAndIgnoreRemainingEvents()
+ scope.cancel()
+ }
+ }
+
+ @Test
+ fun `present - change in notification settings updates the summary for decorations`() = runTest {
+ val userDefinedMode = RoomNotificationMode.MENTIONS_AND_KEYWORDS_ONLY
+ val notificationSettingsService = FakeNotificationSettingsService()
+ val roomListService = FakeRoomListService()
+ roomListService.postAllRooms(listOf(aRoomSummaryFilled(notificationMode = userDefinedMode)))
+ val matrixClient = FakeMatrixClient(
+ roomListService = roomListService,
+ notificationSettingsService = notificationSettingsService
+ )
+ val scope = CoroutineScope(coroutineContext + SupervisorJob())
+ val presenter = createRoomListPresenter(client = matrixClient , coroutineScope = scope)
+ moleculeFlow(RecompositionMode.Immediate) {
+ presenter.present()
+ }.test {
+ notificationSettingsService.setRoomNotificationMode(A_ROOM_ID, userDefinedMode)
+
+ val updatedState = consumeItemsUntilPredicate { state ->
+ state.roomList.any { it.id == A_ROOM_ID.value && it.notificationMode == userDefinedMode }
+ }.last()
+
+ val room = updatedState.roomList.find { it.id == A_ROOM_ID.value }
+ Truth.assertThat(room?.notificationMode).isEqualTo(userDefinedMode)
+ cancelAndIgnoreRemainingEvents()
+ scope.cancel()
}
}
@@ -308,7 +368,8 @@ class RoomListPresenterTests {
lastMessageTimestampFormatter: LastMessageTimestampFormatter = FakeLastMessageTimestampFormatter().apply {
givenFormat(A_FORMATTED_DATE)
},
- roomLastMessageFormatter: RoomLastMessageFormatter = FakeRoomLastMessageFormatter()
+ roomLastMessageFormatter: RoomLastMessageFormatter = FakeRoomLastMessageFormatter(),
+ coroutineScope: CoroutineScope = this,
) = RoomListPresenter(
client = client,
sessionVerificationService = sessionVerificationService,
@@ -320,7 +381,9 @@ class RoomListPresenterTests {
client.roomListService,
lastMessageTimestampFormatter,
roomLastMessageFormatter,
- coroutineDispatchers = testCoroutineDispatchers()
+ coroutineDispatchers = testCoroutineDispatchers(),
+ notificationSettingsService = client.notificationSettingsService(),
+ appScope = coroutineScope
)
)
}
diff --git a/features/verifysession/impl/build.gradle.kts b/features/verifysession/impl/build.gradle.kts
index cba9f2890e..a85d714d1e 100644
--- a/features/verifysession/impl/build.gradle.kts
+++ b/features/verifysession/impl/build.gradle.kts
@@ -47,6 +47,7 @@ dependencies {
testImplementation(libs.test.truth)
testImplementation(libs.test.turbine)
testImplementation(projects.libraries.matrix.test)
+ testImplementation(projects.tests.testutils)
ksp(libs.showkase.processor)
}
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 f5f149cfd9..5b4850ae57 100644
--- a/features/verifysession/impl/src/main/res/values-de/translations.xml
+++ b/features/verifysession/impl/src/main/res/values-de/translations.xml
@@ -1,19 +1,19 @@
- "Etwas scheint nicht zu stimmen. Entweder ist die Antwortzeit für die Anfrage abgelaufen oder die Anfrage wurde abgelehnt."
- "Bestätige, dass die folgenden Emojis mit denen deiner anderen Sitzung übereinstimmen."
+ "Etwas scheint nicht zu stimmen. Entweder ist das Zeitlimit für die Anfrage abgelaufen oder die Anfrage wurde abgelehnt."
+ "Vergewissern Sie sich, dass die folgenden Emojis mit denen in Ihrer anderen Session übereinstimmen."
"Emojis vergleichen"
- "Deine neue Sitzung ist jetzt verifiziert. Sie hat Zugriff auf deine verschlüsselten Nachrichten und andere Benutzer werden sie als vertrauenswürdig sehen."
- "Beweise, dass du es bist, um auf deinen verschlüsselten Nachrichtenverlauf zuzugreifen."
- "Eine bestehende Sitzung öffnen"
- "Verifizierung erneut versuchen"
+ "Ihre neue Session ist nun verifiziert. Sie hat Zugriff auf Ihre verschlüsselten Nachrichten und wird von anderen Benutzern als vertrauenswürdig eingestuft."
+ "Beweisen Sie Ihre Identität, um auf Ihren verschlüsselten Nachrichtenverlauf zuzugreifen."
+ "Öffnen Sie eine bestehende Sitzung"
+ "Verifizierung wiederholen"
"Ich bin bereit"
- "Warten auf Übereinstimmung"
- "Vergleiche die einzigartigen Emojis und achte darauf, dass sie in derselben Reihenfolge erscheinen."
+ "Warten auf eine Übereinstimmung"
+ "Vergleichen Sie die einzelnen Emojis und stellen Sie sicher, dass sie in der gleichen Reihenfolge erscheinen."
"Sie stimmen nicht überein"
"Sie stimmen überein"
- "Akzeptiere die Aufforderung zum Starten des Verifizierungsprozesses in deiner anderen Sitzung, um fortzufahren."
+ "Akzeptieren Sie die Anfrage, um den Verifizierungsprozess in Ihrer anderen Session zu starten, um fortzufahren."
"Warten auf die Annahme der Anfrage"
"Verifizierung abgebrochen"
- "Starten"
+ "Start"
diff --git a/features/verifysession/impl/src/main/res/values-fr/translations.xml b/features/verifysession/impl/src/main/res/values-fr/translations.xml
index dd0bb56708..9339d6c760 100644
--- a/features/verifysession/impl/src/main/res/values-fr/translations.xml
+++ b/features/verifysession/impl/src/main/res/values-fr/translations.xml
@@ -1,19 +1,19 @@
- "Quelque chose ne semble pas normal. Soit la demande a dépassé le temps imparti, soit elle a été refusée."
+ "Quelque chose ne va pas. Soit la demande a expiré, soit elle a été refusée."
"Confirmez que les emojis ci-dessous correspondent à ceux affichés sur votre autre session."
"Comparez les émojis"
"Votre nouvelle session est désormais vérifiée. Elle a accès à vos messages chiffrés et les autres utilisateurs la verront identifiée comme fiable."
- "Prouvez qu\'il s\'agit bien de vous pour accéder à l\'historique de vos messages chiffrés."
+ "Prouvez qu’il s’agit bien de vous pour accéder à l’historique de vos messages chiffrés."
"Ouvrir une session existante"
"Réessayer la vérification"
"Je suis prêt.e"
"En attente de correspondance"
- "Comparez les emoji uniques en veillant à ce qu\'ils apparaissent dans le même ordre."
+ "Comparez les emoji uniques en veillant à ce qu’ils apparaissent dans le même ordre."
"Ils ne correspondent pas"
"Ils correspondent"
"Pour continuer, acceptez la demande de lancement de la procédure de vérification dans votre autre session."
- "En attente d\'acceptation de la demande"
+ "En attente d’acceptation de la demande"
"Vérification annulée"
"Démarrer"
diff --git a/features/verifysession/impl/src/test/kotlin/io/element/android/features/verifysession/impl/VerifySelfSessionPresenterTests.kt b/features/verifysession/impl/src/test/kotlin/io/element/android/features/verifysession/impl/VerifySelfSessionPresenterTests.kt
index 82664f0e03..eee6c51a07 100644
--- a/features/verifysession/impl/src/test/kotlin/io/element/android/features/verifysession/impl/VerifySelfSessionPresenterTests.kt
+++ b/features/verifysession/impl/src/test/kotlin/io/element/android/features/verifysession/impl/VerifySelfSessionPresenterTests.kt
@@ -26,13 +26,18 @@ import io.element.android.libraries.architecture.Async
import io.element.android.libraries.matrix.api.verification.VerificationEmoji
import io.element.android.libraries.matrix.api.verification.VerificationFlowState
import io.element.android.libraries.matrix.test.verification.FakeSessionVerificationService
+import io.element.android.tests.testutils.WarmUpRule
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.runTest
+import org.junit.Rule
import org.junit.Test
@ExperimentalCoroutinesApi
class VerifySelfSessionPresenterTests {
+ @get:Rule
+ val warmUpRule = WarmUpRule()
+
@Test
fun `present - Initial state is received`() = runTest {
val presenter = createPresenter()
diff --git a/gradle.properties b/gradle.properties
index 6f311e45ef..9847be0949 100644
--- a/gradle.properties
+++ b/gradle.properties
@@ -56,6 +56,3 @@ android.experimental.enableTestFixtures=true
# Create BuildConfig files as bytecode to avoid Java compilation phase
android.enableBuildConfigAsBytecode=true
-
-# This should be removed after upgrading to AGP 8.1.0
-android.suppressUnsupportedCompileSdk=34
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index 841e53b75d..67294eeb7a 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -6,23 +6,23 @@
android_gradle_plugin = "8.1.1"
kotlin = "1.9.10"
ksp = "1.9.10-1.0.13"
-molecule = "1.2.0"
+molecule = "1.2.1"
# AndroidX
material = "1.9.0"
-core = "1.10.1"
+core = "1.12.0"
datastore = "1.0.0"
constraintlayout = "2.1.4"
constraintlayout_compose = "1.0.1"
recyclerview = "1.3.1"
-lifecycle = "2.6.1"
+lifecycle = "2.6.2"
activity = "1.7.2"
startup = "1.1.1"
media3 = "1.1.1"
browser = "1.6.0"
# Compose
-compose_bom = "2023.08.00"
+compose_bom = "2023.09.00"
composecompiler = "1.5.3"
# Coroutines
@@ -42,14 +42,15 @@ showkase = "1.0.0-beta18"
jsoup = "1.16.1"
appyx = "1.3.0"
dependencycheck = "8.4.0"
-dependencyanalysis = "1.21.0"
+dependencyanalysis = "1.22.0"
stem = "2.3.0"
sqldelight = "1.5.5"
-telephoto = "0.6.0-SNAPSHOT"
+telephoto = "0.6.0"
+wysiwyg = "2.10.0"
# DI
dagger = "2.48"
-anvil = "2.4.7-1-8"
+anvil = "2.4.8-1-8"
# Auto service
autoservice = "1.1.1"
@@ -65,7 +66,7 @@ android_gradle_plugin = { module = "com.android.tools.build:gradle", version.ref
android_desugar = "com.android.tools:desugar_jdk_libs:2.0.3"
kotlin_gradle_plugin = { module = "org.jetbrains.kotlin:kotlin-gradle-plugin", version.ref = "kotlin" }
# https://firebase.google.com/docs/android/setup#available-libraries
-google_firebase_bom = "com.google.firebase:firebase-bom:32.2.3"
+google_firebase_bom = "com.google.firebase:firebase-bom:32.3.1"
# AndroidX
androidx_material = { module = "com.google.android.material:material", version.ref = "material" }
@@ -90,8 +91,11 @@ androidx_activity_activity = { module = "androidx.activity:activity", version.re
androidx_activity_compose = { module = "androidx.activity:activity-compose", version.ref = "activity" }
androidx_startup = { module = "androidx.startup:startup-runtime", version.ref = "startup" }
androidx_preference = "androidx.preference:preference:1.2.1"
+androidx_webkit = "androidx.webkit:webkit:1.8.0"
androidx_compose_bom = { group = "androidx.compose", name = "compose-bom", version.ref = "compose_bom" }
+# Warning: issue on alpha07, make sure this is working when upgrading
+# Context in https://github.com/vector-im/element-x-android/pull/1239#issuecomment-1711500332
androidx_compose_material3 = "androidx.compose.material3:material3:1.2.0-alpha06"
# Coroutines
@@ -146,7 +150,9 @@ jsoup = { module = "org.jsoup:jsoup", version.ref = "jsoup" }
appyx_core = { module = "com.bumble.appyx:core", version.ref = "appyx" }
molecule-runtime = { module = "app.cash.molecule:molecule-runtime", version.ref = "molecule" }
timber = "com.jakewharton.timber:timber:5.0.1"
-matrix_sdk = "org.matrix.rustcomponents:sdk-android:0.1.48"
+matrix_sdk = "org.matrix.rustcomponents:sdk-android:0.1.54"
+matrix_richtexteditor = { module = "io.element.android:wysiwyg", version.ref = "wysiwyg" }
+matrix_richtexteditor_compose = { module = "io.element.android:wysiwyg-compose", version.ref = "wysiwyg" }
sqldelight-driver-android = { module = "com.squareup.sqldelight:android-driver", version.ref = "sqldelight" }
sqldelight-driver-jvm = { module = "com.squareup.sqldelight:sqlite-driver", version.ref = "sqldelight" }
sqldelight-coroutines = { module = "com.squareup.sqldelight:coroutines-extensions", version.ref = "sqldelight" }
@@ -163,8 +169,8 @@ maplibre_annotation = "org.maplibre.gl:android-plugin-annotation-v9:2.0.1"
# Analytics
posthog = "com.posthog.android:posthog:2.0.3"
-sentry = "io.sentry:sentry-android:6.28.0"
-matrix_analytics_events = "com.github.matrix-org:matrix-analytics-events:42b2faa417c1e95f430bf8f6e379adba25ad5ef8"
+sentry = "io.sentry:sentry-android:6.29.0"
+matrix_analytics_events = "com.github.matrix-org:matrix-analytics-events:e9cd9adaf18cec52ed851395eb84358b4f9b8d7f"
# Emojibase
matrix_emojibase_bindings = "io.element.android:emojibase-bindings:1.1.3"
@@ -203,6 +209,9 @@ dependencygraph = { id = "com.savvasdalkitsis.module-dependency-graph", version.
dependencycheck = { id = "org.owasp.dependencycheck", version.ref = "dependencycheck" }
dependencyanalysis = { id = "com.autonomousapps.dependency-analysis", version.ref = "dependencyanalysis" }
paparazzi = "app.cash.paparazzi:1.3.1"
-sonarqube = "org.sonarqube:4.3.1.3277"
kover = "org.jetbrains.kotlinx.kover:0.6.1"
sqldelight = { id = "com.squareup.sqldelight", version.ref = "sqldelight" }
+
+# Version '4.3.1.3277' introduced some regressions in CI time (more than 2x slower), so make sure
+# this is no longer the case before upgrading.
+sonarqube = "org.sonarqube:4.2.1.3168"
diff --git a/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/file/File.kt b/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/file/File.kt
index ea214ff683..d7628202c0 100644
--- a/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/file/File.kt
+++ b/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/file/File.kt
@@ -25,6 +25,7 @@ import java.util.Locale
import java.util.UUID
fun File.safeDelete() {
+ if (exists().not()) return
tryOrNull(
onError = {
Timber.e(it, "Error, unable to delete file $path")
diff --git a/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/system/SystemUtils.kt b/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/system/SystemUtils.kt
index a9a17dcceb..1aee986d32 100644
--- a/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/system/SystemUtils.kt
+++ b/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/system/SystemUtils.kt
@@ -122,7 +122,7 @@ fun Context.copyToClipboard(
* Shows notification settings for the current app.
* In android O will directly opens the notification settings, in lower version it will show the App settings
*/
-fun Context.startNotificationSettingsIntent(activityResultLauncher: ActivityResultLauncher) {
+fun Context.startNotificationSettingsIntent(activityResultLauncher: ActivityResultLauncher? = null) {
val intent = Intent()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
intent.action = Settings.ACTION_APP_NOTIFICATION_SETTINGS
@@ -132,7 +132,12 @@ fun Context.startNotificationSettingsIntent(activityResultLauncher: ActivityResu
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
intent.data = Uri.fromParts("package", packageName, null)
}
- activityResultLauncher.launch(intent)
+
+ if (activityResultLauncher != null) {
+ activityResultLauncher.launch(intent)
+ } else {
+ startActivity(intent)
+ }
}
fun Context.openAppSettingsPage(
diff --git a/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/ui/View.kt b/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/ui/View.kt
index 0639f29d1f..8bbaf1e5c3 100644
--- a/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/ui/View.kt
+++ b/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/ui/View.kt
@@ -17,8 +17,11 @@
package io.element.android.libraries.androidutils.ui
import android.view.View
+import android.view.ViewTreeObserver
import android.view.inputmethod.InputMethodManager
import androidx.core.content.getSystemService
+import kotlinx.coroutines.suspendCancellableCoroutine
+import kotlin.coroutines.resume
fun View.hideKeyboard() {
val imm = context?.getSystemService()
@@ -41,3 +44,24 @@ fun View.setHorizontalPadding(padding: Int) {
paddingBottom
)
}
+
+suspend fun View.awaitWindowFocus() = suspendCancellableCoroutine { continuation ->
+ if (hasWindowFocus()) {
+ continuation.resume(Unit)
+ } else {
+ val listener = object : ViewTreeObserver.OnWindowFocusChangeListener {
+ override fun onWindowFocusChanged(hasFocus: Boolean) {
+ if (hasFocus) {
+ viewTreeObserver.removeOnWindowFocusChangeListener(this)
+ continuation.resume(Unit)
+ }
+ }
+ }
+
+ viewTreeObserver.addOnWindowFocusChangeListener(listener)
+
+ continuation.invokeOnCancellation {
+ viewTreeObserver.removeOnWindowFocusChangeListener(listener)
+ }
+ }
+}
diff --git a/libraries/androidutils/src/main/res/values-de/translations.xml b/libraries/androidutils/src/main/res/values-de/translations.xml
index d30d83f831..99b7294eb5 100644
--- a/libraries/androidutils/src/main/res/values-de/translations.xml
+++ b/libraries/androidutils/src/main/res/values-de/translations.xml
@@ -1,4 +1,4 @@
- "Keine kompatible App für diese Aktion gefunden."
+ "Für diese Aktion wurde keine kompatible App gefunden."
diff --git a/libraries/androidutils/src/main/res/values-fr/translations.xml b/libraries/androidutils/src/main/res/values-fr/translations.xml
index b974766fce..a575424b39 100644
--- a/libraries/androidutils/src/main/res/values-fr/translations.xml
+++ b/libraries/androidutils/src/main/res/values-fr/translations.xml
@@ -1,4 +1,4 @@
- "Aucune application compatible n\'a été trouvée pour gérer cette action."
+ "Aucune application compatible n’a été trouvée pour gérer cette action."
diff --git a/libraries/core/src/main/kotlin/io/element/android/libraries/core/log/logger/LoggerTag.kt b/libraries/core/src/main/kotlin/io/element/android/libraries/core/log/logger/LoggerTag.kt
index 2c9add5e8d..1bec5524cd 100644
--- a/libraries/core/src/main/kotlin/io/element/android/libraries/core/log/logger/LoggerTag.kt
+++ b/libraries/core/src/main/kotlin/io/element/android/libraries/core/log/logger/LoggerTag.kt
@@ -24,10 +24,8 @@ package io.element.android.libraries.core.log.logger
*/
open class LoggerTag(name: String, parentTag: LoggerTag? = null) {
- object SYNC : LoggerTag("SYNC")
- object VOIP : LoggerTag("VOIP")
- object CRYPTO : LoggerTag("CRYPTO")
- object RENDEZVOUS : LoggerTag("RZ")
+ object PushLoggerTag : LoggerTag("Push")
+ object NotificationLoggerTag : LoggerTag("Notification", PushLoggerTag)
val value: String = if (parentTag == null) {
name
diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/VectorIcons.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/VectorIcons.kt
index 4f06b3ebcb..919c505439 100644
--- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/VectorIcons.kt
+++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/VectorIcons.kt
@@ -27,5 +27,23 @@ object VectorIcons {
val ReportContent = R.drawable.ic_report_content
val Groups = R.drawable.ic_groups
val Share = R.drawable.ic_share
- val EndPoll = R.drawable.ic_poll_end
+ val Poll = R.drawable.ic_poll
+ val PollEnd = R.drawable.ic_poll_end
+ val Bold = R.drawable.ic_bold
+ val BulletList = R.drawable.ic_bullet_list
+ val CodeBlock = R.drawable.ic_code_block
+ val IndentIncrease = R.drawable.ic_indent_increase
+ val IndentDecrease = R.drawable.ic_indent_decrease
+ val InlineCode = R.drawable.ic_inline_code
+ val Italic = R.drawable.ic_italic
+ val Link = R.drawable.ic_link
+ val NumberedList = R.drawable.ic_numbered_list
+ val Quote = R.drawable.ic_quote
+ val Strikethrough = R.drawable.ic_strikethrough
+ val Underline = R.drawable.ic_underline
+ val Mention = R.drawable.ic_mention
+ val Mute = R.drawable.ic_mute
+ val ThreadDecoration = R.drawable.ic_thread_decoration
+ val Plus = R.drawable.ic_plus
+ val Cancel = R.drawable.ic_cancel
}
diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/atoms/ElementLogoAtom.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/atoms/ElementLogoAtom.kt
index 5edc527821..2460d61b49 100644
--- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/atoms/ElementLogoAtom.kt
+++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/atoms/ElementLogoAtom.kt
@@ -22,6 +22,7 @@ import androidx.compose.foundation.border
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Box
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
@@ -50,8 +51,9 @@ fun ElementLogoAtom(
val blur = if (darkTheme) 160.dp else 24.dp
//box-shadow: 0px 6.075949668884277px 24.30379867553711px 0px #1B1D2280;
val shadowColor = if (darkTheme) size.shadowColorDark else size.shadowColorLight
+ val logoShadowColor = if (darkTheme) size.logoShadowColorDark else size.logoShadowColorLight
val backgroundColor = if (darkTheme) Color.White.copy(alpha = 0.2f) else Color.White.copy(alpha = 0.4f)
- val borderColor = if (darkTheme) Color.White.copy(alpha = 0.8f) else Color.White.copy(alpha = 0.4f)
+ val borderColor = if (darkTheme) Color.White.copy(alpha = 0.89f) else Color.White
Box(
modifier = modifier
.size(size.outerSize)
@@ -89,7 +91,21 @@ fun ElementLogoAtom(
.blurCompat(blur)
)
Image(
- modifier = Modifier.size(size.logoSize),
+ modifier = Modifier
+ .size(size.logoSize)
+ // Do the same double shadow than on Figma...
+ .shadow(
+ elevation = 25.dp,
+ clip = false,
+ shape = CircleShape,
+ ambientColor = logoShadowColor,
+ )
+ .shadow(
+ elevation = 25.dp,
+ clip = false,
+ shape = CircleShape,
+ ambientColor = Color(0x80000000),
+ ),
painter = painterResource(id = R.drawable.element_logo),
contentDescription = null
)
@@ -101,6 +117,8 @@ sealed class ElementLogoAtomSize(
val logoSize: Dp,
val cornerRadius: Dp,
val borderWidth: Dp,
+ val logoShadowColorDark: Color,
+ val logoShadowColorLight: Color,
val shadowColorDark: Color,
val shadowColorLight: Color,
val shadowRadius: Dp,
@@ -110,6 +128,8 @@ sealed class ElementLogoAtomSize(
logoSize = 83.5.dp,
cornerRadius = 33.dp,
borderWidth = 0.38.dp,
+ logoShadowColorDark = Color(0x4D000000),
+ logoShadowColorLight = Color(0x66000000),
shadowColorDark = Color.Black.copy(alpha = 0.4f),
shadowColorLight = Color(0x401B1D22),
shadowRadius = 32.dp,
@@ -120,6 +140,8 @@ sealed class ElementLogoAtomSize(
logoSize = 110.dp,
cornerRadius = 44.dp,
borderWidth = 0.5.dp,
+ logoShadowColorDark = Color(0x4D000000),
+ logoShadowColorLight = Color(0x66000000),
shadowColorDark = Color.Black,
shadowColorLight = Color(0x801B1D22),
shadowRadius = 60.dp,
diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/pages/SunsetPage.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/pages/SunsetPage.kt
index 10a6f529af..d4b203d4ae 100644
--- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/pages/SunsetPage.kt
+++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/pages/SunsetPage.kt
@@ -44,6 +44,7 @@ import io.element.android.libraries.designsystem.text.withColoredPeriod
import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.theme.ElementTheme
+import io.element.android.libraries.theme.ForcedDarkElementTheme
@Composable
fun SunsetPage(
@@ -53,9 +54,7 @@ fun SunsetPage(
modifier: Modifier = Modifier,
overallContent: @Composable () -> Unit,
) {
- ElementTheme(
- darkTheme = true
- ) {
+ ForcedDarkElementTheme(lightStatusBar = true) {
Box(
modifier = modifier.fillMaxSize()
) {
diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/colors/AvatarColors.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/colors/AvatarColors.kt
new file mode 100644
index 0000000000..abac299ca6
--- /dev/null
+++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/colors/AvatarColors.kt
@@ -0,0 +1,25 @@
+/*
+ * Copyright (c) 2023 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.element.android.libraries.designsystem.colors
+
+import androidx.compose.ui.graphics.Color
+
+data class AvatarColors(
+ val background: Color,
+ val foreground: Color,
+)
+
diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/colors/AvatarColorsProvider.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/colors/AvatarColorsProvider.kt
new file mode 100644
index 0000000000..bf195e8160
--- /dev/null
+++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/colors/AvatarColorsProvider.kt
@@ -0,0 +1,58 @@
+/*
+ * Copyright (c) 2023 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.element.android.libraries.designsystem.colors
+
+import androidx.collection.LruCache
+import io.element.android.libraries.theme.colors.avatarColorsDark
+import io.element.android.libraries.theme.colors.avatarColorsLight
+
+object AvatarColorsProvider {
+ private val cache = LruCache(200)
+ private var currentThemeIsLight = true
+
+ fun provide(id: String, isLightTheme: Boolean): AvatarColors {
+ if (currentThemeIsLight != isLightTheme) {
+ currentThemeIsLight = isLightTheme
+ cache.evictAll()
+ }
+ val valueFromCache = cache.get(id)
+ return if (valueFromCache != null) {
+ valueFromCache
+ } else {
+ val colors = avatarColors(id, isLightTheme)
+ cache.put(id, colors)
+ colors
+ }
+ }
+
+ private fun avatarColors(id: String, isLightTheme: Boolean): AvatarColors {
+ val hash = id.toHash()
+ val colors = if (isLightTheme) {
+ avatarColorsLight[hash]
+ } else {
+ avatarColorsDark[hash]
+ }
+ return AvatarColors(
+ background = colors.first,
+ foreground = colors.second,
+ )
+ }
+}
+
+internal fun String.toHash(): Int {
+ return toList().sumOf { it.code } % avatarColorsLight.size
+}
diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/Bloom.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/Bloom.kt
new file mode 100644
index 0000000000..b23c0cc2bb
--- /dev/null
+++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/Bloom.kt
@@ -0,0 +1,565 @@
+/*
+ * Copyright (c) 2023 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.element.android.libraries.designsystem.components
+
+import android.graphics.Bitmap
+import android.graphics.Typeface
+import android.graphics.drawable.BitmapDrawable
+import android.os.Build
+import android.text.TextPaint
+import androidx.annotation.FloatRange
+import androidx.compose.foundation.Image
+import androidx.compose.foundation.isSystemInDarkTheme
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.consumeWindowInsets
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Share
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.TopAppBarDefaults
+import androidx.compose.material3.rememberTopAppBarState
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableIntStateOf
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.saveable.rememberSaveable
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.composed
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.draw.drawWithCache
+import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.geometry.Rect
+import androidx.compose.ui.geometry.center
+import androidx.compose.ui.graphics.BlendMode
+import androidx.compose.ui.graphics.ClipOp
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.ImageBitmap
+import androidx.compose.ui.graphics.LinearGradientShader
+import androidx.compose.ui.graphics.Paint
+import androidx.compose.ui.graphics.Path
+import androidx.compose.ui.graphics.RadialGradientShader
+import androidx.compose.ui.graphics.ShaderBrush
+import androidx.compose.ui.graphics.asAndroidBitmap
+import androidx.compose.ui.graphics.asImageBitmap
+import androidx.compose.ui.graphics.drawscope.DrawScope
+import androidx.compose.ui.graphics.drawscope.clipPath
+import androidx.compose.ui.graphics.nativeCanvas
+import androidx.compose.ui.graphics.painter.BitmapPainter
+import androidx.compose.ui.graphics.toArgb
+import androidx.compose.ui.input.nestedscroll.nestedScroll
+import androidx.compose.ui.layout.ContentScale
+import androidx.compose.ui.layout.onSizeChanged
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.platform.LocalDensity
+import androidx.compose.ui.platform.LocalFontFamilyResolver
+import androidx.compose.ui.platform.LocalLayoutDirection
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.text.TextStyle
+import androidx.compose.ui.text.font.FontFamily
+import androidx.compose.ui.text.font.FontStyle
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.rememberTextMeasurer
+import androidx.compose.ui.tooling.preview.PreviewParameter
+import androidx.compose.ui.tooling.preview.PreviewParameterProvider
+import androidx.compose.ui.unit.Density
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.DpOffset
+import androidx.compose.ui.unit.DpSize
+import androidx.compose.ui.unit.IntOffset
+import androidx.compose.ui.unit.IntSize
+import androidx.compose.ui.unit.LayoutDirection
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.isSpecified
+import androidx.compose.ui.unit.toOffset
+import androidx.compose.ui.unit.toSize
+import coil.imageLoader
+import coil.request.DefaultRequestOptions
+import coil.request.ImageRequest
+import coil.size.Size
+import com.airbnb.android.showkase.annotation.ShowkaseComposable
+import com.vanniktech.blurhash.BlurHash
+import io.element.android.libraries.designsystem.R
+import io.element.android.libraries.designsystem.colors.AvatarColorsProvider
+import io.element.android.libraries.designsystem.components.avatar.AvatarData
+import io.element.android.libraries.designsystem.preview.DayNightPreviews
+import io.element.android.libraries.designsystem.preview.ElementPreview
+import io.element.android.libraries.designsystem.preview.PreviewGroup
+import io.element.android.libraries.designsystem.text.toDp
+import io.element.android.libraries.designsystem.theme.components.Icon
+import io.element.android.libraries.designsystem.theme.components.MediumTopAppBar
+import io.element.android.libraries.designsystem.theme.components.Scaffold
+import io.element.android.libraries.designsystem.theme.components.Text
+import io.element.android.libraries.theme.ElementTheme
+import kotlinx.collections.immutable.ImmutableList
+import kotlinx.collections.immutable.persistentListOf
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.withContext
+import kotlin.math.max
+import kotlin.math.roundToInt
+
+/**
+ * Default bloom configuration values.
+ */
+object BloomDefaults {
+ /**
+ * Number of components to use with BlurHash to generate the blur effect.
+ * Larger values mean more detailed blurs.
+ */
+ const val HASH_COMPONENTS = 5
+
+ /** Default bloom layers. */
+ @Composable
+ fun defaultLayers() = persistentListOf(
+ // Bottom layer
+ if (isSystemInDarkTheme()) {
+ BloomLayer(0.5f, BlendMode.Exclusion)
+ } else {
+ BloomLayer(0.2f, BlendMode.Hardlight)
+ },
+ // Top layer
+ BloomLayer(if (isSystemInDarkTheme()) 0.2f else 0.8f, BlendMode.Color),
+ )
+}
+
+/**
+ * Bloom layer configuration.
+ * @param alpha The alpha value to apply to the layer.
+ * @param blendMode The blend mode to apply to the layer.
+ */
+data class BloomLayer(
+ val alpha: Float,
+ val blendMode: BlendMode,
+)
+
+/**
+ * Bloom effect modifier. Applies a bloom effect to the component.
+ * @param hash The BlurHash to use as the bloom source.
+ * @param background The background color to use for the bloom effect. Since we use blend modes it must be non-transparent.
+ * @param blurSize The size of the bloom effect. If not specified the bloom effect will be the size of the component.
+ * @param offset The offset to use for the bloom effect. If not specified the bloom effect will be centered on the component.
+ * @param clipToSize The size to use for clipping the bloom effect. If not specified the bloom effect will not be clipped.
+ * @param layerConfiguration The configuration for the bloom layers. If not specified the default layers configuration will be used.
+ * @param bottomSoftEdgeColor The color to use for the bottom soft edge. If not specified the [background] color will be used.
+ * @param bottomSoftEdgeHeight The height of the bottom soft edge. If not specified the bottom soft edge will not be drawn.
+ * @param bottomSoftEdgeAlpha The alpha value to apply to the bottom soft edge.
+ * @param alpha The alpha value to apply to the bloom effect.
+ */
+fun Modifier.bloom(
+ hash: String?,
+ background: Color,
+ blurSize: DpSize = DpSize.Unspecified,
+ offset: DpOffset = DpOffset.Unspecified,
+ clipToSize: DpSize = DpSize.Unspecified,
+ layerConfiguration: ImmutableList? = null,
+ bottomSoftEdgeColor: Color = background,
+ bottomSoftEdgeHeight: Dp = 40.dp,
+ @FloatRange(from = 0.0, to = 1.0)
+ bottomSoftEdgeAlpha: Float = 1.0f,
+ @FloatRange(from = 0.0, to = 1.0)
+ alpha: Float = 1f,
+) = composed {
+ val defaultLayers = BloomDefaults.defaultLayers()
+ val layers = layerConfiguration ?: defaultLayers
+ // Bloom only works on API 29+
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) return@composed this
+ if (hash == null) return@composed this
+
+ val hashedBitmap = remember(hash) {
+ BlurHash.decode(hash, BloomDefaults.HASH_COMPONENTS, BloomDefaults.HASH_COMPONENTS)?.asImageBitmap()
+ } ?: return@composed this
+ val density = LocalDensity.current
+ val pixelSize = remember(blurSize, density) { blurSize.toIntSize(density) }
+ val clipToPixelSize = remember(clipToSize, density) { clipToSize.toIntSize(density) }
+ val bottomSoftEdgeHeightPixels = remember(bottomSoftEdgeHeight, density) { with(density) { bottomSoftEdgeHeight.roundToPx() } }
+ val isRTL = LocalLayoutDirection.current == LayoutDirection.Rtl
+ drawWithCache {
+ val dstSize = if (pixelSize != IntSize.Zero) {
+ pixelSize
+ } else {
+ IntSize(size.width.toInt(), size.height.toInt())
+ }
+ // Calculate where to place the center of the bloom effect
+ val centerOffset = if (offset.isSpecified) {
+ if (isRTL) {
+ IntOffset(
+ size.width.roundToInt() - offset.x.roundToPx(),
+ size.height.roundToInt() - offset.y.roundToPx(),
+ )
+ } else {
+ IntOffset(
+ offset.x.roundToPx(),
+ offset.y.roundToPx(),
+ )
+ }
+ } else {
+ IntOffset(
+ size.center.x.toInt(),
+ size.center.y.toInt(),
+ )
+ }
+ // Calculate the offset to draw the different layers and apply clipping
+ // This offset is applied to place the top left corner of the bloom effect
+ val layersOffset = if (offset.isSpecified) {
+ // Offsets the layers so the center of the bloom effect is at the provided offset value
+ IntOffset(
+ centerOffset.x - dstSize.width / 2,
+ centerOffset.y - dstSize.height / 2,
+ )
+ } else {
+ // Places the layers at the center of the component
+ IntOffset.Zero
+ }
+ val radius = max(dstSize.width, dstSize.height).toFloat() / 2
+ val circularGradientShader = RadialGradientShader(
+ centerOffset.toOffset(),
+ radius,
+ listOf(Color.Red, Color.Transparent),
+ listOf(0f, 1f)
+ )
+ val circularGradientBrush = ShaderBrush(circularGradientShader)
+ val bottomEdgeGradient = LinearGradientShader(
+ from = IntOffset(0, clipToPixelSize.height - bottomSoftEdgeHeightPixels).toOffset(),
+ to = IntOffset(0, clipToPixelSize.height).toOffset(),
+ listOf(Color.Transparent, bottomSoftEdgeColor),
+ listOf(0f, 1f)
+ )
+ val bottomEdgeGradientBrush = ShaderBrush(bottomEdgeGradient)
+ onDrawBehind {
+ if (dstSize != IntSize.Zero) {
+ val circleClipPath = Path().apply {
+ addOval(Rect(centerOffset.toOffset(), radius - 1))
+ }
+ // Clip the external radius of bloom gradient too, otherwise we have a 1px border
+ clipPath(circleClipPath, clipOp = ClipOp.Intersect) {
+ // Draw the bloom layers
+ drawWithLayer {
+ // Clip rect to the provided size if needed
+ if (clipToPixelSize != IntSize.Zero) {
+ drawContext.canvas.clipRect(Rect(Offset.Zero, clipToPixelSize.toSize()), ClipOp.Intersect)
+ }
+ // Draw background color for blending
+ drawRect(background, size = pixelSize.toSize())
+ // Draw layers
+ for (layer in layers) {
+ drawImage(
+ hashedBitmap,
+ srcSize = IntSize(BloomDefaults.HASH_COMPONENTS, BloomDefaults.HASH_COMPONENTS),
+ dstSize = dstSize,
+ dstOffset = layersOffset,
+ alpha = layer.alpha * alpha,
+ blendMode = layer.blendMode,
+ )
+ }
+ // Mask the layers erasing the outer radius using the gradient brush
+ drawCircle(
+ circularGradientBrush,
+ radius,
+ centerOffset.toOffset(),
+ blendMode = BlendMode.DstIn
+ )
+ }
+ }
+ // Draw the bottom soft edge
+ drawRect(
+ bottomEdgeGradientBrush,
+ topLeft = IntOffset(0, clipToPixelSize.height - bottomSoftEdgeHeight.roundToPx()).toOffset(),
+ size = IntSize(pixelSize.width, bottomSoftEdgeHeight.roundToPx()).toSize(),
+ alpha = bottomSoftEdgeAlpha
+ )
+ }
+ }
+ }
+}
+
+/**
+ * Bloom effect modifier for avatars. Applies a bloom effect to the component.
+ * @param avatarData The avatar data to use as the bloom source.
+ * If the avatar data has a URL it will be used as the bloom source, otherwise the initials will be used. If `null` is passed, no bloom effect will be applied.
+ * @param background The background color to use for the bloom effect. Since we use blend modes it must be non-transparent.
+ * @param blurSize The size of the bloom effect. If not specified the bloom effect will be the size of the component.
+ * @param offset The offset to use for the bloom effect. If not specified the bloom effect will be centered on the component.
+ * @param clipToSize The size to use for clipping the bloom effect. If not specified the bloom effect will not be clipped.
+ * @param bottomSoftEdgeColor The color to use for the bottom soft edge. If not specified the [background] color will be used.
+ * @param bottomSoftEdgeHeight The height of the bottom soft edge. If not specified the bottom soft edge will not be drawn.
+ * @param bottomSoftEdgeAlpha The alpha value to apply to the bottom soft edge.
+ * @param alpha The alpha value to apply to the bloom effect.
+ */
+fun Modifier.avatarBloom(
+ avatarData: AvatarData?,
+ background: Color,
+ blurSize: DpSize = DpSize.Unspecified,
+ offset: DpOffset = DpOffset.Unspecified,
+ clipToSize: DpSize = DpSize.Unspecified,
+ bottomSoftEdgeColor: Color = background,
+ bottomSoftEdgeHeight: Dp = 40.dp,
+ @FloatRange(from = 0.0, to = 1.0)
+ bottomSoftEdgeAlpha: Float = 1.0f,
+ @FloatRange(from = 0.0, to = 1.0)
+ alpha: Float = 1f,
+) = composed {
+ // Bloom only works on API 29+
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) return@composed this
+ avatarData ?: return@composed this
+
+ // Request the avatar contents to use as the bloom source
+ val context = LocalContext.current
+ val density = LocalDensity.current
+ if (avatarData.url != null) {
+ val painterRequest = remember(avatarData) {
+ ImageRequest.Builder(context)
+ .data(avatarData)
+ // Allow cache and default dispatchers
+ .defaults(DefaultRequestOptions())
+ // Needed to be able to read pixels from the Bitmap for the hash
+ .allowHardware(false)
+ // Reduce size so it loads faster for large avatars
+ .size(with(density) { Size(64.dp.roundToPx(), 64.dp.roundToPx()) })
+ .build()
+ }
+
+ // By making it saveable, we'll 'cache' the previous bloom effect until a new one is loaded
+ var blurHash by rememberSaveable(avatarData) { mutableStateOf(null) }
+ LaunchedEffect(avatarData) {
+ withContext(Dispatchers.IO) {
+ val drawable =
+ context.imageLoader.execute(painterRequest).drawable ?: return@withContext
+ val bitmap = (drawable as? BitmapDrawable)?.bitmap ?: return@withContext
+ blurHash = BlurHash.encode(
+ bitmap,
+ BloomDefaults.HASH_COMPONENTS,
+ BloomDefaults.HASH_COMPONENTS
+ )
+ }
+ }
+
+ bloom(
+ hash = blurHash,
+ background = background,
+ blurSize = blurSize,
+ offset = offset,
+ clipToSize = clipToSize,
+ bottomSoftEdgeColor = bottomSoftEdgeColor,
+ bottomSoftEdgeHeight = bottomSoftEdgeHeight,
+ bottomSoftEdgeAlpha = bottomSoftEdgeAlpha,
+ alpha = alpha,
+ )
+ } else {
+ // There is no URL so we'll generate an avatar with the initials and use that as the bloom source
+ val avatarColors = AvatarColorsProvider.provide(avatarData.id, ElementTheme.isLightTheme)
+ val initialsBitmap = initialsBitmap(
+ width = avatarData.size.dp,
+ height = avatarData.size.dp,
+ text = avatarData.initial,
+ textColor = avatarColors.foreground,
+ backgroundColor = avatarColors.background,
+ )
+ val hash = remember(avatarData, avatarColors) {
+ BlurHash.encode(initialsBitmap.asAndroidBitmap(), BloomDefaults.HASH_COMPONENTS, BloomDefaults.HASH_COMPONENTS)
+ }
+ bloom(
+ hash = hash,
+ background = background,
+ blurSize = blurSize,
+ offset = offset,
+ clipToSize = clipToSize,
+ bottomSoftEdgeColor = bottomSoftEdgeColor,
+ bottomSoftEdgeHeight = bottomSoftEdgeHeight,
+ bottomSoftEdgeAlpha = bottomSoftEdgeAlpha,
+ alpha = alpha,
+ )
+ }
+}
+
+// Used to create a Bitmap version of the initials avatar
+@Composable
+private fun initialsBitmap(
+ text: String,
+ backgroundColor: Color,
+ textColor: Color,
+ width: Dp = 32.dp,
+ height: Dp = 32.dp,
+): ImageBitmap = with(LocalDensity.current) {
+ val backgroundPaint = remember(backgroundColor) {
+ Paint().also { it.color = backgroundColor }
+ }
+ val resolver: FontFamily.Resolver = LocalFontFamilyResolver.current
+ val fontSize = remember { height.toSp() / 2 }
+ val typeface: Typeface = remember(resolver) {
+ resolver.resolve(
+ fontFamily = FontFamily.Default,
+ fontWeight = FontWeight.Bold,
+ fontStyle = FontStyle.Normal,
+ )
+ }.value as Typeface
+ val textPaint = remember(textColor, typeface) {
+ TextPaint().apply {
+ color = textColor.toArgb()
+ textSize = fontSize.toPx()
+ this.typeface = typeface
+ }
+ }
+ val textMeasurer = rememberTextMeasurer()
+ val result = remember(text) { textMeasurer.measure(text, TextStyle.Default.copy(fontSize = fontSize)) }
+ val centerPx = remember(width, height) { IntOffset(width.roundToPx() / 2, height.roundToPx() / 2) }
+ remember(text, width, height, backgroundColor, textColor) {
+ val bitmap = Bitmap.createBitmap(width.roundToPx(), height.roundToPx(), Bitmap.Config.ARGB_8888).asImageBitmap()
+ androidx.compose.ui.graphics.Canvas(bitmap).also { canvas ->
+ canvas.drawCircle(centerPx.toOffset(), width.toPx() / 2, backgroundPaint)
+ canvas.nativeCanvas.drawText(text, centerPx.x.toFloat() - result.size.width/2, centerPx.y * 2f - result.size.height/2 - 4, textPaint)
+ }
+ bitmap
+ }
+}
+
+// Translates DP sizes into pixel sizes, taking into account unspecified values
+private fun DpSize.toIntSize(density: Density) = with(density) {
+ if (isSpecified) {
+ IntSize(width.roundToPx(), height.roundToPx())
+ } else {
+ IntSize.Zero
+ }
+}
+
+/**
+ * Helper to draw to a canvas using layers. This allows us to apply clipping to those layers only.
+ */
+fun DrawScope.drawWithLayer(block: DrawScope.() -> Unit) {
+ with(drawContext.canvas.nativeCanvas) {
+ val checkPoint = saveLayer(null, null)
+ block()
+ restoreToCount(checkPoint)
+ }
+}
+
+@OptIn(ExperimentalMaterial3Api::class)
+@DayNightPreviews
+@ShowkaseComposable(group = PreviewGroup.Bloom)
+@Composable
+internal fun BloomPreview() {
+ val blurhash = "eePn{tI?xExEja}ooKWWodjtNJoKR,j@a|sBWpS3WDbGazoKWWWWj@"
+ var topAppBarHeight by remember { mutableIntStateOf(-1) }
+ val topAppBarState = rememberTopAppBarState()
+ val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior(topAppBarState)
+ ElementPreview {
+ Scaffold(
+ modifier = Modifier.fillMaxSize().nestedScroll(scrollBehavior.nestedScrollConnection),
+ topBar = {
+ Box {
+ MediumTopAppBar(
+ modifier = Modifier
+ .onSizeChanged { size ->
+ topAppBarHeight = size.height
+ }
+ .bloom(
+ hash = blurhash,
+ background = ElementTheme.materialColors.background,
+ blurSize = DpSize(430.dp, 430.dp),
+ offset = DpOffset(24.dp, 24.dp),
+ clipToSize = if (topAppBarHeight > 0) DpSize(430.dp, topAppBarHeight.toDp()) else DpSize.Zero,
+ ),
+ colors = TopAppBarDefaults.largeTopAppBarColors(
+ containerColor = Color.Transparent,
+ scrolledContainerColor = Color.Black.copy(alpha = 0.05f),
+ ),
+ navigationIcon = {
+ Image(
+ modifier = Modifier
+ .padding(start = 8.dp)
+ .size(32.dp)
+ .clip(CircleShape),
+ painter = painterResource(id = R.drawable.sample_avatar),
+ contentScale = ContentScale.Crop,
+ contentDescription = null
+ )
+ },
+ actions = {
+ IconButton(onClick = {}) {
+ Icon(imageVector = Icons.Default.Share, contentDescription = null)
+ }
+ },
+ title = {
+ Text("Title")
+ },
+ scrollBehavior = scrollBehavior,
+ )
+ }
+ },
+ ) { paddingValues ->
+ Column(
+ modifier = Modifier
+ .padding(paddingValues)
+ .consumeWindowInsets(paddingValues)
+ .fillMaxSize()
+ .verticalScroll(rememberScrollState()),
+ ) {
+ repeat(20) {
+ Text("Content", modifier = Modifier.padding(vertical = 20.dp))
+ }
+ }
+ }
+ }
+}
+
+class InitialsColorStateProvider : PreviewParameterProvider {
+ override val values: Sequence
+ get() = sequenceOf(0, 1, 2, 3, 4, 5, 6, 7)
+}
+
+@DayNightPreviews
+@Composable
+@ShowkaseComposable(group = PreviewGroup.Bloom)
+internal fun BloomInitialsPreview(@PreviewParameter(InitialsColorStateProvider::class) color: Int) {
+ ElementPreview {
+ val avatarColors = AvatarColorsProvider.provide("$color", ElementTheme.isLightTheme)
+ val bitmap = initialsBitmap(text = "F", backgroundColor = avatarColors.background, textColor = avatarColors.foreground)
+ val hash = BlurHash.encode(bitmap.asAndroidBitmap(), BloomDefaults.HASH_COMPONENTS, BloomDefaults.HASH_COMPONENTS)
+ Box(
+ modifier = Modifier.size(256.dp)
+ .bloom(
+ hash = hash,
+ background = if (ElementTheme.isLightTheme) {
+ // Workaround to display a very subtle bloom for avatars with very soft colors
+ Color(0xFFF9F9F9)
+ } else {
+ ElementTheme.materialColors.background
+ },
+ bottomSoftEdgeColor = ElementTheme.materialColors.background,
+ blurSize = DpSize(256.dp, 256.dp),
+ ),
+ contentAlignment = Alignment.Center
+ ) {
+ Image(
+ modifier = Modifier
+ .size(32.dp)
+ .clip(CircleShape),
+ painter = BitmapPainter(bitmap),
+ contentDescription = null
+ )
+ }
+ }
+}
diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/LabelledOutlinedTextField.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/LabelledOutlinedTextField.kt
new file mode 100644
index 0000000000..4939cac38d
--- /dev/null
+++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/LabelledOutlinedTextField.kt
@@ -0,0 +1,84 @@
+/*
+ * Copyright (c) 2023 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.element.android.libraries.designsystem.components
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.text.KeyboardOptions
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.unit.dp
+import io.element.android.libraries.designsystem.preview.DayNightPreviews
+import io.element.android.libraries.designsystem.preview.ElementPreview
+import io.element.android.libraries.designsystem.theme.components.OutlinedTextField
+import io.element.android.libraries.designsystem.theme.components.Text
+import io.element.android.libraries.theme.ElementTheme
+
+@Composable
+fun LabelledOutlinedTextField(
+ label: String,
+ value: String,
+ modifier: Modifier = Modifier,
+ placeholder: String? = null,
+ singleLine: Boolean = false,
+ maxLines: Int = if (singleLine) 1 else Int.MAX_VALUE,
+ keyboardOptions: KeyboardOptions = KeyboardOptions.Default,
+ onValueChange: (String) -> Unit = {},
+) {
+ Column(
+ modifier = modifier,
+ verticalArrangement = Arrangement.spacedBy(8.dp),
+ ) {
+ Text(
+ modifier = Modifier.padding(horizontal = 16.dp),
+ style = ElementTheme.typography.fontBodyMdRegular,
+ color = MaterialTheme.colorScheme.primary,
+ text = label
+ )
+
+ OutlinedTextField(
+ modifier = Modifier.fillMaxWidth(),
+ value = value,
+ placeholder = placeholder?.let { { Text(placeholder) } },
+ onValueChange = onValueChange,
+ singleLine = singleLine,
+ maxLines = maxLines,
+ keyboardOptions = keyboardOptions,
+ )
+ }
+}
+
+@DayNightPreviews
+@Composable
+internal fun LabelledOutlinedTextFieldPreview() = ElementPreview {
+ Column {
+ LabelledOutlinedTextField(
+ label = "Room name",
+ value = "",
+ placeholder = "e.g. Product Sprint",
+ )
+ LabelledOutlinedTextField(
+ label = "Room name",
+ value = "a room name",
+ placeholder = "e.g. Product Sprint",
+ )
+ }
+}
+
diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/avatar/Avatar.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/avatar/Avatar.kt
index 2e2d98ddbb..4ce4aa8f14 100644
--- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/avatar/Avatar.kt
+++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/avatar/Avatar.kt
@@ -26,13 +26,13 @@ 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.layout.ContentScale
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import coil.compose.AsyncImage
+import io.element.android.libraries.designsystem.colors.AvatarColorsProvider
import io.element.android.libraries.designsystem.preview.ElementThemedPreview
import io.element.android.libraries.designsystem.preview.PreviewGroup
import io.element.android.libraries.designsystem.preview.debugPlaceholderAvatar
@@ -87,20 +87,19 @@ private fun InitialsAvatar(
avatarData: AvatarData,
modifier: Modifier = Modifier,
) {
- // Use temporary color for default avatar background
- val avatarColor = ElementTheme.colors.bgActionPrimaryDisabled
+ val avatarColors = AvatarColorsProvider.provide(avatarData.id, ElementTheme.isLightTheme)
Box(
- modifier.background(color = avatarColor),
+ modifier.background(color = avatarColors.background)
) {
val fontSize = avatarData.size.dp.toSp() / 2
- val originalFont = ElementTheme.typography.fontBodyMdRegular
+ val originalFont = ElementTheme.typography.fontHeadingMdBold
val ratio = fontSize.value / originalFont.fontSize.value
val lineHeight = originalFont.lineHeight * ratio
Text(
modifier = Modifier.align(Alignment.Center),
text = avatarData.initial,
style = originalFont.copy(fontSize = fontSize, lineHeight = lineHeight, letterSpacing = 0.sp),
- color = Color.White,
+ color = avatarColors.foreground,
)
}
}
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 2438e5f017..45d7780393 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
@@ -20,7 +20,7 @@ import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
enum class AvatarSize(val dp: Dp) {
- CurrentUserTopBar(28.dp),
+ CurrentUserTopBar(32.dp),
RoomHeader(96.dp),
RoomListItem(52.dp),
@@ -42,4 +42,8 @@ enum class AvatarSize(val dp: Dp) {
RoomInviteItem(52.dp),
InviteSender(16.dp),
+
+ EditRoomDetails(70.dp),
+
+ NotificationsOptIn(32.dp),
}
diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/avatar/UserAvatarPreview.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/avatar/UserAvatarPreview.kt
new file mode 100644
index 0000000000..ab1ec70db4
--- /dev/null
+++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/avatar/UserAvatarPreview.kt
@@ -0,0 +1,50 @@
+/*
+ * Copyright (c) 2023 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.element.android.libraries.designsystem.components.avatar
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.padding
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.unit.dp
+import io.element.android.libraries.designsystem.preview.DayNightPreviews
+import io.element.android.libraries.designsystem.preview.ElementPreview
+import io.element.android.libraries.designsystem.theme.components.Text
+import io.element.android.libraries.theme.colors.avatarColorsLight
+
+@DayNightPreviews
+@Composable
+internal fun UserAvatarPreview() = ElementPreview {
+ Column(
+ modifier = Modifier.padding(8.dp),
+ verticalArrangement = Arrangement.spacedBy(8.dp),
+ ) {
+ repeat(avatarColorsLight.size) {
+ Row(
+ horizontalArrangement = Arrangement.spacedBy(8.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ // Note: it's OK, since the hash of "0" is 0, the hash of "1" is 1, etc.
+ Avatar(anAvatarData(id = "$it"))
+ Text(text = "Color index $it")
+ }
+ }
+ }
+}
diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/preferences/PreferenceText.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/preferences/PreferenceText.kt
index 3f204ee847..9a5f6b9a41 100644
--- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/preferences/PreferenceText.kt
+++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/preferences/PreferenceText.kt
@@ -39,6 +39,8 @@ import io.element.android.libraries.designsystem.preview.ElementThemedPreview
import io.element.android.libraries.designsystem.preview.PreviewGroup
import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator
import io.element.android.libraries.designsystem.theme.components.Text
+import io.element.android.libraries.designsystem.toEnabledColor
+import io.element.android.libraries.designsystem.toSecondaryEnabledColor
import io.element.android.libraries.theme.ElementTheme
/**
@@ -48,6 +50,7 @@ import io.element.android.libraries.theme.ElementTheme
fun PreferenceText(
title: String,
modifier: Modifier = Modifier,
+ enabled: Boolean = true,
subtitle: String? = null,
currentValue: String? = null,
loadingCurrentValue: Boolean = false,
@@ -68,8 +71,9 @@ fun PreferenceText(
) {
PreferenceIcon(
icon = icon,
+ enabled = enabled,
isVisible = showIconAreaIfNoIcon,
- tintColor = tintColor ?: ElementTheme.materialColors.secondary
+ tintColor = tintColor ?: enabled.toSecondaryEnabledColor(),
)
Column(
modifier = Modifier
@@ -79,13 +83,13 @@ fun PreferenceText(
Text(
style = ElementTheme.typography.fontBodyLgRegular,
text = title,
- color = tintColor ?: ElementTheme.materialColors.primary,
+ color = tintColor ?: enabled.toEnabledColor(),
)
if (subtitle != null) {
Text(
style = ElementTheme.typography.fontBodyMdRegular,
text = subtitle,
- color = tintColor ?: ElementTheme.materialColors.secondary,
+ color = tintColor ?: enabled.toSecondaryEnabledColor(),
)
}
}
@@ -96,7 +100,7 @@ fun PreferenceText(
.padding(start = 16.dp, end = 8.dp),
text = currentValue,
style = ElementTheme.typography.fontBodyXsMedium,
- color = ElementTheme.materialColors.secondary,
+ color = enabled.toSecondaryEnabledColor(),
)
} else if (loadingCurrentValue) {
CircularProgressIndicator(
@@ -135,6 +139,13 @@ private fun ContentToPreview() {
icon = Icons.Default.BugReport,
currentValue = "123",
)
+ PreferenceText(
+ title = "Title",
+ subtitle = "Some content",
+ icon = Icons.Default.BugReport,
+ currentValue = "123",
+ enabled = false,
+ )
PreferenceText(
title = "Title",
subtitle = "Some content",
diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/preview/PreviewGroup.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/preview/PreviewGroup.kt
index 8bfd198f1c..a1f458ee08 100644
--- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/preview/PreviewGroup.kt
+++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/preview/PreviewGroup.kt
@@ -19,6 +19,7 @@ package io.element.android.libraries.designsystem.preview
object PreviewGroup {
const val AppBars = "App Bars"
const val Avatars = "Avatars"
+ const val Bloom = "Bloom"
const val BottomSheets = "Bottom Sheets"
const val Buttons = "Buttons"
const val DateTimePickers = "DateTime pickers"
diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/ColorAliases.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/ColorAliases.kt
index b9e0893836..e7878f9bed 100644
--- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/ColorAliases.kt
+++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/ColorAliases.kt
@@ -65,6 +65,26 @@ val SemanticColors.messageFromOtherBackground
Color(0xFF26282D)
}
+// This color is not present in Semantic color, so put hard-coded value for now
+val SemanticColors.progressIndicatorTrackColor
+ get() = if (isLight) {
+ // We want LightDesignTokens.colorAlphaGray500
+ Color(0x33052448)
+ } else {
+ // We want DarkDesignTokens.colorAlphaGray500
+ Color(0x25F4F7FA)
+ }
+
+// This color is not present in Semantic color, so put hard-coded value for now
+val SemanticColors.iconSuccessPrimaryBackground
+ get() = if (isLight) {
+ // We want LightDesignTokens.colorGreen300
+ Color(0xffe3f7ed)
+ } else {
+ // We want DarkDesignTokens.colorGreen300
+ Color(0xff002513)
+ }
+
// Temporary color, which is not in the token right now
val SemanticColors.temporaryColorBgSpecial
get() = if (isLight) Color(0xFFE4E8F0) else Color(0xFF3A4048)
@@ -90,7 +110,9 @@ private fun ContentToPreview() {
"placeholderBackground" to ElementTheme.colors.placeholderBackground,
"messageFromMeBackground" to ElementTheme.colors.messageFromMeBackground,
"messageFromOtherBackground" to ElementTheme.colors.messageFromOtherBackground,
+ "progressIndicatorTrackColor" to ElementTheme.colors.progressIndicatorTrackColor,
"temporaryColorBgSpecial" to ElementTheme.colors.temporaryColorBgSpecial,
+ "iconSuccessPrimaryBackground" to ElementTheme.colors.iconSuccessPrimaryBackground,
)
)
}
diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/FloatingActionButton.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/FloatingActionButton.kt
index e126a429d8..16af6203ca 100644
--- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/FloatingActionButton.kt
+++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/FloatingActionButton.kt
@@ -19,6 +19,7 @@ package io.element.android.libraries.designsystem.theme.components
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close
import androidx.compose.material3.FloatingActionButtonDefaults
@@ -38,7 +39,7 @@ import io.element.android.libraries.designsystem.preview.PreviewGroup
fun FloatingActionButton(
onClick: () -> Unit,
modifier: Modifier = Modifier,
- shape: Shape = FloatingActionButtonDefaults.shape,
+ shape: Shape = CircleShape, // FloatingActionButtonDefaults.shape,
containerColor: Color = FloatingActionButtonDefaults.containerColor,
contentColor: Color = contentColorFor(containerColor),
elevation: FloatingActionButtonElevation = FloatingActionButtonDefaults.elevation(),
diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/utils/CommonResources.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/utils/CommonResources.kt
new file mode 100644
index 0000000000..adcfd93af8
--- /dev/null
+++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/utils/CommonResources.kt
@@ -0,0 +1,21 @@
+/*
+ * Copyright (c) 2023 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.element.android.libraries.designsystem.utils
+
+import io.element.android.libraries.designsystem.R
+
+typealias CommonDrawables = R.drawable
diff --git a/libraries/designsystem/src/main/res/drawable-night-xxhdpi/element_logo.png b/libraries/designsystem/src/main/res/drawable-night-xxhdpi/element_logo.png
new file mode 100644
index 0000000000..d65c54da17
Binary files /dev/null and b/libraries/designsystem/src/main/res/drawable-night-xxhdpi/element_logo.png differ
diff --git a/libraries/designsystem/src/main/res/drawable-night/onboarding_bg.png b/libraries/designsystem/src/main/res/drawable-night/onboarding_bg.png
index df30707317..2f51442fd3 100644
Binary files a/libraries/designsystem/src/main/res/drawable-night/onboarding_bg.png and b/libraries/designsystem/src/main/res/drawable-night/onboarding_bg.png differ
diff --git a/libraries/designsystem/src/main/res/drawable-xxhdpi/element_logo.png b/libraries/designsystem/src/main/res/drawable-xxhdpi/element_logo.png
new file mode 100644
index 0000000000..a5e24f328b
Binary files /dev/null and b/libraries/designsystem/src/main/res/drawable-xxhdpi/element_logo.png differ
diff --git a/libraries/designsystem/src/main/res/drawable/element_logo.xml b/libraries/designsystem/src/main/res/drawable/element_logo.xml
deleted file mode 100644
index 0101c0d541..0000000000
--- a/libraries/designsystem/src/main/res/drawable/element_logo.xml
+++ /dev/null
@@ -1,26 +0,0 @@
-
-
-
-
-
-
-
diff --git a/libraries/designsystem/src/main/res/drawable/ic_bold.xml b/libraries/designsystem/src/main/res/drawable/ic_bold.xml
new file mode 100644
index 0000000000..5a08fee2f3
--- /dev/null
+++ b/libraries/designsystem/src/main/res/drawable/ic_bold.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/libraries/designsystem/src/main/res/drawable/ic_bullet_list.xml b/libraries/designsystem/src/main/res/drawable/ic_bullet_list.xml
new file mode 100644
index 0000000000..103d0b380d
--- /dev/null
+++ b/libraries/designsystem/src/main/res/drawable/ic_bullet_list.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/libraries/designsystem/src/main/res/drawable/ic_cancel.xml b/libraries/designsystem/src/main/res/drawable/ic_cancel.xml
new file mode 100644
index 0000000000..3e4ee21aee
--- /dev/null
+++ b/libraries/designsystem/src/main/res/drawable/ic_cancel.xml
@@ -0,0 +1,26 @@
+
+
+
+
+
diff --git a/libraries/designsystem/src/main/res/drawable/ic_code_block.xml b/libraries/designsystem/src/main/res/drawable/ic_code_block.xml
new file mode 100644
index 0000000000..18279bd8b5
--- /dev/null
+++ b/libraries/designsystem/src/main/res/drawable/ic_code_block.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/libraries/designsystem/src/main/res/drawable/ic_edit_square.xml b/libraries/designsystem/src/main/res/drawable/ic_edit_square.xml
index 73b092ea47..121486a4a2 100644
--- a/libraries/designsystem/src/main/res/drawable/ic_edit_square.xml
+++ b/libraries/designsystem/src/main/res/drawable/ic_edit_square.xml
@@ -15,12 +15,13 @@
-->
-
+ android:width="24dp"
+ android:height="24dp"
+ android:viewportWidth="24"
+ android:viewportHeight="24">
+
+
+
diff --git a/libraries/designsystem/src/main/res/drawable/ic_indent_decrease.xml b/libraries/designsystem/src/main/res/drawable/ic_indent_decrease.xml
new file mode 100644
index 0000000000..181f94c012
--- /dev/null
+++ b/libraries/designsystem/src/main/res/drawable/ic_indent_decrease.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/libraries/designsystem/src/main/res/drawable/ic_indent_increase.xml b/libraries/designsystem/src/main/res/drawable/ic_indent_increase.xml
new file mode 100644
index 0000000000..06a9ede8d5
--- /dev/null
+++ b/libraries/designsystem/src/main/res/drawable/ic_indent_increase.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/libraries/designsystem/src/main/res/drawable/ic_inline_code.xml b/libraries/designsystem/src/main/res/drawable/ic_inline_code.xml
new file mode 100644
index 0000000000..c15248f8ea
--- /dev/null
+++ b/libraries/designsystem/src/main/res/drawable/ic_inline_code.xml
@@ -0,0 +1,15 @@
+
+
+
+
+
diff --git a/libraries/designsystem/src/main/res/drawable/ic_italic.xml b/libraries/designsystem/src/main/res/drawable/ic_italic.xml
new file mode 100644
index 0000000000..0a389dbf15
--- /dev/null
+++ b/libraries/designsystem/src/main/res/drawable/ic_italic.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/libraries/designsystem/src/main/res/drawable/ic_link.xml b/libraries/designsystem/src/main/res/drawable/ic_link.xml
new file mode 100644
index 0000000000..c8a37cdda2
--- /dev/null
+++ b/libraries/designsystem/src/main/res/drawable/ic_link.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/libraries/designsystem/src/main/res/drawable/ic_mention.xml b/libraries/designsystem/src/main/res/drawable/ic_mention.xml
new file mode 100644
index 0000000000..37f70481e5
--- /dev/null
+++ b/libraries/designsystem/src/main/res/drawable/ic_mention.xml
@@ -0,0 +1,13 @@
+
+
+
+
+
+
diff --git a/libraries/designsystem/src/main/res/drawable/ic_mute.xml b/libraries/designsystem/src/main/res/drawable/ic_mute.xml
new file mode 100644
index 0000000000..ea6f842696
--- /dev/null
+++ b/libraries/designsystem/src/main/res/drawable/ic_mute.xml
@@ -0,0 +1,10 @@
+
+
+
diff --git a/libraries/designsystem/src/main/res/drawable/ic_notification_small.xml b/libraries/designsystem/src/main/res/drawable/ic_notification_small.xml
new file mode 100644
index 0000000000..cf84d679cd
--- /dev/null
+++ b/libraries/designsystem/src/main/res/drawable/ic_notification_small.xml
@@ -0,0 +1,7 @@
+
+
+
+
+
+
diff --git a/libraries/designsystem/src/main/res/drawable/ic_numbered_list.xml b/libraries/designsystem/src/main/res/drawable/ic_numbered_list.xml
new file mode 100644
index 0000000000..63e7269508
--- /dev/null
+++ b/libraries/designsystem/src/main/res/drawable/ic_numbered_list.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/libraries/designsystem/src/main/res/drawable/ic_plus.xml b/libraries/designsystem/src/main/res/drawable/ic_plus.xml
new file mode 100644
index 0000000000..159ed32e1a
--- /dev/null
+++ b/libraries/designsystem/src/main/res/drawable/ic_plus.xml
@@ -0,0 +1,29 @@
+
+
+
+
+
+
diff --git a/libraries/designsystem/src/main/res/drawable/ic_poll.xml b/libraries/designsystem/src/main/res/drawable/ic_poll.xml
new file mode 100644
index 0000000000..6167653c82
--- /dev/null
+++ b/libraries/designsystem/src/main/res/drawable/ic_poll.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/libraries/designsystem/src/main/res/drawable/ic_poll_end.xml b/libraries/designsystem/src/main/res/drawable/ic_poll_end.xml
index 21d895e613..86c169cf41 100644
--- a/libraries/designsystem/src/main/res/drawable/ic_poll_end.xml
+++ b/libraries/designsystem/src/main/res/drawable/ic_poll_end.xml
@@ -1,14 +1,21 @@
-
-
+ android:width="22dp"
+ android:height="22dp"
+ android:viewportWidth="22"
+ android:viewportHeight="22">
+
+
+
+
+
diff --git a/libraries/designsystem/src/main/res/drawable/ic_quote.xml b/libraries/designsystem/src/main/res/drawable/ic_quote.xml
new file mode 100644
index 0000000000..8f4768f818
--- /dev/null
+++ b/libraries/designsystem/src/main/res/drawable/ic_quote.xml
@@ -0,0 +1,18 @@
+
+
+
+
+
+
diff --git a/libraries/designsystem/src/main/res/drawable/ic_search.xml b/libraries/designsystem/src/main/res/drawable/ic_search.xml
new file mode 100644
index 0000000000..440aef72f6
--- /dev/null
+++ b/libraries/designsystem/src/main/res/drawable/ic_search.xml
@@ -0,0 +1,25 @@
+
+
+
+
+
diff --git a/libraries/designsystem/src/main/res/drawable/ic_strikethrough.xml b/libraries/designsystem/src/main/res/drawable/ic_strikethrough.xml
new file mode 100644
index 0000000000..4469c5572d
--- /dev/null
+++ b/libraries/designsystem/src/main/res/drawable/ic_strikethrough.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/libraries/designsystem/src/main/res/drawable/ic_thread_decoration.xml b/libraries/designsystem/src/main/res/drawable/ic_thread_decoration.xml
new file mode 100644
index 0000000000..09d4ad4ace
--- /dev/null
+++ b/libraries/designsystem/src/main/res/drawable/ic_thread_decoration.xml
@@ -0,0 +1,15 @@
+
+
+
+
+
diff --git a/libraries/designsystem/src/main/res/drawable/ic_underline.xml b/libraries/designsystem/src/main/res/drawable/ic_underline.xml
new file mode 100644
index 0000000000..9da2f2e0b4
--- /dev/null
+++ b/libraries/designsystem/src/main/res/drawable/ic_underline.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/libraries/designsystem/src/main/res/drawable/onboarding_bg.png b/libraries/designsystem/src/main/res/drawable/onboarding_bg.png
index 2af2e1c907..3b9468e357 100644
Binary files a/libraries/designsystem/src/main/res/drawable/onboarding_bg.png and b/libraries/designsystem/src/main/res/drawable/onboarding_bg.png differ
diff --git a/libraries/designsystem/src/test/kotlin/io/element/android/libraries/designsystem/colors/AvatarColorsTest.kt b/libraries/designsystem/src/test/kotlin/io/element/android/libraries/designsystem/colors/AvatarColorsTest.kt
new file mode 100644
index 0000000000..7b3392607d
--- /dev/null
+++ b/libraries/designsystem/src/test/kotlin/io/element/android/libraries/designsystem/colors/AvatarColorsTest.kt
@@ -0,0 +1,49 @@
+/*
+ * Copyright (c) 2023 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.element.android.libraries.designsystem.colors
+
+import com.google.common.truth.Truth.assertThat
+import io.element.android.libraries.theme.colors.avatarColorsDark
+import io.element.android.libraries.theme.colors.avatarColorsLight
+import org.junit.Test
+
+class AvatarColorsTest {
+
+ @Test
+ fun `ensure the size of the avatar color are equal for light and dark theme`() {
+ assertThat(avatarColorsDark.size).isEqualTo(avatarColorsLight.size)
+ }
+
+ @Test
+ fun `compute string hash`() {
+ assertThat("@alice:domain.org".toHash()).isEqualTo(6)
+ assertThat("@bob:domain.org".toHash()).isEqualTo(3)
+ assertThat("@charlie:domain.org".toHash()).isEqualTo(0)
+ }
+
+ @Test
+ fun `compute string hash reverse`() {
+ assertThat("0".toHash()).isEqualTo(0)
+ assertThat("1".toHash()).isEqualTo(1)
+ assertThat("2".toHash()).isEqualTo(2)
+ assertThat("3".toHash()).isEqualTo(3)
+ assertThat("4".toHash()).isEqualTo(4)
+ assertThat("5".toHash()).isEqualTo(5)
+ assertThat("6".toHash()).isEqualTo(6)
+ assertThat("7".toHash()).isEqualTo(7)
+ }
+}
diff --git a/libraries/eventformatter/impl/src/main/res/values-de/translations.xml b/libraries/eventformatter/impl/src/main/res/values-de/translations.xml
index 0ca17bc4fe..a7773e49c7 100644
--- a/libraries/eventformatter/impl/src/main/res/values-de/translations.xml
+++ b/libraries/eventformatter/impl/src/main/res/values-de/translations.xml
@@ -1,57 +1,57 @@
- "(Profilbild wurde auch geändert)"
- "%1$s hat sein Profilbild geändert"
- "Du hast deinen Avatar geändert"
- "%1$s hat seinen Anzeigenamen von %2$s in %3$s geändert"
- "Du hast deinen Anzeigenamen von %1$s in %2$s geändert"
- "%1$s hat seinen Anzeigenamen entfernt (es war %2$s)"
- "Du hast deinen Anzeigenamen entfernt (es war %1$s)"
- "%1$s hat seinen Anzeigenamen zu %2$s geändert"
- "Du hast deinen Anzeigenamen auf %1$s geändert"
+ "(Avatar wurde auch geändert)"
+ "%1$s hat den Avatar geändert"
+ "Sie haben Ihren Avatar geändert"
+ "%1$s hat den Anzeigenamen von %2$s auf %3$s geändert"
+ "Sie haben Ihren Anzeigenamen von %1$s auf %2$s geändert"
+ "%1$s hat den Anzeigenamen entfernt (war %2$s)"
+ "Sie haben Ihren Anzeigenamen entfernt (war %1$s)"
+ "%1$s setzen ihren Anzeigenamen auf %2$s"
+ "Sie haben Ihren Anzeigenamen zu %1$s geändert"
"%1$s hat den Raum-Avatar geändert"
- "Du hast den Raum-Avatar geändert"
- "%1$s hat das Raumbild entfernt"
- "Du hast das Raumbild entfernt"
- "%1$s hat %2$s gebannt"
- "Du hast %1$s gebannt"
+ "Sie haben den Raum-Avatar geändert"
+ "%1$s hat den Raum-Avatar entfernt"
+ "Sie haben den Raum-Avatar entfernt"
+ "%1$s hat %2$s gesperrt"
+ "Sie haben %1$s gesperrt"
"%1$s hat den Raum erstellt"
- "Du hast den Raum erstellt"
+ "Sie haben den Raum erstellt"
"%1$s hat %2$s eingeladen"
"%1$s hat die Einladung angenommen"
- "Du hast die Einladung angenommen"
- "Du hast %1$s eingeladen"
+ "Sie haben die Einladung angenommen"
+ "Sie haben %1$s eingeladen"
"%1$s hat dich eingeladen"
- "%1$s ist dem Raum beigetreten"
- "Du bist dem Raum beigetreten"
- "%1$s hat um Beitritt gebeten"
- "%1$s hat %2$s erlaubt, beizutreten"
- "%1$s hat dir erlaubt beizutreten"
- "Du hast um Beitritt gebeten"
+ "%1$s hat den Raum betreten"
+ "Sie haben den Raum betreten"
+ "%1$s hat angefragt beizutreten"
+ "%1$s hat %2$s den Beitritt erlaubt"
+ "%1$s hat Ihnen den Betritt erlaubt"
+ "Sie haben angefragt beizutreten"
"%1$s hat die Beitrittsanfrage von %2$s abgelehnt"
- "Du hast die Beitrittsanfrage von %1$s abgelehnt"
- "%1$s hat deine Beitrittsanfrage abgelehnt"
- "%1$s ist nicht mehr daran interessiert, beizutreten"
- "Du hast deine Beitrittsanfrage zurückgezogen"
+ "Sie haben die Beitrittsanfrage von %1$s abgelehnt"
+ "%1$s hat Ihre Beitrittsanfrage abgelehnt"
+ "%1$s ist nicht mehr an einem Beitritt interessiert"
+ "Sie haben Ihre Beitrittsanfrage zurückgezogen"
"%1$s hat den Raum verlassen"
- "Du hast den Raum verlassen"
+ "Sie haben den Raum verlassen"
"%1$s hat den Raumnamen geändert in: %2$s"
- "Du hast den Raumnamen geändert in: %1$s"
+ "Sie haben den Raumnamen geändert in: %1$s"
"%1$s hat den Raumnamen entfernt"
- "Du hast den Raumnamen entfernt"
+ "Sie haben den Raumnamen entfernt"
"%1$s hat die Einladung abgelehnt"
- "Du hast die Einladung abgelehnt"
+ "Sie haben die Einladung abgelehnt"
"%1$s hat %2$s entfernt"
- "Du hast %1$s entfernt"
- "%1$s hat eine Einladung an %2$s gesendet, um dem Raum beizutreten"
- "Du hast eine Einladung an %1$s gesendet, um dem Raum beizutreten"
- "%1$s hat die Einladung für %2$s widerrufen, dem Raum beizutreten"
- "Du hast die Einladung für %1$s widerrufen, dem Raum beizutreten"
- "%1$s hat das Thema geändert zu: %2$s"
- "Du hast das Thema geändert zu: %1$s"
+ "Sie haben %1$s entfernt"
+ "%1$s hat eine Einladung an %2$s gesendet, dem Raum beizutreten"
+ "Sie haben eine Einladung an %1$s gesendet, dem Raum beizutreten"
+ "%1$s hat die Einladung an %2$s zum Betreten des Raums zurückgezogen"
+ "Sie haben die Einladung an %1$s zum Betreten des Raums zurückgezogen"
+ "%1$s hat das Thema geändert in: %2$s"
+ "Sie haben das Thema geändert in: %1$s"
"%1$s hat das Raumthema entfernt"
- "Du hast das Raumthema entfernt"
- "%1$s hat %2$s entbannt"
- "Du hast %1$s entbannt"
- "%1$s hat eine unbekannte Änderung an seiner Mitgliedschaft vorgenommen"
+ "Sie haben das Raumthema entfernt"
+ "%1$s hat die Sperre für %2$s aufgehoben"
+ "Sie haben die Sperre für %1$s aufgehoben"
+ "%1$s hat eine unbekannte Raumänderung vorgenommen"
diff --git a/libraries/eventformatter/impl/src/main/res/values-fr/translations.xml b/libraries/eventformatter/impl/src/main/res/values-fr/translations.xml
index e7e07c0852..f7c12a2028 100644
--- a/libraries/eventformatter/impl/src/main/res/values-fr/translations.xml
+++ b/libraries/eventformatter/impl/src/main/res/values-fr/translations.xml
@@ -1,57 +1,57 @@
- "(l\'avatar a aussi été modifié)"
+ "(l’avatar a aussi été modifié)"
"%1$s a changé son avatar"
- "Vous avez changé d\'avatar"
- "%1$s a changé son nom d\'affichage de %2$s à %3$s"
- "Vous avez changé votre nom d\'affichage de %1$s à %2$s"
- "%1$s a supprimé son nom d\'affichage (il s\'agissait de %2$s)"
- "Vous avez supprimé votre nom d\'affichage (il s\'agissait de %1$s)"
- "%1$s a défini son nom d\'affichage en tant que %2$s"
- "Vous avez défini votre nom d\'affichage en tant que %1$s"
- "%1$s a changé l\'avatar du salon"
- "Vous avez changé l\'avatar du salon"
- "%1$s a supprimé l\'avatar du salon"
- "Vous avez supprimé l\'avatar du salon"
+ "Vous avez changé d’avatar"
+ "%1$s a changé son pseudonyme de %2$s à %3$s"
+ "Vous avez changé votre pseudonyme de %1$s à %2$s"
+ "%1$s a supprimé son pseudonyme (c’était %2$s)"
+ "Vous avez supprimé votre pseudonyme (c’était %1$s)"
+ "%1$s a défini son pseudonyme en tant que %2$s"
+ "Vous avez défini votre pseudonyme comme %1$s"
+ "%1$s a changé l’avatar du salon"
+ "Vous avez changé l’avatar du salon"
+ "%1$s a supprimé l’avatar du salon"
+ "Vous avez supprimé l’avatar du salon"
"%1$s a banni %2$s"
"Vous avez banni %1$s"
"%1$s a créé le salon"
"Vous avez créé le salon"
"%1$s a invité %2$s"
- "%1$s a accepté l\'invitation"
- "Vous avez accepté l\'invitation"
+ "%1$s a accepté l’invitation"
+ "Vous avez accepté l’invitation"
"Vous avez invité %1$s"
- "%1$s vous a invité."
+ "%1$s vous a invité(e)"
"%1$s a rejoint le salon"
"Vous avez rejoint le salon"
"%1$s a demandé à rejoindre"
"%1$s a autorisé %2$s à rejoindre"
"%1$s vous a autorisé à rejoindre"
"Vous avez demandé à rejoindre"
- "%1$s a rejeté la demande d\'adhésion de %2$s"
- "Vous avez rejeté la demande d\'adhésion de %1$s"
- "%1$s a rejeté votre demande d\'adhésion"
+ "%1$s a rejeté la demande de %2$s pour rejoindre"
+ "Vous avez rejeté la demande de %1$s pour rejoindre"
+ "%1$s a rejeté votre demande pour rejoindre"
"%1$s n’est plus intéressé à rejoindre"
- "Vous avez annulé votre demande d\'adhésion"
+ "Vous avez annulé votre demande d’adhésion"
"%1$s a quitté le salon"
"Vous avez quitté le salon"
"%1$s a changé le nom du salon en : %2$s"
"Vous avez changé le nom du salon en : %1$s"
"%1$s a supprimé le nom du salon"
"Vous avez supprimé le nom du salon"
- "%1$s a rejeté l\'invitation"
- "Vous avez refusé l\'invitation"
+ "%1$s a rejeté l’invitation"
+ "Vous avez refusé l’invitation"
"%1$s a supprimé %2$s"
"Vous avez supprimé %1$s"
"%1$s a envoyé une invitation à %2$s à rejoindre le salon"
"Vous avez envoyé une invitation à %1$s pour rejoindre le salon"
- "%1$s a révoqué l\'invitation de %2$s à rejoindre le salon"
- "Vous avez révoqué l\'invitation de %1$s à rejoindre le salon"
- "%1$s a changé le sujet en : %2$s"
- "Vous avez changé le sujet en : %1$s"
+ "%1$s a révoqué l’invitation de %2$s à rejoindre le salon"
+ "Vous avez révoqué l’invitation de %1$s à rejoindre le salon"
+ "%1$s a changé le sujet pour : %2$s"
+ "Vous avez changé le sujet pour : %1$s"
"%1$s a supprimé le sujet du salon"
"Vous avez supprimé le sujet du salon"
"%1$s a débanni %2$s"
"Vous avez débanni %1$s"
- "%1$s a apporté une modification inconnue à son adhésion"
+ "%1$s a effectué un changement inconnu à son adhésion"
diff --git a/libraries/eventformatter/impl/src/main/res/values-zh-rTW/translations.xml b/libraries/eventformatter/impl/src/main/res/values-zh-rTW/translations.xml
index 45ab0acee1..2453e2d825 100644
--- a/libraries/eventformatter/impl/src/main/res/values-zh-rTW/translations.xml
+++ b/libraries/eventformatter/impl/src/main/res/values-zh-rTW/translations.xml
@@ -1,37 +1,37 @@
- "%1$s將他的顯示名稱從%2$s變更為%3$s"
- "您將您的顯示名稱從%1$s1變更為%2$s"
- "%1$s的顯示名稱已被本人移除(原為%2$s)"
- "您的顯示名稱已被您移除(原為%1$s)"
- "%1$s將他的顯示名稱設為%2$s"
- "您將您的顯示名稱設為%1$s"
- "%1$s建立此聊天室"
+ "%1$s 將他的顯示名稱從 %2$s 變更為 %3$s"
+ "您將您的顯示名稱從 %1$s1 變更為 %2$s"
+ "%1$s 的顯示名稱已被本人移除(原為 %2$s)"
+ "您的顯示名稱已被您移除(原為 %1$s)"
+ "%1$s 將他的顯示名稱設為 %2$s"
+ "您將您的顯示名稱設為 %1$s"
+ "%1$s 建立此聊天室"
"您建立此聊天室"
- "%1$s邀請%2$s"
- "%1$s接受邀請"
+ "%1$s 邀請 %2$s"
+ "%1$s 接受邀請"
"您接受邀請"
- "您邀請%1$s"
- "%1$s邀請您"
- "%1$s加入聊天室"
+ "您邀請 %1$s"
+ "%1$s 邀請您"
+ "%1$s 加入聊天室"
"您加入聊天室"
- "%1$s請求加入"
+ "%1$s 請求加入"
"您請求加入"
- "%1$s拒絕%2$s的加入請求"
- "您拒絕%1$s的加入請求"
- "%1$s拒絕您的加入請求"
- "%1$s離開聊天室"
+ "%1$s 拒絕 %2$s 的加入請求"
+ "您拒絕 %1$s 的加入請求"
+ "%1$s 拒絕您的加入請求"
+ "%1$s 離開聊天室"
"您離開聊天室"
- "%1$s將聊天室名稱變更為%2$s"
- "您將聊天室名稱變更為%1$s"
- "聊天室名稱已被%1$s移除"
+ "%1$s 將聊天室名稱變更為 %2$s"
+ "您將聊天室名稱變更為 %1$s"
+ "聊天室名稱已被 %1$s 移除"
"聊天室名稱已被您移除"
- "%2$s已被%1$s移除"
- "%1$s已被您移除"
- "%1$s邀請%2$s加入聊天室"
- "您邀請%1$s加入聊天室"
- "%1$s將主題變更為%2$s"
- "您將主題變更為%1$s"
- "聊天室主題已被%1$s移除"
+ "%2$s 已被 %1$s 移除"
+ "%1$s 已被您移除"
+ "%1$s 邀請 %2$s 加入聊天室"
+ "您邀請 %1$s 加入聊天室"
+ "%1$s 將主題變更為 %2$s"
+ "您將主題變更為 %1$s"
+ "聊天室主題已被 %1$s 移除"
"聊天室主題已被您移除"
diff --git a/libraries/eventformatter/impl/src/test/kotlin/io/element/android/libraries/eventformatter/impl/DefaultRoomLastMessageFormatterTests.kt b/libraries/eventformatter/impl/src/test/kotlin/io/element/android/libraries/eventformatter/impl/DefaultRoomLastMessageFormatterTests.kt
index 494c63784d..0f22105635 100644
--- a/libraries/eventformatter/impl/src/test/kotlin/io/element/android/libraries/eventformatter/impl/DefaultRoomLastMessageFormatterTests.kt
+++ b/libraries/eventformatter/impl/src/test/kotlin/io/element/android/libraries/eventformatter/impl/DefaultRoomLastMessageFormatterTests.kt
@@ -153,7 +153,7 @@ class DefaultRoomLastMessageFormatterTests {
fun `Message contents`() {
val body = "Shared body"
fun createMessageContent(type: MessageType): MessageContent {
- return MessageContent(body, null, false, type)
+ return MessageContent(body, null, false, false,type)
}
val sharedContentMessagesTypes = arrayOf(
diff --git a/libraries/featureflag/api/src/main/kotlin/io/element/android/libraries/featureflag/api/FeatureFlagService.kt b/libraries/featureflag/api/src/main/kotlin/io/element/android/libraries/featureflag/api/FeatureFlagService.kt
index 59e224a1ae..8089c837ff 100644
--- a/libraries/featureflag/api/src/main/kotlin/io/element/android/libraries/featureflag/api/FeatureFlagService.kt
+++ b/libraries/featureflag/api/src/main/kotlin/io/element/android/libraries/featureflag/api/FeatureFlagService.kt
@@ -28,7 +28,8 @@ interface FeatureFlagService {
* @param feature the feature to enable or disable
* @param enabled true to enable the feature
*
- * @return true if the method succeeds, ie if a RuntimeFeatureFlagProvider is registered
+ * @return true if the method succeeds, ie if a [io.element.android.libraries.featureflag.impl.MutableFeatureFlagProvider]
+ * is registered
*/
suspend fun setFeatureEnabled(feature: Feature, enabled: Boolean): Boolean
}
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 312745a4df..990aee3a93 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
@@ -16,25 +16,31 @@
package io.element.android.libraries.featureflag.api
+/**
+ * To enable or disable a FeatureFlags, change the `defaultValue` value.
+ * Warning: to enable a flag for the release app, you MUST update the file
+ * [io.element.android.libraries.featureflag.impl.StaticFeatureFlagProvider]
+ */
enum class FeatureFlags(
override val key: String,
override val title: String,
override val description: String? = null,
- override val defaultValue: Boolean = true
+ override val defaultValue: Boolean
) : Feature {
LocationSharing(
key = "feature.locationsharing",
title = "Allow user to share location",
+ defaultValue = true,
),
Polls(
key = "feature.polls",
title = "Polls",
description = "Create poll and render poll events in the timeline",
+ defaultValue = true,
),
- UseEncryptionSync(
- key = "feature.useencryptionsync",
- title = "Use encryption sync",
- description = "Use the encryption sync API for decrypting notifications.",
+ NotificationSettings(
+ key = "feature.notificationsettings",
+ title = "Show notification settings",
defaultValue = true,
- )
+ ),
}
diff --git a/libraries/featureflag/impl/src/main/kotlin/io/element/android/libraries/featureflag/impl/DefaultFeatureFlagService.kt b/libraries/featureflag/impl/src/main/kotlin/io/element/android/libraries/featureflag/impl/DefaultFeatureFlagService.kt
index 7298929aea..b445599693 100644
--- a/libraries/featureflag/impl/src/main/kotlin/io/element/android/libraries/featureflag/impl/DefaultFeatureFlagService.kt
+++ b/libraries/featureflag/impl/src/main/kotlin/io/element/android/libraries/featureflag/impl/DefaultFeatureFlagService.kt
@@ -38,7 +38,7 @@ class DefaultFeatureFlagService @Inject constructor(
}
override suspend fun setFeatureEnabled(feature: Feature, enabled: Boolean): Boolean {
- return providers.filterIsInstance(RuntimeFeatureFlagProvider::class.java)
+ return providers.filterIsInstance(MutableFeatureFlagProvider::class.java)
.sortedBy(FeatureFlagProvider::priority)
.firstOrNull()
?.setFeatureEnabled(feature, enabled)
diff --git a/libraries/featureflag/impl/src/main/kotlin/io/element/android/libraries/featureflag/impl/RuntimeFeatureFlagProvider.kt b/libraries/featureflag/impl/src/main/kotlin/io/element/android/libraries/featureflag/impl/MutableFeatureFlagProvider.kt
similarity index 92%
rename from libraries/featureflag/impl/src/main/kotlin/io/element/android/libraries/featureflag/impl/RuntimeFeatureFlagProvider.kt
rename to libraries/featureflag/impl/src/main/kotlin/io/element/android/libraries/featureflag/impl/MutableFeatureFlagProvider.kt
index 1238ad354c..7e2da181b6 100644
--- a/libraries/featureflag/impl/src/main/kotlin/io/element/android/libraries/featureflag/impl/RuntimeFeatureFlagProvider.kt
+++ b/libraries/featureflag/impl/src/main/kotlin/io/element/android/libraries/featureflag/impl/MutableFeatureFlagProvider.kt
@@ -18,6 +18,6 @@ package io.element.android.libraries.featureflag.impl
import io.element.android.libraries.featureflag.api.Feature
-interface RuntimeFeatureFlagProvider : FeatureFlagProvider {
+interface MutableFeatureFlagProvider : FeatureFlagProvider {
suspend fun setFeatureEnabled(feature: Feature, enabled: Boolean)
}
diff --git a/libraries/featureflag/impl/src/main/kotlin/io/element/android/libraries/featureflag/impl/PreferencesFeatureFlagProvider.kt b/libraries/featureflag/impl/src/main/kotlin/io/element/android/libraries/featureflag/impl/PreferencesFeatureFlagProvider.kt
index 15ab08b338..ddffdebd34 100644
--- a/libraries/featureflag/impl/src/main/kotlin/io/element/android/libraries/featureflag/impl/PreferencesFeatureFlagProvider.kt
+++ b/libraries/featureflag/impl/src/main/kotlin/io/element/android/libraries/featureflag/impl/PreferencesFeatureFlagProvider.kt
@@ -30,12 +30,13 @@ import javax.inject.Inject
private val Context.dataStore: DataStore by preferencesDataStore(name = "elementx_featureflag")
-class PreferencesFeatureFlagProvider @Inject constructor(@ApplicationContext context: Context) : RuntimeFeatureFlagProvider {
-
+/**
+ * Note: this will be used only in the nightly and in the debug build.
+ */
+class PreferencesFeatureFlagProvider @Inject constructor(@ApplicationContext context: Context) : MutableFeatureFlagProvider {
private val store = context.dataStore
- override val priority: Int
- get() = MEDIUM_PRIORITY
+ override val priority = MEDIUM_PRIORITY
override suspend fun setFeatureEnabled(feature: Feature, enabled: Boolean) {
store.edit { prefs ->
diff --git a/libraries/featureflag/impl/src/main/kotlin/io/element/android/libraries/featureflag/impl/BuildtimeFeatureFlagProvider.kt b/libraries/featureflag/impl/src/main/kotlin/io/element/android/libraries/featureflag/impl/StaticFeatureFlagProvider.kt
similarity index 76%
rename from libraries/featureflag/impl/src/main/kotlin/io/element/android/libraries/featureflag/impl/BuildtimeFeatureFlagProvider.kt
rename to libraries/featureflag/impl/src/main/kotlin/io/element/android/libraries/featureflag/impl/StaticFeatureFlagProvider.kt
index 5640d108a6..82184d510c 100644
--- a/libraries/featureflag/impl/src/main/kotlin/io/element/android/libraries/featureflag/impl/BuildtimeFeatureFlagProvider.kt
+++ b/libraries/featureflag/impl/src/main/kotlin/io/element/android/libraries/featureflag/impl/StaticFeatureFlagProvider.kt
@@ -20,18 +20,21 @@ import io.element.android.libraries.featureflag.api.Feature
import io.element.android.libraries.featureflag.api.FeatureFlags
import javax.inject.Inject
-class BuildtimeFeatureFlagProvider @Inject constructor() :
+/**
+ * This provider is used for release build.
+ * This is the place to enable or disable feature for the release build.
+ */
+class StaticFeatureFlagProvider @Inject constructor() :
FeatureFlagProvider {
- override val priority: Int
- get() = LOW_PRIORITY
+ override val priority = LOW_PRIORITY
override suspend fun isFeatureEnabled(feature: Feature): Boolean {
return if (feature is FeatureFlags) {
- when (feature) {
+ when(feature) {
FeatureFlags.LocationSharing -> true
- FeatureFlags.Polls -> false
- FeatureFlags.UseEncryptionSync -> true
+ FeatureFlags.Polls -> true
+ FeatureFlags.NotificationSettings -> true
}
} else {
false
diff --git a/libraries/featureflag/impl/src/main/kotlin/io/element/android/libraries/featureflag/impl/di/FeatureFlagModule.kt b/libraries/featureflag/impl/src/main/kotlin/io/element/android/libraries/featureflag/impl/di/FeatureFlagModule.kt
index 07ee53ceee..b2f0a4106d 100644
--- a/libraries/featureflag/impl/src/main/kotlin/io/element/android/libraries/featureflag/impl/di/FeatureFlagModule.kt
+++ b/libraries/featureflag/impl/src/main/kotlin/io/element/android/libraries/featureflag/impl/di/FeatureFlagModule.kt
@@ -22,7 +22,7 @@ import dagger.Provides
import dagger.multibindings.ElementsIntoSet
import io.element.android.libraries.core.meta.BuildType
import io.element.android.libraries.di.AppScope
-import io.element.android.libraries.featureflag.impl.BuildtimeFeatureFlagProvider
+import io.element.android.libraries.featureflag.impl.StaticFeatureFlagProvider
import io.element.android.libraries.featureflag.impl.FeatureFlagProvider
import io.element.android.libraries.featureflag.impl.PreferencesFeatureFlagProvider
@@ -35,14 +35,18 @@ object FeatureFlagModule {
@ElementsIntoSet
fun providesFeatureFlagProvider(
buildType: BuildType,
- runtimeFeatureFlagProvider: PreferencesFeatureFlagProvider,
- buildtimeFeatureFlagProvider: BuildtimeFeatureFlagProvider,
+ mutableFeatureFlagProvider: PreferencesFeatureFlagProvider,
+ staticFeatureFlagProvider: StaticFeatureFlagProvider,
): Set {
val providers = HashSet()
- if (buildType == BuildType.RELEASE) {
- providers.add(buildtimeFeatureFlagProvider)
- } else {
- providers.add(runtimeFeatureFlagProvider)
+ when (buildType) {
+ BuildType.RELEASE -> {
+ providers.add(staticFeatureFlagProvider)
+ }
+ BuildType.NIGHTLY,
+ BuildType.DEBUG -> {
+ providers.add(mutableFeatureFlagProvider)
+ }
}
return providers
}
diff --git a/libraries/featureflag/impl/src/test/kotlin/io/element/android/libraries/featureflag/impl/DefaultFeatureFlagServiceTest.kt b/libraries/featureflag/impl/src/test/kotlin/io/element/android/libraries/featureflag/impl/DefaultFeatureFlagServiceTest.kt
index ea9b03acdf..890959639f 100644
--- a/libraries/featureflag/impl/src/test/kotlin/io/element/android/libraries/featureflag/impl/DefaultFeatureFlagServiceTest.kt
+++ b/libraries/featureflag/impl/src/test/kotlin/io/element/android/libraries/featureflag/impl/DefaultFeatureFlagServiceTest.kt
@@ -39,7 +39,7 @@ class DefaultFeatureFlagServiceTest {
@Test
fun `given service with a runtime provider when set enabled feature is called then it returns true`() = runTest {
- val featureFlagProvider = FakeRuntimeFeatureFlagProvider(0)
+ val featureFlagProvider = FakeMutableFeatureFlagProvider(0)
val featureFlagService = DefaultFeatureFlagService(setOf(featureFlagProvider))
val result = featureFlagService.setFeatureEnabled(FeatureFlags.LocationSharing, true)
assertThat(result).isEqualTo(true)
@@ -47,7 +47,7 @@ class DefaultFeatureFlagServiceTest {
@Test
fun `given service with a runtime provider and feature enabled when feature is checked then it returns the correct value`() = runTest {
- val featureFlagProvider = FakeRuntimeFeatureFlagProvider(0)
+ val featureFlagProvider = FakeMutableFeatureFlagProvider(0)
val featureFlagService = DefaultFeatureFlagService(setOf(featureFlagProvider))
featureFlagService.setFeatureEnabled(FeatureFlags.LocationSharing, true)
assertThat(featureFlagService.isFeatureEnabled(FeatureFlags.LocationSharing)).isEqualTo(true)
@@ -57,11 +57,11 @@ class DefaultFeatureFlagServiceTest {
@Test
fun `given service with 2 runtime providers when feature is checked then it uses the priority correctly`() = runTest {
- val lowPriorityfeatureFlagProvider = FakeRuntimeFeatureFlagProvider(LOW_PRIORITY)
- val highPriorityfeatureFlagProvider = FakeRuntimeFeatureFlagProvider(HIGH_PRIORITY)
- val featureFlagService = DefaultFeatureFlagService(setOf(lowPriorityfeatureFlagProvider, highPriorityfeatureFlagProvider))
- lowPriorityfeatureFlagProvider.setFeatureEnabled(FeatureFlags.LocationSharing, false)
- highPriorityfeatureFlagProvider.setFeatureEnabled(FeatureFlags.LocationSharing, true)
+ val lowPriorityFeatureFlagProvider = FakeMutableFeatureFlagProvider(LOW_PRIORITY)
+ val highPriorityFeatureFlagProvider = FakeMutableFeatureFlagProvider(HIGH_PRIORITY)
+ val featureFlagService = DefaultFeatureFlagService(setOf(lowPriorityFeatureFlagProvider, highPriorityFeatureFlagProvider))
+ lowPriorityFeatureFlagProvider.setFeatureEnabled(FeatureFlags.LocationSharing, false)
+ highPriorityFeatureFlagProvider.setFeatureEnabled(FeatureFlags.LocationSharing, true)
assertThat(featureFlagService.isFeatureEnabled(FeatureFlags.LocationSharing)).isEqualTo(true)
}
}
diff --git a/libraries/featureflag/impl/src/test/kotlin/io/element/android/libraries/featureflag/impl/FakeRuntimeFeatureFlagProvider.kt b/libraries/featureflag/impl/src/test/kotlin/io/element/android/libraries/featureflag/impl/FakeMutableFeatureFlagProvider.kt
similarity index 92%
rename from libraries/featureflag/impl/src/test/kotlin/io/element/android/libraries/featureflag/impl/FakeRuntimeFeatureFlagProvider.kt
rename to libraries/featureflag/impl/src/test/kotlin/io/element/android/libraries/featureflag/impl/FakeMutableFeatureFlagProvider.kt
index 5ff5cf932f..f1d075ace2 100644
--- a/libraries/featureflag/impl/src/test/kotlin/io/element/android/libraries/featureflag/impl/FakeRuntimeFeatureFlagProvider.kt
+++ b/libraries/featureflag/impl/src/test/kotlin/io/element/android/libraries/featureflag/impl/FakeMutableFeatureFlagProvider.kt
@@ -18,7 +18,7 @@ package io.element.android.libraries.featureflag.impl
import io.element.android.libraries.featureflag.api.Feature
-class FakeRuntimeFeatureFlagProvider(override val priority: Int) : RuntimeFeatureFlagProvider {
+class FakeMutableFeatureFlagProvider(override val priority: Int) : MutableFeatureFlagProvider {
private val enabledFeatures = HashMap()
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 2ca4420145..fc215e4780 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
@@ -23,6 +23,8 @@ import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.createroom.CreateRoomParameters
import io.element.android.libraries.matrix.api.media.MatrixMediaLoader
import io.element.android.libraries.matrix.api.notification.NotificationService
+import io.element.android.libraries.matrix.api.notificationsettings.NotificationSettingsService
+import io.element.android.libraries.matrix.api.oidc.AccountManagementAction
import io.element.android.libraries.matrix.api.pusher.PushersService
import io.element.android.libraries.matrix.api.room.MatrixRoom
import io.element.android.libraries.matrix.api.room.RoomMembershipObserver
@@ -45,10 +47,14 @@ interface MatrixClient : Closeable {
suspend fun createDM(userId: UserId): Result
suspend fun getProfile(userId: UserId): Result
suspend fun searchUsers(searchTerm: String, limit: Long): Result
+ suspend fun setDisplayName(displayName: String): Result
+ suspend fun uploadAvatar(mimeType: String, data: ByteArray): Result
+ suspend fun removeAvatar(): Result
fun syncService(): SyncService
fun sessionVerificationService(): SessionVerificationService
fun pushersService(): PushersService
fun notificationService(): NotificationService
+ fun notificationSettingsService(): NotificationSettingsService
suspend fun getCacheSize(): Long
/**
@@ -64,7 +70,7 @@ interface MatrixClient : Closeable {
suspend fun logout(): String?
suspend fun loadUserDisplayName(): Result
suspend fun loadUserAvatarURLString(): Result
- suspend fun getAccountManagementUrl(): Result
+ suspend fun getAccountManagementUrl(action: AccountManagementAction?): Result
suspend fun uploadMedia(mimeType: String, data: ByteArray, progressCallback: ProgressCallback?): Result
fun roomMembershipObserver(): RoomMembershipObserver
diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/notificationsettings/NotificationSettingsService.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/notificationsettings/NotificationSettingsService.kt
new file mode 100644
index 0000000000..5a81edb052
--- /dev/null
+++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/notificationsettings/NotificationSettingsService.kt
@@ -0,0 +1,41 @@
+/*
+ * Copyright (c) 2023 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.element.android.libraries.matrix.api.notificationsettings
+
+import io.element.android.libraries.matrix.api.core.RoomId
+import io.element.android.libraries.matrix.api.room.MatrixRoomNotificationSettingsState
+import io.element.android.libraries.matrix.api.room.RoomNotificationMode
+import io.element.android.libraries.matrix.api.room.RoomNotificationSettings
+import kotlinx.coroutines.flow.SharedFlow
+
+interface NotificationSettingsService {
+ /**
+ * State of the current room notification settings flow ([MatrixRoomNotificationSettingsState.Unknown] if not started).
+ */
+ val notificationSettingsChangeFlow : SharedFlow
+ suspend fun getRoomNotificationSettings(roomId: RoomId, isEncrypted: Boolean, isOneToOne: Boolean): Result
+ suspend fun getDefaultRoomNotificationMode(isEncrypted: Boolean, isOneToOne: Boolean): Result
+ suspend fun setDefaultRoomNotificationMode(isEncrypted: Boolean, mode: RoomNotificationMode, isOneToOne: Boolean): Result
+ suspend fun setRoomNotificationMode(roomId: RoomId, mode: RoomNotificationMode): Result
+ suspend fun restoreDefaultRoomNotificationMode(roomId: RoomId): Result
+ suspend fun muteRoom(roomId: RoomId): Result
+ suspend fun unmuteRoom(roomId: RoomId, isEncrypted: Boolean, isOneToOne: Boolean): Result
+ suspend fun isRoomMentionEnabled(): Result
+ suspend fun setRoomMentionEnabled(enabled: Boolean): Result
+ suspend fun isCallEnabled(): Result
+ suspend fun setCallEnabled(enabled: Boolean): Result
+}
diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/oidc/AccountManagementAction.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/oidc/AccountManagementAction.kt
new file mode 100644
index 0000000000..ceb5f4fb71
--- /dev/null
+++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/oidc/AccountManagementAction.kt
@@ -0,0 +1,24 @@
+/*
+ * Copyright (c) 2023 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.element.android.libraries.matrix.api.oidc
+
+sealed interface AccountManagementAction {
+ data object Profile : AccountManagementAction
+ data object SessionsList : AccountManagementAction
+ data class SessionView(val deviceId: String) : AccountManagementAction
+ data class SessionEnd(val deviceId: String) : AccountManagementAction
+}
diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/MatrixRoom.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/MatrixRoom.kt
index 41f105df8e..1dd6101354 100644
--- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/MatrixRoom.kt
+++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/MatrixRoom.kt
@@ -49,6 +49,12 @@ interface MatrixRoom : Closeable {
val activeMemberCount: Long
val joinedMemberCount: Long
+ /**
+ * A one-to-one is a room with exactly 2 members.
+ * See [the Matrix spec](https://spec.matrix.org/latest/client-server-api/#default-underride-rules).
+ */
+ val isOneToOne: Boolean get() = activeMemberCount == 2L
+
/**
* The current loaded members as a StateFlow.
* Initial value is [MatrixRoomMembersState.Unknown].
@@ -56,11 +62,15 @@ interface MatrixRoom : Closeable {
*/
val membersStateFlow: StateFlow
+ val roomNotificationSettingsStateFlow: StateFlow
+
/**
* Try to load the room members and update the membersFlow.
*/
suspend fun updateMembers(): Result
+ suspend fun updateRoomNotificationSettings(): Result
+
val syncUpdateFlow: StateFlow
val timeline: MatrixTimeline
@@ -75,11 +85,11 @@ interface MatrixRoom : Closeable {
suspend fun userAvatarUrl(userId: UserId): Result
- suspend fun sendMessage(message: String): Result
+ suspend fun sendMessage(body: String, htmlBody: String?): Result
- suspend fun editMessage(originalEventId: EventId?, transactionId: TransactionId?, message: String): Result
+ suspend fun editMessage(originalEventId: EventId?, transactionId: TransactionId?, body: String, htmlBody: String?): Result
- suspend fun replyMessage(eventId: EventId, message: String): Result
+ suspend fun replyMessage(eventId: EventId, body: String, htmlBody: String?): Result
suspend fun redactEvent(eventId: EventId, reason: String? = null): Result
@@ -174,6 +184,7 @@ interface MatrixRoom : Closeable {
suspend fun endPoll(pollStartId: EventId, text: String): Result
override fun close() = destroy()
+
}
diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/MatrixRoomNotificationSettingsState.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/MatrixRoomNotificationSettingsState.kt
new file mode 100644
index 0000000000..d98a3a83d2
--- /dev/null
+++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/MatrixRoomNotificationSettingsState.kt
@@ -0,0 +1,33 @@
+/*
+ * Copyright (c) 2023 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.element.android.libraries.matrix.api.room
+
+sealed interface MatrixRoomNotificationSettingsState {
+ object Unknown : MatrixRoomNotificationSettingsState
+ data class Pending(val prevRoomNotificationSettings: RoomNotificationSettings? = null) : MatrixRoomNotificationSettingsState
+ data class Error(val failure: Throwable, val prevRoomNotificationSettings: RoomNotificationSettings? = null) : MatrixRoomNotificationSettingsState
+ data class Ready(val roomNotificationSettings: RoomNotificationSettings) : MatrixRoomNotificationSettingsState
+}
+
+fun MatrixRoomNotificationSettingsState.roomNotificationSettings(): RoomNotificationSettings? {
+ return when (this) {
+ is MatrixRoomNotificationSettingsState.Ready -> roomNotificationSettings
+ is MatrixRoomNotificationSettingsState.Pending -> prevRoomNotificationSettings
+ is MatrixRoomNotificationSettingsState.Error -> prevRoomNotificationSettings
+ else -> null
+ }
+}
diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/RoomNotificationSettings.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/RoomNotificationSettings.kt
new file mode 100644
index 0000000000..23f2b41797
--- /dev/null
+++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/RoomNotificationSettings.kt
@@ -0,0 +1,26 @@
+/*
+ * Copyright (c) 2023 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.element.android.libraries.matrix.api.room
+
+data class RoomNotificationSettings(
+ val mode: RoomNotificationMode,
+ val isDefault: Boolean,
+)
+
+enum class RoomNotificationMode {
+ ALL_MESSAGES, MENTIONS_AND_KEYWORDS_ONLY, MUTE
+}
diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/roomlist/RoomListService.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/roomlist/RoomListService.kt
index 9ae6c22e7d..8b85b3fe38 100644
--- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/roomlist/RoomListService.kt
+++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/roomlist/RoomListService.kt
@@ -32,6 +32,11 @@ interface RoomListService {
data object Terminated : State()
}
+ sealed class SyncIndicator {
+ data object Show : SyncIndicator()
+ data object Hide : SyncIndicator()
+ }
+
/**
* returns a [RoomList] object of all rooms we want to display.
* This will exclude some rooms like the invites, or spaces.
@@ -49,6 +54,16 @@ interface RoomListService {
*/
fun updateAllRoomsVisibleRange(range: IntRange)
+ /**
+ * Rebuild the room summaries, required when we know some data may have changed. (E.g. room notification settings)
+ */
+ fun rebuildRoomSummaries()
+
+ /**
+ * The sync indicator as a flow.
+ */
+ val syncIndicator: StateFlow
+
/**
* The state of the service as a flow.
*/
diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/roomlist/RoomSummary.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/roomlist/RoomSummary.kt
index 87cf2139d6..4638fc6e03 100644
--- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/roomlist/RoomSummary.kt
+++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/roomlist/RoomSummary.kt
@@ -18,6 +18,7 @@ package io.element.android.libraries.matrix.api.roomlist
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.room.RoomMember
+import io.element.android.libraries.matrix.api.room.RoomNotificationMode
import io.element.android.libraries.matrix.api.room.message.RoomMessage
sealed interface RoomSummary {
@@ -42,4 +43,5 @@ data class RoomSummaryDetails(
val lastMessageTimestamp: Long?,
val unreadNotificationCount: Int,
val inviter: RoomMember? = null,
+ val notificationMode: RoomNotificationMode? = null,
)
diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/sync/SyncService.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/sync/SyncService.kt
index 03a8402b32..994b35edc4 100644
--- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/sync/SyncService.kt
+++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/sync/SyncService.kt
@@ -22,12 +22,12 @@ interface SyncService {
/**
* Tries to start the sync. If already syncing it has no effect.
*/
- suspend fun startSync(reason: StartSyncReason): Result
+ suspend fun startSync(): Result
/**
* Tries to stop the sync. If service is not syncing it has no effect.
*/
- suspend fun stopSync(reason: StartSyncReason): Result
+ suspend fun stopSync(): Result
/**
* Flow of [SyncState]. Will be updated as soon as the current [SyncState] changes.
diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/item/event/EventContent.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/item/event/EventContent.kt
index b16e8d2694..a3edf80bee 100644
--- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/item/event/EventContent.kt
+++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/item/event/EventContent.kt
@@ -32,6 +32,7 @@ data class MessageContent(
val body: String,
val inReplyTo: InReplyTo?,
val isEdited: Boolean,
+ val isThreaded: Boolean,
val type: MessageType?
) : EventContent
diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/item/event/EventTimelineItem.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/item/event/EventTimelineItem.kt
index 50bf5f8ce5..49108f8d54 100644
--- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/item/event/EventTimelineItem.kt
+++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/item/event/EventTimelineItem.kt
@@ -40,6 +40,11 @@ data class EventTimelineItem(
fun inReplyTo(): InReplyTo? {
return (content as? MessageContent)?.inReplyTo
}
+
+ fun isThreaded(): Boolean {
+ return (content as? MessageContent)?.isThreaded ?: false
+ }
+
fun hasNotLoadedInReplyTo(): Boolean {
val details = inReplyTo()
return details is InReplyTo.NotLoaded
diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/tracing/TracingFilterConfiguration.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/tracing/TracingFilterConfiguration.kt
index d34911644e..3062fa3aa3 100644
--- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/tracing/TracingFilterConfiguration.kt
+++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/tracing/TracingFilterConfiguration.kt
@@ -19,27 +19,25 @@ package io.element.android.libraries.matrix.api.tracing
data class TracingFilterConfiguration(
val overrides: Map = emptyMap(),
) {
+ private val defaultLogLevel = LogLevel.INFO
// Order should matters
private val targetsToLogLevel: Map = mapOf(
- Target.COMMON to LogLevel.INFO,
Target.HYPER to LogLevel.WARN,
Target.MATRIX_SDK_CRYPTO to LogLevel.DEBUG,
Target.MATRIX_SDK_HTTP_CLIENT to LogLevel.DEBUG,
Target.MATRIX_SDK_SLIDING_SYNC to LogLevel.TRACE,
Target.MATRIX_SDK_BASE_SLIDING_SYNC to LogLevel.TRACE,
- Target.MATRIX_SDK_UI_TIMELINE to LogLevel.INFO,
)
- fun getLogLevel(target: Target): LogLevel? {
- return overrides[target] ?: targetsToLogLevel[target]
+ fun getLogLevel(target: Target): LogLevel {
+ return overrides[target] ?: targetsToLogLevel[target] ?: defaultLogLevel
}
val filter: String
get() {
- val fullMap = targetsToLogLevel.toMutableMap()
- overrides.forEach { (target, logLevel) ->
- fullMap[target] = logLevel
+ val fullMap = Target.values().associateWith {
+ overrides[it] ?: targetsToLogLevel[it] ?: defaultLogLevel
}
return fullMap.map {
if (it.key.filter.isEmpty()) {
@@ -58,7 +56,10 @@ enum class Target(open val filter: String) {
MATRIX_SDK_FFI("matrix_sdk_ffi"),
MATRIX_SDK_UNIFFI_API("matrix_sdk_ffi::uniffi_api"),
MATRIX_SDK_CRYPTO("matrix_sdk_crypto"),
+ MATRIX_SDK("matrix_sdk"),
MATRIX_SDK_HTTP_CLIENT("matrix_sdk::http_client"),
+ MATRIX_SDK_CLIENT("matrix_sdk::client"),
+ MATRIX_SDK_OIDC("matrix_sdk::oidc"),
MATRIX_SDK_SLIDING_SYNC("matrix_sdk::sliding_sync"),
MATRIX_SDK_BASE_SLIDING_SYNC("matrix_sdk_base::sliding_sync"),
MATRIX_SDK_UI_TIMELINE("matrix_sdk_ui::timeline"),
@@ -75,13 +76,11 @@ enum class LogLevel(open val filter: String) {
object TracingFilterConfigurations {
val release = TracingFilterConfiguration(
overrides = mapOf(
- Target.COMMON to LogLevel.INFO,
Target.ELEMENT to LogLevel.DEBUG
),
)
val debug = TracingFilterConfiguration(
overrides = mapOf(
- Target.COMMON to LogLevel.INFO,
Target.ELEMENT to LogLevel.TRACE
)
)
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 4b0afb0b4f..d99c60b286 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
@@ -29,6 +29,8 @@ import io.element.android.libraries.matrix.api.createroom.RoomPreset
import io.element.android.libraries.matrix.api.createroom.RoomVisibility
import io.element.android.libraries.matrix.api.media.MatrixMediaLoader
import io.element.android.libraries.matrix.api.notification.NotificationService
+import io.element.android.libraries.matrix.api.notificationsettings.NotificationSettingsService
+import io.element.android.libraries.matrix.api.oidc.AccountManagementAction
import io.element.android.libraries.matrix.api.pusher.PushersService
import io.element.android.libraries.matrix.api.room.MatrixRoom
import io.element.android.libraries.matrix.api.room.RoomMembershipObserver
@@ -42,6 +44,8 @@ import io.element.android.libraries.matrix.impl.core.toProgressWatcher
import io.element.android.libraries.matrix.impl.mapper.toSessionData
import io.element.android.libraries.matrix.impl.media.RustMediaLoader
import io.element.android.libraries.matrix.impl.notification.RustNotificationService
+import io.element.android.libraries.matrix.impl.notificationsettings.RustNotificationSettingsService
+import io.element.android.libraries.matrix.impl.oidc.toRustAction
import io.element.android.libraries.matrix.impl.pushers.RustPushersService
import io.element.android.libraries.matrix.impl.room.RoomContentForwarder
import io.element.android.libraries.matrix.impl.room.RustMatrixRoom
@@ -65,6 +69,7 @@ import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeout
import org.matrix.rustcomponents.sdk.Client
import org.matrix.rustcomponents.sdk.ClientDelegate
+import org.matrix.rustcomponents.sdk.NotificationProcessSetup
import org.matrix.rustcomponents.sdk.Room
import org.matrix.rustcomponents.sdk.RoomListItem
import org.matrix.rustcomponents.sdk.use
@@ -98,11 +103,17 @@ class RustMatrixClient constructor(
client = client,
dispatchers = dispatchers,
)
- private val notificationClient = client.notificationClient().use { builder ->
- builder.filterByPushRules().finish()
- }
+ private val notificationProcessSetup = NotificationProcessSetup.SingleProcess(syncService)
+ private val notificationClient = client.notificationClient(notificationProcessSetup)
+ .use { builder ->
+ builder
+ .filterByPushRules()
+ .finish()
+ }
+ private val notificationSettings = client.getNotificationSettings()
private val notificationService = RustNotificationService(sessionId, notificationClient, dispatchers, clock)
+ private val notificationSettingsService = RustNotificationSettingsService(notificationSettings, dispatchers)
private val isLoggingOut = AtomicBoolean(false)
@@ -168,6 +179,7 @@ class RustMatrixClient constructor(
sessionId = sessionId,
roomListItem = roomListItem,
innerRoom = fullRoom,
+ roomNotificationSettingsService = notificationSettingsService,
sessionCoroutineScope = sessionCoroutineScope,
coroutineDispatchers = dispatchers,
systemClock = clock,
@@ -264,6 +276,23 @@ class RustMatrixClient constructor(
}
}
+ override suspend fun setDisplayName(displayName: String): Result =
+ withContext(sessionDispatcher) {
+ runCatching { client.setDisplayName(displayName) }
+ }
+
+ @OptIn(ExperimentalUnsignedTypes::class)
+ override suspend fun uploadAvatar(mimeType: String, data: ByteArray): Result =
+ withContext(sessionDispatcher) {
+ runCatching { client.uploadAvatar(mimeType, data.toUByteArray().toList()) }
+ }
+
+ override suspend fun removeAvatar(): Result =
+ withContext(sessionDispatcher) {
+ runCatching { client.removeAvatar() }
+ }
+
+
override fun syncService(): SyncService = rustSyncService
override fun sessionVerificationService(): SessionVerificationService = verificationService
@@ -272,13 +301,18 @@ class RustMatrixClient constructor(
override fun notificationService(): NotificationService = notificationService
+ override fun notificationSettingsService(): NotificationSettingsService = notificationSettingsService
+
override fun close() {
sessionCoroutineScope.cancel()
client.setDelegate(null)
+ notificationSettings.setDelegate(null)
+ notificationSettings.destroy()
verificationService.destroy()
syncService.destroy()
innerRoomListService.destroy()
notificationClient.destroy()
+ notificationProcessSetup.destroy()
client.destroy()
}
@@ -311,11 +345,13 @@ class RustMatrixClient constructor(
return result
}
- override suspend fun getAccountManagementUrl(): Result = withContext(sessionDispatcher) {
+ override suspend fun getAccountManagementUrl(action: AccountManagementAction?): Result = withContext(sessionDispatcher) {
+ val rustAction = action?.toRustAction()
runCatching {
- client.accountUrl()
+ client.accountUrl(rustAction)
}
}
+
override suspend fun loadUserDisplayName(): Result = withContext(sessionDispatcher) {
runCatching {
client.displayName()
diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClientFactory.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClientFactory.kt
index 3615115bb4..b37266342e 100644
--- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClientFactory.kt
+++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClientFactory.kt
@@ -19,8 +19,6 @@ package io.element.android.libraries.matrix.impl
import android.content.Context
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.di.ApplicationContext
-import io.element.android.libraries.featureflag.api.FeatureFlagService
-import io.element.android.libraries.featureflag.api.FeatureFlags
import io.element.android.libraries.network.useragent.UserAgentProvider
import io.element.android.libraries.sessionstorage.api.SessionData
import io.element.android.libraries.sessionstorage.api.SessionStore
@@ -41,7 +39,6 @@ class RustMatrixClientFactory @Inject constructor(
private val sessionStore: SessionStore,
private val userAgentProvider: UserAgentProvider,
private val clock: SystemClock,
- private val featureFlagsService: FeatureFlagService,
) {
suspend fun create(sessionData: SessionData): RustMatrixClient = withContext(coroutineDispatchers.io) {
@@ -56,11 +53,7 @@ class RustMatrixClientFactory @Inject constructor(
client.restoreSession(sessionData.toSession())
- val syncService = client.syncService().apply {
- if (featureFlagsService.isFeatureEnabled(FeatureFlags.UseEncryptionSync)) {
- withEncryptionSync(withCrossProcessLock = false, appIdentifier = null)
- }
- }
+ val syncService = client.syncService()
.finish()
RustMatrixClient(
diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/auth/OidcConfiguration.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/auth/OidcConfiguration.kt
index f49ee65208..ad848ef95a 100644
--- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/auth/OidcConfiguration.kt
+++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/auth/OidcConfiguration.kt
@@ -26,6 +26,9 @@ val oidcConfiguration: OidcConfiguration = OidcConfiguration(
logoUri = "https://element.io/mobile-icon.png",
tosUri = "https://element.io/acceptable-use-policy-terms",
policyUri = "https://element.io/privacy",
+ contacts = listOf(
+ "support@element.io",
+ ),
/**
* Some homeservers/auth issuers don't support dynamic client registration, and have to be registered manually
*/
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 6014644733..f2acb0b1be 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
@@ -57,6 +57,8 @@ class RustMatrixAuthenticationService @Inject constructor(
userAgent = userAgentProvider.provide(),
oidcConfiguration = oidcConfiguration,
customSlidingSyncProxy = null,
+ sessionDelegate = null,
+ crossProcessRefreshLockId = null,
)
private var currentHomeserver = MutableStateFlow(null)
diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/di/SessionMatrixModule.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/di/SessionMatrixModule.kt
index dba1dbd0a3..1159f668e6 100644
--- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/di/SessionMatrixModule.kt
+++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/di/SessionMatrixModule.kt
@@ -22,6 +22,7 @@ import dagger.Provides
import io.element.android.libraries.di.SessionScope
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.media.MatrixMediaLoader
+import io.element.android.libraries.matrix.api.notificationsettings.NotificationSettingsService
import io.element.android.libraries.matrix.api.room.RoomMembershipObserver
import io.element.android.libraries.matrix.api.roomlist.RoomListService
import io.element.android.libraries.matrix.api.verification.SessionVerificationService
@@ -34,6 +35,11 @@ object SessionMatrixModule {
return matrixClient.sessionVerificationService()
}
+ @Provides
+ fun providesNotificationSettingsService(matrixClient: MatrixClient): NotificationSettingsService {
+ return matrixClient.notificationSettingsService()
+ }
+
@Provides
fun provideRoomMembershipObserver(matrixClient: MatrixClient): RoomMembershipObserver {
return matrixClient.roomMembershipObserver()
diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/notificationsettings/RoomNotificationSettingsMapper.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/notificationsettings/RoomNotificationSettingsMapper.kt
new file mode 100644
index 0000000000..084a6f973e
--- /dev/null
+++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/notificationsettings/RoomNotificationSettingsMapper.kt
@@ -0,0 +1,44 @@
+/*
+ * Copyright (c) 2023 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.element.android.libraries.matrix.impl.notificationsettings
+
+import io.element.android.libraries.matrix.api.room.RoomNotificationMode
+import io.element.android.libraries.matrix.api.room.RoomNotificationSettings
+import org.matrix.rustcomponents.sdk.RoomNotificationMode as RustRoomNotificationMode
+import org.matrix.rustcomponents.sdk.RoomNotificationSettings as RustRoomNotificationSettings
+
+object RoomNotificationSettingsMapper {
+ fun map(roomNotificationSettings: RustRoomNotificationSettings): RoomNotificationSettings =
+ RoomNotificationSettings(
+ mode = mapMode(roomNotificationSettings.mode),
+ isDefault = roomNotificationSettings.isDefault
+ )
+
+ fun mapMode(mode: RustRoomNotificationMode): RoomNotificationMode =
+ when (mode) {
+ RustRoomNotificationMode.ALL_MESSAGES -> RoomNotificationMode.ALL_MESSAGES
+ RustRoomNotificationMode.MENTIONS_AND_KEYWORDS_ONLY -> RoomNotificationMode.MENTIONS_AND_KEYWORDS_ONLY
+ RustRoomNotificationMode.MUTE -> RoomNotificationMode.MUTE
+ }
+
+ fun mapMode(mode: RoomNotificationMode): RustRoomNotificationMode =
+ when (mode) {
+ RoomNotificationMode.ALL_MESSAGES -> RustRoomNotificationMode.ALL_MESSAGES
+ RoomNotificationMode.MENTIONS_AND_KEYWORDS_ONLY -> RustRoomNotificationMode.MENTIONS_AND_KEYWORDS_ONLY
+ RoomNotificationMode.MUTE -> RustRoomNotificationMode.MUTE
+ }
+}
diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/notificationsettings/RustNotificationSettingsService.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/notificationsettings/RustNotificationSettingsService.kt
new file mode 100644
index 0000000000..a2fffdbdfb
--- /dev/null
+++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/notificationsettings/RustNotificationSettingsService.kt
@@ -0,0 +1,113 @@
+/*
+ * Copyright (c) 2023 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.element.android.libraries.matrix.impl.notificationsettings
+
+import io.element.android.libraries.core.coroutine.CoroutineDispatchers
+import io.element.android.libraries.matrix.api.core.RoomId
+import io.element.android.libraries.matrix.api.notificationsettings.NotificationSettingsService
+import io.element.android.libraries.matrix.api.room.RoomNotificationMode
+import io.element.android.libraries.matrix.api.room.RoomNotificationSettings
+import kotlinx.coroutines.channels.BufferOverflow
+import kotlinx.coroutines.flow.MutableSharedFlow
+import kotlinx.coroutines.flow.SharedFlow
+import kotlinx.coroutines.flow.asSharedFlow
+import kotlinx.coroutines.withContext
+import org.matrix.rustcomponents.sdk.NotificationSettings
+import org.matrix.rustcomponents.sdk.NotificationSettingsDelegate
+
+class RustNotificationSettingsService(
+ private val notificationSettings: NotificationSettings,
+ private val dispatchers: CoroutineDispatchers,
+) : NotificationSettingsService {
+
+ private val _notificationSettingsChangeFlow = MutableSharedFlow(extraBufferCapacity = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST)
+ override val notificationSettingsChangeFlow: SharedFlow = _notificationSettingsChangeFlow.asSharedFlow()
+
+ private var notificationSettingsDelegate = object : NotificationSettingsDelegate {
+ override fun settingsDidChange() {
+ _notificationSettingsChangeFlow.tryEmit(Unit)
+ }
+ }
+
+ init {
+ notificationSettings.setDelegate(notificationSettingsDelegate)
+ }
+
+ override suspend fun getRoomNotificationSettings(roomId: RoomId, isEncrypted: Boolean, isOneToOne: Boolean): Result =
+ runCatching {
+ notificationSettings.getRoomNotificationSettings(roomId.value, isEncrypted, isOneToOne).let(RoomNotificationSettingsMapper::map)
+ }
+
+ override suspend fun getDefaultRoomNotificationMode(isEncrypted: Boolean, isOneToOne: Boolean): Result =
+ runCatching {
+ notificationSettings.getDefaultRoomNotificationMode(isEncrypted, isOneToOne).let(RoomNotificationSettingsMapper::mapMode)
+ }
+
+ override suspend fun setDefaultRoomNotificationMode(
+ isEncrypted: Boolean,
+ mode: RoomNotificationMode,
+ isOneToOne: Boolean
+ ): Result = withContext(dispatchers.io) {
+ runCatching {
+ notificationSettings.setDefaultRoomNotificationMode(isEncrypted, isOneToOne, mode.let(RoomNotificationSettingsMapper::mapMode))
+ }
+ }
+
+ override suspend fun setRoomNotificationMode(roomId: RoomId, mode: RoomNotificationMode): Result = withContext(dispatchers.io) {
+ runCatching {
+ notificationSettings.setRoomNotificationMode(roomId.value, mode.let(RoomNotificationSettingsMapper::mapMode))
+ }
+ }
+
+ override suspend fun restoreDefaultRoomNotificationMode(roomId: RoomId): Result = withContext(dispatchers.io) {
+ runCatching {
+ notificationSettings.restoreDefaultRoomNotificationMode(roomId.value)
+ }
+ }
+
+ override suspend fun muteRoom(roomId: RoomId): Result = setRoomNotificationMode(roomId, RoomNotificationMode.MUTE)
+
+ override suspend fun unmuteRoom(roomId: RoomId, isEncrypted: Boolean, isOneToOne: Boolean) = withContext(dispatchers.io) {
+ runCatching {
+ notificationSettings.unmuteRoom(roomId.value, isEncrypted, isOneToOne)
+ }
+ }
+
+ override suspend fun isRoomMentionEnabled(): Result = withContext(dispatchers.io) {
+ runCatching {
+ notificationSettings.isRoomMentionEnabled()
+ }
+ }
+
+ override suspend fun setRoomMentionEnabled(enabled: Boolean): Result = withContext(dispatchers.io) {
+ runCatching {
+ notificationSettings.setRoomMentionEnabled(enabled)
+ }
+ }
+
+ override suspend fun isCallEnabled(): Result = withContext(dispatchers.io) {
+ runCatching {
+ notificationSettings.isCallEnabled()
+ }
+ }
+
+ override suspend fun setCallEnabled(enabled: Boolean): Result = withContext(dispatchers.io) {
+ runCatching {
+ notificationSettings.setCallEnabled(enabled)
+ }
+ }
+}
diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/oidc/AccountManagementAction.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/oidc/AccountManagementAction.kt
new file mode 100644
index 0000000000..2644ac0321
--- /dev/null
+++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/oidc/AccountManagementAction.kt
@@ -0,0 +1,29 @@
+/*
+ * Copyright (c) 2023 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.element.android.libraries.matrix.impl.oidc
+
+import io.element.android.libraries.matrix.api.oidc.AccountManagementAction
+import org.matrix.rustcomponents.sdk.AccountManagementAction as RustAccountManagementAction
+
+fun AccountManagementAction.toRustAction(): RustAccountManagementAction {
+ return when (this) {
+ AccountManagementAction.Profile -> RustAccountManagementAction.Profile
+ is AccountManagementAction.SessionEnd -> RustAccountManagementAction.SessionEnd(deviceId)
+ is AccountManagementAction.SessionView -> RustAccountManagementAction.SessionView(deviceId)
+ AccountManagementAction.SessionsList -> RustAccountManagementAction.SessionsList
+ }
+}
diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RustMatrixRoom.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RustMatrixRoom.kt
index 71caed8f19..766f27a473 100644
--- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RustMatrixRoom.kt
+++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RustMatrixRoom.kt
@@ -33,16 +33,19 @@ import io.element.android.libraries.matrix.api.media.VideoInfo
import io.element.android.libraries.matrix.api.poll.PollKind
import io.element.android.libraries.matrix.api.room.MatrixRoom
import io.element.android.libraries.matrix.api.room.MatrixRoomMembersState
+import io.element.android.libraries.matrix.api.room.MatrixRoomNotificationSettingsState
import io.element.android.libraries.matrix.api.room.MessageEventType
import io.element.android.libraries.matrix.api.room.StateEventType
import io.element.android.libraries.matrix.api.room.location.AssetType
import io.element.android.libraries.matrix.api.room.roomMembers
+import io.element.android.libraries.matrix.api.room.roomNotificationSettings
import io.element.android.libraries.matrix.api.timeline.MatrixTimeline
import io.element.android.libraries.matrix.api.timeline.item.event.EventType
import io.element.android.libraries.matrix.impl.core.toProgressWatcher
import io.element.android.libraries.matrix.impl.media.MediaUploadHandlerImpl
import io.element.android.libraries.matrix.impl.media.map
import io.element.android.libraries.matrix.impl.poll.toInner
+import io.element.android.libraries.matrix.impl.notificationsettings.RustNotificationSettingsService
import io.element.android.libraries.matrix.impl.room.location.toInner
import io.element.android.libraries.matrix.impl.timeline.RustMatrixTimeline
import io.element.android.libraries.matrix.impl.util.destroyAll
@@ -60,9 +63,11 @@ import org.matrix.rustcomponents.sdk.RequiredState
import org.matrix.rustcomponents.sdk.Room
import org.matrix.rustcomponents.sdk.RoomListItem
import org.matrix.rustcomponents.sdk.RoomMember
+import org.matrix.rustcomponents.sdk.RoomMessageEventContentWithoutRelation
import org.matrix.rustcomponents.sdk.RoomSubscription
import org.matrix.rustcomponents.sdk.SendAttachmentJoinHandle
import org.matrix.rustcomponents.sdk.genTransactionId
+import org.matrix.rustcomponents.sdk.messageEventContentFromHtml
import org.matrix.rustcomponents.sdk.messageEventContentFromMarkdown
import timber.log.Timber
import java.io.File
@@ -72,6 +77,7 @@ class RustMatrixRoom(
override val sessionId: SessionId,
private val roomListItem: RoomListItem,
private val innerRoom: Room,
+ private val roomNotificationSettingsService: RustNotificationSettingsService,
sessionCoroutineScope: CoroutineScope,
private val coroutineDispatchers: CoroutineDispatchers,
private val systemClock: SystemClock,
@@ -90,6 +96,10 @@ class RustMatrixRoom(
private val roomCoroutineScope = sessionCoroutineScope.childScope(coroutineDispatchers.main, "RoomScope-$roomId")
private val _membersStateFlow = MutableStateFlow(MatrixRoomMembersState.Unknown)
private val _syncUpdateFlow = MutableStateFlow(0L)
+
+ private val _roomNotificationSettingsStateFlow = MutableStateFlow(MatrixRoomNotificationSettingsState.Unknown)
+ override val roomNotificationSettingsStateFlow: StateFlow = _roomNotificationSettingsStateFlow
+
private val _timeline by lazy {
RustMatrixTimeline(
matrixRoom = this,
@@ -197,37 +207,54 @@ class RustMatrixRoom(
}
}
+ override suspend fun updateRoomNotificationSettings(): Result = withContext(coroutineDispatchers.io) {
+ val currentState = _roomNotificationSettingsStateFlow.value
+ val currentRoomNotificationSettings = currentState.roomNotificationSettings()
+ _roomNotificationSettingsStateFlow.value = MatrixRoomNotificationSettingsState.Pending(prevRoomNotificationSettings = currentRoomNotificationSettings)
+ runCatching {
+ roomNotificationSettingsService.getRoomNotificationSettings(roomId, isEncrypted, isOneToOne).getOrThrow()
+ }.map {
+ _roomNotificationSettingsStateFlow.value = MatrixRoomNotificationSettingsState.Ready(it)
+ }.onFailure {
+ _roomNotificationSettingsStateFlow.value = MatrixRoomNotificationSettingsState.Error(
+ prevRoomNotificationSettings = currentRoomNotificationSettings,
+ failure = it
+ )
+ }
+ }
+
override suspend fun userAvatarUrl(userId: UserId): Result = withContext(roomDispatcher) {
runCatching {
innerRoom.memberAvatarUrl(userId.value)
}
}
- override suspend fun sendMessage(message: String): Result = withContext(roomDispatcher) {
+ override suspend fun sendMessage(body: String, htmlBody: String?): Result = withContext(roomDispatcher) {
val transactionId = genTransactionId()
- messageEventContentFromMarkdown(message).use { content ->
+ messageEventContentFromParts(body, htmlBody).use { content ->
runCatching {
innerRoom.send(content, transactionId)
}
}
}
- override suspend fun editMessage(originalEventId: EventId?, transactionId: TransactionId?, message: String): Result = withContext(roomDispatcher) {
- if (originalEventId != null) {
- runCatching {
- innerRoom.edit(messageEventContentFromMarkdown(message), originalEventId.value, transactionId?.value)
- }
- } else {
- runCatching {
- transactionId?.let { cancelSend(it) }
- innerRoom.send(messageEventContentFromMarkdown(message), genTransactionId())
+ override suspend fun editMessage(originalEventId: EventId?, transactionId: TransactionId?, body: String, htmlBody: String?): Result =
+ withContext(roomDispatcher) {
+ if (originalEventId != null) {
+ runCatching {
+ innerRoom.edit(messageEventContentFromParts(body, htmlBody), originalEventId.value, transactionId?.value)
+ }
+ } else {
+ runCatching {
+ transactionId?.let { cancelSend(it) }
+ innerRoom.send(messageEventContentFromParts(body, htmlBody), genTransactionId())
+ }
}
}
- }
- override suspend fun replyMessage(eventId: EventId, message: String): Result = withContext(roomDispatcher) {
+ override suspend fun replyMessage(eventId: EventId, body: String, htmlBody: String?): Result = withContext(roomDispatcher) {
runCatching {
- innerRoom.sendReply(messageEventContentFromMarkdown(message), eventId.value, genTransactionId())
+ innerRoom.sendReply(messageEventContentFromParts(body, htmlBody), eventId.value, genTransactionId())
}
}
@@ -431,4 +458,11 @@ class RustMatrixRoom(
MediaUploadHandlerImpl(files, handle())
}
}
+
+ private fun messageEventContentFromParts(body: String, htmlBody: String?): RoomMessageEventContentWithoutRelation =
+ if(htmlBody != null) {
+ messageEventContentFromHtml(body, htmlBody)
+ } else {
+ messageEventContentFromMarkdown(body)
+ }
}
diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/roomlist/RoomListExtensions.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/roomlist/RoomListExtensions.kt
index 8d96990a9e..1e599a184d 100644
--- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/roomlist/RoomListExtensions.kt
+++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/roomlist/RoomListExtensions.kt
@@ -33,6 +33,8 @@ import org.matrix.rustcomponents.sdk.RoomListLoadingStateListener
import org.matrix.rustcomponents.sdk.RoomListService
import org.matrix.rustcomponents.sdk.RoomListServiceState
import org.matrix.rustcomponents.sdk.RoomListServiceStateListener
+import org.matrix.rustcomponents.sdk.RoomListServiceSyncIndicator
+import org.matrix.rustcomponents.sdk.RoomListServiceSyncIndicatorListener
import timber.log.Timber
fun RoomList.loadingStateFlow(): Flow =
@@ -83,6 +85,18 @@ fun RoomListService.stateFlow(): Flow =
}
}.buffer(Channel.UNLIMITED)
+fun RoomListService.syncIndicator(): Flow =
+ mxCallbackFlow {
+ val listener = object : RoomListServiceSyncIndicatorListener {
+ override fun onUpdate(syncIndicator: RoomListServiceSyncIndicator) {
+ trySendBlocking(syncIndicator)
+ }
+ }
+ tryOrNull {
+ syncIndicator(listener)
+ }
+ }.buffer(Channel.UNLIMITED)
+
fun RoomListService.roomOrNull(roomId: String): RoomListItem? {
return try {
room(roomId)
diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/roomlist/RoomSummaryDetailsFactory.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/roomlist/RoomSummaryDetailsFactory.kt
index b57eb892e0..a9d46296db 100644
--- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/roomlist/RoomSummaryDetailsFactory.kt
+++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/roomlist/RoomSummaryDetailsFactory.kt
@@ -18,27 +18,30 @@ package io.element.android.libraries.matrix.impl.roomlist
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.roomlist.RoomSummaryDetails
+import io.element.android.libraries.matrix.impl.notificationsettings.RoomNotificationSettingsMapper
import io.element.android.libraries.matrix.impl.room.RoomMemberMapper
import io.element.android.libraries.matrix.impl.room.message.RoomMessageFactory
-import org.matrix.rustcomponents.sdk.Room
-import org.matrix.rustcomponents.sdk.RoomListItem
+import org.matrix.rustcomponents.sdk.RoomInfo
+import org.matrix.rustcomponents.sdk.use
class RoomSummaryDetailsFactory(private val roomMessageFactory: RoomMessageFactory = RoomMessageFactory()) {
- suspend fun create(roomListItem: RoomListItem, room: Room?): RoomSummaryDetails {
- val latestRoomMessage = roomListItem.latestEvent()?.use {
+ fun create(roomInfo: RoomInfo): RoomSummaryDetails {
+ val latestRoomMessage = roomInfo.latestEvent?.use {
roomMessageFactory.create(it)
}
return RoomSummaryDetails(
- roomId = RoomId(roomListItem.id()),
- name = roomListItem.name() ?: roomListItem.id(),
- canonicalAlias = roomListItem.canonicalAlias(),
- isDirect = roomListItem.isDirect(),
- avatarURLString = roomListItem.avatarUrl(),
- unreadNotificationCount = roomListItem.unreadNotifications().use { it.notificationCount().toInt() },
+ roomId = RoomId(roomInfo.id),
+ name = roomInfo.name ?: roomInfo.id,
+ canonicalAlias = roomInfo.canonicalAlias,
+ isDirect = roomInfo.isDirect,
+ avatarURLString = roomInfo.avatarUrl,
+ unreadNotificationCount = roomInfo.notificationCount.toInt(),
lastMessage = latestRoomMessage,
lastMessageTimestamp = latestRoomMessage?.originServerTs,
- inviter = room?.inviter()?.let(RoomMemberMapper::map),
+ inviter = roomInfo.inviter?.let(RoomMemberMapper::map),
+ notificationMode = roomInfo.userDefinedNotificationMode?.let(RoomNotificationSettingsMapper::mapMode),
)
}
}
+
diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/roomlist/RoomSummaryListProcessor.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/roomlist/RoomSummaryListProcessor.kt
index 9a67ff1f30..99b9c17968 100644
--- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/roomlist/RoomSummaryListProcessor.kt
+++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/roomlist/RoomSummaryListProcessor.kt
@@ -20,13 +20,13 @@ import io.element.android.libraries.core.coroutine.parallelMap
import io.element.android.libraries.matrix.api.roomlist.RoomSummary
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.map
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
-import org.matrix.rustcomponents.sdk.Room
import org.matrix.rustcomponents.sdk.RoomListEntriesUpdate
import org.matrix.rustcomponents.sdk.RoomListEntry
-import org.matrix.rustcomponents.sdk.RoomListItem
import org.matrix.rustcomponents.sdk.RoomListService
+import org.matrix.rustcomponents.sdk.use
import timber.log.Timber
import java.util.UUID
@@ -34,7 +34,6 @@ class RoomSummaryListProcessor(
private val roomSummaries: MutableStateFlow>,
private val roomListService: RoomListService,
private val roomSummaryDetailsFactory: RoomSummaryDetailsFactory = RoomSummaryDetailsFactory(),
- private val shouldFetchFullRoom: Boolean = false,
) {
private val roomSummariesByIdentifier = HashMap()
@@ -61,7 +60,18 @@ class RoomSummaryListProcessor(
}
}
- private suspend fun MutableList.applyUpdate(update: RoomListEntriesUpdate) {
+ suspend fun rebuildRoomSummaries() {
+ updateRoomSummaries {
+ forEachIndexed { i, summary ->
+ this[i] = when(summary) {
+ is RoomSummary.Empty -> summary
+ is RoomSummary.Filled -> buildAndCacheRoomSummaryForIdentifier(summary.identifier())
+ }
+ }
+ }
+ }
+
+ private fun MutableList.applyUpdate(update: RoomListEntriesUpdate) {
when (update) {
is RoomListEntriesUpdate.Append -> {
val roomSummaries = update.values.map {
@@ -101,15 +111,18 @@ class RoomSummaryListProcessor(
RoomListEntriesUpdate.Clear -> {
clear()
}
+ is RoomListEntriesUpdate.Truncate -> {
+ subList(update.length.toInt(), size).clear()
+ }
}
}
- private suspend fun buildSummaryForRoomListEntry(entry: RoomListEntry): RoomSummary {
+ private fun buildSummaryForRoomListEntry(entry: RoomListEntry): RoomSummary {
return when (entry) {
RoomListEntry.Empty -> buildEmptyRoomSummary()
is RoomListEntry.Filled -> buildAndCacheRoomSummaryForIdentifier(entry.roomId)
is RoomListEntry.Invalidated -> {
- roomSummariesByIdentifier[entry.roomId] ?: buildEmptyRoomSummary()
+ roomSummariesByIdentifier[entry.roomId] ?: buildAndCacheRoomSummaryForIdentifier(entry.roomId)
}
}
}
@@ -118,11 +131,11 @@ class RoomSummaryListProcessor(
return RoomSummary.Empty(UUID.randomUUID().toString())
}
- private suspend fun buildAndCacheRoomSummaryForIdentifier(identifier: String): RoomSummary {
+ private fun buildAndCacheRoomSummaryForIdentifier(identifier: String): RoomSummary {
val builtRoomSummary = roomListService.roomOrNull(identifier)?.use { roomListItem ->
- roomListItem.fullRoomOrNull().use { fullRoom ->
+ roomListItem.roomInfoBlocking().use { roomInfo ->
RoomSummary.Filled(
- details = roomSummaryDetailsFactory.create(roomListItem, fullRoom)
+ details = roomSummaryDetailsFactory.create(roomInfo)
)
}
} ?: buildEmptyRoomSummary()
@@ -130,14 +143,6 @@ class RoomSummaryListProcessor(
return builtRoomSummary
}
- private fun RoomListItem.fullRoomOrNull(): Room? {
- return if (shouldFetchFullRoom) {
- fullRoom()
- } else {
- null
- }
- }
-
private suspend fun updateRoomSummaries(block: suspend MutableList.() -> Unit) =
mutex.withLock {
val mutableRoomSummaries = roomSummaries.value.toMutableList()
diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/roomlist/RustRoomListService.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/roomlist/RustRoomListService.kt
index f56e55d759..347e53aa0c 100644
--- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/roomlist/RustRoomListService.kt
+++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/roomlist/RustRoomListService.kt
@@ -38,6 +38,7 @@ import org.matrix.rustcomponents.sdk.RoomListInput
import org.matrix.rustcomponents.sdk.RoomListLoadingState
import org.matrix.rustcomponents.sdk.RoomListRange
import org.matrix.rustcomponents.sdk.RoomListServiceState
+import org.matrix.rustcomponents.sdk.RoomListServiceSyncIndicator
import timber.log.Timber
import org.matrix.rustcomponents.sdk.RoomListService as InnerRustRoomListService
@@ -52,9 +53,9 @@ class RustRoomListService(
private val inviteRooms = MutableStateFlow>(emptyList())
private val allRoomsLoadingState: MutableStateFlow = MutableStateFlow(RoomList.LoadingState.NotLoaded)
- private val allRoomsListProcessor = RoomSummaryListProcessor(allRooms, innerRoomListService, roomSummaryDetailsFactory, shouldFetchFullRoom = false)
+ private val allRoomsListProcessor = RoomSummaryListProcessor(allRooms, innerRoomListService, roomSummaryDetailsFactory)
private val invitesLoadingState: MutableStateFlow = MutableStateFlow(RoomList.LoadingState.NotLoaded)
- private val inviteRoomsListProcessor = RoomSummaryListProcessor(inviteRooms, innerRoomListService, roomSummaryDetailsFactory, shouldFetchFullRoom = true)
+ private val inviteRoomsListProcessor = RoomSummaryListProcessor(inviteRooms, innerRoomListService, roomSummaryDetailsFactory)
init {
sessionCoroutineScope.launch(dispatcher) {
@@ -106,6 +107,21 @@ class RustRoomListService(
}
}
+ override fun rebuildRoomSummaries() {
+ sessionCoroutineScope.launch {
+ allRoomsListProcessor.rebuildRoomSummaries()
+ }
+ }
+
+ override val syncIndicator: StateFlow =
+ innerRoomListService.syncIndicator()
+ .map { it.toSyncIndicator() }
+ .onEach { syncIndicator ->
+ Timber.d("SyncIndicator = $syncIndicator")
+ }
+ .distinctUntilChanged()
+ .stateIn(sessionCoroutineScope, SharingStarted.Eagerly, RoomListService.SyncIndicator.Hide)
+
override val state: StateFlow =
innerRoomListService.stateFlow()
.map { it.toRoomListState() }
@@ -126,6 +142,7 @@ private fun RoomListLoadingState.toLoadingState(): RoomList.LoadingState {
private fun RoomListServiceState.toRoomListState(): RoomListService.State {
return when (this) {
RoomListServiceState.INITIAL,
+ RoomListServiceState.RECOVERING,
RoomListServiceState.SETTING_UP -> RoomListService.State.Idle
RoomListServiceState.RUNNING -> RoomListService.State.Running
RoomListServiceState.ERROR -> RoomListService.State.Error
@@ -133,6 +150,13 @@ private fun RoomListServiceState.toRoomListState(): RoomListService.State {
}
}
+private fun RoomListServiceSyncIndicator.toSyncIndicator(): RoomListService.SyncIndicator {
+ return when (this) {
+ RoomListServiceSyncIndicator.SHOW -> RoomListService.SyncIndicator.Show
+ RoomListServiceSyncIndicator.HIDE -> RoomListService.SyncIndicator.Hide
+ }
+}
+
private fun org.matrix.rustcomponents.sdk.RoomList.observeEntriesWithProcessor(processor: RoomSummaryListProcessor): Flow> {
return entriesFlow { roomListEntries ->
processor.postEntries(roomListEntries)
diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/sync/RustSyncService.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/sync/RustSyncService.kt
index b0a9fb31ec..932da42afb 100644
--- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/sync/RustSyncService.kt
+++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/sync/RustSyncService.kt
@@ -16,7 +16,6 @@
package io.element.android.libraries.matrix.impl.sync
-import io.element.android.libraries.matrix.api.sync.StartSyncReason
import io.element.android.libraries.matrix.api.sync.SyncService
import io.element.android.libraries.matrix.api.sync.SyncState
import kotlinx.coroutines.CoroutineScope
@@ -26,8 +25,6 @@ import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.stateIn
-import kotlinx.coroutines.sync.Mutex
-import kotlinx.coroutines.sync.withLock
import org.matrix.rustcomponents.sdk.SyncServiceInterface
import org.matrix.rustcomponents.sdk.SyncServiceState
import timber.log.Timber
@@ -36,36 +33,19 @@ class RustSyncService(
private val innerSyncService: SyncServiceInterface,
sessionCoroutineScope: CoroutineScope
) : SyncService {
- private val mutex = Mutex()
- private val startSyncReasonSet = mutableSetOf()
- override suspend fun startSync(reason: StartSyncReason): Result {
- return mutex.withLock {
- startSyncReasonSet.add(reason)
- runCatching {
- Timber.d("Start sync")
- innerSyncService.start()
- }.onFailure {
- Timber.e("Start sync failed: $it")
- }
- }
+ override suspend fun startSync() = runCatching {
+ Timber.i("Start sync")
+ innerSyncService.start()
+ }.onFailure {
+ Timber.d("Start sync failed: $it")
}
- override suspend fun stopSync(reason: StartSyncReason): Result {
- return mutex.withLock {
- startSyncReasonSet.remove(reason)
- if (startSyncReasonSet.isEmpty()) {
- runCatching {
- Timber.d("Stop sync")
- innerSyncService.stop()
- }.onFailure {
- Timber.e("Stop sync failed: $it")
- }
- } else {
- Timber.d("Stop sync skipped, still $startSyncReasonSet")
- Result.success(Unit)
- }
- }
+ override suspend fun stopSync() = runCatching {
+ Timber.i("Stop sync")
+ innerSyncService.stop()
+ }.onFailure {
+ Timber.d("Stop sync failed: $it")
}
override val syncState: StateFlow =
diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/MatrixTimelineDiffProcessor.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/MatrixTimelineDiffProcessor.kt
index fd880e7fe3..38c4ef8fb6 100644
--- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/MatrixTimelineDiffProcessor.kt
+++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/MatrixTimelineDiffProcessor.kt
@@ -98,6 +98,9 @@ internal class MatrixTimelineDiffProcessor(
TimelineChange.CLEAR -> {
clear()
}
+ TimelineChange.TRUNCATE -> {
+ // Not supported
+ }
}
}
diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/item/event/EventMessageMapper.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/item/event/EventMessageMapper.kt
index 330a06da62..0a59cfddab 100644
--- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/item/event/EventMessageMapper.kt
+++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/item/event/EventMessageMapper.kt
@@ -46,28 +46,29 @@ class EventMessageMapper {
fun map(message: Message): MessageContent = message.use {
val type = it.msgtype().use(this::mapMessageType)
- val inReplyToId = it.inReplyTo()?.eventId?.let(::EventId)
- val inReplyToEvent: InReplyTo? = it.inReplyTo()?.event?.use { details ->
- when (details) {
+ val inReplyToEvent: InReplyTo? = it.inReplyTo()?.use { details ->
+ val inReplyToId = EventId(details.eventId)
+ when (val event = details.event) {
is RepliedToEventDetails.Ready -> {
- val senderProfile = details.senderProfile as? ProfileDetails.Ready
+ val senderProfile = event.senderProfile as? ProfileDetails.Ready
InReplyTo.Ready(
- eventId = inReplyToId!!,
- content = timelineEventContentMapper.map(details.content),
- senderId = UserId(details.sender),
+ eventId = inReplyToId,
+ content = timelineEventContentMapper.map(event.content),
+ senderId = UserId(event.sender),
senderDisplayName = senderProfile?.displayName,
senderAvatarUrl = senderProfile?.avatarUrl,
)
}
is RepliedToEventDetails.Error -> InReplyTo.Error
is RepliedToEventDetails.Pending -> InReplyTo.Pending
- is RepliedToEventDetails.Unavailable -> InReplyTo.NotLoaded(inReplyToId!!)
+ is RepliedToEventDetails.Unavailable -> InReplyTo.NotLoaded(inReplyToId)
}
}
MessageContent(
body = it.body(),
inReplyTo = inReplyToEvent,
isEdited = it.isEdited(),
+ isThreaded = it.isThreaded(),
type = type
)
}
diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/item/event/TimelineEventContentMapper.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/item/event/TimelineEventContentMapper.kt
index 22f8a062f0..01dd32c048 100644
--- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/item/event/TimelineEventContentMapper.kt
+++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/item/event/TimelineEventContentMapper.kt
@@ -34,84 +34,91 @@ import io.element.android.libraries.matrix.impl.media.map
import io.element.android.libraries.matrix.impl.poll.map
import org.matrix.rustcomponents.sdk.TimelineItemContent
import org.matrix.rustcomponents.sdk.TimelineItemContentKind
+import org.matrix.rustcomponents.sdk.use
import org.matrix.rustcomponents.sdk.EncryptedMessage as RustEncryptedMessage
import org.matrix.rustcomponents.sdk.MembershipChange as RustMembershipChange
import org.matrix.rustcomponents.sdk.OtherState as RustOtherState
class TimelineEventContentMapper(private val eventMessageMapper: EventMessageMapper = EventMessageMapper()) {
- fun map(content: TimelineItemContent): EventContent = content.use {
- when (val kind = it.kind()) {
- is TimelineItemContentKind.FailedToParseMessageLike -> {
- FailedToParseMessageLikeContent(
- eventType = kind.eventType,
- error = kind.error
- )
+ fun map(content: TimelineItemContent): EventContent {
+ return content.use {
+ content.kind().use { kind ->
+ map(content, kind)
}
- is TimelineItemContentKind.FailedToParseState -> {
- FailedToParseStateContent(
- eventType = kind.eventType,
- stateKey = kind.stateKey,
- error = kind.error
- )
- }
- TimelineItemContentKind.Message -> {
- val message = it.asMessage()
- if (message == null) {
- UnknownContent
- } else {
- eventMessageMapper.map(message)
- }
- }
- is TimelineItemContentKind.ProfileChange -> {
- ProfileChangeContent(
- displayName = kind.displayName,
- prevDisplayName = kind.prevDisplayName,
- avatarUrl = kind.avatarUrl,
- prevAvatarUrl = kind.prevAvatarUrl
- )
- }
- TimelineItemContentKind.RedactedMessage -> {
- RedactedContent
- }
- is TimelineItemContentKind.RoomMembership -> {
- RoomMembershipContent(
- UserId(kind.userId),
- kind.change?.map()
- )
- }
- is TimelineItemContentKind.State -> {
- StateContent(
- stateKey = kind.stateKey,
- content = kind.content.map()
- )
- }
- is TimelineItemContentKind.Sticker -> {
- StickerContent(
- body = kind.body,
- info = kind.info.map(),
- url = kind.url,
- )
- }
- is TimelineItemContentKind.Poll -> {
- PollContent(
- question = kind.question,
- kind = kind.kind.map(),
- maxSelections = kind.maxSelections,
- answers = kind.answers.map { answer -> answer.map() },
- votes = kind.votes.mapValues { vote ->
- vote.value.map { userId -> UserId(userId) }
- },
- endTime = kind.endTime,
- )
- }
- is TimelineItemContentKind.UnableToDecrypt -> {
- UnableToDecryptContent(
- data = kind.msg.map()
- )
+ }
+ }
+
+ private fun map(content: TimelineItemContent, kind: TimelineItemContentKind) = when (kind) {
+ is TimelineItemContentKind.FailedToParseMessageLike -> {
+ FailedToParseMessageLikeContent(
+ eventType = kind.eventType,
+ error = kind.error
+ )
+ }
+ is TimelineItemContentKind.FailedToParseState -> {
+ FailedToParseStateContent(
+ eventType = kind.eventType,
+ stateKey = kind.stateKey,
+ error = kind.error
+ )
+ }
+ TimelineItemContentKind.Message -> {
+ val message = content.asMessage()
+ if (message == null) {
+ UnknownContent
+ } else {
+ eventMessageMapper.map(message)
}
- else -> UnknownContent
}
+ is TimelineItemContentKind.ProfileChange -> {
+ ProfileChangeContent(
+ displayName = kind.displayName,
+ prevDisplayName = kind.prevDisplayName,
+ avatarUrl = kind.avatarUrl,
+ prevAvatarUrl = kind.prevAvatarUrl
+ )
+ }
+ TimelineItemContentKind.RedactedMessage -> {
+ RedactedContent
+ }
+ is TimelineItemContentKind.RoomMembership -> {
+ RoomMembershipContent(
+ UserId(kind.userId),
+ kind.change?.map()
+ )
+ }
+ is TimelineItemContentKind.State -> {
+ StateContent(
+ stateKey = kind.stateKey,
+ content = kind.content.map()
+ )
+ }
+ is TimelineItemContentKind.Sticker -> {
+ StickerContent(
+ body = kind.body,
+ info = kind.info.map(),
+ url = kind.url,
+ )
+ }
+ is TimelineItemContentKind.Poll -> {
+ PollContent(
+ question = kind.question,
+ kind = kind.kind.map(),
+ maxSelections = kind.maxSelections,
+ answers = kind.answers.map { answer -> answer.map() },
+ votes = kind.votes.mapValues { vote ->
+ vote.value.map { userId -> UserId(userId) }
+ },
+ endTime = kind.endTime,
+ )
+ }
+ is TimelineItemContentKind.UnableToDecrypt -> {
+ UnableToDecryptContent(
+ data = kind.msg.map()
+ )
+ }
+ else -> UnknownContent
}
}
diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/tracing/RustTracingTree.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/tracing/RustTracingTree.kt
index 275994081d..f97b08a018 100644
--- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/tracing/RustTracingTree.kt
+++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/tracing/RustTracingTree.kt
@@ -49,7 +49,7 @@ internal class RustTracingTree(private val retrieveFromStackTrace: Boolean) : Ti
line = location.line,
level = logLevel,
target = Target.ELEMENT.filter,
- message = message,
+ message = if (tag != null) "[$tag] $message" else message,
)
}
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 83f7f3ad79..67a36f0db7 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
@@ -24,6 +24,8 @@ import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.createroom.CreateRoomParameters
import io.element.android.libraries.matrix.api.media.MatrixMediaLoader
import io.element.android.libraries.matrix.api.notification.NotificationService
+import io.element.android.libraries.matrix.api.notificationsettings.NotificationSettingsService
+import io.element.android.libraries.matrix.api.oidc.AccountManagementAction
import io.element.android.libraries.matrix.api.pusher.PushersService
import io.element.android.libraries.matrix.api.room.MatrixRoom
import io.element.android.libraries.matrix.api.room.RoomMembershipObserver
@@ -33,6 +35,7 @@ import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.matrix.api.verification.SessionVerificationService
import io.element.android.libraries.matrix.test.media.FakeMediaLoader
import io.element.android.libraries.matrix.test.notification.FakeNotificationService
+import io.element.android.libraries.matrix.test.notificationsettings.FakeNotificationSettingsService
import io.element.android.libraries.matrix.test.pushers.FakePushersService
import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
import io.element.android.libraries.matrix.test.roomlist.FakeRoomListService
@@ -50,10 +53,18 @@ class FakeMatrixClient(
private val sessionVerificationService: FakeSessionVerificationService = FakeSessionVerificationService(),
private val pushersService: FakePushersService = FakePushersService(),
private val notificationService: FakeNotificationService = FakeNotificationService(),
+ private val notificationSettingsService: FakeNotificationSettingsService = FakeNotificationSettingsService(),
private val syncService: FakeSyncService = FakeSyncService(),
private val accountManagementUrlString: Result = Result.success(null),
) : MatrixClient {
+ var setDisplayNameCalled: Boolean = false
+ private set
+ var uploadAvatarCalled: Boolean = false
+ private set
+ var removeAvatarCalled: Boolean = false
+ private set
+
private var ignoreUserResult: Result = Result.success(Unit)
private var unignoreUserResult: Result = Result.success(Unit)
private var createRoomResult: Result = Result.success(A_ROOM_ID)
@@ -65,6 +76,9 @@ class FakeMatrixClient(
private val searchUserResults = mutableMapOf>()
private val getProfileResults = mutableMapOf>()
private var uploadMediaResult: Result = Result.success(AN_AVATAR_URL)
+ private var setDisplayNameResult: Result = Result.success(Unit)
+ private var uploadAvatarResult: Result = Result.success(Unit)
+ private var removeAvatarResult: Result = Result.success(Unit)
override suspend fun getRoom(roomId: RoomId): MatrixRoom? {
return getRoomResults[roomId]
@@ -126,9 +140,10 @@ class FakeMatrixClient(
return userAvatarURLString
}
- override suspend fun getAccountManagementUrl(): Result {
+ override suspend fun getAccountManagementUrl(action: AccountManagementAction?): Result {
return accountManagementUrlString
}
+
override suspend fun uploadMedia(
mimeType: String,
data: ByteArray,
@@ -137,11 +152,27 @@ class FakeMatrixClient(
return uploadMediaResult
}
+ override suspend fun setDisplayName(displayName: String): Result = simulateLongTask {
+ setDisplayNameCalled = true
+ return setDisplayNameResult
+ }
+
+ override suspend fun uploadAvatar(mimeType: String, data: ByteArray): Result = simulateLongTask {
+ uploadAvatarCalled = true
+ return uploadAvatarResult
+ }
+
+ override suspend fun removeAvatar(): Result = simulateLongTask {
+ removeAvatarCalled = true
+ return removeAvatarResult
+ }
+
override fun sessionVerificationService(): SessionVerificationService = sessionVerificationService
override fun pushersService(): PushersService = pushersService
override fun notificationService(): NotificationService = notificationService
+ override fun notificationSettingsService(): NotificationSettingsService = notificationSettingsService
override fun roomMembershipObserver(): RoomMembershipObserver {
return RoomMembershipObserver()
@@ -192,4 +223,16 @@ class FakeMatrixClient(
fun givenUploadMediaResult(result: Result) {
uploadMediaResult = result
}
+
+ fun givenSetDisplayNameResult(result: Result) {
+ setDisplayNameResult = result
+ }
+
+ fun givenUploadAvatarResult(result: Result) {
+ uploadAvatarResult = result
+ }
+
+ fun givenRemoveAvatarResult(result: Result) {
+ removeAvatarResult = result
+ }
}
diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/TestData.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/TestData.kt
index f6b6e1645f..cae5df4dc4 100644
--- a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/TestData.kt
+++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/TestData.kt
@@ -24,6 +24,8 @@ import io.element.android.libraries.matrix.api.core.SpaceId
import io.element.android.libraries.matrix.api.core.ThreadId
import io.element.android.libraries.matrix.api.core.TransactionId
import io.element.android.libraries.matrix.api.core.UserId
+import io.element.android.libraries.matrix.api.room.RoomNotificationMode
+import io.element.android.libraries.matrix.api.room.RoomNotificationSettings
const val A_USER_NAME = "alice"
const val A_PASSWORD = "password"
@@ -59,6 +61,8 @@ const val A_HOMESERVER_URL_2 = "matrix-client.org"
val A_HOMESERVER = MatrixHomeServerDetails(A_HOMESERVER_URL, supportsPasswordLogin = true, supportsOidcLogin = false)
val A_HOMESERVER_OIDC = MatrixHomeServerDetails(A_HOMESERVER_URL, supportsPasswordLogin = false, supportsOidcLogin = true)
+val A_ROOM_NOTIFICATION_MODE = RoomNotificationMode.MUTE
+val A_ROOM_NOTIFICATION_SETTINGS = RoomNotificationSettings(mode = A_ROOM_NOTIFICATION_MODE, isDefault = false)
const val AN_AVATAR_URL = "mxc://data"
diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/notificationsettings/FakeNotificationSettingsService.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/notificationsettings/FakeNotificationSettingsService.kt
new file mode 100644
index 0000000000..77592d6d1f
--- /dev/null
+++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/notificationsettings/FakeNotificationSettingsService.kt
@@ -0,0 +1,125 @@
+/*
+ * Copyright (c) 2023 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.element.android.libraries.matrix.test.notificationsettings
+
+import io.element.android.libraries.matrix.api.core.RoomId
+import io.element.android.libraries.matrix.api.notificationsettings.NotificationSettingsService
+import io.element.android.libraries.matrix.api.room.RoomNotificationMode
+import io.element.android.libraries.matrix.api.room.RoomNotificationSettings
+import io.element.android.libraries.matrix.test.A_ROOM_NOTIFICATION_MODE
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.SharedFlow
+
+class FakeNotificationSettingsService(
+ initialRoomMode: RoomNotificationMode = A_ROOM_NOTIFICATION_MODE,
+ initialGroupDefaultMode: RoomNotificationMode = RoomNotificationMode.MENTIONS_AND_KEYWORDS_ONLY,
+ initialEncryptedGroupDefaultMode: RoomNotificationMode = RoomNotificationMode.MENTIONS_AND_KEYWORDS_ONLY,
+ initialOneToOneDefaultMode: RoomNotificationMode = RoomNotificationMode.ALL_MESSAGES,
+ initialEncryptedOneToOneDefaultMode: RoomNotificationMode = RoomNotificationMode.ALL_MESSAGES,
+) : NotificationSettingsService {
+ private var _notificationSettingsStateFlow = MutableStateFlow(Unit)
+ private var defaultGroupRoomNotificationMode: RoomNotificationMode = initialGroupDefaultMode
+ private var defaultEncryptedGroupRoomNotificationMode: RoomNotificationMode = initialEncryptedGroupDefaultMode
+ private var defaultOneToOneRoomNotificationMode: RoomNotificationMode = initialOneToOneDefaultMode
+ private var defaultEncryptedOneToOneRoomNotificationMode: RoomNotificationMode = initialEncryptedOneToOneDefaultMode
+ private var roomNotificationMode: RoomNotificationMode = initialRoomMode
+ private var callNotificationsEnabled = false
+ private var atRoomNotificationsEnabled = false
+ override val notificationSettingsChangeFlow: SharedFlow
+ get() = _notificationSettingsStateFlow
+
+ override suspend fun getRoomNotificationSettings(roomId: RoomId, isEncrypted: Boolean, isOneToOne: Boolean): Result {
+ return Result.success(
+ RoomNotificationSettings(
+ mode = roomNotificationMode,
+ isDefault = roomNotificationMode == defaultEncryptedGroupRoomNotificationMode
+ )
+ )
+ }
+
+ override suspend fun getDefaultRoomNotificationMode(isEncrypted: Boolean, isOneToOne: Boolean): Result {
+ return if (isOneToOne) {
+ if (isEncrypted) {
+ Result.success(defaultEncryptedOneToOneRoomNotificationMode)
+ } else {
+ Result.success(defaultOneToOneRoomNotificationMode)
+ }
+ } else {
+ if (isEncrypted) {
+ Result.success(defaultEncryptedGroupRoomNotificationMode)
+ } else {
+ Result.success(defaultGroupRoomNotificationMode)
+ }
+ }
+ }
+
+ override suspend fun setDefaultRoomNotificationMode(isEncrypted: Boolean, mode: RoomNotificationMode, isOneToOne: Boolean): Result {
+ if (isOneToOne) {
+ if (isEncrypted) {
+ defaultEncryptedOneToOneRoomNotificationMode = mode
+ } else {
+ defaultOneToOneRoomNotificationMode = mode
+ }
+ } else {
+ if (isEncrypted) {
+ defaultEncryptedGroupRoomNotificationMode = mode
+ } else {
+ defaultGroupRoomNotificationMode = mode
+ }
+ }
+ _notificationSettingsStateFlow.emit(Unit)
+ return Result.success(Unit)
+ }
+
+ override suspend fun setRoomNotificationMode(roomId: RoomId, mode: RoomNotificationMode): Result {
+ roomNotificationMode = mode
+ _notificationSettingsStateFlow.emit(Unit)
+ return Result.success(Unit)
+ }
+
+ override suspend fun restoreDefaultRoomNotificationMode(roomId: RoomId): Result {
+ roomNotificationMode = defaultEncryptedGroupRoomNotificationMode
+ _notificationSettingsStateFlow.emit(Unit)
+ return Result.success(Unit)
+ }
+
+ override suspend fun muteRoom(roomId: RoomId): Result {
+ return setRoomNotificationMode(roomId, RoomNotificationMode.MUTE)
+ }
+
+ override suspend fun unmuteRoom(roomId: RoomId, isEncrypted: Boolean, isOneToOne: Boolean): Result {
+ return restoreDefaultRoomNotificationMode(roomId)
+ }
+
+ override suspend fun isRoomMentionEnabled(): Result {
+ return Result.success(atRoomNotificationsEnabled)
+ }
+
+ override suspend fun setRoomMentionEnabled(enabled: Boolean): Result {
+ atRoomNotificationsEnabled = enabled
+ return Result.success(Unit)
+ }
+
+ override suspend fun isCallEnabled(): Result {
+ return Result.success(callNotificationsEnabled)
+ }
+
+ override suspend fun setCallEnabled(enabled: Boolean): Result {
+ callNotificationsEnabled = enabled
+ return Result.success(Unit)
+ }
+}
diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/FakeMatrixRoom.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/FakeMatrixRoom.kt
index 7bffb985bb..0e8916e87e 100644
--- a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/FakeMatrixRoom.kt
+++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/FakeMatrixRoom.kt
@@ -27,16 +27,19 @@ 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.media.MediaUploadHandler
import io.element.android.libraries.matrix.api.media.VideoInfo
+import io.element.android.libraries.matrix.api.notificationsettings.NotificationSettingsService
import io.element.android.libraries.matrix.api.poll.PollKind
import io.element.android.libraries.matrix.api.room.MatrixRoom
import io.element.android.libraries.matrix.api.room.MatrixRoomMembersState
import io.element.android.libraries.matrix.api.room.MessageEventType
+import io.element.android.libraries.matrix.api.room.MatrixRoomNotificationSettingsState
import io.element.android.libraries.matrix.api.room.StateEventType
import io.element.android.libraries.matrix.api.room.location.AssetType
import io.element.android.libraries.matrix.api.timeline.MatrixTimeline
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.media.FakeMediaUploadHandler
+import io.element.android.libraries.matrix.test.notificationsettings.FakeNotificationSettingsService
import io.element.android.libraries.matrix.test.timeline.FakeMatrixTimeline
import io.element.android.tests.testutils.simulateLongTask
import kotlinx.coroutines.delay
@@ -58,6 +61,7 @@ class FakeMatrixRoom(
override val isDirect: Boolean = false,
override val joinedMemberCount: Long = 123L,
override val activeMemberCount: Long = 234L,
+ val notificationSettingsService: NotificationSettingsService = FakeNotificationSettingsService(),
private val matrixTimeline: MatrixTimeline = FakeMatrixTimeline(),
canRedact: Boolean = false,
) : MatrixRoom {
@@ -88,7 +92,7 @@ class FakeMatrixRoom(
private var sendPollResponseResult = Result.success(Unit)
private var endPollResult = Result.success(Unit)
private var progressCallbackValues = emptyList>()
- val editMessageCalls = mutableListOf()
+ val editMessageCalls = mutableListOf>()
var sendMediaCount = 0
private set
@@ -136,10 +140,19 @@ class FakeMatrixRoom(
override val membersStateFlow: MutableStateFlow = MutableStateFlow(MatrixRoomMembersState.Unknown)
+ override val roomNotificationSettingsStateFlow: MutableStateFlow =
+ MutableStateFlow(MatrixRoomNotificationSettingsState.Unknown)
+
override suspend fun updateMembers(): Result = simulateLongTask {
updateMembersResult
}
+ override suspend fun updateRoomNotificationSettings(): Result = simulateLongTask {
+ val notificationSettings = notificationSettingsService.getRoomNotificationSettings(roomId, isEncrypted, isOneToOne).getOrThrow()
+ roomNotificationSettingsStateFlow.value = MatrixRoomNotificationSettingsState.Ready(notificationSettings)
+ return Result.success(Unit)
+ }
+
override val syncUpdateFlow: StateFlow = MutableStateFlow(0L)
override val timeline: MatrixTimeline = matrixTimeline
@@ -158,7 +171,7 @@ class FakeMatrixRoom(
userAvatarUrlResult
}
- override suspend fun sendMessage(message: String): Result = simulateLongTask {
+ override suspend fun sendMessage(body: String, htmlBody: String?) = simulateLongTask {
Result.success(Unit)
}
@@ -187,16 +200,16 @@ class FakeMatrixRoom(
return cancelSendResult
}
- override suspend fun editMessage(originalEventId: EventId?, transactionId: TransactionId?, message: String): Result {
- editMessageCalls += message
+ override suspend fun editMessage(originalEventId: EventId?, transactionId: TransactionId?, body: String, htmlBody: String?): Result {
+ editMessageCalls += body to htmlBody
return Result.success(Unit)
}
- var replyMessageParameter: String? = null
+ var replyMessageParameter: Pair? = null
private set
- override suspend fun replyMessage(eventId: EventId, message: String): Result {
- replyMessageParameter = message
+ override suspend fun replyMessage(eventId: EventId, body: String, htmlBody: String?): Result {
+ replyMessageParameter = body to htmlBody
return Result.success(Unit)
}
diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/RoomSummaryFixture.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/RoomSummaryFixture.kt
index 59bac7ad40..d3b4dbc577 100644
--- a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/RoomSummaryFixture.kt
+++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/RoomSummaryFixture.kt
@@ -20,6 +20,7 @@ import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.TransactionId
import io.element.android.libraries.matrix.api.core.UserId
+import io.element.android.libraries.matrix.api.room.RoomNotificationMode
import io.element.android.libraries.matrix.api.roomlist.RoomSummary
import io.element.android.libraries.matrix.api.roomlist.RoomSummaryDetails
import io.element.android.libraries.matrix.api.room.message.RoomMessage
@@ -48,6 +49,7 @@ fun aRoomSummaryFilled(
lastMessage: RoomMessage? = aRoomMessage(),
lastMessageTimestamp: Long? = null,
unreadNotificationCount: Int = 2,
+ notificationMode: RoomNotificationMode? = null,
) = RoomSummary.Filled(
aRoomSummaryDetail(
roomId = roomId,
@@ -57,6 +59,7 @@ fun aRoomSummaryFilled(
lastMessage = lastMessage,
lastMessageTimestamp = lastMessageTimestamp,
unreadNotificationCount = unreadNotificationCount,
+ notificationMode = notificationMode,
)
)
@@ -68,6 +71,7 @@ fun aRoomSummaryDetail(
lastMessage: RoomMessage? = aRoomMessage(),
lastMessageTimestamp: Long? = null,
unreadNotificationCount: Int = 2,
+ notificationMode: RoomNotificationMode? = null,
) = RoomSummaryDetails(
roomId = roomId,
name = name,
@@ -76,6 +80,7 @@ fun aRoomSummaryDetail(
lastMessage = lastMessage,
lastMessageTimestamp = lastMessageTimestamp,
unreadNotificationCount = unreadNotificationCount,
+ notificationMode = notificationMode
)
fun aRoomMessage(
@@ -147,6 +152,7 @@ fun aMessageContent(
body: String = "body",
inReplyTo: InReplyTo? = null,
isEdited: Boolean = false,
+ isThreaded: Boolean = false,
messageType: MessageType = TextMessageType(
body = body,
formatted = null
@@ -155,6 +161,7 @@ fun aMessageContent(
body = body,
inReplyTo = inReplyTo,
isEdited = isEdited,
+ isThreaded = isThreaded,
type = messageType
)
diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/roomlist/FakeRoomListService.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/roomlist/FakeRoomListService.kt
index fa2e347e3b..75a91508d0 100644
--- a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/roomlist/FakeRoomListService.kt
+++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/roomlist/FakeRoomListService.kt
@@ -29,6 +29,7 @@ class FakeRoomListService : RoomListService {
private val allRoomsLoadingStateFlow = MutableStateFlow