Merge branch 'develop' into feature/fga/set_user_status

This commit is contained in:
ganfra
2026-07-08 17:59:30 +02:00
834 changed files with 11059 additions and 3245 deletions
+2 -2
View File
@@ -44,14 +44,14 @@ jobs:
docker-images: true docker-images: true
swap-storage: false swap-storage: false
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
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@ad2b38190b15e4d6bdf0c97fb4fca8412226d287 # v5.3.0 uses: actions/setup-java@1bcf9fb12cf4aa7d266a90ae39939e61372fe520 # v5.4.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@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
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@ad2b38190b15e4d6bdf0c97fb4fca8412226d287 # v5.3.0 uses: actions/setup-java@1bcf9fb12cf4aa7d266a90ae39939e61372fe520 # v5.4.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
@@ -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@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with: with:
persist-credentials: false persist-credentials: false
- name: Add SSH private keys for submodule repositories - name: Add SSH private keys for submodule repositories
@@ -24,7 +24,7 @@ jobs:
- run: | - run: |
npm install --save-dev @babel/plugin-transform-flow-strip-types npm install --save-dev @babel/plugin-transform-flow-strip-types
- name: Danger - name: Danger
uses: danger/danger-js@67ed2c1f42fd2fc198cc3c14b43c8f83351f4fe9 # 13.0.5 uses: danger/danger-js@e9b58820039524318198d8059ec98b0c02b7875c # 13.0.10
with: with:
args: "--dangerfile ./tools/danger/dangerfile.js" args: "--dangerfile ./tools/danger/dangerfile.js"
env: env:
+5 -4
View File
@@ -1,9 +1,10 @@
name: Generate GitHub Pages name: Generate GitHub Pages
on: on:
workflow_dispatch: workflow_dispatch:
schedule: # Disabled for now, as we don't want to generate GitHub Pages on every push to main anymore
# At 00:00 on every Tuesday UTC # schedule:
- cron: '0 0 * * 2' # # At 00:00 on every Tuesday UTC
# - cron: '0 0 * * 2'
permissions: {} permissions: {}
@@ -18,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@ad2b38190b15e4d6bdf0c97fb4fca8412226d287 # v5.3.0 uses: actions/setup-java@1bcf9fb12cf4aa7d266a90ae39939e61372fe520 # v5.4.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
@@ -13,10 +13,10 @@ 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@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with: with:
persist-credentials: false persist-credentials: false
- uses: actions/setup-java@ad2b38190b15e4d6bdf0c97fb4fca8412226d287 # v5.3.0 - uses: actions/setup-java@1bcf9fb12cf4aa7d266a90ae39939e61372fe520 # v5.4.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' if: (github.event_name == 'pull_request' && github.event.pull_request.fork == null) || github.event_name == 'workflow_dispatch'
with: with:
+3 -3
View File
@@ -38,13 +38,13 @@ jobs:
docker-images: true docker-images: true
swap-storage: false swap-storage: false
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
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@ad2b38190b15e4d6bdf0c97fb4fca8412226d287 # v5.3.0 - uses: actions/setup-java@1bcf9fb12cf4aa7d266a90ae39939e61372fe520 # v5.4.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@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
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@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with: with:
persist-credentials: false persist-credentials: false
- name: Use JDK 21 - name: Use JDK 21
uses: actions/setup-java@ad2b38190b15e4d6bdf0c97fb4fca8412226d287 # v5.3.0 uses: actions/setup-java@1bcf9fb12cf4aa7d266a90ae39939e61372fe520 # v5.4.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@ad2b38190b15e4d6bdf0c97fb4fca8412226d287 # v5.3.0 uses: actions/setup-java@1bcf9fb12cf4aa7d266a90ae39939e61372fe520 # v5.4.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@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with: with:
persist-credentials: false persist-credentials: false
- name: Use JDK 21 - name: Use JDK 21
uses: actions/setup-java@ad2b38190b15e4d6bdf0c97fb4fca8412226d287 # v5.3.0 uses: actions/setup-java@1bcf9fb12cf4aa7d266a90ae39939e61372fe520 # v5.4.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'
+20 -20
View File
@@ -33,7 +33,7 @@ jobs:
docker-images: true docker-images: true
swap-storage: false swap-storage: false
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
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,7 +51,7 @@ 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@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with: with:
persist-credentials: false persist-credentials: false
- name: Set up Python 3.12 - name: Set up Python 3.12
@@ -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@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with: with:
persist-credentials: false persist-credentials: false
- name: Use JDK 21 - name: Use JDK 21
uses: actions/setup-java@ad2b38190b15e4d6bdf0c97fb4fca8412226d287 # v5.3.0 uses: actions/setup-java@1bcf9fb12cf4aa7d266a90ae39939e61372fe520 # v5.4.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'
@@ -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@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
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@ad2b38190b15e4d6bdf0c97fb4fca8412226d287 # v5.3.0 uses: actions/setup-java@1bcf9fb12cf4aa7d266a90ae39939e61372fe520 # v5.4.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@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
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@ad2b38190b15e4d6bdf0c97fb4fca8412226d287 # v5.3.0 uses: actions/setup-java@1bcf9fb12cf4aa7d266a90ae39939e61372fe520 # v5.4.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@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
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@ad2b38190b15e4d6bdf0c97fb4fca8412226d287 # v5.3.0 uses: actions/setup-java@1bcf9fb12cf4aa7d266a90ae39939e61372fe520 # v5.4.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@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
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@ad2b38190b15e4d6bdf0c97fb4fca8412226d287 # v5.3.0 uses: actions/setup-java@1bcf9fb12cf4aa7d266a90ae39939e61372fe520 # v5.4.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@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
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@ad2b38190b15e4d6bdf0c97fb4fca8412226d287 # v5.3.0 uses: actions/setup-java@1bcf9fb12cf4aa7d266a90ae39939e61372fe520 # v5.4.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@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
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@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
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@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with: with:
persist-credentials: false persist-credentials: false
- uses: zizmorcore/zizmor-action@5f14fd08f7cf1cb1609c1e344975f152c7ee938d # v0.5.6 - uses: zizmorcore/zizmor-action@192e21d79ab29983730a13d1382995c2307fbcaa # v0.5.7
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@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
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
@@ -360,7 +360,7 @@ jobs:
yarn add danger-plugin-lint-report --dev yarn add danger-plugin-lint-report --dev
- name: Danger lint - name: Danger lint
if: always() if: always()
uses: danger/danger-js@67ed2c1f42fd2fc198cc3c14b43c8f83351f4fe9 # 13.0.5 uses: danger/danger-js@e9b58820039524318198d8059ec98b0c02b7875c # 13.0.10
with: with:
args: "--dangerfile ./tools/danger/dangerfile-lint.js" args: "--dangerfile ./tools/danger/dangerfile-lint.js"
env: env:
+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@ad2b38190b15e4d6bdf0c97fb4fca8412226d287 # v5.3.0 uses: actions/setup-java@1bcf9fb12cf4aa7d266a90ae39939e61372fe520 # v5.4.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@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with: with:
persist-credentials: false persist-credentials: false
- name: Use JDK 21 - name: Use JDK 21
uses: actions/setup-java@ad2b38190b15e4d6bdf0c97fb4fca8412226d287 # v5.3.0 uses: actions/setup-java@1bcf9fb12cf4aa7d266a90ae39939e61372fe520 # v5.4.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@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
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@ad2b38190b15e4d6bdf0c97fb4fca8412226d287 # v5.3.0 uses: actions/setup-java@1bcf9fb12cf4aa7d266a90ae39939e61372fe520 # v5.4.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@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with: with:
persist-credentials: false persist-credentials: false
- name: Use JDK 21 - name: Use JDK 21
uses: actions/setup-java@ad2b38190b15e4d6bdf0c97fb4fca8412226d287 # v5.3.0 uses: actions/setup-java@1bcf9fb12cf4aa7d266a90ae39939e61372fe520 # v5.4.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@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
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@ad2b38190b15e4d6bdf0c97fb4fca8412226d287 # v5.3.0 uses: actions/setup-java@1bcf9fb12cf4aa7d266a90ae39939e61372fe520 # v5.4.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
@@ -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@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with: with:
persist-credentials: false persist-credentials: false
- name: Use JDK 21 - name: Use JDK 21
uses: actions/setup-java@ad2b38190b15e4d6bdf0c97fb4fca8412226d287 # v5.3.0 uses: actions/setup-java@1bcf9fb12cf4aa7d266a90ae39939e61372fe520 # v5.4.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
@@ -14,7 +14,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' }}
# No concurrency required, runs every time on a schedule. # No concurrency required, runs every time on a schedule.
steps: steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with: with:
persist-credentials: false persist-credentials: false
- name: Set up Python 3.12 - name: Set up Python 3.12
+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@ad2b38190b15e4d6bdf0c97fb4fca8412226d287 # v5.3.0 uses: actions/setup-java@1bcf9fb12cf4aa7d266a90ae39939e61372fe520 # v5.4.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'
+49
View File
@@ -1,3 +1,52 @@
Changes in Element X v26.07.0
=============================
<!-- Release notes generated using configuration in .github/release.yml at v26.07.0 -->
## What's Changed
### ✨ Features
* Add read all messages feature by @hghgrtut in https://github.com/element-hq/element-x-android/pull/7049
### 🐛 Bugfixes
* Detect RTL text in events and adjust the text rendering by @jmartinesp in https://github.com/element-hq/element-x-android/pull/6994
### 🗣 Translations
* Sync Strings by @ElementBot in https://github.com/element-hq/element-x-android/pull/7071
* Sync Strings by @ElementBot in https://github.com/element-hq/element-x-android/pull/7115
### 🧱 Build
* Fix Sonar with AGP 9 (some workarounds) by @jmartinesp in https://github.com/element-hq/element-x-android/pull/7055
* Disable periodically generating the screenshot gallery by @jmartinesp in https://github.com/element-hq/element-x-android/pull/7110
* Try a different workaround for flaky `ScanQrCodeViewTest` by @jmartinesp in https://github.com/element-hq/element-x-android/pull/7109
### 🚧 In development 🚧
* Protect link new device by @bmarty in https://github.com/element-hq/element-x-android/pull/6992
### Dependency upgrades
* Update metro to v1.2.1 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7057
* Update gradle/actions action to v6.2.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7063
* Update roborazzi to v1.64.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7068
* Update rnkdsh/action-upload-diawi action to v1.5.13 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7067
* Update dependency io.element.android:element-call-embedded to v0.20.2 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7075
* Update dependency com.github.matrix-org:matrix-analytics-events to v0.37.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7079
* Update actions/setup-java action to v5.3.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7088
* Update dependency org.matrix.rustcomponents:sdk-android to v26.06.18 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7052
* Update firebaseAppDistribution to v5.3.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7096
* Update dependency com.google.firebase:firebase-bom to v34.15.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7089
* Update dependency com.google.crypto.tink:tink-android to v1.22.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7105
* Update actions/checkout action to v7 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7098
* Update lifecycle to v2.11.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7097
* Update danger/danger-js action to v13.0.8 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7104
* Update dependency org.matrix.rustcomponents:sdk-android to v26.06.25 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7107
* Update dependency androidx.compose:compose-bom to v2026.06.00 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7095
* Update dependency io.element.android:element-call-embedded to v0.20.3 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7117
* Update zizmorcore/zizmor-action action to v0.5.7 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7116
* Update dependency org.matrix.rustcomponents:sdk-android to v26.06.30 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7127
* Update dependency com.posthog:posthog-android to v3.51.1 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7128
### Others
* Fix small code smell by @bmarty in https://github.com/element-hq/element-x-android/pull/7058
* Hide message notification content when the app is locked by @bxdxnn in https://github.com/element-hq/element-x-android/pull/6902
## New Contributors
* @hghgrtut made their first contribution in https://github.com/element-hq/element-x-android/pull/7049
**Full Changelog**: https://github.com/element-hq/element-x-android/compare/v26.06.4...v26.07.0
Changes in Element X v26.06.4 Changes in Element X v26.06.4
============================= =============================
@@ -0,0 +1,2 @@
Main changes in this version: bug fixes and improvements.
Full changelog: https://github.com/element-hq/element-x-android/releases
@@ -31,12 +31,12 @@ import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.features.analytics.api.AnalyticsOptInEvents import io.element.android.features.analytics.api.AnalyticsOptInEvents
import io.element.android.libraries.designsystem.atomic.molecules.ButtonColumnMolecule import io.element.android.libraries.designsystem.atomic.molecules.ButtonColumnMolecule
import io.element.android.libraries.designsystem.atomic.molecules.IconTitleSubtitleMolecule import io.element.android.libraries.designsystem.atomic.molecules.IconTitleSubtitleMolecule
import io.element.android.libraries.designsystem.atomic.organisms.InfoListItem
import io.element.android.libraries.designsystem.atomic.organisms.InfoListOrganism
import io.element.android.libraries.designsystem.atomic.pages.HeaderFooterPage import io.element.android.libraries.designsystem.atomic.pages.HeaderFooterPage
import io.element.android.libraries.designsystem.background.OnboardingBackground import io.element.android.libraries.designsystem.background.OnboardingBackground
import io.element.android.libraries.designsystem.components.BigIcon import io.element.android.libraries.designsystem.components.BigIcon
import io.element.android.libraries.designsystem.components.ClickableLinkText import io.element.android.libraries.designsystem.components.ClickableLinkText
import io.element.android.libraries.designsystem.components.visuallist.VisualList
import io.element.android.libraries.designsystem.components.visuallist.VisualListItemData
import io.element.android.libraries.designsystem.preview.ElementPreview 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.text.buildAnnotatedStringWithStyledPart import io.element.android.libraries.designsystem.text.buildAnnotatedStringWithStyledPart
@@ -129,22 +129,21 @@ private fun AnalyticsOptInContent() {
verticalBias = -0.4f verticalBias = -0.4f
) )
) { ) {
InfoListOrganism( VisualList(
items = persistentListOf( items = persistentListOf(
InfoListItem( VisualListItemData(
message = stringResource(id = R.string.screen_analytics_prompt_data_usage), message = stringResource(id = R.string.screen_analytics_prompt_data_usage),
iconVector = CompoundIcons.CheckCircle(), iconVector = CompoundIcons.CheckCircle(),
), ),
InfoListItem( VisualListItemData(
message = stringResource(id = R.string.screen_analytics_prompt_third_party_sharing), message = stringResource(id = R.string.screen_analytics_prompt_third_party_sharing),
iconVector = CompoundIcons.CheckCircle(), iconVector = CompoundIcons.CheckCircle(),
), ),
InfoListItem( VisualListItemData(
message = stringResource(id = R.string.screen_analytics_prompt_settings), message = stringResource(id = R.string.screen_analytics_prompt_settings),
iconVector = CompoundIcons.CheckCircle(), iconVector = CompoundIcons.CheckCircle(),
), ),
), ),
textStyle = ElementTheme.typography.fontBodyLgMedium,
iconTint = ElementTheme.colors.iconSuccessPrimary, iconTint = ElementTheme.colors.iconSuccessPrimary,
) )
} }
@@ -36,10 +36,10 @@ import io.element.android.features.announcement.impl.AnnouncementState
import io.element.android.features.announcement.impl.AnnouncementStateProvider import io.element.android.features.announcement.impl.AnnouncementStateProvider
import io.element.android.libraries.designsystem.atomic.molecules.ButtonColumnMolecule import io.element.android.libraries.designsystem.atomic.molecules.ButtonColumnMolecule
import io.element.android.libraries.designsystem.atomic.molecules.IconTitleSubtitleMolecule import io.element.android.libraries.designsystem.atomic.molecules.IconTitleSubtitleMolecule
import io.element.android.libraries.designsystem.atomic.organisms.InfoListItem
import io.element.android.libraries.designsystem.atomic.organisms.InfoListOrganism
import io.element.android.libraries.designsystem.atomic.pages.HeaderFooterPage import io.element.android.libraries.designsystem.atomic.pages.HeaderFooterPage
import io.element.android.libraries.designsystem.components.BigIcon import io.element.android.libraries.designsystem.components.BigIcon
import io.element.android.libraries.designsystem.components.visuallist.VisualList
import io.element.android.libraries.designsystem.components.visuallist.VisualListItemData
import io.element.android.libraries.designsystem.preview.ElementPreview 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.Button import io.element.android.libraries.designsystem.theme.components.Button
@@ -134,11 +134,9 @@ private fun FullscreenAnnouncementContent(
Column( Column(
modifier = modifier.fillMaxSize(), modifier = modifier.fillMaxSize(),
) { ) {
InfoListOrganism( VisualList(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
items = announcement.items(), items = announcement.items(),
textStyle = ElementTheme.typography.fontBodyLgMedium,
iconTint = ElementTheme.colors.iconSecondary,
iconSize = 24.dp iconSize = 24.dp
) )
announcement.notice()?.let { notice -> announcement.notice()?.let { notice ->
@@ -186,25 +184,25 @@ private fun Announcement.Fullscreen.icon() = when (this) {
} }
@Composable @Composable
private fun Announcement.Fullscreen.items(): ImmutableList<InfoListItem> = when (this) { private fun Announcement.Fullscreen.items(): ImmutableList<VisualListItemData> = when (this) {
Announcement.Fullscreen.Space -> persistentListOf( Announcement.Fullscreen.Space -> persistentListOf(
InfoListItem( VisualListItemData(
message = "View spaces you\'ve created or joined", message = "View spaces you\'ve created or joined",
iconVector = CompoundIcons.VisibilityOn(), iconVector = CompoundIcons.VisibilityOn(),
), ),
InfoListItem( VisualListItemData(
message = "Accept or decline invites to spaces", message = "Accept or decline invites to spaces",
iconVector = CompoundIcons.Email(), iconVector = CompoundIcons.Email(),
), ),
InfoListItem( VisualListItemData(
message = "Discover any rooms you can join in your spaces", message = "Discover any rooms you can join in your spaces",
iconVector = CompoundIcons.Search(), iconVector = CompoundIcons.Search(),
), ),
InfoListItem( VisualListItemData(
message = "Join public spaces", message = "Join public spaces",
iconVector = CompoundIcons.Explore(), iconVector = CompoundIcons.Explore(),
), ),
InfoListItem( VisualListItemData(
message = "Leave any spaces you’ve joined", message = "Leave any spaces you’ve joined",
iconVector = CompoundIcons.Leave(), iconVector = CompoundIcons.Leave(),
), ),
@@ -1,6 +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="call_foreground_service_channel_title_android">"通话进行中"</string> <string name="call_foreground_service_channel_title_android">"正在进行的通话"</string>
<string name="call_foreground_service_message_android">"点击以返回通话"</string> <string name="call_foreground_service_message_android">"点击以返回通话"</string>
<string name="call_foreground_service_title_android">"☎️ 通话中"</string> <string name="call_foreground_service_title_android">"☎️ 通话中"</string>
<string name="call_invalid_audio_device_bluetooth_devices_disabled">"Element Call 不支持在此 Android 版本中使用蓝牙音频设备。请选择其它音频设备。"</string> <string name="call_invalid_audio_device_bluetooth_devices_disabled">"Element Call 不支持在此 Android 版本中使用蓝牙音频设备。请选择其它音频设备。"</string>
@@ -0,0 +1,23 @@
plugins {
id("io.element.android-compose-library")
id("kotlin-parcelize")
}
android {
namespace = "io.element.android.features.contentscanner.api"
}
dependencies {
implementation(projects.libraries.core)
implementation(projects.libraries.architecture)
implementation(projects.libraries.matrix.api)
implementation(projects.libraries.matrixui)
implementation(projects.libraries.designsystem)
testImplementation(libs.test.junit)
testImplementation(libs.coroutines.test)
testImplementation(libs.molecule.runtime)
testImplementation(libs.test.truth)
testImplementation(libs.test.turbine)
testImplementation(projects.libraries.matrix.test)
}
@@ -0,0 +1,35 @@
/*
* 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.
*/
import extension.setupDependencyInjection
plugins {
id("io.element.android-compose-library")
id("kotlin-parcelize")
}
android {
namespace = "io.element.android.features.contentscanner.impl"
}
setupDependencyInjection()
dependencies {
api(projects.features.contentscanner.api)
implementation(projects.libraries.core)
implementation(projects.libraries.architecture)
implementation(projects.libraries.matrix.api)
implementation(projects.libraries.matrixui)
implementation(projects.libraries.designsystem)
testImplementation(libs.test.junit)
testImplementation(libs.coroutines.test)
testImplementation(libs.molecule.runtime)
testImplementation(libs.test.truth)
testImplementation(libs.test.turbine)
testImplementation(projects.libraries.matrix.test)
}
@@ -11,7 +11,8 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.material3.SheetValue
import androidx.compose.material3.rememberBottomSheetState
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
@@ -85,9 +86,9 @@ internal fun SelectParentSpaceOptions(
) )
if (displaySelectSpaceBottomSheet) { if (displaySelectSpaceBottomSheet) {
val sheetState = rememberModalBottomSheetState( val sheetState = rememberBottomSheetState(
skipPartiallyExpanded = true, initialValue = SheetValue.Hidden,
confirmValueChange = { true }, enabledValues = setOf(SheetValue.Hidden, SheetValue.Expanded),
) )
ModalBottomSheet( ModalBottomSheet(
sheetState = sheetState, sheetState = sheetState,
@@ -31,6 +31,6 @@
<string name="screen_create_room_space_selection_no_space_option">"Не додавати до простору"</string> <string name="screen_create_room_space_selection_no_space_option">"Не додавати до простору"</string>
<string name="screen_create_room_space_selection_no_space_title">"Головна"</string> <string name="screen_create_room_space_selection_no_space_title">"Головна"</string>
<string name="screen_create_room_space_selection_sheet_title">"Додати до простору"</string> <string name="screen_create_room_space_selection_sheet_title">"Додати до простору"</string>
<string name="screen_create_room_topic_label">"Тема (необов\'язково)"</string> <string name="screen_create_room_topic_label">"Тематика обговорень (необов’язково)"</string>
<string name="screen_create_room_topic_placeholder">"Додати опис…"</string> <string name="screen_create_room_topic_placeholder">"Додати опис…"</string>
</resources> </resources>
@@ -48,11 +48,11 @@ import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.features.deactivation.impl.R import io.element.android.features.deactivation.impl.R
import io.element.android.features.logout.impl.ui.AccountDeactivationActionDialog import io.element.android.features.logout.impl.ui.AccountDeactivationActionDialog
import io.element.android.libraries.architecture.AsyncAction import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.designsystem.atomic.organisms.InfoListItem
import io.element.android.libraries.designsystem.atomic.organisms.InfoListOrganism
import io.element.android.libraries.designsystem.components.button.BackButton import io.element.android.libraries.designsystem.components.button.BackButton
import io.element.android.libraries.designsystem.components.form.textFieldState import io.element.android.libraries.designsystem.components.form.textFieldState
import io.element.android.libraries.designsystem.components.list.SwitchListItem import io.element.android.libraries.designsystem.components.list.SwitchListItem
import io.element.android.libraries.designsystem.components.visuallist.VisualList
import io.element.android.libraries.designsystem.components.visuallist.VisualListItemData
import io.element.android.libraries.designsystem.modifiers.onTabOrEnterKeyFocusNext import io.element.android.libraries.designsystem.modifiers.onTabOrEnterKeyFocusNext
import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.designsystem.preview.PreviewsDayNight
@@ -175,9 +175,9 @@ private fun Content(
style = ElementTheme.typography.fontBodyMdRegular, style = ElementTheme.typography.fontBodyMdRegular,
color = ElementTheme.colors.textSecondary, color = ElementTheme.colors.textSecondary,
) )
InfoListOrganism( VisualList(
items = persistentListOf( items = persistentListOf(
InfoListItem( VisualListItemData(
message = buildAnnotatedStringWithStyledPart( message = buildAnnotatedStringWithStyledPart(
R.string.screen_deactivate_account_list_item_1, R.string.screen_deactivate_account_list_item_1,
R.string.screen_deactivate_account_list_item_1_bold_part, R.string.screen_deactivate_account_list_item_1_bold_part,
@@ -194,7 +194,7 @@ private fun Content(
) )
}, },
), ),
InfoListItem( VisualListItemData(
message = stringResource(R.string.screen_deactivate_account_list_item_2), message = stringResource(R.string.screen_deactivate_account_list_item_2),
iconComposable = { iconComposable = {
Icon( Icon(
@@ -205,7 +205,7 @@ private fun Content(
) )
}, },
), ),
InfoListItem( VisualListItemData(
message = stringResource(R.string.screen_deactivate_account_list_item_3), message = stringResource(R.string.screen_deactivate_account_list_item_3),
iconComposable = { iconComposable = {
Icon( Icon(
@@ -216,7 +216,7 @@ private fun Content(
) )
}, },
), ),
InfoListItem( VisualListItemData(
message = stringResource(R.string.screen_deactivate_account_list_item_4), message = stringResource(R.string.screen_deactivate_account_list_item_4),
iconComposable = { iconComposable = {
Icon( Icon(
@@ -1,14 +1,14 @@
<?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_deactivate_account_confirmation_dialog_content">"Будь ласка, підтвердіть, що ви хочете деактивувати свій обліковий запис. Ця дія не може бути скасована."</string> <string name="screen_deactivate_account_confirmation_dialog_content">"Підтвердьте, що хочете видалити свій обліковий запис. Цю дію неможливо скасувати."</string>
<string name="screen_deactivate_account_delete_all_messages">"Видалити всі мої повідомлення"</string> <string name="screen_deactivate_account_delete_all_messages">"Видалити всі мої повідомлення"</string>
<string name="screen_deactivate_account_delete_all_messages_notice">"Попередження: майбутні користувачі можуть бачити неповні розмови."</string> <string name="screen_deactivate_account_delete_all_messages_notice">"Попередження: майбутні користувачі можуть бачити неповні розмови."</string>
<string name="screen_deactivate_account_description">"Деактивація вашого облікового запису%1$s , це буде:"</string> <string name="screen_deactivate_account_description">"Видалення вашого облікового запису %1$s призведе до:"</string>
<string name="screen_deactivate_account_description_bold_part">"незворотні"</string> <string name="screen_deactivate_account_description_bold_part">"незворотні"</string>
<string name="screen_deactivate_account_list_item_1">"%1$sваш обліковий запис (ви не можете знову увійти, а ваш ідентифікатор не може бути використаний повторно)."</string> <string name="screen_deactivate_account_list_item_1">"%1$sваш обліковий запис (ви не можете знову увійти, а ваш ідентифікатор не може бути використаний повторно)."</string>
<string name="screen_deactivate_account_list_item_1_bold_part">"Назавжди відключити"</string> <string name="screen_deactivate_account_list_item_1_bold_part">"Назавжди відключити"</string>
<string name="screen_deactivate_account_list_item_2">"Видалити вас з усіх чатів."</string> <string name="screen_deactivate_account_list_item_2">"Вилучити вас з усіх бесід."</string>
<string name="screen_deactivate_account_list_item_3">"Видаліть інформацію свого облікового запису з нашого сервера ідентифікації."</string> <string name="screen_deactivate_account_list_item_3">"Видаліть інформацію свого облікового запису з нашого сервера ідентифікації."</string>
<string name="screen_deactivate_account_list_item_4">"Ваші повідомлення залишатимуться видимими для зареєстрованих користувачів, але недоступними для нових або незареєстрованих користувачів, якщо ви вирішите їх видалити."</string> <string name="screen_deactivate_account_list_item_4">"Ваші повідомлення залишатимуться видимими для зареєстрованих користувачів, але недоступними для нових або незареєстрованих користувачів, якщо ви вирішите їх видалити."</string>
<string name="screen_deactivate_account_title">"Відключити обліковий запис"</string> <string name="screen_deactivate_account_title">"Видалити обліковий запис"</string>
</resources> </resources>
@@ -2,7 +2,7 @@
<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_identity_confirmation_cannot_confirm">"Tidak dapat mengonfirmasi?"</string> <string name="screen_identity_confirmation_cannot_confirm">"Tidak dapat mengonfirmasi?"</string>
<string name="screen_identity_confirmation_create_new_recovery_key">"Buat kunci pemulihan baru"</string> <string name="screen_identity_confirmation_create_new_recovery_key">"Buat kunci pemulihan baru"</string>
<string name="screen_identity_confirmation_subtitle">"Verifikasi perangkat ini untuk menyiapkan perpesanan aman."</string> <string name="screen_identity_confirmation_subtitle">"Pilih cara verifikasi untuk mengatur pengiriman pesan yang aman."</string>
<string name="screen_identity_confirmation_title">"Konfirmasi bahwa ini Anda"</string> <string name="screen_identity_confirmation_title">"Konfirmasi bahwa ini Anda"</string>
<string name="screen_identity_confirmation_use_another_device">"Gunakan perangkat lain"</string> <string name="screen_identity_confirmation_use_another_device">"Gunakan perangkat lain"</string>
<string name="screen_identity_confirmation_use_recovery_key">"Gunakan kunci pemulihan"</string> <string name="screen_identity_confirmation_use_recovery_key">"Gunakan kunci pemulihan"</string>
@@ -2,12 +2,12 @@
<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_identity_confirmation_cannot_confirm">"Не можете підтвердити?"</string> <string name="screen_identity_confirmation_cannot_confirm">"Не можете підтвердити?"</string>
<string name="screen_identity_confirmation_create_new_recovery_key">"Створити новий ключ відновлення"</string> <string name="screen_identity_confirmation_create_new_recovery_key">"Створити новий ключ відновлення"</string>
<string name="screen_identity_confirmation_subtitle">"Верифікуйте цей пристрій, щоб налаштувати безпечний обмін повідомленнями."</string> <string name="screen_identity_confirmation_subtitle">"Виберіть спосіб звіряння, щоб налаштувати безпечний обмін повідомленнями."</string>
<string name="screen_identity_confirmation_title">"Підтвердьте, що це ви"</string> <string name="screen_identity_confirmation_title">"Підтвердьте свою цифрову ідентичність"</string>
<string name="screen_identity_confirmation_use_another_device">"Використовуйте інший пристрій"</string> <string name="screen_identity_confirmation_use_another_device">"Використовуйте інший пристрій"</string>
<string name="screen_identity_confirmation_use_recovery_key">"Використовуйте ключ відновлення"</string> <string name="screen_identity_confirmation_use_recovery_key">"Використовуйте ключ відновлення"</string>
<string name="screen_identity_confirmed_subtitle">"Тепер ви можете безпечно читати або надсилати повідомлення, і кожен, з ким ви спілкуєтесь, також може довіряти цьому пристрою."</string> <string name="screen_identity_confirmed_subtitle">"Тепер ви можете безпечно читати або надсилати повідомлення, і кожен, з ким ви спілкуєтесь, також може довіряти цьому пристрою."</string>
<string name="screen_identity_confirmed_title">"Пристрій перевірено"</string> <string name="screen_identity_confirmed_title">"Пристрій звірено"</string>
<string name="screen_identity_use_another_device">"Використовуйте інший пристрій"</string> <string name="screen_identity_use_another_device">"Використовуйте інший пристрій"</string>
<string name="screen_identity_waiting_on_other_device">"Чекає на інше пристрій…"</string> <string name="screen_identity_waiting_on_other_device">"Чекає на інше пристрій…"</string>
<string name="screen_notification_optin_subtitle">"Ви можете змінити свої налаштування пізніше."</string> <string name="screen_notification_optin_subtitle">"Ви можете змінити свої налаштування пізніше."</string>
+1
View File
@@ -57,6 +57,7 @@ dependencies {
implementation(libs.androidx.datastore.preferences) implementation(libs.androidx.datastore.preferences)
implementation(libs.haze) implementation(libs.haze)
implementation(libs.haze.materials) implementation(libs.haze.materials)
implementation(projects.features.preferences.impl)
implementation(projects.features.reportroom.api) implementation(projects.features.reportroom.api)
implementation(projects.features.rolesandpermissions.api) implementation(projects.features.rolesandpermissions.api)
implementation(projects.libraries.previewutils) implementation(projects.libraries.previewutils)
@@ -106,7 +106,7 @@ private fun RoomListModalBottomSheetContent(
ListItem( ListItem(
headlineContent = { headlineContent = {
Text( Text(
text = stringResource(id = R.string.screen_roomlist_mark_as_read), text = stringResource(id = CommonStrings.action_mark_as_read),
style = MaterialTheme.typography.bodyLarge, style = MaterialTheme.typography.bodyLarge,
) )
}, },
@@ -200,7 +200,7 @@ private fun RoomListModalBottomSheetContent(
@Composable @Composable
internal fun RoomListContextMenuPreview( internal fun RoomListContextMenuPreview(
@PreviewParameter(RoomListStateContextMenuShownProvider::class) contextMenu: RoomListState.ContextMenu.Shown @PreviewParameter(RoomListStateContextMenuShownProvider::class) contextMenu: RoomListState.ContextMenu.Shown
) = ElementPreview { ) = ElementPreview(fillMaxSize = true) {
RoomListContextMenu( RoomListContextMenu(
contextMenu = contextMenu, contextMenu = contextMenu,
canReportRoom = true, canReportRoom = true,
@@ -41,6 +41,7 @@ import io.element.android.features.invite.api.acceptdecline.AcceptDeclineInviteE
import io.element.android.features.invite.api.acceptdecline.AcceptDeclineInviteState import io.element.android.features.invite.api.acceptdecline.AcceptDeclineInviteState
import io.element.android.features.leaveroom.api.LeaveRoomEvent import io.element.android.features.leaveroom.api.LeaveRoomEvent
import io.element.android.features.leaveroom.api.LeaveRoomState import io.element.android.features.leaveroom.api.LeaveRoomState
import io.element.android.features.preferences.impl.tasks.MarkRoomAsRead
import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.featureflag.api.FeatureFlagService import io.element.android.libraries.featureflag.api.FeatureFlagService
@@ -51,11 +52,8 @@ import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.encryption.RecoveryState import io.element.android.libraries.matrix.api.encryption.RecoveryState
import io.element.android.libraries.matrix.api.roomlist.RoomList import io.element.android.libraries.matrix.api.roomlist.RoomList
import io.element.android.libraries.matrix.api.roomlist.RoomListFilter import io.element.android.libraries.matrix.api.roomlist.RoomListFilter
import io.element.android.libraries.matrix.api.timeline.ReceiptType
import io.element.android.libraries.matrix.ui.safety.rememberHideInvitesAvatar import io.element.android.libraries.matrix.ui.safety.rememberHideInvitesAvatar
import io.element.android.libraries.preferences.api.store.SessionPreferencesStore
import io.element.android.libraries.push.api.battery.BatteryOptimizationState import io.element.android.libraries.push.api.battery.BatteryOptimizationState
import io.element.android.libraries.push.api.notifications.NotificationCleaner
import io.element.android.services.analytics.api.AnalyticsService import io.element.android.services.analytics.api.AnalyticsService
import io.element.android.services.analytics.api.watchers.AnalyticsColdStartWatcher import io.element.android.services.analytics.api.watchers.AnalyticsColdStartWatcher
import io.element.android.services.analyticsproviders.api.trackers.captureInteraction import io.element.android.services.analyticsproviders.api.trackers.captureInteraction
@@ -65,7 +63,6 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.collect import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onEach
@@ -79,12 +76,11 @@ class RoomListPresenter(
private val roomListDataSource: RoomListDataSource, private val roomListDataSource: RoomListDataSource,
private val filtersPresenter: Presenter<RoomListFiltersState>, private val filtersPresenter: Presenter<RoomListFiltersState>,
private val searchPresenter: Presenter<RoomListSearchState>, private val searchPresenter: Presenter<RoomListSearchState>,
private val sessionPreferencesStore: SessionPreferencesStore,
private val analyticsService: AnalyticsService, private val analyticsService: AnalyticsService,
private val acceptDeclineInvitePresenter: Presenter<AcceptDeclineInviteState>, private val acceptDeclineInvitePresenter: Presenter<AcceptDeclineInviteState>,
private val fullScreenIntentPermissionsPresenter: Presenter<FullScreenIntentPermissionsState>, private val fullScreenIntentPermissionsPresenter: Presenter<FullScreenIntentPermissionsState>,
private val batteryOptimizationPresenter: Presenter<BatteryOptimizationState>, private val batteryOptimizationPresenter: Presenter<BatteryOptimizationState>,
private val notificationCleaner: NotificationCleaner, private val markRoomAsRead: MarkRoomAsRead,
private val seenInvitesStore: SeenInvitesStore, private val seenInvitesStore: SeenInvitesStore,
private val announcementService: AnnouncementService, private val announcementService: AnnouncementService,
private val coldStartWatcher: AnalyticsColdStartWatcher, private val coldStartWatcher: AnalyticsColdStartWatcher,
@@ -308,19 +304,10 @@ class RoomListPresenter(
} }
private fun CoroutineScope.markAsRead(roomId: RoomId) = launch { private fun CoroutineScope.markAsRead(roomId: RoomId) = launch {
notificationCleaner.clearMessagesForRoom(client.sessionId, roomId) markRoomAsRead(roomId)
client.getRoom(roomId)?.use { room -> .onSuccess {
room.setUnreadFlag(isUnread = false) analyticsService.captureInteraction(name = Interaction.Name.MobileRoomListRoomContextMenuUnreadToggle)
val receiptType = if (sessionPreferencesStore.isSendPublicReadReceiptsEnabled().first()) {
ReceiptType.READ
} else {
ReceiptType.READ_PRIVATE
} }
room.markAsRead(receiptType)
.onSuccess {
analyticsService.captureInteraction(name = Interaction.Name.MobileRoomListRoomContextMenuUnreadToggle)
}
}
} }
private fun CoroutineScope.markAsUnread(roomId: RoomId) = launch { private fun CoroutineScope.markAsUnread(roomId: RoomId) = launch {
@@ -33,6 +33,8 @@ import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
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.tokens.generated.CompoundIcons import io.element.android.compound.tokens.generated.CompoundIcons
@@ -112,10 +114,12 @@ private fun RoomListSearchContent(
title = { title = {
// The stateSaver will keep the selection state when returning to this UI // The stateSaver will keep the selection state when returning to this UI
val focusRequester = remember { FocusRequester() } val focusRequester = remember { FocusRequester() }
val searchLabel = stringResource(CommonStrings.action_search)
FilledTextField( FilledTextField(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.focusRequester(focusRequester), .focusRequester(focusRequester)
.semantics { contentDescription = searchLabel },
state = state.query, state = state.query,
lineLimits = TextFieldLineLimits.SingleLine, lineLimits = TextFieldLineLimits.SingleLine,
colors = TextFieldDefaults.colors( colors = TextFieldDefaults.colors(
@@ -132,7 +136,7 @@ private fun RoomListSearchContent(
IconButton(onClick = { state.eventSink(RoomListSearchEvent.ClearQuery) }) { IconButton(onClick = { state.eventSink(RoomListSearchEvent.ClearQuery) }) {
Icon( Icon(
imageVector = CompoundIcons.Close(), imageVector = CompoundIcons.Close(),
contentDescription = stringResource(CommonStrings.action_cancel) contentDescription = stringResource(CommonStrings.a11y_clear_search_field)
) )
} }
} }
@@ -24,7 +24,7 @@ import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.text.input.TextFieldState import androidx.compose.foundation.text.input.TextFieldState
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.SheetValue import androidx.compose.material3.SheetValue
import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.material3.rememberBottomSheetState
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
@@ -57,8 +57,9 @@ fun SpaceFiltersView(
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
val isSelecting by rememberUpdatedState(state is SpaceFiltersState.Selecting) val isSelecting by rememberUpdatedState(state is SpaceFiltersState.Selecting)
val sheetState = rememberModalBottomSheetState( val sheetState = rememberBottomSheetState(
skipPartiallyExpanded = true, initialValue = SheetValue.Hidden,
enabledValues = setOf(SheetValue.Hidden, SheetValue.Expanded),
confirmValueChange = { sheetValueTarget -> confirmValueChange = { sheetValueTarget ->
// This ensures the hide animation is not cancelled // This ensures the hide animation is not cancelled
when (sheetValueTarget) { when (sheetValueTarget) {
@@ -187,6 +188,6 @@ private fun SpaceFilterItem(
@PreviewsDayNight @PreviewsDayNight
@Composable @Composable
internal fun SpaceFiltersViewPreview(@PreviewParameter(SpaceFiltersStateProvider::class) state: SpaceFiltersState) = ElementPreview { internal fun SpaceFiltersViewPreview(@PreviewParameter(SpaceFiltersStateProvider::class) state: SpaceFiltersState) = ElementPreview(fillMaxSize = true) {
SpaceFiltersView(state = state) SpaceFiltersView(state = state)
} }
@@ -4,8 +4,8 @@
<string name="banner_battery_optimization_submit_android">"Nonaktifkan optimasi"</string> <string name="banner_battery_optimization_submit_android">"Nonaktifkan optimasi"</string>
<string name="banner_battery_optimization_title_android">"Notifikasi tidak masuk?"</string> <string name="banner_battery_optimization_title_android">"Notifikasi tidak masuk?"</string>
<string name="banner_set_up_recovery_content">"Buat kunci pemulihan baru yang dapat digunakan untuk memulihkan riwayat pesan terenkripsi Anda jika Anda kehilangan akses ke perangkat Anda."</string> <string name="banner_set_up_recovery_content">"Buat kunci pemulihan baru yang dapat digunakan untuk memulihkan riwayat pesan terenkripsi Anda jika Anda kehilangan akses ke perangkat Anda."</string>
<string name="banner_set_up_recovery_submit">"Siapkan pemulihan"</string> <string name="banner_set_up_recovery_submit">"Siapkan Kunci pemulihan"</string>
<string name="banner_set_up_recovery_title">"Siapkan pemulihan"</string> <string name="banner_set_up_recovery_title">"Cadangkan obrolan Anda"</string>
<string name="confirm_recovery_key_banner_message">"Konfirmasikan kunci pemulihan Anda untuk mempertahankan akses ke penyimpanan kunci dan riwayat pesan Anda."</string> <string name="confirm_recovery_key_banner_message">"Konfirmasikan kunci pemulihan Anda untuk mempertahankan akses ke penyimpanan kunci dan riwayat pesan Anda."</string>
<string name="confirm_recovery_key_banner_primary_button_title">"Masukkan kunci pemulihan Anda"</string> <string name="confirm_recovery_key_banner_primary_button_title">"Masukkan kunci pemulihan Anda"</string>
<string name="confirm_recovery_key_banner_secondary_button_title">"Lupa kunci pemulihan Anda?"</string> <string name="confirm_recovery_key_banner_secondary_button_title">"Lupa kunci pemulihan Anda?"</string>
@@ -6,7 +6,7 @@
<string name="banner_new_sound_message">"O seu ping de notificação foi atualizado—mais suave, mais rápido, e menos disruptivo."</string> <string name="banner_new_sound_message">"O seu ping de notificação foi atualizado—mais suave, mais rápido, e menos disruptivo."</string>
<string name="banner_new_sound_title">"Recarregamos seus sons"</string> <string name="banner_new_sound_title">"Recarregamos seus sons"</string>
<string name="banner_set_up_recovery_content">"Recupere sua identidade criptográfica e o histórico de mensagens com uma chave de recuperação caso você perda todos os dispositivos existentes."</string> <string name="banner_set_up_recovery_content">"Recupere sua identidade criptográfica e o histórico de mensagens com uma chave de recuperação caso você perda todos os dispositivos existentes."</string>
<string name="banner_set_up_recovery_submit">"Configurar a recuperação"</string> <string name="banner_set_up_recovery_submit">"Receber chave de recuperação"</string>
<string name="banner_set_up_recovery_title">"Configure a recuperação para proteger sua conta"</string> <string name="banner_set_up_recovery_title">"Configure a recuperação para proteger sua conta"</string>
<string name="confirm_recovery_key_banner_message">"Confirme sua chave de recuperação para manter o acesso ao seu armazenamento de chaves e histórico de mensagens."</string> <string name="confirm_recovery_key_banner_message">"Confirme sua chave de recuperação para manter o acesso ao seu armazenamento de chaves e histórico de mensagens."</string>
<string name="confirm_recovery_key_banner_primary_button_title">"Digite sua chave de recuperação"</string> <string name="confirm_recovery_key_banner_primary_button_title">"Digite sua chave de recuperação"</string>
@@ -5,15 +5,15 @@
<string name="banner_battery_optimization_title_android">"Не надходять сповіщення?"</string> <string name="banner_battery_optimization_title_android">"Не надходять сповіщення?"</string>
<string name="banner_new_sound_message">"Ваш сигнал сповіщень оновлено — він став чіткішим, швидшим і не таким надокучливим."</string> <string name="banner_new_sound_message">"Ваш сигнал сповіщень оновлено — він став чіткішим, швидшим і не таким надокучливим."</string>
<string name="banner_new_sound_title">"Ми оновили ваші звуки"</string> <string name="banner_new_sound_title">"Ми оновили ваші звуки"</string>
<string name="banner_set_up_recovery_content">"Відновіть свою криптографічну ідентичність та історію повідомлень за допомогою ключа відновлення, якщо ви втратили всі наявні пристрої."</string> <string name="banner_set_up_recovery_content">"Ваші бесіди автоматично резервуються з використанням наскрізного шифрування. Щоб відновити цю резервну копію та зберегти свою цифрову ідентичність у разі втрати доступу до всіх своїх пристроїв, вам знадобиться ключ відновлення."</string>
<string name="banner_set_up_recovery_submit">"Налаштувати відновлення"</string> <string name="banner_set_up_recovery_submit">"Отримати ключ відновлення"</string>
<string name="banner_set_up_recovery_title">"Налаштуйте відновлення для захисту свого облікового запису"</string> <string name="banner_set_up_recovery_title">"Створити резервну копію своїх бесід"</string>
<string name="confirm_recovery_key_banner_message">"Підтвердіть свій ключ відновлення, щоб мати доступ до сховища ключів та історії повідомлень."</string> <string name="confirm_recovery_key_banner_message">"Підтвердіть свій ключ відновлення, щоб мати доступ до сховища ключів та історії повідомлень."</string>
<string name="confirm_recovery_key_banner_primary_button_title">"Введіть ключ відновлення"</string> <string name="confirm_recovery_key_banner_primary_button_title">"Введіть ключ відновлення"</string>
<string name="confirm_recovery_key_banner_secondary_button_title">"Забули ключ відновлення?"</string> <string name="confirm_recovery_key_banner_secondary_button_title">"Забули ключ відновлення?"</string>
<string name="confirm_recovery_key_banner_title">"Ваше сховище ключів не синхронізовано"</string> <string name="confirm_recovery_key_banner_title">"Ваше сховище ключів не синхронізовано"</string>
<string name="full_screen_intent_banner_message">"Щоб ніколи не пропустити важливий виклик, змініть налаштування, щоб увімкнути повноекранні сповіщення, коли телефон заблоковано."</string> <string name="full_screen_intent_banner_message">"Щоб ніколи не пропустити важливий виклик, змініть налаштування, щоб увімкнути повноекранні сповіщення, коли телефон заблоковано."</string>
<string name="full_screen_intent_banner_title">"Покращуйте досвід дзвінків"</string> <string name="full_screen_intent_banner_title">"Покращуйте якість викликів"</string>
<string name="screen_home_tab_chats">"Бесіди"</string> <string name="screen_home_tab_chats">"Бесіди"</string>
<string name="screen_home_tab_spaces">"Простори"</string> <string name="screen_home_tab_spaces">"Простори"</string>
<string name="screen_invites_decline_chat_message">"Ви впевнені, що хочете відхилити запрошення приєднатися до %1$s?"</string> <string name="screen_invites_decline_chat_message">"Ви впевнені, що хочете відхилити запрошення приєднатися до %1$s?"</string>
@@ -51,6 +51,6 @@
<string name="screen_roomlist_mark_as_unread">"Позначити непрочитаним"</string> <string name="screen_roomlist_mark_as_unread">"Позначити непрочитаним"</string>
<string name="screen_roomlist_tombstoned_room_description">"Цю кімнату оновлено"</string> <string name="screen_roomlist_tombstoned_room_description">"Цю кімнату оновлено"</string>
<string name="screen_roomlist_your_spaces">"Ваші простори"</string> <string name="screen_roomlist_your_spaces">"Ваші простори"</string>
<string name="session_verification_banner_message">"Схоже, ви використовуєте новий пристрій. Щоб отримати доступ до зашифрованих повідомлень, підтвердьте особу за допомогою іншого пристрою."</string> <string name="session_verification_banner_message">"Здається, ви використовуєте новий пристрій. Звірте його за допомогою іншого пристрою, щоб отримати доступ до своїх зашифрованих повідомлень."</string>
<string name="session_verification_banner_title">"Підтвердьте, що це ви"</string> <string name="session_verification_banner_title">"Підтвердьте, що це ви"</string>
</resources> </resources>
@@ -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.home.impl.roomlist
import io.element.android.features.preferences.impl.tasks.MarkRoomAsRead
import io.element.android.libraries.matrix.api.core.RoomId
class FakeMarkRoomAsRead(
private val invokeLambda: suspend (RoomId) -> Result<Unit> = { Result.success(Unit) },
) : MarkRoomAsRead {
val invokedRoomIds = mutableListOf<RoomId>()
override suspend fun invoke(roomId: RoomId): Result<Unit> {
invokedRoomIds.add(roomId)
return invokeLambda(roomId)
}
}
@@ -34,7 +34,7 @@ class RoomListContextMenuTest : RobolectricTest() {
contextMenu = contextMenu, contextMenu = contextMenu,
eventSink = eventsRecorder, eventSink = eventsRecorder,
) )
clickOn(R.string.screen_roomlist_mark_as_read) clickOn(CommonStrings.action_mark_as_read)
eventsRecorder.assertList( eventsRecorder.assertList(
listOf( listOf(
RoomListEvent.HideContextMenu, RoomListEvent.HideContextMenu,
@@ -30,6 +30,7 @@ import io.element.android.features.invite.api.acceptdecline.anAcceptDeclineInvit
import io.element.android.features.invite.test.InMemorySeenInvitesStore import io.element.android.features.invite.test.InMemorySeenInvitesStore
import io.element.android.features.leaveroom.api.LeaveRoomEvent import io.element.android.features.leaveroom.api.LeaveRoomEvent
import io.element.android.features.leaveroom.api.LeaveRoomState import io.element.android.features.leaveroom.api.LeaveRoomState
import io.element.android.features.preferences.impl.tasks.MarkRoomAsRead
import io.element.android.features.rageshake.test.logs.FakeAnnouncementService import io.element.android.features.rageshake.test.logs.FakeAnnouncementService
import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.dateformatter.api.DateFormatter import io.element.android.libraries.dateformatter.api.DateFormatter
@@ -427,11 +428,17 @@ class RoomListPresenterTest {
val notificationCleaner = FakeNotificationCleaner( val notificationCleaner = FakeNotificationCleaner(
clearMessagesForRoomLambda = clearMessagesForRoomLambda, clearMessagesForRoomLambda = clearMessagesForRoomLambda,
) )
val markRoomAsRead = createTestMarkRoomAsRead(
client = matrixClient,
notificationCleaner = notificationCleaner,
sessionPreferencesStore = sessionPreferencesStore,
)
val presenter = createRoomListPresenter( val presenter = createRoomListPresenter(
client = matrixClient, client = matrixClient,
sessionPreferencesStore = sessionPreferencesStore, sessionPreferencesStore = sessionPreferencesStore,
analyticsService = analyticsService, analyticsService = analyticsService,
notificationCleaner = notificationCleaner, notificationCleaner = notificationCleaner,
markRoomAsRead = markRoomAsRead,
) )
presenter.test { presenter.test {
val initialState = awaitItem() val initialState = awaitItem()
@@ -623,6 +630,24 @@ class RoomListPresenterTest {
} }
} }
private fun createTestMarkRoomAsRead(
client: MatrixClient,
notificationCleaner: NotificationCleaner,
sessionPreferencesStore: SessionPreferencesStore,
): MarkRoomAsRead = FakeMarkRoomAsRead { roomId ->
notificationCleaner.clearMessagesForRoom(client.sessionId, roomId)
val room = client.getRoom(roomId) ?: return@FakeMarkRoomAsRead Result.failure(IllegalStateException("Room not found"))
room.use {
it.setUnreadFlag(isUnread = false)
val receiptType = if (sessionPreferencesStore.isSendPublicReadReceiptsEnabled().first()) {
ReceiptType.READ
} else {
ReceiptType.READ_PRIVATE
}
it.markAsRead(receiptType)
}
}
private fun TestScope.createRoomListPresenter( private fun TestScope.createRoomListPresenter(
client: MatrixClient = FakeMatrixClient(), client: MatrixClient = FakeMatrixClient(),
leaveRoomState: LeaveRoomState = aLeaveRoomState(), leaveRoomState: LeaveRoomState = aLeaveRoomState(),
@@ -638,6 +663,7 @@ class RoomListPresenterTest {
seenInvitesStore: SeenInvitesStore = InMemorySeenInvitesStore(), seenInvitesStore: SeenInvitesStore = InMemorySeenInvitesStore(),
announcementService: AnnouncementService = FakeAnnouncementService(), announcementService: AnnouncementService = FakeAnnouncementService(),
featureFlagService: FeatureFlagService = FakeFeatureFlagService(), featureFlagService: FeatureFlagService = FakeFeatureFlagService(),
markRoomAsRead: MarkRoomAsRead? = null,
) = RoomListPresenter( ) = RoomListPresenter(
client = client, client = client,
leaveRoomPresenter = { leaveRoomState }, leaveRoomPresenter = { leaveRoomState },
@@ -654,14 +680,17 @@ class RoomListPresenterTest {
analyticsService = FakeAnalyticsService(), analyticsService = FakeAnalyticsService(),
), ),
searchPresenter = searchPresenter, searchPresenter = searchPresenter,
sessionPreferencesStore = sessionPreferencesStore,
filtersPresenter = filtersPresenter, filtersPresenter = filtersPresenter,
spaceFiltersPresenter = spaceFiltersPresenter, spaceFiltersPresenter = spaceFiltersPresenter,
analyticsService = analyticsService, analyticsService = analyticsService,
acceptDeclineInvitePresenter = acceptDeclineInvitePresenter, acceptDeclineInvitePresenter = acceptDeclineInvitePresenter,
fullScreenIntentPermissionsPresenter = { aFullScreenIntentPermissionsState() }, fullScreenIntentPermissionsPresenter = { aFullScreenIntentPermissionsState() },
batteryOptimizationPresenter = { aBatteryOptimizationState() }, batteryOptimizationPresenter = { aBatteryOptimizationState() },
notificationCleaner = notificationCleaner, markRoomAsRead = markRoomAsRead ?: createTestMarkRoomAsRead(
client = client,
notificationCleaner = notificationCleaner,
sessionPreferencesStore = sessionPreferencesStore,
),
seenInvitesStore = seenInvitesStore, seenInvitesStore = seenInvitesStore,
announcementService = announcementService, announcementService = announcementService,
coldStartWatcher = FakeAnalyticsColdStartWatcher(), coldStartWatcher = FakeAnalyticsColdStartWatcher(),
@@ -2,8 +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">"Уже учасник"</string> <string name="screen_invite_users_already_a_member">"Уже учасник"</string>
<string name="screen_invite_users_already_invited">"Уже запрошені"</string> <string name="screen_invite_users_already_invited">"Уже запрошені"</string>
<string name="screen_invite_users_confirm_dialog_subtitle_multiple_users">"Наразі у вас немає чатів із цими контактами. Підтвердьте запрошення їх до цієї кімнати, перш ніж продовжувати."</string> <string name="screen_invite_users_confirm_dialog_subtitle_multiple_users">"Наразі у вас немає бесід з цими контактами. Підтвердьте запрошення їх до цієї кімнати, перш ніж продовжувати."</string>
<string name="screen_invite_users_confirm_dialog_subtitle_one_user">"Наразі у вас немає чатів із цим контактом. Підтвердьте запрошення до цієї кімнати, перш ніж продовжувати."</string> <string name="screen_invite_users_confirm_dialog_subtitle_one_user">"Наразі у вас немає бесід із цим контактом. Підтвердьте запрошення до цієї кімнати, перш ніж продовжувати."</string>
<string name="screen_invite_users_confirm_dialog_title_mutiple_users">"Запросити нових контактів до цієї кімнати?"</string> <string name="screen_invite_users_confirm_dialog_title_mutiple_users">"Запросити нових контактів до цієї кімнати?"</string>
<string name="screen_invite_users_confirm_dialog_title_one_user">"Запросити нового контакта до цієї кімнати?"</string> <string name="screen_invite_users_confirm_dialog_title_one_user">"Запросити нового контакта до цієї кімнати?"</string>
</resources> </resources>
@@ -15,6 +15,10 @@
<string name="screen_join_room_fail_reason">"Ruangan ini hanya untuk undangan atau mungkin ada pembatasan akses pada tingkat space."</string> <string name="screen_join_room_fail_reason">"Ruangan ini hanya untuk undangan atau mungkin ada pembatasan akses pada tingkat space."</string>
<string name="screen_join_room_forget_action">"Lupa"</string> <string name="screen_join_room_forget_action">"Lupa"</string>
<string name="screen_join_room_invite_required_message">"Anda memerlukan undangan untuk bergabung dalam ruangan ini"</string> <string name="screen_join_room_invite_required_message">"Anda memerlukan undangan untuk bergabung dalam ruangan ini"</string>
<string name="screen_join_room_invited_by">
"Diundang oleh "
<userName/>
</string>
<string name="screen_join_room_join_action">"Gabung"</string> <string name="screen_join_room_join_action">"Gabung"</string>
<string name="screen_join_room_join_restricted_message">"Anda mungkin perlu diundang atau menjadi anggota space untuk bergabung."</string> <string name="screen_join_room_join_restricted_message">"Anda mungkin perlu diundang atau menjadi anggota space untuk bergabung."</string>
<string name="screen_join_room_knock_action">"Ketuk untuk bergabung"</string> <string name="screen_join_room_knock_action">"Ketuk untuk bergabung"</string>
@@ -1,10 +1,10 @@
<?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="leave_conversation_alert_subtitle">"Czy na pewno chcesz opuścić tę konwersację? Konwersacja nie jest publiczna i nie będziesz mógł dołączyć ponownie bez zaproszenia."</string> <string name="leave_conversation_alert_subtitle">"Czy na pewno chcesz opuścić tę konwersację? Konwersacja nie jest publiczna i nie będziesz mógł dołączyć ponownie bez zaproszenia."</string>
<string name="leave_room_alert_empty_subtitle">"Jesteś pewien, że chcesz opuścić ten pokój? Jesteś tu jedyną osobą. Jeśli wyjdziesz, nikt nie będzie mógł dołączyć, w tym Ty."</string> <string name="leave_room_alert_empty_subtitle">"Czy na pewno chcesz opuścić ten pokój? Jesteś tu jedyną osobą. Jeśli wyjdziesz, nikt nie będzie mógł dołączyć, w tym Ty."</string>
<string name="leave_room_alert_private_subtitle">"Czy na pewno chcesz opuścić ten pokój? Ten pokój nie jest publiczny i nie będziesz mógł do niego wrócić bez zaproszenia."</string> <string name="leave_room_alert_private_subtitle">"Czy na pewno chcesz opuścić ten pokój? Ten pokój nie jest publiczny i nie będziesz mógł do niego wrócić bez zaproszenia."</string>
<string name="leave_room_alert_select_new_owner_action">"Wybierz właścicieli"</string> <string name="leave_room_alert_select_new_owner_action">"Wybierz właścicieli"</string>
<string name="leave_room_alert_select_new_owner_subtitle">"Jesteś jedynym właścicielem tego pokoju. Musisz przenieść własność na kogoś innego, zanim go opuścisz."</string> <string name="leave_room_alert_select_new_owner_subtitle">"Jesteś jedynym właścicielem tego pokoju. Musisz przenieść własność na kogoś innego, zanim go opuścisz."</string>
<string name="leave_room_alert_select_new_owner_title">"Przenieś własność"</string> <string name="leave_room_alert_select_new_owner_title">"Przenieś własność"</string>
<string name="leave_room_alert_subtitle">"Jesteś pewien, że chcesz wyjść z tego pokoju?"</string> <string name="leave_room_alert_subtitle">"Czy na pewno chcesz wyjść z tego pokoju?"</string>
</resources> </resources>
@@ -9,6 +9,7 @@
package io.element.android.features.linknewdevice.impl.screens.scan package io.element.android.features.linknewdevice.impl.screens.scan
import android.os.Build.VERSION_CODES.UPSIDE_DOWN_CAKE
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.ui.platform.LocalInspectionMode import androidx.compose.ui.platform.LocalInspectionMode
@@ -25,9 +26,11 @@ import io.element.android.tests.testutils.ensureCalledOnce
import io.element.android.tests.testutils.pressBackKey import io.element.android.tests.testutils.pressBackKey
import io.element.android.tests.testutils.robolectric.RobolectricTest import io.element.android.tests.testutils.robolectric.RobolectricTest
import org.junit.Test import org.junit.Test
import org.robolectric.annotation.Config
class ScanQrCodeViewTest : RobolectricTest() { class ScanQrCodeViewTest : RobolectricTest() {
@Test @Test
@Config(sdk = [UPSIDE_DOWN_CAKE])
fun `on back pressed - calls the expected callback`() = runAndroidComposeUiTest { fun `on back pressed - calls the expected callback`() = runAndroidComposeUiTest {
val eventRecorder = EventsRecorder<ScanQrCodeEvent>(expectEvents = false) val eventRecorder = EventsRecorder<ScanQrCodeEvent>(expectEvents = false)
ensureCalledOnce { callback -> ensureCalledOnce { callback ->
@@ -42,6 +45,7 @@ class ScanQrCodeViewTest : RobolectricTest() {
} }
@Test @Test
@Config(sdk = [UPSIDE_DOWN_CAKE])
fun `try again button clicked - emits the expected event`() = runAndroidComposeUiTest { fun `try again button clicked - emits the expected event`() = runAndroidComposeUiTest {
val eventRecorder = EventsRecorder<ScanQrCodeEvent>() val eventRecorder = EventsRecorder<ScanQrCodeEvent>()
setView( setView(
@@ -30,7 +30,7 @@ import androidx.compose.material3.SheetValue
import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.rememberBottomSheetScaffoldState import androidx.compose.material3.rememberBottomSheetScaffoldState
import androidx.compose.material3.rememberStandardBottomSheetState import androidx.compose.material3.rememberBottomSheetState
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.derivedStateOf
@@ -86,7 +86,9 @@ fun MapBottomSheetScaffold(
customMapStyleUrl: AsyncData<String?>, customMapStyleUrl: AsyncData<String?>,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
scaffoldState: BottomSheetScaffoldState = rememberBottomSheetScaffoldState( scaffoldState: BottomSheetScaffoldState = rememberBottomSheetScaffoldState(
bottomSheetState = rememberStandardBottomSheetState(initialValue = SheetValue.PartiallyExpanded) bottomSheetState = rememberBottomSheetState(
initialValue = SheetValue.PartiallyExpanded,
)
), ),
cameraState: CameraState = rememberCameraState(), cameraState: CameraState = rememberCameraState(),
mapOptions: MapOptions = MapDefaults.options, mapOptions: MapOptions = MapDefaults.options,
@@ -20,7 +20,7 @@ import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.SheetValue import androidx.compose.material3.SheetValue
import androidx.compose.material3.rememberBottomSheetScaffoldState import androidx.compose.material3.rememberBottomSheetScaffoldState
import androidx.compose.material3.rememberStandardBottomSheetState import androidx.compose.material3.rememberBottomSheetState
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
@@ -105,7 +105,9 @@ fun ShareLocationView(
} }
val scaffoldState = rememberBottomSheetScaffoldState( val scaffoldState = rememberBottomSheetScaffoldState(
bottomSheetState = rememberStandardBottomSheetState(initialValue = SheetValue.Expanded) bottomSheetState = rememberBottomSheetState(
initialValue = SheetValue.Expanded,
)
) )
val cameraState = rememberCameraState(firstPosition = MapDefaults.defaultCameraPosition) val cameraState = rememberCameraState(firstPosition = MapDefaults.defaultCameraPosition)
@@ -21,7 +21,7 @@ import androidx.compose.material3.BottomSheetDefaults
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.SheetValue import androidx.compose.material3.SheetValue
import androidx.compose.material3.rememberBottomSheetScaffoldState import androidx.compose.material3.rememberBottomSheetScaffoldState
import androidx.compose.material3.rememberStandardBottomSheetState import androidx.compose.material3.rememberBottomSheetState
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
@@ -92,7 +92,9 @@ fun ShowLocationView(
} }
val scaffoldState = rememberBottomSheetScaffoldState( val scaffoldState = rememberBottomSheetScaffoldState(
bottomSheetState = rememberStandardBottomSheetState(SheetValue.Expanded) bottomSheetState = rememberBottomSheetState(
initialValue = SheetValue.Expanded,
)
) )
LaunchedEffect(state.isSheetDraggable) { LaunchedEffect(state.isSheetDraggable) {
if (!state.isSheetDraggable) { if (!state.isSheetDraggable) {
@@ -23,7 +23,7 @@ Pilih sesuatu yang mudah untuk diingat. Jika Anda lupa PIN ini, Anda akan dikelu
<string name="screen_app_lock_setup_pin_mismatch_dialog_content">"Silakan masukkan PIN yang sama dua kali"</string> <string name="screen_app_lock_setup_pin_mismatch_dialog_content">"Silakan masukkan PIN yang sama dua kali"</string>
<string name="screen_app_lock_setup_pin_mismatch_dialog_title">"PIN tidak cocok"</string> <string name="screen_app_lock_setup_pin_mismatch_dialog_title">"PIN tidak cocok"</string>
<string name="screen_app_lock_signout_alert_message">"Anda harus masuk ulang dan membuat PIN baru untuk melanjutkan"</string> <string name="screen_app_lock_signout_alert_message">"Anda harus masuk ulang dan membuat PIN baru untuk melanjutkan"</string>
<string name="screen_app_lock_signout_alert_title">"Anda sedang dikeluarkan"</string> <string name="screen_app_lock_signout_alert_title">"Perangkat ini sedang dilepas"</string>
<plurals name="screen_app_lock_subtitle"> <plurals name="screen_app_lock_subtitle">
<item quantity="other">"Anda memiliki %1$d percobaan lagi untuk membuka kunci"</item> <item quantity="other">"Anda memiliki %1$d percobaan lagi untuk membuka kunci"</item>
</plurals> </plurals>
@@ -23,7 +23,7 @@ Escolha algo memorável. Se você esquecer este PIN, você será desconectado do
<string name="screen_app_lock_setup_pin_mismatch_dialog_content">"Por favor, digite o mesmo PIN duas vezes"</string> <string name="screen_app_lock_setup_pin_mismatch_dialog_content">"Por favor, digite o mesmo PIN duas vezes"</string>
<string name="screen_app_lock_setup_pin_mismatch_dialog_title">"Os PINs não correspondem"</string> <string name="screen_app_lock_setup_pin_mismatch_dialog_title">"Os PINs não correspondem"</string>
<string name="screen_app_lock_signout_alert_message">"Você terá que entrar novamente e criar um PIN novo para continuar"</string> <string name="screen_app_lock_signout_alert_message">"Você terá que entrar novamente e criar um PIN novo para continuar"</string>
<string name="screen_app_lock_signout_alert_title">"Você está sendo desconectado"</string> <string name="screen_app_lock_signout_alert_title">"Este dispositivo está sendo removido"</string>
<plurals name="screen_app_lock_subtitle"> <plurals name="screen_app_lock_subtitle">
<item quantity="one">"Você tem %1$d tentativa de desbloqueio"</item> <item quantity="one">"Você tem %1$d tentativa de desbloqueio"</item>
<item quantity="other">"Você tem %1$d tentativas de desbloqueio"</item> <item quantity="other">"Você tem %1$d tentativas de desbloqueio"</item>
@@ -34,5 +34,5 @@ Escolha algo memorável. Se você esquecer este PIN, você será desconectado do
</plurals> </plurals>
<string name="screen_app_lock_use_biometric_android">"Usar biometria"</string> <string name="screen_app_lock_use_biometric_android">"Usar biometria"</string>
<string name="screen_app_lock_use_pin_android">"Usar PIN"</string> <string name="screen_app_lock_use_pin_android">"Usar PIN"</string>
<string name="screen_signout_in_progress_dialog_content">"Saindo…"</string> <string name="screen_signout_in_progress_dialog_content">"Removendo dispositivo…"</string>
</resources> </resources>
@@ -15,15 +15,15 @@
<string name="screen_app_lock_setup_biometric_unlock_subtitle">"Заощаджуйте час і використовуйте %1$s для розблокування застосунку щоразу"</string> <string name="screen_app_lock_setup_biometric_unlock_subtitle">"Заощаджуйте час і використовуйте %1$s для розблокування застосунку щоразу"</string>
<string name="screen_app_lock_setup_choose_pin">"Виберіть PIN-код"</string> <string name="screen_app_lock_setup_choose_pin">"Виберіть PIN-код"</string>
<string name="screen_app_lock_setup_confirm_pin">"Підтвердити PIN-код"</string> <string name="screen_app_lock_setup_confirm_pin">"Підтвердити PIN-код"</string>
<string name="screen_app_lock_setup_pin_context">"Заблокуйте %1$s, щоб додати додаткову безпеку вашим чатам. <string name="screen_app_lock_setup_pin_context">"Додаткове блокування %1$s поліпшить захист ваших бесід.
Виберіть щось, що запам\'ятовується. Але якщо ви забудете PIN-код, ви вийдете з застосунку."</string> Виберіть щось, що запам’ятовується. Але якщо ви забудете PIN-код, ви вийдете із застосунку."</string>
<string name="screen_app_lock_setup_pin_forbidden_dialog_content">"Ви не можете вибрати його своїм PIN-кодом з міркувань безпеки"</string> <string name="screen_app_lock_setup_pin_forbidden_dialog_content">"Ви не можете вибрати його своїм PIN-кодом з міркувань безпеки"</string>
<string name="screen_app_lock_setup_pin_forbidden_dialog_title">"Виберіть інший PIN-код"</string> <string name="screen_app_lock_setup_pin_forbidden_dialog_title">"Виберіть інший PIN-код"</string>
<string name="screen_app_lock_setup_pin_mismatch_dialog_content">"Будь ласка, введіть один і той самий PIN-код двічі"</string> <string name="screen_app_lock_setup_pin_mismatch_dialog_content">"Будь ласка, введіть один і той самий PIN-код двічі"</string>
<string name="screen_app_lock_setup_pin_mismatch_dialog_title">"PIN-коди не збігаються"</string> <string name="screen_app_lock_setup_pin_mismatch_dialog_title">"PIN-коди не збігаються"</string>
<string name="screen_app_lock_signout_alert_message">"Щоб продовжити, вам потрібно повторно ввійти та створити новий PIN-код"</string> <string name="screen_app_lock_signout_alert_message">"Щоб продовжити, вам потрібно повторно ввійти та створити новий PIN-код"</string>
<string name="screen_app_lock_signout_alert_title">"Ви виходите з системи"</string> <string name="screen_app_lock_signout_alert_title">"Цей пристрій вилучається"</string>
<plurals name="screen_app_lock_subtitle"> <plurals name="screen_app_lock_subtitle">
<item quantity="one">"Ви маєте %1$d спробу"</item> <item quantity="one">"Ви маєте %1$d спробу"</item>
<item quantity="few">"Ви маєте %1$d спроби"</item> <item quantity="few">"Ви маєте %1$d спроби"</item>
@@ -30,7 +30,6 @@ import androidx.compose.ui.res.stringResource
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
import androidx.compose.ui.unit.sp
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
import io.element.android.features.login.impl.R import io.element.android.features.login.impl.R
@@ -55,11 +54,9 @@ import io.element.android.libraries.testtags.TestTags
import io.element.android.libraries.testtags.testTag import io.element.android.libraries.testtags.testTag
import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.libraries.ui.strings.CommonStrings
// Refs: /**
// FTUE: * Ref: https://www.figma.com/design/pDlJZGBsri47FNTXMnEdXB/Compound-Android-Templates?node-id=41-6503
// - https://www.figma.com/file/o9p34zmiuEpZRyvZXJZAYL/FTUE?type=design&node-id=133-5427&t=5SHVppfYzjvkEywR-0 */
// ElementX:
// - https://www.figma.com/file/0MMNu7cTOzLOlWb7ctTkv3/Element-X?type=design&node-id=1816-97419
@Composable @Composable
fun OnBoardingView( fun OnBoardingView(
state: OnBoardingState, state: OnBoardingState,
@@ -223,14 +220,14 @@ private fun OnBoardingContent(state: OnBoardingState) {
text = stringResource(id = R.string.screen_onboarding_welcome_title), text = stringResource(id = R.string.screen_onboarding_welcome_title),
color = ElementTheme.colors.textPrimary, color = ElementTheme.colors.textPrimary,
style = ElementTheme.typography.fontHeadingLgBold, style = ElementTheme.typography.fontHeadingLgBold,
textAlign = TextAlign.Center textAlign = TextAlign.Center,
) )
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(8.dp))
Text( Text(
text = stringResource(id = R.string.screen_onboarding_welcome_message, state.productionApplicationName), text = stringResource(id = R.string.screen_onboarding_welcome_message, state.productionApplicationName),
color = ElementTheme.colors.textSecondary, color = ElementTheme.colors.textPrimary,
style = ElementTheme.typography.fontBodyLgRegular.copy(fontSize = 17.sp), style = ElementTheme.typography.fontBodyLgRegular,
textAlign = TextAlign.Center textAlign = TextAlign.Center,
) )
} }
} }
@@ -45,6 +45,7 @@
<string name="screen_missing_key_backup_step_5">"Επιστρέψτε στο %1$s"</string> <string name="screen_missing_key_backup_step_5">"Επιστρέψτε στο %1$s"</string>
<string name="screen_missing_key_backup_title">"Ενεργοποιήστε την αποθήκευση κλειδιών σας πριν προχωρήσετε στο %1$s"</string> <string name="screen_missing_key_backup_title">"Ενεργοποιήστε την αποθήκευση κλειδιών σας πριν προχωρήσετε στο %1$s"</string>
<string name="screen_onboarding_app_version">"Έκδοση %1$s"</string> <string name="screen_onboarding_app_version">"Έκδοση %1$s"</string>
<string name="screen_onboarding_checking_account">"Έλεγχος λογαριασμού"</string>
<string name="screen_onboarding_sign_in_manually">"Σύνδεση χειροκίνητα"</string> <string name="screen_onboarding_sign_in_manually">"Σύνδεση χειροκίνητα"</string>
<string name="screen_onboarding_sign_in_to">"Συνδέσου στο %1$s"</string> <string name="screen_onboarding_sign_in_to">"Συνδέσου στο %1$s"</string>
<string name="screen_onboarding_sign_in_with_qr_code">"Συνδέσου με κωδικό QR"</string> <string name="screen_onboarding_sign_in_with_qr_code">"Συνδέσου με κωδικό QR"</string>
@@ -28,7 +28,7 @@
<string name="screen_change_server_subtitle">"Qual é o endereço do seu servidor?"</string> <string name="screen_change_server_subtitle">"Qual é o endereço do seu servidor?"</string>
<string name="screen_change_server_title">"Selecione seu servidor"</string> <string name="screen_change_server_title">"Selecione seu servidor"</string>
<string name="screen_create_account_title">"Criar conta"</string> <string name="screen_create_account_title">"Criar conta"</string>
<string name="screen_login_error_deactivated_account">"Essa conta foi desativada."</string> <string name="screen_login_error_deactivated_account">"Essa conta foi apagada."</string>
<string name="screen_login_error_invalid_credentials">"Nome de usuário e/ou senha incorretos"</string> <string name="screen_login_error_invalid_credentials">"Nome de usuário e/ou senha incorretos"</string>
<string name="screen_login_error_invalid_user_id">"Esse não é um identificador de usuário válido. Formato esperado: \'@usuário:servidor.org\'"</string> <string name="screen_login_error_invalid_user_id">"Esse não é um identificador de usuário válido. Formato esperado: \'@usuário:servidor.org\'"</string>
<string name="screen_login_error_refresh_tokens">"Este servidor está configurado para usar tokens recarregados. Não há suporte a eles ao entrar por uma senha."</string> <string name="screen_login_error_refresh_tokens">"Este servidor está configurado para usar tokens recarregados. Não há suporte a eles ao entrar por uma senha."</string>
@@ -51,9 +51,9 @@
<string name="screen_onboarding_sign_in_with_qr_code">"Увійти за допомогою QR-коду"</string> <string name="screen_onboarding_sign_in_with_qr_code">"Увійти за допомогою QR-коду"</string>
<string name="screen_onboarding_sign_up">"Створити обліковий запис"</string> <string name="screen_onboarding_sign_up">"Створити обліковий запис"</string>
<string name="screen_onboarding_welcome_back">"З поверненням!"</string> <string name="screen_onboarding_welcome_back">"З поверненням!"</string>
<string name="screen_onboarding_welcome_message">"Ласкаво просимо до найшвидшого %1$s. Заряджений для швидкості та простоти."</string> <string name="screen_onboarding_welcome_message">"Вітаємо в %1$s. Сфокусованому на швидкості та простоті."</string>
<string name="screen_onboarding_welcome_subtitle">"Ласкаво просимо до %1$s. Заряджений, для швидкості та простоти."</string> <string name="screen_onboarding_welcome_subtitle">"Вітаємо у %1$s. Сфокусованому на швидкості та простоті."</string>
<string name="screen_onboarding_welcome_title">"Будьте у своєму element"</string> <string name="screen_onboarding_welcome_title">"Спілкуйтесь елементарно"</string>
<string name="screen_qr_code_login_connecting_subtitle">"Встановлення безпечного з\'єднання"</string> <string name="screen_qr_code_login_connecting_subtitle">"Встановлення безпечного з\'єднання"</string>
<string name="screen_qr_code_login_connection_note_secure_state_description">"Не вдалося встановити безпечне з\'єднання з новим пристроєм. Ваші наявні пристрої досі в безпеці, і вам не потрібно про них турбуватися."</string> <string name="screen_qr_code_login_connection_note_secure_state_description">"Не вдалося встановити безпечне з\'єднання з новим пристроєм. Ваші наявні пристрої досі в безпеці, і вам не потрібно про них турбуватися."</string>
<string name="screen_qr_code_login_connection_note_secure_state_list_header">"Що тепер?"</string> <string name="screen_qr_code_login_connection_note_secure_state_list_header">"Що тепер?"</string>
@@ -5,14 +5,14 @@
<string name="screen_signout_confirmation_dialog_title">"Hapus device dari akun"</string> <string name="screen_signout_confirmation_dialog_title">"Hapus device dari akun"</string>
<string name="screen_signout_in_progress_dialog_content">"Mengeluarkan device dari akun…"</string> <string name="screen_signout_in_progress_dialog_content">"Mengeluarkan device dari akun…"</string>
<string name="screen_signout_key_backup_disabled_subtitle">"Anda akan keluar dari sesi terakhir Anda. Jika Anda keluar sekarang, Anda akan kehilangan akses ke pesan terenkripsi Anda."</string> <string name="screen_signout_key_backup_disabled_subtitle">"Anda akan keluar dari sesi terakhir Anda. Jika Anda keluar sekarang, Anda akan kehilangan akses ke pesan terenkripsi Anda."</string>
<string name="screen_signout_key_backup_disabled_title">"Anda telah menonaktifkan pencadangan"</string> <string name="screen_signout_key_backup_disabled_title">"Anda akan segera kehilangan akses ke obrolan terenkripsi Anda."</string>
<string name="screen_signout_key_backup_offline_subtitle">"Kunci Anda masih dicadangkan saat Anda luring. Sambungkan kembali sehingga kunci Anda dapat dicadangkan sebelum keluar."</string> <string name="screen_signout_key_backup_offline_subtitle">"Kunci Anda masih dalam proses pencadangan saat Anda offline. Sambungkan kembali agar kunci Anda dapat dicadangkan sebelum Anda menghapus perangkat ini."</string>
<string name="screen_signout_key_backup_offline_title">"Kunci Anda masih dicadangkan"</string> <string name="screen_signout_key_backup_offline_title">"Kunci Anda masih dicadangkan"</string>
<string name="screen_signout_key_backup_ongoing_subtitle">"Mohon tunggu hingga proses ini selesai sebelum keluar."</string> <string name="screen_signout_key_backup_ongoing_subtitle">"Harap tunggu hingga proses ini selesai sebelum melepas perangkat ini."</string>
<string name="screen_signout_key_backup_ongoing_title">"Kunci Anda masih dicadangkan"</string> <string name="screen_signout_key_backup_ongoing_title">"Kunci Anda masih dicadangkan"</string>
<string name="screen_signout_preference_item">"Hapus device dari akun"</string> <string name="screen_signout_preference_item">"Hapus device dari akun"</string>
<string name="screen_signout_recovery_disabled_subtitle">"Anda akan keluar dari sesi Anda yang terakhir. Jika Anda keluar sekarang, Anda akan kehilangan akses ke pesan terenkripsi Anda."</string> <string name="screen_signout_recovery_disabled_subtitle">"Anda akan keluar dari sesi Anda yang terakhir. Jika Anda keluar sekarang, Anda akan kehilangan akses ke pesan terenkripsi Anda."</string>
<string name="screen_signout_recovery_disabled_title">"Pemulihan belum disiapkan"</string> <string name="screen_signout_recovery_disabled_title">"Anda akan segera kehilangan akses ke obrolan terenkripsi Anda."</string>
<string name="screen_signout_save_recovery_key_subtitle">"Anda akan keluar dari sesi terakhir Anda. Jika Anda keluar sekarang, Anda mungkin kehilangan akses ke pesan terenkripsi Anda."</string> <string name="screen_signout_save_recovery_key_subtitle">"Anda akan keluar dari sesi terakhir Anda. Jika Anda keluar sekarang, Anda mungkin kehilangan akses ke pesan terenkripsi Anda."</string>
<string name="screen_signout_save_recovery_key_title">"Apakah Anda sudah menyimpan kunci pemulihan Anda?"</string> <string name="screen_signout_save_recovery_key_title">"Pastikan Anda memiliki akses ke kunci pemulihan sebelum mencabut perangkat ini."</string>
</resources> </resources>
@@ -1,18 +1,18 @@
<?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_signout_confirmation_dialog_content">"Você tem certeza que deseja sair?"</string> <string name="screen_signout_confirmation_dialog_content">"Você tem certeza que deseja remover este dispositivo?"</string>
<string name="screen_signout_confirmation_dialog_submit">"Sair"</string> <string name="screen_signout_confirmation_dialog_submit">"Remover este dispositivo"</string>
<string name="screen_signout_confirmation_dialog_title">"Sair"</string> <string name="screen_signout_confirmation_dialog_title">"Remover este dispositivo"</string>
<string name="screen_signout_in_progress_dialog_content">"Saindo…"</string> <string name="screen_signout_in_progress_dialog_content">"Removendo dispositivo…"</string>
<string name="screen_signout_key_backup_disabled_subtitle">"Você está prestes a sair da sua última sessão. Se você sair agora, perderá o acesso às suas mensagens criptografadas."</string> <string name="screen_signout_key_backup_disabled_subtitle">"Você está prestes a sair da sua última sessão. Se você sair agora, perderá o acesso às suas mensagens criptografadas."</string>
<string name="screen_signout_key_backup_disabled_title">"Você desativou o backup"</string> <string name="screen_signout_key_backup_disabled_title">"Você desativou o backup"</string>
<string name="screen_signout_key_backup_offline_subtitle">"O backup das suas chaves ainda estava sendo feito quando você ficou off-line. Reconecte-se para que você possa fazer o backup de suas chaves antes de sair."</string> <string name="screen_signout_key_backup_offline_subtitle">"O backup das suas chaves ainda estava sendo feito quando você ficou off-line. Reconecte-se para que você possa fazer o backup de suas chaves antes de sair."</string>
<string name="screen_signout_key_backup_offline_title">"O backup das suas chaves ainda está em andamento"</string> <string name="screen_signout_key_backup_offline_title">"O backup das suas chaves ainda está em andamento"</string>
<string name="screen_signout_key_backup_ongoing_subtitle">"Aguarde até que isso seja concluído antes de sair."</string> <string name="screen_signout_key_backup_ongoing_subtitle">"Aguarde até que isso seja concluído antes de sair."</string>
<string name="screen_signout_key_backup_ongoing_title">"O backup das suas chaves ainda está em andamento"</string> <string name="screen_signout_key_backup_ongoing_title">"O backup das suas chaves ainda está em andamento"</string>
<string name="screen_signout_preference_item">"Sair"</string> <string name="screen_signout_preference_item">"Remover este dispositivo"</string>
<string name="screen_signout_recovery_disabled_subtitle">"Você está prestes a sair da sua última sessão. Se você sair agora, perderá o acesso às suas mensagens criptografadas."</string> <string name="screen_signout_recovery_disabled_subtitle">"Você está prestes a sair da sua última sessão. Se você sair agora, perderá o acesso às suas mensagens criptografadas."</string>
<string name="screen_signout_recovery_disabled_title">"A recuperação não está configurada"</string> <string name="screen_signout_recovery_disabled_title">"Você está prestes a perder o acesso às suas conversas criptografadas"</string>
<string name="screen_signout_save_recovery_key_subtitle">"Você está prestes a sair da sua última sessão. Se você sair agora, poderá perder o acesso às suas mensagens criptografadas."</string> <string name="screen_signout_save_recovery_key_subtitle">"Você está prestes a sair da sua última sessão. Se você sair agora, poderá perder o acesso às suas mensagens criptografadas."</string>
<string name="screen_signout_save_recovery_key_title">"Você salvou sua chave de recuperação?"</string> <string name="screen_signout_save_recovery_key_title">"Você salvou sua chave de recuperação?"</string>
</resources> </resources>
@@ -1,18 +1,18 @@
<?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_signout_confirmation_dialog_content">"Ви впевнені, що хочете видалити цей пристрій?"</string> <string name="screen_signout_confirmation_dialog_content">"Ви впевнені, що хочете видалити цей пристрій?"</string>
<string name="screen_signout_confirmation_dialog_submit">"Вийти"</string> <string name="screen_signout_confirmation_dialog_submit">"Вилучити цей пристрій"</string>
<string name="screen_signout_confirmation_dialog_title">"Вийти"</string> <string name="screen_signout_confirmation_dialog_title">"Вилучити цей пристрій"</string>
<string name="screen_signout_in_progress_dialog_content">"Видалення пристрою…"</string> <string name="screen_signout_in_progress_dialog_content">"Видалення пристрою…"</string>
<string name="screen_signout_key_backup_disabled_subtitle">"Ви збираєтеся вийти зі свого останнього сеансу. Якщо ви вийдете зараз, ви втратите доступ до своїх зашифрованих повідомлень."</string> <string name="screen_signout_key_backup_disabled_subtitle">"Це ваш єдиний пристрій. Якщо ви його вилучите, вам знадобиться ключ відновлення, щоб підтвердити свою цифрову ідентичність і відновити зашифровані бесіди під час наступного входу."</string>
<string name="screen_signout_key_backup_disabled_title">"Ви вимкнули резервне копіювання"</string> <string name="screen_signout_key_backup_disabled_title">"Ви втратите доступ до своїх зашифрованих бесід"</string>
<string name="screen_signout_key_backup_offline_subtitle">"Коли ви вийшли з мережі, резервна копія ваших ключів все ще створювалася. Повторно під\'єднайтеся, щоб зберегти резервну копію ключів перед виходом."</string> <string name="screen_signout_key_backup_offline_subtitle">"Резервні копії ваших ключів продовжували створюватися після вашого виходу з мережі. Під\'єднайтеся знову, щоб створити резервні копії ключів, перш ніж вилучати цей пристрій."</string>
<string name="screen_signout_key_backup_offline_title">"Резервне копіювання ваших ключів ще триває"</string> <string name="screen_signout_key_backup_offline_title">"Резервне копіювання ваших ключів ще триває"</string>
<string name="screen_signout_key_backup_ongoing_subtitle">"Дочекайтеся завершення процесу, перш ніж вийти."</string> <string name="screen_signout_key_backup_ongoing_subtitle">"Дочекайтесь завершення, перш ніж вилучити цей пристрій."</string>
<string name="screen_signout_key_backup_ongoing_title">"Резервне копіювання ваших ключів ще триває"</string> <string name="screen_signout_key_backup_ongoing_title">"Резервне копіювання ваших ключів ще триває"</string>
<string name="screen_signout_preference_item">"Вийти"</string> <string name="screen_signout_preference_item">"Вилучити цей пристрій"</string>
<string name="screen_signout_recovery_disabled_subtitle">"Ви збираєтеся вийти зі свого останнього сеансу. Якщо ви вийдете зараз, ви втратите доступ до своїх зашифрованих повідомлень."</string> <string name="screen_signout_recovery_disabled_subtitle">"Це ваш єдиний пристрій. Якщо ви вилучите його, вам знадобиться ключ відновлення, щоб підтвердити свою цифрову ідентичність і відновити зашифровані бесіди під час наступного входу."</string>
<string name="screen_signout_recovery_disabled_title">"Відновлення не налаштовано"</string> <string name="screen_signout_recovery_disabled_title">"Ви втратите доступ до своїх зашифрованих бесід"</string>
<string name="screen_signout_save_recovery_key_subtitle">"Ви збираєтеся вийти зі свого останнього сеансу. Якщо вийти зараз, ви можете втратити доступ до зашифрованих повідомлень."</string> <string name="screen_signout_save_recovery_key_subtitle">"Це ваш єдиний пристрій. Якщо ви його вилучите, вам знадобиться ключ відновлення, щоб підтвердити свою цифрову ідентичність і відновити зашифровані бесіди під час наступного входу."</string>
<string name="screen_signout_save_recovery_key_title">"Ви зберегли ключ відновлення?"</string> <string name="screen_signout_save_recovery_key_title">"Переконайтеся, що у вас є доступ до ключа відновлення, перш ніж вилучити цей пристрій"</string>
</resources> </resources>
@@ -47,9 +47,12 @@ import io.element.android.features.messages.impl.threads.list.ThreadsListNode
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.debug.EventDebugInfoNode import io.element.android.features.messages.impl.timeline.debug.EventDebugInfoNode
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.GalleryItem
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemAttachmentsContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemAudioContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemAudioContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEventContentWithAttachment import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEventContentWithAttachment
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemFileContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemFileContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemGalleryContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemImageContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemImageContent
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
@@ -86,6 +89,8 @@ 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.messages.RoomMemberProfilesCache import io.element.android.libraries.matrix.ui.messages.RoomMemberProfilesCache
import io.element.android.libraries.matrix.ui.messages.RoomNamesCache import io.element.android.libraries.matrix.ui.messages.RoomNamesCache
import io.element.android.libraries.mediaviewer.api.GalleryInfo
import io.element.android.libraries.mediaviewer.api.GalleryItemData
import io.element.android.libraries.mediaviewer.api.MediaInfo import io.element.android.libraries.mediaviewer.api.MediaInfo
import io.element.android.libraries.mediaviewer.api.MediaViewerEntryPoint import io.element.android.libraries.mediaviewer.api.MediaViewerEntryPoint
import io.element.android.libraries.textcomposer.mentions.LocalMentionSpanUpdater import io.element.android.libraries.textcomposer.mentions.LocalMentionSpanUpdater
@@ -153,7 +158,16 @@ class MessagesFlowNode(
) : NavTarget ) : NavTarget
@Parcelize @Parcelize
data class AttachmentPreview(val timelineMode: Timeline.Mode, val attachment: Attachment, val inReplyToEventId: EventId?) : NavTarget data class GalleryViewer(
val fromPinnedMessages: Boolean,
val eventId: EventId?,
val galleryInfo: GalleryInfo,
val canUseOverlay: Boolean,
val galleryItems: List<GalleryItemData> = emptyList(),
) : NavTarget
@Parcelize
data class AttachmentPreview(val timelineMode: Timeline.Mode, val attachments: ImmutableList<Attachment>, val inReplyToEventId: EventId?) : NavTarget
@Parcelize @Parcelize
data class LocationViewer(val mode: ShowLocationMode) : NavTarget data class LocationViewer(val mode: ShowLocationMode) : NavTarget
@@ -239,7 +253,11 @@ class MessagesFlowNode(
callback.navigateToRoomDetails() callback.navigateToRoomDetails()
} }
override fun handleEventClick(timelineMode: Timeline.Mode, event: TimelineItem.Event, canUseOverlay: Boolean): Boolean { override fun handleEventClick(
timelineMode: Timeline.Mode,
event: TimelineItem.Event,
canUseOverlay: Boolean,
): Boolean {
return processEventClick( return processEventClick(
timelineMode = timelineMode, timelineMode = timelineMode,
event = event, event = event,
@@ -247,10 +265,24 @@ class MessagesFlowNode(
) )
} }
override fun handleGalleryItemClick(
timelineMode: Timeline.Mode,
event: TimelineItem.Event,
galleryItemIndex: Int,
canUseOverlay: Boolean,
): Boolean {
return processGalleryEventClick(
timelineMode = timelineMode,
event = event,
canUseOverlay = canUseOverlay,
galleryItemIndex = galleryItemIndex,
)
}
override fun navigateToPreviewAttachments(attachments: ImmutableList<Attachment>, inReplyToEventId: EventId?) { override fun navigateToPreviewAttachments(attachments: ImmutableList<Attachment>, inReplyToEventId: EventId?) {
backstack.push( backstack.push(
NavTarget.AttachmentPreview( NavTarget.AttachmentPreview(
attachment = attachments.first(), attachments = attachments,
timelineMode = Timeline.Mode.Live, timelineMode = Timeline.Mode.Live,
inReplyToEventId = inReplyToEventId, inReplyToEventId = inReplyToEventId,
) )
@@ -339,13 +371,44 @@ class MessagesFlowNode(
createNode<MessagesNode>(buildContext, listOf(callback, inputs)) createNode<MessagesNode>(buildContext, listOf(callback, inputs))
} }
is NavTarget.MediaViewer -> { is NavTarget.MediaViewer -> {
val params = MediaViewerEntryPoint.Params( val params = MediaViewerEntryPoint.Params.RoomMedia(
mode = navTarget.mode, mode = navTarget.mode,
eventId = navTarget.eventId, eventId = navTarget.eventId,
mediaInfo = navTarget.mediaInfo, mediaInfo = navTarget.mediaInfo,
mediaSource = navTarget.mediaSource, mediaSource = navTarget.mediaSource,
thumbnailSource = navTarget.thumbnailSource, thumbnailSource = navTarget.thumbnailSource,
canShowInfo = true, )
val callback = object : MediaViewerEntryPoint.Callback {
override fun onDone() {
if (navTarget.canUseOverlay) {
overlay.hide()
} else {
backstack.pop()
}
}
override fun viewInTimeline(eventId: EventId) {
this@MessagesFlowNode.viewInTimeline(eventId)
}
override fun forwardEvent(eventId: EventId, fromPinnedEvents: Boolean) {
// Need to go to the parent because of the overlay
callback.forwardEvent(eventId, fromPinnedEvents)
}
}
mediaViewerEntryPoint.createNode(
parentNode = this,
buildContext = buildContext,
params = params,
callback = callback
)
}
is NavTarget.GalleryViewer -> {
val params = MediaViewerEntryPoint.Params.EventGallery(
eventId = navTarget.eventId,
galleryInfo = navTarget.galleryInfo,
galleryItems = navTarget.galleryItems,
fromPinnedMessages = navTarget.fromPinnedMessages,
) )
val callback = object : MediaViewerEntryPoint.Callback { val callback = object : MediaViewerEntryPoint.Callback {
override fun onDone() { override fun onDone() {
@@ -374,7 +437,7 @@ class MessagesFlowNode(
} }
is NavTarget.AttachmentPreview -> { is NavTarget.AttachmentPreview -> {
val inputs = AttachmentsPreviewNode.Inputs( val inputs = AttachmentsPreviewNode.Inputs(
attachment = navTarget.attachment, attachments = navTarget.attachments,
timelineMode = navTarget.timelineMode, timelineMode = navTarget.timelineMode,
inReplyToEventId = navTarget.inReplyToEventId, inReplyToEventId = navTarget.inReplyToEventId,
) )
@@ -455,6 +518,19 @@ class MessagesFlowNode(
) )
} }
override fun handleGalleryItemClick(
event: TimelineItem.Event,
galleryItemIndex: Int,
canUseOverlay: Boolean,
) {
processGalleryEventClick(
timelineMode = Timeline.Mode.PinnedEvents,
event = event,
galleryItemIndex = galleryItemIndex,
canUseOverlay = canUseOverlay,
)
}
override fun navigateToRoomMemberDetails(userId: UserId) { override fun navigateToRoomMemberDetails(userId: UserId) {
callback.navigateToRoomMemberDetails(userId) callback.navigateToRoomMemberDetails(userId)
} }
@@ -490,7 +566,11 @@ class MessagesFlowNode(
focusedEventId = navTarget.focusedEventId, focusedEventId = navTarget.focusedEventId,
) )
val callback = object : ThreadedMessagesNode.Callback { val callback = object : ThreadedMessagesNode.Callback {
override fun handleEventClick(timelineMode: Timeline.Mode, event: TimelineItem.Event, canUseOverlay: Boolean): Boolean { override fun handleEventClick(
timelineMode: Timeline.Mode,
event: TimelineItem.Event,
canUseOverlay: Boolean,
): Boolean {
return processEventClick( return processEventClick(
timelineMode = timelineMode, timelineMode = timelineMode,
event = event, event = event,
@@ -498,10 +578,24 @@ class MessagesFlowNode(
) )
} }
override fun handleGalleryItemClick(
timelineMode: Timeline.Mode,
event: TimelineItem.Event,
galleryItemIndex: Int,
canUseOverlay: Boolean,
): Boolean {
return processGalleryEventClick(
timelineMode = timelineMode,
event = event,
canUseOverlay = canUseOverlay,
galleryItemIndex = galleryItemIndex,
)
}
override fun navigateToPreviewAttachments(attachments: ImmutableList<Attachment>, inReplyToEventId: EventId?) { override fun navigateToPreviewAttachments(attachments: ImmutableList<Attachment>, inReplyToEventId: EventId?) {
backstack.push( backstack.push(
NavTarget.AttachmentPreview( NavTarget.AttachmentPreview(
attachment = attachments.first(), attachments = attachments,
timelineMode = Timeline.Mode.Thread(navTarget.threadRootId), timelineMode = Timeline.Mode.Thread(navTarget.threadRootId),
inReplyToEventId = inReplyToEventId, inReplyToEventId = inReplyToEventId,
) )
@@ -704,6 +798,92 @@ class MessagesFlowNode(
} }
} }
private fun processGalleryEventClick(
timelineMode: Timeline.Mode,
event: TimelineItem.Event,
galleryItemIndex: Int,
canUseOverlay: Boolean,
): Boolean {
val navTarget = when (event.content) {
is TimelineItemGalleryContent -> {
val galleryInfo = GalleryInfo(
caption = event.content.caption,
senderId = event.senderId,
senderName = event.safeSenderName,
senderAvatar = event.senderAvatar.url,
dateSent = dateFormatter.format(
event.sentTimeMillis,
mode = DateFormatterMode.Day,
),
dateSentFull = dateFormatter.format(
timestamp = event.sentTimeMillis,
mode = DateFormatterMode.Full,
),
initialIndex = galleryItemIndex,
)
val galleryItems = event.content.items.map { galleryItem ->
GalleryItemData(
filename = galleryItem.filename,
mimeType = galleryItem.mimeType,
mediaSource = galleryItem.mediaSource,
thumbnailSource = galleryItem.thumbnailSource,
type = galleryItem.type.toMediaViewerType(),
)
}.reversed()
NavTarget.GalleryViewer(
eventId = event.eventId,
galleryInfo = galleryInfo,
canUseOverlay = canUseOverlay,
galleryItems = galleryItems,
fromPinnedMessages = timelineMode is Timeline.Mode.PinnedEvents
)
}
is TimelineItemAttachmentsContent -> {
val galleryInfo = GalleryInfo(
caption = event.content.caption,
senderId = event.senderId,
senderName = event.safeSenderName,
senderAvatar = event.senderAvatar.url,
dateSent = dateFormatter.format(
event.sentTimeMillis,
mode = DateFormatterMode.Day,
),
dateSentFull = dateFormatter.format(
timestamp = event.sentTimeMillis,
mode = DateFormatterMode.Full,
),
initialIndex = galleryItemIndex,
)
val galleryItems = event.content.attachments.map { attachment ->
GalleryItemData(
filename = attachment.filename,
mimeType = attachment.mimeType,
mediaSource = attachment.mediaSource,
thumbnailSource = attachment.thumbnailSource,
type = GalleryItemData.Type.File,
)
}.reversed()
NavTarget.GalleryViewer(
eventId = event.eventId,
galleryInfo = galleryInfo,
canUseOverlay = canUseOverlay,
galleryItems = galleryItems,
fromPinnedMessages = timelineMode is Timeline.Mode.PinnedEvents
)
}
else -> null
}
if (navTarget != null) {
if (canUseOverlay) {
overlay.show(navTarget)
} else {
backstack.push(navTarget)
}
return true
}
return false
}
private fun buildMediaViewerNavTarget( private fun buildMediaViewerNavTarget(
mode: MediaViewerEntryPoint.MediaViewerMode, mode: MediaViewerEntryPoint.MediaViewerMode,
event: TimelineItem.Event, event: TimelineItem.Event,
@@ -768,3 +948,12 @@ class MessagesFlowNode(
} }
} }
} }
private fun GalleryItem.Type.toMediaViewerType(): GalleryItemData.Type {
return when (this) {
GalleryItem.Type.Image -> GalleryItemData.Type.Image
GalleryItem.Type.Video -> GalleryItemData.Type.Video
GalleryItem.Type.Audio -> GalleryItemData.Type.Audio
GalleryItem.Type.File -> GalleryItemData.Type.File
}
}
@@ -119,6 +119,7 @@ class MessagesNode(
interface Callback : Plugin { interface Callback : Plugin {
fun handleEventClick(timelineMode: Timeline.Mode, event: TimelineItem.Event, canUseOverlay: Boolean): Boolean fun handleEventClick(timelineMode: Timeline.Mode, event: TimelineItem.Event, canUseOverlay: Boolean): Boolean
fun handleGalleryItemClick(timelineMode: Timeline.Mode, event: TimelineItem.Event, galleryItemIndex: Int, canUseOverlay: Boolean): Boolean
fun navigateToPreviewAttachments(attachments: ImmutableList<Attachment>, inReplyToEventId: EventId?) fun navigateToPreviewAttachments(attachments: ImmutableList<Attachment>, inReplyToEventId: EventId?)
fun navigateToRoomMemberDetails(userId: UserId) fun navigateToRoomMemberDetails(userId: UserId)
fun handlePermalinkClick(data: PermalinkData) fun handlePermalinkClick(data: PermalinkData)
@@ -289,6 +290,18 @@ class MessagesNode(
} }
} }
}, },
onGalleryEventItemClick = { isLive, event, index ->
if (isLive) {
callback.handleGalleryItemClick(timelineController.mainTimelineMode(), event, index, canUseOverlay)
} else {
val detachedTimelineMode = timelineController.detachedTimelineMode()
if (detachedTimelineMode != null) {
callback.handleGalleryItemClick(detachedTimelineMode, event, index, canUseOverlay)
} else {
false
}
}
},
onUserDataClick = callback::navigateToRoomMemberDetails, onUserDataClick = callback::navigateToRoomMemberDetails,
onLinkClick = { url, customTab -> onLinkClick = { url, customTab ->
onLinkClick( onLinkClick(
@@ -47,10 +47,10 @@ import io.element.android.features.messages.impl.timeline.components.reactionsum
import io.element.android.features.messages.impl.timeline.components.receipt.bottomsheet.ReadReceiptBottomSheetState import io.element.android.features.messages.impl.timeline.components.receipt.bottomsheet.ReadReceiptBottomSheetState
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.TimelineItemThreadInfo import io.element.android.features.messages.impl.timeline.model.TimelineItemThreadInfo
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEventContentWithAttachment
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.model.event.TimelineItemStateContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemStateContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemTextBasedContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemTextBasedContent
import io.element.android.features.messages.impl.timeline.model.event.captionOrNull
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.voicemessages.composer.DefaultVoiceMessageComposerPresenter import io.element.android.features.messages.impl.voicemessages.composer.DefaultVoiceMessageComposerPresenter
import io.element.android.features.roomcall.api.RoomCallState import io.element.android.features.roomcall.api.RoomCallState
@@ -533,7 +533,7 @@ class MessagesPresenter(
) { ) {
val composerMode = MessageComposerMode.EditCaption( val composerMode = MessageComposerMode.EditCaption(
eventOrTransactionId = targetEvent.eventOrTransactionId, eventOrTransactionId = targetEvent.eventOrTransactionId,
content = (targetEvent.content as? TimelineItemEventContentWithAttachment)?.caption.orEmpty(), content = targetEvent.content.captionOrNull().orEmpty(),
) )
composerState.eventSink( composerState.eventSink(
MessageComposerEvent.SetMode(composerMode) MessageComposerEvent.SetMode(composerMode)
@@ -606,7 +606,7 @@ class MessagesPresenter(
} }
private fun handleCopyCaption(event: TimelineItem.Event) { private fun handleCopyCaption(event: TimelineItem.Event) {
val content = (event.content as? TimelineItemEventContentWithAttachment)?.caption ?: return val content = event.content.captionOrNull() ?: return
clipboardHelper.copyPlainText(content) clipboardHelper.copyPlainText(content)
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) { if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) {
snackbarDispatcher.post(SnackbarMessage(CommonStrings.common_copied_to_clipboard)) snackbarDispatcher.post(SnackbarMessage(CommonStrings.common_copied_to_clipboard))
@@ -136,6 +136,7 @@ fun MessagesView(
onBackClick: () -> Unit, onBackClick: () -> Unit,
onRoomDetailsClick: () -> Unit, onRoomDetailsClick: () -> Unit,
onEventContentClick: (isLive: Boolean, event: TimelineItem.Event) -> Boolean, onEventContentClick: (isLive: Boolean, event: TimelineItem.Event) -> Boolean,
onGalleryEventItemClick: (isLive: Boolean, event: TimelineItem.Event, index: Int) -> Boolean,
onUserDataClick: (UserId) -> Unit, onUserDataClick: (UserId) -> Unit,
onLinkClick: (String, Boolean) -> Unit, onLinkClick: (String, Boolean) -> Unit,
onSendLocationClick: () -> Unit, onSendLocationClick: () -> Unit,
@@ -207,15 +208,15 @@ fun MessagesView(
val expandableState = rememberExpandableBottomSheetLayoutState() val expandableState = rememberExpandableBottomSheetLayoutState()
ExpandableBottomSheetLayout( ExpandableBottomSheetLayout(
modifier = modifier modifier = modifier
.fillMaxSize() .fillMaxSize()
.imePadding() .imePadding()
.systemBarsPadding() .systemBarsPadding()
.onSizeChanged { size -> .onSizeChanged { size ->
// Let the composer takes at max half of the available height. // Let the composer takes at max half of the available height.
// The value will be different if the soft keyboard is displayed // The value will be different if the soft keyboard is displayed
// or not. // or not.
maxComposerHeightPx = (size.height * 0.5f).toInt() maxComposerHeightPx = (size.height * 0.5f).toInt()
}, },
content = { content = {
Scaffold( Scaffold(
contentWindowInsets = WindowInsets.statusBars, contentWindowInsets = WindowInsets.statusBars,
@@ -252,12 +253,22 @@ fun MessagesView(
content = { padding -> content = { padding ->
Box( Box(
modifier = Modifier modifier = Modifier
.padding(padding) .padding(padding)
.consumeWindowInsets(padding) .consumeWindowInsets(padding)
) { ) {
MessagesViewContent( MessagesViewContent(
state = state, state = state,
onContentClick = ::onContentClick, onContentClick = ::onContentClick,
onGalleryItemClick = { event, index ->
val hideKeyboard = onGalleryEventItemClick(
state.timelineState.isLive,
event,
index,
)
if (hideKeyboard) {
localView.hideKeyboard()
}
},
onMessageLongClick = ::onMessageLongClick, onMessageLongClick = ::onMessageLongClick,
onUserDataClick = { onUserDataClick = {
hidingKeyboard { hidingKeyboard {
@@ -290,10 +301,10 @@ fun MessagesView(
SuggestionsPickerView( SuggestionsPickerView(
modifier = Modifier modifier = Modifier
.shadow(10.dp) .shadow(10.dp)
.background(ElementTheme.colors.bgCanvasDefault) .background(ElementTheme.colors.bgCanvasDefault)
.align(Alignment.BottomStart) .align(Alignment.BottomStart)
.heightIn(max = 230.dp), .heightIn(max = 230.dp),
roomId = state.roomId, roomId = state.roomId,
roomName = state.roomName, roomName = state.roomName,
roomAvatarData = state.roomAvatar, roomAvatarData = state.roomAvatar,
@@ -459,6 +470,7 @@ private fun MessagesViewContent(
onMoreReactionsClick: (TimelineItem.Event) -> Unit, onMoreReactionsClick: (TimelineItem.Event) -> Unit,
onReadReceiptClick: (TimelineItem.Event) -> Unit, onReadReceiptClick: (TimelineItem.Event) -> Unit,
onMessageLongClick: (TimelineItem.Event) -> Unit, onMessageLongClick: (TimelineItem.Event) -> Unit,
onGalleryItemClick: ((TimelineItem.Event, Int) -> Unit),
onSendLocationClick: () -> Unit, onSendLocationClick: () -> Unit,
onCreatePollClick: () -> Unit, onCreatePollClick: () -> Unit,
onViewAllPinnedMessagesClick: () -> Unit, onViewAllPinnedMessagesClick: () -> Unit,
@@ -469,9 +481,9 @@ private fun MessagesViewContent(
) { ) {
Box( Box(
modifier = modifier modifier = modifier
.fillMaxSize() .fillMaxSize()
.navigationBarsPadding() .navigationBarsPadding()
.imePadding(), .imePadding(),
) { ) {
AttachmentsBottomSheet( AttachmentsBottomSheet(
state = state.composerState, state = state.composerState,
@@ -502,7 +514,9 @@ private fun MessagesViewContent(
pinnedMessagesCount = (state.pinnedMessagesBannerState as? PinnedMessagesBannerState.Visible)?.pinnedMessagesCount() ?: 0, pinnedMessagesCount = (state.pinnedMessagesBannerState as? PinnedMessagesBannerState.Visible)?.pinnedMessagesCount() ?: 0,
) )
val density = LocalDensity.current val density = LocalDensity.current
var pinnedBannerHeightDp by remember { mutableStateOf(0.dp) } // Combined height of the banners overlaid above the timeline. Drives the floating
// date badge offset so the badge sits below whichever banners are currently showing.
var topBannersHeightDp by remember { mutableStateOf(0.dp) }
TimelineView( TimelineView(
state = state.timelineState, state = state.timelineState,
@@ -510,6 +524,7 @@ private fun MessagesViewContent(
onUserDataClick = onUserDataClick, onUserDataClick = onUserDataClick,
onLinkClick = { link -> onLinkClick(link, false) }, onLinkClick = { link -> onLinkClick(link, false) },
onContentClick = onContentClick, onContentClick = onContentClick,
onGalleryItemClick = onGalleryItemClick,
onMessageLongClick = onMessageLongClick, onMessageLongClick = onMessageLongClick,
onSwipeToReply = onSwipeToReply, onSwipeToReply = onSwipeToReply,
onReactionClick = onReactionClick, onReactionClick = onReactionClick,
@@ -518,14 +533,15 @@ private fun MessagesViewContent(
onReadReceiptClick = onReadReceiptClick, onReadReceiptClick = onReadReceiptClick,
forceJumpToBottomVisibility = forceJumpToBottomVisibility, forceJumpToBottomVisibility = forceJumpToBottomVisibility,
nestedScrollConnection = scrollBehavior.nestedScrollConnection, nestedScrollConnection = scrollBehavior.nestedScrollConnection,
floatingDateTopOffset = pinnedBannerHeightDp, floatingDateTopOffset = topBannersHeightDp,
) )
if (state.timelineState.timelineMode !is Timeline.Mode.Thread) { if (state.timelineState.timelineMode !is Timeline.Mode.Thread) {
Column { Column(
modifier = Modifier.onSizeChanged { topBannersHeightDp = with(density) { it.height.toDp() } },
) {
AnimatedVisibility( AnimatedVisibility(
visible = state.pinnedMessagesBannerState is PinnedMessagesBannerState.Visible && scrollBehavior.isVisible, visible = state.pinnedMessagesBannerState is PinnedMessagesBannerState.Visible && scrollBehavior.isVisible,
modifier = Modifier.onSizeChanged { pinnedBannerHeightDp = with(density) { it.height.toDp() } },
enter = expandVertically(), enter = expandVertically(),
exit = shrinkVertically(), exit = shrinkVertically(),
) { ) {
@@ -598,9 +614,9 @@ private fun MessagesViewComposerBottomSheetContents(
private fun CantSendMessageBanner() { private fun CantSendMessageBanner() {
Row( Row(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.background(ElementTheme.colors.bgSubtleSecondary) .background(ElementTheme.colors.bgSubtleSecondary)
.padding(16.dp), .padding(16.dp),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center horizontalArrangement = Arrangement.Center
) { ) {
@@ -637,6 +653,7 @@ internal fun MessagesViewPreview(@PreviewParameter(MessagesStateProvider::class)
onBackClick = {}, onBackClick = {},
onRoomDetailsClick = {}, onRoomDetailsClick = {},
onEventContentClick = { _, _ -> false }, onEventContentClick = { _, _ -> false },
onGalleryEventItemClick = { _, _, _ -> false },
onUserDataClick = {}, onUserDataClick = {},
onLinkClick = { _, _ -> }, onLinkClick = { _, _ -> },
onSendLocationClick = {}, onSendLocationClick = {},
@@ -692,6 +709,7 @@ internal fun MessagesViewA11yPreview() = ElementPreview {
onBackClick = {}, onBackClick = {},
onRoomDetailsClick = {}, onRoomDetailsClick = {},
onEventContentClick = { _, _ -> false }, onEventContentClick = { _, _ -> false },
onGalleryEventItemClick = { _, _, _ -> false },
onUserDataClick = {}, onUserDataClick = {},
onLinkClick = { _, _ -> }, onLinkClick = { _, _ -> },
onSendLocationClick = {}, onSendLocationClick = {},
@@ -27,8 +27,10 @@ import io.element.android.features.messages.impl.crypto.sendfailure.VerifiedUser
import io.element.android.features.messages.impl.crypto.sendfailure.VerifiedUserSendFailureFactory import io.element.android.features.messages.impl.crypto.sendfailure.VerifiedUserSendFailureFactory
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.TimelineItemThreadInfo import io.element.android.features.messages.impl.timeline.model.TimelineItemThreadInfo
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemAttachmentsContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEventContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEventContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEventContentWithAttachment import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEventContentWithAttachment
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemGalleryContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemLegacyCallInviteContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemLegacyCallInviteContent
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.model.event.TimelineItemRedactedContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemRedactedContent
@@ -37,6 +39,7 @@ import io.element.android.features.messages.impl.timeline.model.event.TimelineIt
import io.element.android.features.messages.impl.timeline.model.event.canBeCopied import io.element.android.features.messages.impl.timeline.model.event.canBeCopied
import io.element.android.features.messages.impl.timeline.model.event.canBeForwarded import io.element.android.features.messages.impl.timeline.model.event.canBeForwarded
import io.element.android.features.messages.impl.timeline.model.event.canReact import io.element.android.features.messages.impl.timeline.model.event.canReact
import io.element.android.features.messages.impl.timeline.model.event.captionOrNull
import io.element.android.libraries.architecture.Presenter 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
@@ -198,9 +201,12 @@ class DefaultActionListPresenter(
add(TimelineItemAction.Forward) add(TimelineItemAction.Forward)
} }
if (timelineItem.isEditable && usersEventPermissions.canSendMessage) { if (timelineItem.isEditable && usersEventPermissions.canSendMessage) {
if (timelineItem.content is TimelineItemEventContentWithAttachment) { if (timelineItem.content is TimelineItemEventContentWithAttachment ||
timelineItem.content is TimelineItemGalleryContent ||
timelineItem.content is TimelineItemAttachmentsContent) {
// Caption // Caption
if (timelineItem.content.caption == null) { val caption = timelineItem.content.captionOrNull()
if (caption == null) {
add(TimelineItemAction.AddCaption) add(TimelineItemAction.AddCaption)
} else { } else {
add(TimelineItemAction.EditCaption) add(TimelineItemAction.EditCaption)
@@ -225,7 +231,7 @@ class DefaultActionListPresenter(
} }
if (timelineItem.content.canBeCopied()) { if (timelineItem.content.canBeCopied()) {
add(TimelineItemAction.CopyText) add(TimelineItemAction.CopyText)
} else if ((timelineItem.content as? TimelineItemEventContentWithAttachment)?.caption.isNullOrBlank().not()) { } else if (timelineItem.content.captionOrNull().isNullOrBlank().not()) {
add(TimelineItemAction.CopyCaption) add(TimelineItemAction.CopyCaption)
} }
if (timelineItem.isRemote) { if (timelineItem.isRemote) {
@@ -31,7 +31,8 @@ import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ListItemDefaults import androidx.compose.material3.ListItemDefaults
import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.material3.SheetValue
import androidx.compose.material3.rememberBottomSheetState
import androidx.compose.material3.ripple import androidx.compose.material3.ripple
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.ReadOnlyComposable import androidx.compose.runtime.ReadOnlyComposable
@@ -63,9 +64,11 @@ import io.element.android.features.messages.impl.crypto.sendfailure.VerifiedUser
import io.element.android.features.messages.impl.timeline.a11y.a11yReactionAction import io.element.android.features.messages.impl.timeline.a11y.a11yReactionAction
import io.element.android.features.messages.impl.timeline.components.MessageShieldView import io.element.android.features.messages.impl.timeline.components.MessageShieldView
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.TimelineItemAttachmentsContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemAudioContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemAudioContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEncryptedContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEncryptedContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemFileContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemFileContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemGalleryContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemImageContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemImageContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemLegacyCallInviteContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemLegacyCallInviteContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemLocationContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemLocationContent
@@ -109,7 +112,9 @@ fun ActionListView(
onVerifiedUserSendFailureClick: (TimelineItem.Event) -> Unit, onVerifiedUserSendFailureClick: (TimelineItem.Event) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val sheetState = rememberModalBottomSheetState() val sheetState = rememberBottomSheetState(
initialValue = SheetValue.Hidden,
)
val coroutineScope = rememberCoroutineScope() val coroutineScope = rememberCoroutineScope()
val targetItem = (state.target as? ActionListState.Target.Success)?.event val targetItem = (state.target as? ActionListState.Target.Success)?.event
@@ -299,6 +304,12 @@ private fun MessageSummary(
is TimelineItemImageContent -> { is TimelineItemImageContent -> {
content = { ContentForBody(event.content.bestDescription) } content = { ContentForBody(event.content.bestDescription) }
} }
is TimelineItemGalleryContent -> {
content = { ContentForBody(event.content.body) }
}
is TimelineItemAttachmentsContent -> {
content = { ContentForBody(event.content.body) }
}
is TimelineItemStickerContent -> { is TimelineItemStickerContent -> {
content = { ContentForBody(event.content.bestDescription) } content = { ContentForBody(event.content.bestDescription) }
} }
@@ -401,7 +412,9 @@ private fun EmojiReactionsRow(
} }
Box( Box(
modifier = Modifier.padding(end = 10.dp).requiredSize(48.dp), modifier = Modifier
.padding(end = 10.dp)
.requiredSize(48.dp),
contentAlignment = Alignment.CenterEnd, contentAlignment = Alignment.CenterEnd,
) { ) {
Icon( Icon(
@@ -23,4 +23,5 @@ sealed interface AttachmentsPreviewEvent {
data object ResetImageEdits : AttachmentsPreviewEvent data object ResetImageEdits : AttachmentsPreviewEvent
data class UpdateImageCropRect(val cropRect: NormalizedCropRect) : AttachmentsPreviewEvent data class UpdateImageCropRect(val cropRect: NormalizedCropRect) : AttachmentsPreviewEvent
data object ClearImageEditError : AttachmentsPreviewEvent data object ClearImageEditError : AttachmentsPreviewEvent
data class SetCurrentCarouselIndex(val index: Int) : AttachmentsPreviewEvent
} }
@@ -30,6 +30,7 @@ import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.SessionId import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.api.timeline.Timeline import io.element.android.libraries.matrix.api.timeline.Timeline
import io.element.android.libraries.mediaviewer.api.local.LocalMediaRenderer import io.element.android.libraries.mediaviewer.api.local.LocalMediaRenderer
import kotlinx.collections.immutable.ImmutableList
@ContributesNode(RoomScope::class) @ContributesNode(RoomScope::class)
@AssistedInject @AssistedInject
@@ -42,7 +43,7 @@ class AttachmentsPreviewNode(
private val enterpriseService: EnterpriseService, private val enterpriseService: EnterpriseService,
) : Node(buildContext, plugins = plugins) { ) : Node(buildContext, plugins = plugins) {
data class Inputs( data class Inputs(
val attachment: Attachment, val attachments: ImmutableList<Attachment>,
val timelineMode: Timeline.Mode, val timelineMode: Timeline.Mode,
val inReplyToEventId: EventId?, val inReplyToEventId: EventId?,
) : NodeInputs ) : NodeInputs
@@ -54,7 +55,7 @@ class AttachmentsPreviewNode(
} }
private val presenter = presenterFactory.create( private val presenter = presenterFactory.create(
attachment = inputs.attachment, attachments = inputs.attachments,
timelineMode = inputs.timelineMode, timelineMode = inputs.timelineMode,
onDoneListener = onDoneListener, onDoneListener = onDoneListener,
inReplyToEventId = inputs.inReplyToEventId, inReplyToEventId = inputs.inReplyToEventId,
@@ -11,7 +11,9 @@ package io.element.android.features.messages.impl.attachments.preview
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState import androidx.compose.runtime.MutableState
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
@@ -48,10 +50,11 @@ import io.element.android.libraries.mediaupload.api.allFiles
import io.element.android.libraries.preferences.api.store.VideoCompressionPreset import io.element.android.libraries.preferences.api.store.VideoCompressionPreset
import io.element.android.libraries.textcomposer.model.TextEditorState import io.element.android.libraries.textcomposer.model.TextEditorState
import io.element.android.libraries.textcomposer.model.rememberMarkdownTextEditorState import io.element.android.libraries.textcomposer.model.rememberMarkdownTextEditorState
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import timber.log.Timber import timber.log.Timber
@@ -59,7 +62,7 @@ import java.io.File
@AssistedInject @AssistedInject
class AttachmentsPreviewPresenter( class AttachmentsPreviewPresenter(
@Assisted private val attachment: Attachment, @Assisted private val attachments: ImmutableList<Attachment>,
@Assisted private val onDoneListener: OnDoneListener, @Assisted private val onDoneListener: OnDoneListener,
@Assisted private val timelineMode: Timeline.Mode, @Assisted private val timelineMode: Timeline.Mode,
@Assisted private val inReplyToEventId: EventId?, @Assisted private val inReplyToEventId: EventId?,
@@ -76,13 +79,18 @@ class AttachmentsPreviewPresenter(
@AssistedFactory @AssistedFactory
interface Factory { interface Factory {
fun create( fun create(
attachment: Attachment, attachments: ImmutableList<Attachment>,
timelineMode: Timeline.Mode, timelineMode: Timeline.Mode,
onDoneListener: OnDoneListener, onDoneListener: OnDoneListener,
inReplyToEventId: EventId?, inReplyToEventId: EventId?,
): AttachmentsPreviewPresenter ): AttachmentsPreviewPresenter
} }
data class AttachmentAndEdits(
val attachment: Attachment,
val edits: AttachmentImageEdits,
)
private val mediaSender = mediaSenderFactory.create(timelineMode) private val mediaSender = mediaSenderFactory.create(timelineMode)
@Composable @Composable
@@ -92,14 +100,11 @@ class AttachmentsPreviewPresenter(
val sendActionState = remember { val sendActionState = remember {
mutableStateOf<SendActionState>(SendActionState.Idle) mutableStateOf<SendActionState>(SendActionState.Idle)
} }
val originalLocalMedia = remember { (attachment as Attachment.Media).localMedia } var canEditImage by remember { mutableStateOf(false) }
var currentAttachment by remember { mutableStateOf(attachment) }
var canEditImage by remember { mutableStateOf(originalLocalMedia.info.canEditImage()) }
var imageEditorState by remember { mutableStateOf<AttachmentImageEditorState?>(null) } var imageEditorState by remember { mutableStateOf<AttachmentImageEditorState?>(null) }
var appliedImageEdits by remember { mutableStateOf(AttachmentImageEdits()) }
var isApplyingImageEdits by remember { mutableStateOf(false) } var isApplyingImageEdits by remember { mutableStateOf(false) }
var displayImageEditError by remember { mutableStateOf(false) } var displayImageEditError by remember { mutableStateOf(false) }
var editedTempFile by remember { mutableStateOf<File?>(null) } var editedTempFiles by remember { mutableStateOf<Map<Int, File>>(emptyMap()) }
val markdownTextEditorState = rememberMarkdownTextEditorState(initialText = null, initialFocus = false) val markdownTextEditorState = rememberMarkdownTextEditorState(initialText = null, initialFocus = false)
val textEditorState by rememberUpdatedState( val textEditorState by rememberUpdatedState(
@@ -108,76 +113,115 @@ class AttachmentsPreviewPresenter(
val ongoingSendAttachmentJob = remember { mutableStateOf<Job?>(null) } val ongoingSendAttachmentJob = remember { mutableStateOf<Job?>(null) }
var preprocessMediaJob by remember { mutableStateOf<Job?>(null) } var currentIndex by remember { mutableIntStateOf(0) }
val mediaAttachment = currentAttachment as Attachment.Media var attachmentsAndEdits by remember {
val mediaOptimizationSelectorPresenter = remember { mutableStateOf(
mediaOptimizationSelectorPresenterFactory.create( attachments.map {
localMedia = mediaAttachment.localMedia, AttachmentAndEdits(it, AttachmentImageEdits())
sendAsFile = mediaAttachment.sendAsFile, }
) )
} }
val mediaOptimizationSelectorState by rememberUpdatedState(mediaOptimizationSelectorPresenter.present())
val editedAttachments by remember {
derivedStateOf {
attachmentsAndEdits.map { it.attachment }.toImmutableList()
}
}
var preprocessMediaJob by remember { mutableStateOf<Job?>(null) }
val mediaOptimizationSelectorPresenters = remember {
attachments
.filterIsInstance<Attachment.Media>()
.mapIndexed { index, attachment ->
mediaOptimizationSelectorPresenterFactory.create(
index = index,
localMedia = attachment.localMedia,
sendAsFile = attachment.sendAsFile,
)
}
}
val mediaOptimizationSelectorStates by rememberUpdatedState(
mediaOptimizationSelectorPresenters.map {
it.present()
}.toImmutableList()
)
val observableSendState = snapshotFlow { sendActionState.value } val observableSendState = snapshotFlow { sendActionState.value }
var displayFileTooLargeError by remember { mutableStateOf(false) } var displayFileTooLargeError by remember { mutableStateOf(false) }
LaunchedEffect( LaunchedEffect(
mediaOptimizationSelectorState.displayMediaSelectorViews, mediaOptimizationSelectorStates,
mediaOptimizationSelectorState.videoSizeEstimations,
currentAttachment,
imageEditorState, imageEditorState,
isApplyingImageEdits, isApplyingImageEdits,
editedAttachments,
) { ) {
if (mediaOptimizationSelectorStates.any { it.displayMediaSelectorViews == true } ||
imageEditorState != null ||
isApplyingImageEdits
) {
// If any of the media optimization selectors are displayed, we don't want to pre-process the media yet
return@LaunchedEffect
}
// If the media optimization selector is not displayed, we can pre-process the media // If the media optimization selector is not displayed, we can pre-process the media
// to prepare it for sending. This is done to avoid blocking the UI thread when the // to prepare it for sending. This is done to avoid blocking the UI thread when the
// user clicks on the send button. // user clicks on the send button.
@Suppress("ComplexCondition") val configs = mediaOptimizationSelectorStates.mapIndexed { index, mediaOptimizationSelectorState ->
if (mediaOptimizationSelectorState.displayMediaSelectorViews == false && getAutoPreprocessMediaOptimizationConfig(
preprocessMediaJob == null && mediaAttachment = editedAttachments[index] as Attachment.Media,
imageEditorState == null &&
!isApplyingImageEdits) {
if (mediaAttachment.localMedia.info.mimeType.isMimeTypeVideo() && mediaOptimizationSelectorState.videoSizeEstimations.dataOrNull() == null) {
Timber.d("Waiting for video size estimations to be able to select the best video compression preset before pre-processing the media")
return@LaunchedEffect
}
val config = getAutoPreprocessMediaOptimizationConfig(
mediaAttachment = mediaAttachment,
mediaOptimizationSelectorState = mediaOptimizationSelectorState, mediaOptimizationSelectorState = mediaOptimizationSelectorState,
) ?: return@LaunchedEffect )
preprocessMediaJob = coroutineScope.preProcessAttachment( }
attachment = currentAttachment, preprocessMediaJob?.cancel()
mediaOptimizationConfig = config, preprocessMediaJob = coroutineScope.launch(dispatchers.io) {
preProcessAttachments(
attachments = editedAttachments,
mediaOptimizationConfigs = configs,
displayProgress = false, displayProgress = false,
sendActionState = sendActionState, sendActionState = sendActionState,
) )
} }
} }
LaunchedEffect(originalLocalMedia) { LaunchedEffect(currentIndex) {
canEditImage = originalLocalMedia.info.canEditImage() || attachmentImageEditor.canEdit(originalLocalMedia) val currentMedia = (attachments.getOrNull(currentIndex) as? Attachment.Media)?.localMedia
if (currentMedia != null) {
canEditImage = currentMedia.info.canEditImage() || attachmentImageEditor.canEdit(currentMedia)
}
} }
val maxUploadSize = mediaOptimizationSelectorState.maxUploadSize.dataOrNull() val maxUploadSize = mediaOptimizationSelectorStates.firstNotNullOfOrNull {
it.maxUploadSize.dataOrNull()
}
LaunchedEffect(maxUploadSize) { LaunchedEffect(maxUploadSize) {
// Check file upload size if the media won't be processed for upload if (maxUploadSize != null) {
val isImageFile = mediaAttachment.localMedia.info.isImageAttachment()
val isVideoFile = mediaAttachment.localMedia.info.mimeType.isMimeTypeVideo()
if (maxUploadSize != null && !(isImageFile || isVideoFile)) {
// If file size is not known, we're permissive and allow sending. The SDK will cancel the upload if needed. // If file size is not known, we're permissive and allow sending. The SDK will cancel the upload if needed.
val fileSize = mediaAttachment.localMedia.info.fileSize ?: 0L displayFileTooLargeError = attachments.any { attachment ->
if (maxUploadSize < fileSize) { when (attachment) {
displayFileTooLargeError = true is Attachment.Media -> {
val isImageFile = attachment.localMedia.info.isImageAttachment()
val isVideoFile = attachment.localMedia.info.mimeType.isMimeTypeVideo()
if (isImageFile || isVideoFile) {
false
} else {
val fileSize = attachment.localMedia.info.fileSize ?: 0L
maxUploadSize < fileSize
}
}
}
} }
} }
} }
val videoSizeEstimations = mediaOptimizationSelectorState.videoSizeEstimations.dataOrNull() mediaOptimizationSelectorStates.forEach { mediaOptimizationSelectorState ->
LaunchedEffect(videoSizeEstimations) { val videoSizeEstimations = mediaOptimizationSelectorState.videoSizeEstimations.dataOrNull()
if (videoSizeEstimations != null) { LaunchedEffect(videoSizeEstimations) {
// Check if the video size estimations are too large for the max upload size if (videoSizeEstimations != null) {
displayFileTooLargeError = videoSizeEstimations.none { it.canUpload } // Check if the video size estimations are too large for the max upload size
displayFileTooLargeError = videoSizeEstimations.none { it.canUpload }
}
} }
} }
@@ -185,19 +229,23 @@ class AttachmentsPreviewPresenter(
when (event) { when (event) {
is AttachmentsPreviewEvent.SendAttachment -> { is AttachmentsPreviewEvent.SendAttachment -> {
ongoingSendAttachmentJob.value = coroutineScope.launch { ongoingSendAttachmentJob.value = coroutineScope.launch {
// If the media optimization selector is displayed, we need to wait for the user to select the options if (preprocessMediaJob?.isActive != true && sendActionState.value !is SendActionState.Sending.ReadyToUpload) {
// before we can pre-process the media. val configs = mediaOptimizationSelectorStates.map {
if (mediaOptimizationSelectorState.displayMediaSelectorViews == true) { MediaOptimizationConfig(
val config = MediaOptimizationConfig( compressImages = it.isImageOptimizationEnabled
compressImages = mediaOptimizationSelectorState.isImageOptimizationEnabled == true, ?: mediaOptimizationConfigProvider.get().compressImages,
videoCompressionPreset = mediaOptimizationSelectorState.selectedVideoPreset ?: VideoCompressionPreset.STANDARD, videoCompressionPreset = it.selectedVideoPreset
) ?: mediaOptimizationConfigProvider.get().videoCompressionPreset,
preprocessMediaJob = preProcessAttachment( )
attachment = currentAttachment, }
mediaOptimizationConfig = config, preprocessMediaJob = coroutineScope.launch(dispatchers.io) {
displayProgress = true, preProcessAttachments(
sendActionState = sendActionState, attachments = editedAttachments,
) mediaOptimizationConfigs = configs,
displayProgress = true,
sendActionState = sendActionState,
)
}
} }
// If the processing was hidden before, make it visible now // If the processing was hidden before, make it visible now
@@ -206,35 +254,27 @@ class AttachmentsPreviewPresenter(
} }
// Wait until the media is ready to be uploaded // Wait until the media is ready to be uploaded
val mediaUploadInfo = observableSendState.firstInstanceOf<SendActionState.Sending.ReadyToUpload>().mediaInfo val allMediaUploadInfos = observableSendState.firstInstanceOf<SendActionState.Sending.ReadyToUpload>().mediaInfos
// Pre-processing is done, send the attachment // Pre-processing is done, send the attachment
val caption = markdownTextEditorState.getMessageMarkdown(permalinkBuilder) val caption = markdownTextEditorState.getMessageMarkdown(permalinkBuilder)
.takeIf { it.isNotEmpty() } .takeIf { it.isNotEmpty() }
val editedTempFileToDelete = editedTempFile val editedTempFilesToDelete = editedTempFiles
editedTempFile = null editedTempFiles = emptyMap()
// If we're supposed to send the media as a background job, we can dismiss this screen already
if (coroutineContext.isActive) {
onDoneListener()
}
// Send the media using the session coroutine scope so it doesn't matter if this screen or the chat one are closed // Send the media using the session coroutine scope so it doesn't matter if this screen or the chat one are closed
sessionCoroutineScope.launch(dispatchers.io) { sessionCoroutineScope.launch(dispatchers.io) {
try { sendMedia(
sendPreProcessedMedia( mediaUploadInfos = allMediaUploadInfos,
mediaUploadInfo = mediaUploadInfo, caption = caption,
caption = caption, sendActionState = sendActionState,
sendActionState = sendActionState, inReplyToEventId = inReplyToEventId,
dismissAfterSend = false, )
inReplyToEventId = inReplyToEventId,
) // Clean up the pre-processed media after it's been sent
} finally { mediaSender.cleanUp()
editedTempFileToDelete?.safeDelete() editedTempFilesToDelete.values.forEach { it.safeDelete() }
// Clean up the pre-processed media after it's been sent
mediaSender.cleanUp()
}
} }
} }
} }
@@ -251,7 +291,11 @@ class AttachmentsPreviewPresenter(
ongoingSendAttachmentJob.value?.cancel() ongoingSendAttachmentJob.value?.cancel()
// Dismiss the screen // Dismiss the screen
dismiss(sendActionState, editedTempFile) dismiss(
attachments = editedAttachments,
sendActionState = sendActionState,
editedTempFiles = editedTempFiles,
)
} }
AttachmentsPreviewEvent.CancelAndClearSendState -> { AttachmentsPreviewEvent.CancelAndClearSendState -> {
// Cancel media sending // Cancel media sending
@@ -260,22 +304,23 @@ class AttachmentsPreviewPresenter(
ongoingSendAttachmentJob.value = null ongoingSendAttachmentJob.value = null
} }
val mediaUploadInfo = sendActionState.value.mediaUploadInfo() val mediaUploadInfoList = sendActionState.value.mediaUploadInfoList()
sendActionState.value = if (mediaUploadInfo != null) { sendActionState.value = if (mediaUploadInfoList != null) {
SendActionState.Sending.ReadyToUpload(mediaUploadInfo) SendActionState.Sending.ReadyToUpload(mediaUploadInfoList)
} else { } else {
SendActionState.Idle SendActionState.Idle
} }
} }
AttachmentsPreviewEvent.OpenImageEditor -> { AttachmentsPreviewEvent.OpenImageEditor -> {
val resolvedCanEditImage = canEditImage || originalLocalMedia.info.canEditImage() val currentLocalMedia = (attachments.getOrNull(currentIndex) as? Attachment.Media)?.localMedia ?: return
val resolvedCanEditImage = canEditImage || currentLocalMedia.info.canEditImage()
if (resolvedCanEditImage) { if (resolvedCanEditImage) {
preprocessMediaJob?.cancel() preprocessMediaJob?.cancel()
preprocessMediaJob = null preprocessMediaJob = null
resetPreparedMedia(sendActionState) resetPreparedMedia(sendActionState)
imageEditorState = AttachmentImageEditorState( imageEditorState = AttachmentImageEditorState(
localMedia = originalLocalMedia, localMedia = currentLocalMedia,
edits = appliedImageEdits, edits = attachmentsAndEdits.get(currentIndex).edits,
previewDebug = false, previewDebug = false,
) )
} }
@@ -315,10 +360,15 @@ class AttachmentsPreviewPresenter(
AttachmentsPreviewEvent.ApplyImageEdits -> { AttachmentsPreviewEvent.ApplyImageEdits -> {
val pendingState = imageEditorState ?: return val pendingState = imageEditorState ?: return
if (!pendingState.edits.hasChanges) { if (!pendingState.edits.hasChanges) {
editedTempFile?.safeDelete() editedTempFiles[currentIndex]?.safeDelete()
editedTempFile = null editedTempFiles = editedTempFiles - currentIndex
appliedImageEdits = pendingState.edits val currentAttachment = attachmentsAndEdits[currentIndex].attachment
currentAttachment = Attachment.Media(originalLocalMedia) attachmentsAndEdits = attachmentsAndEdits.toMutableList().also {
it[currentIndex] = AttachmentAndEdits(
currentAttachment,
pendingState.edits,
)
}.toImmutableList()
imageEditorState = null imageEditorState = null
resetPreparedMedia(sendActionState) resetPreparedMedia(sendActionState)
return return
@@ -328,16 +378,21 @@ class AttachmentsPreviewPresenter(
coroutineScope.launch { coroutineScope.launch {
val result = withContext(dispatchers.io) { val result = withContext(dispatchers.io) {
attachmentImageEditor.exportEdits( attachmentImageEditor.exportEdits(
localMedia = originalLocalMedia, localMedia = pendingState.localMedia,
edits = pendingState.edits, edits = pendingState.edits,
) )
} }
result.fold( result.fold(
onSuccess = { editedMedia -> onSuccess = { editedMedia ->
editedTempFile?.safeDelete() editedTempFiles[currentIndex]?.safeDelete()
editedTempFile = editedMedia.file editedTempFiles = editedTempFiles + (currentIndex to editedMedia.file)
appliedImageEdits = pendingState.edits val currentAttachment = Attachment.Media(editedMedia.localMedia)
currentAttachment = Attachment.Media(editedMedia.localMedia) attachmentsAndEdits = attachmentsAndEdits.toMutableList().also {
it[currentIndex] = AttachmentAndEdits(
currentAttachment,
pendingState.edits,
)
}.toImmutableList()
imageEditorState = null imageEditorState = null
resetPreparedMedia(sendActionState) resetPreparedMedia(sendActionState)
}, },
@@ -352,19 +407,23 @@ class AttachmentsPreviewPresenter(
AttachmentsPreviewEvent.ClearImageEditError -> { AttachmentsPreviewEvent.ClearImageEditError -> {
displayImageEditError = false displayImageEditError = false
} }
is AttachmentsPreviewEvent.SetCurrentCarouselIndex -> {
currentIndex = event.index
}
} }
} }
return AttachmentsPreviewState( return AttachmentsPreviewState(
attachment = currentAttachment, attachments = editedAttachments,
imageEditorState = imageEditorState, imageEditorState = imageEditorState,
canEditImage = canEditImage, canEditImage = canEditImage,
isApplyingImageEdits = isApplyingImageEdits, isApplyingImageEdits = isApplyingImageEdits,
displayImageEditError = displayImageEditError, displayImageEditError = displayImageEditError,
sendActionState = sendActionState.value, sendActionState = sendActionState.value,
textEditorState = textEditorState, textEditorState = textEditorState,
mediaOptimizationSelectorState = mediaOptimizationSelectorState, mediaOptimizationSelectorState = mediaOptimizationSelectorStates[currentIndex],
displayFileTooLargeError = displayFileTooLargeError, displayFileTooLargeError = displayFileTooLargeError,
currentIndex = currentIndex,
eventSink = ::handleEvent, eventSink = ::handleEvent,
) )
} }
@@ -372,7 +431,7 @@ class AttachmentsPreviewPresenter(
private suspend fun getAutoPreprocessMediaOptimizationConfig( private suspend fun getAutoPreprocessMediaOptimizationConfig(
mediaAttachment: Attachment.Media, mediaAttachment: Attachment.Media,
mediaOptimizationSelectorState: MediaOptimizationSelectorState, mediaOptimizationSelectorState: MediaOptimizationSelectorState,
): MediaOptimizationConfig? { ): MediaOptimizationConfig {
return if (mediaAttachment.sendAsFile) { return if (mediaAttachment.sendAsFile) {
// If we're sending the media as a file, we can skip image compression and we should select the highest video compression preset that still fits // If we're sending the media as a file, we can skip image compression and we should select the highest video compression preset that still fits
// the upload limit (if the estimations are available) // the upload limit (if the estimations are available)
@@ -386,71 +445,66 @@ class AttachmentsPreviewPresenter(
videoCompressionPreset = videoCompressionPreset, videoCompressionPreset = videoCompressionPreset,
) )
} else { } else {
// Otherwise, we just rely on the user preferences for media optimization MediaOptimizationConfig(
mediaOptimizationConfigProvider.get() compressImages = mediaOptimizationSelectorState.isImageOptimizationEnabled
?: mediaOptimizationConfigProvider.get().compressImages,
videoCompressionPreset = mediaOptimizationSelectorState.selectedVideoPreset
?: mediaOptimizationConfigProvider.get().videoCompressionPreset,
)
} }
} }
private fun CoroutineScope.preProcessAttachment( private suspend fun preProcessAttachments(
attachment: Attachment, attachments: List<Attachment>,
mediaOptimizationConfig: MediaOptimizationConfig, mediaOptimizationConfigs: List<MediaOptimizationConfig>,
displayProgress: Boolean,
sendActionState: MutableState<SendActionState>,
) = launch(dispatchers.io) {
when (attachment) {
is Attachment.Media -> {
preProcessMedia(
mediaAttachment = attachment,
mediaOptimizationConfig = mediaOptimizationConfig,
displayProgress = displayProgress,
sendActionState = sendActionState,
)
}
}
}
private suspend fun preProcessMedia(
mediaAttachment: Attachment.Media,
mediaOptimizationConfig: MediaOptimizationConfig,
displayProgress: Boolean, displayProgress: Boolean,
sendActionState: MutableState<SendActionState>, sendActionState: MutableState<SendActionState>,
) { ) {
sendActionState.value = SendActionState.Sending.Processing(displayProgress = displayProgress) sendActionState.value = SendActionState.Sending.Processing(displayProgress = displayProgress)
mediaSender.preProcessMedia( val mediaUploadInfos = mutableListOf<MediaUploadInfo>()
uri = mediaAttachment.localMedia.uri, attachments.forEachIndexed { index, attachment ->
mimeType = mediaAttachment.localMedia.info.mimeType, when (attachment) {
mediaOptimizationConfig = mediaOptimizationConfig, is Attachment.Media -> {
).fold( mediaSender.preProcessMedia(
onSuccess = { mediaUploadInfo -> uri = attachment.localMedia.uri,
Timber.d("Media ${mediaUploadInfo.file.path.orEmpty().hash()} finished processing, it's now ready to upload") mimeType = attachment.localMedia.info.mimeType,
sendActionState.value = SendActionState.Sending.ReadyToUpload(mediaUploadInfo) mediaOptimizationConfig = mediaOptimizationConfigs[index],
}, ).fold(
onFailure = { onSuccess = { mediaUploadInfo ->
Timber.e(it, "Failed to pre-process media") Timber.d("Media ${mediaUploadInfo.file.path.orEmpty().hash()} finished processing")
if (it is CancellationException) { mediaUploadInfos.add(mediaUploadInfo)
throw it },
} else { onFailure = {
sendActionState.value = SendActionState.Failure(it, null) Timber.e(it, "Failed to pre-process media")
} if (it is CancellationException) {
} throw it
) } else {
} sendActionState.value = SendActionState.Failure(it, emptyList())
return
private fun dismiss( }
sendActionState: MutableState<SendActionState>, }
editedTempFile: File?, )
) {
// Delete the temporary file
when (attachment) {
is Attachment.Media -> {
temporaryUriDeleter.delete(attachment.localMedia.uri)
sendActionState.value.mediaUploadInfo()?.let { data ->
cleanUp(data)
} }
} }
} }
editedTempFile?.safeDelete() sendActionState.value = SendActionState.Sending.ReadyToUpload(mediaUploadInfos)
// Reset the sendActionState to ensure that dialog is closed before the screen }
private fun dismiss(
attachments: List<Attachment>,
sendActionState: MutableState<SendActionState>,
editedTempFiles: Map<Int, File> = emptyMap(),
) {
for (attachment in attachments) {
when (attachment) {
is Attachment.Media -> {
temporaryUriDeleter.delete(attachment.localMedia.uri)
}
}
}
val uploadInfos = (sendActionState.value as? SendActionState.Sending.ReadyToUpload)?.mediaInfos
uploadInfos?.forEach { cleanUp(it) }
editedTempFiles.values.forEach { it.safeDelete() }
sendActionState.value = SendActionState.Done sendActionState.value = SendActionState.Done
onDoneListener() onDoneListener()
} }
@@ -464,41 +518,45 @@ class AttachmentsPreviewPresenter(
} }
private fun resetPreparedMedia(sendActionState: MutableState<SendActionState>) { private fun resetPreparedMedia(sendActionState: MutableState<SendActionState>) {
sendActionState.value.mediaUploadInfo()?.let(::cleanUp) sendActionState.value.mediaUploadInfoList()?.forEach(::cleanUp)
mediaSender.cleanUp() mediaSender.cleanUp()
sendActionState.value = SendActionState.Idle sendActionState.value = SendActionState.Idle
} }
private suspend fun sendPreProcessedMedia( private suspend fun sendMedia(
mediaUploadInfo: MediaUploadInfo, mediaUploadInfos: List<MediaUploadInfo>,
caption: String?, caption: String?,
sendActionState: MutableState<SendActionState>, sendActionState: MutableState<SendActionState>,
dismissAfterSend: Boolean,
inReplyToEventId: EventId?, inReplyToEventId: EventId?,
) = runCatchingExceptions { ) = runCatchingExceptions {
sendActionState.value = SendActionState.Sending.Uploading(mediaUploadInfo) if (mediaUploadInfos.size == 1) {
mediaSender.sendPreProcessedMedia( sendActionState.value = SendActionState.Sending.Uploading(mediaUploadInfos)
mediaUploadInfo = mediaUploadInfo, mediaSender.sendPreProcessedMedia(
caption = caption, mediaUploadInfo = mediaUploadInfos.first(),
formattedCaption = null, caption = caption,
inReplyToEventId = inReplyToEventId, formattedCaption = null,
).getOrThrow() inReplyToEventId = inReplyToEventId,
).getOrThrow()
} else {
mediaSender.sendGallery(
mediaUploadInfos = mediaUploadInfos,
caption = caption,
formattedCaption = null,
inReplyToEventId = inReplyToEventId,
).getOrThrow()
}
}.fold( }.fold(
onSuccess = { onSuccess = {
cleanUp(mediaUploadInfo) mediaUploadInfos.forEach { cleanUp(it) }
// Reset the sendActionState to ensure that dialog is closed before the screen
sendActionState.value = SendActionState.Done sendActionState.value = SendActionState.Done
onDoneListener()
if (dismissAfterSend) {
onDoneListener()
}
}, },
onFailure = { error -> onFailure = { error ->
Timber.e(error, "Failed to send attachment") Timber.e(error, "Failed to send attachment")
if (error is CancellationException) { if (error is CancellationException) {
throw error throw error
} else { } else {
sendActionState.value = SendActionState.Failure(error, mediaUploadInfo) sendActionState.value = SendActionState.Failure(error, mediaUploadInfos)
} }
} }
) )
@@ -14,9 +14,10 @@ import io.element.android.features.messages.impl.attachments.preview.imageeditor
import io.element.android.features.messages.impl.attachments.video.MediaOptimizationSelectorState import io.element.android.features.messages.impl.attachments.video.MediaOptimizationSelectorState
import io.element.android.libraries.mediaupload.api.MediaUploadInfo import io.element.android.libraries.mediaupload.api.MediaUploadInfo
import io.element.android.libraries.textcomposer.model.TextEditorState import io.element.android.libraries.textcomposer.model.TextEditorState
import kotlinx.collections.immutable.ImmutableList
data class AttachmentsPreviewState( data class AttachmentsPreviewState(
val attachment: Attachment, val attachments: ImmutableList<Attachment>,
val imageEditorState: AttachmentImageEditorState?, val imageEditorState: AttachmentImageEditorState?,
val canEditImage: Boolean, val canEditImage: Boolean,
val isApplyingImageEdits: Boolean, val isApplyingImageEdits: Boolean,
@@ -25,8 +26,12 @@ data class AttachmentsPreviewState(
val textEditorState: TextEditorState, val textEditorState: TextEditorState,
val mediaOptimizationSelectorState: MediaOptimizationSelectorState, val mediaOptimizationSelectorState: MediaOptimizationSelectorState,
val displayFileTooLargeError: Boolean, val displayFileTooLargeError: Boolean,
val currentIndex: Int,
val eventSink: (AttachmentsPreviewEvent) -> Unit, val eventSink: (AttachmentsPreviewEvent) -> Unit,
) ) {
val isGallery: Boolean get() = attachments.size > 1
val totalCount: Int get() = attachments.size
}
@Immutable @Immutable
sealed interface SendActionState { sealed interface SendActionState {
@@ -35,17 +40,17 @@ sealed interface SendActionState {
@Immutable @Immutable
sealed interface Sending : SendActionState { sealed interface Sending : SendActionState {
data class Processing(val displayProgress: Boolean) : Sending data class Processing(val displayProgress: Boolean) : Sending
data class ReadyToUpload(val mediaInfo: MediaUploadInfo) : Sending data class ReadyToUpload(val mediaInfos: List<MediaUploadInfo>) : Sending
data class Uploading(val mediaUploadInfo: MediaUploadInfo) : Sending data class Uploading(val mediaInfos: List<MediaUploadInfo>) : Sending
} }
data class Failure(val error: Throwable, val mediaUploadInfo: MediaUploadInfo?) : SendActionState data class Failure(val error: Throwable, val mediaInfos: List<MediaUploadInfo>) : SendActionState
data object Done : SendActionState data object Done : SendActionState
fun mediaUploadInfo(): MediaUploadInfo? = when (this) { fun mediaUploadInfoList(): List<MediaUploadInfo>? = when (this) {
is Sending.ReadyToUpload -> mediaInfo is Sending.ReadyToUpload -> mediaInfos
is Sending.Uploading -> mediaUploadInfo is Sending.Uploading -> mediaInfos
is Failure -> mediaUploadInfo is Failure -> mediaInfos
else -> null else -> null
} }
} }
@@ -41,9 +41,9 @@ open class AttachmentsPreviewStateProvider : PreviewParameterProvider<Attachment
) )
), ),
anAttachmentsPreviewState(sendActionState = SendActionState.Sending.Processing(displayProgress = true)), anAttachmentsPreviewState(sendActionState = SendActionState.Sending.Processing(displayProgress = true)),
anAttachmentsPreviewState(sendActionState = SendActionState.Sending.ReadyToUpload(aMediaUploadInfo())), anAttachmentsPreviewState(sendActionState = SendActionState.Sending.ReadyToUpload(listOf(aMediaUploadInfo()))),
anAttachmentsPreviewState(sendActionState = SendActionState.Sending.Uploading(aMediaUploadInfo())), anAttachmentsPreviewState(sendActionState = SendActionState.Sending.Uploading(listOf(aMediaUploadInfo()))),
anAttachmentsPreviewState(sendActionState = SendActionState.Failure(RuntimeException("error"), aMediaUploadInfo())), anAttachmentsPreviewState(sendActionState = SendActionState.Failure(RuntimeException("error"), listOf(aMediaUploadInfo()))),
anAttachmentsPreviewState( anAttachmentsPreviewState(
imageEditorState = anAttachmentImageEditorState(), imageEditorState = anAttachmentImageEditorState(),
), ),
@@ -72,9 +72,12 @@ fun anAttachmentsPreviewState(
imageEditorState: AttachmentImageEditorState? = null, imageEditorState: AttachmentImageEditorState? = null,
mediaOptimizationSelectorState: MediaOptimizationSelectorState = aMediaOptimisationSelectorState(), mediaOptimizationSelectorState: MediaOptimizationSelectorState = aMediaOptimisationSelectorState(),
displayFileTooLargeError: Boolean = false, displayFileTooLargeError: Boolean = false,
currentIndex: Int = 0,
) = AttachmentsPreviewState( ) = AttachmentsPreviewState(
attachment = Attachment.Media( attachments = persistentListOf(
localMedia = LocalMedia("file://path".toUri(), mediaInfo), Attachment.Media(
localMedia = LocalMedia("file://path".toUri(), mediaInfo),
),
), ),
imageEditorState = imageEditorState, imageEditorState = imageEditorState,
canEditImage = true, canEditImage = true,
@@ -84,6 +87,37 @@ fun anAttachmentsPreviewState(
textEditorState = textEditorState, textEditorState = textEditorState,
mediaOptimizationSelectorState = mediaOptimizationSelectorState, mediaOptimizationSelectorState = mediaOptimizationSelectorState,
displayFileTooLargeError = displayFileTooLargeError, displayFileTooLargeError = displayFileTooLargeError,
currentIndex = currentIndex,
eventSink = {}
)
fun anAttachmentsPreviewGalleryState(
mediaInfo: MediaInfo = anImageMediaInfo(),
textEditorState: TextEditorState = aTextEditorStateMarkdown(),
sendActionState: SendActionState = SendActionState.Idle,
mediaOptimizationSelectorState: MediaOptimizationSelectorState = aMediaOptimisationSelectorState(),
currentIndex: Int = 0,
) = AttachmentsPreviewState(
attachments = persistentListOf(
Attachment.Media(
localMedia = LocalMedia("file://path1".toUri(), mediaInfo),
),
Attachment.Media(
localMedia = LocalMedia("file://path2".toUri(), mediaInfo),
),
Attachment.Media(
localMedia = LocalMedia("file://path3".toUri(), mediaInfo),
),
),
imageEditorState = null,
canEditImage = false,
isApplyingImageEdits = false,
displayImageEditError = false,
sendActionState = sendActionState,
textEditorState = textEditorState,
mediaOptimizationSelectorState = mediaOptimizationSelectorState,
displayFileTooLargeError = false,
currentIndex = currentIndex,
eventSink = {} eventSink = {}
) )
@@ -112,6 +146,7 @@ fun aMediaOptimisationSelectorState(
displayMediaSelectorViews: Boolean = true, displayMediaSelectorViews: Boolean = true,
displayVideoPresetSelectorDialog: Boolean = false, displayVideoPresetSelectorDialog: Boolean = false,
) = MediaOptimizationSelectorState( ) = MediaOptimizationSelectorState(
index = 0,
maxUploadSize = AsyncData.Success(maxUploadSize), maxUploadSize = AsyncData.Success(maxUploadSize),
videoSizeEstimations = videoSizeEstimations, videoSizeEstimations = videoSizeEstimations,
isImageOptimizationEnabled = isImageOptimizationEnabled, isImageOptimizationEnabled = isImageOptimizationEnabled,
@@ -9,11 +9,15 @@
package io.element.android.features.messages.impl.attachments.preview package io.element.android.features.messages.impl.attachments.preview
import androidx.activity.compose.BackHandler import androidx.activity.compose.BackHandler
import androidx.compose.foundation.Image import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.IntrinsicSize import androidx.compose.foundation.layout.IntrinsicSize
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
@@ -21,15 +25,19 @@ import androidx.compose.foundation.layout.height
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.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.rememberPagerState
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.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
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.painterResource
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.heading import androidx.compose.ui.semantics.heading
import androidx.compose.ui.semantics.semantics import androidx.compose.ui.semantics.semantics
@@ -62,11 +70,11 @@ import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.theme.components.IconButton import io.element.android.libraries.designsystem.theme.components.IconButton
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.Surface
import io.element.android.libraries.designsystem.theme.components.Switch import io.element.android.libraries.designsystem.theme.components.Switch
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.CommonDrawables import io.element.android.libraries.designsystem.theme.floatingDateBadgeBackground
import io.element.android.libraries.mediaviewer.api.local.LocalMedia
import io.element.android.libraries.mediaviewer.api.local.LocalMediaRenderer import io.element.android.libraries.mediaviewer.api.local.LocalMediaRenderer
import io.element.android.libraries.preferences.api.store.VideoCompressionPreset import io.element.android.libraries.preferences.api.store.VideoCompressionPreset
import io.element.android.libraries.textcomposer.TextComposer import io.element.android.libraries.textcomposer.TextComposer
@@ -77,6 +85,9 @@ import io.element.android.libraries.ui.utils.formatter.rememberFileSizeFormatter
import io.element.android.wysiwyg.display.TextDisplay import io.element.android.wysiwyg.display.TextDisplay
import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.collectLatest
import kotlin.time.Duration.Companion.milliseconds
/** /**
* Ref: https://www.figma.com/design/zftpgS6LjiczobJZ1GUNpt/Updates-to-Media---File-Upload?node-id=51-3514 * Ref: https://www.figma.com/design/zftpgS6LjiczobJZ1GUNpt/Updates-to-Media---File-Upload?node-id=51-3514
@@ -252,6 +263,7 @@ private fun AttachmentSendStateView(
} }
} }
@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class)
@Composable @Composable
private fun AttachmentPreviewContent( private fun AttachmentPreviewContent(
state: AttachmentsPreviewState, state: AttachmentsPreviewState,
@@ -267,15 +279,74 @@ private fun AttachmentPreviewContent(
Box( Box(
modifier = Modifier modifier = Modifier
.weight(1f), .weight(1f),
contentAlignment = Alignment.Center contentAlignment = Alignment.Center,
) { ) {
when (val attachment = state.attachment) { if (state.isGallery) {
is Attachment.Media -> { val pagerState = rememberPagerState(
localMediaRenderer.Render(attachment.localMedia) initialPage = state.currentIndex,
pageCount = { state.attachments.size },
)
var isPillVisible by remember { mutableStateOf(true) }
HorizontalPager(
state = pagerState,
modifier = Modifier.fillMaxSize(),
beyondViewportPageCount = 1,
contentPadding = PaddingValues(horizontal = 20.dp),
pageSpacing = 10.dp,
) { page ->
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center,
) {
val attachment = state.attachments[page]
when (attachment) {
is Attachment.Media -> {
localMediaRenderer.Render(attachment.localMedia)
}
}
}
}
LaunchedEffect(pagerState) {
snapshotFlow { pagerState.isScrollInProgress }
.collectLatest { isScrolling ->
if (isScrolling) {
isPillVisible = true
} else {
delay(2000.milliseconds)
isPillVisible = false
}
}
}
androidx.compose.animation.AnimatedVisibility(
visible = isPillVisible,
enter = fadeIn(animationSpec = tween(150)),
exit = fadeOut(animationSpec = tween(300)),
modifier = Modifier
.align(Alignment.TopCenter)
.padding(top = 8.dp),
) {
GalleryCarouselPill(
currentIndex = pagerState.currentPage + 1,
totalCount = state.totalCount,
)
}
LaunchedEffect(pagerState.currentPage) {
state.eventSink(AttachmentsPreviewEvent.SetCurrentCarouselIndex(pagerState.currentPage))
}
} else {
val firstAttachment = state.attachments.first()
when (firstAttachment) {
is Attachment.Media -> {
localMediaRenderer.Render(firstAttachment.localMedia)
}
} }
} }
} }
val mediaInfo = (state.attachment as? Attachment.Media)?.localMedia?.info val mediaInfo = (state.attachments[state.currentIndex] as? Attachment.Media)?.localMedia?.info
if (mediaInfo?.isImageAttachment() == true) { if (mediaInfo?.isImageAttachment() == true) {
ImageOptimizationSelector(state.mediaOptimizationSelectorState) ImageOptimizationSelector(state.mediaOptimizationSelectorState)
} else if (mediaInfo?.mimeType?.isMimeTypeVideo() == true) { } else if (mediaInfo?.mimeType?.isMimeTypeVideo() == true) {
@@ -485,16 +556,16 @@ private fun AttachmentsPreviewBottomActions(
internal fun AttachmentsPreviewViewPreview(@PreviewParameter(AttachmentsPreviewStateProvider::class) state: AttachmentsPreviewState) = ElementPreviewDark { internal fun AttachmentsPreviewViewPreview(@PreviewParameter(AttachmentsPreviewStateProvider::class) state: AttachmentsPreviewState) = ElementPreviewDark {
AttachmentsPreviewView( AttachmentsPreviewView(
state = state, state = state,
localMediaRenderer = object : LocalMediaRenderer { localMediaRenderer = SampleMediaRenderer(),
@Composable )
override fun Render(localMedia: LocalMedia) { }
Image(
painter = painterResource(id = CommonDrawables.sample_background), @Preview
modifier = Modifier.fillMaxSize(), @Composable
contentDescription = null, internal fun AttachmentsPreviewGalleryViewPreview() = ElementPreviewDark {
) AttachmentsPreviewView(
} state = anAttachmentsPreviewGalleryState(),
} localMediaRenderer = SampleMediaRenderer(),
) )
} }
@@ -537,3 +608,24 @@ fun VideoCompressionPreset.subtitle(): String {
} }
) )
} }
@Composable
internal fun GalleryCarouselPill(
currentIndex: Int,
totalCount: Int,
modifier: Modifier = Modifier,
) {
Surface(
modifier = modifier,
shape = RoundedCornerShape(16.dp),
color = ElementTheme.colors.floatingDateBadgeBackground,
shadowElevation = 4.dp,
) {
Text(
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
text = stringResource(R.string.screen_media_upload_preview_item_count, currentIndex, totalCount),
style = ElementTheme.typography.fontBodyMdMedium,
color = ElementTheme.colors.textPrimary,
)
}
}
@@ -0,0 +1,32 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.messages.impl.attachments.preview
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource
import io.element.android.libraries.designsystem.utils.CommonDrawables
import io.element.android.libraries.mediaviewer.api.local.LocalMedia
import io.element.android.libraries.mediaviewer.api.local.LocalMediaRenderer
/**
* An implementation of [LocalMediaRenderer] that displays a sample background image.
* To be used for Previews only.
*/
internal class SampleMediaRenderer : LocalMediaRenderer {
@Composable
override fun Render(localMedia: LocalMedia) {
Image(
painter = painterResource(id = CommonDrawables.sample_background),
modifier = Modifier.fillMaxSize(),
contentDescription = null,
)
}
}
@@ -36,6 +36,7 @@ import kotlin.math.roundToLong
@AssistedInject @AssistedInject
class DefaultMediaOptimizationSelectorPresenter( class DefaultMediaOptimizationSelectorPresenter(
@Assisted private val index: Int,
@Assisted private val localMedia: LocalMedia, @Assisted private val localMedia: LocalMedia,
@Assisted private val sendAsFile: Boolean, @Assisted private val sendAsFile: Boolean,
private val maxUploadSizeProvider: MaxUploadSizeProvider, private val maxUploadSizeProvider: MaxUploadSizeProvider,
@@ -48,6 +49,7 @@ class DefaultMediaOptimizationSelectorPresenter(
@AssistedFactory @AssistedFactory
interface Factory : MediaOptimizationSelectorPresenter.Factory { interface Factory : MediaOptimizationSelectorPresenter.Factory {
override fun create( override fun create(
index: Int,
localMedia: LocalMedia, localMedia: LocalMedia,
sendAsFile: Boolean, sendAsFile: Boolean,
): DefaultMediaOptimizationSelectorPresenter ): DefaultMediaOptimizationSelectorPresenter
@@ -183,6 +185,7 @@ class DefaultMediaOptimizationSelectorPresenter(
} }
return MediaOptimizationSelectorState( return MediaOptimizationSelectorState(
index = index,
maxUploadSize = maxUploadSize, maxUploadSize = maxUploadSize,
videoSizeEstimations = videoSizeEstimations, videoSizeEstimations = videoSizeEstimations,
isImageOptimizationEnabled = selectedImageOptimization.dataOrNull(), isImageOptimizationEnabled = selectedImageOptimization.dataOrNull(),
@@ -14,6 +14,7 @@ import io.element.android.libraries.mediaviewer.api.local.LocalMedia
fun interface MediaOptimizationSelectorPresenter : Presenter<MediaOptimizationSelectorState> { fun interface MediaOptimizationSelectorPresenter : Presenter<MediaOptimizationSelectorState> {
interface Factory { interface Factory {
fun create( fun create(
index: Int,
localMedia: LocalMedia, localMedia: LocalMedia,
sendAsFile: Boolean, sendAsFile: Boolean,
): MediaOptimizationSelectorPresenter ): MediaOptimizationSelectorPresenter
@@ -13,6 +13,8 @@ import io.element.android.libraries.preferences.api.store.VideoCompressionPreset
import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableList
data class MediaOptimizationSelectorState( data class MediaOptimizationSelectorState(
// Adding an index helps to fix a Compose issue where the state of the wrong item is updated
val index: Int,
val maxUploadSize: AsyncData<Long>, val maxUploadSize: AsyncData<Long>,
val videoSizeEstimations: AsyncData<ImmutableList<VideoUploadEstimation>>, val videoSizeEstimations: AsyncData<ImmutableList<VideoUploadEstimation>>,
val isImageOptimizationEnabled: Boolean?, val isImageOptimizationEnabled: Boolean?,
@@ -35,6 +35,7 @@ internal fun MessagesViewWithIdentityChangePreview(
onBackClick = {}, onBackClick = {},
onRoomDetailsClick = {}, onRoomDetailsClick = {},
onEventContentClick = { _, _ -> false }, onEventContentClick = { _, _ -> false },
onGalleryEventItemClick = { _, _, _ -> false },
onUserDataClick = {}, onUserDataClick = {},
onLinkClick = { _, _ -> }, onLinkClick = { _, _ -> },
onSendLocationClick = {}, onSendLocationClick = {},
@@ -14,7 +14,8 @@ 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.systemBarsPadding
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.material3.SheetValue
import androidx.compose.material3.rememberBottomSheetState
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
@@ -43,7 +44,10 @@ fun ResolveVerifiedUserSendFailureView(
state: ResolveVerifiedUserSendFailureState, state: ResolveVerifiedUserSendFailureState,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) val sheetState = rememberBottomSheetState(
initialValue = SheetValue.Hidden,
enabledValues = setOf(SheetValue.Hidden, SheetValue.Expanded),
)
var showSheet by remember { mutableStateOf(false) } var showSheet by remember { mutableStateOf(false) }
fun dismiss() { fun dismiss() {
@@ -16,7 +16,8 @@ import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.material3.SheetValue
import androidx.compose.material3.rememberBottomSheetState
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
@@ -72,8 +73,9 @@ internal fun AttachmentsBottomSheet(
if (isVisible) { if (isVisible) {
ModalBottomSheet( ModalBottomSheet(
modifier = modifier, modifier = modifier,
sheetState = rememberModalBottomSheetState( sheetState = rememberBottomSheetState(
skipPartiallyExpanded = true initialValue = SheetValue.Hidden,
enabledValues = setOf(SheetValue.Hidden, SheetValue.Expanded),
), ),
onDismissRequest = { isVisible = false }, onDismissRequest = { isVisible = false },
scrollable = false, scrollable = false,
@@ -151,10 +153,11 @@ private fun AttachmentSourcePickerMenu(
@PreviewsDayNight @PreviewsDayNight
@Composable @Composable
internal fun AttachmentSourcePickerMenuPreview() = ElementPreview { internal fun AttachmentsBottomSheetPreview() = ElementPreview(fillMaxSize = true) {
AttachmentSourcePickerMenu( AttachmentsBottomSheet(
state = aMessageComposerState( state = aMessageComposerState(
canShareLocation = true, canShareLocation = true,
showAttachmentSourcePicker = true,
), ),
onSendLocationClick = {}, onSendLocationClick = {},
onCreatePollClick = {}, onCreatePollClick = {},
@@ -34,7 +34,6 @@ import im.vector.app.features.analytics.plan.Interaction
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
import io.element.android.features.messages.impl.attachments.Attachment.Media
import io.element.android.features.messages.impl.attachments.preview.error.sendAttachmentError import io.element.android.features.messages.impl.attachments.preview.error.sendAttachmentError
import io.element.android.features.messages.impl.draft.ComposerDraftService import io.element.android.features.messages.impl.draft.ComposerDraftService
import io.element.android.features.messages.impl.messagecomposer.suggestions.RoomAliasSuggestionsDataSource import io.element.android.features.messages.impl.messagecomposer.suggestions.RoomAliasSuggestionsDataSource
@@ -48,6 +47,8 @@ import io.element.android.libraries.core.mimetype.MimeTypes
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarMessage import io.element.android.libraries.designsystem.utils.snackbar.SnackbarMessage
import io.element.android.libraries.di.annotations.SessionCoroutineScope import io.element.android.libraries.di.annotations.SessionCoroutineScope
import io.element.android.libraries.featureflag.api.FeatureFlagService
import io.element.android.libraries.featureflag.api.FeatureFlags
import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.ThreadId import io.element.android.libraries.matrix.api.core.ThreadId
import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.core.UserId
@@ -133,6 +134,7 @@ class MessageComposerPresenter(
private val mediaOptimizationConfigProvider: MediaOptimizationConfigProvider, private val mediaOptimizationConfigProvider: MediaOptimizationConfigProvider,
private val notificationConversationService: NotificationConversationService, private val notificationConversationService: NotificationConversationService,
private val slashCommandService: SlashCommandService, private val slashCommandService: SlashCommandService,
private val featureFlagService: FeatureFlagService,
) : Presenter<MessageComposerState> { ) : Presenter<MessageComposerState> {
@AssistedFactory @AssistedFactory
interface Factory { interface Factory {
@@ -177,10 +179,19 @@ class MessageComposerPresenter(
canShareLocation.value = locationService.isServiceAvailable() canShareLocation.value = locationService.isServiceAvailable()
} }
val isSendGalleryMessagesEnabled by featureFlagService.isFeatureEnabledFlow(FeatureFlags.SendGalleryMessages)
.collectAsState(initial = false)
val galleryMediaPicker = mediaPickerProvider.registerGalleryPicker { uri, mimeType -> val galleryMediaPicker = mediaPickerProvider.registerGalleryPicker { uri, mimeType ->
handlePickedMedia(uri, mimeType) handlePickedMedia(uri, mimeType)
} }
val filesPicker = mediaPickerProvider.registerFilePicker(AnyMimeTypes) { uri, mimeType -> val galleryMultiMediaPicker = mediaPickerProvider.registerGalleryMultiPicker { uris ->
handlePickedMediaList(uris)
}
val filesPicker = mediaPickerProvider.registerFileMultiPicker(AnyMimeTypes) { uris ->
handlePickedMediaList(uris, sendAsFile = true)
}
val fileSinglePicker = mediaPickerProvider.registerFilePicker(AnyMimeTypes) { uri, mimeType ->
handlePickedMedia(uri, mimeType ?: MimeTypes.OctetStream, sendAsFile = true) handlePickedMedia(uri, mimeType ?: MimeTypes.OctetStream, sendAsFile = true)
} }
val cameraPhotoPicker = mediaPickerProvider.registerCameraPhotoPicker { uri -> val cameraPhotoPicker = mediaPickerProvider.registerCameraPhotoPicker { uri ->
@@ -266,7 +277,7 @@ class MessageComposerPresenter(
is MessageComposerEvent.SendUri -> { is MessageComposerEvent.SendUri -> {
val inReplyToEventId = (messageComposerContext.composerMode as? MessageComposerMode.Reply)?.eventId val inReplyToEventId = (messageComposerContext.composerMode as? MessageComposerMode.Reply)?.eventId
sessionCoroutineScope.sendAttachment( sessionCoroutineScope.sendAttachment(
attachment = Media( attachment = Attachment.Media(
localMedia = localMediaFactory.createFromUri( localMedia = localMediaFactory.createFromUri(
uri = event.uri, uri = event.uri,
mimeType = null, mimeType = null,
@@ -277,8 +288,7 @@ class MessageComposerPresenter(
inReplyToEventId = inReplyToEventId, inReplyToEventId = inReplyToEventId,
) )
// Reset composer since the attachment has been sent resetComposerModeAfterAttaching()
messageComposerContext.composerMode = MessageComposerMode.Normal
} }
is MessageComposerEvent.SetMode -> { is MessageComposerEvent.SetMode -> {
localCoroutineScope.setMode(event.composerMode, markdownTextEditorState, richTextEditorState) localCoroutineScope.setMode(event.composerMode, markdownTextEditorState, richTextEditorState)
@@ -289,11 +299,19 @@ class MessageComposerPresenter(
MessageComposerEvent.DismissAttachmentMenu -> showAttachmentSourcePicker = false MessageComposerEvent.DismissAttachmentMenu -> showAttachmentSourcePicker = false
MessageComposerEvent.PickAttachmentSource.FromGallery -> localCoroutineScope.launch { MessageComposerEvent.PickAttachmentSource.FromGallery -> localCoroutineScope.launch {
showAttachmentSourcePicker = false showAttachmentSourcePicker = false
galleryMediaPicker.launch() if (isSendGalleryMessagesEnabled) {
galleryMultiMediaPicker.launch()
} else {
galleryMediaPicker.launch()
}
} }
MessageComposerEvent.PickAttachmentSource.FromFiles -> localCoroutineScope.launch { MessageComposerEvent.PickAttachmentSource.FromFiles -> localCoroutineScope.launch {
showAttachmentSourcePicker = false showAttachmentSourcePicker = false
filesPicker.launch() if (isSendGalleryMessagesEnabled) {
filesPicker.launch()
} else {
fileSinglePicker.launch()
}
} }
MessageComposerEvent.PickAttachmentSource.PhotoFromCamera -> localCoroutineScope.launch { MessageComposerEvent.PickAttachmentSource.PhotoFromCamera -> localCoroutineScope.launch {
showAttachmentSourcePicker = false showAttachmentSourcePicker = false
@@ -619,8 +637,39 @@ class MessageComposerPresenter(
val inReplyToEventId = (messageComposerContext.composerMode as? MessageComposerMode.Reply)?.eventId val inReplyToEventId = (messageComposerContext.composerMode as? MessageComposerMode.Reply)?.eventId
navigator.navigateToPreviewAttachments(persistentListOf(mediaAttachment), inReplyToEventId) navigator.navigateToPreviewAttachments(persistentListOf(mediaAttachment), inReplyToEventId)
// Reset composer since the attachment will be sent in a separate flow resetComposerModeAfterAttaching()
messageComposerContext.composerMode = MessageComposerMode.Normal }
private fun handlePickedMediaList(
uris: List<Uri>,
sendAsFile: Boolean = false,
) {
if (uris.isEmpty()) return
if (uris.size == 1) {
handlePickedMedia(uris.first(), sendAsFile = sendAsFile)
return
}
val attachments = uris.map { uri ->
val localMedia = localMediaFactory.createFromUri(
uri = uri,
mimeType = null,
name = null,
formattedFileSize = null,
)
Attachment.Media(localMedia, sendAsFile = sendAsFile)
}.toImmutableList()
val inReplyToEventId = (messageComposerContext.composerMode as? MessageComposerMode.Reply)?.eventId
navigator.navigateToPreviewAttachments(attachments, inReplyToEventId)
resetComposerModeAfterAttaching()
}
private fun resetComposerModeAfterAttaching() {
// An attachment is sent as its own message, so a Reply/Normal mode is consumed and reset.
// An in-progress edit must survive: the typed text still edits the original on the next send.
if (!messageComposerContext.composerMode.isEditing) {
messageComposerContext.composerMode = MessageComposerMode.Normal
}
} }
private suspend fun sendMedia( private suspend fun sendMedia(
@@ -53,6 +53,7 @@ class PinnedMessagesListNode(
) : Node(buildContext, plugins = plugins), PinnedMessagesListNavigator { ) : Node(buildContext, plugins = plugins), PinnedMessagesListNavigator {
interface Callback : Plugin { interface Callback : Plugin {
fun handleEventClick(event: TimelineItem.Event, canUseOverlay: Boolean) fun handleEventClick(event: TimelineItem.Event, canUseOverlay: Boolean)
fun handleGalleryItemClick(event: TimelineItem.Event, galleryItemIndex: Int, canUseOverlay: Boolean)
fun navigateToRoomMemberDetails(userId: UserId) fun navigateToRoomMemberDetails(userId: UserId)
fun viewInTimeline(eventId: EventId) fun viewInTimeline(eventId: EventId)
fun handlePermalinkClick(data: PermalinkData.RoomLink) fun handlePermalinkClick(data: PermalinkData.RoomLink)
@@ -119,6 +120,9 @@ class PinnedMessagesListNode(
onEventClick = { onEventClick = {
callback.handleEventClick(it, canUseOverlay) callback.handleEventClick(it, canUseOverlay)
}, },
onGalleryItemClick = { event, index ->
callback.handleGalleryItemClick(event, index, canUseOverlay)
},
onUserDataClick = { callback.navigateToRoomMemberDetails(it.userId) }, onUserDataClick = { callback.navigateToRoomMemberDetails(it.userId) },
onLinkClick = { link -> onLinkClick(context, link.url) }, onLinkClick = { link -> onLinkClick(context, link.url) },
onLinkLongClick = { onLinkLongClick = {
@@ -60,6 +60,7 @@ fun PinnedMessagesListView(
state: PinnedMessagesListState, state: PinnedMessagesListState,
onBackClick: () -> Unit, onBackClick: () -> Unit,
onEventClick: (event: TimelineItem.Event) -> Unit, onEventClick: (event: TimelineItem.Event) -> Unit,
onGalleryItemClick: (event: TimelineItem.Event, index: Int) -> Unit,
onUserDataClick: (MatrixUser) -> Unit, onUserDataClick: (MatrixUser) -> Unit,
onLinkClick: (Link) -> Unit, onLinkClick: (Link) -> Unit,
onLinkLongClick: (Link) -> Unit, onLinkLongClick: (Link) -> Unit,
@@ -81,6 +82,7 @@ fun PinnedMessagesListView(
PinnedMessagesListContent( PinnedMessagesListContent(
state = state, state = state,
onEventClick = onEventClick, onEventClick = onEventClick,
onGalleryItemClick = onGalleryItemClick,
onUserDataClick = onUserDataClick, onUserDataClick = onUserDataClick,
onLinkClick = onLinkClick, onLinkClick = onLinkClick,
onLinkLongClick = onLinkLongClick, onLinkLongClick = onLinkLongClick,
@@ -111,6 +113,7 @@ private fun PinnedMessagesListTopBar(
private fun PinnedMessagesListContent( private fun PinnedMessagesListContent(
state: PinnedMessagesListState, state: PinnedMessagesListState,
onEventClick: (event: TimelineItem.Event) -> Unit, onEventClick: (event: TimelineItem.Event) -> Unit,
onGalleryItemClick: (event: TimelineItem.Event, index: Int) -> Unit,
onUserDataClick: (MatrixUser) -> Unit, onUserDataClick: (MatrixUser) -> Unit,
onLinkClick: (Link) -> Unit, onLinkClick: (Link) -> Unit,
onLinkLongClick: (Link) -> Unit, onLinkLongClick: (Link) -> Unit,
@@ -131,6 +134,7 @@ private fun PinnedMessagesListContent(
state = state, state = state,
displayThreadSummaries = state.displayThreadSummaries, displayThreadSummaries = state.displayThreadSummaries,
onEventClick = onEventClick, onEventClick = onEventClick,
onGalleryItemClick = onGalleryItemClick,
onUserDataClick = onUserDataClick, onUserDataClick = onUserDataClick,
onLinkClick = onLinkClick, onLinkClick = onLinkClick,
onLinkLongClick = onLinkLongClick, onLinkLongClick = onLinkLongClick,
@@ -169,6 +173,7 @@ private fun PinnedMessagesListLoaded(
state: PinnedMessagesListState.Filled, state: PinnedMessagesListState.Filled,
displayThreadSummaries: Boolean, displayThreadSummaries: Boolean,
onEventClick: (event: TimelineItem.Event) -> Unit, onEventClick: (event: TimelineItem.Event) -> Unit,
onGalleryItemClick: (event: TimelineItem.Event, index: Int) -> Unit,
onUserDataClick: (MatrixUser) -> Unit, onUserDataClick: (MatrixUser) -> Unit,
onLinkClick: (Link) -> Unit, onLinkClick: (Link) -> Unit,
onLinkLongClick: (Link) -> Unit, onLinkLongClick: (Link) -> Unit,
@@ -226,6 +231,7 @@ private fun PinnedMessagesListLoaded(
}, },
onLinkLongClick = onLinkLongClick, onLinkLongClick = onLinkLongClick,
onContentClick = onEventClick, onContentClick = onEventClick,
onGalleryItemClick = onGalleryItemClick,
onLongClick = ::onMessageLongClick, onLongClick = ::onMessageLongClick,
displayThreadSummaries = displayThreadSummaries, displayThreadSummaries = displayThreadSummaries,
inReplyToClick = {}, inReplyToClick = {},
@@ -245,6 +251,7 @@ private fun PinnedMessagesListLoaded(
event = event, event = event,
timelineProtectionState = state.timelineProtectionState, timelineProtectionState = state.timelineProtectionState,
onContentClick = { onEventClick(event) }, onContentClick = { onEventClick(event) },
onGalleryItemClick = { index -> onGalleryItemClick(event, index) },
onLongClick = { onMessageLongClick(event) }, onLongClick = { onMessageLongClick(event) },
onLinkClick = { link -> onLinkClick = { link ->
state.linkState.eventSink(LinkEvent.OnLinkClick(link)) state.linkState.eventSink(LinkEvent.OnLinkClick(link))
@@ -268,6 +275,7 @@ private fun TimelineItemEventContentViewWrapper(
event: TimelineItem.Event, event: TimelineItem.Event,
timelineProtectionState: TimelineProtectionState, timelineProtectionState: TimelineProtectionState,
onContentClick: () -> Unit, onContentClick: () -> Unit,
onGalleryItemClick: (index: Int) -> Unit,
onLinkClick: (Link) -> Unit, onLinkClick: (Link) -> Unit,
onLinkLongClick: (Link) -> Unit, onLinkLongClick: (Link) -> Unit,
onLongClick: (() -> Unit)?, onLongClick: (() -> Unit)?,
@@ -285,6 +293,7 @@ private fun TimelineItemEventContentViewWrapper(
content = event.content, content = event.content,
hideMediaContent = timelineProtectionState.hideMediaContent(event.eventId, event.isMine), hideMediaContent = timelineProtectionState.hideMediaContent(event.eventId, event.isMine),
onShowContentClick = { timelineProtectionState.eventSink(TimelineProtectionEvent.ShowContent(event.eventId)) }, onShowContentClick = { timelineProtectionState.eventSink(TimelineProtectionEvent.ShowContent(event.eventId)) },
onGalleryItemClick = onGalleryItemClick,
onLinkClick = onLinkClick, onLinkClick = onLinkClick,
onLinkLongClick = onLinkLongClick, onLinkLongClick = onLinkLongClick,
eventSink = { }, eventSink = { },
@@ -304,6 +313,7 @@ internal fun PinnedMessagesListViewPreview(@PreviewParameter(PinnedMessagesListS
state = state, state = state,
onBackClick = {}, onBackClick = {},
onEventClick = { }, onEventClick = { },
onGalleryItemClick = { _, _ -> },
onUserDataClick = {}, onUserDataClick = {},
onLinkClick = {}, onLinkClick = {},
onLinkLongClick = {}, onLinkLongClick = {},
@@ -126,6 +126,7 @@ class ThreadedMessagesNode(
interface Callback : Plugin { interface Callback : Plugin {
fun handleEventClick(timelineMode: Timeline.Mode, event: TimelineItem.Event, canUseOverlay: Boolean): Boolean fun handleEventClick(timelineMode: Timeline.Mode, event: TimelineItem.Event, canUseOverlay: Boolean): Boolean
fun handleGalleryItemClick(timelineMode: Timeline.Mode, event: TimelineItem.Event, galleryItemIndex: Int, canUseOverlay: Boolean): Boolean
fun navigateToPreviewAttachments(attachments: ImmutableList<Attachment>, inReplyToEventId: EventId?) fun navigateToPreviewAttachments(attachments: ImmutableList<Attachment>, inReplyToEventId: EventId?)
fun navigateToRoomMemberDetails(userId: UserId) fun navigateToRoomMemberDetails(userId: UserId)
fun handlePermalinkClick(data: PermalinkData) fun handlePermalinkClick(data: PermalinkData)
@@ -289,6 +290,20 @@ class ThreadedMessagesNode(
} }
} == true } == true
}, },
onGalleryEventItemClick = { isLive, event, index ->
timelineController?.let { controller ->
if (isLive) {
callback.handleGalleryItemClick(controller.mainTimelineMode(), event, index, canUseOverlay)
} else {
val detachedTimelineMode = controller.detachedTimelineMode()
if (detachedTimelineMode != null) {
callback.handleGalleryItemClick(detachedTimelineMode, event, index, canUseOverlay)
} else {
false
}
}
} == true
},
onUserDataClick = callback::navigateToRoomMemberDetails, onUserDataClick = callback::navigateToRoomMemberDetails,
onLinkClick = { url, customTab -> onLinkClick = { url, customTab ->
onLinkClick( onLinkClick(
@@ -9,8 +9,10 @@ package io.element.android.features.messages.impl.threads.list
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.produceState import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import dev.zacsweers.metro.Inject import dev.zacsweers.metro.Inject
import io.element.android.features.messages.impl.timeline.factories.event.TimelineItemContentFactory import io.element.android.features.messages.impl.timeline.factories.event.TimelineItemContentFactory
@@ -18,9 +20,12 @@ import io.element.android.features.messages.impl.utils.messagesummary.MessageSum
import io.element.android.libraries.architecture.Presenter 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.designsystem.components.avatar.AvatarData
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.room.JoinedRoom import io.element.android.libraries.matrix.api.room.JoinedRoom
import io.element.android.libraries.matrix.api.room.threads.ThreadListPaginationStatus import io.element.android.libraries.matrix.api.room.threads.ThreadListPaginationStatus
import io.element.android.libraries.matrix.ui.model.getAvatarData
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
@@ -95,6 +100,10 @@ class ThreadsListPresenter(
val roomInfo by room.roomInfoFlow.collectAsState() val roomInfo by room.roomInfoFlow.collectAsState()
val heroes by remember {
derivedStateOf { roomInfo.heroes.map { it.getAvatarData(AvatarSize.CurrentUserTopBar) }.toImmutableList() }
}
fun handleEvent(event: ThreadsListEvents) { fun handleEvent(event: ThreadsListEvents) {
when (event) { when (event) {
ThreadsListEvents.Paginate -> if ((paginationStatus as? ThreadListPaginationStatus.Idle)?.hasMoreToLoad == true) { ThreadsListEvents.Paginate -> if ((paginationStatus as? ThreadListPaginationStatus.Idle)?.hasMoreToLoad == true) {
@@ -115,6 +124,7 @@ class ThreadsListPresenter(
roomName = roomInfo.name ?: room.roomId.value, roomName = roomInfo.name ?: room.roomId.value,
roomAvatarUrl = roomInfo.avatarUrl, roomAvatarUrl = roomInfo.avatarUrl,
isRoomTombstoned = roomInfo.successorRoom != null, isRoomTombstoned = roomInfo.successorRoom != null,
heroes = heroes,
eventSink = ::handleEvent, eventSink = ::handleEvent,
) )
} }
@@ -125,6 +135,7 @@ data class ThreadsListState(
val roomName: String, val roomName: String,
val roomAvatarUrl: String?, val roomAvatarUrl: String?,
val isRoomTombstoned: Boolean, val isRoomTombstoned: Boolean,
val heroes: ImmutableList<AvatarData>,
val threads: ImmutableList<ThreadListRowItem>, val threads: ImmutableList<ThreadListRowItem>,
val eventSink: (ThreadsListEvents) -> Unit, val eventSink: (ThreadsListEvents) -> Unit,
) )
@@ -67,6 +67,7 @@ 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.ui.strings.CommonStrings import io.element.android.libraries.ui.strings.CommonStrings
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
@@ -102,7 +103,10 @@ fun ThreadsListView(
url = state.roomAvatarUrl, url = state.roomAvatarUrl,
size = AvatarSize.CurrentUserTopBar, size = AvatarSize.CurrentUserTopBar,
), ),
avatarType = AvatarType.Room(isTombstoned = state.isRoomTombstoned), avatarType = AvatarType.Room(
heroes = state.heroes,
isTombstoned = state.isRoomTombstoned,
),
contentDescription = null, contentDescription = null,
) )
Column { Column {
@@ -323,6 +327,7 @@ internal fun ThreadsListViewPreview() {
roomId = RoomId("!room-id:server"), roomId = RoomId("!room-id:server"),
roomName = ROOM_NAME, roomName = ROOM_NAME,
roomAvatarUrl = null, roomAvatarUrl = null,
heroes = persistentListOf(),
threads = List(10) { aThreadListRowItem(threadId = ThreadId("\$thread-$it")) }.toImmutableList(), threads = List(10) { aThreadListRowItem(threadId = ThreadId("\$thread-$it")) }.toImmutableList(),
isRoomTombstoned = false, isRoomTombstoned = false,
eventSink = {}, eventSink = {},
@@ -25,6 +25,8 @@ sealed interface TimelineEvent {
data object HideShieldDialog : TimelineEvent data object HideShieldDialog : TimelineEvent
data object MarkAllAsRead : TimelineEvent
/** /**
* Events coming from a timeline item. * Events coming from a timeline item.
*/ */
@@ -33,6 +33,7 @@ import io.element.android.features.messages.impl.timeline.factories.TimelineItem
import io.element.android.features.messages.impl.timeline.factories.TimelineItemsFactoryConfig import io.element.android.features.messages.impl.timeline.factories.TimelineItemsFactoryConfig
import io.element.android.features.messages.impl.timeline.model.NewEventState 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.TimelineItemTypingNotificationModel import io.element.android.features.messages.impl.timeline.model.virtual.TimelineItemTypingNotificationModel
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
@@ -96,6 +97,7 @@ class TimelinePresenter(
private val featureFlagService: FeatureFlagService, private val featureFlagService: FeatureFlagService,
private val analyticsService: AnalyticsService, private val analyticsService: AnalyticsService,
private val liveLocationShareManager: ActiveLiveLocationShareManager, private val liveLocationShareManager: ActiveLiveLocationShareManager,
private val markAsFullyRead: MarkAsFullyRead,
) : Presenter<TimelineState> { ) : Presenter<TimelineState> {
private val tag = "TimelinePresenter" private val tag = "TimelinePresenter"
@@ -134,9 +136,15 @@ class TimelinePresenter(
val prevMostRecentItemId = rememberSaveable { mutableStateOf<UniqueId?>(null) } val prevMostRecentItemId = rememberSaveable { mutableStateOf<UniqueId?>(null) }
val newEventState = remember { mutableStateOf(NewEventState.None) } val newEventState = remember { mutableStateOf<NewEventState>(NewEventState.None) }
val messageShieldDialogData: MutableState<MessageShieldData?> = remember { mutableStateOf(null) } val messageShieldDialogData: MutableState<MessageShieldData?> = remember { mutableStateOf(null) }
// Forces [JumpToUnreadState.Hidden] until the next RoomInfo push. Set after a
// [TimelineEvent.MarkAllAsRead] await completes so the FAB hides without waiting for
// the SDK to push a refreshed fully-read marker; the after-await ordering means any
// RoomInfo update racing the mark-as-read call has already landed and can't undo this.
val suppressJumpToUnread = remember { mutableStateOf(false) }
val resolveVerifiedUserSendFailureState = resolveVerifiedUserSendFailurePresenter.present() val resolveVerifiedUserSendFailureState = resolveVerifiedUserSendFailurePresenter.present()
val isLive by remember { val isLive by remember {
timelineController.isLive() timelineController.isLive()
@@ -145,6 +153,9 @@ class TimelinePresenter(
val displayThreadSummaries by produceState(false) { val displayThreadSummaries by produceState(false) {
value = featureFlagService.isFeatureEnabled(FeatureFlags.Threads) value = featureFlagService.isFeatureEnabled(FeatureFlags.Threads)
} }
val displayJumpToUnread by produceState(false) {
value = featureFlagService.isFeatureEnabled(FeatureFlags.JumpToUnread)
}
fun handleEvent(event: TimelineEvent) { fun handleEvent(event: TimelineEvent) {
when (event) { when (event) {
@@ -218,6 +229,14 @@ class TimelinePresenter(
timelineController.focusOnLive() timelineController.focusOnLive()
} }
TimelineEvent.HideShieldDialog -> messageShieldDialogData.value = null TimelineEvent.HideShieldDialog -> messageShieldDialogData.value = null
TimelineEvent.MarkAllAsRead -> sessionCoroutineScope.launch {
val latestEventId = room.liveTimeline.getLatestEventId().getOrElse {
Timber.tag(tag).w(it, "Failed to get latest event id to mark as fully read")
null
} ?: return@launch
markAsFullyRead(room.roomId, latestEventId)
suppressJumpToUnread.value = true
}
is TimelineEvent.ShowShieldDialog -> messageShieldDialogData.value = event.messageShieldData is TimelineEvent.ShowShieldDialog -> messageShieldDialogData.value = event.messageShieldData
is TimelineEvent.ComputeVerifiedUserSendFailure -> { is TimelineEvent.ComputeVerifiedUserSendFailure -> {
resolveVerifiedUserSendFailureState.eventSink(ResolveVerifiedUserSendFailureEvent.ComputeForMessage(event.event)) resolveVerifiedUserSendFailureState.eventSink(ResolveVerifiedUserSendFailureEvent.ComputeForMessage(event.event))
@@ -274,7 +293,67 @@ class TimelinePresenter(
computeNewItemState(timelineItems, prevMostRecentItemId, newEventState) computeNewItemState(timelineItems, prevMostRecentItemId, newEventState)
} }
LaunchedEffect(timelineItems.size, focusRequestState.value) { // Keyed on the full [timelineItems] reference (not just .size) so we re-scan when the
// read marker advances in place — the SDK swaps the marker virtual item to a new position
// without changing the list length, e.g. when [markRoomAsFullyRead] is sent while at the
// bottom of the room.
//
// The state has three shapes:
// - InWindow: the SDK has materialised a virtual ReadMarker item in the loaded window;
// tapping the FAB smoothly scrolls to its index.
// - OutOfWindow: the marker event is older than the loaded window, so the SDK gives us
// only the event id via RoomInfo.fullyReadEventId; tapping triggers a focused-event
// load via the existing TimelineEvent.FocusOnEvent path.
// - Hidden: feature flag off, no marker, caught-up (marker loaded but no virtual item),
// or initial load (no items yet).
val jumpToUnread = remember { mutableStateOf<JumpToUnreadState>(JumpToUnreadState.Hidden) }
// The SDK is authoritative again once it pushes a new fully-read marker, so drop the
// post-mark-as-read suppression and let the recompute below pick up the new value.
LaunchedEffect(roomInfo.fullyReadEventId) {
suppressJumpToUnread.value = false
}
LaunchedEffect(
timelineItems.map { it.identifier() },
displayJumpToUnread,
roomInfo.fullyReadEventId,
roomInfo.numUnreadMessages,
suppressJumpToUnread.value,
) {
if (!displayJumpToUnread || suppressJumpToUnread.value) {
jumpToUnread.value = JumpToUnreadState.Hidden
return@LaunchedEffect
}
val items = timelineItems
val fullyReadEventId = roomInfo.fullyReadEventId
val hasUnreadMessages = roomInfo.numUnreadMessages > 0
val markerIndex = withContext(dispatchers.computation) {
items.indexOfFirst {
(it as? TimelineItem.Virtual)?.model is TimelineItemReadMarkerModel
}
}
jumpToUnread.value = when {
markerIndex >= 0 -> JumpToUnreadState.InWindow(markerIndex)
// Out-of-window only when there is genuinely unread *displayable* content
// (numUnreadMessages counts "interesting" messages, never state/hidden events) AND
// the marker event isn't merely an in-window item we don't render. isKnown is the
// cheap display-index check; isEventLoaded falls back to the SDK to tell
// "in window but not displayed" apart from "genuinely out of window".
fullyReadEventId != null &&
hasUnreadMessages &&
items.isNotEmpty() &&
!timelineItemIndexer.isKnown(fullyReadEventId) &&
!timelineController.activeTimelineFlow().value.isEventLoaded(fullyReadEventId) ->
JumpToUnreadState.OutOfWindow(fullyReadEventId)
else -> JumpToUnreadState.Hidden
}
}
// Keyed on the full [timelineItems] reference (not just .size) so we re-resolve the index
// when a focused timeline loads with the same item count as the window it replaced — e.g.
// jumping to an out-of-window read marker in a busy room, where both windows fill to the
// same page size. With .size as the key the effect wouldn't re-run, the focused event's
// index would stay unresolved, and the scroll would never fire until a second tap.
LaunchedEffect(timelineItems.map { it.identifier() }, focusRequestState.value) {
val currentFocusRequestState = focusRequestState.value val currentFocusRequestState = focusRequestState.value
if (currentFocusRequestState is FocusRequestState.Success && !currentFocusRequestState.rendered) { if (currentFocusRequestState is FocusRequestState.Success && !currentFocusRequestState.rendered) {
val eventId = currentFocusRequestState.eventId val eventId = currentFocusRequestState.eventId
@@ -321,6 +400,8 @@ class TimelinePresenter(
messageShieldDialogData = messageShieldDialogData.value, messageShieldDialogData = messageShieldDialogData.value,
resolveVerifiedUserSendFailureState = resolveVerifiedUserSendFailureState, resolveVerifiedUserSendFailureState = resolveVerifiedUserSendFailureState,
displayThreadSummaries = displayThreadSummaries, displayThreadSummaries = displayThreadSummaries,
displayJumpToUnread = displayJumpToUnread,
jumpToUnread = jumpToUnread.value,
eventSink = ::handleEvent, eventSink = ::handleEvent,
) )
} }
@@ -382,7 +463,7 @@ class TimelinePresenter(
private suspend fun computeNewItemState( private suspend fun computeNewItemState(
timelineItems: ImmutableList<TimelineItem>, timelineItems: ImmutableList<TimelineItem>,
prevMostRecentItemId: MutableState<UniqueId?>, prevMostRecentItemId: MutableState<UniqueId?>,
newEventState: MutableState<NewEventState> newEventState: MutableState<NewEventState>,
) = withContext(dispatchers.computation) { ) = withContext(dispatchers.computation) {
// FromMe is prioritized over FromOther, so skip if we already have a FromMe // FromMe is prioritized over FromOther, so skip if we already have a FromMe
if (newEventState.value == NewEventState.FromMe) { if (newEventState.value == NewEventState.FromMe) {
@@ -401,12 +482,7 @@ class TimelinePresenter(
if (hasNewEvent) { if (hasNewEvent) {
// Scroll to bottom if the new event is from me, even if sent from another device // Scroll to bottom if the new event is from me, even if sent from another device
val fromMe = newMostRecentItem.isMine newEventState.value = if (newMostRecentItem.isMine) NewEventState.FromMe else NewEventState.FromOther
newEventState.value = if (fromMe) {
NewEventState.FromMe
} else {
NewEventState.FromOther
}
} }
prevMostRecentItemId.value = newMostRecentItemId prevMostRecentItemId.value = newMostRecentItemId
} }
@@ -33,6 +33,8 @@ data class TimelineState(
val messageShieldDialogData: MessageShieldData?, val messageShieldDialogData: MessageShieldData?,
val resolveVerifiedUserSendFailureState: ResolveVerifiedUserSendFailureState, val resolveVerifiedUserSendFailureState: ResolveVerifiedUserSendFailureState,
val displayThreadSummaries: Boolean, val displayThreadSummaries: Boolean,
val displayJumpToUnread: Boolean,
val jumpToUnread: JumpToUnreadState,
val eventSink: (TimelineEvent) -> Unit, val eventSink: (TimelineEvent) -> Unit,
) { ) {
private val lastTimelineEvent = timelineItems.firstOrNull { it is TimelineItem.Event } as? TimelineItem.Event private val lastTimelineEvent = timelineItems.firstOrNull { it is TimelineItem.Event } as? TimelineItem.Event
@@ -81,3 +83,18 @@ data class TimelineRoomInfo(
val typingNotificationState: TypingNotificationState, val typingNotificationState: TypingNotificationState,
val predecessorRoom: PredecessorRoom?, val predecessorRoom: PredecessorRoom?,
) )
/**
* Whether the jump-to-unread FAB should be shown, and if so, how tapping it
* should bring the user to the read marker.
*/
@Immutable
sealed interface JumpToUnreadState {
data object Hidden : JumpToUnreadState
/** The read marker is materialised at [index] in the loaded timeline — smooth scroll to it. */
data class InWindow(val index: Int) : JumpToUnreadState
/** The read marker event is older than the loaded window — load it via focused-event navigation. */
data class OutOfWindow(val eventId: EventId) : JumpToUnreadState
}
@@ -57,6 +57,9 @@ fun aTimelineState(
messageShield: MessageShield? = null, messageShield: MessageShield? = null,
resolveVerifiedUserSendFailureState: ResolveVerifiedUserSendFailureState = aResolveVerifiedUserSendFailureState(), resolveVerifiedUserSendFailureState: ResolveVerifiedUserSendFailureState = aResolveVerifiedUserSendFailureState(),
displayThreadSummaries: Boolean = false, displayThreadSummaries: Boolean = false,
displayJumpToUnread: Boolean = false,
jumpToUnread: JumpToUnreadState = JumpToUnreadState.Hidden,
newEventState: NewEventState = NewEventState.None,
eventSink: (TimelineEvent) -> Unit = {}, eventSink: (TimelineEvent) -> Unit = {},
): TimelineState { ): TimelineState {
val focusedEventId = timelineItems.filterIsInstance<TimelineItem.Event>().getOrNull(focusedEventIndex)?.eventId val focusedEventId = timelineItems.filterIsInstance<TimelineItem.Event>().getOrNull(focusedEventIndex)?.eventId
@@ -69,12 +72,14 @@ fun aTimelineState(
timelineItems = timelineItems, timelineItems = timelineItems,
timelineMode = timelineMode, timelineMode = timelineMode,
timelineRoomInfo = timelineRoomInfo, timelineRoomInfo = timelineRoomInfo,
newEventState = NewEventState.None, newEventState = newEventState,
isLive = isLive, isLive = isLive,
focusRequestState = focusRequestState, focusRequestState = focusRequestState,
messageShieldDialogData = messageShield?.let { MessageShieldData(it) }, messageShieldDialogData = messageShield?.let { MessageShieldData(it) },
resolveVerifiedUserSendFailureState = resolveVerifiedUserSendFailureState, resolveVerifiedUserSendFailureState = resolveVerifiedUserSendFailureState,
displayThreadSummaries = displayThreadSummaries, displayThreadSummaries = displayThreadSummaries,
displayJumpToUnread = displayJumpToUnread,
jumpToUnread = jumpToUnread,
eventSink = eventSink, eventSink = eventSink,
) )
} }
@@ -10,22 +10,34 @@ package io.element.android.features.messages.impl.timeline
import android.view.HapticFeedbackConstants import android.view.HapticFeedbackConstants
import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.MutableTransitionState
import androidx.compose.animation.core.tween import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.scaleIn import androidx.compose.animation.scaleIn
import androidx.compose.animation.scaleOut import androidx.compose.animation.scaleOut
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxScope import androidx.compose.foundation.layout.BoxScope
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.Spacer
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.FloatingActionButtonDefaults 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.LaunchedEffect
@@ -39,16 +51,27 @@ import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow import androidx.compose.runtime.snapshotFlow
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.rotate import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.graphics.TransformOrigin
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalView import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.platform.rememberNestedScrollInteropConnection import androidx.compose.ui.platform.rememberNestedScrollInteropConnection
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
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 androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.IntRect
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Popup
import androidx.compose.ui.window.PopupPositionProvider
import androidx.compose.ui.window.PopupProperties
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
import io.element.android.features.messages.impl.crypto.sendfailure.resolve.ResolveVerifiedUserSendFailureView import io.element.android.features.messages.impl.crypto.sendfailure.resolve.ResolveVerifiedUserSendFailureView
@@ -65,20 +88,23 @@ import io.element.android.features.messages.impl.timeline.model.event.TimelineIt
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.androidutils.system.copyToClipboard import io.element.android.libraries.androidutils.system.copyToClipboard
import io.element.android.libraries.designsystem.atomic.atoms.UnreadIndicatorAtom
import io.element.android.libraries.designsystem.components.dialogs.AlertDialog import io.element.android.libraries.designsystem.components.dialogs.AlertDialog
import io.element.android.libraries.designsystem.preview.ElementPreview 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.FloatingActionButton import io.element.android.libraries.designsystem.text.roundToPx
import io.element.android.libraries.designsystem.theme.components.Icon import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.utils.animateScrollToItemCenter import io.element.android.libraries.designsystem.utils.animateScrollToItemCenter
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.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.testtags.TestTag
import io.element.android.libraries.testtags.TestTags import io.element.android.libraries.testtags.TestTags
import io.element.android.libraries.testtags.testTag import io.element.android.libraries.testtags.testTag
import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.libraries.ui.utils.a11y.isTalkbackActive
import io.element.android.wysiwyg.link.Link import io.element.android.wysiwyg.link.Link
import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
@@ -97,6 +123,7 @@ fun TimelineView(
onUserDataClick: (MatrixUser) -> Unit, onUserDataClick: (MatrixUser) -> Unit,
onLinkClick: (Link) -> Unit, onLinkClick: (Link) -> Unit,
onContentClick: (TimelineItem.Event) -> Unit, onContentClick: (TimelineItem.Event) -> Unit,
onGalleryItemClick: ((TimelineItem.Event, Int) -> Unit),
onMessageLongClick: (TimelineItem.Event) -> Unit, onMessageLongClick: (TimelineItem.Event) -> Unit,
onSwipeToReply: (TimelineItem.Event) -> Unit, onSwipeToReply: (TimelineItem.Event) -> Unit,
onReactionClick: (emoji: String, TimelineItem.Event) -> Unit, onReactionClick: (emoji: String, TimelineItem.Event) -> Unit,
@@ -106,6 +133,7 @@ fun TimelineView(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
lazyListState: LazyListState = rememberLazyListState(), lazyListState: LazyListState = rememberLazyListState(),
forceJumpToBottomVisibility: Boolean = false, forceJumpToBottomVisibility: Boolean = false,
forceJumpToReadMarkerVisibility: Boolean = false,
nestedScrollConnection: NestedScrollConnection = rememberNestedScrollInteropConnection(), nestedScrollConnection: NestedScrollConnection = rememberNestedScrollInteropConnection(),
floatingDateTopOffset: Dp = 0.dp, floatingDateTopOffset: Dp = 0.dp,
) { ) {
@@ -125,12 +153,17 @@ fun TimelineView(
state.eventSink(TimelineEvent.JumpToLive) state.eventSink(TimelineEvent.JumpToLive)
} }
fun onMarkAllAsRead() {
state.eventSink(TimelineEvent.MarkAllAsRead)
}
fun onFocusOnEvent(eventId: EventId) {
state.eventSink(TimelineEvent.FocusOnEvent(eventId))
}
val context = LocalContext.current val context = LocalContext.current
val toastMessage = stringResource(CommonStrings.common_copied_to_clipboard) val toastMessage = stringResource(CommonStrings.common_copied_to_clipboard)
val view = LocalView.current val view = LocalView.current
// Disable reverse layout when TalkBack is enabled to avoid incorrect ordering issues seen in the current Compose UI version
val useReverseLayout = !isTalkbackActive()
fun inReplyToClick(eventId: EventId) { fun inReplyToClick(eventId: EventId) {
state.eventSink(TimelineEvent.FocusOnEvent(eventId)) state.eventSink(TimelineEvent.FocusOnEvent(eventId))
} }
@@ -158,7 +191,7 @@ fun TimelineView(
.nestedScroll(nestedScrollConnection) .nestedScroll(nestedScrollConnection)
.testTag(TestTags.timeline), .testTag(TestTags.timeline),
state = lazyListState, state = lazyListState,
reverseLayout = useReverseLayout, reverseLayout = true,
contentPadding = PaddingValues(top = 64.dp, bottom = 8.dp), contentPadding = PaddingValues(top = 64.dp, bottom = 8.dp),
) { ) {
items( items(
@@ -178,6 +211,7 @@ fun TimelineView(
onLinkClick = onLinkClick, onLinkClick = onLinkClick,
onLinkLongClick = ::onLinkLongClick, onLinkLongClick = ::onLinkLongClick,
onContentClick = onContentClick, onContentClick = onContentClick,
onGalleryItemClick = onGalleryItemClick,
onLongClick = onMessageLongClick, onLongClick = onMessageLongClick,
inReplyToClick = ::inReplyToClick, inReplyToClick = ::inReplyToClick,
onReactionClick = onReactionClick, onReactionClick = onReactionClick,
@@ -204,22 +238,25 @@ fun TimelineView(
hasAnyEvent = state.hasAnyEvent, hasAnyEvent = state.hasAnyEvent,
lazyListState = lazyListState, lazyListState = lazyListState,
forceJumpToBottomVisibility = forceJumpToBottomVisibility, forceJumpToBottomVisibility = forceJumpToBottomVisibility,
forceJumpToReadMarkerVisibility = forceJumpToReadMarkerVisibility,
newEventState = state.newEventState, newEventState = state.newEventState,
isLive = state.isLive, isLive = state.isLive,
focusRequestState = state.focusRequestState, focusRequestState = state.focusRequestState,
displayJumpToUnread = state.displayJumpToUnread,
jumpToUnread = state.jumpToUnread,
onScrollFinishAt = ::onScrollFinishAt, onScrollFinishAt = ::onScrollFinishAt,
onJumpToLive = ::onJumpToLive, onJumpToLive = ::onJumpToLive,
onFocusEventRender = ::onFocusEventRender, onFocusEventRender = ::onFocusEventRender,
onMarkAllAsRead = ::onMarkAllAsRead,
onFocusOnEvent = ::onFocusOnEvent,
) )
if (useReverseLayout) { FloatingDateBadgeOverlay(
FloatingDateBadgeOverlay( lazyListState = lazyListState,
lazyListState = lazyListState, timelineItems = state.timelineItems,
timelineItems = state.timelineItems, isLive = state.isLive,
isLive = state.isLive, topOffset = floatingDateTopOffset,
topOffset = floatingDateTopOffset, )
)
}
} }
} }
@@ -288,10 +325,15 @@ private fun BoxScope.TimelineScrollHelper(
newEventState: NewEventState, newEventState: NewEventState,
isLive: Boolean, isLive: Boolean,
forceJumpToBottomVisibility: Boolean, forceJumpToBottomVisibility: Boolean,
forceJumpToReadMarkerVisibility: Boolean,
focusRequestState: FocusRequestState, focusRequestState: FocusRequestState,
displayJumpToUnread: Boolean,
jumpToUnread: JumpToUnreadState,
onScrollFinishAt: (Int) -> Unit, onScrollFinishAt: (Int) -> Unit,
onJumpToLive: () -> Unit, onJumpToLive: () -> Unit,
onFocusEventRender: () -> Unit, onFocusEventRender: () -> Unit,
onMarkAllAsRead: () -> Unit,
onFocusOnEvent: (EventId) -> Unit,
) { ) {
val coroutineScope = rememberCoroutineScope() val coroutineScope = rememberCoroutineScope()
val isScrollFinished by remember { derivedStateOf { !lazyListState.isScrollInProgress } } val isScrollFinished by remember { derivedStateOf { !lazyListState.isScrollInProgress } }
@@ -300,6 +342,22 @@ private fun BoxScope.TimelineScrollHelper(
lazyListState.firstVisibleItemIndex < 3 && isLive lazyListState.firstVisibleItemIndex < 3 && isLive
} }
} }
val isJumpToUnreadVisible by remember(jumpToUnread, forceJumpToReadMarkerVisibility) {
derivedStateOf {
if (forceJumpToReadMarkerVisibility) return@derivedStateOf true
when (val jtu = jumpToUnread) {
JumpToUnreadState.Hidden -> false
// Marker is outside the loaded window — we have no on-screen anchor, so just show.
is JumpToUnreadState.OutOfWindow -> true
// Marker is in the loaded window — hide once it's scrolled into the visible range.
is JumpToUnreadState.InWindow -> {
val lastVisibleIndex = lazyListState.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: return@derivedStateOf false
jtu.index > lastVisibleIndex
}
}
}
}
val isJumpToBottomVisible = !canAutoScroll || forceJumpToBottomVisibility || !isLive
var jumpToLiveHandled by remember { mutableStateOf(true) } var jumpToLiveHandled by remember { mutableStateOf(true) }
/** /**
@@ -326,6 +384,16 @@ private fun BoxScope.TimelineScrollHelper(
} }
} }
fun jumpToReadMarker() {
when (val jtu = jumpToUnread) {
JumpToUnreadState.Hidden -> Unit
is JumpToUnreadState.InWindow -> coroutineScope.launch {
lazyListState.animateScrollToItemCenter(jtu.index)
}
is JumpToUnreadState.OutOfWindow -> onFocusOnEvent(jtu.eventId)
}
}
LaunchedEffect(jumpToLiveHandled, isLive) { LaunchedEffect(jumpToLiveHandled, isLive) {
if (!jumpToLiveHandled && isLive) { if (!jumpToLiveHandled && isLive) {
lazyListState.scrollToItem(0) lazyListState.scrollToItem(0)
@@ -342,8 +410,11 @@ private fun BoxScope.TimelineScrollHelper(
} }
LaunchedEffect(canAutoScroll, newEventState) { LaunchedEffect(canAutoScroll, newEventState) {
val shouldScrollToBottom = isScrollFinished && val shouldScrollToBottom = isScrollFinished && when (newEventState) {
(canAutoScroll && newEventState == NewEventState.FromOther || newEventState == NewEventState.FromMe) is NewEventState.FromOther -> canAutoScroll
NewEventState.FromMe -> true
NewEventState.None -> false
}
if (shouldScrollToBottom) { if (shouldScrollToBottom) {
scrollToBottom(force = true) scrollToBottom(force = true)
} }
@@ -357,43 +428,145 @@ private fun BoxScope.TimelineScrollHelper(
} }
} }
JumpToBottomButton( Column(
// Use inverse of canAutoScroll otherwise we might briefly see the before the scroll animation is triggered
isVisible = !canAutoScroll || forceJumpToBottomVisibility || !isLive,
modifier = Modifier modifier = Modifier
.align(Alignment.BottomEnd) .align(Alignment.BottomEnd)
.padding(end = 24.dp, bottom = 12.dp), .padding(end = 24.dp, bottom = 16.dp)
onClick = { jumpToBottom() }, ) {
) JumpToPositionButton(
icon = CompoundIcons.ChevronUp(),
contentDescription = stringResource(id = CommonStrings.a11y_jump_to_unread_messages),
modifier = Modifier.padding(bottom = 12.dp),
isVisible = isJumpToUnreadVisible,
hasUnread = true,
onClick = ::jumpToReadMarker,
onMarkAsRead = onMarkAllAsRead,
testTag = TestTags.jumpToUnreadButton,
)
// Reserves space for the jump to bottom button so the jump to unread button above
// stays in the same position regardless of whether the jump to bottom button is visible.
Box(modifier = Modifier.size(36.dp)) {
JumpToPositionButton(
icon = CompoundIcons.ChevronDown(),
contentDescription = stringResource(id = CommonStrings.a11y_jump_to_bottom),
isVisible = isJumpToBottomVisible,
hasUnread = displayJumpToUnread && newEventState is NewEventState.FromOther,
onClick = ::jumpToBottom,
onMarkAsRead = onMarkAllAsRead,
testTag = TestTags.jumpToBottomButton,
dotAlignment = Alignment.BottomCenter,
)
}
}
} }
@Composable @Composable
private fun JumpToBottomButton( private fun JumpToPositionButton(
icon: ImageVector,
contentDescription: String,
isVisible: Boolean, isVisible: Boolean,
hasUnread: Boolean,
onClick: () -> Unit, onClick: () -> Unit,
onMarkAsRead: () -> Unit,
testTag: TestTag,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
dotAlignment: Alignment = Alignment.TopCenter,
) { ) {
AnimatedVisibility( AnimatedVisibility(
modifier = modifier, modifier = modifier,
visible = isVisible, visible = isVisible,
enter = scaleIn(animationSpec = tween(100)), enter = scaleIn(animationSpec = tween(220), initialScale = 0.8f) + fadeIn(animationSpec = tween(220)),
exit = scaleOut(animationSpec = tween(100)), exit = scaleOut(animationSpec = tween(180), targetScale = 0.8f) + fadeOut(animationSpec = tween(180)),
) { ) {
FloatingActionButton( var menuExpanded by remember { mutableStateOf(false) }
onClick = onClick, Box {
elevation = FloatingActionButtonDefaults.elevation(4.dp, 4.dp, 4.dp, 4.dp), Box(
shape = CircleShape,
modifier = Modifier.size(36.dp),
containerColor = ElementTheme.colors.bgSubtleSecondary,
contentColor = ElementTheme.colors.iconSecondary,
) {
Icon(
modifier = Modifier modifier = Modifier
.size(24.dp) .size(36.dp)
.rotate(90f), .background(color = ElementTheme.colors.bgCanvasDefault, shape = CircleShape)
imageVector = CompoundIcons.ArrowRight(), .clip(CircleShape)
contentDescription = stringResource(id = CommonStrings.a11y_jump_to_bottom) .border(1.dp, ElementTheme.colors.borderDisabled, CircleShape)
) .combinedClickable(
onClick = onClick,
onLongClick = { menuExpanded = true },
onLongClickLabel = stringResource(CommonStrings.action_open_context_menu),
)
.testTag(testTag),
contentAlignment = Alignment.Center,
) {
Icon(
modifier = Modifier.size(24.dp),
imageVector = icon,
contentDescription = contentDescription,
tint = ElementTheme.colors.iconSecondary,
)
val menuTransitionState = remember { MutableTransitionState(false) }
.apply { targetState = menuExpanded }
if (menuTransitionState.currentState || menuTransitionState.targetState) {
val gapPx = with(LocalDensity.current) { 8.dp.roundToPx() }
val positionProvider = remember(gapPx) { CenterStartOfAnchorPositionProvider(gapPx) }
Popup(
popupPositionProvider = positionProvider,
onDismissRequest = { menuExpanded = false },
properties = PopupProperties(focusable = true),
) {
// Anchor the scale to the right-center edge so the menu visually grows
// outward from the FAB it's attached to.
val transformOrigin = TransformOrigin(pivotFractionX = 1f, pivotFractionY = 0.5f)
AnimatedVisibility(
visibleState = menuTransitionState,
enter = scaleIn(
animationSpec = tween(180),
initialScale = 0.9f,
transformOrigin = transformOrigin,
) + fadeIn(animationSpec = tween(180)),
exit = scaleOut(
animationSpec = tween(140),
targetScale = 0.9f,
transformOrigin = transformOrigin,
) + fadeOut(animationSpec = tween(140)),
) {
// Hand-rolled instead of DropdownMenuItem: padding here is tighter
// than DropdownMenuItem's 12.dp default to match the Figma spec.
Row(
modifier = Modifier
.shadow(elevation = 1.dp, shape = RoundedCornerShape(8.dp))
.clip(RoundedCornerShape(8.dp))
.background(ElementTheme.colors.bgCanvasDefaultLevel1)
.border(1.dp, ElementTheme.colors.borderDisabled, RoundedCornerShape(8.dp))
.clickable {
menuExpanded = false
onMarkAsRead()
}
.padding(horizontal = 12.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
imageVector = CompoundIcons.MarkAsRead(),
contentDescription = null,
tint = ElementTheme.colors.iconTertiary,
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = stringResource(id = CommonStrings.action_mark_as_read),
color = ElementTheme.colors.textPrimary,
style = ElementTheme.typography.fontBodyLgRegular,
)
}
}
}
}
}
val dotYOffset = if (dotAlignment == Alignment.BottomCenter) 4.dp else (-4).dp
if (hasUnread) {
UnreadIndicatorAtom(
modifier = Modifier
.align(dotAlignment)
.offset { IntOffset(x = 0, y = dotYOffset.roundToPx()) },
color = ElementTheme.colors.iconSuccessPrimary,
border = BorderStroke(2.dp, ElementTheme.colors.bgCanvasDefault),
)
}
} }
} }
} }
@@ -428,7 +601,89 @@ internal fun TimelineViewPreview(
onReactionLongClick = { _, _ -> }, onReactionLongClick = { _, _ -> },
onMoreReactionsClick = {}, onMoreReactionsClick = {},
onReadReceiptClick = {}, onReadReceiptClick = {},
onGalleryItemClick = { _, _ -> },
forceJumpToBottomVisibility = true, forceJumpToBottomVisibility = true,
) )
} }
} }
// The jump-to-unread FAB's indicator is always rendered when the FAB is visible — in
// production the FAB only appears when unread content exists above. So `hasUnreadAbove`
// here only varies the scroll-target state, not the upper indicator's visibility.
@Composable
private fun TimelineViewWithReadMarker(
hasUnreadAbove: Boolean,
hasUnreadBelow: Boolean,
) {
val timelineItems = persistentListOf<TimelineItem>(
aTimelineItemEvent(isMine = false),
aTimelineItemEvent(isMine = false),
aTimelineItemEvent(isMine = true),
aTimelineItemEvent(isMine = false),
aTimelineItemEvent(isMine = false),
aTimelineItemEvent(isMine = false),
)
CompositionLocalProvider(
LocalTimelineItemPresenterFactories provides aFakeTimelineItemPresenterFactories(),
) {
TimelineView(
state = aTimelineState(
timelineItems = timelineItems,
displayJumpToUnread = true,
// Index points past the loaded items, mirroring the real-world state the FAB
// represents: the user has scrolled past the read marker, so it's no longer in
// view. The actual scroll target doesn't matter for a static preview.
jumpToUnread = if (hasUnreadAbove) JumpToUnreadState.InWindow(timelineItems.size) else JumpToUnreadState.Hidden,
newEventState = if (hasUnreadBelow) NewEventState.FromOther else NewEventState.None,
),
timelineProtectionState = aTimelineProtectionState(),
onUserDataClick = {},
onLinkClick = {},
onContentClick = {},
onGalleryItemClick = { _, _ -> },
onMessageLongClick = {},
onSwipeToReply = {},
onReactionClick = { _, _ -> },
onReactionLongClick = { _, _ -> },
onMoreReactionsClick = {},
onReadReceiptClick = {},
forceJumpToBottomVisibility = true,
forceJumpToReadMarkerVisibility = true,
)
}
}
@PreviewsDayNight
@Composable
internal fun TimelineViewWithReadMarkerJumpToUnreadIndicatorOnlyPreview() = ElementPreview {
TimelineViewWithReadMarker(hasUnreadAbove = false, hasUnreadBelow = false)
}
@PreviewsDayNight
@Composable
internal fun TimelineViewWithReadMarkerBothIndicatorsPreview() = ElementPreview {
TimelineViewWithReadMarker(hasUnreadAbove = true, hasUnreadBelow = true)
}
/**
* Anchors the popup so its right edge sits [gapPx] to the left of the anchor and its vertical
* center matches the anchor's. Adapts to localized menu widths and FAB size; coerced to stay
* on-screen.
*/
private class CenterStartOfAnchorPositionProvider(
private val gapPx: Int,
) : PopupPositionProvider {
override fun calculatePosition(
anchorBounds: IntRect,
windowSize: IntSize,
layoutDirection: LayoutDirection,
popupContentSize: IntSize,
): IntOffset {
val x = anchorBounds.left - popupContentSize.width - gapPx
val y = anchorBounds.top + (anchorBounds.height - popupContentSize.height) / 2
return IntOffset(
x = x.coerceIn(0, (windowSize.width - popupContentSize.width).coerceAtLeast(0)),
y = y.coerceIn(0, (windowSize.height - popupContentSize.height).coerceAtLeast(0)),
)
}
}
@@ -49,6 +49,7 @@ internal fun TimelineViewMessageShieldPreview() = ElementPreview {
onReactionLongClick = { _, _ -> }, onReactionLongClick = { _, _ -> },
onMoreReactionsClick = {}, onMoreReactionsClick = {},
onReadReceiptClick = {}, onReadReceiptClick = {},
onGalleryItemClick = { _, _ -> },
forceJumpToBottomVisibility = true, forceJumpToBottomVisibility = true,
) )
} }
@@ -33,6 +33,7 @@ internal fun ATimelineItemEventRow(
isLastOutgoingMessage = isLastOutgoingMessage, isLastOutgoingMessage = isLastOutgoingMessage,
displayThreadSummaries = displayThreadSummaries, displayThreadSummaries = displayThreadSummaries,
onEventClick = {}, onEventClick = {},
onGalleryItemClick = {},
onLongClick = {}, onLongClick = {},
onLinkClick = {}, onLinkClick = {},
onLinkLongClick = {}, onLinkLongClick = {},
@@ -27,6 +27,8 @@ import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.semantics.clearAndSetSemantics
import androidx.compose.ui.semantics.hideFromAccessibility
import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.Dp
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
@@ -102,7 +104,10 @@ internal fun BoxScope.FloatingDateBadgeOverlay(
visible = showBadge, visible = showBadge,
modifier = Modifier modifier = Modifier
.align(Alignment.TopCenter) .align(Alignment.TopCenter)
.padding(top = 8.dp + topOffset), .padding(top = 8.dp + topOffset)
.clearAndSetSemantics {
hideFromAccessibility()
},
enter = fadeIn(animationSpec = tween(150)), enter = fadeIn(animationSpec = tween(150)),
exit = fadeOut(animationSpec = tween(300)), exit = fadeOut(animationSpec = tween(300)),
) { ) {
@@ -43,6 +43,7 @@ fun TimelineEventTimestampView(
event: TimelineItem.Event, event: TimelineItem.Event,
eventSink: (TimelineEvent.TimelineItemEvent) -> Unit, eventSink: (TimelineEvent.TimelineItemEvent) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
isLayoutDirectionMismatched: Boolean = false,
) { ) {
val formattedTime = event.sentTime val formattedTime = event.sentTime
val hasError = event.failedToSend val hasError = event.failedToSend
@@ -77,9 +78,16 @@ fun TimelineEventTimestampView(
else -> Modifier else -> Modifier
} }
} }
val padding = if (!isLayoutDirectionMismatched) {
PaddingValues(start = TimelineEventTimestampViewDefaults.spacing)
} else {
PaddingValues(end = TimelineEventTimestampViewDefaults.spacing)
}
Row( Row(
modifier = Modifier modifier = Modifier
.padding(PaddingValues(start = TimelineEventTimestampViewDefaults.spacing)) .padding(padding)
// For a better click target, make the corners rounded // For a better click target, make the corners rounded
.clip(RoundedCornerShape(8.dp)) .clip(RoundedCornerShape(8.dp))
.then(clickableModifier) .then(clickableModifier)
@@ -0,0 +1,138 @@
/*
* 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.ui.tooling.preview.PreviewParameterProvider
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.model.event.GalleryItem
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEventContent
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemAttachmentsContent
import io.element.android.features.messages.impl.timeline.model.event.anAttachmentItem
import kotlin.time.Duration
class TimelineItemEventContentForGalleryViewProvider :
PreviewParameterProvider<TimelineItemEventContent> {
override val values: Sequence<TimelineItemEventContent>
get() = sequenceOf(
aTimelineItemGalleryContent(
caption = "My vacation photos",
items = listOf(
aGalleryItem(),
aGalleryItem(),
aGalleryItem(),
aGalleryItem(),
),
),
aTimelineItemGalleryContent(
items = listOf(
aGalleryItem(),
aGalleryItem(),
),
),
aTimelineItemGalleryContent(
caption = "Three photos",
items = listOf(
aGalleryItem(),
aGalleryItem(),
aGalleryItem(),
),
),
aTimelineItemGalleryContent(
caption = "Many photos",
items = (1..8).map { aGalleryItem() },
),
aTimelineItemGalleryContent(
caption = "Videos",
items = listOf(
aGalleryItem(
type = GalleryItem.Type.Video,
duration = Duration.parse("PT1M30S")
),
aGalleryItem(
type = GalleryItem.Type.Video,
duration = Duration.parse("PT45S")
),
aGalleryItem(),
),
),
aTimelineItemAttachmentsContent(
caption = "Documents",
attachments = listOf(
anAttachmentItem(
filename = "document.pdf",
fileExtension = "pdf",
),
anAttachmentItem(
filename = "presentation.pdf",
fileExtension = "pdf",
),
anAttachmentItem(
filename = "spreadsheet.xlsx",
fileExtension = "xlsx",
),
),
),
aTimelineItemAttachmentsContent(
caption = "Photos",
attachments = listOf(
anAttachmentItem(
filename = "photo1.jpg",
fileExtension = "jpg",
hasThumbnail = true,
),
anAttachmentItem(
filename = "photo2.jpg",
fileExtension = "jpg",
hasThumbnail = true,
),
anAttachmentItem(
filename = "photo3.jpg",
fileExtension = "jpg",
hasThumbnail = true,
),
),
),
aTimelineItemAttachmentsContent(
caption = "Videos",
attachments = listOf(
anAttachmentItem(
filename = "video1.mp4",
fileExtension = "mp4",
hasThumbnail = true,
fileSize = 150_000_000L,
formattedFileSize = "150MB",
),
anAttachmentItem(
filename = "video2.mov",
fileExtension = "mov",
hasThumbnail = true,
fileSize = 85_000_000L,
formattedFileSize = "85MB",
),
),
),
aTimelineItemAttachmentsContent(
caption = "Audio",
attachments = listOf(
anAttachmentItem(
filename = "recording.mp3",
fileExtension = "mp3",
fileSize = 4_500_000L,
formattedFileSize = "4.5MB",
),
anAttachmentItem(
filename = "voice_message.m4a",
fileExtension = "m4a",
fileSize = 1_200_000L,
formattedFileSize = "1.2MB",
),
),
),
)
}
@@ -39,7 +39,9 @@ import androidx.compose.runtime.rememberCoroutineScope
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
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.platform.LocalViewConfiguration import androidx.compose.ui.platform.LocalViewConfiguration
import androidx.compose.ui.platform.ViewConfiguration import androidx.compose.ui.platform.ViewConfiguration
import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.pluralStringResource
@@ -49,9 +51,12 @@ import androidx.compose.ui.semantics.hideFromAccessibility
import androidx.compose.ui.semantics.isTraversalGroup import androidx.compose.ui.semantics.isTraversalGroup
import androidx.compose.ui.semantics.semantics import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.semantics.traversalIndex import androidx.compose.ui.semantics.traversalIndex
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextDirection
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.DpOffset import androidx.compose.ui.unit.DpOffset
import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.zIndex import androidx.compose.ui.zIndex
import androidx.constraintlayout.compose.ConstrainScope import androidx.constraintlayout.compose.ConstrainScope
@@ -68,12 +73,16 @@ 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.model.TimelineItem import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.features.messages.impl.timeline.model.TimelineItemGroupPosition import io.element.android.features.messages.impl.timeline.model.TimelineItemGroupPosition
import io.element.android.features.messages.impl.timeline.model.TimelineItemReactions
import io.element.android.features.messages.impl.timeline.model.TimelineItemThreadInfo import io.element.android.features.messages.impl.timeline.model.TimelineItemThreadInfo
import io.element.android.features.messages.impl.timeline.model.bubble.BubbleState import io.element.android.features.messages.impl.timeline.model.bubble.BubbleState
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemAttachmentsContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemGalleryContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemImageContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemImageContent
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.TimelineItemPollContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemPollContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemStickerContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemStickerContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemTextContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVideoContent import io.element.android.features.messages.impl.timeline.model.event.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.aTimelineItemImageContent import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemImageContent
@@ -91,6 +100,7 @@ import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.designsystem.components.avatar.AvatarType import io.element.android.libraries.designsystem.components.avatar.AvatarType
import io.element.android.libraries.designsystem.modifiers.niceClickable import io.element.android.libraries.designsystem.modifiers.niceClickable
import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewWithExtraLargeHeight
import io.element.android.libraries.designsystem.preview.PreviewsDayNight 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_ALICE
import io.element.android.libraries.designsystem.swipe.SwipeableActionsState import io.element.android.libraries.designsystem.swipe.SwipeableActionsState
@@ -106,6 +116,7 @@ import io.element.android.libraries.matrix.api.timeline.item.EmbeddedEventInfo
import io.element.android.libraries.matrix.api.timeline.item.ThreadSummary import io.element.android.libraries.matrix.api.timeline.item.ThreadSummary
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.api.timeline.item.event.MessageContent import io.element.android.libraries.matrix.api.timeline.item.event.MessageContent
import io.element.android.libraries.matrix.api.timeline.item.event.MessageShield
import io.element.android.libraries.matrix.api.timeline.item.event.ProfileDetails 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.api.timeline.item.event.TextMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.getAvatarUrl import io.element.android.libraries.matrix.api.timeline.item.event.getAvatarUrl
@@ -122,7 +133,9 @@ import io.element.android.libraries.testtags.testTag
import io.element.android.libraries.ui.strings.CommonPlurals import io.element.android.libraries.ui.strings.CommonPlurals
import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.libraries.ui.utils.a11y.isTalkbackActive import io.element.android.libraries.ui.utils.a11y.isTalkbackActive
import io.element.android.libraries.ui.utils.text.detect
import io.element.android.wysiwyg.link.Link import io.element.android.wysiwyg.link.Link
import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlin.math.abs import kotlin.math.abs
import kotlin.math.roundToInt import kotlin.math.roundToInt
@@ -145,6 +158,7 @@ fun TimelineItemEventRow(
isLastOutgoingMessage: Boolean, isLastOutgoingMessage: Boolean,
displayThreadSummaries: Boolean, displayThreadSummaries: Boolean,
onEventClick: () -> Unit, onEventClick: () -> Unit,
onGalleryItemClick: ((Int) -> Unit),
onLongClick: () -> Unit, onLongClick: () -> Unit,
onLinkClick: (Link) -> Unit, onLinkClick: (Link) -> Unit,
onLinkLongClick: (Link) -> Unit, onLinkLongClick: (Link) -> Unit,
@@ -165,13 +179,14 @@ fun TimelineItemEventRow(
content = event.content, content = event.content,
hideMediaContent = timelineProtectionState.hideMediaContent(event.eventId), hideMediaContent = timelineProtectionState.hideMediaContent(event.eventId),
onContentClick = onContentClick, onContentClick = onContentClick,
onGalleryItemClick = onGalleryItemClick,
onLongClick = onLongClick, onLongClick = onLongClick,
onShowContentClick = { timelineProtectionState.eventSink(TimelineProtectionEvent.ShowContent(event.eventId)) }, onShowContentClick = { timelineProtectionState.eventSink(TimelineProtectionEvent.ShowContent(event.eventId)) },
onLinkClick = onLinkClick, onLinkClick = onLinkClick,
onLinkLongClick = onLinkLongClick, onLinkLongClick = onLinkLongClick,
eventSink = eventSink, eventSink = eventSink,
modifier = contentModifier, modifier = contentModifier,
onContentLayoutChange = onContentLayoutChange onContentLayoutChange = onContentLayoutChange,
) )
}, },
) { ) {
@@ -649,23 +664,44 @@ private fun MessageEventBubbleContent(
.padding(horizontal = 4.dp, vertical = 2.dp) .padding(horizontal = 4.dp, vertical = 2.dp)
) )
} }
TimestampPosition.Aligned -> TimestampPosition.Aligned -> @Composable {
ContentAvoidingLayout( val originalLayoutDirection = LocalLayoutDirection.current
modifier = modifier, // Detect if the direction of the text content (if any) does not match the layout direction, to place the content and timestamp correctly
// The spacing is negative to make the content overlap the empty space at the start of the timestamp val contentDirection = if (event.content is TimelineItemTextContent) {
spacing = (-4).dp, remember(event.content.body) {
overlayOffset = DpOffset(0.dp, -1.dp), when (TextDirection.detect(event.content.body)) {
shrinkContent = canShrinkContent, TextDirection.Ltr, TextDirection.ContentOrLtr -> LayoutDirection.Ltr
content = { content(this::onContentLayoutChange) }, TextDirection.Rtl, TextDirection.ContentOrRtl -> LayoutDirection.Rtl
overlay = { else -> originalLayoutDirection
TimelineEventTimestampView( }
event = event,
eventSink = eventSink,
modifier = Modifier
.padding(horizontal = 8.dp, vertical = 4.dp)
)
} }
) } else {
originalLayoutDirection
}
CompositionLocalProvider(LocalLayoutDirection provides contentDirection) {
ContentAvoidingLayout(
modifier = modifier,
// The spacing is negative to make the content overlap the empty space at the start of the timestamp
spacing = (-4).dp,
overlayOffset = DpOffset(0.dp, -1.dp),
shrinkContent = canShrinkContent,
content = { content(this::onContentLayoutChange) },
overlay = {
// Use the original layout direction for the timestamp
CompositionLocalProvider(LocalLayoutDirection provides originalLayoutDirection) {
TimelineEventTimestampView(
event = event,
eventSink = eventSink,
isLayoutDirectionMismatched = originalLayoutDirection != contentDirection,
modifier = Modifier
.padding(horizontal = 8.dp, vertical = 4.dp)
)
}
}
)
}
}
TimestampPosition.Below -> TimestampPosition.Below ->
Column(modifier) { Column(modifier) {
content {} content {}
@@ -776,6 +812,8 @@ private fun MessageEventBubbleContent(
val timestampPosition = when (val content = event.content) { val timestampPosition = when (val content = event.content) {
is TimelineItemImageContent -> if (content.showCaption) TimestampPosition.Aligned else TimestampPosition.Overlay is TimelineItemImageContent -> if (content.showCaption) TimestampPosition.Aligned else TimestampPosition.Overlay
is TimelineItemVideoContent -> if (content.showCaption) TimestampPosition.Aligned else TimestampPosition.Overlay is TimelineItemVideoContent -> if (content.showCaption) TimestampPosition.Aligned else TimestampPosition.Overlay
is TimelineItemGalleryContent -> if (content.showCaption) TimestampPosition.Aligned else TimestampPosition.Below
is TimelineItemAttachmentsContent -> TimestampPosition.Below
is TimelineItemStickerContent -> TimestampPosition.Overlay is TimelineItemStickerContent -> TimestampPosition.Overlay
is TimelineItemLocationContent -> { is TimelineItemLocationContent -> {
val content = content.ensureActiveLiveLocation() val content = content.ensureActiveLiveLocation()
@@ -790,6 +828,8 @@ private fun MessageEventBubbleContent(
val paddingBehaviour = when (event.content) { val paddingBehaviour = when (event.content) {
is TimelineItemImageContent -> if (event.content.showCaption) ContentPadding.CaptionedMedia else ContentPadding.Media is TimelineItemImageContent -> if (event.content.showCaption) ContentPadding.CaptionedMedia else ContentPadding.Media
is TimelineItemVideoContent -> if (event.content.showCaption) ContentPadding.CaptionedMedia else ContentPadding.Media is TimelineItemVideoContent -> if (event.content.showCaption) ContentPadding.CaptionedMedia else ContentPadding.Media
is TimelineItemGalleryContent -> ContentPadding.CaptionedMedia
is TimelineItemAttachmentsContent -> ContentPadding.CaptionedMedia
is TimelineItemStickerContent, is TimelineItemStickerContent,
is TimelineItemLocationContent -> ContentPadding.Media is TimelineItemLocationContent -> ContentPadding.Media
else -> ContentPadding.Textual else -> ContentPadding.Textual
@@ -879,6 +919,149 @@ internal fun TimelineItemEventRowWithThreadSummaryPreview() = ElementPreview {
} }
} }
@PreviewWithExtraLargeHeight
@Composable
internal fun TimelineItemEventRowRtlContentPreview() = ElementPreview {
Column {
Text(
modifier = Modifier
.fillMaxWidth()
.background(Color.LightGray)
.padding(8.dp),
text = "LTR layout direction",
textAlign = TextAlign.Center,
style = ElementTheme.typography.fontHeadingMdBold,
)
CompositionLocalProvider(LocalLayoutDirection provides LayoutDirection.Ltr) {
sequenceOf(false, true).forEach { isMine ->
ATimelineItemEventRow(
event = aTimelineItemEvent(
senderDisplayName = "Sender with a super long name that should ellipsize",
isMine = isMine,
content = aTimelineItemTextContent(
body = "ظَة وَدَاع يَسْتَغْرِب فِيهَ"
),
timelineItemReactions = TimelineItemReactions(persistentListOf()),
groupPosition = TimelineItemGroupPosition.First,
)
)
ATimelineItemEventRow(
event = aTimelineItemEvent(
senderDisplayName = "Sender with a super long name that should ellipsize",
isMine = isMine,
content = aTimelineItemTextContent(
body = "ظَة وَدَاع يَسْتَغْرِب فِيهَ",
isEdited = true,
),
timelineItemReactions = TimelineItemReactions(persistentListOf()),
groupPosition = TimelineItemGroupPosition.Middle,
messageShield = MessageShield.UnknownDevice(isCritical = true),
)
)
ATimelineItemEventRow(
event = aTimelineItemEvent(
senderDisplayName = "Sender with a super long name that should ellipsize",
isMine = isMine,
content = aTimelineItemTextContent(
body = "ظَة وَدَاع \nيَسْتَغْرِب فِيهَ"
),
timelineItemReactions = TimelineItemReactions(persistentListOf()),
groupPosition = TimelineItemGroupPosition.Middle,
)
)
ATimelineItemEventRow(
event = aTimelineItemEvent(
senderDisplayName = "Sender with a super long name that should ellipsize",
isMine = isMine,
content = aTimelineItemTextContent(
body = "ظَة وَدَاع يَسْتَغْرِب فِيهَا اَلشَّاعِر أَنْ لَا يَبْكِي مِنْ أَلَم اَلْفِرَاق،" +
" وَيَصِف حَالَة اَلْمُودِعِينَ"
),
timelineItemReactions = TimelineItemReactions(persistentListOf()),
groupPosition = TimelineItemGroupPosition.Last,
)
)
}
}
Spacer(modifier = Modifier.height(16.dp))
Text(
modifier = Modifier
.fillMaxWidth()
.background(Color.LightGray)
.padding(8.dp),
text = "RTL layout direction",
textAlign = TextAlign.Center,
style = ElementTheme.typography.fontHeadingMdBold,
)
CompositionLocalProvider(LocalLayoutDirection provides LayoutDirection.Rtl) {
sequenceOf(false, true).forEach { isMine ->
ATimelineItemEventRow(
event = aTimelineItemEvent(
senderDisplayName = "Sender with a super long name that should ellipsize",
isMine = isMine,
content = aTimelineItemTextContent(
body = "ظَة وَدَاع يَسْتَغْرِب فِيهَا اَلشَّاعِر أَنْ لَا يَبْكِي مِنْ أَلَم اَلْفِرَاق،" +
" وَيَصِف حَالَة اَلْمُودِعِينَ",
),
timelineItemReactions = TimelineItemReactions(persistentListOf()),
groupPosition = TimelineItemGroupPosition.First,
)
)
ATimelineItemEventRow(
event = aTimelineItemEvent(
senderDisplayName = "Sender with a super long name that should ellipsize",
isMine = isMine,
content = aTimelineItemTextContent(
body = "Testing\nLTR Line\nBreaks.",
),
timelineItemReactions = TimelineItemReactions(persistentListOf()),
groupPosition = TimelineItemGroupPosition.Middle,
)
)
ATimelineItemEventRow(
event = aTimelineItemEvent(
senderDisplayName = "Sender with a super long name that should ellipsize",
isMine = isMine,
content = aTimelineItemTextContent(
body = "Testing a very long LTR text in an RTL layout."
),
timelineItemReactions = TimelineItemReactions(persistentListOf()),
groupPosition = TimelineItemGroupPosition.Middle,
)
)
ATimelineItemEventRow(
event = aTimelineItemEvent(
senderDisplayName = "Sender with a super long name that should ellipsize",
isMine = isMine,
content = aTimelineItemTextContent(
body = "Testing LTR in RTL layout.",
),
timelineItemReactions = TimelineItemReactions(persistentListOf()),
groupPosition = TimelineItemGroupPosition.Last,
messageShield = MessageShield.UnknownDevice(isCritical = true),
)
)
ATimelineItemEventRow(
event = aTimelineItemEvent(
senderDisplayName = "Sender with a super long name that should ellipsize",
isMine = isMine,
content = aTimelineItemTextContent(
body = "Testing LTR in RTL layout.",
isEdited = true,
),
timelineItemReactions = TimelineItemReactions(persistentListOf()),
groupPosition = TimelineItemGroupPosition.Last,
messageShield = MessageShield.UnknownDevice(isCritical = true),
)
)
}
}
}
}
@PreviewsDayNight @PreviewsDayNight
@Composable @Composable
internal fun ThreadSummaryViewPreview() { internal fun ThreadSummaryViewPreview() {
@@ -0,0 +1,35 @@
/*
* 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.Column
import androidx.compose.runtime.Composable
import androidx.compose.ui.tooling.preview.PreviewParameter
import io.element.android.features.messages.impl.timeline.aTimelineItemEvent
import io.element.android.features.messages.impl.timeline.model.TimelineItemGroupPosition
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEventContent
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
@PreviewsDayNight
@Composable
internal fun TimelineItemEventRowWithGalleryPreview(
@PreviewParameter(TimelineItemEventContentForGalleryViewProvider::class) content: TimelineItemEventContent,
) = ElementPreview {
Column {
sequenceOf(false, true).forEach { isMine ->
ATimelineItemEventRow(
event = aTimelineItemEvent(
isMine = isMine,
content = content,
groupPosition = TimelineItemGroupPosition.Last,
),
)
}
}
}
@@ -34,7 +34,6 @@ 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.EventId
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.utils.a11y.isTalkbackActive
import io.element.android.wysiwyg.link.Link import io.element.android.wysiwyg.link.Link
@Composable @Composable
@@ -69,6 +68,7 @@ fun TimelineItemGroupedEventsRow(
eventSink = eventSink, eventSink = eventSink,
modifier = contentModifier, modifier = contentModifier,
onContentClick = null, onContentClick = null,
onGalleryItemClick = {},
onLongClick = null, onLongClick = null,
onContentLayoutChange = onContentLayoutChange onContentLayoutChange = onContentLayoutChange
) )
@@ -140,6 +140,7 @@ private fun TimelineItemGroupedEventsRowContent(
eventSink = eventSink, eventSink = eventSink,
modifier = contentModifier, modifier = contentModifier,
onContentClick = null, onContentClick = null,
onGalleryItemClick = {},
onLongClick = null, onLongClick = null,
onContentLayoutChange = onContentLayoutChange onContentLayoutChange = onContentLayoutChange
) )
@@ -158,13 +159,7 @@ private fun TimelineItemGroupedEventsRowContent(
) )
if (isExpanded) { if (isExpanded) {
Column { Column {
timelineItem.events.let { timelineItem.events.forEach { subGroupEvent ->
if (isTalkbackActive()) {
it.reversed()
} else {
it
}
}.forEach { subGroupEvent ->
TimelineItemRow( TimelineItemRow(
timelineMode = timelineMode, timelineMode = timelineMode,
timelineItem = subGroupEvent, timelineItem = subGroupEvent,
@@ -177,6 +172,7 @@ private fun TimelineItemGroupedEventsRowContent(
onLinkClick = onLinkClick, onLinkClick = onLinkClick,
onLinkLongClick = onLinkLongClick, onLinkLongClick = onLinkLongClick,
onContentClick = onClick, onContentClick = onClick,
onGalleryItemClick = { _, _ -> },
onLongClick = onLongClick, onLongClick = onLongClick,
inReplyToClick = inReplyToClick, inReplyToClick = inReplyToClick,
onReactionClick = onReactionClick, onReactionClick = onReactionClick,
@@ -64,6 +64,7 @@ internal fun TimelineItemRow(
onLinkClick: (Link) -> Unit, onLinkClick: (Link) -> Unit,
onLinkLongClick: (Link) -> Unit, onLinkLongClick: (Link) -> Unit,
onContentClick: (TimelineItem.Event) -> Unit, onContentClick: (TimelineItem.Event) -> Unit,
onGalleryItemClick: (TimelineItem.Event, Int) -> Unit,
onLongClick: (TimelineItem.Event) -> Unit, onLongClick: (TimelineItem.Event) -> Unit,
inReplyToClick: (EventId) -> Unit, inReplyToClick: (EventId) -> Unit,
onReactionClick: (key: String, TimelineItem.Event) -> Unit, onReactionClick: (key: String, TimelineItem.Event) -> Unit,
@@ -80,12 +81,13 @@ internal fun TimelineItemRow(
hideMediaContent = timelineProtectionState.hideMediaContent(event.eventId, event.isMine), hideMediaContent = timelineProtectionState.hideMediaContent(event.eventId, event.isMine),
onShowContentClick = { timelineProtectionState.eventSink(TimelineProtectionEvent.ShowContent(event.eventId)) }, onShowContentClick = { timelineProtectionState.eventSink(TimelineProtectionEvent.ShowContent(event.eventId)) },
onContentClick = { onContentClick(event) }, onContentClick = { onContentClick(event) },
onGalleryItemClick = { index -> onGalleryItemClick(event, index) },
onLongClick = { onLongClick(event) }, onLongClick = { onLongClick(event) },
onLinkClick = onLinkClick, onLinkClick = onLinkClick,
onLinkLongClick = onLinkLongClick, onLinkLongClick = onLinkLongClick,
eventSink = eventSink, eventSink = eventSink,
modifier = contentModifier, modifier = contentModifier,
onContentLayoutChange = onContentLayoutChange onContentLayoutChange = onContentLayoutChange,
) )
}, },
) { ) {
@@ -178,6 +180,7 @@ internal fun TimelineItemRow(
onMoreReactionsClick = onMoreReactionsClick, onMoreReactionsClick = onMoreReactionsClick,
onReadReceiptClick = onReadReceiptClick, onReadReceiptClick = onReadReceiptClick,
onSwipeToReply = { onSwipeToReply(timelineItem) }, onSwipeToReply = { onSwipeToReply(timelineItem) },
onGalleryItemClick = { index -> onGalleryItemClick(timelineItem, index) },
eventSink = eventSink, eventSink = eventSink,
eventContentView = { contentModifier, onContentLayoutChange -> eventContentView = { contentModifier, onContentLayoutChange ->
eventContentView(timelineItem, contentModifier, onContentLayoutChange) eventContentView(timelineItem, contentModifier, onContentLayoutChange)
@@ -74,6 +74,7 @@ fun TimelineItemStateEventRow(
onShowContentClick = {}, onShowContentClick = {},
eventSink = eventSink, eventSink = eventSink,
onContentClick = null, onContentClick = null,
onGalleryItemClick = {},
onLongClick = null, onLongClick = null,
modifier = Modifier.defaultTimelineContentPadding() modifier = Modifier.defaultTimelineContentPadding()
) )
@@ -10,7 +10,8 @@ package io.element.android.features.messages.impl.timeline.components.customreac
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.material3.SheetValue
import androidx.compose.material3.rememberBottomSheetState
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
@@ -30,7 +31,9 @@ fun CustomReactionBottomSheet(
onSelectEmoji: (EventOrTransactionId, Emoji) -> Unit, onSelectEmoji: (EventOrTransactionId, Emoji) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val sheetState = rememberModalBottomSheetState() val sheetState = rememberBottomSheetState(
initialValue = SheetValue.Hidden,
)
val coroutineScope = rememberCoroutineScope() val coroutineScope = rememberCoroutineScope()
val target = state.target as? CustomReactionState.Target.Success val target = state.target as? CustomReactionState.Target.Success
@@ -8,61 +8,206 @@
package io.element.android.features.messages.impl.timeline.components.customreaction package io.element.android.features.messages.impl.timeline.components.customreaction
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.gestures.detectDragGesturesAfterLongPress
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.indication
import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.PressInteraction
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.sizeIn import androidx.compose.foundation.layout.sizeIn
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.LocalTextStyle import androidx.compose.material3.LocalTextStyle
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.mutableFloatStateOf
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.drawscope.Fill
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.onPlaced
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.layout.positionInParent
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.semantics.clearAndSetSemantics import androidx.compose.ui.semantics.clearAndSetSemantics
import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.TextUnit import androidx.compose.ui.unit.TextUnit
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Popup
import io.element.android.compound.theme.ElementTheme import io.element.android.compound.theme.ElementTheme
import io.element.android.emojibasebindings.Emoji import io.element.android.emojibasebindings.Emoji
import io.element.android.features.messages.impl.timeline.a11y.a11yReactionAction import io.element.android.features.messages.impl.timeline.a11y.a11yReactionAction
import io.element.android.features.messages.impl.timeline.components.customreaction.picker.SkinTonePadding
import io.element.android.features.messages.impl.timeline.components.customreaction.picker.SkinTonePicker
import io.element.android.features.messages.impl.timeline.components.customreaction.picker.SkinToneSlotSize
import io.element.android.features.messages.impl.timeline.components.customreaction.picker.SkinToneSlotSpacing
import io.element.android.features.messages.impl.timeline.components.customreaction.picker.aSkinList
import io.element.android.libraries.designsystem.preview.ElementPreview 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.text.toDp
import io.element.android.libraries.designsystem.theme.components.Text import io.element.android.libraries.designsystem.theme.components.Text
import kotlinx.collections.immutable.ImmutableSet
import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.persistentSetOf
@Composable @Composable
fun EmojiItem( fun EmojiItem(
item: Emoji, item: Emoji,
isSelected: Boolean, isSelected: Boolean,
onSelectEmoji: (Emoji) -> Unit, onSelectEmoji: (Emoji) -> Unit,
onLongPress: (Emoji) -> Unit,
onDismissSkinPicker: () -> Unit,
skinPickerEmoji: Emoji?,
selectedSkinUnicodes: ImmutableSet<String>,
hasSelectedSkin: Boolean,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
emojiSize: TextUnit = 20.sp, emojiSize: TextUnit = 20.sp,
) { ) {
val backgroundColor = if (isSelected) { val backgroundColor = when {
ElementTheme.colors.bgActionPrimaryRest isSelected -> ElementTheme.colors.bgActionPrimaryRest
} else { hasSelectedSkin -> ElementTheme.colors.bgActionTertiarySelected
Color.Transparent else -> Color.Transparent
} }
val density = LocalDensity.current
var itemSize by remember { mutableStateOf(IntSize.Zero) }
var itemOffsetPercent by remember { mutableFloatStateOf(0f) }
var hoveredIndex by remember { mutableIntStateOf(-1) }
var dismissed by remember { mutableStateOf(false) }
val interactionSource = remember { MutableInteractionSource() }
val description = a11yReactionAction( val description = a11yReactionAction(
emoji = item.unicode, emoji = item.unicode,
userAlreadyReacted = isSelected, userAlreadyReacted = isSelected,
) )
val hasSkinTones = !item.skins.isNullOrEmpty()
Box( Box(
modifier = modifier modifier = modifier
.onPlaced { coordinates ->
val position = coordinates.positionInParent()
val parentBounds = coordinates.parentLayoutCoordinates?.size ?: return@onPlaced
// Calculate the offset of the item inside its parent as a percentage, to be able to set the initial offset
// for the selection inside the skin tone picker relative to its size.
itemOffsetPercent = if (parentBounds.width > 0) {
position.x / parentBounds.width
} else {
0f
}
}
.sizeIn(minWidth = 40.dp, minHeight = 40.dp) .sizeIn(minWidth = 40.dp, minHeight = 40.dp)
.onSizeChanged { itemSize = it }
.background(backgroundColor, CircleShape) .background(backgroundColor, CircleShape)
.clickable( .indication(interactionSource, ripple())
enabled = true, .pointerInput(item) {
onClick = { onSelectEmoji(item) }, // Always detect long press and tap gestures
indication = ripple(bounded = false, radius = emojiSize.toDp() / 2 + 10.dp), detectTapGestures(
interactionSource = remember { MutableInteractionSource() } onPress = { pressOffset ->
val press = PressInteraction.Press(pressOffset)
interactionSource.emit(press)
if (tryAwaitRelease()) {
interactionSource.emit(PressInteraction.Release(press))
if (hasSkinTones) {
onSelectEmoji(item)
}
} else {
interactionSource.emit(PressInteraction.Cancel(press))
}
},
onTap = { onSelectEmoji(item) },
)
}
.then(
// Only detect drag after long press for those items which have a skin tone picker
if (hasSkinTones) {
Modifier.pointerInput(item) {
val slotWidthPx = with(density) { SkinToneSlotSize.toPx() }
val spacingPx = with(density) { SkinToneSlotSpacing.toPx() }
val paddingPx = with(density) { SkinTonePadding.toPx() }
var startOffset = Offset.Zero
detectDragGesturesAfterLongPress(
onDragStart = { position ->
startOffset = position
dismissed = false
onLongPress(item)
hoveredIndex = -1
},
onDrag = { change, _ ->
if (!dismissed) {
change.consume()
// It's a valid drag event if it's within ~2 items height above, so, on top of the current one or the skin tone selector,
// or 1 item below, which should be far enough to not be triggered by mistake
val isValidDrag = if (change.position.y < startOffset.y) {
startOffset.y - change.position.y <= itemSize.height * 2f
} else {
change.position.y - startOffset.y <= itemSize.height
}
if (!isValidDrag) {
dismissed = true
hoveredIndex = -1
onDismissSkinPicker()
} else {
val skinItemsCount = item.skins!!.size
// Original + variants
val totalSlots = 1 + skinItemsCount
// Calculate the whole size of the skin tone picker
val pickerWidthPx = 2 * paddingPx + totalSlots * slotWidthPx + (totalSlots - 1) * spacingPx
// Calculate the initial offset inside the picker, given the relative position of the item inside its parent
val initialOffset = pickerWidthPx * itemOffsetPercent
val xInPicker = initialOffset + change.position.x
// If it's a valid offset, calculate the hovered index, otherwise, set it to -1 to indicate that no item is hovered
val index = if (xInPicker in 0f..pickerWidthPx) {
(xInPicker / slotWidthPx).toInt().coerceIn(0, totalSlots - 1)
} else {
-1
}
hoveredIndex = index
}
}
},
onDragEnd = {
if (!dismissed) {
val idx = hoveredIndex
val skinCount = item.skins?.size ?: 0
if (idx in 0..skinCount) {
if (idx == 0) {
onSelectEmoji(item)
} else {
val skin = item.skins?.getOrNull(idx - 1)
if (skin != null) {
onSelectEmoji(item.copy(unicode = skin.unicode))
}
}
} else if (idx < 0) {
onSelectEmoji(item)
}
}
dismissed = false
hoveredIndex = -1
onDismissSkinPicker()
},
onDragCancel = {
dismissed = false
hoveredIndex = -1
},
)
}
} else {
Modifier
}
) )
.clearAndSetSemantics { .clearAndSetSemantics {
contentDescription = description contentDescription = description
@@ -73,6 +218,42 @@ fun EmojiItem(
text = item.unicode, text = item.unicode,
style = LocalTextStyle.current.copy(fontSize = emojiSize), style = LocalTextStyle.current.copy(fontSize = emojiSize),
) )
if (hasSkinTones) {
val pickerIndicatorColor = ElementTheme.colors.borderInteractivePrimary
Canvas(
modifier = Modifier
.align(Alignment.BottomEnd)
.size(8.dp),
) {
val path = Path().apply {
moveTo(size.width, size.height)
lineTo(size.width, 0f)
lineTo(0f, size.height)
close()
}
drawPath(path, color = pickerIndicatorColor, style = Fill)
}
}
if (skinPickerEmoji == item) {
val pickerHeight = SkinTonePadding * 2 + SkinToneSlotSize
val popupOffsetPx = with(density) { -pickerHeight.roundToPx() }
Popup(
onDismissRequest = { onDismissSkinPicker() },
alignment = Alignment.BottomCenter,
offset = IntOffset(0, popupOffsetPx),
) {
SkinTonePicker(
emoji = item,
onSelect = { selectedEmoji ->
onSelectEmoji(selectedEmoji)
onDismissSkinPicker()
},
hoveredIndex = hoveredIndex,
selectedUnicodes = selectedSkinUnicodes,
)
}
}
} }
} }
@@ -80,21 +261,61 @@ fun EmojiItem(
@Composable @Composable
internal fun EmojiItemPreview() = ElementPreview { internal fun EmojiItemPreview() = ElementPreview {
Row( Row(
modifier = Modifier.padding(4.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp),
) { ) {
for (isSelected in listOf(true, false)) { for (isSelected in listOf(false, true)) {
EmojiItem( for (skinList in listOf(persistentListOf(), aSkinList())) {
item = Emoji( val hasSelectedSkinValues = if (skinList.isNotEmpty()) listOf(false, true) else listOf(false)
hexcode = "", for (hasSelectedSkin in hasSelectedSkinValues) {
label = "", EmojiItem(
tags = null, item = Emoji(
shortcodes = persistentListOf(), hexcode = "",
unicode = "👍", label = "",
skins = null tags = null,
), shortcodes = persistentListOf(),
isSelected = isSelected, unicode = "👍",
onSelectEmoji = {}, skins = skinList,
) ),
isSelected = isSelected,
onSelectEmoji = {},
hasSelectedSkin = hasSelectedSkin,
onLongPress = {},
onDismissSkinPicker = {},
skinPickerEmoji = null,
selectedSkinUnicodes = persistentSetOf(),
)
}
}
} }
} }
} }
@PreviewsDayNight
@Composable
internal fun EmojiItemWithPopupPreview() = ElementPreview {
Row(
modifier = Modifier
.padding(top = 100.dp, bottom = 4.dp, start = 200.dp, end = 200.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
val item = Emoji(
hexcode = "",
label = "",
tags = null,
shortcodes = persistentListOf(),
unicode = "👍",
skins = aSkinList(),
)
EmojiItem(
item = item,
isSelected = false,
onSelectEmoji = {},
hasSelectedSkin = false,
onLongPress = {},
onDismissSkinPicker = {},
skinPickerEmoji = item,
selectedSkinUnicodes = persistentSetOf("👍🏾"),
)
}
}
@@ -25,7 +25,11 @@ import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.SecondaryTabRow import androidx.compose.material3.SecondaryTabRow
import androidx.compose.material3.Tab import androidx.compose.material3.Tab
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
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.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.PreviewParameter
@@ -54,6 +58,8 @@ fun EmojiPicker(
) { ) {
val coroutineScope = rememberCoroutineScope() val coroutineScope = rememberCoroutineScope()
val pagerState = rememberPagerState(pageCount = { state.categories.size }) val pagerState = rememberPagerState(pageCount = { state.categories.size })
var skinPickerEmoji by remember { mutableStateOf<Emoji?>(null) }
Column(modifier) { Column(modifier) {
SearchBar( SearchBar(
modifier = Modifier.padding(bottom = 10.dp), modifier = Modifier.padding(bottom = 10.dp),
@@ -68,6 +74,10 @@ fun EmojiPicker(
emojis = emojis, emojis = emojis,
isEmojiSelected = { selectedEmojis.contains(it.unicode) }, isEmojiSelected = { selectedEmojis.contains(it.unicode) },
onSelectEmoji = onSelectEmoji, onSelectEmoji = onSelectEmoji,
onLongPress = { skinPickerEmoji = it },
skinPickerEmoji = skinPickerEmoji,
onDismissSkinPicker = { skinPickerEmoji = null },
selectedEmojis = selectedEmojis,
) )
} }
@@ -106,6 +116,10 @@ fun EmojiPicker(
emojis = emojis, emojis = emojis,
isEmojiSelected = { selectedEmojis.contains(it.unicode) }, isEmojiSelected = { selectedEmojis.contains(it.unicode) },
onSelectEmoji = onSelectEmoji, onSelectEmoji = onSelectEmoji,
onLongPress = { skinPickerEmoji = it },
skinPickerEmoji = skinPickerEmoji,
onDismissSkinPicker = { skinPickerEmoji = null },
selectedEmojis = selectedEmojis,
) )
} }
} }
@@ -117,6 +131,10 @@ private fun EmojiResults(
emojis: ImmutableList<Emoji>, emojis: ImmutableList<Emoji>,
isEmojiSelected: (Emoji) -> Boolean, isEmojiSelected: (Emoji) -> Boolean,
onSelectEmoji: (Emoji) -> Unit, onSelectEmoji: (Emoji) -> Unit,
onLongPress: (Emoji) -> Unit,
skinPickerEmoji: Emoji?,
onDismissSkinPicker: () -> Unit,
selectedEmojis: ImmutableSet<String>,
) { ) {
LazyVerticalGrid( LazyVerticalGrid(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
@@ -131,7 +149,12 @@ private fun EmojiResults(
item = item, item = item,
isSelected = isEmojiSelected(item), isSelected = isEmojiSelected(item),
onSelectEmoji = onSelectEmoji, onSelectEmoji = onSelectEmoji,
onLongPress = onLongPress,
skinPickerEmoji = skinPickerEmoji,
onDismissSkinPicker = onDismissSkinPicker,
emojiSize = 32.dp.toSp(), emojiSize = 32.dp.toSp(),
selectedSkinUnicodes = selectedEmojis,
hasSelectedSkin = item.skins?.any { skin -> skin.unicode in selectedEmojis } == true,
) )
} }
} }

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