mirror of
https://github.com/simplex-chat/simplex-chat.git
synced 2024-12-17 17:20:21 +01:00
Compare commits
44 Commits
iOS
..
docs-polish
| Author | SHA1 | Date | |
|---|---|---|---|
| bcce6a18a1 | |||
| 3bce760e05 | |||
| a7c60f0721 | |||
| 94ad68155f | |||
| cb2b5ff97b | |||
| 313c40590f | |||
| d806efc347 | |||
| 743db49215 | |||
| 4fdffdb8aa | |||
| 1699aae906 | |||
| 82eba49b95 | |||
| 218b1a8b7e | |||
| 6c1bba55b4 | |||
| c9fc1547b0 | |||
| 8a3a8455d4 | |||
| 9c437f6204 | |||
| ef698e58d4 | |||
| 01c0ac3d13 | |||
| ed7089902c | |||
| 1ddd4193a0 | |||
| 932875fd9d | |||
| 1899c84ee2 | |||
| 91e03a016f | |||
| a978c12ca3 | |||
| da1f2d9ed6 | |||
| 1ed6179782 | |||
| e15251e354 | |||
| c348dd765f | |||
| 78a23e6b02 | |||
| 590499684d | |||
| 59c50f8088 | |||
| ee4c759706 | |||
| d5bd3a7d68 | |||
| 7159195cb9 | |||
| 45d3855d84 | |||
| 650a6f0758 | |||
| ae6ba0cfb5 | |||
| d9a2317f82 | |||
| 3d8b521c0c | |||
| 1fd5bbbc4f | |||
| 62299cbf0b | |||
| 3eb969d3c5 | |||
| 85dba0f36b | |||
| cb6490ed59 |
@@ -1,7 +1,7 @@
|
|||||||
name: Bug
|
name: Bug
|
||||||
description: File a bug report/issue
|
description: File a bug report/issue
|
||||||
title: "[Bug]: "
|
title: "[Bug]: "
|
||||||
labels: ["bug", "triage"]
|
labels: ["type:bug", "type:triage"]
|
||||||
body:
|
body:
|
||||||
- type: checkboxes
|
- type: checkboxes
|
||||||
attributes:
|
attributes:
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
name: Feature
|
name: Feature
|
||||||
description: Suggest your feature
|
description: Suggest your feature
|
||||||
title: "[Feature]: "
|
title: "[Feature]: "
|
||||||
labels: ["enhancement", "triage"]
|
labels: ["type:enhancement", "type:triage"]
|
||||||
body:
|
body:
|
||||||
- type: checkboxes
|
- type: checkboxes
|
||||||
attributes:
|
attributes:
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
name: Question
|
name: Question
|
||||||
description: Ask your question
|
description: Ask your question
|
||||||
title: "[Q]: "
|
title: "[Q]: "
|
||||||
labels: ["question", "triage"]
|
labels: ["type:question", "type:triage"]
|
||||||
body:
|
body:
|
||||||
- type: markdown
|
- type: markdown
|
||||||
attributes:
|
attributes:
|
||||||
|
|||||||
+26
-224
@@ -8,7 +8,6 @@ on:
|
|||||||
- users
|
- users
|
||||||
tags:
|
tags:
|
||||||
- "v*"
|
- "v*"
|
||||||
- "!*-fdroid"
|
|
||||||
pull_request:
|
pull_request:
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
@@ -17,11 +16,11 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Clone project
|
- name: Clone project
|
||||||
uses: actions/checkout@v3
|
uses: actions/checkout@v2
|
||||||
|
|
||||||
- name: Build changelog
|
- name: Build changelog
|
||||||
id: build_changelog
|
id: build_changelog
|
||||||
uses: mikepenz/release-changelog-builder-action@v4
|
uses: mikepenz/release-changelog-builder-action@v1
|
||||||
with:
|
with:
|
||||||
configuration: .github/changelog_conf.json
|
configuration: .github/changelog_conf.json
|
||||||
failOnError: true
|
failOnError: true
|
||||||
@@ -53,39 +52,27 @@ jobs:
|
|||||||
- os: ubuntu-20.04
|
- os: ubuntu-20.04
|
||||||
cache_path: ~/.cabal/store
|
cache_path: ~/.cabal/store
|
||||||
asset_name: simplex-chat-ubuntu-20_04-x86-64
|
asset_name: simplex-chat-ubuntu-20_04-x86-64
|
||||||
desktop_asset_name: simplex-desktop-ubuntu-20_04-x86_64.deb
|
|
||||||
- os: ubuntu-22.04
|
- os: ubuntu-22.04
|
||||||
cache_path: ~/.cabal/store
|
cache_path: ~/.cabal/store
|
||||||
asset_name: simplex-chat-ubuntu-22_04-x86-64
|
asset_name: simplex-chat-ubuntu-22_04-x86-64
|
||||||
desktop_asset_name: simplex-desktop-ubuntu-22_04-x86_64.deb
|
|
||||||
- os: macos-latest
|
- os: macos-latest
|
||||||
cache_path: ~/.cabal/store
|
cache_path: ~/.cabal/store
|
||||||
asset_name: simplex-chat-macos-x86-64
|
asset_name: simplex-chat-macos-x86-64
|
||||||
desktop_asset_name: simplex-desktop-macos-x86_64.dmg
|
|
||||||
- os: windows-latest
|
- os: windows-latest
|
||||||
cache_path: C:/cabal
|
cache_path: C:/cabal
|
||||||
asset_name: simplex-chat-windows-x86-64
|
asset_name: simplex-chat-windows-x86-64
|
||||||
desktop_asset_name: simplex-desktop-windows-x86_64.msi
|
|
||||||
steps:
|
steps:
|
||||||
- name: Configure pagefile (Windows)
|
|
||||||
if: matrix.os == 'windows-latest'
|
|
||||||
uses: al-cheb/configure-pagefile-action@v1.3
|
|
||||||
with:
|
|
||||||
minimum-size: 16GB
|
|
||||||
maximum-size: 16GB
|
|
||||||
disk-root: "C:"
|
|
||||||
|
|
||||||
- name: Clone project
|
- name: Clone project
|
||||||
uses: actions/checkout@v3
|
uses: actions/checkout@v2
|
||||||
|
|
||||||
- name: Setup Haskell
|
- name: Setup Haskell
|
||||||
uses: haskell-actions/setup@v2
|
uses: haskell/actions/setup@v1
|
||||||
with:
|
with:
|
||||||
ghc-version: "9.6.3"
|
ghc-version: "8.10.7"
|
||||||
cabal-version: "3.10.1.0"
|
cabal-version: "latest"
|
||||||
|
|
||||||
- name: Cache dependencies
|
- name: Cache dependencies
|
||||||
uses: actions/cache@v3
|
uses: actions/cache@v2
|
||||||
with:
|
with:
|
||||||
path: |
|
path: |
|
||||||
${{ matrix.cache_path }}
|
${{ matrix.cache_path }}
|
||||||
@@ -104,10 +91,6 @@ jobs:
|
|||||||
echo " extra-lib-dirs: /usr/local/opt/openssl@1.1/lib" >> cabal.project.local
|
echo " extra-lib-dirs: /usr/local/opt/openssl@1.1/lib" >> cabal.project.local
|
||||||
echo " flags: +openssl" >> cabal.project.local
|
echo " flags: +openssl" >> cabal.project.local
|
||||||
|
|
||||||
- name: Install AppImage dependencies
|
|
||||||
if: startsWith(github.ref, 'refs/tags/v') && matrix.os == 'ubuntu-20.04'
|
|
||||||
run: sudo apt install -y desktop-file-utils
|
|
||||||
|
|
||||||
- name: Install pkg-config for Mac
|
- name: Install pkg-config for Mac
|
||||||
if: matrix.os == 'macos-latest'
|
if: matrix.os == 'macos-latest'
|
||||||
run: brew install pkg-config
|
run: brew install pkg-config
|
||||||
@@ -120,135 +103,13 @@ jobs:
|
|||||||
echo "package direct-sqlcipher" >> cabal.project.local
|
echo "package direct-sqlcipher" >> cabal.project.local
|
||||||
echo " flags: +openssl" >> cabal.project.local
|
echo " flags: +openssl" >> cabal.project.local
|
||||||
|
|
||||||
- name: Unix build CLI
|
- name: Unix build
|
||||||
id: unix_cli_build
|
id: unix_build
|
||||||
if: matrix.os != 'windows-latest'
|
if: matrix.os != 'windows-latest'
|
||||||
shell: bash
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
cabal build --enable-tests
|
cabal build --enable-tests
|
||||||
path=$(cabal list-bin simplex-chat)
|
echo "::set-output name=bin_path::$(cabal list-bin simplex-chat)"
|
||||||
echo "bin_path=$path" >> $GITHUB_OUTPUT
|
|
||||||
echo "bin_hash=$(echo SHA2-512\(${{ matrix.asset_name }}\)= $(openssl sha512 $path | cut -d' ' -f 2))" >> $GITHUB_OUTPUT
|
|
||||||
|
|
||||||
- name: Unix upload CLI binary to release
|
|
||||||
if: startsWith(github.ref, 'refs/tags/v') && matrix.os != 'windows-latest'
|
|
||||||
uses: svenstaro/upload-release-action@v2
|
|
||||||
with:
|
|
||||||
repo_token: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
file: ${{ steps.unix_cli_build.outputs.bin_path }}
|
|
||||||
asset_name: ${{ matrix.asset_name }}
|
|
||||||
tag: ${{ github.ref }}
|
|
||||||
|
|
||||||
- name: Unix update CLI binary hash
|
|
||||||
if: startsWith(github.ref, 'refs/tags/v') && matrix.os != 'windows-latest'
|
|
||||||
uses: softprops/action-gh-release@v1
|
|
||||||
env:
|
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
with:
|
|
||||||
append_body: true
|
|
||||||
body: |
|
|
||||||
${{ steps.unix_cli_build.outputs.bin_hash }}
|
|
||||||
|
|
||||||
- name: Setup Java
|
|
||||||
if: startsWith(github.ref, 'refs/tags/v')
|
|
||||||
uses: actions/setup-java@v3
|
|
||||||
with:
|
|
||||||
distribution: 'corretto'
|
|
||||||
java-version: '17'
|
|
||||||
cache: 'gradle'
|
|
||||||
|
|
||||||
- name: Linux build desktop
|
|
||||||
id: linux_desktop_build
|
|
||||||
if: startsWith(github.ref, 'refs/tags/v') && (matrix.os == 'ubuntu-20.04' || matrix.os == 'ubuntu-22.04')
|
|
||||||
shell: bash
|
|
||||||
run: |
|
|
||||||
scripts/desktop/build-lib-linux.sh
|
|
||||||
cd apps/multiplatform
|
|
||||||
./gradlew packageDeb
|
|
||||||
path=$(echo $PWD/release/main/deb/simplex_*_amd64.deb)
|
|
||||||
echo "package_path=$path" >> $GITHUB_OUTPUT
|
|
||||||
echo "package_hash=$(echo SHA2-512\(${{ matrix.desktop_asset_name }}\)= $(openssl sha512 $path | cut -d' ' -f 2))" >> $GITHUB_OUTPUT
|
|
||||||
|
|
||||||
- name: Linux make AppImage
|
|
||||||
id: linux_appimage_build
|
|
||||||
if: startsWith(github.ref, 'refs/tags/v') && matrix.os == 'ubuntu-20.04'
|
|
||||||
shell: bash
|
|
||||||
run: |
|
|
||||||
scripts/desktop/make-appimage-linux.sh
|
|
||||||
path=$(echo $PWD/apps/multiplatform/release/main/*imple*.AppImage)
|
|
||||||
echo "appimage_path=$path" >> $GITHUB_OUTPUT
|
|
||||||
echo "appimage_hash=$(echo SHA2-512\(simplex-desktop-x86_64.AppImage\)= $(openssl sha512 $path | cut -d' ' -f 2))" >> $GITHUB_OUTPUT
|
|
||||||
|
|
||||||
- name: Mac build desktop
|
|
||||||
id: mac_desktop_build
|
|
||||||
if: startsWith(github.ref, 'refs/tags/v') && matrix.os == 'macos-latest'
|
|
||||||
shell: bash
|
|
||||||
env:
|
|
||||||
APPLE_SIMPLEX_SIGNING_KEYCHAIN: ${{ secrets.APPLE_SIMPLEX_SIGNING_KEYCHAIN }}
|
|
||||||
APPLE_SIMPLEX_NOTARIZATION_APPLE_ID: ${{ secrets.APPLE_SIMPLEX_NOTARIZATION_APPLE_ID }}
|
|
||||||
APPLE_SIMPLEX_NOTARIZATION_PASSWORD: ${{ secrets.APPLE_SIMPLEX_NOTARIZATION_PASSWORD }}
|
|
||||||
run: |
|
|
||||||
scripts/ci/build-desktop-mac.sh
|
|
||||||
path=$(echo $PWD/apps/multiplatform/release/main/dmg/SimpleX-*.dmg)
|
|
||||||
echo "package_path=$path" >> $GITHUB_OUTPUT
|
|
||||||
echo "package_hash=$(echo SHA2-512\(${{ matrix.desktop_asset_name }}\)= $(openssl sha512 $path | cut -d' ' -f 2))" >> $GITHUB_OUTPUT
|
|
||||||
|
|
||||||
- name: Linux upload desktop package to release
|
|
||||||
if: startsWith(github.ref, 'refs/tags/v') && (matrix.os == 'ubuntu-20.04' || matrix.os == 'ubuntu-22.04')
|
|
||||||
uses: svenstaro/upload-release-action@v2
|
|
||||||
with:
|
|
||||||
repo_token: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
file: ${{ steps.linux_desktop_build.outputs.package_path }}
|
|
||||||
asset_name: ${{ matrix.desktop_asset_name }}
|
|
||||||
tag: ${{ github.ref }}
|
|
||||||
|
|
||||||
- name: Linux update desktop package hash
|
|
||||||
if: startsWith(github.ref, 'refs/tags/v') && (matrix.os == 'ubuntu-20.04' || matrix.os == 'ubuntu-22.04')
|
|
||||||
uses: softprops/action-gh-release@v1
|
|
||||||
env:
|
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
with:
|
|
||||||
append_body: true
|
|
||||||
body: |
|
|
||||||
${{ steps.linux_desktop_build.outputs.package_hash }}
|
|
||||||
|
|
||||||
- name: Linux upload AppImage to release
|
|
||||||
if: startsWith(github.ref, 'refs/tags/v') && matrix.os == 'ubuntu-20.04'
|
|
||||||
uses: svenstaro/upload-release-action@v2
|
|
||||||
with:
|
|
||||||
repo_token: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
file: ${{ steps.linux_appimage_build.outputs.appimage_path }}
|
|
||||||
asset_name: simplex-desktop-x86_64.AppImage
|
|
||||||
tag: ${{ github.ref }}
|
|
||||||
|
|
||||||
- name: Linux update AppImage hash
|
|
||||||
if: startsWith(github.ref, 'refs/tags/v') && matrix.os == 'ubuntu-20.04'
|
|
||||||
uses: softprops/action-gh-release@v1
|
|
||||||
env:
|
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
with:
|
|
||||||
append_body: true
|
|
||||||
body: |
|
|
||||||
${{ steps.linux_appimage_build.outputs.appimage_hash }}
|
|
||||||
|
|
||||||
- name: Mac upload desktop package to release
|
|
||||||
if: startsWith(github.ref, 'refs/tags/v') && matrix.os == 'macos-latest'
|
|
||||||
uses: svenstaro/upload-release-action@v2
|
|
||||||
with:
|
|
||||||
repo_token: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
file: ${{ steps.mac_desktop_build.outputs.package_path }}
|
|
||||||
asset_name: ${{ matrix.desktop_asset_name }}
|
|
||||||
tag: ${{ github.ref }}
|
|
||||||
|
|
||||||
- name: Mac update desktop package hash
|
|
||||||
if: startsWith(github.ref, 'refs/tags/v') && matrix.os == 'macos-latest'
|
|
||||||
uses: softprops/action-gh-release@v1
|
|
||||||
env:
|
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
with:
|
|
||||||
append_body: true
|
|
||||||
body: |
|
|
||||||
${{ steps.mac_desktop_build.outputs.package_hash }}
|
|
||||||
|
|
||||||
- name: Unix test
|
- name: Unix test
|
||||||
if: matrix.os != 'windows-latest'
|
if: matrix.os != 'windows-latest'
|
||||||
@@ -256,50 +117,33 @@ jobs:
|
|||||||
shell: bash
|
shell: bash
|
||||||
run: cabal test --test-show-details=direct
|
run: cabal test --test-show-details=direct
|
||||||
|
|
||||||
|
- name: Unix upload binary to release
|
||||||
|
if: startsWith(github.ref, 'refs/tags/v') && matrix.os != 'windows-latest'
|
||||||
|
uses: svenstaro/upload-release-action@v2
|
||||||
|
with:
|
||||||
|
repo_token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
file: ${{ steps.unix_build.outputs.bin_path }}
|
||||||
|
asset_name: ${{ matrix.asset_name }}
|
||||||
|
tag: ${{ github.ref }}
|
||||||
|
|
||||||
# Unix /
|
# Unix /
|
||||||
|
|
||||||
# / Windows
|
# / Windows
|
||||||
# rm -rf dist-newstyle/src/direct-sq* is here because of the bug in cabal's dependency which prevents second build from finishing
|
|
||||||
|
|
||||||
- name: 'Setup MSYS2'
|
|
||||||
if: matrix.os == 'windows-latest'
|
|
||||||
uses: msys2/setup-msys2@v2
|
|
||||||
with:
|
|
||||||
msystem: ucrt64
|
|
||||||
update: true
|
|
||||||
install: >-
|
|
||||||
git
|
|
||||||
perl
|
|
||||||
make
|
|
||||||
pacboy: >-
|
|
||||||
toolchain:p
|
|
||||||
cmake:p
|
|
||||||
|
|
||||||
|
# * In powershell multiline commands do not fail if individual commands fail - https://github.community/t/multiline-commands-on-windows-do-not-fail-if-individual-commands-fail/16753
|
||||||
|
# * And GitHub Actions does not support parameterizing shell in a matrix job - https://github.community/t/using-matrix-to-specify-shell-is-it-possible/17065
|
||||||
|
|
||||||
- name: Windows build
|
- name: Windows build
|
||||||
id: windows_build
|
id: windows_build
|
||||||
if: matrix.os == 'windows-latest'
|
if: matrix.os == 'windows-latest'
|
||||||
shell: msys2 {0}
|
shell: cmd
|
||||||
run: |
|
run: |
|
||||||
export PATH=$PATH:/c/ghcup/bin
|
|
||||||
scripts/desktop/prepare-openssl-windows.sh
|
|
||||||
openssl_windows_style_path=$(echo `pwd`/dist-newstyle/openssl-1.1.1w | sed 's#/\([a-zA-Z]\)#\1:#' | sed 's#/#\\#g')
|
|
||||||
rm cabal.project.local 2>/dev/null || true
|
|
||||||
echo "ignore-project: False" >> cabal.project.local
|
|
||||||
echo "package direct-sqlcipher" >> cabal.project.local
|
|
||||||
echo " flags: +openssl" >> cabal.project.local
|
|
||||||
echo " extra-include-dirs: $openssl_windows_style_path\include" >> cabal.project.local
|
|
||||||
echo " extra-lib-dirs: $openssl_windows_style_path" >> cabal.project.local
|
|
||||||
|
|
||||||
rm -rf dist-newstyle/src/direct-sq*
|
|
||||||
sed -i "s/, unix /--, unix /" simplex-chat.cabal
|
|
||||||
cabal build --enable-tests
|
cabal build --enable-tests
|
||||||
rm -rf dist-newstyle/src/direct-sq*
|
cabal list-bin simplex-chat > tmp_bin_path
|
||||||
path=$(cabal list-bin simplex-chat | tail -n 1)
|
set /p bin_path= < tmp_bin_path
|
||||||
echo "bin_path=$path" >> $GITHUB_OUTPUT
|
echo ::set-output name=bin_path::%bin_path%
|
||||||
echo "bin_hash=$(echo SHA2-512\(${{ matrix.asset_name }}\)= $(openssl sha512 $path | cut -d' ' -f 2))" >> $GITHUB_OUTPUT
|
|
||||||
|
|
||||||
- name: Windows upload CLI binary to release
|
- name: Windows upload binary to release
|
||||||
if: startsWith(github.ref, 'refs/tags/v') && matrix.os == 'windows-latest'
|
if: startsWith(github.ref, 'refs/tags/v') && matrix.os == 'windows-latest'
|
||||||
uses: svenstaro/upload-release-action@v2
|
uses: svenstaro/upload-release-action@v2
|
||||||
with:
|
with:
|
||||||
@@ -308,46 +152,4 @@ jobs:
|
|||||||
asset_name: ${{ matrix.asset_name }}
|
asset_name: ${{ matrix.asset_name }}
|
||||||
tag: ${{ github.ref }}
|
tag: ${{ github.ref }}
|
||||||
|
|
||||||
- name: Windows update CLI binary hash
|
|
||||||
if: startsWith(github.ref, 'refs/tags/v') && matrix.os == 'windows-latest'
|
|
||||||
uses: softprops/action-gh-release@v1
|
|
||||||
env:
|
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
with:
|
|
||||||
append_body: true
|
|
||||||
body: |
|
|
||||||
${{ steps.windows_build.outputs.bin_hash }}
|
|
||||||
|
|
||||||
- name: Windows build desktop
|
|
||||||
id: windows_desktop_build
|
|
||||||
if: startsWith(github.ref, 'refs/tags/v') && matrix.os == 'windows-latest'
|
|
||||||
shell: msys2 {0}
|
|
||||||
run: |
|
|
||||||
export PATH=$PATH:/c/ghcup/bin
|
|
||||||
scripts/desktop/build-lib-windows.sh
|
|
||||||
cd apps/multiplatform
|
|
||||||
./gradlew packageMsi
|
|
||||||
path=$(echo $PWD/release/main/msi/*imple*.msi | sed 's#/\([a-z]\)#\1:#' | sed 's#/#\\#g')
|
|
||||||
echo "package_path=$path" >> $GITHUB_OUTPUT
|
|
||||||
echo "package_hash=$(echo SHA2-512\(${{ matrix.desktop_asset_name }}\)= $(openssl sha512 $path | cut -d' ' -f 2))" >> $GITHUB_OUTPUT
|
|
||||||
|
|
||||||
- name: Windows upload desktop package to release
|
|
||||||
if: startsWith(github.ref, 'refs/tags/v') && matrix.os == 'windows-latest'
|
|
||||||
uses: svenstaro/upload-release-action@v2
|
|
||||||
with:
|
|
||||||
repo_token: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
file: ${{ steps.windows_desktop_build.outputs.package_path }}
|
|
||||||
asset_name: ${{ matrix.desktop_asset_name }}
|
|
||||||
tag: ${{ github.ref }}
|
|
||||||
|
|
||||||
- name: Windows update desktop package hash
|
|
||||||
if: startsWith(github.ref, 'refs/tags/v') && matrix.os == 'windows-latest'
|
|
||||||
uses: softprops/action-gh-release@v1
|
|
||||||
env:
|
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
with:
|
|
||||||
append_body: true
|
|
||||||
body: |
|
|
||||||
${{ steps.windows_desktop_build.outputs.package_hash }}
|
|
||||||
|
|
||||||
# Windows /
|
# Windows /
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ on:
|
|||||||
- website/**
|
- website/**
|
||||||
- images/**
|
- images/**
|
||||||
- blog/**
|
- blog/**
|
||||||
- docs/**
|
|
||||||
- .github/workflows/web.yml
|
- .github/workflows/web.yml
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
@@ -18,7 +17,7 @@ jobs:
|
|||||||
|
|
||||||
strategy:
|
strategy:
|
||||||
matrix:
|
matrix:
|
||||||
node-version: [16.x]
|
node-version: [12.x]
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v2
|
- uses: actions/checkout@v2
|
||||||
|
|||||||
+1
-2
@@ -49,11 +49,10 @@ logs/
|
|||||||
# for website
|
# for website
|
||||||
website/node_modules/
|
website/node_modules/
|
||||||
website/src/blog/
|
website/src/blog/
|
||||||
website/src/docs/
|
|
||||||
website/translations.json
|
website/translations.json
|
||||||
|
website/src/_data/supported_languages.json
|
||||||
website/src/img/images/
|
website/src/img/images/
|
||||||
website/src/images/
|
website/src/images/
|
||||||
website/src/js/lottie.min.js
|
|
||||||
# Generated files
|
# Generated files
|
||||||
website/package/generated*
|
website/package/generated*
|
||||||
|
|
||||||
|
|||||||
+4
-4
@@ -8,12 +8,12 @@ RUN a=$(arch); curl https://downloads.haskell.org/~ghcup/$a-linux-ghcup -o /usr/
|
|||||||
chmod +x /usr/bin/ghcup
|
chmod +x /usr/bin/ghcup
|
||||||
|
|
||||||
# Install ghc
|
# Install ghc
|
||||||
RUN ghcup install ghc 9.6.3
|
RUN ghcup install ghc 8.10.7
|
||||||
# Install cabal
|
# Install cabal
|
||||||
RUN ghcup install cabal 3.10.1.0
|
RUN ghcup install cabal
|
||||||
# Set both as default
|
# Set both as default
|
||||||
RUN ghcup set ghc 9.6.3 && \
|
RUN ghcup set ghc 8.10.7 && \
|
||||||
ghcup set cabal 3.10.1.0
|
ghcup set cabal
|
||||||
|
|
||||||
COPY . /project
|
COPY . /project
|
||||||
WORKDIR /project
|
WORKDIR /project
|
||||||
|
|||||||
+16
-54
@@ -6,63 +6,27 @@ If you believe that some of the clauses in this document are not aligned with ou
|
|||||||
|
|
||||||
## Privacy Policy
|
## Privacy Policy
|
||||||
|
|
||||||
SimpleX Chat Ltd. ("SimpleX Chat") uses the best industry practices for security and encryption to provide secure [end-to-end encrypted](./docs/GLOSSARY.md#end-to-end-encryption) messaging via private connections. This encryption cannot be compromised by the servers via [man-in-the-middle attack](./docs/GLOSSARY.md#man-in-the-middle-attack).
|
SimpleX Chat Ltd. ("SimpleX Chat") uses the best industry practices for security and end-to-end encryption to provide secure end-to-end encrypted messaging via private connections. SimpleX Chat is built on top of SimpleX messaging and application platform that uses a new message routing protocol that allows establishing private connection without having any kind of addresses that identify its users - we don't use emails, phone numbers, usernames, identity keys or any other user identifiers to pass messages between the users.
|
||||||
|
|
||||||
SimpleX Chat is built on top of SimpleX messaging and application platform that uses a new message routing protocol allowing to establish private connections without having any kind of addresses that identify its users - we don't use emails, phone numbers, usernames, identity keys or any other user identifiers to pass messages between the users.
|
SimpleX Chat security audit was performed in October 2022 by [Trail of Bits](https://www.trailofbits.com/about), and most fixes were released in v4.2.0 – see [the announcement](./blog/20221108-simplex-chat-v4.2-security-audit-new-website.md).
|
||||||
|
|
||||||
SimpleX Chat security assessment was done in October 2022 by [Trail of Bits](https://www.trailofbits.com/about), and most fixes were released in v4.2.0 – see [the announcement](./blog/20221108-simplex-chat-v4.2-security-audit-new-website.md).
|
|
||||||
|
|
||||||
### Information you provide
|
### Information you provide
|
||||||
|
|
||||||
#### User profiles
|
We do not store user profiles. The profile you create in the app is local to your device. When you create a user profile, no records are created on our servers, and we have no access to any part of your profile information, and even to the fact that you created a profile - it is a local record stored only on your device. That means that if you delete the app, and have no backup, you will permanently lose all the data and the private connections you create with other users.
|
||||||
|
|
||||||
We do not store user profiles. The profile you create in the app is local to your device.
|
Messages. SimpleX Chat cannot decrypt or otherwise access the content or even size of your messages (each message is padded to a fixed size of 16kb). SimpleX Chat temporarily stores end-to-end encrypted messages on its servers for delivery to the devices that are offline, these messages are permanently removed as soon as they are delivered. Your message history is stored only on your own devices.
|
||||||
|
|
||||||
When you create a user profile, no records are created on our servers, and we have no access to any part of your profile information, and even to the fact that you created a profile - it is a local record stored only on your device. That means that if you delete the app, and have no backup, you will permanently lose all the data and the private connections you create with other users.
|
Connections with other users. When you create a connection with another user, two messaging queues (you can think about them as about mailboxes) are created on our servers, or on the servers that you configured in the app, in case it allows such configuration (SimpleX uses separate queues for direct and response messages, that the client applications prefer to create on two different servers, in case you have more than one server configured in the app, which is the default). At the time of updating this document all our client applications allow configuring the servers. Our servers do not store information about which queues are linked to your profile on the device, and they do not collect any information that would allow us to establish that these queues are related to your device or your profile - the access to each queue is authorized by a set of anonymous unique cryptographic keys, different for each queue, and separate for sender and recipient of the messages. The exception to that is when you choose to use instant push notifications in our iOS app, because the design of push notifications requires storing the device token on notification server, and the server can observe how many messaging queues your device uses, and approximate how many messages are sent to each queue. It does not allow though to determine the actual addresses of these queues, as a separate address is used to subscibe to the notifications (unless notification and messaging servers exchange information), and who, or even how many contacts, send messages to you, as notifications are delivered to your device end-to-end encrypted by the messaging servers. It also does not allow to see message content or sizes, as the actual messages are not sent via the notification service, only the fact that the message is available and where it can be received from (the latter information is encrypted, so that the notification server cannot see it). You can read more about the design of iOS push notifications [here](https://simplex.chat/blog/20220404-simplex-chat-instant-notifications.html#our-ios-approach-has-one-trade-off).
|
||||||
|
|
||||||
#### Messages and Files
|
|
||||||
|
|
||||||
SimpleX Chat cannot decrypt or otherwise access the content or even the size of your messages and files you send or receive. Each message is padded to a fixed size of 16kb. Each file is sent in chunks of 256kb, 1mb or 8mb via all or some of the configured file servers. Both messages and files are sent end-to-end encrypted, and the servers do not have technical means to compromise this encryption, because part of the [key exchange](./docs/GLOSSARY.md#key-exchange) happens out-of-band.
|
|
||||||
|
|
||||||
Your message history is stored only on your own device and the devices of your contacts. While the recipients' devices are offline SimpleX Chat temporarily stores end-to-end encrypted messages on the messaging (SMP) servers that are preset in the app or chosen by the users.
|
|
||||||
|
|
||||||
The messages are permanently removed from the preset servers as soon as they are delivered. Undelivered messages are deleted after the time that is configured in the messaging servers you use (21 days for preset messaging servers).
|
|
||||||
|
|
||||||
The files are stored on file (XFTP) servers for the time configured in the file servers you use (48 hours for preset file servers).
|
|
||||||
|
|
||||||
If a messaging or file servers are restarted, the encrypted message or the record of the file can be stored in a backup file until it is overwritten by the next restart (usually within 1 week).
|
|
||||||
|
|
||||||
#### Connections with other users
|
|
||||||
|
|
||||||
When you create a connection with another user, two messaging queues (you can think about them as about mailboxes) are created on chosen messaging servers, that can be the preset servers or the servers that you configured in the app, in case it allows such configuration. SimpleX uses separate queues for direct and response messages, that the client applications prefer to create on two different servers, in case you have more than one server configured in the app, which is the default.
|
|
||||||
|
|
||||||
At the time of updating this document all our client applications allow configuring the servers. Our servers do not store information about which queues are linked to your profile on the device, and they do not collect any information that would allow us to establish that these queues are related to your device or your profile - the access to each queue is authorized by two anonymous unique cryptographic keys, different for each queue, and separate for sender and recipient of the messages.
|
|
||||||
|
|
||||||
#### iOS Push Notifications
|
|
||||||
|
|
||||||
When you choose to use instant push notifications in SimpleX iOS app, because the design of push notifications requires storing the device token on notification server, the notifications server can observe how many messaging queues your device has notifications enabled for, and approximately how many messages are sent to each queue.
|
|
||||||
|
|
||||||
Notification server cannot observe the actual addresses of these queues, as a separate address is used to subscribe to the notifications. It also cannot observe who, or even how many contacts, send messages to you, as notifications are delivered to your device end-to-end encrypted by the messaging servers.
|
|
||||||
|
|
||||||
It also does not allow to see message content or sizes, as the actual messages are not sent via the notification server, only the fact that the message is available and where it can be received from (the latter information is encrypted, so that the notification server cannot observe it). You can read more about the design of iOS push notifications [here](https://simplex.chat/blog/20220404-simplex-chat-instant-notifications.html#our-ios-approach-has-one-trade-off).
|
|
||||||
|
|
||||||
#### Another information stored on the servers
|
|
||||||
|
|
||||||
Additional technical information can be stored on our servers, including randomly generated authentication tokens, keys, push tokens, and other material that is necessary to transmit messages. SimpleX Chat limits this additional technical information to the minimum required to operate the Services.
|
Additional technical information can be stored on our servers, including randomly generated authentication tokens, keys, push tokens, and other material that is necessary to transmit messages. SimpleX Chat limits this additional technical information to the minimum required to operate the Services.
|
||||||
|
|
||||||
#### SimpleX Directory Service
|
User Support. If you contact SimpleX Chat any personal data you may share with us is kept only for the purposes of researching the issue and contacting you about your case. We recommend contacting support [via chat](https://simplex.chat/contact#/?v=1&smp=smp%3A%2F%2FPQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo%3D%40smp6.simplex.im%2FK1rslx-m5bpXVIdMZg9NLUZ_8JBm8xTt%23%2F%3Fv%3D1%26dh%3DMCowBQYDK2VuAyEALDeVe-sG8mRY22LsXlPgiwTNs9dbiLrNuA7f3ZMAJ2w%253D%26srv%3Dbylepyau3ty4czmn77q4fglvperknl4bi2eb2fdy2bh4jxtf32kf73yd.onion), when it is possible.
|
||||||
|
|
||||||
[SimpleX directory service](./docs/DIRECTORY.md) stores: your search requests, the messages and the members profiles in the group. You can connect to SimpleX Directory Service via [this address](https://simplex.chat/contact#/?v=1-4&smp=smp%3A%2F%2Fu2dS9sG8nMNURyZwqASV4yROM28Er0luVTx5X1CsMrU%3D%40smp4.simplex.im%2FeXSPwqTkKyDO3px4fLf1wx3MvPdjdLW3%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAaiv6MkMH44L2TcYrt_CsX3ZvM11WgbMEUn0hkIKTOho%253D%26srv%3Do5vmywmrnaxalvz6wi3zicyftgio6psuvyniis6gco6bp6ekl4cqj4id.onion).
|
|
||||||
|
|
||||||
#### User Support.
|
|
||||||
|
|
||||||
If you contact SimpleX Chat any personal data you may share with us is kept only for the purposes of researching the issue and contacting you about your case. We recommend contacting support [via chat](https://simplex.chat/contact#/?v=1&smp=smp%3A%2F%2FPQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo%3D%40smp6.simplex.im%2FK1rslx-m5bpXVIdMZg9NLUZ_8JBm8xTt%23%2F%3Fv%3D1%26dh%3DMCowBQYDK2VuAyEALDeVe-sG8mRY22LsXlPgiwTNs9dbiLrNuA7f3ZMAJ2w%253D%26srv%3Dbylepyau3ty4czmn77q4fglvperknl4bi2eb2fdy2bh4jxtf32kf73yd.onion), when it is possible.
|
|
||||||
|
|
||||||
### Information we may share
|
### Information we may share
|
||||||
|
|
||||||
We operate our Services using third parties. While we do not share any user data, these third party may access the encrypted user data as it is stored or transmitted via our servers.
|
We operate our Services using third parties. While we do not share any user data, these third party may access the encrypted user data as it is stored or transmitted via our servers.
|
||||||
|
|
||||||
We use a third party for email services - if you ask for support via email, your and SimpleX Chat email providers may access these emails according to their privacy policies and terms of service.
|
We use Third party to provide email services - if you ask for support via email, your and SimpleX Chat email providers may access these emails according their privacy policies and terms of service.
|
||||||
|
|
||||||
The cases when SimpleX Chat may need to share the data we temporarily store on the servers:
|
The cases when SimpleX Chat may need to share the data we temporarily store on the servers:
|
||||||
|
|
||||||
@@ -75,19 +39,19 @@ At the time of updating this document, we have never provided or have been reque
|
|||||||
|
|
||||||
### Updates
|
### Updates
|
||||||
|
|
||||||
We will update this Privacy Policy as needed so that it is current, accurate, and as clear as possible. Your continued use of our Services confirms your acceptance of our updated Privacy Policy.
|
We will update this privacy policy as needed so that it is current, accurate, and as clear as possible. Your continued use of our Services confirms your acceptance of our updated Privacy Policy.
|
||||||
|
|
||||||
Please also read our Terms of Service below.
|
Please also read our Terms of Service.
|
||||||
|
|
||||||
If you have questions about our Privacy Policy please contact us via [email](chat@simplex.chat) or [chat](https://simplex.chat/contact#/?v=1&smp=smp%3A%2F%2FPQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo%3D%40smp6.simplex.im%2FK1rslx-m5bpXVIdMZg9NLUZ_8JBm8xTt%23%2F%3Fv%3D1%26dh%3DMCowBQYDK2VuAyEALDeVe-sG8mRY22LsXlPgiwTNs9dbiLrNuA7f3ZMAJ2w%253D%26srv%3Dbylepyau3ty4czmn77q4fglvperknl4bi2eb2fdy2bh4jxtf32kf73yd.onion).
|
If you have questions about our Privacy Policy please contact us at chat@simplex.chat.
|
||||||
|
|
||||||
## Terms of Service
|
## Terms of Service
|
||||||
|
|
||||||
You accept our Terms of Service ("Terms") by installing or using any of our apps or services ("Services").
|
You accept to our Terms of Service ("Terms") by installing or using any of our apps or services ("Services").
|
||||||
|
|
||||||
**Minimal age**. You must be at least 13 years old to use our Services. The minimum age to use our Services without parental approval may be higher in your country.
|
**Minimal age**. You must be at least 13 years old to use our Services. The minimum age to use our Services without parental approval may be higher in your country.
|
||||||
|
|
||||||
**Accessing the servers**. For the efficiency of the network access, the apps access all queues you create on any server via the same network (TCP/IP) connection. Our servers do not collect information about which queues were accessed via the same connection, so we cannot establish which queues belong to the same users. Whoever might observe your network traffic would know which servers you use, and how much data you send, but not to whom it is sent - the data that leaves the servers is always different from the data they receive - there are no identifiers or ciphertext in common. Please refer to our [technical design document](https://github.com/simplex-chat/simplexmq/blob/master/protocol/overview-tjr.md) for more information about our privacy model and known security and privacy risks.
|
**Accessing the servers**. For the efficiency of the network access, the apps access all queues you create on any server via the same network (TCP/IP) connection. Our servers do not collect information about which queues were accessed via the same connection, so we do cannot establish which queues belong to the same users. Whoever might observe your network traffic would know which servers you use, and how much data you send, but not to whom it is sent - the data that leaves the servers is always different from the data they receive - there are no identifiers or cyphertext in common. Please refer to our [technical design document](https://github.com/simplex-chat/simplexmq/blob/master/protocol/overview-tjr.md) for more information about our privacy model and known security and privacy risks.
|
||||||
|
|
||||||
**Privacy of user data**. We do not retain any data we transmit for any longer than necessary to provide the Services. We only collect aggregate statistics across all users, not per user - we do not have information about how many people use SimpleX Chat (we only know an approximate number of app installations and the aggregate traffic through our servers). In any case, we do not and will not sell or in any way monetize user data.
|
**Privacy of user data**. We do not retain any data we transmit for any longer than necessary to provide the Services. We only collect aggregate statistics across all users, not per user - we do not have information about how many people use SimpleX Chat (we only know an approximate number of app installations and the aggregate traffic through our servers). In any case, we do not and will not sell or in any way monetize user data.
|
||||||
|
|
||||||
@@ -103,17 +67,15 @@ You accept our Terms of Service ("Terms") by installing or using any of our apps
|
|||||||
|
|
||||||
**Keeping your data secure**. SimpleX Chat is the first messaging platform that is 100% private by design - we neither have ability to access your messages, nor we have information about who you communicate with. That means that you are solely responsible for keeping your device and your user profile safe and secure. If you lose your phone or remove the app, you will not be able to recover the lost data, unless you made a back up.
|
**Keeping your data secure**. SimpleX Chat is the first messaging platform that is 100% private by design - we neither have ability to access your messages, nor we have information about who you communicate with. That means that you are solely responsible for keeping your device and your user profile safe and secure. If you lose your phone or remove the app, you will not be able to recover the lost data, unless you made a back up.
|
||||||
|
|
||||||
**Storing the messages on the device**. The messages are stored in the encrypted database on your device. Whether and how database passphrase is stored is determined by the configuration of the application you use. Legacy databases created prior to 2023 or in CLI (terminal) app may remain unencrypted, and it will be indicated in the app. In this case, if you make a backup of the app data and store it unencrypted, the backup provider may be able to access the messages. Please note, that the beta version of desktop app currently stores the database passphrase in the configuration file in plaintext, so you may need to remove passphrase from the device via the app configuration.
|
**Storing the messages on the device**. Currently the messages are stored in the database on your device without encryption. It means that if you make a backup of the app and store it unecrypted, the backup provider may be able to access the messages.
|
||||||
|
|
||||||
**Storing the files on the device**. The files are stored on your device unencrypted. If you make a backup of the app data and store it unencrypted, the backup provider will be able to access the files.
|
|
||||||
|
|
||||||
**No Access to Emergency Services**. Our Services do not provide access to emergency service providers like the police, fire department, hospitals, or other public safety organizations. Make sure you can contact emergency service providers through a mobile, fixed-line telephone, or other service.
|
**No Access to Emergency Services**. Our Services do not provide access to emergency service providers like the police, fire department, hospitals, or other public safety organizations. Make sure you can contact emergency service providers through a mobile, fixed-line telephone, or other service.
|
||||||
|
|
||||||
**Third-party services**. Our Services may allow you to access, use, or interact with third-party websites, apps, content, and other products and services. When you use third-party services, their terms and privacy policies govern your use of those services.
|
**Third-party services**. Our Services may allow you to access, use, or interact with third-party websites, apps, content, and other products and services. When you use third-party services, their terms and privacy policies govern your use of those services.
|
||||||
|
|
||||||
**Your Rights**. You own the messages and the information you transmit through our Services. Your recipients are able to retain the messages you receive from you; there is no technical ability to delete data from their devices. While there are various app features that allow deleting messages from the recipients' devices, such as _disappearing messages_ and _full message deletion_, their functioning on your recipients' devices cannot be guaranteed or enforced, as the device may be offline or have a modified version of the app.
|
**Your Rights**. You own the mesasges and information you transmit through our Services. Your recipients are able to retain the messages you receive from you; there is no technical ability to delete data from their devices.
|
||||||
|
|
||||||
**License**. SimpleX Chat grants you a limited, revocable, non-exclusive, and non-transferable license to use our Services in accordance with these Terms. The source-code of services is available and can be used under [AGPL v3 license](https://github.com/simplex-chat/simplex-chat/blob/stable/LICENSE)
|
**License**. SimpleX Chat grants you a limited, revocable, non-exclusive, and non-transferable license to use our Services in accordance with these Terms. The source-code of services is available and can be used under [AGPL v3 licence](https://github.com/simplex-chat/simplex-chat/blob/stable/LICENSE)
|
||||||
|
|
||||||
**SimpleX Chat Rights**. We own all copyrights, trademarks, domains, logos, trade secrets, and other intellectual property rights associated with our Services. You may not use our copyrights, trademarks, domains, logos, and other intellectual property rights unless you have our written permission, and unless under an open-source license distributed together with the source code. To report copyright, trademark, or other intellectual property infringement, please contact chat@simplex.chat.
|
**SimpleX Chat Rights**. We own all copyrights, trademarks, domains, logos, trade secrets, and other intellectual property rights associated with our Services. You may not use our copyrights, trademarks, domains, logos, and other intellectual property rights unless you have our written permission, and unless under an open-source license distributed together with the source code. To report copyright, trademark, or other intellectual property infringement, please contact chat@simplex.chat.
|
||||||
|
|
||||||
@@ -131,4 +93,4 @@ You accept our Terms of Service ("Terms") by installing or using any of our apps
|
|||||||
|
|
||||||
**Ending these Terms**. You may end these Terms with SimpleX Chat at any time by deleting SimpleX Chat app(s) from your device and discontinuing use of our Services. The provisions related to Licenses, Disclaimers, Limitation of Liability, Resolving dispute, Availability, Changes to the terms, Enforcing the terms, and Ending these Terms will survive termination of your relationship with SimpleX Chat.
|
**Ending these Terms**. You may end these Terms with SimpleX Chat at any time by deleting SimpleX Chat app(s) from your device and discontinuing use of our Services. The provisions related to Licenses, Disclaimers, Limitation of Liability, Resolving dispute, Availability, Changes to the terms, Enforcing the terms, and Ending these Terms will survive termination of your relationship with SimpleX Chat.
|
||||||
|
|
||||||
Updated August 17, 2023
|
Updated November 8, 2022
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
[](https://github.com/simplex-chat/simplex-chat/releases)
|
[](https://github.com/simplex-chat/simplex-chat/releases)
|
||||||
[](https://github.com/simplex-chat/simplex-chat/releases)
|
[](https://github.com/simplex-chat/simplex-chat/releases)
|
||||||
[](https://www.reddit.com/r/SimpleXChat)
|
[](https://www.reddit.com/r/SimpleXChat)
|
||||||
<a rel="me" href="https://mastodon.social/@simplex"></a>
|
[](https://mastodon.social/@simplex)
|
||||||
|
|
||||||
| 30/03/2023 | EN, [FR](/docs/lang/fr/README.md), [CZ](/docs/lang/cs/README.md) |
|
| 30/03/2023 | EN, [FR](/docs/lang/fr/README.md), [CZ](/docs/lang/cs/README.md) |
|
||||||
|
|
||||||
@@ -15,7 +15,7 @@
|
|||||||
## Welcome to SimpleX Chat!
|
## Welcome to SimpleX Chat!
|
||||||
|
|
||||||
1. 📲 [Install the app](#install-the-app).
|
1. 📲 [Install the app](#install-the-app).
|
||||||
2. ↔️ [Connect to the team](#connect-to-the-team), [join user groups](#join-user-groups) and [follow our updates](#follow-our-updates).
|
2. ↔️ [Connect to the team](#connect-to-the-team-via-the-app) and [join user groups](#join-user-groups).
|
||||||
3. 🤝 [Make a private connection](#make-a-private-connection) with a friend.
|
3. 🤝 [Make a private connection](#make-a-private-connection) with a friend.
|
||||||
4. 🔤 [Help translating SimpleX Chat](#help-translating-simplex-chat).
|
4. 🔤 [Help translating SimpleX Chat](#help-translating-simplex-chat).
|
||||||
5. ⚡️ [Contribute](#contribute) and [help us with donations](#help-us-with-donations).
|
5. ⚡️ [Contribute](#contribute) and [help us with donations](#help-us-with-donations).
|
||||||
@@ -40,29 +40,21 @@
|
|||||||
- 🚀 [TestFlight preview for iOS](https://testflight.apple.com/join/DWuT2LQu) with the new features 1-2 weeks earlier - **limited to 10,000 users**!
|
- 🚀 [TestFlight preview for iOS](https://testflight.apple.com/join/DWuT2LQu) with the new features 1-2 weeks earlier - **limited to 10,000 users**!
|
||||||
- 🖥 Available as a terminal (console) [app / CLI](#zap-quick-installation-of-a-terminal-app) on Linux, MacOS, Windows.
|
- 🖥 Available as a terminal (console) [app / CLI](#zap-quick-installation-of-a-terminal-app) on Linux, MacOS, Windows.
|
||||||
|
|
||||||
## Connect to the team
|
## Connect to the team via the app
|
||||||
|
|
||||||
You can connect to the team via the app using "chat with the developers button" available when you have no conversations in the profile, "Send questions and ideas" in the app settings or via our [SimpleX address](https://simplex.chat/contact#/?v=1&smp=smp%3A%2F%2FPQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo%3D%40smp6.simplex.im%2FK1rslx-m5bpXVIdMZg9NLUZ_8JBm8xTt%23%2F%3Fv%3D1%26dh%3DMCowBQYDK2VuAyEALDeVe-sG8mRY22LsXlPgiwTNs9dbiLrNuA7f3ZMAJ2w%253D%26srv%3Dbylepyau3ty4czmn77q4fglvperknl4bi2eb2fdy2bh4jxtf32kf73yd.onion). Please connect to:
|
|
||||||
|
|
||||||
- to ask any questions
|
- to ask any questions
|
||||||
- to suggest any improvements
|
- to suggest any improvements
|
||||||
- to share anything relevant
|
- to share anything relevant
|
||||||
|
|
||||||
We are replying the questions manually, so it is not instant – it can take up to 24 hours.
|
|
||||||
|
|
||||||
If you are interested in helping us to integrate open-source language models, and in [joining our team](./docs/JOIN_TEAM.md), please get in touch.
|
|
||||||
|
|
||||||
## Join user groups
|
## Join user groups
|
||||||
|
|
||||||
You can join the groups created by other users via the new [directory service](https://simplex.chat/contact#/?v=1-4&smp=smp%3A%2F%2Fu2dS9sG8nMNURyZwqASV4yROM28Er0luVTx5X1CsMrU%3D%40smp4.simplex.im%2FeXSPwqTkKyDO3px4fLf1wx3MvPdjdLW3%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAaiv6MkMH44L2TcYrt_CsX3ZvM11WgbMEUn0hkIKTOho%253D%26srv%3Do5vmywmrnaxalvz6wi3zicyftgio6psuvyniis6gco6bp6ekl4cqj4id.onion). We are not responsible for the content shared in these groups.
|
|
||||||
|
|
||||||
**Please note**: The groups below are created for the users to be able to ask questions, make suggestions and ask questions about SimpleX Chat only.
|
**Please note**: The groups below are created for the users to be able to ask questions, make suggestions and ask questions about SimpleX Chat only.
|
||||||
|
|
||||||
You also can:
|
You also can:
|
||||||
- criticize the app, and make comparisons with other messengers.
|
- criticize the app, and make comparisons with other messengers.
|
||||||
- share new messengers you think could be interesting for privacy, as long as you don't spam.
|
- share new messengers you think could be interesting for privacy, as long as you don't spam.
|
||||||
- share some privacy related publications, infrequently.
|
- share some privacy related publications, infrequently.
|
||||||
- having preliminary approved with the admin in direct message, share the link to a group you created, but only once. Once the group has more than 10 members it can be submitted to [SimpleX Directory Service](./docs/DIRECTORY.md) where the new users will be able to discover it.
|
- having preliminary approved with the admin in direct message, share the link to a group you created.
|
||||||
|
|
||||||
You must:
|
You must:
|
||||||
- be polite to other users
|
- be polite to other users
|
||||||
@@ -72,30 +64,14 @@ You must:
|
|||||||
|
|
||||||
Messages not following these rules will be deleted, the right to send messages may be revoked, and the access to the new members to the group may be temporarily restricted, to prevent re-joining under a different name - our imperfect group moderation does not have a better solution at the moment.
|
Messages not following these rules will be deleted, the right to send messages may be revoked, and the access to the new members to the group may be temporarily restricted, to prevent re-joining under a different name - our imperfect group moderation does not have a better solution at the moment.
|
||||||
|
|
||||||
You can join an English-speaking users group if you want to ask any questions: [#SimpleX-Group-4](https://simplex.chat/contact#/?v=1-2&smp=smp%3A%2F%2Fu2dS9sG8nMNURyZwqASV4yROM28Er0luVTx5X1CsMrU%3D%40smp4.simplex.im%2Fw2GlucRXtRVgYnbt_9ZP-kmt76DekxxS%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEA0tJhTyMGUxznwmjb7aT24P1I1Wry_iURTuhOFlMb1Eo%253D%26srv%3Do5vmywmrnaxalvz6wi3zicyftgio6psuvyniis6gco6bp6ekl4cqj4id.onion&data=%7B%22type%22%3A%22group%22%2C%22groupLinkId%22%3A%22WoPxjFqGEDlVazECOSi2dg%3D%3D%22%7D)
|
You can join an English-speaking users group if you want to ask any questions: [#SimpleX-Group-3](https://simplex.chat/contact#/?v=1-2&smp=smp%3A%2F%2FPQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo%3D%40smp6.simplex.im%2Fp-j-D_PrY2UMDchFHEUtbSES0nmzCnvD%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEA3gBfMjB_GDEmKQwjNdqGbnX91yfuZ7nRJgQijsx5Khc%253D%26srv%3Dbylepyau3ty4czmn77q4fglvperknl4bi2eb2fdy2bh4jxtf32kf73yd.onion&data=%7B%22type%22%3A%22group%22%2C%22groupLinkId%22%3A%2262MvNZ_Ec2mmlS8V0QNtLQ%3D%3D%22%7D)
|
||||||
|
|
||||||
There is also a group [#simplex-devs](https://simplex.chat/contact#/?v=1-2&smp=smp%3A%2F%2Fu2dS9sG8nMNURyZwqASV4yROM28Er0luVTx5X1CsMrU%3D%40smp4.simplex.im%2F6eHqy7uAbZPOcA6qBtrQgQquVlt4Ll91%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAqV_pg3FF00L98aCXp4D3bOs4Sxv_UmSd-gb0juVoQVs%253D%26srv%3Do5vmywmrnaxalvz6wi3zicyftgio6psuvyniis6gco6bp6ekl4cqj4id.onion&data=%7B%22type%22%3A%22group%22%2C%22groupLinkId%22%3A%22XonlixcHBIb2ijCehbZoiw%3D%3D%22%7D) for developers who build on SimpleX platform:
|
|
||||||
|
|
||||||
- chat bots and automations
|
|
||||||
- integrations with other apps
|
|
||||||
- social apps and services
|
|
||||||
- etc.
|
|
||||||
|
|
||||||
There are groups in other languages, that we have the apps interface translated into. These groups are for testing, and asking questions to other SimpleX Chat users:
|
There are groups in other languages, that we have the apps interface translated into. These groups are for testing, and asking questions to other SimpleX Chat users:
|
||||||
|
|
||||||
[\#SimpleX-DE](https://simplex.chat/contact#/?v=1-2&smp=smp%3A%2F%2FPQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo%3D%40smp6.simplex.im%2FkIEl7OQzcp-J6aDmjdlQbRJwqkcZE7XR%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAR16PCu02MobRmKAsjzhDWMZcWP9hS8l5AUZi-Gs8z18%253D%26srv%3Dbylepyau3ty4czmn77q4fglvperknl4bi2eb2fdy2bh4jxtf32kf73yd.onion&data=%7B%22type%22%3A%22group%22%2C%22groupLinkId%22%3A%22puYPMCQt11yPUvgmI5jCiw%3D%3D%22%7D) (German-speaking), [\#SimpleX-ES](https://simplex.chat/contact#/?v=1-2&smp=smp%3A%2F%2FPQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo%3D%40smp6.simplex.im%2FaJ8O1O8A8GbeoaHTo_V8dcefaCl7ouPb%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEA034qWTA3sWcTsi6aWhNf9BA34vKVCFaEBdP2R66z6Ao%253D%26srv%3Dbylepyau3ty4czmn77q4fglvperknl4bi2eb2fdy2bh4jxtf32kf73yd.onion&data=%7B%22type%22%3A%22group%22%2C%22groupLinkId%22%3A%22wiZ1v_wNjLPlT-nCSB-bRA%3D%3D%22%7D) (Spanish-speaking), [\#SimpleX-FR](https://simplex.chat/contact#/?v=1-2&smp=smp%3A%2F%2Fhpq7_4gGJiilmz5Rf-CswuU5kZGkm_zOIooSw6yALRg%3D%40smp5.simplex.im%2FvIHQDxTor53nwnWWTy5cHNwQQAdWN5Hw%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAPdgK1eBnETmgiqEQufbUkydKBJafoRx4iRrtrC2NAGc%253D%26srv%3Djjbyvoemxysm7qxap7m5d5m35jzv5qq6gnlv7s4rsn7tdwwmuqciwpid.onion&data=%7B%22type%22%3A%22group%22%2C%22groupLinkId%22%3A%221FyUryBPza-1ZFFE80Ekbg%3D%3D%22%7D) (French-speaking), [\#SimpleX-RU](https://simplex.chat/contact#/?v=1-2&smp=smp%3A%2F%2FPQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo%3D%40smp6.simplex.im%2FXZyt3hJmWsycpN7Dqve_wbrAqb6myk1R%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAMFVIoytozTEa_QXOgoZFq_oe0IwZBYKvW50trSFXzXo%253D%26srv%3Dbylepyau3ty4czmn77q4fglvperknl4bi2eb2fdy2bh4jxtf32kf73yd.onion&data=%7B%22type%22%3A%22group%22%2C%22groupLinkId%22%3A%22xz05ngjA3pNIxLZ32a8Vxg%3D%3D%22%7D) (Russian-speaking), [\#SimpleX-IT](https://simplex.chat/contact#/?v=1-2&smp=smp%3A%2F%2Fu2dS9sG8nMNURyZwqASV4yROM28Er0luVTx5X1CsMrU%3D%40smp4.simplex.im%2F0weR-ZgDUl7ruOtI_8TZwEsnJP6UiImA%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAq4PSThO9Fvb5ydF48wB0yNbpzCbuQJCW3vZ9BGUfcxk%253D%26srv%3Do5vmywmrnaxalvz6wi3zicyftgio6psuvyniis6gco6bp6ekl4cqj4id.onion&data=%7B%22type%22%3A%22group%22%2C%22groupLinkId%22%3A%22e-iceLA0SctC62eARgYDWg%3D%3D%22%7D) (Italian-speaking).
|
[\#SimpleX-DE](https://simplex.chat/contact#/?v=1-2&smp=smp%3A%2F%2FPQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo%3D%40smp6.simplex.im%2FkIEl7OQzcp-J6aDmjdlQbRJwqkcZE7XR%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAR16PCu02MobRmKAsjzhDWMZcWP9hS8l5AUZi-Gs8z18%253D%26srv%3Dbylepyau3ty4czmn77q4fglvperknl4bi2eb2fdy2bh4jxtf32kf73yd.onion&data=%7B%22type%22%3A%22group%22%2C%22groupLinkId%22%3A%22puYPMCQt11yPUvgmI5jCiw%3D%3D%22%7D) (German-speaking), [\#SimpleX-FR](https://simplex.chat/contact#/?v=1-2&smp=smp%3A%2F%2Fhpq7_4gGJiilmz5Rf-CswuU5kZGkm_zOIooSw6yALRg%3D%40smp5.simplex.im%2FvIHQDxTor53nwnWWTy5cHNwQQAdWN5Hw%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAPdgK1eBnETmgiqEQufbUkydKBJafoRx4iRrtrC2NAGc%253D%26srv%3Djjbyvoemxysm7qxap7m5d5m35jzv5qq6gnlv7s4rsn7tdwwmuqciwpid.onion&data=%7B%22type%22%3A%22group%22%2C%22groupLinkId%22%3A%221FyUryBPza-1ZFFE80Ekbg%3D%3D%22%7D) (French-speaking), [\#SimpleX-RU](https://simplex.chat/contact#/?v=1-2&smp=smp%3A%2F%2FPQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo%3D%40smp6.simplex.im%2FXZyt3hJmWsycpN7Dqve_wbrAqb6myk1R%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAMFVIoytozTEa_QXOgoZFq_oe0IwZBYKvW50trSFXzXo%253D%26srv%3Dbylepyau3ty4czmn77q4fglvperknl4bi2eb2fdy2bh4jxtf32kf73yd.onion&data=%7B%22type%22%3A%22group%22%2C%22groupLinkId%22%3A%22xz05ngjA3pNIxLZ32a8Vxg%3D%3D%22%7D) (Russian-speaking), [\#SimpleX-IT](https://simplex.chat/contact#/?v=1-2&smp=smp%3A%2F%2Fu2dS9sG8nMNURyZwqASV4yROM28Er0luVTx5X1CsMrU%3D%40smp4.simplex.im%2F0weR-ZgDUl7ruOtI_8TZwEsnJP6UiImA%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAq4PSThO9Fvb5ydF48wB0yNbpzCbuQJCW3vZ9BGUfcxk%253D%26srv%3Do5vmywmrnaxalvz6wi3zicyftgio6psuvyniis6gco6bp6ekl4cqj4id.onion&data=%7B%22type%22%3A%22group%22%2C%22groupLinkId%22%3A%22e-iceLA0SctC62eARgYDWg%3D%3D%22%7D) (Italian-speaking).
|
||||||
|
|
||||||
You can join either by opening these links in the app or by opening them in a desktop browser and scanning the QR code.
|
You can join either by opening these links in the app or by opening them in a desktop browser and scanning the QR code.
|
||||||
|
|
||||||
## Follow our updates
|
|
||||||
|
|
||||||
We publish our updates and releases via:
|
|
||||||
|
|
||||||
- [Reddit](https://www.reddit.com/r/SimpleXChat/), [Twitter](https://twitter.com/SimpleXChat), [Lemmy](https://lemmy.ml/c/simplex), [Mastodon](https://mastodon.social/@simplex) and [Nostr](https://snort.social/p/npub1exv22uulqnmlluszc4yk92jhs2e5ajcs6mu3t00a6avzjcalj9csm7d828).
|
|
||||||
- SimpleX Chat [team profile](#connect-to-the-team).
|
|
||||||
- [blog](https://simplex.chat/blog/) and [RSS feed](https://simplex.chat/feed.rss).
|
|
||||||
- [mailing list](https://simplex.chat/#join-simplex), very rarely.
|
|
||||||
|
|
||||||
## Make a private connection
|
## Make a private connection
|
||||||
|
|
||||||
You need to share a link with your friend or scan a QR code from their phone, in person or during a video call, to make a connection and start messaging.
|
You need to share a link with your friend or scan a QR code from their phone, in person or during a video call, to make a connection and start messaging.
|
||||||
@@ -119,22 +95,15 @@ Join our translators to help SimpleX grow!
|
|||||||
|locale|language |contributor|[Android](https://play.google.com/store/apps/details?id=chat.simplex.app) and [iOS](https://apps.apple.com/us/app/simplex-chat/id1605771084)|[website](https://simplex.chat)|Github docs|
|
|locale|language |contributor|[Android](https://play.google.com/store/apps/details?id=chat.simplex.app) and [iOS](https://apps.apple.com/us/app/simplex-chat/id1605771084)|[website](https://simplex.chat)|Github docs|
|
||||||
|:----:|:-------:|:---------:|:---------:|:---------:|:---------:|
|
|:----:|:-------:|:---------:|:---------:|:---------:|:---------:|
|
||||||
|🇬🇧 en|English | |✓|✓|✓|✓|
|
|🇬🇧 en|English | |✓|✓|✓|✓|
|
||||||
|ar|العربية |[jermanuts](https://github.com/jermanuts)|[](https://hosted.weblate.org/projects/simplex-chat/android/ar/)<br>-|[](https://hosted.weblate.org/projects/simplex-chat/website/ar/)||
|
|ar|العربية |[jermanuts](https://github.com/jermanuts)||[](https://hosted.weblate.org/projects/simplex-chat/website/ar/)||
|
||||||
|🇧🇬 bg|Български | |[](https://hosted.weblate.org/projects/simplex-chat/android/bg/)<br>[](https://hosted.weblate.org/projects/simplex-chat/ios/bg/)|||
|
|
||||||
|🇨🇿 cs|Čeština |[zen0bit](https://github.com/zen0bit)|[](https://hosted.weblate.org/projects/simplex-chat/android/cs/)<br>[](https://hosted.weblate.org/projects/simplex-chat/ios/cs/)|[](https://hosted.weblate.org/projects/simplex-chat/website/cs/)|[✓](https://github.com/simplex-chat/simplex-chat/tree/master/docs/lang/cs)|
|
|🇨🇿 cs|Čeština |[zen0bit](https://github.com/zen0bit)|[](https://hosted.weblate.org/projects/simplex-chat/android/cs/)<br>[](https://hosted.weblate.org/projects/simplex-chat/ios/cs/)|[](https://hosted.weblate.org/projects/simplex-chat/website/cs/)|[✓](https://github.com/simplex-chat/simplex-chat/tree/master/docs/lang/cs)|
|
||||||
|🇩🇪 de|Deutsch |[mlanp](https://github.com/mlanp)|[](https://hosted.weblate.org/projects/simplex-chat/android/de/)<br>[](https://hosted.weblate.org/projects/simplex-chat/ios/de/)|[](https://hosted.weblate.org/projects/simplex-chat/website/de/)||
|
|🇩🇪 de|Deutsch |[mlanp](https://github.com/mlanp)|[](https://hosted.weblate.org/projects/simplex-chat/android/de/)<br>[](https://hosted.weblate.org/projects/simplex-chat/ios/de/)|[](https://hosted.weblate.org/projects/simplex-chat/website/de/)||
|
||||||
|🇪🇸 es|Español |[Mateyhv](https://github.com/Mateyhv)|[](https://hosted.weblate.org/projects/simplex-chat/android/es/)<br>[](https://hosted.weblate.org/projects/simplex-chat/ios/es/)|[](https://hosted.weblate.org/projects/simplex-chat/website/es/)||
|
|🇪🇸 es|Español |[Mateyhv](https://github.com/Mateyhv)|[](https://hosted.weblate.org/projects/simplex-chat/android/es/)<br>[](https://hosted.weblate.org/projects/simplex-chat/ios/es/)|[](https://hosted.weblate.org/projects/simplex-chat/website/es/)||
|
||||||
|🇫🇮 fi|Suomi | |[](https://hosted.weblate.org/projects/simplex-chat/android/fi/)<br>[](https://hosted.weblate.org/projects/simplex-chat/ios/fi/)|[](https://hosted.weblate.org/projects/simplex-chat/website/fi/)||
|
|
||||||
|🇫🇷 fr|Français |[ishi_sama](https://github.com/ishi-sama)|[](https://hosted.weblate.org/projects/simplex-chat/android/fr/)<br>[](https://hosted.weblate.org/projects/simplex-chat/ios/fr/)|[](https://hosted.weblate.org/projects/simplex-chat/website/fr/)|[✓](https://github.com/simplex-chat/simplex-chat/tree/master/docs/lang/fr)|
|
|🇫🇷 fr|Français |[ishi_sama](https://github.com/ishi-sama)|[](https://hosted.weblate.org/projects/simplex-chat/android/fr/)<br>[](https://hosted.weblate.org/projects/simplex-chat/ios/fr/)|[](https://hosted.weblate.org/projects/simplex-chat/website/fr/)|[✓](https://github.com/simplex-chat/simplex-chat/tree/master/docs/lang/fr)|
|
||||||
|🇮🇱 he|עִברִית | |[](https://hosted.weblate.org/projects/simplex-chat/android/he/)<br>-|||
|
|
||||||
|🇮🇹 it|Italiano |[unbranched](https://github.com/unbranched)|[](https://hosted.weblate.org/projects/simplex-chat/android/it/)<br>[](https://hosted.weblate.org/projects/simplex-chat/ios/it/)|[](https://hosted.weblate.org/projects/simplex-chat/website/it/)||
|
|🇮🇹 it|Italiano |[unbranched](https://github.com/unbranched)|[](https://hosted.weblate.org/projects/simplex-chat/android/it/)<br>[](https://hosted.weblate.org/projects/simplex-chat/ios/it/)|[](https://hosted.weblate.org/projects/simplex-chat/website/it/)||
|
||||||
|🇯🇵 ja|日本語 | |[](https://hosted.weblate.org/projects/simplex-chat/android/ja/)<br>[](https://hosted.weblate.org/projects/simplex-chat/ios/ja/)|[](https://hosted.weblate.org/projects/simplex-chat/website/ja/)||
|
|
||||||
|🇳🇱 nl|Nederlands|[mika-nl](https://github.com/mika-nl)|[](https://hosted.weblate.org/projects/simplex-chat/android/nl/)<br>[](https://hosted.weblate.org/projects/simplex-chat/ios/nl/)|[](https://hosted.weblate.org/projects/simplex-chat/website/nl/)||
|
|🇳🇱 nl|Nederlands|[mika-nl](https://github.com/mika-nl)|[](https://hosted.weblate.org/projects/simplex-chat/android/nl/)<br>[](https://hosted.weblate.org/projects/simplex-chat/ios/nl/)|[](https://hosted.weblate.org/projects/simplex-chat/website/nl/)||
|
||||||
|🇵🇱 pl|Polski |[BxOxSxS](https://github.com/BxOxSxS)|[](https://hosted.weblate.org/projects/simplex-chat/android/pl/)<br>[](https://hosted.weblate.org/projects/simplex-chat/ios/pl/)|||
|
|🇵🇱 pl|Polski |[BxOxSxS](https://github.com/BxOxSxS)|[](https://hosted.weblate.org/projects/simplex-chat/android/ru/)<br>[](https://hosted.weblate.org/projects/simplex-chat/ios/pl/)|||
|
||||||
|🇧🇷 pt-BR|Português||[](https://hosted.weblate.org/projects/simplex-chat/android/pt_BR/)<br>-|[](https://hosted.weblate.org/projects/simplex-chat/website/pt_BR/)||
|
|
||||||
|🇷🇺 ru|Русский ||[](https://hosted.weblate.org/projects/simplex-chat/android/ru/)<br>[](https://hosted.weblate.org/projects/simplex-chat/ios/ru/)|||
|
|🇷🇺 ru|Русский ||[](https://hosted.weblate.org/projects/simplex-chat/android/ru/)<br>[](https://hosted.weblate.org/projects/simplex-chat/ios/ru/)|||
|
||||||
|🇹🇭 th|ภาษาไทย |[titapa-punpun](https://github.com/titapa-punpun)|[](https://hosted.weblate.org/projects/simplex-chat/android/th/)<br>[](https://hosted.weblate.org/projects/simplex-chat/ios/th/)|||
|
|
||||||
|🇺🇦 uk|Українська| |[](https://hosted.weblate.org/projects/simplex-chat/android/uk/)<br>[](https://hosted.weblate.org/projects/simplex-chat/ios/uk/)|[](https://hosted.weblate.org/projects/simplex-chat/website/uk/)||
|
|
||||||
|🇨🇳 zh-CHS|简体中文|[sith-on-mars](https://github.com/sith-on-mars)<br><br>[Float-hu](https://github.com/Float-hu)|[](https://hosted.weblate.org/projects/simplex-chat/android/zh_Hans/)<br>[](https://hosted.weblate.org/projects/simplex-chat/ios/zh_Hans/)<br> |<br><br>[](https://hosted.weblate.org/projects/simplex-chat/website/zh_Hans/)||
|
|🇨🇳 zh-CHS|简体中文|[sith-on-mars](https://github.com/sith-on-mars)<br><br>[Float-hu](https://github.com/Float-hu)|[](https://hosted.weblate.org/projects/simplex-chat/android/zh_Hans/)<br>[](https://hosted.weblate.org/projects/simplex-chat/ios/zh_Hans/)<br> |<br><br>[](https://hosted.weblate.org/projects/simplex-chat/website/zh_Hans/)||
|
||||||
|
|
||||||
Languages in progress: Arabic, Japanese, Korean, Portuguese and [others](https://hosted.weblate.org/projects/simplex-chat/#languages). We will be adding more languages as some of the already added are completed – please suggest new languages, review the [translation guide](./docs/TRANSLATIONS.md) and get in touch with us!
|
Languages in progress: Arabic, Japanese, Korean, Portuguese and [others](https://hosted.weblate.org/projects/simplex-chat/#languages). We will be adding more languages as some of the already added are completed – please suggest new languages, review the [translation guide](./docs/TRANSLATIONS.md) and get in touch with us!
|
||||||
@@ -143,7 +112,6 @@ Languages in progress: Arabic, Japanese, Korean, Portuguese and [others](https:/
|
|||||||
|
|
||||||
We would love to have you join the development! You can help us with:
|
We would love to have you join the development! You can help us with:
|
||||||
|
|
||||||
- [share the color theme](./docs/THEMES.md) you use in Android app!
|
|
||||||
- writing a tutorial or recipes about hosting servers, chat bot automations, etc.
|
- writing a tutorial or recipes about hosting servers, chat bot automations, etc.
|
||||||
- contributing to SimpleX Chat knowledge-base.
|
- contributing to SimpleX Chat knowledge-base.
|
||||||
- developing features - please connect to us via chat so we can help you get started.
|
- developing features - please connect to us via chat so we can help you get started.
|
||||||
@@ -162,14 +130,11 @@ It is possible to donate via:
|
|||||||
|
|
||||||
- [GitHub](https://github.com/sponsors/simplex-chat) - it is commission-free for us.
|
- [GitHub](https://github.com/sponsors/simplex-chat) - it is commission-free for us.
|
||||||
- [OpenCollective](https://opencollective.com/simplex-chat) - it charges a commission, and also accepts donations in crypto-currencies.
|
- [OpenCollective](https://opencollective.com/simplex-chat) - it charges a commission, and also accepts donations in crypto-currencies.
|
||||||
- Monero: 8568eeVjaJ1RQ65ZUn9PRQ8ENtqeX9VVhcCYYhnVLxhV4JtBqw42so2VEUDQZNkFfsH5sXCuV7FN8VhRQ21DkNibTZP57Qt
|
- Monero address: 8568eeVjaJ1RQ65ZUn9PRQ8ENtqeX9VVhcCYYhnVLxhV4JtBqw42so2VEUDQZNkFfsH5sXCuV7FN8VhRQ21DkNibTZP57Qt
|
||||||
- Bitcoin: 1bpefFkzuRoMY3ZuBbZNZxycbg7NYPYTG
|
- Bitcoin address: 1bpefFkzuRoMY3ZuBbZNZxycbg7NYPYTG
|
||||||
- BCH: 1bpefFkzuRoMY3ZuBbZNZxycbg7NYPYTG
|
- BCH address: 1bpefFkzuRoMY3ZuBbZNZxycbg7NYPYTG
|
||||||
- USDT:
|
- Ethereum address: 0x83fd788f7241a2be61780ea9dc72d2151e6843e2
|
||||||
- BNB Smart Chain: 0x83fd788f7241a2be61780ea9dc72d2151e6843e2
|
- Solana address: 43tWFWDczgAcn4Rzwkpqg2mqwnQETSiTwznmCgA2tf1L
|
||||||
- Tron: TNnTrKLBmdy2Wn3cAQR98dAVvWhLskQGfW
|
|
||||||
- Ethereum: 0x83fd788f7241a2be61780ea9dc72d2151e6843e2
|
|
||||||
- Solana: 43tWFWDczgAcn4Rzwkpqg2mqwnQETSiTwznmCgA2tf1L
|
|
||||||
|
|
||||||
Thank you,
|
Thank you,
|
||||||
|
|
||||||
@@ -189,7 +154,7 @@ SimpleX Chat founder
|
|||||||
- [News and updates](#news-and-updates)
|
- [News and updates](#news-and-updates)
|
||||||
- [Quick installation of a terminal app](#zap-quick-installation-of-a-terminal-app)
|
- [Quick installation of a terminal app](#zap-quick-installation-of-a-terminal-app)
|
||||||
- [SimpleX Platform design](#simplex-platform-design)
|
- [SimpleX Platform design](#simplex-platform-design)
|
||||||
- [Privacy and security: technical details and limitations](#privacy-and-security-technical-details-and-limitations)
|
- [Privacy: technical details and limitations](#privacy-technical-details-and-limitations)
|
||||||
- [For developers](#for-developers)
|
- [For developers](#for-developers)
|
||||||
- [Roadmap](#roadmap)
|
- [Roadmap](#roadmap)
|
||||||
- [Disclaimers, Security contact, License](#disclaimers)
|
- [Disclaimers, Security contact, License](#disclaimers)
|
||||||
@@ -230,20 +195,20 @@ You can use SimpleX with your own servers and still communicate with people usin
|
|||||||
|
|
||||||
## News and updates
|
## News and updates
|
||||||
|
|
||||||
Recent and important updates:
|
Recent updates:
|
||||||
|
|
||||||
[Nov 25, 2023. SimpleX Chat v5.4 released: link mobile and desktop apps via quantum resistant protocol, and much better groups](./blog/20231125-simplex-chat-v5-4-link-mobile-desktop-quantum-resistant-better-groups.md).
|
|
||||||
|
|
||||||
[Sep 25, 2023. SimpleX Chat v5.3 released: desktop app, local file encryption, improved groups and directory service](./blog/20230925-simplex-chat-v5-3-desktop-app-local-file-encryption-directory-service.md).
|
|
||||||
|
|
||||||
[Jul 22, 2023. SimpleX Chat: v5.2 released with message delivery receipts](./blog/20230722-simplex-chat-v5-2-message-delivery-receipts.md).
|
|
||||||
|
|
||||||
[May 23, 2023. SimpleX Chat: v5.1 released with message reactions and self-destruct passcode](./blog/20230523-simplex-chat-v5-1-message-reactions-self-destruct-passcode.md).
|
|
||||||
|
|
||||||
[Apr 22, 2023. SimpleX Chat: vision and funding, v5.0 released with videos and files up to 1gb](./blog/20230422-simplex-chat-vision-funding-v5-videos-files-passcode.md).
|
[Apr 22, 2023. SimpleX Chat: vision and funding, v5.0 released with videos and files up to 1gb](./blog/20230422-simplex-chat-vision-funding-v5-videos-files-passcode.md).
|
||||||
|
|
||||||
|
[Mar 28, 2023. v4.6 released - with Android 8+ and ARMv7a support, hidden profiles, community moderation, improved audio/video calls and reduced battery usage](./blog/20230328-simplex-chat-v4-6-hidden-profiles.md).
|
||||||
|
|
||||||
[Mar 1, 2023. SimpleX File Transfer Protocol – send large files efficiently, privately and securely, soon to be integrated into SimpleX Chat apps.](./blog/20230301-simplex-file-transfer-protocol.md).
|
[Mar 1, 2023. SimpleX File Transfer Protocol – send large files efficiently, privately and securely, soon to be integrated into SimpleX Chat apps.](./blog/20230301-simplex-file-transfer-protocol.md).
|
||||||
|
|
||||||
|
[Feb 4, 2023. v4.5 released - with multiple user profiles, message draft, transport isolation and Italian interface](./blog/20230204-simplex-chat-v4-5-user-chat-profiles.md).
|
||||||
|
|
||||||
|
[Jan 3, 2023. v4.4 released - with disappearing messages, "live" messages, connection security verifications, GIFs and stickers and with French interface language](./blog/20230103-simplex-chat-v4.4-disappearing-messages.md).
|
||||||
|
|
||||||
|
[Dec 6, 2022. November reviews and v4.3 released - with instant voice messages, irreversible deletion of sent messages and improved server configuration](./blog/20221206-simplex-chat-v4.3-voice-messages.md).
|
||||||
|
|
||||||
[Nov 8, 2022. Security audit by Trail of Bits, the new website and v4.2 released](./blog/20221108-simplex-chat-v4.2-security-audit-new-website.md).
|
[Nov 8, 2022. Security audit by Trail of Bits, the new website and v4.2 released](./blog/20221108-simplex-chat-v4.2-security-audit-new-website.md).
|
||||||
|
|
||||||
[Sep 28, 2022. v4.0: encrypted local chat database and many other changes](./blog/20220928-simplex-chat-v4-encrypted-database.md).
|
[Sep 28, 2022. v4.0: encrypted local chat database and many other changes](./blog/20220928-simplex-chat-v4-encrypted-database.md).
|
||||||
@@ -276,36 +241,29 @@ See [SimpleX whitepaper](https://github.com/simplex-chat/simplexmq/blob/stable/p
|
|||||||
|
|
||||||
See [SimpleX Chat Protocol](./docs/protocol/simplex-chat.md) for the format of messages sent between chat clients over [SimpleX Messaging Protocol](https://github.com/simplex-chat/simplexmq/blob/stable/protocol/simplex-messaging.md).
|
See [SimpleX Chat Protocol](./docs/protocol/simplex-chat.md) for the format of messages sent between chat clients over [SimpleX Messaging Protocol](https://github.com/simplex-chat/simplexmq/blob/stable/protocol/simplex-messaging.md).
|
||||||
|
|
||||||
## Privacy and security: technical details and limitations
|
## Privacy: technical details and limitations
|
||||||
|
|
||||||
SimpleX Chat is a work in progress – we are releasing improvements as they are ready. You have to decide if the current state is good enough for your usage scenario.
|
SimpleX Chat is a work in progress – we are releasing improvements as they are ready. You have to decide if the current state is good enough for your usage scenario.
|
||||||
|
|
||||||
We compiled a [glossary of terms](./docs/GLOSSARY.md) used to describe communication systems to help understand some terms below and to help compare advantages and disadvantages of various communication systems.
|
|
||||||
|
|
||||||
What is already implemented:
|
What is already implemented:
|
||||||
|
|
||||||
1. Instead of user profile identifiers used by all other platforms, even the most private ones, SimpleX uses [pairwise per-queue identifiers](./docs/GLOSSARY.md#pairwise-pseudonymous-identifier) (2 addresses for each unidirectional message queue, with an optional 3rd address for push notifications on iOS, 2 queues in each connection between the users). It makes observing the network graph on the application level more difficult, as for `n` users there can be up to `n * (n-1)` message queues.
|
1. Instead of user profile identifiers used by all other platforms, even the most private ones, SimpleX uses pairwise per-queue identifiers (2 addresses for each unidirectional message queue, with an optional 3rd address for push notifications on iOS, 2 queues in each connection between the users). It makes observing the network graph on the application level more difficult, as for `n` users there can be up to `n * (n-1)` message queues.
|
||||||
2. [End-to-end encryption](./docs/GLOSSARY.md#end-to-end-encryption) in each message queue using [NaCl cryptobox](https://nacl.cr.yp.to/box.html). This is added to allow redundancy in the future (passing each message via several servers), to avoid having the same ciphertext in different queues (that would only be visible to the attacker if TLS is compromised). The encryption keys used for this encryption are not rotated, instead we are planning to rotate the queues. Curve25519 keys are used for key negotiation.
|
2. End-to-end encryption in each message queue using [NaCl cryptobox](https://nacl.cr.yp.to/box.html). This is added to allow redundancy in the future (passing each message via several servers), to avoid having the same ciphertext in different queues (that would only be visible to the attacker if TLS is compromised). The encryption keys used for this encryption are not rotated, instead we are planning to rotate the queues. Curve25519 keys are used for key negotiation.
|
||||||
3. [Double ratchet](./docs/GLOSSARY.md#double-ratchet-algorithm) end-to-end encryption in each conversation between two users (or group members). This is the same algorithm that is used in Signal and many other messaging apps; it provides OTR messaging with [forward secrecy](./docs/GLOSSARY.md#forward-secrecy) (each message is encrypted by its own ephemeral key) and [break-in recovery](./docs/GLOSSARY.md#post-compromise-security) (the keys are frequently re-negotiated as part of the message exchange). Two pairs of Curve448 keys are used for the initial [key agreement](./docs/GLOSSARY.md#key-agreement-protocol), initiating party passes these keys via the connection link, accepting side - in the header of the confirmation message.
|
3. [Double ratchet](https://signal.org/docs/specifications/doubleratchet/) end-to-end encryption in each conversation between two users (or group members). This is the same algorithm that is used in Signal and many other messaging apps; it provides OTR messaging with forward secrecy (each message is encrypted by its own ephemeral key), break-in recovery (the keys are frequently re-negotiated as part of the message exchange). Two pairs of Curve448 keys are used for the initial key agreement, initiating party passes these keys via the connection link, accepting side - in the header of the confirmation message.
|
||||||
4. Additional layer of encryption using NaCL cryptobox for the messages delivered from the server to the recipient. This layer avoids having any ciphertext in common between sent and received traffic of the server inside TLS (and there are no identifiers in common as well).
|
4. Additional layer of encryption using NaCL cryptobox for the messages delivered from the server to the recipient. This layer avoids having any ciphertext in common between sent and received traffic of the server inside TLS (and there are no identifiers in common as well).
|
||||||
5. Several levels of [content padding](./docs/GLOSSARY.md#message-padding) to frustrate message size attacks.
|
5. Several levels of content padding to frustrate message size attacks.
|
||||||
6. All message metadata, including the time when the message was received by the server (rounded to a second) is sent to the recipients inside an encrypted envelope, so even if TLS is compromised it cannot be observed.
|
6. Starting from v2 of SMP protocol (the current version is v4) all message metadata, including the time when the message was received by the server (rounded to a second) is sent to the recipients inside an encrypted envelope, so even if TLS is compromised it cannot be observed.
|
||||||
7. Only TLS 1.2/1.3 are allowed for client-server connections, limited to cryptographic algorithms: CHACHA20POLY1305_SHA256, Ed25519/Ed448, Curve25519/Curve448.
|
7. Only TLS 1.2/1.3 are allowed for client-server connections, limited to cryptographic algorithms: CHACHA20POLY1305_SHA256, Ed25519/Ed448, Curve25519/Curve448.
|
||||||
8. To protect against replay attacks SimpleX servers require [tlsunique channel binding](https://www.rfc-editor.org/rfc/rfc5929.html) as session ID in each client command signed with per-queue ephemeral key.
|
8. To protect against replay attacks SimpleX servers require [tlsunique channel binding](https://www.rfc-editor.org/rfc/rfc5929.html) as session ID in each client command signed with per-queue ephemeral key.
|
||||||
9. To protect your IP address all SimpleX Chat clients support accessing messaging servers via Tor - see [v3.1 release announcement](./blog/20220808-simplex-chat-v3.1-chat-groups.md) for more details.
|
9. To protect your IP address all SimpleX Chat clients support accessing messaging servers via Tor - see [v3.1 release announcement](./blog/20220808-simplex-chat-v3.1-chat-groups.md) for more details.
|
||||||
10. Local database encryption with passphrase - your contacts, groups and all sent and received messages are stored encrypted. If you used SimpleX Chat before v4.0 you need to enable the encryption via the app settings.
|
10. Local database encryption with passphrase - your contacts, groups and all sent and received messages are stored encrypted. If you used SimpleX Chat before v4.0 you need to enable the encryption via the app settings.
|
||||||
11. Transport isolation - different TCP connections and Tor circuits are used for traffic of different user profiles, optionally - for different contacts and group member connections.
|
11. Transport isolation - different TCP connections and Tor circuits are used for traffic of different user profiles, optionally - for different contacts and group member connections.
|
||||||
12. Manual messaging queue rotations to move conversation to another SMP relay.
|
|
||||||
13. Sending end-to-end encrypted files using [XFTP protocol](https://simplex.chat/blog/20230301-simplex-file-transfer-protocol.html).
|
|
||||||
14. Local files encryption, except videos (to be added later).
|
|
||||||
|
|
||||||
We plan to add:
|
We plan to add soon:
|
||||||
|
|
||||||
1. Senders' SMP relays and recipients' XFTP relays to reduce traffic and conceal IP addresses from the relays chosen, and potentially controlled, by another party.
|
1. Automatic message queue rotation. Currently the queues created between two users are used until the queue is manually changed by the user or contact is deleted. We are planning to add automatic queue rotation to make these identifiers temporary and rotate based on some schedule TBC (e.g., every X messages, or every X hours/days).
|
||||||
2. Post-quantum resistant key exchange in double ratchet protocol.
|
2. Local files encryption. Currently the images and files you send and receive are stored in the app unencrypted, you can delete them via `Settings / Database passphrase & export`.
|
||||||
3. Automatic message queue rotation and redundancy. Currently the queues created between two users are used until the queue is manually changed by the user or contact is deleted. We are planning to add automatic queue rotation to make these identifiers temporary and rotate based on some schedule TBC (e.g., every X messages, or every X hours/days).
|
3. Message "mixing" - adding latency to message delivery, to protect against traffic correlation by message time.
|
||||||
4. Message "mixing" - adding latency to message delivery, to protect against traffic correlation by message time.
|
|
||||||
5. Reproducible builds – this is the limitation of the development stack, but we will be investing into solving this problem. Users can still build all applications and services from the source code.
|
|
||||||
|
|
||||||
## For developers
|
## For developers
|
||||||
|
|
||||||
@@ -318,8 +276,6 @@ You can:
|
|||||||
|
|
||||||
If you are considering developing with SimpleX platform please get in touch for any advice and support.
|
If you are considering developing with SimpleX platform please get in touch for any advice and support.
|
||||||
|
|
||||||
Please also join [#simplex-devs](https://simplex.chat/contact#/?v=1-2&smp=smp%3A%2F%2Fu2dS9sG8nMNURyZwqASV4yROM28Er0luVTx5X1CsMrU%3D%40smp4.simplex.im%2F6eHqy7uAbZPOcA6qBtrQgQquVlt4Ll91%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAqV_pg3FF00L98aCXp4D3bOs4Sxv_UmSd-gb0juVoQVs%253D%26srv%3Do5vmywmrnaxalvz6wi3zicyftgio6psuvyniis6gco6bp6ekl4cqj4id.onion&data=%7B%22type%22%3A%22group%22%2C%22groupLinkId%22%3A%22XonlixcHBIb2ijCehbZoiw%3D%3D%22%7D) group to ask any questions and share your success stories.
|
|
||||||
|
|
||||||
## Roadmap
|
## Roadmap
|
||||||
|
|
||||||
- ✅ Easy to deploy SimpleX server with in-memory message storage, without any dependencies.
|
- ✅ Easy to deploy SimpleX server with in-memory message storage, without any dependencies.
|
||||||
@@ -360,32 +316,26 @@ Please also join [#simplex-devs](https://simplex.chat/contact#/?v=1-2&smp=smp%3A
|
|||||||
- ✅ Sending and receiving large files via [XFTP protocol](./blog/20230301-simplex-file-transfer-protocol.md).
|
- ✅ Sending and receiving large files via [XFTP protocol](./blog/20230301-simplex-file-transfer-protocol.md).
|
||||||
- ✅ Video messages.
|
- ✅ Video messages.
|
||||||
- ✅ App access passcode.
|
- ✅ App access passcode.
|
||||||
- ✅ Improved Android app UI design.
|
- 🏗 Improved Android app UI design.
|
||||||
- ✅ Optional alternative access password.
|
- 🏗 SMP queue redundancy and rotation (manual is supported).
|
||||||
- ✅ Message reactions
|
- 🏗 Reduced battery and traffic usage in large groups.
|
||||||
- ✅ Message editing history
|
|
||||||
- ✅ Reduced battery and traffic usage in large groups.
|
|
||||||
- ✅ Message delivery confirmation (with sender opt-out per contact).
|
|
||||||
- ✅ Desktop client.
|
|
||||||
- ✅ Encryption of local files stored in the app.
|
|
||||||
- ✅ Using mobile profiles from the desktop app.
|
|
||||||
- 🏗 Improve experience for the new users.
|
|
||||||
- 🏗 Post-quantum resistant key exchange in double ratchet protocol.
|
|
||||||
- 🏗 Large groups, communities and public channels.
|
|
||||||
- Message delivery relay for senders (to conceal IP address from the recipients' servers and to reduce the traffic).
|
|
||||||
- Privacy & security slider - a simple way to set all settings at once.
|
|
||||||
- Improve sending videos (including encryption of locally stored videos).
|
|
||||||
- SMP queue redundancy and rotation (manual is supported).
|
|
||||||
- Include optional message into connection request sent via contact address.
|
- Include optional message into connection request sent via contact address.
|
||||||
- Improved navigation and search in the conversation (expand and scroll to quoted message, scroll to search results, etc.).
|
|
||||||
- Feeds/broadcasts.
|
|
||||||
- Ephemeral/disappearing/OTR conversations with the existing contacts.
|
- Ephemeral/disappearing/OTR conversations with the existing contacts.
|
||||||
|
- Optional alternative access password.
|
||||||
|
- Local app files encryption.
|
||||||
|
- Improved navigation and search in the conversation (expand and scroll to quoted message, scroll to search results, etc.).
|
||||||
|
- Message delivery confirmation (with sender opt-in or opt-out per contact, TBC).
|
||||||
- Privately share your location.
|
- Privately share your location.
|
||||||
|
- Feeds/broadcasts.
|
||||||
- Web widgets for custom interactivity in the chats.
|
- Web widgets for custom interactivity in the chats.
|
||||||
- Programmable chat automations / rules (automatic replies/forward/deletion/sending, reminders, etc.).
|
- Programmable chat automations / rules (automatic replies/forward/deletion/sending, reminders, etc.).
|
||||||
|
- Supporting the same profile on multiple devices.
|
||||||
|
- Desktop client.
|
||||||
- Privacy-preserving identity server for optional DNS-based contact/group addresses to simplify connection and discovery, but not used to deliver messages:
|
- Privacy-preserving identity server for optional DNS-based contact/group addresses to simplify connection and discovery, but not used to deliver messages:
|
||||||
- keep all your contacts and groups even if you lose the domain.
|
- keep all your contacts and groups even if you lose the domain.
|
||||||
- the server doesn't have information about your contacts and groups.
|
- the server doesn't have information about your contacts and groups.
|
||||||
|
- Hosting server for large groups, communities and public channels.
|
||||||
|
- Message delivery relay for senders (to conceal IP address from the recipients' servers and to reduce the traffic).
|
||||||
- High capacity multi-node SMP relays.
|
- High capacity multi-node SMP relays.
|
||||||
|
|
||||||
## Disclaimers
|
## Disclaimers
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
*.iml
|
||||||
|
.gradle
|
||||||
|
/local.properties
|
||||||
|
/.idea/caches
|
||||||
|
/.idea/libraries
|
||||||
|
/.idea/modules.xml
|
||||||
|
/.idea/workspace.xml
|
||||||
|
/.idea/navEditor.xml
|
||||||
|
/.idea/assetWizardSettings.xml
|
||||||
|
/.idea/deploymentTargetDropDown.xml
|
||||||
|
/.idea/misc.xml
|
||||||
|
/.idea/uiDesigner.xml
|
||||||
|
/.idea/kotlinc.xml
|
||||||
|
.DS_Store
|
||||||
|
/build
|
||||||
|
/captures
|
||||||
|
.externalNativeBuild
|
||||||
|
.cxx
|
||||||
|
local.properties
|
||||||
|
app/src/main/cpp/libs/
|
||||||
Generated
+1
@@ -0,0 +1 @@
|
|||||||
|
SimpleX
|
||||||
Generated
Generated
+6
@@ -0,0 +1,6 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="CompilerConfiguration">
|
||||||
|
<bytecodeTargetLevel target="11" />
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
Generated
+19
@@ -0,0 +1,19 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="GradleMigrationSettings" migrationVersion="1" />
|
||||||
|
<component name="GradleSettings">
|
||||||
|
<option name="linkedExternalProjectsSettings">
|
||||||
|
<GradleProjectSettings>
|
||||||
|
<option name="testRunner" value="GRADLE" />
|
||||||
|
<option name="distributionType" value="DEFAULT_WRAPPED" />
|
||||||
|
<option name="externalProjectPath" value="$PROJECT_DIR$" />
|
||||||
|
<option name="modules">
|
||||||
|
<set>
|
||||||
|
<option value="$PROJECT_DIR$" />
|
||||||
|
<option value="$PROJECT_DIR$/app" />
|
||||||
|
</set>
|
||||||
|
</option>
|
||||||
|
</GradleProjectSettings>
|
||||||
|
</option>
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
<component name="InspectionProjectProfileManager">
|
||||||
|
<profile version="1.0">
|
||||||
|
<option name="myName" value="Project Default" />
|
||||||
|
<inspection_tool class="PreviewAnnotationInFunctionWithParameters" enabled="true" level="ERROR" enabled_by_default="true">
|
||||||
|
<option name="previewFile" value="true" />
|
||||||
|
</inspection_tool>
|
||||||
|
<inspection_tool class="PreviewDimensionRespectsLimit" enabled="true" level="WARNING" enabled_by_default="true">
|
||||||
|
<option name="previewFile" value="true" />
|
||||||
|
</inspection_tool>
|
||||||
|
<inspection_tool class="PreviewMultipleParameterProviders" enabled="true" level="ERROR" enabled_by_default="true">
|
||||||
|
<option name="previewFile" value="true" />
|
||||||
|
</inspection_tool>
|
||||||
|
<inspection_tool class="PreviewMustBeTopLevelFunction" enabled="true" level="ERROR" enabled_by_default="true">
|
||||||
|
<option name="previewFile" value="true" />
|
||||||
|
</inspection_tool>
|
||||||
|
<inspection_tool class="PreviewNeedsComposableAnnotation" enabled="true" level="ERROR" enabled_by_default="true">
|
||||||
|
<option name="previewFile" value="true" />
|
||||||
|
</inspection_tool>
|
||||||
|
</profile>
|
||||||
|
</component>
|
||||||
Generated
+6
@@ -0,0 +1,6 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="VcsDirectoryMappings">
|
||||||
|
<mapping directory="$PROJECT_DIR$/../.." vcs="Git" />
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
@@ -4,6 +4,7 @@ This readme is currently a stub and as such is in development.
|
|||||||
|
|
||||||
Ultimately, this readme will act as a guide to contributing to the develop of the SimpleX android app.
|
Ultimately, this readme will act as a guide to contributing to the develop of the SimpleX android app.
|
||||||
|
|
||||||
|
|
||||||
## Gotchas
|
## Gotchas
|
||||||
|
|
||||||
#### SHA Signature for verification for app links/deep links
|
#### SHA Signature for verification for app links/deep links
|
||||||
@@ -22,15 +23,3 @@ To find your SHA certificate fingerprint perform the following steps.
|
|||||||
More information is available [here](https://developer.android.com/training/app-links/verify-site-associations#manual-verification). If there is no response when running the `pm get-app-links` command, the intents in `AndroidManifest.xml` are likely misspecified. A verification attempt can be triggered using `adb shell pm verify-app-links --re-verify chat.simplex.app`.
|
More information is available [here](https://developer.android.com/training/app-links/verify-site-associations#manual-verification). If there is no response when running the `pm get-app-links` command, the intents in `AndroidManifest.xml` are likely misspecified. A verification attempt can be triggered using `adb shell pm verify-app-links --re-verify chat.simplex.app`.
|
||||||
|
|
||||||
Note that this is not an issue for the app store build of the app as this is signed with our app store credentials and thus there is a stable signature over users. Developers do not have general access to these credentials for development and testing.
|
Note that this is not an issue for the app store build of the app as this is signed with our app store credentials and thus there is a stable signature over users. Developers do not have general access to these credentials for development and testing.
|
||||||
|
|
||||||
## Adding icons
|
|
||||||
|
|
||||||
1. Find a [Material symbol](https://fonts.google.com/icons?icon.style=Rounded) in Rounded category.
|
|
||||||
|
|
||||||
2. Set weight to 400, grade to -25 and size to 48px.
|
|
||||||
|
|
||||||
3. Click on the icon, choose Android and download XML file.
|
|
||||||
|
|
||||||
4. Update the color to black (#FF000000) and the size to "24.dp", as in other icons.
|
|
||||||
|
|
||||||
For example, this is [add reaction icon](https://fonts.google.com/icons?selected=Material+Symbols+Rounded:add_reaction:FILL@0;wght@300;GRAD@-25;opsz@24&icon.style=Rounded).
|
|
||||||
@@ -0,0 +1,235 @@
|
|||||||
|
plugins {
|
||||||
|
id 'com.android.application'
|
||||||
|
id 'org.jetbrains.kotlin.android'
|
||||||
|
id 'org.jetbrains.kotlin.plugin.serialization'
|
||||||
|
}
|
||||||
|
|
||||||
|
android {
|
||||||
|
compileSdk 32
|
||||||
|
|
||||||
|
defaultConfig {
|
||||||
|
applicationId "chat.simplex.app"
|
||||||
|
minSdk 26
|
||||||
|
targetSdk 32
|
||||||
|
versionCode 117
|
||||||
|
versionName "5.0"
|
||||||
|
|
||||||
|
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||||
|
vectorDrawables {
|
||||||
|
useSupportLibrary true
|
||||||
|
}
|
||||||
|
externalNativeBuild {
|
||||||
|
cmake {
|
||||||
|
cppFlags ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
manifestPlaceholders.app_name = "@string/app_name"
|
||||||
|
manifestPlaceholders.provider_authorities = "chat.simplex.app.provider"
|
||||||
|
manifestPlaceholders.extract_native_libs = compression_level != "0"
|
||||||
|
}
|
||||||
|
|
||||||
|
buildTypes {
|
||||||
|
debug {
|
||||||
|
applicationIdSuffix "$application_id_suffix"
|
||||||
|
debuggable new Boolean("$enable_debuggable")
|
||||||
|
manifestPlaceholders.app_name = "$app_name"
|
||||||
|
// Provider can't be the same for different apps on the same device
|
||||||
|
manifestPlaceholders.provider_authorities = "chat.simplex.app${application_id_suffix}.provider"
|
||||||
|
}
|
||||||
|
release {
|
||||||
|
minifyEnabled false
|
||||||
|
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
compileOptions {
|
||||||
|
sourceCompatibility JavaVersion.VERSION_1_8
|
||||||
|
targetCompatibility JavaVersion.VERSION_1_8
|
||||||
|
}
|
||||||
|
kotlinOptions {
|
||||||
|
jvmTarget = '1.8'
|
||||||
|
freeCompilerArgs += "-opt-in=kotlinx.coroutines.DelicateCoroutinesApi"
|
||||||
|
freeCompilerArgs += "-opt-in=androidx.compose.foundation.ExperimentalFoundationApi"
|
||||||
|
freeCompilerArgs += "-opt-in=androidx.compose.ui.text.ExperimentalTextApi"
|
||||||
|
freeCompilerArgs += "-opt-in=androidx.compose.material.ExperimentalMaterialApi"
|
||||||
|
freeCompilerArgs += "-opt-in=com.google.accompanist.insets.ExperimentalAnimatedInsets"
|
||||||
|
freeCompilerArgs += "-opt-in=com.google.accompanist.permissions.ExperimentalPermissionsApi"
|
||||||
|
freeCompilerArgs += "-opt-in=kotlinx.serialization.InternalSerializationApi"
|
||||||
|
freeCompilerArgs += "-opt-in=kotlinx.serialization.ExperimentalSerializationApi"
|
||||||
|
}
|
||||||
|
externalNativeBuild {
|
||||||
|
cmake {
|
||||||
|
path file('src/main/cpp/CMakeLists.txt')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
buildFeatures {
|
||||||
|
compose true
|
||||||
|
}
|
||||||
|
composeOptions {
|
||||||
|
kotlinCompilerExtensionVersion compose_version
|
||||||
|
}
|
||||||
|
packagingOptions {
|
||||||
|
resources {
|
||||||
|
excludes += '/META-INF/{AL2.0,LGPL2.1}'
|
||||||
|
}
|
||||||
|
jniLibs.useLegacyPackaging = compression_level != "0"
|
||||||
|
}
|
||||||
|
def isRelease = gradle.getStartParameter().taskNames.find({ it.toLowerCase().contains("release") }) != null
|
||||||
|
def isBundle = gradle.getStartParameter().taskNames.find({ it.toLowerCase().contains("bundle") }) != null
|
||||||
|
// if (isRelease) {
|
||||||
|
// Comma separated list of languages that will be included in the apk
|
||||||
|
android.defaultConfig.resConfigs(
|
||||||
|
"en",
|
||||||
|
"cs",
|
||||||
|
"de",
|
||||||
|
"es",
|
||||||
|
"fr",
|
||||||
|
"it",
|
||||||
|
"nl",
|
||||||
|
"pl",
|
||||||
|
"ru",
|
||||||
|
"zh-rCN"
|
||||||
|
)
|
||||||
|
// }
|
||||||
|
if (isBundle) {
|
||||||
|
defaultConfig.ndk.abiFilters 'arm64-v8a', 'armeabi-v7a'
|
||||||
|
} else {
|
||||||
|
splits {
|
||||||
|
abi {
|
||||||
|
enable true
|
||||||
|
reset()
|
||||||
|
if (isRelease) {
|
||||||
|
include 'arm64-v8a', 'armeabi-v7a'
|
||||||
|
} else {
|
||||||
|
include 'arm64-v8a', 'armeabi-v7a'
|
||||||
|
universalApk false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
implementation 'androidx.core:core-ktx:1.7.0'
|
||||||
|
implementation "androidx.compose.ui:ui:$compose_version"
|
||||||
|
implementation "androidx.compose.material:material:$compose_version"
|
||||||
|
implementation "androidx.compose.ui:ui-tooling-preview:$compose_version"
|
||||||
|
implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.4.1'
|
||||||
|
implementation 'androidx.lifecycle:lifecycle-process:2.4.1'
|
||||||
|
implementation 'androidx.activity:activity-compose:1.4.0'
|
||||||
|
implementation 'androidx.fragment:fragment:1.4.1'
|
||||||
|
implementation 'org.jetbrains.kotlinx:kotlinx-datetime:0.3.2'
|
||||||
|
implementation 'org.jetbrains.kotlinx:kotlinx-serialization-json:1.3.2'
|
||||||
|
implementation 'com.charleskorn.kaml:kaml:0.43.0'
|
||||||
|
//implementation "androidx.compose.material:material-icons-extended:$compose_version"
|
||||||
|
implementation "androidx.compose.ui:ui-util:$compose_version"
|
||||||
|
implementation "androidx.navigation:navigation-compose:2.4.1"
|
||||||
|
implementation "com.google.accompanist:accompanist-insets:0.23.0"
|
||||||
|
implementation 'androidx.webkit:webkit:1.4.0'
|
||||||
|
implementation "com.godaddy.android.colorpicker:compose-color-picker:0.4.2"
|
||||||
|
|
||||||
|
def work_version = "2.7.1"
|
||||||
|
implementation "androidx.work:work-runtime-ktx:$work_version"
|
||||||
|
implementation "androidx.work:work-multiprocess:$work_version"
|
||||||
|
|
||||||
|
def camerax_version = "1.1.0-beta01"
|
||||||
|
implementation "androidx.camera:camera-core:${camerax_version}"
|
||||||
|
implementation "androidx.camera:camera-camera2:${camerax_version}"
|
||||||
|
implementation "androidx.camera:camera-lifecycle:${camerax_version}"
|
||||||
|
implementation "androidx.camera:camera-view:${camerax_version}"
|
||||||
|
|
||||||
|
//Barcode
|
||||||
|
implementation 'org.boofcv:boofcv-android:0.40.1'
|
||||||
|
implementation 'org.boofcv:boofcv-core:0.40.1'
|
||||||
|
|
||||||
|
//Camera Permission
|
||||||
|
implementation "com.google.accompanist:accompanist-permissions:0.23.0"
|
||||||
|
implementation "com.google.accompanist:accompanist-pager:0.25.1"
|
||||||
|
|
||||||
|
// Link Previews
|
||||||
|
implementation 'org.jsoup:jsoup:1.13.1'
|
||||||
|
|
||||||
|
// Biometric authentication
|
||||||
|
implementation 'androidx.biometric:biometric:1.2.0-alpha04'
|
||||||
|
|
||||||
|
// GIFs support
|
||||||
|
implementation "io.coil-kt:coil-compose:2.1.0"
|
||||||
|
implementation "io.coil-kt:coil-gif:2.1.0"
|
||||||
|
|
||||||
|
// Video support
|
||||||
|
implementation "com.google.android.exoplayer:exoplayer:2.17.1"
|
||||||
|
|
||||||
|
testImplementation 'junit:junit:4.13.2'
|
||||||
|
androidTestImplementation 'androidx.test.ext:junit:1.1.3'
|
||||||
|
androidTestImplementation 'androidx.test.espresso:espresso-core:3.4.0'
|
||||||
|
androidTestImplementation "androidx.compose.ui:ui-test-junit4:$compose_version"
|
||||||
|
debugImplementation "androidx.compose.ui:ui-tooling:$compose_version"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Don't do anything if no compression is needed
|
||||||
|
if (compression_level != "0") {
|
||||||
|
tasks.whenTaskAdded { task ->
|
||||||
|
if (task.name == 'packageDebug') {
|
||||||
|
task.finalizedBy compressApk
|
||||||
|
} else if (task.name == 'packageRelease') {
|
||||||
|
task.finalizedBy compressApk
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tasks.register("compressApk") {
|
||||||
|
doLast {
|
||||||
|
def isRelease = gradle.getStartParameter().taskNames.find({ it.toLowerCase().contains("release") }) != null
|
||||||
|
def buildType
|
||||||
|
if (isRelease) {
|
||||||
|
buildType = "release"
|
||||||
|
} else {
|
||||||
|
buildType = "debug"
|
||||||
|
}
|
||||||
|
def javaHome = System.properties['java.home'] ?: org.gradle.internal.jvm.Jvm.current().getJavaHome()
|
||||||
|
def sdkDir = android.getSdkDirectory().getAbsolutePath()
|
||||||
|
def keyAlias = ""
|
||||||
|
def keyPassword = ""
|
||||||
|
def storeFile = ""
|
||||||
|
def storePassword = ""
|
||||||
|
if (project.properties['android.injected.signing.key.alias'] != null) {
|
||||||
|
keyAlias = project.properties['android.injected.signing.key.alias']
|
||||||
|
keyPassword = project.properties['android.injected.signing.key.password']
|
||||||
|
storeFile = project.properties['android.injected.signing.store.file']
|
||||||
|
storePassword = project.properties['android.injected.signing.store.password']
|
||||||
|
} else if (android.signingConfigs.hasProperty(buildType)) {
|
||||||
|
def gradleConfig = android.signingConfigs[buildType]
|
||||||
|
keyAlias = gradleConfig.keyAlias
|
||||||
|
keyPassword = gradleConfig.keyPassword
|
||||||
|
storeFile = gradleConfig.storeFile
|
||||||
|
storePassword = gradleConfig.storePassword
|
||||||
|
} else {
|
||||||
|
// There is no signing config for current build type, can't sign the apk
|
||||||
|
println("No signing configs for this build type: $buildType")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
def outputDir = tasks["package${buildType.capitalize()}"].outputs.files.last()
|
||||||
|
|
||||||
|
exec {
|
||||||
|
workingDir '../../../scripts/android'
|
||||||
|
setEnvironment(['JAVA_HOME': "$javaHome"])
|
||||||
|
commandLine './compress-and-sign-apk.sh', \
|
||||||
|
"$compression_level", \
|
||||||
|
"$outputDir", \
|
||||||
|
"$sdkDir", \
|
||||||
|
"$storeFile", \
|
||||||
|
"$storePassword", \
|
||||||
|
"$keyAlias", \
|
||||||
|
"$keyPassword"
|
||||||
|
}
|
||||||
|
|
||||||
|
if (project.properties['android.injected.signing.key.alias'] != null && buildType == 'release') {
|
||||||
|
new File(outputDir, "app-release.apk").renameTo(new File(outputDir, "simplex.apk"))
|
||||||
|
new File(outputDir, "app-armeabi-v7a-release.apk").renameTo(new File(outputDir, "simplex-armv7a.apk"))
|
||||||
|
new File(outputDir, "app-arm64-v8a-release.apk").renameTo(new File(outputDir, "simplex.apk"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// View all gradle properties set
|
||||||
|
// project.properties.each { k, v -> println "$k -> $v" }
|
||||||
|
}
|
||||||
|
}
|
||||||
+4
-3
@@ -6,7 +6,6 @@
|
|||||||
<uses-feature android:name="android.hardware.camera" />
|
<uses-feature android:name="android.hardware.camera" />
|
||||||
|
|
||||||
<uses-permission android:name="android.permission.CAMERA" />
|
<uses-permission android:name="android.permission.CAMERA" />
|
||||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
|
||||||
<uses-permission android:name="android.permission.VIDEO_CAPTURE" />
|
<uses-permission android:name="android.permission.VIDEO_CAPTURE" />
|
||||||
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
||||||
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
|
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
|
||||||
@@ -15,6 +14,7 @@
|
|||||||
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
|
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
|
||||||
<uses-permission android:name="android.permission.VIBRATE" />
|
<uses-permission android:name="android.permission.VIBRATE" />
|
||||||
<uses-permission android:name="android.permission.INTERNET" />
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
|
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
|
||||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||||
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"
|
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"
|
||||||
tools:ignore="ScopedStorage" />
|
tools:ignore="ScopedStorage" />
|
||||||
@@ -23,8 +23,9 @@
|
|||||||
|
|
||||||
<application
|
<application
|
||||||
android:name="SimplexApp"
|
android:name="SimplexApp"
|
||||||
android:allowBackup="false"
|
android:allowBackup="true"
|
||||||
android:fullBackupOnly="false"
|
android:fullBackupOnly="true"
|
||||||
|
android:backupAgent="BackupAgent"
|
||||||
android:icon="@mipmap/icon"
|
android:icon="@mipmap/icon"
|
||||||
android:label="${app_name}"
|
android:label="${app_name}"
|
||||||
android:extractNativeLibs="${extract_native_libs}"
|
android:extractNativeLibs="${extract_native_libs}"
|
||||||
+18
-118
@@ -14,7 +14,6 @@ var VideoCamera;
|
|||||||
// for debugging
|
// for debugging
|
||||||
// var sendMessageToNative = ({resp}: WVApiMessage) => console.log(JSON.stringify({command: resp}))
|
// var sendMessageToNative = ({resp}: WVApiMessage) => console.log(JSON.stringify({command: resp}))
|
||||||
var sendMessageToNative = (msg) => console.log(JSON.stringify(msg));
|
var sendMessageToNative = (msg) => console.log(JSON.stringify(msg));
|
||||||
var toggleScreenShare = async () => { };
|
|
||||||
// Global object with cryptrographic/encoding functions
|
// Global object with cryptrographic/encoding functions
|
||||||
const callCrypto = callCryptoFunction();
|
const callCrypto = callCryptoFunction();
|
||||||
var TransformOperation;
|
var TransformOperation;
|
||||||
@@ -24,10 +23,6 @@ var TransformOperation;
|
|||||||
})(TransformOperation || (TransformOperation = {}));
|
})(TransformOperation || (TransformOperation = {}));
|
||||||
let activeCall;
|
let activeCall;
|
||||||
let answerTimeout = 30000;
|
let answerTimeout = 30000;
|
||||||
var useWorker = false;
|
|
||||||
var isDesktop = false;
|
|
||||||
var localizedState = "";
|
|
||||||
var localizedDescription = "";
|
|
||||||
const processCommand = (function () {
|
const processCommand = (function () {
|
||||||
const defaultIceServers = [
|
const defaultIceServers = [
|
||||||
{ urls: ["stun:stun.simplex.im:443"] },
|
{ urls: ["stun:stun.simplex.im:443"] },
|
||||||
@@ -43,9 +38,9 @@ const processCommand = (function () {
|
|||||||
iceTransportPolicy: relay ? "relay" : "all",
|
iceTransportPolicy: relay ? "relay" : "all",
|
||||||
},
|
},
|
||||||
iceCandidates: {
|
iceCandidates: {
|
||||||
delay: 750,
|
delay: 3000,
|
||||||
extrasInterval: 1500,
|
extrasInterval: 2000,
|
||||||
extrasTimeout: 12000,
|
extrasTimeout: 8000,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -86,8 +81,6 @@ const processCommand = (function () {
|
|||||||
if (delay)
|
if (delay)
|
||||||
clearTimeout(delay);
|
clearTimeout(delay);
|
||||||
resolved = true;
|
resolved = true;
|
||||||
// console.log("resolveIceCandidates", JSON.stringify(candidates))
|
|
||||||
console.log("resolveIceCandidates");
|
|
||||||
const iceCandidates = serialize(candidates);
|
const iceCandidates = serialize(candidates);
|
||||||
candidates = [];
|
candidates = [];
|
||||||
resolve(iceCandidates);
|
resolve(iceCandidates);
|
||||||
@@ -95,37 +88,19 @@ const processCommand = (function () {
|
|||||||
function sendIceCandidates() {
|
function sendIceCandidates() {
|
||||||
if (candidates.length === 0)
|
if (candidates.length === 0)
|
||||||
return;
|
return;
|
||||||
// console.log("sendIceCandidates", JSON.stringify(candidates))
|
|
||||||
console.log("sendIceCandidates");
|
|
||||||
const iceCandidates = serialize(candidates);
|
const iceCandidates = serialize(candidates);
|
||||||
candidates = [];
|
candidates = [];
|
||||||
sendMessageToNative({ resp: { type: "ice", iceCandidates } });
|
sendMessageToNative({ resp: { type: "ice", iceCandidates } });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
async function initializeCall(config, mediaType, aesKey) {
|
async function initializeCall(config, mediaType, aesKey, useWorker) {
|
||||||
const pc = new RTCPeerConnection(config.peerConnectionConfig);
|
const pc = new RTCPeerConnection(config.peerConnectionConfig);
|
||||||
const remoteStream = new MediaStream();
|
const remoteStream = new MediaStream();
|
||||||
const localCamera = VideoCamera.User;
|
const localCamera = VideoCamera.User;
|
||||||
const localStream = await getLocalMediaStream(mediaType, localCamera);
|
const localStream = await getLocalMediaStream(mediaType, localCamera);
|
||||||
if (isDesktop) {
|
|
||||||
localStream
|
|
||||||
.getTracks()
|
|
||||||
.filter((elem) => elem.kind == "video")
|
|
||||||
.forEach((elem) => (elem.enabled = false));
|
|
||||||
}
|
|
||||||
const iceCandidates = getIceCandidates(pc, config);
|
const iceCandidates = getIceCandidates(pc, config);
|
||||||
const call = {
|
const call = { connection: pc, iceCandidates, localMedia: mediaType, localCamera, localStream, remoteStream, aesKey, useWorker };
|
||||||
connection: pc,
|
|
||||||
iceCandidates,
|
|
||||||
localMedia: mediaType,
|
|
||||||
localCamera,
|
|
||||||
localStream,
|
|
||||||
remoteStream,
|
|
||||||
aesKey,
|
|
||||||
screenShareEnabled: false,
|
|
||||||
cameraEnabled: true,
|
|
||||||
};
|
|
||||||
await setupMediaStreams(call);
|
await setupMediaStreams(call);
|
||||||
let connectionTimeout = setTimeout(connectionHandler, answerTimeout);
|
let connectionTimeout = setTimeout(connectionHandler, answerTimeout);
|
||||||
pc.addEventListener("connectionstatechange", connectionStateChange);
|
pc.addEventListener("connectionstatechange", connectionStateChange);
|
||||||
@@ -203,17 +178,17 @@ const processCommand = (function () {
|
|||||||
// This request for local media stream is made to prompt for camera/mic permissions on call start
|
// This request for local media stream is made to prompt for camera/mic permissions on call start
|
||||||
if (command.media)
|
if (command.media)
|
||||||
await getLocalMediaStream(command.media, VideoCamera.User);
|
await getLocalMediaStream(command.media, VideoCamera.User);
|
||||||
const encryption = supportsInsertableStreams(useWorker);
|
const encryption = supportsInsertableStreams(command.useWorker);
|
||||||
resp = { type: "capabilities", capabilities: { encryption } };
|
resp = { type: "capabilities", capabilities: { encryption } };
|
||||||
break;
|
break;
|
||||||
case "start": {
|
case "start": {
|
||||||
console.log("starting incoming call - create webrtc session");
|
console.log("starting incoming call - create webrtc session");
|
||||||
if (activeCall)
|
if (activeCall)
|
||||||
endCall();
|
endCall();
|
||||||
const { media, iceServers, relay } = command;
|
const { media, useWorker, iceServers, relay } = command;
|
||||||
const encryption = supportsInsertableStreams(useWorker);
|
const encryption = supportsInsertableStreams(useWorker);
|
||||||
const aesKey = encryption ? command.aesKey : undefined;
|
const aesKey = encryption ? command.aesKey : undefined;
|
||||||
activeCall = await initializeCall(getCallConfig(encryption && !!aesKey, iceServers, relay), media, aesKey);
|
activeCall = await initializeCall(getCallConfig(encryption && !!aesKey, iceServers, relay), media, aesKey, useWorker);
|
||||||
const pc = activeCall.connection;
|
const pc = activeCall.connection;
|
||||||
const offer = await pc.createOffer();
|
const offer = await pc.createOffer();
|
||||||
await pc.setLocalDescription(offer);
|
await pc.setLocalDescription(offer);
|
||||||
@@ -227,6 +202,7 @@ const processCommand = (function () {
|
|||||||
// iceServers,
|
// iceServers,
|
||||||
// relay,
|
// relay,
|
||||||
// aesKey,
|
// aesKey,
|
||||||
|
// useWorker,
|
||||||
// }
|
// }
|
||||||
resp = {
|
resp = {
|
||||||
type: "offer",
|
type: "offer",
|
||||||
@@ -234,23 +210,21 @@ const processCommand = (function () {
|
|||||||
iceCandidates: await activeCall.iceCandidates,
|
iceCandidates: await activeCall.iceCandidates,
|
||||||
capabilities: { encryption },
|
capabilities: { encryption },
|
||||||
};
|
};
|
||||||
// console.log("offer response", JSON.stringify(resp))
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case "offer":
|
case "offer":
|
||||||
if (activeCall) {
|
if (activeCall) {
|
||||||
resp = { type: "error", message: "accept: call already started" };
|
resp = { type: "error", message: "accept: call already started" };
|
||||||
}
|
}
|
||||||
else if (!supportsInsertableStreams(useWorker) && command.aesKey) {
|
else if (!supportsInsertableStreams(command.useWorker) && command.aesKey) {
|
||||||
resp = { type: "error", message: "accept: encryption is not supported" };
|
resp = { type: "error", message: "accept: encryption is not supported" };
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
const offer = parse(command.offer);
|
const offer = parse(command.offer);
|
||||||
const remoteIceCandidates = parse(command.iceCandidates);
|
const remoteIceCandidates = parse(command.iceCandidates);
|
||||||
const { media, aesKey, iceServers, relay } = command;
|
const { media, aesKey, useWorker, iceServers, relay } = command;
|
||||||
activeCall = await initializeCall(getCallConfig(!!aesKey, iceServers, relay), media, aesKey);
|
activeCall = await initializeCall(getCallConfig(!!aesKey, iceServers, relay), media, aesKey, useWorker);
|
||||||
const pc = activeCall.connection;
|
const pc = activeCall.connection;
|
||||||
// console.log("offer remoteIceCandidates", JSON.stringify(remoteIceCandidates))
|
|
||||||
await pc.setRemoteDescription(new RTCSessionDescription(offer));
|
await pc.setRemoteDescription(new RTCSessionDescription(offer));
|
||||||
const answer = await pc.createAnswer();
|
const answer = await pc.createAnswer();
|
||||||
await pc.setLocalDescription(answer);
|
await pc.setLocalDescription(answer);
|
||||||
@@ -262,7 +236,6 @@ const processCommand = (function () {
|
|||||||
iceCandidates: await activeCall.iceCandidates,
|
iceCandidates: await activeCall.iceCandidates,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
// console.log("answer response", JSON.stringify(resp))
|
|
||||||
break;
|
break;
|
||||||
case "answer":
|
case "answer":
|
||||||
if (!pc) {
|
if (!pc) {
|
||||||
@@ -277,7 +250,6 @@ const processCommand = (function () {
|
|||||||
else {
|
else {
|
||||||
const answer = parse(command.answer);
|
const answer = parse(command.answer);
|
||||||
const remoteIceCandidates = parse(command.iceCandidates);
|
const remoteIceCandidates = parse(command.iceCandidates);
|
||||||
// console.log("answer remoteIceCandidates", JSON.stringify(remoteIceCandidates))
|
|
||||||
await pc.setRemoteDescription(new RTCSessionDescription(answer));
|
await pc.setRemoteDescription(new RTCSessionDescription(answer));
|
||||||
addIceCandidates(pc, remoteIceCandidates);
|
addIceCandidates(pc, remoteIceCandidates);
|
||||||
resp = { type: "ok" };
|
resp = { type: "ok" };
|
||||||
@@ -314,11 +286,6 @@ const processCommand = (function () {
|
|||||||
resp = { type: "ok" };
|
resp = { type: "ok" };
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
case "description":
|
|
||||||
localizedState = command.state;
|
|
||||||
localizedDescription = command.description;
|
|
||||||
resp = { type: "ok" };
|
|
||||||
break;
|
|
||||||
case "end":
|
case "end":
|
||||||
endCall();
|
endCall();
|
||||||
resp = { type: "ok" };
|
resp = { type: "ok" };
|
||||||
@@ -343,14 +310,12 @@ const processCommand = (function () {
|
|||||||
catch (e) {
|
catch (e) {
|
||||||
console.log(e);
|
console.log(e);
|
||||||
}
|
}
|
||||||
shutdownCameraAndMic();
|
|
||||||
activeCall = undefined;
|
activeCall = undefined;
|
||||||
resetVideoElements();
|
resetVideoElements();
|
||||||
}
|
}
|
||||||
function addIceCandidates(conn, iceCandidates) {
|
function addIceCandidates(conn, iceCandidates) {
|
||||||
for (const c of iceCandidates) {
|
for (const c of iceCandidates) {
|
||||||
conn.addIceCandidate(new RTCIceCandidate(c));
|
conn.addIceCandidate(new RTCIceCandidate(c));
|
||||||
// console.log("addIceCandidates", JSON.stringify(c))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
async function setupMediaStreams(call) {
|
async function setupMediaStreams(call) {
|
||||||
@@ -370,11 +335,11 @@ const processCommand = (function () {
|
|||||||
if (call.aesKey) {
|
if (call.aesKey) {
|
||||||
if (!call.key)
|
if (!call.key)
|
||||||
call.key = await callCrypto.decodeAesKey(call.aesKey);
|
call.key = await callCrypto.decodeAesKey(call.aesKey);
|
||||||
if (useWorker && !call.worker) {
|
if (call.useWorker && !call.worker) {
|
||||||
const workerCode = `const callCrypto = (${callCryptoFunction.toString()})(); (${workerFunction.toString()})()`;
|
const workerCode = `const callCrypto = (${callCryptoFunction.toString()})(); (${workerFunction.toString()})()`;
|
||||||
call.worker = new Worker(URL.createObjectURL(new Blob([workerCode], { type: "text/javascript" })));
|
call.worker = new Worker(URL.createObjectURL(new Blob([workerCode], { type: "text/javascript" })));
|
||||||
call.worker.onerror = ({ error, filename, lineno, message }) => console.log({ error, filename, lineno, message });
|
call.worker.onerror = ({ error, filename, lineno, message }) => console.log(JSON.stringify({ error, filename, lineno, message }));
|
||||||
// call.worker.onmessage = ({data}) => console.log(JSON.stringify({message: data}))
|
call.worker.onmessage = ({ data }) => console.log(JSON.stringify({ message: data }));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -448,31 +413,12 @@ const processCommand = (function () {
|
|||||||
if (!videos)
|
if (!videos)
|
||||||
throw Error("no video elements");
|
throw Error("no video elements");
|
||||||
const pc = call.connection;
|
const pc = call.connection;
|
||||||
const oldAudioTracks = call.localStream.getAudioTracks();
|
|
||||||
const audioWasEnabled = oldAudioTracks.some((elem) => elem.enabled);
|
|
||||||
let localStream;
|
|
||||||
try {
|
|
||||||
localStream = call.screenShareEnabled ? await getLocalScreenCaptureStream() : await getLocalMediaStream(call.localMedia, camera);
|
|
||||||
}
|
|
||||||
catch (e) {
|
|
||||||
if (call.screenShareEnabled) {
|
|
||||||
call.screenShareEnabled = false;
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
for (const t of call.localStream.getTracks())
|
for (const t of call.localStream.getTracks())
|
||||||
t.stop();
|
t.stop();
|
||||||
call.localCamera = camera;
|
call.localCamera = camera;
|
||||||
const audioTracks = localStream.getAudioTracks();
|
const localStream = await getLocalMediaStream(call.localMedia, camera);
|
||||||
const videoTracks = localStream.getVideoTracks();
|
replaceTracks(pc, localStream.getVideoTracks());
|
||||||
if (!audioWasEnabled && oldAudioTracks.length > 0) {
|
replaceTracks(pc, localStream.getAudioTracks());
|
||||||
audioTracks.forEach((elem) => (elem.enabled = false));
|
|
||||||
}
|
|
||||||
if (!call.cameraEnabled && !call.screenShareEnabled) {
|
|
||||||
videoTracks.forEach((elem) => (elem.enabled = false));
|
|
||||||
}
|
|
||||||
replaceTracks(pc, audioTracks);
|
|
||||||
replaceTracks(pc, videoTracks);
|
|
||||||
call.localStream = localStream;
|
call.localStream = localStream;
|
||||||
videos.local.srcObject = localStream;
|
videos.local.srcObject = localStream;
|
||||||
}
|
}
|
||||||
@@ -509,21 +455,6 @@ const processCommand = (function () {
|
|||||||
const constraints = callMediaConstraints(mediaType, facingMode);
|
const constraints = callMediaConstraints(mediaType, facingMode);
|
||||||
return navigator.mediaDevices.getUserMedia(constraints);
|
return navigator.mediaDevices.getUserMedia(constraints);
|
||||||
}
|
}
|
||||||
function getLocalScreenCaptureStream() {
|
|
||||||
const constraints /* DisplayMediaStreamConstraints */ = {
|
|
||||||
video: {
|
|
||||||
frameRate: 24,
|
|
||||||
//width: {
|
|
||||||
//min: 480,
|
|
||||||
//ideal: 720,
|
|
||||||
//max: 1280,
|
|
||||||
//},
|
|
||||||
//aspectRatio: 1.33,
|
|
||||||
},
|
|
||||||
audio: true,
|
|
||||||
};
|
|
||||||
return navigator.mediaDevices.getDisplayMedia(constraints);
|
|
||||||
}
|
|
||||||
function callMediaConstraints(mediaType, facingMode) {
|
function callMediaConstraints(mediaType, facingMode) {
|
||||||
switch (mediaType) {
|
switch (mediaType) {
|
||||||
case CallMediaType.Audio:
|
case CallMediaType.Audio:
|
||||||
@@ -548,11 +479,6 @@ const processCommand = (function () {
|
|||||||
return (("createEncodedStreams" in RTCRtpSender.prototype && "createEncodedStreams" in RTCRtpReceiver.prototype) ||
|
return (("createEncodedStreams" in RTCRtpSender.prototype && "createEncodedStreams" in RTCRtpReceiver.prototype) ||
|
||||||
(!!useWorker && "RTCRtpScriptTransform" in window));
|
(!!useWorker && "RTCRtpScriptTransform" in window));
|
||||||
}
|
}
|
||||||
function shutdownCameraAndMic() {
|
|
||||||
if (activeCall === null || activeCall === void 0 ? void 0 : activeCall.localStream) {
|
|
||||||
activeCall.localStream.getTracks().forEach((track) => track.stop());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
function resetVideoElements() {
|
function resetVideoElements() {
|
||||||
const videos = getVideoElements();
|
const videos = getVideoElements();
|
||||||
if (!videos)
|
if (!videos)
|
||||||
@@ -578,35 +504,9 @@ const processCommand = (function () {
|
|||||||
const tracks = media == CallMediaType.Video ? s.getVideoTracks() : s.getAudioTracks();
|
const tracks = media == CallMediaType.Video ? s.getVideoTracks() : s.getAudioTracks();
|
||||||
for (const t of tracks)
|
for (const t of tracks)
|
||||||
t.enabled = enable;
|
t.enabled = enable;
|
||||||
if (media == CallMediaType.Video && activeCall) {
|
|
||||||
activeCall.cameraEnabled = enable;
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
toggleScreenShare = async function () {
|
|
||||||
const call = activeCall;
|
|
||||||
if (!call)
|
|
||||||
return;
|
|
||||||
call.screenShareEnabled = !call.screenShareEnabled;
|
|
||||||
await replaceMedia(call, call.localCamera);
|
|
||||||
};
|
|
||||||
return processCommand;
|
return processCommand;
|
||||||
})();
|
})();
|
||||||
function toggleRemoteVideoFitFill() {
|
|
||||||
const remote = document.getElementById("remote-video-stream");
|
|
||||||
remote.style.objectFit = remote.style.objectFit != "contain" ? "contain" : "cover";
|
|
||||||
}
|
|
||||||
function toggleMedia(s, media) {
|
|
||||||
let res = false;
|
|
||||||
const tracks = media == CallMediaType.Video ? s.getVideoTracks() : s.getAudioTracks();
|
|
||||||
for (const t of tracks) {
|
|
||||||
t.enabled = !t.enabled;
|
|
||||||
res = t.enabled;
|
|
||||||
}
|
|
||||||
if (media == CallMediaType.Video && activeCall) {
|
|
||||||
activeCall.cameraEnabled = res;
|
|
||||||
}
|
|
||||||
return res;
|
|
||||||
}
|
|
||||||
// Cryptography function - it is loaded both in the main window and in worker context (if the worker is used)
|
// Cryptography function - it is loaded both in the main window and in worker context (if the worker is used)
|
||||||
function callCryptoFunction() {
|
function callCryptoFunction() {
|
||||||
const initialPlainTextRequired = {
|
const initialPlainTextRequired = {
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
#include <jni.h>
|
||||||
|
|
||||||
|
// from the RTS
|
||||||
|
void hs_init(int * argc, char **argv[]);
|
||||||
|
|
||||||
|
// from android-support
|
||||||
|
void setLineBuffering(void);
|
||||||
|
int pipe_std_to_socket(const char * name);
|
||||||
|
|
||||||
|
extern void __svfscanf(void){};
|
||||||
|
extern void __vfwscanf(void){};
|
||||||
|
extern void __memset_chk_fail(void){};
|
||||||
|
extern void __strcpy_chk_generic(void){};
|
||||||
|
extern void __strcat_chk_generic(void){};
|
||||||
|
extern void __libc_globals(void){};
|
||||||
|
extern void __rel_iplt_start(void){};
|
||||||
|
|
||||||
|
// Android 9 only, not 13
|
||||||
|
extern void reallocarray(void){};
|
||||||
|
|
||||||
|
JNIEXPORT jint JNICALL
|
||||||
|
Java_chat_simplex_app_SimplexAppKt_pipeStdOutToSocket(JNIEnv *env, __unused jclass clazz, jstring socket_name) {
|
||||||
|
const char *name = (*env)->GetStringUTFChars(env, socket_name, JNI_FALSE);
|
||||||
|
int ret = pipe_std_to_socket(name);
|
||||||
|
(*env)->ReleaseStringUTFChars(env, socket_name, name);
|
||||||
|
return ret;
|
||||||
|
}
|
||||||
|
|
||||||
|
JNIEXPORT void JNICALL
|
||||||
|
Java_chat_simplex_app_SimplexAppKt_initHS(__unused JNIEnv *env, __unused jclass clazz) {
|
||||||
|
hs_init(NULL, NULL);
|
||||||
|
setLineBuffering();
|
||||||
|
}
|
||||||
|
|
||||||
|
// from simplex-chat
|
||||||
|
typedef long* chat_ctrl;
|
||||||
|
|
||||||
|
extern char *chat_migrate_init(const char *path, const char *key, const char *confirm, chat_ctrl *ctrl);
|
||||||
|
extern char *chat_send_cmd(chat_ctrl ctrl, const char *cmd);
|
||||||
|
extern char *chat_recv_msg(chat_ctrl ctrl); // deprecated
|
||||||
|
extern char *chat_recv_msg_wait(chat_ctrl ctrl, const int wait);
|
||||||
|
extern char *chat_parse_markdown(const char *str);
|
||||||
|
extern char *chat_parse_server(const char *str);
|
||||||
|
extern char *chat_password_hash(const char *pwd, const char *salt);
|
||||||
|
|
||||||
|
JNIEXPORT jobjectArray JNICALL
|
||||||
|
Java_chat_simplex_app_SimplexAppKt_chatMigrateInit(JNIEnv *env, __unused jclass clazz, jstring dbPath, jstring dbKey, jstring confirm) {
|
||||||
|
const char *_dbPath = (*env)->GetStringUTFChars(env, dbPath, JNI_FALSE);
|
||||||
|
const char *_dbKey = (*env)->GetStringUTFChars(env, dbKey, JNI_FALSE);
|
||||||
|
const char *_confirm = (*env)->GetStringUTFChars(env, confirm, JNI_FALSE);
|
||||||
|
jlong _ctrl = (jlong) 0;
|
||||||
|
jstring res = (*env)->NewStringUTF(env, chat_migrate_init(_dbPath, _dbKey, _confirm, &_ctrl));
|
||||||
|
(*env)->ReleaseStringUTFChars(env, dbPath, _dbPath);
|
||||||
|
(*env)->ReleaseStringUTFChars(env, dbKey, _dbKey);
|
||||||
|
(*env)->ReleaseStringUTFChars(env, dbKey, _confirm);
|
||||||
|
|
||||||
|
// Creating array of Object's (boxed values can be passed, eg. Long instead of long)
|
||||||
|
jobjectArray ret = (jobjectArray)(*env)->NewObjectArray(env, 2, (*env)->FindClass(env, "java/lang/Object"), NULL);
|
||||||
|
// Java's String
|
||||||
|
(*env)->SetObjectArrayElement(env, ret, 0, res);
|
||||||
|
// Java's Long
|
||||||
|
(*env)->SetObjectArrayElement(env, ret, 1,
|
||||||
|
(*env)->NewObject(env, (*env)->FindClass(env, "java/lang/Long"),
|
||||||
|
(*env)->GetMethodID(env, (*env)->FindClass(env, "java/lang/Long"), "<init>", "(J)V"),
|
||||||
|
_ctrl));
|
||||||
|
return ret;
|
||||||
|
}
|
||||||
|
|
||||||
|
JNIEXPORT jstring JNICALL
|
||||||
|
Java_chat_simplex_app_SimplexAppKt_chatSendCmd(JNIEnv *env, __unused jclass clazz, jlong controller, jstring msg) {
|
||||||
|
const char *_msg = (*env)->GetStringUTFChars(env, msg, JNI_FALSE);
|
||||||
|
jstring res = (*env)->NewStringUTF(env, chat_send_cmd((void*)controller, _msg));
|
||||||
|
(*env)->ReleaseStringUTFChars(env, msg, _msg);
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
|
||||||
|
JNIEXPORT jstring JNICALL
|
||||||
|
Java_chat_simplex_app_SimplexAppKt_chatRecvMsg(JNIEnv *env, __unused jclass clazz, jlong controller) {
|
||||||
|
return (*env)->NewStringUTF(env, chat_recv_msg((void*)controller));
|
||||||
|
}
|
||||||
|
|
||||||
|
JNIEXPORT jstring JNICALL
|
||||||
|
Java_chat_simplex_app_SimplexAppKt_chatRecvMsgWait(JNIEnv *env, __unused jclass clazz, jlong controller, jint wait) {
|
||||||
|
return (*env)->NewStringUTF(env, chat_recv_msg_wait((void*)controller, wait));
|
||||||
|
}
|
||||||
|
|
||||||
|
JNIEXPORT jstring JNICALL
|
||||||
|
Java_chat_simplex_app_SimplexAppKt_chatParseMarkdown(JNIEnv *env, __unused jclass clazz, jstring str) {
|
||||||
|
const char *_str = (*env)->GetStringUTFChars(env, str, JNI_FALSE);
|
||||||
|
jstring res = (*env)->NewStringUTF(env, chat_parse_markdown(_str));
|
||||||
|
(*env)->ReleaseStringUTFChars(env, str, _str);
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
|
||||||
|
JNIEXPORT jstring JNICALL
|
||||||
|
Java_chat_simplex_app_SimplexAppKt_chatParseServer(JNIEnv *env, __unused jclass clazz, jstring str) {
|
||||||
|
const char *_str = (*env)->GetStringUTFChars(env, str, JNI_FALSE);
|
||||||
|
jstring res = (*env)->NewStringUTF(env, chat_parse_server(_str));
|
||||||
|
(*env)->ReleaseStringUTFChars(env, str, _str);
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
|
||||||
|
JNIEXPORT jstring JNICALL
|
||||||
|
Java_chat_simplex_app_SimplexAppKt_chatPasswordHash(JNIEnv *env, __unused jclass clazz, jstring pwd, jstring salt) {
|
||||||
|
const char *_pwd = (*env)->GetStringUTFChars(env, pwd, JNI_FALSE);
|
||||||
|
const char *_salt = (*env)->GetStringUTFChars(env, salt, JNI_FALSE);
|
||||||
|
jstring res = (*env)->NewStringUTF(env, chat_password_hash(_pwd, _salt));
|
||||||
|
(*env)->ReleaseStringUTFChars(env, pwd, _pwd);
|
||||||
|
(*env)->ReleaseStringUTFChars(env, salt, _salt);
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
Before Width: | Height: | Size: 34 KiB After Width: | Height: | Size: 34 KiB |
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 32 KiB |
@@ -0,0 +1,18 @@
|
|||||||
|
package chat.simplex.app
|
||||||
|
|
||||||
|
import android.app.backup.BackupAgentHelper
|
||||||
|
import android.app.backup.FullBackupDataOutput
|
||||||
|
import android.content.Context
|
||||||
|
import chat.simplex.app.model.AppPreferences
|
||||||
|
import chat.simplex.app.model.AppPreferences.Companion.SHARED_PREFS_PRIVACY_FULL_BACKUP
|
||||||
|
|
||||||
|
class BackupAgent: BackupAgentHelper() {
|
||||||
|
override fun onFullBackup(data: FullBackupDataOutput?) {
|
||||||
|
if (applicationContext
|
||||||
|
.getSharedPreferences(AppPreferences.SHARED_PREFS_ID, Context.MODE_PRIVATE)
|
||||||
|
.getBoolean(SHARED_PREFS_PRIVACY_FULL_BACKUP, true)
|
||||||
|
) {
|
||||||
|
super.onFullBackup(data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,666 @@
|
|||||||
|
package chat.simplex.app
|
||||||
|
|
||||||
|
import android.app.Application
|
||||||
|
import android.content.Intent
|
||||||
|
import android.net.Uri
|
||||||
|
import android.os.*
|
||||||
|
import android.os.SystemClock.elapsedRealtime
|
||||||
|
import android.util.Log
|
||||||
|
import android.view.WindowManager
|
||||||
|
import androidx.activity.compose.setContent
|
||||||
|
import androidx.activity.viewModels
|
||||||
|
import androidx.compose.animation.core.*
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.material.*
|
||||||
|
import androidx.compose.runtime.*
|
||||||
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.graphicsLayer
|
||||||
|
import androidx.compose.ui.res.painterResource
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.fragment.app.FragmentActivity
|
||||||
|
import androidx.lifecycle.*
|
||||||
|
import chat.simplex.app.MainActivity.Companion.enteredBackground
|
||||||
|
import chat.simplex.app.model.*
|
||||||
|
import chat.simplex.app.model.NtfManager.Companion.getUserIdFromIntent
|
||||||
|
import chat.simplex.app.ui.theme.*
|
||||||
|
import chat.simplex.app.views.SplashView
|
||||||
|
import chat.simplex.app.views.call.ActiveCallView
|
||||||
|
import chat.simplex.app.views.call.IncomingCallAlertView
|
||||||
|
import chat.simplex.app.views.chat.ChatView
|
||||||
|
import chat.simplex.app.views.chatlist.*
|
||||||
|
import chat.simplex.app.views.database.DatabaseErrorView
|
||||||
|
import chat.simplex.app.views.helpers.*
|
||||||
|
import chat.simplex.app.views.helpers.DatabaseUtils.ksAppPassword
|
||||||
|
import chat.simplex.app.views.localauth.SetAppPasscodeView
|
||||||
|
import chat.simplex.app.views.newchat.*
|
||||||
|
import chat.simplex.app.views.onboarding.*
|
||||||
|
import chat.simplex.app.views.usersettings.LAMode
|
||||||
|
import kotlinx.coroutines.*
|
||||||
|
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||||
|
|
||||||
|
class MainActivity: FragmentActivity() {
|
||||||
|
companion object {
|
||||||
|
/**
|
||||||
|
* We don't want these values to be bound to Activity lifecycle since activities are changed often, for example, when a user
|
||||||
|
* clicks on new message in notification. In this case savedInstanceState will be null (this prevents restoring the values)
|
||||||
|
* See [SimplexService.onTaskRemoved] for another part of the logic which nullifies the values when app closed by the user
|
||||||
|
* */
|
||||||
|
val userAuthorized = mutableStateOf<Boolean?>(null)
|
||||||
|
val enteredBackground = mutableStateOf<Long?>(null)
|
||||||
|
// Remember result and show it after orientation change
|
||||||
|
private val laFailed = mutableStateOf(false)
|
||||||
|
|
||||||
|
fun clearAuthState() {
|
||||||
|
userAuthorized.value = null
|
||||||
|
enteredBackground.value = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
private val vm by viewModels<SimplexViewModel>()
|
||||||
|
|
||||||
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
|
super.onCreate(savedInstanceState)
|
||||||
|
// testJson()
|
||||||
|
val m = vm.chatModel
|
||||||
|
applyAppLocale(m.controller.appPrefs.appLanguage)
|
||||||
|
// When call ended and orientation changes, it re-process old intent, it's unneeded.
|
||||||
|
// Only needed to be processed on first creation of activity
|
||||||
|
if (savedInstanceState == null) {
|
||||||
|
processNotificationIntent(intent, m)
|
||||||
|
processIntent(intent, m)
|
||||||
|
processExternalIntent(intent, m)
|
||||||
|
}
|
||||||
|
if (m.controller.appPrefs.privacyProtectScreen.get()) {
|
||||||
|
Log.d(TAG, "onCreate: set FLAG_SECURE")
|
||||||
|
window.setFlags(
|
||||||
|
WindowManager.LayoutParams.FLAG_SECURE,
|
||||||
|
WindowManager.LayoutParams.FLAG_SECURE
|
||||||
|
)
|
||||||
|
}
|
||||||
|
setContent {
|
||||||
|
SimpleXTheme {
|
||||||
|
MainPage(
|
||||||
|
m,
|
||||||
|
userAuthorized,
|
||||||
|
laFailed,
|
||||||
|
::runAuthenticate,
|
||||||
|
::setPerformLA,
|
||||||
|
showLANotice = { showLANotice(m.controller.appPrefs.laNoticeShown, this) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
SimplexApp.context.schedulePeriodicServiceRestartWorker()
|
||||||
|
SimplexApp.context.schedulePeriodicWakeUp()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onNewIntent(intent: Intent?) {
|
||||||
|
super.onNewIntent(intent)
|
||||||
|
processIntent(intent, vm.chatModel)
|
||||||
|
processExternalIntent(intent, vm.chatModel)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onResume() {
|
||||||
|
super.onResume()
|
||||||
|
val enteredBackgroundVal = enteredBackground.value
|
||||||
|
val delay = vm.chatModel.controller.appPrefs.laLockDelay.get()
|
||||||
|
if (enteredBackgroundVal == null || elapsedRealtime() - enteredBackgroundVal >= delay * 1000) {
|
||||||
|
runAuthenticate()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onPause() {
|
||||||
|
super.onPause()
|
||||||
|
/**
|
||||||
|
* When new activity is created after a click on notification, the old one receives onPause before
|
||||||
|
* recreation but receives onStop after recreation. So using both (onPause and onStop) to prevent
|
||||||
|
* unwanted multiple auth dialogs from [runAuthenticate]
|
||||||
|
* */
|
||||||
|
enteredBackground.value = elapsedRealtime()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onStop() {
|
||||||
|
super.onStop()
|
||||||
|
VideoPlayer.stopAll()
|
||||||
|
enteredBackground.value = elapsedRealtime()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onBackPressed() {
|
||||||
|
if (
|
||||||
|
onBackPressedDispatcher.hasEnabledCallbacks() // Has something to do in a backstack
|
||||||
|
|| Build.VERSION.SDK_INT >= Build.VERSION_CODES.R // Android 11 or above
|
||||||
|
|| isTaskRoot // there are still other tasks after we reach the main (home) activity
|
||||||
|
) {
|
||||||
|
// https://medium.com/mobile-app-development-publication/the-risk-of-android-strandhogg-security-issue-and-how-it-can-be-mitigated-80d2ddb4af06
|
||||||
|
super.onBackPressed()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!onBackPressedDispatcher.hasEnabledCallbacks() && vm.chatModel.controller.appPrefs.performLA.get()) {
|
||||||
|
// When pressed Back and there is no one wants to process the back event, clear auth state to force re-auth on launch
|
||||||
|
clearAuthState()
|
||||||
|
laFailed.value = true
|
||||||
|
}
|
||||||
|
if (!onBackPressedDispatcher.hasEnabledCallbacks()) {
|
||||||
|
// Drop shared content
|
||||||
|
SimplexApp.context.chatModel.sharedContent.value = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun runAuthenticate() {
|
||||||
|
val m = vm.chatModel
|
||||||
|
if (!m.controller.appPrefs.performLA.get()) {
|
||||||
|
userAuthorized.value = true
|
||||||
|
} else {
|
||||||
|
userAuthorized.value = false
|
||||||
|
ModalManager.shared.closeModals()
|
||||||
|
// To make Main thread free in order to allow to Compose to show blank view that hiding content underneath of it faster on slow devices
|
||||||
|
CoroutineScope(Dispatchers.Default).launch {
|
||||||
|
delay(50)
|
||||||
|
withContext(Dispatchers.Main) {
|
||||||
|
authenticate(
|
||||||
|
if (m.controller.appPrefs.laMode.get() == LAMode.SYSTEM)
|
||||||
|
generalGetString(R.string.auth_unlock)
|
||||||
|
else
|
||||||
|
generalGetString(R.string.la_enter_app_passcode),
|
||||||
|
if (m.controller.appPrefs.laMode.get() == LAMode.SYSTEM)
|
||||||
|
generalGetString(R.string.auth_log_in_using_credential)
|
||||||
|
else
|
||||||
|
generalGetString(R.string.auth_unlock),
|
||||||
|
this@MainActivity,
|
||||||
|
completed = { laResult ->
|
||||||
|
when (laResult) {
|
||||||
|
LAResult.Success ->
|
||||||
|
userAuthorized.value = true
|
||||||
|
is LAResult.Failed -> { /* Can be called multiple times on every failure */ }
|
||||||
|
is LAResult.Error -> {
|
||||||
|
laFailed.value = true
|
||||||
|
if (m.controller.appPrefs.laMode.get() == LAMode.PASSCODE) {
|
||||||
|
laFailedAlert()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
is LAResult.Unavailable -> {
|
||||||
|
userAuthorized.value = true
|
||||||
|
m.performLA.value = false
|
||||||
|
m.controller.appPrefs.performLA.set(false)
|
||||||
|
laUnavailableTurningOffAlert()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun showLANotice(laNoticeShown: SharedPreference<Boolean>, activity: FragmentActivity) {
|
||||||
|
Log.d(TAG, "showLANotice")
|
||||||
|
if (!laNoticeShown.get()) {
|
||||||
|
laNoticeShown.set(true)
|
||||||
|
AlertManager.shared.showAlertDialog(
|
||||||
|
title = generalGetString(R.string.la_notice_title_simplex_lock),
|
||||||
|
text = generalGetString(R.string.la_notice_to_protect_your_information_turn_on_simplex_lock_you_will_be_prompted_to_complete_authentication_before_this_feature_is_enabled),
|
||||||
|
confirmText = generalGetString(R.string.la_notice_turn_on),
|
||||||
|
onConfirm = {
|
||||||
|
withBGApi { // to remove this call, change ordering of onConfirm call in AlertManager
|
||||||
|
showChooseLAMode(laNoticeShown, activity)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun showChooseLAMode(laNoticeShown: SharedPreference<Boolean>, activity: FragmentActivity) {
|
||||||
|
Log.d(TAG, "showLANotice")
|
||||||
|
laNoticeShown.set(true)
|
||||||
|
AlertManager.shared.showAlertDialogStacked(
|
||||||
|
title = generalGetString(R.string.la_lock_mode),
|
||||||
|
text = null,
|
||||||
|
confirmText = generalGetString(R.string.la_lock_mode_passcode),
|
||||||
|
dismissText = generalGetString(R.string.la_lock_mode_system),
|
||||||
|
onConfirm = {
|
||||||
|
AlertManager.shared.hideAlert()
|
||||||
|
setPasscode()
|
||||||
|
},
|
||||||
|
onDismiss = {
|
||||||
|
AlertManager.shared.hideAlert()
|
||||||
|
initialEnableLA(activity)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun initialEnableLA(activity: FragmentActivity) {
|
||||||
|
val m = vm.chatModel
|
||||||
|
val appPrefs = m.controller.appPrefs
|
||||||
|
m.controller.appPrefs.laMode.set(LAMode.SYSTEM)
|
||||||
|
authenticate(
|
||||||
|
generalGetString(R.string.auth_enable_simplex_lock),
|
||||||
|
generalGetString(R.string.auth_confirm_credential),
|
||||||
|
activity,
|
||||||
|
completed = { laResult ->
|
||||||
|
when (laResult) {
|
||||||
|
LAResult.Success -> {
|
||||||
|
m.performLA.value = true
|
||||||
|
appPrefs.performLA.set(true)
|
||||||
|
laTurnedOnAlert()
|
||||||
|
}
|
||||||
|
is LAResult.Failed -> { /* Can be called multiple times on every failure */ }
|
||||||
|
is LAResult.Error -> {
|
||||||
|
m.performLA.value = false
|
||||||
|
appPrefs.performLA.set(false)
|
||||||
|
laFailedAlert()
|
||||||
|
}
|
||||||
|
is LAResult.Unavailable -> {
|
||||||
|
m.performLA.value = false
|
||||||
|
appPrefs.performLA.set(false)
|
||||||
|
m.showAdvertiseLAUnavailableAlert.value = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun setPasscode() {
|
||||||
|
val chatModel = vm.chatModel
|
||||||
|
val appPrefs = chatModel.controller.appPrefs
|
||||||
|
ModalManager.shared.showCustomModal { close ->
|
||||||
|
Surface(Modifier.fillMaxSize(), color = MaterialTheme.colors.background) {
|
||||||
|
SetAppPasscodeView(
|
||||||
|
submit = {
|
||||||
|
chatModel.performLA.value = true
|
||||||
|
appPrefs.performLA.set(true)
|
||||||
|
appPrefs.laMode.set(LAMode.PASSCODE)
|
||||||
|
laTurnedOnAlert()
|
||||||
|
},
|
||||||
|
cancel = {
|
||||||
|
chatModel.performLA.value = false
|
||||||
|
appPrefs.performLA.set(false)
|
||||||
|
laPasscodeNotSetAlert()
|
||||||
|
},
|
||||||
|
close)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun setPerformLA(on: Boolean, activity: FragmentActivity) {
|
||||||
|
vm.chatModel.controller.appPrefs.laNoticeShown.set(true)
|
||||||
|
if (on) {
|
||||||
|
enableLA(activity)
|
||||||
|
} else {
|
||||||
|
disableLA(activity)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun enableLA(activity: FragmentActivity) {
|
||||||
|
val m = vm.chatModel
|
||||||
|
authenticate(
|
||||||
|
if (m.controller.appPrefs.laMode.get() == LAMode.SYSTEM)
|
||||||
|
generalGetString(R.string.auth_enable_simplex_lock)
|
||||||
|
else
|
||||||
|
generalGetString(R.string.new_passcode),
|
||||||
|
if (m.controller.appPrefs.laMode.get() == LAMode.SYSTEM)
|
||||||
|
generalGetString(R.string.auth_confirm_credential)
|
||||||
|
else
|
||||||
|
"",
|
||||||
|
activity,
|
||||||
|
completed = { laResult ->
|
||||||
|
val prefPerformLA = m.controller.appPrefs.performLA
|
||||||
|
when (laResult) {
|
||||||
|
LAResult.Success -> {
|
||||||
|
m.performLA.value = true
|
||||||
|
prefPerformLA.set(true)
|
||||||
|
laTurnedOnAlert()
|
||||||
|
}
|
||||||
|
is LAResult.Failed -> { /* Can be called multiple times on every failure */ }
|
||||||
|
is LAResult.Error -> {
|
||||||
|
m.performLA.value = false
|
||||||
|
prefPerformLA.set(false)
|
||||||
|
laFailedAlert()
|
||||||
|
}
|
||||||
|
is LAResult.Unavailable -> {
|
||||||
|
m.performLA.value = false
|
||||||
|
prefPerformLA.set(false)
|
||||||
|
laUnavailableInstructionAlert()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun disableLA(activity: FragmentActivity) {
|
||||||
|
val m = vm.chatModel
|
||||||
|
authenticate(
|
||||||
|
if (m.controller.appPrefs.laMode.get() == LAMode.SYSTEM)
|
||||||
|
generalGetString(R.string.auth_disable_simplex_lock)
|
||||||
|
else
|
||||||
|
generalGetString(R.string.la_enter_app_passcode),
|
||||||
|
if (m.controller.appPrefs.laMode.get() == LAMode.SYSTEM)
|
||||||
|
generalGetString(R.string.auth_confirm_credential)
|
||||||
|
else
|
||||||
|
generalGetString(R.string.auth_disable_simplex_lock),
|
||||||
|
activity,
|
||||||
|
completed = { laResult ->
|
||||||
|
val prefPerformLA = m.controller.appPrefs.performLA
|
||||||
|
when (laResult) {
|
||||||
|
LAResult.Success -> {
|
||||||
|
m.performLA.value = false
|
||||||
|
prefPerformLA.set(false)
|
||||||
|
ksAppPassword.remove()
|
||||||
|
}
|
||||||
|
is LAResult.Failed -> { /* Can be called multiple times on every failure */ }
|
||||||
|
is LAResult.Error -> {
|
||||||
|
m.performLA.value = true
|
||||||
|
prefPerformLA.set(true)
|
||||||
|
laFailedAlert()
|
||||||
|
}
|
||||||
|
is LAResult.Unavailable -> {
|
||||||
|
m.performLA.value = false
|
||||||
|
prefPerformLA.set(false)
|
||||||
|
laUnavailableTurningOffAlert()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class SimplexViewModel(application: Application): AndroidViewModel(application) {
|
||||||
|
val app = getApplication<SimplexApp>()
|
||||||
|
val chatModel = app.chatModel
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun MainPage(
|
||||||
|
chatModel: ChatModel,
|
||||||
|
userAuthorized: MutableState<Boolean?>,
|
||||||
|
laFailed: MutableState<Boolean>,
|
||||||
|
runAuthenticate: () -> Unit,
|
||||||
|
setPerformLA: (Boolean, FragmentActivity) -> Unit,
|
||||||
|
showLANotice: () -> Unit
|
||||||
|
) {
|
||||||
|
var showChatDatabaseError by rememberSaveable {
|
||||||
|
mutableStateOf(chatModel.chatDbStatus.value != DBMigrationResult.OK && chatModel.chatDbStatus.value != null)
|
||||||
|
}
|
||||||
|
LaunchedEffect(chatModel.chatDbStatus.value) {
|
||||||
|
showChatDatabaseError = chatModel.chatDbStatus.value != DBMigrationResult.OK && chatModel.chatDbStatus.value != null
|
||||||
|
}
|
||||||
|
|
||||||
|
var showAdvertiseLAAlert by remember { mutableStateOf(false) }
|
||||||
|
LaunchedEffect(showAdvertiseLAAlert) {
|
||||||
|
if (
|
||||||
|
!chatModel.controller.appPrefs.laNoticeShown.get()
|
||||||
|
&& showAdvertiseLAAlert
|
||||||
|
&& chatModel.onboardingStage.value == OnboardingStage.OnboardingComplete
|
||||||
|
&& chatModel.chats.isNotEmpty()
|
||||||
|
&& chatModel.activeCallInvitation.value == null
|
||||||
|
) {
|
||||||
|
showLANotice()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
LaunchedEffect(chatModel.showAdvertiseLAUnavailableAlert.value) {
|
||||||
|
if (chatModel.showAdvertiseLAUnavailableAlert.value) {
|
||||||
|
laUnavailableInstructionAlert()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
LaunchedEffect(chatModel.clearOverlays.value) {
|
||||||
|
if (chatModel.clearOverlays.value) {
|
||||||
|
ModalManager.shared.closeModals()
|
||||||
|
chatModel.clearOverlays.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun authView() {
|
||||||
|
Box(
|
||||||
|
Modifier.fillMaxSize().background(MaterialTheme.colors.background),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
SimpleButton(
|
||||||
|
stringResource(R.string.auth_unlock),
|
||||||
|
icon = painterResource(R.drawable.ic_lock),
|
||||||
|
click = {
|
||||||
|
laFailed.value = false
|
||||||
|
runAuthenticate()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Box {
|
||||||
|
val onboarding = chatModel.onboardingStage.value
|
||||||
|
val userCreated = chatModel.userCreated.value
|
||||||
|
when {
|
||||||
|
showChatDatabaseError -> {
|
||||||
|
chatModel.chatDbStatus.value?.let {
|
||||||
|
DatabaseErrorView(chatModel.chatDbStatus, chatModel.controller.appPrefs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
onboarding == null || userCreated == null -> SplashView()
|
||||||
|
userAuthorized.value != true -> {
|
||||||
|
if (chatModel.controller.appPrefs.performLA.get() && laFailed.value) {
|
||||||
|
authView()
|
||||||
|
} else {
|
||||||
|
SplashView()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
onboarding == OnboardingStage.OnboardingComplete && userCreated -> {
|
||||||
|
Box {
|
||||||
|
if (chatModel.showCallView.value) ActiveCallView(chatModel)
|
||||||
|
else {
|
||||||
|
showAdvertiseLAAlert = true
|
||||||
|
BoxWithConstraints {
|
||||||
|
var currentChatId by rememberSaveable { mutableStateOf(chatModel.chatId.value) }
|
||||||
|
val offset = remember { Animatable(if (chatModel.chatId.value == null) 0f else maxWidth.value) }
|
||||||
|
Box(
|
||||||
|
Modifier
|
||||||
|
.graphicsLayer {
|
||||||
|
translationX = -offset.value.dp.toPx()
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
val stopped = chatModel.chatRunning.value == false
|
||||||
|
if (chatModel.sharedContent.value == null)
|
||||||
|
ChatListView(chatModel, setPerformLA, stopped)
|
||||||
|
else
|
||||||
|
ShareListView(chatModel, stopped)
|
||||||
|
}
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
val onComposed: () -> Unit = {
|
||||||
|
scope.launch {
|
||||||
|
offset.animateTo(
|
||||||
|
if (chatModel.chatId.value == null) 0f else maxWidth.value,
|
||||||
|
chatListAnimationSpec()
|
||||||
|
)
|
||||||
|
if (offset.value == 0f) {
|
||||||
|
currentChatId = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
launch {
|
||||||
|
snapshotFlow { chatModel.chatId.value }
|
||||||
|
.distinctUntilChanged()
|
||||||
|
.collect {
|
||||||
|
if (it != null) currentChatId = it
|
||||||
|
else onComposed()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Box (Modifier.graphicsLayer { translationX = maxWidth.toPx() - offset.value.dp.toPx() }) Box2@ {
|
||||||
|
currentChatId?.let {
|
||||||
|
ChatView(it, chatModel, onComposed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
onboarding == OnboardingStage.Step1_SimpleXInfo -> SimpleXInfo(chatModel, onboarding = true)
|
||||||
|
onboarding == OnboardingStage.Step2_CreateProfile -> CreateProfile(chatModel) {}
|
||||||
|
onboarding == OnboardingStage.Step3_SetNotificationsMode -> SetNotificationsMode(chatModel)
|
||||||
|
}
|
||||||
|
ModalManager.shared.showInView()
|
||||||
|
val invitation = chatModel.activeCallInvitation.value
|
||||||
|
if (invitation != null) IncomingCallAlertView(invitation, chatModel)
|
||||||
|
AlertManager.shared.showInView()
|
||||||
|
}
|
||||||
|
|
||||||
|
DisposableEffectOnRotate {
|
||||||
|
// When using lock delay = 0 and screen rotates, the app will be locked which is not useful.
|
||||||
|
// Let's prolong the unlocked period to 3 sec for screen rotation to take place
|
||||||
|
if (chatModel.controller.appPrefs.laLockDelay.get() == 0) {
|
||||||
|
enteredBackground.value = elapsedRealtime() + 3000
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun processNotificationIntent(intent: Intent?, chatModel: ChatModel) {
|
||||||
|
val userId = getUserIdFromIntent(intent)
|
||||||
|
when (intent?.action) {
|
||||||
|
NtfManager.OpenChatAction -> {
|
||||||
|
val chatId = intent.getStringExtra("chatId")
|
||||||
|
Log.d(TAG, "processNotificationIntent: OpenChatAction $chatId")
|
||||||
|
if (chatId != null) {
|
||||||
|
withBGApi {
|
||||||
|
awaitChatStartedIfNeeded(chatModel)
|
||||||
|
if (userId != null && userId != chatModel.currentUser.value?.userId && chatModel.currentUser.value != null) {
|
||||||
|
chatModel.controller.changeActiveUser(userId, null)
|
||||||
|
}
|
||||||
|
val cInfo = chatModel.getChat(chatId)?.chatInfo
|
||||||
|
chatModel.clearOverlays.value = true
|
||||||
|
if (cInfo != null) openChat(cInfo, chatModel)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
NtfManager.ShowChatsAction -> {
|
||||||
|
Log.d(TAG, "processNotificationIntent: ShowChatsAction")
|
||||||
|
withBGApi {
|
||||||
|
awaitChatStartedIfNeeded(chatModel)
|
||||||
|
if (userId != null && userId != chatModel.currentUser.value?.userId && chatModel.currentUser.value != null) {
|
||||||
|
chatModel.controller.changeActiveUser(userId, null)
|
||||||
|
}
|
||||||
|
chatModel.chatId.value = null
|
||||||
|
chatModel.clearOverlays.value = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
NtfManager.AcceptCallAction -> {
|
||||||
|
val chatId = intent.getStringExtra("chatId")
|
||||||
|
if (chatId == null || chatId == "") return
|
||||||
|
Log.d(TAG, "processNotificationIntent: AcceptCallAction $chatId")
|
||||||
|
chatModel.clearOverlays.value = true
|
||||||
|
val invitation = chatModel.callInvitations[chatId]
|
||||||
|
if (invitation == null) {
|
||||||
|
AlertManager.shared.showAlertMsg(generalGetString(R.string.call_already_ended))
|
||||||
|
} else {
|
||||||
|
chatModel.callManager.acceptIncomingCall(invitation = invitation)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun processIntent(intent: Intent?, chatModel: ChatModel) {
|
||||||
|
when (intent?.action) {
|
||||||
|
"android.intent.action.VIEW" -> {
|
||||||
|
val uri = intent.data
|
||||||
|
if (uri != null) connectIfOpenedViaUri(uri, chatModel)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun processExternalIntent(intent: Intent?, chatModel: ChatModel) {
|
||||||
|
when (intent?.action) {
|
||||||
|
Intent.ACTION_SEND -> {
|
||||||
|
// Close active chat and show a list of chats
|
||||||
|
chatModel.chatId.value = null
|
||||||
|
chatModel.clearOverlays.value = true
|
||||||
|
when {
|
||||||
|
intent.type == "text/plain" -> {
|
||||||
|
val text = intent.getStringExtra(Intent.EXTRA_TEXT)
|
||||||
|
if (text != null) {
|
||||||
|
chatModel.sharedContent.value = SharedContent.Text(text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
isMediaIntent(intent) -> {
|
||||||
|
val uri = intent.getParcelableExtra<Parcelable>(Intent.EXTRA_STREAM) as? Uri
|
||||||
|
if (uri != null) {
|
||||||
|
chatModel.sharedContent.value = SharedContent.Media(intent.getStringExtra(Intent.EXTRA_TEXT) ?: "", listOf(uri))
|
||||||
|
} // All other mime types
|
||||||
|
}
|
||||||
|
else -> {
|
||||||
|
val uri = intent.getParcelableExtra<Parcelable>(Intent.EXTRA_STREAM) as? Uri
|
||||||
|
if (uri != null) {
|
||||||
|
chatModel.sharedContent.value = SharedContent.File(intent.getStringExtra(Intent.EXTRA_TEXT) ?: "", uri)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Intent.ACTION_SEND_MULTIPLE -> {
|
||||||
|
// Close active chat and show a list of chats
|
||||||
|
chatModel.chatId.value = null
|
||||||
|
chatModel.clearOverlays.value = true
|
||||||
|
Log.e(TAG, "ACTION_SEND_MULTIPLE ${intent.type}")
|
||||||
|
when {
|
||||||
|
isMediaIntent(intent) -> {
|
||||||
|
val uris = intent.getParcelableArrayListExtra<Parcelable>(Intent.EXTRA_STREAM) as? List<Uri>
|
||||||
|
if (uris != null) {
|
||||||
|
chatModel.sharedContent.value = SharedContent.Media(intent.getStringExtra(Intent.EXTRA_TEXT) ?: "", uris)
|
||||||
|
} // All other mime types
|
||||||
|
}
|
||||||
|
else -> {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun isMediaIntent(intent: Intent): Boolean =
|
||||||
|
intent.type?.startsWith("image/") == true || intent.type?.startsWith("video/") == true
|
||||||
|
|
||||||
|
fun connectIfOpenedViaUri(uri: Uri, chatModel: ChatModel) {
|
||||||
|
Log.d(TAG, "connectIfOpenedViaUri: opened via link")
|
||||||
|
if (chatModel.currentUser.value == null) {
|
||||||
|
chatModel.appOpenUrl.value = uri
|
||||||
|
} else {
|
||||||
|
withUriAction(uri) { linkType ->
|
||||||
|
val title = when (linkType) {
|
||||||
|
ConnectionLinkType.CONTACT -> generalGetString(R.string.connect_via_contact_link)
|
||||||
|
ConnectionLinkType.INVITATION -> generalGetString(R.string.connect_via_invitation_link)
|
||||||
|
ConnectionLinkType.GROUP -> generalGetString(R.string.connect_via_group_link)
|
||||||
|
}
|
||||||
|
AlertManager.shared.showAlertDialog(
|
||||||
|
title = title,
|
||||||
|
text = if (linkType == ConnectionLinkType.GROUP)
|
||||||
|
generalGetString(R.string.you_will_join_group)
|
||||||
|
else
|
||||||
|
generalGetString(R.string.profile_will_be_sent_to_contact_sending_link),
|
||||||
|
confirmText = generalGetString(R.string.connect_via_link_verb),
|
||||||
|
onConfirm = {
|
||||||
|
withApi {
|
||||||
|
Log.d(TAG, "connectIfOpenedViaUri: connecting")
|
||||||
|
connectViaUri(chatModel, linkType, uri)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun awaitChatStartedIfNeeded(chatModel: ChatModel, timeout: Long = 30_000) {
|
||||||
|
// Still decrypting database
|
||||||
|
if (chatModel.chatRunning.value == null) {
|
||||||
|
val step = 50L
|
||||||
|
for (i in 0..(timeout / step)) {
|
||||||
|
if (chatModel.chatRunning.value == true || chatModel.onboardingStage.value == OnboardingStage.Step1_SimpleXInfo) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
delay(step)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
//fun testJson() {
|
||||||
|
// val str: String = """
|
||||||
|
// """.trimIndent()
|
||||||
|
//
|
||||||
|
// println(json.decodeFromString<APIResponse>(str))
|
||||||
|
//}
|
||||||
@@ -0,0 +1,260 @@
|
|||||||
|
package chat.simplex.app
|
||||||
|
|
||||||
|
import android.app.Application
|
||||||
|
import android.net.LocalServerSocket
|
||||||
|
import android.util.Log
|
||||||
|
import androidx.lifecycle.*
|
||||||
|
import androidx.work.*
|
||||||
|
import chat.simplex.app.model.*
|
||||||
|
import chat.simplex.app.ui.theme.DefaultTheme
|
||||||
|
import chat.simplex.app.views.helpers.*
|
||||||
|
import chat.simplex.app.views.onboarding.OnboardingStage
|
||||||
|
import chat.simplex.app.views.usersettings.NotificationsMode
|
||||||
|
import kotlinx.coroutines.*
|
||||||
|
import kotlinx.serialization.decodeFromString
|
||||||
|
import java.io.*
|
||||||
|
import java.util.*
|
||||||
|
import java.util.concurrent.Semaphore
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
import kotlin.concurrent.thread
|
||||||
|
|
||||||
|
const val TAG = "SIMPLEX"
|
||||||
|
|
||||||
|
// ghc's rts
|
||||||
|
external fun initHS()
|
||||||
|
// android-support
|
||||||
|
external fun pipeStdOutToSocket(socketName: String) : Int
|
||||||
|
|
||||||
|
// SimpleX API
|
||||||
|
typealias ChatCtrl = Long
|
||||||
|
external fun chatMigrateInit(dbPath: String, dbKey: String, confirm: String): Array<Any>
|
||||||
|
external fun chatSendCmd(ctrl: ChatCtrl, msg: String): String
|
||||||
|
external fun chatRecvMsg(ctrl: ChatCtrl): String
|
||||||
|
external fun chatRecvMsgWait(ctrl: ChatCtrl, timeout: Int): String
|
||||||
|
external fun chatParseMarkdown(str: String): String
|
||||||
|
external fun chatParseServer(str: String): String
|
||||||
|
external fun chatPasswordHash(pwd: String, salt: String): String
|
||||||
|
|
||||||
|
class SimplexApp: Application(), LifecycleEventObserver {
|
||||||
|
lateinit var chatController: ChatController
|
||||||
|
|
||||||
|
var isAppOnForeground: Boolean = false
|
||||||
|
|
||||||
|
val defaultLocale: Locale = Locale.getDefault()
|
||||||
|
|
||||||
|
fun initChatController(useKey: String? = null, confirmMigrations: MigrationConfirmation? = null, startChat: Boolean = true) {
|
||||||
|
val dbKey = useKey ?: DatabaseUtils.useDatabaseKey()
|
||||||
|
val dbAbsolutePathPrefix = getFilesDirectory(SimplexApp.context)
|
||||||
|
val confirm = confirmMigrations ?: if (appPreferences.confirmDBUpgrades.get()) MigrationConfirmation.Error else MigrationConfirmation.YesUp
|
||||||
|
val migrated: Array<Any> = chatMigrateInit(dbAbsolutePathPrefix, dbKey, confirm.value)
|
||||||
|
val res: DBMigrationResult = kotlin.runCatching {
|
||||||
|
json.decodeFromString<DBMigrationResult>(migrated[0] as String)
|
||||||
|
}.getOrElse { DBMigrationResult.Unknown(migrated[0] as String) }
|
||||||
|
val ctrl = if (res is DBMigrationResult.OK) {
|
||||||
|
migrated[1] as Long
|
||||||
|
} else null
|
||||||
|
if (::chatController.isInitialized) {
|
||||||
|
chatController.ctrl = ctrl
|
||||||
|
} else {
|
||||||
|
chatController = ChatController(ctrl, ntfManager, applicationContext, appPreferences)
|
||||||
|
}
|
||||||
|
chatModel.chatDbEncrypted.value = dbKey != ""
|
||||||
|
chatModel.chatDbStatus.value = res
|
||||||
|
if (res != DBMigrationResult.OK) {
|
||||||
|
Log.d(TAG, "Unable to migrate successfully: $res")
|
||||||
|
} else if (startChat) {
|
||||||
|
// If we migrated successfully means previous re-encryption process on database level finished successfully too
|
||||||
|
if (appPreferences.encryptionStartedAt.get() != null) appPreferences.encryptionStartedAt.set(null)
|
||||||
|
withApi {
|
||||||
|
val user = chatController.apiGetActiveUser()
|
||||||
|
if (user == null) {
|
||||||
|
chatModel.onboardingStage.value = OnboardingStage.Step1_SimpleXInfo
|
||||||
|
} else {
|
||||||
|
chatController.startChat(user)
|
||||||
|
chatController.showBackgroundServiceNoticeIfNeeded()
|
||||||
|
if (appPreferences.notificationsMode.get() == NotificationsMode.SERVICE.name)
|
||||||
|
SimplexService.start(applicationContext)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val chatModel: ChatModel
|
||||||
|
get() = chatController.chatModel
|
||||||
|
|
||||||
|
private val ntfManager: NtfManager by lazy {
|
||||||
|
NtfManager(applicationContext, appPreferences)
|
||||||
|
}
|
||||||
|
|
||||||
|
private val appPreferences: AppPreferences by lazy {
|
||||||
|
AppPreferences(applicationContext)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onCreate() {
|
||||||
|
super.onCreate()
|
||||||
|
context = this
|
||||||
|
initChatController()
|
||||||
|
ProcessLifecycleOwner.get().lifecycle.addObserver(this)
|
||||||
|
context.getDir("temp", MODE_PRIVATE).deleteRecursively()
|
||||||
|
runMigrations()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onStateChanged(source: LifecycleOwner, event: Lifecycle.Event) {
|
||||||
|
Log.d(TAG, "onStateChanged: $event")
|
||||||
|
withApi {
|
||||||
|
when (event) {
|
||||||
|
Lifecycle.Event.ON_START -> {
|
||||||
|
isAppOnForeground = true
|
||||||
|
if (chatModel.chatRunning.value == true) {
|
||||||
|
kotlin.runCatching {
|
||||||
|
val currentUserId = chatModel.currentUser.value?.userId
|
||||||
|
val chats = ArrayList(chatController.apiGetChats())
|
||||||
|
/** Active user can be changed in background while [ChatController.apiGetChats] is executing */
|
||||||
|
if (chatModel.currentUser.value?.userId == currentUserId) {
|
||||||
|
val currentChatId = chatModel.chatId.value
|
||||||
|
val oldStats = if (currentChatId != null) chatModel.getChat(currentChatId)?.chatStats else null
|
||||||
|
if (oldStats != null) {
|
||||||
|
val indexOfCurrentChat = chats.indexOfFirst { it.id == currentChatId }
|
||||||
|
/** Pass old chatStats because unreadCounter can be changed already while [ChatController.apiGetChats] is executing */
|
||||||
|
if (indexOfCurrentChat >= 0) chats[indexOfCurrentChat] = chats[indexOfCurrentChat].copy(chatStats = oldStats)
|
||||||
|
}
|
||||||
|
chatModel.updateChats(chats)
|
||||||
|
}
|
||||||
|
}.onFailure { Log.e(TAG, it.stackTraceToString()) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Lifecycle.Event.ON_RESUME -> {
|
||||||
|
isAppOnForeground = true
|
||||||
|
if (chatModel.onboardingStage.value == OnboardingStage.OnboardingComplete) {
|
||||||
|
chatController.showBackgroundServiceNoticeIfNeeded()
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* We're starting service here instead of in [Lifecycle.Event.ON_START] because
|
||||||
|
* after calling [ChatController.showBackgroundServiceNoticeIfNeeded] notification mode in prefs can be changed.
|
||||||
|
* It can happen when app was started and a user enables battery optimization while app in background
|
||||||
|
* */
|
||||||
|
if (chatModel.chatRunning.value != false &&
|
||||||
|
chatModel.onboardingStage.value == OnboardingStage.OnboardingComplete &&
|
||||||
|
appPreferences.notificationsMode.get() == NotificationsMode.SERVICE.name
|
||||||
|
) {
|
||||||
|
SimplexService.start(applicationContext)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else -> isAppOnForeground = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun allowToStartServiceAfterAppExit() = with(chatModel.controller) {
|
||||||
|
appPrefs.notificationsMode.get() == NotificationsMode.SERVICE.name &&
|
||||||
|
(!NotificationsMode.SERVICE.requiresIgnoringBattery || isIgnoringBatteryOptimizations(chatModel.controller.appContext))
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun allowToStartPeriodically() = with(chatModel.controller) {
|
||||||
|
appPrefs.notificationsMode.get() == NotificationsMode.PERIODIC.name &&
|
||||||
|
(!NotificationsMode.PERIODIC.requiresIgnoringBattery || isIgnoringBatteryOptimizations(chatModel.controller.appContext))
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* It takes 1-10 milliseconds to process this function. Better to do it in a background thread
|
||||||
|
* */
|
||||||
|
fun schedulePeriodicServiceRestartWorker() = CoroutineScope(Dispatchers.Default).launch {
|
||||||
|
if (!allowToStartServiceAfterAppExit()) {
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
|
val workerVersion = chatController.appPrefs.autoRestartWorkerVersion.get()
|
||||||
|
val workPolicy = if (workerVersion == SimplexService.SERVICE_START_WORKER_VERSION) {
|
||||||
|
Log.d(TAG, "ServiceStartWorker version matches: choosing KEEP as existing work policy")
|
||||||
|
ExistingPeriodicWorkPolicy.KEEP
|
||||||
|
} else {
|
||||||
|
Log.d(TAG, "ServiceStartWorker version DOES NOT MATCH: choosing REPLACE as existing work policy")
|
||||||
|
chatController.appPrefs.autoRestartWorkerVersion.set(SimplexService.SERVICE_START_WORKER_VERSION)
|
||||||
|
ExistingPeriodicWorkPolicy.REPLACE
|
||||||
|
}
|
||||||
|
val work = PeriodicWorkRequestBuilder<SimplexService.ServiceStartWorker>(SimplexService.SERVICE_START_WORKER_INTERVAL_MINUTES, TimeUnit.MINUTES)
|
||||||
|
.addTag(SimplexService.TAG)
|
||||||
|
.addTag(SimplexService.SERVICE_START_WORKER_WORK_NAME_PERIODIC)
|
||||||
|
.build()
|
||||||
|
Log.d(TAG, "ServiceStartWorker: Scheduling period work every ${SimplexService.SERVICE_START_WORKER_INTERVAL_MINUTES} minutes")
|
||||||
|
WorkManager.getInstance(context)?.enqueueUniquePeriodicWork(SimplexService.SERVICE_START_WORKER_WORK_NAME_PERIODIC, workPolicy, work)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun schedulePeriodicWakeUp() = CoroutineScope(Dispatchers.Default).launch {
|
||||||
|
if (!allowToStartPeriodically()) {
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
|
MessagesFetcherWorker.scheduleWork()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun runMigrations() {
|
||||||
|
val lastMigration = chatModel.controller.appPrefs.lastMigratedVersionCode
|
||||||
|
if (lastMigration.get() < BuildConfig.VERSION_CODE) {
|
||||||
|
while (true) {
|
||||||
|
if (lastMigration.get() < 117) {
|
||||||
|
if (chatModel.controller.appPrefs.currentTheme.get() == DefaultTheme.DARK.name) {
|
||||||
|
chatModel.controller.appPrefs.currentTheme.set(DefaultTheme.SIMPLEX.name)
|
||||||
|
}
|
||||||
|
lastMigration.set(117)
|
||||||
|
} else {
|
||||||
|
lastMigration.set(BuildConfig.VERSION_CODE)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
lateinit var context: SimplexApp private set
|
||||||
|
|
||||||
|
init {
|
||||||
|
val socketName = BuildConfig.APPLICATION_ID + ".local.socket.address.listen.native.cmd2"
|
||||||
|
val s = Semaphore(0)
|
||||||
|
thread(name="stdout/stderr pipe") {
|
||||||
|
Log.d(TAG, "starting server")
|
||||||
|
var server: LocalServerSocket? = null
|
||||||
|
for (i in 0..100) {
|
||||||
|
try {
|
||||||
|
server = LocalServerSocket(socketName + i)
|
||||||
|
break
|
||||||
|
} catch (e: IOException) {
|
||||||
|
Log.e(TAG, e.stackTraceToString())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (server == null) {
|
||||||
|
throw Error("Unable to setup local server socket. Contact developers")
|
||||||
|
}
|
||||||
|
Log.d(TAG, "started server")
|
||||||
|
s.release()
|
||||||
|
val receiver = server.accept()
|
||||||
|
Log.d(TAG, "started receiver")
|
||||||
|
val logbuffer = FifoQueue<String>(500)
|
||||||
|
if (receiver != null) {
|
||||||
|
val inStream = receiver.inputStream
|
||||||
|
val inStreamReader = InputStreamReader(inStream)
|
||||||
|
val input = BufferedReader(inStreamReader)
|
||||||
|
Log.d(TAG, "starting receiver loop")
|
||||||
|
while (true) {
|
||||||
|
val line = input.readLine() ?: break
|
||||||
|
Log.w("$TAG (stdout/stderr)", line)
|
||||||
|
logbuffer.add(line)
|
||||||
|
}
|
||||||
|
Log.w(TAG, "exited receiver loop")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
System.loadLibrary("app-lib")
|
||||||
|
|
||||||
|
s.acquire()
|
||||||
|
pipeStdOutToSocket(socketName)
|
||||||
|
|
||||||
|
initHS()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class FifoQueue<E>(private var capacity: Int) : LinkedList<E>() {
|
||||||
|
override fun add(element: E): Boolean {
|
||||||
|
if(size > capacity) removeFirst()
|
||||||
|
return super.add(element)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,329 @@
|
|||||||
|
package chat.simplex.app
|
||||||
|
|
||||||
|
import android.app.*
|
||||||
|
import android.content.*
|
||||||
|
import android.content.pm.PackageManager
|
||||||
|
import android.os.*
|
||||||
|
import android.provider.Settings
|
||||||
|
import android.util.Log
|
||||||
|
import androidx.core.app.NotificationCompat
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
|
import androidx.work.*
|
||||||
|
import chat.simplex.app.views.helpers.*
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
|
||||||
|
// based on:
|
||||||
|
// https://robertohuertas.com/2019/06/29/android_foreground_services/
|
||||||
|
// https://github.com/binwiederhier/ntfy-android/blob/main/app/src/main/java/io/heckel/ntfy/service/SubscriberService.kt
|
||||||
|
|
||||||
|
class SimplexService: Service() {
|
||||||
|
private var wakeLock: PowerManager.WakeLock? = null
|
||||||
|
private var isStartingService = false
|
||||||
|
private var notificationManager: NotificationManager? = null
|
||||||
|
private var serviceNotification: Notification? = null
|
||||||
|
|
||||||
|
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||||
|
Log.d(TAG, "onStartCommand startId: $startId")
|
||||||
|
if (intent != null) {
|
||||||
|
val action = intent.action
|
||||||
|
Log.d(TAG, "intent action $action")
|
||||||
|
when (action) {
|
||||||
|
Action.START.name -> startService()
|
||||||
|
else -> Log.e(TAG, "No action in the intent")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Log.d(TAG, "null intent. Probably restarted by the system.")
|
||||||
|
}
|
||||||
|
return START_STICKY // to restart if killed
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onCreate() {
|
||||||
|
super.onCreate()
|
||||||
|
Log.d(TAG, "Simplex service created")
|
||||||
|
val title = getString(R.string.simplex_service_notification_title)
|
||||||
|
val text = getString(R.string.simplex_service_notification_text)
|
||||||
|
notificationManager = createNotificationChannel()
|
||||||
|
serviceNotification = createNotification(title, text)
|
||||||
|
startForeground(SIMPLEX_SERVICE_ID, serviceNotification)
|
||||||
|
/**
|
||||||
|
* The reason [stopAfterStart] exists is because when the service is not called [startForeground] yet, and
|
||||||
|
* we call [stopSelf] on the same service, [ForegroundServiceDidNotStartInTimeException] will be thrown.
|
||||||
|
* To prevent that, we can call [stopSelf] only when the service made [startForeground] call
|
||||||
|
* */
|
||||||
|
if (stopAfterStart) {
|
||||||
|
stopForeground(true)
|
||||||
|
stopSelf()
|
||||||
|
} else {
|
||||||
|
isServiceStarted = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDestroy() {
|
||||||
|
Log.d(TAG, "Simplex service destroyed")
|
||||||
|
try {
|
||||||
|
wakeLock?.let {
|
||||||
|
while (it.isHeld) it.release() // release all, in case acquired more than once
|
||||||
|
}
|
||||||
|
wakeLock = null
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.d(TAG, "Exception while releasing wakelock: ${e.message}")
|
||||||
|
}
|
||||||
|
isServiceStarted = false
|
||||||
|
stopAfterStart = false
|
||||||
|
saveServiceState(this, ServiceState.STOPPED)
|
||||||
|
|
||||||
|
// If notification service is enabled and battery optimization is disabled, restart the service
|
||||||
|
if (SimplexApp.context.allowToStartServiceAfterAppExit())
|
||||||
|
sendBroadcast(Intent(this, AutoRestartReceiver::class.java))
|
||||||
|
super.onDestroy()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun startService() {
|
||||||
|
Log.d(TAG, "SimplexService startService")
|
||||||
|
if (wakeLock != null || isStartingService) return
|
||||||
|
val self = this
|
||||||
|
isStartingService = true
|
||||||
|
withApi {
|
||||||
|
val chatController = (application as SimplexApp).chatController
|
||||||
|
try {
|
||||||
|
Log.w(TAG, "Starting foreground service")
|
||||||
|
val chatDbStatus = chatController.chatModel.chatDbStatus.value
|
||||||
|
if (chatDbStatus != DBMigrationResult.OK) {
|
||||||
|
Log.w(chat.simplex.app.TAG, "SimplexService: problem with the database: $chatDbStatus")
|
||||||
|
showPassphraseNotification(chatDbStatus)
|
||||||
|
safeStopService(self)
|
||||||
|
return@withApi
|
||||||
|
}
|
||||||
|
saveServiceState(self, ServiceState.STARTED)
|
||||||
|
wakeLock = (getSystemService(Context.POWER_SERVICE) as PowerManager).run {
|
||||||
|
newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, WAKE_LOCK_TAG).apply {
|
||||||
|
acquire()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
isStartingService = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun createNotificationChannel(): NotificationManager? {
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||||
|
val notificationManager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||||
|
val channel = NotificationChannel(NOTIFICATION_CHANNEL_ID, NOTIFICATION_CHANNEL_NAME, NotificationManager.IMPORTANCE_LOW).let {
|
||||||
|
it.setShowBadge(false) // no long-press badge
|
||||||
|
it
|
||||||
|
}
|
||||||
|
notificationManager.createNotificationChannel(channel)
|
||||||
|
return notificationManager
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun createNotification(title: String, text: String): Notification {
|
||||||
|
val pendingIntent: PendingIntent = Intent(this, MainActivity::class.java).let { notificationIntent ->
|
||||||
|
PendingIntent.getActivity(this, 0, notificationIntent, PendingIntent.FLAG_IMMUTABLE)
|
||||||
|
}
|
||||||
|
|
||||||
|
val builder = NotificationCompat.Builder(this, NOTIFICATION_CHANNEL_ID)
|
||||||
|
.setSmallIcon(R.drawable.ntf_service_icon)
|
||||||
|
.setColor(0x88FFFF)
|
||||||
|
.setContentTitle(title)
|
||||||
|
.setContentText(text)
|
||||||
|
.setContentIntent(pendingIntent)
|
||||||
|
.setSilent(true)
|
||||||
|
.setShowWhen(false) // no date/time
|
||||||
|
|
||||||
|
// Shows a button which opens notification channel settings
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||||
|
val flags = PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
||||||
|
val setupIntent = Intent(Settings.ACTION_CHANNEL_NOTIFICATION_SETTINGS)
|
||||||
|
setupIntent.putExtra(Settings.EXTRA_APP_PACKAGE, packageName)
|
||||||
|
setupIntent.putExtra(Settings.EXTRA_CHANNEL_ID, NOTIFICATION_CHANNEL_ID)
|
||||||
|
val setup = PendingIntent.getActivity(this, 0, setupIntent, flags)
|
||||||
|
builder.addAction(0, getString(R.string.hide_notification), setup)
|
||||||
|
}
|
||||||
|
|
||||||
|
return builder.build()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onBind(intent: Intent): IBinder? {
|
||||||
|
return null // no binding
|
||||||
|
}
|
||||||
|
|
||||||
|
// re-schedules the task when "Clear recent apps" is pressed
|
||||||
|
override fun onTaskRemoved(rootIntent: Intent) {
|
||||||
|
// Just to make sure that after restart of the app the user will need to re-authenticate
|
||||||
|
MainActivity.clearAuthState()
|
||||||
|
|
||||||
|
// If notification service isn't enabled or battery optimization isn't disabled, we shouldn't restart the service
|
||||||
|
if (!SimplexApp.context.allowToStartServiceAfterAppExit()) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val restartServiceIntent = Intent(applicationContext, SimplexService::class.java).also {
|
||||||
|
it.setPackage(packageName)
|
||||||
|
};
|
||||||
|
val restartServicePendingIntent: PendingIntent = PendingIntent.getService(this, 1, restartServiceIntent, PendingIntent.FLAG_ONE_SHOT or PendingIntent.FLAG_IMMUTABLE);
|
||||||
|
applicationContext.getSystemService(Context.ALARM_SERVICE);
|
||||||
|
val alarmService: AlarmManager = applicationContext.getSystemService(Context.ALARM_SERVICE) as AlarmManager;
|
||||||
|
alarmService.set(AlarmManager.ELAPSED_REALTIME, SystemClock.elapsedRealtime() + 1000, restartServicePendingIntent);
|
||||||
|
}
|
||||||
|
|
||||||
|
// restart on reboot
|
||||||
|
class StartReceiver: BroadcastReceiver() {
|
||||||
|
override fun onReceive(context: Context, intent: Intent) {
|
||||||
|
Log.d(TAG, "StartReceiver: onReceive called")
|
||||||
|
scheduleStart(context)
|
||||||
|
}
|
||||||
|
companion object {
|
||||||
|
fun toggleReceiver(enable: Boolean) {
|
||||||
|
Log.d(TAG, "StartReceiver: toggleReceiver enabled: $enable")
|
||||||
|
val component = ComponentName(BuildConfig.APPLICATION_ID, StartReceiver::class.java.name)
|
||||||
|
SimplexApp.context.packageManager.setComponentEnabledSetting(
|
||||||
|
component,
|
||||||
|
if (enable) PackageManager.COMPONENT_ENABLED_STATE_ENABLED else PackageManager.COMPONENT_ENABLED_STATE_DISABLED,
|
||||||
|
PackageManager.DONT_KILL_APP
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// restart on destruction
|
||||||
|
class AutoRestartReceiver: BroadcastReceiver() {
|
||||||
|
override fun onReceive(context: Context, intent: Intent) {
|
||||||
|
Log.d(TAG, "AutoRestartReceiver: onReceive called")
|
||||||
|
scheduleStart(context)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class ServiceStartWorker(private val context: Context, params: WorkerParameters): CoroutineWorker(context, params) {
|
||||||
|
override suspend fun doWork(): Result {
|
||||||
|
val id = this.id
|
||||||
|
if (context.applicationContext !is Application) {
|
||||||
|
Log.d(TAG, "ServiceStartWorker: Failed, no application found (work ID: $id)")
|
||||||
|
return Result.failure()
|
||||||
|
}
|
||||||
|
if (getServiceState(context) == ServiceState.STARTED) {
|
||||||
|
Log.d(TAG, "ServiceStartWorker: Starting foreground service (work ID: $id)")
|
||||||
|
start(context)
|
||||||
|
}
|
||||||
|
return Result.success()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum class Action {
|
||||||
|
START,
|
||||||
|
}
|
||||||
|
|
||||||
|
enum class ServiceState {
|
||||||
|
STARTED,
|
||||||
|
STOPPED,
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
const val TAG = "SIMPLEX_SERVICE"
|
||||||
|
const val NOTIFICATION_CHANNEL_ID = "chat.simplex.app.SIMPLEX_SERVICE_NOTIFICATION"
|
||||||
|
const val NOTIFICATION_CHANNEL_NAME = "SimpleX Chat service"
|
||||||
|
const val SIMPLEX_SERVICE_ID = 6789
|
||||||
|
const val SERVICE_START_WORKER_VERSION = BuildConfig.VERSION_CODE
|
||||||
|
const val SERVICE_START_WORKER_INTERVAL_MINUTES = 3 * 60L
|
||||||
|
const val SERVICE_START_WORKER_WORK_NAME_PERIODIC = "SimplexAutoRestartWorkerPeriodic" // Do not change!
|
||||||
|
|
||||||
|
private const val PASSPHRASE_NOTIFICATION_ID = 1535
|
||||||
|
|
||||||
|
private const val WAKE_LOCK_TAG = "SimplexService::lock"
|
||||||
|
private const val SHARED_PREFS_ID = "chat.simplex.app.SIMPLEX_SERVICE_PREFS"
|
||||||
|
private const val SHARED_PREFS_SERVICE_STATE = "SIMPLEX_SERVICE_STATE"
|
||||||
|
private const val WORK_NAME_ONCE = "ServiceStartWorkerOnce"
|
||||||
|
|
||||||
|
private var isServiceStarted = false
|
||||||
|
private var stopAfterStart = false
|
||||||
|
|
||||||
|
fun scheduleStart(context: Context) {
|
||||||
|
Log.d(TAG, "Enqueuing work to start subscriber service")
|
||||||
|
val workManager = WorkManager.getInstance(context)
|
||||||
|
val startServiceRequest = OneTimeWorkRequest.Builder(ServiceStartWorker::class.java).build()
|
||||||
|
workManager.enqueueUniqueWork(WORK_NAME_ONCE, ExistingWorkPolicy.KEEP, startServiceRequest) // Unique avoids races!
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun start(context: Context) = serviceAction(context, Action.START)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* If there is a need to stop the service, use this function only. It makes sure that the service will be stopped without an
|
||||||
|
* exception related to foreground services lifecycle
|
||||||
|
* */
|
||||||
|
fun safeStopService(context: Context) {
|
||||||
|
if (isServiceStarted) {
|
||||||
|
context.stopService(Intent(context, SimplexService::class.java))
|
||||||
|
} else {
|
||||||
|
stopAfterStart = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun serviceAction(context: Context, action: Action) {
|
||||||
|
Log.d(TAG, "SimplexService serviceAction: ${action.name}")
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
Intent(context, SimplexService::class.java).also {
|
||||||
|
it.action = action.name
|
||||||
|
ContextCompat.startForegroundService(context, it)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun restart(context: Context) {
|
||||||
|
Intent(context, SimplexService::class.java).also { intent ->
|
||||||
|
context.stopService(intent) // Service will auto-restart
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun saveServiceState(context: Context, state: ServiceState) {
|
||||||
|
getPreferences(context).edit()
|
||||||
|
.putString(SHARED_PREFS_SERVICE_STATE, state.name)
|
||||||
|
.apply()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getServiceState(context: Context): ServiceState {
|
||||||
|
val value = getPreferences(context)
|
||||||
|
.getString(SHARED_PREFS_SERVICE_STATE, ServiceState.STOPPED.name)
|
||||||
|
return ServiceState.valueOf(value!!)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun showPassphraseNotification(chatDbStatus: DBMigrationResult?) {
|
||||||
|
val pendingIntent: PendingIntent = Intent(SimplexApp.context, MainActivity::class.java).let { notificationIntent ->
|
||||||
|
PendingIntent.getActivity(SimplexApp.context, 0, notificationIntent, PendingIntent.FLAG_IMMUTABLE)
|
||||||
|
}
|
||||||
|
|
||||||
|
val title = when(chatDbStatus) {
|
||||||
|
is DBMigrationResult.ErrorNotADatabase -> generalGetString(R.string.enter_passphrase_notification_title)
|
||||||
|
is DBMigrationResult.OK -> return
|
||||||
|
else -> generalGetString(R.string.database_initialization_error_title)
|
||||||
|
}
|
||||||
|
|
||||||
|
val description = when(chatDbStatus) {
|
||||||
|
is DBMigrationResult.ErrorNotADatabase -> generalGetString(R.string.enter_passphrase_notification_desc)
|
||||||
|
is DBMigrationResult.OK -> return
|
||||||
|
else -> generalGetString(R.string.database_initialization_error_desc)
|
||||||
|
}
|
||||||
|
|
||||||
|
val builder = NotificationCompat.Builder(SimplexApp.context, NOTIFICATION_CHANNEL_ID)
|
||||||
|
.setSmallIcon(R.drawable.ntf_service_icon)
|
||||||
|
.setColor(0x88FFFF)
|
||||||
|
.setContentTitle(title)
|
||||||
|
.setContentText(description)
|
||||||
|
.setContentIntent(pendingIntent)
|
||||||
|
.setSilent(true)
|
||||||
|
.setShowWhen(false)
|
||||||
|
|
||||||
|
val notificationManager = SimplexApp.context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||||
|
notificationManager.notify(PASSPHRASE_NOTIFICATION_ID, builder.build())
|
||||||
|
}
|
||||||
|
|
||||||
|
fun cancelPassphraseNotification() {
|
||||||
|
val notificationManager = SimplexApp.context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||||
|
notificationManager.cancel(PASSPHRASE_NOTIFICATION_ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getPreferences(context: Context): SharedPreferences = context.getSharedPreferences(SHARED_PREFS_ID, Context.MODE_PRIVATE)
|
||||||
|
}
|
||||||
|
}
|
||||||
+257
-967
File diff suppressed because it is too large
Load Diff
+85
-49
@@ -1,29 +1,24 @@
|
|||||||
package chat.simplex.app.model
|
package chat.simplex.app.model
|
||||||
|
|
||||||
import android.app.*
|
import android.app.*
|
||||||
import android.app.TaskStackBuilder
|
|
||||||
import android.content.*
|
import android.content.*
|
||||||
import android.content.pm.PackageManager
|
|
||||||
import android.graphics.BitmapFactory
|
import android.graphics.BitmapFactory
|
||||||
import android.hardware.display.DisplayManager
|
import android.hardware.display.DisplayManager
|
||||||
import android.media.AudioAttributes
|
import android.media.AudioAttributes
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
|
import android.util.Log
|
||||||
import android.view.Display
|
import android.view.Display
|
||||||
import androidx.compose.ui.graphics.asAndroidBitmap
|
import androidx.core.app.NotificationCompat
|
||||||
import androidx.core.app.*
|
import androidx.core.app.NotificationManagerCompat
|
||||||
import chat.simplex.app.*
|
import chat.simplex.app.*
|
||||||
import chat.simplex.app.TAG
|
import chat.simplex.app.views.call.*
|
||||||
import chat.simplex.app.views.call.IncomingCallActivity
|
import chat.simplex.app.views.chatlist.acceptContactRequest
|
||||||
import chat.simplex.app.views.call.getKeyguardManager
|
import chat.simplex.app.views.helpers.*
|
||||||
import chat.simplex.common.views.helpers.*
|
import chat.simplex.app.views.usersettings.NotificationPreviewMode
|
||||||
import chat.simplex.common.model.*
|
|
||||||
import chat.simplex.common.platform.*
|
|
||||||
import chat.simplex.common.views.call.CallMediaType
|
|
||||||
import chat.simplex.common.views.call.RcvCallInvitation
|
|
||||||
import kotlinx.datetime.Clock
|
import kotlinx.datetime.Clock
|
||||||
import chat.simplex.res.MR
|
|
||||||
|
|
||||||
object NtfManager {
|
class NtfManager(val context: Context, private val appPreferences: AppPreferences) {
|
||||||
|
companion object {
|
||||||
const val MessageChannel: String = "chat.simplex.app.MESSAGE_NOTIFICATION"
|
const val MessageChannel: String = "chat.simplex.app.MESSAGE_NOTIFICATION"
|
||||||
const val MessageGroup: String = "chat.simplex.app.MESSAGE_NOTIFICATION"
|
const val MessageGroup: String = "chat.simplex.app.MESSAGE_NOTIFICATION"
|
||||||
const val OpenChatAction: String = "chat.simplex.app.OPEN_CHAT"
|
const val OpenChatAction: String = "chat.simplex.app.OPEN_CHAT"
|
||||||
@@ -34,16 +29,15 @@ object NtfManager {
|
|||||||
const val AcceptCallAction: String = "chat.simplex.app.ACCEPT_CALL"
|
const val AcceptCallAction: String = "chat.simplex.app.ACCEPT_CALL"
|
||||||
const val RejectCallAction: String = "chat.simplex.app.REJECT_CALL"
|
const val RejectCallAction: String = "chat.simplex.app.REJECT_CALL"
|
||||||
const val CallNotificationId: Int = -1
|
const val CallNotificationId: Int = -1
|
||||||
|
|
||||||
private const val UserIdKey: String = "userId"
|
private const val UserIdKey: String = "userId"
|
||||||
private const val ChatIdKey: String = "chatId"
|
private const val ChatIdKey: String = "chatId"
|
||||||
private val appPreferences: AppPreferences = ChatController.appPrefs
|
|
||||||
private val context: Context
|
|
||||||
get() = SimplexApp.context
|
|
||||||
|
|
||||||
fun getUserIdFromIntent(intent: Intent?): Long? {
|
fun getUserIdFromIntent(intent: Intent?): Long? {
|
||||||
val userId = intent?.getLongExtra(UserIdKey, -1L)
|
val userId = intent?.getLongExtra(UserIdKey, -1L)
|
||||||
return if (userId == -1L || userId == null) null else userId
|
return if (userId == -1L || userId == null) null else userId
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private val manager: NotificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
private val manager: NotificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||||
private var prevNtfTime = mutableMapOf<String, Long>()
|
private var prevNtfTime = mutableMapOf<String, Long>()
|
||||||
@@ -53,6 +47,10 @@ object NtfManager {
|
|||||||
if (manager.areNotificationsEnabled()) createNtfChannelsMaybeShowAlert()
|
if (manager.areNotificationsEnabled()) createNtfChannelsMaybeShowAlert()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum class NotificationAction {
|
||||||
|
ACCEPT_CONTACT_REQUEST
|
||||||
|
}
|
||||||
|
|
||||||
private fun callNotificationChannel(channelId: String, channelName: String): NotificationChannel {
|
private fun callNotificationChannel(channelId: String, channelName: String): NotificationChannel {
|
||||||
val callChannel = NotificationChannel(channelId, channelName, NotificationManager.IMPORTANCE_HIGH)
|
val callChannel = NotificationChannel(channelId, channelName, NotificationManager.IMPORTANCE_HIGH)
|
||||||
val attrs = AudioAttributes.Builder()
|
val attrs = AudioAttributes.Builder()
|
||||||
@@ -60,7 +58,7 @@ object NtfManager {
|
|||||||
.setUsage(AudioAttributes.USAGE_NOTIFICATION_RINGTONE)
|
.setUsage(AudioAttributes.USAGE_NOTIFICATION_RINGTONE)
|
||||||
.build()
|
.build()
|
||||||
val soundUri = Uri.parse(ContentResolver.SCHEME_ANDROID_RESOURCE + "://" + context.packageName + "/" + R.raw.ring_once)
|
val soundUri = Uri.parse(ContentResolver.SCHEME_ANDROID_RESOURCE + "://" + context.packageName + "/" + R.raw.ring_once)
|
||||||
Log.d(TAG, "callNotificationChannel sound: $soundUri")
|
Log.d(TAG,"callNotificationChannel sound: $soundUri")
|
||||||
callChannel.setSound(soundUri, attrs)
|
callChannel.setSound(soundUri, attrs)
|
||||||
callChannel.enableVibration(true)
|
callChannel.enableVibration(true)
|
||||||
// the numbers below are explained here: https://developer.android.com/reference/android/os/Vibrator
|
// the numbers below are explained here: https://developer.android.com/reference/android/os/Vibrator
|
||||||
@@ -72,8 +70,8 @@ object NtfManager {
|
|||||||
fun cancelNotificationsForChat(chatId: String) {
|
fun cancelNotificationsForChat(chatId: String) {
|
||||||
prevNtfTime.remove(chatId)
|
prevNtfTime.remove(chatId)
|
||||||
manager.cancel(chatId.hashCode())
|
manager.cancel(chatId.hashCode())
|
||||||
val msgNtfs = manager.activeNotifications.filter { ntf ->
|
val msgNtfs = manager.activeNotifications.filter {
|
||||||
ntf.notification.channelId == MessageChannel
|
ntf -> ntf.notification.channelId == MessageChannel
|
||||||
}
|
}
|
||||||
if (msgNtfs.count() == 1) {
|
if (msgNtfs.count() == 1) {
|
||||||
// Have a group notification with no children so cancel it
|
// Have a group notification with no children so cancel it
|
||||||
@@ -81,19 +79,45 @@ object NtfManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun displayNotification(user: UserLike, chatId: String, displayName: String, msgText: String, image: String? = null, actions: List<NotificationAction> = emptyList()) {
|
fun notifyContactRequestReceived(user: User, cInfo: ChatInfo.ContactRequest) {
|
||||||
|
displayNotification(
|
||||||
|
user = user,
|
||||||
|
chatId = cInfo.id,
|
||||||
|
displayName = cInfo.displayName,
|
||||||
|
msgText = generalGetString(R.string.notification_new_contact_request),
|
||||||
|
image = cInfo.image,
|
||||||
|
listOf(NotificationAction.ACCEPT_CONTACT_REQUEST)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun notifyContactConnected(user: User, contact: Contact) {
|
||||||
|
displayNotification(
|
||||||
|
user = user,
|
||||||
|
chatId = contact.id,
|
||||||
|
displayName = contact.displayName,
|
||||||
|
msgText = generalGetString(R.string.notification_contact_connected)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun notifyMessageReceived(user: User, cInfo: ChatInfo, cItem: ChatItem) {
|
||||||
|
if (!cInfo.ntfsEnabled) return
|
||||||
|
displayNotification(user = user, chatId = cInfo.id, displayName = cInfo.displayName, msgText = hideSecrets(cItem))
|
||||||
|
}
|
||||||
|
|
||||||
|
fun displayNotification(user: User, chatId: String, displayName: String, msgText: String, image: String? = null, actions: List<NotificationAction> = emptyList()) {
|
||||||
if (!user.showNotifications) return
|
if (!user.showNotifications) return
|
||||||
Log.d(TAG, "notifyMessageReceived $chatId")
|
Log.d(TAG, "notifyMessageReceived $chatId")
|
||||||
val now = Clock.System.now().toEpochMilliseconds()
|
val now = Clock.System.now().toEpochMilliseconds()
|
||||||
val recentNotification = (now - prevNtfTime.getOrDefault(chatId, 0) < msgNtfTimeoutMs)
|
val recentNotification = (now - prevNtfTime.getOrDefault(chatId, 0) < msgNtfTimeoutMs)
|
||||||
prevNtfTime[chatId] = now
|
prevNtfTime[chatId] = now
|
||||||
|
|
||||||
val previewMode = appPreferences.notificationPreviewMode.get()
|
val previewMode = appPreferences.notificationPreviewMode.get()
|
||||||
val title = if (previewMode == NotificationPreviewMode.HIDDEN.name) generalGetString(MR.strings.notification_preview_somebody) else displayName
|
val title = if (previewMode == NotificationPreviewMode.HIDDEN.name) generalGetString(R.string.notification_preview_somebody) else displayName
|
||||||
val content = if (previewMode != NotificationPreviewMode.MESSAGE.name) generalGetString(MR.strings.notification_preview_new_message) else msgText
|
val content = if (previewMode != NotificationPreviewMode.MESSAGE.name) generalGetString(R.string.notification_preview_new_message) else msgText
|
||||||
val largeIcon = when {
|
val largeIcon = when {
|
||||||
actions.isEmpty() -> null
|
actions.isEmpty() -> null
|
||||||
image == null || previewMode == NotificationPreviewMode.HIDDEN.name -> BitmapFactory.decodeResource(context.resources, R.drawable.icon)
|
image == null || previewMode == NotificationPreviewMode.HIDDEN.name -> BitmapFactory.decodeResource(context.resources, R.drawable.icon)
|
||||||
else -> base64ToBitmap(image).asAndroidBitmap()
|
else -> base64ToBitmap(image)
|
||||||
}
|
}
|
||||||
val builder = NotificationCompat.Builder(context, MessageChannel)
|
val builder = NotificationCompat.Builder(context, MessageChannel)
|
||||||
.setContentTitle(title)
|
.setContentTitle(title)
|
||||||
@@ -117,11 +141,11 @@ object NtfManager {
|
|||||||
actionIntent.putExtra(ChatIdKey, chatId)
|
actionIntent.putExtra(ChatIdKey, chatId)
|
||||||
val actionPendingIntent: PendingIntent = PendingIntent.getBroadcast(SimplexApp.context, 0, actionIntent, flags)
|
val actionPendingIntent: PendingIntent = PendingIntent.getBroadcast(SimplexApp.context, 0, actionIntent, flags)
|
||||||
val actionButton = when (action) {
|
val actionButton = when (action) {
|
||||||
NotificationAction.ACCEPT_CONTACT_REQUEST -> generalGetString(MR.strings.accept)
|
NotificationAction.ACCEPT_CONTACT_REQUEST -> generalGetString(R.string.accept)
|
||||||
NotificationAction.ACCEPT_CONTACT_REQUEST_INCOGNITO -> generalGetString(MR.strings.accept_contact_incognito_button)
|
|
||||||
}
|
}
|
||||||
builder.addAction(0, actionButton, actionPendingIntent)
|
builder.addAction(0, actionButton, actionPendingIntent)
|
||||||
}
|
}
|
||||||
|
|
||||||
val summary = NotificationCompat.Builder(context, MessageChannel)
|
val summary = NotificationCompat.Builder(context, MessageChannel)
|
||||||
.setSmallIcon(R.drawable.ntf_icon)
|
.setSmallIcon(R.drawable.ntf_icon)
|
||||||
.setColor(0x88FFFF)
|
.setColor(0x88FFFF)
|
||||||
@@ -133,23 +157,20 @@ object NtfManager {
|
|||||||
|
|
||||||
with(NotificationManagerCompat.from(context)) {
|
with(NotificationManagerCompat.from(context)) {
|
||||||
// using cInfo.id only shows one notification per chat and updates it when the message arrives
|
// using cInfo.id only shows one notification per chat and updates it when the message arrives
|
||||||
if (ActivityCompat.checkSelfPermission(SimplexApp.context, android.Manifest.permission.POST_NOTIFICATIONS) == PackageManager.PERMISSION_GRANTED) {
|
|
||||||
notify(chatId.hashCode(), builder.build())
|
notify(chatId.hashCode(), builder.build())
|
||||||
notify(0, summary)
|
notify(0, summary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
fun notifyCallInvitation(invitation: RcvCallInvitation) {
|
fun notifyCallInvitation(invitation: RcvCallInvitation) {
|
||||||
val keyguardManager = getKeyguardManager(context)
|
val keyguardManager = getKeyguardManager(context)
|
||||||
Log.d(
|
Log.d(TAG,
|
||||||
TAG,
|
|
||||||
"notifyCallInvitation pre-requests: " +
|
"notifyCallInvitation pre-requests: " +
|
||||||
"keyguard locked ${keyguardManager.isKeyguardLocked}, " +
|
"keyguard locked ${keyguardManager.isKeyguardLocked}, " +
|
||||||
"callOnLockScreen ${appPreferences.callOnLockScreen.get()}, " +
|
"callOnLockScreen ${appPreferences.callOnLockScreen.get()}, " +
|
||||||
"onForeground ${isAppOnForeground}"
|
"onForeground ${SimplexApp.context.isAppOnForeground}"
|
||||||
)
|
)
|
||||||
if (isAppOnForeground) return
|
if (SimplexApp.context.isAppOnForeground) return
|
||||||
val contactId = invitation.contact.id
|
val contactId = invitation.contact.id
|
||||||
Log.d(TAG, "notifyCallInvitation $contactId")
|
Log.d(TAG, "notifyCallInvitation $contactId")
|
||||||
val image = invitation.contact.image
|
val image = invitation.contact.image
|
||||||
@@ -167,27 +188,27 @@ object NtfManager {
|
|||||||
val fullScreenPendingIntent = PendingIntent.getActivity(context, 0, Intent(), PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)
|
val fullScreenPendingIntent = PendingIntent.getActivity(context, 0, Intent(), PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)
|
||||||
NotificationCompat.Builder(context, CallChannel)
|
NotificationCompat.Builder(context, CallChannel)
|
||||||
.setContentIntent(chatPendingIntent(OpenChatAction, invitation.user.userId, invitation.contact.id))
|
.setContentIntent(chatPendingIntent(OpenChatAction, invitation.user.userId, invitation.contact.id))
|
||||||
.addAction(R.drawable.ntf_icon, generalGetString(MR.strings.accept), chatPendingIntent(AcceptCallAction, invitation.user.userId, contactId))
|
.addAction(R.drawable.ntf_icon, generalGetString(R.string.accept), chatPendingIntent(AcceptCallAction, invitation.user.userId, contactId))
|
||||||
.addAction(R.drawable.ntf_icon, generalGetString(MR.strings.reject), chatPendingIntent(RejectCallAction, invitation.user.userId, contactId, true))
|
.addAction(R.drawable.ntf_icon, generalGetString(R.string.reject), chatPendingIntent(RejectCallAction, invitation.user.userId, contactId, true))
|
||||||
.setFullScreenIntent(fullScreenPendingIntent, true)
|
.setFullScreenIntent(fullScreenPendingIntent, true)
|
||||||
.setSound(soundUri)
|
.setSound(soundUri)
|
||||||
}
|
}
|
||||||
val text = generalGetString(
|
val text = generalGetString(
|
||||||
if (invitation.callType.media == CallMediaType.Video) {
|
if (invitation.callType.media == CallMediaType.Video) {
|
||||||
if (invitation.sharedKey == null) MR.strings.video_call_no_encryption else MR.strings.encrypted_video_call
|
if (invitation.sharedKey == null) R.string.video_call_no_encryption else R.string.encrypted_video_call
|
||||||
} else {
|
} else {
|
||||||
if (invitation.sharedKey == null) MR.strings.audio_call_no_encryption else MR.strings.encrypted_audio_call
|
if (invitation.sharedKey == null) R.string.audio_call_no_encryption else R.string.encrypted_audio_call
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
val previewMode = appPreferences.notificationPreviewMode.get()
|
val previewMode = appPreferences.notificationPreviewMode.get()
|
||||||
val title = if (previewMode == NotificationPreviewMode.HIDDEN.name)
|
val title = if (previewMode == NotificationPreviewMode.HIDDEN.name)
|
||||||
generalGetString(MR.strings.notification_preview_somebody)
|
generalGetString(R.string.notification_preview_somebody)
|
||||||
else
|
else
|
||||||
invitation.contact.displayName
|
invitation.contact.displayName
|
||||||
val largeIcon = if (image == null || previewMode == NotificationPreviewMode.HIDDEN.name)
|
val largeIcon = if (image == null || previewMode == NotificationPreviewMode.HIDDEN.name)
|
||||||
BitmapFactory.decodeResource(context.resources, R.drawable.icon)
|
BitmapFactory.decodeResource(context.resources, R.drawable.icon)
|
||||||
else
|
else
|
||||||
base64ToBitmap(image).asAndroidBitmap()
|
base64ToBitmap(image)
|
||||||
|
|
||||||
ntfBuilder = ntfBuilder
|
ntfBuilder = ntfBuilder
|
||||||
.setContentTitle(title)
|
.setContentTitle(title)
|
||||||
@@ -202,22 +223,29 @@ object NtfManager {
|
|||||||
// This makes notification sound and vibration repeat endlessly
|
// This makes notification sound and vibration repeat endlessly
|
||||||
notification.flags = notification.flags or NotificationCompat.FLAG_INSISTENT
|
notification.flags = notification.flags or NotificationCompat.FLAG_INSISTENT
|
||||||
with(NotificationManagerCompat.from(context)) {
|
with(NotificationManagerCompat.from(context)) {
|
||||||
if (ActivityCompat.checkSelfPermission(SimplexApp.context, android.Manifest.permission.POST_NOTIFICATIONS) == PackageManager.PERMISSION_GRANTED) {
|
|
||||||
notify(CallNotificationId, notification)
|
notify(CallNotificationId, notification)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
fun cancelCallNotification() {
|
fun cancelCallNotification() {
|
||||||
manager.cancel(CallNotificationId)
|
manager.cancel(CallNotificationId)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun cancelAllNotifications() {
|
|
||||||
manager.cancelAll()
|
|
||||||
}
|
|
||||||
|
|
||||||
fun hasNotificationsForChat(chatId: String): Boolean = manager.activeNotifications.any { it.id == chatId.hashCode() }
|
fun hasNotificationsForChat(chatId: String): Boolean = manager.activeNotifications.any { it.id == chatId.hashCode() }
|
||||||
|
|
||||||
|
private fun hideSecrets(cItem: ChatItem) : String {
|
||||||
|
val md = cItem.formattedText
|
||||||
|
return if (md != null) {
|
||||||
|
var res = ""
|
||||||
|
for (ft in md) {
|
||||||
|
res += if (ft.format is Format.Secret) "..." else ft.text
|
||||||
|
}
|
||||||
|
res
|
||||||
|
} else {
|
||||||
|
cItem.text
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun chatPendingIntent(intentAction: String, userId: Long?, chatId: String? = null, broadcast: Boolean = false): PendingIntent {
|
private fun chatPendingIntent(intentAction: String, userId: Long?, chatId: String? = null, broadcast: Boolean = false): PendingIntent {
|
||||||
Log.d(TAG, "chatPendingIntent for $intentAction")
|
Log.d(TAG, "chatPendingIntent for $intentAction")
|
||||||
val uniqueInt = (System.currentTimeMillis() and 0xfffffff).toInt()
|
val uniqueInt = (System.currentTimeMillis() and 0xfffffff).toInt()
|
||||||
@@ -244,8 +272,8 @@ object NtfManager {
|
|||||||
* old ones if needed
|
* old ones if needed
|
||||||
* */
|
* */
|
||||||
fun createNtfChannelsMaybeShowAlert() {
|
fun createNtfChannelsMaybeShowAlert() {
|
||||||
manager.createNotificationChannel(NotificationChannel(MessageChannel, generalGetString(MR.strings.ntf_channel_messages), NotificationManager.IMPORTANCE_HIGH))
|
manager.createNotificationChannel(NotificationChannel(MessageChannel, generalGetString(R.string.ntf_channel_messages), NotificationManager.IMPORTANCE_HIGH))
|
||||||
manager.createNotificationChannel(callNotificationChannel(CallChannel, generalGetString(MR.strings.ntf_channel_calls)))
|
manager.createNotificationChannel(callNotificationChannel(CallChannel, generalGetString(R.string.ntf_channel_calls)))
|
||||||
// Remove old channels since they can't be edited
|
// Remove old channels since they can't be edited
|
||||||
manager.deleteNotificationChannel("chat.simplex.app.CALL_NOTIFICATION")
|
manager.deleteNotificationChannel("chat.simplex.app.CALL_NOTIFICATION")
|
||||||
manager.deleteNotificationChannel("chat.simplex.app.LOCK_SCREEN_CALL_NOTIFICATION")
|
manager.deleteNotificationChannel("chat.simplex.app.LOCK_SCREEN_CALL_NOTIFICATION")
|
||||||
@@ -261,15 +289,23 @@ object NtfManager {
|
|||||||
val chatId = intent?.getStringExtra(ChatIdKey) ?: return
|
val chatId = intent?.getStringExtra(ChatIdKey) ?: return
|
||||||
val m = SimplexApp.context.chatModel
|
val m = SimplexApp.context.chatModel
|
||||||
when (intent.action) {
|
when (intent.action) {
|
||||||
NotificationAction.ACCEPT_CONTACT_REQUEST.name -> ntfManager.acceptContactRequestAction(userId, incognito = false, chatId)
|
NotificationAction.ACCEPT_CONTACT_REQUEST.name -> {
|
||||||
NotificationAction.ACCEPT_CONTACT_REQUEST_INCOGNITO.name -> ntfManager.acceptContactRequestAction(userId, incognito = true, chatId)
|
val isCurrentUser = m.currentUser.value?.userId == userId
|
||||||
|
val cInfo: ChatInfo.ContactRequest? = if (isCurrentUser) {
|
||||||
|
(m.getChat(chatId)?.chatInfo as? ChatInfo.ContactRequest) ?: return
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
val apiId = chatId.replace("<@", "").toLongOrNull() ?: return
|
||||||
|
acceptContactRequest(apiId, cInfo, isCurrentUser, m)
|
||||||
|
m.controller.ntfManager.cancelNotificationsForChat(chatId)
|
||||||
|
}
|
||||||
RejectCallAction -> {
|
RejectCallAction -> {
|
||||||
val invitation = m.callInvitations[chatId]
|
val invitation = m.callInvitations[chatId]
|
||||||
if (invitation != null) {
|
if (invitation != null) {
|
||||||
m.callManager.endCall(invitation = invitation)
|
m.callManager.endCall(invitation = invitation)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
else -> {
|
else -> {
|
||||||
Log.e(TAG, "Unknown action. Make sure you provide action from NotificationAction enum")
|
Log.e(TAG, "Unknown action. Make sure you provide action from NotificationAction enum")
|
||||||
}
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
+2
-1
@@ -1,4 +1,4 @@
|
|||||||
package chat.simplex.common.ui.theme
|
package chat.simplex.app.ui.theme
|
||||||
|
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
|
|
||||||
@@ -21,6 +21,7 @@ val ToolbarDark = Color(80, 80, 80, 12)
|
|||||||
val SettingsSecondaryLight = Color(200, 196, 195, 90)
|
val SettingsSecondaryLight = Color(200, 196, 195, 90)
|
||||||
val GroupDark = Color(80, 80, 80, 60)
|
val GroupDark = Color(80, 80, 80, 60)
|
||||||
val IncomingCallLight = Color(239, 237, 236, 255)
|
val IncomingCallLight = Color(239, 237, 236, 255)
|
||||||
|
val IncomingCallDark = Color(34, 30, 29, 255)
|
||||||
val WarningOrange = Color(255, 127, 0, 255)
|
val WarningOrange = Color(255, 127, 0, 255)
|
||||||
val WarningYellow = Color(255, 192, 0, 255)
|
val WarningYellow = Color(255, 192, 0, 255)
|
||||||
val FileLight = Color(183, 190, 199, 255)
|
val FileLight = Color(183, 190, 199, 255)
|
||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
package chat.simplex.common.ui.theme
|
package chat.simplex.app.ui.theme
|
||||||
|
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material.Shapes
|
import androidx.compose.material.Shapes
|
||||||
+27
-47
@@ -1,19 +1,21 @@
|
|||||||
package chat.simplex.common.ui.theme
|
package chat.simplex.app.ui.theme
|
||||||
|
|
||||||
|
import android.app.UiModeManager
|
||||||
|
import android.content.Context
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.isSystemInDarkTheme
|
||||||
import androidx.compose.material.*
|
import androidx.compose.material.*
|
||||||
import androidx.compose.runtime.*
|
import androidx.compose.runtime.*
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.geometry.Offset
|
import androidx.compose.ui.geometry.Offset
|
||||||
import androidx.compose.ui.graphics.*
|
import androidx.compose.ui.graphics.*
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import chat.simplex.common.model.ChatController
|
import chat.simplex.app.R
|
||||||
import chat.simplex.common.platform.isInNightMode
|
import chat.simplex.app.SimplexApp
|
||||||
import chat.simplex.common.views.helpers.*
|
import chat.simplex.app.views.helpers.*
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.serialization.SerialName
|
import kotlinx.serialization.SerialName
|
||||||
import kotlinx.serialization.Serializable
|
import kotlinx.serialization.Serializable
|
||||||
import chat.simplex.res.MR
|
|
||||||
|
|
||||||
enum class DefaultTheme {
|
enum class DefaultTheme {
|
||||||
SYSTEM, LIGHT, DARK, SIMPLEX;
|
SYSTEM, LIGHT, DARK, SIMPLEX;
|
||||||
@@ -67,15 +69,15 @@ enum class ThemeColor {
|
|||||||
|
|
||||||
val text: String
|
val text: String
|
||||||
get() = when (this) {
|
get() = when (this) {
|
||||||
PRIMARY -> generalGetString(MR.strings.color_primary)
|
PRIMARY -> generalGetString(R.string.color_primary)
|
||||||
PRIMARY_VARIANT -> generalGetString(MR.strings.color_primary_variant)
|
PRIMARY_VARIANT -> generalGetString(R.string.color_primary_variant)
|
||||||
SECONDARY -> generalGetString(MR.strings.color_secondary)
|
SECONDARY -> generalGetString(R.string.color_secondary)
|
||||||
SECONDARY_VARIANT -> generalGetString(MR.strings.color_secondary_variant)
|
SECONDARY_VARIANT -> generalGetString(R.string.color_secondary_variant)
|
||||||
BACKGROUND -> generalGetString(MR.strings.color_background)
|
BACKGROUND -> generalGetString(R.string.color_background)
|
||||||
SURFACE -> generalGetString(MR.strings.color_surface)
|
SURFACE -> generalGetString(R.string.color_surface)
|
||||||
TITLE -> generalGetString(MR.strings.color_title)
|
TITLE -> generalGetString(R.string.color_title)
|
||||||
SENT_MESSAGE -> generalGetString(MR.strings.color_sent_message)
|
SENT_MESSAGE -> generalGetString(R.string.color_sent_message)
|
||||||
RECEIVED_MESSAGE -> generalGetString(MR.strings.color_received_message)
|
RECEIVED_MESSAGE -> generalGetString(R.string.color_received_message)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -126,29 +128,11 @@ data class ThemeColors(
|
|||||||
receivedMessage = receivedMessage?.colorFromReadableHex() ?: baseColors.receivedMessage,
|
receivedMessage = receivedMessage?.colorFromReadableHex() ?: baseColors.receivedMessage,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun withFilledColors(base: DefaultTheme): ThemeColors {
|
|
||||||
val c = toColors(base)
|
|
||||||
val ac = toAppColors(base)
|
|
||||||
return ThemeColors(
|
|
||||||
primary = c.primary.toReadableHex(),
|
|
||||||
primaryVariant = c.primaryVariant.toReadableHex(),
|
|
||||||
secondary = c.secondary.toReadableHex(),
|
|
||||||
secondaryVariant = c.secondaryVariant.toReadableHex(),
|
|
||||||
background = c.background.toReadableHex(),
|
|
||||||
surface = c.surface.toReadableHex(),
|
|
||||||
title = ac.title.toReadableHex(),
|
|
||||||
sentMessage = ac.sentMessage.toReadableHex(),
|
|
||||||
receivedMessage = ac.receivedMessage.toReadableHex()
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun String.colorFromReadableHex(): Color =
|
private fun String.colorFromReadableHex(): Color =
|
||||||
Color(this.replace("#", "").toLongOrNull(16) ?: Color.White.toArgb().toLong())
|
Color(this.replace("#", "").toLongOrNull(16) ?: Color.White.toArgb().toLong())
|
||||||
|
|
||||||
private fun Color.toReadableHex(): String = "#" + Integer.toHexString(toArgb())
|
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
data class ThemeOverrides (
|
data class ThemeOverrides (
|
||||||
val base: DefaultTheme,
|
val base: DefaultTheme,
|
||||||
@@ -169,6 +153,9 @@ data class ThemeOverrides (
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class ThemeData (val colors: ThemeColors)
|
||||||
|
|
||||||
fun Modifier.themedBackground(baseTheme: DefaultTheme = CurrentColors.value.base, shape: Shape = RectangleShape): Modifier {
|
fun Modifier.themedBackground(baseTheme: DefaultTheme = CurrentColors.value.base, shape: Shape = RectangleShape): Modifier {
|
||||||
return if (baseTheme == DefaultTheme.SIMPLEX) {
|
return if (baseTheme == DefaultTheme.SIMPLEX) {
|
||||||
this.background(brush = Brush.linearGradient(
|
this.background(brush = Brush.linearGradient(
|
||||||
@@ -190,11 +177,6 @@ val DEFAULT_PADDING_HALF = DEFAULT_PADDING / 2
|
|||||||
val DEFAULT_BOTTOM_PADDING = 48.dp
|
val DEFAULT_BOTTOM_PADDING = 48.dp
|
||||||
val DEFAULT_BOTTOM_BUTTON_PADDING = 20.dp
|
val DEFAULT_BOTTOM_BUTTON_PADDING = 20.dp
|
||||||
|
|
||||||
val DEFAULT_START_MODAL_WIDTH = 388.dp
|
|
||||||
val DEFAULT_MIN_CENTER_MODAL_WIDTH = 590.dp
|
|
||||||
val DEFAULT_END_MODAL_WIDTH = 388.dp
|
|
||||||
val DEFAULT_MAX_IMAGE_WIDTH = 500.dp
|
|
||||||
|
|
||||||
val DarkColorPalette = darkColors(
|
val DarkColorPalette = darkColors(
|
||||||
primary = SimplexBlue, // If this value changes also need to update #0088ff in string resource files
|
primary = SimplexBlue, // If this value changes also need to update #0088ff in string resource files
|
||||||
primaryVariant = SimplexBlue,
|
primaryVariant = SimplexBlue,
|
||||||
@@ -256,18 +238,13 @@ val SimplexColorPaletteApp = AppColors(
|
|||||||
|
|
||||||
val CurrentColors: MutableStateFlow<ThemeManager.ActiveTheme> = MutableStateFlow(ThemeManager.currentColors(isInNightMode()))
|
val CurrentColors: MutableStateFlow<ThemeManager.ActiveTheme> = MutableStateFlow(ThemeManager.currentColors(isInNightMode()))
|
||||||
|
|
||||||
|
// Non-@Composable implementation
|
||||||
|
private fun isInNightMode() =
|
||||||
|
(SimplexApp.context.getSystemService(Context.UI_MODE_SERVICE) as UiModeManager).nightMode == UiModeManager.MODE_NIGHT_YES
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun isInDarkTheme(): Boolean = !CurrentColors.collectAsState().value.colors.isLight
|
fun isInDarkTheme(): Boolean = !CurrentColors.collectAsState().value.colors.isLight
|
||||||
|
|
||||||
expect fun isSystemInDarkTheme(): Boolean
|
|
||||||
|
|
||||||
fun reactOnDarkThemeChanges(isDark: Boolean) {
|
|
||||||
if (ChatController.appPrefs.currentTheme.get() == DefaultTheme.SYSTEM.name && CurrentColors.value.colors.isLight == isDark) {
|
|
||||||
// Change active colors from light to dark and back based on system theme
|
|
||||||
ThemeManager.applyTheme(DefaultTheme.SYSTEM.name, isDark)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun SimpleXTheme(darkTheme: Boolean? = null, content: @Composable () -> Unit) {
|
fun SimpleXTheme(darkTheme: Boolean? = null, content: @Composable () -> Unit) {
|
||||||
LaunchedEffect(darkTheme) {
|
LaunchedEffect(darkTheme) {
|
||||||
@@ -277,7 +254,10 @@ fun SimpleXTheme(darkTheme: Boolean? = null, content: @Composable () -> Unit) {
|
|||||||
}
|
}
|
||||||
val systemDark = isSystemInDarkTheme()
|
val systemDark = isSystemInDarkTheme()
|
||||||
LaunchedEffect(systemDark) {
|
LaunchedEffect(systemDark) {
|
||||||
reactOnDarkThemeChanges(systemDark)
|
if (SimplexApp.context.chatModel.controller.appPrefs.currentTheme.get() == DefaultTheme.SYSTEM.name && CurrentColors.value.colors.isLight == systemDark) {
|
||||||
|
// Change active colors from light to dark and back based on system theme
|
||||||
|
ThemeManager.applyTheme(DefaultTheme.SYSTEM.name, systemDark)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
val theme by CurrentColors.collectAsState()
|
val theme by CurrentColors.collectAsState()
|
||||||
MaterialTheme(
|
MaterialTheme(
|
||||||
+30
-31
@@ -1,21 +1,18 @@
|
|||||||
package chat.simplex.common.ui.theme
|
package chat.simplex.app.ui.theme
|
||||||
|
|
||||||
import androidx.compose.material.Colors
|
import androidx.compose.material.Colors
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.graphics.toArgb
|
import androidx.compose.ui.graphics.toArgb
|
||||||
import androidx.compose.ui.text.font.FontFamily
|
import chat.simplex.app.R
|
||||||
import chat.simplex.res.MR
|
import chat.simplex.app.SimplexApp
|
||||||
import chat.simplex.common.model.AppPreferences
|
import chat.simplex.app.model.AppPreferences
|
||||||
import chat.simplex.common.model.ChatController
|
import chat.simplex.app.views.helpers.generalGetString
|
||||||
import chat.simplex.common.views.helpers.generalGetString
|
import okhttp3.internal.toHexString
|
||||||
|
|
||||||
// https://github.com/rsms/inter
|
|
||||||
// I place it here because IDEA shows an error (but still works anyway) when this declaration inside Type.kt
|
|
||||||
expect val Inter: FontFamily
|
|
||||||
expect val EmojiFont: FontFamily
|
|
||||||
|
|
||||||
object ThemeManager {
|
object ThemeManager {
|
||||||
private val appPrefs: AppPreferences = ChatController.appPrefs
|
private val appPrefs: AppPreferences by lazy {
|
||||||
|
SimplexApp.context.chatModel.controller.appPrefs
|
||||||
|
}
|
||||||
|
|
||||||
data class ActiveTheme(val name: String, val base: DefaultTheme, val colors: Colors, val appColors: AppColors)
|
data class ActiveTheme(val name: String, val base: DefaultTheme, val colors: Colors, val appColors: AppColors)
|
||||||
|
|
||||||
@@ -47,16 +44,19 @@ object ThemeManager {
|
|||||||
return ActiveTheme(themeName, baseTheme.first, theme.colors.toColors(theme.base), theme.colors.toAppColors(theme.base))
|
return ActiveTheme(themeName, baseTheme.first, theme.colors.toColors(theme.base), theme.colors.toAppColors(theme.base))
|
||||||
}
|
}
|
||||||
|
|
||||||
fun currentThemeOverridesForExport(darkForSystemTheme: Boolean): ThemeOverrides {
|
fun currentThemeData(darkForSystemTheme: Boolean): ThemeData {
|
||||||
val themeName = appPrefs.currentTheme.get()!!
|
val t = currentColors(darkForSystemTheme)
|
||||||
val nonSystemThemeName = if (themeName != DefaultTheme.SYSTEM.name) {
|
return ThemeData(colors = ThemeColors(
|
||||||
themeName
|
primary = t.colors.primary.toReadableHex(),
|
||||||
} else {
|
primaryVariant = t.colors.primaryVariant.toReadableHex(),
|
||||||
if (darkForSystemTheme) appPrefs.systemDarkTheme.get()!! else DefaultTheme.LIGHT.name
|
secondary = t.colors.secondary.toReadableHex(),
|
||||||
}
|
secondaryVariant = t.colors.secondaryVariant.toReadableHex(),
|
||||||
val overrides = appPrefs.themeOverrides.get().toMutableMap()
|
background = t.colors.background.toReadableHex(),
|
||||||
val nonFilledTheme = overrides[nonSystemThemeName] ?: ThemeOverrides(base = CurrentColors.value.base, colors = ThemeColors())
|
surface = t.colors.surface.toReadableHex(),
|
||||||
return nonFilledTheme.copy(colors = nonFilledTheme.colors.withFilledColors(CurrentColors.value.base))
|
title = t.appColors.title.toReadableHex(),
|
||||||
|
sentMessage = t.appColors.sentMessage.toReadableHex(),
|
||||||
|
receivedMessage = t.appColors.receivedMessage.toReadableHex()
|
||||||
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
// colors, default theme enum, localized name of theme
|
// colors, default theme enum, localized name of theme
|
||||||
@@ -66,28 +66,28 @@ object ThemeManager {
|
|||||||
Triple(
|
Triple(
|
||||||
if (darkForSystemTheme) systemDarkThemeColors().first else LightColorPalette,
|
if (darkForSystemTheme) systemDarkThemeColors().first else LightColorPalette,
|
||||||
DefaultTheme.SYSTEM,
|
DefaultTheme.SYSTEM,
|
||||||
generalGetString(MR.strings.theme_system)
|
generalGetString(R.string.theme_system)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
allThemes.add(
|
allThemes.add(
|
||||||
Triple(
|
Triple(
|
||||||
LightColorPalette,
|
LightColorPalette,
|
||||||
DefaultTheme.LIGHT,
|
DefaultTheme.LIGHT,
|
||||||
generalGetString(MR.strings.theme_light)
|
generalGetString(R.string.theme_light)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
allThemes.add(
|
allThemes.add(
|
||||||
Triple(
|
Triple(
|
||||||
DarkColorPalette,
|
DarkColorPalette,
|
||||||
DefaultTheme.DARK,
|
DefaultTheme.DARK,
|
||||||
generalGetString(MR.strings.theme_dark)
|
generalGetString(R.string.theme_dark)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
allThemes.add(
|
allThemes.add(
|
||||||
Triple(
|
Triple(
|
||||||
SimplexColorPalette,
|
SimplexColorPalette,
|
||||||
DefaultTheme.SIMPLEX,
|
DefaultTheme.SIMPLEX,
|
||||||
generalGetString(MR.strings.theme_simplex)
|
generalGetString(R.string.theme_simplex)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
return allThemes
|
return allThemes
|
||||||
@@ -128,12 +128,11 @@ object ThemeManager {
|
|||||||
CurrentColors.value = currentColors(!CurrentColors.value.colors.isLight)
|
CurrentColors.value = currentColors(!CurrentColors.value.colors.isLight)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun saveAndApplyThemeOverrides(theme: ThemeOverrides, darkForSystemTheme: Boolean) {
|
fun saveAndApplyThemeData(name: String, theme: ThemeData, darkForSystemTheme: Boolean) {
|
||||||
val overrides = appPrefs.themeOverrides.get().toMutableMap()
|
val overrides = appPrefs.themeOverrides.get().toMutableMap()
|
||||||
val prevValue = overrides[theme.base.name] ?: ThemeOverrides(base = CurrentColors.value.base, colors = ThemeColors())
|
val prevValue = overrides[name] ?: ThemeOverrides(base = CurrentColors.value.base, colors = ThemeColors())
|
||||||
overrides[theme.base.name] = prevValue.copy(colors = theme.colors)
|
overrides[name] = prevValue.copy(colors = theme.colors)
|
||||||
appPrefs.themeOverrides.set(overrides)
|
appPrefs.themeOverrides.set(overrides)
|
||||||
appPrefs.currentTheme.set(theme.base.name)
|
|
||||||
CurrentColors.value = currentColors(!CurrentColors.value.colors.isLight)
|
CurrentColors.value = currentColors(!CurrentColors.value.colors.isLight)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -152,4 +151,4 @@ object ThemeManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun Color.toReadableHex(): String = "#" + Integer.toHexString(toArgb())
|
private fun Color.toReadableHex(): String = "#" + toArgb().toHexString()
|
||||||
+12
-6
@@ -1,9 +1,20 @@
|
|||||||
package chat.simplex.common.ui.theme
|
package chat.simplex.app.ui.theme
|
||||||
|
|
||||||
import androidx.compose.material.Typography
|
import androidx.compose.material.Typography
|
||||||
import androidx.compose.ui.text.TextStyle
|
import androidx.compose.ui.text.TextStyle
|
||||||
import androidx.compose.ui.text.font.*
|
import androidx.compose.ui.text.font.*
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
|
import chat.simplex.app.R
|
||||||
|
|
||||||
|
// https://github.com/rsms/inter
|
||||||
|
val Inter = FontFamily(
|
||||||
|
Font(R.font.inter_regular),
|
||||||
|
Font(R.font.inter_italic, style = FontStyle.Italic),
|
||||||
|
Font(R.font.inter_bold, weight = FontWeight.Bold),
|
||||||
|
Font(R.font.inter_semi_bold, weight = FontWeight.SemiBold),
|
||||||
|
Font(R.font.inter_medium, weight = FontWeight.Medium),
|
||||||
|
Font(R.font.inter_light, weight = FontWeight.Light),
|
||||||
|
)
|
||||||
|
|
||||||
// Set of Material typography styles to start with
|
// Set of Material typography styles to start with
|
||||||
val Typography = Typography(
|
val Typography = Typography(
|
||||||
@@ -22,11 +33,6 @@ val Typography = Typography(
|
|||||||
fontWeight = FontWeight.Normal,
|
fontWeight = FontWeight.Normal,
|
||||||
fontSize = 18.5.sp
|
fontSize = 18.5.sp
|
||||||
),
|
),
|
||||||
h4 = TextStyle(
|
|
||||||
fontFamily = Inter,
|
|
||||||
fontWeight = FontWeight.Normal,
|
|
||||||
fontSize = 17.5.sp
|
|
||||||
),
|
|
||||||
body1 = TextStyle(
|
body1 = TextStyle(
|
||||||
fontFamily = Inter,
|
fontFamily = Inter,
|
||||||
fontWeight = FontWeight.Normal,
|
fontWeight = FontWeight.Normal,
|
||||||
+2
-2
@@ -1,4 +1,4 @@
|
|||||||
package chat.simplex.common.views
|
package chat.simplex.app.views
|
||||||
|
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.material.MaterialTheme
|
import androidx.compose.material.MaterialTheme
|
||||||
@@ -14,7 +14,7 @@ fun SplashView() {
|
|||||||
color = MaterialTheme.colors.background
|
color = MaterialTheme.colors.background
|
||||||
) {
|
) {
|
||||||
// Image(
|
// Image(
|
||||||
// painter = painterResource(MR.images.logo),
|
// painter = painterResource(R.drawable.logo),
|
||||||
// contentDescription = "Simplex Icon",
|
// contentDescription = "Simplex Icon",
|
||||||
// modifier = Modifier
|
// modifier = Modifier
|
||||||
// .height(230.dp)
|
// .height(230.dp)
|
||||||
+25
-46
@@ -1,6 +1,7 @@
|
|||||||
package chat.simplex.common.views
|
package chat.simplex.app.views
|
||||||
|
|
||||||
import androidx.compose.desktop.ui.tooling.preview.Preview
|
import android.content.res.Configuration
|
||||||
|
import androidx.activity.compose.BackHandler
|
||||||
import androidx.compose.foundation.*
|
import androidx.compose.foundation.*
|
||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.*
|
||||||
import androidx.compose.foundation.lazy.*
|
import androidx.compose.foundation.lazy.*
|
||||||
@@ -8,28 +9,23 @@ import androidx.compose.foundation.text.selection.SelectionContainer
|
|||||||
import androidx.compose.material.*
|
import androidx.compose.material.*
|
||||||
import androidx.compose.runtime.*
|
import androidx.compose.runtime.*
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.platform.LocalClipboardManager
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.text.TextStyle
|
import androidx.compose.ui.text.TextStyle
|
||||||
import androidx.compose.ui.text.font.FontFamily
|
import androidx.compose.ui.text.font.FontFamily
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
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 chat.simplex.common.model.*
|
import chat.simplex.app.model.*
|
||||||
import chat.simplex.common.ui.theme.*
|
import chat.simplex.app.ui.theme.*
|
||||||
import chat.simplex.common.views.chat.*
|
import chat.simplex.app.views.chat.*
|
||||||
import chat.simplex.common.views.helpers.*
|
import chat.simplex.app.views.helpers.*
|
||||||
import chat.simplex.common.model.ChatModel
|
import com.google.accompanist.insets.ProvideWindowInsets
|
||||||
import chat.simplex.common.platform.*
|
import com.google.accompanist.insets.navigationBarsWithImePadding
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun TerminalView(chatModel: ChatModel, close: () -> Unit) {
|
fun TerminalView(chatModel: ChatModel, close: () -> Unit) {
|
||||||
val composeState = remember { mutableStateOf(ComposeState(useLinkPreviews = false)) }
|
val composeState = remember { mutableStateOf(ComposeState(useLinkPreviews = false)) }
|
||||||
val close = {
|
|
||||||
close()
|
|
||||||
if (appPlatform.isDesktop) {
|
|
||||||
ModalManager.center.closeModals()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
BackHandler(onBack = {
|
BackHandler(onBack = {
|
||||||
close()
|
close()
|
||||||
})
|
})
|
||||||
@@ -46,15 +42,14 @@ private fun sendCommand(chatModel: ChatModel, composeState: MutableState<Compose
|
|||||||
val prefPerformLA = chatModel.controller.appPrefs.performLA.get()
|
val prefPerformLA = chatModel.controller.appPrefs.performLA.get()
|
||||||
val s = composeState.value.message
|
val s = composeState.value.message
|
||||||
if (s.startsWith("/sql") && (!prefPerformLA || !developerTools)) {
|
if (s.startsWith("/sql") && (!prefPerformLA || !developerTools)) {
|
||||||
val resp = CR.ChatCmdError(null, ChatError.ChatErrorChat(ChatErrorType.CommandError("Failed reading: empty")))
|
val resp = CR.ChatCmdError(null, ChatError.ChatErrorChat(ChatErrorType.СommandError("Failed reading: empty")))
|
||||||
chatModel.addTerminalItem(TerminalItem.cmd(null, CC.Console(s)))
|
chatModel.addTerminalItem(TerminalItem.cmd(CC.Console(s)))
|
||||||
chatModel.addTerminalItem(TerminalItem.resp(null, resp))
|
chatModel.addTerminalItem(TerminalItem.resp(resp))
|
||||||
composeState.value = ComposeState(useLinkPreviews = false)
|
composeState.value = ComposeState(useLinkPreviews = false)
|
||||||
} else {
|
} else {
|
||||||
withApi {
|
withApi {
|
||||||
// show "in progress"
|
// show "in progress"
|
||||||
// TODO show active remote host in chat console?
|
chatModel.controller.sendCmd(CC.Console(s))
|
||||||
chatModel.controller.sendCmd(chatModel.remoteHostId(), CC.Console(s))
|
|
||||||
composeState.value = ComposeState(useLinkPreviews = false)
|
composeState.value = ComposeState(useLinkPreviews = false)
|
||||||
// hide "in progress"
|
// hide "in progress"
|
||||||
}
|
}
|
||||||
@@ -86,19 +81,15 @@ fun TerminalLayout(
|
|||||||
recState = remember { mutableStateOf(RecordingState.NotStarted) },
|
recState = remember { mutableStateOf(RecordingState.NotStarted) },
|
||||||
isDirectChat = false,
|
isDirectChat = false,
|
||||||
liveMessageAlertShown = SharedPreference(get = { false }, set = {}),
|
liveMessageAlertShown = SharedPreference(get = { false }, set = {}),
|
||||||
sendMsgEnabled = true,
|
|
||||||
nextSendGrpInv = false,
|
|
||||||
needToAllowVoiceToContact = false,
|
needToAllowVoiceToContact = false,
|
||||||
allowedVoiceByPrefs = false,
|
allowedVoiceByPrefs = false,
|
||||||
userIsObserver = false,
|
userIsObserver = false,
|
||||||
userCanSend = true,
|
userCanSend = true,
|
||||||
allowVoiceToContact = {},
|
allowVoiceToContact = {},
|
||||||
sendMessage = { sendCommand() },
|
sendMessage = sendCommand,
|
||||||
sendLiveMessage = null,
|
sendLiveMessage = null,
|
||||||
updateLiveMessage = null,
|
updateLiveMessage = null,
|
||||||
editPrevMessage = {},
|
|
||||||
onMessageChange = ::onMessageChange,
|
onMessageChange = ::onMessageChange,
|
||||||
onFilesPasted = {},
|
|
||||||
textStyle = textStyle
|
textStyle = textStyle
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -108,8 +99,8 @@ fun TerminalLayout(
|
|||||||
Surface(
|
Surface(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.padding(contentPadding)
|
.padding(contentPadding)
|
||||||
.fillMaxWidth(),
|
.fillMaxWidth()
|
||||||
color = MaterialTheme.colors.background
|
.themedBackground()
|
||||||
) {
|
) {
|
||||||
TerminalLog(terminalItems)
|
TerminalLog(terminalItems)
|
||||||
}
|
}
|
||||||
@@ -125,32 +116,19 @@ fun TerminalLog(terminalItems: List<TerminalItem>) {
|
|||||||
DisposableEffect(Unit) {
|
DisposableEffect(Unit) {
|
||||||
onDispose { lazyListState = listState.firstVisibleItemIndex to listState.firstVisibleItemScrollOffset }
|
onDispose { lazyListState = listState.firstVisibleItemIndex to listState.firstVisibleItemScrollOffset }
|
||||||
}
|
}
|
||||||
val reversedTerminalItems by remember {
|
val reversedTerminalItems by remember { derivedStateOf { terminalItems.reversed().toList() } }
|
||||||
derivedStateOf {
|
val context = LocalContext.current
|
||||||
// Such logic prevents concurrent modification
|
|
||||||
val res = ArrayList<TerminalItem>()
|
|
||||||
var i = 0
|
|
||||||
while (i < terminalItems.size) {
|
|
||||||
res.add(terminalItems[i])
|
|
||||||
i++
|
|
||||||
}
|
|
||||||
res.asReversed()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
val clipboard = LocalClipboardManager.current
|
|
||||||
LazyColumn(state = listState, reverseLayout = true) {
|
LazyColumn(state = listState, reverseLayout = true) {
|
||||||
items(reversedTerminalItems) { item ->
|
items(reversedTerminalItems) { item ->
|
||||||
val rhId = item.remoteHostId
|
|
||||||
val rhIdStr = if (rhId == null) "" else "$rhId "
|
|
||||||
Text(
|
Text(
|
||||||
"$rhIdStr${item.date.toString().subSequence(11, 19)} ${item.label}",
|
"${item.date.toString().subSequence(11, 19)} ${item.label}",
|
||||||
style = TextStyle(fontFamily = FontFamily.Monospace, fontSize = 18.sp, color = MaterialTheme.colors.primary),
|
style = TextStyle(fontFamily = FontFamily.Monospace, fontSize = 18.sp, color = MaterialTheme.colors.primary),
|
||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
overflow = TextOverflow.Ellipsis,
|
overflow = TextOverflow.Ellipsis,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.clickable {
|
.clickable {
|
||||||
ModalManager.start.showModal(endButtons = { ShareButton { clipboard.shareText(item.details) } }) {
|
ModalManager.shared.showModal(endButtons = { ShareButton { shareText(context, item.details) } }) {
|
||||||
SelectionContainer(modifier = Modifier.verticalScroll(rememberScrollState())) {
|
SelectionContainer(modifier = Modifier.verticalScroll(rememberScrollState())) {
|
||||||
Text(item.details, modifier = Modifier.padding(horizontal = DEFAULT_PADDING).padding(bottom = DEFAULT_PADDING))
|
Text(item.details, modifier = Modifier.padding(horizontal = DEFAULT_PADDING).padding(bottom = DEFAULT_PADDING))
|
||||||
}
|
}
|
||||||
@@ -161,11 +139,12 @@ fun TerminalLog(terminalItems: List<TerminalItem>) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Preview/*(
|
@Preview(showBackground = true)
|
||||||
|
@Preview(
|
||||||
uiMode = Configuration.UI_MODE_NIGHT_YES,
|
uiMode = Configuration.UI_MODE_NIGHT_YES,
|
||||||
showBackground = true,
|
showBackground = true,
|
||||||
name = "Dark Mode"
|
name = "Dark Mode"
|
||||||
)*/
|
)
|
||||||
@Composable
|
@Composable
|
||||||
fun PreviewTerminalLayout() {
|
fun PreviewTerminalLayout() {
|
||||||
SimpleXTheme {
|
SimpleXTheme {
|
||||||
@@ -0,0 +1,189 @@
|
|||||||
|
package chat.simplex.app.views
|
||||||
|
|
||||||
|
import androidx.compose.foundation.*
|
||||||
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.foundation.text.BasicTextField
|
||||||
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
|
import androidx.compose.material.*
|
||||||
|
import androidx.compose.material.MaterialTheme.colors
|
||||||
|
import androidx.compose.runtime.*
|
||||||
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.focus.*
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.SolidColor
|
||||||
|
import androidx.compose.ui.res.painterResource
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.TextStyle
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.input.KeyboardCapitalization
|
||||||
|
import androidx.compose.ui.text.style.*
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import chat.simplex.app.*
|
||||||
|
import chat.simplex.app.R
|
||||||
|
import chat.simplex.app.model.ChatModel
|
||||||
|
import chat.simplex.app.model.Profile
|
||||||
|
import chat.simplex.app.ui.theme.*
|
||||||
|
import chat.simplex.app.views.helpers.*
|
||||||
|
import chat.simplex.app.views.onboarding.OnboardingStage
|
||||||
|
import chat.simplex.app.views.onboarding.ReadableText
|
||||||
|
import com.google.accompanist.insets.navigationBarsWithImePadding
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||||
|
|
||||||
|
fun isValidDisplayName(name: String) : Boolean {
|
||||||
|
return (name.firstOrNull { it.isWhitespace() }) == null && !name.startsWith("@") && !name.startsWith("#")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun CreateProfilePanel(chatModel: ChatModel, close: () -> Unit) {
|
||||||
|
val displayName = rememberSaveable { mutableStateOf("") }
|
||||||
|
val fullName = rememberSaveable { mutableStateOf("") }
|
||||||
|
val focusRequester = remember { FocusRequester() }
|
||||||
|
|
||||||
|
Surface(Modifier.background(MaterialTheme.colors.onBackground)) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.fillMaxSize().verticalScroll(rememberScrollState())
|
||||||
|
) {
|
||||||
|
/*CloseSheetBar(close = {
|
||||||
|
if (chatModel.users.isEmpty()) {
|
||||||
|
chatModel.onboardingStage.value = OnboardingStage.Step1_SimpleXInfo
|
||||||
|
} else {
|
||||||
|
close()
|
||||||
|
}
|
||||||
|
})*/
|
||||||
|
Column(Modifier.padding(horizontal = DEFAULT_PADDING * 1f)) {
|
||||||
|
AppBarTitle(stringResource(R.string.create_profile))
|
||||||
|
ReadableText(R.string.your_profile_is_stored_on_your_device, TextAlign.Center, padding = PaddingValues())
|
||||||
|
ReadableText(R.string.profile_is_only_shared_with_your_contacts, TextAlign.Center)
|
||||||
|
Spacer(Modifier.height(DEFAULT_PADDING * 1.5f))
|
||||||
|
Row(Modifier.padding(bottom = DEFAULT_PADDING_HALF).fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
|
||||||
|
Text(
|
||||||
|
stringResource(R.string.display_name),
|
||||||
|
fontSize = 16.sp
|
||||||
|
)
|
||||||
|
if (!isValidDisplayName(displayName.value)) {
|
||||||
|
Text(
|
||||||
|
stringResource(R.string.no_spaces),
|
||||||
|
fontSize = 16.sp,
|
||||||
|
color = Color.Red
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ProfileNameField(displayName, "", ::isValidDisplayName, focusRequester)
|
||||||
|
Spacer(Modifier.height(DEFAULT_PADDING))
|
||||||
|
Text(
|
||||||
|
stringResource(R.string.full_name_optional__prompt),
|
||||||
|
fontSize = 16.sp,
|
||||||
|
modifier = Modifier.padding(bottom = DEFAULT_PADDING_HALF)
|
||||||
|
)
|
||||||
|
ProfileNameField(fullName, "", ::isValidDisplayName)
|
||||||
|
}
|
||||||
|
Spacer(Modifier.fillMaxHeight().weight(1f))
|
||||||
|
Row {
|
||||||
|
if (chatModel.users.isEmpty()) {
|
||||||
|
SimpleButtonDecorated(
|
||||||
|
text = stringResource(R.string.about_simplex),
|
||||||
|
icon = painterResource(R.drawable.ic_arrow_back_ios_new),
|
||||||
|
textDecoration = TextDecoration.None,
|
||||||
|
fontWeight = FontWeight.Medium
|
||||||
|
) { chatModel.onboardingStage.value = OnboardingStage.Step1_SimpleXInfo }
|
||||||
|
}
|
||||||
|
Spacer(Modifier.fillMaxWidth().weight(1f))
|
||||||
|
val enabled = displayName.value.isNotEmpty() && isValidDisplayName(displayName.value)
|
||||||
|
val createModifier: Modifier
|
||||||
|
val createColor: Color
|
||||||
|
if (enabled) {
|
||||||
|
createModifier = Modifier.clickable { createProfile(chatModel, displayName.value, fullName.value, close) }.padding(8.dp)
|
||||||
|
createColor = MaterialTheme.colors.primary
|
||||||
|
} else {
|
||||||
|
createModifier = Modifier.padding(8.dp)
|
||||||
|
createColor = MaterialTheme.colors.secondary
|
||||||
|
}
|
||||||
|
Surface(shape = RoundedCornerShape(20.dp)) {
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically, modifier = createModifier) {
|
||||||
|
Text(stringResource(R.string.create_profile_button), style = MaterialTheme.typography.caption, color = createColor, fontWeight = FontWeight.Medium)
|
||||||
|
Icon(painterResource(R.drawable.ic_arrow_forward_ios), stringResource(R.string.create_profile_button), tint = createColor)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
delay(300)
|
||||||
|
focusRequester.requestFocus()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun createProfile(chatModel: ChatModel, displayName: String, fullName: String, close: () -> Unit) {
|
||||||
|
withApi {
|
||||||
|
val user = chatModel.controller.apiCreateActiveUser(
|
||||||
|
Profile(displayName, fullName, null)
|
||||||
|
) ?: return@withApi
|
||||||
|
chatModel.currentUser.value = user
|
||||||
|
if (chatModel.users.isEmpty()) {
|
||||||
|
chatModel.controller.startChat(user)
|
||||||
|
chatModel.onboardingStage.value = OnboardingStage.Step3_SetNotificationsMode
|
||||||
|
SimplexApp.context.chatModel.controller.ntfManager.createNtfChannelsMaybeShowAlert()
|
||||||
|
} else {
|
||||||
|
val users = chatModel.controller.listUsers()
|
||||||
|
chatModel.users.clear()
|
||||||
|
chatModel.users.addAll(users)
|
||||||
|
chatModel.controller.getUserChatData()
|
||||||
|
close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun ProfileNameField(name: MutableState<String>, placeholder: String = "", isValid: (String) -> Boolean = { true }, focusRequester: FocusRequester? = null) {
|
||||||
|
var valid by rememberSaveable { mutableStateOf(true) }
|
||||||
|
var focused by rememberSaveable { mutableStateOf(false) }
|
||||||
|
val strokeColor by remember {
|
||||||
|
derivedStateOf {
|
||||||
|
if (valid) {
|
||||||
|
if (focused) {
|
||||||
|
CurrentColors.value.colors.secondary.copy(alpha = 0.6f)
|
||||||
|
} else {
|
||||||
|
CurrentColors.value.colors.secondary.copy(alpha = 0.3f)
|
||||||
|
}
|
||||||
|
} else Color.Red
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = DEFAULT_PADDING)
|
||||||
|
.navigationBarsWithImePadding()
|
||||||
|
.onFocusChanged { focused = it.isFocused }
|
||||||
|
Box(
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.height(52.dp)
|
||||||
|
.border(border = BorderStroke(1.dp, strokeColor), shape = RoundedCornerShape(50)),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
BasicTextField(
|
||||||
|
value = name.value,
|
||||||
|
onValueChange = { name.value = it },
|
||||||
|
modifier = if (focusRequester == null) modifier else modifier.focusRequester(focusRequester),
|
||||||
|
textStyle = TextStyle(fontSize = 18.sp, color = colors.onBackground),
|
||||||
|
keyboardOptions = KeyboardOptions(
|
||||||
|
capitalization = KeyboardCapitalization.None,
|
||||||
|
autoCorrect = false
|
||||||
|
),
|
||||||
|
singleLine = true,
|
||||||
|
cursorBrush = SolidColor(MaterialTheme.colors.secondary)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
snapshotFlow { name.value }
|
||||||
|
.distinctUntilChanged()
|
||||||
|
.collect {
|
||||||
|
valid = isValid(it)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+18
-16
@@ -1,8 +1,10 @@
|
|||||||
package chat.simplex.common.views.call
|
package chat.simplex.app.views.call
|
||||||
|
|
||||||
import chat.simplex.common.model.ChatModel
|
import android.util.Log
|
||||||
import chat.simplex.common.platform.*
|
import chat.simplex.app.TAG
|
||||||
import chat.simplex.common.views.helpers.withApi
|
import chat.simplex.app.model.ChatModel
|
||||||
|
import chat.simplex.app.views.helpers.ModalManager
|
||||||
|
import chat.simplex.app.views.helpers.withApi
|
||||||
import kotlinx.datetime.Clock
|
import kotlinx.datetime.Clock
|
||||||
import kotlin.time.Duration.Companion.minutes
|
import kotlin.time.Duration.Companion.minutes
|
||||||
|
|
||||||
@@ -14,16 +16,17 @@ class CallManager(val chatModel: ChatModel) {
|
|||||||
if (invitation.user.showNotifications) {
|
if (invitation.user.showNotifications) {
|
||||||
if (Clock.System.now() - invitation.callTs <= 3.minutes) {
|
if (Clock.System.now() - invitation.callTs <= 3.minutes) {
|
||||||
activeCallInvitation.value = invitation
|
activeCallInvitation.value = invitation
|
||||||
ntfManager.notifyCallInvitation(invitation)
|
controller.ntfManager.notifyCallInvitation(invitation)
|
||||||
} else {
|
} else {
|
||||||
val contact = invitation.contact
|
val contact = invitation.contact
|
||||||
ntfManager.displayNotification(user = invitation.user, chatId = contact.id, displayName = contact.displayName, msgText = invitation.callTypeText)
|
controller.ntfManager.displayNotification(user = invitation.user, chatId = contact.id, displayName = contact.displayName, msgText = invitation.callTypeText)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun acceptIncomingCall(invitation: RcvCallInvitation) {
|
fun acceptIncomingCall(invitation: RcvCallInvitation) {
|
||||||
|
ModalManager.shared.closeModals()
|
||||||
val call = chatModel.activeCall.value
|
val call = chatModel.activeCall.value
|
||||||
if (call == null) {
|
if (call == null) {
|
||||||
justAcceptIncomingCall(invitation = invitation)
|
justAcceptIncomingCall(invitation = invitation)
|
||||||
@@ -43,26 +46,25 @@ class CallManager(val chatModel: ChatModel) {
|
|||||||
private fun justAcceptIncomingCall(invitation: RcvCallInvitation) {
|
private fun justAcceptIncomingCall(invitation: RcvCallInvitation) {
|
||||||
with (chatModel) {
|
with (chatModel) {
|
||||||
activeCall.value = Call(
|
activeCall.value = Call(
|
||||||
remoteHostId = invitation.remoteHostId,
|
|
||||||
contact = invitation.contact,
|
contact = invitation.contact,
|
||||||
callState = CallState.InvitationAccepted,
|
callState = CallState.InvitationAccepted,
|
||||||
localMedia = invitation.callType.media,
|
localMedia = invitation.callType.media,
|
||||||
sharedKey = invitation.sharedKey,
|
sharedKey = invitation.sharedKey
|
||||||
)
|
)
|
||||||
showCallView.value = true
|
showCallView.value = true
|
||||||
val useRelay = controller.appPrefs.webrtcPolicyRelay.get()
|
val useRelay = controller.appPrefs.webrtcPolicyRelay.get()
|
||||||
val iceServers = getIceServers()
|
val iceServers = getIceServers()
|
||||||
Log.d(TAG, "answerIncomingCall iceServers: $iceServers")
|
Log.d(TAG, "answerIncomingCall iceServers: $iceServers")
|
||||||
callCommand.add(WCallCommand.Start(
|
callCommand.value = WCallCommand.Start(
|
||||||
media = invitation.callType.media,
|
media = invitation.callType.media,
|
||||||
aesKey = invitation.sharedKey,
|
aesKey = invitation.sharedKey,
|
||||||
iceServers = iceServers,
|
iceServers = iceServers,
|
||||||
relay = useRelay
|
relay = useRelay
|
||||||
))
|
)
|
||||||
callInvitations.remove(invitation.contact.id)
|
callInvitations.remove(invitation.contact.id)
|
||||||
if (invitation.contact.id == activeCallInvitation.value?.contact?.id) {
|
if (invitation.contact.id == activeCallInvitation.value?.contact?.id) {
|
||||||
activeCallInvitation.value = null
|
activeCallInvitation.value = null
|
||||||
ntfManager.cancelCallNotification()
|
controller.ntfManager.cancelCallNotification()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -75,9 +77,9 @@ class CallManager(val chatModel: ChatModel) {
|
|||||||
showCallView.value = false
|
showCallView.value = false
|
||||||
} else {
|
} else {
|
||||||
Log.d(TAG, "CallManager.endCall: ending call...")
|
Log.d(TAG, "CallManager.endCall: ending call...")
|
||||||
callCommand.add(WCallCommand.End)
|
callCommand.value = WCallCommand.End
|
||||||
showCallView.value = false
|
showCallView.value = false
|
||||||
controller.apiEndCall(call.remoteHostId, call.contact)
|
controller.apiEndCall(call.contact)
|
||||||
activeCall.value = null
|
activeCall.value = null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -88,10 +90,10 @@ class CallManager(val chatModel: ChatModel) {
|
|||||||
callInvitations.remove(invitation.contact.id)
|
callInvitations.remove(invitation.contact.id)
|
||||||
if (invitation.contact.id == activeCallInvitation.value?.contact?.id) {
|
if (invitation.contact.id == activeCallInvitation.value?.contact?.id) {
|
||||||
activeCallInvitation.value = null
|
activeCallInvitation.value = null
|
||||||
ntfManager.cancelCallNotification()
|
controller.ntfManager.cancelCallNotification()
|
||||||
}
|
}
|
||||||
withApi {
|
withApi {
|
||||||
if (!controller.apiRejectCall(invitation.remoteHostId, invitation.contact)) {
|
if (!controller.apiRejectCall(invitation.contact)) {
|
||||||
Log.e(TAG, "apiRejectCall error")
|
Log.e(TAG, "apiRejectCall error")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -101,7 +103,7 @@ class CallManager(val chatModel: ChatModel) {
|
|||||||
fun reportCallRemoteEnded(invitation: RcvCallInvitation) {
|
fun reportCallRemoteEnded(invitation: RcvCallInvitation) {
|
||||||
if (chatModel.activeCallInvitation.value?.contact?.id == invitation.contact.id) {
|
if (chatModel.activeCallInvitation.value?.contact?.id == invitation.contact.id) {
|
||||||
chatModel.activeCallInvitation.value = null
|
chatModel.activeCallInvitation.value = null
|
||||||
ntfManager.cancelCallNotification()
|
chatModel.controller.ntfManager.cancelCallNotification()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+72
-125
@@ -1,4 +1,4 @@
|
|||||||
package chat.simplex.common.views.call
|
package chat.simplex.app.views.call
|
||||||
|
|
||||||
import android.Manifest
|
import android.Manifest
|
||||||
import android.annotation.SuppressLint
|
import android.annotation.SuppressLint
|
||||||
@@ -9,64 +9,59 @@ import android.media.*
|
|||||||
import android.os.Build
|
import android.os.Build
|
||||||
import android.os.PowerManager
|
import android.os.PowerManager
|
||||||
import android.os.PowerManager.PROXIMITY_SCREEN_OFF_WAKE_LOCK
|
import android.os.PowerManager.PROXIMITY_SCREEN_OFF_WAKE_LOCK
|
||||||
|
import android.util.Log
|
||||||
import android.view.ViewGroup
|
import android.view.ViewGroup
|
||||||
import android.webkit.*
|
import android.webkit.*
|
||||||
import androidx.compose.desktop.ui.tooling.preview.Preview
|
import androidx.activity.compose.BackHandler
|
||||||
import androidx.compose.foundation.*
|
import androidx.annotation.StringRes
|
||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.*
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
|
||||||
import androidx.compose.material.*
|
import androidx.compose.material.*
|
||||||
import androidx.compose.runtime.*
|
import androidx.compose.runtime.*
|
||||||
import androidx.compose.runtime.saveable.rememberSaveable
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
import androidx.compose.runtime.snapshots.SnapshotStateList
|
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.graphics.painter.Painter
|
import androidx.compose.ui.graphics.painter.Painter
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.platform.LocalLifecycleOwner
|
import androidx.compose.ui.platform.LocalLifecycleOwner
|
||||||
import dev.icerock.moko.resources.compose.painterResource
|
import androidx.compose.ui.res.painterResource
|
||||||
import dev.icerock.moko.resources.compose.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.text.TextStyle
|
import androidx.compose.ui.text.TextStyle
|
||||||
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.viewinterop.AndroidView
|
import androidx.compose.ui.viewinterop.AndroidView
|
||||||
import androidx.lifecycle.Lifecycle
|
import androidx.lifecycle.Lifecycle
|
||||||
import androidx.lifecycle.LifecycleEventObserver
|
import androidx.lifecycle.LifecycleEventObserver
|
||||||
import androidx.webkit.WebViewAssetLoader
|
import androidx.webkit.WebViewAssetLoader
|
||||||
import androidx.webkit.WebViewClientCompat
|
import androidx.webkit.WebViewClientCompat
|
||||||
import chat.simplex.common.model.*
|
import chat.simplex.app.*
|
||||||
import chat.simplex.common.ui.theme.*
|
import chat.simplex.app.R
|
||||||
import chat.simplex.common.model.ChatModel
|
import chat.simplex.app.model.*
|
||||||
import chat.simplex.common.model.Contact
|
import chat.simplex.app.ui.theme.*
|
||||||
import chat.simplex.common.platform.*
|
import chat.simplex.app.views.helpers.ProfileImage
|
||||||
import chat.simplex.common.views.helpers.*
|
import chat.simplex.app.views.helpers.withApi
|
||||||
import chat.simplex.res.MR
|
import chat.simplex.app.views.usersettings.NotificationsMode
|
||||||
import com.google.accompanist.permissions.rememberMultiplePermissionsState
|
import com.google.accompanist.permissions.rememberMultiplePermissionsState
|
||||||
import dev.icerock.moko.resources.StringResource
|
|
||||||
import kotlinx.coroutines.*
|
import kotlinx.coroutines.*
|
||||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
|
||||||
import kotlinx.coroutines.flow.filterNotNull
|
|
||||||
import kotlinx.datetime.Clock
|
|
||||||
import kotlinx.serialization.decodeFromString
|
import kotlinx.serialization.decodeFromString
|
||||||
import kotlinx.serialization.encodeToString
|
import kotlinx.serialization.encodeToString
|
||||||
|
|
||||||
@SuppressLint("SourceLockedOrientationActivity")
|
@SuppressLint("SourceLockedOrientationActivity")
|
||||||
@Composable
|
@Composable
|
||||||
actual fun ActiveCallView() {
|
fun ActiveCallView(chatModel: ChatModel) {
|
||||||
val chatModel = ChatModel
|
|
||||||
BackHandler(onBack = {
|
BackHandler(onBack = {
|
||||||
val call = chatModel.activeCall.value
|
val call = chatModel.activeCall.value
|
||||||
if (call != null) withBGApi { chatModel.callManager.endCall(call) }
|
if (call != null) withApi { chatModel.callManager.endCall(call) }
|
||||||
})
|
})
|
||||||
val audioViaBluetooth = rememberSaveable { mutableStateOf(false) }
|
val audioViaBluetooth = rememberSaveable { mutableStateOf(false) }
|
||||||
val ntfModeService = remember { chatModel.controller.appPrefs.notificationsMode.get() == NotificationsMode.SERVICE }
|
val ntfModeService = remember { chatModel.controller.appPrefs.notificationsMode.get() == NotificationsMode.SERVICE.name }
|
||||||
LaunchedEffect(Unit) {
|
LaunchedEffect(Unit) {
|
||||||
// Start service when call happening since it's not already started.
|
// Start service when call happening since it's not already started.
|
||||||
// It's needed to prevent Android from shutting down a microphone after a minute or so when screen is off
|
// It's needed to prevent Android from shutting down a microphone after a minute or so when screen is off
|
||||||
if (!ntfModeService) platform.androidServiceStart()
|
if (!ntfModeService) SimplexService.start(SimplexApp.context)
|
||||||
}
|
}
|
||||||
DisposableEffect(Unit) {
|
DisposableEffect(Unit) {
|
||||||
val am = androidAppContext.getSystemService(Context.AUDIO_SERVICE) as AudioManager
|
val am = SimplexApp.context.getSystemService(Context.AUDIO_SERVICE) as AudioManager
|
||||||
var btDeviceCount = 0
|
var btDeviceCount = 0
|
||||||
val audioCallback = object: AudioDeviceCallback() {
|
val audioCallback = object: AudioDeviceCallback() {
|
||||||
override fun onAudioDevicesAdded(addedDevices: Array<out AudioDeviceInfo>) {
|
override fun onAudioDevicesAdded(addedDevices: Array<out AudioDeviceInfo>) {
|
||||||
@@ -93,16 +88,16 @@ actual fun ActiveCallView() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
am.registerAudioDeviceCallback(audioCallback, null)
|
am.registerAudioDeviceCallback(audioCallback, null)
|
||||||
val pm = (androidAppContext.getSystemService(Context.POWER_SERVICE) as PowerManager)
|
val pm = (SimplexApp.context.getSystemService(Context.POWER_SERVICE) as PowerManager)
|
||||||
val proximityLock = if (pm.isWakeLockLevelSupported(PROXIMITY_SCREEN_OFF_WAKE_LOCK)) {
|
val proximityLock = if (pm.isWakeLockLevelSupported(PROXIMITY_SCREEN_OFF_WAKE_LOCK)) {
|
||||||
pm.newWakeLock(PROXIMITY_SCREEN_OFF_WAKE_LOCK, androidAppContext.packageName + ":proximityLock")
|
pm.newWakeLock(PROXIMITY_SCREEN_OFF_WAKE_LOCK, SimplexApp.context.packageName + ":proximityLock")
|
||||||
} else {
|
} else {
|
||||||
null
|
null
|
||||||
}
|
}
|
||||||
proximityLock?.acquire()
|
proximityLock?.acquire()
|
||||||
onDispose {
|
onDispose {
|
||||||
// Stop it when call ended
|
// Stop it when call ended
|
||||||
if (!ntfModeService) platform.androidServiceSafeStop()
|
if (!ntfModeService) SimplexService.safeStopService(SimplexApp.context)
|
||||||
dropAudioManagerOverrides()
|
dropAudioManagerOverrides()
|
||||||
am.unregisterAudioDeviceCallback(audioCallback)
|
am.unregisterAudioDeviceCallback(audioCallback)
|
||||||
proximityLock?.release()
|
proximityLock?.release()
|
||||||
@@ -115,32 +110,31 @@ actual fun ActiveCallView() {
|
|||||||
val call = chatModel.activeCall.value
|
val call = chatModel.activeCall.value
|
||||||
if (call != null) {
|
if (call != null) {
|
||||||
Log.d(TAG, "has active call $call")
|
Log.d(TAG, "has active call $call")
|
||||||
val callRh = call.remoteHostId
|
|
||||||
when (val r = apiMsg.resp) {
|
when (val r = apiMsg.resp) {
|
||||||
is WCallResponse.Capabilities -> withBGApi {
|
is WCallResponse.Capabilities -> withApi {
|
||||||
val callType = CallType(call.localMedia, r.capabilities)
|
val callType = CallType(call.localMedia, r.capabilities)
|
||||||
chatModel.controller.apiSendCallInvitation(callRh, call.contact, callType)
|
chatModel.controller.apiSendCallInvitation(call.contact, callType)
|
||||||
chatModel.activeCall.value = call.copy(callState = CallState.InvitationSent, localCapabilities = r.capabilities)
|
chatModel.activeCall.value = call.copy(callState = CallState.InvitationSent, localCapabilities = r.capabilities)
|
||||||
}
|
}
|
||||||
is WCallResponse.Offer -> withBGApi {
|
is WCallResponse.Offer -> withApi {
|
||||||
chatModel.controller.apiSendCallOffer(callRh, call.contact, r.offer, r.iceCandidates, call.localMedia, r.capabilities)
|
chatModel.controller.apiSendCallOffer(call.contact, r.offer, r.iceCandidates, call.localMedia, r.capabilities)
|
||||||
chatModel.activeCall.value = call.copy(callState = CallState.OfferSent, localCapabilities = r.capabilities)
|
chatModel.activeCall.value = call.copy(callState = CallState.OfferSent, localCapabilities = r.capabilities)
|
||||||
}
|
}
|
||||||
is WCallResponse.Answer -> withBGApi {
|
is WCallResponse.Answer -> withApi {
|
||||||
chatModel.controller.apiSendCallAnswer(callRh, call.contact, r.answer, r.iceCandidates)
|
chatModel.controller.apiSendCallAnswer(call.contact, r.answer, r.iceCandidates)
|
||||||
chatModel.activeCall.value = call.copy(callState = CallState.Negotiated)
|
chatModel.activeCall.value = call.copy(callState = CallState.Negotiated)
|
||||||
}
|
}
|
||||||
is WCallResponse.Ice -> withBGApi {
|
is WCallResponse.Ice -> withApi {
|
||||||
chatModel.controller.apiSendCallExtraInfo(callRh, call.contact, r.iceCandidates)
|
chatModel.controller.apiSendCallExtraInfo(call.contact, r.iceCandidates)
|
||||||
}
|
}
|
||||||
is WCallResponse.Connection ->
|
is WCallResponse.Connection ->
|
||||||
try {
|
try {
|
||||||
val callStatus = json.decodeFromString<WebRTCCallStatus>("\"${r.state.connectionState}\"")
|
val callStatus = json.decodeFromString<WebRTCCallStatus>("\"${r.state.connectionState}\"")
|
||||||
if (callStatus == WebRTCCallStatus.Connected) {
|
if (callStatus == WebRTCCallStatus.Connected) {
|
||||||
chatModel.activeCall.value = call.copy(callState = CallState.Connected, connectedAt = Clock.System.now())
|
chatModel.activeCall.value = call.copy(callState = CallState.Connected)
|
||||||
setCallSound(call.soundSpeaker, audioViaBluetooth)
|
setCallSound(call.soundSpeaker, audioViaBluetooth)
|
||||||
}
|
}
|
||||||
withBGApi { chatModel.controller.apiCallStatus(callRh, call.contact, callStatus) }
|
withApi { chatModel.controller.apiCallStatus(call.contact, callStatus) }
|
||||||
} catch (e: Error) {
|
} catch (e: Error) {
|
||||||
Log.d(TAG,"call status ${r.state.connectionState} not used")
|
Log.d(TAG,"call status ${r.state.connectionState} not used")
|
||||||
}
|
}
|
||||||
@@ -150,12 +144,9 @@ actual fun ActiveCallView() {
|
|||||||
setCallSound(call.soundSpeaker, audioViaBluetooth)
|
setCallSound(call.soundSpeaker, audioViaBluetooth)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
is WCallResponse.End -> {
|
|
||||||
withBGApi { chatModel.callManager.endCall(call) }
|
|
||||||
}
|
|
||||||
is WCallResponse.Ended -> {
|
is WCallResponse.Ended -> {
|
||||||
chatModel.activeCall.value = call.copy(callState = CallState.Ended)
|
chatModel.activeCall.value = call.copy(callState = CallState.Ended)
|
||||||
withBGApi { chatModel.callManager.endCall(call) }
|
withApi { chatModel.callManager.endCall(call) }
|
||||||
chatModel.showCallView.value = false
|
chatModel.showCallView.value = false
|
||||||
}
|
}
|
||||||
is WCallResponse.Ok -> when (val cmd = apiMsg.command) {
|
is WCallResponse.Ok -> when (val cmd = apiMsg.command) {
|
||||||
@@ -170,7 +161,7 @@ actual fun ActiveCallView() {
|
|||||||
is WCallCommand.Camera -> {
|
is WCallCommand.Camera -> {
|
||||||
chatModel.activeCall.value = call.copy(localCamera = cmd.camera)
|
chatModel.activeCall.value = call.copy(localCamera = cmd.camera)
|
||||||
if (!call.audioEnabled) {
|
if (!call.audioEnabled) {
|
||||||
chatModel.callCommand.add(WCallCommand.Media(CallMediaType.Audio, enable = false))
|
chatModel.callCommand.value = WCallCommand.Media(CallMediaType.Audio, enable = false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
is WCallCommand.End ->
|
is WCallCommand.End ->
|
||||||
@@ -194,17 +185,10 @@ actual fun ActiveCallView() {
|
|||||||
activity.volumeControlStream = AudioManager.STREAM_VOICE_CALL
|
activity.volumeControlStream = AudioManager.STREAM_VOICE_CALL
|
||||||
// Lock orientation to portrait in order to have good experience with calls
|
// Lock orientation to portrait in order to have good experience with calls
|
||||||
activity.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_PORTRAIT
|
activity.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_PORTRAIT
|
||||||
chatModel.activeCallViewIsVisible.value = true
|
|
||||||
// After the first call, End command gets added to the list which prevents making another calls
|
|
||||||
chatModel.callCommand.removeAll { it is WCallCommand.End }
|
|
||||||
keepScreenOn(true)
|
|
||||||
onDispose {
|
onDispose {
|
||||||
activity.volumeControlStream = prevVolumeControlStream
|
activity.volumeControlStream = prevVolumeControlStream
|
||||||
// Unlock orientation
|
// Unlock orientation
|
||||||
activity.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED
|
activity.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED
|
||||||
chatModel.activeCallViewIsVisible.value = false
|
|
||||||
chatModel.callCommand.clear()
|
|
||||||
keepScreenOn(false)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -214,9 +198,9 @@ private fun ActiveCallOverlay(call: Call, chatModel: ChatModel, audioViaBluetoot
|
|||||||
ActiveCallOverlayLayout(
|
ActiveCallOverlayLayout(
|
||||||
call = call,
|
call = call,
|
||||||
speakerCanBeEnabled = !audioViaBluetooth.value,
|
speakerCanBeEnabled = !audioViaBluetooth.value,
|
||||||
dismiss = { withBGApi { chatModel.callManager.endCall(call) } },
|
dismiss = { withApi { chatModel.callManager.endCall(call) } },
|
||||||
toggleAudio = { chatModel.callCommand.add(WCallCommand.Media(CallMediaType.Audio, enable = !call.audioEnabled)) },
|
toggleAudio = { chatModel.callCommand.value = WCallCommand.Media(CallMediaType.Audio, enable = !call.audioEnabled) },
|
||||||
toggleVideo = { chatModel.callCommand.add(WCallCommand.Media(CallMediaType.Video, enable = !call.videoEnabled)) },
|
toggleVideo = { chatModel.callCommand.value = WCallCommand.Media(CallMediaType.Video, enable = !call.videoEnabled) },
|
||||||
toggleSound = {
|
toggleSound = {
|
||||||
var call = chatModel.activeCall.value
|
var call = chatModel.activeCall.value
|
||||||
if (call != null) {
|
if (call != null) {
|
||||||
@@ -225,12 +209,12 @@ private fun ActiveCallOverlay(call: Call, chatModel: ChatModel, audioViaBluetoot
|
|||||||
setCallSound(call.soundSpeaker, audioViaBluetooth)
|
setCallSound(call.soundSpeaker, audioViaBluetooth)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
flipCamera = { chatModel.callCommand.add(WCallCommand.Camera(call.localCamera.flipped)) }
|
flipCamera = { chatModel.callCommand.value = WCallCommand.Camera(call.localCamera.flipped) }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun setCallSound(speaker: Boolean, audioViaBluetooth: MutableState<Boolean>) {
|
private fun setCallSound(speaker: Boolean, audioViaBluetooth: MutableState<Boolean>) {
|
||||||
val am = androidAppContext.getSystemService(Context.AUDIO_SERVICE) as AudioManager
|
val am = SimplexApp.context.getSystemService(Context.AUDIO_SERVICE) as AudioManager
|
||||||
Log.d(TAG, "setCallSound: set audio mode, speaker enabled: $speaker")
|
Log.d(TAG, "setCallSound: set audio mode, speaker enabled: $speaker")
|
||||||
am.mode = AudioManager.MODE_IN_COMMUNICATION
|
am.mode = AudioManager.MODE_IN_COMMUNICATION
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||||
@@ -256,7 +240,7 @@ private fun setCallSound(speaker: Boolean, audioViaBluetooth: MutableState<Boole
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun dropAudioManagerOverrides() {
|
private fun dropAudioManagerOverrides() {
|
||||||
val am = androidAppContext.getSystemService(Context.AUDIO_SERVICE) as AudioManager
|
val am = SimplexApp.context.getSystemService(Context.AUDIO_SERVICE) as AudioManager
|
||||||
am.mode = AudioManager.MODE_NORMAL
|
am.mode = AudioManager.MODE_NORMAL
|
||||||
// Clear selected communication device to default value after we changed it in call
|
// Clear selected communication device to default value after we changed it in call
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||||
@@ -281,21 +265,19 @@ private fun ActiveCallOverlayLayout(
|
|||||||
when (call.peerMedia ?: call.localMedia) {
|
when (call.peerMedia ?: call.localMedia) {
|
||||||
CallMediaType.Video -> {
|
CallMediaType.Video -> {
|
||||||
CallInfoView(call, alignment = Alignment.Start)
|
CallInfoView(call, alignment = Alignment.Start)
|
||||||
Box(Modifier.fillMaxWidth().fillMaxHeight().weight(1f), contentAlignment = Alignment.BottomCenter) {
|
Spacer(Modifier.fillMaxHeight().weight(1f))
|
||||||
DisabledBackgroundCallsButton()
|
|
||||||
}
|
|
||||||
Row(Modifier.fillMaxWidth().padding(horizontal = 6.dp), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically) {
|
Row(Modifier.fillMaxWidth().padding(horizontal = 6.dp), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically) {
|
||||||
ToggleAudioButton(call, toggleAudio)
|
ToggleAudioButton(call, toggleAudio)
|
||||||
Spacer(Modifier.size(40.dp))
|
Spacer(Modifier.size(40.dp))
|
||||||
IconButton(onClick = dismiss) {
|
IconButton(onClick = dismiss) {
|
||||||
Icon(painterResource(MR.images.ic_call_end_filled), stringResource(MR.strings.icon_descr_hang_up), tint = Color.Red, modifier = Modifier.size(64.dp))
|
Icon(painterResource(R.drawable.ic_call_end_filled), stringResource(R.string.icon_descr_hang_up), tint = Color.Red, modifier = Modifier.size(64.dp))
|
||||||
}
|
}
|
||||||
if (call.videoEnabled) {
|
if (call.videoEnabled) {
|
||||||
ControlButton(call, painterResource(MR.images.ic_flip_camera_android_filled), MR.strings.icon_descr_flip_camera, flipCamera)
|
ControlButton(call, painterResource(R.drawable.ic_flip_camera_android_filled), R.string.icon_descr_flip_camera, flipCamera)
|
||||||
ControlButton(call, painterResource(MR.images.ic_videocam_filled), MR.strings.icon_descr_video_off, toggleVideo)
|
ControlButton(call, painterResource(R.drawable.ic_videocam_filled), R.string.icon_descr_video_off, toggleVideo)
|
||||||
} else {
|
} else {
|
||||||
Spacer(Modifier.size(48.dp))
|
Spacer(Modifier.size(48.dp))
|
||||||
ControlButton(call, painterResource(MR.images.ic_videocam_off), MR.strings.icon_descr_video_on, toggleVideo)
|
ControlButton(call, painterResource(R.drawable.ic_videocam_off), R.string.icon_descr_video_on, toggleVideo)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -309,13 +291,11 @@ private fun ActiveCallOverlayLayout(
|
|||||||
ProfileImage(size = 192.dp, image = call.contact.profile.image)
|
ProfileImage(size = 192.dp, image = call.contact.profile.image)
|
||||||
CallInfoView(call, alignment = Alignment.CenterHorizontally)
|
CallInfoView(call, alignment = Alignment.CenterHorizontally)
|
||||||
}
|
}
|
||||||
Box(Modifier.fillMaxWidth().fillMaxHeight().weight(1f), contentAlignment = Alignment.BottomCenter) {
|
Spacer(Modifier.fillMaxHeight().weight(1f))
|
||||||
DisabledBackgroundCallsButton()
|
|
||||||
}
|
|
||||||
Box(Modifier.fillMaxWidth().padding(bottom = DEFAULT_BOTTOM_PADDING), contentAlignment = Alignment.CenterStart) {
|
Box(Modifier.fillMaxWidth().padding(bottom = DEFAULT_BOTTOM_PADDING), contentAlignment = Alignment.CenterStart) {
|
||||||
Box(Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) {
|
Box(Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) {
|
||||||
IconButton(onClick = dismiss) {
|
IconButton(onClick = dismiss) {
|
||||||
Icon(painterResource(MR.images.ic_call_end_filled), stringResource(MR.strings.icon_descr_hang_up), tint = Color.Red, modifier = Modifier.size(64.dp))
|
Icon(painterResource(R.drawable.ic_call_end_filled), stringResource(R.string.icon_descr_hang_up), tint = Color.Red, modifier = Modifier.size(64.dp))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Box(Modifier.padding(start = 32.dp)) {
|
Box(Modifier.padding(start = 32.dp)) {
|
||||||
@@ -333,7 +313,7 @@ private fun ActiveCallOverlayLayout(
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun ControlButton(call: Call, icon: Painter, iconText: StringResource, action: () -> Unit, enabled: Boolean = true) {
|
private fun ControlButton(call: Call, icon: Painter, @StringRes iconText: Int, action: () -> Unit, enabled: Boolean = true) {
|
||||||
if (call.hasMedia) {
|
if (call.hasMedia) {
|
||||||
IconButton(onClick = action, enabled = enabled) {
|
IconButton(onClick = action, enabled = enabled) {
|
||||||
Icon(icon, stringResource(iconText), tint = if (enabled) Color(0xFFFFFFD8) else MaterialTheme.colors.secondary, modifier = Modifier.size(40.dp))
|
Icon(icon, stringResource(iconText), tint = if (enabled) Color(0xFFFFFFD8) else MaterialTheme.colors.secondary, modifier = Modifier.size(40.dp))
|
||||||
@@ -346,18 +326,18 @@ private fun ControlButton(call: Call, icon: Painter, iconText: StringResource, a
|
|||||||
@Composable
|
@Composable
|
||||||
private fun ToggleAudioButton(call: Call, toggleAudio: () -> Unit) {
|
private fun ToggleAudioButton(call: Call, toggleAudio: () -> Unit) {
|
||||||
if (call.audioEnabled) {
|
if (call.audioEnabled) {
|
||||||
ControlButton(call, painterResource(MR.images.ic_mic), MR.strings.icon_descr_audio_off, toggleAudio)
|
ControlButton(call, painterResource(R.drawable.ic_mic), R.string.icon_descr_audio_off, toggleAudio)
|
||||||
} else {
|
} else {
|
||||||
ControlButton(call, painterResource(MR.images.ic_mic_off), MR.strings.icon_descr_audio_on, toggleAudio)
|
ControlButton(call, painterResource(R.drawable.ic_mic_off), R.string.icon_descr_audio_on, toggleAudio)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun ToggleSoundButton(call: Call, enabled: Boolean, toggleSound: () -> Unit) {
|
private fun ToggleSoundButton(call: Call, enabled: Boolean, toggleSound: () -> Unit) {
|
||||||
if (call.soundSpeaker) {
|
if (call.soundSpeaker) {
|
||||||
ControlButton(call, painterResource(MR.images.ic_volume_up), MR.strings.icon_descr_speaker_off, toggleSound, enabled)
|
ControlButton(call, painterResource(R.drawable.ic_volume_up), R.string.icon_descr_speaker_off, toggleSound, enabled)
|
||||||
} else {
|
} else {
|
||||||
ControlButton(call, painterResource(MR.images.ic_volume_down), MR.strings.icon_descr_speaker_on, toggleSound, enabled)
|
ControlButton(call, painterResource(R.drawable.ic_volume_down), R.string.icon_descr_speaker_on, toggleSound, enabled)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -370,37 +350,12 @@ fun CallInfoView(call: Call, alignment: Alignment.Horizontal) {
|
|||||||
InfoText(call.callState.text)
|
InfoText(call.callState.text)
|
||||||
|
|
||||||
val connInfo = call.connectionInfo
|
val connInfo = call.connectionInfo
|
||||||
// val connInfoText = if (connInfo == null) "" else " (${connInfo.text}, ${connInfo.protocolText})"
|
// val connInfoText = if (connInfo == null) "" else " (${connInfo.text}, ${connInfo.protocolText})"
|
||||||
val connInfoText = if (connInfo == null) "" else " (${connInfo.text})"
|
val connInfoText = if (connInfo == null) "" else " (${connInfo.text})"
|
||||||
InfoText(call.encryptionStatus + connInfoText)
|
InfoText(call.encryptionStatus + connInfoText)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun DisabledBackgroundCallsButton() {
|
|
||||||
var show by remember { mutableStateOf(!platform.androidIsBackgroundCallAllowed()) }
|
|
||||||
if (show) {
|
|
||||||
Row(
|
|
||||||
Modifier
|
|
||||||
.padding(bottom = 24.dp)
|
|
||||||
.clickable {
|
|
||||||
withBGApi {
|
|
||||||
show = !platform.androidAskToAllowBackgroundCalls()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.background(WarningOrange.copy(0.3f), RoundedCornerShape(50))
|
|
||||||
.padding(start = 14.dp, top = 4.dp, end = 8.dp, bottom = 4.dp),
|
|
||||||
verticalAlignment = Alignment.CenterVertically
|
|
||||||
) {
|
|
||||||
Text(stringResource(MR.strings.system_restricted_background_in_call_title), color = WarningOrange)
|
|
||||||
Spacer(Modifier.width(8.dp))
|
|
||||||
IconButton(onClick = { show = false }, Modifier.size(24.dp)) {
|
|
||||||
Icon(painterResource(MR.images.ic_close), null, tint = WarningOrange)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
//@Composable
|
//@Composable
|
||||||
//fun CallViewDebug(close: () -> Unit) {
|
//fun CallViewDebug(close: () -> Unit) {
|
||||||
// val callCommand = remember { mutableStateOf<WCallCommand?>(null)}
|
// val callCommand = remember { mutableStateOf<WCallCommand?>(null)}
|
||||||
@@ -452,7 +407,7 @@ private fun DisabledBackgroundCallsButton() {
|
|||||||
//}
|
//}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun WebRTCView(callCommand: SnapshotStateList<WCallCommand>, onResponse: (WVAPIMessage) -> Unit) {
|
fun WebRTCView(callCommand: MutableState<WCallCommand?>, onResponse: (WVAPIMessage) -> Unit) {
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
val webView = remember { mutableStateOf<WebView?>(null) }
|
val webView = remember { mutableStateOf<WebView?>(null) }
|
||||||
val permissionsState = rememberMultiplePermissionsState(
|
val permissionsState = rememberMultiplePermissionsState(
|
||||||
@@ -483,19 +438,13 @@ fun WebRTCView(callCommand: SnapshotStateList<WCallCommand>, onResponse: (WVAPIM
|
|||||||
webView.value = null
|
webView.value = null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
LaunchedEffect(callCommand.value, webView.value) {
|
||||||
|
val cmd = callCommand.value
|
||||||
val wv = webView.value
|
val wv = webView.value
|
||||||
if (wv != null) {
|
if (cmd != null && wv != null) {
|
||||||
LaunchedEffect(Unit) {
|
|
||||||
snapshotFlow { callCommand.firstOrNull() }
|
|
||||||
.distinctUntilChanged()
|
|
||||||
.filterNotNull()
|
|
||||||
.collect {
|
|
||||||
while (callCommand.isNotEmpty()) {
|
|
||||||
val cmd = callCommand.removeFirst()
|
|
||||||
Log.d(TAG, "WebRTCView LaunchedEffect executing $cmd")
|
Log.d(TAG, "WebRTCView LaunchedEffect executing $cmd")
|
||||||
processCommand(wv, cmd)
|
processCommand(wv, cmd)
|
||||||
}
|
callCommand.value = null
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
val assetLoader = WebViewAssetLoader.Builder()
|
val assetLoader = WebViewAssetLoader.Builder()
|
||||||
@@ -521,7 +470,7 @@ fun WebRTCView(callCommand: SnapshotStateList<WCallCommand>, onResponse: (WVAPIM
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
this.webViewClient = LocalContentWebViewClient(webView, assetLoader)
|
this.webViewClient = LocalContentWebViewClient(assetLoader)
|
||||||
this.clearHistory()
|
this.clearHistory()
|
||||||
this.clearCache(true)
|
this.clearCache(true)
|
||||||
this.addJavascriptInterface(WebRTCInterface(onResponse), "WebRTCInterface")
|
this.addJavascriptInterface(WebRTCInterface(onResponse), "WebRTCInterface")
|
||||||
@@ -531,10 +480,19 @@ fun WebRTCView(callCommand: SnapshotStateList<WCallCommand>, onResponse: (WVAPIM
|
|||||||
webViewSettings.javaScriptEnabled = true
|
webViewSettings.javaScriptEnabled = true
|
||||||
webViewSettings.mediaPlaybackRequiresUserGesture = false
|
webViewSettings.mediaPlaybackRequiresUserGesture = false
|
||||||
webViewSettings.cacheMode = WebSettings.LOAD_NO_CACHE
|
webViewSettings.cacheMode = WebSettings.LOAD_NO_CACHE
|
||||||
this.loadUrl("file:android_asset/www/android/call.html")
|
this.loadUrl("file:android_asset/www/call.html")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
) { wv ->
|
||||||
|
Log.d(TAG, "WebRTCView: webview ready")
|
||||||
|
// for debugging
|
||||||
|
// wv.evaluateJavascript("sendMessageToNative = ({resp}) => WebRTCInterface.postMessage(JSON.stringify({command: resp}))", null)
|
||||||
|
scope.launch {
|
||||||
|
delay(2000L)
|
||||||
|
wv.evaluateJavascript("sendMessageToNative = (msg) => WebRTCInterface.postMessage(JSON.stringify(msg))", null)
|
||||||
|
webView.value = wv
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
) { /* WebView */ }
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -549,28 +507,19 @@ class WebRTCInterface(private val onResponse: (WVAPIMessage) -> Unit) {
|
|||||||
// for debugging
|
// for debugging
|
||||||
// onResponse(message)
|
// onResponse(message)
|
||||||
onResponse(json.decodeFromString(message))
|
onResponse(json.decodeFromString(message))
|
||||||
} catch (e: Exception) {
|
} catch (e: Error) {
|
||||||
Log.e(TAG, "failed parsing WebView message: $message")
|
Log.e(TAG, "failed parsing WebView message: $message")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private class LocalContentWebViewClient(val webView: MutableState<WebView?>, private val assetLoader: WebViewAssetLoader) : WebViewClientCompat() {
|
private class LocalContentWebViewClient(private val assetLoader: WebViewAssetLoader) : WebViewClientCompat() {
|
||||||
override fun shouldInterceptRequest(
|
override fun shouldInterceptRequest(
|
||||||
view: WebView,
|
view: WebView,
|
||||||
request: WebResourceRequest
|
request: WebResourceRequest
|
||||||
): WebResourceResponse? {
|
): WebResourceResponse? {
|
||||||
return assetLoader.shouldInterceptRequest(request.url)
|
return assetLoader.shouldInterceptRequest(request.url)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onPageFinished(view: WebView, url: String) {
|
|
||||||
super.onPageFinished(view, url)
|
|
||||||
view.evaluateJavascript("sendMessageToNative = (msg) => WebRTCInterface.postMessage(JSON.stringify(msg))", null)
|
|
||||||
webView.value = view
|
|
||||||
Log.d(TAG, "WebRTCView: webview ready")
|
|
||||||
// for debugging
|
|
||||||
// view.evaluateJavascript("sendMessageToNative = ({resp}) => WebRTCInterface.postMessage(JSON.stringify({command: resp}))", null)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Preview
|
@Preview
|
||||||
@@ -579,7 +528,6 @@ fun PreviewActiveCallOverlayVideo() {
|
|||||||
SimpleXTheme {
|
SimpleXTheme {
|
||||||
ActiveCallOverlayLayout(
|
ActiveCallOverlayLayout(
|
||||||
call = Call(
|
call = Call(
|
||||||
remoteHostId = null,
|
|
||||||
contact = Contact.sampleData,
|
contact = Contact.sampleData,
|
||||||
callState = CallState.Negotiated,
|
callState = CallState.Negotiated,
|
||||||
localMedia = CallMediaType.Video,
|
localMedia = CallMediaType.Video,
|
||||||
@@ -605,7 +553,6 @@ fun PreviewActiveCallOverlayAudio() {
|
|||||||
SimpleXTheme {
|
SimpleXTheme {
|
||||||
ActiveCallOverlayLayout(
|
ActiveCallOverlayLayout(
|
||||||
call = Call(
|
call = Call(
|
||||||
remoteHostId = null,
|
|
||||||
contact = Contact.sampleData,
|
contact = Contact.sampleData,
|
||||||
callState = CallState.Negotiated,
|
callState = CallState.Negotiated,
|
||||||
localMedia = CallMediaType.Audio,
|
localMedia = CallMediaType.Audio,
|
||||||
+28
-30
@@ -4,14 +4,16 @@ import android.app.Activity
|
|||||||
import android.app.KeyguardManager
|
import android.app.KeyguardManager
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
|
import android.content.res.Configuration
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import chat.simplex.common.platform.Log
|
import android.util.Log
|
||||||
import android.view.WindowManager
|
import android.view.WindowManager
|
||||||
import androidx.activity.ComponentActivity
|
import androidx.activity.ComponentActivity
|
||||||
import androidx.activity.compose.setContent
|
import androidx.activity.compose.setContent
|
||||||
import androidx.compose.desktop.ui.tooling.preview.Preview
|
import androidx.activity.viewModels
|
||||||
import androidx.compose.foundation.Image
|
import androidx.compose.foundation.Image
|
||||||
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.*
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material.*
|
import androidx.compose.material.*
|
||||||
@@ -23,26 +25,25 @@ import androidx.compose.ui.graphics.Color
|
|||||||
import androidx.compose.ui.graphics.painter.Painter
|
import androidx.compose.ui.graphics.painter.Painter
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.res.painterResource
|
import androidx.compose.ui.res.painterResource
|
||||||
|
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.Preview
|
||||||
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 chat.simplex.app.*
|
import chat.simplex.app.*
|
||||||
import chat.simplex.app.R
|
import chat.simplex.app.R
|
||||||
import chat.simplex.common.model.*
|
import chat.simplex.app.model.*
|
||||||
import chat.simplex.app.model.NtfManager.OpenChatAction
|
import chat.simplex.app.model.NtfManager.Companion.OpenChatAction
|
||||||
import chat.simplex.common.platform.ntfManager
|
import chat.simplex.app.ui.theme.*
|
||||||
import chat.simplex.common.ui.theme.*
|
import chat.simplex.app.views.helpers.ProfileImage
|
||||||
import chat.simplex.common.views.call.*
|
|
||||||
import chat.simplex.common.views.helpers.*
|
|
||||||
import chat.simplex.res.MR
|
|
||||||
import dev.icerock.moko.resources.compose.stringResource
|
|
||||||
import kotlinx.datetime.Clock
|
import kotlinx.datetime.Clock
|
||||||
|
|
||||||
class IncomingCallActivity: ComponentActivity() {
|
class IncomingCallActivity: ComponentActivity() {
|
||||||
|
private val vm by viewModels<SimplexViewModel>()
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
setContent { IncomingCallActivityView(ChatModel) }
|
setContent { IncomingCallActivityView(vm.chatModel) }
|
||||||
unlockForIncomingCall()
|
unlockForIncomingCall()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -96,12 +97,11 @@ fun IncomingCallActivityView(m: ChatModel) {
|
|||||||
SimpleXTheme {
|
SimpleXTheme {
|
||||||
Surface(
|
Surface(
|
||||||
Modifier
|
Modifier
|
||||||
.fillMaxSize(),
|
.themedBackground()
|
||||||
color = MaterialTheme.colors.background
|
.fillMaxSize()) {
|
||||||
) {
|
|
||||||
if (showCallView) {
|
if (showCallView) {
|
||||||
Box {
|
Box {
|
||||||
ActiveCallView()
|
ActiveCallView(m)
|
||||||
if (invitation != null) IncomingCallAlertView(invitation, m)
|
if (invitation != null) IncomingCallAlertView(invitation, m)
|
||||||
}
|
}
|
||||||
} else if (invitation != null) {
|
} else if (invitation != null) {
|
||||||
@@ -119,7 +119,7 @@ fun IncomingCallLockScreenAlert(invitation: RcvCallInvitation, chatModel: ChatMo
|
|||||||
DisposableEffect(Unit) {
|
DisposableEffect(Unit) {
|
||||||
onDispose {
|
onDispose {
|
||||||
// Cancel notification whatever happens next since otherwise sound from notification and from inside the app can co-exist
|
// Cancel notification whatever happens next since otherwise sound from notification and from inside the app can co-exist
|
||||||
ntfManager.cancelCallNotification()
|
chatModel.controller.ntfManager.cancelCallNotification()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
IncomingCallLockScreenAlertLayout(
|
IncomingCallLockScreenAlertLayout(
|
||||||
@@ -129,7 +129,7 @@ fun IncomingCallLockScreenAlert(invitation: RcvCallInvitation, chatModel: ChatMo
|
|||||||
rejectCall = { cm.endCall(invitation = invitation) },
|
rejectCall = { cm.endCall(invitation = invitation) },
|
||||||
ignoreCall = {
|
ignoreCall = {
|
||||||
chatModel.activeCallInvitation.value = null
|
chatModel.activeCallInvitation.value = null
|
||||||
ntfManager.cancelCallNotification()
|
chatModel.controller.ntfManager.cancelCallNotification()
|
||||||
},
|
},
|
||||||
acceptCall = { cm.acceptIncomingCall(invitation = invitation) },
|
acceptCall = { cm.acceptIncomingCall(invitation = invitation) },
|
||||||
openApp = {
|
openApp = {
|
||||||
@@ -169,18 +169,18 @@ fun IncomingCallLockScreenAlertLayout(
|
|||||||
Text(invitation.contact.chatViewName, style = MaterialTheme.typography.h2)
|
Text(invitation.contact.chatViewName, style = MaterialTheme.typography.h2)
|
||||||
Spacer(Modifier.fillMaxHeight().weight(1f))
|
Spacer(Modifier.fillMaxHeight().weight(1f))
|
||||||
Row {
|
Row {
|
||||||
LockScreenCallButton(stringResource(MR.strings.reject), painterResource(R.drawable.ic_call_end_filled), Color.Red, rejectCall)
|
LockScreenCallButton(stringResource(R.string.reject), painterResource(R.drawable.ic_call_end_filled), Color.Red, rejectCall)
|
||||||
Spacer(Modifier.size(48.dp))
|
Spacer(Modifier.size(48.dp))
|
||||||
LockScreenCallButton(stringResource(MR.strings.ignore), painterResource(R.drawable.ic_close), MaterialTheme.colors.primary, ignoreCall)
|
LockScreenCallButton(stringResource(R.string.ignore), painterResource(R.drawable.ic_close), MaterialTheme.colors.primary, ignoreCall)
|
||||||
Spacer(Modifier.size(48.dp))
|
Spacer(Modifier.size(48.dp))
|
||||||
LockScreenCallButton(stringResource(MR.strings.accept), painterResource(R.drawable.ic_check_filled), SimplexGreen, acceptCall)
|
LockScreenCallButton(stringResource(R.string.accept), painterResource(R.drawable.ic_check_filled), SimplexGreen, acceptCall)
|
||||||
}
|
}
|
||||||
} else if (callOnLockScreen == CallOnLockScreen.SHOW) {
|
} else if (callOnLockScreen == CallOnLockScreen.SHOW) {
|
||||||
SimpleXLogo()
|
SimpleXLogo()
|
||||||
Text(stringResource(MR.strings.open_simplex_chat_to_accept_call), textAlign = TextAlign.Center, lineHeight = 22.sp)
|
Text(stringResource(R.string.open_simplex_chat_to_accept_call), textAlign = TextAlign.Center, lineHeight = 22.sp)
|
||||||
Text(stringResource(MR.strings.allow_accepting_calls_from_lock_screen), textAlign = TextAlign.Center, style = MaterialTheme.typography.body2, lineHeight = 22.sp)
|
Text(stringResource(R.string.allow_accepting_calls_from_lock_screen), textAlign = TextAlign.Center, style = MaterialTheme.typography.body2, lineHeight = 22.sp)
|
||||||
Spacer(Modifier.fillMaxHeight().weight(1f))
|
Spacer(Modifier.fillMaxHeight().weight(1f))
|
||||||
SimpleButton(text = stringResource(MR.strings.open_verb), icon = painterResource(R.drawable.ic_check_filled), click = openApp)
|
SimpleButton(text = stringResource(R.string.open_verb), icon = painterResource(R.drawable.ic_check_filled), click = openApp)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -189,7 +189,7 @@ fun IncomingCallLockScreenAlertLayout(
|
|||||||
private fun SimpleXLogo() {
|
private fun SimpleXLogo() {
|
||||||
Image(
|
Image(
|
||||||
painter = painterResource(if (isInDarkTheme()) R.drawable.logo_light else R.drawable.logo),
|
painter = painterResource(if (isInDarkTheme()) R.drawable.logo_light else R.drawable.logo),
|
||||||
contentDescription = stringResource(MR.strings.image_descr_simplex_logo),
|
contentDescription = stringResource(R.string.image_descr_simplex_logo),
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.padding(vertical = DEFAULT_PADDING)
|
.padding(vertical = DEFAULT_PADDING)
|
||||||
.fillMaxWidth(0.80f)
|
.fillMaxWidth(0.80f)
|
||||||
@@ -217,21 +217,19 @@ private fun LockScreenCallButton(text: String, icon: Painter, color: Color, acti
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Preview/*(
|
@Preview(
|
||||||
uiMode = Configuration.UI_MODE_NIGHT_YES,
|
uiMode = Configuration.UI_MODE_NIGHT_YES,
|
||||||
showBackground = true
|
showBackground = true
|
||||||
)*/
|
)
|
||||||
@Composable
|
@Composable
|
||||||
fun PreviewIncomingCallLockScreenAlert() {
|
fun PreviewIncomingCallLockScreenAlert() {
|
||||||
SimpleXTheme(true) {
|
SimpleXTheme(true) {
|
||||||
Surface(
|
Surface(
|
||||||
Modifier
|
Modifier
|
||||||
.fillMaxSize(),
|
.themedBackground()
|
||||||
color = MaterialTheme.colors.background
|
.fillMaxSize()) {
|
||||||
) {
|
|
||||||
IncomingCallLockScreenAlertLayout(
|
IncomingCallLockScreenAlertLayout(
|
||||||
invitation = RcvCallInvitation(
|
invitation = RcvCallInvitation(
|
||||||
remoteHostId = null,
|
|
||||||
user = User.sampleData,
|
user = User.sampleData,
|
||||||
contact = Contact.sampleData,
|
contact = Contact.sampleData,
|
||||||
callType = CallType(media = CallMediaType.Audio, capabilities = CallCapabilities(encryption = false)),
|
callType = CallType(media = CallMediaType.Audio, capabilities = CallCapabilities(encryption = false)),
|
||||||
+24
-24
@@ -1,6 +1,5 @@
|
|||||||
package chat.simplex.common.views.call
|
package chat.simplex.app.views.call
|
||||||
|
|
||||||
import androidx.compose.desktop.ui.tooling.preview.Preview
|
|
||||||
import androidx.compose.foundation.*
|
import androidx.compose.foundation.*
|
||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.*
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
@@ -11,31 +10,33 @@ import androidx.compose.ui.Modifier
|
|||||||
import androidx.compose.ui.draw.scale
|
import androidx.compose.ui.draw.scale
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.graphics.painter.Painter
|
import androidx.compose.ui.graphics.painter.Painter
|
||||||
import dev.icerock.moko.resources.compose.painterResource
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import dev.icerock.moko.resources.compose.stringResource
|
import androidx.compose.ui.res.painterResource
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import chat.simplex.common.model.*
|
import chat.simplex.app.R
|
||||||
import chat.simplex.common.ui.theme.*
|
import chat.simplex.app.SimplexApp
|
||||||
import chat.simplex.common.views.helpers.ProfileImage
|
import chat.simplex.app.model.*
|
||||||
import chat.simplex.common.views.usersettings.ProfilePreview
|
import chat.simplex.app.ui.theme.*
|
||||||
import chat.simplex.common.platform.ntfManager
|
import chat.simplex.app.views.helpers.ProfileImage
|
||||||
import chat.simplex.common.platform.SoundPlayer
|
import chat.simplex.app.views.usersettings.ProfilePreview
|
||||||
import chat.simplex.res.MR
|
|
||||||
import kotlinx.datetime.Clock
|
import kotlinx.datetime.Clock
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun IncomingCallAlertView(invitation: RcvCallInvitation, chatModel: ChatModel) {
|
fun IncomingCallAlertView(invitation: RcvCallInvitation, chatModel: ChatModel) {
|
||||||
val cm = chatModel.callManager
|
val cm = chatModel.callManager
|
||||||
|
val cxt = LocalContext.current
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
LaunchedEffect(true) { SoundPlayer.start(scope, sound = !chatModel.showCallView.value) }
|
LaunchedEffect(true) { SoundPlayer.shared.start(cxt, scope, sound = !chatModel.showCallView.value) }
|
||||||
DisposableEffect(true) { onDispose { SoundPlayer.stop() } }
|
DisposableEffect(true) { onDispose { SoundPlayer.shared.stop() } }
|
||||||
IncomingCallAlertLayout(
|
IncomingCallAlertLayout(
|
||||||
invitation,
|
invitation,
|
||||||
chatModel,
|
chatModel,
|
||||||
rejectCall = { cm.endCall(invitation = invitation) },
|
rejectCall = { cm.endCall(invitation = invitation) },
|
||||||
ignoreCall = {
|
ignoreCall = {
|
||||||
chatModel.activeCallInvitation.value = null
|
chatModel.activeCallInvitation.value = null
|
||||||
ntfManager.cancelCallNotification()
|
chatModel.controller.ntfManager.cancelCallNotification()
|
||||||
},
|
},
|
||||||
acceptCall = { cm.acceptIncomingCall(invitation = invitation) }
|
acceptCall = { cm.acceptIncomingCall(invitation = invitation) }
|
||||||
)
|
)
|
||||||
@@ -49,18 +50,18 @@ fun IncomingCallAlertLayout(
|
|||||||
ignoreCall: () -> Unit,
|
ignoreCall: () -> Unit,
|
||||||
acceptCall: () -> Unit
|
acceptCall: () -> Unit
|
||||||
) {
|
) {
|
||||||
val color = if (isInDarkTheme()) MaterialTheme.colors.surface else IncomingCallLight
|
val color = if (isInDarkTheme()) IncomingCallDark else IncomingCallLight
|
||||||
Column(Modifier.fillMaxWidth().background(color).padding(top = DEFAULT_PADDING, bottom = DEFAULT_PADDING, start = DEFAULT_PADDING, end = 8.dp)) {
|
Column(Modifier.fillMaxWidth().background(color).padding(top = DEFAULT_PADDING, bottom = DEFAULT_PADDING, start = DEFAULT_PADDING, end = 8.dp)) {
|
||||||
IncomingCallInfo(invitation, chatModel)
|
IncomingCallInfo(invitation, chatModel)
|
||||||
Spacer(Modifier.height(8.dp))
|
Spacer(Modifier.height(8.dp))
|
||||||
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween) {
|
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween) {
|
||||||
Row(Modifier.fillMaxWidth().weight(1f), verticalAlignment = Alignment.CenterVertically) {
|
Row(Modifier.fillMaxWidth().weight(1f), verticalAlignment = Alignment.CenterVertically) {
|
||||||
ProfilePreview(profileOf = invitation.contact, size = 64.dp)
|
ProfilePreview(profileOf = invitation.contact, size = 64.dp, color = Color.White)
|
||||||
}
|
}
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
CallButton(stringResource(MR.strings.reject), painterResource(MR.images.ic_call_end_filled), Color.Red, rejectCall)
|
CallButton(stringResource(R.string.reject), painterResource(R.drawable.ic_call_end_filled), Color.Red, rejectCall)
|
||||||
CallButton(stringResource(MR.strings.ignore), painterResource(MR.images.ic_close), MaterialTheme.colors.primary, ignoreCall)
|
CallButton(stringResource(R.string.ignore), painterResource(R.drawable.ic_close), MaterialTheme.colors.primary, ignoreCall)
|
||||||
CallButton(stringResource(MR.strings.accept), painterResource(MR.images.ic_check_filled), SimplexGreen, acceptCall)
|
CallButton(stringResource(R.string.accept), painterResource(R.drawable.ic_check_filled), SimplexGreen, acceptCall)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -74,10 +75,10 @@ fun IncomingCallInfo(invitation: RcvCallInvitation, chatModel: ChatModel) {
|
|||||||
ProfileImage(size = 32.dp, image = invitation.user.profile.image, color = MaterialTheme.colors.secondaryVariant)
|
ProfileImage(size = 32.dp, image = invitation.user.profile.image, color = MaterialTheme.colors.secondaryVariant)
|
||||||
Spacer(Modifier.width(4.dp))
|
Spacer(Modifier.width(4.dp))
|
||||||
}
|
}
|
||||||
if (invitation.callType.media == CallMediaType.Video) CallIcon(painterResource(MR.images.ic_videocam_filled), stringResource(MR.strings.icon_descr_video_call))
|
if (invitation.callType.media == CallMediaType.Video) CallIcon(painterResource(R.drawable.ic_videocam_filled), stringResource(R.string.icon_descr_video_call))
|
||||||
else CallIcon(painterResource(MR.images.ic_call_filled), stringResource(MR.strings.icon_descr_audio_call))
|
else CallIcon(painterResource(R.drawable.ic_call_filled), stringResource(R.string.icon_descr_audio_call))
|
||||||
Spacer(Modifier.width(4.dp))
|
Spacer(Modifier.width(4.dp))
|
||||||
Text(invitation.callTypeText, color = MaterialTheme.colors.onBackground)
|
Text(invitation.callTypeText)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -106,14 +107,13 @@ fun PreviewIncomingCallAlertLayout() {
|
|||||||
SimpleXTheme {
|
SimpleXTheme {
|
||||||
IncomingCallAlertLayout(
|
IncomingCallAlertLayout(
|
||||||
invitation = RcvCallInvitation(
|
invitation = RcvCallInvitation(
|
||||||
remoteHostId = null,
|
|
||||||
user = User.sampleData,
|
user = User.sampleData,
|
||||||
contact = Contact.sampleData,
|
contact = Contact.sampleData,
|
||||||
callType = CallType(media = CallMediaType.Audio, capabilities = CallCapabilities(encryption = false)),
|
callType = CallType(media = CallMediaType.Audio, capabilities = CallCapabilities(encryption = false)),
|
||||||
sharedKey = null,
|
sharedKey = null,
|
||||||
callTs = Clock.System.now()
|
callTs = Clock.System.now()
|
||||||
),
|
),
|
||||||
chatModel = ChatModel,
|
chatModel = SimplexApp.context.chatModel,
|
||||||
rejectCall = {},
|
rejectCall = {},
|
||||||
ignoreCall = {},
|
ignoreCall = {},
|
||||||
acceptCall = {}
|
acceptCall = {}
|
||||||
+14
-10
@@ -1,22 +1,22 @@
|
|||||||
package chat.simplex.common.helpers
|
package chat.simplex.app.views.call
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
import android.media.*
|
import android.media.*
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import android.os.VibrationEffect
|
import android.os.VibrationEffect
|
||||||
import android.os.Vibrator
|
import android.os.Vibrator
|
||||||
import androidx.core.content.ContextCompat
|
import androidx.core.content.ContextCompat
|
||||||
import chat.simplex.common.R
|
import chat.simplex.app.R
|
||||||
import chat.simplex.common.platform.SoundPlayerInterface
|
import chat.simplex.app.SimplexApp
|
||||||
import chat.simplex.common.platform.androidAppContext
|
import chat.simplex.app.views.helpers.withScope
|
||||||
import chat.simplex.common.views.helpers.withScope
|
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
|
|
||||||
object SoundPlayer: SoundPlayerInterface {
|
class SoundPlayer {
|
||||||
private var player: MediaPlayer? = null
|
private var player: MediaPlayer? = null
|
||||||
var playing = false
|
var playing = false
|
||||||
|
|
||||||
override fun start(scope: CoroutineScope, sound: Boolean) {
|
fun start(cxt: Context, scope: CoroutineScope, sound: Boolean) {
|
||||||
player?.reset()
|
player?.reset()
|
||||||
player = MediaPlayer().apply {
|
player = MediaPlayer().apply {
|
||||||
setAudioAttributes(
|
setAudioAttributes(
|
||||||
@@ -25,10 +25,10 @@ object SoundPlayer: SoundPlayerInterface {
|
|||||||
.setUsage(AudioAttributes.USAGE_NOTIFICATION_RINGTONE)
|
.setUsage(AudioAttributes.USAGE_NOTIFICATION_RINGTONE)
|
||||||
.build()
|
.build()
|
||||||
)
|
)
|
||||||
setDataSource(androidAppContext, Uri.parse("android.resource://" + androidAppContext.packageName + "/" + R.raw.ring_once))
|
setDataSource(SimplexApp.context, Uri.parse("android.resource://" + SimplexApp.context.packageName + "/" + R.raw.ring_once))
|
||||||
prepare()
|
prepare()
|
||||||
}
|
}
|
||||||
val vibrator = ContextCompat.getSystemService(androidAppContext, Vibrator::class.java)
|
val vibrator = ContextCompat.getSystemService(cxt, Vibrator::class.java)
|
||||||
val effect = VibrationEffect.createOneShot(250, VibrationEffect.DEFAULT_AMPLITUDE)
|
val effect = VibrationEffect.createOneShot(250, VibrationEffect.DEFAULT_AMPLITUDE)
|
||||||
playing = true
|
playing = true
|
||||||
withScope(scope) {
|
withScope(scope) {
|
||||||
@@ -40,8 +40,12 @@ object SoundPlayer: SoundPlayerInterface {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun stop() {
|
fun stop() {
|
||||||
playing = false
|
playing = false
|
||||||
player?.stop()
|
player?.stop()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
val shared = SoundPlayer()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
+34
-40
@@ -1,8 +1,13 @@
|
|||||||
package chat.simplex.common.views.call
|
package chat.simplex.app.views.call
|
||||||
|
|
||||||
import chat.simplex.common.views.helpers.generalGetString
|
import android.util.Log
|
||||||
import chat.simplex.common.model.*
|
import androidx.compose.runtime.Composable
|
||||||
import chat.simplex.res.MR
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.toUpperCase
|
||||||
|
import chat.simplex.app.*
|
||||||
|
import chat.simplex.app.model.Contact
|
||||||
|
import chat.simplex.app.model.User
|
||||||
|
import chat.simplex.app.views.helpers.generalGetString
|
||||||
import kotlinx.datetime.Instant
|
import kotlinx.datetime.Instant
|
||||||
import kotlinx.serialization.SerialName
|
import kotlinx.serialization.SerialName
|
||||||
import kotlinx.serialization.Serializable
|
import kotlinx.serialization.Serializable
|
||||||
@@ -11,7 +16,6 @@ import java.util.*
|
|||||||
import kotlin.collections.ArrayList
|
import kotlin.collections.ArrayList
|
||||||
|
|
||||||
data class Call(
|
data class Call(
|
||||||
val remoteHostId: Long?,
|
|
||||||
val contact: Contact,
|
val contact: Contact,
|
||||||
val callState: CallState,
|
val callState: CallState,
|
||||||
val localMedia: CallMediaType,
|
val localMedia: CallMediaType,
|
||||||
@@ -22,17 +26,16 @@ data class Call(
|
|||||||
val videoEnabled: Boolean = localMedia == CallMediaType.Video,
|
val videoEnabled: Boolean = localMedia == CallMediaType.Video,
|
||||||
val soundSpeaker: Boolean = localMedia == CallMediaType.Video,
|
val soundSpeaker: Boolean = localMedia == CallMediaType.Video,
|
||||||
var localCamera: VideoCamera = VideoCamera.User,
|
var localCamera: VideoCamera = VideoCamera.User,
|
||||||
val connectionInfo: ConnectionInfo? = null,
|
val connectionInfo: ConnectionInfo? = null
|
||||||
var connectedAt: Instant? = null
|
|
||||||
) {
|
) {
|
||||||
val encrypted: Boolean get() = localEncrypted && sharedKey != null
|
val encrypted: Boolean get() = localEncrypted && sharedKey != null
|
||||||
val localEncrypted: Boolean get() = localCapabilities?.encryption ?: false
|
val localEncrypted: Boolean get() = localCapabilities?.encryption ?: false
|
||||||
|
|
||||||
val encryptionStatus: String get() = when(callState) {
|
val encryptionStatus: String @Composable get() = when(callState) {
|
||||||
CallState.WaitCapabilities -> ""
|
CallState.WaitCapabilities -> ""
|
||||||
CallState.InvitationSent -> generalGetString(if (localEncrypted) MR.strings.status_e2e_encrypted else MR.strings.status_no_e2e_encryption)
|
CallState.InvitationSent -> stringResource(if (localEncrypted) R.string.status_e2e_encrypted else R.string.status_no_e2e_encryption)
|
||||||
CallState.InvitationAccepted -> generalGetString(if (sharedKey == null) MR.strings.status_contact_has_no_e2e_encryption else MR.strings.status_contact_has_e2e_encryption)
|
CallState.InvitationAccepted -> stringResource(if (sharedKey == null) R.string.status_contact_has_no_e2e_encryption else R.string.status_contact_has_e2e_encryption)
|
||||||
else -> generalGetString(if (!localEncrypted) MR.strings.status_no_e2e_encryption else if (sharedKey == null) MR.strings.status_contact_has_no_e2e_encryption else MR.strings.status_e2e_encrypted)
|
else -> stringResource(if (!localEncrypted) R.string.status_no_e2e_encryption else if (sharedKey == null) R.string.status_contact_has_no_e2e_encryption else R.string.status_e2e_encrypted)
|
||||||
}
|
}
|
||||||
|
|
||||||
val hasMedia: Boolean get() = callState == CallState.OfferSent || callState == CallState.Negotiated || callState == CallState.Connected
|
val hasMedia: Boolean get() = callState == CallState.OfferSent || callState == CallState.Negotiated || callState == CallState.Connected
|
||||||
@@ -49,16 +52,16 @@ enum class CallState {
|
|||||||
Connected,
|
Connected,
|
||||||
Ended;
|
Ended;
|
||||||
|
|
||||||
val text: String get() = when(this) {
|
val text: String @Composable get() = when(this) {
|
||||||
WaitCapabilities -> generalGetString(MR.strings.callstate_starting)
|
WaitCapabilities -> stringResource(R.string.callstate_starting)
|
||||||
InvitationSent -> generalGetString(MR.strings.callstate_waiting_for_answer)
|
InvitationSent -> stringResource(R.string.callstate_waiting_for_answer)
|
||||||
InvitationAccepted -> generalGetString(MR.strings.callstate_starting)
|
InvitationAccepted -> stringResource(R.string.callstate_starting)
|
||||||
OfferSent -> generalGetString(MR.strings.callstate_waiting_for_confirmation)
|
OfferSent -> stringResource(R.string.callstate_waiting_for_confirmation)
|
||||||
OfferReceived -> generalGetString(MR.strings.callstate_received_answer)
|
OfferReceived -> stringResource(R.string.callstate_received_answer)
|
||||||
AnswerReceived -> generalGetString(MR.strings.callstate_received_confirmation)
|
AnswerReceived -> stringResource(R.string.callstate_received_confirmation)
|
||||||
Negotiated -> generalGetString(MR.strings.callstate_connecting)
|
Negotiated -> stringResource(R.string.callstate_connecting)
|
||||||
Connected -> generalGetString(MR.strings.callstate_connected)
|
Connected -> stringResource(R.string.callstate_connected)
|
||||||
Ended -> generalGetString(MR.strings.callstate_ended)
|
Ended -> stringResource(R.string.callstate_ended)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -67,14 +70,13 @@ enum class CallState {
|
|||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
sealed class WCallCommand {
|
sealed class WCallCommand {
|
||||||
@Serializable @SerialName("capabilities") data class Capabilities(val media: CallMediaType): WCallCommand()
|
@Serializable @SerialName("capabilities") object Capabilities: WCallCommand()
|
||||||
@Serializable @SerialName("start") data class Start(val media: CallMediaType, val aesKey: String? = null, val iceServers: List<RTCIceServer>? = null, val relay: Boolean? = null): WCallCommand()
|
@Serializable @SerialName("start") data class Start(val media: CallMediaType, val aesKey: String? = null, val iceServers: List<RTCIceServer>? = null, val relay: Boolean? = null): WCallCommand()
|
||||||
@Serializable @SerialName("offer") data class Offer(val offer: String, val iceCandidates: String, val media: CallMediaType, val aesKey: String? = null, val iceServers: List<RTCIceServer>? = null, val relay: Boolean? = null): WCallCommand()
|
@Serializable @SerialName("offer") data class Offer(val offer: String, val iceCandidates: String, val media: CallMediaType, val aesKey: String? = null, val iceServers: List<RTCIceServer>? = null, val relay: Boolean? = null): WCallCommand()
|
||||||
@Serializable @SerialName("answer") data class Answer (val answer: String, val iceCandidates: String): WCallCommand()
|
@Serializable @SerialName("answer") data class Answer (val answer: String, val iceCandidates: String): WCallCommand()
|
||||||
@Serializable @SerialName("ice") data class Ice(val iceCandidates: String): WCallCommand()
|
@Serializable @SerialName("ice") data class Ice(val iceCandidates: String): WCallCommand()
|
||||||
@Serializable @SerialName("media") data class Media(val media: CallMediaType, val enable: Boolean): WCallCommand()
|
@Serializable @SerialName("media") data class Media(val media: CallMediaType, val enable: Boolean): WCallCommand()
|
||||||
@Serializable @SerialName("camera") data class Camera(val camera: VideoCamera): WCallCommand()
|
@Serializable @SerialName("camera") data class Camera(val camera: VideoCamera): WCallCommand()
|
||||||
@Serializable @SerialName("description") data class Description(val state: String, val description: String): WCallCommand()
|
|
||||||
@Serializable @SerialName("end") object End: WCallCommand()
|
@Serializable @SerialName("end") object End: WCallCommand()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -86,7 +88,6 @@ sealed class WCallResponse {
|
|||||||
@Serializable @SerialName("ice") data class Ice(val iceCandidates: String): WCallResponse()
|
@Serializable @SerialName("ice") data class Ice(val iceCandidates: String): WCallResponse()
|
||||||
@Serializable @SerialName("connection") data class Connection(val state: ConnectionState): WCallResponse()
|
@Serializable @SerialName("connection") data class Connection(val state: ConnectionState): WCallResponse()
|
||||||
@Serializable @SerialName("connected") data class Connected(val connectionInfo: ConnectionInfo): WCallResponse()
|
@Serializable @SerialName("connected") data class Connected(val connectionInfo: ConnectionInfo): WCallResponse()
|
||||||
@Serializable @SerialName("end") object End: WCallResponse()
|
|
||||||
@Serializable @SerialName("ended") object Ended: WCallResponse()
|
@Serializable @SerialName("ended") object Ended: WCallResponse()
|
||||||
@Serializable @SerialName("ok") object Ok: WCallResponse()
|
@Serializable @SerialName("ok") object Ok: WCallResponse()
|
||||||
@Serializable @SerialName("error") data class Error(val message: String): WCallResponse()
|
@Serializable @SerialName("error") data class Error(val message: String): WCallResponse()
|
||||||
@@ -96,33 +97,26 @@ sealed class WCallResponse {
|
|||||||
@Serializable data class WebRTCSession(val rtcSession: String, val rtcIceCandidates: String)
|
@Serializable data class WebRTCSession(val rtcSession: String, val rtcIceCandidates: String)
|
||||||
@Serializable data class WebRTCExtraInfo(val rtcIceCandidates: String)
|
@Serializable data class WebRTCExtraInfo(val rtcIceCandidates: String)
|
||||||
@Serializable data class CallType(val media: CallMediaType, val capabilities: CallCapabilities)
|
@Serializable data class CallType(val media: CallMediaType, val capabilities: CallCapabilities)
|
||||||
@Serializable data class RcvCallInvitation(
|
@Serializable data class RcvCallInvitation(val user: User, val contact: Contact, val callType: CallType, val sharedKey: String? = null, val callTs: Instant) {
|
||||||
val remoteHostId: Long?,
|
|
||||||
val user: User,
|
|
||||||
val contact: Contact,
|
|
||||||
val callType: CallType,
|
|
||||||
val sharedKey: String? = null,
|
|
||||||
val callTs: Instant
|
|
||||||
) {
|
|
||||||
val callTypeText: String get() = generalGetString(when(callType.media) {
|
val callTypeText: String get() = generalGetString(when(callType.media) {
|
||||||
CallMediaType.Video -> if (sharedKey == null) MR.strings.video_call_no_encryption else MR.strings.encrypted_video_call
|
CallMediaType.Video -> if (sharedKey == null) R.string.video_call_no_encryption else R.string.encrypted_video_call
|
||||||
CallMediaType.Audio -> if (sharedKey == null) MR.strings.audio_call_no_encryption else MR.strings.encrypted_audio_call
|
CallMediaType.Audio -> if (sharedKey == null) R.string.audio_call_no_encryption else R.string.encrypted_audio_call
|
||||||
})
|
})
|
||||||
val callTitle: String get() = generalGetString(when(callType.media) {
|
val callTitle: String get() = generalGetString(when(callType.media) {
|
||||||
CallMediaType.Video -> MR.strings.incoming_video_call
|
CallMediaType.Video -> R.string.incoming_video_call
|
||||||
CallMediaType.Audio -> MR.strings.incoming_audio_call
|
CallMediaType.Audio -> R.string.incoming_audio_call
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@Serializable data class CallCapabilities(val encryption: Boolean)
|
@Serializable data class CallCapabilities(val encryption: Boolean)
|
||||||
@Serializable data class ConnectionInfo(private val localCandidate: RTCIceCandidate?, private val remoteCandidate: RTCIceCandidate?) {
|
@Serializable data class ConnectionInfo(private val localCandidate: RTCIceCandidate?, private val remoteCandidate: RTCIceCandidate?) {
|
||||||
val text: String get() {
|
val text: String @Composable get() {
|
||||||
val local = localCandidate?.candidateType
|
val local = localCandidate?.candidateType
|
||||||
val remote = remoteCandidate?.candidateType
|
val remote = remoteCandidate?.candidateType
|
||||||
return when {
|
return when {
|
||||||
local == RTCIceCandidateType.Host && remote == RTCIceCandidateType.Host ->
|
local == RTCIceCandidateType.Host && remote == RTCIceCandidateType.Host ->
|
||||||
generalGetString(MR.strings.call_connection_peer_to_peer)
|
stringResource(R.string.call_connection_peer_to_peer)
|
||||||
local == RTCIceCandidateType.Relay && remote == RTCIceCandidateType.Relay ->
|
local == RTCIceCandidateType.Relay && remote == RTCIceCandidateType.Relay ->
|
||||||
generalGetString(MR.strings.call_connection_via_relay)
|
stringResource(R.string.call_connection_via_relay)
|
||||||
else ->
|
else ->
|
||||||
"${local?.value ?: "unknown"} / ${remote?.value ?: "unknown"}"
|
"${local?.value ?: "unknown"} / ${remote?.value ?: "unknown"}"
|
||||||
}
|
}
|
||||||
@@ -221,7 +215,7 @@ fun parseRTCIceServers(servers: List<String>): List<RTCIceServer>? {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun getIceServers(): List<RTCIceServer>? {
|
fun getIceServers(): List<RTCIceServer>? {
|
||||||
val value = ChatController.appPrefs.webrtcIceServers.get() ?: return null
|
val value = SimplexApp.context.chatController.appPrefs.webrtcIceServers.get() ?: return null
|
||||||
val servers: List<String> = value.split("\n")
|
val servers: List<String> = value.split("\n")
|
||||||
return parseRTCIceServers(servers)
|
return parseRTCIceServers(servers)
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,465 @@
|
|||||||
|
package chat.simplex.app.views.chat
|
||||||
|
|
||||||
|
import InfoRow
|
||||||
|
import InfoRowEllipsis
|
||||||
|
import SectionBottomSpacer
|
||||||
|
import SectionDividerSpaced
|
||||||
|
import SectionItemView
|
||||||
|
import SectionSpacer
|
||||||
|
import SectionView
|
||||||
|
import android.widget.Toast
|
||||||
|
import androidx.activity.compose.BackHandler
|
||||||
|
import androidx.compose.foundation.*
|
||||||
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.foundation.text.KeyboardActions
|
||||||
|
import androidx.compose.material.*
|
||||||
|
import androidx.compose.runtime.*
|
||||||
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.platform.ClipboardManager
|
||||||
|
import androidx.compose.ui.platform.LocalClipboardManager
|
||||||
|
import androidx.compose.ui.res.painterResource
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.AnnotatedString
|
||||||
|
import androidx.compose.ui.text.TextStyle
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import chat.simplex.app.R
|
||||||
|
import chat.simplex.app.SimplexApp
|
||||||
|
import chat.simplex.app.model.*
|
||||||
|
import chat.simplex.app.ui.theme.*
|
||||||
|
import chat.simplex.app.views.helpers.*
|
||||||
|
import chat.simplex.app.views.usersettings.*
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.flow.*
|
||||||
|
import kotlinx.datetime.Clock
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun ChatInfoView(
|
||||||
|
chatModel: ChatModel,
|
||||||
|
contact: Contact,
|
||||||
|
connStats: ConnectionStats?,
|
||||||
|
customUserProfile: Profile?,
|
||||||
|
localAlias: String,
|
||||||
|
connectionCode: String?,
|
||||||
|
close: () -> Unit,
|
||||||
|
) {
|
||||||
|
BackHandler(onBack = close)
|
||||||
|
val chat = chatModel.chats.firstOrNull { it.id == chatModel.chatId.value }
|
||||||
|
val developerTools = chatModel.controller.appPrefs.developerTools.get()
|
||||||
|
if (chat != null) {
|
||||||
|
val contactNetworkStatus = remember(chatModel.networkStatuses.toMap()) {
|
||||||
|
mutableStateOf(chatModel.contactNetworkStatus(contact))
|
||||||
|
}
|
||||||
|
ChatInfoLayout(
|
||||||
|
chat,
|
||||||
|
contact,
|
||||||
|
connStats,
|
||||||
|
contactNetworkStatus.value,
|
||||||
|
customUserProfile,
|
||||||
|
localAlias,
|
||||||
|
connectionCode,
|
||||||
|
developerTools,
|
||||||
|
onLocalAliasChanged = {
|
||||||
|
setContactAlias(chat.chatInfo.apiId, it, chatModel)
|
||||||
|
},
|
||||||
|
openPreferences = {
|
||||||
|
ModalManager.shared.showCustomModal { close ->
|
||||||
|
val user = chatModel.currentUser.value
|
||||||
|
if (user != null) {
|
||||||
|
ContactPreferencesView(chatModel, user, contact.contactId, close)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
deleteContact = { deleteContactDialog(chat.chatInfo, chatModel, close) },
|
||||||
|
clearChat = { clearChatDialog(chat.chatInfo, chatModel, close) },
|
||||||
|
switchContactAddress = {
|
||||||
|
showSwitchContactAddressAlert(chatModel, contact.contactId)
|
||||||
|
},
|
||||||
|
verifyClicked = {
|
||||||
|
ModalManager.shared.showModalCloseable { close ->
|
||||||
|
remember { derivedStateOf { (chatModel.getContactChat(contact.contactId)?.chatInfo as? ChatInfo.Direct)?.contact } }.value?.let { ct ->
|
||||||
|
VerifyCodeView(
|
||||||
|
ct.displayName,
|
||||||
|
connectionCode,
|
||||||
|
ct.verified,
|
||||||
|
verify = { code ->
|
||||||
|
chatModel.controller.apiVerifyContact(ct.contactId, code)?.let { r ->
|
||||||
|
val (verified, existingCode) = r
|
||||||
|
chatModel.updateContact(
|
||||||
|
ct.copy(
|
||||||
|
activeConn = ct.activeConn.copy(
|
||||||
|
connectionCode = if (verified) SecurityCode(existingCode, Clock.System.now()) else null
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
r
|
||||||
|
}
|
||||||
|
},
|
||||||
|
close,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun deleteContactDialog(chatInfo: ChatInfo, chatModel: ChatModel, close: (() -> Unit)? = null) {
|
||||||
|
AlertManager.shared.showAlertDialog(
|
||||||
|
title = generalGetString(R.string.delete_contact_question),
|
||||||
|
text = generalGetString(R.string.delete_contact_all_messages_deleted_cannot_undo_warning),
|
||||||
|
confirmText = generalGetString(R.string.delete_verb),
|
||||||
|
onConfirm = {
|
||||||
|
withApi {
|
||||||
|
val r = chatModel.controller.apiDeleteChat(chatInfo.chatType, chatInfo.apiId)
|
||||||
|
if (r) {
|
||||||
|
chatModel.removeChat(chatInfo.id)
|
||||||
|
chatModel.chatId.value = null
|
||||||
|
chatModel.controller.ntfManager.cancelNotificationsForChat(chatInfo.id)
|
||||||
|
close?.invoke()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
destructive = true,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun clearChatDialog(chatInfo: ChatInfo, chatModel: ChatModel, close: (() -> Unit)? = null) {
|
||||||
|
AlertManager.shared.showAlertDialog(
|
||||||
|
title = generalGetString(R.string.clear_chat_question),
|
||||||
|
text = generalGetString(R.string.clear_chat_warning),
|
||||||
|
confirmText = generalGetString(R.string.clear_verb),
|
||||||
|
onConfirm = {
|
||||||
|
withApi {
|
||||||
|
val updatedChatInfo = chatModel.controller.apiClearChat(chatInfo.chatType, chatInfo.apiId)
|
||||||
|
if (updatedChatInfo != null) {
|
||||||
|
chatModel.clearChat(updatedChatInfo)
|
||||||
|
chatModel.controller.ntfManager.cancelNotificationsForChat(chatInfo.id)
|
||||||
|
close?.invoke()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
destructive = true,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun ChatInfoLayout(
|
||||||
|
chat: Chat,
|
||||||
|
contact: Contact,
|
||||||
|
connStats: ConnectionStats?,
|
||||||
|
contactNetworkStatus: NetworkStatus,
|
||||||
|
customUserProfile: Profile?,
|
||||||
|
localAlias: String,
|
||||||
|
connectionCode: String?,
|
||||||
|
developerTools: Boolean,
|
||||||
|
onLocalAliasChanged: (String) -> Unit,
|
||||||
|
openPreferences: () -> Unit,
|
||||||
|
deleteContact: () -> Unit,
|
||||||
|
clearChat: () -> Unit,
|
||||||
|
switchContactAddress: () -> Unit,
|
||||||
|
verifyClicked: () -> Unit,
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.verticalScroll(rememberScrollState())
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.Center
|
||||||
|
) {
|
||||||
|
ChatInfoHeader(chat.chatInfo, contact)
|
||||||
|
}
|
||||||
|
|
||||||
|
LocalAliasEditor(localAlias, updateValue = onLocalAliasChanged)
|
||||||
|
SectionSpacer()
|
||||||
|
if (customUserProfile != null) {
|
||||||
|
SectionView(generalGetString(R.string.incognito).uppercase()) {
|
||||||
|
InfoRow(generalGetString(R.string.incognito_random_profile), customUserProfile.chatViewName)
|
||||||
|
}
|
||||||
|
SectionDividerSpaced()
|
||||||
|
}
|
||||||
|
|
||||||
|
SectionView {
|
||||||
|
if (connectionCode != null) {
|
||||||
|
VerifyCodeButton(contact.verified, verifyClicked)
|
||||||
|
}
|
||||||
|
ContactPreferencesButton(openPreferences)
|
||||||
|
}
|
||||||
|
|
||||||
|
SectionDividerSpaced()
|
||||||
|
|
||||||
|
SectionView(title = stringResource(R.string.conn_stats_section_title_servers)) {
|
||||||
|
SwitchAddressButton(switchContactAddress)
|
||||||
|
if (connStats != null) {
|
||||||
|
SectionItemView({
|
||||||
|
AlertManager.shared.showAlertMsg(
|
||||||
|
generalGetString(R.string.network_status),
|
||||||
|
contactNetworkStatus.statusExplanation
|
||||||
|
)}) {
|
||||||
|
NetworkStatusRow(contactNetworkStatus)
|
||||||
|
}
|
||||||
|
val rcvServers = connStats.rcvServers
|
||||||
|
if (rcvServers != null && rcvServers.isNotEmpty()) {
|
||||||
|
SimplexServers(stringResource(R.string.receiving_via), rcvServers)
|
||||||
|
}
|
||||||
|
val sndServers = connStats.sndServers
|
||||||
|
if (sndServers != null && sndServers.isNotEmpty()) {
|
||||||
|
SimplexServers(stringResource(R.string.sending_via), sndServers)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
SectionDividerSpaced()
|
||||||
|
SectionView {
|
||||||
|
ClearChatButton(clearChat)
|
||||||
|
DeleteContactButton(deleteContact)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (developerTools) {
|
||||||
|
SectionDividerSpaced()
|
||||||
|
SectionView(title = stringResource(R.string.section_title_for_console)) {
|
||||||
|
InfoRow(stringResource(R.string.info_row_local_name), chat.chatInfo.localDisplayName)
|
||||||
|
InfoRow(stringResource(R.string.info_row_database_id), chat.chatInfo.apiId.toString())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
SectionBottomSpacer()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun ChatInfoHeader(cInfo: ChatInfo, contact: Contact) {
|
||||||
|
Column(
|
||||||
|
Modifier.padding(horizontal = 8.dp),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally
|
||||||
|
) {
|
||||||
|
ChatInfoImage(cInfo, size = 192.dp, iconColor = if (isInDarkTheme()) GroupDark else SettingsSecondaryLight)
|
||||||
|
Row(Modifier.padding(bottom = 8.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
if (contact.verified) {
|
||||||
|
Icon(painterResource(R.drawable.ic_verified_user), null, Modifier.padding(end = 6.dp, top = 4.dp).size(24.dp), tint = MaterialTheme.colors.secondary)
|
||||||
|
}
|
||||||
|
Text(
|
||||||
|
contact.profile.displayName, style = MaterialTheme.typography.h1.copy(fontWeight = FontWeight.Normal),
|
||||||
|
color = MaterialTheme.colors.onBackground,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (cInfo.fullName != "" && cInfo.fullName != cInfo.displayName && cInfo.fullName != contact.profile.displayName) {
|
||||||
|
Text(
|
||||||
|
cInfo.fullName, style = MaterialTheme.typography.h2,
|
||||||
|
color = MaterialTheme.colors.onBackground,
|
||||||
|
maxLines = 2,
|
||||||
|
overflow = TextOverflow.Ellipsis
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun LocalAliasEditor(
|
||||||
|
initialValue: String,
|
||||||
|
center: Boolean = true,
|
||||||
|
leadingIcon: Boolean = false,
|
||||||
|
focus: Boolean = false,
|
||||||
|
updateValue: (String) -> Unit
|
||||||
|
) {
|
||||||
|
var value by rememberSaveable { mutableStateOf(initialValue) }
|
||||||
|
val modifier = if (center)
|
||||||
|
Modifier.padding(horizontal = if (!leadingIcon) DEFAULT_PADDING else 0.dp).widthIn(min = 100.dp)
|
||||||
|
else
|
||||||
|
Modifier.padding(horizontal = if (!leadingIcon) DEFAULT_PADDING else 0.dp).fillMaxWidth()
|
||||||
|
Row(Modifier.fillMaxWidth(), horizontalArrangement = if (center) Arrangement.Center else Arrangement.Start) {
|
||||||
|
DefaultBasicTextField(
|
||||||
|
modifier,
|
||||||
|
value,
|
||||||
|
{
|
||||||
|
Text(
|
||||||
|
generalGetString(R.string.text_field_set_contact_placeholder),
|
||||||
|
textAlign = if (center) TextAlign.Center else TextAlign.Start,
|
||||||
|
color = MaterialTheme.colors.secondary
|
||||||
|
)
|
||||||
|
},
|
||||||
|
leadingIcon = if (leadingIcon) {
|
||||||
|
{ Icon(painterResource(R.drawable.ic_edit_filled), null, Modifier.padding(start = 7.dp)) }
|
||||||
|
} else null,
|
||||||
|
color = MaterialTheme.colors.secondary,
|
||||||
|
focus = focus,
|
||||||
|
textStyle = TextStyle.Default.copy(textAlign = if (value.isEmpty() || !center) TextAlign.Start else TextAlign.Center),
|
||||||
|
keyboardActions = KeyboardActions(onDone = { updateValue(value) })
|
||||||
|
) {
|
||||||
|
value = it
|
||||||
|
}
|
||||||
|
}
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
snapshotFlow { value }
|
||||||
|
.onEach { delay(500) } // wait a little after every new character, don't emit until user stops typing
|
||||||
|
.conflate() // get the latest value
|
||||||
|
.filter { it == value } // don't process old ones
|
||||||
|
.collect {
|
||||||
|
updateValue(value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
DisposableEffect(Unit) {
|
||||||
|
onDispose { updateValue(value) } // just in case snapshotFlow will be canceled when user presses Back too fast
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun NetworkStatusRow(networkStatus: NetworkStatus) {
|
||||||
|
Row(
|
||||||
|
Modifier.fillMaxSize(),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(4.dp)
|
||||||
|
) {
|
||||||
|
Text(stringResource(R.string.network_status))
|
||||||
|
Icon(
|
||||||
|
painterResource(R.drawable.ic_info),
|
||||||
|
stringResource(R.string.network_status),
|
||||||
|
tint = MaterialTheme.colors.primary
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(4.dp)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
networkStatus.statusString,
|
||||||
|
color = MaterialTheme.colors.secondary
|
||||||
|
)
|
||||||
|
ServerImage(networkStatus)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ServerImage(networkStatus: NetworkStatus) {
|
||||||
|
Box(Modifier.size(18.dp)) {
|
||||||
|
when (networkStatus) {
|
||||||
|
is NetworkStatus.Connected ->
|
||||||
|
Icon(painterResource(R.drawable.ic_circle_filled), stringResource(R.string.icon_descr_server_status_connected), tint = MaterialTheme.colors.primaryVariant)
|
||||||
|
is NetworkStatus.Disconnected ->
|
||||||
|
Icon(painterResource(R.drawable.ic_pending_filled), stringResource(R.string.icon_descr_server_status_disconnected), tint = MaterialTheme.colors.secondary)
|
||||||
|
is NetworkStatus.Error ->
|
||||||
|
Icon(painterResource(R.drawable.ic_error_filled), stringResource(R.string.icon_descr_server_status_error), tint = MaterialTheme.colors.secondary)
|
||||||
|
else -> Icon(painterResource(R.drawable.ic_circle), stringResource(R.string.icon_descr_server_status_pending), tint = MaterialTheme.colors.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun SimplexServers(text: String, servers: List<String>) {
|
||||||
|
val info = servers.joinToString(separator = ", ") { it.substringAfter("@") }
|
||||||
|
val clipboardManager: ClipboardManager = LocalClipboardManager.current
|
||||||
|
InfoRowEllipsis(text, info) {
|
||||||
|
clipboardManager.setText(AnnotatedString(servers.joinToString(separator = ",")))
|
||||||
|
Toast.makeText(SimplexApp.context, generalGetString(R.string.copied), Toast.LENGTH_SHORT).show()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun SwitchAddressButton(onClick: () -> Unit) {
|
||||||
|
SectionItemView(onClick) {
|
||||||
|
Text(stringResource(R.string.switch_receiving_address), color = MaterialTheme.colors.primary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun VerifyCodeButton(contactVerified: Boolean, onClick: () -> Unit) {
|
||||||
|
SettingsActionItem(
|
||||||
|
if (contactVerified) painterResource(R.drawable.ic_verified_user) else painterResource(R.drawable.ic_shield),
|
||||||
|
stringResource(if (contactVerified) R.string.view_security_code else R.string.verify_security_code),
|
||||||
|
click = onClick,
|
||||||
|
iconColor = MaterialTheme.colors.secondary,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ContactPreferencesButton(onClick: () -> Unit) {
|
||||||
|
SettingsActionItem(
|
||||||
|
painterResource(R.drawable.ic_toggle_on),
|
||||||
|
stringResource(R.string.contact_preferences),
|
||||||
|
click = onClick
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun ClearChatButton(onClick: () -> Unit) {
|
||||||
|
SettingsActionItem(
|
||||||
|
painterResource(R.drawable.ic_settings_backup_restore),
|
||||||
|
stringResource(R.string.clear_chat_button),
|
||||||
|
click = onClick,
|
||||||
|
textColor = WarningOrange,
|
||||||
|
iconColor = WarningOrange,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun DeleteContactButton(onClick: () -> Unit) {
|
||||||
|
SettingsActionItem(
|
||||||
|
painterResource(R.drawable.ic_delete),
|
||||||
|
stringResource(R.string.button_delete_contact),
|
||||||
|
click = onClick,
|
||||||
|
textColor = Color.Red,
|
||||||
|
iconColor = Color.Red,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun setContactAlias(contactApiId: Long, localAlias: String, chatModel: ChatModel) = withApi {
|
||||||
|
chatModel.controller.apiSetContactAlias(contactApiId, localAlias)?.let {
|
||||||
|
chatModel.updateContact(it)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun showSwitchContactAddressAlert(m: ChatModel, contactId: Long) {
|
||||||
|
AlertManager.shared.showAlertDialog(
|
||||||
|
title = generalGetString(R.string.switch_receiving_address_question),
|
||||||
|
text = generalGetString(R.string.switch_receiving_address_desc),
|
||||||
|
confirmText = generalGetString(R.string.switch_verb),
|
||||||
|
onConfirm = {
|
||||||
|
switchContactAddress(m, contactId)
|
||||||
|
},
|
||||||
|
destructive = true,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun switchContactAddress(m: ChatModel, contactId: Long) = withApi {
|
||||||
|
m.controller.apiSwitchContact(contactId)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Preview
|
||||||
|
@Composable
|
||||||
|
fun PreviewChatInfoLayout() {
|
||||||
|
SimpleXTheme {
|
||||||
|
ChatInfoLayout(
|
||||||
|
chat = Chat(
|
||||||
|
chatInfo = ChatInfo.Direct.sampleData,
|
||||||
|
chatItems = arrayListOf()
|
||||||
|
),
|
||||||
|
Contact.sampleData,
|
||||||
|
localAlias = "",
|
||||||
|
connectionCode = "123",
|
||||||
|
developerTools = false,
|
||||||
|
connStats = null,
|
||||||
|
contactNetworkStatus = NetworkStatus.Connected(),
|
||||||
|
onLocalAliasChanged = {},
|
||||||
|
customUserProfile = null,
|
||||||
|
openPreferences = {},
|
||||||
|
deleteContact = {},
|
||||||
|
clearChat = {},
|
||||||
|
switchContactAddress = {},
|
||||||
|
verifyClicked = {},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
+162
-555
File diff suppressed because it is too large
Load Diff
+9
-11
@@ -1,6 +1,3 @@
|
|||||||
package chat.simplex.common.views.chat
|
|
||||||
|
|
||||||
import androidx.compose.desktop.ui.tooling.preview.Preview
|
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.*
|
||||||
import androidx.compose.material.*
|
import androidx.compose.material.*
|
||||||
@@ -8,11 +5,12 @@ import androidx.compose.runtime.Composable
|
|||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import dev.icerock.moko.resources.compose.painterResource
|
import androidx.compose.ui.res.painterResource
|
||||||
import dev.icerock.moko.resources.compose.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import chat.simplex.common.ui.theme.*
|
import chat.simplex.app.R
|
||||||
import chat.simplex.res.MR
|
import chat.simplex.app.ui.theme.*
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun ComposeFileView(fileName: String, cancelFile: () -> Unit, cancelEnabled: Boolean) {
|
fun ComposeFileView(fileName: String, cancelFile: () -> Unit, cancelEnabled: Boolean) {
|
||||||
@@ -26,8 +24,8 @@ fun ComposeFileView(fileName: String, cancelFile: () -> Unit, cancelEnabled: Boo
|
|||||||
verticalAlignment = Alignment.CenterVertically
|
verticalAlignment = Alignment.CenterVertically
|
||||||
) {
|
) {
|
||||||
Icon(
|
Icon(
|
||||||
painterResource(MR.images.ic_draft_filled),
|
painterResource(R.drawable.ic_draft_filled),
|
||||||
stringResource(MR.strings.icon_descr_file),
|
stringResource(R.string.icon_descr_file),
|
||||||
Modifier
|
Modifier
|
||||||
.padding(start = 4.dp, end = 2.dp)
|
.padding(start = 4.dp, end = 2.dp)
|
||||||
.size(36.dp),
|
.size(36.dp),
|
||||||
@@ -38,8 +36,8 @@ fun ComposeFileView(fileName: String, cancelFile: () -> Unit, cancelEnabled: Boo
|
|||||||
if (cancelEnabled) {
|
if (cancelEnabled) {
|
||||||
IconButton(onClick = cancelFile, modifier = Modifier.padding(0.dp)) {
|
IconButton(onClick = cancelFile, modifier = Modifier.padding(0.dp)) {
|
||||||
Icon(
|
Icon(
|
||||||
painterResource(MR.images.ic_close),
|
painterResource(R.drawable.ic_close),
|
||||||
contentDescription = stringResource(MR.strings.icon_descr_cancel_file_preview),
|
contentDescription = stringResource(R.string.icon_descr_cancel_file_preview),
|
||||||
tint = MaterialTheme.colors.primary,
|
tint = MaterialTheme.colors.primary,
|
||||||
modifier = Modifier.padding(10.dp)
|
modifier = Modifier.padding(10.dp)
|
||||||
)
|
)
|
||||||
+13
-12
@@ -1,4 +1,4 @@
|
|||||||
package chat.simplex.common.views.chat
|
package chat.simplex.app.views.chat
|
||||||
|
|
||||||
import androidx.compose.foundation.Image
|
import androidx.compose.foundation.Image
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
@@ -10,13 +10,14 @@ import androidx.compose.runtime.*
|
|||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import dev.icerock.moko.resources.compose.painterResource
|
import androidx.compose.ui.graphics.asImageBitmap
|
||||||
import dev.icerock.moko.resources.compose.stringResource
|
import androidx.compose.ui.res.painterResource
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import chat.simplex.common.platform.base64ToBitmap
|
import chat.simplex.app.R
|
||||||
import chat.simplex.res.MR
|
import chat.simplex.app.ui.theme.*
|
||||||
import chat.simplex.common.ui.theme.*
|
import chat.simplex.app.views.helpers.UploadContent
|
||||||
import chat.simplex.common.views.helpers.UploadContent
|
import chat.simplex.app.views.helpers.base64ToBitmap
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun ComposeImageView(media: ComposePreview.MediaPreview, cancelImages: () -> Unit, cancelEnabled: Boolean) {
|
fun ComposeImageView(media: ComposePreview.MediaPreview, cancelImages: () -> Unit, cancelEnabled: Boolean) {
|
||||||
@@ -36,14 +37,14 @@ fun ComposeImageView(media: ComposePreview.MediaPreview, cancelImages: () -> Uni
|
|||||||
val content = media.content[index]
|
val content = media.content[index]
|
||||||
if (content is UploadContent.Video) {
|
if (content is UploadContent.Video) {
|
||||||
Box(contentAlignment = Alignment.Center) {
|
Box(contentAlignment = Alignment.Center) {
|
||||||
val imageBitmap = base64ToBitmap(item)
|
val imageBitmap = base64ToBitmap(item).asImageBitmap()
|
||||||
Image(
|
Image(
|
||||||
imageBitmap,
|
imageBitmap,
|
||||||
"preview video",
|
"preview video",
|
||||||
modifier = Modifier.widthIn(max = 80.dp).height(60.dp)
|
modifier = Modifier.widthIn(max = 80.dp).height(60.dp)
|
||||||
)
|
)
|
||||||
Icon(
|
Icon(
|
||||||
painterResource(MR.images.ic_videocam_filled),
|
painterResource(R.drawable.ic_videocam_filled),
|
||||||
"preview video",
|
"preview video",
|
||||||
Modifier
|
Modifier
|
||||||
.size(20.dp),
|
.size(20.dp),
|
||||||
@@ -51,7 +52,7 @@ fun ComposeImageView(media: ComposePreview.MediaPreview, cancelImages: () -> Uni
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
val imageBitmap = base64ToBitmap(item)
|
val imageBitmap = base64ToBitmap(item).asImageBitmap()
|
||||||
Image(
|
Image(
|
||||||
imageBitmap,
|
imageBitmap,
|
||||||
"preview image",
|
"preview image",
|
||||||
@@ -63,8 +64,8 @@ fun ComposeImageView(media: ComposePreview.MediaPreview, cancelImages: () -> Uni
|
|||||||
if (cancelEnabled) {
|
if (cancelEnabled) {
|
||||||
IconButton(onClick = cancelImages) {
|
IconButton(onClick = cancelImages) {
|
||||||
Icon(
|
Icon(
|
||||||
painterResource(MR.images.ic_close),
|
painterResource(R.drawable.ic_close),
|
||||||
contentDescription = stringResource(MR.strings.icon_descr_cancel_image_preview),
|
contentDescription = stringResource(R.string.icon_descr_cancel_image_preview),
|
||||||
tint = MaterialTheme.colors.primary,
|
tint = MaterialTheme.colors.primary,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
+253
-297
@@ -1,6 +1,21 @@
|
|||||||
@file:UseSerializers(UriSerializer::class)
|
@file:UseSerializers(UriSerializer::class)
|
||||||
package chat.simplex.common.views.chat
|
package chat.simplex.app.views.chat
|
||||||
|
|
||||||
|
import ComposeFileView
|
||||||
|
import ComposeVoiceView
|
||||||
|
import android.Manifest
|
||||||
|
import android.app.Activity
|
||||||
|
import android.content.*
|
||||||
|
import android.content.pm.PackageManager
|
||||||
|
import android.graphics.*
|
||||||
|
import android.graphics.drawable.AnimatedImageDrawable
|
||||||
|
import android.net.Uri
|
||||||
|
import android.os.Build
|
||||||
|
import android.provider.MediaStore
|
||||||
|
import android.webkit.MimeTypeMap
|
||||||
|
import android.widget.Toast
|
||||||
|
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||||
|
import androidx.activity.result.contract.ActivityResultContract
|
||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.*
|
||||||
import androidx.compose.foundation.shape.CircleShape
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
import androidx.compose.material.*
|
import androidx.compose.material.*
|
||||||
@@ -10,24 +25,20 @@ import androidx.compose.runtime.saveable.rememberSaveable
|
|||||||
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.ImageBitmap
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import dev.icerock.moko.resources.compose.painterResource
|
import androidx.compose.ui.res.painterResource
|
||||||
import dev.icerock.moko.resources.compose.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import chat.simplex.common.model.*
|
import androidx.core.content.ContextCompat
|
||||||
import chat.simplex.common.model.ChatModel.controller
|
import chat.simplex.app.*
|
||||||
import chat.simplex.common.model.ChatModel.filesToDelete
|
import chat.simplex.app.R
|
||||||
import chat.simplex.common.platform.*
|
import chat.simplex.app.model.*
|
||||||
import chat.simplex.common.ui.theme.Indigo
|
import chat.simplex.app.views.chat.item.*
|
||||||
import chat.simplex.common.ui.theme.isSystemInDarkTheme
|
import chat.simplex.app.views.helpers.*
|
||||||
import chat.simplex.common.views.chat.item.*
|
|
||||||
import chat.simplex.common.views.helpers.*
|
|
||||||
import chat.simplex.res.MR
|
|
||||||
import kotlinx.coroutines.*
|
import kotlinx.coroutines.*
|
||||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||||
import kotlinx.serialization.*
|
import kotlinx.serialization.*
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import java.net.URI
|
|
||||||
import java.nio.file.Files
|
import java.nio.file.Files
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
@@ -36,7 +47,7 @@ sealed class ComposePreview {
|
|||||||
@Serializable class CLinkPreview(val linkPreview: LinkPreview?): ComposePreview()
|
@Serializable class CLinkPreview(val linkPreview: LinkPreview?): ComposePreview()
|
||||||
@Serializable class MediaPreview(val images: List<String>, val content: List<UploadContent>): ComposePreview()
|
@Serializable class MediaPreview(val images: List<String>, val content: List<UploadContent>): ComposePreview()
|
||||||
@Serializable data class VoicePreview(val voice: String, val durationMs: Int, val finished: Boolean): ComposePreview()
|
@Serializable data class VoicePreview(val voice: String, val durationMs: Int, val finished: Boolean): ComposePreview()
|
||||||
@Serializable class FilePreview(val fileName: String, val uri: URI): ComposePreview()
|
@Serializable class FilePreview(val fileName: String, val uri: Uri): ComposePreview()
|
||||||
}
|
}
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
@@ -107,7 +118,7 @@ data class ComposeState(
|
|||||||
|
|
||||||
val attachmentDisabled: Boolean
|
val attachmentDisabled: Boolean
|
||||||
get() {
|
get() {
|
||||||
if (editing || liveMessage != null || inProgress) return true
|
if (editing || liveMessage != null) return true
|
||||||
return when (preview) {
|
return when (preview) {
|
||||||
ComposePreview.NoPreview -> false
|
ComposePreview.NoPreview -> false
|
||||||
is ComposePreview.CLinkPreview -> false
|
is ComposePreview.CLinkPreview -> false
|
||||||
@@ -116,7 +127,7 @@ data class ComposeState(
|
|||||||
}
|
}
|
||||||
|
|
||||||
val empty: Boolean
|
val empty: Boolean
|
||||||
get() = message.isEmpty() && preview is ComposePreview.NoPreview && contextItem is ComposeContextItem.NoContextItem
|
get() = message.isEmpty() && preview is ComposePreview.NoPreview
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
fun saver(): Saver<MutableState<ComposeState>, *> = Saver(
|
fun saver(): Saver<MutableState<ComposeState>, *> = Saver(
|
||||||
@@ -128,8 +139,6 @@ data class ComposeState(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private val maxFileSize = getMaxFileSize(FileProtocol.XFTP)
|
|
||||||
|
|
||||||
sealed class RecordingState {
|
sealed class RecordingState {
|
||||||
object NotStarted: RecordingState()
|
object NotStarted: RecordingState()
|
||||||
class Started(val filePath: String, val progressMs: Int = 0): RecordingState()
|
class Started(val filePath: String, val progressMs: Int = 0): RecordingState()
|
||||||
@@ -154,73 +163,71 @@ fun chatItemPreview(chatItem: ChatItem): ComposePreview {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
expect fun AttachmentSelection(
|
fun ComposeView(
|
||||||
|
chatModel: ChatModel,
|
||||||
|
chat: Chat,
|
||||||
composeState: MutableState<ComposeState>,
|
composeState: MutableState<ComposeState>,
|
||||||
attachmentOption: MutableState<AttachmentOption?>,
|
attachmentOption: MutableState<AttachmentOption?>,
|
||||||
processPickedFile: (URI?, String?) -> Unit,
|
showChooseAttachment: () -> Unit
|
||||||
processPickedMedia: (List<URI>, String?) -> Unit
|
) {
|
||||||
)
|
val context = LocalContext.current
|
||||||
|
val linkUrl = rememberSaveable { mutableStateOf<String?>(null) }
|
||||||
fun MutableState<ComposeState>.onFilesAttached(uris: List<URI>) {
|
val prevLinkUrl = rememberSaveable { mutableStateOf<String?>(null) }
|
||||||
val groups = uris.groupBy { isImage(it) }
|
val pendingLinkUrl = rememberSaveable { mutableStateOf<String?>(null) }
|
||||||
val images = groups[true] ?: emptyList()
|
val cancelledLinks = rememberSaveable { mutableSetOf<String>() }
|
||||||
val files = groups[false] ?: emptyList()
|
val useLinkPreviews = chatModel.controller.appPrefs.privacyLinkPreviews.get()
|
||||||
if (images.isNotEmpty()) {
|
val maxFileSize = getMaxFileSize(FileProtocol.XFTP)
|
||||||
CoroutineScope(Dispatchers.IO).launch { processPickedMedia(images, null) }
|
val smallFont = MaterialTheme.typography.body1.copy(color = MaterialTheme.colors.onBackground)
|
||||||
} else if (files.isNotEmpty()) {
|
val textStyle = remember { mutableStateOf(smallFont) }
|
||||||
processPickedFile(uris.first(), null)
|
val cameraLauncher = rememberCameraLauncher { uri: Uri? ->
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun MutableState<ComposeState>.processPickedFile(uri: URI?, text: String?) {
|
|
||||||
if (uri != null) {
|
if (uri != null) {
|
||||||
val fileSize = getFileSize(uri)
|
val bitmap: Bitmap? = getBitmapFromUri(uri)
|
||||||
if (fileSize != null && fileSize <= maxFileSize) {
|
if (bitmap != null) {
|
||||||
val fileName = getFileName(uri)
|
val imagePreview = resizeImageToStrSize(bitmap, maxDataSize = 14000)
|
||||||
if (fileName != null) {
|
composeState.value = composeState.value.copy(preview = ComposePreview.MediaPreview(listOf(imagePreview), listOf(UploadContent.SimpleImage(uri))))
|
||||||
value = value.copy(message = text ?: value.message, preview = ComposePreview.FilePreview(fileName, uri))
|
|
||||||
}
|
}
|
||||||
} else if (fileSize != null) {
|
}
|
||||||
AlertManager.shared.showAlertMsg(
|
}
|
||||||
generalGetString(MR.strings.large_file),
|
val cameraPermissionLauncher = rememberPermissionLauncher { isGranted: Boolean ->
|
||||||
String.format(generalGetString(MR.strings.maximum_supported_file_size), formatBytes(maxFileSize))
|
if (isGranted) {
|
||||||
)
|
cameraLauncher.launchWithFallback()
|
||||||
} else {
|
} else {
|
||||||
showWrongUriAlert()
|
Toast.makeText(context, generalGetString(R.string.toast_permission_denied), Toast.LENGTH_SHORT).show()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
val processPickedMedia = { uris: List<Uri>, text: String? ->
|
||||||
|
|
||||||
suspend fun MutableState<ComposeState>.processPickedMedia(uris: List<URI>, text: String?) {
|
|
||||||
val content = ArrayList<UploadContent>()
|
val content = ArrayList<UploadContent>()
|
||||||
val imagesPreview = ArrayList<String>()
|
val imagesPreview = ArrayList<String>()
|
||||||
uris.forEach { uri ->
|
uris.forEach { uri ->
|
||||||
var bitmap: ImageBitmap?
|
var bitmap: Bitmap? = null
|
||||||
|
val isImage = MimeTypeMap.getSingleton().getMimeTypeFromExtension(getFileName(SimplexApp.context, uri)?.split(".")?.last())?.contains("image/") == true
|
||||||
when {
|
when {
|
||||||
isImage(uri) -> {
|
isImage -> {
|
||||||
// Image
|
// Image
|
||||||
val drawable = getDrawableFromUri(uri)
|
val drawable = getDrawableFromUri(uri)
|
||||||
// Do not show alert in case it's already shown from the function above
|
bitmap = if (drawable != null) getBitmapFromUri(uri) else null
|
||||||
bitmap = getBitmapFromUri(uri, withAlertOnException = AlertManager.shared.alertViews.isEmpty())
|
val isAnimNewApi = Build.VERSION.SDK_INT >= 28 && drawable is AnimatedImageDrawable
|
||||||
if (isAnimImage(uri, drawable)) {
|
val isAnimOldApi = Build.VERSION.SDK_INT < 28 &&
|
||||||
|
(getFileName(SimplexApp.context, uri)?.endsWith(".gif") == true || getFileName(SimplexApp.context, uri)?.endsWith(".webp") == true)
|
||||||
|
if (isAnimNewApi || isAnimOldApi) {
|
||||||
// It's a gif or webp
|
// It's a gif or webp
|
||||||
val fileSize = getFileSize(uri)
|
val fileSize = getFileSize(context, uri)
|
||||||
if (fileSize != null && fileSize <= maxFileSize) {
|
if (fileSize != null && fileSize <= maxFileSize) {
|
||||||
content.add(UploadContent.AnimatedImage(uri))
|
content.add(UploadContent.AnimatedImage(uri))
|
||||||
} else {
|
} else {
|
||||||
bitmap = null
|
bitmap = null
|
||||||
AlertManager.shared.showAlertMsg(
|
AlertManager.shared.showAlertMsg(
|
||||||
generalGetString(MR.strings.large_file),
|
generalGetString(R.string.large_file),
|
||||||
String.format(generalGetString(MR.strings.maximum_supported_file_size), formatBytes(maxFileSize))
|
String.format(generalGetString(R.string.maximum_supported_file_size), formatBytes(maxFileSize))
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
} else if (bitmap != null) {
|
} else {
|
||||||
content.add(UploadContent.SimpleImage(uri))
|
content.add(UploadContent.SimpleImage(uri))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
else -> {
|
else -> {
|
||||||
// Video
|
// Video
|
||||||
val res = getBitmapFromVideo(uri, withAlertOnException = true)
|
val res = getBitmapFromVideo(uri)
|
||||||
bitmap = res.preview
|
bitmap = res.preview
|
||||||
val durationMs = res.duration
|
val durationMs = res.duration
|
||||||
content.add(UploadContent.Video(uri, durationMs?.div(1000)?.toInt() ?: 0))
|
content.add(UploadContent.Video(uri, durationMs?.div(1000)?.toInt() ?: 0))
|
||||||
@@ -231,35 +238,75 @@ suspend fun MutableState<ComposeState>.processPickedMedia(uris: List<URI>, text:
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (imagesPreview.isNotEmpty()) {
|
if (imagesPreview.isNotEmpty()) {
|
||||||
value = value.copy(message = text ?: value.message, preview = ComposePreview.MediaPreview(imagesPreview, content))
|
composeState.value = composeState.value.copy(message = text ?: composeState.value.message, preview = ComposePreview.MediaPreview(imagesPreview, content))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
val processPickedFile = { uri: Uri?, text: String? ->
|
||||||
|
if (uri != null) {
|
||||||
|
val fileSize = getFileSize(context, uri)
|
||||||
|
if (fileSize != null && fileSize <= maxFileSize) {
|
||||||
|
val fileName = getFileName(SimplexApp.context, uri)
|
||||||
|
if (fileName != null) {
|
||||||
|
composeState.value = composeState.value.copy(message = text ?: composeState.value.message, preview = ComposePreview.FilePreview(fileName, uri))
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
AlertManager.shared.showAlertMsg(
|
||||||
|
generalGetString(R.string.large_file),
|
||||||
|
String.format(generalGetString(R.string.maximum_supported_file_size), formatBytes(maxFileSize))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val galleryImageLauncher = rememberLauncherForActivityResult(contract = PickMultipleImagesFromGallery()) { processPickedMedia(it, null) }
|
||||||
|
val galleryImageLauncherFallback = rememberGetMultipleContentsLauncher { processPickedMedia(it, null) }
|
||||||
|
val galleryVideoLauncher = rememberLauncherForActivityResult(contract = PickMultipleVideosFromGallery()) { processPickedMedia(it, null) }
|
||||||
|
val galleryVideoLauncherFallback = rememberGetMultipleContentsLauncher { processPickedMedia(it, null) }
|
||||||
|
|
||||||
@Composable
|
val filesLauncher = rememberGetContentLauncher { processPickedFile(it, null) }
|
||||||
fun ComposeView(
|
|
||||||
chatModel: ChatModel,
|
|
||||||
chat: Chat,
|
|
||||||
composeState: MutableState<ComposeState>,
|
|
||||||
attachmentOption: MutableState<AttachmentOption?>,
|
|
||||||
showChooseAttachment: () -> Unit
|
|
||||||
) {
|
|
||||||
val linkUrl = rememberSaveable { mutableStateOf<String?>(null) }
|
|
||||||
val prevLinkUrl = rememberSaveable { mutableStateOf<String?>(null) }
|
|
||||||
val pendingLinkUrl = rememberSaveable { mutableStateOf<String?>(null) }
|
|
||||||
val cancelledLinks = rememberSaveable { mutableSetOf<String>() }
|
|
||||||
val useLinkPreviews = chatModel.controller.appPrefs.privacyLinkPreviews.get()
|
|
||||||
val saveLastDraft = chatModel.controller.appPrefs.privacySaveLastDraft.get()
|
|
||||||
val smallFont = MaterialTheme.typography.body1.copy(color = MaterialTheme.colors.onBackground)
|
|
||||||
val textStyle = remember(MaterialTheme.colors.isLight) { mutableStateOf(smallFont) }
|
|
||||||
val recState: MutableState<RecordingState> = remember { mutableStateOf(RecordingState.NotStarted) }
|
val recState: MutableState<RecordingState> = remember { mutableStateOf(RecordingState.NotStarted) }
|
||||||
|
|
||||||
AttachmentSelection(composeState, attachmentOption, composeState::processPickedFile) { uris, text -> CoroutineScope(Dispatchers.IO).launch { composeState.processPickedMedia(uris, text) } }
|
LaunchedEffect(attachmentOption.value) {
|
||||||
|
when (attachmentOption.value) {
|
||||||
|
AttachmentOption.CameraPhoto -> {
|
||||||
|
when (PackageManager.PERMISSION_GRANTED) {
|
||||||
|
ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) -> {
|
||||||
|
cameraLauncher.launchWithFallback()
|
||||||
|
}
|
||||||
|
else -> {
|
||||||
|
cameraPermissionLauncher.launch(Manifest.permission.CAMERA)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
attachmentOption.value = null
|
||||||
|
}
|
||||||
|
AttachmentOption.GalleryImage -> {
|
||||||
|
try {
|
||||||
|
galleryImageLauncher.launch(0)
|
||||||
|
} catch (e: ActivityNotFoundException) {
|
||||||
|
galleryImageLauncherFallback.launch("image/*")
|
||||||
|
}
|
||||||
|
attachmentOption.value = null
|
||||||
|
}
|
||||||
|
AttachmentOption.GalleryVideo -> {
|
||||||
|
try {
|
||||||
|
galleryVideoLauncher.launch(0)
|
||||||
|
} catch (e: ActivityNotFoundException) {
|
||||||
|
galleryVideoLauncherFallback.launch("video/*")
|
||||||
|
}
|
||||||
|
attachmentOption.value = null
|
||||||
|
}
|
||||||
|
AttachmentOption.File -> {
|
||||||
|
filesLauncher.launch("*/*")
|
||||||
|
attachmentOption.value = null
|
||||||
|
}
|
||||||
|
else -> {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun isSimplexLink(link: String): Boolean =
|
fun isSimplexLink(link: String): Boolean =
|
||||||
link.startsWith("https://simplex.chat", true) || link.startsWith("http://simplex.chat", true)
|
link.startsWith("https://simplex.chat", true) || link.startsWith("http://simplex.chat", true)
|
||||||
|
|
||||||
fun parseMessage(msg: String): String? {
|
fun parseMessage(msg: String): String? {
|
||||||
val parsedMsg = parseToMarkdown(msg)
|
val parsedMsg = runBlocking { chatModel.controller.apiParseMarkdown(msg) }
|
||||||
val link = parsedMsg?.firstOrNull { ft -> ft.format is Format.Uri && !cancelledLinks.contains(ft.text) && !isSimplexLink(ft.text) }
|
val link = parsedMsg?.firstOrNull { ft -> ft.format is Format.Uri && !cancelledLinks.contains(ft.text) && !isSimplexLink(ft.text) }
|
||||||
return link?.text
|
return link?.text
|
||||||
}
|
}
|
||||||
@@ -302,20 +349,6 @@ fun ComposeView(
|
|||||||
cancelledLinks.clear()
|
cancelledLinks.clear()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun clearPrevDraft(prevChatId: String?) {
|
|
||||||
if (chatModel.draftChatId.value == prevChatId) {
|
|
||||||
chatModel.draft.value = null
|
|
||||||
chatModel.draftChatId.value = null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun clearCurrentDraft() {
|
|
||||||
if (chatModel.draftChatId.value == chat.id) {
|
|
||||||
chatModel.draft.value = null
|
|
||||||
chatModel.draftChatId.value = null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun clearState(live: Boolean = false) {
|
fun clearState(live: Boolean = false) {
|
||||||
if (live) {
|
if (live) {
|
||||||
composeState.value = composeState.value.copy(inProgress = false)
|
composeState.value = composeState.value.copy(inProgress = false)
|
||||||
@@ -329,49 +362,26 @@ fun ComposeView(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun deleteUnusedFiles() {
|
fun deleteUnusedFiles() {
|
||||||
val shared = chatModel.sharedContent.value
|
|
||||||
if (shared == null) {
|
|
||||||
chatModel.filesToDelete.forEach { it.delete() }
|
chatModel.filesToDelete.forEach { it.delete() }
|
||||||
chatModel.filesToDelete.clear()
|
chatModel.filesToDelete.clear()
|
||||||
} else {
|
|
||||||
val sharedPaths = when (shared) {
|
|
||||||
is SharedContent.Media -> shared.uris.map { it.toString() }
|
|
||||||
is SharedContent.File -> listOf(shared.uri.toString())
|
|
||||||
is SharedContent.Text -> emptyList()
|
|
||||||
}
|
|
||||||
// When sharing a file and pasting it in SimpleX itself, the file shouldn't be deleted before sending or before leaving the chat after sharing
|
|
||||||
chatModel.filesToDelete.removeAll { file ->
|
|
||||||
if (sharedPaths.any { it.endsWith(file.name) }) {
|
|
||||||
false
|
|
||||||
} else {
|
|
||||||
file.delete()
|
|
||||||
true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun send(chat: Chat, mc: MsgContent, quoted: Long?, file: CryptoFile? = null, live: Boolean = false, ttl: Int?): ChatItem? {
|
suspend fun send(cInfo: ChatInfo, mc: MsgContent, quoted: Long?, file: String? = null, live: Boolean = false): ChatItem? {
|
||||||
val cInfo = chat.chatInfo
|
|
||||||
val aChatItem = chatModel.controller.apiSendMessage(
|
val aChatItem = chatModel.controller.apiSendMessage(
|
||||||
rh = chat.remoteHostId,
|
|
||||||
type = cInfo.chatType,
|
type = cInfo.chatType,
|
||||||
id = cInfo.apiId,
|
id = cInfo.apiId,
|
||||||
file = file,
|
file = file,
|
||||||
quotedItemId = quoted,
|
quotedItemId = quoted,
|
||||||
mc = mc,
|
mc = mc,
|
||||||
live = live,
|
live = live
|
||||||
ttl = ttl
|
|
||||||
)
|
)
|
||||||
if (aChatItem != null) {
|
if (aChatItem != null) chatModel.addChatItem(cInfo, aChatItem.chatItem)
|
||||||
chatModel.addChatItem(chat.remoteHostId, cInfo, aChatItem.chatItem)
|
return aChatItem?.chatItem
|
||||||
return aChatItem.chatItem
|
|
||||||
}
|
|
||||||
if (file != null) removeFile(file.filePath)
|
|
||||||
return null
|
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun sendMessageAsync(text: String?, live: Boolean, ttl: Int?): ChatItem? {
|
|
||||||
|
|
||||||
|
suspend fun sendMessageAsync(text: String?, live: Boolean): ChatItem? {
|
||||||
val cInfo = chat.chatInfo
|
val cInfo = chat.chatInfo
|
||||||
val cs = composeState.value
|
val cs = composeState.value
|
||||||
var sent: ChatItem?
|
var sent: ChatItem?
|
||||||
@@ -392,7 +402,6 @@ fun ComposeView(
|
|||||||
MsgContent.MCText(msgText)
|
MsgContent.MCText(msgText)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
else -> MsgContent.MCText(msgText)
|
else -> MsgContent.MCText(msgText)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -409,27 +418,17 @@ fun ComposeView(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun sendMemberContactInvitation() {
|
suspend fun updateMessage(ei: ChatItem, cInfo: ChatInfo, live: Boolean): ChatItem? {
|
||||||
val mc = checkLinkPreview()
|
|
||||||
val contact = chatModel.controller.apiSendMemberContactInvitation(chat.remoteHostId, chat.chatInfo.apiId, mc)
|
|
||||||
if (contact != null) {
|
|
||||||
chatModel.updateContact(chat.remoteHostId, contact)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun updateMessage(ei: ChatItem, chat: Chat, live: Boolean): ChatItem? {
|
|
||||||
val cInfo = chat.chatInfo
|
|
||||||
val oldMsgContent = ei.content.msgContent
|
val oldMsgContent = ei.content.msgContent
|
||||||
if (oldMsgContent != null) {
|
if (oldMsgContent != null) {
|
||||||
val updatedItem = chatModel.controller.apiUpdateChatItem(
|
val updatedItem = chatModel.controller.apiUpdateChatItem(
|
||||||
rh = chat.remoteHostId,
|
|
||||||
type = cInfo.chatType,
|
type = cInfo.chatType,
|
||||||
id = cInfo.apiId,
|
id = cInfo.apiId,
|
||||||
itemId = ei.meta.itemId,
|
itemId = ei.meta.itemId,
|
||||||
mc = updateMsgContent(oldMsgContent),
|
mc = updateMsgContent(oldMsgContent),
|
||||||
live = live
|
live = live
|
||||||
)
|
)
|
||||||
if (updatedItem != null) chatModel.upsertChatItem(chat.remoteHostId, cInfo, updatedItem.chatItem)
|
if (updatedItem != null) chatModel.upsertChatItem(cInfo, updatedItem.chatItem)
|
||||||
return updatedItem?.chatItem
|
return updatedItem?.chatItem
|
||||||
}
|
}
|
||||||
return null
|
return null
|
||||||
@@ -440,36 +439,24 @@ fun ComposeView(
|
|||||||
if (liveMessage != null) composeState.value = cs.copy(liveMessage = null)
|
if (liveMessage != null) composeState.value = cs.copy(liveMessage = null)
|
||||||
sending()
|
sending()
|
||||||
}
|
}
|
||||||
clearCurrentDraft()
|
|
||||||
|
|
||||||
if (chat.nextSendGrpInv) {
|
if (cs.contextItem is ComposeContextItem.EditingItem) {
|
||||||
sendMemberContactInvitation()
|
|
||||||
sent = null
|
|
||||||
} else if (cs.contextItem is ComposeContextItem.EditingItem) {
|
|
||||||
val ei = cs.contextItem.chatItem
|
val ei = cs.contextItem.chatItem
|
||||||
sent = updateMessage(ei, chat, live)
|
sent = updateMessage(ei, cInfo, live)
|
||||||
} else if (liveMessage != null && liveMessage.sent) {
|
} else if (liveMessage != null && liveMessage.sent) {
|
||||||
sent = updateMessage(liveMessage.chatItem, chat, live)
|
sent = updateMessage(liveMessage.chatItem, cInfo, live)
|
||||||
} else {
|
} else {
|
||||||
val msgs: ArrayList<MsgContent> = ArrayList()
|
val msgs: ArrayList<MsgContent> = ArrayList()
|
||||||
val files: ArrayList<CryptoFile> = ArrayList()
|
val files: ArrayList<String> = ArrayList()
|
||||||
val remoteHost = chatModel.currentRemoteHost.value
|
|
||||||
when (val preview = cs.preview) {
|
when (val preview = cs.preview) {
|
||||||
ComposePreview.NoPreview -> msgs.add(MsgContent.MCText(msgText))
|
ComposePreview.NoPreview -> msgs.add(MsgContent.MCText(msgText))
|
||||||
is ComposePreview.CLinkPreview -> msgs.add(checkLinkPreview())
|
is ComposePreview.CLinkPreview -> msgs.add(checkLinkPreview())
|
||||||
is ComposePreview.MediaPreview -> {
|
is ComposePreview.MediaPreview -> {
|
||||||
preview.content.forEachIndexed { index, it ->
|
preview.content.forEachIndexed { index, it ->
|
||||||
val encrypted = chatController.appPrefs.privacyEncryptLocalFiles.get()
|
|
||||||
val file = when (it) {
|
val file = when (it) {
|
||||||
is UploadContent.SimpleImage ->
|
is UploadContent.SimpleImage -> saveImage(context, it.uri)
|
||||||
if (remoteHost == null) saveImage(it.uri, encrypted = encrypted)
|
is UploadContent.AnimatedImage -> saveAnimImage(context, it.uri)
|
||||||
else desktopSaveImageInTmp(it.uri)
|
is UploadContent.Video -> saveFileFromUri(context, it.uri)
|
||||||
is UploadContent.AnimatedImage ->
|
|
||||||
if (remoteHost == null) saveAnimImage(it.uri, encrypted = encrypted)
|
|
||||||
else CryptoFile.desktopPlain(it.uri)
|
|
||||||
is UploadContent.Video ->
|
|
||||||
if (remoteHost == null) saveFileFromUri(it.uri, encrypted = false)
|
|
||||||
else CryptoFile.desktopPlain(it.uri)
|
|
||||||
}
|
}
|
||||||
if (file != null) {
|
if (file != null) {
|
||||||
files.add(file)
|
files.add(file)
|
||||||
@@ -484,32 +471,16 @@ fun ComposeView(
|
|||||||
is ComposePreview.VoicePreview -> {
|
is ComposePreview.VoicePreview -> {
|
||||||
val tmpFile = File(preview.voice)
|
val tmpFile = File(preview.voice)
|
||||||
AudioPlayer.stop(tmpFile.absolutePath)
|
AudioPlayer.stop(tmpFile.absolutePath)
|
||||||
if (remoteHost == null) {
|
val actualFile = File(getAppFilePath(SimplexApp.context, tmpFile.name.replaceAfter(RecorderNative.extension, "")))
|
||||||
val actualFile = File(getAppFilePath(tmpFile.name.replaceAfter(RecorderInterface.extension, "")))
|
withContext(Dispatchers.IO) {
|
||||||
files.add(withContext(Dispatchers.IO) {
|
|
||||||
if (chatController.appPrefs.privacyEncryptLocalFiles.get()) {
|
|
||||||
val args = encryptCryptoFile(tmpFile.absolutePath, actualFile.absolutePath)
|
|
||||||
tmpFile.delete()
|
|
||||||
CryptoFile(actualFile.name, args)
|
|
||||||
} else {
|
|
||||||
Files.move(tmpFile.toPath(), actualFile.toPath())
|
Files.move(tmpFile.toPath(), actualFile.toPath())
|
||||||
CryptoFile.plain(actualFile.name)
|
|
||||||
}
|
}
|
||||||
})
|
files.add(actualFile.name)
|
||||||
deleteUnusedFiles()
|
deleteUnusedFiles()
|
||||||
} else {
|
|
||||||
files.add(CryptoFile.plain(tmpFile.absolutePath))
|
|
||||||
// It will be deleted on JVM shutdown or next start (if the app crashes unexpectedly)
|
|
||||||
filesToDelete.remove(tmpFile)
|
|
||||||
}
|
|
||||||
msgs.add(MsgContent.MCVoice(if (msgs.isEmpty()) msgText else "", preview.durationMs / 1000))
|
msgs.add(MsgContent.MCVoice(if (msgs.isEmpty()) msgText else "", preview.durationMs / 1000))
|
||||||
}
|
}
|
||||||
is ComposePreview.FilePreview -> {
|
is ComposePreview.FilePreview -> {
|
||||||
val file = if (remoteHost == null) {
|
val file = saveFileFromUri(context, preview.uri)
|
||||||
saveFileFromUri(preview.uri, encrypted = chatController.appPrefs.privacyEncryptLocalFiles.get())
|
|
||||||
} else {
|
|
||||||
CryptoFile.desktopPlain(preview.uri)
|
|
||||||
}
|
|
||||||
if (file != null) {
|
if (file != null) {
|
||||||
files.add((file))
|
files.add((file))
|
||||||
msgs.add(MsgContent.MCFile(if (msgs.isEmpty()) msgText else ""))
|
msgs.add(MsgContent.MCFile(if (msgs.isEmpty()) msgText else ""))
|
||||||
@@ -523,17 +494,8 @@ fun ComposeView(
|
|||||||
sent = null
|
sent = null
|
||||||
msgs.forEachIndexed { index, content ->
|
msgs.forEachIndexed { index, content ->
|
||||||
if (index > 0) delay(100)
|
if (index > 0) delay(100)
|
||||||
var file = files.getOrNull(index)
|
sent = send(cInfo, content, if (index == 0) quotedItemId else null, files.getOrNull(index),
|
||||||
if (remoteHost != null && file != null) {
|
if (content !is MsgContent.MCVoice && index == msgs.lastIndex) live else false
|
||||||
file = controller.storeRemoteFile(
|
|
||||||
rhId = remoteHost.remoteHostId,
|
|
||||||
storeEncrypted = if (content is MsgContent.MCVideo) false else null,
|
|
||||||
localPath = file.filePath
|
|
||||||
)
|
|
||||||
}
|
|
||||||
sent = send(chat, content, if (index == 0) quotedItemId else null, file,
|
|
||||||
live = if (content !is MsgContent.MCVoice && index == msgs.lastIndex) live else false,
|
|
||||||
ttl = ttl
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
if (sent == null &&
|
if (sent == null &&
|
||||||
@@ -541,16 +503,16 @@ fun ComposeView(
|
|||||||
cs.preview is ComposePreview.FilePreview ||
|
cs.preview is ComposePreview.FilePreview ||
|
||||||
cs.preview is ComposePreview.VoicePreview)
|
cs.preview is ComposePreview.VoicePreview)
|
||||||
) {
|
) {
|
||||||
sent = send(chat, MsgContent.MCText(msgText), quotedItemId, null, live, ttl)
|
sent = send(cInfo, MsgContent.MCText(msgText), quotedItemId, null, live)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
clearState(live)
|
clearState(live)
|
||||||
return sent
|
return sent
|
||||||
}
|
}
|
||||||
|
|
||||||
fun sendMessage(ttl: Int?) {
|
fun sendMessage() {
|
||||||
withBGApi {
|
withBGApi {
|
||||||
sendMessageAsync(null, false, ttl)
|
sendMessageAsync(null, false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -576,7 +538,7 @@ fun ComposeView(
|
|||||||
fun allowVoiceToContact() {
|
fun allowVoiceToContact() {
|
||||||
val contact = (chat.chatInfo as ChatInfo.Direct?)?.contact ?: return
|
val contact = (chat.chatInfo as ChatInfo.Direct?)?.contact ?: return
|
||||||
withApi {
|
withApi {
|
||||||
chatModel.controller.allowFeatureToContact(chat.remoteHostId, contact, ChatFeature.Voice)
|
chatModel.controller.allowFeatureToContact(contact, ChatFeature.Voice)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -598,7 +560,7 @@ fun ComposeView(
|
|||||||
recState.value = RecordingState.NotStarted
|
recState.value = RecordingState.NotStarted
|
||||||
composeState.value = composeState.value.copy(preview = ComposePreview.NoPreview)
|
composeState.value = composeState.value.copy(preview = ComposePreview.NoPreview)
|
||||||
withBGApi {
|
withBGApi {
|
||||||
RecorderInterface.stopRecording?.invoke()
|
RecorderNative.stopRecording?.invoke()
|
||||||
AudioPlayer.stop(filePath)
|
AudioPlayer.stop(filePath)
|
||||||
filePath?.let { File(it).delete() }
|
filePath?.let { File(it).delete() }
|
||||||
}
|
}
|
||||||
@@ -625,8 +587,8 @@ fun ComposeView(
|
|||||||
suspend fun sendLiveMessage() {
|
suspend fun sendLiveMessage() {
|
||||||
val cs = composeState.value
|
val cs = composeState.value
|
||||||
val typedMsg = cs.message
|
val typedMsg = cs.message
|
||||||
if ((cs.sendEnabled() || cs.contextItem is ComposeContextItem.QuotedItem) && (cs.liveMessage == null || !cs.liveMessage.sent)) {
|
if ((cs.sendEnabled() || cs.contextItem is ComposeContextItem.QuotedItem) && (cs.liveMessage == null || !cs.liveMessage?.sent)) {
|
||||||
val ci = sendMessageAsync(typedMsg, live = true, ttl = null)
|
val ci = sendMessageAsync(typedMsg, live = true)
|
||||||
if (ci != null) {
|
if (ci != null) {
|
||||||
composeState.value = composeState.value.copy(liveMessage = LiveMessage(ci, typedMsg = typedMsg, sentMsg = typedMsg, sent = true))
|
composeState.value = composeState.value.copy(liveMessage = LiveMessage(ci, typedMsg = typedMsg, sentMsg = typedMsg, sent = true))
|
||||||
}
|
}
|
||||||
@@ -647,7 +609,7 @@ fun ComposeView(
|
|||||||
if (liveMessage != null) {
|
if (liveMessage != null) {
|
||||||
val sentMsg = liveMessageToSend(liveMessage, typedMsg)
|
val sentMsg = liveMessageToSend(liveMessage, typedMsg)
|
||||||
if (sentMsg != null) {
|
if (sentMsg != null) {
|
||||||
val ci = sendMessageAsync(sentMsg, live = true, ttl = null)
|
val ci = sendMessageAsync(sentMsg, live = true)
|
||||||
if (ci != null) {
|
if (ci != null) {
|
||||||
composeState.value = composeState.value.copy(liveMessage = LiveMessage(ci, typedMsg = typedMsg, sentMsg = sentMsg, sent = true))
|
composeState.value = composeState.value.copy(liveMessage = LiveMessage(ci, typedMsg = typedMsg, sentMsg = sentMsg, sent = true))
|
||||||
}
|
}
|
||||||
@@ -657,39 +619,27 @@ fun ComposeView(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun editPrevMessage() {
|
|
||||||
if (composeState.value.contextItem != ComposeContextItem.NoContextItem || composeState.value.preview != ComposePreview.NoPreview) return
|
|
||||||
val lastEditable = chatModel.chatItems.findLast { it.meta.editable }
|
|
||||||
if (lastEditable != null) {
|
|
||||||
composeState.value = ComposeState(editingItem = lastEditable, useLinkPreviews = useLinkPreviews)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun previewView() {
|
fun previewView() {
|
||||||
when (val preview = composeState.value.preview) {
|
when (val preview = composeState.value.preview) {
|
||||||
ComposePreview.NoPreview -> {}
|
ComposePreview.NoPreview -> {}
|
||||||
is ComposePreview.CLinkPreview -> ComposeLinkView(
|
is ComposePreview.CLinkPreview -> ComposeLinkView(preview.linkPreview, ::cancelLinkPreview)
|
||||||
preview.linkPreview,
|
|
||||||
::cancelLinkPreview,
|
|
||||||
cancelEnabled = !composeState.value.inProgress
|
|
||||||
)
|
|
||||||
is ComposePreview.MediaPreview -> ComposeImageView(
|
is ComposePreview.MediaPreview -> ComposeImageView(
|
||||||
preview,
|
preview,
|
||||||
::cancelImages,
|
::cancelImages,
|
||||||
cancelEnabled = !composeState.value.editing && !composeState.value.inProgress
|
cancelEnabled = !composeState.value.editing
|
||||||
)
|
)
|
||||||
is ComposePreview.VoicePreview -> ComposeVoiceView(
|
is ComposePreview.VoicePreview -> ComposeVoiceView(
|
||||||
preview.voice,
|
preview.voice,
|
||||||
preview.durationMs,
|
preview.durationMs,
|
||||||
preview.finished,
|
preview.finished,
|
||||||
cancelEnabled = !composeState.value.editing && !composeState.value.inProgress,
|
cancelEnabled = !composeState.value.editing,
|
||||||
::cancelVoice
|
::cancelVoice
|
||||||
)
|
)
|
||||||
is ComposePreview.FilePreview -> ComposeFileView(
|
is ComposePreview.FilePreview -> ComposeFileView(
|
||||||
preview.fileName,
|
preview.fileName,
|
||||||
::cancelFile,
|
::cancelFile,
|
||||||
cancelEnabled = !composeState.value.editing && !composeState.value.inProgress
|
cancelEnabled = !composeState.value.editing
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -698,92 +648,47 @@ fun ComposeView(
|
|||||||
fun contextItemView() {
|
fun contextItemView() {
|
||||||
when (val contextItem = composeState.value.contextItem) {
|
when (val contextItem = composeState.value.contextItem) {
|
||||||
ComposeContextItem.NoContextItem -> {}
|
ComposeContextItem.NoContextItem -> {}
|
||||||
is ComposeContextItem.QuotedItem -> ContextItemView(contextItem.chatItem, painterResource(MR.images.ic_reply)) {
|
is ComposeContextItem.QuotedItem -> ContextItemView(contextItem.chatItem, painterResource(R.drawable.ic_reply)) {
|
||||||
composeState.value = composeState.value.copy(contextItem = ComposeContextItem.NoContextItem)
|
composeState.value = composeState.value.copy(contextItem = ComposeContextItem.NoContextItem)
|
||||||
}
|
}
|
||||||
is ComposeContextItem.EditingItem -> ContextItemView(contextItem.chatItem, painterResource(MR.images.ic_edit_filled)) {
|
is ComposeContextItem.EditingItem -> ContextItemView(contextItem.chatItem, painterResource(R.drawable.ic_edit_filled)) {
|
||||||
clearState()
|
clearState()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// In case a user sent something, state is in progress, the user rotates a screen to different orientation.
|
|
||||||
// Without clearing the state the user will be unable to send anything until re-enters ChatView
|
|
||||||
LaunchedEffect(Unit) {
|
|
||||||
if (composeState.value.inProgress) {
|
|
||||||
clearState()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
LaunchedEffect(chatModel.sharedContent.value) {
|
LaunchedEffect(chatModel.sharedContent.value) {
|
||||||
// Important. If it's null, don't do anything, chat is not closed yet but will be after a moment
|
// Important. If it's null, don't do anything, chat is not closed yet but will be after a moment
|
||||||
if (chatModel.chatId.value == null) return@LaunchedEffect
|
if (chatModel.chatId.value == null) return@LaunchedEffect
|
||||||
|
|
||||||
when (val shared = chatModel.sharedContent.value) {
|
when (val shared = chatModel.sharedContent.value) {
|
||||||
is SharedContent.Text -> onMessageChange(shared.text)
|
is SharedContent.Text -> onMessageChange(shared.text)
|
||||||
is SharedContent.Media -> composeState.processPickedMedia(shared.uris, shared.text)
|
is SharedContent.Media -> processPickedMedia(shared.uris, shared.text)
|
||||||
is SharedContent.File -> composeState.processPickedFile(shared.uri, shared.text)
|
is SharedContent.File -> processPickedFile(shared.uri, shared.text)
|
||||||
null -> {}
|
null -> {}
|
||||||
}
|
}
|
||||||
chatModel.sharedContent.value = null
|
chatModel.sharedContent.value = null
|
||||||
}
|
}
|
||||||
|
|
||||||
val userCanSend = rememberUpdatedState(chat.userCanSend)
|
val userCanSend = rememberUpdatedState(chat.userCanSend)
|
||||||
val sendMsgEnabled = rememberUpdatedState(chat.chatInfo.sendMsgEnabled)
|
|
||||||
val userIsObserver = rememberUpdatedState(chat.userIsObserver)
|
val userIsObserver = rememberUpdatedState(chat.userIsObserver)
|
||||||
val nextSendGrpInv = rememberUpdatedState(chat.nextSendGrpInv)
|
|
||||||
|
|
||||||
Column {
|
Column {
|
||||||
if (nextSendGrpInv.value) {
|
|
||||||
ComposeContextInvitingContactMemberView()
|
|
||||||
}
|
|
||||||
if (composeState.value.preview !is ComposePreview.VoicePreview || composeState.value.editing) {
|
|
||||||
contextItemView()
|
contextItemView()
|
||||||
when {
|
when {
|
||||||
composeState.value.editing && composeState.value.preview is ComposePreview.VoicePreview -> {}
|
composeState.value.editing && composeState.value.preview is ComposePreview.VoicePreview -> {}
|
||||||
composeState.value.editing && composeState.value.preview is ComposePreview.FilePreview -> {}
|
composeState.value.editing && composeState.value.preview is ComposePreview.FilePreview -> {}
|
||||||
else -> previewView()
|
else -> previewView()
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
Box {
|
|
||||||
Box(Modifier.align(Alignment.TopStart).padding(bottom = 69.dp)) {
|
|
||||||
contextItemView()
|
|
||||||
}
|
|
||||||
Box(Modifier.align(Alignment.BottomStart)) {
|
|
||||||
previewView()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier.padding(end = 8.dp),
|
modifier = Modifier.padding(end = 8.dp),
|
||||||
verticalAlignment = Alignment.Bottom,
|
verticalAlignment = Alignment.Bottom,
|
||||||
) {
|
) {
|
||||||
val isGroupAndProhibitedFiles = chat.chatInfo is ChatInfo.Group && !chat.chatInfo.groupInfo.fullGroupPreferences.files.on
|
IconButton(showChooseAttachment, enabled = !composeState.value.attachmentDisabled && rememberUpdatedState(chat.userCanSend).value) {
|
||||||
val attachmentClicked = if (isGroupAndProhibitedFiles) {
|
|
||||||
{
|
|
||||||
AlertManager.shared.showAlertMsg(
|
|
||||||
title = generalGetString(MR.strings.files_and_media_prohibited),
|
|
||||||
text = generalGetString(MR.strings.only_owners_can_enable_files_and_media)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
showChooseAttachment
|
|
||||||
}
|
|
||||||
val attachmentEnabled =
|
|
||||||
!composeState.value.attachmentDisabled
|
|
||||||
&& sendMsgEnabled.value
|
|
||||||
&& userCanSend.value
|
|
||||||
&& !isGroupAndProhibitedFiles
|
|
||||||
&& !nextSendGrpInv.value
|
|
||||||
IconButton(
|
|
||||||
attachmentClicked,
|
|
||||||
Modifier.padding(bottom = if (appPlatform.isAndroid) 0.dp else 7.dp),
|
|
||||||
enabled = attachmentEnabled
|
|
||||||
) {
|
|
||||||
Icon(
|
Icon(
|
||||||
painterResource(MR.images.ic_attach_file_filled_500),
|
painterResource(R.drawable.ic_attach_file_filled_500),
|
||||||
contentDescription = stringResource(MR.strings.attach),
|
contentDescription = stringResource(R.string.attach),
|
||||||
tint = if (attachmentEnabled) MaterialTheme.colors.primary else MaterialTheme.colors.secondary,
|
tint = if (!composeState.value.attachmentDisabled && userCanSend.value) MaterialTheme.colors.primary else MaterialTheme.colors.secondary,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.size(28.dp)
|
.size(28.dp)
|
||||||
.clip(CircleShape)
|
.clip(CircleShape)
|
||||||
@@ -808,16 +713,19 @@ fun ComposeView(
|
|||||||
.collect {
|
.collect {
|
||||||
when(it) {
|
when(it) {
|
||||||
is RecordingState.Started -> onAudioAdded(it.filePath, it.progressMs, false)
|
is RecordingState.Started -> onAudioAdded(it.filePath, it.progressMs, false)
|
||||||
is RecordingState.Finished -> if (it.durationMs > 300) {
|
is RecordingState.Finished -> onAudioAdded(it.filePath, it.durationMs, true)
|
||||||
onAudioAdded(it.filePath, it.durationMs, true)
|
|
||||||
} else {
|
|
||||||
cancelVoice()
|
|
||||||
}
|
|
||||||
is RecordingState.NotStarted -> {}
|
is RecordingState.NotStarted -> {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun clearCurrentDraft() {
|
||||||
|
if (chatModel.draftChatId.value == chat.id) {
|
||||||
|
chatModel.draft.value = null
|
||||||
|
chatModel.draftChatId.value = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
LaunchedEffect(rememberUpdatedState(chat.userCanSend).value) {
|
LaunchedEffect(rememberUpdatedState(chat.userCanSend).value) {
|
||||||
if (!chat.userCanSend) {
|
if (!chat.userCanSend) {
|
||||||
clearCurrentDraft()
|
clearCurrentDraft()
|
||||||
@@ -825,57 +733,45 @@ fun ComposeView(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
KeyChangeEffect(chatModel.chatId.value) { prevChatId ->
|
val activity = LocalContext.current as Activity
|
||||||
|
DisposableEffect(Unit) {
|
||||||
|
val orientation = activity.resources.configuration.orientation
|
||||||
|
onDispose {
|
||||||
|
if (orientation == activity.resources.configuration.orientation) {
|
||||||
val cs = composeState.value
|
val cs = composeState.value
|
||||||
if (cs.liveMessage != null && (cs.message.isNotEmpty() || cs.liveMessage.sent)) {
|
if (cs.liveMessage != null && (cs.message.isNotEmpty() || cs.liveMessage.sent)) {
|
||||||
sendMessage(null)
|
sendMessage()
|
||||||
resetLinkPreview()
|
resetLinkPreview()
|
||||||
clearPrevDraft(prevChatId)
|
clearCurrentDraft()
|
||||||
deleteUnusedFiles()
|
deleteUnusedFiles()
|
||||||
} else if (cs.inProgress) {
|
} else if (!composeState.value.empty) {
|
||||||
clearPrevDraft(prevChatId)
|
|
||||||
} else if (!cs.empty) {
|
|
||||||
if (cs.preview is ComposePreview.VoicePreview && !cs.preview.finished) {
|
if (cs.preview is ComposePreview.VoicePreview && !cs.preview.finished) {
|
||||||
composeState.value = cs.copy(preview = cs.preview.copy(finished = true))
|
composeState.value = cs.copy(preview = cs.preview.copy(finished = true))
|
||||||
}
|
}
|
||||||
if (saveLastDraft) {
|
|
||||||
chatModel.draft.value = composeState.value
|
chatModel.draft.value = composeState.value
|
||||||
chatModel.draftChatId.value = prevChatId
|
chatModel.draftChatId.value = chat.id
|
||||||
}
|
|
||||||
composeState.value = ComposeState(useLinkPreviews = useLinkPreviews)
|
|
||||||
} else if (chatModel.draftChatId.value == chatModel.chatId.value && chatModel.draft.value != null) {
|
|
||||||
composeState.value = chatModel.draft.value ?: ComposeState(useLinkPreviews = useLinkPreviews)
|
|
||||||
} else {
|
} else {
|
||||||
clearPrevDraft(prevChatId)
|
clearCurrentDraft()
|
||||||
deleteUnusedFiles()
|
deleteUnusedFiles()
|
||||||
}
|
}
|
||||||
chatModel.removeLiveDummy()
|
chatModel.removeLiveDummy()
|
||||||
CIFile.cachedRemoteFileRequests.clear()
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
val timedMessageAllowed = remember(chat.chatInfo) { chat.chatInfo.featureEnabled(ChatFeature.TimedMessages) }
|
|
||||||
val sendButtonColor =
|
|
||||||
if (chat.chatInfo.incognito)
|
|
||||||
if (isSystemInDarkTheme()) Indigo else Indigo.copy(alpha = 0.7F)
|
|
||||||
else MaterialTheme.colors.primary
|
|
||||||
SendMsgView(
|
SendMsgView(
|
||||||
composeState,
|
composeState,
|
||||||
showVoiceRecordIcon = true,
|
showVoiceRecordIcon = true,
|
||||||
recState,
|
recState,
|
||||||
chat.chatInfo is ChatInfo.Direct,
|
chat.chatInfo is ChatInfo.Direct,
|
||||||
liveMessageAlertShown = chatModel.controller.appPrefs.liveMessageAlertShown,
|
liveMessageAlertShown = chatModel.controller.appPrefs.liveMessageAlertShown,
|
||||||
sendMsgEnabled = sendMsgEnabled.value,
|
|
||||||
nextSendGrpInv = nextSendGrpInv.value,
|
|
||||||
needToAllowVoiceToContact,
|
needToAllowVoiceToContact,
|
||||||
allowedVoiceByPrefs,
|
allowedVoiceByPrefs,
|
||||||
allowVoiceToContact = ::allowVoiceToContact,
|
allowVoiceToContact = ::allowVoiceToContact,
|
||||||
userIsObserver = userIsObserver.value,
|
userIsObserver = userIsObserver.value,
|
||||||
userCanSend = userCanSend.value,
|
userCanSend = userCanSend.value,
|
||||||
sendButtonColor = sendButtonColor,
|
sendMessage = {
|
||||||
timedMessageAllowed = timedMessageAllowed,
|
sendMessage()
|
||||||
customDisappearingMessageTimePref = chatModel.controller.appPrefs.customDisappearingMessageTime,
|
|
||||||
sendMessage = { ttl ->
|
|
||||||
sendMessage(ttl)
|
|
||||||
resetLinkPreview()
|
resetLinkPreview()
|
||||||
},
|
},
|
||||||
sendLiveMessage = ::sendLiveMessage,
|
sendLiveMessage = ::sendLiveMessage,
|
||||||
@@ -884,11 +780,71 @@ fun ComposeView(
|
|||||||
composeState.value = composeState.value.copy(liveMessage = null)
|
composeState.value = composeState.value.copy(liveMessage = null)
|
||||||
chatModel.removeLiveDummy()
|
chatModel.removeLiveDummy()
|
||||||
},
|
},
|
||||||
editPrevMessage = ::editPrevMessage,
|
|
||||||
onFilesPasted = { composeState.onFilesAttached(it) },
|
|
||||||
onMessageChange = ::onMessageChange,
|
onMessageChange = ::onMessageChange,
|
||||||
textStyle = textStyle
|
textStyle = textStyle
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class PickFromGallery: ActivityResultContract<Int, Uri?>() {
|
||||||
|
override fun createIntent(context: Context, input: Int) =
|
||||||
|
Intent(Intent.ACTION_PICK, MediaStore.Images.Media.INTERNAL_CONTENT_URI).apply {
|
||||||
|
type = "image/*"
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun parseResult(resultCode: Int, intent: Intent?): Uri? = intent?.data
|
||||||
|
}
|
||||||
|
|
||||||
|
class PickMultipleImagesFromGallery: ActivityResultContract<Int, List<Uri>>() {
|
||||||
|
override fun createIntent(context: Context, input: Int) =
|
||||||
|
Intent(Intent.ACTION_PICK, MediaStore.Images.Media.INTERNAL_CONTENT_URI).apply {
|
||||||
|
putExtra(Intent.EXTRA_ALLOW_MULTIPLE, true)
|
||||||
|
type = "image/*"
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun parseResult(resultCode: Int, intent: Intent?): List<Uri> =
|
||||||
|
if (intent?.data != null)
|
||||||
|
listOf(intent.data!!)
|
||||||
|
else if (intent?.clipData != null)
|
||||||
|
with(intent.clipData!!) {
|
||||||
|
val uris = ArrayList<Uri>()
|
||||||
|
for (i in 0 until kotlin.math.min(itemCount, 10)) {
|
||||||
|
val uri = getItemAt(i).uri
|
||||||
|
if (uri != null) uris.add(uri)
|
||||||
|
}
|
||||||
|
if (itemCount > 10) {
|
||||||
|
AlertManager.shared.showAlertMsg(R.string.images_limit_title, R.string.images_limit_desc)
|
||||||
|
}
|
||||||
|
uris
|
||||||
|
}
|
||||||
|
else
|
||||||
|
emptyList()
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class PickMultipleVideosFromGallery: ActivityResultContract<Int, List<Uri>>() {
|
||||||
|
override fun createIntent(context: Context, input: Int) =
|
||||||
|
Intent(Intent.ACTION_PICK, MediaStore.Video.Media.INTERNAL_CONTENT_URI).apply {
|
||||||
|
putExtra(Intent.EXTRA_ALLOW_MULTIPLE, true)
|
||||||
|
type = "video/*"
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun parseResult(resultCode: Int, intent: Intent?): List<Uri> =
|
||||||
|
if (intent?.data != null)
|
||||||
|
listOf(intent.data!!)
|
||||||
|
else if (intent?.clipData != null)
|
||||||
|
with(intent.clipData!!) {
|
||||||
|
val uris = ArrayList<Uri>()
|
||||||
|
for (i in 0 until kotlin.math.min(itemCount, 10)) {
|
||||||
|
val uri = getItemAt(i).uri
|
||||||
|
if (uri != null) uris.add(uri)
|
||||||
|
}
|
||||||
|
if (itemCount > 10) {
|
||||||
|
AlertManager.shared.showAlertMsg(R.string.videos_limit_title, R.string.videos_limit_desc)
|
||||||
|
}
|
||||||
|
uris
|
||||||
|
}
|
||||||
|
else
|
||||||
|
emptyList()
|
||||||
|
}
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
import androidx.compose.animation.core.Animatable
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.material.*
|
||||||
|
import androidx.compose.runtime.*
|
||||||
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.res.painterResource
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import chat.simplex.app.R
|
||||||
|
import chat.simplex.app.model.durationText
|
||||||
|
import chat.simplex.app.ui.theme.*
|
||||||
|
import chat.simplex.app.views.helpers.*
|
||||||
|
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun ComposeVoiceView(
|
||||||
|
filePath: String,
|
||||||
|
recordedDurationMs: Int,
|
||||||
|
finishedRecording: Boolean,
|
||||||
|
cancelEnabled: Boolean,
|
||||||
|
cancelVoice: () -> Unit
|
||||||
|
) {
|
||||||
|
BoxWithConstraints(Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
) {
|
||||||
|
val audioPlaying = rememberSaveable { mutableStateOf(false) }
|
||||||
|
val progress = rememberSaveable { mutableStateOf(0) }
|
||||||
|
val duration = rememberSaveable(recordedDurationMs) { mutableStateOf(recordedDurationMs) }
|
||||||
|
val progressBarWidth = remember { Animatable(0f) }
|
||||||
|
LaunchedEffect(recordedDurationMs, finishedRecording) {
|
||||||
|
snapshotFlow { progress.value }
|
||||||
|
.distinctUntilChanged()
|
||||||
|
.collect {
|
||||||
|
val startTime = when {
|
||||||
|
finishedRecording -> progress.value
|
||||||
|
else -> recordedDurationMs
|
||||||
|
}
|
||||||
|
val endTime = when {
|
||||||
|
finishedRecording -> duration.value
|
||||||
|
audioPlaying.value -> recordedDurationMs
|
||||||
|
else -> MAX_VOICE_MILLIS_FOR_SENDING
|
||||||
|
}
|
||||||
|
val to = ((startTime.toDouble() / endTime) * maxWidth.value).dp
|
||||||
|
progressBarWidth.animateTo(to.value, audioProgressBarAnimationSpec())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Spacer(
|
||||||
|
Modifier
|
||||||
|
.requiredWidth(progressBarWidth.value.dp)
|
||||||
|
.padding(top = 58.dp)
|
||||||
|
.height(3.dp)
|
||||||
|
.background(MaterialTheme.colors.primary)
|
||||||
|
)
|
||||||
|
val sentColor = CurrentColors.collectAsState().value.appColors.sentMessage
|
||||||
|
Row(
|
||||||
|
Modifier
|
||||||
|
.height(60.dp)
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(top = 8.dp)
|
||||||
|
.background(sentColor),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
IconButton(
|
||||||
|
onClick = {
|
||||||
|
if (!audioPlaying.value) {
|
||||||
|
AudioPlayer.play(filePath, audioPlaying, progress, duration, false)
|
||||||
|
} else {
|
||||||
|
AudioPlayer.pause(audioPlaying, progress)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
enabled = finishedRecording) {
|
||||||
|
Icon(
|
||||||
|
if (audioPlaying.value) painterResource(R.drawable.ic_pause_filled) else painterResource(R.drawable.ic_play_arrow_filled),
|
||||||
|
stringResource(R.string.icon_descr_file),
|
||||||
|
Modifier
|
||||||
|
.padding(start = 4.dp, end = 2.dp)
|
||||||
|
.size(36.dp),
|
||||||
|
tint = if (finishedRecording) MaterialTheme.colors.primary else MaterialTheme.colors.secondary
|
||||||
|
)
|
||||||
|
}
|
||||||
|
val numberInText = remember(recordedDurationMs, progress.value) {
|
||||||
|
derivedStateOf {
|
||||||
|
when {
|
||||||
|
finishedRecording && progress.value == 0 && !audioPlaying.value -> duration.value / 1000
|
||||||
|
finishedRecording -> progress.value / 1000
|
||||||
|
else -> recordedDurationMs / 1000
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Text(
|
||||||
|
durationText(numberInText.value),
|
||||||
|
fontSize = 18.sp,
|
||||||
|
color = MaterialTheme.colors.secondary,
|
||||||
|
)
|
||||||
|
Spacer(Modifier.weight(1f))
|
||||||
|
if (cancelEnabled) {
|
||||||
|
IconButton(
|
||||||
|
onClick = {
|
||||||
|
AudioPlayer.stop(filePath)
|
||||||
|
cancelVoice()
|
||||||
|
},
|
||||||
|
modifier = Modifier.padding(0.dp)
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
painterResource(R.drawable.ic_close),
|
||||||
|
contentDescription = stringResource(R.string.icon_descr_cancel_file_preview),
|
||||||
|
tint = MaterialTheme.colors.primary,
|
||||||
|
modifier = Modifier.padding(10.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Preview
|
||||||
|
@Composable
|
||||||
|
fun PreviewComposeAudioView() {
|
||||||
|
SimpleXTheme {
|
||||||
|
ComposeFileView(
|
||||||
|
"test.txt",
|
||||||
|
cancelFile = {},
|
||||||
|
cancelEnabled = true
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
+35
-36
@@ -1,4 +1,4 @@
|
|||||||
package chat.simplex.common.views.chat
|
package chat.simplex.app.views.chat
|
||||||
|
|
||||||
import InfoRow
|
import InfoRow
|
||||||
import SectionBottomSpacer
|
import SectionBottomSpacer
|
||||||
@@ -14,18 +14,17 @@ import androidx.compose.runtime.*
|
|||||||
import androidx.compose.runtime.saveable.rememberSaveable
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import dev.icerock.moko.resources.compose.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import chat.simplex.common.ui.theme.*
|
import chat.simplex.app.R
|
||||||
import chat.simplex.common.views.helpers.*
|
import chat.simplex.app.model.*
|
||||||
import chat.simplex.common.views.usersettings.PreferenceToggle
|
import chat.simplex.app.ui.theme.*
|
||||||
import chat.simplex.common.model.*
|
import chat.simplex.app.views.helpers.*
|
||||||
import chat.simplex.res.MR
|
import chat.simplex.app.views.usersettings.PreferenceToggle
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun ContactPreferencesView(
|
fun ContactPreferencesView(
|
||||||
m: ChatModel,
|
m: ChatModel,
|
||||||
user: User,
|
user: User,
|
||||||
rhId: Long?,
|
|
||||||
contactId: Long,
|
contactId: Long,
|
||||||
close: () -> Unit,
|
close: () -> Unit,
|
||||||
) {
|
) {
|
||||||
@@ -37,9 +36,9 @@ fun ContactPreferencesView(
|
|||||||
fun savePrefs(afterSave: () -> Unit = {}) {
|
fun savePrefs(afterSave: () -> Unit = {}) {
|
||||||
withApi {
|
withApi {
|
||||||
val prefs = contactFeaturesAllowedToPrefs(featuresAllowed)
|
val prefs = contactFeaturesAllowedToPrefs(featuresAllowed)
|
||||||
val toContact = m.controller.apiSetContactPrefs(rhId, ct.contactId, prefs)
|
val toContact = m.controller.apiSetContactPrefs(ct.contactId, prefs)
|
||||||
if (toContact != null) {
|
if (toContact != null) {
|
||||||
m.updateContact(rhId, toContact)
|
m.updateContact(toContact)
|
||||||
currentFeaturesAllowed = featuresAllowed
|
currentFeaturesAllowed = featuresAllowed
|
||||||
}
|
}
|
||||||
afterSave()
|
afterSave()
|
||||||
@@ -82,7 +81,7 @@ private fun ContactPreferencesLayout(
|
|||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.verticalScroll(rememberScrollState()),
|
.verticalScroll(rememberScrollState()),
|
||||||
) {
|
) {
|
||||||
AppBarTitle(stringResource(MR.strings.contact_preferences))
|
AppBarTitle(stringResource(R.string.contact_preferences))
|
||||||
val timedMessages: MutableState<Boolean> = remember(featuresAllowed) { mutableStateOf(featuresAllowed.timedMessagesAllowed) }
|
val timedMessages: MutableState<Boolean> = remember(featuresAllowed) { mutableStateOf(featuresAllowed.timedMessagesAllowed) }
|
||||||
val onTTLUpdated = { ttl: Int? ->
|
val onTTLUpdated = { ttl: Int? ->
|
||||||
applyPrefs(featuresAllowed.copy(timedMessagesTTL = ttl))
|
applyPrefs(featuresAllowed.copy(timedMessagesTTL = ttl))
|
||||||
@@ -96,11 +95,6 @@ private fun ContactPreferencesLayout(
|
|||||||
applyPrefs(featuresAllowed.copy(fullDelete = it))
|
applyPrefs(featuresAllowed.copy(fullDelete = it))
|
||||||
}
|
}
|
||||||
SectionDividerSpaced(true, maxBottomPadding = false)
|
SectionDividerSpaced(true, maxBottomPadding = false)
|
||||||
val allowReactions: MutableState<ContactFeatureAllowed> = remember(featuresAllowed) { mutableStateOf(featuresAllowed.reactions) }
|
|
||||||
FeatureSection(ChatFeature.Reactions, user.fullPreferences.reactions.allow, contact.mergedPreferences.reactions, allowReactions) {
|
|
||||||
applyPrefs(featuresAllowed.copy(reactions = it))
|
|
||||||
}
|
|
||||||
SectionDividerSpaced(true, maxBottomPadding = false)
|
|
||||||
val allowVoice: MutableState<ContactFeatureAllowed> = remember(featuresAllowed) { mutableStateOf(featuresAllowed.voice) }
|
val allowVoice: MutableState<ContactFeatureAllowed> = remember(featuresAllowed) { mutableStateOf(featuresAllowed.voice) }
|
||||||
FeatureSection(ChatFeature.Voice, user.fullPreferences.voice.allow, contact.mergedPreferences.voice, allowVoice) {
|
FeatureSection(ChatFeature.Voice, user.fullPreferences.voice.allow, contact.mergedPreferences.voice, allowVoice) {
|
||||||
applyPrefs(featuresAllowed.copy(voice = it))
|
applyPrefs(featuresAllowed.copy(voice = it))
|
||||||
@@ -141,18 +135,19 @@ private fun FeatureSection(
|
|||||||
leadingIcon = true,
|
leadingIcon = true,
|
||||||
) {
|
) {
|
||||||
ExposedDropDownSettingRow(
|
ExposedDropDownSettingRow(
|
||||||
generalGetString(MR.strings.chat_preferences_you_allow),
|
generalGetString(R.string.chat_preferences_you_allow),
|
||||||
ContactFeatureAllowed.values(userDefault).map { it to it.text },
|
ContactFeatureAllowed.values(userDefault).map { it to it.text },
|
||||||
allowFeature,
|
allowFeature,
|
||||||
icon = null,
|
icon = null,
|
||||||
|
enabled = remember { mutableStateOf(feature != ChatFeature.Calls) },
|
||||||
onSelected = onSelected
|
onSelected = onSelected
|
||||||
)
|
)
|
||||||
InfoRow(
|
InfoRow(
|
||||||
generalGetString(MR.strings.chat_preferences_contact_allows),
|
generalGetString(R.string.chat_preferences_contact_allows),
|
||||||
pref.contactPreference.allow.text
|
pref.contactPreference.allow.text
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
SectionTextFooter(feature.enabledDescription(enabled))
|
SectionTextFooter(feature.enabledDescription(enabled) + (if (feature == ChatFeature.Calls) generalGetString(R.string.available_in_v51) else ""))
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
@@ -176,28 +171,20 @@ private fun TimedMessagesFeatureSection(
|
|||||||
leadingIcon = true,
|
leadingIcon = true,
|
||||||
) {
|
) {
|
||||||
PreferenceToggle(
|
PreferenceToggle(
|
||||||
generalGetString(MR.strings.chat_preferences_you_allow),
|
generalGetString(R.string.chat_preferences_you_allow),
|
||||||
checked = allowFeature.value,
|
checked = allowFeature.value,
|
||||||
) { allow ->
|
) { allow ->
|
||||||
onSelected(allow, if (allow) featuresAllowed.timedMessagesTTL ?: 86400 else null)
|
onSelected(allow, if (allow) featuresAllowed.timedMessagesTTL ?: 86400 else null)
|
||||||
}
|
}
|
||||||
InfoRow(
|
InfoRow(
|
||||||
generalGetString(MR.strings.chat_preferences_contact_allows),
|
generalGetString(R.string.chat_preferences_contact_allows),
|
||||||
pref.contactPreference.allow.text
|
pref.contactPreference.allow.text
|
||||||
)
|
)
|
||||||
if (featuresAllowed.timedMessagesAllowed) {
|
if (featuresAllowed.timedMessagesAllowed) {
|
||||||
val ttl = rememberSaveable(featuresAllowed.timedMessagesTTL) { mutableStateOf(featuresAllowed.timedMessagesTTL) }
|
val ttl = rememberSaveable(featuresAllowed.timedMessagesTTL) { mutableStateOf(featuresAllowed.timedMessagesTTL) }
|
||||||
DropdownCustomTimePickerSettingRow(
|
TimedMessagesTTLPicker(ttl, onTTLUpdated)
|
||||||
selection = ttl,
|
|
||||||
propagateExternalSelectionUpdate = true, // for Reset
|
|
||||||
label = generalGetString(MR.strings.delete_after),
|
|
||||||
dropdownValues = TimedMessagesPreference.ttlValues,
|
|
||||||
customPickerTitle = generalGetString(MR.strings.delete_after),
|
|
||||||
customPickerConfirmButtonText = generalGetString(MR.strings.custom_time_picker_select),
|
|
||||||
onSelected = onTTLUpdated
|
|
||||||
)
|
|
||||||
} else if (pref.contactPreference.allow == FeatureAllowed.YES || pref.contactPreference.allow == FeatureAllowed.ALWAYS) {
|
} else if (pref.contactPreference.allow == FeatureAllowed.YES || pref.contactPreference.allow == FeatureAllowed.ALWAYS) {
|
||||||
InfoRow(generalGetString(MR.strings.delete_after), timeText(pref.contactPreference.ttl))
|
InfoRow(generalGetString(R.string.delete_after), TimedMessagesPreference.ttlText(pref.contactPreference.ttl))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
SectionTextFooter(ChatFeature.TimedMessages.enabledDescription(enabled))
|
SectionTextFooter(ChatFeature.TimedMessages.enabledDescription(enabled))
|
||||||
@@ -207,19 +194,31 @@ private fun TimedMessagesFeatureSection(
|
|||||||
private fun ResetSaveButtons(reset: () -> Unit, save: () -> Unit, disabled: Boolean) {
|
private fun ResetSaveButtons(reset: () -> Unit, save: () -> Unit, disabled: Boolean) {
|
||||||
SectionView {
|
SectionView {
|
||||||
SectionItemView(reset, disabled = disabled) {
|
SectionItemView(reset, disabled = disabled) {
|
||||||
Text(stringResource(MR.strings.reset_verb), color = if (disabled) MaterialTheme.colors.secondary else MaterialTheme.colors.primary)
|
Text(stringResource(R.string.reset_verb), color = if (disabled) MaterialTheme.colors.secondary else MaterialTheme.colors.primary)
|
||||||
}
|
}
|
||||||
SectionItemView(save, disabled = disabled) {
|
SectionItemView(save, disabled = disabled) {
|
||||||
Text(stringResource(MR.strings.save_and_notify_contact), color = if (disabled) MaterialTheme.colors.secondary else MaterialTheme.colors.primary)
|
Text(stringResource(R.string.save_and_notify_contact), color = if (disabled) MaterialTheme.colors.secondary else MaterialTheme.colors.primary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun TimedMessagesTTLPicker(selection: MutableState<Int?>, onSelected: (Int?) -> Unit) {
|
||||||
|
val ttlValues = TimedMessagesPreference.ttlValues
|
||||||
|
val values = ttlValues + if (ttlValues.contains(selection.value)) listOf() else listOf(selection.value)
|
||||||
|
ExposedDropDownSettingRow(
|
||||||
|
generalGetString(R.string.delete_after),
|
||||||
|
values.map { it to TimedMessagesPreference.ttlText(it) },
|
||||||
|
selection,
|
||||||
|
onSelected = onSelected
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
private fun showUnsavedChangesAlert(save: () -> Unit, revert: () -> Unit) {
|
private fun showUnsavedChangesAlert(save: () -> Unit, revert: () -> Unit) {
|
||||||
AlertManager.shared.showAlertDialogStacked(
|
AlertManager.shared.showAlertDialogStacked(
|
||||||
title = generalGetString(MR.strings.save_preferences_question),
|
title = generalGetString(R.string.save_preferences_question),
|
||||||
confirmText = generalGetString(MR.strings.save_and_notify_contact),
|
confirmText = generalGetString(R.string.save_and_notify_contact),
|
||||||
dismissText = generalGetString(MR.strings.exit_without_saving),
|
dismissText = generalGetString(R.string.exit_without_saving),
|
||||||
onConfirm = save,
|
onConfirm = save,
|
||||||
onDismiss = revert,
|
onDismiss = revert,
|
||||||
)
|
)
|
||||||
+17
-39
@@ -1,4 +1,4 @@
|
|||||||
package chat.simplex.common.views.chat
|
package chat.simplex.app.views.chat
|
||||||
|
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.*
|
||||||
@@ -8,16 +8,14 @@ import androidx.compose.runtime.Composable
|
|||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import dev.icerock.moko.resources.compose.painterResource
|
import androidx.compose.ui.res.painterResource
|
||||||
import dev.icerock.moko.resources.compose.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.desktop.ui.tooling.preview.Preview
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
import androidx.compose.ui.text.TextStyle
|
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import chat.simplex.app.R
|
||||||
import chat.simplex.common.ui.theme.*
|
import chat.simplex.app.model.*
|
||||||
import chat.simplex.common.views.chat.item.*
|
import chat.simplex.app.ui.theme.*
|
||||||
import chat.simplex.common.model.*
|
import chat.simplex.app.views.chat.item.*
|
||||||
import chat.simplex.res.MR
|
|
||||||
import kotlinx.datetime.Clock
|
import kotlinx.datetime.Clock
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
@@ -29,17 +27,6 @@ fun ContextItemView(
|
|||||||
val sent = contextItem.chatDir.sent
|
val sent = contextItem.chatDir.sent
|
||||||
val sentColor = CurrentColors.collectAsState().value.appColors.sentMessage
|
val sentColor = CurrentColors.collectAsState().value.appColors.sentMessage
|
||||||
val receivedColor = CurrentColors.collectAsState().value.appColors.receivedMessage
|
val receivedColor = CurrentColors.collectAsState().value.appColors.receivedMessage
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun msgContentView(lines: Int) {
|
|
||||||
MarkdownText(
|
|
||||||
contextItem.text, contextItem.formattedText,
|
|
||||||
maxLines = lines,
|
|
||||||
linkMode = SimplexLinkMode.DESCRIPTION,
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
Row(
|
Row(
|
||||||
Modifier
|
Modifier
|
||||||
.padding(top = 8.dp)
|
.padding(top = 8.dp)
|
||||||
@@ -59,29 +46,20 @@ fun ContextItemView(
|
|||||||
.padding(horizontal = 8.dp)
|
.padding(horizontal = 8.dp)
|
||||||
.height(20.dp)
|
.height(20.dp)
|
||||||
.width(20.dp),
|
.width(20.dp),
|
||||||
contentDescription = stringResource(MR.strings.icon_descr_context),
|
contentDescription = stringResource(R.string.icon_descr_context),
|
||||||
tint = MaterialTheme.colors.secondary,
|
tint = MaterialTheme.colors.secondary,
|
||||||
)
|
)
|
||||||
val sender = contextItem.memberDisplayName
|
MarkdownText(
|
||||||
if (sender != null) {
|
contextItem.text, contextItem.formattedText,
|
||||||
Column(
|
sender = contextItem.memberDisplayName, senderBold = true, maxLines = 3,
|
||||||
horizontalAlignment = Alignment.Start,
|
linkMode = SimplexLinkMode.DESCRIPTION,
|
||||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
sender,
|
|
||||||
style = TextStyle(fontSize = 13.5.sp, color = CurrentColors.value.colors.secondary)
|
|
||||||
)
|
)
|
||||||
msgContentView(lines = 2)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
msgContentView(lines = 3)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
IconButton(onClick = cancelContextItem) {
|
IconButton(onClick = cancelContextItem) {
|
||||||
Icon(
|
Icon(
|
||||||
painterResource(MR.images.ic_close),
|
painterResource(R.drawable.ic_close),
|
||||||
contentDescription = stringResource(MR.strings.cancel_verb),
|
contentDescription = stringResource(R.string.cancel_verb),
|
||||||
tint = MaterialTheme.colors.primary,
|
tint = MaterialTheme.colors.primary,
|
||||||
modifier = Modifier.padding(10.dp)
|
modifier = Modifier.padding(10.dp)
|
||||||
)
|
)
|
||||||
@@ -95,7 +73,7 @@ fun PreviewContextItemView() {
|
|||||||
SimpleXTheme {
|
SimpleXTheme {
|
||||||
ContextItemView(
|
ContextItemView(
|
||||||
contextItem = ChatItem.getSampleData(1, CIDirection.DirectRcv(), Clock.System.now(), "hello"),
|
contextItem = ChatItem.getSampleData(1, CIDirection.DirectRcv(), Clock.System.now(), "hello"),
|
||||||
contextIcon = painterResource(MR.images.ic_edit_filled)
|
contextIcon = painterResource(R.drawable.ic_edit_filled)
|
||||||
) {}
|
) {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
package chat.simplex.app.views.chat
|
||||||
|
|
||||||
|
import android.Manifest
|
||||||
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.material.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import chat.simplex.app.R
|
||||||
|
import chat.simplex.app.ui.theme.DEFAULT_PADDING
|
||||||
|
import chat.simplex.app.views.helpers.*
|
||||||
|
import chat.simplex.app.views.newchat.QRCodeScanner
|
||||||
|
import com.google.accompanist.permissions.rememberPermissionState
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun ScanCodeView(verifyCode: (String?, cb: (Boolean) -> Unit) -> Unit, close: () -> Unit) {
|
||||||
|
val cameraPermissionState = rememberPermissionState(permission = Manifest.permission.CAMERA)
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
cameraPermissionState.launchPermissionRequest()
|
||||||
|
}
|
||||||
|
ScanCodeLayout(verifyCode, close)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ScanCodeLayout(verifyCode: (String?, cb: (Boolean) -> Unit) -> Unit, close: () -> Unit) {
|
||||||
|
Column(
|
||||||
|
Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(horizontal = DEFAULT_PADDING)
|
||||||
|
) {
|
||||||
|
AppBarTitle(stringResource(R.string.scan_code), false)
|
||||||
|
Box(
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.aspectRatio(ratio = 1F)
|
||||||
|
.padding(bottom = DEFAULT_PADDING)
|
||||||
|
) {
|
||||||
|
QRCodeScanner { text ->
|
||||||
|
verifyCode(text) {
|
||||||
|
if (it) {
|
||||||
|
close()
|
||||||
|
} else {
|
||||||
|
AlertManager.shared.showAlertMsg(
|
||||||
|
title = generalGetString(R.string.incorrect_code)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Text(stringResource(R.string.scan_code_from_contacts_app))
|
||||||
|
}
|
||||||
|
}
|
||||||
+220
-241
@@ -1,11 +1,24 @@
|
|||||||
package chat.simplex.common.views.chat
|
package chat.simplex.app.views.chat
|
||||||
|
|
||||||
|
import android.Manifest
|
||||||
|
import android.annotation.SuppressLint
|
||||||
|
import android.app.Activity
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.pm.ActivityInfo
|
||||||
|
import android.content.res.Configuration
|
||||||
|
import android.os.Build
|
||||||
|
import android.text.InputType
|
||||||
|
import android.util.Log
|
||||||
|
import android.view.ViewGroup
|
||||||
|
import android.view.WindowManager
|
||||||
|
import android.view.inputmethod.*
|
||||||
|
import android.widget.EditText
|
||||||
|
import android.widget.TextView
|
||||||
import androidx.compose.animation.core.*
|
import androidx.compose.animation.core.*
|
||||||
import androidx.compose.desktop.ui.tooling.preview.Preview
|
|
||||||
import androidx.compose.foundation.*
|
import androidx.compose.foundation.*
|
||||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.*
|
||||||
import androidx.compose.foundation.shape.*
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
import androidx.compose.material.*
|
import androidx.compose.material.*
|
||||||
import androidx.compose.material.ripple.rememberRipple
|
import androidx.compose.material.ripple.rememberRipple
|
||||||
import androidx.compose.runtime.*
|
import androidx.compose.runtime.*
|
||||||
@@ -15,22 +28,29 @@ import androidx.compose.ui.draw.alpha
|
|||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.graphics.*
|
import androidx.compose.ui.graphics.*
|
||||||
import androidx.compose.ui.graphics.painter.Painter
|
import androidx.compose.ui.graphics.painter.Painter
|
||||||
|
import androidx.compose.ui.platform.*
|
||||||
|
import androidx.compose.ui.res.painterResource
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.semantics.Role
|
import androidx.compose.ui.semantics.Role
|
||||||
import androidx.compose.ui.text.TextStyle
|
import androidx.compose.ui.text.TextStyle
|
||||||
|
import androidx.compose.ui.text.font.FontStyle
|
||||||
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
import androidx.compose.ui.unit.*
|
import androidx.compose.ui.unit.*
|
||||||
import chat.simplex.common.model.*
|
import androidx.compose.ui.viewinterop.AndroidView
|
||||||
import chat.simplex.common.ui.theme.*
|
import androidx.core.graphics.drawable.DrawableCompat
|
||||||
import chat.simplex.common.views.chat.item.ItemAction
|
import androidx.core.view.inputmethod.EditorInfoCompat
|
||||||
import chat.simplex.common.views.helpers.*
|
import androidx.core.view.inputmethod.InputConnectionCompat
|
||||||
import chat.simplex.common.model.ChatItem
|
import androidx.core.widget.*
|
||||||
import chat.simplex.common.platform.*
|
import chat.simplex.app.R
|
||||||
import chat.simplex.common.views.usersettings.showInDevelopingAlert
|
import chat.simplex.app.SimplexApp
|
||||||
import chat.simplex.res.MR
|
import chat.simplex.app.model.*
|
||||||
import dev.icerock.moko.resources.compose.stringResource
|
import chat.simplex.app.ui.theme.CurrentColors
|
||||||
import dev.icerock.moko.resources.compose.painterResource
|
import chat.simplex.app.ui.theme.SimpleXTheme
|
||||||
|
import chat.simplex.app.views.chat.item.ItemAction
|
||||||
|
import chat.simplex.app.views.helpers.*
|
||||||
|
import com.google.accompanist.permissions.rememberMultiplePermissionsState
|
||||||
import kotlinx.coroutines.*
|
import kotlinx.coroutines.*
|
||||||
import java.io.File
|
import java.lang.reflect.Field
|
||||||
import java.net.URI
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun SendMsgView(
|
fun SendMsgView(
|
||||||
@@ -39,63 +59,33 @@ fun SendMsgView(
|
|||||||
recState: MutableState<RecordingState>,
|
recState: MutableState<RecordingState>,
|
||||||
isDirectChat: Boolean,
|
isDirectChat: Boolean,
|
||||||
liveMessageAlertShown: SharedPreference<Boolean>,
|
liveMessageAlertShown: SharedPreference<Boolean>,
|
||||||
sendMsgEnabled: Boolean,
|
|
||||||
nextSendGrpInv: Boolean,
|
|
||||||
needToAllowVoiceToContact: Boolean,
|
needToAllowVoiceToContact: Boolean,
|
||||||
allowedVoiceByPrefs: Boolean,
|
allowedVoiceByPrefs: Boolean,
|
||||||
userIsObserver: Boolean,
|
userIsObserver: Boolean,
|
||||||
userCanSend: Boolean,
|
userCanSend: Boolean,
|
||||||
sendButtonColor: Color = MaterialTheme.colors.primary,
|
|
||||||
allowVoiceToContact: () -> Unit,
|
allowVoiceToContact: () -> Unit,
|
||||||
timedMessageAllowed: Boolean = false,
|
sendMessage: () -> Unit,
|
||||||
customDisappearingMessageTimePref: SharedPreference<Int>? = null,
|
|
||||||
sendMessage: (Int?) -> Unit,
|
|
||||||
sendLiveMessage: (suspend () -> Unit)? = null,
|
sendLiveMessage: (suspend () -> Unit)? = null,
|
||||||
updateLiveMessage: (suspend () -> Unit)? = null,
|
updateLiveMessage: (suspend () -> Unit)? = null,
|
||||||
cancelLiveMessage: (() -> Unit)? = null,
|
cancelLiveMessage: (() -> Unit)? = null,
|
||||||
editPrevMessage: () -> Unit,
|
|
||||||
onFilesPasted: (List<URI>) -> Unit,
|
|
||||||
onMessageChange: (String) -> Unit,
|
onMessageChange: (String) -> Unit,
|
||||||
textStyle: MutableState<TextStyle>
|
textStyle: MutableState<TextStyle>
|
||||||
) {
|
) {
|
||||||
val showCustomDisappearingMessageDialog = remember { mutableStateOf(false) }
|
|
||||||
|
|
||||||
if (showCustomDisappearingMessageDialog.value) {
|
|
||||||
CustomDisappearingMessageDialog(
|
|
||||||
sendMessage = sendMessage,
|
|
||||||
setShowDialog = { showCustomDisappearingMessageDialog.value = it },
|
|
||||||
customDisappearingMessageTimePref = customDisappearingMessageTimePref
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
Box(Modifier.padding(vertical = 8.dp)) {
|
Box(Modifier.padding(vertical = 8.dp)) {
|
||||||
val cs = composeState.value
|
val cs = composeState.value
|
||||||
var progressByTimeout by rememberSaveable { mutableStateOf(false) }
|
val showProgress = cs.inProgress && (cs.preview is ComposePreview.MediaPreview || cs.preview is ComposePreview.FilePreview)
|
||||||
LaunchedEffect(composeState.value.inProgress) {
|
val showVoiceButton = cs.message.isEmpty() && showVoiceRecordIcon && !composeState.value.editing &&
|
||||||
progressByTimeout = if (composeState.value.inProgress) {
|
|
||||||
delay(500)
|
|
||||||
composeState.value.inProgress
|
|
||||||
} else {
|
|
||||||
false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
val showVoiceButton = !nextSendGrpInv && cs.message.isEmpty() && showVoiceRecordIcon && !composeState.value.editing &&
|
|
||||||
cs.liveMessage == null && (cs.preview is ComposePreview.NoPreview || recState.value is RecordingState.Started)
|
cs.liveMessage == null && (cs.preview is ComposePreview.NoPreview || recState.value is RecordingState.Started)
|
||||||
val showDeleteTextButton = rememberSaveable { mutableStateOf(false) }
|
val showDeleteTextButton = rememberSaveable { mutableStateOf(false) }
|
||||||
PlatformTextField(composeState, sendMsgEnabled, textStyle, showDeleteTextButton, userIsObserver, onMessageChange, editPrevMessage, onFilesPasted) {
|
NativeKeyboard(composeState, textStyle, showDeleteTextButton, userIsObserver, onMessageChange)
|
||||||
if (!cs.inProgress) {
|
|
||||||
sendMessage(null)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Disable clicks on text field
|
// Disable clicks on text field
|
||||||
if (!sendMsgEnabled || cs.preview is ComposePreview.VoicePreview || !userCanSend || cs.inProgress) {
|
if (cs.preview is ComposePreview.VoicePreview || !userCanSend) {
|
||||||
Box(
|
Box(Modifier
|
||||||
Modifier
|
|
||||||
.matchParentSize()
|
.matchParentSize()
|
||||||
.clickable(enabled = !userCanSend, indication = null, interactionSource = remember { MutableInteractionSource() }, onClick = {
|
.clickable(enabled = !userCanSend, indication = null, interactionSource = remember { MutableInteractionSource() }, onClick = {
|
||||||
AlertManager.shared.showAlertMsg(
|
AlertManager.shared.showAlertMsg(
|
||||||
title = generalGetString(MR.strings.observer_cant_send_message_title),
|
title = generalGetString(R.string.observer_cant_send_message_title),
|
||||||
text = generalGetString(MR.strings.observer_cant_send_message_desc)
|
text = generalGetString(R.string.observer_cant_send_message_desc)
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
@@ -103,9 +93,10 @@ fun SendMsgView(
|
|||||||
if (showDeleteTextButton.value) {
|
if (showDeleteTextButton.value) {
|
||||||
DeleteTextButton(composeState)
|
DeleteTextButton(composeState)
|
||||||
}
|
}
|
||||||
Box(Modifier.align(Alignment.BottomEnd).padding(bottom = if (appPlatform.isAndroid) 0.dp else 5.dp)) {
|
Box(Modifier.align(Alignment.BottomEnd)) {
|
||||||
val sendButtonSize = remember { Animatable(36f) }
|
val sendButtonSize = remember { Animatable(36f) }
|
||||||
val sendButtonAlpha = remember { Animatable(1f) }
|
val sendButtonAlpha = remember { Animatable(1f) }
|
||||||
|
val permissionsState = rememberMultiplePermissionsState(listOf(Manifest.permission.RECORD_AUDIO))
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
LaunchedEffect(Unit) {
|
LaunchedEffect(Unit) {
|
||||||
// Making LiveMessage alive when screen orientation was changed
|
// Making LiveMessage alive when screen orientation was changed
|
||||||
@@ -114,8 +105,8 @@ fun SendMsgView(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
when {
|
when {
|
||||||
progressByTimeout -> ProgressIndicator()
|
showProgress -> ProgressIndicator()
|
||||||
showVoiceButton && sendMsgEnabled -> {
|
showVoiceButton -> {
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
val stopRecOnNextClick = remember { mutableStateOf(false) }
|
val stopRecOnNextClick = remember { mutableStateOf(false) }
|
||||||
when {
|
when {
|
||||||
@@ -128,16 +119,15 @@ fun SendMsgView(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
!allowedToRecordVoiceByPlatform() ->
|
!permissionsState.allPermissionsGranted ->
|
||||||
VoiceButtonWithoutPermissionByPlatform()
|
VoiceButtonWithoutPermission { permissionsState.launchMultiplePermissionRequest() }
|
||||||
else ->
|
else ->
|
||||||
RecordVoiceView(recState, stopRecOnNextClick)
|
RecordVoiceView(recState, stopRecOnNextClick)
|
||||||
}
|
}
|
||||||
if (sendLiveMessage != null
|
if (sendLiveMessage != null
|
||||||
&& updateLiveMessage != null
|
&& updateLiveMessage != null
|
||||||
&& (cs.preview !is ComposePreview.VoicePreview || !stopRecOnNextClick.value)
|
&& (cs.preview !is ComposePreview.VoicePreview || !stopRecOnNextClick.value)
|
||||||
&& cs.contextItem is ComposeContextItem.NoContextItem
|
&& cs.contextItem is ComposeContextItem.NoContextItem) {
|
||||||
) {
|
|
||||||
Spacer(Modifier.width(10.dp))
|
Spacer(Modifier.width(10.dp))
|
||||||
StartLiveMessageButton(userCanSend) {
|
StartLiveMessageButton(userCanSend) {
|
||||||
if (composeState.value.preview is ComposePreview.NoPreview) {
|
if (composeState.value.preview is ComposePreview.NoPreview) {
|
||||||
@@ -154,25 +144,23 @@ fun SendMsgView(
|
|||||||
}
|
}
|
||||||
else -> {
|
else -> {
|
||||||
val cs = composeState.value
|
val cs = composeState.value
|
||||||
val icon = if (cs.editing || cs.liveMessage != null) painterResource(MR.images.ic_check_filled) else painterResource(MR.images.ic_arrow_upward)
|
val icon = if (cs.editing || cs.liveMessage != null) painterResource(R.drawable.ic_check_filled) else painterResource(R.drawable.ic_arrow_upward)
|
||||||
val disabled = !sendMsgEnabled || !cs.sendEnabled() ||
|
val disabled = !cs.sendEnabled() ||
|
||||||
(!allowedVoiceByPrefs && cs.preview is ComposePreview.VoicePreview) ||
|
(!allowedVoiceByPrefs && cs.preview is ComposePreview.VoicePreview) ||
|
||||||
cs.endLiveDisabled
|
cs.endLiveDisabled
|
||||||
val showDropdown = rememberSaveable { mutableStateOf(false) }
|
if (cs.liveMessage == null &&
|
||||||
|
cs.preview !is ComposePreview.VoicePreview && !cs.editing &&
|
||||||
@Composable
|
|
||||||
fun MenuItems(): List<@Composable () -> Unit> {
|
|
||||||
val menuItems = mutableListOf<@Composable () -> Unit>()
|
|
||||||
|
|
||||||
if (cs.liveMessage == null && !cs.editing && !nextSendGrpInv || sendMsgEnabled) {
|
|
||||||
if (
|
|
||||||
cs.preview !is ComposePreview.VoicePreview &&
|
|
||||||
cs.contextItem is ComposeContextItem.NoContextItem &&
|
cs.contextItem is ComposeContextItem.NoContextItem &&
|
||||||
sendLiveMessage != null && updateLiveMessage != null
|
sendLiveMessage != null && updateLiveMessage != null
|
||||||
) {
|
) {
|
||||||
menuItems.add {
|
val showDropdown = rememberSaveable { mutableStateOf(false) }
|
||||||
|
SendMsgButton(icon, sendButtonSize, sendButtonAlpha, !disabled, sendMessage) { showDropdown.value = true }
|
||||||
|
|
||||||
|
DefaultDropdownMenu(
|
||||||
|
showDropdown,
|
||||||
|
) {
|
||||||
ItemAction(
|
ItemAction(
|
||||||
generalGetString(MR.strings.send_live_message),
|
generalGetString(R.string.send_live_message),
|
||||||
BoltFilled,
|
BoltFilled,
|
||||||
onClick = {
|
onClick = {
|
||||||
startLiveMessage(scope, sendLiveMessage, updateLiveMessage, sendButtonSize, sendButtonAlpha, composeState, liveMessageAlertShown)
|
startLiveMessage(scope, sendLiveMessage, updateLiveMessage, sendButtonSize, sendButtonAlpha, composeState, liveMessageAlertShown)
|
||||||
@@ -180,32 +168,8 @@ fun SendMsgView(
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
if (timedMessageAllowed) {
|
|
||||||
menuItems.add {
|
|
||||||
ItemAction(
|
|
||||||
generalGetString(MR.strings.disappearing_message),
|
|
||||||
painterResource(MR.images.ic_timer),
|
|
||||||
onClick = {
|
|
||||||
showCustomDisappearingMessageDialog.value = true
|
|
||||||
showDropdown.value = false
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return menuItems
|
|
||||||
}
|
|
||||||
|
|
||||||
val menuItems = MenuItems()
|
|
||||||
if (menuItems.isNotEmpty()) {
|
|
||||||
SendMsgButton(icon, sendButtonSize, sendButtonAlpha, sendButtonColor, !disabled, sendMessage) { showDropdown.value = true }
|
|
||||||
DefaultDropdownMenu(showDropdown) {
|
|
||||||
menuItems.forEach { composable -> composable() }
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
SendMsgButton(icon, sendButtonSize, sendButtonAlpha, sendButtonColor, !disabled, sendMessage)
|
SendMsgButton(icon, sendButtonSize, sendButtonAlpha, !disabled, sendMessage)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -214,101 +178,115 @@ fun SendMsgView(
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
expect fun allowedToRecordVoiceByPlatform(): Boolean
|
private fun NativeKeyboard(
|
||||||
|
composeState: MutableState<ComposeState>,
|
||||||
@Composable
|
textStyle: MutableState<TextStyle>,
|
||||||
expect fun VoiceButtonWithoutPermissionByPlatform()
|
showDeleteTextButton: MutableState<Boolean>,
|
||||||
|
userIsObserver: Boolean,
|
||||||
@Composable
|
onMessageChange: (String) -> Unit
|
||||||
private fun CustomDisappearingMessageDialog(
|
|
||||||
sendMessage: (Int?) -> Unit,
|
|
||||||
setShowDialog: (Boolean) -> Unit,
|
|
||||||
customDisappearingMessageTimePref: SharedPreference<Int>?
|
|
||||||
) {
|
) {
|
||||||
val showCustomTimePicker = remember { mutableStateOf(false) }
|
val cs = composeState.value
|
||||||
|
val textColor = MaterialTheme.colors.onBackground
|
||||||
if (showCustomTimePicker.value) {
|
val tintColor = MaterialTheme.colors.secondaryVariant
|
||||||
val selectedDisappearingMessageTime = remember {
|
val padding = PaddingValues(12.dp, 7.dp, 45.dp, 0.dp)
|
||||||
mutableStateOf(customDisappearingMessageTimePref?.get?.invoke() ?: 300)
|
val paddingStart = with(LocalDensity.current) { 12.dp.roundToPx() }
|
||||||
|
val paddingTop = with(LocalDensity.current) { 7.dp.roundToPx() }
|
||||||
|
val paddingEnd = with(LocalDensity.current) { 45.dp.roundToPx() }
|
||||||
|
val paddingBottom = with(LocalDensity.current) { 7.dp.roundToPx() }
|
||||||
|
var showKeyboard by remember { mutableStateOf(false) }
|
||||||
|
LaunchedEffect(cs.contextItem) {
|
||||||
|
if (cs.contextItem is ComposeContextItem.QuotedItem) {
|
||||||
|
delay(100)
|
||||||
|
showKeyboard = true
|
||||||
|
} else if (cs.contextItem is ComposeContextItem.EditingItem) {
|
||||||
|
// Keyboard will not show up if we try to show it too fast
|
||||||
|
delay(300)
|
||||||
|
showKeyboard = true
|
||||||
}
|
}
|
||||||
CustomTimePickerDialog(
|
}
|
||||||
selectedDisappearingMessageTime,
|
|
||||||
title = generalGetString(MR.strings.delete_after),
|
AndroidView(modifier = Modifier, factory = {
|
||||||
confirmButtonText = generalGetString(MR.strings.send_disappearing_message_send),
|
val editText = @SuppressLint("AppCompatCustomView") object: EditText(it) {
|
||||||
confirmButtonAction = { ttl ->
|
override fun setOnReceiveContentListener(
|
||||||
sendMessage(ttl)
|
mimeTypes: Array<out String>?,
|
||||||
customDisappearingMessageTimePref?.set?.invoke(ttl)
|
listener: android.view.OnReceiveContentListener?
|
||||||
setShowDialog(false)
|
) {
|
||||||
},
|
super.setOnReceiveContentListener(mimeTypes, listener)
|
||||||
cancel = { setShowDialog(false) }
|
}
|
||||||
)
|
|
||||||
|
override fun onCreateInputConnection(editorInfo: EditorInfo): InputConnection {
|
||||||
|
val connection = super.onCreateInputConnection(editorInfo)
|
||||||
|
EditorInfoCompat.setContentMimeTypes(editorInfo, arrayOf("image/*"))
|
||||||
|
val onCommit = InputConnectionCompat.OnCommitContentListener { inputContentInfo, _, _ ->
|
||||||
|
try {
|
||||||
|
inputContentInfo.requestPermission()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
return@OnCommitContentListener false
|
||||||
|
}
|
||||||
|
SimplexApp.context.chatModel.sharedContent.value = SharedContent.Media("", listOf(inputContentInfo.contentUri))
|
||||||
|
true
|
||||||
|
}
|
||||||
|
return InputConnectionCompat.createWrapper(connection, editorInfo, onCommit)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
editText.layoutParams = ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT)
|
||||||
|
editText.maxLines = 16
|
||||||
|
editText.inputType = InputType.TYPE_TEXT_FLAG_CAP_SENTENCES or editText.inputType
|
||||||
|
editText.setTextColor(textColor.toArgb())
|
||||||
|
editText.textSize = textStyle.value.fontSize.value
|
||||||
|
val drawable = it.getDrawable(R.drawable.send_msg_view_background)!!
|
||||||
|
DrawableCompat.setTint(drawable, tintColor.toArgb())
|
||||||
|
editText.background = drawable
|
||||||
|
editText.setPadding(paddingStart, paddingTop, paddingEnd, paddingBottom)
|
||||||
|
editText.setText(cs.message)
|
||||||
|
if (Build.VERSION.SDK_INT >= 29) {
|
||||||
|
editText.textCursorDrawable?.let { DrawableCompat.setTint(it, CurrentColors.value.colors.secondary.toArgb()) }
|
||||||
} else {
|
} else {
|
||||||
@Composable
|
try {
|
||||||
fun ChoiceButton(
|
val f: Field = TextView::class.java.getDeclaredField("mCursorDrawableRes")
|
||||||
text: String,
|
f.isAccessible = true
|
||||||
onClick: () -> Unit
|
f.set(editText, R.drawable.edit_text_cursor)
|
||||||
) {
|
} catch (e: Exception) {
|
||||||
TextButton(onClick) {
|
Log.e(chat.simplex.app.TAG, e.stackTraceToString())
|
||||||
Text(
|
|
||||||
text,
|
|
||||||
fontSize = 18.sp,
|
|
||||||
color = MaterialTheme.colors.primary
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
editText.doOnTextChanged { text, _, _, _ -> onMessageChange(text.toString()) }
|
||||||
|
editText.doAfterTextChanged { text -> if (composeState.value.preview is ComposePreview.VoicePreview && text.toString() != "") editText.setText("") }
|
||||||
|
editText
|
||||||
|
}) {
|
||||||
|
it.setTextColor(textColor.toArgb())
|
||||||
|
it.textSize = textStyle.value.fontSize.value
|
||||||
|
DrawableCompat.setTint(it.background, tintColor.toArgb())
|
||||||
|
it.isFocusable = composeState.value.preview !is ComposePreview.VoicePreview
|
||||||
|
it.isFocusableInTouchMode = it.isFocusable
|
||||||
|
if (cs.message != it.text.toString()) {
|
||||||
|
it.setText(cs.message)
|
||||||
|
// Set cursor to the end of the text
|
||||||
|
it.setSelection(it.text.length)
|
||||||
|
}
|
||||||
|
if (showKeyboard) {
|
||||||
|
it.requestFocus()
|
||||||
|
val imm: InputMethodManager = SimplexApp.context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
|
||||||
|
imm.showSoftInput(it, InputMethodManager.SHOW_IMPLICIT)
|
||||||
|
showKeyboard = false
|
||||||
|
}
|
||||||
|
showDeleteTextButton.value = it.lineCount >= 4
|
||||||
|
}
|
||||||
|
if (composeState.value.preview is ComposePreview.VoicePreview) {
|
||||||
|
ComposeOverlay(R.string.voice_message_send_text, textStyle, padding)
|
||||||
|
} else if (userIsObserver) {
|
||||||
|
ComposeOverlay(R.string.you_are_observer, textStyle, padding)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
DefaultDialog(onDismissRequest = { setShowDialog(false) }) {
|
@Composable
|
||||||
Surface(
|
private fun ComposeOverlay(textId: Int, textStyle: MutableState<TextStyle>, padding: PaddingValues) {
|
||||||
shape = RoundedCornerShape(corner = CornerSize(25.dp))
|
|
||||||
) {
|
|
||||||
Box(
|
|
||||||
contentAlignment = Alignment.Center
|
|
||||||
) {
|
|
||||||
Column(
|
|
||||||
modifier = Modifier.padding(DEFAULT_PADDING),
|
|
||||||
verticalArrangement = Arrangement.spacedBy(6.dp),
|
|
||||||
horizontalAlignment = Alignment.CenterHorizontally
|
|
||||||
) {
|
|
||||||
Row(
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
|
||||||
verticalAlignment = Alignment.CenterVertically
|
|
||||||
) {
|
|
||||||
Text(" ") // centers title
|
|
||||||
Text(
|
Text(
|
||||||
generalGetString(MR.strings.send_disappearing_message),
|
generalGetString(textId),
|
||||||
fontSize = 16.sp,
|
Modifier.padding(padding),
|
||||||
color = MaterialTheme.colors.secondary
|
color = MaterialTheme.colors.secondary,
|
||||||
|
style = textStyle.value.copy(fontStyle = FontStyle.Italic)
|
||||||
)
|
)
|
||||||
Icon(
|
|
||||||
painterResource(MR.images.ic_close),
|
|
||||||
generalGetString(MR.strings.icon_descr_close_button),
|
|
||||||
tint = MaterialTheme.colors.secondary,
|
|
||||||
modifier = Modifier
|
|
||||||
.size(25.dp)
|
|
||||||
.clickable { setShowDialog(false) }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
ChoiceButton(generalGetString(MR.strings.send_disappearing_message_30_seconds)) {
|
|
||||||
sendMessage(30)
|
|
||||||
setShowDialog(false)
|
|
||||||
}
|
|
||||||
ChoiceButton(generalGetString(MR.strings.send_disappearing_message_1_minute)) {
|
|
||||||
sendMessage(60)
|
|
||||||
setShowDialog(false)
|
|
||||||
}
|
|
||||||
ChoiceButton(generalGetString(MR.strings.send_disappearing_message_5_minutes)) {
|
|
||||||
sendMessage(300)
|
|
||||||
setShowDialog(false)
|
|
||||||
}
|
|
||||||
ChoiceButton(generalGetString(MR.strings.send_disappearing_message_custom_time)) {
|
|
||||||
showCustomTimePicker.value = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
@@ -317,13 +295,13 @@ private fun BoxScope.DeleteTextButton(composeState: MutableState<ComposeState>)
|
|||||||
{ composeState.value = composeState.value.copy(message = "") },
|
{ composeState.value = composeState.value.copy(message = "") },
|
||||||
Modifier.align(Alignment.TopEnd).size(36.dp)
|
Modifier.align(Alignment.TopEnd).size(36.dp)
|
||||||
) {
|
) {
|
||||||
Icon(painterResource(MR.images.ic_close), null, Modifier.padding(7.dp).size(36.dp), tint = MaterialTheme.colors.secondary)
|
Icon(painterResource(R.drawable.ic_close), null, Modifier.padding(7.dp).size(36.dp), tint = MaterialTheme.colors.secondary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun RecordVoiceView(recState: MutableState<RecordingState>, stopRecOnNextClick: MutableState<Boolean>) {
|
private fun RecordVoiceView(recState: MutableState<RecordingState>, stopRecOnNextClick: MutableState<Boolean>) {
|
||||||
val rec: RecorderInterface = remember { RecorderNative() }
|
val rec: Recorder = remember { RecorderNative(MAX_VOICE_SIZE_FOR_SENDING) }
|
||||||
DisposableEffect(Unit) { onDispose { rec.stop() } }
|
DisposableEffect(Unit) { onDispose { rec.stop() } }
|
||||||
val stopRecordingAndAddAudio: () -> Unit = {
|
val stopRecordingAndAddAudio: () -> Unit = {
|
||||||
recState.value.filePathNullable?.let {
|
recState.value.filePathNullable?.let {
|
||||||
@@ -340,10 +318,7 @@ private fun RecordVoiceView(recState: MutableState<RecordingState>, stopRecOnNex
|
|||||||
LockToCurrentOrientationUntilDispose()
|
LockToCurrentOrientationUntilDispose()
|
||||||
StopRecordButton(stopRecordingAndAddAudio)
|
StopRecordButton(stopRecordingAndAddAudio)
|
||||||
} else {
|
} else {
|
||||||
val startRecording: () -> Unit = out@ {
|
val startRecording: () -> Unit = {
|
||||||
if (appPlatform.isDesktop) {
|
|
||||||
return@out showInDevelopingAlert()
|
|
||||||
}
|
|
||||||
recState.value = RecordingState.Started(
|
recState.value = RecordingState.Started(
|
||||||
filePath = rec.start { progress: Int?, finished: Boolean ->
|
filePath = rec.start { progress: Int?, finished: Boolean ->
|
||||||
val state = recState.value
|
val state = recState.value
|
||||||
@@ -377,8 +352,8 @@ private fun RecordVoiceView(recState: MutableState<RecordingState>, stopRecOnNex
|
|||||||
private fun DisallowedVoiceButton(enabled: Boolean, onClick: () -> Unit) {
|
private fun DisallowedVoiceButton(enabled: Boolean, onClick: () -> Unit) {
|
||||||
IconButton(onClick, Modifier.size(36.dp), enabled = enabled) {
|
IconButton(onClick, Modifier.size(36.dp), enabled = enabled) {
|
||||||
Icon(
|
Icon(
|
||||||
painterResource(MR.images.ic_keyboard_voice),
|
painterResource(R.drawable.ic_keyboard_voice),
|
||||||
stringResource(MR.strings.icon_descr_record_voice_message),
|
stringResource(R.string.icon_descr_record_voice_message),
|
||||||
tint = MaterialTheme.colors.secondary,
|
tint = MaterialTheme.colors.secondary,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.size(36.dp)
|
.size(36.dp)
|
||||||
@@ -388,11 +363,11 @@ private fun DisallowedVoiceButton(enabled: Boolean, onClick: () -> Unit) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun VoiceButtonWithoutPermission(onClick: () -> Unit) {
|
private fun VoiceButtonWithoutPermission(onClick: () -> Unit) {
|
||||||
IconButton(onClick, Modifier.size(36.dp)) {
|
IconButton(onClick, Modifier.size(36.dp)) {
|
||||||
Icon(
|
Icon(
|
||||||
painterResource(MR.images.ic_keyboard_voice_filled),
|
painterResource(R.drawable.ic_keyboard_voice_filled),
|
||||||
stringResource(MR.strings.icon_descr_record_voice_message),
|
stringResource(R.string.icon_descr_record_voice_message),
|
||||||
tint = MaterialTheme.colors.primary,
|
tint = MaterialTheme.colors.primary,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.size(34.dp)
|
.size(34.dp)
|
||||||
@@ -401,12 +376,30 @@ fun VoiceButtonWithoutPermission(onClick: () -> Unit) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun LockToCurrentOrientationUntilDispose() {
|
||||||
|
val context = LocalContext.current
|
||||||
|
DisposableEffect(Unit) {
|
||||||
|
val activity = context as Activity
|
||||||
|
val manager = context.getSystemService(Activity.WINDOW_SERVICE) as WindowManager
|
||||||
|
val rotation = if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.Q) manager.defaultDisplay.rotation else activity.display?.rotation
|
||||||
|
activity.requestedOrientation = when (rotation) {
|
||||||
|
android.view.Surface.ROTATION_90 -> ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE
|
||||||
|
android.view.Surface.ROTATION_180 -> ActivityInfo.SCREEN_ORIENTATION_REVERSE_PORTRAIT
|
||||||
|
android.view.Surface.ROTATION_270 -> ActivityInfo.SCREEN_ORIENTATION_REVERSE_LANDSCAPE
|
||||||
|
else -> ActivityInfo.SCREEN_ORIENTATION_PORTRAIT
|
||||||
|
}
|
||||||
|
// Unlock orientation
|
||||||
|
onDispose { activity.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun StopRecordButton(onClick: () -> Unit) {
|
private fun StopRecordButton(onClick: () -> Unit) {
|
||||||
IconButton(onClick, Modifier.size(36.dp)) {
|
IconButton(onClick, Modifier.size(36.dp)) {
|
||||||
Icon(
|
Icon(
|
||||||
painterResource(MR.images.ic_stop_filled),
|
painterResource(R.drawable.ic_stop_filled),
|
||||||
stringResource(MR.strings.icon_descr_record_voice_message),
|
stringResource(R.string.icon_descr_record_voice_message),
|
||||||
tint = MaterialTheme.colors.primary,
|
tint = MaterialTheme.colors.primary,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.size(36.dp)
|
.size(36.dp)
|
||||||
@@ -419,8 +412,8 @@ private fun StopRecordButton(onClick: () -> Unit) {
|
|||||||
private fun RecordVoiceButton(interactionSource: MutableInteractionSource) {
|
private fun RecordVoiceButton(interactionSource: MutableInteractionSource) {
|
||||||
IconButton({}, Modifier.size(36.dp), interactionSource = interactionSource) {
|
IconButton({}, Modifier.size(36.dp), interactionSource = interactionSource) {
|
||||||
Icon(
|
Icon(
|
||||||
painterResource(MR.images.ic_keyboard_voice_filled),
|
painterResource(R.drawable.ic_keyboard_voice_filled),
|
||||||
stringResource(MR.strings.icon_descr_record_voice_message),
|
stringResource(R.string.icon_descr_record_voice_message),
|
||||||
tint = MaterialTheme.colors.primary,
|
tint = MaterialTheme.colors.primary,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.size(34.dp)
|
.size(34.dp)
|
||||||
@@ -440,8 +433,8 @@ private fun CancelLiveMessageButton(
|
|||||||
) {
|
) {
|
||||||
IconButton(onClick, Modifier.size(36.dp)) {
|
IconButton(onClick, Modifier.size(36.dp)) {
|
||||||
Icon(
|
Icon(
|
||||||
painterResource(MR.images.ic_close),
|
painterResource(R.drawable.ic_close),
|
||||||
stringResource(MR.strings.icon_descr_cancel_live_message),
|
stringResource(R.string.icon_descr_cancel_live_message),
|
||||||
tint = MaterialTheme.colors.primary,
|
tint = MaterialTheme.colors.primary,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.size(36.dp)
|
.size(36.dp)
|
||||||
@@ -455,35 +448,33 @@ private fun SendMsgButton(
|
|||||||
icon: Painter,
|
icon: Painter,
|
||||||
sizeDp: Animatable<Float, AnimationVector1D>,
|
sizeDp: Animatable<Float, AnimationVector1D>,
|
||||||
alpha: Animatable<Float, AnimationVector1D>,
|
alpha: Animatable<Float, AnimationVector1D>,
|
||||||
sendButtonColor: Color,
|
|
||||||
enabled: Boolean,
|
enabled: Boolean,
|
||||||
sendMessage: (Int?) -> Unit,
|
sendMessage: () -> Unit,
|
||||||
onLongClick: (() -> Unit)? = null
|
onLongClick: (() -> Unit)? = null
|
||||||
) {
|
) {
|
||||||
val interactionSource = remember { MutableInteractionSource() }
|
val interactionSource = remember { MutableInteractionSource() }
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier.requiredSize(36.dp)
|
modifier = Modifier.requiredSize(36.dp)
|
||||||
.combinedClickable(
|
.combinedClickable(
|
||||||
onClick = { sendMessage(null) },
|
onClick = sendMessage,
|
||||||
onLongClick = onLongClick,
|
onLongClick = onLongClick,
|
||||||
enabled = enabled,
|
enabled = enabled,
|
||||||
role = Role.Button,
|
role = Role.Button,
|
||||||
interactionSource = interactionSource,
|
interactionSource = interactionSource,
|
||||||
indication = rememberRipple(bounded = false, radius = 24.dp)
|
indication = rememberRipple(bounded = false, radius = 24.dp)
|
||||||
)
|
),
|
||||||
.onRightClick { onLongClick?.invoke() },
|
|
||||||
contentAlignment = Alignment.Center
|
contentAlignment = Alignment.Center
|
||||||
) {
|
) {
|
||||||
Icon(
|
Icon(
|
||||||
icon,
|
icon,
|
||||||
stringResource(MR.strings.icon_descr_send_message),
|
stringResource(R.string.icon_descr_send_message),
|
||||||
tint = Color.White,
|
tint = Color.White,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.size(sizeDp.value.dp)
|
.size(sizeDp.value.dp)
|
||||||
.padding(4.dp)
|
.padding(4.dp)
|
||||||
.alpha(alpha.value)
|
.alpha(alpha.value)
|
||||||
.clip(CircleShape)
|
.clip(CircleShape)
|
||||||
.background(if (enabled) sendButtonColor else MaterialTheme.colors.secondary)
|
.background(if (enabled) MaterialTheme.colors.primary else MaterialTheme.colors.secondary)
|
||||||
.padding(3.dp)
|
.padding(3.dp)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -505,7 +496,7 @@ private fun StartLiveMessageButton(enabled: Boolean, onClick: () -> Unit) {
|
|||||||
) {
|
) {
|
||||||
Icon(
|
Icon(
|
||||||
BoltFilled,
|
BoltFilled,
|
||||||
stringResource(MR.strings.icon_descr_send_message),
|
stringResource(R.string.icon_descr_send_message),
|
||||||
tint = if (enabled) MaterialTheme.colors.primary else MaterialTheme.colors.secondary,
|
tint = if (enabled) MaterialTheme.colors.primary else MaterialTheme.colors.secondary,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.size(36.dp)
|
.size(36.dp)
|
||||||
@@ -556,9 +547,9 @@ private fun startLiveMessage(
|
|||||||
start()
|
start()
|
||||||
} else {
|
} else {
|
||||||
AlertManager.shared.showAlertDialog(
|
AlertManager.shared.showAlertDialog(
|
||||||
title = generalGetString(MR.strings.live_message),
|
title = generalGetString(R.string.live_message),
|
||||||
text = generalGetString(MR.strings.send_live_message_desc),
|
text = generalGetString(R.string.send_live_message_desc),
|
||||||
confirmText = generalGetString(MR.strings.send_verb),
|
confirmText = generalGetString(R.string.send_verb),
|
||||||
onConfirm = {
|
onConfirm = {
|
||||||
liveMessageAlertShown.set(true)
|
liveMessageAlertShown.set(true)
|
||||||
start()
|
start()
|
||||||
@@ -568,31 +559,32 @@ private fun startLiveMessage(
|
|||||||
|
|
||||||
private fun showNeedToAllowVoiceAlert(onConfirm: () -> Unit) {
|
private fun showNeedToAllowVoiceAlert(onConfirm: () -> Unit) {
|
||||||
AlertManager.shared.showAlertDialog(
|
AlertManager.shared.showAlertDialog(
|
||||||
title = generalGetString(MR.strings.allow_voice_messages_question),
|
title = generalGetString(R.string.allow_voice_messages_question),
|
||||||
text = generalGetString(MR.strings.you_need_to_allow_to_send_voice),
|
text = generalGetString(R.string.you_need_to_allow_to_send_voice),
|
||||||
confirmText = generalGetString(MR.strings.allow_verb),
|
confirmText = generalGetString(R.string.allow_verb),
|
||||||
dismissText = generalGetString(MR.strings.cancel_verb),
|
dismissText = generalGetString(R.string.cancel_verb),
|
||||||
onConfirm = onConfirm,
|
onConfirm = onConfirm,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun showDisabledVoiceAlert(isDirectChat: Boolean) {
|
private fun showDisabledVoiceAlert(isDirectChat: Boolean) {
|
||||||
AlertManager.shared.showAlertMsg(
|
AlertManager.shared.showAlertMsg(
|
||||||
title = generalGetString(MR.strings.voice_messages_prohibited),
|
title = generalGetString(R.string.voice_messages_prohibited),
|
||||||
text = generalGetString(
|
text = generalGetString(
|
||||||
if (isDirectChat)
|
if (isDirectChat)
|
||||||
MR.strings.ask_your_contact_to_enable_voice
|
R.string.ask_your_contact_to_enable_voice
|
||||||
else
|
else
|
||||||
MR.strings.only_group_owners_can_enable_voice
|
R.string.only_group_owners_can_enable_voice
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Preview/*(
|
@Preview(showBackground = true)
|
||||||
|
@Preview(
|
||||||
uiMode = Configuration.UI_MODE_NIGHT_YES,
|
uiMode = Configuration.UI_MODE_NIGHT_YES,
|
||||||
showBackground = true,
|
showBackground = true,
|
||||||
name = "Dark Mode"
|
name = "Dark Mode"
|
||||||
)*/
|
)
|
||||||
@Composable
|
@Composable
|
||||||
fun PreviewSendMsgView() {
|
fun PreviewSendMsgView() {
|
||||||
val smallFont = MaterialTheme.typography.body1.copy(color = MaterialTheme.colors.onBackground)
|
val smallFont = MaterialTheme.typography.body1.copy(color = MaterialTheme.colors.onBackground)
|
||||||
@@ -604,28 +596,24 @@ fun PreviewSendMsgView() {
|
|||||||
recState = remember { mutableStateOf(RecordingState.NotStarted) },
|
recState = remember { mutableStateOf(RecordingState.NotStarted) },
|
||||||
isDirectChat = true,
|
isDirectChat = true,
|
||||||
liveMessageAlertShown = SharedPreference(get = { true }, set = { }),
|
liveMessageAlertShown = SharedPreference(get = { true }, set = { }),
|
||||||
sendMsgEnabled = true,
|
|
||||||
nextSendGrpInv = false,
|
|
||||||
needToAllowVoiceToContact = false,
|
needToAllowVoiceToContact = false,
|
||||||
allowedVoiceByPrefs = true,
|
allowedVoiceByPrefs = true,
|
||||||
userIsObserver = false,
|
userIsObserver = false,
|
||||||
userCanSend = true,
|
userCanSend = true,
|
||||||
allowVoiceToContact = {},
|
allowVoiceToContact = {},
|
||||||
timedMessageAllowed = false,
|
|
||||||
sendMessage = {},
|
sendMessage = {},
|
||||||
editPrevMessage = {},
|
|
||||||
onMessageChange = { _ -> },
|
onMessageChange = { _ -> },
|
||||||
onFilesPasted = {},
|
|
||||||
textStyle = textStyle
|
textStyle = textStyle
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Preview/*(
|
@Preview(showBackground = true)
|
||||||
|
@Preview(
|
||||||
uiMode = Configuration.UI_MODE_NIGHT_YES,
|
uiMode = Configuration.UI_MODE_NIGHT_YES,
|
||||||
showBackground = true,
|
showBackground = true,
|
||||||
name = "Dark Mode"
|
name = "Dark Mode"
|
||||||
)*/
|
)
|
||||||
@Composable
|
@Composable
|
||||||
fun PreviewSendMsgViewEditing() {
|
fun PreviewSendMsgViewEditing() {
|
||||||
val smallFont = MaterialTheme.typography.body1.copy(color = MaterialTheme.colors.onBackground)
|
val smallFont = MaterialTheme.typography.body1.copy(color = MaterialTheme.colors.onBackground)
|
||||||
@@ -638,28 +626,24 @@ fun PreviewSendMsgViewEditing() {
|
|||||||
recState = remember { mutableStateOf(RecordingState.NotStarted) },
|
recState = remember { mutableStateOf(RecordingState.NotStarted) },
|
||||||
isDirectChat = true,
|
isDirectChat = true,
|
||||||
liveMessageAlertShown = SharedPreference(get = { true }, set = { }),
|
liveMessageAlertShown = SharedPreference(get = { true }, set = { }),
|
||||||
sendMsgEnabled = true,
|
|
||||||
nextSendGrpInv = false,
|
|
||||||
needToAllowVoiceToContact = false,
|
needToAllowVoiceToContact = false,
|
||||||
allowedVoiceByPrefs = true,
|
allowedVoiceByPrefs = true,
|
||||||
userIsObserver = false,
|
userIsObserver = false,
|
||||||
userCanSend = true,
|
userCanSend = true,
|
||||||
allowVoiceToContact = {},
|
allowVoiceToContact = {},
|
||||||
timedMessageAllowed = false,
|
|
||||||
sendMessage = {},
|
sendMessage = {},
|
||||||
editPrevMessage = {},
|
|
||||||
onMessageChange = { _ -> },
|
onMessageChange = { _ -> },
|
||||||
onFilesPasted = {},
|
|
||||||
textStyle = textStyle
|
textStyle = textStyle
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Preview/*(
|
@Preview(showBackground = true)
|
||||||
|
@Preview(
|
||||||
uiMode = Configuration.UI_MODE_NIGHT_YES,
|
uiMode = Configuration.UI_MODE_NIGHT_YES,
|
||||||
showBackground = true,
|
showBackground = true,
|
||||||
name = "Dark Mode"
|
name = "Dark Mode"
|
||||||
)*/
|
)
|
||||||
@Composable
|
@Composable
|
||||||
fun PreviewSendMsgViewInProgress() {
|
fun PreviewSendMsgViewInProgress() {
|
||||||
val smallFont = MaterialTheme.typography.body1.copy(color = MaterialTheme.colors.onBackground)
|
val smallFont = MaterialTheme.typography.body1.copy(color = MaterialTheme.colors.onBackground)
|
||||||
@@ -672,18 +656,13 @@ fun PreviewSendMsgViewInProgress() {
|
|||||||
recState = remember { mutableStateOf(RecordingState.NotStarted) },
|
recState = remember { mutableStateOf(RecordingState.NotStarted) },
|
||||||
isDirectChat = true,
|
isDirectChat = true,
|
||||||
liveMessageAlertShown = SharedPreference(get = { true }, set = { }),
|
liveMessageAlertShown = SharedPreference(get = { true }, set = { }),
|
||||||
sendMsgEnabled = true,
|
|
||||||
nextSendGrpInv = false,
|
|
||||||
needToAllowVoiceToContact = false,
|
needToAllowVoiceToContact = false,
|
||||||
allowedVoiceByPrefs = true,
|
allowedVoiceByPrefs = true,
|
||||||
userIsObserver = false,
|
userIsObserver = false,
|
||||||
userCanSend = true,
|
userCanSend = true,
|
||||||
allowVoiceToContact = {},
|
allowVoiceToContact = {},
|
||||||
timedMessageAllowed = false,
|
|
||||||
sendMessage = {},
|
sendMessage = {},
|
||||||
editPrevMessage = {},
|
|
||||||
onMessageChange = { _ -> },
|
onMessageChange = { _ -> },
|
||||||
onFilesPasted = {},
|
|
||||||
textStyle = textStyle
|
textStyle = textStyle
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
+21
-25
@@ -1,4 +1,4 @@
|
|||||||
package chat.simplex.common.views.chat
|
package chat.simplex.app.views.chat
|
||||||
|
|
||||||
import SectionBottomSpacer
|
import SectionBottomSpacer
|
||||||
import SectionView
|
import SectionView
|
||||||
@@ -10,18 +10,16 @@ import androidx.compose.material.*
|
|||||||
import androidx.compose.runtime.*
|
import androidx.compose.runtime.*
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.platform.LocalClipboardManager
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import dev.icerock.moko.resources.compose.painterResource
|
import androidx.compose.ui.res.painterResource
|
||||||
import dev.icerock.moko.resources.compose.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.text.font.FontFamily
|
import androidx.compose.ui.text.font.FontFamily
|
||||||
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 chat.simplex.common.platform.appPlatform
|
import chat.simplex.app.R
|
||||||
import chat.simplex.common.platform.shareText
|
import chat.simplex.app.ui.theme.*
|
||||||
import chat.simplex.common.ui.theme.*
|
import chat.simplex.app.views.helpers.*
|
||||||
import chat.simplex.common.views.helpers.*
|
import chat.simplex.app.views.newchat.QRCode
|
||||||
import chat.simplex.common.views.newchat.QRCode
|
|
||||||
import chat.simplex.res.MR
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun VerifyCodeView(
|
fun VerifyCodeView(
|
||||||
@@ -63,14 +61,14 @@ private fun VerifyCodeLayout(
|
|||||||
.verticalScroll(rememberScrollState())
|
.verticalScroll(rememberScrollState())
|
||||||
.padding(horizontal = DEFAULT_PADDING)
|
.padding(horizontal = DEFAULT_PADDING)
|
||||||
) {
|
) {
|
||||||
AppBarTitle(stringResource(MR.strings.security_code), withPadding = false)
|
AppBarTitle(stringResource(R.string.security_code), false)
|
||||||
val splitCode = splitToParts(connectionCode, 24)
|
val splitCode = splitToParts(connectionCode, 24)
|
||||||
Row(Modifier.fillMaxWidth().padding(bottom = DEFAULT_PADDING_HALF), horizontalArrangement = Arrangement.Center) {
|
Row(Modifier.fillMaxWidth().padding(bottom = DEFAULT_PADDING_HALF), horizontalArrangement = Arrangement.Center) {
|
||||||
if (connectionVerified) {
|
if (connectionVerified) {
|
||||||
Icon(painterResource(MR.images.ic_verified_user), null, Modifier.padding(end = 4.dp).size(22.dp), tint = MaterialTheme.colors.secondary)
|
Icon(painterResource(R.drawable.ic_verified_user), null, Modifier.padding(end = 4.dp).size(22.dp), tint = MaterialTheme.colors.secondary)
|
||||||
Text(String.format(stringResource(MR.strings.is_verified), displayName))
|
Text(String.format(stringResource(R.string.is_verified), displayName))
|
||||||
} else {
|
} else {
|
||||||
Text(String.format(stringResource(MR.strings.is_not_verified), displayName))
|
Text(String.format(stringResource(R.string.is_not_verified), displayName))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -88,17 +86,17 @@ private fun VerifyCodeLayout(
|
|||||||
maxLines = 20
|
maxLines = 20
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
val context = LocalContext.current
|
||||||
Box(Modifier.weight(1f)) {
|
Box(Modifier.weight(1f)) {
|
||||||
val clipboard = LocalClipboardManager.current
|
IconButton({ shareText(context, connectionCode) }, Modifier.size(20.dp).align(Alignment.CenterStart)) {
|
||||||
IconButton({ clipboard.shareText(connectionCode) }, Modifier.size(20.dp).align(Alignment.CenterStart)) {
|
Icon(painterResource(R.drawable.ic_share_filled), null, tint = MaterialTheme.colors.primary)
|
||||||
Icon(painterResource(MR.images.ic_share_filled), null, tint = MaterialTheme.colors.primary)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Spacer(Modifier.weight(1f))
|
Spacer(Modifier.weight(1f))
|
||||||
}
|
}
|
||||||
|
|
||||||
Text(
|
Text(
|
||||||
generalGetString(MR.strings.to_verify_compare),
|
generalGetString(R.string.to_verify_compare),
|
||||||
Modifier.padding(bottom = DEFAULT_PADDING)
|
Modifier.padding(bottom = DEFAULT_PADDING)
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -107,22 +105,20 @@ private fun VerifyCodeLayout(
|
|||||||
horizontalArrangement = Arrangement.spacedBy(10.dp)
|
horizontalArrangement = Arrangement.spacedBy(10.dp)
|
||||||
) {
|
) {
|
||||||
if (connectionVerified) {
|
if (connectionVerified) {
|
||||||
SimpleButton(generalGetString(MR.strings.clear_verification), painterResource(MR.images.ic_shield)) {
|
SimpleButton(generalGetString(R.string.clear_verification), painterResource(R.drawable.ic_shield)) {
|
||||||
verifyCode(null) {}
|
verifyCode(null) {}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
if (appPlatform.isAndroid) {
|
SimpleButton(generalGetString(R.string.scan_code), painterResource(R.drawable.ic_qr_code)) {
|
||||||
SimpleButton(generalGetString(MR.strings.scan_code), painterResource(MR.images.ic_qr_code)) {
|
ModalManager.shared.showModal {
|
||||||
ModalManager.end.showModal {
|
|
||||||
ScanCodeView(verifyCode) { }
|
ScanCodeView(verifyCode) { }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
SimpleButton(generalGetString(R.string.mark_code_verified), painterResource(R.drawable.ic_verified_user)) {
|
||||||
SimpleButton(generalGetString(MR.strings.mark_code_verified), painterResource(MR.images.ic_verified_user)) {
|
|
||||||
verifyCode(connectionCode) { verified ->
|
verifyCode(connectionCode) { verified ->
|
||||||
if (!verified) {
|
if (!verified) {
|
||||||
AlertManager.shared.showAlertMsg(
|
AlertManager.shared.showAlertMsg(
|
||||||
title = generalGetString(MR.strings.incorrect_code)
|
title = generalGetString(R.string.incorrect_code)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+53
-93
@@ -1,4 +1,4 @@
|
|||||||
package chat.simplex.common.views.chat.group
|
package chat.simplex.app.views.chat.group
|
||||||
|
|
||||||
import SectionBottomSpacer
|
import SectionBottomSpacer
|
||||||
import SectionCustomFooter
|
import SectionCustomFooter
|
||||||
@@ -6,59 +6,55 @@ import SectionDividerSpaced
|
|||||||
import SectionItemView
|
import SectionItemView
|
||||||
import SectionSpacer
|
import SectionSpacer
|
||||||
import SectionView
|
import SectionView
|
||||||
|
import androidx.activity.compose.BackHandler
|
||||||
import androidx.compose.foundation.*
|
import androidx.compose.foundation.*
|
||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.*
|
||||||
import androidx.compose.material.*
|
import androidx.compose.material.*
|
||||||
import androidx.compose.runtime.*
|
import androidx.compose.runtime.*
|
||||||
import androidx.compose.runtime.saveable.rememberSaveable
|
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.graphics.painter.Painter
|
import androidx.compose.ui.graphics.painter.Painter
|
||||||
import dev.icerock.moko.resources.compose.painterResource
|
import androidx.compose.ui.res.painterResource
|
||||||
import dev.icerock.moko.resources.compose.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.text.input.TextFieldValue
|
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.desktop.ui.tooling.preview.Preview
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
|
||||||
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 chat.simplex.common.model.*
|
import chat.simplex.app.R
|
||||||
import chat.simplex.common.ui.theme.*
|
import chat.simplex.app.model.*
|
||||||
import chat.simplex.common.views.chat.ChatInfoToolbarTitle
|
import chat.simplex.app.ui.theme.*
|
||||||
import chat.simplex.common.views.helpers.*
|
import chat.simplex.app.views.chat.ChatInfoToolbarTitle
|
||||||
import chat.simplex.common.views.usersettings.SettingsActionItem
|
import chat.simplex.app.views.helpers.*
|
||||||
import chat.simplex.common.model.GroupInfo
|
import chat.simplex.app.views.newchat.InfoAboutIncognito
|
||||||
import chat.simplex.common.platform.*
|
import chat.simplex.app.views.usersettings.SettingsActionItem
|
||||||
import chat.simplex.res.MR
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun AddGroupMembersView(rhId: Long?, groupInfo: GroupInfo, creatingGroup: Boolean = false, chatModel: ChatModel, close: () -> Unit) {
|
fun AddGroupMembersView(groupInfo: GroupInfo, creatingGroup: Boolean = false, chatModel: ChatModel, close: () -> Unit) {
|
||||||
val selectedContacts = remember { mutableStateListOf<Long>() }
|
val selectedContacts = remember { mutableStateListOf<Long>() }
|
||||||
val selectedRole = remember { mutableStateOf(GroupMemberRole.Member) }
|
val selectedRole = remember { mutableStateOf(GroupMemberRole.Member) }
|
||||||
var allowModifyMembers by remember { mutableStateOf(true) }
|
var allowModifyMembers by remember { mutableStateOf(true) }
|
||||||
val searchText = rememberSaveable(stateSaver = TextFieldValue.Saver) { mutableStateOf(TextFieldValue("")) }
|
|
||||||
BackHandler(onBack = close)
|
BackHandler(onBack = close)
|
||||||
AddGroupMembersLayout(
|
AddGroupMembersLayout(
|
||||||
|
chatModel.incognito.value,
|
||||||
groupInfo = groupInfo,
|
groupInfo = groupInfo,
|
||||||
creatingGroup = creatingGroup,
|
creatingGroup = creatingGroup,
|
||||||
contactsToAdd = getContactsToAdd(chatModel, searchText.value.text),
|
contactsToAdd = getContactsToAdd(chatModel),
|
||||||
selectedContacts = selectedContacts,
|
selectedContacts = selectedContacts,
|
||||||
selectedRole = selectedRole,
|
selectedRole = selectedRole,
|
||||||
allowModifyMembers = allowModifyMembers,
|
allowModifyMembers = allowModifyMembers,
|
||||||
searchText,
|
|
||||||
openPreferences = {
|
openPreferences = {
|
||||||
ModalManager.end.showCustomModal { close ->
|
ModalManager.shared.showCustomModal { close ->
|
||||||
GroupPreferencesView(chatModel, rhId, groupInfo.id, close)
|
GroupPreferencesView(chatModel, groupInfo.id, close)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
inviteMembers = {
|
inviteMembers = {
|
||||||
allowModifyMembers = false
|
allowModifyMembers = false
|
||||||
withApi {
|
withApi {
|
||||||
for (contactId in selectedContacts) {
|
for (contactId in selectedContacts) {
|
||||||
val member = chatModel.controller.apiAddMember(rhId, groupInfo.groupId, contactId, selectedRole.value)
|
val member = chatModel.controller.apiAddMember(groupInfo.groupId, contactId, selectedRole.value)
|
||||||
if (member != null) {
|
if (member != null) {
|
||||||
chatModel.upsertGroupMember(rhId, groupInfo, member)
|
chatModel.upsertGroupMember(groupInfo, member)
|
||||||
} else {
|
} else {
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
@@ -71,13 +67,9 @@ fun AddGroupMembersView(rhId: Long?, groupInfo: GroupInfo, creatingGroup: Boolea
|
|||||||
removeContact = { contactId -> selectedContacts.removeIf { it == contactId } },
|
removeContact = { contactId -> selectedContacts.removeIf { it == contactId } },
|
||||||
close = close,
|
close = close,
|
||||||
)
|
)
|
||||||
KeyChangeEffect(chatModel.chatId.value) {
|
|
||||||
close()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun getContactsToAdd(chatModel: ChatModel, search: String): List<Contact> {
|
fun getContactsToAdd(chatModel: ChatModel): List<Contact> {
|
||||||
val s = search.trim().lowercase()
|
|
||||||
val memberContactIds = chatModel.groupMembers
|
val memberContactIds = chatModel.groupMembers
|
||||||
.filter { it.memberCurrent }
|
.filter { it.memberCurrent }
|
||||||
.mapNotNull { it.memberContactId }
|
.mapNotNull { it.memberContactId }
|
||||||
@@ -86,20 +78,20 @@ fun getContactsToAdd(chatModel: ChatModel, search: String): List<Contact> {
|
|||||||
.map { it.chatInfo }
|
.map { it.chatInfo }
|
||||||
.filterIsInstance<ChatInfo.Direct>()
|
.filterIsInstance<ChatInfo.Direct>()
|
||||||
.map { it.contact }
|
.map { it.contact }
|
||||||
.filter { it.contactId !in memberContactIds && it.chatViewName.lowercase().contains(s) }
|
.filter { it.contactId !in memberContactIds }
|
||||||
.sortedBy { it.displayName.lowercase() }
|
.sortedBy { it.displayName.lowercase() }
|
||||||
.toList()
|
.toList()
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun AddGroupMembersLayout(
|
fun AddGroupMembersLayout(
|
||||||
|
chatModelIncognito: Boolean,
|
||||||
groupInfo: GroupInfo,
|
groupInfo: GroupInfo,
|
||||||
creatingGroup: Boolean,
|
creatingGroup: Boolean,
|
||||||
contactsToAdd: List<Contact>,
|
contactsToAdd: List<Contact>,
|
||||||
selectedContacts: List<Long>,
|
selectedContacts: List<Long>,
|
||||||
selectedRole: MutableState<GroupMemberRole>,
|
selectedRole: MutableState<GroupMemberRole>,
|
||||||
allowModifyMembers: Boolean,
|
allowModifyMembers: Boolean,
|
||||||
searchText: MutableState<TextFieldValue>,
|
|
||||||
openPreferences: () -> Unit,
|
openPreferences: () -> Unit,
|
||||||
inviteMembers: () -> Unit,
|
inviteMembers: () -> Unit,
|
||||||
clearSelection: () -> Unit,
|
clearSelection: () -> Unit,
|
||||||
@@ -107,31 +99,19 @@ fun AddGroupMembersLayout(
|
|||||||
removeContact: (Long) -> Unit,
|
removeContact: (Long) -> Unit,
|
||||||
close: () -> Unit,
|
close: () -> Unit,
|
||||||
) {
|
) {
|
||||||
@Composable fun profileText() {
|
|
||||||
Row(
|
|
||||||
Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(vertical = 4.dp),
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
horizontalArrangement = Arrangement.Center
|
|
||||||
) {
|
|
||||||
Icon(
|
|
||||||
painterResource(MR.images.ic_info),
|
|
||||||
null,
|
|
||||||
tint = MaterialTheme.colors.secondary,
|
|
||||||
modifier = Modifier.padding(end = 10.dp).size(20.dp)
|
|
||||||
)
|
|
||||||
Text(generalGetString(MR.strings.group_main_profile_sent), textAlign = TextAlign.Center, style = MaterialTheme.typography.body2)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Column(
|
Column(
|
||||||
Modifier
|
Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.verticalScroll(rememberScrollState()),
|
.verticalScroll(rememberScrollState()),
|
||||||
) {
|
) {
|
||||||
AppBarTitle(stringResource(MR.strings.button_add_members))
|
AppBarTitle(stringResource(R.string.button_add_members))
|
||||||
profileText()
|
InfoAboutIncognito(
|
||||||
|
chatModelIncognito,
|
||||||
|
false,
|
||||||
|
generalGetString(R.string.group_unsupported_incognito_main_profile_sent),
|
||||||
|
generalGetString(R.string.group_main_profile_sent),
|
||||||
|
true
|
||||||
|
)
|
||||||
Spacer(Modifier.size(DEFAULT_PADDING))
|
Spacer(Modifier.size(DEFAULT_PADDING))
|
||||||
Row(
|
Row(
|
||||||
Modifier.fillMaxWidth(),
|
Modifier.fillMaxWidth(),
|
||||||
@@ -145,13 +125,13 @@ fun AddGroupMembersLayout(
|
|||||||
}
|
}
|
||||||
SectionSpacer()
|
SectionSpacer()
|
||||||
|
|
||||||
if (contactsToAdd.isEmpty() && searchText.value.text.isEmpty()) {
|
if (contactsToAdd.isEmpty()) {
|
||||||
Row(
|
Row(
|
||||||
Modifier.fillMaxWidth(),
|
Modifier.fillMaxWidth(),
|
||||||
horizontalArrangement = Arrangement.Center
|
horizontalArrangement = Arrangement.Center
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
stringResource(MR.strings.no_contacts_to_add),
|
stringResource(R.string.no_contacts_to_add),
|
||||||
Modifier.padding(),
|
Modifier.padding(),
|
||||||
color = MaterialTheme.colors.secondary
|
color = MaterialTheme.colors.secondary
|
||||||
)
|
)
|
||||||
@@ -160,7 +140,7 @@ fun AddGroupMembersLayout(
|
|||||||
SectionView {
|
SectionView {
|
||||||
if (creatingGroup) {
|
if (creatingGroup) {
|
||||||
SectionItemView(openPreferences) {
|
SectionItemView(openPreferences) {
|
||||||
Text(stringResource(MR.strings.set_group_preferences))
|
Text(stringResource(R.string.set_group_preferences))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
RoleSelectionRow(groupInfo, selectedRole, allowModifyMembers)
|
RoleSelectionRow(groupInfo, selectedRole, allowModifyMembers)
|
||||||
@@ -174,10 +154,8 @@ fun AddGroupMembersLayout(
|
|||||||
InviteSectionFooter(selectedContactsCount = selectedContacts.size, allowModifyMembers, clearSelection)
|
InviteSectionFooter(selectedContactsCount = selectedContacts.size, allowModifyMembers, clearSelection)
|
||||||
}
|
}
|
||||||
SectionDividerSpaced(maxTopPadding = true)
|
SectionDividerSpaced(maxTopPadding = true)
|
||||||
SectionView(stringResource(MR.strings.select_contacts)) {
|
|
||||||
SectionItemView(padding = PaddingValues(start = DEFAULT_PADDING, end = DEFAULT_PADDING_HALF)) {
|
SectionView(stringResource(R.string.select_contacts)) {
|
||||||
SearchRowView(searchText)
|
|
||||||
}
|
|
||||||
ContactList(contacts = contactsToAdd, selectedContacts, groupInfo, allowModifyMembers, addContact, removeContact)
|
ContactList(contacts = contactsToAdd, selectedContacts, groupInfo, allowModifyMembers, addContact, removeContact)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -185,19 +163,6 @@ fun AddGroupMembersLayout(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun SearchRowView(
|
|
||||||
searchText: MutableState<TextFieldValue> = rememberSaveable(stateSaver = TextFieldValue.Saver) { mutableStateOf(TextFieldValue()) }
|
|
||||||
) {
|
|
||||||
Box(Modifier.width(36.dp), contentAlignment = Alignment.Center) {
|
|
||||||
Icon(painterResource(MR.images.ic_search), stringResource(MR.strings.search_verb), tint = MaterialTheme.colors.secondary)
|
|
||||||
}
|
|
||||||
Spacer(Modifier.width(DEFAULT_SPACE_AFTER_ICON))
|
|
||||||
SearchTextField(Modifier.fillMaxWidth(), searchText = searchText, alwaysVisible = true) {
|
|
||||||
searchText.value = searchText.value.copy(it)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun RoleSelectionRow(groupInfo: GroupInfo, selectedRole: MutableState<GroupMemberRole>, enabled: Boolean) {
|
private fun RoleSelectionRow(groupInfo: GroupInfo, selectedRole: MutableState<GroupMemberRole>, enabled: Boolean) {
|
||||||
Row(
|
Row(
|
||||||
@@ -205,11 +170,9 @@ private fun RoleSelectionRow(groupInfo: GroupInfo, selectedRole: MutableState<Gr
|
|||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
horizontalArrangement = Arrangement.SpaceBetween
|
horizontalArrangement = Arrangement.SpaceBetween
|
||||||
) {
|
) {
|
||||||
val values = GroupMemberRole.values()
|
val values = GroupMemberRole.values().filter { it <= groupInfo.membership.memberRole }.map { it to it.text }
|
||||||
.filter { it <= groupInfo.membership.memberRole && it != GroupMemberRole.Author }
|
|
||||||
.map { it to it.text }
|
|
||||||
ExposedDropDownSettingRow(
|
ExposedDropDownSettingRow(
|
||||||
generalGetString(MR.strings.new_member_role),
|
generalGetString(R.string.new_member_role),
|
||||||
values,
|
values,
|
||||||
selectedRole,
|
selectedRole,
|
||||||
icon = null,
|
icon = null,
|
||||||
@@ -221,8 +184,8 @@ private fun RoleSelectionRow(groupInfo: GroupInfo, selectedRole: MutableState<Gr
|
|||||||
@Composable
|
@Composable
|
||||||
fun InviteMembersButton(onClick: () -> Unit, disabled: Boolean) {
|
fun InviteMembersButton(onClick: () -> Unit, disabled: Boolean) {
|
||||||
SettingsActionItem(
|
SettingsActionItem(
|
||||||
painterResource(MR.images.ic_check),
|
painterResource(R.drawable.ic_check),
|
||||||
stringResource(MR.strings.invite_to_group_button),
|
stringResource(R.string.invite_to_group_button),
|
||||||
click = onClick,
|
click = onClick,
|
||||||
textColor = MaterialTheme.colors.primary,
|
textColor = MaterialTheme.colors.primary,
|
||||||
iconColor = MaterialTheme.colors.primary,
|
iconColor = MaterialTheme.colors.primary,
|
||||||
@@ -233,8 +196,8 @@ fun InviteMembersButton(onClick: () -> Unit, disabled: Boolean) {
|
|||||||
@Composable
|
@Composable
|
||||||
fun SkipInvitingButton(onClick: () -> Unit) {
|
fun SkipInvitingButton(onClick: () -> Unit) {
|
||||||
SettingsActionItem(
|
SettingsActionItem(
|
||||||
painterResource(MR.images.ic_check),
|
painterResource(R.drawable.ic_check),
|
||||||
stringResource(MR.strings.skip_inviting_button),
|
stringResource(R.string.skip_inviting_button),
|
||||||
click = onClick,
|
click = onClick,
|
||||||
textColor = MaterialTheme.colors.primary,
|
textColor = MaterialTheme.colors.primary,
|
||||||
iconColor = MaterialTheme.colors.primary,
|
iconColor = MaterialTheme.colors.primary,
|
||||||
@@ -250,7 +213,7 @@ fun InviteSectionFooter(selectedContactsCount: Int, enabled: Boolean, clearSelec
|
|||||||
) {
|
) {
|
||||||
if (selectedContactsCount >= 1) {
|
if (selectedContactsCount >= 1) {
|
||||||
Text(
|
Text(
|
||||||
String.format(generalGetString(MR.strings.num_contacts_selected), selectedContactsCount),
|
String.format(generalGetString(R.string.num_contacts_selected), selectedContactsCount),
|
||||||
color = MaterialTheme.colors.secondary,
|
color = MaterialTheme.colors.secondary,
|
||||||
fontSize = 12.sp
|
fontSize = 12.sp
|
||||||
)
|
)
|
||||||
@@ -258,14 +221,14 @@ fun InviteSectionFooter(selectedContactsCount: Int, enabled: Boolean, clearSelec
|
|||||||
Modifier.clickable { if (enabled) clearSelection() }
|
Modifier.clickable { if (enabled) clearSelection() }
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
stringResource(MR.strings.clear_contacts_selection_button),
|
stringResource(R.string.clear_contacts_selection_button),
|
||||||
color = if (enabled) MaterialTheme.colors.primary else MaterialTheme.colors.secondary,
|
color = if (enabled) MaterialTheme.colors.primary else MaterialTheme.colors.secondary,
|
||||||
fontSize = 12.sp
|
fontSize = 12.sp
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
Text(
|
Text(
|
||||||
stringResource(MR.strings.no_contacts_selected),
|
stringResource(R.string.no_contacts_selected),
|
||||||
color = MaterialTheme.colors.secondary,
|
color = MaterialTheme.colors.secondary,
|
||||||
fontSize = 12.sp
|
fontSize = 12.sp
|
||||||
)
|
)
|
||||||
@@ -306,13 +269,13 @@ fun ContactCheckRow(
|
|||||||
val icon: Painter
|
val icon: Painter
|
||||||
val iconColor: Color
|
val iconColor: Color
|
||||||
if (prohibitedToInviteIncognito) {
|
if (prohibitedToInviteIncognito) {
|
||||||
icon = painterResource(MR.images.ic_theater_comedy_filled)
|
icon = painterResource(R.drawable.ic_theater_comedy_filled)
|
||||||
iconColor = MaterialTheme.colors.secondary
|
iconColor = MaterialTheme.colors.secondary
|
||||||
} else if (checked) {
|
} else if (checked) {
|
||||||
icon = painterResource(MR.images.ic_check_circle_filled)
|
icon = painterResource(R.drawable.ic_check_circle_filled)
|
||||||
iconColor = if (enabled) MaterialTheme.colors.primary else MaterialTheme.colors.secondary
|
iconColor = if (enabled) MaterialTheme.colors.primary else MaterialTheme.colors.secondary
|
||||||
} else {
|
} else {
|
||||||
icon = painterResource(MR.images.ic_circle)
|
icon = painterResource(R.drawable.ic_circle)
|
||||||
iconColor = MaterialTheme.colors.secondary
|
iconColor = MaterialTheme.colors.secondary
|
||||||
}
|
}
|
||||||
SectionItemView(
|
SectionItemView(
|
||||||
@@ -330,16 +293,13 @@ fun ContactCheckRow(
|
|||||||
ProfileImage(size = 36.dp, contact.image)
|
ProfileImage(size = 36.dp, contact.image)
|
||||||
Spacer(Modifier.width(DEFAULT_SPACE_AFTER_ICON))
|
Spacer(Modifier.width(DEFAULT_SPACE_AFTER_ICON))
|
||||||
Text(
|
Text(
|
||||||
contact.chatViewName,
|
contact.chatViewName, maxLines = 1, overflow = TextOverflow.Ellipsis,
|
||||||
modifier = Modifier.weight(10f, fill = true),
|
|
||||||
maxLines = 1,
|
|
||||||
overflow = TextOverflow.Ellipsis,
|
|
||||||
color = if (prohibitedToInviteIncognito) MaterialTheme.colors.secondary else Color.Unspecified
|
color = if (prohibitedToInviteIncognito) MaterialTheme.colors.secondary else Color.Unspecified
|
||||||
)
|
)
|
||||||
Spacer(Modifier.fillMaxWidth().weight(1f))
|
Spacer(Modifier.fillMaxWidth().weight(1f))
|
||||||
Icon(
|
Icon(
|
||||||
icon,
|
icon,
|
||||||
contentDescription = stringResource(MR.strings.icon_descr_contact_checked),
|
contentDescription = stringResource(R.string.icon_descr_contact_checked),
|
||||||
tint = iconColor
|
tint = iconColor
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -347,9 +307,9 @@ fun ContactCheckRow(
|
|||||||
|
|
||||||
fun showProhibitedToInviteIncognitoAlertDialog() {
|
fun showProhibitedToInviteIncognitoAlertDialog() {
|
||||||
AlertManager.shared.showAlertMsg(
|
AlertManager.shared.showAlertMsg(
|
||||||
title = generalGetString(MR.strings.invite_prohibited),
|
title = generalGetString(R.string.invite_prohibited),
|
||||||
text = generalGetString(MR.strings.invite_prohibited_description),
|
text = generalGetString(R.string.invite_prohibited_description),
|
||||||
confirmText = generalGetString(MR.strings.ok),
|
confirmText = generalGetString(R.string.ok),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -358,13 +318,13 @@ fun showProhibitedToInviteIncognitoAlertDialog() {
|
|||||||
fun PreviewAddGroupMembersLayout() {
|
fun PreviewAddGroupMembersLayout() {
|
||||||
SimpleXTheme {
|
SimpleXTheme {
|
||||||
AddGroupMembersLayout(
|
AddGroupMembersLayout(
|
||||||
|
chatModelIncognito = false,
|
||||||
groupInfo = GroupInfo.sampleData,
|
groupInfo = GroupInfo.sampleData,
|
||||||
creatingGroup = false,
|
creatingGroup = false,
|
||||||
contactsToAdd = listOf(Contact.sampleData, Contact.sampleData, Contact.sampleData),
|
contactsToAdd = listOf(Contact.sampleData, Contact.sampleData, Contact.sampleData),
|
||||||
selectedContacts = remember { mutableStateListOf() },
|
selectedContacts = remember { mutableStateListOf() },
|
||||||
selectedRole = remember { mutableStateOf(GroupMemberRole.Admin) },
|
selectedRole = remember { mutableStateOf(GroupMemberRole.Admin) },
|
||||||
allowModifyMembers = true,
|
allowModifyMembers = true,
|
||||||
searchText = remember { mutableStateOf(TextFieldValue("")) },
|
|
||||||
openPreferences = {},
|
openPreferences = {},
|
||||||
inviteMembers = {},
|
inviteMembers = {},
|
||||||
clearSelection = {},
|
clearSelection = {},
|
||||||
+410
@@ -0,0 +1,410 @@
|
|||||||
|
package chat.simplex.app.views.chat.group
|
||||||
|
|
||||||
|
import InfoRow
|
||||||
|
import SectionBottomSpacer
|
||||||
|
import SectionDividerSpaced
|
||||||
|
import SectionItemView
|
||||||
|
import SectionSpacer
|
||||||
|
import SectionTextFooter
|
||||||
|
import SectionView
|
||||||
|
import android.util.Log
|
||||||
|
import androidx.activity.compose.BackHandler
|
||||||
|
import androidx.compose.foundation.*
|
||||||
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.material.*
|
||||||
|
import androidx.compose.runtime.*
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.res.painterResource
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import chat.simplex.app.R
|
||||||
|
import chat.simplex.app.TAG
|
||||||
|
import chat.simplex.app.model.*
|
||||||
|
import chat.simplex.app.ui.theme.*
|
||||||
|
import chat.simplex.app.views.chat.*
|
||||||
|
import chat.simplex.app.views.chatlist.cantInviteIncognitoAlert
|
||||||
|
import chat.simplex.app.views.chatlist.setGroupMembers
|
||||||
|
import chat.simplex.app.views.helpers.*
|
||||||
|
import chat.simplex.app.views.usersettings.*
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun GroupChatInfoView(chatModel: ChatModel, groupLink: String?, groupLinkMemberRole: GroupMemberRole?, onGroupLinkUpdated: (Pair<String?, GroupMemberRole?>) -> Unit, close: () -> Unit) {
|
||||||
|
BackHandler(onBack = close)
|
||||||
|
val chat = chatModel.chats.firstOrNull { it.id == chatModel.chatId.value }
|
||||||
|
val developerTools = chatModel.controller.appPrefs.developerTools.get()
|
||||||
|
if (chat != null && chat.chatInfo is ChatInfo.Group) {
|
||||||
|
val groupInfo = chat.chatInfo.groupInfo
|
||||||
|
GroupChatInfoLayout(
|
||||||
|
chat,
|
||||||
|
groupInfo,
|
||||||
|
members = chatModel.groupMembers
|
||||||
|
.filter { it.memberStatus != GroupMemberStatus.MemLeft && it.memberStatus != GroupMemberStatus.MemRemoved }
|
||||||
|
.sortedBy { it.displayName.lowercase() },
|
||||||
|
developerTools,
|
||||||
|
groupLink,
|
||||||
|
addMembers = {
|
||||||
|
withApi {
|
||||||
|
setGroupMembers(groupInfo, chatModel)
|
||||||
|
ModalManager.shared.showModalCloseable(true) { close ->
|
||||||
|
AddGroupMembersView(groupInfo, false, chatModel, close)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
showMemberInfo = { member ->
|
||||||
|
withApi {
|
||||||
|
val stats = chatModel.controller.apiGroupMemberInfo(groupInfo.groupId, member.groupMemberId)
|
||||||
|
val (_, code) = if (member.memberActive) {
|
||||||
|
try {
|
||||||
|
chatModel.controller.apiGetGroupMemberCode(groupInfo.apiId, member.groupMemberId)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, e.stackTraceToString())
|
||||||
|
member to null
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
member to null
|
||||||
|
}
|
||||||
|
ModalManager.shared.showModalCloseable(true) { closeCurrent ->
|
||||||
|
remember { derivedStateOf { chatModel.groupMembers.firstOrNull { it.memberId == member.memberId } } }.value?.let { mem ->
|
||||||
|
GroupMemberInfoView(groupInfo, mem, stats, code, chatModel, closeCurrent) {
|
||||||
|
closeCurrent()
|
||||||
|
close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
editGroupProfile = {
|
||||||
|
ModalManager.shared.showCustomModal { close -> GroupProfileView(groupInfo, chatModel, close) }
|
||||||
|
},
|
||||||
|
addOrEditWelcomeMessage = {
|
||||||
|
ModalManager.shared.showCustomModal { close -> GroupWelcomeView(chatModel, groupInfo, close) }
|
||||||
|
},
|
||||||
|
openPreferences = {
|
||||||
|
ModalManager.shared.showCustomModal { close ->
|
||||||
|
GroupPreferencesView(
|
||||||
|
chatModel,
|
||||||
|
chat.id,
|
||||||
|
close
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
deleteGroup = { deleteGroupDialog(chat.chatInfo, groupInfo, chatModel, close) },
|
||||||
|
clearChat = { clearChatDialog(chat.chatInfo, chatModel, close) },
|
||||||
|
leaveGroup = { leaveGroupDialog(groupInfo, chatModel, close) },
|
||||||
|
manageGroupLink = {
|
||||||
|
ModalManager.shared.showModal { GroupLinkView(chatModel, groupInfo, groupLink, groupLinkMemberRole, onGroupLinkUpdated) }
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun deleteGroupDialog(chatInfo: ChatInfo, groupInfo: GroupInfo, chatModel: ChatModel, close: (() -> Unit)? = null) {
|
||||||
|
val alertTextKey =
|
||||||
|
if (groupInfo.membership.memberCurrent) R.string.delete_group_for_all_members_cannot_undo_warning
|
||||||
|
else R.string.delete_group_for_self_cannot_undo_warning
|
||||||
|
AlertManager.shared.showAlertDialog(
|
||||||
|
title = generalGetString(R.string.delete_group_question),
|
||||||
|
text = generalGetString(alertTextKey),
|
||||||
|
confirmText = generalGetString(R.string.delete_verb),
|
||||||
|
onConfirm = {
|
||||||
|
withApi {
|
||||||
|
val r = chatModel.controller.apiDeleteChat(chatInfo.chatType, chatInfo.apiId)
|
||||||
|
if (r) {
|
||||||
|
chatModel.removeChat(chatInfo.id)
|
||||||
|
chatModel.chatId.value = null
|
||||||
|
chatModel.controller.ntfManager.cancelNotificationsForChat(chatInfo.id)
|
||||||
|
close?.invoke()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
destructive = true,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun leaveGroupDialog(groupInfo: GroupInfo, chatModel: ChatModel, close: (() -> Unit)? = null) {
|
||||||
|
AlertManager.shared.showAlertDialog(
|
||||||
|
title = generalGetString(R.string.leave_group_question),
|
||||||
|
text = generalGetString(R.string.you_will_stop_receiving_messages_from_this_group_chat_history_will_be_preserved),
|
||||||
|
confirmText = generalGetString(R.string.leave_group_button),
|
||||||
|
onConfirm = {
|
||||||
|
withApi {
|
||||||
|
chatModel.controller.leaveGroup(groupInfo.groupId)
|
||||||
|
close?.invoke()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
destructive = true,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun GroupChatInfoLayout(
|
||||||
|
chat: Chat,
|
||||||
|
groupInfo: GroupInfo,
|
||||||
|
members: List<GroupMember>,
|
||||||
|
developerTools: Boolean,
|
||||||
|
groupLink: String?,
|
||||||
|
addMembers: () -> Unit,
|
||||||
|
showMemberInfo: (GroupMember) -> Unit,
|
||||||
|
editGroupProfile: () -> Unit,
|
||||||
|
addOrEditWelcomeMessage: () -> Unit,
|
||||||
|
openPreferences: () -> Unit,
|
||||||
|
deleteGroup: () -> Unit,
|
||||||
|
clearChat: () -> Unit,
|
||||||
|
leaveGroup: () -> Unit,
|
||||||
|
manageGroupLink: () -> Unit,
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.verticalScroll(rememberScrollState())
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.Center
|
||||||
|
) {
|
||||||
|
GroupChatInfoHeader(chat.chatInfo)
|
||||||
|
}
|
||||||
|
SectionSpacer()
|
||||||
|
|
||||||
|
SectionView {
|
||||||
|
if (groupInfo.canEdit) {
|
||||||
|
EditGroupProfileButton(editGroupProfile)
|
||||||
|
AddOrEditWelcomeMessage(groupInfo.groupProfile.description, addOrEditWelcomeMessage)
|
||||||
|
}
|
||||||
|
GroupPreferencesButton(openPreferences)
|
||||||
|
}
|
||||||
|
SectionTextFooter(stringResource(R.string.only_group_owners_can_change_prefs))
|
||||||
|
SectionDividerSpaced(maxTopPadding = true)
|
||||||
|
|
||||||
|
SectionView(title = String.format(generalGetString(R.string.group_info_section_title_num_members), members.count() + 1)) {
|
||||||
|
if (groupInfo.canAddMembers) {
|
||||||
|
if (groupLink == null) {
|
||||||
|
CreateGroupLinkButton(manageGroupLink)
|
||||||
|
} else {
|
||||||
|
GroupLinkButton(manageGroupLink)
|
||||||
|
}
|
||||||
|
|
||||||
|
val onAddMembersClick = if (chat.chatInfo.incognito) ::cantInviteIncognitoAlert else addMembers
|
||||||
|
val tint = if (chat.chatInfo.incognito) MaterialTheme.colors.secondary else MaterialTheme.colors.primary
|
||||||
|
AddMembersButton(tint, onAddMembersClick)
|
||||||
|
}
|
||||||
|
SectionItemView(minHeight = 54.dp) {
|
||||||
|
MemberRow(groupInfo.membership, user = true)
|
||||||
|
}
|
||||||
|
MembersList(members, showMemberInfo)
|
||||||
|
}
|
||||||
|
SectionDividerSpaced(maxTopPadding = true, maxBottomPadding = false)
|
||||||
|
SectionView {
|
||||||
|
ClearChatButton(clearChat)
|
||||||
|
if (groupInfo.canDelete) {
|
||||||
|
DeleteGroupButton(deleteGroup)
|
||||||
|
}
|
||||||
|
if (groupInfo.membership.memberCurrent) {
|
||||||
|
LeaveGroupButton(leaveGroup)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (developerTools) {
|
||||||
|
SectionDividerSpaced()
|
||||||
|
SectionView(title = stringResource(R.string.section_title_for_console)) {
|
||||||
|
InfoRow(stringResource(R.string.info_row_local_name), groupInfo.localDisplayName)
|
||||||
|
InfoRow(stringResource(R.string.info_row_database_id), groupInfo.apiId.toString())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
SectionBottomSpacer()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun GroupChatInfoHeader(cInfo: ChatInfo) {
|
||||||
|
Column(
|
||||||
|
Modifier.padding(horizontal = 8.dp),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally
|
||||||
|
) {
|
||||||
|
ChatInfoImage(cInfo, size = 192.dp, iconColor = if (isInDarkTheme()) GroupDark else SettingsSecondaryLight)
|
||||||
|
Text(
|
||||||
|
cInfo.displayName, style = MaterialTheme.typography.h1.copy(fontWeight = FontWeight.Normal),
|
||||||
|
color = MaterialTheme.colors.onBackground,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis
|
||||||
|
)
|
||||||
|
if (cInfo.fullName != "" && cInfo.fullName != cInfo.displayName) {
|
||||||
|
Text(
|
||||||
|
cInfo.fullName, style = MaterialTheme.typography.h2,
|
||||||
|
color = MaterialTheme.colors.onBackground,
|
||||||
|
maxLines = 2,
|
||||||
|
overflow = TextOverflow.Ellipsis
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun GroupPreferencesButton(onClick: () -> Unit) {
|
||||||
|
SettingsActionItem(
|
||||||
|
painterResource(R.drawable.ic_toggle_on),
|
||||||
|
stringResource(R.string.group_preferences),
|
||||||
|
click = onClick
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun AddMembersButton(tint: Color = MaterialTheme.colors.primary, onClick: () -> Unit) {
|
||||||
|
SettingsActionItem(
|
||||||
|
painterResource(R.drawable.ic_add),
|
||||||
|
stringResource(R.string.button_add_members),
|
||||||
|
onClick,
|
||||||
|
iconColor = tint,
|
||||||
|
textColor = tint
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun MembersList(members: List<GroupMember>, showMemberInfo: (GroupMember) -> Unit) {
|
||||||
|
Column {
|
||||||
|
members.forEachIndexed { index, member ->
|
||||||
|
Divider()
|
||||||
|
SectionItemView({ showMemberInfo(member) }, minHeight = 54.dp) {
|
||||||
|
MemberRow(member)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun MemberRow(member: GroupMember, user: Boolean = false) {
|
||||||
|
Row(
|
||||||
|
Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
Modifier.weight(1f).padding(end = DEFAULT_PADDING),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(4.dp)
|
||||||
|
) {
|
||||||
|
ProfileImage(size = 46.dp, member.image)
|
||||||
|
Spacer(Modifier.width(DEFAULT_PADDING_HALF))
|
||||||
|
Column {
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
if (member.verified) {
|
||||||
|
MemberVerifiedShield()
|
||||||
|
}
|
||||||
|
Text(
|
||||||
|
member.chatViewName, maxLines = 1, overflow = TextOverflow.Ellipsis,
|
||||||
|
color = if (member.memberIncognito) Indigo else Color.Unspecified
|
||||||
|
)
|
||||||
|
}
|
||||||
|
val s = member.memberStatus.shortText
|
||||||
|
val statusDescr = if (user) String.format(generalGetString(R.string.group_info_member_you), s) else s
|
||||||
|
Text(
|
||||||
|
statusDescr,
|
||||||
|
color = MaterialTheme.colors.secondary,
|
||||||
|
fontSize = 12.sp,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val role = member.memberRole
|
||||||
|
if (role == GroupMemberRole.Owner || role == GroupMemberRole.Admin) {
|
||||||
|
Text(role.text, color = MaterialTheme.colors.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun MemberVerifiedShield() {
|
||||||
|
Icon(painterResource(R.drawable.ic_verified_user), null, Modifier.padding(end = 3.dp).size(16.dp), tint = MaterialTheme.colors.secondary)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun GroupLinkButton(onClick: () -> Unit) {
|
||||||
|
SettingsActionItem(
|
||||||
|
painterResource(R.drawable.ic_link),
|
||||||
|
stringResource(R.string.group_link),
|
||||||
|
onClick,
|
||||||
|
iconColor = MaterialTheme.colors.secondary
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun CreateGroupLinkButton(onClick: () -> Unit) {
|
||||||
|
SettingsActionItem(
|
||||||
|
painterResource(R.drawable.ic_add_link),
|
||||||
|
stringResource(R.string.create_group_link),
|
||||||
|
onClick,
|
||||||
|
iconColor = MaterialTheme.colors.secondary
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun EditGroupProfileButton(onClick: () -> Unit) {
|
||||||
|
SettingsActionItem(
|
||||||
|
painterResource(R.drawable.ic_edit),
|
||||||
|
stringResource(R.string.button_edit_group_profile),
|
||||||
|
onClick,
|
||||||
|
iconColor = MaterialTheme.colors.secondary
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun AddOrEditWelcomeMessage(welcomeMessage: String?, onClick: () -> Unit) {
|
||||||
|
val text = if (welcomeMessage == null) {
|
||||||
|
stringResource(R.string.button_add_welcome_message)
|
||||||
|
} else {
|
||||||
|
stringResource(R.string.button_welcome_message)
|
||||||
|
}
|
||||||
|
SettingsActionItem(
|
||||||
|
painterResource(R.drawable.ic_maps_ugc),
|
||||||
|
text,
|
||||||
|
onClick,
|
||||||
|
iconColor = MaterialTheme.colors.secondary
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun LeaveGroupButton(onClick: () -> Unit) {
|
||||||
|
SettingsActionItem(
|
||||||
|
painterResource(R.drawable.ic_logout),
|
||||||
|
stringResource(R.string.button_leave_group),
|
||||||
|
onClick,
|
||||||
|
iconColor = Color.Red,
|
||||||
|
textColor = Color.Red
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun DeleteGroupButton(onClick: () -> Unit) {
|
||||||
|
SettingsActionItem(
|
||||||
|
painterResource(R.drawable.ic_delete),
|
||||||
|
stringResource(R.string.button_delete_group),
|
||||||
|
onClick,
|
||||||
|
iconColor = Color.Red,
|
||||||
|
textColor = Color.Red
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Preview
|
||||||
|
@Composable
|
||||||
|
fun PreviewGroupChatInfoLayout() {
|
||||||
|
SimpleXTheme {
|
||||||
|
GroupChatInfoLayout(
|
||||||
|
chat = Chat(
|
||||||
|
chatInfo = ChatInfo.Direct.sampleData,
|
||||||
|
chatItems = arrayListOf()
|
||||||
|
),
|
||||||
|
groupInfo = GroupInfo.sampleData,
|
||||||
|
members = listOf(GroupMember.sampleData, GroupMember.sampleData, GroupMember.sampleData),
|
||||||
|
developerTools = false,
|
||||||
|
groupLink = null,
|
||||||
|
addMembers = {}, showMemberInfo = {}, editGroupProfile = {}, addOrEditWelcomeMessage = {}, openPreferences = {}, deleteGroup = {}, clearChat = {}, leaveGroup = {}, manageGroupLink = {},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
+34
-68
@@ -1,4 +1,4 @@
|
|||||||
package chat.simplex.common.views.chat.group
|
package chat.simplex.app.views.chat.group
|
||||||
|
|
||||||
import SectionBottomSpacer
|
import SectionBottomSpacer
|
||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.*
|
||||||
@@ -10,40 +10,31 @@ import androidx.compose.runtime.saveable.rememberSaveable
|
|||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.platform.LocalClipboardManager
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import dev.icerock.moko.resources.compose.painterResource
|
import androidx.compose.ui.res.painterResource
|
||||||
import dev.icerock.moko.resources.compose.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
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 chat.simplex.common.model.*
|
import chat.simplex.app.R
|
||||||
import chat.simplex.common.platform.shareText
|
import chat.simplex.app.model.*
|
||||||
import chat.simplex.common.ui.theme.*
|
import chat.simplex.app.ui.theme.*
|
||||||
import chat.simplex.common.views.helpers.*
|
import chat.simplex.app.views.helpers.*
|
||||||
import chat.simplex.common.views.newchat.*
|
import chat.simplex.app.views.newchat.QRCode
|
||||||
import chat.simplex.res.MR
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun GroupLinkView(
|
fun GroupLinkView(chatModel: ChatModel, groupInfo: GroupInfo, connReqContact: String?, memberRole: GroupMemberRole?, onGroupLinkUpdated: (Pair<String?, GroupMemberRole?>) -> Unit) {
|
||||||
chatModel: ChatModel,
|
|
||||||
rhId: Long?,
|
|
||||||
groupInfo: GroupInfo,
|
|
||||||
connReqContact: String?,
|
|
||||||
memberRole: GroupMemberRole?,
|
|
||||||
onGroupLinkUpdated: ((Pair<String, GroupMemberRole>?) -> Unit)?,
|
|
||||||
creatingGroup: Boolean = false,
|
|
||||||
close: (() -> Unit)? = null
|
|
||||||
) {
|
|
||||||
var groupLink by rememberSaveable { mutableStateOf(connReqContact) }
|
var groupLink by rememberSaveable { mutableStateOf(connReqContact) }
|
||||||
val groupLinkMemberRole = rememberSaveable { mutableStateOf(memberRole) }
|
val groupLinkMemberRole = rememberSaveable { mutableStateOf(memberRole) }
|
||||||
var creatingLink by rememberSaveable { mutableStateOf(false) }
|
var creatingLink by rememberSaveable { mutableStateOf(false) }
|
||||||
|
val cxt = LocalContext.current
|
||||||
fun createLink() {
|
fun createLink() {
|
||||||
creatingLink = true
|
creatingLink = true
|
||||||
withApi {
|
withApi {
|
||||||
val link = chatModel.controller.apiCreateGroupLink(rhId, groupInfo.groupId)
|
val link = chatModel.controller.apiCreateGroupLink(groupInfo.groupId)
|
||||||
if (link != null) {
|
if (link != null) {
|
||||||
groupLink = link.first
|
groupLink = link.first
|
||||||
groupLinkMemberRole.value = link.second
|
groupLinkMemberRole.value = link.second
|
||||||
onGroupLinkUpdated?.invoke(link)
|
onGroupLinkUpdated(groupLink to groupLinkMemberRole.value)
|
||||||
}
|
}
|
||||||
creatingLink = false
|
creatingLink = false
|
||||||
}
|
}
|
||||||
@@ -59,38 +50,37 @@ fun GroupLinkView(
|
|||||||
groupLinkMemberRole,
|
groupLinkMemberRole,
|
||||||
creatingLink,
|
creatingLink,
|
||||||
createLink = ::createLink,
|
createLink = ::createLink,
|
||||||
|
share = { shareText(cxt, groupLink ?: return@GroupLinkLayout) },
|
||||||
updateLink = {
|
updateLink = {
|
||||||
val role = groupLinkMemberRole.value
|
val role = groupLinkMemberRole.value
|
||||||
if (role != null) {
|
if (role != null) {
|
||||||
withBGApi {
|
withBGApi {
|
||||||
val link = chatModel.controller.apiGroupLinkMemberRole(rhId, groupInfo.groupId, role)
|
val link = chatModel.controller.apiGroupLinkMemberRole(groupInfo.groupId, role)
|
||||||
if (link != null) {
|
if (link != null) {
|
||||||
groupLink = link.first
|
groupLink = link.first
|
||||||
groupLinkMemberRole.value = link.second
|
groupLinkMemberRole.value = link.second
|
||||||
onGroupLinkUpdated?.invoke(link)
|
onGroupLinkUpdated(groupLink to groupLinkMemberRole.value)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
deleteLink = {
|
deleteLink = {
|
||||||
AlertManager.shared.showAlertDialog(
|
AlertManager.shared.showAlertDialog(
|
||||||
title = generalGetString(MR.strings.delete_link_question),
|
title = generalGetString(R.string.delete_link_question),
|
||||||
text = generalGetString(MR.strings.all_group_members_will_remain_connected),
|
text = generalGetString(R.string.all_group_members_will_remain_connected),
|
||||||
confirmText = generalGetString(MR.strings.delete_verb),
|
confirmText = generalGetString(R.string.delete_verb),
|
||||||
onConfirm = {
|
onConfirm = {
|
||||||
withApi {
|
withApi {
|
||||||
val r = chatModel.controller.apiDeleteGroupLink(rhId, groupInfo.groupId)
|
val r = chatModel.controller.apiDeleteGroupLink(groupInfo.groupId)
|
||||||
if (r) {
|
if (r) {
|
||||||
groupLink = null
|
groupLink = null
|
||||||
onGroupLinkUpdated?.invoke(null)
|
onGroupLinkUpdated(null to null)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
destructive = true,
|
destructive = true,
|
||||||
)
|
)
|
||||||
},
|
}
|
||||||
creatingGroup = creatingGroup,
|
|
||||||
close = close
|
|
||||||
)
|
)
|
||||||
if (creatingLink) {
|
if (creatingLink) {
|
||||||
ProgressIndicator()
|
ProgressIndicator()
|
||||||
@@ -104,27 +94,17 @@ fun GroupLinkLayout(
|
|||||||
groupLinkMemberRole: MutableState<GroupMemberRole?>,
|
groupLinkMemberRole: MutableState<GroupMemberRole?>,
|
||||||
creatingLink: Boolean,
|
creatingLink: Boolean,
|
||||||
createLink: () -> Unit,
|
createLink: () -> Unit,
|
||||||
|
share: () -> Unit,
|
||||||
updateLink: () -> Unit,
|
updateLink: () -> Unit,
|
||||||
deleteLink: () -> Unit,
|
deleteLink: () -> Unit
|
||||||
creatingGroup: Boolean = false,
|
|
||||||
close: (() -> Unit)? = null
|
|
||||||
) {
|
) {
|
||||||
@Composable
|
|
||||||
fun ContinueButton(close: () -> Unit) {
|
|
||||||
SimpleButton(
|
|
||||||
stringResource(MR.strings.continue_to_next_step),
|
|
||||||
icon = painterResource(MR.images.ic_check),
|
|
||||||
click = close
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
Column(
|
Column(
|
||||||
Modifier
|
Modifier
|
||||||
.verticalScroll(rememberScrollState()),
|
.verticalScroll(rememberScrollState()),
|
||||||
) {
|
) {
|
||||||
AppBarTitle(stringResource(MR.strings.group_link))
|
AppBarTitle(stringResource(R.string.group_link))
|
||||||
Text(
|
Text(
|
||||||
stringResource(MR.strings.you_can_share_group_link_anybody_will_be_able_to_connect),
|
stringResource(R.string.you_can_share_group_link_anybody_will_be_able_to_connect),
|
||||||
Modifier.padding(start = DEFAULT_PADDING, end = DEFAULT_PADDING, bottom = 12.dp),
|
Modifier.padding(start = DEFAULT_PADDING, end = DEFAULT_PADDING, bottom = 12.dp),
|
||||||
lineHeight = 22.sp
|
lineHeight = 22.sp
|
||||||
)
|
)
|
||||||
@@ -134,16 +114,7 @@ fun GroupLinkLayout(
|
|||||||
verticalArrangement = Arrangement.SpaceEvenly
|
verticalArrangement = Arrangement.SpaceEvenly
|
||||||
) {
|
) {
|
||||||
if (groupLink == null) {
|
if (groupLink == null) {
|
||||||
Row(
|
SimpleButton(stringResource(R.string.button_create_group_link), icon = painterResource(R.drawable.ic_add_link), disabled = creatingLink, click = createLink)
|
||||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
modifier = Modifier.padding(horizontal = DEFAULT_PADDING, vertical = 10.dp)
|
|
||||||
) {
|
|
||||||
SimpleButton(stringResource(MR.strings.button_create_group_link), icon = painterResource(MR.images.ic_add_link), disabled = creatingLink, click = createLink)
|
|
||||||
if (creatingGroup && close != null) {
|
|
||||||
ContinueButton(close)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
RoleSelectionRow(groupInfo, groupLinkMemberRole)
|
RoleSelectionRow(groupInfo, groupLinkMemberRole)
|
||||||
var initialLaunch by remember { mutableStateOf(true) }
|
var initialLaunch by remember { mutableStateOf(true) }
|
||||||
@@ -153,31 +124,26 @@ fun GroupLinkLayout(
|
|||||||
}
|
}
|
||||||
initialLaunch = false
|
initialLaunch = false
|
||||||
}
|
}
|
||||||
SimpleXLinkQRCode(groupLink, Modifier.aspectRatio(1f).padding(horizontal = DEFAULT_PADDING))
|
QRCode(groupLink, Modifier.aspectRatio(1f).padding(horizontal = DEFAULT_PADDING))
|
||||||
Row(
|
Row(
|
||||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
modifier = Modifier.padding(horizontal = DEFAULT_PADDING, vertical = 10.dp)
|
modifier = Modifier.padding(horizontal = DEFAULT_PADDING, vertical = 10.dp)
|
||||||
) {
|
) {
|
||||||
val clipboard = LocalClipboardManager.current
|
|
||||||
SimpleButton(
|
SimpleButton(
|
||||||
stringResource(MR.strings.share_link),
|
stringResource(R.string.share_link),
|
||||||
icon = painterResource(MR.images.ic_share),
|
icon = painterResource(R.drawable.ic_share),
|
||||||
click = { clipboard.shareText(simplexChatLink(groupLink)) }
|
click = share
|
||||||
)
|
)
|
||||||
if (creatingGroup && close != null) {
|
|
||||||
ContinueButton(close)
|
|
||||||
} else {
|
|
||||||
SimpleButton(
|
SimpleButton(
|
||||||
stringResource(MR.strings.delete_link),
|
stringResource(R.string.delete_link),
|
||||||
icon = painterResource(MR.images.ic_delete),
|
icon = painterResource(R.drawable.ic_delete),
|
||||||
color = Color.Red,
|
color = Color.Red,
|
||||||
click = deleteLink
|
click = deleteLink
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
SectionBottomSpacer()
|
SectionBottomSpacer()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -191,7 +157,7 @@ private fun RoleSelectionRow(groupInfo: GroupInfo, selectedRole: MutableState<Gr
|
|||||||
) {
|
) {
|
||||||
val values = listOf(GroupMemberRole.Member, GroupMemberRole.Observer).map { it to it.text }
|
val values = listOf(GroupMemberRole.Member, GroupMemberRole.Observer).map { it to it.text }
|
||||||
ExposedDropDownSettingRow(
|
ExposedDropDownSettingRow(
|
||||||
generalGetString(MR.strings.initial_member_role),
|
generalGetString(R.string.initial_member_role),
|
||||||
values,
|
values,
|
||||||
selectedRole,
|
selectedRole,
|
||||||
icon = null,
|
icon = null,
|
||||||
+349
@@ -0,0 +1,349 @@
|
|||||||
|
package chat.simplex.app.views.chat.group
|
||||||
|
|
||||||
|
import InfoRow
|
||||||
|
import SectionBottomSpacer
|
||||||
|
import SectionDividerSpaced
|
||||||
|
import SectionSpacer
|
||||||
|
import SectionView
|
||||||
|
import androidx.activity.compose.BackHandler
|
||||||
|
import androidx.compose.foundation.*
|
||||||
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.material.*
|
||||||
|
import androidx.compose.runtime.*
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.res.painterResource
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import chat.simplex.app.R
|
||||||
|
import chat.simplex.app.model.*
|
||||||
|
import chat.simplex.app.ui.theme.*
|
||||||
|
import chat.simplex.app.views.chat.*
|
||||||
|
import chat.simplex.app.views.helpers.*
|
||||||
|
import chat.simplex.app.views.usersettings.SettingsActionItem
|
||||||
|
import kotlinx.datetime.Clock
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun GroupMemberInfoView(
|
||||||
|
groupInfo: GroupInfo,
|
||||||
|
member: GroupMember,
|
||||||
|
connStats: ConnectionStats?,
|
||||||
|
connectionCode: String?,
|
||||||
|
chatModel: ChatModel,
|
||||||
|
close: () -> Unit,
|
||||||
|
closeAll: () -> Unit, // Close all open windows up to ChatView
|
||||||
|
) {
|
||||||
|
BackHandler(onBack = close)
|
||||||
|
val chat = chatModel.chats.firstOrNull { it.id == chatModel.chatId.value }
|
||||||
|
val developerTools = chatModel.controller.appPrefs.developerTools.get()
|
||||||
|
if (chat != null) {
|
||||||
|
val newRole = remember { mutableStateOf(member.memberRole) }
|
||||||
|
GroupMemberInfoLayout(
|
||||||
|
groupInfo,
|
||||||
|
member,
|
||||||
|
connStats,
|
||||||
|
newRole,
|
||||||
|
developerTools,
|
||||||
|
connectionCode,
|
||||||
|
getContactChat = { chatModel.getContactChat(it) },
|
||||||
|
openDirectChat = {
|
||||||
|
withApi {
|
||||||
|
val c = chatModel.controller.apiGetChat(ChatType.Direct, it)
|
||||||
|
if (c != null) {
|
||||||
|
if (chatModel.getContactChat(it) == null) {
|
||||||
|
chatModel.addChat(c)
|
||||||
|
}
|
||||||
|
chatModel.chatItems.clear()
|
||||||
|
chatModel.chatItems.addAll(c.chatItems)
|
||||||
|
chatModel.chatId.value = c.id
|
||||||
|
closeAll()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
removeMember = { removeMemberDialog(groupInfo, member, chatModel, close) },
|
||||||
|
onRoleSelected = {
|
||||||
|
if (it == newRole.value) return@GroupMemberInfoLayout
|
||||||
|
val prevValue = newRole.value
|
||||||
|
newRole.value = it
|
||||||
|
updateMemberRoleDialog(it, member, onDismiss = {
|
||||||
|
newRole.value = prevValue
|
||||||
|
}) {
|
||||||
|
withApi {
|
||||||
|
kotlin.runCatching {
|
||||||
|
val mem = chatModel.controller.apiMemberRole(groupInfo.groupId, member.groupMemberId, it)
|
||||||
|
chatModel.upsertGroupMember(groupInfo, mem)
|
||||||
|
}.onFailure {
|
||||||
|
newRole.value = prevValue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
switchMemberAddress = {
|
||||||
|
switchMemberAddress(chatModel, groupInfo, member)
|
||||||
|
},
|
||||||
|
verifyClicked = {
|
||||||
|
ModalManager.shared.showModalCloseable { close ->
|
||||||
|
remember { derivedStateOf { chatModel.groupMembers.firstOrNull { it.memberId == member.memberId } } }.value?.let { mem ->
|
||||||
|
VerifyCodeView(
|
||||||
|
mem.displayName,
|
||||||
|
connectionCode,
|
||||||
|
mem.verified,
|
||||||
|
verify = { code ->
|
||||||
|
chatModel.controller.apiVerifyGroupMember(mem.groupId, mem.groupMemberId, code)?.let { r ->
|
||||||
|
val (verified, existingCode) = r
|
||||||
|
chatModel.upsertGroupMember(
|
||||||
|
groupInfo,
|
||||||
|
mem.copy(
|
||||||
|
activeConn = mem.activeConn?.copy(
|
||||||
|
connectionCode = if (verified) SecurityCode(existingCode, Clock.System.now()) else null
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
r
|
||||||
|
}
|
||||||
|
},
|
||||||
|
close,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun removeMemberDialog(groupInfo: GroupInfo, member: GroupMember, chatModel: ChatModel, close: (() -> Unit)? = null) {
|
||||||
|
AlertManager.shared.showAlertDialog(
|
||||||
|
title = generalGetString(R.string.button_remove_member),
|
||||||
|
text = generalGetString(R.string.member_will_be_removed_from_group_cannot_be_undone),
|
||||||
|
confirmText = generalGetString(R.string.remove_member_confirmation),
|
||||||
|
onConfirm = {
|
||||||
|
withApi {
|
||||||
|
val removedMember = chatModel.controller.apiRemoveMember(member.groupId, member.groupMemberId)
|
||||||
|
if (removedMember != null) {
|
||||||
|
chatModel.upsertGroupMember(groupInfo, removedMember)
|
||||||
|
}
|
||||||
|
close?.invoke()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
destructive = true,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun GroupMemberInfoLayout(
|
||||||
|
groupInfo: GroupInfo,
|
||||||
|
member: GroupMember,
|
||||||
|
connStats: ConnectionStats?,
|
||||||
|
newRole: MutableState<GroupMemberRole>,
|
||||||
|
developerTools: Boolean,
|
||||||
|
connectionCode: String?,
|
||||||
|
getContactChat: (Long) -> Chat?,
|
||||||
|
openDirectChat: (Long) -> Unit,
|
||||||
|
removeMember: () -> Unit,
|
||||||
|
onRoleSelected: (GroupMemberRole) -> Unit,
|
||||||
|
switchMemberAddress: () -> Unit,
|
||||||
|
verifyClicked: () -> Unit,
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.verticalScroll(rememberScrollState()),
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.Center
|
||||||
|
) {
|
||||||
|
GroupMemberInfoHeader(member)
|
||||||
|
}
|
||||||
|
SectionSpacer()
|
||||||
|
|
||||||
|
if (member.memberActive) {
|
||||||
|
val contactId = member.memberContactId
|
||||||
|
if (contactId != null) {
|
||||||
|
SectionView {
|
||||||
|
val chat = getContactChat(contactId)
|
||||||
|
if ((chat != null && chat.chatInfo is ChatInfo.Direct && chat.chatInfo.contact.directOrUsed) || groupInfo.fullGroupPreferences.directMessages.on) {
|
||||||
|
OpenChatButton(onClick = { openDirectChat(contactId) })
|
||||||
|
}
|
||||||
|
if (connectionCode != null) {
|
||||||
|
VerifyCodeButton(member.verified, verifyClicked)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
SectionSpacer()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
SectionView(title = stringResource(R.string.member_info_section_title_member)) {
|
||||||
|
InfoRow(stringResource(R.string.info_row_group), groupInfo.displayName)
|
||||||
|
val roles = remember { member.canChangeRoleTo(groupInfo) }
|
||||||
|
if (roles != null) {
|
||||||
|
RoleSelectionRow(roles, newRole, onRoleSelected)
|
||||||
|
} else {
|
||||||
|
InfoRow(stringResource(R.string.role_in_group), member.memberRole.text)
|
||||||
|
}
|
||||||
|
val conn = member.activeConn
|
||||||
|
if (conn != null) {
|
||||||
|
val connLevelDesc =
|
||||||
|
if (conn.connLevel == 0) stringResource(R.string.conn_level_desc_direct)
|
||||||
|
else String.format(generalGetString(R.string.conn_level_desc_indirect), conn.connLevel)
|
||||||
|
InfoRow(stringResource(R.string.info_row_connection), connLevelDesc)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (connStats != null) {
|
||||||
|
SectionDividerSpaced()
|
||||||
|
SectionView(title = stringResource(R.string.conn_stats_section_title_servers)) {
|
||||||
|
SwitchAddressButton(switchMemberAddress)
|
||||||
|
val rcvServers = connStats.rcvServers
|
||||||
|
val sndServers = connStats.sndServers
|
||||||
|
if ((rcvServers != null && rcvServers.isNotEmpty()) || (sndServers != null && sndServers.isNotEmpty())) {
|
||||||
|
if (rcvServers != null && rcvServers.isNotEmpty()) {
|
||||||
|
SimplexServers(stringResource(R.string.receiving_via), rcvServers)
|
||||||
|
if (sndServers != null && sndServers.isNotEmpty()) {
|
||||||
|
SimplexServers(stringResource(R.string.sending_via), sndServers)
|
||||||
|
}
|
||||||
|
} else if (sndServers != null && sndServers.isNotEmpty()) {
|
||||||
|
SimplexServers(stringResource(R.string.sending_via), sndServers)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (member.canBeRemoved(groupInfo)) {
|
||||||
|
SectionDividerSpaced(maxBottomPadding = false)
|
||||||
|
SectionView {
|
||||||
|
RemoveMemberButton(removeMember)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (developerTools) {
|
||||||
|
SectionDividerSpaced()
|
||||||
|
SectionView(title = stringResource(R.string.section_title_for_console)) {
|
||||||
|
InfoRow(stringResource(R.string.info_row_local_name), member.localDisplayName)
|
||||||
|
InfoRow(stringResource(R.string.info_row_database_id), member.groupMemberId.toString())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
SectionBottomSpacer()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun GroupMemberInfoHeader(member: GroupMember) {
|
||||||
|
Column(
|
||||||
|
Modifier.padding(horizontal = 8.dp),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally
|
||||||
|
) {
|
||||||
|
ProfileImage(size = 192.dp, member.image, color = if (isInDarkTheme()) GroupDark else SettingsSecondaryLight)
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
if (member.verified) {
|
||||||
|
Icon(painterResource(R.drawable.ic_verified_user), null, Modifier.padding(end = 6.dp, top = 4.dp).size(24.dp), tint = MaterialTheme.colors.secondary)
|
||||||
|
}
|
||||||
|
Text(
|
||||||
|
member.displayName, style = MaterialTheme.typography.h1.copy(fontWeight = FontWeight.Normal),
|
||||||
|
color = MaterialTheme.colors.onBackground,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (member.fullName != "" && member.fullName != member.displayName) {
|
||||||
|
Text(
|
||||||
|
member.fullName, style = MaterialTheme.typography.h2,
|
||||||
|
color = MaterialTheme.colors.onBackground,
|
||||||
|
maxLines = 2,
|
||||||
|
overflow = TextOverflow.Ellipsis
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun RemoveMemberButton(onClick: () -> Unit) {
|
||||||
|
SettingsActionItem(
|
||||||
|
painterResource(R.drawable.ic_delete),
|
||||||
|
stringResource(R.string.button_remove_member),
|
||||||
|
click = onClick,
|
||||||
|
textColor = Color.Red,
|
||||||
|
iconColor = Color.Red,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun OpenChatButton(onClick: () -> Unit) {
|
||||||
|
SettingsActionItem(
|
||||||
|
painterResource(R.drawable.ic_chat),
|
||||||
|
stringResource(R.string.button_send_direct_message),
|
||||||
|
click = onClick,
|
||||||
|
textColor = MaterialTheme.colors.primary,
|
||||||
|
iconColor = MaterialTheme.colors.primary,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun RoleSelectionRow(
|
||||||
|
roles: List<GroupMemberRole>,
|
||||||
|
selectedRole: MutableState<GroupMemberRole>,
|
||||||
|
onSelected: (GroupMemberRole) -> Unit
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
Modifier.fillMaxWidth(),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween
|
||||||
|
) {
|
||||||
|
val values = remember { roles.map { it to it.text } }
|
||||||
|
ExposedDropDownSettingRow(
|
||||||
|
generalGetString(R.string.change_role),
|
||||||
|
values,
|
||||||
|
selectedRole,
|
||||||
|
icon = null,
|
||||||
|
enabled = remember { mutableStateOf(true) },
|
||||||
|
onSelected = onSelected
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun updateMemberRoleDialog(
|
||||||
|
newRole: GroupMemberRole,
|
||||||
|
member: GroupMember,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
onConfirm: () -> Unit
|
||||||
|
) {
|
||||||
|
AlertManager.shared.showAlertDialog(
|
||||||
|
title = generalGetString(R.string.change_member_role_question),
|
||||||
|
text = if (member.memberCurrent)
|
||||||
|
String.format(generalGetString(R.string.member_role_will_be_changed_with_notification), newRole.text)
|
||||||
|
else
|
||||||
|
String.format(generalGetString(R.string.member_role_will_be_changed_with_invitation), newRole.text),
|
||||||
|
confirmText = generalGetString(R.string.change_verb),
|
||||||
|
onDismiss = onDismiss,
|
||||||
|
onConfirm = onConfirm,
|
||||||
|
onDismissRequest = onDismiss
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun switchMemberAddress(m: ChatModel, groupInfo: GroupInfo, member: GroupMember) = withApi {
|
||||||
|
m.controller.apiSwitchGroupMember(groupInfo.apiId, member.groupMemberId)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Preview
|
||||||
|
@Composable
|
||||||
|
fun PreviewGroupMemberInfoLayout() {
|
||||||
|
SimpleXTheme {
|
||||||
|
GroupMemberInfoLayout(
|
||||||
|
groupInfo = GroupInfo.sampleData,
|
||||||
|
member = GroupMember.sampleData,
|
||||||
|
connStats = null,
|
||||||
|
newRole = remember { mutableStateOf(GroupMemberRole.Member) },
|
||||||
|
developerTools = false,
|
||||||
|
connectionCode = "123",
|
||||||
|
getContactChat = { Chat.sampleData },
|
||||||
|
openDirectChat = {},
|
||||||
|
removeMember = {},
|
||||||
|
onRoleSelected = {},
|
||||||
|
switchMemberAddress = {},
|
||||||
|
verifyClicked = {},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
+22
-43
@@ -1,4 +1,4 @@
|
|||||||
package chat.simplex.common.views.chat.group
|
package chat.simplex.app.views.chat.group
|
||||||
|
|
||||||
import InfoRow
|
import InfoRow
|
||||||
import SectionBottomSpacer
|
import SectionBottomSpacer
|
||||||
@@ -13,20 +13,17 @@ import androidx.compose.material.Text
|
|||||||
import androidx.compose.runtime.*
|
import androidx.compose.runtime.*
|
||||||
import androidx.compose.runtime.saveable.rememberSaveable
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import dev.icerock.moko.resources.compose.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import chat.simplex.common.ui.theme.*
|
import chat.simplex.app.R
|
||||||
import chat.simplex.common.views.helpers.*
|
import chat.simplex.app.model.*
|
||||||
import chat.simplex.common.views.usersettings.PreferenceToggleWithIcon
|
import chat.simplex.app.ui.theme.*
|
||||||
import chat.simplex.common.model.*
|
import chat.simplex.app.views.chat.TimedMessagesTTLPicker
|
||||||
import chat.simplex.res.MR
|
import chat.simplex.app.views.helpers.*
|
||||||
|
import chat.simplex.app.views.usersettings.PreferenceToggleWithIcon
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun GroupPreferencesView(m: ChatModel, rhId: Long?, chatId: String, close: () -> Unit,) {
|
fun GroupPreferencesView(m: ChatModel, chatId: String, close: () -> Unit,) {
|
||||||
val groupInfo = remember { derivedStateOf {
|
val groupInfo = remember { derivedStateOf { (m.getChat(chatId)?.chatInfo as? ChatInfo.Group)?.groupInfo } }
|
||||||
val ch = m.getChat(chatId)
|
|
||||||
val g = (ch?.chatInfo as? ChatInfo.Group)?.groupInfo
|
|
||||||
if (g == null || ch?.remoteHostId != rhId) null else g
|
|
||||||
}}
|
|
||||||
val gInfo = groupInfo.value ?: return
|
val gInfo = groupInfo.value ?: return
|
||||||
var preferences by rememberSaveable(gInfo, stateSaver = serializableSaver()) { mutableStateOf(gInfo.fullGroupPreferences) }
|
var preferences by rememberSaveable(gInfo, stateSaver = serializableSaver()) { mutableStateOf(gInfo.fullGroupPreferences) }
|
||||||
var currentPreferences by rememberSaveable(gInfo, stateSaver = serializableSaver()) { mutableStateOf(preferences) }
|
var currentPreferences by rememberSaveable(gInfo, stateSaver = serializableSaver()) { mutableStateOf(preferences) }
|
||||||
@@ -34,9 +31,9 @@ fun GroupPreferencesView(m: ChatModel, rhId: Long?, chatId: String, close: () ->
|
|||||||
fun savePrefs(afterSave: () -> Unit = {}) {
|
fun savePrefs(afterSave: () -> Unit = {}) {
|
||||||
withApi {
|
withApi {
|
||||||
val gp = gInfo.groupProfile.copy(groupPreferences = preferences.toGroupPreferences())
|
val gp = gInfo.groupProfile.copy(groupPreferences = preferences.toGroupPreferences())
|
||||||
val g = m.controller.apiUpdateGroup(rhId, gInfo.groupId, gp)
|
val gInfo = m.controller.apiUpdateGroup(gInfo.groupId, gp)
|
||||||
if (g != null) {
|
if (gInfo != null) {
|
||||||
m.updateGroup(rhId, g)
|
m.updateGroup(gInfo)
|
||||||
currentPreferences = preferences
|
currentPreferences = preferences
|
||||||
}
|
}
|
||||||
afterSave()
|
afterSave()
|
||||||
@@ -75,10 +72,10 @@ private fun GroupPreferencesLayout(
|
|||||||
Column(
|
Column(
|
||||||
Modifier.fillMaxWidth().verticalScroll(rememberScrollState()),
|
Modifier.fillMaxWidth().verticalScroll(rememberScrollState()),
|
||||||
) {
|
) {
|
||||||
AppBarTitle(stringResource(MR.strings.group_preferences))
|
AppBarTitle(stringResource(R.string.group_preferences))
|
||||||
val timedMessages = remember(preferences) { mutableStateOf(preferences.timedMessages.enable) }
|
val timedMessages = remember(preferences) { mutableStateOf(preferences.timedMessages.enable) }
|
||||||
val onTTLUpdated = { ttl: Int? ->
|
val onTTLUpdated = { ttl: Int? ->
|
||||||
applyPrefs(preferences.copy(timedMessages = preferences.timedMessages.copy(ttl = ttl)))
|
applyPrefs(preferences.copy(timedMessages = preferences.timedMessages.copy(ttl = ttl ?: 86400)))
|
||||||
}
|
}
|
||||||
FeatureSection(GroupFeature.TimedMessages, timedMessages, groupInfo, preferences, onTTLUpdated) { enable ->
|
FeatureSection(GroupFeature.TimedMessages, timedMessages, groupInfo, preferences, onTTLUpdated) { enable ->
|
||||||
if (enable == GroupFeatureEnabled.ON) {
|
if (enable == GroupFeatureEnabled.ON) {
|
||||||
@@ -98,20 +95,10 @@ private fun GroupPreferencesLayout(
|
|||||||
applyPrefs(preferences.copy(fullDelete = GroupPreference(enable = it)))
|
applyPrefs(preferences.copy(fullDelete = GroupPreference(enable = it)))
|
||||||
}
|
}
|
||||||
SectionDividerSpaced(true, maxBottomPadding = false)
|
SectionDividerSpaced(true, maxBottomPadding = false)
|
||||||
val allowReactions = remember(preferences) { mutableStateOf(preferences.reactions.enable) }
|
|
||||||
FeatureSection(GroupFeature.Reactions, allowReactions, groupInfo, preferences, onTTLUpdated) {
|
|
||||||
applyPrefs(preferences.copy(reactions = GroupPreference(enable = it)))
|
|
||||||
}
|
|
||||||
SectionDividerSpaced(true, maxBottomPadding = false)
|
|
||||||
val allowVoice = remember(preferences) { mutableStateOf(preferences.voice.enable) }
|
val allowVoice = remember(preferences) { mutableStateOf(preferences.voice.enable) }
|
||||||
FeatureSection(GroupFeature.Voice, allowVoice, groupInfo, preferences, onTTLUpdated) {
|
FeatureSection(GroupFeature.Voice, allowVoice, groupInfo, preferences, onTTLUpdated) {
|
||||||
applyPrefs(preferences.copy(voice = GroupPreference(enable = it)))
|
applyPrefs(preferences.copy(voice = GroupPreference(enable = it)))
|
||||||
}
|
}
|
||||||
SectionDividerSpaced(true, maxBottomPadding = false)
|
|
||||||
val allowFiles = remember(preferences) { mutableStateOf(preferences.files.enable) }
|
|
||||||
FeatureSection(GroupFeature.Files, allowFiles, groupInfo, preferences, onTTLUpdated) {
|
|
||||||
applyPrefs(preferences.copy(files = GroupPreference(enable = it)))
|
|
||||||
}
|
|
||||||
if (groupInfo.canEdit) {
|
if (groupInfo.canEdit) {
|
||||||
SectionDividerSpaced(maxTopPadding = true, maxBottomPadding = false)
|
SectionDividerSpaced(maxTopPadding = true, maxBottomPadding = false)
|
||||||
ResetSaveButtons(
|
ResetSaveButtons(
|
||||||
@@ -149,15 +136,7 @@ private fun FeatureSection(
|
|||||||
}
|
}
|
||||||
if (timedOn) {
|
if (timedOn) {
|
||||||
val ttl = rememberSaveable(preferences.timedMessages) { mutableStateOf(preferences.timedMessages.ttl) }
|
val ttl = rememberSaveable(preferences.timedMessages) { mutableStateOf(preferences.timedMessages.ttl) }
|
||||||
DropdownCustomTimePickerSettingRow(
|
TimedMessagesTTLPicker(ttl, onTTLUpdated)
|
||||||
selection = ttl,
|
|
||||||
propagateExternalSelectionUpdate = true, // for Reset
|
|
||||||
label = generalGetString(MR.strings.delete_after),
|
|
||||||
dropdownValues = TimedMessagesPreference.ttlValues,
|
|
||||||
customPickerTitle = generalGetString(MR.strings.delete_after),
|
|
||||||
customPickerConfirmButtonText = generalGetString(MR.strings.custom_time_picker_select),
|
|
||||||
onSelected = onTTLUpdated
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
InfoRow(
|
InfoRow(
|
||||||
@@ -167,7 +146,7 @@ private fun FeatureSection(
|
|||||||
iconTint = iconTint,
|
iconTint = iconTint,
|
||||||
)
|
)
|
||||||
if (timedOn) {
|
if (timedOn) {
|
||||||
InfoRow(generalGetString(MR.strings.delete_after), timeText(preferences.timedMessages.ttl))
|
InfoRow(generalGetString(R.string.delete_after), TimedMessagesPreference.ttlText(preferences.timedMessages.ttl))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -178,19 +157,19 @@ private fun FeatureSection(
|
|||||||
private fun ResetSaveButtons(reset: () -> Unit, save: () -> Unit, disabled: Boolean) {
|
private fun ResetSaveButtons(reset: () -> Unit, save: () -> Unit, disabled: Boolean) {
|
||||||
SectionView {
|
SectionView {
|
||||||
SectionItemView(reset, disabled = disabled) {
|
SectionItemView(reset, disabled = disabled) {
|
||||||
Text(stringResource(MR.strings.reset_verb), color = if (disabled) MaterialTheme.colors.secondary else MaterialTheme.colors.primary)
|
Text(stringResource(R.string.reset_verb), color = if (disabled) MaterialTheme.colors.secondary else MaterialTheme.colors.primary)
|
||||||
}
|
}
|
||||||
SectionItemView(save, disabled = disabled) {
|
SectionItemView(save, disabled = disabled) {
|
||||||
Text(stringResource(MR.strings.save_and_notify_group_members), color = if (disabled) MaterialTheme.colors.secondary else MaterialTheme.colors.primary)
|
Text(stringResource(R.string.save_and_notify_group_members), color = if (disabled) MaterialTheme.colors.secondary else MaterialTheme.colors.primary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun showUnsavedChangesAlert(save: () -> Unit, revert: () -> Unit) {
|
private fun showUnsavedChangesAlert(save: () -> Unit, revert: () -> Unit) {
|
||||||
AlertManager.shared.showAlertDialogStacked(
|
AlertManager.shared.showAlertDialogStacked(
|
||||||
title = generalGetString(MR.strings.save_preferences_question),
|
title = generalGetString(R.string.save_preferences_question),
|
||||||
confirmText = generalGetString(MR.strings.save_and_notify_group_members),
|
confirmText = generalGetString(R.string.save_and_notify_group_members),
|
||||||
dismissText = generalGetString(MR.strings.exit_without_saving),
|
dismissText = generalGetString(R.string.exit_without_saving),
|
||||||
onConfirm = save,
|
onConfirm = save,
|
||||||
onDismiss = revert,
|
onDismiss = revert,
|
||||||
)
|
)
|
||||||
+41
-44
@@ -1,7 +1,8 @@
|
|||||||
package chat.simplex.common.views.chat.group
|
package chat.simplex.app.views.chat.group
|
||||||
|
|
||||||
import SectionBottomSpacer
|
import SectionBottomSpacer
|
||||||
import androidx.compose.desktop.ui.tooling.preview.Preview
|
import android.content.res.Configuration
|
||||||
|
import android.net.Uri
|
||||||
import androidx.compose.foundation.*
|
import androidx.compose.foundation.*
|
||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.*
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
@@ -12,33 +13,34 @@ import androidx.compose.ui.Alignment
|
|||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.focus.FocusRequester
|
import androidx.compose.ui.focus.FocusRequester
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import dev.icerock.moko.resources.compose.stringResource
|
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.Preview
|
||||||
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 chat.simplex.common.model.*
|
import chat.simplex.app.R
|
||||||
import chat.simplex.common.platform.*
|
import chat.simplex.app.model.*
|
||||||
import chat.simplex.common.ui.theme.*
|
import chat.simplex.app.ui.theme.*
|
||||||
import chat.simplex.common.views.*
|
import chat.simplex.app.views.ProfileNameField
|
||||||
import chat.simplex.common.views.helpers.*
|
import chat.simplex.app.views.helpers.*
|
||||||
import chat.simplex.common.views.onboarding.ReadableText
|
import chat.simplex.app.views.isValidDisplayName
|
||||||
import chat.simplex.common.views.usersettings.*
|
import chat.simplex.app.views.onboarding.ReadableText
|
||||||
import chat.simplex.res.MR
|
import chat.simplex.app.views.usersettings.*
|
||||||
import dev.icerock.moko.resources.compose.painterResource
|
import com.google.accompanist.insets.ProvideWindowInsets
|
||||||
|
import com.google.accompanist.insets.navigationBarsWithImePadding
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import java.net.URI
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun GroupProfileView(rhId: Long?, groupInfo: GroupInfo, chatModel: ChatModel, close: () -> Unit) {
|
fun GroupProfileView(groupInfo: GroupInfo, chatModel: ChatModel, close: () -> Unit) {
|
||||||
GroupProfileLayout(
|
GroupProfileLayout(
|
||||||
close = close,
|
close = close,
|
||||||
groupProfile = groupInfo.groupProfile,
|
groupProfile = groupInfo.groupProfile,
|
||||||
saveProfile = { p ->
|
saveProfile = { p ->
|
||||||
withApi {
|
withApi {
|
||||||
val gInfo = chatModel.controller.apiUpdateGroup(rhId, groupInfo.groupId, p)
|
val gInfo = chatModel.controller.apiUpdateGroup(groupInfo.groupId, p)
|
||||||
if (gInfo != null) {
|
if (gInfo != null) {
|
||||||
chatModel.updateGroup(rhId, gInfo)
|
chatModel.updateGroup(gInfo)
|
||||||
close.invoke()
|
close.invoke()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -55,7 +57,7 @@ fun GroupProfileLayout(
|
|||||||
val bottomSheetModalState = rememberModalBottomSheetState(initialValue = ModalBottomSheetValue.Hidden)
|
val bottomSheetModalState = rememberModalBottomSheetState(initialValue = ModalBottomSheetValue.Hidden)
|
||||||
val displayName = rememberSaveable { mutableStateOf(groupProfile.displayName) }
|
val displayName = rememberSaveable { mutableStateOf(groupProfile.displayName) }
|
||||||
val fullName = rememberSaveable { mutableStateOf(groupProfile.fullName) }
|
val fullName = rememberSaveable { mutableStateOf(groupProfile.fullName) }
|
||||||
val chosenImage = rememberSaveable { mutableStateOf<URI?>(null) }
|
val chosenImage = rememberSaveable { mutableStateOf<Uri?>(null) }
|
||||||
val profileImage = rememberSaveable { mutableStateOf(groupProfile.image) }
|
val profileImage = rememberSaveable { mutableStateOf(groupProfile.image) }
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
val scrollState = rememberScrollState()
|
val scrollState = rememberScrollState()
|
||||||
@@ -65,13 +67,13 @@ fun GroupProfileLayout(
|
|||||||
fullName.value == groupProfile.fullName &&
|
fullName.value == groupProfile.fullName &&
|
||||||
groupProfile.image == profileImage.value
|
groupProfile.image == profileImage.value
|
||||||
val closeWithAlert = {
|
val closeWithAlert = {
|
||||||
if (dataUnchanged || !canUpdateProfile(displayName.value, groupProfile)) {
|
if (dataUnchanged || !(displayName.value.isNotEmpty() && isValidDisplayName(displayName.value))) {
|
||||||
close()
|
close()
|
||||||
} else {
|
} else {
|
||||||
showUnsavedChangesAlert({
|
showUnsavedChangesAlert({
|
||||||
saveProfile(
|
saveProfile(
|
||||||
groupProfile.copy(
|
groupProfile.copy(
|
||||||
displayName = displayName.value.trim(),
|
displayName = displayName.value,
|
||||||
fullName = fullName.value,
|
fullName = fullName.value,
|
||||||
image = profileImage.value
|
image = profileImage.value
|
||||||
)
|
)
|
||||||
@@ -103,7 +105,7 @@ fun GroupProfileLayout(
|
|||||||
Modifier.fillMaxWidth()
|
Modifier.fillMaxWidth()
|
||||||
.padding(horizontal = DEFAULT_PADDING)
|
.padding(horizontal = DEFAULT_PADDING)
|
||||||
) {
|
) {
|
||||||
ReadableText(MR.strings.group_profile_is_stored_on_members_devices, TextAlign.Center)
|
ReadableText(R.string.group_profile_is_stored_on_members_devices, TextAlign.Center)
|
||||||
Box(
|
Box(
|
||||||
Modifier
|
Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
@@ -122,35 +124,35 @@ fun GroupProfileLayout(
|
|||||||
}
|
}
|
||||||
Row(Modifier.padding(bottom = DEFAULT_PADDING_HALF).fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
|
Row(Modifier.padding(bottom = DEFAULT_PADDING_HALF).fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
|
||||||
Text(
|
Text(
|
||||||
stringResource(MR.strings.group_display_name_field),
|
stringResource(R.string.group_display_name_field),
|
||||||
fontSize = 16.sp
|
fontSize = 16.sp
|
||||||
)
|
)
|
||||||
if (!isValidNewProfileName(displayName.value, groupProfile)) {
|
if (!isValidDisplayName(displayName.value)) {
|
||||||
Spacer(Modifier.size(DEFAULT_PADDING_HALF))
|
Spacer(Modifier.size(DEFAULT_PADDING_HALF))
|
||||||
IconButton({ showInvalidNameAlert(mkValidName(displayName.value), displayName) }, Modifier.size(20.dp)) {
|
Text(
|
||||||
Icon(painterResource(MR.images.ic_info), null, tint = MaterialTheme.colors.error)
|
stringResource(R.string.no_spaces),
|
||||||
|
fontSize = 16.sp,
|
||||||
|
color = Color.Red
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
ProfileNameField(displayName, "", ::isValidDisplayName, focusRequester)
|
||||||
ProfileNameField(displayName, "", { isValidNewProfileName(it, groupProfile) }, focusRequester)
|
|
||||||
if (groupProfile.fullName.isNotEmpty() && groupProfile.fullName != groupProfile.displayName) {
|
|
||||||
Spacer(Modifier.height(DEFAULT_PADDING))
|
Spacer(Modifier.height(DEFAULT_PADDING))
|
||||||
Text(
|
Text(
|
||||||
stringResource(MR.strings.group_full_name_field),
|
stringResource(R.string.group_full_name_field),
|
||||||
fontSize = 16.sp,
|
fontSize = 16.sp,
|
||||||
modifier = Modifier.padding(bottom = DEFAULT_PADDING_HALF)
|
modifier = Modifier.padding(bottom = DEFAULT_PADDING_HALF)
|
||||||
)
|
)
|
||||||
ProfileNameField(fullName)
|
ProfileNameField(fullName)
|
||||||
}
|
|
||||||
Spacer(Modifier.height(DEFAULT_PADDING))
|
Spacer(Modifier.height(DEFAULT_PADDING))
|
||||||
val enabled = !dataUnchanged && canUpdateProfile(displayName.value, groupProfile)
|
val enabled = !dataUnchanged && displayName.value.isNotEmpty() && isValidDisplayName(displayName.value)
|
||||||
if (enabled) {
|
if (enabled) {
|
||||||
Text(
|
Text(
|
||||||
stringResource(MR.strings.save_group_profile),
|
stringResource(R.string.save_group_profile),
|
||||||
modifier = Modifier.clickable {
|
modifier = Modifier.clickable {
|
||||||
saveProfile(
|
saveProfile(
|
||||||
groupProfile.copy(
|
groupProfile.copy(
|
||||||
displayName = displayName.value.trim(),
|
displayName = displayName.value,
|
||||||
fullName = fullName.value,
|
fullName = fullName.value,
|
||||||
image = profileImage.value
|
image = profileImage.value
|
||||||
)
|
)
|
||||||
@@ -160,7 +162,7 @@ fun GroupProfileLayout(
|
|||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
Text(
|
Text(
|
||||||
stringResource(MR.strings.save_group_profile),
|
stringResource(R.string.save_group_profile),
|
||||||
color = MaterialTheme.colors.secondary
|
color = MaterialTheme.colors.secondary
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -178,27 +180,22 @@ fun GroupProfileLayout(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun canUpdateProfile(displayName: String, groupProfile: GroupProfile): Boolean =
|
|
||||||
displayName.trim().isNotEmpty() && isValidNewProfileName(displayName, groupProfile)
|
|
||||||
|
|
||||||
private fun isValidNewProfileName(displayName: String, groupProfile: GroupProfile): Boolean =
|
|
||||||
displayName == groupProfile.displayName || isValidDisplayName(displayName.trim())
|
|
||||||
|
|
||||||
private fun showUnsavedChangesAlert(save: () -> Unit, revert: () -> Unit) {
|
private fun showUnsavedChangesAlert(save: () -> Unit, revert: () -> Unit) {
|
||||||
AlertManager.shared.showAlertDialogStacked(
|
AlertManager.shared.showAlertDialogStacked(
|
||||||
title = generalGetString(MR.strings.save_preferences_question),
|
title = generalGetString(R.string.save_preferences_question),
|
||||||
confirmText = generalGetString(MR.strings.save_and_notify_group_members),
|
confirmText = generalGetString(R.string.save_and_notify_group_members),
|
||||||
dismissText = generalGetString(MR.strings.exit_without_saving),
|
dismissText = generalGetString(R.string.exit_without_saving),
|
||||||
onConfirm = save,
|
onConfirm = save,
|
||||||
onDismiss = revert,
|
onDismiss = revert,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Preview/*(
|
@Preview(showBackground = true)
|
||||||
|
@Preview(
|
||||||
uiMode = Configuration.UI_MODE_NIGHT_YES,
|
uiMode = Configuration.UI_MODE_NIGHT_YES,
|
||||||
showBackground = true,
|
showBackground = true,
|
||||||
name = "Dark Mode"
|
name = "Dark Mode"
|
||||||
)*/
|
)
|
||||||
@Composable
|
@Composable
|
||||||
fun PreviewGroupProfileLayout() {
|
fun PreviewGroupProfileLayout() {
|
||||||
SimpleXTheme {
|
SimpleXTheme {
|
||||||
+95
@@ -0,0 +1,95 @@
|
|||||||
|
package chat.simplex.app.views.chat.group
|
||||||
|
|
||||||
|
import SectionBottomSpacer
|
||||||
|
import SectionItemView
|
||||||
|
import SectionSpacer
|
||||||
|
import SectionView
|
||||||
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.material.MaterialTheme
|
||||||
|
import androidx.compose.material.Text
|
||||||
|
import androidx.compose.runtime.*
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import chat.simplex.app.R
|
||||||
|
import chat.simplex.app.model.*
|
||||||
|
import chat.simplex.app.ui.theme.*
|
||||||
|
import chat.simplex.app.views.helpers.*
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun GroupWelcomeView(m: ChatModel, groupInfo: GroupInfo, close: () -> Unit) {
|
||||||
|
var groupInfo by remember { mutableStateOf(groupInfo) }
|
||||||
|
val welcomeText = remember { mutableStateOf(groupInfo.groupProfile.description ?: "") }
|
||||||
|
|
||||||
|
fun save(afterSave: () -> Unit = {}) {
|
||||||
|
withApi {
|
||||||
|
var welcome: String? = welcomeText.value.trim('\n', ' ')
|
||||||
|
if (welcome?.length == 0) {
|
||||||
|
welcome = null
|
||||||
|
}
|
||||||
|
val groupProfileUpdated = groupInfo.groupProfile.copy(description = welcome)
|
||||||
|
val res = m.controller.apiUpdateGroup(groupInfo.groupId, groupProfileUpdated)
|
||||||
|
if (res != null) {
|
||||||
|
groupInfo = res
|
||||||
|
m.updateGroup(res)
|
||||||
|
welcomeText.value = welcome ?: ""
|
||||||
|
}
|
||||||
|
afterSave()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ModalView(
|
||||||
|
close = {
|
||||||
|
if (welcomeText.value == groupInfo.groupProfile.description || (welcomeText.value == "" && groupInfo.groupProfile.description == null)) close()
|
||||||
|
else showUnsavedChangesAlert({ save(close) }, close)
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
GroupWelcomeLayout(
|
||||||
|
welcomeText,
|
||||||
|
groupInfo,
|
||||||
|
save = ::save
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun GroupWelcomeLayout(
|
||||||
|
welcomeText: MutableState<String>,
|
||||||
|
groupInfo: GroupInfo,
|
||||||
|
save: () -> Unit,
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
Modifier.fillMaxWidth().verticalScroll(rememberScrollState()),
|
||||||
|
) {
|
||||||
|
AppBarTitle(stringResource(R.string.group_welcome_title))
|
||||||
|
val welcomeText = remember { welcomeText }
|
||||||
|
TextEditor(Modifier.padding(horizontal = DEFAULT_PADDING).height(160.dp), text = welcomeText)
|
||||||
|
SectionSpacer()
|
||||||
|
SaveButton(
|
||||||
|
save = save,
|
||||||
|
disabled = welcomeText.value == groupInfo.groupProfile.description || (welcomeText.value == "" && groupInfo.groupProfile.description == null)
|
||||||
|
)
|
||||||
|
SectionBottomSpacer()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun SaveButton(save: () -> Unit, disabled: Boolean) {
|
||||||
|
SectionView {
|
||||||
|
SectionItemView(save, disabled = disabled) {
|
||||||
|
Text(stringResource(R.string.save_and_update_group_profile), color = if (disabled) MaterialTheme.colors.secondary else MaterialTheme.colors.primary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun showUnsavedChangesAlert(save: () -> Unit, revert: () -> Unit) {
|
||||||
|
AlertManager.shared.showAlertDialogStacked(
|
||||||
|
title = generalGetString(R.string.save_welcome_message_question),
|
||||||
|
confirmText = generalGetString(R.string.save_and_update_group_profile),
|
||||||
|
dismissText = generalGetString(R.string.exit_without_saving),
|
||||||
|
onConfirm = save,
|
||||||
|
onDismiss = revert,
|
||||||
|
)
|
||||||
|
}
|
||||||
+16
-17
@@ -1,4 +1,4 @@
|
|||||||
package chat.simplex.common.views.chat.item
|
package chat.simplex.app.views.chat.item
|
||||||
|
|
||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.*
|
||||||
import androidx.compose.material.*
|
import androidx.compose.material.*
|
||||||
@@ -6,14 +6,13 @@ import androidx.compose.runtime.Composable
|
|||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import dev.icerock.moko.resources.compose.painterResource
|
import androidx.compose.ui.res.painterResource
|
||||||
import dev.icerock.moko.resources.compose.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
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 chat.simplex.common.ui.theme.*
|
import chat.simplex.app.R
|
||||||
import chat.simplex.common.model.*
|
import chat.simplex.app.model.*
|
||||||
import chat.simplex.common.views.helpers.SimpleButton
|
import chat.simplex.app.ui.theme.*
|
||||||
import chat.simplex.res.MR
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun CICallItemView(cInfo: ChatInfo, cItem: ChatItem, status: CICallStatus, duration: Int, acceptCall: (Contact) -> Unit) {
|
fun CICallItemView(cInfo: ChatInfo, cItem: ChatItem, status: CICallStatus, duration: Int, acceptCall: (Contact) -> Unit) {
|
||||||
@@ -22,20 +21,20 @@ fun CICallItemView(cInfo: ChatInfo, cItem: ChatItem, status: CICallStatus, durat
|
|||||||
Modifier
|
Modifier
|
||||||
.padding(horizontal = 4.dp)
|
.padding(horizontal = 4.dp)
|
||||||
.padding(bottom = 8.dp), horizontalAlignment = Alignment.CenterHorizontally) {
|
.padding(bottom = 8.dp), horizontalAlignment = Alignment.CenterHorizontally) {
|
||||||
@Composable fun ConnectingCallIcon() = Icon(painterResource(MR.images.ic_settings_phone), stringResource(MR.strings.icon_descr_call_connecting), tint = SimplexGreen)
|
@Composable fun ConnectingCallIcon() = Icon(painterResource(R.drawable.ic_settings_phone), stringResource(R.string.icon_descr_call_connecting), tint = SimplexGreen)
|
||||||
when (status) {
|
when (status) {
|
||||||
CICallStatus.Pending -> if (sent) {
|
CICallStatus.Pending -> if (sent) {
|
||||||
Icon(painterResource(MR.images.ic_call), stringResource(MR.strings.icon_descr_call_pending_sent))
|
Icon(painterResource(R.drawable.ic_call), stringResource(R.string.icon_descr_call_pending_sent))
|
||||||
} else {
|
} else {
|
||||||
AcceptCallButton(cInfo, acceptCall)
|
AcceptCallButton(cInfo, acceptCall)
|
||||||
}
|
}
|
||||||
CICallStatus.Missed -> Icon(painterResource(MR.images.ic_call), stringResource(MR.strings.icon_descr_call_missed), tint = Color.Red)
|
CICallStatus.Missed -> Icon(painterResource(R.drawable.ic_call), stringResource(R.string.icon_descr_call_missed), tint = Color.Red)
|
||||||
CICallStatus.Rejected -> Icon(painterResource(MR.images.ic_call_end), stringResource(MR.strings.icon_descr_call_rejected), tint = Color.Red)
|
CICallStatus.Rejected -> Icon(painterResource(R.drawable.ic_call_end), stringResource(R.string.icon_descr_call_rejected), tint = Color.Red)
|
||||||
CICallStatus.Accepted -> ConnectingCallIcon()
|
CICallStatus.Accepted -> ConnectingCallIcon()
|
||||||
CICallStatus.Negotiated -> ConnectingCallIcon()
|
CICallStatus.Negotiated -> ConnectingCallIcon()
|
||||||
CICallStatus.Progress -> Icon(painterResource(MR.images.ic_phone_in_talk_filled), stringResource(MR.strings.icon_descr_call_progress), tint = SimplexGreen)
|
CICallStatus.Progress -> Icon(painterResource(R.drawable.ic_phone_in_talk_filled), stringResource(R.string.icon_descr_call_progress), tint = SimplexGreen)
|
||||||
CICallStatus.Ended -> Row {
|
CICallStatus.Ended -> Row {
|
||||||
Icon(painterResource(MR.images.ic_call_end), stringResource(MR.strings.icon_descr_call_ended), tint = MaterialTheme.colors.secondary, modifier = Modifier.padding(end = 4.dp))
|
Icon(painterResource(R.drawable.ic_call_end), stringResource(R.string.icon_descr_call_ended), tint = MaterialTheme.colors.secondary, modifier = Modifier.padding(end = 4.dp))
|
||||||
Text(durationText(duration), color = MaterialTheme.colors.secondary)
|
Text(durationText(duration), color = MaterialTheme.colors.secondary)
|
||||||
}
|
}
|
||||||
CICallStatus.Error -> {}
|
CICallStatus.Error -> {}
|
||||||
@@ -53,9 +52,9 @@ fun CICallItemView(cInfo: ChatInfo, cItem: ChatItem, status: CICallStatus, durat
|
|||||||
@Composable
|
@Composable
|
||||||
fun AcceptCallButton(cInfo: ChatInfo, acceptCall: (Contact) -> Unit) {
|
fun AcceptCallButton(cInfo: ChatInfo, acceptCall: (Contact) -> Unit) {
|
||||||
if (cInfo is ChatInfo.Direct) {
|
if (cInfo is ChatInfo.Direct) {
|
||||||
SimpleButton(stringResource(MR.strings.answer_call), painterResource(MR.images.ic_ring_volume)) { acceptCall(cInfo.contact) }
|
SimpleButton(stringResource(R.string.answer_call), painterResource(R.drawable.ic_ring_volume)) { acceptCall(cInfo.contact) }
|
||||||
} else {
|
} else {
|
||||||
Icon(painterResource(MR.images.ic_ring_volume), stringResource(MR.strings.answer_call), tint = MaterialTheme.colors.secondary)
|
Icon(painterResource(R.drawable.ic_ring_volume), stringResource(R.string.answer_call), tint = MaterialTheme.colors.secondary)
|
||||||
}
|
}
|
||||||
// if case let .direct(contact) = chatInfo {
|
// if case let .direct(contact) = chatInfo {
|
||||||
// Button {
|
// Button {
|
||||||
@@ -68,7 +67,7 @@ fun AcceptCallButton(cInfo: ChatInfo, acceptCall: (Contact) -> Unit) {
|
|||||||
// sharedKey: invitation.sharedKey
|
// sharedKey: invitation.sharedKey
|
||||||
// )
|
// )
|
||||||
// m.showCallView = true
|
// m.showCallView = true
|
||||||
// m.callCommand = .start(media: invitation.peerMedia, aesKey: invitation.sharedKey: true)
|
// m.callCommand = .start(media: invitation.peerMedia, aesKey: invitation.sharedKey, useWorker: true)
|
||||||
// } else {
|
// } else {
|
||||||
// AlertManager.shared.showAlertMsg(title: "Call already ended!")
|
// AlertManager.shared.showAlertMsg(title: "Call already ended!")
|
||||||
// }
|
// }
|
||||||
@@ -141,7 +140,7 @@ fun AcceptCallButton(cInfo: ChatInfo, acceptCall: (Contact) -> Unit) {
|
|||||||
// sharedKey: invitation.sharedKey
|
// sharedKey: invitation.sharedKey
|
||||||
// )
|
// )
|
||||||
// m.showCallView = true
|
// m.showCallView = true
|
||||||
// m.callCommand = .start(media: invitation.peerMedia, aesKey: invitation.sharedKey: true)
|
// m.callCommand = .start(media: invitation.peerMedia, aesKey: invitation.sharedKey, useWorker: true)
|
||||||
// } else {
|
// } else {
|
||||||
// AlertManager.shared.showAlertMsg(title: "Call already ended!")
|
// AlertManager.shared.showAlertMsg(title: "Call already ended!")
|
||||||
// }
|
// }
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
package chat.simplex.app.views.chat.item
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.material.*
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.painter.Painter
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import chat.simplex.app.model.*
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun CIChatFeatureView(
|
||||||
|
chatItem: ChatItem,
|
||||||
|
feature: Feature,
|
||||||
|
iconColor: Color,
|
||||||
|
icon: Painter? = null
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
Modifier.padding(horizontal = 6.dp, vertical = 6.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(4.dp)
|
||||||
|
) {
|
||||||
|
Icon(icon ?: feature.iconFilled(), feature.text, Modifier.size(18.dp), tint = iconColor)
|
||||||
|
Text(
|
||||||
|
chatEventText(chatItem),
|
||||||
|
Modifier,
|
||||||
|
// this is important. Otherwise, aligning will be bad because annotated string has a Span with size 12.sp
|
||||||
|
fontSize = 12.sp
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
package chat.simplex.app.views.chat.item
|
||||||
|
|
||||||
|
import android.content.res.Configuration
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.material.*
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.text.*
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import chat.simplex.app.model.ChatItem
|
||||||
|
import chat.simplex.app.ui.theme.*
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun CIEventView(ci: ChatItem) {
|
||||||
|
@Composable
|
||||||
|
fun chatEventTextView(text: AnnotatedString) {
|
||||||
|
Text(text, style = MaterialTheme.typography.body1.copy(lineHeight = 22.sp))
|
||||||
|
}
|
||||||
|
Row(
|
||||||
|
Modifier.padding(horizontal = 6.dp, vertical = 6.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
val memberDisplayName = ci.memberDisplayName
|
||||||
|
if (memberDisplayName != null) {
|
||||||
|
chatEventTextView(
|
||||||
|
buildAnnotatedString {
|
||||||
|
withStyle(chatEventStyle) { append(memberDisplayName) }
|
||||||
|
append(" ")
|
||||||
|
}.plus(chatEventText(ci))
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
chatEventTextView(chatEventText(ci))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val chatEventStyle = SpanStyle(fontSize = 12.sp, fontWeight = FontWeight.Light, color = CurrentColors.value.colors.secondary)
|
||||||
|
|
||||||
|
fun chatEventText(ci: ChatItem): AnnotatedString =
|
||||||
|
buildAnnotatedString {
|
||||||
|
withStyle(chatEventStyle) { append(ci.content.text + " " + ci.timestampText) }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Preview(showBackground = true)
|
||||||
|
@Preview(
|
||||||
|
uiMode = Configuration.UI_MODE_NIGHT_YES,
|
||||||
|
name = "Dark Mode"
|
||||||
|
)
|
||||||
|
@Composable
|
||||||
|
fun CIEventViewPreview() {
|
||||||
|
SimpleXTheme {
|
||||||
|
CIEventView(
|
||||||
|
ChatItem.getGroupEventSample()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
+5
-5
@@ -1,4 +1,4 @@
|
|||||||
package chat.simplex.common.views.chat.item
|
package chat.simplex.app.views.chat.item
|
||||||
|
|
||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.*
|
||||||
import androidx.compose.material.*
|
import androidx.compose.material.*
|
||||||
@@ -9,9 +9,9 @@ import androidx.compose.ui.text.*
|
|||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
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 chat.simplex.common.views.helpers.generalGetString
|
import chat.simplex.app.R
|
||||||
import chat.simplex.common.model.*
|
import chat.simplex.app.model.*
|
||||||
import chat.simplex.res.MR
|
import chat.simplex.app.views.helpers.generalGetString
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun CIFeaturePreferenceView(
|
fun CIFeaturePreferenceView(
|
||||||
@@ -30,7 +30,7 @@ fun CIFeaturePreferenceView(
|
|||||||
if (contact != null && allowed != FeatureAllowed.NO && contact.allowsFeature(feature) && !contact.userAllowsFeature(feature)) {
|
if (contact != null && allowed != FeatureAllowed.NO && contact.allowsFeature(feature) && !contact.userAllowsFeature(feature)) {
|
||||||
val acceptStyle = SpanStyle(color = MaterialTheme.colors.primary, fontSize = 12.sp)
|
val acceptStyle = SpanStyle(color = MaterialTheme.colors.primary, fontSize = 12.sp)
|
||||||
val setParam = feature == ChatFeature.TimedMessages && contact.mergedPreferences.timedMessages.userPreference.pref.ttl == null
|
val setParam = feature == ChatFeature.TimedMessages && contact.mergedPreferences.timedMessages.userPreference.pref.ttl == null
|
||||||
val acceptTextId = if (setParam) MR.strings.accept_feature_set_1_day else MR.strings.accept_feature
|
val acceptTextId = if (setParam) R.string.accept_feature_set_1_day else R.string.accept_feature
|
||||||
val param = if (setParam) 86400 else null
|
val param = if (setParam) 86400 else null
|
||||||
val annotatedText = buildAnnotatedString {
|
val annotatedText = buildAnnotatedString {
|
||||||
withStyle(chatEventStyle) { append(chatItem.content.text + " ") }
|
withStyle(chatEventStyle) { append(chatItem.content.text + " ") }
|
||||||
+35
-69
@@ -1,5 +1,6 @@
|
|||||||
package chat.simplex.common.views.chat.item
|
package chat.simplex.app.views.chat.item
|
||||||
|
|
||||||
|
import android.widget.Toast
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.*
|
||||||
import androidx.compose.foundation.shape.CornerSize
|
import androidx.compose.foundation.shape.CornerSize
|
||||||
@@ -11,26 +12,27 @@ 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.Color
|
||||||
import androidx.compose.ui.graphics.painter.Painter
|
import androidx.compose.ui.graphics.painter.Painter
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.platform.LocalDensity
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
import dev.icerock.moko.resources.compose.painterResource
|
import androidx.compose.ui.res.painterResource
|
||||||
import dev.icerock.moko.resources.compose.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.tooling.preview.*
|
||||||
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 chat.simplex.common.model.*
|
import chat.simplex.app.R
|
||||||
import chat.simplex.common.platform.*
|
import chat.simplex.app.model.*
|
||||||
import chat.simplex.common.ui.theme.*
|
import chat.simplex.app.ui.theme.*
|
||||||
import chat.simplex.common.views.helpers.*
|
import chat.simplex.app.views.helpers.*
|
||||||
import chat.simplex.res.MR
|
import kotlinx.datetime.Clock
|
||||||
import java.io.File
|
|
||||||
import java.net.URI
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun CIFileView(
|
fun CIFileView(
|
||||||
file: CIFile?,
|
file: CIFile?,
|
||||||
edited: Boolean,
|
edited: Boolean,
|
||||||
receiveFile: (Long, Boolean) -> Unit
|
receiveFile: (Long) -> Unit
|
||||||
) {
|
) {
|
||||||
val saveFileLauncher = rememberSaveFileLauncher(ciFile = file)
|
val context = LocalContext.current
|
||||||
|
val saveFileLauncher = rememberSaveFileLauncher(cxt = context, ciFile = file)
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun fileIcon(
|
fun fileIcon(
|
||||||
@@ -41,15 +43,15 @@ fun CIFileView(
|
|||||||
contentAlignment = Alignment.Center
|
contentAlignment = Alignment.Center
|
||||||
) {
|
) {
|
||||||
Icon(
|
Icon(
|
||||||
painterResource(MR.images.ic_draft_filled),
|
painterResource(R.drawable.ic_draft_filled),
|
||||||
stringResource(MR.strings.icon_descr_file),
|
stringResource(R.string.icon_descr_file),
|
||||||
Modifier.fillMaxSize(),
|
Modifier.fillMaxSize(),
|
||||||
tint = color
|
tint = color
|
||||||
)
|
)
|
||||||
if (innerIcon != null) {
|
if (innerIcon != null) {
|
||||||
Icon(
|
Icon(
|
||||||
innerIcon,
|
innerIcon,
|
||||||
stringResource(MR.strings.icon_descr_file),
|
stringResource(R.string.icon_descr_file),
|
||||||
Modifier
|
Modifier
|
||||||
.size(32.dp)
|
.size(32.dp)
|
||||||
.padding(top = 12.dp),
|
.padding(top = 12.dp),
|
||||||
@@ -71,12 +73,11 @@ fun CIFileView(
|
|||||||
when (file.fileStatus) {
|
when (file.fileStatus) {
|
||||||
is CIFileStatus.RcvInvitation -> {
|
is CIFileStatus.RcvInvitation -> {
|
||||||
if (fileSizeValid()) {
|
if (fileSizeValid()) {
|
||||||
val encrypted = chatController.appPrefs.privacyEncryptLocalFiles.get()
|
receiveFile(file.fileId)
|
||||||
receiveFile(file.fileId, encrypted)
|
|
||||||
} else {
|
} else {
|
||||||
AlertManager.shared.showAlertMsg(
|
AlertManager.shared.showAlertMsg(
|
||||||
generalGetString(MR.strings.large_file),
|
generalGetString(R.string.large_file),
|
||||||
String.format(generalGetString(MR.strings.contact_sent_large_file), formatBytes(getMaxFileSize(file.fileProtocol)))
|
String.format(generalGetString(R.string.contact_sent_large_file), formatBytes(getMaxFileSize(file.fileProtocol)))
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -84,29 +85,21 @@ fun CIFileView(
|
|||||||
when (file.fileProtocol) {
|
when (file.fileProtocol) {
|
||||||
FileProtocol.XFTP ->
|
FileProtocol.XFTP ->
|
||||||
AlertManager.shared.showAlertMsg(
|
AlertManager.shared.showAlertMsg(
|
||||||
generalGetString(MR.strings.waiting_for_file),
|
generalGetString(R.string.waiting_for_file),
|
||||||
generalGetString(MR.strings.file_will_be_received_when_contact_completes_uploading)
|
generalGetString(R.string.file_will_be_received_when_contact_completes_uploading)
|
||||||
)
|
)
|
||||||
FileProtocol.SMP ->
|
FileProtocol.SMP ->
|
||||||
AlertManager.shared.showAlertMsg(
|
AlertManager.shared.showAlertMsg(
|
||||||
generalGetString(MR.strings.waiting_for_file),
|
generalGetString(R.string.waiting_for_file),
|
||||||
generalGetString(MR.strings.file_will_be_received_when_contact_is_online)
|
generalGetString(R.string.file_will_be_received_when_contact_is_online)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
is CIFileStatus.RcvComplete -> {
|
is CIFileStatus.RcvComplete -> {
|
||||||
withBGApi {
|
val filePath = getLoadedFilePath(context, file)
|
||||||
var filePath = getLoadedFilePath(file)
|
|
||||||
if (chatModel.connectedToRemote() && filePath == null) {
|
|
||||||
file.loadRemoteFile(true)
|
|
||||||
filePath = getLoadedFilePath(file)
|
|
||||||
}
|
|
||||||
if (filePath != null) {
|
if (filePath != null) {
|
||||||
withApi {
|
|
||||||
saveFileLauncher.launch(file.fileName)
|
saveFileLauncher.launch(file.fileName)
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
showToast(generalGetString(MR.strings.file_not_found))
|
Toast.makeText(context, generalGetString(R.string.file_not_found), Toast.LENGTH_SHORT).show()
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
else -> {}
|
else -> {}
|
||||||
@@ -158,15 +151,15 @@ fun CIFileView(
|
|||||||
FileProtocol.XFTP -> progressCircle(file.fileStatus.sndProgress, file.fileStatus.sndTotal)
|
FileProtocol.XFTP -> progressCircle(file.fileStatus.sndProgress, file.fileStatus.sndTotal)
|
||||||
FileProtocol.SMP -> progressIndicator()
|
FileProtocol.SMP -> progressIndicator()
|
||||||
}
|
}
|
||||||
is CIFileStatus.SndComplete -> fileIcon(innerIcon = painterResource(MR.images.ic_check_filled))
|
is CIFileStatus.SndComplete -> fileIcon(innerIcon = painterResource(R.drawable.ic_check_filled))
|
||||||
is CIFileStatus.SndCancelled -> fileIcon(innerIcon = painterResource(MR.images.ic_close))
|
is CIFileStatus.SndCancelled -> fileIcon(innerIcon = painterResource(R.drawable.ic_close))
|
||||||
is CIFileStatus.SndError -> fileIcon(innerIcon = painterResource(MR.images.ic_close))
|
is CIFileStatus.SndError -> fileIcon(innerIcon = painterResource(R.drawable.ic_close))
|
||||||
is CIFileStatus.RcvInvitation ->
|
is CIFileStatus.RcvInvitation ->
|
||||||
if (fileSizeValid())
|
if (fileSizeValid())
|
||||||
fileIcon(innerIcon = painterResource(MR.images.ic_arrow_downward), color = MaterialTheme.colors.primary)
|
fileIcon(innerIcon = painterResource(R.drawable.ic_arrow_downward), color = MaterialTheme.colors.primary)
|
||||||
else
|
else
|
||||||
fileIcon(innerIcon = painterResource(MR.images.ic_priority_high), color = WarningOrange)
|
fileIcon(innerIcon = painterResource(R.drawable.ic_priority_high), color = WarningOrange)
|
||||||
is CIFileStatus.RcvAccepted -> fileIcon(innerIcon = painterResource(MR.images.ic_more_horiz))
|
is CIFileStatus.RcvAccepted -> fileIcon(innerIcon = painterResource(R.drawable.ic_more_horiz))
|
||||||
is CIFileStatus.RcvTransfer ->
|
is CIFileStatus.RcvTransfer ->
|
||||||
if (file.fileProtocol == FileProtocol.XFTP && file.fileStatus.rcvProgress < file.fileStatus.rcvTotal) {
|
if (file.fileProtocol == FileProtocol.XFTP && file.fileStatus.rcvProgress < file.fileStatus.rcvTotal) {
|
||||||
progressCircle(file.fileStatus.rcvProgress, file.fileStatus.rcvTotal)
|
progressCircle(file.fileStatus.rcvProgress, file.fileStatus.rcvTotal)
|
||||||
@@ -174,9 +167,8 @@ fun CIFileView(
|
|||||||
progressIndicator()
|
progressIndicator()
|
||||||
}
|
}
|
||||||
is CIFileStatus.RcvComplete -> fileIcon()
|
is CIFileStatus.RcvComplete -> fileIcon()
|
||||||
is CIFileStatus.RcvCancelled -> fileIcon(innerIcon = painterResource(MR.images.ic_close))
|
is CIFileStatus.RcvCancelled -> fileIcon(innerIcon = painterResource(R.drawable.ic_close))
|
||||||
is CIFileStatus.RcvError -> fileIcon(innerIcon = painterResource(MR.images.ic_close))
|
is CIFileStatus.RcvError -> fileIcon(innerIcon = painterResource(R.drawable.ic_close))
|
||||||
is CIFileStatus.Invalid -> fileIcon(innerIcon = painterResource(MR.images.ic_question_mark))
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
fileIcon()
|
fileIcon()
|
||||||
@@ -213,37 +205,12 @@ fun CIFileView(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun rememberSaveFileLauncher(ciFile: CIFile?): FileChooserLauncher =
|
|
||||||
rememberFileChooserLauncher(false, ciFile) { to: URI? ->
|
|
||||||
val filePath = getLoadedFilePath(ciFile)
|
|
||||||
if (filePath != null && to != null) {
|
|
||||||
if (ciFile?.fileSource?.cryptoArgs != null) {
|
|
||||||
createTmpFileAndDelete { tmpFile ->
|
|
||||||
try {
|
|
||||||
decryptCryptoFile(filePath, ciFile.fileSource.cryptoArgs, tmpFile.absolutePath)
|
|
||||||
} catch (e: Exception) {
|
|
||||||
Log.e(TAG, "Unable to decrypt crypto file: " + e.stackTraceToString())
|
|
||||||
tmpFile.delete()
|
|
||||||
return@createTmpFileAndDelete
|
|
||||||
}
|
|
||||||
copyFileToFile(tmpFile, to) {}
|
|
||||||
tmpFile.delete()
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
copyFileToFile(File(filePath), to) {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/*
|
|
||||||
class ChatItemProvider: PreviewParameterProvider<ChatItem> {
|
class ChatItemProvider: PreviewParameterProvider<ChatItem> {
|
||||||
private val sentFile = ChatItem(
|
private val sentFile = ChatItem(
|
||||||
chatDir = CIDirection.DirectSnd(),
|
chatDir = CIDirection.DirectSnd(),
|
||||||
meta = CIMeta.getSample(1, Clock.System.now(), "", CIStatus.SndSent(), itemEdited = true),
|
meta = CIMeta.getSample(1, Clock.System.now(), "", CIStatus.SndSent(), itemEdited = true),
|
||||||
content = CIContent.SndMsgContent(msgContent = MsgContent.MCFile("")),
|
content = CIContent.SndMsgContent(msgContent = MsgContent.MCFile("")),
|
||||||
quotedItem = null,
|
quotedItem = null,
|
||||||
reactions = listOf(),
|
|
||||||
file = CIFile.getSample(fileStatus = CIFileStatus.SndComplete)
|
file = CIFile.getSample(fileStatus = CIFileStatus.SndComplete)
|
||||||
)
|
)
|
||||||
private val fileChatItemWtFile = ChatItem(
|
private val fileChatItemWtFile = ChatItem(
|
||||||
@@ -251,7 +218,6 @@ class ChatItemProvider: PreviewParameterProvider<ChatItem> {
|
|||||||
meta = CIMeta.getSample(1, Clock.System.now(), "", CIStatus.RcvRead(), ),
|
meta = CIMeta.getSample(1, Clock.System.now(), "", CIStatus.RcvRead(), ),
|
||||||
content = CIContent.RcvMsgContent(msgContent = MsgContent.MCFile("")),
|
content = CIContent.RcvMsgContent(msgContent = MsgContent.MCFile("")),
|
||||||
quotedItem = null,
|
quotedItem = null,
|
||||||
reactions = listOf(),
|
|
||||||
file = null
|
file = null
|
||||||
)
|
)
|
||||||
override val values = listOf(
|
override val values = listOf(
|
||||||
@@ -275,4 +241,4 @@ fun PreviewCIFileFramedItemView(@PreviewParameter(ChatItemProvider::class) chatI
|
|||||||
SimpleXTheme {
|
SimpleXTheme {
|
||||||
FramedItemView(ChatInfo.Direct.sampleData, chatItem, linkMode = SimplexLinkMode.DESCRIPTION, showMenu = showMenu, receiveFile = {})
|
FramedItemView(ChatInfo.Direct.sampleData, chatItem, linkMode = SimplexLinkMode.DESCRIPTION, showMenu = showMenu, receiveFile = {})
|
||||||
}
|
}
|
||||||
}*/
|
}
|
||||||
+31
-60
@@ -1,24 +1,23 @@
|
|||||||
package chat.simplex.common.views.chat.item
|
package chat.simplex.app.views.chat.item
|
||||||
|
|
||||||
import androidx.compose.desktop.ui.tooling.preview.Preview
|
import android.content.res.Configuration
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.*
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material.*
|
import androidx.compose.material.*
|
||||||
import androidx.compose.runtime.*
|
import androidx.compose.runtime.*
|
||||||
import androidx.compose.runtime.saveable.rememberSaveable
|
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import dev.icerock.moko.resources.compose.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.tooling.preview.*
|
||||||
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 chat.simplex.common.ui.theme.*
|
import chat.simplex.app.R
|
||||||
import chat.simplex.common.views.helpers.*
|
import chat.simplex.app.model.*
|
||||||
import chat.simplex.common.model.*
|
import chat.simplex.app.ui.theme.*
|
||||||
import chat.simplex.res.MR
|
import chat.simplex.app.views.helpers.*
|
||||||
import kotlinx.coroutines.delay
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun CIGroupInvitationView(
|
fun CIGroupInvitationView(
|
||||||
@@ -26,26 +25,16 @@ fun CIGroupInvitationView(
|
|||||||
groupInvitation: CIGroupInvitation,
|
groupInvitation: CIGroupInvitation,
|
||||||
memberRole: GroupMemberRole,
|
memberRole: GroupMemberRole,
|
||||||
chatIncognito: Boolean = false,
|
chatIncognito: Boolean = false,
|
||||||
joinGroup: (Long, () -> Unit) -> Unit
|
joinGroup: (Long) -> Unit
|
||||||
) {
|
) {
|
||||||
val sent = ci.chatDir.sent
|
val sent = ci.chatDir.sent
|
||||||
val action = !sent && groupInvitation.status == CIGroupInvitationStatus.Pending
|
val action = !sent && groupInvitation.status == CIGroupInvitationStatus.Pending
|
||||||
val inProgress = remember { mutableStateOf(false) }
|
|
||||||
var progressByTimeout by rememberSaveable { mutableStateOf(false) }
|
|
||||||
LaunchedEffect(inProgress.value) {
|
|
||||||
progressByTimeout = if (inProgress.value) {
|
|
||||||
delay(1000)
|
|
||||||
inProgress.value
|
|
||||||
} else {
|
|
||||||
false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun groupInfoView() {
|
fun groupInfoView() {
|
||||||
val p = groupInvitation.groupProfile
|
val p = groupInvitation.groupProfile
|
||||||
val iconColor =
|
val iconColor =
|
||||||
if (action && !inProgress.value) if (chatIncognito) Indigo else MaterialTheme.colors.primary
|
if (action) if (chatIncognito) Indigo else MaterialTheme.colors.primary
|
||||||
else if (isInDarkTheme()) FileDark else FileLight
|
else if (isInDarkTheme()) FileDark else FileLight
|
||||||
|
|
||||||
Row(
|
Row(
|
||||||
@@ -54,7 +43,7 @@ fun CIGroupInvitationView(
|
|||||||
.padding(vertical = 4.dp)
|
.padding(vertical = 4.dp)
|
||||||
.padding(end = 2.dp)
|
.padding(end = 2.dp)
|
||||||
) {
|
) {
|
||||||
ProfileImage(size = 60.dp, image = groupInvitation.groupProfile.image, icon = MR.images.ic_supervised_user_circle_filled, color = iconColor)
|
ProfileImage(size = 60.dp, image = groupInvitation.groupProfile.image, icon = R.drawable.ic_supervised_user_circle_filled, color = iconColor)
|
||||||
Spacer(Modifier.padding(horizontal = 3.dp))
|
Spacer(Modifier.padding(horizontal = 3.dp))
|
||||||
Column(
|
Column(
|
||||||
Modifier.defaultMinSize(minHeight = 60.dp),
|
Modifier.defaultMinSize(minHeight = 60.dp),
|
||||||
@@ -71,20 +60,19 @@ fun CIGroupInvitationView(
|
|||||||
@Composable
|
@Composable
|
||||||
fun groupInvitationText() {
|
fun groupInvitationText() {
|
||||||
when {
|
when {
|
||||||
sent -> Text(stringResource(MR.strings.you_sent_group_invitation))
|
sent -> Text(stringResource(R.string.you_sent_group_invitation))
|
||||||
!sent && groupInvitation.status == CIGroupInvitationStatus.Pending -> Text(stringResource(MR.strings.you_are_invited_to_group))
|
!sent && groupInvitation.status == CIGroupInvitationStatus.Pending -> Text(stringResource(R.string.you_are_invited_to_group))
|
||||||
!sent && groupInvitation.status == CIGroupInvitationStatus.Accepted -> Text(stringResource(MR.strings.you_joined_this_group))
|
!sent && groupInvitation.status == CIGroupInvitationStatus.Accepted -> Text(stringResource(R.string.you_joined_this_group))
|
||||||
!sent && groupInvitation.status == CIGroupInvitationStatus.Rejected -> Text(stringResource(MR.strings.you_rejected_group_invitation))
|
!sent && groupInvitation.status == CIGroupInvitationStatus.Rejected -> Text(stringResource(R.string.you_rejected_group_invitation))
|
||||||
!sent && groupInvitation.status == CIGroupInvitationStatus.Expired -> Text(stringResource(MR.strings.group_invitation_expired))
|
!sent && groupInvitation.status == CIGroupInvitationStatus.Expired -> Text(stringResource(R.string.group_invitation_expired))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
val sentColor = CurrentColors.collectAsState().value.appColors.sentMessage
|
val sentColor = CurrentColors.collectAsState().value.appColors.sentMessage
|
||||||
val receivedColor = CurrentColors.collectAsState().value.appColors.receivedMessage
|
val receivedColor = CurrentColors.collectAsState().value.appColors.receivedMessage
|
||||||
Surface(
|
Surface(
|
||||||
modifier = if (action && !inProgress.value) Modifier.clickable(onClick = {
|
modifier = if (action) Modifier.clickable(onClick = {
|
||||||
inProgress.value = true
|
joinGroup(groupInvitation.groupId)
|
||||||
joinGroup(groupInvitation.groupId) { inProgress.value = false }
|
|
||||||
}) else Modifier,
|
}) else Modifier,
|
||||||
shape = RoundedCornerShape(18.dp),
|
shape = RoundedCornerShape(18.dp),
|
||||||
color = if (sent) sentColor else receivedColor,
|
color = if (sent) sentColor else receivedColor,
|
||||||
@@ -95,9 +83,6 @@ fun CIGroupInvitationView(
|
|||||||
.padding(vertical = 3.dp)
|
.padding(vertical = 3.dp)
|
||||||
.padding(start = 8.dp, end = 12.dp),
|
.padding(start = 8.dp, end = 12.dp),
|
||||||
contentAlignment = Alignment.BottomEnd
|
contentAlignment = Alignment.BottomEnd
|
||||||
) {
|
|
||||||
Box(
|
|
||||||
contentAlignment = Alignment.Center
|
|
||||||
) {
|
) {
|
||||||
Column(
|
Column(
|
||||||
Modifier
|
Modifier
|
||||||
@@ -109,15 +94,9 @@ fun CIGroupInvitationView(
|
|||||||
Divider(Modifier.fillMaxWidth().padding(bottom = 4.dp))
|
Divider(Modifier.fillMaxWidth().padding(bottom = 4.dp))
|
||||||
if (action) {
|
if (action) {
|
||||||
groupInvitationText()
|
groupInvitationText()
|
||||||
Text(
|
Text(stringResource(
|
||||||
stringResource(
|
if (chatIncognito) R.string.group_invitation_tap_to_join_incognito else R.string.group_invitation_tap_to_join),
|
||||||
if (chatIncognito) MR.strings.group_invitation_tap_to_join_incognito else MR.strings.group_invitation_tap_to_join
|
color = if (chatIncognito) Indigo else MaterialTheme.colors.primary)
|
||||||
),
|
|
||||||
color = if (inProgress.value)
|
|
||||||
MaterialTheme.colors.secondary
|
|
||||||
else
|
|
||||||
if (chatIncognito) Indigo else MaterialTheme.colors.primary
|
|
||||||
)
|
|
||||||
} else {
|
} else {
|
||||||
Box(Modifier.padding(end = 48.dp)) {
|
Box(Modifier.padding(end = 48.dp)) {
|
||||||
groupInvitationText()
|
groupInvitationText()
|
||||||
@@ -125,16 +104,6 @@ fun CIGroupInvitationView(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (progressByTimeout) {
|
|
||||||
CircularProgressIndicator(
|
|
||||||
Modifier.size(32.dp),
|
|
||||||
color = if (isInDarkTheme()) FileDark else FileLight,
|
|
||||||
strokeWidth = 3.dp
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Text(
|
Text(
|
||||||
ci.timestampText,
|
ci.timestampText,
|
||||||
color = MaterialTheme.colors.secondary,
|
color = MaterialTheme.colors.secondary,
|
||||||
@@ -145,10 +114,11 @@ fun CIGroupInvitationView(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Preview/*(
|
@Preview(showBackground = true)
|
||||||
|
@Preview(
|
||||||
uiMode = Configuration.UI_MODE_NIGHT_YES,
|
uiMode = Configuration.UI_MODE_NIGHT_YES,
|
||||||
name = "Dark Mode"
|
name = "Dark Mode"
|
||||||
)*/
|
)
|
||||||
@Composable
|
@Composable
|
||||||
fun PendingCIGroupInvitationViewPreview() {
|
fun PendingCIGroupInvitationViewPreview() {
|
||||||
SimpleXTheme {
|
SimpleXTheme {
|
||||||
@@ -156,15 +126,16 @@ fun PendingCIGroupInvitationViewPreview() {
|
|||||||
ci = ChatItem.getGroupInvitationSample(),
|
ci = ChatItem.getGroupInvitationSample(),
|
||||||
groupInvitation = CIGroupInvitation.getSample(),
|
groupInvitation = CIGroupInvitation.getSample(),
|
||||||
memberRole = GroupMemberRole.Admin,
|
memberRole = GroupMemberRole.Admin,
|
||||||
joinGroup = { _, _ -> }
|
joinGroup = {}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Preview/*(
|
@Preview(showBackground = true)
|
||||||
|
@Preview(
|
||||||
uiMode = Configuration.UI_MODE_NIGHT_YES,
|
uiMode = Configuration.UI_MODE_NIGHT_YES,
|
||||||
name = "Dark Mode"
|
name = "Dark Mode"
|
||||||
)*/
|
)
|
||||||
@Composable
|
@Composable
|
||||||
fun CIGroupInvitationViewAcceptedPreview() {
|
fun CIGroupInvitationViewAcceptedPreview() {
|
||||||
SimpleXTheme {
|
SimpleXTheme {
|
||||||
@@ -172,12 +143,12 @@ fun CIGroupInvitationViewAcceptedPreview() {
|
|||||||
ci = ChatItem.getGroupInvitationSample(),
|
ci = ChatItem.getGroupInvitationSample(),
|
||||||
groupInvitation = CIGroupInvitation.getSample(status = CIGroupInvitationStatus.Accepted),
|
groupInvitation = CIGroupInvitation.getSample(status = CIGroupInvitationStatus.Accepted),
|
||||||
memberRole = GroupMemberRole.Admin,
|
memberRole = GroupMemberRole.Admin,
|
||||||
joinGroup = { _, _ -> }
|
joinGroup = {}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Preview
|
@Preview(showBackground = true)
|
||||||
@Composable
|
@Composable
|
||||||
fun CIGroupInvitationViewLongNamePreview() {
|
fun CIGroupInvitationViewLongNamePreview() {
|
||||||
SimpleXTheme {
|
SimpleXTheme {
|
||||||
@@ -188,7 +159,7 @@ fun CIGroupInvitationViewLongNamePreview() {
|
|||||||
status = CIGroupInvitationStatus.Accepted
|
status = CIGroupInvitationStatus.Accepted
|
||||||
),
|
),
|
||||||
memberRole = GroupMemberRole.Admin,
|
memberRole = GroupMemberRole.Admin,
|
||||||
joinGroup = { _, _ -> }
|
joinGroup = {}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,215 @@
|
|||||||
|
package chat.simplex.app.views.chat.item
|
||||||
|
|
||||||
|
import android.graphics.Bitmap
|
||||||
|
import android.os.Build.VERSION.SDK_INT
|
||||||
|
import androidx.annotation.StringRes
|
||||||
|
import androidx.compose.foundation.Image
|
||||||
|
import androidx.compose.foundation.combinedClickable
|
||||||
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.material.CircularProgressIndicator
|
||||||
|
import androidx.compose.material.Icon
|
||||||
|
import androidx.compose.runtime.*
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.asImageBitmap
|
||||||
|
import androidx.compose.ui.graphics.painter.BitmapPainter
|
||||||
|
import androidx.compose.ui.graphics.painter.Painter
|
||||||
|
import androidx.compose.ui.layout.ContentScale
|
||||||
|
import androidx.compose.ui.layout.layoutId
|
||||||
|
import androidx.compose.ui.platform.*
|
||||||
|
import androidx.compose.ui.res.painterResource
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.unit.Dp
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.core.content.FileProvider
|
||||||
|
import chat.simplex.app.*
|
||||||
|
import chat.simplex.app.R
|
||||||
|
import chat.simplex.app.model.*
|
||||||
|
import chat.simplex.app.views.helpers.*
|
||||||
|
import coil.ImageLoader
|
||||||
|
import coil.compose.rememberAsyncImagePainter
|
||||||
|
import coil.decode.GifDecoder
|
||||||
|
import coil.decode.ImageDecoderDecoder
|
||||||
|
import coil.request.ImageRequest
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun CIImageView(
|
||||||
|
image: String,
|
||||||
|
file: CIFile?,
|
||||||
|
imageProvider: () -> ImageGalleryProvider,
|
||||||
|
showMenu: MutableState<Boolean>,
|
||||||
|
receiveFile: (Long) -> Unit
|
||||||
|
) {
|
||||||
|
@Composable
|
||||||
|
fun progressIndicator() {
|
||||||
|
CircularProgressIndicator(
|
||||||
|
Modifier.size(16.dp),
|
||||||
|
color = Color.White,
|
||||||
|
strokeWidth = 2.dp
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun fileIcon(icon: Painter, @StringRes stringId: Int) {
|
||||||
|
Icon(
|
||||||
|
icon,
|
||||||
|
stringResource(stringId),
|
||||||
|
Modifier.fillMaxSize(),
|
||||||
|
tint = Color.White
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun loadingIndicator() {
|
||||||
|
if (file != null) {
|
||||||
|
Box(
|
||||||
|
Modifier
|
||||||
|
.padding(8.dp)
|
||||||
|
.size(20.dp),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
when (file.fileStatus) {
|
||||||
|
is CIFileStatus.SndStored ->
|
||||||
|
when (file.fileProtocol) {
|
||||||
|
FileProtocol.XFTP -> progressIndicator()
|
||||||
|
FileProtocol.SMP -> {}
|
||||||
|
}
|
||||||
|
is CIFileStatus.SndTransfer -> progressIndicator()
|
||||||
|
is CIFileStatus.SndComplete -> fileIcon(painterResource(R.drawable.ic_check_filled), R.string.icon_descr_image_snd_complete)
|
||||||
|
is CIFileStatus.SndCancelled -> fileIcon(painterResource(R.drawable.ic_close), R.string.icon_descr_file)
|
||||||
|
is CIFileStatus.SndError -> fileIcon(painterResource(R.drawable.ic_close), R.string.icon_descr_file)
|
||||||
|
is CIFileStatus.RcvInvitation -> fileIcon(painterResource(R.drawable.ic_arrow_downward), R.string.icon_descr_asked_to_receive)
|
||||||
|
is CIFileStatus.RcvAccepted -> fileIcon(painterResource(R.drawable.ic_more_horiz), R.string.icon_descr_waiting_for_image)
|
||||||
|
is CIFileStatus.RcvTransfer -> progressIndicator()
|
||||||
|
is CIFileStatus.RcvCancelled -> fileIcon(painterResource(R.drawable.ic_close), R.string.icon_descr_file)
|
||||||
|
is CIFileStatus.RcvError -> fileIcon(painterResource(R.drawable.ic_close), R.string.icon_descr_file)
|
||||||
|
else -> {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun imageViewFullWidth(): Dp {
|
||||||
|
val approximatePadding = 100.dp
|
||||||
|
return with(LocalDensity.current) { minOf(1000.dp, LocalView.current.width.toDp() - approximatePadding) }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun imageView(imageBitmap: Bitmap, onClick: () -> Unit) {
|
||||||
|
Image(
|
||||||
|
imageBitmap.asImageBitmap(),
|
||||||
|
contentDescription = stringResource(R.string.image_descr),
|
||||||
|
// .width(1000.dp) is a hack for image to increase IntrinsicSize of FramedItemView
|
||||||
|
// if text is short and take all available width if text is long
|
||||||
|
modifier = Modifier
|
||||||
|
.width(if (imageBitmap.width * 0.97 <= imageBitmap.height) imageViewFullWidth() * 0.75f else 1000.dp)
|
||||||
|
.combinedClickable(
|
||||||
|
onLongClick = { showMenu.value = true },
|
||||||
|
onClick = onClick
|
||||||
|
),
|
||||||
|
contentScale = ContentScale.FillWidth,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun imageView(painter: Painter, onClick: () -> Unit) {
|
||||||
|
Image(
|
||||||
|
painter,
|
||||||
|
contentDescription = stringResource(R.string.image_descr),
|
||||||
|
// .width(1000.dp) is a hack for image to increase IntrinsicSize of FramedItemView
|
||||||
|
// if text is short and take all available width if text is long
|
||||||
|
modifier = Modifier
|
||||||
|
.width(if (painter.intrinsicSize.width * 0.97 <= painter.intrinsicSize.height) imageViewFullWidth() * 0.75f else 1000.dp)
|
||||||
|
.combinedClickable(
|
||||||
|
onLongClick = { showMenu.value = true },
|
||||||
|
onClick = onClick
|
||||||
|
),
|
||||||
|
contentScale = ContentScale.FillWidth,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun fileSizeValid(): Boolean {
|
||||||
|
if (file != null) {
|
||||||
|
return file.fileSize <= getMaxFileSize(file.fileProtocol)
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
fun imageAndFilePath(file: CIFile?): Pair<Bitmap?, String?> {
|
||||||
|
val imageBitmap: Bitmap? = getLoadedImage(SimplexApp.context, file)
|
||||||
|
val filePath = getLoadedFilePath(SimplexApp.context, file)
|
||||||
|
return imageBitmap to filePath
|
||||||
|
}
|
||||||
|
|
||||||
|
Box(
|
||||||
|
Modifier.layoutId(CHAT_IMAGE_LAYOUT_ID),
|
||||||
|
contentAlignment = Alignment.TopEnd
|
||||||
|
) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
val (imageBitmap, filePath) = remember(file) { imageAndFilePath(file) }
|
||||||
|
if (imageBitmap != null && filePath != null) {
|
||||||
|
val uri = remember(filePath) { FileProvider.getUriForFile(context, "${BuildConfig.APPLICATION_ID}.provider", File(filePath)) }
|
||||||
|
val imagePainter = rememberAsyncImagePainter(
|
||||||
|
ImageRequest.Builder(context).data(data = uri).size(coil.size.Size.ORIGINAL).build(),
|
||||||
|
placeholder = BitmapPainter(imageBitmap.asImageBitmap()), // show original image while it's still loading by coil
|
||||||
|
imageLoader = imageLoader
|
||||||
|
)
|
||||||
|
val view = LocalView.current
|
||||||
|
imageView(imagePainter, onClick = {
|
||||||
|
hideKeyboard(view)
|
||||||
|
if (getLoadedFilePath(context, file) != null) {
|
||||||
|
ModalManager.shared.showCustomModal(animated = false) { close ->
|
||||||
|
ImageFullScreenView(imageProvider, close)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
imageView(base64ToBitmap(image), onClick = {
|
||||||
|
if (file != null) {
|
||||||
|
when (file.fileStatus) {
|
||||||
|
CIFileStatus.RcvInvitation ->
|
||||||
|
if (fileSizeValid()) {
|
||||||
|
receiveFile(file.fileId)
|
||||||
|
} else {
|
||||||
|
AlertManager.shared.showAlertMsg(
|
||||||
|
generalGetString(R.string.large_file),
|
||||||
|
String.format(generalGetString(R.string.contact_sent_large_file), formatBytes(getMaxFileSize(file.fileProtocol)))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
CIFileStatus.RcvAccepted ->
|
||||||
|
when (file.fileProtocol) {
|
||||||
|
FileProtocol.XFTP ->
|
||||||
|
AlertManager.shared.showAlertMsg(
|
||||||
|
generalGetString(R.string.waiting_for_image),
|
||||||
|
generalGetString(R.string.image_will_be_received_when_contact_completes_uploading)
|
||||||
|
)
|
||||||
|
FileProtocol.SMP ->
|
||||||
|
AlertManager.shared.showAlertMsg(
|
||||||
|
generalGetString(R.string.waiting_for_image),
|
||||||
|
generalGetString(R.string.image_will_be_received_when_contact_is_online)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
CIFileStatus.RcvTransfer(rcvProgress = 7, rcvTotal = 10) -> {} // ?
|
||||||
|
CIFileStatus.RcvComplete -> {} // ?
|
||||||
|
CIFileStatus.RcvCancelled -> {} // TODO
|
||||||
|
else -> {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
loadingIndicator()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private val imageLoader = ImageLoader.Builder(SimplexApp.context)
|
||||||
|
.components {
|
||||||
|
if (SDK_INT >= 28) {
|
||||||
|
add(ImageDecoderDecoder.Factory())
|
||||||
|
} else {
|
||||||
|
add(GifDecoder.Factory())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.build()
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
package chat.simplex.app.views.chat.item
|
||||||
|
|
||||||
|
import SectionSpacer
|
||||||
|
import SectionView
|
||||||
|
import androidx.compose.foundation.*
|
||||||
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.material.*
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.res.painterResource
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.font.FontStyle
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import chat.simplex.app.R
|
||||||
|
import chat.simplex.app.ui.theme.DEFAULT_PADDING
|
||||||
|
import chat.simplex.app.views.helpers.*
|
||||||
|
import chat.simplex.app.views.usersettings.SettingsActionItem
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun CIInvalidJSONView(json: String) {
|
||||||
|
Row(Modifier
|
||||||
|
.clickable { ModalManager.shared.showModal(true) { InvalidJSONView(json) } }
|
||||||
|
.padding(horizontal = 10.dp, vertical = 6.dp)
|
||||||
|
) {
|
||||||
|
Text(stringResource(R.string.invalid_data), color = Color.Red, fontStyle = FontStyle.Italic)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun InvalidJSONView(json: String) {
|
||||||
|
Column {
|
||||||
|
Spacer(Modifier.height(DEFAULT_PADDING))
|
||||||
|
SectionView {
|
||||||
|
val context = LocalContext.current
|
||||||
|
SettingsActionItem(painterResource(R.drawable.ic_share), generalGetString(R.string.share_verb), click = {
|
||||||
|
shareText(context, json)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
Column(Modifier.padding(DEFAULT_PADDING).fillMaxWidth().verticalScroll(rememberScrollState())) {
|
||||||
|
Text(json)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+21
-48
@@ -1,91 +1,67 @@
|
|||||||
package chat.simplex.common.views.chat.item
|
package chat.simplex.app.views.chat.item
|
||||||
|
|
||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.*
|
||||||
import androidx.compose.material.*
|
import androidx.compose.material.*
|
||||||
import androidx.compose.ui.graphics.painter.Painter
|
import androidx.compose.ui.graphics.painter.Painter
|
||||||
import dev.icerock.moko.resources.compose.painterResource
|
import androidx.compose.ui.res.painterResource
|
||||||
|
import chat.simplex.app.R
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.desktop.ui.tooling.preview.Preview
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
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 chat.simplex.common.ui.theme.CurrentColors
|
import chat.simplex.app.model.*
|
||||||
import chat.simplex.common.model.*
|
import chat.simplex.app.ui.theme.CurrentColors
|
||||||
import chat.simplex.common.ui.theme.isInDarkTheme
|
|
||||||
import chat.simplex.res.MR
|
|
||||||
import kotlinx.datetime.Clock
|
import kotlinx.datetime.Clock
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun CIMetaView(
|
fun CIMetaView(chatItem: ChatItem, timedMessagesTTL: Int?, metaColor: Color = MaterialTheme.colors.secondary) {
|
||||||
chatItem: ChatItem,
|
|
||||||
timedMessagesTTL: Int?,
|
|
||||||
metaColor: Color = MaterialTheme.colors.secondary,
|
|
||||||
paleMetaColor: Color = if (isInDarkTheme()) {
|
|
||||||
metaColor.copy(
|
|
||||||
red = metaColor.red * 0.67F,
|
|
||||||
green = metaColor.green * 0.67F,
|
|
||||||
blue = metaColor.red * 0.67F)
|
|
||||||
} else {
|
|
||||||
metaColor.copy(
|
|
||||||
red = minOf(metaColor.red * 1.33F, 1F),
|
|
||||||
green = minOf(metaColor.green * 1.33F, 1F),
|
|
||||||
blue = minOf(metaColor.red * 1.33F, 1F))
|
|
||||||
}
|
|
||||||
) {
|
|
||||||
Row(Modifier.padding(start = 3.dp), verticalAlignment = Alignment.CenterVertically) {
|
Row(Modifier.padding(start = 3.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||||
if (chatItem.isDeletedContent) {
|
if (chatItem.isDeletedContent) {
|
||||||
Text(
|
Text(
|
||||||
chatItem.timestampText,
|
chatItem.timestampText,
|
||||||
color = metaColor,
|
color = metaColor,
|
||||||
fontSize = 12.sp,
|
fontSize = 14.sp,
|
||||||
modifier = Modifier.padding(start = 3.dp)
|
modifier = Modifier.padding(start = 3.dp)
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
CIMetaText(chatItem.meta, timedMessagesTTL, encrypted = chatItem.encryptedFile, metaColor, paleMetaColor)
|
CIMetaText(chatItem.meta, timedMessagesTTL, metaColor)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
// changing this function requires updating reserveSpaceForMeta
|
// changing this function requires updating reserveSpaceForMeta
|
||||||
private fun CIMetaText(meta: CIMeta, chatTTL: Int?, encrypted: Boolean?, color: Color, paleColor: Color) {
|
private fun CIMetaText(meta: CIMeta, chatTTL: Int?, color: Color) {
|
||||||
if (meta.itemEdited) {
|
if (meta.itemEdited) {
|
||||||
StatusIconText(painterResource(MR.images.ic_edit), color)
|
StatusIconText(painterResource(R.drawable.ic_edit), color)
|
||||||
Spacer(Modifier.width(3.dp))
|
Spacer(Modifier.width(3.dp))
|
||||||
}
|
}
|
||||||
if (meta.disappearing) {
|
if (meta.disappearing) {
|
||||||
StatusIconText(painterResource(MR.images.ic_timer), color)
|
StatusIconText(painterResource(R.drawable.ic_timer), color)
|
||||||
val ttl = meta.itemTimed?.ttl
|
val ttl = meta.itemTimed?.ttl
|
||||||
if (ttl != chatTTL) {
|
if (ttl != chatTTL) {
|
||||||
Text(shortTimeText(ttl), color = color, fontSize = 12.sp)
|
Text(TimedMessagesPreference.shortTtlText(ttl), color = color, fontSize = 13.sp)
|
||||||
}
|
}
|
||||||
Spacer(Modifier.width(4.dp))
|
Spacer(Modifier.width(4.dp))
|
||||||
}
|
}
|
||||||
val statusIcon = meta.statusIcon(MaterialTheme.colors.primary, color, paleColor)
|
val statusIcon = meta.statusIcon(MaterialTheme.colors.primary, color)
|
||||||
if (statusIcon != null) {
|
if (statusIcon != null) {
|
||||||
val (icon, statusColor) = statusIcon
|
val (icon, statusColor) = statusIcon
|
||||||
if (meta.itemStatus is CIStatus.SndSent || meta.itemStatus is CIStatus.SndRcvd) {
|
|
||||||
Icon(painterResource(icon), null, Modifier.height(17.dp), tint = statusColor)
|
|
||||||
} else {
|
|
||||||
StatusIconText(painterResource(icon), statusColor)
|
StatusIconText(painterResource(icon), statusColor)
|
||||||
}
|
|
||||||
Spacer(Modifier.width(4.dp))
|
Spacer(Modifier.width(4.dp))
|
||||||
} else if (!meta.disappearing) {
|
} else if (!meta.disappearing) {
|
||||||
StatusIconText(painterResource(MR.images.ic_circle_filled), Color.Transparent)
|
StatusIconText(painterResource(R.drawable.ic_circle_filled), Color.Transparent)
|
||||||
Spacer(Modifier.width(4.dp))
|
Spacer(Modifier.width(4.dp))
|
||||||
}
|
}
|
||||||
if (encrypted != null) {
|
Text(meta.timestampText, color = color, fontSize = 13.sp, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||||
StatusIconText(painterResource(if (encrypted) MR.images.ic_lock else MR.images.ic_lock_open_right), color)
|
|
||||||
Spacer(Modifier.width(4.dp))
|
|
||||||
}
|
|
||||||
Text(meta.timestampText, color = color, fontSize = 12.sp, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// the conditions in this function should match CIMetaText
|
// the conditions in this function should match CIMetaText
|
||||||
fun reserveSpaceForMeta(meta: CIMeta, chatTTL: Int?, encrypted: Boolean?): String {
|
fun reserveSpaceForMeta(meta: CIMeta, chatTTL: Int?): String {
|
||||||
val iconSpace = " "
|
val iconSpace = " "
|
||||||
var res = ""
|
var res = ""
|
||||||
if (meta.itemEdited) res += iconSpace
|
if (meta.itemEdited) res += iconSpace
|
||||||
@@ -93,15 +69,12 @@ fun reserveSpaceForMeta(meta: CIMeta, chatTTL: Int?, encrypted: Boolean?): Strin
|
|||||||
res += iconSpace
|
res += iconSpace
|
||||||
val ttl = meta.itemTimed.ttl
|
val ttl = meta.itemTimed.ttl
|
||||||
if (ttl != chatTTL) {
|
if (ttl != chatTTL) {
|
||||||
res += shortTimeText(ttl)
|
res += TimedMessagesPreference.shortTtlText(ttl)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (meta.statusIcon(CurrentColors.value.colors.secondary) != null || !meta.disappearing) {
|
if (meta.statusIcon(CurrentColors.value.colors.secondary) != null || !meta.disappearing) {
|
||||||
res += iconSpace
|
res += iconSpace
|
||||||
}
|
}
|
||||||
if (encrypted != null) {
|
|
||||||
res += iconSpace
|
|
||||||
}
|
|
||||||
return res + meta.timestampText
|
return res + meta.timestampText
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -161,7 +134,7 @@ fun PreviewCIMetaViewSendNoAuth() {
|
|||||||
fun PreviewCIMetaViewSendSent() {
|
fun PreviewCIMetaViewSendSent() {
|
||||||
CIMetaView(
|
CIMetaView(
|
||||||
chatItem = ChatItem.getSampleData(
|
chatItem = ChatItem.getSampleData(
|
||||||
1, CIDirection.DirectSnd(), Clock.System.now(), "hello", status = CIStatus.SndSent(SndCIStatusProgress.Complete)
|
1, CIDirection.DirectSnd(), Clock.System.now(), "hello", status = CIStatus.SndSent()
|
||||||
),
|
),
|
||||||
null
|
null
|
||||||
)
|
)
|
||||||
@@ -186,7 +159,7 @@ fun PreviewCIMetaViewEditedUnread() {
|
|||||||
chatItem = ChatItem.getSampleData(
|
chatItem = ChatItem.getSampleData(
|
||||||
1, CIDirection.DirectRcv(), Clock.System.now(), "hello",
|
1, CIDirection.DirectRcv(), Clock.System.now(), "hello",
|
||||||
itemEdited = true,
|
itemEdited = true,
|
||||||
status= CIStatus.RcvNew()
|
status=CIStatus.RcvNew()
|
||||||
),
|
),
|
||||||
null
|
null
|
||||||
)
|
)
|
||||||
@@ -199,7 +172,7 @@ fun PreviewCIMetaViewEditedSent() {
|
|||||||
chatItem = ChatItem.getSampleData(
|
chatItem = ChatItem.getSampleData(
|
||||||
1, CIDirection.DirectSnd(), Clock.System.now(), "hello",
|
1, CIDirection.DirectSnd(), Clock.System.now(), "hello",
|
||||||
itemEdited = true,
|
itemEdited = true,
|
||||||
status= CIStatus.SndSent(SndCIStatusProgress.Complete)
|
status=CIStatus.SndSent()
|
||||||
),
|
),
|
||||||
null
|
null
|
||||||
)
|
)
|
||||||
+24
@@ -0,0 +1,24 @@
|
|||||||
|
package chat.simplex.app.views.chat.item
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import chat.simplex.app.R
|
||||||
|
import chat.simplex.app.model.*
|
||||||
|
import chat.simplex.app.views.helpers.AlertManager
|
||||||
|
import chat.simplex.app.views.helpers.generalGetString
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun CIRcvDecryptionError(msgDecryptError: MsgDecryptError, msgCount: UInt, ci: ChatItem, timedMessagesTTL: Int?, showMember: Boolean) {
|
||||||
|
CIMsgError(ci, timedMessagesTTL, showMember) {
|
||||||
|
AlertManager.shared.showAlertMsg(
|
||||||
|
title = generalGetString(R.string.decryption_error),
|
||||||
|
text = when (msgDecryptError) {
|
||||||
|
MsgDecryptError.RatchetHeader -> String.format(generalGetString(R.string.alert_text_decryption_error_header), msgCount.toLong()) + "\n" +
|
||||||
|
generalGetString(R.string.alert_text_fragment_encryption_out_of_sync_old_database) + "\n" +
|
||||||
|
generalGetString(R.string.alert_text_fragment_permanent_error_reconnect)
|
||||||
|
MsgDecryptError.TooManySkipped -> String.format(generalGetString(R.string.alert_text_decryption_error_too_many_skipped), msgCount.toLong()) + "\n" +
|
||||||
|
generalGetString(R.string.alert_text_fragment_encryption_out_of_sync_old_database) + "\n" +
|
||||||
|
generalGetString(R.string.alert_text_fragment_permanent_error_reconnect)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
+85
-81
@@ -1,5 +1,9 @@
|
|||||||
package chat.simplex.common.views.chat.item
|
package chat.simplex.app.views.chat.item
|
||||||
|
|
||||||
|
import android.graphics.Bitmap
|
||||||
|
import android.graphics.Rect
|
||||||
|
import android.net.Uri
|
||||||
|
import androidx.annotation.StringRes
|
||||||
import androidx.compose.foundation.*
|
import androidx.compose.foundation.*
|
||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.*
|
||||||
import androidx.compose.foundation.shape.CornerSize
|
import androidx.compose.foundation.shape.CornerSize
|
||||||
@@ -8,22 +12,25 @@ import androidx.compose.material.*
|
|||||||
import androidx.compose.runtime.*
|
import androidx.compose.runtime.*
|
||||||
import androidx.compose.runtime.saveable.rememberSaveable
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
import androidx.compose.ui.*
|
import androidx.compose.ui.*
|
||||||
import androidx.compose.ui.graphics.*
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.asImageBitmap
|
||||||
import androidx.compose.ui.graphics.painter.Painter
|
import androidx.compose.ui.graphics.painter.Painter
|
||||||
import androidx.compose.ui.layout.*
|
import androidx.compose.ui.layout.*
|
||||||
import androidx.compose.ui.platform.*
|
import androidx.compose.ui.platform.*
|
||||||
import dev.icerock.moko.resources.compose.painterResource
|
import androidx.compose.ui.res.painterResource
|
||||||
import dev.icerock.moko.resources.compose.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.unit.*
|
import androidx.compose.ui.unit.*
|
||||||
import chat.simplex.res.MR
|
import androidx.compose.ui.viewinterop.AndroidView
|
||||||
import chat.simplex.common.ui.theme.*
|
import androidx.core.content.FileProvider
|
||||||
import chat.simplex.common.views.helpers.*
|
import androidx.core.graphics.drawable.toDrawable
|
||||||
import chat.simplex.common.model.*
|
import chat.simplex.app.*
|
||||||
import chat.simplex.common.platform.*
|
import chat.simplex.app.R
|
||||||
import dev.icerock.moko.resources.StringResource
|
import chat.simplex.app.model.*
|
||||||
import kotlinx.coroutines.flow.*
|
import chat.simplex.app.ui.theme.*
|
||||||
|
import chat.simplex.app.views.helpers.*
|
||||||
|
import com.google.android.exoplayer2.ui.AspectRatioFrameLayout.RESIZE_MODE_FIXED_WIDTH
|
||||||
|
import com.google.android.exoplayer2.ui.StyledPlayerView
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import java.net.URI
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun CIVideoView(
|
fun CIVideoView(
|
||||||
@@ -32,53 +39,43 @@ fun CIVideoView(
|
|||||||
file: CIFile?,
|
file: CIFile?,
|
||||||
imageProvider: () -> ImageGalleryProvider,
|
imageProvider: () -> ImageGalleryProvider,
|
||||||
showMenu: MutableState<Boolean>,
|
showMenu: MutableState<Boolean>,
|
||||||
receiveFile: (Long, Boolean) -> Unit
|
receiveFile: (Long) -> Unit
|
||||||
) {
|
) {
|
||||||
Box(
|
Box(
|
||||||
Modifier.layoutId(CHAT_IMAGE_LAYOUT_ID),
|
Modifier.layoutId(CHAT_IMAGE_LAYOUT_ID),
|
||||||
contentAlignment = Alignment.TopEnd
|
contentAlignment = Alignment.TopEnd
|
||||||
) {
|
) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
val filePath = remember(file) { getLoadedFilePath(SimplexApp.context, file) }
|
||||||
val preview = remember(image) { base64ToBitmap(image) }
|
val preview = remember(image) { base64ToBitmap(image) }
|
||||||
val filePath = remember(file, CIFile.cachedRemoteFileRequests.toList()) { mutableStateOf(getLoadedFilePath(file)) }
|
if (file != null && filePath != null) {
|
||||||
if (chatModel.connectedToRemote()) {
|
val uri = remember(filePath) { FileProvider.getUriForFile(context, "${BuildConfig.APPLICATION_ID}.provider", File(filePath)) }
|
||||||
LaunchedEffect(file) {
|
val view = LocalView.current
|
||||||
withBGApi {
|
|
||||||
if (file != null && file.loaded && getLoadedFilePath(file) == null) {
|
|
||||||
file.loadRemoteFile(false)
|
|
||||||
filePath.value = getLoadedFilePath(file)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
val f = filePath.value
|
|
||||||
if (file != null && f != null) {
|
|
||||||
val uri = remember(filePath) { getAppFileUri(f.substringAfterLast(File.separator)) }
|
|
||||||
val view = LocalMultiplatformView()
|
|
||||||
VideoView(uri, file, preview, duration * 1000L, showMenu, onClick = {
|
VideoView(uri, file, preview, duration * 1000L, showMenu, onClick = {
|
||||||
hideKeyboard(view)
|
hideKeyboard(view)
|
||||||
ModalManager.fullscreen.showCustomModal(animated = false) { close ->
|
ModalManager.shared.showCustomModal(animated = false) { close ->
|
||||||
ImageFullScreenView(imageProvider, close)
|
ImageFullScreenView(imageProvider, close)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
Box {
|
Box {
|
||||||
VideoPreviewImageView(preview, onClick = {
|
ImageView(preview, showMenu, onClick = {
|
||||||
if (file != null) {
|
if (file != null) {
|
||||||
when (file.fileStatus) {
|
when (file.fileStatus) {
|
||||||
CIFileStatus.RcvInvitation ->
|
CIFileStatus.RcvInvitation ->
|
||||||
receiveFileIfValidSize(file, encrypted = false, receiveFile)
|
receiveFileIfValidSize(file, receiveFile)
|
||||||
CIFileStatus.RcvAccepted ->
|
CIFileStatus.RcvAccepted ->
|
||||||
when (file.fileProtocol) {
|
when (file.fileProtocol) {
|
||||||
FileProtocol.XFTP ->
|
FileProtocol.XFTP ->
|
||||||
AlertManager.shared.showAlertMsg(
|
AlertManager.shared.showAlertMsg(
|
||||||
generalGetString(MR.strings.waiting_for_video),
|
generalGetString(R.string.waiting_for_video),
|
||||||
generalGetString(MR.strings.video_will_be_received_when_contact_completes_uploading)
|
generalGetString(R.string.video_will_be_received_when_contact_completes_uploading)
|
||||||
)
|
)
|
||||||
|
|
||||||
FileProtocol.SMP ->
|
FileProtocol.SMP ->
|
||||||
AlertManager.shared.showAlertMsg(
|
AlertManager.shared.showAlertMsg(
|
||||||
generalGetString(MR.strings.waiting_for_video),
|
generalGetString(R.string.waiting_for_video),
|
||||||
generalGetString(MR.strings.video_will_be_received_when_contact_is_online)
|
generalGetString(R.string.video_will_be_received_when_contact_is_online)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
CIFileStatus.RcvTransfer(rcvProgress = 7, rcvTotal = 10) -> {} // ?
|
CIFileStatus.RcvTransfer(rcvProgress = 7, rcvTotal = 10) -> {} // ?
|
||||||
@@ -87,15 +84,12 @@ fun CIVideoView(
|
|||||||
else -> {}
|
else -> {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
|
||||||
onLongClick = {
|
|
||||||
showMenu.value = true
|
|
||||||
})
|
})
|
||||||
if (file != null) {
|
if (file != null) {
|
||||||
DurationProgress(file, remember { mutableStateOf(false) }, remember { mutableStateOf(duration * 1000L) }, remember { mutableStateOf(0L) }/*, soundEnabled*/)
|
DurationProgress(file, remember { mutableStateOf(false) }, remember { mutableStateOf(duration * 1000L) }, remember { mutableStateOf(0L) }/*, soundEnabled*/)
|
||||||
}
|
}
|
||||||
if (file?.fileStatus is CIFileStatus.RcvInvitation) {
|
if (file?.fileStatus is CIFileStatus.RcvInvitation) {
|
||||||
PlayButton(error = false, { showMenu.value = true }) { receiveFileIfValidSize(file, encrypted = false, receiveFile) }
|
PlayButton(error = false, { showMenu.value = true }) { receiveFileIfValidSize(file, receiveFile) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -104,13 +98,14 @@ fun CIVideoView(
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun VideoView(uri: URI, file: CIFile, defaultPreview: ImageBitmap, defaultDuration: Long, showMenu: MutableState<Boolean>, onClick: () -> Unit) {
|
private fun VideoView(uri: Uri, file: CIFile, defaultPreview: Bitmap, defaultDuration: Long, showMenu: MutableState<Boolean>, onClick: () -> Unit) {
|
||||||
val player = remember(uri) { VideoPlayerHolder.getOrCreate(uri, false, defaultPreview, defaultDuration, true) }
|
val context = LocalContext.current
|
||||||
|
val player = remember(uri) { VideoPlayer.getOrCreate(uri, false, defaultPreview, defaultDuration, true, context) }
|
||||||
val videoPlaying = remember(uri.path) { player.videoPlaying }
|
val videoPlaying = remember(uri.path) { player.videoPlaying }
|
||||||
val progress = remember(uri.path) { player.progress }
|
val progress = remember(uri.path) { player.progress }
|
||||||
val duration = remember(uri.path) { player.duration }
|
val duration = remember(uri.path) { player.duration }
|
||||||
val preview by remember { player.preview }
|
val preview by remember { player.preview }
|
||||||
// val soundEnabled by rememberSaveable(uri.path) { player.soundEnabled }
|
// val soundEnabled by rememberSaveable(uri.path) { player.soundEnabled }
|
||||||
val brokenVideo by rememberSaveable(uri.path) { player.brokenVideo }
|
val brokenVideo by rememberSaveable(uri.path) { player.brokenVideo }
|
||||||
val play = {
|
val play = {
|
||||||
player.enableSound(true)
|
player.enableSound(true)
|
||||||
@@ -126,28 +121,32 @@ private fun VideoView(uri: URI, file: CIFile, defaultPreview: ImageBitmap, defau
|
|||||||
stop()
|
stop()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
val onLongClick = { showMenu.value = true }
|
|
||||||
Box {
|
Box {
|
||||||
val windowWidth = LocalWindowWidth()
|
val windowWidth = LocalWindowWidth()
|
||||||
val width = remember(preview) { if (preview.width * 0.97 <= preview.height) videoViewFullWidth(windowWidth) * 0.75f else DEFAULT_MAX_IMAGE_WIDTH }
|
val width = remember(preview) { if (preview.width * 0.97 <= preview.height) videoViewFullWidth(windowWidth) * 0.75f else 1000.dp }
|
||||||
PlayerView(
|
AndroidView(
|
||||||
player,
|
factory = { ctx ->
|
||||||
width,
|
StyledPlayerView(ctx).apply {
|
||||||
onClick = onClick,
|
useController = false
|
||||||
onLongClick = onLongClick,
|
resizeMode = RESIZE_MODE_FIXED_WIDTH
|
||||||
stop
|
this.player = player.player
|
||||||
|
}
|
||||||
|
},
|
||||||
|
Modifier
|
||||||
|
.width(width)
|
||||||
|
.combinedClickable(
|
||||||
|
onLongClick = { showMenu.value = true },
|
||||||
|
onClick = { if (player.player.playWhenReady) stop() else onClick() }
|
||||||
|
)
|
||||||
)
|
)
|
||||||
if (showPreview.value) {
|
if (showPreview.value) {
|
||||||
VideoPreviewImageView(preview, onClick, onLongClick)
|
ImageView(preview, showMenu, onClick)
|
||||||
PlayButton(brokenVideo, onLongClick = onLongClick, play)
|
PlayButton(brokenVideo, onLongClick = { showMenu.value = true }, play)
|
||||||
}
|
}
|
||||||
DurationProgress(file, videoPlaying, duration, progress/*, soundEnabled*/)
|
DurationProgress(file, videoPlaying, duration, progress/*, soundEnabled*/)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
|
||||||
expect fun PlayerView(player: VideoPlayer, width: Dp, onClick: () -> Unit, onLongClick: () -> Unit, stop: () -> Unit)
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun BoxScope.PlayButton(error: Boolean = false, onLongClick: () -> Unit, onClick: () -> Unit) {
|
private fun BoxScope.PlayButton(error: Boolean = false, onLongClick: () -> Unit, onClick: () -> Unit) {
|
||||||
Surface(
|
Surface(
|
||||||
@@ -158,12 +157,11 @@ private fun BoxScope.PlayButton(error: Boolean = false, onLongClick: () -> Unit,
|
|||||||
Box(
|
Box(
|
||||||
Modifier
|
Modifier
|
||||||
.defaultMinSize(minWidth = 40.dp, minHeight = 40.dp)
|
.defaultMinSize(minWidth = 40.dp, minHeight = 40.dp)
|
||||||
.combinedClickable(onClick = onClick, onLongClick = onLongClick)
|
.combinedClickable(onClick = onClick, onLongClick = onLongClick),
|
||||||
.onRightClick { onLongClick.invoke() },
|
|
||||||
contentAlignment = Alignment.Center
|
contentAlignment = Alignment.Center
|
||||||
) {
|
) {
|
||||||
Icon(
|
Icon(
|
||||||
painterResource(MR.images.ic_play_arrow_filled),
|
painterResource(R.drawable.ic_play_arrow_filled),
|
||||||
contentDescription = null,
|
contentDescription = null,
|
||||||
tint = if (error) WarningOrange else Color.White
|
tint = if (error) WarningOrange else Color.White
|
||||||
)
|
)
|
||||||
@@ -191,7 +189,7 @@ private fun DurationProgress(file: CIFile, playing: MutableState<Boolean>, durat
|
|||||||
color = Color.White
|
color = Color.White
|
||||||
)
|
)
|
||||||
/*if (!soundEnabled.value) {
|
/*if (!soundEnabled.value) {
|
||||||
Icon(painterResource(MR.images.ic_volume_off_filled), null,
|
Icon(painterResource(R.drawable.ic_volume_off_filled), null,
|
||||||
Modifier.padding(start = 5.dp).size(10.dp),
|
Modifier.padding(start = 5.dp).size(10.dp),
|
||||||
tint = Color.White
|
tint = Color.White
|
||||||
)
|
)
|
||||||
@@ -217,25 +215,32 @@ private fun DurationProgress(file: CIFile, playing: MutableState<Boolean>, durat
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun VideoPreviewImageView(preview: ImageBitmap, onClick: () -> Unit, onLongClick: () -> Unit) {
|
private fun ImageView(preview: Bitmap, showMenu: MutableState<Boolean>, onClick: () -> Unit) {
|
||||||
val windowWidth = LocalWindowWidth()
|
val windowWidth = LocalWindowWidth()
|
||||||
val width = remember(preview) { if (preview.width * 0.97 <= preview.height) videoViewFullWidth(windowWidth) * 0.75f else DEFAULT_MAX_IMAGE_WIDTH }
|
val width = remember(preview) { if (preview.width * 0.97 <= preview.height) videoViewFullWidth(windowWidth) * 0.75f else 1000.dp }
|
||||||
Image(
|
Image(
|
||||||
preview,
|
preview.asImageBitmap(),
|
||||||
contentDescription = stringResource(MR.strings.video_descr),
|
contentDescription = stringResource(R.string.video_descr),
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.width(width)
|
.width(width)
|
||||||
.combinedClickable(
|
.combinedClickable(
|
||||||
onLongClick = onLongClick,
|
onLongClick = { showMenu.value = true },
|
||||||
onClick = onClick
|
onClick = onClick
|
||||||
)
|
),
|
||||||
.onRightClick(onLongClick),
|
|
||||||
contentScale = ContentScale.FillWidth,
|
contentScale = ContentScale.FillWidth,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
expect fun LocalWindowWidth(): Dp
|
private fun LocalWindowWidth(): Dp {
|
||||||
|
val view = LocalView.current
|
||||||
|
val density = LocalDensity.current.density
|
||||||
|
return remember {
|
||||||
|
val rect = Rect()
|
||||||
|
view.getWindowVisibleDisplayFrame(rect)
|
||||||
|
(rect.width() / density).dp
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun progressIndicator() {
|
private fun progressIndicator() {
|
||||||
@@ -247,7 +252,7 @@ private fun progressIndicator() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun fileIcon(icon: Painter, stringId: StringResource) {
|
private fun fileIcon(icon: Painter, @StringRes stringId: Int) {
|
||||||
Icon(
|
Icon(
|
||||||
icon,
|
icon,
|
||||||
stringResource(stringId),
|
stringResource(stringId),
|
||||||
@@ -290,20 +295,19 @@ private fun loadingIndicator(file: CIFile?) {
|
|||||||
FileProtocol.XFTP -> progressCircle(file.fileStatus.sndProgress, file.fileStatus.sndTotal)
|
FileProtocol.XFTP -> progressCircle(file.fileStatus.sndProgress, file.fileStatus.sndTotal)
|
||||||
FileProtocol.SMP -> progressIndicator()
|
FileProtocol.SMP -> progressIndicator()
|
||||||
}
|
}
|
||||||
is CIFileStatus.SndComplete -> fileIcon(painterResource(MR.images.ic_check_filled), MR.strings.icon_descr_video_snd_complete)
|
is CIFileStatus.SndComplete -> fileIcon(painterResource(R.drawable.ic_check_filled), R.string.icon_descr_video_snd_complete)
|
||||||
is CIFileStatus.SndCancelled -> fileIcon(painterResource(MR.images.ic_close), MR.strings.icon_descr_file)
|
is CIFileStatus.SndCancelled -> fileIcon(painterResource(R.drawable.ic_close), R.string.icon_descr_file)
|
||||||
is CIFileStatus.SndError -> fileIcon(painterResource(MR.images.ic_close), MR.strings.icon_descr_file)
|
is CIFileStatus.SndError -> fileIcon(painterResource(R.drawable.ic_close), R.string.icon_descr_file)
|
||||||
is CIFileStatus.RcvInvitation -> fileIcon(painterResource(MR.images.ic_arrow_downward), MR.strings.icon_descr_video_asked_to_receive)
|
is CIFileStatus.RcvInvitation -> fileIcon(painterResource(R.drawable.ic_arrow_downward), R.string.icon_descr_video_asked_to_receive)
|
||||||
is CIFileStatus.RcvAccepted -> fileIcon(painterResource(MR.images.ic_more_horiz), MR.strings.icon_descr_waiting_for_video)
|
is CIFileStatus.RcvAccepted -> fileIcon(painterResource(R.drawable.ic_more_horiz), R.string.icon_descr_waiting_for_video)
|
||||||
is CIFileStatus.RcvTransfer ->
|
is CIFileStatus.RcvTransfer ->
|
||||||
if (file.fileProtocol == FileProtocol.XFTP && file.fileStatus.rcvProgress < file.fileStatus.rcvTotal) {
|
if (file.fileProtocol == FileProtocol.XFTP && file.fileStatus.rcvProgress < file.fileStatus.rcvTotal) {
|
||||||
progressCircle(file.fileStatus.rcvProgress, file.fileStatus.rcvTotal)
|
progressCircle(file.fileStatus.rcvProgress, file.fileStatus.rcvTotal)
|
||||||
} else {
|
} else {
|
||||||
progressIndicator()
|
progressIndicator()
|
||||||
}
|
}
|
||||||
is CIFileStatus.RcvCancelled -> fileIcon(painterResource(MR.images.ic_close), MR.strings.icon_descr_file)
|
is CIFileStatus.RcvCancelled -> fileIcon(painterResource(R.drawable.ic_close), R.string.icon_descr_file)
|
||||||
is CIFileStatus.RcvError -> fileIcon(painterResource(MR.images.ic_close), MR.strings.icon_descr_file)
|
is CIFileStatus.RcvError -> fileIcon(painterResource(R.drawable.ic_close), R.string.icon_descr_file)
|
||||||
is CIFileStatus.Invalid -> fileIcon(painterResource(MR.images.ic_question_mark), MR.strings.icon_descr_file)
|
|
||||||
else -> {}
|
else -> {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -317,18 +321,18 @@ private fun fileSizeValid(file: CIFile?): Boolean {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun receiveFileIfValidSize(file: CIFile, encrypted: Boolean, receiveFile: (Long, Boolean) -> Unit) {
|
private fun receiveFileIfValidSize(file: CIFile, receiveFile: (Long) -> Unit) {
|
||||||
if (fileSizeValid(file)) {
|
if (fileSizeValid(file)) {
|
||||||
receiveFile(file.fileId, encrypted)
|
receiveFile(file.fileId)
|
||||||
} else {
|
} else {
|
||||||
AlertManager.shared.showAlertMsg(
|
AlertManager.shared.showAlertMsg(
|
||||||
generalGetString(MR.strings.large_file),
|
generalGetString(R.string.large_file),
|
||||||
String.format(generalGetString(MR.strings.contact_sent_large_file), formatBytes(getMaxFileSize(file.fileProtocol)))
|
String.format(generalGetString(R.string.contact_sent_large_file), formatBytes(getMaxFileSize(file.fileProtocol)))
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun videoViewFullWidth(windowWidth: Dp): Dp {
|
private fun videoViewFullWidth(windowWidth: Dp): Dp {
|
||||||
val approximatePadding = 100.dp
|
val approximatePadding = 100.dp
|
||||||
return minOf(DEFAULT_MAX_IMAGE_WIDTH, windowWidth - approximatePadding)
|
return minOf(1000.dp, windowWidth - approximatePadding)
|
||||||
}
|
}
|
||||||
+42
-109
@@ -1,4 +1,4 @@
|
|||||||
package chat.simplex.common.views.chat.item
|
package chat.simplex.app.views.chat.item
|
||||||
|
|
||||||
import androidx.compose.foundation.combinedClickable
|
import androidx.compose.foundation.combinedClickable
|
||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.*
|
||||||
@@ -9,20 +9,20 @@ import androidx.compose.runtime.*
|
|||||||
import androidx.compose.runtime.saveable.rememberSaveable
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
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.*
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.draw.drawWithCache
|
||||||
import androidx.compose.ui.geometry.Offset
|
import androidx.compose.ui.geometry.Offset
|
||||||
import androidx.compose.ui.geometry.Size
|
import androidx.compose.ui.geometry.Size
|
||||||
import androidx.compose.ui.graphics.*
|
import androidx.compose.ui.graphics.*
|
||||||
import androidx.compose.ui.graphics.drawscope.Stroke
|
import androidx.compose.ui.graphics.drawscope.Stroke
|
||||||
import androidx.compose.ui.platform.*
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import dev.icerock.moko.resources.compose.painterResource
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
|
import androidx.compose.ui.res.painterResource
|
||||||
import androidx.compose.ui.unit.*
|
import androidx.compose.ui.unit.*
|
||||||
import chat.simplex.common.ui.theme.*
|
import chat.simplex.app.R
|
||||||
import chat.simplex.common.views.helpers.*
|
import chat.simplex.app.model.*
|
||||||
import chat.simplex.common.model.*
|
import chat.simplex.app.ui.theme.*
|
||||||
import chat.simplex.common.platform.*
|
import chat.simplex.app.views.helpers.*
|
||||||
import chat.simplex.res.MR
|
|
||||||
import kotlinx.coroutines.flow.*
|
|
||||||
|
|
||||||
// TODO refactor https://github.com/simplex-chat/simplex-chat/pull/1451#discussion_r1033429901
|
// TODO refactor https://github.com/simplex-chat/simplex-chat/pull/1451#discussion_r1033429901
|
||||||
|
|
||||||
@@ -36,34 +36,22 @@ fun CIVoiceView(
|
|||||||
ci: ChatItem,
|
ci: ChatItem,
|
||||||
timedMessagesTTL: Int?,
|
timedMessagesTTL: Int?,
|
||||||
longClick: () -> Unit,
|
longClick: () -> Unit,
|
||||||
receiveFile: (Long, Boolean) -> Unit,
|
|
||||||
) {
|
) {
|
||||||
Row(
|
Row(
|
||||||
Modifier.padding(top = if (hasText) 14.dp else 4.dp, bottom = if (hasText) 14.dp else 6.dp, start = if (hasText) 6.dp else 0.dp, end = if (hasText) 6.dp else 0.dp),
|
Modifier.padding(top = if (hasText) 14.dp else 4.dp, bottom = if (hasText) 14.dp else 6.dp, start = 6.dp, end = 6.dp),
|
||||||
verticalAlignment = Alignment.CenterVertically
|
verticalAlignment = Alignment.CenterVertically
|
||||||
) {
|
) {
|
||||||
if (file != null) {
|
if (file != null) {
|
||||||
val f = file.fileSource?.filePath
|
val context = LocalContext.current
|
||||||
val fileSource = remember(f, file.fileStatus, CIFile.cachedRemoteFileRequests.toList()) { mutableStateOf(getLoadedFileSource(file)) }
|
val filePath = remember(file.filePath, file.fileStatus) { getLoadedFilePath(context, file) }
|
||||||
var brokenAudio by rememberSaveable(f) { mutableStateOf(false) }
|
var brokenAudio by rememberSaveable(file.filePath) { mutableStateOf(false) }
|
||||||
val audioPlaying = rememberSaveable(f) { mutableStateOf(false) }
|
val audioPlaying = rememberSaveable(file.filePath) { mutableStateOf(false) }
|
||||||
val progress = rememberSaveable(f) { mutableStateOf(0) }
|
val progress = rememberSaveable(file.filePath) { mutableStateOf(0) }
|
||||||
val duration = rememberSaveable(f) { mutableStateOf(providedDurationSec * 1000) }
|
val duration = rememberSaveable(file.filePath) { mutableStateOf(providedDurationSec * 1000) }
|
||||||
val play: () -> Unit = {
|
val play = {
|
||||||
val playIfExists = {
|
AudioPlayer.play(filePath, audioPlaying, progress, duration, true)
|
||||||
if (fileSource.value != null) {
|
|
||||||
AudioPlayer.play(fileSource.value!!, audioPlaying, progress, duration, true)
|
|
||||||
brokenAudio = !audioPlaying.value
|
brokenAudio = !audioPlaying.value
|
||||||
}
|
}
|
||||||
}
|
|
||||||
if (chatModel.connectedToRemote() && fileSource.value == null) {
|
|
||||||
withBGApi {
|
|
||||||
file.loadRemoteFile(true)
|
|
||||||
fileSource.value = getLoadedFileSource(file)
|
|
||||||
playIfExists()
|
|
||||||
}
|
|
||||||
} else playIfExists()
|
|
||||||
}
|
|
||||||
val pause = {
|
val pause = {
|
||||||
AudioPlayer.pause(audioPlaying, progress)
|
AudioPlayer.pause(audioPlaying, progress)
|
||||||
}
|
}
|
||||||
@@ -76,11 +64,9 @@ fun CIVoiceView(
|
|||||||
durationText(time / 1000)
|
durationText(time / 1000)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
VoiceLayout(file, ci, text, audioPlaying, progress, duration, brokenAudio, sent, hasText, timedMessagesTTL, play, pause, longClick, receiveFile) {
|
VoiceLayout(file, ci, text, audioPlaying, progress, duration, brokenAudio, sent, hasText, timedMessagesTTL, play, pause, longClick)
|
||||||
AudioPlayer.seekTo(it, progress, fileSource.value?.filePath)
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
VoiceMsgIndicator(null, false, sent, hasText, null, null, false, {}, {}, longClick, receiveFile)
|
VoiceMsgIndicator(null, false, sent, hasText, null, null, false, {}, {}, longClick)
|
||||||
val metaReserve = if (edited)
|
val metaReserve = if (edited)
|
||||||
" "
|
" "
|
||||||
else
|
else
|
||||||
@@ -104,91 +90,41 @@ private fun VoiceLayout(
|
|||||||
timedMessagesTTL: Int?,
|
timedMessagesTTL: Int?,
|
||||||
play: () -> Unit,
|
play: () -> Unit,
|
||||||
pause: () -> Unit,
|
pause: () -> Unit,
|
||||||
longClick: () -> Unit,
|
longClick: () -> Unit
|
||||||
receiveFile: (Long, Boolean) -> Unit,
|
|
||||||
onProgressChanged: (Int) -> Unit,
|
|
||||||
) {
|
) {
|
||||||
@Composable
|
|
||||||
fun RowScope.Slider(backgroundColor: Color, padding: PaddingValues = PaddingValues(horizontal = DEFAULT_PADDING_HALF)) {
|
|
||||||
var movedManuallyTo by rememberSaveable(file.fileId) { mutableStateOf(-1) }
|
|
||||||
if (audioPlaying.value || progress.value > 0 || movedManuallyTo == progress.value) {
|
|
||||||
val dp4 = with(LocalDensity.current) { 4.dp.toPx() }
|
|
||||||
val dp10 = with(LocalDensity.current) { 10.dp.toPx() }
|
|
||||||
val primary = MaterialTheme.colors.primary
|
|
||||||
val inactiveTrackColor =
|
|
||||||
MaterialTheme.colors.primary.mixWith(
|
|
||||||
backgroundColor.copy(1f).mixWith(MaterialTheme.colors.background, backgroundColor.alpha),
|
|
||||||
0.24f)
|
|
||||||
val width = LocalWindowWidth()
|
|
||||||
val colors = SliderDefaults.colors(
|
|
||||||
inactiveTrackColor = inactiveTrackColor
|
|
||||||
)
|
|
||||||
Slider(
|
|
||||||
progress.value.toFloat(),
|
|
||||||
onValueChange = {
|
|
||||||
onProgressChanged(it.toInt())
|
|
||||||
movedManuallyTo = it.toInt()
|
|
||||||
},
|
|
||||||
Modifier
|
|
||||||
.size(width, 48.dp)
|
|
||||||
.weight(1f)
|
|
||||||
.padding(padding)
|
|
||||||
.drawBehind {
|
|
||||||
drawRect(primary, Offset(0f, (size.height - dp4) / 2), size = androidx.compose.ui.geometry.Size(dp10, dp4))
|
|
||||||
drawRect(inactiveTrackColor, Offset(size.width - dp10, (size.height - dp4) / 2), size = androidx.compose.ui.geometry.Size(dp10, dp4))
|
|
||||||
},
|
|
||||||
valueRange = 0f..duration.value.toFloat(),
|
|
||||||
colors = colors
|
|
||||||
)
|
|
||||||
LaunchedEffect(Unit) {
|
|
||||||
snapshotFlow { audioPlaying.value }
|
|
||||||
.distinctUntilChanged()
|
|
||||||
.collect {
|
|
||||||
movedManuallyTo = -1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
when {
|
when {
|
||||||
hasText -> {
|
hasText -> {
|
||||||
val sentColor = CurrentColors.collectAsState().value.appColors.sentMessage
|
|
||||||
val receivedColor = CurrentColors.collectAsState().value.appColors.receivedMessage
|
|
||||||
Spacer(Modifier.width(6.dp))
|
Spacer(Modifier.width(6.dp))
|
||||||
VoiceMsgIndicator(file, audioPlaying.value, sent, hasText, progress, duration, brokenAudio, play, pause, longClick, receiveFile)
|
VoiceMsgIndicator(file, audioPlaying.value, sent, hasText, progress, duration, brokenAudio, play, pause, longClick)
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
|
||||||
DurationText(text, PaddingValues(start = 12.dp))
|
DurationText(text, PaddingValues(start = 12.dp))
|
||||||
Slider(if (ci.chatDir.sent) sentColor else receivedColor)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
sent -> {
|
sent -> {
|
||||||
Column(horizontalAlignment = Alignment.End) {
|
|
||||||
Row {
|
Row {
|
||||||
Row(Modifier.weight(1f, false), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.End) {
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
Spacer(Modifier.height(56.dp))
|
Spacer(Modifier.height(56.dp))
|
||||||
Slider(MaterialTheme.colors.background, PaddingValues(end = DEFAULT_PADDING_HALF + 3.dp))
|
|
||||||
DurationText(text, PaddingValues(end = 12.dp))
|
DurationText(text, PaddingValues(end = 12.dp))
|
||||||
}
|
}
|
||||||
VoiceMsgIndicator(file, audioPlaying.value, sent, hasText, progress, duration, brokenAudio, play, pause, longClick, receiveFile)
|
Column {
|
||||||
}
|
VoiceMsgIndicator(file, audioPlaying.value, sent, hasText, progress, duration, brokenAudio, play, pause, longClick)
|
||||||
Box(Modifier.padding(top = 6.dp, end = 6.dp)) {
|
Box(Modifier.align(Alignment.CenterHorizontally).padding(top = 6.dp)) {
|
||||||
CIMetaView(ci, timedMessagesTTL)
|
CIMetaView(ci, timedMessagesTTL)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
else -> {
|
else -> {
|
||||||
Column(horizontalAlignment = Alignment.Start) {
|
|
||||||
Row {
|
Row {
|
||||||
VoiceMsgIndicator(file, audioPlaying.value, sent, hasText, progress, duration, brokenAudio, play, pause, longClick, receiveFile)
|
Column {
|
||||||
Row(Modifier.weight(1f, false), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.Start) {
|
VoiceMsgIndicator(file, audioPlaying.value, sent, hasText, progress, duration, brokenAudio, play, pause, longClick)
|
||||||
DurationText(text, PaddingValues(start = 12.dp))
|
Box(Modifier.align(Alignment.CenterHorizontally).padding(top = 6.dp)) {
|
||||||
Slider(MaterialTheme.colors.background, PaddingValues(start = DEFAULT_PADDING_HALF + 3.dp))
|
|
||||||
Spacer(Modifier.height(56.dp))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Box(Modifier.padding(top = 6.dp)) {
|
|
||||||
CIMetaView(ci, timedMessagesTTL)
|
CIMetaView(ci, timedMessagesTTL)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
DurationText(text, PaddingValues(start = 12.dp))
|
||||||
|
Spacer(Modifier.height(56.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -233,12 +169,11 @@ private fun PlayPauseButton(
|
|||||||
.combinedClickable(
|
.combinedClickable(
|
||||||
onClick = { if (!audioPlaying) play() else pause() },
|
onClick = { if (!audioPlaying) play() else pause() },
|
||||||
onLongClick = longClick
|
onLongClick = longClick
|
||||||
)
|
),
|
||||||
.onRightClick { longClick() },
|
|
||||||
contentAlignment = Alignment.Center
|
contentAlignment = Alignment.Center
|
||||||
) {
|
) {
|
||||||
Icon(
|
Icon(
|
||||||
if (audioPlaying) painterResource(MR.images.ic_pause_filled) else painterResource(MR.images.ic_play_arrow_filled),
|
if (audioPlaying) painterResource(R.drawable.ic_pause_filled) else painterResource(R.drawable.ic_play_arrow_filled),
|
||||||
contentDescription = null,
|
contentDescription = null,
|
||||||
Modifier.size(36.dp),
|
Modifier.size(36.dp),
|
||||||
tint = if (error) WarningOrange else if (!enabled) MaterialTheme.colors.secondary else MaterialTheme.colors.primary
|
tint = if (error) WarningOrange else if (!enabled) MaterialTheme.colors.secondary else MaterialTheme.colors.primary
|
||||||
@@ -258,8 +193,7 @@ private fun VoiceMsgIndicator(
|
|||||||
error: Boolean,
|
error: Boolean,
|
||||||
play: () -> Unit,
|
play: () -> Unit,
|
||||||
pause: () -> Unit,
|
pause: () -> Unit,
|
||||||
longClick: () -> Unit,
|
longClick: () -> Unit
|
||||||
receiveFile: (Long, Boolean) -> Unit,
|
|
||||||
) {
|
) {
|
||||||
val strokeWidth = with(LocalDensity.current) { 3.dp.toPx() }
|
val strokeWidth = with(LocalDensity.current) { 3.dp.toPx() }
|
||||||
val strokeColor = MaterialTheme.colors.primary
|
val strokeColor = MaterialTheme.colors.primary
|
||||||
@@ -268,7 +202,7 @@ private fun VoiceMsgIndicator(
|
|||||||
if (hasText) {
|
if (hasText) {
|
||||||
IconButton({ if (!audioPlaying) play() else pause() }, Modifier.size(56.dp).drawRingModifier(angle, strokeColor, strokeWidth)) {
|
IconButton({ if (!audioPlaying) play() else pause() }, Modifier.size(56.dp).drawRingModifier(angle, strokeColor, strokeWidth)) {
|
||||||
Icon(
|
Icon(
|
||||||
if (audioPlaying) painterResource(MR.images.ic_pause_filled) else painterResource(MR.images.ic_play_arrow_filled),
|
if (audioPlaying) painterResource(R.drawable.ic_pause_filled) else painterResource(R.drawable.ic_play_arrow_filled),
|
||||||
contentDescription = null,
|
contentDescription = null,
|
||||||
Modifier.size(36.dp),
|
Modifier.size(36.dp),
|
||||||
tint = MaterialTheme.colors.primary
|
tint = MaterialTheme.colors.primary
|
||||||
@@ -278,9 +212,8 @@ private fun VoiceMsgIndicator(
|
|||||||
PlayPauseButton(audioPlaying, sent, angle, strokeWidth, strokeColor, true, error, play, pause, longClick = longClick)
|
PlayPauseButton(audioPlaying, sent, angle, strokeWidth, strokeColor, true, error, play, pause, longClick = longClick)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
if (file?.fileStatus is CIFileStatus.RcvInvitation) {
|
if (file?.fileStatus is CIFileStatus.RcvInvitation
|
||||||
PlayPauseButton(audioPlaying, sent, 0f, strokeWidth, strokeColor, true, error, { receiveFile(file.fileId, chatController.appPrefs.privacyEncryptLocalFiles.get()) }, {}, longClick = longClick)
|
|| file?.fileStatus is CIFileStatus.RcvTransfer
|
||||||
} else if (file?.fileStatus is CIFileStatus.RcvTransfer
|
|
||||||
|| file?.fileStatus is CIFileStatus.RcvAccepted
|
|| file?.fileStatus is CIFileStatus.RcvAccepted
|
||||||
) {
|
) {
|
||||||
Box(
|
Box(
|
||||||
@@ -0,0 +1,457 @@
|
|||||||
|
package chat.simplex.app.views.chat.item
|
||||||
|
|
||||||
|
import android.Manifest
|
||||||
|
import android.os.Build
|
||||||
|
import androidx.compose.foundation.combinedClickable
|
||||||
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material.*
|
||||||
|
import androidx.compose.runtime.*
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.painter.Painter
|
||||||
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
|
import androidx.compose.ui.platform.*
|
||||||
|
import androidx.compose.ui.res.painterResource
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import chat.simplex.app.*
|
||||||
|
import chat.simplex.app.R
|
||||||
|
import chat.simplex.app.model.*
|
||||||
|
import chat.simplex.app.ui.theme.*
|
||||||
|
import chat.simplex.app.views.chat.ComposeContextItem
|
||||||
|
import chat.simplex.app.views.chat.ComposeState
|
||||||
|
import chat.simplex.app.views.helpers.*
|
||||||
|
import com.google.accompanist.permissions.rememberPermissionState
|
||||||
|
import kotlinx.datetime.Clock
|
||||||
|
|
||||||
|
// TODO refactor so that FramedItemView can show all CIContent items if they're deleted (see Swift code)
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun ChatItemView(
|
||||||
|
cInfo: ChatInfo,
|
||||||
|
cItem: ChatItem,
|
||||||
|
composeState: MutableState<ComposeState>,
|
||||||
|
imageProvider: (() -> ImageGalleryProvider)? = null,
|
||||||
|
showMember: Boolean = false,
|
||||||
|
useLinkPreviews: Boolean,
|
||||||
|
linkMode: SimplexLinkMode,
|
||||||
|
deleteMessage: (Long, CIDeleteMode) -> Unit,
|
||||||
|
receiveFile: (Long) -> Unit,
|
||||||
|
cancelFile: (Long) -> Unit,
|
||||||
|
joinGroup: (Long) -> Unit,
|
||||||
|
acceptCall: (Contact) -> Unit,
|
||||||
|
scrollToItem: (Long) -> Unit,
|
||||||
|
acceptFeature: (Contact, ChatFeature, Int?) -> Unit
|
||||||
|
) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
val uriHandler = LocalUriHandler.current
|
||||||
|
val sent = cItem.chatDir.sent
|
||||||
|
val alignment = if (sent) Alignment.CenterEnd else Alignment.CenterStart
|
||||||
|
val showMenu = remember { mutableStateOf(false) }
|
||||||
|
val revealed = remember { mutableStateOf(false) }
|
||||||
|
val fullDeleteAllowed = remember(cInfo) { cInfo.featureEnabled(ChatFeature.FullDelete) }
|
||||||
|
val saveFileLauncher = rememberSaveFileLauncher(cxt = context, ciFile = cItem.file)
|
||||||
|
val onLinkLongClick = { _: String -> showMenu.value = true }
|
||||||
|
val live = composeState.value.liveMessage != null
|
||||||
|
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.padding(bottom = 4.dp)
|
||||||
|
.fillMaxWidth(),
|
||||||
|
contentAlignment = alignment,
|
||||||
|
) {
|
||||||
|
val onClick = {
|
||||||
|
when (cItem.meta.itemStatus) {
|
||||||
|
is CIStatus.SndErrorAuth -> {
|
||||||
|
showMsgDeliveryErrorAlert(generalGetString(R.string.message_delivery_error_desc))
|
||||||
|
}
|
||||||
|
is CIStatus.SndError -> {
|
||||||
|
showMsgDeliveryErrorAlert(generalGetString(R.string.unknown_error) + ": ${cItem.meta.itemStatus.agentError}")
|
||||||
|
}
|
||||||
|
else -> {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Column(
|
||||||
|
Modifier
|
||||||
|
.clip(RoundedCornerShape(18.dp))
|
||||||
|
.combinedClickable(onLongClick = { showMenu.value = true }, onClick = onClick),
|
||||||
|
) {
|
||||||
|
@Composable
|
||||||
|
fun framedItemView() {
|
||||||
|
FramedItemView(cInfo, cItem, uriHandler, imageProvider, showMember = showMember, linkMode = linkMode, showMenu, receiveFile, onLinkLongClick, scrollToItem)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun deleteMessageQuestionText(): String {
|
||||||
|
return if (fullDeleteAllowed) {
|
||||||
|
generalGetString(R.string.delete_message_cannot_be_undone_warning)
|
||||||
|
} else {
|
||||||
|
generalGetString(R.string.delete_message_mark_deleted_warning)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun moderateMessageQuestionText(): String {
|
||||||
|
return if (fullDeleteAllowed) {
|
||||||
|
generalGetString(R.string.moderate_message_will_be_deleted_warning)
|
||||||
|
} else {
|
||||||
|
generalGetString(R.string.moderate_message_will_be_marked_warning)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun MsgContentItemDropdownMenu() {
|
||||||
|
DefaultDropdownMenu(showMenu) {
|
||||||
|
if (cItem.meta.itemDeleted == null && !live) {
|
||||||
|
ItemAction(stringResource(R.string.reply_verb), painterResource(R.drawable.ic_reply), onClick = {
|
||||||
|
if (composeState.value.editing) {
|
||||||
|
composeState.value = ComposeState(contextItem = ComposeContextItem.QuotedItem(cItem), useLinkPreviews = useLinkPreviews)
|
||||||
|
} else {
|
||||||
|
composeState.value = composeState.value.copy(contextItem = ComposeContextItem.QuotedItem(cItem))
|
||||||
|
}
|
||||||
|
showMenu.value = false
|
||||||
|
})
|
||||||
|
}
|
||||||
|
ItemAction(stringResource(R.string.share_verb), painterResource(R.drawable.ic_share), onClick = {
|
||||||
|
val filePath = getLoadedFilePath(SimplexApp.context, cItem.file)
|
||||||
|
when {
|
||||||
|
filePath != null -> shareFile(context, cItem.text, filePath)
|
||||||
|
else -> shareText(context, cItem.content.text)
|
||||||
|
}
|
||||||
|
showMenu.value = false
|
||||||
|
})
|
||||||
|
ItemAction(stringResource(R.string.copy_verb), painterResource(R.drawable.ic_content_copy), onClick = {
|
||||||
|
copyText(context, cItem.content.text)
|
||||||
|
showMenu.value = false
|
||||||
|
})
|
||||||
|
if (cItem.content.msgContent is MsgContent.MCImage || cItem.content.msgContent is MsgContent.MCVideo || cItem.content.msgContent is MsgContent.MCFile || cItem.content.msgContent is MsgContent.MCVoice) {
|
||||||
|
val filePath = getLoadedFilePath(context, cItem.file)
|
||||||
|
if (filePath != null) {
|
||||||
|
val writePermissionState = rememberPermissionState(permission = Manifest.permission.WRITE_EXTERNAL_STORAGE)
|
||||||
|
ItemAction(stringResource(R.string.save_verb), painterResource(R.drawable.ic_download), onClick = {
|
||||||
|
when (cItem.content.msgContent) {
|
||||||
|
is MsgContent.MCImage -> {
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R || writePermissionState.hasPermission) {
|
||||||
|
saveImage(context, cItem.file)
|
||||||
|
} else {
|
||||||
|
writePermissionState.launchPermissionRequest()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
is MsgContent.MCFile, is MsgContent.MCVoice, is MsgContent.MCVideo -> saveFileLauncher.launch(cItem.file?.fileName)
|
||||||
|
else -> {}
|
||||||
|
}
|
||||||
|
showMenu.value = false
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (cItem.meta.editable && cItem.content.msgContent !is MsgContent.MCVoice && !live) {
|
||||||
|
ItemAction(stringResource(R.string.edit_verb), painterResource(R.drawable.ic_edit_filled), onClick = {
|
||||||
|
composeState.value = ComposeState(editingItem = cItem, useLinkPreviews = useLinkPreviews)
|
||||||
|
showMenu.value = false
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (cItem.meta.itemDeleted != null && revealed.value) {
|
||||||
|
ItemAction(
|
||||||
|
stringResource(R.string.hide_verb),
|
||||||
|
painterResource(R.drawable.ic_visibility_off),
|
||||||
|
onClick = {
|
||||||
|
revealed.value = false
|
||||||
|
showMenu.value = false
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (cItem.meta.itemDeleted == null && cItem.file != null && cItem.file.cancelAction != null) {
|
||||||
|
CancelFileItemAction(cItem.file.fileId, showMenu, cancelFile = cancelFile, cancelAction = cItem.file.cancelAction)
|
||||||
|
}
|
||||||
|
if (!(live && cItem.meta.isLive)) {
|
||||||
|
DeleteItemAction(cItem, showMenu, questionText = deleteMessageQuestionText(), deleteMessage)
|
||||||
|
}
|
||||||
|
val groupInfo = cItem.memberToModerate(cInfo)?.first
|
||||||
|
if (groupInfo != null) {
|
||||||
|
ModerateItemAction(cItem, questionText = moderateMessageQuestionText(), showMenu, deleteMessage)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun MarkedDeletedItemDropdownMenu() {
|
||||||
|
DefaultDropdownMenu(showMenu) {
|
||||||
|
if (!cItem.isDeletedContent) {
|
||||||
|
ItemAction(
|
||||||
|
stringResource(R.string.reveal_verb),
|
||||||
|
painterResource(R.drawable.ic_visibility),
|
||||||
|
onClick = {
|
||||||
|
revealed.value = true
|
||||||
|
showMenu.value = false
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
DeleteItemAction(cItem, showMenu, questionText = deleteMessageQuestionText(), deleteMessage)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun ContentItem() {
|
||||||
|
val mc = cItem.content.msgContent
|
||||||
|
if (cItem.meta.itemDeleted != null && !revealed.value) {
|
||||||
|
MarkedDeletedItemView(cItem, cInfo.timedMessagesTTL, showMember = showMember)
|
||||||
|
MarkedDeletedItemDropdownMenu()
|
||||||
|
} else if (cItem.quotedItem == null && cItem.meta.itemDeleted == null && !cItem.meta.isLive) {
|
||||||
|
if (mc is MsgContent.MCText && isShortEmoji(cItem.content.text)) {
|
||||||
|
EmojiItemView(cItem, cInfo.timedMessagesTTL)
|
||||||
|
MsgContentItemDropdownMenu()
|
||||||
|
} else if (mc is MsgContent.MCVoice && cItem.content.text.isEmpty()) {
|
||||||
|
CIVoiceView(mc.duration, cItem.file, cItem.meta.itemEdited, cItem.chatDir.sent, hasText = false, cItem, cInfo.timedMessagesTTL, longClick = { onLinkLongClick("") })
|
||||||
|
MsgContentItemDropdownMenu()
|
||||||
|
} else {
|
||||||
|
framedItemView()
|
||||||
|
MsgContentItemDropdownMenu()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
framedItemView()
|
||||||
|
MsgContentItemDropdownMenu()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable fun DeletedItem() {
|
||||||
|
DeletedItemView(cItem, cInfo.timedMessagesTTL, showMember = showMember)
|
||||||
|
DefaultDropdownMenu(showMenu) {
|
||||||
|
DeleteItemAction(cItem, showMenu, questionText = deleteMessageQuestionText(), deleteMessage)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable fun CallItem(status: CICallStatus, duration: Int) {
|
||||||
|
CICallItemView(cInfo, cItem, status, duration, acceptCall)
|
||||||
|
}
|
||||||
|
|
||||||
|
when (val c = cItem.content) {
|
||||||
|
is CIContent.SndMsgContent -> ContentItem()
|
||||||
|
is CIContent.RcvMsgContent -> ContentItem()
|
||||||
|
is CIContent.SndDeleted -> DeletedItem()
|
||||||
|
is CIContent.RcvDeleted -> DeletedItem()
|
||||||
|
is CIContent.SndCall -> CallItem(c.status, c.duration)
|
||||||
|
is CIContent.RcvCall -> CallItem(c.status, c.duration)
|
||||||
|
is CIContent.RcvIntegrityError -> IntegrityErrorItemView(c.msgError, cItem, cInfo.timedMessagesTTL, showMember = showMember)
|
||||||
|
is CIContent.RcvDecryptionError -> CIRcvDecryptionError(c.msgDecryptError, c.msgCount, cItem, cInfo.timedMessagesTTL, showMember = showMember)
|
||||||
|
is CIContent.RcvGroupInvitation -> CIGroupInvitationView(cItem, c.groupInvitation, c.memberRole, joinGroup = joinGroup, chatIncognito = cInfo.incognito)
|
||||||
|
is CIContent.SndGroupInvitation -> CIGroupInvitationView(cItem, c.groupInvitation, c.memberRole, joinGroup = joinGroup, chatIncognito = cInfo.incognito)
|
||||||
|
is CIContent.RcvGroupEventContent -> CIEventView(cItem)
|
||||||
|
is CIContent.SndGroupEventContent -> CIEventView(cItem)
|
||||||
|
is CIContent.RcvConnEventContent -> CIEventView(cItem)
|
||||||
|
is CIContent.SndConnEventContent -> CIEventView(cItem)
|
||||||
|
is CIContent.RcvChatFeature -> CIChatFeatureView(cItem, c.feature, c.enabled.iconColor)
|
||||||
|
is CIContent.SndChatFeature -> CIChatFeatureView(cItem, c.feature, c.enabled.iconColor)
|
||||||
|
is CIContent.RcvChatPreference -> {
|
||||||
|
val ct = if (cInfo is ChatInfo.Direct) cInfo.contact else null
|
||||||
|
CIFeaturePreferenceView(cItem, ct, c.feature, c.allowed, acceptFeature)
|
||||||
|
}
|
||||||
|
is CIContent.SndChatPreference -> CIChatFeatureView(cItem, c.feature, MaterialTheme.colors.secondary, icon = c.feature.icon,)
|
||||||
|
is CIContent.RcvGroupFeature -> CIChatFeatureView(cItem, c.groupFeature, c.preference.enable.iconColor)
|
||||||
|
is CIContent.SndGroupFeature -> CIChatFeatureView(cItem, c.groupFeature, c.preference.enable.iconColor)
|
||||||
|
is CIContent.RcvChatFeatureRejected -> CIChatFeatureView(cItem, c.feature, Color.Red)
|
||||||
|
is CIContent.RcvGroupFeatureRejected -> CIChatFeatureView(cItem, c.groupFeature, Color.Red)
|
||||||
|
is CIContent.SndModerated -> MarkedDeletedItemView(cItem, cInfo.timedMessagesTTL, showMember = showMember)
|
||||||
|
is CIContent.RcvModerated -> MarkedDeletedItemView(cItem, cInfo.timedMessagesTTL, showMember = showMember)
|
||||||
|
is CIContent.InvalidJSON -> CIInvalidJSONView(c.json)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun CancelFileItemAction(
|
||||||
|
fileId: Long,
|
||||||
|
showMenu: MutableState<Boolean>,
|
||||||
|
cancelFile: (Long) -> Unit,
|
||||||
|
cancelAction: CancelAction
|
||||||
|
) {
|
||||||
|
ItemAction(
|
||||||
|
stringResource(cancelAction.uiActionId),
|
||||||
|
painterResource(R.drawable.ic_close),
|
||||||
|
onClick = {
|
||||||
|
showMenu.value = false
|
||||||
|
cancelFileAlertDialog(fileId, cancelFile = cancelFile, cancelAction = cancelAction)
|
||||||
|
},
|
||||||
|
color = Color.Red
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun DeleteItemAction(
|
||||||
|
cItem: ChatItem,
|
||||||
|
showMenu: MutableState<Boolean>,
|
||||||
|
questionText: String,
|
||||||
|
deleteMessage: (Long, CIDeleteMode) -> Unit
|
||||||
|
) {
|
||||||
|
ItemAction(
|
||||||
|
stringResource(R.string.delete_verb),
|
||||||
|
painterResource(R.drawable.ic_delete),
|
||||||
|
onClick = {
|
||||||
|
showMenu.value = false
|
||||||
|
deleteMessageAlertDialog(cItem, questionText, deleteMessage = deleteMessage)
|
||||||
|
},
|
||||||
|
color = Color.Red
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun ModerateItemAction(
|
||||||
|
cItem: ChatItem,
|
||||||
|
questionText: String,
|
||||||
|
showMenu: MutableState<Boolean>,
|
||||||
|
deleteMessage: (Long, CIDeleteMode) -> Unit
|
||||||
|
) {
|
||||||
|
ItemAction(
|
||||||
|
stringResource(R.string.moderate_verb),
|
||||||
|
painterResource(R.drawable.ic_flag),
|
||||||
|
onClick = {
|
||||||
|
showMenu.value = false
|
||||||
|
moderateMessageAlertDialog(cItem, questionText, deleteMessage = deleteMessage)
|
||||||
|
},
|
||||||
|
color = Color.Red
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun ItemAction(text: String, icon: Painter, onClick: () -> Unit, color: Color = Color.Unspecified) {
|
||||||
|
val finalColor = if (color == Color.Unspecified) {
|
||||||
|
if (isInDarkTheme()) MenuTextColorDark else Color.Black
|
||||||
|
} else color
|
||||||
|
DropdownMenuItem(onClick, contentPadding = PaddingValues(horizontal = DEFAULT_PADDING * 1.5f)) {
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Text(
|
||||||
|
text,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.weight(1F)
|
||||||
|
.padding(end = 15.dp),
|
||||||
|
color = finalColor
|
||||||
|
)
|
||||||
|
Icon(icon, text, tint = finalColor)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun ItemAction(text: String, icon: ImageVector, onClick: () -> Unit, color: Color = Color.Unspecified) {
|
||||||
|
val finalColor = if (color == Color.Unspecified) {
|
||||||
|
if (isInDarkTheme()) MenuTextColorDark else Color.Black
|
||||||
|
} else color
|
||||||
|
DropdownMenuItem(onClick, contentPadding = PaddingValues(horizontal = DEFAULT_PADDING * 1.5f)) {
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Text(
|
||||||
|
text,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.weight(1F)
|
||||||
|
.padding(end = 15.dp),
|
||||||
|
color = finalColor
|
||||||
|
)
|
||||||
|
Icon(icon, text, tint = finalColor)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun cancelFileAlertDialog(fileId: Long, cancelFile: (Long) -> Unit, cancelAction: CancelAction) {
|
||||||
|
AlertManager.shared.showAlertDialog(
|
||||||
|
title = generalGetString(cancelAction.alert.titleId),
|
||||||
|
text = generalGetString(cancelAction.alert.messageId),
|
||||||
|
confirmText = generalGetString(cancelAction.alert.confirmId),
|
||||||
|
destructive = true,
|
||||||
|
onConfirm = {
|
||||||
|
cancelFile(fileId)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun deleteMessageAlertDialog(chatItem: ChatItem, questionText: String, deleteMessage: (Long, CIDeleteMode) -> Unit) {
|
||||||
|
AlertManager.shared.showAlertDialogButtons(
|
||||||
|
title = generalGetString(R.string.delete_message__question),
|
||||||
|
text = questionText,
|
||||||
|
buttons = {
|
||||||
|
Row(
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 8.dp, vertical = 2.dp),
|
||||||
|
horizontalArrangement = Arrangement.Center,
|
||||||
|
) {
|
||||||
|
TextButton(onClick = {
|
||||||
|
deleteMessage(chatItem.id, CIDeleteMode.cidmInternal)
|
||||||
|
AlertManager.shared.hideAlert()
|
||||||
|
}) { Text(stringResource(R.string.for_me_only), color = MaterialTheme.colors.error) }
|
||||||
|
if (chatItem.meta.editable) {
|
||||||
|
Spacer(Modifier.padding(horizontal = 4.dp))
|
||||||
|
TextButton(onClick = {
|
||||||
|
deleteMessage(chatItem.id, CIDeleteMode.cidmBroadcast)
|
||||||
|
AlertManager.shared.hideAlert()
|
||||||
|
}) { Text(stringResource(R.string.for_everybody), color = MaterialTheme.colors.error) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun moderateMessageAlertDialog(chatItem: ChatItem, questionText: String, deleteMessage: (Long, CIDeleteMode) -> Unit) {
|
||||||
|
AlertManager.shared.showAlertDialog(
|
||||||
|
title = generalGetString(R.string.delete_member_message__question),
|
||||||
|
text = questionText,
|
||||||
|
confirmText = generalGetString(R.string.delete_verb),
|
||||||
|
destructive = true,
|
||||||
|
onConfirm = {
|
||||||
|
deleteMessage(chatItem.id, CIDeleteMode.cidmBroadcast)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun showMsgDeliveryErrorAlert(description: String) {
|
||||||
|
AlertManager.shared.showAlertMsg(
|
||||||
|
title = generalGetString(R.string.message_delivery_error_title),
|
||||||
|
text = description,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Preview
|
||||||
|
@Composable
|
||||||
|
fun PreviewChatItemView() {
|
||||||
|
SimpleXTheme {
|
||||||
|
ChatItemView(
|
||||||
|
ChatInfo.Direct.sampleData,
|
||||||
|
ChatItem.getSampleData(
|
||||||
|
1, CIDirection.DirectSnd(), Clock.System.now(), "hello"
|
||||||
|
),
|
||||||
|
useLinkPreviews = true,
|
||||||
|
linkMode = SimplexLinkMode.DESCRIPTION,
|
||||||
|
composeState = remember { mutableStateOf(ComposeState(useLinkPreviews = true)) },
|
||||||
|
deleteMessage = { _, _ -> },
|
||||||
|
receiveFile = {},
|
||||||
|
cancelFile = {},
|
||||||
|
joinGroup = {},
|
||||||
|
acceptCall = { _ -> },
|
||||||
|
scrollToItem = {},
|
||||||
|
acceptFeature = { _, _, _ -> }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Preview
|
||||||
|
@Composable
|
||||||
|
fun PreviewChatItemViewDeletedContent() {
|
||||||
|
SimpleXTheme {
|
||||||
|
ChatItemView(
|
||||||
|
ChatInfo.Direct.sampleData,
|
||||||
|
ChatItem.getDeletedContentSampleData(),
|
||||||
|
useLinkPreviews = true,
|
||||||
|
linkMode = SimplexLinkMode.DESCRIPTION,
|
||||||
|
composeState = remember { mutableStateOf(ComposeState(useLinkPreviews = true)) },
|
||||||
|
deleteMessage = { _, _ -> },
|
||||||
|
receiveFile = {},
|
||||||
|
cancelFile = {},
|
||||||
|
joinGroup = {},
|
||||||
|
acceptCall = { _ -> },
|
||||||
|
scrollToItem = {},
|
||||||
|
acceptFeature = { _, _, _ -> }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
+10
-7
@@ -1,5 +1,6 @@
|
|||||||
package chat.simplex.common.views.chat.item
|
package chat.simplex.app.views.chat.item
|
||||||
|
|
||||||
|
import android.content.res.Configuration
|
||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.*
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material.*
|
import androidx.compose.material.*
|
||||||
@@ -9,14 +10,14 @@ import androidx.compose.ui.Alignment
|
|||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.text.*
|
import androidx.compose.ui.text.*
|
||||||
import androidx.compose.ui.text.font.FontStyle
|
import androidx.compose.ui.text.font.FontStyle
|
||||||
import androidx.compose.desktop.ui.tooling.preview.Preview
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
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 chat.simplex.common.model.ChatItem
|
import chat.simplex.app.model.ChatItem
|
||||||
import chat.simplex.common.ui.theme.*
|
import chat.simplex.app.ui.theme.*
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun DeletedItemView(ci: ChatItem, timedMessagesTTL: Int?) {
|
fun DeletedItemView(ci: ChatItem, timedMessagesTTL: Int?, showMember: Boolean = false) {
|
||||||
val sent = ci.chatDir.sent
|
val sent = ci.chatDir.sent
|
||||||
val sentColor = CurrentColors.collectAsState().value.appColors.sentMessage
|
val sentColor = CurrentColors.collectAsState().value.appColors.sentMessage
|
||||||
val receivedColor = CurrentColors.collectAsState().value.appColors.receivedMessage
|
val receivedColor = CurrentColors.collectAsState().value.appColors.receivedMessage
|
||||||
@@ -30,6 +31,7 @@ fun DeletedItemView(ci: ChatItem, timedMessagesTTL: Int?) {
|
|||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
buildAnnotatedString {
|
buildAnnotatedString {
|
||||||
|
appendSender(this, if (showMember) ci.memberDisplayName else null, true)
|
||||||
withStyle(SpanStyle(fontStyle = FontStyle.Italic, color = MaterialTheme.colors.secondary)) { append(ci.content.text) }
|
withStyle(SpanStyle(fontStyle = FontStyle.Italic, color = MaterialTheme.colors.secondary)) { append(ci.content.text) }
|
||||||
},
|
},
|
||||||
style = MaterialTheme.typography.body1.copy(lineHeight = 22.sp),
|
style = MaterialTheme.typography.body1.copy(lineHeight = 22.sp),
|
||||||
@@ -40,10 +42,11 @@ fun DeletedItemView(ci: ChatItem, timedMessagesTTL: Int?) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Preview/*(
|
@Preview(showBackground = true)
|
||||||
|
@Preview(
|
||||||
uiMode = Configuration.UI_MODE_NIGHT_YES,
|
uiMode = Configuration.UI_MODE_NIGHT_YES,
|
||||||
name = "Dark Mode"
|
name = "Dark Mode"
|
||||||
)*/
|
)
|
||||||
@Composable
|
@Composable
|
||||||
fun PreviewDeletedItemView() {
|
fun PreviewDeletedItemView() {
|
||||||
SimpleXTheme {
|
SimpleXTheme {
|
||||||
+8
-7
@@ -1,4 +1,4 @@
|
|||||||
package chat.simplex.common.views.chat.item
|
package chat.simplex.app.views.chat.item
|
||||||
|
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
@@ -9,12 +9,10 @@ import androidx.compose.ui.Modifier
|
|||||||
import androidx.compose.ui.text.TextStyle
|
import androidx.compose.ui.text.TextStyle
|
||||||
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 chat.simplex.common.model.ChatItem
|
import chat.simplex.app.model.ChatItem
|
||||||
import chat.simplex.common.model.MREmojiChar
|
|
||||||
import chat.simplex.common.ui.theme.EmojiFont
|
|
||||||
|
|
||||||
val largeEmojiFont: TextStyle = TextStyle(fontSize = 48.sp, fontFamily = EmojiFont)
|
val largeEmojiFont: TextStyle = TextStyle(fontSize = 48.sp)
|
||||||
val mediumEmojiFont: TextStyle = TextStyle(fontSize = 36.sp, fontFamily = EmojiFont)
|
val mediumEmojiFont: TextStyle = TextStyle(fontSize = 36.sp)
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun EmojiItemView(chatItem: ChatItem, timedMessagesTTL: Int?) {
|
fun EmojiItemView(chatItem: ChatItem, timedMessagesTTL: Int?) {
|
||||||
@@ -28,7 +26,10 @@ fun EmojiItemView(chatItem: ChatItem, timedMessagesTTL: Int?) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
expect fun EmojiText(text: String)
|
fun EmojiText(text: String) {
|
||||||
|
val s = text.trim()
|
||||||
|
Text(s, style = if (s.codePoints().count() < 4) largeEmojiFont else mediumEmojiFont)
|
||||||
|
}
|
||||||
|
|
||||||
// https://stackoverflow.com/a/46279500
|
// https://stackoverflow.com/a/46279500
|
||||||
private const val emojiStr = "^(" +
|
private const val emojiStr = "^(" +
|
||||||
+40
-69
@@ -1,4 +1,4 @@
|
|||||||
package chat.simplex.common.views.chat.item
|
package chat.simplex.app.views.chat.item
|
||||||
|
|
||||||
import androidx.compose.foundation.*
|
import androidx.compose.foundation.*
|
||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.*
|
||||||
@@ -10,21 +10,23 @@ import androidx.compose.ui.Modifier
|
|||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.draw.clipToBounds
|
import androidx.compose.ui.draw.clipToBounds
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.asImageBitmap
|
||||||
import androidx.compose.ui.graphics.painter.Painter
|
import androidx.compose.ui.graphics.painter.Painter
|
||||||
import androidx.compose.ui.layout.*
|
import androidx.compose.ui.layout.*
|
||||||
import androidx.compose.ui.platform.UriHandler
|
import androidx.compose.ui.platform.UriHandler
|
||||||
import dev.icerock.moko.resources.compose.painterResource
|
import androidx.compose.ui.res.painterResource
|
||||||
import dev.icerock.moko.resources.compose.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.text.*
|
import androidx.compose.ui.text.*
|
||||||
import androidx.compose.ui.text.font.FontStyle
|
import androidx.compose.ui.text.font.FontStyle
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.tooling.preview.*
|
||||||
import androidx.compose.ui.unit.*
|
import androidx.compose.ui.unit.*
|
||||||
import chat.simplex.common.model.*
|
import androidx.compose.ui.util.fastMap
|
||||||
import chat.simplex.common.platform.*
|
import chat.simplex.app.R
|
||||||
import chat.simplex.common.ui.theme.*
|
import chat.simplex.app.model.*
|
||||||
import chat.simplex.common.views.helpers.*
|
import chat.simplex.app.ui.theme.*
|
||||||
import chat.simplex.common.views.chat.MEMBER_IMAGE_SIZE
|
import chat.simplex.app.views.helpers.*
|
||||||
import chat.simplex.res.MR
|
import kotlinx.datetime.Clock
|
||||||
import kotlin.math.min
|
import kotlin.math.min
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
@@ -33,9 +35,10 @@ fun FramedItemView(
|
|||||||
ci: ChatItem,
|
ci: ChatItem,
|
||||||
uriHandler: UriHandler? = null,
|
uriHandler: UriHandler? = null,
|
||||||
imageProvider: (() -> ImageGalleryProvider)? = null,
|
imageProvider: (() -> ImageGalleryProvider)? = null,
|
||||||
|
showMember: Boolean = false,
|
||||||
linkMode: SimplexLinkMode,
|
linkMode: SimplexLinkMode,
|
||||||
showMenu: MutableState<Boolean>,
|
showMenu: MutableState<Boolean>,
|
||||||
receiveFile: (Long, Boolean) -> Unit,
|
receiveFile: (Long) -> Unit,
|
||||||
onLinkLongClick: (link: String) -> Unit = {},
|
onLinkLongClick: (link: String) -> Unit = {},
|
||||||
scrollToItem: (Long) -> Unit = {},
|
scrollToItem: (Long) -> Unit = {},
|
||||||
) {
|
) {
|
||||||
@@ -49,39 +52,17 @@ fun FramedItemView(
|
|||||||
@Composable
|
@Composable
|
||||||
fun Color.toQuote(): Color = if (isInDarkTheme()) lighter(0.12f) else darker(0.12f)
|
fun Color.toQuote(): Color = if (isInDarkTheme()) lighter(0.12f) else darker(0.12f)
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun ciQuotedMsgTextView(qi: CIQuote, lines: Int) {
|
|
||||||
MarkdownText(
|
|
||||||
qi.text,
|
|
||||||
qi.formattedText,
|
|
||||||
maxLines = lines,
|
|
||||||
overflow = TextOverflow.Ellipsis,
|
|
||||||
style = TextStyle(fontSize = 15.sp, color = MaterialTheme.colors.onSurface),
|
|
||||||
linkMode = linkMode,
|
|
||||||
uriHandler = if (appPlatform.isDesktop) uriHandler else null
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun ciQuotedMsgView(qi: CIQuote) {
|
fun ciQuotedMsgView(qi: CIQuote) {
|
||||||
Box(
|
Box(
|
||||||
Modifier.padding(vertical = 6.dp, horizontal = 12.dp),
|
Modifier.padding(vertical = 6.dp, horizontal = 12.dp),
|
||||||
contentAlignment = Alignment.TopStart
|
contentAlignment = Alignment.TopStart
|
||||||
) {
|
) {
|
||||||
val sender = qi.sender(membership())
|
MarkdownText(
|
||||||
if (sender != null) {
|
qi.text, qi.formattedText, sender = qi.sender(membership()), senderBold = true, maxLines = 3,
|
||||||
Column(
|
style = TextStyle(fontSize = 15.sp, color = MaterialTheme.colors.onSurface),
|
||||||
horizontalAlignment = Alignment.Start
|
linkMode = linkMode
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
sender,
|
|
||||||
style = TextStyle(fontSize = 13.5.sp, color = CurrentColors.value.colors.secondary)
|
|
||||||
)
|
)
|
||||||
ciQuotedMsgTextView(qi, lines = 2)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
ciQuotedMsgTextView(qi, lines = 3)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -129,17 +110,16 @@ fun FramedItemView(
|
|||||||
onLongClick = { showMenu.value = true },
|
onLongClick = { showMenu.value = true },
|
||||||
onClick = { scrollToItem(qi.itemId?: return@combinedClickable) }
|
onClick = { scrollToItem(qi.itemId?: return@combinedClickable) }
|
||||||
)
|
)
|
||||||
.onRightClick { showMenu.value = true }
|
|
||||||
) {
|
) {
|
||||||
when (qi.content) {
|
when (qi.content) {
|
||||||
is MsgContent.MCImage -> {
|
is MsgContent.MCImage -> {
|
||||||
Box(Modifier.fillMaxWidth().weight(1f)) {
|
Box(Modifier.fillMaxWidth().weight(1f)) {
|
||||||
ciQuotedMsgView(qi)
|
ciQuotedMsgView(qi)
|
||||||
}
|
}
|
||||||
val imageBitmap = base64ToBitmap(qi.content.image)
|
val imageBitmap = base64ToBitmap(qi.content.image).asImageBitmap()
|
||||||
Image(
|
Image(
|
||||||
imageBitmap,
|
imageBitmap,
|
||||||
contentDescription = stringResource(MR.strings.image_descr),
|
contentDescription = stringResource(R.string.image_descr),
|
||||||
contentScale = ContentScale.Crop,
|
contentScale = ContentScale.Crop,
|
||||||
modifier = Modifier.size(68.dp).clipToBounds()
|
modifier = Modifier.size(68.dp).clipToBounds()
|
||||||
)
|
)
|
||||||
@@ -148,10 +128,10 @@ fun FramedItemView(
|
|||||||
Box(Modifier.fillMaxWidth().weight(1f)) {
|
Box(Modifier.fillMaxWidth().weight(1f)) {
|
||||||
ciQuotedMsgView(qi)
|
ciQuotedMsgView(qi)
|
||||||
}
|
}
|
||||||
val imageBitmap = base64ToBitmap(qi.content.image)
|
val imageBitmap = base64ToBitmap(qi.content.image).asImageBitmap()
|
||||||
Image(
|
Image(
|
||||||
imageBitmap,
|
imageBitmap,
|
||||||
contentDescription = stringResource(MR.strings.video_descr),
|
contentDescription = stringResource(R.string.video_descr),
|
||||||
contentScale = ContentScale.Crop,
|
contentScale = ContentScale.Crop,
|
||||||
modifier = Modifier.size(68.dp).clipToBounds()
|
modifier = Modifier.size(68.dp).clipToBounds()
|
||||||
)
|
)
|
||||||
@@ -161,8 +141,8 @@ fun FramedItemView(
|
|||||||
ciQuotedMsgView(qi)
|
ciQuotedMsgView(qi)
|
||||||
}
|
}
|
||||||
Icon(
|
Icon(
|
||||||
if (qi.content is MsgContent.MCFile) painterResource(MR.images.ic_draft_filled) else painterResource(MR.images.ic_mic_filled),
|
if (qi.content is MsgContent.MCFile) painterResource(R.drawable.ic_draft_filled) else painterResource(R.drawable.ic_mic_filled),
|
||||||
if (qi.content is MsgContent.MCFile) stringResource(MR.strings.icon_descr_file) else stringResource(MR.strings.voice_message),
|
if (qi.content is MsgContent.MCFile) stringResource(R.string.icon_descr_file) else stringResource(R.string.voice_message),
|
||||||
Modifier
|
Modifier
|
||||||
.padding(top = 6.dp, end = 4.dp)
|
.padding(top = 6.dp, end = 4.dp)
|
||||||
.size(22.dp),
|
.size(22.dp),
|
||||||
@@ -178,7 +158,7 @@ fun FramedItemView(
|
|||||||
fun ciFileView(ci: ChatItem, text: String) {
|
fun ciFileView(ci: ChatItem, text: String) {
|
||||||
CIFileView(ci.file, ci.meta.itemEdited, receiveFile)
|
CIFileView(ci.file, ci.meta.itemEdited, receiveFile)
|
||||||
if (text != "" || ci.meta.isLive) {
|
if (text != "" || ci.meta.isLive) {
|
||||||
CIMarkdownText(ci, chatTTL, linkMode = linkMode, uriHandler)
|
CIMarkdownText(ci, chatTTL, showMember, linkMode = linkMode, uriHandler)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -201,19 +181,13 @@ fun FramedItemView(
|
|||||||
Column(Modifier.width(IntrinsicSize.Max)) {
|
Column(Modifier.width(IntrinsicSize.Max)) {
|
||||||
PriorityLayout(Modifier, CHAT_IMAGE_LAYOUT_ID) {
|
PriorityLayout(Modifier, CHAT_IMAGE_LAYOUT_ID) {
|
||||||
if (ci.meta.itemDeleted != null) {
|
if (ci.meta.itemDeleted != null) {
|
||||||
when (ci.meta.itemDeleted) {
|
if (ci.meta.itemDeleted is CIDeleted.Moderated) {
|
||||||
is CIDeleted.Moderated -> {
|
FramedItemHeader(String.format(stringResource(R.string.moderated_item_description), ci.meta.itemDeleted.byGroupMember.chatViewName), true, painterResource(R.drawable.ic_flag))
|
||||||
FramedItemHeader(String.format(stringResource(MR.strings.moderated_item_description), ci.meta.itemDeleted.byGroupMember.chatViewName), true, painterResource(MR.images.ic_flag))
|
} else {
|
||||||
}
|
FramedItemHeader(stringResource(R.string.marked_deleted_description), true, painterResource(R.drawable.ic_delete))
|
||||||
is CIDeleted.Blocked -> {
|
|
||||||
FramedItemHeader(stringResource(MR.strings.blocked_item_description), true, painterResource(MR.images.ic_back_hand))
|
|
||||||
}
|
|
||||||
else -> {
|
|
||||||
FramedItemHeader(stringResource(MR.strings.marked_deleted_description), true, painterResource(MR.images.ic_delete))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} else if (ci.meta.isLive) {
|
} else if (ci.meta.isLive) {
|
||||||
FramedItemHeader(stringResource(MR.strings.live), false)
|
FramedItemHeader(stringResource(R.string.live), false)
|
||||||
}
|
}
|
||||||
ci.quotedItem?.let { ciQuoteView(it) }
|
ci.quotedItem?.let { ciQuoteView(it) }
|
||||||
if (ci.file == null && ci.formattedText == null && !ci.meta.isLive && isShortEmoji(ci.content.text)) {
|
if (ci.file == null && ci.formattedText == null && !ci.meta.isLive && isShortEmoji(ci.content.text)) {
|
||||||
@@ -231,11 +205,11 @@ fun FramedItemView(
|
|||||||
} else {
|
} else {
|
||||||
when (val mc = ci.content.msgContent) {
|
when (val mc = ci.content.msgContent) {
|
||||||
is MsgContent.MCImage -> {
|
is MsgContent.MCImage -> {
|
||||||
CIImageView(image = mc.image, file = ci.file, ci.encryptLocalFile, metaColor = metaColor, imageProvider ?: return@PriorityLayout, showMenu, receiveFile)
|
CIImageView(image = mc.image, file = ci.file, imageProvider ?: return@PriorityLayout, showMenu, receiveFile)
|
||||||
if (mc.text == "" && !ci.meta.isLive) {
|
if (mc.text == "" && !ci.meta.isLive) {
|
||||||
metaColor = Color.White
|
metaColor = Color.White
|
||||||
} else {
|
} else {
|
||||||
CIMarkdownText(ci, chatTTL, linkMode, uriHandler)
|
CIMarkdownText(ci, chatTTL, showMember, linkMode, uriHandler)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
is MsgContent.MCVideo -> {
|
is MsgContent.MCVideo -> {
|
||||||
@@ -243,29 +217,27 @@ fun FramedItemView(
|
|||||||
if (mc.text == "" && !ci.meta.isLive) {
|
if (mc.text == "" && !ci.meta.isLive) {
|
||||||
metaColor = Color.White
|
metaColor = Color.White
|
||||||
} else {
|
} else {
|
||||||
CIMarkdownText(ci, chatTTL, linkMode, uriHandler)
|
CIMarkdownText(ci, chatTTL, showMember, linkMode, uriHandler)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
is MsgContent.MCVoice -> {
|
is MsgContent.MCVoice -> {
|
||||||
CIVoiceView(mc.duration, ci.file, ci.meta.itemEdited, ci.chatDir.sent, hasText = true, ci, timedMessagesTTL = chatTTL, longClick = { onLinkLongClick("") }, receiveFile)
|
CIVoiceView(mc.duration, ci.file, ci.meta.itemEdited, ci.chatDir.sent, hasText = true, ci, timedMessagesTTL = chatTTL, longClick = { onLinkLongClick("") })
|
||||||
if (mc.text != "") {
|
if (mc.text != "") {
|
||||||
CIMarkdownText(ci, chatTTL, linkMode, uriHandler)
|
CIMarkdownText(ci, chatTTL, showMember, linkMode, uriHandler)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
is MsgContent.MCFile -> ciFileView(ci, mc.text)
|
is MsgContent.MCFile -> ciFileView(ci, mc.text)
|
||||||
is MsgContent.MCUnknown ->
|
is MsgContent.MCUnknown ->
|
||||||
if (ci.file == null) {
|
if (ci.file == null) {
|
||||||
CIMarkdownText(ci, chatTTL, linkMode, uriHandler, onLinkLongClick)
|
CIMarkdownText(ci, chatTTL, showMember, linkMode, uriHandler, onLinkLongClick)
|
||||||
} else {
|
} else {
|
||||||
ciFileView(ci, mc.text)
|
ciFileView(ci, mc.text)
|
||||||
}
|
}
|
||||||
is MsgContent.MCLink -> {
|
is MsgContent.MCLink -> {
|
||||||
ChatItemLinkView(mc.preview)
|
ChatItemLinkView(mc.preview)
|
||||||
Box(Modifier.widthIn(max = DEFAULT_MAX_IMAGE_WIDTH)) {
|
CIMarkdownText(ci, chatTTL, showMember, linkMode, uriHandler, onLinkLongClick)
|
||||||
CIMarkdownText(ci, chatTTL, linkMode, uriHandler, onLinkLongClick)
|
|
||||||
}
|
}
|
||||||
}
|
else -> CIMarkdownText(ci, chatTTL, showMember, linkMode, uriHandler, onLinkLongClick)
|
||||||
else -> CIMarkdownText(ci, chatTTL, linkMode, uriHandler, onLinkLongClick)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -281,6 +253,7 @@ fun FramedItemView(
|
|||||||
fun CIMarkdownText(
|
fun CIMarkdownText(
|
||||||
ci: ChatItem,
|
ci: ChatItem,
|
||||||
chatTTL: Int?,
|
chatTTL: Int?,
|
||||||
|
showMember: Boolean,
|
||||||
linkMode: SimplexLinkMode,
|
linkMode: SimplexLinkMode,
|
||||||
uriHandler: UriHandler?,
|
uriHandler: UriHandler?,
|
||||||
onLinkLongClick: (link: String) -> Unit = {}
|
onLinkLongClick: (link: String) -> Unit = {}
|
||||||
@@ -288,7 +261,7 @@ fun CIMarkdownText(
|
|||||||
Box(Modifier.padding(vertical = 6.dp, horizontal = 12.dp)) {
|
Box(Modifier.padding(vertical = 6.dp, horizontal = 12.dp)) {
|
||||||
val text = if (ci.meta.isLive) ci.content.msgContent?.text ?: ci.text else ci.text
|
val text = if (ci.meta.isLive) ci.content.msgContent?.text ?: ci.text else ci.text
|
||||||
MarkdownText(
|
MarkdownText(
|
||||||
text, if (text.isEmpty()) emptyList() else ci.formattedText,
|
text, if (text.isEmpty()) emptyList() else ci.formattedText, if (showMember) ci.memberDisplayName else null,
|
||||||
meta = ci.meta, chatTTL = chatTTL, linkMode = linkMode,
|
meta = ci.meta, chatTTL = chatTTL, linkMode = linkMode,
|
||||||
uriHandler = uriHandler, senderBold = true, onLinkLongClick = onLinkLongClick
|
uriHandler = uriHandler, senderBold = true, onLinkLongClick = onLinkLongClick
|
||||||
)
|
)
|
||||||
@@ -327,7 +300,7 @@ fun PriorityLayout(
|
|||||||
// Find important element which should tell what max width other elements can use
|
// Find important element which should tell what max width other elements can use
|
||||||
// Expecting only one such element. Can be less than one but not more
|
// Expecting only one such element. Can be less than one but not more
|
||||||
val imagePlaceable = measureable.firstOrNull { it.layoutId == priorityLayoutId }?.measure(constraints)
|
val imagePlaceable = measureable.firstOrNull { it.layoutId == priorityLayoutId }?.measure(constraints)
|
||||||
val placeables: List<Placeable> = measureable.map {
|
val placeables: List<Placeable> = measureable.fastMap {
|
||||||
if (it.layoutId == priorityLayoutId)
|
if (it.layoutId == priorityLayoutId)
|
||||||
imagePlaceable!!
|
imagePlaceable!!
|
||||||
else
|
else
|
||||||
@@ -344,7 +317,6 @@ fun PriorityLayout(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
/*
|
|
||||||
|
|
||||||
class EditedProvider: PreviewParameterProvider<Boolean> {
|
class EditedProvider: PreviewParameterProvider<Boolean> {
|
||||||
override val values = listOf(false, true).asSequence()
|
override val values = listOf(false, true).asSequence()
|
||||||
@@ -534,4 +506,3 @@ fun PreviewQuoteWithLongTextAndFile(@PreviewParameter(EditedProvider::class) edi
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
*/
|
|
||||||
+79
-41
@@ -1,23 +1,42 @@
|
|||||||
package chat.simplex.common.views.chat.item
|
package chat.simplex.app.views.chat.item
|
||||||
|
|
||||||
|
import android.graphics.Bitmap
|
||||||
|
import android.net.Uri
|
||||||
|
import android.os.Build
|
||||||
|
import android.view.View
|
||||||
|
import androidx.activity.compose.BackHandler
|
||||||
import androidx.compose.foundation.*
|
import androidx.compose.foundation.*
|
||||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.*
|
||||||
import androidx.compose.foundation.pager.HorizontalPager
|
import androidx.compose.material.*
|
||||||
import androidx.compose.foundation.pager.rememberPagerState
|
|
||||||
import androidx.compose.runtime.*
|
import androidx.compose.runtime.*
|
||||||
import androidx.compose.runtime.saveable.rememberSaveable
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.*
|
import androidx.compose.ui.graphics.*
|
||||||
|
import androidx.compose.ui.graphics.painter.BitmapPainter
|
||||||
import androidx.compose.ui.input.pointer.*
|
import androidx.compose.ui.input.pointer.*
|
||||||
|
import androidx.compose.ui.layout.ContentScale
|
||||||
import androidx.compose.ui.layout.onGloballyPositioned
|
import androidx.compose.ui.layout.onGloballyPositioned
|
||||||
import chat.simplex.common.platform.*
|
import androidx.compose.ui.platform.*
|
||||||
import chat.simplex.common.views.chat.ProviderMedia
|
import androidx.compose.ui.res.stringResource
|
||||||
import chat.simplex.common.views.helpers.*
|
import androidx.compose.ui.unit.*
|
||||||
|
import androidx.compose.ui.viewinterop.AndroidView
|
||||||
|
import androidx.core.view.isVisible
|
||||||
|
import chat.simplex.app.R
|
||||||
|
import chat.simplex.app.views.chat.ProviderMedia
|
||||||
|
import chat.simplex.app.views.helpers.*
|
||||||
|
import coil.ImageLoader
|
||||||
|
import coil.compose.rememberAsyncImagePainter
|
||||||
|
import coil.decode.GifDecoder
|
||||||
|
import coil.decode.ImageDecoderDecoder
|
||||||
|
import coil.request.ImageRequest
|
||||||
|
import coil.size.Size
|
||||||
|
import com.google.accompanist.pager.*
|
||||||
|
import com.google.android.exoplayer2.ui.AspectRatioFrameLayout
|
||||||
|
import com.google.android.exoplayer2.ui.StyledPlayerView
|
||||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import java.net.URI
|
|
||||||
import kotlin.math.absoluteValue
|
import kotlin.math.absoluteValue
|
||||||
|
|
||||||
interface ImageGalleryProvider {
|
interface ImageGalleryProvider {
|
||||||
@@ -29,15 +48,11 @@ interface ImageGalleryProvider {
|
|||||||
fun onDismiss(index: Int)
|
fun onDismiss(index: Int)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalPagerApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun ImageFullScreenView(imageProvider: () -> ImageGalleryProvider, close: () -> Unit) {
|
fun ImageFullScreenView(imageProvider: () -> ImageGalleryProvider, close: () -> Unit) {
|
||||||
val provider = remember { imageProvider() }
|
val provider = remember { imageProvider() }
|
||||||
val pagerState = rememberPagerState(
|
val pagerState = rememberPagerState(provider.initialIndex)
|
||||||
initialPage = provider.initialIndex,
|
|
||||||
initialPageOffsetFraction = 0f
|
|
||||||
) {
|
|
||||||
provider.totalMediaSize.value
|
|
||||||
}
|
|
||||||
val goBack = { provider.onDismiss(pagerState.currentPage); close() }
|
val goBack = { provider.onDismiss(pagerState.currentPage); close() }
|
||||||
BackHandler(onBack = goBack)
|
BackHandler(onBack = goBack)
|
||||||
// Pager doesn't ask previous page at initialization step who knows why. By not doing this, prev page is not checked and can be blank,
|
// Pager doesn't ask previous page at initialization step who knows why. By not doing this, prev page is not checked and can be blank,
|
||||||
@@ -49,13 +64,11 @@ fun ImageFullScreenView(imageProvider: () -> ImageGalleryProvider, close: () ->
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
val playersToRelease = rememberSaveable { mutableSetOf<URI>() }
|
val playersToRelease = rememberSaveable { mutableSetOf<Uri>() }
|
||||||
DisposableEffectOnGone(
|
DisposableEffectOnGone(
|
||||||
whenGone = { playersToRelease.forEach { VideoPlayerHolder.release(it, true, true) } }
|
whenGone = { playersToRelease.forEach { VideoPlayer.release(it, true, true) } }
|
||||||
)
|
)
|
||||||
|
HorizontalPager(count = remember { provider.totalMediaSize }.value, state = pagerState) { index ->
|
||||||
@Composable
|
|
||||||
fun Content(index: Int) {
|
|
||||||
Column(
|
Column(
|
||||||
Modifier
|
Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
@@ -127,14 +140,32 @@ fun ImageFullScreenView(imageProvider: () -> ImageGalleryProvider, close: () ->
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
// LALAL
|
|
||||||
// https://github.com/JetBrains/compose-multiplatform/pull/2015/files#diff-841b3825c504584012e1d1c834d731bae794cce6acad425d81847c8bbbf239e0R24
|
|
||||||
if (media is ProviderMedia.Image) {
|
if (media is ProviderMedia.Image) {
|
||||||
val (data: ByteArray, imageBitmap: ImageBitmap) = media
|
val (uri: Uri, imageBitmap: Bitmap) = media
|
||||||
FullScreenImageView(modifier, data, imageBitmap)
|
// I'm making a new instance of imageLoader here because if I use one instance in multiple places
|
||||||
|
// after end of composition here a GIF from the first instance will be paused automatically which isn't what I want
|
||||||
|
val imageLoader = ImageLoader.Builder(LocalContext.current)
|
||||||
|
.components {
|
||||||
|
if (Build.VERSION.SDK_INT >= 28) {
|
||||||
|
add(ImageDecoderDecoder.Factory())
|
||||||
|
} else {
|
||||||
|
add(GifDecoder.Factory())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.build()
|
||||||
|
Image(
|
||||||
|
rememberAsyncImagePainter(
|
||||||
|
ImageRequest.Builder(LocalContext.current).data(data = uri).size(Size.ORIGINAL).build(),
|
||||||
|
placeholder = BitmapPainter(imageBitmap.asImageBitmap()), // show original image while it's still loading by coil
|
||||||
|
imageLoader = imageLoader
|
||||||
|
),
|
||||||
|
contentDescription = stringResource(R.string.image_descr),
|
||||||
|
contentScale = ContentScale.Fit,
|
||||||
|
modifier = modifier,
|
||||||
|
)
|
||||||
} else if (media is ProviderMedia.Video) {
|
} else if (media is ProviderMedia.Video) {
|
||||||
val preview = remember(media.uri.path) { base64ToBitmap(media.preview) }
|
val preview = remember(media.uri.path) { base64ToBitmap(media.preview) }
|
||||||
VideoView(modifier, media.uri, preview, index == settledCurrentPage, close)
|
VideoView(modifier, media.uri, preview, index == settledCurrentPage)
|
||||||
DisposableEffect(Unit) {
|
DisposableEffect(Unit) {
|
||||||
onDispose { playersToRelease.add(media.uri) }
|
onDispose { playersToRelease.add(media.uri) }
|
||||||
}
|
}
|
||||||
@@ -142,19 +173,12 @@ fun ImageFullScreenView(imageProvider: () -> ImageGalleryProvider, close: () ->
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (appPlatform.isAndroid) {
|
|
||||||
HorizontalPager(state = pagerState) { index -> Content(index) }
|
|
||||||
} else {
|
|
||||||
Content(pagerState.currentPage)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
expect fun FullScreenImageView(modifier: Modifier, data: ByteArray, imageBitmap: ImageBitmap)
|
private fun VideoView(modifier: Modifier, uri: Uri, defaultPreview: Bitmap, currentPage: Boolean) {
|
||||||
|
val context = LocalContext.current
|
||||||
@Composable
|
val player = remember(uri) { VideoPlayer.getOrCreate(uri, true, defaultPreview, 0L, true, context) }
|
||||||
private fun VideoView(modifier: Modifier, uri: URI, defaultPreview: ImageBitmap, currentPage: Boolean, close: () -> Unit) {
|
|
||||||
val player = remember(uri) { VideoPlayerHolder.getOrCreate(uri, true, defaultPreview, 0L, true) }
|
|
||||||
val isCurrentPage = rememberUpdatedState(currentPage)
|
val isCurrentPage = rememberUpdatedState(currentPage)
|
||||||
val play = {
|
val play = {
|
||||||
player.play(true)
|
player.play(true)
|
||||||
@@ -163,18 +187,32 @@ private fun VideoView(modifier: Modifier, uri: URI, defaultPreview: ImageBitmap,
|
|||||||
player.stop()
|
player.stop()
|
||||||
}
|
}
|
||||||
LaunchedEffect(Unit) {
|
LaunchedEffect(Unit) {
|
||||||
|
player.enableSound(true)
|
||||||
snapshotFlow { isCurrentPage.value }
|
snapshotFlow { isCurrentPage.value }
|
||||||
.distinctUntilChanged()
|
.distinctUntilChanged()
|
||||||
.collect {
|
.collect { if (it) play() else stop() }
|
||||||
if (it) play() else stop()
|
|
||||||
player.enableSound(true)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||||
FullScreenVideoView(player, modifier, close)
|
AndroidView(
|
||||||
|
factory = { ctx ->
|
||||||
|
StyledPlayerView(ctx).apply {
|
||||||
|
resizeMode = if (ctx.resources.configuration.screenWidthDp > ctx.resources.configuration.screenHeightDp) {
|
||||||
|
AspectRatioFrameLayout.RESIZE_MODE_FIXED_HEIGHT
|
||||||
|
} else {
|
||||||
|
AspectRatioFrameLayout.RESIZE_MODE_FIXED_WIDTH
|
||||||
|
}
|
||||||
|
setShowPreviousButton(false)
|
||||||
|
setShowNextButton(false)
|
||||||
|
setShowSubtitleButton(false)
|
||||||
|
setShowVrButton(false)
|
||||||
|
controllerAutoShow = false
|
||||||
|
findViewById<View>(com.google.android.exoplayer2.R.id.exo_controls_background).setBackgroundColor(Color.Black.copy(alpha = 0.3f).toArgb())
|
||||||
|
findViewById<View>(com.google.android.exoplayer2.R.id.exo_settings).isVisible = false
|
||||||
|
this.player = player.player
|
||||||
|
}
|
||||||
|
},
|
||||||
|
modifier
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
|
||||||
expect fun FullScreenVideoView(player: VideoPlayer, modifier: Modifier, close: () -> Unit)
|
|
||||||
+26
-23
@@ -1,5 +1,6 @@
|
|||||||
package chat.simplex.common.views.chat.item
|
package chat.simplex.app.views.chat.item
|
||||||
|
|
||||||
|
import android.content.res.Configuration
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
@@ -12,45 +13,45 @@ import androidx.compose.ui.Modifier
|
|||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.text.*
|
import androidx.compose.ui.text.*
|
||||||
import androidx.compose.ui.text.font.FontStyle
|
import androidx.compose.ui.text.font.FontStyle
|
||||||
import androidx.compose.desktop.ui.tooling.preview.Preview
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
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 chat.simplex.common.model.ChatItem
|
import chat.simplex.app.R
|
||||||
import chat.simplex.common.model.MsgErrorType
|
import chat.simplex.app.model.ChatItem
|
||||||
import chat.simplex.common.ui.theme.CurrentColors
|
import chat.simplex.app.model.MsgErrorType
|
||||||
import chat.simplex.common.ui.theme.SimpleXTheme
|
import chat.simplex.app.ui.theme.CurrentColors
|
||||||
import chat.simplex.common.views.helpers.AlertManager
|
import chat.simplex.app.ui.theme.SimpleXTheme
|
||||||
import chat.simplex.common.views.helpers.generalGetString
|
import chat.simplex.app.views.helpers.AlertManager
|
||||||
import chat.simplex.res.MR
|
import chat.simplex.app.views.helpers.generalGetString
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun IntegrityErrorItemView(msgError: MsgErrorType, ci: ChatItem, timedMessagesTTL: Int?) {
|
fun IntegrityErrorItemView(msgError: MsgErrorType, ci: ChatItem, timedMessagesTTL: Int?, showMember: Boolean = false) {
|
||||||
CIMsgError(ci, timedMessagesTTL) {
|
CIMsgError(ci, timedMessagesTTL, showMember) {
|
||||||
when (msgError) {
|
when (msgError) {
|
||||||
is MsgErrorType.MsgSkipped ->
|
is MsgErrorType.MsgSkipped ->
|
||||||
AlertManager.shared.showAlertMsg(
|
AlertManager.shared.showAlertMsg(
|
||||||
title = generalGetString(MR.strings.alert_title_skipped_messages),
|
title = generalGetString(R.string.alert_title_skipped_messages),
|
||||||
text = generalGetString(MR.strings.alert_text_skipped_messages_it_can_happen_when)
|
text = generalGetString(R.string.alert_text_skipped_messages_it_can_happen_when)
|
||||||
)
|
)
|
||||||
is MsgErrorType.MsgBadHash ->
|
is MsgErrorType.MsgBadHash ->
|
||||||
AlertManager.shared.showAlertMsg(
|
AlertManager.shared.showAlertMsg(
|
||||||
title = generalGetString(MR.strings.alert_title_msg_bad_hash),
|
title = generalGetString(R.string.alert_title_msg_bad_hash),
|
||||||
text = generalGetString(MR.strings.alert_text_msg_bad_hash) + "\n" +
|
text = generalGetString(R.string.alert_text_msg_bad_hash) + "\n" +
|
||||||
generalGetString(MR.strings.alert_text_fragment_encryption_out_of_sync_old_database) + "\n" +
|
generalGetString(R.string.alert_text_fragment_encryption_out_of_sync_old_database) + "\n" +
|
||||||
generalGetString(MR.strings.alert_text_fragment_please_report_to_developers)
|
generalGetString(R.string.alert_text_fragment_please_report_to_developers)
|
||||||
)
|
)
|
||||||
is MsgErrorType.MsgBadId, is MsgErrorType.MsgDuplicate ->
|
is MsgErrorType.MsgBadId, is MsgErrorType.MsgDuplicate ->
|
||||||
AlertManager.shared.showAlertMsg(
|
AlertManager.shared.showAlertMsg(
|
||||||
title = generalGetString(MR.strings.alert_title_msg_bad_id),
|
title = generalGetString(R.string.alert_title_msg_bad_id),
|
||||||
text = generalGetString(MR.strings.alert_text_msg_bad_id) + "\n" +
|
text = generalGetString(R.string.alert_text_msg_bad_id) + "\n" +
|
||||||
generalGetString(MR.strings.alert_text_fragment_please_report_to_developers)
|
generalGetString(R.string.alert_text_fragment_please_report_to_developers)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun CIMsgError(ci: ChatItem, timedMessagesTTL: Int?, onClick: () -> Unit) {
|
fun CIMsgError(ci: ChatItem, timedMessagesTTL: Int?, showMember: Boolean = false, onClick: () -> Unit) {
|
||||||
val receivedColor = CurrentColors.collectAsState().value.appColors.receivedMessage
|
val receivedColor = CurrentColors.collectAsState().value.appColors.receivedMessage
|
||||||
Surface(
|
Surface(
|
||||||
Modifier.clickable(onClick = onClick),
|
Modifier.clickable(onClick = onClick),
|
||||||
@@ -63,6 +64,7 @@ fun CIMsgError(ci: ChatItem, timedMessagesTTL: Int?, onClick: () -> Unit) {
|
|||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
buildAnnotatedString {
|
buildAnnotatedString {
|
||||||
|
appendSender(this, if (showMember) ci.memberDisplayName else null, true)
|
||||||
withStyle(SpanStyle(fontStyle = FontStyle.Italic, color = Color.Red)) { append(ci.content.text) }
|
withStyle(SpanStyle(fontStyle = FontStyle.Italic, color = Color.Red)) { append(ci.content.text) }
|
||||||
},
|
},
|
||||||
style = MaterialTheme.typography.body1.copy(lineHeight = 22.sp),
|
style = MaterialTheme.typography.body1.copy(lineHeight = 22.sp),
|
||||||
@@ -73,10 +75,11 @@ fun CIMsgError(ci: ChatItem, timedMessagesTTL: Int?, onClick: () -> Unit) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Preview/*(
|
@Preview(showBackground = true)
|
||||||
|
@Preview(
|
||||||
uiMode = Configuration.UI_MODE_NIGHT_YES,
|
uiMode = Configuration.UI_MODE_NIGHT_YES,
|
||||||
name = "Dark Mode"
|
name = "Dark Mode"
|
||||||
)*/
|
)
|
||||||
@Composable
|
@Composable
|
||||||
fun IntegrityErrorItemViewPreview() {
|
fun IntegrityErrorItemViewPreview() {
|
||||||
SimpleXTheme {
|
SimpleXTheme {
|
||||||
+74
@@ -0,0 +1,74 @@
|
|||||||
|
package chat.simplex.app.views.chat.item
|
||||||
|
|
||||||
|
import android.content.res.Configuration
|
||||||
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material.*
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.text.*
|
||||||
|
import androidx.compose.ui.text.font.FontStyle
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import chat.simplex.app.R
|
||||||
|
import chat.simplex.app.model.CIDeleted
|
||||||
|
import chat.simplex.app.model.ChatItem
|
||||||
|
import chat.simplex.app.ui.theme.*
|
||||||
|
import chat.simplex.app.views.helpers.generalGetString
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun MarkedDeletedItemView(ci: ChatItem, timedMessagesTTL: Int?, showMember: Boolean = false) {
|
||||||
|
val sentColor = CurrentColors.collectAsState().value.appColors.sentMessage
|
||||||
|
val receivedColor = CurrentColors.collectAsState().value.appColors.receivedMessage
|
||||||
|
Surface(
|
||||||
|
shape = RoundedCornerShape(18.dp),
|
||||||
|
color = if (ci.chatDir.sent) sentColor else receivedColor,
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
Modifier.padding(horizontal = 12.dp, vertical = 6.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Box(Modifier.weight(1f, false)) {
|
||||||
|
if (ci.meta.itemDeleted is CIDeleted.Moderated) {
|
||||||
|
MarkedDeletedText(String.format(generalGetString(R.string.moderated_item_description), ci.meta.itemDeleted.byGroupMember.chatViewName))
|
||||||
|
} else {
|
||||||
|
MarkedDeletedText(generalGetString(R.string.marked_deleted_description))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
CIMetaView(ci, timedMessagesTTL)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun MarkedDeletedText(text: String) {
|
||||||
|
Text(
|
||||||
|
buildAnnotatedString {
|
||||||
|
// appendSender(this, if (showMember) ci.memberDisplayName else null, true) // TODO font size
|
||||||
|
withStyle(SpanStyle(fontSize = 12.sp, fontStyle = FontStyle.Italic, color = MaterialTheme.colors.secondary)) { append(text) }
|
||||||
|
},
|
||||||
|
style = MaterialTheme.typography.body1.copy(lineHeight = 22.sp),
|
||||||
|
modifier = Modifier.padding(end = 8.dp),
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Preview(showBackground = true)
|
||||||
|
@Preview(
|
||||||
|
uiMode = Configuration.UI_MODE_NIGHT_YES,
|
||||||
|
name = "Dark Mode"
|
||||||
|
)
|
||||||
|
@Composable
|
||||||
|
fun PreviewMarkedDeletedItemView() {
|
||||||
|
SimpleXTheme {
|
||||||
|
DeletedItemView(
|
||||||
|
ChatItem.getSampleData(itemDeleted = CIDeleted.Deleted()),
|
||||||
|
null
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
+40
-57
@@ -1,32 +1,43 @@
|
|||||||
package chat.simplex.common.views.chat.item
|
package chat.simplex.app.views.chat.item
|
||||||
|
|
||||||
import androidx.compose.foundation.text.BasicText
|
import android.app.Activity
|
||||||
import androidx.compose.foundation.text.InlineTextContent
|
import android.content.ActivityNotFoundException
|
||||||
|
import android.util.Log
|
||||||
|
import androidx.annotation.IntRange
|
||||||
|
import androidx.compose.foundation.text.*
|
||||||
import androidx.compose.material.MaterialTheme
|
import androidx.compose.material.MaterialTheme
|
||||||
import androidx.compose.material.Text
|
import androidx.compose.material.Text
|
||||||
import androidx.compose.runtime.*
|
import androidx.compose.runtime.*
|
||||||
import androidx.compose.runtime.saveable.rememberSaveable
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.input.pointer.*
|
import androidx.compose.ui.input.pointer.pointerInput
|
||||||
import androidx.compose.ui.platform.*
|
import androidx.compose.ui.platform.*
|
||||||
import androidx.compose.ui.text.*
|
import androidx.compose.ui.text.*
|
||||||
import androidx.compose.ui.text.font.FontFamily
|
import androidx.compose.ui.text.font.FontFamily
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.text.style.TextDecoration
|
import androidx.compose.ui.text.style.TextDecoration
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.LayoutDirection
|
import androidx.compose.ui.unit.*
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.core.text.BidiFormatter
|
||||||
import chat.simplex.common.model.*
|
import chat.simplex.app.TAG
|
||||||
import chat.simplex.common.platform.*
|
import chat.simplex.app.model.*
|
||||||
import chat.simplex.common.ui.theme.CurrentColors
|
import chat.simplex.app.ui.theme.CurrentColors
|
||||||
import chat.simplex.common.views.helpers.*
|
import chat.simplex.app.views.helpers.detectGesture
|
||||||
import kotlinx.coroutines.*
|
import kotlinx.coroutines.*
|
||||||
import java.awt.*
|
|
||||||
|
|
||||||
val reserveTimestampStyle = SpanStyle(color = Color.Transparent)
|
val reserveTimestampStyle = SpanStyle(color = Color.Transparent)
|
||||||
val boldFont = SpanStyle(fontWeight = FontWeight.Medium)
|
val boldFont = SpanStyle(fontWeight = FontWeight.Medium)
|
||||||
|
|
||||||
|
fun appendGroupMember(b: AnnotatedString.Builder, chatItem: ChatItem, groupMemberBold: Boolean) {
|
||||||
|
if (chatItem.chatDir is CIDirection.GroupRcv) {
|
||||||
|
val name = chatItem.chatDir.groupMember.memberProfile.displayName
|
||||||
|
if (groupMemberBold) b.withStyle(boldFont) { append(name) }
|
||||||
|
else b.append(name)
|
||||||
|
b.append(": ")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun appendSender(b: AnnotatedString.Builder, sender: String?, senderBold: Boolean) {
|
fun appendSender(b: AnnotatedString.Builder, sender: String?, senderBold: Boolean) {
|
||||||
if (sender != null) {
|
if (sender != null) {
|
||||||
if (senderBold) b.withStyle(boldFont) { append(sender) }
|
if (senderBold) b.withStyle(boldFont) { append(sender) }
|
||||||
@@ -45,7 +56,7 @@ private val typingIndicators: List<AnnotatedString> = listOf(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
private fun typingIndicator(recent: Boolean, typingIdx: Int): AnnotatedString = buildAnnotatedString {
|
private fun typingIndicator(recent: Boolean, @IntRange (from = 0, to = 4) typingIdx: Int): AnnotatedString = buildAnnotatedString {
|
||||||
pushStyle(SpanStyle(color = CurrentColors.value.colors.secondary, fontFamily = FontFamily.Monospace, letterSpacing = (-1).sp))
|
pushStyle(SpanStyle(color = CurrentColors.value.colors.secondary, fontFamily = FontFamily.Monospace, letterSpacing = (-1).sp))
|
||||||
append(if (recent) typingIndicators[typingIdx] else noTyping)
|
append(if (recent) typingIndicators[typingIdx] else noTyping)
|
||||||
}
|
}
|
||||||
@@ -71,12 +82,12 @@ fun MarkdownText (
|
|||||||
onLinkLongClick: (link: String) -> Unit = {}
|
onLinkLongClick: (link: String) -> Unit = {}
|
||||||
) {
|
) {
|
||||||
val textLayoutDirection = remember (text) {
|
val textLayoutDirection = remember (text) {
|
||||||
if (isRtl(text.subSequence(0, kotlin.math.min(50, text.length)))) LayoutDirection.Rtl else LayoutDirection.Ltr
|
if (BidiFormatter.getInstance().isRtl(text.subSequence(0, kotlin.math.min(50, text.length)))) LayoutDirection.Rtl else LayoutDirection.Ltr
|
||||||
}
|
}
|
||||||
val reserve = if (textLayoutDirection != LocalLayoutDirection.current && meta != null) {
|
val reserve = if (textLayoutDirection != LocalLayoutDirection.current && meta != null) {
|
||||||
"\n"
|
"\n"
|
||||||
} else if (meta != null) {
|
} else if (meta != null) {
|
||||||
reserveSpaceForMeta(meta, chatTTL, null) // LALAL
|
reserveSpaceForMeta(meta, chatTTL)
|
||||||
} else {
|
} else {
|
||||||
" "
|
" "
|
||||||
}
|
}
|
||||||
@@ -106,14 +117,18 @@ fun MarkdownText (
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (meta?.isLive == true) {
|
if (meta?.isLive == true) {
|
||||||
|
val activity = LocalContext.current as Activity
|
||||||
LaunchedEffect(meta.recent, meta.isLive) {
|
LaunchedEffect(meta.recent, meta.isLive) {
|
||||||
switchTyping()
|
switchTyping()
|
||||||
}
|
}
|
||||||
DisposableEffectOnGone(
|
DisposableEffect(Unit) {
|
||||||
whenGone = {
|
val orientation = activity.resources.configuration.orientation
|
||||||
|
onDispose {
|
||||||
|
if (orientation == activity.resources.configuration.orientation) {
|
||||||
stopTyping()
|
stopTyping()
|
||||||
}
|
}
|
||||||
)
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (formattedText == null) {
|
if (formattedText == null) {
|
||||||
val annotatedText = buildAnnotatedString {
|
val annotatedText = buildAnnotatedString {
|
||||||
@@ -136,8 +151,12 @@ fun MarkdownText (
|
|||||||
val link = ft.link(linkMode)
|
val link = ft.link(linkMode)
|
||||||
if (link != null) {
|
if (link != null) {
|
||||||
hasLinks = true
|
hasLinks = true
|
||||||
val ftStyle = ft.format.style
|
val ftStyle = if (ft.format is Format.SimplexLink && !ft.format.trustedUri && linkMode == SimplexLinkMode.BROWSER) {
|
||||||
withAnnotation(tag = if (ft.format is Format.SimplexLink) "SIMPLEX_URL" else "URL", annotation = link) {
|
SpanStyle(color = Color.Red, textDecoration = TextDecoration.Underline)
|
||||||
|
} else {
|
||||||
|
ft.format.style
|
||||||
|
}
|
||||||
|
withAnnotation(tag = "URL", annotation = link) {
|
||||||
withStyle(ftStyle) { append(ft.viewText(linkMode)) }
|
withStyle(ftStyle) { append(ft.viewText(linkMode)) }
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -154,42 +173,25 @@ fun MarkdownText (
|
|||||||
else */if (meta != null) withStyle(reserveTimestampStyle) { append(reserve) }
|
else */if (meta != null) withStyle(reserveTimestampStyle) { append(reserve) }
|
||||||
}
|
}
|
||||||
if (hasLinks && uriHandler != null) {
|
if (hasLinks && uriHandler != null) {
|
||||||
val icon = remember { mutableStateOf(PointerIcon.Default) }
|
ClickableText(annotatedText, style = style, modifier = modifier, maxLines = maxLines, overflow = overflow,
|
||||||
ClickableText(annotatedText, style = style, modifier = modifier.pointerHoverIcon(icon.value), maxLines = maxLines, overflow = overflow,
|
|
||||||
onLongClick = { offset ->
|
onLongClick = { offset ->
|
||||||
annotatedText.getStringAnnotations(tag = "URL", start = offset, end = offset)
|
annotatedText.getStringAnnotations(tag = "URL", start = offset, end = offset)
|
||||||
.firstOrNull()?.let { annotation -> onLinkLongClick(annotation.item) }
|
.firstOrNull()?.let { annotation -> onLinkLongClick(annotation.item) }
|
||||||
annotatedText.getStringAnnotations(tag = "SIMPLEX_URL", start = offset, end = offset)
|
|
||||||
.firstOrNull()?.let { annotation -> onLinkLongClick(annotation.item) }
|
|
||||||
},
|
},
|
||||||
onClick = { offset ->
|
onClick = { offset ->
|
||||||
annotatedText.getStringAnnotations(tag = "URL", start = offset, end = offset)
|
annotatedText.getStringAnnotations(tag = "URL", start = offset, end = offset)
|
||||||
.firstOrNull()?.let { annotation ->
|
.firstOrNull()?.let { annotation ->
|
||||||
try {
|
try {
|
||||||
uriHandler.openUri(annotation.item)
|
uriHandler.openUri(annotation.item)
|
||||||
} catch (e: Exception) {
|
} catch (e: ActivityNotFoundException) {
|
||||||
// It can happen, for example, when you click on a text 0.00001 but don't have any app that can catch
|
// It can happen, for example, when you click on a text 0.00001 but don't have any app that can catch
|
||||||
// `tel:` scheme in url installed on a device (no phone app or contacts, maybe)
|
// `tel:` scheme in url installed on a device (no phone app or contacts, maybe)
|
||||||
Log.e(TAG, "Open url: ${e.stackTraceToString()}")
|
Log.e(TAG, "Open url: ${e.stackTraceToString()}")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
annotatedText.getStringAnnotations(tag = "SIMPLEX_URL", start = offset, end = offset)
|
|
||||||
.firstOrNull()?.let { annotation ->
|
|
||||||
uriHandler.openVerifiedSimplexUri(annotation.item)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onHover = { offset ->
|
|
||||||
icon.value = annotatedText.getStringAnnotations(tag = "URL", start = offset, end = offset)
|
|
||||||
.firstOrNull()?.let {
|
|
||||||
PointerIcon.Hand
|
|
||||||
} ?: annotatedText.getStringAnnotations(tag = "SIMPLEX_URL", start = offset, end = offset)
|
|
||||||
.firstOrNull()?.let {
|
|
||||||
PointerIcon.Hand
|
|
||||||
} ?: PointerIcon.Default
|
|
||||||
},
|
},
|
||||||
shouldConsumeEvent = { offset ->
|
shouldConsumeEvent = { offset ->
|
||||||
annotatedText.getStringAnnotations(tag = "URL", start = offset, end = offset).any()
|
annotatedText.getStringAnnotations(tag = "URL", start = offset, end = offset).any()
|
||||||
annotatedText.getStringAnnotations(tag = "SIMPLEX_URL", start = offset, end = offset).any()
|
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
@@ -210,7 +212,6 @@ fun ClickableText(
|
|||||||
onTextLayout: (TextLayoutResult) -> Unit = {},
|
onTextLayout: (TextLayoutResult) -> Unit = {},
|
||||||
onClick: (Int) -> Unit,
|
onClick: (Int) -> Unit,
|
||||||
onLongClick: (Int) -> Unit = {},
|
onLongClick: (Int) -> Unit = {},
|
||||||
onHover: (Int) -> Unit = {},
|
|
||||||
shouldConsumeEvent: (Int) -> Boolean
|
shouldConsumeEvent: (Int) -> Boolean
|
||||||
) {
|
) {
|
||||||
val layoutResult = remember { mutableStateOf<TextLayoutResult?>(null) }
|
val layoutResult = remember { mutableStateOf<TextLayoutResult?>(null) }
|
||||||
@@ -234,14 +235,6 @@ fun ClickableText(
|
|||||||
consume
|
consume
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}.pointerInput(onHover) {
|
|
||||||
if (appPlatform.isDesktop) {
|
|
||||||
detectCursorMove { pos ->
|
|
||||||
layoutResult.value?.let { layoutResult ->
|
|
||||||
onHover(layoutResult.getOffsetForPosition(pos))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
BasicText(
|
BasicText(
|
||||||
@@ -257,13 +250,3 @@ fun ClickableText(
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun isRtl(s: CharSequence): Boolean {
|
|
||||||
for (element in s) {
|
|
||||||
val d = Character.getDirectionality(element)
|
|
||||||
if (d == Character.DIRECTIONALITY_RIGHT_TO_LEFT || d == Character.DIRECTIONALITY_RIGHT_TO_LEFT_ARABIC || d == Character.DIRECTIONALITY_RIGHT_TO_LEFT_EMBEDDING || d == Character.DIRECTIONALITY_RIGHT_TO_LEFT_OVERRIDE) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
package chat.simplex.app.views.chatlist
|
||||||
|
|
||||||
|
import android.content.res.Configuration
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.material.*
|
||||||
|
import androidx.compose.ui.res.painterResource
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.platform.LocalUriHandler
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.SpanStyle
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import chat.simplex.app.R
|
||||||
|
import chat.simplex.app.ui.theme.SimpleXTheme
|
||||||
|
import chat.simplex.app.views.helpers.annotatedStringResource
|
||||||
|
import chat.simplex.app.views.helpers.openUriCatching
|
||||||
|
import chat.simplex.app.views.usersettings.MarkdownHelpView
|
||||||
|
import chat.simplex.app.views.usersettings.simplexTeamUri
|
||||||
|
|
||||||
|
val bold = SpanStyle(fontWeight = FontWeight.Bold)
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun ChatHelpView(addContact: (() -> Unit)? = null) {
|
||||||
|
Column(
|
||||||
|
verticalArrangement = Arrangement.spacedBy(10.dp)
|
||||||
|
) {
|
||||||
|
val uriHandler = LocalUriHandler.current
|
||||||
|
|
||||||
|
Text(stringResource(R.string.thank_you_for_installing_simplex), lineHeight = 22.sp)
|
||||||
|
Text(
|
||||||
|
annotatedStringResource(R.string.you_can_connect_to_simplex_chat_founder),
|
||||||
|
modifier = Modifier.clickable(onClick = {
|
||||||
|
uriHandler.openUriCatching(simplexTeamUri)
|
||||||
|
}),
|
||||||
|
lineHeight = 22.sp
|
||||||
|
)
|
||||||
|
|
||||||
|
Column(
|
||||||
|
Modifier.padding(top = 24.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(10.dp)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
stringResource(R.string.to_start_a_new_chat_help_header),
|
||||||
|
style = MaterialTheme.typography.h2,
|
||||||
|
lineHeight = 22.sp
|
||||||
|
)
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||||
|
) {
|
||||||
|
Text(stringResource(R.string.chat_help_tap_button))
|
||||||
|
Icon(
|
||||||
|
painterResource(R.drawable.ic_person_add),
|
||||||
|
stringResource(R.string.add_contact),
|
||||||
|
modifier = if (addContact != null) Modifier.clickable(onClick = addContact) else Modifier,
|
||||||
|
)
|
||||||
|
Text(stringResource(R.string.above_then_preposition_continuation))
|
||||||
|
}
|
||||||
|
Text(annotatedStringResource(R.string.add_new_contact_to_create_one_time_QR_code), lineHeight = 22.sp)
|
||||||
|
Text(annotatedStringResource(R.string.scan_QR_code_to_connect_to_contact_who_shows_QR_code), lineHeight = 22.sp)
|
||||||
|
}
|
||||||
|
|
||||||
|
Column(
|
||||||
|
Modifier.padding(top = 24.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(10.dp)
|
||||||
|
) {
|
||||||
|
Text(stringResource(R.string.to_connect_via_link_title), style = MaterialTheme.typography.h2)
|
||||||
|
Text(stringResource(R.string.if_you_received_simplex_invitation_link_you_can_open_in_browser), lineHeight = 22.sp)
|
||||||
|
Text(annotatedStringResource(R.string.desktop_scan_QR_code_from_app_via_scan_QR_code), lineHeight = 22.sp)
|
||||||
|
Text(annotatedStringResource(R.string.mobile_tap_open_in_mobile_app_then_tap_connect_in_app), lineHeight = 22.sp)
|
||||||
|
}
|
||||||
|
|
||||||
|
Column(
|
||||||
|
Modifier.padding(vertical = 24.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(10.dp)
|
||||||
|
) {
|
||||||
|
Text(stringResource(R.string.markdown_in_messages), style = MaterialTheme.typography.h2)
|
||||||
|
MarkdownHelpView()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Preview(showBackground = true)
|
||||||
|
@Preview(
|
||||||
|
uiMode = Configuration.UI_MODE_NIGHT_YES,
|
||||||
|
showBackground = true,
|
||||||
|
name = "Dark Mode"
|
||||||
|
)
|
||||||
|
@Composable
|
||||||
|
fun PreviewChatHelpLayout() {
|
||||||
|
SimpleXTheme {
|
||||||
|
ChatHelpView {}
|
||||||
|
}
|
||||||
|
}
|
||||||
+687
@@ -0,0 +1,687 @@
|
|||||||
|
package chat.simplex.app.views.chatlist
|
||||||
|
|
||||||
|
import android.content.res.Configuration
|
||||||
|
import androidx.compose.foundation.combinedClickable
|
||||||
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.material.*
|
||||||
|
import androidx.compose.runtime.*
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
|
import androidx.compose.ui.res.painterResource
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import chat.simplex.app.R
|
||||||
|
import chat.simplex.app.model.*
|
||||||
|
import chat.simplex.app.ui.theme.*
|
||||||
|
import chat.simplex.app.views.chat.*
|
||||||
|
import chat.simplex.app.views.chat.group.deleteGroupDialog
|
||||||
|
import chat.simplex.app.views.chat.group.leaveGroupDialog
|
||||||
|
import chat.simplex.app.views.chat.item.InvalidJSONView
|
||||||
|
import chat.simplex.app.views.chat.item.ItemAction
|
||||||
|
import chat.simplex.app.views.helpers.*
|
||||||
|
import chat.simplex.app.views.newchat.ContactConnectionInfoView
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.datetime.Clock
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun ChatListNavLinkView(chat: Chat, chatModel: ChatModel) {
|
||||||
|
val showMenu = remember { mutableStateOf(false) }
|
||||||
|
val showMarkRead = remember(chat.chatStats.unreadCount, chat.chatStats.unreadChat) {
|
||||||
|
chat.chatStats.unreadCount > 0 || chat.chatStats.unreadChat
|
||||||
|
}
|
||||||
|
val stopped = chatModel.chatRunning.value == false
|
||||||
|
val linkMode by remember { chatModel.controller.appPrefs.simplexLinkMode.state }
|
||||||
|
LaunchedEffect(chat.id) {
|
||||||
|
showMenu.value = false
|
||||||
|
delay(500L)
|
||||||
|
}
|
||||||
|
when (chat.chatInfo) {
|
||||||
|
is ChatInfo.Direct -> {
|
||||||
|
val contactNetworkStatus = chatModel.contactNetworkStatus(chat.chatInfo.contact)
|
||||||
|
ChatListNavLinkLayout(
|
||||||
|
chatLinkPreview = { ChatPreviewView(chat, chatModel.draft.value, chatModel.draftChatId.value, chatModel.incognito.value, chatModel.currentUser.value?.profile?.displayName, contactNetworkStatus, stopped, linkMode) },
|
||||||
|
click = { directChatAction(chat.chatInfo, chatModel) },
|
||||||
|
dropdownMenuItems = { ContactMenuItems(chat, chatModel, showMenu, showMarkRead) },
|
||||||
|
showMenu,
|
||||||
|
stopped
|
||||||
|
)
|
||||||
|
}
|
||||||
|
is ChatInfo.Group ->
|
||||||
|
ChatListNavLinkLayout(
|
||||||
|
chatLinkPreview = { ChatPreviewView(chat, chatModel.draft.value, chatModel.draftChatId.value, chatModel.incognito.value, chatModel.currentUser.value?.profile?.displayName, null, stopped, linkMode) },
|
||||||
|
click = { groupChatAction(chat.chatInfo.groupInfo, chatModel) },
|
||||||
|
dropdownMenuItems = { GroupMenuItems(chat, chat.chatInfo.groupInfo, chatModel, showMenu, showMarkRead) },
|
||||||
|
showMenu,
|
||||||
|
stopped
|
||||||
|
)
|
||||||
|
is ChatInfo.ContactRequest ->
|
||||||
|
ChatListNavLinkLayout(
|
||||||
|
chatLinkPreview = { ContactRequestView(chatModel.incognito.value, chat.chatInfo) },
|
||||||
|
click = { contactRequestAlertDialog(chat.chatInfo, chatModel) },
|
||||||
|
dropdownMenuItems = { ContactRequestMenuItems(chat.chatInfo, chatModel, showMenu) },
|
||||||
|
showMenu,
|
||||||
|
stopped
|
||||||
|
)
|
||||||
|
is ChatInfo.ContactConnection ->
|
||||||
|
ChatListNavLinkLayout(
|
||||||
|
chatLinkPreview = { ContactConnectionView(chat.chatInfo.contactConnection) },
|
||||||
|
click = {
|
||||||
|
ModalManager.shared.showModalCloseable(true) { close ->
|
||||||
|
ContactConnectionInfoView(chatModel, chat.chatInfo.contactConnection.connReqInv, chat.chatInfo.contactConnection, false, close)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
dropdownMenuItems = { ContactConnectionMenuItems(chat.chatInfo, chatModel, showMenu) },
|
||||||
|
showMenu,
|
||||||
|
stopped
|
||||||
|
)
|
||||||
|
is ChatInfo.InvalidJSON ->
|
||||||
|
ChatListNavLinkLayout(
|
||||||
|
chatLinkPreview = {
|
||||||
|
InvalidDataView()
|
||||||
|
},
|
||||||
|
click = {
|
||||||
|
ModalManager.shared.showModal(true) { InvalidJSONView(chat.chatInfo.json) }
|
||||||
|
},
|
||||||
|
dropdownMenuItems = null,
|
||||||
|
showMenu,
|
||||||
|
stopped
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun directChatAction(chatInfo: ChatInfo, chatModel: ChatModel) {
|
||||||
|
if (chatInfo.ready) {
|
||||||
|
withApi { openChat(chatInfo, chatModel) }
|
||||||
|
} else {
|
||||||
|
pendingContactAlertDialog(chatInfo, chatModel)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun groupChatAction(groupInfo: GroupInfo, chatModel: ChatModel) {
|
||||||
|
when (groupInfo.membership.memberStatus) {
|
||||||
|
GroupMemberStatus.MemInvited -> acceptGroupInvitationAlertDialog(groupInfo, chatModel)
|
||||||
|
GroupMemberStatus.MemAccepted -> groupInvitationAcceptedAlert()
|
||||||
|
else -> withApi { openChat(ChatInfo.Group(groupInfo), chatModel) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun openChat(chatInfo: ChatInfo, chatModel: ChatModel) {
|
||||||
|
val chat = chatModel.controller.apiGetChat(chatInfo.chatType, chatInfo.apiId)
|
||||||
|
if (chat != null) {
|
||||||
|
chatModel.chatItems.clear()
|
||||||
|
chatModel.chatItems.addAll(chat.chatItems)
|
||||||
|
chatModel.chatId.value = chatInfo.id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun apiLoadPrevMessages(chatInfo: ChatInfo, chatModel: ChatModel, beforeChatItemId: Long, search: String) {
|
||||||
|
val pagination = ChatPagination.Before(beforeChatItemId, ChatPagination.PRELOAD_COUNT)
|
||||||
|
val chat = chatModel.controller.apiGetChat(chatInfo.chatType, chatInfo.apiId, pagination, search) ?: return
|
||||||
|
chatModel.chatItems.addAll(0, chat.chatItems)
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun apiFindMessages(chatInfo: ChatInfo, chatModel: ChatModel, search: String) {
|
||||||
|
val chat = chatModel.controller.apiGetChat(chatInfo.chatType, chatInfo.apiId, search = search) ?: return
|
||||||
|
chatModel.chatItems.clear()
|
||||||
|
chatModel.chatItems.addAll(0, chat.chatItems)
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun setGroupMembers(groupInfo: GroupInfo, chatModel: ChatModel) {
|
||||||
|
val groupMembers = chatModel.controller.apiListMembers(groupInfo.groupId)
|
||||||
|
chatModel.groupMembers.clear()
|
||||||
|
chatModel.groupMembers.addAll(groupMembers)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun ContactMenuItems(chat: Chat, chatModel: ChatModel, showMenu: MutableState<Boolean>, showMarkRead: Boolean) {
|
||||||
|
if (showMarkRead) {
|
||||||
|
MarkReadChatAction(chat, chatModel, showMenu)
|
||||||
|
} else {
|
||||||
|
MarkUnreadChatAction(chat, chatModel, showMenu)
|
||||||
|
}
|
||||||
|
ToggleNotificationsChatAction(chat, chatModel, chat.chatInfo.ntfsEnabled, showMenu)
|
||||||
|
ClearChatAction(chat, chatModel, showMenu)
|
||||||
|
DeleteContactAction(chat, chatModel, showMenu)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun GroupMenuItems(chat: Chat, groupInfo: GroupInfo, chatModel: ChatModel, showMenu: MutableState<Boolean>, showMarkRead: Boolean) {
|
||||||
|
when (groupInfo.membership.memberStatus) {
|
||||||
|
GroupMemberStatus.MemInvited -> {
|
||||||
|
JoinGroupAction(chat, groupInfo, chatModel, showMenu)
|
||||||
|
if (groupInfo.canDelete) {
|
||||||
|
DeleteGroupAction(chat, groupInfo, chatModel, showMenu)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else -> {
|
||||||
|
if (showMarkRead) {
|
||||||
|
MarkReadChatAction(chat, chatModel, showMenu)
|
||||||
|
} else {
|
||||||
|
MarkUnreadChatAction(chat, chatModel, showMenu)
|
||||||
|
}
|
||||||
|
ToggleNotificationsChatAction(chat, chatModel, chat.chatInfo.ntfsEnabled, showMenu)
|
||||||
|
ClearChatAction(chat, chatModel, showMenu)
|
||||||
|
if (groupInfo.membership.memberCurrent) {
|
||||||
|
LeaveGroupAction(groupInfo, chatModel, showMenu)
|
||||||
|
}
|
||||||
|
if (groupInfo.canDelete) {
|
||||||
|
DeleteGroupAction(chat, groupInfo, chatModel, showMenu)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun MarkReadChatAction(chat: Chat, chatModel: ChatModel, showMenu: MutableState<Boolean>) {
|
||||||
|
ItemAction(
|
||||||
|
stringResource(R.string.mark_read),
|
||||||
|
painterResource(R.drawable.ic_check),
|
||||||
|
onClick = {
|
||||||
|
markChatRead(chat, chatModel)
|
||||||
|
chatModel.controller.ntfManager.cancelNotificationsForChat(chat.id)
|
||||||
|
showMenu.value = false
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun MarkUnreadChatAction(chat: Chat, chatModel: ChatModel, showMenu: MutableState<Boolean>) {
|
||||||
|
ItemAction(
|
||||||
|
stringResource(R.string.mark_unread),
|
||||||
|
painterResource(R.drawable.ic_mark_chat_unread),
|
||||||
|
onClick = {
|
||||||
|
markChatUnread(chat, chatModel)
|
||||||
|
showMenu.value = false
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun ToggleNotificationsChatAction(chat: Chat, chatModel: ChatModel, ntfsEnabled: Boolean, showMenu: MutableState<Boolean>) {
|
||||||
|
ItemAction(
|
||||||
|
if (ntfsEnabled) stringResource(R.string.mute_chat) else stringResource(R.string.unmute_chat),
|
||||||
|
if (ntfsEnabled) painterResource(R.drawable.ic_notifications_off) else painterResource(R.drawable.ic_notifications),
|
||||||
|
onClick = {
|
||||||
|
changeNtfsStatePerChat(!ntfsEnabled, mutableStateOf(ntfsEnabled), chat, chatModel)
|
||||||
|
showMenu.value = false
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun ClearChatAction(chat: Chat, chatModel: ChatModel, showMenu: MutableState<Boolean>) {
|
||||||
|
ItemAction(
|
||||||
|
stringResource(R.string.clear_chat_menu_action),
|
||||||
|
painterResource(R.drawable.ic_settings_backup_restore),
|
||||||
|
onClick = {
|
||||||
|
clearChatDialog(chat.chatInfo, chatModel)
|
||||||
|
showMenu.value = false
|
||||||
|
},
|
||||||
|
color = WarningOrange
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun DeleteContactAction(chat: Chat, chatModel: ChatModel, showMenu: MutableState<Boolean>) {
|
||||||
|
ItemAction(
|
||||||
|
stringResource(R.string.delete_contact_menu_action),
|
||||||
|
painterResource(R.drawable.ic_delete),
|
||||||
|
onClick = {
|
||||||
|
deleteContactDialog(chat.chatInfo, chatModel)
|
||||||
|
showMenu.value = false
|
||||||
|
},
|
||||||
|
color = Color.Red
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun DeleteGroupAction(chat: Chat, groupInfo: GroupInfo, chatModel: ChatModel, showMenu: MutableState<Boolean>) {
|
||||||
|
ItemAction(
|
||||||
|
stringResource(R.string.delete_group_menu_action),
|
||||||
|
painterResource(R.drawable.ic_delete),
|
||||||
|
onClick = {
|
||||||
|
deleteGroupDialog(chat.chatInfo, groupInfo, chatModel)
|
||||||
|
showMenu.value = false
|
||||||
|
},
|
||||||
|
color = Color.Red
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun JoinGroupAction(chat: Chat, groupInfo: GroupInfo, chatModel: ChatModel, showMenu: MutableState<Boolean>) {
|
||||||
|
val joinGroup: () -> Unit = { withApi { chatModel.controller.apiJoinGroup(groupInfo.groupId) } }
|
||||||
|
ItemAction(
|
||||||
|
if (chat.chatInfo.incognito) stringResource(R.string.join_group_incognito_button) else stringResource(R.string.join_group_button),
|
||||||
|
if (chat.chatInfo.incognito) painterResource(R.drawable.ic_theater_comedy_filled) else painterResource(R.drawable.ic_login),
|
||||||
|
color = if (chat.chatInfo.incognito) Indigo else MaterialTheme.colors.onBackground,
|
||||||
|
onClick = {
|
||||||
|
joinGroup()
|
||||||
|
showMenu.value = false
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun LeaveGroupAction(groupInfo: GroupInfo, chatModel: ChatModel, showMenu: MutableState<Boolean>) {
|
||||||
|
ItemAction(
|
||||||
|
stringResource(R.string.leave_group_button),
|
||||||
|
painterResource(R.drawable.ic_logout),
|
||||||
|
onClick = {
|
||||||
|
leaveGroupDialog(groupInfo, chatModel)
|
||||||
|
showMenu.value = false
|
||||||
|
},
|
||||||
|
color = Color.Red
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun ContactRequestMenuItems(chatInfo: ChatInfo.ContactRequest, chatModel: ChatModel, showMenu: MutableState<Boolean>) {
|
||||||
|
ItemAction(
|
||||||
|
if (chatModel.incognito.value) stringResource(R.string.accept_contact_incognito_button) else stringResource(R.string.accept_contact_button),
|
||||||
|
if (chatModel.incognito.value) painterResource(R.drawable.ic_theater_comedy_filled) else painterResource(R.drawable.ic_check),
|
||||||
|
color = if (chatModel.incognito.value) Indigo else MaterialTheme.colors.onBackground,
|
||||||
|
onClick = {
|
||||||
|
acceptContactRequest(chatInfo.apiId, chatInfo, true, chatModel)
|
||||||
|
showMenu.value = false
|
||||||
|
}
|
||||||
|
)
|
||||||
|
ItemAction(
|
||||||
|
stringResource(R.string.reject_contact_button),
|
||||||
|
painterResource(R.drawable.ic_close),
|
||||||
|
onClick = {
|
||||||
|
rejectContactRequest(chatInfo, chatModel)
|
||||||
|
showMenu.value = false
|
||||||
|
},
|
||||||
|
color = Color.Red
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun ContactConnectionMenuItems(chatInfo: ChatInfo.ContactConnection, chatModel: ChatModel, showMenu: MutableState<Boolean>) {
|
||||||
|
ItemAction(
|
||||||
|
stringResource(R.string.set_contact_name),
|
||||||
|
painterResource(R.drawable.ic_edit),
|
||||||
|
onClick = {
|
||||||
|
ModalManager.shared.showModalCloseable(true) { close ->
|
||||||
|
ContactConnectionInfoView(chatModel, chatInfo.contactConnection.connReqInv, chatInfo.contactConnection, true, close)
|
||||||
|
}
|
||||||
|
showMenu.value = false
|
||||||
|
},
|
||||||
|
)
|
||||||
|
ItemAction(
|
||||||
|
stringResource(R.string.delete_verb),
|
||||||
|
painterResource(R.drawable.ic_delete),
|
||||||
|
onClick = {
|
||||||
|
deleteContactConnectionAlert(chatInfo.contactConnection, chatModel) {}
|
||||||
|
showMenu.value = false
|
||||||
|
},
|
||||||
|
color = Color.Red
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun InvalidDataView() {
|
||||||
|
Row {
|
||||||
|
ProfileImage(72.dp, null, R.drawable.ic_account_circle_filled, MaterialTheme.colors.secondary)
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.padding(horizontal = 8.dp)
|
||||||
|
.weight(1F)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
stringResource(R.string.invalid_data),
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
style = MaterialTheme.typography.h3,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
color = Color.Red
|
||||||
|
)
|
||||||
|
val height = with(LocalDensity.current) { 46.sp.toDp() }
|
||||||
|
Spacer(Modifier.height(height))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun markChatRead(c: Chat, chatModel: ChatModel) {
|
||||||
|
var chat = c
|
||||||
|
withApi {
|
||||||
|
if (chat.chatStats.unreadCount > 0) {
|
||||||
|
val minUnreadItemId = chat.chatStats.minUnreadItemId
|
||||||
|
chatModel.markChatItemsRead(chat.chatInfo)
|
||||||
|
chatModel.controller.apiChatRead(
|
||||||
|
chat.chatInfo.chatType,
|
||||||
|
chat.chatInfo.apiId,
|
||||||
|
CC.ItemRange(minUnreadItemId, chat.chatItems.last().id)
|
||||||
|
)
|
||||||
|
chat = chatModel.getChat(chat.id) ?: return@withApi
|
||||||
|
}
|
||||||
|
if (chat.chatStats.unreadChat) {
|
||||||
|
val success = chatModel.controller.apiChatUnread(
|
||||||
|
chat.chatInfo.chatType,
|
||||||
|
chat.chatInfo.apiId,
|
||||||
|
false
|
||||||
|
)
|
||||||
|
if (success) {
|
||||||
|
chatModel.replaceChat(chat.id, chat.copy(chatStats = chat.chatStats.copy(unreadChat = false)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun markChatUnread(chat: Chat, chatModel: ChatModel) {
|
||||||
|
// Just to be sure
|
||||||
|
if (chat.chatStats.unreadChat) return
|
||||||
|
|
||||||
|
withApi {
|
||||||
|
val success = chatModel.controller.apiChatUnread(
|
||||||
|
chat.chatInfo.chatType,
|
||||||
|
chat.chatInfo.apiId,
|
||||||
|
true
|
||||||
|
)
|
||||||
|
if (success) {
|
||||||
|
chatModel.replaceChat(chat.id, chat.copy(chatStats = chat.chatStats.copy(unreadChat = true)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun contactRequestAlertDialog(contactRequest: ChatInfo.ContactRequest, chatModel: ChatModel) {
|
||||||
|
AlertManager.shared.showAlertDialog(
|
||||||
|
title = generalGetString(R.string.accept_connection_request__question),
|
||||||
|
text = generalGetString(R.string.if_you_choose_to_reject_the_sender_will_not_be_notified),
|
||||||
|
confirmText = if (chatModel.incognito.value) generalGetString(R.string.accept_contact_incognito_button) else generalGetString(R.string.accept_contact_button),
|
||||||
|
onConfirm = { acceptContactRequest(contactRequest.apiId, contactRequest, true, chatModel) },
|
||||||
|
dismissText = generalGetString(R.string.reject_contact_button),
|
||||||
|
onDismiss = { rejectContactRequest(contactRequest, chatModel) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun acceptContactRequest(apiId: Long, contactRequest: ChatInfo.ContactRequest?, isCurrentUser: Boolean, chatModel: ChatModel) {
|
||||||
|
withApi {
|
||||||
|
val contact = chatModel.controller.apiAcceptContactRequest(apiId)
|
||||||
|
if (contact != null && isCurrentUser && contactRequest != null) {
|
||||||
|
val chat = Chat(ChatInfo.Direct(contact), listOf())
|
||||||
|
chatModel.replaceChat(contactRequest.id, chat)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun rejectContactRequest(contactRequest: ChatInfo.ContactRequest, chatModel: ChatModel) {
|
||||||
|
withApi {
|
||||||
|
chatModel.controller.apiRejectContactRequest(contactRequest.apiId)
|
||||||
|
chatModel.removeChat(contactRequest.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun contactConnectionAlertDialog(connection: PendingContactConnection, chatModel: ChatModel) {
|
||||||
|
AlertManager.shared.showAlertDialogButtons(
|
||||||
|
title = generalGetString(
|
||||||
|
if (connection.initiated) R.string.you_invited_your_contact
|
||||||
|
else R.string.you_accepted_connection
|
||||||
|
),
|
||||||
|
text = generalGetString(
|
||||||
|
if (connection.viaContactUri) R.string.you_will_be_connected_when_your_connection_request_is_accepted
|
||||||
|
else R.string.you_will_be_connected_when_your_contacts_device_is_online
|
||||||
|
),
|
||||||
|
buttons = {
|
||||||
|
Row(
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 8.dp, vertical = 2.dp),
|
||||||
|
horizontalArrangement = Arrangement.Center,
|
||||||
|
) {
|
||||||
|
TextButton(onClick = {
|
||||||
|
AlertManager.shared.hideAlert()
|
||||||
|
deleteContactConnectionAlert(connection, chatModel) {}
|
||||||
|
}) {
|
||||||
|
Text(stringResource(R.string.delete_verb))
|
||||||
|
}
|
||||||
|
Spacer(Modifier.padding(horizontal = 4.dp))
|
||||||
|
TextButton(onClick = { AlertManager.shared.hideAlert() }) {
|
||||||
|
Text(stringResource(R.string.ok))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun deleteContactConnectionAlert(connection: PendingContactConnection, chatModel: ChatModel, onSuccess: () -> Unit) {
|
||||||
|
AlertManager.shared.showAlertDialog(
|
||||||
|
title = generalGetString(R.string.delete_pending_connection__question),
|
||||||
|
text = generalGetString(
|
||||||
|
if (connection.initiated) R.string.contact_you_shared_link_with_wont_be_able_to_connect
|
||||||
|
else R.string.connection_you_accepted_will_be_cancelled
|
||||||
|
),
|
||||||
|
confirmText = generalGetString(R.string.delete_verb),
|
||||||
|
onConfirm = {
|
||||||
|
withApi {
|
||||||
|
AlertManager.shared.hideAlert()
|
||||||
|
if (chatModel.controller.apiDeleteChat(ChatType.ContactConnection, connection.apiId)) {
|
||||||
|
chatModel.removeChat(connection.id)
|
||||||
|
onSuccess()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
destructive = true,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun pendingContactAlertDialog(chatInfo: ChatInfo, chatModel: ChatModel) {
|
||||||
|
AlertManager.shared.showAlertDialog(
|
||||||
|
title = generalGetString(R.string.alert_title_contact_connection_pending),
|
||||||
|
text = generalGetString(R.string.alert_text_connection_pending_they_need_to_be_online_can_delete_and_retry),
|
||||||
|
confirmText = generalGetString(R.string.button_delete_contact),
|
||||||
|
onConfirm = {
|
||||||
|
withApi {
|
||||||
|
val r = chatModel.controller.apiDeleteChat(chatInfo.chatType, chatInfo.apiId)
|
||||||
|
if (r) {
|
||||||
|
chatModel.removeChat(chatInfo.id)
|
||||||
|
chatModel.chatId.value = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
destructive = true,
|
||||||
|
dismissText = generalGetString(R.string.cancel_verb),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun acceptGroupInvitationAlertDialog(groupInfo: GroupInfo, chatModel: ChatModel) {
|
||||||
|
AlertManager.shared.showAlertDialog(
|
||||||
|
title = generalGetString(R.string.join_group_question),
|
||||||
|
text = generalGetString(R.string.you_are_invited_to_group_join_to_connect_with_group_members),
|
||||||
|
confirmText = if (groupInfo.membership.memberIncognito) generalGetString(R.string.join_group_incognito_button) else generalGetString(R.string.join_group_button),
|
||||||
|
onConfirm = { withApi { chatModel.controller.apiJoinGroup(groupInfo.groupId) } },
|
||||||
|
dismissText = generalGetString(R.string.delete_verb),
|
||||||
|
onDismiss = { deleteGroup(groupInfo, chatModel) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun cantInviteIncognitoAlert() {
|
||||||
|
AlertManager.shared.showAlertMsg(
|
||||||
|
title = generalGetString(R.string.alert_title_cant_invite_contacts),
|
||||||
|
text = generalGetString(R.string.alert_title_cant_invite_contacts_descr),
|
||||||
|
confirmText = generalGetString(R.string.ok),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun deleteGroup(groupInfo: GroupInfo, chatModel: ChatModel) {
|
||||||
|
withApi {
|
||||||
|
val r = chatModel.controller.apiDeleteChat(ChatType.Group, groupInfo.apiId)
|
||||||
|
if (r) {
|
||||||
|
chatModel.removeChat(groupInfo.id)
|
||||||
|
chatModel.chatId.value = null
|
||||||
|
chatModel.controller.ntfManager.cancelNotificationsForChat(groupInfo.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun groupInvitationAcceptedAlert() {
|
||||||
|
AlertManager.shared.showAlertMsg(
|
||||||
|
generalGetString(R.string.joining_group),
|
||||||
|
generalGetString(R.string.youve_accepted_group_invitation_connecting_to_inviting_group_member)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun changeNtfsStatePerChat(enabled: Boolean, currentState: MutableState<Boolean>, chat: Chat, chatModel: ChatModel) {
|
||||||
|
val newChatInfo = when(chat.chatInfo) {
|
||||||
|
is ChatInfo.Direct -> with (chat.chatInfo) {
|
||||||
|
ChatInfo.Direct(contact.copy(chatSettings = contact.chatSettings.copy(enableNtfs = enabled)))
|
||||||
|
}
|
||||||
|
is ChatInfo.Group -> with(chat.chatInfo) {
|
||||||
|
ChatInfo.Group(groupInfo.copy(chatSettings = groupInfo.chatSettings.copy(enableNtfs = enabled)))
|
||||||
|
}
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
withApi {
|
||||||
|
val res = when (newChatInfo) {
|
||||||
|
is ChatInfo.Direct -> with(newChatInfo) {
|
||||||
|
chatModel.controller.apiSetSettings(chatType, apiId, contact.chatSettings)
|
||||||
|
}
|
||||||
|
is ChatInfo.Group -> with(newChatInfo) {
|
||||||
|
chatModel.controller.apiSetSettings(chatType, apiId, groupInfo.chatSettings)
|
||||||
|
}
|
||||||
|
else -> false
|
||||||
|
}
|
||||||
|
if (res && newChatInfo != null) {
|
||||||
|
chatModel.updateChatInfo(newChatInfo)
|
||||||
|
if (!enabled) {
|
||||||
|
chatModel.controller.ntfManager.cancelNotificationsForChat(chat.id)
|
||||||
|
}
|
||||||
|
currentState.value = enabled
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun ChatListNavLinkLayout(
|
||||||
|
chatLinkPreview: @Composable () -> Unit,
|
||||||
|
click: () -> Unit,
|
||||||
|
dropdownMenuItems: (@Composable () -> Unit)?,
|
||||||
|
showMenu: MutableState<Boolean>,
|
||||||
|
stopped: Boolean
|
||||||
|
) {
|
||||||
|
var modifier = Modifier.fillMaxWidth()
|
||||||
|
if (!stopped) modifier = modifier.combinedClickable(onClick = click, onLongClick = { showMenu.value = true })
|
||||||
|
Box(modifier) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(start = 8.dp, top = 8.dp, end = 12.dp, bottom = 8.dp),
|
||||||
|
verticalAlignment = Alignment.Top
|
||||||
|
) {
|
||||||
|
chatLinkPreview()
|
||||||
|
}
|
||||||
|
if (dropdownMenuItems != null) {
|
||||||
|
DefaultDropdownMenu(showMenu, dropdownMenuItems = dropdownMenuItems)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Divider(Modifier.padding(horizontal = 8.dp))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Preview
|
||||||
|
@Preview(
|
||||||
|
uiMode = Configuration.UI_MODE_NIGHT_YES,
|
||||||
|
showBackground = true,
|
||||||
|
name = "Dark Mode"
|
||||||
|
)
|
||||||
|
@Composable
|
||||||
|
fun PreviewChatListNavLinkDirect() {
|
||||||
|
SimpleXTheme {
|
||||||
|
ChatListNavLinkLayout(
|
||||||
|
chatLinkPreview = {
|
||||||
|
ChatPreviewView(
|
||||||
|
chat = Chat(
|
||||||
|
chatInfo = ChatInfo.Direct.sampleData,
|
||||||
|
chatItems = listOf(
|
||||||
|
ChatItem.getSampleData(
|
||||||
|
1,
|
||||||
|
CIDirection.DirectSnd(),
|
||||||
|
Clock.System.now(),
|
||||||
|
"Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat."
|
||||||
|
)
|
||||||
|
),
|
||||||
|
chatStats = Chat.ChatStats()
|
||||||
|
),
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
false,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
stopped = false,
|
||||||
|
linkMode = SimplexLinkMode.DESCRIPTION
|
||||||
|
)
|
||||||
|
},
|
||||||
|
click = {},
|
||||||
|
dropdownMenuItems = null,
|
||||||
|
showMenu = remember { mutableStateOf(false) },
|
||||||
|
stopped = false
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Preview
|
||||||
|
@Preview(
|
||||||
|
uiMode = Configuration.UI_MODE_NIGHT_YES,
|
||||||
|
showBackground = true,
|
||||||
|
name = "Dark Mode"
|
||||||
|
)
|
||||||
|
@Composable
|
||||||
|
fun PreviewChatListNavLinkGroup() {
|
||||||
|
SimpleXTheme {
|
||||||
|
ChatListNavLinkLayout(
|
||||||
|
chatLinkPreview = {
|
||||||
|
ChatPreviewView(
|
||||||
|
Chat(
|
||||||
|
chatInfo = ChatInfo.Group.sampleData,
|
||||||
|
chatItems = listOf(
|
||||||
|
ChatItem.getSampleData(
|
||||||
|
1,
|
||||||
|
CIDirection.DirectSnd(),
|
||||||
|
Clock.System.now(),
|
||||||
|
"Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat."
|
||||||
|
)
|
||||||
|
),
|
||||||
|
chatStats = Chat.ChatStats()
|
||||||
|
),
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
false,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
stopped = false,
|
||||||
|
linkMode = SimplexLinkMode.DESCRIPTION
|
||||||
|
)
|
||||||
|
},
|
||||||
|
click = {},
|
||||||
|
dropdownMenuItems = null,
|
||||||
|
showMenu = remember { mutableStateOf(false) },
|
||||||
|
stopped = false
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Preview
|
||||||
|
@Preview(
|
||||||
|
uiMode = Configuration.UI_MODE_NIGHT_YES,
|
||||||
|
showBackground = true,
|
||||||
|
name = "Dark Mode"
|
||||||
|
)
|
||||||
|
@Composable
|
||||||
|
fun PreviewChatListNavLinkContactRequest() {
|
||||||
|
SimpleXTheme {
|
||||||
|
ChatListNavLinkLayout(
|
||||||
|
chatLinkPreview = {
|
||||||
|
ContactRequestView(false, ChatInfo.ContactRequest.sampleData)
|
||||||
|
},
|
||||||
|
click = {},
|
||||||
|
dropdownMenuItems = null,
|
||||||
|
showMenu = remember { mutableStateOf(false) },
|
||||||
|
stopped = false
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
+60
-143
@@ -1,5 +1,6 @@
|
|||||||
package chat.simplex.common.views.chatlist
|
package chat.simplex.app.views.chatlist
|
||||||
|
|
||||||
|
import androidx.activity.compose.BackHandler
|
||||||
import androidx.compose.foundation.*
|
import androidx.compose.foundation.*
|
||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.*
|
||||||
import androidx.compose.foundation.lazy.*
|
import androidx.compose.foundation.lazy.*
|
||||||
@@ -11,28 +12,32 @@ import androidx.compose.runtime.saveable.rememberSaveable
|
|||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.*
|
import androidx.compose.ui.graphics.*
|
||||||
import dev.icerock.moko.resources.compose.painterResource
|
import androidx.compose.ui.platform.LocalUriHandler
|
||||||
import dev.icerock.moko.resources.compose.stringResource
|
import androidx.compose.ui.res.painterResource
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.capitalize
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.intl.Locale
|
||||||
import androidx.compose.ui.unit.*
|
import androidx.compose.ui.unit.*
|
||||||
import chat.simplex.common.SettingsViewState
|
import androidx.fragment.app.FragmentActivity
|
||||||
import chat.simplex.common.model.*
|
import chat.simplex.app.*
|
||||||
import chat.simplex.common.model.ChatController.stopRemoteHostAndReloadHosts
|
import chat.simplex.app.R
|
||||||
import chat.simplex.common.ui.theme.*
|
import chat.simplex.app.model.*
|
||||||
import chat.simplex.common.views.helpers.*
|
import chat.simplex.app.ui.theme.*
|
||||||
import chat.simplex.common.views.onboarding.WhatsNewView
|
import chat.simplex.app.views.helpers.*
|
||||||
import chat.simplex.common.views.onboarding.shouldShowWhatsNew
|
import chat.simplex.app.views.newchat.NewChatSheet
|
||||||
import chat.simplex.common.views.usersettings.SettingsView
|
import chat.simplex.app.views.onboarding.WhatsNewView
|
||||||
import chat.simplex.common.platform.*
|
import chat.simplex.app.views.onboarding.shouldShowWhatsNew
|
||||||
import chat.simplex.common.views.newchat.*
|
import chat.simplex.app.views.usersettings.SettingsView
|
||||||
import chat.simplex.res.MR
|
import chat.simplex.app.views.usersettings.simplexTeamUri
|
||||||
import kotlinx.coroutines.*
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import java.net.URI
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun ChatListView(chatModel: ChatModel, settingsState: SettingsViewState, setPerformLA: (Boolean) -> Unit, stopped: Boolean) {
|
fun ChatListView(chatModel: ChatModel, setPerformLA: (Boolean, FragmentActivity) -> Unit, stopped: Boolean) {
|
||||||
val newChatSheetState by rememberSaveable(stateSaver = AnimatedViewState.saver()) { mutableStateOf(MutableStateFlow(AnimatedViewState.GONE)) }
|
val newChatSheetState by rememberSaveable(stateSaver = AnimatedViewState.saver()) { mutableStateOf(MutableStateFlow(AnimatedViewState.GONE)) }
|
||||||
|
val userPickerState by rememberSaveable(stateSaver = AnimatedViewState.saver()) { mutableStateOf(MutableStateFlow(AnimatedViewState.GONE)) }
|
||||||
val showNewChatSheet = {
|
val showNewChatSheet = {
|
||||||
newChatSheetState.value = AnimatedViewState.VISIBLE
|
newChatSheetState.value = AnimatedViewState.VISIBLE
|
||||||
}
|
}
|
||||||
@@ -43,7 +48,7 @@ fun ChatListView(chatModel: ChatModel, settingsState: SettingsViewState, setPerf
|
|||||||
LaunchedEffect(Unit) {
|
LaunchedEffect(Unit) {
|
||||||
if (shouldShowWhatsNew(chatModel)) {
|
if (shouldShowWhatsNew(chatModel)) {
|
||||||
delay(1000L)
|
delay(1000L)
|
||||||
ModalManager.center.showCustomModal { close -> WhatsNewView(close = close) }
|
ModalManager.shared.showCustomModal { close -> WhatsNewView(close = close) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
LaunchedEffect(chatModel.clearOverlays.value) {
|
LaunchedEffect(chatModel.clearOverlays.value) {
|
||||||
@@ -53,27 +58,17 @@ fun ChatListView(chatModel: ChatModel, settingsState: SettingsViewState, setPerf
|
|||||||
val url = chatModel.appOpenUrl.value
|
val url = chatModel.appOpenUrl.value
|
||||||
if (url != null) {
|
if (url != null) {
|
||||||
chatModel.appOpenUrl.value = null
|
chatModel.appOpenUrl.value = null
|
||||||
connectIfOpenedViaUri(chatModel.remoteHostId(), url, chatModel)
|
connectIfOpenedViaUri(url, chatModel)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (appPlatform.isDesktop) {
|
|
||||||
KeyChangeEffect(chatModel.chatId.value) {
|
|
||||||
if (chatModel.chatId.value != null) {
|
|
||||||
ModalManager.end.closeModalsExceptFirst()
|
|
||||||
}
|
|
||||||
AudioPlayer.stop()
|
|
||||||
VideoPlayerHolder.stopAll()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
val endPadding = if (appPlatform.isDesktop) 56.dp else 0.dp
|
|
||||||
var searchInList by rememberSaveable { mutableStateOf("") }
|
var searchInList by rememberSaveable { mutableStateOf("") }
|
||||||
|
val scaffoldState = rememberScaffoldState()
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
val (userPickerState, scaffoldState, switchingUsersAndHosts ) = settingsState
|
val switchingUsers = rememberSaveable { mutableStateOf(false) }
|
||||||
Scaffold(topBar = { Box(Modifier.padding(end = endPadding)) { ChatListToolbar(chatModel, scaffoldState.drawerState, userPickerState, stopped) { searchInList = it.trim() } } },
|
Scaffold(topBar = { ChatListToolbar(chatModel, scaffoldState.drawerState, userPickerState, stopped) { searchInList = it.trim() } },
|
||||||
scaffoldState = scaffoldState,
|
scaffoldState = scaffoldState,
|
||||||
drawerContent = { SettingsView(chatModel, setPerformLA, scaffoldState.drawerState) },
|
drawerContent = { SettingsView(chatModel, setPerformLA) },
|
||||||
drawerScrimColor = MaterialTheme.colors.onSurface.copy(alpha = if (isInDarkTheme()) 0.16f else 0.32f),
|
drawerScrimColor = MaterialTheme.colors.onSurface.copy(alpha = if (isInDarkTheme()) 0.16f else 0.32f),
|
||||||
drawerGesturesEnabled = appPlatform.isAndroid,
|
|
||||||
floatingActionButton = {
|
floatingActionButton = {
|
||||||
if (searchInList.isEmpty()) {
|
if (searchInList.isEmpty()) {
|
||||||
FloatingActionButton(
|
FloatingActionButton(
|
||||||
@@ -82,7 +77,7 @@ fun ChatListView(chatModel: ChatModel, settingsState: SettingsViewState, setPerf
|
|||||||
if (newChatSheetState.value.isVisible()) hideNewChatSheet(true) else showNewChatSheet()
|
if (newChatSheetState.value.isVisible()) hideNewChatSheet(true) else showNewChatSheet()
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
Modifier.padding(end = DEFAULT_PADDING - 16.dp + endPadding, bottom = DEFAULT_PADDING - 16.dp),
|
Modifier.padding(end = DEFAULT_PADDING - 16.dp, bottom = DEFAULT_PADDING - 16.dp),
|
||||||
elevation = FloatingActionButtonDefaults.elevation(
|
elevation = FloatingActionButtonDefaults.elevation(
|
||||||
defaultElevation = 0.dp,
|
defaultElevation = 0.dp,
|
||||||
pressedElevation = 0.dp,
|
pressedElevation = 0.dp,
|
||||||
@@ -92,41 +87,36 @@ fun ChatListView(chatModel: ChatModel, settingsState: SettingsViewState, setPerf
|
|||||||
backgroundColor = if (!stopped) MaterialTheme.colors.primary else MaterialTheme.colors.secondary,
|
backgroundColor = if (!stopped) MaterialTheme.colors.primary else MaterialTheme.colors.secondary,
|
||||||
contentColor = Color.White
|
contentColor = Color.White
|
||||||
) {
|
) {
|
||||||
Icon(if (!newChatSheetState.collectAsState().value.isVisible()) painterResource(MR.images.ic_edit_filled) else painterResource(MR.images.ic_close), stringResource(MR.strings.add_contact_or_create_group))
|
Icon(if (!newChatSheetState.collectAsState().value.isVisible()) painterResource(R.drawable.ic_edit_filled) else painterResource(R.drawable.ic_close), stringResource(R.string.add_contact_or_create_group))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
) {
|
) {
|
||||||
Box(Modifier.padding(it).padding(end = endPadding)) {
|
Box(Modifier.padding(it)) {
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
) {
|
) {
|
||||||
if (chatModel.chats.isNotEmpty()) {
|
if (chatModel.chats.isNotEmpty()) {
|
||||||
ChatList(chatModel, search = searchInList)
|
ChatList(chatModel, search = searchInList)
|
||||||
} else if (!switchingUsersAndHosts.value) {
|
} else if (!switchingUsers.value) {
|
||||||
Box(Modifier.fillMaxSize()) {
|
Box(Modifier.fillMaxSize()) {
|
||||||
if (!stopped && !newChatSheetState.collectAsState().value.isVisible()) {
|
if (!stopped && !newChatSheetState.collectAsState().value.isVisible()) {
|
||||||
OnboardingButtons(showNewChatSheet)
|
OnboardingButtons(showNewChatSheet)
|
||||||
}
|
}
|
||||||
Text(stringResource(MR.strings.you_have_no_chats), Modifier.align(Alignment.Center), color = MaterialTheme.colors.secondary)
|
Text(stringResource(R.string.you_have_no_chats), Modifier.align(Alignment.Center), color = MaterialTheme.colors.secondary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (searchInList.isEmpty()) {
|
if (searchInList.isEmpty()) {
|
||||||
DesktopActiveCallOverlayLayout(newChatSheetState)
|
|
||||||
// TODO disable this button and sheet for the duration of the switch
|
|
||||||
NewChatSheet(chatModel, newChatSheetState, stopped, hideNewChatSheet)
|
NewChatSheet(chatModel, newChatSheetState, stopped, hideNewChatSheet)
|
||||||
}
|
}
|
||||||
if (appPlatform.isAndroid) {
|
UserPicker(chatModel, userPickerState, switchingUsers) {
|
||||||
UserPicker(chatModel, userPickerState, switchingUsersAndHosts) {
|
|
||||||
scope.launch { if (scaffoldState.drawerState.isOpen) scaffoldState.drawerState.close() else scaffoldState.drawerState.open() }
|
scope.launch { if (scaffoldState.drawerState.isOpen) scaffoldState.drawerState.close() else scaffoldState.drawerState.open() }
|
||||||
userPickerState.value = AnimatedViewState.GONE
|
|
||||||
}
|
}
|
||||||
}
|
if (switchingUsers.value) {
|
||||||
if (switchingUsersAndHosts.value) {
|
|
||||||
Box(
|
Box(
|
||||||
Modifier.fillMaxSize().clickable(enabled = false, onClick = {}),
|
Modifier.fillMaxSize().clickable(enabled = false, onClick = {}),
|
||||||
contentAlignment = Alignment.Center
|
contentAlignment = Alignment.Center
|
||||||
@@ -139,7 +129,12 @@ fun ChatListView(chatModel: ChatModel, settingsState: SettingsViewState, setPerf
|
|||||||
@Composable
|
@Composable
|
||||||
private fun OnboardingButtons(openNewChatSheet: () -> Unit) {
|
private fun OnboardingButtons(openNewChatSheet: () -> Unit) {
|
||||||
Column(Modifier.fillMaxSize().padding(DEFAULT_PADDING), horizontalAlignment = Alignment.End, verticalArrangement = Arrangement.Bottom) {
|
Column(Modifier.fillMaxSize().padding(DEFAULT_PADDING), horizontalAlignment = Alignment.End, verticalArrangement = Arrangement.Bottom) {
|
||||||
ConnectButton(generalGetString(MR.strings.tap_to_start_new_chat), openNewChatSheet)
|
val uriHandler = LocalUriHandler.current
|
||||||
|
ConnectButton(generalGetString(R.string.chat_with_developers)) {
|
||||||
|
uriHandler.openUriCatching(simplexTeamUri)
|
||||||
|
}
|
||||||
|
Spacer(Modifier.height(DEFAULT_PADDING))
|
||||||
|
ConnectButton(generalGetString(R.string.tap_to_start_new_chat), openNewChatSheet)
|
||||||
val color = MaterialTheme.colors.primaryVariant
|
val color = MaterialTheme.colors.primaryVariant
|
||||||
Canvas(modifier = Modifier.width(40.dp).height(10.dp), onDraw = {
|
Canvas(modifier = Modifier.width(40.dp).height(10.dp), onDraw = {
|
||||||
val trianglePath = Path().apply {
|
val trianglePath = Path().apply {
|
||||||
@@ -181,10 +176,10 @@ private fun ChatListToolbar(chatModel: ChatModel, drawerState: DrawerState, user
|
|||||||
BackHandler(onBack = hideSearchOnBack)
|
BackHandler(onBack = hideSearchOnBack)
|
||||||
}
|
}
|
||||||
val barButtons = arrayListOf<@Composable RowScope.() -> Unit>()
|
val barButtons = arrayListOf<@Composable RowScope.() -> Unit>()
|
||||||
if (chatModel.chats.size > 0) {
|
if (chatModel.chats.size >= 8) {
|
||||||
barButtons.add {
|
barButtons.add {
|
||||||
IconButton({ showSearch = true }) {
|
IconButton({ showSearch = true }) {
|
||||||
Icon(painterResource(MR.images.ic_search_500), stringResource(MR.strings.search_verb), tint = MaterialTheme.colors.primary)
|
Icon(painterResource(R.drawable.ic_search_500), stringResource(android.R.string.search_go).capitalize(Locale.current), tint = MaterialTheme.colors.primary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -192,13 +187,13 @@ private fun ChatListToolbar(chatModel: ChatModel, drawerState: DrawerState, user
|
|||||||
barButtons.add {
|
barButtons.add {
|
||||||
IconButton(onClick = {
|
IconButton(onClick = {
|
||||||
AlertManager.shared.showAlertMsg(
|
AlertManager.shared.showAlertMsg(
|
||||||
generalGetString(MR.strings.chat_is_stopped_indication),
|
generalGetString(R.string.chat_is_stopped_indication),
|
||||||
generalGetString(MR.strings.you_can_start_chat_via_setting_or_by_restarting_the_app)
|
generalGetString(R.string.you_can_start_chat_via_setting_or_by_restarting_the_app)
|
||||||
)
|
)
|
||||||
}) {
|
}) {
|
||||||
Icon(
|
Icon(
|
||||||
painterResource(MR.images.ic_report_filled),
|
painterResource(R.drawable.ic_report_filled),
|
||||||
generalGetString(MR.strings.chat_is_stopped_indication),
|
generalGetString(R.string.chat_is_stopped_indication),
|
||||||
tint = Color.Red,
|
tint = Color.Red,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -217,7 +212,7 @@ private fun ChatListToolbar(chatModel: ChatModel, drawerState: DrawerState, user
|
|||||||
.filter { u -> !u.user.activeUser && !u.user.hidden }
|
.filter { u -> !u.user.activeUser && !u.user.hidden }
|
||||||
.all { u -> u.unreadCount == 0 }
|
.all { u -> u.unreadCount == 0 }
|
||||||
UserProfileButton(chatModel.currentUser.value?.profile?.image, allRead) {
|
UserProfileButton(chatModel.currentUser.value?.profile?.image, allRead) {
|
||||||
if (users.size == 1 && chatModel.remoteHosts.isEmpty()) {
|
if (users.size == 1) {
|
||||||
scope.launch { drawerState.open() }
|
scope.launch { drawerState.open() }
|
||||||
} else {
|
} else {
|
||||||
userPickerState.value = AnimatedViewState.VISIBLE
|
userPickerState.value = AnimatedViewState.VISIBLE
|
||||||
@@ -228,12 +223,17 @@ private fun ChatListToolbar(chatModel: ChatModel, drawerState: DrawerState, user
|
|||||||
title = {
|
title = {
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
Text(
|
Text(
|
||||||
stringResource(MR.strings.your_chats),
|
stringResource(R.string.your_chats),
|
||||||
color = MaterialTheme.colors.onBackground,
|
color = MaterialTheme.colors.onBackground,
|
||||||
fontWeight = FontWeight.SemiBold,
|
fontWeight = FontWeight.SemiBold,
|
||||||
)
|
)
|
||||||
if (chatModel.chats.size > 0) {
|
if (chatModel.incognito.value) {
|
||||||
ToggleFilterButton()
|
Icon(
|
||||||
|
painterResource(R.drawable.ic_theater_comedy_filled),
|
||||||
|
stringResource(R.string.incognito),
|
||||||
|
tint = Indigo,
|
||||||
|
modifier = Modifier.padding(10.dp).size(26.dp)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -247,7 +247,6 @@ private fun ChatListToolbar(chatModel: ChatModel, drawerState: DrawerState, user
|
|||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun UserProfileButton(image: String?, allRead: Boolean, onButtonClicked: () -> Unit) {
|
fun UserProfileButton(image: String?, allRead: Boolean, onButtonClicked: () -> Unit) {
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
|
||||||
IconButton(onClick = onButtonClicked) {
|
IconButton(onClick = onButtonClicked) {
|
||||||
Box {
|
Box {
|
||||||
ProfileImage(
|
ProfileImage(
|
||||||
@@ -259,16 +258,6 @@ fun UserProfileButton(image: String?, allRead: Boolean, onButtonClicked: () -> U
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (appPlatform.isDesktop) {
|
|
||||||
val h by remember { chatModel.currentRemoteHost }
|
|
||||||
if (h != null) {
|
|
||||||
Spacer(Modifier.width(12.dp))
|
|
||||||
HostDisconnectButton {
|
|
||||||
stopRemoteHostAndReloadHosts(h!!, true)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
@@ -286,24 +275,6 @@ private fun BoxScope.unreadBadge(text: String? = "") {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun ToggleFilterButton() {
|
|
||||||
val pref = remember { ChatController.appPrefs.showUnreadAndFavorites }
|
|
||||||
IconButton(onClick = { pref.set(!pref.get()) }) {
|
|
||||||
Icon(
|
|
||||||
painterResource(MR.images.ic_filter_list),
|
|
||||||
null,
|
|
||||||
tint = if (pref.state.value) MaterialTheme.colors.background else MaterialTheme.colors.primary,
|
|
||||||
modifier = Modifier
|
|
||||||
.padding(3.dp)
|
|
||||||
.background(color = if (pref.state.value) MaterialTheme.colors.primary else MaterialTheme.colors.background, shape = RoundedCornerShape(50))
|
|
||||||
.border(width = 1.dp, color = MaterialTheme.colors.primary, shape = RoundedCornerShape(50))
|
|
||||||
.padding(3.dp)
|
|
||||||
.size(16.dp)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun ProgressIndicator() {
|
private fun ProgressIndicator() {
|
||||||
CircularProgressIndicator(
|
CircularProgressIndicator(
|
||||||
@@ -315,34 +286,18 @@ private fun ProgressIndicator() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
|
||||||
expect fun DesktopActiveCallOverlayLayout(newChatSheetState: MutableStateFlow<AnimatedViewState>)
|
|
||||||
|
|
||||||
fun connectIfOpenedViaUri(rhId: Long?, uri: URI, chatModel: ChatModel) {
|
|
||||||
Log.d(TAG, "connectIfOpenedViaUri: opened via link")
|
|
||||||
if (chatModel.currentUser.value == null) {
|
|
||||||
chatModel.appOpenUrl.value = uri
|
|
||||||
} else {
|
|
||||||
withApi {
|
|
||||||
planAndConnect(chatModel, rhId, uri, incognito = null, close = null)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private var lazyListState = 0 to 0
|
private var lazyListState = 0 to 0
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun ChatList(chatModel: ChatModel, search: String) {
|
private fun ChatList(chatModel: ChatModel, search: String) {
|
||||||
|
val filter: (Chat) -> Boolean = { chat: Chat ->
|
||||||
|
chat.chatInfo.chatViewName.lowercase().contains(search.lowercase())
|
||||||
|
}
|
||||||
val listState = rememberLazyListState(lazyListState.first, lazyListState.second)
|
val listState = rememberLazyListState(lazyListState.first, lazyListState.second)
|
||||||
DisposableEffect(Unit) {
|
DisposableEffect(Unit) {
|
||||||
onDispose { lazyListState = listState.firstVisibleItemIndex to listState.firstVisibleItemScrollOffset }
|
onDispose { lazyListState = listState.firstVisibleItemIndex to listState.firstVisibleItemScrollOffset }
|
||||||
}
|
}
|
||||||
val showUnreadAndFavorites = remember { ChatController.appPrefs.showUnreadAndFavorites.state }.value
|
val chats by remember(search) { derivedStateOf { if (search.isEmpty()) chatModel.chats else chatModel.chats.filter(filter) } }
|
||||||
val allChats = remember { chatModel.chats }
|
|
||||||
// In some not always reproducible situations this code produce IndexOutOfBoundsException on Compose's side
|
|
||||||
// which is related to [derivedStateOf]. Using safe alternative instead
|
|
||||||
// val chats by remember(search, showUnreadAndFavorites) { derivedStateOf { filteredChats(showUnreadAndFavorites, search, allChats.toList()) } }
|
|
||||||
val chats = filteredChats(showUnreadAndFavorites, search, allChats.toList())
|
|
||||||
LazyColumn(
|
LazyColumn(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
listState
|
listState
|
||||||
@@ -351,42 +306,4 @@ private fun ChatList(chatModel: ChatModel, search: String) {
|
|||||||
ChatListNavLinkView(chat, chatModel)
|
ChatListNavLinkView(chat, chatModel)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (chats.isEmpty() && !chatModel.chats.isEmpty()) {
|
|
||||||
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
|
||||||
Text(generalGetString(MR.strings.no_filtered_chats), color = MaterialTheme.colors.secondary)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun filteredChats(showUnreadAndFavorites: Boolean, searchText: String, chats: List<Chat>): List<Chat> {
|
|
||||||
val s = searchText.trim().lowercase()
|
|
||||||
return if (s.isEmpty() && !showUnreadAndFavorites)
|
|
||||||
chats
|
|
||||||
else {
|
|
||||||
chats.filter { chat ->
|
|
||||||
when (val cInfo = chat.chatInfo) {
|
|
||||||
is ChatInfo.Direct -> if (s.isEmpty()) {
|
|
||||||
filtered(chat)
|
|
||||||
} else {
|
|
||||||
(viewNameContains(cInfo, s) ||
|
|
||||||
cInfo.contact.profile.displayName.lowercase().contains(s) ||
|
|
||||||
cInfo.contact.fullName.lowercase().contains(s))
|
|
||||||
}
|
|
||||||
is ChatInfo.Group -> if (s.isEmpty()) {
|
|
||||||
(filtered(chat) || cInfo.groupInfo.membership.memberStatus == GroupMemberStatus.MemInvited)
|
|
||||||
} else {
|
|
||||||
viewNameContains(cInfo, s)
|
|
||||||
}
|
|
||||||
is ChatInfo.ContactRequest -> s.isEmpty() || viewNameContains(cInfo, s)
|
|
||||||
is ChatInfo.ContactConnection -> s.isNotEmpty() && cInfo.contactConnection.localAlias.lowercase().contains(s)
|
|
||||||
is ChatInfo.InvalidJSON -> false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun filtered(chat: Chat): Boolean =
|
|
||||||
(chat.chatInfo.chatSettings?.favorite ?: false) || chat.chatStats.unreadCount > 0 || chat.chatStats.unreadChat
|
|
||||||
|
|
||||||
private fun viewNameContains(cInfo: ChatInfo, s: String): Boolean =
|
|
||||||
cInfo.chatViewName.lowercase().contains(s.lowercase())
|
|
||||||
@@ -0,0 +1,309 @@
|
|||||||
|
package chat.simplex.app.views.chatlist
|
||||||
|
|
||||||
|
import android.content.res.Configuration
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
import androidx.compose.foundation.text.InlineTextContent
|
||||||
|
import androidx.compose.foundation.text.appendInlineContent
|
||||||
|
import androidx.compose.material.*
|
||||||
|
import androidx.compose.runtime.*
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
|
import androidx.compose.ui.res.painterResource
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.*
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
|
import androidx.compose.ui.unit.*
|
||||||
|
import chat.simplex.app.R
|
||||||
|
import chat.simplex.app.model.*
|
||||||
|
import chat.simplex.app.ui.theme.*
|
||||||
|
import chat.simplex.app.views.chat.ComposePreview
|
||||||
|
import chat.simplex.app.views.chat.ComposeState
|
||||||
|
import chat.simplex.app.views.chat.item.MarkdownText
|
||||||
|
import chat.simplex.app.views.helpers.*
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun ChatPreviewView(
|
||||||
|
chat: Chat,
|
||||||
|
chatModelDraft: ComposeState?,
|
||||||
|
chatModelDraftChatId: ChatId?,
|
||||||
|
chatModelIncognito: Boolean,
|
||||||
|
currentUserProfileDisplayName: String?,
|
||||||
|
contactNetworkStatus: NetworkStatus?,
|
||||||
|
stopped: Boolean,
|
||||||
|
linkMode: SimplexLinkMode
|
||||||
|
) {
|
||||||
|
val cInfo = chat.chatInfo
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun groupInactiveIcon() {
|
||||||
|
Icon(
|
||||||
|
painterResource(R.drawable.ic_cancel_filled),
|
||||||
|
stringResource(R.string.icon_descr_group_inactive),
|
||||||
|
Modifier.size(18.dp).background(MaterialTheme.colors.background, CircleShape),
|
||||||
|
tint = MaterialTheme.colors.secondary
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun chatPreviewImageOverlayIcon() {
|
||||||
|
if (cInfo is ChatInfo.Group) {
|
||||||
|
when (cInfo.groupInfo.membership.memberStatus) {
|
||||||
|
GroupMemberStatus.MemLeft -> groupInactiveIcon()
|
||||||
|
GroupMemberStatus.MemRemoved -> groupInactiveIcon()
|
||||||
|
GroupMemberStatus.MemGroupDeleted -> groupInactiveIcon()
|
||||||
|
else -> {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun chatPreviewTitleText(color: Color = Color.Unspecified) {
|
||||||
|
Text(
|
||||||
|
cInfo.chatViewName,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
style = MaterialTheme.typography.h3,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
color = color
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun VerifiedIcon() {
|
||||||
|
Icon(painterResource(R.drawable.ic_verified_user), null, Modifier.size(19.dp).padding(end = 3.dp, top = 1.dp), tint = MaterialTheme.colors.secondary)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun messageDraft(draft: ComposeState): Pair<AnnotatedString, Map<String, InlineTextContent>> {
|
||||||
|
fun attachment(): Pair<Int, String?>? =
|
||||||
|
when (draft.preview) {
|
||||||
|
is ComposePreview.FilePreview -> R.drawable.ic_draft_filled to draft.preview.fileName
|
||||||
|
is ComposePreview.MediaPreview -> R.drawable.ic_image to null
|
||||||
|
is ComposePreview.VoicePreview -> R.drawable.ic_play_arrow_filled to durationText(draft.preview.durationMs / 1000)
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
|
||||||
|
val attachment = attachment()
|
||||||
|
val text = buildAnnotatedString {
|
||||||
|
appendInlineContent(id = "editIcon")
|
||||||
|
append(" ")
|
||||||
|
if (attachment != null) {
|
||||||
|
appendInlineContent(id = "attachmentIcon")
|
||||||
|
if (attachment.second != null) {
|
||||||
|
append(attachment.second as String)
|
||||||
|
}
|
||||||
|
append(" ")
|
||||||
|
}
|
||||||
|
append(draft.message)
|
||||||
|
}
|
||||||
|
val inlineContent: Map<String, InlineTextContent> = mapOf(
|
||||||
|
"editIcon" to InlineTextContent(
|
||||||
|
Placeholder(20.sp, 20.sp, PlaceholderVerticalAlign.TextCenter)
|
||||||
|
) {
|
||||||
|
Icon(painterResource(R.drawable.ic_edit_note), null, tint = MaterialTheme.colors.primary)
|
||||||
|
},
|
||||||
|
"attachmentIcon" to InlineTextContent(
|
||||||
|
Placeholder(20.sp, 20.sp, PlaceholderVerticalAlign.TextCenter)
|
||||||
|
) {
|
||||||
|
Icon(if (attachment?.first != null) painterResource(attachment.first) else painterResource(R.drawable.ic_edit_note), null, tint = MaterialTheme.colors.secondary)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return text to inlineContent
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun chatPreviewTitle() {
|
||||||
|
when (cInfo) {
|
||||||
|
is ChatInfo.Direct ->
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
if (cInfo.contact.verified) {
|
||||||
|
VerifiedIcon()
|
||||||
|
}
|
||||||
|
chatPreviewTitleText(if (cInfo.ready) Color.Unspecified else MaterialTheme.colors.secondary)
|
||||||
|
}
|
||||||
|
is ChatInfo.Group ->
|
||||||
|
when (cInfo.groupInfo.membership.memberStatus) {
|
||||||
|
GroupMemberStatus.MemInvited -> chatPreviewTitleText(if (chat.chatInfo.incognito) Indigo else MaterialTheme.colors.primary)
|
||||||
|
GroupMemberStatus.MemAccepted -> chatPreviewTitleText(MaterialTheme.colors.secondary)
|
||||||
|
else -> chatPreviewTitleText()
|
||||||
|
}
|
||||||
|
else -> chatPreviewTitleText()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun chatPreviewText(chatModelIncognito: Boolean) {
|
||||||
|
val ci = chat.chatItems.lastOrNull()
|
||||||
|
if (ci != null) {
|
||||||
|
val (text: CharSequence, inlineTextContent) = when {
|
||||||
|
chatModelDraftChatId == chat.id && chatModelDraft != null -> remember(chatModelDraft) { messageDraft(chatModelDraft) }
|
||||||
|
ci.meta.itemDeleted == null -> ci.text to null
|
||||||
|
else -> generalGetString(R.string.marked_deleted_description) to null
|
||||||
|
}
|
||||||
|
val formattedText = when {
|
||||||
|
chatModelDraftChatId == chat.id && chatModelDraft != null -> null
|
||||||
|
ci.meta.itemDeleted == null -> ci.formattedText
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
MarkdownText(
|
||||||
|
text,
|
||||||
|
formattedText,
|
||||||
|
sender = when {
|
||||||
|
chatModelDraftChatId == chat.id && chatModelDraft != null -> null
|
||||||
|
cInfo is ChatInfo.Group && !ci.chatDir.sent -> ci.memberDisplayName
|
||||||
|
else -> null
|
||||||
|
},
|
||||||
|
linkMode = linkMode,
|
||||||
|
senderBold = true,
|
||||||
|
maxLines = 2,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
style = MaterialTheme.typography.body1.copy(color = if (isInDarkTheme()) MessagePreviewDark else MessagePreviewLight, lineHeight = 22.sp),
|
||||||
|
inlineContent = inlineTextContent,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
when (cInfo) {
|
||||||
|
is ChatInfo.Direct ->
|
||||||
|
if (!cInfo.ready) {
|
||||||
|
Text(stringResource(R.string.contact_connection_pending), color = MaterialTheme.colors.secondary)
|
||||||
|
}
|
||||||
|
is ChatInfo.Group ->
|
||||||
|
when (cInfo.groupInfo.membership.memberStatus) {
|
||||||
|
GroupMemberStatus.MemInvited -> Text(groupInvitationPreviewText(chatModelIncognito, currentUserProfileDisplayName, cInfo.groupInfo))
|
||||||
|
GroupMemberStatus.MemAccepted -> Text(stringResource(R.string.group_connection_pending), color = MaterialTheme.colors.secondary)
|
||||||
|
else -> {}
|
||||||
|
}
|
||||||
|
else -> {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Row {
|
||||||
|
Box(contentAlignment = Alignment.BottomEnd) {
|
||||||
|
ChatInfoImage(cInfo, size = 72.dp)
|
||||||
|
Box(Modifier.padding(end = 6.dp, bottom = 6.dp)) {
|
||||||
|
chatPreviewImageOverlayIcon()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.padding(horizontal = 8.dp)
|
||||||
|
.weight(1F)
|
||||||
|
) {
|
||||||
|
chatPreviewTitle()
|
||||||
|
val height = with(LocalDensity.current) { 46.sp.toDp() }
|
||||||
|
Row(Modifier.heightIn(min = height)) {
|
||||||
|
chatPreviewText(chatModelIncognito)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val ts = chat.chatItems.lastOrNull()?.timestampText ?: getTimestampText(chat.chatInfo.updatedAt)
|
||||||
|
|
||||||
|
Box(
|
||||||
|
contentAlignment = Alignment.TopEnd
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
ts,
|
||||||
|
color = MaterialTheme.colors.secondary,
|
||||||
|
style = MaterialTheme.typography.body2,
|
||||||
|
modifier = Modifier.padding(bottom = 5.dp)
|
||||||
|
)
|
||||||
|
val n = chat.chatStats.unreadCount
|
||||||
|
val showNtfsIcon = !chat.chatInfo.ntfsEnabled && (chat.chatInfo is ChatInfo.Direct || chat.chatInfo is ChatInfo.Group)
|
||||||
|
if (n > 0 || chat.chatStats.unreadChat) {
|
||||||
|
Box(
|
||||||
|
Modifier.padding(top = 24.dp),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
if (n > 0) unreadCountStr(n) else "",
|
||||||
|
color = Color.White,
|
||||||
|
fontSize = 11.sp,
|
||||||
|
modifier = Modifier
|
||||||
|
.background(if (stopped || showNtfsIcon) MaterialTheme.colors.secondary else MaterialTheme.colors.primaryVariant, shape = CircleShape)
|
||||||
|
.badgeLayout()
|
||||||
|
.padding(horizontal = 3.dp)
|
||||||
|
.padding(vertical = 1.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else if (showNtfsIcon) {
|
||||||
|
Box(
|
||||||
|
Modifier.padding(top = 24.dp),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
painterResource(R.drawable.ic_notifications_off_filled),
|
||||||
|
contentDescription = generalGetString(R.string.notifications),
|
||||||
|
tint = MaterialTheme.colors.secondary,
|
||||||
|
modifier = Modifier
|
||||||
|
.padding(horizontal = 3.dp)
|
||||||
|
.padding(vertical = 1.dp)
|
||||||
|
.size(17.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (cInfo is ChatInfo.Direct) {
|
||||||
|
Box(
|
||||||
|
Modifier.padding(top = 52.dp),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
ChatStatusImage(contactNetworkStatus)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun groupInvitationPreviewText(chatModelIncognito: Boolean, currentUserProfileDisplayName: String?, groupInfo: GroupInfo): String {
|
||||||
|
return if (groupInfo.membership.memberIncognito)
|
||||||
|
String.format(stringResource(R.string.group_preview_join_as), groupInfo.membership.memberProfile.displayName)
|
||||||
|
else if (chatModelIncognito)
|
||||||
|
String.format(stringResource(R.string.group_preview_join_as), currentUserProfileDisplayName ?: "")
|
||||||
|
else
|
||||||
|
stringResource(R.string.group_preview_you_are_invited)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun unreadCountStr(n: Int): String {
|
||||||
|
return if (n < 1000) "$n" else "${n / 1000}" + stringResource(R.string.thousand_abbreviation)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun ChatStatusImage(s: NetworkStatus?) {
|
||||||
|
val descr = s?.statusString
|
||||||
|
if (s is NetworkStatus.Error) {
|
||||||
|
Icon(
|
||||||
|
painterResource(R.drawable.ic_error),
|
||||||
|
contentDescription = descr,
|
||||||
|
tint = MaterialTheme.colors.secondary,
|
||||||
|
modifier = Modifier
|
||||||
|
.size(19.dp)
|
||||||
|
)
|
||||||
|
} else if (s !is NetworkStatus.Connected) {
|
||||||
|
CircularProgressIndicator(
|
||||||
|
Modifier
|
||||||
|
.padding(horizontal = 2.dp)
|
||||||
|
.size(15.dp),
|
||||||
|
color = MaterialTheme.colors.secondary,
|
||||||
|
strokeWidth = 1.5.dp
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Preview(showBackground = true)
|
||||||
|
@Preview(
|
||||||
|
uiMode = Configuration.UI_MODE_NIGHT_YES,
|
||||||
|
showBackground = true,
|
||||||
|
name = "Dark Mode"
|
||||||
|
)
|
||||||
|
@Composable
|
||||||
|
fun PreviewChatPreviewView() {
|
||||||
|
SimpleXTheme {
|
||||||
|
ChatPreviewView(Chat.sampleData, null, null, false, "", contactNetworkStatus = NetworkStatus.Connected(), stopped = false, linkMode = SimplexLinkMode.DESCRIPTION)
|
||||||
|
}
|
||||||
|
}
|
||||||
+10
-16
@@ -1,4 +1,4 @@
|
|||||||
package chat.simplex.common.views.chatlist
|
package chat.simplex.app.views.chatlist
|
||||||
|
|
||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.*
|
||||||
import androidx.compose.material.MaterialTheme
|
import androidx.compose.material.MaterialTheme
|
||||||
@@ -7,21 +7,21 @@ import androidx.compose.runtime.*
|
|||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.platform.LocalDensity
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
|
import androidx.compose.ui.res.painterResource
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
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 chat.simplex.common.ui.theme.*
|
import chat.simplex.app.R
|
||||||
import chat.simplex.common.views.helpers.ProfileImage
|
import chat.simplex.app.model.*
|
||||||
import chat.simplex.common.model.PendingContactConnection
|
import chat.simplex.app.ui.theme.*
|
||||||
import chat.simplex.common.model.getTimestampText
|
import chat.simplex.app.views.helpers.ProfileImage
|
||||||
import chat.simplex.res.MR
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun ContactConnectionView(contactConnection: PendingContactConnection) {
|
fun ContactConnectionView(contactConnection: PendingContactConnection) {
|
||||||
Row {
|
Row {
|
||||||
Box(Modifier.size(72.dp), contentAlignment = Alignment.Center) {
|
Box(Modifier.size(72.dp), contentAlignment = Alignment.Center) {
|
||||||
ProfileImage(size = 54.dp, null, if (contactConnection.initiated) MR.images.ic_add_link else MR.images.ic_link)
|
ProfileImage(size = 54.dp, null, if (contactConnection.initiated) R.drawable.ic_add_link else R.drawable.ic_link)
|
||||||
}
|
}
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
@@ -39,22 +39,16 @@ fun ContactConnectionView(contactConnection: PendingContactConnection) {
|
|||||||
val height = with(LocalDensity.current) { 46.sp.toDp() }
|
val height = with(LocalDensity.current) { 46.sp.toDp() }
|
||||||
Text(contactConnection.description, Modifier.heightIn(min = height), maxLines = 2, color = if (isInDarkTheme()) MessagePreviewDark else MessagePreviewLight)
|
Text(contactConnection.description, Modifier.heightIn(min = height), maxLines = 2, color = if (isInDarkTheme()) MessagePreviewDark else MessagePreviewLight)
|
||||||
}
|
}
|
||||||
Box(
|
|
||||||
contentAlignment = Alignment.TopEnd
|
|
||||||
) {
|
|
||||||
val ts = getTimestampText(contactConnection.updatedAt)
|
val ts = getTimestampText(contactConnection.updatedAt)
|
||||||
|
Column(
|
||||||
|
Modifier.fillMaxHeight(),
|
||||||
|
) {
|
||||||
Text(
|
Text(
|
||||||
ts,
|
ts,
|
||||||
color = MaterialTheme.colors.secondary,
|
color = MaterialTheme.colors.secondary,
|
||||||
style = MaterialTheme.typography.body2,
|
style = MaterialTheme.typography.body2,
|
||||||
modifier = Modifier.padding(bottom = 5.dp)
|
modifier = Modifier.padding(bottom = 5.dp)
|
||||||
)
|
)
|
||||||
Box(
|
|
||||||
Modifier.padding(top = 50.dp),
|
|
||||||
contentAlignment = Alignment.Center
|
|
||||||
) {
|
|
||||||
IncognitoIcon(contactConnection.incognito)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+9
-10
@@ -1,4 +1,4 @@
|
|||||||
package chat.simplex.common.views.chatlist
|
package chat.simplex.app.views.chatlist
|
||||||
|
|
||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.*
|
||||||
import androidx.compose.material.MaterialTheme
|
import androidx.compose.material.MaterialTheme
|
||||||
@@ -6,19 +6,18 @@ import androidx.compose.material.Text
|
|||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.platform.LocalDensity
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
import dev.icerock.moko.resources.compose.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
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 chat.simplex.common.ui.theme.*
|
import chat.simplex.app.R
|
||||||
import chat.simplex.common.views.helpers.ChatInfoImage
|
import chat.simplex.app.model.*
|
||||||
import chat.simplex.common.model.ChatInfo
|
import chat.simplex.app.ui.theme.*
|
||||||
import chat.simplex.common.model.getTimestampText
|
import chat.simplex.app.views.helpers.ChatInfoImage
|
||||||
import chat.simplex.res.MR
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun ContactRequestView(contactRequest: ChatInfo.ContactRequest) {
|
fun ContactRequestView(chatModelIncognito: Boolean, contactRequest: ChatInfo.ContactRequest) {
|
||||||
Row {
|
Row {
|
||||||
ChatInfoImage(contactRequest, size = 72.dp)
|
ChatInfoImage(contactRequest, size = 72.dp)
|
||||||
Column(
|
Column(
|
||||||
@@ -32,10 +31,10 @@ fun ContactRequestView(contactRequest: ChatInfo.ContactRequest) {
|
|||||||
overflow = TextOverflow.Ellipsis,
|
overflow = TextOverflow.Ellipsis,
|
||||||
style = MaterialTheme.typography.h3,
|
style = MaterialTheme.typography.h3,
|
||||||
fontWeight = FontWeight.Bold,
|
fontWeight = FontWeight.Bold,
|
||||||
color = MaterialTheme.colors.primary
|
color = if (chatModelIncognito) Indigo else MaterialTheme.colors.primary
|
||||||
)
|
)
|
||||||
val height = with(LocalDensity.current) { 46.sp.toDp() }
|
val height = with(LocalDensity.current) { 46.sp.toDp() }
|
||||||
Text(stringResource(MR.strings.contact_wants_to_connect_with_you), Modifier.heightIn(min = height), maxLines = 2, color = if (isInDarkTheme()) MessagePreviewDark else MessagePreviewLight)
|
Text(stringResource(R.string.contact_wants_to_connect_with_you), Modifier.heightIn(min = height), maxLines = 2, color = if (isInDarkTheme()) MessagePreviewDark else MessagePreviewLight)
|
||||||
}
|
}
|
||||||
val ts = getTimestampText(contactRequest.contactRequest.updatedAt)
|
val ts = getTimestampText(contactRequest.contactRequest.updatedAt)
|
||||||
Column(
|
Column(
|
||||||
+6
-6
@@ -1,4 +1,4 @@
|
|||||||
package chat.simplex.common.views.chatlist
|
package chat.simplex.app.views.chatlist
|
||||||
|
|
||||||
import SectionItemView
|
import SectionItemView
|
||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.*
|
||||||
@@ -9,9 +9,9 @@ import androidx.compose.ui.Modifier
|
|||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import chat.simplex.common.ui.theme.Indigo
|
import chat.simplex.app.model.*
|
||||||
import chat.simplex.common.views.helpers.ProfileImage
|
import chat.simplex.app.ui.theme.Indigo
|
||||||
import chat.simplex.common.model.*
|
import chat.simplex.app.views.helpers.ProfileImage
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun ShareListNavLinkView(chat: Chat, chatModel: ChatModel) {
|
fun ShareListNavLinkView(chat: Chat, chatModel: ChatModel) {
|
||||||
@@ -20,13 +20,13 @@ fun ShareListNavLinkView(chat: Chat, chatModel: ChatModel) {
|
|||||||
is ChatInfo.Direct ->
|
is ChatInfo.Direct ->
|
||||||
ShareListNavLinkLayout(
|
ShareListNavLinkLayout(
|
||||||
chatLinkPreview = { SharePreviewView(chat) },
|
chatLinkPreview = { SharePreviewView(chat) },
|
||||||
click = { directChatAction(chat.remoteHostId, chat.chatInfo.contact, chatModel) },
|
click = { directChatAction(chat.chatInfo, chatModel) },
|
||||||
stopped
|
stopped
|
||||||
)
|
)
|
||||||
is ChatInfo.Group ->
|
is ChatInfo.Group ->
|
||||||
ShareListNavLinkLayout(
|
ShareListNavLinkLayout(
|
||||||
chatLinkPreview = { SharePreviewView(chat) },
|
chatLinkPreview = { SharePreviewView(chat) },
|
||||||
click = { groupChatAction(chat.remoteHostId, chat.chatInfo.groupInfo, chatModel) },
|
click = { groupChatAction(chat.chatInfo.groupInfo, chatModel) },
|
||||||
stopped
|
stopped
|
||||||
)
|
)
|
||||||
is ChatInfo.ContactRequest, is ChatInfo.ContactConnection, is ChatInfo.InvalidJSON -> {}
|
is ChatInfo.ContactRequest, is ChatInfo.ContactConnection, is ChatInfo.InvalidJSON -> {}
|
||||||
+35
-31
@@ -1,5 +1,7 @@
|
|||||||
package chat.simplex.common.views.chatlist
|
package chat.simplex.app.views.chatlist
|
||||||
|
|
||||||
|
import androidx.activity.compose.BackHandler
|
||||||
|
import androidx.compose.foundation.*
|
||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.*
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.foundation.lazy.items
|
import androidx.compose.foundation.lazy.items
|
||||||
@@ -9,34 +11,31 @@ import androidx.compose.runtime.saveable.rememberSaveable
|
|||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import dev.icerock.moko.resources.compose.painterResource
|
import androidx.compose.ui.res.painterResource
|
||||||
import dev.icerock.moko.resources.compose.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.capitalize
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.intl.Locale
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import chat.simplex.common.SettingsViewState
|
import chat.simplex.app.R
|
||||||
import chat.simplex.common.ui.theme.*
|
import chat.simplex.app.model.*
|
||||||
import chat.simplex.common.views.helpers.*
|
import chat.simplex.app.ui.theme.*
|
||||||
import chat.simplex.common.model.Chat
|
import chat.simplex.app.views.helpers.*
|
||||||
import chat.simplex.common.model.ChatModel
|
|
||||||
import chat.simplex.common.platform.BackHandler
|
|
||||||
import chat.simplex.common.platform.appPlatform
|
|
||||||
import chat.simplex.res.MR
|
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun ShareListView(chatModel: ChatModel, settingsState: SettingsViewState, stopped: Boolean) {
|
fun ShareListView(chatModel: ChatModel, stopped: Boolean) {
|
||||||
var searchInList by rememberSaveable { mutableStateOf("") }
|
var searchInList by rememberSaveable { mutableStateOf("") }
|
||||||
val (userPickerState, scaffoldState, switchingUsersAndHosts) = settingsState
|
val userPickerState by rememberSaveable(stateSaver = AnimatedViewState.saver()) { mutableStateOf(MutableStateFlow(AnimatedViewState.GONE)) }
|
||||||
val endPadding = if (appPlatform.isDesktop) 56.dp else 0.dp
|
val switchingUsers = rememberSaveable { mutableStateOf(false) }
|
||||||
Scaffold(
|
Scaffold(
|
||||||
Modifier.padding(end = endPadding),
|
|
||||||
scaffoldState = scaffoldState,
|
|
||||||
topBar = { Column { ShareListToolbar(chatModel, userPickerState, stopped) { searchInList = it.trim() } } },
|
topBar = { Column { ShareListToolbar(chatModel, userPickerState, stopped) { searchInList = it.trim() } } },
|
||||||
) {
|
) {
|
||||||
Box(Modifier.padding(it)) {
|
Box(Modifier.padding(it)) {
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
|
.themedBackground()
|
||||||
) {
|
) {
|
||||||
if (chatModel.chats.isNotEmpty()) {
|
if (chatModel.chats.isNotEmpty()) {
|
||||||
ShareList(chatModel, search = searchInList)
|
ShareList(chatModel, search = searchInList)
|
||||||
@@ -46,18 +45,15 @@ fun ShareListView(chatModel: ChatModel, settingsState: SettingsViewState, stoppe
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (appPlatform.isAndroid) {
|
UserPicker(chatModel, userPickerState, switchingUsers, showSettings = false, showCancel = true, cancelClicked = {
|
||||||
UserPicker(chatModel, userPickerState, switchingUsersAndHosts, showSettings = false, showCancel = true, cancelClicked = {
|
|
||||||
chatModel.sharedContent.value = null
|
chatModel.sharedContent.value = null
|
||||||
userPickerState.value = AnimatedViewState.GONE
|
|
||||||
})
|
})
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun EmptyList() {
|
private fun EmptyList() {
|
||||||
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||||
Text(stringResource(MR.strings.you_have_no_chats), color = MaterialTheme.colors.secondary)
|
Text(stringResource(R.string.you_have_no_chats), color = MaterialTheme.colors.secondary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -73,7 +69,7 @@ private fun ShareListToolbar(chatModel: ChatModel, userPickerState: MutableState
|
|||||||
val navButton: @Composable RowScope.() -> Unit = {
|
val navButton: @Composable RowScope.() -> Unit = {
|
||||||
when {
|
when {
|
||||||
showSearch -> NavigationButtonBack(hideSearchOnBack)
|
showSearch -> NavigationButtonBack(hideSearchOnBack)
|
||||||
users.size > 1 || chatModel.remoteHosts.isNotEmpty() -> {
|
users.size > 1 -> {
|
||||||
val allRead = users
|
val allRead = users
|
||||||
.filter { u -> !u.user.activeUser && !u.user.hidden }
|
.filter { u -> !u.user.activeUser && !u.user.hidden }
|
||||||
.all { u -> u.unreadCount == 0 }
|
.all { u -> u.unreadCount == 0 }
|
||||||
@@ -87,7 +83,7 @@ private fun ShareListToolbar(chatModel: ChatModel, userPickerState: MutableState
|
|||||||
if (chatModel.chats.size >= 8) {
|
if (chatModel.chats.size >= 8) {
|
||||||
barButtons.add {
|
barButtons.add {
|
||||||
IconButton({ showSearch = true }) {
|
IconButton({ showSearch = true }) {
|
||||||
Icon(painterResource(MR.images.ic_search_500), stringResource(MR.strings.search_verb), tint = MaterialTheme.colors.primary)
|
Icon(painterResource(R.drawable.ic_search_500), stringResource(android.R.string.search_go).capitalize(Locale.current), tint = MaterialTheme.colors.primary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -95,13 +91,13 @@ private fun ShareListToolbar(chatModel: ChatModel, userPickerState: MutableState
|
|||||||
barButtons.add {
|
barButtons.add {
|
||||||
IconButton(onClick = {
|
IconButton(onClick = {
|
||||||
AlertManager.shared.showAlertMsg(
|
AlertManager.shared.showAlertMsg(
|
||||||
generalGetString(MR.strings.chat_is_stopped_indication),
|
generalGetString(R.string.chat_is_stopped_indication),
|
||||||
generalGetString(MR.strings.you_can_start_chat_via_setting_or_by_restarting_the_app)
|
generalGetString(R.string.you_can_start_chat_via_setting_or_by_restarting_the_app)
|
||||||
)
|
)
|
||||||
}) {
|
}) {
|
||||||
Icon(
|
Icon(
|
||||||
painterResource(MR.images.ic_report_filled),
|
painterResource(R.drawable.ic_report_filled),
|
||||||
generalGetString(MR.strings.chat_is_stopped_indication),
|
generalGetString(R.string.chat_is_stopped_indication),
|
||||||
tint = Color.Red,
|
tint = Color.Red,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -114,14 +110,22 @@ private fun ShareListToolbar(chatModel: ChatModel, userPickerState: MutableState
|
|||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
Text(
|
Text(
|
||||||
when (chatModel.sharedContent.value) {
|
when (chatModel.sharedContent.value) {
|
||||||
is SharedContent.Text -> stringResource(MR.strings.share_message)
|
is SharedContent.Text -> stringResource(R.string.share_message)
|
||||||
is SharedContent.Media -> stringResource(MR.strings.share_image)
|
is SharedContent.Media -> stringResource(R.string.share_image)
|
||||||
is SharedContent.File -> stringResource(MR.strings.share_file)
|
is SharedContent.File -> stringResource(R.string.share_file)
|
||||||
else -> stringResource(MR.strings.share_message)
|
else -> stringResource(R.string.share_message)
|
||||||
},
|
},
|
||||||
color = MaterialTheme.colors.onBackground,
|
color = MaterialTheme.colors.onBackground,
|
||||||
fontWeight = FontWeight.SemiBold,
|
fontWeight = FontWeight.SemiBold,
|
||||||
)
|
)
|
||||||
|
if (chatModel.incognito.value) {
|
||||||
|
Icon(
|
||||||
|
painterResource(R.drawable.ic_theater_comedy_filled),
|
||||||
|
stringResource(R.string.incognito),
|
||||||
|
tint = Indigo,
|
||||||
|
modifier = Modifier.padding(10.dp).size(26.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onTitleClick = null,
|
onTitleClick = null,
|
||||||
@@ -0,0 +1,241 @@
|
|||||||
|
package chat.simplex.app.views.chatlist
|
||||||
|
|
||||||
|
import SectionItemView
|
||||||
|
import android.util.Log
|
||||||
|
import androidx.compose.animation.core.*
|
||||||
|
import androidx.compose.foundation.*
|
||||||
|
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||||
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.foundation.shape.*
|
||||||
|
import androidx.compose.material.*
|
||||||
|
import androidx.compose.runtime.*
|
||||||
|
import androidx.compose.ui.*
|
||||||
|
import androidx.compose.ui.draw.*
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.graphicsLayer
|
||||||
|
import androidx.compose.ui.platform.LocalConfiguration
|
||||||
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
|
import androidx.compose.ui.res.painterResource
|
||||||
|
import androidx.compose.ui.text.capitalize
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.intl.Locale
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
import androidx.compose.ui.unit.*
|
||||||
|
import chat.simplex.app.R
|
||||||
|
import chat.simplex.app.TAG
|
||||||
|
import chat.simplex.app.model.*
|
||||||
|
import chat.simplex.app.ui.theme.*
|
||||||
|
import chat.simplex.app.views.helpers.*
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.flow.*
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun UserPicker(
|
||||||
|
chatModel: ChatModel,
|
||||||
|
userPickerState: MutableStateFlow<AnimatedViewState>,
|
||||||
|
switchingUsers: MutableState<Boolean>,
|
||||||
|
showSettings: Boolean = true,
|
||||||
|
showCancel: Boolean = false,
|
||||||
|
cancelClicked: () -> Unit = {},
|
||||||
|
settingsClicked: () -> Unit = {},
|
||||||
|
) {
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
var newChat by remember { mutableStateOf(userPickerState.value) }
|
||||||
|
val users by remember {
|
||||||
|
derivedStateOf {
|
||||||
|
chatModel.users
|
||||||
|
.filter { u -> u.user.activeUser || !u.user.hidden }
|
||||||
|
.sortedByDescending { it.user.activeUser }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val animatedFloat = remember { Animatable(if (newChat.isVisible()) 0f else 1f) }
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
launch {
|
||||||
|
userPickerState.collect {
|
||||||
|
newChat = it
|
||||||
|
launch {
|
||||||
|
animatedFloat.animateTo(if (newChat.isVisible()) 1f else 0f, newChatSheetAnimSpec())
|
||||||
|
if (newChat.isHiding()) userPickerState.value = AnimatedViewState.GONE
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
snapshotFlow { newChat.isVisible() }
|
||||||
|
.distinctUntilChanged()
|
||||||
|
.filter { it }
|
||||||
|
.collect {
|
||||||
|
try {
|
||||||
|
val updatedUsers = chatModel.controller.listUsers().sortedByDescending { it.user.activeUser }
|
||||||
|
var same = users.size == updatedUsers.size
|
||||||
|
if (same) {
|
||||||
|
for (i in 0 until minOf(users.size, updatedUsers.size)) {
|
||||||
|
val prev = updatedUsers[i].user
|
||||||
|
val next = users[i].user
|
||||||
|
if (prev.userId != next.userId || prev.activeUser != next.activeUser || prev.chatViewName != next.chatViewName || prev.image != next.image) {
|
||||||
|
same = false
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!same) {
|
||||||
|
chatModel.users.clear()
|
||||||
|
chatModel.users.addAll(updatedUsers)
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "Error updating users ${e.stackTraceToString()}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val xOffset = with(LocalDensity.current) { 10.dp.roundToPx() }
|
||||||
|
val maxWidth = with(LocalDensity.current) { LocalConfiguration.current.screenWidthDp * density }
|
||||||
|
Box(Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.offset { IntOffset(if (newChat.isGone()) -maxWidth.roundToInt() else xOffset, 0) }
|
||||||
|
.clickable(interactionSource = remember { MutableInteractionSource() }, indication = null, onClick = { userPickerState.value = AnimatedViewState.HIDING })
|
||||||
|
.padding(bottom = 10.dp, top = 10.dp)
|
||||||
|
.graphicsLayer {
|
||||||
|
alpha = animatedFloat.value
|
||||||
|
translationY = (animatedFloat.value - 1) * xOffset
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
Modifier
|
||||||
|
.widthIn(min = 260.dp)
|
||||||
|
.width(IntrinsicSize.Min)
|
||||||
|
.height(IntrinsicSize.Min)
|
||||||
|
.shadow(8.dp, RoundedCornerShape(corner = CornerSize(25.dp)), clip = true)
|
||||||
|
.background(MaterialTheme.colors.surface, RoundedCornerShape(corner = CornerSize(25.dp)))
|
||||||
|
.clip(RoundedCornerShape(corner = CornerSize(25.dp)))
|
||||||
|
) {
|
||||||
|
Column(Modifier.weight(1f).verticalScroll(rememberScrollState())) {
|
||||||
|
users.forEach { u ->
|
||||||
|
UserProfilePickerItem(u.user, u.unreadCount, PaddingValues(start = DEFAULT_PADDING_HALF, end = DEFAULT_PADDING), openSettings = {
|
||||||
|
settingsClicked()
|
||||||
|
userPickerState.value = AnimatedViewState.GONE
|
||||||
|
}) {
|
||||||
|
userPickerState.value = AnimatedViewState.HIDING
|
||||||
|
if (!u.user.activeUser) {
|
||||||
|
scope.launch {
|
||||||
|
val job = launch {
|
||||||
|
delay(500)
|
||||||
|
switchingUsers.value = true
|
||||||
|
}
|
||||||
|
chatModel.controller.changeActiveUser(u.user.userId, null)
|
||||||
|
job.cancel()
|
||||||
|
switchingUsers.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Divider(Modifier.requiredHeight(1.dp))
|
||||||
|
if (u.user.activeUser) Divider(Modifier.requiredHeight(0.5.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (showSettings) {
|
||||||
|
SettingsPickerItem {
|
||||||
|
settingsClicked()
|
||||||
|
userPickerState.value = AnimatedViewState.GONE
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (showCancel) {
|
||||||
|
CancelPickerItem {
|
||||||
|
cancelClicked()
|
||||||
|
userPickerState.value = AnimatedViewState.GONE
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun UserProfilePickerItem(u: User, unreadCount: Int = 0, padding: PaddingValues = PaddingValues(start = DEFAULT_PADDING_HALF, end = DEFAULT_PADDING), onLongClick: () -> Unit = {}, openSettings: () -> Unit = {}, onClick: () -> Unit) {
|
||||||
|
Row(
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.sizeIn(minHeight = 46.dp)
|
||||||
|
.combinedClickable(
|
||||||
|
onClick = if (u.activeUser) openSettings else onClick,
|
||||||
|
onLongClick = onLongClick,
|
||||||
|
interactionSource = remember { MutableInteractionSource() },
|
||||||
|
indication = if (!u.activeUser) LocalIndication.current else null
|
||||||
|
)
|
||||||
|
.padding(padding),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
UserProfileRow(u)
|
||||||
|
if (u.activeUser) {
|
||||||
|
Icon(painterResource(R.drawable.ic_done_filled), null, Modifier.size(20.dp), tint = MaterialTheme.colors.onBackground)
|
||||||
|
} else if (u.hidden) {
|
||||||
|
Icon(painterResource(R.drawable.ic_lock), null, Modifier.size(20.dp), tint = MaterialTheme.colors.secondary)
|
||||||
|
} else if (unreadCount > 0) {
|
||||||
|
Box(
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
unreadCountStr(unreadCount),
|
||||||
|
color = Color.White,
|
||||||
|
fontSize = 11.sp,
|
||||||
|
modifier = Modifier
|
||||||
|
.background(MaterialTheme.colors.primaryVariant, shape = CircleShape)
|
||||||
|
.padding(2.dp)
|
||||||
|
.badgeLayout()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else if (!u.showNtfs) {
|
||||||
|
Icon(painterResource(R.drawable.ic_notifications_off), null, Modifier.size(20.dp), tint = MaterialTheme.colors.secondary)
|
||||||
|
} else {
|
||||||
|
Box(Modifier.size(20.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun UserProfileRow(u: User) {
|
||||||
|
Row(
|
||||||
|
Modifier
|
||||||
|
.widthIn(max = LocalConfiguration.current.screenWidthDp.dp * 0.7f)
|
||||||
|
.padding(vertical = 8.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
ProfileImage(
|
||||||
|
image = u.image,
|
||||||
|
size = 54.dp
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
u.displayName,
|
||||||
|
modifier = Modifier
|
||||||
|
.padding(start = 10.dp, end = 8.dp),
|
||||||
|
color = if (isInDarkTheme()) MenuTextColorDark else Color.Black,
|
||||||
|
fontWeight = if (u.activeUser) FontWeight.Medium else FontWeight.Normal
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun SettingsPickerItem(onClick: () -> Unit) {
|
||||||
|
SectionItemView(onClick, padding = PaddingValues(start = DEFAULT_PADDING + 7.dp, end = DEFAULT_PADDING), minHeight = 68.dp) {
|
||||||
|
val text = generalGetString(R.string.settings_section_title_settings).lowercase().capitalize(Locale.current)
|
||||||
|
Icon(painterResource(R.drawable.ic_settings), text, Modifier.size(20.dp), tint = MaterialTheme.colors.onBackground)
|
||||||
|
Spacer(Modifier.width(DEFAULT_PADDING + 6.dp))
|
||||||
|
Text(
|
||||||
|
text,
|
||||||
|
color = if (isInDarkTheme()) MenuTextColorDark else Color.Black,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun CancelPickerItem(onClick: () -> Unit) {
|
||||||
|
SectionItemView(onClick, padding = PaddingValues(start = DEFAULT_PADDING + 7.dp, end = DEFAULT_PADDING), minHeight = 68.dp) {
|
||||||
|
val text = generalGetString(R.string.cancel_verb)
|
||||||
|
Icon(painterResource(R.drawable.ic_close), text, Modifier.size(20.dp), tint = MaterialTheme.colors.onBackground)
|
||||||
|
Spacer(Modifier.width(DEFAULT_PADDING + 6.dp))
|
||||||
|
Text(
|
||||||
|
text,
|
||||||
|
color = if (isInDarkTheme()) MenuTextColorDark else Color.Black,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user