Merge branch 'release/26.08.0'

# Conflicts:
#	tools/manifest/gplay/release/aaptDump.txt
This commit is contained in:
ganfra
2026-07-29 17:46:31 +02:00
978 changed files with 9576 additions and 4451 deletions
+4
View File
@@ -28,6 +28,10 @@ changelog:
labels:
- PR-Dependencies
- title: 💼 Element Pro
labels:
- PR-Element-Pro
- title: Others
labels:
- PR-misc
+2 -2
View File
@@ -44,14 +44,14 @@ jobs:
docker-images: true
swap-storage: false
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
# Ensure we are building the branch and not the branch after being merged on develop
# https://github.com/actions/checkout/issues/881
ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.ref }}
persist-credentials: false
- name: Use JDK 21
uses: actions/setup-java@1bcf9fb12cf4aa7d266a90ae39939e61372fe520 # v5.4.0
uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5.6.0
with:
distribution: 'temurin' # See 'Supported distributions' for available options
java-version: '21'
+2 -2
View File
@@ -43,7 +43,7 @@ jobs:
docker-images: true
swap-storage: false
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
# Ensure we are building the branch and not the branch after being merged on develop
# https://github.com/actions/checkout/issues/881
@@ -56,7 +56,7 @@ jobs:
- name: Clone submodules
run: git submodule update --init --recursive
- name: Use JDK 21
uses: actions/setup-java@1bcf9fb12cf4aa7d266a90ae39939e61372fe520 # v5.4.0
uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5.6.0
with:
distribution: 'temurin' # See 'Supported distributions' for available options
java-version: '21'
+1 -1
View File
@@ -11,7 +11,7 @@ jobs:
# Skip in forks, it doesn't work even with the fallback token
if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'element-hq/element-x-android' }}
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Add SSH private keys for submodule repositories
+2 -2
View File
@@ -19,7 +19,7 @@ jobs:
- name: ⏬ Checkout with LFS
uses: nschloe/action-cached-lfs-checkout@385a8ecc719e50b8c71af6ab01a624b486b7c3bc # v1.2.5
- name: Use JDK 21
uses: actions/setup-java@1bcf9fb12cf4aa7d266a90ae39939e61372fe520 # v5.4.0
uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5.6.0
with:
distribution: 'temurin' # See 'Supported distributions' for available options
java-version: '21'
@@ -28,7 +28,7 @@ jobs:
with:
cache-read-only: ${{ github.ref != 'refs/heads/develop' }}
- name: Set up Python 3.12
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: 3.14
- name: Run World screenshots generation script
+2 -3
View File
@@ -13,12 +13,11 @@ jobs:
# Skip in forks
if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'element-hq/element-x-android' }}
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- uses: actions/setup-java@1bcf9fb12cf4aa7d266a90ae39939e61372fe520 # v5.4.0
- uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5.6.0
name: Use JDK 21
if: (github.event_name == 'pull_request' && github.event.pull_request.fork == null) || github.event_name == 'workflow_dispatch'
with:
distribution: 'temurin' # See 'Supported distributions' for available options
java-version: '21'
+3 -3
View File
@@ -38,13 +38,13 @@ jobs:
docker-images: true
swap-storage: false
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
# Ensure we are building the branch and not the branch after being merged on develop
# https://github.com/actions/checkout/issues/881
ref: ${{ github.ref }}
persist-credentials: false
- uses: actions/setup-java@1bcf9fb12cf4aa7d266a90ae39939e61372fe520 # v5.4.0
- uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5.6.0
name: Use JDK 21
with:
distribution: 'temurin' # See 'Supported distributions' for available options
@@ -78,7 +78,7 @@ jobs:
concurrency:
group: maestro-test
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
if: (github.event_name == 'pull_request' && github.event.pull_request.fork == null) || github.event_name == 'workflow_dispatch'
with:
# Ensure we are building the branch and not the branch after being merged on develop
+2 -2
View File
@@ -32,11 +32,11 @@ jobs:
docker-images: true
swap-storage: false
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Use JDK 21
uses: actions/setup-java@1bcf9fb12cf4aa7d266a90ae39939e61372fe520 # v5.4.0
uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5.6.0
with:
distribution: 'temurin' # See 'Supported distributions' for available options
java-version: '21'
+3 -3
View File
@@ -37,7 +37,7 @@ jobs:
uses: nschloe/action-cached-lfs-checkout@385a8ecc719e50b8c71af6ab01a624b486b7c3bc # v1.2.5
- name: Use JDK 21
uses: actions/setup-java@1bcf9fb12cf4aa7d266a90ae39939e61372fe520 # v5.4.0
uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5.6.0
with:
distribution: 'temurin' # See 'Supported distributions' for available options
java-version: '21'
@@ -76,11 +76,11 @@ jobs:
name: Dependency analysis
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Use JDK 21
uses: actions/setup-java@1bcf9fb12cf4aa7d266a90ae39939e61372fe520 # v5.4.0
uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5.6.0
with:
distribution: 'temurin' # See 'Supported distributions' for available options
java-version: '21'
+21 -21
View File
@@ -33,7 +33,7 @@ jobs:
docker-images: true
swap-storage: false
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Add SSH private keys for submodule repositories
@@ -51,11 +51,11 @@ jobs:
name: Search for invalid screenshot files
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Set up Python 3.12
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: 3.14
- name: Search for invalid screenshot files
@@ -65,11 +65,11 @@ jobs:
name: Search for invalid dependencies
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Use JDK 21
uses: actions/setup-java@1bcf9fb12cf4aa7d266a90ae39939e61372fe520 # v5.4.0
uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5.6.0
with:
distribution: 'temurin' # See 'Supported distributions' for available options
java-version: '21'
@@ -78,7 +78,7 @@ jobs:
with:
cache-read-only: ${{ github.ref != 'refs/heads/develop' }}
- name: Set up Python 3.12
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: 3.14
- name: Search for invalid dependencies
@@ -93,7 +93,7 @@ jobs:
group: ${{ github.ref == 'refs/heads/main' && format('check-konsist-main-{0}', github.sha) || github.ref == 'refs/heads/develop' && format('check-konsist-develop-{0}', github.sha) || format('check-konsist-{0}', github.ref) }}
cancel-in-progress: true
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
# Ensure we are building the branch and not the branch after being merged on develop
# https://github.com/actions/checkout/issues/881
@@ -108,7 +108,7 @@ jobs:
if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'element-hq/element-x-android' }}
run: git submodule update --init --recursive
- name: Use JDK 21
uses: actions/setup-java@1bcf9fb12cf4aa7d266a90ae39939e61372fe520 # v5.4.0
uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5.6.0
with:
distribution: 'temurin' # See 'Supported distributions' for available options
java-version: '21'
@@ -134,7 +134,7 @@ jobs:
group: ${{ github.ref == 'refs/heads/main' && format('check-compose-main-{0}', github.sha) || github.ref == 'refs/heads/develop' && format('check-compose-develop-{0}', github.sha) || format('check-compose-{0}', github.ref) }}
cancel-in-progress: true
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
# Ensure we are building the branch and not the branch after being merged on develop
# https://github.com/actions/checkout/issues/881
@@ -149,7 +149,7 @@ jobs:
if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'element-hq/element-x-android' }}
run: git submodule update --init --recursive
- name: Use JDK 21
uses: actions/setup-java@1bcf9fb12cf4aa7d266a90ae39939e61372fe520 # v5.4.0
uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5.6.0
with:
distribution: 'temurin' # See 'Supported distributions' for available options
java-version: '21'
@@ -168,7 +168,7 @@ jobs:
group: ${{ github.ref == 'refs/heads/main' && format('check-lint-main-{0}', github.sha) || github.ref == 'refs/heads/develop' && format('check-lint-develop-{0}', github.sha) || format('check-lint-{0}', github.ref) }}
cancel-in-progress: true
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
# Ensure we are building the branch and not the branch after being merged on develop
# https://github.com/actions/checkout/issues/881
@@ -183,7 +183,7 @@ jobs:
if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'element-hq/element-x-android' }}
run: git submodule update --init --recursive
- name: Use JDK 21
uses: actions/setup-java@1bcf9fb12cf4aa7d266a90ae39939e61372fe520 # v5.4.0
uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5.6.0
with:
distribution: 'temurin' # See 'Supported distributions' for available options
java-version: '21'
@@ -213,7 +213,7 @@ jobs:
group: ${{ github.ref == 'refs/heads/main' && format('check-detekt-main-{0}', github.sha) || github.ref == 'refs/heads/develop' && format('check-detekt-develop-{0}', github.sha) || format('check-detekt-{0}', github.ref) }}
cancel-in-progress: true
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
# Ensure we are building the branch and not the branch after being merged on develop
# https://github.com/actions/checkout/issues/881
@@ -228,7 +228,7 @@ jobs:
if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'element-hq/element-x-android' }}
run: git submodule update --init --recursive
- name: Use JDK 21
uses: actions/setup-java@1bcf9fb12cf4aa7d266a90ae39939e61372fe520 # v5.4.0
uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5.6.0
with:
distribution: 'temurin' # See 'Supported distributions' for available options
java-version: '21'
@@ -254,7 +254,7 @@ jobs:
group: ${{ github.ref == 'refs/heads/main' && format('check-ktlint-main-{0}', github.sha) || github.ref == 'refs/heads/develop' && format('check-ktlint-develop-{0}', github.sha) || format('check-ktlint-{0}', github.ref) }}
cancel-in-progress: true
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
# Ensure we are building the branch and not the branch after being merged on develop
# https://github.com/actions/checkout/issues/881
@@ -269,7 +269,7 @@ jobs:
if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'element-hq/element-x-android' }}
run: git submodule update --init --recursive
- name: Use JDK 21
uses: actions/setup-java@1bcf9fb12cf4aa7d266a90ae39939e61372fe520 # v5.4.0
uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5.6.0
with:
distribution: 'temurin' # See 'Supported distributions' for available options
java-version: '21'
@@ -295,7 +295,7 @@ jobs:
group: ${{ github.ref == 'refs/heads/main' && format('check-docs-main-{0}', github.sha) || github.ref == 'refs/heads/develop' && format('check-docs-develop-{0}', github.sha) || format('check-docs-{0}', github.ref) }}
cancel-in-progress: true
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
# Ensure we are building the branch and not the branch after being merged on develop
# https://github.com/actions/checkout/issues/881
@@ -319,7 +319,7 @@ jobs:
name: Check shell scripts
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Run shellcheck
@@ -333,10 +333,10 @@ jobs:
permissions:
security-events: write # Required for upload-sarif (used by zizmor-action) to upload SARIF files.
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- uses: zizmorcore/zizmor-action@192e21d79ab29983730a13d1382995c2307fbcaa # v0.5.7
- uses: zizmorcore/zizmor-action@6599ee8b7a49aef6a770f63d261d214911a7ce02 # v0.6.0
upload_reports:
name: Project Check Suite
@@ -344,7 +344,7 @@ jobs:
needs: [konsist, lint, ktlint, detekt]
if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'element-hq/element-x-android' }}
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
# Ensure we are building the branch and not the branch after being merged on develop
# https://github.com/actions/checkout/issues/881
+1 -1
View File
@@ -54,7 +54,7 @@ jobs:
with:
persist-credentials: false
- name: ☕️ Use JDK 21
uses: actions/setup-java@1bcf9fb12cf4aa7d266a90ae39939e61372fe520 # v5.4.0
uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5.6.0
with:
distribution: 'temurin' # See 'Supported distributions' for available options
java-version: '21'
+6 -6
View File
@@ -34,11 +34,11 @@ jobs:
docker-images: true
swap-storage: false
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Use JDK 21
uses: actions/setup-java@1bcf9fb12cf4aa7d266a90ae39939e61372fe520 # v5.4.0
uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5.6.0
with:
distribution: 'temurin' # See 'Supported distributions' for available options
java-version: '21'
@@ -71,7 +71,7 @@ jobs:
group: ${{ format('build-release-main-enterprise-{0}', github.sha) }}
cancel-in-progress: true
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Add SSH private keys for submodule repositories
@@ -82,7 +82,7 @@ jobs:
- name: Clone submodules
run: git submodule update --init --recursive
- name: Use JDK 21
uses: actions/setup-java@1bcf9fb12cf4aa7d266a90ae39939e61372fe520 # v5.4.0
uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5.6.0
with:
distribution: 'temurin' # See 'Supported distributions' for available options
java-version: '21'
@@ -122,11 +122,11 @@ jobs:
docker-images: true
swap-storage: false
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Use JDK 21
uses: actions/setup-java@1bcf9fb12cf4aa7d266a90ae39939e61372fe520 # v5.4.0
uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5.6.0
with:
distribution: 'temurin' # See 'Supported distributions' for available options
java-version: '21'
+2 -2
View File
@@ -38,14 +38,14 @@ jobs:
docker-images: true
swap-storage: false
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
# Ensure we are building the branch and not the branch after being merged on develop
# https://github.com/actions/checkout/issues/881
ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.ref }}
persist-credentials: false
- name: Use JDK 21
uses: actions/setup-java@1bcf9fb12cf4aa7d266a90ae39939e61372fe520 # v5.4.0
uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5.6.0
with:
distribution: 'temurin' # See 'Supported distributions' for available options
java-version: '21'
+3 -3
View File
@@ -13,11 +13,11 @@ jobs:
# Skip in forks
if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'element-hq/element-x-android' }}
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Use JDK 21
uses: actions/setup-java@1bcf9fb12cf4aa7d266a90ae39939e61372fe520 # v5.4.0
uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5.6.0
with:
distribution: 'temurin' # See 'Supported distributions' for available options
java-version: '21'
@@ -26,7 +26,7 @@ jobs:
with:
cache-read-only: ${{ github.ref != 'refs/heads/develop' }}
- name: Set up Python 3.12
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: 3.14
- name: Setup Localazy
+2 -2
View File
@@ -14,11 +14,11 @@ jobs:
if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'element-hq/element-x-android' }}
# No concurrency required, runs every time on a schedule.
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Set up Python 3.12
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: 3.14
- name: Install Prerequisite dependencies
+1 -1
View File
@@ -63,7 +63,7 @@ jobs:
if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'element-hq/element-x-android' }}
run: git submodule update --init --recursive
- name: ☕️ Use JDK 21
uses: actions/setup-java@1bcf9fb12cf4aa7d266a90ae39939e61372fe520 # v5.4.0
uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5.6.0
with:
distribution: 'temurin' # See 'Supported distributions' for available options
java-version: '21'
+34
View File
@@ -1,5 +1,39 @@
<component name="ProjectCodeStyleConfiguration">
<code_scheme name="Project" version="173">
<JavaCodeStyleSettings>
<option name="IMPORT_LAYOUT_TABLE">
<value>
<package name="android" withSubpackages="true" static="true" />
<package name="androidx" withSubpackages="true" static="true" />
<package name="com" withSubpackages="true" static="true" />
<package name="junit" withSubpackages="true" static="true" />
<package name="net" withSubpackages="true" static="true" />
<package name="org" withSubpackages="true" static="true" />
<package name="java" withSubpackages="true" static="true" />
<package name="javax" withSubpackages="true" static="true" />
<package name="" withSubpackages="true" static="true" />
<emptyLine />
<package name="android" withSubpackages="true" static="false" />
<emptyLine />
<package name="androidx" withSubpackages="true" static="false" />
<emptyLine />
<package name="com" withSubpackages="true" static="false" />
<emptyLine />
<package name="junit" withSubpackages="true" static="false" />
<emptyLine />
<package name="net" withSubpackages="true" static="false" />
<emptyLine />
<package name="org" withSubpackages="true" static="false" />
<emptyLine />
<package name="java" withSubpackages="true" static="false" />
<emptyLine />
<package name="javax" withSubpackages="true" static="false" />
<emptyLine />
<package name="" withSubpackages="true" static="false" />
<emptyLine />
</value>
</option>
</JavaCodeStyleSettings>
<JetCodeStyleSettings>
<option name="LINE_BREAK_AFTER_MULTILINE_WHEN_ENTRY" value="false" />
<option name="CODE_STYLE_DEFAULTS" value="KOTLIN_OFFICIAL" />
+2 -2
View File
@@ -2,6 +2,6 @@
<project version="4">
<component name="KotlinJpsPluginSettings">
<option name="externalSystemId" value="Gradle" />
<option name="version" value="2.4.0" />
<option name="version" value="2.4.10" />
</component>
</project>
</project>
+1 -1
View File
@@ -16,4 +16,4 @@ appId: ${MAESTRO_APP_ID}
- extendedWaitUntil:
visible: "Device verified"
timeout: 30000
- tapOn: "Continue"
- tapOn: "Done"
+70
View File
@@ -1,3 +1,73 @@
Changes in Element X v26.07.1
=============================
<!-- Release notes generated using configuration in .github/release.yml at v26.07.1 -->
## What's Changed
### ✨ Features
* Scroll to unread messages by @bmarty in https://github.com/element-hq/element-x-android/pull/7024
### 🙌 Improvements
* Collapse runs of deleted messages in the timeline by @manfrommedan in https://github.com/element-hq/element-x-android/pull/7195
* Allow homeserver to disable encryption in Element Pro by @jmartinesp in https://github.com/element-hq/element-x-android/pull/7198
### 🐛 Bugfixes
* Fix jump-to-unread requiring two taps for an out-of-window read marker by @jennaharris7 in https://github.com/element-hq/element-x-android/pull/6943
* Add content description to video preview for screen readers by @bmarty in https://github.com/element-hq/element-x-android/pull/7137
* Fix accessibility for message composer input and read receipts button by @bmarty in https://github.com/element-hq/element-x-android/pull/7135
* Add accessibility label to search input by @bmarty in https://github.com/element-hq/element-x-android/pull/7133
* Fix timeline reading order distorted when TalkBack is active by @bmarty in https://github.com/element-hq/element-x-android/pull/7138
* Add programmatic label and focus indicator to PIN entry field by @bmarty in https://github.com/element-hq/element-x-android/pull/7132
* Fix timelines not loading after marking a room as read from notification when app was killed by @jmartinesp in https://github.com/element-hq/element-x-android/pull/7202
### 🗣 Translations
* Sync Strings by @ElementBot in https://github.com/element-hq/element-x-android/pull/7192
### 🧱 Build
* Bump the default Gradle daemon heap to match what CI already uses by @PolynomialDivision in https://github.com/element-hq/element-x-android/pull/7152
* Update screenshots by @jmartinesp in https://github.com/element-hq/element-x-android/pull/7155
* Update GitHub templates by @bmarty in https://github.com/element-hq/element-x-android/pull/7170
* Add check on merged manifest by @bmarty in https://github.com/element-hq/element-x-android/pull/7172
* Fix AAPT dump missing and reordering some permissions by @jmartinesp in https://github.com/element-hq/element-x-android/pull/7193
* Try fixing DM creation in Maestro by @jmartinesp in https://github.com/element-hq/element-x-android/pull/7204
### 🚧 In development 🚧
* Support gallery messages by @bxdxnn in https://github.com/element-hq/element-x-android/pull/6519
* Support gallery messages by @bmarty in https://github.com/element-hq/element-x-android/pull/7112
* Initial content scanner integration by @jmartinesp in https://github.com/element-hq/element-x-android/pull/7139
* WIP : Set user status by @ganfra in https://github.com/element-hq/element-x-android/pull/7094
### Dependency upgrades
* Update dependency io.sentry:sentry-android to v8.44.1 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7093
* Update dependency org.maplibre.gl:android-sdk to v13.3.1 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7129
* Update actions/setup-java action to v5.4.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7143
* Update danger/danger-js action to v13.0.10 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7142
* Update dependency io.sentry:sentry-android to v8.46.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7140
* Update dependencyAnalysis to v3.16.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7144
* Update plugin gms_google_services to v4.5.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7086
* Update dependency androidx.compose.material3:material3 to v1.5.0-alpha21 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7007
* Update dependency androidx.compose.material3:material3 to v1.5.0-alpha22 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7158
* Update metro to v1.3.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7168
* Update dependency io.sentry:sentry-android to v8.47.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7178
* Update dependency androidx.compose:compose-bom to v2026.06.01 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7174
* Update dependency org.jetbrains.kotlinx:kotlinx-collections-immutable to v0.5.1 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7180
* Update dependency io.github.sergio-sastre.ComposablePreviewScanner:android to v0.9.1 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7201
* Update dependency io.github.zxing-cpp:android to v3.1.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7206
* Update dependency org.matrix.rustcomponents:sdk-android to v26.07.13 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7194
* Update dependency androidx.sqlite:sqlite-ktx to v2.7.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7177
* Update dependency org.matrix.rustcomponents:sdk-android to v26.07.15 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7216
### Others
* Timeline: jump-to-unread FAB and mark-as-read shortcut by @jennaharris7 in https://github.com/element-hq/element-x-android/pull/6694
* Iterate on visual list by @bmarty in https://github.com/element-hq/element-x-android/pull/7141
* Fix wrong avatar shown in the thread list's top bar by @bxdxnn in https://github.com/element-hq/element-x-android/pull/7044
* Update onboarding screen text style by @bmarty in https://github.com/element-hq/element-x-android/pull/7157
* Add full SVG support by @bxdxnn in https://github.com/element-hq/element-x-android/pull/7020
* Add skin tone picker to the emoji picker by @bxdxnn in https://github.com/element-hq/element-x-android/pull/6917
* Fix the message edit being lost when attaching media by @manfrommedan in https://github.com/element-hq/element-x-android/pull/7090
* Cleanup AndroidManifest.xml files. by @bmarty in https://github.com/element-hq/element-x-android/pull/7171
* Handle local network permission - Android 17 by @ganfra in https://github.com/element-hq/element-x-android/pull/7165
* Add item count text as the placeholder for in reply to box for galleries by @bxdxnn in https://github.com/element-hq/element-x-android/pull/7176
* Default to dark theme for EC by @toger5 in https://github.com/element-hq/element-x-android/pull/7162
## New Contributors
* @PolynomialDivision made their first contribution in https://github.com/element-hq/element-x-android/pull/7152
**Full Changelog**: https://github.com/element-hq/element-x-android/compare/v26.07.0...v26.07.1
Changes in Element X v26.07.0
=============================
@@ -28,8 +28,6 @@ import io.element.android.libraries.di.BaseDirectory
import io.element.android.libraries.di.CacheDirectory
import io.element.android.libraries.di.annotations.AppCoroutineScope
import io.element.android.libraries.di.annotations.ApplicationContext
import io.element.android.libraries.recentemojis.api.EmojibaseProvider
import io.element.android.libraries.recentemojis.impl.DefaultEmojibaseProvider
import io.element.android.x.BuildConfig
import io.element.android.x.R
import kotlinx.coroutines.CoroutineName
@@ -115,10 +113,4 @@ object AppModule {
fun provideSnackbarDispatcher(): SnackbarDispatcher {
return SnackbarDispatcher()
}
@Provides
@SingleIn(AppScope::class)
fun providesEmojibaseProvider(@ApplicationContext context: Context): EmojibaseProvider {
return DefaultEmojibaseProvider(context)
}
}
@@ -0,0 +1,2 @@
Main changes in this version: bug fixes and improvements.
Full changelog: https://github.com/element-hq/element-x-android/releases
@@ -0,0 +1,22 @@
/*
* 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.contentscanner.api
import io.element.android.libraries.matrix.api.media.MediaSource
import io.element.android.libraries.matrix.api.scanner.ContentScanner
import io.element.android.libraries.matrix.ui.media.contentvalidation.ContentValidationState
/**
* Service to perform security scans in the contents for a given media source.
*
* The default implementation (FOSS) always returns a valid state.
* This will only process media when a [ContentScanner] is provided when building the client in Pro.
*/
fun interface ContentScannerService {
fun scan(mediaSources: List<MediaSource>, contentValidationState: ContentValidationState)
}
@@ -32,4 +32,5 @@ dependencies {
testImplementation(libs.test.truth)
testImplementation(libs.test.turbine)
testImplementation(projects.libraries.matrix.test)
testImplementation(projects.tests.testutils)
}
@@ -0,0 +1,63 @@
/*
* 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.contentscanner.impl
import io.element.android.features.contentscanner.api.ContentScannerService
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.matrix.api.exception.ClientException
import io.element.android.libraries.matrix.api.media.MediaSource
import io.element.android.libraries.matrix.api.scanner.ContentScanner
import io.element.android.libraries.matrix.ui.media.contentvalidation.ContentValidationState
import io.element.android.libraries.matrix.ui.media.contentvalidation.ContentValidationValue
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import java.io.IOException
/**
* Default implementation of [ContentScannerService] that uses a [ContentScanner] to scan media sources.
*/
class DefaultContentScannerService(
private val contentScanner: ContentScanner,
private val coroutineScope: CoroutineScope,
coroutineDispatchers: CoroutineDispatchers,
) : ContentScannerService {
private val dispatcher = coroutineDispatchers.io.limitedParallelism(4)
override fun scan(mediaSources: List<MediaSource>, contentValidationState: ContentValidationState) {
for (mediaSource in mediaSources) {
val url = mediaSource.safeUrl
val currentState = contentValidationState.getCurrentMediaState(url)
if (currentState != ContentValidationValue.Unknown) continue
contentValidationState.update(url, ContentValidationValue.Loading)
coroutineScope.launch(dispatcher) {
contentScanner.scan(mediaSource)
.onSuccess { isValid ->
val contentValidationValue = if (isValid) ContentValidationValue.Valid else ContentValidationValue.Invalid
contentValidationState.update(url, contentValidationValue)
}
.onFailure { exception ->
when (exception) {
is IOException -> {
// If it's an IO-related exception, we can retry later, so we don't store the error
contentValidationState.update(url, ContentValidationValue.Unknown)
}
is ClientException.ContentScanner if exception.reason.isDangerous() -> {
// It's a dangerous content (banned mime-type?), we mark it as invalid
contentValidationState.update(url, ContentValidationValue.Invalid)
}
else -> {
// For other exceptions, we cache the failure
contentValidationState.update(url, ContentValidationValue.UnrecoverableError(exception))
}
}
}
}
}
}
}
@@ -0,0 +1,39 @@
/*
* 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.contentscanner.impl
import dev.zacsweers.metro.ContributesBinding
import dev.zacsweers.metro.SingleIn
import io.element.android.libraries.di.RoomScope
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.scanner.ContentScanner
import io.element.android.libraries.matrix.ui.media.contentvalidation.ContentValidationState
import io.element.android.libraries.matrix.ui.media.contentvalidation.DefaultContentValidationState
import io.element.android.libraries.matrix.ui.media.contentvalidation.EventContentValidationCache
import io.element.android.libraries.matrix.ui.media.contentvalidation.NoopContentValidationState
import timber.log.Timber
import java.util.concurrent.ConcurrentHashMap
@SingleIn(RoomScope::class)
@ContributesBinding(RoomScope::class)
class DefaultEventContentValidationCache(
private val contentScanner: ContentScanner?,
) : EventContentValidationCache {
private val cache = ConcurrentHashMap<EventId, ContentValidationState>()
override operator fun get(eventId: EventId): ContentValidationState {
return cache.getOrPut(eventId) {
if (contentScanner != null) {
DefaultContentValidationState()
} else {
Timber.v("Content scanner is not available, returning NoopContentValidationState for eventId: $eventId")
NoopContentValidationState()
}
}
}
}
@@ -0,0 +1,28 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.contentscanner.impl.di
import io.element.android.features.contentscanner.api.ContentScannerService
import io.element.android.libraries.matrix.api.media.MediaSource
import io.element.android.libraries.matrix.ui.media.contentvalidation.ContentValidationState
import io.element.android.libraries.matrix.ui.media.contentvalidation.ContentValidationValue
/**
* Noop implementation of [ContentScannerService] that always returns success.
*
* This is used when the content scanner feature is not enabled or available.
*/
class AlwaysValidContentScannerService : ContentScannerService {
override fun scan(mediaSources: List<MediaSource>, contentValidationState: ContentValidationState) {
// Always return success for the noop implementation
for (mediaSource in mediaSources) {
val url = mediaSource.safeUrl
contentValidationState.update(url, ContentValidationValue.Valid)
}
}
}
@@ -0,0 +1,42 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.contentscanner.impl.di
import dev.zacsweers.metro.BindingContainer
import dev.zacsweers.metro.ContributesTo
import dev.zacsweers.metro.Provides
import dev.zacsweers.metro.SingleIn
import io.element.android.features.contentscanner.api.ContentScannerService
import io.element.android.features.contentscanner.impl.DefaultContentScannerService
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.di.RoomScope
import io.element.android.libraries.di.annotations.RoomCoroutineScope
import io.element.android.libraries.matrix.api.scanner.ContentScanner
import kotlinx.coroutines.CoroutineScope
@BindingContainer
@ContributesTo(RoomScope::class)
object ContentScannerModule {
@Provides
@SingleIn(RoomScope::class)
fun providesContentScannerService(
contentScanner: ContentScanner?,
@RoomCoroutineScope coroutineScope: CoroutineScope,
coroutineDispatchers: CoroutineDispatchers,
): ContentScannerService {
return if (contentScanner != null) {
DefaultContentScannerService(
contentScanner = contentScanner,
coroutineScope = coroutineScope,
coroutineDispatchers = coroutineDispatchers,
)
} else {
AlwaysValidContentScannerService()
}
}
}
@@ -0,0 +1,208 @@
/*
* 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.contentscanner.impl
import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.matrix.api.media.MediaSource
import io.element.android.libraries.matrix.api.scanner.ContentScanner
import io.element.android.libraries.matrix.test.media.aMediaSource
import io.element.android.libraries.matrix.test.scanner.FakeContentScanner
import io.element.android.libraries.matrix.ui.media.contentvalidation.ContentValidationValue
import io.element.android.libraries.matrix.ui.media.contentvalidation.DefaultContentValidationState
import io.element.android.libraries.matrix.ui.media.contentvalidation.NoopContentValidationState
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.CoroutineScope
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.runCurrent
import kotlinx.coroutines.test.runTest
import org.junit.Test
import java.io.FileNotFoundException
@OptIn(ExperimentalCoroutinesApi::class)
class DefaultContentScannerServiceTest {
@Test
fun `scan with no media sources returns unknown`() = runTest {
val scanLambda = lambdaRecorder<MediaSource, Result<Boolean>> { Result.success(true) }
val scanner = FakeContentScanner(scan = scanLambda)
val contentValidationState = DefaultContentValidationState()
createDefaultContentScannerService(
contentScanner = scanner,
).scan(
mediaSources = emptyList(),
contentValidationState = contentValidationState,
)
scanLambda.assertions().isNeverCalled()
assertThat(contentValidationState.getCurrentOverallState()).isEqualTo(ContentValidationValue.Unknown)
}
@Test
fun `scan with single media source`() = runTest {
val scanLambda = lambdaRecorder<MediaSource, Result<Boolean>> { Result.success(false) }
val scanner = FakeContentScanner(scan = scanLambda)
val contentValidationState = DefaultContentValidationState()
val mediaSource = aMediaSource("https://example.com/media.jpg")
createDefaultContentScannerService(
contentScanner = scanner,
).scan(
mediaSources = listOf(mediaSource),
contentValidationState = contentValidationState,
)
runCurrent()
scanLambda.assertions().isCalledOnce().with(value(mediaSource))
assertThat(contentValidationState.getCurrentOverallState()).isEqualTo(ContentValidationValue.Invalid)
}
@Test
fun `scan with several media sources`() = runTest {
val mediaSourceA = aMediaSource("https://example.com/mediaA.jpg")
val mediaSourceB = aMediaSource("https://example.com/mediaB.jpg")
val scanLambda = lambdaRecorder<MediaSource, Result<Boolean>> { source ->
when (source) {
mediaSourceA -> Result.success(true)
mediaSourceB -> Result.success(false)
else -> error("Unexpected media source: $source")
}
}
val scanner = FakeContentScanner(scan = scanLambda)
val contentValidationState = DefaultContentValidationState()
createDefaultContentScannerService(
contentScanner = scanner,
).scan(
mediaSources = listOf(mediaSourceA, mediaSourceB),
contentValidationState = contentValidationState,
)
runCurrent()
scanLambda.assertions().isCalledExactly(2)
assertThat(contentValidationState.getMediaStateFlow(mediaSourceA.safeUrl).first()).isEqualTo(ContentValidationValue.Valid)
assertThat(contentValidationState.getMediaStateFlow(mediaSourceB.safeUrl).first()).isEqualTo(ContentValidationValue.Invalid)
assertThat(contentValidationState.overallStateFlow.first()).isEqualTo(ContentValidationValue.Invalid)
}
@Test
fun `scan won't be repeated for sources which are already being scanned`() = runTest {
val scanLambda = lambdaRecorder<MediaSource, Result<Boolean>> { Result.success(false) }
val scanner = FakeContentScanner(scan = scanLambda)
val contentValidationState = DefaultContentValidationState()
val mediaSource = aMediaSource("https://example.com/media.jpg")
val service = createDefaultContentScannerService(
contentScanner = scanner,
)
// First scan attempt
service.scan(
mediaSources = listOf(mediaSource),
contentValidationState = contentValidationState,
)
// Immediately after, a new scan attempt is made for the same media source
service.scan(
mediaSources = listOf(mediaSource),
contentValidationState = contentValidationState,
)
runCurrent()
// The scan action was done just once, and the overall state is invalid as expected
scanLambda.assertions().isCalledOnce().with(value(mediaSource))
assertThat(contentValidationState.getCurrentOverallState()).isEqualTo(ContentValidationValue.Invalid)
}
@Test
fun `scan won't be repeated for sources which already have a cached value`() = runTest {
val scanLambda = lambdaRecorder<MediaSource, Result<Boolean>> { Result.success(false) }
val scanner = FakeContentScanner(scan = scanLambda)
val contentValidationState = NoopContentValidationState(ContentValidationValue.Valid)
val mediaSource = aMediaSource("https://example.com/media.jpg")
val service = createDefaultContentScannerService(
contentScanner = scanner,
)
service.scan(
mediaSources = listOf(mediaSource),
contentValidationState = contentValidationState,
)
runCurrent()
scanLambda.assertions().isNeverCalled()
}
@Test
fun `if a scan fails with a recoverable error, the content validation value will be back to Unknown`() = runTest {
val scanLambda = lambdaRecorder<MediaSource, Result<Boolean>> { Result.failure(FileNotFoundException("Some IO exception")) }
val scanner = FakeContentScanner(scan = scanLambda)
val contentValidationState = DefaultContentValidationState()
val mediaSource = aMediaSource("https://example.com/media.jpg")
val service = createDefaultContentScannerService(
contentScanner = scanner,
)
service.scan(
mediaSources = listOf(mediaSource),
contentValidationState = contentValidationState,
)
runCurrent()
scanLambda.assertions().isCalledOnce().with(value(mediaSource))
assertThat(contentValidationState.getCurrentMediaState(mediaSource.safeUrl)).isEqualTo(ContentValidationValue.Unknown)
assertThat(contentValidationState.getCurrentOverallState()).isEqualTo(ContentValidationValue.Unknown)
}
@Test
fun `if a scan fails with an unrecoverable error, the content validation value will be back to UnrecoverableError`() = runTest {
val error = OutOfMemoryError("BOOM")
val scanLambda = lambdaRecorder<MediaSource, Result<Boolean>> { Result.failure(error) }
val scanner = FakeContentScanner(scan = scanLambda)
val contentValidationState = DefaultContentValidationState()
val mediaSource = aMediaSource("https://example.com/media.jpg")
val service = createDefaultContentScannerService(
contentScanner = scanner,
)
service.scan(
mediaSources = listOf(mediaSource),
contentValidationState = contentValidationState,
)
runCurrent()
scanLambda.assertions().isCalledOnce().with(value(mediaSource))
assertThat(contentValidationState.getCurrentMediaState(mediaSource.safeUrl)).isEqualTo(ContentValidationValue.UnrecoverableError(error))
assertThat(contentValidationState.getCurrentOverallState()).isEqualTo(ContentValidationValue.UnrecoverableError(error))
}
private fun TestScope.createDefaultContentScannerService(
contentScanner: ContentScanner = FakeContentScanner(),
sessionCoroutineScope: CoroutineScope = backgroundScope,
coroutineDispatchers: CoroutineDispatchers = testCoroutineDispatchers(),
): DefaultContentScannerService {
return DefaultContentScannerService(
contentScanner = contentScanner,
coroutineScope = sessionCoroutineScope,
coroutineDispatchers = coroutineDispatchers
)
}
}
@@ -0,0 +1,44 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.contentscanner.impl
import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.matrix.api.scanner.ContentScanner
import io.element.android.libraries.matrix.test.AN_EVENT_ID
import io.element.android.libraries.matrix.test.scanner.FakeContentScanner
import io.element.android.libraries.matrix.ui.media.contentvalidation.DefaultContentValidationState
import io.element.android.libraries.matrix.ui.media.contentvalidation.NoopContentValidationState
import org.junit.Test
class DefaultEventContentValidationCacheTest {
@Test
fun `get returns NoopContentValidationState when ContentScanner is null`() {
// Given
val contentScanner: ContentScanner? = null
val cache = DefaultEventContentValidationCache(contentScanner)
// When
val state = cache[AN_EVENT_ID]
// Then
assertThat(state).isInstanceOf(NoopContentValidationState::class.java)
}
@Test
fun `get returns an actual DefaultContentValidationState when ContentScanner is not null`() {
// Given
val contentScanner: ContentScanner? = FakeContentScanner()
val cache = DefaultEventContentValidationCache(contentScanner)
// When
val state = cache[AN_EVENT_ID]
// Then
assertThat(state).isInstanceOf(DefaultContentValidationState::class.java)
}
}
@@ -0,0 +1,54 @@
/*
* 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.contentscanner.impl.di
import com.google.common.truth.Truth.assertThat
import io.element.android.features.contentscanner.impl.DefaultContentScannerService
import io.element.android.libraries.matrix.api.scanner.ContentScanner
import io.element.android.libraries.matrix.test.scanner.FakeContentScanner
import io.element.android.tests.testutils.testCoroutineDispatchers
import kotlinx.coroutines.test.runTest
import org.junit.Test
class ContentScannerModuleTest {
@Test
fun `ContentScannerModule provides NoopContentScannerService when ContentScanner is null`() = runTest {
// Given
val contentScanner: ContentScanner? = null
val sessionCoroutineScope = backgroundScope
val coroutineDispatchers = testCoroutineDispatchers()
// When
val contentScannerService = ContentScannerModule.providesContentScannerService(
contentScanner = contentScanner,
coroutineScope = sessionCoroutineScope,
coroutineDispatchers = coroutineDispatchers,
)
// Then
assertThat(contentScannerService).isInstanceOf(AlwaysValidContentScannerService::class.java)
}
@Test
fun `ContentScannerModule provides DefaultContentScannerService when ContentScanner is not null`() = runTest {
// Given
val contentScanner: ContentScanner? = FakeContentScanner()
val sessionCoroutineScope = backgroundScope
val coroutineDispatchers = testCoroutineDispatchers()
// When
val contentScannerService = ContentScannerModule.providesContentScannerService(
contentScanner = contentScanner,
coroutineScope = sessionCoroutineScope,
coroutineDispatchers = coroutineDispatchers,
)
// Then
assertThat(contentScannerService).isInstanceOf(DefaultContentScannerService::class.java)
}
}
+1
View File
@@ -17,4 +17,5 @@ dependencies {
implementation(projects.libraries.compound)
implementation(projects.libraries.architecture)
implementation(projects.libraries.matrix.api)
implementation(projects.libraries.wellknown.api)
}
@@ -11,6 +11,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.core.SessionId
import io.element.android.libraries.wellknown.api.ElementWellKnown
import kotlinx.coroutines.flow.Flow
interface EnterpriseService {
@@ -41,6 +42,11 @@ interface EnterpriseService {
*/
fun getNoisyNotificationChannelId(sessionId: SessionId): String?
/**
* Gets the overridden Element Well-Known data if it has been set, or null if not set.
*/
fun overriddenElementWellKnown(): ElementWellKnown?
companion object {
const val ANY_ACCOUNT_PROVIDER = "*"
}
@@ -23,6 +23,7 @@ dependencies {
api(projects.features.enterprise.api)
implementation(projects.libraries.architecture)
implementation(projects.libraries.matrix.api)
implementation(projects.libraries.wellknown.api)
testCommonDependencies(libs)
testImplementation(projects.libraries.matrix.test)
@@ -15,6 +15,7 @@ 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.core.SessionId
import io.element.android.libraries.wellknown.api.ElementWellKnown
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flowOf
@@ -45,4 +46,6 @@ class DefaultEnterpriseService : EnterpriseService {
}
override fun getNoisyNotificationChannelId(sessionId: SessionId): String? = null
override fun overriddenElementWellKnown(): ElementWellKnown? = null
}
@@ -18,5 +18,6 @@ dependencies {
implementation(projects.libraries.architecture)
implementation(projects.libraries.compound)
implementation(projects.libraries.matrix.api)
implementation(projects.libraries.wellknown.api)
implementation(projects.tests.testutils)
}
@@ -13,6 +13,7 @@ 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.core.SessionId
import io.element.android.libraries.wellknown.api.ElementWellKnown
import io.element.android.tests.testutils.lambda.lambdaError
import io.element.android.tests.testutils.simulateLongTask
import kotlinx.coroutines.flow.Flow
@@ -31,6 +32,7 @@ class FakeEnterpriseService(
private val unifiedPushDefaultPushGatewayResult: () -> String? = { lambdaError() },
private val getNoisyNotificationChannelIdResult: (SessionId?) -> String? = { lambdaError() },
private val tweakMasUrlResult: (String, String) -> String = { _, _ -> lambdaError() },
private val overrideWellKnownResult: () -> ElementWellKnown? = { lambdaError() },
) : EnterpriseService {
private val brandColorState = MutableStateFlow(initialBrandColor)
private val semanticColorsState = MutableStateFlow(initialSemanticColors)
@@ -79,4 +81,8 @@ class FakeEnterpriseService(
override fun getNoisyNotificationChannelId(sessionId: SessionId): String? {
return getNoisyNotificationChannelIdResult(sessionId)
}
override fun overriddenElementWellKnown(): ElementWellKnown? {
return overrideWellKnownResult()
}
}
@@ -19,6 +19,7 @@ import androidx.compose.foundation.layout.calculateStartPadding
import androidx.compose.foundation.layout.consumeWindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.plus
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
@@ -65,6 +66,8 @@ import io.element.android.libraries.designsystem.theme.components.HorizontalFloa
import io.element.android.libraries.designsystem.theme.components.HorizontalFloatingToolbarSeparator
import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.theme.components.Scaffold
import io.element.android.libraries.designsystem.utils.lazyColumnContentPadding
import io.element.android.libraries.designsystem.utils.scaffoldScrollableContentInsets
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarHost
import io.element.android.libraries.designsystem.utils.snackbar.rememberSnackbarHostState
import io.element.android.libraries.matrix.api.core.RoomId
@@ -234,7 +237,15 @@ private fun HomeScaffold(
)
},
floatingActionButtonPosition = FabPosition.Center,
contentWindowInsets = scaffoldScrollableContentInsets,
content = { padding ->
val outerPadding = PaddingValues(
start = padding.calculateStartPadding(LocalLayoutDirection.current),
end = padding.calculateEndPadding(LocalLayoutDirection.current),
// Remove these two lines once https://issuetracker.google.com/issues/436432313 has been fixed
bottom = padding.calculateBottomPadding(),
top = padding.calculateTopPadding()
)
val contentPadding = PaddingValues(
bottom = 96.dp,
)
@@ -251,18 +262,10 @@ private fun HomeScaffold(
onConfirmRecoveryKeyClick = onConfirmRecoveryKeyClick,
onRoomClick = ::onRoomClick,
onCreateRoomClick = onStartChatClick,
contentPadding = contentPadding,
contentPadding = lazyColumnContentPadding + contentPadding,
modifier = Modifier
.padding(
PaddingValues(
start = padding.calculateStartPadding(LocalLayoutDirection.current),
end = padding.calculateEndPadding(LocalLayoutDirection.current),
// Remove these two lines once https://issuetracker.google.com/issues/436432313 has been fixed
bottom = padding.calculateBottomPadding(),
top = padding.calculateTopPadding()
)
)
.consumeWindowInsets(padding)
.padding(outerPadding)
.consumeWindowInsets(outerPadding)
.hazeSource(state = hazeState)
)
SpaceFiltersView(roomListState.spaceFiltersState)
@@ -271,10 +274,10 @@ private fun HomeScaffold(
HomeSpacesView(
modifier = Modifier
.fillMaxSize()
.padding(padding)
.consumeWindowInsets(padding)
.padding(outerPadding)
.consumeWindowInsets(outerPadding)
.hazeSource(state = hazeState),
contentPadding = contentPadding,
contentPadding = lazyColumnContentPadding + contentPadding,
state = state.homeSpacesState,
lazyListState = spacesLazyListState,
onSpaceClick = { spaceId ->
@@ -12,8 +12,12 @@ import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.RowScope
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.only
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeDrawing
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.pager.VerticalPager
import androidx.compose.foundation.pager.rememberPagerState
@@ -96,13 +100,15 @@ fun HomeTopBar(
spaceFiltersState: SpaceFiltersState,
modifier: Modifier = Modifier,
) {
val contentPadding = WindowInsets.safeDrawing.only(WindowInsetsSides.Horizontal).asPaddingValues()
Column(modifier) {
TopAppBar(
modifier = Modifier
.backgroundVerticalGradient(
isVisible = !areSearchResultsDisplayed,
)
.statusBarsPadding(),
.statusBarsPadding()
.padding(contentPadding),
colors = TopAppBarDefaults.topAppBarColors(
containerColor = Color.Transparent,
scrolledContainerColor = Color.Transparent,
@@ -153,7 +159,7 @@ fun HomeTopBar(
TopAppBarScrollBehaviorLayout(scrollBehavior = scrollBehavior) {
RoomListFiltersView(
state = filtersState,
modifier = Modifier.padding(bottom = 16.dp)
modifier = Modifier.padding(bottom = 16.dp).padding(contentPadding)
)
}
}
@@ -63,6 +63,8 @@ import io.element.android.libraries.designsystem.theme.roomListRoomName
import io.element.android.libraries.designsystem.theme.unreadIndicator
import io.element.android.libraries.matrix.api.notification.CallIntent
import io.element.android.libraries.matrix.api.room.RoomNotificationMode
import io.element.android.libraries.matrix.api.user.DisplayedStatus
import io.element.android.libraries.matrix.ui.components.DisplayNameWithStatus
import io.element.android.libraries.matrix.ui.components.InviteSenderView
import io.element.android.libraries.matrix.ui.model.InviteSender
import io.element.android.libraries.ui.strings.CommonStrings
@@ -126,7 +128,8 @@ internal fun RoomSummaryRow(
NameAndTimestampRow(
name = room.name,
timestamp = room.timestamp,
isHighlighted = room.isHighlighted
isHighlighted = room.isHighlighted,
dmUserStatus = room.dmUserStatus,
)
MessagePreviewAndIndicatorRow(room = room, showUnreadCount = showUnreadCount)
}
@@ -142,7 +145,8 @@ internal fun RoomSummaryRow(
NameAndTimestampRow(
name = room.name,
timestamp = null,
isHighlighted = room.isHighlighted
isHighlighted = room.isHighlighted,
dmUserStatus = null,
)
if (room.canonicalAlias != null) {
Text(
@@ -218,22 +222,21 @@ private fun NameAndTimestampRow(
name: String?,
timestamp: String?,
isHighlighted: Boolean,
dmUserStatus: DisplayedStatus?,
modifier: Modifier = Modifier
) {
Row(
modifier = modifier.fillMaxWidth(),
horizontalArrangement = spacedBy(16.dp)
) {
Text(
modifier = Modifier
.weight(1f)
.clipToBounds(),
val displayName = name?.toSafeLength(ellipsize = true) ?: stringResource(id = CommonStrings.common_no_room_name)
DisplayNameWithStatus(
name = displayName,
status = dmUserStatus,
modifier = Modifier.weight(1f),
style = ElementTheme.typography.fontBodyLgMedium,
text = name?.toSafeLength(ellipsize = true) ?: stringResource(id = CommonStrings.common_no_room_name),
fontStyle = FontStyle.Italic.takeIf { name == null },
color = ElementTheme.colors.roomListRoomName,
maxLines = 1,
overflow = TextOverflow.Ellipsis
nameColor = ElementTheme.colors.roomListRoomName,
nameFontStyle = FontStyle.Italic.takeIf { name == null },
)
// Timestamp
Text(
@@ -21,6 +21,7 @@ import io.element.android.libraries.matrix.api.room.CallIntentConsensus
import io.element.android.libraries.matrix.api.room.CurrentUserMembership
import io.element.android.libraries.matrix.api.roomlist.LatestEventValue
import io.element.android.libraries.matrix.api.roomlist.RoomSummary
import io.element.android.libraries.matrix.ui.model.dmUserStatus
import io.element.android.libraries.matrix.ui.model.getAvatarData
import io.element.android.libraries.matrix.ui.model.toInviteSender
import kotlinx.collections.immutable.toImmutableList
@@ -76,6 +77,7 @@ class RoomListRoomSummaryFactory(
}.toImmutableList(),
isTombstoned = roomInfo.successorRoom != null,
isSpace = roomInfo.isSpace,
dmUserStatus = roomInfo.dmUserStatus(),
)
}
@@ -15,6 +15,7 @@ import io.element.android.libraries.matrix.api.core.RoomAlias
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.notification.CallIntent
import io.element.android.libraries.matrix.api.room.RoomNotificationMode
import io.element.android.libraries.matrix.api.user.DisplayedStatus
import io.element.android.libraries.matrix.ui.model.InviteSender
import kotlinx.collections.immutable.ImmutableList
@@ -42,6 +43,7 @@ data class RoomListRoomSummary(
val isTombstoned: Boolean,
val heroes: ImmutableList<AvatarData>,
val isSpace: Boolean,
val dmUserStatus: DisplayedStatus?,
) {
val isHighlighted = userDefinedNotificationMode != RoomNotificationMode.MUTE &&
(numberOfUnreadNotifications > 0 || numberOfUnreadMentions > 0) ||
@@ -20,6 +20,7 @@ import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.notification.CallIntent
import io.element.android.libraries.matrix.api.room.RoomNotificationMode
import io.element.android.libraries.matrix.api.user.DisplayedStatus
import io.element.android.libraries.matrix.ui.model.InviteSender
import kotlinx.collections.immutable.toImmutableList
@@ -182,6 +183,7 @@ internal fun aRoomListRoomSummary(
heroes: List<AvatarData> = emptyList(),
isTombstoned: Boolean = false,
isSpace: Boolean = false,
dmUserStatus: DisplayedStatus? = null,
) = RoomListRoomSummary(
id = id,
roomId = RoomId(id),
@@ -204,5 +206,6 @@ internal fun aRoomListRoomSummary(
canonicalAlias = canonicalAlias,
heroes = heroes.toImmutableList(),
isTombstoned = isTombstoned,
isSpace = isSpace
isSpace = isSpace,
dmUserStatus = dmUserStatus,
)
@@ -13,9 +13,15 @@ import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.WindowInsetsSides
import androidx.compose.foundation.layout.asPaddingValues
import androidx.compose.foundation.layout.consumeWindowInsets
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.only
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeDrawing
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
@@ -153,7 +159,8 @@ private fun RoomListSearchContent(
}
},
)
}
},
contentWindowInsets = WindowInsets.safeDrawing.only(WindowInsetsSides.Bottom),
) { padding ->
Column(
modifier = Modifier
@@ -167,6 +174,7 @@ private fun RoomListSearchContent(
LazyColumn(
state = lazyListState,
modifier = Modifier.weight(1f),
contentPadding = WindowInsets.navigationBars.asPaddingValues(),
) {
items(
items = state.results,
@@ -15,9 +15,7 @@ import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.systemBarsPadding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
@@ -45,6 +43,8 @@ import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.ModalBottomSheet
import io.element.android.libraries.designsystem.theme.components.SearchField
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.utils.lazyColumnContentPadding
import io.element.android.libraries.designsystem.utils.scaffoldScrollableContentInsets
import io.element.android.libraries.matrix.api.spaces.SpaceServiceFilter
import io.element.android.libraries.matrix.ui.model.getAvatarData
import io.element.android.libraries.ui.strings.CommonStrings
@@ -75,9 +75,7 @@ fun SpaceFiltersView(
}
if (sheetState.isVisible || isSelecting) {
ModalBottomSheet(
modifier = modifier
.systemBarsPadding()
.navigationBarsPadding(),
modifier = modifier,
sheetState = sheetState,
onDismissRequest = {
if (state is SpaceFiltersState.Selecting) {
@@ -85,6 +83,7 @@ fun SpaceFiltersView(
}
},
scrollable = false,
contentWindowInsets = { scaffoldScrollableContentInsets },
) {
Box(
modifier = Modifier
@@ -113,7 +112,7 @@ private fun SpaceFiltersBottomSheetContent(
modifier: Modifier = Modifier
) {
Column(
modifier = modifier.padding(vertical = 16.dp)
modifier = modifier.padding(top = 16.dp)
) {
Text(
text = stringResource(R.string.screen_roomlist_your_spaces),
@@ -131,7 +130,9 @@ private fun SpaceFiltersBottomSheetContent(
placeholder = stringResource(CommonStrings.action_search),
)
Spacer(modifier = Modifier.height(16.dp))
LazyColumn {
LazyColumn(
contentPadding = lazyColumnContentPadding,
) {
items(filters) { filter ->
SpaceFilterItem(
filter = filter,
@@ -12,6 +12,7 @@ import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.matrix.api.room.RoomNotificationMode
import io.element.android.libraries.matrix.api.user.DisplayedStatus
import io.element.android.libraries.matrix.test.A_ROOM_ID
import io.element.android.libraries.matrix.test.A_ROOM_NAME
import kotlinx.collections.immutable.toImmutableList
@@ -87,6 +88,7 @@ internal fun createRoomListRoomSummary(
timestamp: String? = null,
isTombstoned: Boolean = false,
isSpace: Boolean = false,
dmUserStatus: DisplayedStatus? = null,
) = RoomListRoomSummary(
id = A_ROOM_ID.value,
roomId = A_ROOM_ID,
@@ -109,5 +111,6 @@ internal fun createRoomListRoomSummary(
isDm = false,
heroes = heroes.toImmutableList(),
isTombstoned = isTombstoned,
isSpace = isSpace
isSpace = isSpace,
dmUserStatus = dmUserStatus,
)
@@ -88,7 +88,7 @@ class DefaultInvitePeoplePresenter(
override fun present(): InvitePeopleState {
val roomMembers = remember { mutableStateOf<AsyncData<ImmutableList<RoomMember>>>(AsyncData.Loading()) }
val selectedUsers = remember { mutableStateOf<ImmutableList<MatrixUser>>(persistentListOf()) }
val searchResults = remember { mutableStateOf<SearchBarResultState<ImmutableList<InvitableUser>>>(SearchBarResultState.Initial()) }
val searchResults = remember { mutableStateOf<SearchBarResultState<ImmutableList<InvitableUser>>>(SearchBarResultState.Initial) }
val queryState = rememberTextFieldState()
var searchActive by rememberSaveable { mutableStateOf(false) }
val showSearchLoader = rememberSaveable { mutableStateOf(false) }
@@ -332,15 +332,15 @@ class DefaultInvitePeoplePresenter(
showSearchLoader: MutableState<Boolean>,
searchQuery: String,
) = withContext(coroutineDispatchers.io) {
searchResults.value = SearchBarResultState.Initial()
searchResults.value = SearchBarResultState.Initial
showSearchLoader.value = false
val joinedMembers = roomMembers.value.dataOrNull().orEmpty()
userRepository.search(searchQuery).onEach { state ->
showSearchLoader.value = state.isSearching
searchResults.value = when {
state.results.isEmpty() && state.isSearching -> SearchBarResultState.Initial()
state.results.isEmpty() && !state.isSearching -> SearchBarResultState.NoResultsFound()
state.results.isEmpty() && state.isSearching -> SearchBarResultState.Initial
state.results.isEmpty() && !state.isSearching -> SearchBarResultState.NoResultsFound
else -> SearchBarResultState.Results(state.results.map { result ->
val existingMembership = joinedMembers.firstOrNull { j -> j.userId == result.matrixUser.userId }?.membership
val isJoined = existingMembership == RoomMembershipState.JOIN
@@ -33,7 +33,7 @@ internal class DefaultInvitePeopleStateProvider : PreviewParameterProvider<Defau
aDefaultInvitePeopleState(canInvite = true, selectedUsers = aMatrixUserList().toImmutableList()),
aDefaultInvitePeopleState(isSearchActive = true, searchQuery = "some query"),
aDefaultInvitePeopleState(isSearchActive = true, searchQuery = "some query", selectedUsers = aMatrixUserList().toImmutableList()),
aDefaultInvitePeopleState(isSearchActive = true, searchQuery = "some query", searchResults = SearchBarResultState.NoResultsFound()),
aDefaultInvitePeopleState(isSearchActive = true, searchQuery = "some query", searchResults = SearchBarResultState.NoResultsFound),
aDefaultInvitePeopleState(
isSearchActive = true,
canInvite = true,
@@ -115,7 +115,7 @@ private fun aDefaultInvitePeopleState(
room: AsyncData<Unit> = AsyncData.Success(Unit),
canInvite: Boolean = false,
searchQuery: String = "",
searchResults: SearchBarResultState<ImmutableList<InvitableUser>> = SearchBarResultState.Initial(),
searchResults: SearchBarResultState<ImmutableList<InvitableUser>> = SearchBarResultState.Initial,
selectedUsers: ImmutableList<MatrixUser> = persistentListOf(),
isSearchActive: Boolean = false,
showSearchLoader: Boolean = false,
@@ -2,4 +2,8 @@
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_invite_users_already_a_member">"Allerede medlem"</string>
<string name="screen_invite_users_already_invited">"Allerede invitert"</string>
<string name="screen_invite_users_confirm_dialog_subtitle_multiple_users">"Du har for øyeblikket ingen samtaler med disse kontaktene. Bekreft at du vil invitere dem til dette rommet før du fortsetter."</string>
<string name="screen_invite_users_confirm_dialog_subtitle_one_user">"Du har for øyeblikket ingen samtaler med denne kontakten. Bekreft at du vil invitere vedkommende til dette rommet før du fortsetter."</string>
<string name="screen_invite_users_confirm_dialog_title_mutiple_users">"Vil du invitere nye kontakter til dette rommet?"</string>
<string name="screen_invite_users_confirm_dialog_title_one_user">"Vil du invitere en ny kontakt til dette rommet?"</string>
</resources>
@@ -95,7 +95,7 @@ internal class DefaultInvitePeoplePresenterTest {
resultState.searchQuery.setTextAndPlaceCursorAtEnd("some query")
assertThat(awaitItemAsDefault().searchQuery.text.toString()).isEqualTo("some query")
resultState.eventSink(InvitePeopleEvents.CloseSearch)
skipItems(2)
skipItems(1)
awaitItemAsDefault().also {
assertThat(it.isSearchActive).isFalse()
assertThat(it.searchQuery.text.toString()).isEmpty()
@@ -116,7 +116,7 @@ internal class DefaultInvitePeoplePresenterTest {
initialState.searchQuery.setTextAndPlaceCursorAtEnd("some query")
assertThat(repository.providedQuery).isEqualTo("some query")
repository.emitState(UserSearchResultState(results = emptyList(), isSearching = true))
skipItems(3)
skipItems(2)
awaitItemAsDefault().also { state ->
assertThat(state.searchResults).isInstanceOf(SearchBarResultState.Initial::class.java)
assertThat(state.showSearchLoader).isTrue()
@@ -145,7 +145,6 @@ internal class DefaultInvitePeoplePresenterTest {
assertThat(repository.providedQuery).isEqualTo("some query")
repository.emitStateWithUsers(users = aMatrixUserList())
skipItems(1)
val resultState = awaitItemAsDefault()
assertThat(resultState.searchResults).isInstanceOf(SearchBarResultState.Results::class.java)
@@ -198,7 +197,6 @@ internal class DefaultInvitePeoplePresenterTest {
assertThat(repository.providedQuery).isEqualTo("some query")
repository.emitStateWithUsers(users = aMatrixUserList())
skipItems(1)
val resultState = awaitItemAsDefault()
assertThat(resultState.searchResults).isInstanceOf(SearchBarResultState.Results::class.java)
@@ -265,7 +263,6 @@ internal class DefaultInvitePeoplePresenterTest {
it
)
})
skipItems(1)
val resultState = awaitItemAsDefault()
assertThat(resultState.searchResults).isInstanceOf(SearchBarResultState.Results::class.java)
@@ -329,7 +326,7 @@ internal class DefaultInvitePeoplePresenterTest {
assertThat(repository.providedQuery).isEqualTo("some query")
repository.emitStateWithUsers(users = aMatrixUserList() + selectedUser)
skipItems(2)
skipItems(1)
val resultState = awaitItemAsDefault()
assertThat(resultState.searchResults).isInstanceOf(SearchBarResultState.Results::class.java)
@@ -366,7 +363,6 @@ internal class DefaultInvitePeoplePresenterTest {
assertThat(repository.providedQuery).isEqualTo("some query")
repository.emitStateWithUsers(users = aMatrixUserList() + selectedUser)
skipItems(1)
awaitItemAsDefault().also { state ->
// selectedUser is not selected
assertThat(state.searchResults).isInstanceOf(SearchBarResultState.Results::class.java)
@@ -732,7 +728,7 @@ internal class DefaultInvitePeoplePresenterTest {
isSearching = true
)
)
skipItems(3)
skipItems(2)
awaitItemAsDefault().run {
assertThat(selectedUsers).containsExactly(alice, bob, charlie)
@@ -23,6 +23,7 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.plus
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
@@ -73,6 +74,8 @@ import io.element.android.libraries.designsystem.theme.components.Scaffold
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.theme.components.TextButton
import io.element.android.libraries.designsystem.theme.components.TopAppBar
import io.element.android.libraries.designsystem.utils.lazyColumnContentPadding
import io.element.android.libraries.designsystem.utils.scaffoldScrollableContentInsets
import io.element.android.libraries.ui.strings.CommonStrings
import kotlinx.collections.immutable.ImmutableList
@@ -94,7 +97,8 @@ fun KnockRequestsListView(
.padding(padding)
.consumeWindowInsets(padding),
)
}
},
contentWindowInsets = scaffoldScrollableContentInsets,
)
}
@@ -132,7 +136,7 @@ private fun KnockRequestsListContent(
onAcceptClick = ::onAcceptClick,
onDeclineClick = ::onDeclineClick,
onBanClick = ::onBanClick,
contentPadding = PaddingValues(bottom = bottomPaddingInPixels.toDp()),
contentPadding = lazyColumnContentPadding + PaddingValues(bottom = bottomPaddingInPixels.toDp()),
)
}
}
@@ -9,7 +9,7 @@
package io.element.android.features.licenses.impl.details
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.plus
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.ExperimentalMaterial3Api
@@ -25,6 +25,8 @@ import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.ListItem
import io.element.android.libraries.designsystem.theme.components.Scaffold
import io.element.android.libraries.designsystem.theme.components.TopAppBar
import io.element.android.libraries.designsystem.utils.lazyColumnContentPadding
import io.element.android.libraries.designsystem.utils.scaffoldScrollableContentInsets
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@@ -41,9 +43,10 @@ fun DependenciesDetailsView(
navigationIcon = { BackButton(onClick = onBack) },
)
},
contentWindowInsets = scaffoldScrollableContentInsets,
) { contentPadding ->
LazyColumn(
modifier = Modifier.padding(contentPadding),
contentPadding = contentPadding + lazyColumnContentPadding,
) {
val licenses = licenseItem.licenses.orEmpty() +
licenseItem.unknownLicenses.orEmpty()
@@ -12,6 +12,7 @@ import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.plus
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.ExperimentalMaterial3Api
@@ -34,6 +35,8 @@ import io.element.android.libraries.designsystem.theme.components.Scaffold
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.theme.components.TextField
import io.element.android.libraries.designsystem.theme.components.TopAppBar
import io.element.android.libraries.designsystem.utils.lazyColumnContentPadding
import io.element.android.libraries.designsystem.utils.scaffoldScrollableContentInsets
import io.element.android.libraries.ui.strings.CommonStrings
@OptIn(ExperimentalMaterial3Api::class)
@@ -52,10 +55,11 @@ fun DependencyLicensesListView(
navigationIcon = { BackButton(onClick = onBackClick) },
)
},
contentWindowInsets = scaffoldScrollableContentInsets,
) { contentPadding ->
Column(
modifier = Modifier
.padding(contentPadding)
.padding(contentPadding + lazyColumnContentPadding)
.padding(horizontal = 16.dp)
) {
if (state.licenses.isSuccess()) {
@@ -72,7 +76,9 @@ fun DependencyLicensesListView(
modifier = Modifier.fillMaxWidth(),
)
}
LazyColumn {
LazyColumn(
contentPadding = lazyColumnContentPadding,
) {
when (state.licenses) {
is AsyncData.Failure -> item {
Text(
@@ -9,21 +9,25 @@
package io.element.android.features.linknewdevice.impl.screens.desktop
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme
import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.features.linknewdevice.impl.R
import io.element.android.libraries.designsystem.atomic.organisms.NumberedListOrganism
@@ -34,6 +38,7 @@ import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.LocalBuildMeta
import io.element.android.libraries.designsystem.theme.components.Button
import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.utils.annotatedTextWithBold
import io.element.android.libraries.permissions.api.PermissionsView
import kotlinx.collections.immutable.persistentListOf
@@ -62,6 +67,7 @@ fun DesktopNoticeView(
title = stringResource(R.string.screen_link_new_device_desktop_title, appName),
iconStyle = BigIcon.Style.Default(CompoundIcons.Computer()),
modifier = modifier,
isScrollable = true,
buttons = {
Button(
text = stringResource(R.string.screen_link_new_device_desktop_submit),
@@ -71,11 +77,10 @@ fun DesktopNoticeView(
}
) {
Column(
Modifier.fillMaxWidth()
Modifier.fillMaxWidth(),
) {
Spacer(modifier = Modifier.height(40.dp))
NumberedListOrganism(
modifier = Modifier.fillMaxSize(),
items = persistentListOf(
AnnotatedString(stringResource(R.string.screen_link_new_device_desktop_step1, appName)),
annotatedTextWithBold(
@@ -88,6 +93,17 @@ fun DesktopNoticeView(
AnnotatedString(stringResource(R.string.screen_link_new_device_desktop_step3)),
)
)
Spacer(modifier = Modifier.height(32.dp))
Text(
modifier = Modifier
.clip(RoundedCornerShape(12.dp))
.background(ElementTheme.colors.bgCriticalSubtle)
.padding(16.dp)
.fillMaxWidth(),
text = stringResource(R.string.screen_link_new_device_desktop_warning),
style = ElementTheme.typography.fontBodyMdMedium,
color = ElementTheme.colors.textCriticalPrimary,
)
}
}
@@ -5,6 +5,7 @@
<string name="screen_link_new_device_desktop_step3">"Zeskanuj kod QR za pomocą tego urządzenia"</string>
<string name="screen_link_new_device_desktop_submit">"Gotowy do skanowania"</string>
<string name="screen_link_new_device_desktop_title">"Otwórz %1$s na komputerze stacjonarnym, aby uzyskać kod QR"</string>
<string name="screen_link_new_device_desktop_warning">"Zeskanuj kod QR dopiero po wykonaniu powyższych kroków na swoim urządzeniu. Nigdy nie skanuj kodu przesłanego przez inną osobę."</string>
<string name="screen_link_new_device_enter_number_error_numbers_do_not_match">"Liczby nie pasują do siebie"</string>
<string name="screen_link_new_device_enter_number_notice">"Wprowadź 2-cyfrowy kod"</string>
<string name="screen_link_new_device_enter_number_subtitle">"Pozwoli to sprawdzić, czy połączenie z drugim urządzeniem jest bezpieczne."</string>
@@ -5,6 +5,7 @@
<string name="screen_link_new_device_desktop_step3">"Зіскануйте QR-код цим пристроєм"</string>
<string name="screen_link_new_device_desktop_submit">"Готовий до сканування"</string>
<string name="screen_link_new_device_desktop_title">"Відкрийте %1$s на комп\'ютері, щоб отримати QR-код"</string>
<string name="screen_link_new_device_desktop_warning">"Скануйте QR-код лише після виконання наведених вище кроків на своєму власному пристрої. Ніколи не скануйте код, надісланий іншою особою."</string>
<string name="screen_link_new_device_enter_number_error_numbers_do_not_match">"Цифри не збігаються"</string>
<string name="screen_link_new_device_enter_number_notice">"Введіть 2-значний код"</string>
<string name="screen_link_new_device_enter_number_subtitle">"Це підтвердить, що з\'єднання з іншим пристроєм захищене."</string>
@@ -5,6 +5,7 @@
<string name="screen_link_new_device_desktop_step3">"使用此设备扫描二维码"</string>
<string name="screen_link_new_device_desktop_submit">"准备进行扫描"</string>
<string name="screen_link_new_device_desktop_title">"在台式电脑上打开 %1$s 以获取二维码"</string>
<string name="screen_link_new_device_desktop_warning">"确保仅在属于你自己的设备上按上述步骤操作后再扫描二维码。绝对不要扫描来自他人的二维码。"</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>
@@ -5,6 +5,7 @@
<string name="screen_link_new_device_desktop_step3">"Scan the QR code with this device"</string>
<string name="screen_link_new_device_desktop_submit">"Ready to scan"</string>
<string name="screen_link_new_device_desktop_title">"Open %1$s on a desktop computer to get the QR code"</string>
<string name="screen_link_new_device_desktop_warning">"Only scan a QR code after following the steps above on your own device. Never scan a code sent by another person."</string>
<string name="screen_link_new_device_enter_number_error_numbers_do_not_match">"The numbers don’t match"</string>
<string name="screen_link_new_device_enter_number_notice">"Enter 2-digit code"</string>
<string name="screen_link_new_device_enter_number_subtitle">"This will verify that the connection to your other device is secure."</string>
@@ -52,9 +52,11 @@ class DefaultActiveLiveLocationShareManager(
private val clock: SystemClock,
private val sessionObserver: SessionObserver,
) : ActiveLiveLocationShareManager, LiveLocationReceiver {
private data class Timeout(val expiresAt: Instant, val job: Job)
private val isSetup = AtomicBoolean(false)
private val cachedRooms = ConcurrentHashMap<RoomId, JoinedRoom>()
private val timeoutJobs = ConcurrentHashMap<RoomId, Job>()
private val timeouts = ConcurrentHashMap<RoomId, Timeout>()
private val syncedActiveShareIds = MutableStateFlow<Set<BeaconId>>(emptySet())
private val localSharingRoomIds = MutableStateFlow<Set<RoomId>>(emptySet())
override val sharingRoomIds: StateFlow<Set<RoomId>> = localSharingRoomIds
@@ -139,9 +141,9 @@ class DefaultActiveLiveLocationShareManager(
}
override suspend fun onLocationUpdate(location: Location) {
val activeSharesCount = localSharingRoomIds.value.size
Timber.d("ActiveLiveLocationShareManager received location update for $activeSharesCount active share(s)")
localSharingRoomIds.value.forEach { roomId ->
val active = stopExpiredShares()
Timber.d("ActiveLiveLocationShareManager received location update for ${active.size} active share(s)")
active.forEach { roomId ->
Timber.d("ActiveLiveLocationShareManager sending location to room $roomId")
sendLiveLocation(roomId, location)
.onFailure {
@@ -150,6 +152,19 @@ class DefaultActiveLiveLocationShareManager(
}
}
private suspend fun stopExpiredShares(): List<RoomId> {
val nowMillis = clock.epochMillis()
val (expired, active) = localSharingRoomIds.value.partition { roomId ->
val timeout = timeouts[roomId] ?: return@partition false
timeout.expiresAt.toEpochMilliseconds() <= nowMillis
}
expired.forEach { roomId ->
Timber.d("ActiveLiveLocationShareManager location tick detected expired share for room $roomId, stopping")
stopShare(roomId)
}
return active
}
private suspend fun sendLiveLocation(roomId: RoomId, location: Location): Result<Unit> {
val room = cachedRooms.getOrPut(roomId) {
matrixClient.getJoinedRoom(roomId) ?: return Result.failure(IllegalStateException("No room found for $roomId"))
@@ -192,20 +207,21 @@ class DefaultActiveLiveLocationShareManager(
}
private fun scheduleTimeout(roomId: RoomId, expiresAt: Instant) {
timeoutJobs.remove(roomId)?.cancel()
timeouts.remove(roomId)?.job?.cancel()
val delayMillis = expiresAt.toEpochMilliseconds() - clock.epochMillis()
timeoutJobs[roomId] = matrixClient.sessionCoroutineScope.launch {
val job = matrixClient.sessionCoroutineScope.launch {
delay(delayMillis)
stopShare(roomId)
.onFailure { error ->
Timber.e(error, "ActiveLiveLocationShareManager failed to stop timed out share for room $roomId")
}
}
timeouts[roomId] = Timeout(expiresAt = expiresAt, job = job)
}
private suspend fun stopLocalShare(roomId: RoomId) {
Timber.d("ActiveLiveLocationShareManager stop local share in $roomId")
timeoutJobs.remove(roomId)?.cancel()
timeouts.remove(roomId)?.job?.cancel()
val wasSharing = localSharingRoomIds.getAndUpdate { it - roomId }.isNotEmpty()
cachedRooms.remove(roomId)?.close()
liveLocationStore.removeLiveLocationExpiry(roomId)
@@ -220,11 +236,11 @@ class DefaultActiveLiveLocationShareManager(
sessionObserver.removeListener(sessionListener)
coordinator.unregister(matrixClient.sessionId)
liveLocationStore.clear()
timeouts.values.forEach { it.job.cancel() }
timeouts.clear()
for (room in cachedRooms.values) {
room.close()
timeoutJobs[room.roomId]?.cancel()
}
timeoutJobs.clear()
cachedRooms.clear()
localSharingRoomIds.value = emptySet()
syncedActiveShareIds.value = emptySet()
@@ -1,4 +1,6 @@
<?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">"Din sanntids lokasjonshistorikk lagres i rommet og vil være synlig for medlemmene etter at økten er avsluttet."</string>
<string name="screen_share_location_live_location_duration_picker_title">"Velg hvor lenge du vil dele din sanntidslokasjon."</string>
<string name="screen_share_location_live_location_missing_permissions">"Du har ikke tillatelse til å dele din sanntidslokasjon i dette rommet"</string>
</resources>
@@ -373,6 +373,53 @@ class DefaultActiveLiveLocationShareManagerTest {
}
}
@Test
fun `location update after expiry stops the share and does not send location`() = runTest {
val liveLocationStore = createInMemoryLiveLocationStore()
val beaconInfoUpdates = MutableSharedFlow<BeaconInfoUpdate>(replay = 1)
val stopLiveLocationShareResult = lambdaRecorder<Result<Unit>> { Result.success(Unit) }
val sendLiveLocationResult = lambdaRecorder<String, Result<Unit>> { _ -> Result.success(Unit) }
val clock = FakeSystemClock(epochMillisResult = 1_000L)
val manager = createManager(
client = FakeMatrixClient(
sessionId = A_SESSION_ID,
sessionCoroutineScope = backgroundScope,
ownBeaconInfoUpdates = beaconInfoUpdates,
).apply {
givenGetRoomResult(
A_ROOM_ID,
FakeJoinedRoom(
startLiveLocationShareResult = { Result.success(AN_EVENT_ID) },
stopLiveLocationShareResult = stopLiveLocationShareResult,
sendLiveLocationResult = sendLiveLocationResult,
),
)
},
coordinator = createCoordinator(),
liveLocationStore = liveLocationStore,
clock = clock,
)
advanceUntilIdle()
val startResult = async { manager.startShare(A_ROOM_ID, 1.minutes) }
beaconInfoUpdates.emit(BeaconInfoUpdate(roomId = A_ROOM_ID, beaconId = AN_EVENT_ID, isLive = true))
assertThat(startResult.await().isSuccess).isTrue()
// Advance the clock past the expiry so the next location tick is beyond the deadline.
clock.epochMillisResult = 1_000L + 1.minutes.inWholeMilliseconds + 1_000L
manager.sharingRoomIds.test {
assertThat(awaitItem()).containsExactly(A_ROOM_ID)
manager.onLocationUpdate(io.element.android.features.location.api.Location(lat = 0.0, lon = 0.0, accuracy = null))
assertThat(awaitItem()).isEmpty()
advanceUntilIdle()
}
assertThat(liveLocationStore.getLiveLocationExpiries()).doesNotContainKey(A_ROOM_ID)
assert(sendLiveLocationResult).isNeverCalled()
// stopLiveLocationShare is called twice: once defensively in startShare, once from the tick-triggered stopShare.
assert(stopLiveLocationShareResult).isCalledExactly(2)
}
@Test
fun `session deleted clears local state`() = runTest {
val startServiceRecorder = lambdaRecorder<Unit> { }
@@ -18,6 +18,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme
import io.element.android.compound.tokens.generated.CompoundIcons
@@ -40,7 +41,7 @@ fun AccountProviderOtherView(
Column(
modifier = modifier
.fillMaxWidth()
.clickable { onClick() }
.clickable(role = Role.Button) { onClick() }
) {
HorizontalDivider()
Row(
@@ -19,6 +19,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme
@@ -45,7 +46,7 @@ fun AccountProviderView(
Column(
modifier = modifier
.fillMaxWidth()
.clickable { onClick() }
.clickable(role = Role.Button) { onClick() }
) {
HorizontalDivider()
Column(
@@ -141,6 +141,8 @@ class QrCodeLoginFlowNode(
}
QrLoginException.CheckCodeAlreadySent,
QrLoginException.CheckCodeCannotBeSent,
QrLoginException.ContinuationAlreadySent,
QrLoginException.ContinuationCannotBeSent,
QrLoginException.UnsupportedQrCodeType,
QrLoginException.Unknown -> {
Timber.e(error, "Unknown error found")
@@ -39,6 +39,8 @@ import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.Scaffold
import io.element.android.libraries.designsystem.theme.components.TopAppBar
import io.element.android.libraries.designsystem.utils.lazyColumnContentPadding
import io.element.android.libraries.designsystem.utils.scaffoldScrollableContentInsets
/**
* https://www.figma.com/file/o9p34zmiuEpZRyvZXJZAYL/FTUE?type=design&node-id=604-60817
@@ -59,7 +61,8 @@ fun ChangeAccountProviderView(
title = {},
navigationIcon = { BackButton(onClick = onBackClick) }
)
}
},
contentWindowInsets = scaffoldScrollableContentInsets,
) { padding ->
Box(
modifier = Modifier
@@ -71,6 +74,7 @@ fun ChangeAccountProviderView(
Column(
modifier = Modifier
.verticalScroll(state = rememberScrollState())
.padding(lazyColumnContentPadding)
) {
IconTitleSubtitleMolecule(
modifier = Modifier.padding(top = 16.dp, bottom = 32.dp, start = 16.dp, end = 16.dp),
@@ -44,6 +44,8 @@ import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.Button
import io.element.android.libraries.designsystem.theme.components.Scaffold
import io.element.android.libraries.designsystem.theme.components.TopAppBar
import io.element.android.libraries.designsystem.utils.lazyColumnContentPadding
import io.element.android.libraries.designsystem.utils.scaffoldScrollableContentInsets
import io.element.android.libraries.matrix.api.auth.OAuthDetails
import io.element.android.libraries.permissions.api.localnetwork.LocalNetworkPermissionDialogView
import io.element.android.libraries.ui.strings.CommonStrings
@@ -71,7 +73,8 @@ fun ChooseAccountProviderView(
title = {},
navigationIcon = { BackButton(onClick = onBackClick) }
)
}
},
contentWindowInsets = scaffoldScrollableContentInsets,
) { padding ->
Box(
modifier = Modifier
@@ -84,6 +87,7 @@ fun ChooseAccountProviderView(
modifier = Modifier
.fillMaxSize()
.verticalScroll(state = rememberScrollState())
.padding(lazyColumnContentPadding)
) {
IconTitleSubtitleMolecule(
modifier = Modifier.padding(top = 16.dp, bottom = 32.dp, start = 16.dp, end = 16.dp),
@@ -36,6 +36,7 @@ import androidx.compose.ui.focus.FocusDirection
import androidx.compose.ui.platform.LocalAutofillManager
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.semantics.contentType
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.input.ImeAction
@@ -211,10 +212,15 @@ private fun LoginForm(
singleLine = true,
trailingIcon = if (loginFieldState.isNotEmpty()) {
{
Box(Modifier.clickable {
loginFieldState = ""
eventSink(LoginPasswordEvents.SetLogin(""))
}) {
Box(
Modifier.clickable(
onClickLabel = stringResource(CommonStrings.action_clear),
role = Role.Button,
) {
loginFieldState = ""
eventSink(LoginPasswordEvents.SetLogin(""))
}
) {
Icon(
imageVector = CompoundIcons.Close(),
contentDescription = stringResource(CommonStrings.action_clear),
@@ -27,6 +27,7 @@ import androidx.compose.ui.BiasAlignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
@@ -336,7 +337,7 @@ private fun OnBoardingButtons(
} else {
Text(
modifier = Modifier
.clickable {
.clickable(role = Role.Button) {
state.eventSink(OnBoardingEvents.OnVersionClick)
}
.padding(16.dp),
@@ -17,7 +17,6 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import dev.zacsweers.metro.Inject
import io.element.android.features.login.impl.accesscontrol.DefaultAccountProviderAccessControl
import io.element.android.features.login.impl.qrcode.QrCodeLoginManager
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.architecture.Presenter
@@ -39,7 +38,6 @@ class QrCodeScanPresenter(
private val qrCodeLoginDataFactory: MatrixQrCodeLoginDataFactory,
private val qrCodeLoginManager: QrCodeLoginManager,
private val coroutineDispatchers: CoroutineDispatchers,
private val defaultAccountProviderAccessControl: DefaultAccountProviderAccessControl,
) : Presenter<QrCodeScanState> {
private var isScanning by mutableStateOf(true)
@@ -97,13 +95,6 @@ class QrCodeScanPresenter(
val data = qrCodeLoginDataFactory.parseQrCodeData(code).onFailure {
Timber.e(it, "Error parsing QR code data")
}.getOrThrow()
val serverName = data.serverName()
if (serverName != null) {
defaultAccountProviderAccessControl.assertIsAllowedToConnectToAccountProvider(
title = serverName,
accountProviderUrl = serverName,
)
}
data
}.runCatchingUpdatingState(codeScannedAction)
}.invokeOnCompletion {
@@ -34,6 +34,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusDirection
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.tooling.preview.PreviewParameter
@@ -125,10 +126,15 @@ fun SearchAccountProviderView(
singleLine = true,
trailingIcon = if (userInputState.isNotEmpty()) {
{
Box(Modifier.clickable {
userInputState = ""
eventSink(SearchAccountProviderEvents.UserInput(""))
}) {
Box(
Modifier.clickable(
onClickLabel = stringResource(CommonStrings.action_clear),
role = Role.Button,
) {
userInputState = ""
eventSink(SearchAccountProviderEvents.UserInput(""))
}
) {
Icon(
imageVector = CompoundIcons.Close(),
contentDescription = stringResource(CommonStrings.action_clear)
@@ -37,7 +37,14 @@
<string name="screen_login_subtitle">"Matrix er et åpent nettverk for sikker, desentralisert kommunikasjon."</string>
<string name="screen_login_title">"Velkommen tilbake!"</string>
<string name="screen_login_title_with_homeserver">"Logg inn på %1$s"</string>
<string name="screen_missing_key_backup_open_element_classic">"Åpne Element Classic"</string>
<string name="screen_missing_key_backup_step_1">"Åpne Element Classic på enheten din"</string>
<string name="screen_missing_key_backup_step_2_android">"Gå til Innstillinger → Sikkerhet og personvern"</string>
<string name="screen_missing_key_backup_step_4">"Følg instruksjonene for å aktivere nøkkellagringen din"</string>
<string name="screen_missing_key_backup_step_5">"Kom tilbake til %1$s"</string>
<string name="screen_missing_key_backup_title">"Aktiver nøkkellagringen din før du går videre til %1$s"</string>
<string name="screen_onboarding_app_version">"Versjon %1$s"</string>
<string name="screen_onboarding_checking_account">"Sjekker kontoen"</string>
<string name="screen_onboarding_sign_in_manually">"Logg på manuelt"</string>
<string name="screen_onboarding_sign_in_to">"Logg inn på %1$s"</string>
<string name="screen_onboarding_sign_in_with_qr_code">"Logg inn med QR-kode"</string>
@@ -9,18 +9,12 @@
package io.element.android.features.login.impl.screens.qrcode.scan
import com.google.common.truth.Truth.assertThat
import io.element.android.features.enterprise.api.EnterpriseService
import io.element.android.features.enterprise.test.FakeEnterpriseService
import io.element.android.features.login.impl.accesscontrol.DefaultAccountProviderAccessControl
import io.element.android.features.login.impl.changeserver.AccountProviderAccessException
import io.element.android.features.login.impl.qrcode.FakeQrCodeLoginManager
import io.element.android.features.wellknown.test.FakeWellknownRetriever
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.matrix.api.auth.qrlogin.QrCodeLoginStep
import io.element.android.libraries.matrix.api.auth.qrlogin.QrLoginException
import io.element.android.libraries.matrix.test.auth.qrlogin.FakeMatrixQrCodeLoginData
import io.element.android.libraries.matrix.test.auth.qrlogin.FakeMatrixQrCodeLoginDataFactory
import io.element.android.libraries.wellknown.api.WellknownRetriever
import io.element.android.tests.testutils.lambda.lambdaRecorder
import io.element.android.tests.testutils.test
import io.element.android.tests.testutils.testCoroutineDispatchers
@@ -53,9 +47,6 @@ class QrCodeScanPresenterTest {
)
val presenter = createQrCodeScanPresenter(
qrCodeLoginDataFactory = qrCodeLoginDataFactory,
enterpriseService = FakeEnterpriseService(
isAllowedToConnectToHomeserverResult = { true },
)
)
presenter.test {
val initialState = awaitItem()
@@ -66,44 +57,6 @@ class QrCodeScanPresenterTest {
}
}
@Test
fun `present - scanned QR code successfully, but homeserver not allowed`() = runTest {
val qrCodeLoginDataFactory = FakeMatrixQrCodeLoginDataFactory(
parseQrCodeLoginDataResult = {
Result.success(
FakeMatrixQrCodeLoginData(
serverNameResult = { "example.com" }
)
)
}
)
val presenter = createQrCodeScanPresenter(
qrCodeLoginDataFactory = qrCodeLoginDataFactory,
enterpriseService = FakeEnterpriseService(
isAllowedToConnectToHomeserverResult = { false },
defaultHomeserverListResult = { listOf("element.io") },
)
)
presenter.test {
val initialState = awaitItem()
initialState.eventSink(QrCodeScanEvents.QrCodeScanned(byteArrayOf()))
assertThat(awaitItem().isScanning).isFalse()
assertThat(awaitItem().authenticationAction.isLoading()).isTrue()
awaitItem().also { state ->
assertThat(
(state.authenticationAction
.errorOrNull() as AccountProviderAccessException.UnauthorizedAccountProviderException).unauthorisedAccountProviderTitle
)
.isEqualTo("example.com")
assertThat(
(state.authenticationAction
.errorOrNull() as AccountProviderAccessException.UnauthorizedAccountProviderException).authorisedAccountProviderTitles
)
.containsExactly("element.io")
}
}
}
@Test
fun `present - scanned QR code failed and can be retried`() = runTest {
val qrCodeLoginDataFactory = FakeMatrixQrCodeLoginDataFactory(
@@ -153,15 +106,9 @@ class QrCodeScanPresenterTest {
qrCodeLoginDataFactory: FakeMatrixQrCodeLoginDataFactory = FakeMatrixQrCodeLoginDataFactory(),
coroutineDispatchers: CoroutineDispatchers = testCoroutineDispatchers(),
qrCodeLoginManager: FakeQrCodeLoginManager = FakeQrCodeLoginManager(),
enterpriseService: EnterpriseService = FakeEnterpriseService(),
wellknownRetriever: WellknownRetriever = FakeWellknownRetriever(),
) = QrCodeScanPresenter(
qrCodeLoginDataFactory = qrCodeLoginDataFactory,
qrCodeLoginManager = qrCodeLoginManager,
coroutineDispatchers = coroutineDispatchers,
defaultAccountProviderAccessControl = DefaultAccountProviderAccessControl(
enterpriseService = enterpriseService,
wellknownRetriever = wellknownRetriever,
),
)
}
+4 -2
View File
@@ -51,7 +51,7 @@ dependencies {
implementation(projects.libraries.mediaupload.api)
implementation(projects.libraries.permissions.api)
implementation(projects.libraries.preferences.api)
implementation(projects.libraries.recentemojis.api)
implementation(projects.libraries.emoji.api)
implementation(projects.libraries.roomselect.api)
implementation(projects.libraries.slashcommands.api)
implementation(projects.libraries.audio.api)
@@ -62,6 +62,7 @@ dependencies {
implementation(projects.libraries.uiUtils)
implementation(projects.libraries.testtags)
implementation(projects.features.networkmonitor.api)
implementation(projects.features.contentscanner.api)
implementation(projects.services.analytics.compose)
implementation(projects.services.appnavstate.api)
implementation(projects.services.toolbox.api)
@@ -105,6 +106,7 @@ dependencies {
testImplementation(projects.libraries.testtags)
testImplementation(projects.features.poll.test)
testImplementation(projects.libraries.eventformatter.test)
testImplementation(projects.libraries.recentemojis.test)
testImplementation(projects.libraries.emoji.impl)
testImplementation(projects.libraries.emoji.test)
testImplementation(projects.libraries.slashcommands.test)
}
@@ -57,6 +57,7 @@ import io.element.android.features.messages.impl.timeline.model.event.TimelineIt
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemLocationContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVideoContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVoiceContent
import io.element.android.features.messages.impl.timeline.model.event.blurHash
import io.element.android.features.messages.impl.timeline.model.event.duration
import io.element.android.features.poll.api.create.CreatePollEntryPoint
import io.element.android.features.poll.api.create.CreatePollMode
@@ -155,6 +156,7 @@ class MessagesFlowNode(
val mediaSource: MediaSource,
val thumbnailSource: MediaSource?,
val canUseOverlay: Boolean,
val blurHash: String?,
) : NavTarget
@Parcelize
@@ -377,6 +379,7 @@ class MessagesFlowNode(
mediaInfo = navTarget.mediaInfo,
mediaSource = navTarget.mediaSource,
thumbnailSource = navTarget.thumbnailSource,
blurHash = navTarget.blurHash,
)
val callback = object : MediaViewerEntryPoint.Callback {
override fun onDone() {
@@ -828,6 +831,7 @@ class MessagesFlowNode(
mediaSource = galleryItem.mediaSource,
thumbnailSource = galleryItem.thumbnailSource,
type = galleryItem.type.toMediaViewerType(),
blurHash = galleryItem.blurhash,
)
}.reversed()
NavTarget.GalleryViewer(
@@ -861,6 +865,7 @@ class MessagesFlowNode(
mediaSource = attachment.mediaSource,
thumbnailSource = attachment.thumbnailSource,
type = GalleryItemData.Type.File,
blurHash = null,
)
}.reversed()
NavTarget.GalleryViewer(
@@ -920,6 +925,7 @@ class MessagesFlowNode(
mediaSource = mediaSource,
thumbnailSource = thumbnailSource,
canUseOverlay = canUseOverlay,
blurHash = content.blurHash(),
)
}
@@ -38,6 +38,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 +55,7 @@ import io.element.android.libraries.designsystem.utils.OnLifecycleEvent
import io.element.android.libraries.di.RoomScope
import io.element.android.libraries.di.annotations.ApplicationContext
import io.element.android.libraries.di.annotations.SessionCoroutineScope
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
@@ -66,6 +68,8 @@ import io.element.android.libraries.matrix.api.room.JoinedRoom
import io.element.android.libraries.matrix.api.room.alias.matches
import io.element.android.libraries.matrix.api.timeline.Timeline
import io.element.android.libraries.matrix.api.timeline.item.TimelineItemDebugInfo
import io.element.android.libraries.matrix.ui.media.contentvalidation.EventContentValidationCache
import io.element.android.libraries.matrix.ui.media.contentvalidation.LocalEventContentValidationState
import io.element.android.libraries.matrix.ui.model.getBestName
import io.element.android.libraries.mediaplayer.api.MediaPlayer
import io.element.android.libraries.ui.strings.CommonStrings
@@ -97,6 +101,8 @@ class MessagesNode(
private val permalinkParser: PermalinkParser,
private val knockRequestsBannerRenderer: KnockRequestsBannerRenderer,
private val roomMemberModerationRenderer: RoomMemberModerationRenderer,
private val eventContentValidationCache: EventContentValidationCache,
private val emojiPickerRenderer: EmojiPickerRenderer,
) : Node(buildContext, plugins = plugins), MessagesNavigator {
data class Inputs(
val focusedEventId: EventId?,
@@ -261,6 +267,7 @@ class MessagesNode(
val canUseOverlay = !isTalkbackActive() && !hasExternalKeyboard()
CompositionLocalProvider(
LocalTimelineItemPresenterFactories provides timelineItemPresenterFactories,
LocalEventContentValidationState provides eventContentValidationCache,
) {
val state = presenter.present()
@@ -325,6 +332,15 @@ class MessagesNode(
onViewRequestsClick = callback::navigateToKnockRequestsList,
)
},
customReactionBottomSheet = {
CustomReactionBottomSheet(
state = state.customReactionState,
onSelectEmoji = { uniqueId, emoji ->
state.eventSink(MessagesEvent.ToggleReaction(emoji.unicode, uniqueId))
},
emojiPickerRenderer = emojiPickerRenderer,
)
},
onThreadsListClick = callback::navigateToThreadsList,
)
roomMemberModerationRenderer.Render(
@@ -69,6 +69,7 @@ import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatch
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarMessage
import io.element.android.libraries.designsystem.utils.snackbar.collectSnackbarMessageAsState
import io.element.android.libraries.di.annotations.SessionCoroutineScope
import io.element.android.libraries.emoji.api.recentemojis.AddRecentEmoji
import io.element.android.libraries.featureflag.api.FeatureFlagService
import io.element.android.libraries.featureflag.api.FeatureFlags
import io.element.android.libraries.matrix.api.core.toThreadId
@@ -83,9 +84,9 @@ import io.element.android.libraries.matrix.api.room.powerlevels.permissionsAsSta
import io.element.android.libraries.matrix.api.timeline.Timeline
import io.element.android.libraries.matrix.api.timeline.item.event.EventOrTransactionId
import io.element.android.libraries.matrix.ui.messages.reply.map
import io.element.android.libraries.matrix.ui.model.dmUserStatus
import io.element.android.libraries.matrix.ui.model.getAvatarData
import io.element.android.libraries.matrix.ui.room.getDirectRoomMember
import io.element.android.libraries.recentemojis.api.AddRecentEmoji
import io.element.android.libraries.textcomposer.model.MessageComposerMode
import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.services.analytics.api.AnalyticsService
@@ -319,6 +320,7 @@ class MessagesPresenter(
appName = buildMeta.applicationName,
pinnedMessagesBannerState = pinnedMessagesBannerState,
dmUserVerificationState = dmUserVerificationState,
dmUserStatus = roomInfo.dmUserStatus(),
roomMemberModerationState = roomMemberModerationState,
topBarSharedHistoryIcon = topBarSharedHistoryIcon,
successorRoom = roomInfo.successorRoom,
@@ -27,6 +27,7 @@ import io.element.android.libraries.designsystem.utils.snackbar.SnackbarMessage
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.encryption.identity.IdentityState
import io.element.android.libraries.matrix.api.room.tombstone.SuccessorRoom
import io.element.android.libraries.matrix.api.user.DisplayedStatus
import kotlinx.collections.immutable.ImmutableList
data class MessagesState(
@@ -53,6 +54,7 @@ data class MessagesState(
val appName: String,
val pinnedMessagesBannerState: PinnedMessagesBannerState,
val dmUserVerificationState: IdentityState?,
val dmUserStatus: DisplayedStatus?,
val roomMemberModerationState: RoomMemberModerationState,
/** Type of "shared history" icon to show in the top bar. */
val topBarSharedHistoryIcon: SharedHistoryIcon,
@@ -50,10 +50,10 @@ import io.element.android.libraries.matrix.api.core.ThreadId
import io.element.android.libraries.matrix.api.encryption.identity.IdentityState
import io.element.android.libraries.matrix.api.room.tombstone.SuccessorRoom
import io.element.android.libraries.matrix.api.timeline.Timeline
import io.element.android.libraries.matrix.api.user.DisplayedStatus
import io.element.android.libraries.textcomposer.model.MessageComposerMode
import io.element.android.libraries.textcomposer.model.aTextEditorStateMarkdown
import io.element.android.libraries.textcomposer.model.aTextEditorStateRich
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.persistentSetOf
@@ -129,6 +129,7 @@ fun aMessagesState(
hasUnreadThreads = false,
),
isCurrentlySharingLiveLocationInRoom: Boolean = false,
dmUserStatus: DisplayedStatus? = null,
eventSink: (MessagesEvent) -> Unit = {},
) = MessagesState(
roomId = RoomId("!id:domain"),
@@ -159,6 +160,7 @@ fun aMessagesState(
successorRoom = successorRoom,
threads = threads,
showLiveLocationShareBanner = isCurrentlySharingLiveLocationInRoom,
dmUserStatus = dmUserStatus,
eventSink = eventSink,
)
@@ -193,11 +195,9 @@ fun aReactionSummaryState(
fun aCustomReactionState(
target: CustomReactionState.Target = CustomReactionState.Target.None,
recentEmojis: ImmutableList<String> = persistentListOf(),
eventSink: (CustomReactionEvent) -> Unit = {},
) = CustomReactionState(
target = target,
recentEmojis = recentEmojis,
selectedEmoji = persistentSetOf(),
eventSink = eventSink,
)
@@ -20,14 +20,17 @@ import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.WindowInsetsSides
import androidx.compose.foundation.layout.asPaddingValues
import androidx.compose.foundation.layout.consumeWindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.only
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.statusBars
import androidx.compose.foundation.layout.safeDrawing
import androidx.compose.foundation.layout.systemBarsPadding
import androidx.compose.foundation.layout.width
import androidx.compose.material3.MaterialTheme
@@ -81,7 +84,6 @@ import io.element.android.features.messages.impl.timeline.aTimelineItemDaySepara
import io.element.android.features.messages.impl.timeline.aTimelineItemEvent
import io.element.android.features.messages.impl.timeline.aTimelineState
import io.element.android.features.messages.impl.timeline.components.CallMenuItem
import io.element.android.features.messages.impl.timeline.components.customreaction.CustomReactionBottomSheet
import io.element.android.features.messages.impl.timeline.components.customreaction.CustomReactionEvent
import io.element.android.features.messages.impl.timeline.components.reactionsummary.ReactionSummaryEvent
import io.element.android.features.messages.impl.timeline.components.reactionsummary.ReactionSummaryView
@@ -113,6 +115,7 @@ import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.utils.HideKeyboardWhenDisposed
import io.element.android.libraries.designsystem.utils.KeepScreenOn
import io.element.android.libraries.designsystem.utils.OnLifecycleEvent
import io.element.android.libraries.designsystem.utils.scaffoldScrollableContentInsets
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarHost
import io.element.android.libraries.designsystem.utils.snackbar.rememberSnackbarHostState
import io.element.android.libraries.matrix.api.core.EventId
@@ -123,6 +126,8 @@ import io.element.android.libraries.matrix.api.room.tombstone.SuccessorRoom
import io.element.android.libraries.matrix.api.timeline.Timeline
import io.element.android.libraries.matrix.api.timeline.item.event.LocalEventSendState
import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.matrix.ui.media.contentvalidation.ContentValidationValue
import io.element.android.libraries.matrix.ui.media.contentvalidation.LocalEventContentValidationState
import io.element.android.libraries.textcomposer.model.TextEditorState
import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.wysiwyg.link.Link
@@ -144,10 +149,13 @@ fun MessagesView(
onJoinCallClick: (isAudioCall: Boolean) -> Unit,
onViewAllPinnedMessagesClick: () -> Unit,
onThreadsListClick: () -> Unit,
knockRequestsBannerView: @Composable () -> Unit,
modifier: Modifier = Modifier,
forceJumpToBottomVisibility: Boolean = false,
knockRequestsBannerView: @Composable () -> Unit,
customReactionBottomSheet: @Composable () -> Unit,
) {
val eventContentValidationState = LocalEventContentValidationState.current
OnLifecycleEvent { _, event ->
state.voiceMessageComposerState.eventSink(VoiceMessageComposerEvent.LifecycleEvent(event))
}
@@ -170,6 +178,9 @@ fun MessagesView(
fun onContentClick(event: TimelineItem.Event) {
Timber.v("onMessageClick= ${event.id}")
val eventId = event.eventId ?: return
if (eventContentValidationState[eventId].getCurrentOverallState() != ContentValidationValue.Valid) return
val hideKeyboard = onEventContentClick(state.timelineState.isLive, event)
if (hideKeyboard) {
localView.hideKeyboard()
@@ -219,7 +230,7 @@ fun MessagesView(
},
content = {
Scaffold(
contentWindowInsets = WindowInsets.statusBars,
contentWindowInsets = scaffoldScrollableContentInsets,
topBar = {
if (state.timelineState.timelineMode is Timeline.Mode.Thread) {
ThreadTopBar(
@@ -237,6 +248,7 @@ fun MessagesView(
heroes = state.heroes,
dmUserIdentityState = state.dmUserVerificationState,
sharedHistoryIcon = state.topBarSharedHistoryIcon,
dmUserStatus = state.dmUserStatus,
onBackClick = { hidingKeyboard { onBackClick() } },
onRoomDetailsClick = { hidingKeyboard { onRoomDetailsClick() } },
menuActions = {
@@ -294,6 +306,7 @@ fun MessagesView(
onSwipeToReply = { targetEvent ->
state.eventSink(MessagesEvent.HandleAction(TimelineItemAction.Reply, targetEvent))
},
onJoinCallClick = onJoinCallClick,
forceJumpToBottomVisibility = forceJumpToBottomVisibility,
onViewAllPinnedMessagesClick = onViewAllPinnedMessagesClick,
knockRequestsBannerView = knockRequestsBannerView,
@@ -402,12 +415,7 @@ fun MessagesView(
},
)
CustomReactionBottomSheet(
state = state.customReactionState,
onSelectEmoji = { uniqueId, emoji ->
state.eventSink(MessagesEvent.ToggleReaction(emoji.unicode, uniqueId))
}
)
customReactionBottomSheet()
ReactionSummaryView(state = state.reactionSummaryState)
ReadReceiptBottomSheet(
@@ -474,6 +482,7 @@ private fun MessagesViewContent(
onSendLocationClick: () -> Unit,
onCreatePollClick: () -> Unit,
onViewAllPinnedMessagesClick: () -> Unit,
onJoinCallClick: (isAudioCall: Boolean) -> Unit,
forceJumpToBottomVisibility: Boolean,
onSwipeToReply: (TimelineItem.Event) -> Unit,
modifier: Modifier = Modifier,
@@ -531,6 +540,7 @@ private fun MessagesViewContent(
onReactionLongClick = onReactionLongClick,
onMoreReactionsClick = onMoreReactionsClick,
onReadReceiptClick = onReadReceiptClick,
onJoinCallClick = onJoinCallClick,
forceJumpToBottomVisibility = forceJumpToBottomVisibility,
nestedScrollConnection = scrollBehavior.nestedScrollConnection,
floatingDateTopOffset = topBannersHeightDp,
@@ -576,12 +586,17 @@ private fun MessagesViewComposerBottomSheetContents(
onRoomSuccessorClick: (RoomId) -> Unit,
onLinkClick: (String, Boolean) -> Unit,
) {
val contentPadding = WindowInsets.safeDrawing.only(WindowInsetsSides.Horizontal).asPaddingValues()
when {
state.successorRoom != null -> {
SuccessorRoomBanner(roomSuccessor = state.successorRoom, onRoomSuccessorClick = onRoomSuccessorClick)
SuccessorRoomBanner(
modifier = Modifier.fillMaxWidth().padding(contentPadding),
roomSuccessor = state.successorRoom,
onRoomSuccessorClick = onRoomSuccessorClick
)
}
state.userEventPermissions.canSendMessage -> {
Column(modifier = Modifier.fillMaxWidth()) {
Column(modifier = Modifier.fillMaxWidth().padding(contentPadding)) {
// Do not show the identity change if user is composing a Rich message or is seeing suggestion(s).
if (state.composerState.suggestions.isEmpty() &&
state.composerState.textEditorState is TextEditorState.Markdown) {
@@ -605,15 +620,15 @@ private fun MessagesViewComposerBottomSheetContents(
}
}
else -> {
CantSendMessageBanner()
CantSendMessageBanner(Modifier.padding(contentPadding))
}
}
}
@Composable
private fun CantSendMessageBanner() {
private fun CantSendMessageBanner(modifier: Modifier = Modifier) {
Row(
modifier = Modifier
modifier = modifier
.fillMaxWidth()
.background(ElementTheme.colors.bgSubtleSecondary)
.padding(16.dp),
@@ -662,6 +677,7 @@ internal fun MessagesViewPreview(@PreviewParameter(MessagesStateProvider::class)
onViewAllPinnedMessagesClick = { },
forceJumpToBottomVisibility = true,
knockRequestsBannerView = {},
customReactionBottomSheet = {},
onThreadsListClick = {},
)
}
@@ -719,5 +735,6 @@ internal fun MessagesViewA11yPreview() = ElementPreview {
onThreadsListClick = {},
forceJumpToBottomVisibility = true,
knockRequestsBannerView = {},
customReactionBottomSheet = {},
)
}
@@ -44,13 +44,13 @@ import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.dateformatter.api.DateFormatter
import io.element.android.libraries.dateformatter.api.DateFormatterMode
import io.element.android.libraries.di.RoomScope
import io.element.android.libraries.emoji.api.recentemojis.GetRecentEmojis
import io.element.android.libraries.featureflag.api.FeatureFlagService
import io.element.android.libraries.featureflag.api.FeatureFlags
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.room.BaseRoom
import io.element.android.libraries.matrix.api.timeline.Timeline
import io.element.android.libraries.preferences.api.store.AppPreferencesStore
import io.element.android.libraries.recentemojis.api.GetRecentEmojis
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
@@ -43,6 +43,7 @@ internal fun MessagesViewWithIdentityChangePreview(
onJoinCallClick = {},
onViewAllPinnedMessagesClick = {},
knockRequestsBannerView = {},
customReactionBottomSheet = {},
onThreadsListClick = {},
)
}
@@ -31,6 +31,7 @@ import dev.zacsweers.metro.AssistedFactory
import dev.zacsweers.metro.AssistedInject
import im.vector.app.features.analytics.plan.Composer
import im.vector.app.features.analytics.plan.Interaction
import io.element.android.features.contentscanner.api.ContentScannerService
import io.element.android.features.location.api.LocationService
import io.element.android.features.messages.impl.MessagesNavigator
import io.element.android.features.messages.impl.attachments.Attachment
@@ -61,8 +62,12 @@ import io.element.android.libraries.matrix.api.room.draft.ComposerDraftType
import io.element.android.libraries.matrix.api.room.getDirectRoomMember
import io.element.android.libraries.matrix.api.room.powerlevels.use
import io.element.android.libraries.matrix.api.timeline.TimelineException
import io.element.android.libraries.matrix.api.timeline.item.event.mediaSources
import io.element.android.libraries.matrix.api.timeline.item.event.toEventOrTransactionId
import io.element.android.libraries.matrix.ui.media.contentvalidation.EventContentValidationCache
import io.element.android.libraries.matrix.ui.messages.reply.InReplyToDetails
import io.element.android.libraries.matrix.ui.messages.reply.content
import io.element.android.libraries.matrix.ui.messages.reply.eventId
import io.element.android.libraries.matrix.ui.messages.reply.map
import io.element.android.libraries.mediapickers.api.PickerProvider
import io.element.android.libraries.mediaupload.api.MediaOptimizationConfigProvider
@@ -135,6 +140,8 @@ class MessageComposerPresenter(
private val notificationConversationService: NotificationConversationService,
private val slashCommandService: SlashCommandService,
private val featureFlagService: FeatureFlagService,
private val contentScannerService: ContentScannerService,
private val contentValidationCache: EventContentValidationCache,
) : Presenter<MessageComposerState> {
@AssistedFactory
interface Factory {
@@ -848,6 +855,11 @@ class MessageComposerPresenter(
setText(newComposerMode.content, markdownTextEditorState, richTextEditorState, requestFocus = true)
}
else -> {
if (currentComposerMode is MessageComposerMode.Reply) {
val mediaSources = currentComposerMode.replyToDetails.content()?.mediaSources() ?: return@launch
val contentValidationState = contentValidationCache[currentComposerMode.replyToDetails.eventId()]
contentScannerService.scan(mediaSources, contentValidationState)
}
// When coming from edit, just clear the composer as it'd be weird to reset a volatile draft in this scenario.
if (currentComposerMode.isEditing) {
setText("", markdownTextEditorState, richTextEditorState)
@@ -41,6 +41,8 @@ import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.room.RoomMember
import io.element.android.libraries.matrix.api.room.RoomMembershipState
import io.element.android.libraries.matrix.api.room.getBestName
import io.element.android.libraries.matrix.ui.components.DisplayNameWithStatus
import io.element.android.libraries.matrix.ui.model.getAvatarData
import io.element.android.libraries.slashcommands.api.SlashCommandSuggestion
import io.element.android.libraries.textcomposer.mentions.ResolvedSuggestion
@@ -112,12 +114,16 @@ private fun SuggestionItemView(
is ResolvedSuggestion.Member -> AvatarType.User
is ResolvedSuggestion.Command -> null
}
val title = when (suggestion) {
val title: String? = when (suggestion) {
is ResolvedSuggestion.AtRoom -> stringResource(R.string.screen_room_mentions_at_room_title)
is ResolvedSuggestion.Member -> suggestion.roomMember.displayName
is ResolvedSuggestion.Member -> suggestion.roomMember.getBestName()
is ResolvedSuggestion.Alias -> suggestion.roomName
is ResolvedSuggestion.Command -> suggestion.command.command
}
val status = when (suggestion) {
is ResolvedSuggestion.Member -> suggestion.roomMember.displayedStatus
else -> null
}
val details = when (suggestion) {
is ResolvedSuggestion.AtRoom,
is ResolvedSuggestion.Member,
@@ -148,11 +154,11 @@ private fun SuggestionItemView(
horizontalArrangement = Arrangement.spacedBy(4.dp)
) {
title?.let {
Text(
text = it,
DisplayNameWithStatus(
name = title,
status = status,
style = ElementTheme.typography.fontBodyLgRegular,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
nameColor = ElementTheme.colors.textPrimary,
)
}
details?.let {
@@ -191,6 +197,7 @@ internal fun SuggestionsPickerViewPreview() {
role = RoomMember.Role.User,
membershipChangeReason = null,
isServiceMember = false,
displayedStatus = null,
)
val anAlias = remember { RoomAlias("#room:domain.org") }
SuggestionsPickerView(
@@ -16,10 +16,15 @@ import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.WindowInsetsSides
import androidx.compose.foundation.layout.asPaddingValues
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
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.width
import androidx.compose.runtime.Composable
@@ -98,7 +103,8 @@ private fun PinnedMessagesBannerRow(
onClick(state.currentPinnedMessage.eventId)
state.eventSink(PinnedMessagesBannerEvent.MoveToNextPinned)
}
},
}
.padding(WindowInsets.safeDrawing.only(WindowInsetsSides.Horizontal).asPaddingValues()),
verticalAlignment = Alignment.CenterVertically,
) {
Spacer(modifier = Modifier.width(26.dp))
@@ -13,6 +13,7 @@ import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.consumeWindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.plus
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
@@ -36,7 +37,6 @@ import io.element.android.features.messages.impl.timeline.components.event.Timel
import io.element.android.features.messages.impl.timeline.components.layout.ContentAvoidingLayoutData
import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemPollContent
import io.element.android.features.messages.impl.timeline.protection.TimelineProtectionEvent
import io.element.android.features.messages.impl.timeline.protection.TimelineProtectionState
import io.element.android.features.poll.api.pollcontent.PollTitleView
import io.element.android.libraries.designsystem.atomic.molecules.IconTitleSubtitleMolecule
@@ -48,6 +48,8 @@ import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator
import io.element.android.libraries.designsystem.theme.components.Scaffold
import io.element.android.libraries.designsystem.theme.components.TopAppBar
import io.element.android.libraries.designsystem.utils.lazyColumnContentPadding
import io.element.android.libraries.designsystem.utils.scaffoldScrollableContentInsets
import io.element.android.libraries.matrix.api.timeline.Timeline
import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.ui.strings.CommonStrings
@@ -91,7 +93,8 @@ fun PinnedMessagesListView(
.padding(padding)
.consumeWindowInsets(padding),
)
}
},
contentWindowInsets = scaffoldScrollableContentInsets,
)
}
@@ -207,11 +210,12 @@ private fun PinnedMessagesListLoaded(
onEmojiReactionClick = { _, _ -> },
onVerifiedUserSendFailureClick = {}
)
LazyColumn(
modifier = modifier.fillMaxSize(),
state = rememberLazyListState(),
reverseLayout = true,
contentPadding = PaddingValues(vertical = 8.dp),
contentPadding = lazyColumnContentPadding + PaddingValues(vertical = 8.dp),
) {
items(
items = state.timelineItems,
@@ -240,6 +244,7 @@ private fun PinnedMessagesListLoaded(
onMoreReactionsClick = {},
onReadReceiptClick = {},
onSwipeToReply = {},
onJoinCallClick = {},
eventSink = { timelineItemEvent ->
when (timelineItemEvent) {
is TimelineEvent.OpenThread -> state.eventSink(PinnedMessagesListEvent.OpenThread(timelineItemEvent.threadRootEventId))
@@ -290,9 +295,9 @@ private fun TimelineItemEventContentViewWrapper(
)
} else {
TimelineItemEventContentView(
eventId = event.eventId,
content = event.content,
hideMediaContent = timelineProtectionState.hideMediaContent(event.eventId, event.isMine),
onShowContentClick = { timelineProtectionState.eventSink(TimelineProtectionEvent.ShowContent(event.eventId)) },
timelineProtectionState = timelineProtectionState,
onGalleryItemClick = onGalleryItemClick,
onLinkClick = onLinkClick,
onLinkLongClick = onLinkLongClick,
@@ -322,6 +322,7 @@ class ThreadedMessagesNode(
onViewAllPinnedMessagesClick = {},
modifier = modifier,
knockRequestsBannerView = {},
customReactionBottomSheet = {},
onThreadsListClick = {},
)
@@ -16,6 +16,7 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.plus
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
@@ -54,6 +55,8 @@ import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.theme.components.Scaffold
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.theme.components.TopAppBar
import io.element.android.libraries.designsystem.utils.lazyColumnContentPadding
import io.element.android.libraries.designsystem.utils.scaffoldScrollableContentInsets
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.ThreadId
import io.element.android.libraries.matrix.api.core.UserId
@@ -129,14 +132,15 @@ fun ThreadsListView(
},
navigationIcon = {
BackButton(onBackClick)
}
},
windowInsets = scaffoldScrollableContentInsets,
)
}
) { padding ->
val lazyListState = rememberLazyListState()
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = padding,
contentPadding = padding + lazyColumnContentPadding,
state = lazyListState,
) {
itemsIndexed(state.threads, key = { _, row -> row.item.threadId }) { index, row ->
@@ -383,7 +387,12 @@ fun aThreadListItem(
fun aThreadListItemEvent(
threadId: ThreadId = ThreadId("\$a-thread-id"),
senderId: UserId = UserId("@a-user-id:server"),
senderProfile: ProfileDetails = ProfileDetails.Ready(displayName = USER_NAME_ALICE, displayNameAmbiguous = false, avatarUrl = null),
senderProfile: ProfileDetails = ProfileDetails.Ready(
displayName = USER_NAME_ALICE,
displayNameAmbiguous = false,
avatarUrl = null,
displayedStatus = null,
),
isOwn: Boolean = false,
content: EventContent = MessageContent(
body = "Hello world!",
@@ -13,7 +13,9 @@ import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.ThreadId
import io.element.android.libraries.matrix.api.media.MediaSource
import io.element.android.libraries.matrix.api.timeline.Timeline
import io.element.android.libraries.matrix.ui.media.contentvalidation.ContentValidationState
import kotlin.time.Duration
sealed interface TimelineEvent {
@@ -61,4 +63,10 @@ sealed interface TimelineEvent {
) : TimelineItemPollEvent
data object StopLiveLocationShare : TimelineItemEvent
data class ValidateMedia(
val eventId: EventId,
val mediaSources: List<MediaSource>,
val validationState: ContentValidationState
) : TimelineItemEvent
}
@@ -35,6 +35,8 @@ import io.element.android.features.messages.impl.timeline.model.NewEventState
import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.features.messages.impl.timeline.model.virtual.TimelineItemReadMarkerModel
import io.element.android.features.messages.impl.timeline.model.virtual.TimelineItemTypingNotificationModel
import io.element.android.features.messages.impl.timeline.protection.TimelineProtectionEvent
import io.element.android.features.messages.impl.timeline.protection.TimelineProtectionState
import io.element.android.features.messages.impl.typing.TypingNotificationState
import io.element.android.features.messages.impl.userEventPermissions
import io.element.android.features.messages.impl.voicemessages.timeline.RedactedVoiceMessageManager
@@ -64,6 +66,7 @@ import io.element.android.services.analytics.api.finishLongRunningTransaction
import io.element.android.services.analyticsproviders.api.AnalyticsUserData
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.combine
@@ -98,6 +101,7 @@ class TimelinePresenter(
private val analyticsService: AnalyticsService,
private val liveLocationShareManager: ActiveLiveLocationShareManager,
private val markAsFullyRead: MarkAsFullyRead,
private val timelineProtectionPresenter: Presenter<TimelineProtectionState>,
) : Presenter<TimelineState> {
private val tag = "TimelinePresenter"
@@ -157,6 +161,8 @@ class TimelinePresenter(
value = featureFlagService.isFeatureEnabled(FeatureFlags.JumpToUnread)
}
val timelineProtectionState = timelineProtectionPresenter.present()
fun handleEvent(event: TimelineEvent) {
when (event) {
is TimelineEvent.LoadMore -> {
@@ -252,6 +258,9 @@ class TimelinePresenter(
focusedEventId = event.focusedEvent,
)
}
is TimelineEvent.ValidateMedia -> {
timelineProtectionState.eventSink(TimelineProtectionEvent.ValidateContent(event.mediaSources, event.validationState))
}
}
}
@@ -259,7 +268,7 @@ class TimelinePresenter(
timelineItemsFactory.timelineItems
.onEach { newTimelineItems ->
timelineItemIndexer.process(newTimelineItems)
timelineItems = newTimelineItems
timelineItems = newTimelineItems.toImmutableList()
analyticsService.run {
finishLongRunningTransaction(DisplayFirstTimelineItems)
@@ -27,9 +27,15 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.WindowInsetsSides
import androidx.compose.foundation.layout.asPaddingValues
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.only
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.plus
import androidx.compose.foundation.layout.safeDrawing
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
@@ -130,6 +136,7 @@ fun TimelineView(
onReactionLongClick: (emoji: String, TimelineItem.Event) -> Unit,
onMoreReactionsClick: (TimelineItem.Event) -> Unit,
onReadReceiptClick: (TimelineItem.Event) -> Unit,
onJoinCallClick: (isAudioCall: Boolean) -> Unit,
modifier: Modifier = Modifier,
lazyListState: LazyListState = rememberLazyListState(),
forceJumpToBottomVisibility: Boolean = false,
@@ -192,7 +199,7 @@ fun TimelineView(
.testTag(TestTags.timeline),
state = lazyListState,
reverseLayout = true,
contentPadding = PaddingValues(top = 64.dp, bottom = 8.dp),
contentPadding = WindowInsets.safeDrawing.only(WindowInsetsSides.Horizontal).asPaddingValues() + PaddingValues(top = 64.dp, bottom = 8.dp),
) {
items(
items = state.timelineItems,
@@ -219,6 +226,7 @@ fun TimelineView(
onMoreReactionsClick = onMoreReactionsClick,
onReadReceiptClick = onReadReceiptClick,
onSwipeToReply = onSwipeToReply,
onJoinCallClick = onJoinCallClick,
eventSink = state.eventSink,
)
}
@@ -599,6 +607,7 @@ internal fun TimelineViewPreview(
onSwipeToReply = {},
onReactionClick = { _, _ -> },
onReactionLongClick = { _, _ -> },
onJoinCallClick = {},
onMoreReactionsClick = {},
onReadReceiptClick = {},
onGalleryItemClick = { _, _ -> },
@@ -649,6 +658,7 @@ private fun TimelineViewWithReadMarker(
onReadReceiptClick = {},
forceJumpToBottomVisibility = true,
forceJumpToReadMarkerVisibility = true,
onJoinCallClick = { },
)
}
}
@@ -49,6 +49,7 @@ internal fun TimelineViewMessageShieldPreview() = ElementPreview {
onReactionLongClick = { _, _ -> },
onMoreReactionsClick = {},
onReadReceiptClick = {},
onJoinCallClick = {},
onGalleryItemClick = { _, _ -> },
forceJumpToBottomVisibility = true,
)
@@ -0,0 +1,378 @@
/*
* 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.components
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.IntrinsicSize
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.produceState
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme
import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.features.messages.impl.timeline.TimelineRoomInfo
import io.element.android.features.messages.impl.timeline.aTimelineItemEvent
import io.element.android.features.messages.impl.timeline.aTimelineItemReadReceipts
import io.element.android.features.messages.impl.timeline.aTimelineRoomInfo
import io.element.android.features.messages.impl.timeline.components.receipt.ReadReceiptViewState
import io.element.android.features.messages.impl.timeline.components.receipt.TimelineItemReadReceiptView
import io.element.android.features.messages.impl.timeline.components.receipt.aReadReceiptData
import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.features.messages.impl.timeline.model.event.RtcNotificationState
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemRtcNotificationContent
import io.element.android.libraries.dateformatter.api.toHumanReadableDuration
import io.element.android.libraries.designsystem.components.avatar.Avatar
import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.designsystem.components.avatar.AvatarRow
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.designsystem.components.avatar.AvatarType
import io.element.android.libraries.designsystem.modifiers.onKeyboardContextMenuAction
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewWithLargeHeight
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.preview.USER_NAME_ALICE
import io.element.android.libraries.designsystem.preview.USER_NAME_BOB
import io.element.android.libraries.designsystem.preview.USER_NAME_CAROL
import io.element.android.libraries.designsystem.preview.USER_NAME_CHARLIE
import io.element.android.libraries.designsystem.preview.USER_NAME_DAVID
import io.element.android.libraries.designsystem.preview.USER_NAME_JOHN_DOE
import io.element.android.libraries.designsystem.theme.components.Button
import io.element.android.libraries.designsystem.theme.components.ButtonSize
import io.element.android.libraries.designsystem.utils.CommonDrawables
import io.element.android.libraries.matrix.api.notification.CallIntent
import io.element.android.libraries.matrix.ui.components.aMatrixUser
import io.element.android.libraries.ui.strings.CommonPlurals
import io.element.android.libraries.ui.strings.CommonStrings
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.delay
import kotlin.time.Duration.Companion.seconds
@Composable
internal fun ActiveCallTimelineItemView(
timelineRoomInfo: TimelineRoomInfo,
event: TimelineItem.Event,
state: RtcNotificationState.Active,
isLastOutgoingMessage: Boolean,
onLongClick: (TimelineItem.Event) -> Unit,
onReadReceiptsClick: (TimelineItem.Event) -> Unit,
onJoinCallClick: (isAudioCall: Boolean) -> Unit,
modifier: Modifier = Modifier
) {
Column(modifier = modifier) {
Row(
modifier = Modifier
.fillMaxWidth()
.height(IntrinsicSize.Min)
.padding(start = 16.dp, end = 16.dp, top = 16.dp)
.border(1.dp, ElementTheme.colors.borderInteractivePrimary, RoundedCornerShape(8.dp))
.combinedClickable(
enabled = true,
onClick = {},
onLongClick = { onLongClick(event) },
onLongClickLabel = stringResource(CommonStrings.action_open_context_menu),
)
.onKeyboardContextMenuAction { onLongClick(event) }
.padding(start = 12.dp, end = 12.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
modifier = Modifier
.padding(top = 12.dp, bottom = 12.dp)
.size(40.dp)
.background(ElementTheme.colors.bgSubtleSecondary, RoundedCornerShape(8.dp))
.padding(10.dp),
imageVector = if (state.callIntent == CallIntent.AUDIO) CompoundIcons.VoiceCallSolid() else CompoundIcons.VideoCallSolid(),
contentDescription = null,
tint = ElementTheme.colors.iconPrimary,
)
if (timelineRoomInfo.isDm) {
DirectMessageCallBody(state, Modifier.weight(1f))
} else {
GroupCallBody(
event,
state,
Modifier
.weight(1f)
.padding(top = 12.dp, bottom = 12.dp)
)
}
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier.padding(top = 12.dp, bottom = 12.dp)
) {
if (!state.isJoined) {
Button(
text = stringResource(CommonStrings.action_join),
size = ButtonSize.Small,
onClick = { onJoinCallClick(state.callIntent == CallIntent.AUDIO) }
)
}
if (state.callStartTsMillis != null) {
CallDurationText(
state.callStartTsMillis,
)
}
}
}
TimelineItemReadReceiptView(
state = ReadReceiptViewState(
sendState = event.localSendState,
isLastOutgoingMessage = isLastOutgoingMessage,
receipts = event.readReceiptState.receipts,
),
onReadReceiptsClick = { onReadReceiptsClick(event) },
modifier = Modifier.padding(top = 4.dp),
)
}
}
@Composable
private fun DirectMessageCallBody(state: RtcNotificationState.Active, modifier: Modifier = Modifier) {
Row(
verticalAlignment = Alignment.Top,
modifier = modifier
) {
if (state.joinedMembers.size == 1) {
val caller = state.joinedMembers.first()
Avatar(
avatarData = AvatarData(
id = caller.userId.value,
name = caller.displayName,
url = caller.avatarUrl,
size = AvatarSize.ActiveCallItem,
),
avatarType = AvatarType.User
)
Text(
modifier = Modifier.padding(start = 6.dp),
text = stringResource(CommonStrings.common_user_started_a_call, caller.displayName ?: caller.userId),
style = ElementTheme.typography.fontBodyMdMedium,
color = ElementTheme.colors.textPrimary,
)
} else {
AvatarRow(
avatarDataList = state.joinedMembers
.map { user ->
AvatarData(
id = user.userId.value,
name = user.displayName,
url = user.avatarUrl,
size = AvatarSize.ActiveCallItem
)
}
.toImmutableList(),
avatarType = AvatarType.User,
overlapRatio = 0.5f,
lastOnTop = true,
)
Text(
modifier = Modifier.padding(start = 6.dp),
text = stringResource(CommonStrings.common_call_in_progress),
style = ElementTheme.typography.fontBodyMdMedium,
color = ElementTheme.colors.textPrimary,
)
}
}
}
@Composable
private fun GroupCallBody(event: TimelineItem.Event, state: RtcNotificationState.Active, modifier: Modifier = Modifier) {
Column(modifier = modifier) {
Text(
text = stringResource(CommonStrings.common_group_call_in_progress),
style = ElementTheme.typography.fontBodyMdMedium,
color = ElementTheme.colors.textPrimary,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Row(
verticalAlignment = Alignment.CenterVertically
) {
if (state.joinedMembers.size == 1) {
val caller = state.joinedMembers.first()
Avatar(
avatarData = AvatarData(
id = caller.userId.value,
name = caller.displayName,
url = caller.avatarUrl,
size = AvatarSize.ActiveCallItem,
),
avatarType = AvatarType.User
)
// Only show started a call if the last remaining user in the call
// is really the one who started the call
if (caller.userId == event.senderId) {
Text(
modifier = Modifier.padding(start = 6.dp),
text = stringResource(CommonStrings.common_user_started_a_call, caller.displayName ?: caller.userId),
style = ElementTheme.typography.fontBodySmRegular,
color = ElementTheme.colors.textSecondary,
)
}
} else {
val displayMembers = state.joinedMembers.take(3)
AvatarRow(
avatarDataList = displayMembers
.map { user ->
AvatarData(
id = user.userId.value,
name = user.displayName,
url = user.avatarUrl,
size = AvatarSize.ActiveCallItem
)
}
.toImmutableList(),
avatarType = AvatarType.User,
overlapRatio = 0.5f,
lastOnTop = true,
)
Text(
text = pluralStringResource(
CommonPlurals.screen_timeline_active_call_joined_count,
count = state.joinedMembers.size,
state.joinedMembers.size
),
style = ElementTheme.typography.fontBodySmRegular,
color = ElementTheme.colors.textSecondary,
)
}
}
}
}
@Composable
private fun CallDurationText(
startTsMillis: Long,
modifier: Modifier = Modifier,
) {
fun formatElapsed(startTsMillis: Long): String =
(System.currentTimeMillis() - startTsMillis).coerceAtLeast(0).toHumanReadableDuration()
val duration by produceState(
initialValue = formatElapsed(startTsMillis),
startTsMillis,
) {
while (true) {
delay(1.seconds)
value = formatElapsed(startTsMillis)
}
}
Text(
modifier = modifier,
text = "($duration)",
style = ElementTheme.typography.fontBodyXsRegular,
color = ElementTheme.colors.textSecondary,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
@PreviewsDayNight
@PreviewWithLargeHeight
@Composable
internal fun ActiveCallTimelineItemViewPreview() = ElementPreview(
drawableFallbackForImages = CommonDrawables.sample_avatar
) {
val readReceiptState = mutableListOf(
aTimelineItemReadReceipts(
receipts = List(3) { aReadReceiptData(it) },
)
)
Column(modifier = Modifier.padding(bottom = 16.dp)) {
listOf(CallIntent.AUDIO, CallIntent.VIDEO).forEach { callIntent ->
listOf(
RtcNotificationState.Active(
listOf(aMatrixUser(displayName = USER_NAME_ALICE, avatarUrl = "anUrl")),
isJoined = true,
callStartTsMillis = System.currentTimeMillis() - 60_000,
callIntent = callIntent
) to true,
RtcNotificationState.Active(
listOf(
aMatrixUser(displayName = USER_NAME_ALICE, avatarUrl = "anUrl"),
aMatrixUser(displayName = USER_NAME_BOB)
),
isJoined = true,
callStartTsMillis = System.currentTimeMillis() - 60_000,
callIntent = callIntent
) to true,
RtcNotificationState.Active(
listOf(
aMatrixUser(displayName = USER_NAME_ALICE),
aMatrixUser(displayName = USER_NAME_BOB, avatarUrl = "anUrlB"),
aMatrixUser(displayName = USER_NAME_CAROL),
aMatrixUser(displayName = USER_NAME_DAVID),
aMatrixUser(displayName = USER_NAME_CHARLIE),
aMatrixUser(displayName = USER_NAME_JOHN_DOE, avatarUrl = "anUrlC"),
),
isJoined = false,
callStartTsMillis = System.currentTimeMillis(),
callIntent = callIntent
) to false,
// Group call only one member still in call, but it is not the creator
RtcNotificationState.Active(
listOf(
aMatrixUser(displayName = USER_NAME_ALICE, avatarUrl = "aliceURl"),
),
isJoined = true,
callStartTsMillis = System.currentTimeMillis(),
callIntent = callIntent
) to false,
// Group call only one member that is the call creator
RtcNotificationState.Active(
listOf(
aMatrixUser(
id = "@senderId:domain",
displayName = "Sender"
),
),
isJoined = true,
callStartTsMillis = System.currentTimeMillis(),
callIntent = callIntent
) to false,
).forEach { (state, isDm) ->
val content = TimelineItemRtcNotificationContent(callIntent, state)
ActiveCallTimelineItemView(
timelineRoomInfo = aTimelineRoomInfo(isDm = isDm),
event = aTimelineItemEvent(
content = content,
// Only display read receipts for the first item
readReceiptState = readReceiptState.removeFirstOrNull() ?: aTimelineItemReadReceipts(),
),
state,
isLastOutgoingMessage = false,
onLongClick = {},
onReadReceiptsClick = {},
onJoinCallClick = {},
)
}
}
}
}
@@ -11,5 +11,6 @@ package io.element.android.features.messages.impl.timeline.components
enum class ContentPadding {
Textual,
Media,
CaptionedMedia
CaptionedMedia,
InvalidContent,
}
@@ -19,7 +19,9 @@ import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.ripple
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawWithCache
@@ -27,6 +29,8 @@ import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.BlendMode
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.graphics.drawOutline
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.graphics.layer.CompositingStrategy
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.LayoutDirection
@@ -62,6 +66,8 @@ fun MessageEventBubble(
onClick: () -> Unit,
onLongClick: () -> Unit,
modifier: Modifier = Modifier,
customBackgroundColor: Color? = null,
borderColor: Color? = null,
content: @Composable BoxScope.() -> Unit = {},
) {
val clickableModifier = if (isTalkbackActive()) {
@@ -79,10 +85,12 @@ fun MessageEventBubble(
val cutTopStart = state.cutTopStart
// Ignore state.isHighlighted for now, we need a design decision on it.
val backgroundBubbleColor = MessageEventBubbleDefaults.backgroundBubbleColor(state.isMine)
val backgroundBubbleColor by rememberUpdatedState(customBackgroundColor ?: MessageEventBubbleDefaults.backgroundBubbleColor(state.isMine))
val bubbleShape = remember(state) { MessageEventBubbleDefaults.shape(state.cutTopStart, state.groupPosition, state.isMine) }
val radiusPx = (avatarRadius + SENDER_AVATAR_BORDER_WIDTH).toPx()
val yOffsetPx = -(NEGATIVE_MARGIN_FOR_BUBBLE + avatarRadius).toPx()
val updatedBorderColor by rememberUpdatedState(borderColor)
BoxWithConstraints(
modifier = modifier
.drawWithCache {
@@ -103,6 +111,9 @@ fun MessageEventBubble(
// Then draw the content on top of it
drawContent()
// Draw border color, if any
updatedBorderColor?.let { drawOutline(outline, it, style = Stroke(width = 1.dp.toPx())) }
// And then clip the top start corner if needed to make room for the avatar
if (cutTopStart) {
drawCircle(
@@ -166,6 +166,7 @@ internal fun MessageShieldViewPreview() {
displayName = USER_NAME_ALICE,
displayNameAmbiguous = false,
avatarUrl = null,
displayedStatus = null,
),
)
)
@@ -52,6 +52,7 @@ internal fun TimelineItemCallNotifyView(
timelineRoomInfo: TimelineRoomInfo,
event: TimelineItem.Event,
content: TimelineItemRtcNotificationContent,
state: RtcNotificationState.Tombstoned,
isLastOutgoingMessage: Boolean,
onLongClick: (TimelineItem.Event) -> Unit,
onReadReceiptsClick: (TimelineItem.Event) -> Unit,
@@ -83,7 +84,7 @@ internal fun TimelineItemCallNotifyView(
Text(
modifier = Modifier.weight(1f),
text = stringResource(getTextRes(timelineRoomInfo, content)),
text = stringResource(getTextRes(timelineRoomInfo, state)),
style = ElementTheme.typography.fontBodyMdRegular,
color = ElementTheme.colors.textSecondary,
maxLines = 1,
@@ -114,13 +115,13 @@ internal fun TimelineItemCallNotifyView(
@StringRes
private fun getTextRes(
timelineRoomInfo: TimelineRoomInfo,
content: TimelineItemRtcNotificationContent
state: RtcNotificationState.Tombstoned
): Int = if (timelineRoomInfo.isDm) {
when (content.state) {
when (state) {
is RtcNotificationState.Declined -> {
if (content.state.byMe) CommonStrings.common_call_you_declined else CommonStrings.common_call_declined
if (state.byMe) CommonStrings.common_call_you_declined else CommonStrings.common_call_declined
}
RtcNotificationState.Started -> CommonStrings.common_call_started
else -> CommonStrings.common_call_started
}
} else {
// In Rooms, do not show declined info.
@@ -166,6 +167,7 @@ internal fun TimelineItemCallNotifyViewPreview() = ElementPreview {
readReceiptState = readReceiptState.removeFirstOrNull() ?: aTimelineItemReadReceipts(),
),
content = content,
state,
isLastOutgoingMessage = false,
onLongClick = {},
onReadReceiptsClick = {},
@@ -0,0 +1,346 @@
/*
* 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.components
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.remember
import androidx.compose.ui.unit.dp
import io.element.android.features.messages.impl.timeline.aTimelineItemEvent
import io.element.android.features.messages.impl.timeline.components.event.ElementTimelineItemPreview
import io.element.android.features.messages.impl.timeline.components.event.aGalleryItem
import io.element.android.features.messages.impl.timeline.components.event.aTimelineItemGalleryContent
import io.element.android.features.messages.impl.timeline.di.LocalTimelineItemPresenterFactories
import io.element.android.features.messages.impl.timeline.di.aFakeTimelineItemPresenterFactories
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemAttachmentsContent
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemAudioContent
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemFileContent
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemImageContent
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemStickerContent
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemTextContent
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemVideoContent
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemVoiceContent
import io.element.android.features.messages.impl.timeline.model.event.anAttachmentItem
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.media.MediaSource
import io.element.android.libraries.matrix.api.timeline.item.event.ImageMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.MessageContent
import io.element.android.libraries.matrix.api.timeline.item.event.ProfileDetails
import io.element.android.libraries.matrix.api.timeline.item.event.TextMessageType
import io.element.android.libraries.matrix.ui.media.contentvalidation.ContentValidationValue
import io.element.android.libraries.matrix.ui.media.contentvalidation.DefaultContentValidationState
import io.element.android.libraries.matrix.ui.media.contentvalidation.InMemoryEventContentValidationCache
import io.element.android.libraries.matrix.ui.media.contentvalidation.LocalEventContentValidationState
import io.element.android.libraries.matrix.ui.media.contentvalidation.NoopContentValidationState
import io.element.android.libraries.matrix.ui.messages.reply.InReplyToDetails
private val AN_EVENT_ID = EventId($$"$eventId")
@PreviewsDayNight
@Composable
internal fun TimelineItemImageViewScanningContentPreview() = ElementPreview {
val cache = remember {
InMemoryEventContentValidationCache(initial = mapOf(AN_EVENT_ID to NoopContentValidationState(ContentValidationValue.Loading)))
}
CompositionLocalProvider(LocalEventContentValidationState provides cache) {
ATimelineItemEventRow(
event = aTimelineItemEvent(eventId = AN_EVENT_ID, content = aTimelineItemImageContent()),
)
}
}
@PreviewsDayNight
@Composable
internal fun TimelineItemStickerViewScanningContentPreview() = ElementPreview {
val cache = remember {
InMemoryEventContentValidationCache(initial = mapOf(AN_EVENT_ID to NoopContentValidationState(ContentValidationValue.Loading)))
}
CompositionLocalProvider(LocalEventContentValidationState provides cache) {
ATimelineItemEventRow(
event = aTimelineItemEvent(eventId = AN_EVENT_ID, content = aTimelineItemStickerContent(aspectRatio = 1.5f)),
)
}
}
@PreviewsDayNight
@Composable
internal fun TimelineItemVideoViewScanningContentPreview() = ElementPreview {
val cache = remember {
InMemoryEventContentValidationCache(initial = mapOf(AN_EVENT_ID to NoopContentValidationState(ContentValidationValue.Loading)))
}
CompositionLocalProvider(LocalEventContentValidationState provides cache) {
ATimelineItemEventRow(
event = aTimelineItemEvent(eventId = AN_EVENT_ID, content = aTimelineItemVideoContent()),
)
}
}
@PreviewsDayNight
@Composable
internal fun TimelineItemFileViewScanningContentPreview() {
ElementTimelineItemPreview {
val cache = remember {
InMemoryEventContentValidationCache(initial = mapOf(AN_EVENT_ID to NoopContentValidationState(ContentValidationValue.Loading)))
}
CompositionLocalProvider(
LocalEventContentValidationState provides cache
) {
ATimelineItemEventRow(event = aTimelineItemEvent(eventId = AN_EVENT_ID, content = aTimelineItemFileContent()))
}
}
}
@PreviewsDayNight
@Composable
internal fun TimelineItemAudioViewScanningContentPreview() {
ElementTimelineItemPreview {
val cache = remember {
InMemoryEventContentValidationCache(initial = mapOf(AN_EVENT_ID to NoopContentValidationState(ContentValidationValue.Loading)))
}
CompositionLocalProvider(
LocalEventContentValidationState provides cache
) {
ATimelineItemEventRow(event = aTimelineItemEvent(eventId = AN_EVENT_ID, content = aTimelineItemAudioContent()))
}
}
}
@PreviewsDayNight
@Composable
internal fun TimelineItemVoiceViewScanningContentPreview() = ElementPreview {
val cache = remember {
InMemoryEventContentValidationCache(initial = mapOf(AN_EVENT_ID to NoopContentValidationState(ContentValidationValue.Loading)))
}
ElementTimelineItemPreview {
CompositionLocalProvider(
LocalEventContentValidationState provides cache,
LocalTimelineItemPresenterFactories provides aFakeTimelineItemPresenterFactories(),
) {
ATimelineItemEventRow(
event = aTimelineItemEvent(eventId = AN_EVENT_ID, content = aTimelineItemVoiceContent()),
)
}
}
}
@PreviewsDayNight
@Composable
internal fun TimelineItemScanningContentFailedPreview() = ElementPreview {
val cache = remember {
InMemoryEventContentValidationCache(initial = mapOf(AN_EVENT_ID to NoopContentValidationState(ContentValidationValue.Invalid)))
}
CompositionLocalProvider(LocalEventContentValidationState provides cache) {
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
ATimelineItemEventRow(event = aTimelineItemEvent(eventId = AN_EVENT_ID, content = aTimelineItemImageContent()))
ATimelineItemEventRow(event = aTimelineItemEvent(eventId = AN_EVENT_ID, content = aTimelineItemImageContent(caption = "A caption")))
}
}
}
@PreviewsDayNight
@Composable
internal fun TimelineItemScanningContentNotFoundPreview() = ElementPreview {
val cache = remember {
InMemoryEventContentValidationCache(initial = mapOf(
AN_EVENT_ID to NoopContentValidationState(ContentValidationValue.UnrecoverableError(IllegalStateException("Not found")))
))
}
CompositionLocalProvider(LocalEventContentValidationState provides cache) {
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
ATimelineItemEventRow(event = aTimelineItemEvent(eventId = AN_EVENT_ID, content = aTimelineItemImageContent()))
ATimelineItemEventRow(event = aTimelineItemEvent(eventId = AN_EVENT_ID, content = aTimelineItemImageContent(caption = "A caption")))
}
}
}
@PreviewsDayNight
@Composable
internal fun TimelineItemScanningContentWithInvalidRepliesPreview() = ElementPreview {
val cache = remember {
InMemoryEventContentValidationCache(initial = mapOf(AN_EVENT_ID to NoopContentValidationState(ContentValidationValue.Invalid)))
}
CompositionLocalProvider(LocalEventContentValidationState provides cache) {
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
ATimelineItemEventRow(
event = aTimelineItemEvent(
content = aTimelineItemTextContent(),
inReplyTo = inReplyToInvalidContent(),
)
)
ATimelineItemEventRow(
event = aTimelineItemEvent(
eventId = AN_EVENT_ID,
content = aTimelineItemImageContent(),
inReplyTo = inReplyToInvalidContent(),
)
)
ATimelineItemEventRow(
event = aTimelineItemEvent(
eventId = AN_EVENT_ID,
content = aTimelineItemImageContent(),
inReplyTo = inReplyToTextContent(),
)
)
}
}
}
@PreviewsDayNight
@Composable
internal fun TimelineItemScanningContentWithRepliesFailedPreview() = ElementPreview {
val cache = remember {
InMemoryEventContentValidationCache(
initial = mapOf(AN_EVENT_ID to NoopContentValidationState(ContentValidationValue.UnrecoverableError(IllegalStateException("BOOM"))))
)
}
CompositionLocalProvider(LocalEventContentValidationState provides cache) {
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
ATimelineItemEventRow(
event = aTimelineItemEvent(
content = aTimelineItemTextContent(),
inReplyTo = inReplyToInvalidContent(),
)
)
ATimelineItemEventRow(
event = aTimelineItemEvent(
eventId = AN_EVENT_ID,
content = aTimelineItemImageContent(),
inReplyTo = inReplyToInvalidContent(),
)
)
ATimelineItemEventRow(
event = aTimelineItemEvent(
eventId = AN_EVENT_ID,
content = aTimelineItemImageContent(),
inReplyTo = inReplyToTextContent(),
)
)
}
}
}
@PreviewsDayNight
@Composable
internal fun TimelineItemGalleryViewScanningContentFailedPreview() = ElementPreview {
val cache = remember {
InMemoryEventContentValidationCache(
initial =
mapOf(
AN_EVENT_ID to DefaultContentValidationState(mapOf(
"invalid" to ContentValidationValue.Invalid,
"error" to ContentValidationValue.UnrecoverableError(IllegalStateException("BOOM")),
"" to ContentValidationValue.Valid
))
)
)
}
CompositionLocalProvider(LocalEventContentValidationState provides cache) {
ATimelineItemEventRow(
event = aTimelineItemEvent(
eventId = AN_EVENT_ID,
content = aTimelineItemGalleryContent(
items = listOf(
aGalleryItem(),
aGalleryItem(mediaSource = MediaSource("invalid", "{}")),
aGalleryItem(),
aGalleryItem(mediaSource = MediaSource("invalid", "{}")),
aGalleryItem(mediaSource = MediaSource("error", "{}")),
aGalleryItem(mediaSource = MediaSource("invalid", "{}")),
aGalleryItem(),
)
)
)
)
}
}
@PreviewsDayNight
@Composable
internal fun TimelineItemAttachmentsViewScanningContentFailedPreview() = ElementPreview {
val cache = remember {
InMemoryEventContentValidationCache(
initial =
mapOf(
AN_EVENT_ID to DefaultContentValidationState(mapOf(
"invalid" to ContentValidationValue.Invalid,
"error" to ContentValidationValue.UnrecoverableError(IllegalStateException("BOOM")),
"" to ContentValidationValue.Valid
))
)
)
}
CompositionLocalProvider(LocalEventContentValidationState provides cache) {
ATimelineItemEventRow(
event = aTimelineItemEvent(
eventId = AN_EVENT_ID,
content = aTimelineItemAttachmentsContent(
attachments = listOf(
anAttachmentItem(),
anAttachmentItem(mediaSource = MediaSource("invalid", "{}")),
anAttachmentItem(),
anAttachmentItem(),
anAttachmentItem(mediaSource = MediaSource("error", "{}")),
anAttachmentItem(mediaSource = MediaSource("invalid", "{}")),
)
)
)
)
}
}
private fun inReplyToInvalidContent(): InReplyToDetails.Ready = InReplyToDetails.Ready(
eventId = AN_EVENT_ID,
senderId = UserId("@sender:matrix.org"),
eventContent = MessageContent(
body = "A body",
inReplyTo = null,
isEdited = false,
threadInfo = null,
type = ImageMessageType(
filename = "A file",
caption = "A caption",
formattedCaption = null,
source = MediaSource("", ""),
info = null,
)
),
textContent = "A text content",
senderProfile = ProfileDetails.Ready(
displayName = "Sender",
displayNameAmbiguous = false,
avatarUrl = null,
displayedStatus = null
)
)
private fun inReplyToTextContent(): InReplyToDetails.Ready = InReplyToDetails.Ready(
eventId = EventId($$"$text_eventId"),
senderId = UserId("@sender:matrix.org"),
eventContent = MessageContent(
body = "A body",
inReplyTo = null,
isEdited = false,
threadInfo = null,
type = TextMessageType(
body = "A body",
formatted = null,
)
),
textContent = "A text content",
senderProfile = ProfileDetails.Ready(
displayName = "Sender",
displayNameAmbiguous = false,
avatarUrl = null,
displayedStatus = null
)
)
@@ -33,9 +33,12 @@ import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.movableContentOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
@@ -88,7 +91,6 @@ import io.element.android.features.messages.impl.timeline.model.event.TimelineIt
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemImageContent
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemTextContent
import io.element.android.features.messages.impl.timeline.model.event.ensureActiveLiveLocation
import io.element.android.features.messages.impl.timeline.protection.TimelineProtectionEvent
import io.element.android.features.messages.impl.timeline.protection.TimelineProtectionState
import io.element.android.features.messages.impl.timeline.protection.mustBeProtected
import io.element.android.libraries.architecture.AsyncData
@@ -122,9 +124,13 @@ import io.element.android.libraries.matrix.api.timeline.item.event.TextMessageTy
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.user.MatrixUser
import io.element.android.libraries.matrix.ui.media.contentvalidation.collectOverallState
import io.element.android.libraries.matrix.ui.media.contentvalidation.rememberEventContentValidationState
import io.element.android.libraries.matrix.ui.messages.reply.InReplyToDetails
import io.element.android.libraries.matrix.ui.messages.reply.InReplyToView
import io.element.android.libraries.matrix.ui.messages.reply.content
import io.element.android.libraries.matrix.ui.messages.reply.eventId
import io.element.android.libraries.matrix.ui.messages.sender.SenderName
import io.element.android.libraries.matrix.ui.messages.sender.SenderNameMode
@@ -176,12 +182,12 @@ fun TimelineItemEventRow(
val onContentClick = onEventClick.takeUnless { event.isWholeContentClickable }
TimelineItemEventContentView(
eventId = event.eventId,
content = event.content,
hideMediaContent = timelineProtectionState.hideMediaContent(event.eventId),
timelineProtectionState = timelineProtectionState,
onContentClick = onContentClick,
onGalleryItemClick = onGalleryItemClick,
onLongClick = onLongClick,
onShowContentClick = { timelineProtectionState.eventSink(TimelineProtectionEvent.ShowContent(event.eventId)) },
onLinkClick = onLinkClick,
onLinkLongClick = onLinkLongClick,
eventSink = eventSink,
@@ -468,6 +474,23 @@ private fun TimelineItemEventRowContent(
)
}
val currentContentValidationState by rememberEventContentValidationState(eventId = event.eventId, needsValidation = event.content.isMedia)
.collectOverallState()
val needsInvalidContentCustomisations =
// Gallery events should not apply the custom bubble color, instead each item will apply some custom color if needed
event.content !is TimelineItemGalleryContent &&
event.content !is TimelineItemAttachmentsContent &&
currentContentValidationState.hasError() &&
event.content.isMedia
// If the event has a dangerous media content we need to set custom message bubble background and border colors
val themeColors = ElementTheme.colors
val (dangerousContentBubbleColor, borderColor) = remember(themeColors.isLight, needsInvalidContentCustomisations, event.content.type) {
val background = themeColors.bgCriticalSubtle.takeIf { needsInvalidContentCustomisations }
val border = themeColors.borderCriticalSubtle.takeIf { needsInvalidContentCustomisations }
background to border
}
// Message bubble
val bubbleState = BubbleState(
groupPosition = event.groupPosition,
@@ -494,6 +517,8 @@ private fun TimelineItemEventRowContent(
interactionSource = interactionSource,
onClick = onContentClick,
onLongClick = onLongClick,
customBackgroundColor = dangerousContentBubbleColor,
borderColor = borderColor,
) {
MessageEventBubbleContent(
event = event,
@@ -747,6 +772,7 @@ private fun MessageEventBubbleContent(
}
ContentPadding.CaptionedMedia ->
Modifier.padding(start = 8.dp, end = 8.dp, top = topPadding, bottom = 8.dp)
ContentPadding.InvalidContent -> Modifier.padding(top = topPadding, bottom = 8.dp)
}
val threadDecoration = @Composable {
@@ -770,10 +796,13 @@ private fun MessageEventBubbleContent(
}
val inReplyTo = @Composable { inReplyTo: InReplyToDetails ->
val currentContentValidationState by rememberEventContentValidationState(eventId = inReplyTo.eventId(), eventContent = inReplyTo.content())
.collectOverallState()
val topPadding = if (showThreadDecoration) 0.dp else 8.dp
val shape = RoundedCornerShape(6.dp)
val inReplyToModifier = Modifier
.padding(top = topPadding, start = 8.dp, end = 8.dp)
.clip(RoundedCornerShape(6.dp))
.clip(shape)
val talkbackCompatModifier = if (isTalkbackActive()) {
// Use z-index to make the replied to text being read after the message
@@ -782,14 +811,25 @@ private fun MessageEventBubbleContent(
} else {
inReplyToModifier.clickable(onClick = inReplyToClick)
}
val contentHasError = currentContentValidationState.hasError()
val borderColor = if (contentHasError) ElementTheme.colors.borderCriticalSubtle else ElementTheme.colors.separatorPrimary
val backgroundColor = if (contentHasError) ElementTheme.colors.bgCriticalSubtle else ElementTheme.colors.bgCanvasDefault
Box(
modifier = talkbackCompatModifier
.border(1.dp, ElementTheme.colors.separatorPrimary, RoundedCornerShape(6.dp))
.background(ElementTheme.colors.bgCanvasDefault, RoundedCornerShape(6.dp))
.border(1.dp, borderColor, shape)
.background(backgroundColor, shape)
.padding(4.dp)
) {
val contentValidationState = rememberEventContentValidationState(eventId = inReplyTo.eventId(), eventContent = inReplyTo.content())
val updatedEventSink by rememberUpdatedState(eventSink)
LaunchedEffect(inReplyTo) {
val mediaSources = inReplyTo.content()?.mediaSources() ?: return@LaunchedEffect
updatedEventSink(TimelineEvent.ValidateMedia(inReplyTo.eventId(), mediaSources, contentValidationState))
}
InReplyToView(
inReplyTo = inReplyTo,
contentValidationValue = currentContentValidationState,
hideImage = timelineProtectionState.hideMediaContent(inReplyTo.eventId()),
)
}
@@ -809,30 +849,47 @@ private fun MessageEventBubbleContent(
}
}
val timestampPosition = when (val content = event.content) {
is TimelineItemImageContent -> if (content.showCaption) TimestampPosition.Aligned else TimestampPosition.Overlay
is TimelineItemVideoContent -> if (content.showCaption) TimestampPosition.Aligned else TimestampPosition.Overlay
is TimelineItemGalleryContent -> if (content.showCaption) TimestampPosition.Aligned else TimestampPosition.Below
is TimelineItemAttachmentsContent -> TimestampPosition.Below
is TimelineItemStickerContent -> TimestampPosition.Overlay
is TimelineItemLocationContent -> {
val content = content.ensureActiveLiveLocation()
val shouldHide = content.mode is TimelineItemLocationContent.Mode.Live &&
content.mode.isActive &&
content.mode.isOwnUser
if (shouldHide) TimestampPosition.Hidden else TimestampPosition.Overlay
val contentValidationState by rememberEventContentValidationState(eventId = event.eventId, needsValidation = event.content.isMedia).collectOverallState()
val needsInvalidContentLayout =
// Gallery events should not apply custom paddings or layout dispositions
event.content !is TimelineItemGalleryContent &&
event.content !is TimelineItemAttachmentsContent &&
contentValidationState.hasError()
val timestampPosition = if (needsInvalidContentLayout) {
// The invalid content view will be displayed in all these cases, independent of the event content
TimestampPosition.Aligned
} else {
when (val content = event.content) {
is TimelineItemImageContent -> if (content.showCaption) TimestampPosition.Aligned else TimestampPosition.Overlay
is TimelineItemVideoContent -> if (content.showCaption) TimestampPosition.Aligned else TimestampPosition.Overlay
is TimelineItemGalleryContent -> if (content.showCaption) TimestampPosition.Aligned else TimestampPosition.Below
is TimelineItemAttachmentsContent -> if (content.showCaption) TimestampPosition.Aligned else TimestampPosition.Below
is TimelineItemStickerContent -> TimestampPosition.Overlay
is TimelineItemLocationContent -> {
val content = content.ensureActiveLiveLocation()
val shouldHide = content.mode is TimelineItemLocationContent.Mode.Live &&
content.mode.isActive &&
content.mode.isOwnUser
if (shouldHide) TimestampPosition.Hidden else TimestampPosition.Overlay
}
is TimelineItemPollContent -> TimestampPosition.Below
else -> TimestampPosition.Default
}
is TimelineItemPollContent -> TimestampPosition.Below
else -> TimestampPosition.Default
}
val paddingBehaviour = when (event.content) {
is TimelineItemImageContent -> if (event.content.showCaption) ContentPadding.CaptionedMedia else ContentPadding.Media
is TimelineItemVideoContent -> if (event.content.showCaption) ContentPadding.CaptionedMedia else ContentPadding.Media
is TimelineItemGalleryContent -> ContentPadding.CaptionedMedia
is TimelineItemAttachmentsContent -> ContentPadding.CaptionedMedia
is TimelineItemStickerContent,
is TimelineItemLocationContent -> ContentPadding.Media
else -> ContentPadding.Textual
val paddingBehaviour = if (needsInvalidContentLayout) {
ContentPadding.InvalidContent
} else {
when (event.content) {
is TimelineItemImageContent -> if (event.content.showCaption) ContentPadding.CaptionedMedia else ContentPadding.Media
is TimelineItemVideoContent -> if (event.content.showCaption) ContentPadding.CaptionedMedia else ContentPadding.Media
is TimelineItemGalleryContent -> ContentPadding.CaptionedMedia
is TimelineItemAttachmentsContent -> ContentPadding.CaptionedMedia
is TimelineItemStickerContent,
is TimelineItemLocationContent -> ContentPadding.Media
else -> ContentPadding.Textual
}
}
CommonLayout(
showThreadDecoration = timelineMode !is Timeline.Mode.Thread && event.threadInfo is TimelineItemThreadInfo.ThreadResponse,
@@ -905,6 +962,7 @@ internal fun TimelineItemEventRowWithThreadSummaryPreview() = ElementPreview {
displayName = USER_NAME_ALICE,
avatarUrl = null,
displayNameAmbiguous = false,
displayedStatus = null,
),
timestamp = 0L,
)
@@ -1083,6 +1141,7 @@ internal fun ThreadSummaryViewPreview() {
displayName = USER_NAME_ALICE,
avatarUrl = null,
displayNameAmbiguous = true,
displayedStatus = null,
),
timestamp = 0L,
)
@@ -28,7 +28,6 @@ import io.element.android.features.messages.impl.timeline.components.receipt.Rea
import io.element.android.features.messages.impl.timeline.components.receipt.TimelineItemReadReceiptView
import io.element.android.features.messages.impl.timeline.groups.isRedactedMessagesGroup
import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.features.messages.impl.timeline.protection.TimelineProtectionEvent
import io.element.android.features.messages.impl.timeline.protection.TimelineProtectionState
import io.element.android.features.messages.impl.timeline.protection.aTimelineProtectionState
import io.element.android.libraries.designsystem.preview.ElementPreview
@@ -62,9 +61,9 @@ fun TimelineItemGroupedEventsRow(
eventContentView: @Composable (TimelineItem.Event, Modifier, (ContentAvoidingLayoutData) -> Unit) -> Unit =
{ event, contentModifier, onContentLayoutChange ->
TimelineItemEventContentView(
eventId = event.eventId,
content = event.content,
hideMediaContent = timelineProtectionState.hideMediaContent(event.eventId, event.isMine),
onShowContentClick = { timelineProtectionState.eventSink(TimelineProtectionEvent.ShowContent(event.eventId)) },
timelineProtectionState = timelineProtectionState,
onLinkClick = onLinkClick,
onLinkLongClick = onLinkLongClick,
eventSink = eventSink,
@@ -134,9 +133,9 @@ private fun TimelineItemGroupedEventsRowContent(
eventContentView: @Composable (TimelineItem.Event, Modifier, (ContentAvoidingLayoutData) -> Unit) -> Unit =
{ event, contentModifier, onContentLayoutChange ->
TimelineItemEventContentView(
eventId = event.eventId,
content = event.content,
hideMediaContent = timelineProtectionState.hideMediaContent(event.eventId, event.isMine),
onShowContentClick = { timelineProtectionState.eventSink(TimelineProtectionEvent.ShowContent(event.eventId)) },
timelineProtectionState = timelineProtectionState,
onLinkClick = onLinkClick,
onLinkLongClick = onLinkLongClick,
eventSink = eventSink,
@@ -187,6 +186,7 @@ private fun TimelineItemGroupedEventsRowContent(
onReactionLongClick = onReactionLongClick,
onMoreReactionsClick = onMoreReactionsClick,
onReadReceiptClick = onReadReceiptClick,
onJoinCallClick = {},
onSwipeToReply = {},
eventSink = eventSink,
eventContentView = eventContentView,

Some files were not shown because too many files have changed in this diff Show More