mirror of
https://github.com/simplex-chat/simplex-chat.git
synced 2025-06-29 20:59:53 +00:00
Compare commits
344 commits
v6.2.2-arm
...
stable
Author | SHA1 | Date | |
---|---|---|---|
|
3d22b738d8 | ||
|
c08189108e | ||
|
442d9afc4b | ||
|
a593557c21 | ||
|
07abe24e18 | ||
|
5f6595dda9 | ||
|
6fdd50efb9 | ||
|
50dfda6c09 | ||
|
ea1a81fcac | ||
|
cf0639bf28 | ||
|
7b362ff655 | ||
|
26e5742354 | ||
|
5dd89fe127 | ||
|
a36a6d44db | ||
|
8af3cb935e | ||
|
dc35e5f765 | ||
|
9b4908c370 | ||
|
9098e22d4b | ||
|
1f8609a31f | ||
|
348961576b | ||
|
a5a9d4f7d5 | ||
|
c822fa53f6 | ||
|
c0b9a0e094 | ||
|
9e60ce7a60 | ||
|
bb2e7baaa8 | ||
|
2a43a02af3 | ||
|
e1aa32952e | ||
|
8d54acef92 | ||
|
d338696035 | ||
|
5b7f3fdd78 | ||
|
4b42a19ccb | ||
|
5f56f61c36 | ||
|
f49c51ae16 | ||
|
ecb4a36045 | ||
|
73fe6827b2 | ||
|
9329bf6144 | ||
|
05de019ecd | ||
|
24b0f0290b | ||
|
a0d1cca389 | ||
|
f5c706f2dd | ||
|
e7a4611be9 | ||
|
38b8e0cee6 | ||
|
ca49167ec6 | ||
|
7cac164b84 | ||
|
6390263370 | ||
|
7b11d8514a | ||
|
d53c13f8be | ||
|
623a46e418 | ||
|
3625233931 | ||
|
83b3d631f5 | ||
|
3257b60b70 | ||
|
5351fa68d0 | ||
|
96b962809f | ||
|
52e2af6e32 | ||
|
a6fd5ce902 | ||
|
b637d370f3 | ||
|
82f9fecccf | ||
|
0f3e546e36 | ||
|
f52d06af3a | ||
|
df99ed495c | ||
|
45e395d35a | ||
|
38c2529d8b | ||
|
14d9240995 | ||
|
eae281df60 | ||
|
8766891124 | ||
|
090f576b65 | ||
|
48b1ef764b | ||
|
3fb09d3def | ||
|
e7f8533112 | ||
|
d45ecff13a | ||
|
4b6d1d4585 | ||
|
66273790e6 | ||
|
a32ed2ec1f | ||
|
af56b3fed0 | ||
|
7c1d900e1f | ||
|
27f2926aed | ||
|
4443786474 | ||
|
f8fddb1daf | ||
|
99dcaa34ba | ||
|
9f853e2e84 | ||
|
15742aee30 | ||
|
6020c6010d | ||
|
cd20dc0a04 | ||
|
6b75f61537 | ||
|
6e7df9c72d | ||
|
6556e09a33 | ||
|
745372dd7a | ||
|
e58d09ce78 | ||
|
b8e2e71a60 | ||
|
ae24da090c | ||
|
364aa667ad | ||
|
45c7c6bc6e | ||
|
aba09939e2 | ||
|
5050d60825 | ||
|
2317cee3eb | ||
|
16cf91902c | ||
|
ed625347bd | ||
|
4bd95c8e4e | ||
|
9dfa68bf57 | ||
|
3188d9f087 | ||
|
89dddab060 | ||
|
f31372a771 | ||
|
3412ceba01 | ||
|
27f63dafaa | ||
|
e2d488266c | ||
|
430e212a9e | ||
|
47adbe2813 | ||
|
cc2a45bdaf | ||
|
ad4adf66ec | ||
|
f53b21f8c6 | ||
|
2d203c1a18 | ||
|
a6631ce629 | ||
|
5bef7349d8 | ||
|
37050a99c9 | ||
|
ca31c9a5e9 | ||
|
a0560a5ad0 | ||
|
a3a27b250c | ||
|
7a3663f1e0 | ||
|
9dac472191 | ||
|
8c7df76c24 | ||
|
3425bd0826 | ||
|
257208a99b | ||
|
8140710660 | ||
|
518ab2cd3e | ||
|
d2e60503f9 | ||
|
7c2153762f | ||
|
0a97218440 | ||
|
2788a1dbb3 | ||
|
1ddf7a62ad | ||
|
8b030075d7 | ||
|
b2de37a9fb | ||
|
27bf19c2b1 | ||
|
3d076a89e7 | ||
|
7471fd2af5 | ||
|
50232fd179 | ||
|
1b757911fa | ||
|
dcea008fb9 | ||
|
dce8502165 | ||
|
fefddb3b5a | ||
|
b482d4d812 | ||
|
1fcb352db4 | ||
|
f701ffa4e0 | ||
|
511ff1d35c | ||
|
981901d587 | ||
|
a5334b36f8 | ||
|
b494c43706 | ||
|
c972298dd2 | ||
|
b96b6c70d2 | ||
|
4f00f9efa0 | ||
|
c81fa7e6b0 | ||
|
41ccb14bfa | ||
|
bf37c0762e | ||
|
bc9885675d | ||
|
dd13450443 | ||
|
e59967b0d6 | ||
|
b27e964d0c | ||
|
ca3687488f | ||
|
676583d3c3 | ||
|
dc980ae88f | ||
|
ec519afb3f | ||
|
9d1329498b | ||
|
704bab171d | ||
|
f2430cc57f | ||
|
5a0821f9fc | ||
|
85409db1cc | ||
|
cb3ace5f71 | ||
|
88bb387b1b | ||
|
7c5966df70 | ||
|
1f8755f941 | ||
|
e9893989df | ||
|
87569e379a | ||
|
dfe5a4464b | ||
|
a90f255df5 | ||
|
8dbebbe3d6 | ||
|
47997fd90b | ||
|
0e40d4b5ff | ||
|
d39b4863b4 | ||
|
e06741c936 | ||
|
e7361cf025 | ||
|
c5bb2c4ca2 | ||
|
3267eb2b27 | ||
|
9533772aa2 | ||
|
205ced1c1d | ||
|
ff35643533 | ||
|
9c28a51fee | ||
|
75388b997e | ||
|
75685df2e8 | ||
|
38c5c19b17 | ||
|
a91599543e | ||
|
83984e482c | ||
|
e4d6a8822c | ||
|
dac8389263 | ||
|
6b7c4509fe | ||
|
e6ddbc1172 | ||
|
5b947b3130 | ||
|
a622cb91f9 | ||
|
844b24be9d | ||
|
f4b93f6e8a | ||
|
37d9954cf7 | ||
|
fffeef0e28 | ||
|
517679e2df | ||
|
760ea17fb9 | ||
|
82dffd55a9 | ||
|
43e374cf20 | ||
|
442282be93 | ||
|
f71aa3104c | ||
|
92772d3d09 | ||
|
5591b72feb | ||
|
9e000d6bce | ||
|
1332480170 | ||
|
f7d133a63c | ||
|
68e63c7eb6 | ||
|
5d18a49726 | ||
|
7f09de18d9 | ||
|
3bc822a1e9 | ||
|
4ed67f094f | ||
|
2d719273a8 | ||
|
e0d6e4ccf7 | ||
|
621b291da1 | ||
|
c20e94f2fb | ||
|
2a58f36563 | ||
|
cb0e362c01 | ||
|
90a2faae93 | ||
|
62895a72b5 | ||
|
1306df81e4 | ||
|
5072a8475b | ||
|
56eaf12840 | ||
|
f9a4445e1a | ||
|
d86e6b35be | ||
|
d4eedd5886 | ||
|
f3664619ec | ||
|
9ccea0dc50 | ||
|
969a7c433d | ||
|
8e609ac507 | ||
|
5bd8dc1f71 | ||
|
9cf2b5a1e4 | ||
|
b491a7e735 | ||
|
7e864f9178 | ||
|
20fa30eacc | ||
|
0e940719c1 | ||
|
830838fc4c | ||
|
d238a3c18f | ||
|
a59dea27b9 | ||
|
951156f7fb | ||
|
46f9a7898a | ||
|
0c31c9f523 | ||
|
b070af5c74 | ||
|
6b5a1bf25d | ||
|
9c44ff404e | ||
|
a2b1c939a1 | ||
|
748af1fdc2 | ||
|
27481116f0 | ||
|
457774bd7d | ||
|
9cefcb3fe8 | ||
|
49bf3cc673 | ||
|
0d44e9f0f5 | ||
|
bd396cb4d6 | ||
|
ddd3956a68 | ||
|
db8f33debe | ||
|
ef72d8e446 | ||
|
daea3b1056 | ||
|
161143add0 | ||
|
d287df2640 | ||
|
748287b724 | ||
|
9a736b6417 | ||
|
eacae74fed | ||
|
0d6b26c269 | ||
|
d81ae757eb | ||
|
bbb58c8e09 | ||
|
e3ddf04266 | ||
|
821f034d18 | ||
|
57cd99f619 | ||
|
77de92be03 | ||
|
94815bf644 | ||
|
c8c6a832dd | ||
|
5fcf5c2cf8 | ||
|
3f116c01d3 | ||
|
d5ce770f41 | ||
|
5289d86254 | ||
|
e05a35e26e | ||
|
13fae855fc | ||
|
cd9eb66ebb | ||
|
c25d0ea224 | ||
|
5256606f9d | ||
|
3cfc74e0fd | ||
|
146c968a79 | ||
|
bcb7c8bd7b | ||
|
7281255480 | ||
|
7e344b3ee8 | ||
|
105d188e76 | ||
|
569832c8de | ||
|
8dc29082d5 | ||
|
f33a9650bc | ||
|
05a5d161fb | ||
|
912aaa2741 | ||
|
e3e5d9646c | ||
|
38db2d075d | ||
|
ea68681b9b | ||
|
8b5bc44106 | ||
|
95b19a4947 | ||
|
651c5640e2 | ||
|
c9f6f3c053 | ||
|
2793692a16 | ||
|
4813ab526d | ||
|
23b20ac743 | ||
|
aa7095dee2 | ||
|
ab0c320fcb | ||
|
cab938b9f0 | ||
|
0dfcd60490 | ||
|
e27f8a8d6a | ||
|
206f7898c3 | ||
|
d37d309f85 | ||
|
ddf0adfc29 | ||
|
0a596e6417 | ||
|
fc7f509364 | ||
|
4250a19299 | ||
|
f3670965fb | ||
|
ce76c00c69 | ||
|
00bc59b3a0 | ||
|
086e375bac | ||
|
400967b03b | ||
|
a0cc177eb5 | ||
|
84a45cedbe | ||
|
32773c1d6e | ||
|
1fd45f3478 | ||
|
90c15ee07d | ||
|
6218896753 | ||
|
e9bd7200c6 | ||
|
cb721f6c71 | ||
|
ba601552d2 | ||
|
0160d57e58 | ||
|
bcdf08488e | ||
|
909f52342c | ||
|
323c5d26b5 | ||
|
a3140c2d3e | ||
|
5aa8b8cd1b | ||
|
00973d6e13 | ||
|
9adff0bfd1 | ||
|
143be1edaf | ||
|
fcb2d1dbac | ||
|
a73fb89c44 | ||
|
6ad6549bdb | ||
|
24d126a125 | ||
|
6bae86d93b |
791 changed files with 88439 additions and 32269 deletions
47
.github/actions/prepare-build/action.yml
vendored
Normal file
47
.github/actions/prepare-build/action.yml
vendored
Normal file
|
@ -0,0 +1,47 @@
|
|||
name: "Prebuilt steps for build"
|
||||
description: "Reusable steps for multiple jobs"
|
||||
inputs:
|
||||
java_ver:
|
||||
required: true
|
||||
description: "Java version to install"
|
||||
ghc_ver:
|
||||
required: true
|
||||
description: "GHC version to install"
|
||||
github_ref:
|
||||
required: true
|
||||
description: "Git reference"
|
||||
os:
|
||||
required: true
|
||||
description: "Target OS"
|
||||
cache_path:
|
||||
required: false
|
||||
default: "~/.cabal/store"
|
||||
description: "Cache path"
|
||||
cabal_ver:
|
||||
required: false
|
||||
default: 3.10.1.0
|
||||
description: "GHC version to install"
|
||||
runs:
|
||||
using: "composite"
|
||||
steps:
|
||||
- name: Setup Haskell
|
||||
uses: simplex-chat/setup-haskell-action@v2
|
||||
with:
|
||||
ghc-version: ${{ inputs.ghc_ver }}
|
||||
cabal-version: ${{ inputs.cabal_ver }}
|
||||
|
||||
- name: Setup Java
|
||||
if: startsWith(inputs.github_ref, 'refs/tags/v')
|
||||
uses: actions/setup-java@v3
|
||||
with:
|
||||
distribution: 'corretto'
|
||||
java-version: ${{ inputs.java_ver }}
|
||||
cache: 'gradle'
|
||||
|
||||
- name: Restore cached build
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
${{ inputs.cache_path }}
|
||||
dist-newstyle
|
||||
key: ${{ inputs.os }}-ghc${{ inputs.ghc_ver }}-${{ hashFiles('cabal.project', 'simplex-chat.cabal') }}
|
39
.github/actions/prepare-release/action.yml
vendored
Normal file
39
.github/actions/prepare-release/action.yml
vendored
Normal file
|
@ -0,0 +1,39 @@
|
|||
name: "Upload binary and update hash"
|
||||
description: "Reusable steps for multiple jobs"
|
||||
inputs:
|
||||
bin_path:
|
||||
required: true
|
||||
description: "Path to binary to upload"
|
||||
bin_name:
|
||||
required: true
|
||||
description: "Name of uploaded binary"
|
||||
bin_hash:
|
||||
required: true
|
||||
description: "Message with SHA to include in release"
|
||||
github_ref:
|
||||
required: true
|
||||
description: "Github reference"
|
||||
github_token:
|
||||
required: true
|
||||
description: "Github token"
|
||||
runs:
|
||||
using: "composite"
|
||||
steps:
|
||||
- name: Upload file with specific name
|
||||
if: startsWith(inputs.github_ref, 'refs/tags/v')
|
||||
uses: simplex-chat/upload-release-action@v2
|
||||
with:
|
||||
repo_token: ${{ inputs.github_token }}
|
||||
file: ${{ inputs.bin_path }}
|
||||
asset_name: ${{ inputs.bin_name }}
|
||||
tag: ${{ inputs.github_ref }}
|
||||
|
||||
- name: Add hash to release notes
|
||||
if: startsWith(inputs.github_ref, 'refs/tags/v')
|
||||
uses: simplex-chat/action-gh-release@v2
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ inputs.github_token }}
|
||||
with:
|
||||
append_body: true
|
||||
body: |
|
||||
${{ inputs.bin_hash }}
|
44
.github/actions/swap/action.yml
vendored
Normal file
44
.github/actions/swap/action.yml
vendored
Normal file
|
@ -0,0 +1,44 @@
|
|||
name: 'Set Swap Space'
|
||||
description: 'Add moar swap'
|
||||
branding:
|
||||
icon: 'crop'
|
||||
color: 'orange'
|
||||
inputs:
|
||||
swap-size-gb:
|
||||
description: 'Swap space to create, in Gigabytes.'
|
||||
required: false
|
||||
default: '10'
|
||||
runs:
|
||||
using: "composite"
|
||||
steps:
|
||||
- name: Swap space report before modification
|
||||
shell: bash
|
||||
run: |
|
||||
echo "Memory and swap:"
|
||||
free -h
|
||||
echo
|
||||
swapon --show
|
||||
echo
|
||||
- name: Set Swap
|
||||
shell: bash
|
||||
run: |
|
||||
export SWAP_FILE=$(swapon --show=NAME | tail -n 1)
|
||||
echo "Swap file: $SWAP_FILE"
|
||||
if [ -z "$SWAP_FILE" ]; then
|
||||
SWAP_FILE=/opt/swapfile
|
||||
else
|
||||
sudo swapoff $SWAP_FILE
|
||||
sudo rm $SWAP_FILE
|
||||
fi
|
||||
sudo fallocate -l ${{ inputs.swap-size-gb }}G $SWAP_FILE
|
||||
sudo chmod 600 $SWAP_FILE
|
||||
sudo mkswap $SWAP_FILE
|
||||
sudo swapon $SWAP_FILE
|
||||
- name: Swap space report after modification
|
||||
shell: bash
|
||||
run: |
|
||||
echo "Memory and swap:"
|
||||
free -h
|
||||
echo
|
||||
swapon --show
|
||||
echo
|
649
.github/workflows/build.yml
vendored
649
.github/workflows/build.yml
vendored
|
@ -22,17 +22,58 @@ on:
|
|||
- "README.md"
|
||||
- "PRIVACY.md"
|
||||
|
||||
# This workflow uses custom actions (prepare-build and prepare-release) defined in:
|
||||
#
|
||||
# .github/actions/
|
||||
# ├── prepare-build
|
||||
# │ └── action.yml
|
||||
# └── prepare-release
|
||||
# └── action.yml
|
||||
|
||||
# Important!
|
||||
# Do not use always(), it makes build unskippable.
|
||||
# See: https://github.com/actions/runner/issues/1846#issuecomment-1246102753
|
||||
|
||||
jobs:
|
||||
prepare-release:
|
||||
if: startsWith(github.ref, 'refs/tags/v')
|
||||
|
||||
# =============================
|
||||
# Global variables
|
||||
# =============================
|
||||
|
||||
# That is the only and less hacky way to setup global variables
|
||||
# to use in strategy matrix (env:/YAML anchors doesn't work).
|
||||
# See: https://github.com/orgs/community/discussions/56787#discussioncomment-6041789
|
||||
# https://github.com/actions/runner/issues/1182
|
||||
# https://stackoverflow.com/a/77549656
|
||||
|
||||
variables:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
GHC_VER: 9.6.3
|
||||
JAVA_VER: 17
|
||||
steps:
|
||||
- name: Dummy job when we have just simple variables
|
||||
if: false
|
||||
run: echo
|
||||
|
||||
# =============================
|
||||
# Create release
|
||||
# =============================
|
||||
|
||||
# Create release, but only if it's triggered by tag push.
|
||||
# On pull requests/commits push, this job will always complete.
|
||||
|
||||
maybe-release:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Clone project
|
||||
if: startsWith(github.ref, 'refs/tags/v')
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Build changelog
|
||||
id: build_changelog
|
||||
uses: mikepenz/release-changelog-builder-action@v4
|
||||
if: startsWith(github.ref, 'refs/tags/v')
|
||||
uses: simplex-chat/release-changelog-builder-action@v5
|
||||
with:
|
||||
configuration: .github/changelog_conf.json
|
||||
failOnError: true
|
||||
|
@ -42,7 +83,8 @@ jobs:
|
|||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Create release
|
||||
uses: softprops/action-gh-release@v1
|
||||
if: startsWith(github.ref, 'refs/tags/v')
|
||||
uses: simplex-chat/action-gh-release@v2
|
||||
with:
|
||||
body: ${{ steps.build_changelog.outputs.changelog }}
|
||||
prerelease: true
|
||||
|
@ -52,178 +94,295 @@ jobs:
|
|||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
build:
|
||||
name: build-${{ matrix.os }}-${{ matrix.ghc }}
|
||||
if: always()
|
||||
needs: prepare-release
|
||||
runs-on: ${{ matrix.os }}
|
||||
# =========================
|
||||
# Linux Build
|
||||
# =========================
|
||||
|
||||
build-linux:
|
||||
name: "ubuntu-${{ matrix.os }} (CLI,Desktop), GHC: ${{ matrix.ghc }}"
|
||||
needs: [maybe-release, variables]
|
||||
runs-on: ubuntu-${{ matrix.os }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- os: ubuntu-20.04
|
||||
- os: 22.04
|
||||
ghc: "8.10.7"
|
||||
cache_path: ~/.cabal/store
|
||||
- os: ubuntu-20.04
|
||||
ghc: "9.6.3"
|
||||
cache_path: ~/.cabal/store
|
||||
asset_name: simplex-chat-ubuntu-20_04-x86-64
|
||||
desktop_asset_name: simplex-desktop-ubuntu-20_04-x86_64.deb
|
||||
- os: ubuntu-22.04
|
||||
ghc: "9.6.3"
|
||||
cache_path: ~/.cabal/store
|
||||
asset_name: simplex-chat-ubuntu-22_04-x86-64
|
||||
should_run: ${{ !(github.ref == 'refs/heads/stable' || startsWith(github.ref, 'refs/tags/v')) }}
|
||||
- os: 22.04
|
||||
ghc: ${{ needs.variables.outputs.GHC_VER }}
|
||||
cli_asset_name: simplex-chat-ubuntu-22_04-x86-64
|
||||
desktop_asset_name: simplex-desktop-ubuntu-22_04-x86_64.deb
|
||||
- os: macos-latest
|
||||
ghc: "9.6.3"
|
||||
cache_path: ~/.cabal/store
|
||||
asset_name: simplex-chat-macos-aarch64
|
||||
desktop_asset_name: simplex-desktop-macos-aarch64.dmg
|
||||
- os: macos-13
|
||||
ghc: "9.6.3"
|
||||
cache_path: ~/.cabal/store
|
||||
asset_name: simplex-chat-macos-x86-64
|
||||
desktop_asset_name: simplex-desktop-macos-x86_64.dmg
|
||||
- os: windows-latest
|
||||
ghc: "9.6.3"
|
||||
cache_path: C:/cabal
|
||||
asset_name: simplex-chat-windows-x86-64
|
||||
desktop_asset_name: simplex-desktop-windows-x86_64.msi
|
||||
should_run: true
|
||||
- os: 24.04
|
||||
ghc: ${{ needs.variables.outputs.GHC_VER }}
|
||||
cli_asset_name: simplex-chat-ubuntu-24_04-x86-64
|
||||
desktop_asset_name: simplex-desktop-ubuntu-24_04-x86_64.deb
|
||||
should_run: true
|
||||
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: Checkout Code
|
||||
if: matrix.should_run == true
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Setup Haskell
|
||||
uses: haskell-actions/setup@v2
|
||||
- name: Setup swap
|
||||
if: matrix.ghc == '8.10.7' && matrix.should_run == true
|
||||
uses: ./.github/actions/swap
|
||||
with:
|
||||
ghc-version: ${{ matrix.ghc }}
|
||||
cabal-version: "3.10.1.0"
|
||||
swap-size-gb: 30
|
||||
|
||||
# Otherwise we run out of disk space with Docker build
|
||||
- name: Free disk space
|
||||
if: matrix.should_run == true
|
||||
shell: bash
|
||||
run: ./scripts/ci/linux_util_free_space.sh
|
||||
|
||||
- name: Restore cached build
|
||||
id: restore_cache
|
||||
uses: actions/cache/restore@v3
|
||||
if: matrix.should_run == true
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
${{ matrix.cache_path }}
|
||||
~/.cabal/store
|
||||
dist-newstyle
|
||||
key: ${{ matrix.os }}-ghc${{ matrix.ghc }}-${{ hashFiles('cabal.project', 'simplex-chat.cabal') }}
|
||||
key: ubuntu-${{ matrix.os }}-ghc${{ matrix.ghc }}-${{ hashFiles('cabal.project', 'simplex-chat.cabal') }}
|
||||
|
||||
# / Unix
|
||||
- name: Set up Docker Buildx
|
||||
if: matrix.should_run == true
|
||||
uses: simplex-chat/docker-setup-buildx-action@v3
|
||||
|
||||
- name: Unix prepare cabal.project.local for Mac
|
||||
if: matrix.os == 'macos-latest'
|
||||
- name: Build and cache Docker image
|
||||
if: matrix.should_run == true
|
||||
uses: simplex-chat/docker-build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
load: true
|
||||
file: Dockerfile.build
|
||||
tags: build/${{ matrix.os }}:latest
|
||||
build-args: |
|
||||
TAG=${{ matrix.os }}
|
||||
GHC=${{ matrix.ghc }}
|
||||
|
||||
# Docker needs these flags for AppImage build:
|
||||
# --device /dev/fuse
|
||||
# --cap-add SYS_ADMIN
|
||||
# --security-opt apparmor:unconfined
|
||||
- name: Start container
|
||||
if: matrix.should_run == true
|
||||
shell: bash
|
||||
run: |
|
||||
echo "ignore-project: False" >> cabal.project.local
|
||||
echo "package simplexmq" >> cabal.project.local
|
||||
echo " extra-include-dirs: /opt/homebrew/opt/openssl@3.0/include" >> cabal.project.local
|
||||
echo " extra-lib-dirs: /opt/homebrew/opt/openssl@3.0/lib" >> cabal.project.local
|
||||
echo "" >> cabal.project.local
|
||||
echo "package direct-sqlcipher" >> cabal.project.local
|
||||
echo " extra-include-dirs: /opt/homebrew/opt/openssl@3.0/include" >> cabal.project.local
|
||||
echo " extra-lib-dirs: /opt/homebrew/opt/openssl@3.0/lib" >> cabal.project.local
|
||||
echo " flags: +openssl" >> cabal.project.local
|
||||
docker run -t -d \
|
||||
--device /dev/fuse \
|
||||
--cap-add SYS_ADMIN \
|
||||
--security-opt apparmor:unconfined \
|
||||
--name builder \
|
||||
-v ~/.cabal:/root/.cabal \
|
||||
-v /home/runner/work/_temp:/home/runner/work/_temp \
|
||||
-v ${{ github.workspace }}:/project \
|
||||
build/${{ matrix.os }}:latest
|
||||
|
||||
- name: Unix prepare cabal.project.local for Mac
|
||||
if: matrix.os == 'macos-13'
|
||||
shell: bash
|
||||
run: |
|
||||
echo "ignore-project: False" >> cabal.project.local
|
||||
echo "package simplexmq" >> cabal.project.local
|
||||
echo " extra-include-dirs: /usr/local/opt/openssl@3.0/include" >> cabal.project.local
|
||||
echo " extra-lib-dirs: /usr/local/opt/openssl@3.0/lib" >> cabal.project.local
|
||||
echo "" >> cabal.project.local
|
||||
echo "package direct-sqlcipher" >> cabal.project.local
|
||||
echo " extra-include-dirs: /usr/local/opt/openssl@3.0/include" >> cabal.project.local
|
||||
echo " extra-lib-dirs: /usr/local/opt/openssl@3.0/lib" >> cabal.project.local
|
||||
echo " flags: +openssl" >> cabal.project.local
|
||||
|
||||
- name: Install AppImage dependencies
|
||||
if: startsWith(github.ref, 'refs/tags/v') && matrix.asset_name && matrix.os == 'ubuntu-20.04'
|
||||
run: sudo apt install -y desktop-file-utils
|
||||
|
||||
- name: Install openssl for Mac
|
||||
if: matrix.os == 'macos-latest' || matrix.os == 'macos-13'
|
||||
run: brew install openssl@3.0
|
||||
|
||||
- name: Unix prepare cabal.project.local for Ubuntu
|
||||
if: matrix.os == 'ubuntu-20.04' || matrix.os == 'ubuntu-22.04'
|
||||
- name: Prepare cabal.project.local
|
||||
if: matrix.should_run == true
|
||||
shell: bash
|
||||
run: |
|
||||
echo "ignore-project: False" >> cabal.project.local
|
||||
echo "package direct-sqlcipher" >> cabal.project.local
|
||||
echo " flags: +openssl" >> cabal.project.local
|
||||
|
||||
- name: Unix build CLI
|
||||
id: unix_cli_build
|
||||
if: matrix.os != 'windows-latest'
|
||||
# chmod/git commands are used to workaround permission issues when cache is restored
|
||||
- name: Build CLI
|
||||
if: matrix.should_run == true
|
||||
shell: docker exec -t builder sh -eu {0}
|
||||
run: |
|
||||
chmod -R 777 dist-newstyle ~/.cabal && git config --global --add safe.directory '*'
|
||||
cabal clean
|
||||
cabal update
|
||||
cabal build -j --enable-tests
|
||||
mkdir -p /out
|
||||
for i in simplex-chat simplex-chat-test; do
|
||||
bin=$(find /project/dist-newstyle -name "$i" -type f -executable)
|
||||
chmod +x "$bin"
|
||||
mv "$bin" /out/
|
||||
done
|
||||
strip /out/simplex-chat
|
||||
|
||||
- name: Copy tests from container
|
||||
if: matrix.should_run == true
|
||||
shell: bash
|
||||
run: |
|
||||
cabal build --enable-tests
|
||||
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
|
||||
docker cp builder:/out/simplex-chat-test .
|
||||
|
||||
- name: Unix upload CLI binary to release
|
||||
if: startsWith(github.ref, 'refs/tags/v') && matrix.asset_name && 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.asset_name && 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') && matrix.asset_name
|
||||
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.asset_name && (matrix.os == 'ubuntu-20.04' || matrix.os == 'ubuntu-22.04')
|
||||
- name: Copy CLI from container and prepare it
|
||||
id: linux_cli_prepare
|
||||
if: startsWith(github.ref, 'refs/tags/v') && matrix.should_run == true
|
||||
shell: bash
|
||||
run: |
|
||||
docker cp builder:/out/simplex-chat ./${{ matrix.cli_asset_name }}
|
||||
path="${{ github.workspace }}/${{ matrix.cli_asset_name }}"
|
||||
echo "bin_path=$path" >> $GITHUB_OUTPUT
|
||||
echo "bin_hash=$(echo SHA2-256\(${{ matrix.cli_asset_name }}\)= $(openssl sha256 $path | cut -d' ' -f 2))" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Upload CLI
|
||||
if: startsWith(github.ref, 'refs/tags/v') && matrix.should_run == true
|
||||
uses: ./.github/actions/prepare-release
|
||||
with:
|
||||
bin_path: ${{ steps.linux_cli_prepare.outputs.bin_path }}
|
||||
bin_name: ${{ matrix.cli_asset_name }}
|
||||
bin_hash: ${{ steps.linux_cli_prepare.outputs.bin_hash }}
|
||||
github_ref: ${{ github.ref }}
|
||||
github_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Build Desktop
|
||||
if: startsWith(github.ref, 'refs/tags/v') && matrix.should_run == true
|
||||
shell: docker exec -t builder sh -eu {0}
|
||||
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.asset_name && matrix.os == 'ubuntu-20.04'
|
||||
- name: Prepare Desktop
|
||||
id: linux_desktop_build
|
||||
if: startsWith(github.ref, 'refs/tags/v') && matrix.should_run == true
|
||||
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
|
||||
path=$(echo ${{ github.workspace }}/apps/multiplatform/release/main/deb/simplex_*_amd64.deb )
|
||||
echo "package_path=$path" >> $GITHUB_OUTPUT
|
||||
echo "package_hash=$(echo SHA2-256\(${{ matrix.desktop_asset_name }}\)= $(openssl sha256 $path | cut -d' ' -f 2))" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Mac build desktop
|
||||
- name: Upload Desktop
|
||||
uses: ./.github/actions/prepare-release
|
||||
if: startsWith(github.ref, 'refs/tags/v') && matrix.should_run == true
|
||||
with:
|
||||
bin_path: ${{ steps.linux_desktop_build.outputs.package_path }}
|
||||
bin_name: ${{ matrix.desktop_asset_name }}
|
||||
bin_hash: ${{ steps.linux_desktop_build.outputs.package_hash }}
|
||||
github_ref: ${{ github.ref }}
|
||||
github_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Build AppImage
|
||||
if: startsWith(github.ref, 'refs/tags/v') && matrix.os == '22.04' && matrix.should_run == true
|
||||
shell: docker exec -t builder sh -eu {0}
|
||||
run: |
|
||||
scripts/desktop/make-appimage-linux.sh
|
||||
|
||||
- name: Prepare AppImage
|
||||
id: linux_appimage_build
|
||||
if: startsWith(github.ref, 'refs/tags/v') && matrix.os == '22.04' && matrix.should_run == true
|
||||
shell: bash
|
||||
run: |
|
||||
path=$(echo ${{ github.workspace }}/apps/multiplatform/release/main/*imple*.AppImage)
|
||||
echo "appimage_path=$path" >> $GITHUB_OUTPUT
|
||||
echo "appimage_hash=$(echo SHA2-256\(simplex-desktop-x86_64.AppImage\)= $(openssl sha256 $path | cut -d' ' -f 2))" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Upload AppImage
|
||||
if: startsWith(github.ref, 'refs/tags/v') && matrix.os == '22.04' && matrix.should_run == true
|
||||
uses: ./.github/actions/prepare-release
|
||||
with:
|
||||
bin_path: ${{ steps.linux_appimage_build.outputs.appimage_path }}
|
||||
bin_name: "simplex-desktop-x86_64.AppImage"
|
||||
bin_hash: ${{ steps.linux_appimage_build.outputs.appimage_hash }}
|
||||
github_ref: ${{ github.ref }}
|
||||
github_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Fix permissions for cache
|
||||
if: matrix.should_run == true
|
||||
shell: bash
|
||||
run: |
|
||||
sudo chmod -R 777 dist-newstyle ~/.cabal
|
||||
sudo chown -R $(id -u):$(id -g) dist-newstyle ~/.cabal
|
||||
|
||||
- name: Run tests
|
||||
if: matrix.should_run == true
|
||||
timeout-minutes: 120
|
||||
shell: bash
|
||||
run: |
|
||||
i=1
|
||||
attempts=1
|
||||
${{ (github.ref == 'refs/heads/stable' || startsWith(github.ref, 'refs/tags/v')) }} && attempts=3
|
||||
while [ "$i" -le "$attempts" ]; do
|
||||
if ./simplex-chat-test; then
|
||||
break
|
||||
else
|
||||
echo "Attempt $i failed, retrying..."
|
||||
i=$((i + 1))
|
||||
sleep 1
|
||||
fi
|
||||
done
|
||||
if [ "$i" -gt "$attempts" ]; then
|
||||
echo "All "$attempts" attempts failed."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# =========================
|
||||
# MacOS Build
|
||||
# =========================
|
||||
|
||||
build-macos:
|
||||
name: "${{ matrix.os }} (CLI,Desktop), GHC: ${{ matrix.ghc }}"
|
||||
needs: [maybe-release, variables]
|
||||
runs-on: ${{ matrix.os }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- os: macos-latest
|
||||
ghc: ${{ needs.variables.outputs.GHC_VER }}
|
||||
cli_asset_name: simplex-chat-macos-aarch64
|
||||
desktop_asset_name: simplex-desktop-macos-aarch64.dmg
|
||||
openssl_dir: "/opt/homebrew/opt"
|
||||
- os: macos-13
|
||||
ghc: ${{ needs.variables.outputs.GHC_VER }}
|
||||
cli_asset_name: simplex-chat-macos-x86-64
|
||||
desktop_asset_name: simplex-desktop-macos-x86_64.dmg
|
||||
openssl_dir: "/usr/local/opt"
|
||||
steps:
|
||||
- name: Checkout Code
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Prepare build
|
||||
uses: ./.github/actions/prepare-build
|
||||
with:
|
||||
java_ver: ${{ needs.variables.outputs.JAVA_VER }}
|
||||
ghc_ver: ${{ matrix.ghc }}
|
||||
os: ${{ matrix.os }}
|
||||
github_ref: ${{ github.ref }}
|
||||
|
||||
- name: Install OpenSSL
|
||||
run: brew install openssl@3.0
|
||||
|
||||
- name: Prepare cabal.project.local
|
||||
shell: bash
|
||||
run: |
|
||||
echo "ignore-project: False" >> cabal.project.local
|
||||
echo "package simplexmq" >> cabal.project.local
|
||||
echo " extra-include-dirs: ${{ matrix.opnessl_dir }}/openssl@3.0/include" >> cabal.project.local
|
||||
echo " extra-lib-dirs: ${{ matrix.openssl_dir}}/openssl@3.0/lib" >> cabal.project.local
|
||||
echo "" >> cabal.project.local
|
||||
echo "package direct-sqlcipher" >> cabal.project.local
|
||||
echo " extra-include-dirs: ${{ matrix.openssl_dir }}/openssl@3.0/include" >> cabal.project.local
|
||||
echo " extra-lib-dirs: ${{ matrix.openssl_dir }}/openssl@3.0/lib" >> cabal.project.local
|
||||
echo " flags: +openssl" >> cabal.project.local
|
||||
|
||||
- name: Build CLI
|
||||
id: mac_cli_build
|
||||
shell: bash
|
||||
run: |
|
||||
cabal build -j --enable-tests
|
||||
path=$(cabal list-bin simplex-chat)
|
||||
echo "bin_path=$path" >> $GITHUB_OUTPUT
|
||||
echo "bin_hash=$(echo SHA2-256\(${{ matrix.cli_asset_name }}\)= $(openssl sha256 $path | cut -d' ' -f 2))" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Upload CLI
|
||||
if: startsWith(github.ref, 'refs/tags/v')
|
||||
uses: ./.github/actions/prepare-release
|
||||
with:
|
||||
bin_path: ${{ steps.mac_cli_build.outputs.bin_path }}
|
||||
bin_name: ${{ matrix.cli_asset_name }}
|
||||
bin_hash: ${{ steps.mac_cli_build.outputs.bin_hash }}
|
||||
github_ref: ${{ github.ref }}
|
||||
github_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Build Desktop
|
||||
id: mac_desktop_build
|
||||
if: startsWith(github.ref, 'refs/tags/v') && (matrix.os == 'macos-latest' || matrix.os == 'macos-13')
|
||||
if: startsWith(github.ref, 'refs/tags/v')
|
||||
shell: bash
|
||||
env:
|
||||
APPLE_SIMPLEX_SIGNING_KEYCHAIN: ${{ secrets.APPLE_SIMPLEX_SIGNING_KEYCHAIN }}
|
||||
|
@ -233,88 +392,77 @@ jobs:
|
|||
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
|
||||
echo "package_hash=$(echo SHA2-256\(${{ matrix.desktop_asset_name }}\)= $(openssl sha256 $path | cut -d' ' -f 2))" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Linux upload desktop package to release
|
||||
if: startsWith(github.ref, 'refs/tags/v') && matrix.asset_name && (matrix.os == 'ubuntu-20.04' || matrix.os == 'ubuntu-22.04')
|
||||
uses: svenstaro/upload-release-action@v2
|
||||
- name: Upload Desktop
|
||||
if: startsWith(github.ref, 'refs/tags/v')
|
||||
uses: ./.github/actions/prepare-release
|
||||
with:
|
||||
repo_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
file: ${{ steps.linux_desktop_build.outputs.package_path }}
|
||||
asset_name: ${{ matrix.desktop_asset_name }}
|
||||
tag: ${{ github.ref }}
|
||||
bin_path: ${{ steps.mac_desktop_build.outputs.package_path }}
|
||||
bin_name: ${{ matrix.desktop_asset_name }}
|
||||
bin_hash: ${{ steps.mac_desktop_build.outputs.package_hash }}
|
||||
github_ref: ${{ github.ref }}
|
||||
github_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Linux update desktop package hash
|
||||
if: startsWith(github.ref, 'refs/tags/v') && matrix.asset_name && (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.asset_name && 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.asset_name && 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' || matrix.os == 'macos-13')
|
||||
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' || matrix.os == 'macos-13')
|
||||
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: Cache unix build
|
||||
uses: actions/cache/save@v3
|
||||
if: matrix.os != 'windows-latest'
|
||||
with:
|
||||
path: |
|
||||
${{ matrix.cache_path }}
|
||||
dist-newstyle
|
||||
key: ${{ steps.restore_cache.outputs.cache-primary-key }}
|
||||
|
||||
- name: Unix test
|
||||
if: matrix.os != 'windows-latest'
|
||||
timeout-minutes: 40
|
||||
- name: Run tests
|
||||
timeout-minutes: 120
|
||||
shell: bash
|
||||
run: cabal test --test-show-details=direct
|
||||
run: |
|
||||
i=1
|
||||
attempts=1
|
||||
${{ (github.ref == 'refs/heads/stable' || startsWith(github.ref, 'refs/tags/v')) }} && attempts=3
|
||||
while [ "$i" -le "$attempts" ]; do
|
||||
if cabal test --test-show-details=direct; then
|
||||
break
|
||||
else
|
||||
echo "Attempt $i failed, retrying..."
|
||||
i=$((i + 1))
|
||||
sleep 1
|
||||
fi
|
||||
done
|
||||
if [ "$i" -gt "$attempts" ]; then
|
||||
echo "All "$attempts" attempts failed."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Unix /
|
||||
# =========================
|
||||
# Windows Build
|
||||
# =========================
|
||||
|
||||
# / Windows
|
||||
# rm -rf dist-newstyle/src/direct-sq* is here because of the bug in cabal's dependency which prevents second build from finishing
|
||||
build-windows:
|
||||
name: "${{ matrix.os }} (CLI,Desktop), GHC: ${{ matrix.ghc }}"
|
||||
needs: [maybe-release, variables]
|
||||
runs-on: ${{ matrix.os }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- os: windows-latest
|
||||
ghc: ${{ needs.variables.outputs.GHC_VER }}
|
||||
cli_asset_name: simplex-chat-windows-x86-64
|
||||
desktop_asset_name: simplex-desktop-windows-x86_64.msi
|
||||
steps:
|
||||
- name: Checkout Code
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Prepare build
|
||||
uses: ./.github/actions/prepare-build
|
||||
with:
|
||||
java_ver: ${{ needs.variables.outputs.JAVA_VER }}
|
||||
ghc_ver: ${{ matrix.ghc }}
|
||||
os: ${{ matrix.os }}
|
||||
cache_path: "C:/cabal"
|
||||
github_ref: ${{ github.ref }}
|
||||
|
||||
- name: Configure pagefile (Windows)
|
||||
uses: simplex-chat/configure-pagefile-action@v1.4
|
||||
with:
|
||||
minimum-size: 16GB
|
||||
maximum-size: 16GB
|
||||
disk-root: "C:"
|
||||
|
||||
- name: 'Setup MSYS2'
|
||||
if: matrix.os == 'windows-latest'
|
||||
uses: msys2/setup-msys2@v2
|
||||
uses: simplex-chat/setup-msys2@v2
|
||||
with:
|
||||
msystem: ucrt64
|
||||
update: true
|
||||
|
@ -326,10 +474,9 @@ jobs:
|
|||
toolchain:p
|
||||
cmake:p
|
||||
|
||||
|
||||
- name: Windows build
|
||||
id: windows_build
|
||||
if: matrix.os == 'windows-latest'
|
||||
# rm -rf dist-newstyle/src/direct-sq* is here because of the bug in cabal's dependency which prevents second build from finishing
|
||||
- name: Build CLI
|
||||
id: windows_cli_build
|
||||
shell: msys2 {0}
|
||||
run: |
|
||||
export PATH=$PATH:/c/ghcup/bin:$(echo /c/tools/ghc-*/bin || echo)
|
||||
|
@ -344,70 +491,42 @@ jobs:
|
|||
|
||||
rm -rf dist-newstyle/src/direct-sq*
|
||||
sed -i "s/, unix /--, unix /" simplex-chat.cabal
|
||||
cabal build --enable-tests
|
||||
cabal build -j --enable-tests
|
||||
rm -rf dist-newstyle/src/direct-sq*
|
||||
path=$(cabal list-bin simplex-chat | tail -n 1)
|
||||
echo "bin_path=$path" >> $GITHUB_OUTPUT
|
||||
echo "bin_hash=$(echo SHA2-512\(${{ matrix.asset_name }}\)= $(openssl sha512 $path | cut -d' ' -f 2))" >> $GITHUB_OUTPUT
|
||||
echo "bin_hash=$(echo SHA2-256\(${{ matrix.cli_asset_name }}\)= $(openssl sha256 $path | cut -d' ' -f 2))" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Windows upload CLI binary to release
|
||||
if: startsWith(github.ref, 'refs/tags/v') && matrix.os == 'windows-latest'
|
||||
uses: svenstaro/upload-release-action@v2
|
||||
- name: Upload CLI
|
||||
if: startsWith(github.ref, 'refs/tags/v')
|
||||
uses: ./.github/actions/prepare-release
|
||||
with:
|
||||
repo_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
file: ${{ steps.windows_build.outputs.bin_path }}
|
||||
asset_name: ${{ matrix.asset_name }}
|
||||
tag: ${{ github.ref }}
|
||||
bin_path: ${{ steps.windows_cli_build.outputs.bin_path }}
|
||||
bin_name: ${{ matrix.cli_asset_name }}
|
||||
bin_hash: ${{ steps.windows_cli_build.outputs.bin_hash }}
|
||||
github_ref: ${{ github.ref }}
|
||||
github_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- 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
|
||||
- name: Build Desktop
|
||||
id: windows_desktop_build
|
||||
if: startsWith(github.ref, 'refs/tags/v') && matrix.os == 'windows-latest'
|
||||
if: startsWith(github.ref, 'refs/tags/v')
|
||||
shell: msys2 {0}
|
||||
run: |
|
||||
export PATH=$PATH:/c/ghcup/bin:$(echo /c/tools/ghc-*/bin || echo)
|
||||
scripts/desktop/build-lib-windows.sh
|
||||
cd apps/multiplatform
|
||||
./gradlew packageMsi
|
||||
rm -rf dist-newstyle/src/direct-sq*
|
||||
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
|
||||
echo "package_hash=$(echo SHA2-256\(${{ matrix.desktop_asset_name }}\)= $(openssl sha256 $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
|
||||
- name: Upload Desktop
|
||||
if: startsWith(github.ref, 'refs/tags/v')
|
||||
uses: ./.github/actions/prepare-release
|
||||
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 }}
|
||||
|
||||
- name: Cache windows build
|
||||
uses: actions/cache/save@v3
|
||||
if: matrix.os == 'windows-latest'
|
||||
with:
|
||||
path: |
|
||||
${{ matrix.cache_path }}
|
||||
dist-newstyle
|
||||
key: ${{ steps.restore_cache.outputs.cache-primary-key }}
|
||||
|
||||
# Windows /
|
||||
bin_path: ${{ steps.windows_desktop_build.outputs.package_path }}
|
||||
bin_name: ${{ matrix.desktop_asset_name }}
|
||||
bin_hash: ${{ steps.windows_desktop_build.outputs.package_hash }}
|
||||
github_ref: ${{ github.ref }}
|
||||
github_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
|
45
.github/workflows/reproduce-schedule.yml
vendored
Normal file
45
.github/workflows/reproduce-schedule.yml
vendored
Normal file
|
@ -0,0 +1,45 @@
|
|||
name: Reproduce latest release
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
schedule:
|
||||
- cron: '0 2 * * *' # every day at 02:00 night
|
||||
|
||||
jobs:
|
||||
reproduce:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Get latest release
|
||||
shell: bash
|
||||
run: |
|
||||
curl --proto '=https' \
|
||||
--tlsv1.2 \
|
||||
-sSf -L \
|
||||
'https://api.github.com/repos/simplex-chat/simplex-chat/releases/latest' \
|
||||
2>/dev/null | \
|
||||
grep -i "tag_name" | \
|
||||
awk -F \" '{print "TAG="$4}' >> $GITHUB_ENV
|
||||
|
||||
- name: Execute reproduce script
|
||||
run: |
|
||||
${GITHUB_WORKSPACE}/scripts/reproduce-builds.sh "$TAG"
|
||||
|
||||
- name: Check if build has been reproduced
|
||||
env:
|
||||
url: ${{ secrets.STATUS_SIMPLEX_WEBHOOK_URL }}
|
||||
user: ${{ secrets.STATUS_SIMPLEX_WEBHOOK_USER }}
|
||||
pass: ${{ secrets.STATUS_SIMPLEX_WEBHOOK_PASS }}
|
||||
run: |
|
||||
if [ -f "${GITHUB_WORKSPACE}/$TAG/_sha256sums" ]; then
|
||||
exit 0
|
||||
else
|
||||
curl --proto '=https' --tlsv1.2 -sSf \
|
||||
-u "${user}:${pass}" \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"title": "👾 GitHub: Runner", "description": "⛔️ '"$TAG"' did not reproduce."}' \
|
||||
"$url"
|
||||
exit 1
|
||||
fi
|
2
.github/workflows/web.yml
vendored
2
.github/workflows/web.yml
vendored
|
@ -33,7 +33,7 @@ jobs:
|
|||
./website/web.sh
|
||||
|
||||
- name: Deploy
|
||||
uses: peaceiris/actions-gh-pages@v3
|
||||
uses: simplex-chat/actions-gh-pages@v3
|
||||
with:
|
||||
publish_dir: ./website/_site
|
||||
github_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
|
|
@ -29,7 +29,7 @@ RUN cp ./scripts/cabal.project.local.linux ./cabal.project.local
|
|||
|
||||
# Compile simplex-chat
|
||||
RUN cabal update
|
||||
RUN cabal build exe:simplex-chat --constraint 'simplexmq +client_library'
|
||||
RUN cabal build exe:simplex-chat --constraint 'simplexmq +client_library' --constraint 'simplex-chat +client_library'
|
||||
|
||||
# Strip the binary from debug symbols to reduce size
|
||||
RUN bin=$(find /project/dist-newstyle -name "simplex-chat" -type f -executable) && \
|
||||
|
|
92
Dockerfile.build
Normal file
92
Dockerfile.build
Normal file
|
@ -0,0 +1,92 @@
|
|||
# syntax=docker/dockerfile:1.7.0-labs
|
||||
ARG TAG=24.04
|
||||
FROM ubuntu:${TAG} AS build
|
||||
|
||||
### Build stage
|
||||
|
||||
ARG GHC=9.6.3
|
||||
ARG CABAL=3.10.1.0
|
||||
ARG JAVA=17
|
||||
|
||||
ENV TZ=Etc/UTC \
|
||||
DEBIAN_FRONTEND=noninteractive
|
||||
|
||||
# Install curl, git and and simplex-chat dependencies
|
||||
RUN apt-get update && \
|
||||
apt-get install -y curl \
|
||||
libpq-dev \
|
||||
git \
|
||||
sqlite3 \
|
||||
libsqlite3-dev \
|
||||
build-essential \
|
||||
libgmp3-dev \
|
||||
zlib1g-dev \
|
||||
llvm \
|
||||
cmake \
|
||||
llvm-dev \
|
||||
libnuma-dev \
|
||||
libssl-dev \
|
||||
desktop-file-utils \
|
||||
patchelf \
|
||||
ca-certificates \
|
||||
zip \
|
||||
wget \
|
||||
fuse3 \
|
||||
file \
|
||||
appstream \
|
||||
gpg \
|
||||
unzip &&\
|
||||
ln -s /bin/fusermount /bin/fusermount3 || :
|
||||
|
||||
# Install Java Coretto
|
||||
# Required, because official Java in Ubuntu
|
||||
# depends on libjpeg.so.8 and liblcms2.so.2 which are NOT copied into final
|
||||
# /usr/lib/runtime/lib directory and I do not have time to figure out gradle.kotlin
|
||||
# to fix this :(
|
||||
RUN curl --proto '=https' --tlsv1.2 -sSf 'https://apt.corretto.aws/corretto.key' | gpg --dearmor -o /usr/share/keyrings/corretto-keyring.gpg &&\
|
||||
echo "deb [signed-by=/usr/share/keyrings/corretto-keyring.gpg] https://apt.corretto.aws stable main" > /etc/apt/sources.list.d/corretto.list &&\
|
||||
apt update &&\
|
||||
apt install -y java-${JAVA}-amazon-corretto-jdk
|
||||
|
||||
# Specify bootstrap Haskell versions
|
||||
ENV BOOTSTRAP_HASKELL_GHC_VERSION=${GHC}
|
||||
ENV BOOTSTRAP_HASKELL_CABAL_VERSION=${CABAL}
|
||||
|
||||
# Do not install Stack
|
||||
ENV BOOTSTRAP_HASKELL_INSTALL_NO_STACK=true
|
||||
ENV BOOTSTRAP_HASKELL_INSTALL_NO_STACK_HOOK=true
|
||||
|
||||
# Install ghcup
|
||||
RUN curl --proto '=https' --tlsv1.2 -sSf https://get-ghcup.haskell.org | BOOTSTRAP_HASKELL_NONINTERACTIVE=1 sh
|
||||
|
||||
# Adjust PATH
|
||||
ENV PATH="/root/.cabal/bin:/root/.ghcup/bin:$PATH"
|
||||
|
||||
# Set both as default
|
||||
RUN ghcup set ghc "${GHC}" && \
|
||||
ghcup set cabal "${CABAL}"
|
||||
|
||||
#=====================
|
||||
# Install Android SDK
|
||||
#=====================
|
||||
ARG SDK_VERSION=13114758
|
||||
|
||||
ENV SDK_VERSION=$SDK_VERSION \
|
||||
ANDROID_HOME=/root
|
||||
|
||||
RUN curl -L -o tools.zip "https://dl.google.com/android/repository/commandlinetools-linux-${SDK_VERSION}_latest.zip" && \
|
||||
unzip tools.zip && rm tools.zip && \
|
||||
mv cmdline-tools tools && mkdir "$ANDROID_HOME/cmdline-tools" && mv tools "$ANDROID_HOME/cmdline-tools/" && \
|
||||
ln -s "$ANDROID_HOME/cmdline-tools/tools" "$ANDROID_HOME/cmdline-tools/latest"
|
||||
|
||||
ENV PATH="$PATH:$ANDROID_HOME/cmdline-tools/latest/bin:$ANDROID_HOME/cmdline-tools/tools/bin"
|
||||
|
||||
# https://askubuntu.com/questions/885658/android-sdk-repositories-cfg-could-not-be-loaded
|
||||
RUN mkdir -p ~/.android ~/.gradle && \
|
||||
touch ~/.android/repositories.cfg && \
|
||||
echo 'org.gradle.console=plain' > ~/.gradle/gradle.properties &&\
|
||||
yes | sdkmanager --licenses >/dev/null
|
||||
|
||||
ENV PATH=$PATH:$ANDROID_HOME/platform-tools:$ANDROID_HOME/build-tools
|
||||
|
||||
WORKDIR /project
|
31
PRIVACY.md
31
PRIVACY.md
|
@ -123,6 +123,16 @@ This section applies only to the experimental group directory operated by Simple
|
|||
|
||||
[SimpleX Directory](/docs/DIRECTORY.md) stores: your search requests, the messages and the members profiles in the registered groups. You can connect to SimpleX Directory 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).
|
||||
|
||||
#### Public groups and content channels
|
||||
|
||||
You may participate in a public group and receive content from a public channel (Group). In case you send messages or comments to the Group, you grant a license:
|
||||
- to all recipients:
|
||||
- to share your messages with the new Group members and outside of the group, e.g. via quoting (replying), forwarding and copy-pasting your message. When your message is deleted or marked as deleted, the copies of your message will not be deleted.
|
||||
- to retain a copy of your messages according to the Group settings (e.g., the Group may allow irreversible message deletion from the recipient devices for a limited period of time, or it may only allow to edit and mark messages as deleted on recipient devices). Deleting message from the recipient devices or marking message as deleted revokes the license to share the message.
|
||||
- to Group owners: to share your messages with the new Group members as history of the Group. Currently, the Group history shared with the new members is limited to 100 messages.
|
||||
|
||||
Group owners may use chat relays or automated bots (Chat Relays) to re-broadcast member messages to all members, for efficiency. The Chat Relays may be operated by the group owners, by preset operators or by 3rd parties. The Chat Relays have access to and will retain messages in line with Group settings, for technical functioning of the Group. Neither you nor group owners grant any content license to Chat Relay operators.
|
||||
|
||||
#### User Support
|
||||
|
||||
The app includes support contact operated by SimpleX Chat Ltd. If you contact support, any personal data you share 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, and avoid sharing any personal information.
|
||||
|
@ -131,9 +141,9 @@ The app includes support contact operated by SimpleX Chat Ltd. If you contact su
|
|||
|
||||
Preset server operators will not share the information on their servers with each other, other than aggregate usage statistics.
|
||||
|
||||
Preset server operators will not provide general access to their servers or the data on their servers to each other.
|
||||
Preset server operators must not provide general access to their servers or the data on their servers to each other.
|
||||
|
||||
Preset server operators will provide non-administrative access to control port of preset servers to SimpleX Chat Ltd, for the purposes of removing identified illegal content. This control port access only allows deleting known links and files, and access to aggregate statistics, but does NOT allow enumerating any information on the servers.
|
||||
Preset server operators will provide non-administrative access to control port of preset servers to SimpleX Chat Ltd, for the purposes of removing illegal content identified in publicly accessible resources (contact and group addresses, and downloadable files). This control port access only allows deleting known links and files, and accessing aggregate server-wide statistics, but does NOT allow enumerating any information on the servers or accessing statistics related to specific users.
|
||||
|
||||
### Information Preset Server Operators May Share
|
||||
|
||||
|
@ -148,7 +158,7 @@ The cases when the preset server operators may share the data temporarily stored
|
|||
- To detect, prevent, or otherwise address fraud, security, or technical issues.
|
||||
- To protect against harm to the rights, property, or safety of software users, operators of preset servers, or the public as required or permitted by law.
|
||||
|
||||
At the time of updating this document, the preset server operators have never provided or have been requested the access to the preset relay servers or any information from the servers by any third parties. If the preset server operators are ever requested to provide such access or information, they will follow the due legal process to limit any information shared with the third parties to the minimally required by law.
|
||||
By the time of updating this document, the preset server operators were not served with any enforceable requests and did not provide any information from the servers to any third parties. If the preset server operators are ever requested to provide such access or information, they will follow the due legal process to limit any information shared with the third parties to the minimally required by law.
|
||||
|
||||
Preset server operators will publish information they are legally allowed to share about such requests in the [Transparency reports](./docs/TRANSPARENCY.md).
|
||||
|
||||
|
@ -190,7 +200,18 @@ You accept the Conditions of Use of Software and Infrastructure ("Conditions") b
|
|||
|
||||
**Legal usage**. You agree to use SimpleX Chat Applications only for legal purposes. You will not use (or assist others in using) the Applications in ways that: 1) violate or infringe the rights of Software users, SimpleX Chat Ltd, other preset server operators, or others, including privacy, publicity, intellectual property, or other proprietary rights; 2) involve sending illegal communications, e.g. spam. While server operators cannot access content or identify messages or groups, in some cases the links to the illegal communications can be shared publicly on social media or websites. Preset server operators reserve the right to remove such links from the preset servers and disrupt the conversations that send illegal content via their servers, whether they were reported by the users or discovered by the operators themselves.
|
||||
|
||||
**Damage to SimpleX Chat Ltd and Preset Server Operators**. You must not (or assist others to) access, use, modify, distribute, transfer, or exploit SimpleX Chat Applications in unauthorized manners, or in ways that harm Software users, SimpleX Chat Ltd, other preset server operators, their Infrastructure, or any other systems. For example, you must not 1) access preset operators' Infrastructure or systems without authorization, in any way other than by using the Software; 2) disrupt the integrity or performance of preset operators' Infrastructure; 3) collect information about the users in any manner; or 4) sell, rent, or charge for preset operators' Infrastructure. This does not prohibit you from providing your own Infrastructure to others, whether free or for a fee, as long as you do not violate these Conditions and AGPLv3 license, including the requirement to publish any modifications of the relay server software.
|
||||
**Damage to SimpleX Chat Ltd and Preset Server Operators**. You must not (or assist others to) access, use, modify, distribute, transfer, or exploit SimpleX Chat Applications in unauthorized manners, or in ways that harm Software users, SimpleX Chat Ltd, other preset server operators, their Infrastructure, or any other systems. For example, you must not 1) access preset operators' Infrastructure or systems without authorization, in any way other than by using the Software or by using a 3rd party client applications that satisfies the requirements of the Conditions of use (see the next section); 2) disrupt the integrity or performance of preset operators' Infrastructure; 3) collect information about the users in any manner; or 4) sell, rent, or charge for preset operators' Infrastructure. This does not prohibit you from providing your own Infrastructure to others, whether free or for a fee, as long as you do not violate these Conditions and AGPLv3 license, including the requirement to publish any modifications of the relay server software.
|
||||
|
||||
**3rd party client applications**. You may use a 3rd party application (App) to access preset operators' Infrastructure or systems, provided that this App:
|
||||
- is compatible with the protocol specifications not older than 1 year,
|
||||
- provides user-to-user messaging only or enables automated chat bots sending messages requested by users (in case of bots, it must be made clear to the users that these are automated bots),
|
||||
- implements the same limits, rules and restrictions as Software,
|
||||
- requires that the users accept the same Conditions of use of preset operators' Infrastructure as in Software prior to providing access to this Infrastructure,
|
||||
- displays the notice that it is the App for using SimpleX network,
|
||||
- provides its source code under open-source license accessible to the users via the App interface. In case the App uses the source code of Software, the App's source code must be provided under AGPLv3 license, and in case it is developed without using Software code its source code must be provided under any widely recognized free open-source license,
|
||||
- does NOT use the branding of SimpleX Chat Ltd without the permission,
|
||||
- does NOT pretend to be Software,
|
||||
- complies with these Conditions of use.
|
||||
|
||||
**Keeping your data secure**. SimpleX Chat is the first communication software that aims to be 100% private by design - server software neither has the ability to access your messages, nor it has information about who you communicate with. That means that you are solely responsible for keeping your device, your user profile and any data safe and secure. If you lose your phone or remove the Software from the device, you will not be able to recover the lost data, unless you made a back up. To protect the data you need to make regular backups, as using old backups may disrupt your communication with some of the contacts. SimpleX Chat Ltd and other preset server operators are not responsible for any data loss.
|
||||
|
||||
|
@ -222,4 +243,4 @@ You accept the Conditions of Use of Software and Infrastructure ("Conditions") b
|
|||
|
||||
**Ending these conditions**. You may end these Conditions with SimpleX Chat Ltd and preset server operators at any time by deleting the Applications from your devices and discontinuing use of the Infrastructure of SimpleX Chat Ltd and preset server operators. The provisions related to Licenses, Disclaimers, Limitation of Liability, Resolving dispute, Availability, Changes to the conditions, Enforcing the conditions, and Ending these conditions will survive termination of your relationship with SimpleX Chat Ltd and/or preset server operators.
|
||||
|
||||
Updated November 14, 2024
|
||||
Updated March 3, 2025
|
||||
|
|
30
README.md
30
README.md
|
@ -10,7 +10,7 @@
|
|||
|
||||
# SimpleX - the first messaging platform that has no user identifiers of any kind - 100% private by design!
|
||||
|
||||
[<img src="./images/trail-of-bits.jpg" height="100">](http://simplex.chat/blog/20221108-simplex-chat-v4.2-security-audit-new-website.html) [<img src="./images/privacy-guides.jpg" height="80">](https://www.privacyguides.org/en/real-time-communication/#simplex-chat) [<img src="./images/kuketz-blog.jpg" height="80">](https://www.kuketz-blog.de/simplex-eindruecke-vom-messenger-ohne-identifier/)
|
||||
[<img src="./images/trail-of-bits.jpg" height="80">](http://simplex.chat/blog/20221108-simplex-chat-v4.2-security-audit-new-website.html) [<img src="./images/privacy-guides.jpg" height="64">](https://www.privacyguides.org/en/real-time-communication/#simplex-chat) [<img src="./images/whonix-logo.jpg" height="64">](https://www.whonix.org/wiki/Chat#Recommendation) [<img src="./images/kuketz-blog.jpg" height="64">](https://www.kuketz-blog.de/simplex-eindruecke-vom-messenger-ohne-identifier/)
|
||||
|
||||
## Welcome to SimpleX Chat!
|
||||
|
||||
|
@ -72,7 +72,7 @@ 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.
|
||||
|
||||
You can join an English-speaking users group if you want to ask any questions: [#SimpleX users group](https://simplex.chat/contact#/?v=2-4&smp=smp%3A%2F%2FPQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo%3D%40smp6.simplex.im%2Fos8FftfoV8zjb2T89fUEjJtF7y64p5av%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAQqMgh0fw2lPhjn3PDIEfAKA_E0-gf8Hr8zzhYnDivRs%253D%26srv%3Dbylepyau3ty4czmn77q4fglvperknl4bi2eb2fdy2bh4jxtf32kf73yd.onion&data=%7B%22type%22%3A%22group%22%2C%22groupLinkId%22%3A%22lBPiveK2mjfUH43SN77R0w%3D%3D%22%7D)
|
||||
You can join an English-speaking users group if you want to ask any questions: [#SimpleX users group](https://simplex.chat/contact#/?v=2-7&smp=smp%3A%2F%2Fhpq7_4gGJiilmz5Rf-CswuU5kZGkm_zOIooSw6yALRg%3D%40smp5.simplex.im%2FiBkJE72asZX1NUZaYFIeKRVk6oVjb-iv%23%2F%3Fv%3D1-3%26dh%3DMCowBQYDK2VuAyEAinqu3j74AMjODLoIRR487ZW6ysip_dlpD6Zxk18SPFY%253D%26srv%3Djjbyvoemxysm7qxap7m5d5m35jzv5qq6gnlv7s4rsn7tdwwmuqciwpid.onion&data=%7B%22groupLinkId%22%3A%223wAFGCLygQHR5AwynZOHlQ%3D%3D%22%7D)
|
||||
|
||||
There is also a group [#simplex-devs](https://simplex.chat/contact#/?v=1-4&smp=smp%3A%2F%2FPQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo%3D%40smp6.simplex.im%2FvYCRjIflKNMGYlfTkuHe4B40qSlQ0439%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAHNdcqNbzXZhyMoSBjT2R0-Eb1EPaLyUg3KZjn-kmM1w%253D%26srv%3Dbylepyau3ty4czmn77q4fglvperknl4bi2eb2fdy2bh4jxtf32kf73yd.onion&data=%7B%22type%22%3A%22group%22%2C%22groupLinkId%22%3A%22PD20tcXjw7IpkkMCfR6HLA%3D%3D%22%7D) for developers who build on SimpleX platform:
|
||||
|
||||
|
@ -110,6 +110,15 @@ After you connect, you can [verify connection security code](./blog/20230103-sim
|
|||
|
||||
Read about the app features and settings in the new [User guide](./docs/guide/README.md).
|
||||
|
||||
## Contribute
|
||||
|
||||
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.
|
||||
- contributing to SimpleX Chat knowledge-base.
|
||||
- developing features - please connect to us via chat so we can help you get started.
|
||||
|
||||
## Help translating SimpleX Chat
|
||||
|
||||
Thanks to our users and [Weblate](https://hosted.weblate.org/engage/simplex-chat/), SimpleX Chat apps, website and documents are translated to many other languages.
|
||||
|
@ -141,15 +150,6 @@ Join our translators to help SimpleX grow!
|
|||
|
||||
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!
|
||||
|
||||
## Contribute
|
||||
|
||||
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.
|
||||
- contributing to SimpleX Chat knowledge-base.
|
||||
- developing features - please connect to us via chat so we can help you get started.
|
||||
|
||||
## Please support us with your donations
|
||||
|
||||
Huge thank you to everybody who donated to SimpleX Chat!
|
||||
|
@ -169,6 +169,7 @@ It is possible to donate via:
|
|||
- ETH: 0xD9ee7Db0AD0dc1Dfa7eD53290199ED06beA04692
|
||||
- USDT (Ethereum): 0xD9ee7Db0AD0dc1Dfa7eD53290199ED06beA04692
|
||||
- ZEC: t1fwjQW5gpFhDqXNhxqDWyF9j9WeKvVS5Jg
|
||||
- ZEC shielded: u16rnvkflumf5uw9frngc2lymvmzgdr2mmc9unyu0l44unwfmdcpfm0axujd2w34ct3ye709azxsqge45705lpvvqu264ltzvfay55ygyq
|
||||
- DOGE: D99pV4n9TrPxBPCkQGx4w4SMSa6QjRBxPf
|
||||
- SOL: 7JCf5m3TiHmYKZVr6jCu1KeZVtb9Y1jRMQDU69p5ARnu
|
||||
- please ask if you want to donate any other coins.
|
||||
|
@ -234,6 +235,10 @@ You can use SimpleX with your own servers and still communicate with people usin
|
|||
|
||||
Recent and important updates:
|
||||
|
||||
[Mar 8, 2025. SimpleX Chat v6.3: new user experience and safety in public groups](./blog/20250308-simplex-chat-v6-3-new-user-experience-safety-in-public-groups.md)
|
||||
|
||||
[Jan 14, 2025. SimpleX network: large groups and privacy-preserving content moderation](./blog/20250114-simplex-network-large-groups-privacy-preserving-content-moderation.md)
|
||||
|
||||
[Dec 10, 2024. SimpleX network: preset servers operated by Flux, business chats and more with v6.2 of the apps](./20241210-simplex-network-v6-2-servers-by-flux-business-chats.md)
|
||||
|
||||
[Oct 14, 2024. SimpleX network: security review of protocols design by Trail of Bits, v6.1 released with better calls and user experience.](./blog/20241014-simplex-network-v6-1-security-review-better-calls-user-experience.md)
|
||||
|
@ -305,12 +310,13 @@ What is already implemented:
|
|||
15. Manual messaging queue rotations to move conversation to another SMP relay.
|
||||
16. Sending end-to-end encrypted files using [XFTP protocol](https://simplex.chat/blog/20230301-simplex-file-transfer-protocol.html).
|
||||
17. Local files encryption.
|
||||
18. [Reproducible server builds](./docs/SERVER.md#reproduce-builds).
|
||||
|
||||
We plan to add:
|
||||
|
||||
1. 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).
|
||||
2. Message "mixing" - adding latency to message delivery, to protect against traffic correlation by message time.
|
||||
3. 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.
|
||||
3. Reproducible clients builds – this is a complex problem, but we are aiming to have it in 2025 at least partially.
|
||||
4. Recipients' XFTP relays to reduce traffic and conceal IP addresses from the relays chosen, and potentially controlled, by another party.
|
||||
|
||||
## For developers
|
||||
|
|
|
@ -54,7 +54,7 @@ class AppDelegate: NSObject, UIApplicationDelegate {
|
|||
try await apiVerifyToken(token: token, nonce: nonce, code: verification)
|
||||
m.tokenStatus = .active
|
||||
} catch {
|
||||
if let cr = error as? ChatResponse, case .chatCmdError(_, .errorAgent(.NTF(.AUTH))) = cr {
|
||||
if let cr = error as? ChatError, case .errorAgent(.NTF(.AUTH)) = cr {
|
||||
m.tokenStatus = .expired
|
||||
}
|
||||
logger.error("AppDelegate: didReceiveRemoteNotification: apiVerifyToken or apiIntervalNofication error: \(responseError(error))")
|
||||
|
|
23
apps/ios/Shared/Assets.xcassets/vertical_logo.imageset/Contents.json
vendored
Normal file
23
apps/ios/Shared/Assets.xcassets/vertical_logo.imageset/Contents.json
vendored
Normal file
|
@ -0,0 +1,23 @@
|
|||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "vertical_logo_x1.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"filename" : "vertical_logo_x2.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"filename" : "vertical_logo_x3.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "3x"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
BIN
apps/ios/Shared/Assets.xcassets/vertical_logo.imageset/vertical_logo_x1.png
vendored
Normal file
BIN
apps/ios/Shared/Assets.xcassets/vertical_logo.imageset/vertical_logo_x1.png
vendored
Normal file
Binary file not shown.
After Width: | Height: | Size: 1.2 KiB |
BIN
apps/ios/Shared/Assets.xcassets/vertical_logo.imageset/vertical_logo_x2.png
vendored
Normal file
BIN
apps/ios/Shared/Assets.xcassets/vertical_logo.imageset/vertical_logo_x2.png
vendored
Normal file
Binary file not shown.
After Width: | Height: | Size: 1.6 KiB |
BIN
apps/ios/Shared/Assets.xcassets/vertical_logo.imageset/vertical_logo_x3.png
vendored
Normal file
BIN
apps/ios/Shared/Assets.xcassets/vertical_logo.imageset/vertical_logo_x3.png
vendored
Normal file
Binary file not shown.
After Width: | Height: | Size: 2 KiB |
|
@ -11,12 +11,10 @@ import SimpleXChat
|
|||
|
||||
private enum NoticesSheet: Identifiable {
|
||||
case whatsNew(updatedConditions: Bool)
|
||||
case updatedConditions
|
||||
|
||||
var id: String {
|
||||
switch self {
|
||||
case .whatsNew: return "whatsNew"
|
||||
case .updatedConditions: return "updatedConditions"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -76,7 +74,7 @@ struct ContentView: View {
|
|||
}
|
||||
}
|
||||
|
||||
@ViewBuilder func allViews() -> some View {
|
||||
func allViews() -> some View {
|
||||
ZStack {
|
||||
let showCallArea = chatModel.activeCall != nil && chatModel.activeCall?.callState != .waitCapabilities && chatModel.activeCall?.callState != .invitationAccepted
|
||||
// contentView() has to be in a single branch, so that enabling authentication doesn't trigger re-rendering and close settings.
|
||||
|
@ -211,7 +209,7 @@ struct ContentView: View {
|
|||
}
|
||||
}
|
||||
|
||||
@ViewBuilder private func activeCallInteractiveArea(_ call: Call) -> some View {
|
||||
private func activeCallInteractiveArea(_ call: Call) -> some View {
|
||||
HStack {
|
||||
Text(call.contact.displayName).font(.body).foregroundColor(.white)
|
||||
Spacer()
|
||||
|
@ -278,18 +276,18 @@ struct ContentView: View {
|
|||
let showWhatsNew = shouldShowWhatsNew()
|
||||
let showUpdatedConditions = chatModel.conditions.conditionsAction?.showNotice ?? false
|
||||
noticesShown = showWhatsNew || showUpdatedConditions
|
||||
if showWhatsNew {
|
||||
if showWhatsNew || showUpdatedConditions {
|
||||
noticesSheetItem = .whatsNew(updatedConditions: showUpdatedConditions)
|
||||
} else if showUpdatedConditions {
|
||||
noticesSheetItem = .updatedConditions
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
prefShowLANotice = true
|
||||
connectViaUrl()
|
||||
showReRegisterTokenAlert()
|
||||
}
|
||||
.onChange(of: chatModel.appOpenUrl) { _ in connectViaUrl() }
|
||||
.onChange(of: chatModel.reRegisterTknStatus) { _ in showReRegisterTokenAlert() }
|
||||
.sheet(item: $noticesSheetItem) { item in
|
||||
switch item {
|
||||
case let .whatsNew(updatedConditions):
|
||||
|
@ -298,13 +296,6 @@ struct ContentView: View {
|
|||
.if(updatedConditions) { v in
|
||||
v.task { await setConditionsNotified_() }
|
||||
}
|
||||
case .updatedConditions:
|
||||
UsageConditionsView(
|
||||
currUserServers: Binding.constant([]),
|
||||
userServers: Binding.constant([])
|
||||
)
|
||||
.modifier(ThemedBackground(grouped: true))
|
||||
.task { await setConditionsNotified_() }
|
||||
}
|
||||
}
|
||||
if chatModel.setDeliveryReceipts {
|
||||
|
@ -315,6 +306,12 @@ struct ContentView: View {
|
|||
.onContinueUserActivity("INStartCallIntent", perform: processUserActivity)
|
||||
.onContinueUserActivity("INStartAudioCallIntent", perform: processUserActivity)
|
||||
.onContinueUserActivity("INStartVideoCallIntent", perform: processUserActivity)
|
||||
.onContinueUserActivity(NSUserActivityTypeBrowsingWeb) { userActivity in
|
||||
if let url = userActivity.webpageURL {
|
||||
logger.debug("onContinueUserActivity.NSUserActivityTypeBrowsingWeb: \(url)")
|
||||
chatModel.appOpenUrl = url
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func setConditionsNotified_() async {
|
||||
|
@ -446,12 +443,12 @@ struct ContentView: View {
|
|||
}
|
||||
|
||||
func connectViaUrl() {
|
||||
dismissAllSheets() {
|
||||
let m = ChatModel.shared
|
||||
if let url = m.appOpenUrl {
|
||||
m.appOpenUrl = nil
|
||||
let m = ChatModel.shared
|
||||
if let url = m.appOpenUrl {
|
||||
m.appOpenUrl = nil
|
||||
dismissAllSheets() {
|
||||
var path = url.path
|
||||
if (path == "/contact" || path == "/invitation") {
|
||||
if (path == "/contact" || path == "/invitation" || path == "/a" || path == "/c" || path == "/g" || path == "/i") {
|
||||
path.removeFirst()
|
||||
let link = url.absoluteString.replacingOccurrences(of: "///\(path)", with: "/\(path)")
|
||||
planAndConnect(
|
||||
|
@ -468,6 +465,21 @@ struct ContentView: View {
|
|||
}
|
||||
}
|
||||
|
||||
func showReRegisterTokenAlert() {
|
||||
dismissAllSheets() {
|
||||
let m = ChatModel.shared
|
||||
if let errorTknStatus = m.reRegisterTknStatus, let token = chatModel.deviceToken {
|
||||
chatModel.reRegisterTknStatus = nil
|
||||
AlertManager.shared.showAlert(Alert(
|
||||
title: Text("Notifications error"),
|
||||
message: Text(tokenStatusInfo(errorTknStatus, register: true)),
|
||||
primaryButton: .default(Text("Register")) { reRegisterToken(token: token) },
|
||||
secondaryButton: .cancel()
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func showPlanAndConnectAlert(_ alert: PlanAndConnectAlert) {
|
||||
AlertManager.shared.showAlert(planAndConnectAlert(alert, dismiss: false))
|
||||
}
|
||||
|
|
2281
apps/ios/Shared/Model/AppAPITypes.swift
Normal file
2281
apps/ios/Shared/Model/AppAPITypes.swift
Normal file
File diff suppressed because it is too large
Load diff
|
@ -30,9 +30,18 @@ actor TerminalItems {
|
|||
}
|
||||
}
|
||||
|
||||
func addCommand(_ start: Date, _ cmd: ChatCommand, _ resp: ChatResponse) async {
|
||||
func addCommand<R: ChatAPIResult>(_ start: Date, _ cmd: ChatCommand, _ res: APIResult<R>) async {
|
||||
await add(.cmd(start, cmd))
|
||||
await add(.resp(.now, resp))
|
||||
await addResult(res)
|
||||
}
|
||||
|
||||
func addResult<R: ChatAPIResult>(_ res: APIResult<R>) async {
|
||||
let item: TerminalItem = switch res {
|
||||
case let .result(r): .res(.now, r)
|
||||
case let .error(e): .err(.now, e)
|
||||
case let .invalid(type, json): .bad(.now, type, json)
|
||||
}
|
||||
await add(item)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -53,11 +62,20 @@ class ItemsModel: ObservableObject {
|
|||
var itemAdded = false {
|
||||
willSet { publisher.send() }
|
||||
}
|
||||
|
||||
|
||||
let chatState = ActiveChatState()
|
||||
|
||||
// Publishes directly to `objectWillChange` publisher,
|
||||
// this will cause reversedChatItems to be rendered without throttling
|
||||
@Published var isLoading = false
|
||||
@Published var showLoadingProgress = false
|
||||
@Published var showLoadingProgress: ChatId? = nil
|
||||
|
||||
private var navigationTimeoutTask: Task<Void, Never>? = nil
|
||||
private var loadChatTask: Task<Void, Never>? = nil
|
||||
|
||||
var lastItemsLoaded: Bool {
|
||||
chatState.splits.isEmpty || chatState.splits.first != reversedChatItems.first?.id
|
||||
}
|
||||
|
||||
init() {
|
||||
publisher
|
||||
|
@ -67,37 +85,149 @@ class ItemsModel: ObservableObject {
|
|||
}
|
||||
|
||||
func loadOpenChat(_ chatId: ChatId, willNavigate: @escaping () -> Void = {}) {
|
||||
let navigationTimeout = Task {
|
||||
navigationTimeoutTask?.cancel()
|
||||
loadChatTask?.cancel()
|
||||
navigationTimeoutTask = Task {
|
||||
do {
|
||||
try await Task.sleep(nanoseconds: 250_000000)
|
||||
await MainActor.run {
|
||||
willNavigate()
|
||||
ChatModel.shared.chatId = chatId
|
||||
willNavigate()
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
let progressTimeout = Task {
|
||||
do {
|
||||
try await Task.sleep(nanoseconds: 1500_000000)
|
||||
await MainActor.run { showLoadingProgress = true }
|
||||
} catch {}
|
||||
}
|
||||
Task {
|
||||
if let chat = ChatModel.shared.getChat(chatId) {
|
||||
await MainActor.run { self.isLoading = true }
|
||||
// try? await Task.sleep(nanoseconds: 5000_000000)
|
||||
await loadChat(chat: chat)
|
||||
navigationTimeout.cancel()
|
||||
progressTimeout.cancel()
|
||||
loadChatTask = Task {
|
||||
await MainActor.run { self.isLoading = true }
|
||||
// try? await Task.sleep(nanoseconds: 1000_000000)
|
||||
await loadChat(chatId: chatId)
|
||||
if !Task.isCancelled {
|
||||
await MainActor.run {
|
||||
self.isLoading = false
|
||||
self.showLoadingProgress = false
|
||||
willNavigate()
|
||||
ChatModel.shared.chatId = chatId
|
||||
self.showLoadingProgress = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func loadOpenChatNoWait(_ chatId: ChatId, _ openAroundItemId: ChatItem.ID? = nil) {
|
||||
navigationTimeoutTask?.cancel()
|
||||
loadChatTask?.cancel()
|
||||
loadChatTask = Task {
|
||||
// try? await Task.sleep(nanoseconds: 1000_000000)
|
||||
await loadChat(chatId: chatId, openAroundItemId: openAroundItemId, clearItems: openAroundItemId == nil)
|
||||
if !Task.isCancelled {
|
||||
await MainActor.run {
|
||||
if openAroundItemId == nil {
|
||||
ChatModel.shared.chatId = chatId
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class ChatTagsModel: ObservableObject {
|
||||
static let shared = ChatTagsModel()
|
||||
|
||||
@Published var userTags: [ChatTag] = []
|
||||
@Published var activeFilter: ActiveFilter? = nil
|
||||
@Published var presetTags: [PresetTag:Int] = [:]
|
||||
@Published var unreadTags: [Int64:Int] = [:]
|
||||
|
||||
func updateChatTags(_ chats: [Chat]) {
|
||||
let tm = ChatTagsModel.shared
|
||||
var newPresetTags: [PresetTag:Int] = [:]
|
||||
var newUnreadTags: [Int64:Int] = [:]
|
||||
for chat in chats {
|
||||
for tag in PresetTag.allCases {
|
||||
if presetTagMatchesChat(tag, chat.chatInfo, chat.chatStats) {
|
||||
newPresetTags[tag] = (newPresetTags[tag] ?? 0) + 1
|
||||
}
|
||||
}
|
||||
if chat.unreadTag, let tags = chat.chatInfo.chatTags {
|
||||
for tag in tags {
|
||||
newUnreadTags[tag] = (newUnreadTags[tag] ?? 0) + 1
|
||||
}
|
||||
}
|
||||
}
|
||||
presetTags = newPresetTags
|
||||
unreadTags = newUnreadTags
|
||||
clearActiveChatFilterIfNeeded()
|
||||
}
|
||||
|
||||
func updateChatFavorite(favorite: Bool, wasFavorite: Bool) {
|
||||
let count = presetTags[.favorites]
|
||||
if favorite && !wasFavorite {
|
||||
presetTags[.favorites] = (count ?? 0) + 1
|
||||
} else if !favorite && wasFavorite, let count {
|
||||
presetTags[.favorites] = max(0, count - 1)
|
||||
clearActiveChatFilterIfNeeded()
|
||||
}
|
||||
}
|
||||
|
||||
func addPresetChatTags(_ chatInfo: ChatInfo, _ chatStats: ChatStats) {
|
||||
for tag in PresetTag.allCases {
|
||||
if presetTagMatchesChat(tag, chatInfo, chatStats) {
|
||||
presetTags[tag] = (presetTags[tag] ?? 0) + 1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func removePresetChatTags(_ chatInfo: ChatInfo, _ chatStats: ChatStats) {
|
||||
for tag in PresetTag.allCases {
|
||||
if presetTagMatchesChat(tag, chatInfo, chatStats) {
|
||||
if let count = presetTags[tag] {
|
||||
if count > 1 {
|
||||
presetTags[tag] = count - 1
|
||||
} else {
|
||||
presetTags.removeValue(forKey: tag)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
clearActiveChatFilterIfNeeded()
|
||||
}
|
||||
|
||||
func markChatTagRead(_ chat: Chat) -> Void {
|
||||
if chat.unreadTag, let tags = chat.chatInfo.chatTags {
|
||||
decTagsReadCount(tags)
|
||||
}
|
||||
}
|
||||
|
||||
func updateChatTagRead(_ chat: Chat, wasUnread: Bool) -> Void {
|
||||
guard let tags = chat.chatInfo.chatTags else { return }
|
||||
let nowUnread = chat.unreadTag
|
||||
if nowUnread && !wasUnread {
|
||||
for tag in tags {
|
||||
unreadTags[tag] = (unreadTags[tag] ?? 0) + 1
|
||||
}
|
||||
} else if !nowUnread && wasUnread {
|
||||
decTagsReadCount(tags)
|
||||
}
|
||||
}
|
||||
|
||||
func decTagsReadCount(_ tags: [Int64]) -> Void {
|
||||
for tag in tags {
|
||||
if let count = unreadTags[tag] {
|
||||
unreadTags[tag] = max(0, count - 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func changeGroupReportsTag(_ by: Int = 0) {
|
||||
if by == 0 { return }
|
||||
presetTags[.groupReports] = max(0, (presetTags[.groupReports] ?? 0) + by)
|
||||
clearActiveChatFilterIfNeeded()
|
||||
}
|
||||
|
||||
func clearActiveChatFilterIfNeeded() {
|
||||
let clear = switch activeFilter {
|
||||
case let .presetTag(tag): (presetTags[tag] ?? 0) == 0
|
||||
case let .userTag(tag): !userTags.contains(tag)
|
||||
case .unread, nil: false
|
||||
}
|
||||
if clear { activeFilter = nil }
|
||||
}
|
||||
}
|
||||
|
||||
class NetworkModel: ObservableObject {
|
||||
|
@ -156,6 +286,7 @@ final class ChatModel: ObservableObject {
|
|||
@Published var deletedChats: Set<String> = []
|
||||
// current chat
|
||||
@Published var chatId: String?
|
||||
@Published var openAroundItemId: ChatItem.ID? = nil
|
||||
var chatItemStatuses: Dictionary<Int64, CIStatus> = [:]
|
||||
@Published var chatToTop: String?
|
||||
@Published var groupMembers: [GMember] = []
|
||||
|
@ -170,6 +301,7 @@ final class ChatModel: ObservableObject {
|
|||
@Published var deviceToken: DeviceToken?
|
||||
@Published var savedToken: DeviceToken?
|
||||
@Published var tokenRegistered = false
|
||||
@Published var reRegisterTknStatus: NtfTknStatus? = nil
|
||||
@Published var tokenStatus: NtfTknStatus?
|
||||
@Published var notificationMode = NotificationsMode.off
|
||||
@Published var notificationServer: String?
|
||||
|
@ -344,6 +476,7 @@ final class ChatModel: ObservableObject {
|
|||
updateChatInfo(cInfo)
|
||||
} else if addMissing {
|
||||
addChat(Chat(chatInfo: cInfo, chatItems: []))
|
||||
ChatTagsModel.shared.addPresetChatTags(cInfo, ChatStats())
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -401,7 +534,7 @@ final class ChatModel: ObservableObject {
|
|||
[cItem]
|
||||
}
|
||||
if case .rcvNew = cItem.meta.itemStatus {
|
||||
unreadCollector.changeUnreadCounter(cInfo.id, by: 1)
|
||||
unreadCollector.changeUnreadCounter(cInfo.id, by: 1, unreadMentions: cItem.meta.userMention ? 1 : 0)
|
||||
}
|
||||
popChatCollector.throttlePopChat(cInfo.id, currentPosition: i)
|
||||
} else {
|
||||
|
@ -447,6 +580,7 @@ final class ChatModel: ObservableObject {
|
|||
ci.meta.itemStatus = status
|
||||
}
|
||||
im.reversedChatItems.insert(ci, at: hasLiveDummy ? 1 : 0)
|
||||
im.chatState.itemAdded((ci.id, ci.isRcvNew), hasLiveDummy ? 1 : 0)
|
||||
im.itemAdded = true
|
||||
ChatItemDummyModel.shared.sendUpdate()
|
||||
return true
|
||||
|
@ -481,7 +615,7 @@ final class ChatModel: ObservableObject {
|
|||
|
||||
func removeChatItem(_ cInfo: ChatInfo, _ cItem: ChatItem) {
|
||||
if cItem.isRcvNew {
|
||||
unreadCollector.changeUnreadCounter(cInfo.id, by: -1)
|
||||
unreadCollector.changeUnreadCounter(cInfo.id, by: -1, unreadMentions: cItem.meta.userMention ? -1 : 0)
|
||||
}
|
||||
// update previews
|
||||
if let chat = getChat(cInfo.id) {
|
||||
|
@ -492,14 +626,54 @@ final class ChatModel: ObservableObject {
|
|||
// remove from current chat
|
||||
if chatId == cInfo.id {
|
||||
if let i = getChatItemIndex(cItem) {
|
||||
_ = withAnimation {
|
||||
im.reversedChatItems.remove(at: i)
|
||||
withAnimation {
|
||||
let item = im.reversedChatItems.remove(at: i)
|
||||
im.chatState.itemsRemoved([(item.id, i, item.isRcvNew)], im.reversedChatItems.reversed())
|
||||
}
|
||||
}
|
||||
}
|
||||
VoiceItemState.stopVoiceInChatView(cInfo, cItem)
|
||||
}
|
||||
|
||||
func removeMemberItems(_ removedMember: GroupMember, byMember: GroupMember, _ groupInfo: GroupInfo) {
|
||||
// this should not happen, only another member can "remove" user, user can only "leave" (another event).
|
||||
if byMember.groupMemberId == groupInfo.membership.groupMemberId {
|
||||
logger.debug("exiting removeMemberItems")
|
||||
return
|
||||
}
|
||||
if chatId == groupInfo.id {
|
||||
for i in 0..<im.reversedChatItems.count {
|
||||
if let updatedItem = removedUpdatedItem(im.reversedChatItems[i]) {
|
||||
_updateChatItem(at: i, with: updatedItem)
|
||||
}
|
||||
}
|
||||
} else if let chat = getChat(groupInfo.id),
|
||||
chat.chatItems.count > 0,
|
||||
let updatedItem = removedUpdatedItem(chat.chatItems[0]) {
|
||||
chat.chatItems = [updatedItem]
|
||||
}
|
||||
|
||||
func removedUpdatedItem(_ item: ChatItem) -> ChatItem? {
|
||||
let newContent: CIContent
|
||||
if case .groupSnd = item.chatDir, removedMember.groupMemberId == groupInfo.membership.groupMemberId {
|
||||
newContent = .sndModerated
|
||||
} else if case let .groupRcv(groupMember) = item.chatDir, groupMember.groupMemberId == removedMember.groupMemberId {
|
||||
newContent = .rcvModerated
|
||||
} else {
|
||||
return nil
|
||||
}
|
||||
var updatedItem = item
|
||||
updatedItem.meta.itemDeleted = .moderated(deletedTs: Date.now, byGroupMember: byMember)
|
||||
if groupInfo.fullGroupPreferences.fullDelete.on {
|
||||
updatedItem.content = newContent
|
||||
}
|
||||
if item.isActiveReport {
|
||||
decreaseGroupReportsCounter(groupInfo.id)
|
||||
}
|
||||
return updatedItem
|
||||
}
|
||||
}
|
||||
|
||||
func nextChatItemData<T>(_ chatItemId: Int64, previous: Bool, map: @escaping (ChatItem) -> T?) -> T? {
|
||||
guard var i = im.reversedChatItems.firstIndex(where: { $0.id == chatItemId }) else { return nil }
|
||||
if previous {
|
||||
|
@ -542,6 +716,7 @@ final class ChatModel: ObservableObject {
|
|||
let cItem = ChatItem.liveDummy(chatInfo.chatType)
|
||||
withAnimation {
|
||||
im.reversedChatItems.insert(cItem, at: 0)
|
||||
im.chatState.itemAdded((cItem.id, cItem.isRcvNew), 0)
|
||||
im.itemAdded = true
|
||||
}
|
||||
return cItem
|
||||
|
@ -561,71 +736,37 @@ final class ChatModel: ObservableObject {
|
|||
im.reversedChatItems.first?.isLiveDummy == true
|
||||
}
|
||||
|
||||
func markChatItemsRead(_ cInfo: ChatInfo) {
|
||||
func markAllChatItemsRead(_ cInfo: ChatInfo) {
|
||||
// update preview
|
||||
_updateChat(cInfo.id) { chat in
|
||||
self.decreaseUnreadCounter(user: self.currentUser!, by: chat.chatStats.unreadCount)
|
||||
self.updateFloatingButtons(unreadCount: 0)
|
||||
self.decreaseUnreadCounter(user: self.currentUser!, chat: chat)
|
||||
ChatTagsModel.shared.markChatTagRead(chat)
|
||||
chat.chatStats = ChatStats()
|
||||
}
|
||||
// update current chat
|
||||
if chatId == cInfo.id {
|
||||
markCurrentChatRead()
|
||||
}
|
||||
}
|
||||
|
||||
private func markCurrentChatRead(fromIndex i: Int = 0) {
|
||||
var j = i
|
||||
while j < im.reversedChatItems.count {
|
||||
markChatItemRead_(j)
|
||||
j += 1
|
||||
}
|
||||
}
|
||||
|
||||
private func updateFloatingButtons(unreadCount: Int) {
|
||||
let fbm = ChatView.FloatingButtonModel.shared
|
||||
fbm.totalUnread = unreadCount
|
||||
fbm.objectWillChange.send()
|
||||
}
|
||||
|
||||
func markChatItemsRead(_ cInfo: ChatInfo, aboveItem: ChatItem? = nil) {
|
||||
if let cItem = aboveItem {
|
||||
if chatId == cInfo.id, let i = getChatItemIndex(cItem) {
|
||||
markCurrentChatRead(fromIndex: i)
|
||||
_updateChat(cInfo.id) { chat in
|
||||
var unreadBelow = 0
|
||||
var j = i - 1
|
||||
while j >= 0 {
|
||||
if case .rcvNew = self.im.reversedChatItems[j].meta.itemStatus {
|
||||
unreadBelow += 1
|
||||
}
|
||||
j -= 1
|
||||
}
|
||||
// update preview
|
||||
let markedCount = chat.chatStats.unreadCount - unreadBelow
|
||||
if markedCount > 0 {
|
||||
chat.chatStats.unreadCount -= markedCount
|
||||
self.decreaseUnreadCounter(user: self.currentUser!, by: markedCount)
|
||||
self.updateFloatingButtons(unreadCount: chat.chatStats.unreadCount)
|
||||
}
|
||||
}
|
||||
var i = 0
|
||||
while i < im.reversedChatItems.count {
|
||||
markChatItemRead_(i)
|
||||
i += 1
|
||||
}
|
||||
} else {
|
||||
markChatItemsRead(cInfo)
|
||||
im.chatState.itemsRead(nil, im.reversedChatItems.reversed())
|
||||
}
|
||||
}
|
||||
|
||||
func markChatUnread(_ cInfo: ChatInfo, unreadChat: Bool = true) {
|
||||
_updateChat(cInfo.id) { chat in
|
||||
let wasUnread = chat.unreadTag
|
||||
chat.chatStats.unreadChat = unreadChat
|
||||
ChatTagsModel.shared.updateChatTagRead(chat, wasUnread: wasUnread)
|
||||
}
|
||||
}
|
||||
|
||||
func clearChat(_ cInfo: ChatInfo) {
|
||||
// clear preview
|
||||
if let chat = getChat(cInfo.id) {
|
||||
self.decreaseUnreadCounter(user: self.currentUser!, by: chat.chatStats.unreadCount)
|
||||
self.decreaseUnreadCounter(user: self.currentUser!, chat: chat)
|
||||
chat.chatItems = []
|
||||
ChatTagsModel.shared.markChatTagRead(chat)
|
||||
chat.chatStats = ChatStats()
|
||||
chat.chatInfo = cInfo
|
||||
}
|
||||
|
@ -633,18 +774,27 @@ final class ChatModel: ObservableObject {
|
|||
if chatId == cInfo.id {
|
||||
chatItemStatuses = [:]
|
||||
im.reversedChatItems = []
|
||||
im.chatState.clear()
|
||||
}
|
||||
}
|
||||
|
||||
func markChatItemsRead(_ cInfo: ChatInfo, _ itemIds: [ChatItem.ID]) {
|
||||
func markChatItemsRead(_ cInfo: ChatInfo, _ itemIds: [ChatItem.ID], _ mentionsRead: Int) {
|
||||
if self.chatId == cInfo.id {
|
||||
for itemId in itemIds {
|
||||
if let i = im.reversedChatItems.firstIndex(where: { $0.id == itemId }) {
|
||||
var unreadItemIds: Set<ChatItem.ID> = []
|
||||
var i = 0
|
||||
var ids = Set(itemIds)
|
||||
while i < im.reversedChatItems.count && !ids.isEmpty {
|
||||
let item = im.reversedChatItems[i]
|
||||
if ids.contains(item.id) && item.isRcvNew {
|
||||
markChatItemRead_(i)
|
||||
unreadItemIds.insert(item.id)
|
||||
ids.remove(item.id)
|
||||
}
|
||||
i += 1
|
||||
}
|
||||
im.chatState.itemsRead(unreadItemIds, im.reversedChatItems.reversed())
|
||||
}
|
||||
self.unreadCollector.changeUnreadCounter(cInfo.id, by: -itemIds.count)
|
||||
self.unreadCollector.changeUnreadCounter(cInfo.id, by: -itemIds.count, unreadMentions: -mentionsRead)
|
||||
}
|
||||
|
||||
private let unreadCollector = UnreadCollector()
|
||||
|
@ -652,16 +802,16 @@ final class ChatModel: ObservableObject {
|
|||
class UnreadCollector {
|
||||
private let subject = PassthroughSubject<Void, Never>()
|
||||
private var bag = Set<AnyCancellable>()
|
||||
private var unreadCounts: [ChatId: Int] = [:]
|
||||
private var unreadCounts: [ChatId: (unread: Int, mentions: Int)] = [:]
|
||||
|
||||
init() {
|
||||
subject
|
||||
.debounce(for: 1, scheduler: DispatchQueue.main)
|
||||
.sink {
|
||||
let m = ChatModel.shared
|
||||
for (chatId, count) in self.unreadCounts {
|
||||
if let i = m.getChatIndex(chatId) {
|
||||
m.changeUnreadCounter(i, by: count)
|
||||
for (chatId, (unread, mentions)) in self.unreadCounts {
|
||||
if unread != 0 || mentions != 0, let i = m.getChatIndex(chatId) {
|
||||
m.changeUnreadCounter(i, by: unread, unreadMentions: mentions)
|
||||
}
|
||||
}
|
||||
self.unreadCounts = [:]
|
||||
|
@ -669,11 +819,9 @@ final class ChatModel: ObservableObject {
|
|||
.store(in: &bag)
|
||||
}
|
||||
|
||||
func changeUnreadCounter(_ chatId: ChatId, by count: Int) {
|
||||
if chatId == ChatModel.shared.chatId {
|
||||
ChatView.FloatingButtonModel.shared.totalUnread += count
|
||||
}
|
||||
self.unreadCounts[chatId] = (self.unreadCounts[chatId] ?? 0) + count
|
||||
func changeUnreadCounter(_ chatId: ChatId, by count: Int, unreadMentions: Int) {
|
||||
let (unread, mentions) = self.unreadCounts[chatId] ?? (0, 0)
|
||||
self.unreadCounts[chatId] = (unread + count, mentions + unreadMentions)
|
||||
subject.send()
|
||||
}
|
||||
}
|
||||
|
@ -751,8 +899,12 @@ final class ChatModel: ObservableObject {
|
|||
}
|
||||
}
|
||||
|
||||
func changeUnreadCounter(_ chatIndex: Int, by count: Int) {
|
||||
chats[chatIndex].chatStats.unreadCount = chats[chatIndex].chatStats.unreadCount + count
|
||||
func changeUnreadCounter(_ chatIndex: Int, by count: Int, unreadMentions: Int) {
|
||||
let wasUnread = chats[chatIndex].unreadTag
|
||||
let stats = chats[chatIndex].chatStats
|
||||
chats[chatIndex].chatStats.unreadCount = stats.unreadCount + count
|
||||
chats[chatIndex].chatStats.unreadMentions = stats.unreadMentions + unreadMentions
|
||||
ChatTagsModel.shared.updateChatTagRead(chats[chatIndex], wasUnread: wasUnread)
|
||||
changeUnreadCounter(user: currentUser!, by: count)
|
||||
}
|
||||
|
||||
|
@ -760,6 +912,13 @@ final class ChatModel: ObservableObject {
|
|||
changeUnreadCounter(user: user, by: 1)
|
||||
}
|
||||
|
||||
func decreaseUnreadCounter(user: any UserLike, chat: Chat) {
|
||||
let by = chat.chatInfo.chatSettings?.enableNtfs == .mentions
|
||||
? chat.chatStats.unreadMentions
|
||||
: chat.chatStats.unreadCount
|
||||
decreaseUnreadCounter(user: user, by: by)
|
||||
}
|
||||
|
||||
func decreaseUnreadCounter(user: any UserLike, by: Int = 1) {
|
||||
changeUnreadCounter(user: user, by: -by)
|
||||
}
|
||||
|
@ -772,8 +931,41 @@ final class ChatModel: ObservableObject {
|
|||
}
|
||||
|
||||
func totalUnreadCountForAllUsers() -> Int {
|
||||
chats.filter { $0.chatInfo.ntfsEnabled }.reduce(0, { count, chat in count + chat.chatStats.unreadCount }) +
|
||||
users.filter { !$0.user.activeUser }.reduce(0, { unread, next -> Int in unread + next.unreadCount })
|
||||
var unread: Int = 0
|
||||
for chat in chats {
|
||||
switch chat.chatInfo.chatSettings?.enableNtfs {
|
||||
case .all: unread += chat.chatStats.unreadCount
|
||||
case .mentions: unread += chat.chatStats.unreadMentions
|
||||
default: ()
|
||||
}
|
||||
}
|
||||
for u in users {
|
||||
if !u.user.activeUser {
|
||||
unread += u.unreadCount
|
||||
}
|
||||
}
|
||||
return unread
|
||||
}
|
||||
|
||||
func increaseGroupReportsCounter(_ chatId: ChatId) {
|
||||
changeGroupReportsCounter(chatId, 1)
|
||||
}
|
||||
|
||||
func decreaseGroupReportsCounter(_ chatId: ChatId, by: Int = 1) {
|
||||
changeGroupReportsCounter(chatId, -by)
|
||||
}
|
||||
|
||||
private func changeGroupReportsCounter(_ chatId: ChatId, _ by: Int = 0) {
|
||||
if by == 0 { return }
|
||||
|
||||
if let i = getChatIndex(chatId) {
|
||||
let chat = chats[i]
|
||||
let wasReportsCount = chat.chatStats.reportsCount
|
||||
chat.chatStats.reportsCount = max(0, chat.chatStats.reportsCount + by)
|
||||
let nowReportsCount = chat.chatStats.reportsCount
|
||||
let by = wasReportsCount == 0 && nowReportsCount > 0 ? 1 : (wasReportsCount > 0 && nowReportsCount == 0) ? -1 : 0
|
||||
ChatTagsModel.shared.changeGroupReportsTag(by)
|
||||
}
|
||||
}
|
||||
|
||||
// this function analyses "connected" events and assumes that each member will be there only once
|
||||
|
@ -821,12 +1013,17 @@ final class ChatModel: ObservableObject {
|
|||
|
||||
// returns the previous member in the same merge group and the count of members in this group
|
||||
func getPrevHiddenMember(_ member: GroupMember, _ range: ClosedRange<Int>) -> (GroupMember?, Int) {
|
||||
let items = im.reversedChatItems
|
||||
var prevMember: GroupMember? = nil
|
||||
var memberIds: Set<Int64> = []
|
||||
for i in range {
|
||||
if case let .groupRcv(m) = im.reversedChatItems[i].chatDir {
|
||||
if prevMember == nil && m.groupMemberId != member.groupMemberId { prevMember = m }
|
||||
memberIds.insert(m.groupMemberId)
|
||||
if i < items.count {
|
||||
if case let .groupRcv(m) = items[i].chatDir {
|
||||
if prevMember == nil && m.groupMemberId != member.groupMemberId { prevMember = m }
|
||||
memberIds.insert(m.groupMemberId)
|
||||
}
|
||||
} else {
|
||||
logger.error("getPrevHiddenMember: index >= count of reversed items: \(i) vs \(items.count), range: \(String(describing: range))")
|
||||
}
|
||||
}
|
||||
return (prevMember, memberIds.count)
|
||||
|
@ -857,7 +1054,11 @@ final class ChatModel: ObservableObject {
|
|||
|
||||
func removeChat(_ id: String) {
|
||||
withAnimation {
|
||||
chats.removeAll(where: { $0.id == id })
|
||||
if let i = getChatIndex(id) {
|
||||
let removed = chats.remove(at: i)
|
||||
ChatTagsModel.shared.removePresetChatTags(removed.chatInfo, removed.chatStats)
|
||||
removeWallpaperFilesFromChat(removed)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -895,6 +1096,23 @@ final class ChatModel: ObservableObject {
|
|||
_ = upsertGroupMember(groupInfo, updatedMember)
|
||||
}
|
||||
}
|
||||
|
||||
func removeWallpaperFilesFromChat(_ chat: Chat) {
|
||||
if case let .direct(contact) = chat.chatInfo {
|
||||
removeWallpaperFilesFromTheme(contact.uiThemes)
|
||||
} else if case let .group(groupInfo) = chat.chatInfo {
|
||||
removeWallpaperFilesFromTheme(groupInfo.uiThemes)
|
||||
}
|
||||
}
|
||||
|
||||
func removeWallpaperFilesFromAllChats(_ user: User) {
|
||||
// Currently, only removing everything from currently active user is supported. Inactive users are TODO
|
||||
if user.userId == currentUser?.userId {
|
||||
chats.forEach {
|
||||
removeWallpaperFilesFromChat($0)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct ShowingInvitation {
|
||||
|
@ -934,27 +1152,14 @@ final class Chat: ObservableObject, Identifiable, ChatLike {
|
|||
)
|
||||
}
|
||||
|
||||
var userCanSend: Bool {
|
||||
switch chatInfo {
|
||||
case .direct: return true
|
||||
case let .group(groupInfo):
|
||||
let m = groupInfo.membership
|
||||
return m.memberActive && m.memberRole >= .member
|
||||
case .local:
|
||||
return true
|
||||
default: return false
|
||||
var unreadTag: Bool {
|
||||
switch chatInfo.chatSettings?.enableNtfs {
|
||||
case .all: chatStats.unreadChat || chatStats.unreadCount > 0
|
||||
case .mentions: chatStats.unreadChat || chatStats.unreadMentions > 0
|
||||
default: chatStats.unreadChat
|
||||
}
|
||||
}
|
||||
|
||||
var userIsObserver: Bool {
|
||||
switch chatInfo {
|
||||
case let .group(groupInfo):
|
||||
let m = groupInfo.membership
|
||||
return m.memberActive && m.memberRole == .observer
|
||||
default: return false
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
var id: ChatId { get { chatInfo.id } }
|
||||
|
||||
var viewId: String { get { "\(chatInfo.id) \(created.timeIntervalSince1970)" } }
|
||||
|
|
|
@ -248,7 +248,7 @@ class NtfManager: NSObject, UNUserNotificationCenterDelegate, ObservableObject {
|
|||
|
||||
func notifyMessageReceived(_ user: any UserLike, _ cInfo: ChatInfo, _ cItem: ChatItem) {
|
||||
logger.debug("NtfManager.notifyMessageReceived")
|
||||
if cInfo.ntfsEnabled {
|
||||
if cInfo.ntfsEnabled(chatItem: cItem) {
|
||||
addNotification(createMessageReceivedNtf(user, cInfo, cItem, 0))
|
||||
}
|
||||
}
|
||||
|
|
File diff suppressed because it is too large
Load diff
|
@ -19,6 +19,7 @@ struct SimpleXApp: App {
|
|||
|
||||
@Environment(\.scenePhase) var scenePhase
|
||||
@State private var enteredBackgroundAuthenticated: TimeInterval? = nil
|
||||
@State private var appOpenUrlLater: URL?
|
||||
|
||||
init() {
|
||||
DispatchQueue.global(qos: .background).sync {
|
||||
|
@ -42,7 +43,11 @@ struct SimpleXApp: App {
|
|||
.environmentObject(AppTheme.shared)
|
||||
.onOpenURL { url in
|
||||
logger.debug("ContentView.onOpenURL: \(url)")
|
||||
chatModel.appOpenUrl = url
|
||||
if AppChatState.shared.value == .active {
|
||||
chatModel.appOpenUrl = url
|
||||
} else {
|
||||
appOpenUrlLater = url
|
||||
}
|
||||
}
|
||||
.onAppear() {
|
||||
// Present screen for continue migration if it wasn't finished yet
|
||||
|
@ -93,7 +98,16 @@ struct SimpleXApp: App {
|
|||
if !chatModel.showCallView && !CallController.shared.hasActiveCalls() {
|
||||
await updateCallInvitations()
|
||||
}
|
||||
if let url = appOpenUrlLater {
|
||||
await MainActor.run {
|
||||
appOpenUrlLater = nil
|
||||
chatModel.appOpenUrl = url
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if let url = appOpenUrlLater {
|
||||
appOpenUrlLater = nil
|
||||
chatModel.appOpenUrl = url
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -243,7 +243,7 @@ struct ActiveCallView: View {
|
|||
ChatReceiver.shared.messagesChannel = nil
|
||||
return
|
||||
}
|
||||
if case let .chatItemsStatusesUpdated(_, chatItems) = msg,
|
||||
if case let .result(.chatItemsStatusesUpdated(_, chatItems)) = msg,
|
||||
chatItems.contains(where: { ci in
|
||||
ci.chatInfo.id == call.contact.id &&
|
||||
ci.chatItem.content.isSndCall &&
|
||||
|
@ -361,7 +361,7 @@ struct ActiveCallOverlay: View {
|
|||
HStack {
|
||||
Text(call.encryptionStatus)
|
||||
if let connInfo = call.connectionInfo {
|
||||
Text("(") + Text(connInfo.text) + Text(")")
|
||||
Text(verbatim: "(") + Text(connInfo.text) + Text(verbatim: ")")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -390,7 +390,7 @@ struct ActiveCallOverlay: View {
|
|||
HStack {
|
||||
Text(call.encryptionStatus)
|
||||
if let connInfo = call.connectionInfo {
|
||||
Text("(") + Text(connInfo.text) + Text(")")
|
||||
Text(verbatim: "(") + Text(connInfo.text) + Text(verbatim: ")")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -467,7 +467,7 @@ struct ActiveCallOverlay: View {
|
|||
.disabled(call.initialCallType == .audio && client.activeCall?.peerHasOldVersion == true)
|
||||
}
|
||||
|
||||
@ViewBuilder private func flipCameraButton() -> some View {
|
||||
private func flipCameraButton() -> some View {
|
||||
controlButton(call, "arrow.triangle.2.circlepath", padding: 12) {
|
||||
Task {
|
||||
if await WebRTCClient.isAuthorized(for: .video) {
|
||||
|
@ -477,11 +477,11 @@ struct ActiveCallOverlay: View {
|
|||
}
|
||||
}
|
||||
|
||||
@ViewBuilder private func controlButton(_ call: Call, _ imageName: String, padding: CGFloat, _ perform: @escaping () -> Void) -> some View {
|
||||
private func controlButton(_ call: Call, _ imageName: String, padding: CGFloat, _ perform: @escaping () -> Void) -> some View {
|
||||
callButton(imageName, call.peerMediaSources.hasVideo ? Color.black.opacity(0.2) : Color.white.opacity(0.2), padding: padding, perform)
|
||||
}
|
||||
|
||||
@ViewBuilder private func audioDevicePickerButton() -> some View {
|
||||
private func audioDevicePickerButton() -> some View {
|
||||
AudioDevicePicker()
|
||||
.opacity(0.8)
|
||||
.scaleEffect(2)
|
||||
|
|
|
@ -7,7 +7,7 @@
|
|||
//
|
||||
|
||||
import SwiftUI
|
||||
import SimpleXChat
|
||||
@preconcurrency import SimpleXChat
|
||||
|
||||
func infoRow(_ title: LocalizedStringKey, _ value: String) -> some View {
|
||||
HStack {
|
||||
|
@ -109,6 +109,7 @@ struct ChatInfoView: View {
|
|||
@State private var showConnectContactViaAddressDialog = false
|
||||
@State private var sendReceipts = SendReceipts.userDefault(true)
|
||||
@State private var sendReceiptsUserDefault = true
|
||||
@State private var progressIndicator = false
|
||||
@AppStorage(DEFAULT_DEVELOPER_TOOLS) private var developerTools = false
|
||||
|
||||
enum ChatInfoViewAlert: Identifiable {
|
||||
|
@ -137,50 +138,50 @@ struct ChatInfoView: View {
|
|||
|
||||
var body: some View {
|
||||
NavigationView {
|
||||
List {
|
||||
contactInfoHeader()
|
||||
.listRowBackground(Color.clear)
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture {
|
||||
aliasTextFieldFocused = false
|
||||
}
|
||||
|
||||
Group {
|
||||
ZStack {
|
||||
List {
|
||||
contactInfoHeader()
|
||||
.listRowBackground(Color.clear)
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture {
|
||||
aliasTextFieldFocused = false
|
||||
}
|
||||
|
||||
localAliasTextEdit()
|
||||
}
|
||||
.listRowBackground(Color.clear)
|
||||
.listRowSeparator(.hidden)
|
||||
.padding(.bottom, 18)
|
||||
|
||||
GeometryReader { g in
|
||||
HStack(alignment: .center, spacing: 8) {
|
||||
let buttonWidth = g.size.width / 4
|
||||
searchButton(width: buttonWidth)
|
||||
AudioCallButton(chat: chat, contact: contact, connectionStats: $connectionStats, width: buttonWidth) { alert = .someAlert(alert: $0) }
|
||||
VideoButton(chat: chat, contact: contact, connectionStats: $connectionStats, width: buttonWidth) { alert = .someAlert(alert: $0) }
|
||||
muteButton(width: buttonWidth)
|
||||
}
|
||||
}
|
||||
.padding(.trailing)
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(height: infoViewActionButtonHeight)
|
||||
.listRowBackground(Color.clear)
|
||||
.listRowSeparator(.hidden)
|
||||
.listRowInsets(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 8))
|
||||
|
||||
if let customUserProfile = customUserProfile {
|
||||
Section(header: Text("Incognito").foregroundColor(theme.colors.secondary)) {
|
||||
HStack {
|
||||
Text("Your random profile")
|
||||
Spacer()
|
||||
Text(customUserProfile.chatViewName)
|
||||
.foregroundStyle(.indigo)
|
||||
.listRowBackground(Color.clear)
|
||||
.listRowSeparator(.hidden)
|
||||
.padding(.bottom, 18)
|
||||
|
||||
GeometryReader { g in
|
||||
HStack(alignment: .center, spacing: 8) {
|
||||
let buttonWidth = g.size.width / 4
|
||||
searchButton(width: buttonWidth)
|
||||
AudioCallButton(chat: chat, contact: contact, connectionStats: $connectionStats, width: buttonWidth) { alert = .someAlert(alert: $0) }
|
||||
VideoButton(chat: chat, contact: contact, connectionStats: $connectionStats, width: buttonWidth) { alert = .someAlert(alert: $0) }
|
||||
if let nextNtfMode = chat.chatInfo.nextNtfMode {
|
||||
muteButton(width: buttonWidth, nextNtfMode: nextNtfMode)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Section {
|
||||
Group {
|
||||
.padding(.trailing)
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(height: infoViewActionButtonHeight)
|
||||
.listRowBackground(Color.clear)
|
||||
.listRowSeparator(.hidden)
|
||||
.listRowInsets(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 8))
|
||||
|
||||
if let customUserProfile = customUserProfile {
|
||||
Section(header: Text("Incognito").foregroundColor(theme.colors.secondary)) {
|
||||
HStack {
|
||||
Text("Your random profile")
|
||||
Spacer()
|
||||
Text(customUserProfile.chatViewName)
|
||||
.foregroundStyle(.indigo)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Section {
|
||||
if let code = connectionCode { verifyCodeButton(code) }
|
||||
contactPreferencesButton()
|
||||
sendReceiptsOption()
|
||||
|
@ -191,97 +192,109 @@ struct ChatInfoView: View {
|
|||
// } else if developerTools {
|
||||
// synchronizeConnectionButtonForce()
|
||||
// }
|
||||
|
||||
NavigationLink {
|
||||
ChatWallpaperEditorSheet(chat: chat)
|
||||
} label: {
|
||||
Label("Chat theme", systemImage: "photo")
|
||||
}
|
||||
// } else if developerTools {
|
||||
// synchronizeConnectionButtonForce()
|
||||
// }
|
||||
}
|
||||
.disabled(!contact.ready || !contact.active)
|
||||
NavigationLink {
|
||||
ChatWallpaperEditorSheet(chat: chat)
|
||||
} label: {
|
||||
Label("Chat theme", systemImage: "photo")
|
||||
}
|
||||
// } else if developerTools {
|
||||
// synchronizeConnectionButtonForce()
|
||||
// }
|
||||
}
|
||||
.disabled(!contact.ready || !contact.active)
|
||||
|
||||
if let conn = contact.activeConn {
|
||||
|
||||
Section {
|
||||
infoRow(Text(String("E2E encryption")), conn.connPQEnabled ? "Quantum resistant" : "Standard")
|
||||
}
|
||||
}
|
||||
|
||||
if let contactLink = contact.contactLink {
|
||||
Section {
|
||||
SimpleXLinkQRCode(uri: contactLink)
|
||||
Button {
|
||||
showShareSheet(items: [simplexChatLink(contactLink)])
|
||||
} label: {
|
||||
Label("Share address", systemImage: "square.and.arrow.up")
|
||||
}
|
||||
} header: {
|
||||
Text("Address")
|
||||
.foregroundColor(theme.colors.secondary)
|
||||
ChatTTLOption(chat: chat, progressIndicator: $progressIndicator)
|
||||
} footer: {
|
||||
Text("You can share this address with your contacts to let them connect with **\(contact.displayName)**.")
|
||||
.foregroundColor(theme.colors.secondary)
|
||||
Text("Delete chat messages from your device.")
|
||||
}
|
||||
}
|
||||
|
||||
if contact.ready && contact.active {
|
||||
Section(header: Text("Servers").foregroundColor(theme.colors.secondary)) {
|
||||
networkStatusRow()
|
||||
.onTapGesture {
|
||||
alert = .networkStatusAlert
|
||||
|
||||
if let conn = contact.activeConn {
|
||||
Section {
|
||||
infoRow(Text(String("E2E encryption")), conn.connPQEnabled ? "Quantum resistant" : "Standard")
|
||||
}
|
||||
}
|
||||
|
||||
if let contactLink = contact.contactLink {
|
||||
Section {
|
||||
SimpleXLinkQRCode(uri: contactLink)
|
||||
Button {
|
||||
showShareSheet(items: [simplexChatLink(contactLink)])
|
||||
} label: {
|
||||
Label("Share address", systemImage: "square.and.arrow.up")
|
||||
}
|
||||
if let connStats = connectionStats {
|
||||
Button("Change receiving address") {
|
||||
alert = .switchAddressAlert
|
||||
}
|
||||
.disabled(
|
||||
connStats.rcvQueuesInfo.contains { $0.rcvSwitchStatus != nil }
|
||||
|| connStats.ratchetSyncSendProhibited
|
||||
)
|
||||
if connStats.rcvQueuesInfo.contains(where: { $0.rcvSwitchStatus != nil }) {
|
||||
Button("Abort changing address") {
|
||||
alert = .abortSwitchAddressAlert
|
||||
} header: {
|
||||
Text("Address")
|
||||
.foregroundColor(theme.colors.secondary)
|
||||
} footer: {
|
||||
Text("You can share this address with your contacts to let them connect with **\(contact.displayName)**.")
|
||||
.foregroundColor(theme.colors.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
if contact.ready && contact.active {
|
||||
Section(header: Text("Servers").foregroundColor(theme.colors.secondary)) {
|
||||
networkStatusRow()
|
||||
.onTapGesture {
|
||||
alert = .networkStatusAlert
|
||||
}
|
||||
if let connStats = connectionStats {
|
||||
Button("Change receiving address") {
|
||||
alert = .switchAddressAlert
|
||||
}
|
||||
.disabled(
|
||||
connStats.rcvQueuesInfo.contains { $0.rcvSwitchStatus != nil && !$0.canAbortSwitch }
|
||||
connStats.rcvQueuesInfo.contains { $0.rcvSwitchStatus != nil }
|
||||
|| connStats.ratchetSyncSendProhibited
|
||||
)
|
||||
if connStats.rcvQueuesInfo.contains(where: { $0.rcvSwitchStatus != nil }) {
|
||||
Button("Abort changing address") {
|
||||
alert = .abortSwitchAddressAlert
|
||||
}
|
||||
.disabled(
|
||||
connStats.rcvQueuesInfo.contains { $0.rcvSwitchStatus != nil && !$0.canAbortSwitch }
|
||||
|| connStats.ratchetSyncSendProhibited
|
||||
)
|
||||
}
|
||||
smpServers("Receiving via", connStats.rcvQueuesInfo.map { $0.rcvServer }, theme.colors.secondary)
|
||||
smpServers("Sending via", connStats.sndQueuesInfo.map { $0.sndServer }, theme.colors.secondary)
|
||||
}
|
||||
smpServers("Receiving via", connStats.rcvQueuesInfo.map { $0.rcvServer }, theme.colors.secondary)
|
||||
smpServers("Sending via", connStats.sndQueuesInfo.map { $0.sndServer }, theme.colors.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Section {
|
||||
clearChatButton()
|
||||
deleteContactButton()
|
||||
}
|
||||
|
||||
if developerTools {
|
||||
Section(header: Text("For console").foregroundColor(theme.colors.secondary)) {
|
||||
infoRow("Local name", chat.chatInfo.localDisplayName)
|
||||
infoRow("Database ID", "\(chat.chatInfo.apiId)")
|
||||
Button ("Debug delivery") {
|
||||
Task {
|
||||
do {
|
||||
let info = queueInfoText(try await apiContactQueueInfo(chat.chatInfo.apiId))
|
||||
await MainActor.run { alert = .queueInfo(info: info) }
|
||||
} catch let e {
|
||||
logger.error("apiContactQueueInfo error: \(responseError(e))")
|
||||
let a = getErrorAlert(e, "Error")
|
||||
await MainActor.run { alert = .error(title: a.title, error: a.message) }
|
||||
|
||||
Section {
|
||||
clearChatButton()
|
||||
deleteContactButton()
|
||||
}
|
||||
|
||||
if developerTools {
|
||||
Section(header: Text("For console").foregroundColor(theme.colors.secondary)) {
|
||||
infoRow("Local name", chat.chatInfo.localDisplayName)
|
||||
infoRow("Database ID", "\(chat.chatInfo.apiId)")
|
||||
Button ("Debug delivery") {
|
||||
Task {
|
||||
do {
|
||||
let info = queueInfoText(try await apiContactQueueInfo(chat.chatInfo.apiId))
|
||||
await MainActor.run { alert = .queueInfo(info: info) }
|
||||
} catch let e {
|
||||
logger.error("apiContactQueueInfo error: \(responseError(e))")
|
||||
let a = getErrorAlert(e, "Error")
|
||||
await MainActor.run { alert = .error(title: a.title, error: a.message) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.modifier(ThemedBackground(grouped: true))
|
||||
.navigationBarHidden(true)
|
||||
.disabled(progressIndicator)
|
||||
.opacity(progressIndicator ? 0.6 : 1)
|
||||
|
||||
if progressIndicator {
|
||||
ProgressView().scaleEffect(2)
|
||||
}
|
||||
}
|
||||
.modifier(ThemedBackground(grouped: true))
|
||||
.navigationBarHidden(true)
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
|
||||
.onAppear {
|
||||
|
@ -290,7 +303,6 @@ struct ChatInfoView: View {
|
|||
}
|
||||
sendReceipts = SendReceipts.fromBool(contact.chatSettings.sendRcpts, userDefault: sendReceiptsUserDefault)
|
||||
|
||||
|
||||
Task {
|
||||
do {
|
||||
let (stats, profile) = try await apiContactInfo(chat.chatInfo.apiId)
|
||||
|
@ -332,7 +344,7 @@ struct ChatInfoView: View {
|
|||
.sheet(item: $sheet) {
|
||||
if #available(iOS 16.0, *) {
|
||||
$0.content
|
||||
.presentationDetents([.fraction(0.4)])
|
||||
.presentationDetents([.fraction($0.fraction)])
|
||||
} else {
|
||||
$0.content
|
||||
}
|
||||
|
@ -422,13 +434,13 @@ struct ChatInfoView: View {
|
|||
.disabled(!contact.ready || chat.chatItems.isEmpty)
|
||||
}
|
||||
|
||||
private func muteButton(width: CGFloat) -> some View {
|
||||
InfoViewButton(
|
||||
image: chat.chatInfo.ntfsEnabled ? "speaker.slash.fill" : "speaker.wave.2.fill",
|
||||
title: chat.chatInfo.ntfsEnabled ? "mute" : "unmute",
|
||||
private func muteButton(width: CGFloat, nextNtfMode: MsgFilter) -> some View {
|
||||
return InfoViewButton(
|
||||
image: nextNtfMode.iconFilled,
|
||||
title: "\(nextNtfMode.text(mentions: false))",
|
||||
width: width
|
||||
) {
|
||||
toggleNotifications(chat, enableNtfs: !chat.chatInfo.ntfsEnabled)
|
||||
toggleNotifications(chat, enableNtfs: nextNtfMode)
|
||||
}
|
||||
.disabled(!contact.ready || !contact.active)
|
||||
}
|
||||
|
@ -498,7 +510,7 @@ struct ChatInfoView: View {
|
|||
chatSettings.sendRcpts = sendReceipts.bool()
|
||||
updateChatSettings(chat, chatSettings: chatSettings)
|
||||
}
|
||||
|
||||
|
||||
private func synchronizeConnectionButton() -> some View {
|
||||
Button {
|
||||
Task {
|
||||
|
@ -643,6 +655,69 @@ struct ChatInfoView: View {
|
|||
}
|
||||
}
|
||||
|
||||
struct ChatTTLOption: View {
|
||||
@ObservedObject var chat: Chat
|
||||
@Binding var progressIndicator: Bool
|
||||
@State private var currentChatItemTTL: ChatTTL = ChatTTL.userDefault(.seconds(0))
|
||||
@State private var chatItemTTL: ChatTTL = ChatTTL.chat(.seconds(0))
|
||||
|
||||
var body: some View {
|
||||
Picker("Delete messages after", selection: $chatItemTTL) {
|
||||
ForEach(ChatItemTTL.values) { ttl in
|
||||
Text(ttl.deleteAfterText).tag(ChatTTL.chat(ttl))
|
||||
}
|
||||
let defaultTTL = ChatTTL.userDefault(ChatModel.shared.chatItemTTL)
|
||||
Text(defaultTTL.text).tag(defaultTTL)
|
||||
|
||||
if case .chat(let ttl) = chatItemTTL, case .seconds = ttl {
|
||||
Text(ttl.deleteAfterText).tag(chatItemTTL)
|
||||
}
|
||||
}
|
||||
.disabled(progressIndicator)
|
||||
.frame(height: 36)
|
||||
.onChange(of: chatItemTTL) { ttl in
|
||||
if ttl == currentChatItemTTL { return }
|
||||
setChatTTL(
|
||||
ttl,
|
||||
hasPreviousTTL: !currentChatItemTTL.neverExpires,
|
||||
onCancel: { chatItemTTL = currentChatItemTTL }
|
||||
) {
|
||||
progressIndicator = true
|
||||
Task {
|
||||
let m = ChatModel.shared
|
||||
do {
|
||||
try await setChatTTL(chatType: chat.chatInfo.chatType, id: chat.chatInfo.apiId, ttl)
|
||||
await loadChat(chat: chat, clearItems: true)
|
||||
await MainActor.run {
|
||||
progressIndicator = false
|
||||
currentChatItemTTL = chatItemTTL
|
||||
if ItemsModel.shared.reversedChatItems.isEmpty && m.chatId == chat.id,
|
||||
let chat = m.getChat(chat.id) {
|
||||
chat.chatItems = []
|
||||
m.replaceChat(chat.id, chat)
|
||||
}
|
||||
}
|
||||
}
|
||||
catch let error {
|
||||
logger.error("setChatTTL error \(responseError(error))")
|
||||
await loadChat(chat: chat, clearItems: true)
|
||||
await MainActor.run {
|
||||
chatItemTTL = currentChatItemTTL
|
||||
progressIndicator = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
let sm = ChatModel.shared
|
||||
let ttl = chat.chatInfo.ttl(sm.chatItemTTL)
|
||||
chatItemTTL = ttl
|
||||
currentChatItemTTL = ttl
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func syncContactConnection(_ contact: Contact, force: Bool, showAlert: (SomeAlert) -> Void) async -> ConnectionStats? {
|
||||
do {
|
||||
let stats = try apiSyncContactRatchet(contact.apiId, force)
|
||||
|
@ -1054,6 +1129,33 @@ func deleteContactDialog(
|
|||
}
|
||||
}
|
||||
|
||||
func setChatTTL(_ ttl: ChatTTL, hasPreviousTTL: Bool, onCancel: @escaping () -> Void, onConfirm: @escaping () -> Void) {
|
||||
let title = if ttl.neverExpires {
|
||||
NSLocalizedString("Disable automatic message deletion?", comment: "alert title")
|
||||
} else if ttl.usingDefault || hasPreviousTTL {
|
||||
NSLocalizedString("Change automatic message deletion?", comment: "alert title")
|
||||
} else {
|
||||
NSLocalizedString("Enable automatic message deletion?", comment: "alert title")
|
||||
}
|
||||
|
||||
let message = if ttl.neverExpires {
|
||||
NSLocalizedString("Messages in this chat will never be deleted.", comment: "alert message")
|
||||
} else {
|
||||
NSLocalizedString("This action cannot be undone - the messages sent and received in this chat earlier than selected will be deleted.", comment: "alert message")
|
||||
}
|
||||
|
||||
showAlert(title, message: message) {
|
||||
[
|
||||
UIAlertAction(
|
||||
title: ttl.neverExpires ? NSLocalizedString("Disable delete messages", comment: "alert button") : NSLocalizedString("Delete messages", comment: "alert button"),
|
||||
style: .destructive,
|
||||
handler: { _ in onConfirm() }
|
||||
),
|
||||
UIAlertAction(title: NSLocalizedString("Cancel", comment: "alert button"), style: .cancel, handler: { _ in onCancel() })
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
private func deleteContactOrConversationDialog(
|
||||
_ chat: Chat,
|
||||
_ contact: Contact,
|
||||
|
@ -1254,7 +1356,7 @@ struct ChatInfoView_Previews: PreviewProvider {
|
|||
localAlias: "",
|
||||
featuresAllowed: contactUserPrefsToFeaturesAllowed(Contact.sampleData.mergedPreferences),
|
||||
currentFeaturesAllowed: contactUserPrefsToFeaturesAllowed(Contact.sampleData.mergedPreferences),
|
||||
onSearch: {}
|
||||
onSearch: {}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -50,7 +50,7 @@ struct CICallItemView: View {
|
|||
Image(systemName: "phone.connection").foregroundColor(.green)
|
||||
}
|
||||
|
||||
@ViewBuilder private func endedCallIcon(_ sent: Bool) -> some View {
|
||||
private func endedCallIcon(_ sent: Bool) -> some View {
|
||||
HStack {
|
||||
Image(systemName: "phone.down")
|
||||
Text(durationText(duration)).foregroundColor(theme.colors.secondary)
|
||||
|
@ -60,16 +60,16 @@ struct CICallItemView: View {
|
|||
|
||||
@ViewBuilder private func acceptCallButton() -> some View {
|
||||
if case let .direct(contact) = chat.chatInfo {
|
||||
Button {
|
||||
if let invitation = m.callInvitations[contact.id] {
|
||||
CallController.shared.answerCall(invitation: invitation)
|
||||
logger.debug("acceptCallButton call answered")
|
||||
} else {
|
||||
AlertManager.shared.showAlertMsg(title: "Call already ended!")
|
||||
}
|
||||
} label: {
|
||||
Label("Answer call", systemImage: "phone.arrow.down.left")
|
||||
}
|
||||
Label("Answer call", systemImage: "phone.arrow.down.left")
|
||||
.foregroundColor(theme.colors.primary)
|
||||
.simultaneousGesture(TapGesture().onEnded {
|
||||
if let invitation = m.callInvitations[contact.id] {
|
||||
CallController.shared.answerCall(invitation: invitation)
|
||||
logger.debug("acceptCallButton call answered")
|
||||
} else {
|
||||
AlertManager.shared.showAlertMsg(title: "Call already ended!")
|
||||
}
|
||||
})
|
||||
} else {
|
||||
Image(systemName: "phone.arrow.down.left").foregroundColor(theme.colors.secondary)
|
||||
}
|
||||
|
|
|
@ -26,9 +26,9 @@ struct CIFeaturePreferenceView: View {
|
|||
allowed != .no && ct.allowsFeature(feature) && !ct.userAllowsFeature(feature) {
|
||||
let setParam = feature == .timedMessages && ct.mergedPreferences.timedMessages.userPreference.preference.ttl == nil
|
||||
featurePreferenceView(acceptText: setParam ? "Set 1 day" : "Accept")
|
||||
.onTapGesture {
|
||||
.simultaneousGesture(TapGesture().onEnded {
|
||||
allowFeatureToContact(ct, feature, param: setParam ? 86400 : nil)
|
||||
}
|
||||
})
|
||||
} else {
|
||||
featurePreferenceView()
|
||||
}
|
||||
|
|
|
@ -19,42 +19,42 @@ struct CIFileView: View {
|
|||
var body: some View {
|
||||
if smallViewSize != nil {
|
||||
fileIndicator()
|
||||
.onTapGesture(perform: fileAction)
|
||||
.simultaneousGesture(TapGesture().onEnded(fileAction))
|
||||
} else {
|
||||
let metaReserve = edited
|
||||
? " "
|
||||
: " "
|
||||
Button(action: fileAction) {
|
||||
HStack(alignment: .bottom, spacing: 6) {
|
||||
fileIndicator()
|
||||
.padding(.top, 5)
|
||||
.padding(.bottom, 3)
|
||||
if let file = file {
|
||||
let prettyFileSize = ByteCountFormatter.string(fromByteCount: file.fileSize, countStyle: .binary)
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(file.fileName)
|
||||
.lineLimit(1)
|
||||
.multilineTextAlignment(.leading)
|
||||
.foregroundColor(theme.colors.onBackground)
|
||||
Text(prettyFileSize + metaReserve)
|
||||
.font(.caption)
|
||||
.lineLimit(1)
|
||||
.multilineTextAlignment(.leading)
|
||||
.foregroundColor(theme.colors.secondary)
|
||||
}
|
||||
} else {
|
||||
Text(metaReserve)
|
||||
HStack(alignment: .bottom, spacing: 6) {
|
||||
fileIndicator()
|
||||
.padding(.top, 5)
|
||||
.padding(.bottom, 3)
|
||||
if let file = file {
|
||||
let prettyFileSize = ByteCountFormatter.string(fromByteCount: file.fileSize, countStyle: .binary)
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(file.fileName)
|
||||
.lineLimit(1)
|
||||
.multilineTextAlignment(.leading)
|
||||
.foregroundColor(theme.colors.onBackground)
|
||||
Text(prettyFileSize + metaReserve)
|
||||
.font(.caption)
|
||||
.lineLimit(1)
|
||||
.multilineTextAlignment(.leading)
|
||||
.foregroundColor(theme.colors.secondary)
|
||||
}
|
||||
} else {
|
||||
Text(metaReserve)
|
||||
}
|
||||
.padding(.top, 4)
|
||||
.padding(.bottom, 6)
|
||||
.padding(.leading, 10)
|
||||
.padding(.trailing, 12)
|
||||
}
|
||||
.padding(.top, 4)
|
||||
.padding(.bottom, 6)
|
||||
.padding(.leading, 10)
|
||||
.padding(.trailing, 12)
|
||||
.simultaneousGesture(TapGesture().onEnded(fileAction))
|
||||
.disabled(!itemInteractive)
|
||||
}
|
||||
}
|
||||
|
||||
@inline(__always)
|
||||
private var itemInteractive: Bool {
|
||||
if let file = file {
|
||||
switch (file.fileStatus) {
|
||||
|
@ -118,16 +118,10 @@ struct CIFileView: View {
|
|||
}
|
||||
case let .rcvError(rcvFileError):
|
||||
logger.debug("CIFileView fileAction - in .rcvError")
|
||||
AlertManager.shared.showAlert(Alert(
|
||||
title: Text("File error"),
|
||||
message: Text(rcvFileError.errorInfo)
|
||||
))
|
||||
showFileErrorAlert(rcvFileError)
|
||||
case let .rcvWarning(rcvFileError):
|
||||
logger.debug("CIFileView fileAction - in .rcvWarning")
|
||||
AlertManager.shared.showAlert(Alert(
|
||||
title: Text("Temporary file error"),
|
||||
message: Text(rcvFileError.errorInfo)
|
||||
))
|
||||
showFileErrorAlert(rcvFileError, temporary: true)
|
||||
case .sndStored:
|
||||
logger.debug("CIFileView fileAction - in .sndStored")
|
||||
if file.fileProtocol == .local, let fileSource = getLoadedFileSource(file) {
|
||||
|
@ -140,16 +134,10 @@ struct CIFileView: View {
|
|||
}
|
||||
case let .sndError(sndFileError):
|
||||
logger.debug("CIFileView fileAction - in .sndError")
|
||||
AlertManager.shared.showAlert(Alert(
|
||||
title: Text("File error"),
|
||||
message: Text(sndFileError.errorInfo)
|
||||
))
|
||||
showFileErrorAlert(sndFileError)
|
||||
case let .sndWarning(sndFileError):
|
||||
logger.debug("CIFileView fileAction - in .sndWarning")
|
||||
AlertManager.shared.showAlert(Alert(
|
||||
title: Text("Temporary file error"),
|
||||
message: Text(sndFileError.errorInfo)
|
||||
))
|
||||
showFileErrorAlert(sndFileError, temporary: true)
|
||||
default: break
|
||||
}
|
||||
}
|
||||
|
@ -268,6 +256,26 @@ func saveCryptoFile(_ fileSource: CryptoFile) {
|
|||
}
|
||||
}
|
||||
|
||||
func showFileErrorAlert(_ err: FileError, temporary: Bool = false) {
|
||||
let title: String = if temporary {
|
||||
NSLocalizedString("Temporary file error", comment: "file error alert title")
|
||||
} else {
|
||||
NSLocalizedString("File error", comment: "file error alert title")
|
||||
}
|
||||
if let btn = err.moreInfoButton {
|
||||
showAlert(title, message: err.errorInfo) {
|
||||
[
|
||||
okAlertAction,
|
||||
UIAlertAction(title: NSLocalizedString("How it works", comment: "alert button"), style: .default, handler: { _ in
|
||||
UIApplication.shared.open(contentModerationPostLink)
|
||||
})
|
||||
]
|
||||
}
|
||||
} else {
|
||||
showAlert(title, message: err.errorInfo)
|
||||
}
|
||||
}
|
||||
|
||||
struct CIFileView_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
let sentFile: ChatItem = ChatItem(
|
||||
|
@ -285,16 +293,16 @@ struct CIFileView_Previews: PreviewProvider {
|
|||
file: nil
|
||||
)
|
||||
Group {
|
||||
ChatItemView(chat: Chat.sampleData, chatItem: sentFile)
|
||||
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getFileMsgContentSample())
|
||||
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getFileMsgContentSample(fileName: "some_long_file_name_here", fileStatus: .rcvInvitation))
|
||||
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getFileMsgContentSample(fileStatus: .rcvAccepted))
|
||||
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getFileMsgContentSample(fileStatus: .rcvTransfer(rcvProgress: 7, rcvTotal: 10)))
|
||||
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getFileMsgContentSample(fileStatus: .rcvCancelled))
|
||||
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getFileMsgContentSample(fileSize: 1_000_000_000, fileStatus: .rcvInvitation))
|
||||
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getFileMsgContentSample(text: "Hello there", fileStatus: .rcvInvitation))
|
||||
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getFileMsgContentSample(text: "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. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.", fileStatus: .rcvInvitation))
|
||||
ChatItemView(chat: Chat.sampleData, chatItem: fileChatItemWtFile)
|
||||
ChatItemView(chat: Chat.sampleData, chatItem: sentFile, scrollToItemId: { _ in })
|
||||
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getFileMsgContentSample(), scrollToItemId: { _ in })
|
||||
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getFileMsgContentSample(fileName: "some_long_file_name_here", fileStatus: .rcvInvitation), scrollToItemId: { _ in })
|
||||
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getFileMsgContentSample(fileStatus: .rcvAccepted), scrollToItemId: { _ in })
|
||||
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getFileMsgContentSample(fileStatus: .rcvTransfer(rcvProgress: 7, rcvTotal: 10)), scrollToItemId: { _ in })
|
||||
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getFileMsgContentSample(fileStatus: .rcvCancelled), scrollToItemId: { _ in })
|
||||
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getFileMsgContentSample(fileSize: 1_000_000_000, fileStatus: .rcvInvitation), scrollToItemId: { _ in })
|
||||
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getFileMsgContentSample(text: "Hello there", fileStatus: .rcvInvitation), scrollToItemId: { _ in })
|
||||
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getFileMsgContentSample(text: "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. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.", fileStatus: .rcvInvitation), scrollToItemId: { _ in })
|
||||
ChatItemView(chat: Chat.sampleData, chatItem: fileChatItemWtFile, scrollToItemId: { _ in })
|
||||
}
|
||||
.environment(\.revealed, false)
|
||||
.previewLayout(.fixed(width: 360, height: 360))
|
||||
|
|
|
@ -84,12 +84,12 @@ struct CIGroupInvitationView: View {
|
|||
}
|
||||
|
||||
if action {
|
||||
v.onTapGesture {
|
||||
v.simultaneousGesture(TapGesture().onEnded {
|
||||
inProgress = true
|
||||
joinGroup(groupInvitation.groupId) {
|
||||
await MainActor.run { inProgress = false }
|
||||
}
|
||||
}
|
||||
})
|
||||
.disabled(inProgress)
|
||||
} else {
|
||||
v
|
||||
|
|
|
@ -12,6 +12,7 @@ import SimpleXChat
|
|||
struct CIImageView: View {
|
||||
@EnvironmentObject var m: ChatModel
|
||||
let chatItem: ChatItem
|
||||
var scrollToItemId: ((ChatItem.ID) -> Void)? = nil
|
||||
var preview: UIImage?
|
||||
let maxWidth: CGFloat
|
||||
var imgWidth: CGFloat?
|
||||
|
@ -25,12 +26,14 @@ struct CIImageView: View {
|
|||
if let uiImage = getLoadedImage(file) {
|
||||
Group { if smallView { smallViewImageView(uiImage) } else { imageView(uiImage) } }
|
||||
.fullScreenCover(isPresented: $showFullScreenImage) {
|
||||
FullScreenMediaView(chatItem: chatItem, image: uiImage, showView: $showFullScreenImage)
|
||||
FullScreenMediaView(chatItem: chatItem, scrollToItemId: scrollToItemId, image: uiImage, showView: $showFullScreenImage)
|
||||
}
|
||||
.if(!smallView) { view in
|
||||
view.modifier(PrivacyBlur(blurred: $blurred))
|
||||
}
|
||||
.onTapGesture { showFullScreenImage = true }
|
||||
.if(!blurred) { v in
|
||||
v.simultaneousGesture(TapGesture().onEnded { showFullScreenImage = true })
|
||||
}
|
||||
.onChange(of: m.activeCallViewIsCollapsed) { _ in
|
||||
showFullScreenImage = false
|
||||
}
|
||||
|
@ -42,7 +45,7 @@ struct CIImageView: View {
|
|||
imageView(preview).modifier(PrivacyBlur(blurred: $blurred))
|
||||
}
|
||||
}
|
||||
.onTapGesture {
|
||||
.simultaneousGesture(TapGesture().onEnded {
|
||||
if let file = file {
|
||||
switch file.fileStatus {
|
||||
case .rcvInvitation, .rcvAborted:
|
||||
|
@ -69,29 +72,17 @@ struct CIImageView: View {
|
|||
case .rcvComplete: () // ?
|
||||
case .rcvCancelled: () // TODO
|
||||
case let .rcvError(rcvFileError):
|
||||
AlertManager.shared.showAlert(Alert(
|
||||
title: Text("File error"),
|
||||
message: Text(rcvFileError.errorInfo)
|
||||
))
|
||||
showFileErrorAlert(rcvFileError)
|
||||
case let .rcvWarning(rcvFileError):
|
||||
AlertManager.shared.showAlert(Alert(
|
||||
title: Text("Temporary file error"),
|
||||
message: Text(rcvFileError.errorInfo)
|
||||
))
|
||||
showFileErrorAlert(rcvFileError, temporary: true)
|
||||
case let .sndError(sndFileError):
|
||||
AlertManager.shared.showAlert(Alert(
|
||||
title: Text("File error"),
|
||||
message: Text(sndFileError.errorInfo)
|
||||
))
|
||||
showFileErrorAlert(sndFileError)
|
||||
case let .sndWarning(sndFileError):
|
||||
AlertManager.shared.showAlert(Alert(
|
||||
title: Text("Temporary file error"),
|
||||
message: Text(sndFileError.errorInfo)
|
||||
))
|
||||
showFileErrorAlert(sndFileError, temporary: true)
|
||||
default: ()
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
.onDisappear {
|
||||
|
|
|
@ -7,10 +7,11 @@
|
|||
//
|
||||
|
||||
import SwiftUI
|
||||
import SimpleXChat
|
||||
|
||||
struct CIInvalidJSONView: View {
|
||||
@EnvironmentObject var theme: AppTheme
|
||||
var json: String
|
||||
var json: Data?
|
||||
@State private var showJSON = false
|
||||
|
||||
var body: some View {
|
||||
|
@ -23,16 +24,16 @@ struct CIInvalidJSONView: View {
|
|||
.padding(.vertical, 6)
|
||||
.background(Color(uiColor: .tertiarySystemGroupedBackground))
|
||||
.textSelection(.disabled)
|
||||
.onTapGesture { showJSON = true }
|
||||
.simultaneousGesture(TapGesture().onEnded { showJSON = true })
|
||||
.appSheet(isPresented: $showJSON) {
|
||||
invalidJSONView(json)
|
||||
invalidJSONView(dataToString(json))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func invalidJSONView(_ json: String) -> some View {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
Button {
|
||||
Button { // this is used in the sheet, Button works here
|
||||
showShareSheet(items: [json])
|
||||
} label: {
|
||||
Image(systemName: "square.and.arrow.up")
|
||||
|
@ -49,6 +50,6 @@ func invalidJSONView(_ json: String) -> some View {
|
|||
|
||||
struct CIInvalidJSONView_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
CIInvalidJSONView(json: "{}")
|
||||
CIInvalidJSONView(json: "{}".data(using: .utf8)!)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -21,15 +21,15 @@ struct CILinkView: View {
|
|||
.resizable()
|
||||
.scaledToFit()
|
||||
.modifier(PrivacyBlur(blurred: $blurred))
|
||||
.if(!blurred) { v in
|
||||
v.simultaneousGesture(TapGesture().onEnded {
|
||||
openBrowserAlert(uri: linkPreview.uri)
|
||||
})
|
||||
}
|
||||
}
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text(linkPreview.title)
|
||||
.lineLimit(3)
|
||||
// if linkPreview.description != "" {
|
||||
// Text(linkPreview.description)
|
||||
// .font(.subheadline)
|
||||
// .lineLimit(12)
|
||||
// }
|
||||
Text(linkPreview.uri.absoluteString)
|
||||
.font(.caption)
|
||||
.lineLimit(1)
|
||||
|
@ -37,10 +37,32 @@ struct CILinkView: View {
|
|||
}
|
||||
.padding(.horizontal, 12)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.simultaneousGesture(TapGesture().onEnded {
|
||||
openBrowserAlert(uri: linkPreview.uri)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func openBrowserAlert(uri: URL) {
|
||||
showAlert(
|
||||
NSLocalizedString("Open link?", comment: "alert title"),
|
||||
message: uri.absoluteString,
|
||||
actions: {[
|
||||
UIAlertAction(
|
||||
title: NSLocalizedString("Cancel", comment: "alert action"),
|
||||
style: .default,
|
||||
handler: { _ in }
|
||||
),
|
||||
UIAlertAction(
|
||||
title: NSLocalizedString("Open", comment: "alert action"),
|
||||
style: .default,
|
||||
handler: { _ in UIApplication.shared.open(uri) }
|
||||
)
|
||||
]}
|
||||
)
|
||||
}
|
||||
|
||||
struct LargeLinkPreview_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
let preview = LinkPreview(
|
||||
|
|
|
@ -20,12 +20,11 @@ struct CIMemberCreatedContactView: View {
|
|||
case let .groupRcv(groupMember):
|
||||
if let contactId = groupMember.memberContactId {
|
||||
memberCreatedContactView(openText: "Open")
|
||||
.onTapGesture {
|
||||
dismissAllSheets(animated: true)
|
||||
DispatchQueue.main.async {
|
||||
ItemsModel.shared.loadOpenChat("@\(contactId)")
|
||||
.simultaneousGesture(TapGesture().onEnded {
|
||||
ItemsModel.shared.loadOpenChat("@\(contactId)") {
|
||||
dismissAllSheets(animated: true)
|
||||
}
|
||||
}
|
||||
})
|
||||
} else {
|
||||
memberCreatedContactView()
|
||||
}
|
||||
|
|
|
@ -15,7 +15,7 @@ struct CIMetaView: View {
|
|||
@Environment(\.showTimestamp) var showTimestamp: Bool
|
||||
var chatItem: ChatItem
|
||||
var metaColor: Color
|
||||
var paleMetaColor = Color(UIColor.tertiaryLabel)
|
||||
var paleMetaColor = Color(uiColor: .tertiaryLabel)
|
||||
var showStatus = true
|
||||
var showEdited = true
|
||||
var invertedMaterial = false
|
||||
|
@ -152,11 +152,13 @@ func ciMetaText(
|
|||
return r.font(.caption)
|
||||
}
|
||||
|
||||
@inline(__always)
|
||||
private func statusIconText(_ icon: String, _ color: Color?) -> Text {
|
||||
colored(Text(Image(systemName: icon)), color)
|
||||
}
|
||||
|
||||
// Applying `foregroundColor(nil)` breaks `.invertedForegroundStyle` modifier
|
||||
@inline(__always)
|
||||
private func colored(_ t: Text, _ color: Color?) -> Text {
|
||||
if let color {
|
||||
t.foregroundColor(color)
|
||||
|
|
|
@ -68,7 +68,7 @@ struct CIRcvDecryptionError: View {
|
|||
}
|
||||
}
|
||||
|
||||
@ViewBuilder private func viewBody() -> some View {
|
||||
private func viewBody() -> some View {
|
||||
Group {
|
||||
if case let .direct(contact) = chat.chatInfo,
|
||||
let contactStats = contact.activeConn?.connectionStats {
|
||||
|
@ -133,7 +133,7 @@ struct CIRcvDecryptionError: View {
|
|||
CIMetaView(chat: chat, chatItem: chatItem, metaColor: theme.colors.secondary)
|
||||
.padding(.horizontal, 12)
|
||||
}
|
||||
.onTapGesture(perform: { onClick() })
|
||||
.simultaneousGesture(TapGesture().onEnded(onClick))
|
||||
.padding(.vertical, 6)
|
||||
.textSelection(.disabled)
|
||||
}
|
||||
|
@ -151,7 +151,7 @@ struct CIRcvDecryptionError: View {
|
|||
CIMetaView(chat: chat, chatItem: chatItem, metaColor: theme.colors.secondary)
|
||||
.padding(.horizontal, 12)
|
||||
}
|
||||
.onTapGesture(perform: { onClick() })
|
||||
.simultaneousGesture(TapGesture().onEnded(onClick))
|
||||
.padding(.vertical, 6)
|
||||
.textSelection(.disabled)
|
||||
}
|
||||
|
@ -161,13 +161,13 @@ struct CIRcvDecryptionError: View {
|
|||
let why = Text(decryptErrorReason)
|
||||
switch msgDecryptError {
|
||||
case .ratchetHeader:
|
||||
message = Text("\(msgCount) messages failed to decrypt.") + Text("\n") + why
|
||||
message = Text("\(msgCount) messages failed to decrypt.") + textNewLine + why
|
||||
case .tooManySkipped:
|
||||
message = Text("\(msgCount) messages skipped.") + Text("\n") + why
|
||||
message = Text("\(msgCount) messages skipped.") + textNewLine + why
|
||||
case .ratchetEarlier:
|
||||
message = Text("\(msgCount) messages failed to decrypt.") + Text("\n") + why
|
||||
message = Text("\(msgCount) messages failed to decrypt.") + textNewLine + why
|
||||
case .other:
|
||||
message = Text("\(msgCount) messages failed to decrypt.") + Text("\n") + why
|
||||
message = Text("\(msgCount) messages failed to decrypt.") + textNewLine + why
|
||||
case .ratchetSync:
|
||||
message = Text("Encryption re-negotiation failed.")
|
||||
}
|
||||
|
|
|
@ -47,57 +47,57 @@ struct CIVideoView: View {
|
|||
let file = chatItem.file
|
||||
ZStack(alignment: smallView ? .topLeading : .center) {
|
||||
ZStack(alignment: .topLeading) {
|
||||
if let file = file, let preview = preview, let decrypted = urlDecrypted, smallView {
|
||||
smallVideoView(decrypted, file, preview)
|
||||
} else if let file = file, let preview = preview, let player = player, let decrypted = urlDecrypted {
|
||||
videoView(player, decrypted, file, preview, duration)
|
||||
} else if let file = file, let defaultPreview = preview, file.loaded && urlDecrypted == nil, smallView {
|
||||
smallVideoViewEncrypted(file, defaultPreview)
|
||||
} else if let file = file, let defaultPreview = preview, file.loaded && urlDecrypted == nil {
|
||||
videoViewEncrypted(file, defaultPreview, duration)
|
||||
} else if let preview, let file {
|
||||
Group { if smallView { smallViewImageView(preview, file) } else { imageView(preview) } }
|
||||
.onTapGesture {
|
||||
switch file.fileStatus {
|
||||
case .rcvInvitation, .rcvAborted:
|
||||
receiveFileIfValidSize(file: file, receiveFile: receiveFile)
|
||||
case .rcvAccepted:
|
||||
switch file.fileProtocol {
|
||||
case .xftp:
|
||||
AlertManager.shared.showAlertMsg(
|
||||
title: "Waiting for video",
|
||||
message: "Video will be received when your contact completes uploading it."
|
||||
)
|
||||
case .smp:
|
||||
AlertManager.shared.showAlertMsg(
|
||||
title: "Waiting for video",
|
||||
message: "Video will be received when your contact is online, please wait or check later!"
|
||||
)
|
||||
case .local: ()
|
||||
}
|
||||
case .rcvTransfer: () // ?
|
||||
case .rcvComplete: () // ?
|
||||
case .rcvCancelled: () // TODO
|
||||
default: ()
|
||||
}
|
||||
if let file, let preview {
|
||||
if let urlDecrypted {
|
||||
if smallView {
|
||||
smallVideoView(urlDecrypted, file, preview)
|
||||
} else if let player {
|
||||
videoView(player, urlDecrypted, file, preview, duration)
|
||||
}
|
||||
} else if file.loaded {
|
||||
if smallView {
|
||||
smallVideoViewEncrypted(file, preview)
|
||||
} else {
|
||||
videoViewEncrypted(file, preview, duration)
|
||||
}
|
||||
} else {
|
||||
Group { if smallView { smallViewImageView(preview, file) } else { imageView(preview) } }
|
||||
.simultaneousGesture(TapGesture().onEnded {
|
||||
switch file.fileStatus {
|
||||
case .rcvInvitation, .rcvAborted:
|
||||
receiveFileIfValidSize(file: file, receiveFile: receiveFile)
|
||||
case .rcvAccepted:
|
||||
switch file.fileProtocol {
|
||||
case .xftp:
|
||||
AlertManager.shared.showAlertMsg(
|
||||
title: "Waiting for video",
|
||||
message: "Video will be received when your contact completes uploading it."
|
||||
)
|
||||
case .smp:
|
||||
AlertManager.shared.showAlertMsg(
|
||||
title: "Waiting for video",
|
||||
message: "Video will be received when your contact is online, please wait or check later!"
|
||||
)
|
||||
case .local: ()
|
||||
}
|
||||
case .rcvTransfer: () // ?
|
||||
case .rcvComplete: () // ?
|
||||
case .rcvCancelled: () // TODO
|
||||
default: ()
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
if !smallView {
|
||||
durationProgress()
|
||||
}
|
||||
}
|
||||
if !blurred, let file, showDownloadButton(file.fileStatus) {
|
||||
if !smallView {
|
||||
Button {
|
||||
receiveFileIfValidSize(file: file, receiveFile: receiveFile)
|
||||
} label: {
|
||||
playPauseIcon("play.fill")
|
||||
}
|
||||
} else if !file.showStatusIconInSmallView {
|
||||
if !smallView || !file.showStatusIconInSmallView {
|
||||
playPauseIcon("play.fill")
|
||||
.onTapGesture {
|
||||
.simultaneousGesture(TapGesture().onEnded {
|
||||
receiveFileIfValidSize(file: file, receiveFile: receiveFile)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -151,27 +151,26 @@ struct CIVideoView: View {
|
|||
ZStack(alignment: .center) {
|
||||
let canBePlayed = !chatItem.chatDir.sent || file.fileStatus == CIFileStatus.sndComplete || (file.fileStatus == .sndStored && file.fileProtocol == .local)
|
||||
imageView(defaultPreview)
|
||||
.onTapGesture {
|
||||
.simultaneousGesture(TapGesture().onEnded {
|
||||
decrypt(file: file) {
|
||||
showFullScreenPlayer = urlDecrypted != nil
|
||||
}
|
||||
}
|
||||
})
|
||||
.onChange(of: m.activeCallViewIsCollapsed) { _ in
|
||||
showFullScreenPlayer = false
|
||||
}
|
||||
if !blurred {
|
||||
if !decryptionInProgress {
|
||||
Button {
|
||||
decrypt(file: file) {
|
||||
if urlDecrypted != nil {
|
||||
videoPlaying = true
|
||||
player?.play()
|
||||
playPauseIcon(canBePlayed ? "play.fill" : "play.slash")
|
||||
.simultaneousGesture(TapGesture().onEnded {
|
||||
decrypt(file: file) {
|
||||
if urlDecrypted != nil {
|
||||
videoPlaying = true
|
||||
player?.play()
|
||||
}
|
||||
}
|
||||
}
|
||||
} label: {
|
||||
playPauseIcon(canBePlayed ? "play.fill" : "play.slash")
|
||||
}
|
||||
.disabled(!canBePlayed)
|
||||
})
|
||||
.disabled(!canBePlayed)
|
||||
} else {
|
||||
videoDecryptionProgress()
|
||||
}
|
||||
|
@ -194,29 +193,30 @@ struct CIVideoView: View {
|
|||
}
|
||||
}
|
||||
.modifier(PrivacyBlur(enabled: !videoPlaying, blurred: $blurred))
|
||||
.onTapGesture {
|
||||
switch player.timeControlStatus {
|
||||
case .playing:
|
||||
player.pause()
|
||||
videoPlaying = false
|
||||
case .paused:
|
||||
if canBePlayed {
|
||||
showFullScreenPlayer = true
|
||||
.if(!blurred) { v in
|
||||
v.simultaneousGesture(TapGesture().onEnded {
|
||||
switch player.timeControlStatus {
|
||||
case .playing:
|
||||
player.pause()
|
||||
videoPlaying = false
|
||||
case .paused:
|
||||
if canBePlayed {
|
||||
showFullScreenPlayer = true
|
||||
}
|
||||
default: ()
|
||||
}
|
||||
default: ()
|
||||
}
|
||||
})
|
||||
}
|
||||
.onChange(of: m.activeCallViewIsCollapsed) { _ in
|
||||
showFullScreenPlayer = false
|
||||
}
|
||||
if !videoPlaying && !blurred {
|
||||
Button {
|
||||
m.stopPreviousRecPlay = url
|
||||
player.play()
|
||||
} label: {
|
||||
playPauseIcon(canBePlayed ? "play.fill" : "play.slash")
|
||||
}
|
||||
.disabled(!canBePlayed)
|
||||
playPauseIcon(canBePlayed ? "play.fill" : "play.slash")
|
||||
.simultaneousGesture(TapGesture().onEnded {
|
||||
m.stopPreviousRecPlay = url
|
||||
player.play()
|
||||
})
|
||||
.disabled(!canBePlayed)
|
||||
}
|
||||
}
|
||||
fileStatusIcon()
|
||||
|
@ -235,7 +235,7 @@ struct CIVideoView: View {
|
|||
return ZStack(alignment: .topLeading) {
|
||||
let canBePlayed = !chatItem.chatDir.sent || file.fileStatus == CIFileStatus.sndComplete || (file.fileStatus == .sndStored && file.fileProtocol == .local)
|
||||
smallViewImageView(preview, file)
|
||||
.onTapGesture {
|
||||
.onTapGesture { // this is shown in chat list, where onTapGesture works
|
||||
decrypt(file: file) {
|
||||
showFullScreenPlayer = urlDecrypted != nil
|
||||
}
|
||||
|
@ -256,7 +256,7 @@ struct CIVideoView: View {
|
|||
private func smallVideoView(_ url: URL, _ file: CIFile, _ preview: UIImage) -> some View {
|
||||
return ZStack(alignment: .topLeading) {
|
||||
smallViewImageView(preview, file)
|
||||
.onTapGesture {
|
||||
.onTapGesture { // this is shown in chat list, where onTapGesture works
|
||||
showFullScreenPlayer = true
|
||||
}
|
||||
.onChange(of: m.activeCallViewIsCollapsed) { _ in
|
||||
|
@ -354,20 +354,14 @@ struct CIVideoView: View {
|
|||
case .sndCancelled: fileIcon("xmark", 10, 13)
|
||||
case let .sndError(sndFileError):
|
||||
fileIcon("xmark", 10, 13)
|
||||
.onTapGesture {
|
||||
AlertManager.shared.showAlert(Alert(
|
||||
title: Text("File error"),
|
||||
message: Text(sndFileError.errorInfo)
|
||||
))
|
||||
}
|
||||
.simultaneousGesture(TapGesture().onEnded {
|
||||
showFileErrorAlert(sndFileError)
|
||||
})
|
||||
case let .sndWarning(sndFileError):
|
||||
fileIcon("exclamationmark.triangle.fill", 10, 13)
|
||||
.onTapGesture {
|
||||
AlertManager.shared.showAlert(Alert(
|
||||
title: Text("Temporary file error"),
|
||||
message: Text(sndFileError.errorInfo)
|
||||
))
|
||||
}
|
||||
.simultaneousGesture(TapGesture().onEnded {
|
||||
showFileErrorAlert(sndFileError, temporary: true)
|
||||
})
|
||||
case .rcvInvitation: fileIcon("arrow.down", 10, 13)
|
||||
case .rcvAccepted: fileIcon("ellipsis", 14, 11)
|
||||
case let .rcvTransfer(rcvProgress, rcvTotal):
|
||||
|
@ -381,20 +375,14 @@ struct CIVideoView: View {
|
|||
case .rcvCancelled: fileIcon("xmark", 10, 13)
|
||||
case let .rcvError(rcvFileError):
|
||||
fileIcon("xmark", 10, 13)
|
||||
.onTapGesture {
|
||||
AlertManager.shared.showAlert(Alert(
|
||||
title: Text("File error"),
|
||||
message: Text(rcvFileError.errorInfo)
|
||||
))
|
||||
}
|
||||
.simultaneousGesture(TapGesture().onEnded {
|
||||
showFileErrorAlert(rcvFileError)
|
||||
})
|
||||
case let .rcvWarning(rcvFileError):
|
||||
fileIcon("exclamationmark.triangle.fill", 10, 13)
|
||||
.onTapGesture {
|
||||
AlertManager.shared.showAlert(Alert(
|
||||
title: Text("Temporary file error"),
|
||||
message: Text(rcvFileError.errorInfo)
|
||||
))
|
||||
}
|
||||
.simultaneousGesture(TapGesture().onEnded {
|
||||
showFileErrorAlert(rcvFileError, temporary: true)
|
||||
})
|
||||
case .invalid: fileIcon("questionmark", 10, 13)
|
||||
}
|
||||
}
|
||||
|
@ -441,7 +429,7 @@ struct CIVideoView: View {
|
|||
Color.black.edgesIgnoringSafeArea(.all)
|
||||
VideoPlayer(player: fullPlayer)
|
||||
.overlay(alignment: .topLeading, content: {
|
||||
Button(action: { showFullScreenPlayer = false },
|
||||
Button(action: { showFullScreenPlayer = false }, // this is used in full screen player, Button works here
|
||||
label: {
|
||||
Image(systemName: "multiply")
|
||||
.resizable()
|
||||
|
|
|
@ -168,20 +168,14 @@ struct VoiceMessagePlayer: View {
|
|||
case .sndCancelled: playbackButton()
|
||||
case let .sndError(sndFileError):
|
||||
fileStatusIcon("multiply", 14)
|
||||
.onTapGesture {
|
||||
AlertManager.shared.showAlert(Alert(
|
||||
title: Text("File error"),
|
||||
message: Text(sndFileError.errorInfo)
|
||||
))
|
||||
}
|
||||
.simultaneousGesture(TapGesture().onEnded {
|
||||
showFileErrorAlert(sndFileError)
|
||||
})
|
||||
case let .sndWarning(sndFileError):
|
||||
fileStatusIcon("exclamationmark.triangle.fill", 16)
|
||||
.onTapGesture {
|
||||
AlertManager.shared.showAlert(Alert(
|
||||
title: Text("Temporary file error"),
|
||||
message: Text(sndFileError.errorInfo)
|
||||
))
|
||||
}
|
||||
.simultaneousGesture(TapGesture().onEnded {
|
||||
showFileErrorAlert(sndFileError, temporary: true)
|
||||
})
|
||||
case .rcvInvitation: downloadButton(recordingFile, "play.fill")
|
||||
case .rcvAccepted: loadingIcon()
|
||||
case .rcvTransfer: loadingIcon()
|
||||
|
@ -190,20 +184,14 @@ struct VoiceMessagePlayer: View {
|
|||
case .rcvCancelled: playPauseIcon("play.fill", Color(uiColor: .tertiaryLabel))
|
||||
case let .rcvError(rcvFileError):
|
||||
fileStatusIcon("multiply", 14)
|
||||
.onTapGesture {
|
||||
AlertManager.shared.showAlert(Alert(
|
||||
title: Text("File error"),
|
||||
message: Text(rcvFileError.errorInfo)
|
||||
))
|
||||
}
|
||||
.simultaneousGesture(TapGesture().onEnded {
|
||||
showFileErrorAlert(rcvFileError)
|
||||
})
|
||||
case let .rcvWarning(rcvFileError):
|
||||
fileStatusIcon("exclamationmark.triangle.fill", 16)
|
||||
.onTapGesture {
|
||||
AlertManager.shared.showAlert(Alert(
|
||||
title: Text("Temporary file error"),
|
||||
message: Text(rcvFileError.errorInfo)
|
||||
))
|
||||
}
|
||||
.simultaneousGesture(TapGesture().onEnded {
|
||||
showFileErrorAlert(rcvFileError, temporary: true)
|
||||
})
|
||||
case .invalid: playPauseIcon("play.fill", Color(uiColor: .tertiaryLabel))
|
||||
}
|
||||
} else {
|
||||
|
@ -267,59 +255,29 @@ struct VoiceMessagePlayer: View {
|
|||
}
|
||||
}
|
||||
|
||||
@ViewBuilder private func playbackButton() -> some View {
|
||||
if sizeMultiplier != 1 {
|
||||
switch playbackState {
|
||||
case .noPlayback:
|
||||
playPauseIcon("play.fill", theme.colors.primary)
|
||||
.onTapGesture {
|
||||
if let recordingSource = getLoadedFileSource(recordingFile) {
|
||||
startPlayback(recordingSource)
|
||||
}
|
||||
}
|
||||
case .playing:
|
||||
playPauseIcon("pause.fill", theme.colors.primary)
|
||||
.onTapGesture {
|
||||
audioPlayer?.pause()
|
||||
playbackState = .paused
|
||||
notifyStateChange()
|
||||
}
|
||||
case .paused:
|
||||
playPauseIcon("play.fill", theme.colors.primary)
|
||||
.onTapGesture {
|
||||
audioPlayer?.play()
|
||||
playbackState = .playing
|
||||
notifyStateChange()
|
||||
}
|
||||
}
|
||||
} else {
|
||||
switch playbackState {
|
||||
case .noPlayback:
|
||||
Button {
|
||||
private func playbackButton() -> some View {
|
||||
let icon = switch playbackState {
|
||||
case .noPlayback: "play.fill"
|
||||
case .playing: "pause.fill"
|
||||
case .paused: "play.fill"
|
||||
}
|
||||
return playPauseIcon(icon, theme.colors.primary)
|
||||
.simultaneousGesture(TapGesture().onEnded { _ in
|
||||
switch playbackState {
|
||||
case .noPlayback:
|
||||
if let recordingSource = getLoadedFileSource(recordingFile) {
|
||||
startPlayback(recordingSource)
|
||||
}
|
||||
} label: {
|
||||
playPauseIcon("play.fill", theme.colors.primary)
|
||||
}
|
||||
case .playing:
|
||||
Button {
|
||||
case .playing:
|
||||
audioPlayer?.pause()
|
||||
playbackState = .paused
|
||||
notifyStateChange()
|
||||
} label: {
|
||||
playPauseIcon("pause.fill", theme.colors.primary)
|
||||
}
|
||||
case .paused:
|
||||
Button {
|
||||
case .paused:
|
||||
audioPlayer?.play()
|
||||
playbackState = .playing
|
||||
notifyStateChange()
|
||||
} label: {
|
||||
playPauseIcon("play.fill", theme.colors.primary)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
private func playPauseIcon(_ image: String, _ color: Color/* = .accentColor*/) -> some View {
|
||||
|
@ -341,28 +299,14 @@ struct VoiceMessagePlayer: View {
|
|||
}
|
||||
|
||||
private func downloadButton(_ recordingFile: CIFile, _ icon: String) -> some View {
|
||||
Group {
|
||||
if sizeMultiplier != 1 {
|
||||
playPauseIcon(icon, theme.colors.primary)
|
||||
.onTapGesture {
|
||||
Task {
|
||||
if let user = chatModel.currentUser {
|
||||
await receiveFile(user: user, fileId: recordingFile.fileId)
|
||||
}
|
||||
}
|
||||
playPauseIcon(icon, theme.colors.primary)
|
||||
.simultaneousGesture(TapGesture().onEnded {
|
||||
Task {
|
||||
if let user = chatModel.currentUser {
|
||||
await receiveFile(user: user, fileId: recordingFile.fileId)
|
||||
}
|
||||
} else {
|
||||
Button {
|
||||
Task {
|
||||
if let user = chatModel.currentUser {
|
||||
await receiveFile(user: user, fileId: recordingFile.fileId)
|
||||
}
|
||||
}
|
||||
} label: {
|
||||
playPauseIcon(icon, theme.colors.primary)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func notifyStateChange() {
|
||||
|
@ -442,6 +386,7 @@ struct VoiceMessagePlayer: View {
|
|||
}
|
||||
}
|
||||
|
||||
@inline(__always)
|
||||
func voiceMessageSizeBasedOnSquareSize(_ squareSize: CGFloat) -> CGFloat {
|
||||
let squareToCircleRatio = 0.935
|
||||
return squareSize + squareSize * (1 - squareToCircleRatio)
|
||||
|
@ -458,10 +403,12 @@ class VoiceItemState {
|
|||
self.playbackTime = playbackTime
|
||||
}
|
||||
|
||||
@inline(__always)
|
||||
static func id(_ chat: Chat, _ chatItem: ChatItem) -> String {
|
||||
"\(chat.id) \(chatItem.id)"
|
||||
}
|
||||
|
||||
@inline(__always)
|
||||
static func id(_ chatInfo: ChatInfo, _ chatItem: ChatItem) -> String {
|
||||
"\(chatInfo.id) \(chatItem.id)"
|
||||
}
|
||||
|
@ -510,10 +457,10 @@ struct CIVoiceView_Previews: PreviewProvider {
|
|||
duration: 30,
|
||||
allowMenu: Binding.constant(true)
|
||||
)
|
||||
ChatItemView(chat: Chat.sampleData, chatItem: sentVoiceMessage, allowMenu: .constant(true))
|
||||
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getVoiceMsgContentSample(), allowMenu: .constant(true))
|
||||
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getVoiceMsgContentSample(fileStatus: .rcvTransfer(rcvProgress: 7, rcvTotal: 10)), allowMenu: .constant(true))
|
||||
ChatItemView(chat: Chat.sampleData, chatItem: voiceMessageWtFile, allowMenu: .constant(true))
|
||||
ChatItemView(chat: Chat.sampleData, chatItem: sentVoiceMessage, scrollToItemId: { _ in }, allowMenu: .constant(true))
|
||||
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getVoiceMsgContentSample(), scrollToItemId: { _ in }, allowMenu: .constant(true))
|
||||
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getVoiceMsgContentSample(fileStatus: .rcvTransfer(rcvProgress: 7, rcvTotal: 10)), scrollToItemId: { _ in }, allowMenu: .constant(true))
|
||||
ChatItemView(chat: Chat.sampleData, chatItem: voiceMessageWtFile, scrollToItemId: { _ in }, allowMenu: .constant(true))
|
||||
}
|
||||
.previewLayout(.fixed(width: 360, height: 360))
|
||||
}
|
||||
|
|
|
@ -92,11 +92,11 @@ struct FramedCIVoiceView_Previews: PreviewProvider {
|
|||
file: CIFile.getSample(fileStatus: .sndComplete)
|
||||
)
|
||||
Group {
|
||||
ChatItemView(chat: Chat.sampleData, chatItem: sentVoiceMessage)
|
||||
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getVoiceMsgContentSample(text: "Hello there"))
|
||||
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getVoiceMsgContentSample(text: "Hello there", fileStatus: .rcvTransfer(rcvProgress: 7, rcvTotal: 10)))
|
||||
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getVoiceMsgContentSample(text: "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. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum."))
|
||||
ChatItemView(chat: Chat.sampleData, chatItem: voiceMessageWithQuote)
|
||||
ChatItemView(chat: Chat.sampleData, chatItem: sentVoiceMessage, scrollToItemId: { _ in })
|
||||
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getVoiceMsgContentSample(text: "Hello there"), scrollToItemId: { _ in })
|
||||
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getVoiceMsgContentSample(text: "Hello there", fileStatus: .rcvTransfer(rcvProgress: 7, rcvTotal: 10)), scrollToItemId: { _ in })
|
||||
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getVoiceMsgContentSample(text: "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. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum."), scrollToItemId: { _ in })
|
||||
ChatItemView(chat: Chat.sampleData, chatItem: voiceMessageWithQuote, scrollToItemId: { _ in })
|
||||
}
|
||||
.environment(\.revealed, false)
|
||||
.previewLayout(.fixed(width: 360, height: 360))
|
||||
|
|
File diff suppressed because one or more lines are too long
|
@ -13,8 +13,8 @@ import AVKit
|
|||
|
||||
struct FullScreenMediaView: View {
|
||||
@EnvironmentObject var m: ChatModel
|
||||
@EnvironmentObject var scrollModel: ReverseListScrollModel
|
||||
@State var chatItem: ChatItem
|
||||
var scrollToItemId: ((ChatItem.ID) -> Void)?
|
||||
@State var image: UIImage?
|
||||
@State var player: AVPlayer? = nil
|
||||
@State var url: URL? = nil
|
||||
|
@ -71,7 +71,7 @@ struct FullScreenMediaView: View {
|
|||
let w = abs(t.width)
|
||||
if t.height > 60 && t.height > w * 2 {
|
||||
showView = false
|
||||
scrollModel.scrollToItem(id: chatItem.id)
|
||||
scrollToItemId?(chatItem.id)
|
||||
} else if w > 60 && w > abs(t.height) * 2 && !scrolling {
|
||||
let previous = t.width > 0
|
||||
scrolling = true
|
||||
|
@ -126,7 +126,7 @@ struct FullScreenMediaView: View {
|
|||
.scaledToFit()
|
||||
}
|
||||
}
|
||||
.onTapGesture { showView = false }
|
||||
.onTapGesture { showView = false } // this is used in full screen view, onTapGesture works
|
||||
}
|
||||
|
||||
private func videoView( _ player: AVPlayer, _ url: URL) -> some View {
|
||||
|
|
|
@ -31,8 +31,8 @@ struct IntegrityErrorItemView: View {
|
|||
case .msgBadHash:
|
||||
AlertManager.shared.showAlert(Alert(
|
||||
title: Text("Bad message hash"),
|
||||
message: Text("The hash of the previous message is different.") + Text("\n") +
|
||||
Text(decryptErrorReason) + Text("\n") +
|
||||
message: Text("The hash of the previous message is different.") + textNewLine +
|
||||
Text(decryptErrorReason) + textNewLine +
|
||||
Text("Please report it to the developers.")
|
||||
))
|
||||
case .msgBadId: msgBadIdAlert()
|
||||
|
@ -47,7 +47,7 @@ struct IntegrityErrorItemView: View {
|
|||
message: Text("""
|
||||
The ID of the next message is incorrect (less or equal to the previous).
|
||||
It can happen because of some bug or when the connection is compromised.
|
||||
""") + Text("\n") +
|
||||
""") + textNewLine +
|
||||
Text("Please report it to the developers.")
|
||||
))
|
||||
}
|
||||
|
@ -71,7 +71,7 @@ struct CIMsgError: View {
|
|||
.padding(.vertical, 6)
|
||||
.background { chatItemFrameColor(chatItem, theme).modifier(ChatTailPadding()) }
|
||||
.textSelection(.disabled)
|
||||
.onTapGesture(perform: onTap)
|
||||
.simultaneousGesture(TapGesture().onEnded(onTap))
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -67,11 +67,15 @@ struct MarkedDeletedItemView: View {
|
|||
// same texts are in markedDeletedText in ChatPreviewView, but it returns String;
|
||||
// can be refactored into a single function if functions calling these are changed to return same type
|
||||
var markedDeletedText: LocalizedStringKey {
|
||||
switch chatItem.meta.itemDeleted {
|
||||
case let .moderated(_, byGroupMember): "moderated by \(byGroupMember.displayName)"
|
||||
case .blocked: "blocked"
|
||||
case .blockedByAdmin: "blocked by admin"
|
||||
case .deleted, nil: "marked deleted"
|
||||
if chatItem.meta.itemDeleted != nil, chatItem.isReport {
|
||||
"archived report"
|
||||
} else {
|
||||
switch chatItem.meta.itemDeleted {
|
||||
case let .moderated(_, byGroupMember): "moderated by \(byGroupMember.displayName)"
|
||||
case .blocked: "blocked"
|
||||
case .blockedByAdmin: "blocked by admin"
|
||||
case .deleted, nil: "marked deleted"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -11,50 +11,74 @@ import SimpleXChat
|
|||
|
||||
let uiLinkColor = UIColor(red: 0, green: 0.533, blue: 1, alpha: 1)
|
||||
|
||||
private let noTyping = Text(verbatim: " ")
|
||||
|
||||
private let typingIndicators: [Text] = [
|
||||
(typing(.black) + typing() + typing()),
|
||||
(typing(.bold) + typing(.black) + typing()),
|
||||
(typing() + typing(.bold) + typing(.black)),
|
||||
(typing() + typing() + typing(.bold))
|
||||
]
|
||||
|
||||
private func typing(_ w: Font.Weight = .light) -> Text {
|
||||
Text(".").fontWeight(w)
|
||||
private func typing(_ theme: AppTheme, _ descr: UIFontDescriptor, _ ws: [UIFont.Weight]) -> NSMutableAttributedString {
|
||||
let res = NSMutableAttributedString()
|
||||
for w in ws {
|
||||
res.append(NSAttributedString(string: ".", attributes: [
|
||||
.font: UIFont.monospacedSystemFont(ofSize: descr.pointSize, weight: w),
|
||||
.kern: -2 as NSNumber,
|
||||
.foregroundColor: UIColor(theme.colors.secondary)
|
||||
]))
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
struct MsgContentView: View {
|
||||
@ObservedObject var chat: Chat
|
||||
@Environment(\.showTimestamp) var showTimestamp: Bool
|
||||
@Environment(\.containerBackground) var containerBackground: UIColor
|
||||
@EnvironmentObject var theme: AppTheme
|
||||
var text: String
|
||||
var formattedText: [FormattedText]? = nil
|
||||
var textStyle: UIFont.TextStyle
|
||||
var sender: String? = nil
|
||||
var meta: CIMeta? = nil
|
||||
var mentions: [String: CIMention]? = nil
|
||||
var userMemberId: String? = nil
|
||||
var rightToLeft = false
|
||||
var showSecrets: Bool
|
||||
var prefix: NSAttributedString? = nil
|
||||
@State private var showSecrets: Set<Int> = []
|
||||
@State private var typingIdx = 0
|
||||
@State private var timer: Timer?
|
||||
@State private var typingIndicators: [NSAttributedString] = []
|
||||
@State private var noTyping = NSAttributedString(string: " ")
|
||||
@State private var phase: CGFloat = 0
|
||||
|
||||
@AppStorage(DEFAULT_SHOW_SENT_VIA_RPOXY) private var showSentViaProxy = false
|
||||
|
||||
var body: some View {
|
||||
let v = msgContentView()
|
||||
if meta?.isLive == true {
|
||||
msgContentView()
|
||||
.onAppear { switchTyping() }
|
||||
v.onAppear {
|
||||
let descr = UIFontDescriptor.preferredFontDescriptor(withTextStyle: .body)
|
||||
noTyping = NSAttributedString(string: " ", attributes: [
|
||||
.font: UIFont.monospacedSystemFont(ofSize: descr.pointSize, weight: .regular),
|
||||
.kern: -2 as NSNumber,
|
||||
.foregroundColor: UIColor(theme.colors.secondary)
|
||||
])
|
||||
switchTyping()
|
||||
}
|
||||
.onDisappear(perform: stopTyping)
|
||||
.onChange(of: meta?.isLive, perform: switchTyping)
|
||||
.onChange(of: meta?.recent, perform: switchTyping)
|
||||
} else {
|
||||
msgContentView()
|
||||
v
|
||||
}
|
||||
}
|
||||
|
||||
private func switchTyping(_: Bool? = nil) {
|
||||
if let meta = meta, meta.isLive && meta.recent {
|
||||
if typingIndicators.isEmpty {
|
||||
let descr = UIFontDescriptor.preferredFontDescriptor(withTextStyle: .body)
|
||||
typingIndicators = [
|
||||
typing(theme, descr, [.black, .light, .light]),
|
||||
typing(theme, descr, [.bold, .black, .light]),
|
||||
typing(theme, descr, [.light, .bold, .black]),
|
||||
typing(theme, descr, [.light, .light, .bold])
|
||||
]
|
||||
}
|
||||
timer = timer ?? Timer.scheduledTimer(withTimeInterval: 0.25, repeats: true) { _ in
|
||||
typingIdx = (typingIdx + 1) % typingIndicators.count
|
||||
typingIdx = typingIdx + 1
|
||||
}
|
||||
} else {
|
||||
stopTyping()
|
||||
|
@ -64,95 +88,276 @@ struct MsgContentView: View {
|
|||
private func stopTyping() {
|
||||
timer?.invalidate()
|
||||
timer = nil
|
||||
typingIdx = 0
|
||||
}
|
||||
|
||||
private func msgContentView() -> Text {
|
||||
var v = messageText(text, formattedText, sender, showSecrets: showSecrets, secondaryColor: theme.colors.secondary)
|
||||
@inline(__always)
|
||||
private func msgContentView() -> some View {
|
||||
let r = messageText(text, formattedText, textStyle: textStyle, sender: sender, mentions: mentions, userMemberId: userMemberId, showSecrets: showSecrets, backgroundColor: containerBackground, prefix: prefix)
|
||||
let s = r.string
|
||||
let t: Text
|
||||
if let mt = meta {
|
||||
if mt.isLive {
|
||||
v = v + typingIndicator(mt.recent)
|
||||
s.append(typingIndicator(mt.recent))
|
||||
}
|
||||
v = v + reserveSpaceForMeta(mt)
|
||||
t = Text(AttributedString(s)) + reserveSpaceForMeta(mt)
|
||||
} else {
|
||||
t = Text(AttributedString(s))
|
||||
}
|
||||
return v
|
||||
return msgTextResultView(r, t, showSecrets: $showSecrets)
|
||||
}
|
||||
|
||||
private func typingIndicator(_ recent: Bool) -> Text {
|
||||
return (recent ? typingIndicators[typingIdx] : noTyping)
|
||||
.font(.body.monospaced())
|
||||
.kerning(-2)
|
||||
.foregroundColor(theme.colors.secondary)
|
||||
@inline(__always)
|
||||
private func typingIndicator(_ recent: Bool) -> NSAttributedString {
|
||||
recent && !typingIndicators.isEmpty
|
||||
? typingIndicators[typingIdx % 4]
|
||||
: noTyping
|
||||
}
|
||||
|
||||
@inline(__always)
|
||||
private func reserveSpaceForMeta(_ mt: CIMeta) -> Text {
|
||||
(rightToLeft ? Text("\n") : Text(verbatim: " ")) + ciMetaText(mt, chatTTL: chat.chatInfo.timedMessagesTTL, encrypted: nil, colorMode: .transparent, showViaProxy: showSentViaProxy, showTimesamp: showTimestamp)
|
||||
(rightToLeft ? textNewLine : Text(verbatim: " ")) + ciMetaText(mt, chatTTL: chat.chatInfo.timedMessagesTTL, encrypted: nil, colorMode: .transparent, showViaProxy: showSentViaProxy, showTimesamp: showTimestamp)
|
||||
}
|
||||
}
|
||||
|
||||
func messageText(_ text: String, _ formattedText: [FormattedText]?, _ sender: String?, icon: String? = nil, preview: Bool = false, showSecrets: Bool, secondaryColor: Color) -> Text {
|
||||
let s = text
|
||||
var res: Text
|
||||
if let ft = formattedText, ft.count > 0 && ft.count <= 200 {
|
||||
res = formatText(ft[0], preview, showSecret: showSecrets)
|
||||
var i = 1
|
||||
while i < ft.count {
|
||||
res = res + formatText(ft[i], preview, showSecret: showSecrets)
|
||||
i = i + 1
|
||||
}
|
||||
} else {
|
||||
res = Text(s)
|
||||
}
|
||||
|
||||
if let i = icon {
|
||||
res = Text(Image(systemName: i)).foregroundColor(secondaryColor) + textSpace + res
|
||||
}
|
||||
|
||||
if let s = sender {
|
||||
let t = Text(s)
|
||||
return (preview ? t : t.fontWeight(.medium)) + Text(": ") + res
|
||||
} else {
|
||||
return res
|
||||
}
|
||||
func msgTextResultView(_ r: MsgTextResult, _ t: Text, showSecrets: Binding<Set<Int>>? = nil) -> some View {
|
||||
t.if(r.hasSecrets, transform: hiddenSecretsView)
|
||||
.if(r.handleTaps) { $0.overlay(handleTextTaps(r.string, showSecrets: showSecrets)) }
|
||||
}
|
||||
|
||||
private func formatText(_ ft: FormattedText, _ preview: Bool, showSecret: Bool) -> Text {
|
||||
let t = ft.text
|
||||
if let f = ft.format {
|
||||
switch (f) {
|
||||
case .bold: return Text(t).bold()
|
||||
case .italic: return Text(t).italic()
|
||||
case .strikeThrough: return Text(t).strikethrough()
|
||||
case .snippet: return Text(t).font(.body.monospaced())
|
||||
case .secret: return
|
||||
showSecret
|
||||
? Text(t)
|
||||
: Text(AttributedString(t, attributes: AttributeContainer([
|
||||
.foregroundColor: UIColor.clear as Any,
|
||||
.backgroundColor: UIColor.secondarySystemFill as Any
|
||||
])))
|
||||
case let .colored(color): return Text(t).foregroundColor(color.uiColor)
|
||||
case .uri: return linkText(t, t, preview, prefix: "")
|
||||
case let .simplexLink(linkType, simplexUri, smpHosts):
|
||||
switch privacySimplexLinkModeDefault.get() {
|
||||
case .description: return linkText(simplexLinkText(linkType, smpHosts), simplexUri, preview, prefix: "")
|
||||
case .full: return linkText(t, simplexUri, preview, prefix: "")
|
||||
case .browser: return linkText(t, simplexUri, preview, prefix: "")
|
||||
@inline(__always)
|
||||
private func handleTextTaps(_ s: NSAttributedString, showSecrets: Binding<Set<Int>>? = nil) -> some View {
|
||||
return GeometryReader { g in
|
||||
Rectangle()
|
||||
.fill(Color.clear)
|
||||
.contentShape(Rectangle())
|
||||
.simultaneousGesture(DragGesture(minimumDistance: 0).onEnded { event in
|
||||
let t = event.translation
|
||||
if t.width * t.width + t.height * t.height > 100 { return }
|
||||
let framesetter = CTFramesetterCreateWithAttributedString(s as CFAttributedString)
|
||||
let path = CGPath(rect: CGRect(origin: .zero, size: g.size), transform: nil)
|
||||
let frame = CTFramesetterCreateFrame(framesetter, CFRangeMake(0, s.length), path, nil)
|
||||
let point = CGPoint(x: event.location.x, y: g.size.height - event.location.y) // Flip y for UIKit
|
||||
var index: CFIndex?
|
||||
if let lines = CTFrameGetLines(frame) as? [CTLine] {
|
||||
var origins = [CGPoint](repeating: .zero, count: lines.count)
|
||||
CTFrameGetLineOrigins(frame, CFRangeMake(0, 0), &origins)
|
||||
for i in 0 ..< lines.count {
|
||||
let bounds = CTLineGetBoundsWithOptions(lines[i], .useOpticalBounds)
|
||||
if bounds.offsetBy(dx: origins[i].x, dy: origins[i].y).contains(point) {
|
||||
index = CTLineGetStringIndexForPosition(lines[i], point)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if let index, let (url, browser) = attributedStringLink(s, for: index) {
|
||||
if browser {
|
||||
openBrowserAlert(uri: url)
|
||||
} else {
|
||||
UIApplication.shared.open(url)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func attributedStringLink(_ s: NSAttributedString, for index: CFIndex) -> (URL, Bool)? {
|
||||
var linkURL: URL?
|
||||
var browser: Bool = false
|
||||
s.enumerateAttributes(in: NSRange(location: 0, length: s.length)) { attrs, range, stop in
|
||||
if index >= range.location && index < range.location + range.length {
|
||||
if let url = attrs[linkAttrKey] as? NSURL {
|
||||
linkURL = url.absoluteURL
|
||||
browser = attrs[webLinkAttrKey] != nil
|
||||
} else if let showSecrets, let i = attrs[secretAttrKey] as? Int {
|
||||
if showSecrets.wrappedValue.contains(i) {
|
||||
showSecrets.wrappedValue.remove(i)
|
||||
} else {
|
||||
showSecrets.wrappedValue.insert(i)
|
||||
}
|
||||
}
|
||||
stop.pointee = true
|
||||
}
|
||||
case .email: return linkText(t, t, preview, prefix: "mailto:")
|
||||
case .phone: return linkText(t, t.replacingOccurrences(of: " ", with: ""), preview, prefix: "tel:")
|
||||
}
|
||||
} else {
|
||||
return Text(t)
|
||||
return if let linkURL { (linkURL, browser) } else { nil }
|
||||
}
|
||||
}
|
||||
|
||||
private func linkText(_ s: String, _ link: String, _ preview: Bool, prefix: String, color: Color = Color(uiColor: uiLinkColor), uiColor: UIColor = uiLinkColor) -> Text {
|
||||
preview
|
||||
? Text(s).foregroundColor(color).underline(color: color)
|
||||
: Text(AttributedString(s, attributes: AttributeContainer([
|
||||
.link: NSURL(string: prefix + link) as Any,
|
||||
.foregroundColor: uiColor as Any
|
||||
]))).underline()
|
||||
func hiddenSecretsView<V: View>(_ v: V) -> some View {
|
||||
v.overlay(
|
||||
GeometryReader { g in
|
||||
let size = (g.size.width + g.size.height) / 1.4142
|
||||
Image("vertical_logo")
|
||||
.resizable(resizingMode: .tile)
|
||||
.frame(width: size, height: size)
|
||||
.rotationEffect(.degrees(45), anchor: .center)
|
||||
.position(x: g.size.width / 2, y: g.size.height / 2)
|
||||
.clipped()
|
||||
.saturation(0.65)
|
||||
.opacity(0.35)
|
||||
}
|
||||
.mask(v)
|
||||
)
|
||||
}
|
||||
|
||||
private let linkAttrKey = NSAttributedString.Key("chat.simplex.app.link")
|
||||
|
||||
private let webLinkAttrKey = NSAttributedString.Key("chat.simplex.app.webLink")
|
||||
|
||||
private let secretAttrKey = NSAttributedString.Key("chat.simplex.app.secret")
|
||||
|
||||
typealias MsgTextResult = (string: NSMutableAttributedString, hasSecrets: Bool, handleTaps: Bool)
|
||||
|
||||
func messageText(
|
||||
_ text: String,
|
||||
_ formattedText: [FormattedText]?,
|
||||
textStyle: UIFont.TextStyle = .body,
|
||||
sender: String?,
|
||||
preview: Bool = false,
|
||||
mentions: [String: CIMention]?,
|
||||
userMemberId: String?,
|
||||
showSecrets: Set<Int>?,
|
||||
backgroundColor: UIColor,
|
||||
prefix: NSAttributedString? = nil
|
||||
) -> MsgTextResult {
|
||||
let res = NSMutableAttributedString()
|
||||
let descr = UIFontDescriptor.preferredFontDescriptor(withTextStyle: textStyle)
|
||||
let font = UIFont.preferredFont(forTextStyle: textStyle)
|
||||
let plain: [NSAttributedString.Key: Any] = [
|
||||
.font: font,
|
||||
.foregroundColor: UIColor.label
|
||||
]
|
||||
let secretColor = backgroundColor.withAlphaComponent(1)
|
||||
var link: [NSAttributedString.Key: Any]?
|
||||
var hasSecrets = false
|
||||
var handleTaps = false
|
||||
|
||||
if let sender {
|
||||
if preview {
|
||||
res.append(NSAttributedString(string: sender + ": ", attributes: plain))
|
||||
} else {
|
||||
var attrs = plain
|
||||
attrs[.font] = UIFont(descriptor: descr.addingAttributes([.traits: [UIFontDescriptor.TraitKey.weight: UIFont.Weight.medium]]), size: descr.pointSize)
|
||||
res.append(NSAttributedString(string: sender, attributes: attrs))
|
||||
res.append(NSAttributedString(string: ": ", attributes: plain))
|
||||
}
|
||||
}
|
||||
|
||||
if let prefix {
|
||||
res.append(prefix)
|
||||
}
|
||||
|
||||
if let fts = formattedText, fts.count > 0 {
|
||||
var bold: UIFont?
|
||||
var italic: UIFont?
|
||||
var snippet: UIFont?
|
||||
var mention: UIFont?
|
||||
var secretIdx: Int = 0
|
||||
for ft in fts {
|
||||
var t = ft.text
|
||||
var attrs = plain
|
||||
switch (ft.format) {
|
||||
case .bold:
|
||||
bold = bold ?? UIFont(descriptor: descr.addingAttributes([.traits: [UIFontDescriptor.TraitKey.weight: UIFont.Weight.bold]]), size: descr.pointSize)
|
||||
attrs[.font] = bold
|
||||
case .italic:
|
||||
italic = italic ?? UIFont(descriptor: descr.withSymbolicTraits(.traitItalic) ?? descr, size: descr.pointSize)
|
||||
attrs[.font] = italic
|
||||
case .strikeThrough:
|
||||
attrs[.strikethroughStyle] = NSUnderlineStyle.single.rawValue
|
||||
case .snippet:
|
||||
snippet = snippet ?? UIFont.monospacedSystemFont(ofSize: descr.pointSize, weight: .regular)
|
||||
attrs[.font] = snippet
|
||||
case .secret:
|
||||
if let showSecrets {
|
||||
if !showSecrets.contains(secretIdx) {
|
||||
attrs[.foregroundColor] = UIColor.clear
|
||||
attrs[.backgroundColor] = secretColor
|
||||
}
|
||||
attrs[secretAttrKey] = secretIdx
|
||||
secretIdx += 1
|
||||
handleTaps = true
|
||||
} else {
|
||||
attrs[.foregroundColor] = UIColor.clear
|
||||
attrs[.backgroundColor] = secretColor
|
||||
}
|
||||
hasSecrets = true
|
||||
case let .colored(color):
|
||||
if let c = color.uiColor {
|
||||
attrs[.foregroundColor] = UIColor(c)
|
||||
}
|
||||
case .uri:
|
||||
attrs = linkAttrs()
|
||||
if !preview {
|
||||
let s = t.lowercased()
|
||||
let link = s.hasPrefix("http://") || s.hasPrefix("https://")
|
||||
? t
|
||||
: "https://" + t
|
||||
attrs[linkAttrKey] = NSURL(string: link)
|
||||
attrs[webLinkAttrKey] = true
|
||||
handleTaps = true
|
||||
}
|
||||
case let .simplexLink(linkType, simplexUri, smpHosts):
|
||||
attrs = linkAttrs()
|
||||
if !preview {
|
||||
attrs[linkAttrKey] = NSURL(string: simplexUri)
|
||||
handleTaps = true
|
||||
}
|
||||
if case .description = privacySimplexLinkModeDefault.get() {
|
||||
t = simplexLinkText(linkType, smpHosts)
|
||||
}
|
||||
case let .mention(memberName):
|
||||
if let m = mentions?[memberName] {
|
||||
mention = mention ?? UIFont(descriptor: descr.addingAttributes([.traits: [UIFontDescriptor.TraitKey.weight: UIFont.Weight.semibold]]), size: descr.pointSize)
|
||||
attrs[.font] = mention
|
||||
if let ref = m.memberRef {
|
||||
let name: String = if let alias = ref.localAlias, alias != "" {
|
||||
"\(alias) (\(ref.displayName))"
|
||||
} else {
|
||||
ref.displayName
|
||||
}
|
||||
if m.memberId == userMemberId {
|
||||
attrs[.foregroundColor] = UIColor.tintColor
|
||||
}
|
||||
t = mentionText(name)
|
||||
} else {
|
||||
t = mentionText(memberName)
|
||||
}
|
||||
}
|
||||
case .email:
|
||||
attrs = linkAttrs()
|
||||
if !preview {
|
||||
attrs[linkAttrKey] = NSURL(string: "mailto:" + ft.text)
|
||||
handleTaps = true
|
||||
}
|
||||
case .phone:
|
||||
attrs = linkAttrs()
|
||||
if !preview {
|
||||
attrs[linkAttrKey] = NSURL(string: "tel:" + t.replacingOccurrences(of: " ", with: ""))
|
||||
handleTaps = true
|
||||
}
|
||||
case .none: ()
|
||||
}
|
||||
res.append(NSAttributedString(string: t, attributes: attrs))
|
||||
}
|
||||
} else {
|
||||
res.append(NSMutableAttributedString(string: text, attributes: plain))
|
||||
}
|
||||
|
||||
return (string: res, hasSecrets: hasSecrets, handleTaps: handleTaps)
|
||||
|
||||
func linkAttrs() -> [NSAttributedString.Key: Any] {
|
||||
link = link ?? [
|
||||
.font: font,
|
||||
.foregroundColor: uiLinkColor,
|
||||
.underlineStyle: NSUnderlineStyle.single.rawValue
|
||||
]
|
||||
return link!
|
||||
}
|
||||
}
|
||||
|
||||
@inline(__always)
|
||||
private func mentionText(_ name: String) -> String {
|
||||
name.contains(" @") ? "@'\(name)'" : "@\(name)"
|
||||
}
|
||||
|
||||
func simplexLinkText(_ linkType: SimplexLinkType, _ smpHosts: [String]) -> String {
|
||||
|
@ -166,9 +371,9 @@ struct MsgContentView_Previews: PreviewProvider {
|
|||
chat: Chat.sampleData,
|
||||
text: chatItem.text,
|
||||
formattedText: chatItem.formattedText,
|
||||
textStyle: .body,
|
||||
sender: chatItem.memberDisplayName,
|
||||
meta: chatItem.meta,
|
||||
showSecrets: false
|
||||
meta: chatItem.meta
|
||||
)
|
||||
.environmentObject(Chat.sampleData)
|
||||
}
|
||||
|
|
|
@ -41,7 +41,7 @@ struct ChatItemForwardingView: View {
|
|||
.alert(item: $alert) { $0.alert }
|
||||
}
|
||||
|
||||
@ViewBuilder private func forwardListView() -> some View {
|
||||
private func forwardListView() -> some View {
|
||||
VStack(alignment: .leading) {
|
||||
if !chatsToForwardTo.isEmpty {
|
||||
List {
|
||||
|
|
|
@ -14,6 +14,7 @@ struct ChatItemInfoView: View {
|
|||
@Environment(\.dismiss) var dismiss
|
||||
@EnvironmentObject var theme: AppTheme
|
||||
var ci: ChatItem
|
||||
var userMemberId: String?
|
||||
@Binding var chatItemInfo: ChatItemInfo?
|
||||
@State private var selection: CIInfoTab = .history
|
||||
@State private var alert: CIInfoViewAlert? = nil
|
||||
|
@ -130,9 +131,9 @@ struct ChatItemInfoView: View {
|
|||
}
|
||||
}
|
||||
|
||||
@ViewBuilder private func details() -> some View {
|
||||
private func details() -> some View {
|
||||
let meta = ci.meta
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
return VStack(alignment: .leading, spacing: 16) {
|
||||
Text(title)
|
||||
.font(.largeTitle)
|
||||
.bold()
|
||||
|
@ -196,7 +197,7 @@ struct ChatItemInfoView: View {
|
|||
}
|
||||
}
|
||||
|
||||
@ViewBuilder private func historyTab() -> some View {
|
||||
private func historyTab() -> some View {
|
||||
GeometryReader { g in
|
||||
let maxWidth = (g.size.width - 32) * 0.84
|
||||
ScrollView {
|
||||
|
@ -226,12 +227,13 @@ struct ChatItemInfoView: View {
|
|||
}
|
||||
}
|
||||
|
||||
@ViewBuilder private func itemVersionView(_ itemVersion: ChatItemVersion, _ maxWidth: CGFloat, current: Bool) -> some View {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
textBubble(itemVersion.msgContent.text, itemVersion.formattedText, nil)
|
||||
private func itemVersionView(_ itemVersion: ChatItemVersion, _ maxWidth: CGFloat, current: Bool) -> some View {
|
||||
let backgroundColor = chatItemFrameColor(ci, theme)
|
||||
return VStack(alignment: .leading, spacing: 4) {
|
||||
textBubble(itemVersion.msgContent.text, itemVersion.formattedText, nil, backgroundColor: UIColor(backgroundColor))
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 6)
|
||||
.background(chatItemFrameColor(ci, theme))
|
||||
.background(backgroundColor)
|
||||
.modifier(ChatItemClipped())
|
||||
.contextMenu {
|
||||
if itemVersion.msgContent.text != "" {
|
||||
|
@ -256,9 +258,9 @@ struct ChatItemInfoView: View {
|
|||
.frame(maxWidth: maxWidth, alignment: .leading)
|
||||
}
|
||||
|
||||
@ViewBuilder private func textBubble(_ text: String, _ formattedText: [FormattedText]?, _ sender: String? = nil) -> some View {
|
||||
@ViewBuilder private func textBubble(_ text: String, _ formattedText: [FormattedText]?, _ sender: String? = nil, backgroundColor: UIColor) -> some View {
|
||||
if text != "" {
|
||||
TextBubble(text: text, formattedText: formattedText, sender: sender)
|
||||
TextBubble(text: text, formattedText: formattedText, sender: sender, mentions: ci.mentions, userMemberId: userMemberId, backgroundColor: backgroundColor)
|
||||
} else {
|
||||
Text("no text")
|
||||
.italic()
|
||||
|
@ -271,14 +273,18 @@ struct ChatItemInfoView: View {
|
|||
var text: String
|
||||
var formattedText: [FormattedText]?
|
||||
var sender: String? = nil
|
||||
@State private var showSecrets = false
|
||||
var mentions: [String: CIMention]?
|
||||
var userMemberId: String?
|
||||
var backgroundColor: UIColor
|
||||
@State private var showSecrets: Set<Int> = []
|
||||
|
||||
var body: some View {
|
||||
toggleSecrets(formattedText, $showSecrets, messageText(text, formattedText, sender, showSecrets: showSecrets, secondaryColor: theme.colors.secondary))
|
||||
let r = messageText(text, formattedText, sender: sender, mentions: mentions, userMemberId: userMemberId, showSecrets: showSecrets, backgroundColor: backgroundColor)
|
||||
return msgTextResultView(r, Text(AttributedString(r.string)), showSecrets: $showSecrets)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder private func quoteTab(_ qi: CIQuote) -> some View {
|
||||
private func quoteTab(_ qi: CIQuote) -> some View {
|
||||
GeometryReader { g in
|
||||
let maxWidth = (g.size.width - 32) * 0.84
|
||||
ScrollView {
|
||||
|
@ -296,9 +302,10 @@ struct ChatItemInfoView: View {
|
|||
}
|
||||
}
|
||||
|
||||
@ViewBuilder private func quotedMsgView(_ qi: CIQuote, _ maxWidth: CGFloat) -> some View {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
textBubble(qi.text, qi.formattedText, qi.getSender(nil))
|
||||
private func quotedMsgView(_ qi: CIQuote, _ maxWidth: CGFloat) -> some View {
|
||||
let backgroundColor = quotedMsgFrameColor(qi, theme)
|
||||
return VStack(alignment: .leading, spacing: 4) {
|
||||
textBubble(qi.text, qi.formattedText, qi.getSender(nil), backgroundColor: UIColor(backgroundColor))
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 6)
|
||||
.background(quotedMsgFrameColor(qi, theme))
|
||||
|
@ -331,7 +338,7 @@ struct ChatItemInfoView: View {
|
|||
: theme.appColors.receivedMessage
|
||||
}
|
||||
|
||||
@ViewBuilder private func forwardedFromTab(_ forwardedFromItem: AChatItem) -> some View {
|
||||
private func forwardedFromTab(_ forwardedFromItem: AChatItem) -> some View {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
details()
|
||||
|
@ -351,8 +358,9 @@ struct ChatItemInfoView: View {
|
|||
Button {
|
||||
Task {
|
||||
await MainActor.run {
|
||||
ItemsModel.shared.loadOpenChat(forwardedFromItem.chatInfo.id)
|
||||
dismiss()
|
||||
ItemsModel.shared.loadOpenChat(forwardedFromItem.chatInfo.id) {
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
}
|
||||
} label: {
|
||||
|
@ -368,7 +376,7 @@ struct ChatItemInfoView: View {
|
|||
}
|
||||
}
|
||||
|
||||
@ViewBuilder private func forwardedFromSender(_ forwardedFromItem: AChatItem) -> some View {
|
||||
private func forwardedFromSender(_ forwardedFromItem: AChatItem) -> some View {
|
||||
HStack {
|
||||
ChatInfoImage(chat: Chat(chatInfo: forwardedFromItem.chatInfo), size: 48)
|
||||
.padding(.trailing, 6)
|
||||
|
@ -399,7 +407,7 @@ struct ChatItemInfoView: View {
|
|||
}
|
||||
}
|
||||
|
||||
@ViewBuilder private func deliveryTab(_ memberDeliveryStatuses: [MemberDeliveryStatus]) -> some View {
|
||||
private func deliveryTab(_ memberDeliveryStatuses: [MemberDeliveryStatus]) -> some View {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
details()
|
||||
|
@ -414,7 +422,7 @@ struct ChatItemInfoView: View {
|
|||
.frame(maxHeight: .infinity, alignment: .top)
|
||||
}
|
||||
|
||||
@ViewBuilder private func memberDeliveryStatusesView(_ memberDeliveryStatuses: [MemberDeliveryStatus]) -> some View {
|
||||
private func memberDeliveryStatusesView(_ memberDeliveryStatuses: [MemberDeliveryStatus]) -> some View {
|
||||
LazyVStack(alignment: .leading, spacing: 12) {
|
||||
let mss = membersStatuses(memberDeliveryStatuses)
|
||||
if !mss.isEmpty {
|
||||
|
@ -548,6 +556,6 @@ func localTimestamp(_ date: Date) -> String {
|
|||
|
||||
struct ChatItemInfoView_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
ChatItemInfoView(ci: ChatItem.getSample(1, .directSnd, .now, "hello"), chatItemInfo: Binding.constant(nil))
|
||||
ChatItemInfoView(ci: ChatItem.getSample(1, .directSnd, .now, "hello"), userMemberId: Chat.sampleData.chatInfo.groupInfo?.membership.memberId, chatItemInfo: Binding.constant(nil))
|
||||
}
|
||||
}
|
||||
|
|
|
@ -18,6 +18,10 @@ extension EnvironmentValues {
|
|||
static let defaultValue: Bool = true
|
||||
}
|
||||
|
||||
struct ContainerBackground: EnvironmentKey {
|
||||
static let defaultValue: UIColor = .clear
|
||||
}
|
||||
|
||||
var showTimestamp: Bool {
|
||||
get { self[ShowTimestamp.self] }
|
||||
set { self[ShowTimestamp.self] = newValue }
|
||||
|
@ -27,6 +31,11 @@ extension EnvironmentValues {
|
|||
get { self[Revealed.self] }
|
||||
set { self[Revealed.self] = newValue }
|
||||
}
|
||||
|
||||
var containerBackground: UIColor {
|
||||
get { self[ContainerBackground.self] }
|
||||
set { self[ContainerBackground.self] = newValue }
|
||||
}
|
||||
}
|
||||
|
||||
struct ChatItemView: View {
|
||||
|
@ -35,18 +44,21 @@ struct ChatItemView: View {
|
|||
@Environment(\.showTimestamp) var showTimestamp: Bool
|
||||
@Environment(\.revealed) var revealed: Bool
|
||||
var chatItem: ChatItem
|
||||
var scrollToItemId: (ChatItem.ID) -> Void
|
||||
var maxWidth: CGFloat = .infinity
|
||||
@Binding var allowMenu: Bool
|
||||
|
||||
init(
|
||||
chat: Chat,
|
||||
chatItem: ChatItem,
|
||||
scrollToItemId: @escaping (ChatItem.ID) -> Void,
|
||||
showMember: Bool = false,
|
||||
maxWidth: CGFloat = .infinity,
|
||||
allowMenu: Binding<Bool> = .constant(false)
|
||||
) {
|
||||
self.chat = chat
|
||||
self.chatItem = chatItem
|
||||
self.scrollToItemId = scrollToItemId
|
||||
self.maxWidth = maxWidth
|
||||
_allowMenu = allowMenu
|
||||
}
|
||||
|
@ -90,6 +102,7 @@ struct ChatItemView: View {
|
|||
return FramedItemView(
|
||||
chat: chat,
|
||||
chatItem: chatItem,
|
||||
scrollToItemId: scrollToItemId,
|
||||
preview: preview,
|
||||
maxWidth: maxWidth,
|
||||
imgWidth: adjustedMaxWidth,
|
||||
|
@ -244,15 +257,15 @@ func chatEventText(_ ci: ChatItem, _ secondaryColor: Color) -> Text {
|
|||
struct ChatItemView_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
Group{
|
||||
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(1, .directSnd, .now, "hello"))
|
||||
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directRcv, .now, "hello there too"))
|
||||
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(1, .directSnd, .now, "🙂"))
|
||||
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directRcv, .now, "🙂🙂🙂🙂🙂"))
|
||||
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directRcv, .now, "🙂🙂🙂🙂🙂🙂"))
|
||||
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getDeletedContentSample())
|
||||
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(1, .directSnd, .now, "hello", .sndSent(sndProgress: .complete), itemDeleted: .deleted(deletedTs: .now)))
|
||||
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(1, .directSnd, .now, "🙂", .sndSent(sndProgress: .complete), itemLive: true)).environment(\.revealed, true)
|
||||
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(1, .directSnd, .now, "hello", .sndSent(sndProgress: .complete), itemLive: true)).environment(\.revealed, true)
|
||||
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(1, .directSnd, .now, "hello"), scrollToItemId: { _ in })
|
||||
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directRcv, .now, "hello there too"), scrollToItemId: { _ in })
|
||||
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(1, .directSnd, .now, "🙂"), scrollToItemId: { _ in })
|
||||
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directRcv, .now, "🙂🙂🙂🙂🙂"), scrollToItemId: { _ in })
|
||||
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directRcv, .now, "🙂🙂🙂🙂🙂🙂"), scrollToItemId: { _ in })
|
||||
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getDeletedContentSample(), scrollToItemId: { _ in })
|
||||
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(1, .directSnd, .now, "hello", .sndSent(sndProgress: .complete), itemDeleted: .deleted(deletedTs: .now)), scrollToItemId: { _ in })
|
||||
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(1, .directSnd, .now, "🙂", .sndSent(sndProgress: .complete), itemLive: true), scrollToItemId: { _ in }).environment(\.revealed, true)
|
||||
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(1, .directSnd, .now, "hello", .sndSent(sndProgress: .complete), itemLive: true), scrollToItemId: { _ in }).environment(\.revealed, true)
|
||||
}
|
||||
.environment(\.revealed, false)
|
||||
.previewLayout(.fixed(width: 360, height: 70))
|
||||
|
@ -272,7 +285,8 @@ struct ChatItemView_NonMsgContentDeleted_Previews: PreviewProvider {
|
|||
content: .rcvIntegrityError(msgError: .msgSkipped(fromMsgId: 1, toMsgId: 2)),
|
||||
quotedItem: nil,
|
||||
file: nil
|
||||
)
|
||||
),
|
||||
scrollToItemId: { _ in }
|
||||
)
|
||||
ChatItemView(
|
||||
chat: Chat.sampleData,
|
||||
|
@ -282,7 +296,8 @@ struct ChatItemView_NonMsgContentDeleted_Previews: PreviewProvider {
|
|||
content: .rcvDecryptionError(msgDecryptError: .ratchetHeader, msgCount: 2),
|
||||
quotedItem: nil,
|
||||
file: nil
|
||||
)
|
||||
),
|
||||
scrollToItemId: { _ in }
|
||||
)
|
||||
ChatItemView(
|
||||
chat: Chat.sampleData,
|
||||
|
@ -292,7 +307,8 @@ struct ChatItemView_NonMsgContentDeleted_Previews: PreviewProvider {
|
|||
content: .rcvGroupInvitation(groupInvitation: CIGroupInvitation.getSample(status: .pending), memberRole: .admin),
|
||||
quotedItem: nil,
|
||||
file: nil
|
||||
)
|
||||
),
|
||||
scrollToItemId: { _ in }
|
||||
)
|
||||
ChatItemView(
|
||||
chat: Chat.sampleData,
|
||||
|
@ -302,7 +318,8 @@ struct ChatItemView_NonMsgContentDeleted_Previews: PreviewProvider {
|
|||
content: .rcvGroupEvent(rcvGroupEvent: .memberAdded(groupMemberId: 1, profile: Profile.sampleData)),
|
||||
quotedItem: nil,
|
||||
file: nil
|
||||
)
|
||||
),
|
||||
scrollToItemId: { _ in }
|
||||
)
|
||||
ChatItemView(
|
||||
chat: Chat.sampleData,
|
||||
|
@ -312,7 +329,8 @@ struct ChatItemView_NonMsgContentDeleted_Previews: PreviewProvider {
|
|||
content: ciFeatureContent,
|
||||
quotedItem: nil,
|
||||
file: nil
|
||||
)
|
||||
),
|
||||
scrollToItemId: { _ in }
|
||||
)
|
||||
}
|
||||
.environment(\.revealed, true)
|
||||
|
|
511
apps/ios/Shared/Views/Chat/ChatItemsLoader.swift
Normal file
511
apps/ios/Shared/Views/Chat/ChatItemsLoader.swift
Normal file
|
@ -0,0 +1,511 @@
|
|||
//
|
||||
// ChatItemsLoader.swift
|
||||
// SimpleX (iOS)
|
||||
//
|
||||
// Created by Stanislav Dmitrenko on 17.12.2024.
|
||||
// Copyright © 2024 SimpleX Chat. All rights reserved.
|
||||
//
|
||||
|
||||
import SimpleXChat
|
||||
import SwiftUI
|
||||
|
||||
let TRIM_KEEP_COUNT = 200
|
||||
|
||||
func apiLoadMessages(
|
||||
_ chatId: ChatId,
|
||||
_ pagination: ChatPagination,
|
||||
_ chatState: ActiveChatState,
|
||||
_ search: String = "",
|
||||
_ openAroundItemId: ChatItem.ID? = nil,
|
||||
_ visibleItemIndexesNonReversed: @MainActor () -> ClosedRange<Int> = { 0 ... 0 }
|
||||
) async {
|
||||
let chat: Chat
|
||||
let navInfo: NavigationInfo
|
||||
do {
|
||||
(chat, navInfo) = try await apiGetChat(chatId: chatId, pagination: pagination, search: search)
|
||||
} catch let error {
|
||||
logger.error("apiLoadMessages error: \(responseError(error))")
|
||||
return
|
||||
}
|
||||
|
||||
let chatModel = ChatModel.shared
|
||||
|
||||
// For .initial allow the chatItems to be empty as well as chatModel.chatId to not match this chat because these values become set after .initial finishes
|
||||
let paginationIsInitial = switch pagination { case .initial: true; default: false }
|
||||
let paginationIsLast = switch pagination { case .last: true; default: false }
|
||||
// When openAroundItemId is provided, chatId can be different too
|
||||
if ((chatModel.chatId != chat.id || chat.chatItems.isEmpty) && !paginationIsInitial && !paginationIsLast && openAroundItemId == nil) || Task.isCancelled {
|
||||
return
|
||||
}
|
||||
|
||||
let unreadAfterItemId = chatState.unreadAfterItemId
|
||||
|
||||
let oldItems = Array(ItemsModel.shared.reversedChatItems.reversed())
|
||||
var newItems: [ChatItem] = []
|
||||
switch pagination {
|
||||
case .initial:
|
||||
let newSplits: [Int64] = if !chat.chatItems.isEmpty && navInfo.afterTotal > 0 { [chat.chatItems.last!.id] } else { [] }
|
||||
if chatModel.getChat(chat.id) == nil {
|
||||
chatModel.addChat(chat)
|
||||
}
|
||||
await MainActor.run {
|
||||
chatModel.chatItemStatuses.removeAll()
|
||||
ItemsModel.shared.reversedChatItems = chat.chatItems.reversed()
|
||||
chatModel.updateChatInfo(chat.chatInfo)
|
||||
chatState.splits = newSplits
|
||||
if !chat.chatItems.isEmpty {
|
||||
chatState.unreadAfterItemId = chat.chatItems.last!.id
|
||||
}
|
||||
chatState.totalAfter = navInfo.afterTotal
|
||||
chatState.unreadTotal = chat.chatStats.unreadCount
|
||||
chatState.unreadAfter = navInfo.afterUnread
|
||||
chatState.unreadAfterNewestLoaded = navInfo.afterUnread
|
||||
|
||||
PreloadState.shared.clear()
|
||||
}
|
||||
case let .before(paginationChatItemId, _):
|
||||
newItems.append(contentsOf: oldItems)
|
||||
let indexInCurrentItems = oldItems.firstIndex(where: { $0.id == paginationChatItemId })
|
||||
guard let indexInCurrentItems else { return }
|
||||
let (newIds, _) = mapItemsToIds(chat.chatItems)
|
||||
let wasSize = newItems.count
|
||||
let visibleItemIndexes = await MainActor.run { visibleItemIndexesNonReversed() }
|
||||
let modifiedSplits = removeDuplicatesAndModifySplitsOnBeforePagination(
|
||||
unreadAfterItemId, &newItems, newIds, chatState.splits, visibleItemIndexes
|
||||
)
|
||||
let insertAt = max((indexInCurrentItems - (wasSize - newItems.count) + modifiedSplits.trimmedIds.count), 0)
|
||||
newItems.insert(contentsOf: chat.chatItems, at: insertAt)
|
||||
let newReversed: [ChatItem] = newItems.reversed()
|
||||
await MainActor.run {
|
||||
ItemsModel.shared.reversedChatItems = newReversed
|
||||
chatState.splits = modifiedSplits.newSplits
|
||||
chatState.moveUnreadAfterItem(modifiedSplits.oldUnreadSplitIndex, modifiedSplits.newUnreadSplitIndex, oldItems)
|
||||
}
|
||||
case let .after(paginationChatItemId, _):
|
||||
newItems.append(contentsOf: oldItems)
|
||||
let indexInCurrentItems = oldItems.firstIndex(where: { $0.id == paginationChatItemId })
|
||||
guard let indexInCurrentItems else { return }
|
||||
|
||||
let mappedItems = mapItemsToIds(chat.chatItems)
|
||||
let newIds = mappedItems.0
|
||||
let (newSplits, unreadInLoaded) = removeDuplicatesAndModifySplitsOnAfterPagination(
|
||||
mappedItems.1, paginationChatItemId, &newItems, newIds, chat, chatState.splits
|
||||
)
|
||||
let indexToAdd = min(indexInCurrentItems + 1, newItems.count)
|
||||
let indexToAddIsLast = indexToAdd == newItems.count
|
||||
newItems.insert(contentsOf: chat.chatItems, at: indexToAdd)
|
||||
let new: [ChatItem] = newItems
|
||||
let newReversed: [ChatItem] = newItems.reversed()
|
||||
await MainActor.run {
|
||||
ItemsModel.shared.reversedChatItems = newReversed
|
||||
chatState.splits = newSplits
|
||||
chatState.moveUnreadAfterItem(chatState.splits.first ?? new.last!.id, new)
|
||||
// loading clear bottom area, updating number of unread items after the newest loaded item
|
||||
if indexToAddIsLast {
|
||||
chatState.unreadAfterNewestLoaded -= unreadInLoaded
|
||||
}
|
||||
}
|
||||
case .around:
|
||||
var newSplits: [Int64]
|
||||
if openAroundItemId == nil {
|
||||
newItems.append(contentsOf: oldItems)
|
||||
newSplits = await removeDuplicatesAndUpperSplits(&newItems, chat, chatState.splits, visibleItemIndexesNonReversed)
|
||||
} else {
|
||||
newSplits = []
|
||||
}
|
||||
let (itemIndex, splitIndex) = indexToInsertAround(chat.chatInfo.chatType, chat.chatItems.last, to: newItems, Set(newSplits))
|
||||
//indexToInsertAroundTest()
|
||||
newItems.insert(contentsOf: chat.chatItems, at: itemIndex)
|
||||
newSplits.insert(chat.chatItems.last!.id, at: splitIndex)
|
||||
let newReversed: [ChatItem] = newItems.reversed()
|
||||
let orderedSplits = newSplits
|
||||
await MainActor.run {
|
||||
ItemsModel.shared.reversedChatItems = newReversed
|
||||
chatState.splits = orderedSplits
|
||||
chatState.unreadAfterItemId = chat.chatItems.last!.id
|
||||
chatState.totalAfter = navInfo.afterTotal
|
||||
chatState.unreadTotal = chat.chatStats.unreadCount
|
||||
chatState.unreadAfter = navInfo.afterUnread
|
||||
|
||||
if let openAroundItemId {
|
||||
chatState.unreadAfterNewestLoaded = navInfo.afterUnread
|
||||
ChatModel.shared.openAroundItemId = openAroundItemId
|
||||
ChatModel.shared.chatId = chatId
|
||||
} else {
|
||||
// no need to set it, count will be wrong
|
||||
// chatState.unreadAfterNewestLoaded = navInfo.afterUnread
|
||||
}
|
||||
PreloadState.shared.clear()
|
||||
}
|
||||
case .last:
|
||||
newItems.append(contentsOf: oldItems)
|
||||
let newSplits = await removeDuplicatesAndUnusedSplits(&newItems, chat, chatState.splits)
|
||||
newItems.append(contentsOf: chat.chatItems)
|
||||
let items = newItems
|
||||
await MainActor.run {
|
||||
ItemsModel.shared.reversedChatItems = items.reversed()
|
||||
chatState.splits = newSplits
|
||||
chatModel.updateChatInfo(chat.chatInfo)
|
||||
chatState.unreadAfterNewestLoaded = 0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private class ModifiedSplits {
|
||||
let oldUnreadSplitIndex: Int
|
||||
let newUnreadSplitIndex: Int
|
||||
let trimmedIds: Set<Int64>
|
||||
let newSplits: [Int64]
|
||||
|
||||
init(oldUnreadSplitIndex: Int, newUnreadSplitIndex: Int, trimmedIds: Set<Int64>, newSplits: [Int64]) {
|
||||
self.oldUnreadSplitIndex = oldUnreadSplitIndex
|
||||
self.newUnreadSplitIndex = newUnreadSplitIndex
|
||||
self.trimmedIds = trimmedIds
|
||||
self.newSplits = newSplits
|
||||
}
|
||||
}
|
||||
|
||||
private func removeDuplicatesAndModifySplitsOnBeforePagination(
|
||||
_ unreadAfterItemId: Int64,
|
||||
_ newItems: inout [ChatItem],
|
||||
_ newIds: Set<Int64>,
|
||||
_ splits: [Int64],
|
||||
_ visibleItemIndexes: ClosedRange<Int>
|
||||
) -> ModifiedSplits {
|
||||
var oldUnreadSplitIndex: Int = -1
|
||||
var newUnreadSplitIndex: Int = -1
|
||||
var lastSplitIndexTrimmed: Int? = nil
|
||||
var allowedTrimming = true
|
||||
var index = 0
|
||||
/** keep the newest [TRIM_KEEP_COUNT] items (bottom area) and oldest [TRIM_KEEP_COUNT] items, trim others */
|
||||
let trimLowerBound = visibleItemIndexes.upperBound + TRIM_KEEP_COUNT
|
||||
let trimUpperBound = newItems.count - TRIM_KEEP_COUNT
|
||||
let trimRange = trimUpperBound >= trimLowerBound ? trimLowerBound ... trimUpperBound : -1 ... -1
|
||||
var trimmedIds = Set<Int64>()
|
||||
let prevTrimLowerBound = visibleItemIndexes.upperBound + TRIM_KEEP_COUNT + 1
|
||||
let prevTrimUpperBound = newItems.count - TRIM_KEEP_COUNT
|
||||
let prevItemTrimRange = prevTrimUpperBound >= prevTrimLowerBound ? prevTrimLowerBound ... prevTrimUpperBound : -1 ... -1
|
||||
var newSplits = splits
|
||||
|
||||
newItems.removeAll(where: {
|
||||
let invisibleItemToTrim = trimRange.contains(index) && allowedTrimming
|
||||
let prevItemWasTrimmed = prevItemTrimRange.contains(index) && allowedTrimming
|
||||
// may disable it after clearing the whole split range
|
||||
if !splits.isEmpty && $0.id == splits.first {
|
||||
// trim only in one split range
|
||||
allowedTrimming = false
|
||||
}
|
||||
let indexInSplits = splits.firstIndex(of: $0.id)
|
||||
if let indexInSplits {
|
||||
lastSplitIndexTrimmed = indexInSplits
|
||||
}
|
||||
if invisibleItemToTrim {
|
||||
if prevItemWasTrimmed {
|
||||
trimmedIds.insert($0.id)
|
||||
} else {
|
||||
newUnreadSplitIndex = index
|
||||
// prev item is not supposed to be trimmed, so exclude current one from trimming and set a split here instead.
|
||||
// this allows to define splitRange of the oldest items and to start loading trimmed items when user scrolls in the opposite direction
|
||||
if let lastSplitIndexTrimmed {
|
||||
var new = newSplits
|
||||
new[lastSplitIndexTrimmed] = $0.id
|
||||
newSplits = new
|
||||
} else {
|
||||
newSplits = [$0.id] + newSplits
|
||||
}
|
||||
}
|
||||
}
|
||||
if unreadAfterItemId == $0.id {
|
||||
oldUnreadSplitIndex = index
|
||||
}
|
||||
index += 1
|
||||
return (invisibleItemToTrim && prevItemWasTrimmed) || newIds.contains($0.id)
|
||||
})
|
||||
// will remove any splits that now becomes obsolete because items were merged
|
||||
newSplits = newSplits.filter { split in !newIds.contains(split) && !trimmedIds.contains(split) }
|
||||
return ModifiedSplits(oldUnreadSplitIndex: oldUnreadSplitIndex, newUnreadSplitIndex: newUnreadSplitIndex, trimmedIds: trimmedIds, newSplits: newSplits)
|
||||
}
|
||||
|
||||
private func removeDuplicatesAndModifySplitsOnAfterPagination(
|
||||
_ unreadInLoaded: Int,
|
||||
_ paginationChatItemId: Int64,
|
||||
_ newItems: inout [ChatItem],
|
||||
_ newIds: Set<Int64>,
|
||||
_ chat: Chat,
|
||||
_ splits: [Int64]
|
||||
) -> ([Int64], Int) {
|
||||
var unreadInLoaded = unreadInLoaded
|
||||
var firstItemIdBelowAllSplits: Int64? = nil
|
||||
var splitsToRemove: Set<Int64> = []
|
||||
let indexInSplitRanges = splits.firstIndex(of: paginationChatItemId)
|
||||
// Currently, it should always load from split range
|
||||
let loadingFromSplitRange = indexInSplitRanges != nil
|
||||
let topSplits: [Int64]
|
||||
var splitsToMerge: [Int64]
|
||||
if let indexInSplitRanges, loadingFromSplitRange && indexInSplitRanges + 1 <= splits.count {
|
||||
splitsToMerge = Array(splits[indexInSplitRanges + 1 ..< splits.count])
|
||||
topSplits = Array(splits[0 ..< indexInSplitRanges + 1])
|
||||
} else {
|
||||
splitsToMerge = []
|
||||
topSplits = []
|
||||
}
|
||||
newItems.removeAll(where: { new in
|
||||
let duplicate = newIds.contains(new.id)
|
||||
if loadingFromSplitRange && duplicate {
|
||||
if splitsToMerge.contains(new.id) {
|
||||
splitsToMerge.removeAll(where: { $0 == new.id })
|
||||
splitsToRemove.insert(new.id)
|
||||
} else if firstItemIdBelowAllSplits == nil && splitsToMerge.isEmpty {
|
||||
// we passed all splits and found duplicated item below all of them, which means no splits anymore below the loaded items
|
||||
firstItemIdBelowAllSplits = new.id
|
||||
}
|
||||
}
|
||||
if duplicate && new.isRcvNew {
|
||||
unreadInLoaded -= 1
|
||||
}
|
||||
return duplicate
|
||||
})
|
||||
var newSplits: [Int64] = []
|
||||
if firstItemIdBelowAllSplits != nil {
|
||||
// no splits below anymore, all were merged with bottom items
|
||||
newSplits = topSplits
|
||||
} else {
|
||||
if !splitsToRemove.isEmpty {
|
||||
var new = splits
|
||||
new.removeAll(where: { splitsToRemove.contains($0) })
|
||||
newSplits = new
|
||||
}
|
||||
let enlargedSplit = splits.firstIndex(of: paginationChatItemId)
|
||||
if let enlargedSplit {
|
||||
// move the split to the end of loaded items
|
||||
var new = splits
|
||||
new[enlargedSplit] = chat.chatItems.last!.id
|
||||
newSplits = new
|
||||
}
|
||||
}
|
||||
return (newSplits, unreadInLoaded)
|
||||
}
|
||||
|
||||
private func removeDuplicatesAndUpperSplits(
|
||||
_ newItems: inout [ChatItem],
|
||||
_ chat: Chat,
|
||||
_ splits: [Int64],
|
||||
_ visibleItemIndexesNonReversed: @MainActor () -> ClosedRange<Int>
|
||||
) async -> [Int64] {
|
||||
if splits.isEmpty {
|
||||
removeDuplicates(&newItems, chat)
|
||||
return splits
|
||||
}
|
||||
|
||||
var newSplits = splits
|
||||
let visibleItemIndexes = await MainActor.run { visibleItemIndexesNonReversed() }
|
||||
let (newIds, _) = mapItemsToIds(chat.chatItems)
|
||||
var idsToTrim: [BoxedValue<Set<Int64>>] = []
|
||||
idsToTrim.append(BoxedValue(Set()))
|
||||
var index = 0
|
||||
newItems.removeAll(where: {
|
||||
let duplicate = newIds.contains($0.id)
|
||||
if (!duplicate && visibleItemIndexes.lowerBound > index) {
|
||||
idsToTrim.last?.boxedValue.insert($0.id)
|
||||
}
|
||||
if visibleItemIndexes.lowerBound > index, let firstIndex = newSplits.firstIndex(of: $0.id) {
|
||||
newSplits.remove(at: firstIndex)
|
||||
// closing previous range. All items in idsToTrim that ends with empty set should be deleted.
|
||||
// Otherwise, the last set should be excluded from trimming because it is in currently visible split range
|
||||
idsToTrim.append(BoxedValue(Set()))
|
||||
}
|
||||
|
||||
index += 1
|
||||
return duplicate
|
||||
})
|
||||
if !idsToTrim.last!.boxedValue.isEmpty {
|
||||
// it has some elements to trim from currently visible range which means the items shouldn't be trimmed
|
||||
// Otherwise, the last set would be empty
|
||||
idsToTrim.removeLast()
|
||||
}
|
||||
let allItemsToDelete = idsToTrim.compactMap { set in set.boxedValue }.joined()
|
||||
if !allItemsToDelete.isEmpty {
|
||||
newItems.removeAll(where: { allItemsToDelete.contains($0.id) })
|
||||
}
|
||||
return newSplits
|
||||
}
|
||||
|
||||
private func removeDuplicatesAndUnusedSplits(
|
||||
_ newItems: inout [ChatItem],
|
||||
_ chat: Chat,
|
||||
_ splits: [Int64]
|
||||
) async -> [Int64] {
|
||||
if splits.isEmpty {
|
||||
removeDuplicates(&newItems, chat)
|
||||
return splits
|
||||
}
|
||||
|
||||
var newSplits = splits
|
||||
let (newIds, _) = mapItemsToIds(chat.chatItems)
|
||||
newItems.removeAll(where: {
|
||||
let duplicate = newIds.contains($0.id)
|
||||
if duplicate, let firstIndex = newSplits.firstIndex(of: $0.id) {
|
||||
newSplits.remove(at: firstIndex)
|
||||
}
|
||||
return duplicate
|
||||
})
|
||||
return newSplits
|
||||
}
|
||||
|
||||
// ids, number of unread items
|
||||
private func mapItemsToIds(_ items: [ChatItem]) -> (Set<Int64>, Int) {
|
||||
var unreadInLoaded = 0
|
||||
var ids: Set<Int64> = Set()
|
||||
var i = 0
|
||||
while i < items.count {
|
||||
let item = items[i]
|
||||
ids.insert(item.id)
|
||||
if item.isRcvNew {
|
||||
unreadInLoaded += 1
|
||||
}
|
||||
i += 1
|
||||
}
|
||||
return (ids, unreadInLoaded)
|
||||
}
|
||||
|
||||
private func removeDuplicates(_ newItems: inout [ChatItem], _ chat: Chat) {
|
||||
let (newIds, _) = mapItemsToIds(chat.chatItems)
|
||||
newItems.removeAll { newIds.contains($0.id) }
|
||||
}
|
||||
|
||||
private typealias SameTimeItem = (index: Int, item: ChatItem)
|
||||
|
||||
// return (item index, split index)
|
||||
private func indexToInsertAround(_ chatType: ChatType, _ lastNew: ChatItem?, to: [ChatItem], _ splits: Set<Int64>) -> (Int, Int) {
|
||||
guard to.count > 0, let lastNew = lastNew else { return (0, 0) }
|
||||
// group sorting: item_ts, item_id
|
||||
// everything else: created_at, item_id
|
||||
let compareByTimeTs = chatType == .group
|
||||
// in case several items have the same time as another item in the `to` array
|
||||
var sameTime: [SameTimeItem] = []
|
||||
|
||||
// trying to find new split index for item looks difficult but allows to not use one more loop.
|
||||
// The idea is to memorize how many splits were till any index (map number of splits until index)
|
||||
// and use resulting itemIndex to decide new split index position.
|
||||
// Because of the possibility to have many items with the same timestamp, it's possible to see `itemIndex < || == || > i`.
|
||||
var splitsTillIndex: [Int] = []
|
||||
var splitsPerPrevIndex = 0
|
||||
|
||||
for i in 0 ..< to.count {
|
||||
let item = to[i]
|
||||
|
||||
splitsPerPrevIndex = splits.contains(item.id) ? splitsPerPrevIndex + 1 : splitsPerPrevIndex
|
||||
splitsTillIndex.append(splitsPerPrevIndex)
|
||||
|
||||
let itemIsNewer = (compareByTimeTs ? item.meta.itemTs > lastNew.meta.itemTs : item.meta.createdAt > lastNew.meta.createdAt)
|
||||
if itemIsNewer || i + 1 == to.count {
|
||||
if (compareByTimeTs ? lastNew.meta.itemTs == item.meta.itemTs : lastNew.meta.createdAt == item.meta.createdAt) {
|
||||
sameTime.append((i, item))
|
||||
}
|
||||
// time to stop the loop. Item is newer or it's the last item in `to` array, taking previous items and checking position inside them
|
||||
let itemIndex: Int
|
||||
if sameTime.count > 1, let first = sameTime.sorted(by: { prev, next in prev.item.meta.itemId < next.item.id }).first(where: { same in same.item.id > lastNew.id }) {
|
||||
itemIndex = first.index
|
||||
} else if sameTime.count == 1 {
|
||||
itemIndex = sameTime[0].item.id > lastNew.id ? sameTime[0].index : sameTime[0].index + 1
|
||||
} else {
|
||||
itemIndex = itemIsNewer ? i : i + 1
|
||||
}
|
||||
let splitIndex = splitsTillIndex[min(itemIndex, splitsTillIndex.count - 1)]
|
||||
let prevItemSplitIndex = itemIndex == 0 ? 0 : splitsTillIndex[min(itemIndex - 1, splitsTillIndex.count - 1)]
|
||||
return (itemIndex, splitIndex == prevItemSplitIndex ? splitIndex : prevItemSplitIndex)
|
||||
}
|
||||
|
||||
if (compareByTimeTs ? lastNew.meta.itemTs == item.meta.itemTs : lastNew.meta.createdAt == item.meta.createdAt) {
|
||||
sameTime.append(SameTimeItem(index: i, item: item))
|
||||
} else {
|
||||
sameTime = []
|
||||
}
|
||||
}
|
||||
// shouldn't be here
|
||||
return (to.count, splits.count)
|
||||
}
|
||||
|
||||
private func indexToInsertAroundTest() {
|
||||
func assert(_ one: (Int, Int), _ two: (Int, Int)) {
|
||||
if one != two {
|
||||
logger.debug("\(String(describing: one)) != \(String(describing: two))")
|
||||
fatalError()
|
||||
}
|
||||
}
|
||||
|
||||
let itemsToInsert = [ChatItem.getSample(3, .groupSnd, Date.init(timeIntervalSince1970: 3), "")]
|
||||
let items1 = [
|
||||
ChatItem.getSample(0, .groupSnd, Date.init(timeIntervalSince1970: 0), ""),
|
||||
ChatItem.getSample(1, .groupSnd, Date.init(timeIntervalSince1970: 1), ""),
|
||||
ChatItem.getSample(2, .groupSnd, Date.init(timeIntervalSince1970: 2), "")
|
||||
]
|
||||
assert(indexToInsertAround(.group, itemsToInsert.last, to: items1, Set([1])), (3, 1))
|
||||
|
||||
let items2 = [
|
||||
ChatItem.getSample(0, .groupSnd, Date.init(timeIntervalSince1970: 0), ""),
|
||||
ChatItem.getSample(1, .groupSnd, Date.init(timeIntervalSince1970: 1), ""),
|
||||
ChatItem.getSample(2, .groupSnd, Date.init(timeIntervalSince1970: 3), "")
|
||||
]
|
||||
assert(indexToInsertAround(.group, itemsToInsert.last, to: items2, Set([2])), (3, 1))
|
||||
|
||||
let items3 = [
|
||||
ChatItem.getSample(0, .groupSnd, Date.init(timeIntervalSince1970: 0), ""),
|
||||
ChatItem.getSample(1, .groupSnd, Date.init(timeIntervalSince1970: 3), ""),
|
||||
ChatItem.getSample(2, .groupSnd, Date.init(timeIntervalSince1970: 3), "")
|
||||
]
|
||||
assert(indexToInsertAround(.group, itemsToInsert.last, to: items3, Set([1])), (3, 1))
|
||||
|
||||
let items4 = [
|
||||
ChatItem.getSample(0, .groupSnd, Date.init(timeIntervalSince1970: 0), ""),
|
||||
ChatItem.getSample(4, .groupSnd, Date.init(timeIntervalSince1970: 3), ""),
|
||||
ChatItem.getSample(5, .groupSnd, Date.init(timeIntervalSince1970: 3), "")
|
||||
]
|
||||
assert(indexToInsertAround(.group, itemsToInsert.last, to: items4, Set([4])), (1, 0))
|
||||
|
||||
let items5 = [
|
||||
ChatItem.getSample(0, .groupSnd, Date.init(timeIntervalSince1970: 0), ""),
|
||||
ChatItem.getSample(2, .groupSnd, Date.init(timeIntervalSince1970: 3), ""),
|
||||
ChatItem.getSample(4, .groupSnd, Date.init(timeIntervalSince1970: 3), "")
|
||||
]
|
||||
assert(indexToInsertAround(.group, itemsToInsert.last, to: items5, Set([2])), (2, 1))
|
||||
|
||||
let items6 = [
|
||||
ChatItem.getSample(4, .groupSnd, Date.init(timeIntervalSince1970: 4), ""),
|
||||
ChatItem.getSample(5, .groupSnd, Date.init(timeIntervalSince1970: 4), ""),
|
||||
ChatItem.getSample(6, .groupSnd, Date.init(timeIntervalSince1970: 4), "")
|
||||
]
|
||||
assert(indexToInsertAround(.group, itemsToInsert.last, to: items6, Set([5])), (0, 0))
|
||||
|
||||
let items7 = [
|
||||
ChatItem.getSample(4, .groupSnd, Date.init(timeIntervalSince1970: 4), ""),
|
||||
ChatItem.getSample(5, .groupSnd, Date.init(timeIntervalSince1970: 4), ""),
|
||||
ChatItem.getSample(6, .groupSnd, Date.init(timeIntervalSince1970: 4), "")
|
||||
]
|
||||
assert(indexToInsertAround(.group, nil, to: items7, Set([6])), (0, 0))
|
||||
|
||||
let items8 = [
|
||||
ChatItem.getSample(2, .groupSnd, Date.init(timeIntervalSince1970: 4), ""),
|
||||
ChatItem.getSample(4, .groupSnd, Date.init(timeIntervalSince1970: 3), ""),
|
||||
ChatItem.getSample(5, .groupSnd, Date.init(timeIntervalSince1970: 4), "")
|
||||
]
|
||||
assert(indexToInsertAround(.group, itemsToInsert.last, to: items8, Set([2])), (0, 0))
|
||||
|
||||
let items9 = [
|
||||
ChatItem.getSample(2, .groupSnd, Date.init(timeIntervalSince1970: 3), ""),
|
||||
ChatItem.getSample(4, .groupSnd, Date.init(timeIntervalSince1970: 3), ""),
|
||||
ChatItem.getSample(5, .groupSnd, Date.init(timeIntervalSince1970: 4), "")
|
||||
]
|
||||
assert(indexToInsertAround(.group, itemsToInsert.last, to: items9, Set([5])), (1, 0))
|
||||
|
||||
let items10 = [
|
||||
ChatItem.getSample(4, .groupSnd, Date.init(timeIntervalSince1970: 3), ""),
|
||||
ChatItem.getSample(5, .groupSnd, Date.init(timeIntervalSince1970: 3), ""),
|
||||
ChatItem.getSample(6, .groupSnd, Date.init(timeIntervalSince1970: 4), "")
|
||||
]
|
||||
assert(indexToInsertAround(.group, itemsToInsert.last, to: items10, Set([4])), (0, 0))
|
||||
|
||||
let items11: [ChatItem] = []
|
||||
assert(indexToInsertAround(.group, itemsToInsert.last, to: items11, Set([])), (0, 0))
|
||||
}
|
456
apps/ios/Shared/Views/Chat/ChatItemsMerger.swift
Normal file
456
apps/ios/Shared/Views/Chat/ChatItemsMerger.swift
Normal file
|
@ -0,0 +1,456 @@
|
|||
//
|
||||
// ChatItemsMerger.swift
|
||||
// SimpleX (iOS)
|
||||
//
|
||||
// Created by Stanislav Dmitrenko on 02.12.2024.
|
||||
// Copyright © 2024 SimpleX Chat. All rights reserved.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import SimpleXChat
|
||||
|
||||
struct MergedItems: Hashable, Equatable {
|
||||
let items: [MergedItem]
|
||||
let splits: [SplitRange]
|
||||
// chat item id, index in list
|
||||
let indexInParentItems: Dictionary<Int64, Int>
|
||||
|
||||
static func == (lhs: Self, rhs: Self) -> Bool {
|
||||
lhs.hashValue == rhs.hashValue
|
||||
}
|
||||
|
||||
func hash(into hasher: inout Hasher) {
|
||||
hasher.combine("\(items.hashValue)")
|
||||
}
|
||||
|
||||
static func create(_ items: [ChatItem], _ revealedItems: Set<Int64>, _ chatState: ActiveChatState) -> MergedItems {
|
||||
if items.isEmpty {
|
||||
return MergedItems(items: [], splits: [], indexInParentItems: [:])
|
||||
}
|
||||
|
||||
let unreadCount = chatState.unreadTotal
|
||||
|
||||
let unreadAfterItemId = chatState.unreadAfterItemId
|
||||
let itemSplits = chatState.splits
|
||||
var mergedItems: [MergedItem] = []
|
||||
// Indexes of splits here will be related to reversedChatItems, not chatModel.chatItems
|
||||
var splitRanges: [SplitRange] = []
|
||||
var indexInParentItems = Dictionary<Int64, Int>()
|
||||
var index = 0
|
||||
var unclosedSplitIndex: Int? = nil
|
||||
var unclosedSplitIndexInParent: Int? = nil
|
||||
var visibleItemIndexInParent = -1
|
||||
var unreadBefore = unreadCount - chatState.unreadAfterNewestLoaded
|
||||
var lastRevealedIdsInMergedItems: BoxedValue<[Int64]>? = nil
|
||||
var lastRangeInReversedForMergedItems: BoxedValue<ClosedRange<Int>>? = nil
|
||||
var recent: MergedItem? = nil
|
||||
while index < items.count {
|
||||
let item = items[index]
|
||||
let prev = index >= 1 ? items[index - 1] : nil
|
||||
let next = index + 1 < items.count ? items[index + 1] : nil
|
||||
let category = item.mergeCategory
|
||||
let itemIsSplit = itemSplits.contains(item.id)
|
||||
|
||||
if item.id == unreadAfterItemId {
|
||||
unreadBefore = unreadCount - chatState.unreadAfter
|
||||
}
|
||||
if item.isRcvNew {
|
||||
unreadBefore -= 1
|
||||
}
|
||||
|
||||
let revealed = item.mergeCategory == nil || revealedItems.contains(item.id)
|
||||
if recent != nil, case let .grouped(items, _, _, _, mergeCategory, unreadIds, _, _) = recent, mergeCategory == category, let first = items.boxedValue.first, !revealedItems.contains(first.item.id) && !itemIsSplit {
|
||||
let listItem = ListItem(item: item, prevItem: prev, nextItem: next, unreadBefore: unreadBefore)
|
||||
items.boxedValue.append(listItem)
|
||||
|
||||
if item.isRcvNew {
|
||||
unreadIds.boxedValue.insert(item.id)
|
||||
}
|
||||
if let lastRevealedIdsInMergedItems, let lastRangeInReversedForMergedItems {
|
||||
if revealed {
|
||||
lastRevealedIdsInMergedItems.boxedValue.append(item.id)
|
||||
}
|
||||
lastRangeInReversedForMergedItems.boxedValue = lastRangeInReversedForMergedItems.boxedValue.lowerBound ... index
|
||||
}
|
||||
} else {
|
||||
visibleItemIndexInParent += 1
|
||||
let listItem = ListItem(item: item, prevItem: prev, nextItem: next, unreadBefore: unreadBefore)
|
||||
if item.mergeCategory != nil {
|
||||
if item.mergeCategory != prev?.mergeCategory || lastRevealedIdsInMergedItems == nil {
|
||||
lastRevealedIdsInMergedItems = BoxedValue(revealedItems.contains(item.id) ? [item.id] : [])
|
||||
} else if revealed, let lastRevealedIdsInMergedItems {
|
||||
lastRevealedIdsInMergedItems.boxedValue.append(item.id)
|
||||
}
|
||||
lastRangeInReversedForMergedItems = BoxedValue(index ... index)
|
||||
recent = MergedItem.grouped(
|
||||
items: BoxedValue([listItem]),
|
||||
revealed: revealed,
|
||||
revealedIdsWithinGroup: lastRevealedIdsInMergedItems!,
|
||||
rangeInReversed: lastRangeInReversedForMergedItems!,
|
||||
mergeCategory: item.mergeCategory,
|
||||
unreadIds: BoxedValue(item.isRcvNew ? Set(arrayLiteral: item.id) : Set()),
|
||||
startIndexInReversedItems: index,
|
||||
hash: listItem.genHash(revealedItems.contains(prev?.id ?? -1), revealedItems.contains(next?.id ?? -1))
|
||||
)
|
||||
} else {
|
||||
lastRangeInReversedForMergedItems = nil
|
||||
recent = MergedItem.single(
|
||||
item: listItem,
|
||||
startIndexInReversedItems: index,
|
||||
hash: listItem.genHash(revealedItems.contains(prev?.id ?? -1), revealedItems.contains(next?.id ?? -1))
|
||||
)
|
||||
}
|
||||
mergedItems.append(recent!)
|
||||
}
|
||||
if itemIsSplit {
|
||||
// found item that is considered as a split
|
||||
if let unclosedSplitIndex, let unclosedSplitIndexInParent {
|
||||
// it was at least second split in the list
|
||||
splitRanges.append(SplitRange(itemId: items[unclosedSplitIndex].id, indexRangeInReversed: unclosedSplitIndex ... index - 1, indexRangeInParentItems: unclosedSplitIndexInParent ... visibleItemIndexInParent - 1))
|
||||
}
|
||||
unclosedSplitIndex = index
|
||||
unclosedSplitIndexInParent = visibleItemIndexInParent
|
||||
} else if index + 1 == items.count, let unclosedSplitIndex, let unclosedSplitIndexInParent {
|
||||
// just one split for the whole list, there will be no more, it's the end
|
||||
splitRanges.append(SplitRange(itemId: items[unclosedSplitIndex].id, indexRangeInReversed: unclosedSplitIndex ... index, indexRangeInParentItems: unclosedSplitIndexInParent ... visibleItemIndexInParent))
|
||||
}
|
||||
indexInParentItems[item.id] = visibleItemIndexInParent
|
||||
index += 1
|
||||
}
|
||||
return MergedItems(
|
||||
items: mergedItems,
|
||||
splits: splitRanges,
|
||||
indexInParentItems: indexInParentItems
|
||||
)
|
||||
}
|
||||
|
||||
// Use this check to ensure that mergedItems state based on currently actual state of global
|
||||
// splits and reversedChatItems
|
||||
func isActualState() -> Bool {
|
||||
let im = ItemsModel.shared
|
||||
// do not load anything if global splits state is different than in merged items because it
|
||||
// will produce undefined results in terms of loading and placement of items.
|
||||
// Same applies to reversedChatItems
|
||||
return indexInParentItems.count == im.reversedChatItems.count &&
|
||||
splits.count == im.chatState.splits.count &&
|
||||
// that's just an optimization because most of the time only 1 split exists
|
||||
((splits.count == 1 && splits[0].itemId == im.chatState.splits[0]) || splits.map({ split in split.itemId }).sorted() == im.chatState.splits.sorted())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
enum MergedItem: Identifiable, Hashable, Equatable {
|
||||
// equatable and hashable implementations allows to see the difference and correctly scroll to items we want
|
||||
static func == (lhs: Self, rhs: Self) -> Bool {
|
||||
lhs.hash == rhs.hash
|
||||
}
|
||||
|
||||
var id: Int64 { newest().item.id }
|
||||
|
||||
func hash(into hasher: inout Hasher) {
|
||||
hasher.combine(hash)
|
||||
}
|
||||
|
||||
var hash: String {
|
||||
switch self {
|
||||
case .single(_, _, let hash): hash + " 1"
|
||||
case .grouped(let items, _, _, _, _, _, _, let hash): hash + " \(items.boxedValue.count)"
|
||||
}
|
||||
}
|
||||
|
||||
// the item that is always single, cannot be grouped and always revealed
|
||||
case single(
|
||||
item: ListItem,
|
||||
startIndexInReversedItems: Int,
|
||||
hash: String
|
||||
)
|
||||
|
||||
/** The item that can contain multiple items or just one depending on revealed state. When the whole group of merged items is revealed,
|
||||
* there will be multiple [Grouped] items with revealed flag set to true. When the whole group is collapsed, it will be just one instance
|
||||
* of [Grouped] item with all grouped items inside [items]. In other words, number of [MergedItem] will always be equal to number of
|
||||
* visible items in ChatView's EndlessScrollView */
|
||||
case grouped (
|
||||
items: BoxedValue<[ListItem]>,
|
||||
revealed: Bool,
|
||||
// it stores ids for all consecutive revealed items from the same group in order to hide them all on user's action
|
||||
// it's the same list instance for all Grouped items within revealed group
|
||||
/** @see reveal */
|
||||
revealedIdsWithinGroup: BoxedValue<[Int64]>,
|
||||
rangeInReversed: BoxedValue<ClosedRange<Int>>,
|
||||
mergeCategory: CIMergeCategory?,
|
||||
unreadIds: BoxedValue<Set<Int64>>,
|
||||
startIndexInReversedItems: Int,
|
||||
hash: String
|
||||
)
|
||||
|
||||
func revealItems(_ reveal: Bool, _ revealedItems: Binding<Set<Int64>>) {
|
||||
if case .grouped(let items, _, let revealedIdsWithinGroup, _, _, _, _, _) = self {
|
||||
var newRevealed = revealedItems.wrappedValue
|
||||
var i = 0
|
||||
if reveal {
|
||||
while i < items.boxedValue.count {
|
||||
newRevealed.insert(items.boxedValue[i].item.id)
|
||||
i += 1
|
||||
}
|
||||
} else {
|
||||
while i < revealedIdsWithinGroup.boxedValue.count {
|
||||
newRevealed.remove(revealedIdsWithinGroup.boxedValue[i])
|
||||
i += 1
|
||||
}
|
||||
revealedIdsWithinGroup.boxedValue.removeAll()
|
||||
}
|
||||
revealedItems.wrappedValue = newRevealed
|
||||
}
|
||||
}
|
||||
|
||||
var startIndexInReversedItems: Int {
|
||||
get {
|
||||
switch self {
|
||||
case let .single(_, startIndexInReversedItems, _): startIndexInReversedItems
|
||||
case let .grouped(_, _, _, _, _, _, startIndexInReversedItems, _): startIndexInReversedItems
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func hasUnread() -> Bool {
|
||||
switch self {
|
||||
case let .single(item, _, _): item.item.isRcvNew
|
||||
case let .grouped(_, _, _, _, _, unreadIds, _, _): !unreadIds.boxedValue.isEmpty
|
||||
}
|
||||
}
|
||||
|
||||
func newest() -> ListItem {
|
||||
switch self {
|
||||
case let .single(item, _, _): item
|
||||
case let .grouped(items, _, _, _, _, _, _, _): items.boxedValue[0]
|
||||
}
|
||||
}
|
||||
|
||||
func oldest() -> ListItem {
|
||||
switch self {
|
||||
case let .single(item, _, _): item
|
||||
case let .grouped(items, _, _, _, _, _, _, _): items.boxedValue[items.boxedValue.count - 1]
|
||||
}
|
||||
}
|
||||
|
||||
func lastIndexInReversed() -> Int {
|
||||
switch self {
|
||||
case .single: startIndexInReversedItems
|
||||
case let .grouped(items, _, _, _, _, _, _, _): startIndexInReversedItems + items.boxedValue.count - 1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct SplitRange {
|
||||
let itemId: Int64
|
||||
/** range of indexes inside reversedChatItems where the first element is the split (it's index is [indexRangeInReversed.first])
|
||||
* so [0, 1, 2, -100-, 101] if the 3 is a split, SplitRange(indexRange = 3 .. 4) will be this SplitRange instance
|
||||
* (3, 4 indexes of the splitRange with the split itself at index 3)
|
||||
* */
|
||||
let indexRangeInReversed: ClosedRange<Int>
|
||||
/** range of indexes inside LazyColumn where the first element is the split (it's index is [indexRangeInParentItems.first]) */
|
||||
let indexRangeInParentItems: ClosedRange<Int>
|
||||
}
|
||||
|
||||
struct ListItem: Hashable {
|
||||
let item: ChatItem
|
||||
let prevItem: ChatItem?
|
||||
let nextItem: ChatItem?
|
||||
// how many unread items before (older than) this one (excluding this one)
|
||||
let unreadBefore: Int
|
||||
|
||||
private func chatDirHash(_ chatDir: CIDirection?) -> Int {
|
||||
guard let chatDir else { return 0 }
|
||||
return switch chatDir {
|
||||
case .directSnd: 0
|
||||
case .directRcv: 1
|
||||
case .groupSnd: 2
|
||||
case let .groupRcv(mem): "\(mem.groupMemberId) \(mem.displayName) \(mem.memberStatus.rawValue) \(mem.memberRole.rawValue) \(mem.image?.hash ?? 0)".hash
|
||||
case .localSnd: 4
|
||||
case .localRcv: 5
|
||||
}
|
||||
}
|
||||
|
||||
// using meta.hashValue instead of parts takes much more time so better to use partial meta here
|
||||
func genHash(_ prevRevealed: Bool, _ nextRevealed: Bool) -> String {
|
||||
"\(item.meta.itemId) \(item.meta.updatedAt.hashValue) \(item.meta.itemEdited) \(item.meta.itemDeleted?.hashValue ?? 0) \(item.meta.itemTimed?.hashValue ?? 0) \(item.meta.itemStatus.hashValue) \(item.meta.sentViaProxy ?? false) \(item.mergeCategory?.hashValue ?? 0) \(chatDirHash(item.chatDir)) \(item.reactions.hashValue) \(item.meta.isRcvNew) \(item.text.hash) \(item.file?.hashValue ?? 0) \(item.quotedItem?.itemId ?? 0) \(unreadBefore) \(prevItem?.id ?? 0) \(chatDirHash(prevItem?.chatDir)) \(prevItem?.mergeCategory?.hashValue ?? 0) \(prevRevealed) \(nextItem?.id ?? 0) \(chatDirHash(nextItem?.chatDir)) \(nextItem?.mergeCategory?.hashValue ?? 0) \(nextRevealed)"
|
||||
}
|
||||
}
|
||||
|
||||
class ActiveChatState {
|
||||
var splits: [Int64] = []
|
||||
var unreadAfterItemId: Int64 = -1
|
||||
// total items after unread after item (exclusive)
|
||||
var totalAfter: Int = 0
|
||||
var unreadTotal: Int = 0
|
||||
// exclusive
|
||||
var unreadAfter: Int = 0
|
||||
// exclusive
|
||||
var unreadAfterNewestLoaded: Int = 0
|
||||
|
||||
func moveUnreadAfterItem(_ toItemId: Int64?, _ nonReversedItems: [ChatItem]) {
|
||||
guard let toItemId else { return }
|
||||
let currentIndex = nonReversedItems.firstIndex(where: { $0.id == unreadAfterItemId })
|
||||
let newIndex = nonReversedItems.firstIndex(where: { $0.id == toItemId })
|
||||
guard let currentIndex, let newIndex else {
|
||||
return
|
||||
}
|
||||
unreadAfterItemId = toItemId
|
||||
let unreadDiff = newIndex > currentIndex
|
||||
? -nonReversedItems[currentIndex + 1..<newIndex + 1].filter { $0.isRcvNew }.count
|
||||
: nonReversedItems[newIndex + 1..<currentIndex + 1].filter { $0.isRcvNew }.count
|
||||
unreadAfter += unreadDiff
|
||||
}
|
||||
|
||||
func moveUnreadAfterItem(_ fromIndex: Int, _ toIndex: Int, _ nonReversedItems: [ChatItem]) {
|
||||
if fromIndex == -1 || toIndex == -1 {
|
||||
return
|
||||
}
|
||||
unreadAfterItemId = nonReversedItems[toIndex].id
|
||||
let unreadDiff = toIndex > fromIndex
|
||||
? -nonReversedItems[fromIndex + 1..<toIndex + 1].filter { $0.isRcvNew }.count
|
||||
: nonReversedItems[toIndex + 1..<fromIndex + 1].filter { $0.isRcvNew }.count
|
||||
unreadAfter += unreadDiff
|
||||
}
|
||||
|
||||
func clear() {
|
||||
splits = []
|
||||
unreadAfterItemId = -1
|
||||
totalAfter = 0
|
||||
unreadTotal = 0
|
||||
unreadAfter = 0
|
||||
unreadAfterNewestLoaded = 0
|
||||
}
|
||||
|
||||
func itemsRead(_ itemIds: Set<Int64>?, _ newItems: [ChatItem]) {
|
||||
guard let itemIds else {
|
||||
// special case when the whole chat became read
|
||||
unreadTotal = 0
|
||||
unreadAfter = 0
|
||||
return
|
||||
}
|
||||
var unreadAfterItemIndex: Int = -1
|
||||
// since it's more often that the newest items become read, it's logical to loop from the end of the list to finish it faster
|
||||
var i = newItems.count - 1
|
||||
var ids = itemIds
|
||||
// intermediate variables to prevent re-setting state value a lot of times without reason
|
||||
var newUnreadTotal = unreadTotal
|
||||
var newUnreadAfter = unreadAfter
|
||||
while i >= 0 {
|
||||
let item = newItems[i]
|
||||
if item.id == unreadAfterItemId {
|
||||
unreadAfterItemIndex = i
|
||||
}
|
||||
if ids.contains(item.id) {
|
||||
// was unread, now this item is read
|
||||
if (unreadAfterItemIndex == -1) {
|
||||
newUnreadAfter -= 1
|
||||
}
|
||||
newUnreadTotal -= 1
|
||||
ids.remove(item.id)
|
||||
if ids.isEmpty {
|
||||
break
|
||||
}
|
||||
}
|
||||
i -= 1
|
||||
}
|
||||
unreadTotal = newUnreadTotal
|
||||
unreadAfter = newUnreadAfter
|
||||
}
|
||||
|
||||
func itemAdded(_ item: (Int64, Bool), _ index: Int) {
|
||||
if item.1 {
|
||||
unreadAfter += 1
|
||||
unreadTotal += 1
|
||||
}
|
||||
}
|
||||
|
||||
func itemsRemoved(_ itemIds: [(Int64, Int, Bool)], _ newItems: [ChatItem]) {
|
||||
var newSplits: [Int64] = []
|
||||
for split in splits {
|
||||
let index = itemIds.firstIndex(where: { (delId, _, _) in delId == split })
|
||||
// deleted the item that was right before the split between items, find newer item so it will act like the split
|
||||
if let index {
|
||||
let idx = itemIds[index].1 - itemIds.filter { (_, delIndex, _) in delIndex <= index }.count
|
||||
let newSplit = newItems.count > idx && idx >= 0 ? newItems[idx].id : nil
|
||||
// it the whole section is gone and splits overlap, don't add it at all
|
||||
if let newSplit, !newSplits.contains(newSplit) {
|
||||
newSplits.append(newSplit)
|
||||
}
|
||||
} else {
|
||||
newSplits.append(split)
|
||||
}
|
||||
}
|
||||
splits = newSplits
|
||||
|
||||
let index = itemIds.firstIndex(where: { (delId, _, _) in delId == unreadAfterItemId })
|
||||
// unread after item was removed
|
||||
if let index {
|
||||
let idx = itemIds[index].1 - itemIds.filter { (_, delIndex, _) in delIndex <= index }.count
|
||||
var newUnreadAfterItemId = newItems.count > idx && idx >= 0 ? newItems[idx].id : nil
|
||||
let newUnreadAfterItemWasNull = newUnreadAfterItemId == nil
|
||||
if newUnreadAfterItemId == nil {
|
||||
// everything on top (including unread after item) were deleted, take top item as unread after id
|
||||
newUnreadAfterItemId = newItems.first?.id
|
||||
}
|
||||
if let newUnreadAfterItemId {
|
||||
unreadAfterItemId = newUnreadAfterItemId
|
||||
totalAfter -= itemIds.filter { (_, delIndex, _) in delIndex > index }.count
|
||||
unreadTotal -= itemIds.filter { (_, delIndex, isRcvNew) in delIndex <= index && isRcvNew }.count
|
||||
unreadAfter -= itemIds.filter { (_, delIndex, isRcvNew) in delIndex > index && isRcvNew }.count
|
||||
if newUnreadAfterItemWasNull {
|
||||
// since the unread after item was moved one item after initial position, adjust counters accordingly
|
||||
if newItems.first?.isRcvNew == true {
|
||||
unreadTotal += 1
|
||||
unreadAfter -= 1
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// all items were deleted, 0 items in chatItems
|
||||
unreadAfterItemId = -1
|
||||
totalAfter = 0
|
||||
unreadTotal = 0
|
||||
unreadAfter = 0
|
||||
}
|
||||
} else {
|
||||
totalAfter -= itemIds.count
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class BoxedValue<T: Hashable>: Equatable, Hashable {
|
||||
static func == (lhs: BoxedValue<T>, rhs: BoxedValue<T>) -> Bool {
|
||||
lhs.boxedValue == rhs.boxedValue
|
||||
}
|
||||
|
||||
func hash(into hasher: inout Hasher) {
|
||||
hasher.combine("\(self)")
|
||||
}
|
||||
|
||||
var boxedValue : T
|
||||
init(_ value: T) {
|
||||
self.boxedValue = value
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func visibleItemIndexesNonReversed(_ listState: EndlessScrollView<MergedItem>.ListState, _ mergedItems: MergedItems) -> ClosedRange<Int> {
|
||||
let zero = 0 ... 0
|
||||
let items = mergedItems.items
|
||||
if items.isEmpty {
|
||||
return zero
|
||||
}
|
||||
let newest = items.count > listState.firstVisibleItemIndex ? items[listState.firstVisibleItemIndex].startIndexInReversedItems : nil
|
||||
let oldest = items.count > listState.lastVisibleItemIndex ? items[listState.lastVisibleItemIndex].lastIndexInReversed() : nil
|
||||
guard let newest, let oldest else {
|
||||
return zero
|
||||
}
|
||||
let size = ItemsModel.shared.reversedChatItems.count
|
||||
let range = size - oldest ... size - newest
|
||||
if range.lowerBound < 0 || range.upperBound < 0 {
|
||||
return zero
|
||||
}
|
||||
|
||||
// visible items mapped to their underlying data structure which is ItemsModel.shared.reversedChatItems.reversed()
|
||||
return range
|
||||
}
|
185
apps/ios/Shared/Views/Chat/ChatScrollHelpers.swift
Normal file
185
apps/ios/Shared/Views/Chat/ChatScrollHelpers.swift
Normal file
|
@ -0,0 +1,185 @@
|
|||
//
|
||||
// ChatScrollHelpers.swift
|
||||
// SimpleX (iOS)
|
||||
//
|
||||
// Created by Stanislav Dmitrenko on 20.12.2024.
|
||||
// Copyright © 2024 SimpleX Chat. All rights reserved.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import SimpleXChat
|
||||
|
||||
func loadLastItems(_ loadingMoreItems: Binding<Bool>, loadingBottomItems: Binding<Bool>, _ chat: Chat) async {
|
||||
await MainActor.run {
|
||||
loadingMoreItems.wrappedValue = true
|
||||
loadingBottomItems.wrappedValue = true
|
||||
}
|
||||
try? await Task.sleep(nanoseconds: 500_000000)
|
||||
if ChatModel.shared.chatId != chat.chatInfo.id {
|
||||
await MainActor.run {
|
||||
loadingMoreItems.wrappedValue = false
|
||||
loadingBottomItems.wrappedValue = false
|
||||
}
|
||||
return
|
||||
}
|
||||
await apiLoadMessages(chat.chatInfo.id, ChatPagination.last(count: 50), ItemsModel.shared.chatState)
|
||||
await MainActor.run {
|
||||
loadingMoreItems.wrappedValue = false
|
||||
loadingBottomItems.wrappedValue = false
|
||||
}
|
||||
}
|
||||
|
||||
class PreloadState {
|
||||
static let shared = PreloadState()
|
||||
var prevFirstVisible: Int64 = Int64.min
|
||||
var prevItemsCount: Int = 0
|
||||
var preloading: Bool = false
|
||||
|
||||
func clear() {
|
||||
prevFirstVisible = Int64.min
|
||||
prevItemsCount = 0
|
||||
preloading = false
|
||||
}
|
||||
}
|
||||
|
||||
func preloadIfNeeded(
|
||||
_ allowLoadMoreItems: Binding<Bool>,
|
||||
_ ignoreLoadingRequests: Binding<Int64?>,
|
||||
_ listState: EndlessScrollView<MergedItem>.ListState,
|
||||
_ mergedItems: BoxedValue<MergedItems>,
|
||||
loadItems: @escaping (Bool, ChatPagination) async -> Bool,
|
||||
loadLastItems: @escaping () async -> Void
|
||||
) {
|
||||
let state = PreloadState.shared
|
||||
guard !listState.isScrolling && !listState.isAnimatedScrolling,
|
||||
!state.preloading,
|
||||
listState.totalItemsCount > 0
|
||||
else {
|
||||
return
|
||||
}
|
||||
if state.prevFirstVisible != listState.firstVisibleItemId as! Int64 || state.prevItemsCount != mergedItems.boxedValue.indexInParentItems.count {
|
||||
state.preloading = true
|
||||
let allowLoadMore = allowLoadMoreItems.wrappedValue
|
||||
Task {
|
||||
defer { state.preloading = false }
|
||||
var triedToLoad = true
|
||||
await preloadItems(mergedItems.boxedValue, allowLoadMore, listState, ignoreLoadingRequests) { pagination in
|
||||
triedToLoad = await loadItems(false, pagination)
|
||||
return triedToLoad
|
||||
}
|
||||
if triedToLoad {
|
||||
state.prevFirstVisible = listState.firstVisibleItemId as! Int64
|
||||
state.prevItemsCount = mergedItems.boxedValue.indexInParentItems.count
|
||||
}
|
||||
// it's important to ask last items when the view is fully covered with items. Otherwise, visible items from one
|
||||
// split will be merged with last items and position of scroll will change unexpectedly.
|
||||
if listState.itemsCanCoverScreen && !ItemsModel.shared.lastItemsLoaded {
|
||||
await loadLastItems()
|
||||
}
|
||||
}
|
||||
} else if listState.itemsCanCoverScreen && !ItemsModel.shared.lastItemsLoaded {
|
||||
state.preloading = true
|
||||
Task {
|
||||
defer { state.preloading = false }
|
||||
await loadLastItems()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func preloadItems(
|
||||
_ mergedItems: MergedItems,
|
||||
_ allowLoadMoreItems: Bool,
|
||||
_ listState: EndlessScrollView<MergedItem>.ListState,
|
||||
_ ignoreLoadingRequests: Binding<Int64?>,
|
||||
_ loadItems: @escaping (ChatPagination) async -> Bool)
|
||||
async {
|
||||
let allowLoad = allowLoadMoreItems || mergedItems.items.count == listState.lastVisibleItemIndex + 1
|
||||
let remaining = ChatPagination.UNTIL_PRELOAD_COUNT
|
||||
let firstVisibleIndex = listState.firstVisibleItemIndex
|
||||
|
||||
if !(await preloadItemsBefore()) {
|
||||
await preloadItemsAfter()
|
||||
}
|
||||
|
||||
func preloadItemsBefore() async -> Bool {
|
||||
let splits = mergedItems.splits
|
||||
let lastVisibleIndex = listState.lastVisibleItemIndex
|
||||
var lastIndexToLoadFrom: Int? = findLastIndexToLoadFromInSplits(firstVisibleIndex, lastVisibleIndex, remaining, splits)
|
||||
let items: [ChatItem] = ItemsModel.shared.reversedChatItems.reversed()
|
||||
if splits.isEmpty && !items.isEmpty && lastVisibleIndex > mergedItems.items.count - remaining {
|
||||
lastIndexToLoadFrom = items.count - 1
|
||||
}
|
||||
let loadFromItemId: Int64?
|
||||
if allowLoad, let lastIndexToLoadFrom {
|
||||
let index = items.count - 1 - lastIndexToLoadFrom
|
||||
loadFromItemId = index >= 0 ? items[index].id : nil
|
||||
} else {
|
||||
loadFromItemId = nil
|
||||
}
|
||||
guard let loadFromItemId, ignoreLoadingRequests.wrappedValue != loadFromItemId else {
|
||||
return false
|
||||
}
|
||||
let sizeWas = items.count
|
||||
let firstItemIdWas = items.first?.id
|
||||
let triedToLoad = await loadItems(ChatPagination.before(chatItemId: loadFromItemId, count: ChatPagination.PRELOAD_COUNT))
|
||||
if triedToLoad && sizeWas == ItemsModel.shared.reversedChatItems.count && firstItemIdWas == ItemsModel.shared.reversedChatItems.last?.id {
|
||||
ignoreLoadingRequests.wrappedValue = loadFromItemId
|
||||
return false
|
||||
}
|
||||
return triedToLoad
|
||||
}
|
||||
|
||||
func preloadItemsAfter() async {
|
||||
let splits = mergedItems.splits
|
||||
let split = splits.last(where: { $0.indexRangeInParentItems.contains(firstVisibleIndex) })
|
||||
// we're inside a splitRange (top --- [end of the splitRange --- we're here --- start of the splitRange] --- bottom)
|
||||
let reversedItems: [ChatItem] = ItemsModel.shared.reversedChatItems
|
||||
if let split, split.indexRangeInParentItems.lowerBound + remaining > firstVisibleIndex {
|
||||
let index = split.indexRangeInReversed.lowerBound
|
||||
if index >= 0 {
|
||||
let loadFromItemId = reversedItems[index].id
|
||||
_ = await loadItems(ChatPagination.after(chatItemId: loadFromItemId, count: ChatPagination.PRELOAD_COUNT))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func oldestPartiallyVisibleListItemInListStateOrNull(_ listState: EndlessScrollView<MergedItem>.ListState) -> ListItem? {
|
||||
if listState.lastVisibleItemIndex < listState.items.count {
|
||||
return listState.items[listState.lastVisibleItemIndex].oldest()
|
||||
} else {
|
||||
return listState.items.last?.oldest()
|
||||
}
|
||||
}
|
||||
|
||||
private func findLastIndexToLoadFromInSplits(_ firstVisibleIndex: Int, _ lastVisibleIndex: Int, _ remaining: Int, _ splits: [SplitRange]) -> Int? {
|
||||
for split in splits {
|
||||
// before any split
|
||||
if split.indexRangeInParentItems.lowerBound > firstVisibleIndex {
|
||||
if lastVisibleIndex > (split.indexRangeInParentItems.lowerBound - remaining) {
|
||||
return split.indexRangeInReversed.lowerBound - 1
|
||||
}
|
||||
break
|
||||
}
|
||||
let containsInRange = split.indexRangeInParentItems.contains(firstVisibleIndex)
|
||||
if containsInRange {
|
||||
if lastVisibleIndex > (split.indexRangeInParentItems.upperBound - remaining) {
|
||||
return split.indexRangeInReversed.upperBound
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
/// Disable animation on iOS 15
|
||||
func withConditionalAnimation<Result>(
|
||||
_ animation: Animation? = .default,
|
||||
_ body: () throws -> Result
|
||||
) rethrows -> Result {
|
||||
if #available(iOS 16.0, *) {
|
||||
try withAnimation(animation, body)
|
||||
} else {
|
||||
try body()
|
||||
}
|
||||
}
|
File diff suppressed because it is too large
Load diff
|
@ -18,7 +18,7 @@ struct ComposeLinkView: View {
|
|||
|
||||
var body: some View {
|
||||
HStack(alignment: .center, spacing: 8) {
|
||||
if let linkPreview = linkPreview {
|
||||
if let linkPreview {
|
||||
linkPreviewView(linkPreview)
|
||||
} else {
|
||||
ProgressView()
|
||||
|
|
|
@ -11,6 +11,8 @@ import SimpleXChat
|
|||
import SwiftyGif
|
||||
import PhotosUI
|
||||
|
||||
let MAX_NUMBER_OF_MENTIONS = 3
|
||||
|
||||
enum ComposePreview {
|
||||
case noPreview
|
||||
case linkPreview(linkPreview: LinkPreview?)
|
||||
|
@ -19,11 +21,12 @@ enum ComposePreview {
|
|||
case filePreview(fileName: String, file: URL)
|
||||
}
|
||||
|
||||
enum ComposeContextItem {
|
||||
enum ComposeContextItem: Equatable {
|
||||
case noContextItem
|
||||
case quotedItem(chatItem: ChatItem)
|
||||
case editingItem(chatItem: ChatItem)
|
||||
case forwardingItems(chatItems: [ChatItem], fromChatInfo: ChatInfo)
|
||||
case reportedItem(chatItem: ChatItem, reason: ReportReason)
|
||||
}
|
||||
|
||||
enum VoiceMessageRecordingState {
|
||||
|
@ -38,31 +41,41 @@ struct LiveMessage {
|
|||
var sentMsg: String?
|
||||
}
|
||||
|
||||
typealias MentionedMembers = [String: CIMention]
|
||||
|
||||
struct ComposeState {
|
||||
var message: String
|
||||
var parsedMessage: [FormattedText]
|
||||
var liveMessage: LiveMessage? = nil
|
||||
var preview: ComposePreview
|
||||
var contextItem: ComposeContextItem
|
||||
var voiceMessageRecordingState: VoiceMessageRecordingState
|
||||
var inProgress = false
|
||||
var useLinkPreviews: Bool = UserDefaults.standard.bool(forKey: DEFAULT_PRIVACY_LINK_PREVIEWS)
|
||||
var mentions: MentionedMembers = [:]
|
||||
|
||||
init(
|
||||
message: String = "",
|
||||
parsedMessage: [FormattedText] = [],
|
||||
liveMessage: LiveMessage? = nil,
|
||||
preview: ComposePreview = .noPreview,
|
||||
contextItem: ComposeContextItem = .noContextItem,
|
||||
voiceMessageRecordingState: VoiceMessageRecordingState = .noRecording
|
||||
voiceMessageRecordingState: VoiceMessageRecordingState = .noRecording,
|
||||
mentions: MentionedMembers = [:]
|
||||
) {
|
||||
self.message = message
|
||||
self.parsedMessage = parsedMessage
|
||||
self.liveMessage = liveMessage
|
||||
self.preview = preview
|
||||
self.contextItem = contextItem
|
||||
self.voiceMessageRecordingState = voiceMessageRecordingState
|
||||
self.mentions = mentions
|
||||
}
|
||||
|
||||
init(editingItem: ChatItem) {
|
||||
self.message = editingItem.content.text
|
||||
let text = editingItem.content.text
|
||||
self.message = text
|
||||
self.parsedMessage = editingItem.formattedText ?? FormattedText.plain(text)
|
||||
self.preview = chatItemPreview(chatItem: editingItem)
|
||||
self.contextItem = .editingItem(chatItem: editingItem)
|
||||
if let emc = editingItem.content.msgContent,
|
||||
|
@ -71,10 +84,12 @@ struct ComposeState {
|
|||
} else {
|
||||
self.voiceMessageRecordingState = .noRecording
|
||||
}
|
||||
self.mentions = editingItem.mentions ?? [:]
|
||||
}
|
||||
|
||||
init(forwardingItems: [ChatItem], fromChatInfo: ChatInfo) {
|
||||
self.message = ""
|
||||
self.parsedMessage = []
|
||||
self.preview = .noPreview
|
||||
self.contextItem = .forwardingItems(chatItems: forwardingItems, fromChatInfo: fromChatInfo)
|
||||
self.voiceMessageRecordingState = .noRecording
|
||||
|
@ -82,20 +97,38 @@ struct ComposeState {
|
|||
|
||||
func copy(
|
||||
message: String? = nil,
|
||||
parsedMessage: [FormattedText]? = nil,
|
||||
liveMessage: LiveMessage? = nil,
|
||||
preview: ComposePreview? = nil,
|
||||
contextItem: ComposeContextItem? = nil,
|
||||
voiceMessageRecordingState: VoiceMessageRecordingState? = nil
|
||||
voiceMessageRecordingState: VoiceMessageRecordingState? = nil,
|
||||
mentions: MentionedMembers? = nil
|
||||
) -> ComposeState {
|
||||
ComposeState(
|
||||
message: message ?? self.message,
|
||||
parsedMessage: parsedMessage ?? self.parsedMessage,
|
||||
liveMessage: liveMessage ?? self.liveMessage,
|
||||
preview: preview ?? self.preview,
|
||||
contextItem: contextItem ?? self.contextItem,
|
||||
voiceMessageRecordingState: voiceMessageRecordingState ?? self.voiceMessageRecordingState
|
||||
voiceMessageRecordingState: voiceMessageRecordingState ?? self.voiceMessageRecordingState,
|
||||
mentions: mentions ?? self.mentions
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
func mentionMemberName(_ name: String) -> String {
|
||||
var n = 0
|
||||
var tryName = name
|
||||
while mentions[tryName] != nil {
|
||||
n += 1
|
||||
tryName = "\(name)_\(n)"
|
||||
}
|
||||
return tryName
|
||||
}
|
||||
|
||||
var memberMentions: [String: Int64] {
|
||||
self.mentions.compactMapValues { $0.memberRef?.groupMemberId }
|
||||
}
|
||||
|
||||
var editing: Bool {
|
||||
switch contextItem {
|
||||
case .editingItem: return true
|
||||
|
@ -116,13 +149,31 @@ struct ComposeState {
|
|||
default: return false
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
var reporting: Bool {
|
||||
switch contextItem {
|
||||
case .reportedItem: return true
|
||||
default: return false
|
||||
}
|
||||
}
|
||||
|
||||
var submittingValidReport: Bool {
|
||||
switch contextItem {
|
||||
case let .reportedItem(_, reason):
|
||||
switch reason {
|
||||
case .other: return !message.isEmpty
|
||||
default: return true
|
||||
}
|
||||
default: return false
|
||||
}
|
||||
}
|
||||
|
||||
var sendEnabled: Bool {
|
||||
switch preview {
|
||||
case let .mediaPreviews(media): return !media.isEmpty
|
||||
case .voicePreview: return voiceMessageRecordingState == .finished
|
||||
case .filePreview: return true
|
||||
default: return !message.isEmpty || forwarding || liveMessage != nil
|
||||
default: return !message.isEmpty || forwarding || liveMessage != nil || submittingValidReport
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -175,7 +226,7 @@ struct ComposeState {
|
|||
}
|
||||
|
||||
var attachmentDisabled: Bool {
|
||||
if editing || forwarding || liveMessage != nil || inProgress { return true }
|
||||
if editing || forwarding || liveMessage != nil || inProgress || reporting { return true }
|
||||
switch preview {
|
||||
case .noPreview: return false
|
||||
case .linkPreview: return false
|
||||
|
@ -193,6 +244,15 @@ struct ComposeState {
|
|||
}
|
||||
}
|
||||
|
||||
var placeholder: String? {
|
||||
switch contextItem {
|
||||
case let .reportedItem(_, reason):
|
||||
return reason.text
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
var empty: Bool {
|
||||
message == "" && noPreview
|
||||
}
|
||||
|
@ -265,6 +325,9 @@ struct ComposeView: View {
|
|||
@ObservedObject var chat: Chat
|
||||
@Binding var composeState: ComposeState
|
||||
@Binding var keyboardVisible: Bool
|
||||
@Binding var keyboardHiddenDate: Date
|
||||
@Binding var selectedRange: NSRange
|
||||
var disabledText: LocalizedStringKey? = nil
|
||||
|
||||
@State var linkUrl: URL? = nil
|
||||
@State var hasSimplexLink: Bool = false
|
||||
|
@ -297,6 +360,11 @@ struct ComposeView: View {
|
|||
ContextInvitingContactMemberView()
|
||||
Divider()
|
||||
}
|
||||
|
||||
if case let .reportedItem(_, reason) = composeState.contextItem {
|
||||
reportReasonView(reason)
|
||||
Divider()
|
||||
}
|
||||
// preference checks should match checks in forwarding list
|
||||
let simplexLinkProhibited = hasSimplexLink && !chat.groupFeatureEnabled(.simplexLinks)
|
||||
let fileProhibited = composeState.attachmentPreview && !chat.groupFeatureEnabled(.files)
|
||||
|
@ -324,9 +392,9 @@ struct ComposeView: View {
|
|||
Image(systemName: "paperclip")
|
||||
.resizable()
|
||||
}
|
||||
.disabled(composeState.attachmentDisabled || !chat.userCanSend || (chat.chatInfo.contact?.nextSendGrpInv ?? false))
|
||||
.disabled(composeState.attachmentDisabled || !chat.chatInfo.sendMsgEnabled || (chat.chatInfo.contact?.nextSendGrpInv ?? false))
|
||||
.frame(width: 25, height: 25)
|
||||
.padding(.bottom, 12)
|
||||
.padding(.bottom, 16)
|
||||
.padding(.leading, 12)
|
||||
.tint(theme.colors.primary)
|
||||
if case let .group(g) = chat.chatInfo,
|
||||
|
@ -343,6 +411,7 @@ struct ComposeView: View {
|
|||
ZStack(alignment: .leading) {
|
||||
SendMessageView(
|
||||
composeState: $composeState,
|
||||
selectedRange: $selectedRange,
|
||||
sendMessage: { ttl in
|
||||
sendMessage(ttl: ttl)
|
||||
resetLinkPreview()
|
||||
|
@ -367,24 +436,19 @@ struct ComposeView: View {
|
|||
timedMessageAllowed: chat.chatInfo.featureEnabled(.timedMessages),
|
||||
onMediaAdded: { media in if !media.isEmpty { chosenMedia = media }},
|
||||
keyboardVisible: $keyboardVisible,
|
||||
keyboardHiddenDate: $keyboardHiddenDate,
|
||||
sendButtonColor: chat.chatInfo.incognito
|
||||
? .indigo.opacity(colorScheme == .dark ? 1 : 0.7)
|
||||
: theme.colors.primary
|
||||
)
|
||||
.padding(.trailing, 12)
|
||||
.disabled(!chat.userCanSend)
|
||||
.disabled(!chat.chatInfo.sendMsgEnabled)
|
||||
|
||||
if chat.userIsObserver {
|
||||
Text("you are observer")
|
||||
if let disabledText {
|
||||
Text(disabledText)
|
||||
.italic()
|
||||
.foregroundColor(theme.colors.secondary)
|
||||
.padding(.horizontal, 12)
|
||||
.onTapGesture {
|
||||
AlertManager.shared.showAlertMsg(
|
||||
title: "You can't send messages!",
|
||||
message: "Please contact group admin."
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -395,21 +459,23 @@ struct ComposeView: View {
|
|||
.ignoresSafeArea(.all, edges: .bottom)
|
||||
}
|
||||
.onChange(of: composeState.message) { msg in
|
||||
let parsedMsg = parseSimpleXMarkdown(msg)
|
||||
composeState = composeState.copy(parsedMessage: parsedMsg ?? FormattedText.plain(msg))
|
||||
if composeState.linkPreviewAllowed {
|
||||
if msg.count > 0 {
|
||||
showLinkPreview(msg)
|
||||
showLinkPreview(parsedMsg)
|
||||
} else {
|
||||
resetLinkPreview()
|
||||
hasSimplexLink = false
|
||||
}
|
||||
} else if msg.count > 0 && !chat.groupFeatureEnabled(.simplexLinks) {
|
||||
(_, hasSimplexLink) = parseMessage(msg)
|
||||
(_, hasSimplexLink) = getSimplexLink(parsedMsg)
|
||||
} else {
|
||||
hasSimplexLink = false
|
||||
}
|
||||
}
|
||||
.onChange(of: chat.userCanSend) { canSend in
|
||||
if !canSend {
|
||||
.onChange(of: chat.chatInfo.sendMsgEnabled) { sendEnabled in
|
||||
if !sendEnabled {
|
||||
cancelCurrentVoiceRecording()
|
||||
clearCurrentDraft()
|
||||
clearState()
|
||||
|
@ -686,6 +752,27 @@ struct ComposeView: View {
|
|||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(.thinMaterial)
|
||||
}
|
||||
|
||||
|
||||
private func reportReasonView(_ reason: ReportReason) -> some View {
|
||||
let reportText = switch reason {
|
||||
case .spam: NSLocalizedString("Report spam: only group moderators will see it.", comment: "report reason")
|
||||
case .profile: NSLocalizedString("Report member profile: only group moderators will see it.", comment: "report reason")
|
||||
case .community: NSLocalizedString("Report violation: only group moderators will see it.", comment: "report reason")
|
||||
case .illegal: NSLocalizedString("Report content: only group moderators will see it.", comment: "report reason")
|
||||
case .other: NSLocalizedString("Report other: only group moderators will see it.", comment: "report reason")
|
||||
case .unknown: "" // Should never happen
|
||||
}
|
||||
|
||||
return Text(reportText)
|
||||
.italic()
|
||||
.font(.caption)
|
||||
.padding(12)
|
||||
.frame(minHeight: 44)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(.thinMaterial)
|
||||
}
|
||||
|
||||
|
||||
@ViewBuilder private func contextItemView() -> some View {
|
||||
switch composeState.contextItem {
|
||||
|
@ -715,6 +802,15 @@ struct ComposeView: View {
|
|||
cancelContextItem: { composeState = composeState.copy(contextItem: .noContextItem) }
|
||||
)
|
||||
Divider()
|
||||
case let .reportedItem(chatItem: reportedItem, _):
|
||||
ContextItemView(
|
||||
chat: chat,
|
||||
contextItems: [reportedItem],
|
||||
contextIcon: "flag",
|
||||
cancelContextItem: { composeState = composeState.copy(contextItem: .noContextItem) },
|
||||
contextIconForeground: Color.red
|
||||
)
|
||||
Divider()
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -730,6 +826,7 @@ struct ComposeView: View {
|
|||
var sent: ChatItem?
|
||||
let msgText = text ?? composeState.message
|
||||
let liveMessage = composeState.liveMessage
|
||||
let mentions = composeState.memberMentions
|
||||
if !live {
|
||||
if liveMessage != nil { composeState = composeState.copy(liveMessage: nil) }
|
||||
await sending()
|
||||
|
@ -740,12 +837,14 @@ struct ComposeView: View {
|
|||
// Composed text is send as a reply to the last forwarded item
|
||||
sent = await forwardItems(chatItems, fromChatInfo, ttl).last
|
||||
if !composeState.message.isEmpty {
|
||||
_ = await send(checkLinkPreview(), quoted: sent?.id, live: false, ttl: ttl)
|
||||
_ = await send(checkLinkPreview(), quoted: sent?.id, live: false, ttl: ttl, mentions: mentions)
|
||||
}
|
||||
} else if case let .editingItem(ci) = composeState.contextItem {
|
||||
sent = await updateMessage(ci, live: live)
|
||||
} else if let liveMessage = liveMessage, liveMessage.sentMsg != nil {
|
||||
sent = await updateMessage(liveMessage.chatItem, live: live)
|
||||
} else if case let .reportedItem(chatItem, reason) = composeState.contextItem {
|
||||
sent = await send(reason, chatItemId: chatItem.id)
|
||||
} else {
|
||||
var quoted: Int64? = nil
|
||||
if case let .quotedItem(chatItem: quotedItem) = composeState.contextItem {
|
||||
|
@ -754,10 +853,11 @@ struct ComposeView: View {
|
|||
|
||||
switch (composeState.preview) {
|
||||
case .noPreview:
|
||||
sent = await send(.text(msgText), quoted: quoted, live: live, ttl: ttl)
|
||||
sent = await send(.text(msgText), quoted: quoted, live: live, ttl: ttl, mentions: mentions)
|
||||
case .linkPreview:
|
||||
sent = await send(checkLinkPreview(), quoted: quoted, live: live, ttl: ttl)
|
||||
sent = await send(checkLinkPreview(), quoted: quoted, live: live, ttl: ttl, mentions: mentions)
|
||||
case let .mediaPreviews(media):
|
||||
// TODO: CHECK THIS
|
||||
let last = media.count - 1
|
||||
var msgs: [ComposedMessage] = []
|
||||
if last >= 0 {
|
||||
|
@ -782,10 +882,10 @@ struct ComposeView: View {
|
|||
case let .voicePreview(recordingFileName, duration):
|
||||
stopPlayback.toggle()
|
||||
let file = voiceCryptoFile(recordingFileName)
|
||||
sent = await send(.voice(text: msgText, duration: duration), quoted: quoted, file: file, ttl: ttl)
|
||||
sent = await send(.voice(text: msgText, duration: duration), quoted: quoted, file: file, ttl: ttl, mentions: mentions)
|
||||
case let .filePreview(_, file):
|
||||
if let savedFile = saveFileFromURL(file) {
|
||||
sent = await send(.file(msgText), quoted: quoted, file: savedFile, live: live, ttl: ttl)
|
||||
sent = await send(.file(msgText), quoted: quoted, file: savedFile, live: live, ttl: ttl, mentions: mentions)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -840,7 +940,7 @@ struct ComposeView: View {
|
|||
type: chat.chatInfo.chatType,
|
||||
id: chat.chatInfo.apiId,
|
||||
itemId: ei.id,
|
||||
msg: mc,
|
||||
updatedMessage: UpdatedMessage(msgContent: mc, mentions: composeState.memberMentions),
|
||||
live: live
|
||||
)
|
||||
await MainActor.run {
|
||||
|
@ -872,6 +972,8 @@ struct ComposeView: View {
|
|||
return .voice(text: msgText, duration: duration)
|
||||
case .file:
|
||||
return .file(msgText)
|
||||
case .report(_, let reason):
|
||||
return .report(text: msgText, reason: reason)
|
||||
case .unknown(let type, _):
|
||||
return .unknown(type: type, text: msgText)
|
||||
}
|
||||
|
@ -891,10 +993,28 @@ struct ComposeView: View {
|
|||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func send(_ mc: MsgContent, quoted: Int64?, file: CryptoFile? = nil, live: Bool = false, ttl: Int?) async -> ChatItem? {
|
||||
|
||||
func send(_ reportReason: ReportReason, chatItemId: Int64) async -> ChatItem? {
|
||||
if let chatItems = await apiReportMessage(
|
||||
groupId: chat.chatInfo.apiId,
|
||||
chatItemId: chatItemId,
|
||||
reportReason: reportReason,
|
||||
reportText: msgText
|
||||
) {
|
||||
await MainActor.run {
|
||||
for chatItem in chatItems {
|
||||
chatModel.addChatItem(chat.chatInfo, chatItem)
|
||||
}
|
||||
}
|
||||
return chatItems.first
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func send(_ mc: MsgContent, quoted: Int64?, file: CryptoFile? = nil, live: Bool = false, ttl: Int?, mentions: [String: Int64]) async -> ChatItem? {
|
||||
await send(
|
||||
[ComposedMessage(fileSource: file, quotedItemId: quoted, msgContent: mc)],
|
||||
[ComposedMessage(fileSource: file, quotedItemId: quoted, msgContent: mc, mentions: mentions)],
|
||||
live: live,
|
||||
ttl: ttl
|
||||
).first
|
||||
|
@ -958,7 +1078,8 @@ struct ComposeView: View {
|
|||
func checkLinkPreview() -> MsgContent {
|
||||
switch (composeState.preview) {
|
||||
case let .linkPreview(linkPreview: linkPreview):
|
||||
if let url = parseMessage(msgText).url,
|
||||
if let parsedMsg = parseSimpleXMarkdown(msgText),
|
||||
let url = getSimplexLink(parsedMsg).url,
|
||||
let linkPreview = linkPreview,
|
||||
url == linkPreview.uri {
|
||||
return .link(text: msgText, preview: linkPreview)
|
||||
|
@ -1077,9 +1198,9 @@ struct ComposeView: View {
|
|||
}
|
||||
}
|
||||
|
||||
private func showLinkPreview(_ s: String) {
|
||||
private func showLinkPreview(_ parsedMsg: [FormattedText]?) {
|
||||
prevLinkUrl = linkUrl
|
||||
(linkUrl, hasSimplexLink) = parseMessage(s)
|
||||
(linkUrl, hasSimplexLink) = getSimplexLink(parsedMsg)
|
||||
if let url = linkUrl {
|
||||
if url != composeState.linkPreview?.uri && url != pendingLinkUrl {
|
||||
pendingLinkUrl = url
|
||||
|
@ -1096,8 +1217,8 @@ struct ComposeView: View {
|
|||
}
|
||||
}
|
||||
|
||||
private func parseMessage(_ msg: String) -> (url: URL?, hasSimplexLink: Bool) {
|
||||
guard let parsedMsg = parseSimpleXMarkdown(msg) else { return (nil, false) }
|
||||
private func getSimplexLink(_ parsedMsg: [FormattedText]?) -> (url: URL?, hasSimplexLink: Bool) {
|
||||
guard let parsedMsg else { return (nil, false) }
|
||||
let url: URL? = if let uri = parsedMsg.first(where: { ft in
|
||||
ft.format == .uri && !cancelledLinks.contains(ft.text) && !isSimplexLink(ft.text)
|
||||
}) {
|
||||
|
@ -1128,11 +1249,14 @@ struct ComposeView: View {
|
|||
if pendingLinkUrl == url {
|
||||
composeState = composeState.copy(preview: .linkPreview(linkPreview: nil))
|
||||
getLinkPreview(url: url) { linkPreview in
|
||||
if let linkPreview = linkPreview,
|
||||
pendingLinkUrl == url {
|
||||
if let linkPreview, pendingLinkUrl == url {
|
||||
composeState = composeState.copy(preview: .linkPreview(linkPreview: linkPreview))
|
||||
pendingLinkUrl = nil
|
||||
} else {
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
|
||||
composeState = composeState.copy(preview: .noPreview)
|
||||
}
|
||||
}
|
||||
pendingLinkUrl = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -1149,18 +1273,23 @@ struct ComposeView_Previews: PreviewProvider {
|
|||
static var previews: some View {
|
||||
let chat = Chat(chatInfo: ChatInfo.sampleData.direct, chatItems: [])
|
||||
@State var composeState = ComposeState(message: "hello")
|
||||
@State var selectedRange = NSRange()
|
||||
|
||||
return Group {
|
||||
ComposeView(
|
||||
chat: chat,
|
||||
composeState: $composeState,
|
||||
keyboardVisible: Binding.constant(true)
|
||||
keyboardVisible: Binding.constant(true),
|
||||
keyboardHiddenDate: Binding.constant(Date.now),
|
||||
selectedRange: $selectedRange
|
||||
)
|
||||
.environmentObject(ChatModel())
|
||||
ComposeView(
|
||||
chat: chat,
|
||||
composeState: $composeState,
|
||||
keyboardVisible: Binding.constant(true)
|
||||
keyboardVisible: Binding.constant(true),
|
||||
keyboardHiddenDate: Binding.constant(Date.now),
|
||||
selectedRange: $selectedRange
|
||||
)
|
||||
.environmentObject(ChatModel())
|
||||
}
|
||||
|
|
|
@ -15,6 +15,7 @@ struct ContextItemView: View {
|
|||
let contextItems: [ChatItem]
|
||||
let contextIcon: String
|
||||
let cancelContextItem: () -> Void
|
||||
var contextIconForeground: Color? = nil
|
||||
var showSender: Bool = true
|
||||
|
||||
var body: some View {
|
||||
|
@ -23,7 +24,7 @@ struct ContextItemView: View {
|
|||
.resizable()
|
||||
.aspectRatio(contentMode: .fit)
|
||||
.frame(width: 16, height: 16)
|
||||
.foregroundColor(theme.colors.secondary)
|
||||
.foregroundColor(contextIconForeground ?? theme.colors.secondary)
|
||||
if let singleItem = contextItems.first, contextItems.count == 1 {
|
||||
if showSender, let sender = singleItem.memberDisplayName {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
|
@ -69,8 +70,10 @@ struct ContextItemView: View {
|
|||
.lineLimit(lines)
|
||||
}
|
||||
|
||||
private func contextMsgPreview(_ contextItem: ChatItem) -> Text {
|
||||
return attachment() + messageText(contextItem.text, contextItem.formattedText, nil, preview: true, showSecrets: false, secondaryColor: theme.colors.secondary)
|
||||
private func contextMsgPreview(_ contextItem: ChatItem) -> some View {
|
||||
let r = messageText(contextItem.text, contextItem.formattedText, sender: nil, preview: true, mentions: contextItem.mentions, userMemberId: nil, showSecrets: nil, backgroundColor: UIColor(background))
|
||||
let t = attachment() + Text(AttributedString(r.string))
|
||||
return t.if(r.hasSecrets, transform: hiddenSecretsView)
|
||||
|
||||
func attachment() -> Text {
|
||||
let isFileLoaded = if let fileSource = getLoadedFileSource(contextItem.file) {
|
||||
|
@ -93,6 +96,6 @@ struct ContextItemView: View {
|
|||
struct ContextItemView_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
let contextItem: ChatItem = ChatItem.getSample(1, .directSnd, .now, "hello")
|
||||
return ContextItemView(chat: Chat.sampleData, contextItems: [contextItem], contextIcon: "pencil.circle", cancelContextItem: {})
|
||||
return ContextItemView(chat: Chat.sampleData, contextItems: [contextItem], contextIcon: "pencil.circle", cancelContextItem: {}, contextIconForeground: Color.red)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -16,18 +16,15 @@ struct NativeTextEditor: UIViewRepresentable {
|
|||
@Binding var disableEditing: Bool
|
||||
@Binding var height: CGFloat
|
||||
@Binding var focused: Bool
|
||||
@Binding var lastUnfocusedDate: Date
|
||||
@Binding var placeholder: String?
|
||||
@Binding var selectedRange: NSRange
|
||||
let onImagesAdded: ([UploadContent]) -> Void
|
||||
|
||||
private let minHeight: CGFloat = 37
|
||||
static let minHeight: CGFloat = 39
|
||||
|
||||
private let defaultHeight: CGFloat = {
|
||||
let field = CustomUITextField(height: Binding.constant(0))
|
||||
field.textContainerInset = UIEdgeInsets(top: 8, left: 5, bottom: 6, right: 4)
|
||||
return min(max(field.sizeThatFits(CGSizeMake(field.frame.size.width, CGFloat.greatestFiniteMagnitude)).height, 37), 360).rounded(.down)
|
||||
}()
|
||||
|
||||
func makeUIView(context: Context) -> UITextView {
|
||||
let field = CustomUITextField(height: _height)
|
||||
func makeUIView(context: Context) -> CustomUITextField {
|
||||
let field = CustomUITextField(parent: self, height: _height)
|
||||
field.backgroundColor = .clear
|
||||
field.text = text
|
||||
field.textAlignment = alignment(text)
|
||||
|
@ -36,10 +33,9 @@ struct NativeTextEditor: UIViewRepresentable {
|
|||
if !disableEditing {
|
||||
text = newText
|
||||
field.textAlignment = alignment(text)
|
||||
updateFont(field)
|
||||
field.updateFont()
|
||||
// Speed up the process of updating layout, reduce jumping content on screen
|
||||
updateHeight(field)
|
||||
self.height = field.frame.size.height
|
||||
field.updateHeight()
|
||||
} else {
|
||||
field.text = text
|
||||
}
|
||||
|
@ -47,42 +43,32 @@ struct NativeTextEditor: UIViewRepresentable {
|
|||
onImagesAdded(images)
|
||||
}
|
||||
}
|
||||
field.setOnFocusChangedListener { focused = $0 }
|
||||
field.setOnFocusChangedListener {
|
||||
focused = $0
|
||||
if !focused {
|
||||
lastUnfocusedDate = .now
|
||||
}
|
||||
}
|
||||
field.delegate = field
|
||||
field.textContainerInset = UIEdgeInsets(top: 8, left: 5, bottom: 6, right: 4)
|
||||
updateFont(field)
|
||||
updateHeight(field)
|
||||
field.setPlaceholderView()
|
||||
field.updateFont()
|
||||
field.updateHeight(updateBindingNow: false)
|
||||
return field
|
||||
}
|
||||
|
||||
func updateUIView(_ field: UITextView, context: Context) {
|
||||
func updateUIView(_ field: CustomUITextField, context: Context) {
|
||||
if field.markedTextRange == nil && field.text != text {
|
||||
field.text = text
|
||||
field.textAlignment = alignment(text)
|
||||
updateFont(field)
|
||||
updateHeight(field)
|
||||
field.updateFont()
|
||||
field.updateHeight(updateBindingNow: false)
|
||||
}
|
||||
}
|
||||
|
||||
private func updateHeight(_ field: UITextView) {
|
||||
let maxHeight = min(360, field.font!.lineHeight * 12)
|
||||
// When having emoji in text view and then removing it, sizeThatFits shows previous size (too big for empty text view), so using work around with default size
|
||||
let newHeight = field.text == ""
|
||||
? defaultHeight
|
||||
: min(max(field.sizeThatFits(CGSizeMake(field.frame.size.width, CGFloat.greatestFiniteMagnitude)).height, minHeight), maxHeight).rounded(.down)
|
||||
|
||||
if field.frame.size.height != newHeight {
|
||||
field.frame.size = CGSizeMake(field.frame.size.width, newHeight)
|
||||
(field as! CustomUITextField).invalidateIntrinsicContentHeight(newHeight)
|
||||
if field.placeholder != placeholder {
|
||||
field.placeholder = placeholder
|
||||
}
|
||||
}
|
||||
|
||||
private func updateFont(_ field: UITextView) {
|
||||
let newFont = isShortEmoji(field.text)
|
||||
? (field.text.count < 4 ? largeEmojiUIFont : mediumEmojiUIFont)
|
||||
: UIFont.preferredFont(forTextStyle: .body)
|
||||
if field.font != newFont {
|
||||
field.font = newFont
|
||||
if field.selectedRange != selectedRange {
|
||||
field.selectedRange = selectedRange
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -91,17 +77,26 @@ private func alignment(_ text: String) -> NSTextAlignment {
|
|||
isRightToLeft(text) ? .right : .left
|
||||
}
|
||||
|
||||
private class CustomUITextField: UITextView, UITextViewDelegate {
|
||||
class CustomUITextField: UITextView, UITextViewDelegate {
|
||||
var parent: NativeTextEditor?
|
||||
var height: Binding<CGFloat>
|
||||
var newHeight: CGFloat = 0
|
||||
var onTextChanged: (String, [UploadContent]) -> Void = { newText, image in }
|
||||
var onFocusChanged: (Bool) -> Void = { focused in }
|
||||
|
||||
init(height: Binding<CGFloat>) {
|
||||
|
||||
private let placeholderLabel: UILabel = UILabel()
|
||||
|
||||
init(parent: NativeTextEditor?, height: Binding<CGFloat>) {
|
||||
self.parent = parent
|
||||
self.height = height
|
||||
super.init(frame: .zero, textContainer: nil)
|
||||
}
|
||||
|
||||
var placeholder: String? {
|
||||
get { placeholderLabel.text }
|
||||
set { placeholderLabel.text = newValue }
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("Not implemented")
|
||||
}
|
||||
|
@ -114,16 +109,63 @@ private class CustomUITextField: UITextView, UITextViewDelegate {
|
|||
invalidateIntrinsicContentSize()
|
||||
}
|
||||
|
||||
override var intrinsicContentSize: CGSize {
|
||||
if height.wrappedValue != newHeight {
|
||||
DispatchQueue.main.asyncAfter(deadline: .now(), execute: { self.height.wrappedValue = self.newHeight })
|
||||
func updateHeight(updateBindingNow: Bool = true) {
|
||||
let maxHeight = min(360, font!.lineHeight * 12)
|
||||
let newHeight = min(max(sizeThatFits(CGSizeMake(frame.size.width, CGFloat.greatestFiniteMagnitude)).height, NativeTextEditor.minHeight), maxHeight).rounded(.down)
|
||||
|
||||
if self.newHeight != newHeight {
|
||||
frame.size = CGSizeMake(frame.size.width, newHeight)
|
||||
invalidateIntrinsicContentHeight(newHeight)
|
||||
if updateBindingNow {
|
||||
self.height.wrappedValue = newHeight
|
||||
} else {
|
||||
DispatchQueue.main.async {
|
||||
self.height.wrappedValue = newHeight
|
||||
}
|
||||
}
|
||||
}
|
||||
return CGSizeMake(0, newHeight)
|
||||
}
|
||||
|
||||
func updateFont() {
|
||||
let newFont = isShortEmoji(text)
|
||||
? (text.count < 4 ? largeEmojiUIFont : mediumEmojiUIFont)
|
||||
: UIFont.preferredFont(forTextStyle: .body)
|
||||
if font != newFont {
|
||||
font = newFont
|
||||
// force apply new font because it has problem with doing it when the field had two emojis
|
||||
if text.count == 0 {
|
||||
text = " "
|
||||
text = ""
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override func layoutSubviews() {
|
||||
super.layoutSubviews()
|
||||
updateHeight()
|
||||
}
|
||||
|
||||
override var intrinsicContentSize: CGSize {
|
||||
CGSizeMake(0, newHeight)
|
||||
}
|
||||
|
||||
func setOnTextChangedListener(onTextChanged: @escaping (String, [UploadContent]) -> Void) {
|
||||
self.onTextChanged = onTextChanged
|
||||
}
|
||||
|
||||
func setPlaceholderView() {
|
||||
placeholderLabel.textColor = .lightGray
|
||||
placeholderLabel.font = UIFont.preferredFont(forTextStyle: .body)
|
||||
placeholderLabel.isHidden = !text.isEmpty
|
||||
placeholderLabel.translatesAutoresizingMaskIntoConstraints = false
|
||||
addSubview(placeholderLabel)
|
||||
|
||||
NSLayoutConstraint.activate([
|
||||
placeholderLabel.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 7),
|
||||
placeholderLabel.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -7),
|
||||
placeholderLabel.topAnchor.constraint(equalTo: topAnchor, constant: 8)
|
||||
])
|
||||
}
|
||||
|
||||
func setOnFocusChangedListener(onFocusChanged: @escaping (Bool) -> Void) {
|
||||
self.onFocusChanged = onFocusChanged
|
||||
|
@ -172,6 +214,7 @@ private class CustomUITextField: UITextView, UITextViewDelegate {
|
|||
}
|
||||
|
||||
func textViewDidChange(_ textView: UITextView) {
|
||||
placeholderLabel.isHidden = !text.isEmpty
|
||||
if textView.markedTextRange == nil {
|
||||
var images: [UploadContent] = []
|
||||
var rangeDiff = 0
|
||||
|
@ -203,10 +246,22 @@ private class CustomUITextField: UITextView, UITextViewDelegate {
|
|||
|
||||
func textViewDidBeginEditing(_ textView: UITextView) {
|
||||
onFocusChanged(true)
|
||||
updateSelectedRange(textView)
|
||||
}
|
||||
|
||||
func textViewDidEndEditing(_ textView: UITextView) {
|
||||
onFocusChanged(false)
|
||||
updateSelectedRange(textView)
|
||||
}
|
||||
|
||||
func textViewDidChangeSelection(_ textView: UITextView) {
|
||||
updateSelectedRange(textView)
|
||||
}
|
||||
|
||||
private func updateSelectedRange(_ textView: UITextView) {
|
||||
if parent?.selectedRange != textView.selectedRange {
|
||||
parent?.selectedRange = textView.selectedRange
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -217,6 +272,9 @@ struct NativeTextEditor_Previews: PreviewProvider{
|
|||
disableEditing: Binding.constant(false),
|
||||
height: Binding.constant(100),
|
||||
focused: Binding.constant(false),
|
||||
lastUnfocusedDate: Binding.constant(.now),
|
||||
placeholder: Binding.constant("Placeholder"),
|
||||
selectedRange: Binding.constant(NSRange(location: 0, length: 0)),
|
||||
onImagesAdded: { _ in }
|
||||
)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
|
|
|
@ -13,7 +13,9 @@ private let liveMsgInterval: UInt64 = 3000_000000
|
|||
|
||||
struct SendMessageView: View {
|
||||
@Binding var composeState: ComposeState
|
||||
@Binding var selectedRange: NSRange
|
||||
@EnvironmentObject var theme: AppTheme
|
||||
@Environment(\.isEnabled) var isEnabled
|
||||
var sendMessage: (Int?) -> Void
|
||||
var sendLiveMessage: (() async -> Void)? = nil
|
||||
var updateLiveMessage: (() async -> Void)? = nil
|
||||
|
@ -31,8 +33,9 @@ struct SendMessageView: View {
|
|||
@State private var holdingVMR = false
|
||||
@Namespace var namespace
|
||||
@Binding var keyboardVisible: Bool
|
||||
@Binding var keyboardHiddenDate: Date
|
||||
var sendButtonColor = Color.accentColor
|
||||
@State private var teHeight: CGFloat = 42
|
||||
@State private var teHeight: CGFloat = NativeTextEditor.minHeight
|
||||
@State private var teFont: Font = .body
|
||||
@State private var sendButtonSize: CGFloat = 29
|
||||
@State private var sendButtonOpacity: CGFloat = 1
|
||||
|
@ -43,51 +46,54 @@ struct SendMessageView: View {
|
|||
@UserDefault(DEFAULT_LIVE_MESSAGE_ALERT_SHOWN) private var liveMessageAlertShown = false
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
let composeShape = RoundedRectangle(cornerSize: CGSize(width: 20, height: 20))
|
||||
HStack(alignment: .bottom) {
|
||||
ZStack(alignment: .leading) {
|
||||
if case .voicePreview = composeState.preview {
|
||||
Text("Voice message…")
|
||||
.font(teFont.italic())
|
||||
.multilineTextAlignment(.leading)
|
||||
.foregroundColor(theme.colors.secondary)
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.vertical, 8)
|
||||
.frame(maxWidth: .infinity)
|
||||
} else {
|
||||
NativeTextEditor(
|
||||
text: $composeState.message,
|
||||
disableEditing: $composeState.inProgress,
|
||||
height: $teHeight,
|
||||
focused: $keyboardVisible,
|
||||
onImagesAdded: onMediaAdded
|
||||
)
|
||||
.allowsTightening(false)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
}
|
||||
if progressByTimeout {
|
||||
ProgressView()
|
||||
.scaleEffect(1.4)
|
||||
.frame(width: 31, height: 31, alignment: .center)
|
||||
.padding([.bottom, .trailing], 3)
|
||||
} else {
|
||||
VStack(alignment: .trailing) {
|
||||
if teHeight > 100 && !composeState.inProgress {
|
||||
deleteTextButton()
|
||||
Spacer()
|
||||
}
|
||||
composeActionButtons()
|
||||
}
|
||||
.frame(height: teHeight, alignment: .bottom)
|
||||
}
|
||||
let composeShape = RoundedRectangle(cornerSize: CGSize(width: 20, height: 20))
|
||||
ZStack(alignment: .leading) {
|
||||
if case .voicePreview = composeState.preview {
|
||||
Text("Voice message…")
|
||||
.font(teFont.italic())
|
||||
.multilineTextAlignment(.leading)
|
||||
.foregroundColor(theme.colors.secondary)
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.vertical, 8)
|
||||
.padding(.trailing, 32)
|
||||
.frame(maxWidth: .infinity)
|
||||
} else {
|
||||
NativeTextEditor(
|
||||
text: $composeState.message,
|
||||
disableEditing: $composeState.inProgress,
|
||||
height: $teHeight,
|
||||
focused: $keyboardVisible,
|
||||
lastUnfocusedDate: $keyboardHiddenDate,
|
||||
placeholder: Binding(get: { composeState.placeholder }, set: { _ in }),
|
||||
selectedRange: $selectedRange,
|
||||
onImagesAdded: onMediaAdded
|
||||
)
|
||||
.padding(.trailing, 32)
|
||||
.allowsTightening(false)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
.padding(.vertical, 1)
|
||||
.background(theme.colors.background)
|
||||
.clipShape(composeShape)
|
||||
.overlay(composeShape.strokeBorder(.secondary, lineWidth: 0.5).opacity(0.7))
|
||||
}
|
||||
.overlay(alignment: .topTrailing, content: {
|
||||
if !progressByTimeout && teHeight > 100 && !composeState.inProgress {
|
||||
deleteTextButton()
|
||||
}
|
||||
})
|
||||
.overlay(alignment: .bottomTrailing, content: {
|
||||
if progressByTimeout {
|
||||
ProgressView()
|
||||
.scaleEffect(1.4)
|
||||
.frame(width: 31, height: 31, alignment: .center)
|
||||
.padding([.bottom, .trailing], 4)
|
||||
} else {
|
||||
composeActionButtons()
|
||||
// required for intercepting clicks
|
||||
.background(.white.opacity(0.000001))
|
||||
}
|
||||
})
|
||||
.padding(.vertical, 1)
|
||||
.background(theme.colors.background)
|
||||
.clipShape(composeShape)
|
||||
.overlay(composeShape.strokeBorder(.secondary, lineWidth: 0.5).opacity(0.7))
|
||||
.onChange(of: composeState.message, perform: { text in updateFont(text) })
|
||||
.onChange(of: composeState.inProgress) { inProgress in
|
||||
if inProgress {
|
||||
|
@ -105,6 +111,8 @@ struct SendMessageView: View {
|
|||
let vmrs = composeState.voiceMessageRecordingState
|
||||
if nextSendGrpInv {
|
||||
inviteMemberContactButton()
|
||||
} else if case .reportedItem = composeState.contextItem {
|
||||
sendMessageButton()
|
||||
} else if showVoiceMessageButton
|
||||
&& composeState.message.isEmpty
|
||||
&& !composeState.editing
|
||||
|
@ -164,7 +172,7 @@ struct SendMessageView: View {
|
|||
!composeState.sendEnabled ||
|
||||
composeState.inProgress
|
||||
)
|
||||
.frame(width: 29, height: 29)
|
||||
.frame(width: 31, height: 31)
|
||||
.padding([.bottom, .trailing], 4)
|
||||
}
|
||||
|
||||
|
@ -187,7 +195,7 @@ struct SendMessageView: View {
|
|||
composeState.endLiveDisabled ||
|
||||
disableSendButton
|
||||
)
|
||||
.frame(width: 29, height: 29)
|
||||
.frame(width: 31, height: 31)
|
||||
.contextMenu{
|
||||
sendButtonContextMenuItems()
|
||||
}
|
||||
|
@ -248,6 +256,7 @@ struct SendMessageView: View {
|
|||
}
|
||||
|
||||
private struct RecordVoiceMessageButton: View {
|
||||
@Environment(\.isEnabled) var isEnabled
|
||||
@EnvironmentObject var theme: AppTheme
|
||||
var startVoiceMessageRecording: (() -> Void)?
|
||||
var finishVoiceMessageRecording: (() -> Void)?
|
||||
|
@ -256,15 +265,14 @@ struct SendMessageView: View {
|
|||
@State private var pressed: TimeInterval? = nil
|
||||
|
||||
var body: some View {
|
||||
Button(action: {}) {
|
||||
Image(systemName: "mic.fill")
|
||||
.resizable()
|
||||
.scaledToFit()
|
||||
.frame(width: 20, height: 20)
|
||||
.foregroundColor(theme.colors.primary)
|
||||
}
|
||||
Image(systemName: isEnabled ? "mic.fill" : "mic")
|
||||
.resizable()
|
||||
.scaledToFit()
|
||||
.frame(width: 20, height: 20)
|
||||
.foregroundColor(isEnabled ? theme.colors.primary : theme.colors.secondary)
|
||||
.opacity(holdingVMR ? 0.7 : 1)
|
||||
.disabled(disabled)
|
||||
.frame(width: 29, height: 29)
|
||||
.frame(width: 31, height: 31)
|
||||
.padding([.bottom, .trailing], 4)
|
||||
._onButtonGesture { down in
|
||||
if down {
|
||||
|
@ -272,9 +280,7 @@ struct SendMessageView: View {
|
|||
pressed = ProcessInfo.processInfo.systemUptime
|
||||
startVoiceMessageRecording?()
|
||||
} else {
|
||||
let now = ProcessInfo.processInfo.systemUptime
|
||||
if let pressed = pressed,
|
||||
now - pressed >= 1 {
|
||||
if let pressed, ProcessInfo.processInfo.systemUptime - pressed >= 1 {
|
||||
finishVoiceMessageRecording?()
|
||||
}
|
||||
holdingVMR = false
|
||||
|
@ -320,7 +326,7 @@ struct SendMessageView: View {
|
|||
.foregroundColor(theme.colors.secondary)
|
||||
}
|
||||
.disabled(composeState.inProgress)
|
||||
.frame(width: 29, height: 29)
|
||||
.frame(width: 31, height: 31)
|
||||
.padding([.bottom, .trailing], 4)
|
||||
}
|
||||
|
||||
|
@ -348,7 +354,7 @@ struct SendMessageView: View {
|
|||
Image(systemName: "bolt.fill")
|
||||
.resizable()
|
||||
.scaledToFit()
|
||||
.foregroundColor(theme.colors.primary)
|
||||
.foregroundColor(isEnabled ? theme.colors.primary : theme.colors.secondary)
|
||||
.frame(width: 20, height: 20)
|
||||
}
|
||||
.frame(width: 29, height: 29)
|
||||
|
@ -405,7 +411,7 @@ struct SendMessageView: View {
|
|||
.foregroundColor(theme.colors.primary)
|
||||
}
|
||||
.disabled(composeState.inProgress)
|
||||
.frame(width: 29, height: 29)
|
||||
.frame(width: 31, height: 31)
|
||||
.padding([.bottom, .trailing], 4)
|
||||
}
|
||||
|
||||
|
@ -421,8 +427,10 @@ struct SendMessageView: View {
|
|||
struct SendMessageView_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
@State var composeStateNew = ComposeState()
|
||||
@State var selectedRange = NSRange()
|
||||
let ci = ChatItem.getSample(1, .directSnd, .now, "hello")
|
||||
@State var composeStateEditing = ComposeState(editingItem: ci)
|
||||
@State var selectedRangeEditing = NSRange()
|
||||
@State var sendEnabled: Bool = true
|
||||
|
||||
return Group {
|
||||
|
@ -431,9 +439,11 @@ struct SendMessageView_Previews: PreviewProvider {
|
|||
Spacer(minLength: 0)
|
||||
SendMessageView(
|
||||
composeState: $composeStateNew,
|
||||
selectedRange: $selectedRange,
|
||||
sendMessage: { _ in },
|
||||
onMediaAdded: { _ in },
|
||||
keyboardVisible: Binding.constant(true)
|
||||
keyboardVisible: Binding.constant(true),
|
||||
keyboardHiddenDate: Binding.constant(Date.now)
|
||||
)
|
||||
}
|
||||
VStack {
|
||||
|
@ -441,9 +451,11 @@ struct SendMessageView_Previews: PreviewProvider {
|
|||
Spacer(minLength: 0)
|
||||
SendMessageView(
|
||||
composeState: $composeStateEditing,
|
||||
selectedRange: $selectedRangeEditing,
|
||||
sendMessage: { _ in },
|
||||
onMediaAdded: { _ in },
|
||||
keyboardVisible: Binding.constant(true)
|
||||
keyboardVisible: Binding.constant(true),
|
||||
keyboardHiddenDate: Binding.constant(Date.now)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
715
apps/ios/Shared/Views/Chat/EndlessScrollView.swift
Normal file
715
apps/ios/Shared/Views/Chat/EndlessScrollView.swift
Normal file
|
@ -0,0 +1,715 @@
|
|||
//
|
||||
// EndlessScrollView.swift
|
||||
// SimpleX (iOS)
|
||||
//
|
||||
// Created by Stanislav Dmitrenko on 25.01.2025.
|
||||
// Copyright © 2024 SimpleX Chat. All rights reserved.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct ScrollRepresentable<Content: View, ScrollItem>: UIViewControllerRepresentable where ScrollItem : Identifiable, ScrollItem: Hashable {
|
||||
|
||||
let scrollView: EndlessScrollView<ScrollItem>
|
||||
let content: (Int, ScrollItem) -> Content
|
||||
|
||||
func makeUIViewController(context: Context) -> ScrollController {
|
||||
ScrollController.init(scrollView: scrollView, content: content)
|
||||
}
|
||||
|
||||
func updateUIViewController(_ controller: ScrollController, context: Context) {}
|
||||
|
||||
class ScrollController: UIViewController {
|
||||
let scrollView: EndlessScrollView<ScrollItem>
|
||||
fileprivate var items: [ScrollItem] = []
|
||||
fileprivate var content: ((Int, ScrollItem) -> Content)!
|
||||
|
||||
fileprivate init(scrollView: EndlessScrollView<ScrollItem>, content: @escaping (Int, ScrollItem) -> Content) {
|
||||
self.scrollView = scrollView
|
||||
self.content = content
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
self.view = scrollView
|
||||
scrollView.createCell = createCell
|
||||
scrollView.updateCell = updateCell
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) { fatalError() }
|
||||
|
||||
private func createCell(_ index: Int, _ items: [ScrollItem], _ cellsToReuse: inout [UIView]) -> UIView {
|
||||
let item: ScrollItem? = index >= 0 && index < items.count ? items[index] : nil
|
||||
let cell: UIView
|
||||
if #available(iOS 16.0, *), false {
|
||||
let c: UITableViewCell = cellsToReuse.isEmpty ? UITableViewCell() : cellsToReuse.removeLast() as! UITableViewCell
|
||||
if let item {
|
||||
c.contentConfiguration = UIHostingConfiguration { self.content(index, item) }
|
||||
.margins(.all, 0)
|
||||
.minSize(height: 1) // Passing zero will result in system default of 44 points being used
|
||||
}
|
||||
cell = c
|
||||
} else {
|
||||
let c = cellsToReuse.isEmpty ? HostingCell<Content>() : cellsToReuse.removeLast() as! HostingCell<Content>
|
||||
if let item {
|
||||
c.set(content: self.content(index, item), parent: self)
|
||||
}
|
||||
cell = c
|
||||
}
|
||||
cell.isHidden = false
|
||||
cell.backgroundColor = .clear
|
||||
let size = cell.systemLayoutSizeFitting(CGSizeMake(scrollView.bounds.width, CGFloat.greatestFiniteMagnitude))
|
||||
cell.frame.size.width = scrollView.bounds.width
|
||||
cell.frame.size.height = size.height
|
||||
return cell
|
||||
}
|
||||
|
||||
private func updateCell(cell: UIView, _ index: Int, _ items: [ScrollItem]) {
|
||||
let item = items[index]
|
||||
if #available(iOS 16.0, *), false {
|
||||
(cell as! UITableViewCell).contentConfiguration = UIHostingConfiguration { self.content(index, item) }
|
||||
.margins(.all, 0)
|
||||
.minSize(height: 1) // Passing zero will result in system default of 44 points being used
|
||||
} else {
|
||||
if let cell = cell as? HostingCell<Content> {
|
||||
cell.set(content: self.content(index, item), parent: self)
|
||||
} else {
|
||||
fatalError("Unexpected Cell Type for: \(item)")
|
||||
}
|
||||
}
|
||||
let size = cell.systemLayoutSizeFitting(CGSizeMake(scrollView.bounds.width, CGFloat.greatestFiniteMagnitude))
|
||||
cell.frame.size.width = scrollView.bounds.width
|
||||
cell.frame.size.height = size.height
|
||||
cell.setNeedsLayout()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class EndlessScrollView<ScrollItem>: UIScrollView, UIScrollViewDelegate, UIGestureRecognizerDelegate where ScrollItem : Identifiable, ScrollItem: Hashable {
|
||||
|
||||
/// Stores actual state of the scroll view and all elements drawn on the screen
|
||||
let listState: ListState = ListState()
|
||||
|
||||
/// Just some random big number that will probably be enough to scrolling down and up without reaching the end
|
||||
var initialOffset: CGFloat = 100000000
|
||||
|
||||
/// Default item id when no items in the visible items list. Something that will never be in real data
|
||||
fileprivate static var DEFAULT_ITEM_ID: any Hashable { get { Int64.min } }
|
||||
|
||||
/// Storing an offset that was already used for laying down content to be able to see the difference
|
||||
var prevProcessedOffset: CGFloat = 0
|
||||
|
||||
/// When screen is being rotated, it's important to track the view size and adjust scroll offset accordingly because the view doesn't know that the content
|
||||
/// starts from bottom and ends at top, not vice versa as usual
|
||||
var oldScreenHeight: CGFloat = 0
|
||||
|
||||
/// Not 100% correct height of the content since the items loaded lazily and their dimensions are unkown until they are on screen
|
||||
var estimatedContentHeight: ContentHeight = ContentHeight()
|
||||
|
||||
/// Specify here the value that is small enough to NOT see any weird animation when you scroll to items. Minimum expected item size is ok. Scroll speed depends on it too
|
||||
var averageItemHeight: CGFloat = 30
|
||||
|
||||
/// This is used as a multiplier for difference between current index and scrollTo index using [averageItemHeight] as well. Increase it to get faster speed
|
||||
var scrollStepMultiplier: CGFloat = 0.37
|
||||
|
||||
/// Adds content padding to top
|
||||
var insetTop: CGFloat = 100
|
||||
|
||||
/// Adds content padding to bottom
|
||||
var insetBottom: CGFloat = 100
|
||||
|
||||
var scrollToItemIndexDelayed: Int? = nil
|
||||
|
||||
/// The second scroll view that is used only for purpose of displaying scroll bar with made-up content size and scroll offset that is gathered from main scroll view, see [estimatedContentHeight]
|
||||
let scrollBarView: UIScrollView = UIScrollView(frame: .zero)
|
||||
|
||||
/// Stores views that can be used to hold new content so it will be faster to replace something than to create the whole view from scratch
|
||||
var cellsToReuse: [UIView] = []
|
||||
|
||||
/// Enable debug to see hundreds of logs
|
||||
var debug: Bool = false
|
||||
|
||||
var createCell: (Int, [ScrollItem], inout [UIView]) -> UIView? = { _, _, _ in nil }
|
||||
var updateCell: (UIView, Int, [ScrollItem]) -> Void = { cell, _, _ in }
|
||||
|
||||
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
self.delegate = self
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) { fatalError() }
|
||||
|
||||
class ListState: NSObject {
|
||||
|
||||
/// Will be called on every change of the items array, visible items, and scroll position
|
||||
var onUpdateListener: () -> Void = {}
|
||||
|
||||
/// Items that were used to lay out the screen
|
||||
var items: [ScrollItem] = [] {
|
||||
didSet {
|
||||
onUpdateListener()
|
||||
}
|
||||
}
|
||||
|
||||
/// It is equai to the number of [items]
|
||||
var totalItemsCount: Int {
|
||||
items.count
|
||||
}
|
||||
|
||||
/// The items with their positions and other useful information. Only those that are visible on screen
|
||||
var visibleItems: [EndlessScrollView<ScrollItem>.VisibleItem] = []
|
||||
|
||||
/// Index in [items] of the first item on screen. This is intentiallty not derived from visible items because it's is used as a starting point for laying out the screen
|
||||
var firstVisibleItemIndex: Int = 0
|
||||
|
||||
/// Unique item id of the first visible item on screen
|
||||
var firstVisibleItemId: any Hashable = EndlessScrollView<ScrollItem>.DEFAULT_ITEM_ID
|
||||
|
||||
/// Item offset of the first item on screen. Most of the time it's non-positive but it can be positive as well when a user produce overscroll effect on top/bottom of the scroll view
|
||||
var firstVisibleItemOffset: CGFloat = -100
|
||||
|
||||
/// Index of the last visible item on screen
|
||||
var lastVisibleItemIndex: Int {
|
||||
visibleItems.last?.index ?? 0
|
||||
}
|
||||
|
||||
/// Specifies if visible items cover the whole screen or can cover it (if overscrolled)
|
||||
var itemsCanCoverScreen: Bool = false
|
||||
|
||||
/// Whether there is a non-animated scroll to item in progress or not
|
||||
var isScrolling: Bool = false
|
||||
/// Whether there is an animated scroll to item in progress or not
|
||||
var isAnimatedScrolling: Bool = false
|
||||
|
||||
override init() {
|
||||
super.init()
|
||||
}
|
||||
}
|
||||
|
||||
class VisibleItem {
|
||||
let index: Int
|
||||
let item: ScrollItem
|
||||
let view: UIView
|
||||
var offset: CGFloat
|
||||
|
||||
init(index: Int, item: ScrollItem, view: UIView, offset: CGFloat) {
|
||||
self.index = index
|
||||
self.item = item
|
||||
self.view = view
|
||||
self.offset = offset
|
||||
}
|
||||
}
|
||||
|
||||
class ContentHeight {
|
||||
/// After that you should see overscroll effect. When scroll positon is far from
|
||||
/// top/bottom items, these values are estimated based on items count multiplied by averageItemHeight or real item height (from visible items). Example:
|
||||
/// [ 10, 9, 8, 7, (6, 5, 4, 3), 2, 1, 0] - 6, 5, 4, 3 are visible and have know heights but others have unknown height and for them averageItemHeight will be used to calculate the whole content height
|
||||
var topOffsetY: CGFloat = 0
|
||||
var bottomOffsetY: CGFloat = 0
|
||||
|
||||
var virtualScrollOffsetY: CGFloat = 0
|
||||
|
||||
/// How much distance were overscolled on top which often means to show sticky scrolling that should scroll back to real position after a users finishes dragging the scrollView
|
||||
var overscrolledTop: CGFloat = 0
|
||||
|
||||
/// Adds content padding to bottom and top
|
||||
var inset: CGFloat = 100
|
||||
|
||||
/// Estimated height of the contents of scroll view
|
||||
var height: CGFloat {
|
||||
get { bottomOffsetY - topOffsetY }
|
||||
}
|
||||
|
||||
/// Estimated height of the contents of scroll view + distance of overscrolled effect. It's only updated when number of item changes to prevent jumping of scroll bar
|
||||
var virtualOverscrolledHeight: CGFloat {
|
||||
get {
|
||||
bottomOffsetY - topOffsetY + overscrolledTop - inset * 2
|
||||
}
|
||||
}
|
||||
|
||||
func update(
|
||||
_ contentOffset: CGPoint,
|
||||
_ listState: ListState,
|
||||
_ averageItemHeight: CGFloat,
|
||||
_ updateStaleHeight: Bool
|
||||
) {
|
||||
let lastVisible = listState.visibleItems.last
|
||||
let firstVisible = listState.visibleItems.first
|
||||
guard let last = lastVisible, let first = firstVisible else {
|
||||
topOffsetY = contentOffset.y
|
||||
bottomOffsetY = contentOffset.y
|
||||
virtualScrollOffsetY = 0
|
||||
overscrolledTop = 0
|
||||
return
|
||||
}
|
||||
topOffsetY = last.view.frame.origin.y - CGFloat(listState.totalItemsCount - last.index - 1) * averageItemHeight - self.inset
|
||||
bottomOffsetY = first.view.frame.origin.y + first.view.bounds.height + CGFloat(first.index) * averageItemHeight + self.inset
|
||||
virtualScrollOffsetY = contentOffset.y - topOffsetY
|
||||
overscrolledTop = max(0, last.index == listState.totalItemsCount - 1 ? last.view.frame.origin.y - contentOffset.y : 0)
|
||||
}
|
||||
}
|
||||
|
||||
var topY: CGFloat {
|
||||
get { contentOffset.y }
|
||||
}
|
||||
|
||||
var bottomY: CGFloat {
|
||||
get { contentOffset.y + bounds.height }
|
||||
}
|
||||
|
||||
override func layoutSubviews() {
|
||||
super.layoutSubviews()
|
||||
if contentSize.height == 0 {
|
||||
setup()
|
||||
}
|
||||
let newScreenHeight = bounds.height
|
||||
if newScreenHeight != oldScreenHeight && oldScreenHeight != 0 {
|
||||
contentOffset.y += oldScreenHeight - newScreenHeight
|
||||
scrollBarView.frame = CGRectMake(frame.width - 10, self.insetTop, 10, frame.height - self.insetTop - self.insetBottom)
|
||||
}
|
||||
oldScreenHeight = newScreenHeight
|
||||
adaptItems(listState.items, false)
|
||||
if let index = scrollToItemIndexDelayed {
|
||||
scrollToItem(index)
|
||||
scrollToItemIndexDelayed = nil
|
||||
}
|
||||
}
|
||||
|
||||
private func setup() {
|
||||
contentSize = CGSizeMake(frame.size.width, initialOffset * 2)
|
||||
prevProcessedOffset = initialOffset
|
||||
contentOffset = CGPointMake(0, initialOffset)
|
||||
|
||||
showsVerticalScrollIndicator = false
|
||||
scrollBarView.showsHorizontalScrollIndicator = false
|
||||
panGestureRecognizer.delegate = self
|
||||
addGestureRecognizer(scrollBarView.panGestureRecognizer)
|
||||
superview!.addSubview(scrollBarView)
|
||||
}
|
||||
|
||||
func updateItems(_ items: [ScrollItem], _ forceReloadVisible: Bool = false) {
|
||||
if !Thread.isMainThread {
|
||||
logger.error("Use main thread to update items")
|
||||
return
|
||||
}
|
||||
if bounds.height == 0 {
|
||||
self.listState.items = items
|
||||
// this function requires to have valid bounds and it will be called again once it has them
|
||||
return
|
||||
}
|
||||
adaptItems(items, forceReloadVisible)
|
||||
snapToContent(animated: false)
|
||||
}
|
||||
|
||||
/// [forceReloadVisible]: reloads every item that was visible regardless of hashValue changes
|
||||
private func adaptItems(_ items: [ScrollItem], _ forceReloadVisible: Bool, overridenOffset: CGFloat? = nil) {
|
||||
let start = Date.now
|
||||
// special case when everything was removed
|
||||
if items.isEmpty {
|
||||
listState.visibleItems.forEach { item in item.view.removeFromSuperview() }
|
||||
listState.visibleItems = []
|
||||
listState.itemsCanCoverScreen = false
|
||||
listState.firstVisibleItemId = EndlessScrollView<ScrollItem>.DEFAULT_ITEM_ID
|
||||
listState.firstVisibleItemIndex = 0
|
||||
listState.firstVisibleItemOffset = -insetTop
|
||||
|
||||
estimatedContentHeight.update(contentOffset, listState, averageItemHeight, true)
|
||||
scrollBarView.contentSize = .zero
|
||||
scrollBarView.contentOffset = .zero
|
||||
|
||||
prevProcessedOffset = contentOffset.y
|
||||
// this check is just to prevent didSet listener from firing on the same empty array, no use for this
|
||||
if !self.listState.items.isEmpty {
|
||||
self.listState.items = items
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
let contentOffsetY = overridenOffset ?? contentOffset.y
|
||||
|
||||
var oldVisible = listState.visibleItems
|
||||
var newVisible: [VisibleItem] = []
|
||||
var visibleItemsHeight: CGFloat = 0
|
||||
let offsetsDiff = contentOffsetY - prevProcessedOffset
|
||||
|
||||
var shouldBeFirstVisible = items.firstIndex(where: { item in item.id == listState.firstVisibleItemId as! ScrollItem.ID }) ?? 0
|
||||
|
||||
var wasFirstVisibleItemOffset = listState.firstVisibleItemOffset
|
||||
var alreadyChangedIndexWhileScrolling = false
|
||||
var allowOneMore = false
|
||||
var nextOffsetY: CGFloat = 0
|
||||
var i = shouldBeFirstVisible
|
||||
// building list of visible items starting from the first one that should be visible
|
||||
while i >= 0 && i < items.count {
|
||||
let item = items[i]
|
||||
let visibleIndex = oldVisible.firstIndex(where: { vis in vis.item.id == item.id })
|
||||
let visible: VisibleItem?
|
||||
if let visibleIndex {
|
||||
let v = oldVisible.remove(at: visibleIndex)
|
||||
if forceReloadVisible || v.view.bounds.width != bounds.width || v.item.hashValue != item.hashValue {
|
||||
let wasHeight = v.view.bounds.height
|
||||
updateCell(v.view, i, items)
|
||||
if wasHeight < v.view.bounds.height && i == 0 && shouldBeFirstVisible == i {
|
||||
v.view.frame.origin.y -= v.view.bounds.height - wasHeight
|
||||
}
|
||||
}
|
||||
visible = v
|
||||
} else {
|
||||
visible = nil
|
||||
}
|
||||
if shouldBeFirstVisible == i {
|
||||
if let vis = visible {
|
||||
|
||||
if // there is auto scroll in progress and the first item has a higher offset than bottom part
|
||||
// of the screen. In order to make scrolling down & up equal in time, we treat this as a sign to
|
||||
// re-make the first visible item
|
||||
(listState.isAnimatedScrolling && vis.view.frame.origin.y + vis.view.bounds.height < contentOffsetY + bounds.height) ||
|
||||
// the fist visible item previously is hidden now, remove it and move on
|
||||
!isVisible(vis.view) {
|
||||
let newIndex: Int
|
||||
if listState.isAnimatedScrolling {
|
||||
// skip many items to make the scrolling take less time
|
||||
var indexDiff = !alreadyChangedIndexWhileScrolling ? Int(ceil(abs(offsetsDiff / averageItemHeight))) : 0
|
||||
// if index was already changed, no need to change it again. Otherwise, the scroll will overscoll and return back animated. Because it means the whole screen was scrolled
|
||||
alreadyChangedIndexWhileScrolling = true
|
||||
|
||||
indexDiff = offsetsDiff <= 0 ? indexDiff : -indexDiff
|
||||
newIndex = max(0, min(items.count - 1, i + indexDiff))
|
||||
// offset for the first visible item can now be 0 because the previous first visible item doesn't exist anymore
|
||||
wasFirstVisibleItemOffset = 0
|
||||
} else {
|
||||
// don't skip multiple items if it's manual scrolling gesture
|
||||
newIndex = i + (offsetsDiff <= 0 ? 1 : -1)
|
||||
}
|
||||
shouldBeFirstVisible = newIndex
|
||||
i = newIndex
|
||||
|
||||
cellsToReuse.append(vis.view)
|
||||
hideAndRemoveFromSuperviewIfNeeded(vis.view)
|
||||
continue
|
||||
}
|
||||
}
|
||||
let vis: VisibleItem
|
||||
if let visible {
|
||||
vis = VisibleItem(index: i, item: item, view: visible.view, offset: offsetToBottom(visible.view))
|
||||
} else {
|
||||
let cell = createCell(i, items, &cellsToReuse)!
|
||||
cell.frame.origin.y = bottomY + wasFirstVisibleItemOffset - cell.frame.height
|
||||
vis = VisibleItem(index: i, item: item, view: cell, offset: offsetToBottom(cell))
|
||||
}
|
||||
if vis.view.superview == nil {
|
||||
addSubview(vis.view)
|
||||
}
|
||||
newVisible.append(vis)
|
||||
visibleItemsHeight += vis.view.frame.height
|
||||
nextOffsetY = vis.view.frame.origin.y
|
||||
} else {
|
||||
let vis: VisibleItem
|
||||
if let visible {
|
||||
vis = VisibleItem(index: i, item: item, view: visible.view, offset: offsetToBottom(visible.view))
|
||||
nextOffsetY -= vis.view.frame.height
|
||||
vis.view.frame.origin.y = nextOffsetY
|
||||
} else {
|
||||
let cell = createCell(i, items, &cellsToReuse)!
|
||||
nextOffsetY -= cell.frame.height
|
||||
cell.frame.origin.y = nextOffsetY
|
||||
vis = VisibleItem(index: i, item: item, view: cell, offset: offsetToBottom(cell))
|
||||
}
|
||||
if vis.view.superview == nil {
|
||||
addSubview(vis.view)
|
||||
}
|
||||
newVisible.append(vis)
|
||||
visibleItemsHeight += vis.view.frame.height
|
||||
}
|
||||
if abs(nextOffsetY) < contentOffsetY && !allowOneMore {
|
||||
break
|
||||
} else if abs(nextOffsetY) < contentOffsetY {
|
||||
allowOneMore = false
|
||||
}
|
||||
i += 1
|
||||
}
|
||||
if let firstVisible = newVisible.first, firstVisible.view.frame.origin.y + firstVisible.view.frame.height < contentOffsetY + bounds.height, firstVisible.index > 0 {
|
||||
var offset: CGFloat = firstVisible.view.frame.origin.y + firstVisible.view.frame.height
|
||||
let index = firstVisible.index
|
||||
for i in stride(from: index - 1, through: 0, by: -1) {
|
||||
let item = items[i]
|
||||
let visibleIndex = oldVisible.firstIndex(where: { vis in vis.item.id == item.id })
|
||||
let vis: VisibleItem
|
||||
if let visibleIndex {
|
||||
let visible = oldVisible.remove(at: visibleIndex)
|
||||
visible.view.frame.origin.y = offset
|
||||
vis = VisibleItem(index: i, item: item, view: visible.view, offset: offsetToBottom(visible.view))
|
||||
} else {
|
||||
let cell = createCell(i, items, &cellsToReuse)!
|
||||
cell.frame.origin.y = offset
|
||||
vis = VisibleItem(index: i, item: item, view: cell, offset: offsetToBottom(cell))
|
||||
}
|
||||
if vis.view.superview == nil {
|
||||
addSubview(vis.view)
|
||||
}
|
||||
offset += vis.view.frame.height
|
||||
newVisible.insert(vis, at: 0)
|
||||
visibleItemsHeight += vis.view.frame.height
|
||||
if offset >= contentOffsetY + bounds.height {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// removing already unneeded visible items
|
||||
oldVisible.forEach { vis in
|
||||
cellsToReuse.append(vis.view)
|
||||
hideAndRemoveFromSuperviewIfNeeded(vis.view)
|
||||
}
|
||||
let itemsCountChanged = listState.items.count != items.count
|
||||
prevProcessedOffset = contentOffsetY
|
||||
|
||||
listState.visibleItems = newVisible
|
||||
// bottom drawing starts from 0 until top visible area at least (bound.height - insetTop) or above top bar (bounds.height).
|
||||
// For visible items to preserve offset after adding more items having such height is enough
|
||||
listState.itemsCanCoverScreen = visibleItemsHeight >= bounds.height - insetTop
|
||||
|
||||
listState.firstVisibleItemId = listState.visibleItems.first?.item.id ?? EndlessScrollView<ScrollItem>.DEFAULT_ITEM_ID
|
||||
listState.firstVisibleItemIndex = listState.visibleItems.first?.index ?? 0
|
||||
listState.firstVisibleItemOffset = listState.visibleItems.first?.offset ?? -insetTop
|
||||
// updating the items with the last step in order to call listener with fully updated state
|
||||
listState.items = items
|
||||
|
||||
estimatedContentHeight.update(contentOffset, listState, averageItemHeight, itemsCountChanged)
|
||||
scrollBarView.contentSize = CGSizeMake(bounds.width, estimatedContentHeight.virtualOverscrolledHeight)
|
||||
scrollBarView.contentOffset = CGPointMake(0, estimatedContentHeight.virtualScrollOffsetY)
|
||||
scrollBarView.isHidden = listState.visibleItems.count == listState.items.count && (listState.visibleItems.isEmpty || -listState.firstVisibleItemOffset + (listState.visibleItems.last?.offset ?? 0) + insetTop < bounds.height)
|
||||
|
||||
if debug {
|
||||
println("time spent \((-start.timeIntervalSinceNow).description.prefix(5).replacingOccurrences(of: "0.000", with: "<0").replacingOccurrences(of: "0.", with: ""))")
|
||||
}
|
||||
}
|
||||
|
||||
func setScrollPosition(_ index: Int, _ id: Int64, _ offset: CGFloat = 0) {
|
||||
listState.firstVisibleItemIndex = index
|
||||
listState.firstVisibleItemId = id
|
||||
listState.firstVisibleItemOffset = offset == 0 ? -bounds.height + insetTop + insetBottom : offset
|
||||
}
|
||||
|
||||
func scrollToItem(_ index: Int, top: Bool = true) {
|
||||
if index >= listState.items.count || listState.isScrolling || listState.isAnimatedScrolling {
|
||||
return
|
||||
}
|
||||
if bounds.height == 0 || contentSize.height == 0 {
|
||||
scrollToItemIndexDelayed = index
|
||||
return
|
||||
}
|
||||
listState.isScrolling = true
|
||||
defer {
|
||||
listState.isScrolling = false
|
||||
}
|
||||
|
||||
// just a faster way to set top item as requested index
|
||||
listState.firstVisibleItemIndex = index
|
||||
listState.firstVisibleItemId = listState.items[index].id
|
||||
listState.firstVisibleItemOffset = -bounds.height + insetTop + insetBottom
|
||||
scrollBarView.flashScrollIndicators()
|
||||
adaptItems(listState.items, false)
|
||||
|
||||
var adjustedOffset = self.contentOffset.y
|
||||
var i = 0
|
||||
|
||||
var upPrev = index > listState.firstVisibleItemIndex
|
||||
//let firstOrLastIndex = upPrev ? listState.visibleItems.last?.index ?? 0 : listState.firstVisibleItemIndex
|
||||
//let step: CGFloat = max(0.1, CGFloat(abs(index - firstOrLastIndex)) * scrollStepMultiplier)
|
||||
|
||||
var stepSlowdownMultiplier: CGFloat = 1
|
||||
while i < 200 {
|
||||
let up = index > listState.firstVisibleItemIndex
|
||||
if upPrev != up {
|
||||
stepSlowdownMultiplier = stepSlowdownMultiplier * 0.5
|
||||
upPrev = up
|
||||
}
|
||||
|
||||
// these two lines makes scrolling's finish non-linear and NOT overscroll visually when reach target index
|
||||
let firstOrLastIndex = up ? listState.visibleItems.last?.index ?? 0 : listState.firstVisibleItemIndex
|
||||
let step: CGFloat = max(0.1, CGFloat(abs(index - firstOrLastIndex)) * scrollStepMultiplier) * stepSlowdownMultiplier
|
||||
|
||||
let offsetToScroll = (up ? -averageItemHeight : averageItemHeight) * step
|
||||
adjustedOffset += offsetToScroll
|
||||
if let item = listState.visibleItems.first(where: { $0.index == index }) {
|
||||
let y = if top {
|
||||
min(estimatedContentHeight.bottomOffsetY - bounds.height, item.view.frame.origin.y - insetTop)
|
||||
} else {
|
||||
max(estimatedContentHeight.topOffsetY - insetTop - insetBottom, item.view.frame.origin.y + item.view.bounds.height - bounds.height + insetBottom)
|
||||
}
|
||||
setContentOffset(CGPointMake(contentOffset.x, y), animated: false)
|
||||
scrollBarView.flashScrollIndicators()
|
||||
break
|
||||
}
|
||||
contentOffset = CGPointMake(contentOffset.x, adjustedOffset)
|
||||
adaptItems(listState.items, false)
|
||||
snapToContent(animated: false)
|
||||
i += 1
|
||||
}
|
||||
adaptItems(listState.items, false)
|
||||
snapToContent(animated: false)
|
||||
estimatedContentHeight.update(contentOffset, listState, averageItemHeight, true)
|
||||
}
|
||||
|
||||
func scrollToItemAnimated(_ index: Int, top: Bool = true) async {
|
||||
if index >= listState.items.count || listState.isScrolling || listState.isAnimatedScrolling {
|
||||
return
|
||||
}
|
||||
listState.isAnimatedScrolling = true
|
||||
defer {
|
||||
listState.isAnimatedScrolling = false
|
||||
}
|
||||
var adjustedOffset = self.contentOffset.y
|
||||
var i = 0
|
||||
|
||||
var upPrev = index > listState.firstVisibleItemIndex
|
||||
//let firstOrLastIndex = upPrev ? listState.visibleItems.last?.index ?? 0 : listState.firstVisibleItemIndex
|
||||
//let step: CGFloat = max(0.1, CGFloat(abs(index - firstOrLastIndex)) * scrollStepMultiplier)
|
||||
|
||||
var stepSlowdownMultiplier: CGFloat = 1
|
||||
while i < 200 {
|
||||
let up = index > listState.firstVisibleItemIndex
|
||||
if upPrev != up {
|
||||
stepSlowdownMultiplier = stepSlowdownMultiplier * 0.5
|
||||
upPrev = up
|
||||
}
|
||||
|
||||
// these two lines makes scrolling's finish non-linear and NOT overscroll visually when reach target index
|
||||
let firstOrLastIndex = up ? listState.visibleItems.last?.index ?? 0 : listState.firstVisibleItemIndex
|
||||
let step: CGFloat = max(0.1, CGFloat(abs(index - firstOrLastIndex)) * scrollStepMultiplier) * stepSlowdownMultiplier
|
||||
|
||||
//println("Scrolling step \(step) \(stepSlowdownMultiplier) index \(index) \(firstOrLastIndex) \(index - firstOrLastIndex) \(adjustedOffset), up \(up), i \(i)")
|
||||
|
||||
let offsetToScroll = (up ? -averageItemHeight : averageItemHeight) * step
|
||||
adjustedOffset += offsetToScroll
|
||||
if let item = listState.visibleItems.first(where: { $0.index == index }) {
|
||||
let y = if top {
|
||||
min(estimatedContentHeight.bottomOffsetY - bounds.height, item.view.frame.origin.y - insetTop)
|
||||
} else {
|
||||
max(estimatedContentHeight.topOffsetY - insetTop - insetBottom, item.view.frame.origin.y + item.view.bounds.height - bounds.height + insetBottom)
|
||||
}
|
||||
setContentOffset(CGPointMake(contentOffset.x, y), animated: true)
|
||||
scrollBarView.flashScrollIndicators()
|
||||
break
|
||||
}
|
||||
contentOffset = CGPointMake(contentOffset.x, adjustedOffset)
|
||||
|
||||
// skipping unneded relayout if this offset is already processed
|
||||
if prevProcessedOffset - contentOffset.y != 0 {
|
||||
adaptItems(listState.items, false)
|
||||
snapToContent(animated: false)
|
||||
}
|
||||
// let UI time to update to see the animated position change
|
||||
await MainActor.run {}
|
||||
|
||||
i += 1
|
||||
}
|
||||
estimatedContentHeight.update(contentOffset, listState, averageItemHeight, true)
|
||||
}
|
||||
|
||||
func scrollToBottom() {
|
||||
scrollToItem(0, top: false)
|
||||
}
|
||||
|
||||
func scrollToBottomAnimated() {
|
||||
Task {
|
||||
await scrollToItemAnimated(0, top: false)
|
||||
}
|
||||
}
|
||||
|
||||
func scroll(by: CGFloat, animated: Bool = true) {
|
||||
setContentOffset(CGPointMake(contentOffset.x, contentOffset.y + by), animated: animated)
|
||||
}
|
||||
|
||||
func scrollViewShouldScrollToTop(_ scrollView: UIScrollView) -> Bool {
|
||||
if !listState.items.isEmpty {
|
||||
scrollToBottomAnimated()
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
private func snapToContent(animated: Bool) {
|
||||
let topBlankSpace = estimatedContentHeight.height < bounds.height ? bounds.height - estimatedContentHeight.height : 0
|
||||
if topY < estimatedContentHeight.topOffsetY - topBlankSpace {
|
||||
setContentOffset(CGPointMake(0, estimatedContentHeight.topOffsetY - topBlankSpace), animated: animated)
|
||||
} else if bottomY > estimatedContentHeight.bottomOffsetY {
|
||||
setContentOffset(CGPointMake(0, estimatedContentHeight.bottomOffsetY - bounds.height), animated: animated)
|
||||
}
|
||||
}
|
||||
|
||||
func offsetToBottom(_ view: UIView) -> CGFloat {
|
||||
bottomY - (view.frame.origin.y + view.frame.height)
|
||||
}
|
||||
|
||||
/// If I try to .removeFromSuperview() right when I need to remove the view, it is possible to crash the app when the view was hidden in result of
|
||||
/// pressing Hide in menu on top of the revealed item within the group. So at that point the item should still be attached to the view
|
||||
func hideAndRemoveFromSuperviewIfNeeded(_ view: UIView) {
|
||||
if view.isHidden {
|
||||
// already passed this function
|
||||
return
|
||||
}
|
||||
(view as? ReusableView)?.prepareForReuse()
|
||||
view.isHidden = true
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) {
|
||||
if view.isHidden { view.removeFromSuperview() }
|
||||
}
|
||||
}
|
||||
|
||||
/// Synchronizing both scrollViews
|
||||
func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool {
|
||||
true
|
||||
}
|
||||
|
||||
func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) {
|
||||
if !decelerate {
|
||||
snapToContent(animated: true)
|
||||
}
|
||||
}
|
||||
|
||||
override var contentOffset: CGPoint {
|
||||
get { super.contentOffset }
|
||||
set {
|
||||
var newOffset = newValue
|
||||
let topBlankSpace = estimatedContentHeight.height < bounds.height ? bounds.height - estimatedContentHeight.height : 0
|
||||
if contentOffset.y > 0 && newOffset.y < estimatedContentHeight.topOffsetY - topBlankSpace && contentOffset.y > newOffset.y {
|
||||
if !isDecelerating {
|
||||
newOffset.y = min(contentOffset.y, newOffset.y + abs(newOffset.y - estimatedContentHeight.topOffsetY + topBlankSpace) / 1.8)
|
||||
} else {
|
||||
DispatchQueue.main.async {
|
||||
self.setContentOffset(newValue, animated: false)
|
||||
self.snapToContent(animated: true)
|
||||
}
|
||||
}
|
||||
} else if contentOffset.y > 0 && newOffset.y + bounds.height > estimatedContentHeight.bottomOffsetY && contentOffset.y < newOffset.y {
|
||||
if !isDecelerating {
|
||||
newOffset.y = max(contentOffset.y, newOffset.y - abs(newOffset.y + bounds.height - estimatedContentHeight.bottomOffsetY) / 1.8)
|
||||
} else {
|
||||
DispatchQueue.main.async {
|
||||
self.setContentOffset(newValue, animated: false)
|
||||
self.snapToContent(animated: true)
|
||||
}
|
||||
}
|
||||
}
|
||||
super.contentOffset = newOffset
|
||||
}
|
||||
}
|
||||
|
||||
private func stopScrolling() {
|
||||
let offsetYToStopAt = if abs(contentOffset.y - estimatedContentHeight.topOffsetY) < abs(bottomY - estimatedContentHeight.bottomOffsetY) {
|
||||
estimatedContentHeight.topOffsetY
|
||||
} else {
|
||||
estimatedContentHeight.bottomOffsetY - bounds.height
|
||||
}
|
||||
setContentOffset(CGPointMake(contentOffset.x, offsetYToStopAt), animated: false)
|
||||
}
|
||||
|
||||
func isVisible(_ view: UIView) -> Bool {
|
||||
if view.superview == nil {
|
||||
return false
|
||||
}
|
||||
return view.frame.intersects(CGRectMake(0, contentOffset.y, bounds.width, bounds.height))
|
||||
}
|
||||
}
|
||||
|
||||
private func println(_ text: String) {
|
||||
print("\(Date.now.timeIntervalSince1970): \(text)")
|
||||
}
|
|
@ -145,9 +145,9 @@ struct AddGroupMembersViewCommon: View {
|
|||
return dummy
|
||||
}()
|
||||
|
||||
@ViewBuilder private func inviteMembersButton() -> some View {
|
||||
private func inviteMembersButton() -> some View {
|
||||
let label: LocalizedStringKey = groupInfo.businessChat == nil ? "Invite to group" : "Invite to chat"
|
||||
Button {
|
||||
return Button {
|
||||
inviteMembers()
|
||||
} label: {
|
||||
HStack {
|
||||
|
@ -175,10 +175,8 @@ struct AddGroupMembersViewCommon: View {
|
|||
|
||||
private func rolePicker() -> some View {
|
||||
Picker("New member role", selection: $selectedRole) {
|
||||
ForEach(GroupMemberRole.allCases) { role in
|
||||
if role <= groupInfo.membership.memberRole && role != .author {
|
||||
Text(role.text)
|
||||
}
|
||||
ForEach(GroupMemberRole.supportedRoles.filter({ $0 <= groupInfo.membership.memberRole })) { role in
|
||||
Text(role.text)
|
||||
}
|
||||
}
|
||||
.frame(height: 36)
|
||||
|
|
|
@ -18,8 +18,10 @@ struct GroupChatInfoView: View {
|
|||
@ObservedObject var chat: Chat
|
||||
@Binding var groupInfo: GroupInfo
|
||||
var onSearch: () -> Void
|
||||
@State var localAlias: String
|
||||
@FocusState private var aliasTextFieldFocused: Bool
|
||||
@State private var alert: GroupChatInfoViewAlert? = nil
|
||||
@State private var groupLink: String?
|
||||
@State private var groupLink: CreatedConnLink?
|
||||
@State private var groupLinkMemberRole: GroupMemberRole = .member
|
||||
@State private var groupLinkNavLinkActive: Bool = false
|
||||
@State private var addMembersNavLinkActive: Bool = false
|
||||
|
@ -27,6 +29,7 @@ struct GroupChatInfoView: View {
|
|||
@State private var connectionCode: String?
|
||||
@State private var sendReceipts = SendReceipts.userDefault(true)
|
||||
@State private var sendReceiptsUserDefault = true
|
||||
@State private var progressIndicator = false
|
||||
@AppStorage(DEFAULT_DEVELOPER_TOOLS) private var developerTools = false
|
||||
@State private var searchText: String = ""
|
||||
@FocusState private var searchFocussed
|
||||
|
@ -67,101 +70,112 @@ struct GroupChatInfoView: View {
|
|||
.filter { m in let status = m.wrapped.memberStatus; return status != .memLeft && status != .memRemoved }
|
||||
.sorted { $0.wrapped.memberRole > $1.wrapped.memberRole }
|
||||
|
||||
List {
|
||||
groupInfoHeader()
|
||||
.listRowBackground(Color.clear)
|
||||
.padding(.bottom, 18)
|
||||
|
||||
infoActionButtons()
|
||||
.padding(.horizontal)
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(height: infoViewActionButtonHeight)
|
||||
.listRowBackground(Color.clear)
|
||||
.listRowSeparator(.hidden)
|
||||
.listRowInsets(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0))
|
||||
|
||||
Section {
|
||||
if groupInfo.isOwner && groupInfo.businessChat == nil {
|
||||
editGroupButton()
|
||||
}
|
||||
if groupInfo.groupProfile.description != nil || (groupInfo.isOwner && groupInfo.businessChat == nil) {
|
||||
addOrEditWelcomeMessage()
|
||||
}
|
||||
GroupPreferencesButton(groupInfo: $groupInfo, preferences: groupInfo.fullGroupPreferences, currentPreferences: groupInfo.fullGroupPreferences)
|
||||
if members.filter({ $0.wrapped.memberCurrent }).count <= SMALL_GROUPS_RCPS_MEM_LIMIT {
|
||||
sendReceiptsOption()
|
||||
} else {
|
||||
sendReceiptsOptionDisabled()
|
||||
}
|
||||
NavigationLink {
|
||||
ChatWallpaperEditorSheet(chat: chat)
|
||||
} label: {
|
||||
Label("Chat theme", systemImage: "photo")
|
||||
}
|
||||
} header: {
|
||||
Text("")
|
||||
} footer: {
|
||||
let label: LocalizedStringKey = (
|
||||
groupInfo.businessChat == nil
|
||||
? "Only group owners can change group preferences."
|
||||
: "Only chat owners can change preferences."
|
||||
)
|
||||
Text(label)
|
||||
.foregroundColor(theme.colors.secondary)
|
||||
}
|
||||
|
||||
Section(header: Text("\(members.count + 1) members").foregroundColor(theme.colors.secondary)) {
|
||||
if groupInfo.canAddMembers {
|
||||
if groupInfo.businessChat == nil {
|
||||
groupLinkButton()
|
||||
ZStack {
|
||||
List {
|
||||
groupInfoHeader()
|
||||
.listRowBackground(Color.clear)
|
||||
|
||||
localAliasTextEdit()
|
||||
.listRowBackground(Color.clear)
|
||||
.listRowSeparator(.hidden)
|
||||
.padding(.bottom, 18)
|
||||
|
||||
infoActionButtons()
|
||||
.padding(.horizontal)
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(height: infoViewActionButtonHeight)
|
||||
.listRowBackground(Color.clear)
|
||||
.listRowSeparator(.hidden)
|
||||
.listRowInsets(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0))
|
||||
|
||||
Section {
|
||||
if groupInfo.isOwner && groupInfo.businessChat == nil {
|
||||
editGroupButton()
|
||||
}
|
||||
if (chat.chatInfo.incognito) {
|
||||
Label("Invite members", systemImage: "plus")
|
||||
.foregroundColor(Color(uiColor: .tertiaryLabel))
|
||||
.onTapGesture { alert = .cantInviteIncognitoAlert }
|
||||
if groupInfo.groupProfile.description != nil || (groupInfo.isOwner && groupInfo.businessChat == nil) {
|
||||
addOrEditWelcomeMessage()
|
||||
}
|
||||
GroupPreferencesButton(groupInfo: $groupInfo, preferences: groupInfo.fullGroupPreferences, currentPreferences: groupInfo.fullGroupPreferences)
|
||||
if members.filter({ $0.wrapped.memberCurrent }).count <= SMALL_GROUPS_RCPS_MEM_LIMIT {
|
||||
sendReceiptsOption()
|
||||
} else {
|
||||
addMembersButton()
|
||||
sendReceiptsOptionDisabled()
|
||||
}
|
||||
|
||||
NavigationLink {
|
||||
ChatWallpaperEditorSheet(chat: chat)
|
||||
} label: {
|
||||
Label("Chat theme", systemImage: "photo")
|
||||
}
|
||||
} header: {
|
||||
Text("")
|
||||
} footer: {
|
||||
let label: LocalizedStringKey = (
|
||||
groupInfo.businessChat == nil
|
||||
? "Only group owners can change group preferences."
|
||||
: "Only chat owners can change preferences."
|
||||
)
|
||||
Text(label)
|
||||
.foregroundColor(theme.colors.secondary)
|
||||
}
|
||||
if members.count > 8 {
|
||||
|
||||
Section {
|
||||
ChatTTLOption(chat: chat, progressIndicator: $progressIndicator)
|
||||
} footer: {
|
||||
Text("Delete chat messages from your device.")
|
||||
}
|
||||
|
||||
Section(header: Text("\(members.count + 1) members").foregroundColor(theme.colors.secondary)) {
|
||||
if groupInfo.canAddMembers {
|
||||
if groupInfo.businessChat == nil {
|
||||
groupLinkButton()
|
||||
}
|
||||
if (chat.chatInfo.incognito) {
|
||||
Label("Invite members", systemImage: "plus")
|
||||
.foregroundColor(Color(uiColor: .tertiaryLabel))
|
||||
.onTapGesture { alert = .cantInviteIncognitoAlert }
|
||||
} else {
|
||||
addMembersButton()
|
||||
}
|
||||
}
|
||||
searchFieldView(text: $searchText, focussed: $searchFocussed, theme.colors.onBackground, theme.colors.secondary)
|
||||
.padding(.leading, 8)
|
||||
let s = searchText.trimmingCharacters(in: .whitespaces).localizedLowercase
|
||||
let filteredMembers = s == ""
|
||||
? members
|
||||
: members.filter { $0.wrapped.localAliasAndFullName.localizedLowercase.contains(s) }
|
||||
MemberRowView(chat: chat, groupInfo: groupInfo, groupMember: GMember(groupInfo.membership), user: true, alert: $alert)
|
||||
ForEach(filteredMembers) { member in
|
||||
MemberRowView(chat: chat, groupInfo: groupInfo, groupMember: member, alert: $alert)
|
||||
}
|
||||
}
|
||||
let s = searchText.trimmingCharacters(in: .whitespaces).localizedLowercase
|
||||
let filteredMembers = s == "" ? members : members.filter { $0.wrapped.chatViewName.localizedLowercase.contains(s) }
|
||||
MemberRowView(groupInfo: groupInfo, groupMember: GMember(groupInfo.membership), user: true, alert: $alert)
|
||||
ForEach(filteredMembers) { member in
|
||||
ZStack {
|
||||
NavigationLink {
|
||||
memberInfoView(member)
|
||||
} label: {
|
||||
EmptyView()
|
||||
}
|
||||
.opacity(0)
|
||||
MemberRowView(groupInfo: groupInfo, groupMember: member, alert: $alert)
|
||||
|
||||
Section {
|
||||
clearChatButton()
|
||||
if groupInfo.canDelete {
|
||||
deleteGroupButton()
|
||||
}
|
||||
if groupInfo.membership.memberCurrent {
|
||||
leaveGroupButton()
|
||||
}
|
||||
}
|
||||
|
||||
if developerTools {
|
||||
Section(header: Text("For console").foregroundColor(theme.colors.secondary)) {
|
||||
infoRow("Local name", chat.chatInfo.localDisplayName)
|
||||
infoRow("Database ID", "\(chat.chatInfo.apiId)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Section {
|
||||
clearChatButton()
|
||||
if groupInfo.canDelete {
|
||||
deleteGroupButton()
|
||||
}
|
||||
if groupInfo.membership.memberCurrent {
|
||||
leaveGroupButton()
|
||||
}
|
||||
}
|
||||
|
||||
if developerTools {
|
||||
Section(header: Text("For console").foregroundColor(theme.colors.secondary)) {
|
||||
infoRow("Local name", chat.chatInfo.localDisplayName)
|
||||
infoRow("Database ID", "\(chat.chatInfo.apiId)")
|
||||
}
|
||||
.modifier(ThemedBackground(grouped: true))
|
||||
.navigationBarHidden(true)
|
||||
.disabled(progressIndicator)
|
||||
.opacity(progressIndicator ? 0.6 : 1)
|
||||
|
||||
if progressIndicator {
|
||||
ProgressView().scaleEffect(2)
|
||||
}
|
||||
}
|
||||
.modifier(ThemedBackground(grouped: true))
|
||||
.navigationBarHidden(true)
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
|
||||
.alert(item: $alert) { alertItem in
|
||||
|
@ -200,7 +214,7 @@ struct GroupChatInfoView: View {
|
|||
ChatInfoImage(chat: chat, size: 192, color: Color(uiColor: .tertiarySystemFill))
|
||||
.padding(.top, 12)
|
||||
.padding()
|
||||
Text(cInfo.displayName)
|
||||
Text(cInfo.groupInfo?.groupProfile.displayName ?? cInfo.displayName)
|
||||
.font(.largeTitle)
|
||||
.multilineTextAlignment(.center)
|
||||
.lineLimit(4)
|
||||
|
@ -215,6 +229,37 @@ struct GroupChatInfoView: View {
|
|||
.frame(maxWidth: .infinity, alignment: .center)
|
||||
}
|
||||
|
||||
private func localAliasTextEdit() -> some View {
|
||||
TextField("Set chat name…", text: $localAlias)
|
||||
.disableAutocorrection(true)
|
||||
.focused($aliasTextFieldFocused)
|
||||
.submitLabel(.done)
|
||||
.onChange(of: aliasTextFieldFocused) { focused in
|
||||
if !focused {
|
||||
setGroupAlias()
|
||||
}
|
||||
}
|
||||
.onSubmit {
|
||||
setGroupAlias()
|
||||
}
|
||||
.multilineTextAlignment(.center)
|
||||
.foregroundColor(theme.colors.secondary)
|
||||
}
|
||||
|
||||
private func setGroupAlias() {
|
||||
Task {
|
||||
do {
|
||||
if let gInfo = try await apiSetGroupAlias(groupId: chat.chatInfo.apiId, localAlias: localAlias) {
|
||||
await MainActor.run {
|
||||
chatModel.updateGroup(gInfo)
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
logger.error("setGroupAlias error: \(responseError(error))")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func infoActionButtons() -> some View {
|
||||
GeometryReader { g in
|
||||
let buttonWidth = g.size.width / 4
|
||||
|
@ -223,7 +268,9 @@ struct GroupChatInfoView: View {
|
|||
if groupInfo.canAddMembers {
|
||||
addMembersActionButton(width: buttonWidth)
|
||||
}
|
||||
muteButton(width: buttonWidth)
|
||||
if let nextNtfMode = chat.chatInfo.nextNtfMode {
|
||||
muteButton(width: buttonWidth, nextNtfMode: nextNtfMode)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .center)
|
||||
}
|
||||
|
@ -237,9 +284,9 @@ struct GroupChatInfoView: View {
|
|||
.disabled(!groupInfo.ready || chat.chatItems.isEmpty)
|
||||
}
|
||||
|
||||
@ViewBuilder private func addMembersActionButton(width: CGFloat) -> some View {
|
||||
if chat.chatInfo.incognito {
|
||||
ZStack {
|
||||
private func addMembersActionButton(width: CGFloat) -> some View {
|
||||
ZStack {
|
||||
if chat.chatInfo.incognito {
|
||||
InfoViewButton(image: "link.badge.plus", title: "invite", width: width) {
|
||||
groupLinkNavLinkActive = true
|
||||
}
|
||||
|
@ -251,10 +298,7 @@ struct GroupChatInfoView: View {
|
|||
}
|
||||
.frame(width: 1, height: 1)
|
||||
.hidden()
|
||||
}
|
||||
.disabled(!groupInfo.ready)
|
||||
} else {
|
||||
ZStack {
|
||||
} else {
|
||||
InfoViewButton(image: "person.fill.badge.plus", title: "invite", width: width) {
|
||||
addMembersNavLinkActive = true
|
||||
}
|
||||
|
@ -267,17 +311,17 @@ struct GroupChatInfoView: View {
|
|||
.frame(width: 1, height: 1)
|
||||
.hidden()
|
||||
}
|
||||
.disabled(!groupInfo.ready)
|
||||
}
|
||||
.disabled(!groupInfo.ready)
|
||||
}
|
||||
|
||||
private func muteButton(width: CGFloat) -> some View {
|
||||
InfoViewButton(
|
||||
image: chat.chatInfo.ntfsEnabled ? "speaker.slash.fill" : "speaker.wave.2.fill",
|
||||
title: chat.chatInfo.ntfsEnabled ? "mute" : "unmute",
|
||||
private func muteButton(width: CGFloat, nextNtfMode: MsgFilter) -> some View {
|
||||
return InfoViewButton(
|
||||
image: nextNtfMode.iconFilled,
|
||||
title: "\(nextNtfMode.text(mentions: true))",
|
||||
width: width
|
||||
) {
|
||||
toggleNotifications(chat, enableNtfs: !chat.chatInfo.ntfsEnabled)
|
||||
toggleNotifications(chat, enableNtfs: nextNtfMode)
|
||||
}
|
||||
.disabled(!groupInfo.ready)
|
||||
}
|
||||
|
@ -300,16 +344,13 @@ struct GroupChatInfoView: View {
|
|||
.onAppear {
|
||||
searchFocussed = false
|
||||
Task {
|
||||
let groupMembers = await apiListMembers(groupInfo.groupId)
|
||||
await MainActor.run {
|
||||
chatModel.groupMembers = groupMembers.map { GMember.init($0) }
|
||||
chatModel.populateGroupMembersIndexes()
|
||||
}
|
||||
await chatModel.loadGroupMembers(groupInfo)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct MemberRowView: View {
|
||||
var chat: Chat
|
||||
var groupInfo: GroupInfo
|
||||
@ObservedObject var groupMember: GMember
|
||||
@EnvironmentObject var theme: AppTheme
|
||||
|
@ -318,7 +359,7 @@ struct GroupChatInfoView: View {
|
|||
|
||||
var body: some View {
|
||||
let member = groupMember.wrapped
|
||||
let v = HStack{
|
||||
let v1 = HStack{
|
||||
MemberProfileImage(member, size: 38)
|
||||
.padding(.trailing, 2)
|
||||
// TODO server connection status
|
||||
|
@ -334,7 +375,21 @@ struct GroupChatInfoView: View {
|
|||
Spacer()
|
||||
memberInfo(member)
|
||||
}
|
||||
|
||||
|
||||
let v = ZStack {
|
||||
if user {
|
||||
v1
|
||||
} else {
|
||||
NavigationLink {
|
||||
memberInfoView()
|
||||
} label: {
|
||||
EmptyView()
|
||||
}
|
||||
.opacity(0)
|
||||
v1
|
||||
}
|
||||
}
|
||||
|
||||
if user {
|
||||
v
|
||||
} else if groupInfo.membership.memberRole >= .admin {
|
||||
|
@ -359,6 +414,11 @@ struct GroupChatInfoView: View {
|
|||
}
|
||||
}
|
||||
|
||||
private func memberInfoView() -> some View {
|
||||
GroupMemberInfoView(groupInfo: groupInfo, chat: chat, groupMember: groupMember)
|
||||
.navigationBarHidden(false)
|
||||
}
|
||||
|
||||
private func memberConnStatus(_ member: GroupMember) -> LocalizedStringKey {
|
||||
if member.activeConn?.connDisabled ?? false {
|
||||
return "disabled"
|
||||
|
@ -438,11 +498,6 @@ struct GroupChatInfoView: View {
|
|||
}
|
||||
}
|
||||
|
||||
private func memberInfoView(_ groupMember: GMember) -> some View {
|
||||
GroupMemberInfoView(groupInfo: groupInfo, chat: chat, groupMember: groupMember)
|
||||
.navigationBarHidden(false)
|
||||
}
|
||||
|
||||
private func groupLinkButton() -> some View {
|
||||
NavigationLink {
|
||||
groupLinkDestinationView()
|
||||
|
@ -518,9 +573,9 @@ struct GroupChatInfoView: View {
|
|||
}
|
||||
}
|
||||
|
||||
@ViewBuilder private func leaveGroupButton() -> some View {
|
||||
private func leaveGroupButton() -> some View {
|
||||
let label: LocalizedStringKey = groupInfo.businessChat == nil ? "Leave group" : "Leave chat"
|
||||
Button(role: .destructive) {
|
||||
return Button(role: .destructive) {
|
||||
alert = .leaveGroupAlert
|
||||
} label: {
|
||||
Label(label, systemImage: "rectangle.portrait.and.arrow.right")
|
||||
|
@ -630,12 +685,14 @@ struct GroupChatInfoView: View {
|
|||
primaryButton: .destructive(Text("Remove")) {
|
||||
Task {
|
||||
do {
|
||||
let updatedMember = try await apiRemoveMember(groupInfo.groupId, mem.groupMemberId)
|
||||
let updatedMembers = try await apiRemoveMembers(groupInfo.groupId, [mem.groupMemberId])
|
||||
await MainActor.run {
|
||||
_ = chatModel.upsertGroupMember(groupInfo, updatedMember)
|
||||
updatedMembers.forEach { updatedMember in
|
||||
_ = chatModel.upsertGroupMember(groupInfo, updatedMember)
|
||||
}
|
||||
}
|
||||
} catch let error {
|
||||
logger.error("apiRemoveMember error: \(responseError(error))")
|
||||
logger.error("apiRemoveMembers error: \(responseError(error))")
|
||||
let a = getErrorAlert(error, "Error removing member")
|
||||
alert = .error(title: a.title, error: a.message)
|
||||
}
|
||||
|
@ -739,7 +796,8 @@ struct GroupChatInfoView_Previews: PreviewProvider {
|
|||
GroupChatInfoView(
|
||||
chat: Chat(chatInfo: ChatInfo.sampleData.group, chatItems: []),
|
||||
groupInfo: Binding.constant(GroupInfo.sampleData),
|
||||
onSearch: {}
|
||||
onSearch: {},
|
||||
localAlias: ""
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -10,12 +10,14 @@ import SwiftUI
|
|||
import SimpleXChat
|
||||
|
||||
struct GroupLinkView: View {
|
||||
@EnvironmentObject var theme: AppTheme
|
||||
var groupId: Int64
|
||||
@Binding var groupLink: String?
|
||||
@Binding var groupLink: CreatedConnLink?
|
||||
@Binding var groupLinkMemberRole: GroupMemberRole
|
||||
var showTitle: Bool = false
|
||||
var creatingGroup: Bool = false
|
||||
var linkCreatedCb: (() -> Void)? = nil
|
||||
@State private var showShortLink = true
|
||||
@State private var creatingLink = false
|
||||
@State private var alert: GroupLinkAlert?
|
||||
@State private var shouldCreate = true
|
||||
|
@ -69,10 +71,10 @@ struct GroupLinkView: View {
|
|||
}
|
||||
}
|
||||
.frame(height: 36)
|
||||
SimpleXLinkQRCode(uri: groupLink)
|
||||
.id("simplex-qrcode-view-for-\(groupLink)")
|
||||
SimpleXCreatedLinkQRCode(link: groupLink, short: $showShortLink)
|
||||
.id("simplex-qrcode-view-for-\(groupLink.simplexChatUri(short: showShortLink))")
|
||||
Button {
|
||||
showShareSheet(items: [simplexChatLink(groupLink)])
|
||||
showShareSheet(items: [groupLink.simplexChatUri(short: showShortLink)])
|
||||
} label: {
|
||||
Label("Share link", systemImage: "square.and.arrow.up")
|
||||
}
|
||||
|
@ -93,6 +95,10 @@ struct GroupLinkView: View {
|
|||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
}
|
||||
} header: {
|
||||
if let groupLink, groupLink.connShortLink != nil {
|
||||
ToggleShortLinkHeader(text: Text(""), link: groupLink, short: $showShortLink)
|
||||
}
|
||||
}
|
||||
.alert(item: $alert) { alert in
|
||||
switch alert {
|
||||
|
@ -158,8 +164,8 @@ struct GroupLinkView: View {
|
|||
|
||||
struct GroupLinkView_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
@State var groupLink: String? = "https://simplex.chat/contact#/?v=1&smp=smp%3A%2F%2FPQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo%3D%40smp6.simplex.im%2FK1rslx-m5bpXVIdMZg9NLUZ_8JBm8xTt%23MCowBQYDK2VuAyEALDeVe-sG8mRY22LsXlPgiwTNs9dbiLrNuA7f3ZMAJ2w%3D"
|
||||
@State var noGroupLink: String? = nil
|
||||
@State var groupLink: CreatedConnLink? = CreatedConnLink(connFullLink: "https://simplex.chat/contact#/?v=1&smp=smp%3A%2F%2FPQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo%3D%40smp6.simplex.im%2FK1rslx-m5bpXVIdMZg9NLUZ_8JBm8xTt%23MCowBQYDK2VuAyEALDeVe-sG8mRY22LsXlPgiwTNs9dbiLrNuA7f3ZMAJ2w%3D", connShortLink: nil)
|
||||
@State var noGroupLink: CreatedConnLink? = nil
|
||||
|
||||
return Group {
|
||||
GroupLinkView(groupId: 1, groupLink: $groupLink, groupLinkMemberRole: Binding.constant(.member))
|
||||
|
|
|
@ -162,7 +162,7 @@ struct GroupMemberInfoView: View {
|
|||
}
|
||||
.disabled(
|
||||
connStats.rcvQueuesInfo.contains { $0.rcvSwitchStatus != nil }
|
||||
|| connStats.ratchetSyncSendProhibited
|
||||
|| !member.sendMsgEnabled
|
||||
)
|
||||
if connStats.rcvQueuesInfo.contains(where: { $0.rcvSwitchStatus != nil }) {
|
||||
Button("Abort changing address") {
|
||||
|
@ -170,7 +170,7 @@ struct GroupMemberInfoView: View {
|
|||
}
|
||||
.disabled(
|
||||
connStats.rcvQueuesInfo.contains { $0.rcvSwitchStatus != nil && !$0.canAbortSwitch }
|
||||
|| connStats.ratchetSyncSendProhibited
|
||||
|| !member.sendMsgEnabled
|
||||
)
|
||||
}
|
||||
smpServers("Receiving via", connStats.rcvQueuesInfo.map { $0.rcvServer }, theme.colors.secondary)
|
||||
|
@ -296,8 +296,8 @@ struct GroupMemberInfoView: View {
|
|||
} else if groupInfo.fullGroupPreferences.directMessages.on(for: groupInfo.membership) {
|
||||
if let contactId = member.memberContactId {
|
||||
newDirectChatButton(contactId, width: buttonWidth)
|
||||
} else if member.activeConn?.peerChatVRange.isCompatibleRange(CREATE_MEMBER_CONTACT_VRANGE) ?? false {
|
||||
createMemberContactButton(width: buttonWidth)
|
||||
} else if member.versionRange.maxVersion >= CREATE_MEMBER_CONTACT_VERSION {
|
||||
createMemberContactButton(member, width: buttonWidth)
|
||||
}
|
||||
InfoViewButton(image: "phone.fill", title: "call", disabledLook: true, width: buttonWidth) { showSendMessageToEnableCallsAlert()
|
||||
}
|
||||
|
@ -366,45 +366,48 @@ struct GroupMemberInfoView: View {
|
|||
func newDirectChatButton(_ contactId: Int64, width: CGFloat) -> some View {
|
||||
InfoViewButton(image: "message.fill", title: "message", width: width) {
|
||||
Task {
|
||||
do {
|
||||
let chat = try await apiGetChat(type: .direct, id: contactId)
|
||||
chatModel.addChat(chat)
|
||||
ItemsModel.shared.loadOpenChat(chat.id) {
|
||||
dismissAllSheets(animated: true)
|
||||
}
|
||||
} catch let error {
|
||||
logger.error("openDirectChatButton apiGetChat error: \(responseError(error))")
|
||||
ItemsModel.shared.loadOpenChat("@\(contactId)") {
|
||||
dismissAllSheets(animated: true)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func createMemberContactButton(width: CGFloat) -> some View {
|
||||
InfoViewButton(image: "message.fill", title: "message", width: width) {
|
||||
if let connStats = connectionStats {
|
||||
if connStats.ratchetSyncState == .ok {
|
||||
progressIndicator = true
|
||||
Task {
|
||||
do {
|
||||
let memberContact = try await apiCreateMemberContact(groupInfo.apiId, groupMember.groupMemberId)
|
||||
await MainActor.run {
|
||||
progressIndicator = false
|
||||
chatModel.addChat(Chat(chatInfo: .direct(contact: memberContact)))
|
||||
ItemsModel.shared.loadOpenChat(memberContact.id) {
|
||||
dismissAllSheets(animated: true)
|
||||
}
|
||||
NetworkModel.shared.setContactNetworkStatus(memberContact, .connected)
|
||||
}
|
||||
} catch let error {
|
||||
logger.error("createMemberContactButton apiCreateMemberContact error: \(responseError(error))")
|
||||
let a = getErrorAlert(error, "Error creating member contact")
|
||||
await MainActor.run {
|
||||
progressIndicator = false
|
||||
alert = .error(title: a.title, error: a.message)
|
||||
func createMemberContactButton(_ member: GroupMember, width: CGFloat) -> some View {
|
||||
InfoViewButton(
|
||||
image: "message.fill",
|
||||
title: "message",
|
||||
disabledLook:
|
||||
!(
|
||||
member.sendMsgEnabled ||
|
||||
(member.activeConn?.connectionStats?.ratchetSyncAllowed ?? false)
|
||||
),
|
||||
width: width
|
||||
) {
|
||||
if member.sendMsgEnabled {
|
||||
progressIndicator = true
|
||||
Task {
|
||||
do {
|
||||
let memberContact = try await apiCreateMemberContact(groupInfo.apiId, groupMember.groupMemberId)
|
||||
await MainActor.run {
|
||||
progressIndicator = false
|
||||
chatModel.addChat(Chat(chatInfo: .direct(contact: memberContact)))
|
||||
ItemsModel.shared.loadOpenChat(memberContact.id) {
|
||||
dismissAllSheets(animated: true)
|
||||
}
|
||||
NetworkModel.shared.setContactNetworkStatus(memberContact, .connected)
|
||||
}
|
||||
} catch let error {
|
||||
logger.error("createMemberContactButton apiCreateMemberContact error: \(responseError(error))")
|
||||
let a = getErrorAlert(error, "Error creating member contact")
|
||||
await MainActor.run {
|
||||
progressIndicator = false
|
||||
alert = .error(title: a.title, error: a.message)
|
||||
}
|
||||
}
|
||||
} else if connStats.ratchetSyncAllowed {
|
||||
}
|
||||
} else if let connStats = connectionStats {
|
||||
if connStats.ratchetSyncAllowed {
|
||||
alert = .someAlert(alert: SomeAlert(
|
||||
alert: Alert(
|
||||
title: Text("Fix connection?"),
|
||||
|
@ -416,13 +419,21 @@ struct GroupMemberInfoView: View {
|
|||
),
|
||||
id: "can't message member, fix connection"
|
||||
))
|
||||
} else {
|
||||
} else if connStats.ratchetSyncInProgress {
|
||||
alert = .someAlert(alert: SomeAlert(
|
||||
alert: mkAlert(
|
||||
title: "Can't message member",
|
||||
message: "Encryption renegotiation in progress."
|
||||
),
|
||||
id: "can't message contact, encryption renegotiation in progress"
|
||||
id: "can't message member, encryption renegotiation in progress"
|
||||
))
|
||||
} else {
|
||||
alert = .someAlert(alert: SomeAlert(
|
||||
alert: mkAlert(
|
||||
title: "Can't message member",
|
||||
message: "Connection not ready."
|
||||
),
|
||||
id: "can't message member, connection not ready"
|
||||
))
|
||||
}
|
||||
}
|
||||
|
@ -599,13 +610,15 @@ struct GroupMemberInfoView: View {
|
|||
primaryButton: .destructive(Text("Remove")) {
|
||||
Task {
|
||||
do {
|
||||
let updatedMember = try await apiRemoveMember(groupInfo.groupId, mem.groupMemberId)
|
||||
let updatedMembers = try await apiRemoveMembers(groupInfo.groupId, [mem.groupMemberId])
|
||||
await MainActor.run {
|
||||
_ = chatModel.upsertGroupMember(groupInfo, updatedMember)
|
||||
updatedMembers.forEach { updatedMember in
|
||||
_ = chatModel.upsertGroupMember(groupInfo, updatedMember)
|
||||
}
|
||||
dismiss()
|
||||
}
|
||||
} catch let error {
|
||||
logger.error("apiRemoveMember error: \(responseError(error))")
|
||||
logger.error("apiRemoveMembers error: \(responseError(error))")
|
||||
let a = getErrorAlert(error, "Error removing member")
|
||||
alert = .error(title: a.title, error: a.message)
|
||||
}
|
||||
|
@ -630,14 +643,16 @@ struct GroupMemberInfoView: View {
|
|||
primaryButton: .default(Text("Change")) {
|
||||
Task {
|
||||
do {
|
||||
let updatedMember = try await apiMemberRole(groupInfo.groupId, mem.groupMemberId, newRole)
|
||||
let updatedMembers = try await apiMembersRole(groupInfo.groupId, [mem.groupMemberId], newRole)
|
||||
await MainActor.run {
|
||||
_ = chatModel.upsertGroupMember(groupInfo, updatedMember)
|
||||
updatedMembers.forEach { updatedMember in
|
||||
_ = chatModel.upsertGroupMember(groupInfo, updatedMember)
|
||||
}
|
||||
}
|
||||
|
||||
} catch let error {
|
||||
newRole = mem.memberRole
|
||||
logger.error("apiMemberRole error: \(responseError(error))")
|
||||
logger.error("apiMembersRole error: \(responseError(error))")
|
||||
let a = getErrorAlert(error, "Error changing role")
|
||||
alert = .error(title: a.title, error: a.message)
|
||||
}
|
||||
|
@ -789,12 +804,14 @@ func unblockForAllAlert(_ gInfo: GroupInfo, _ mem: GroupMember) -> Alert {
|
|||
func blockMemberForAll(_ gInfo: GroupInfo, _ member: GroupMember, _ blocked: Bool) {
|
||||
Task {
|
||||
do {
|
||||
let updatedMember = try await apiBlockMemberForAll(gInfo.groupId, member.groupMemberId, blocked)
|
||||
let updatedMembers = try await apiBlockMembersForAll(gInfo.groupId, [member.groupMemberId], blocked)
|
||||
await MainActor.run {
|
||||
_ = ChatModel.shared.upsertGroupMember(gInfo, updatedMember)
|
||||
updatedMembers.forEach { updatedMember in
|
||||
_ = ChatModel.shared.upsertGroupMember(gInfo, updatedMember)
|
||||
}
|
||||
}
|
||||
} catch let error {
|
||||
logger.error("apiBlockMemberForAll error: \(responseError(error))")
|
||||
logger.error("apiBlockMembersForAll error: \(responseError(error))")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
249
apps/ios/Shared/Views/Chat/Group/GroupMentions.swift
Normal file
249
apps/ios/Shared/Views/Chat/Group/GroupMentions.swift
Normal file
|
@ -0,0 +1,249 @@
|
|||
//
|
||||
// GroupMentions.swift
|
||||
// SimpleX (iOS)
|
||||
//
|
||||
// Created by Diogo Cunha on 30/01/2025.
|
||||
// Copyright © 2025 SimpleX Chat. All rights reserved.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import SimpleXChat
|
||||
|
||||
let MENTION_START: Character = "@"
|
||||
let QUOTE: Character = "'"
|
||||
let MEMBER_ROW_SIZE: CGFloat = 60
|
||||
let MAX_VISIBLE_MEMBER_ROWS: CGFloat = 4.8
|
||||
|
||||
struct GroupMentionsView: View {
|
||||
@EnvironmentObject var m: ChatModel
|
||||
@EnvironmentObject var theme: AppTheme
|
||||
var groupInfo: GroupInfo
|
||||
@Binding var composeState: ComposeState
|
||||
@Binding var selectedRange: NSRange
|
||||
@Binding var keyboardVisible: Bool
|
||||
|
||||
@State private var isVisible = false
|
||||
@State private var currentMessage: String = ""
|
||||
@State private var mentionName: String = ""
|
||||
@State private var mentionRange: NSRange?
|
||||
@State private var mentionMemberId: String?
|
||||
@State private var sortedMembers: [GMember] = []
|
||||
|
||||
var body: some View {
|
||||
ZStack(alignment: .bottom) {
|
||||
if isVisible {
|
||||
let filtered = filteredMembers()
|
||||
if filtered.count > 0 {
|
||||
Color.white.opacity(0.01)
|
||||
.edgesIgnoringSafeArea(.all)
|
||||
.onTapGesture {
|
||||
isVisible = false
|
||||
}
|
||||
VStack(spacing: 0) {
|
||||
Spacer()
|
||||
Divider()
|
||||
let scroll = ScrollView {
|
||||
LazyVStack(spacing: 0) {
|
||||
ForEach(Array(filtered.enumerated()), id: \.element.wrapped.groupMemberId) { index, member in
|
||||
let mentioned = mentionMemberId == member.wrapped.memberId
|
||||
let disabled = composeState.mentions.count >= MAX_NUMBER_OF_MENTIONS && !mentioned
|
||||
ZStack(alignment: .bottom) {
|
||||
memberRowView(member.wrapped, mentioned)
|
||||
.contentShape(Rectangle())
|
||||
.disabled(disabled)
|
||||
.opacity(disabled ? 0.6 : 1)
|
||||
.onTapGesture {
|
||||
memberSelected(member)
|
||||
}
|
||||
.padding(.horizontal)
|
||||
.frame(height: MEMBER_ROW_SIZE)
|
||||
|
||||
Divider()
|
||||
.padding(.leading)
|
||||
.padding(.leading, 48)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(maxHeight: MEMBER_ROW_SIZE * min(MAX_VISIBLE_MEMBER_ROWS, CGFloat(filtered.count)))
|
||||
.background(Color(UIColor.systemBackground))
|
||||
|
||||
if #available(iOS 16.0, *) {
|
||||
scroll.scrollDismissesKeyboard(.never)
|
||||
} else {
|
||||
scroll
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.onChange(of: composeState.parsedMessage) { parsedMsg in
|
||||
currentMessage = composeState.message
|
||||
messageChanged(currentMessage, parsedMsg, selectedRange)
|
||||
}
|
||||
.onChange(of: selectedRange) { r in
|
||||
// This condition is needed to prevent messageChanged called twice,
|
||||
// because composeState.formattedText triggers later when message changes.
|
||||
// The condition is only true if position changed without text change
|
||||
if currentMessage == composeState.message {
|
||||
messageChanged(currentMessage, composeState.parsedMessage, r)
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
currentMessage = composeState.message
|
||||
}
|
||||
}
|
||||
|
||||
private func filteredMembers() -> [GMember] {
|
||||
let s = mentionName.lowercased()
|
||||
return s.isEmpty
|
||||
? sortedMembers
|
||||
: sortedMembers.filter { $0.wrapped.localAliasAndFullName.localizedLowercase.contains(s) }
|
||||
}
|
||||
|
||||
private func messageChanged(_ msg: String, _ parsedMsg: [FormattedText], _ range: NSRange) {
|
||||
removeUnusedMentions(parsedMsg)
|
||||
if let (ft, r) = selectedMarkdown(parsedMsg, range) {
|
||||
switch ft.format {
|
||||
case let .mention(name):
|
||||
isVisible = true
|
||||
mentionName = name
|
||||
mentionRange = r
|
||||
mentionMemberId = composeState.mentions[name]?.memberId
|
||||
if !m.membersLoaded {
|
||||
Task {
|
||||
await m.loadGroupMembers(groupInfo)
|
||||
sortMembers()
|
||||
}
|
||||
}
|
||||
return
|
||||
case .none: () //
|
||||
let pos = range.location
|
||||
if range.length == 0, let (at, atRange) = getCharacter(msg, pos - 1), at == "@" {
|
||||
let prevChar = getCharacter(msg, pos - 2)?.char
|
||||
if prevChar == nil || prevChar == " " || prevChar == "\n" {
|
||||
isVisible = true
|
||||
mentionName = ""
|
||||
mentionRange = atRange
|
||||
mentionMemberId = nil
|
||||
Task {
|
||||
await m.loadGroupMembers(groupInfo)
|
||||
sortMembers()
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
default: ()
|
||||
}
|
||||
}
|
||||
closeMemberList()
|
||||
}
|
||||
|
||||
private func sortMembers() {
|
||||
sortedMembers = m.groupMembers.filter({ m in
|
||||
let status = m.wrapped.memberStatus
|
||||
return status != .memLeft && status != .memRemoved && status != .memInvited
|
||||
})
|
||||
.sorted { $0.wrapped.memberRole > $1.wrapped.memberRole }
|
||||
}
|
||||
|
||||
private func removeUnusedMentions(_ parsedMsg: [FormattedText]) {
|
||||
let usedMentions: Set<String> = Set(parsedMsg.compactMap { ft in
|
||||
if case let .mention(name) = ft.format { name } else { nil }
|
||||
})
|
||||
if usedMentions.count < composeState.mentions.count {
|
||||
composeState = composeState.copy(mentions: composeState.mentions.filter({ usedMentions.contains($0.key) }))
|
||||
}
|
||||
}
|
||||
|
||||
private func getCharacter(_ s: String, _ pos: Int) -> (char: String.SubSequence, range: NSRange)? {
|
||||
if pos < 0 || pos >= s.count { return nil }
|
||||
let r = NSRange(location: pos, length: 1)
|
||||
return if let range = Range(r, in: s) {
|
||||
(s[range], r)
|
||||
} else {
|
||||
nil
|
||||
}
|
||||
}
|
||||
|
||||
private func selectedMarkdown(_ parsedMsg: [FormattedText], _ range: NSRange) -> (FormattedText, NSRange)? {
|
||||
if parsedMsg.isEmpty { return nil }
|
||||
var i = 0
|
||||
var pos: Int = 0
|
||||
while i < parsedMsg.count && pos + parsedMsg[i].text.count < range.location {
|
||||
pos += parsedMsg[i].text.count
|
||||
i += 1
|
||||
}
|
||||
// the second condition will be true when two markdowns are selected
|
||||
return i >= parsedMsg.count || range.location + range.length > pos + parsedMsg[i].text.count
|
||||
? nil
|
||||
: (parsedMsg[i], NSRange(location: pos, length: parsedMsg[i].text.count))
|
||||
}
|
||||
|
||||
private func memberSelected(_ member: GMember) {
|
||||
if let range = mentionRange, mentionMemberId == nil || mentionMemberId != member.wrapped.memberId {
|
||||
addMemberMention(member, range)
|
||||
}
|
||||
}
|
||||
|
||||
private func addMemberMention(_ member: GMember, _ r: NSRange) {
|
||||
guard let range = Range(r, in: composeState.message) else { return }
|
||||
var mentions = composeState.mentions
|
||||
var newName: String
|
||||
if let mm = mentions.first(where: { $0.value.memberId == member.wrapped.memberId }) {
|
||||
newName = mm.key
|
||||
} else {
|
||||
newName = composeState.mentionMemberName(member.wrapped.memberProfile.displayName)
|
||||
}
|
||||
mentions[newName] = CIMention(groupMember: member.wrapped)
|
||||
var msgMention = newName.contains(" ") || newName.last?.isPunctuation == true
|
||||
? "@'\(newName)'"
|
||||
: "@\(newName)"
|
||||
var newPos = r.location + msgMention.count
|
||||
let newMsgLength = composeState.message.count + msgMention.count - r.length
|
||||
print(newPos)
|
||||
print(newMsgLength)
|
||||
if newPos == newMsgLength {
|
||||
msgMention += " "
|
||||
newPos += 1
|
||||
}
|
||||
composeState = composeState.copy(
|
||||
message: composeState.message.replacingCharacters(in: range, with: msgMention),
|
||||
mentions: mentions
|
||||
)
|
||||
selectedRange = NSRange(location: newPos, length: 0)
|
||||
closeMemberList()
|
||||
keyboardVisible = true
|
||||
}
|
||||
|
||||
private func closeMemberList() {
|
||||
isVisible = false
|
||||
mentionName = ""
|
||||
mentionRange = nil
|
||||
mentionMemberId = nil
|
||||
}
|
||||
|
||||
private func memberRowView(_ member: GroupMember, _ mentioned: Bool) -> some View {
|
||||
return HStack{
|
||||
MemberProfileImage(member, size: 38)
|
||||
.padding(.trailing, 2)
|
||||
VStack(alignment: .leading) {
|
||||
let t = Text(member.localAliasAndFullName).foregroundColor(member.memberIncognito ? .indigo : theme.colors.onBackground)
|
||||
(member.verified ? memberVerifiedShield() + t : t)
|
||||
.lineLimit(1)
|
||||
}
|
||||
Spacer()
|
||||
if mentioned {
|
||||
Image(systemName: "checkmark")
|
||||
}
|
||||
}
|
||||
|
||||
func memberVerifiedShield() -> Text {
|
||||
(Text(Image(systemName: "checkmark.shield")) + textSpace)
|
||||
.font(.caption)
|
||||
.baselineOffset(2)
|
||||
.kerning(-2)
|
||||
.foregroundColor(theme.colors.secondary)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -37,6 +37,7 @@ struct GroupPreferencesView: View {
|
|||
featureSection(.voice, $preferences.voice.enable, $preferences.voice.role)
|
||||
featureSection(.files, $preferences.files.enable, $preferences.files.role)
|
||||
featureSection(.simplexLinks, $preferences.simplexLinks.enable, $preferences.simplexLinks.role)
|
||||
featureSection(.reports, $preferences.reports.enable)
|
||||
featureSection(.history, $preferences.history.enable)
|
||||
|
||||
if groupInfo.isOwner {
|
||||
|
@ -89,6 +90,7 @@ struct GroupPreferencesView: View {
|
|||
settingsRow(icon, color: color) {
|
||||
Toggle(feature.text, isOn: enable)
|
||||
}
|
||||
.disabled(feature == .reports) // remove in 6.4
|
||||
if timedOn {
|
||||
DropdownCustomTimePicker(
|
||||
selection: $preferences.timedMessages.ttl,
|
||||
|
|
|
@ -18,6 +18,7 @@ struct GroupWelcomeView: View {
|
|||
@State private var editMode = true
|
||||
@FocusState private var keyboardVisible: Bool
|
||||
@State private var showSaveDialog = false
|
||||
@State private var showSecrets: Set<Int> = []
|
||||
|
||||
let maxByteCount = 1200
|
||||
|
||||
|
@ -58,7 +59,8 @@ struct GroupWelcomeView: View {
|
|||
}
|
||||
|
||||
private func textPreview() -> some View {
|
||||
messageText(welcomeText, parseSimpleXMarkdown(welcomeText), nil, showSecrets: false, secondaryColor: theme.colors.secondary)
|
||||
let r = messageText(welcomeText, parseSimpleXMarkdown(welcomeText), sender: nil, mentions: nil, userMemberId: nil, showSecrets: showSecrets, backgroundColor: UIColor(theme.colors.background))
|
||||
return msgTextResultView(r, Text(AttributedString(r.string)), showSecrets: $showSecrets)
|
||||
.frame(minHeight: 130, alignment: .topLeading)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
|
|
|
@ -1,371 +0,0 @@
|
|||
//
|
||||
// ReverseList.swift
|
||||
// SimpleX (iOS)
|
||||
//
|
||||
// Created by Levitating Pineapple on 11/06/2024.
|
||||
// Copyright © 2024 SimpleX Chat. All rights reserved.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import Combine
|
||||
import SimpleXChat
|
||||
|
||||
/// A List, which displays it's items in reverse order - from bottom to top
|
||||
struct ReverseList<Content: View>: UIViewControllerRepresentable {
|
||||
let items: Array<ChatItem>
|
||||
|
||||
@Binding var scrollState: ReverseListScrollModel.State
|
||||
|
||||
/// Closure, that returns user interface for a given item
|
||||
let content: (ChatItem) -> Content
|
||||
|
||||
let loadPage: () -> Void
|
||||
|
||||
func makeUIViewController(context: Context) -> Controller {
|
||||
Controller(representer: self)
|
||||
}
|
||||
|
||||
func updateUIViewController(_ controller: Controller, context: Context) {
|
||||
controller.representer = self
|
||||
if case let .scrollingTo(destination) = scrollState, !items.isEmpty {
|
||||
controller.view.layer.removeAllAnimations()
|
||||
switch destination {
|
||||
case .nextPage:
|
||||
controller.scrollToNextPage()
|
||||
case let .item(id):
|
||||
controller.scroll(to: items.firstIndex(where: { $0.id == id }), position: .bottom)
|
||||
case .bottom:
|
||||
controller.scroll(to: 0, position: .top)
|
||||
}
|
||||
} else {
|
||||
controller.update(items: items)
|
||||
}
|
||||
}
|
||||
|
||||
/// Controller, which hosts SwiftUI cells
|
||||
class Controller: UITableViewController {
|
||||
private enum Section { case main }
|
||||
var representer: ReverseList
|
||||
private var dataSource: UITableViewDiffableDataSource<Section, ChatItem>!
|
||||
private var itemCount: Int = 0
|
||||
private let updateFloatingButtons = PassthroughSubject<Void, Never>()
|
||||
private var bag = Set<AnyCancellable>()
|
||||
|
||||
init(representer: ReverseList) {
|
||||
self.representer = representer
|
||||
super.init(style: .plain)
|
||||
|
||||
// 1. Style
|
||||
tableView = InvertedTableView()
|
||||
tableView.separatorStyle = .none
|
||||
tableView.transform = .verticalFlip
|
||||
tableView.backgroundColor = .clear
|
||||
|
||||
// 2. Register cells
|
||||
if #available(iOS 16.0, *) {
|
||||
tableView.register(
|
||||
UITableViewCell.self,
|
||||
forCellReuseIdentifier: cellReuseId
|
||||
)
|
||||
} else {
|
||||
tableView.register(
|
||||
HostingCell<Content>.self,
|
||||
forCellReuseIdentifier: cellReuseId
|
||||
)
|
||||
}
|
||||
|
||||
// 3. Configure data source
|
||||
self.dataSource = UITableViewDiffableDataSource<Section, ChatItem>(
|
||||
tableView: tableView
|
||||
) { (tableView, indexPath, item) -> UITableViewCell? in
|
||||
if indexPath.item > self.itemCount - 8 {
|
||||
self.representer.loadPage()
|
||||
}
|
||||
let cell = tableView.dequeueReusableCell(withIdentifier: cellReuseId, for: indexPath)
|
||||
if #available(iOS 16.0, *) {
|
||||
cell.contentConfiguration = UIHostingConfiguration { self.representer.content(item) }
|
||||
.margins(.all, 0)
|
||||
.minSize(height: 1) // Passing zero will result in system default of 44 points being used
|
||||
} else {
|
||||
if let cell = cell as? HostingCell<Content> {
|
||||
cell.set(content: self.representer.content(item), parent: self)
|
||||
} else {
|
||||
fatalError("Unexpected Cell Type for: \(item)")
|
||||
}
|
||||
}
|
||||
cell.transform = .verticalFlip
|
||||
cell.selectionStyle = .none
|
||||
cell.backgroundColor = .clear
|
||||
return cell
|
||||
}
|
||||
|
||||
// 4. External state changes will require manual layout updates
|
||||
NotificationCenter.default
|
||||
.addObserver(
|
||||
self,
|
||||
selector: #selector(updateLayout),
|
||||
name: notificationName,
|
||||
object: nil
|
||||
)
|
||||
|
||||
updateFloatingButtons
|
||||
.throttle(for: 0.2, scheduler: DispatchQueue.global(qos: .background), latest: true)
|
||||
.sink {
|
||||
if let listState = DispatchQueue.main.sync(execute: { [weak self] in self?.getListState() }) {
|
||||
ChatView.FloatingButtonModel.shared.updateOnListChange(listState)
|
||||
}
|
||||
}
|
||||
.store(in: &bag)
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) { fatalError() }
|
||||
|
||||
deinit { NotificationCenter.default.removeObserver(self) }
|
||||
|
||||
@objc private func updateLayout() {
|
||||
if #available(iOS 16.0, *) {
|
||||
tableView.setNeedsLayout()
|
||||
tableView.layoutIfNeeded()
|
||||
} else {
|
||||
tableView.reloadData()
|
||||
}
|
||||
}
|
||||
|
||||
/// Hides keyboard, when user begins to scroll.
|
||||
/// Equivalent to `.scrollDismissesKeyboard(.immediately)`
|
||||
override func scrollViewWillBeginDragging(_ scrollView: UIScrollView) {
|
||||
UIApplication.shared
|
||||
.sendAction(
|
||||
#selector(UIResponder.resignFirstResponder),
|
||||
to: nil,
|
||||
from: nil,
|
||||
for: nil
|
||||
)
|
||||
NotificationCenter.default.post(name: .chatViewWillBeginScrolling, object: nil)
|
||||
}
|
||||
|
||||
override func viewDidAppear(_ animated: Bool) {
|
||||
tableView.clipsToBounds = false
|
||||
parent?.viewIfLoaded?.clipsToBounds = false
|
||||
}
|
||||
|
||||
/// Scrolls up
|
||||
func scrollToNextPage() {
|
||||
tableView.setContentOffset(
|
||||
CGPoint(
|
||||
x: tableView.contentOffset.x,
|
||||
y: tableView.contentOffset.y + tableView.bounds.height
|
||||
),
|
||||
animated: true
|
||||
)
|
||||
Task { representer.scrollState = .atDestination }
|
||||
}
|
||||
|
||||
/// Scrolls to Item at index path
|
||||
/// - Parameter indexPath: Item to scroll to - will scroll to beginning of the list, if `nil`
|
||||
func scroll(to index: Int?, position: UITableView.ScrollPosition) {
|
||||
var animated = false
|
||||
if #available(iOS 16.0, *) {
|
||||
animated = true
|
||||
}
|
||||
if let index, tableView.numberOfRows(inSection: 0) != 0 {
|
||||
tableView.scrollToRow(
|
||||
at: IndexPath(row: index, section: 0),
|
||||
at: position,
|
||||
animated: animated
|
||||
)
|
||||
} else {
|
||||
tableView.setContentOffset(
|
||||
CGPoint(x: .zero, y: -InvertedTableView.inset),
|
||||
animated: animated
|
||||
)
|
||||
}
|
||||
Task { representer.scrollState = .atDestination }
|
||||
}
|
||||
|
||||
func update(items: [ChatItem]) {
|
||||
var snapshot = NSDiffableDataSourceSnapshot<Section, ChatItem>()
|
||||
snapshot.appendSections([.main])
|
||||
snapshot.appendItems(items)
|
||||
dataSource.defaultRowAnimation = .none
|
||||
dataSource.apply(
|
||||
snapshot,
|
||||
animatingDifferences: itemCount != 0 && abs(items.count - itemCount) == 1
|
||||
)
|
||||
// Sets content offset on initial load
|
||||
if itemCount == 0 {
|
||||
tableView.setContentOffset(
|
||||
CGPoint(x: 0, y: -InvertedTableView.inset),
|
||||
animated: false
|
||||
)
|
||||
}
|
||||
itemCount = items.count
|
||||
updateFloatingButtons.send()
|
||||
}
|
||||
|
||||
override func scrollViewDidScroll(_ scrollView: UIScrollView) {
|
||||
updateFloatingButtons.send()
|
||||
}
|
||||
|
||||
func getListState() -> ListState? {
|
||||
if let visibleRows = tableView.indexPathsForVisibleRows,
|
||||
visibleRows.last?.item ?? 0 < representer.items.count {
|
||||
let scrollOffset: Double = tableView.contentOffset.y + InvertedTableView.inset
|
||||
let topItemDate: Date? =
|
||||
if let lastVisible = visibleRows.last(where: { isVisible(indexPath: $0) }) {
|
||||
representer.items[lastVisible.item].meta.itemTs
|
||||
} else {
|
||||
nil
|
||||
}
|
||||
let bottomItemId: ChatItem.ID? =
|
||||
if let firstVisible = visibleRows.first(where: { isVisible(indexPath: $0) }) {
|
||||
representer.items[firstVisible.item].id
|
||||
} else {
|
||||
nil
|
||||
}
|
||||
return (scrollOffset: scrollOffset, topItemDate: topItemDate, bottomItemId: bottomItemId)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
private func isVisible(indexPath: IndexPath) -> Bool {
|
||||
if let relativeFrame = tableView.superview?.convert(
|
||||
tableView.rectForRow(at: indexPath),
|
||||
from: tableView
|
||||
) {
|
||||
relativeFrame.maxY > InvertedTableView.inset &&
|
||||
relativeFrame.minY < tableView.frame.height - InvertedTableView.inset
|
||||
} else { false }
|
||||
}
|
||||
}
|
||||
|
||||
/// `UIHostingConfiguration` back-port for iOS14 and iOS15
|
||||
/// Implemented as a `UITableViewCell` that wraps and manages a generic `UIHostingController`
|
||||
private final class HostingCell<Hosted: View>: UITableViewCell {
|
||||
private let hostingController = UIHostingController<Hosted?>(rootView: nil)
|
||||
|
||||
/// Updates content of the cell
|
||||
/// For reference: https://noahgilmore.com/blog/swiftui-self-sizing-cells/
|
||||
func set(content: Hosted, parent: UIViewController) {
|
||||
hostingController.view.backgroundColor = .clear
|
||||
hostingController.rootView = content
|
||||
if let hostingView = hostingController.view {
|
||||
hostingView.invalidateIntrinsicContentSize()
|
||||
if hostingController.parent != parent { parent.addChild(hostingController) }
|
||||
if !contentView.subviews.contains(hostingController.view) {
|
||||
contentView.addSubview(hostingController.view)
|
||||
hostingView.translatesAutoresizingMaskIntoConstraints = false
|
||||
NSLayoutConstraint.activate([
|
||||
hostingView.leadingAnchor
|
||||
.constraint(equalTo: contentView.leadingAnchor),
|
||||
hostingView.trailingAnchor
|
||||
.constraint(equalTo: contentView.trailingAnchor),
|
||||
hostingView.topAnchor
|
||||
.constraint(equalTo: contentView.topAnchor),
|
||||
hostingView.bottomAnchor
|
||||
.constraint(equalTo: contentView.bottomAnchor)
|
||||
])
|
||||
}
|
||||
if hostingController.parent != parent { hostingController.didMove(toParent: parent) }
|
||||
} else {
|
||||
fatalError("Hosting View not loaded \(hostingController)")
|
||||
}
|
||||
}
|
||||
|
||||
override func prepareForReuse() {
|
||||
super.prepareForReuse()
|
||||
hostingController.rootView = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
typealias ListState = (
|
||||
scrollOffset: Double,
|
||||
topItemDate: Date?,
|
||||
bottomItemId: ChatItem.ID?
|
||||
)
|
||||
|
||||
/// Manages ``ReverseList`` scrolling
|
||||
class ReverseListScrollModel: ObservableObject {
|
||||
/// Represents Scroll State of ``ReverseList``
|
||||
enum State: Equatable {
|
||||
enum Destination: Equatable {
|
||||
case nextPage
|
||||
case item(ChatItem.ID)
|
||||
case bottom
|
||||
}
|
||||
|
||||
case scrollingTo(Destination)
|
||||
case atDestination
|
||||
}
|
||||
|
||||
@Published var state: State = .atDestination
|
||||
|
||||
func scrollToNextPage() {
|
||||
state = .scrollingTo(.nextPage)
|
||||
}
|
||||
|
||||
func scrollToBottom() {
|
||||
state = .scrollingTo(.bottom)
|
||||
}
|
||||
|
||||
func scrollToItem(id: ChatItem.ID) {
|
||||
state = .scrollingTo(.item(id))
|
||||
}
|
||||
}
|
||||
|
||||
fileprivate let cellReuseId = "hostingCell"
|
||||
|
||||
fileprivate let notificationName = NSNotification.Name(rawValue: "reverseListNeedsLayout")
|
||||
|
||||
fileprivate extension CGAffineTransform {
|
||||
/// Transform that vertically flips the view, preserving it's location
|
||||
static let verticalFlip = CGAffineTransform(scaleX: 1, y: -1)
|
||||
}
|
||||
|
||||
extension NotificationCenter {
|
||||
static func postReverseListNeedsLayout() {
|
||||
NotificationCenter.default.post(
|
||||
name: notificationName,
|
||||
object: nil
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Disable animation on iOS 15
|
||||
func withConditionalAnimation<Result>(
|
||||
_ animation: Animation? = .default,
|
||||
_ body: () throws -> Result
|
||||
) rethrows -> Result {
|
||||
if #available(iOS 16.0, *) {
|
||||
try withAnimation(animation, body)
|
||||
} else {
|
||||
try body()
|
||||
}
|
||||
}
|
||||
|
||||
class InvertedTableView: UITableView {
|
||||
static let inset = CGFloat(100)
|
||||
|
||||
static let insets = UIEdgeInsets(
|
||||
top: inset,
|
||||
left: .zero,
|
||||
bottom: inset,
|
||||
right: .zero
|
||||
)
|
||||
|
||||
override var contentInsetAdjustmentBehavior: UIScrollView.ContentInsetAdjustmentBehavior {
|
||||
get { .never }
|
||||
set { }
|
||||
}
|
||||
|
||||
override var contentInset: UIEdgeInsets {
|
||||
get { Self.insets }
|
||||
set { }
|
||||
}
|
||||
|
||||
override var adjustedContentInset: UIEdgeInsets {
|
||||
Self.insets
|
||||
}
|
||||
}
|
52
apps/ios/Shared/Views/Chat/ScrollViewCells.swift
Normal file
52
apps/ios/Shared/Views/Chat/ScrollViewCells.swift
Normal file
|
@ -0,0 +1,52 @@
|
|||
//
|
||||
// ScrollViewCells.swift
|
||||
// SimpleX (iOS)
|
||||
//
|
||||
// Created by Stanislav Dmitrenko on 27.01.2025.
|
||||
// Copyright © 2024 SimpleX Chat. All rights reserved.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
protocol ReusableView {
|
||||
func prepareForReuse()
|
||||
}
|
||||
|
||||
/// `UIHostingConfiguration` back-port for iOS14 and iOS15
|
||||
/// Implemented as a `UIView` that wraps and manages a generic `UIHostingController`
|
||||
final class HostingCell<Hosted: View>: UIView, ReusableView {
|
||||
private let hostingController = UIHostingController<Hosted?>(rootView: nil)
|
||||
|
||||
/// Updates content of the cell
|
||||
/// For reference: https://noahgilmore.com/blog/swiftui-self-sizing-cells/
|
||||
func set(content: Hosted, parent: UIViewController) {
|
||||
hostingController.view.backgroundColor = .clear
|
||||
hostingController.rootView = content
|
||||
if let hostingView = hostingController.view {
|
||||
hostingView.invalidateIntrinsicContentSize()
|
||||
if hostingController.parent != parent { parent.addChild(hostingController) }
|
||||
if !subviews.contains(hostingController.view) {
|
||||
addSubview(hostingController.view)
|
||||
hostingView.translatesAutoresizingMaskIntoConstraints = false
|
||||
NSLayoutConstraint.activate([
|
||||
hostingView.leadingAnchor
|
||||
.constraint(equalTo: leadingAnchor),
|
||||
hostingView.trailingAnchor
|
||||
.constraint(equalTo: trailingAnchor),
|
||||
hostingView.topAnchor
|
||||
.constraint(equalTo: topAnchor),
|
||||
hostingView.bottomAnchor
|
||||
.constraint(equalTo: bottomAnchor)
|
||||
])
|
||||
}
|
||||
if hostingController.parent != parent { hostingController.didMove(toParent: parent) }
|
||||
} else {
|
||||
fatalError("Hosting View not loaded \(hostingController)")
|
||||
}
|
||||
}
|
||||
|
||||
func prepareForReuse() {
|
||||
//super.prepareForReuse()
|
||||
hostingController.rootView = nil
|
||||
}
|
||||
}
|
|
@ -30,12 +30,15 @@ struct SelectedItemsBottomToolbar: View {
|
|||
var chatInfo: ChatInfo
|
||||
// Bool - delete for everyone is possible
|
||||
var deleteItems: (Bool) -> Void
|
||||
var archiveItems: () -> Void
|
||||
var moderateItems: () -> Void
|
||||
//var shareItems: () -> Void
|
||||
var forwardItems: () -> Void
|
||||
@State var deleteEnabled: Bool = false
|
||||
@State var deleteForEveryoneEnabled: Bool = false
|
||||
|
||||
@State var canArchiveReports: Bool = false
|
||||
|
||||
@State var canModerate: Bool = false
|
||||
@State var moderateEnabled: Bool = false
|
||||
|
||||
|
@ -50,7 +53,11 @@ struct SelectedItemsBottomToolbar: View {
|
|||
|
||||
HStack(alignment: .center) {
|
||||
Button {
|
||||
deleteItems(deleteForEveryoneEnabled)
|
||||
if canArchiveReports {
|
||||
archiveItems()
|
||||
} else {
|
||||
deleteItems(deleteForEveryoneEnabled)
|
||||
}
|
||||
} label: {
|
||||
Image(systemName: "trash")
|
||||
.resizable()
|
||||
|
@ -109,19 +116,25 @@ struct SelectedItemsBottomToolbar: View {
|
|||
deleteCountProhibited = count == 0 || count > 200
|
||||
forwardCountProhibited = count == 0 || count > 20
|
||||
canModerate = possibleToModerate(chatInfo)
|
||||
let groupInfo: GroupInfo? = if case let ChatInfo.group(groupInfo: info) = chatInfo {
|
||||
info
|
||||
} else {
|
||||
nil
|
||||
}
|
||||
if let selected = selectedItems {
|
||||
let me: Bool
|
||||
let onlyOwnGroupItems: Bool
|
||||
(deleteEnabled, deleteForEveryoneEnabled, me, onlyOwnGroupItems, forwardEnabled, selectedChatItems) = chatItems.reduce((true, true, true, true, true, [])) { (r, ci) in
|
||||
(deleteEnabled, deleteForEveryoneEnabled, canArchiveReports, me, onlyOwnGroupItems, forwardEnabled, selectedChatItems) = chatItems.reduce((true, true, true, true, true, true, [])) { (r, ci) in
|
||||
if selected.contains(ci.id) {
|
||||
var (de, dee, me, onlyOwnGroupItems, fe, sel) = r
|
||||
var (de, dee, ar, me, onlyOwnGroupItems, fe, sel) = r
|
||||
de = de && ci.canBeDeletedForSelf
|
||||
dee = dee && ci.meta.deletable && !ci.localNote
|
||||
onlyOwnGroupItems = onlyOwnGroupItems && ci.chatDir == .groupSnd
|
||||
me = me && ci.content.msgContent != nil && ci.memberToModerate(chatInfo) != nil
|
||||
fe = fe && ci.content.msgContent != nil && ci.meta.itemDeleted == nil && !ci.isLiveDummy
|
||||
dee = dee && ci.meta.deletable && !ci.localNote && !ci.isReport
|
||||
ar = ar && ci.isActiveReport && ci.chatDir != .groupSnd && groupInfo != nil && groupInfo!.membership.memberRole >= .moderator
|
||||
onlyOwnGroupItems = onlyOwnGroupItems && ci.chatDir == .groupSnd && !ci.isReport
|
||||
me = me && ci.content.msgContent != nil && ci.memberToModerate(chatInfo) != nil && !ci.isReport
|
||||
fe = fe && ci.content.msgContent != nil && ci.meta.itemDeleted == nil && !ci.isLiveDummy && !ci.isReport
|
||||
sel.insert(ci.id) // we are collecting new selected items here to account for any changes in chat items list
|
||||
return (de, dee, me, onlyOwnGroupItems, fe, sel)
|
||||
return (de, dee, ar, me, onlyOwnGroupItems, fe, sel)
|
||||
} else {
|
||||
return r
|
||||
}
|
||||
|
|
|
@ -43,9 +43,11 @@ func dynamicSize(_ font: DynamicTypeSize) -> DynamicSizes {
|
|||
struct ChatListNavLink: View {
|
||||
@EnvironmentObject var chatModel: ChatModel
|
||||
@EnvironmentObject var theme: AppTheme
|
||||
@EnvironmentObject var chatTagsModel: ChatTagsModel
|
||||
@Environment(\.dynamicTypeSize) private var userFont: DynamicTypeSize
|
||||
@AppStorage(GROUP_DEFAULT_ONE_HAND_UI, store: groupDefaults) private var oneHandUI = false
|
||||
@ObservedObject var chat: Chat
|
||||
@Binding var parentSheet: SomeSheet<AnyView>?
|
||||
@State private var showContactRequestDialog = false
|
||||
@State private var showJoinGroupDialog = false
|
||||
@State private var showContactConnectionInfo = false
|
||||
|
@ -85,13 +87,14 @@ struct ChatListNavLink: View {
|
|||
progressByTimeout = false
|
||||
}
|
||||
}
|
||||
.actionSheet(item: $actionSheet) { $0.actionSheet }
|
||||
}
|
||||
|
||||
@ViewBuilder private func contactNavLink(_ contact: Contact) -> some View {
|
||||
private func contactNavLink(_ contact: Contact) -> some View {
|
||||
Group {
|
||||
if contact.activeConn == nil && contact.profile.contactLink != nil && contact.active {
|
||||
ChatPreviewView(chat: chat, progressByTimeout: Binding.constant(false))
|
||||
.frame(height: dynamicRowHeight)
|
||||
.frameCompat(height: dynamicRowHeight)
|
||||
.swipeActions(edge: .trailing, allowsFullSwipe: true) {
|
||||
Button {
|
||||
deleteContactDialog(
|
||||
|
@ -118,12 +121,14 @@ struct ChatListNavLink: View {
|
|||
selection: $chatModel.chatId,
|
||||
label: { ChatPreviewView(chat: chat, progressByTimeout: Binding.constant(false)) }
|
||||
)
|
||||
.frameCompat(height: dynamicRowHeight)
|
||||
.swipeActions(edge: .leading, allowsFullSwipe: true) {
|
||||
markReadButton()
|
||||
toggleFavoriteButton()
|
||||
toggleNtfsButton(chat: chat)
|
||||
}
|
||||
.swipeActions(edge: .trailing, allowsFullSwipe: true) {
|
||||
tagChatButton(chat)
|
||||
if !chat.chatItems.isEmpty {
|
||||
clearChatButton()
|
||||
}
|
||||
|
@ -141,15 +146,13 @@ struct ChatListNavLink: View {
|
|||
}
|
||||
.tint(.red)
|
||||
}
|
||||
.frame(height: dynamicRowHeight)
|
||||
}
|
||||
}
|
||||
.alert(item: $alert) { $0.alert }
|
||||
.actionSheet(item: $actionSheet) { $0.actionSheet }
|
||||
.sheet(item: $sheet) {
|
||||
if #available(iOS 16.0, *) {
|
||||
$0.content
|
||||
.presentationDetents([.fraction(0.4)])
|
||||
.presentationDetents([.fraction($0.fraction)])
|
||||
} else {
|
||||
$0.content
|
||||
}
|
||||
|
@ -160,7 +163,7 @@ struct ChatListNavLink: View {
|
|||
switch (groupInfo.membership.memberStatus) {
|
||||
case .memInvited:
|
||||
ChatPreviewView(chat: chat, progressByTimeout: $progressByTimeout)
|
||||
.frame(height: dynamicRowHeight)
|
||||
.frameCompat(height: dynamicRowHeight)
|
||||
.swipeActions(edge: .trailing, allowsFullSwipe: true) {
|
||||
joinGroupButton()
|
||||
if groupInfo.canDelete {
|
||||
|
@ -180,11 +183,12 @@ struct ChatListNavLink: View {
|
|||
.disabled(inProgress)
|
||||
case .memAccepted:
|
||||
ChatPreviewView(chat: chat, progressByTimeout: Binding.constant(false))
|
||||
.frame(height: dynamicRowHeight)
|
||||
.frameCompat(height: dynamicRowHeight)
|
||||
.onTapGesture {
|
||||
AlertManager.shared.showAlert(groupInvitationAcceptedAlert())
|
||||
}
|
||||
.swipeActions(edge: .trailing) {
|
||||
tagChatButton(chat)
|
||||
if (groupInfo.membership.memberCurrent) {
|
||||
leaveGroupChatButton(groupInfo)
|
||||
}
|
||||
|
@ -199,34 +203,54 @@ struct ChatListNavLink: View {
|
|||
label: { ChatPreviewView(chat: chat, progressByTimeout: Binding.constant(false)) },
|
||||
disabled: !groupInfo.ready
|
||||
)
|
||||
.frame(height: dynamicRowHeight)
|
||||
.frameCompat(height: dynamicRowHeight)
|
||||
.swipeActions(edge: .leading, allowsFullSwipe: true) {
|
||||
markReadButton()
|
||||
toggleFavoriteButton()
|
||||
toggleNtfsButton(chat: chat)
|
||||
}
|
||||
.swipeActions(edge: .trailing, allowsFullSwipe: true) {
|
||||
if !chat.chatItems.isEmpty {
|
||||
tagChatButton(chat)
|
||||
let showReportsButton = chat.chatStats.reportsCount > 0 && groupInfo.membership.memberRole >= .moderator
|
||||
let showClearButton = !chat.chatItems.isEmpty
|
||||
let showDeleteGroup = groupInfo.canDelete
|
||||
let showLeaveGroup = groupInfo.membership.memberCurrent
|
||||
let totalNumberOfButtons = 1 + (showReportsButton ? 1 : 0) + (showClearButton ? 1 : 0) + (showDeleteGroup ? 1 : 0) + (showLeaveGroup ? 1 : 0)
|
||||
|
||||
if showClearButton && totalNumberOfButtons <= 3 {
|
||||
clearChatButton()
|
||||
}
|
||||
if (groupInfo.membership.memberCurrent) {
|
||||
|
||||
if showReportsButton && totalNumberOfButtons <= 3 {
|
||||
archiveAllReportsButton()
|
||||
}
|
||||
|
||||
if showLeaveGroup {
|
||||
leaveGroupChatButton(groupInfo)
|
||||
}
|
||||
if groupInfo.canDelete {
|
||||
|
||||
if showDeleteGroup && totalNumberOfButtons <= 3 {
|
||||
deleteGroupChatButton(groupInfo)
|
||||
} else if totalNumberOfButtons > 3 {
|
||||
if showDeleteGroup && !groupInfo.membership.memberActive {
|
||||
deleteGroupChatButton(groupInfo)
|
||||
moreOptionsButton(false, chat, groupInfo)
|
||||
} else {
|
||||
moreOptionsButton(true, chat, groupInfo)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder private func noteFolderNavLink(_ noteFolder: NoteFolder) -> some View {
|
||||
private func noteFolderNavLink(_ noteFolder: NoteFolder) -> some View {
|
||||
NavLinkPlain(
|
||||
chatId: chat.chatInfo.id,
|
||||
selection: $chatModel.chatId,
|
||||
label: { ChatPreviewView(chat: chat, progressByTimeout: Binding.constant(false)) },
|
||||
disabled: !noteFolder.ready
|
||||
)
|
||||
.frame(height: dynamicRowHeight)
|
||||
.frameCompat(height: dynamicRowHeight)
|
||||
.swipeActions(edge: .leading, allowsFullSwipe: true) {
|
||||
markReadButton()
|
||||
}
|
||||
|
@ -287,14 +311,22 @@ struct ChatListNavLink: View {
|
|||
}
|
||||
|
||||
@ViewBuilder private func toggleNtfsButton(chat: Chat) -> some View {
|
||||
Button {
|
||||
toggleNotifications(chat, enableNtfs: !chat.chatInfo.ntfsEnabled)
|
||||
} label: {
|
||||
if chat.chatInfo.ntfsEnabled {
|
||||
SwipeLabel(NSLocalizedString("Mute", comment: "swipe action"), systemImage: "speaker.slash.fill", inverted: oneHandUI)
|
||||
} else {
|
||||
SwipeLabel(NSLocalizedString("Unmute", comment: "swipe action"), systemImage: "speaker.wave.2.fill", inverted: oneHandUI)
|
||||
if let nextMode = chat.chatInfo.nextNtfMode {
|
||||
Button {
|
||||
toggleNotifications(chat, enableNtfs: nextMode)
|
||||
} label: {
|
||||
SwipeLabel(nextMode.text(mentions: chat.chatInfo.hasMentions), systemImage: nextMode.iconFilled, inverted: oneHandUI)
|
||||
}
|
||||
} else {
|
||||
EmptyView()
|
||||
}
|
||||
}
|
||||
|
||||
private func archiveAllReportsButton() -> some View {
|
||||
Button {
|
||||
AlertManager.shared.showAlert(archiveAllReportsAlert())
|
||||
} label: {
|
||||
SwipeLabel(NSLocalizedString("Archive reports", comment: "swipe action"), systemImage: "archivebox", inverted: oneHandUI)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -306,7 +338,72 @@ struct ChatListNavLink: View {
|
|||
}
|
||||
.tint(Color.orange)
|
||||
}
|
||||
|
||||
private func tagChatButton(_ chat: Chat) -> some View {
|
||||
Button {
|
||||
setTagChatSheet(chat)
|
||||
} label: {
|
||||
SwipeLabel(NSLocalizedString("List", comment: "swipe action"), systemImage: "tag.fill", inverted: oneHandUI)
|
||||
}
|
||||
.tint(.mint)
|
||||
}
|
||||
|
||||
private func setTagChatSheet(_ chat: Chat) {
|
||||
let screenHeight = UIScreen.main.bounds.height
|
||||
let reservedSpace: Double = 4 * 44 // 2 for padding, 1 for "Create list" and another for extra tag
|
||||
let tagsSpace = Double(max(chatTagsModel.userTags.count, 3)) * 44
|
||||
let fraction = min((reservedSpace + tagsSpace) / screenHeight, 0.62)
|
||||
|
||||
parentSheet = SomeSheet(
|
||||
content: {
|
||||
AnyView(
|
||||
NavigationView {
|
||||
if chatTagsModel.userTags.isEmpty {
|
||||
TagListEditor(chat: chat)
|
||||
} else {
|
||||
TagListView(chat: chat)
|
||||
}
|
||||
}
|
||||
)
|
||||
},
|
||||
id: "lists sheet",
|
||||
fraction: fraction
|
||||
)
|
||||
}
|
||||
|
||||
private func moreOptionsButton(_ canShowGroupDelete: Bool, _ chat: Chat, _ groupInfo: GroupInfo?) -> some View {
|
||||
Button {
|
||||
var buttons: [Alert.Button] = []
|
||||
buttons.append(.default(Text("Clear")) {
|
||||
AlertManager.shared.showAlert(clearChatAlert())
|
||||
})
|
||||
|
||||
if let groupInfo, chat.chatStats.reportsCount > 0 && groupInfo.membership.memberRole >= .moderator && groupInfo.ready {
|
||||
buttons.append(.default(Text("Archive reports")) {
|
||||
AlertManager.shared.showAlert(archiveAllReportsAlert())
|
||||
})
|
||||
}
|
||||
|
||||
if canShowGroupDelete, let gi = groupInfo, gi.canDelete {
|
||||
buttons.append(.destructive(Text("Delete")) {
|
||||
AlertManager.shared.showAlert(deleteGroupAlert(gi))
|
||||
})
|
||||
}
|
||||
|
||||
buttons.append(.cancel())
|
||||
|
||||
actionSheet = SomeActionSheet(
|
||||
actionSheet: ActionSheet(
|
||||
title: canShowGroupDelete ? Text("Clear or delete group?") : Text("Clear group?"),
|
||||
buttons: buttons
|
||||
),
|
||||
id: "other options"
|
||||
)
|
||||
} label: {
|
||||
SwipeLabel(NSLocalizedString("More", comment: "swipe action"), systemImage: "ellipsis", inverted: oneHandUI)
|
||||
}
|
||||
}
|
||||
|
||||
private func clearNoteFolderButton() -> some View {
|
||||
Button {
|
||||
AlertManager.shared.showAlert(clearNoteFolderAlert())
|
||||
|
@ -336,6 +433,7 @@ struct ChatListNavLink: View {
|
|||
|
||||
private func contactRequestNavLink(_ contactRequest: UserContactRequest) -> some View {
|
||||
ContactRequestView(contactRequest: contactRequest, chat: chat)
|
||||
.frameCompat(height: dynamicRowHeight)
|
||||
.swipeActions(edge: .trailing, allowsFullSwipe: true) {
|
||||
Button {
|
||||
Task { await acceptContactRequest(incognito: false, contactRequest: contactRequest) }
|
||||
|
@ -354,7 +452,6 @@ struct ChatListNavLink: View {
|
|||
}
|
||||
.tint(.red)
|
||||
}
|
||||
.frame(height: dynamicRowHeight)
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture { showContactRequestDialog = true }
|
||||
.confirmationDialog("Accept connection request?", isPresented: $showContactRequestDialog, titleVisibility: .visible) {
|
||||
|
@ -366,6 +463,7 @@ struct ChatListNavLink: View {
|
|||
|
||||
private func contactConnectionNavLink(_ contactConnection: PendingContactConnection) -> some View {
|
||||
ContactConnectionView(chat: chat)
|
||||
.frameCompat(height: dynamicRowHeight)
|
||||
.swipeActions(edge: .trailing, allowsFullSwipe: true) {
|
||||
Button {
|
||||
AlertManager.shared.showAlert(deleteContactConnectionAlert(contactConnection) { a in
|
||||
|
@ -383,7 +481,6 @@ struct ChatListNavLink: View {
|
|||
}
|
||||
.tint(theme.colors.primary)
|
||||
}
|
||||
.frame(height: dynamicRowHeight)
|
||||
.appSheet(isPresented: $showContactConnectionInfo) {
|
||||
Group {
|
||||
if case let .contactConnection(contactConnection) = chat.chatInfo {
|
||||
|
@ -415,6 +512,27 @@ struct ChatListNavLink: View {
|
|||
)
|
||||
}
|
||||
|
||||
private func archiveAllReportsAlert() -> Alert {
|
||||
Alert(
|
||||
title: Text("Archive all reports?"),
|
||||
message: Text("All reports will be archived for you."),
|
||||
primaryButton: .destructive(Text("Archive")) {
|
||||
Task { await archiveAllReportsForMe(chat.chatInfo.apiId) }
|
||||
},
|
||||
secondaryButton: .cancel()
|
||||
)
|
||||
}
|
||||
|
||||
private func archiveAllReportsForMe(_ apiId: Int64) async {
|
||||
do {
|
||||
if case let .groupChatItemsDeleted(user, groupInfo, chatItemIDs, _, member) = try await apiArchiveReceivedReports(groupId: apiId) {
|
||||
await groupChatItemsDeleted(user, groupInfo, chatItemIDs, member)
|
||||
}
|
||||
} catch {
|
||||
logger.error("archiveAllReportsForMe error: \(responseError(error))")
|
||||
}
|
||||
}
|
||||
|
||||
private func clearChatAlert() -> Alert {
|
||||
Alert(
|
||||
title: Text("Clear conversation?"),
|
||||
|
@ -461,14 +579,14 @@ struct ChatListNavLink: View {
|
|||
)
|
||||
}
|
||||
|
||||
private func invalidJSONPreview(_ json: String) -> some View {
|
||||
private func invalidJSONPreview(_ json: Data?) -> some View {
|
||||
Text("invalid chat data")
|
||||
.foregroundColor(.red)
|
||||
.padding(4)
|
||||
.frame(height: dynamicRowHeight)
|
||||
.frameCompat(height: dynamicRowHeight)
|
||||
.onTapGesture { showInvalidJSON = true }
|
||||
.appSheet(isPresented: $showInvalidJSON) {
|
||||
invalidJSONView(json)
|
||||
invalidJSONView(dataToString(json))
|
||||
.environment(\EnvironmentValues.refresh as! WritableKeyPath<EnvironmentValues, RefreshAction?>, nil)
|
||||
}
|
||||
}
|
||||
|
@ -477,8 +595,27 @@ struct ChatListNavLink: View {
|
|||
Task {
|
||||
let ok = await connectContactViaAddress(contact.contactId, incognito, showAlert: { AlertManager.shared.showAlert($0) })
|
||||
if ok {
|
||||
ItemsModel.shared.loadOpenChat(contact.id)
|
||||
AlertManager.shared.showAlert(connReqSentAlert(.contact))
|
||||
ItemsModel.shared.loadOpenChat(contact.id) {
|
||||
AlertManager.shared.showAlert(connReqSentAlert(.contact))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension View {
|
||||
@inline(__always)
|
||||
@ViewBuilder fileprivate func frameCompat(height: CGFloat) -> some View {
|
||||
if #available(iOS 16, *) {
|
||||
self.frame(height: height)
|
||||
} else {
|
||||
VStack(spacing: 0) {
|
||||
Divider()
|
||||
.padding(.leading, 16)
|
||||
self
|
||||
.frame(height: height)
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, 8)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -570,7 +707,7 @@ func joinGroup(_ groupId: Int64, _ onComplete: @escaping () async -> Void) {
|
|||
}
|
||||
|
||||
func getErrorAlert(_ error: Error, _ title: LocalizedStringKey) -> ErrorAlert {
|
||||
if let r = error as? ChatResponse,
|
||||
if let r = error as? ChatError,
|
||||
let alert = getNetworkErrorAlert(r) {
|
||||
return alert
|
||||
} else {
|
||||
|
@ -585,15 +722,15 @@ struct ChatListNavLink_Previews: PreviewProvider {
|
|||
ChatListNavLink(chat: Chat(
|
||||
chatInfo: ChatInfo.sampleData.direct,
|
||||
chatItems: [ChatItem.getSample(1, .directSnd, .now, "hello")]
|
||||
))
|
||||
), parentSheet: .constant(nil))
|
||||
ChatListNavLink(chat: Chat(
|
||||
chatInfo: ChatInfo.sampleData.direct,
|
||||
chatItems: [ChatItem.getSample(1, .directSnd, .now, "hello")]
|
||||
))
|
||||
), parentSheet: .constant(nil))
|
||||
ChatListNavLink(chat: Chat(
|
||||
chatInfo: ChatInfo.sampleData.contactRequest,
|
||||
chatItems: []
|
||||
))
|
||||
), parentSheet: .constant(nil))
|
||||
}
|
||||
.previewLayout(.fixed(width: 360, height: 82))
|
||||
}
|
||||
|
|
|
@ -31,6 +31,35 @@ enum UserPickerSheet: Identifiable {
|
|||
}
|
||||
}
|
||||
|
||||
enum PresetTag: Int, Identifiable, CaseIterable, Equatable {
|
||||
case groupReports = 0
|
||||
case favorites = 1
|
||||
case contacts = 2
|
||||
case groups = 3
|
||||
case business = 4
|
||||
case notes = 5
|
||||
|
||||
var id: Int { rawValue }
|
||||
|
||||
var сollapse: Bool {
|
||||
self != .groupReports
|
||||
}
|
||||
}
|
||||
|
||||
enum ActiveFilter: Identifiable, Equatable {
|
||||
case presetTag(PresetTag)
|
||||
case userTag(ChatTag)
|
||||
case unread
|
||||
|
||||
var id: String {
|
||||
switch self {
|
||||
case let .presetTag(tag): "preset \(tag.id)"
|
||||
case let .userTag(tag): "user \(tag.chatTagId)"
|
||||
case .unread: "unread"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class SaveableSettings: ObservableObject {
|
||||
@Published var servers: ServerSettings = ServerSettings(currUserServers: [], userServers: [], serverErrors: [])
|
||||
}
|
||||
|
@ -117,13 +146,17 @@ struct ChatListView: View {
|
|||
@State private var searchChatFilteredBySimplexLink: String? = nil
|
||||
@State private var scrollToSearchBar = false
|
||||
@State private var userPickerShown: Bool = false
|
||||
@State private var sheet: SomeSheet<AnyView>? = nil
|
||||
@StateObject private var chatTagsModel = ChatTagsModel.shared
|
||||
|
||||
// iOS 15 is required it to show/hide toolbar while chat is hidden/visible
|
||||
@State private var viewOnScreen = true
|
||||
|
||||
@AppStorage(DEFAULT_SHOW_UNREAD_AND_FAVORITES) private var showUnreadAndFavorites = false
|
||||
@AppStorage(GROUP_DEFAULT_ONE_HAND_UI, store: groupDefaults) private var oneHandUI = true
|
||||
@AppStorage(DEFAULT_ONE_HAND_UI_CARD_SHOWN) private var oneHandUICardShown = false
|
||||
@AppStorage(DEFAULT_ADDRESS_CREATION_CARD_SHOWN) private var addressCreationCardShown = false
|
||||
@AppStorage(DEFAULT_TOOLBAR_MATERIAL) private var toolbarMaterial = ToolbarMaterial.defaultMaterial
|
||||
|
||||
|
||||
var body: some View {
|
||||
if #available(iOS 16.0, *) {
|
||||
viewBody.scrollDismissesKeyboard(.immediately)
|
||||
|
@ -131,7 +164,7 @@ struct ChatListView: View {
|
|||
viewBody
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private var viewBody: some View {
|
||||
ZStack(alignment: oneHandUI ? .bottomLeading : .topLeading) {
|
||||
NavStackCompat(
|
||||
|
@ -161,8 +194,9 @@ struct ChatListView: View {
|
|||
}
|
||||
}
|
||||
}
|
||||
.environmentObject(chatTagsModel)
|
||||
}
|
||||
|
||||
|
||||
private var chatListView: some View {
|
||||
let tm = ToolbarMaterial.material(toolbarMaterial)
|
||||
return withToolbar(tm) {
|
||||
|
@ -172,7 +206,17 @@ struct ChatListView: View {
|
|||
.navigationBarHidden(searchMode || oneHandUI)
|
||||
}
|
||||
.scaleEffect(x: 1, y: oneHandUI ? -1 : 1, anchor: .center)
|
||||
.onDisappear() { activeUserPickerSheet = nil }
|
||||
.onAppear {
|
||||
if #unavailable(iOS 16.0), !viewOnScreen {
|
||||
viewOnScreen = true
|
||||
}
|
||||
}
|
||||
.onDisappear {
|
||||
activeUserPickerSheet = nil
|
||||
if #unavailable(iOS 16.0) {
|
||||
viewOnScreen = false
|
||||
}
|
||||
}
|
||||
.refreshable {
|
||||
AlertManager.shared.showAlert(Alert(
|
||||
title: Text("Reconnect servers?"),
|
||||
|
@ -197,15 +241,22 @@ struct ChatListView: View {
|
|||
Divider().padding(.bottom, Self.hasHomeIndicator ? 0 : 8).background(tm)
|
||||
}
|
||||
}
|
||||
.sheet(item: $sheet) { sheet in
|
||||
if #available(iOS 16.0, *) {
|
||||
sheet.content.presentationDetents([.fraction(sheet.fraction)])
|
||||
} else {
|
||||
sheet.content
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
static var hasHomeIndicator: Bool = {
|
||||
if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
|
||||
let window = windowScene.windows.first {
|
||||
window.safeAreaInsets.bottom > 0
|
||||
} else { false }
|
||||
}()
|
||||
|
||||
|
||||
@ViewBuilder func withToolbar(_ material: Material, content: () -> some View) -> some View {
|
||||
if #available(iOS 16.0, *) {
|
||||
if oneHandUI {
|
||||
|
@ -220,19 +271,19 @@ struct ChatListView: View {
|
|||
}
|
||||
} else {
|
||||
if oneHandUI {
|
||||
content().toolbar { bottomToolbarGroup }
|
||||
content().toolbar { bottomToolbarGroup() }
|
||||
} else {
|
||||
content().toolbar { topToolbar }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ToolbarContentBuilder var topToolbar: some ToolbarContent {
|
||||
ToolbarItem(placement: .topBarLeading) { leadingToolbarItem }
|
||||
ToolbarItem(placement: .principal) { SubsStatusIndicator() }
|
||||
ToolbarItem(placement: .topBarTrailing) { trailingToolbarItem }
|
||||
}
|
||||
|
||||
|
||||
@ToolbarContentBuilder var bottomToolbar: some ToolbarContent {
|
||||
let padding: Double = Self.hasHomeIndicator ? 0 : 14
|
||||
ToolbarItem(placement: .bottomBar) {
|
||||
|
@ -247,10 +298,10 @@ struct ChatListView: View {
|
|||
.onTapGesture { scrollToSearchBar = true }
|
||||
}
|
||||
}
|
||||
|
||||
@ToolbarContentBuilder var bottomToolbarGroup: some ToolbarContent {
|
||||
|
||||
@ToolbarContentBuilder func bottomToolbarGroup() -> some ToolbarContent {
|
||||
let padding: Double = Self.hasHomeIndicator ? 0 : 14
|
||||
ToolbarItemGroup(placement: .bottomBar) {
|
||||
ToolbarItemGroup(placement: viewOnScreen ? .bottomBar : .principal) {
|
||||
leadingToolbarItem.padding(.bottom, padding)
|
||||
Spacer()
|
||||
SubsStatusIndicator().padding(.bottom, padding)
|
||||
|
@ -258,7 +309,7 @@ struct ChatListView: View {
|
|||
trailingToolbarItem.padding(.bottom, padding)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ViewBuilder var leadingToolbarItem: some View {
|
||||
let user = chatModel.currentUser ?? User.sampleData
|
||||
ZStack(alignment: .topTrailing) {
|
||||
|
@ -275,7 +326,7 @@ struct ChatListView: View {
|
|||
userPickerShown = true
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ViewBuilder var trailingToolbarItem: some View {
|
||||
switch chatModel.chatRunning {
|
||||
case .some(true): NewChatMenuButton()
|
||||
|
@ -283,10 +334,10 @@ struct ChatListView: View {
|
|||
case .none: EmptyView()
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder private var chatList: some View {
|
||||
|
||||
private var chatList: some View {
|
||||
let cs = filteredChats()
|
||||
ZStack {
|
||||
return ZStack {
|
||||
ScrollViewReader { scrollProxy in
|
||||
List {
|
||||
if !chatModel.chats.isEmpty {
|
||||
|
@ -295,7 +346,8 @@ struct ChatListView: View {
|
|||
searchFocussed: $searchFocussed,
|
||||
searchText: $searchText,
|
||||
searchShowingSimplexLink: $searchShowingSimplexLink,
|
||||
searchChatFilteredBySimplexLink: $searchChatFilteredBySimplexLink
|
||||
searchChatFilteredBySimplexLink: $searchChatFilteredBySimplexLink,
|
||||
parentSheet: $sheet
|
||||
)
|
||||
.scaleEffect(x: 1, y: oneHandUI ? -1 : 1, anchor: .center)
|
||||
.listRowSeparator(.hidden)
|
||||
|
@ -306,7 +358,7 @@ struct ChatListView: View {
|
|||
}
|
||||
if #available(iOS 16.0, *) {
|
||||
ForEach(cs, id: \.viewId) { chat in
|
||||
ChatListNavLink(chat: chat)
|
||||
ChatListNavLink(chat: chat, parentSheet: $sheet)
|
||||
.scaleEffect(x: 1, y: oneHandUI ? -1 : 1, anchor: .center)
|
||||
.padding(.trailing, -16)
|
||||
.disabled(chatModel.chatRunning != true || chatModel.deletedChats.contains(chat.chatInfo.id))
|
||||
|
@ -315,13 +367,7 @@ struct ChatListView: View {
|
|||
.offset(x: -8)
|
||||
} else {
|
||||
ForEach(cs, id: \.viewId) { chat in
|
||||
VStack(spacing: .zero) {
|
||||
Divider()
|
||||
.padding(.leading, 16)
|
||||
ChatListNavLink(chat: chat)
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, 6)
|
||||
}
|
||||
ChatListNavLink(chat: chat, parentSheet: $sheet)
|
||||
.scaleEffect(x: 1, y: oneHandUI ? -1 : 1, anchor: .center)
|
||||
.listRowSeparator(.hidden)
|
||||
.listRowInsets(EdgeInsets())
|
||||
|
@ -363,80 +409,97 @@ struct ChatListView: View {
|
|||
}
|
||||
}
|
||||
if cs.isEmpty && !chatModel.chats.isEmpty {
|
||||
Text("No filtered chats")
|
||||
noChatsView()
|
||||
.scaleEffect(x: 1, y: oneHandUI ? -1 : 1, anchor: .center)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder private func noChatsView() -> some View {
|
||||
if searchString().isEmpty {
|
||||
switch chatTagsModel.activeFilter {
|
||||
case .presetTag: Text("No filtered chats") // this should not happen
|
||||
case let .userTag(tag): Text("No chats in list \(tag.chatTagText)")
|
||||
case .unread:
|
||||
Button {
|
||||
chatTagsModel.activeFilter = nil
|
||||
} label: {
|
||||
HStack {
|
||||
Image(systemName: "line.3.horizontal.decrease")
|
||||
Text("No unread chats")
|
||||
}
|
||||
}
|
||||
case .none: Text("No chats")
|
||||
}
|
||||
} else {
|
||||
Text("No chats found")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private func unreadBadge(size: CGFloat = 18) -> some View {
|
||||
Circle()
|
||||
.frame(width: size, height: size)
|
||||
.foregroundColor(theme.colors.primary)
|
||||
}
|
||||
|
||||
|
||||
@ViewBuilder private func chatView() -> some View {
|
||||
if let chatId = chatModel.chatId, let chat = chatModel.getChat(chatId) {
|
||||
ChatView(chat: chat)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
func stopAudioPlayer() {
|
||||
VoiceItemState.smallView.values.forEach { $0.audioPlayer?.stop() }
|
||||
VoiceItemState.smallView = [:]
|
||||
}
|
||||
|
||||
|
||||
private func filteredChats() -> [Chat] {
|
||||
if let linkChatId = searchChatFilteredBySimplexLink {
|
||||
return chatModel.chats.filter { $0.id == linkChatId }
|
||||
} else {
|
||||
let s = searchString()
|
||||
return s == "" && !showUnreadAndFavorites
|
||||
return s == ""
|
||||
? chatModel.chats.filter { chat in
|
||||
!chat.chatInfo.chatDeleted && chatContactType(chat: chat) != ContactType.card
|
||||
!chat.chatInfo.chatDeleted && !chat.chatInfo.contactCard && filtered(chat)
|
||||
}
|
||||
: chatModel.chats.filter { chat in
|
||||
let cInfo = chat.chatInfo
|
||||
switch cInfo {
|
||||
return switch cInfo {
|
||||
case let .direct(contact):
|
||||
return !contact.chatDeleted && chatContactType(chat: chat) != ContactType.card && (
|
||||
s == ""
|
||||
? filtered(chat)
|
||||
: (viewNameContains(cInfo, s) ||
|
||||
contact.profile.displayName.localizedLowercase.contains(s) ||
|
||||
contact.fullName.localizedLowercase.contains(s))
|
||||
!contact.chatDeleted && !chat.chatInfo.contactCard && (
|
||||
( viewNameContains(cInfo, s) ||
|
||||
contact.profile.displayName.localizedLowercase.contains(s) ||
|
||||
contact.fullName.localizedLowercase.contains(s)
|
||||
)
|
||||
)
|
||||
case let .group(gInfo):
|
||||
return s == ""
|
||||
? (filtered(chat) || gInfo.membership.memberStatus == .memInvited)
|
||||
: viewNameContains(cInfo, s)
|
||||
case .local:
|
||||
return s == "" || viewNameContains(cInfo, s)
|
||||
case .contactRequest:
|
||||
return s == "" || viewNameContains(cInfo, s)
|
||||
case let .contactConnection(conn):
|
||||
return s != "" && conn.localAlias.localizedLowercase.contains(s)
|
||||
case .invalidJSON:
|
||||
return false
|
||||
case .group: viewNameContains(cInfo, s)
|
||||
case .local: viewNameContains(cInfo, s)
|
||||
case .contactRequest: viewNameContains(cInfo, s)
|
||||
case let .contactConnection(conn): conn.localAlias.localizedLowercase.contains(s)
|
||||
case .invalidJSON: false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func searchString() -> String {
|
||||
searchShowingSimplexLink ? "" : searchText.trimmingCharacters(in: .whitespaces).localizedLowercase
|
||||
}
|
||||
|
||||
|
||||
func filtered(_ chat: Chat) -> Bool {
|
||||
(chat.chatInfo.chatSettings?.favorite ?? false) ||
|
||||
chat.chatStats.unreadChat ||
|
||||
(chat.chatInfo.ntfsEnabled && chat.chatStats.unreadCount > 0)
|
||||
switch chatTagsModel.activeFilter {
|
||||
case let .presetTag(tag): presetTagMatchesChat(tag, chat.chatInfo, chat.chatStats)
|
||||
case let .userTag(tag): chat.chatInfo.chatTags?.contains(tag.chatTagId) == true
|
||||
case .unread: chat.unreadTag
|
||||
case .none: true
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
func viewNameContains(_ cInfo: ChatInfo, _ s: String) -> Bool {
|
||||
cInfo.chatViewName.localizedLowercase.contains(s)
|
||||
}
|
||||
}
|
||||
|
||||
func searchString() -> String {
|
||||
searchShowingSimplexLink ? "" : searchText.trimmingCharacters(in: .whitespaces).localizedLowercase
|
||||
}
|
||||
}
|
||||
|
||||
struct SubsStatusIndicator: View {
|
||||
|
@ -500,18 +563,20 @@ struct SubsStatusIndicator: View {
|
|||
struct ChatListSearchBar: View {
|
||||
@EnvironmentObject var m: ChatModel
|
||||
@EnvironmentObject var theme: AppTheme
|
||||
@EnvironmentObject var chatTagsModel: ChatTagsModel
|
||||
@Binding var searchMode: Bool
|
||||
@FocusState.Binding var searchFocussed: Bool
|
||||
@Binding var searchText: String
|
||||
@Binding var searchShowingSimplexLink: Bool
|
||||
@Binding var searchChatFilteredBySimplexLink: String?
|
||||
@Binding var parentSheet: SomeSheet<AnyView>?
|
||||
@State private var ignoreSearchTextChange = false
|
||||
@State private var alert: PlanAndConnectAlert?
|
||||
@State private var sheet: PlanAndConnectActionSheet?
|
||||
@AppStorage(DEFAULT_SHOW_UNREAD_AND_FAVORITES) private var showUnreadAndFavorites = false
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 12) {
|
||||
ScrollView([.horizontal], showsIndicators: false) { TagsView(parentSheet: $parentSheet, searchText: $searchText) }
|
||||
HStack(spacing: 12) {
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: "magnifyingglass")
|
||||
|
@ -569,6 +634,9 @@ struct ChatListSearchBar: View {
|
|||
}
|
||||
}
|
||||
}
|
||||
.onChange(of: chatTagsModel.activeFilter) { _ in
|
||||
searchText = ""
|
||||
}
|
||||
.alert(item: $alert) { a in
|
||||
planAndConnectAlert(a, dismiss: true, cleanup: { searchText = "" })
|
||||
}
|
||||
|
@ -578,16 +646,21 @@ struct ChatListSearchBar: View {
|
|||
}
|
||||
|
||||
private func toggleFilterButton() -> some View {
|
||||
ZStack {
|
||||
let showUnread = chatTagsModel.activeFilter == .unread
|
||||
return ZStack {
|
||||
Color.clear
|
||||
.frame(width: 22, height: 22)
|
||||
Image(systemName: showUnreadAndFavorites ? "line.3.horizontal.decrease.circle.fill" : "line.3.horizontal.decrease")
|
||||
Image(systemName: showUnread ? "line.3.horizontal.decrease.circle.fill" : "line.3.horizontal.decrease")
|
||||
.resizable()
|
||||
.scaledToFit()
|
||||
.foregroundColor(showUnreadAndFavorites ? theme.colors.primary : theme.colors.secondary)
|
||||
.frame(width: showUnreadAndFavorites ? 22 : 16, height: showUnreadAndFavorites ? 22 : 16)
|
||||
.foregroundColor(showUnread ? theme.colors.primary : theme.colors.secondary)
|
||||
.frame(width: showUnread ? 22 : 16, height: showUnread ? 22 : 16)
|
||||
.onTapGesture {
|
||||
showUnreadAndFavorites = !showUnreadAndFavorites
|
||||
if chatTagsModel.activeFilter == .unread {
|
||||
chatTagsModel.activeFilter = nil
|
||||
} else {
|
||||
chatTagsModel.activeFilter = .unread
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -605,6 +678,198 @@ struct ChatListSearchBar: View {
|
|||
}
|
||||
}
|
||||
|
||||
struct TagsView: View {
|
||||
@EnvironmentObject var chatTagsModel: ChatTagsModel
|
||||
@EnvironmentObject var chatModel: ChatModel
|
||||
@EnvironmentObject var theme: AppTheme
|
||||
@Binding var parentSheet: SomeSheet<AnyView>?
|
||||
@Binding var searchText: String
|
||||
|
||||
var body: some View {
|
||||
HStack {
|
||||
tagsView()
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder private func tagsView() -> some View {
|
||||
if chatTagsModel.presetTags.count > 1 {
|
||||
if chatTagsModel.presetTags.count + chatTagsModel.userTags.count <= 3 {
|
||||
expandedPresetTagsFiltersView()
|
||||
} else {
|
||||
collapsedTagsFilterView()
|
||||
ForEach(PresetTag.allCases, id: \.id) { (tag: PresetTag) in
|
||||
if !tag.сollapse && (chatTagsModel.presetTags[tag] ?? 0) > 0 {
|
||||
expandedTagFilterView(tag)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let selectedTag: ChatTag? = if case let .userTag(tag) = chatTagsModel.activeFilter {
|
||||
tag
|
||||
} else {
|
||||
nil
|
||||
}
|
||||
ForEach(chatTagsModel.userTags, id: \.id) { tag in
|
||||
let current = tag == selectedTag
|
||||
let color: Color = current ? .accentColor : .secondary
|
||||
ZStack {
|
||||
HStack(spacing: 4) {
|
||||
if let emoji = tag.chatTagEmoji {
|
||||
Text(emoji)
|
||||
} else {
|
||||
Image(systemName: current ? "tag.fill" : "tag")
|
||||
.foregroundColor(color)
|
||||
}
|
||||
ZStack {
|
||||
let badge = Text(verbatim: (chatTagsModel.unreadTags[tag.chatTagId] ?? 0) > 0 ? " ●" : "").font(.footnote)
|
||||
(Text(tag.chatTagText).fontWeight(.semibold) + badge).foregroundColor(.clear)
|
||||
Text(tag.chatTagText).fontWeight(current ? .semibold : .regular).foregroundColor(color) + badge.foregroundColor(theme.colors.primary)
|
||||
}
|
||||
}
|
||||
.onTapGesture {
|
||||
setActiveFilter(filter: .userTag(tag))
|
||||
}
|
||||
.onLongPressGesture {
|
||||
let screenHeight = UIScreen.main.bounds.height
|
||||
let reservedSpace: Double = 4 * 44 // 2 for padding, 1 for "Create list" and another for extra tag
|
||||
let tagsSpace = Double(max(chatTagsModel.userTags.count, 3)) * 44
|
||||
let fraction = min((reservedSpace + tagsSpace) / screenHeight, 0.62)
|
||||
|
||||
parentSheet = SomeSheet(
|
||||
content: {
|
||||
AnyView(
|
||||
NavigationView {
|
||||
TagListView(chat: nil)
|
||||
.modifier(ThemedBackground(grouped: true))
|
||||
}
|
||||
)
|
||||
},
|
||||
id: "tag list",
|
||||
fraction: fraction
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Button {
|
||||
parentSheet = SomeSheet(
|
||||
content: {
|
||||
AnyView(
|
||||
NavigationView {
|
||||
TagListEditor()
|
||||
}
|
||||
)
|
||||
},
|
||||
id: "tag create"
|
||||
)
|
||||
} label: {
|
||||
if chatTagsModel.userTags.isEmpty {
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: "plus")
|
||||
Text("Add list")
|
||||
}
|
||||
} else {
|
||||
Image(systemName: "plus")
|
||||
}
|
||||
}
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
|
||||
@ViewBuilder private func expandedTagFilterView(_ tag: PresetTag) -> some View {
|
||||
let selectedPresetTag: PresetTag? = if case let .presetTag(tag) = chatTagsModel.activeFilter {
|
||||
tag
|
||||
} else {
|
||||
nil
|
||||
}
|
||||
let active = tag == selectedPresetTag
|
||||
let (icon, text) = presetTagLabel(tag: tag, active: active)
|
||||
let color: Color = active ? .accentColor : .secondary
|
||||
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: icon)
|
||||
.foregroundColor(color)
|
||||
ZStack {
|
||||
Text(text).fontWeight(.semibold).foregroundColor(.clear)
|
||||
Text(text).fontWeight(active ? .semibold : .regular).foregroundColor(color)
|
||||
}
|
||||
}
|
||||
.onTapGesture {
|
||||
setActiveFilter(filter: .presetTag(tag))
|
||||
}
|
||||
}
|
||||
|
||||
private func expandedPresetTagsFiltersView() -> some View {
|
||||
ForEach(PresetTag.allCases, id: \.id) { tag in
|
||||
if (chatTagsModel.presetTags[tag] ?? 0) > 0 {
|
||||
expandedTagFilterView(tag)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder private func collapsedTagsFilterView() -> some View {
|
||||
let selectedPresetTag: PresetTag? = if case let .presetTag(tag) = chatTagsModel.activeFilter {
|
||||
tag
|
||||
} else {
|
||||
nil
|
||||
}
|
||||
Menu {
|
||||
if chatTagsModel.activeFilter != nil || !searchText.isEmpty {
|
||||
Button {
|
||||
chatTagsModel.activeFilter = nil
|
||||
searchText = ""
|
||||
} label: {
|
||||
HStack {
|
||||
Image(systemName: "list.bullet")
|
||||
Text("All")
|
||||
}
|
||||
}
|
||||
}
|
||||
ForEach(PresetTag.allCases, id: \.id) { tag in
|
||||
if (chatTagsModel.presetTags[tag] ?? 0) > 0 && tag.сollapse {
|
||||
Button {
|
||||
setActiveFilter(filter: .presetTag(tag))
|
||||
} label: {
|
||||
let (systemName, text) = presetTagLabel(tag: tag, active: tag == selectedPresetTag)
|
||||
HStack {
|
||||
Image(systemName: systemName)
|
||||
Text(text)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} label: {
|
||||
if let tag = selectedPresetTag, tag.сollapse {
|
||||
let (systemName, _) = presetTagLabel(tag: tag, active: true)
|
||||
Image(systemName: systemName)
|
||||
.foregroundColor(.accentColor)
|
||||
} else {
|
||||
Image(systemName: "list.bullet")
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
.frame(minWidth: 28)
|
||||
}
|
||||
|
||||
private func presetTagLabel(tag: PresetTag, active: Bool) -> (String, LocalizedStringKey) {
|
||||
switch tag {
|
||||
case .groupReports: (active ? "flag.fill" : "flag", "Reports")
|
||||
case .favorites: (active ? "star.fill" : "star", "Favorites")
|
||||
case .contacts: (active ? "person.fill" : "person", "Contacts")
|
||||
case .groups: (active ? "person.2.fill" : "person.2", "Groups")
|
||||
case .business: (active ? "briefcase.fill" : "briefcase", "Businesses")
|
||||
case .notes: (active ? "folder.fill" : "folder", "Notes")
|
||||
}
|
||||
}
|
||||
|
||||
private func setActiveFilter(filter: ActiveFilter) {
|
||||
if filter != chatTagsModel.activeFilter {
|
||||
chatTagsModel.activeFilter = filter
|
||||
} else {
|
||||
chatTagsModel.activeFilter = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func chatStoppedIcon() -> some View {
|
||||
Button {
|
||||
AlertManager.shared.showAlertMsg(
|
||||
|
@ -616,6 +881,35 @@ func chatStoppedIcon() -> some View {
|
|||
}
|
||||
}
|
||||
|
||||
func presetTagMatchesChat(_ tag: PresetTag, _ chatInfo: ChatInfo, _ chatStats: ChatStats) -> Bool {
|
||||
switch tag {
|
||||
case .groupReports:
|
||||
chatStats.reportsCount > 0
|
||||
case .favorites:
|
||||
chatInfo.chatSettings?.favorite == true
|
||||
case .contacts:
|
||||
switch chatInfo {
|
||||
case let .direct(contact): !(contact.activeConn == nil && contact.profile.contactLink != nil && contact.active) && !contact.chatDeleted
|
||||
case .contactRequest: true
|
||||
case .contactConnection: true
|
||||
case let .group(groupInfo): groupInfo.businessChat?.chatType == .customer
|
||||
default: false
|
||||
}
|
||||
case .groups:
|
||||
switch chatInfo {
|
||||
case let .group(groupInfo): groupInfo.businessChat == nil
|
||||
default: false
|
||||
}
|
||||
case .business:
|
||||
chatInfo.groupInfo?.businessChat?.chatType == .business
|
||||
case .notes:
|
||||
switch chatInfo {
|
||||
case .local: true
|
||||
default: false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct ChatListView_Previews: PreviewProvider {
|
||||
@State static var userPickerSheet: UserPickerSheet? = .none
|
||||
|
||||
|
|
|
@ -27,72 +27,80 @@ struct ChatPreviewView: View {
|
|||
|
||||
var body: some View {
|
||||
let cItem = chat.chatItems.last
|
||||
return HStack(spacing: 8) {
|
||||
ZStack(alignment: .bottomTrailing) {
|
||||
ChatInfoImage(chat: chat, size: dynamicSize(userFont).profileImageSize)
|
||||
chatPreviewImageOverlayIcon()
|
||||
.padding([.bottom, .trailing], 1)
|
||||
}
|
||||
.padding(.leading, 4)
|
||||
|
||||
let chatTs = if let cItem {
|
||||
cItem.meta.itemTs
|
||||
} else {
|
||||
chat.chatInfo.chatTs
|
||||
}
|
||||
VStack(spacing: 0) {
|
||||
HStack(alignment: .top) {
|
||||
chatPreviewTitle()
|
||||
Spacer()
|
||||
(formatTimestampText(chatTs))
|
||||
.font(.subheadline)
|
||||
.frame(minWidth: 60, alignment: .trailing)
|
||||
.foregroundColor(theme.colors.secondary)
|
||||
.padding(.top, 4)
|
||||
return ZStack {
|
||||
HStack(spacing: 8) {
|
||||
ZStack(alignment: .bottomTrailing) {
|
||||
ChatInfoImage(chat: chat, size: dynamicSize(userFont).profileImageSize)
|
||||
chatPreviewImageOverlayIcon()
|
||||
.padding([.bottom, .trailing], 1)
|
||||
}
|
||||
.padding(.bottom, 4)
|
||||
.padding(.horizontal, 8)
|
||||
|
||||
ZStack(alignment: .topTrailing) {
|
||||
let chat = activeContentPreview?.chat ?? chat
|
||||
let ci = activeContentPreview?.ci ?? chat.chatItems.last
|
||||
let mc = ci?.content.msgContent
|
||||
HStack(alignment: .top) {
|
||||
let deleted = ci?.isDeletedContent == true || ci?.meta.itemDeleted != nil
|
||||
let showContentPreview = (showChatPreviews && chatModel.draftChatId != chat.id && !deleted) || activeContentPreview != nil
|
||||
if let ci, showContentPreview {
|
||||
chatItemContentPreview(chat, ci)
|
||||
}
|
||||
let mcIsVoice = switch mc { case .voice: true; default: false }
|
||||
if !mcIsVoice || !showContentPreview || mc?.text != "" || chatModel.draftChatId == chat.id {
|
||||
let hasFilePreview = if case .file = mc { true } else { false }
|
||||
chatMessagePreview(cItem, hasFilePreview)
|
||||
} else {
|
||||
Spacer()
|
||||
chatInfoIcon(chat).frame(minWidth: 37, alignment: .trailing)
|
||||
}
|
||||
}
|
||||
.onChange(of: chatModel.stopPreviousRecPlay?.path) { _ in
|
||||
checkActiveContentPreview(chat, ci, mc)
|
||||
}
|
||||
.onChange(of: activeContentPreview) { _ in
|
||||
checkActiveContentPreview(chat, ci, mc)
|
||||
}
|
||||
.onChange(of: showFullscreenGallery) { _ in
|
||||
checkActiveContentPreview(chat, ci, mc)
|
||||
}
|
||||
chatStatusImage()
|
||||
.padding(.top, dynamicChatInfoSize * 1.44)
|
||||
.frame(maxWidth: .infinity, alignment: .trailing)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(.trailing, 8)
|
||||
.padding(.leading, 4)
|
||||
|
||||
Spacer()
|
||||
let chatTs = if let cItem {
|
||||
cItem.meta.itemTs
|
||||
} else {
|
||||
chat.chatInfo.chatTs
|
||||
}
|
||||
VStack(spacing: 0) {
|
||||
HStack(alignment: .top) {
|
||||
chatPreviewTitle()
|
||||
Spacer()
|
||||
(formatTimestampText(chatTs))
|
||||
.font(.subheadline)
|
||||
.frame(minWidth: 60, alignment: .trailing)
|
||||
.foregroundColor(theme.colors.secondary)
|
||||
.padding(.top, 4)
|
||||
}
|
||||
.padding(.bottom, 4)
|
||||
.padding(.horizontal, 8)
|
||||
|
||||
ZStack(alignment: .topTrailing) {
|
||||
let chat = activeContentPreview?.chat ?? chat
|
||||
let ci = activeContentPreview?.ci ?? chat.chatItems.last
|
||||
let mc = ci?.content.msgContent
|
||||
HStack(alignment: .top) {
|
||||
let deleted = ci?.isDeletedContent == true || ci?.meta.itemDeleted != nil
|
||||
let showContentPreview = (showChatPreviews && chatModel.draftChatId != chat.id && !deleted) || activeContentPreview != nil
|
||||
if let ci, showContentPreview {
|
||||
chatItemContentPreview(chat, ci)
|
||||
}
|
||||
let mcIsVoice = switch mc { case .voice: true; default: false }
|
||||
if !mcIsVoice || !showContentPreview || mc?.text != "" || chatModel.draftChatId == chat.id {
|
||||
let hasFilePreview = if case .file = mc { true } else { false }
|
||||
chatMessagePreview(cItem, hasFilePreview)
|
||||
} else {
|
||||
Spacer()
|
||||
chatInfoIcon(chat).frame(minWidth: 37, alignment: .trailing)
|
||||
}
|
||||
}
|
||||
.onChange(of: chatModel.stopPreviousRecPlay?.path) { _ in
|
||||
checkActiveContentPreview(chat, ci, mc)
|
||||
}
|
||||
.onChange(of: activeContentPreview) { _ in
|
||||
checkActiveContentPreview(chat, ci, mc)
|
||||
}
|
||||
.onChange(of: showFullscreenGallery) { _ in
|
||||
checkActiveContentPreview(chat, ci, mc)
|
||||
}
|
||||
chatStatusImage()
|
||||
.padding(.top, dynamicChatInfoSize * 1.44)
|
||||
.frame(maxWidth: .infinity, alignment: .trailing)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(.trailing, 8)
|
||||
|
||||
Spacer()
|
||||
}
|
||||
.frame(maxHeight: .infinity)
|
||||
}
|
||||
.opacity(deleting ? 0.4 : 1)
|
||||
.padding(.bottom, -8)
|
||||
|
||||
if deleting {
|
||||
ProgressView()
|
||||
.scaleEffect(2)
|
||||
}
|
||||
.frame(maxHeight: .infinity)
|
||||
}
|
||||
.padding(.bottom, -8)
|
||||
.onChange(of: chatModel.deletedChats.contains(chat.chatInfo.id)) { contains in
|
||||
deleting = contains
|
||||
// Stop voice when deleting the chat
|
||||
|
@ -135,6 +143,7 @@ struct ChatPreviewView: View {
|
|||
}
|
||||
case let .group(groupInfo):
|
||||
switch (groupInfo.membership.memberStatus) {
|
||||
case .memRejected: inactiveIcon()
|
||||
case .memLeft: inactiveIcon()
|
||||
case .memRemoved: inactiveIcon()
|
||||
case .memGroupDeleted: inactiveIcon()
|
||||
|
@ -145,7 +154,7 @@ struct ChatPreviewView: View {
|
|||
}
|
||||
}
|
||||
|
||||
@ViewBuilder private func inactiveIcon() -> some View {
|
||||
private func inactiveIcon() -> some View {
|
||||
Image(systemName: "multiply.circle.fill")
|
||||
.foregroundColor(.secondary.opacity(0.65))
|
||||
.background(Circle().foregroundColor(Color(uiColor: .systemBackground)))
|
||||
|
@ -160,7 +169,7 @@ struct ChatPreviewView: View {
|
|||
let v = previewTitle(t)
|
||||
switch (groupInfo.membership.memberStatus) {
|
||||
case .memInvited: v.foregroundColor(deleting ? theme.colors.secondary : chat.chatInfo.incognito ? .indigo : theme.colors.primary)
|
||||
case .memAccepted: v.foregroundColor(theme.colors.secondary)
|
||||
case .memAccepted, .memRejected: v.foregroundColor(theme.colors.secondary)
|
||||
default: if deleting { v.foregroundColor(theme.colors.secondary) } else { v }
|
||||
}
|
||||
default: previewTitle(t)
|
||||
|
@ -178,14 +187,17 @@ struct ChatPreviewView: View {
|
|||
.kerning(-2)
|
||||
}
|
||||
|
||||
private func chatPreviewLayout(_ text: Text?, draft: Bool = false, _ hasFilePreview: Bool = false) -> some View {
|
||||
private func chatPreviewLayout(_ text: Text?, draft: Bool = false, hasFilePreview: Bool = false, hasSecrets: Bool) -> some View {
|
||||
ZStack(alignment: .topTrailing) {
|
||||
let s = chat.chatStats
|
||||
let mentionWidth: CGFloat = if s.unreadMentions > 0 && s.unreadCount > 1 { dynamicSize(userFont).unreadCorner } else { 0 }
|
||||
let t = text
|
||||
.lineLimit(userFont <= .xxxLarge ? 2 : 1)
|
||||
.multilineTextAlignment(.leading)
|
||||
.if(hasSecrets, transform: hiddenSecretsView)
|
||||
.frame(maxWidth: .infinity, alignment: .topLeading)
|
||||
.padding(.leading, hasFilePreview ? 0 : 8)
|
||||
.padding(.trailing, hasFilePreview ? 38 : 36)
|
||||
.padding(.trailing, mentionWidth + (hasFilePreview ? 38 : 36))
|
||||
.offset(x: hasFilePreview ? -2 : 0)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
if !showChatPreviews && !draft {
|
||||
|
@ -200,19 +212,34 @@ struct ChatPreviewView: View {
|
|||
@ViewBuilder private func chatInfoIcon(_ chat: Chat) -> some View {
|
||||
let s = chat.chatStats
|
||||
if s.unreadCount > 0 || s.unreadChat {
|
||||
unreadCountText(s.unreadCount)
|
||||
.font(userFont <= .xxxLarge ? .caption : .caption2)
|
||||
.foregroundColor(.white)
|
||||
.padding(.horizontal, dynamicSize(userFont).unreadPadding)
|
||||
.frame(minWidth: dynamicChatInfoSize, minHeight: dynamicChatInfoSize)
|
||||
.background(chat.chatInfo.ntfsEnabled || chat.chatInfo.chatType == .local ? theme.colors.primary : theme.colors.secondary)
|
||||
.cornerRadius(dynamicSize(userFont).unreadCorner)
|
||||
} else if !chat.chatInfo.ntfsEnabled && chat.chatInfo.chatType != .local {
|
||||
Image(systemName: "speaker.slash.fill")
|
||||
let mentionColor = mentionColor(chat)
|
||||
HStack(alignment: .center, spacing: 2) {
|
||||
if s.unreadMentions > 0 && s.unreadCount > 1 {
|
||||
Text("\(MENTION_START)")
|
||||
.font(userFont <= .xxxLarge ? .body : .callout)
|
||||
.foregroundColor(mentionColor)
|
||||
.frame(minWidth: dynamicChatInfoSize, minHeight: dynamicChatInfoSize)
|
||||
.cornerRadius(dynamicSize(userFont).unreadCorner)
|
||||
.padding(.bottom, 1)
|
||||
}
|
||||
let singleUnreadIsMention = s.unreadMentions > 0 && s.unreadCount == 1
|
||||
(singleUnreadIsMention ? Text("\(MENTION_START)") : unreadCountText(s.unreadCount))
|
||||
.font(userFont <= .xxxLarge ? .caption : .caption2)
|
||||
.foregroundColor(.white)
|
||||
.padding(.horizontal, dynamicSize(userFont).unreadPadding)
|
||||
.frame(minWidth: dynamicChatInfoSize, minHeight: dynamicChatInfoSize)
|
||||
.background(singleUnreadIsMention ? mentionColor : chat.chatInfo.ntfsEnabled(false) || chat.chatInfo.chatType == .local ? theme.colors.primary : theme.colors.secondary)
|
||||
.cornerRadius(dynamicSize(userFont).unreadCorner)
|
||||
}
|
||||
.frame(height: dynamicChatInfoSize)
|
||||
} else if let ntfMode = chat.chatInfo.chatSettings?.enableNtfs, ntfMode != .all {
|
||||
let iconSize = ntfMode == .mentions ? dynamicChatInfoSize * 0.8 : dynamicChatInfoSize
|
||||
let iconColor = ntfMode == .mentions ? theme.colors.secondary.opacity(0.7) : theme.colors.secondary
|
||||
Image(systemName: ntfMode.iconFilled)
|
||||
.resizable()
|
||||
.scaledToFill()
|
||||
.frame(width: dynamicChatInfoSize, height: dynamicChatInfoSize)
|
||||
.foregroundColor(theme.colors.secondary)
|
||||
.frame(width: iconSize, height: iconSize)
|
||||
.foregroundColor(iconColor)
|
||||
} else if chat.chatInfo.chatSettings?.favorite ?? false {
|
||||
Image(systemName: "star.fill")
|
||||
.resizable()
|
||||
|
@ -224,12 +251,22 @@ struct ChatPreviewView: View {
|
|||
Color.clear.frame(width: 0)
|
||||
}
|
||||
}
|
||||
|
||||
private func mentionColor(_ chat: Chat) -> Color {
|
||||
switch chat.chatInfo.chatSettings?.enableNtfs {
|
||||
case .all: theme.colors.primary
|
||||
case .mentions: theme.colors.primary
|
||||
default: theme.colors.secondary
|
||||
}
|
||||
}
|
||||
|
||||
private func messageDraft(_ draft: ComposeState) -> Text {
|
||||
private func messageDraft(_ draft: ComposeState) -> (Text, Bool) {
|
||||
let msg = draft.message
|
||||
return image("rectangle.and.pencil.and.ellipsis", color: theme.colors.primary)
|
||||
+ attachment()
|
||||
+ messageText(msg, parseSimpleXMarkdown(msg), nil, preview: true, showSecrets: false, secondaryColor: theme.colors.secondary)
|
||||
let r = messageText(msg, parseSimpleXMarkdown(msg), sender: nil, preview: true, mentions: draft.mentions, userMemberId: nil, showSecrets: nil, backgroundColor: UIColor(theme.colors.background))
|
||||
return (image("rectangle.and.pencil.and.ellipsis", color: theme.colors.primary)
|
||||
+ attachment()
|
||||
+ Text(AttributedString(r.string)),
|
||||
r.hasSecrets)
|
||||
|
||||
func image(_ s: String, color: Color = Color(uiColor: .tertiaryLabel)) -> Text {
|
||||
Text(Image(systemName: s)).foregroundColor(color) + textSpace
|
||||
|
@ -245,19 +282,24 @@ struct ChatPreviewView: View {
|
|||
}
|
||||
}
|
||||
|
||||
func chatItemPreview(_ cItem: ChatItem) -> Text {
|
||||
func chatItemPreview(_ cItem: ChatItem) -> (Text, Bool) {
|
||||
let itemText = cItem.meta.itemDeleted == nil ? cItem.text : markedDeletedText()
|
||||
let itemFormattedText = cItem.meta.itemDeleted == nil ? cItem.formattedText : nil
|
||||
return messageText(itemText, itemFormattedText, cItem.memberDisplayName, icon: nil, preview: true, showSecrets: false, secondaryColor: theme.colors.secondary)
|
||||
let r = messageText(itemText, itemFormattedText, sender: cItem.memberDisplayName, preview: true, mentions: cItem.mentions, userMemberId: chat.chatInfo.groupInfo?.membership.memberId, showSecrets: nil, backgroundColor: UIColor(theme.colors.background), prefix: prefix())
|
||||
return (Text(AttributedString(r.string)), r.hasSecrets)
|
||||
|
||||
// same texts are in markedDeletedText in MarkedDeletedItemView, but it returns LocalizedStringKey;
|
||||
// can be refactored into a single function if functions calling these are changed to return same type
|
||||
func markedDeletedText() -> String {
|
||||
switch cItem.meta.itemDeleted {
|
||||
case let .moderated(_, byGroupMember): String.localizedStringWithFormat(NSLocalizedString("moderated by %@", comment: "marked deleted chat item preview text"), byGroupMember.displayName)
|
||||
case .blocked: NSLocalizedString("blocked", comment: "marked deleted chat item preview text")
|
||||
case .blockedByAdmin: NSLocalizedString("blocked by admin", comment: "marked deleted chat item preview text")
|
||||
case .deleted, nil: NSLocalizedString("marked deleted", comment: "marked deleted chat item preview text")
|
||||
if cItem.meta.itemDeleted != nil, cItem.isReport {
|
||||
"archived report"
|
||||
} else {
|
||||
switch cItem.meta.itemDeleted {
|
||||
case let .moderated(_, byGroupMember): String.localizedStringWithFormat(NSLocalizedString("moderated by %@", comment: "marked deleted chat item preview text"), byGroupMember.displayName)
|
||||
case .blocked: NSLocalizedString("blocked", comment: "marked deleted chat item preview text")
|
||||
case .blockedByAdmin: NSLocalizedString("blocked by admin", comment: "marked deleted chat item preview text")
|
||||
case .deleted, nil: NSLocalizedString("marked deleted", comment: "marked deleted chat item preview text")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -270,13 +312,22 @@ struct ChatPreviewView: View {
|
|||
default: return nil
|
||||
}
|
||||
}
|
||||
|
||||
func prefix() -> NSAttributedString? {
|
||||
switch cItem.content.msgContent {
|
||||
case let .report(_, reason): reason.attrString
|
||||
default: nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder private func chatMessagePreview(_ cItem: ChatItem?, _ hasFilePreview: Bool = false) -> some View {
|
||||
if chatModel.draftChatId == chat.id, let draft = chatModel.draft {
|
||||
chatPreviewLayout(messageDraft(draft), draft: true, hasFilePreview)
|
||||
let (t, hasSecrets) = messageDraft(draft)
|
||||
chatPreviewLayout(t, draft: true, hasFilePreview: hasFilePreview, hasSecrets: hasSecrets)
|
||||
} else if let cItem = cItem {
|
||||
chatPreviewLayout(itemStatusMark(cItem) + chatItemPreview(cItem), hasFilePreview)
|
||||
let (t, hasSecrets) = chatItemPreview(cItem)
|
||||
chatPreviewLayout(itemStatusMark(cItem) + t, hasFilePreview: hasFilePreview, hasSecrets: hasSecrets)
|
||||
} else {
|
||||
switch (chat.chatInfo) {
|
||||
case let .direct(contact):
|
||||
|
@ -292,6 +343,7 @@ struct ChatPreviewView: View {
|
|||
}
|
||||
case let .group(groupInfo):
|
||||
switch (groupInfo.membership.memberStatus) {
|
||||
case .memRejected: chatPreviewInfoText("rejected")
|
||||
case .memInvited: groupInvitationPreviewText(groupInfo)
|
||||
case .memAccepted: chatPreviewInfoText("connecting…")
|
||||
default: EmptyView()
|
||||
|
@ -323,18 +375,16 @@ struct ChatPreviewView: View {
|
|||
.cornerRadius(8)
|
||||
}
|
||||
.onTapGesture {
|
||||
UIApplication.shared.open(preview.uri)
|
||||
openBrowserAlert(uri: preview.uri)
|
||||
}
|
||||
}
|
||||
case let .image(_, image):
|
||||
smallContentPreview(size: dynamicMediaSize) {
|
||||
CIImageView(chatItem: ci, preview: imageFromBase64(image), maxWidth: dynamicMediaSize, smallView: true, showFullScreenImage: $showFullscreenGallery)
|
||||
.environmentObject(ReverseListScrollModel())
|
||||
}
|
||||
case let .video(_,image, duration):
|
||||
smallContentPreview(size: dynamicMediaSize) {
|
||||
CIVideoView(chatItem: ci, preview: imageFromBase64(image), duration: duration, maxWidth: dynamicMediaSize, videoWidth: nil, smallView: true, showFullscreenPlayer: $showFullscreenGallery)
|
||||
.environmentObject(ReverseListScrollModel())
|
||||
}
|
||||
case let .voice(_, duration):
|
||||
smallContentPreviewVoice(size: dynamicMediaSize) {
|
||||
|
@ -355,7 +405,7 @@ struct ChatPreviewView: View {
|
|||
: chatPreviewInfoText("you are invited to group")
|
||||
}
|
||||
|
||||
@ViewBuilder private func chatPreviewInfoText(_ text: LocalizedStringKey) -> some View {
|
||||
private func chatPreviewInfoText(_ text: LocalizedStringKey) -> some View {
|
||||
Text(text)
|
||||
.frame(maxWidth: .infinity, minHeight: 44, maxHeight: 44, alignment: .topLeading)
|
||||
.padding([.leading, .trailing], 8)
|
||||
|
@ -388,6 +438,8 @@ struct ChatPreviewView: View {
|
|||
case .group:
|
||||
if progressByTimeout {
|
||||
ProgressView()
|
||||
} else if chat.chatStats.reportsCount > 0 {
|
||||
groupReportsIcon(size: size * 0.8)
|
||||
} else {
|
||||
incognitoIcon(chat.chatInfo.incognito, theme.colors.secondary, size: size)
|
||||
}
|
||||
|
@ -433,6 +485,14 @@ struct ChatPreviewView: View {
|
|||
}
|
||||
}
|
||||
|
||||
func groupReportsIcon(size: CGFloat) -> some View {
|
||||
Image(systemName: "flag")
|
||||
.resizable()
|
||||
.scaledToFit()
|
||||
.frame(width: size, height: size)
|
||||
.foregroundColor(.red)
|
||||
}
|
||||
|
||||
func smallContentPreview(size: CGFloat, _ view: @escaping () -> some View) -> some View {
|
||||
view()
|
||||
.frame(width: size, height: size)
|
||||
|
|
|
@ -14,6 +14,7 @@ struct ContactConnectionInfo: View {
|
|||
@EnvironmentObject var theme: AppTheme
|
||||
@Environment(\.dismiss) var dismiss: DismissAction
|
||||
@State var contactConnection: PendingContactConnection
|
||||
@State private var showShortLink: Bool = true
|
||||
@State private var alert: CCInfoAlert?
|
||||
@State private var localAlias = ""
|
||||
@State private var showIncognitoSheet = false
|
||||
|
@ -61,14 +62,19 @@ struct ContactConnectionInfo: View {
|
|||
}
|
||||
|
||||
if contactConnection.initiated,
|
||||
let connReqInv = contactConnection.connReqInv {
|
||||
SimpleXLinkQRCode(uri: simplexChatLink(connReqInv))
|
||||
let connLinkInv = contactConnection.connLinkInv {
|
||||
SimpleXCreatedLinkQRCode(link: connLinkInv, short: $showShortLink)
|
||||
.id("simplex-invitation-qrcode-\(connLinkInv.simplexChatUri(short: showShortLink))")
|
||||
incognitoEnabled()
|
||||
shareLinkButton(connReqInv, theme.colors.secondary)
|
||||
oneTimeLinkLearnMoreButton(theme.colors.secondary)
|
||||
shareLinkButton(connLinkInv, short: showShortLink)
|
||||
oneTimeLinkLearnMoreButton()
|
||||
} else {
|
||||
incognitoEnabled()
|
||||
oneTimeLinkLearnMoreButton(theme.colors.secondary)
|
||||
oneTimeLinkLearnMoreButton()
|
||||
}
|
||||
} header: {
|
||||
if let connLinkInv = contactConnection.connLinkInv, connLinkInv.connShortLink != nil {
|
||||
ToggleShortLinkHeader(text: Text(""), link: connLinkInv, short: $showShortLink)
|
||||
}
|
||||
} footer: {
|
||||
sharedProfileInfo(contactConnection.incognito)
|
||||
|
@ -167,26 +173,22 @@ struct ContactConnectionInfo: View {
|
|||
}
|
||||
}
|
||||
|
||||
private func shareLinkButton(_ connReqInvitation: String, _ secondaryColor: Color) -> some View {
|
||||
private func shareLinkButton(_ connLinkInvitation: CreatedConnLink, short: Bool) -> some View {
|
||||
Button {
|
||||
showShareSheet(items: [simplexChatLink(connReqInvitation)])
|
||||
showShareSheet(items: [connLinkInvitation.simplexChatUri(short: short)])
|
||||
} label: {
|
||||
settingsRow("square.and.arrow.up", color: secondaryColor) {
|
||||
Text("Share 1-time link")
|
||||
}
|
||||
Label("Share 1-time link", systemImage: "square.and.arrow.up")
|
||||
}
|
||||
}
|
||||
|
||||
private func oneTimeLinkLearnMoreButton(_ secondaryColor: Color) -> some View {
|
||||
private func oneTimeLinkLearnMoreButton() -> some View {
|
||||
NavigationLink {
|
||||
AddContactLearnMore(showTitle: false)
|
||||
.navigationTitle("One-time invitation link")
|
||||
.modifier(ThemedBackground())
|
||||
.navigationBarTitleDisplayMode(.large)
|
||||
} label: {
|
||||
settingsRow("info.circle", color: secondaryColor) {
|
||||
Text("Learn more")
|
||||
}
|
||||
Label("Learn more", systemImage: "info.circle")
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -245,7 +245,7 @@ struct ServersSummaryView: View {
|
|||
}
|
||||
}
|
||||
|
||||
@ViewBuilder private func smpServersListView(
|
||||
private func smpServersListView(
|
||||
_ servers: [SMPServerSummary],
|
||||
_ statsStartedAt: Date,
|
||||
_ header: LocalizedStringKey? = nil,
|
||||
|
@ -256,7 +256,7 @@ struct ServersSummaryView: View {
|
|||
? serverAddress($0.smpServer) < serverAddress($1.smpServer)
|
||||
: $0.hasSubs && !$1.hasSubs
|
||||
}
|
||||
Section {
|
||||
return Section {
|
||||
ForEach(sortedServers) { server in
|
||||
smpServerView(server, statsStartedAt)
|
||||
}
|
||||
|
@ -318,14 +318,14 @@ struct ServersSummaryView: View {
|
|||
return onionHosts == .require ? .indigo : .accentColor
|
||||
}
|
||||
|
||||
@ViewBuilder private func xftpServersListView(
|
||||
private func xftpServersListView(
|
||||
_ servers: [XFTPServerSummary],
|
||||
_ statsStartedAt: Date,
|
||||
_ header: LocalizedStringKey? = nil,
|
||||
_ footer: LocalizedStringKey? = nil
|
||||
) -> some View {
|
||||
let sortedServers = servers.sorted { serverAddress($0.xftpServer) < serverAddress($1.xftpServer) }
|
||||
Section {
|
||||
return Section {
|
||||
ForEach(sortedServers) { server in
|
||||
xftpServerView(server, statsStartedAt)
|
||||
}
|
||||
|
@ -587,7 +587,7 @@ struct SMPStatsView: View {
|
|||
} header: {
|
||||
Text("Statistics")
|
||||
} footer: {
|
||||
Text("Starting from \(localTimestamp(statsStartedAt)).") + Text("\n") + Text("All data is kept private on your device.")
|
||||
Text("Starting from \(localTimestamp(statsStartedAt)).") + textNewLine + Text("All data is kept private on your device.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -703,7 +703,7 @@ struct XFTPStatsView: View {
|
|||
} header: {
|
||||
Text("Statistics")
|
||||
} footer: {
|
||||
Text("Starting from \(localTimestamp(statsStartedAt)).") + Text("\n") + Text("All data is kept private on your device.")
|
||||
Text("Starting from \(localTimestamp(statsStartedAt)).") + textNewLine + Text("All data is kept private on your device.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
408
apps/ios/Shared/Views/ChatList/TagListView.swift
Normal file
408
apps/ios/Shared/Views/ChatList/TagListView.swift
Normal file
|
@ -0,0 +1,408 @@
|
|||
//
|
||||
// TagListView.swift
|
||||
// SimpleX (iOS)
|
||||
//
|
||||
// Created by Diogo Cunha on 31/12/2024.
|
||||
// Copyright © 2024 SimpleX Chat. All rights reserved.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import SimpleXChat
|
||||
import ElegantEmojiPicker
|
||||
|
||||
struct TagEditorNavParams {
|
||||
let chat: Chat?
|
||||
let chatListTag: ChatTagData?
|
||||
let tagId: Int64?
|
||||
}
|
||||
|
||||
struct TagListView: View {
|
||||
var chat: Chat? = nil
|
||||
@Environment(\.dismiss) var dismiss: DismissAction
|
||||
@EnvironmentObject var theme: AppTheme
|
||||
@EnvironmentObject var chatTagsModel: ChatTagsModel
|
||||
@EnvironmentObject var m: ChatModel
|
||||
@State private var editMode = EditMode.inactive
|
||||
@State private var tagEditorNavParams: TagEditorNavParams? = nil
|
||||
|
||||
var chatTagsIds: [Int64] { chat?.chatInfo.contact?.chatTags ?? chat?.chatInfo.groupInfo?.chatTags ?? [] }
|
||||
|
||||
var body: some View {
|
||||
List {
|
||||
Section {
|
||||
ForEach(chatTagsModel.userTags, id: \.id) { tag in
|
||||
let text = tag.chatTagText
|
||||
let emoji = tag.chatTagEmoji
|
||||
let tagId = tag.chatTagId
|
||||
let selected = chatTagsIds.contains(tagId)
|
||||
|
||||
HStack {
|
||||
if let emoji {
|
||||
Text(emoji)
|
||||
} else {
|
||||
Image(systemName: "tag")
|
||||
}
|
||||
Text(text)
|
||||
.padding(.leading, 12)
|
||||
Spacer()
|
||||
if chat != nil {
|
||||
radioButton(selected: selected)
|
||||
}
|
||||
}
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture {
|
||||
if let c = chat {
|
||||
setChatTag(tagId: selected ? nil : tagId, chat: c) { dismiss() }
|
||||
} else {
|
||||
tagEditorNavParams = TagEditorNavParams(chat: nil, chatListTag: ChatTagData(emoji: emoji, text: text), tagId: tagId)
|
||||
}
|
||||
}
|
||||
.swipeActions(edge: .trailing, allowsFullSwipe: true) {
|
||||
Button {
|
||||
showAlert(
|
||||
NSLocalizedString("Delete list?", comment: "alert title"),
|
||||
message: String.localizedStringWithFormat(NSLocalizedString("All chats will be removed from the list %@, and the list deleted.", comment: "alert message"), text),
|
||||
actions: {[
|
||||
UIAlertAction(
|
||||
title: NSLocalizedString("Cancel", comment: "alert action"),
|
||||
style: .default
|
||||
),
|
||||
UIAlertAction(
|
||||
title: NSLocalizedString("Delete", comment: "alert action"),
|
||||
style: .destructive,
|
||||
handler: { _ in
|
||||
deleteTag(tagId)
|
||||
}
|
||||
)
|
||||
]}
|
||||
)
|
||||
} label: {
|
||||
Label("Delete", systemImage: "trash.fill")
|
||||
}
|
||||
.tint(.red)
|
||||
}
|
||||
.swipeActions(edge: .leading, allowsFullSwipe: true) {
|
||||
Button {
|
||||
tagEditorNavParams = TagEditorNavParams(chat: nil, chatListTag: ChatTagData(emoji: emoji, text: text), tagId: tagId)
|
||||
} label: {
|
||||
Label("Edit", systemImage: "pencil")
|
||||
}
|
||||
.tint(theme.colors.primary)
|
||||
}
|
||||
.background(
|
||||
// isActive required to navigate to edit view from any possible tag edited in swipe action
|
||||
NavigationLink(isActive: Binding(get: { tagEditorNavParams != nil }, set: { _ in tagEditorNavParams = nil })) {
|
||||
if let params = tagEditorNavParams {
|
||||
TagListEditor(
|
||||
chat: params.chat,
|
||||
tagId: params.tagId,
|
||||
emoji: params.chatListTag?.emoji,
|
||||
name: params.chatListTag?.text ?? ""
|
||||
)
|
||||
}
|
||||
} label: {
|
||||
EmptyView()
|
||||
}
|
||||
.opacity(0)
|
||||
)
|
||||
}
|
||||
.onMove(perform: moveItem)
|
||||
|
||||
NavigationLink {
|
||||
TagListEditor(chat: chat)
|
||||
} label: {
|
||||
Label("Create list", systemImage: "plus")
|
||||
}
|
||||
} header: {
|
||||
if chat == nil {
|
||||
editTagsButton()
|
||||
.textCase(nil)
|
||||
.frame(maxWidth: .infinity, alignment: .trailing)
|
||||
}
|
||||
}
|
||||
}
|
||||
.modifier(ThemedBackground(grouped: true))
|
||||
.environment(\.editMode, $editMode)
|
||||
}
|
||||
|
||||
private func editTagsButton() -> some View {
|
||||
if editMode.isEditing {
|
||||
Button("Done") {
|
||||
editMode = .inactive
|
||||
dismiss()
|
||||
}
|
||||
} else {
|
||||
Button("Edit") {
|
||||
editMode = .active
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func radioButton(selected: Bool) -> some View {
|
||||
Image(systemName: selected ? "checkmark.circle.fill" : "circle")
|
||||
.imageScale(.large)
|
||||
.foregroundStyle(selected ? Color.accentColor : Color(.tertiaryLabel))
|
||||
}
|
||||
|
||||
private func moveItem(from source: IndexSet, to destination: Int) {
|
||||
Task {
|
||||
do {
|
||||
var tags = chatTagsModel.userTags
|
||||
tags.move(fromOffsets: source, toOffset: destination)
|
||||
try await apiReorderChatTags(tagIds: tags.map { $0.chatTagId })
|
||||
|
||||
await MainActor.run {
|
||||
chatTagsModel.userTags = tags
|
||||
}
|
||||
} catch let error {
|
||||
showAlert(
|
||||
NSLocalizedString("Error reordering lists", comment: "alert title"),
|
||||
message: responseError(error)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func deleteTag(_ tagId: Int64) {
|
||||
Task {
|
||||
try await apiDeleteChatTag(tagId: tagId)
|
||||
|
||||
await MainActor.run {
|
||||
chatTagsModel.userTags = chatTagsModel.userTags.filter { $0.chatTagId != tagId }
|
||||
if case let .userTag(tag) = chatTagsModel.activeFilter, tagId == tag.chatTagId {
|
||||
chatTagsModel.activeFilter = nil
|
||||
}
|
||||
m.chats.forEach { c in
|
||||
if var contact = c.chatInfo.contact, contact.chatTags.contains(tagId) {
|
||||
contact.chatTags = contact.chatTags.filter({ $0 != tagId })
|
||||
m.updateContact(contact)
|
||||
} else if var group = c.chatInfo.groupInfo, group.chatTags.contains(tagId) {
|
||||
group.chatTags = group.chatTags.filter({ $0 != tagId })
|
||||
m.updateGroup(group)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func setChatTag(tagId: Int64?, chat: Chat, closeSheet: @escaping () -> Void) {
|
||||
Task {
|
||||
do {
|
||||
let tagIds: [Int64] = if let t = tagId { [t] } else {[]}
|
||||
let (userTags, chatTags) = try await apiSetChatTags(
|
||||
type: chat.chatInfo.chatType,
|
||||
id: chat.chatInfo.apiId,
|
||||
tagIds: tagIds
|
||||
)
|
||||
|
||||
await MainActor.run {
|
||||
let m = ChatModel.shared
|
||||
let tm = ChatTagsModel.shared
|
||||
tm.userTags = userTags
|
||||
if chat.unreadTag, let tags = chat.chatInfo.chatTags {
|
||||
tm.decTagsReadCount(tags)
|
||||
}
|
||||
if var contact = chat.chatInfo.contact {
|
||||
contact.chatTags = chatTags
|
||||
m.updateContact(contact)
|
||||
} else if var group = chat.chatInfo.groupInfo {
|
||||
group.chatTags = chatTags
|
||||
m.updateGroup(group)
|
||||
}
|
||||
ChatTagsModel.shared.updateChatTagRead(chat, wasUnread: false)
|
||||
closeSheet()
|
||||
}
|
||||
} catch let error {
|
||||
showAlert(
|
||||
NSLocalizedString("Error saving chat list", comment: "alert title"),
|
||||
message: responseError(error)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct EmojiPickerView: UIViewControllerRepresentable {
|
||||
@Binding var selectedEmoji: String?
|
||||
@Binding var showingPicker: Bool
|
||||
@Environment(\.presentationMode) var presentationMode
|
||||
|
||||
class Coordinator: NSObject, ElegantEmojiPickerDelegate, UIAdaptivePresentationControllerDelegate {
|
||||
var parent: EmojiPickerView
|
||||
|
||||
init(parent: EmojiPickerView) {
|
||||
self.parent = parent
|
||||
}
|
||||
|
||||
func emojiPicker(_ picker: ElegantEmojiPicker, didSelectEmoji emoji: Emoji?) {
|
||||
parent.selectedEmoji = emoji?.emoji
|
||||
parent.showingPicker = false
|
||||
picker.dismiss(animated: true)
|
||||
}
|
||||
|
||||
// Called when the picker is dismissed manually (without selection)
|
||||
func presentationControllerWillDismiss(_ presentationController: UIPresentationController) {
|
||||
parent.showingPicker = false
|
||||
}
|
||||
}
|
||||
|
||||
func makeCoordinator() -> Coordinator {
|
||||
return Coordinator(parent: self)
|
||||
}
|
||||
|
||||
func makeUIViewController(context: Context) -> UIViewController {
|
||||
let config = ElegantConfiguration(showRandom: false, showReset: true, showClose: false)
|
||||
let picker = ElegantEmojiPicker(delegate: context.coordinator, configuration: config)
|
||||
|
||||
picker.presentationController?.delegate = context.coordinator
|
||||
|
||||
let viewController = UIViewController()
|
||||
DispatchQueue.main.async {
|
||||
if let topVC = getTopViewController() {
|
||||
topVC.present(picker, animated: true)
|
||||
}
|
||||
}
|
||||
|
||||
return viewController
|
||||
}
|
||||
|
||||
func updateUIViewController(_ uiViewController: UIViewController, context: Context) {
|
||||
// No need to update the controller after creation
|
||||
}
|
||||
}
|
||||
|
||||
struct TagListEditor: View {
|
||||
@Environment(\.dismiss) var dismiss: DismissAction
|
||||
@EnvironmentObject var chatTagsModel: ChatTagsModel
|
||||
@EnvironmentObject var theme: AppTheme
|
||||
var chat: Chat? = nil
|
||||
var tagId: Int64? = nil
|
||||
var emoji: String?
|
||||
var name: String = ""
|
||||
@State private var newEmoji: String?
|
||||
@State private var newName: String = ""
|
||||
@State private var isPickerPresented = false
|
||||
@State private var saving: Bool?
|
||||
|
||||
var body: some View {
|
||||
VStack {
|
||||
List {
|
||||
let isDuplicateEmojiOrName = chatTagsModel.userTags.contains { tag in
|
||||
tag.chatTagId != tagId &&
|
||||
((newEmoji != nil && tag.chatTagEmoji == newEmoji) || tag.chatTagText == trimmedName)
|
||||
}
|
||||
|
||||
Section {
|
||||
HStack {
|
||||
Button {
|
||||
isPickerPresented = true
|
||||
} label: {
|
||||
if let newEmoji {
|
||||
Text(newEmoji)
|
||||
} else {
|
||||
Image(systemName: "face.smiling")
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
TextField("List name...", text: $newName)
|
||||
}
|
||||
|
||||
Button {
|
||||
saving = true
|
||||
if let tId = tagId {
|
||||
updateChatTag(tagId: tId, chatTagData: ChatTagData(emoji: newEmoji, text: trimmedName))
|
||||
} else {
|
||||
createChatTag()
|
||||
}
|
||||
} label: {
|
||||
Text(
|
||||
chat != nil
|
||||
? "Add to list"
|
||||
: "Save list"
|
||||
)
|
||||
}
|
||||
.disabled(saving != nil || (trimmedName == name && newEmoji == emoji) || trimmedName.isEmpty || isDuplicateEmojiOrName)
|
||||
} footer: {
|
||||
if isDuplicateEmojiOrName && saving != false { // if not saved already, to prevent flickering
|
||||
HStack {
|
||||
Image(systemName: "exclamationmark.circle")
|
||||
.foregroundColor(.red)
|
||||
Text("List name and emoji should be different for all lists.")
|
||||
.foregroundColor(theme.colors.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if isPickerPresented {
|
||||
EmojiPickerView(selectedEmoji: $newEmoji, showingPicker: $isPickerPresented)
|
||||
}
|
||||
}
|
||||
.modifier(ThemedBackground(grouped: true))
|
||||
.onAppear {
|
||||
newEmoji = emoji
|
||||
newName = name
|
||||
}
|
||||
}
|
||||
|
||||
var trimmedName: String {
|
||||
newName.trimmingCharacters(in: .whitespaces)
|
||||
}
|
||||
|
||||
private func createChatTag() {
|
||||
Task {
|
||||
do {
|
||||
let text = trimmedName
|
||||
let userTags = try await apiCreateChatTag(
|
||||
tag: ChatTagData(emoji: newEmoji , text: text)
|
||||
)
|
||||
await MainActor.run {
|
||||
saving = false
|
||||
chatTagsModel.userTags = userTags
|
||||
}
|
||||
if let chat, let tag = userTags.first(where: { $0.chatTagText == text && $0.chatTagEmoji == newEmoji}) {
|
||||
setChatTag(tagId: tag.chatTagId, chat: chat) { dismiss() }
|
||||
} else {
|
||||
await MainActor.run { dismiss() }
|
||||
}
|
||||
} catch let error {
|
||||
await MainActor.run {
|
||||
saving = nil
|
||||
showAlert(
|
||||
NSLocalizedString("Error creating list", comment: "alert title"),
|
||||
message: responseError(error)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func updateChatTag(tagId: Int64, chatTagData: ChatTagData) {
|
||||
Task {
|
||||
do {
|
||||
try await apiUpdateChatTag(tagId: tagId, tag: chatTagData)
|
||||
await MainActor.run {
|
||||
saving = false
|
||||
for i in 0..<chatTagsModel.userTags.count {
|
||||
if chatTagsModel.userTags[i].chatTagId == tagId {
|
||||
chatTagsModel.userTags[i] = ChatTag(
|
||||
chatTagId: tagId,
|
||||
chatTagText: chatTagData.text,
|
||||
chatTagEmoji: chatTagData.emoji
|
||||
)
|
||||
}
|
||||
}
|
||||
dismiss()
|
||||
}
|
||||
} catch let error {
|
||||
await MainActor.run {
|
||||
saving = nil
|
||||
showAlert(
|
||||
NSLocalizedString("Error creating list", comment: "alert title"),
|
||||
message: responseError(error)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -20,7 +20,7 @@ struct ContactListNavLink: View {
|
|||
@State private var showContactRequestDialog = false
|
||||
|
||||
var body: some View {
|
||||
let contactType = chatContactType(chat: chat)
|
||||
let contactType = chatContactType(chat)
|
||||
|
||||
Group {
|
||||
switch (chat.chatInfo) {
|
||||
|
@ -140,9 +140,9 @@ struct ContactListNavLink: View {
|
|||
}
|
||||
}
|
||||
|
||||
@ViewBuilder private func previewTitle(_ contact: Contact, titleColor: Color) -> some View {
|
||||
private func previewTitle(_ contact: Contact, titleColor: Color) -> some View {
|
||||
let t = Text(chat.chatInfo.chatViewName).foregroundColor(titleColor)
|
||||
(
|
||||
return (
|
||||
contact.verified == true
|
||||
? verifiedIcon + t
|
||||
: t
|
||||
|
@ -188,8 +188,7 @@ struct ContactListNavLink: View {
|
|||
Task {
|
||||
let ok = await connectContactViaAddress(contact.contactId, incognito, showAlert: { alert = SomeAlert(alert: $0, id: "ContactListNavLink connectContactViaAddress") })
|
||||
if ok {
|
||||
ItemsModel.shared.loadOpenChat(contact.id)
|
||||
DispatchQueue.main.async {
|
||||
ItemsModel.shared.loadOpenChat(contact.id) {
|
||||
dismissAllSheets(animated: true) {
|
||||
AlertManager.shared.showAlert(connReqSentAlert(.contact))
|
||||
}
|
||||
|
|
|
@ -173,7 +173,7 @@ struct DatabaseEncryptionView: View {
|
|||
}
|
||||
return true
|
||||
} catch let error {
|
||||
if case .chatCmdError(_, .errorDatabase(.errorExport(.errorNotADatabase))) = error as? ChatResponse {
|
||||
if case .errorDatabase(.errorExport(.errorNotADatabase)) = error as? ChatError {
|
||||
await operationEnded(.currentPassphraseError)
|
||||
} else {
|
||||
await operationEnded(.error(title: "Error encrypting database", error: "\(responseError(error))"))
|
||||
|
|
|
@ -28,7 +28,7 @@ struct DatabaseErrorView: View {
|
|||
}
|
||||
}
|
||||
|
||||
@ViewBuilder private func databaseErrorView() -> some View {
|
||||
private func databaseErrorView() -> some View {
|
||||
VStack(alignment: .center, spacing: 20) {
|
||||
switch status {
|
||||
case let .errorNotADatabase(dbFile):
|
||||
|
@ -141,7 +141,7 @@ struct DatabaseErrorView: View {
|
|||
}
|
||||
|
||||
private func migrationsText(_ ms: [String]) -> some View {
|
||||
(Text("Migrations:").font(.subheadline) + Text(verbatim: "\n") + Text(ms.joined(separator: "\n")).font(.caption))
|
||||
(Text("Migrations:").font(.subheadline) + textNewLine + Text(ms.joined(separator: "\n")).font(.caption))
|
||||
.multilineTextAlignment(.center)
|
||||
.padding(.horizontal, 25)
|
||||
}
|
||||
|
|
|
@ -279,7 +279,7 @@ struct DatabaseView: View {
|
|||
case let .archiveExportedWithErrors(archivePath, errs):
|
||||
return Alert(
|
||||
title: Text("Chat database exported"),
|
||||
message: Text("You may save the exported archive.") + Text(verbatim: "\n") + Text("Some file(s) were not exported:") + Text(archiveErrorsText(errs)),
|
||||
message: Text("You may save the exported archive.") + textNewLine + Text("Some file(s) were not exported:") + Text(archiveErrorsText(errs)),
|
||||
dismissButton: .default(Text("Continue")) {
|
||||
showShareSheet(items: [archivePath])
|
||||
}
|
||||
|
|
|
@ -9,6 +9,7 @@
|
|||
import SwiftUI
|
||||
|
||||
extension View {
|
||||
@inline(__always)
|
||||
@ViewBuilder func `if`<Content: View>(_ condition: Bool, transform: (Self) -> Content) -> some View {
|
||||
if condition {
|
||||
transform(self)
|
||||
|
@ -36,9 +37,9 @@ struct PrivacyBlur: ViewModifier {
|
|||
.overlay {
|
||||
if (blurred && enabled) {
|
||||
Color.clear.contentShape(Rectangle())
|
||||
.onTapGesture {
|
||||
.simultaneousGesture(TapGesture().onEnded {
|
||||
blurred = false
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
.onReceive(NotificationCenter.default.publisher(for: .chatViewWillBeginScrolling)) { _ in
|
||||
|
|
|
@ -65,6 +65,7 @@ struct LocalAuthView: View {
|
|||
// Clear sensitive data on screen just in case app fails to hide its views while new database is created
|
||||
m.chatId = nil
|
||||
ItemsModel.shared.reversedChatItems = []
|
||||
ItemsModel.shared.chatState.clear()
|
||||
m.updateChats([])
|
||||
m.users = []
|
||||
_ = kcAppPassword.set(password)
|
||||
|
|
|
@ -28,7 +28,7 @@ struct PasscodeEntry: View {
|
|||
}
|
||||
}
|
||||
|
||||
@ViewBuilder private func passwordView() -> some View {
|
||||
private func passwordView() -> some View {
|
||||
Text(
|
||||
password == ""
|
||||
? " "
|
||||
|
|
|
@ -177,7 +177,7 @@ struct MigrateFromDevice: View {
|
|||
case let .archiveExportedWithErrors(archivePath, errs):
|
||||
return Alert(
|
||||
title: Text("Chat database exported"),
|
||||
message: Text("You may migrate the exported database.") + Text(verbatim: "\n") + Text("Some file(s) were not exported:") + Text(archiveErrorsText(errs)),
|
||||
message: Text("You may migrate the exported database.") + textNewLine + Text("Some file(s) were not exported:") + Text(archiveErrorsText(errs)),
|
||||
dismissButton: .default(Text("Continue")) {
|
||||
Task { await uploadArchive(path: archivePath) }
|
||||
}
|
||||
|
@ -520,15 +520,15 @@ struct MigrateFromDevice: View {
|
|||
chatReceiver = MigrationChatReceiver(ctrl: ctrl, databaseUrl: tempDatabaseUrl) { msg in
|
||||
await MainActor.run {
|
||||
switch msg {
|
||||
case let .sndFileProgressXFTP(_, _, fileTransferMeta, sentSize, totalSize):
|
||||
case let .result(.sndFileProgressXFTP(_, _, fileTransferMeta, sentSize, totalSize)):
|
||||
if case let .uploadProgress(uploaded, total, _, _, _) = migrationState, uploaded != total {
|
||||
migrationState = .uploadProgress(uploadedBytes: sentSize, totalBytes: totalSize, fileId: fileTransferMeta.fileId, archivePath: archivePath, ctrl: ctrl)
|
||||
}
|
||||
case .sndFileRedirectStartXFTP:
|
||||
case .result(.sndFileRedirectStartXFTP):
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
|
||||
migrationState = .linkCreation
|
||||
}
|
||||
case let .sndStandaloneFileComplete(_, fileTransferMeta, rcvURIs):
|
||||
case let .result(.sndStandaloneFileComplete(_, fileTransferMeta, rcvURIs)):
|
||||
let cfg = getNetCfg()
|
||||
let proxy: NetworkProxy? = if cfg.socksProxy == nil {
|
||||
nil
|
||||
|
@ -546,7 +546,7 @@ struct MigrateFromDevice: View {
|
|||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
|
||||
migrationState = .linkShown(fileId: fileTransferMeta.fileId, link: data.addToLink(link: rcvURIs[0]), archivePath: archivePath, ctrl: ctrl)
|
||||
}
|
||||
case .sndFileError:
|
||||
case .result(.sndFileError):
|
||||
alert = .error(title: "Upload failed", error: "Check your internet connection and try again")
|
||||
migrationState = .uploadFailed(totalBytes: totalBytes, archivePath: archivePath)
|
||||
default:
|
||||
|
@ -691,7 +691,7 @@ private struct PassphraseConfirmationView: View {
|
|||
migrationState = .uploadConfirmation
|
||||
}
|
||||
} catch let error {
|
||||
if case .chatCmdError(_, .errorDatabase(.errorOpen(.errorNotADatabase))) = error as? ChatResponse {
|
||||
if case .errorDatabase(.errorOpen(.errorNotADatabase)) = error as? ChatError {
|
||||
showErrorOnMigrationIfNeeded(.errorNotADatabase(dbFile: ""), $alert)
|
||||
} else {
|
||||
alert = .error(title: "Error", error: NSLocalizedString("Error verifying passphrase:", comment: "") + " " + String(responseError(error)))
|
||||
|
@ -733,11 +733,11 @@ func chatStoppedView() -> some View {
|
|||
private class MigrationChatReceiver {
|
||||
let ctrl: chat_ctrl
|
||||
let databaseUrl: URL
|
||||
let processReceivedMsg: (ChatResponse) async -> Void
|
||||
let processReceivedMsg: (APIResult<ChatEvent>) async -> Void
|
||||
private var receiveLoop: Task<Void, Never>?
|
||||
private var receiveMessages = true
|
||||
|
||||
init(ctrl: chat_ctrl, databaseUrl: URL, _ processReceivedMsg: @escaping (ChatResponse) async -> Void) {
|
||||
init(ctrl: chat_ctrl, databaseUrl: URL, _ processReceivedMsg: @escaping (APIResult<ChatEvent>) async -> Void) {
|
||||
self.ctrl = ctrl
|
||||
self.databaseUrl = databaseUrl
|
||||
self.processReceivedMsg = processReceivedMsg
|
||||
|
@ -752,9 +752,9 @@ private class MigrationChatReceiver {
|
|||
|
||||
func receiveMsgLoop() async {
|
||||
// TODO use function that has timeout
|
||||
if let msg = await chatRecvMsg(ctrl) {
|
||||
if let msg: APIResult<ChatEvent> = await chatRecvMsg(ctrl) {
|
||||
Task {
|
||||
await TerminalItems.shared.add(.resp(.now, msg))
|
||||
await TerminalItems.shared.addResult(msg)
|
||||
}
|
||||
logger.debug("processReceivedMsg: \(msg.responseType)")
|
||||
await processReceivedMsg(msg)
|
||||
|
|
|
@ -496,10 +496,10 @@ struct MigrateToDevice: View {
|
|||
chatReceiver = MigrationChatReceiver(ctrl: ctrl, databaseUrl: tempDatabaseUrl) { msg in
|
||||
await MainActor.run {
|
||||
switch msg {
|
||||
case let .rcvFileProgressXFTP(_, _, receivedSize, totalSize, rcvFileTransfer):
|
||||
case let .result(.rcvFileProgressXFTP(_, _, receivedSize, totalSize, rcvFileTransfer)):
|
||||
migrationState = .downloadProgress(downloadedBytes: receivedSize, totalBytes: totalSize, fileId: rcvFileTransfer.fileId, link: link, archivePath: archivePath, ctrl: ctrl)
|
||||
MigrationToDeviceState.save(.downloadProgress(link: link, archiveName: URL(fileURLWithPath: archivePath).lastPathComponent))
|
||||
case .rcvStandaloneFileComplete:
|
||||
case .result(.rcvStandaloneFileComplete):
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
|
||||
// User closed the whole screen before new state was saved
|
||||
if migrationState == nil {
|
||||
|
@ -509,10 +509,10 @@ struct MigrateToDevice: View {
|
|||
MigrationToDeviceState.save(.archiveImport(archiveName: URL(fileURLWithPath: archivePath).lastPathComponent))
|
||||
}
|
||||
}
|
||||
case .rcvFileError:
|
||||
case .result(.rcvFileError):
|
||||
alert = .error(title: "Download failed", error: "File was deleted or link is invalid")
|
||||
migrationState = .downloadFailed(totalBytes: totalBytes, link: link, archivePath: archivePath)
|
||||
case .chatError(_, .error(.noRcvFileUser)):
|
||||
case .error(.error(.noRcvFileUser)):
|
||||
alert = .error(title: "Download failed", error: "File was deleted or link is invalid")
|
||||
migrationState = .downloadFailed(totalBytes: totalBytes, link: link, archivePath: archivePath)
|
||||
default:
|
||||
|
@ -539,7 +539,7 @@ struct MigrateToDevice: View {
|
|||
chatInitControllerRemovingDatabases()
|
||||
} else if ChatModel.shared.chatRunning == true {
|
||||
// cannot delete storage if chat is running
|
||||
try await apiStopChat()
|
||||
try await stopChatAsync()
|
||||
}
|
||||
try await apiDeleteStorage()
|
||||
try? FileManager.default.createDirectory(at: getWallpaperDirectory(), withIntermediateDirectories: true)
|
||||
|
@ -623,7 +623,7 @@ struct MigrateToDevice: View {
|
|||
AlertManager.shared.showAlert(
|
||||
Alert(
|
||||
title: Text("Error migrating settings"),
|
||||
message: Text ("Some app settings were not migrated.") + Text("\n") + Text(responseError(error)))
|
||||
message: Text ("Some app settings were not migrated.") + textNewLine + Text(responseError(error)))
|
||||
)
|
||||
}
|
||||
hideView()
|
||||
|
@ -632,6 +632,8 @@ struct MigrateToDevice: View {
|
|||
private func hideView() {
|
||||
onboardingStageDefault.set(.onboardingComplete)
|
||||
m.onboardingStage = .onboardingComplete
|
||||
m.migrationState = nil
|
||||
MigrationToDeviceState.save(nil)
|
||||
dismiss()
|
||||
}
|
||||
|
||||
|
@ -749,11 +751,11 @@ private func progressView() -> some View {
|
|||
private class MigrationChatReceiver {
|
||||
let ctrl: chat_ctrl
|
||||
let databaseUrl: URL
|
||||
let processReceivedMsg: (ChatResponse) async -> Void
|
||||
let processReceivedMsg: (APIResult<ChatEvent>) async -> Void
|
||||
private var receiveLoop: Task<Void, Never>?
|
||||
private var receiveMessages = true
|
||||
|
||||
init(ctrl: chat_ctrl, databaseUrl: URL, _ processReceivedMsg: @escaping (ChatResponse) async -> Void) {
|
||||
init(ctrl: chat_ctrl, databaseUrl: URL, _ processReceivedMsg: @escaping (APIResult<ChatEvent>) async -> Void) {
|
||||
self.ctrl = ctrl
|
||||
self.databaseUrl = databaseUrl
|
||||
self.processReceivedMsg = processReceivedMsg
|
||||
|
@ -770,7 +772,7 @@ private class MigrationChatReceiver {
|
|||
// TODO use function that has timeout
|
||||
if let msg = await chatRecvMsg(ctrl) {
|
||||
Task {
|
||||
await TerminalItems.shared.add(.resp(.now, msg))
|
||||
await TerminalItems.shared.addResult(msg)
|
||||
}
|
||||
logger.debug("processReceivedMsg: \(msg.responseType)")
|
||||
await processReceivedMsg(msg)
|
||||
|
|
|
@ -23,7 +23,7 @@ struct AddGroupView: View {
|
|||
@State private var showTakePhoto = false
|
||||
@State private var chosenImage: UIImage? = nil
|
||||
@State private var showInvalidNameAlert = false
|
||||
@State private var groupLink: String?
|
||||
@State private var groupLink: CreatedConnLink?
|
||||
@State private var groupLinkMemberRole: GroupMemberRole = .member
|
||||
|
||||
var body: some View {
|
||||
|
@ -191,11 +191,7 @@ struct AddGroupView: View {
|
|||
profile.groupPreferences = GroupPreferences(history: GroupPreference(enable: .on))
|
||||
let gInfo = try apiNewGroup(incognito: incognitoDefault, groupProfile: profile)
|
||||
Task {
|
||||
let groupMembers = await apiListMembers(gInfo.groupId)
|
||||
await MainActor.run {
|
||||
m.groupMembers = groupMembers.map { GMember.init($0) }
|
||||
m.populateGroupMembersIndexes()
|
||||
}
|
||||
await m.loadGroupMembers(gInfo)
|
||||
}
|
||||
let c = Chat(chatInfo: .group(groupInfo: gInfo), chatItems: [])
|
||||
m.addChat(c)
|
||||
|
|
|
@ -85,7 +85,7 @@ struct NewChatSheet: View {
|
|||
}
|
||||
}
|
||||
|
||||
@ViewBuilder private func viewBody(_ showArchive: Bool) -> some View {
|
||||
private func viewBody(_ showArchive: Bool) -> some View {
|
||||
List {
|
||||
HStack {
|
||||
ContactsListSearchBar(
|
||||
|
@ -186,7 +186,7 @@ struct NewChatSheet: View {
|
|||
}
|
||||
}
|
||||
|
||||
func chatContactType(chat: Chat) -> ContactType {
|
||||
func chatContactType(_ chat: Chat) -> ContactType {
|
||||
switch chat.chatInfo {
|
||||
case .contactRequest:
|
||||
return .request
|
||||
|
@ -207,7 +207,7 @@ func chatContactType(chat: Chat) -> ContactType {
|
|||
|
||||
private func filterContactTypes(chats: [Chat], contactTypes: [ContactType]) -> [Chat] {
|
||||
return chats.filter { chat in
|
||||
contactTypes.contains(chatContactType(chat: chat))
|
||||
contactTypes.contains(chatContactType(chat))
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -258,7 +258,7 @@ struct ContactsList: View {
|
|||
}
|
||||
}
|
||||
|
||||
@ViewBuilder private func noResultSection(text: String) -> some View {
|
||||
private func noResultSection(text: String) -> some View {
|
||||
Section {
|
||||
Text(text)
|
||||
.foregroundColor(theme.colors.secondary)
|
||||
|
@ -279,8 +279,8 @@ struct ContactsList: View {
|
|||
}
|
||||
|
||||
private func chatsByTypeComparator(chat1: Chat, chat2: Chat) -> Bool {
|
||||
let chat1Type = chatContactType(chat: chat1)
|
||||
let chat2Type = chatContactType(chat: chat2)
|
||||
let chat1Type = chatContactType(chat1)
|
||||
let chat2Type = chatContactType(chat2)
|
||||
|
||||
if chat1Type.rawValue < chat2Type.rawValue {
|
||||
return true
|
||||
|
|
|
@ -25,6 +25,7 @@ struct SomeActionSheet: Identifiable {
|
|||
struct SomeSheet<Content: View>: Identifiable {
|
||||
@ViewBuilder var content: Content
|
||||
var id: String
|
||||
var fraction = 0.4
|
||||
}
|
||||
|
||||
private enum NewChatViewAlert: Identifiable {
|
||||
|
@ -80,7 +81,8 @@ struct NewChatView: View {
|
|||
@State var selection: NewChatOption
|
||||
@State var showQRCodeScanner = false
|
||||
@State private var invitationUsed: Bool = false
|
||||
@State private var connReqInvitation: String = ""
|
||||
@State private var connLinkInvitation: CreatedConnLink = CreatedConnLink(connFullLink: "", connShortLink: nil)
|
||||
@State private var showShortLink = true
|
||||
@State private var creatingConnReq = false
|
||||
@State var choosingProfile = false
|
||||
@State private var pastedLink: String = ""
|
||||
|
@ -173,11 +175,12 @@ struct NewChatView: View {
|
|||
|
||||
private func prepareAndInviteView() -> some View {
|
||||
ZStack { // ZStack is needed for views to not make transitions between each other
|
||||
if connReqInvitation != "" {
|
||||
if connLinkInvitation.connFullLink != "" {
|
||||
InviteView(
|
||||
invitationUsed: $invitationUsed,
|
||||
contactConnection: $contactConnection,
|
||||
connReqInvitation: $connReqInvitation,
|
||||
connLinkInvitation: $connLinkInvitation,
|
||||
showShortLink: $showShortLink,
|
||||
choosingProfile: $choosingProfile
|
||||
)
|
||||
} else if creatingConnReq {
|
||||
|
@ -189,16 +192,16 @@ struct NewChatView: View {
|
|||
}
|
||||
|
||||
private func createInvitation() {
|
||||
if connReqInvitation == "" && contactConnection == nil && !creatingConnReq {
|
||||
if connLinkInvitation.connFullLink == "" && contactConnection == nil && !creatingConnReq {
|
||||
creatingConnReq = true
|
||||
Task {
|
||||
_ = try? await Task.sleep(nanoseconds: 250_000000)
|
||||
let (r, apiAlert) = await apiAddContact(incognito: incognitoGroupDefault.get())
|
||||
if let (connReq, pcc) = r {
|
||||
if let (connLink, pcc) = r {
|
||||
await MainActor.run {
|
||||
m.updateContactConnection(pcc)
|
||||
m.showingInvitation = ShowingInvitation(pcc: pcc, connChatUsed: false)
|
||||
connReqInvitation = connReq
|
||||
connLinkInvitation = connLink
|
||||
contactConnection = pcc
|
||||
}
|
||||
} else {
|
||||
|
@ -242,7 +245,8 @@ private struct InviteView: View {
|
|||
@EnvironmentObject var theme: AppTheme
|
||||
@Binding var invitationUsed: Bool
|
||||
@Binding var contactConnection: PendingContactConnection?
|
||||
@Binding var connReqInvitation: String
|
||||
@Binding var connLinkInvitation: CreatedConnLink
|
||||
@Binding var showShortLink: Bool
|
||||
@Binding var choosingProfile: Bool
|
||||
|
||||
@AppStorage(GROUP_DEFAULT_INCOGNITO, store: groupDefaults) private var incognitoDefault = false
|
||||
|
@ -260,7 +264,7 @@ private struct InviteView: View {
|
|||
NavigationLink {
|
||||
ActiveProfilePicker(
|
||||
contactConnection: $contactConnection,
|
||||
connReqInvitation: $connReqInvitation,
|
||||
connLinkInvitation: $connLinkInvitation,
|
||||
incognitoEnabled: $incognitoDefault,
|
||||
choosingProfile: $choosingProfile,
|
||||
selectedProfile: selectedProfile
|
||||
|
@ -295,7 +299,7 @@ private struct InviteView: View {
|
|||
|
||||
private func shareLinkView() -> some View {
|
||||
HStack {
|
||||
let link = simplexChatLink(connReqInvitation)
|
||||
let link = connLinkInvitation.simplexChatUri(short: showShortLink)
|
||||
linkTextView(link)
|
||||
Button {
|
||||
showShareSheet(items: [link])
|
||||
|
@ -309,9 +313,9 @@ private struct InviteView: View {
|
|||
}
|
||||
|
||||
private func qrCodeView() -> some View {
|
||||
Section(header: Text("Or show this code").foregroundColor(theme.colors.secondary)) {
|
||||
SimpleXLinkQRCode(uri: connReqInvitation, onShare: setInvitationUsed)
|
||||
.id("simplex-qrcode-view-for-\(connReqInvitation)")
|
||||
Section {
|
||||
SimpleXCreatedLinkQRCode(link: connLinkInvitation, short: $showShortLink, onShare: setInvitationUsed)
|
||||
.id("simplex-qrcode-view-for-\(connLinkInvitation.simplexChatUri(short: showShortLink))")
|
||||
.padding()
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 12, style: .continuous)
|
||||
|
@ -321,6 +325,8 @@ private struct InviteView: View {
|
|||
.listRowBackground(Color.clear)
|
||||
.listRowSeparator(.hidden)
|
||||
.listRowInsets(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0))
|
||||
} header: {
|
||||
ToggleShortLinkHeader(text: Text("Or show this code"), link: connLinkInvitation, short: $showShortLink)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -342,7 +348,7 @@ private struct ActiveProfilePicker: View {
|
|||
@EnvironmentObject var chatModel: ChatModel
|
||||
@EnvironmentObject var theme: AppTheme
|
||||
@Binding var contactConnection: PendingContactConnection?
|
||||
@Binding var connReqInvitation: String
|
||||
@Binding var connLinkInvitation: CreatedConnLink
|
||||
@Binding var incognitoEnabled: Bool
|
||||
@Binding var choosingProfile: Bool
|
||||
@State private var alert: SomeAlert?
|
||||
|
@ -414,12 +420,11 @@ private struct ActiveProfilePicker: View {
|
|||
}
|
||||
Task {
|
||||
do {
|
||||
if let contactConn = contactConnection,
|
||||
let conn = try await apiChangeConnectionUser(connId: contactConn.pccConnId, userId: profile.userId) {
|
||||
|
||||
if let contactConn = contactConnection {
|
||||
let conn = try await apiChangeConnectionUser(connId: contactConn.pccConnId, userId: profile.userId)
|
||||
await MainActor.run {
|
||||
contactConnection = conn
|
||||
connReqInvitation = conn.connReqInv ?? ""
|
||||
connLinkInvitation = conn.connLinkInv ?? CreatedConnLink(connFullLink: "", connShortLink: nil)
|
||||
incognitoEnabled = false
|
||||
chatModel.updateContactConnection(conn)
|
||||
}
|
||||
|
@ -501,7 +506,7 @@ private struct ActiveProfilePicker: View {
|
|||
}
|
||||
}
|
||||
|
||||
@ViewBuilder private func profilerPickerUserOption(_ user: User) -> some View {
|
||||
private func profilerPickerUserOption(_ user: User) -> some View {
|
||||
Button {
|
||||
if selectedProfile == user && incognitoEnabled {
|
||||
incognitoEnabled = false
|
||||
|
@ -835,23 +840,25 @@ func sharedProfileInfo(_ incognito: Bool) -> Text {
|
|||
}
|
||||
|
||||
enum PlanAndConnectAlert: Identifiable {
|
||||
case ownInvitationLinkConfirmConnect(connectionLink: String, connectionPlan: ConnectionPlan, incognito: Bool)
|
||||
case invitationLinkConnecting(connectionLink: String)
|
||||
case ownContactAddressConfirmConnect(connectionLink: String, connectionPlan: ConnectionPlan, incognito: Bool)
|
||||
case contactAddressConnectingConfirmReconnect(connectionLink: String, connectionPlan: ConnectionPlan, incognito: Bool)
|
||||
case groupLinkConfirmConnect(connectionLink: String, connectionPlan: ConnectionPlan, incognito: Bool)
|
||||
case groupLinkConnectingConfirmReconnect(connectionLink: String, connectionPlan: ConnectionPlan, incognito: Bool)
|
||||
case groupLinkConnecting(connectionLink: String, groupInfo: GroupInfo?)
|
||||
case ownInvitationLinkConfirmConnect(connectionLink: CreatedConnLink, connectionPlan: ConnectionPlan, incognito: Bool)
|
||||
case invitationLinkConnecting(connectionLink: CreatedConnLink)
|
||||
case ownContactAddressConfirmConnect(connectionLink: CreatedConnLink, connectionPlan: ConnectionPlan, incognito: Bool)
|
||||
case contactAddressConnectingConfirmReconnect(connectionLink: CreatedConnLink, connectionPlan: ConnectionPlan, incognito: Bool)
|
||||
case groupLinkConfirmConnect(connectionLink: CreatedConnLink, connectionPlan: ConnectionPlan, incognito: Bool)
|
||||
case groupLinkConnectingConfirmReconnect(connectionLink: CreatedConnLink, connectionPlan: ConnectionPlan, incognito: Bool)
|
||||
case groupLinkConnecting(connectionLink: CreatedConnLink, groupInfo: GroupInfo?)
|
||||
case error(shortOrFullLink: String, alert: Alert)
|
||||
|
||||
var id: String {
|
||||
switch self {
|
||||
case let .ownInvitationLinkConfirmConnect(connectionLink, _, _): return "ownInvitationLinkConfirmConnect \(connectionLink)"
|
||||
case let .invitationLinkConnecting(connectionLink): return "invitationLinkConnecting \(connectionLink)"
|
||||
case let .ownContactAddressConfirmConnect(connectionLink, _, _): return "ownContactAddressConfirmConnect \(connectionLink)"
|
||||
case let .contactAddressConnectingConfirmReconnect(connectionLink, _, _): return "contactAddressConnectingConfirmReconnect \(connectionLink)"
|
||||
case let .groupLinkConfirmConnect(connectionLink, _, _): return "groupLinkConfirmConnect \(connectionLink)"
|
||||
case let .groupLinkConnectingConfirmReconnect(connectionLink, _, _): return "groupLinkConnectingConfirmReconnect \(connectionLink)"
|
||||
case let .groupLinkConnecting(connectionLink, _): return "groupLinkConnecting \(connectionLink)"
|
||||
case let .ownInvitationLinkConfirmConnect(connectionLink, _, _): return "ownInvitationLinkConfirmConnect \(connectionLink.connFullLink)"
|
||||
case let .invitationLinkConnecting(connectionLink): return "invitationLinkConnecting \(connectionLink.connFullLink)"
|
||||
case let .ownContactAddressConfirmConnect(connectionLink, _, _): return "ownContactAddressConfirmConnect \(connectionLink.connFullLink)"
|
||||
case let .contactAddressConnectingConfirmReconnect(connectionLink, _, _): return "contactAddressConnectingConfirmReconnect \(connectionLink.connFullLink)"
|
||||
case let .groupLinkConfirmConnect(connectionLink, _, _): return "groupLinkConfirmConnect \(connectionLink.connFullLink)"
|
||||
case let .groupLinkConnectingConfirmReconnect(connectionLink, _, _): return "groupLinkConnectingConfirmReconnect \(connectionLink.connFullLink)"
|
||||
case let .groupLinkConnecting(connectionLink, _): return "groupLinkConnecting \(connectionLink.connFullLink)"
|
||||
case let .error(shortOrFullLink, alert): return "error \(shortOrFullLink)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -934,21 +941,22 @@ func planAndConnectAlert(_ alert: PlanAndConnectAlert, dismiss: Bool, cleanup: (
|
|||
dismissButton: .default(Text("OK")) { cleanup?() }
|
||||
)
|
||||
}
|
||||
case let .error(_, alert): return alert
|
||||
}
|
||||
}
|
||||
|
||||
enum PlanAndConnectActionSheet: Identifiable {
|
||||
case askCurrentOrIncognitoProfile(connectionLink: String, connectionPlan: ConnectionPlan?, title: LocalizedStringKey)
|
||||
case askCurrentOrIncognitoProfileDestructive(connectionLink: String, connectionPlan: ConnectionPlan, title: LocalizedStringKey)
|
||||
case askCurrentOrIncognitoProfile(connectionLink: CreatedConnLink, connectionPlan: ConnectionPlan?, title: LocalizedStringKey)
|
||||
case askCurrentOrIncognitoProfileDestructive(connectionLink: CreatedConnLink, connectionPlan: ConnectionPlan, title: LocalizedStringKey)
|
||||
case askCurrentOrIncognitoProfileConnectContactViaAddress(contact: Contact)
|
||||
case ownGroupLinkConfirmConnect(connectionLink: String, connectionPlan: ConnectionPlan, incognito: Bool?, groupInfo: GroupInfo)
|
||||
case ownGroupLinkConfirmConnect(connectionLink: CreatedConnLink, connectionPlan: ConnectionPlan, incognito: Bool?, groupInfo: GroupInfo)
|
||||
|
||||
var id: String {
|
||||
switch self {
|
||||
case let .askCurrentOrIncognitoProfile(connectionLink, _, _): return "askCurrentOrIncognitoProfile \(connectionLink)"
|
||||
case let .askCurrentOrIncognitoProfileDestructive(connectionLink, _, _): return "askCurrentOrIncognitoProfileDestructive \(connectionLink)"
|
||||
case let .askCurrentOrIncognitoProfile(connectionLink, _, _): return "askCurrentOrIncognitoProfile \(connectionLink.connFullLink)"
|
||||
case let .askCurrentOrIncognitoProfileDestructive(connectionLink, _, _): return "askCurrentOrIncognitoProfileDestructive \(connectionLink.connFullLink)"
|
||||
case let .askCurrentOrIncognitoProfileConnectContactViaAddress(contact): return "askCurrentOrIncognitoProfileConnectContactViaAddress \(contact.contactId)"
|
||||
case let .ownGroupLinkConfirmConnect(connectionLink, _, _, _): return "ownGroupLinkConfirmConnect \(connectionLink)"
|
||||
case let .ownGroupLinkConfirmConnect(connectionLink, _, _, _): return "ownGroupLinkConfirmConnect \(connectionLink.connFullLink)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -1007,7 +1015,7 @@ func planAndConnectActionSheet(_ sheet: PlanAndConnectActionSheet, dismiss: Bool
|
|||
}
|
||||
|
||||
func planAndConnect(
|
||||
_ connectionLink: String,
|
||||
_ shortOrFullLink: String,
|
||||
showAlert: @escaping (PlanAndConnectAlert) -> Void,
|
||||
showActionSheet: @escaping (PlanAndConnectActionSheet) -> Void,
|
||||
dismiss: Bool,
|
||||
|
@ -1017,8 +1025,8 @@ func planAndConnect(
|
|||
filterKnownGroup: ((GroupInfo) -> Void)? = nil
|
||||
) {
|
||||
Task {
|
||||
do {
|
||||
let connectionPlan = try await apiConnectPlan(connReq: connectionLink)
|
||||
let (result, alert) = await apiConnectPlan(connLink: shortOrFullLink)
|
||||
if let (connectionLink, connectionPlan) = result {
|
||||
switch connectionPlan {
|
||||
case let .invitationLink(ilp):
|
||||
switch ilp {
|
||||
|
@ -1027,32 +1035,40 @@ func planAndConnect(
|
|||
if let incognito = incognito {
|
||||
connectViaLink(connectionLink, connectionPlan: connectionPlan, dismiss: dismiss, incognito: incognito, cleanup: cleanup)
|
||||
} else {
|
||||
showActionSheet(.askCurrentOrIncognitoProfile(connectionLink: connectionLink, connectionPlan: connectionPlan, title: "Connect via one-time link"))
|
||||
await MainActor.run {
|
||||
showActionSheet(.askCurrentOrIncognitoProfile(connectionLink: connectionLink, connectionPlan: connectionPlan, title: "Connect via one-time link"))
|
||||
}
|
||||
}
|
||||
case .ownLink:
|
||||
logger.debug("planAndConnect, .invitationLink, .ownLink, incognito=\(incognito?.description ?? "nil")")
|
||||
if let incognito = incognito {
|
||||
showAlert(.ownInvitationLinkConfirmConnect(connectionLink: connectionLink, connectionPlan: connectionPlan, incognito: incognito))
|
||||
} else {
|
||||
showActionSheet(.askCurrentOrIncognitoProfileDestructive(connectionLink: connectionLink, connectionPlan: connectionPlan, title: "Connect to yourself?\nThis is your own one-time link!"))
|
||||
await MainActor.run {
|
||||
if let incognito = incognito {
|
||||
showAlert(.ownInvitationLinkConfirmConnect(connectionLink: connectionLink, connectionPlan: connectionPlan, incognito: incognito))
|
||||
} else {
|
||||
showActionSheet(.askCurrentOrIncognitoProfileDestructive(connectionLink: connectionLink, connectionPlan: connectionPlan, title: "Connect to yourself?\nThis is your own one-time link!"))
|
||||
}
|
||||
}
|
||||
case let .connecting(contact_):
|
||||
logger.debug("planAndConnect, .invitationLink, .connecting, incognito=\(incognito?.description ?? "nil")")
|
||||
if let contact = contact_ {
|
||||
if let f = filterKnownContact {
|
||||
f(contact)
|
||||
await MainActor.run {
|
||||
if let contact = contact_ {
|
||||
if let f = filterKnownContact {
|
||||
f(contact)
|
||||
} else {
|
||||
openKnownContact(contact, dismiss: dismiss) { AlertManager.shared.showAlert(contactAlreadyConnectingAlert(contact)) }
|
||||
}
|
||||
} else {
|
||||
openKnownContact(contact, dismiss: dismiss) { AlertManager.shared.showAlert(contactAlreadyConnectingAlert(contact)) }
|
||||
showAlert(.invitationLinkConnecting(connectionLink: connectionLink))
|
||||
}
|
||||
} else {
|
||||
showAlert(.invitationLinkConnecting(connectionLink: connectionLink))
|
||||
}
|
||||
case let .known(contact):
|
||||
logger.debug("planAndConnect, .invitationLink, .known, incognito=\(incognito?.description ?? "nil")")
|
||||
if let f = filterKnownContact {
|
||||
f(contact)
|
||||
} else {
|
||||
openKnownContact(contact, dismiss: dismiss) { AlertManager.shared.showAlert(contactAlreadyExistsAlert(contact)) }
|
||||
await MainActor.run {
|
||||
if let f = filterKnownContact {
|
||||
f(contact)
|
||||
} else {
|
||||
openKnownContact(contact, dismiss: dismiss) { AlertManager.shared.showAlert(contactAlreadyExistsAlert(contact)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
case let .contactAddress(cap):
|
||||
|
@ -1062,83 +1078,109 @@ func planAndConnect(
|
|||
if let incognito = incognito {
|
||||
connectViaLink(connectionLink, connectionPlan: connectionPlan, dismiss: dismiss, incognito: incognito, cleanup: cleanup)
|
||||
} else {
|
||||
showActionSheet(.askCurrentOrIncognitoProfile(connectionLink: connectionLink, connectionPlan: connectionPlan, title: "Connect via contact address"))
|
||||
await MainActor.run {
|
||||
showActionSheet(.askCurrentOrIncognitoProfile(connectionLink: connectionLink, connectionPlan: connectionPlan, title: "Connect via contact address"))
|
||||
}
|
||||
}
|
||||
case .ownLink:
|
||||
logger.debug("planAndConnect, .contactAddress, .ownLink, incognito=\(incognito?.description ?? "nil")")
|
||||
if let incognito = incognito {
|
||||
showAlert(.ownContactAddressConfirmConnect(connectionLink: connectionLink, connectionPlan: connectionPlan, incognito: incognito))
|
||||
} else {
|
||||
showActionSheet(.askCurrentOrIncognitoProfileDestructive(connectionLink: connectionLink, connectionPlan: connectionPlan, title: "Connect to yourself?\nThis is your own SimpleX address!"))
|
||||
await MainActor.run {
|
||||
if let incognito = incognito {
|
||||
showAlert(.ownContactAddressConfirmConnect(connectionLink: connectionLink, connectionPlan: connectionPlan, incognito: incognito))
|
||||
} else {
|
||||
showActionSheet(.askCurrentOrIncognitoProfileDestructive(connectionLink: connectionLink, connectionPlan: connectionPlan, title: "Connect to yourself?\nThis is your own SimpleX address!"))
|
||||
}
|
||||
}
|
||||
case .connectingConfirmReconnect:
|
||||
logger.debug("planAndConnect, .contactAddress, .connectingConfirmReconnect, incognito=\(incognito?.description ?? "nil")")
|
||||
if let incognito = incognito {
|
||||
showAlert(.contactAddressConnectingConfirmReconnect(connectionLink: connectionLink, connectionPlan: connectionPlan, incognito: incognito))
|
||||
} else {
|
||||
showActionSheet(.askCurrentOrIncognitoProfileDestructive(connectionLink: connectionLink, connectionPlan: connectionPlan, title: "You have already requested connection!\nRepeat connection request?"))
|
||||
await MainActor.run {
|
||||
if let incognito = incognito {
|
||||
showAlert(.contactAddressConnectingConfirmReconnect(connectionLink: connectionLink, connectionPlan: connectionPlan, incognito: incognito))
|
||||
} else {
|
||||
showActionSheet(.askCurrentOrIncognitoProfileDestructive(connectionLink: connectionLink, connectionPlan: connectionPlan, title: "You have already requested connection!\nRepeat connection request?"))
|
||||
}
|
||||
}
|
||||
case let .connectingProhibit(contact):
|
||||
logger.debug("planAndConnect, .contactAddress, .connectingProhibit, incognito=\(incognito?.description ?? "nil")")
|
||||
if let f = filterKnownContact {
|
||||
f(contact)
|
||||
} else {
|
||||
openKnownContact(contact, dismiss: dismiss) { AlertManager.shared.showAlert(contactAlreadyConnectingAlert(contact)) }
|
||||
await MainActor.run {
|
||||
if let f = filterKnownContact {
|
||||
f(contact)
|
||||
} else {
|
||||
openKnownContact(contact, dismiss: dismiss) { AlertManager.shared.showAlert(contactAlreadyConnectingAlert(contact)) }
|
||||
}
|
||||
}
|
||||
case let .known(contact):
|
||||
logger.debug("planAndConnect, .contactAddress, .known, incognito=\(incognito?.description ?? "nil")")
|
||||
if let f = filterKnownContact {
|
||||
f(contact)
|
||||
} else {
|
||||
openKnownContact(contact, dismiss: dismiss) { AlertManager.shared.showAlert(contactAlreadyExistsAlert(contact)) }
|
||||
await MainActor.run {
|
||||
if let f = filterKnownContact {
|
||||
f(contact)
|
||||
} else {
|
||||
openKnownContact(contact, dismiss: dismiss) { AlertManager.shared.showAlert(contactAlreadyExistsAlert(contact)) }
|
||||
}
|
||||
}
|
||||
case let .contactViaAddress(contact):
|
||||
logger.debug("planAndConnect, .contactAddress, .contactViaAddress, incognito=\(incognito?.description ?? "nil")")
|
||||
if let incognito = incognito {
|
||||
connectContactViaAddress_(contact, dismiss: dismiss, incognito: incognito, cleanup: cleanup)
|
||||
} else {
|
||||
showActionSheet(.askCurrentOrIncognitoProfileConnectContactViaAddress(contact: contact))
|
||||
await MainActor.run {
|
||||
showActionSheet(.askCurrentOrIncognitoProfileConnectContactViaAddress(contact: contact))
|
||||
}
|
||||
}
|
||||
}
|
||||
case let .groupLink(glp):
|
||||
switch glp {
|
||||
case .ok:
|
||||
if let incognito = incognito {
|
||||
showAlert(.groupLinkConfirmConnect(connectionLink: connectionLink, connectionPlan: connectionPlan, incognito: incognito))
|
||||
} else {
|
||||
showActionSheet(.askCurrentOrIncognitoProfile(connectionLink: connectionLink, connectionPlan: connectionPlan, title: "Join group"))
|
||||
await MainActor.run {
|
||||
if let incognito = incognito {
|
||||
showAlert(.groupLinkConfirmConnect(connectionLink: connectionLink, connectionPlan: connectionPlan, incognito: incognito))
|
||||
} else {
|
||||
showActionSheet(.askCurrentOrIncognitoProfile(connectionLink: connectionLink, connectionPlan: connectionPlan, title: "Join group"))
|
||||
}
|
||||
}
|
||||
case let .ownLink(groupInfo):
|
||||
logger.debug("planAndConnect, .groupLink, .ownLink, incognito=\(incognito?.description ?? "nil")")
|
||||
if let f = filterKnownGroup {
|
||||
f(groupInfo)
|
||||
await MainActor.run {
|
||||
if let f = filterKnownGroup {
|
||||
f(groupInfo)
|
||||
}
|
||||
showActionSheet(.ownGroupLinkConfirmConnect(connectionLink: connectionLink, connectionPlan: connectionPlan, incognito: incognito, groupInfo: groupInfo))
|
||||
}
|
||||
showActionSheet(.ownGroupLinkConfirmConnect(connectionLink: connectionLink, connectionPlan: connectionPlan, incognito: incognito, groupInfo: groupInfo))
|
||||
case .connectingConfirmReconnect:
|
||||
logger.debug("planAndConnect, .groupLink, .connectingConfirmReconnect, incognito=\(incognito?.description ?? "nil")")
|
||||
if let incognito = incognito {
|
||||
showAlert(.groupLinkConnectingConfirmReconnect(connectionLink: connectionLink, connectionPlan: connectionPlan, incognito: incognito))
|
||||
} else {
|
||||
showActionSheet(.askCurrentOrIncognitoProfileDestructive(connectionLink: connectionLink, connectionPlan: connectionPlan, title: "You are already joining the group!\nRepeat join request?"))
|
||||
await MainActor.run {
|
||||
if let incognito = incognito {
|
||||
showAlert(.groupLinkConnectingConfirmReconnect(connectionLink: connectionLink, connectionPlan: connectionPlan, incognito: incognito))
|
||||
} else {
|
||||
showActionSheet(.askCurrentOrIncognitoProfileDestructive(connectionLink: connectionLink, connectionPlan: connectionPlan, title: "You are already joining the group!\nRepeat join request?"))
|
||||
}
|
||||
}
|
||||
case let .connectingProhibit(groupInfo_):
|
||||
logger.debug("planAndConnect, .groupLink, .connectingProhibit, incognito=\(incognito?.description ?? "nil")")
|
||||
showAlert(.groupLinkConnecting(connectionLink: connectionLink, groupInfo: groupInfo_))
|
||||
await MainActor.run {
|
||||
showAlert(.groupLinkConnecting(connectionLink: connectionLink, groupInfo: groupInfo_))
|
||||
}
|
||||
case let .known(groupInfo):
|
||||
logger.debug("planAndConnect, .groupLink, .known, incognito=\(incognito?.description ?? "nil")")
|
||||
if let f = filterKnownGroup {
|
||||
f(groupInfo)
|
||||
} else {
|
||||
openKnownGroup(groupInfo, dismiss: dismiss) { AlertManager.shared.showAlert(groupAlreadyExistsAlert(groupInfo)) }
|
||||
await MainActor.run {
|
||||
if let f = filterKnownGroup {
|
||||
f(groupInfo)
|
||||
} else {
|
||||
openKnownGroup(groupInfo, dismiss: dismiss) { AlertManager.shared.showAlert(groupAlreadyExistsAlert(groupInfo)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
case let .error(chatError):
|
||||
logger.debug("planAndConnect, .error \(chatErrorString(chatError))")
|
||||
if let incognito = incognito {
|
||||
connectViaLink(connectionLink, connectionPlan: nil, dismiss: dismiss, incognito: incognito, cleanup: cleanup)
|
||||
} else {
|
||||
showActionSheet(.askCurrentOrIncognitoProfile(connectionLink: connectionLink, connectionPlan: nil, title: "Connect via link"))
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
logger.debug("planAndConnect, plan error")
|
||||
if let incognito = incognito {
|
||||
connectViaLink(connectionLink, connectionPlan: nil, dismiss: dismiss, incognito: incognito, cleanup: cleanup)
|
||||
} else {
|
||||
showActionSheet(.askCurrentOrIncognitoProfile(connectionLink: connectionLink, connectionPlan: nil, title: "Connect via link"))
|
||||
} else if let alert {
|
||||
await MainActor.run {
|
||||
showAlert(.error(shortOrFullLink: shortOrFullLink, alert: alert))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -1160,22 +1202,22 @@ private func connectContactViaAddress_(_ contact: Contact, dismiss: Bool, incogn
|
|||
}
|
||||
|
||||
private func connectViaLink(
|
||||
_ connectionLink: String,
|
||||
_ connectionLink: CreatedConnLink,
|
||||
connectionPlan: ConnectionPlan?,
|
||||
dismiss: Bool,
|
||||
incognito: Bool,
|
||||
cleanup: (() -> Void)?
|
||||
) {
|
||||
Task {
|
||||
if let (connReqType, pcc) = await apiConnect(incognito: incognito, connReq: connectionLink) {
|
||||
if let (connReqType, pcc) = await apiConnect(incognito: incognito, connLink: connectionLink) {
|
||||
await MainActor.run {
|
||||
ChatModel.shared.updateContactConnection(pcc)
|
||||
}
|
||||
let crt: ConnReqType
|
||||
if let plan = connectionPlan {
|
||||
crt = planToConnReqType(plan)
|
||||
crt = if let plan = connectionPlan {
|
||||
planToConnReqType(plan) ?? connReqType
|
||||
} else {
|
||||
crt = connReqType
|
||||
connReqType
|
||||
}
|
||||
DispatchQueue.main.async {
|
||||
if dismiss {
|
||||
|
@ -1198,39 +1240,35 @@ private func connectViaLink(
|
|||
}
|
||||
|
||||
func openKnownContact(_ contact: Contact, dismiss: Bool, showAlreadyExistsAlert: (() -> Void)?) {
|
||||
Task {
|
||||
let m = ChatModel.shared
|
||||
if let c = m.getContactChat(contact.contactId) {
|
||||
DispatchQueue.main.async {
|
||||
if dismiss {
|
||||
dismissAllSheets(animated: true) {
|
||||
ItemsModel.shared.loadOpenChat(c.id)
|
||||
showAlreadyExistsAlert?()
|
||||
}
|
||||
} else {
|
||||
ItemsModel.shared.loadOpenChat(c.id)
|
||||
let m = ChatModel.shared
|
||||
if let c = m.getContactChat(contact.contactId) {
|
||||
if dismiss {
|
||||
dismissAllSheets(animated: true) {
|
||||
ItemsModel.shared.loadOpenChat(c.id) {
|
||||
showAlreadyExistsAlert?()
|
||||
}
|
||||
}
|
||||
} else {
|
||||
ItemsModel.shared.loadOpenChat(c.id) {
|
||||
showAlreadyExistsAlert?()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func openKnownGroup(_ groupInfo: GroupInfo, dismiss: Bool, showAlreadyExistsAlert: (() -> Void)?) {
|
||||
Task {
|
||||
let m = ChatModel.shared
|
||||
if let g = m.getGroupChat(groupInfo.groupId) {
|
||||
DispatchQueue.main.async {
|
||||
if dismiss {
|
||||
dismissAllSheets(animated: true) {
|
||||
ItemsModel.shared.loadOpenChat(g.id)
|
||||
showAlreadyExistsAlert?()
|
||||
}
|
||||
} else {
|
||||
ItemsModel.shared.loadOpenChat(g.id)
|
||||
let m = ChatModel.shared
|
||||
if let g = m.getGroupChat(groupInfo.groupId) {
|
||||
if dismiss {
|
||||
dismissAllSheets(animated: true) {
|
||||
ItemsModel.shared.loadOpenChat(g.id) {
|
||||
showAlreadyExistsAlert?()
|
||||
}
|
||||
}
|
||||
} else {
|
||||
ItemsModel.shared.loadOpenChat(g.id) {
|
||||
showAlreadyExistsAlert?()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -1268,11 +1306,12 @@ enum ConnReqType: Equatable {
|
|||
}
|
||||
}
|
||||
|
||||
private func planToConnReqType(_ connectionPlan: ConnectionPlan) -> ConnReqType {
|
||||
private func planToConnReqType(_ connectionPlan: ConnectionPlan) -> ConnReqType? {
|
||||
switch connectionPlan {
|
||||
case .invitationLink: return .invitation
|
||||
case .contactAddress: return .contact
|
||||
case .groupLink: return .groupLink
|
||||
case .invitationLink: .invitation
|
||||
case .contactAddress: .contact
|
||||
case .groupLink: .groupLink
|
||||
case .error: nil
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -8,6 +8,7 @@
|
|||
|
||||
import SwiftUI
|
||||
import CoreImage.CIFilterBuiltins
|
||||
import SimpleXChat
|
||||
|
||||
struct MutableQRCode: View {
|
||||
@Binding var uri: String
|
||||
|
@ -20,6 +21,16 @@ struct MutableQRCode: View {
|
|||
}
|
||||
}
|
||||
|
||||
struct SimpleXCreatedLinkQRCode: View {
|
||||
let link: CreatedConnLink
|
||||
@Binding var short: Bool
|
||||
var onShare: (() -> Void)? = nil
|
||||
|
||||
var body: some View {
|
||||
QRCode(uri: link.simplexChatUri(short: short), onShare: onShare)
|
||||
}
|
||||
}
|
||||
|
||||
struct SimpleXLinkQRCode: View {
|
||||
let uri: String
|
||||
var withLogo: Bool = true
|
||||
|
@ -31,12 +42,6 @@ struct SimpleXLinkQRCode: View {
|
|||
}
|
||||
}
|
||||
|
||||
func simplexChatLink(_ uri: String) -> String {
|
||||
uri.starts(with: "simplex:/")
|
||||
? uri.replacingOccurrences(of: "simplex:/", with: "https://simplex.chat/")
|
||||
: uri
|
||||
}
|
||||
|
||||
struct QRCode: View {
|
||||
let uri: String
|
||||
var withLogo: Bool = true
|
||||
|
|
|
@ -21,7 +21,7 @@ struct AddressCreationCard: View {
|
|||
var body: some View {
|
||||
let addressExists = chatModel.userAddress != nil
|
||||
let chats = chatModel.chats.filter { chat in
|
||||
!chat.chatInfo.chatDeleted && chatContactType(chat: chat) != ContactType.card
|
||||
!chat.chatInfo.chatDeleted && !chat.chatInfo.contactCard
|
||||
}
|
||||
ZStack(alignment: .topTrailing) {
|
||||
HStack(alignment: .top, spacing: 16) {
|
||||
|
|
|
@ -43,26 +43,23 @@ struct OnboardingButtonStyle: ButtonStyle {
|
|||
}
|
||||
}
|
||||
|
||||
private enum ChooseServerOperatorsSheet: Identifiable {
|
||||
case showInfo
|
||||
private enum OnboardingConditionsViewSheet: Identifiable {
|
||||
case showConditions
|
||||
case configureOperators
|
||||
|
||||
var id: String {
|
||||
switch self {
|
||||
case .showInfo: return "showInfo"
|
||||
case .showConditions: return "showConditions"
|
||||
case .configureOperators: return "configureOperators"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct ChooseServerOperators: View {
|
||||
@Environment(\.dismiss) var dismiss: DismissAction
|
||||
@Environment(\.colorScheme) var colorScheme: ColorScheme
|
||||
struct OnboardingConditionsView: View {
|
||||
@EnvironmentObject var theme: AppTheme
|
||||
var onboarding: Bool
|
||||
@State private var serverOperators: [ServerOperator] = []
|
||||
@State private var selectedOperatorIds = Set<Int64>()
|
||||
@State private var sheetItem: ChooseServerOperatorsSheet? = nil
|
||||
@State private var sheetItem: OnboardingConditionsViewSheet? = nil
|
||||
@State private var notificationsModeNavLinkActive = false
|
||||
@State private var justOpened = true
|
||||
|
||||
|
@ -70,83 +67,47 @@ struct ChooseServerOperators: View {
|
|||
|
||||
var body: some View {
|
||||
GeometryReader { g in
|
||||
ScrollView {
|
||||
let v = ScrollView {
|
||||
VStack(alignment: .leading, spacing: 20) {
|
||||
let title = Text("Server operators")
|
||||
Text("Conditions of use")
|
||||
.font(.largeTitle)
|
||||
.bold()
|
||||
.frame(maxWidth: .infinity, alignment: .center)
|
||||
|
||||
if onboarding {
|
||||
title.padding(.top, 25)
|
||||
} else {
|
||||
title
|
||||
}
|
||||
|
||||
infoText()
|
||||
.frame(maxWidth: .infinity, alignment: .center)
|
||||
.padding(.top, 25)
|
||||
|
||||
Spacer()
|
||||
|
||||
ForEach(serverOperators) { srvOperator in
|
||||
operatorCheckView(srvOperator)
|
||||
|
||||
VStack(alignment: .leading, spacing: 20) {
|
||||
Text("Private chats, groups and your contacts are not accessible to server operators.")
|
||||
.lineSpacing(2)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
Text("""
|
||||
By using SimpleX Chat you agree to:
|
||||
- send only legal content in public groups.
|
||||
- respect other users – no spam.
|
||||
""")
|
||||
.lineSpacing(2)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
|
||||
Button("Privacy policy and conditions of use.") {
|
||||
sheetItem = .showConditions
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
VStack {
|
||||
Text("SimpleX Chat and Flux made an agreement to include Flux-operated servers into the app.").padding(.bottom, 8)
|
||||
Text("You can configure servers via settings.")
|
||||
}
|
||||
.font(.footnote)
|
||||
.multilineTextAlignment(.center)
|
||||
.frame(maxWidth: .infinity, alignment: .center)
|
||||
.padding(.horizontal, 16)
|
||||
|
||||
.padding(.horizontal, 4)
|
||||
|
||||
Spacer()
|
||||
|
||||
let reviewForOperators = selectedOperators.filter { !$0.conditionsAcceptance.conditionsAccepted }
|
||||
let canReviewLater = reviewForOperators.allSatisfy { $0.conditionsAcceptance.usageAllowed }
|
||||
let currEnabledOperatorIds = Set(serverOperators.filter { $0.enabled }.map { $0.operatorId })
|
||||
|
||||
VStack(spacing: 8) {
|
||||
if !reviewForOperators.isEmpty {
|
||||
reviewConditionsButton()
|
||||
} else if selectedOperatorIds != currEnabledOperatorIds && !selectedOperatorIds.isEmpty {
|
||||
setOperatorsButton()
|
||||
} else {
|
||||
continueButton()
|
||||
|
||||
VStack(spacing: 12) {
|
||||
acceptConditionsButton()
|
||||
|
||||
Button("Configure server operators") {
|
||||
sheetItem = .configureOperators
|
||||
}
|
||||
if onboarding {
|
||||
Group {
|
||||
if reviewForOperators.isEmpty {
|
||||
Button("Conditions of use") {
|
||||
sheetItem = .showConditions
|
||||
}
|
||||
} else {
|
||||
Text("Conditions of use")
|
||||
.foregroundColor(.clear)
|
||||
}
|
||||
}
|
||||
.font(.system(size: 17, weight: .semibold))
|
||||
.frame(minHeight: 40)
|
||||
}
|
||||
}
|
||||
|
||||
if !onboarding && !reviewForOperators.isEmpty {
|
||||
VStack(spacing: 8) {
|
||||
reviewLaterButton()
|
||||
(
|
||||
Text("Conditions will be accepted for enabled operators after 30 days.")
|
||||
+ textSpace
|
||||
+ Text("You can configure operators in Network & servers settings.")
|
||||
)
|
||||
.multilineTextAlignment(.center)
|
||||
.font(.footnote)
|
||||
.padding(.horizontal, 32)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.disabled(!canReviewLater)
|
||||
.padding(.bottom)
|
||||
.frame(minHeight: 40)
|
||||
}
|
||||
}
|
||||
.padding(25)
|
||||
.frame(minHeight: g.size.height)
|
||||
}
|
||||
.onAppear {
|
||||
|
@ -158,130 +119,28 @@ struct ChooseServerOperators: View {
|
|||
}
|
||||
.sheet(item: $sheetItem) { item in
|
||||
switch item {
|
||||
case .showInfo:
|
||||
ChooseServerOperatorsInfoView()
|
||||
case .showConditions:
|
||||
UsageConditionsView(
|
||||
currUserServers: Binding.constant([]),
|
||||
userServers: Binding.constant([])
|
||||
)
|
||||
.modifier(ThemedBackground(grouped: true))
|
||||
SimpleConditionsView()
|
||||
.modifier(ThemedBackground(grouped: true))
|
||||
case .configureOperators:
|
||||
ChooseServerOperators(serverOperators: serverOperators, selectedOperatorIds: $selectedOperatorIds)
|
||||
.modifier(ThemedBackground())
|
||||
}
|
||||
}
|
||||
.frame(maxHeight: .infinity, alignment: .top)
|
||||
if #available(iOS 16.4, *) {
|
||||
v.scrollBounceBehavior(.basedOnSize)
|
||||
} else {
|
||||
v
|
||||
}
|
||||
}
|
||||
.frame(maxHeight: .infinity, alignment: .top)
|
||||
.padding(onboarding ? 25 : 16)
|
||||
}
|
||||
|
||||
private func infoText() -> some View {
|
||||
Button {
|
||||
sheetItem = .showInfo
|
||||
} label: {
|
||||
Label("How it helps privacy", systemImage: "info.circle")
|
||||
.font(.headline)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder private func operatorCheckView(_ serverOperator: ServerOperator) -> some View {
|
||||
let checked = selectedOperatorIds.contains(serverOperator.operatorId)
|
||||
let icon = checked ? "checkmark.circle.fill" : "circle"
|
||||
let iconColor = checked ? theme.colors.primary : Color(uiColor: .tertiaryLabel).asAnotherColorFromSecondary(theme)
|
||||
HStack(spacing: 10) {
|
||||
Image(serverOperator.largeLogo(colorScheme))
|
||||
.resizable()
|
||||
.scaledToFit()
|
||||
.frame(height: 48)
|
||||
Spacer()
|
||||
Image(systemName: icon)
|
||||
.resizable()
|
||||
.scaledToFit()
|
||||
.frame(width: 26, height: 26)
|
||||
.foregroundColor(iconColor)
|
||||
}
|
||||
.background(theme.colors.background)
|
||||
.padding()
|
||||
.clipShape(RoundedRectangle(cornerRadius: 18))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 18)
|
||||
.stroke(Color(uiColor: .secondarySystemFill), lineWidth: 2)
|
||||
)
|
||||
.padding(.horizontal, 2)
|
||||
.onTapGesture {
|
||||
if checked {
|
||||
selectedOperatorIds.remove(serverOperator.operatorId)
|
||||
} else {
|
||||
selectedOperatorIds.insert(serverOperator.operatorId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func reviewConditionsButton() -> some View {
|
||||
NavigationLink("Review conditions") {
|
||||
reviewConditionsView()
|
||||
.navigationTitle("Conditions of use")
|
||||
.navigationBarTitleDisplayMode(.large)
|
||||
.toolbar { ToolbarItem(placement: .navigationBarTrailing, content: conditionsLinkButton) }
|
||||
.modifier(ThemedBackground(grouped: true))
|
||||
}
|
||||
.buttonStyle(OnboardingButtonStyle(isDisabled: selectedOperatorIds.isEmpty))
|
||||
.disabled(selectedOperatorIds.isEmpty)
|
||||
}
|
||||
|
||||
private func setOperatorsButton() -> some View {
|
||||
notificationsModeNavLinkButton {
|
||||
Button {
|
||||
Task {
|
||||
if let enabledOperators = enabledOperators(serverOperators) {
|
||||
let r = try await setServerOperators(operators: enabledOperators)
|
||||
await MainActor.run {
|
||||
ChatModel.shared.conditions = r
|
||||
continueToNextStep()
|
||||
}
|
||||
} else {
|
||||
await MainActor.run {
|
||||
continueToNextStep()
|
||||
}
|
||||
}
|
||||
}
|
||||
} label: {
|
||||
Text("Update")
|
||||
}
|
||||
.buttonStyle(OnboardingButtonStyle(isDisabled: selectedOperatorIds.isEmpty))
|
||||
.disabled(selectedOperatorIds.isEmpty)
|
||||
}
|
||||
}
|
||||
|
||||
private func continueButton() -> some View {
|
||||
notificationsModeNavLinkButton {
|
||||
Button {
|
||||
continueToNextStep()
|
||||
} label: {
|
||||
Text("Continue")
|
||||
}
|
||||
.buttonStyle(OnboardingButtonStyle(isDisabled: selectedOperatorIds.isEmpty))
|
||||
.disabled(selectedOperatorIds.isEmpty)
|
||||
}
|
||||
}
|
||||
|
||||
private func reviewLaterButton() -> some View {
|
||||
notificationsModeNavLinkButton {
|
||||
Button {
|
||||
continueToNextStep()
|
||||
} label: {
|
||||
Text("Review later")
|
||||
}
|
||||
.buttonStyle(.borderless)
|
||||
}
|
||||
.navigationBarHidden(true) // necessary on iOS 15
|
||||
}
|
||||
|
||||
private func continueToNextStep() {
|
||||
if onboarding {
|
||||
onboardingStageDefault.set(.step4_SetNotificationsMode)
|
||||
notificationsModeNavLinkActive = true
|
||||
} else {
|
||||
dismiss()
|
||||
}
|
||||
onboardingStageDefault.set(.step4_SetNotificationsMode)
|
||||
notificationsModeNavLinkActive = true
|
||||
}
|
||||
|
||||
func notificationsModeNavLinkButton(_ button: @escaping (() -> some View)) -> some View {
|
||||
|
@ -304,25 +163,6 @@ struct ChooseServerOperators: View {
|
|||
.modifier(ThemedBackground())
|
||||
}
|
||||
|
||||
@ViewBuilder private func reviewConditionsView() -> some View {
|
||||
let operatorsWithConditionsAccepted = ChatModel.shared.conditions.serverOperators.filter { $0.conditionsAcceptance.conditionsAccepted }
|
||||
let acceptForOperators = selectedOperators.filter { !$0.conditionsAcceptance.conditionsAccepted }
|
||||
VStack(alignment: .leading, spacing: 20) {
|
||||
if !operatorsWithConditionsAccepted.isEmpty {
|
||||
Text("Conditions are already accepted for these operator(s): **\(operatorsWithConditionsAccepted.map { $0.legalName_ }.joined(separator: ", "))**.")
|
||||
Text("The same conditions will apply to operator(s): **\(acceptForOperators.map { $0.legalName_ }.joined(separator: ", "))**.")
|
||||
} else {
|
||||
Text("Conditions will be accepted for operator(s): **\(acceptForOperators.map { $0.legalName_ }.joined(separator: ", "))**.")
|
||||
}
|
||||
ConditionsTextView()
|
||||
.frame(maxHeight: .infinity)
|
||||
acceptConditionsButton()
|
||||
.padding(.bottom)
|
||||
.padding(.bottom)
|
||||
}
|
||||
.padding(.horizontal, 25)
|
||||
}
|
||||
|
||||
private func acceptConditionsButton() -> some View {
|
||||
notificationsModeNavLinkButton {
|
||||
Button {
|
||||
|
@ -356,9 +196,10 @@ struct ChooseServerOperators: View {
|
|||
}
|
||||
}
|
||||
} label: {
|
||||
Text("Accept conditions")
|
||||
Text("Accept")
|
||||
}
|
||||
.buttonStyle(OnboardingButtonStyle())
|
||||
.buttonStyle(OnboardingButtonStyle(isDisabled: selectedOperatorIds.isEmpty))
|
||||
.disabled(selectedOperatorIds.isEmpty)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -393,6 +234,126 @@ struct ChooseServerOperators: View {
|
|||
}
|
||||
}
|
||||
|
||||
private enum ChooseServerOperatorsSheet: Identifiable {
|
||||
case showInfo
|
||||
|
||||
var id: String {
|
||||
switch self {
|
||||
case .showInfo: return "showInfo"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct ChooseServerOperators: View {
|
||||
@Environment(\.dismiss) var dismiss: DismissAction
|
||||
@Environment(\.colorScheme) var colorScheme: ColorScheme
|
||||
@EnvironmentObject var theme: AppTheme
|
||||
var serverOperators: [ServerOperator]
|
||||
@Binding var selectedOperatorIds: Set<Int64>
|
||||
@State private var sheetItem: ChooseServerOperatorsSheet? = nil
|
||||
|
||||
var body: some View {
|
||||
GeometryReader { g in
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 20) {
|
||||
Text("Server operators")
|
||||
.font(.largeTitle)
|
||||
.bold()
|
||||
.frame(maxWidth: .infinity, alignment: .center)
|
||||
.padding(.top, 25)
|
||||
|
||||
infoText()
|
||||
.frame(maxWidth: .infinity, alignment: .center)
|
||||
|
||||
Spacer()
|
||||
|
||||
ForEach(serverOperators) { srvOperator in
|
||||
operatorCheckView(srvOperator)
|
||||
}
|
||||
VStack {
|
||||
Text("SimpleX Chat and Flux made an agreement to include Flux-operated servers into the app.").padding(.bottom, 8)
|
||||
Text("You can configure servers via settings.")
|
||||
}
|
||||
.font(.footnote)
|
||||
.multilineTextAlignment(.center)
|
||||
.frame(maxWidth: .infinity, alignment: .center)
|
||||
.padding(.horizontal, 16)
|
||||
|
||||
Spacer()
|
||||
|
||||
VStack(spacing: 8) {
|
||||
setOperatorsButton()
|
||||
onboardingButtonPlaceholder()
|
||||
}
|
||||
}
|
||||
.frame(minHeight: g.size.height)
|
||||
}
|
||||
.sheet(item: $sheetItem) { item in
|
||||
switch item {
|
||||
case .showInfo:
|
||||
ChooseServerOperatorsInfoView()
|
||||
}
|
||||
}
|
||||
.frame(maxHeight: .infinity, alignment: .top)
|
||||
}
|
||||
.frame(maxHeight: .infinity, alignment: .top)
|
||||
.padding(25)
|
||||
.interactiveDismissDisabled(selectedOperatorIds.isEmpty)
|
||||
}
|
||||
|
||||
private func infoText() -> some View {
|
||||
Button {
|
||||
sheetItem = .showInfo
|
||||
} label: {
|
||||
Label("How it helps privacy", systemImage: "info.circle")
|
||||
.font(.headline)
|
||||
}
|
||||
}
|
||||
|
||||
private func operatorCheckView(_ serverOperator: ServerOperator) -> some View {
|
||||
let checked = selectedOperatorIds.contains(serverOperator.operatorId)
|
||||
let icon = checked ? "checkmark.circle.fill" : "circle"
|
||||
let iconColor = checked ? theme.colors.primary : Color(uiColor: .tertiaryLabel).asAnotherColorFromSecondary(theme)
|
||||
return HStack(spacing: 10) {
|
||||
Image(serverOperator.largeLogo(colorScheme))
|
||||
.resizable()
|
||||
.scaledToFit()
|
||||
.frame(height: 48)
|
||||
Spacer()
|
||||
Image(systemName: icon)
|
||||
.resizable()
|
||||
.scaledToFit()
|
||||
.frame(width: 26, height: 26)
|
||||
.foregroundColor(iconColor)
|
||||
}
|
||||
.background(theme.colors.background)
|
||||
.padding()
|
||||
.clipShape(RoundedRectangle(cornerRadius: 18))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 18)
|
||||
.stroke(Color(uiColor: .secondarySystemFill), lineWidth: 2)
|
||||
)
|
||||
.padding(.horizontal, 2)
|
||||
.onTapGesture {
|
||||
if checked {
|
||||
selectedOperatorIds.remove(serverOperator.operatorId)
|
||||
} else {
|
||||
selectedOperatorIds.insert(serverOperator.operatorId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func setOperatorsButton() -> some View {
|
||||
Button {
|
||||
dismiss()
|
||||
} label: {
|
||||
Text("OK")
|
||||
}
|
||||
.buttonStyle(OnboardingButtonStyle(isDisabled: selectedOperatorIds.isEmpty))
|
||||
.disabled(selectedOperatorIds.isEmpty)
|
||||
}
|
||||
}
|
||||
|
||||
let operatorsPostLink = URL(string: "https://simplex.chat/blog/20241125-servers-operated-by-flux-true-privacy-and-decentralization-for-all-users.html")!
|
||||
|
||||
struct ChooseServerOperatorsInfoView: View {
|
||||
|
@ -447,5 +408,5 @@ struct ChooseServerOperatorsInfoView: View {
|
|||
}
|
||||
|
||||
#Preview {
|
||||
ChooseServerOperators(onboarding: true)
|
||||
OnboardingConditionsView()
|
||||
}
|
||||
|
|
|
@ -62,8 +62,7 @@ struct CreateProfile: View {
|
|||
.frame(height: 20)
|
||||
} footer: {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("Your profile, contacts and delivered messages are stored on your device.")
|
||||
Text("The profile is only shared with your contacts.")
|
||||
Text("Your profile is stored on your device and only shared with your contacts.")
|
||||
}
|
||||
.foregroundColor(theme.colors.secondary)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
|
@ -118,25 +117,22 @@ struct CreateFirstProfile: View {
|
|||
@State private var nextStepNavLinkActive = false
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 20) {
|
||||
VStack(alignment: .center, spacing: 20) {
|
||||
Text("Create your profile")
|
||||
let v = VStack(alignment: .leading, spacing: 16) {
|
||||
VStack(alignment: .center, spacing: 16) {
|
||||
Text("Create profile")
|
||||
.font(.largeTitle)
|
||||
.bold()
|
||||
.multilineTextAlignment(.center)
|
||||
|
||||
Text("Your profile, contacts and delivered messages are stored on your device.")
|
||||
.font(.callout)
|
||||
.foregroundColor(theme.colors.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
|
||||
Text("The profile is only shared with your contacts.")
|
||||
|
||||
Text("Your profile is stored on your device and only shared with your contacts.")
|
||||
.font(.callout)
|
||||
.foregroundColor(theme.colors.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
}
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
.frame(maxWidth: .infinity) // Ensures it takes up the full width
|
||||
.padding(.horizontal, 10)
|
||||
.onTapGesture { focusDisplayName = false }
|
||||
|
||||
HStack {
|
||||
let name = displayName.trimmingCharacters(in: .whitespaces)
|
||||
|
@ -145,6 +141,7 @@ struct CreateFirstProfile: View {
|
|||
TextField("Enter your name…", text: $displayName)
|
||||
.focused($focusDisplayName)
|
||||
.padding(.horizontal)
|
||||
.padding(.trailing, 20)
|
||||
.padding(.vertical, 10)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 10, style: .continuous)
|
||||
|
@ -173,13 +170,23 @@ struct CreateFirstProfile: View {
|
|||
}
|
||||
}
|
||||
.onAppear() {
|
||||
focusDisplayName = true
|
||||
setLastVersionDefault()
|
||||
if #available(iOS 16, *) {
|
||||
focusDisplayName = true
|
||||
} else {
|
||||
// it does not work before animation completes on iOS 15
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
|
||||
focusDisplayName = true
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 25)
|
||||
.padding(.top, 10)
|
||||
.padding(.bottom, 25)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
if #available(iOS 16, *) {
|
||||
return v.padding(.top, 10)
|
||||
} else {
|
||||
return v.padding(.top, 75).ignoresSafeArea(.all, edges: .top)
|
||||
}
|
||||
}
|
||||
|
||||
func createProfileButton() -> some View {
|
||||
|
@ -207,7 +214,7 @@ struct CreateFirstProfile: View {
|
|||
}
|
||||
|
||||
private func nextStepDestinationView() -> some View {
|
||||
ChooseServerOperators(onboarding: true)
|
||||
OnboardingConditionsView()
|
||||
.navigationBarBackButtonHidden(true)
|
||||
.modifier(ThemedBackground())
|
||||
}
|
||||
|
@ -236,15 +243,15 @@ private func showCreateProfileAlert(
|
|||
_ error: Error
|
||||
) {
|
||||
let m = ChatModel.shared
|
||||
switch error as? ChatResponse {
|
||||
case .chatCmdError(_, .errorStore(.duplicateName)),
|
||||
.chatCmdError(_, .error(.userExists)):
|
||||
switch error as? ChatError {
|
||||
case .errorStore(.duplicateName),
|
||||
.error(.userExists):
|
||||
if m.currentUser == nil {
|
||||
AlertManager.shared.showAlert(duplicateUserAlert)
|
||||
} else {
|
||||
showAlert(.duplicateUserError)
|
||||
}
|
||||
case .chatCmdError(_, .error(.invalidDisplayName)):
|
||||
case .error(.invalidDisplayName):
|
||||
if m.currentUser == nil {
|
||||
AlertManager.shared.showAlert(invalidDisplayNameAlert)
|
||||
} else {
|
||||
|
|
|
@ -31,7 +31,7 @@ struct CreateSimpleXAddress: View {
|
|||
Spacer()
|
||||
|
||||
if let userAddress = m.userAddress {
|
||||
SimpleXLinkQRCode(uri: userAddress.connReqContact)
|
||||
SimpleXCreatedLinkQRCode(link: userAddress.connLinkContact, short: Binding.constant(false))
|
||||
.frame(maxHeight: g.size.width)
|
||||
shareQRCodeButton(userAddress)
|
||||
.frame(maxWidth: .infinity)
|
||||
|
@ -77,9 +77,9 @@ struct CreateSimpleXAddress: View {
|
|||
progressIndicator = true
|
||||
Task {
|
||||
do {
|
||||
let connReqContact = try await apiCreateUserAddress()
|
||||
let connLinkContact = try await apiCreateUserAddress(short: false)
|
||||
DispatchQueue.main.async {
|
||||
m.userAddress = UserContactLink(connReqContact: connReqContact)
|
||||
m.userAddress = UserContactLink(connLinkContact: connLinkContact)
|
||||
}
|
||||
await MainActor.run { progressIndicator = false }
|
||||
} catch let error {
|
||||
|
@ -121,7 +121,7 @@ struct CreateSimpleXAddress: View {
|
|||
|
||||
private func shareQRCodeButton(_ userAddress: UserContactLink) -> some View {
|
||||
Button {
|
||||
showShareSheet(items: [simplexChatLink(userAddress.connReqContact)])
|
||||
showShareSheet(items: [simplexChatLink(userAddress.connLinkContact.simplexChatUri(short: false))])
|
||||
} label: {
|
||||
Label("Share", systemImage: "square.and.arrow.up")
|
||||
}
|
||||
|
@ -189,7 +189,7 @@ struct SendAddressMailView: View {
|
|||
let messageBody = String(format: NSLocalizedString("""
|
||||
<p>Hi!</p>
|
||||
<p><a href="%@">Connect to me via SimpleX Chat</a></p>
|
||||
""", comment: "email text"), simplexChatLink(userAddress.connReqContact))
|
||||
""", comment: "email text"), simplexChatLink(userAddress.connLinkContact.simplexChatUri(short: false)))
|
||||
MailView(
|
||||
isShowing: self.$showMailView,
|
||||
result: $mailViewResult,
|
||||
|
|
|
@ -23,7 +23,7 @@ struct OnboardingView: View {
|
|||
case .step3_CreateSimpleXAddress: // deprecated
|
||||
CreateSimpleXAddress()
|
||||
case .step3_ChooseServerOperators:
|
||||
ChooseServerOperators(onboarding: true)
|
||||
OnboardingConditionsView()
|
||||
.navigationBarBackButtonHidden(true)
|
||||
.modifier(ThemedBackground())
|
||||
case .step4_SetNotificationsMode:
|
||||
|
@ -44,7 +44,7 @@ enum OnboardingStage: String, Identifiable {
|
|||
case step1_SimpleXInfo
|
||||
case step2_CreateProfile // deprecated
|
||||
case step3_CreateSimpleXAddress // deprecated
|
||||
case step3_ChooseServerOperators
|
||||
case step3_ChooseServerOperators // changed to simplified conditions
|
||||
case step4_SetNotificationsMode
|
||||
case onboardingComplete
|
||||
|
||||
|
|
|
@ -17,7 +17,7 @@ struct SetNotificationsMode: View {
|
|||
|
||||
var body: some View {
|
||||
GeometryReader { g in
|
||||
ScrollView {
|
||||
let v = ScrollView {
|
||||
VStack(alignment: .center, spacing: 20) {
|
||||
Text("Push notifications")
|
||||
.font(.largeTitle)
|
||||
|
@ -57,11 +57,17 @@ struct SetNotificationsMode: View {
|
|||
.padding(25)
|
||||
.frame(minHeight: g.size.height)
|
||||
}
|
||||
if #available(iOS 16.4, *) {
|
||||
v.scrollBounceBehavior(.basedOnSize)
|
||||
} else {
|
||||
v
|
||||
}
|
||||
}
|
||||
.frame(maxHeight: .infinity)
|
||||
.sheet(isPresented: $showInfo) {
|
||||
NotificationsInfoView()
|
||||
}
|
||||
.navigationBarHidden(true) // necessary on iOS 15
|
||||
}
|
||||
|
||||
private func setNotificationsMode(_ token: DeviceToken, _ mode: NotificationsMode) {
|
||||
|
|
|
@ -18,7 +18,7 @@ struct SimpleXInfo: View {
|
|||
|
||||
var body: some View {
|
||||
GeometryReader { g in
|
||||
ScrollView {
|
||||
let v = ScrollView {
|
||||
VStack(alignment: .leading) {
|
||||
VStack(alignment: .center, spacing: 10) {
|
||||
Image(colorScheme == .light ? "logo" : "logo-light")
|
||||
|
@ -36,7 +36,7 @@ struct SimpleXInfo: View {
|
|||
.font(.headline)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Spacer()
|
||||
|
||||
VStack(alignment: .leading) {
|
||||
|
@ -66,6 +66,9 @@ struct SimpleXInfo: View {
|
|||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 25)
|
||||
.padding(.top, 75)
|
||||
.padding(.bottom, 25)
|
||||
.frame(minHeight: g.size.height)
|
||||
}
|
||||
.sheet(isPresented: Binding(
|
||||
|
@ -88,11 +91,17 @@ struct SimpleXInfo: View {
|
|||
createProfileNavLinkActive: $createProfileNavLinkActive
|
||||
)
|
||||
}
|
||||
if #available(iOS 16.4, *) {
|
||||
v.scrollBounceBehavior(.basedOnSize)
|
||||
} else {
|
||||
v
|
||||
}
|
||||
}
|
||||
.onAppear() {
|
||||
setLastVersionDefault()
|
||||
}
|
||||
.frame(maxHeight: .infinity)
|
||||
.padding(.horizontal, 25)
|
||||
.padding(.top, 75)
|
||||
.padding(.bottom, 25)
|
||||
.navigationBarHidden(true) // necessary on iOS 15
|
||||
}
|
||||
|
||||
private func onboardingInfoRow(_ image: String, _ title: LocalizedStringKey, _ text: LocalizedStringKey, width: CGFloat) -> some View {
|
||||
|
@ -126,6 +135,7 @@ struct SimpleXInfo: View {
|
|||
|
||||
NavigationLink(isActive: $createProfileNavLinkActive) {
|
||||
CreateFirstProfile()
|
||||
.modifier(ThemedBackground())
|
||||
} label: {
|
||||
EmptyView()
|
||||
}
|
||||
|
@ -137,6 +147,8 @@ struct SimpleXInfo: View {
|
|||
|
||||
let textSpace = Text(verbatim: " ")
|
||||
|
||||
let textNewLine = Text(verbatim: "\n")
|
||||
|
||||
struct SimpleXInfo_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
SimpleXInfo(onboarding: true)
|
||||
|
|
|
@ -539,7 +539,46 @@ private let versionDescriptions: [VersionDescription] = [
|
|||
description: "Delivered even when Apple drops them."
|
||||
)),
|
||||
]
|
||||
)
|
||||
),
|
||||
VersionDescription(
|
||||
version: "v6.3",
|
||||
post: URL(string: "https://simplex.chat/blog/20250308-simplex-chat-v6-3-new-user-experience-safety-in-public-groups.html"),
|
||||
features: [
|
||||
.feature(Description(
|
||||
icon: "at",
|
||||
title: "Mention members 👋",
|
||||
description: "Get notified when mentioned."
|
||||
)),
|
||||
.feature(Description(
|
||||
icon: "flag",
|
||||
title: "Send private reports",
|
||||
description: "Help admins moderating their groups."
|
||||
)),
|
||||
.feature(Description(
|
||||
icon: "list.bullet",
|
||||
title: "Organize chats into lists",
|
||||
description: "Don't miss important messages."
|
||||
)),
|
||||
.feature(Description(
|
||||
icon: nil,
|
||||
title: "Better privacy and security",
|
||||
description: nil,
|
||||
subfeatures: [
|
||||
("eye.slash", "Private media file names."),
|
||||
("trash", "Set message expiration in chats.")
|
||||
]
|
||||
)),
|
||||
.feature(Description(
|
||||
icon: nil,
|
||||
title: "Better groups performance",
|
||||
description: nil,
|
||||
subfeatures: [
|
||||
("bolt", "Faster sending messages."),
|
||||
("person.2.slash", "Faster deletion of groups.")
|
||||
]
|
||||
)),
|
||||
]
|
||||
),
|
||||
]
|
||||
|
||||
private let lastVersion = versionDescriptions.last!.version
|
||||
|
@ -555,8 +594,6 @@ func shouldShowWhatsNew() -> Bool {
|
|||
}
|
||||
|
||||
fileprivate struct NewOperatorsView: View {
|
||||
@State private var showOperatorsSheet = false
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading) {
|
||||
Image((operatorsInfo[.flux] ?? ServerOperator.dummyOperatorInfo).largeLogo)
|
||||
|
@ -567,16 +604,7 @@ fileprivate struct NewOperatorsView: View {
|
|||
.multilineTextAlignment(.leading)
|
||||
.lineLimit(10)
|
||||
HStack {
|
||||
Button("Enable Flux") {
|
||||
showOperatorsSheet = true
|
||||
}
|
||||
Text("for better metadata privacy.")
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showOperatorsSheet) {
|
||||
NavigationView {
|
||||
ChooseServerOperators(onboarding: false)
|
||||
.modifier(ThemedBackground())
|
||||
Text("Enable Flux in Network & servers settings for better metadata privacy.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -456,12 +456,12 @@ struct ConnectDesktopView: View {
|
|||
}
|
||||
} catch let e {
|
||||
await MainActor.run {
|
||||
switch e as? ChatResponse {
|
||||
case .chatCmdError(_, .errorRemoteCtrl(.badInvitation)): alert = .badInvitationError
|
||||
case .chatCmdError(_, .error(.commandError)): alert = .badInvitationError
|
||||
case let .chatCmdError(_, .errorRemoteCtrl(.badVersion(v))): alert = .badVersionError(version: v)
|
||||
case .chatCmdError(_, .errorAgent(.RCP(.version))): alert = .badVersionError(version: nil)
|
||||
case .chatCmdError(_, .errorAgent(.RCP(.ctrlAuth))): alert = .desktopDisconnectedError
|
||||
switch e as? ChatError {
|
||||
case .errorRemoteCtrl(.badInvitation): alert = .badInvitationError
|
||||
case .error(.commandError): alert = .badInvitationError
|
||||
case let .errorRemoteCtrl(.badVersion(v)): alert = .badVersionError(version: v)
|
||||
case .errorAgent(.RCP(.version)): alert = .badVersionError(version: nil)
|
||||
case .errorAgent(.RCP(.ctrlAuth)): alert = .desktopDisconnectedError
|
||||
default: errorAlert(e)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -18,7 +18,9 @@ struct TerminalView: View {
|
|||
@AppStorage(DEFAULT_PERFORM_LA) private var prefPerformLA = false
|
||||
@AppStorage(DEFAULT_DEVELOPER_TOOLS) private var developerTools = false
|
||||
@State var composeState: ComposeState = ComposeState()
|
||||
@State var selectedRange = NSRange()
|
||||
@State private var keyboardVisible = false
|
||||
@State private var keyboardHiddenDate = Date.now
|
||||
@State var authorized = !UserDefaults.standard.bool(forKey: DEFAULT_PERFORM_LA)
|
||||
@State private var terminalItem: TerminalItem?
|
||||
@State private var scrolled = false
|
||||
|
@ -96,10 +98,12 @@ struct TerminalView: View {
|
|||
|
||||
SendMessageView(
|
||||
composeState: $composeState,
|
||||
selectedRange: $selectedRange,
|
||||
sendMessage: { _ in consoleSendMessage() },
|
||||
showVoiceMessageButton: false,
|
||||
onMediaAdded: { _ in },
|
||||
keyboardVisible: $keyboardVisible
|
||||
keyboardVisible: $keyboardVisible,
|
||||
keyboardHiddenDate: $keyboardHiddenDate
|
||||
)
|
||||
.padding(.horizontal, 12)
|
||||
}
|
||||
|
@ -141,18 +145,18 @@ struct TerminalView: View {
|
|||
}
|
||||
|
||||
func consoleSendMessage() {
|
||||
let cmd = ChatCommand.string(composeState.message)
|
||||
if composeState.message.starts(with: "/sql") && (!prefPerformLA || !developerTools) {
|
||||
let resp = ChatResponse.chatCmdError(user_: nil, chatError: ChatError.error(errorType: ChatErrorType.commandError(message: "Failed reading: empty")))
|
||||
let resp: APIResult<ChatResponse2> = APIResult.error(ChatError.error(errorType: ChatErrorType.commandError(message: "Failed reading: empty")))
|
||||
Task {
|
||||
await TerminalItems.shared.addCommand(.now, cmd, resp)
|
||||
await TerminalItems.shared.addCommand(.now, .string(composeState.message), resp)
|
||||
}
|
||||
} else {
|
||||
let cmd = composeState.message
|
||||
DispatchQueue.global().async {
|
||||
Task {
|
||||
composeState.inProgress = true
|
||||
_ = await chatSendCmd(cmd)
|
||||
composeState.inProgress = false
|
||||
await MainActor.run { composeState.inProgress = true }
|
||||
await sendTerminalCmd(cmd)
|
||||
await MainActor.run { composeState.inProgress = false }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -160,12 +164,38 @@ struct TerminalView: View {
|
|||
}
|
||||
}
|
||||
|
||||
func sendTerminalCmd(_ cmd: String) async {
|
||||
let start: Date = .now
|
||||
await withCheckedContinuation { (cont: CheckedContinuation<Void, Never>) in
|
||||
let d = sendSimpleXCmdStr(cmd)
|
||||
Task {
|
||||
guard let d else {
|
||||
await TerminalItems.shared.addCommand(start, ChatCommand.string(cmd), APIResult<ChatResponse2>.error(.invalidJSON(json: nil)))
|
||||
return
|
||||
}
|
||||
let r0: APIResult<ChatResponse0> = decodeAPIResult(d)
|
||||
guard case .invalid = r0 else {
|
||||
await TerminalItems.shared.addCommand(start, .string(cmd), r0)
|
||||
return
|
||||
}
|
||||
let r1: APIResult<ChatResponse1> = decodeAPIResult(d)
|
||||
guard case .invalid = r1 else {
|
||||
await TerminalItems.shared.addCommand(start, .string(cmd), r1)
|
||||
return
|
||||
}
|
||||
let r2: APIResult<ChatResponse2> = decodeAPIResult(d)
|
||||
await TerminalItems.shared.addCommand(start, .string(cmd), r2)
|
||||
}
|
||||
cont.resume(returning: ())
|
||||
}
|
||||
}
|
||||
|
||||
struct TerminalView_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
let chatModel = ChatModel()
|
||||
chatModel.terminalItems = [
|
||||
.resp(.now, ChatResponse.response(type: "contactSubscribed", json: "{}")),
|
||||
.resp(.now, ChatResponse.response(type: "newChatItems", json: "{}"))
|
||||
.err(.now, APIResult<ChatResponse2>.invalid(type: "contactSubscribed", json: "{}".data(using: .utf8)!).unexpected),
|
||||
.err(.now, APIResult<ChatResponse2>.invalid(type: "newChatItems", json: "{}".data(using: .utf8)!).unexpected)
|
||||
]
|
||||
return NavigationView {
|
||||
TerminalView()
|
||||
|
|
|
@ -367,13 +367,13 @@ struct ChatThemePreview: View {
|
|||
let alice = ChatItem.getSample(1, CIDirection.directRcv, Date.now, NSLocalizedString("Good afternoon!", comment: "message preview"))
|
||||
let bob = ChatItem.getSample(2, CIDirection.directSnd, Date.now, NSLocalizedString("Good morning!", comment: "message preview"), quotedItem: CIQuote.getSample(alice.id, alice.meta.itemTs, alice.content.text, chatDir: alice.chatDir))
|
||||
HStack {
|
||||
ChatItemView(chat: Chat.sampleData, chatItem: alice)
|
||||
ChatItemView(chat: Chat.sampleData, chatItem: alice, scrollToItemId: { _ in })
|
||||
.modifier(ChatItemClipped(alice, tailVisible: true))
|
||||
Spacer()
|
||||
}
|
||||
HStack {
|
||||
Spacer()
|
||||
ChatItemView(chat: Chat.sampleData, chatItem: bob)
|
||||
ChatItemView(chat: Chat.sampleData, chatItem: bob, scrollToItemId: { _ in })
|
||||
.modifier(ChatItemClipped(bob, tailVisible: true))
|
||||
.frame(alignment: .trailing)
|
||||
}
|
||||
|
|
|
@ -54,6 +54,13 @@ struct DeveloperView: View {
|
|||
settingsRow("internaldrive", color: theme.colors.secondary) {
|
||||
Toggle("Confirm database upgrades", isOn: $confirmDatabaseUpgrades)
|
||||
}
|
||||
NavigationLink {
|
||||
StorageView()
|
||||
.navigationTitle("Storage")
|
||||
.navigationBarTitleDisplayMode(.large)
|
||||
} label: {
|
||||
settingsRow("internaldrive", color: theme.colors.secondary) { Text("Storage") }
|
||||
}
|
||||
} header: {
|
||||
Text("Developer options")
|
||||
}
|
||||
|
|
|
@ -19,7 +19,7 @@ struct MarkdownHelp: View {
|
|||
mdFormat("_italic_", Text("italic").italic())
|
||||
mdFormat("~strike~", Text("strike").strikethrough())
|
||||
mdFormat("`a + b`", Text("`a + b`").font(.body.monospaced()))
|
||||
mdFormat("!1 colored!", Text("colored").foregroundColor(.red) + Text(" (") + color("1", .red) + color("2", .green) + color("3", .blue) + color("4", .yellow) + color("5", .cyan) + Text("6").foregroundColor(.purple) + Text(")"))
|
||||
mdFormat("!1 colored!", Text("colored").foregroundColor(.red) + Text(verbatim: " (") + color("1", .red) + color("2", .green) + color("3", .blue) + color("4", .yellow) + color("5", .cyan) + Text("6").foregroundColor(.purple) + Text(verbatim: ")"))
|
||||
(
|
||||
mdFormat("#secret#", Text("secret")
|
||||
.foregroundColor(.clear)
|
||||
|
@ -39,7 +39,7 @@ private func mdFormat(_ format: LocalizedStringKey, _ example: Text) -> some Vie
|
|||
}
|
||||
|
||||
private func color(_ s: String, _ c: Color) -> Text {
|
||||
Text(s).foregroundColor(c) + Text(", ")
|
||||
Text(s).foregroundColor(c) + Text(verbatim: ", ")
|
||||
}
|
||||
|
||||
struct MarkdownHelp_Previews: PreviewProvider {
|
||||
|
|
|
@ -208,6 +208,19 @@ struct AdvancedNetworkSettings: View {
|
|||
}
|
||||
}
|
||||
|
||||
Section {
|
||||
Picker("Use web port", selection: $netCfg.smpWebPortServers) {
|
||||
ForEach(SMPWebPortServers.allCases, id: \.self) { Text($0.text) }
|
||||
}
|
||||
.frame(height: 36)
|
||||
} header: {
|
||||
Text("TCP port for messaging")
|
||||
} footer: {
|
||||
netCfg.smpWebPortServers == .preset
|
||||
? Text("Use TCP port 443 for preset servers only.")
|
||||
: Text("Use TCP port \(netCfg.smpWebPortServers == .all ? "443" : "5223") when no port is specified.")
|
||||
}
|
||||
|
||||
Section("TCP connection") {
|
||||
timeoutSettingPicker("TCP connection timeout", selection: $netCfg.tcpConnectTimeout, values: [10_000000, 15_000000, 20_000000, 30_000000, 45_000000, 60_000000, 90_000000], label: secondsLabel)
|
||||
timeoutSettingPicker("Protocol timeout", selection: $netCfg.tcpTimeout, values: [5_000000, 7_000000, 10_000000, 15_000000, 20_000000, 30_000000], label: secondsLabel)
|
||||
|
@ -360,8 +373,8 @@ struct AdvancedNetworkSettings: View {
|
|||
let userMode = Text("A separate TCP connection will be used **for each chat profile you have in the app**.")
|
||||
return switch mode {
|
||||
case .user: userMode
|
||||
case .session: userMode + Text("\n") + Text("New SOCKS credentials will be used every time you start the app.")
|
||||
case .server: userMode + Text("\n") + Text("New SOCKS credentials will be used for each server.")
|
||||
case .session: userMode + textNewLine + Text("New SOCKS credentials will be used every time you start the app.")
|
||||
case .server: userMode + textNewLine + Text("New SOCKS credentials will be used for each server.")
|
||||
case .entity: Text("A separate TCP connection will be used **for each contact and group member**.\n**Please note**: if you have many connections, your battery and traffic consumption can be substantially higher and some connections may fail.")
|
||||
}
|
||||
}
|
||||
|
|
|
@ -238,22 +238,23 @@ struct UsageConditionsView: View {
|
|||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 20) {
|
||||
HStack {
|
||||
Text("Conditions of use").font(.largeTitle).bold()
|
||||
Spacer()
|
||||
conditionsLinkButton()
|
||||
}
|
||||
.padding(.top)
|
||||
.padding(.top)
|
||||
|
||||
switch ChatModel.shared.conditions.conditionsAction {
|
||||
|
||||
case .none:
|
||||
regularConditionsHeader()
|
||||
.padding(.top)
|
||||
.padding(.top)
|
||||
ConditionsTextView()
|
||||
.padding(.bottom)
|
||||
.padding(.bottom)
|
||||
|
||||
case let .review(operators, deadline, _):
|
||||
HStack {
|
||||
Text("Updated conditions").font(.largeTitle).bold()
|
||||
}
|
||||
.padding(.top)
|
||||
.padding(.top)
|
||||
|
||||
Text("Conditions will be accepted for the operator(s): **\(operators.map { $0.legalName_ }.joined(separator: ", "))**.")
|
||||
ConditionsTextView()
|
||||
VStack(spacing: 8) {
|
||||
|
@ -265,6 +266,10 @@ struct UsageConditionsView: View {
|
|||
.multilineTextAlignment(.center)
|
||||
.frame(maxWidth: .infinity, alignment: .center)
|
||||
.padding(.horizontal, 32)
|
||||
conditionsDiffButton(.footnote)
|
||||
} else {
|
||||
conditionsDiffButton()
|
||||
.padding(.top)
|
||||
}
|
||||
}
|
||||
.padding(.bottom)
|
||||
|
@ -272,6 +277,9 @@ struct UsageConditionsView: View {
|
|||
|
||||
|
||||
case let .accepted(operators):
|
||||
regularConditionsHeader()
|
||||
.padding(.top)
|
||||
.padding(.top)
|
||||
Text("Conditions are accepted for the operator(s): **\(operators.map { $0.legalName_ }.joined(separator: ", "))**.")
|
||||
ConditionsTextView()
|
||||
.padding(.bottom)
|
||||
|
@ -312,6 +320,43 @@ struct UsageConditionsView: View {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder private func conditionsDiffButton(_ font: Font? = nil) -> some View {
|
||||
let commit = ChatModel.shared.conditions.currentConditions.conditionsCommit
|
||||
if let commitUrl = URL(string: "https://github.com/simplex-chat/simplex-chat/commit/\(commit)") {
|
||||
Link(destination: commitUrl) {
|
||||
HStack {
|
||||
Text("Open changes")
|
||||
Image(systemName: "arrow.up.right.circle")
|
||||
}
|
||||
.font(font)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func regularConditionsHeader() -> some View {
|
||||
HStack {
|
||||
Text("Conditions of use").font(.largeTitle).bold()
|
||||
Spacer()
|
||||
conditionsLinkButton()
|
||||
}
|
||||
}
|
||||
|
||||
struct SimpleConditionsView: View {
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 20) {
|
||||
regularConditionsHeader()
|
||||
.padding(.top)
|
||||
.padding(.top)
|
||||
ConditionsTextView()
|
||||
.padding(.bottom)
|
||||
.padding(.bottom)
|
||||
}
|
||||
.padding(.horizontal, 25)
|
||||
.frame(maxHeight: .infinity)
|
||||
}
|
||||
}
|
||||
|
||||
func validateServers_(_ userServers: Binding<[UserOperatorServers]>, _ serverErrors: Binding<[UserServersError]>) {
|
||||
|
|
|
@ -38,9 +38,9 @@ struct OperatorView: View {
|
|||
.allowsHitTesting(!testing)
|
||||
}
|
||||
|
||||
@ViewBuilder private func operatorView() -> some View {
|
||||
private func operatorView() -> some View {
|
||||
let duplicateHosts = findDuplicateHosts(serverErrors)
|
||||
VStack {
|
||||
return VStack {
|
||||
List {
|
||||
Section {
|
||||
infoViewLink()
|
||||
|
@ -53,7 +53,7 @@ struct OperatorView: View {
|
|||
ServersErrorView(errStr: errStr)
|
||||
} else {
|
||||
switch (userServers[operatorIndex].operator_.conditionsAcceptance) {
|
||||
case let .accepted(acceptedAt):
|
||||
case let .accepted(acceptedAt, _):
|
||||
if let acceptedAt = acceptedAt {
|
||||
Text("Conditions accepted on: \(conditionsTimestamp(acceptedAt)).")
|
||||
.foregroundColor(theme.colors.secondary)
|
||||
|
@ -500,14 +500,14 @@ struct SingleOperatorUsageConditionsView: View {
|
|||
}
|
||||
}
|
||||
|
||||
@ViewBuilder private func acceptConditionsButton() -> some View {
|
||||
private func acceptConditionsButton() -> some View {
|
||||
let operatorIds = ChatModel.shared.conditions.serverOperators
|
||||
.filter {
|
||||
$0.operatorId == userServers[operatorIndex].operator_.operatorId || // Opened operator
|
||||
($0.enabled && !$0.conditionsAcceptance.conditionsAccepted) // Other enabled operators with conditions not accepted
|
||||
}
|
||||
.map { $0.operatorId }
|
||||
Button {
|
||||
return Button {
|
||||
acceptForOperators(operatorIds, operatorIndex)
|
||||
} label: {
|
||||
Text("Accept conditions")
|
||||
|
|
|
@ -38,9 +38,9 @@ struct YourServersView: View {
|
|||
.allowsHitTesting(!testing)
|
||||
}
|
||||
|
||||
@ViewBuilder private func yourServersView() -> some View {
|
||||
private func yourServersView() -> some View {
|
||||
let duplicateHosts = findDuplicateHosts(serverErrors)
|
||||
List {
|
||||
return List {
|
||||
if !userServers[operatorIndex].smpServers.filter({ !$0.deleted }).isEmpty {
|
||||
Section {
|
||||
ForEach($userServers[operatorIndex].smpServers) { srv in
|
||||
|
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue