mirror of
https://github.com/Novaloop-AG/element-x-android.git
synced 2026-11-02 12:07:28 +00:00
Merge branch 'develop' into fix/5794-account-data-api
This commit is contained in:
@@ -51,12 +51,12 @@ jobs:
|
||||
ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.ref }}
|
||||
persist-credentials: false
|
||||
- name: Use JDK 21
|
||||
uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5.6.0
|
||||
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
|
||||
with:
|
||||
distribution: 'temurin' # See 'Supported distributions' for available options
|
||||
java-version: '21'
|
||||
- name: Configure gradle
|
||||
uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0
|
||||
uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0
|
||||
with:
|
||||
cache-read-only: ${{ github.ref != 'refs/heads/develop' }}
|
||||
- name: Assemble debug APKs
|
||||
|
||||
@@ -56,12 +56,12 @@ jobs:
|
||||
- name: Clone submodules
|
||||
run: git submodule update --init --recursive
|
||||
- name: Use JDK 21
|
||||
uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5.6.0
|
||||
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
|
||||
with:
|
||||
distribution: 'temurin' # See 'Supported distributions' for available options
|
||||
java-version: '21'
|
||||
- name: Configure gradle
|
||||
uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0
|
||||
uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0
|
||||
with:
|
||||
cache-read-only: ${{ github.ref != 'refs/heads/develop' }}
|
||||
- name: Assemble debug Gplay Enterprise APK
|
||||
|
||||
@@ -19,12 +19,12 @@ jobs:
|
||||
- name: ⏬ Checkout with LFS
|
||||
uses: nschloe/action-cached-lfs-checkout@385a8ecc719e50b8c71af6ab01a624b486b7c3bc # v1.2.5
|
||||
- name: Use JDK 21
|
||||
uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5.6.0
|
||||
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
|
||||
with:
|
||||
distribution: 'temurin' # See 'Supported distributions' for available options
|
||||
java-version: '21'
|
||||
- name: Configure gradle
|
||||
uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0
|
||||
uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0
|
||||
with:
|
||||
cache-read-only: ${{ github.ref != 'refs/heads/develop' }}
|
||||
- name: Set up Python 3.12
|
||||
|
||||
@@ -16,7 +16,7 @@ jobs:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5.6.0
|
||||
- uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
|
||||
name: Use JDK 21
|
||||
with:
|
||||
distribution: 'temurin' # See 'Supported distributions' for available options
|
||||
|
||||
@@ -44,13 +44,13 @@ jobs:
|
||||
# https://github.com/actions/checkout/issues/881
|
||||
ref: ${{ github.ref }}
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5.6.0
|
||||
- uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
|
||||
name: Use JDK 21
|
||||
with:
|
||||
distribution: 'temurin' # See 'Supported distributions' for available options
|
||||
java-version: '21'
|
||||
- name: Configure gradle
|
||||
uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0
|
||||
uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0
|
||||
with:
|
||||
cache-read-only: ${{ github.ref != 'refs/heads/develop' }}
|
||||
- name: Assemble debug APK
|
||||
|
||||
@@ -36,7 +36,7 @@ jobs:
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Use JDK 21
|
||||
uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5.6.0
|
||||
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
|
||||
with:
|
||||
distribution: 'temurin' # See 'Supported distributions' for available options
|
||||
java-version: '21'
|
||||
|
||||
@@ -37,13 +37,13 @@ jobs:
|
||||
uses: nschloe/action-cached-lfs-checkout@385a8ecc719e50b8c71af6ab01a624b486b7c3bc # v1.2.5
|
||||
|
||||
- name: Use JDK 21
|
||||
uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5.6.0
|
||||
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
|
||||
with:
|
||||
distribution: 'temurin' # See 'Supported distributions' for available options
|
||||
java-version: '21'
|
||||
|
||||
- name: Configure gradle
|
||||
uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0
|
||||
uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0
|
||||
with:
|
||||
cache-read-only: false
|
||||
|
||||
@@ -80,12 +80,12 @@ jobs:
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Use JDK 21
|
||||
uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5.6.0
|
||||
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
|
||||
with:
|
||||
distribution: 'temurin' # See 'Supported distributions' for available options
|
||||
java-version: '21'
|
||||
- name: Configure gradle
|
||||
uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0
|
||||
uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0
|
||||
with:
|
||||
cache-read-only: ${{ github.ref != 'refs/heads/develop' }}
|
||||
- name: Dependency analysis
|
||||
|
||||
@@ -69,12 +69,12 @@ jobs:
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Use JDK 21
|
||||
uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5.6.0
|
||||
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
|
||||
with:
|
||||
distribution: 'temurin' # See 'Supported distributions' for available options
|
||||
java-version: '21'
|
||||
- name: Configure gradle
|
||||
uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0
|
||||
uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0
|
||||
with:
|
||||
cache-read-only: ${{ github.ref != 'refs/heads/develop' }}
|
||||
- name: Set up Python 3.12
|
||||
@@ -108,12 +108,12 @@ jobs:
|
||||
if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'element-hq/element-x-android' }}
|
||||
run: git submodule update --init --recursive
|
||||
- name: Use JDK 21
|
||||
uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5.6.0
|
||||
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
|
||||
with:
|
||||
distribution: 'temurin' # See 'Supported distributions' for available options
|
||||
java-version: '21'
|
||||
- name: Configure gradle
|
||||
uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0
|
||||
uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0
|
||||
with:
|
||||
cache-read-only: ${{ github.ref != 'refs/heads/develop' }}
|
||||
- name: Run Konsist tests
|
||||
@@ -149,12 +149,12 @@ jobs:
|
||||
if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'element-hq/element-x-android' }}
|
||||
run: git submodule update --init --recursive
|
||||
- name: Use JDK 21
|
||||
uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5.6.0
|
||||
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
|
||||
with:
|
||||
distribution: 'temurin' # See 'Supported distributions' for available options
|
||||
java-version: '21'
|
||||
- name: Configure gradle
|
||||
uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0
|
||||
uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0
|
||||
with:
|
||||
cache-read-only: ${{ github.ref != 'refs/heads/develop' }}
|
||||
- name: Run compose tests
|
||||
@@ -183,12 +183,12 @@ jobs:
|
||||
if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'element-hq/element-x-android' }}
|
||||
run: git submodule update --init --recursive
|
||||
- name: Use JDK 21
|
||||
uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5.6.0
|
||||
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
|
||||
with:
|
||||
distribution: 'temurin' # See 'Supported distributions' for available options
|
||||
java-version: '21'
|
||||
- name: Configure gradle
|
||||
uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0
|
||||
uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0
|
||||
with:
|
||||
cache-read-only: ${{ github.ref != 'refs/heads/develop' }}
|
||||
- name: Build Gplay Debug
|
||||
@@ -228,12 +228,12 @@ jobs:
|
||||
if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'element-hq/element-x-android' }}
|
||||
run: git submodule update --init --recursive
|
||||
- name: Use JDK 21
|
||||
uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5.6.0
|
||||
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
|
||||
with:
|
||||
distribution: 'temurin' # See 'Supported distributions' for available options
|
||||
java-version: '21'
|
||||
- name: Configure gradle
|
||||
uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0
|
||||
uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0
|
||||
with:
|
||||
cache-read-only: ${{ github.ref != 'refs/heads/develop' }}
|
||||
- name: Run Detekt
|
||||
@@ -269,12 +269,12 @@ jobs:
|
||||
if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'element-hq/element-x-android' }}
|
||||
run: git submodule update --init --recursive
|
||||
- name: Use JDK 21
|
||||
uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5.6.0
|
||||
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
|
||||
with:
|
||||
distribution: 'temurin' # See 'Supported distributions' for available options
|
||||
java-version: '21'
|
||||
- name: Configure gradle
|
||||
uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0
|
||||
uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0
|
||||
with:
|
||||
cache-read-only: ${{ github.ref != 'refs/heads/develop' }}
|
||||
- name: Run Ktlint check
|
||||
@@ -336,7 +336,7 @@ jobs:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: zizmorcore/zizmor-action@6fc4b006235f201fdab3722e17240ab420d580e5 # v0.6.1
|
||||
- uses: zizmorcore/zizmor-action@3dc1ecc9bcb9e94e9b2c709687979e1298497054 # v0.6.2
|
||||
|
||||
upload_reports:
|
||||
name: Project Check Suite
|
||||
|
||||
@@ -54,13 +54,13 @@ jobs:
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: ☕️ Use JDK 21
|
||||
uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5.6.0
|
||||
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
|
||||
with:
|
||||
distribution: 'temurin' # See 'Supported distributions' for available options
|
||||
java-version: '21'
|
||||
# Add gradle cache, this should speed up the process
|
||||
- name: Configure gradle
|
||||
uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0
|
||||
uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0
|
||||
with:
|
||||
cache-read-only: ${{ github.ref != 'refs/heads/develop' }}
|
||||
- name: Record screenshots
|
||||
|
||||
@@ -38,12 +38,12 @@ jobs:
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Use JDK 21
|
||||
uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5.6.0
|
||||
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
|
||||
with:
|
||||
distribution: 'temurin' # See 'Supported distributions' for available options
|
||||
java-version: '21'
|
||||
- name: Configure gradle
|
||||
uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0
|
||||
uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0
|
||||
- name: Create app bundle
|
||||
env:
|
||||
ELEMENT_ANDROID_MAPTILER_API_KEY: ${{ secrets.MAPTILER_KEY }}
|
||||
@@ -82,12 +82,12 @@ jobs:
|
||||
- name: Clone submodules
|
||||
run: git submodule update --init --recursive
|
||||
- name: Use JDK 21
|
||||
uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5.6.0
|
||||
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
|
||||
with:
|
||||
distribution: 'temurin' # See 'Supported distributions' for available options
|
||||
java-version: '21'
|
||||
- name: Configure gradle
|
||||
uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0
|
||||
uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0
|
||||
- name: Create Enterprise app bundle
|
||||
env:
|
||||
ELEMENT_ANDROID_MAPTILER_API_KEY: ${{ secrets.MAPTILER_KEY }}
|
||||
@@ -126,12 +126,12 @@ jobs:
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Use JDK 21
|
||||
uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5.6.0
|
||||
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
|
||||
with:
|
||||
distribution: 'temurin' # See 'Supported distributions' for available options
|
||||
java-version: '21'
|
||||
- name: Configure gradle
|
||||
uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0
|
||||
uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0
|
||||
- name: Create APKs
|
||||
env:
|
||||
ELEMENT_ANDROID_MAPTILER_API_KEY: ${{ secrets.MAPTILER_KEY }}
|
||||
|
||||
@@ -45,12 +45,12 @@ jobs:
|
||||
ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.ref }}
|
||||
persist-credentials: false
|
||||
- name: Use JDK 21
|
||||
uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5.6.0
|
||||
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
|
||||
with:
|
||||
distribution: 'temurin' # See 'Supported distributions' for available options
|
||||
java-version: '21'
|
||||
- name: Configure gradle
|
||||
uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0
|
||||
uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0
|
||||
with:
|
||||
cache-read-only: ${{ github.ref != 'refs/heads/develop' }}
|
||||
- name: Build debug code and test fixtures
|
||||
|
||||
@@ -17,12 +17,12 @@ jobs:
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Use JDK 21
|
||||
uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5.6.0
|
||||
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
|
||||
with:
|
||||
distribution: 'temurin' # See 'Supported distributions' for available options
|
||||
java-version: '21'
|
||||
- name: Configure gradle
|
||||
uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0
|
||||
uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0
|
||||
with:
|
||||
cache-read-only: ${{ github.ref != 'refs/heads/develop' }}
|
||||
- name: Set up Python 3.12
|
||||
|
||||
@@ -63,12 +63,12 @@ jobs:
|
||||
if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'element-hq/element-x-android' }}
|
||||
run: git submodule update --init --recursive
|
||||
- name: ☕️ Use JDK 21
|
||||
uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5.6.0
|
||||
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
|
||||
with:
|
||||
distribution: 'temurin' # See 'Supported distributions' for available options
|
||||
java-version: '21'
|
||||
- name: Configure gradle
|
||||
uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0
|
||||
uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0
|
||||
with:
|
||||
cache-read-only: ${{ github.ref != 'refs/heads/develop' }}
|
||||
|
||||
|
||||
@@ -60,6 +60,7 @@ dependencies {
|
||||
testCommonDependencies(libs)
|
||||
testImplementation(projects.features.login.test)
|
||||
testImplementation(projects.features.share.test)
|
||||
testImplementation(projects.libraries.featureflag.test)
|
||||
testImplementation(projects.libraries.matrix.test)
|
||||
testImplementation(projects.libraries.oauth.test)
|
||||
testImplementation(projects.libraries.permissions.test)
|
||||
|
||||
@@ -30,6 +30,8 @@ import io.element.android.libraries.architecture.Presenter
|
||||
import io.element.android.libraries.core.extensions.runCatchingExceptions
|
||||
import io.element.android.libraries.core.log.logger.LoggerTag
|
||||
import io.element.android.libraries.core.meta.BuildMeta
|
||||
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.encryption.EncryptionService
|
||||
import io.element.android.libraries.matrix.api.encryption.RecoveryState
|
||||
@@ -65,6 +67,7 @@ class LoggedInPresenter(
|
||||
private val buildMeta: BuildMeta,
|
||||
private val networkMonitor: NetworkMonitor,
|
||||
private val localNetworkPermissionAdvisor: LocalNetworkPermissionAdvisor,
|
||||
private val featureFlagService: FeatureFlagService,
|
||||
permissionsPresenterFactory: PermissionsPresenter.Factory,
|
||||
) : Presenter<LoggedInState> {
|
||||
private val localNetworkPermissionsPresenter: PermissionsPresenter =
|
||||
@@ -120,6 +123,16 @@ class LoggedInPresenter(
|
||||
}.launchIn(this)
|
||||
}
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
// Keep automatic call status (m.call) in sync with the feature flag and homeserver support.
|
||||
featureFlagService.isFeatureEnabledFlow(FeatureFlags.UserStatus)
|
||||
.onEach { isUserStatusEnabled ->
|
||||
val enabled = isUserStatusEnabled && matrixClient.isUserStatusSupported().getOrDefault(false)
|
||||
matrixClient.enableAutomaticCallStatus(enabled)
|
||||
}
|
||||
.launchIn(this)
|
||||
}
|
||||
|
||||
val networkConnectivity by networkMonitor.connectivity.collectAsState()
|
||||
LaunchedEffect(networkConnectivity) {
|
||||
if (networkConnectivity == NetworkStatus.Connected) {
|
||||
|
||||
@@ -17,6 +17,9 @@ import im.vector.app.features.analytics.plan.UserProperties
|
||||
import io.element.android.features.networkmonitor.api.NetworkStatus
|
||||
import io.element.android.features.networkmonitor.test.FakeNetworkMonitor
|
||||
import io.element.android.libraries.core.meta.BuildMeta
|
||||
import io.element.android.libraries.featureflag.api.FeatureFlagService
|
||||
import io.element.android.libraries.featureflag.api.FeatureFlags
|
||||
import io.element.android.libraries.featureflag.test.FakeFeatureFlagService
|
||||
import io.element.android.libraries.matrix.api.MatrixClient
|
||||
import io.element.android.libraries.matrix.api.core.SessionId
|
||||
import io.element.android.libraries.matrix.api.encryption.EncryptionService
|
||||
@@ -48,6 +51,7 @@ import io.element.android.services.analytics.api.AnalyticsService
|
||||
import io.element.android.services.analytics.test.FakeAnalyticsService
|
||||
import io.element.android.tests.testutils.WarmUpRule
|
||||
import io.element.android.tests.testutils.consumeItemsUntilPredicate
|
||||
import io.element.android.tests.testutils.lambda.assert
|
||||
import io.element.android.tests.testutils.lambda.lambdaError
|
||||
import io.element.android.tests.testutils.lambda.lambdaRecorder
|
||||
import io.element.android.tests.testutils.lambda.value
|
||||
@@ -127,6 +131,7 @@ class LoggedInPresenterTest {
|
||||
buildMeta = buildMeta,
|
||||
networkMonitor = networkMonitor,
|
||||
localNetworkPermissionAdvisor = FakeLocalNetworkPermissionAdvisor(),
|
||||
featureFlagService = FakeFeatureFlagService(),
|
||||
permissionsPresenterFactory = FakePermissionsPresenterFactory(),
|
||||
).test {
|
||||
encryptionService.emitRecoveryState(RecoveryState.UNKNOWN)
|
||||
@@ -346,6 +351,57 @@ class LoggedInPresenterTest {
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - does not enable automatic call status when server does not support it`() = runTest {
|
||||
val enableAutomaticCallStatusLambda = lambdaRecorder<Boolean, Unit> { }
|
||||
val matrixClient = FakeMatrixClient(
|
||||
accountManagementUrlResult = { Result.success(null) },
|
||||
enableAutomaticCallStatusLambda = enableAutomaticCallStatusLambda,
|
||||
).apply {
|
||||
isUserStatusSupportedResult = Result.success(false)
|
||||
}
|
||||
createLoggedInPresenter(
|
||||
matrixClient = matrixClient,
|
||||
featureFlagService = FakeFeatureFlagService(
|
||||
initialState = mapOf(FeatureFlags.UserStatus.key to true),
|
||||
),
|
||||
).test {
|
||||
cancelAndConsumeRemainingEvents()
|
||||
assert(enableAutomaticCallStatusLambda)
|
||||
.isCalledOnce()
|
||||
.with(value(false))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - reacts to user status flag being toggled at runtime`() = runTest {
|
||||
val enableAutomaticCallStatusLambda = lambdaRecorder<Boolean, Unit> { }
|
||||
val matrixClient = FakeMatrixClient(
|
||||
accountManagementUrlResult = { Result.success(null) },
|
||||
enableAutomaticCallStatusLambda = enableAutomaticCallStatusLambda,
|
||||
).apply {
|
||||
isUserStatusSupportedResult = Result.success(true)
|
||||
}
|
||||
val featureFlagService = FakeFeatureFlagService(
|
||||
initialState = mapOf(FeatureFlags.UserStatus.key to false),
|
||||
)
|
||||
createLoggedInPresenter(
|
||||
matrixClient = matrixClient,
|
||||
featureFlagService = featureFlagService,
|
||||
).test {
|
||||
awaitItem()
|
||||
// Turning the flag on at runtime enables it.
|
||||
featureFlagService.setFeatureEnabled(FeatureFlags.UserStatus, true)
|
||||
cancelAndConsumeRemainingEvents()
|
||||
assert(enableAutomaticCallStatusLambda)
|
||||
.isCalledExactly(2)
|
||||
.withSequence(
|
||||
listOf(value(false)),
|
||||
listOf(value(true)),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun <T> ReceiveTurbine<T>.awaitFirstItem(): T {
|
||||
skipItems(1)
|
||||
return awaitItem()
|
||||
@@ -362,6 +418,7 @@ class LoggedInPresenterTest {
|
||||
),
|
||||
buildMeta: BuildMeta = aBuildMeta(),
|
||||
networkMonitor: FakeNetworkMonitor = FakeNetworkMonitor(),
|
||||
featureFlagService: FeatureFlagService = FakeFeatureFlagService(),
|
||||
): LoggedInPresenter {
|
||||
return LoggedInPresenter(
|
||||
matrixClient = matrixClient,
|
||||
@@ -373,6 +430,7 @@ class LoggedInPresenterTest {
|
||||
buildMeta = buildMeta,
|
||||
networkMonitor = networkMonitor,
|
||||
localNetworkPermissionAdvisor = FakeLocalNetworkPermissionAdvisor(),
|
||||
featureFlagService = featureFlagService,
|
||||
permissionsPresenterFactory = FakePermissionsPresenterFactory(),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -303,6 +303,8 @@ Follow these steps to install and configure the plugin and templates:
|
||||
- Click on OK
|
||||
4. Configure generate-module-from-template plugin :
|
||||
- Navigate to AS/Settings/Tools/Module Template Settings
|
||||
- If a `FeatureModule` template is already listed, select it and remove it, since the plugin keeps its own copy of the
|
||||
imported template and will not pick up changes made to `FeatureModule.json` on its own
|
||||
- Click on + / Import From File
|
||||
- Pick the `tools/templates/FeatureModule.json`
|
||||
|
||||
@@ -321,8 +323,10 @@ Example for a new feature called RoomDetails:
|
||||
5. The modules api/impl should be created under `features/roomdetails` directory.
|
||||
6. Sync project with Gradle so the modules are recognized (no need to add them to settings.gradle).
|
||||
7. You can now add more Presentation classes (Events, State, StateProvider, View, Presenter) in the impl module with the `Template Presentation Classes`.
|
||||
To use it, just right click on the package where you want to generate classes, and click on `Template Presentation Classes`.
|
||||
Fill the text field with the base name of the classes, ie `RootRoomDetails` in the `root` package.
|
||||
To use it, just right click on the package where you want to generate classes, and click on `New` / `Template Presentation Classes`.
|
||||
Fill the text field with the base name of the classes: the generated files are `<BaseName>Event.kt`, `<BaseName>State.kt`,
|
||||
`<BaseName>StateProvider.kt`, `<BaseName>Presenter.kt`, `<BaseName>Node.kt` and `<BaseName>View.kt`. For instance
|
||||
`PreferencesRoot` in the `features/preferences/impl/root` package.
|
||||
|
||||
|
||||
Note that naming of files and classes is important, since those names are used to set up code coverage rules. For instance, presenters MUST have a
|
||||
|
||||
+1
-1
Submodule enterprise updated: 029db99513...f5f910856c
@@ -1,14 +1,14 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
|
||||
<string name="screen_deactivate_account_confirmation_dialog_content">"請確認您想要停用您的帳號。此動作無法還原。"</string>
|
||||
<string name="screen_deactivate_account_confirmation_dialog_content">"請確認您想要刪除您的帳號。此動作無法還原。"</string>
|
||||
<string name="screen_deactivate_account_delete_all_messages">"刪除我所有的訊息"</string>
|
||||
<string name="screen_deactivate_account_delete_all_messages_notice">"警告:未來的使用者可能會看到不完整的對話。"</string>
|
||||
<string name="screen_deactivate_account_description">"停用您的帳號為 %1$s,它將:"</string>
|
||||
<string name="screen_deactivate_account_description">"刪除您的帳號為 %1$s,它將:"</string>
|
||||
<string name="screen_deactivate_account_description_bold_part">"不可逆"</string>
|
||||
<string name="screen_deactivate_account_list_item_1">"%1$s 您的帳號(您將無法重新登入,也無法重用您的 ID)。"</string>
|
||||
<string name="screen_deactivate_account_list_item_1_bold_part">"永久停用"</string>
|
||||
<string name="screen_deactivate_account_list_item_2">"將您從所有聊天室移除。"</string>
|
||||
<string name="screen_deactivate_account_list_item_3">"從我們的身份伺服器將您的帳號資訊刪除。"</string>
|
||||
<string name="screen_deactivate_account_list_item_4">"註冊使用者仍可看到您的訊息,但如果您選擇刪除,新使用者與未註冊的使用者將看不到它們。"</string>
|
||||
<string name="screen_deactivate_account_title">"停用帳號"</string>
|
||||
<string name="screen_deactivate_account_title">"刪除帳號"</string>
|
||||
</resources>
|
||||
|
||||
+7
-1
@@ -10,6 +10,7 @@ package io.element.android.features.enterprise.api
|
||||
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import io.element.android.compound.colors.SemanticColorsLightDark
|
||||
import io.element.android.libraries.matrix.api.UrlContentFetcher
|
||||
import io.element.android.libraries.matrix.api.core.SessionId
|
||||
import io.element.android.libraries.wellknown.api.ElementWellKnown
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
@@ -17,7 +18,7 @@ import kotlinx.coroutines.flow.Flow
|
||||
interface EnterpriseService {
|
||||
val isEnterpriseBuild: Boolean
|
||||
suspend fun isEnterpriseUser(sessionId: SessionId): Boolean
|
||||
suspend fun tweakMasUrl(url: String, homeserver: String): String
|
||||
suspend fun tweakMasUrl(url: String, homeserver: String, urlContentFetcher: UrlContentFetcher): String
|
||||
fun defaultHomeserverList(): List<String>
|
||||
suspend fun isAllowedToConnectToHomeserver(homeserverUrl: String): Boolean
|
||||
|
||||
@@ -47,6 +48,11 @@ interface EnterpriseService {
|
||||
*/
|
||||
fun overriddenElementWellKnown(): ElementWellKnown?
|
||||
|
||||
/**
|
||||
* Gets the Element Server Suite (ESS) config endpoint URL for the given domain.
|
||||
*/
|
||||
fun essConfigEndpointUrl(domain: String): String?
|
||||
|
||||
companion object {
|
||||
const val ANY_ACCOUNT_PROVIDER = "*"
|
||||
}
|
||||
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Element Creations Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.features.enterprise.api.remoteconfig
|
||||
|
||||
import io.element.android.libraries.wellknown.api.ElementWellKnown
|
||||
|
||||
/**
|
||||
* Typealias for the well-known configuration used for enterprise features.
|
||||
*
|
||||
* This will allow for easier refactoring in the future when we'll want to move as much of its logic as possible to this module.
|
||||
*/
|
||||
typealias RemoteEnterpriseConfig = ElementWellKnown
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Element Creations Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.features.enterprise.api.remoteconfig
|
||||
|
||||
import io.element.android.libraries.matrix.api.UrlContentFetcher
|
||||
import io.element.android.libraries.matrix.api.core.SessionId
|
||||
import io.element.android.libraries.wellknown.api.WellknownRetrieverResult
|
||||
|
||||
/**
|
||||
* Wrapper to fetch the remote enterprise configuration for a given [SessionId], either from a local cache or from a remote source.
|
||||
*/
|
||||
interface RemoteEnterpriseConfigProvider {
|
||||
suspend fun get(sessionId: SessionId): WellknownRetrieverResult<RemoteEnterpriseConfig>
|
||||
|
||||
interface Factory {
|
||||
fun create(urlContentFetcher: UrlContentFetcher): RemoteEnterpriseConfigProvider
|
||||
}
|
||||
}
|
||||
+4
-1
@@ -14,6 +14,7 @@ import dev.zacsweers.metro.ContributesBinding
|
||||
import io.element.android.compound.colors.SemanticColorsLightDark
|
||||
import io.element.android.features.enterprise.api.BugReportUrl
|
||||
import io.element.android.features.enterprise.api.EnterpriseService
|
||||
import io.element.android.libraries.matrix.api.UrlContentFetcher
|
||||
import io.element.android.libraries.matrix.api.core.SessionId
|
||||
import io.element.android.libraries.wellknown.api.ElementWellKnown
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
@@ -24,7 +25,7 @@ class DefaultEnterpriseService : EnterpriseService {
|
||||
override val isEnterpriseBuild = false
|
||||
|
||||
override suspend fun isEnterpriseUser(sessionId: SessionId) = false
|
||||
override suspend fun tweakMasUrl(url: String, homeserver: String) = url
|
||||
override suspend fun tweakMasUrl(url: String, homeserver: String, urlContentFetcher: UrlContentFetcher) = url
|
||||
override fun defaultHomeserverList(): List<String> = emptyList()
|
||||
override suspend fun isAllowedToConnectToHomeserver(homeserverUrl: String) = true
|
||||
|
||||
@@ -48,4 +49,6 @@ class DefaultEnterpriseService : EnterpriseService {
|
||||
override fun getNoisyNotificationChannelId(sessionId: SessionId): String? = null
|
||||
|
||||
override fun overriddenElementWellKnown(): ElementWellKnown? = null
|
||||
|
||||
override fun essConfigEndpointUrl(domain: String): String? = null
|
||||
}
|
||||
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Element Creations Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.features.enterprise.impl.remoteconfig
|
||||
|
||||
import dev.zacsweers.metro.AppScope
|
||||
import dev.zacsweers.metro.ContributesBinding
|
||||
import io.element.android.features.enterprise.api.remoteconfig.RemoteEnterpriseConfig
|
||||
import io.element.android.features.enterprise.api.remoteconfig.RemoteEnterpriseConfigProvider
|
||||
import io.element.android.libraries.matrix.api.UrlContentFetcher
|
||||
import io.element.android.libraries.matrix.api.core.SessionId
|
||||
import io.element.android.libraries.wellknown.api.WellknownRetrieverResult
|
||||
|
||||
@ContributesBinding(AppScope::class)
|
||||
class DefaultRemoteEnterpriseConfigProvider : RemoteEnterpriseConfigProvider {
|
||||
override suspend fun get(sessionId: SessionId): WellknownRetrieverResult<RemoteEnterpriseConfig> {
|
||||
return WellknownRetrieverResult.NotFound
|
||||
}
|
||||
|
||||
@ContributesBinding(AppScope::class)
|
||||
class Factory : RemoteEnterpriseConfigProvider.Factory {
|
||||
override fun create(urlContentFetcher: UrlContentFetcher): RemoteEnterpriseConfigProvider {
|
||||
return DefaultRemoteEnterpriseConfigProvider()
|
||||
}
|
||||
}
|
||||
}
|
||||
+11
-2
@@ -5,10 +5,12 @@
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.libraries.matrix.impl.scanner
|
||||
package io.element.android.features.enterprise.impl.scanner
|
||||
|
||||
import dev.zacsweers.metro.AppScope
|
||||
import dev.zacsweers.metro.ContributesBinding
|
||||
import io.element.android.libraries.matrix.api.UrlContentFetcher
|
||||
import io.element.android.libraries.matrix.api.core.SessionId
|
||||
import io.element.android.libraries.matrix.api.scanner.ContentScannerUrlProvider
|
||||
|
||||
/**
|
||||
@@ -16,5 +18,12 @@ import io.element.android.libraries.matrix.api.scanner.ContentScannerUrlProvider
|
||||
*/
|
||||
@ContributesBinding(AppScope::class)
|
||||
class DefaultContentScannerUrlProvider : ContentScannerUrlProvider {
|
||||
override suspend fun getContentScannerUrl(homeserver: String): Result<String?> = Result.success(null)
|
||||
override suspend fun getContentScannerUrl(sessionId: SessionId): Result<String?> = Result.success(null)
|
||||
|
||||
@ContributesBinding(AppScope::class)
|
||||
class Factory : ContentScannerUrlProvider.Factory {
|
||||
override fun create(urlContentFetcher: UrlContentFetcher): ContentScannerUrlProvider {
|
||||
return DefaultContentScannerUrlProvider()
|
||||
}
|
||||
}
|
||||
}
|
||||
+9
-3
@@ -12,6 +12,7 @@ import androidx.compose.ui.graphics.Color
|
||||
import io.element.android.compound.colors.SemanticColorsLightDark
|
||||
import io.element.android.features.enterprise.api.BugReportUrl
|
||||
import io.element.android.features.enterprise.api.EnterpriseService
|
||||
import io.element.android.libraries.matrix.api.UrlContentFetcher
|
||||
import io.element.android.libraries.matrix.api.core.SessionId
|
||||
import io.element.android.libraries.wellknown.api.ElementWellKnown
|
||||
import io.element.android.tests.testutils.lambda.lambdaError
|
||||
@@ -31,8 +32,9 @@ class FakeEnterpriseService(
|
||||
private val firebasePushGatewayResult: () -> String? = { lambdaError() },
|
||||
private val unifiedPushDefaultPushGatewayResult: () -> String? = { lambdaError() },
|
||||
private val getNoisyNotificationChannelIdResult: (SessionId?) -> String? = { lambdaError() },
|
||||
private val tweakMasUrlResult: (String, String) -> String = { _, _ -> lambdaError() },
|
||||
private val tweakMasUrlResult: (String, String, UrlContentFetcher) -> String = { _, _, _ -> lambdaError() },
|
||||
private val overrideWellKnownResult: () -> ElementWellKnown? = { lambdaError() },
|
||||
private val essConfigEndpointUrlResult: (String) -> String = { lambdaError() },
|
||||
) : EnterpriseService {
|
||||
private val brandColorState = MutableStateFlow(initialBrandColor)
|
||||
private val semanticColorsState = MutableStateFlow(initialSemanticColors)
|
||||
@@ -41,8 +43,8 @@ class FakeEnterpriseService(
|
||||
isEnterpriseUserResult(sessionId)
|
||||
}
|
||||
|
||||
override suspend fun tweakMasUrl(url: String, homeserver: String): String = simulateLongTask {
|
||||
tweakMasUrlResult(url, homeserver)
|
||||
override suspend fun tweakMasUrl(url: String, homeserver: String, urlContentFetcher: UrlContentFetcher): String = simulateLongTask {
|
||||
tweakMasUrlResult(url, homeserver, urlContentFetcher)
|
||||
}
|
||||
|
||||
override fun defaultHomeserverList(): List<String> {
|
||||
@@ -85,4 +87,8 @@ class FakeEnterpriseService(
|
||||
override fun overriddenElementWellKnown(): ElementWellKnown? {
|
||||
return overrideWellKnownResult()
|
||||
}
|
||||
|
||||
override fun essConfigEndpointUrl(domain: String): String {
|
||||
return essConfigEndpointUrlResult(domain)
|
||||
}
|
||||
}
|
||||
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Element Creations Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.features.enterprise.test.remoteconfig
|
||||
|
||||
import io.element.android.features.enterprise.api.remoteconfig.RemoteEnterpriseConfig
|
||||
import io.element.android.features.enterprise.api.remoteconfig.RemoteEnterpriseConfigProvider
|
||||
import io.element.android.libraries.matrix.api.core.SessionId
|
||||
import io.element.android.libraries.wellknown.api.WellknownRetrieverResult
|
||||
|
||||
class FakeRemoteEnterpriseConfigProvider(
|
||||
private val getResult: (SessionId) -> WellknownRetrieverResult<RemoteEnterpriseConfig> = { WellknownRetrieverResult.NotFound }
|
||||
) : RemoteEnterpriseConfigProvider {
|
||||
override suspend fun get(sessionId: SessionId): WellknownRetrieverResult<RemoteEnterpriseConfig> {
|
||||
return getResult(sessionId)
|
||||
}
|
||||
}
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Element Creations Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.features.enterprise.test.remoteconfig
|
||||
|
||||
import io.element.android.features.enterprise.api.remoteconfig.RemoteEnterpriseConfigProvider
|
||||
import io.element.android.libraries.matrix.api.UrlContentFetcher
|
||||
|
||||
class FakeRemoteEnterpriseConfigProviderFactory(
|
||||
private val remoteEnterpriseConfigProvider: RemoteEnterpriseConfigProvider = FakeRemoteEnterpriseConfigProvider(),
|
||||
) : RemoteEnterpriseConfigProvider.Factory {
|
||||
override fun create(urlContentFetcher: UrlContentFetcher): RemoteEnterpriseConfigProvider = remoteEnterpriseConfigProvider
|
||||
}
|
||||
+127
-5
@@ -8,6 +8,8 @@
|
||||
|
||||
package io.element.android.features.home.impl.components
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.RowScope
|
||||
@@ -15,9 +17,11 @@ import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.WindowInsetsSides
|
||||
import androidx.compose.foundation.layout.asPaddingValues
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.offset
|
||||
import androidx.compose.foundation.layout.only
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.safeDrawing
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.statusBarsPadding
|
||||
import androidx.compose.foundation.pager.VerticalPager
|
||||
import androidx.compose.foundation.pager.rememberPagerState
|
||||
@@ -25,8 +29,11 @@ import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.IconButtonDefaults
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.material3.TopAppBarScrollBehavior
|
||||
import androidx.compose.material3.minimumInteractiveComponentSize
|
||||
import androidx.compose.material3.rememberTopAppBarState
|
||||
import androidx.compose.material3.ripple
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
@@ -37,10 +44,20 @@ import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.snapshotFlow
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.drawWithContent
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.graphics.BlendMode
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.CompositingStrategy
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.platform.LocalLayoutDirection
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.semantics.heading
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.unit.Density
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.LayoutDirection
|
||||
import androidx.compose.ui.unit.dp
|
||||
import io.element.android.appconfig.RoomListConfig
|
||||
import io.element.android.compound.theme.ElementTheme
|
||||
@@ -71,10 +88,13 @@ import io.element.android.libraries.designsystem.theme.components.IconButton
|
||||
import io.element.android.libraries.designsystem.theme.components.Text
|
||||
import io.element.android.libraries.designsystem.theme.components.TopAppBar
|
||||
import io.element.android.libraries.matrix.api.core.SessionId
|
||||
import io.element.android.libraries.matrix.api.user.DisplayedStatus
|
||||
import io.element.android.libraries.matrix.api.user.MatrixUser
|
||||
import io.element.android.libraries.matrix.api.user.UserStatus
|
||||
import io.element.android.libraries.matrix.ui.components.aMatrixUser
|
||||
import io.element.android.libraries.matrix.ui.components.aMatrixUserList
|
||||
import io.element.android.libraries.matrix.ui.model.getAvatarData
|
||||
import io.element.android.libraries.matrix.ui.model.toEmojiText
|
||||
import io.element.android.libraries.testtags.TestTags
|
||||
import io.element.android.libraries.testtags.testTag
|
||||
import io.element.android.libraries.ui.strings.CommonStrings
|
||||
@@ -151,7 +171,7 @@ fun HomeTopBar(
|
||||
},
|
||||
// We want a 16dp left padding for the navigationIcon :
|
||||
// 4dp from default TopAppBarHorizontalPadding
|
||||
// 8dp from AccountIcon default padding (because of IconButton)
|
||||
// 8dp from AccountIcon default padding (48dp touch target around 32dp avatar)
|
||||
// 4dp extra padding using left insets
|
||||
windowInsets = WindowInsets(left = 4.dp),
|
||||
)
|
||||
@@ -316,19 +336,36 @@ private fun AccountIcon(
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val testTag = if (isCurrentAccount) Modifier.testTag(TestTags.homeScreenSettings) else Modifier
|
||||
IconButton(
|
||||
modifier = modifier.then(testTag),
|
||||
onClick = onClick,
|
||||
val interactionSource = remember { MutableInteractionSource() }
|
||||
Box(
|
||||
modifier = modifier
|
||||
.then(testTag)
|
||||
.minimumInteractiveComponentSize()
|
||||
.clickable(
|
||||
interactionSource = interactionSource,
|
||||
onClick = onClick,
|
||||
indication = ripple(bounded = false),
|
||||
),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Box {
|
||||
val avatarData by remember(matrixUser) {
|
||||
derivedStateOf {
|
||||
matrixUser.getAvatarData(size = AvatarSize.CurrentUserTopBar)
|
||||
}
|
||||
}
|
||||
val statusEmoji = matrixUser.displayedStatus?.toEmojiText()
|
||||
val avatarModifier = if (statusEmoji != null) {
|
||||
Modifier.eraseStatusEmojiBackground(
|
||||
parentSize = AvatarSize.CurrentUserTopBar.dp,
|
||||
layoutDirection = LocalLayoutDirection.current,
|
||||
)
|
||||
} else {
|
||||
Modifier
|
||||
}
|
||||
Avatar(
|
||||
avatarData = avatarData,
|
||||
avatarType = AvatarType.User,
|
||||
modifier = avatarModifier,
|
||||
contentDescription = if (isCurrentAccount) {
|
||||
if (showAvatarIndicator) {
|
||||
stringResource(CommonStrings.a11y_settings_with_required_action)
|
||||
@@ -339,11 +376,69 @@ private fun AccountIcon(
|
||||
null
|
||||
},
|
||||
)
|
||||
if (statusEmoji != null) {
|
||||
StatusEmojiBadge(
|
||||
emoji = statusEmoji,
|
||||
modifier = Modifier.align(Alignment.BottomEnd),
|
||||
)
|
||||
}
|
||||
if (showAvatarIndicator) {
|
||||
RedIndicatorAtom(
|
||||
modifier = Modifier.align(Alignment.TopEnd)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private val statusEmojiBadgeSize = 20.dp
|
||||
private val statusEmojiBadgeRadius = statusEmojiBadgeSize / 2
|
||||
private val statusEmojiBadgeOffset = 8.dp
|
||||
|
||||
private fun Modifier.eraseStatusEmojiBackground(
|
||||
parentSize: Dp,
|
||||
layoutDirection: LayoutDirection,
|
||||
): Modifier = this
|
||||
.graphicsLayer {
|
||||
compositingStrategy = CompositingStrategy.Offscreen
|
||||
}
|
||||
.drawWithContent {
|
||||
drawContent()
|
||||
drawCircle(
|
||||
color = Color.Black,
|
||||
center = Offset(
|
||||
x = if (layoutDirection == LayoutDirection.Ltr) {
|
||||
(parentSize - statusEmojiBadgeRadius + statusEmojiBadgeOffset).toPx()
|
||||
} else {
|
||||
(statusEmojiBadgeRadius - statusEmojiBadgeOffset).toPx()
|
||||
},
|
||||
y = size.height - statusEmojiBadgeRadius.toPx(),
|
||||
),
|
||||
radius = statusEmojiBadgeRadius.toPx(),
|
||||
blendMode = BlendMode.Clear,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun StatusEmojiBadge(
|
||||
emoji: String,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val density = LocalDensity.current
|
||||
// Ensure the emoji size is not affected by the user's font scale settings
|
||||
val fixedFontScaleDensity = remember(density.density) {
|
||||
Density(density = density.density, fontScale = 1f)
|
||||
}
|
||||
Box(
|
||||
modifier = modifier
|
||||
.offset(x = statusEmojiBadgeOffset)
|
||||
.size(statusEmojiBadgeSize),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
CompositionLocalProvider(LocalDensity provides fixedFontScaleDensity) {
|
||||
Text(
|
||||
text = emoji,
|
||||
style = ElementTheme.typography.fontBodyMdRegular,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -432,6 +527,33 @@ internal fun HomeTopBarWithIndicatorPreview() = ElementPreview {
|
||||
)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@PreviewsDayNight
|
||||
@Composable
|
||||
internal fun HomeTopBarWithStatusPreview() = ElementPreview {
|
||||
HomeTopBar(
|
||||
selectedNavigationItem = HomeNavigationBarItem.Chats,
|
||||
currentUserAndNeighbors = persistentListOf(
|
||||
aMatrixUser(
|
||||
id = "@id:domain",
|
||||
displayName = USER_NAME_ALICE,
|
||||
displayedStatus = DisplayedStatus.UserSet(UserStatus(emoji = "🌴", text = "Away")),
|
||||
)
|
||||
),
|
||||
showAvatarIndicator = false,
|
||||
areSearchResultsDisplayed = false,
|
||||
scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState()),
|
||||
onOpenSettings = {},
|
||||
onAccountSwitch = {},
|
||||
onToggleSearch = {},
|
||||
canReportBug = true,
|
||||
displayFilters = true,
|
||||
filtersState = aRoomListFiltersState(),
|
||||
spaceFiltersState = anUnselectedSpaceFiltersState(),
|
||||
onMenuActionClick = {},
|
||||
)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@PreviewsDayNight
|
||||
@Composable
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
<string name="banner_battery_optimization_title_android">"Powiadomienia nie dochodzą?"</string>
|
||||
<string name="banner_new_sound_message">"Sygnał powiadomień został zaktualizowany — jest wyraźniejszy, szybszy i mniej uciążliwy."</string>
|
||||
<string name="banner_new_sound_title">"Odświeżyliśmy Twoje dźwięki"</string>
|
||||
<string name="banner_set_up_recovery_content">"Twoje czaty są automatycznie archiwizowane za pomocą szyfrowania end-to-end. Aby przywrócić tę kopię zapasową i swoją tożsamość cyfrową, wymagany będzie klucz przywracania."</string>
|
||||
<string name="banner_set_up_recovery_content">"Twoje czaty są automatycznie archiwizowane za pomocą szyfrowania end-to-end. Aby przywrócić kopię zapasową i swoją tożsamość cyfrową, wymagany będzie klucz przywracania."</string>
|
||||
<string name="banner_set_up_recovery_submit">"Uzyskaj klucz przywracania"</string>
|
||||
<string name="banner_set_up_recovery_title">"Utwórz kopię zapasową swoich czatów"</string>
|
||||
<string name="confirm_recovery_key_banner_message">"Potwierdź klucz przywracania, aby zachować dostęp do magazynu kluczy i historii wiadomości."</string>
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
<string name="banner_battery_optimization_title_android">"Nu primiți notificări?"</string>
|
||||
<string name="banner_new_sound_message">"Sunetul pentru notificări a fost actualizat — mai clar, mai rapid și mai puțin perturbatoar."</string>
|
||||
<string name="banner_new_sound_title">"Am reîmprospătat sunetele"</string>
|
||||
<string name="banner_set_up_recovery_content">"Chaturile dumneavoastră sunt salvate automat cu criptare end-to-end. Pentru a restaura această copie de rezervă și a vă păstra identitatea digitală atunci când pierzdeți accesul la toate dispozitivele dumneavoastră, veți avea nevoie de cheia de recuperare."</string>
|
||||
<string name="banner_set_up_recovery_content">"Chaturile dumneavoastră sunt salvate automat cu criptare end-to-end. Pentru a restaura aceast backup și a vă păstra identitatea digitală atunci când pierzdeți accesul la toate dispozitivele dumneavoastră, veți avea nevoie de cheia de recuperare."</string>
|
||||
<string name="banner_set_up_recovery_submit">"Obțineți cheia de recuperare"</string>
|
||||
<string name="banner_set_up_recovery_title">"Faceți un backup al mesajelor"</string>
|
||||
<string name="confirm_recovery_key_banner_message">"Backup-ul pentru chat nu este sincronizat. Trebuie să confirmați cheia de recuperare pentru a menține accesul la backup."</string>
|
||||
|
||||
@@ -2,4 +2,8 @@
|
||||
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
|
||||
<string name="screen_invite_users_already_a_member">"Bereits Mitglied"</string>
|
||||
<string name="screen_invite_users_already_invited">"Bereits eingeladen"</string>
|
||||
<string name="screen_invite_users_confirm_dialog_subtitle_multiple_users">"Du hast derzeit keine Chats mit diesen Kontakten. Bestätige, dass du sie in diesen Chat einladen möchtest, bevor du fortfährst."</string>
|
||||
<string name="screen_invite_users_confirm_dialog_subtitle_one_user">"Du hast derzeit keine Chats mit diesem Kontakt. Bestätige, dass du ihn in diesen Chat einladen möchtest, bevor du fortfährst."</string>
|
||||
<string name="screen_invite_users_confirm_dialog_title_mutiple_users">"Möchtest du neue Kontakte in diesen Chat einladen?"</string>
|
||||
<string name="screen_invite_users_confirm_dialog_title_one_user">"Willst du einen neuen Kontakt in diesen Chat einladen?"</string>
|
||||
</resources>
|
||||
|
||||
@@ -2,4 +2,8 @@
|
||||
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
|
||||
<string name="screen_invite_users_already_a_member">"已是成員"</string>
|
||||
<string name="screen_invite_users_already_invited">"已邀請"</string>
|
||||
<string name="screen_invite_users_confirm_dialog_subtitle_multiple_users">"您目前與這些聯絡人沒有任何聊天紀錄。請確認是否要邀請他們加入此聊天室,然後再繼續。"</string>
|
||||
<string name="screen_invite_users_confirm_dialog_subtitle_one_user">"您目前與此聯絡人沒有任何聊天紀錄。請確認是否要邀請對方加入此聊天室,然後再繼續。"</string>
|
||||
<string name="screen_invite_users_confirm_dialog_title_mutiple_users">"要邀請新聯絡人加入此聊天室嗎?"</string>
|
||||
<string name="screen_invite_users_confirm_dialog_title_one_user">"要邀請新聯絡人加入此聊天室嗎?"</string>
|
||||
</resources>
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
<string name="screen_link_new_device_desktop_step3">"Skaneeri QR-koodi selle seadmega"</string>
|
||||
<string name="screen_link_new_device_desktop_submit">"Skaneerimiseks valmis"</string>
|
||||
<string name="screen_link_new_device_desktop_title">"QR-koodi laadimiseks ava %1$s süle- või lauaarvutis"</string>
|
||||
<string name="screen_link_new_device_desktop_warning">"Skaneeri QR-koodi peale järgnevate sammude läbitegemist oma seadmes. Ära iialgi skaneeri QR-koodi, mille on saatnud keegi teine."</string>
|
||||
<string name="screen_link_new_device_enter_number_error_numbers_do_not_match">"Numbrid ei klapi"</string>
|
||||
<string name="screen_link_new_device_enter_number_notice">"Sisesta kahekohaline kood"</string>
|
||||
<string name="screen_link_new_device_enter_number_subtitle">"Sellega verifitseerime, et ühendus sinu teise seadmega on turvaline."</string>
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
<string name="screen_link_new_device_desktop_step3">"Skenirajte QR kod ovim uređajem"</string>
|
||||
<string name="screen_link_new_device_desktop_submit">"Spremno za skeniranje"</string>
|
||||
<string name="screen_link_new_device_desktop_title">"Otvorite %1$s na stolnom računalu kako biste dobili QR kod"</string>
|
||||
<string name="screen_link_new_device_desktop_warning">"QR kod skenirajte tek nakon što ste na vlastitom uređaju slijedili gore navedene korake. Nikada ne skenirajte kod koji vam je poslala druga osoba."</string>
|
||||
<string name="screen_link_new_device_enter_number_error_numbers_do_not_match">"Brojevi se ne podudaraju"</string>
|
||||
<string name="screen_link_new_device_enter_number_notice">"Unesite dvoznamenkasti kod"</string>
|
||||
<string name="screen_link_new_device_enter_number_subtitle">"Time ćete potvrditi da je veza s vašim drugim uređajem sigurna."</string>
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
<string name="screen_link_new_device_desktop_step3">"Olvassa be a QR-kódot ezzel az eszközzel"</string>
|
||||
<string name="screen_link_new_device_desktop_submit">"Készen áll a beolvasásra"</string>
|
||||
<string name="screen_link_new_device_desktop_title">"Nyissa meg az asztali %1$s alkalmazást, hogy megkapja a QR-kódot"</string>
|
||||
<string name="screen_link_new_device_desktop_warning">"Csak a fenti lépések végrehajtása után olvass be QR-kódot a saját eszközén. Soha ne olvasson be más által küldött kódot."</string>
|
||||
<string name="screen_link_new_device_enter_number_error_numbers_do_not_match">"A számok nem egyeznek"</string>
|
||||
<string name="screen_link_new_device_enter_number_notice">"Írja be a kétjegyű kódot"</string>
|
||||
<string name="screen_link_new_device_enter_number_subtitle">"Ezzel ellenőrizni fogja, hogy a másik eszközzel való kapcsolat biztonságos-e."</string>
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
<string name="screen_link_new_device_desktop_step3">"Scanați codul QR cu acest dispozitiv"</string>
|
||||
<string name="screen_link_new_device_desktop_submit">"Gata de scanare"</string>
|
||||
<string name="screen_link_new_device_desktop_title">"Deschide %1$s pe un computer desktop pentru a obține codul QR"</string>
|
||||
<string name="screen_link_new_device_desktop_warning">"Scanați un cod QR doar după ce urmați pașii de mai sus pe propriul dispozitiv. Nu scanați niciodată un cod trimis de o altă persoană."</string>
|
||||
<string name="screen_link_new_device_enter_number_error_numbers_do_not_match">"Numerele nu se potrivesc"</string>
|
||||
<string name="screen_link_new_device_enter_number_notice">"Introduceți codul de 2 cifre"</string>
|
||||
<string name="screen_link_new_device_enter_number_subtitle">"Aceasta va verifica dacă conexiunea cu celălalt dispozitiv este sigură."</string>
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
<string name="screen_link_new_device_desktop_step3">"使用此裝置掃描 QR code"</string>
|
||||
<string name="screen_link_new_device_desktop_submit">"準備掃描"</string>
|
||||
<string name="screen_link_new_device_desktop_title">"在桌上型電腦上開啟 %1$s 以取得 QR code"</string>
|
||||
<string name="screen_link_new_device_desktop_warning">"請務必先在您的裝置上完成上述步驟,之後才掃描 QR code。切勿掃描他人傳送的 QR code。"</string>
|
||||
<string name="screen_link_new_device_enter_number_error_numbers_do_not_match">"數字不符"</string>
|
||||
<string name="screen_link_new_device_enter_number_notice">"輸入兩位數代碼"</string>
|
||||
<string name="screen_link_new_device_enter_number_subtitle">"這將確認您與另一台裝置之間的連線是否安全。"</string>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
|
||||
<string name="screen_share_location_live_location_disclaimer_title">"Der Verlauf deines Live-Standorts wird im Chat gespeichert und bleibt für die Mitglieder sichtbar, auch nachdem du die Freigabe beendet hast."</string>
|
||||
<string name="screen_share_location_live_location_duration_picker_title">"Wie lange soll der Live-Standort geteilt werden?"</string>
|
||||
</resources>
|
||||
|
||||
@@ -2,4 +2,5 @@
|
||||
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
|
||||
<string name="screen_share_location_live_location_disclaimer_title">"您的即時位置歷史將儲存於聊天室中,並在工作階段結束後對其他成員可見。"</string>
|
||||
<string name="screen_share_location_live_location_duration_picker_title">"選擇分享即時位置的時間長度。"</string>
|
||||
<string name="screen_share_location_live_location_missing_permissions">"您無權在此聊天室分享您的即時位置"</string>
|
||||
</resources>
|
||||
|
||||
+19
-10
@@ -13,13 +13,15 @@ import dev.zacsweers.metro.ContributesBinding
|
||||
import io.element.android.features.enterprise.api.EnterpriseService
|
||||
import io.element.android.features.login.api.accesscontrol.AccountProviderAccessControl
|
||||
import io.element.android.features.login.impl.changeserver.AccountProviderAccessException
|
||||
import io.element.android.libraries.core.uri.ensureProtocol
|
||||
import io.element.android.libraries.matrix.api.TemporaryMatrixClientFactory
|
||||
import io.element.android.libraries.wellknown.api.EnterpriseRemoteConfigSource
|
||||
import io.element.android.libraries.wellknown.api.WellknownRetriever
|
||||
|
||||
@ContributesBinding(AppScope::class)
|
||||
class DefaultAccountProviderAccessControl(
|
||||
private val enterpriseService: EnterpriseService,
|
||||
private val wellknownRetriever: WellknownRetriever,
|
||||
private val wellknownRetrieverFactory: WellknownRetriever.Factory,
|
||||
private val temporaryMatrixClientFactory: TemporaryMatrixClientFactory,
|
||||
) : AccountProviderAccessControl {
|
||||
override suspend fun isAllowedToConnectToAccountProvider(accountProviderUrl: String) = try {
|
||||
assertIsAllowedToConnectToAccountProvider(
|
||||
@@ -38,14 +40,21 @@ class DefaultAccountProviderAccessControl(
|
||||
) {
|
||||
if (enterpriseService.isEnterpriseBuild.not()) {
|
||||
// Ensure that Element Pro is not required for this account provider
|
||||
val wellKnown = wellknownRetriever.getElementWellKnown(
|
||||
baseUrl = accountProviderUrl.ensureProtocol(),
|
||||
).dataOrNull()
|
||||
if (wellKnown?.enforceElementPro == true) {
|
||||
throw AccountProviderAccessException.NeedElementProException(
|
||||
unauthorisedAccountProviderTitle = title,
|
||||
applicationId = ELEMENT_PRO_APPLICATION_ID,
|
||||
)
|
||||
val temporaryMatrixClient = temporaryMatrixClientFactory.create(accountProviderUrl).getOrThrow()
|
||||
temporaryMatrixClient.use {
|
||||
val wellknownRetriever = wellknownRetrieverFactory.create(temporaryMatrixClient)
|
||||
val wellKnown = wellknownRetriever.getElementWellKnown(
|
||||
host = accountProviderUrl,
|
||||
// It's expected this is hardcoded to the well-known endpoint
|
||||
source = EnterpriseRemoteConfigSource.WELLKNOWN_ENDPOINT,
|
||||
).dataOrNull()
|
||||
|
||||
if (wellKnown?.enforceElementPro == true) {
|
||||
throw AccountProviderAccessException.NeedElementProException(
|
||||
unauthorisedAccountProviderTitle = title,
|
||||
applicationId = ELEMENT_PRO_APPLICATION_ID,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (enterpriseService.isAllowedToConnectToHomeserver(accountProviderUrl).not()) {
|
||||
|
||||
+9
-2
@@ -14,6 +14,8 @@ import io.element.android.libraries.core.coroutine.parallelMap
|
||||
import io.element.android.libraries.core.uri.ensureProtocol
|
||||
import io.element.android.libraries.core.uri.isValidUrl
|
||||
import io.element.android.libraries.matrix.api.auth.HomeServerLoginCompatibilityChecker
|
||||
import io.element.android.libraries.matrix.api.core.MatrixPatterns
|
||||
import io.element.android.libraries.matrix.api.core.UserId
|
||||
import io.element.android.libraries.permissions.api.localnetwork.LocalNetworkPermissionAdvisor
|
||||
import kotlinx.coroutines.currentCoroutineContext
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
@@ -34,8 +36,13 @@ class HomeserverResolver(
|
||||
fun resolve(userInput: String): Flow<List<HomeserverData>> = flow {
|
||||
val flowContext = currentCoroutineContext()
|
||||
val trimmedUserInput = userInput.trim()
|
||||
if (trimmedUserInput.length < 4) return@flow
|
||||
val candidateBase = trimmedUserInput.ensureProtocol().removeSuffix("/")
|
||||
val searchTerm = when {
|
||||
MatrixPatterns.isUserId(trimmedUserInput) -> UserId(trimmedUserInput).domainName.orEmpty()
|
||||
trimmedUserInput.startsWith("@") -> return@flow
|
||||
else -> trimmedUserInput
|
||||
}
|
||||
if (searchTerm.length < 4) return@flow
|
||||
val candidateBase = searchTerm.ensureProtocol().removeSuffix("/")
|
||||
val list = getUrlCandidates(candidateBase)
|
||||
val currentList = Collections.synchronizedList(mutableListOf<HomeserverData>())
|
||||
// Run all the requests in parallel
|
||||
|
||||
+22
-12
@@ -13,6 +13,8 @@ import dev.zacsweers.metro.AppScope
|
||||
import dev.zacsweers.metro.ContributesBinding
|
||||
import io.element.android.appconfig.AuthenticationConfig
|
||||
import io.element.android.features.login.impl.screens.createaccount.AccountCreationNotSupported
|
||||
import io.element.android.libraries.matrix.api.TemporaryMatrixClientFactory
|
||||
import io.element.android.libraries.wellknown.api.EnterpriseRemoteConfigSource
|
||||
import io.element.android.libraries.wellknown.api.WellknownRetriever
|
||||
import timber.log.Timber
|
||||
|
||||
@@ -22,24 +24,32 @@ interface WebClientUrlForAuthenticationRetriever {
|
||||
|
||||
@ContributesBinding(AppScope::class)
|
||||
class DefaultWebClientUrlForAuthenticationRetriever(
|
||||
private val wellknownRetriever: WellknownRetriever,
|
||||
private val wellknownRetrieverFactory: WellknownRetriever.Factory,
|
||||
private val temporaryMatrixClientFactory: TemporaryMatrixClientFactory,
|
||||
) : WebClientUrlForAuthenticationRetriever {
|
||||
override suspend fun retrieve(homeServerUrl: String): String {
|
||||
if (homeServerUrl != AuthenticationConfig.MATRIX_ORG_URL) {
|
||||
Timber.w("Temporary account creation flow is only supported on matrix.org")
|
||||
throw AccountCreationNotSupported()
|
||||
}
|
||||
val wellknown = wellknownRetriever.getElementWellKnown(homeServerUrl).dataOrNull()
|
||||
?: throw AccountCreationNotSupported()
|
||||
val registrationHelperUrl = wellknown.registrationHelperUrl
|
||||
return if (registrationHelperUrl != null) {
|
||||
registrationHelperUrl.toUri()
|
||||
.buildUpon()
|
||||
.appendQueryParameter("hs_url", homeServerUrl)
|
||||
.build()
|
||||
.toString()
|
||||
} else {
|
||||
throw AccountCreationNotSupported()
|
||||
val temporaryMatrixClient = temporaryMatrixClientFactory.create(homeServerUrl).getOrThrow()
|
||||
return temporaryMatrixClient.use {
|
||||
val wellknownRetriever = wellknownRetrieverFactory.create(temporaryMatrixClient)
|
||||
val wellknown = wellknownRetriever.getElementWellKnown(
|
||||
host = homeServerUrl,
|
||||
source = EnterpriseRemoteConfigSource.WELLKNOWN_ENDPOINT
|
||||
).dataOrNull() ?: throw AccountCreationNotSupported()
|
||||
|
||||
val registrationHelperUrl = wellknown.registrationHelperUrl
|
||||
if (registrationHelperUrl != null) {
|
||||
registrationHelperUrl.toUri()
|
||||
.buildUpon()
|
||||
.appendQueryParameter("hs_url", homeServerUrl)
|
||||
.build()
|
||||
.toString()
|
||||
} else {
|
||||
throw AccountCreationNotSupported()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,7 +37,14 @@
|
||||
<string name="screen_login_subtitle">"Matrix ist ein offenes Netzwerk für eine sichere, dezentrale Kommunikation."</string>
|
||||
<string name="screen_login_title">"Willkommen zurück!"</string>
|
||||
<string name="screen_login_title_with_homeserver">"Anmelden bei %1$s"</string>
|
||||
<string name="screen_missing_key_backup_open_element_classic">"Element Classic öffnen"</string>
|
||||
<string name="screen_missing_key_backup_step_1">"Öffne Element Classic auf deinem Gerät"</string>
|
||||
<string name="screen_missing_key_backup_step_2_android">"Geh zu Einstellungen > Sicherheit & Datenschutz"</string>
|
||||
<string name="screen_missing_key_backup_step_4">"Folge den Anweisungen, um den Schlüsselspeicher zu aktivieren."</string>
|
||||
<string name="screen_missing_key_backup_step_5">"Zurück zu %1$s"</string>
|
||||
<string name="screen_missing_key_backup_title">"Aktiviere deinen Schlüsselspeicher, bevor du mit %1$s fortfährst."</string>
|
||||
<string name="screen_onboarding_app_version">"Version %1$s"</string>
|
||||
<string name="screen_onboarding_checking_account">"Konto wird geprüft"</string>
|
||||
<string name="screen_onboarding_sign_in_manually">"Manuell anmelden"</string>
|
||||
<string name="screen_onboarding_sign_in_to">"Anmelden bei %1$s"</string>
|
||||
<string name="screen_onboarding_sign_in_with_qr_code">"Mit QR-Code anmelden"</string>
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
<string name="screen_change_server_subtitle">"Vad är adressen till din server?"</string>
|
||||
<string name="screen_change_server_title">"Välj din server"</string>
|
||||
<string name="screen_create_account_title">"Skapa konto"</string>
|
||||
<string name="screen_login_error_deactivated_account">"Detta konto har avaktiverats."</string>
|
||||
<string name="screen_login_error_deactivated_account">"Detta konto har raderats."</string>
|
||||
<string name="screen_login_error_invalid_credentials">"Felaktigt användarnamn och/eller lösenord"</string>
|
||||
<string name="screen_login_error_invalid_user_id">"Detta är inte en giltig användaridentifierare. Förväntat format: \'@användare:hemserver.org\'"</string>
|
||||
<string name="screen_login_error_refresh_tokens">"Den här servern är konfigurerad för att använda uppdateringstokens. Dessa stöds inte när du använder lösenordsbaserad inloggning."</string>
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
<string name="screen_account_provider_signup_title">"您即將在 %s 建立帳號"</string>
|
||||
<string name="screen_change_account_provider_matrix_org_subtitle">"Matrix.org 由 Matrix.org 基金會營運,是用於安全、去中心化通訊的公共 Matrix 網路上的大型免費伺服器。"</string>
|
||||
<string name="screen_change_account_provider_other">"其他"</string>
|
||||
<string name="screen_change_account_provider_subtitle">"使用不同的帳戶提供者,例如您自己的伺服器或工作帳號。"</string>
|
||||
<string name="screen_change_account_provider_subtitle">"使用不同的帳號提供者,例如您自己的伺服器或工作帳號。"</string>
|
||||
<string name="screen_change_account_provider_title">"更改帳號提供者"</string>
|
||||
<string name="screen_change_server_error_element_pro_required_action_android">"Google Play"</string>
|
||||
<string name="screen_change_server_error_element_pro_required_message">"%1$s 需要 Element Pro 應用程式。請從商店下載。"</string>
|
||||
@@ -28,7 +28,7 @@
|
||||
<string name="screen_change_server_subtitle">"您的伺服器地址?"</string>
|
||||
<string name="screen_change_server_title">"選擇您的伺服器"</string>
|
||||
<string name="screen_create_account_title">"建立帳號"</string>
|
||||
<string name="screen_login_error_deactivated_account">"這個帳號已被停用。"</string>
|
||||
<string name="screen_login_error_deactivated_account">"此帳號已被刪除。"</string>
|
||||
<string name="screen_login_error_invalid_credentials">"不正確的使用者名稱或密碼"</string>
|
||||
<string name="screen_login_error_invalid_user_id">"此非有效的使用者識別字串。預期的格式:‘@user:homeserver.org’"</string>
|
||||
<string name="screen_login_error_refresh_tokens">"此伺服器已設定為使用重新整理權杖。使用以密碼為基礎的登入方式時,不支援這些功能。"</string>
|
||||
|
||||
+13
-8
@@ -12,10 +12,12 @@ import com.google.common.truth.Truth.assertThat
|
||||
import io.element.android.features.enterprise.test.FakeEnterpriseService
|
||||
import io.element.android.features.login.impl.changeserver.AccountProviderAccessException
|
||||
import io.element.android.features.wellknown.test.FakeWellknownRetriever
|
||||
import io.element.android.features.wellknown.test.FakeWellknownRetrieverFactory
|
||||
import io.element.android.features.wellknown.test.anElementWellKnown
|
||||
import io.element.android.libraries.matrix.test.AN_ACCOUNT_PROVIDER
|
||||
import io.element.android.libraries.matrix.test.AN_ACCOUNT_PROVIDER_2
|
||||
import io.element.android.libraries.matrix.test.AN_ACCOUNT_PROVIDER_URL
|
||||
import io.element.android.libraries.matrix.test.FakeTemporaryMatrixClientFactory
|
||||
import io.element.android.libraries.wellknown.api.ElementWellKnown
|
||||
import io.element.android.libraries.wellknown.api.WellknownRetrieverResult
|
||||
import kotlinx.coroutines.test.runTest
|
||||
@@ -156,15 +158,18 @@ class DefaultAccountProviderAccessControlTest {
|
||||
isAllowedToConnectToHomeserverResult = { isAllowedToConnectToHomeserver },
|
||||
defaultHomeserverListResult = { allowedAccountProviders },
|
||||
),
|
||||
wellknownRetriever = FakeWellknownRetriever(
|
||||
getElementWellKnownResult = {
|
||||
if (elementWellKnown == null) {
|
||||
WellknownRetrieverResult.NotFound
|
||||
} else {
|
||||
WellknownRetrieverResult.Success(elementWellKnown)
|
||||
}
|
||||
},
|
||||
wellknownRetrieverFactory = FakeWellknownRetrieverFactory(
|
||||
wellknownRetriever = FakeWellknownRetriever(
|
||||
getElementWellKnownResult = { _, _ ->
|
||||
if (elementWellKnown == null) {
|
||||
WellknownRetrieverResult.NotFound
|
||||
} else {
|
||||
WellknownRetrieverResult.Success(elementWellKnown)
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
temporaryMatrixClientFactory = FakeTemporaryMatrixClientFactory(),
|
||||
)
|
||||
|
||||
private fun DefaultAccountProviderAccessControl.expectNeedElementProException() {
|
||||
|
||||
+8
-9
@@ -17,18 +17,19 @@ import io.element.android.features.login.impl.accountprovider.AccountProviderDat
|
||||
import io.element.android.features.login.impl.error.ChangeServerError
|
||||
import io.element.android.features.login.impl.localnetwork.LocalNetworkPermissionGate
|
||||
import io.element.android.features.wellknown.test.FakeWellknownRetriever
|
||||
import io.element.android.features.wellknown.test.FakeWellknownRetrieverFactory
|
||||
import io.element.android.features.wellknown.test.anElementWellKnown
|
||||
import io.element.android.libraries.architecture.AsyncData
|
||||
import io.element.android.libraries.core.uri.ensureProtocol
|
||||
import io.element.android.libraries.matrix.test.AN_EXCEPTION
|
||||
import io.element.android.libraries.matrix.test.A_HOMESERVER_URL
|
||||
import io.element.android.libraries.matrix.test.FakeTemporaryMatrixClientFactory
|
||||
import io.element.android.libraries.matrix.test.auth.FakeMatrixAuthenticationService
|
||||
import io.element.android.libraries.matrix.test.auth.aMatrixHomeServerDetails
|
||||
import io.element.android.libraries.permissions.api.localnetwork.LocalNetworkPermissionDialog
|
||||
import io.element.android.libraries.permissions.test.FakeLocalNetworkPermissionAdvisor
|
||||
import io.element.android.libraries.permissions.test.FakePermissionsPresenter
|
||||
import io.element.android.libraries.permissions.test.FakePermissionsPresenterFactory
|
||||
import io.element.android.libraries.wellknown.api.ElementWellKnown
|
||||
import io.element.android.libraries.wellknown.api.EnterpriseRemoteConfigSource
|
||||
import io.element.android.libraries.wellknown.api.WellknownRetriever
|
||||
import io.element.android.libraries.wellknown.api.WellknownRetrieverResult
|
||||
import io.element.android.tests.testutils.WarmUpRule
|
||||
@@ -158,7 +159,8 @@ class ChangeServerPresenterTest {
|
||||
|
||||
@Test
|
||||
fun `present - change server element pro required error`() = runTest {
|
||||
val getElementWellKnownResult = lambdaRecorder<String, WellknownRetrieverResult<ElementWellKnown>> {
|
||||
val getElementWellKnownResult = lambdaRecorder { homeserver: String, _: EnterpriseRemoteConfigSource ->
|
||||
assertThat(homeserver).isEqualTo(A_HOMESERVER_URL)
|
||||
WellknownRetrieverResult.Success(
|
||||
anElementWellKnown(
|
||||
enforceElementPro = true,
|
||||
@@ -174,8 +176,6 @@ class ChangeServerPresenterTest {
|
||||
assertThat(initialState.changeServerAction).isEqualTo(AsyncData.Uninitialized)
|
||||
val anAccountProvider = AccountProvider(url = A_HOMESERVER_URL)
|
||||
initialState.eventSink.invoke(ChangeServerEvents.ChangeServer(anAccountProvider))
|
||||
val loadingState = awaitItem()
|
||||
assertThat(loadingState.changeServerAction).isInstanceOf(AsyncData.Loading::class.java)
|
||||
val failureState = awaitItem()
|
||||
assertThat(
|
||||
(failureState.changeServerAction.errorOrNull() as ChangeServerError.NeedElementPro).unauthorisedAccountProviderTitle
|
||||
@@ -183,9 +183,7 @@ class ChangeServerPresenterTest {
|
||||
assertThat(
|
||||
(failureState.changeServerAction.errorOrNull() as ChangeServerError.NeedElementPro).applicationId
|
||||
).isEqualTo("io.element.enterprise")
|
||||
getElementWellKnownResult.assertions()
|
||||
.isCalledOnce()
|
||||
.with(value(A_HOMESERVER_URL.ensureProtocol()))
|
||||
getElementWellKnownResult.assertions().isCalledOnce()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -269,7 +267,8 @@ class ChangeServerPresenterTest {
|
||||
accountProviderDataSource = accountProviderDataSource,
|
||||
defaultAccountProviderAccessControl = DefaultAccountProviderAccessControl(
|
||||
enterpriseService = enterpriseService,
|
||||
wellknownRetriever = wellknownRetriever,
|
||||
wellknownRetrieverFactory = FakeWellknownRetrieverFactory(wellknownRetriever = wellknownRetriever),
|
||||
temporaryMatrixClientFactory = FakeTemporaryMatrixClientFactory(),
|
||||
),
|
||||
localNetworkPermissionGate = LocalNetworkPermissionGate(
|
||||
advisor = localNetworkPermissionAdvisor,
|
||||
|
||||
+108
@@ -0,0 +1,108 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Element Creations Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.features.login.impl.resolver
|
||||
|
||||
import app.cash.turbine.test
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import io.element.android.libraries.matrix.test.auth.FakeHomeServerLoginCompatibilityChecker
|
||||
import io.element.android.libraries.permissions.test.FakeLocalNetworkPermissionAdvisor
|
||||
import io.element.android.tests.testutils.lambda.lambdaRecorder
|
||||
import io.element.android.tests.testutils.lambda.value
|
||||
import io.element.android.tests.testutils.testCoroutineDispatchers
|
||||
import kotlinx.coroutines.test.TestScope
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Test
|
||||
|
||||
class HomeserverResolverTest {
|
||||
@Test
|
||||
fun `resolve - a server name is used as is`() = runTest {
|
||||
val checkResult = lambdaRecorder<String, Result<Boolean>> { Result.success(it == "https://example.org") }
|
||||
val sut = createHomeserverResolver(checkResult)
|
||||
sut.resolve("example.org").test {
|
||||
assertThat(awaitItem()).isEqualTo(listOf(HomeserverData(homeserverUrl = "https://example.org")))
|
||||
awaitComplete()
|
||||
}
|
||||
checkResult.assertions().isCalledExactly(1)
|
||||
.withSequence(
|
||||
listOf(value("https://example.org")),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `resolve - an url is used as is`() = runTest {
|
||||
val checkResult = lambdaRecorder<String, Result<Boolean>> { Result.success(it == "https://example.org") }
|
||||
val sut = createHomeserverResolver(checkResult)
|
||||
sut.resolve("https://example.org").test {
|
||||
assertThat(awaitItem()).isEqualTo(listOf(HomeserverData(homeserverUrl = "https://example.org")))
|
||||
awaitComplete()
|
||||
}
|
||||
checkResult.assertions().isCalledExactly(1)
|
||||
.withSequence(
|
||||
listOf(value("https://example.org")),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `resolve - a user id resolves the server name of the user id`() = runTest {
|
||||
val checkResult = lambdaRecorder<String, Result<Boolean>> { Result.success(it == "https://example.org") }
|
||||
val sut = createHomeserverResolver(checkResult)
|
||||
sut.resolve("@alice:example.org").test {
|
||||
assertThat(awaitItem()).isEqualTo(listOf(HomeserverData(homeserverUrl = "https://example.org")))
|
||||
awaitComplete()
|
||||
}
|
||||
checkResult.assertions().isCalledExactly(1)
|
||||
.withSequence(
|
||||
listOf(value("https://example.org")),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `resolve - a user id with a port resolves the server name of the user id`() = runTest {
|
||||
val checkResult = lambdaRecorder<String, Result<Boolean>> { Result.success(it == "https://example.org:8448") }
|
||||
val sut = createHomeserverResolver(checkResult)
|
||||
sut.resolve("@alice:example.org:8448").test {
|
||||
assertThat(awaitItem()).isEqualTo(listOf(HomeserverData(homeserverUrl = "https://example.org:8448")))
|
||||
awaitComplete()
|
||||
}
|
||||
checkResult.assertions().isCalledExactly(1)
|
||||
.withSequence(
|
||||
listOf(value("https://example.org:8448")),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `resolve - an incomplete user id does not resolve anything`() = runTest {
|
||||
val checkResult = lambdaRecorder<String, Result<Boolean>> { Result.success(true) }
|
||||
val sut = createHomeserverResolver(checkResult)
|
||||
sut.resolve("@alice").test {
|
||||
awaitComplete()
|
||||
}
|
||||
sut.resolve("@alice:").test {
|
||||
awaitComplete()
|
||||
}
|
||||
checkResult.assertions().isNeverCalled()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `resolve - a user id with a too short server name does not resolve anything`() = runTest {
|
||||
val checkResult = lambdaRecorder<String, Result<Boolean>> { Result.success(true) }
|
||||
val sut = createHomeserverResolver(checkResult)
|
||||
sut.resolve("@alice:a.b").test {
|
||||
awaitComplete()
|
||||
}
|
||||
checkResult.assertions().isNeverCalled()
|
||||
}
|
||||
|
||||
private fun TestScope.createHomeserverResolver(
|
||||
checkResult: (String) -> Result<Boolean>,
|
||||
) = HomeserverResolver(
|
||||
dispatchers = testCoroutineDispatchers(),
|
||||
homeServerLoginCompatibilityChecker = FakeHomeServerLoginCompatibilityChecker(checkResult = checkResult),
|
||||
localNetworkPermissionAdvisor = FakeLocalNetworkPermissionAdvisor(),
|
||||
)
|
||||
}
|
||||
+10
-4
@@ -20,6 +20,7 @@ import io.element.android.features.login.impl.login.LoginModePresenter
|
||||
import io.element.android.features.login.impl.web.FakeWebClientUrlForAuthenticationRetriever
|
||||
import io.element.android.features.login.impl.web.WebClientUrlForAuthenticationRetriever
|
||||
import io.element.android.features.wellknown.test.FakeWellknownRetriever
|
||||
import io.element.android.features.wellknown.test.FakeWellknownRetrieverFactory
|
||||
import io.element.android.libraries.architecture.AsyncData
|
||||
import io.element.android.libraries.core.meta.BuildMeta
|
||||
import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService
|
||||
@@ -30,11 +31,15 @@ import io.element.android.libraries.matrix.test.AN_EXCEPTION
|
||||
import io.element.android.libraries.matrix.test.A_HOMESERVER_URL
|
||||
import io.element.android.libraries.matrix.test.A_HOMESERVER_URL_2
|
||||
import io.element.android.libraries.matrix.test.A_LOGIN_HINT
|
||||
import io.element.android.libraries.matrix.test.FakeTemporaryMatrixClientFactory
|
||||
import io.element.android.libraries.matrix.test.auth.FakeMatrixAuthenticationService
|
||||
import io.element.android.libraries.matrix.test.core.aBuildMeta
|
||||
import io.element.android.libraries.oauth.api.OAuthActionFlow
|
||||
import io.element.android.libraries.oauth.test.customtab.FakeOAuthActionFlow
|
||||
import io.element.android.libraries.permissions.api.PermissionsPresenter
|
||||
import io.element.android.libraries.permissions.api.localnetwork.LocalNetworkPermissionAdvisor
|
||||
import io.element.android.libraries.permissions.test.FakeLocalNetworkPermissionAdvisor
|
||||
import io.element.android.libraries.permissions.test.FakePermissionsPresenterFactory
|
||||
import io.element.android.libraries.sessionstorage.api.SessionStore
|
||||
import io.element.android.libraries.sessionstorage.test.InMemorySessionStore
|
||||
import io.element.android.libraries.sessionstorage.test.aSessionData
|
||||
@@ -304,7 +309,8 @@ private fun createPresenter(
|
||||
enterpriseService = enterpriseService,
|
||||
defaultAccountProviderAccessControl = DefaultAccountProviderAccessControl(
|
||||
enterpriseService = enterpriseService,
|
||||
wellknownRetriever = wellknownRetriever,
|
||||
wellknownRetrieverFactory = FakeWellknownRetrieverFactory(wellknownRetriever = wellknownRetriever),
|
||||
temporaryMatrixClientFactory = FakeTemporaryMatrixClientFactory(),
|
||||
),
|
||||
rageshakeFeatureAvailability = rageshakeFeatureAvailability,
|
||||
loginModePresenter = loginModePresenter,
|
||||
@@ -318,9 +324,9 @@ fun createLoginModePresenter(
|
||||
authenticationService: MatrixAuthenticationService = FakeMatrixAuthenticationService(),
|
||||
webClientUrlForAuthenticationRetriever: WebClientUrlForAuthenticationRetriever = FakeWebClientUrlForAuthenticationRetriever(),
|
||||
localNetworkPermissionAdvisor: LocalNetworkPermissionAdvisor =
|
||||
io.element.android.libraries.permissions.test.FakeLocalNetworkPermissionAdvisor(),
|
||||
permissionsPresenterFactory: io.element.android.libraries.permissions.api.PermissionsPresenter.Factory =
|
||||
io.element.android.libraries.permissions.test.FakePermissionsPresenterFactory(),
|
||||
FakeLocalNetworkPermissionAdvisor(),
|
||||
permissionsPresenterFactory: PermissionsPresenter.Factory =
|
||||
FakePermissionsPresenterFactory(),
|
||||
): LoginModePresenter = LoginModePresenter(
|
||||
oAuthActionFlow = oAuthActionFlow,
|
||||
authenticationService = authenticationService,
|
||||
|
||||
+13
-1
@@ -28,6 +28,7 @@ import dev.zacsweers.metro.Assisted
|
||||
import dev.zacsweers.metro.AssistedInject
|
||||
import io.element.android.annotations.ContributesNode
|
||||
import io.element.android.compound.theme.ElementTheme
|
||||
import io.element.android.features.messages.impl.MessagesEvent
|
||||
import io.element.android.features.messages.impl.MessagesNavigator
|
||||
import io.element.android.features.messages.impl.MessagesPresenter
|
||||
import io.element.android.features.messages.impl.MessagesState
|
||||
@@ -40,6 +41,7 @@ import io.element.android.features.messages.impl.messagecomposer.MessageComposer
|
||||
import io.element.android.features.messages.impl.timeline.TimelineController
|
||||
import io.element.android.features.messages.impl.timeline.TimelineEvent
|
||||
import io.element.android.features.messages.impl.timeline.TimelinePresenter
|
||||
import io.element.android.features.messages.impl.timeline.components.customreaction.CustomReactionBottomSheet
|
||||
import io.element.android.features.messages.impl.timeline.di.LocalTimelineItemPresenterFactories
|
||||
import io.element.android.features.messages.impl.timeline.di.TimelineItemPresenterFactories
|
||||
import io.element.android.features.messages.impl.timeline.model.TimelineItem
|
||||
@@ -54,6 +56,7 @@ import io.element.android.libraries.architecture.callback
|
||||
import io.element.android.libraries.architecture.inputs
|
||||
import io.element.android.libraries.designsystem.utils.OnLifecycleEvent
|
||||
import io.element.android.libraries.di.RoomScope
|
||||
import io.element.android.libraries.emoji.api.picker.EmojiPickerRenderer
|
||||
import io.element.android.libraries.matrix.api.analytics.toAnalyticsViewRoom
|
||||
import io.element.android.libraries.matrix.api.core.EventId
|
||||
import io.element.android.libraries.matrix.api.core.RoomId
|
||||
@@ -91,6 +94,7 @@ class ThreadedMessagesNode(
|
||||
private val permalinkParser: PermalinkParser,
|
||||
private val appNavigationStateService: AppNavigationStateService,
|
||||
private val roomMemberModerationRenderer: RoomMemberModerationRenderer,
|
||||
private val emojiPickerRenderer: EmojiPickerRenderer,
|
||||
) : Node(buildContext, plugins = plugins), MessagesNavigator {
|
||||
data class Inputs(
|
||||
val threadRootEventId: ThreadId,
|
||||
@@ -322,7 +326,15 @@ class ThreadedMessagesNode(
|
||||
onViewAllPinnedMessagesClick = {},
|
||||
modifier = modifier,
|
||||
knockRequestsBannerView = {},
|
||||
customReactionBottomSheet = {},
|
||||
customReactionBottomSheet = {
|
||||
CustomReactionBottomSheet(
|
||||
state = state.customReactionState,
|
||||
onSelectEmoji = { uniqueId, emoji ->
|
||||
state.eventSink(MessagesEvent.ToggleReaction(emoji.unicode, uniqueId))
|
||||
},
|
||||
emojiPickerRenderer = emojiPickerRenderer,
|
||||
)
|
||||
},
|
||||
onThreadsListClick = {},
|
||||
)
|
||||
|
||||
|
||||
+7
-10
@@ -125,6 +125,7 @@ import io.element.android.libraries.matrix.api.timeline.item.event.getAvatarUrl
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.getDisambiguatedDisplayName
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.getDisplayName
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.mediaSources
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.toMatrixUser
|
||||
import io.element.android.libraries.matrix.api.user.MatrixUser
|
||||
import io.element.android.libraries.matrix.ui.media.contentvalidation.collectOverallState
|
||||
import io.element.android.libraries.matrix.ui.media.contentvalidation.rememberEventContentValidationState
|
||||
@@ -207,11 +208,7 @@ fun TimelineItemEventRow(
|
||||
}
|
||||
|
||||
fun onUserDataClick() {
|
||||
val sender = MatrixUser(
|
||||
userId = event.senderId,
|
||||
displayName = event.senderProfile.getDisplayName(),
|
||||
avatarUrl = event.senderProfile.getAvatarUrl(),
|
||||
)
|
||||
val sender = event.senderProfile.toMatrixUser(event.senderId)
|
||||
onUserDataClick(sender)
|
||||
}
|
||||
|
||||
@@ -804,15 +801,15 @@ private fun MessageEventBubbleContent(
|
||||
.padding(top = topPadding, start = 8.dp, end = 8.dp)
|
||||
.clip(shape)
|
||||
|
||||
val talkbackCompatModifier = if (isTalkbackActive()) {
|
||||
val talkbackCompatModifier = when {
|
||||
// Use z-index to make the replied to text being read after the message
|
||||
// Usually, you'd use traversalIndex for that, but it's not working for some reason
|
||||
inReplyToModifier.zIndex(1f)
|
||||
} else {
|
||||
inReplyToModifier.clickable(onClick = inReplyToClick)
|
||||
isTalkbackActive() -> inReplyToModifier.zIndex(1f)
|
||||
inReplyTo is InReplyToDetails.Error -> inReplyToModifier
|
||||
else -> inReplyToModifier.clickable(onClick = inReplyToClick)
|
||||
}
|
||||
|
||||
val contentHasError = currentContentValidationState.hasError()
|
||||
val contentHasError = currentContentValidationState.hasError() || inReplyTo is InReplyToDetails.Error
|
||||
val borderColor = if (contentHasError) ElementTheme.colors.borderCriticalSubtle else ElementTheme.colors.separatorPrimary
|
||||
val backgroundColor = if (contentHasError) ElementTheme.colors.bgCriticalSubtle else ElementTheme.colors.bgCanvasDefault
|
||||
Box(
|
||||
|
||||
+17
@@ -8,11 +8,16 @@
|
||||
|
||||
package io.element.android.features.messages.impl.timeline.components.customreaction
|
||||
|
||||
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.isImeVisible
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.SheetState
|
||||
import androidx.compose.material3.SheetValue
|
||||
import androidx.compose.material3.rememberBottomSheetState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.ui.Modifier
|
||||
import io.element.android.emojibasebindings.Emoji
|
||||
@@ -54,6 +59,7 @@ fun CustomReactionBottomSheet(
|
||||
modifier = modifier,
|
||||
scrollable = false,
|
||||
) {
|
||||
ExpandSheetWhenImeIsVisible(sheetState)
|
||||
emojiPickerRenderer.Render(
|
||||
state = state.target.emojiPickerState,
|
||||
onSelectEmoji = ::onEmojiSelectedDismiss,
|
||||
@@ -67,3 +73,14 @@ fun CustomReactionBottomSheet(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalLayoutApi::class, ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
internal fun ExpandSheetWhenImeIsVisible(sheetState: SheetState) {
|
||||
val isImeVisible = WindowInsets.isImeVisible
|
||||
LaunchedEffect(isImeVisible) {
|
||||
if (isImeVisible) {
|
||||
sheetState.expand()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+3
-6
@@ -12,7 +12,6 @@ import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateSetOf
|
||||
import androidx.compose.runtime.remember
|
||||
import dev.zacsweers.metro.Inject
|
||||
import dev.zacsweers.metro.SingleIn
|
||||
@@ -20,7 +19,6 @@ import io.element.android.features.contentscanner.api.ContentScannerService
|
||||
import io.element.android.libraries.architecture.Presenter
|
||||
import io.element.android.libraries.core.coroutine.mapState
|
||||
import io.element.android.libraries.di.RoomScope
|
||||
import io.element.android.libraries.matrix.api.core.EventId
|
||||
import io.element.android.libraries.matrix.api.media.MediaPreviewService
|
||||
import io.element.android.libraries.matrix.api.media.isPreviewEnabled
|
||||
import io.element.android.libraries.matrix.api.room.BaseRoom
|
||||
@@ -32,9 +30,8 @@ class TimelineProtectionPresenter(
|
||||
private val mediaPreviewService: MediaPreviewService,
|
||||
private val room: BaseRoom,
|
||||
private val contentScannerService: ContentScannerService,
|
||||
private val timelineProtectionStore: TimelineProtectionStore,
|
||||
) : Presenter<TimelineProtectionState> {
|
||||
private val allowedEvents = mutableStateSetOf<EventId>()
|
||||
|
||||
@Composable
|
||||
override fun present(): TimelineProtectionState {
|
||||
val mediaPreviewValue = remember {
|
||||
@@ -48,7 +45,7 @@ class TimelineProtectionPresenter(
|
||||
if (isPreviewEnabled) {
|
||||
ProtectionState.RenderAll
|
||||
} else {
|
||||
ProtectionState.RenderOnly(eventIds = allowedEvents.toImmutableSet())
|
||||
ProtectionState.RenderOnly(eventIds = timelineProtectionStore.allowedEventIds.toImmutableSet())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -56,7 +53,7 @@ class TimelineProtectionPresenter(
|
||||
fun handleEvent(event: TimelineProtectionEvent) {
|
||||
when (event) {
|
||||
is TimelineProtectionEvent.ShowContent -> {
|
||||
allowedEvents += setOfNotNull(event.eventId)
|
||||
event.eventId?.let(timelineProtectionStore::allowEvent)
|
||||
}
|
||||
is TimelineProtectionEvent.ValidateContent -> {
|
||||
contentScannerService.scan(event.mediaSources, event.validationState)
|
||||
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Element Creations Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.features.messages.impl.timeline.protection
|
||||
|
||||
import androidx.compose.runtime.mutableStateSetOf
|
||||
import androidx.compose.runtime.snapshots.SnapshotStateSet
|
||||
import dev.zacsweers.metro.ContributesBinding
|
||||
import dev.zacsweers.metro.SingleIn
|
||||
import io.element.android.libraries.di.SessionScope
|
||||
import io.element.android.libraries.matrix.api.core.EventId
|
||||
|
||||
interface TimelineProtectionStore {
|
||||
val allowedEventIds: SnapshotStateSet<EventId>
|
||||
fun allowEvent(eventId: EventId)
|
||||
}
|
||||
|
||||
@ContributesBinding(SessionScope::class)
|
||||
@SingleIn(SessionScope::class)
|
||||
class DefaultTimelineProtectionStore : TimelineProtectionStore {
|
||||
private val allowedEvents = mutableStateSetOf<EventId>()
|
||||
|
||||
override val allowedEventIds: SnapshotStateSet<EventId> = allowedEvents
|
||||
|
||||
override fun allowEvent(eventId: EventId) {
|
||||
allowedEvents += eventId
|
||||
}
|
||||
}
|
||||
@@ -70,6 +70,10 @@
|
||||
<string name="screen_room_timeline_reactions_show_more">"Több megjelenítése"</string>
|
||||
<string name="screen_room_timeline_reactions_show_reactions_summary">"Reakció-összefoglaló megjelenítése"</string>
|
||||
<string name="screen_room_timeline_read_marker_title">"Új"</string>
|
||||
<plurals name="screen_room_timeline_redacted_messages">
|
||||
<item quantity="one">"%1$d eltávolított üzenet"</item>
|
||||
<item quantity="other">"%1$d eltávolított üzenet"</item>
|
||||
</plurals>
|
||||
<plurals name="screen_room_timeline_state_changes">
|
||||
<item quantity="one">"%1$d szobaváltozás"</item>
|
||||
<item quantity="other">"%1$d szobaváltozás"</item>
|
||||
|
||||
@@ -32,7 +32,7 @@
|
||||
<string name="screen_media_upload_preview_processing">"Przetwarzanie…"</string>
|
||||
<string name="screen_media_upload_preview_title">"Dodaj media"</string>
|
||||
<string name="screen_report_content_block_user">"Zablokuj użytkownika"</string>
|
||||
<string name="screen_report_content_block_user_hint">"Sprawdź, czy chcesz ukryć wszystkie bieżące i przyszłe wiadomości od tego użytkownika."</string>
|
||||
<string name="screen_report_content_block_user_hint">"Zaznacz, jeśli chcesz ukryć wszystkie bieżące i przyszłe wiadomości od tego użytkownika."</string>
|
||||
<string name="screen_report_content_explanation">"Ta wiadomość zostanie zgłoszona do administratora Twojego serwera domowego. Nie będzie mógł on przeczytać żadnych zaszyfrowanych wiadomości."</string>
|
||||
<string name="screen_report_content_hint">"Powód zgłoszenia treści"</string>
|
||||
<string name="screen_room_attachment_source_camera">"Kamera"</string>
|
||||
|
||||
@@ -7,6 +7,19 @@
|
||||
<string name="crypto_event_authenticity_unknown_device">"Criptat de un dispozitiv necunoscut sau șters."</string>
|
||||
<string name="crypto_event_authenticity_unsigned_device">"Criptat de un dispozitiv care nu este verificat de proprietarul său."</string>
|
||||
<string name="crypto_event_authenticity_unverified_identity">"Criptat de un utilizator neverificat."</string>
|
||||
<string name="screen_image_edition_a11y_flip_image_horizontally">"Răsturnați imaginea pe orizontală"</string>
|
||||
<string name="screen_image_edition_a11y_flip_image_horizontally_state_flipped">"Răsturnat pe orizontală"</string>
|
||||
<string name="screen_image_edition_a11y_flip_image_horizontally_state_original">"Original"</string>
|
||||
<string name="screen_image_edition_a11y_flip_image_vertically">"Răsturnați imaginea pe verticală"</string>
|
||||
<string name="screen_image_edition_a11y_flip_image_vertically_state_flipped">"Răsturnat pe verticală"</string>
|
||||
<string name="screen_image_edition_a11y_flip_image_vertically_state_original">"Original"</string>
|
||||
<string name="screen_image_edition_a11y_rotate_to_the_left">"Rotiți imaginea la stânga"</string>
|
||||
<plurals name="screen_image_edition_a11y_rotation_state">
|
||||
<item quantity="one">"%1$d grad"</item>
|
||||
<item quantity="few">"%1$d grade"</item>
|
||||
<item quantity="other">"%1$d grade"</item>
|
||||
</plurals>
|
||||
<string name="screen_image_edition_title">"Editați poza"</string>
|
||||
<string name="screen_media_upload_preview_caption_warning">"Este posibil ca descrierile să nu fie vizibile pentru persoanele care folosesc aplicații mai vechi."</string>
|
||||
<string name="screen_media_upload_preview_change_video_quality_prompt">"Atingeți pentru a modifica calitatea încărcării videoclipului"</string>
|
||||
<string name="screen_media_upload_preview_error_could_not_be_uploaded">"Fișierul nu a putut fi încărcat."</string>
|
||||
@@ -17,6 +30,7 @@
|
||||
<string name="screen_media_upload_preview_item_count">"Elementul %1$d din %2$d"</string>
|
||||
<string name="screen_media_upload_preview_optimize_image_quality_title">"Optimizați calitatea imaginii"</string>
|
||||
<string name="screen_media_upload_preview_processing">"Se procesează…"</string>
|
||||
<string name="screen_media_upload_preview_title">"Adăugați media"</string>
|
||||
<string name="screen_report_content_block_user">"Blocați utilizatorul"</string>
|
||||
<string name="screen_report_content_block_user_hint">"Confirmați că doriți să ascundeți toate mesajele curente și viitoare de la acest utilizator"</string>
|
||||
<string name="screen_report_content_explanation">"Acest mesaj va fi raportat administratorilor homeserver-ului tau. Ei nu vor putea citi niciun mesaj criptat."</string>
|
||||
@@ -59,6 +73,11 @@
|
||||
<string name="screen_room_timeline_reactions_show_more">"Afișați mai mult"</string>
|
||||
<string name="screen_room_timeline_reactions_show_reactions_summary">"Afișați rezumatul reacțiilor"</string>
|
||||
<string name="screen_room_timeline_read_marker_title">"Nou"</string>
|
||||
<plurals name="screen_room_timeline_redacted_messages">
|
||||
<item quantity="one">"%1$d mesaj șters"</item>
|
||||
<item quantity="few">"%1$d mesaje șterse"</item>
|
||||
<item quantity="other">"%1$d mesaje șterse"</item>
|
||||
</plurals>
|
||||
<plurals name="screen_room_timeline_state_changes">
|
||||
<item quantity="one">"%1$d schimbare a camerii"</item>
|
||||
<item quantity="few">"%1$d schimbări ale camerei"</item>
|
||||
|
||||
@@ -7,6 +7,17 @@
|
||||
<string name="crypto_event_authenticity_unknown_device">"由未知或已刪除的裝置加密。"</string>
|
||||
<string name="crypto_event_authenticity_unsigned_device">"由未經其擁有者驗證的裝置加密。"</string>
|
||||
<string name="crypto_event_authenticity_unverified_identity">"由未經驗證的使用者加密。"</string>
|
||||
<string name="screen_image_edition_a11y_flip_image_horizontally">"水平翻轉圖片"</string>
|
||||
<string name="screen_image_edition_a11y_flip_image_horizontally_state_flipped">"水平翻轉"</string>
|
||||
<string name="screen_image_edition_a11y_flip_image_horizontally_state_original">"原始狀態"</string>
|
||||
<string name="screen_image_edition_a11y_flip_image_vertically">"垂直翻轉圖片"</string>
|
||||
<string name="screen_image_edition_a11y_flip_image_vertically_state_flipped">"垂直翻轉"</string>
|
||||
<string name="screen_image_edition_a11y_flip_image_vertically_state_original">"原始狀態"</string>
|
||||
<string name="screen_image_edition_a11y_rotate_to_the_left">"向左旋轉圖片"</string>
|
||||
<plurals name="screen_image_edition_a11y_rotation_state">
|
||||
<item quantity="other">"%1$d 度"</item>
|
||||
</plurals>
|
||||
<string name="screen_image_edition_title">"編輯照片"</string>
|
||||
<string name="screen_media_upload_preview_caption_warning">"使用舊應用程式的使用者可能看不到標題。"</string>
|
||||
<string name="screen_media_upload_preview_change_video_quality_prompt">"輕點即可變更影片上傳品質"</string>
|
||||
<string name="screen_media_upload_preview_error_could_not_be_uploaded">"無法上傳檔案。"</string>
|
||||
@@ -17,6 +28,7 @@
|
||||
<string name="screen_media_upload_preview_item_count">"第 %1$d 個項目,共 %2$d 個"</string>
|
||||
<string name="screen_media_upload_preview_optimize_image_quality_title">"最佳化影像品質"</string>
|
||||
<string name="screen_media_upload_preview_processing">"正在處理……"</string>
|
||||
<string name="screen_media_upload_preview_title">"新增媒體"</string>
|
||||
<string name="screen_report_content_block_user">"封鎖使用者"</string>
|
||||
<string name="screen_report_content_block_user_hint">"檢查您是否要隱藏所有來自此使用者的目前及未來的訊息"</string>
|
||||
<string name="screen_report_content_explanation">"此訊息將會回報給您的家伺服器管理員。他們將無法讀取任何已加密的訊息。"</string>
|
||||
@@ -55,6 +67,9 @@
|
||||
<string name="screen_room_timeline_reactions_show_more">"更多"</string>
|
||||
<string name="screen_room_timeline_reactions_show_reactions_summary">"顯示反應摘要"</string>
|
||||
<string name="screen_room_timeline_read_marker_title">"新訊息"</string>
|
||||
<plurals name="screen_room_timeline_redacted_messages">
|
||||
<item quantity="other">"%1$d 則已移除的訊息"</item>
|
||||
</plurals>
|
||||
<plurals name="screen_room_timeline_state_changes">
|
||||
<item quantity="other">"%1$d 個聊天室變更"</item>
|
||||
</plurals>
|
||||
|
||||
+65
@@ -13,8 +13,12 @@ package io.element.android.features.messages.impl.timeline
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.compose.ui.test.AndroidComposeUiTest
|
||||
import androidx.compose.ui.test.ExperimentalTestApi
|
||||
import androidx.compose.ui.test.assertIsDisplayed
|
||||
import androidx.compose.ui.test.onAllNodesWithText
|
||||
import androidx.compose.ui.test.onFirst
|
||||
import androidx.compose.ui.test.onNodeWithContentDescription
|
||||
import androidx.compose.ui.test.onNodeWithTag
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.ui.test.performClick
|
||||
import androidx.compose.ui.test.performScrollToIndex
|
||||
import androidx.compose.ui.test.v2.runAndroidComposeUiTest
|
||||
@@ -29,8 +33,14 @@ import io.element.android.features.messages.impl.timeline.protection.aTimelinePr
|
||||
import io.element.android.libraries.matrix.api.core.EventId
|
||||
import io.element.android.libraries.matrix.api.core.UniqueId
|
||||
import io.element.android.libraries.matrix.api.timeline.Timeline
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.MessageContent
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.MessageShield
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.TextMessageType
|
||||
import io.element.android.libraries.matrix.api.user.MatrixUser
|
||||
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.ui.messages.reply.InReplyToDetails
|
||||
import io.element.android.libraries.matrix.ui.messages.reply.aProfileDetailsReady
|
||||
import io.element.android.libraries.ui.strings.CommonStrings
|
||||
import io.element.android.tests.testutils.EnsureNeverCalledWithParam
|
||||
import io.element.android.tests.testutils.EnsureNeverCalledWithTwoParams
|
||||
@@ -168,6 +178,61 @@ class TimelineViewTest : RobolectricTest() {
|
||||
eventsRecorder.assertSingle(TimelineEvent.HideShieldDialog)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `clicking on a reply to a loaded event emits the expected Event`() = runAndroidComposeUiTest {
|
||||
val eventsRecorder = EventsRecorder<TimelineEvent>()
|
||||
setTimelineView(
|
||||
state = aTimelineState(
|
||||
timelineItems = persistentListOf(
|
||||
aTimelineItemEvent(
|
||||
content = aTimelineItemTextContent(),
|
||||
inReplyTo = InReplyToDetails.Ready(
|
||||
eventId = AN_EVENT_ID,
|
||||
senderId = A_USER_ID,
|
||||
senderProfile = aProfileDetailsReady(),
|
||||
eventContent = MessageContent(
|
||||
body = "Replied message",
|
||||
inReplyTo = null,
|
||||
isEdited = false,
|
||||
threadInfo = null,
|
||||
type = TextMessageType("Replied message", null),
|
||||
),
|
||||
textContent = "Replied message",
|
||||
),
|
||||
),
|
||||
),
|
||||
eventSink = eventsRecorder,
|
||||
),
|
||||
)
|
||||
eventsRecorder.clear()
|
||||
|
||||
onAllNodesWithText("Replied message").onFirst().performClick()
|
||||
eventsRecorder.assertSingle(TimelineEvent.FocusOnEvent(AN_EVENT_ID))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a reply to an event which could not be loaded renders a placeholder and does not emit any Event when clicked`() = runAndroidComposeUiTest {
|
||||
val eventsRecorder = EventsRecorder<TimelineEvent>()
|
||||
setTimelineView(
|
||||
state = aTimelineState(
|
||||
timelineItems = persistentListOf(
|
||||
aTimelineItemEvent(
|
||||
content = aTimelineItemTextContent(),
|
||||
inReplyTo = InReplyToDetails.Error(eventId = AN_EVENT_ID, message = "Failed to fetch the event"),
|
||||
),
|
||||
),
|
||||
eventSink = eventsRecorder,
|
||||
),
|
||||
onMessageClick = {},
|
||||
)
|
||||
eventsRecorder.assertSingle(TimelineEvent.OnScrollFinished(firstIndex = 0))
|
||||
eventsRecorder.clear()
|
||||
|
||||
onNodeWithText("Failed to fetch the event").assertDoesNotExist()
|
||||
onNodeWithText(activity!!.getString(CommonStrings.error_message_not_found)).assertIsDisplayed().performClick()
|
||||
eventsRecorder.assertEmpty()
|
||||
}
|
||||
|
||||
@Ignore(
|
||||
"performScrollToIndex in compose tests no longer sets LazyListState.isScrollInProgress to true, so the LoadMore event is not emitted." +
|
||||
"This needs to be reworked to use a different approach to check the LoadMore event was emitted."
|
||||
|
||||
+62
@@ -0,0 +1,62 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Element Creations Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
@file:OptIn(ExperimentalMaterial3Api::class, ExperimentalTestApi::class)
|
||||
|
||||
package io.element.android.features.messages.impl.timeline.components.customreaction
|
||||
|
||||
import android.view.View
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.SheetState
|
||||
import androidx.compose.material3.SheetValue
|
||||
import androidx.compose.material3.rememberBottomSheetState
|
||||
import androidx.compose.ui.platform.LocalView
|
||||
import androidx.compose.ui.test.AndroidComposeUiTest
|
||||
import androidx.compose.ui.test.ExperimentalTestApi
|
||||
import androidx.compose.ui.test.v2.runAndroidComposeUiTest
|
||||
import androidx.core.graphics.Insets
|
||||
import androidx.core.view.ViewCompat
|
||||
import androidx.core.view.WindowInsetsCompat
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import io.element.android.tests.testutils.robolectric.RobolectricTest
|
||||
import org.junit.Test
|
||||
|
||||
class ExpandSheetWhenImeIsVisibleTest : RobolectricTest() {
|
||||
@Test
|
||||
fun `the sheet is left alone while the keyboard is hidden`() = runAndroidComposeUiTest<ComponentActivity> {
|
||||
val sheetState = renderWithIme(isImeVisible = false)
|
||||
assertThat(sheetState.currentValue).isEqualTo(SheetValue.PartiallyExpanded)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the sheet is expanded when the keyboard is shown`() = runAndroidComposeUiTest<ComponentActivity> {
|
||||
val sheetState = renderWithIme(isImeVisible = true)
|
||||
assertThat(sheetState.currentValue).isEqualTo(SheetValue.Expanded)
|
||||
}
|
||||
}
|
||||
|
||||
private fun AndroidComposeUiTest<ComponentActivity>.renderWithIme(isImeVisible: Boolean): SheetState {
|
||||
lateinit var sheetState: SheetState
|
||||
lateinit var view: View
|
||||
setContent {
|
||||
view = LocalView.current
|
||||
sheetState = rememberBottomSheetState(initialValue = SheetValue.PartiallyExpanded)
|
||||
ExpandSheetWhenImeIsVisible(sheetState)
|
||||
}
|
||||
runOnIdle {
|
||||
ViewCompat.dispatchApplyWindowInsets(
|
||||
view,
|
||||
WindowInsetsCompat.Builder()
|
||||
.setVisible(WindowInsetsCompat.Type.ime(), isImeVisible)
|
||||
.setInsets(WindowInsetsCompat.Type.ime(), if (isImeVisible) Insets.of(0, 0, 0, 300) else Insets.NONE)
|
||||
.build()
|
||||
)
|
||||
}
|
||||
waitForIdle()
|
||||
return sheetState
|
||||
}
|
||||
+20
@@ -95,6 +95,24 @@ class TimelineProtectionPresenterTest {
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - shown content is restored when the presenter is recreated`() = runTest {
|
||||
val mediaPreviewConfig = MediaPreviewConfig(mediaPreviewValue = MediaPreviewValue.Off, hideInviteAvatar = false)
|
||||
val mediaPreviewService = FakeMediaPreviewService(mediaPreviewConfigFlow = MutableStateFlow(mediaPreviewConfig))
|
||||
val timelineProtectionStore = DefaultTimelineProtectionStore()
|
||||
createPresenter(mediaPreviewService = mediaPreviewService, timelineProtectionStore = timelineProtectionStore).test {
|
||||
val initialState = awaitItem()
|
||||
assertThat(initialState.protectionState).isEqualTo(ProtectionState.RenderOnly(persistentSetOf()))
|
||||
initialState.eventSink(TimelineProtectionEvent.ShowContent(eventId = AN_EVENT_ID))
|
||||
val finalState = awaitItem()
|
||||
assertThat(finalState.protectionState).isEqualTo(ProtectionState.RenderOnly(persistentSetOf(AN_EVENT_ID)))
|
||||
}
|
||||
createPresenter(mediaPreviewService = mediaPreviewService, timelineProtectionStore = timelineProtectionStore).test {
|
||||
val initialState = awaitItem()
|
||||
assertThat(initialState.protectionState).isEqualTo(ProtectionState.RenderOnly(persistentSetOf(AN_EVENT_ID)))
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
@Test
|
||||
fun `present - validate media scans the media source`() = runTest {
|
||||
@@ -134,9 +152,11 @@ class TimelineProtectionPresenterTest {
|
||||
room: BaseRoom = FakeBaseRoom(),
|
||||
mediaPreviewService: MediaPreviewService = FakeMediaPreviewService(),
|
||||
contentScannerService: ContentScannerService = ContentScannerService { _: List<MediaSource>, _: ContentValidationState -> },
|
||||
timelineProtectionStore: TimelineProtectionStore = DefaultTimelineProtectionStore(),
|
||||
) = TimelineProtectionPresenter(
|
||||
mediaPreviewService = mediaPreviewService,
|
||||
room = room,
|
||||
contentScannerService = contentScannerService,
|
||||
timelineProtectionStore = timelineProtectionStore,
|
||||
)
|
||||
}
|
||||
|
||||
+8
-2
@@ -74,8 +74,14 @@ class PreferencesRootPresenter(
|
||||
val showLinkNewDevice by remember {
|
||||
featureFlagService.isFeatureEnabledFlow(FeatureFlags.QrCodeLogin)
|
||||
}.collectAsState(initial = false)
|
||||
val isUserStatusEnabled by featureFlagService.isFeatureEnabledFlow(FeatureFlags.UserStatus).collectAsState(initial = false)
|
||||
val userStatusState = if (isUserStatusEnabled) userStatusPresenter.present() else null
|
||||
|
||||
val isUserStatusSupported by produceState(false) {
|
||||
featureFlagService.isFeatureEnabledFlow(FeatureFlags.UserStatus)
|
||||
.collect { isUserStatusEnabled ->
|
||||
value = isUserStatusEnabled && matrixClient.isUserStatusSupported().getOrDefault(false)
|
||||
}
|
||||
}
|
||||
val userStatusState = if (isUserStatusSupported) userStatusPresenter.present() else null
|
||||
|
||||
val otherSessions by remember {
|
||||
sessionStore.sessionsFlow().map { list ->
|
||||
|
||||
+82
-60
@@ -9,9 +9,12 @@
|
||||
package io.element.android.features.preferences.impl.root
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.BoxScope
|
||||
import androidx.compose.foundation.layout.ColumnScope
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.statusBarsPadding
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
@@ -25,7 +28,10 @@ import io.element.android.features.preferences.impl.R
|
||||
import io.element.android.features.preferences.impl.user.UserPreferences
|
||||
import io.element.android.features.preferences.impl.userstatus.UserStatusState
|
||||
import io.element.android.features.preferences.impl.userstatus.UserStatusView
|
||||
import io.element.android.libraries.architecture.AsyncAction
|
||||
import io.element.android.libraries.architecture.coverage.ExcludeFromCoverage
|
||||
import io.element.android.libraries.designsystem.components.async.AsyncActionIndicator
|
||||
import io.element.android.libraries.designsystem.components.async.AsyncIndicator
|
||||
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
|
||||
import io.element.android.libraries.designsystem.components.list.ListItemContent
|
||||
import io.element.android.libraries.designsystem.components.preferences.PreferencePage
|
||||
@@ -71,71 +77,87 @@ fun PreferencesRootView(
|
||||
) {
|
||||
val snackbarHostState = rememberSnackbarHostState(snackbarMessage = state.snackbarMessage)
|
||||
|
||||
// Include pref from other modules
|
||||
PreferencePage(
|
||||
modifier = modifier,
|
||||
onBackClick = onBackClick,
|
||||
title = stringResource(id = CommonStrings.common_settings),
|
||||
snackbarHost = { SnackbarHost(snackbarHostState) }
|
||||
) {
|
||||
UserPreferences(
|
||||
modifier = Modifier.clickable {
|
||||
onOpenUserProfile(state.myUser)
|
||||
},
|
||||
matrixUser = state.myUser,
|
||||
)
|
||||
if (state.isMultiAccountEnabled) {
|
||||
MultiAccountSection(
|
||||
state = state,
|
||||
onAddAccountClick = onAddAccountClick,
|
||||
Box(modifier = modifier) {
|
||||
// Include pref from other modules
|
||||
PreferencePage(
|
||||
onBackClick = onBackClick,
|
||||
title = stringResource(id = CommonStrings.common_settings),
|
||||
snackbarHost = { SnackbarHost(snackbarHostState) }
|
||||
) {
|
||||
UserPreferences(
|
||||
modifier = Modifier.clickable {
|
||||
onOpenUserProfile(state.myUser)
|
||||
},
|
||||
matrixUser = state.myUser,
|
||||
)
|
||||
}
|
||||
if (state.userStatusState != null) {
|
||||
UserStatusSection(
|
||||
userStatusState = state.userStatusState,
|
||||
emojiPickerRenderer = emojiPickerRenderer,
|
||||
showTopDivider = !state.isMultiAccountEnabled,
|
||||
)
|
||||
}
|
||||
// 'Account' section
|
||||
ManageAccountSection(
|
||||
state = state,
|
||||
onManageAccountClick = onManageAccountClick,
|
||||
onLinkNewDeviceClick = onLinkNewDeviceClick,
|
||||
onOpenBlockedUsers = onOpenBlockedUsers
|
||||
)
|
||||
// 'Manage my app' section
|
||||
ManageAppSection(
|
||||
state = state,
|
||||
onOpenNotificationSettings = onOpenNotificationSettings,
|
||||
onOpenLockScreenSettings = onOpenLockScreenSettings,
|
||||
onSecureBackupClick = onSecureBackupClick,
|
||||
)
|
||||
|
||||
// General section
|
||||
GeneralSection(
|
||||
state = state,
|
||||
onOpenAbout = onOpenAbout,
|
||||
onOpenAnalytics = onOpenAnalytics,
|
||||
onOpenRageShake = onOpenRageShake,
|
||||
onOpenAdvancedSettings = onOpenAdvancedSettings,
|
||||
onOpenDeveloperSettings = onOpenDeveloperSettings,
|
||||
onOpenLabs = onOpenLabs,
|
||||
onSignOutClick = onSignOutClick,
|
||||
onDeactivateClick = onDeactivateClick,
|
||||
)
|
||||
// Version
|
||||
Footer(
|
||||
version = state.version,
|
||||
onClick = if (!state.showDeveloperSettings) {
|
||||
{ state.eventSink(PreferencesRootEvent.OnVersionInfoClick) }
|
||||
} else {
|
||||
null
|
||||
if (state.isMultiAccountEnabled) {
|
||||
MultiAccountSection(
|
||||
state = state,
|
||||
onAddAccountClick = onAddAccountClick,
|
||||
)
|
||||
}
|
||||
)
|
||||
if (state.userStatusState != null) {
|
||||
UserStatusSection(
|
||||
userStatusState = state.userStatusState,
|
||||
emojiPickerRenderer = emojiPickerRenderer,
|
||||
showTopDivider = !state.isMultiAccountEnabled,
|
||||
)
|
||||
}
|
||||
// 'Account' section
|
||||
ManageAccountSection(
|
||||
state = state,
|
||||
onManageAccountClick = onManageAccountClick,
|
||||
onLinkNewDeviceClick = onLinkNewDeviceClick,
|
||||
onOpenBlockedUsers = onOpenBlockedUsers
|
||||
)
|
||||
// 'Manage my app' section
|
||||
ManageAppSection(
|
||||
state = state,
|
||||
onOpenNotificationSettings = onOpenNotificationSettings,
|
||||
onOpenLockScreenSettings = onOpenLockScreenSettings,
|
||||
onSecureBackupClick = onSecureBackupClick,
|
||||
)
|
||||
|
||||
// General section
|
||||
GeneralSection(
|
||||
state = state,
|
||||
onOpenAbout = onOpenAbout,
|
||||
onOpenAnalytics = onOpenAnalytics,
|
||||
onOpenRageShake = onOpenRageShake,
|
||||
onOpenAdvancedSettings = onOpenAdvancedSettings,
|
||||
onOpenDeveloperSettings = onOpenDeveloperSettings,
|
||||
onOpenLabs = onOpenLabs,
|
||||
onSignOutClick = onSignOutClick,
|
||||
onDeactivateClick = onDeactivateClick,
|
||||
)
|
||||
// Version
|
||||
Footer(
|
||||
version = state.version,
|
||||
onClick = if (!state.showDeveloperSettings) {
|
||||
{ state.eventSink(PreferencesRootEvent.OnVersionInfoClick) }
|
||||
} else {
|
||||
null
|
||||
}
|
||||
)
|
||||
}
|
||||
state.userStatusState?.let {
|
||||
UserStatusUpdateIndicator(it.updateStatusAction)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun BoxScope.UserStatusUpdateIndicator(updateStatusAction: AsyncAction<Unit>) {
|
||||
AsyncActionIndicator(
|
||||
asyncAction = updateStatusAction,
|
||||
modifier = Modifier
|
||||
.align(Alignment.TopCenter)
|
||||
.statusBarsPadding(),
|
||||
loading = { AsyncIndicator.Loading(text = stringResource(CommonStrings.common_saving)) },
|
||||
failure = { _ -> AsyncIndicator.Failure(text = stringResource(CommonStrings.common_failed)) },
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ColumnScope.UserStatusSection(
|
||||
userStatusState: UserStatusState,
|
||||
|
||||
+10
-2
@@ -18,7 +18,9 @@ import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import dev.zacsweers.metro.Inject
|
||||
import io.element.android.libraries.architecture.AsyncAction
|
||||
import io.element.android.libraries.architecture.Presenter
|
||||
import io.element.android.libraries.architecture.runUpdatingState
|
||||
import io.element.android.libraries.emoji.api.picker.EmojiPickerPresenter
|
||||
import io.element.android.libraries.emoji.api.recentemojis.EmptyGetRecentEmojis
|
||||
import io.element.android.libraries.matrix.api.MatrixClient
|
||||
@@ -38,6 +40,7 @@ class UserStatusPresenter(
|
||||
var isEmojiPickerVisible by remember { mutableStateOf(false) }
|
||||
val customTextFieldState = rememberTextFieldState()
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
val updateStatusAction = remember { mutableStateOf<AsyncAction<Unit>>(AsyncAction.Uninitialized) }
|
||||
|
||||
fun handleEvent(event: UserStatusEvent) {
|
||||
when (event) {
|
||||
@@ -47,11 +50,15 @@ class UserStatusPresenter(
|
||||
UserStatusEvent.CancelCustomInput -> pickerState = UserStatusPickerState.Hidden
|
||||
is UserStatusEvent.SetStatus -> {
|
||||
pickerState = UserStatusPickerState.Hidden
|
||||
coroutineScope.launch { matrixClient.setUserStatus(event.status) }
|
||||
coroutineScope.launch {
|
||||
updateStatusAction.runUpdatingState { matrixClient.setUserStatus(event.status) }
|
||||
}
|
||||
}
|
||||
UserStatusEvent.ClearStatus -> {
|
||||
pickerState = UserStatusPickerState.Hidden
|
||||
coroutineScope.launch { matrixClient.clearUserStatus() }
|
||||
coroutineScope.launch {
|
||||
updateStatusAction.runUpdatingState { matrixClient.clearUserStatus() }
|
||||
}
|
||||
}
|
||||
UserStatusEvent.OpenCustomInput -> {
|
||||
val raw = userProfile.rawStatus
|
||||
@@ -93,6 +100,7 @@ class UserStatusPresenter(
|
||||
displayedStatus = userProfile.displayedStatus,
|
||||
rawStatus = userProfile.rawStatus,
|
||||
pickerState = effectivePickerState,
|
||||
updateStatusAction = updateStatusAction.value,
|
||||
eventSink = ::handleEvent,
|
||||
)
|
||||
}
|
||||
|
||||
+2
@@ -11,6 +11,7 @@ import androidx.annotation.StringRes
|
||||
import androidx.compose.foundation.text.input.TextFieldState
|
||||
import androidx.compose.runtime.Immutable
|
||||
import io.element.android.features.preferences.impl.R
|
||||
import io.element.android.libraries.architecture.AsyncAction
|
||||
import io.element.android.libraries.emoji.api.picker.EmojiPickerState
|
||||
import io.element.android.libraries.matrix.api.user.DisplayedStatus
|
||||
import io.element.android.libraries.matrix.api.user.UserStatus
|
||||
@@ -19,6 +20,7 @@ data class UserStatusState(
|
||||
val displayedStatus: DisplayedStatus?,
|
||||
val rawStatus: UserStatus?,
|
||||
val pickerState: UserStatusPickerState,
|
||||
val updateStatusAction: AsyncAction<Unit>,
|
||||
val eventSink: (UserStatusEvent) -> Unit,
|
||||
)
|
||||
|
||||
|
||||
+3
@@ -9,6 +9,7 @@ package io.element.android.features.preferences.impl.userstatus
|
||||
|
||||
import androidx.compose.foundation.text.input.TextFieldState
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
||||
import io.element.android.libraries.architecture.AsyncAction
|
||||
import io.element.android.libraries.matrix.api.user.DisplayedStatus
|
||||
import io.element.android.libraries.matrix.api.user.UserStatus
|
||||
|
||||
@@ -44,10 +45,12 @@ fun aUserStatusState(
|
||||
displayedStatus: DisplayedStatus? = null,
|
||||
rawStatus: UserStatus? = null,
|
||||
pickerState: UserStatusPickerState = UserStatusPickerState.Hidden,
|
||||
updateStatusAction: AsyncAction<Unit> = AsyncAction.Uninitialized,
|
||||
eventSink: (UserStatusEvent) -> Unit = {},
|
||||
) = UserStatusState(
|
||||
displayedStatus = displayedStatus,
|
||||
rawStatus = rawStatus,
|
||||
pickerState = pickerState,
|
||||
updateStatusAction = updateStatusAction,
|
||||
eventSink = eventSink,
|
||||
)
|
||||
|
||||
@@ -11,6 +11,12 @@
|
||||
<string name="screen_advanced_settings_hide_invite_avatars_toggle_title">"Avatare in Chateinladungen ausblenden"</string>
|
||||
<string name="screen_advanced_settings_hide_timeline_media_toggle_title">"Medienvorschau im Nachrichtenverlauf ausblenden"</string>
|
||||
<string name="screen_advanced_settings_labs">"Labs"</string>
|
||||
<string name="screen_advanced_settings_live_location_section_description">"Die Entfernung, die du zurücklegen musst, um ein Update auszulösen."</string>
|
||||
<string name="screen_advanced_settings_live_location_section_title">"Live-Standort-Updates"</string>
|
||||
<plurals name="screen_advanced_settings_live_location_update_distance">
|
||||
<item quantity="one">"Alle %1$d Meter"</item>
|
||||
<item quantity="other">"Alle %1$d Meter"</item>
|
||||
</plurals>
|
||||
<string name="screen_advanced_settings_media_compression_description">"Lade Fotos und Videos schneller hoch und reduziere den Datenverbrauch"</string>
|
||||
<string name="screen_advanced_settings_media_compression_title">"Optimiere die Medienqualität"</string>
|
||||
<string name="screen_advanced_settings_moderation_and_safety_section_title">"Moderation und Sicherheit"</string>
|
||||
|
||||
@@ -101,6 +101,14 @@ Ha folytatja, egyes beállítások megváltozhatnak."</string>
|
||||
<string name="screen_notification_settings_system_notifications_action_required_content_link">"rendszerbeállításokat"</string>
|
||||
<string name="screen_notification_settings_system_notifications_turned_off">"A rendszerértesítések ki vannak kapcsolva"</string>
|
||||
<string name="screen_notification_settings_title">"Értesítések"</string>
|
||||
<string name="screen_settings_user_status_away">"Távol"</string>
|
||||
<string name="screen_settings_user_status_be_right_back">"Rögtön visszajövök"</string>
|
||||
<string name="screen_settings_user_status_custom">"Egyedi…"</string>
|
||||
<string name="screen_settings_user_status_custom_hint">"Egyéni állapot…"</string>
|
||||
<string name="screen_settings_user_status_focus_time">"Feladatra koncentrál"</string>
|
||||
<string name="screen_settings_user_status_in_a_meeting">"Értekezleten"</string>
|
||||
<string name="screen_settings_user_status_on_the_road">"Úton"</string>
|
||||
<string name="screen_settings_user_status_placeholder">"Mi az állapota?"</string>
|
||||
<string name="theme_black">"Fekete"</string>
|
||||
<string name="theme_dark">"Sötét"</string>
|
||||
<string name="theme_light">"Világos"</string>
|
||||
|
||||
@@ -33,15 +33,15 @@
|
||||
<string name="screen_advanced_settings_push_provider_android">"Dostawca powiadomień push"</string>
|
||||
<string name="screen_advanced_settings_rich_text_editor_description">"Wyłącz edytor tekstu bogatego, aby pisać tekst Markdown ręcznie."</string>
|
||||
<string name="screen_advanced_settings_send_read_receipts">"Potwierdzenia odczytania"</string>
|
||||
<string name="screen_advanced_settings_send_read_receipts_description">"Gdy wyłączona, Twoje potwierdzenia odczytania nie zostaną wysłane. Potwierdzenia od innych wciąż będą odbierane."</string>
|
||||
<string name="screen_advanced_settings_send_read_receipts_description">"Po wyłączeniu potwierdzenia odczytu nie będą wysyłane. Nadal będziesz otrzymywać potwierdzenia od innych użytkowników."</string>
|
||||
<string name="screen_advanced_settings_share_presence">"Udostępnij obecność"</string>
|
||||
<string name="screen_advanced_settings_share_presence_description">"Gdy wyłączona, nie będziesz mógł wysyłać lub odbierać potwierdzeń odczytu ani powiadomień pisania."</string>
|
||||
<string name="screen_advanced_settings_share_presence_description">"Po wyłączeniu nie będzie można wysyłać ani odbierać potwierdzeń odczytu i powiadomień o pisaniu."</string>
|
||||
<string name="screen_advanced_settings_show_media_timeline_always_hide">"Zawsze ukrywaj"</string>
|
||||
<string name="screen_advanced_settings_show_media_timeline_always_show">"Zawsze pokazuj"</string>
|
||||
<string name="screen_advanced_settings_show_media_timeline_private_rooms">"W pokojach prywatnych"</string>
|
||||
<string name="screen_advanced_settings_show_media_timeline_subtitle">"Ukryte media można zawsze wyświetlić, dotykając ich"</string>
|
||||
<string name="screen_advanced_settings_show_media_timeline_title">"Pokaż media na osi czasu"</string>
|
||||
<string name="screen_advanced_settings_view_source_description">"Włącz opcję, aby wyświetlić źródło wiadomości na osi czasu."</string>
|
||||
<string name="screen_advanced_settings_view_source_description">"Włącz, aby móc wyświetlać źródło wiadomości na osi czasu."</string>
|
||||
<string name="screen_blocked_users_empty">"Nie blokujesz żadnych użytkowników"</string>
|
||||
<string name="screen_blocked_users_unblock_alert_action">"Odblokuj"</string>
|
||||
<string name="screen_blocked_users_unblock_alert_description">"Będziesz mógł ponownie zobaczyć wszystkie wiadomości od tego użytkownika."</string>
|
||||
|
||||
@@ -59,6 +59,7 @@
|
||||
<string name="screen_labs_header_title">"Doriți experiențe noi?"</string>
|
||||
<string name="screen_labs_title">"Laboratoare"</string>
|
||||
<string name="screen_notification_settings_additional_settings_section_title">"Setări adiționale"</string>
|
||||
<string name="screen_notification_settings_call_ringtone_label">"Ton de apel"</string>
|
||||
<string name="screen_notification_settings_calls_label">"Apeluri audio și video"</string>
|
||||
<string name="screen_notification_settings_configuration_mismatch">"Nepotrivire de configurație"</string>
|
||||
<string name="screen_notification_settings_configuration_mismatch_description">"Am simplificat Setările pentru notificări pentru a face opțiunile mai ușor de găsit.
|
||||
@@ -79,14 +80,38 @@ Dacă continuați, unele dintre setările dumneavoastră pot fi modificate."</st
|
||||
<string name="screen_notification_settings_invite_for_me_label">"Invitații"</string>
|
||||
<string name="screen_notification_settings_mentions_only_disclaimer">"Serverul dumneavoastră nu acceptă această opțiune în camerele criptate, este posibil să nu primiți notificări în unele camere."</string>
|
||||
<string name="screen_notification_settings_mentions_section_title">"Mențiuni"</string>
|
||||
<string name="screen_notification_settings_message_sound_dialog_choose_other">"Alegeți un alt sunet…"</string>
|
||||
<string name="screen_notification_settings_message_sound_dialog_current_subtitle">"Se utilizează în prezent %1$s"</string>
|
||||
<string name="screen_notification_settings_message_sound_dialog_title">"Sunet de mesaj"</string>
|
||||
<string name="screen_notification_settings_message_sound_label">"Sunet de mesaj"</string>
|
||||
<string name="screen_notification_settings_mode_all">"Toate"</string>
|
||||
<string name="screen_notification_settings_mode_mentions">"Mențiuni"</string>
|
||||
<string name="screen_notification_settings_notification_section_title">"Anunță-mă pentru"</string>
|
||||
<string name="screen_notification_settings_room_mention_label">"Anunțați-mă pentru @room"</string>
|
||||
<string name="screen_notification_settings_sound_custom_fallback">"Personalizat"</string>
|
||||
<string name="screen_notification_settings_sound_custom_sound_button_title">"Sunet personalizat…"</string>
|
||||
<string name="screen_notification_settings_sound_delete_sound_error_title">"Eroare la ștergerea fișierului"</string>
|
||||
<string name="screen_notification_settings_sound_element_default">"Element Implicit"</string>
|
||||
<string name="screen_notification_settings_sound_element_fade">"Element Fade"</string>
|
||||
<string name="screen_notification_settings_sound_import_sound_error_title">"Eroare la importul fișierului"</string>
|
||||
<string name="screen_notification_settings_sound_preview_sound_error_title">"A fost intâmpinată o problemă la previzualizarea sunetului de alertă"</string>
|
||||
<string name="screen_notification_settings_sound_section_title">"Sunet"</string>
|
||||
<string name="screen_notification_settings_sound_set_sound_error_dismiss_a11y">"Ignorați eroarea privind sunetul de alertă"</string>
|
||||
<string name="screen_notification_settings_sound_set_sound_error_title">"A fost întâmpinată o problemă la setarea sunetului de alertă"</string>
|
||||
<string name="screen_notification_settings_sound_silent">"Silențios"</string>
|
||||
<string name="screen_notification_settings_sound_system_default">"Implicit sistem"</string>
|
||||
<string name="screen_notification_settings_system_notifications_action_required">"Pentru a primi notificări, vă rugăm să vă schimbați %1$s."</string>
|
||||
<string name="screen_notification_settings_system_notifications_action_required_content_link">"Setări de sistem"</string>
|
||||
<string name="screen_notification_settings_system_notifications_turned_off">"Notificările de sistem sunt dezactivate"</string>
|
||||
<string name="screen_notification_settings_title">"Notificări"</string>
|
||||
<string name="screen_settings_user_status_away">"Plecat"</string>
|
||||
<string name="screen_settings_user_status_be_right_back">"Revin imediat"</string>
|
||||
<string name="screen_settings_user_status_custom">"Personalizat…"</string>
|
||||
<string name="screen_settings_user_status_custom_hint">"Status personalizat…"</string>
|
||||
<string name="screen_settings_user_status_focus_time">"Timp de concentrare"</string>
|
||||
<string name="screen_settings_user_status_in_a_meeting">"Într-o ședință"</string>
|
||||
<string name="screen_settings_user_status_on_the_road">"Pe drum"</string>
|
||||
<string name="screen_settings_user_status_placeholder">"Care este statusul dumneavoastră?"</string>
|
||||
<string name="theme_black">"Negru"</string>
|
||||
<string name="theme_dark">"Întunecat"</string>
|
||||
<string name="theme_light">"Deschis"</string>
|
||||
|
||||
@@ -11,6 +11,13 @@
|
||||
<string name="screen_advanced_settings_hide_invite_avatars_toggle_title">"在聊天室邀請請求中隱藏大頭貼"</string>
|
||||
<string name="screen_advanced_settings_hide_timeline_media_toggle_title">"在時間軸中隱藏媒體預覽"</string>
|
||||
<string name="screen_advanced_settings_labs">"實驗室"</string>
|
||||
<string name="screen_advanced_settings_live_location_section_description">"觸發更新所需的移動距離。"</string>
|
||||
<string name="screen_advanced_settings_live_location_section_footer">"請確保已為此應用程式啟用「精確位置」功能。若要變更此權限,請前往 %1$s。"</string>
|
||||
<string name="screen_advanced_settings_live_location_section_footer_link">"應用程式設定"</string>
|
||||
<string name="screen_advanced_settings_live_location_section_title">"即時位置更新"</string>
|
||||
<plurals name="screen_advanced_settings_live_location_update_distance">
|
||||
<item quantity="other">"每 %1$d 公尺"</item>
|
||||
</plurals>
|
||||
<string name="screen_advanced_settings_media_compression_description">"上傳照片與影片更快且減少資料使用量"</string>
|
||||
<string name="screen_advanced_settings_media_compression_title">"最佳化媒體品質"</string>
|
||||
<string name="screen_advanced_settings_moderation_and_safety_section_title">"管理與安全"</string>
|
||||
@@ -50,6 +57,7 @@
|
||||
<string name="screen_labs_header_title">"想不想來點新花樣?"</string>
|
||||
<string name="screen_labs_title">"實驗室"</string>
|
||||
<string name="screen_notification_settings_additional_settings_section_title">"其他設定"</string>
|
||||
<string name="screen_notification_settings_call_ringtone_label">"通話鈴聲"</string>
|
||||
<string name="screen_notification_settings_calls_label">"音訊與視訊通話"</string>
|
||||
<string name="screen_notification_settings_configuration_mismatch">"組態錯誤"</string>
|
||||
<string name="screen_notification_settings_configuration_mismatch_description">"我們簡化了通知設定,讓選項更容易搜尋。您過去選擇的某些自訂設定將不會在此顯示,但它們仍有作用。
|
||||
@@ -68,14 +76,39 @@
|
||||
<string name="screen_notification_settings_invite_for_me_label">"邀請"</string>
|
||||
<string name="screen_notification_settings_mentions_only_disclaimer">"您的家伺服器在加密聊天室中不支援此選項,可能無法收到部份聊天室的通知。"</string>
|
||||
<string name="screen_notification_settings_mentions_section_title">"提及"</string>
|
||||
<string name="screen_notification_settings_message_sound_dialog_choose_other">"選擇其他音效⋯⋯"</string>
|
||||
<string name="screen_notification_settings_message_sound_dialog_current_subtitle">"目前正在使用 %1$s"</string>
|
||||
<string name="screen_notification_settings_message_sound_dialog_title">"訊息音效"</string>
|
||||
<string name="screen_notification_settings_message_sound_label">"訊息音效"</string>
|
||||
<string name="screen_notification_settings_mode_all">"全部"</string>
|
||||
<string name="screen_notification_settings_mode_mentions">"提及"</string>
|
||||
<string name="screen_notification_settings_notification_section_title">"通知我"</string>
|
||||
<string name="screen_notification_settings_room_mention_label">"於 @room 通知我"</string>
|
||||
<string name="screen_notification_settings_sound_custom_fallback">"自訂"</string>
|
||||
<string name="screen_notification_settings_sound_custom_sound_button_title">"自訂音效⋯⋯"</string>
|
||||
<string name="screen_notification_settings_sound_delete_sound_error_title">"刪除檔案時發生錯誤"</string>
|
||||
<string name="screen_notification_settings_sound_element_default">"Element 預設值"</string>
|
||||
<string name="screen_notification_settings_sound_element_fade">"Element Fade"</string>
|
||||
<string name="screen_notification_settings_sound_import_sound_error_title">"匯入檔案時發生錯誤"</string>
|
||||
<string name="screen_notification_settings_sound_preview_sound_error_title">"預覽警示音時發生問題"</string>
|
||||
<string name="screen_notification_settings_sound_section_title">"音效"</string>
|
||||
<string name="screen_notification_settings_sound_set_sound_error_dismiss_a11y">"忽略警示音錯誤"</string>
|
||||
<string name="screen_notification_settings_sound_set_sound_error_title">"設定警示音時發生問題"</string>
|
||||
<string name="screen_notification_settings_sound_silent">"靜音"</string>
|
||||
<string name="screen_notification_settings_sound_system_default">"系統預設值"</string>
|
||||
<string name="screen_notification_settings_system_notifications_action_required">"要收到通知,請變更您的 %1$s。"</string>
|
||||
<string name="screen_notification_settings_system_notifications_action_required_content_link">"系統設定"</string>
|
||||
<string name="screen_notification_settings_system_notifications_turned_off">"已關閉系統通知"</string>
|
||||
<string name="screen_notification_settings_title">"通知"</string>
|
||||
<string name="screen_settings_user_status_away">"離開"</string>
|
||||
<string name="screen_settings_user_status_be_right_back">"稍後回來"</string>
|
||||
<string name="screen_settings_user_status_custom">"自訂⋯⋯"</string>
|
||||
<string name="screen_settings_user_status_custom_hint">"自訂狀態⋯⋯"</string>
|
||||
<string name="screen_settings_user_status_focus_time">"專注時間"</string>
|
||||
<string name="screen_settings_user_status_in_a_meeting">"在會議中"</string>
|
||||
<string name="screen_settings_user_status_on_the_road">"在路上"</string>
|
||||
<string name="screen_settings_user_status_placeholder">"您的狀態如何?"</string>
|
||||
<string name="theme_black">"黑色"</string>
|
||||
<string name="theme_dark">"深色"</string>
|
||||
<string name="theme_light">"淺色"</string>
|
||||
<string name="theme_system">"系統"</string>
|
||||
|
||||
+39
@@ -8,9 +8,11 @@
|
||||
package io.element.android.features.preferences.impl.userstatus
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import io.element.android.libraries.architecture.AsyncAction
|
||||
import io.element.android.libraries.emoji.test.fakeEmojiPickerPresenterFactory
|
||||
import io.element.android.libraries.matrix.api.user.DisplayedStatus
|
||||
import io.element.android.libraries.matrix.api.user.UserStatus
|
||||
import io.element.android.libraries.matrix.test.AN_EXCEPTION
|
||||
import io.element.android.libraries.matrix.test.FakeMatrixClient
|
||||
import io.element.android.tests.testutils.test
|
||||
import kotlinx.coroutines.test.runTest
|
||||
@@ -61,6 +63,24 @@ class UserStatusPresenterTest {
|
||||
val newState = awaitItem()
|
||||
assertThat(newState.pickerState).isEqualTo(UserStatusPickerState.Hidden)
|
||||
assertThat(newState.displayedStatus).isEqualTo(DisplayedStatus.UserSet(status))
|
||||
assertThat(newState.updateStatusAction).isInstanceOf(AsyncAction.Success::class.java)
|
||||
assertThat(client.setUserStatusCalled).isTrue()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Set event failure surfaces a Failure action`() = runTest {
|
||||
val client = FakeMatrixClient().apply {
|
||||
setUserStatusResult = Result.failure(AN_EXCEPTION)
|
||||
}
|
||||
createPresenter(client).test {
|
||||
val status = UserStatus(emoji = "💬", text = "In a meeting")
|
||||
awaitItem().eventSink(UserStatusEvent.SetStatus(status))
|
||||
val newState = awaitItem()
|
||||
assertThat(newState.pickerState).isEqualTo(UserStatusPickerState.Hidden)
|
||||
// On failure the SDK does not emit a new profile, so nothing is displayed.
|
||||
assertThat(newState.displayedStatus).isNull()
|
||||
assertThat(newState.updateStatusAction).isInstanceOf(AsyncAction.Failure::class.java)
|
||||
assertThat(client.setUserStatusCalled).isTrue()
|
||||
}
|
||||
}
|
||||
@@ -121,6 +141,25 @@ class UserStatusPresenterTest {
|
||||
val clearedState = awaitItem()
|
||||
assertThat(clearedState.pickerState).isEqualTo(UserStatusPickerState.Hidden)
|
||||
assertThat(clearedState.displayedStatus).isNull()
|
||||
assertThat(clearedState.updateStatusAction).isInstanceOf(AsyncAction.Success::class.java)
|
||||
assertThat(client.clearUserStatusCalled).isTrue()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Clear event failure surfaces a Failure action`() = runTest {
|
||||
val client = FakeMatrixClient().apply {
|
||||
clearUserStatusResult = Result.failure(AN_EXCEPTION)
|
||||
}
|
||||
createPresenter(client).test {
|
||||
val status = UserStatus(emoji = "☕", text = "Be right back")
|
||||
awaitItem().eventSink(UserStatusEvent.SetStatus(status))
|
||||
awaitItem().eventSink(UserStatusEvent.ClearStatus)
|
||||
val clearedState = awaitItem()
|
||||
assertThat(clearedState.pickerState).isEqualTo(UserStatusPickerState.Hidden)
|
||||
// Clear failed, so the previously set status is still displayed.
|
||||
assertThat(clearedState.displayedStatus).isEqualTo(DisplayedStatus.UserSet(status))
|
||||
assertThat(clearedState.updateStatusAction).isInstanceOf(AsyncAction.Failure::class.java)
|
||||
assertThat(client.clearUserStatusCalled).isTrue()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,8 @@
|
||||
<string name="screen_bug_report_editor_placeholder">"Descrieți problema…"</string>
|
||||
<string name="screen_bug_report_editor_supporting">"Dacă posibil, vă rugăm să scrieți descrierea în engleză."</string>
|
||||
<string name="screen_bug_report_error_description_too_short">"Descrierea este prea scurtă, vă rugăm să oferiți mai multe detalii despre ceea ce s-a întâmplat. Vă mulțumim!"</string>
|
||||
<string name="screen_bug_report_github_issue_description">"Puteți introduce numărul unui tichet GitHub asociat, dacă există."</string>
|
||||
<string name="screen_bug_report_github_issue_label">"Tichet GitHub"</string>
|
||||
<string name="screen_bug_report_include_crash_logs">"Trimiteți log-uri"</string>
|
||||
<string name="screen_bug_report_include_logs">"Permiteți log-uri"</string>
|
||||
<string name="screen_bug_report_include_logs_error">"Jurnalele dumneavoastră sunt prea mari și nu pot fi incluse în acest raport. Vă rugăm să ni le trimiteți prin altă metodă."</string>
|
||||
|
||||
@@ -8,6 +8,8 @@
|
||||
<string name="screen_bug_report_editor_placeholder">"描述問題…"</string>
|
||||
<string name="screen_bug_report_editor_supporting">"如果方便的話,請使用英文。"</string>
|
||||
<string name="screen_bug_report_error_description_too_short">"您的描述太短了,請提供更多細節。謝謝!"</string>
|
||||
<string name="screen_bug_report_github_issue_description">"若有相關的 GitHub 議題編號,請在此處輸入。"</string>
|
||||
<string name="screen_bug_report_github_issue_label">"GitHub 議題"</string>
|
||||
<string name="screen_bug_report_include_crash_logs">"傳送當機紀錄"</string>
|
||||
<string name="screen_bug_report_include_logs">"提供日誌"</string>
|
||||
<string name="screen_bug_report_include_logs_error">"您的紀錄檔太大了,無法包含在此報告中,請透過其他方式傳送給我們。"</string>
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
<string name="screen_room_change_permissions_delete_messages">"移除訊息"</string>
|
||||
<string name="screen_room_change_permissions_everyone">"成員"</string>
|
||||
<string name="screen_room_change_permissions_invite_people">"邀請夥伴"</string>
|
||||
<string name="screen_room_change_permissions_live_location">"分享即時位置"</string>
|
||||
<string name="screen_room_change_permissions_manage_space">"管理空間"</string>
|
||||
<string name="screen_room_change_permissions_manage_space_rooms">"管理聊天室"</string>
|
||||
<string name="screen_room_change_permissions_member_moderation">"管理成員"</string>
|
||||
|
||||
+35
-11
@@ -37,6 +37,7 @@ import io.element.android.libraries.matrix.api.room.roomMembers
|
||||
import io.element.android.libraries.matrix.api.room.toMatrixUser
|
||||
import io.element.android.libraries.matrix.ui.room.PowerLevelRoomMemberComparator
|
||||
import io.element.android.libraries.matrix.ui.room.roomMemberIdentityStateChange
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.ImmutableMap
|
||||
import kotlinx.collections.immutable.persistentMapOf
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
@@ -58,6 +59,7 @@ class RoomMemberListPresenter(
|
||||
override fun present(): RoomMemberListState {
|
||||
val searchQuery = rememberTextFieldState()
|
||||
val membersState by room.membersStateFlow.collectAsState()
|
||||
val roomInfo by room.roomInfoFlow.collectAsState()
|
||||
val canInvite by room.permissionsAsState(false) { perms -> perms.canOwnUserInvite() }
|
||||
val roomModerationState = roomMembersModerationPresenter.present()
|
||||
|
||||
@@ -72,13 +74,18 @@ class RoomMemberListPresenter(
|
||||
var selectedSection by remember { mutableStateOf(SelectedSection.MEMBERS) }
|
||||
var roomMembers: AsyncData<RoomMembers> by remember { mutableStateOf(AsyncData.Loading()) }
|
||||
var filteredRoomMembers: AsyncData<RoomMembers> by remember { mutableStateOf(AsyncData.Loading()) }
|
||||
val activeCallParticipants = roomInfo.activeRoomCallParticipants
|
||||
|
||||
val comparator = remember(activeCallParticipants) {
|
||||
roomMemberListComparator(activeCallParticipants)
|
||||
}
|
||||
|
||||
// Update the room members when the screen is loaded
|
||||
LaunchedEffect(Unit) {
|
||||
room.updateMembers()
|
||||
}
|
||||
|
||||
LaunchedEffect(membersState, roomMemberIdentityStates) {
|
||||
LaunchedEffect(membersState, roomMemberIdentityStates, activeCallParticipants) {
|
||||
if (membersState is RoomMembersState.Unknown) {
|
||||
return@LaunchedEffect
|
||||
}
|
||||
@@ -90,23 +97,23 @@ class RoomMemberListPresenter(
|
||||
}
|
||||
withContext(coroutineDispatchers.io) {
|
||||
val members = membersState.roomMembers().orEmpty().groupBy { it.membership }
|
||||
val info = room.info()
|
||||
if (members.getOrDefault(RoomMembershipState.JOIN, emptyList()).size < info.joinedMembersCount / 2) {
|
||||
if (members.getOrDefault(RoomMembershipState.JOIN, emptyList()).size < roomInfo.joinedMembersCount / 2) {
|
||||
// Don't display initial room member list if we have less than half of the joined members:
|
||||
// This result will come from the timeline loading membership events and it'll be wrong.
|
||||
// This result will come from the timeline loading membership events, and it'll be wrong.
|
||||
return@withContext
|
||||
}
|
||||
val result = RoomMembers(
|
||||
invited = members.getOrDefault(RoomMembershipState.INVITE, emptyList())
|
||||
.map { it.withIdentityState(roomMemberIdentityStates) }
|
||||
.map { it.withIdentityState(roomMemberIdentityStates, activeCallParticipants) }
|
||||
.toImmutableList(),
|
||||
joined = members.getOrDefault(RoomMembershipState.JOIN, emptyList())
|
||||
.sortedWith(powerLevelRoomMemberComparator)
|
||||
.map { it.withIdentityState(roomMemberIdentityStates) }
|
||||
.sortedWith(comparator)
|
||||
// .sortedWith(powerLevelRoomMemberComparator)
|
||||
.map { it.withIdentityState(roomMemberIdentityStates, activeCallParticipants) }
|
||||
.toImmutableList(),
|
||||
banned = members.getOrDefault(RoomMembershipState.BAN, emptyList())
|
||||
.sortedBy { it.userId.value }
|
||||
.map { it.withIdentityState(roomMemberIdentityStates) }
|
||||
.map { it.withIdentityState(roomMemberIdentityStates, activeCallParticipants) }
|
||||
.toImmutableList(),
|
||||
)
|
||||
roomMembers = if (membersState is RoomMembersState.Pending) {
|
||||
@@ -150,12 +157,29 @@ class RoomMemberListPresenter(
|
||||
return state
|
||||
}
|
||||
|
||||
private suspend fun RoomMember.withIdentityState(identityStates: ImmutableMap<UserId, IdentityState>): RoomMemberWithIdentityState {
|
||||
private suspend fun RoomMember.withIdentityState(
|
||||
identityStates: ImmutableMap<UserId, IdentityState>,
|
||||
activeCallParticipants: ImmutableList<UserId>
|
||||
): RoomMemberWithIdentityState {
|
||||
return if (room.info().isEncrypted != true) {
|
||||
RoomMemberWithIdentityState(this, null)
|
||||
RoomMemberWithIdentityState(this, null, userId in activeCallParticipants)
|
||||
} else {
|
||||
val identityState = identityStates[userId] ?: encryptionService.getUserIdentity(userId, fallbackToServer = false).getOrNull()
|
||||
RoomMemberWithIdentityState(this, identityState)
|
||||
RoomMemberWithIdentityState(this, identityState, userId in activeCallParticipants)
|
||||
}
|
||||
}
|
||||
|
||||
private fun roomMemberListComparator(
|
||||
activeCallParticipants: List<UserId>,
|
||||
): Comparator<RoomMember> =
|
||||
Comparator { a, b ->
|
||||
val aInCall = a.userId in activeCallParticipants
|
||||
val bInCall = b.userId in activeCallParticipants
|
||||
|
||||
when {
|
||||
aInCall && !bInCall -> -1
|
||||
!aInCall && bInCall -> 1
|
||||
else -> powerLevelRoomMemberComparator.compare(a, b)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+1
@@ -66,4 +66,5 @@ data class RoomMembers(
|
||||
data class RoomMemberWithIdentityState(
|
||||
val roomMember: RoomMember,
|
||||
val identityState: IdentityState?,
|
||||
val isInCall: Boolean
|
||||
)
|
||||
|
||||
+6
-2
@@ -78,7 +78,7 @@ private fun aLoadedRoomMembers() = AsyncData.Success(
|
||||
joined = persistentListOf(
|
||||
anAlice().withIdentity(identityState = IdentityState.Verified),
|
||||
aBob().withIdentity(identityState = IdentityState.PinViolation),
|
||||
aCarol().withIdentity(),
|
||||
aCarol().withIdentity(isInCall = true),
|
||||
aDavid().withIdentity(),
|
||||
anEve().withIdentity(identityState = IdentityState.VerificationViolation)
|
||||
),
|
||||
@@ -174,4 +174,8 @@ fun aBannedSusie(): RoomMember = aRoomMember(UserId("@susie:server.org"), USER_N
|
||||
|
||||
fun aBannedMallory(): RoomMember = aRoomMember(UserId("@mallory:server.org"), USER_NAME_MALLORY, membership = RoomMembershipState.BAN)
|
||||
|
||||
private fun RoomMember.withIdentity(identityState: IdentityState? = null) = RoomMemberWithIdentityState(this, identityState)
|
||||
private fun RoomMember.withIdentity(identityState: IdentityState? = null, isInCall: Boolean = false) = RoomMemberWithIdentityState(
|
||||
this,
|
||||
identityState,
|
||||
isInCall
|
||||
)
|
||||
|
||||
+9
@@ -338,6 +338,15 @@ private fun RoomMemberListItem(
|
||||
color = ElementTheme.colors.textSecondary,
|
||||
)
|
||||
}
|
||||
|
||||
if (roomMemberWithIdentity.isInCall) {
|
||||
Icon(
|
||||
modifier = Modifier.size(20.dp),
|
||||
imageVector = CompoundIcons.VideoCallSolid(),
|
||||
contentDescription = stringResource(CommonStrings.action_video_call),
|
||||
tint = ElementTheme.colors.iconSuccessPrimary
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
+32
@@ -191,6 +191,38 @@ class RoomMemberListPresenterTest {
|
||||
awaitItem().eventSink(RoomMemberListEvent.RoomMemberSelected(anInvitedVictor()))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `call participants are shown first in joined members`() = runTest {
|
||||
val room = createFakeJoinedRoom().apply {
|
||||
givenRoomInfo(
|
||||
aRoomInfo(
|
||||
joinedMembersCount = 5,
|
||||
activeRoomCallParticipants = listOf(
|
||||
aCarol().userId
|
||||
),
|
||||
)
|
||||
)
|
||||
givenRoomMembersState(RoomMembersState.Ready(aRoomMemberList()))
|
||||
}
|
||||
|
||||
val presenter = createPresenter(joinedRoom = room)
|
||||
|
||||
presenter.test {
|
||||
skipItems(1)
|
||||
|
||||
val loadedState = awaitItem()
|
||||
val joined = loadedState.filteredRoomMembers.dataOrNull()!!.joined
|
||||
|
||||
assertThat(joined.first().roomMember.userId)
|
||||
.isEqualTo(aCarol().userId)
|
||||
|
||||
assertThat(joined.first().isInCall).isTrue()
|
||||
|
||||
assertThat(joined.count { it.isInCall }).isEqualTo(1)
|
||||
assertThat(joined.drop(1).all { !it.isInCall }).isTrue()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun createFakeJoinedRoom(
|
||||
|
||||
@@ -8,10 +8,11 @@
|
||||
<string name="screen_chat_backup_key_storage_toggle_description">"Schlüssel von diesem Gerät hochladen"</string>
|
||||
<string name="screen_chat_backup_key_storage_toggle_title">"Schlüsselspeicherung zulassen"</string>
|
||||
<string name="screen_chat_backup_recovery_action_change">"Wiederherstellungsschlüssel ändern"</string>
|
||||
<string name="screen_chat_backup_recovery_action_change_description">"Stelle deine kryptographische Identität und deinen Nachrichtenverlauf mit einem Wiederherstellungsschlüssel wieder her, falls du deine Geräte verloren hast."</string>
|
||||
<string name="screen_chat_backup_recovery_action_change_description">"Von deinen Chats wird automatisch ein Ende-zu-Ende-verschlüsseltes Backup erstellt. Wenn du den Zugriff auf alle deine Geräte verlierst, brauchst du deinen Wiederherstellungsschlüssel, um dieses Backup wiederherzustellen und deine digitale Identität zu behalten."</string>
|
||||
<string name="screen_chat_backup_recovery_action_confirm">"Wiederherstellungsschlüssel eingeben"</string>
|
||||
<string name="screen_chat_backup_recovery_action_confirm_description">"Dein Schlüssel ist derzeit nicht synchronisiert."</string>
|
||||
<string name="screen_chat_backup_recovery_action_setup">"Wiederherstellungsschlüssel einrichten"</string>
|
||||
<string name="screen_chat_backup_recovery_action_setup_description">"Von deinen Chats wird automatisch ein Ende-zu-Ende-verschlüsseltes Backup erstellt. Wenn du den Zugriff auf alle deine Geräte verlierst, brauchst du deinen Wiederherstellungsschlüssel, um dieses Backup wiederherzustellen und deine digitale Identität zu behalten."</string>
|
||||
<string name="screen_create_new_recovery_key_list_item_1">
|
||||
"Öffne "
|
||||
<b>"%1$s"</b>
|
||||
|
||||
@@ -8,11 +8,11 @@
|
||||
<string name="screen_chat_backup_key_storage_toggle_description">"Prześlij klucze z tego urządzenia"</string>
|
||||
<string name="screen_chat_backup_key_storage_toggle_title">"Zezwól na magazynowanie kluczy"</string>
|
||||
<string name="screen_chat_backup_recovery_action_change">"Zmień klucz przywracania"</string>
|
||||
<string name="screen_chat_backup_recovery_action_change_description">"Twoje czaty są automatycznie archiwizowane za pomocą szyfrowania end-to-end. Aby przywrócić tę kopię zapasową i swoją tożsamość cyfrową, wymagany będzie klucz przywracania."</string>
|
||||
<string name="screen_chat_backup_recovery_action_change_description">"Twoje czaty są automatycznie archiwizowane za pomocą szyfrowania end-to-end. Aby przywrócić kopię zapasową i swoją tożsamość cyfrową, wymagany będzie klucz przywracania."</string>
|
||||
<string name="screen_chat_backup_recovery_action_confirm">"Wprowadź klucz przywracania"</string>
|
||||
<string name="screen_chat_backup_recovery_action_confirm_description">"Magazyn kluczy nie jest zsynchronizowany."</string>
|
||||
<string name="screen_chat_backup_recovery_action_setup">"Uzyskaj klucz przywracania"</string>
|
||||
<string name="screen_chat_backup_recovery_action_setup_description">"Twoje czaty są automatycznie archiwizowane za pomocą szyfrowania end-to-end. Aby przywrócić tę kopię zapasową i swoją tożsamość cyfrową, wymagany będzie klucz przywracania."</string>
|
||||
<string name="screen_chat_backup_recovery_action_setup_description">"Twoje czaty są automatycznie archiwizowane za pomocą szyfrowania end-to-end. Aby przywrócić kopię zapasową i swoją tożsamość cyfrową, wymagany będzie klucz przywracania."</string>
|
||||
<string name="screen_create_new_recovery_key_list_item_1">"Otwórz %1$s na urządzeniu stacjonarnym"</string>
|
||||
<string name="screen_create_new_recovery_key_list_item_2">"Zaloguj się ponownie na swoje konto"</string>
|
||||
<string name="screen_create_new_recovery_key_list_item_3">"Gdy pojawi się prośba o weryfikację urządzenia, wybierz %1$s"</string>
|
||||
|
||||
@@ -8,11 +8,11 @@
|
||||
<string name="screen_chat_backup_key_storage_toggle_description">"Încărcați cheile de pe acest dispozitiv"</string>
|
||||
<string name="screen_chat_backup_key_storage_toggle_title">"Permiteți stocarea cheilor"</string>
|
||||
<string name="screen_chat_backup_recovery_action_change">"Schimbați cheia de recuperare"</string>
|
||||
<string name="screen_chat_backup_recovery_action_change_description">"Mesajele dumneavoastră sunt copiate automat cu criptare end-to-end. Pentru a restaura această copie de rezervă și a vă păstra identitatea digitală atunci când pierdeți accesul la toate dispozitivele dumneavoastră, veți avea nevoie de cheia de recuperare."</string>
|
||||
<string name="screen_chat_backup_recovery_action_change_description">"Mesajele dumneavoastră sunt copiate automat cu criptare end-to-end. Pentru a restaura aceast backup și a vă păstra identitatea digitală atunci când pierdeți accesul la toate dispozitivele dumneavoastră, veți avea nevoie de cheia de recuperare."</string>
|
||||
<string name="screen_chat_backup_recovery_action_confirm">"Introduceți cheia de recuperare"</string>
|
||||
<string name="screen_chat_backup_recovery_action_confirm_description">"Backup-ul pentru chat nu este sincronizat în prezent."</string>
|
||||
<string name="screen_chat_backup_recovery_action_setup">"Obțineți cheia de recuperare"</string>
|
||||
<string name="screen_chat_backup_recovery_action_setup_description">"Chaturile dumneavoastră sunt salvate automat cu criptare end-to-end. Pentru a restaura această copie de rezervă și a vă păstra identitatea digitală atunci când pierdeți accesul la toate dispozitivele dumneavoastră, veți avea nevoie de cheia de recuperare."</string>
|
||||
<string name="screen_chat_backup_recovery_action_setup_description">"Chaturile dumneavoastră sunt salvate automat cu criptare end-to-end. Pentru a restaura această backup și a vă păstra identitatea digitală atunci când pierdeți accesul la toate dispozitivele dumneavoastră, veți avea nevoie de cheia de recuperare."</string>
|
||||
<string name="screen_create_new_recovery_key_list_item_1">"Deschideți %1$s pe un dispozitiv desktop"</string>
|
||||
<string name="screen_create_new_recovery_key_list_item_2">"Conectați-vă din nou la contul dumneavoastră"</string>
|
||||
<string name="screen_create_new_recovery_key_list_item_3">"Când vi se cere să vă verificați dispozitivul, selectați%1$s"</string>
|
||||
@@ -20,6 +20,28 @@
|
||||
<string name="screen_create_new_recovery_key_list_item_4">"Urmați instrucțiunile pentru a crea o nouă cheie de recuperare"</string>
|
||||
<string name="screen_create_new_recovery_key_list_item_5">"Salvați noua cheie de recuperare într-un manager de parole sau o notă criptată"</string>
|
||||
<string name="screen_create_new_recovery_key_title">"Resetați criptarea contului dumneavoastră folosind un alt dispozitiv"</string>
|
||||
<string name="screen_custom_recovery_key_confirm_error_mismatch">"Cheia de recuperare introdusă nu se potrivește"</string>
|
||||
<string name="screen_custom_recovery_key_confirm_submit">"Finalizați configurarea"</string>
|
||||
<string name="screen_custom_recovery_key_confirm_subtitle">"Introduceți din nou cheia de recuperare."</string>
|
||||
<string name="screen_custom_recovery_key_confirm_title">"Confirmați cheia de recuperare"</string>
|
||||
<plurals name="screen_custom_recovery_key_input_notice">
|
||||
<item quantity="one">"Minim %1$d caracter. Nu folosiți parola contului dvs."</item>
|
||||
<item quantity="few">"Minim %1$d caractere. Nu folosiți parola contului dvs."</item>
|
||||
<item quantity="other">"Minim %1$d caractere. Nu folosiți parola contului dvs."</item>
|
||||
</plurals>
|
||||
<string name="screen_custom_recovery_key_input_strength">"Rezistenţă"</string>
|
||||
<string name="screen_custom_recovery_key_input_strength_a11y">"Rezistența parolei: %1$s"</string>
|
||||
<string name="screen_custom_recovery_key_input_strength_moderate">"Moderată"</string>
|
||||
<string name="screen_custom_recovery_key_input_strength_strong">"Puternică"</string>
|
||||
<string name="screen_custom_recovery_key_input_strength_very_strong">"Foarte puternică"</string>
|
||||
<string name="screen_custom_recovery_key_input_strength_very_weak">"Foarte slabă"</string>
|
||||
<string name="screen_custom_recovery_key_input_strength_weak">"Slabă"</string>
|
||||
<string name="screen_custom_recovery_key_input_subtitle">"Alegeți o cheie de recuperare pe care o puteți memora."</string>
|
||||
<string name="screen_custom_recovery_key_input_title">"Introduceți o cheie de recuperare"</string>
|
||||
<string name="screen_custom_recovery_key_loading_a11y">"Se încarcă cerințele cheii de recuperare"</string>
|
||||
<string name="screen_custom_recovery_key_success_notice">"Pentru a schimba cheia de recuperare, accesați Setări → Criptare → Backup"</string>
|
||||
<string name="screen_custom_recovery_key_success_subtitle">"Puteți folosi cheia de recuperare pentru a confirma dispozitive noi sau pentru a restaura chat-urile criptate dacă pierdeți accesul la toate dispozitivele."</string>
|
||||
<string name="screen_custom_recovery_key_success_title">"Backup-ul dumneavoastră este acum complet configurat"</string>
|
||||
<string name="screen_encryption_reset_action_continue_reset">"Continuați resetarea"</string>
|
||||
<string name="screen_encryption_reset_bullet_1">"Detaliile contului, contactele, preferințele și lista de chat vor fi păstrate"</string>
|
||||
<string name="screen_encryption_reset_bullet_2">"Veți pierde mesajele anterioare care au fost stocate doar pe server"</string>
|
||||
|
||||
@@ -20,6 +20,26 @@
|
||||
<string name="screen_create_new_recovery_key_list_item_4">"按照說明建立新復原金鑰"</string>
|
||||
<string name="screen_create_new_recovery_key_list_item_5">"將您的新復原金鑰儲存在密碼管理程式或加密筆記中"</string>
|
||||
<string name="screen_create_new_recovery_key_title">"使用其他裝置重設您帳號的加密"</string>
|
||||
<string name="screen_custom_recovery_key_confirm_error_mismatch">"您輸入的復原金鑰不符合"</string>
|
||||
<string name="screen_custom_recovery_key_confirm_submit">"完成設定"</string>
|
||||
<string name="screen_custom_recovery_key_confirm_subtitle">"請再次輸入您的復原金鑰。"</string>
|
||||
<string name="screen_custom_recovery_key_confirm_title">"確認您的復原金鑰"</string>
|
||||
<plurals name="screen_custom_recovery_key_input_notice">
|
||||
<item quantity="other">"最少 %1$d 個字元。請勿使用您的帳號密碼。"</item>
|
||||
</plurals>
|
||||
<string name="screen_custom_recovery_key_input_strength">"強度"</string>
|
||||
<string name="screen_custom_recovery_key_input_strength_a11y">"密碼短語強度:%1$s"</string>
|
||||
<string name="screen_custom_recovery_key_input_strength_moderate">"中等"</string>
|
||||
<string name="screen_custom_recovery_key_input_strength_strong">"強"</string>
|
||||
<string name="screen_custom_recovery_key_input_strength_very_strong">"非常強"</string>
|
||||
<string name="screen_custom_recovery_key_input_strength_very_weak">"非常弱"</string>
|
||||
<string name="screen_custom_recovery_key_input_strength_weak">"弱"</string>
|
||||
<string name="screen_custom_recovery_key_input_subtitle">"請輸入您可以記住的復原金鑰。"</string>
|
||||
<string name="screen_custom_recovery_key_input_title">"輸入復原金鑰"</string>
|
||||
<string name="screen_custom_recovery_key_loading_a11y">"正在載入復原金鑰需求"</string>
|
||||
<string name="screen_custom_recovery_key_success_notice">"若要變更復原金鑰,請前往「設定」→「加密」→「備份」"</string>
|
||||
<string name="screen_custom_recovery_key_success_subtitle">"若您無法存取您所有的裝置,可以使用復原金鑰來確認新裝置或還原加密聊天紀錄。"</string>
|
||||
<string name="screen_custom_recovery_key_success_title">"您的備份已設定完成"</string>
|
||||
<string name="screen_encryption_reset_action_continue_reset">"繼續重設"</string>
|
||||
<string name="screen_encryption_reset_bullet_1">"您的帳號詳細資訊、聯絡人、偏好設定與聊天清單都會保留"</string>
|
||||
<string name="screen_encryption_reset_bullet_2">"您將會遺失僅儲存在伺服器上的任何訊息歷史紀錄"</string>
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
<string name="screen_start_chat_error_starting_chat">"Beim Versuch, einen Chat zu starten, ist ein Fehler aufgetreten"</string>
|
||||
<string name="screen_start_chat_join_room_by_address_action">"Chat per Adresse beitreten"</string>
|
||||
<string name="screen_start_chat_join_room_by_address_invalid_address">"Keine gültige Adresse"</string>
|
||||
<string name="screen_start_chat_join_room_by_address_placeholder">"Eintreten…"</string>
|
||||
<string name="screen_start_chat_join_room_by_address_placeholder">"Chatraum Adresse eingeben …"</string>
|
||||
<string name="screen_start_chat_join_room_by_address_room_found">"Passender Chat gefunden"</string>
|
||||
<string name="screen_start_chat_join_room_by_address_room_not_found">"Chat nicht gefunden"</string>
|
||||
<string name="screen_start_chat_join_room_by_address_supporting_text">"z. B. #room -name:matrix.org"</string>
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
<string name="screen_start_chat_error_starting_chat">"A apărut o eroare la încercarea începerii conversației"</string>
|
||||
<string name="screen_start_chat_join_room_by_address_action">"Gasiți o cameră după adresă"</string>
|
||||
<string name="screen_start_chat_join_room_by_address_invalid_address">"Adresa nu e este validă"</string>
|
||||
<string name="screen_start_chat_join_room_by_address_placeholder">"Introduceți…"</string>
|
||||
<string name="screen_start_chat_join_room_by_address_placeholder">"Introduceți adresa…"</string>
|
||||
<string name="screen_start_chat_join_room_by_address_room_found">"S-a găsit o cameră"</string>
|
||||
<string name="screen_start_chat_join_room_by_address_room_not_found">"Nu a putut fi găsită nici o cameră"</string>
|
||||
<string name="screen_start_chat_join_room_by_address_supporting_text">"de exemplu #nume-camera:matrix.org"</string>
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
<string name="screen_start_chat_error_starting_chat">"嘗試開始聊天時發生錯誤"</string>
|
||||
<string name="screen_start_chat_join_room_by_address_action">"按地址加入聊天室"</string>
|
||||
<string name="screen_start_chat_join_room_by_address_invalid_address">"不是有效的位址"</string>
|
||||
<string name="screen_start_chat_join_room_by_address_placeholder">"輸入……"</string>
|
||||
<string name="screen_start_chat_join_room_by_address_placeholder">"輸入地址⋯⋯"</string>
|
||||
<string name="screen_start_chat_join_room_by_address_room_found">"找到相符的聊天室"</string>
|
||||
<string name="screen_start_chat_join_room_by_address_room_not_found">"找不到聊天室"</string>
|
||||
<string name="screen_start_chat_join_room_by_address_supporting_text">"例如 #room-name:matrix.org"</string>
|
||||
|
||||
@@ -11,16 +11,16 @@
|
||||
<string name="screen_identity_use_another_device">"Użyj innego urządzenia"</string>
|
||||
<string name="screen_identity_waiting_on_other_device">"Oczekiwanie na inne urządzenie…"</string>
|
||||
<string name="screen_session_verification_cancelled_subtitle">"Coś tu nie gra. Albo upłynął limit czasu, albo żądanie zostało odrzucone."</string>
|
||||
<string name="screen_session_verification_compare_emojis_subtitle">"Upewnij się, że emoji poniżej pasują do tych pokazanych na innej sesji."</string>
|
||||
<string name="screen_session_verification_compare_emojis_subtitle">"Upewnij się, że zestaw emoji poniżej pasuje do tych na drugiej sesji."</string>
|
||||
<string name="screen_session_verification_compare_emojis_title">"Porównaj emoji"</string>
|
||||
<string name="screen_session_verification_compare_emojis_user_subtitle">"Upewnij się, że emoji poniżej odpowiadają tym na urządzeniu drugiego użytkownika."</string>
|
||||
<string name="screen_session_verification_compare_numbers_subtitle">"Upewnij się, że liczby poniżej pasują do tych wyświetlanych na innej sesji."</string>
|
||||
<string name="screen_session_verification_compare_numbers_subtitle">"Upewnij się, że liczby poniżej pasują do tych wyświetlanych na drugiej sesji."</string>
|
||||
<string name="screen_session_verification_compare_numbers_title">"Porównaj liczby"</string>
|
||||
<string name="screen_session_verification_complete_subtitle">"Teraz możesz bezpiecznie czytać i wysyłać wiadomości na swoim drugim urządzeniu."</string>
|
||||
<string name="screen_session_verification_complete_user_subtitle">"Teraz możesz zaufać tożsamości tego użytkownika podczas wysyłania lub odbierania wiadomości."</string>
|
||||
<string name="screen_session_verification_device_verified">"Urządzenie zweryfikowane"</string>
|
||||
<string name="screen_session_verification_enter_recovery_key">"Wprowadź klucz przywracania"</string>
|
||||
<string name="screen_session_verification_failed_subtitle">"Albo upłynął limit czasu żądania, albo żądanie zostało odrzucone, albo wystąpił błąd weryfikacji."</string>
|
||||
<string name="screen_session_verification_failed_subtitle">"Upłynął limit czasu żądania, żądanie zostało odrzucone lub wystąpił błąd weryfikacji."</string>
|
||||
<string name="screen_session_verification_open_existing_session_subtitle">"Udowodnij, że to ty, aby uzyskać dostęp do historii zaszyfrowanych wiadomości."</string>
|
||||
<string name="screen_session_verification_open_existing_session_title">"Otwórz istniejącą sesję"</string>
|
||||
<string name="screen_session_verification_positive_button_canceled">"Ponów weryfikację"</string>
|
||||
@@ -29,9 +29,9 @@
|
||||
<string name="screen_session_verification_ready_subtitle">"Porównaj unikalny zestaw emoji."</string>
|
||||
<string name="screen_session_verification_request_accepted_subtitle">"Porównaj unikalny zestaw emoji i upewnij się, że są w tej samej kolejności."</string>
|
||||
<string name="screen_session_verification_request_details_timestamp">"Zalogowano"</string>
|
||||
<string name="screen_session_verification_request_failure_subtitle">"Albo upłynął limit czasu żądania, albo żądanie zostało odrzucone, albo wystąpił błąd weryfikacji."</string>
|
||||
<string name="screen_session_verification_request_failure_subtitle">"Upłynął limit czasu żądania, żądanie zostało odrzucone lub wystąpił błąd weryfikacji."</string>
|
||||
<string name="screen_session_verification_request_failure_title">"Weryfikacja nie powiodła się"</string>
|
||||
<string name="screen_session_verification_request_footer">"Kontynuuj tylko, jeśli to Ty zainicjowałeś tę weryfikację."</string>
|
||||
<string name="screen_session_verification_request_footer">"Kontynuuj tylko jeśli to Ty zainicjowałeś proces weryfikacji."</string>
|
||||
<string name="screen_session_verification_request_subtitle">"Zweryfikuj drugie urządzenie, aby zabezpieczyć historię wiadomości."</string>
|
||||
<string name="screen_session_verification_request_success_subtitle">"Teraz możesz bezpiecznie czytać i wysyłać wiadomości na swoim drugim urządzeniu."</string>
|
||||
<string name="screen_session_verification_request_success_title">"Urządzenie zweryfikowane"</string>
|
||||
|
||||
@@ -270,7 +270,7 @@ metro = { id = "dev.zacsweers.metro", version.ref = "metro" }
|
||||
detekt = { id = "io.gitlab.arturbosch.detekt", version.ref = "detekt" }
|
||||
ktlint = "org.jlleitschuh.gradle.ktlint:14.2.0"
|
||||
dependencygraph = "com.savvasdalkitsis.module-dependency-graph:0.12"
|
||||
dependencycheck = "org.owasp.dependencycheck:12.2.2"
|
||||
dependencycheck = "org.owasp.dependencycheck:13.0.0"
|
||||
dependencyanalysis = { id = "com.autonomousapps.dependency-analysis", version.ref = "dependencyAnalysis" }
|
||||
paparazzi = "app.cash.paparazzi:2.0.0-alpha05"
|
||||
roborazzi = { id = "io.github.takahirom.roborazzi", version.ref = "roborazzi" }
|
||||
|
||||
+2
@@ -13,3 +13,5 @@ import java.nio.charset.Charset
|
||||
|
||||
fun String.urlEncoded(charset: Charset = Charsets.UTF_8): String = URLEncoder.encode(this, charset.name())
|
||||
fun String.urlDecoded(charset: Charset = Charsets.UTF_8): String = URLDecoder.decode(this, charset.name())
|
||||
|
||||
fun String.takeIfNotBlank(): String? = ifBlank { null }
|
||||
|
||||
+114
@@ -0,0 +1,114 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Element Creations Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.libraries.designsystem.components.async
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberUpdatedState
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import io.element.android.libraries.architecture.AsyncAction
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlin.time.Duration
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
import kotlin.time.TimeMark
|
||||
import kotlin.time.TimeSource
|
||||
|
||||
private val DefaultShowDelay = 300.milliseconds
|
||||
private val DefaultMinDisplayDuration = 500.milliseconds
|
||||
private val DefaultFailureDuration = 500.milliseconds
|
||||
|
||||
/**
|
||||
* An [AsyncIndicatorHost] driven by an [AsyncAction], with anti-flicker timing.
|
||||
*
|
||||
* While [asyncAction] is [AsyncAction.Loading], [loading] is displayed, but only if the operation
|
||||
* runs longer than [showDelay]; once shown it stays visible for at least [minDisplayDuration]. This
|
||||
* prevents the indicator from flashing for operations that complete almost instantly.
|
||||
*
|
||||
* When [asyncAction] is [AsyncAction.Failure], [failure] is displayed immediately (failures are
|
||||
* never debounced) and auto-dismisses after [failureDuration].
|
||||
*
|
||||
* @param asyncAction the action whose progress drives the indicator.
|
||||
* @param loading the composable to display while loading.
|
||||
* @param failure the composable to display on failure.
|
||||
* @param modifier the modifier to apply to the underlying [AsyncIndicatorHost].
|
||||
* @param state the [AsyncIndicatorState] backing the host.
|
||||
* @param showDelay how long the loading state must persist before the indicator is shown.
|
||||
* @param minDisplayDuration the minimum duration the loading indicator stays visible once shown.
|
||||
* @param failureDuration how long the failure indicator stays visible before auto-dismissing.
|
||||
*/
|
||||
@Composable
|
||||
fun AsyncActionIndicator(
|
||||
asyncAction: AsyncAction<*>,
|
||||
loading: @Composable () -> Unit,
|
||||
failure: @Composable (Throwable) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
state: AsyncIndicatorState = rememberAsyncIndicatorState(),
|
||||
showDelay: Duration = DefaultShowDelay,
|
||||
minDisplayDuration: Duration = DefaultMinDisplayDuration,
|
||||
failureDuration: Duration = DefaultFailureDuration,
|
||||
) {
|
||||
AsyncIndicatorHost(modifier = modifier, state = state)
|
||||
AsyncActionIndicatorEffect(
|
||||
asyncAction = asyncAction,
|
||||
state = state,
|
||||
showDelay = showDelay,
|
||||
minDisplayDuration = minDisplayDuration,
|
||||
failureDuration = failureDuration,
|
||||
loading = loading,
|
||||
failure = failure,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun AsyncActionIndicatorEffect(
|
||||
asyncAction: AsyncAction<*>,
|
||||
state: AsyncIndicatorState,
|
||||
showDelay: Duration,
|
||||
minDisplayDuration: Duration,
|
||||
failureDuration: Duration,
|
||||
loading: @Composable () -> Unit,
|
||||
failure: @Composable (Throwable) -> Unit,
|
||||
timeSource: TimeSource = TimeSource.Monotonic,
|
||||
) {
|
||||
// Referenced inside the restarting LaunchedEffect below, so keep them up to date.
|
||||
val currentLoading by rememberUpdatedState(loading)
|
||||
val currentFailure by rememberUpdatedState(failure)
|
||||
// Mark set when the loader actually becomes visible, null while it is hidden.
|
||||
var loaderShownAt by remember { mutableStateOf<TimeMark?>(null) }
|
||||
LaunchedEffect(asyncAction) {
|
||||
when (asyncAction) {
|
||||
is AsyncAction.Loading -> {
|
||||
// If the operation completes before this delay elapses, the effect is cancelled
|
||||
// and the loader is never shown.
|
||||
delay(showDelay)
|
||||
state.enqueue(composable = currentLoading)
|
||||
loaderShownAt = timeSource.markNow()
|
||||
}
|
||||
is AsyncAction.Failure -> {
|
||||
// Always surface failures immediately, even if the loader was never shown.
|
||||
val error = asyncAction.error
|
||||
state.enqueue(durationMs = failureDuration.inWholeMilliseconds, composable = { currentFailure(error) })
|
||||
loaderShownAt = null
|
||||
}
|
||||
is AsyncAction.Success -> {
|
||||
// Hold the loader for its minimum duration before clearing it.
|
||||
loaderShownAt?.let { shownAt ->
|
||||
val remaining = minDisplayDuration - shownAt.elapsedNow()
|
||||
if (remaining > Duration.ZERO) delay(remaining)
|
||||
}
|
||||
state.clear()
|
||||
loaderShownAt = null
|
||||
}
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
}
|
||||
+123
@@ -0,0 +1,123 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Element Creations Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.libraries.designsystem.component.async
|
||||
|
||||
import androidx.compose.runtime.State
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
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.AsyncAction
|
||||
import io.element.android.libraries.designsystem.components.async.AsyncActionIndicatorEffect
|
||||
import io.element.android.libraries.designsystem.components.async.AsyncIndicatorState
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.test.advanceTimeBy
|
||||
import kotlinx.coroutines.test.runCurrent
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Test
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
import kotlin.time.TestTimeSource
|
||||
import kotlin.time.TimeSource
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
class AsyncActionIndicatorTest {
|
||||
private val showDelay = 300.milliseconds
|
||||
private val minDisplay = 500.milliseconds
|
||||
|
||||
@Test
|
||||
fun `loader is not shown before the show delay elapses`() = runTest {
|
||||
val state = AsyncIndicatorState()
|
||||
val action = mutableStateOf<AsyncAction<*>>(AsyncAction.Uninitialized)
|
||||
indicatorFlow(action, state).test {
|
||||
action.value = AsyncAction.Loading
|
||||
advanceTimeBy(100.milliseconds)
|
||||
runCurrent()
|
||||
assertThat(state.currentAnimationState.targetState).isFalse()
|
||||
// Once the show delay has fully elapsed, the loader is shown.
|
||||
advanceTimeBy(300.milliseconds)
|
||||
runCurrent()
|
||||
assertThat(state.currentAnimationState.targetState).isTrue()
|
||||
cancelAndIgnoreRemainingEvents()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `loader is never shown when the operation completes before the show delay`() = runTest {
|
||||
val state = AsyncIndicatorState()
|
||||
val action = mutableStateOf<AsyncAction<*>>(AsyncAction.Uninitialized)
|
||||
indicatorFlow(action, state).test {
|
||||
action.value = AsyncAction.Loading
|
||||
advanceTimeBy(100.milliseconds)
|
||||
runCurrent()
|
||||
action.value = AsyncAction.Success(Unit)
|
||||
advanceTimeBy(1000.milliseconds)
|
||||
runCurrent()
|
||||
assertThat(state.currentAnimationState.targetState).isFalse()
|
||||
cancelAndIgnoreRemainingEvents()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `failure is shown immediately, without waiting for the show delay`() = runTest {
|
||||
val state = AsyncIndicatorState()
|
||||
val action = mutableStateOf<AsyncAction<*>>(AsyncAction.Uninitialized)
|
||||
indicatorFlow(action, state).test {
|
||||
action.value = AsyncAction.Failure(Exception("failed"))
|
||||
runCurrent()
|
||||
assertThat(state.currentAnimationState.targetState).isTrue()
|
||||
cancelAndIgnoreRemainingEvents()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `loader stays visible for the minimum duration once shown`() = runTest {
|
||||
val state = AsyncIndicatorState()
|
||||
val action = mutableStateOf<AsyncAction<*>>(AsyncAction.Uninitialized)
|
||||
val timeSource = TestTimeSource()
|
||||
indicatorFlow(action, state, timeSource).test {
|
||||
// Show the loader.
|
||||
action.value = AsyncAction.Loading
|
||||
advanceTimeBy(400.milliseconds)
|
||||
runCurrent()
|
||||
assertThat(state.currentAnimationState.targetState).isTrue()
|
||||
// 200ms of display has already elapsed when the operation succeeds.
|
||||
timeSource += 200.milliseconds
|
||||
action.value = AsyncAction.Success(Unit)
|
||||
runCurrent()
|
||||
// Still within the minimum display duration (200ms shown, 300ms remaining) -> stays visible.
|
||||
advanceTimeBy(200.milliseconds)
|
||||
runCurrent()
|
||||
assertThat(state.currentAnimationState.targetState).isTrue()
|
||||
// Past the minimum display duration -> cleared.
|
||||
advanceTimeBy(200.milliseconds)
|
||||
runCurrent()
|
||||
assertThat(state.currentAnimationState.targetState).isFalse()
|
||||
cancelAndIgnoreRemainingEvents()
|
||||
}
|
||||
}
|
||||
|
||||
private fun indicatorFlow(
|
||||
action: State<AsyncAction<*>>,
|
||||
state: AsyncIndicatorState,
|
||||
timeSource: TimeSource = TimeSource.Monotonic,
|
||||
): Flow<Boolean> = moleculeFlow(RecompositionMode.Immediate) {
|
||||
AsyncActionIndicatorEffect(
|
||||
asyncAction = action.value,
|
||||
state = state,
|
||||
showDelay = showDelay,
|
||||
minDisplayDuration = minDisplay,
|
||||
failureDuration = minDisplay,
|
||||
loading = {},
|
||||
failure = {},
|
||||
timeSource = timeSource,
|
||||
)
|
||||
state.currentAnimationState.targetState
|
||||
}
|
||||
}
|
||||
@@ -36,5 +36,5 @@ dependencies {
|
||||
implementation(libs.matrix.emojibase.bindings)
|
||||
|
||||
testImplementation(projects.libraries.matrix.test)
|
||||
testCommonDependencies(libs)
|
||||
testCommonDependencies(libs, includeTestComposeView = true)
|
||||
}
|
||||
|
||||
+1
-1
@@ -62,7 +62,7 @@ internal fun EmojiPickerView(
|
||||
|
||||
Column(modifier) {
|
||||
SearchBar(
|
||||
modifier = Modifier.padding(bottom = 10.dp),
|
||||
modifier = Modifier.padding(bottom = if (state.isSearchActive) 0.dp else 10.dp),
|
||||
queryState = state.searchQuery,
|
||||
resultState = state.searchResults,
|
||||
active = state.isSearchActive,
|
||||
|
||||
+72
@@ -0,0 +1,72 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Element Creations Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
@file:OptIn(ExperimentalTestApi::class)
|
||||
|
||||
package io.element.android.libraries.emoji.impl.picker
|
||||
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.test.AndroidComposeUiTest
|
||||
import androidx.compose.ui.test.ExperimentalTestApi
|
||||
import androidx.compose.ui.test.assertIsEqualTo
|
||||
import androidx.compose.ui.test.getBoundsInRoot
|
||||
import androidx.compose.ui.test.hasScrollToIndexAction
|
||||
import androidx.compose.ui.test.v2.runAndroidComposeUiTest
|
||||
import androidx.compose.ui.unit.dp
|
||||
import io.element.android.emojibasebindings.Emoji
|
||||
import io.element.android.libraries.designsystem.theme.components.SearchBarResultState
|
||||
import io.element.android.tests.testutils.robolectric.RobolectricTest
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
import kotlinx.collections.immutable.persistentSetOf
|
||||
import org.junit.Test
|
||||
import org.robolectric.annotation.Config
|
||||
|
||||
private val PICKER_HEIGHT = 600.dp
|
||||
|
||||
class EmojiPickerViewTest : RobolectricTest() {
|
||||
@Config(qualifiers = "w400dp-h800dp")
|
||||
@Test
|
||||
fun `the search results reach the bottom of the picker`() = runAndroidComposeUiTest<ComponentActivity> {
|
||||
setEmojiPickerView()
|
||||
onNode(hasScrollToIndexAction())
|
||||
.getBoundsInRoot()
|
||||
.bottom
|
||||
.assertIsEqualTo(PICKER_HEIGHT, "emoji grid bottom")
|
||||
}
|
||||
}
|
||||
|
||||
private fun AndroidComposeUiTest<ComponentActivity>.setEmojiPickerView() {
|
||||
setContent {
|
||||
Box(Modifier.size(400.dp, PICKER_HEIGHT)) {
|
||||
EmojiPickerView(
|
||||
state = aDefaultEmojiPickerState(
|
||||
isSearchActive = true,
|
||||
searchQuery = "smile",
|
||||
searchResults = SearchBarResultState.Results(
|
||||
persistentListOf(
|
||||
Emoji(
|
||||
hexcode = "0x00",
|
||||
label = "grinning face",
|
||||
tags = persistentListOf("grinning"),
|
||||
shortcodes = persistentListOf("smile"),
|
||||
unicode = "😀",
|
||||
skins = null,
|
||||
),
|
||||
)
|
||||
),
|
||||
),
|
||||
onSelectEmoji = {},
|
||||
selectedEmojis = persistentSetOf(),
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+7
-6
@@ -54,7 +54,7 @@ import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import java.util.Optional
|
||||
|
||||
interface MatrixClient {
|
||||
interface MatrixClient : UrlContentFetcher {
|
||||
val sessionId: SessionId
|
||||
val deviceId: DeviceId
|
||||
|
||||
@@ -99,6 +99,12 @@ interface MatrixClient {
|
||||
|
||||
/** Clears both m.status and m.call profile fields (maps to DELETE on the profile endpoint per MSC4426). */
|
||||
suspend fun clearUserStatus(): Result<Unit>
|
||||
|
||||
/** Whether the homeserver advertises support for user status (MSC4426). */
|
||||
suspend fun isUserStatusSupported(): Result<Boolean>
|
||||
|
||||
/** Enable or disable automatically setting the user's status to "in a call" (m.call) while in a call. */
|
||||
fun enableAutomaticCallStatus(enabled: Boolean)
|
||||
suspend fun joinRoom(roomId: RoomId): Result<RoomInfo?>
|
||||
suspend fun joinRoomByIdOrAlias(roomIdOrAlias: RoomIdOrAlias, serverNames: List<String>): Result<RoomInfo?>
|
||||
suspend fun knockRoom(roomIdOrAlias: RoomIdOrAlias, message: String, serverNames: List<String>): Result<RoomInfo?>
|
||||
@@ -167,11 +173,6 @@ interface MatrixClient {
|
||||
*/
|
||||
fun userIdServerName(): String
|
||||
|
||||
/**
|
||||
* Execute generic GET requests through the SDKs internal HTTP client.
|
||||
*/
|
||||
suspend fun getUrl(url: String): Result<ByteArray>
|
||||
|
||||
/**
|
||||
* Get a room preview for a given room ID or alias. This is especially useful for rooms that the user is not a member of, or hasn't joined yet.
|
||||
*/
|
||||
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Element Creations Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.libraries.matrix.api
|
||||
|
||||
import java.lang.AutoCloseable
|
||||
|
||||
/**
|
||||
* A Matrix client in an 'unauthenticated' state that can still be used to resolve URLs.
|
||||
*
|
||||
* Note: this client implements an [AutoCloseable] interface, so using [AutoCloseable.close] or [AutoCloseable.use] will clean up any resources associated
|
||||
* with it, such as temporary files.
|
||||
*/
|
||||
interface TemporaryMatrixClient : UrlContentFetcher, AutoCloseable
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Element Creations Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.libraries.matrix.api
|
||||
|
||||
/**
|
||||
* Factory to create a temporary [TemporaryMatrixClient] for a given home server URL.
|
||||
*/
|
||||
interface TemporaryMatrixClientFactory {
|
||||
suspend fun create(homeServerUrl: String): Result<TemporaryMatrixClient>
|
||||
}
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Element Creations Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.libraries.matrix.api
|
||||
|
||||
/**
|
||||
* An interface to fetch content from a URL using a simple GET request.
|
||||
*/
|
||||
fun interface UrlContentFetcher {
|
||||
/**
|
||||
* Execute generic GET requests through the SDKs internal HTTP client.
|
||||
*/
|
||||
suspend fun getUrl(url: String): Result<ByteArray>
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user