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: labels:
- PR-Dependencies - PR-Dependencies
- title: 💼 Element Pro
labels:
- PR-Element-Pro
- title: Others - title: Others
labels: labels:
- PR-misc - PR-misc
+2 -2
View File
@@ -44,14 +44,14 @@ jobs:
docker-images: true docker-images: true
swap-storage: false swap-storage: false
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with: with:
# Ensure we are building the branch and not the branch after being merged on develop # Ensure we are building the branch and not the branch after being merged on develop
# https://github.com/actions/checkout/issues/881 # https://github.com/actions/checkout/issues/881
ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.ref }} ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.ref }}
persist-credentials: false persist-credentials: false
- name: Use JDK 21 - name: Use JDK 21
uses: actions/setup-java@1bcf9fb12cf4aa7d266a90ae39939e61372fe520 # v5.4.0 uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5.6.0
with: with:
distribution: 'temurin' # See 'Supported distributions' for available options distribution: 'temurin' # See 'Supported distributions' for available options
java-version: '21' java-version: '21'
+2 -2
View File
@@ -43,7 +43,7 @@ jobs:
docker-images: true docker-images: true
swap-storage: false swap-storage: false
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with: with:
# Ensure we are building the branch and not the branch after being merged on develop # Ensure we are building the branch and not the branch after being merged on develop
# https://github.com/actions/checkout/issues/881 # https://github.com/actions/checkout/issues/881
@@ -56,7 +56,7 @@ jobs:
- name: Clone submodules - name: Clone submodules
run: git submodule update --init --recursive run: git submodule update --init --recursive
- name: Use JDK 21 - name: Use JDK 21
uses: actions/setup-java@1bcf9fb12cf4aa7d266a90ae39939e61372fe520 # v5.4.0 uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5.6.0
with: with:
distribution: 'temurin' # See 'Supported distributions' for available options distribution: 'temurin' # See 'Supported distributions' for available options
java-version: '21' java-version: '21'
+1 -1
View File
@@ -11,7 +11,7 @@ jobs:
# Skip in forks, it doesn't work even with the fallback token # 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' }} if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'element-hq/element-x-android' }}
steps: steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with: with:
persist-credentials: false persist-credentials: false
- name: Add SSH private keys for submodule repositories - name: Add SSH private keys for submodule repositories
+2 -2
View File
@@ -19,7 +19,7 @@ jobs:
- name: ⏬ Checkout with LFS - name: ⏬ Checkout with LFS
uses: nschloe/action-cached-lfs-checkout@385a8ecc719e50b8c71af6ab01a624b486b7c3bc # v1.2.5 uses: nschloe/action-cached-lfs-checkout@385a8ecc719e50b8c71af6ab01a624b486b7c3bc # v1.2.5
- name: Use JDK 21 - name: Use JDK 21
uses: actions/setup-java@1bcf9fb12cf4aa7d266a90ae39939e61372fe520 # v5.4.0 uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5.6.0
with: with:
distribution: 'temurin' # See 'Supported distributions' for available options distribution: 'temurin' # See 'Supported distributions' for available options
java-version: '21' java-version: '21'
@@ -28,7 +28,7 @@ jobs:
with: with:
cache-read-only: ${{ github.ref != 'refs/heads/develop' }} cache-read-only: ${{ github.ref != 'refs/heads/develop' }}
- name: Set up Python 3.12 - name: Set up Python 3.12
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with: with:
python-version: 3.14 python-version: 3.14
- name: Run World screenshots generation script - name: Run World screenshots generation script
+2 -3
View File
@@ -13,12 +13,11 @@ jobs:
# Skip in forks # Skip in forks
if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'element-hq/element-x-android' }} if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'element-hq/element-x-android' }}
steps: steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with: with:
persist-credentials: false persist-credentials: false
- uses: actions/setup-java@1bcf9fb12cf4aa7d266a90ae39939e61372fe520 # v5.4.0 - uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5.6.0
name: Use JDK 21 name: Use JDK 21
if: (github.event_name == 'pull_request' && github.event.pull_request.fork == null) || github.event_name == 'workflow_dispatch'
with: with:
distribution: 'temurin' # See 'Supported distributions' for available options distribution: 'temurin' # See 'Supported distributions' for available options
java-version: '21' java-version: '21'
+3 -3
View File
@@ -38,13 +38,13 @@ jobs:
docker-images: true docker-images: true
swap-storage: false swap-storage: false
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with: with:
# Ensure we are building the branch and not the branch after being merged on develop # Ensure we are building the branch and not the branch after being merged on develop
# https://github.com/actions/checkout/issues/881 # https://github.com/actions/checkout/issues/881
ref: ${{ github.ref }} ref: ${{ github.ref }}
persist-credentials: false persist-credentials: false
- uses: actions/setup-java@1bcf9fb12cf4aa7d266a90ae39939e61372fe520 # v5.4.0 - uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5.6.0
name: Use JDK 21 name: Use JDK 21
with: with:
distribution: 'temurin' # See 'Supported distributions' for available options distribution: 'temurin' # See 'Supported distributions' for available options
@@ -78,7 +78,7 @@ jobs:
concurrency: concurrency:
group: maestro-test group: maestro-test
steps: 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' if: (github.event_name == 'pull_request' && github.event.pull_request.fork == null) || github.event_name == 'workflow_dispatch'
with: with:
# Ensure we are building the branch and not the branch after being merged on develop # 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 docker-images: true
swap-storage: false swap-storage: false
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with: with:
persist-credentials: false persist-credentials: false
- name: Use JDK 21 - name: Use JDK 21
uses: actions/setup-java@1bcf9fb12cf4aa7d266a90ae39939e61372fe520 # v5.4.0 uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5.6.0
with: with:
distribution: 'temurin' # See 'Supported distributions' for available options distribution: 'temurin' # See 'Supported distributions' for available options
java-version: '21' java-version: '21'
+3 -3
View File
@@ -37,7 +37,7 @@ jobs:
uses: nschloe/action-cached-lfs-checkout@385a8ecc719e50b8c71af6ab01a624b486b7c3bc # v1.2.5 uses: nschloe/action-cached-lfs-checkout@385a8ecc719e50b8c71af6ab01a624b486b7c3bc # v1.2.5
- name: Use JDK 21 - name: Use JDK 21
uses: actions/setup-java@1bcf9fb12cf4aa7d266a90ae39939e61372fe520 # v5.4.0 uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5.6.0
with: with:
distribution: 'temurin' # See 'Supported distributions' for available options distribution: 'temurin' # See 'Supported distributions' for available options
java-version: '21' java-version: '21'
@@ -76,11 +76,11 @@ jobs:
name: Dependency analysis name: Dependency analysis
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with: with:
persist-credentials: false persist-credentials: false
- name: Use JDK 21 - name: Use JDK 21
uses: actions/setup-java@1bcf9fb12cf4aa7d266a90ae39939e61372fe520 # v5.4.0 uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5.6.0
with: with:
distribution: 'temurin' # See 'Supported distributions' for available options distribution: 'temurin' # See 'Supported distributions' for available options
java-version: '21' java-version: '21'
+21 -21
View File
@@ -33,7 +33,7 @@ jobs:
docker-images: true docker-images: true
swap-storage: false swap-storage: false
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with: with:
persist-credentials: false persist-credentials: false
- name: Add SSH private keys for submodule repositories - name: Add SSH private keys for submodule repositories
@@ -51,11 +51,11 @@ jobs:
name: Search for invalid screenshot files name: Search for invalid screenshot files
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with: with:
persist-credentials: false persist-credentials: false
- name: Set up Python 3.12 - name: Set up Python 3.12
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with: with:
python-version: 3.14 python-version: 3.14
- name: Search for invalid screenshot files - name: Search for invalid screenshot files
@@ -65,11 +65,11 @@ jobs:
name: Search for invalid dependencies name: Search for invalid dependencies
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with: with:
persist-credentials: false persist-credentials: false
- name: Use JDK 21 - name: Use JDK 21
uses: actions/setup-java@1bcf9fb12cf4aa7d266a90ae39939e61372fe520 # v5.4.0 uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5.6.0
with: with:
distribution: 'temurin' # See 'Supported distributions' for available options distribution: 'temurin' # See 'Supported distributions' for available options
java-version: '21' java-version: '21'
@@ -78,7 +78,7 @@ jobs:
with: with:
cache-read-only: ${{ github.ref != 'refs/heads/develop' }} cache-read-only: ${{ github.ref != 'refs/heads/develop' }}
- name: Set up Python 3.12 - name: Set up Python 3.12
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with: with:
python-version: 3.14 python-version: 3.14
- name: Search for invalid dependencies - 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) }} 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 cancel-in-progress: true
steps: steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with: with:
# Ensure we are building the branch and not the branch after being merged on develop # Ensure we are building the branch and not the branch after being merged on develop
# https://github.com/actions/checkout/issues/881 # 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' }} 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 run: git submodule update --init --recursive
- name: Use JDK 21 - name: Use JDK 21
uses: actions/setup-java@1bcf9fb12cf4aa7d266a90ae39939e61372fe520 # v5.4.0 uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5.6.0
with: with:
distribution: 'temurin' # See 'Supported distributions' for available options distribution: 'temurin' # See 'Supported distributions' for available options
java-version: '21' 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) }} 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 cancel-in-progress: true
steps: steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with: with:
# Ensure we are building the branch and not the branch after being merged on develop # Ensure we are building the branch and not the branch after being merged on develop
# https://github.com/actions/checkout/issues/881 # 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' }} 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 run: git submodule update --init --recursive
- name: Use JDK 21 - name: Use JDK 21
uses: actions/setup-java@1bcf9fb12cf4aa7d266a90ae39939e61372fe520 # v5.4.0 uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5.6.0
with: with:
distribution: 'temurin' # See 'Supported distributions' for available options distribution: 'temurin' # See 'Supported distributions' for available options
java-version: '21' 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) }} 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 cancel-in-progress: true
steps: steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with: with:
# Ensure we are building the branch and not the branch after being merged on develop # Ensure we are building the branch and not the branch after being merged on develop
# https://github.com/actions/checkout/issues/881 # 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' }} 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 run: git submodule update --init --recursive
- name: Use JDK 21 - name: Use JDK 21
uses: actions/setup-java@1bcf9fb12cf4aa7d266a90ae39939e61372fe520 # v5.4.0 uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5.6.0
with: with:
distribution: 'temurin' # See 'Supported distributions' for available options distribution: 'temurin' # See 'Supported distributions' for available options
java-version: '21' 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) }} 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 cancel-in-progress: true
steps: steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with: with:
# Ensure we are building the branch and not the branch after being merged on develop # Ensure we are building the branch and not the branch after being merged on develop
# https://github.com/actions/checkout/issues/881 # 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' }} 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 run: git submodule update --init --recursive
- name: Use JDK 21 - name: Use JDK 21
uses: actions/setup-java@1bcf9fb12cf4aa7d266a90ae39939e61372fe520 # v5.4.0 uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5.6.0
with: with:
distribution: 'temurin' # See 'Supported distributions' for available options distribution: 'temurin' # See 'Supported distributions' for available options
java-version: '21' 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) }} 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 cancel-in-progress: true
steps: steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with: with:
# Ensure we are building the branch and not the branch after being merged on develop # Ensure we are building the branch and not the branch after being merged on develop
# https://github.com/actions/checkout/issues/881 # 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' }} 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 run: git submodule update --init --recursive
- name: Use JDK 21 - name: Use JDK 21
uses: actions/setup-java@1bcf9fb12cf4aa7d266a90ae39939e61372fe520 # v5.4.0 uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5.6.0
with: with:
distribution: 'temurin' # See 'Supported distributions' for available options distribution: 'temurin' # See 'Supported distributions' for available options
java-version: '21' 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) }} 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 cancel-in-progress: true
steps: steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with: with:
# Ensure we are building the branch and not the branch after being merged on develop # Ensure we are building the branch and not the branch after being merged on develop
# https://github.com/actions/checkout/issues/881 # https://github.com/actions/checkout/issues/881
@@ -319,7 +319,7 @@ jobs:
name: Check shell scripts name: Check shell scripts
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with: with:
persist-credentials: false persist-credentials: false
- name: Run shellcheck - name: Run shellcheck
@@ -333,10 +333,10 @@ jobs:
permissions: permissions:
security-events: write # Required for upload-sarif (used by zizmor-action) to upload SARIF files. security-events: write # Required for upload-sarif (used by zizmor-action) to upload SARIF files.
steps: steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with: with:
persist-credentials: false persist-credentials: false
- uses: zizmorcore/zizmor-action@192e21d79ab29983730a13d1382995c2307fbcaa # v0.5.7 - uses: zizmorcore/zizmor-action@6599ee8b7a49aef6a770f63d261d214911a7ce02 # v0.6.0
upload_reports: upload_reports:
name: Project Check Suite name: Project Check Suite
@@ -344,7 +344,7 @@ jobs:
needs: [konsist, lint, ktlint, detekt] needs: [konsist, lint, ktlint, detekt]
if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'element-hq/element-x-android' }} if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'element-hq/element-x-android' }}
steps: steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with: with:
# Ensure we are building the branch and not the branch after being merged on develop # Ensure we are building the branch and not the branch after being merged on develop
# https://github.com/actions/checkout/issues/881 # https://github.com/actions/checkout/issues/881
+1 -1
View File
@@ -54,7 +54,7 @@ jobs:
with: with:
persist-credentials: false persist-credentials: false
- name: ☕️ Use JDK 21 - name: ☕️ Use JDK 21
uses: actions/setup-java@1bcf9fb12cf4aa7d266a90ae39939e61372fe520 # v5.4.0 uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5.6.0
with: with:
distribution: 'temurin' # See 'Supported distributions' for available options distribution: 'temurin' # See 'Supported distributions' for available options
java-version: '21' java-version: '21'
+6 -6
View File
@@ -34,11 +34,11 @@ jobs:
docker-images: true docker-images: true
swap-storage: false swap-storage: false
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with: with:
persist-credentials: false persist-credentials: false
- name: Use JDK 21 - name: Use JDK 21
uses: actions/setup-java@1bcf9fb12cf4aa7d266a90ae39939e61372fe520 # v5.4.0 uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5.6.0
with: with:
distribution: 'temurin' # See 'Supported distributions' for available options distribution: 'temurin' # See 'Supported distributions' for available options
java-version: '21' java-version: '21'
@@ -71,7 +71,7 @@ jobs:
group: ${{ format('build-release-main-enterprise-{0}', github.sha) }} group: ${{ format('build-release-main-enterprise-{0}', github.sha) }}
cancel-in-progress: true cancel-in-progress: true
steps: steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with: with:
persist-credentials: false persist-credentials: false
- name: Add SSH private keys for submodule repositories - name: Add SSH private keys for submodule repositories
@@ -82,7 +82,7 @@ jobs:
- name: Clone submodules - name: Clone submodules
run: git submodule update --init --recursive run: git submodule update --init --recursive
- name: Use JDK 21 - name: Use JDK 21
uses: actions/setup-java@1bcf9fb12cf4aa7d266a90ae39939e61372fe520 # v5.4.0 uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5.6.0
with: with:
distribution: 'temurin' # See 'Supported distributions' for available options distribution: 'temurin' # See 'Supported distributions' for available options
java-version: '21' java-version: '21'
@@ -122,11 +122,11 @@ jobs:
docker-images: true docker-images: true
swap-storage: false swap-storage: false
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with: with:
persist-credentials: false persist-credentials: false
- name: Use JDK 21 - name: Use JDK 21
uses: actions/setup-java@1bcf9fb12cf4aa7d266a90ae39939e61372fe520 # v5.4.0 uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5.6.0
with: with:
distribution: 'temurin' # See 'Supported distributions' for available options distribution: 'temurin' # See 'Supported distributions' for available options
java-version: '21' java-version: '21'
+2 -2
View File
@@ -38,14 +38,14 @@ jobs:
docker-images: true docker-images: true
swap-storage: false swap-storage: false
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with: with:
# Ensure we are building the branch and not the branch after being merged on develop # Ensure we are building the branch and not the branch after being merged on develop
# https://github.com/actions/checkout/issues/881 # https://github.com/actions/checkout/issues/881
ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.ref }} ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.ref }}
persist-credentials: false persist-credentials: false
- name: Use JDK 21 - name: Use JDK 21
uses: actions/setup-java@1bcf9fb12cf4aa7d266a90ae39939e61372fe520 # v5.4.0 uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5.6.0
with: with:
distribution: 'temurin' # See 'Supported distributions' for available options distribution: 'temurin' # See 'Supported distributions' for available options
java-version: '21' java-version: '21'
+3 -3
View File
@@ -13,11 +13,11 @@ jobs:
# Skip in forks # Skip in forks
if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'element-hq/element-x-android' }} if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'element-hq/element-x-android' }}
steps: steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with: with:
persist-credentials: false persist-credentials: false
- name: Use JDK 21 - name: Use JDK 21
uses: actions/setup-java@1bcf9fb12cf4aa7d266a90ae39939e61372fe520 # v5.4.0 uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5.6.0
with: with:
distribution: 'temurin' # See 'Supported distributions' for available options distribution: 'temurin' # See 'Supported distributions' for available options
java-version: '21' java-version: '21'
@@ -26,7 +26,7 @@ jobs:
with: with:
cache-read-only: ${{ github.ref != 'refs/heads/develop' }} cache-read-only: ${{ github.ref != 'refs/heads/develop' }}
- name: Set up Python 3.12 - name: Set up Python 3.12
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with: with:
python-version: 3.14 python-version: 3.14
- name: Setup Localazy - 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' }} 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. # No concurrency required, runs every time on a schedule.
steps: steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with: with:
persist-credentials: false persist-credentials: false
- name: Set up Python 3.12 - name: Set up Python 3.12
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with: with:
python-version: 3.14 python-version: 3.14
- name: Install Prerequisite dependencies - 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' }} 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 run: git submodule update --init --recursive
- name: ☕️ Use JDK 21 - name: ☕️ Use JDK 21
uses: actions/setup-java@1bcf9fb12cf4aa7d266a90ae39939e61372fe520 # v5.4.0 uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5.6.0
with: with:
distribution: 'temurin' # See 'Supported distributions' for available options distribution: 'temurin' # See 'Supported distributions' for available options
java-version: '21' java-version: '21'
+34
View File
@@ -1,5 +1,39 @@
<component name="ProjectCodeStyleConfiguration"> <component name="ProjectCodeStyleConfiguration">
<code_scheme name="Project" version="173"> <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> <JetCodeStyleSettings>
<option name="LINE_BREAK_AFTER_MULTILINE_WHEN_ENTRY" value="false" /> <option name="LINE_BREAK_AFTER_MULTILINE_WHEN_ENTRY" value="false" />
<option name="CODE_STYLE_DEFAULTS" value="KOTLIN_OFFICIAL" /> <option name="CODE_STYLE_DEFAULTS" value="KOTLIN_OFFICIAL" />
+2 -2
View File
@@ -2,6 +2,6 @@
<project version="4"> <project version="4">
<component name="KotlinJpsPluginSettings"> <component name="KotlinJpsPluginSettings">
<option name="externalSystemId" value="Gradle" /> <option name="externalSystemId" value="Gradle" />
<option name="version" value="2.4.0" /> <option name="version" value="2.4.10" />
</component> </component>
</project> </project>
+1 -1
View File
@@ -16,4 +16,4 @@ appId: ${MAESTRO_APP_ID}
- extendedWaitUntil: - extendedWaitUntil:
visible: "Device verified" visible: "Device verified"
timeout: 30000 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 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.CacheDirectory
import io.element.android.libraries.di.annotations.AppCoroutineScope import io.element.android.libraries.di.annotations.AppCoroutineScope
import io.element.android.libraries.di.annotations.ApplicationContext 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.BuildConfig
import io.element.android.x.R import io.element.android.x.R
import kotlinx.coroutines.CoroutineName import kotlinx.coroutines.CoroutineName
@@ -115,10 +113,4 @@ object AppModule {
fun provideSnackbarDispatcher(): SnackbarDispatcher { fun provideSnackbarDispatcher(): SnackbarDispatcher {
return 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.truth)
testImplementation(libs.test.turbine) testImplementation(libs.test.turbine)
testImplementation(projects.libraries.matrix.test) 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.compound)
implementation(projects.libraries.architecture) implementation(projects.libraries.architecture)
implementation(projects.libraries.matrix.api) 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 androidx.compose.ui.graphics.Color
import io.element.android.compound.colors.SemanticColorsLightDark import io.element.android.compound.colors.SemanticColorsLightDark
import io.element.android.libraries.matrix.api.core.SessionId 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.Flow
interface EnterpriseService { interface EnterpriseService {
@@ -41,6 +42,11 @@ interface EnterpriseService {
*/ */
fun getNoisyNotificationChannelId(sessionId: SessionId): String? 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 { companion object {
const val ANY_ACCOUNT_PROVIDER = "*" const val ANY_ACCOUNT_PROVIDER = "*"
} }
@@ -23,6 +23,7 @@ dependencies {
api(projects.features.enterprise.api) api(projects.features.enterprise.api)
implementation(projects.libraries.architecture) implementation(projects.libraries.architecture)
implementation(projects.libraries.matrix.api) implementation(projects.libraries.matrix.api)
implementation(projects.libraries.wellknown.api)
testCommonDependencies(libs) testCommonDependencies(libs)
testImplementation(projects.libraries.matrix.test) 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.BugReportUrl
import io.element.android.features.enterprise.api.EnterpriseService import io.element.android.features.enterprise.api.EnterpriseService
import io.element.android.libraries.matrix.api.core.SessionId 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.Flow
import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.flowOf
@@ -45,4 +46,6 @@ class DefaultEnterpriseService : EnterpriseService {
} }
override fun getNoisyNotificationChannelId(sessionId: SessionId): String? = null override fun getNoisyNotificationChannelId(sessionId: SessionId): String? = null
override fun overriddenElementWellKnown(): ElementWellKnown? = null
} }
@@ -18,5 +18,6 @@ dependencies {
implementation(projects.libraries.architecture) implementation(projects.libraries.architecture)
implementation(projects.libraries.compound) implementation(projects.libraries.compound)
implementation(projects.libraries.matrix.api) implementation(projects.libraries.matrix.api)
implementation(projects.libraries.wellknown.api)
implementation(projects.tests.testutils) 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.BugReportUrl
import io.element.android.features.enterprise.api.EnterpriseService import io.element.android.features.enterprise.api.EnterpriseService
import io.element.android.libraries.matrix.api.core.SessionId 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.lambda.lambdaError
import io.element.android.tests.testutils.simulateLongTask import io.element.android.tests.testutils.simulateLongTask
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
@@ -31,6 +32,7 @@ class FakeEnterpriseService(
private val unifiedPushDefaultPushGatewayResult: () -> String? = { lambdaError() }, private val unifiedPushDefaultPushGatewayResult: () -> String? = { lambdaError() },
private val getNoisyNotificationChannelIdResult: (SessionId?) -> String? = { lambdaError() }, private val getNoisyNotificationChannelIdResult: (SessionId?) -> String? = { lambdaError() },
private val tweakMasUrlResult: (String, String) -> String = { _, _ -> lambdaError() }, private val tweakMasUrlResult: (String, String) -> String = { _, _ -> lambdaError() },
private val overrideWellKnownResult: () -> ElementWellKnown? = { lambdaError() },
) : EnterpriseService { ) : EnterpriseService {
private val brandColorState = MutableStateFlow(initialBrandColor) private val brandColorState = MutableStateFlow(initialBrandColor)
private val semanticColorsState = MutableStateFlow(initialSemanticColors) private val semanticColorsState = MutableStateFlow(initialSemanticColors)
@@ -79,4 +81,8 @@ class FakeEnterpriseService(
override fun getNoisyNotificationChannelId(sessionId: SessionId): String? { override fun getNoisyNotificationChannelId(sessionId: SessionId): String? {
return getNoisyNotificationChannelIdResult(sessionId) 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.consumeWindowInsets
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.plus
import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi 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.HorizontalFloatingToolbarSeparator
import io.element.android.libraries.designsystem.theme.components.Icon 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.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.SnackbarHost
import io.element.android.libraries.designsystem.utils.snackbar.rememberSnackbarHostState import io.element.android.libraries.designsystem.utils.snackbar.rememberSnackbarHostState
import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.RoomId
@@ -234,7 +237,15 @@ private fun HomeScaffold(
) )
}, },
floatingActionButtonPosition = FabPosition.Center, floatingActionButtonPosition = FabPosition.Center,
contentWindowInsets = scaffoldScrollableContentInsets,
content = { padding -> 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( val contentPadding = PaddingValues(
bottom = 96.dp, bottom = 96.dp,
) )
@@ -251,18 +262,10 @@ private fun HomeScaffold(
onConfirmRecoveryKeyClick = onConfirmRecoveryKeyClick, onConfirmRecoveryKeyClick = onConfirmRecoveryKeyClick,
onRoomClick = ::onRoomClick, onRoomClick = ::onRoomClick,
onCreateRoomClick = onStartChatClick, onCreateRoomClick = onStartChatClick,
contentPadding = contentPadding, contentPadding = lazyColumnContentPadding + contentPadding,
modifier = Modifier modifier = Modifier
.padding( .padding(outerPadding)
PaddingValues( .consumeWindowInsets(outerPadding)
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)
.hazeSource(state = hazeState) .hazeSource(state = hazeState)
) )
SpaceFiltersView(roomListState.spaceFiltersState) SpaceFiltersView(roomListState.spaceFiltersState)
@@ -271,10 +274,10 @@ private fun HomeScaffold(
HomeSpacesView( HomeSpacesView(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.padding(padding) .padding(outerPadding)
.consumeWindowInsets(padding) .consumeWindowInsets(outerPadding)
.hazeSource(state = hazeState), .hazeSource(state = hazeState),
contentPadding = contentPadding, contentPadding = lazyColumnContentPadding + contentPadding,
state = state.homeSpacesState, state = state.homeSpacesState,
lazyListState = spacesLazyListState, lazyListState = spacesLazyListState,
onSpaceClick = { spaceId -> onSpaceClick = { spaceId ->
@@ -12,8 +12,12 @@ import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.RowScope import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.WindowInsets 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.height
import androidx.compose.foundation.layout.only
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeDrawing
import androidx.compose.foundation.layout.statusBarsPadding import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.pager.VerticalPager import androidx.compose.foundation.pager.VerticalPager
import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.foundation.pager.rememberPagerState
@@ -96,13 +100,15 @@ fun HomeTopBar(
spaceFiltersState: SpaceFiltersState, spaceFiltersState: SpaceFiltersState,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val contentPadding = WindowInsets.safeDrawing.only(WindowInsetsSides.Horizontal).asPaddingValues()
Column(modifier) { Column(modifier) {
TopAppBar( TopAppBar(
modifier = Modifier modifier = Modifier
.backgroundVerticalGradient( .backgroundVerticalGradient(
isVisible = !areSearchResultsDisplayed, isVisible = !areSearchResultsDisplayed,
) )
.statusBarsPadding(), .statusBarsPadding()
.padding(contentPadding),
colors = TopAppBarDefaults.topAppBarColors( colors = TopAppBarDefaults.topAppBarColors(
containerColor = Color.Transparent, containerColor = Color.Transparent,
scrolledContainerColor = Color.Transparent, scrolledContainerColor = Color.Transparent,
@@ -153,7 +159,7 @@ fun HomeTopBar(
TopAppBarScrollBehaviorLayout(scrollBehavior = scrollBehavior) { TopAppBarScrollBehaviorLayout(scrollBehavior = scrollBehavior) {
RoomListFiltersView( RoomListFiltersView(
state = filtersState, 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.designsystem.theme.unreadIndicator
import io.element.android.libraries.matrix.api.notification.CallIntent 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.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.components.InviteSenderView
import io.element.android.libraries.matrix.ui.model.InviteSender import io.element.android.libraries.matrix.ui.model.InviteSender
import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.libraries.ui.strings.CommonStrings
@@ -126,7 +128,8 @@ internal fun RoomSummaryRow(
NameAndTimestampRow( NameAndTimestampRow(
name = room.name, name = room.name,
timestamp = room.timestamp, timestamp = room.timestamp,
isHighlighted = room.isHighlighted isHighlighted = room.isHighlighted,
dmUserStatus = room.dmUserStatus,
) )
MessagePreviewAndIndicatorRow(room = room, showUnreadCount = showUnreadCount) MessagePreviewAndIndicatorRow(room = room, showUnreadCount = showUnreadCount)
} }
@@ -142,7 +145,8 @@ internal fun RoomSummaryRow(
NameAndTimestampRow( NameAndTimestampRow(
name = room.name, name = room.name,
timestamp = null, timestamp = null,
isHighlighted = room.isHighlighted isHighlighted = room.isHighlighted,
dmUserStatus = null,
) )
if (room.canonicalAlias != null) { if (room.canonicalAlias != null) {
Text( Text(
@@ -218,22 +222,21 @@ private fun NameAndTimestampRow(
name: String?, name: String?,
timestamp: String?, timestamp: String?,
isHighlighted: Boolean, isHighlighted: Boolean,
dmUserStatus: DisplayedStatus?,
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
Row( Row(
modifier = modifier.fillMaxWidth(), modifier = modifier.fillMaxWidth(),
horizontalArrangement = spacedBy(16.dp) horizontalArrangement = spacedBy(16.dp)
) { ) {
Text( val displayName = name?.toSafeLength(ellipsize = true) ?: stringResource(id = CommonStrings.common_no_room_name)
modifier = Modifier DisplayNameWithStatus(
.weight(1f) name = displayName,
.clipToBounds(), status = dmUserStatus,
modifier = Modifier.weight(1f),
style = ElementTheme.typography.fontBodyLgMedium, style = ElementTheme.typography.fontBodyLgMedium,
text = name?.toSafeLength(ellipsize = true) ?: stringResource(id = CommonStrings.common_no_room_name), nameColor = ElementTheme.colors.roomListRoomName,
fontStyle = FontStyle.Italic.takeIf { name == null }, nameFontStyle = FontStyle.Italic.takeIf { name == null },
color = ElementTheme.colors.roomListRoomName,
maxLines = 1,
overflow = TextOverflow.Ellipsis
) )
// Timestamp // Timestamp
Text( 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.room.CurrentUserMembership
import io.element.android.libraries.matrix.api.roomlist.LatestEventValue import io.element.android.libraries.matrix.api.roomlist.LatestEventValue
import io.element.android.libraries.matrix.api.roomlist.RoomSummary 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.getAvatarData
import io.element.android.libraries.matrix.ui.model.toInviteSender import io.element.android.libraries.matrix.ui.model.toInviteSender
import kotlinx.collections.immutable.toImmutableList import kotlinx.collections.immutable.toImmutableList
@@ -76,6 +77,7 @@ class RoomListRoomSummaryFactory(
}.toImmutableList(), }.toImmutableList(),
isTombstoned = roomInfo.successorRoom != null, isTombstoned = roomInfo.successorRoom != null,
isSpace = roomInfo.isSpace, 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.core.RoomId
import io.element.android.libraries.matrix.api.notification.CallIntent 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.room.RoomNotificationMode
import io.element.android.libraries.matrix.api.user.DisplayedStatus
import io.element.android.libraries.matrix.ui.model.InviteSender import io.element.android.libraries.matrix.ui.model.InviteSender
import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableList
@@ -42,6 +43,7 @@ data class RoomListRoomSummary(
val isTombstoned: Boolean, val isTombstoned: Boolean,
val heroes: ImmutableList<AvatarData>, val heroes: ImmutableList<AvatarData>,
val isSpace: Boolean, val isSpace: Boolean,
val dmUserStatus: DisplayedStatus?,
) { ) {
val isHighlighted = userDefinedNotificationMode != RoomNotificationMode.MUTE && val isHighlighted = userDefinedNotificationMode != RoomNotificationMode.MUTE &&
(numberOfUnreadNotifications > 0 || numberOfUnreadMentions > 0) || (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.core.UserId
import io.element.android.libraries.matrix.api.notification.CallIntent 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.room.RoomNotificationMode
import io.element.android.libraries.matrix.api.user.DisplayedStatus
import io.element.android.libraries.matrix.ui.model.InviteSender import io.element.android.libraries.matrix.ui.model.InviteSender
import kotlinx.collections.immutable.toImmutableList import kotlinx.collections.immutable.toImmutableList
@@ -182,6 +183,7 @@ internal fun aRoomListRoomSummary(
heroes: List<AvatarData> = emptyList(), heroes: List<AvatarData> = emptyList(),
isTombstoned: Boolean = false, isTombstoned: Boolean = false,
isSpace: Boolean = false, isSpace: Boolean = false,
dmUserStatus: DisplayedStatus? = null,
) = RoomListRoomSummary( ) = RoomListRoomSummary(
id = id, id = id,
roomId = RoomId(id), roomId = RoomId(id),
@@ -204,5 +206,6 @@ internal fun aRoomListRoomSummary(
canonicalAlias = canonicalAlias, canonicalAlias = canonicalAlias,
heroes = heroes.toImmutableList(), heroes = heroes.toImmutableList(),
isTombstoned = isTombstoned, 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.fadeIn
import androidx.compose.animation.fadeOut import androidx.compose.animation.fadeOut
import androidx.compose.foundation.layout.Column 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.consumeWindowInsets
import androidx.compose.foundation.layout.fillMaxWidth 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.padding
import androidx.compose.foundation.layout.safeDrawing
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.lazy.rememberLazyListState
@@ -153,7 +159,8 @@ private fun RoomListSearchContent(
} }
}, },
) )
} },
contentWindowInsets = WindowInsets.safeDrawing.only(WindowInsetsSides.Bottom),
) { padding -> ) { padding ->
Column( Column(
modifier = Modifier modifier = Modifier
@@ -167,6 +174,7 @@ private fun RoomListSearchContent(
LazyColumn( LazyColumn(
state = lazyListState, state = lazyListState,
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
contentPadding = WindowInsets.navigationBars.asPaddingValues(),
) { ) {
items( items(
items = state.results, items = state.results,
@@ -15,9 +15,7 @@ import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.systemBarsPadding
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items 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.ModalBottomSheet
import io.element.android.libraries.designsystem.theme.components.SearchField import io.element.android.libraries.designsystem.theme.components.SearchField
import io.element.android.libraries.designsystem.theme.components.Text 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.api.spaces.SpaceServiceFilter
import io.element.android.libraries.matrix.ui.model.getAvatarData import io.element.android.libraries.matrix.ui.model.getAvatarData
import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.libraries.ui.strings.CommonStrings
@@ -75,9 +75,7 @@ fun SpaceFiltersView(
} }
if (sheetState.isVisible || isSelecting) { if (sheetState.isVisible || isSelecting) {
ModalBottomSheet( ModalBottomSheet(
modifier = modifier modifier = modifier,
.systemBarsPadding()
.navigationBarsPadding(),
sheetState = sheetState, sheetState = sheetState,
onDismissRequest = { onDismissRequest = {
if (state is SpaceFiltersState.Selecting) { if (state is SpaceFiltersState.Selecting) {
@@ -85,6 +83,7 @@ fun SpaceFiltersView(
} }
}, },
scrollable = false, scrollable = false,
contentWindowInsets = { scaffoldScrollableContentInsets },
) { ) {
Box( Box(
modifier = Modifier modifier = Modifier
@@ -113,7 +112,7 @@ private fun SpaceFiltersBottomSheetContent(
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
Column( Column(
modifier = modifier.padding(vertical = 16.dp) modifier = modifier.padding(top = 16.dp)
) { ) {
Text( Text(
text = stringResource(R.string.screen_roomlist_your_spaces), text = stringResource(R.string.screen_roomlist_your_spaces),
@@ -131,7 +130,9 @@ private fun SpaceFiltersBottomSheetContent(
placeholder = stringResource(CommonStrings.action_search), placeholder = stringResource(CommonStrings.action_search),
) )
Spacer(modifier = Modifier.height(16.dp)) Spacer(modifier = Modifier.height(16.dp))
LazyColumn { LazyColumn(
contentPadding = lazyColumnContentPadding,
) {
items(filters) { filter -> items(filters) { filter ->
SpaceFilterItem( SpaceFilterItem(
filter = filter, 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.AvatarData
import io.element.android.libraries.designsystem.components.avatar.AvatarSize 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.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_ID
import io.element.android.libraries.matrix.test.A_ROOM_NAME import io.element.android.libraries.matrix.test.A_ROOM_NAME
import kotlinx.collections.immutable.toImmutableList import kotlinx.collections.immutable.toImmutableList
@@ -87,6 +88,7 @@ internal fun createRoomListRoomSummary(
timestamp: String? = null, timestamp: String? = null,
isTombstoned: Boolean = false, isTombstoned: Boolean = false,
isSpace: Boolean = false, isSpace: Boolean = false,
dmUserStatus: DisplayedStatus? = null,
) = RoomListRoomSummary( ) = RoomListRoomSummary(
id = A_ROOM_ID.value, id = A_ROOM_ID.value,
roomId = A_ROOM_ID, roomId = A_ROOM_ID,
@@ -109,5 +111,6 @@ internal fun createRoomListRoomSummary(
isDm = false, isDm = false,
heroes = heroes.toImmutableList(), heroes = heroes.toImmutableList(),
isTombstoned = isTombstoned, isTombstoned = isTombstoned,
isSpace = isSpace isSpace = isSpace,
dmUserStatus = dmUserStatus,
) )
@@ -88,7 +88,7 @@ class DefaultInvitePeoplePresenter(
override fun present(): InvitePeopleState { override fun present(): InvitePeopleState {
val roomMembers = remember { mutableStateOf<AsyncData<ImmutableList<RoomMember>>>(AsyncData.Loading()) } val roomMembers = remember { mutableStateOf<AsyncData<ImmutableList<RoomMember>>>(AsyncData.Loading()) }
val selectedUsers = remember { mutableStateOf<ImmutableList<MatrixUser>>(persistentListOf()) } 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() val queryState = rememberTextFieldState()
var searchActive by rememberSaveable { mutableStateOf(false) } var searchActive by rememberSaveable { mutableStateOf(false) }
val showSearchLoader = rememberSaveable { mutableStateOf(false) } val showSearchLoader = rememberSaveable { mutableStateOf(false) }
@@ -332,15 +332,15 @@ class DefaultInvitePeoplePresenter(
showSearchLoader: MutableState<Boolean>, showSearchLoader: MutableState<Boolean>,
searchQuery: String, searchQuery: String,
) = withContext(coroutineDispatchers.io) { ) = withContext(coroutineDispatchers.io) {
searchResults.value = SearchBarResultState.Initial() searchResults.value = SearchBarResultState.Initial
showSearchLoader.value = false showSearchLoader.value = false
val joinedMembers = roomMembers.value.dataOrNull().orEmpty() val joinedMembers = roomMembers.value.dataOrNull().orEmpty()
userRepository.search(searchQuery).onEach { state -> userRepository.search(searchQuery).onEach { state ->
showSearchLoader.value = state.isSearching showSearchLoader.value = state.isSearching
searchResults.value = when { searchResults.value = when {
state.results.isEmpty() && state.isSearching -> SearchBarResultState.Initial() state.results.isEmpty() && state.isSearching -> SearchBarResultState.Initial
state.results.isEmpty() && !state.isSearching -> SearchBarResultState.NoResultsFound() state.results.isEmpty() && !state.isSearching -> SearchBarResultState.NoResultsFound
else -> SearchBarResultState.Results(state.results.map { result -> else -> SearchBarResultState.Results(state.results.map { result ->
val existingMembership = joinedMembers.firstOrNull { j -> j.userId == result.matrixUser.userId }?.membership val existingMembership = joinedMembers.firstOrNull { j -> j.userId == result.matrixUser.userId }?.membership
val isJoined = existingMembership == RoomMembershipState.JOIN val isJoined = existingMembership == RoomMembershipState.JOIN
@@ -33,7 +33,7 @@ internal class DefaultInvitePeopleStateProvider : PreviewParameterProvider<Defau
aDefaultInvitePeopleState(canInvite = true, selectedUsers = aMatrixUserList().toImmutableList()), aDefaultInvitePeopleState(canInvite = true, selectedUsers = aMatrixUserList().toImmutableList()),
aDefaultInvitePeopleState(isSearchActive = true, searchQuery = "some query"), aDefaultInvitePeopleState(isSearchActive = true, searchQuery = "some query"),
aDefaultInvitePeopleState(isSearchActive = true, searchQuery = "some query", selectedUsers = aMatrixUserList().toImmutableList()), 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( aDefaultInvitePeopleState(
isSearchActive = true, isSearchActive = true,
canInvite = true, canInvite = true,
@@ -115,7 +115,7 @@ private fun aDefaultInvitePeopleState(
room: AsyncData<Unit> = AsyncData.Success(Unit), room: AsyncData<Unit> = AsyncData.Success(Unit),
canInvite: Boolean = false, canInvite: Boolean = false,
searchQuery: String = "", searchQuery: String = "",
searchResults: SearchBarResultState<ImmutableList<InvitableUser>> = SearchBarResultState.Initial(), searchResults: SearchBarResultState<ImmutableList<InvitableUser>> = SearchBarResultState.Initial,
selectedUsers: ImmutableList<MatrixUser> = persistentListOf(), selectedUsers: ImmutableList<MatrixUser> = persistentListOf(),
isSearchActive: Boolean = false, isSearchActive: Boolean = false,
showSearchLoader: Boolean = false, showSearchLoader: Boolean = false,
@@ -2,4 +2,8 @@
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> <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_a_member">"Allerede medlem"</string>
<string name="screen_invite_users_already_invited">"Allerede invitert"</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> </resources>
@@ -95,7 +95,7 @@ internal class DefaultInvitePeoplePresenterTest {
resultState.searchQuery.setTextAndPlaceCursorAtEnd("some query") resultState.searchQuery.setTextAndPlaceCursorAtEnd("some query")
assertThat(awaitItemAsDefault().searchQuery.text.toString()).isEqualTo("some query") assertThat(awaitItemAsDefault().searchQuery.text.toString()).isEqualTo("some query")
resultState.eventSink(InvitePeopleEvents.CloseSearch) resultState.eventSink(InvitePeopleEvents.CloseSearch)
skipItems(2) skipItems(1)
awaitItemAsDefault().also { awaitItemAsDefault().also {
assertThat(it.isSearchActive).isFalse() assertThat(it.isSearchActive).isFalse()
assertThat(it.searchQuery.text.toString()).isEmpty() assertThat(it.searchQuery.text.toString()).isEmpty()
@@ -116,7 +116,7 @@ internal class DefaultInvitePeoplePresenterTest {
initialState.searchQuery.setTextAndPlaceCursorAtEnd("some query") initialState.searchQuery.setTextAndPlaceCursorAtEnd("some query")
assertThat(repository.providedQuery).isEqualTo("some query") assertThat(repository.providedQuery).isEqualTo("some query")
repository.emitState(UserSearchResultState(results = emptyList(), isSearching = true)) repository.emitState(UserSearchResultState(results = emptyList(), isSearching = true))
skipItems(3) skipItems(2)
awaitItemAsDefault().also { state -> awaitItemAsDefault().also { state ->
assertThat(state.searchResults).isInstanceOf(SearchBarResultState.Initial::class.java) assertThat(state.searchResults).isInstanceOf(SearchBarResultState.Initial::class.java)
assertThat(state.showSearchLoader).isTrue() assertThat(state.showSearchLoader).isTrue()
@@ -145,7 +145,6 @@ internal class DefaultInvitePeoplePresenterTest {
assertThat(repository.providedQuery).isEqualTo("some query") assertThat(repository.providedQuery).isEqualTo("some query")
repository.emitStateWithUsers(users = aMatrixUserList()) repository.emitStateWithUsers(users = aMatrixUserList())
skipItems(1)
val resultState = awaitItemAsDefault() val resultState = awaitItemAsDefault()
assertThat(resultState.searchResults).isInstanceOf(SearchBarResultState.Results::class.java) assertThat(resultState.searchResults).isInstanceOf(SearchBarResultState.Results::class.java)
@@ -198,7 +197,6 @@ internal class DefaultInvitePeoplePresenterTest {
assertThat(repository.providedQuery).isEqualTo("some query") assertThat(repository.providedQuery).isEqualTo("some query")
repository.emitStateWithUsers(users = aMatrixUserList()) repository.emitStateWithUsers(users = aMatrixUserList())
skipItems(1)
val resultState = awaitItemAsDefault() val resultState = awaitItemAsDefault()
assertThat(resultState.searchResults).isInstanceOf(SearchBarResultState.Results::class.java) assertThat(resultState.searchResults).isInstanceOf(SearchBarResultState.Results::class.java)
@@ -265,7 +263,6 @@ internal class DefaultInvitePeoplePresenterTest {
it it
) )
}) })
skipItems(1)
val resultState = awaitItemAsDefault() val resultState = awaitItemAsDefault()
assertThat(resultState.searchResults).isInstanceOf(SearchBarResultState.Results::class.java) assertThat(resultState.searchResults).isInstanceOf(SearchBarResultState.Results::class.java)
@@ -329,7 +326,7 @@ internal class DefaultInvitePeoplePresenterTest {
assertThat(repository.providedQuery).isEqualTo("some query") assertThat(repository.providedQuery).isEqualTo("some query")
repository.emitStateWithUsers(users = aMatrixUserList() + selectedUser) repository.emitStateWithUsers(users = aMatrixUserList() + selectedUser)
skipItems(2) skipItems(1)
val resultState = awaitItemAsDefault() val resultState = awaitItemAsDefault()
assertThat(resultState.searchResults).isInstanceOf(SearchBarResultState.Results::class.java) assertThat(resultState.searchResults).isInstanceOf(SearchBarResultState.Results::class.java)
@@ -366,7 +363,6 @@ internal class DefaultInvitePeoplePresenterTest {
assertThat(repository.providedQuery).isEqualTo("some query") assertThat(repository.providedQuery).isEqualTo("some query")
repository.emitStateWithUsers(users = aMatrixUserList() + selectedUser) repository.emitStateWithUsers(users = aMatrixUserList() + selectedUser)
skipItems(1)
awaitItemAsDefault().also { state -> awaitItemAsDefault().also { state ->
// selectedUser is not selected // selectedUser is not selected
assertThat(state.searchResults).isInstanceOf(SearchBarResultState.Results::class.java) assertThat(state.searchResults).isInstanceOf(SearchBarResultState.Results::class.java)
@@ -732,7 +728,7 @@ internal class DefaultInvitePeoplePresenterTest {
isSearching = true isSearching = true
) )
) )
skipItems(3) skipItems(2)
awaitItemAsDefault().run { awaitItemAsDefault().run {
assertThat(selectedUsers).containsExactly(alice, bob, charlie) 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.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.plus
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn 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.Text
import io.element.android.libraries.designsystem.theme.components.TextButton import io.element.android.libraries.designsystem.theme.components.TextButton
import io.element.android.libraries.designsystem.theme.components.TopAppBar 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 io.element.android.libraries.ui.strings.CommonStrings
import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableList
@@ -94,7 +97,8 @@ fun KnockRequestsListView(
.padding(padding) .padding(padding)
.consumeWindowInsets(padding), .consumeWindowInsets(padding),
) )
} },
contentWindowInsets = scaffoldScrollableContentInsets,
) )
} }
@@ -132,7 +136,7 @@ private fun KnockRequestsListContent(
onAcceptClick = ::onAcceptClick, onAcceptClick = ::onAcceptClick,
onDeclineClick = ::onDeclineClick, onDeclineClick = ::onDeclineClick,
onBanClick = ::onBanClick, 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 package io.element.android.features.licenses.impl.details
import androidx.compose.foundation.interaction.MutableInteractionSource 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.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.material3.ExperimentalMaterial3Api 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.ListItem
import io.element.android.libraries.designsystem.theme.components.Scaffold import io.element.android.libraries.designsystem.theme.components.Scaffold
import io.element.android.libraries.designsystem.theme.components.TopAppBar 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) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
@@ -41,9 +43,10 @@ fun DependenciesDetailsView(
navigationIcon = { BackButton(onClick = onBack) }, navigationIcon = { BackButton(onClick = onBack) },
) )
}, },
contentWindowInsets = scaffoldScrollableContentInsets,
) { contentPadding -> ) { contentPadding ->
LazyColumn( LazyColumn(
modifier = Modifier.padding(contentPadding), contentPadding = contentPadding + lazyColumnContentPadding,
) { ) {
val licenses = licenseItem.licenses.orEmpty() + val licenses = licenseItem.licenses.orEmpty() +
licenseItem.unknownLicenses.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.Column
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.plus
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.material3.ExperimentalMaterial3Api 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.Text
import io.element.android.libraries.designsystem.theme.components.TextField import io.element.android.libraries.designsystem.theme.components.TextField
import io.element.android.libraries.designsystem.theme.components.TopAppBar 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 io.element.android.libraries.ui.strings.CommonStrings
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@@ -52,10 +55,11 @@ fun DependencyLicensesListView(
navigationIcon = { BackButton(onClick = onBackClick) }, navigationIcon = { BackButton(onClick = onBackClick) },
) )
}, },
contentWindowInsets = scaffoldScrollableContentInsets,
) { contentPadding -> ) { contentPadding ->
Column( Column(
modifier = Modifier modifier = Modifier
.padding(contentPadding) .padding(contentPadding + lazyColumnContentPadding)
.padding(horizontal = 16.dp) .padding(horizontal = 16.dp)
) { ) {
if (state.licenses.isSuccess()) { if (state.licenses.isSuccess()) {
@@ -72,7 +76,9 @@ fun DependencyLicensesListView(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
) )
} }
LazyColumn { LazyColumn(
contentPadding = lazyColumnContentPadding,
) {
when (state.licenses) { when (state.licenses) {
is AsyncData.Failure -> item { is AsyncData.Failure -> item {
Text( Text(
@@ -9,21 +9,25 @@
package io.element.android.features.linknewdevice.impl.screens.desktop 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.Column
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height 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.material3.ExperimentalMaterial3Api
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp 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.compound.tokens.generated.CompoundIcons
import io.element.android.features.linknewdevice.impl.R import io.element.android.features.linknewdevice.impl.R
import io.element.android.libraries.designsystem.atomic.organisms.NumberedListOrganism 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.LocalBuildMeta
import io.element.android.libraries.designsystem.theme.components.Button 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.Icon
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.utils.annotatedTextWithBold import io.element.android.libraries.designsystem.utils.annotatedTextWithBold
import io.element.android.libraries.permissions.api.PermissionsView import io.element.android.libraries.permissions.api.PermissionsView
import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.persistentListOf
@@ -62,6 +67,7 @@ fun DesktopNoticeView(
title = stringResource(R.string.screen_link_new_device_desktop_title, appName), title = stringResource(R.string.screen_link_new_device_desktop_title, appName),
iconStyle = BigIcon.Style.Default(CompoundIcons.Computer()), iconStyle = BigIcon.Style.Default(CompoundIcons.Computer()),
modifier = modifier, modifier = modifier,
isScrollable = true,
buttons = { buttons = {
Button( Button(
text = stringResource(R.string.screen_link_new_device_desktop_submit), text = stringResource(R.string.screen_link_new_device_desktop_submit),
@@ -71,11 +77,10 @@ fun DesktopNoticeView(
} }
) { ) {
Column( Column(
Modifier.fillMaxWidth() Modifier.fillMaxWidth(),
) { ) {
Spacer(modifier = Modifier.height(40.dp)) Spacer(modifier = Modifier.height(40.dp))
NumberedListOrganism( NumberedListOrganism(
modifier = Modifier.fillMaxSize(),
items = persistentListOf( items = persistentListOf(
AnnotatedString(stringResource(R.string.screen_link_new_device_desktop_step1, appName)), AnnotatedString(stringResource(R.string.screen_link_new_device_desktop_step1, appName)),
annotatedTextWithBold( annotatedTextWithBold(
@@ -88,6 +93,17 @@ fun DesktopNoticeView(
AnnotatedString(stringResource(R.string.screen_link_new_device_desktop_step3)), 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_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_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_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_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_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> <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_step3">"Зіскануйте QR-код цим пристроєм"</string>
<string name="screen_link_new_device_desktop_submit">"Готовий до сканування"</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_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_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_notice">"Введіть 2-значний код"</string>
<string name="screen_link_new_device_enter_number_subtitle">"Це підтвердить, що з\'єднання з іншим пристроєм захищене."</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_step3">"使用此设备扫描二维码"</string>
<string name="screen_link_new_device_desktop_submit">"准备进行扫描"</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_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_error_numbers_do_not_match">"数字不匹配"</string>
<string name="screen_link_new_device_enter_number_notice">"输入两位数字的代码"</string> <string name="screen_link_new_device_enter_number_notice">"输入两位数字的代码"</string>
<string name="screen_link_new_device_enter_number_subtitle">"这将验证你与其它设备的连接是否安全。"</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_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_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_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_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_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> <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 clock: SystemClock,
private val sessionObserver: SessionObserver, private val sessionObserver: SessionObserver,
) : ActiveLiveLocationShareManager, LiveLocationReceiver { ) : ActiveLiveLocationShareManager, LiveLocationReceiver {
private data class Timeout(val expiresAt: Instant, val job: Job)
private val isSetup = AtomicBoolean(false) private val isSetup = AtomicBoolean(false)
private val cachedRooms = ConcurrentHashMap<RoomId, JoinedRoom>() 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 syncedActiveShareIds = MutableStateFlow<Set<BeaconId>>(emptySet())
private val localSharingRoomIds = MutableStateFlow<Set<RoomId>>(emptySet()) private val localSharingRoomIds = MutableStateFlow<Set<RoomId>>(emptySet())
override val sharingRoomIds: StateFlow<Set<RoomId>> = localSharingRoomIds override val sharingRoomIds: StateFlow<Set<RoomId>> = localSharingRoomIds
@@ -139,9 +141,9 @@ class DefaultActiveLiveLocationShareManager(
} }
override suspend fun onLocationUpdate(location: Location) { override suspend fun onLocationUpdate(location: Location) {
val activeSharesCount = localSharingRoomIds.value.size val active = stopExpiredShares()
Timber.d("ActiveLiveLocationShareManager received location update for $activeSharesCount active share(s)") Timber.d("ActiveLiveLocationShareManager received location update for ${active.size} active share(s)")
localSharingRoomIds.value.forEach { roomId -> active.forEach { roomId ->
Timber.d("ActiveLiveLocationShareManager sending location to room $roomId") Timber.d("ActiveLiveLocationShareManager sending location to room $roomId")
sendLiveLocation(roomId, location) sendLiveLocation(roomId, location)
.onFailure { .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> { private suspend fun sendLiveLocation(roomId: RoomId, location: Location): Result<Unit> {
val room = cachedRooms.getOrPut(roomId) { val room = cachedRooms.getOrPut(roomId) {
matrixClient.getJoinedRoom(roomId) ?: return Result.failure(IllegalStateException("No room found for $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) { private fun scheduleTimeout(roomId: RoomId, expiresAt: Instant) {
timeoutJobs.remove(roomId)?.cancel() timeouts.remove(roomId)?.job?.cancel()
val delayMillis = expiresAt.toEpochMilliseconds() - clock.epochMillis() val delayMillis = expiresAt.toEpochMilliseconds() - clock.epochMillis()
timeoutJobs[roomId] = matrixClient.sessionCoroutineScope.launch { val job = matrixClient.sessionCoroutineScope.launch {
delay(delayMillis) delay(delayMillis)
stopShare(roomId) stopShare(roomId)
.onFailure { error -> .onFailure { error ->
Timber.e(error, "ActiveLiveLocationShareManager failed to stop timed out share for room $roomId") 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) { private suspend fun stopLocalShare(roomId: RoomId) {
Timber.d("ActiveLiveLocationShareManager stop local share in $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() val wasSharing = localSharingRoomIds.getAndUpdate { it - roomId }.isNotEmpty()
cachedRooms.remove(roomId)?.close() cachedRooms.remove(roomId)?.close()
liveLocationStore.removeLiveLocationExpiry(roomId) liveLocationStore.removeLiveLocationExpiry(roomId)
@@ -220,11 +236,11 @@ class DefaultActiveLiveLocationShareManager(
sessionObserver.removeListener(sessionListener) sessionObserver.removeListener(sessionListener)
coordinator.unregister(matrixClient.sessionId) coordinator.unregister(matrixClient.sessionId)
liveLocationStore.clear() liveLocationStore.clear()
timeouts.values.forEach { it.job.cancel() }
timeouts.clear()
for (room in cachedRooms.values) { for (room in cachedRooms.values) {
room.close() room.close()
timeoutJobs[room.roomId]?.cancel()
} }
timeoutJobs.clear()
cachedRooms.clear() cachedRooms.clear()
localSharingRoomIds.value = emptySet() localSharingRoomIds.value = emptySet()
syncedActiveShareIds.value = emptySet() syncedActiveShareIds.value = emptySet()
@@ -1,4 +1,6 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> <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_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> </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 @Test
fun `session deleted clears local state`() = runTest { fun `session deleted clears local state`() = runTest {
val startServiceRecorder = lambdaRecorder<Unit> { } val startServiceRecorder = lambdaRecorder<Unit> { }
@@ -18,6 +18,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme import io.element.android.compound.theme.ElementTheme
import io.element.android.compound.tokens.generated.CompoundIcons import io.element.android.compound.tokens.generated.CompoundIcons
@@ -40,7 +41,7 @@ fun AccountProviderOtherView(
Column( Column(
modifier = modifier modifier = modifier
.fillMaxWidth() .fillMaxWidth()
.clickable { onClick() } .clickable(role = Role.Button) { onClick() }
) { ) {
HorizontalDivider() HorizontalDivider()
Row( Row(
@@ -19,6 +19,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme import io.element.android.compound.theme.ElementTheme
@@ -45,7 +46,7 @@ fun AccountProviderView(
Column( Column(
modifier = modifier modifier = modifier
.fillMaxWidth() .fillMaxWidth()
.clickable { onClick() } .clickable(role = Role.Button) { onClick() }
) { ) {
HorizontalDivider() HorizontalDivider()
Column( Column(
@@ -141,6 +141,8 @@ class QrCodeLoginFlowNode(
} }
QrLoginException.CheckCodeAlreadySent, QrLoginException.CheckCodeAlreadySent,
QrLoginException.CheckCodeCannotBeSent, QrLoginException.CheckCodeCannotBeSent,
QrLoginException.ContinuationAlreadySent,
QrLoginException.ContinuationCannotBeSent,
QrLoginException.UnsupportedQrCodeType, QrLoginException.UnsupportedQrCodeType,
QrLoginException.Unknown -> { QrLoginException.Unknown -> {
Timber.e(error, "Unknown error found") 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.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.Scaffold import io.element.android.libraries.designsystem.theme.components.Scaffold
import io.element.android.libraries.designsystem.theme.components.TopAppBar 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 * https://www.figma.com/file/o9p34zmiuEpZRyvZXJZAYL/FTUE?type=design&node-id=604-60817
@@ -59,7 +61,8 @@ fun ChangeAccountProviderView(
title = {}, title = {},
navigationIcon = { BackButton(onClick = onBackClick) } navigationIcon = { BackButton(onClick = onBackClick) }
) )
} },
contentWindowInsets = scaffoldScrollableContentInsets,
) { padding -> ) { padding ->
Box( Box(
modifier = Modifier modifier = Modifier
@@ -71,6 +74,7 @@ fun ChangeAccountProviderView(
Column( Column(
modifier = Modifier modifier = Modifier
.verticalScroll(state = rememberScrollState()) .verticalScroll(state = rememberScrollState())
.padding(lazyColumnContentPadding)
) { ) {
IconTitleSubtitleMolecule( IconTitleSubtitleMolecule(
modifier = Modifier.padding(top = 16.dp, bottom = 32.dp, start = 16.dp, end = 16.dp), 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.Button
import io.element.android.libraries.designsystem.theme.components.Scaffold import io.element.android.libraries.designsystem.theme.components.Scaffold
import io.element.android.libraries.designsystem.theme.components.TopAppBar 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.matrix.api.auth.OAuthDetails
import io.element.android.libraries.permissions.api.localnetwork.LocalNetworkPermissionDialogView import io.element.android.libraries.permissions.api.localnetwork.LocalNetworkPermissionDialogView
import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.libraries.ui.strings.CommonStrings
@@ -71,7 +73,8 @@ fun ChooseAccountProviderView(
title = {}, title = {},
navigationIcon = { BackButton(onClick = onBackClick) } navigationIcon = { BackButton(onClick = onBackClick) }
) )
} },
contentWindowInsets = scaffoldScrollableContentInsets,
) { padding -> ) { padding ->
Box( Box(
modifier = Modifier modifier = Modifier
@@ -84,6 +87,7 @@ fun ChooseAccountProviderView(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.verticalScroll(state = rememberScrollState()) .verticalScroll(state = rememberScrollState())
.padding(lazyColumnContentPadding)
) { ) {
IconTitleSubtitleMolecule( IconTitleSubtitleMolecule(
modifier = Modifier.padding(top = 16.dp, bottom = 32.dp, start = 16.dp, end = 16.dp), 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.LocalAutofillManager
import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.semantics.contentType import androidx.compose.ui.semantics.contentType
import androidx.compose.ui.semantics.semantics import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.ImeAction
@@ -211,10 +212,15 @@ private fun LoginForm(
singleLine = true, singleLine = true,
trailingIcon = if (loginFieldState.isNotEmpty()) { trailingIcon = if (loginFieldState.isNotEmpty()) {
{ {
Box(Modifier.clickable { Box(
loginFieldState = "" Modifier.clickable(
eventSink(LoginPasswordEvents.SetLogin("")) onClickLabel = stringResource(CommonStrings.action_clear),
}) { role = Role.Button,
) {
loginFieldState = ""
eventSink(LoginPasswordEvents.SetLogin(""))
}
) {
Icon( Icon(
imageVector = CompoundIcons.Close(), imageVector = CompoundIcons.Close(),
contentDescription = stringResource(CommonStrings.action_clear), contentDescription = stringResource(CommonStrings.action_clear),
@@ -27,6 +27,7 @@ import androidx.compose.ui.BiasAlignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@@ -336,7 +337,7 @@ private fun OnBoardingButtons(
} else { } else {
Text( Text(
modifier = Modifier modifier = Modifier
.clickable { .clickable(role = Role.Button) {
state.eventSink(OnBoardingEvents.OnVersionClick) state.eventSink(OnBoardingEvents.OnVersionClick)
} }
.padding(16.dp), .padding(16.dp),
@@ -17,7 +17,6 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import dev.zacsweers.metro.Inject 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.features.login.impl.qrcode.QrCodeLoginManager
import io.element.android.libraries.architecture.AsyncAction import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.architecture.Presenter
@@ -39,7 +38,6 @@ class QrCodeScanPresenter(
private val qrCodeLoginDataFactory: MatrixQrCodeLoginDataFactory, private val qrCodeLoginDataFactory: MatrixQrCodeLoginDataFactory,
private val qrCodeLoginManager: QrCodeLoginManager, private val qrCodeLoginManager: QrCodeLoginManager,
private val coroutineDispatchers: CoroutineDispatchers, private val coroutineDispatchers: CoroutineDispatchers,
private val defaultAccountProviderAccessControl: DefaultAccountProviderAccessControl,
) : Presenter<QrCodeScanState> { ) : Presenter<QrCodeScanState> {
private var isScanning by mutableStateOf(true) private var isScanning by mutableStateOf(true)
@@ -97,13 +95,6 @@ class QrCodeScanPresenter(
val data = qrCodeLoginDataFactory.parseQrCodeData(code).onFailure { val data = qrCodeLoginDataFactory.parseQrCodeData(code).onFailure {
Timber.e(it, "Error parsing QR code data") Timber.e(it, "Error parsing QR code data")
}.getOrThrow() }.getOrThrow()
val serverName = data.serverName()
if (serverName != null) {
defaultAccountProviderAccessControl.assertIsAllowedToConnectToAccountProvider(
title = serverName,
accountProviderUrl = serverName,
)
}
data data
}.runCatchingUpdatingState(codeScannedAction) }.runCatchingUpdatingState(codeScannedAction)
}.invokeOnCompletion { }.invokeOnCompletion {
@@ -34,6 +34,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusDirection import androidx.compose.ui.focus.FocusDirection
import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.res.stringResource 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.ImeAction
import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.PreviewParameter
@@ -125,10 +126,15 @@ fun SearchAccountProviderView(
singleLine = true, singleLine = true,
trailingIcon = if (userInputState.isNotEmpty()) { trailingIcon = if (userInputState.isNotEmpty()) {
{ {
Box(Modifier.clickable { Box(
userInputState = "" Modifier.clickable(
eventSink(SearchAccountProviderEvents.UserInput("")) onClickLabel = stringResource(CommonStrings.action_clear),
}) { role = Role.Button,
) {
userInputState = ""
eventSink(SearchAccountProviderEvents.UserInput(""))
}
) {
Icon( Icon(
imageVector = CompoundIcons.Close(), imageVector = CompoundIcons.Close(),
contentDescription = stringResource(CommonStrings.action_clear) 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_subtitle">"Matrix er et åpent nettverk for sikker, desentralisert kommunikasjon."</string>
<string name="screen_login_title">"Velkommen tilbake!"</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_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_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_manually">"Logg på manuelt"</string>
<string name="screen_onboarding_sign_in_to">"Logg inn på %1$s"</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> <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 package io.element.android.features.login.impl.screens.qrcode.scan
import com.google.common.truth.Truth.assertThat 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.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.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.matrix.api.auth.qrlogin.QrCodeLoginStep 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.api.auth.qrlogin.QrLoginException
import io.element.android.libraries.matrix.test.auth.qrlogin.FakeMatrixQrCodeLoginData 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.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.lambda.lambdaRecorder
import io.element.android.tests.testutils.test import io.element.android.tests.testutils.test
import io.element.android.tests.testutils.testCoroutineDispatchers import io.element.android.tests.testutils.testCoroutineDispatchers
@@ -53,9 +47,6 @@ class QrCodeScanPresenterTest {
) )
val presenter = createQrCodeScanPresenter( val presenter = createQrCodeScanPresenter(
qrCodeLoginDataFactory = qrCodeLoginDataFactory, qrCodeLoginDataFactory = qrCodeLoginDataFactory,
enterpriseService = FakeEnterpriseService(
isAllowedToConnectToHomeserverResult = { true },
)
) )
presenter.test { presenter.test {
val initialState = awaitItem() 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 @Test
fun `present - scanned QR code failed and can be retried`() = runTest { fun `present - scanned QR code failed and can be retried`() = runTest {
val qrCodeLoginDataFactory = FakeMatrixQrCodeLoginDataFactory( val qrCodeLoginDataFactory = FakeMatrixQrCodeLoginDataFactory(
@@ -153,15 +106,9 @@ class QrCodeScanPresenterTest {
qrCodeLoginDataFactory: FakeMatrixQrCodeLoginDataFactory = FakeMatrixQrCodeLoginDataFactory(), qrCodeLoginDataFactory: FakeMatrixQrCodeLoginDataFactory = FakeMatrixQrCodeLoginDataFactory(),
coroutineDispatchers: CoroutineDispatchers = testCoroutineDispatchers(), coroutineDispatchers: CoroutineDispatchers = testCoroutineDispatchers(),
qrCodeLoginManager: FakeQrCodeLoginManager = FakeQrCodeLoginManager(), qrCodeLoginManager: FakeQrCodeLoginManager = FakeQrCodeLoginManager(),
enterpriseService: EnterpriseService = FakeEnterpriseService(),
wellknownRetriever: WellknownRetriever = FakeWellknownRetriever(),
) = QrCodeScanPresenter( ) = QrCodeScanPresenter(
qrCodeLoginDataFactory = qrCodeLoginDataFactory, qrCodeLoginDataFactory = qrCodeLoginDataFactory,
qrCodeLoginManager = qrCodeLoginManager, qrCodeLoginManager = qrCodeLoginManager,
coroutineDispatchers = coroutineDispatchers, 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.mediaupload.api)
implementation(projects.libraries.permissions.api) implementation(projects.libraries.permissions.api)
implementation(projects.libraries.preferences.api) implementation(projects.libraries.preferences.api)
implementation(projects.libraries.recentemojis.api) implementation(projects.libraries.emoji.api)
implementation(projects.libraries.roomselect.api) implementation(projects.libraries.roomselect.api)
implementation(projects.libraries.slashcommands.api) implementation(projects.libraries.slashcommands.api)
implementation(projects.libraries.audio.api) implementation(projects.libraries.audio.api)
@@ -62,6 +62,7 @@ dependencies {
implementation(projects.libraries.uiUtils) implementation(projects.libraries.uiUtils)
implementation(projects.libraries.testtags) implementation(projects.libraries.testtags)
implementation(projects.features.networkmonitor.api) implementation(projects.features.networkmonitor.api)
implementation(projects.features.contentscanner.api)
implementation(projects.services.analytics.compose) implementation(projects.services.analytics.compose)
implementation(projects.services.appnavstate.api) implementation(projects.services.appnavstate.api)
implementation(projects.services.toolbox.api) implementation(projects.services.toolbox.api)
@@ -105,6 +106,7 @@ dependencies {
testImplementation(projects.libraries.testtags) testImplementation(projects.libraries.testtags)
testImplementation(projects.features.poll.test) testImplementation(projects.features.poll.test)
testImplementation(projects.libraries.eventformatter.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) 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.TimelineItemLocationContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVideoContent 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.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.messages.impl.timeline.model.event.duration
import io.element.android.features.poll.api.create.CreatePollEntryPoint import io.element.android.features.poll.api.create.CreatePollEntryPoint
import io.element.android.features.poll.api.create.CreatePollMode import io.element.android.features.poll.api.create.CreatePollMode
@@ -155,6 +156,7 @@ class MessagesFlowNode(
val mediaSource: MediaSource, val mediaSource: MediaSource,
val thumbnailSource: MediaSource?, val thumbnailSource: MediaSource?,
val canUseOverlay: Boolean, val canUseOverlay: Boolean,
val blurHash: String?,
) : NavTarget ) : NavTarget
@Parcelize @Parcelize
@@ -377,6 +379,7 @@ class MessagesFlowNode(
mediaInfo = navTarget.mediaInfo, mediaInfo = navTarget.mediaInfo,
mediaSource = navTarget.mediaSource, mediaSource = navTarget.mediaSource,
thumbnailSource = navTarget.thumbnailSource, thumbnailSource = navTarget.thumbnailSource,
blurHash = navTarget.blurHash,
) )
val callback = object : MediaViewerEntryPoint.Callback { val callback = object : MediaViewerEntryPoint.Callback {
override fun onDone() { override fun onDone() {
@@ -828,6 +831,7 @@ class MessagesFlowNode(
mediaSource = galleryItem.mediaSource, mediaSource = galleryItem.mediaSource,
thumbnailSource = galleryItem.thumbnailSource, thumbnailSource = galleryItem.thumbnailSource,
type = galleryItem.type.toMediaViewerType(), type = galleryItem.type.toMediaViewerType(),
blurHash = galleryItem.blurhash,
) )
}.reversed() }.reversed()
NavTarget.GalleryViewer( NavTarget.GalleryViewer(
@@ -861,6 +865,7 @@ class MessagesFlowNode(
mediaSource = attachment.mediaSource, mediaSource = attachment.mediaSource,
thumbnailSource = attachment.thumbnailSource, thumbnailSource = attachment.thumbnailSource,
type = GalleryItemData.Type.File, type = GalleryItemData.Type.File,
blurHash = null,
) )
}.reversed() }.reversed()
NavTarget.GalleryViewer( NavTarget.GalleryViewer(
@@ -920,6 +925,7 @@ class MessagesFlowNode(
mediaSource = mediaSource, mediaSource = mediaSource,
thumbnailSource = thumbnailSource, thumbnailSource = thumbnailSource,
canUseOverlay = canUseOverlay, 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.TimelineController
import io.element.android.features.messages.impl.timeline.TimelineEvent 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.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.LocalTimelineItemPresenterFactories
import io.element.android.features.messages.impl.timeline.di.TimelineItemPresenterFactories import io.element.android.features.messages.impl.timeline.di.TimelineItemPresenterFactories
import io.element.android.features.messages.impl.timeline.model.TimelineItem 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.RoomScope
import io.element.android.libraries.di.annotations.ApplicationContext import io.element.android.libraries.di.annotations.ApplicationContext
import io.element.android.libraries.di.annotations.SessionCoroutineScope 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.analytics.toAnalyticsViewRoom
import io.element.android.libraries.matrix.api.core.EventId 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.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.room.alias.matches
import io.element.android.libraries.matrix.api.timeline.Timeline 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.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.matrix.ui.model.getBestName
import io.element.android.libraries.mediaplayer.api.MediaPlayer import io.element.android.libraries.mediaplayer.api.MediaPlayer
import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.libraries.ui.strings.CommonStrings
@@ -97,6 +101,8 @@ class MessagesNode(
private val permalinkParser: PermalinkParser, private val permalinkParser: PermalinkParser,
private val knockRequestsBannerRenderer: KnockRequestsBannerRenderer, private val knockRequestsBannerRenderer: KnockRequestsBannerRenderer,
private val roomMemberModerationRenderer: RoomMemberModerationRenderer, private val roomMemberModerationRenderer: RoomMemberModerationRenderer,
private val eventContentValidationCache: EventContentValidationCache,
private val emojiPickerRenderer: EmojiPickerRenderer,
) : Node(buildContext, plugins = plugins), MessagesNavigator { ) : Node(buildContext, plugins = plugins), MessagesNavigator {
data class Inputs( data class Inputs(
val focusedEventId: EventId?, val focusedEventId: EventId?,
@@ -261,6 +267,7 @@ class MessagesNode(
val canUseOverlay = !isTalkbackActive() && !hasExternalKeyboard() val canUseOverlay = !isTalkbackActive() && !hasExternalKeyboard()
CompositionLocalProvider( CompositionLocalProvider(
LocalTimelineItemPresenterFactories provides timelineItemPresenterFactories, LocalTimelineItemPresenterFactories provides timelineItemPresenterFactories,
LocalEventContentValidationState provides eventContentValidationCache,
) { ) {
val state = presenter.present() val state = presenter.present()
@@ -325,6 +332,15 @@ class MessagesNode(
onViewRequestsClick = callback::navigateToKnockRequestsList, onViewRequestsClick = callback::navigateToKnockRequestsList,
) )
}, },
customReactionBottomSheet = {
CustomReactionBottomSheet(
state = state.customReactionState,
onSelectEmoji = { uniqueId, emoji ->
state.eventSink(MessagesEvent.ToggleReaction(emoji.unicode, uniqueId))
},
emojiPickerRenderer = emojiPickerRenderer,
)
},
onThreadsListClick = callback::navigateToThreadsList, onThreadsListClick = callback::navigateToThreadsList,
) )
roomMemberModerationRenderer.Render( 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.SnackbarMessage
import io.element.android.libraries.designsystem.utils.snackbar.collectSnackbarMessageAsState import io.element.android.libraries.designsystem.utils.snackbar.collectSnackbarMessageAsState
import io.element.android.libraries.di.annotations.SessionCoroutineScope 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.FeatureFlagService
import io.element.android.libraries.featureflag.api.FeatureFlags import io.element.android.libraries.featureflag.api.FeatureFlags
import io.element.android.libraries.matrix.api.core.toThreadId 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.Timeline
import io.element.android.libraries.matrix.api.timeline.item.event.EventOrTransactionId 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.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.model.getAvatarData
import io.element.android.libraries.matrix.ui.room.getDirectRoomMember 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.textcomposer.model.MessageComposerMode
import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.services.analytics.api.AnalyticsService import io.element.android.services.analytics.api.AnalyticsService
@@ -319,6 +320,7 @@ class MessagesPresenter(
appName = buildMeta.applicationName, appName = buildMeta.applicationName,
pinnedMessagesBannerState = pinnedMessagesBannerState, pinnedMessagesBannerState = pinnedMessagesBannerState,
dmUserVerificationState = dmUserVerificationState, dmUserVerificationState = dmUserVerificationState,
dmUserStatus = roomInfo.dmUserStatus(),
roomMemberModerationState = roomMemberModerationState, roomMemberModerationState = roomMemberModerationState,
topBarSharedHistoryIcon = topBarSharedHistoryIcon, topBarSharedHistoryIcon = topBarSharedHistoryIcon,
successorRoom = roomInfo.successorRoom, 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.core.RoomId
import io.element.android.libraries.matrix.api.encryption.identity.IdentityState 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.room.tombstone.SuccessorRoom
import io.element.android.libraries.matrix.api.user.DisplayedStatus
import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableList
data class MessagesState( data class MessagesState(
@@ -53,6 +54,7 @@ data class MessagesState(
val appName: String, val appName: String,
val pinnedMessagesBannerState: PinnedMessagesBannerState, val pinnedMessagesBannerState: PinnedMessagesBannerState,
val dmUserVerificationState: IdentityState?, val dmUserVerificationState: IdentityState?,
val dmUserStatus: DisplayedStatus?,
val roomMemberModerationState: RoomMemberModerationState, val roomMemberModerationState: RoomMemberModerationState,
/** Type of "shared history" icon to show in the top bar. */ /** Type of "shared history" icon to show in the top bar. */
val topBarSharedHistoryIcon: SharedHistoryIcon, 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.encryption.identity.IdentityState
import io.element.android.libraries.matrix.api.room.tombstone.SuccessorRoom 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.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.MessageComposerMode
import io.element.android.libraries.textcomposer.model.aTextEditorStateMarkdown import io.element.android.libraries.textcomposer.model.aTextEditorStateMarkdown
import io.element.android.libraries.textcomposer.model.aTextEditorStateRich import io.element.android.libraries.textcomposer.model.aTextEditorStateRich
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.persistentSetOf import kotlinx.collections.immutable.persistentSetOf
@@ -129,6 +129,7 @@ fun aMessagesState(
hasUnreadThreads = false, hasUnreadThreads = false,
), ),
isCurrentlySharingLiveLocationInRoom: Boolean = false, isCurrentlySharingLiveLocationInRoom: Boolean = false,
dmUserStatus: DisplayedStatus? = null,
eventSink: (MessagesEvent) -> Unit = {}, eventSink: (MessagesEvent) -> Unit = {},
) = MessagesState( ) = MessagesState(
roomId = RoomId("!id:domain"), roomId = RoomId("!id:domain"),
@@ -159,6 +160,7 @@ fun aMessagesState(
successorRoom = successorRoom, successorRoom = successorRoom,
threads = threads, threads = threads,
showLiveLocationShareBanner = isCurrentlySharingLiveLocationInRoom, showLiveLocationShareBanner = isCurrentlySharingLiveLocationInRoom,
dmUserStatus = dmUserStatus,
eventSink = eventSink, eventSink = eventSink,
) )
@@ -193,11 +195,9 @@ fun aReactionSummaryState(
fun aCustomReactionState( fun aCustomReactionState(
target: CustomReactionState.Target = CustomReactionState.Target.None, target: CustomReactionState.Target = CustomReactionState.Target.None,
recentEmojis: ImmutableList<String> = persistentListOf(),
eventSink: (CustomReactionEvent) -> Unit = {}, eventSink: (CustomReactionEvent) -> Unit = {},
) = CustomReactionState( ) = CustomReactionState(
target = target, target = target,
recentEmojis = recentEmojis,
selectedEmoji = persistentSetOf(), selectedEmoji = persistentSetOf(),
eventSink = eventSink, eventSink = eventSink,
) )
@@ -20,14 +20,17 @@ import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.RowScope import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets 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.consumeWindowInsets
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.only
import androidx.compose.foundation.layout.padding 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.systemBarsPadding
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.material3.MaterialTheme 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.aTimelineItemEvent
import io.element.android.features.messages.impl.timeline.aTimelineState 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.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.customreaction.CustomReactionEvent
import io.element.android.features.messages.impl.timeline.components.reactionsummary.ReactionSummaryEvent import io.element.android.features.messages.impl.timeline.components.reactionsummary.ReactionSummaryEvent
import io.element.android.features.messages.impl.timeline.components.reactionsummary.ReactionSummaryView import io.element.android.features.messages.impl.timeline.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.HideKeyboardWhenDisposed
import io.element.android.libraries.designsystem.utils.KeepScreenOn import io.element.android.libraries.designsystem.utils.KeepScreenOn
import io.element.android.libraries.designsystem.utils.OnLifecycleEvent 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.SnackbarHost
import io.element.android.libraries.designsystem.utils.snackbar.rememberSnackbarHostState import io.element.android.libraries.designsystem.utils.snackbar.rememberSnackbarHostState
import io.element.android.libraries.matrix.api.core.EventId 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.Timeline
import io.element.android.libraries.matrix.api.timeline.item.event.LocalEventSendState 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.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.textcomposer.model.TextEditorState
import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.wysiwyg.link.Link import io.element.android.wysiwyg.link.Link
@@ -144,10 +149,13 @@ fun MessagesView(
onJoinCallClick: (isAudioCall: Boolean) -> Unit, onJoinCallClick: (isAudioCall: Boolean) -> Unit,
onViewAllPinnedMessagesClick: () -> Unit, onViewAllPinnedMessagesClick: () -> Unit,
onThreadsListClick: () -> Unit, onThreadsListClick: () -> Unit,
knockRequestsBannerView: @Composable () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
forceJumpToBottomVisibility: Boolean = false, forceJumpToBottomVisibility: Boolean = false,
knockRequestsBannerView: @Composable () -> Unit, customReactionBottomSheet: @Composable () -> Unit,
) { ) {
val eventContentValidationState = LocalEventContentValidationState.current
OnLifecycleEvent { _, event -> OnLifecycleEvent { _, event ->
state.voiceMessageComposerState.eventSink(VoiceMessageComposerEvent.LifecycleEvent(event)) state.voiceMessageComposerState.eventSink(VoiceMessageComposerEvent.LifecycleEvent(event))
} }
@@ -170,6 +178,9 @@ fun MessagesView(
fun onContentClick(event: TimelineItem.Event) { fun onContentClick(event: TimelineItem.Event) {
Timber.v("onMessageClick= ${event.id}") Timber.v("onMessageClick= ${event.id}")
val eventId = event.eventId ?: return
if (eventContentValidationState[eventId].getCurrentOverallState() != ContentValidationValue.Valid) return
val hideKeyboard = onEventContentClick(state.timelineState.isLive, event) val hideKeyboard = onEventContentClick(state.timelineState.isLive, event)
if (hideKeyboard) { if (hideKeyboard) {
localView.hideKeyboard() localView.hideKeyboard()
@@ -219,7 +230,7 @@ fun MessagesView(
}, },
content = { content = {
Scaffold( Scaffold(
contentWindowInsets = WindowInsets.statusBars, contentWindowInsets = scaffoldScrollableContentInsets,
topBar = { topBar = {
if (state.timelineState.timelineMode is Timeline.Mode.Thread) { if (state.timelineState.timelineMode is Timeline.Mode.Thread) {
ThreadTopBar( ThreadTopBar(
@@ -237,6 +248,7 @@ fun MessagesView(
heroes = state.heroes, heroes = state.heroes,
dmUserIdentityState = state.dmUserVerificationState, dmUserIdentityState = state.dmUserVerificationState,
sharedHistoryIcon = state.topBarSharedHistoryIcon, sharedHistoryIcon = state.topBarSharedHistoryIcon,
dmUserStatus = state.dmUserStatus,
onBackClick = { hidingKeyboard { onBackClick() } }, onBackClick = { hidingKeyboard { onBackClick() } },
onRoomDetailsClick = { hidingKeyboard { onRoomDetailsClick() } }, onRoomDetailsClick = { hidingKeyboard { onRoomDetailsClick() } },
menuActions = { menuActions = {
@@ -294,6 +306,7 @@ fun MessagesView(
onSwipeToReply = { targetEvent -> onSwipeToReply = { targetEvent ->
state.eventSink(MessagesEvent.HandleAction(TimelineItemAction.Reply, targetEvent)) state.eventSink(MessagesEvent.HandleAction(TimelineItemAction.Reply, targetEvent))
}, },
onJoinCallClick = onJoinCallClick,
forceJumpToBottomVisibility = forceJumpToBottomVisibility, forceJumpToBottomVisibility = forceJumpToBottomVisibility,
onViewAllPinnedMessagesClick = onViewAllPinnedMessagesClick, onViewAllPinnedMessagesClick = onViewAllPinnedMessagesClick,
knockRequestsBannerView = knockRequestsBannerView, knockRequestsBannerView = knockRequestsBannerView,
@@ -402,12 +415,7 @@ fun MessagesView(
}, },
) )
CustomReactionBottomSheet( customReactionBottomSheet()
state = state.customReactionState,
onSelectEmoji = { uniqueId, emoji ->
state.eventSink(MessagesEvent.ToggleReaction(emoji.unicode, uniqueId))
}
)
ReactionSummaryView(state = state.reactionSummaryState) ReactionSummaryView(state = state.reactionSummaryState)
ReadReceiptBottomSheet( ReadReceiptBottomSheet(
@@ -474,6 +482,7 @@ private fun MessagesViewContent(
onSendLocationClick: () -> Unit, onSendLocationClick: () -> Unit,
onCreatePollClick: () -> Unit, onCreatePollClick: () -> Unit,
onViewAllPinnedMessagesClick: () -> Unit, onViewAllPinnedMessagesClick: () -> Unit,
onJoinCallClick: (isAudioCall: Boolean) -> Unit,
forceJumpToBottomVisibility: Boolean, forceJumpToBottomVisibility: Boolean,
onSwipeToReply: (TimelineItem.Event) -> Unit, onSwipeToReply: (TimelineItem.Event) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
@@ -531,6 +540,7 @@ private fun MessagesViewContent(
onReactionLongClick = onReactionLongClick, onReactionLongClick = onReactionLongClick,
onMoreReactionsClick = onMoreReactionsClick, onMoreReactionsClick = onMoreReactionsClick,
onReadReceiptClick = onReadReceiptClick, onReadReceiptClick = onReadReceiptClick,
onJoinCallClick = onJoinCallClick,
forceJumpToBottomVisibility = forceJumpToBottomVisibility, forceJumpToBottomVisibility = forceJumpToBottomVisibility,
nestedScrollConnection = scrollBehavior.nestedScrollConnection, nestedScrollConnection = scrollBehavior.nestedScrollConnection,
floatingDateTopOffset = topBannersHeightDp, floatingDateTopOffset = topBannersHeightDp,
@@ -576,12 +586,17 @@ private fun MessagesViewComposerBottomSheetContents(
onRoomSuccessorClick: (RoomId) -> Unit, onRoomSuccessorClick: (RoomId) -> Unit,
onLinkClick: (String, Boolean) -> Unit, onLinkClick: (String, Boolean) -> Unit,
) { ) {
val contentPadding = WindowInsets.safeDrawing.only(WindowInsetsSides.Horizontal).asPaddingValues()
when { when {
state.successorRoom != null -> { state.successorRoom != null -> {
SuccessorRoomBanner(roomSuccessor = state.successorRoom, onRoomSuccessorClick = onRoomSuccessorClick) SuccessorRoomBanner(
modifier = Modifier.fillMaxWidth().padding(contentPadding),
roomSuccessor = state.successorRoom,
onRoomSuccessorClick = onRoomSuccessorClick
)
} }
state.userEventPermissions.canSendMessage -> { 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). // Do not show the identity change if user is composing a Rich message or is seeing suggestion(s).
if (state.composerState.suggestions.isEmpty() && if (state.composerState.suggestions.isEmpty() &&
state.composerState.textEditorState is TextEditorState.Markdown) { state.composerState.textEditorState is TextEditorState.Markdown) {
@@ -605,15 +620,15 @@ private fun MessagesViewComposerBottomSheetContents(
} }
} }
else -> { else -> {
CantSendMessageBanner() CantSendMessageBanner(Modifier.padding(contentPadding))
} }
} }
} }
@Composable @Composable
private fun CantSendMessageBanner() { private fun CantSendMessageBanner(modifier: Modifier = Modifier) {
Row( Row(
modifier = Modifier modifier = modifier
.fillMaxWidth() .fillMaxWidth()
.background(ElementTheme.colors.bgSubtleSecondary) .background(ElementTheme.colors.bgSubtleSecondary)
.padding(16.dp), .padding(16.dp),
@@ -662,6 +677,7 @@ internal fun MessagesViewPreview(@PreviewParameter(MessagesStateProvider::class)
onViewAllPinnedMessagesClick = { }, onViewAllPinnedMessagesClick = { },
forceJumpToBottomVisibility = true, forceJumpToBottomVisibility = true,
knockRequestsBannerView = {}, knockRequestsBannerView = {},
customReactionBottomSheet = {},
onThreadsListClick = {}, onThreadsListClick = {},
) )
} }
@@ -719,5 +735,6 @@ internal fun MessagesViewA11yPreview() = ElementPreview {
onThreadsListClick = {}, onThreadsListClick = {},
forceJumpToBottomVisibility = true, forceJumpToBottomVisibility = true,
knockRequestsBannerView = {}, 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.DateFormatter
import io.element.android.libraries.dateformatter.api.DateFormatterMode import io.element.android.libraries.dateformatter.api.DateFormatterMode
import io.element.android.libraries.di.RoomScope 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.FeatureFlagService
import io.element.android.libraries.featureflag.api.FeatureFlags import io.element.android.libraries.featureflag.api.FeatureFlags
import io.element.android.libraries.matrix.api.core.EventId 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.room.BaseRoom
import io.element.android.libraries.matrix.api.timeline.Timeline import io.element.android.libraries.matrix.api.timeline.Timeline
import io.element.android.libraries.preferences.api.store.AppPreferencesStore 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.ImmutableList
import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList import kotlinx.collections.immutable.toImmutableList
@@ -43,6 +43,7 @@ internal fun MessagesViewWithIdentityChangePreview(
onJoinCallClick = {}, onJoinCallClick = {},
onViewAllPinnedMessagesClick = {}, onViewAllPinnedMessagesClick = {},
knockRequestsBannerView = {}, knockRequestsBannerView = {},
customReactionBottomSheet = {},
onThreadsListClick = {}, onThreadsListClick = {},
) )
} }
@@ -31,6 +31,7 @@ import dev.zacsweers.metro.AssistedFactory
import dev.zacsweers.metro.AssistedInject import dev.zacsweers.metro.AssistedInject
import im.vector.app.features.analytics.plan.Composer import im.vector.app.features.analytics.plan.Composer
import im.vector.app.features.analytics.plan.Interaction 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.location.api.LocationService
import io.element.android.features.messages.impl.MessagesNavigator import io.element.android.features.messages.impl.MessagesNavigator
import io.element.android.features.messages.impl.attachments.Attachment 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.getDirectRoomMember
import io.element.android.libraries.matrix.api.room.powerlevels.use 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.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.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.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.matrix.ui.messages.reply.map
import io.element.android.libraries.mediapickers.api.PickerProvider import io.element.android.libraries.mediapickers.api.PickerProvider
import io.element.android.libraries.mediaupload.api.MediaOptimizationConfigProvider import io.element.android.libraries.mediaupload.api.MediaOptimizationConfigProvider
@@ -135,6 +140,8 @@ class MessageComposerPresenter(
private val notificationConversationService: NotificationConversationService, private val notificationConversationService: NotificationConversationService,
private val slashCommandService: SlashCommandService, private val slashCommandService: SlashCommandService,
private val featureFlagService: FeatureFlagService, private val featureFlagService: FeatureFlagService,
private val contentScannerService: ContentScannerService,
private val contentValidationCache: EventContentValidationCache,
) : Presenter<MessageComposerState> { ) : Presenter<MessageComposerState> {
@AssistedFactory @AssistedFactory
interface Factory { interface Factory {
@@ -848,6 +855,11 @@ class MessageComposerPresenter(
setText(newComposerMode.content, markdownTextEditorState, richTextEditorState, requestFocus = true) setText(newComposerMode.content, markdownTextEditorState, richTextEditorState, requestFocus = true)
} }
else -> { 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. // When coming from edit, just clear the composer as it'd be weird to reset a volatile draft in this scenario.
if (currentComposerMode.isEditing) { if (currentComposerMode.isEditing) {
setText("", markdownTextEditorState, richTextEditorState) 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.core.UserId
import io.element.android.libraries.matrix.api.room.RoomMember 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.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.matrix.ui.model.getAvatarData
import io.element.android.libraries.slashcommands.api.SlashCommandSuggestion import io.element.android.libraries.slashcommands.api.SlashCommandSuggestion
import io.element.android.libraries.textcomposer.mentions.ResolvedSuggestion import io.element.android.libraries.textcomposer.mentions.ResolvedSuggestion
@@ -112,12 +114,16 @@ private fun SuggestionItemView(
is ResolvedSuggestion.Member -> AvatarType.User is ResolvedSuggestion.Member -> AvatarType.User
is ResolvedSuggestion.Command -> null 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.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.Alias -> suggestion.roomName
is ResolvedSuggestion.Command -> suggestion.command.command is ResolvedSuggestion.Command -> suggestion.command.command
} }
val status = when (suggestion) {
is ResolvedSuggestion.Member -> suggestion.roomMember.displayedStatus
else -> null
}
val details = when (suggestion) { val details = when (suggestion) {
is ResolvedSuggestion.AtRoom, is ResolvedSuggestion.AtRoom,
is ResolvedSuggestion.Member, is ResolvedSuggestion.Member,
@@ -148,11 +154,11 @@ private fun SuggestionItemView(
horizontalArrangement = Arrangement.spacedBy(4.dp) horizontalArrangement = Arrangement.spacedBy(4.dp)
) { ) {
title?.let { title?.let {
Text( DisplayNameWithStatus(
text = it, name = title,
status = status,
style = ElementTheme.typography.fontBodyLgRegular, style = ElementTheme.typography.fontBodyLgRegular,
maxLines = 1, nameColor = ElementTheme.colors.textPrimary,
overflow = TextOverflow.Ellipsis,
) )
} }
details?.let { details?.let {
@@ -191,6 +197,7 @@ internal fun SuggestionsPickerViewPreview() {
role = RoomMember.Role.User, role = RoomMember.Role.User,
membershipChangeReason = null, membershipChangeReason = null,
isServiceMember = false, isServiceMember = false,
displayedStatus = null,
) )
val anAlias = remember { RoomAlias("#room:domain.org") } val anAlias = remember { RoomAlias("#room:domain.org") }
SuggestionsPickerView( SuggestionsPickerView(
@@ -16,10 +16,15 @@ import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer 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.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.only
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeDrawing
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
@@ -98,7 +103,8 @@ private fun PinnedMessagesBannerRow(
onClick(state.currentPinnedMessage.eventId) onClick(state.currentPinnedMessage.eventId)
state.eventSink(PinnedMessagesBannerEvent.MoveToNextPinned) state.eventSink(PinnedMessagesBannerEvent.MoveToNextPinned)
} }
}, }
.padding(WindowInsets.safeDrawing.only(WindowInsetsSides.Horizontal).asPaddingValues()),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
Spacer(modifier = Modifier.width(26.dp)) 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.consumeWindowInsets
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.plus
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState 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.components.layout.ContentAvoidingLayoutData
import io.element.android.features.messages.impl.timeline.model.TimelineItem 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.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.messages.impl.timeline.protection.TimelineProtectionState
import io.element.android.features.poll.api.pollcontent.PollTitleView import io.element.android.features.poll.api.pollcontent.PollTitleView
import io.element.android.libraries.designsystem.atomic.molecules.IconTitleSubtitleMolecule 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.CircularProgressIndicator
import io.element.android.libraries.designsystem.theme.components.Scaffold import io.element.android.libraries.designsystem.theme.components.Scaffold
import io.element.android.libraries.designsystem.theme.components.TopAppBar 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.timeline.Timeline
import io.element.android.libraries.matrix.api.user.MatrixUser import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.libraries.ui.strings.CommonStrings
@@ -91,7 +93,8 @@ fun PinnedMessagesListView(
.padding(padding) .padding(padding)
.consumeWindowInsets(padding), .consumeWindowInsets(padding),
) )
} },
contentWindowInsets = scaffoldScrollableContentInsets,
) )
} }
@@ -207,11 +210,12 @@ private fun PinnedMessagesListLoaded(
onEmojiReactionClick = { _, _ -> }, onEmojiReactionClick = { _, _ -> },
onVerifiedUserSendFailureClick = {} onVerifiedUserSendFailureClick = {}
) )
LazyColumn( LazyColumn(
modifier = modifier.fillMaxSize(), modifier = modifier.fillMaxSize(),
state = rememberLazyListState(), state = rememberLazyListState(),
reverseLayout = true, reverseLayout = true,
contentPadding = PaddingValues(vertical = 8.dp), contentPadding = lazyColumnContentPadding + PaddingValues(vertical = 8.dp),
) { ) {
items( items(
items = state.timelineItems, items = state.timelineItems,
@@ -240,6 +244,7 @@ private fun PinnedMessagesListLoaded(
onMoreReactionsClick = {}, onMoreReactionsClick = {},
onReadReceiptClick = {}, onReadReceiptClick = {},
onSwipeToReply = {}, onSwipeToReply = {},
onJoinCallClick = {},
eventSink = { timelineItemEvent -> eventSink = { timelineItemEvent ->
when (timelineItemEvent) { when (timelineItemEvent) {
is TimelineEvent.OpenThread -> state.eventSink(PinnedMessagesListEvent.OpenThread(timelineItemEvent.threadRootEventId)) is TimelineEvent.OpenThread -> state.eventSink(PinnedMessagesListEvent.OpenThread(timelineItemEvent.threadRootEventId))
@@ -290,9 +295,9 @@ private fun TimelineItemEventContentViewWrapper(
) )
} else { } else {
TimelineItemEventContentView( TimelineItemEventContentView(
eventId = event.eventId,
content = event.content, content = event.content,
hideMediaContent = timelineProtectionState.hideMediaContent(event.eventId, event.isMine), timelineProtectionState = timelineProtectionState,
onShowContentClick = { timelineProtectionState.eventSink(TimelineProtectionEvent.ShowContent(event.eventId)) },
onGalleryItemClick = onGalleryItemClick, onGalleryItemClick = onGalleryItemClick,
onLinkClick = onLinkClick, onLinkClick = onLinkClick,
onLinkLongClick = onLinkLongClick, onLinkLongClick = onLinkLongClick,
@@ -322,6 +322,7 @@ class ThreadedMessagesNode(
onViewAllPinnedMessagesClick = {}, onViewAllPinnedMessagesClick = {},
modifier = modifier, modifier = modifier,
knockRequestsBannerView = {}, knockRequestsBannerView = {},
customReactionBottomSheet = {},
onThreadsListClick = {}, onThreadsListClick = {},
) )
@@ -16,6 +16,7 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.plus
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn 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.Scaffold
import io.element.android.libraries.designsystem.theme.components.Text import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.theme.components.TopAppBar 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.RoomId
import io.element.android.libraries.matrix.api.core.ThreadId import io.element.android.libraries.matrix.api.core.ThreadId
import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.core.UserId
@@ -129,14 +132,15 @@ fun ThreadsListView(
}, },
navigationIcon = { navigationIcon = {
BackButton(onBackClick) BackButton(onBackClick)
} },
windowInsets = scaffoldScrollableContentInsets,
) )
} }
) { padding -> ) { padding ->
val lazyListState = rememberLazyListState() val lazyListState = rememberLazyListState()
LazyColumn( LazyColumn(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
contentPadding = padding, contentPadding = padding + lazyColumnContentPadding,
state = lazyListState, state = lazyListState,
) { ) {
itemsIndexed(state.threads, key = { _, row -> row.item.threadId }) { index, row -> itemsIndexed(state.threads, key = { _, row -> row.item.threadId }) { index, row ->
@@ -383,7 +387,12 @@ fun aThreadListItem(
fun aThreadListItemEvent( fun aThreadListItemEvent(
threadId: ThreadId = ThreadId("\$a-thread-id"), threadId: ThreadId = ThreadId("\$a-thread-id"),
senderId: UserId = UserId("@a-user-id:server"), 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, isOwn: Boolean = false,
content: EventContent = MessageContent( content: EventContent = MessageContent(
body = "Hello world!", 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.EventId
import io.element.android.libraries.matrix.api.core.RoomId 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.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.api.timeline.Timeline
import io.element.android.libraries.matrix.ui.media.contentvalidation.ContentValidationState
import kotlin.time.Duration import kotlin.time.Duration
sealed interface TimelineEvent { sealed interface TimelineEvent {
@@ -61,4 +63,10 @@ sealed interface TimelineEvent {
) : TimelineItemPollEvent ) : TimelineItemPollEvent
data object StopLiveLocationShare : TimelineItemEvent 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.TimelineItem
import io.element.android.features.messages.impl.timeline.model.virtual.TimelineItemReadMarkerModel 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.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.typing.TypingNotificationState
import io.element.android.features.messages.impl.userEventPermissions import io.element.android.features.messages.impl.userEventPermissions
import io.element.android.features.messages.impl.voicemessages.timeline.RedactedVoiceMessageManager 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 io.element.android.services.analyticsproviders.api.AnalyticsUserData
import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
@@ -98,6 +101,7 @@ class TimelinePresenter(
private val analyticsService: AnalyticsService, private val analyticsService: AnalyticsService,
private val liveLocationShareManager: ActiveLiveLocationShareManager, private val liveLocationShareManager: ActiveLiveLocationShareManager,
private val markAsFullyRead: MarkAsFullyRead, private val markAsFullyRead: MarkAsFullyRead,
private val timelineProtectionPresenter: Presenter<TimelineProtectionState>,
) : Presenter<TimelineState> { ) : Presenter<TimelineState> {
private val tag = "TimelinePresenter" private val tag = "TimelinePresenter"
@@ -157,6 +161,8 @@ class TimelinePresenter(
value = featureFlagService.isFeatureEnabled(FeatureFlags.JumpToUnread) value = featureFlagService.isFeatureEnabled(FeatureFlags.JumpToUnread)
} }
val timelineProtectionState = timelineProtectionPresenter.present()
fun handleEvent(event: TimelineEvent) { fun handleEvent(event: TimelineEvent) {
when (event) { when (event) {
is TimelineEvent.LoadMore -> { is TimelineEvent.LoadMore -> {
@@ -252,6 +258,9 @@ class TimelinePresenter(
focusedEventId = event.focusedEvent, focusedEventId = event.focusedEvent,
) )
} }
is TimelineEvent.ValidateMedia -> {
timelineProtectionState.eventSink(TimelineProtectionEvent.ValidateContent(event.mediaSources, event.validationState))
}
} }
} }
@@ -259,7 +268,7 @@ class TimelinePresenter(
timelineItemsFactory.timelineItems timelineItemsFactory.timelineItems
.onEach { newTimelineItems -> .onEach { newTimelineItems ->
timelineItemIndexer.process(newTimelineItems) timelineItemIndexer.process(newTimelineItems)
timelineItems = newTimelineItems timelineItems = newTimelineItems.toImmutableList()
analyticsService.run { analyticsService.run {
finishLongRunningTransaction(DisplayFirstTimelineItems) finishLongRunningTransaction(DisplayFirstTimelineItems)
@@ -27,9 +27,15 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer 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.fillMaxSize
import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.only
import androidx.compose.foundation.layout.padding 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.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
@@ -130,6 +136,7 @@ fun TimelineView(
onReactionLongClick: (emoji: String, TimelineItem.Event) -> Unit, onReactionLongClick: (emoji: String, TimelineItem.Event) -> Unit,
onMoreReactionsClick: (TimelineItem.Event) -> Unit, onMoreReactionsClick: (TimelineItem.Event) -> Unit,
onReadReceiptClick: (TimelineItem.Event) -> Unit, onReadReceiptClick: (TimelineItem.Event) -> Unit,
onJoinCallClick: (isAudioCall: Boolean) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
lazyListState: LazyListState = rememberLazyListState(), lazyListState: LazyListState = rememberLazyListState(),
forceJumpToBottomVisibility: Boolean = false, forceJumpToBottomVisibility: Boolean = false,
@@ -192,7 +199,7 @@ fun TimelineView(
.testTag(TestTags.timeline), .testTag(TestTags.timeline),
state = lazyListState, state = lazyListState,
reverseLayout = true, 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(
items = state.timelineItems, items = state.timelineItems,
@@ -219,6 +226,7 @@ fun TimelineView(
onMoreReactionsClick = onMoreReactionsClick, onMoreReactionsClick = onMoreReactionsClick,
onReadReceiptClick = onReadReceiptClick, onReadReceiptClick = onReadReceiptClick,
onSwipeToReply = onSwipeToReply, onSwipeToReply = onSwipeToReply,
onJoinCallClick = onJoinCallClick,
eventSink = state.eventSink, eventSink = state.eventSink,
) )
} }
@@ -599,6 +607,7 @@ internal fun TimelineViewPreview(
onSwipeToReply = {}, onSwipeToReply = {},
onReactionClick = { _, _ -> }, onReactionClick = { _, _ -> },
onReactionLongClick = { _, _ -> }, onReactionLongClick = { _, _ -> },
onJoinCallClick = {},
onMoreReactionsClick = {}, onMoreReactionsClick = {},
onReadReceiptClick = {}, onReadReceiptClick = {},
onGalleryItemClick = { _, _ -> }, onGalleryItemClick = { _, _ -> },
@@ -649,6 +658,7 @@ private fun TimelineViewWithReadMarker(
onReadReceiptClick = {}, onReadReceiptClick = {},
forceJumpToBottomVisibility = true, forceJumpToBottomVisibility = true,
forceJumpToReadMarkerVisibility = true, forceJumpToReadMarkerVisibility = true,
onJoinCallClick = { },
) )
} }
} }
@@ -49,6 +49,7 @@ internal fun TimelineViewMessageShieldPreview() = ElementPreview {
onReactionLongClick = { _, _ -> }, onReactionLongClick = { _, _ -> },
onMoreReactionsClick = {}, onMoreReactionsClick = {},
onReadReceiptClick = {}, onReadReceiptClick = {},
onJoinCallClick = {},
onGalleryItemClick = { _, _ -> }, onGalleryItemClick = { _, _ -> },
forceJumpToBottomVisibility = true, 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 { enum class ContentPadding {
Textual, Textual,
Media, Media,
CaptionedMedia CaptionedMedia,
InvalidContent,
} }
@@ -19,7 +19,9 @@ import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.ripple import androidx.compose.material3.ripple
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawWithCache 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.BlendMode
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Shape 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.graphics.layer.CompositingStrategy
import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.LayoutDirection
@@ -62,6 +66,8 @@ fun MessageEventBubble(
onClick: () -> Unit, onClick: () -> Unit,
onLongClick: () -> Unit, onLongClick: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
customBackgroundColor: Color? = null,
borderColor: Color? = null,
content: @Composable BoxScope.() -> Unit = {}, content: @Composable BoxScope.() -> Unit = {},
) { ) {
val clickableModifier = if (isTalkbackActive()) { val clickableModifier = if (isTalkbackActive()) {
@@ -79,10 +85,12 @@ fun MessageEventBubble(
val cutTopStart = state.cutTopStart val cutTopStart = state.cutTopStart
// Ignore state.isHighlighted for now, we need a design decision on it. // 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 bubbleShape = remember(state) { MessageEventBubbleDefaults.shape(state.cutTopStart, state.groupPosition, state.isMine) }
val radiusPx = (avatarRadius + SENDER_AVATAR_BORDER_WIDTH).toPx() val radiusPx = (avatarRadius + SENDER_AVATAR_BORDER_WIDTH).toPx()
val yOffsetPx = -(NEGATIVE_MARGIN_FOR_BUBBLE + avatarRadius).toPx() val yOffsetPx = -(NEGATIVE_MARGIN_FOR_BUBBLE + avatarRadius).toPx()
val updatedBorderColor by rememberUpdatedState(borderColor)
BoxWithConstraints( BoxWithConstraints(
modifier = modifier modifier = modifier
.drawWithCache { .drawWithCache {
@@ -103,6 +111,9 @@ fun MessageEventBubble(
// Then draw the content on top of it // Then draw the content on top of it
drawContent() 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 // And then clip the top start corner if needed to make room for the avatar
if (cutTopStart) { if (cutTopStart) {
drawCircle( drawCircle(
@@ -166,6 +166,7 @@ internal fun MessageShieldViewPreview() {
displayName = USER_NAME_ALICE, displayName = USER_NAME_ALICE,
displayNameAmbiguous = false, displayNameAmbiguous = false,
avatarUrl = null, avatarUrl = null,
displayedStatus = null,
), ),
) )
) )
@@ -52,6 +52,7 @@ internal fun TimelineItemCallNotifyView(
timelineRoomInfo: TimelineRoomInfo, timelineRoomInfo: TimelineRoomInfo,
event: TimelineItem.Event, event: TimelineItem.Event,
content: TimelineItemRtcNotificationContent, content: TimelineItemRtcNotificationContent,
state: RtcNotificationState.Tombstoned,
isLastOutgoingMessage: Boolean, isLastOutgoingMessage: Boolean,
onLongClick: (TimelineItem.Event) -> Unit, onLongClick: (TimelineItem.Event) -> Unit,
onReadReceiptsClick: (TimelineItem.Event) -> Unit, onReadReceiptsClick: (TimelineItem.Event) -> Unit,
@@ -83,7 +84,7 @@ internal fun TimelineItemCallNotifyView(
Text( Text(
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
text = stringResource(getTextRes(timelineRoomInfo, content)), text = stringResource(getTextRes(timelineRoomInfo, state)),
style = ElementTheme.typography.fontBodyMdRegular, style = ElementTheme.typography.fontBodyMdRegular,
color = ElementTheme.colors.textSecondary, color = ElementTheme.colors.textSecondary,
maxLines = 1, maxLines = 1,
@@ -114,13 +115,13 @@ internal fun TimelineItemCallNotifyView(
@StringRes @StringRes
private fun getTextRes( private fun getTextRes(
timelineRoomInfo: TimelineRoomInfo, timelineRoomInfo: TimelineRoomInfo,
content: TimelineItemRtcNotificationContent state: RtcNotificationState.Tombstoned
): Int = if (timelineRoomInfo.isDm) { ): Int = if (timelineRoomInfo.isDm) {
when (content.state) { when (state) {
is RtcNotificationState.Declined -> { 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 { } else {
// In Rooms, do not show declined info. // In Rooms, do not show declined info.
@@ -166,6 +167,7 @@ internal fun TimelineItemCallNotifyViewPreview() = ElementPreview {
readReceiptState = readReceiptState.removeFirstOrNull() ?: aTimelineItemReadReceipts(), readReceiptState = readReceiptState.removeFirstOrNull() ?: aTimelineItemReadReceipts(),
), ),
content = content, content = content,
state,
isLastOutgoingMessage = false, isLastOutgoingMessage = false,
onLongClick = {}, onLongClick = {},
onReadReceiptsClick = {}, 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.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.movableContentOf import androidx.compose.runtime.movableContentOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip 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.aTimelineItemImageContent
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemTextContent 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.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.TimelineProtectionState
import io.element.android.features.messages.impl.timeline.protection.mustBeProtected import io.element.android.features.messages.impl.timeline.protection.mustBeProtected
import io.element.android.libraries.architecture.AsyncData 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.getAvatarUrl
import io.element.android.libraries.matrix.api.timeline.item.event.getDisambiguatedDisplayName 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.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.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.InReplyToDetails
import io.element.android.libraries.matrix.ui.messages.reply.InReplyToView 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.reply.eventId
import io.element.android.libraries.matrix.ui.messages.sender.SenderName import io.element.android.libraries.matrix.ui.messages.sender.SenderName
import io.element.android.libraries.matrix.ui.messages.sender.SenderNameMode import io.element.android.libraries.matrix.ui.messages.sender.SenderNameMode
@@ -176,12 +182,12 @@ fun TimelineItemEventRow(
val onContentClick = onEventClick.takeUnless { event.isWholeContentClickable } val onContentClick = onEventClick.takeUnless { event.isWholeContentClickable }
TimelineItemEventContentView( TimelineItemEventContentView(
eventId = event.eventId,
content = event.content, content = event.content,
hideMediaContent = timelineProtectionState.hideMediaContent(event.eventId), timelineProtectionState = timelineProtectionState,
onContentClick = onContentClick, onContentClick = onContentClick,
onGalleryItemClick = onGalleryItemClick, onGalleryItemClick = onGalleryItemClick,
onLongClick = onLongClick, onLongClick = onLongClick,
onShowContentClick = { timelineProtectionState.eventSink(TimelineProtectionEvent.ShowContent(event.eventId)) },
onLinkClick = onLinkClick, onLinkClick = onLinkClick,
onLinkLongClick = onLinkLongClick, onLinkLongClick = onLinkLongClick,
eventSink = eventSink, 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 // Message bubble
val bubbleState = BubbleState( val bubbleState = BubbleState(
groupPosition = event.groupPosition, groupPosition = event.groupPosition,
@@ -494,6 +517,8 @@ private fun TimelineItemEventRowContent(
interactionSource = interactionSource, interactionSource = interactionSource,
onClick = onContentClick, onClick = onContentClick,
onLongClick = onLongClick, onLongClick = onLongClick,
customBackgroundColor = dangerousContentBubbleColor,
borderColor = borderColor,
) { ) {
MessageEventBubbleContent( MessageEventBubbleContent(
event = event, event = event,
@@ -747,6 +772,7 @@ private fun MessageEventBubbleContent(
} }
ContentPadding.CaptionedMedia -> ContentPadding.CaptionedMedia ->
Modifier.padding(start = 8.dp, end = 8.dp, top = topPadding, bottom = 8.dp) 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 { val threadDecoration = @Composable {
@@ -770,10 +796,13 @@ private fun MessageEventBubbleContent(
} }
val inReplyTo = @Composable { inReplyTo: InReplyToDetails -> 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 topPadding = if (showThreadDecoration) 0.dp else 8.dp
val shape = RoundedCornerShape(6.dp)
val inReplyToModifier = Modifier val inReplyToModifier = Modifier
.padding(top = topPadding, start = 8.dp, end = 8.dp) .padding(top = topPadding, start = 8.dp, end = 8.dp)
.clip(RoundedCornerShape(6.dp)) .clip(shape)
val talkbackCompatModifier = if (isTalkbackActive()) { val talkbackCompatModifier = if (isTalkbackActive()) {
// Use z-index to make the replied to text being read after the message // Use z-index to make the replied to text being read after the message
@@ -782,14 +811,25 @@ private fun MessageEventBubbleContent(
} else { } else {
inReplyToModifier.clickable(onClick = inReplyToClick) 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( Box(
modifier = talkbackCompatModifier modifier = talkbackCompatModifier
.border(1.dp, ElementTheme.colors.separatorPrimary, RoundedCornerShape(6.dp)) .border(1.dp, borderColor, shape)
.background(ElementTheme.colors.bgCanvasDefault, RoundedCornerShape(6.dp)) .background(backgroundColor, shape)
.padding(4.dp) .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( InReplyToView(
inReplyTo = inReplyTo, inReplyTo = inReplyTo,
contentValidationValue = currentContentValidationState,
hideImage = timelineProtectionState.hideMediaContent(inReplyTo.eventId()), hideImage = timelineProtectionState.hideMediaContent(inReplyTo.eventId()),
) )
} }
@@ -809,30 +849,47 @@ private fun MessageEventBubbleContent(
} }
} }
val timestampPosition = when (val content = event.content) { val contentValidationState by rememberEventContentValidationState(eventId = event.eventId, needsValidation = event.content.isMedia).collectOverallState()
is TimelineItemImageContent -> if (content.showCaption) TimestampPosition.Aligned else TimestampPosition.Overlay val needsInvalidContentLayout =
is TimelineItemVideoContent -> if (content.showCaption) TimestampPosition.Aligned else TimestampPosition.Overlay // Gallery events should not apply custom paddings or layout dispositions
is TimelineItemGalleryContent -> if (content.showCaption) TimestampPosition.Aligned else TimestampPosition.Below event.content !is TimelineItemGalleryContent &&
is TimelineItemAttachmentsContent -> TimestampPosition.Below event.content !is TimelineItemAttachmentsContent &&
is TimelineItemStickerContent -> TimestampPosition.Overlay contentValidationState.hasError()
is TimelineItemLocationContent -> {
val content = content.ensureActiveLiveLocation() val timestampPosition = if (needsInvalidContentLayout) {
val shouldHide = content.mode is TimelineItemLocationContent.Mode.Live && // The invalid content view will be displayed in all these cases, independent of the event content
content.mode.isActive && TimestampPosition.Aligned
content.mode.isOwnUser } else {
if (shouldHide) TimestampPosition.Hidden else TimestampPosition.Overlay 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 val paddingBehaviour = if (needsInvalidContentLayout) {
is TimelineItemVideoContent -> if (event.content.showCaption) ContentPadding.CaptionedMedia else ContentPadding.Media ContentPadding.InvalidContent
is TimelineItemGalleryContent -> ContentPadding.CaptionedMedia } else {
is TimelineItemAttachmentsContent -> ContentPadding.CaptionedMedia when (event.content) {
is TimelineItemStickerContent, is TimelineItemImageContent -> if (event.content.showCaption) ContentPadding.CaptionedMedia else ContentPadding.Media
is TimelineItemLocationContent -> ContentPadding.Media is TimelineItemVideoContent -> if (event.content.showCaption) ContentPadding.CaptionedMedia else ContentPadding.Media
else -> ContentPadding.Textual is TimelineItemGalleryContent -> ContentPadding.CaptionedMedia
is TimelineItemAttachmentsContent -> ContentPadding.CaptionedMedia
is TimelineItemStickerContent,
is TimelineItemLocationContent -> ContentPadding.Media
else -> ContentPadding.Textual
}
} }
CommonLayout( CommonLayout(
showThreadDecoration = timelineMode !is Timeline.Mode.Thread && event.threadInfo is TimelineItemThreadInfo.ThreadResponse, showThreadDecoration = timelineMode !is Timeline.Mode.Thread && event.threadInfo is TimelineItemThreadInfo.ThreadResponse,
@@ -905,6 +962,7 @@ internal fun TimelineItemEventRowWithThreadSummaryPreview() = ElementPreview {
displayName = USER_NAME_ALICE, displayName = USER_NAME_ALICE,
avatarUrl = null, avatarUrl = null,
displayNameAmbiguous = false, displayNameAmbiguous = false,
displayedStatus = null,
), ),
timestamp = 0L, timestamp = 0L,
) )
@@ -1083,6 +1141,7 @@ internal fun ThreadSummaryViewPreview() {
displayName = USER_NAME_ALICE, displayName = USER_NAME_ALICE,
avatarUrl = null, avatarUrl = null,
displayNameAmbiguous = true, displayNameAmbiguous = true,
displayedStatus = null,
), ),
timestamp = 0L, 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.components.receipt.TimelineItemReadReceiptView
import io.element.android.features.messages.impl.timeline.groups.isRedactedMessagesGroup 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.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.TimelineProtectionState
import io.element.android.features.messages.impl.timeline.protection.aTimelineProtectionState import io.element.android.features.messages.impl.timeline.protection.aTimelineProtectionState
import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.ElementPreview
@@ -62,9 +61,9 @@ fun TimelineItemGroupedEventsRow(
eventContentView: @Composable (TimelineItem.Event, Modifier, (ContentAvoidingLayoutData) -> Unit) -> Unit = eventContentView: @Composable (TimelineItem.Event, Modifier, (ContentAvoidingLayoutData) -> Unit) -> Unit =
{ event, contentModifier, onContentLayoutChange -> { event, contentModifier, onContentLayoutChange ->
TimelineItemEventContentView( TimelineItemEventContentView(
eventId = event.eventId,
content = event.content, content = event.content,
hideMediaContent = timelineProtectionState.hideMediaContent(event.eventId, event.isMine), timelineProtectionState = timelineProtectionState,
onShowContentClick = { timelineProtectionState.eventSink(TimelineProtectionEvent.ShowContent(event.eventId)) },
onLinkClick = onLinkClick, onLinkClick = onLinkClick,
onLinkLongClick = onLinkLongClick, onLinkLongClick = onLinkLongClick,
eventSink = eventSink, eventSink = eventSink,
@@ -134,9 +133,9 @@ private fun TimelineItemGroupedEventsRowContent(
eventContentView: @Composable (TimelineItem.Event, Modifier, (ContentAvoidingLayoutData) -> Unit) -> Unit = eventContentView: @Composable (TimelineItem.Event, Modifier, (ContentAvoidingLayoutData) -> Unit) -> Unit =
{ event, contentModifier, onContentLayoutChange -> { event, contentModifier, onContentLayoutChange ->
TimelineItemEventContentView( TimelineItemEventContentView(
eventId = event.eventId,
content = event.content, content = event.content,
hideMediaContent = timelineProtectionState.hideMediaContent(event.eventId, event.isMine), timelineProtectionState = timelineProtectionState,
onShowContentClick = { timelineProtectionState.eventSink(TimelineProtectionEvent.ShowContent(event.eventId)) },
onLinkClick = onLinkClick, onLinkClick = onLinkClick,
onLinkLongClick = onLinkLongClick, onLinkLongClick = onLinkLongClick,
eventSink = eventSink, eventSink = eventSink,
@@ -187,6 +186,7 @@ private fun TimelineItemGroupedEventsRowContent(
onReactionLongClick = onReactionLongClick, onReactionLongClick = onReactionLongClick,
onMoreReactionsClick = onMoreReactionsClick, onMoreReactionsClick = onMoreReactionsClick,
onReadReceiptClick = onReadReceiptClick, onReadReceiptClick = onReadReceiptClick,
onJoinCallClick = {},
onSwipeToReply = {}, onSwipeToReply = {},
eventSink = eventSink, eventSink = eventSink,
eventContentView = eventContentView, eventContentView = eventContentView,

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