mirror of
https://github.com/Novaloop-AG/element-x-android.git
synced 2026-11-05 12:54:35 +00:00
Merge branch 'develop' into feature/fga/set_user_status
This commit is contained in:
@@ -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'
|
||||||
|
|||||||
@@ -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'
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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'
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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'
|
||||||
|
|||||||
@@ -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'
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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'
|
||||||
|
|||||||
@@ -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'
|
||||||
|
|||||||
@@ -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'
|
||||||
|
|||||||
@@ -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'
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
=============================
|
=============================
|
||||||
|
|
||||||
|
|||||||
+1
-1
Submodule enterprise updated: efdf9d9a15...bc38a11f95
@@ -0,0 +1,2 @@
|
|||||||
|
Main changes in this version: bug fixes and improvements.
|
||||||
|
Full changelog: https://github.com/element-hq/element-x-android/releases
|
||||||
+6
-7
@@ -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,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
+9
-11
@@ -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)
|
||||||
|
}
|
||||||
+5
-4
@@ -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>
|
||||||
|
|||||||
+7
-7
@@ -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>
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
+2
-2
@@ -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,
|
||||||
|
|||||||
+5
-18
@@ -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 {
|
||||||
|
|||||||
+6
-2
@@ -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)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+5
-4
@@ -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>
|
||||||
|
|||||||
+22
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
+1
-1
@@ -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,
|
||||||
|
|||||||
+31
-2
@@ -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>
|
||||||
|
|||||||
+4
@@ -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(
|
||||||
|
|||||||
+4
-2
@@ -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,
|
||||||
|
|||||||
+4
-2
@@ -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)
|
||||||
|
|
||||||
|
|||||||
+4
-2
@@ -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>
|
||||||
|
|||||||
+7
-10
@@ -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>
|
||||||
|
|||||||
+197
-8
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+13
@@ -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(
|
||||||
|
|||||||
+3
-3
@@ -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))
|
||||||
|
|||||||
+43
-25
@@ -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 = {},
|
||||||
|
|||||||
+9
-3
@@ -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) {
|
||||||
|
|||||||
+16
-3
@@ -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(
|
||||||
|
|||||||
+1
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
+3
-2
@@ -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,
|
||||||
|
|||||||
+232
-174
@@ -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,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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+40
-5
@@ -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,
|
||||||
|
|||||||
+111
-19
@@ -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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+32
@@ -0,0 +1,32 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2026 Element Creations Ltd.
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||||
|
* Please see LICENSE files in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.features.messages.impl.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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
+3
@@ -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(),
|
||||||
|
|||||||
+1
@@ -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
|
||||||
|
|||||||
+2
@@ -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?,
|
||||||
|
|||||||
+1
@@ -35,6 +35,7 @@ internal fun MessagesViewWithIdentityChangePreview(
|
|||||||
onBackClick = {},
|
onBackClick = {},
|
||||||
onRoomDetailsClick = {},
|
onRoomDetailsClick = {},
|
||||||
onEventContentClick = { _, _ -> false },
|
onEventContentClick = { _, _ -> false },
|
||||||
|
onGalleryEventItemClick = { _, _, _ -> false },
|
||||||
onUserDataClick = {},
|
onUserDataClick = {},
|
||||||
onLinkClick = { _, _ -> },
|
onLinkClick = { _, _ -> },
|
||||||
onSendLocationClick = {},
|
onSendLocationClick = {},
|
||||||
|
|||||||
+6
-2
@@ -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() {
|
||||||
|
|||||||
+8
-5
@@ -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 = {},
|
||||||
|
|||||||
+58
-9
@@ -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(
|
||||||
|
|||||||
+4
@@ -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 = {
|
||||||
|
|||||||
+10
@@ -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 = {},
|
||||||
|
|||||||
+15
@@ -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(
|
||||||
|
|||||||
+11
@@ -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,
|
||||||
)
|
)
|
||||||
|
|||||||
+6
-1
@@ -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 = {},
|
||||||
|
|||||||
+2
@@ -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.
|
||||||
*/
|
*/
|
||||||
|
|||||||
+85
-9
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
+17
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
+6
-1
@@ -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,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
+296
-41
@@ -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)),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+1
@@ -49,6 +49,7 @@ internal fun TimelineViewMessageShieldPreview() = ElementPreview {
|
|||||||
onReactionLongClick = { _, _ -> },
|
onReactionLongClick = { _, _ -> },
|
||||||
onMoreReactionsClick = {},
|
onMoreReactionsClick = {},
|
||||||
onReadReceiptClick = {},
|
onReadReceiptClick = {},
|
||||||
|
onGalleryItemClick = { _, _ -> },
|
||||||
forceJumpToBottomVisibility = true,
|
forceJumpToBottomVisibility = true,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
+1
@@ -33,6 +33,7 @@ internal fun ATimelineItemEventRow(
|
|||||||
isLastOutgoingMessage = isLastOutgoingMessage,
|
isLastOutgoingMessage = isLastOutgoingMessage,
|
||||||
displayThreadSummaries = displayThreadSummaries,
|
displayThreadSummaries = displayThreadSummaries,
|
||||||
onEventClick = {},
|
onEventClick = {},
|
||||||
|
onGalleryItemClick = {},
|
||||||
onLongClick = {},
|
onLongClick = {},
|
||||||
onLinkClick = {},
|
onLinkClick = {},
|
||||||
onLinkLongClick = {},
|
onLinkLongClick = {},
|
||||||
|
|||||||
+6
-1
@@ -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)),
|
||||||
) {
|
) {
|
||||||
|
|||||||
+9
-1
@@ -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)
|
||||||
|
|||||||
+138
@@ -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",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
+200
-17
@@ -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() {
|
||||||
|
|||||||
+35
@@ -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,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+4
-8
@@ -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,
|
||||||
|
|||||||
+4
-1
@@ -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)
|
||||||
|
|||||||
+1
@@ -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()
|
||||||
)
|
)
|
||||||
|
|||||||
+5
-2
@@ -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
|
||||||
|
|
||||||
|
|||||||
+245
-24
@@ -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("👍🏾"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+23
@@ -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
Reference in New Issue
Block a user