diff --git a/.github/actions/prepare-build/action.yml b/.github/actions/prepare-build/action.yml
index ce75b7a57c..d64d579520 100644
--- a/.github/actions/prepare-build/action.yml
+++ b/.github/actions/prepare-build/action.yml
@@ -19,7 +19,7 @@ inputs:
description: "Cache path"
cabal_ver:
required: false
- default: 3.10.1.0
+ default: 3.10.2.0
description: "GHC version to install"
runs:
using: "composite"
diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index a4814895c6..d9209b35b6 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -99,26 +99,43 @@ jobs:
# =========================
build-linux:
- name: "ubuntu-${{ matrix.os }} (CLI,Desktop), GHC: ${{ matrix.ghc }}"
+ name: "ubuntu-${{ matrix.os }}-${{ matrix.arch }} (CLI,Desktop), GHC: ${{ matrix.ghc }}"
needs: [maybe-release, variables]
- runs-on: ubuntu-${{ matrix.os }}
+ runs-on: ${{ matrix.runner }}
strategy:
fail-fast: false
matrix:
include:
- os: 22.04
+ os_underscore: 22_04
+ arch: x86_64
+ runner: "ubuntu-22.04"
ghc: "8.10.7"
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_underscore: 22_04
+ arch: x86_64
+ runner: "ubuntu-22.04"
should_run: true
+ ghc: ${{ needs.variables.outputs.GHC_VER }}
- 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
+ os_underscore: 24_04
+ arch: x86_64
+ runner: "ubuntu-24.04"
should_run: true
+ ghc: ${{ needs.variables.outputs.GHC_VER }}
+ - os: 22.04
+ os_underscore: 22_04
+ arch: aarch64
+ runner: "ubuntu-22.04-arm"
+ should_run: true
+ ghc: ${{ needs.variables.outputs.GHC_VER }}
+ - os: 24.04
+ os_underscore: 24_04
+ arch: aarch64
+ runner: "ubuntu-24.04-arm"
+ should_run: true
+ ghc: ${{ needs.variables.outputs.GHC_VER }}
steps:
- name: Checkout Code
if: matrix.should_run == true
@@ -143,7 +160,7 @@ jobs:
path: |
~/.cabal/store
dist-newstyle
- key: ubuntu-${{ matrix.os }}-ghc${{ matrix.ghc }}-${{ hashFiles('cabal.project', 'simplex-chat.cabal') }}
+ key: ubuntu-${{ matrix.os }}-${{ matrix.arch }}-ghc${{ matrix.ghc }}-${{ hashFiles('cabal.project', 'simplex-chat.cabal') }}
- name: Set up Docker Buildx
if: matrix.should_run == true
@@ -215,17 +232,17 @@ jobs:
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 }}"
+ docker cp builder:/out/simplex-chat ./simplex-chat-ubuntu-${{ matrix.os_underscore }}-${{ matrix.arch }}
+ path="${{ github.workspace }}/simplex-chat-ubuntu-${{ matrix.os_underscore }}-${{ matrix.arch }}"
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
+ echo "bin_hash=$(echo SHA2-256\(simplex-chat-ubuntu-${{ matrix.os_underscore }}-${{ matrix.arch }}\)= $(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_name: simplex-chat-ubuntu-${{ matrix.os_underscore }}-${{ matrix.arch }}
bin_hash: ${{ steps.linux_cli_prepare.outputs.bin_hash }}
github_ref: ${{ github.ref }}
github_token: ${{ secrets.GITHUB_TOKEN }}
@@ -234,25 +251,23 @@ jobs:
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
+ scripts/desktop/make-deb-linux.sh
- name: Prepare Desktop
id: linux_desktop_build
if: startsWith(github.ref, 'refs/tags/v') && matrix.should_run == true
shell: bash
run: |
- path=$(echo ${{ github.workspace }}/apps/multiplatform/release/main/deb/simplex_*_amd64.deb )
+ path=$(echo ${{ github.workspace }}/apps/multiplatform/release/main/deb/simplex_${{ matrix.arch }}.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
+ echo "package_hash=$(echo SHA2-256\(simplex-desktop-ubuntu-${{ matrix.os_underscore }}-${{ matrix.arch }}.deb\)= $(openssl sha256 $path | cut -d' ' -f 2))" >> $GITHUB_OUTPUT
- 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_name: simplex-desktop-ubuntu-${{ matrix.os_underscore }}-${{ matrix.arch }}.deb
bin_hash: ${{ steps.linux_desktop_build.outputs.package_hash }}
github_ref: ${{ github.ref }}
github_token: ${{ secrets.GITHUB_TOKEN }}
@@ -270,14 +285,14 @@ jobs:
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
+ echo "appimage_hash=$(echo SHA2-256\(simplex-desktop-${{ matrix.arch }}.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_name: "simplex-desktop-${{ matrix.arch }}.AppImage"
bin_hash: ${{ steps.linux_appimage_build.outputs.appimage_hash }}
github_ref: ${{ github.ref }}
github_token: ${{ secrets.GITHUB_TOKEN }}
@@ -290,7 +305,7 @@ jobs:
sudo chown -R $(id -u):$(id -g) dist-newstyle ~/.cabal
- name: Run tests
- if: matrix.should_run == true
+ if: matrix.should_run == true && matrix.arch == 'x86_64'
timeout-minutes: 120
shell: bash
run: |
diff --git a/.github/workflows/reproduce-schedule.yml b/.github/workflows/reproduce-schedule.yml
index 7de44addc7..7d28d6f70c 100644
--- a/.github/workflows/reproduce-schedule.yml
+++ b/.github/workflows/reproduce-schedule.yml
@@ -25,7 +25,7 @@ jobs:
- name: Execute reproduce script
run: |
- ${GITHUB_WORKSPACE}/scripts/reproduce-builds.sh "$TAG"
+ ${GITHUB_WORKSPACE}/scripts/simplex-chat-reproduce-builds.sh "$TAG" || :
- name: Check if build has been reproduced
env:
@@ -33,7 +33,7 @@ jobs:
user: ${{ secrets.STATUS_SIMPLEX_WEBHOOK_USER }}
pass: ${{ secrets.STATUS_SIMPLEX_WEBHOOK_PASS }}
run: |
- if [ -f "${GITHUB_WORKSPACE}/$TAG/_sha256sums" ]; then
+ if [ -f "${GITHUB_WORKSPACE}/${TAG}-simplex-chat/_sha256sums" ]; then
exit 0
else
curl --proto '=https' --tlsv1.2 -sSf \
diff --git a/.gitignore b/.gitignore
index 645b55ec9d..4560272980 100644
--- a/.gitignore
+++ b/.gitignore
@@ -79,3 +79,4 @@ website/package-lock.json
website/.cache
website/test/stubs-layout-cache/_includes/*.js
apps/android/app/release
+apps/multiplatform/.kotlin/sessions
diff --git a/Dockerfile.build b/Dockerfile.build
index 76bb1127f2..3c841cfb25 100644
--- a/Dockerfile.build
+++ b/Dockerfile.build
@@ -5,7 +5,7 @@ FROM ubuntu:${TAG} AS build
### Build stage
ARG GHC=9.6.3
-ARG CABAL=3.10.1.0
+ARG CABAL=3.10.2.0
ARG JAVA=17
ENV TZ=Etc/UTC \
@@ -16,6 +16,7 @@ RUN apt-get update && \
apt-get install -y curl \
libpq-dev \
git \
+ strip-nondeterminism \
sqlite3 \
libsqlite3-dev \
build-essential \
diff --git a/README.md b/README.md
index 554c6068d9..6ed444c7b5 100644
--- a/README.md
+++ b/README.md
@@ -72,9 +72,9 @@ 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-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)
+You can join an English-speaking users group if you want to ask any questions: [#SimpleX users group](https://smp4.simplex.im/g#hr4lvFeBmndWMKTwqiodPz3VBo_6UmdGWocXd1SupsM)
-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:
+There is also a group [#simplex-devs](https://smp6.simplex.im/g#Drx3efC-n418AuSpzTspw9SER0iJwrQTmKBafQHwkKM) for developers who build on SimpleX platform:
- chat bots and automations
- integrations with other apps
@@ -83,7 +83,7 @@ There is also a group [#simplex-devs](https://simplex.chat/contact#/?v=1-4&smp=s
There are groups in other languages, that we have the apps interface translated into. These groups are for testing, and asking questions to other SimpleX Chat users:
-[\#SimpleX-DE](https://simplex.chat/contact#/?v=1-4&smp=smp%3A%2F%2FPQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo%3D%40smp6.simplex.im%2FmfiivxDKWFuowXrQOp11jsY8TuP__rBL%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAiz3pKNwvKudckFYMUfgoT0s96B0jfZ7ALHAu7rtE9HQ%253D%26srv%3Dbylepyau3ty4czmn77q4fglvperknl4bi2eb2fdy2bh4jxtf32kf73yd.onion&data=%7B%22type%22%3A%22group%22%2C%22groupLinkId%22%3A%22jZeJpXGrRXQJU_-MSJ_v2A%3D%3D%22%7D) (German-speaking), [\#SimpleX-ES](https://simplex.chat/contact#/?v=2-4&smp=smp%3A%2F%2Fhpq7_4gGJiilmz5Rf-CswuU5kZGkm_zOIooSw6yALRg%3D%40smp5.simplex.im%2FJ5ES83pJimY2BRklS8fvy_iQwIU37xra%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEA0F0STP6UqN_12_k2cjjTrIjFgBGeWhOAmbY1qlk3pnM%253D%26srv%3Djjbyvoemxysm7qxap7m5d5m35jzv5qq6gnlv7s4rsn7tdwwmuqciwpid.onion&data=%7B%22type%22%3A%22group%22%2C%22groupLinkId%22%3A%22VmUU0fqmYdCRmVCyvStvHA%3D%3D%22%7D) (Spanish-speaking), [\#SimpleX-FR](https://simplex.chat/contact#/?v=2-7&smp=smp%3A%2F%2FPQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo%3D%40smp6.simplex.im%2FxCHBE_6PBRMqNEpm4UQDHXb9cz-mN7dd%23%2F%3Fv%3D1-3%26dh%3DMCowBQYDK2VuAyEAetqlcM7zTCRw-iatnwCrvpJSto7lq5Yv6AsBMWv7GSM%253D%26srv%3Dbylepyau3ty4czmn77q4fglvperknl4bi2eb2fdy2bh4jxtf32kf73yd.onion&data=%7B%22type%22%3A%22group%22%2C%22groupLinkId%22%3A%22foO5Xw4hhjOa_x7zET7otw%3D%3D%22%7D) (French-speaking), [\#SimpleX-RU](https://simplex.chat/contact#/?v=2-4&smp=smp%3A%2F%2Fhpq7_4gGJiilmz5Rf-CswuU5kZGkm_zOIooSw6yALRg%3D%40smp5.simplex.im%2FVXQTB0J2lLjYkgjWByhl6-1qmb5fgZHh%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAI6JaEWezfSwvcoTEkk6au-gkjrXR2ew2OqZYMYBvayk%253D%26srv%3Djjbyvoemxysm7qxap7m5d5m35jzv5qq6gnlv7s4rsn7tdwwmuqciwpid.onion&data=%7B%22type%22%3A%22group%22%2C%22groupLinkId%22%3A%22ORH9OEe8Duissh-hslfeVg%3D%3D%22%7D) (Russian-speaking), [\#SimpleX-IT](https://simplex.chat/contact#/?v=2-7&smp=smp%3A%2F%2Fhpq7_4gGJiilmz5Rf-CswuU5kZGkm_zOIooSw6yALRg%3D%40smp5.simplex.im%2FqpHu0psOUdYfc11yQCzSyq5JhijrBzZT%23%2F%3Fv%3D1-3%26dh%3DMCowBQYDK2VuAyEACZ_7fbwlM45wl6cGif8cY47oPQ_AMdP0ATqOYLA6zHY%253D%26srv%3Djjbyvoemxysm7qxap7m5d5m35jzv5qq6gnlv7s4rsn7tdwwmuqciwpid.onion&data=%7B%22type%22%3A%22group%22%2C%22groupLinkId%22%3A%229uRQRTir3ealdcSfB0zsrw%3D%3D%22%7D) (Italian-speaking).
+[\#SimpleX-DE](https://smp6.simplex.im/g#V6tQ-lJqsdgJJdJiLPtP326oQFKHvwinIbgruZ9K2oU) (German-speaking), [\#SimpleX-ES](https://smp5.simplex.im/g#xJ5kwDLq2305O5FmpUzvgRIXXAcAJ9S5BItCd2Wmloc) (Spanish-speaking), [\#SimpleX-FR](https://smp6.simplex.im/g#cVOpB0CKd6hEf2aWQ6sJ22E2DVgQLtdHoiSdKxXeKqk) (French-speaking), [\#SimpleX-RU](https://smp5.simplex.im/g#vwXRdfG5SgtaG6aVcITiUGd--Ux0rY1IuH4QXYxlq3U) (Russian-speaking), [\#SimpleX-IT](https://smp5.simplex.im/g#BtRcjsl29ULFNBSE2OPhp1UwZfW7PW9gUYFQTKHdjqU) (Italian-speaking).
You can join either by opening these links in the app or by opening them in a desktop browser and scanning the QR code.
@@ -114,9 +114,8 @@ Read about the app features and settings in the new [User guide](./docs/guide/RE
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.
+- [develop a chat bot](#develop-a-chat-bot) for SimpleX Chat!
+- writing a tutorial or recipes about hosting servers, chat bots, etc.
- developing features - please connect to us via chat so we can help you get started.
## Help translating SimpleX Chat
@@ -194,6 +193,7 @@ SimpleX Chat founder
- [SimpleX Platform design](#simplex-platform-design)
- [Privacy and security: technical details and limitations](#privacy-and-security-technical-details-and-limitations)
- [For developers](#for-developers)
+- [Develop a chat bot](#develop-a-chat-bot)
- [Roadmap](#roadmap)
- [Disclaimers, Security contact, License](#disclaimers)
@@ -235,6 +235,10 @@ You can use SimpleX with your own servers and still communicate with people usin
Recent and important updates:
+[Jul 29, 2025 SimpleX Chat v6.4.1: welcome your contacts, review members to protect groups, and more.](./blog/20250729-simplex-chat-v6-4-1-welcome-contacts-protect-groups-app-security.md)
+
+[Jul 3, 2025 SimpleX network: new experience of connecting with people — available in SimpleX Chat v6.4-beta.4](./blog/20250703-simplex-network-protocol-extension-for-securely-connecting-people.md)
+
[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)
@@ -323,15 +327,23 @@ We plan to add:
You can:
+- [create chat bots and services](#develop-a-chat-bot).
+- run [simplex-chat terminal CLI](./docs/CLI.md) to execute individual chat commands, e.g. to send messages as part of shell script execution.
- use SimpleX Chat library to integrate chat functionality into your mobile apps.
- create chat bots and services in Haskell - see [simple](./apps/simplex-bot/) and more [advanced chat bot example](./apps/simplex-bot-advanced/).
-- create chat bots and services in any language running SimpleX Chat terminal CLI as a local WebSocket server. See [TypeScript SimpleX Chat client](./packages/simplex-chat-client/) and [JavaScript chat bot example](./packages/simplex-chat-client/typescript/examples/squaring-bot.js).
-- run [simplex-chat terminal CLI](./docs/CLI.md) to execute individual chat commands, e.g. to send messages as part of shell script execution.
If you are considering developing with SimpleX platform please get in touch for any advice and support.
Please also join [#simplex-devs](https://simplex.chat/contact#/?v=1-2&smp=smp%3A%2F%2Fu2dS9sG8nMNURyZwqASV4yROM28Er0luVTx5X1CsMrU%3D%40smp4.simplex.im%2F6eHqy7uAbZPOcA6qBtrQgQquVlt4Ll91%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAqV_pg3FF00L98aCXp4D3bOs4Sxv_UmSd-gb0juVoQVs%253D%26srv%3Do5vmywmrnaxalvz6wi3zicyftgio6psuvyniis6gco6bp6ekl4cqj4id.onion&data=%7B%22type%22%3A%22group%22%2C%22groupLinkId%22%3A%22XonlixcHBIb2ijCehbZoiw%3D%3D%22%7D) group to ask any questions and share your success stories.
+## Develop a chat bot
+
+You can create a chat bot or any chat-based service in any language running SimpleX Chat terminal CLI as a local WebSocket server.
+
+See [our new bot API reference](./bots/README.md). Most of it is automatically generated from core library types, so it stays up to date.
+
+Also see [TypeScript SimpleX Chat client](./packages/simplex-chat-client/) and [JavaScript chat bot example](./packages/simplex-chat-client/typescript/examples/squaring-bot.js).
+
## Roadmap
- ✅ Easy to deploy SimpleX server with in-memory message storage, without any dependencies.
@@ -430,7 +442,7 @@ Please do NOT report security vulnerabilities via GitHub issues.
## License
-[AGPL v3](./LICENSE)
+This software is licensed under the GNU Affero General Public License version 3 (AGPLv3). See the [LICENSE](./LICENSE) file for details. The SimpleX and SimpleX Chat name, logo, and associated branding materials are not covered by this license and are subject to the terms outlined in the [TRADEMARK](./docs/TRADEMARK.md) file.
[
](https://apps.apple.com/us/app/simplex-chat/id1605771084)
diff --git a/apps/ios/Shared/ContentView.swift b/apps/ios/Shared/ContentView.swift
index 2ad8d546f2..7adf7a0435 100644
--- a/apps/ios/Shared/ContentView.swift
+++ b/apps/ios/Shared/ContentView.swift
@@ -45,21 +45,10 @@ struct ContentView: View {
@State private var showChooseLAMode = false
@State private var showSetPasscode = false
@State private var waitingForOrPassedAuth = true
- @State private var chatListActionSheet: ChatListActionSheet? = nil
@State private var chatListUserPickerSheet: UserPickerSheet? = nil
private let callTopPadding: CGFloat = 40
- private enum ChatListActionSheet: Identifiable {
- case planAndConnectSheet(sheet: PlanAndConnectActionSheet)
-
- var id: String {
- switch self {
- case let .planAndConnectSheet(sheet): return sheet.id
- }
- }
- }
-
private var accessAuthenticated: Bool {
chatModel.contentViewAccessAuthenticated || contentAccessAuthenticationExtended
}
@@ -181,11 +170,6 @@ struct ContentView: View {
if case .onboardingComplete = step,
chatModel.currentUser != nil {
mainView()
- .actionSheet(item: $chatListActionSheet) { sheet in
- switch sheet {
- case let .planAndConnectSheet(sheet): return planAndConnectActionSheet(sheet, dismiss: false)
- }
- }
} else {
OnboardingView(onboarding: step)
}
@@ -446,21 +430,27 @@ struct ContentView: View {
let m = ChatModel.shared
if let url = m.appOpenUrl {
m.appOpenUrl = nil
- dismissAllSheets() {
- var path = url.path
- 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(
- link,
- showAlert: showPlanAndConnectAlert,
- showActionSheet: { chatListActionSheet = .planAndConnectSheet(sheet: $0) },
- dismiss: false,
- incognito: nil
- )
- } else {
- AlertManager.shared.showAlert(Alert(title: Text("Error: URL is invalid")))
- }
+ connectViaUrl_(url)
+ } else if let url = m.appOpenUrlLater, AppChatState.shared.value == .active, scenePhase == .active {
+ // correcting branch in case .onChange(of: scenePhase) in SimpleXApp doesn't trigger and transfer appOpenUrlLater into appOpenUrl
+ m.appOpenUrlLater = nil
+ connectViaUrl_(url)
+ }
+ }
+
+ func connectViaUrl_(_ url: URL) {
+ dismissAllSheets() {
+ var path = url.path
+ 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(
+ link,
+ theme: theme,
+ dismiss: false
+ )
+ } else {
+ AlertManager.shared.showAlert(Alert(title: Text("Error: URL is invalid")))
}
}
}
@@ -479,10 +469,6 @@ struct ContentView: View {
}
}
}
-
- private func showPlanAndConnectAlert(_ alert: PlanAndConnectAlert) {
- AlertManager.shared.showAlert(planAndConnectAlert(alert, dismiss: false))
- }
}
final class AlertManager: ObservableObject {
diff --git a/apps/ios/Shared/Model/AppAPITypes.swift b/apps/ios/Shared/Model/AppAPITypes.swift
index 3bf4cb7b56..35b9bf033e 100644
--- a/apps/ios/Shared/Model/AppAPITypes.swift
+++ b/apps/ios/Shared/Model/AppAPITypes.swift
@@ -18,6 +18,7 @@ enum ChatCommand: ChatCmdProtocol {
case setAllContactReceipts(enable: Bool)
case apiSetUserContactReceipts(userId: Int64, userMsgReceiptSettings: UserMsgReceiptSettings)
case apiSetUserGroupReceipts(userId: Int64, userMsgReceiptSettings: UserMsgReceiptSettings)
+ case apiSetUserAutoAcceptMemberContacts(userId: Int64, enable: Bool)
case apiHideUser(userId: Int64, viewPwd: String)
case apiUnhideUser(userId: Int64, viewPwd: String)
case apiMuteUser(userId: Int64)
@@ -39,9 +40,9 @@ enum ChatCommand: ChatCmdProtocol {
case apiGetSettings(settings: AppSettings)
case apiGetChatTags(userId: Int64)
case apiGetChats(userId: Int64)
- case apiGetChat(chatId: ChatId, pagination: ChatPagination, search: String)
- case apiGetChatItemInfo(type: ChatType, id: Int64, itemId: Int64)
- case apiSendMessages(type: ChatType, id: Int64, live: Bool, ttl: Int?, composedMessages: [ComposedMessage])
+ case apiGetChat(chatId: ChatId, scope: GroupChatScope?, contentTag: MsgContentTag?, pagination: ChatPagination, search: String)
+ case apiGetChatItemInfo(type: ChatType, id: Int64, scope: GroupChatScope?, itemId: Int64)
+ case apiSendMessages(type: ChatType, id: Int64, scope: GroupChatScope?, live: Bool, ttl: Int?, composedMessages: [ComposedMessage])
case apiCreateChatTag(tag: ChatTagData)
case apiSetChatTags(type: ChatType, id: Int64, tagIds: [Int64])
case apiDeleteChatTag(tagId: Int64)
@@ -49,15 +50,15 @@ enum ChatCommand: ChatCmdProtocol {
case apiReorderChatTags(tagIds: [Int64])
case apiCreateChatItems(noteFolderId: Int64, composedMessages: [ComposedMessage])
case apiReportMessage(groupId: Int64, chatItemId: Int64, reportReason: ReportReason, reportText: String)
- case apiUpdateChatItem(type: ChatType, id: Int64, itemId: Int64, updatedMessage: UpdatedMessage, live: Bool)
- case apiDeleteChatItem(type: ChatType, id: Int64, itemIds: [Int64], mode: CIDeleteMode)
+ case apiUpdateChatItem(type: ChatType, id: Int64, scope: GroupChatScope?, itemId: Int64, updatedMessage: UpdatedMessage, live: Bool)
+ case apiDeleteChatItem(type: ChatType, id: Int64, scope: GroupChatScope?, itemIds: [Int64], mode: CIDeleteMode)
case apiDeleteMemberChatItem(groupId: Int64, itemIds: [Int64])
case apiArchiveReceivedReports(groupId: Int64)
case apiDeleteReceivedReports(groupId: Int64, itemIds: [Int64], mode: CIDeleteMode)
- case apiChatItemReaction(type: ChatType, id: Int64, itemId: Int64, add: Bool, reaction: MsgReaction)
+ case apiChatItemReaction(type: ChatType, id: Int64, scope: GroupChatScope?, itemId: Int64, add: Bool, reaction: MsgReaction)
case apiGetReactionMembers(userId: Int64, groupId: Int64, itemId: Int64, reaction: MsgReaction)
- case apiPlanForwardChatItems(toChatType: ChatType, toChatId: Int64, itemIds: [Int64])
- case apiForwardChatItems(toChatType: ChatType, toChatId: Int64, fromChatType: ChatType, fromChatId: Int64, itemIds: [Int64], ttl: Int?)
+ case apiPlanForwardChatItems(fromChatType: ChatType, fromChatId: Int64, fromScope: GroupChatScope?, itemIds: [Int64])
+ case apiForwardChatItems(toChatType: ChatType, toChatId: Int64, toScope: GroupChatScope?, fromChatType: ChatType, fromChatId: Int64, fromScope: GroupChatScope?, itemIds: [Int64], ttl: Int?)
case apiGetNtfToken
case apiRegisterToken(token: DeviceToken, notificationMode: NotificationsMode)
case apiVerifyToken(token: DeviceToken, nonce: String, code: String)
@@ -68,18 +69,22 @@ enum ChatCommand: ChatCmdProtocol {
case apiNewGroup(userId: Int64, incognito: Bool, groupProfile: GroupProfile)
case apiAddMember(groupId: Int64, contactId: Int64, memberRole: GroupMemberRole)
case apiJoinGroup(groupId: Int64)
+ case apiAcceptMember(groupId: Int64, groupMemberId: Int64, memberRole: GroupMemberRole)
+ case apiDeleteMemberSupportChat(groupId: Int64, groupMemberId: Int64)
case apiMembersRole(groupId: Int64, memberIds: [Int64], memberRole: GroupMemberRole)
case apiBlockMembersForAll(groupId: Int64, memberIds: [Int64], blocked: Bool)
case apiRemoveMembers(groupId: Int64, memberIds: [Int64], withMessages: Bool)
case apiLeaveGroup(groupId: Int64)
case apiListMembers(groupId: Int64)
case apiUpdateGroupProfile(groupId: Int64, groupProfile: GroupProfile)
- case apiCreateGroupLink(groupId: Int64, memberRole: GroupMemberRole, short: Bool)
+ case apiCreateGroupLink(groupId: Int64, memberRole: GroupMemberRole)
case apiGroupLinkMemberRole(groupId: Int64, memberRole: GroupMemberRole)
case apiDeleteGroupLink(groupId: Int64)
case apiGetGroupLink(groupId: Int64)
+ case apiAddGroupShortLink(groupId: Int64)
case apiCreateMemberContact(groupId: Int64, groupMemberId: Int64)
case apiSendMemberContactInvitation(contactId: Int64, msg: MsgContent)
+ case apiAcceptMemberContact(contactId: Int64)
case apiTestProtoServer(userId: Int64, server: String)
case apiGetServerOperators
case apiSetServerOperators(operators: [ServerOperator])
@@ -113,10 +118,16 @@ enum ChatCommand: ChatCmdProtocol {
case apiGetGroupMemberCode(groupId: Int64, groupMemberId: Int64)
case apiVerifyContact(contactId: Int64, connectionCode: String?)
case apiVerifyGroupMember(groupId: Int64, groupMemberId: Int64, connectionCode: String?)
- case apiAddContact(userId: Int64, short: Bool, incognito: Bool)
+ case apiAddContact(userId: Int64, incognito: Bool)
case apiSetConnectionIncognito(connId: Int64, incognito: Bool)
case apiChangeConnectionUser(connId: Int64, userId: Int64)
case apiConnectPlan(userId: Int64, connLink: String)
+ case apiPrepareContact(userId: Int64, connLink: CreatedConnLink, contactShortLinkData: ContactShortLinkData)
+ case apiPrepareGroup(userId: Int64, connLink: CreatedConnLink, groupShortLinkData: GroupShortLinkData)
+ case apiChangePreparedContactUser(contactId: Int64, newUserId: Int64)
+ case apiChangePreparedGroupUser(groupId: Int64, newUserId: Int64)
+ case apiConnectPreparedContact(contactId: Int64, incognito: Bool, msg: MsgContent?)
+ case apiConnectPreparedGroup(groupId: Int64, incognito: Bool, msg: MsgContent?)
case apiConnect(userId: Int64, incognito: Bool, connLink: CreatedConnLink)
case apiConnectContactViaAddress(userId: Int64, incognito: Bool, contactId: Int64)
case apiDeleteChat(type: ChatType, id: Int64, chatDeleteMode: ChatDeleteMode)
@@ -129,11 +140,12 @@ enum ChatCommand: ChatCmdProtocol {
case apiSetConnectionAlias(connId: Int64, localAlias: String)
case apiSetUserUIThemes(userId: Int64, themes: ThemeModeOverrides?)
case apiSetChatUIThemes(chatId: String, themes: ThemeModeOverrides?)
- case apiCreateMyAddress(userId: Int64, short: Bool)
+ case apiCreateMyAddress(userId: Int64)
case apiDeleteMyAddress(userId: Int64)
case apiShowMyAddress(userId: Int64)
+ case apiAddMyAddressShortLink(userId: Int64)
case apiSetProfileAddress(userId: Int64, on: Bool)
- case apiAddressAutoAccept(userId: Int64, autoAccept: AutoAccept?)
+ case apiSetAddressSettings(userId: Int64, addressSettings: AddressSettings)
case apiAcceptContact(incognito: Bool, contactReqId: Int64)
case apiRejectContact(contactReqId: Int64)
// WebRTC calls
@@ -147,8 +159,8 @@ enum ChatCommand: ChatCmdProtocol {
case apiCallStatus(contact: Contact, callStatus: WebRTCCallStatus)
// WebRTC calls /
case apiGetNetworkStatuses
- case apiChatRead(type: ChatType, id: Int64)
- case apiChatItemsRead(type: ChatType, id: Int64, itemIds: [Int64])
+ case apiChatRead(type: ChatType, id: Int64, scope: GroupChatScope?)
+ case apiChatItemsRead(type: ChatType, id: Int64, scope: GroupChatScope?, itemIds: [Int64])
case apiChatUnread(type: ChatType, id: Int64, unreadChat: Bool)
case receiveFile(fileId: Int64, userApprovedRelays: Bool, encrypted: Bool?, inline: Bool?)
case setFileToReceive(fileId: Int64, userApprovedRelays: Bool, encrypted: Bool?)
@@ -188,6 +200,8 @@ enum ChatCommand: ChatCmdProtocol {
case let .apiSetUserGroupReceipts(userId, userMsgReceiptSettings):
let umrs = userMsgReceiptSettings
return "/_set receipts groups \(userId) \(onOff(umrs.enable)) clear_overrides=\(onOff(umrs.clearOverrides))"
+ case let .apiSetUserAutoAcceptMemberContacts(userId, enable):
+ return "/_set accept member contacts \(userId) \(onOff(enable))"
case let .apiHideUser(userId, viewPwd): return "/_hide user \(userId) \(encodeJSON(viewPwd))"
case let .apiUnhideUser(userId, viewPwd): return "/_unhide user \(userId) \(encodeJSON(viewPwd))"
case let .apiMuteUser(userId): return "/_mute user \(userId)"
@@ -209,15 +223,16 @@ enum ChatCommand: ChatCmdProtocol {
case let .apiGetSettings(settings): return "/_get app settings \(encodeJSON(settings))"
case let .apiGetChatTags(userId): return "/_get tags \(userId)"
case let .apiGetChats(userId): return "/_get chats \(userId) pcc=on"
- case let .apiGetChat(chatId, pagination, search): return "/_get chat \(chatId) \(pagination.cmdString)" +
- (search == "" ? "" : " search=\(search)")
- case let .apiGetChatItemInfo(type, id, itemId): return "/_get item info \(ref(type, id)) \(itemId)"
- case let .apiSendMessages(type, id, live, ttl, composedMessages):
+ case let .apiGetChat(chatId, scope, contentTag, pagination, search):
+ let tag = contentTag != nil ? " content=\(contentTag!.rawValue)" : ""
+ return "/_get chat \(chatId)\(scopeRef(scope: scope))\(tag) \(pagination.cmdString)" + (search == "" ? "" : " search=\(search)")
+ case let .apiGetChatItemInfo(type, id, scope, itemId): return "/_get item info \(ref(type, id, scope: scope)) \(itemId)"
+ case let .apiSendMessages(type, id, scope, live, ttl, composedMessages):
let msgs = encodeJSON(composedMessages)
let ttlStr = ttl != nil ? "\(ttl!)" : "default"
- return "/_send \(ref(type, id)) live=\(onOff(live)) ttl=\(ttlStr) json \(msgs)"
+ return "/_send \(ref(type, id, scope: scope)) live=\(onOff(live)) ttl=\(ttlStr) json \(msgs)"
case let .apiCreateChatTag(tag): return "/_create tag \(encodeJSON(tag))"
- case let .apiSetChatTags(type, id, tagIds): return "/_tags \(ref(type, id)) \(tagIds.map({ "\($0)" }).joined(separator: ","))"
+ case let .apiSetChatTags(type, id, tagIds): return "/_tags \(ref(type, id, scope: nil)) \(tagIds.map({ "\($0)" }).joined(separator: ","))"
case let .apiDeleteChatTag(tagId): return "/_delete tag \(tagId)"
case let .apiUpdateChatTag(tagId, tagData): return "/_update tag \(tagId) \(encodeJSON(tagData))"
case let .apiReorderChatTags(tagIds): return "/_reorder tags \(tagIds.map({ "\($0)" }).joined(separator: ","))"
@@ -226,17 +241,17 @@ enum ChatCommand: ChatCmdProtocol {
return "/_create *\(noteFolderId) json \(msgs)"
case let .apiReportMessage(groupId, chatItemId, reportReason, reportText):
return "/_report #\(groupId) \(chatItemId) reason=\(reportReason) \(reportText)"
- case let .apiUpdateChatItem(type, id, itemId, um, live): return "/_update item \(ref(type, id)) \(itemId) live=\(onOff(live)) \(um.cmdString)"
- case let .apiDeleteChatItem(type, id, itemIds, mode): return "/_delete item \(ref(type, id)) \(itemIds.map({ "\($0)" }).joined(separator: ",")) \(mode.rawValue)"
+ case let .apiUpdateChatItem(type, id, scope, itemId, um, live): return "/_update item \(ref(type, id, scope: scope)) \(itemId) live=\(onOff(live)) \(um.cmdString)"
+ case let .apiDeleteChatItem(type, id, scope, itemIds, mode): return "/_delete item \(ref(type, id, scope: scope)) \(itemIds.map({ "\($0)" }).joined(separator: ",")) \(mode.rawValue)"
case let .apiDeleteMemberChatItem(groupId, itemIds): return "/_delete member item #\(groupId) \(itemIds.map({ "\($0)" }).joined(separator: ","))"
case let .apiArchiveReceivedReports(groupId): return "/_archive reports #\(groupId)"
case let .apiDeleteReceivedReports(groupId, itemIds, mode): return "/_delete reports #\(groupId) \(itemIds.map({ "\($0)" }).joined(separator: ",")) \(mode.rawValue)"
- case let .apiChatItemReaction(type, id, itemId, add, reaction): return "/_reaction \(ref(type, id)) \(itemId) \(onOff(add)) \(encodeJSON(reaction))"
+ case let .apiChatItemReaction(type, id, scope, itemId, add, reaction): return "/_reaction \(ref(type, id, scope: scope)) \(itemId) \(onOff(add)) \(encodeJSON(reaction))"
case let .apiGetReactionMembers(userId, groupId, itemId, reaction): return "/_reaction members \(userId) #\(groupId) \(itemId) \(encodeJSON(reaction))"
- case let .apiPlanForwardChatItems(type, id, itemIds): return "/_forward plan \(ref(type, id)) \(itemIds.map({ "\($0)" }).joined(separator: ","))"
- case let .apiForwardChatItems(toChatType, toChatId, fromChatType, fromChatId, itemIds, ttl):
+ case let .apiPlanForwardChatItems(type, id, scope, itemIds): return "/_forward plan \(ref(type, id, scope: scope)) \(itemIds.map({ "\($0)" }).joined(separator: ","))"
+ case let .apiForwardChatItems(toChatType, toChatId, toScope, fromChatType, fromChatId, fromScope, itemIds, ttl):
let ttlStr = ttl != nil ? "\(ttl!)" : "default"
- return "/_forward \(ref(toChatType, toChatId)) \(ref(fromChatType, fromChatId)) \(itemIds.map({ "\($0)" }).joined(separator: ",")) ttl=\(ttlStr)"
+ return "/_forward \(ref(toChatType, toChatId, scope: toScope)) \(ref(fromChatType, fromChatId, scope: fromScope)) \(itemIds.map({ "\($0)" }).joined(separator: ",")) ttl=\(ttlStr)"
case .apiGetNtfToken: return "/_ntf get "
case let .apiRegisterToken(token, notificationMode): return "/_ntf register \(token.cmdString) \(notificationMode.rawValue)"
case let .apiVerifyToken(token, nonce, code): return "/_ntf verify \(token.cmdString) \(nonce) \(code)"
@@ -247,18 +262,22 @@ enum ChatCommand: ChatCmdProtocol {
case let .apiNewGroup(userId, incognito, groupProfile): return "/_group \(userId) incognito=\(onOff(incognito)) \(encodeJSON(groupProfile))"
case let .apiAddMember(groupId, contactId, memberRole): return "/_add #\(groupId) \(contactId) \(memberRole)"
case let .apiJoinGroup(groupId): return "/_join #\(groupId)"
+ case let .apiAcceptMember(groupId, groupMemberId, memberRole): return "/_accept member #\(groupId) \(groupMemberId) \(memberRole.rawValue)"
+ case let .apiDeleteMemberSupportChat(groupId, groupMemberId): return "/_delete member chat #\(groupId) \(groupMemberId)"
case let .apiMembersRole(groupId, memberIds, memberRole): return "/_member role #\(groupId) \(memberIds.map({ "\($0)" }).joined(separator: ",")) \(memberRole.rawValue)"
case let .apiBlockMembersForAll(groupId, memberIds, blocked): return "/_block #\(groupId) \(memberIds.map({ "\($0)" }).joined(separator: ",")) blocked=\(onOff(blocked))"
case let .apiRemoveMembers(groupId, memberIds, withMessages): return "/_remove #\(groupId) \(memberIds.map({ "\($0)" }).joined(separator: ",")) messages=\(onOff(withMessages))"
case let .apiLeaveGroup(groupId): return "/_leave #\(groupId)"
case let .apiListMembers(groupId): return "/_members #\(groupId)"
case let .apiUpdateGroupProfile(groupId, groupProfile): return "/_group_profile #\(groupId) \(encodeJSON(groupProfile))"
- case let .apiCreateGroupLink(groupId, memberRole, short): return "/_create link #\(groupId) \(memberRole) short=\(onOff(short))"
+ case let .apiCreateGroupLink(groupId, memberRole): return "/_create link #\(groupId) \(memberRole)"
case let .apiGroupLinkMemberRole(groupId, memberRole): return "/_set link role #\(groupId) \(memberRole)"
case let .apiDeleteGroupLink(groupId): return "/_delete link #\(groupId)"
case let .apiGetGroupLink(groupId): return "/_get link #\(groupId)"
+ case let .apiAddGroupShortLink(groupId): return "/_short link #\(groupId)"
case let .apiCreateMemberContact(groupId, groupMemberId): return "/_create member contact #\(groupId) \(groupMemberId)"
case let .apiSendMemberContactInvitation(contactId, mc): return "/_invite member contact @\(contactId) \(mc.cmdString)"
+ case let .apiAcceptMemberContact(contactId): return "/_accept member contact @\(contactId)"
case let .apiTestProtoServer(userId, server): return "/_server test \(userId) \(server)"
case .apiGetServerOperators: return "/_operators"
case let .apiSetServerOperators(operators): return "/_operators \(encodeJSON(operators))"
@@ -270,13 +289,13 @@ enum ChatCommand: ChatCmdProtocol {
case let .apiAcceptConditions(conditionsId, operatorIds): return "/_accept_conditions \(conditionsId) \(joinedIds(operatorIds))"
case let .apiSetChatItemTTL(userId, seconds): return "/_ttl \(userId) \(chatItemTTLStr(seconds: seconds))"
case let .apiGetChatItemTTL(userId): return "/_ttl \(userId)"
- case let .apiSetChatTTL(userId, type, id, seconds): return "/_ttl \(userId) \(ref(type, id)) \(chatItemTTLStr(seconds: seconds))"
+ case let .apiSetChatTTL(userId, type, id, seconds): return "/_ttl \(userId) \(ref(type, id, scope: nil)) \(chatItemTTLStr(seconds: seconds))"
case let .apiSetNetworkConfig(networkConfig): return "/_network \(encodeJSON(networkConfig))"
case .apiGetNetworkConfig: return "/network"
case let .apiSetNetworkInfo(networkInfo): return "/_network info \(encodeJSON(networkInfo))"
case .reconnectAllServers: return "/reconnect"
case let .reconnectServer(userId, smpServer): return "/reconnect \(userId) \(smpServer)"
- case let .apiSetChatSettings(type, id, chatSettings): return "/_settings \(ref(type, id)) \(encodeJSON(chatSettings))"
+ case let .apiSetChatSettings(type, id, chatSettings): return "/_settings \(ref(type, id, scope: nil)) \(encodeJSON(chatSettings))"
case let .apiSetMemberSettings(groupId, groupMemberId, memberSettings): return "/_member settings #\(groupId) \(groupMemberId) \(encodeJSON(memberSettings))"
case let .apiContactInfo(contactId): return "/_info @\(contactId)"
case let .apiGroupMemberInfo(groupId, groupMemberId): return "/_info #\(groupId) \(groupMemberId)"
@@ -302,14 +321,20 @@ enum ChatCommand: ChatCmdProtocol {
case let .apiVerifyContact(contactId, .none): return "/_verify code @\(contactId)"
case let .apiVerifyGroupMember(groupId, groupMemberId, .some(connectionCode)): return "/_verify code #\(groupId) \(groupMemberId) \(connectionCode)"
case let .apiVerifyGroupMember(groupId, groupMemberId, .none): return "/_verify code #\(groupId) \(groupMemberId)"
- case let .apiAddContact(userId, short, incognito): return "/_connect \(userId) short=\(onOff(short)) incognito=\(onOff(incognito))"
+ case let .apiAddContact(userId, incognito): return "/_connect \(userId) incognito=\(onOff(incognito))"
case let .apiSetConnectionIncognito(connId, incognito): return "/_set incognito :\(connId) \(onOff(incognito))"
case let .apiChangeConnectionUser(connId, userId): return "/_set conn user :\(connId) \(userId)"
case let .apiConnectPlan(userId, connLink): return "/_connect plan \(userId) \(connLink)"
+ case let .apiPrepareContact(userId, connLink, contactShortLinkData): return "/_prepare contact \(userId) \(connLink.connFullLink) \(connLink.connShortLink ?? "") \(encodeJSON(contactShortLinkData))"
+ case let .apiPrepareGroup(userId, connLink, groupShortLinkData): return "/_prepare group \(userId) \(connLink.connFullLink) \(connLink.connShortLink ?? "") \(encodeJSON(groupShortLinkData))"
+ case let .apiChangePreparedContactUser(contactId, newUserId): return "/_set contact user @\(contactId) \(newUserId)"
+ case let .apiChangePreparedGroupUser(groupId, newUserId): return "/_set group user #\(groupId) \(newUserId)"
+ case let .apiConnectPreparedContact(contactId, incognito, mc): return "/_connect contact @\(contactId) incognito=\(onOff(incognito))\(maybeContent(mc))"
+ case let .apiConnectPreparedGroup(groupId, incognito, mc): return "/_connect group #\(groupId) incognito=\(onOff(incognito))\(maybeContent(mc))"
case let .apiConnect(userId, incognito, connLink): return "/_connect \(userId) incognito=\(onOff(incognito)) \(connLink.connFullLink) \(connLink.connShortLink ?? "")"
case let .apiConnectContactViaAddress(userId, incognito, contactId): return "/_connect contact \(userId) incognito=\(onOff(incognito)) \(contactId)"
- case let .apiDeleteChat(type, id, chatDeleteMode): return "/_delete \(ref(type, id)) \(chatDeleteMode.cmdString)"
- case let .apiClearChat(type, id): return "/_clear chat \(ref(type, id))"
+ case let .apiDeleteChat(type, id, chatDeleteMode): return "/_delete \(ref(type, id, scope: nil)) \(chatDeleteMode.cmdString)"
+ case let .apiClearChat(type, id): return "/_clear chat \(ref(type, id, scope: nil))"
case let .apiListContacts(userId): return "/_contacts \(userId)"
case let .apiUpdateProfile(userId, profile): return "/_profile \(userId) \(encodeJSON(profile))"
case let .apiSetContactPrefs(contactId, preferences): return "/_set prefs @\(contactId) \(encodeJSON(preferences))"
@@ -318,11 +343,12 @@ enum ChatCommand: ChatCmdProtocol {
case let .apiSetConnectionAlias(connId, localAlias): return "/_set alias :\(connId) \(localAlias.trimmingCharacters(in: .whitespaces))"
case let .apiSetUserUIThemes(userId, themes): return "/_set theme user \(userId) \(themes != nil ? encodeJSON(themes) : "")"
case let .apiSetChatUIThemes(chatId, themes): return "/_set theme \(chatId) \(themes != nil ? encodeJSON(themes) : "")"
- case let .apiCreateMyAddress(userId, short): return "/_address \(userId) short=\(onOff(short))"
+ case let .apiCreateMyAddress(userId): return "/_address \(userId)"
case let .apiDeleteMyAddress(userId): return "/_delete_address \(userId)"
case let .apiShowMyAddress(userId): return "/_show_address \(userId)"
+ case let .apiAddMyAddressShortLink(userId): return "/_short_link_address \(userId)"
case let .apiSetProfileAddress(userId, on): return "/_profile_address \(userId) \(onOff(on))"
- case let .apiAddressAutoAccept(userId, autoAccept): return "/_auto_accept \(userId) \(AutoAccept.cmdString(autoAccept))"
+ case let .apiSetAddressSettings(userId, addressSettings): return "/_address_settings \(userId) \(encodeJSON(addressSettings))"
case let .apiAcceptContact(incognito, contactReqId): return "/_accept incognito=\(onOff(incognito)) \(contactReqId)"
case let .apiRejectContact(contactReqId): return "/_reject \(contactReqId)"
case let .apiSendCallInvitation(contact, callType): return "/_call invite @\(contact.apiId) \(encodeJSON(callType))"
@@ -334,9 +360,9 @@ enum ChatCommand: ChatCmdProtocol {
case .apiGetCallInvitations: return "/_call get"
case let .apiCallStatus(contact, callStatus): return "/_call status @\(contact.apiId) \(callStatus.rawValue)"
case .apiGetNetworkStatuses: return "/_network_statuses"
- case let .apiChatRead(type, id): return "/_read chat \(ref(type, id))"
- case let .apiChatItemsRead(type, id, itemIds): return "/_read chat items \(ref(type, id)) \(joinedIds(itemIds))"
- case let .apiChatUnread(type, id, unreadChat): return "/_unread chat \(ref(type, id)) \(onOff(unreadChat))"
+ case let .apiChatRead(type, id, scope): return "/_read chat \(ref(type, id, scope: scope))"
+ case let .apiChatItemsRead(type, id, scope, itemIds): return "/_read chat items \(ref(type, id, scope: scope)) \(joinedIds(itemIds))"
+ case let .apiChatUnread(type, id, unreadChat): return "/_unread chat \(ref(type, id, scope: nil)) \(onOff(unreadChat))"
case let .receiveFile(fileId, userApprovedRelays, encrypt, inline): return "/freceive \(fileId)\(onOffParam("approved_relays", userApprovedRelays))\(onOffParam("encrypt", encrypt))\(onOffParam("inline", inline))"
case let .setFileToReceive(fileId, userApprovedRelays, encrypt): return "/_set_file_to_receive \(fileId)\(onOffParam("approved_relays", userApprovedRelays))\(onOffParam("encrypt", encrypt))"
case let .cancelFile(fileId): return "/fcancel \(fileId)"
@@ -370,6 +396,7 @@ enum ChatCommand: ChatCmdProtocol {
case .setAllContactReceipts: return "setAllContactReceipts"
case .apiSetUserContactReceipts: return "apiSetUserContactReceipts"
case .apiSetUserGroupReceipts: return "apiSetUserGroupReceipts"
+ case .apiSetUserAutoAcceptMemberContacts: return "apiSetUserAutoAcceptMemberContacts"
case .apiHideUser: return "apiHideUser"
case .apiUnhideUser: return "apiUnhideUser"
case .apiMuteUser: return "apiMuteUser"
@@ -421,6 +448,8 @@ enum ChatCommand: ChatCmdProtocol {
case .apiNewGroup: return "apiNewGroup"
case .apiAddMember: return "apiAddMember"
case .apiJoinGroup: return "apiJoinGroup"
+ case .apiAcceptMember: return "apiAcceptMember"
+ case .apiDeleteMemberSupportChat: return "apiDeleteMemberSupportChat"
case .apiMembersRole: return "apiMembersRole"
case .apiBlockMembersForAll: return "apiBlockMembersForAll"
case .apiRemoveMembers: return "apiRemoveMembers"
@@ -431,8 +460,10 @@ enum ChatCommand: ChatCmdProtocol {
case .apiGroupLinkMemberRole: return "apiGroupLinkMemberRole"
case .apiDeleteGroupLink: return "apiDeleteGroupLink"
case .apiGetGroupLink: return "apiGetGroupLink"
+ case .apiAddGroupShortLink: return "apiAddGroupShortLink"
case .apiCreateMemberContact: return "apiCreateMemberContact"
case .apiSendMemberContactInvitation: return "apiSendMemberContactInvitation"
+ case .apiAcceptMemberContact: return "apiAcceptMemberContact"
case .apiTestProtoServer: return "apiTestProtoServer"
case .apiGetServerOperators: return "apiGetServerOperators"
case .apiSetServerOperators: return "apiSetServerOperators"
@@ -470,6 +501,12 @@ enum ChatCommand: ChatCmdProtocol {
case .apiSetConnectionIncognito: return "apiSetConnectionIncognito"
case .apiChangeConnectionUser: return "apiChangeConnectionUser"
case .apiConnectPlan: return "apiConnectPlan"
+ case .apiPrepareContact: return "apiPrepareContact"
+ case .apiPrepareGroup: return "apiPrepareGroup"
+ case .apiChangePreparedContactUser: return "apiChangePreparedContactUser"
+ case .apiChangePreparedGroupUser: return "apiChangePreparedGroupUser"
+ case .apiConnectPreparedContact: return "apiConnectPreparedContact"
+ case .apiConnectPreparedGroup: return "apiConnectPreparedGroup"
case .apiConnect: return "apiConnect"
case .apiDeleteChat: return "apiDeleteChat"
case .apiClearChat: return "apiClearChat"
@@ -484,8 +521,9 @@ enum ChatCommand: ChatCmdProtocol {
case .apiCreateMyAddress: return "apiCreateMyAddress"
case .apiDeleteMyAddress: return "apiDeleteMyAddress"
case .apiShowMyAddress: return "apiShowMyAddress"
+ case .apiAddMyAddressShortLink: return "apiAddMyAddressShortLink"
case .apiSetProfileAddress: return "apiSetProfileAddress"
- case .apiAddressAutoAccept: return "apiAddressAutoAccept"
+ case .apiSetAddressSettings: return "apiSetAddressSettings"
case .apiAcceptContact: return "apiAcceptContact"
case .apiRejectContact: return "apiRejectContact"
case .apiSendCallInvitation: return "apiSendCallInvitation"
@@ -523,8 +561,22 @@ enum ChatCommand: ChatCmdProtocol {
}
}
- func ref(_ type: ChatType, _ id: Int64) -> String {
- "\(type.rawValue)\(id)"
+ func ref(_ type: ChatType, _ id: Int64, scope: GroupChatScope?) -> String {
+ "\(type.rawValue)\(id)\(scopeRef(scope: scope))"
+ }
+
+ func scopeRef(scope: GroupChatScope?) -> String {
+ switch (scope) {
+ case .none: ""
+ case let .memberSupport(groupMemberId_):
+ if let groupMemberId = groupMemberId_ {
+ "(_support:\(groupMemberId))"
+ } else {
+ "(_support)"
+ }
+ case .reports:
+ "(reports, prohibited)" // can't use surrogate Reports scope
+ }
}
func joinedIds(_ ids: [Int64]) -> String {
@@ -578,6 +630,16 @@ enum ChatCommand: ChatCmdProtocol {
private func maybePwd(_ pwd: String?) -> String {
pwd == "" || pwd == nil ? "" : " " + encodeJSON(pwd)
}
+
+ private func maybeContent(_ mc: MsgContent?) -> String {
+ if case let .text(s) = mc, s.isEmpty {
+ ""
+ } else if let mc {
+ " " + mc.cmdString
+ } else {
+ ""
+ }
+ }
}
// ChatResponse is split to three enums to reduce stack size used when parsing it, parsing large enums is very inefficient.
@@ -645,7 +707,7 @@ enum ChatResponse0: Decodable, ChatAPIResult {
case .tagsUpdated: "tagsUpdated"
}
}
-
+
var details: String {
switch self {
case let .activeUser(user): return String(describing: user)
@@ -704,13 +766,19 @@ enum ChatResponse1: Decodable, ChatAPIResult {
case connectionIncognitoUpdated(user: UserRef, toConnection: PendingContactConnection)
case connectionUserChanged(user: UserRef, fromConnection: PendingContactConnection, toConnection: PendingContactConnection, newUser: UserRef)
case connectionPlan(user: UserRef, connLink: CreatedConnLink, connectionPlan: ConnectionPlan)
+ case newPreparedChat(user: UserRef, chat: ChatData)
+ case contactUserChanged(user: UserRef, fromContact: Contact, newUser: UserRef, toContact: Contact)
+ case groupUserChanged(user: UserRef, fromGroup: GroupInfo, newUser: UserRef, toGroup: GroupInfo)
case sentConfirmation(user: UserRef, connection: PendingContactConnection)
case sentInvitation(user: UserRef, connection: PendingContactConnection)
+ case startedConnectionToContact(user: UserRef, contact: Contact)
+ case startedConnectionToGroup(user: UserRef, groupInfo: GroupInfo)
case sentInvitationToContact(user: UserRef, contact: Contact, customUserProfile: Profile?)
case contactAlreadyExists(user: UserRef, contact: Contact)
case contactDeleted(user: UserRef, contact: Contact)
case contactConnectionDeleted(user: UserRef, connection: PendingContactConnection)
case groupDeletedUser(user: UserRef, groupInfo: GroupInfo)
+ case itemsReadForChat(user: UserRef, chatInfo: ChatInfo)
case chatCleared(user: UserRef, chatInfo: ChatInfo)
case userProfileNoChange(user: User)
case userProfileUpdated(user: User, fromProfile: Profile, toProfile: Profile, updateSummary: UserProfileUpdateSummary)
@@ -724,7 +792,7 @@ enum ChatResponse1: Decodable, ChatAPIResult {
case userContactLinkCreated(user: User, connLinkContact: CreatedConnLink)
case userContactLinkDeleted(user: User)
case acceptingContactRequest(user: UserRef, contact: Contact)
- case contactRequestRejected(user: UserRef)
+ case contactRequestRejected(user: UserRef, contactRequest: UserContactRequest, contact_: Contact?)
case networkStatuses(user_: UserRef?, networkStatuses: [ConnNetworkStatus])
case newChatItems(user: UserRef, chatItems: [AChatItem])
case groupChatItemsDeleted(user: UserRef, groupInfo: GroupInfo, chatItemIDs: Set, byUser: Bool, member_: GroupMember?)
@@ -742,13 +810,19 @@ enum ChatResponse1: Decodable, ChatAPIResult {
case .connectionIncognitoUpdated: "connectionIncognitoUpdated"
case .connectionUserChanged: "connectionUserChanged"
case .connectionPlan: "connectionPlan"
+ case .newPreparedChat: "newPreparedChat"
+ case .contactUserChanged: "contactUserChanged"
+ case .groupUserChanged: "groupUserChanged"
case .sentConfirmation: "sentConfirmation"
case .sentInvitation: "sentInvitation"
+ case .startedConnectionToContact: "startedConnectionToContact"
+ case .startedConnectionToGroup: "startedConnectionToGroup"
case .sentInvitationToContact: "sentInvitationToContact"
case .contactAlreadyExists: "contactAlreadyExists"
case .contactDeleted: "contactDeleted"
case .contactConnectionDeleted: "contactConnectionDeleted"
case .groupDeletedUser: "groupDeletedUser"
+ case .itemsReadForChat: "itemsReadForChat"
case .chatCleared: "chatCleared"
case .userProfileNoChange: "userProfileNoChange"
case .userProfileUpdated: "userProfileUpdated"
@@ -775,12 +849,13 @@ enum ChatResponse1: Decodable, ChatAPIResult {
case .contactsList: "contactsList"
}
}
-
+
var details: String {
switch self {
case let .contactDeleted(u, contact): return withUser(u, String(describing: contact))
case let .contactConnectionDeleted(u, connection): return withUser(u, String(describing: connection))
case let .groupDeletedUser(u, groupInfo): return withUser(u, String(describing: groupInfo))
+ case let .itemsReadForChat(u, chatInfo): return withUser(u, String(describing: chatInfo))
case let .chatCleared(u, chatInfo): return withUser(u, String(describing: chatInfo))
case .userProfileNoChange: return noDetails
case let .userProfileUpdated(u, _, toProfile, _): return withUser(u, String(describing: toProfile))
@@ -789,12 +864,12 @@ enum ChatResponse1: Decodable, ChatAPIResult {
case let .groupAliasUpdated(u, toGroup): return withUser(u, String(describing: toGroup))
case let .connectionAliasUpdated(u, toConnection): return withUser(u, String(describing: toConnection))
case let .contactPrefsUpdated(u, fromContact, toContact): return withUser(u, "fromContact: \(String(describing: fromContact))\ntoContact: \(String(describing: toContact))")
- case let .userContactLink(u, contactLink): return withUser(u, contactLink.responseDetails)
- case let .userContactLinkUpdated(u, contactLink): return withUser(u, contactLink.responseDetails)
+ case let .userContactLink(u, contactLink): return withUser(u, String(describing: contactLink))
+ case let .userContactLinkUpdated(u, contactLink): return withUser(u, String(describing: contactLink))
case let .userContactLinkCreated(u, connLink): return withUser(u, String(describing: connLink))
case .userContactLinkDeleted: return noDetails
case let .acceptingContactRequest(u, contact): return withUser(u, String(describing: contact))
- case .contactRequestRejected: return noDetails
+ case let .contactRequestRejected(u, contactRequest, contact_): return withUser(u, "contactRequest: \(String(describing: contactRequest))\ncontact_: \(String(describing: contact_))")
case let .networkStatuses(u, statuses): return withUser(u, String(describing: statuses))
case let .newChatItems(u, chatItems):
let itemsString = chatItems.map { chatItem in String(describing: chatItem) }.joined(separator: "\n")
@@ -815,8 +890,13 @@ enum ChatResponse1: Decodable, ChatAPIResult {
case let .connectionIncognitoUpdated(u, toConnection): return withUser(u, String(describing: toConnection))
case let .connectionUserChanged(u, fromConnection, toConnection, newUser): return withUser(u, "fromConnection: \(String(describing: fromConnection))\ntoConnection: \(String(describing: toConnection))\nnewUserId: \(String(describing: newUser.userId))")
case let .connectionPlan(u, connLink, connectionPlan): return withUser(u, "connLink: \(String(describing: connLink))\nconnectionPlan: \(String(describing: connectionPlan))")
+ case let .newPreparedChat(u, chat): return withUser(u, String(describing: chat))
+ case let .contactUserChanged(u, fromContact, newUser, toContact): return withUser(u, "fromContact: \(String(describing: fromContact))\nnewUserId: \(String(describing: newUser.userId))\ntoContact: \(String(describing: toContact))")
+ case let .groupUserChanged(u, fromGroup, newUser, toGroup): return withUser(u, "fromGroup: \(String(describing: fromGroup))\nnewUserId: \(String(describing: newUser.userId))\ntoGroup: \(String(describing: toGroup))")
case let .sentConfirmation(u, connection): return withUser(u, String(describing: connection))
case let .sentInvitation(u, connection): return withUser(u, String(describing: connection))
+ case let .startedConnectionToContact(u, contact): return withUser(u, String(describing: contact))
+ case let .startedConnectionToGroup(u, groupInfo): return withUser(u, String(describing: groupInfo))
case let .sentInvitationToContact(u, contact, _): return withUser(u, String(describing: contact))
case let .contactAlreadyExists(u, contact): return withUser(u, String(describing: contact))
}
@@ -831,14 +911,18 @@ enum ChatResponse2: Decodable, ChatAPIResult {
case userDeletedMembers(user: UserRef, groupInfo: GroupInfo, members: [GroupMember], withMessages: Bool)
case leftMemberUser(user: UserRef, groupInfo: GroupInfo)
case groupMembers(user: UserRef, group: SimpleXChat.Group)
+ case memberAccepted(user: UserRef, groupInfo: GroupInfo, member: GroupMember)
+ case memberSupportChatRead(user: UserRef, groupInfo: GroupInfo, member: GroupMember)
+ case memberSupportChatDeleted(user: UserRef, groupInfo: GroupInfo, member: GroupMember)
case membersRoleUser(user: UserRef, groupInfo: GroupInfo, members: [GroupMember], toRole: GroupMemberRole)
case membersBlockedForAllUser(user: UserRef, groupInfo: GroupInfo, members: [GroupMember], blocked: Bool)
case groupUpdated(user: UserRef, toGroup: GroupInfo)
- case groupLinkCreated(user: UserRef, groupInfo: GroupInfo, connLinkContact: CreatedConnLink, memberRole: GroupMemberRole)
- case groupLink(user: UserRef, groupInfo: GroupInfo, connLinkContact: CreatedConnLink, memberRole: GroupMemberRole)
+ case groupLinkCreated(user: UserRef, groupInfo: GroupInfo, groupLink: GroupLink)
+ case groupLink(user: UserRef, groupInfo: GroupInfo, groupLink: GroupLink)
case groupLinkDeleted(user: UserRef, groupInfo: GroupInfo)
case newMemberContact(user: UserRef, contact: Contact, groupInfo: GroupInfo, member: GroupMember)
case newMemberContactSentInv(user: UserRef, contact: Contact, groupInfo: GroupInfo, member: GroupMember)
+ case memberContactAccepted(user: UserRef, contact: Contact)
// receiving file responses
case rcvFileAccepted(user: UserRef, chatItem: AChatItem)
case rcvFileAcceptedSndCancelled(user: UserRef, rcvFileTransfer: RcvFileTransfer)
@@ -848,8 +932,6 @@ enum ChatResponse2: Decodable, ChatAPIResult {
// sending file responses
case sndFileCancelled(user: UserRef, chatItem_: AChatItem?, fileTransferMeta: FileTransferMeta, sndFileTransfers: [SndFileTransfer])
case sndStandaloneFileCreated(user: UserRef, fileTransferMeta: FileTransferMeta) // returned by _upload
- case sndFileStartXFTP(user: UserRef, chatItem: AChatItem, fileTransferMeta: FileTransferMeta) // not used
- case sndFileCancelledXFTP(user: UserRef, chatItem_: AChatItem?, fileTransferMeta: FileTransferMeta)
// call invitations
case callInvitations(callInvitations: [RcvCallInvitation])
// notifications
@@ -879,6 +961,9 @@ enum ChatResponse2: Decodable, ChatAPIResult {
case .userDeletedMembers: "userDeletedMembers"
case .leftMemberUser: "leftMemberUser"
case .groupMembers: "groupMembers"
+ case .memberAccepted: "memberAccepted"
+ case .memberSupportChatRead: "memberSupportChatRead"
+ case .memberSupportChatDeleted: "memberSupportChatDeleted"
case .membersRoleUser: "membersRoleUser"
case .membersBlockedForAllUser: "membersBlockedForAllUser"
case .groupUpdated: "groupUpdated"
@@ -887,6 +972,7 @@ enum ChatResponse2: Decodable, ChatAPIResult {
case .groupLinkDeleted: "groupLinkDeleted"
case .newMemberContact: "newMemberContact"
case .newMemberContactSentInv: "newMemberContactSentInv"
+ case .memberContactAccepted: "memberContactAccepted"
case .rcvFileAccepted: "rcvFileAccepted"
case .rcvFileAcceptedSndCancelled: "rcvFileAcceptedSndCancelled"
case .standaloneFileInfo: "standaloneFileInfo"
@@ -894,8 +980,6 @@ enum ChatResponse2: Decodable, ChatAPIResult {
case .rcvFileCancelled: "rcvFileCancelled"
case .sndFileCancelled: "sndFileCancelled"
case .sndStandaloneFileCreated: "sndStandaloneFileCreated"
- case .sndFileStartXFTP: "sndFileStartXFTP"
- case .sndFileCancelledXFTP: "sndFileCancelledXFTP"
case .callInvitations: "callInvitations"
case .ntfTokenStatus: "ntfTokenStatus"
case .ntfToken: "ntfToken"
@@ -923,14 +1007,18 @@ enum ChatResponse2: Decodable, ChatAPIResult {
case let .userDeletedMembers(u, groupInfo, members, withMessages): return withUser(u, "groupInfo: \(groupInfo)\nmembers: \(members)\nwithMessages: \(withMessages)")
case let .leftMemberUser(u, groupInfo): return withUser(u, String(describing: groupInfo))
case let .groupMembers(u, group): return withUser(u, String(describing: group))
+ case let .memberAccepted(u, groupInfo, member): return withUser(u, "groupInfo: \(groupInfo)\nmember: \(member)")
+ case let .memberSupportChatRead(u, groupInfo, member): return withUser(u, "groupInfo: \(groupInfo)\nmember: \(member)")
+ case let .memberSupportChatDeleted(u, groupInfo, member): return withUser(u, "groupInfo: \(groupInfo)\nmember: \(member)")
case let .membersRoleUser(u, groupInfo, members, toRole): return withUser(u, "groupInfo: \(groupInfo)\nmembers: \(members)\ntoRole: \(toRole)")
case let .membersBlockedForAllUser(u, groupInfo, members, blocked): return withUser(u, "groupInfo: \(groupInfo)\nmember: \(members)\nblocked: \(blocked)")
case let .groupUpdated(u, toGroup): return withUser(u, String(describing: toGroup))
- case let .groupLinkCreated(u, groupInfo, connLinkContact, memberRole): return withUser(u, "groupInfo: \(groupInfo)\nconnLinkContact: \(connLinkContact)\nmemberRole: \(memberRole)")
- case let .groupLink(u, groupInfo, connLinkContact, memberRole): return withUser(u, "groupInfo: \(groupInfo)\nconnLinkContact: \(connLinkContact)\nmemberRole: \(memberRole)")
+ case let .groupLinkCreated(u, groupInfo, groupLink): return withUser(u, "groupInfo: \(groupInfo)\ngroupLink: \(groupLink)")
+ case let .groupLink(u, groupInfo, groupLink): return withUser(u, "groupInfo: \(groupInfo)\ngroupLink: \(groupLink)")
case let .groupLinkDeleted(u, groupInfo): return withUser(u, String(describing: groupInfo))
case let .newMemberContact(u, contact, groupInfo, member): return withUser(u, "contact: \(contact)\ngroupInfo: \(groupInfo)\nmember: \(member)")
case let .newMemberContactSentInv(u, contact, groupInfo, member): return withUser(u, "contact: \(contact)\ngroupInfo: \(groupInfo)\nmember: \(member)")
+ case let .memberContactAccepted(u, contact): return withUser(u, "contact: \(contact)")
case let .rcvFileAccepted(u, chatItem): return withUser(u, String(describing: chatItem))
case .rcvFileAcceptedSndCancelled: return noDetails
case let .standaloneFileInfo(fileMeta): return String(describing: fileMeta)
@@ -938,8 +1026,6 @@ enum ChatResponse2: Decodable, ChatAPIResult {
case let .rcvFileCancelled(u, chatItem, _): return withUser(u, String(describing: chatItem))
case let .sndFileCancelled(u, chatItem, _, _): return withUser(u, String(describing: chatItem))
case .sndStandaloneFileCreated: return noDetails
- case let .sndFileStartXFTP(u, chatItem, _): return withUser(u, String(describing: chatItem))
- case let .sndFileCancelledXFTP(u, chatItem, _): return withUser(u, String(describing: chatItem))
case let .callInvitations(invs): return String(describing: invs)
case let .ntfTokenStatus(status): return String(describing: status)
case let .ntfToken(token, status, ntfMode, ntfServer): return "token: \(token)\nstatus: \(status.rawValue)\nntfMode: \(ntfMode.rawValue)\nntfServer: \(ntfServer)"
@@ -970,12 +1056,13 @@ enum ChatEvent: Decodable, ChatAPIResult {
case contactConnected(user: UserRef, contact: Contact, userCustomProfile: Profile?)
case contactConnecting(user: UserRef, contact: Contact)
case contactSndReady(user: UserRef, contact: Contact)
- case receivedContactRequest(user: UserRef, contactRequest: UserContactRequest)
+ case receivedContactRequest(user: UserRef, contactRequest: UserContactRequest, chat_: ChatData?)
case contactUpdated(user: UserRef, toContact: Contact)
case groupMemberUpdated(user: UserRef, groupInfo: GroupInfo, fromMember: GroupMember, toMember: GroupMember)
case contactsMerged(user: UserRef, intoContact: Contact, mergedContact: Contact)
case networkStatus(networkStatus: NetworkStatus, connections: [String])
case networkStatuses(user_: UserRef?, networkStatuses: [ConnNetworkStatus])
+ case chatInfoUpdated(user: UserRef, chatInfo: ChatInfo)
case newChatItems(user: UserRef, chatItems: [AChatItem])
case chatItemsStatusesUpdated(user: UserRef, chatItems: [AChatItem])
case chatItemUpdated(user: UserRef, chatItem: AChatItem)
@@ -988,6 +1075,7 @@ enum ChatEvent: Decodable, ChatAPIResult {
case groupLinkConnecting(user: UserRef, groupInfo: GroupInfo, hostMember: GroupMember)
case businessLinkConnecting(user: UserRef, groupInfo: GroupInfo, hostMember: GroupMember, fromContact: Contact)
case joinedGroupMemberConnecting(user: UserRef, groupInfo: GroupInfo, hostMember: GroupMember, member: GroupMember)
+ case memberAcceptedByOther(user: UserRef, groupInfo: GroupInfo, acceptingMember: GroupMember, member: GroupMember)
case memberRole(user: UserRef, groupInfo: GroupInfo, byMember: GroupMember, member: GroupMember, fromRole: GroupMemberRole, toRole: GroupMemberRole)
case memberBlockedForAll(user: UserRef, groupInfo: GroupInfo, byMember: GroupMember, member: GroupMember, blocked: Bool)
case deletedMemberUser(user: UserRef, groupInfo: GroupInfo, member: GroupMember, withMessages: Bool)
@@ -1035,7 +1123,7 @@ enum ChatEvent: Decodable, ChatAPIResult {
case remoteCtrlStopped(rcsState: RemoteCtrlSessionState, rcStopReason: RemoteCtrlStopReason)
// pq
case contactPQEnabled(user: UserRef, contact: Contact, pqEnabled: Bool)
-
+
var responseType: String {
switch self {
case .chatSuspended: "chatSuspended"
@@ -1053,6 +1141,7 @@ enum ChatEvent: Decodable, ChatAPIResult {
case .contactsMerged: "contactsMerged"
case .networkStatus: "networkStatus"
case .networkStatuses: "networkStatuses"
+ case .chatInfoUpdated: "chatInfoUpdated"
case .newChatItems: "newChatItems"
case .chatItemsStatusesUpdated: "chatItemsStatusesUpdated"
case .chatItemUpdated: "chatItemUpdated"
@@ -1064,6 +1153,7 @@ enum ChatEvent: Decodable, ChatAPIResult {
case .groupLinkConnecting: "groupLinkConnecting"
case .businessLinkConnecting: "businessLinkConnecting"
case .joinedGroupMemberConnecting: "joinedGroupMemberConnecting"
+ case .memberAcceptedByOther: "memberAcceptedByOther"
case .memberRole: "memberRole"
case .memberBlockedForAll: "memberBlockedForAll"
case .deletedMemberUser: "deletedMemberUser"
@@ -1107,7 +1197,7 @@ enum ChatEvent: Decodable, ChatAPIResult {
case .contactPQEnabled: "contactPQEnabled"
}
}
-
+
var details: String {
switch self {
case .chatSuspended: return noDetails
@@ -1119,12 +1209,13 @@ enum ChatEvent: Decodable, ChatAPIResult {
case let .contactConnected(u, contact, _): return withUser(u, String(describing: contact))
case let .contactConnecting(u, contact): return withUser(u, String(describing: contact))
case let .contactSndReady(u, contact): return withUser(u, String(describing: contact))
- case let .receivedContactRequest(u, contactRequest): return withUser(u, String(describing: contactRequest))
+ case let .receivedContactRequest(u, contactRequest, chat_): return withUser(u, "contactRequest: \(String(describing: contactRequest))\nchat_: \(String(describing: chat_))")
case let .contactUpdated(u, toContact): return withUser(u, String(describing: toContact))
case let .groupMemberUpdated(u, groupInfo, fromMember, toMember): return withUser(u, "groupInfo: \(groupInfo)\nfromMember: \(fromMember)\ntoMember: \(toMember)")
case let .contactsMerged(u, intoContact, mergedContact): return withUser(u, "intoContact: \(intoContact)\nmergedContact: \(mergedContact)")
case let .networkStatus(status, conns): return "networkStatus: \(String(describing: status))\nconnections: \(String(describing: conns))"
case let .networkStatuses(u, statuses): return withUser(u, String(describing: statuses))
+ case let .chatInfoUpdated(u, chatInfo): return withUser(u, String(describing: chatInfo))
case let .newChatItems(u, chatItems):
let itemsString = chatItems.map { chatItem in String(describing: chatItem) }.joined(separator: "\n")
return withUser(u, itemsString)
@@ -1144,6 +1235,7 @@ enum ChatEvent: Decodable, ChatAPIResult {
case let .groupLinkConnecting(u, groupInfo, hostMember): return withUser(u, "groupInfo: \(groupInfo)\nhostMember: \(String(describing: hostMember))")
case let .businessLinkConnecting(u, groupInfo, hostMember, fromContact): return withUser(u, "groupInfo: \(groupInfo)\nhostMember: \(String(describing: hostMember))\nfromContact: \(String(describing: fromContact))")
case let .joinedGroupMemberConnecting(u, groupInfo, hostMember, member): return withUser(u, "groupInfo: \(groupInfo)\nhostMember: \(hostMember)\nmember: \(member)")
+ case let .memberAcceptedByOther(u, groupInfo, acceptingMember, member): return withUser(u, "groupInfo: \(groupInfo)\nacceptingMember: \(acceptingMember)\nmember: \(member)")
case let .memberRole(u, groupInfo, byMember, member, fromRole, toRole): return withUser(u, "groupInfo: \(groupInfo)\nbyMember: \(byMember)\nmember: \(member)\nfromRole: \(fromRole)\ntoRole: \(toRole)")
case let .memberBlockedForAll(u, groupInfo, byMember, member, blocked): return withUser(u, "groupInfo: \(groupInfo)\nbyMember: \(byMember)\nmember: \(member)\nblocked: \(blocked)")
case let .deletedMemberUser(u, groupInfo, member, withMessages): return withUser(u, "groupInfo: \(groupInfo)\nmember: \(member)\nwithMessages: \(withMessages)")
@@ -1186,7 +1278,7 @@ enum ChatEvent: Decodable, ChatAPIResult {
case let .remoteCtrlStopped(rcsState, rcStopReason): return "rcsState: \(String(describing: rcsState))\nrcStopReason: \(String(describing: rcStopReason))"
case let .contactPQEnabled(u, contact, pqEnabled): return withUser(u, "contact: \(String(describing: contact))\npqEnabled: \(pqEnabled)")
}
- }
+ }
}
struct NewUser: Encodable {
@@ -1224,14 +1316,14 @@ enum ConnectionPlan: Decodable, Hashable {
}
enum InvitationLinkPlan: Decodable, Hashable {
- case ok
+ case ok(contactSLinkData_: ContactShortLinkData?)
case ownLink
case connecting(contact_: Contact?)
case known(contact: Contact)
}
enum ContactAddressPlan: Decodable, Hashable {
- case ok
+ case ok(contactSLinkData_: ContactShortLinkData?)
case ownLink
case connectingConfirmReconnect
case connectingProhibit(contact: Contact)
@@ -1240,7 +1332,7 @@ enum ContactAddressPlan: Decodable, Hashable {
}
enum GroupLinkPlan: Decodable, Hashable {
- case ok
+ case ok(groupSLinkData_: GroupShortLinkData?)
case ownLink(groupInfo: GroupInfo)
case connectingConfirmReconnect
case connectingProhibit(groupInfo_: GroupInfo?)
@@ -1255,7 +1347,7 @@ struct ChatTagData: Encodable {
struct UpdatedMessage: Encodable {
var msgContent: MsgContent
var mentions: [String: Int64]
-
+
var cmdString: String {
"json \(encodeJSON(self))"
}
@@ -1331,34 +1423,56 @@ struct UserMsgReceiptSettings: Codable {
var clearOverrides: Bool
}
+protocol SimplexAddress {
+ var connLinkContact: CreatedConnLink { get }
+ var shortLinkDataSet: Bool { get }
+ var shortLinkLargeDataSet: Bool { get }
+}
-struct UserContactLink: Decodable, Hashable {
- var connLinkContact: CreatedConnLink
- var autoAccept: AutoAccept?
+extension SimplexAddress {
+ var shouldBeUpgraded: Bool {
+ connLinkContact.connShortLink == nil || !shortLinkDataSet || !shortLinkLargeDataSet
+ }
- var responseDetails: String {
- "connLinkContact: \(connLinkContact)\nautoAccept: \(AutoAccept.cmdString(autoAccept))"
+ func shareAddress(short: Bool) {
+ showShareSheet(items: [simplexChatLink(connLinkContact.simplexChatUri(short: short))])
}
}
-struct AutoAccept: Codable, Hashable {
- var businessAddress: Bool
- var acceptIncognito: Bool
- var autoReply: MsgContent?
+struct UserContactLink: Decodable, Hashable, SimplexAddress {
+ var connLinkContact: CreatedConnLink
+ var shortLinkDataSet: Bool
+ var shortLinkLargeDataSet: Bool
+ var addressSettings: AddressSettings
- static func cmdString(_ autoAccept: AutoAccept?) -> String {
- guard let autoAccept = autoAccept else { return "off" }
- var s = "on"
- if autoAccept.acceptIncognito {
- s += " incognito=on"
- } else if autoAccept.businessAddress {
- s += " business"
- }
- guard let msg = autoAccept.autoReply else { return s }
- return s + " " + msg.cmdString
+ init(_ ccLink: CreatedConnLink) {
+ connLinkContact = ccLink
+ let slDataSet = ccLink.connShortLink != nil
+ shortLinkDataSet = slDataSet
+ shortLinkLargeDataSet = slDataSet
+ addressSettings = AddressSettings(businessAddress: false)
}
}
+struct AddressSettings: Codable, Hashable {
+ var businessAddress: Bool
+ var autoAccept: AutoAccept?
+ var autoReply: MsgContent?
+}
+
+struct AutoAccept: Codable, Hashable {
+ var acceptIncognito: Bool
+}
+
+struct GroupLink: Decodable, Hashable, SimplexAddress {
+ var userContactLinkId: Int64
+ var connLinkContact: CreatedConnLink
+ var shortLinkDataSet: Bool
+ var shortLinkLargeDataSet: Bool
+ var groupLinkId: String
+ var acceptMemberRole: GroupMemberRole
+}
+
struct DeviceToken: Decodable {
var pushProvider: PushProvider
var token: String
@@ -1876,13 +1990,13 @@ struct ProtocolTestFailure: Decodable, Error, Equatable {
let err = String.localizedStringWithFormat(NSLocalizedString("Test failed at step %@.", comment: "server test failure"), testStep.text)
switch testError {
case .SMP(_, .AUTH):
- return err + " " + NSLocalizedString("Server requires authorization to create queues, check password", comment: "server test error")
+ return err + " " + NSLocalizedString("Server requires authorization to create queues, check password.", comment: "server test error")
case .XFTP(.AUTH):
- return err + " " + NSLocalizedString("Server requires authorization to upload, check password", comment: "server test error")
- case .BROKER(_, .NETWORK):
- return err + " " + NSLocalizedString("Possibly, certificate fingerprint in server address is incorrect", comment: "server test error")
+ return err + " " + NSLocalizedString("Server requires authorization to upload, check password.", comment: "server test error")
+ case .BROKER(_, .NETWORK(.unknownCAError)):
+ return err + " " + NSLocalizedString("Fingerprint in server address does not match certificate.", comment: "server test error")
default:
- return err
+ return err + " " + String.localizedStringWithFormat(NSLocalizedString("Error: %@.", comment: "server test error"), String(describing: testError))
}
}
}
diff --git a/apps/ios/Shared/Model/ChatModel.swift b/apps/ios/Shared/Model/ChatModel.swift
index 9b9fda0397..e5fd6362a3 100644
--- a/apps/ios/Shared/Model/ChatModel.swift
+++ b/apps/ios/Shared/Model/ChatModel.swift
@@ -34,7 +34,7 @@ actor TerminalItems {
await add(.cmd(start, cmd))
await addResult(res)
}
-
+
func addResult(_ res: APIResult) async {
let item: TerminalItem = switch res {
case let .result(r): .res(.now, r)
@@ -52,8 +52,26 @@ private func addTermItem(_ items: inout [TerminalItem], _ item: TerminalItem) {
items.append(item)
}
+// analogue for SecondaryContextFilter in Kotlin
+enum SecondaryItemsModelFilter {
+ case groupChatScopeContext(groupScopeInfo: GroupChatScopeInfo)
+ case msgContentTagContext(contentTag: MsgContentTag)
+
+ func descr() -> String {
+ switch self {
+ case let .groupChatScopeContext(groupScopeInfo):
+ return "groupChatScopeContext \(groupScopeInfo.toChatScope())"
+ case let .msgContentTagContext(contentTag):
+ return "msgContentTagContext \(contentTag.rawValue)"
+ }
+ }
+}
+
+// analogue for ChatsContext in Kotlin
class ItemsModel: ObservableObject {
- static let shared = ItemsModel()
+ static let shared = ItemsModel(secondaryIMFilter: nil)
+ public var secondaryIMFilter: SecondaryItemsModelFilter?
+ public var preloadState = PreloadState()
private let publisher = ObservableObjectPublisher()
private var bag = Set()
var reversedChatItems: [ChatItem] = [] {
@@ -77,13 +95,20 @@ class ItemsModel: ObservableObject {
chatState.splits.isEmpty || chatState.splits.first != reversedChatItems.first?.id
}
- init() {
+ init(secondaryIMFilter: SecondaryItemsModelFilter? = nil) {
+ self.secondaryIMFilter = secondaryIMFilter
publisher
.throttle(for: 0.2, scheduler: DispatchQueue.main, latest: true)
.sink { self.objectWillChange.send() }
.store(in: &bag)
}
+ static func loadSecondaryChat(_ chatId: ChatId, chatFilter: SecondaryItemsModelFilter, willNavigate: @escaping () -> Void = {}) {
+ let im = ItemsModel(secondaryIMFilter: chatFilter)
+ ChatModel.shared.secondaryIM = im
+ im.loadOpenChat(chatId, willNavigate: willNavigate)
+ }
+
func loadOpenChat(_ chatId: ChatId, willNavigate: @escaping () -> Void = {}) {
navigationTimeoutTask?.cancel()
loadChatTask?.cancel()
@@ -99,7 +124,7 @@ class ItemsModel: ObservableObject {
loadChatTask = Task {
await MainActor.run { self.isLoading = true }
// try? await Task.sleep(nanoseconds: 1000_000000)
- await loadChat(chatId: chatId)
+ await loadChat(chatId: chatId, im: self)
if !Task.isCancelled {
await MainActor.run {
self.isLoading = false
@@ -114,7 +139,7 @@ class ItemsModel: ObservableObject {
loadChatTask?.cancel()
loadChatTask = Task {
// try? await Task.sleep(nanoseconds: 1000_000000)
- await loadChat(chatId: chatId, openAroundItemId: openAroundItemId, clearItems: openAroundItemId == nil)
+ await loadChat(chatId: chatId, im: self, openAroundItemId: openAroundItemId, clearItems: openAroundItemId == nil)
if !Task.isCancelled {
await MainActor.run {
if openAroundItemId == nil {
@@ -124,16 +149,44 @@ class ItemsModel: ObservableObject {
}
}
}
+
+ public var contentTag: MsgContentTag? {
+ switch secondaryIMFilter {
+ case nil: nil
+ case .groupChatScopeContext: nil
+ case let .msgContentTagContext(contentTag): contentTag
+ }
+ }
+
+ public var groupScopeInfo: GroupChatScopeInfo? {
+ switch secondaryIMFilter {
+ case nil: nil
+ case let .groupChatScopeContext(scopeInfo): scopeInfo
+ case .msgContentTagContext: nil
+ }
+ }
+}
+
+class PreloadState {
+ var prevFirstVisible: Int64 = Int64.min
+ var prevItemsCount: Int = 0
+ var preloading: Bool = false
+
+ func clear() {
+ prevFirstVisible = Int64.min
+ prevItemsCount = 0
+ preloading = false
+ }
}
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] = [:]
@@ -187,13 +240,13 @@ class ChatTagsModel: ObservableObject {
}
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
@@ -261,6 +314,41 @@ class ChatItemDummyModel: ObservableObject {
func sendUpdate() { objectWillChange.send() }
}
+class ConnectProgressManager: ObservableObject {
+ @Published private var connectInProgress: String? = nil
+ @Published private var connectProgressByTimeout: Bool = false
+ private var onCancel: (() -> Void)?
+
+ static let shared = ConnectProgressManager()
+
+ func startConnectProgress(_ text: String, onCancel: (() -> Void)? = nil) {
+ connectInProgress = text
+ self.onCancel = onCancel
+ DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
+ self.connectProgressByTimeout = self.connectInProgress != nil
+ }
+ }
+
+ func stopConnectProgress() {
+ connectInProgress = nil
+ onCancel = nil
+ connectProgressByTimeout = false
+ }
+
+ func cancelConnectProgress() {
+ onCancel?()
+ stopConnectProgress()
+ }
+
+ var showConnectProgress: String? {
+ connectProgressByTimeout ? connectInProgress : nil
+ }
+
+ var isInProgress: Bool {
+ connectInProgress != nil
+ }
+}
+
final class ChatModel: ObservableObject {
@Published var onboardingStage: OnboardingStage?
@Published var setDeliveryReceipts = false
@@ -287,7 +375,6 @@ final class ChatModel: ObservableObject {
// current chat
@Published var chatId: String?
@Published var openAroundItemId: ChatItem.ID? = nil
- var chatItemStatuses: Dictionary = [:]
@Published var chatToTop: String?
@Published var groupMembers: [GMember] = []
@Published var groupMembersIndexes: Dictionary = [:] // groupMemberId to index in groupMembers list
@@ -298,6 +385,7 @@ final class ChatModel: ObservableObject {
@Published var userAddress: UserContactLink?
@Published var chatItemTTL: ChatItemTTL = .none
@Published var appOpenUrl: URL?
+ @Published var appOpenUrlLater: URL?
@Published var deviceToken: DeviceToken?
@Published var savedToken: DeviceToken?
@Published var tokenRegistered = false
@@ -336,6 +424,10 @@ final class ChatModel: ObservableObject {
let im = ItemsModel.shared
+ // ItemsModel for secondary chat view (such as support scope chat), as opposed to ItemsModel.shared used for primary chat
+ @Published var secondaryIM: ItemsModel? = nil
+ @Published var secondaryPendingInviteeChatOpened = false
+
static var ok: Bool { ChatModel.shared.chatDbStatus == .ok }
let ntfEnableLocal = true
@@ -348,6 +440,10 @@ final class ChatModel: ObservableObject {
remoteCtrlSession?.active ?? false
}
+ var addressShortLinkDataSet: Bool {
+ userAddress?.shortLinkDataSet ?? true
+ }
+
func getUser(_ userId: Int64) -> User? {
currentUser?.userId == userId
? currentUser
@@ -393,7 +489,7 @@ final class ChatModel: ObservableObject {
func getGroupChat(_ groupId: Int64) -> Chat? {
chats.first { chat in
- if case let .group(groupInfo) = chat.chatInfo {
+ if case let .group(groupInfo, _) = chat.chatInfo {
return groupInfo.groupId == groupId
} else {
return false
@@ -446,7 +542,11 @@ final class ChatModel: ObservableObject {
func updateChatInfo(_ cInfo: ChatInfo) {
if let i = getChatIndex(cInfo.id) {
- chats[i].chatInfo = cInfo
+ if case let .group(groupInfo, groupChatScope) = cInfo, groupChatScope != nil {
+ chats[i].chatInfo = .group(groupInfo: groupInfo, groupChatScope: nil)
+ } else {
+ chats[i].chatInfo = cInfo
+ }
chats[i].created = Date.now
}
}
@@ -468,7 +568,7 @@ final class ChatModel: ObservableObject {
}
func updateGroup(_ groupInfo: GroupInfo) {
- updateChat(.group(groupInfo: groupInfo))
+ updateChat(.group(groupInfo: groupInfo, groupChatScope: nil))
}
private func updateChat(_ cInfo: ChatInfo, addMissing: Bool = true) {
@@ -500,8 +600,15 @@ final class ChatModel: ObservableObject {
}
}
- func updateChats(_ newChats: [ChatData]) {
- chats = newChats.map { Chat($0) }
+ func updateChats(_ newChats: [ChatData], keepingChatId: String? = nil) {
+ if let keepingChatId,
+ let chatToKeep = getChat(keepingChatId),
+ let i = newChats.firstIndex(where: { $0.id == keepingChatId }) {
+ let remainingNewChats = Array(newChats[..= currentPreviewItem.meta.itemTs {
- [cItem]
+ // update preview
+ if cInfo.groupChatScope() == nil || cInfo.groupInfo?.membership.memberPending ?? false {
+ chats[i].chatItems = switch cInfo {
+ case .group:
+ if let currentPreviewItem = chats[i].chatItems.first {
+ if cItem.meta.itemTs >= currentPreviewItem.meta.itemTs {
+ [cItem]
+ } else {
+ [currentPreviewItem]
+ }
} else {
- [currentPreviewItem]
+ [cItem]
}
- } else {
+ default:
[cItem]
}
- default:
- [cItem]
- }
- if case .rcvNew = cItem.meta.itemStatus {
- unreadCollector.changeUnreadCounter(cInfo.id, by: 1, unreadMentions: cItem.meta.userMention ? 1 : 0)
+ if case .rcvNew = cItem.meta.itemStatus {
+ unreadCollector.changeUnreadCounter(cInfo.id, by: 1, unreadMentions: cItem.meta.userMention ? 1 : 0)
+ }
}
+ // pop chat
popChatCollector.throttlePopChat(cInfo.id, currentPosition: i)
} else {
- addChat(Chat(chatInfo: cInfo, chatItems: [cItem]))
+ if cInfo.groupChatScope() == nil {
+ addChat(Chat(chatInfo: cInfo, chatItems: [cItem]))
+ } else {
+ addChat(Chat(chatInfo: cInfo, chatItems: []))
+ }
}
- // add to current chat
- if chatId == cInfo.id {
- _ = _upsertChatItem(cInfo, cItem)
+ // add to current scope
+ if let ciIM = getCIItemsModel(cInfo, cItem) {
+ _ = _upsertChatItem(ciIM, cInfo, cItem)
+ }
+ }
+
+ func getCIItemsModel(_ cInfo: ChatInfo, _ ci: ChatItem) -> ItemsModel? {
+ let cInfoScope = cInfo.groupChatScope()
+ return if let cInfoScope = cInfoScope {
+ switch (cInfoScope, secondaryIM?.secondaryIMFilter) {
+ case let (.memberSupport, .some(.groupChatScopeContext(groupScopeInfo))):
+ // Chat with member or Chat with admins opened (secondaryIM has .groupChatScopeContext filter), cInfo has matching scope
+ (cInfo.id == chatId && sameChatScope(cInfoScope, groupScopeInfo.toChatScope())) ? secondaryIM : nil
+
+ case let (.memberSupport, .some(.msgContentTagContext(contentTag))):
+ // Reports view opened (secondaryIM has .msgContentTagContext(.report) filter), we process event (cInfo has proper .memberSupport scope)
+ (cInfo.id == chatId && ci.isReport && contentTag == .report) ? secondaryIM : nil
+
+ case let (.reports, .some(.msgContentTagContext(contentTag))):
+ // Reports view opened (secondaryIM has .msgContentTagContext(.report) filter), we process user action (cInfo has surrogate .reports scope)
+ (cInfo.id == chatId && ci.isReport && contentTag == .report) ? secondaryIM : nil
+ default:
+ nil
+ }
+ } else {
+ cInfo.id == chatId ? im : nil
}
}
func upsertChatItem(_ cInfo: ChatInfo, _ cItem: ChatItem) -> Bool {
- // update previews
- var res: Bool
- if let chat = getChat(cInfo.id) {
- if let pItem = chat.chatItems.last {
- if pItem.id == cItem.id || (chatId == cInfo.id && im.reversedChatItems.first(where: { $0.id == cItem.id }) == nil) {
+ // update chat list
+ var itemAdded: Bool = false
+ if cInfo.groupChatScope() == nil {
+ if let chat = getChat(cInfo.id) {
+ if let pItem = chat.chatItems.last {
+ if pItem.id == cItem.id || (chatId == cInfo.id && im.reversedChatItems.first(where: { $0.id == cItem.id }) == nil) {
+ chat.chatItems = [cItem]
+ }
+ } else {
chat.chatItems = [cItem]
}
} else {
- chat.chatItems = [cItem]
+ addChat(Chat(chatInfo: cInfo, chatItems: [cItem]))
+ itemAdded = true
+ }
+ if cItem.isDeletedContent || cItem.meta.itemDeleted != nil {
+ VoiceItemState.stopVoiceInChatView(cInfo, cItem)
}
- res = false
- } else {
- addChat(Chat(chatInfo: cInfo, chatItems: [cItem]))
- res = true
}
- if cItem.isDeletedContent || cItem.meta.itemDeleted != nil {
- VoiceItemState.stopVoiceInChatView(cInfo, cItem)
+ // update current scope
+ if let ciIM = getCIItemsModel(cInfo, cItem) {
+ itemAdded = _upsertChatItem(ciIM, cInfo, cItem)
}
- // update current chat
- return chatId == cInfo.id ? _upsertChatItem(cInfo, cItem) : res
+ return itemAdded
}
- private func _upsertChatItem(_ cInfo: ChatInfo, _ cItem: ChatItem) -> Bool {
- if let i = getChatItemIndex(cItem) {
- _updateChatItem(at: i, with: cItem)
- ChatItemDummyModel.shared.sendUpdate()
+ private func _upsertChatItem(_ ciIM: ItemsModel, _ cInfo: ChatInfo, _ cItem: ChatItem) -> Bool {
+ if let i = getChatItemIndex(ciIM, cItem) {
+ let oldStatus = ciIM.reversedChatItems[i].meta.itemStatus
+ let newStatus = cItem.meta.itemStatus
+ var ci = cItem
+ if shouldKeepOldSndCIStatus(oldStatus: oldStatus, newStatus: newStatus) {
+ ci.meta.itemStatus = oldStatus
+ }
+ _updateChatItem(ciIM: ciIM, at: i, with: ci)
+ ChatItemDummyModel.shared.sendUpdate() // TODO [knocking] review what's this
return false
} else {
- var ci = cItem
- if let status = chatItemStatuses.removeValue(forKey: ci.id), case .sndNew = ci.meta.itemStatus {
- 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
+ ciIM.reversedChatItems.insert(cItem, at: hasLiveDummy ? 1 : 0)
+ ciIM.chatState.itemAdded((cItem.id, cItem.isRcvNew), hasLiveDummy ? 1 : 0)
+ ciIM.itemAdded = true
ChatItemDummyModel.shared.sendUpdate()
return true
}
@@ -595,40 +744,42 @@ final class ChatModel: ObservableObject {
}
func updateChatItem(_ cInfo: ChatInfo, _ cItem: ChatItem, status: CIStatus? = nil) {
- if chatId == cInfo.id, let i = getChatItemIndex(cItem) {
+ if let ciIM = getCIItemsModel(cInfo, cItem),
+ let i = getChatItemIndex(ciIM, cItem) {
withConditionalAnimation {
- _updateChatItem(at: i, with: cItem)
+ _updateChatItem(ciIM: ciIM, at: i, with: cItem)
}
- } else if let status = status {
- chatItemStatuses.updateValue(status, forKey: cItem.id)
}
}
- private func _updateChatItem(at i: Int, with cItem: ChatItem) {
- im.reversedChatItems[i] = cItem
- im.reversedChatItems[i].viewTimestamp = .now
+ private func _updateChatItem(ciIM: ItemsModel, at i: Int, with cItem: ChatItem) {
+ ciIM.reversedChatItems[i] = cItem
+ ciIM.reversedChatItems[i].viewTimestamp = .now
}
- func getChatItemIndex(_ cItem: ChatItem) -> Int? {
- im.reversedChatItems.firstIndex(where: { $0.id == cItem.id })
+ func getChatItemIndex(_ ciIM: ItemsModel, _ cItem: ChatItem) -> Int? {
+ ciIM.reversedChatItems.firstIndex(where: { $0.id == cItem.id })
}
func removeChatItem(_ cInfo: ChatInfo, _ cItem: ChatItem) {
- if cItem.isRcvNew {
- unreadCollector.changeUnreadCounter(cInfo.id, by: -1, unreadMentions: cItem.meta.userMention ? -1 : 0)
- }
- // update previews
- if let chat = getChat(cInfo.id) {
- if let pItem = chat.chatItems.last, pItem.id == cItem.id {
- chat.chatItems = [ChatItem.deletedItemDummy()]
+ // update chat list
+ if cInfo.groupChatScope() == nil {
+ if cItem.isRcvNew {
+ unreadCollector.changeUnreadCounter(cInfo.id, by: -1, unreadMentions: cItem.meta.userMention ? -1 : 0)
+ }
+ // update previews
+ if let chat = getChat(cInfo.id) {
+ if let pItem = chat.chatItems.last, pItem.id == cItem.id {
+ chat.chatItems = [ChatItem.deletedItemDummy()]
+ }
}
}
- // remove from current chat
- if chatId == cInfo.id {
- if let i = getChatItemIndex(cItem) {
+ // remove from current scope
+ if let ciIM = getCIItemsModel(cInfo, cItem) {
+ if let i = getChatItemIndex(ciIM, cItem) {
withAnimation {
- let item = im.reversedChatItems.remove(at: i)
- im.chatState.itemsRemoved([(item.id, i, item.isRcvNew)], im.reversedChatItems.reversed())
+ let item = ciIM.reversedChatItems.remove(at: i)
+ ciIM.chatState.itemsRemoved([(item.id, i, item.isRcvNew)], im.reversedChatItems.reversed())
}
}
}
@@ -644,7 +795,7 @@ final class ChatModel: ObservableObject {
if chatId == groupInfo.id {
for i in 0.. ChatItem? {
let newContent: CIContent
if case .groupSnd = item.chatDir, removedMember.groupMemberId == groupInfo.membership.groupMemberId {
@@ -736,7 +887,7 @@ final class ChatModel: ObservableObject {
im.reversedChatItems.first?.isLiveDummy == true
}
- func markAllChatItemsRead(_ cInfo: ChatInfo) {
+ func markAllChatItemsRead(_ chatIM: ItemsModel, _ cInfo: ChatInfo) {
// update preview
_updateChat(cInfo.id) { chat in
self.decreaseUnreadCounter(user: self.currentUser!, chat: chat)
@@ -747,7 +898,7 @@ final class ChatModel: ObservableObject {
if chatId == cInfo.id {
var i = 0
while i < im.reversedChatItems.count {
- markChatItemRead_(i)
+ markChatItemRead_(chatIM, i)
i += 1
}
im.chatState.itemsRead(nil, im.reversedChatItems.reversed())
@@ -772,27 +923,26 @@ final class ChatModel: ObservableObject {
}
// clear current chat
if chatId == cInfo.id {
- chatItemStatuses = [:]
im.reversedChatItems = []
im.chatState.clear()
}
}
- func markChatItemsRead(_ cInfo: ChatInfo, _ itemIds: [ChatItem.ID], _ mentionsRead: Int) {
+ func markChatItemsRead(_ chatIM: ItemsModel, _ cInfo: ChatInfo, _ itemIds: [ChatItem.ID], _ mentionsRead: Int) {
if self.chatId == cInfo.id {
var unreadItemIds: Set = []
var i = 0
var ids = Set(itemIds)
- while i < im.reversedChatItems.count && !ids.isEmpty {
- let item = im.reversedChatItems[i]
+ while i < chatIM.reversedChatItems.count && !ids.isEmpty {
+ let item = chatIM.reversedChatItems[i]
if ids.contains(item.id) && item.isRcvNew {
- markChatItemRead_(i)
+ markChatItemRead_(chatIM, i)
unreadItemIds.insert(item.id)
ids.remove(item.id)
}
i += 1
}
- im.chatState.itemsRead(unreadItemIds, im.reversedChatItems.reversed())
+ chatIM.chatState.itemsRead(unreadItemIds, chatIM.reversedChatItems.reversed())
}
self.unreadCollector.changeUnreadCounter(cInfo.id, by: -itemIds.count, unreadMentions: -mentionsRead)
}
@@ -827,7 +977,7 @@ final class ChatModel: ObservableObject {
}
let popChatCollector = PopChatCollector()
-
+
class PopChatCollector {
private let subject = PassthroughSubject()
private var bag = Set()
@@ -840,7 +990,7 @@ final class ChatModel: ObservableObject {
.sink { self.popCollectedChats() }
.store(in: &bag)
}
-
+
func throttlePopChat(_ chatId: ChatId, currentPosition: Int) {
let m = ChatModel.shared
if currentPosition > 0 && m.chatId == chatId {
@@ -851,7 +1001,7 @@ final class ChatModel: ObservableObject {
subject.send()
}
}
-
+
func clear() {
chatsToPop = [:]
}
@@ -888,13 +1038,13 @@ final class ChatModel: ObservableObject {
}
}
- private func markChatItemRead_(_ i: Int) {
- let meta = im.reversedChatItems[i].meta
+ private func markChatItemRead_(_ chatIM: ItemsModel, _ i: Int) {
+ let meta = chatIM.reversedChatItems[i].meta
if case .rcvNew = meta.itemStatus {
- im.reversedChatItems[i].meta.itemStatus = .rcvRead
- im.reversedChatItems[i].viewTimestamp = .now
+ chatIM.reversedChatItems[i].meta.itemStatus = .rcvRead
+ chatIM.reversedChatItems[i].viewTimestamp = .now
if meta.itemLive != true, let ttl = meta.itemTimed?.ttl {
- im.reversedChatItems[i].meta.itemTimed?.deleteAt = .now + TimeInterval(ttl)
+ chatIM.reversedChatItems[i].meta.itemTimed?.deleteAt = .now + TimeInterval(ttl)
}
}
}
@@ -973,7 +1123,7 @@ final class ChatModel: ObservableObject {
var count = 0
var ns: [String] = []
if let ciCategory = chatItem.mergeCategory,
- var i = getChatItemIndex(chatItem) {
+ var i = getChatItemIndex(im, chatItem) { // TODO [knocking] review: use getCIItemsModel?
while i < im.reversedChatItems.count {
let ci = im.reversedChatItems[i]
if ci.mergeCategory != ciCategory { break }
@@ -989,7 +1139,7 @@ final class ChatModel: ObservableObject {
// returns the index of the passed item and the next item (it has smaller index)
func getNextChatItem(_ ci: ChatItem) -> (Int?, ChatItem?) {
- if let i = getChatItemIndex(ci) {
+ if let i = getChatItemIndex(im, ci) { // TODO [knocking] review: use getCIItemsModel?
(i, i > 0 ? im.reversedChatItems[i - 1] : nil)
} else {
(nil, nil)
@@ -1100,7 +1250,7 @@ final class ChatModel: ObservableObject {
func removeWallpaperFilesFromChat(_ chat: Chat) {
if case let .direct(contact) = chat.chatInfo {
removeWallpaperFilesFromTheme(contact.uiThemes)
- } else if case let .group(groupInfo) = chat.chatInfo {
+ } else if case let .group(groupInfo, _) = chat.chatInfo {
removeWallpaperFilesFromTheme(groupInfo.uiThemes)
}
}
@@ -1121,7 +1271,6 @@ struct ShowingInvitation {
}
struct NTFContactRequest {
- var incognito: Bool
var chatId: String
}
@@ -1159,11 +1308,23 @@ final class Chat: ObservableObject, Identifiable, ChatLike {
default: chatStats.unreadChat
}
}
-
+
var id: ChatId { get { chatInfo.id } }
var viewId: String { get { "\(chatInfo.id) \(created.timeIntervalSince1970)" } }
+ var supportUnreadCount: Int {
+ switch chatInfo {
+ case let .group(groupInfo, _):
+ if groupInfo.canModerate {
+ return groupInfo.membersRequireAttention
+ } else {
+ return groupInfo.membership.supportChat?.unread ?? 0
+ }
+ default: return 0
+ }
+ }
+
public static var sampleData: Chat = Chat(chatInfo: ChatInfo.sampleData.direct, chatItems: [])
}
diff --git a/apps/ios/Shared/Model/NtfManager.swift b/apps/ios/Shared/Model/NtfManager.swift
index da55bd90d0..79f4ef2f09 100644
--- a/apps/ios/Shared/Model/NtfManager.swift
+++ b/apps/ios/Shared/Model/NtfManager.swift
@@ -12,7 +12,6 @@ import UIKit
import SimpleXChat
let ntfActionAcceptContact = "NTF_ACT_ACCEPT_CONTACT"
-let ntfActionAcceptContactIncognito = "NTF_ACT_ACCEPT_CONTACT_INCOGNITO"
let ntfActionAcceptCall = "NTF_ACT_ACCEPT_CALL"
let ntfActionRejectCall = "NTF_ACT_REJECT_CALL"
@@ -59,13 +58,12 @@ class NtfManager: NSObject, UNUserNotificationCenterDelegate, ObservableObject {
logger.debug("NtfManager.processNotificationResponse changeActiveUser")
changeActiveUser(userId, viewPwd: nil)
}
- if content.categoryIdentifier == ntfCategoryContactRequest && (action == ntfActionAcceptContact || action == ntfActionAcceptContactIncognito),
+ if content.categoryIdentifier == ntfCategoryContactRequest && action == ntfActionAcceptContact,
let chatId = content.userInfo["chatId"] as? String {
- let incognito = action == ntfActionAcceptContactIncognito
if case let .contactRequest(contactRequest) = chatModel.getChat(chatId)?.chatInfo {
- Task { await acceptContactRequest(incognito: incognito, contactRequest: contactRequest) }
+ Task { await acceptContactRequest(incognito: false, contactRequestId: contactRequest.apiId) }
} else {
- chatModel.ntfContactRequest = NTFContactRequest(incognito: incognito, chatId: chatId)
+ chatModel.ntfContactRequest = NTFContactRequest(chatId: chatId)
}
} else if let (chatId, ntfAction) = ntfCallAction(content, action) {
if let invitation = chatModel.callInvitations.removeValue(forKey: chatId) {
@@ -161,10 +159,6 @@ class NtfManager: NSObject, UNUserNotificationCenterDelegate, ObservableObject {
identifier: ntfActionAcceptContact,
title: NSLocalizedString("Accept", comment: "accept contact request via notification"),
options: .foreground
- ), UNNotificationAction(
- identifier: ntfActionAcceptContactIncognito,
- title: NSLocalizedString("Accept incognito", comment: "accept contact request via notification"),
- options: .foreground
)
],
intentIdentifiers: [],
diff --git a/apps/ios/Shared/Model/SimpleXAPI.swift b/apps/ios/Shared/Model/SimpleXAPI.swift
index d92411decd..f95e6ac2dd 100644
--- a/apps/ios/Shared/Model/SimpleXAPI.swift
+++ b/apps/ios/Shared/Model/SimpleXAPI.swift
@@ -94,14 +94,14 @@ func chatSendCmdSync(_ cmd: ChatCommand, bgTask: Bool = true,
return try apiResult(res)
}
-func chatApiSendCmdSync(_ cmd: ChatCommand, bgTask: Bool = true, bgDelay: Double? = nil, ctrl: chat_ctrl? = nil, log: Bool = true) -> APIResult {
+func chatApiSendCmdSync(_ cmd: ChatCommand, bgTask: Bool = true, bgDelay: Double? = nil, ctrl: chat_ctrl? = nil, retryNum: Int32 = 0, log: Bool = true) -> APIResult {
if log {
logger.debug("chatSendCmd \(cmd.cmdType)")
}
let start = Date.now
let resp: APIResult = bgTask
- ? withBGTask(bgDelay: bgDelay) { sendSimpleXCmd(cmd, ctrl) }
- : sendSimpleXCmd(cmd, ctrl)
+ ? withBGTask(bgDelay: bgDelay) { sendSimpleXCmd(cmd, ctrl, retryNum: retryNum) }
+ : sendSimpleXCmd(cmd, ctrl, retryNum: retryNum)
if log {
logger.debug("chatSendCmd \(cmd.cmdType): \(resp.responseType)")
if case let .invalid(_, json) = resp {
@@ -120,10 +120,102 @@ func chatSendCmd(_ cmd: ChatCommand, bgTask: Bool = true, bgDe
return try apiResult(res)
}
+func chatApiSendCmdWithRetry(_ cmd: ChatCommand, bgTask: Bool = true, bgDelay: Double? = nil, inProgress: BoxedValue? = nil, retryNum: Int32 = 0) async -> APIResult? {
+ let r: APIResult = await chatApiSendCmd(cmd, bgTask: bgTask, bgDelay: bgDelay, retryNum: retryNum)
+ if inProgress == nil || inProgress?.boxedValue == true,
+ case let .error(e) = r, let alert = retryableNetworkErrorAlert(e) {
+ return await withCheckedContinuation { cont in
+ showRetryAlert(
+ alert,
+ onCancel: { _ in
+ cont.resume(returning: nil)
+ },
+ onRetry: {
+ let r1: APIResult? = await chatApiSendCmdWithRetry(cmd, bgTask: bgTask, bgDelay: bgDelay, inProgress: inProgress, retryNum: retryNum + 1)
+ cont.resume(returning: r1)
+ }
+ )
+ }
+ } else {
+ return r
+ }
+}
+
@inline(__always)
-func chatApiSendCmd(_ cmd: ChatCommand, bgTask: Bool = true, bgDelay: Double? = nil, ctrl: chat_ctrl? = nil, log: Bool = true) async -> APIResult {
+func showRetryAlert(_ alert: (title: String, message: String), onCancel: @escaping (UIAlertAction) -> Void, onRetry: @escaping () async -> Void) {
+ DispatchQueue.main.async {
+ showAlert(
+ alert.title,
+ message: alert.message,
+ actions: {[
+ UIAlertAction(
+ title: NSLocalizedString("Cancel", comment: "alert action"),
+ style: .cancel,
+ handler: onCancel
+ ),
+ UIAlertAction(
+ title: NSLocalizedString("Retry", comment: "alert action"),
+ style: .default,
+ handler: { _ in Task(operation: onRetry) }
+ )
+ ]}
+ )
+ }
+}
+
+func retryableNetworkErrorAlert(_ e: ChatError) -> (title: String, message: String)? {
+ switch e {
+ case let .errorAgent(.BROKER(addr, .TIMEOUT)): (
+ title: NSLocalizedString("Connection timeout", comment: "alert title"),
+ message: serverErrorAlertMessage(addr)
+ )
+ case let .errorAgent(.BROKER(addr, .NETWORK(.unknownCAError))): nil
+ case let .errorAgent(.BROKER(addr, .NETWORK)): (
+ title: NSLocalizedString("Connection error", comment: "alert title"),
+ message: serverErrorAlertMessage(addr)
+ )
+ case let .errorAgent(.SMP(serverAddress, .PROXY(.BROKER(.TIMEOUT)))): (
+ title: NSLocalizedString("Private routing timeout", comment: "alert title"),
+ message: proxyErrorAlertMessage(serverAddress)
+ )
+ case let .errorAgent(.SMP(serverAddress, .PROXY(.BROKER(.NETWORK(.unknownCAError))))): nil
+ case let .errorAgent(.SMP(serverAddress, .PROXY(.BROKER(.NETWORK)))): (
+ title: NSLocalizedString("Private routing error", comment: "alert title"),
+ message: proxyErrorAlertMessage(serverAddress)
+ )
+ case let .errorAgent(.PROXY(proxyServer, destServer, .protocolError(.PROXY(.BROKER(.TIMEOUT))))): (
+ title: NSLocalizedString("Private routing timeout", comment: "alert title"),
+ message: proxyDestinationErrorAlertMessage(proxyServer: proxyServer, destServer: destServer)
+ )
+ case let .errorAgent(.PROXY(proxyServer, destServer, .protocolError(.PROXY(.BROKER(.NETWORK(.unknownCAError)))))): nil
+ case let .errorAgent(.PROXY(proxyServer, destServer, .protocolError(.PROXY(.BROKER(.NETWORK))))): (
+ title: NSLocalizedString("Private routing error", comment: "alert title"),
+ message: proxyDestinationErrorAlertMessage(proxyServer: proxyServer, destServer: destServer)
+ )
+ case let .errorAgent(.PROXY(proxyServer, destServer, .protocolError(.PROXY(.NO_SESSION)))): (
+ title: NSLocalizedString("No private routing session", comment: "alert title"),
+ message: proxyDestinationErrorAlertMessage(proxyServer: proxyServer, destServer: destServer)
+ )
+ default: nil
+ }
+}
+
+func serverErrorAlertMessage(_ addr: String) -> String {
+ String.localizedStringWithFormat(NSLocalizedString("Please check your network connection with %@ and try again.", comment: "alert message"), serverHostname(addr))
+}
+
+func proxyErrorAlertMessage(_ addr: String) -> String {
+ String.localizedStringWithFormat(NSLocalizedString("Error connecting to forwarding server %@. Please try later.", comment: "alert message"), serverHostname(addr))
+}
+
+func proxyDestinationErrorAlertMessage(proxyServer: String, destServer: String) -> String {
+ String.localizedStringWithFormat(NSLocalizedString("Forwarding server %@ failed to connect to destination server %@. Please try later.", comment: "alert message"), serverHostname(proxyServer), serverHostname(destServer))
+}
+
+@inline(__always)
+func chatApiSendCmd(_ cmd: ChatCommand, bgTask: Bool = true, bgDelay: Double? = nil, ctrl: chat_ctrl? = nil, retryNum: Int32 = 0, log: Bool = true) async -> APIResult {
await withCheckedContinuation { cont in
- cont.resume(returning: chatApiSendCmdSync(cmd, bgTask: bgTask, bgDelay: bgDelay, ctrl: ctrl, log: log))
+ cont.resume(returning: chatApiSendCmdSync(cmd, bgTask: bgTask, bgDelay: bgDelay, ctrl: ctrl, retryNum: retryNum, log: log))
}
}
@@ -200,6 +292,10 @@ func apiSetUserGroupReceipts(_ userId: Int64, userMsgReceiptSettings: UserMsgRec
try await sendCommandOkResp(.apiSetUserGroupReceipts(userId: userId, userMsgReceiptSettings: userMsgReceiptSettings))
}
+func apiSetUserAutoAcceptMemberContacts(_ userId: Int64, enable: Bool) async throws {
+ try await sendCommandOkResp(.apiSetUserAutoAcceptMemberContacts(userId: userId, enable: enable))
+}
+
func apiHideUser(_ userId: Int64, viewPwd: String) async throws -> User {
try await setUserPrivacy_(.apiHideUser(userId: userId, viewPwd: viewPwd))
}
@@ -344,43 +440,54 @@ func apiGetChatTagsAsync() async throws -> [ChatTag] {
let loadItemsPerPage = 50
-func apiGetChat(chatId: ChatId, pagination: ChatPagination, search: String = "") async throws -> (Chat, NavigationInfo) {
- let r: ChatResponse0 = try await chatSendCmd(.apiGetChat(chatId: chatId, pagination: pagination, search: search))
+func apiGetChat(chatId: ChatId, scope: GroupChatScope?, contentTag: MsgContentTag? = nil, pagination: ChatPagination, search: String = "") async throws -> (Chat, NavigationInfo) {
+ let r: ChatResponse0 = try await chatSendCmd(.apiGetChat(chatId: chatId, scope: scope, contentTag: contentTag, pagination: pagination, search: search))
if case let .apiChat(_, chat, navInfo) = r { return (Chat.init(chat), navInfo ?? NavigationInfo()) }
throw r.unexpected
}
-func loadChat(chat: Chat, search: String = "", clearItems: Bool = true) async {
- await loadChat(chatId: chat.chatInfo.id, search: search, clearItems: clearItems)
+func loadChat(chat: Chat, im: ItemsModel, search: String = "", clearItems: Bool = true) async {
+ await loadChat(chatId: chat.chatInfo.id, im: im, search: search, clearItems: clearItems)
}
-func loadChat(chatId: ChatId, search: String = "", openAroundItemId: ChatItem.ID? = nil, clearItems: Bool = true) async {
- let m = ChatModel.shared
- let im = ItemsModel.shared
+func loadChat(chatId: ChatId, im: ItemsModel, search: String = "", openAroundItemId: ChatItem.ID? = nil, clearItems: Bool = true) async {
await MainActor.run {
- m.chatItemStatuses = [:]
if clearItems {
im.reversedChatItems = []
- ItemsModel.shared.chatState.clear()
+ im.chatState.clear()
}
}
- await apiLoadMessages(chatId, openAroundItemId != nil ? .around(chatItemId: openAroundItemId!, count: loadItemsPerPage) : (search == "" ? .initial(count: loadItemsPerPage) : .last(count: loadItemsPerPage)), im.chatState, search, openAroundItemId, { 0...0 })
+ await apiLoadMessages(
+ chatId,
+ im,
+ ( // pagination
+ openAroundItemId != nil
+ ? .around(chatItemId: openAroundItemId!, count: loadItemsPerPage)
+ : (
+ search == ""
+ ? .initial(count: loadItemsPerPage) : .last(count: loadItemsPerPage)
+ )
+ ),
+ search,
+ openAroundItemId,
+ { 0...0 }
+ )
}
-func apiGetChatItemInfo(type: ChatType, id: Int64, itemId: Int64) async throws -> ChatItemInfo {
- let r: ChatResponse0 = try await chatSendCmd(.apiGetChatItemInfo(type: type, id: id, itemId: itemId))
+func apiGetChatItemInfo(type: ChatType, id: Int64, scope: GroupChatScope?, itemId: Int64) async throws -> ChatItemInfo {
+ let r: ChatResponse0 = try await chatSendCmd(.apiGetChatItemInfo(type: type, id: id, scope: scope, itemId: itemId))
if case let .chatItemInfo(_, _, chatItemInfo) = r { return chatItemInfo }
throw r.unexpected
}
-func apiPlanForwardChatItems(type: ChatType, id: Int64, itemIds: [Int64]) async throws -> ([Int64], ForwardConfirmation?) {
- let r: ChatResponse1 = try await chatSendCmd(.apiPlanForwardChatItems(toChatType: type, toChatId: id, itemIds: itemIds))
+func apiPlanForwardChatItems(type: ChatType, id: Int64, scope: GroupChatScope?, itemIds: [Int64]) async throws -> ([Int64], ForwardConfirmation?) {
+ let r: ChatResponse1 = try await chatSendCmd(.apiPlanForwardChatItems(fromChatType: type, fromChatId: id, fromScope: scope, itemIds: itemIds))
if case let .forwardPlan(_, chatItemIds, forwardConfimation) = r { return (chatItemIds, forwardConfimation) }
throw r.unexpected
}
-func apiForwardChatItems(toChatType: ChatType, toChatId: Int64, fromChatType: ChatType, fromChatId: Int64, itemIds: [Int64], ttl: Int?) async -> [ChatItem]? {
- let cmd: ChatCommand = .apiForwardChatItems(toChatType: toChatType, toChatId: toChatId, fromChatType: fromChatType, fromChatId: fromChatId, itemIds: itemIds, ttl: ttl)
+func apiForwardChatItems(toChatType: ChatType, toChatId: Int64, toScope: GroupChatScope?, fromChatType: ChatType, fromChatId: Int64, fromScope: GroupChatScope?, itemIds: [Int64], ttl: Int?) async -> [ChatItem]? {
+ let cmd: ChatCommand = .apiForwardChatItems(toChatType: toChatType, toChatId: toChatId, toScope: toScope, fromChatType: fromChatType, fromChatId: fromChatId, fromScope: fromScope, itemIds: itemIds, ttl: ttl)
return await processSendMessageCmd(toChatType: toChatType, cmd: cmd)
}
@@ -412,8 +519,8 @@ func apiReorderChatTags(tagIds: [Int64]) async throws {
try await sendCommandOkResp(.apiReorderChatTags(tagIds: tagIds))
}
-func apiSendMessages(type: ChatType, id: Int64, live: Bool = false, ttl: Int? = nil, composedMessages: [ComposedMessage]) async -> [ChatItem]? {
- let cmd: ChatCommand = .apiSendMessages(type: type, id: id, live: live, ttl: ttl, composedMessages: composedMessages)
+func apiSendMessages(type: ChatType, id: Int64, scope: GroupChatScope?, live: Bool = false, ttl: Int? = nil, composedMessages: [ComposedMessage]) async -> [ChatItem]? {
+ let cmd: ChatCommand = .apiSendMessages(type: type, id: id, scope: scope, live: live, ttl: ttl, composedMessages: composedMessages)
return await processSendMessageCmd(toChatType: type, cmd: cmd)
}
@@ -490,8 +597,8 @@ private func createChatItemsErrorAlert(_ r: ChatError) {
)
}
-func apiUpdateChatItem(type: ChatType, id: Int64, itemId: Int64, updatedMessage: UpdatedMessage, live: Bool = false) async throws -> ChatItem {
- let r: ChatResponse1 = try await chatSendCmd(.apiUpdateChatItem(type: type, id: id, itemId: itemId, updatedMessage: updatedMessage, live: live), bgDelay: msgDelay)
+func apiUpdateChatItem(type: ChatType, id: Int64, scope: GroupChatScope?, itemId: Int64, updatedMessage: UpdatedMessage, live: Bool = false) async throws -> ChatItem {
+ let r: ChatResponse1 = try await chatSendCmd(.apiUpdateChatItem(type: type, id: id, scope: scope, itemId: itemId, updatedMessage: updatedMessage, live: live), bgDelay: msgDelay)
switch r {
case let .chatItemUpdated(_, aChatItem): return aChatItem.chatItem
case let .chatItemNotChanged(_, aChatItem): return aChatItem.chatItem
@@ -499,8 +606,8 @@ func apiUpdateChatItem(type: ChatType, id: Int64, itemId: Int64, updatedMessage:
}
}
-func apiChatItemReaction(type: ChatType, id: Int64, itemId: Int64, add: Bool, reaction: MsgReaction) async throws -> ChatItem {
- let r: ChatResponse1 = try await chatSendCmd(.apiChatItemReaction(type: type, id: id, itemId: itemId, add: add, reaction: reaction), bgDelay: msgDelay)
+func apiChatItemReaction(type: ChatType, id: Int64, scope: GroupChatScope?, itemId: Int64, add: Bool, reaction: MsgReaction) async throws -> ChatItem {
+ let r: ChatResponse1 = try await chatSendCmd(.apiChatItemReaction(type: type, id: id, scope: scope, itemId: itemId, add: add, reaction: reaction), bgDelay: msgDelay)
if case let .chatItemReaction(_, _, reaction) = r { return reaction.chatReaction.chatItem }
throw r.unexpected
}
@@ -512,8 +619,8 @@ func apiGetReactionMembers(groupId: Int64, itemId: Int64, reaction: MsgReaction)
throw r.unexpected
}
-func apiDeleteChatItems(type: ChatType, id: Int64, itemIds: [Int64], mode: CIDeleteMode) async throws -> [ChatItemDeletion] {
- let r: ChatResponse1 = try await chatSendCmd(.apiDeleteChatItem(type: type, id: id, itemIds: itemIds, mode: mode), bgDelay: msgDelay)
+func apiDeleteChatItems(type: ChatType, id: Int64, scope: GroupChatScope?, itemIds: [Int64], mode: CIDeleteMode) async throws -> [ChatItemDeletion] {
+ let r: ChatResponse1 = try await chatSendCmd(.apiDeleteChatItem(type: type, id: id, scope: scope, itemIds: itemIds, mode: mode), bgDelay: msgDelay)
if case let .chatItemsDeleted(_, items, _) = r { return items }
throw r.unexpected
}
@@ -784,16 +891,16 @@ func apiGroupMemberInfo(_ groupId: Int64, _ groupMemberId: Int64) async throws -
throw r.unexpected
}
-func apiContactQueueInfo(_ contactId: Int64) async throws -> (RcvMsgInfo?, ServerQueueInfo) {
- let r: ChatResponse0 = try await chatSendCmd(.apiContactQueueInfo(contactId: contactId))
- if case let .queueInfo(_, rcvMsgInfo, queueInfo) = r { return (rcvMsgInfo, queueInfo) }
- throw r.unexpected
+func apiContactQueueInfo(_ contactId: Int64) async throws -> (RcvMsgInfo?, ServerQueueInfo)? {
+ let r: APIResult? = await chatApiSendCmdWithRetry(.apiContactQueueInfo(contactId: contactId))
+ if case let .result(.queueInfo(_, rcvMsgInfo, queueInfo)) = r { return (rcvMsgInfo, queueInfo) }
+ if let r { throw r.unexpected } else { return nil }
}
-func apiGroupMemberQueueInfo(_ groupId: Int64, _ groupMemberId: Int64) async throws -> (RcvMsgInfo?, ServerQueueInfo) {
- let r: ChatResponse0 = try await chatSendCmd(.apiGroupMemberQueueInfo(groupId: groupId, groupMemberId: groupMemberId))
- if case let .queueInfo(_, rcvMsgInfo, queueInfo) = r { return (rcvMsgInfo, queueInfo) }
- throw r.unexpected
+func apiGroupMemberQueueInfo(_ groupId: Int64, _ groupMemberId: Int64) async throws -> (RcvMsgInfo?, ServerQueueInfo)? {
+ let r: APIResult? = await chatApiSendCmdWithRetry(.apiGroupMemberQueueInfo(groupId: groupId, groupMemberId: groupMemberId))
+ if case let .result(.queueInfo(_, rcvMsgInfo, queueInfo)) = r { return (rcvMsgInfo, queueInfo) }
+ if let r { throw r.unexpected } else { return nil }
}
func apiSwitchContact(contactId: Int64) throws -> ConnectionStats {
@@ -863,10 +970,9 @@ func apiAddContact(incognito: Bool) async -> ((CreatedConnLink, PendingContactCo
logger.error("apiAddContact: no current user")
return (nil, nil)
}
- let short = UserDefaults.standard.bool(forKey: DEFAULT_PRIVACY_SHORT_LINKS)
- let r: APIResult = await chatApiSendCmd(.apiAddContact(userId: userId, short: short, incognito: incognito), bgTask: false)
+ let r: APIResult? = await chatApiSendCmdWithRetry(.apiAddContact(userId: userId, incognito: incognito), bgTask: false)
if case let .result(.invitation(_, connLinkInv, connection)) = r { return ((connLinkInv, connection), nil) }
- let alert = connectionErrorAlert(r)
+ let alert: Alert? = if let r { connectionErrorAlert(r) } else { nil }
return (nil, alert)
}
@@ -876,21 +982,20 @@ func apiSetConnectionIncognito(connId: Int64, incognito: Bool) async throws -> P
throw r.unexpected
}
-func apiChangeConnectionUser(connId: Int64, userId: Int64) async throws -> PendingContactConnection {
- let r: ChatResponse1 = try await chatSendCmd(.apiChangeConnectionUser(connId: connId, userId: userId))
-
- if case let .connectionUserChanged(_, _, toConnection, _) = r {return toConnection}
- throw r.unexpected
+func apiChangeConnectionUser(connId: Int64, userId: Int64) async throws -> PendingContactConnection? {
+ let r: APIResult? = await chatApiSendCmdWithRetry(.apiChangeConnectionUser(connId: connId, userId: userId))
+ if case let .result(.connectionUserChanged(_, _, toConnection, _)) = r {return toConnection}
+ if let r { throw r.unexpected } else { return nil }
}
-func apiConnectPlan(connLink: String) async -> ((CreatedConnLink, ConnectionPlan)?, Alert?) {
+func apiConnectPlan(connLink: String, inProgress: BoxedValue) async -> ((CreatedConnLink, ConnectionPlan)?, Alert?) {
guard let userId = ChatModel.shared.currentUser?.userId else {
logger.error("apiConnectPlan: no current user")
return (nil, nil)
}
- let r: APIResult = await chatApiSendCmd(.apiConnectPlan(userId: userId, connLink: connLink))
+ let r: APIResult? = await chatApiSendCmdWithRetry(.apiConnectPlan(userId: userId, connLink: connLink), inProgress: inProgress)
if case let .result(.connectionPlan(_, connLink, connPlan)) = r { return ((connLink, connPlan), nil) }
- let alert = apiConnectResponseAlert(r.unexpected) ?? connectionErrorAlert(r)
+ let alert: Alert? = if let r { apiConnectResponseAlert(r) } else { nil }
return (nil, alert)
}
@@ -909,7 +1014,7 @@ func apiConnect_(incognito: Bool, connLink: CreatedConnLink) async -> ((ConnReqT
logger.error("apiConnect: no current user")
return (nil, nil)
}
- let r: APIResult = await chatApiSendCmd(.apiConnect(userId: userId, incognito: incognito, connLink: connLink))
+ let r: APIResult? = await chatApiSendCmdWithRetry(.apiConnect(userId: userId, incognito: incognito, connLink: connLink))
let m = ChatModel.shared
switch r {
case let .result(.sentConfirmation(_, connection)):
@@ -924,12 +1029,12 @@ func apiConnect_(incognito: Bool, connLink: CreatedConnLink) async -> ((ConnReqT
return (nil, alert)
default: ()
}
- let alert = apiConnectResponseAlert(r.unexpected) ?? connectionErrorAlert(r)
+ let alert: Alert? = if let r { apiConnectResponseAlert(r) } else { nil }
return (nil, alert)
}
-private func apiConnectResponseAlert(_ r: ChatError) -> Alert? {
- switch r {
+private func apiConnectResponseAlert(_ r: APIResult) -> Alert {
+ switch r.unexpected {
case .error(.invalidConnReq):
mkAlert(
title: "Invalid connection link",
@@ -965,12 +1070,12 @@ private func apiConnectResponseAlert(_ r: ChatError) -> Alert? {
if internalErr == "SEUniqueID" {
mkAlert(
title: "Already connected?",
- message: "It seems like you are already connected via this link. If it is not the case, there was an error (\(responseError(r)))."
+ message: "It seems like you are already connected via this link. If it is not the case, there was an error (\(internalErr))."
)
} else {
- nil
+ connectionErrorAlert(r)
}
- default: nil
+ default: connectionErrorAlert(r)
}
}
@@ -992,16 +1097,59 @@ private func connectionErrorAlert(_ r: APIResult) -> Alert {
}
}
+func apiPrepareContact(connLink: CreatedConnLink, contactShortLinkData: ContactShortLinkData) async throws -> ChatData {
+ let userId = try currentUserId("apiPrepareContact")
+ let r: ChatResponse1 = try await chatSendCmd(.apiPrepareContact(userId: userId, connLink: connLink, contactShortLinkData: contactShortLinkData))
+ if case let .newPreparedChat(_, chat) = r { return chat }
+ throw r.unexpected
+}
+
+func apiPrepareGroup(connLink: CreatedConnLink, groupShortLinkData: GroupShortLinkData) async throws -> ChatData {
+ let userId = try currentUserId("apiPrepareGroup")
+ let r: ChatResponse1 = try await chatSendCmd(.apiPrepareGroup(userId: userId, connLink: connLink, groupShortLinkData: groupShortLinkData))
+ if case let .newPreparedChat(_, chat) = r { return chat }
+ throw r.unexpected
+}
+
+func apiChangePreparedContactUser(contactId: Int64, newUserId: Int64) async throws -> Contact {
+ let r: ChatResponse1 = try await chatSendCmd(.apiChangePreparedContactUser(contactId: contactId, newUserId: newUserId))
+ if case let .contactUserChanged(_, _, _, toContact) = r {return toContact}
+ throw r.unexpected
+}
+
+func apiChangePreparedGroupUser(groupId: Int64, newUserId: Int64) async throws -> GroupInfo {
+ let r: ChatResponse1 = try await chatSendCmd(.apiChangePreparedGroupUser(groupId: groupId, newUserId: newUserId))
+ if case let .groupUserChanged(_, _, _, toGroup) = r {return toGroup}
+ throw r.unexpected
+}
+
+func apiConnectPreparedContact(contactId: Int64, incognito: Bool, msg: MsgContent?) async -> Contact? {
+ let r: APIResult? = await chatApiSendCmdWithRetry(.apiConnectPreparedContact(contactId: contactId, incognito: incognito, msg: msg))
+ if case let .result(.startedConnectionToContact(_, contact)) = r { return contact }
+ if let r { AlertManager.shared.showAlert(apiConnectResponseAlert(r)) }
+ return nil
+}
+
+func apiConnectPreparedGroup(groupId: Int64, incognito: Bool, msg: MsgContent?) async -> GroupInfo? {
+ let r: APIResult? = await chatApiSendCmdWithRetry(.apiConnectPreparedGroup(groupId: groupId, incognito: incognito, msg: msg))
+ if case let .result(.startedConnectionToGroup(_, groupInfo)) = r { return groupInfo }
+ if let r { AlertManager.shared.showAlert(apiConnectResponseAlert(r)) }
+ return nil
+}
+
func apiConnectContactViaAddress(incognito: Bool, contactId: Int64) async -> (Contact?, Alert?) {
guard let userId = ChatModel.shared.currentUser?.userId else {
logger.error("apiConnectContactViaAddress: no current user")
return (nil, nil)
}
- let r: APIResult = await chatApiSendCmd(.apiConnectContactViaAddress(userId: userId, incognito: incognito, contactId: contactId))
+ let r: APIResult? = await chatApiSendCmdWithRetry(.apiConnectContactViaAddress(userId: userId, incognito: incognito, contactId: contactId))
if case let .result(.sentInvitationToContact(_, contact, _)) = r { return (contact, nil) }
- logger.error("apiConnectContactViaAddress error: \(responseError(r.unexpected))")
- let alert = connectionErrorAlert(r)
- return (nil, alert)
+ if let r {
+ logger.error("apiConnectContactViaAddress error: \(responseError(r.unexpected))")
+ return (nil, connectionErrorAlert(r))
+ } else {
+ return (nil, nil)
+ }
}
func apiDeleteChat(type: ChatType, id: Int64, chatDeleteMode: ChatDeleteMode = .full(notify: true)) async throws {
@@ -1167,18 +1315,18 @@ func apiSetChatUIThemes(chatId: ChatId, themes: ThemeModeOverrides?) async -> Bo
}
-func apiCreateUserAddress(short: Bool) async throws -> CreatedConnLink {
+func apiCreateUserAddress() async throws -> CreatedConnLink? {
let userId = try currentUserId("apiCreateUserAddress")
- let r: ChatResponse1 = try await chatSendCmd(.apiCreateMyAddress(userId: userId, short: short))
- if case let .userContactLinkCreated(_, connLink) = r { return connLink }
- throw r.unexpected
+ let r: APIResult? = await chatApiSendCmdWithRetry(.apiCreateMyAddress(userId: userId))
+ if case let .result(.userContactLinkCreated(_, connLink)) = r { return connLink }
+ if let r { throw r.unexpected } else { return nil }
}
func apiDeleteUserAddress() async throws -> User? {
let userId = try currentUserId("apiDeleteUserAddress")
- let r: ChatResponse1 = try await chatSendCmd(.apiDeleteMyAddress(userId: userId))
- if case let .userContactLinkDeleted(user) = r { return user }
- throw r.unexpected
+ let r: APIResult? = await chatApiSendCmdWithRetry(.apiDeleteMyAddress(userId: userId))
+ if case let .result(.userContactLinkDeleted(user)) = r { return user }
+ if let r { throw r.unexpected } else { return nil }
}
func apiGetUserAddress() throws -> UserContactLink? {
@@ -1199,18 +1347,25 @@ private func userAddressResponse(_ r: APIResult) throws -> UserCo
}
}
-func userAddressAutoAccept(_ autoAccept: AutoAccept?) async throws -> UserContactLink? {
- let userId = try currentUserId("userAddressAutoAccept")
- let r: APIResult = await chatApiSendCmd(.apiAddressAutoAccept(userId: userId, autoAccept: autoAccept))
+func apiAddMyAddressShortLink() async throws -> UserContactLink? {
+ let userId = try currentUserId("apiAddMyAddressShortLink")
+ let r: APIResult? = await chatApiSendCmdWithRetry(.apiAddMyAddressShortLink(userId: userId))
+ if case let .result(.userContactLink(_, contactLink)) = r { return contactLink }
+ if let r { throw r.unexpected } else { return nil }
+}
+
+func apiSetUserAddressSettings(_ settings: AddressSettings) async throws -> UserContactLink? {
+ let userId = try currentUserId("apiSetUserAddressSettings")
+ let r: APIResult? = await chatApiSendCmdWithRetry(.apiSetAddressSettings(userId: userId, addressSettings: settings))
switch r {
case let .result(.userContactLinkUpdated(_, contactLink)): return contactLink
case .error(.errorStore(storeError: .userContactLinkNotFound)): return nil
- default: throw r.unexpected
+ default: if let r { throw r.unexpected } else { return nil }
}
}
func apiAcceptContactRequest(incognito: Bool, contactReqId: Int64) async -> Contact? {
- let r: APIResult = await chatApiSendCmd(.apiAcceptContact(incognito: incognito, contactReqId: contactReqId))
+ let r: APIResult? = await chatApiSendCmdWithRetry(.apiAcceptContact(incognito: incognito, contactReqId: contactReqId))
let am = AlertManager.shared
if case let .result(.acceptingContactRequest(_, contact)) = r { return contact }
@@ -1219,30 +1374,40 @@ func apiAcceptContactRequest(incognito: Bool, contactReqId: Int64) async -> Cont
title: "Connection error (AUTH)",
message: "Sender may have deleted the connection request."
)
- } else if let networkErrorAlert = networkErrorAlert(r) {
- am.showAlert(networkErrorAlert)
- } else {
- logger.error("apiAcceptContactRequest error: \(String(describing: r))")
- am.showAlertMsg(
- title: "Error accepting contact request",
- message: "Error: \(responseError(r.unexpected))"
- )
+ } else if let r {
+ if let networkErrorAlert = networkErrorAlert(r) {
+ am.showAlert(networkErrorAlert)
+ } else {
+ logger.error("apiAcceptContactRequest error: \(String(describing: r))")
+ am.showAlertMsg(
+ title: "Error accepting contact request",
+ message: "Error: \(responseError(r.unexpected))"
+ )
+ }
}
return nil
}
-func apiRejectContactRequest(contactReqId: Int64) async throws {
+func apiRejectContactRequest(contactReqId: Int64) async throws -> Contact? {
let r: ChatResponse1 = try await chatSendCmd(.apiRejectContact(contactReqId: contactReqId))
- if case .contactRequestRejected = r { return }
+ if case let .contactRequestRejected(_, _, contact_) = r { return contact_ }
throw r.unexpected
}
func apiChatRead(type: ChatType, id: Int64) async throws {
- try await sendCommandOkResp(.apiChatRead(type: type, id: id))
+ try await sendCommandOkResp(.apiChatRead(type: type, id: id, scope: nil))
}
-func apiChatItemsRead(type: ChatType, id: Int64, itemIds: [Int64]) async throws {
- try await sendCommandOkResp(.apiChatItemsRead(type: type, id: id, itemIds: itemIds))
+func apiSupportChatRead(type: ChatType, id: Int64, scope: GroupChatScope) async throws -> (GroupInfo, GroupMember) {
+ let r: ChatResponse2 = try await chatSendCmd(.apiChatRead(type: type, id: id, scope: scope))
+ if case let .memberSupportChatRead(_, groupInfo, member) = r { return (groupInfo, member) }
+ throw r.unexpected
+}
+
+func apiChatItemsRead(type: ChatType, id: Int64, scope: GroupChatScope?, itemIds: [Int64]) async throws -> ChatInfo {
+ let r: ChatResponse1 = try await chatSendCmd(.apiChatItemsRead(type: type, id: id, scope: scope, itemIds: itemIds))
+ if case let .itemsReadForChat(_, updatedChatInfo) = r { return updatedChatInfo }
+ throw r.unexpected
}
func apiChatUnread(type: ChatType, id: Int64, unreadChat: Bool) async throws {
@@ -1294,7 +1459,7 @@ func receiveFiles(user: any UserLike, fileIds: [Int64], userApprovedRelays: Bool
var fileIdsToApprove: [Int64] = []
var srvsToApprove: Set = []
var otherFileErrs: [APIResult] = []
-
+
for fileId in fileIds {
let r: APIResult = await chatApiSendCmd(
.receiveFile(
@@ -1318,7 +1483,7 @@ func receiveFiles(user: any UserLike, fileIds: [Int64], userApprovedRelays: Bool
otherFileErrs.append(r)
}
}
-
+
if !auto {
let otherErrsStr = fileErrorStrs(otherFileErrs)
// If there are not approved files, alert is shown the same way both in case of singular and plural files reception
@@ -1383,7 +1548,7 @@ func receiveFiles(user: any UserLike, fileIds: [Int64], userApprovedRelays: Bool
}
}
}
-
+
func fileErrorStrs(_ errs: [APIResult]) -> String {
var errStr = ""
if errs.count >= 1 {
@@ -1398,7 +1563,7 @@ func receiveFiles(user: any UserLike, fileIds: [Int64], userApprovedRelays: Bool
return errStr
}
}
-
+
func cancelFile(user: User, fileId: Int64) async {
if let chatItem = await apiCancelFile(fileId: fileId) {
await chatItemSimpleUpdate(user, chatItem)
@@ -1465,29 +1630,53 @@ func networkErrorAlert(_ res: APIResult) -> Alert? {
}
}
-func acceptContactRequest(incognito: Bool, contactRequest: UserContactRequest) async {
- if let contact = await apiAcceptContactRequest(incognito: incognito, contactReqId: contactRequest.apiId) {
+func acceptContactRequest(incognito: Bool, contactRequestId: Int64, inProgress: Binding? = nil) async {
+ await MainActor.run { inProgress?.wrappedValue = true }
+ if let contact = await apiAcceptContactRequest(incognito: incognito, contactReqId: contactRequestId) {
let chat = Chat(chatInfo: ChatInfo.direct(contact: contact), chatItems: [])
await MainActor.run {
- ChatModel.shared.replaceChat(contactRequest.id, chat)
+ if contact.contactRequestId != nil { // means contact request was initially created with contact, so we don't need to replace it
+ ChatModel.shared.updateContact(contact)
+ } else {
+ ChatModel.shared.replaceChat(contactRequestChatId(contactRequestId), chat)
+ }
NetworkModel.shared.setContactNetworkStatus(contact, .connected)
+ inProgress?.wrappedValue = false
}
if contact.sndReady {
+ let chatId = chat.id
DispatchQueue.main.async {
dismissAllSheets(animated: true) {
- ItemsModel.shared.loadOpenChat(chat.id)
+ ItemsModel.shared.loadOpenChat(chatId)
}
}
}
+ } else {
+ await MainActor.run { inProgress?.wrappedValue = false }
}
}
-func rejectContactRequest(_ contactRequest: UserContactRequest) async {
+func rejectContactRequest(_ contactRequestId: Int64, dismissToChatList: Bool = false) async {
do {
- try await apiRejectContactRequest(contactReqId: contactRequest.apiId)
- DispatchQueue.main.async { ChatModel.shared.removeChat(contactRequest.id) }
+ let contact_ = try await apiRejectContactRequest(contactReqId: contactRequestId)
+ await MainActor.run {
+ if let contact = contact_ { // means contact request was initially created with contact, so we need to remove contact chat
+ ChatModel.shared.removeChat(contact.id)
+ } else {
+ ChatModel.shared.removeChat(contactRequestChatId(contactRequestId))
+ }
+ if dismissToChatList {
+ ChatModel.shared.chatId = nil
+ }
+ }
} catch let error {
logger.error("rejectContactRequest: \(responseError(error))")
+ await MainActor.run {
+ showAlert(
+ NSLocalizedString("Error rejecting contact request", comment: "alert title"),
+ message: responseError(error)
+ )
+ }
}
}
@@ -1545,13 +1734,13 @@ func apiGetNetworkStatuses() throws -> [ConnNetworkStatus] {
throw r.unexpected
}
-func markChatRead(_ chat: Chat) async {
+func markChatRead(_ im: ItemsModel, _ chat: Chat) async {
do {
if chat.chatStats.unreadCount > 0 {
let cInfo = chat.chatInfo
try await apiChatRead(type: cInfo.chatType, id: cInfo.apiId)
await MainActor.run {
- withAnimation { ChatModel.shared.markAllChatItemsRead(cInfo) }
+ withAnimation { ChatModel.shared.markAllChatItemsRead(im, cInfo) }
}
}
if chat.chatStats.unreadChat {
@@ -1574,11 +1763,26 @@ func markChatUnread(_ chat: Chat, unreadChat: Bool = true) async {
}
}
-func apiMarkChatItemsRead(_ cInfo: ChatInfo, _ itemIds: [ChatItem.ID], mentionsRead: Int) async {
+func markSupportChatRead(_ groupInfo: GroupInfo, _ member: GroupMember) async {
do {
- try await apiChatItemsRead(type: cInfo.chatType, id: cInfo.apiId, itemIds: itemIds)
- DispatchQueue.main.async {
- ChatModel.shared.markChatItemsRead(cInfo, itemIds, mentionsRead)
+ if member.supportChatNotRead {
+ let (updatedGroupInfo, updatedMember) = try await apiSupportChatRead(type: .group, id: groupInfo.apiId, scope: .memberSupport(groupMemberId_: member.groupMemberId))
+ await MainActor.run {
+ _ = ChatModel.shared.upsertGroupMember(updatedGroupInfo, updatedMember)
+ ChatModel.shared.updateGroup(updatedGroupInfo)
+ }
+ }
+ } catch {
+ logger.error("markSupportChatRead apiChatRead error: \(responseError(error))")
+ }
+}
+
+func apiMarkChatItemsRead(_ im: ItemsModel, _ cInfo: ChatInfo, _ itemIds: [ChatItem.ID], mentionsRead: Int) async {
+ do {
+ let updatedChatInfo = try await apiChatItemsRead(type: cInfo.chatType, id: cInfo.apiId, scope: cInfo.groupChatScope(), itemIds: itemIds)
+ await MainActor.run {
+ ChatModel.shared.updateChatInfo(updatedChatInfo)
+ ChatModel.shared.markChatItemsRead(im, cInfo, itemIds, mentionsRead)
}
} catch {
logger.error("apiChatItemsRead error: \(responseError(error))")
@@ -1616,19 +1820,31 @@ enum JoinGroupResult {
case groupNotFound
}
-func apiJoinGroup(_ groupId: Int64) async throws -> JoinGroupResult {
- let r: APIResult = await chatApiSendCmd(.apiJoinGroup(groupId: groupId))
+func apiJoinGroup(_ groupId: Int64) async throws -> JoinGroupResult? {
+ let r: APIResult? = await chatApiSendCmdWithRetry(.apiJoinGroup(groupId: groupId))
switch r {
case let .result(.userAcceptedGroupSent(_, groupInfo, _)): return .joined(groupInfo: groupInfo)
case .error(.errorAgent(.SMP(_, .AUTH))): return .invitationRemoved
case .error(.errorStore(.groupNotFound)): return .groupNotFound
- default: throw r.unexpected
+ default: if let r { throw r.unexpected } else { return nil }
}
}
-func apiRemoveMembers(_ groupId: Int64, _ memberIds: [Int64], _ withMessages: Bool = false) async throws -> [GroupMember] {
+func apiAcceptMember(_ groupId: Int64, _ groupMemberId: Int64, _ memberRole: GroupMemberRole) async throws -> (GroupInfo, GroupMember) {
+ let r: ChatResponse2 = try await chatSendCmd(.apiAcceptMember(groupId: groupId, groupMemberId: groupMemberId, memberRole: memberRole))
+ if case let .memberAccepted(_, groupInfo, member) = r { return (groupInfo, member) }
+ throw r.unexpected
+}
+
+func apiDeleteMemberSupportChat(_ groupId: Int64, _ groupMemberId: Int64) async throws -> (GroupInfo, GroupMember) {
+ let r: ChatResponse2 = try await chatSendCmd(.apiDeleteMemberSupportChat(groupId: groupId, groupMemberId: groupMemberId))
+ if case let .memberSupportChatDeleted(_, groupInfo, member) = r { return (groupInfo, member) }
+ throw r.unexpected
+}
+
+func apiRemoveMembers(_ groupId: Int64, _ memberIds: [Int64], _ withMessages: Bool = false) async throws -> (GroupInfo, [GroupMember]) {
let r: ChatResponse2 = try await chatSendCmd(.apiRemoveMembers(groupId: groupId, memberIds: memberIds, withMessages: withMessages), bgTask: false)
- if case let .userDeletedMembers(_, _, members, withMessages) = r { return members }
+ if case let .userDeletedMembers(_, updatedGroupInfo, members, _withMessages) = r { return (updatedGroupInfo, members) }
throw r.unexpected
}
@@ -1669,8 +1885,8 @@ func apiListMembers(_ groupId: Int64) async -> [GroupMember] {
func filterMembersToAdd(_ ms: [GMember]) -> [Contact] {
let memberContactIds = ms.compactMap{ m in m.wrapped.memberCurrent ? m.wrapped.memberContactId : nil }
return ChatModel.shared.chats
- .compactMap{ $0.chatInfo.contact }
- .filter{ c in c.sendMsgEnabled && !c.nextSendGrpInv && !memberContactIds.contains(c.apiId) }
+ .compactMap{ c in c.chatInfo.sendMsgEnabled ? c.chatInfo.contact : nil }
+ .filter{ c in !c.sendMsgToConnect && !memberContactIds.contains(c.apiId) }
.sorted{ $0.displayName.lowercased() < $1.displayName.lowercased() }
}
@@ -1680,36 +1896,41 @@ func apiUpdateGroup(_ groupId: Int64, _ groupProfile: GroupProfile) async throws
throw r.unexpected
}
-func apiCreateGroupLink(_ groupId: Int64, memberRole: GroupMemberRole = .member) async throws -> (CreatedConnLink, GroupMemberRole) {
- let short = UserDefaults.standard.bool(forKey: DEFAULT_PRIVACY_SHORT_LINKS)
- let r: ChatResponse2 = try await chatSendCmd(.apiCreateGroupLink(groupId: groupId, memberRole: memberRole, short: short))
- if case let .groupLinkCreated(_, _, connLink, memberRole) = r { return (connLink, memberRole) }
- throw r.unexpected
+func apiCreateGroupLink(_ groupId: Int64, memberRole: GroupMemberRole = .member) async throws -> GroupLink? {
+ let r: APIResult? = await chatApiSendCmdWithRetry(.apiCreateGroupLink(groupId: groupId, memberRole: memberRole))
+ if case let .result(.groupLinkCreated(_, _, groupLink)) = r { return groupLink }
+ if let r { throw r.unexpected } else { return nil }
}
-func apiGroupLinkMemberRole(_ groupId: Int64, memberRole: GroupMemberRole = .member) async throws -> (CreatedConnLink, GroupMemberRole) {
+func apiGroupLinkMemberRole(_ groupId: Int64, memberRole: GroupMemberRole = .member) async throws -> GroupLink {
let r: ChatResponse2 = try await chatSendCmd(.apiGroupLinkMemberRole(groupId: groupId, memberRole: memberRole))
- if case let .groupLink(_, _, connLink, memberRole) = r { return (connLink, memberRole) }
+ if case let .groupLink(_, _, groupLink) = r { return groupLink }
throw r.unexpected
}
func apiDeleteGroupLink(_ groupId: Int64) async throws {
- let r: ChatResponse2 = try await chatSendCmd(.apiDeleteGroupLink(groupId: groupId))
- if case .groupLinkDeleted = r { return }
- throw r.unexpected
+ let r: APIResult? = await chatApiSendCmdWithRetry(.apiDeleteGroupLink(groupId: groupId))
+ if case .result(.groupLinkDeleted) = r { return }
+ if let r { throw r.unexpected }
}
-func apiGetGroupLink(_ groupId: Int64) throws -> (CreatedConnLink, GroupMemberRole)? {
+func apiGetGroupLink(_ groupId: Int64) throws -> GroupLink? {
let r: APIResult = chatApiSendCmdSync(.apiGetGroupLink(groupId: groupId))
switch r {
- case let .result(.groupLink(_, _, connLink, memberRole)):
- return (connLink, memberRole)
+ case let .result(.groupLink(_, _, groupLink)):
+ return groupLink
case .error(.errorStore(storeError: .groupLinkNotFound)):
return nil
default: throw r.unexpected
}
}
+func apiAddGroupShortLink(_ groupId: Int64) async throws -> GroupLink? {
+ let r: APIResult? = await chatApiSendCmdWithRetry(.apiAddGroupShortLink(groupId: groupId))
+ if case let .result(.groupLink(_, _, groupLink)) = r { return groupLink }
+ if let r { throw r.unexpected } else { return nil }
+}
+
func apiCreateMemberContact(_ groupId: Int64, _ groupMemberId: Int64) async throws -> Contact {
let r: ChatResponse2 = try await chatSendCmd(.apiCreateMemberContact(groupId: groupId, groupMemberId: groupMemberId))
if case let .newMemberContact(_, contact, _, _) = r { return contact }
@@ -1722,6 +1943,33 @@ func apiSendMemberContactInvitation(_ contactId: Int64, _ msg: MsgContent) async
throw r.unexpected
}
+func apiAcceptMemberContact(contactId: Int64) async -> Contact? {
+ let r: APIResult? = await chatApiSendCmdWithRetry(.apiAcceptMemberContact(contactId: contactId))
+ if case let .result(.memberContactAccepted(_, contact)) = r { return contact }
+ if let r { AlertManager.shared.showAlert(apiConnectResponseAlert(r)) }
+ return nil
+}
+
+func acceptMemberContact(contactId: Int64, inProgress: Binding? = nil) async {
+ await MainActor.run { inProgress?.wrappedValue = true }
+ if let contact = await apiAcceptMemberContact(contactId: contactId) {
+ await MainActor.run {
+ ChatModel.shared.updateContact(contact)
+ NetworkModel.shared.setContactNetworkStatus(contact, .connected)
+ inProgress?.wrappedValue = false
+ }
+ if contact.sndReady {
+ DispatchQueue.main.async {
+ dismissAllSheets(animated: true) {
+ ItemsModel.shared.loadOpenChat(contact.id)
+ }
+ }
+ }
+ } else {
+ await MainActor.run { inProgress?.wrappedValue = false }
+ }
+}
+
func apiGetVersion() throws -> CoreVersionInfo {
let r: ChatResponse2 = try chatSendCmdSync(.showVersion)
if case let .versionInfo(info, _, _) = r { return info }
@@ -1885,7 +2133,7 @@ private func changeActiveUser_(_ userId: Int64, viewPwd: String?) throws {
try getUserChatData()
}
-func changeActiveUserAsync_(_ userId: Int64?, viewPwd: String?) async throws {
+func changeActiveUserAsync_(_ userId: Int64?, viewPwd: String?, keepingChatId: String? = nil) async throws {
let currentUser = if let userId = userId {
try await apiSetActiveUserAsync(userId, viewPwd: viewPwd)
} else {
@@ -1897,7 +2145,7 @@ func changeActiveUserAsync_(_ userId: Int64?, viewPwd: String?) async throws {
m.currentUser = currentUser
m.users = users
}
- try await getUserChatDataAsync()
+ try await getUserChatDataAsync(keepingChatId: keepingChatId)
await MainActor.run {
if let currentUser = currentUser, var (_, invitation) = ChatModel.shared.callInvitations.first(where: { _, inv in inv.user.userId == userId }) {
invitation.user = currentUser
@@ -1919,7 +2167,7 @@ func getUserChatData() throws {
tm.updateChatTags(m.chats)
}
-private func getUserChatDataAsync() async throws {
+private func getUserChatDataAsync(keepingChatId: String?) async throws {
let m = ChatModel.shared
let tm = ChatTagsModel.shared
if m.currentUser != nil {
@@ -1930,7 +2178,7 @@ private func getUserChatDataAsync() async throws {
await MainActor.run {
m.userAddress = userAddress
m.chatItemTTL = chatItemTTL
- m.updateChats(chats)
+ m.updateChats(chats, keepingChatId: keepingChatId)
tm.activeFilter = nil
tm.userTags = tags
tm.updateChatTags(m.chats)
@@ -2041,17 +2289,27 @@ func processReceivedMsg(_ res: ChatEvent) async {
await MainActor.run {
n.setContactNetworkStatus(contact, .connected)
}
- case let .receivedContactRequest(user, contactRequest):
+ case let .receivedContactRequest(user, contactRequest, chat_):
if active(user) {
- let cInfo = ChatInfo.contactRequest(contactRequest: contactRequest)
await MainActor.run {
- if m.hasChat(contactRequest.id) {
- m.updateChatInfo(cInfo)
+ if let chat = chat_ { // means contact request was created with contact, so we need to add/update contact chat
+ if !m.hasChat(chat.id) {
+ m.addChat(Chat(chat))
+ } else if m.chatId == chat.id {
+ m.updateChatInfo(chat.chatInfo)
+ } else {
+ m.replaceChat(chat.id, Chat(chat))
+ }
} else {
- m.addChat(Chat(
- chatInfo: cInfo,
- chatItems: []
- ))
+ let cInfo = ChatInfo.contactRequest(contactRequest: contactRequest)
+ if m.hasChat(contactRequest.id) {
+ m.updateChatInfo(cInfo)
+ } else {
+ m.addChat(Chat(
+ chatInfo: cInfo,
+ chatItems: []
+ ))
+ }
}
}
}
@@ -2104,6 +2362,12 @@ func processReceivedMsg(_ res: ChatEvent) async {
n.networkStatuses = ns
}
}
+ case let .chatInfoUpdated(user, chatInfo):
+ if active(user) {
+ await MainActor.run {
+ m.updateChatInfo(chatInfo)
+ }
+ }
case let .newChatItems(user, chatItems):
for chatItem in chatItems {
let cInfo = chatItem.chatInfo
@@ -2132,7 +2396,7 @@ func processReceivedMsg(_ res: ChatEvent) async {
let cInfo = chatItem.chatInfo
let cItem = chatItem.chatItem
if !cItem.isDeletedContent && active(user) {
- await MainActor.run { m.updateChatItem(cInfo, cItem, status: cItem.meta.itemStatus) }
+ _ = await MainActor.run { m.upsertChatItem(cInfo, cItem) }
}
if let endTask = m.messageDelivery[cItem.id] {
switch cItem.meta.itemStatus {
@@ -2180,6 +2444,9 @@ func processReceivedMsg(_ res: ChatEvent) async {
m.decreaseGroupReportsCounter(item.deletedChatItem.chatInfo.id)
}
}
+ if let updatedChatInfo = items.last?.deletedChatItem.chatInfo {
+ m.updateChatInfo(updatedChatInfo)
+ }
}
case let .groupChatItemsDeleted(user, groupInfo, chatItemIDs, _, member_):
await groupChatItemsDeleted(user, groupInfo, chatItemIDs, member_)
@@ -2228,6 +2495,13 @@ func processReceivedMsg(_ res: ChatEvent) async {
_ = m.upsertGroupMember(groupInfo, member)
}
}
+ case let .memberAcceptedByOther(user, groupInfo, _, member):
+ if active(user) {
+ await MainActor.run {
+ _ = m.upsertGroupMember(groupInfo, member)
+ m.updateGroup(groupInfo)
+ }
+ }
case let .deletedMemberUser(user, groupInfo, member, withMessages): // TODO update user member
if active(user) {
await MainActor.run {
@@ -2240,6 +2514,7 @@ func processReceivedMsg(_ res: ChatEvent) async {
case let .deletedMember(user, groupInfo, byMember, deletedMember, withMessages):
if active(user) {
await MainActor.run {
+ m.updateGroup(groupInfo)
_ = m.upsertGroupMember(groupInfo, deletedMember)
if withMessages {
m.removeMemberItems(deletedMember, byMember: byMember, groupInfo)
@@ -2249,6 +2524,7 @@ func processReceivedMsg(_ res: ChatEvent) async {
case let .leftMember(user, groupInfo, member):
if active(user) {
await MainActor.run {
+ m.updateGroup(groupInfo)
_ = m.upsertGroupMember(groupInfo, member)
}
}
@@ -2263,6 +2539,17 @@ func processReceivedMsg(_ res: ChatEvent) async {
await MainActor.run {
m.updateGroup(groupInfo)
}
+ if m.chatId == groupInfo.id {
+ if groupInfo.membership.memberPending {
+ await MainActor.run {
+ m.secondaryPendingInviteeChatOpened = true
+ }
+ } else if case .memberSupport(nil) = m.secondaryIM?.groupScopeInfo {
+ await MainActor.run {
+ m.secondaryPendingInviteeChatOpened = false
+ }
+ }
+ }
}
case let .joinedGroupMember(user, groupInfo, member):
if active(user) {
@@ -2310,7 +2597,7 @@ func processReceivedMsg(_ res: ChatEvent) async {
case let .rcvFileAccepted(user, aChatItem): // usually rcvFileAccepted is a response, but it's also an event for XFTP files auto-accepted from NSE
await chatItemSimpleUpdate(user, aChatItem)
// TODO when aChatItem added
-// case let .rcvFileAcceptedSndCancelled(user, aChatItem, _): // usually rcvFileAcceptedSndCancelled is a response, but it's also an event for XFTP files auto-accepted from NSE
+// case let .rcvFileAcceptedSndCancelled(user, aChatItem, _): // usually rcvFileAcceptedSndCancelled is a response, but it's also an event for legacy files auto-accepted from NSE.
// await chatItemSimpleUpdate(user, aChatItem)
// Task { cleanupFile(aChatItem) }
case let .rcvFileStart(user, aChatItem):
@@ -2549,7 +2836,7 @@ func groupChatItemsDeleted(_ user: UserRef, _ groupInfo: GroupInfo, _ chatItemID
return
}
let im = ItemsModel.shared
- let cInfo = ChatInfo.group(groupInfo: groupInfo)
+ let cInfo = ChatInfo.group(groupInfo: groupInfo, groupChatScope: nil)
await MainActor.run {
m.decreaseGroupReportsCounter(cInfo.id, by: chatItemIDs.count)
}
diff --git a/apps/ios/Shared/SimpleXApp.swift b/apps/ios/Shared/SimpleXApp.swift
index f8d69c5fc8..e1a6bb61e8 100644
--- a/apps/ios/Shared/SimpleXApp.swift
+++ b/apps/ios/Shared/SimpleXApp.swift
@@ -19,7 +19,6 @@ struct SimpleXApp: App {
@Environment(\.scenePhase) var scenePhase
@State private var enteredBackgroundAuthenticated: TimeInterval? = nil
- @State private var appOpenUrlLater: URL?
init() {
DispatchQueue.global(qos: .background).sync {
@@ -46,7 +45,7 @@ struct SimpleXApp: App {
if AppChatState.shared.value == .active {
chatModel.appOpenUrl = url
} else {
- appOpenUrlLater = url
+ chatModel.appOpenUrlLater = url
}
}
.onAppear() {
@@ -98,15 +97,15 @@ struct SimpleXApp: App {
if !chatModel.showCallView && !CallController.shared.hasActiveCalls() {
await updateCallInvitations()
}
- if let url = appOpenUrlLater {
+ if let url = chatModel.appOpenUrlLater {
await MainActor.run {
- appOpenUrlLater = nil
+ chatModel.appOpenUrlLater = nil
chatModel.appOpenUrl = url
}
}
}
- } else if let url = appOpenUrlLater {
- appOpenUrlLater = nil
+ } else if let url = chatModel.appOpenUrlLater {
+ chatModel.appOpenUrlLater = nil
chatModel.appOpenUrl = url
}
}
@@ -159,12 +158,12 @@ struct SimpleXApp: App {
if let id = chatModel.chatId,
let chat = chatModel.getChat(id),
!NtfManager.shared.navigatingToChat {
- Task { await loadChat(chat: chat, clearItems: false) }
+ Task { await loadChat(chat: chat, im: ItemsModel.shared, clearItems: false) }
}
if let ncr = chatModel.ntfContactRequest {
await MainActor.run { chatModel.ntfContactRequest = nil }
if case let .contactRequest(contactRequest) = chatModel.getChat(ncr.chatId)?.chatInfo {
- Task { await acceptContactRequest(incognito: ncr.incognito, contactRequest: contactRequest) }
+ Task { await acceptContactRequest(incognito: false, contactRequestId: contactRequest.apiId) }
}
}
} catch let error {
diff --git a/apps/ios/Shared/Views/Chat/ChatInfoToolbar.swift b/apps/ios/Shared/Views/Chat/ChatInfoToolbar.swift
index 62a41c504a..b60842a4a0 100644
--- a/apps/ios/Shared/Views/Chat/ChatInfoToolbar.swift
+++ b/apps/ios/Shared/Views/Chat/ChatInfoToolbar.swift
@@ -22,11 +22,28 @@ struct ChatInfoToolbar: View {
Image(systemName: "theatermasks").frame(maxWidth: 24, maxHeight: 24, alignment: .center).foregroundColor(.indigo)
Spacer().frame(width: 16)
}
- ChatInfoImage(
- chat: chat,
- size: imageSize,
- color: Color(uiColor: .tertiaryLabel)
- )
+ ZStack(alignment: .bottomTrailing) {
+ ChatInfoImage(
+ chat: chat,
+ size: imageSize,
+ color: Color(uiColor: .tertiaryLabel)
+ )
+ if chat.chatStats.reportsCount > 0 {
+ Image(systemName: "flag.circle.fill")
+ .resizable()
+ .scaledToFit()
+ .frame(width: 14, height: 14)
+ .symbolRenderingMode(.palette)
+ .foregroundStyle(.white, .red)
+ } else if chat.supportUnreadCount > 0 {
+ Image(systemName: "flag.circle.fill")
+ .resizable()
+ .scaledToFit()
+ .frame(width: 14, height: 14)
+ .symbolRenderingMode(.palette)
+ .foregroundStyle(.white, theme.colors.primary)
+ }
+ }
.padding(.trailing, 4)
let t = Text(cInfo.displayName).font(.headline)
(cInfo.contact?.verified == true ? contactVerifiedShield + t : t)
diff --git a/apps/ios/Shared/Views/Chat/ChatInfoView.swift b/apps/ios/Shared/Views/Chat/ChatInfoView.swift
index 8194c8fe6f..77c1db341a 100644
--- a/apps/ios/Shared/Views/Chat/ChatInfoView.swift
+++ b/apps/ios/Shared/Views/Chat/ChatInfoView.swift
@@ -111,7 +111,8 @@ struct ChatInfoView: View {
@State private var sendReceiptsUserDefault = true
@State private var progressIndicator = false
@AppStorage(DEFAULT_DEVELOPER_TOOLS) private var developerTools = false
-
+ @State private var showSecrets: Set = []
+
enum ChatInfoViewAlert: Identifiable {
case clearChatAlert
case networkStatusAlert
@@ -135,7 +136,7 @@ struct ChatInfoView: View {
}
}
}
-
+
var body: some View {
NavigationView {
ZStack {
@@ -146,12 +147,12 @@ struct ChatInfoView: View {
.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
@@ -169,7 +170,7 @@ struct ChatInfoView: View {
.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 {
@@ -180,7 +181,7 @@ struct ChatInfoView: View {
}
}
}
-
+
Section {
if let code = connectionCode { verifyCodeButton(code) }
contactPreferencesButton()
@@ -203,19 +204,19 @@ struct ChatInfoView: View {
// }
}
.disabled(!contact.ready || !contact.active)
-
+
Section {
ChatTTLOption(chat: chat, progressIndicator: $progressIndicator)
} footer: {
Text("Delete chat messages from your device.")
}
-
+
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)
@@ -232,7 +233,7 @@ struct ChatInfoView: View {
.foregroundColor(theme.colors.secondary)
}
}
-
+
if contact.ready && contact.active {
Section(header: Text("Servers").foregroundColor(theme.colors.secondary)) {
networkStatusRow()
@@ -261,12 +262,12 @@ struct ChatInfoView: View {
}
}
}
-
+
Section {
clearChatButton()
deleteContactButton()
}
-
+
if developerTools {
Section(header: Text("For console").foregroundColor(theme.colors.secondary)) {
infoRow("Local name", chat.chatInfo.localDisplayName)
@@ -274,8 +275,9 @@ struct ChatInfoView: View {
Button ("Debug delivery") {
Task {
do {
- let info = queueInfoText(try await apiContactQueueInfo(chat.chatInfo.apiId))
- await MainActor.run { alert = .queueInfo(info: info) }
+ if let info = try await apiContactQueueInfo(chat.chatInfo.apiId) {
+ await MainActor.run { alert = .queueInfo(info: queueInfoText(info)) }
+ }
} catch let e {
logger.error("apiContactQueueInfo error: \(responseError(e))")
let a = getErrorAlert(e, "Error")
@@ -290,7 +292,7 @@ struct ChatInfoView: View {
.navigationBarHidden(true)
.disabled(progressIndicator)
.opacity(progressIndicator ? 0.6 : 1)
-
+
if progressIndicator {
ProgressView().scaleEffect(2)
}
@@ -302,7 +304,7 @@ struct ChatInfoView: View {
sendReceiptsUserDefault = currentUser.sendRcptsContacts
}
sendReceipts = SendReceipts.fromBool(contact.chatSettings.sendRcpts, userDefault: sendReceiptsUserDefault)
-
+
Task {
do {
let (stats, profile) = try await apiContactInfo(chat.chatInfo.apiId)
@@ -341,7 +343,7 @@ struct ChatInfoView: View {
}
}
.actionSheet(item: $actionSheet) { $0.actionSheet }
- .sheet(item: $sheet) {
+ .sheet(item: $sheet) {
if #available(iOS 16.0, *) {
$0.content
.presentationDetents([.fraction($0.fraction)])
@@ -360,41 +362,52 @@ struct ChatInfoView: View {
}
}
}
-
+
private func contactInfoHeader() -> some View {
VStack(spacing: 8) {
let cInfo = chat.chatInfo
ChatInfoImage(chat: chat, size: 192, color: Color(uiColor: .tertiarySystemFill))
.padding(.vertical, 12)
+ // show actual display name, alias can be edited in this view
+ let displayName = contact.profile.displayName.trimmingCharacters(in: .whitespacesAndNewlines)
+ let fullName = cInfo.fullName.trimmingCharacters(in: .whitespacesAndNewlines)
if contact.verified {
(
Text(Image(systemName: "checkmark.shield"))
.foregroundColor(theme.colors.secondary)
.font(.title2)
+ textSpace
- + Text(contact.profile.displayName)
+ + Text(displayName)
.font(.largeTitle)
)
.multilineTextAlignment(.center)
.lineLimit(2)
.padding(.bottom, 2)
} else {
- Text(contact.profile.displayName)
+ Text(displayName)
.font(.largeTitle)
.multilineTextAlignment(.center)
.lineLimit(2)
.padding(.bottom, 2)
}
- if cInfo.fullName != "" && cInfo.fullName != cInfo.displayName && cInfo.fullName != contact.profile.displayName {
+ if fullName != "" && fullName != displayName && fullName != cInfo.displayName.trimmingCharacters(in: .whitespacesAndNewlines) {
Text(cInfo.fullName)
.font(.title2)
+ .multilineTextAlignment(.center)
+ .lineLimit(3)
+ .padding(.bottom, 2)
+ }
+ if let descr = cInfo.shortDescr?.trimmingCharacters(in: .whitespacesAndNewlines), descr != "" {
+ let r = markdownText(descr, textStyle: .subheadline, showSecrets: showSecrets, backgroundColor: theme.colors.background)
+ msgTextResultView(r, Text(AttributedString(r.string)), showSecrets: $showSecrets, centered: true, smallFont: true)
.multilineTextAlignment(.center)
.lineLimit(4)
+ .fixedSize(horizontal: false, vertical: true)
}
}
.frame(maxWidth: .infinity, alignment: .center)
}
-
+
private func localAliasTextEdit() -> some View {
TextField("Set contact name…", text: $localAlias)
.disableAutocorrection(true)
@@ -411,7 +424,7 @@ struct ChatInfoView: View {
.multilineTextAlignment(.center)
.foregroundColor(theme.colors.secondary)
}
-
+
private func setContactAlias() {
Task {
do {
@@ -474,7 +487,7 @@ struct ChatInfoView: View {
)
}
}
-
+
private func contactPreferencesButton() -> some View {
NavigationLink {
ContactPreferencesView(
@@ -490,21 +503,20 @@ struct ChatInfoView: View {
Label("Contact preferences", systemImage: "switch.2")
}
}
-
+
private func sendReceiptsOption() -> some View {
- Picker(selection: $sendReceipts) {
+ WrappedPicker(selection: $sendReceipts) {
ForEach([.yes, .no, .userDefault(sendReceiptsUserDefault)]) { (opt: SendReceipts) in
Text(opt.text)
}
} label: {
Label("Send receipts", systemImage: "checkmark.message")
}
- .frame(height: 36)
.onChange(of: sendReceipts) { _ in
setSendReceipts()
}
}
-
+
private func setSendReceipts() {
var chatSettings = chat.chatInfo.chatSettings ?? ChatSettings.defaults
chatSettings.sendRcpts = sendReceipts.bool()
@@ -524,7 +536,7 @@ struct ChatInfoView: View {
.foregroundColor(.orange)
}
}
-
+
private func synchronizeConnectionButtonForce() -> some View {
Button {
alert = .syncConnectionForceAlert
@@ -533,7 +545,7 @@ struct ChatInfoView: View {
.foregroundColor(.red)
}
}
-
+
private func networkStatusRow() -> some View {
HStack {
Text("Network status")
@@ -546,14 +558,14 @@ struct ChatInfoView: View {
serverImage()
}
}
-
+
private func serverImage() -> some View {
let status = networkModel.contactNetworkStatus(contact)
return Image(systemName: status.imageName)
.foregroundColor(status == .connected ? .green : theme.colors.secondary)
.font(.system(size: 12))
}
-
+
private func deleteContactButton() -> some View {
Button(role: .destructive) {
deleteContactDialog(
@@ -569,7 +581,7 @@ struct ChatInfoView: View {
.foregroundColor(Color.red)
}
}
-
+
private func clearChatButton() -> some View {
Button() {
alert = .clearChatAlert
@@ -578,7 +590,7 @@ struct ChatInfoView: View {
.foregroundColor(Color.orange)
}
}
-
+
private func clearChatAlert() -> Alert {
Alert(
title: Text("Clear conversation?"),
@@ -592,14 +604,14 @@ struct ChatInfoView: View {
secondaryButton: .cancel()
)
}
-
+
private func networkStatusAlert() -> Alert {
Alert(
title: Text("Network status"),
message: Text(networkModel.contactNetworkStatus(contact).statusExplanation)
)
}
-
+
private func switchContactAddress() {
Task {
do {
@@ -618,7 +630,7 @@ struct ChatInfoView: View {
}
}
}
-
+
private func abortSwitchContactAddress() {
Task {
do {
@@ -636,7 +648,7 @@ struct ChatInfoView: View {
}
}
}
-
+
private func savePreferences() {
Task {
do {
@@ -662,19 +674,18 @@ struct ChatTTLOption: View {
@State private var chatItemTTL: ChatTTL = ChatTTL.chat(.seconds(0))
var body: some View {
- Picker("Delete messages after", selection: $chatItemTTL) {
+ WrappedPicker("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(
@@ -687,7 +698,7 @@ struct ChatTTLOption: View {
let m = ChatModel.shared
do {
try await setChatTTL(chatType: chat.chatInfo.chatType, id: chat.chatInfo.apiId, ttl)
- await loadChat(chat: chat, clearItems: true)
+ await loadChat(chat: chat, im: ItemsModel.shared, clearItems: true)
await MainActor.run {
progressIndicator = false
currentChatItemTTL = chatItemTTL
@@ -700,7 +711,7 @@ struct ChatTTLOption: View {
}
catch let error {
logger.error("setChatTTL error \(responseError(error))")
- await loadChat(chat: chat, clearItems: true)
+ await loadChat(chat: chat, im: ItemsModel.shared, clearItems: true)
await MainActor.run {
chatItemTTL = currentChatItemTTL
progressIndicator = false
@@ -833,7 +844,7 @@ private struct CallButton: View {
))
}
}
- } else if contact.nextSendGrpInv {
+ } else if contact.sendMsgToConnect {
showAlert(SomeAlert(
alert: mkAlert(
title: "Can't call contact",
@@ -938,7 +949,7 @@ struct ChatWallpaperEditorSheet: View {
self.chat = chat
self.themes = if case let ChatInfo.direct(contact) = chat.chatInfo, let uiThemes = contact.uiThemes {
uiThemes
- } else if case let ChatInfo.group(groupInfo) = chat.chatInfo, let uiThemes = groupInfo.uiThemes {
+ } else if case let ChatInfo.group(groupInfo, _) = chat.chatInfo, let uiThemes = groupInfo.uiThemes {
uiThemes
} else {
ThemeModeOverrides()
@@ -974,7 +985,7 @@ struct ChatWallpaperEditorSheet: View {
private func themesFromChat(_ chat: Chat) -> ThemeModeOverrides {
if case let ChatInfo.direct(contact) = chat.chatInfo, let uiThemes = contact.uiThemes {
uiThemes
- } else if case let ChatInfo.group(groupInfo) = chat.chatInfo, let uiThemes = groupInfo.uiThemes {
+ } else if case let ChatInfo.group(groupInfo, _) = chat.chatInfo, let uiThemes = groupInfo.uiThemes {
uiThemes
} else {
ThemeModeOverrides()
@@ -1052,12 +1063,12 @@ struct ChatWallpaperEditorSheet: View {
chat.wrappedValue = Chat.init(chatInfo: ChatInfo.direct(contact: contact))
themes = themesFromChat(chat.wrappedValue)
}
- } else if case var ChatInfo.group(groupInfo) = chat.wrappedValue.chatInfo {
+ } else if case var ChatInfo.group(groupInfo, _) = chat.wrappedValue.chatInfo {
groupInfo.uiThemes = changedThemesConstant
await MainActor.run {
- ChatModel.shared.updateChatInfo(ChatInfo.group(groupInfo: groupInfo))
- chat.wrappedValue = Chat.init(chatInfo: ChatInfo.group(groupInfo: groupInfo))
+ ChatModel.shared.updateChatInfo(ChatInfo.group(groupInfo: groupInfo, groupChatScope: nil))
+ chat.wrappedValue = Chat.init(chatInfo: ChatInfo.group(groupInfo: groupInfo, groupChatScope: nil))
themes = themesFromChat(chat.wrappedValue)
}
}
@@ -1137,13 +1148,13 @@ func setChatTTL(_ ttl: ChatTTL, hasPreviousTTL: Bool, onCancel: @escaping () ->
} 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(
diff --git a/apps/ios/Shared/Views/Chat/ChatItem/CIChatFeatureView.swift b/apps/ios/Shared/Views/Chat/ChatItem/CIChatFeatureView.swift
index 02be8af73b..b2b4441646 100644
--- a/apps/ios/Shared/Views/Chat/ChatItem/CIChatFeatureView.swift
+++ b/apps/ios/Shared/Views/Chat/ChatItem/CIChatFeatureView.swift
@@ -12,8 +12,8 @@ import SimpleXChat
struct CIChatFeatureView: View {
@EnvironmentObject var m: ChatModel
@Environment(\.revealed) var revealed: Bool
- @ObservedObject var im = ItemsModel.shared
@ObservedObject var chat: Chat
+ @ObservedObject var im: ItemsModel
@EnvironmentObject var theme: AppTheme
var chatItem: ChatItem
var feature: Feature
@@ -53,7 +53,7 @@ struct CIChatFeatureView: View {
private func mergedFeatures() -> [FeatureInfo]? {
var fs: [FeatureInfo] = []
var icons: Set = []
- if var i = m.getChatItemIndex(chatItem) {
+ if var i = m.getChatItemIndex(im, chatItem) {
while i < im.reversedChatItems.count,
let f = featureInfo(im.reversedChatItems[i]) {
if !icons.contains(f.icon) {
@@ -108,6 +108,7 @@ struct CIChatFeatureView_Previews: PreviewProvider {
let enabled = FeatureEnabled(forUser: false, forContact: false)
CIChatFeatureView(
chat: Chat.sampleData,
+ im: ItemsModel.shared,
chatItem: ChatItem.getChatFeatureSample(.fullDelete, enabled), feature: ChatFeature.fullDelete, iconColor: enabled.iconColor(.secondary)
).environment(\.revealed, true)
}
diff --git a/apps/ios/Shared/Views/Chat/ChatItem/CIFileView.swift b/apps/ios/Shared/Views/Chat/ChatItem/CIFileView.swift
index b0b404d8b5..1b9376b5db 100644
--- a/apps/ios/Shared/Views/Chat/ChatItem/CIFileView.swift
+++ b/apps/ios/Shared/Views/Chat/ChatItem/CIFileView.swift
@@ -278,6 +278,7 @@ func showFileErrorAlert(_ err: FileError, temporary: Bool = false) {
struct CIFileView_Previews: PreviewProvider {
static var previews: some View {
+ let im = ItemsModel.shared
let sentFile: ChatItem = ChatItem(
chatDir: .directSnd,
meta: CIMeta.getSample(1, .now, "", .sndSent(sndProgress: .complete), itemEdited: true),
@@ -293,16 +294,16 @@ struct CIFileView_Previews: PreviewProvider {
file: nil
)
Group {
- 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 })
+ ChatItemView(chat: Chat.sampleData, im: im, chatItem: sentFile, scrollToItem: { _ in }, scrollToItemId: Binding.constant(nil))
+ ChatItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getFileMsgContentSample(), scrollToItem: { _ in }, scrollToItemId: Binding.constant(nil))
+ ChatItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getFileMsgContentSample(fileName: "some_long_file_name_here", fileStatus: .rcvInvitation), scrollToItem: { _ in }, scrollToItemId: Binding.constant(nil))
+ ChatItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getFileMsgContentSample(fileStatus: .rcvAccepted), scrollToItem: { _ in }, scrollToItemId: Binding.constant(nil))
+ ChatItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getFileMsgContentSample(fileStatus: .rcvTransfer(rcvProgress: 7, rcvTotal: 10)), scrollToItem: { _ in }, scrollToItemId: Binding.constant(nil))
+ ChatItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getFileMsgContentSample(fileStatus: .rcvCancelled), scrollToItem: { _ in }, scrollToItemId: Binding.constant(nil))
+ ChatItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getFileMsgContentSample(fileSize: 1_000_000_000, fileStatus: .rcvInvitation), scrollToItem: { _ in }, scrollToItemId: Binding.constant(nil))
+ ChatItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getFileMsgContentSample(text: "Hello there", fileStatus: .rcvInvitation), scrollToItem: { _ in }, scrollToItemId: Binding.constant(nil))
+ ChatItemView(chat: Chat.sampleData, im: im, 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), scrollToItem: { _ in }, scrollToItemId: Binding.constant(nil))
+ ChatItemView(chat: Chat.sampleData, im: im, chatItem: fileChatItemWtFile, scrollToItem: { _ in }, scrollToItemId: Binding.constant(nil))
}
.environment(\.revealed, false)
.previewLayout(.fixed(width: 360, height: 360))
diff --git a/apps/ios/Shared/Views/Chat/ChatItem/CIImageView.swift b/apps/ios/Shared/Views/Chat/ChatItem/CIImageView.swift
index d30369339d..d1f49f635a 100644
--- a/apps/ios/Shared/Views/Chat/ChatItem/CIImageView.swift
+++ b/apps/ios/Shared/Views/Chat/ChatItem/CIImageView.swift
@@ -12,7 +12,7 @@ import SimpleXChat
struct CIImageView: View {
@EnvironmentObject var m: ChatModel
let chatItem: ChatItem
- var scrollToItemId: ((ChatItem.ID) -> Void)? = nil
+ var scrollToItem: ((ChatItem.ID) -> Void)? = nil
var preview: UIImage?
let maxWidth: CGFloat
var imgWidth: CGFloat?
@@ -26,7 +26,7 @@ struct CIImageView: View {
if let uiImage = getLoadedImage(file) {
Group { if smallView { smallViewImageView(uiImage) } else { imageView(uiImage) } }
.fullScreenCover(isPresented: $showFullScreenImage) {
- FullScreenMediaView(chatItem: chatItem, scrollToItemId: scrollToItemId, image: uiImage, showView: $showFullScreenImage)
+ FullScreenMediaView(chatItem: chatItem, scrollToItem: scrollToItem, image: uiImage, showView: $showFullScreenImage)
}
.if(!smallView) { view in
view.modifier(PrivacyBlur(blurred: $blurred))
diff --git a/apps/ios/Shared/Views/Chat/ChatItem/CILinkView.swift b/apps/ios/Shared/Views/Chat/ChatItem/CILinkView.swift
index f9dbaede63..f07e90b953 100644
--- a/apps/ios/Shared/Views/Chat/ChatItem/CILinkView.swift
+++ b/apps/ios/Shared/Views/Chat/ChatItem/CILinkView.swift
@@ -30,7 +30,7 @@ struct CILinkView: View {
VStack(alignment: .leading, spacing: 6) {
Text(linkPreview.title)
.lineLimit(3)
- Text(linkPreview.uri.absoluteString)
+ Text(linkPreview.uri)
.font(.caption)
.lineLimit(1)
.foregroundColor(theme.colors.secondary)
@@ -44,29 +44,71 @@ struct CILinkView: View {
}
}
-func openBrowserAlert(uri: URL) {
+func openBrowserAlert(uri: String) {
+ let (url, err) = sanitizeUri(uri)
+ if let url {
+ let uriStr = url.uri.absoluteString
+ showAlert(
+ NSLocalizedString("Open link?", comment: "alert title"),
+ message: uriStr.count > 160 ? "\(uriStr.prefix(160))…" : uriStr,
+ actions: {
+ if let sanitizedUri = url.sanitizedUri {
+ [
+ cancelAlertAction,
+ UIAlertAction(
+ title: NSLocalizedString("Open full link", comment: "alert action"),
+ style: .default,
+ handler: { _ in UIApplication.shared.open(url.uri) }
+ ),
+ UIAlertAction(
+ title: NSLocalizedString("Open clean link", comment: "alert action"),
+ style: .default,
+ handler: { _ in UIApplication.shared.open(sanitizedUri) }
+ )
+ ]
+ } else {
+ [
+ cancelAlertAction,
+ UIAlertAction(
+ title: NSLocalizedString("Open", comment: "alert action"),
+ style: .default,
+ handler: { _ in UIApplication.shared.open(url.uri) }
+ )
+ ]
+ }
+ }
+ )
+ } else {
+ showInvalidLinkAlert(uri, error: err)
+ }
+}
+
+func showInvalidLinkAlert(_ uri: String, error: String? = nil) {
+ let message = if let error, !error.isEmpty {
+ error + "\n" + uri
+ } else {
+ uri
+ }
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) }
- )
- ]}
+ NSLocalizedString("Invalid link", comment: "alert title"),
+ message: message,
+ actions: {[okAlertAction]}
)
}
+func sanitizeUri(_ s: String) -> (url: (uri: URL, sanitizedUri: URL?)?, error: String?) {
+ let parsed = parseSanitizeUri(s, safe: false)
+ return if let uri = URL(string: s), let uriInfo = parsed?.uriInfo {
+ (url: (uri: uri, sanitizedUri: uriInfo.sanitized.flatMap { URL(string: $0) }), error: nil)
+ } else {
+ (url: nil, error: parsed?.parseError)
+ }
+}
+
struct LargeLinkPreview_Previews: PreviewProvider {
static var previews: some View {
let preview = LinkPreview(
- uri: URL(string: "http://DuckDuckGo.com")!,
+ uri: "http://DuckDuckGo.com",
title: "Privacy, simplified.",
description: "",
image: "data:image/jpg;base64,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"
diff --git a/apps/ios/Shared/Views/Chat/ChatItem/CIRcvDecryptionError.swift b/apps/ios/Shared/Views/Chat/ChatItem/CIRcvDecryptionError.swift
index 4e5713c263..3201332c1e 100644
--- a/apps/ios/Shared/Views/Chat/ChatItem/CIRcvDecryptionError.swift
+++ b/apps/ios/Shared/Views/Chat/ChatItem/CIRcvDecryptionError.swift
@@ -45,7 +45,7 @@ struct CIRcvDecryptionError: View {
viewBody()
.onAppear {
// for direct chat ConnectionStats are populated on opening chat, see ChatView onAppear
- if case let .group(groupInfo) = chat.chatInfo,
+ if case let .group(groupInfo, _) = chat.chatInfo,
case let .groupRcv(groupMember) = chatItem.chatDir {
do {
let (member, stats) = try apiGroupMemberInfoSync(groupInfo.apiId, groupMember.groupMemberId)
@@ -83,7 +83,7 @@ struct CIRcvDecryptionError: View {
} else {
basicDecryptionErrorItem()
}
- } else if case let .group(groupInfo) = chat.chatInfo,
+ } else if case let .group(groupInfo, _) = chat.chatInfo,
case let .groupRcv(groupMember) = chatItem.chatDir,
let mem = m.getGroupMember(groupMember.groupMemberId),
let memberStats = mem.wrapped.activeConn?.connectionStats {
diff --git a/apps/ios/Shared/Views/Chat/ChatItem/CIVoiceView.swift b/apps/ios/Shared/Views/Chat/ChatItem/CIVoiceView.swift
index 715e606a74..47aee2a586 100644
--- a/apps/ios/Shared/Views/Chat/ChatItem/CIVoiceView.swift
+++ b/apps/ios/Shared/Views/Chat/ChatItem/CIVoiceView.swift
@@ -435,6 +435,7 @@ class VoiceItemState {
struct CIVoiceView_Previews: PreviewProvider {
static var previews: some View {
+ let im = ItemsModel.shared
let sentVoiceMessage: ChatItem = ChatItem(
chatDir: .directSnd,
meta: CIMeta.getSample(1, .now, "", .sndSent(sndProgress: .complete), itemEdited: true),
@@ -457,10 +458,10 @@ struct CIVoiceView_Previews: PreviewProvider {
duration: 30,
allowMenu: Binding.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))
+ ChatItemView(chat: Chat.sampleData, im: im, chatItem: sentVoiceMessage, scrollToItem: { _ in }, scrollToItemId: Binding.constant(nil), allowMenu: .constant(true))
+ ChatItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getVoiceMsgContentSample(), scrollToItem: { _ in }, scrollToItemId: Binding.constant(nil), allowMenu: .constant(true))
+ ChatItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getVoiceMsgContentSample(fileStatus: .rcvTransfer(rcvProgress: 7, rcvTotal: 10)), scrollToItem: { _ in }, scrollToItemId: Binding.constant(nil), allowMenu: .constant(true))
+ ChatItemView(chat: Chat.sampleData, im: im, chatItem: voiceMessageWtFile, scrollToItem: { _ in }, scrollToItemId: Binding.constant(nil), allowMenu: .constant(true))
}
.previewLayout(.fixed(width: 360, height: 360))
}
diff --git a/apps/ios/Shared/Views/Chat/ChatItem/FramedCIVoiceView.swift b/apps/ios/Shared/Views/Chat/ChatItem/FramedCIVoiceView.swift
index f4e2a4135a..0b6f249b9c 100644
--- a/apps/ios/Shared/Views/Chat/ChatItem/FramedCIVoiceView.swift
+++ b/apps/ios/Shared/Views/Chat/ChatItem/FramedCIVoiceView.swift
@@ -77,6 +77,7 @@ struct FramedCIVoiceView: View {
struct FramedCIVoiceView_Previews: PreviewProvider {
static var previews: some View {
+ let im = ItemsModel.shared
let sentVoiceMessage: ChatItem = ChatItem(
chatDir: .directSnd,
meta: CIMeta.getSample(1, .now, "", .sndSent(sndProgress: .complete), itemEdited: true),
@@ -92,11 +93,11 @@ struct FramedCIVoiceView_Previews: PreviewProvider {
file: CIFile.getSample(fileStatus: .sndComplete)
)
Group {
- 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 })
+ ChatItemView(chat: Chat.sampleData, im: im, chatItem: sentVoiceMessage, scrollToItem: { _ in }, scrollToItemId: Binding.constant(nil))
+ ChatItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getVoiceMsgContentSample(text: "Hello there"), scrollToItem: { _ in }, scrollToItemId: Binding.constant(nil))
+ ChatItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getVoiceMsgContentSample(text: "Hello there", fileStatus: .rcvTransfer(rcvProgress: 7, rcvTotal: 10)), scrollToItem: { _ in }, scrollToItemId: Binding.constant(nil))
+ ChatItemView(chat: Chat.sampleData, im: im, 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."), scrollToItem: { _ in }, scrollToItemId: Binding.constant(nil))
+ ChatItemView(chat: Chat.sampleData, im: im, chatItem: voiceMessageWithQuote, scrollToItem: { _ in }, scrollToItemId: Binding.constant(nil))
}
.environment(\.revealed, false)
.previewLayout(.fixed(width: 360, height: 360))
diff --git a/apps/ios/Shared/Views/Chat/ChatItem/FramedItemView.swift b/apps/ios/Shared/Views/Chat/ChatItem/FramedItemView.swift
index b27d266d8a..c9c9952688 100644
--- a/apps/ios/Shared/Views/Chat/ChatItem/FramedItemView.swift
+++ b/apps/ios/Shared/Views/Chat/ChatItem/FramedItemView.swift
@@ -13,8 +13,10 @@ struct FramedItemView: View {
@EnvironmentObject var m: ChatModel
@EnvironmentObject var theme: AppTheme
@ObservedObject var chat: Chat
+ @ObservedObject var im: ItemsModel
var chatItem: ChatItem
- var scrollToItemId: (ChatItem.ID) -> Void
+ var scrollToItem: (ChatItem.ID) -> Void
+ @Binding var scrollToItemId: ChatItem.ID?
var preview: UIImage?
var maxWidth: CGFloat = .infinity
@State var msgWidth: CGFloat = 0
@@ -56,12 +58,16 @@ struct FramedItemView: View {
if let qi = chatItem.quotedItem {
ciQuoteView(qi)
.simultaneousGesture(TapGesture().onEnded {
- if let ci = ItemsModel.shared.reversedChatItems.first(where: { $0.id == qi.itemId }) {
+ if let ci = im.reversedChatItems.first(where: { $0.id == qi.itemId }) {
withAnimation {
- scrollToItemId(ci.id)
+ scrollToItem(ci.id)
}
} else if let id = qi.itemId {
- scrollToItemId(id)
+ if (chatItem.isReport && im.secondaryIMFilter != nil) {
+ scrollToItemId = id
+ } else {
+ scrollToItem(id)
+ }
} else {
showQuotedItemDoesNotExistAlert()
}
@@ -70,7 +76,7 @@ struct FramedItemView: View {
framedItemHeader(icon: "arrowshape.turn.up.forward", caption: Text(itemForwarded.text(chat.chatInfo.chatType)).italic(), pad: true)
}
- ChatItemContentView(chat: chat, chatItem: chatItem, msgContentView: framedMsgContentView)
+ ChatItemContentView(chat: chat, im: im, chatItem: chatItem, msgContentView: framedMsgContentView)
.padding(chatItem.content.msgContent != nil ? 0 : 4)
.overlay(DetermineWidth())
}
@@ -119,7 +125,7 @@ struct FramedItemView: View {
} else {
switch (chatItem.content.msgContent) {
case let .image(text, _):
- CIImageView(chatItem: chatItem, scrollToItemId: scrollToItemId, preview: preview, maxWidth: maxWidth, imgWidth: imgWidth, showFullScreenImage: $showFullscreenGallery)
+ CIImageView(chatItem: chatItem, scrollToItem: scrollToItem, preview: preview, maxWidth: maxWidth, imgWidth: imgWidth, showFullScreenImage: $showFullscreenGallery)
.overlay(DetermineWidth())
if text == "" && !chatItem.meta.isLive {
Color.clear
@@ -290,7 +296,7 @@ struct FramedItemView: View {
private func membership() -> GroupMember? {
switch chat.chatInfo {
- case let .group(groupInfo: groupInfo): return groupInfo.membership
+ case let .group(groupInfo: groupInfo, _): return groupInfo.membership
default: return nil
}
}
@@ -386,15 +392,16 @@ func chatItemFrameContextColor(_ ci: ChatItem, _ theme: AppTheme) -> Color {
struct FramedItemView_Previews: PreviewProvider {
static var previews: some View {
+ let im = ItemsModel.shared
Group{
- FramedItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(1, .directSnd, .now, "hello"), scrollToItemId: { _ in }, allowMenu: Binding.constant(true))
- FramedItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(1, .groupRcv(groupMember: GroupMember.sampleData), .now, "hello", quotedItem: CIQuote.getSample(1, .now, "hi", chatDir: .directSnd)), scrollToItemId: { _ in }, allowMenu: Binding.constant(true))
- FramedItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directSnd, .now, "https://simplex.chat", .sndSent(sndProgress: .complete), quotedItem: CIQuote.getSample(1, .now, "hi", chatDir: .directRcv)), scrollToItemId: { _ in }, allowMenu: Binding.constant(true))
- FramedItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directSnd, .now, "👍", .sndSent(sndProgress: .complete), quotedItem: CIQuote.getSample(1, .now, "Hello too", chatDir: .directRcv)), scrollToItemId: { _ in }, allowMenu: Binding.constant(true))
- FramedItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directRcv, .now, "hello there too!!! this covers -"), scrollToItemId: { _ in }, allowMenu: Binding.constant(true))
- FramedItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directRcv, .now, "hello there too!!! this text has the time on the same line "), scrollToItemId: { _ in }, allowMenu: Binding.constant(true))
- FramedItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directRcv, .now, "https://simplex.chat"), scrollToItemId: { _ in }, allowMenu: Binding.constant(true))
- FramedItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directRcv, .now, "chaT@simplex.chat"), scrollToItemId: { _ in }, allowMenu: Binding.constant(true))
+ FramedItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getSample(1, .directSnd, .now, "hello"), scrollToItem: { _ in }, scrollToItemId: Binding.constant(nil), allowMenu: Binding.constant(true))
+ FramedItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getSample(1, .groupRcv(groupMember: GroupMember.sampleData), .now, "hello", quotedItem: CIQuote.getSample(1, .now, "hi", chatDir: .directSnd)), scrollToItem: { _ in }, scrollToItemId: Binding.constant(nil), allowMenu: Binding.constant(true))
+ FramedItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getSample(2, .directSnd, .now, "https://simplex.chat", .sndSent(sndProgress: .complete), quotedItem: CIQuote.getSample(1, .now, "hi", chatDir: .directRcv)), scrollToItem: { _ in }, scrollToItemId: Binding.constant(nil), allowMenu: Binding.constant(true))
+ FramedItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getSample(2, .directSnd, .now, "👍", .sndSent(sndProgress: .complete), quotedItem: CIQuote.getSample(1, .now, "Hello too", chatDir: .directRcv)), scrollToItem: { _ in }, scrollToItemId: Binding.constant(nil), allowMenu: Binding.constant(true))
+ FramedItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getSample(2, .directRcv, .now, "hello there too!!! this covers -"), scrollToItem: { _ in }, scrollToItemId: Binding.constant(nil), allowMenu: Binding.constant(true))
+ FramedItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getSample(2, .directRcv, .now, "hello there too!!! this text has the time on the same line "), scrollToItem: { _ in }, scrollToItemId: Binding.constant(nil), allowMenu: Binding.constant(true))
+ FramedItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getSample(2, .directRcv, .now, "https://simplex.chat"), scrollToItem: { _ in }, scrollToItemId: Binding.constant(nil), allowMenu: Binding.constant(true))
+ FramedItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getSample(2, .directRcv, .now, "chaT@simplex.chat"), scrollToItem: { _ in }, scrollToItemId: Binding.constant(nil), allowMenu: Binding.constant(true))
}
.previewLayout(.fixed(width: 360, height: 200))
}
@@ -402,17 +409,18 @@ struct FramedItemView_Previews: PreviewProvider {
struct FramedItemView_Edited_Previews: PreviewProvider {
static var previews: some View {
+ let im = ItemsModel.shared
Group {
- FramedItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(1, .directSnd, .now, "hello", .sndSent(sndProgress: .complete), itemEdited: true), scrollToItemId: { _ in }, allowMenu: Binding.constant(true))
- FramedItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(1, .groupRcv(groupMember: GroupMember.sampleData), .now, "hello", quotedItem: CIQuote.getSample(1, .now, "hi", chatDir: .directSnd), itemEdited: true), scrollToItemId: { _ in }, allowMenu: Binding.constant(true))
- FramedItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directSnd, .now, "https://simplex.chat", .sndSent(sndProgress: .complete), quotedItem: CIQuote.getSample(1, .now, "hi", chatDir: .directRcv), itemEdited: true), scrollToItemId: { _ in }, allowMenu: Binding.constant(true))
- FramedItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directSnd, .now, "👍", .sndSent(sndProgress: .complete), quotedItem: CIQuote.getSample(1, .now, "Hello too", chatDir: .directRcv), itemEdited: true), scrollToItemId: { _ in }, allowMenu: Binding.constant(true))
- FramedItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directRcv, .now, "hello there too!!! this covers -", .rcvRead, itemEdited: true), scrollToItemId: { _ in }, allowMenu: Binding.constant(true))
- FramedItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directRcv, .now, "hello there too!!! this text has the time on the same line ", .rcvRead, itemEdited: true), scrollToItemId: { _ in }, allowMenu: Binding.constant(true))
- FramedItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directRcv, .now, "https://simplex.chat", .rcvRead, itemEdited: true), scrollToItemId: { _ in }, allowMenu: Binding.constant(true))
- FramedItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directRcv, .now, "chaT@simplex.chat", .rcvRead, itemEdited: true), scrollToItemId: { _ in }, allowMenu: Binding.constant(true))
- FramedItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(1, .groupRcv(groupMember: GroupMember.sampleData), .now, "hello", quotedItem: CIQuote.getSample(1, .now, "hi there hello hello hello ther hello hello", chatDir: .directSnd, image: "data:image/jpg;base64,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"), itemEdited: true), scrollToItemId: { _ in }, allowMenu: Binding.constant(true))
- FramedItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(1, .groupRcv(groupMember: GroupMember.sampleData), .now, "hello there this is a long text", quotedItem: CIQuote.getSample(1, .now, "hi there", chatDir: .directSnd, image: "data:image/jpg;base64,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"), itemEdited: true), scrollToItemId: { _ in }, allowMenu: Binding.constant(true))
+ FramedItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getSample(1, .directSnd, .now, "hello", .sndSent(sndProgress: .complete), itemEdited: true), scrollToItem: { _ in }, scrollToItemId: Binding.constant(nil), allowMenu: Binding.constant(true))
+ FramedItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getSample(1, .groupRcv(groupMember: GroupMember.sampleData), .now, "hello", quotedItem: CIQuote.getSample(1, .now, "hi", chatDir: .directSnd), itemEdited: true), scrollToItem: { _ in }, scrollToItemId: Binding.constant(nil), allowMenu: Binding.constant(true))
+ FramedItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getSample(2, .directSnd, .now, "https://simplex.chat", .sndSent(sndProgress: .complete), quotedItem: CIQuote.getSample(1, .now, "hi", chatDir: .directRcv), itemEdited: true), scrollToItem: { _ in }, scrollToItemId: Binding.constant(nil), allowMenu: Binding.constant(true))
+ FramedItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getSample(2, .directSnd, .now, "👍", .sndSent(sndProgress: .complete), quotedItem: CIQuote.getSample(1, .now, "Hello too", chatDir: .directRcv), itemEdited: true), scrollToItem: { _ in }, scrollToItemId: Binding.constant(nil), allowMenu: Binding.constant(true))
+ FramedItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getSample(2, .directRcv, .now, "hello there too!!! this covers -", .rcvRead, itemEdited: true), scrollToItem: { _ in }, scrollToItemId: Binding.constant(nil), allowMenu: Binding.constant(true))
+ FramedItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getSample(2, .directRcv, .now, "hello there too!!! this text has the time on the same line ", .rcvRead, itemEdited: true), scrollToItem: { _ in }, scrollToItemId: Binding.constant(nil), allowMenu: Binding.constant(true))
+ FramedItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getSample(2, .directRcv, .now, "https://simplex.chat", .rcvRead, itemEdited: true), scrollToItem: { _ in }, scrollToItemId: Binding.constant(nil), allowMenu: Binding.constant(true))
+ FramedItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getSample(2, .directRcv, .now, "chaT@simplex.chat", .rcvRead, itemEdited: true), scrollToItem: { _ in }, scrollToItemId: Binding.constant(nil), allowMenu: Binding.constant(true))
+ FramedItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getSample(1, .groupRcv(groupMember: GroupMember.sampleData), .now, "hello", quotedItem: CIQuote.getSample(1, .now, "hi there hello hello hello ther hello hello", chatDir: .directSnd, image: "data:image/jpg;base64,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"), itemEdited: true), scrollToItem: { _ in }, scrollToItemId: Binding.constant(nil), allowMenu: Binding.constant(true))
+ FramedItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getSample(1, .groupRcv(groupMember: GroupMember.sampleData), .now, "hello there this is a long text", quotedItem: CIQuote.getSample(1, .now, "hi there", chatDir: .directSnd, image: "data:image/jpg;base64,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"), itemEdited: true), scrollToItem: { _ in }, scrollToItemId: Binding.constant(nil), allowMenu: Binding.constant(true))
}
.environment(\.revealed, false)
.previewLayout(.fixed(width: 360, height: 200))
@@ -421,17 +429,18 @@ struct FramedItemView_Edited_Previews: PreviewProvider {
struct FramedItemView_Deleted_Previews: PreviewProvider {
static var previews: some View {
+ let im = ItemsModel.shared
Group {
- FramedItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(1, .directSnd, .now, "hello", .sndSent(sndProgress: .complete), itemDeleted: .deleted(deletedTs: .now)), scrollToItemId: { _ in }, allowMenu: Binding.constant(true))
- FramedItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(1, .groupRcv(groupMember: GroupMember.sampleData), .now, "hello", quotedItem: CIQuote.getSample(1, .now, "hi", chatDir: .directSnd), itemDeleted: .deleted(deletedTs: .now)), scrollToItemId: { _ in }, allowMenu: Binding.constant(true))
- FramedItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directSnd, .now, "https://simplex.chat", .sndSent(sndProgress: .complete), quotedItem: CIQuote.getSample(1, .now, "hi", chatDir: .directRcv), itemDeleted: .deleted(deletedTs: .now)), scrollToItemId: { _ in }, allowMenu: Binding.constant(true))
- FramedItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directSnd, .now, "👍", .sndSent(sndProgress: .complete), quotedItem: CIQuote.getSample(1, .now, "Hello too", chatDir: .directRcv), itemDeleted: .deleted(deletedTs: .now)), scrollToItemId: { _ in }, allowMenu: Binding.constant(true))
- FramedItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directRcv, .now, "hello there too!!! this covers -", .rcvRead, itemDeleted: .deleted(deletedTs: .now)), scrollToItemId: { _ in }, allowMenu: Binding.constant(true))
- FramedItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directRcv, .now, "hello there too!!! this text has the time on the same line ", .rcvRead, itemDeleted: .deleted(deletedTs: .now)), scrollToItemId: { _ in }, allowMenu: Binding.constant(true))
- FramedItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directRcv, .now, "https://simplex.chat", .rcvRead, itemDeleted: .deleted(deletedTs: .now)), scrollToItemId: { _ in }, allowMenu: Binding.constant(true))
- FramedItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directRcv, .now, "chaT@simplex.chat", .rcvRead, itemDeleted: .deleted(deletedTs: .now)), scrollToItemId: { _ in }, allowMenu: Binding.constant(true))
- FramedItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(1, .groupRcv(groupMember: GroupMember.sampleData), .now, "hello", quotedItem: CIQuote.getSample(1, .now, "hi there hello hello hello ther hello hello", chatDir: .directSnd, image: "data:image/jpg;base64,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"), itemDeleted: .deleted(deletedTs: .now)), scrollToItemId: { _ in }, allowMenu: Binding.constant(true))
- FramedItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(1, .groupRcv(groupMember: GroupMember.sampleData), .now, "hello there this is a long text", quotedItem: CIQuote.getSample(1, .now, "hi there", chatDir: .directSnd, image: "data:image/jpg;base64,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"), itemDeleted: .deleted(deletedTs: .now)), scrollToItemId: { _ in }, allowMenu: Binding.constant(true))
+ FramedItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getSample(1, .directSnd, .now, "hello", .sndSent(sndProgress: .complete), itemDeleted: .deleted(deletedTs: .now)), scrollToItem: { _ in }, scrollToItemId: Binding.constant(nil), allowMenu: Binding.constant(true))
+ FramedItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getSample(1, .groupRcv(groupMember: GroupMember.sampleData), .now, "hello", quotedItem: CIQuote.getSample(1, .now, "hi", chatDir: .directSnd), itemDeleted: .deleted(deletedTs: .now)), scrollToItem: { _ in }, scrollToItemId: Binding.constant(nil), allowMenu: Binding.constant(true))
+ FramedItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getSample(2, .directSnd, .now, "https://simplex.chat", .sndSent(sndProgress: .complete), quotedItem: CIQuote.getSample(1, .now, "hi", chatDir: .directRcv), itemDeleted: .deleted(deletedTs: .now)), scrollToItem: { _ in }, scrollToItemId: Binding.constant(nil), allowMenu: Binding.constant(true))
+ FramedItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getSample(2, .directSnd, .now, "👍", .sndSent(sndProgress: .complete), quotedItem: CIQuote.getSample(1, .now, "Hello too", chatDir: .directRcv), itemDeleted: .deleted(deletedTs: .now)), scrollToItem: { _ in }, scrollToItemId: Binding.constant(nil), allowMenu: Binding.constant(true))
+ FramedItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getSample(2, .directRcv, .now, "hello there too!!! this covers -", .rcvRead, itemDeleted: .deleted(deletedTs: .now)), scrollToItem: { _ in }, scrollToItemId: Binding.constant(nil), allowMenu: Binding.constant(true))
+ FramedItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getSample(2, .directRcv, .now, "hello there too!!! this text has the time on the same line ", .rcvRead, itemDeleted: .deleted(deletedTs: .now)), scrollToItem: { _ in }, scrollToItemId: Binding.constant(nil), allowMenu: Binding.constant(true))
+ FramedItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getSample(2, .directRcv, .now, "https://simplex.chat", .rcvRead, itemDeleted: .deleted(deletedTs: .now)), scrollToItem: { _ in }, scrollToItemId: Binding.constant(nil), allowMenu: Binding.constant(true))
+ FramedItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getSample(2, .directRcv, .now, "chaT@simplex.chat", .rcvRead, itemDeleted: .deleted(deletedTs: .now)), scrollToItem: { _ in }, scrollToItemId: Binding.constant(nil), allowMenu: Binding.constant(true))
+ FramedItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getSample(1, .groupRcv(groupMember: GroupMember.sampleData), .now, "hello", quotedItem: CIQuote.getSample(1, .now, "hi there hello hello hello ther hello hello", chatDir: .directSnd, image: "data:image/jpg;base64,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"), itemDeleted: .deleted(deletedTs: .now)), scrollToItem: { _ in }, scrollToItemId: Binding.constant(nil), allowMenu: Binding.constant(true))
+ FramedItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getSample(1, .groupRcv(groupMember: GroupMember.sampleData), .now, "hello there this is a long text", quotedItem: CIQuote.getSample(1, .now, "hi there", chatDir: .directSnd, image: "data:image/jpg;base64,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"), itemDeleted: .deleted(deletedTs: .now)), scrollToItem: { _ in }, scrollToItemId: Binding.constant(nil), allowMenu: Binding.constant(true))
}
.environment(\.revealed, false)
.previewLayout(.fixed(width: 360, height: 200))
diff --git a/apps/ios/Shared/Views/Chat/ChatItem/FullScreenMediaView.swift b/apps/ios/Shared/Views/Chat/ChatItem/FullScreenMediaView.swift
index 10e5efa298..f243a83142 100644
--- a/apps/ios/Shared/Views/Chat/ChatItem/FullScreenMediaView.swift
+++ b/apps/ios/Shared/Views/Chat/ChatItem/FullScreenMediaView.swift
@@ -14,7 +14,7 @@ import AVKit
struct FullScreenMediaView: View {
@EnvironmentObject var m: ChatModel
@State var chatItem: ChatItem
- var scrollToItemId: ((ChatItem.ID) -> Void)?
+ var scrollToItem: ((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
- scrollToItemId?(chatItem.id)
+ scrollToItem?(chatItem.id)
} else if w > 60 && w > abs(t.height) * 2 && !scrolling {
let previous = t.width > 0
scrolling = true
diff --git a/apps/ios/Shared/Views/Chat/ChatItem/MarkedDeletedItemView.swift b/apps/ios/Shared/Views/Chat/ChatItem/MarkedDeletedItemView.swift
index 87a9b2ce61..c6a5d0353c 100644
--- a/apps/ios/Shared/Views/Chat/ChatItem/MarkedDeletedItemView.swift
+++ b/apps/ios/Shared/Views/Chat/ChatItem/MarkedDeletedItemView.swift
@@ -14,6 +14,7 @@ struct MarkedDeletedItemView: View {
@EnvironmentObject var theme: AppTheme
@Environment(\.revealed) var revealed: Bool
@ObservedObject var chat: Chat
+ @ObservedObject var im: ItemsModel
var chatItem: ChatItem
var body: some View {
@@ -29,14 +30,14 @@ struct MarkedDeletedItemView: View {
var mergedMarkedDeletedText: LocalizedStringKey {
if !revealed,
let ciCategory = chatItem.mergeCategory,
- var i = m.getChatItemIndex(chatItem) {
+ var i = m.getChatItemIndex(im, chatItem) {
var moderated = 0
var blocked = 0
var blockedByAdmin = 0
var deleted = 0
var moderatedBy: Set = []
- while i < ItemsModel.shared.reversedChatItems.count,
- let ci = .some(ItemsModel.shared.reversedChatItems[i]),
+ while i < im.reversedChatItems.count,
+ let ci = .some(im.reversedChatItems[i]),
ci.mergeCategory == ciCategory,
let itemDeleted = ci.meta.itemDeleted {
switch itemDeleted {
@@ -85,6 +86,7 @@ struct MarkedDeletedItemView_Previews: PreviewProvider {
Group {
MarkedDeletedItemView(
chat: Chat.sampleData,
+ im: ItemsModel.shared,
chatItem: ChatItem.getSample(1, .directSnd, .now, "hello", .sndSent(sndProgress: .complete), itemDeleted: .deleted(deletedTs: .now))
).environment(\.revealed, true)
}
diff --git a/apps/ios/Shared/Views/Chat/ChatItem/MsgContentView.swift b/apps/ios/Shared/Views/Chat/ChatItem/MsgContentView.swift
index e04584dfff..2a1b526893 100644
--- a/apps/ios/Shared/Views/Chat/ChatItem/MsgContentView.swift
+++ b/apps/ios/Shared/Views/Chat/ChatItem/MsgContentView.swift
@@ -93,7 +93,18 @@ struct MsgContentView: View {
@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 r = messageText(
+ text,
+ formattedText,
+ textStyle: textStyle,
+ sender: sender,
+ mentions: mentions,
+ userMemberId: userMemberId,
+ showSecrets: showSecrets,
+ commands: chat.chatInfo.useCommands && chat.chatInfo.sndReady,
+ backgroundColor: containerBackground,
+ prefix: prefix
+ )
let s = r.string
let t: Text
if let mt = meta {
@@ -104,7 +115,7 @@ struct MsgContentView: View {
} else {
t = Text(AttributedString(s))
}
- return msgTextResultView(r, t, showSecrets: $showSecrets)
+ return msgTextResultView(r, t, showSecrets: $showSecrets, sendCommand: { cmd in sendCommandMsg(chat, cmd) })
}
@inline(__always)
@@ -120,13 +131,27 @@ struct MsgContentView: View {
}
}
-func msgTextResultView(_ r: MsgTextResult, _ t: Text, showSecrets: Binding>? = nil) -> some View {
+func msgTextResultView(
+ _ r: MsgTextResult,
+ _ t: Text,
+ showSecrets: Binding>? = nil,
+ sendCommand: ((String) -> Void)? = nil,
+ centered: Bool = false,
+ smallFont: Bool = false
+) -> some View {
t.if(r.hasSecrets, transform: hiddenSecretsView)
- .if(r.handleTaps) { $0.overlay(handleTextTaps(r.string, showSecrets: showSecrets)) }
+ .if(r.handleTaps) { $0.overlay(handleTextTaps(r.string, showSecrets: showSecrets, sendCommand: sendCommand, centered: centered, smallFont: smallFont)) }
}
+// smallFont parameter is used to pad height, otherwise CTFrameGetLines fails to see them as lines - it's needed if font is not .body
@inline(__always)
-private func handleTextTaps(_ s: NSAttributedString, showSecrets: Binding>? = nil) -> some View {
+private func handleTextTaps(
+ _ s: NSAttributedString,
+ showSecrets: Binding>? = nil,
+ sendCommand: ((String) -> Void)? = nil,
+ centered: Bool,
+ smallFont: Bool
+) -> some View {
return GeometryReader { g in
Rectangle()
.fill(Color.clear)
@@ -135,38 +160,52 @@ private func handleTextTaps(_ s: NSAttributedString, showSecrets: Binding 100 { return }
let framesetter = CTFramesetterCreateWithAttributedString(s as CFAttributedString)
- let path = CGPath(rect: CGRect(origin: .zero, size: g.size), transform: nil)
+ let paddedSize = smallFont ? CGSize(width: g.size.width, height: g.size.height + 1.0) : g.size
+ let path = CGPath(rect: CGRect(origin: .zero, size: paddedSize), 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)
+ var maxWidth: CGFloat = 0
+ if centered {
+ for line in lines {
+ let bounds = CTLineGetBoundsWithOptions(line, .useOpticalBounds)
+ if bounds.width > maxWidth {
+ maxWidth = bounds.width
+ }
+ }
+ }
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)
+ let offsetX = centered ? (maxWidth - bounds.width) / 2 : 0
+ if bounds.offsetBy(dx: origins[i].x + offsetX, dy: origins[i].y).contains(point) {
+ let relativePoint = centered ? CGPoint(x: point.x - origins[i].x - offsetX, y: point.y - origins[i].y) : point
+ index = CTLineGetStringIndexForPosition(lines[i], relativePoint)
break
}
}
}
- if let index, let (url, browser) = attributedStringLink(s, for: index) {
+ if let index, let (uri, browser) = attributedStringLink(s, for: index) {
if browser {
- openBrowserAlert(uri: url)
- } else {
+ openBrowserAlert(uri: uri)
+ } else if let url = URL(string: uri) {
UIApplication.shared.open(url)
+ } else {
+ showInvalidLinkAlert(uri)
}
}
})
}
- func attributedStringLink(_ s: NSAttributedString, for index: CFIndex) -> (URL, Bool)? {
- var linkURL: URL?
+ func attributedStringLink(_ s: NSAttributedString, for index: CFIndex) -> (String, Bool)? {
+ var linkURL: String?
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
+ if let url = attrs[linkAttrKey] as? String {
+ linkURL = url
browser = attrs[webLinkAttrKey] != nil
} else if let showSecrets, let i = attrs[secretAttrKey] as? Int {
if showSecrets.wrappedValue.contains(i) {
@@ -174,6 +213,8 @@ private func handleTextTaps(_ s: NSAttributedString, showSecrets: Binding? = nil,
+ backgroundColor: Color
+) -> MsgTextResult {
+ messageText(
+ s,
+ parseSimpleXMarkdown(s),
+ textStyle: textStyle,
+ sender: sender,
+ preview: preview,
+ mentions: mentions,
+ userMemberId: userMemberId,
+ showSecrets: showSecrets,
+ commands: false,
+ backgroundColor: UIColor(backgroundColor)
+ )
+}
+
+
func messageText(
_ text: String,
_ formattedText: [FormattedText]?,
@@ -216,6 +285,7 @@ func messageText(
mentions: [String: CIMention]?,
userMemberId: String?,
showSecrets: Set?,
+ commands: Bool = false,
backgroundColor: UIColor,
prefix: NSAttributedString? = nil
) -> MsgTextResult {
@@ -288,22 +358,41 @@ func messageText(
case .uri:
attrs = linkAttrs()
if !preview {
- let s = t.lowercased()
- let link = s.hasPrefix("http://") || s.hasPrefix("https://")
+ let link = t.hasPrefix("http://") || t.hasPrefix("https://")
? t
: "https://" + t
- attrs[linkAttrKey] = NSURL(string: link)
+ attrs[linkAttrKey] = link
attrs[webLinkAttrKey] = true
handleTaps = true
}
- case let .simplexLink(linkType, simplexUri, smpHosts):
+ case let .hyperLink(text, uri):
attrs = linkAttrs()
+ if let text { t = text }
if !preview {
- attrs[linkAttrKey] = NSURL(string: simplexUri)
+ attrs[linkAttrKey] = uri
+ attrs[webLinkAttrKey] = true
handleTaps = true
}
- if case .description = privacySimplexLinkModeDefault.get() {
- t = simplexLinkText(linkType, smpHosts)
+ case let .simplexLink(text, linkType, simplexUri, smpHosts):
+ attrs = linkAttrs()
+ if !preview {
+ attrs[linkAttrKey] = simplexUri
+ handleTaps = true
+ }
+ if let s = text ?? (privacySimplexLinkModeDefault.get() == .description ? linkType.description : nil) {
+ res.append(NSAttributedString(string: s + " ", attributes: attrs))
+ italic = italic ?? UIFont(descriptor: descr.withSymbolicTraits(.traitItalic) ?? descr, size: descr.pointSize)
+ attrs[.font] = italic
+ t = viaHost(smpHosts)
+ }
+ case let .command(cmdStr):
+ snippet = snippet ?? UIFont.monospacedSystemFont(ofSize: descr.pointSize, weight: .regular)
+ attrs[.font] = snippet
+ t = "/" + cmdStr
+ if !preview && commands {
+ attrs[.foregroundColor] = uiLinkColor
+ attrs[commandAttrKey] = t
+ handleTaps = true
}
case let .mention(memberName):
if let m = mentions?[memberName] {
@@ -326,15 +415,16 @@ func messageText(
case .email:
attrs = linkAttrs()
if !preview {
- attrs[linkAttrKey] = NSURL(string: "mailto:" + ft.text)
+ attrs[linkAttrKey] = "mailto:" + ft.text
handleTaps = true
}
case .phone:
attrs = linkAttrs()
if !preview {
- attrs[linkAttrKey] = NSURL(string: "tel:" + t.replacingOccurrences(of: " ", with: ""))
+ attrs[linkAttrKey] = "tel:" + t.replacingOccurrences(of: " ", with: "")
handleTaps = true
}
+ case .unknown: ()
case .none: ()
}
res.append(NSAttributedString(string: t, attributes: attrs))
@@ -361,7 +451,11 @@ private func mentionText(_ name: String) -> String {
}
func simplexLinkText(_ linkType: SimplexLinkType, _ smpHosts: [String]) -> String {
- linkType.description + " " + "(via \(smpHosts.first ?? "?"))"
+ linkType.description + " " + viaHost(smpHosts)
+}
+
+func viaHost(_ smpHosts: [String]) -> String {
+ "(via \(smpHosts.first ?? "?"))"
}
struct MsgContentView_Previews: PreviewProvider {
diff --git a/apps/ios/Shared/Views/Chat/ChatItemInfoView.swift b/apps/ios/Shared/Views/Chat/ChatItemInfoView.swift
index cd75d1b0cd..87c6ba92f8 100644
--- a/apps/ios/Shared/Views/Chat/ChatItemInfoView.swift
+++ b/apps/ios/Shared/Views/Chat/ChatItemInfoView.swift
@@ -230,7 +230,7 @@ struct ChatItemInfoView: View {
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))
+ textBubble(itemVersion.msgContent.text, itemVersion.formattedText, nil, backgroundColor: backgroundColor)
.padding(.horizontal, 12)
.padding(.vertical, 6)
.background(backgroundColor)
@@ -258,7 +258,7 @@ struct ChatItemInfoView: View {
.frame(maxWidth: maxWidth, alignment: .leading)
}
- @ViewBuilder private func textBubble(_ text: String, _ formattedText: [FormattedText]?, _ sender: String? = nil, backgroundColor: UIColor) -> some View {
+ @ViewBuilder private func textBubble(_ text: String, _ formattedText: [FormattedText]?, _ sender: String? = nil, backgroundColor: Color) -> some View {
if text != "" {
TextBubble(text: text, formattedText: formattedText, sender: sender, mentions: ci.mentions, userMemberId: userMemberId, backgroundColor: backgroundColor)
} else {
@@ -275,11 +275,11 @@ struct ChatItemInfoView: View {
var sender: String? = nil
var mentions: [String: CIMention]?
var userMemberId: String?
- var backgroundColor: UIColor
+ var backgroundColor: Color
@State private var showSecrets: Set = []
var body: some View {
- let r = messageText(text, formattedText, sender: sender, mentions: mentions, userMemberId: userMemberId, showSecrets: showSecrets, backgroundColor: backgroundColor)
+ let r = messageText(text, formattedText, sender: sender, mentions: mentions, userMemberId: userMemberId, showSecrets: showSecrets, backgroundColor: UIColor(backgroundColor))
return msgTextResultView(r, Text(AttributedString(r.string)), showSecrets: $showSecrets)
}
}
@@ -305,7 +305,7 @@ struct ChatItemInfoView: View {
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))
+ textBubble(qi.text, qi.formattedText, qi.getSender(nil), backgroundColor: backgroundColor)
.padding(.horizontal, 12)
.padding(.vertical, 6)
.background(quotedMsgFrameColor(qi, theme))
diff --git a/apps/ios/Shared/Views/Chat/ChatItemView.swift b/apps/ios/Shared/Views/Chat/ChatItemView.swift
index f5558bcd93..5f48c18881 100644
--- a/apps/ios/Shared/Views/Chat/ChatItemView.swift
+++ b/apps/ios/Shared/Views/Chat/ChatItemView.swift
@@ -40,25 +40,31 @@ extension EnvironmentValues {
struct ChatItemView: View {
@ObservedObject var chat: Chat
+ @ObservedObject var im: ItemsModel
@EnvironmentObject var theme: AppTheme
@Environment(\.showTimestamp) var showTimestamp: Bool
@Environment(\.revealed) var revealed: Bool
var chatItem: ChatItem
- var scrollToItemId: (ChatItem.ID) -> Void
+ var scrollToItem: (ChatItem.ID) -> Void
+ @Binding var scrollToItemId: ChatItem.ID?
var maxWidth: CGFloat = .infinity
@Binding var allowMenu: Bool
init(
chat: Chat,
+ im: ItemsModel,
chatItem: ChatItem,
- scrollToItemId: @escaping (ChatItem.ID) -> Void,
+ scrollToItem: @escaping (ChatItem.ID) -> Void,
+ scrollToItemId: Binding = .constant(nil),
showMember: Bool = false,
maxWidth: CGFloat = .infinity,
allowMenu: Binding = .constant(false)
) {
self.chat = chat
+ self.im = im
self.chatItem = chatItem
- self.scrollToItemId = scrollToItemId
+ self.scrollToItem = scrollToItem
+ _scrollToItemId = scrollToItemId
self.maxWidth = maxWidth
_allowMenu = allowMenu
}
@@ -66,14 +72,14 @@ struct ChatItemView: View {
var body: some View {
let ci = chatItem
if chatItem.meta.itemDeleted != nil && (!revealed || chatItem.isDeletedContent) {
- MarkedDeletedItemView(chat: chat, chatItem: chatItem)
+ MarkedDeletedItemView(chat: chat, im: im, chatItem: chatItem)
} else if ci.quotedItem == nil && ci.meta.itemForwarded == nil && ci.meta.itemDeleted == nil && !ci.meta.isLive {
if let mc = ci.content.msgContent, mc.isText && isShortEmoji(ci.content.text) {
EmojiItemView(chat: chat, chatItem: ci)
} else if ci.content.text.isEmpty, case let .voice(_, duration) = ci.content.msgContent {
CIVoiceView(chat: chat, chatItem: ci, recordingFile: ci.file, duration: duration, allowMenu: $allowMenu)
} else if ci.content.msgContent == nil {
- ChatItemContentView(chat: chat, chatItem: chatItem, msgContentView: { Text(ci.text) }) // msgContent is unreachable branch in this case
+ ChatItemContentView(chat: chat, im: im, chatItem: chatItem, msgContentView: { Text(ci.text) }) // msgContent is unreachable branch in this case
} else {
framedItemView()
}
@@ -101,8 +107,10 @@ struct ChatItemView: View {
}()
return FramedItemView(
chat: chat,
+ im: im,
chatItem: chatItem,
- scrollToItemId: scrollToItemId,
+ scrollToItem: scrollToItem,
+ scrollToItemId: $scrollToItemId,
preview: preview,
maxWidth: maxWidth,
imgWidth: adjustedMaxWidth,
@@ -117,6 +125,7 @@ struct ChatItemContentView: View {
@EnvironmentObject var theme: AppTheme
@Environment(\.revealed) var revealed: Bool
@ObservedObject var chat: Chat
+ @ObservedObject var im: ItemsModel
var chatItem: ChatItem
var msgContentView: () -> Content
@AppStorage(DEFAULT_DEVELOPER_TOOLS) private var developerTools = false
@@ -140,7 +149,9 @@ struct ChatItemContentView: View {
case let .sndGroupInvitation(groupInvitation, memberRole): groupInvitationItemView(groupInvitation, memberRole)
case .rcvDirectEvent: eventItemView()
case .rcvGroupEvent(.memberCreatedContact): CIMemberCreatedContactView(chatItem: chatItem)
+ case .rcvGroupEvent(.newMemberPendingReview): CIEventView(eventText: pendingReviewEventItemText())
case .rcvGroupEvent: eventItemView()
+ case .sndGroupEvent(.userPendingReview): CIEventView(eventText: pendingReviewEventItemText())
case .sndGroupEvent: eventItemView()
case .rcvConnEvent: eventItemView()
case .sndConnEvent: eventItemView()
@@ -149,7 +160,7 @@ struct ChatItemContentView: View {
case let .rcvChatPreference(feature, allowed, param):
CIFeaturePreferenceView(chat: chat, chatItem: chatItem, feature: feature, allowed: allowed, param: param)
case let .sndChatPreference(feature, _, _):
- CIChatFeatureView(chat: chat, chatItem: chatItem, feature: feature, icon: feature.icon, iconColor: theme.colors.secondary)
+ CIChatFeatureView(chat: chat, im: im, chatItem: chatItem, feature: feature, icon: feature.icon, iconColor: theme.colors.secondary)
case let .rcvGroupFeature(feature, preference, _, role): chatFeatureView(feature, preference.enabled(role, for: chat.chatInfo.groupInfo?.membership).iconColor(theme.colors.secondary))
case let .sndGroupFeature(feature, preference, _, role): chatFeatureView(feature, preference.enabled(role, for: chat.chatInfo.groupInfo?.membership).iconColor(theme.colors.secondary))
case let .rcvChatFeatureRejected(feature): chatFeatureView(feature, .red)
@@ -161,6 +172,7 @@ struct ChatItemContentView: View {
case let .rcvDirectE2EEInfo(e2eeInfo): CIEventView(eventText: directE2EEInfoText(e2eeInfo))
case .sndGroupE2EEInfo: CIEventView(eventText: e2eeInfoNoPQText())
case .rcvGroupE2EEInfo: CIEventView(eventText: e2eeInfoNoPQText())
+ case .chatBanner: EmptyView()
case let .invalidJSON(json): CIInvalidJSONView(json: json)
}
}
@@ -181,6 +193,13 @@ struct ChatItemContentView: View {
CIEventView(eventText: eventItemViewText(theme.colors.secondary))
}
+ private func pendingReviewEventItemText() -> Text {
+ Text(chatItem.content.text)
+ .font(.caption)
+ .foregroundColor(theme.colors.secondary)
+ .fontWeight(.bold)
+ }
+
private func eventItemViewText(_ secondaryColor: Color) -> Text {
if !revealed, let t = mergedGroupEventText {
return chatEventText(t + textSpace + chatItem.timestampText, secondaryColor)
@@ -196,7 +215,7 @@ struct ChatItemContentView: View {
}
private func chatFeatureView(_ feature: Feature, _ iconColor: Color) -> some View {
- CIChatFeatureView(chat: chat, chatItem: chatItem, feature: feature, iconColor: iconColor)
+ CIChatFeatureView(chat: chat, im: im, chatItem: chatItem, feature: feature, iconColor: iconColor)
}
private var mergedGroupEventText: Text? {
@@ -223,16 +242,21 @@ struct ChatItemContentView: View {
}
private func directE2EEInfoText(_ info: E2EEInfo) -> Text {
- info.pqEnabled
- ? Text("Messages, files and calls are protected by **quantum resistant e2e encryption** with perfect forward secrecy, repudiation and break-in recovery.")
- .font(.caption)
- .foregroundColor(theme.colors.secondary)
- .fontWeight(.light)
- : e2eeInfoNoPQText()
+ if let pqEnabled = info.pqEnabled {
+ pqEnabled
+ ? e2eeInfoText("Messages, files and calls are protected by **quantum resistant e2e encryption** with perfect forward secrecy, repudiation and break-in recovery.")
+ : e2eeInfoNoPQText()
+ } else {
+ e2eeInfoText("Messages are protected by **end-to-end encryption**.")
+ }
}
private func e2eeInfoNoPQText() -> Text {
- Text("Messages, files and calls are protected by **end-to-end encryption** with perfect forward secrecy, repudiation and break-in recovery.")
+ e2eeInfoText("Messages, files and calls are protected by **end-to-end encryption** with perfect forward secrecy, repudiation and break-in recovery.")
+ }
+
+ private func e2eeInfoText(_ s: LocalizedStringKey) -> Text {
+ Text(s)
.font(.caption)
.foregroundColor(theme.colors.secondary)
.fontWeight(.light)
@@ -256,16 +280,17 @@ func chatEventText(_ ci: ChatItem, _ secondaryColor: Color) -> Text {
struct ChatItemView_Previews: PreviewProvider {
static var previews: some View {
+ let im = ItemsModel.shared
Group{
- 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)
+ ChatItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getSample(1, .directSnd, .now, "hello"), scrollToItem: { _ in }, scrollToItemId: Binding.constant(nil))
+ ChatItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getSample(2, .directRcv, .now, "hello there too"), scrollToItem: { _ in }, scrollToItemId: Binding.constant(nil))
+ ChatItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getSample(1, .directSnd, .now, "🙂"), scrollToItem: { _ in }, scrollToItemId: Binding.constant(nil))
+ ChatItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getSample(2, .directRcv, .now, "🙂🙂🙂🙂🙂"), scrollToItem: { _ in }, scrollToItemId: Binding.constant(nil))
+ ChatItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getSample(2, .directRcv, .now, "🙂🙂🙂🙂🙂🙂"), scrollToItem: { _ in }, scrollToItemId: Binding.constant(nil))
+ ChatItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getDeletedContentSample(), scrollToItem: { _ in }, scrollToItemId: Binding.constant(nil))
+ ChatItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getSample(1, .directSnd, .now, "hello", .sndSent(sndProgress: .complete), itemDeleted: .deleted(deletedTs: .now)), scrollToItem: { _ in }, scrollToItemId: Binding.constant(nil))
+ ChatItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getSample(1, .directSnd, .now, "🙂", .sndSent(sndProgress: .complete), itemLive: true), scrollToItem: { _ in }, scrollToItemId: Binding.constant(nil)).environment(\.revealed, true)
+ ChatItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getSample(1, .directSnd, .now, "hello", .sndSent(sndProgress: .complete), itemLive: true), scrollToItem: { _ in }, scrollToItemId: Binding.constant(nil)).environment(\.revealed, true)
}
.environment(\.revealed, false)
.previewLayout(.fixed(width: 360, height: 70))
@@ -275,10 +300,12 @@ struct ChatItemView_Previews: PreviewProvider {
struct ChatItemView_NonMsgContentDeleted_Previews: PreviewProvider {
static var previews: some View {
+ let im = ItemsModel.shared
let ciFeatureContent = CIContent.rcvChatFeature(feature: .fullDelete, enabled: FeatureEnabled(forUser: false, forContact: false), param: nil)
Group{
ChatItemView(
chat: Chat.sampleData,
+ im: im,
chatItem: ChatItem(
chatDir: .directRcv,
meta: CIMeta.getSample(1, .now, "1 skipped message", .rcvRead, itemDeleted: .deleted(deletedTs: .now)),
@@ -286,10 +313,12 @@ struct ChatItemView_NonMsgContentDeleted_Previews: PreviewProvider {
quotedItem: nil,
file: nil
),
- scrollToItemId: { _ in }
+ scrollToItem: { _ in },
+ scrollToItemId: Binding.constant(nil)
)
ChatItemView(
chat: Chat.sampleData,
+ im: im,
chatItem: ChatItem(
chatDir: .directRcv,
meta: CIMeta.getSample(1, .now, "1 skipped message", .rcvRead),
@@ -297,10 +326,11 @@ struct ChatItemView_NonMsgContentDeleted_Previews: PreviewProvider {
quotedItem: nil,
file: nil
),
- scrollToItemId: { _ in }
+ scrollToItem: { _ in }, scrollToItemId: Binding.constant(nil)
)
ChatItemView(
chat: Chat.sampleData,
+ im: im,
chatItem: ChatItem(
chatDir: .directRcv,
meta: CIMeta.getSample(1, .now, "received invitation to join group team as admin", .rcvRead, itemDeleted: .deleted(deletedTs: .now)),
@@ -308,10 +338,12 @@ struct ChatItemView_NonMsgContentDeleted_Previews: PreviewProvider {
quotedItem: nil,
file: nil
),
- scrollToItemId: { _ in }
+ scrollToItem: { _ in },
+ scrollToItemId: Binding.constant(nil)
)
ChatItemView(
chat: Chat.sampleData,
+ im: im,
chatItem: ChatItem(
chatDir: .directRcv,
meta: CIMeta.getSample(1, .now, "group event text", .rcvRead, itemDeleted: .deleted(deletedTs: .now)),
@@ -319,10 +351,12 @@ struct ChatItemView_NonMsgContentDeleted_Previews: PreviewProvider {
quotedItem: nil,
file: nil
),
- scrollToItemId: { _ in }
+ scrollToItem: { _ in },
+ scrollToItemId: Binding.constant(nil)
)
ChatItemView(
chat: Chat.sampleData,
+ im: im,
chatItem: ChatItem(
chatDir: .directRcv,
meta: CIMeta.getSample(1, .now, ciFeatureContent.text, .rcvRead, itemDeleted: .deleted(deletedTs: .now)),
@@ -330,7 +364,8 @@ struct ChatItemView_NonMsgContentDeleted_Previews: PreviewProvider {
quotedItem: nil,
file: nil
),
- scrollToItemId: { _ in }
+ scrollToItem: { _ in },
+ scrollToItemId: Binding.constant(nil)
)
}
.environment(\.revealed, true)
diff --git a/apps/ios/Shared/Views/Chat/ChatItemsLoader.swift b/apps/ios/Shared/Views/Chat/ChatItemsLoader.swift
index 07034cf8ec..93ecf870eb 100644
--- a/apps/ios/Shared/Views/Chat/ChatItemsLoader.swift
+++ b/apps/ios/Shared/Views/Chat/ChatItemsLoader.swift
@@ -13,8 +13,8 @@ let TRIM_KEEP_COUNT = 200
func apiLoadMessages(
_ chatId: ChatId,
+ _ im: ItemsModel,
_ pagination: ChatPagination,
- _ chatState: ActiveChatState,
_ search: String = "",
_ openAroundItemId: ChatItem.ID? = nil,
_ visibleItemIndexesNonReversed: @MainActor () -> ClosedRange = { 0 ... 0 }
@@ -22,7 +22,7 @@ func apiLoadMessages(
let chat: Chat
let navInfo: NavigationInfo
do {
- (chat, navInfo) = try await apiGetChat(chatId: chatId, pagination: pagination, search: search)
+ (chat, navInfo) = try await apiGetChat(chatId: chatId, scope: im.groupScopeInfo?.toChatScope(), contentTag: im.contentTag, pagination: pagination, search: search)
} catch let error {
logger.error("apiLoadMessages error: \(responseError(error))")
return
@@ -38,30 +38,31 @@ func apiLoadMessages(
return
}
- let unreadAfterItemId = chatState.unreadAfterItemId
+ let unreadAfterItemId = im.chatState.unreadAfterItemId
- let oldItems = Array(ItemsModel.shared.reversedChatItems.reversed())
+ let oldItems = Array(im.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 {
+ if im.secondaryIMFilter == nil && 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
+ im.reversedChatItems = chat.chatItems.reversed()
+ if im.secondaryIMFilter == nil {
+ chatModel.updateChatInfo(chat.chatInfo)
}
- chatState.totalAfter = navInfo.afterTotal
- chatState.unreadTotal = chat.chatStats.unreadCount
- chatState.unreadAfter = navInfo.afterUnread
- chatState.unreadAfterNewestLoaded = navInfo.afterUnread
+ im.chatState.splits = newSplits
+ if !chat.chatItems.isEmpty {
+ im.chatState.unreadAfterItemId = chat.chatItems.last!.id
+ }
+ im.chatState.totalAfter = navInfo.afterTotal
+ im.chatState.unreadTotal = chat.chatStats.unreadCount
+ im.chatState.unreadAfter = navInfo.afterUnread
+ im.chatState.unreadAfterNewestLoaded = navInfo.afterUnread
- PreloadState.shared.clear()
+ im.preloadState.clear()
}
case let .before(paginationChatItemId, _):
newItems.append(contentsOf: oldItems)
@@ -71,15 +72,15 @@ func apiLoadMessages(
let wasSize = newItems.count
let visibleItemIndexes = await MainActor.run { visibleItemIndexesNonReversed() }
let modifiedSplits = removeDuplicatesAndModifySplitsOnBeforePagination(
- unreadAfterItemId, &newItems, newIds, chatState.splits, visibleItemIndexes
+ unreadAfterItemId, &newItems, newIds, im.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)
+ im.reversedChatItems = newReversed
+ im.chatState.splits = modifiedSplits.newSplits
+ im.chatState.moveUnreadAfterItem(modifiedSplits.oldUnreadSplitIndex, modifiedSplits.newUnreadSplitIndex, oldItems)
}
case let .after(paginationChatItemId, _):
newItems.append(contentsOf: oldItems)
@@ -89,7 +90,7 @@ func apiLoadMessages(
let mappedItems = mapItemsToIds(chat.chatItems)
let newIds = mappedItems.0
let (newSplits, unreadInLoaded) = removeDuplicatesAndModifySplitsOnAfterPagination(
- mappedItems.1, paginationChatItemId, &newItems, newIds, chat, chatState.splits
+ mappedItems.1, paginationChatItemId, &newItems, newIds, chat, im.chatState.splits
)
let indexToAdd = min(indexInCurrentItems + 1, newItems.count)
let indexToAddIsLast = indexToAdd == newItems.count
@@ -97,19 +98,19 @@ func apiLoadMessages(
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)
+ im.reversedChatItems = newReversed
+ im.chatState.splits = newSplits
+ im.chatState.moveUnreadAfterItem(im.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
+ im.chatState.unreadAfterNewestLoaded -= unreadInLoaded
}
}
case .around:
var newSplits: [Int64]
if openAroundItemId == nil {
newItems.append(contentsOf: oldItems)
- newSplits = await removeDuplicatesAndUpperSplits(&newItems, chat, chatState.splits, visibleItemIndexesNonReversed)
+ newSplits = await removeDuplicatesAndUpperSplits(&newItems, chat, im.chatState.splits, visibleItemIndexesNonReversed)
} else {
newSplits = []
}
@@ -120,33 +121,37 @@ func apiLoadMessages(
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
+ im.reversedChatItems = newReversed
+ im.chatState.splits = orderedSplits
+ im.chatState.unreadAfterItemId = chat.chatItems.last!.id
+ im.chatState.totalAfter = navInfo.afterTotal
+ im.chatState.unreadTotal = chat.chatStats.unreadCount
+ im.chatState.unreadAfter = navInfo.afterUnread
if let openAroundItemId {
- chatState.unreadAfterNewestLoaded = navInfo.afterUnread
- ChatModel.shared.openAroundItemId = openAroundItemId
- ChatModel.shared.chatId = chatId
+ im.chatState.unreadAfterNewestLoaded = navInfo.afterUnread
+ if im.secondaryIMFilter == nil {
+ ChatModel.shared.openAroundItemId = openAroundItemId // TODO [knocking] move openAroundItemId from ChatModel to ItemsModel?
+ ChatModel.shared.chatId = chat.id
+ }
} else {
// no need to set it, count will be wrong
// chatState.unreadAfterNewestLoaded = navInfo.afterUnread
}
- PreloadState.shared.clear()
+ im.preloadState.clear()
}
case .last:
newItems.append(contentsOf: oldItems)
- let newSplits = await removeDuplicatesAndUnusedSplits(&newItems, chat, chatState.splits)
+ let newSplits = await removeDuplicatesAndUnusedSplits(&newItems, chat, im.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
+ im.reversedChatItems = items.reversed()
+ im.chatState.splits = newSplits
+ if im.secondaryIMFilter == nil {
+ chatModel.updateChatInfo(chat.chatInfo)
+ }
+ im.chatState.unreadAfterNewestLoaded = 0
}
}
}
diff --git a/apps/ios/Shared/Views/Chat/ChatItemsMerger.swift b/apps/ios/Shared/Views/Chat/ChatItemsMerger.swift
index 0a55ed48cc..5f2102b8bc 100644
--- a/apps/ios/Shared/Views/Chat/ChatItemsMerger.swift
+++ b/apps/ios/Shared/Views/Chat/ChatItemsMerger.swift
@@ -10,6 +10,7 @@ import SwiftUI
import SimpleXChat
struct MergedItems: Hashable, Equatable {
+ let im: ItemsModel
let items: [MergedItem]
let splits: [SplitRange]
// chat item id, index in list
@@ -23,15 +24,15 @@ struct MergedItems: Hashable, Equatable {
hasher.combine("\(items.hashValue)")
}
- static func create(_ items: [ChatItem], _ revealedItems: Set, _ chatState: ActiveChatState) -> MergedItems {
- if items.isEmpty {
- return MergedItems(items: [], splits: [], indexInParentItems: [:])
+ static func create(_ im: ItemsModel, _ revealedItems: Set) -> MergedItems {
+ if im.reversedChatItems.isEmpty {
+ return MergedItems(im: im, items: [], splits: [], indexInParentItems: [:])
}
- let unreadCount = chatState.unreadTotal
+ let unreadCount = im.chatState.unreadTotal
- let unreadAfterItemId = chatState.unreadAfterItemId
- let itemSplits = chatState.splits
+ let unreadAfterItemId = im.chatState.unreadAfterItemId
+ let itemSplits = im.chatState.splits
var mergedItems: [MergedItem] = []
// Indexes of splits here will be related to reversedChatItems, not chatModel.chatItems
var splitRanges: [SplitRange] = []
@@ -40,19 +41,19 @@ struct MergedItems: Hashable, Equatable {
var unclosedSplitIndex: Int? = nil
var unclosedSplitIndexInParent: Int? = nil
var visibleItemIndexInParent = -1
- var unreadBefore = unreadCount - chatState.unreadAfterNewestLoaded
+ var unreadBefore = unreadCount - im.chatState.unreadAfterNewestLoaded
var lastRevealedIdsInMergedItems: BoxedValue<[Int64]>? = nil
var lastRangeInReversedForMergedItems: BoxedValue>? = 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
+ while index < im.reversedChatItems.count {
+ let item = im.reversedChatItems[index]
+ let prev = index >= 1 ? im.reversedChatItems[index - 1] : nil
+ let next = index + 1 < im.reversedChatItems.count ? im.reversedChatItems[index + 1] : nil
let category = item.mergeCategory
let itemIsSplit = itemSplits.contains(item.id)
if item.id == unreadAfterItemId {
- unreadBefore = unreadCount - chatState.unreadAfter
+ unreadBefore = unreadCount - im.chatState.unreadAfter
}
if item.isRcvNew {
unreadBefore -= 1
@@ -106,18 +107,19 @@ struct MergedItems: Hashable, Equatable {
// 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))
+ splitRanges.append(SplitRange(itemId: im.reversedChatItems[unclosedSplitIndex].id, indexRangeInReversed: unclosedSplitIndex ... index - 1, indexRangeInParentItems: unclosedSplitIndexInParent ... visibleItemIndexInParent - 1))
}
unclosedSplitIndex = index
unclosedSplitIndexInParent = visibleItemIndexInParent
- } else if index + 1 == items.count, let unclosedSplitIndex, let unclosedSplitIndexInParent {
+ } else if index + 1 == im.reversedChatItems.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))
+ splitRanges.append(SplitRange(itemId: im.reversedChatItems[unclosedSplitIndex].id, indexRangeInReversed: unclosedSplitIndex ... index, indexRangeInParentItems: unclosedSplitIndexInParent ... visibleItemIndexInParent))
}
indexInParentItems[item.id] = visibleItemIndexInParent
index += 1
}
return MergedItems(
+ im: im,
items: mergedItems,
splits: splitRanges,
indexInParentItems: indexInParentItems
@@ -127,7 +129,6 @@ struct MergedItems: Hashable, Equatable {
// 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
@@ -434,7 +435,7 @@ class BoxedValue: Equatable, Hashable {
}
@MainActor
-func visibleItemIndexesNonReversed(_ listState: EndlessScrollView.ListState, _ mergedItems: MergedItems) -> ClosedRange {
+func visibleItemIndexesNonReversed(_ im: ItemsModel, _ listState: EndlessScrollView.ListState, _ mergedItems: MergedItems) -> ClosedRange {
let zero = 0 ... 0
let items = mergedItems.items
if items.isEmpty {
@@ -445,12 +446,12 @@ func visibleItemIndexesNonReversed(_ listState: EndlessScrollView.Li
guard let newest, let oldest else {
return zero
}
- let size = ItemsModel.shared.reversedChatItems.count
+ let size = im.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()
+ // visible items mapped to their underlying data structure which is im.reversedChatItems.reversed()
return range
}
diff --git a/apps/ios/Shared/Views/Chat/ChatScrollHelpers.swift b/apps/ios/Shared/Views/Chat/ChatScrollHelpers.swift
index c1a1eec7d2..2fb1c3fb35 100644
--- a/apps/ios/Shared/Views/Chat/ChatScrollHelpers.swift
+++ b/apps/ios/Shared/Views/Chat/ChatScrollHelpers.swift
@@ -9,7 +9,7 @@
import SwiftUI
import SimpleXChat
-func loadLastItems(_ loadingMoreItems: Binding, loadingBottomItems: Binding, _ chat: Chat) async {
+func loadLastItems(_ loadingMoreItems: Binding, loadingBottomItems: Binding, _ chat: Chat, _ im: ItemsModel) async {
await MainActor.run {
loadingMoreItems.wrappedValue = true
loadingBottomItems.wrappedValue = true
@@ -22,27 +22,15 @@ func loadLastItems(_ loadingMoreItems: Binding, loadingBottomItems: Bindin
}
return
}
- await apiLoadMessages(chat.chatInfo.id, ChatPagination.last(count: 50), ItemsModel.shared.chatState)
+ await apiLoadMessages(chat.chatInfo.id, im, ChatPagination.last(count: 50))
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(
+ _ im: ItemsModel,
_ allowLoadMoreItems: Binding,
_ ignoreLoadingRequests: Binding,
_ listState: EndlessScrollView.ListState,
@@ -50,7 +38,7 @@ func preloadIfNeeded(
loadItems: @escaping (Bool, ChatPagination) async -> Bool,
loadLastItems: @escaping () async -> Void
) {
- let state = PreloadState.shared
+ let state = im.preloadState
guard !listState.isScrolling && !listState.isAnimatedScrolling,
!state.preloading,
listState.totalItemsCount > 0
@@ -63,7 +51,7 @@ func preloadIfNeeded(
Task {
defer { state.preloading = false }
var triedToLoad = true
- await preloadItems(mergedItems.boxedValue, allowLoadMore, listState, ignoreLoadingRequests) { pagination in
+ await preloadItems(im, mergedItems.boxedValue, allowLoadMore, listState, ignoreLoadingRequests) { pagination in
triedToLoad = await loadItems(false, pagination)
return triedToLoad
}
@@ -73,11 +61,11 @@ func preloadIfNeeded(
}
// 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 {
+ if listState.itemsCanCoverScreen && !im.lastItemsLoaded {
await loadLastItems()
}
}
- } else if listState.itemsCanCoverScreen && !ItemsModel.shared.lastItemsLoaded {
+ } else if listState.itemsCanCoverScreen && !im.lastItemsLoaded {
state.preloading = true
Task {
defer { state.preloading = false }
@@ -87,6 +75,7 @@ func preloadIfNeeded(
}
func preloadItems(
+ _ im: ItemsModel,
_ mergedItems: MergedItems,
_ allowLoadMoreItems: Bool,
_ listState: EndlessScrollView.ListState,
@@ -105,7 +94,7 @@ async {
let splits = mergedItems.splits
let lastVisibleIndex = listState.lastVisibleItemIndex
var lastIndexToLoadFrom: Int? = findLastIndexToLoadFromInSplits(firstVisibleIndex, lastVisibleIndex, remaining, splits)
- let items: [ChatItem] = ItemsModel.shared.reversedChatItems.reversed()
+ let items: [ChatItem] = im.reversedChatItems.reversed()
if splits.isEmpty && !items.isEmpty && lastVisibleIndex > mergedItems.items.count - remaining {
lastIndexToLoadFrom = items.count - 1
}
@@ -122,7 +111,7 @@ async {
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 {
+ if triedToLoad && sizeWas == im.reversedChatItems.count && firstItemIdWas == im.reversedChatItems.last?.id {
ignoreLoadingRequests.wrappedValue = loadFromItemId
return false
}
@@ -133,7 +122,7 @@ 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
+ let reversedItems: [ChatItem] = im.reversedChatItems
if let split, split.indexRangeInParentItems.lowerBound + remaining > firstVisibleIndex {
let index = split.indexRangeInReversed.lowerBound
if index >= 0 {
diff --git a/apps/ios/Shared/Views/Chat/ChatView.swift b/apps/ios/Shared/Views/Chat/ChatView.swift
index c136ebc01b..fa53045391 100644
--- a/apps/ios/Shared/Views/Chat/ChatView.swift
+++ b/apps/ios/Shared/Views/Chat/ChatView.swift
@@ -15,8 +15,7 @@ private let memberImageSize: CGFloat = 34
struct ChatView: View {
@EnvironmentObject var chatModel: ChatModel
- @ObservedObject var im = ItemsModel.shared
- @State var mergedItems: BoxedValue = BoxedValue(MergedItems.create(ItemsModel.shared.reversedChatItems, [], ItemsModel.shared.chatState))
+ @StateObject private var connectProgressManager = ConnectProgressManager.shared
@State var revealedItems: Set = Set()
@State var theme: AppTheme = buildTheme()
@Environment(\.dismiss) var dismiss
@@ -24,6 +23,10 @@ struct ChatView: View {
@Environment(\.presentationMode) var presentationMode
@Environment(\.scenePhase) var scenePhase
@State @ObservedObject var chat: Chat
+ @ObservedObject var im: ItemsModel
+ @State var mergedItems: BoxedValue
+ @State var floatingButtonModel: FloatingButtonModel
+ @Binding var scrollToItemId: ChatItem.ID?
@State private var showChatInfoSheet: Bool = false
@State private var showAddMembersSheet: Bool = false
@State private var composeState = ComposeState()
@@ -45,7 +48,7 @@ struct ChatView: View {
@State private var selectedMember: GMember? = nil
// opening GroupLinkView on link button (incognito)
@State private var showGroupLinkSheet: Bool = false
- @State private var groupLink: CreatedConnLink?
+ @State private var groupLink: GroupLink?
@State private var groupLinkMemberRole: GroupMemberRole = .member
@State private var forwardedChatItems: [ChatItem] = []
@State private var selectedChatItems: Set? = nil
@@ -55,12 +58,16 @@ struct ChatView: View {
@State private var allowLoadMoreItems: Bool = false
@State private var ignoreLoadingRequests: Int64? = nil
@State private var animatedScrollingInProgress: Bool = false
- @State private var floatingButtonModel: FloatingButtonModel = FloatingButtonModel()
+ @State private var showUserSupportChatSheet = false
+ @State private var showCommandsMenu = false
+ @State private var supportChatMemberInfoLinkActive = false
@State private var scrollView: EndlessScrollView = EndlessScrollView(frame: .zero)
@AppStorage(DEFAULT_TOOLBAR_MATERIAL) private var toolbarMaterial = ToolbarMaterial.defaultMaterial
+ let userSupportScopeInfo: GroupChatScopeInfo = .memberSupport(groupMember_: nil)
+
var body: some View {
if #available(iOS 16.0, *) {
viewBody
@@ -73,42 +80,74 @@ struct ChatView: View {
private var viewBody: some View {
let cInfo = chat.chatInfo
+ let memberSupportChat: (groupInfo: GroupInfo, member: GroupMember?)? =
+ if case let .group(groupInfo, .memberSupport(member)) = cInfo {
+ (groupInfo, member)
+ } else {
+ nil
+ }
+ let userMemberKnockingChat = memberSupportChat?.groupInfo.membership.memberPending == true
return ZStack {
let wallpaperImage = theme.wallpaper.type.image
let wallpaperType = theme.wallpaper.type
let backgroundColor = theme.wallpaper.background ?? wallpaperType.defaultBackgroundColor(theme.base, theme.colors.background)
let tintColor = theme.wallpaper.tint ?? wallpaperType.defaultTintColor(theme.base)
Color.clear.ignoresSafeArea(.all)
- .if(wallpaperImage != nil) { view in
+ .if(wallpaperImage != nil && im.secondaryIMFilter == nil) { view in
view.modifier(
ChatViewBackground(image: wallpaperImage!, imageType: wallpaperType, background: backgroundColor, tint: tintColor)
)
}
VStack(spacing: 0) {
ZStack(alignment: .bottomTrailing) {
- chatItemsList()
- if let groupInfo = chat.chatInfo.groupInfo, !composeState.message.isEmpty {
- GroupMentionsView(groupInfo: groupInfo, composeState: $composeState, selectedRange: $selectedRange, keyboardVisible: $keyboardVisible)
+ if userMemberKnockingChat {
+ ZStack(alignment: .top) {
+ chatItemsList()
+ userMemberKnockingTitleBar()
+ }
+ } else {
+ chatItemsList()
}
- FloatingButtons(theme: theme, scrollView: scrollView, chat: chat, loadingMoreItems: $loadingMoreItems, loadingTopItems: $loadingTopItems, requestedTopScroll: $requestedTopScroll, loadingBottomItems: $loadingBottomItems, requestedBottomScroll: $requestedBottomScroll, animatedScrollingInProgress: $animatedScrollingInProgress, listState: scrollView.listState, model: floatingButtonModel, reloadItems: {
- mergedItems.boxedValue = MergedItems.create(im.reversedChatItems, revealedItems, im.chatState)
+ if let groupInfo = chat.chatInfo.groupInfo, !composeState.message.isEmpty {
+ GroupMentionsView(im: im, groupInfo: groupInfo, composeState: $composeState, selectedRange: $selectedRange, keyboardVisible: $keyboardVisible)
+ }
+ if !chat.chatInfo.menuCommands.isEmpty {
+ CommandsMenuView(chat: chat, composeState: $composeState, selectedRange: $selectedRange, showCommandsMenu: $showCommandsMenu)
+ }
+ FloatingButtons(im: im, theme: theme, scrollView: scrollView, chat: chat, loadingMoreItems: $loadingMoreItems, loadingTopItems: $loadingTopItems, requestedTopScroll: $requestedTopScroll, loadingBottomItems: $loadingBottomItems, requestedBottomScroll: $requestedBottomScroll, animatedScrollingInProgress: $animatedScrollingInProgress, listState: scrollView.listState, model: floatingButtonModel, reloadItems: {
+ mergedItems.boxedValue = MergedItems.create(im, revealedItems)
scrollView.updateItems(mergedItems.boxedValue.items)
}
)
}
- connectingText()
+ if let connectInProgressText = connectProgressManager.showConnectProgress {
+ connectInProgressView(connectInProgressText)
+ }
+ if let connectingText {
+ Text(connectingText)
+ .font(.caption)
+ .foregroundColor(theme.colors.secondary)
+ .padding(.top)
+ }
if selectedChatItems == nil {
let reason = chat.chatInfo.userCantSendReason
+ let composeEnabled = (
+ chat.chatInfo.sendMsgEnabled ||
+ (chat.chatInfo.groupInfo?.nextConnectPrepared ?? false) || // allow to join prepared group without message
+ (chat.chatInfo.contact?.nextAcceptContactRequest ?? false) // allow to accept or reject contact request
+ )
ComposeView(
chat: chat,
+ im: im,
composeState: $composeState,
+ showCommandsMenu: $showCommandsMenu,
keyboardVisible: $keyboardVisible,
keyboardHiddenDate: $keyboardHiddenDate,
selectedRange: $selectedRange,
disabledText: reason?.composeLabel
)
- .disabled(!cInfo.sendMsgEnabled)
- .if(!cInfo.sendMsgEnabled) { v in
+ .disabled(!composeEnabled)
+ .if(!composeEnabled) { v in
v.disabled(true).onTapGesture {
AlertManager.shared.showAlertMsg(
title: "You can't send messages!",
@@ -118,7 +157,7 @@ struct ChatView: View {
}
} else {
SelectedItemsBottomToolbar(
- chatItems: ItemsModel.shared.reversedChatItems,
+ im: im,
selectedChatItems: $selectedChatItems,
chatInfo: chat.chatInfo,
deleteItems: { forAll in
@@ -129,7 +168,7 @@ struct ChatView: View {
showArchiveSelectedReports = true
},
moderateItems: {
- if case let .group(groupInfo) = chat.chatInfo {
+ if case let .group(groupInfo, _) = chat.chatInfo {
showModerateSelectedMessagesAlert(groupInfo)
}
},
@@ -140,6 +179,28 @@ struct ChatView: View {
if im.showLoadingProgress == chat.id {
ProgressView().scaleEffect(2)
}
+ if case let .group(groupInfo, _) = chat.chatInfo,
+ case let .groupChatScopeContext(groupScopeInfo) = im.secondaryIMFilter,
+ case let .memberSupport(groupMember_) = groupScopeInfo,
+ let groupMember = groupMember_ {
+ NavigationLink(isActive: $supportChatMemberInfoLinkActive) {
+ GroupMemberInfoView(
+ groupInfo: groupInfo,
+ chat: chat,
+ groupMember: GMember(groupMember),
+ scrollToItemId: $scrollToItemId,
+ openedFromSupportChat: true
+ )
+ .navigationBarHidden(false)
+ .modifier(BackButton(disabled: Binding.constant(false)) {
+ supportChatMemberInfoLinkActive = false
+ })
+ } label: {
+ EmptyView()
+ }
+ .frame(width: 1, height: 1)
+ .hidden()
+ }
}
.safeAreaInset(edge: .top) {
VStack(spacing: .zero) {
@@ -148,7 +209,11 @@ struct ChatView: View {
}
.background(ToolbarMaterial.material(toolbarMaterial))
}
- .navigationTitle(cInfo.chatViewName)
+ .navigationTitle(
+ memberSupportChat == nil
+ ? cInfo.chatViewName
+ : memberSupportChat?.member?.chatViewName ?? NSLocalizedString("Chat with admins", comment: "chat toolbar")
+ )
.background(theme.colors.background)
.navigationBarTitleDisplayMode(.inline)
.environmentObject(theme)
@@ -169,35 +234,34 @@ struct ChatView: View {
.confirmationDialog(selectedChatItems?.count == 1 ? "Archive report?" : "Archive \((selectedChatItems?.count ?? 0)) reports?", isPresented: $showArchiveSelectedReports, titleVisibility: .visible) {
Button("For me", role: .destructive) {
if let selected = selectedChatItems {
- archiveReports(chat.chatInfo, selected.sorted(), false, deletedSelectedMessages)
+ archiveReports(chat, selected.sorted(), false, deletedSelectedMessages)
}
}
- if case let ChatInfo.group(groupInfo) = chat.chatInfo, groupInfo.membership.memberActive {
+ if case let ChatInfo.group(groupInfo, _) = chat.chatInfo, groupInfo.membership.memberActive {
Button("For all moderators", role: .destructive) {
if let selected = selectedChatItems {
- archiveReports(chat.chatInfo, selected.sorted(), true, deletedSelectedMessages)
+ archiveReports(chat, selected.sorted(), true, deletedSelectedMessages)
}
}
}
}
- .appSheet(item: $selectedMember) { member in
- Group {
- if case let .group(groupInfo) = chat.chatInfo {
- GroupMemberInfoView(
- groupInfo: groupInfo,
- chat: chat,
- groupMember: member,
- navigation: true
- )
- }
+ .appSheet(item: $selectedMember, onDismiss: {
+ chatModel.secondaryIM = nil
+ }) { member in
+ if case let .group(groupInfo, _) = chat.chatInfo {
+ GroupMemberInfoView(
+ groupInfo: groupInfo,
+ chat: chat,
+ groupMember: member,
+ scrollToItemId: $scrollToItemId,
+ navigation: true
+ )
}
}
// it should be presented on top level in order to prevent a bug in SwiftUI on iOS 16 related to .focused() modifier in AddGroupMembersView's search field
.appSheet(isPresented: $showAddMembersSheet) {
- Group {
- if case let .group(groupInfo) = cInfo {
- AddGroupMembersView(chat: chat, groupInfo: groupInfo)
- }
+ if case let .group(groupInfo, _) = cInfo {
+ AddGroupMembersView(chat: chat, groupInfo: groupInfo)
}
}
.sheet(isPresented: Binding(
@@ -216,7 +280,23 @@ struct ChatView: View {
ChatItemForwardingView(chatItems: forwardedChatItems, fromChatInfo: chat.chatInfo, composeState: $composeState)
}
}
+ .appSheet(
+ isPresented: $showUserSupportChatSheet,
+ onDismiss: {
+ if chat.chatInfo.groupInfo?.membership.memberPending ?? false {
+ chatModel.chatId = nil
+ }
+ }
+ ) {
+ if let groupInfo = cInfo.groupInfo {
+ SecondaryChatView(
+ chat: Chat(chatInfo: .group(groupInfo: groupInfo, groupChatScope: userSupportScopeInfo), chatItems: [], chatStats: ChatStats()),
+ scrollToItemId: $scrollToItemId
+ )
+ }
+ }
.onAppear {
+ ConnectProgressManager.shared.cancelConnectProgress()
scrollView.listState.onUpdateListener = onChatItemsUpdated
selectedChatItems = nil
revealedItems = Set()
@@ -231,8 +311,24 @@ struct ChatView: View {
}
}
}
+ // if this is the main chat of the group with the pending member (knocking)
+ if case let .group(groupInfo, nil) = chat.chatInfo,
+ groupInfo.membership.memberPending {
+ ItemsModel.loadSecondaryChat(chat.id, chatFilter: .groupChatScopeContext(groupScopeInfo: userSupportScopeInfo)) {
+ showUserSupportChatSheet = true
+ chatModel.secondaryPendingInviteeChatOpened = true
+ }
+ }
+ }
+ .onChange(of: chatModel.secondaryPendingInviteeChatOpened) { secondaryChatOpened in
+ if secondaryChatOpened {
+ ItemsModel.loadSecondaryChat(chat.id, chatFilter: .groupChatScopeContext(groupScopeInfo: userSupportScopeInfo)) {
+ showUserSupportChatSheet = true
+ }
+ }
}
.onChange(of: chatModel.chatId) { cId in
+ ConnectProgressManager.shared.cancelConnectProgress()
showChatInfoSheet = false
selectedChatItems = nil
revealedItems = Set()
@@ -245,7 +341,7 @@ struct ChatView: View {
initChatView()
theme = buildTheme()
closeSearch()
- mergedItems.boxedValue = MergedItems.create(im.reversedChatItems, revealedItems, im.chatState)
+ mergedItems.boxedValue = MergedItems.create(im, revealedItems)
scrollView.updateItems(mergedItems.boxedValue.items)
if let openAround = chatModel.openAroundItemId, let index = mergedItems.boxedValue.indexInParentItems[openAround] {
@@ -262,10 +358,18 @@ struct ChatView: View {
dismiss()
}
}
+ .onChange(of: chatModel.secondaryPendingInviteeChatOpened) { opened in
+ if im.secondaryIMFilter != nil && !opened {
+ Task {
+ try? await Task.sleep(nanoseconds: 650_000000)
+ dismiss()
+ }
+ }
+ }
.onChange(of: chatModel.openAroundItemId) { openAround in
if let openAround {
closeSearch()
- mergedItems.boxedValue = MergedItems.create(im.reversedChatItems, revealedItems, im.chatState)
+ mergedItems.boxedValue = MergedItems.create(im, revealedItems)
scrollView.updateItems(mergedItems.boxedValue.items)
chatModel.openAroundItemId = nil
@@ -283,14 +387,14 @@ struct ChatView: View {
}
}
.onDisappear {
+ ConnectProgressManager.shared.cancelConnectProgress()
VideoPlayerView.players.removeAll()
stopAudioPlayer()
if chatModel.chatId == cInfo.id && !presentationMode.wrappedValue.isPresented {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.35) {
if chatModel.chatId == nil {
- chatModel.chatItemStatuses = [:]
- ItemsModel.shared.reversedChatItems = []
- ItemsModel.shared.chatState.clear()
+ im.reversedChatItems = []
+ im.chatState.clear()
chatModel.groupMembers = []
chatModel.groupMembersIndexes.removeAll()
chatModel.membersLoaded = false
@@ -303,124 +407,253 @@ struct ChatView: View {
}
.toolbar {
ToolbarItem(placement: .principal) {
- if selectedChatItems != nil {
- SelectedItemsTopToolbar(selectedChatItems: $selectedChatItems)
- } else if case let .direct(contact) = cInfo {
- Button {
- Task {
- showChatInfoSheet = true
- }
- } label: {
- ChatInfoToolbar(chat: chat)
- }
- .appSheet(isPresented: $showChatInfoSheet, onDismiss: { theme = buildTheme() }) {
- ChatInfoView(
- chat: chat,
- contact: contact,
- localAlias: chat.chatInfo.localAlias,
- featuresAllowed: contactUserPrefsToFeaturesAllowed(contact.mergedPreferences),
- currentFeaturesAllowed: contactUserPrefsToFeaturesAllowed(contact.mergedPreferences),
- onSearch: { focusSearch() }
- )
- }
- } else if case let .group(groupInfo) = cInfo {
- Button {
- Task { await chatModel.loadGroupMembers(groupInfo) { showChatInfoSheet = true } }
- } label: {
- ChatInfoToolbar(chat: chat)
- .tint(theme.colors.primary)
- }
- .appSheet(isPresented: $showChatInfoSheet, onDismiss: { theme = buildTheme() }) {
- GroupChatInfoView(
- chat: chat,
- groupInfo: Binding(
- get: { groupInfo },
- set: { gInfo in
- chat.chatInfo = .group(groupInfo: gInfo)
- chat.created = Date.now
- }
- ),
- onSearch: { focusSearch() },
- localAlias: groupInfo.localAlias
- )
- }
- } else if case .local = cInfo {
- ChatInfoToolbar(chat: chat)
+ if im.secondaryIMFilter == nil {
+ primaryPrincipalToolbarContent()
+ } else if !userMemberKnockingChat { // no toolbar while knocking chat, it's unstable on sheet
+ secondaryPrincipalToolbarContent()
}
}
ToolbarItem(placement: .navigationBarTrailing) {
- if selectedChatItems != nil {
- Button {
- withAnimation {
- selectedChatItems = nil
- }
- } label: {
- Text("Cancel")
- }
- } else {
- switch cInfo {
- case let .direct(contact):
- HStack {
- let callsPrefEnabled = contact.mergedPreferences.calls.enabled.forUser
- if callsPrefEnabled {
- if chatModel.activeCall == nil {
- callButton(contact, .audio, imageName: "phone")
- .disabled(!contact.ready || !contact.active)
- } else if let call = chatModel.activeCall, call.contact.id == cInfo.id {
- endCallButton(call)
- }
- }
- Menu {
- if callsPrefEnabled && chatModel.activeCall == nil {
- Button {
- CallController.shared.startCall(contact, .video)
- } label: {
- Label("Video call", systemImage: "video")
- }
- .disabled(!contact.ready || !contact.active)
- }
- searchButton()
- ToggleNtfsButton(chat: chat)
- .disabled(!contact.ready || !contact.active)
- } label: {
- Image(systemName: "ellipsis")
- }
- }
- case let .group(groupInfo):
- HStack {
- if groupInfo.canAddMembers {
- if (chat.chatInfo.incognito) {
- groupLinkButton()
- .appSheet(isPresented: $showGroupLinkSheet) {
- GroupLinkView(
- groupId: groupInfo.groupId,
- groupLink: $groupLink,
- groupLinkMemberRole: $groupLinkMemberRole,
- showTitle: true,
- creatingGroup: false
- )
- }
- } else {
- addMembersButton()
- }
- }
- Menu {
- searchButton()
- ToggleNtfsButton(chat: chat)
- } label: {
- Image(systemName: "ellipsis")
- }
- }
- case .local:
- searchButton()
- default:
- EmptyView()
+ if im.secondaryIMFilter == nil {
+ primaryTrailingToolbarContent()
+ } else if !userMemberKnockingChat {
+ secondaryTrailingToolbarContent()
+ }
+ }
+ }
+ .if(im.secondaryIMFilter == nil) { v in
+ v.onChange(of: scrollToItemId) { itemId in
+ if let itemId = itemId {
+ dismissAllSheets(animated: false) {
+ scrollToItem(itemId)
+ scrollToItemId = nil
}
}
}
}
}
+ private func connectInProgressView(_ s: String) -> some View {
+ VStack(spacing: 0) {
+ Divider()
+
+ HStack(spacing: 12) {
+ ProgressView()
+ Text(s)
+
+ Spacer()
+
+ Button {
+ ConnectProgressManager.shared.cancelConnectProgress()
+ } label: {
+ Image(systemName: "multiply")
+ }
+ .tint(theme.colors.primary)
+ }
+ .padding(12)
+ .frame(minHeight: 54)
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .background(ToolbarMaterial.material(toolbarMaterial))
+ }
+ }
+
+ @inline(__always)
+ @ViewBuilder private func primaryPrincipalToolbarContent() -> some View {
+ let cInfo = chat.chatInfo
+ if selectedChatItems != nil {
+ SelectedItemsTopToolbar(selectedChatItems: $selectedChatItems)
+ } else if case let .direct(contact) = cInfo {
+ Button {
+ Task {
+ showChatInfoSheet = true
+ }
+ } label: {
+ ChatInfoToolbar(chat: chat)
+ }
+ .appSheet(isPresented: $showChatInfoSheet, onDismiss: { theme = buildTheme() }) {
+ ChatInfoView(
+ chat: chat,
+ contact: contact,
+ localAlias: chat.chatInfo.localAlias,
+ featuresAllowed: contactUserPrefsToFeaturesAllowed(contact.mergedPreferences),
+ currentFeaturesAllowed: contactUserPrefsToFeaturesAllowed(contact.mergedPreferences),
+ onSearch: { focusSearch() }
+ )
+ }
+ } else if case let .group(groupInfo, _) = cInfo {
+ Button {
+ Task { await chatModel.loadGroupMembers(groupInfo) { showChatInfoSheet = true } }
+ } label: {
+ ChatInfoToolbar(chat: chat)
+ .tint(theme.colors.primary)
+ }
+ .appSheet(isPresented: $showChatInfoSheet, onDismiss: {
+ chatModel.secondaryIM = nil
+ theme = buildTheme()
+ }) {
+ GroupChatInfoView(
+ chat: chat,
+ groupInfo: Binding(
+ get: { groupInfo },
+ set: { gInfo in
+ chat.chatInfo = .group(groupInfo: gInfo, groupChatScope: nil)
+ chat.created = Date.now
+ }
+ ),
+ scrollToItemId: $scrollToItemId,
+ onSearch: { focusSearch() },
+ localAlias: groupInfo.localAlias
+ )
+ }
+ } else if case .local = cInfo {
+ ChatInfoToolbar(chat: chat)
+ }
+ }
+
+ @inline(__always)
+ @ViewBuilder private func primaryTrailingToolbarContent() -> some View {
+ let cInfo = chat.chatInfo
+ if selectedChatItems != nil {
+ Button {
+ withAnimation {
+ selectedChatItems = nil
+ }
+ } label: {
+ Text("Cancel")
+ }
+ } else {
+ switch cInfo {
+ case let .direct(contact):
+ HStack {
+ let callsPrefEnabled = contact.mergedPreferences.calls.enabled.forUser
+ if callsPrefEnabled {
+ if chatModel.activeCall == nil {
+ callButton(contact, .audio, imageName: "phone")
+ .disabled(!contact.ready || !contact.active)
+ } else if let call = chatModel.activeCall, call.contact.id == cInfo.id {
+ endCallButton(call)
+ }
+ }
+ Menu {
+ if callsPrefEnabled && chatModel.activeCall == nil {
+ Button {
+ CallController.shared.startCall(contact, .video)
+ } label: {
+ Label("Video call", systemImage: "video")
+ }
+ .disabled(!contact.ready || !contact.active)
+ }
+ searchButton()
+ ToggleNtfsButton(chat: chat)
+ .disabled(!contact.ready || !contact.active)
+ } label: {
+ Image(systemName: "ellipsis")
+ }
+ }
+ case let .group(groupInfo, _):
+ HStack {
+ if groupInfo.canAddMembers {
+ if (chat.chatInfo.incognito) {
+ groupLinkButton()
+ .appSheet(isPresented: $showGroupLinkSheet) {
+ GroupLinkView(
+ groupId: groupInfo.groupId,
+ groupLink: $groupLink,
+ groupLinkMemberRole: $groupLinkMemberRole,
+ showTitle: true,
+ creatingGroup: false
+ )
+ }
+ } else {
+ addMembersButton()
+ }
+ }
+ Menu {
+ searchButton()
+ ToggleNtfsButton(chat: chat)
+ } label: {
+ Image(systemName: "ellipsis")
+ }
+ }
+ case .local:
+ searchButton()
+ default:
+ EmptyView()
+ }
+ }
+ }
+
+ @inline(__always)
+ @ViewBuilder private func secondaryPrincipalToolbarContent() -> some View {
+ if selectedChatItems != nil {
+ SelectedItemsTopToolbar(selectedChatItems: $selectedChatItems)
+ } else {
+ switch im.secondaryIMFilter {
+ case let .groupChatScopeContext(groupScopeInfo):
+ switch groupScopeInfo {
+ case let .memberSupport(groupMember_):
+ if let groupMember = groupMember_ {
+ Button {
+ supportChatMemberInfoLinkActive = true
+ } label: {
+ MemberSupportChatToolbar(groupMember: groupMember)
+ }
+ } else {
+ textChatToolbar("Chat with admins")
+ }
+ case .reports:
+ textChatToolbar("Member reports")
+ }
+ case let .msgContentTagContext(contentTag):
+ switch contentTag {
+ case .report:
+ textChatToolbar("Member reports")
+ default:
+ EmptyView()
+ }
+ case .none:
+ EmptyView()
+ }
+ }
+ }
+
+ @inline(__always)
+ @ViewBuilder private func secondaryTrailingToolbarContent() -> some View {
+ if selectedChatItems != nil {
+ Button {
+ withAnimation {
+ selectedChatItems = nil
+ }
+ } label: {
+ Text("Cancel")
+ }
+ } else {
+ searchButton()
+ }
+ }
+
+ @inline(__always)
+ private func userMemberKnockingTitleBar() -> some View {
+ VStack(spacing: 0) {
+ Text("Chat with admins")
+ .font(.headline)
+ .foregroundColor(theme.colors.onBackground)
+ .padding(.top, 8)
+ .padding(.bottom, 14)
+ .frame(maxWidth: .infinity)
+ .background(ToolbarMaterial.material(toolbarMaterial))
+ Divider()
+ }
+ }
+
+ func textChatToolbar(_ text: LocalizedStringKey) -> some View {
+ Text(text)
+ .font(.headline)
+ .lineLimit(1)
+ .foregroundColor(theme.colors.onBackground)
+ .frame(width: 220)
+ }
+
private func initChatView() {
let cInfo = chat.chatInfo
// This check prevents the call to apiContactInfo after the app is suspended, and the database is closed.
@@ -451,19 +684,19 @@ struct ChatView: View {
floatingButtonModel.updateOnListChange(scrollView.listState)
}
- private func scrollToItemId(_ itemId: ChatItem.ID) {
+ private func scrollToItem(_ itemId: ChatItem.ID) {
Task {
do {
var index = mergedItems.boxedValue.indexInParentItems[itemId]
if index == nil {
let pagination = ChatPagination.around(chatItemId: itemId, count: ChatPagination.PRELOAD_COUNT * 2)
- let oldSize = ItemsModel.shared.reversedChatItems.count
+ let oldSize = im.reversedChatItems.count
let triedToLoad = await loadChatItems(chat, pagination)
if !triedToLoad {
return
}
var repeatsLeft = 50
- while oldSize == ItemsModel.shared.reversedChatItems.count && repeatsLeft > 0 {
+ while oldSize == im.reversedChatItems.count && repeatsLeft > 0 {
try await Task.sleep(nanoseconds: 20_000000)
repeatsLeft -= 1
}
@@ -473,7 +706,7 @@ struct ChatView: View {
closeKeyboardAndRun {
Task {
await MainActor.run { animatedScrollingInProgress = true }
- await scrollView.scrollToItemAnimated(min(ItemsModel.shared.reversedChatItems.count - 1, index))
+ await scrollView.scrollToItemAnimated(min(im.reversedChatItems.count - 1, index))
await MainActor.run { animatedScrollingInProgress = false }
}
}
@@ -539,31 +772,48 @@ struct ChatView: View {
case let .single(item, _, _): item.item
case let .grouped(items, _, _, _, _, _, _, _): items.boxedValue.last!.item
}
- let voiceNoFrame = voiceWithoutFrame(ci)
- let maxWidth = cInfo.chatType == .group
- ? voiceNoFrame
- ? (g.size.width - 28) - 42
- : (g.size.width - 28) * 0.84 - 42
- : voiceNoFrame
- ? (g.size.width - 32)
- : (g.size.width - 32) * 0.84
- return ChatItemWithMenu(
- chat: $chat,
- index: index,
- isLastItem: index == mergedItems.boxedValue.items.count - 1,
- chatItem: ci,
- scrollToItemId: scrollToItemId,
- merged: mergedItem,
- maxWidth: maxWidth,
- composeState: $composeState,
- selectedMember: $selectedMember,
- showChatInfoSheet: $showChatInfoSheet,
- revealedItems: $revealedItems,
- selectedChatItems: $selectedChatItems,
- forwardedChatItems: $forwardedChatItems,
- searchText: $searchText,
- closeKeyboardAndRun: closeKeyboardAndRun
- )
+ return Group {
+ if case .chatBanner = ci.content {
+ VStack {
+ ChatBannerView(chat: $chat)
+ .padding(.bottom, 90)
+ .padding(.top, 8)
+
+ let listItem = mergedItem.newest()
+ if let prevItem = listItem.prevItem {
+ DateSeparator(date: prevItem.meta.itemTs).padding(8)
+ }
+ }
+ } else {
+ let voiceNoFrame = voiceWithoutFrame(ci)
+ let maxWidth = cInfo.chatType == .group
+ ? voiceNoFrame
+ ? (g.size.width - 28) - 42
+ : (g.size.width - 28) * 0.84 - 42
+ : voiceNoFrame
+ ? (g.size.width - 32)
+ : (g.size.width - 32) * 0.84
+ ChatItemWithMenu(
+ im: im,
+ chat: $chat,
+ index: index,
+ isLastItem: index == mergedItems.boxedValue.items.count - 1,
+ chatItem: ci,
+ scrollToItem: scrollToItem,
+ scrollToItemId: $scrollToItemId,
+ merged: mergedItem,
+ maxWidth: maxWidth,
+ composeState: $composeState,
+ selectedMember: $selectedMember,
+ showChatInfoSheet: $showChatInfoSheet,
+ revealedItems: $revealedItems,
+ selectedChatItems: $selectedChatItems,
+ forwardedChatItems: $forwardedChatItems,
+ searchText: $searchText,
+ closeKeyboardAndRun: closeKeyboardAndRun
+ )
+ }
+ }
// crashes on Cell size calculation without this line
.environmentObject(ChatModel.shared)
.environmentObject(theme) // crashes without this line when scrolling to the first unread in EndlessScrollVIew
@@ -580,7 +830,7 @@ struct ChatView: View {
}
}
.onChange(of: im.reversedChatItems) { items in
- mergedItems.boxedValue = MergedItems.create(items, revealedItems, im.chatState)
+ mergedItems.boxedValue = MergedItems.create(im, revealedItems)
scrollView.updateItems(mergedItems.boxedValue.items)
if im.itemAdded {
im.itemAdded = false
@@ -592,7 +842,7 @@ struct ChatView: View {
}
}
.onChange(of: revealedItems) { revealed in
- mergedItems.boxedValue = MergedItems.create(im.reversedChatItems, revealed, im.chatState)
+ mergedItems.boxedValue = MergedItems.create(im, revealed)
scrollView.updateItems(mergedItems.boxedValue.items)
}
.onChange(of: chat.id) { _ in
@@ -611,23 +861,164 @@ struct ChatView: View {
}
}
- @ViewBuilder private func connectingText() -> some View {
- if case let .direct(contact) = chat.chatInfo,
- !contact.sndReady,
- contact.active,
- !contact.nextSendGrpInv {
- Text("connecting…")
- .font(.caption)
- .foregroundColor(theme.colors.secondary)
- .padding(.top)
- } else {
- EmptyView()
+ struct ChatBannerView: View {
+ @EnvironmentObject var theme: AppTheme
+ @AppStorage(DEFAULT_CHAT_ITEM_ROUNDNESS) private var roundness = defaultChatItemRoundness
+ @Binding @ObservedObject var chat: Chat
+ @State private var showSecrets: Set = []
+
+ var body: some View {
+ let v = VStack(spacing: 8) {
+ ChatInfoImage(chat: chat, size: alertProfileImageSize)
+
+ Text(chat.chatInfo.displayName)
+ .font(.title3)
+ .multilineTextAlignment(.center)
+ .lineLimit(2)
+ .fixedSize(horizontal: false, vertical: true)
+ .frame(maxWidth: 240)
+
+ let fullName = chat.chatInfo.fullName.trimmingCharacters(in: .whitespacesAndNewlines)
+ if fullName != "" && fullName != chat.chatInfo.displayName && fullName != chat.chatInfo.displayName.trimmingCharacters(in: .whitespacesAndNewlines) {
+ Text(chat.chatInfo.fullName)
+ .font(.subheadline)
+ .multilineTextAlignment(.center)
+ .lineLimit(3)
+ .fixedSize(horizontal: false, vertical: true)
+ .frame(maxWidth: 260)
+ }
+
+ if let shortDescr = chat.chatInfo.shortDescr {
+ let r = markdownText(shortDescr, textStyle: .subheadline, showSecrets: showSecrets, backgroundColor: theme.colors.background)
+ msgTextResultView(r, Text(AttributedString(r.string)), showSecrets: $showSecrets, centered: true, smallFont: true)
+ .multilineTextAlignment(.center)
+ .lineLimit(4)
+ .fixedSize(horizontal: false, vertical: true)
+ .padding(.horizontal)
+ }
+
+ if let chatContext {
+ Text(chatContext)
+ .font(.callout)
+ .foregroundColor(theme.colors.secondary)
+ .padding(.top, 8)
+ }
+ }
+ .frame(maxWidth: .infinity)
+ .padding()
+ .background(theme.appColors.receivedMessage)
+ .clipShape(RoundedRectangle(cornerRadius: msgRectMaxRadius * roundness))
+ if let (label, connLink) = chatAddress() {
+ v.contextMenu {
+ Button {
+ let shareItems: [Any] = [connLink]
+ showShareSheet(items: shareItems)
+ } label: {
+ Label(label, systemImage: "square.and.arrow.up")
+ }
+ }
+ .padding(.horizontal)
+ } else {
+ v.padding(.horizontal)
+ }
+
+ }
+
+ func chatAddress() -> (label: LocalizedStringKey, connLink: String)? {
+ switch chat.chatInfo {
+ case let .direct(contact):
+ if !contact.nextConnectPrepared && !contact.nextAcceptContactRequest {
+ let connLink: String? = if let pct = contact.preparedContact, case .con = pct.uiConnLinkType {
+ pct.connLinkToConnect.simplexChatUri()
+ } else {
+ contact.profile.contactLink
+ }
+ if let connLink {
+ return ("SimpleX address", connLink)
+ }
+ }
+ case let .group(groupInfo, _):
+ if !groupInfo.nextConnectPrepared {
+ if let pg = groupInfo.preparedGroup {
+ let connLink = pg.connLinkToConnect.simplexChatUri()
+ switch groupInfo.businessChat?.chatType {
+ case .none: return ("Group link", connLink)
+ case .business: return ("Business address", connLink)
+ default: ()
+ }
+ }
+ }
+ default: ()
+ }
+ return nil
+ }
+
+ var chatContext: LocalizedStringKey? {
+ switch chat.chatInfo {
+ case let .direct(contact):
+ if contact.nextConnectPrepared, let linkType = contact.preparedContact?.uiConnLinkType {
+ switch linkType {
+ case .inv:
+ "Tap Connect to chat"
+ case .con:
+ contact.isBot ? "Tap Connect to use bot" : "Tap Connect to send request"
+ }
+ } else if contact.nextAcceptContactRequest {
+ "Accept contact request"
+ } else if case .bot = contact.profile.peerType {
+ "Bot"
+ } else {
+ "Your contact"
+ }
+ case let .group(groupInfo, _):
+ switch groupInfo.businessChat?.chatType {
+ case .none:
+ if groupInfo.nextConnectPrepared {
+ "Tap Join group"
+ } else {
+ switch (groupInfo.membership.memberStatus) {
+ case .memInvited: "Join group"
+ case .memCreator: "Your group"
+ default: "Group"
+ }
+ }
+ case .business:
+ if groupInfo.nextConnectPrepared {
+ "Tap Connect to chat"
+ } else {
+ "Business connection"
+ }
+ case .customer:
+ "Your business contact"
+ }
+ default: nil
+ }
+ }
+ }
+
+ private var connectingText: LocalizedStringKey? {
+ switch (chat.chatInfo) {
+ case let .direct(contact):
+ if !contact.sndReady && contact.active && !contact.sendMsgToConnect && !contact.nextAcceptContactRequest {
+ (contact.preparedContact?.uiConnLinkType == .con && !contact.isBot) || contact.contactGroupMemberId != nil
+ ? "contact should accept…"
+ : "connecting…"
+ } else {
+ nil
+ }
+ case let .group(groupInfo, _):
+ switch (groupInfo.membership.memberStatus) {
+ case .memUnknown: groupInfo.preparedGroup?.connLinkStartedConnection == true ? "connecting…" : nil
+ case .memAccepted: "connecting…"
+ default: nil
+ }
+ default: nil
}
}
private func updateWithInitiallyLoadedItems() {
if mergedItems.boxedValue.items.isEmpty {
- mergedItems.boxedValue = MergedItems.create(im.reversedChatItems, revealedItems, ItemsModel.shared.chatState)
+ mergedItems.boxedValue = MergedItems.create(im, revealedItems)
}
let unreadIndex = mergedItems.boxedValue.items.lastIndex(where: { $0.hasUnread() })
let unreadItemId: Int64? = if let unreadIndex { mergedItems.boxedValue.items[unreadIndex].newest().item.id } else { nil }
@@ -647,8 +1038,8 @@ struct ChatView: View {
private func searchTextChanged(_ s: String) {
Task {
- await loadChat(chat: chat, search: s)
- mergedItems.boxedValue = MergedItems.create(im.reversedChatItems, revealedItems, im.chatState)
+ await loadChat(chat: chat, im: im, search: s)
+ mergedItems.boxedValue = MergedItems.create(im, revealedItems)
await MainActor.run {
scrollView.updateItems(mergedItems.boxedValue.items)
}
@@ -663,79 +1054,8 @@ struct ChatView: View {
}
}
- class FloatingButtonModel: ObservableObject {
- @Published var unreadAbove: Int = 0
- @Published var unreadBelow: Int = 0
- @Published var isNearBottom: Bool = true
- @Published var date: Date? = nil
- @Published var isDateVisible: Bool = false
- var hideDateWorkItem: DispatchWorkItem? = nil
-
- func updateOnListChange(_ listState: EndlessScrollView.ListState) {
- let lastVisibleItem = oldestPartiallyVisibleListItemInListStateOrNull(listState)
- let unreadBelow = if let lastVisibleItem {
- max(0, ItemsModel.shared.chatState.unreadTotal - lastVisibleItem.unreadBefore)
- } else {
- 0
- }
- let unreadAbove = ItemsModel.shared.chatState.unreadTotal - unreadBelow
- let date: Date? =
- if let lastVisible = listState.visibleItems.last {
- Calendar.current.startOfDay(for: lastVisible.item.oldest().item.meta.itemTs)
- } else {
- nil
- }
-
- // set the counters and date indicator
- DispatchQueue.main.async { [weak self] in
- guard let it = self else { return }
- it.setDate(visibility: true)
- it.unreadAbove = unreadAbove
- it.unreadBelow = unreadBelow
- it.date = date
- }
-
- // set floating button indication mode
- let nearBottom = listState.firstVisibleItemIndex < 1
- if nearBottom != self.isNearBottom {
- DispatchQueue.main.asyncAfter(deadline: .now() + 0.35) { [weak self] in
- self?.isNearBottom = nearBottom
- }
- }
-
- // hide Date indicator after 1 second of no scrolling
- hideDateWorkItem?.cancel()
- let workItem = DispatchWorkItem { [weak self] in
- guard let it = self else { return }
- it.setDate(visibility: false)
- it.hideDateWorkItem = nil
- }
- DispatchQueue.main.async { [weak self] in
- self?.hideDateWorkItem = workItem
- DispatchQueue.main.asyncAfter(deadline: .now() + 1, execute: workItem)
- }
- }
-
- func resetDate() {
- date = nil
- isDateVisible = false
- }
-
- private func setDate(visibility isVisible: Bool) {
- if isVisible {
- if !isNearBottom,
- !isDateVisible,
- let date, !Calendar.current.isDateInToday(date) {
- withAnimation { self.isDateVisible = true }
- }
- } else if isDateVisible {
- withAnimation { self.isDateVisible = false }
- }
- }
-
- }
-
private struct FloatingButtons: View {
+ @ObservedObject var im: ItemsModel
let theme: AppTheme
let scrollView: EndlessScrollView
let chat: Chat
@@ -751,7 +1071,7 @@ struct ChatView: View {
var body: some View {
ZStack(alignment: .top) {
- if let date = model.date {
+ if let date = model.date, date.timeIntervalSince1970 > 0 {
DateSeparator(date: date)
.padding(.vertical, 4).padding(.horizontal, 8)
.background(.thinMaterial)
@@ -780,7 +1100,7 @@ struct ChatView: View {
.contextMenu {
Button {
Task {
- await markChatRead(chat)
+ await markChatRead(im, chat)
}
} label: {
Label("Mark read", systemImage: "checkmark")
@@ -805,7 +1125,7 @@ struct ChatView: View {
}
}
.onTapGesture {
- if loadingBottomItems || !ItemsModel.shared.lastItemsLoaded {
+ if loadingBottomItems || !im.lastItemsLoaded {
requestedTopScroll = false
requestedBottomScroll = true
} else {
@@ -825,7 +1145,7 @@ struct ChatView: View {
}
}
.onChange(of: loadingBottomItems) { loading in
- if !loading && requestedBottomScroll && ItemsModel.shared.lastItemsLoaded {
+ if !loading && requestedBottomScroll && im.lastItemsLoaded {
requestedBottomScroll = false
scrollToBottom()
}
@@ -835,9 +1155,9 @@ struct ChatView: View {
private func scrollToTopUnread() {
Task {
- if !ItemsModel.shared.chatState.splits.isEmpty {
+ if !im.chatState.splits.isEmpty {
await MainActor.run { loadingMoreItems = true }
- await loadChat(chatId: chat.id, openAroundItemId: nil, clearItems: false)
+ await loadChat(chatId: chat.id, im: im, openAroundItemId: nil, clearItems: false)
await MainActor.run { reloadItems() }
if let index = listState.items.lastIndex(where: { $0.hasUnread() }) {
await MainActor.run { animatedScrollingInProgress = true }
@@ -947,7 +1267,7 @@ struct ChatView: View {
private func addMembersButton() -> some View {
Button {
- if case let .group(gInfo) = chat.chatInfo {
+ if case let .group(gInfo, _) = chat.chatInfo {
Task { await chatModel.loadGroupMembers(gInfo) { showAddMembersSheet = true } }
}
} label: {
@@ -957,11 +1277,12 @@ struct ChatView: View {
private func groupLinkButton() -> some View {
Button {
- if case let .group(gInfo) = chat.chatInfo {
+ if case let .group(gInfo, _) = chat.chatInfo {
Task {
do {
- if let link = try apiGetGroupLink(gInfo.groupId) {
- (groupLink, groupLinkMemberRole) = link
+ if let gLink = try apiGetGroupLink(gInfo.groupId) {
+ groupLink = gLink
+ groupLinkMemberRole = gLink.acceptMemberRole
}
} catch let error {
logger.error("ChatView apiGetGroupLink: \(responseError(error))")
@@ -1008,6 +1329,7 @@ struct ChatView: View {
let (validItems, confirmation) = try await apiPlanForwardChatItems(
type: chat.chatInfo.chatType,
id: chat.chatInfo.apiId,
+ scope: chat.chatInfo.groupChatScope(),
itemIds: Array(selectedChatItems)
)
if let confirmation {
@@ -1097,7 +1419,6 @@ struct ChatView: View {
}
func openForwardingSheet(_ items: [Int64]) async {
- let im = ItemsModel.shared
var items = Set(items)
var fci = [ChatItem]()
for reversedChatItem in im.reversedChatItems {
@@ -1136,11 +1457,11 @@ struct ChatView: View {
private func loadChatItemsUnchecked(_ chat: Chat, _ pagination: ChatPagination) async -> Bool {
await apiLoadMessages(
chat.chatInfo.id,
+ im,
pagination,
- im.chatState,
searchText,
nil,
- { visibleItemIndexesNonReversed(scrollView.listState, mergedItems.boxedValue) }
+ { visibleItemIndexesNonReversed(im, scrollView.listState, mergedItems.boxedValue) }
)
return true
}
@@ -1152,11 +1473,12 @@ struct ChatView: View {
func onChatItemsUpdated() {
if !mergedItems.boxedValue.isActualState() {
- //logger.debug("Items are not actual, waiting for the next update: \(String(describing: mergedItems.boxedValue.splits)) \(ItemsModel.shared.chatState.splits), \(mergedItems.boxedValue.indexInParentItems.count) vs \(ItemsModel.shared.reversedChatItems.count)")
+ //logger.debug("Items are not actual, waiting for the next update: \(String(describing: mergedItems.boxedValue.splits)) \(im.chatState.splits), \(mergedItems.boxedValue.indexInParentItems.count) vs \(im.reversedChatItems.count)")
return
}
floatingButtonModel.updateOnListChange(scrollView.listState)
preloadIfNeeded(
+ im,
$allowLoadMoreItems,
$ignoreLoadingRequests,
scrollView.listState,
@@ -1170,13 +1492,14 @@ struct ChatView: View {
},
loadLastItems: {
if !loadingMoreItems {
- await loadLastItems($loadingMoreItems, loadingBottomItems: $loadingBottomItems, chat)
+ await loadLastItems($loadingMoreItems, loadingBottomItems: $loadingBottomItems, chat, im)
}
}
)
}
private struct ChatItemWithMenu: View {
+ @ObservedObject var im: ItemsModel
@EnvironmentObject var m: ChatModel
@EnvironmentObject var theme: AppTheme
@AppStorage(DEFAULT_PROFILE_IMAGE_CORNER_RADIUS) private var profileRadius = defaultProfileImageCorner
@@ -1185,7 +1508,8 @@ struct ChatView: View {
let index: Int
let isLastItem: Bool
let chatItem: ChatItem
- let scrollToItemId: (ChatItem.ID) -> Void
+ let scrollToItem: (ChatItem.ID) -> Void
+ @Binding var scrollToItemId: ChatItem.ID?
let merged: MergedItem
let maxWidth: CGFloat
@Binding var composeState: ComposeState
@@ -1261,8 +1585,6 @@ struct ChatView: View {
}
var body: some View {
- let im = ItemsModel.shared
-
let last = isLastItem ? im.reversedChatItems.last : nil
let listItem = merged.newest()
let item = listItem.item
@@ -1271,7 +1593,7 @@ struct ChatView: View {
} else {
nil
}
- let showAvatar = shouldShowAvatar(item, listItem.nextItem)
+ let showAvatar = shouldShowAvatar(item, merged.oldest().nextItem)
let single = switch merged {
case .single: true
default: false
@@ -1306,12 +1628,12 @@ struct ChatView: View {
let (itemIds, unreadMentions) = unreadItemIds(range)
if !itemIds.isEmpty {
waitToMarkRead {
- await apiMarkChatItemsRead(chat.chatInfo, itemIds, mentionsRead: unreadMentions)
+ await apiMarkChatItemsRead(im, chat.chatInfo, itemIds, mentionsRead: unreadMentions)
}
}
} else if chatItem.isRcvNew {
waitToMarkRead {
- await apiMarkChatItemsRead(chat.chatInfo, [chatItem.id], mentionsRead: chatItem.meta.userMention ? 1 : 0)
+ await apiMarkChatItemsRead(im, chat.chatInfo, [chatItem.id], mentionsRead: chatItem.meta.userMention ? 1 : 0)
}
}
}
@@ -1333,7 +1655,6 @@ struct ChatView: View {
}
private func unreadItemIds(_ range: ClosedRange) -> ([ChatItem.ID], Int) {
- let im = ItemsModel.shared
var unreadItems: [ChatItem.ID] = []
var unreadMentions: Int = 0
@@ -1424,7 +1745,7 @@ struct ChatView: View {
) -> some View {
let bottomPadding: Double = itemSeparation.largeGap ? 10 : 2
if case let .groupRcv(member) = ci.chatDir,
- case .group = chat.chatInfo {
+ case let .group(groupInfo, _) = chat.chatInfo {
if showAvatar {
VStack(alignment: .leading, spacing: 4) {
if ci.content.showMemberName {
@@ -1435,22 +1756,27 @@ struct ChatView: View {
} else {
(nil, 1)
}
- if memCount == 1 && member.memberRole > .member {
+ if memCount == 1 && (member.memberRole > .member || ci.meta.showGroupAsSender) {
+ let (name, role) = if ci.meta.showGroupAsSender {
+ (groupInfo.chatViewName, NSLocalizedString("group", comment: "shown on group welcome message"))
+ } else {
+ (member.chatViewName, member.memberRole.text)
+ }
Group {
if #available(iOS 16.0, *) {
MemberLayout(spacing: 16, msgWidth: msgWidth) {
- Text(member.chatViewName)
+ Text(name)
.lineLimit(1)
- Text(member.memberRole.text)
+ Text(role)
.fontWeight(.semibold)
.lineLimit(1)
.padding(.trailing, 8)
}
} else {
HStack(spacing: 16) {
- Text(member.chatViewName)
+ Text(name)
.lineLimit(1)
- Text(member.memberRole.text)
+ Text(role)
.fontWeight(.semibold)
.lineLimit(1)
.layoutPriority(1)
@@ -1477,17 +1803,24 @@ struct ChatView: View {
.padding(.trailing, 12)
}
HStack(alignment: .top, spacing: 10) {
- MemberProfileImage(member, size: memberImageSize, backgroundColor: theme.colors.background)
- .simultaneousGesture(TapGesture().onEnded {
- if let mem = m.getGroupMember(member.groupMemberId) {
- selectedMember = mem
- } else {
- let mem = GMember.init(member)
- m.groupMembers.append(mem)
- m.groupMembersIndexes[member.groupMemberId] = m.groupMembers.count - 1
- selectedMember = mem
- }
- })
+ if ci.meta.showGroupAsSender {
+ ProfileImage(imageStr: groupInfo.image, iconName: groupInfo.chatIconName, size: memberImageSize, backgroundColor: theme.colors.background)
+ .simultaneousGesture(TapGesture().onEnded {
+ showChatInfoSheet = true
+ })
+ } else {
+ MemberProfileImage(member, size: memberImageSize, backgroundColor: theme.colors.background)
+ .simultaneousGesture(TapGesture().onEnded {
+ if let mem = m.getGroupMember(member.groupMemberId) {
+ selectedMember = mem
+ } else {
+ let mem = GMember.init(member)
+ m.groupMembers.append(mem)
+ m.groupMembersIndexes[member.groupMemberId] = m.groupMembers.count - 1
+ selectedMember = mem
+ }
+ })
+ }
chatItemWithMenu(ci, range, maxWidth, itemSeparation)
.onPreferenceChange(DetermineWidth.Key.self) { msgWidth = $0 }
}
@@ -1546,8 +1879,10 @@ struct ChatView: View {
}
ChatItemView(
chat: chat,
+ im: im,
chatItem: ci,
- scrollToItemId: scrollToItemId,
+ scrollToItem: scrollToItem,
+ scrollToItemId: $scrollToItemId,
maxWidth: maxWidth,
allowMenu: $allowMenu
)
@@ -1583,14 +1918,14 @@ struct ChatView: View {
.confirmationDialog(archivingReports?.count == 1 ? "Archive report?" : "Archive \(archivingReports?.count ?? 0) reports?", isPresented: $showArchivingReports, titleVisibility: .visible) {
Button("For me", role: .destructive) {
if let reports = self.archivingReports {
- archiveReports(chat.chatInfo, reports.sorted(), false)
+ archiveReports(chat, reports.sorted(), false)
self.archivingReports = []
}
}
- if case let ChatInfo.group(groupInfo) = chat.chatInfo, groupInfo.membership.memberActive {
+ if case let ChatInfo.group(groupInfo, _) = chat.chatInfo, groupInfo.membership.memberActive {
Button("For all moderators", role: .destructive) {
if let reports = self.archivingReports {
- archiveReports(chat.chatInfo, reports.sorted(), true)
+ archiveReports(chat, reports.sorted(), true)
self.archivingReports = []
}
}
@@ -1636,7 +1971,7 @@ struct ChatView: View {
})
}
switch chat.chatInfo {
- case let .group(groupInfo):
+ case let .group(groupInfo, _):
v.contextMenu {
ReactionContextMenu(
groupInfo: groupInfo,
@@ -1659,7 +1994,7 @@ struct ChatView: View {
@ViewBuilder
private func menu(_ ci: ChatItem, _ range: ClosedRange?, live: Bool) -> some View {
- if case let .group(gInfo) = chat.chatInfo, ci.isReport, ci.meta.itemDeleted == nil {
+ if case let .group(gInfo, _) = chat.chatInfo, ci.isReport, ci.meta.itemDeleted == nil {
if ci.chatDir != .groupSnd, gInfo.membership.memberRole >= .moderator {
archiveReportButton(ci)
}
@@ -1718,7 +2053,7 @@ struct ChatView: View {
if let (groupInfo, _) = ci.memberToModerate(chat.chatInfo) {
moderateButton(ci, groupInfo)
} else if ci.meta.itemDeleted == nil && chat.groupFeatureEnabled(.reports),
- case let .group(gInfo) = chat.chatInfo,
+ case let .group(gInfo, _) = chat.chatInfo,
gInfo.membership.memberRole == .member
&& !live
&& composeState.voiceMessageRecordingState == .noRecording {
@@ -1829,6 +2164,7 @@ struct ChatView: View {
let chatItem = try await apiChatItemReaction(
type: cInfo.chatType,
id: cInfo.apiId,
+ scope: cInfo.groupChatScope(),
itemId: ci.id,
add: add,
reaction: reaction
@@ -1888,7 +2224,7 @@ struct ChatView: View {
} label: {
Label(
NSLocalizedString("Save", comment: "chat item action"),
- systemImage: file.cryptoArgs == nil ? "square.and.arrow.down" : "lock.open"
+ systemImage: "square.and.arrow.down"
)
}
}
@@ -1942,11 +2278,11 @@ struct ChatView: View {
Task {
do {
let cInfo = chat.chatInfo
- let ciInfo = try await apiGetChatItemInfo(type: cInfo.chatType, id: cInfo.apiId, itemId: ci.id)
+ let ciInfo = try await apiGetChatItemInfo(type: cInfo.chatType, id: cInfo.apiId, scope: cInfo.groupChatScope(), itemId: ci.id)
await MainActor.run {
chatItemInfo = ciInfo
}
- if case let .group(gInfo) = chat.chatInfo {
+ if case let .group(gInfo, _) = chat.chatInfo {
await m.loadGroupMembers(gInfo)
}
} catch let error {
@@ -2000,13 +2336,13 @@ struct ChatView: View {
private func deleteButton(_ ci: ChatItem, label: LocalizedStringKey = "Delete") -> Button {
Button(role: .destructive) {
if !revealed,
- let currIndex = m.getChatItemIndex(ci),
+ let currIndex = m.getChatItemIndex(im, ci),
let ciCategory = ci.mergeCategory {
let (prevHidden, _) = m.getPrevShownChatItem(currIndex, ciCategory)
if let range = itemsRange(currIndex, prevHidden) {
var itemIds: [Int64] = []
for i in range {
- itemIds.append(ItemsModel.shared.reversedChatItems[i].id)
+ itemIds.append(im.reversedChatItems[i].id)
}
showDeleteMessages = true
deletingItems = itemIds
@@ -2144,12 +2480,12 @@ struct ChatView: View {
selectedChatItems = selectedChatItems ?? []
var itemIds: [Int64] = []
if !revealed,
- let currIndex = m.getChatItemIndex(ci),
+ let currIndex = m.getChatItemIndex(im, ci),
let ciCategory = ci.mergeCategory {
let (prevHidden, _) = m.getPrevShownChatItem(currIndex, ciCategory)
if let range = itemsRange(currIndex, prevHidden) {
for i in range {
- itemIds.append(ItemsModel.shared.reversedChatItems[i].id)
+ itemIds.append(im.reversedChatItems[i].id)
}
} else {
itemIds.append(ci.id)
@@ -2183,6 +2519,7 @@ struct ChatView: View {
try await apiDeleteChatItems(
type: chat.chatInfo.chatType,
id: chat.chatInfo.apiId,
+ scope: chat.chatInfo.groupChatScope(),
itemIds: [di.id],
mode: mode
)
@@ -2199,6 +2536,7 @@ struct ChatView: View {
if deletedItem.isActiveReport {
m.decreaseGroupReportsCounter(chat.chatInfo.id)
}
+ m.updateChatInfo(itemDeletion.deletedChatItem.chatInfo)
}
}
}
@@ -2237,14 +2575,14 @@ struct ChatView: View {
if searchIsNotBlank {
goToItemInnerButton(alignStart, "magnifyingglass", touchInProgress: touchInProgress) {
closeKeyboardAndRun {
- ItemsModel.shared.loadOpenChatNoWait(chat.id, chatItem.id)
+ im.loadOpenChatNoWait(chat.id, chatItem.id)
}
}
} else if let chatTypeApiIdMsgId {
goToItemInnerButton(alignStart, "arrow.right", touchInProgress: touchInProgress) {
closeKeyboardAndRun {
let (chatType, apiId, msgId) = chatTypeApiIdMsgId
- ItemsModel.shared.loadOpenChatNoWait("\(chatType.rawValue)\(apiId)", msgId)
+ im.loadOpenChatNoWait("\(chatType.rawValue)\(apiId)", msgId)
}
}
}
@@ -2271,6 +2609,84 @@ struct ChatView: View {
}
}
+class FloatingButtonModel: ObservableObject {
+ @ObservedObject var im: ItemsModel
+
+ public init(im: ItemsModel) {
+ self.im = im
+ }
+
+ @Published var unreadAbove: Int = 0
+ @Published var unreadBelow: Int = 0
+ @Published var isNearBottom: Bool = true
+ @Published var date: Date? = nil
+ @Published var isDateVisible: Bool = false
+ var hideDateWorkItem: DispatchWorkItem? = nil
+
+ func updateOnListChange(_ listState: EndlessScrollView.ListState) {
+ let lastVisibleItem = oldestPartiallyVisibleListItemInListStateOrNull(listState)
+ let unreadBelow = if let lastVisibleItem {
+ max(0, im.chatState.unreadTotal - lastVisibleItem.unreadBefore)
+ } else {
+ 0
+ }
+ let unreadAbove = im.chatState.unreadTotal - unreadBelow
+ let date: Date? =
+ if let lastVisible = listState.visibleItems.last {
+ Calendar.current.startOfDay(for: lastVisible.item.oldest().item.meta.itemTs)
+ } else {
+ nil
+ }
+
+ // set the counters and date indicator
+ DispatchQueue.main.async { [weak self] in
+ guard let it = self else { return }
+ it.setDate(visibility: true)
+ it.unreadAbove = unreadAbove
+ it.unreadBelow = unreadBelow
+ it.date = date
+ }
+
+ // set floating button indication mode
+ let nearBottom = listState.firstVisibleItemIndex < 1
+ if nearBottom != self.isNearBottom {
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.35) { [weak self] in
+ self?.isNearBottom = nearBottom
+ }
+ }
+
+ // hide Date indicator after 1 second of no scrolling
+ hideDateWorkItem?.cancel()
+ let workItem = DispatchWorkItem { [weak self] in
+ guard let it = self else { return }
+ it.setDate(visibility: false)
+ it.hideDateWorkItem = nil
+ }
+ DispatchQueue.main.async { [weak self] in
+ self?.hideDateWorkItem = workItem
+ DispatchQueue.main.asyncAfter(deadline: .now() + 1, execute: workItem)
+ }
+ }
+
+ func resetDate() {
+ date = nil
+ isDateVisible = false
+ }
+
+ private func setDate(visibility isVisible: Bool) {
+ if isVisible {
+ if !isNearBottom,
+ !isDateVisible,
+ let date, !Calendar.current.isDateInToday(date) {
+ withAnimation { self.isDateVisible = true }
+ }
+ } else if isDateVisible {
+ withAnimation { self.isDateVisible = false }
+ }
+ }
+
+}
+
private func broadcastDeleteButtonText(_ chat: Chat) -> LocalizedStringKey {
chat.chatInfo.featureEnabled(.fullDelete) ? "Delete for everyone" : "Mark deleted for everyone"
}
@@ -2292,6 +2708,7 @@ private func deleteMessages(_ chat: Chat, _ deletingItems: [Int64], _ mode: CIDe
try await apiDeleteChatItems(
type: chatInfo.chatType,
id: chatInfo.apiId,
+ scope: chatInfo.groupChatScope(),
itemIds: itemIds,
mode: mode
)
@@ -2300,15 +2717,18 @@ private func deleteMessages(_ chat: Chat, _ deletingItems: [Int64], _ mode: CIDe
await MainActor.run {
for di in deletedItems {
if let toItem = di.toChatItem {
- _ = ChatModel.shared.upsertChatItem(chat.chatInfo, toItem.chatItem)
+ _ = ChatModel.shared.upsertChatItem(chatInfo, toItem.chatItem)
} else {
ChatModel.shared.removeChatItem(chatInfo, di.deletedChatItem.chatItem)
}
let deletedItem = di.deletedChatItem.chatItem
if deletedItem.isActiveReport {
- ChatModel.shared.decreaseGroupReportsCounter(chat.chatInfo.id)
+ ChatModel.shared.decreaseGroupReportsCounter(chatInfo.id)
}
}
+ if let updatedChatInfo = deletedItems.last?.deletedChatItem.chatInfo {
+ ChatModel.shared.updateChatInfo(updatedChatInfo)
+ }
}
await onSuccess()
} catch {
@@ -2318,8 +2738,9 @@ private func deleteMessages(_ chat: Chat, _ deletingItems: [Int64], _ mode: CIDe
}
}
-func archiveReports(_ chatInfo: ChatInfo, _ itemIds: [Int64], _ forAll: Bool, _ onSuccess: @escaping () async -> Void = {}) {
+func archiveReports(_ chat: Chat, _ itemIds: [Int64], _ forAll: Bool, _ onSuccess: @escaping () async -> Void = {}) {
if itemIds.count > 0 {
+ let chatInfo = chat.chatInfo
Task {
do {
let deleted = try await apiDeleteReceivedReports(
@@ -2340,6 +2761,9 @@ func archiveReports(_ chatInfo: ChatInfo, _ itemIds: [Int64], _ forAll: Bool, _
ChatModel.shared.decreaseGroupReportsCounter(chatInfo.id)
}
}
+ if let updatedChatInfo = deleted.last?.deletedChatItem.chatInfo {
+ ChatModel.shared.updateChatInfo(updatedChatInfo)
+ }
}
await onSuccess()
} catch {
@@ -2353,7 +2777,7 @@ private func buildTheme() -> AppTheme {
if let cId = ChatModel.shared.chatId, let chat = ChatModel.shared.getChat(cId) {
let perChatTheme = if case let .direct(contact) = chat.chatInfo {
contact.uiThemes?.preferredMode(!AppTheme.shared.colors.isLight)
- } else if case let .group(groupInfo) = chat.chatInfo {
+ } else if case let .group(groupInfo, _) = chat.chatInfo {
groupInfo.uiThemes?.preferredMode(!AppTheme.shared.colors.isLight)
} else {
nil as ThemeModeOverride?
@@ -2506,7 +2930,7 @@ func updateChatSettings(_ chat: Chat, chatSettings: ChatSettings) {
case var .direct(contact):
contact.chatSettings = chatSettings
ChatModel.shared.updateContact(contact)
- case var .group(groupInfo):
+ case var .group(groupInfo, _):
groupInfo.chatSettings = chatSettings
ChatModel.shared.updateGroup(groupInfo)
default: ()
@@ -2523,7 +2947,8 @@ struct ChatView_Previews: PreviewProvider {
static var previews: some View {
let chatModel = ChatModel()
chatModel.chatId = "@1"
- ItemsModel.shared.reversedChatItems = [
+ let im = ItemsModel.shared
+ im.reversedChatItems = [
ChatItem.getSample(1, .directSnd, .now, "hello"),
ChatItem.getSample(2, .directRcv, .now, "hi"),
ChatItem.getSample(3, .directRcv, .now, "hi there"),
@@ -2535,7 +2960,13 @@ struct ChatView_Previews: PreviewProvider {
ChatItem.getSample(9, .directSnd, .now, "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. 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.")
]
@State var showChatInfo = false
- return ChatView(chat: Chat(chatInfo: ChatInfo.sampleData.direct, chatItems: []))
- .environmentObject(chatModel)
+ return ChatView(
+ chat: Chat(chatInfo: ChatInfo.sampleData.direct, chatItems: []),
+ im: im,
+ mergedItems: BoxedValue(MergedItems.create(im, [])),
+ floatingButtonModel: FloatingButtonModel(im: im),
+ scrollToItemId: Binding.constant(nil)
+ )
+ .environmentObject(chatModel)
}
}
diff --git a/apps/ios/Shared/Views/Chat/CommandsMenuView.swift b/apps/ios/Shared/Views/Chat/CommandsMenuView.swift
new file mode 100644
index 0000000000..525bf5725c
--- /dev/null
+++ b/apps/ios/Shared/Views/Chat/CommandsMenuView.swift
@@ -0,0 +1,187 @@
+//
+// CommandsMenuView.swift
+// SimpleX (iOS)
+//
+// Created by EP on 03/08/2025.
+// Copyright © 2025 SimpleX Chat. All rights reserved.
+//
+
+import SwiftUI
+import SimpleXChat
+
+let COMMAND_ROW_SIZE: CGFloat = 48
+let MAX_VISIBLE_COMMAND_ROWS: CGFloat = 5.8
+
+struct CommandsMenuView: View {
+ @EnvironmentObject var m: ChatModel
+ @EnvironmentObject var theme: AppTheme
+ @ObservedObject var chat: Chat
+ @Binding var composeState: ComposeState
+ @Binding var selectedRange: NSRange
+ @Binding var showCommandsMenu: Bool
+
+ @State private var currentCommands: [ChatBotCommand] = []
+ @State private var menuTreeBackPath: [(label: String, commands: [ChatBotCommand])] = []
+ @State private var keywordWidth: CGFloat = 0
+
+ var body: some View {
+ ZStack(alignment: .bottom) {
+ if !currentCommands.isEmpty {
+ Color.white.opacity(0.01)
+ .edgesIgnoringSafeArea(.all)
+ .onTapGesture {
+ showCommandsMenu = false
+ currentCommands = []
+ menuTreeBackPath = []
+ }
+ VStack(spacing: 0) {
+ Spacer()
+ let cmdsCount = currentCommands.count + (menuTreeBackPath.isEmpty ? 0 : 1)
+ let scroll = ScrollView {
+ VStack(spacing: 0) {
+ if let prev = menuTreeBackPath.last {
+ Divider()
+ menuLabelRow(prev)
+ }
+ ForEach(currentCommands, id: \.self, content: commandRow)
+ }
+ }
+ .frame(maxWidth: .infinity, maxHeight: COMMAND_ROW_SIZE * min(MAX_VISIBLE_COMMAND_ROWS, CGFloat(cmdsCount)))
+ .background(theme.colors.background)
+
+ if #available(iOS 16.0, *) {
+ scroll.scrollDismissesKeyboard(.never)
+ } else {
+ scroll
+ }
+ }
+ .onPreferenceChange(DetermineWidth.Key.self) { keywordWidth = $0 }
+ }
+ }
+ .onChange(of: composeState.message) { message in
+ let msg = message.trimmingCharacters(in: .whitespaces)
+ if msg == "/" {
+ currentCommands = chat.chatInfo.menuCommands
+ } else if msg.first == "/" {
+ currentCommands = filterShownCommands(chat.chatInfo.menuCommands, msg.dropFirst())
+ } else {
+ showCommandsMenu = false
+ currentCommands = []
+ }
+ menuTreeBackPath = []
+ }
+ .onChange(of: showCommandsMenu) { show in
+ currentCommands = show ? chat.chatInfo.menuCommands : []
+ menuTreeBackPath = []
+ }
+ }
+
+ private func menuLabelRow(_ prev: (label: String, commands: [ChatBotCommand])) -> some View {
+ HStack {
+ Image(systemName: "chevron.left")
+ .foregroundColor(theme.colors.secondary)
+ Text(prev.label)
+ .fontWeight(.medium)
+ .frame(maxWidth: .infinity)
+ }
+ .padding(.horizontal)
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .frame(height: COMMAND_ROW_SIZE, alignment: .center)
+ .contentShape(Rectangle())
+ .onTapGesture {
+ if !menuTreeBackPath.isEmpty {
+ currentCommands = menuTreeBackPath.removeLast().commands
+ }
+ }
+ }
+
+ @ViewBuilder
+ private func commandRow(_ command: ChatBotCommand) -> some View {
+ Divider()
+ switch command {
+ case let .command(keyword, label, params):
+ HStack {
+ Text(label)
+ .lineLimit(1)
+ .frame(maxWidth: .infinity, alignment: .leading)
+ Text("/" + keyword)
+ .font(.subheadline)
+ .lineLimit(1)
+ .foregroundColor(theme.colors.secondary)
+ .frame(minWidth: keywordWidth, alignment: .trailing)
+ .overlay(DetermineWidth())
+ }
+ .padding(.horizontal)
+ .frame(height: COMMAND_ROW_SIZE, alignment: .center)
+ .contentShape(Rectangle())
+ .onTapGesture {
+ if let params {
+ composeState.message = "/\(keyword) \(params)"
+ selectedRange = NSRange(location: composeState.message.count, length: 0)
+ } else {
+ composeState.message = ""
+ sendCommandMsg(chat, "/\(keyword)")
+ }
+ showCommandsMenu = false
+ currentCommands = []
+ menuTreeBackPath = []
+ }
+ case let .menu(label, cmds):
+ HStack {
+ Text(label)
+ .fontWeight(.medium)
+ .lineLimit(1)
+ Spacer()
+ Image(systemName: "chevron.right")
+ .foregroundColor(theme.colors.secondary)
+ }
+ .padding(.horizontal)
+ .frame(height: COMMAND_ROW_SIZE, alignment: .center)
+ .contentShape(Rectangle())
+ .onTapGesture {
+ menuTreeBackPath.append((label: label, commands: currentCommands))
+ currentCommands = cmds
+ }
+ }
+ }
+
+ private func filterShownCommands(_ commands: [ChatBotCommand], _ msg: String.SubSequence) -> [ChatBotCommand] {
+ var cmds: [ChatBotCommand] = []
+ for command in commands {
+ switch command {
+ case let .command(keyword, _, _):
+ if keyword.starts(with: msg) {
+ cmds.append(command)
+ }
+ case let .menu(_, innerCmds):
+ cmds.append(contentsOf: filterShownCommands(innerCmds, msg))
+ }
+ }
+ return cmds
+ }
+}
+
+func sendCommandMsg(_ chat: Chat, _ cmd: String) {
+ if chat.chatInfo.sndReady {
+ Task {
+ if let chatItems = await apiSendMessages(
+ type: chat.chatInfo.chatType,
+ id: chat.chatInfo.apiId,
+ scope: chat.chatInfo.groupChatScope(),
+ composedMessages: [ComposedMessage(msgContent: .text(cmd))]
+ ) {
+ await MainActor.run {
+ for ci in chatItems {
+ ChatModel.shared.addChatItem(chat.chatInfo, ci)
+ }
+ }
+ }
+ }
+ } else {
+ showAlert(
+ NSLocalizedString("You can't send messages!", comment: "alert title"),
+ message: NSLocalizedString("To send commands you must be connected.", comment: "alert message"),
+ actions: { [okAlertAction] }
+ )
+ }
+}
diff --git a/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeLinkView.swift b/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeLinkView.swift
index e629a984df..878ebd9cbf 100644
--- a/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeLinkView.swift
+++ b/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeLinkView.swift
@@ -49,7 +49,7 @@ struct ComposeLinkView: View {
VStack(alignment: .center, spacing: 4) {
Text(linkPreview.title)
.lineLimit(1)
- Text(linkPreview.uri.absoluteString)
+ Text(linkPreview.uri)
.font(.caption)
.lineLimit(1)
.foregroundColor(theme.colors.secondary)
@@ -63,7 +63,7 @@ struct ComposeLinkView: View {
struct SmallLinkPreview_Previews: PreviewProvider {
static var previews: some View {
let preview = LinkPreview(
- uri: URL(string: "http://DuckDuckGo.com")!,
+ uri: "http://DuckDuckGo.com",
title: "Privacy, simplified.",
description: "",
image: "data:image/jpg;base64,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"
diff --git a/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift b/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift
index 8993de886f..683dea0f56 100644
--- a/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift
+++ b/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift
@@ -1,10 +1,3 @@
-//
-// ComposeView.swift
-// SimpleX
-//
-// Created by Evgeny on 13/03/2022.
-// Copyright © 2022 SimpleX Chat. All rights reserved.
-//
import SwiftUI
import SimpleXChat
@@ -51,7 +44,8 @@ struct ComposeState {
var contextItem: ComposeContextItem
var voiceMessageRecordingState: VoiceMessageRecordingState
var inProgress = false
- var useLinkPreviews: Bool = UserDefaults.standard.bool(forKey: DEFAULT_PRIVACY_LINK_PREVIEWS)
+ var progressByTimeout = false
+ var useLinkPreviews = true
var mentions: MentionedMembers = [:]
init(
@@ -114,7 +108,7 @@ struct ComposeState {
mentions: mentions ?? self.mentions
)
}
-
+
func mentionMemberName(_ name: String) -> String {
var n = 0
var tryName = name
@@ -124,11 +118,11 @@ struct ComposeState {
}
return tryName
}
-
+
var memberMentions: [String: Int64] {
self.mentions.compactMapValues { $0.memberRef?.groupMemberId }
}
-
+
var editing: Bool {
switch contextItem {
case .editingItem: return true
@@ -149,14 +143,14 @@ 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):
@@ -167,13 +161,13 @@ struct ComposeState {
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 || submittingValidReport
+ default: return !whitespaceOnly || forwarding || liveMessage != nil || submittingValidReport
}
}
@@ -254,7 +248,11 @@ struct ComposeState {
}
var empty: Bool {
- message == "" && noPreview
+ whitespaceOnly && noPreview
+ }
+
+ var whitespaceOnly: Bool {
+ message.allSatisfy { $0.isWhitespace }
}
}
@@ -323,16 +321,18 @@ struct ComposeView: View {
@EnvironmentObject var chatModel: ChatModel
@EnvironmentObject var theme: AppTheme
@ObservedObject var chat: Chat
+ @ObservedObject var im: ItemsModel
@Binding var composeState: ComposeState
+ @Binding var showCommandsMenu: Bool
@Binding var keyboardVisible: Bool
@Binding var keyboardHiddenDate: Date
@Binding var selectedRange: NSRange
var disabledText: LocalizedStringKey? = nil
- @State var linkUrl: URL? = nil
+ @State var linkUrl: String? = nil
@State var hasSimplexLink: Bool = false
- @State var prevLinkUrl: URL? = nil
- @State var pendingLinkUrl: URL? = nil
+ @State var prevLinkUrl: String? = nil
+ @State var pendingLinkUrl: String? = nil
@State var cancelledLinks: Set = []
@Environment(\.colorScheme) private var colorScheme
@@ -352,23 +352,46 @@ struct ComposeView: View {
@UserDefault(DEFAULT_PRIVACY_SAVE_LAST_DRAFT) private var saveLastDraft = true
@UserDefault(DEFAULT_TOOLBAR_MATERIAL) private var toolbarMaterial = ToolbarMaterial.defaultMaterial
+ @AppStorage(GROUP_DEFAULT_INCOGNITO, store: groupDefaults) private var incognitoDefault = false
+ @AppStorage(GROUP_DEFAULT_PRIVACY_SANITIZE_LINKS, store: groupDefaults) private var privacySanitizeLinks = false
+ @State private var updatingCompose = false
var body: some View {
VStack(spacing: 0) {
Divider()
- if chat.chatInfo.contact?.nextSendGrpInv ?? false {
- ContextInvitingContactMemberView()
+
+ if chat.chatInfo.nextConnectPrepared,
+ let user = chatModel.currentUser {
+ ContextProfilePickerView(
+ chat: chat,
+ selectedUser: user
+ )
Divider()
}
-
+
+ if let groupInfo = chat.chatInfo.groupInfo,
+ case let .groupChatScopeContext(groupScopeInfo) = im.secondaryIMFilter,
+ case let .memberSupport(member) = groupScopeInfo,
+ let member = member,
+ member.memberPending,
+ composeState.contextItem == .noContextItem,
+ composeState.noPreview {
+ ContextPendingMemberActionsView(
+ groupInfo: groupInfo,
+ member: member
+ )
+ 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)
+ let simplexLinkProhibited = im.secondaryIMFilter == nil && hasSimplexLink && !chat.groupFeatureEnabled(.simplexLinks)
+ let fileProhibited = im.secondaryIMFilter == nil && composeState.attachmentPreview && !chat.groupFeatureEnabled(.files)
let voiceProhibited = composeState.voicePreview && !chat.chatInfo.featureEnabled(.voice)
+ let disableSendButton = simplexLinkProhibited || fileProhibited || voiceProhibited
if simplexLinkProhibited {
msgNotAllowedView("SimpleX links not allowed", icon: "link")
Divider()
@@ -385,72 +408,46 @@ struct ComposeView: View {
case (true, .voicePreview): EmptyView() // ? we may allow playback when editing is allowed
default: previewView()
}
- HStack (alignment: .bottom) {
- let b = Button {
- showChooseSource = true
- } label: {
- Image(systemName: "paperclip")
- .resizable()
- }
- .disabled(composeState.attachmentDisabled || !chat.chatInfo.sendMsgEnabled || (chat.chatInfo.contact?.nextSendGrpInv ?? false))
- .frame(width: 25, height: 25)
- .padding(.bottom, 16)
- .padding(.leading, 12)
- .tint(theme.colors.primary)
- if case let .group(g) = chat.chatInfo,
- !g.fullGroupPreferences.files.on(for: g.membership) {
- b.disabled(true).onTapGesture {
- AlertManager.shared.showAlertMsg(
- title: "Files and media prohibited!",
- message: "Only group owners can enable files and media."
- )
- }
- } else {
- b
- }
- ZStack(alignment: .leading) {
- SendMessageView(
- composeState: $composeState,
- selectedRange: $selectedRange,
- sendMessage: { ttl in
- sendMessage(ttl: ttl)
- resetLinkPreview()
- },
- sendLiveMessage: chat.chatInfo.chatType != .local ? sendLiveMessage : nil,
- updateLiveMessage: updateLiveMessage,
- cancelLiveMessage: {
- composeState.liveMessage = nil
- chatModel.removeLiveDummy()
- },
- nextSendGrpInv: chat.chatInfo.contact?.nextSendGrpInv ?? false,
- voiceMessageAllowed: chat.chatInfo.featureEnabled(.voice),
- disableSendButton: simplexLinkProhibited || fileProhibited || voiceProhibited,
- showEnableVoiceMessagesAlert: chat.chatInfo.showEnableVoiceMessagesAlert,
- startVoiceMessageRecording: {
- Task {
- await startVoiceMessageRecording()
- }
- },
- finishVoiceMessageRecording: finishVoiceMessageRecording,
- allowVoiceMessagesToContact: allowVoiceMessagesToContact,
- 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.chatInfo.sendMsgEnabled)
- if let disabledText {
- Text(disabledText)
- .italic()
- .foregroundColor(theme.colors.secondary)
- .padding(.horizontal, 12)
+ let contact = chat.chatInfo.contact
+
+ if chat.chatInfo.groupInfo?.nextConnectPrepared == true {
+ if chat.chatInfo.groupInfo?.businessChat == nil {
+ connectButtonView("Join group", icon: "person.2.fill", connect: connectPreparedGroup)
+ } else {
+ sendContactRequestView(disableSendButton, icon: "briefcase.fill", sendRequest: connectPreparedGroup)
+ }
+ } else if contact?.nextSendGrpInv == true {
+ contextSendMessageToConnect("Send direct message to connect")
+ Divider()
+ HStack (alignment: .center) {
+ attachmentAndCommandsButtons().disabled(true)
+ sendMessageView(disableSendButton, sendToConnect: sendMemberContactInvitation)
+ }
+ .padding(.horizontal, 12)
+ } else if let contact,
+ contact.nextConnectPrepared == true,
+ let linkType = contact.preparedContact?.uiConnLinkType {
+ switch linkType {
+ case .inv:
+ connectButtonView("Connect", icon: "person.fill.badge.plus", connect: sendConnectPreparedContact)
+ case .con:
+ if contact.isBot {
+ connectButtonView("Connect", icon: "bolt.fill", connect: sendConnectPreparedContact)
+ } else {
+ sendContactRequestView(disableSendButton, icon: "person.fill.badge.plus", sendRequest: sendConnectPreparedContactRequest)
}
}
+ } else if contact?.nextAcceptContactRequest == true, let crId = contact?.contactRequestId {
+ ContextContactRequestActionsView(contactRequestId: crId)
+ } else if let ct = contact, ct.nextAcceptContactRequest, let groupDirectInv = ct.groupDirectInv {
+ ContextMemberContactActionsView(contact: ct, groupDirectInv: groupDirectInv)
+ } else {
+ HStack (alignment: .center) {
+ attachmentAndCommandsButtons()
+ sendMessageView(disableSendButton)
+ }
+ .padding(.horizontal, 12)
}
}
.background {
@@ -459,19 +456,40 @@ 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 {
+ if updatingCompose {
+ updatingCompose = false
+ return
+ }
+ var parsedMsg = parseSimpleXMarkdown(msg)
+ if privacySanitizeLinks, let parsed = parsedMsg {
+ let r = sanitizeMessage(parsed)
+ if let sanitizedPos = r.sanitizedPos {
+ updatingCompose = true
+ composeState = composeState.copy(message: r.message, parsedMessage: r.parsedMsg)
+ if sanitizedPos < selectedRange.location {
+ selectedRange = NSRange(location: sanitizedPos, length: 0)
+ }
+ parsedMsg = r.parsedMsg
+ } else {
+ composeState = composeState.copy(parsedMessage: parsedMsg)
+ }
+ } else {
+ composeState = composeState.copy(parsedMessage: parsedMsg ?? FormattedText.plain(msg))
+ }
+ if composeState.linkPreviewAllowed && UserDefaults.standard.bool(forKey: DEFAULT_PRIVACY_LINK_PREVIEWS) {
+ if !msg.isEmpty {
showLinkPreview(parsedMsg)
} else {
resetLinkPreview()
hasSimplexLink = false
+ composeState = composeState.copy(preview: .noPreview)
}
- } else if msg.count > 0 && !chat.groupFeatureEnabled(.simplexLinks) {
- (_, hasSimplexLink) = getSimplexLink(parsedMsg)
} else {
- hasSimplexLink = false
+ resetLinkPreview()
+ hasSimplexLink = !msg.isEmpty && !chat.groupFeatureEnabled(.simplexLinks) && getMessageLinks(parsedMsg).hasSimplexLink
+ if composeState.linkPreviewAllowed {
+ composeState = composeState.copy(preview: .noPreview)
+ }
}
}
.onChange(of: chat.chatInfo.sendMsgEnabled) { sendEnabled in
@@ -481,6 +499,15 @@ struct ComposeView: View {
clearState()
}
}
+ .onChange(of: composeState.inProgress) { inProgress in
+ if inProgress {
+ DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
+ composeState.progressByTimeout = composeState.inProgress
+ }
+ } else {
+ composeState.progressByTimeout = false
+ }
+ }
.confirmationDialog("Attach", isPresented: $showChooseSource, titleVisibility: .visible) {
Button("Take picture") {
showTakePhoto = true
@@ -616,6 +643,243 @@ struct ComposeView: View {
}
}
+ private func connectButtonView(_ label: LocalizedStringKey, icon: String, connect: @escaping () -> Void) -> some View {
+ Button(action: connect) {
+ ZStack(alignment: .trailing) {
+ Label(label, systemImage: icon)
+ .frame(maxWidth: .infinity)
+ if composeState.progressByTimeout {
+ ProgressView()
+ .padding()
+ }
+ }
+ }
+ .frame(height: 60)
+ .disabled(composeState.inProgress)
+ }
+
+ private func sendContactRequestView(_ disableSendButton: Bool, icon: String, sendRequest: @escaping () -> Void) -> some View {
+ HStack (alignment: .center) {
+ sendMessageView(
+ disableSendButton,
+ placeholder: NSLocalizedString("Add message", comment: "placeholder for sending contact request"),
+ sendToConnect: sendRequest
+ )
+ if composeState.whitespaceOnly {
+ Button(action: sendRequest) {
+ HStack {
+ Text("Connect").fontWeight(.medium)
+ Image(systemName: icon)
+ }
+ }
+ .padding(.horizontal, 8)
+ .disabled(composeState.inProgress)
+ }
+ }
+ .padding(.horizontal, 12)
+ }
+
+ private func sendMessageView(_ disableSendButton: Bool, placeholder: String? = nil, sendToConnect: (() -> Void)? = nil) -> some View {
+ ZStack(alignment: .leading) {
+ SendMessageView(
+ placeholder: placeholder,
+ composeState: $composeState,
+ selectedRange: $selectedRange,
+ sendMessage: { ttl in
+ sendMessage(ttl: ttl)
+ resetLinkPreview()
+ },
+ sendLiveMessage: chat.chatInfo.chatType != .local ? sendLiveMessage : nil,
+ updateLiveMessage: updateLiveMessage,
+ cancelLiveMessage: {
+ composeState.liveMessage = nil
+ chatModel.removeLiveDummy()
+ },
+ sendToConnect: sendToConnect,
+ hideSendButton: chat.chatInfo.nextConnect && chat.chatInfo.contact?.nextSendGrpInv != true && composeState.whitespaceOnly,
+ voiceMessageAllowed: chat.chatInfo.featureEnabled(.voice),
+ disableSendButton: disableSendButton,
+ showEnableVoiceMessagesAlert: chat.chatInfo.showEnableVoiceMessagesAlert,
+ startVoiceMessageRecording: {
+ Task {
+ await startVoiceMessageRecording()
+ }
+ },
+ finishVoiceMessageRecording: finishVoiceMessageRecording,
+ allowVoiceMessagesToContact: allowVoiceMessagesToContact,
+ 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
+ )
+ .disabled(!chat.chatInfo.sendMsgEnabled)
+
+ if let disabledText {
+ Text(disabledText)
+ .italic()
+ .foregroundColor(theme.colors.secondary)
+ .padding(.horizontal, 12)
+ }
+ }
+ }
+
+ @ViewBuilder private func attachmentAndCommandsButtons() -> some View {
+ let msg = composeState.message.trimmingCharacters(in: .whitespaces)
+ let showAttachment = chat.chatInfo.contact?.profile.peerType != .bot || chat.chatInfo.featureEnabled(.files)
+ let showCommands = chat.chatInfo.useCommands && (!showAttachment || msg.isEmpty || msg.starts(with: "/"))
+ if showCommands {
+ commandsButton()
+ }
+ if showAttachment {
+ attachmentButton()
+ .padding(.trailing, 3)
+ .if(showCommands) { v in v.padding(.leading, 3) }
+ }
+ }
+
+ private func commandsButton() -> some View {
+ Button {
+ showCommandsMenu.toggle()
+ } label: {
+ Text(verbatim: "//")
+ .font(.title3)
+ .italic()
+ .contentShape(Rectangle())
+ }
+ .disabled(!chat.chatInfo.sendMsgEnabled || chat.chatInfo.menuCommands.isEmpty)
+ .frame(width: 25, height: 25)
+ .tint(theme.colors.primary)
+ .padding(.bottom, 2)
+ }
+
+ @ViewBuilder private func attachmentButton() -> some View {
+ let b = Button {
+ showChooseSource = true
+ } label: {
+ Image(systemName: "paperclip")
+ .resizable()
+ }
+ .disabled(composeState.attachmentDisabled || !chat.chatInfo.sendMsgEnabled)
+ .frame(width: 25, height: 25)
+ .tint(theme.colors.primary)
+ if im.secondaryIMFilter == nil,
+ !chat.chatInfo.featureEnabled(.files) {
+ b.disabled(true).onTapGesture {
+ AlertManager.shared.showAlertMsg(
+ title: "Files and media prohibited!",
+ message: chat.chatInfo.groupInfo == nil ? nil : "Only group owners can enable files and media."
+ )
+ }
+ } else {
+ b
+ }
+ }
+
+ private func sendMemberContactInvitation() {
+ Task {
+ do {
+ await MainActor.run { hideKeyboard() }
+ if let mc = connectCheckLinkPreview() {
+ await sending()
+ let contact = try await apiSendMemberContactInvitation(chat.chatInfo.apiId, mc)
+ await MainActor.run {
+ self.chatModel.updateContact(contact)
+ clearState()
+ NetworkModel.shared.setContactNetworkStatus(contact, .connected)
+ }
+ } else {
+ AlertManager.shared.showAlertMsg(title: "Empty message!")
+ }
+ } catch {
+ await MainActor.run { composeState.inProgress = false }
+ logger.error("ChatView.sendMemberContactInvitation error: \(error.localizedDescription)")
+ AlertManager.shared.showAlertMsg(title: "Error sending member contact invitation", message: "Error: \(responseError(error))")
+ }
+ }
+ }
+
+ private func sendConnectPreparedContactRequest() {
+ hideKeyboard()
+ let empty = composeState.whitespaceOnly
+ AlertManager.shared.showAlert(Alert(
+ title: Text("Send contact request?"),
+ message: Text("You will be able to send messages **only after your request is accepted**."),
+ primaryButton: .default(
+ Text(empty ? "Send request without message" : "Send request"),
+ action: sendConnectPreparedContact
+ ),
+ secondaryButton:
+ empty
+ ? .cancel(Text("Add message"), action: hideKeyboard)
+ : .cancel()
+ ))
+ }
+
+ private func sendConnectPreparedContact() {
+ Task {
+ await MainActor.run { hideKeyboard() }
+ await sending()
+ let mc = connectCheckLinkPreview()
+ let incognito = chat.chatInfo.profileChangeProhibited ? chat.chatInfo.incognito : incognitoDefault
+ if let contact = await apiConnectPreparedContact(contactId: chat.chatInfo.apiId, incognito: incognito, msg: mc) {
+ await MainActor.run {
+ self.chatModel.updateContact(contact)
+ NetworkModel.shared.setContactNetworkStatus(contact, .connected)
+ clearState()
+ }
+ } else {
+ await MainActor.run { composeState.inProgress = false }
+ }
+ }
+ }
+
+ private func connectPreparedGroup() {
+ Task {
+ await MainActor.run { hideKeyboard() }
+ await sending()
+ let mc = connectCheckLinkPreview()
+ let incognito = chat.chatInfo.profileChangeProhibited ? chat.chatInfo.incognito : incognitoDefault
+ if let groupInfo = await apiConnectPreparedGroup(groupId: chat.chatInfo.apiId, incognito: incognito, msg: mc) {
+ await MainActor.run {
+ self.chatModel.updateGroup(groupInfo)
+ clearState()
+ }
+ } else {
+ await MainActor.run { composeState.inProgress = false }
+ }
+ }
+ }
+
+ @inline(__always)
+ private func connectCheckLinkPreview() -> MsgContent? {
+ let msgText = composeState.message.trimmingCharacters(in: .whitespacesAndNewlines)
+ return msgText.isEmpty ? nil : checkLinkPreview_(msgText)
+ }
+
+ @inline(__always)
+ private func checkLinkPreview() -> MsgContent {
+ checkLinkPreview_(composeState.message.trimmingCharacters(in: .whitespacesAndNewlines))
+ }
+
+ private func checkLinkPreview_(_ msgText: String) -> MsgContent {
+ switch (composeState.preview) {
+ case let .linkPreview(linkPreview: linkPreview):
+ if let parsedMsg = parseSimpleXMarkdown(msgText),
+ let url = getMessageLinks(parsedMsg).url,
+ let linkPreview = linkPreview,
+ url == linkPreview.uri {
+ return .link(text: msgText, preview: linkPreview)
+ } else {
+ return .text(msgText)
+ }
+ default:
+ return .text(msgText)
+ }
+ }
+
private func addMediaContent(_ content: UploadContent) async {
if let img = await resizeImageToStrSize(content.uiImage, maxDataSize: 14000) {
var newMedia: [(String, UploadContent?)] = []
@@ -752,8 +1016,19 @@ struct ComposeView: View {
.frame(maxWidth: .infinity, alignment: .leading)
.background(.thinMaterial)
}
-
-
+
+ private func contextSendMessageToConnect(_ s: LocalizedStringKey) -> some View {
+ HStack {
+ Image(systemName: "message")
+ .foregroundColor(theme.colors.secondary)
+ Text(s)
+ }
+ .padding(12)
+ .frame(minHeight: 54)
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .background(ToolbarMaterial.material(toolbarMaterial))
+ }
+
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")
@@ -831,9 +1106,7 @@ struct ComposeView: View {
if liveMessage != nil { composeState = composeState.copy(liveMessage: nil) }
await sending()
}
- if chat.chatInfo.contact?.nextSendGrpInv ?? false {
- await sendMemberContactInvitation()
- } else if case let .forwardingItems(chatItems, fromChatInfo) = composeState.contextItem {
+ if case let .forwardingItems(chatItems, fromChatInfo) = composeState.contextItem {
// Composed text is send as a reply to the last forwarded item
sent = await forwardItems(chatItems, fromChatInfo, ttl).last
if !composeState.message.isEmpty {
@@ -913,23 +1186,6 @@ struct ComposeView: View {
nil
}
}
-
- func sending() async {
- await MainActor.run { composeState.inProgress = true }
- }
-
- func sendMemberContactInvitation() async {
- do {
- let mc = checkLinkPreview()
- let contact = try await apiSendMemberContactInvitation(chat.chatInfo.apiId, mc)
- await MainActor.run {
- self.chatModel.updateContact(contact)
- }
- } catch {
- logger.error("ChatView.sendMemberContactInvitation error: \(error.localizedDescription)")
- AlertManager.shared.showAlertMsg(title: "Error sending member contact invitation", message: "Error: \(responseError(error))")
- }
- }
func updateMessage(_ ei: ChatItem, live: Bool) async -> ChatItem? {
if let oldMsgContent = ei.content.msgContent {
@@ -939,6 +1195,7 @@ struct ComposeView: View {
let chatItem = try await apiUpdateChatItem(
type: chat.chatInfo.chatType,
id: chat.chatInfo.apiId,
+ scope: chat.chatInfo.groupChatScope(),
itemId: ei.id,
updatedMessage: UpdatedMessage(msgContent: mc, mentions: composeState.memberMentions),
live: live
@@ -974,6 +1231,9 @@ struct ComposeView: View {
return .file(msgText)
case .report(_, let reason):
return .report(text: msgText, reason: reason)
+ // TODO [short links] update chat link
+ case let .chat(_, chatLink):
+ return .chat(text: msgText, chatLink: chatLink)
case .unknown(let type, _):
return .unknown(type: type, text: msgText)
}
@@ -993,7 +1253,7 @@ struct ComposeView: View {
return nil
}
}
-
+
func send(_ reportReason: ReportReason, chatItemId: Int64) async -> ChatItem? {
if let chatItems = await apiReportMessage(
groupId: chat.chatInfo.apiId,
@@ -1001,17 +1261,37 @@ struct ComposeView: View {
reportReason: reportReason,
reportText: msgText
) {
- await MainActor.run {
- for chatItem in chatItems {
- chatModel.addChatItem(chat.chatInfo, chatItem)
+ if showReportsInSupportChatAlertDefault.get() {
+ await MainActor.run {
+ showReportsInSupportChatAlert()
}
}
return chatItems.first
}
-
+
return nil
}
-
+
+ func showReportsInSupportChatAlert() {
+ showAlert(
+ NSLocalizedString("Report sent to moderators", comment: "alert title"),
+ message: NSLocalizedString("You can view your reports in Chat with admins.", comment: "alert message"),
+ actions: {[
+ UIAlertAction(
+ title: NSLocalizedString("Don't show again", comment: "alert action"),
+ style: .default,
+ handler: { _ in
+ showReportsInSupportChatAlertDefault.set(false)
+ }
+ ),
+ UIAlertAction(
+ title: NSLocalizedString("Ok", comment: "alert action"),
+ style: .default
+ )
+ ]}
+ )
+ }
+
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, mentions: mentions)],
@@ -1026,6 +1306,7 @@ struct ComposeView: View {
: await apiSendMessages(
type: chat.chatInfo.chatType,
id: chat.chatInfo.apiId,
+ scope: chat.chatInfo.groupChatScope(),
live: live,
ttl: ttl,
composedMessages: msgs
@@ -1050,8 +1331,10 @@ struct ComposeView: View {
if let chatItems = await apiForwardChatItems(
toChatType: chat.chatInfo.chatType,
toChatId: chat.chatInfo.apiId,
+ toScope: chat.chatInfo.groupChatScope(),
fromChatType: fromChatInfo.chatType,
fromChatId: fromChatInfo.apiId,
+ fromScope: fromChatInfo.groupChatScope(),
itemIds: forwardedItems.map { $0.id },
ttl: ttl
) {
@@ -1074,22 +1357,10 @@ struct ComposeView: View {
return []
}
}
+ }
- func checkLinkPreview() -> MsgContent {
- switch (composeState.preview) {
- case let .linkPreview(linkPreview: linkPreview):
- if let parsedMsg = parseSimpleXMarkdown(msgText),
- let url = getSimplexLink(parsedMsg).url,
- let linkPreview = linkPreview,
- url == linkPreview.uri {
- return .link(text: msgText, preview: linkPreview)
- } else {
- return .text(msgText)
- }
- default:
- return .text(msgText)
- }
- }
+ func sending() async {
+ await MainActor.run { composeState.inProgress = true }
}
private func startVoiceMessageRecording() async {
@@ -1200,7 +1471,7 @@ struct ComposeView: View {
private func showLinkPreview(_ parsedMsg: [FormattedText]?) {
prevLinkUrl = linkUrl
- (linkUrl, hasSimplexLink) = getSimplexLink(parsedMsg)
+ (linkUrl, hasSimplexLink) = getMessageLinks(parsedMsg)
if let url = linkUrl {
if url != composeState.linkPreview?.uri && url != pendingLinkUrl {
pendingLinkUrl = url
@@ -1217,39 +1488,38 @@ struct ComposeView: View {
}
}
- private func getSimplexLink(_ parsedMsg: [FormattedText]?) -> (url: URL?, hasSimplexLink: Bool) {
+ private func getMessageLinks(_ parsedMsg: [FormattedText]?) -> (url: String?, 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)
- }) {
- URL(string: uri.text)
- } else {
- nil
- }
let simplexLink = parsedMsgHasSimplexLink(parsedMsg)
- return (url, simplexLink)
+ for ft in parsedMsg {
+ if let link = ft.linkUri, !cancelledLinks.contains(link) && !isSimplexLink(link) {
+ return (link, simplexLink)
+ }
+ }
+ return (nil, simplexLink)
}
private func isSimplexLink(_ link: String) -> Bool {
- link.starts(with: "https://simplex.chat") || link.starts(with: "http://simplex.chat")
+ link.starts(with: "https://simplex.chat") || link.starts(with: "http://simplex.chat") || link.starts(with: "simplex:/")
}
private func cancelLinkPreview() {
- if let pendingLink = pendingLinkUrl?.absoluteString {
+ if let pendingLink = pendingLinkUrl {
cancelledLinks.insert(pendingLink)
}
- if let uri = composeState.linkPreview?.uri.absoluteString {
+ if let uri = composeState.linkPreview?.uri {
cancelledLinks.insert(uri)
}
pendingLinkUrl = nil
composeState = composeState.copy(preview: .noPreview)
}
- private func loadLinkPreview(_ url: URL) {
- if pendingLinkUrl == url {
+ private func loadLinkPreview(_ urlStr: String) {
+ if pendingLinkUrl == urlStr, let url = URL(string: urlStr) {
composeState = composeState.copy(preview: .linkPreview(linkPreview: nil))
getLinkPreview(url: url) { linkPreview in
- if let linkPreview, pendingLinkUrl == url {
+ if let linkPreview, pendingLinkUrl == urlStr {
+ privacyLinkPreviewsShowAlertGroupDefault.set(false) // to avoid showing alert to current users, show alert in v6.5
composeState = composeState.copy(preview: .linkPreview(linkPreview: linkPreview))
} else {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
@@ -1269,29 +1539,31 @@ struct ComposeView: View {
}
}
-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),
- keyboardHiddenDate: Binding.constant(Date.now),
- selectedRange: $selectedRange
- )
- .environmentObject(ChatModel())
- ComposeView(
- chat: chat,
- composeState: $composeState,
- keyboardVisible: Binding.constant(true),
- keyboardHiddenDate: Binding.constant(Date.now),
- selectedRange: $selectedRange
- )
- .environmentObject(ChatModel())
+func sanitizeMessage(_ parsedMsg: [FormattedText]) -> (message: String, parsedMsg: [FormattedText], sanitizedPos: Int?) {
+ var pos: Int = 0
+ var updatedMsg = ""
+ var sanitizedPos: Int? = nil
+ let updatedParsedMsg = parsedMsg.map { ft in
+ var updated = ft
+ switch ft.format {
+ case .uri:
+ if let sanitized = parseSanitizeUri(ft.text, safe: true)?.uriInfo?.sanitized {
+ updated = FormattedText(text: sanitized, format: .uri)
+ pos += updated.text.count
+ sanitizedPos = pos
+ }
+ case let .hyperLink(text, uri):
+ if let sanitized = parseSanitizeUri(uri, safe: true)?.uriInfo?.sanitized {
+ let updatedText = if let text { "[\(text)](\(sanitized))" } else { sanitized }
+ updated = FormattedText(text: updatedText, format: .hyperLink(showText: text, linkUri: sanitized))
+ pos += updated.text.count
+ sanitizedPos = pos
+ }
+ default:
+ pos += ft.text.count
}
+ updatedMsg += updated.text
+ return updated
}
+ return (message: updatedMsg, parsedMsg: updatedParsedMsg, sanitizedPos: sanitizedPos)
}
diff --git a/apps/ios/Shared/Views/Chat/ComposeMessage/ContextContactRequestActionsView.swift b/apps/ios/Shared/Views/Chat/ComposeMessage/ContextContactRequestActionsView.swift
new file mode 100644
index 0000000000..82c89cd43d
--- /dev/null
+++ b/apps/ios/Shared/Views/Chat/ComposeMessage/ContextContactRequestActionsView.swift
@@ -0,0 +1,97 @@
+//
+// ContextContactRequestActionsView.swift
+// SimpleX (iOS)
+//
+// Created by spaced4ndy on 02.05.2025.
+// Copyright © 2025 SimpleX Chat. All rights reserved.
+//
+
+import SwiftUI
+import SimpleXChat
+
+struct ContextContactRequestActionsView: View {
+ @EnvironmentObject var theme: AppTheme
+ var contactRequestId: Int64
+ @UserDefault(DEFAULT_TOOLBAR_MATERIAL) private var toolbarMaterial = ToolbarMaterial.defaultMaterial
+ @State private var inProgress = false
+ @State private var progressByTimeout = false
+
+ var body: some View {
+ HStack(spacing: 0) {
+ Button(role: .destructive, action: showRejectRequestAlert) {
+ Label("Reject", systemImage: "multiply")
+ }
+ .frame(maxWidth: .infinity, minHeight: 60)
+
+ Button {
+ if ChatModel.shared.addressShortLinkDataSet {
+ acceptRequest()
+ } else {
+ showAcceptRequestAlert()
+ }
+ } label: {
+ Label("Accept", systemImage: "checkmark")
+ }
+ .frame(maxWidth: .infinity, minHeight: 60)
+ }
+ .disabled(inProgress)
+ .frame(maxWidth: .infinity)
+ .background(ToolbarMaterial.material(toolbarMaterial))
+ .opacity(progressByTimeout ? 0.4 : 1)
+ .overlay {
+ if progressByTimeout {
+ ProgressView()
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
+ }
+ }
+ .onChange(of: inProgress) { inPrgrs in
+ if inPrgrs {
+ DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
+ progressByTimeout = inProgress
+ }
+ } else {
+ progressByTimeout = false
+ }
+ }
+ }
+
+ private func showRejectRequestAlert() {
+ showAlert(
+ NSLocalizedString("Reject contact request", comment: "alert title"),
+ message: NSLocalizedString("The sender will NOT be notified", comment: "alert message"),
+ actions: {[
+ UIAlertAction(title: NSLocalizedString("Reject", comment: "alert action"), style: .destructive) { _ in
+ Task { await rejectContactRequest(contactRequestId, dismissToChatList: true) }
+ },
+ cancelAlertAction
+ ]}
+ )
+ }
+
+ private func showAcceptRequestAlert() {
+ showAlert(
+ NSLocalizedString("Accept contact request", comment: "alert title"),
+ actions: {[
+ UIAlertAction(title: NSLocalizedString("Accept", comment: "alert action"), style: .default) { _ in
+ acceptRequest()
+ },
+ UIAlertAction(title: NSLocalizedString("Accept incognito", comment: "alert action"), style: .default) { _ in
+ acceptRequest(incognito: true)
+ },
+ cancelAlertAction
+ ]}
+ )
+ }
+
+ private func acceptRequest(incognito: Bool = false) {
+ Task {
+ await acceptContactRequest(incognito: incognito, contactRequestId: contactRequestId, inProgress: $inProgress)
+ }
+ }
+}
+
+#Preview {
+ ContextContactRequestActionsView(
+ contactRequestId: 1
+ )
+}
diff --git a/apps/ios/Shared/Views/Chat/ComposeMessage/ContextInvitingContactMemberView.swift b/apps/ios/Shared/Views/Chat/ComposeMessage/ContextInvitingContactMemberView.swift
deleted file mode 100644
index 82090f312a..0000000000
--- a/apps/ios/Shared/Views/Chat/ComposeMessage/ContextInvitingContactMemberView.swift
+++ /dev/null
@@ -1,31 +0,0 @@
-//
-// ContextInvitingContactMemberView.swift
-// SimpleX (iOS)
-//
-// Created by spaced4ndy on 18.09.2023.
-// Copyright © 2023 SimpleX Chat. All rights reserved.
-//
-
-import SwiftUI
-
-struct ContextInvitingContactMemberView: View {
- @EnvironmentObject var theme: AppTheme
-
- var body: some View {
- HStack {
- Image(systemName: "message")
- .foregroundColor(theme.colors.secondary)
- Text("Send direct message to connect")
- }
- .padding(12)
- .frame(minHeight: 54)
- .frame(maxWidth: .infinity, alignment: .leading)
- .background(.thinMaterial)
- }
-}
-
-struct ContextInvitingContactMemberView_Previews: PreviewProvider {
- static var previews: some View {
- ContextInvitingContactMemberView()
- }
-}
diff --git a/apps/ios/Shared/Views/Chat/ComposeMessage/ContextMemberContactActionsView.swift b/apps/ios/Shared/Views/Chat/ComposeMessage/ContextMemberContactActionsView.swift
new file mode 100644
index 0000000000..9a73b2b5d4
--- /dev/null
+++ b/apps/ios/Shared/Views/Chat/ComposeMessage/ContextMemberContactActionsView.swift
@@ -0,0 +1,110 @@
+//
+// ContextMemberContactActionsView.swift
+// SimpleX (iOS)
+//
+// Created by spaced4ndy on 31.07.2025.
+// Copyright © 2025 SimpleX Chat. All rights reserved.
+//
+
+import SwiftUI
+import SimpleXChat
+
+struct ContextMemberContactActionsView: View {
+ @EnvironmentObject var theme: AppTheme
+ var contact: Contact
+ var groupDirectInv: GroupDirectInvitation
+ @UserDefault(DEFAULT_TOOLBAR_MATERIAL) private var toolbarMaterial = ToolbarMaterial.defaultMaterial
+ @State private var inProgress = false
+ @State private var progressByTimeout = false
+
+ var body: some View {
+ VStack {
+ if groupDirectInv.memberRemoved {
+ Label("Member is deleted - can't accept request", systemImage: "info.circle")
+ .foregroundColor(theme.colors.secondary)
+ .font(.subheadline)
+ .padding(.horizontal)
+ .frame(maxWidth: .infinity, minHeight: 60)
+ } else {
+ HStack(spacing: 0) {
+ Button(role: .destructive, action: { showRejectMemberContactRequestAlert(contact) }) {
+ Label("Reject", systemImage: "multiply")
+ }
+ .frame(maxWidth: .infinity, minHeight: 60)
+
+ Button {
+ acceptMemberContactRequest(contact, inProgress: $inProgress)
+ } label: {
+ Label("Accept", systemImage: "checkmark")
+ }
+ .frame(maxWidth: .infinity, minHeight: 60)
+ }
+ }
+ }
+ .disabled(inProgress || groupDirectInv.memberRemoved)
+ .frame(maxWidth: .infinity)
+ .background(ToolbarMaterial.material(toolbarMaterial))
+ .opacity(progressByTimeout ? 0.4 : 1)
+ .overlay {
+ if progressByTimeout {
+ ProgressView()
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
+ }
+ }
+ .onChange(of: inProgress) { inPrgrs in
+ if inPrgrs {
+ DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
+ progressByTimeout = inProgress
+ }
+ } else {
+ progressByTimeout = false
+ }
+ }
+ }
+}
+
+func showRejectMemberContactRequestAlert(_ contact: Contact) {
+ showAlert(
+ NSLocalizedString("Reject contact request", comment: "alert title"),
+ message: NSLocalizedString("The sender will NOT be notified", comment: "alert message"),
+ actions: {[
+ UIAlertAction(title: NSLocalizedString("Reject", comment: "alert action"), style: .destructive) { _ in
+ deleteContact(contact)
+ },
+ cancelAlertAction
+ ]}
+ )
+}
+
+private func deleteContact(_ contact: Contact) {
+ Task {
+ do {
+ _ = try await apiDeleteContact(id: contact.contactId, chatDeleteMode: .full(notify: false))
+ await MainActor.run {
+ ChatModel.shared.removeChat(contact.id)
+ ChatModel.shared.chatId = nil
+ }
+ } catch let error {
+ logger.error("apiDeleteContact: \(responseError(error))")
+ await MainActor.run {
+ showAlert(
+ NSLocalizedString("Error deleting chat!", comment: "alert title"),
+ message: responseError(error)
+ )
+ }
+ }
+ }
+}
+
+func acceptMemberContactRequest(_ contact: Contact, inProgress: Binding? = nil) {
+ Task {
+ await acceptMemberContact(contactId: contact.contactId, inProgress: inProgress)
+ }
+}
+
+#Preview {
+ ContextMemberContactActionsView(
+ contact: Contact.sampleData,
+ groupDirectInv: GroupDirectInvitation.sampleData
+ )
+}
diff --git a/apps/ios/Shared/Views/Chat/ComposeMessage/ContextPendingMemberActionsView.swift b/apps/ios/Shared/Views/Chat/ComposeMessage/ContextPendingMemberActionsView.swift
new file mode 100644
index 0000000000..143bf42ea4
--- /dev/null
+++ b/apps/ios/Shared/Views/Chat/ComposeMessage/ContextPendingMemberActionsView.swift
@@ -0,0 +1,106 @@
+//
+// ContextPendingMemberActionsView.swift
+// SimpleX (iOS)
+//
+// Created by spaced4ndy on 02.05.2025.
+// Copyright © 2025 SimpleX Chat. All rights reserved.
+//
+
+import SwiftUI
+import SimpleXChat
+
+struct ContextPendingMemberActionsView: View {
+ @EnvironmentObject var theme: AppTheme
+ @Environment(\.dismiss) var dismiss
+ var groupInfo: GroupInfo
+ var member: GroupMember
+ @UserDefault(DEFAULT_TOOLBAR_MATERIAL) private var toolbarMaterial = ToolbarMaterial.defaultMaterial
+
+ var body: some View {
+ HStack(spacing: 0) {
+ ZStack {
+ Text("Reject")
+ .foregroundColor(.red)
+ }
+ .frame(maxWidth: .infinity)
+ .contentShape(Rectangle())
+ .onTapGesture {
+ showRejectMemberAlert(groupInfo, member, dismiss: dismiss)
+ }
+
+ ZStack {
+ Text("Accept")
+ .foregroundColor(theme.colors.primary)
+ }
+ .frame(maxWidth: .infinity)
+ .contentShape(Rectangle())
+ .onTapGesture {
+ showAcceptMemberAlert(groupInfo, member, dismiss: dismiss)
+ }
+ }
+ .frame(minHeight: 54)
+ .frame(maxWidth: .infinity)
+ .background(ToolbarMaterial.material(toolbarMaterial))
+ }
+}
+
+func showRejectMemberAlert(_ groupInfo: GroupInfo, _ member: GroupMember, dismiss: DismissAction? = nil) {
+ showAlert(
+ title: NSLocalizedString("Reject member?", comment: "alert title"),
+ buttonTitle: "Reject",
+ buttonAction: { removeMember(groupInfo, member, dismiss: dismiss) },
+ cancelButton: true
+ )
+}
+
+func showAcceptMemberAlert(_ groupInfo: GroupInfo, _ member: GroupMember, dismiss: DismissAction? = nil) {
+ showAlert(
+ NSLocalizedString("Accept member", comment: "alert title"),
+ message: NSLocalizedString("Member will join the group, accept member?", comment: "alert message"),
+ actions: {[
+ UIAlertAction(
+ title: NSLocalizedString("Accept as member", comment: "alert action"),
+ style: .default,
+ handler: { _ in
+ acceptMember(groupInfo, member, .member, dismiss: dismiss)
+ }
+ ),
+ UIAlertAction(
+ title: NSLocalizedString("Accept as observer", comment: "alert action"),
+ style: .default,
+ handler: { _ in
+ acceptMember(groupInfo, member, .observer, dismiss: dismiss)
+ }
+ ),
+ cancelAlertAction
+ ]}
+ )
+}
+
+func acceptMember(_ groupInfo: GroupInfo, _ member: GroupMember, _ role: GroupMemberRole, dismiss: DismissAction? = nil) {
+ Task {
+ do {
+ let (gInfo, acceptedMember) = try await apiAcceptMember(groupInfo.groupId, member.groupMemberId, role)
+ await MainActor.run {
+ _ = ChatModel.shared.upsertGroupMember(gInfo, acceptedMember)
+ ChatModel.shared.updateGroup(gInfo)
+ dismiss?()
+ }
+ } catch let error {
+ logger.error("apiAcceptMember error: \(responseError(error))")
+ await MainActor.run {
+ showAlert(
+ NSLocalizedString("Error accepting member", comment: "alert title"),
+ message: responseError(error)
+ )
+ }
+ }
+ }
+}
+
+#Preview {
+ ContextPendingMemberActionsView(
+ groupInfo: GroupInfo.sampleData,
+ member: GroupMember.sampleData
+ )
+}
diff --git a/apps/ios/Shared/Views/Chat/ComposeMessage/ContextProfilePickerView.swift b/apps/ios/Shared/Views/Chat/ComposeMessage/ContextProfilePickerView.swift
new file mode 100644
index 0000000000..427a600627
--- /dev/null
+++ b/apps/ios/Shared/Views/Chat/ComposeMessage/ContextProfilePickerView.swift
@@ -0,0 +1,305 @@
+//
+// ContextProfilePickerView.swift
+// SimpleX (iOS)
+//
+// Created by spaced4ndy on 13.06.2025.
+// Copyright © 2025 SimpleX Chat. All rights reserved.
+//
+
+import SwiftUI
+import SimpleXChat
+
+let USER_ROW_SIZE: CGFloat = 60
+let MAX_VISIBLE_USER_ROWS: CGFloat = 4.8
+
+struct ContextProfilePickerView: View {
+ @ObservedObject var chat: Chat
+ @EnvironmentObject var chatModel: ChatModel
+ @EnvironmentObject var theme: AppTheme
+ @State var selectedUser: User
+ @State private var users: [User] = []
+ @State private var listExpanded = false
+ @State private var expandedListReady = false
+ @State private var showIncognitoSheet = false
+
+ @AppStorage(GROUP_DEFAULT_INCOGNITO, store: groupDefaults) private var incognitoDefault = false
+
+ var body: some View {
+ viewBody()
+ .onAppear {
+ users = chatModel.users
+ .map { $0.user }
+ .filter { u in u.activeUser || !u.hidden }
+ }
+ .sheet(isPresented: $showIncognitoSheet) {
+ IncognitoHelp()
+ }
+ }
+
+ private func viewBody() -> some View {
+ Group {
+ if !listExpanded || chat.chatInfo.profileChangeProhibited {
+ currentSelection()
+ } else {
+ profilePicker()
+ }
+ }
+ }
+
+ private func currentSelection() -> some View {
+ VStack(spacing: 0) {
+ HStack {
+ Text("Your profile")
+ .font(.callout)
+ .foregroundColor(theme.colors.secondary)
+ Spacer()
+ }
+ .padding(.top, 8)
+ .padding(.bottom, -4)
+ .padding(.leading, 12)
+ .padding(.trailing)
+
+ if chat.chatInfo.profileChangeProhibited {
+ if chat.chatInfo.incognito {
+ incognitoOption()
+ } else {
+ profilerPickerUserOption(selectedUser)
+ }
+ } else if incognitoDefault {
+ incognitoOption()
+ } else {
+ profilerPickerUserOption(selectedUser)
+ }
+ }
+ }
+
+ private func profilePicker() -> some View {
+ ScrollViewReader { proxy in
+ Group {
+ if expandedListReady {
+ let scroll = ScrollView {
+ LazyVStack(spacing: 0) {
+ let otherUsers = users
+ .filter { u in u.userId != selectedUser.userId }
+ .sorted(using: KeyPathComparator(\.activeOrder))
+ ForEach(otherUsers) { p in
+ profilerPickerUserOption(p)
+ .contentShape(Rectangle())
+ Divider()
+ .padding(.leading)
+ .padding(.leading, 48)
+ }
+
+ if incognitoDefault {
+ profilerPickerUserOption(selectedUser)
+ .contentShape(Rectangle())
+ Divider()
+ .padding(.leading)
+ .padding(.leading, 48)
+
+ incognitoOption()
+ .contentShape(Rectangle())
+ .id("BOTTOM_ANCHOR")
+ } else {
+ incognitoOption()
+ .contentShape(Rectangle())
+ Divider()
+ .padding(.leading)
+ .padding(.leading, 48)
+
+ profilerPickerUserOption(selectedUser)
+ .contentShape(Rectangle())
+ .id("BOTTOM_ANCHOR")
+ }
+ }
+ }
+ .frame(maxHeight: USER_ROW_SIZE * min(MAX_VISIBLE_USER_ROWS, CGFloat(users.count + 1))) // + 1 for incognito
+ .onAppear {
+ DispatchQueue.main.async {
+ withAnimation(nil) {
+ proxy.scrollTo("BOTTOM_ANCHOR", anchor: .bottom)
+ }
+ }
+ }
+ .onDisappear {
+ expandedListReady = false
+ }
+
+ if #available(iOS 16.0, *) {
+ scroll.scrollDismissesKeyboard(.never)
+ } else {
+ scroll
+ }
+ } else {
+ // Keep showing current selection to avoid flickering of scroll to bottom
+ currentSelection()
+ .onAppear {
+ // Delay rendering of expanded profile list
+ DispatchQueue.main.async {
+ expandedListReady = true
+ }
+ }
+ }
+ }
+ }
+ }
+
+ private func profilerPickerUserOption(_ user: User) -> some View {
+ Button {
+ if !chat.chatInfo.profileChangeProhibited {
+ if selectedUser == user {
+ if !incognitoDefault {
+ listExpanded.toggle()
+ } else {
+ incognitoDefault = false
+ listExpanded = false
+ }
+ } else if selectedUser != user {
+ changeProfile(user)
+ }
+ } else {
+ showCantChangeProfileAlert()
+ }
+ } label: {
+ HStack {
+ ProfileImage(imageStr: user.image, size: 38)
+ Text(user.chatViewName)
+ .fontWeight(selectedUser == user && !incognitoDefault ? .medium : .regular)
+ .foregroundColor(theme.colors.onBackground)
+ .lineLimit(1)
+
+ Spacer()
+
+ if selectedUser == user && !incognitoDefault {
+ if listExpanded {
+ Image(systemName: "chevron.down")
+ .font(.system(size: 12, weight: .bold))
+ .foregroundColor(theme.colors.secondary)
+ .opacity(0.7)
+ } else if !chat.chatInfo.profileChangeProhibited {
+ Image(systemName: "chevron.up")
+ .font(.system(size: 12, weight: .bold))
+ .foregroundColor(theme.colors.secondary)
+ .opacity(0.7)
+ }
+ }
+ }
+ .padding(.leading, 12)
+ .padding(.trailing)
+ .frame(height: USER_ROW_SIZE)
+ }
+ }
+
+ private func changeProfile(_ newUser: User) {
+ Task {
+ do {
+ if let contact = chat.chatInfo.contact {
+ let updatedContact = try await apiChangePreparedContactUser(contactId: contact.contactId, newUserId: newUser.userId)
+ await MainActor.run {
+ selectedUser = newUser
+ incognitoDefault = false
+ listExpanded = false
+ chatModel.updateContact(updatedContact)
+ }
+ } else if let groupInfo = chat.chatInfo.groupInfo {
+ let updatedGroupInfo = try await apiChangePreparedGroupUser(groupId: groupInfo.groupId, newUserId: newUser.userId)
+ await MainActor.run {
+ selectedUser = newUser
+ incognitoDefault = false
+ listExpanded = false
+ chatModel.updateGroup(updatedGroupInfo)
+ }
+ }
+ do {
+ try await changeActiveUserAsync_(newUser.userId, viewPwd: nil, keepingChatId: chat.id)
+ } catch {
+ await MainActor.run {
+ showAlert(
+ NSLocalizedString("Error switching profile", comment: "alert title"),
+ message: String.localizedStringWithFormat(NSLocalizedString("Your chat was moved to %@ but an unexpected error occurred while redirecting you to the profile.", comment: "alert message"), newUser.chatViewName)
+ )
+ }
+ }
+ } catch let error {
+ await MainActor.run {
+ if let currentUser = chatModel.currentUser {
+ selectedUser = currentUser
+ }
+ showAlert(
+ NSLocalizedString("Error changing chat profile", comment: "alert title"),
+ message: responseError(error)
+ )
+ }
+ }
+ }
+ }
+
+ private func incognitoOption() -> some View {
+ Button {
+ if !chat.chatInfo.profileChangeProhibited {
+ if incognitoDefault {
+ listExpanded.toggle()
+ } else {
+ incognitoDefault = true
+ listExpanded = false
+ }
+ } else {
+ showCantChangeProfileAlert()
+ }
+ } label : {
+ HStack {
+ incognitoProfileImage()
+ Text("Incognito")
+ .fontWeight(incognitoDefault ? .medium : .regular)
+ .foregroundColor(theme.colors.onBackground)
+ Image(systemName: "info.circle")
+ .font(.system(size: 16))
+ .foregroundColor(theme.colors.primary)
+ .onTapGesture {
+ showIncognitoSheet = true
+ }
+
+ Spacer()
+
+ if incognitoDefault {
+ if listExpanded {
+ Image(systemName: "chevron.down")
+ .font(.system(size: 12, weight: .bold))
+ .foregroundColor(theme.colors.secondary)
+ .opacity(0.7)
+ } else if !chat.chatInfo.profileChangeProhibited {
+ Image(systemName: "chevron.up")
+ .font(.system(size: 12, weight: .bold))
+ .foregroundColor(theme.colors.secondary)
+ .opacity(0.7)
+ }
+ }
+ }
+ .padding(.leading, 12)
+ .padding(.trailing)
+ .frame(height: USER_ROW_SIZE)
+ }
+ }
+
+ private func incognitoProfileImage() -> some View {
+ Image(systemName: "theatermasks.fill")
+ .resizable()
+ .scaledToFit()
+ .frame(width: 38)
+ .foregroundColor(.indigo)
+ }
+
+ private func showCantChangeProfileAlert() {
+ showAlert(
+ NSLocalizedString("Can't change profile", comment: "alert title"),
+ message: NSLocalizedString("To use another profile after connection attempt, delete the chat and use the link again.", comment: "alert message")
+ )
+ }
+}
+
+#Preview {
+ ContextProfilePickerView(
+ chat: Chat.sampleData,
+ selectedUser: User.sampleData
+ )
+}
diff --git a/apps/ios/Shared/Views/Chat/ComposeMessage/NativeTextEditor.swift b/apps/ios/Shared/Views/Chat/ComposeMessage/NativeTextEditor.swift
index d809fd7b76..31d4ceecc6 100644
--- a/apps/ios/Shared/Views/Chat/ComposeMessage/NativeTextEditor.swift
+++ b/apps/ios/Shared/Views/Chat/ComposeMessage/NativeTextEditor.swift
@@ -63,13 +63,21 @@ struct NativeTextEditor: UIViewRepresentable {
field.textAlignment = alignment(text)
field.updateFont()
field.updateHeight(updateBindingNow: false)
+ field.placeholder = text.isEmpty ? placeholder : ""
}
if field.placeholder != placeholder {
- field.placeholder = placeholder
+ field.placeholder = text.isEmpty ? placeholder : ""
}
if field.selectedRange != selectedRange {
field.selectedRange = selectedRange
}
+ if focused && !field.isFocused {
+ DispatchQueue.main.async {
+ if !field.isFocused {
+ field.becomeFirstResponder()
+ }
+ }
+ }
}
}
diff --git a/apps/ios/Shared/Views/Chat/ComposeMessage/SendMessageView.swift b/apps/ios/Shared/Views/Chat/ComposeMessage/SendMessageView.swift
index e7b02c9aea..07cd61583b 100644
--- a/apps/ios/Shared/Views/Chat/ComposeMessage/SendMessageView.swift
+++ b/apps/ios/Shared/Views/Chat/ComposeMessage/SendMessageView.swift
@@ -12,6 +12,7 @@ import SimpleXChat
private let liveMsgInterval: UInt64 = 3000_000000
struct SendMessageView: View {
+ var placeholder: String?
@Binding var composeState: ComposeState
@Binding var selectedRange: NSRange
@EnvironmentObject var theme: AppTheme
@@ -20,7 +21,8 @@ struct SendMessageView: View {
var sendLiveMessage: (() async -> Void)? = nil
var updateLiveMessage: (() async -> Void)? = nil
var cancelLiveMessage: (() -> Void)? = nil
- var nextSendGrpInv: Bool = false
+ var sendToConnect: (() -> Void)? = nil
+ var hideSendButton: Bool = false
var showVoiceMessageButton: Bool = true
var voiceMessageAllowed: Bool = true
var disableSendButton = false
@@ -42,7 +44,6 @@ struct SendMessageView: View {
@State private var showCustomDisappearingMessageDialogue = false
@State private var showCustomTimePicker = false
@State private var selectedDisappearingMessageTime: Int? = customDisappearingMessageTimeDefault.get()
- @State private var progressByTimeout = false
@UserDefault(DEFAULT_LIVE_MESSAGE_ALERT_SHOWN) private var liveMessageAlertShown = false
var body: some View {
@@ -64,7 +65,7 @@ struct SendMessageView: View {
height: $teHeight,
focused: $keyboardVisible,
lastUnfocusedDate: $keyboardHiddenDate,
- placeholder: Binding(get: { composeState.placeholder }, set: { _ in }),
+ placeholder: Binding(get: { placeholder ?? composeState.placeholder }, set: { _ in }),
selectedRange: $selectedRange,
onImagesAdded: onMediaAdded
)
@@ -74,12 +75,12 @@ struct SendMessageView: View {
}
}
.overlay(alignment: .topTrailing, content: {
- if !progressByTimeout && teHeight > 100 && !composeState.inProgress {
+ if !composeState.progressByTimeout && teHeight > 100 && !composeState.inProgress {
deleteTextButton()
}
})
- .overlay(alignment: .bottomTrailing, content: {
- if progressByTimeout {
+ .overlay(alignment: .bottomTrailing) {
+ if composeState.progressByTimeout {
ProgressView()
.scaleEffect(1.4)
.frame(width: 31, height: 31, alignment: .center)
@@ -89,28 +90,21 @@ struct SendMessageView: View {
// 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 {
- DispatchQueue.main.asyncAfter(deadline: .now() + 3) {
- progressByTimeout = composeState.inProgress
- }
- } else {
- progressByTimeout = false
- }
- }
.padding(.vertical, 8)
}
@ViewBuilder private func composeActionButtons() -> some View {
let vmrs = composeState.voiceMessageRecordingState
- if nextSendGrpInv {
- inviteMemberContactButton()
+ if hideSendButton {
+ EmptyView()
+ } else if let connect = sendToConnect {
+ sendToConnectButton(connect)
} else if case .reportedItem = composeState.contextItem {
sendMessageButton()
} else if showVoiceMessageButton
@@ -158,20 +152,16 @@ struct SendMessageView: View {
.padding([.top, .trailing], 4)
}
- private func inviteMemberContactButton() -> some View {
- Button {
- sendMessage(nil)
- } label: {
+ private func sendToConnectButton(_ connect: @escaping () -> Void) -> some View {
+ let disabled = !composeState.sendEnabled || composeState.inProgress || disableSendButton
+ return Button(action: connect) {
Image(systemName: "arrow.up.circle.fill")
.resizable()
- .foregroundColor(sendButtonColor)
+ .foregroundColor(disabled ? theme.colors.secondary.opacity(0.67) : sendButtonColor)
.frame(width: sendButtonSize, height: sendButtonSize)
.opacity(sendButtonOpacity)
}
- .disabled(
- !composeState.sendEnabled ||
- composeState.inProgress
- )
+ .disabled(disabled)
.frame(width: 31, height: 31)
.padding([.bottom, .trailing], 4)
}
diff --git a/apps/ios/Shared/Views/Chat/Group/AddGroupMembersView.swift b/apps/ios/Shared/Views/Chat/Group/AddGroupMembersView.swift
index 7cd543af10..3154f16f5b 100644
--- a/apps/ios/Shared/Views/Chat/Group/AddGroupMembersView.swift
+++ b/apps/ios/Shared/Views/Chat/Group/AddGroupMembersView.swift
@@ -78,6 +78,12 @@ struct AddGroupMembersViewCommon: View {
let count = selectedContacts.count
Section {
if creatingGroup {
+ MemberAdmissionButton(
+ groupInfo: $groupInfo,
+ admission: groupInfo.groupProfile.memberAdmission_,
+ currentAdmission: groupInfo.groupProfile.memberAdmission_,
+ creatingGroup: true
+ )
GroupPreferencesButton(
groupInfo: $groupInfo,
preferences: groupInfo.fullGroupPreferences,
diff --git a/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift b/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift
index 15749b0761..d8929caa3e 100644
--- a/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift
+++ b/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift
@@ -17,11 +17,12 @@ struct GroupChatInfoView: View {
@Environment(\.dismiss) var dismiss: DismissAction
@ObservedObject var chat: Chat
@Binding var groupInfo: GroupInfo
+ @Binding var scrollToItemId: ChatItem.ID?
var onSearch: () -> Void
@State var localAlias: String
@FocusState private var aliasTextFieldFocused: Bool
@State private var alert: GroupChatInfoViewAlert? = nil
- @State private var groupLink: CreatedConnLink?
+ @State private var groupLink: GroupLink?
@State private var groupLinkMemberRole: GroupMemberRole = .member
@State private var groupLinkNavLinkActive: Bool = false
@State private var addMembersNavLinkActive: Bool = false
@@ -33,6 +34,7 @@ struct GroupChatInfoView: View {
@AppStorage(DEFAULT_DEVELOPER_TOOLS) private var developerTools = false
@State private var searchText: String = ""
@FocusState private var searchFocussed
+ @State private var showSecrets: Set = []
enum GroupChatInfoViewAlert: Identifiable {
case deleteGroupAlert
@@ -74,12 +76,12 @@ struct GroupChatInfoView: View {
List {
groupInfoHeader()
.listRowBackground(Color.clear)
-
+
localAliasTextEdit()
.listRowBackground(Color.clear)
.listRowSeparator(.hidden)
.padding(.bottom, 18)
-
+
infoActionButtons()
.padding(.horizontal)
.frame(maxWidth: .infinity)
@@ -87,7 +89,25 @@ struct GroupChatInfoView: View {
.listRowBackground(Color.clear)
.listRowSeparator(.hidden)
.listRowInsets(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0))
-
+
+ Section {
+ if groupInfo.canAddMembers && groupInfo.businessChat == nil {
+ groupLinkButton()
+ }
+ if groupInfo.businessChat == nil && groupInfo.membership.memberRole >= .moderator {
+ memberSupportButton()
+ }
+ if groupInfo.canModerate {
+ GroupReportsChatNavLink(chat: chat, groupInfo: groupInfo, scrollToItemId: $scrollToItemId)
+ }
+ if groupInfo.membership.memberActive
+ && (groupInfo.membership.memberRole < .moderator || groupInfo.membership.supportChat != nil) {
+ UserSupportChatNavLink(chat: chat, groupInfo: groupInfo, scrollToItemId: $scrollToItemId)
+ }
+ } header: {
+ Text("")
+ }
+
Section {
if groupInfo.isOwner && groupInfo.businessChat == nil {
editGroupButton()
@@ -96,19 +116,6 @@ struct GroupChatInfoView: View {
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
@@ -118,48 +125,64 @@ struct GroupChatInfoView: View {
Text(label)
.foregroundColor(theme.colors.secondary)
}
-
+
Section {
+ 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")
+ }
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 !groupInfo.nextConnectPrepared {
+ Section(header: Text("\(members.count + 1) members").foregroundColor(theme.colors.secondary)) {
+ if groupInfo.canAddMembers {
+ if (chat.chatInfo.incognito) {
+ Label("Invite members", systemImage: "plus")
+ .foregroundColor(Color(uiColor: .tertiaryLabel))
+ .onTapGesture { alert = .cantInviteIncognitoAlert }
+ } else {
+ addMembersButton()
+ }
}
- 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 == ""
+ 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)
+ MemberRowView(
+ chat: chat,
+ groupInfo: groupInfo,
+ groupMember: GMember(groupInfo.membership),
+ scrollToItemId: $scrollToItemId,
+ user: true,
+ alert: $alert
+ )
+ ForEach(filteredMembers) { member in
+ MemberRowView(chat: chat, groupInfo: groupInfo, groupMember: member, scrollToItemId: $scrollToItemId, alert: $alert)
+ }
}
}
-
+
Section {
clearChatButton()
if groupInfo.canDelete {
deleteGroupButton()
}
- if groupInfo.membership.memberCurrent {
+ if groupInfo.membership.memberCurrentOrPending {
leaveGroupButton()
}
}
-
+
if developerTools {
Section(header: Text("For console").foregroundColor(theme.colors.secondary)) {
infoRow("Local name", chat.chatInfo.localDisplayName)
@@ -171,7 +194,7 @@ struct GroupChatInfoView: View {
.navigationBarHidden(true)
.disabled(progressIndicator)
.opacity(progressIndicator ? 0.6 : 1)
-
+
if progressIndicator {
ProgressView().scaleEffect(2)
}
@@ -199,8 +222,9 @@ struct GroupChatInfoView: View {
}
sendReceipts = SendReceipts.fromBool(groupInfo.chatSettings.sendRcpts, userDefault: sendReceiptsUserDefault)
do {
- if let link = try apiGetGroupLink(groupInfo.groupId) {
- (groupLink, groupLinkMemberRole) = link
+ if let gLink = try apiGetGroupLink(groupInfo.groupId) {
+ groupLink = gLink
+ groupLinkMemberRole = gLink.acceptMemberRole
}
} catch let error {
logger.error("GroupChatInfoView apiGetGroupLink: \(responseError(error))")
@@ -211,19 +235,30 @@ struct GroupChatInfoView: View {
private func groupInfoHeader() -> some View {
VStack {
let cInfo = chat.chatInfo
+ // show actual display name, alias can be edited in this view
+ let displayName = (cInfo.groupInfo?.groupProfile.displayName ?? cInfo.displayName).trimmingCharacters(in: .whitespacesAndNewlines)
+ let fullName = cInfo.fullName.trimmingCharacters(in: .whitespacesAndNewlines)
ChatInfoImage(chat: chat, size: 192, color: Color(uiColor: .tertiarySystemFill))
.padding(.top, 12)
.padding()
- Text(cInfo.groupInfo?.groupProfile.displayName ?? cInfo.displayName)
+ Text(displayName)
.font(.largeTitle)
.multilineTextAlignment(.center)
.lineLimit(4)
.padding(.bottom, 2)
- if cInfo.fullName != "" && cInfo.fullName != cInfo.displayName {
+ if fullName != "" && fullName != displayName && fullName != cInfo.displayName.trimmingCharacters(in: .whitespacesAndNewlines) {
Text(cInfo.fullName)
.font(.title2)
.multilineTextAlignment(.center)
- .lineLimit(8)
+ .lineLimit(3)
+ .padding(.bottom, 2)
+ }
+ if let descr = cInfo.shortDescr?.trimmingCharacters(in: .whitespacesAndNewlines), descr != "" {
+ let r = markdownText(descr, textStyle: .subheadline, showSecrets: showSecrets, backgroundColor: theme.colors.background)
+ msgTextResultView(r, Text(AttributedString(r.string)), showSecrets: $showSecrets, centered: true, smallFont: true)
+ .multilineTextAlignment(.center)
+ .lineLimit(4)
+ .fixedSize(horizontal: false, vertical: true)
}
}
.frame(maxWidth: .infinity, alignment: .center)
@@ -245,7 +280,7 @@ struct GroupChatInfoView: View {
.multilineTextAlignment(.center)
.foregroundColor(theme.colors.secondary)
}
-
+
private func setGroupAlias() {
Task {
do {
@@ -259,7 +294,7 @@ struct GroupChatInfoView: View {
}
}
}
-
+
func infoActionButtons() -> some View {
GeometryReader { g in
let buttonWidth = g.size.width / 4
@@ -353,6 +388,7 @@ struct GroupChatInfoView: View {
var chat: Chat
var groupInfo: GroupInfo
@ObservedObject var groupMember: GMember
+ @Binding var scrollToItemId: ChatItem.ID?
@EnvironmentObject var theme: AppTheme
var user: Bool = false
@Binding var alert: GroupChatInfoViewAlert?
@@ -392,7 +428,7 @@ struct GroupChatInfoView: View {
if user {
v
- } else if groupInfo.membership.memberRole >= .admin {
+ } else if groupInfo.membership.memberRole >= .moderator {
// TODO if there are more actions, refactor with lists of swipeActions
let canBlockForAll = member.canBlockForAll(groupInfo: groupInfo)
let canRemove = member.canBeRemoved(groupInfo: groupInfo)
@@ -415,7 +451,7 @@ struct GroupChatInfoView: View {
}
private func memberInfoView() -> some View {
- GroupMemberInfoView(groupInfo: groupInfo, chat: chat, groupMember: groupMember)
+ GroupMemberInfoView(groupInfo: groupInfo, chat: chat, groupMember: groupMember, scrollToItemId: $scrollToItemId)
.navigationBarHidden(false)
}
@@ -435,7 +471,7 @@ struct GroupChatInfoView: View {
.foregroundColor(theme.colors.secondary)
} else {
let role = member.memberRole
- if [.owner, .admin, .observer].contains(role) {
+ if [.owner, .admin, .moderator, .observer].contains(role) {
Text(member.memberRole.text)
.foregroundColor(theme.colors.secondary)
}
@@ -523,15 +559,99 @@ struct GroupChatInfoView: View {
.navigationBarTitleDisplayMode(.large)
}
+ struct UserSupportChatNavLink: View {
+ @ObservedObject var chat: Chat
+ @EnvironmentObject var theme: AppTheme
+ var groupInfo: GroupInfo
+ @EnvironmentObject var chatModel: ChatModel
+ @Binding var scrollToItemId: ChatItem.ID?
+ @State private var navLinkActive = false
+
+ var body: some View {
+ let scopeInfo: GroupChatScopeInfo = .memberSupport(groupMember_: nil)
+ NavigationLink(isActive: $navLinkActive) {
+ SecondaryChatView(
+ chat: Chat(chatInfo: .group(groupInfo: groupInfo, groupChatScope: scopeInfo), chatItems: [], chatStats: ChatStats()),
+ scrollToItemId: $scrollToItemId
+ )
+ } label: {
+ HStack {
+ Label("Chat with admins", systemImage: chat.supportUnreadCount > 0 ? "flag.fill" : "flag")
+ Spacer()
+ if chat.supportUnreadCount > 0 {
+ UnreadBadge(count: chat.supportUnreadCount, color: theme.colors.primary)
+ }
+ }
+ }
+ .onChange(of: navLinkActive) { active in
+ if active {
+ ItemsModel.loadSecondaryChat(groupInfo.id, chatFilter: .groupChatScopeContext(groupScopeInfo: scopeInfo))
+ }
+ }
+ }
+ }
+
+ private func memberSupportButton() -> some View {
+ NavigationLink {
+ MemberSupportView(groupInfo: groupInfo, scrollToItemId: $scrollToItemId)
+ .navigationBarTitle("Chats with members")
+ .modifier(ThemedBackground())
+ .navigationBarTitleDisplayMode(.large)
+ } label: {
+ HStack {
+ Label(
+ "Chats with members",
+ systemImage: chat.supportUnreadCount > 0 ? "flag.fill" : "flag"
+ )
+ Spacer()
+ if chat.supportUnreadCount > 0 {
+ UnreadBadge(count: chat.supportUnreadCount, color: theme.colors.primary)
+ }
+ }
+ }
+ }
+
+ struct GroupReportsChatNavLink: View {
+ @ObservedObject var chat: Chat
+ @EnvironmentObject var theme: AppTheme
+ var groupInfo: GroupInfo
+ @EnvironmentObject var chatModel: ChatModel
+ @Binding var scrollToItemId: ChatItem.ID?
+ @State private var navLinkActive = false
+
+ var body: some View {
+ NavigationLink(isActive: $navLinkActive) {
+ SecondaryChatView(
+ chat: Chat(chatInfo: .group(groupInfo: groupInfo, groupChatScope: .reports), chatItems: [], chatStats: ChatStats()),
+ scrollToItemId: $scrollToItemId
+ )
+ } label: {
+ HStack {
+ Label {
+ Text("Member reports")
+ } icon: {
+ Image(systemName: chat.chatStats.reportsCount > 0 ? "flag.fill" : "flag").foregroundColor(.red)
+ }
+ Spacer()
+ if chat.chatStats.reportsCount > 0 {
+ UnreadBadge(count: chat.chatStats.reportsCount, color: .red)
+ }
+ }
+ }
+ .onChange(of: navLinkActive) { active in
+ if active {
+ ItemsModel.loadSecondaryChat(chat.id, chatFilter: .msgContentTagContext(contentTag: .report))
+ }
+ }
+ }
+ }
+
private func editGroupButton() -> some View {
NavigationLink {
GroupProfileView(
groupInfo: $groupInfo,
groupProfile: groupInfo.groupProfile
)
- .navigationBarTitle("Group profile")
- .modifier(ThemedBackground())
- .navigationBarTitleDisplayMode(.large)
} label: {
Label("Edit group profile", systemImage: "pencil")
}
@@ -642,14 +762,13 @@ struct GroupChatInfoView: View {
}
private func sendReceiptsOption() -> some View {
- Picker(selection: $sendReceipts) {
+ WrappedPicker(selection: $sendReceipts) {
ForEach([.yes, .no, .userDefault(sendReceiptsUserDefault)]) { (opt: SendReceipts) in
Text(opt.text)
}
} label: {
Label("Send receipts", systemImage: "checkmark.message")
}
- .frame(height: 36)
.onChange(of: sendReceipts) { _ in
setSendReceipts()
}
@@ -683,26 +802,36 @@ struct GroupChatInfoView: View {
title: Text("Remove member?"),
message: Text(messageLabel),
primaryButton: .destructive(Text("Remove")) {
- Task {
- do {
- let updatedMembers = try await apiRemoveMembers(groupInfo.groupId, [mem.groupMemberId])
- await MainActor.run {
- updatedMembers.forEach { updatedMember in
- _ = chatModel.upsertGroupMember(groupInfo, updatedMember)
- }
- }
- } catch let error {
- logger.error("apiRemoveMembers error: \(responseError(error))")
- let a = getErrorAlert(error, "Error removing member")
- alert = .error(title: a.title, error: a.message)
- }
- }
+ removeMember(groupInfo, mem)
},
secondaryButton: .cancel()
)
}
}
+func removeMember(_ groupInfo: GroupInfo, _ mem: GroupMember, dismiss: DismissAction? = nil) {
+ Task {
+ do {
+ let (updatedGroupInfo, updatedMembers) = try await apiRemoveMembers(groupInfo.groupId, [mem.groupMemberId])
+ await MainActor.run {
+ ChatModel.shared.updateGroup(updatedGroupInfo)
+ updatedMembers.forEach { updatedMember in
+ _ = ChatModel.shared.upsertGroupMember(updatedGroupInfo, updatedMember)
+ }
+ dismiss?()
+ }
+ } catch let error {
+ logger.error("apiRemoveMembers error: \(responseError(error))")
+ await MainActor.run {
+ showAlert(
+ NSLocalizedString("Error removing member", comment: "alert title"),
+ message: responseError(error)
+ )
+ }
+ }
+ }
+}
+
func deleteGroupAlertMessage(_ groupInfo: GroupInfo) -> Text {
groupInfo.businessChat == nil ? (
groupInfo.membership.memberCurrent ? Text("Group will be deleted for all members - this cannot be undone!") : Text("Group will be deleted for you - this cannot be undone!")
@@ -716,11 +845,11 @@ struct GroupPreferencesButton: View {
@State var preferences: FullGroupPreferences
@State var currentPreferences: FullGroupPreferences
var creatingGroup: Bool = false
-
+
private var label: LocalizedStringKey {
groupInfo.businessChat == nil ? "Group preferences" : "Chat preferences"
}
-
+
var body: some View {
NavigationLink {
GroupPreferencesView(
@@ -738,7 +867,7 @@ struct GroupPreferencesButton: View {
creatingGroup ? "Save" : "Save and notify group members",
comment: "alert button"
)
-
+
if groupInfo.fullGroupPreferences != preferences {
showAlert(
title: NSLocalizedString("Save preferences?", comment: "alert title"),
@@ -756,7 +885,7 @@ struct GroupPreferencesButton: View {
}
}
}
-
+
private func savePreferences() {
Task {
do {
@@ -773,7 +902,6 @@ struct GroupPreferencesButton: View {
}
}
}
-
}
@@ -796,6 +924,7 @@ struct GroupChatInfoView_Previews: PreviewProvider {
GroupChatInfoView(
chat: Chat(chatInfo: ChatInfo.sampleData.group, chatItems: []),
groupInfo: Binding.constant(GroupInfo.sampleData),
+ scrollToItemId: Binding.constant(nil),
onSearch: {},
localAlias: ""
)
diff --git a/apps/ios/Shared/Views/Chat/Group/GroupLinkView.swift b/apps/ios/Shared/Views/Chat/Group/GroupLinkView.swift
index a11c073a42..bc1ac4ab65 100644
--- a/apps/ios/Shared/Views/Chat/Group/GroupLinkView.swift
+++ b/apps/ios/Shared/Views/Chat/Group/GroupLinkView.swift
@@ -12,7 +12,7 @@ import SimpleXChat
struct GroupLinkView: View {
@EnvironmentObject var theme: AppTheme
var groupId: Int64
- @Binding var groupLink: CreatedConnLink?
+ @Binding var groupLink: GroupLink?
@Binding var groupLinkMemberRole: GroupMemberRole
var showTitle: Bool = false
var creatingGroup: Bool = false
@@ -35,16 +35,23 @@ struct GroupLinkView: View {
}
var body: some View {
- if creatingGroup {
- groupLinkView()
- .navigationBarBackButtonHidden()
- .toolbar {
- ToolbarItem(placement: .navigationBarTrailing) {
- Button ("Continue") { linkCreatedCb?() }
+ ZStack {
+ if creatingGroup {
+ groupLinkView()
+ .navigationBarBackButtonHidden()
+ .toolbar {
+ ToolbarItem(placement: .navigationBarTrailing) {
+ Button ("Continue") { linkCreatedCb?() }
+ }
}
- }
- } else {
- groupLinkView()
+ } else {
+ groupLinkView()
+ }
+ if creatingLink {
+ ProgressView()
+ .scaleEffect(2)
+ .frame(maxWidth: .infinity)
+ }
}
}
@@ -71,10 +78,21 @@ struct GroupLinkView: View {
}
}
.frame(height: 36)
- SimpleXCreatedLinkQRCode(link: groupLink, short: $showShortLink)
- .id("simplex-qrcode-view-for-\(groupLink.simplexChatUri(short: showShortLink))")
+ SimpleXCreatedLinkQRCode(link: groupLink.connLinkContact, short: $showShortLink)
+ .id("simplex-qrcode-view-for-\(groupLink.connLinkContact.simplexChatUri(short: showShortLink))")
+ if groupLink.shouldBeUpgraded {
+ Button {
+ upgradeAndShareLinkAlert()
+ } label: {
+ Label("Upgrade link", systemImage: "arrow.up")
+ }
+ }
Button {
- showShareSheet(items: [groupLink.simplexChatUri(short: showShortLink)])
+ if groupLink.shouldBeUpgraded {
+ upgradeAndShareLinkAlert(groupLink: groupLink)
+ } else {
+ groupLink.shareAddress(short: showShortLink)
+ }
} label: {
Label("Share link", systemImage: "square.and.arrow.up")
}
@@ -89,15 +107,10 @@ struct GroupLinkView: View {
Label("Create link", systemImage: "link.badge.plus")
}
.disabled(creatingLink)
- if creatingLink {
- ProgressView()
- .scaleEffect(2)
- .frame(maxWidth: .infinity)
- }
}
} header: {
- if let groupLink, groupLink.connShortLink != nil {
- ToggleShortLinkHeader(text: Text(""), link: groupLink, short: $showShortLink)
+ if let groupLink, groupLink.connLinkContact.connShortLink != nil {
+ ToggleShortLinkHeader(text: Text(""), link: groupLink.connLinkContact, short: $showShortLink)
}
}
.alert(item: $alert) { alert in
@@ -124,7 +137,7 @@ struct GroupLinkView: View {
.onChange(of: groupLinkMemberRole) { _ in
Task {
do {
- _ = try await apiGroupLinkMemberRole(groupId, memberRole: groupLinkMemberRole)
+ groupLink = try await apiGroupLinkMemberRole(groupId, memberRole: groupLinkMemberRole)
} catch let error {
let a = getErrorAlert(error, "Error updating group link")
alert = .error(title: a.title, error: a.message)
@@ -145,10 +158,10 @@ struct GroupLinkView: View {
Task {
do {
creatingLink = true
- let link = try await apiCreateGroupLink(groupId)
+ let gLink = try await apiCreateGroupLink(groupId)
await MainActor.run {
creatingLink = false
- (groupLink, groupLinkMemberRole) = link
+ groupLink = gLink
}
} catch let error {
logger.error("GroupLinkView apiCreateGroupLink: \(responseError(error))")
@@ -160,12 +173,61 @@ struct GroupLinkView: View {
}
}
}
+
+ private func upgradeAndShareLinkAlert(groupLink: GroupLink? = nil) {
+ showAlert(
+ NSLocalizedString("Upgrade group link?", comment: "alert message"),
+ message: NSLocalizedString("The link will be short, and group profile will be shared via the link.", comment: "alert message"),
+ actions: {
+ var actions = [UIAlertAction(title: NSLocalizedString("Upgrade", comment: "alert button"), style: .default) { _ in
+ addShortLink(shareOnCompletion: groupLink != nil)
+ }]
+ if let groupLink {
+ actions.append(UIAlertAction(title: NSLocalizedString("Share old link", comment: "alert button"), style: .default) { _ in
+ groupLink.shareAddress(short: showShortLink)
+ })
+ }
+ actions.append(cancelAlertAction)
+ return actions
+ }
+ )
+ }
+
+ private func addShortLink(shareOnCompletion: Bool = false) {
+ Task {
+ do {
+ creatingLink = true
+ let gLink = try await apiAddGroupShortLink(groupId)
+ await MainActor.run {
+ creatingLink = false
+ groupLink = gLink
+ if shareOnCompletion, let gLink {
+ gLink.shareAddress(short: showShortLink)
+ }
+ }
+ } catch let error {
+ logger.error("apiAddGroupShortLink: \(responseError(error))")
+ await MainActor.run {
+ creatingLink = false
+ let a = getErrorAlert(error, "Error adding short link")
+ alert = .error(title: a.title, error: a.message)
+ }
+ }
+ }
+ }
}
struct GroupLinkView_Previews: PreviewProvider {
static var previews: some View {
- @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
+ @State var groupLink: GroupLink? = GroupLink(
+ userContactLinkId: 1,
+ connLinkContact: 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),
+ shortLinkDataSet: false,
+ shortLinkLargeDataSet: false,
+ groupLinkId: "abc",
+ acceptMemberRole: .member
+ )
+ @State var noGroupLink: GroupLink? = nil
return Group {
GroupLinkView(groupId: 1, groupLink: $groupLink, groupLinkMemberRole: Binding.constant(.member))
@@ -173,4 +235,3 @@ struct GroupLinkView_Previews: PreviewProvider {
}
}
}
-
diff --git a/apps/ios/Shared/Views/Chat/Group/GroupMemberInfoView.swift b/apps/ios/Shared/Views/Chat/Group/GroupMemberInfoView.swift
index 79ad242366..2298af614e 100644
--- a/apps/ios/Shared/Views/Chat/Group/GroupMemberInfoView.swift
+++ b/apps/ios/Shared/Views/Chat/Group/GroupMemberInfoView.swift
@@ -16,7 +16,9 @@ struct GroupMemberInfoView: View {
@State var groupInfo: GroupInfo
@ObservedObject var chat: Chat
@ObservedObject var groupMember: GMember
+ @Binding var scrollToItemId: ChatItem.ID?
var navigation: Bool = false
+ var openedFromSupportChat: Bool = false
@State private var connectionStats: ConnectionStats? = nil
@State private var connectionCode: String? = nil
@State private var connectionLoaded: Bool = false
@@ -25,7 +27,6 @@ struct GroupMemberInfoView: View {
@State private var knownContactConnectionStats: ConnectionStats? = nil
@State private var newRole: GroupMemberRole = .member
@State private var alert: GroupMemberInfoViewAlert?
- @State private var sheet: PlanAndConnectActionSheet?
@AppStorage(DEFAULT_DEVELOPER_TOOLS) private var developerTools = false
@State private var justOpened = true
@State private var progressIndicator = false
@@ -40,7 +41,6 @@ struct GroupMemberInfoView: View {
case switchAddressAlert
case abortSwitchAddressAlert
case syncConnectionForceAlert
- case planAndConnectAlert(alert: PlanAndConnectAlert)
case queueInfo(info: String)
case someAlert(alert: SomeAlert)
case error(title: LocalizedStringKey, error: LocalizedStringKey?)
@@ -56,7 +56,6 @@ struct GroupMemberInfoView: View {
case .switchAddressAlert: return "switchAddressAlert"
case .abortSwitchAddressAlert: return "abortSwitchAddressAlert"
case .syncConnectionForceAlert: return "syncConnectionForceAlert"
- case let .planAndConnectAlert(alert): return "planAndConnectAlert \(alert.id)"
case let .queueInfo(info): return "queueInfo \(info)"
case let .someAlert(alert): return "someAlert \(alert.id)"
case let .error(title, _): return "error \(title)"
@@ -103,6 +102,11 @@ struct GroupMemberInfoView: View {
if member.memberActive {
Section {
+ if !openedFromSupportChat
+ && groupInfo.membership.memberRole >= .moderator
+ && (member.memberRole < .moderator || member.supportChat != nil) {
+ MemberInfoSupportChatNavLink(groupInfo: groupInfo, member: groupMember, scrollToItemId: $scrollToItemId)
+ }
if let code = connectionCode { verifyCodeButton(code) }
if let connStats = connectionStats,
connStats.ratchetSyncAllowed {
@@ -178,7 +182,7 @@ struct GroupMemberInfoView: View {
}
}
- if groupInfo.membership.memberRole >= .admin {
+ if groupInfo.membership.memberRole >= .moderator {
adminDestructiveSection(member)
} else {
nonAdminBlockSection(member)
@@ -195,8 +199,9 @@ struct GroupMemberInfoView: View {
Button ("Debug delivery") {
Task {
do {
- let info = queueInfoText(try await apiGroupMemberQueueInfo(groupInfo.apiId, member.groupMemberId))
- await MainActor.run { alert = .queueInfo(info: info) }
+ if let info = try await apiGroupMemberQueueInfo(groupInfo.apiId, member.groupMemberId) {
+ await MainActor.run { alert = .queueInfo(info: queueInfoText(info)) }
+ }
} catch let e {
logger.error("apiContactQueueInfo error: \(responseError(e))")
let a = getErrorAlert(e, "Error")
@@ -265,20 +270,18 @@ struct GroupMemberInfoView: View {
case .switchAddressAlert: return switchAddressAlert(switchMemberAddress)
case .abortSwitchAddressAlert: return abortSwitchAddressAlert(abortSwitchMemberAddress)
case .syncConnectionForceAlert: return syncConnectionForceAlert({ syncMemberConnection(force: true) })
- case let .planAndConnectAlert(alert): return planAndConnectAlert(alert, dismiss: true)
case let .queueInfo(info): return queueInfoAlert(info)
case let .someAlert(a): return a.alert
case let .error(title, error): return mkAlert(title: title, message: error)
}
}
- .actionSheet(item: $sheet) { s in planAndConnectActionSheet(s, dismiss: true) }
if progressIndicator {
ProgressView().scaleEffect(2)
}
}
.onChange(of: chat.chatInfo) { c in
- if case let .group(gI) = chat.chatInfo {
+ if case let .group(gI, _) = chat.chatInfo {
groupInfo = gI
}
}
@@ -345,10 +348,8 @@ struct GroupMemberInfoView: View {
Button {
planAndConnect(
contactLink,
- showAlert: { alert = .planAndConnectAlert(alert: $0) },
- showActionSheet: { sheet = $0 },
- dismiss: true,
- incognito: nil
+ theme: theme,
+ dismiss: true
)
} label: {
Label("Connect", systemImage: "link")
@@ -445,35 +446,70 @@ struct GroupMemberInfoView: View {
MemberProfileImage(mem, size: 192, color: Color(uiColor: .tertiarySystemFill))
.padding(.top, 12)
.padding()
+ // show alias if set, alias cannot be edited in this view
+ let displayName = mem.displayName.trimmingCharacters(in: .whitespacesAndNewlines)
+ let fullName = mem.fullName.trimmingCharacters(in: .whitespacesAndNewlines)
if mem.verified {
(
Text(Image(systemName: "checkmark.shield"))
.foregroundColor(theme.colors.secondary)
.font(.title2)
+ textSpace
- + Text(mem.displayName)
+ + Text(displayName)
.font(.largeTitle)
)
.multilineTextAlignment(.center)
.lineLimit(2)
.padding(.bottom, 2)
} else {
- Text(mem.displayName)
+ Text(displayName)
.font(.largeTitle)
.multilineTextAlignment(.center)
.lineLimit(2)
.padding(.bottom, 2)
}
- if mem.fullName != "" && mem.fullName != mem.displayName {
+ if fullName != "" && fullName != displayName && fullName != mem.memberProfile.displayName.trimmingCharacters(in: .whitespacesAndNewlines) {
Text(mem.fullName)
.font(.title2)
.multilineTextAlignment(.center)
+ .lineLimit(3)
+ .padding(.bottom, 2)
+ }
+ if let descr = mem.memberProfile.shortDescr?.trimmingCharacters(in: .whitespacesAndNewlines), descr != "" {
+ Text(descr)
+ .font(.subheadline)
+ .multilineTextAlignment(.center)
.lineLimit(4)
}
}
.frame(maxWidth: .infinity, alignment: .center)
}
+ struct MemberInfoSupportChatNavLink: View {
+ @EnvironmentObject var theme: AppTheme
+ var groupInfo: GroupInfo
+ var member: GMember
+ @Binding var scrollToItemId: ChatItem.ID?
+ @State private var navLinkActive = false
+
+ var body: some View {
+ let scopeInfo: GroupChatScopeInfo = .memberSupport(groupMember_: member.wrapped)
+ NavigationLink(isActive: $navLinkActive) {
+ SecondaryChatView(
+ chat: Chat(chatInfo: .group(groupInfo: groupInfo, groupChatScope: scopeInfo), chatItems: [], chatStats: ChatStats()),
+ scrollToItemId: $scrollToItemId
+ )
+ } label: {
+ Label("Chat with member", systemImage: "flag")
+ }
+ .onChange(of: navLinkActive) { active in
+ if active {
+ ItemsModel.loadSecondaryChat(groupInfo.id, chatFilter: .groupChatScopeContext(groupScopeInfo: scopeInfo))
+ }
+ }
+ }
+ }
+
private func verifyCodeButton(_ code: String) -> some View {
let member = groupMember.wrapped
return NavigationLink {
@@ -610,10 +646,11 @@ struct GroupMemberInfoView: View {
primaryButton: .destructive(Text("Remove")) {
Task {
do {
- let updatedMembers = try await apiRemoveMembers(groupInfo.groupId, [mem.groupMemberId])
+ let (updatedGroupInfo, updatedMembers) = try await apiRemoveMembers(groupInfo.groupId, [mem.groupMemberId])
await MainActor.run {
+ chatModel.updateGroup(updatedGroupInfo)
updatedMembers.forEach { updatedMember in
- _ = chatModel.upsertGroupMember(groupInfo, updatedMember)
+ _ = chatModel.upsertGroupMember(updatedGroupInfo, updatedMember)
}
dismiss()
}
@@ -821,7 +858,8 @@ struct GroupMemberInfoView_Previews: PreviewProvider {
GroupMemberInfoView(
groupInfo: GroupInfo.sampleData,
chat: Chat.sampleData,
- groupMember: GMember.sampleData
+ groupMember: GMember.sampleData,
+ scrollToItemId: Binding.constant(nil)
)
}
}
diff --git a/apps/ios/Shared/Views/Chat/Group/GroupMentions.swift b/apps/ios/Shared/Views/Chat/Group/GroupMentions.swift
index 9bb4a0cc35..cdbed7fe30 100644
--- a/apps/ios/Shared/Views/Chat/Group/GroupMentions.swift
+++ b/apps/ios/Shared/Views/Chat/Group/GroupMentions.swift
@@ -17,6 +17,7 @@ let MAX_VISIBLE_MEMBER_ROWS: CGFloat = 4.8
struct GroupMentionsView: View {
@EnvironmentObject var m: ChatModel
@EnvironmentObject var theme: AppTheme
+ var im: ItemsModel
var groupInfo: GroupInfo
@Binding var composeState: ComposeState
@Binding var selectedRange: NSRange
@@ -66,7 +67,7 @@ struct GroupMentionsView: View {
}
}
.frame(maxHeight: MEMBER_ROW_SIZE * min(MAX_VISIBLE_MEMBER_ROWS, CGFloat(filtered.count)))
- .background(Color(UIColor.systemBackground))
+ .background(theme.colors.background)
if #available(iOS 16.0, *) {
scroll.scrollDismissesKeyboard(.never)
@@ -93,12 +94,33 @@ struct GroupMentionsView: View {
currentMessage = composeState.message
}
}
-
+
+ func contextMemberFilter(_ member: GroupMember) -> Bool {
+ switch im.secondaryIMFilter {
+ case nil:
+ return true
+ case let .groupChatScopeContext(groupScopeInfo):
+ switch (groupScopeInfo) {
+ case let .memberSupport(groupMember_):
+ if let scopeMember = groupMember_ {
+ return member.memberRole >= .moderator || member.groupMemberId == scopeMember.groupMemberId
+ } else {
+ return member.memberRole >= .moderator
+ }
+ case .reports:
+ return false
+ }
+ case .msgContentTagContext:
+ return false
+ }
+ }
+
private func filteredMembers() -> [GMember] {
let s = mentionName.lowercased()
- return s.isEmpty
- ? sortedMembers
- : sortedMembers.filter { $0.wrapped.localAliasAndFullName.localizedLowercase.contains(s) }
+ return sortedMembers.filter {
+ contextMemberFilter($0.wrapped)
+ && (s.isEmpty || $0.wrapped.localAliasAndFullName.localizedLowercase.contains(s))
+ }
}
private func messageChanged(_ msg: String, _ parsedMsg: [FormattedText], _ range: NSRange) {
diff --git a/apps/ios/Shared/Views/Chat/Group/GroupPreferencesView.swift b/apps/ios/Shared/Views/Chat/Group/GroupPreferencesView.swift
index ed39c401ce..55b1dc6d2e 100644
--- a/apps/ios/Shared/Views/Chat/Group/GroupPreferencesView.swift
+++ b/apps/ios/Shared/Views/Chat/Group/GroupPreferencesView.swift
@@ -30,6 +30,14 @@ struct GroupPreferencesView: View {
let saveText: LocalizedStringKey = creatingGroup ? "Save" : "Save and notify group members"
VStack {
List {
+ Section {
+ MemberAdmissionButton(
+ groupInfo: $groupInfo,
+ admission: groupInfo.groupProfile.memberAdmission_,
+ currentAdmission: groupInfo.groupProfile.memberAdmission_,
+ creatingGroup: creatingGroup
+ )
+ }
featureSection(.timedMessages, $preferences.timedMessages.enable)
featureSection(.fullDelete, $preferences.fullDelete.enable)
featureSection(.directMessages, $preferences.directMessages.enable, $preferences.directMessages.role)
@@ -140,6 +148,66 @@ struct GroupPreferencesView: View {
}
}
+struct MemberAdmissionButton: View {
+ @Binding var groupInfo: GroupInfo
+ @State var admission: GroupMemberAdmission
+ @State var currentAdmission: GroupMemberAdmission
+ var creatingGroup: Bool = false
+
+ var body: some View {
+ NavigationLink {
+ MemberAdmissionView(
+ groupInfo: $groupInfo,
+ admission: $admission,
+ currentAdmission: currentAdmission,
+ creatingGroup: creatingGroup,
+ saveAdmission: saveAdmission
+ )
+ .navigationBarTitle("Member admission")
+ .modifier(ThemedBackground(grouped: true))
+ .navigationBarTitleDisplayMode(.large)
+ .onDisappear {
+ let saveText = NSLocalizedString(
+ creatingGroup ? "Save" : "Save and notify group members",
+ comment: "alert button"
+ )
+
+ if groupInfo.groupProfile.memberAdmission_ != admission {
+ showAlert(
+ title: NSLocalizedString("Save admission settings?", comment: "alert title"),
+ buttonTitle: saveText,
+ buttonAction: { saveAdmission() },
+ cancelButton: true
+ )
+ }
+ }
+ } label: {
+ if creatingGroup {
+ Text("Set member admission")
+ } else {
+ Label("Member admission", systemImage: "switch.2")
+ }
+ }
+ }
+
+ private func saveAdmission() {
+ Task {
+ do {
+ var gp = groupInfo.groupProfile
+ gp.memberAdmission = admission
+ let gInfo = try await apiUpdateGroup(groupInfo.groupId, gp)
+ await MainActor.run {
+ groupInfo = gInfo
+ ChatModel.shared.updateGroup(gInfo)
+ currentAdmission = admission
+ }
+ } catch {
+ logger.error("MemberAdmissionView apiUpdateGroup error: \(responseError(error))")
+ }
+ }
+ }
+}
+
struct GroupPreferencesView_Previews: PreviewProvider {
static var previews: some View {
GroupPreferencesView(
diff --git a/apps/ios/Shared/Views/Chat/Group/GroupProfileView.swift b/apps/ios/Shared/Views/Chat/Group/GroupProfileView.swift
index 1617edd11f..69587c0152 100644
--- a/apps/ios/Shared/Views/Chat/Group/GroupProfileView.swift
+++ b/apps/ios/Shared/Views/Chat/Group/GroupProfileView.swift
@@ -26,6 +26,8 @@ struct GroupProfileView: View {
@Environment(\.dismiss) var dismiss: DismissAction
@Binding var groupInfo: GroupInfo
@State var groupProfile: GroupProfile
+ @State private var shortDescr: String = ""
+ @State private var currentProfileHash: Int?
@State private var showChooseSource = false
@State private var showImagePicker = false
@State private var showTakePhoto = false
@@ -34,60 +36,54 @@ struct GroupProfileView: View {
@FocusState private var focusDisplayName
var body: some View {
- return VStack(alignment: .leading) {
- Text("Group profile is stored on members' devices, not on the servers.")
- .padding(.vertical)
+ List {
+ EditProfileImage(profileImage: $groupProfile.image, showChooseSource: $showChooseSource)
+ .if(!focusDisplayName) { $0.padding(.top) }
- ZStack(alignment: .center) {
- ZStack(alignment: .topTrailing) {
- profileImageView(groupProfile.image)
- if groupProfile.image != nil {
- Button {
- groupProfile.image = nil
- } label: {
- Image(systemName: "multiply")
- .resizable()
- .aspectRatio(contentMode: .fit)
- .frame(width: 12)
- }
- }
- }
-
- editImageButton { showChooseSource = true }
- }
- .frame(maxWidth: .infinity, alignment: .center)
-
- VStack(alignment: .leading) {
- ZStack(alignment: .topLeading) {
- if !validNewProfileName() {
+ Section {
+ HStack {
+ TextField("Group display name", text: $groupProfile.displayName)
+ .focused($focusDisplayName)
+ if !validNewProfileName {
Button {
alert = .invalidName(validName: mkValidName(groupProfile.displayName))
} label: {
Image(systemName: "exclamationmark.circle").foregroundColor(.red)
}
- } else {
- Image(systemName: "exclamationmark.circle").foregroundColor(.clear)
}
- profileNameTextEdit("Group display name", $groupProfile.displayName)
- .focused($focusDisplayName)
}
- .padding(.bottom)
let fullName = groupInfo.groupProfile.fullName
if fullName != "" && fullName != groupProfile.displayName {
- profileNameTextEdit("Group full name (optional)", $groupProfile.fullName)
- .padding(.bottom)
+ TextField("Group full name (optional)", text: $groupProfile.fullName)
}
- HStack(spacing: 20) {
- Button("Cancel") { dismiss() }
- Button("Save group profile") { saveProfile() }
- .disabled(!canUpdateProfile())
+ HStack {
+ TextField("Short description", text: $shortDescr)
+ if !shortDescrFitsLimit() {
+ Button {
+ showAlert(NSLocalizedString("Description too large", comment: "alert title"))
+ } label: {
+ Image(systemName: "exclamationmark.circle").foregroundColor(.red)
+ }
+ }
}
+ } footer: {
+ Text("Group profile is stored on members' devices, not on the servers.")
}
- .frame(maxWidth: .infinity, minHeight: 120, alignment: .leading)
+ Section {
+ Button("Reset") {
+ groupProfile = groupInfo.groupProfile
+ shortDescr = groupInfo.groupProfile.shortDescr ?? ""
+ currentProfileHash = groupProfile.hashValue
+ }
+ .disabled(
+ currentProfileHash == groupProfile.hashValue &&
+ (groupInfo.groupProfile.shortDescr ?? "") == shortDescr.trimmingCharacters(in: .whitespaces)
+ )
+ Button("Save group profile", action: saveProfile)
+ .disabled(!canUpdateProfile)
+ }
}
- .padding()
- .frame(maxHeight: .infinity, alignment: .top)
.confirmationDialog("Group image", isPresented: $showChooseSource, titleVisibility: .visible) {
Button("Take picture") {
showTakePhoto = true
@@ -95,6 +91,11 @@ struct GroupProfileView: View {
Button("Choose from library") {
showImagePicker = true
}
+ if UIPasteboard.general.hasImages {
+ Button("Paste image") {
+ chosenImage = UIPasteboard.general.image
+ }
+ }
}
.fullScreenCover(isPresented: $showTakePhoto) {
ZStack {
@@ -120,8 +121,21 @@ struct GroupProfileView: View {
}
}
.onAppear {
+ currentProfileHash = groupProfile.hashValue
+ shortDescr = groupInfo.groupProfile.shortDescr ?? ""
DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
- focusDisplayName = true
+ withAnimation { focusDisplayName = true }
+ }
+ }
+ .onDisappear {
+ if canUpdateProfile {
+ showAlert(
+ title: NSLocalizedString("Save group profile?", comment: "alert title"),
+ message: NSLocalizedString("Group profile was changed. If you save it, the updated profile will be sent to group members.", comment: "alert message"),
+ buttonTitle: NSLocalizedString("Save (and notify members)", comment: "alert button"),
+ buttonAction: saveProfile,
+ cancelButton: true
+ )
}
}
.alert(item: $alert) { a in
@@ -135,30 +149,39 @@ struct GroupProfileView: View {
return createInvalidNameAlert(name, $groupProfile.displayName)
}
}
- .contentShape(Rectangle())
- .onTapGesture { hideKeyboard() }
+ .navigationBarTitle("Group profile")
+ .modifier(ThemedBackground(grouped: true))
+ .navigationBarTitleDisplayMode(focusDisplayName ? .inline : .large)
}
- private func canUpdateProfile() -> Bool {
- groupProfile.displayName.trimmingCharacters(in: .whitespaces) != "" && validNewProfileName()
+ private var canUpdateProfile: Bool {
+ (
+ currentProfileHash != groupProfile.hashValue ||
+ (groupProfile.shortDescr ?? "") != shortDescr.trimmingCharacters(in: .whitespaces)
+ ) &&
+ groupProfile.displayName.trimmingCharacters(in: .whitespaces) != "" &&
+ validNewProfileName &&
+ shortDescrFitsLimit()
}
- private func validNewProfileName() -> Bool {
+ private var validNewProfileName: Bool {
groupProfile.displayName == groupInfo.groupProfile.displayName
|| validDisplayName(groupProfile.displayName.trimmingCharacters(in: .whitespaces))
}
- func profileNameTextEdit(_ label: LocalizedStringKey, _ name: Binding) -> some View {
- TextField(label, text: name)
- .padding(.leading, 32)
+ private func shortDescrFitsLimit() -> Bool {
+ chatJsonLength(shortDescr) <= MAX_BIO_LENGTH_BYTES
}
func saveProfile() {
Task {
do {
groupProfile.displayName = groupProfile.displayName.trimmingCharacters(in: .whitespaces)
+ groupProfile.fullName = groupProfile.fullName.trimmingCharacters(in: .whitespaces)
+ groupProfile.shortDescr = shortDescr.trimmingCharacters(in: .whitespaces)
let gInfo = try await apiUpdateGroup(groupInfo.groupId, groupProfile)
await MainActor.run {
+ currentProfileHash = groupProfile.hashValue
groupInfo = gInfo
chatModel.updateGroup(gInfo)
dismiss()
@@ -174,6 +197,9 @@ struct GroupProfileView: View {
struct GroupProfileView_Previews: PreviewProvider {
static var previews: some View {
- GroupProfileView(groupInfo: Binding.constant(GroupInfo.sampleData), groupProfile: GroupProfile.sampleData)
+ GroupProfileView(
+ groupInfo: Binding.constant(GroupInfo.sampleData),
+ groupProfile: GroupProfile.sampleData
+ )
}
}
diff --git a/apps/ios/Shared/Views/Chat/Group/GroupWelcomeView.swift b/apps/ios/Shared/Views/Chat/Group/GroupWelcomeView.swift
index 97bff70efb..f58f2c213d 100644
--- a/apps/ios/Shared/Views/Chat/Group/GroupWelcomeView.swift
+++ b/apps/ios/Shared/Views/Chat/Group/GroupWelcomeView.swift
@@ -59,7 +59,7 @@ struct GroupWelcomeView: View {
}
private func textPreview() -> some View {
- let r = messageText(welcomeText, parseSimpleXMarkdown(welcomeText), sender: nil, mentions: nil, userMemberId: nil, showSecrets: showSecrets, backgroundColor: UIColor(theme.colors.background))
+ let r = markdownText(welcomeText, showSecrets: showSecrets, backgroundColor: theme.colors.background)
return msgTextResultView(r, Text(AttributedString(r.string)), showSecrets: $showSecrets)
.frame(minHeight: 130, alignment: .topLeading)
.frame(maxWidth: .infinity, alignment: .leading)
@@ -157,6 +157,9 @@ struct GroupWelcomeView: View {
struct GroupWelcomeView_Previews: PreviewProvider {
static var previews: some View {
- GroupProfileView(groupInfo: Binding.constant(GroupInfo.sampleData), groupProfile: GroupProfile.sampleData)
+ GroupProfileView(
+ groupInfo: Binding.constant(GroupInfo.sampleData),
+ groupProfile: GroupProfile.sampleData
+ )
}
}
diff --git a/apps/ios/Shared/Views/Chat/Group/MemberAdmissionView.swift b/apps/ios/Shared/Views/Chat/Group/MemberAdmissionView.swift
new file mode 100644
index 0000000000..d80615b5d2
--- /dev/null
+++ b/apps/ios/Shared/Views/Chat/Group/MemberAdmissionView.swift
@@ -0,0 +1,93 @@
+//
+// MemberAdmissionView.swift
+// SimpleX (iOS)
+//
+// Created by spaced4ndy on 28.04.2025.
+// Copyright © 2025 SimpleX Chat. All rights reserved.
+//
+
+import SwiftUI
+import SimpleXChat
+
+private let memberCriterias: [(criteria: MemberCriteria?, text: LocalizedStringKey)] = [
+ (nil, "off"),
+ (.all, "all")
+]
+
+struct MemberAdmissionView: View {
+ @Environment(\.dismiss) var dismiss: DismissAction
+ @EnvironmentObject var chatModel: ChatModel
+ @EnvironmentObject var theme: AppTheme
+ @Binding var groupInfo: GroupInfo
+ @Binding var admission: GroupMemberAdmission
+ var currentAdmission: GroupMemberAdmission
+ let creatingGroup: Bool
+ let saveAdmission: () -> Void
+ @State private var showSaveDialogue = false
+
+ var body: some View {
+ let saveText: LocalizedStringKey = creatingGroup ? "Save" : "Save and notify group members"
+ VStack {
+ List {
+ admissionSection(
+ NSLocalizedString("Review members", comment: "admission stage"),
+ NSLocalizedString("Review members before admitting (\"knocking\").", comment: "admission stage description"),
+ $admission.review
+ )
+
+ if groupInfo.isOwner {
+ Section {
+ Button("Reset") { admission = currentAdmission }
+ Button(saveText) { saveAdmission() }
+ }
+ .disabled(currentAdmission == admission)
+ }
+ }
+ }
+ .modifier(BackButton(disabled: Binding.constant(false)) {
+ if currentAdmission == admission {
+ dismiss()
+ } else {
+ showSaveDialogue = true
+ }
+ })
+ .confirmationDialog("Save admission settings?", isPresented: $showSaveDialogue) {
+ Button(saveText) {
+ saveAdmission()
+ dismiss()
+ }
+ Button("Exit without saving") {
+ admission = currentAdmission
+ dismiss()
+ }
+ }
+ }
+
+ private func admissionSection(_ admissionStageStr: String, _ admissionStageDescrStr: String, _ memberCriteria: Binding) -> some View {
+ Section {
+ if groupInfo.isOwner {
+ Picker(admissionStageStr, selection: memberCriteria) {
+ ForEach(memberCriterias, id: \.criteria) { mc in
+ Text(mc.text)
+ }
+ }
+ .frame(height: 36)
+ } else {
+ infoRow(Text(admissionStageStr), memberCriteria.wrappedValue?.text ?? NSLocalizedString("off", comment: "member criteria value"))
+ }
+ } footer: {
+ Text(admissionStageDescrStr)
+ .foregroundColor(theme.colors.secondary)
+ }
+ }
+}
+
+#Preview {
+ MemberAdmissionView(
+ groupInfo: Binding.constant(GroupInfo.sampleData),
+ admission: Binding.constant(GroupMemberAdmission.sampleData),
+ currentAdmission: GroupMemberAdmission.sampleData,
+ creatingGroup: false,
+ saveAdmission: {}
+ )
+}
diff --git a/apps/ios/Shared/Views/Chat/Group/MemberSupportChatToolbar.swift b/apps/ios/Shared/Views/Chat/Group/MemberSupportChatToolbar.swift
new file mode 100644
index 0000000000..23001e64bf
--- /dev/null
+++ b/apps/ios/Shared/Views/Chat/Group/MemberSupportChatToolbar.swift
@@ -0,0 +1,44 @@
+//
+// MemberSupportChatToolbar.swift
+// SimpleX (iOS)
+//
+// Created by spaced4ndy on 01.05.2025.
+// Copyright © 2025 SimpleX Chat. All rights reserved.
+//
+
+import SwiftUI
+import SimpleXChat
+
+struct MemberSupportChatToolbar: View {
+ @Environment(\.colorScheme) var colorScheme
+ @EnvironmentObject var theme: AppTheme
+ var groupMember: GroupMember
+ var imageSize: CGFloat = 32
+
+ var body: some View {
+ return HStack {
+ MemberProfileImage(groupMember, size: imageSize)
+ .padding(.trailing, 4)
+ let t = Text(groupMember.chatViewName).font(.headline)
+ (groupMember.verified ? memberVerifiedShield + t : t)
+ .lineLimit(1)
+ }
+ .foregroundColor(theme.colors.onBackground)
+ .frame(width: 220)
+ }
+
+ private var memberVerifiedShield: Text {
+ (Text(Image(systemName: "checkmark.shield")) + textSpace)
+ .font(.caption)
+ .foregroundColor(theme.colors.secondary)
+ .baselineOffset(1)
+ .kerning(-2)
+ }
+}
+
+#Preview {
+ MemberSupportChatToolbar(
+ groupMember: GroupMember.sampleData
+ )
+ .environmentObject(CurrentColors.toAppTheme())
+}
diff --git a/apps/ios/Shared/Views/Chat/Group/MemberSupportView.swift b/apps/ios/Shared/Views/Chat/Group/MemberSupportView.swift
new file mode 100644
index 0000000000..75a6840c4e
--- /dev/null
+++ b/apps/ios/Shared/Views/Chat/Group/MemberSupportView.swift
@@ -0,0 +1,297 @@
+//
+// MemberSupportView.swift
+// SimpleX (iOS)
+//
+// Created by spaced4ndy on 28.04.2025.
+// Copyright © 2025 SimpleX Chat. All rights reserved.
+//
+
+import SwiftUI
+import SimpleXChat
+
+struct MemberSupportView: View {
+ @EnvironmentObject var chatModel: ChatModel
+ @EnvironmentObject var theme: AppTheme
+ @State private var searchText: String = ""
+ @FocusState private var searchFocussed
+ var groupInfo: GroupInfo
+ @Binding var scrollToItemId: ChatItem.ID?
+
+ var body: some View {
+ viewBody()
+ .onAppear {
+ Task {
+ await chatModel.loadGroupMembers(groupInfo)
+ }
+ }
+ .toolbar {
+ ToolbarItem(placement: .navigationBarTrailing) {
+ Button {
+ Task {
+ await chatModel.loadGroupMembers(groupInfo)
+ }
+ } label: {
+ Image(systemName: "arrow.clockwise")
+ }
+ }
+ }
+ }
+
+ @ViewBuilder private func viewBody() -> some View {
+ let membersWithChats = sortedMembersWithChats()
+ let s = searchText.trimmingCharacters(in: .whitespaces).localizedLowercase
+ let filteredMembersWithChats = s == ""
+ ? membersWithChats
+ : membersWithChats.filter { $0.wrapped.localAliasAndFullName.localizedLowercase.contains(s) }
+
+ if membersWithChats.isEmpty {
+ Text("No chats with members")
+ .foregroundColor(.secondary)
+ } else {
+ List {
+ searchFieldView(text: $searchText, focussed: $searchFocussed, theme.colors.onBackground, theme.colors.secondary)
+ .padding(.leading, 8)
+ ForEach(filteredMembersWithChats) { memberWithChat in
+ MemberSupportChatNavLink(
+ groupInfo: groupInfo,
+ memberWithChat: memberWithChat,
+ scrollToItemId: $scrollToItemId
+ )
+ }
+ }
+ }
+ }
+
+ struct MemberSupportChatNavLink: View {
+ @EnvironmentObject var chatModel: ChatModel
+ @EnvironmentObject var theme: AppTheme
+ @State private var memberSupportChatNavLinkActive = false
+ var groupInfo: GroupInfo
+ var memberWithChat: GMember
+ @Binding var scrollToItemId: ChatItem.ID?
+
+ var body: some View {
+ ZStack {
+ let scopeInfo: GroupChatScopeInfo = .memberSupport(groupMember_: memberWithChat.wrapped)
+ Button {
+ ItemsModel.loadSecondaryChat(groupInfo.id, chatFilter: .groupChatScopeContext(groupScopeInfo: scopeInfo)) {
+ memberSupportChatNavLinkActive = true
+ }
+ } label: {
+ SupportChatRowView(groupMember: memberWithChat, groupInfo: groupInfo)
+ }
+
+ NavigationLink(isActive: $memberSupportChatNavLinkActive) {
+ SecondaryChatView(
+ chat: Chat(chatInfo: .group(groupInfo: groupInfo, groupChatScope: scopeInfo), chatItems: [], chatStats: ChatStats()),
+ scrollToItemId: $scrollToItemId
+ )
+ } label: {
+ EmptyView()
+ }
+ .frame(width: 1, height: 1)
+ .hidden()
+ }
+ .if(!memberWithChat.wrapped.memberPending && memberWithChat.wrapped.supportChatNotRead) { v in
+ v.swipeActions(edge: .leading, allowsFullSwipe: true) {
+ Button {
+ Task { await markSupportChatRead(groupInfo, memberWithChat.wrapped) }
+ } label: {
+ Label("Read", systemImage: "checkmark")
+ }
+ .tint(theme.colors.primary)
+ }
+ }
+ .swipeActions(edge: .trailing, allowsFullSwipe: true) {
+ if memberWithChat.wrapped.memberPending {
+ Button {
+ showAcceptMemberAlert(groupInfo, memberWithChat.wrapped)
+ } label: {
+ Label("Accept", systemImage: "checkmark")
+ }
+ .tint(theme.colors.primary)
+ } else {
+ Button {
+ showDeleteMemberSupportChatAlert(groupInfo, memberWithChat.wrapped)
+ } label: {
+ Label("Delete", systemImage: "trash")
+ }
+ .tint(.red)
+ }
+ }
+ }
+ }
+
+ func sortedMembersWithChats() -> [GMember] {
+ chatModel.groupMembers
+ .filter {
+ $0.wrapped.supportChat != nil &&
+ $0.wrapped.memberStatus != .memLeft &&
+ $0.wrapped.memberStatus != .memRemoved
+ }
+ .sorted { (m0: GMember, m1: GMember) -> Bool in
+ if m0.wrapped.memberPending != m1.wrapped.memberPending {
+ return m0.wrapped.memberPending
+ }
+
+ let mentions0 = (m0.wrapped.supportChat?.mentions ?? 0) > 0
+ let mentions1 = (m1.wrapped.supportChat?.mentions ?? 0) > 0
+ if mentions0 != mentions1 {
+ return mentions0
+ }
+
+ let attention0 = (m0.wrapped.supportChat?.memberAttention ?? 0) > 0
+ let attention1 = (m1.wrapped.supportChat?.memberAttention ?? 0) > 0
+ if attention0 != attention1 {
+ return attention0
+ }
+
+ let unread0 = (m0.wrapped.supportChat?.unread ?? 0) > 0
+ let unread1 = (m1.wrapped.supportChat?.unread ?? 0) > 0
+ if unread0 != unread1 {
+ return unread0
+ }
+
+ return (m0.wrapped.supportChat?.chatTs ?? .distantPast) > (m1.wrapped.supportChat?.chatTs ?? .distantPast)
+ }
+ }
+
+ private struct SupportChatRowView: View {
+ @EnvironmentObject var chatModel: ChatModel
+ @ObservedObject var groupMember: GMember
+ @EnvironmentObject var theme: AppTheme
+ @Environment(\.dynamicTypeSize) private var userFont: DynamicTypeSize
+ var groupInfo: GroupInfo
+
+ var dynamicChatInfoSize: CGFloat { dynamicSize(userFont).chatInfoSize }
+
+ var body: some View {
+ let member = groupMember.wrapped
+ HStack{
+ MemberProfileImage(member, size: 38)
+ .padding(.trailing, 2)
+ VStack(alignment: .leading) {
+ let t = Text(member.chatViewName).foregroundColor(theme.colors.onBackground)
+ (member.verified ? memberVerifiedShield + t : t)
+ .lineLimit(1)
+ Text(memberStatus(member))
+ .lineLimit(1)
+ .font(.caption)
+ .foregroundColor(theme.colors.secondary)
+ }
+
+ Spacer()
+
+ if member.memberPending {
+ Image(systemName: "flag.fill")
+ .resizable()
+ .scaledToFill()
+ .frame(width: dynamicChatInfoSize * 0.8, height: dynamicChatInfoSize * 0.8)
+ .foregroundColor(theme.colors.primary)
+ }
+ if let supportChat = member.supportChat {
+ SupportChatUnreadIndicator(supportChat: supportChat)
+ }
+ }
+ }
+
+ private func memberStatus(_ member: GroupMember) -> LocalizedStringKey {
+ if member.activeConn?.connDisabled ?? false {
+ return "disabled"
+ } else if member.activeConn?.connInactive ?? false {
+ return "inactive"
+ } else if member.memberPending {
+ return member.memberStatus.text
+ } else {
+ return LocalizedStringKey(member.memberRole.text)
+ }
+ }
+
+ struct SupportChatUnreadIndicator: View {
+ @EnvironmentObject var theme: AppTheme
+ @Environment(\.dynamicTypeSize) private var userFont: DynamicTypeSize
+ var supportChat: GroupSupportChat
+
+ var dynamicChatInfoSize: CGFloat { dynamicSize(userFont).chatInfoSize }
+
+ private var indicatorTint: Color {
+ if supportChat.mentions > 0 || supportChat.memberAttention > 0 {
+ return theme.colors.primary
+ } else {
+ return theme.colors.secondary
+ }
+ }
+
+ var body: some View {
+ HStack(alignment: .center, spacing: 2) {
+ if supportChat.unread > 0 || supportChat.mentions > 0 || supportChat.memberAttention > 0 {
+ if supportChat.mentions > 0 && supportChat.unread > 1 {
+ Text("\(MENTION_START)")
+ .font(userFont <= .xxxLarge ? .body : .callout)
+ .foregroundColor(indicatorTint)
+ .frame(minWidth: dynamicChatInfoSize, minHeight: dynamicChatInfoSize)
+ .cornerRadius(dynamicSize(userFont).unreadCorner)
+ .padding(.bottom, 1)
+ }
+ let singleUnreadIsMention = supportChat.mentions > 0 && supportChat.unread == 1
+ (singleUnreadIsMention ? Text("\(MENTION_START)") : unreadCountText(supportChat.unread))
+ .font(userFont <= .xxxLarge ? .caption : .caption2)
+ .foregroundColor(.white)
+ .padding(.horizontal, dynamicSize(userFont).unreadPadding)
+ .frame(minWidth: dynamicChatInfoSize, minHeight: dynamicChatInfoSize)
+ .background(indicatorTint)
+ .cornerRadius(dynamicSize(userFont).unreadCorner)
+ }
+ }
+ .frame(height: dynamicChatInfoSize)
+ .frame(minWidth: 22)
+ }
+ }
+
+ private var memberVerifiedShield: Text {
+ (Text(Image(systemName: "checkmark.shield")) + textSpace)
+ .font(.caption)
+ .baselineOffset(2)
+ .kerning(-2)
+ .foregroundColor(theme.colors.secondary)
+ }
+ }
+}
+
+func showDeleteMemberSupportChatAlert(_ groupInfo: GroupInfo, _ member: GroupMember) {
+ showAlert(
+ title: NSLocalizedString("Delete chat with member?", comment: "alert title"),
+ buttonTitle: "Delete",
+ buttonAction: { deleteMemberSupportChat(groupInfo, member) },
+ cancelButton: true
+ )
+}
+
+func deleteMemberSupportChat(_ groupInfo: GroupInfo, _ member: GroupMember) {
+ Task {
+ do {
+ let (gInfo, updatedMember) = try await apiDeleteMemberSupportChat(groupInfo.groupId, member.groupMemberId)
+ await MainActor.run {
+ _ = ChatModel.shared.upsertGroupMember(gInfo, updatedMember)
+ ChatModel.shared.updateGroup(gInfo)
+ }
+ // TODO member row doesn't get removed from list (upsertGroupMember correctly sets supportChat to nil) - this repopulates list to fix it
+ await ChatModel.shared.loadGroupMembers(gInfo)
+ } catch let error {
+ logger.error("apiDeleteMemberSupportChat error: \(responseError(error))")
+ await MainActor.run {
+ showAlert(
+ NSLocalizedString("Error deleting chat", comment: "alert title"),
+ message: responseError(error)
+ )
+ }
+ }
+ }
+}
+
+#Preview {
+ MemberSupportView(
+ groupInfo: GroupInfo.sampleData,
+ scrollToItemId: Binding.constant(nil)
+ )
+}
diff --git a/apps/ios/Shared/Views/Chat/Group/SecondaryChatView.swift b/apps/ios/Shared/Views/Chat/Group/SecondaryChatView.swift
new file mode 100644
index 0000000000..e2092f7a24
--- /dev/null
+++ b/apps/ios/Shared/Views/Chat/Group/SecondaryChatView.swift
@@ -0,0 +1,44 @@
+//
+// SecondaryChatView.swift
+// SimpleX (iOS)
+//
+// Created by spaced4ndy on 29.04.2025.
+// Copyright © 2025 SimpleX Chat. All rights reserved.
+//
+
+import SwiftUI
+import SimpleXChat
+
+struct SecondaryChatView: View {
+ @Environment(\.dismiss) var dismiss
+ @EnvironmentObject var chatModel: ChatModel
+ @ObservedObject var chat: Chat
+ @Binding var scrollToItemId: ChatItem.ID?
+
+ var body: some View {
+ if let im = chatModel.secondaryIM {
+ ChatView(
+ chat: chat,
+ im: im,
+ mergedItems: BoxedValue(MergedItems.create(im, [])),
+ floatingButtonModel: FloatingButtonModel(im: im),
+ scrollToItemId: $scrollToItemId
+ )
+ .modifier(BackButton(disabled: Binding.constant(false)) {
+ chatModel.secondaryIM = nil
+ dismiss()
+ })
+ }
+ }
+}
+
+#Preview {
+ SecondaryChatView(
+ chat: Chat(
+ chatInfo: .group(groupInfo: GroupInfo.sampleData, groupChatScope: .memberSupport(groupMember_: GroupMember.sampleData)),
+ chatItems: [],
+ chatStats: ChatStats()
+ ),
+ scrollToItemId: Binding.constant(nil)
+ )
+}
diff --git a/apps/ios/Shared/Views/Chat/SelectableChatItemToolbars.swift b/apps/ios/Shared/Views/Chat/SelectableChatItemToolbars.swift
index 85d6b279c5..4855c3ca8d 100644
--- a/apps/ios/Shared/Views/Chat/SelectableChatItemToolbars.swift
+++ b/apps/ios/Shared/Views/Chat/SelectableChatItemToolbars.swift
@@ -25,7 +25,7 @@ struct SelectedItemsTopToolbar: View {
struct SelectedItemsBottomToolbar: View {
@Environment(\.colorScheme) var colorScheme
@EnvironmentObject var theme: AppTheme
- let chatItems: [ChatItem]
+ let im: ItemsModel
@Binding var selectedChatItems: Set?
var chatInfo: ChatInfo
// Bool - delete for everyone is possible
@@ -75,9 +75,9 @@ struct SelectedItemsBottomToolbar: View {
.resizable()
.scaledToFit()
.frame(width: 20, height: 20, alignment: .center)
- .foregroundColor(!moderateEnabled || deleteCountProhibited ? theme.colors.secondary : .red)
+ .foregroundColor(!moderateEnabled || deleteCountProhibited || im.secondaryIMFilter != nil ? theme.colors.secondary : .red)
}
- .disabled(!moderateEnabled || deleteCountProhibited)
+ .disabled(!moderateEnabled || deleteCountProhibited || im.secondaryIMFilter != nil)
.opacity(canModerate ? 1 : 0)
Spacer()
@@ -88,24 +88,24 @@ struct SelectedItemsBottomToolbar: View {
.resizable()
.scaledToFit()
.frame(width: 20, height: 20, alignment: .center)
- .foregroundColor(!forwardEnabled || forwardCountProhibited ? theme.colors.secondary : theme.colors.primary)
+ .foregroundColor(!forwardEnabled || forwardCountProhibited || im.secondaryIMFilter != nil ? theme.colors.secondary : theme.colors.primary)
}
- .disabled(!forwardEnabled || forwardCountProhibited)
+ .disabled(!forwardEnabled || forwardCountProhibited || im.secondaryIMFilter != nil)
}
.frame(maxHeight: .infinity)
.padding([.leading, .trailing], 12)
}
.onAppear {
- recheckItems(chatInfo, chatItems, selectedChatItems)
+ recheckItems(chatInfo, im.reversedChatItems, selectedChatItems)
}
.onChange(of: chatInfo) { info in
- recheckItems(info, chatItems, selectedChatItems)
+ recheckItems(info, im.reversedChatItems, selectedChatItems)
}
- .onChange(of: chatItems) { items in
+ .onChange(of: im.reversedChatItems) { items in
recheckItems(chatInfo, items, selectedChatItems)
}
.onChange(of: selectedChatItems) { selected in
- recheckItems(chatInfo, chatItems, selected)
+ recheckItems(chatInfo, im.reversedChatItems, selected)
}
.frame(height: 55.5)
.background(.thinMaterial)
@@ -116,7 +116,7 @@ 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 {
+ let groupInfo: GroupInfo? = if case let ChatInfo.group(groupInfo: info, _) = chatInfo {
info
} else {
nil
@@ -145,8 +145,8 @@ struct SelectedItemsBottomToolbar: View {
private func possibleToModerate(_ chatInfo: ChatInfo) -> Bool {
return switch chatInfo {
- case let .group(groupInfo):
- groupInfo.membership.memberRole >= .admin
+ case let .group(groupInfo, _):
+ groupInfo.membership.memberRole >= .moderator
default: false
}
}
diff --git a/apps/ios/Shared/Views/Chat/VerifyCodeView.swift b/apps/ios/Shared/Views/Chat/VerifyCodeView.swift
index 7b01fe0300..373311073a 100644
--- a/apps/ios/Shared/Views/Chat/VerifyCodeView.swift
+++ b/apps/ios/Shared/Views/Chat/VerifyCodeView.swift
@@ -24,85 +24,70 @@ struct VerifyCodeView: View {
}
private func verifyCodeView(_ code: String) -> some View {
- ScrollView {
- let splitCode = splitToParts(code, length: 24)
- VStack(alignment: .leading) {
- Group {
+ let splitCode = splitToParts(code, length: 24)
+ return List {
+ Section {
+ QRCode(uri: code, small: true)
+
+ Text(splitCode)
+ .multilineTextAlignment(.leading)
+ .font(.body.monospaced())
+ .lineLimit(20)
+ .frame(maxWidth: .infinity, alignment: .center)
+ } header: {
+ if connectionVerified {
HStack {
- if connectionVerified {
- Image(systemName: "checkmark.shield")
- .foregroundColor(theme.colors.secondary)
- Text("\(displayName) is verified")
- } else {
- Text("\(displayName) is not verified")
- }
+ Image(systemName: "checkmark.shield").foregroundColor(theme.colors.secondary)
+ Text("\(displayName) is verified").textCase(.none)
}
- .frame(height: 24)
-
- QRCode(uri: code)
- .padding(.horizontal)
-
- Text(splitCode)
- .multilineTextAlignment(.leading)
- .font(.body.monospaced())
- .lineLimit(20)
- .padding(.bottom, 8)
+ } else {
+ Text("\(displayName) is not verified").textCase(.none)
}
- .frame(maxWidth: .infinity, alignment: .center)
-
+ } footer: {
Text("To verify end-to-end encryption with your contact compare (or scan) the code on your devices.")
- .padding(.bottom)
+ }
- Group {
- if connectionVerified {
- Button {
- verifyCode(nil)
- } label: {
- Label("Clear verification", systemImage: "shield")
- }
- .padding()
- } else {
- HStack {
- NavigationLink {
- ScanCodeView(connectionVerified: $connectionVerified, verify: verify)
- .navigationBarTitleDisplayMode(.large)
- .navigationTitle("Scan code")
- .modifier(ThemedBackground())
- } label: {
- Label("Scan code", systemImage: "qrcode")
- }
- .padding()
- Button {
- verifyCode(code) { verified in
- if !verified { showCodeError = true }
- }
- } label: {
- Label("Mark verified", systemImage: "checkmark.shield")
- }
- .padding()
- .alert(isPresented: $showCodeError) {
- Alert(title: Text("Incorrect security code!"))
- }
- }
- }
- }
- .frame(maxWidth: .infinity, alignment: .center)
- }
- .padding()
- .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
- .toolbar {
- ToolbarItem(placement: .navigationBarTrailing) {
+ Section {
+ if connectionVerified {
Button {
- showShareSheet(items: [splitCode])
+ verifyCode(nil)
} label: {
- Image(systemName: "square.and.arrow.up")
+ Label("Clear verification", systemImage: "shield")
+ }
+ } else {
+ NavigationLink {
+ ScanCodeView(connectionVerified: $connectionVerified, verify: verify)
+ .navigationBarTitleDisplayMode(.large)
+ .navigationTitle("Scan code")
+ .modifier(ThemedBackground())
+ } label: {
+ Label("Scan code", systemImage: "qrcode")
+ }
+ Button {
+ verifyCode(code) { verified in
+ if !verified { showCodeError = true }
+ }
+ } label: {
+ Label("Mark verified", systemImage: "checkmark.shield")
+ }
+ .alert(isPresented: $showCodeError) {
+ Alert(title: Text("Incorrect security code!"))
}
}
}
- .onChange(of: connectionVerified) { _ in
- if connectionVerified { dismiss() }
+ }
+ .toolbar {
+ ToolbarItem(placement: .navigationBarTrailing) {
+ Button {
+ showShareSheet(items: [splitCode])
+ } label: {
+ Image(systemName: "square.and.arrow.up")
+ }
}
}
+ .onChange(of: connectionVerified) { _ in
+ if connectionVerified { dismiss() }
+ }
}
private func verifyCode(_ code: String?, _ cb: ((Bool) -> Void)? = nil) {
diff --git a/apps/ios/Shared/Views/ChatList/ChatListNavLink.swift b/apps/ios/Shared/Views/ChatList/ChatListNavLink.swift
index 81d78fbadd..4937bca20e 100644
--- a/apps/ios/Shared/Views/ChatList/ChatListNavLink.swift
+++ b/apps/ios/Shared/Views/ChatList/ChatListNavLink.swift
@@ -66,7 +66,7 @@ struct ChatListNavLink: View {
switch chat.chatInfo {
case let .direct(contact):
contactNavLink(contact)
- case let .group(groupInfo):
+ case let .group(groupInfo, _):
groupNavLink(groupInfo)
case let .local(noteFolder):
noteFolderNavLink(noteFolder)
@@ -92,7 +92,7 @@ struct ChatListNavLink: View {
private func contactNavLink(_ contact: Contact) -> some View {
Group {
- if contact.activeConn == nil && contact.profile.contactLink != nil && contact.active {
+ if contact.isContactCard {
ChatPreviewView(chat: chat, progressByTimeout: Binding.constant(false))
.frameCompat(height: dynamicRowHeight)
.swipeActions(edge: .trailing, allowsFullSwipe: true) {
@@ -122,29 +122,81 @@ struct ChatListNavLink: View {
label: { ChatPreviewView(chat: chat, progressByTimeout: Binding.constant(false)) }
)
.frameCompat(height: dynamicRowHeight)
- .swipeActions(edge: .leading, allowsFullSwipe: true) {
- markReadButton()
- toggleFavoriteButton()
- toggleNtfsButton(chat: chat)
+ .if(!contact.nextAcceptContactRequest) { v in
+ v.swipeActions(edge: .leading, allowsFullSwipe: true) {
+ markReadButton()
+ toggleFavoriteButton()
+ toggleNtfsButton(chat: chat)
+ }
}
.swipeActions(edge: .trailing, allowsFullSwipe: true) {
- tagChatButton(chat)
- if !chat.chatItems.isEmpty {
- clearChatButton()
+ if contact.nextAcceptContactRequest {
+ if let contactRequestId = contact.contactRequestId {
+ Button {
+ Task { await acceptContactRequest(incognito: false, contactRequestId: contactRequestId) }
+ } label: { SwipeLabel(NSLocalizedString("Accept", comment: "swipe action"), systemImage: "checkmark", inverted: oneHandUI) }
+ .tint(theme.colors.primary)
+ if !ChatModel.shared.addressShortLinkDataSet {
+ Button {
+ Task { await acceptContactRequest(incognito: true, contactRequestId: contactRequestId) }
+ } label: {
+ SwipeLabel(NSLocalizedString("Accept incognito", comment: "swipe action"), systemImage: "theatermasks.fill", inverted: oneHandUI)
+ }
+ .tint(.indigo)
+ }
+ Button {
+ AlertManager.shared.showAlert(rejectContactRequestAlert(contactRequestId))
+ } label: {
+ SwipeLabel(NSLocalizedString("Reject", comment: "swipe action"), systemImage: "multiply", inverted: oneHandUI)
+ }
+ .tint(.red)
+ } else if let groupDirectInv = contact.groupDirectInv, !groupDirectInv.memberRemoved {
+ Button {
+ acceptMemberContactRequest(contact)
+ } label: {
+ Label("Accept", systemImage: "checkmark")
+ }
+ .tint(theme.colors.primary)
+ Button {
+ showRejectMemberContactRequestAlert(contact)
+ } label: {
+ Label("Reject", systemImage: "multiply")
+ }
+ .tint(.red)
+ } else {
+ Button {
+ deleteContactDialog(
+ chat,
+ contact,
+ dismissToChatList: false,
+ showAlert: { alert = $0 },
+ showActionSheet: { actionSheet = $0 },
+ showSheetContent: { sheet = $0 }
+ )
+ } label: {
+ deleteLabel
+ }
+ .tint(.red)
+ }
+ } else {
+ tagChatButton(chat)
+ if !chat.chatItems.isEmpty {
+ clearChatButton()
+ }
+ Button {
+ deleteContactDialog(
+ chat,
+ contact,
+ dismissToChatList: false,
+ showAlert: { alert = $0 },
+ showActionSheet: { actionSheet = $0 },
+ showSheetContent: { sheet = $0 }
+ )
+ } label: {
+ deleteLabel
+ }
+ .tint(.red)
}
- Button {
- deleteContactDialog(
- chat,
- contact,
- dismissToChatList: false,
- showAlert: { alert = $0 },
- showActionSheet: { actionSheet = $0 },
- showSheetContent: { sheet = $0 }
- )
- } label: {
- deleteLabel
- }
- .tint(.red)
}
}
}
@@ -189,7 +241,7 @@ struct ChatListNavLink: View {
}
.swipeActions(edge: .trailing) {
tagChatButton(chat)
- if (groupInfo.membership.memberCurrent) {
+ if (groupInfo.membership.memberCurrentOrPending) {
leaveGroupChatButton(groupInfo)
}
if groupInfo.canDelete {
@@ -214,7 +266,7 @@ struct ChatListNavLink: View {
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 showLeaveGroup = groupInfo.membership.memberCurrentOrPending
let totalNumberOfButtons = 1 + (showReportsButton ? 1 : 0) + (showClearButton ? 1 : 0) + (showDeleteGroup ? 1 : 0) + (showLeaveGroup ? 1 : 0)
if showClearButton && totalNumberOfButtons <= 3 {
@@ -276,7 +328,7 @@ struct ChatListNavLink: View {
@ViewBuilder private func markReadButton() -> some View {
if chat.chatStats.unreadCount > 0 || chat.chatStats.unreadChat {
Button {
- Task { await markChatRead(chat) }
+ Task { await markChatRead(ItemsModel.shared, chat) }
} label: {
SwipeLabel(NSLocalizedString("Read", comment: "swipe action"), systemImage: "checkmark", inverted: oneHandUI)
}
@@ -436,28 +488,32 @@ struct ChatListNavLink: View {
.frameCompat(height: dynamicRowHeight)
.swipeActions(edge: .trailing, allowsFullSwipe: true) {
Button {
- Task { await acceptContactRequest(incognito: false, contactRequest: contactRequest) }
+ Task { await acceptContactRequest(incognito: false, contactRequestId: contactRequest.apiId) }
} label: { SwipeLabel(NSLocalizedString("Accept", comment: "swipe action"), systemImage: "checkmark", inverted: oneHandUI) }
.tint(theme.colors.primary)
- Button {
- Task { await acceptContactRequest(incognito: true, contactRequest: contactRequest) }
- } label: {
- SwipeLabel(NSLocalizedString("Accept incognito", comment: "swipe action"), systemImage: "theatermasks.fill", inverted: oneHandUI)
+ if !ChatModel.shared.addressShortLinkDataSet {
+ Button {
+ Task { await acceptContactRequest(incognito: true, contactRequestId: contactRequest.apiId) }
+ } label: {
+ SwipeLabel(NSLocalizedString("Accept incognito", comment: "swipe action"), systemImage: "theatermasks.fill", inverted: oneHandUI)
+ }
+ .tint(.indigo)
}
- .tint(.indigo)
Button {
- AlertManager.shared.showAlert(rejectContactRequestAlert(contactRequest))
+ AlertManager.shared.showAlert(rejectContactRequestAlert(contactRequest.apiId))
} label: {
- SwipeLabel(NSLocalizedString("Reject", comment: "swipe action"), systemImage: "multiply.fill", inverted: oneHandUI)
+ SwipeLabel(NSLocalizedString("Reject", comment: "swipe action"), systemImage: "multiply", inverted: oneHandUI)
}
.tint(.red)
}
.contentShape(Rectangle())
.onTapGesture { showContactRequestDialog = true }
.confirmationDialog("Accept connection request?", isPresented: $showContactRequestDialog, titleVisibility: .visible) {
- Button("Accept") { Task { await acceptContactRequest(incognito: false, contactRequest: contactRequest) } }
- Button("Accept incognito") { Task { await acceptContactRequest(incognito: true, contactRequest: contactRequest) } }
- Button("Reject (sender NOT notified)", role: .destructive) { Task { await rejectContactRequest(contactRequest) } }
+ Button("Accept") { Task { await acceptContactRequest(incognito: false, contactRequestId: contactRequest.apiId) } }
+ if !ChatModel.shared.addressShortLinkDataSet {
+ Button("Accept incognito") { Task { await acceptContactRequest(incognito: true, contactRequestId: contactRequest.apiId) } }
+ }
+ Button("Reject (sender NOT notified)", role: .destructive) { Task { await rejectContactRequest(contactRequest.apiId) } }
}
}
@@ -482,12 +538,10 @@ struct ChatListNavLink: View {
.tint(theme.colors.primary)
}
.appSheet(isPresented: $showContactConnectionInfo) {
- Group {
- if case let .contactConnection(contactConnection) = chat.chatInfo {
- ContactConnectionInfo(contactConnection: contactConnection)
- .environment(\EnvironmentValues.refresh as! WritableKeyPath, nil)
- .modifier(ThemedBackground(grouped: true))
- }
+ if case let .contactConnection(contactConnection) = chat.chatInfo {
+ ContactConnectionInfo(contactConnection: contactConnection)
+ .environment(\EnvironmentValues.refresh as! WritableKeyPath, nil)
+ .modifier(ThemedBackground(grouped: true))
}
}
.contentShape(Rectangle())
@@ -621,12 +675,12 @@ extension View {
}
}
-func rejectContactRequestAlert(_ contactRequest: UserContactRequest) -> Alert {
+func rejectContactRequestAlert(_ contactRequestId: Int64) -> Alert {
Alert(
title: Text("Reject contact request"),
message: Text("The sender will NOT be notified"),
primaryButton: .destructive(Text("Reject")) {
- Task { await rejectContactRequest(contactRequest) }
+ Task { await rejectContactRequest(contactRequestId) }
},
secondaryButton: .cancel()
)
@@ -676,16 +730,17 @@ func joinGroup(_ groupId: Int64, _ onComplete: @escaping () async -> Void) {
Task {
logger.debug("joinGroup")
do {
- let r = try await apiJoinGroup(groupId)
- switch r {
- case let .joined(groupInfo):
- await MainActor.run { ChatModel.shared.updateGroup(groupInfo) }
- case .invitationRemoved:
- AlertManager.shared.showAlertMsg(title: "Invitation expired!", message: "Group invitation is no longer valid, it was removed by sender.")
- await deleteGroup()
- case .groupNotFound:
- AlertManager.shared.showAlertMsg(title: "No group!", message: "This group no longer exists.")
- await deleteGroup()
+ if let r = try await apiJoinGroup(groupId) {
+ switch r {
+ case let .joined(groupInfo):
+ await MainActor.run { ChatModel.shared.updateGroup(groupInfo) }
+ case .invitationRemoved:
+ AlertManager.shared.showAlertMsg(title: "Invitation expired!", message: "Group invitation is no longer valid, it was removed by sender.")
+ await deleteGroup()
+ case .groupNotFound:
+ AlertManager.shared.showAlertMsg(title: "No group!", message: "This group no longer exists.")
+ await deleteGroup()
+ }
}
await onComplete()
} catch let error {
diff --git a/apps/ios/Shared/Views/ChatList/ChatListView.swift b/apps/ios/Shared/Views/ChatList/ChatListView.swift
index f34f930c6f..0450bd439c 100644
--- a/apps/ios/Shared/Views/ChatList/ChatListView.swift
+++ b/apps/ios/Shared/Views/ChatList/ChatListView.swift
@@ -137,6 +137,7 @@ struct UserPickerSheetView: View {
struct ChatListView: View {
@EnvironmentObject var chatModel: ChatModel
+ @StateObject private var connectProgressManager = ConnectProgressManager.shared
@EnvironmentObject var theme: AppTheme
@Binding var activeUserPickerSheet: UserPickerSheet?
@State private var searchMode = false
@@ -148,6 +149,7 @@ struct ChatListView: View {
@State private var userPickerShown: Bool = false
@State private var sheet: SomeSheet? = nil
@StateObject private var chatTagsModel = ChatTagsModel.shared
+ @State private var scrollToItemId: ChatItem.ID? = nil
// iOS 15 is required it to show/hide toolbar while chat is hidden/visible
@State private var viewOnScreen = true
@@ -446,7 +448,14 @@ struct ChatListView: View {
@ViewBuilder private func chatView() -> some View {
if let chatId = chatModel.chatId, let chat = chatModel.getChat(chatId) {
- ChatView(chat: chat)
+ let im = ItemsModel.shared
+ ChatView(
+ chat: chat,
+ im: im,
+ mergedItems: BoxedValue(MergedItems.create(im, [])),
+ floatingButtonModel: FloatingButtonModel(im: im),
+ scrollToItemId: $scrollToItemId
+ )
}
}
@@ -564,6 +573,7 @@ struct ChatListSearchBar: View {
@EnvironmentObject var m: ChatModel
@EnvironmentObject var theme: AppTheme
@EnvironmentObject var chatTagsModel: ChatTagsModel
+ @StateObject private var connectProgressManager = ConnectProgressManager.shared
@Binding var searchMode: Bool
@FocusState.Binding var searchFocussed: Bool
@Binding var searchText: String
@@ -571,8 +581,6 @@ struct ChatListSearchBar: View {
@Binding var searchChatFilteredBySimplexLink: String?
@Binding var parentSheet: SomeSheet?
@State private var ignoreSearchTextChange = false
- @State private var alert: PlanAndConnectAlert?
- @State private var sheet: PlanAndConnectActionSheet?
var body: some View {
VStack(spacing: 12) {
@@ -585,6 +593,9 @@ struct ChatListSearchBar: View {
.disabled(searchShowingSimplexLink)
.focused($searchFocussed)
.frame(maxWidth: .infinity)
+ if connectProgressManager.showConnectProgress != nil {
+ ProgressView()
+ }
if !searchText.isEmpty {
Image(systemName: "xmark.circle.fill")
.onTapGesture {
@@ -618,7 +629,7 @@ struct ChatListSearchBar: View {
} else {
if let link = strHasSingleSimplexLink(t.trimmingCharacters(in: .whitespaces)) { // if SimpleX link is pasted, show connection dialogue
searchFocussed = false
- if case let .simplexLink(linkType, _, smpHosts) = link.format {
+ if case let .simplexLink(_, linkType, _, smpHosts) = link.format {
ignoreSearchTextChange = true
searchText = simplexLinkText(linkType, smpHosts)
}
@@ -628,6 +639,8 @@ struct ChatListSearchBar: View {
} else {
if t != "" { // if some other text is pasted, enter search mode
searchFocussed = true
+ } else {
+ ConnectProgressManager.shared.cancelConnectProgress()
}
searchShowingSimplexLink = false
searchChatFilteredBySimplexLink = nil
@@ -637,12 +650,6 @@ struct ChatListSearchBar: View {
.onChange(of: chatTagsModel.activeFilter) { _ in
searchText = ""
}
- .alert(item: $alert) { a in
- planAndConnectAlert(a, dismiss: true, cleanup: { searchText = "" })
- }
- .actionSheet(item: $sheet) { s in
- planAndConnectActionSheet(s, dismiss: true, cleanup: { searchText = "" })
- }
}
private func toggleFilterButton() -> some View {
@@ -668,10 +675,12 @@ struct ChatListSearchBar: View {
private func connect(_ link: String) {
planAndConnect(
link,
- showAlert: { alert = $0 },
- showActionSheet: { sheet = $0 },
+ theme: theme,
dismiss: false,
- incognito: nil,
+ cleanup: {
+ searchText = ""
+ searchFocussed = false
+ },
filterKnownContact: { searchChatFilteredBySimplexLink = $0.id },
filterKnownGroup: { searchChatFilteredBySimplexLink = $0.id }
)
@@ -889,15 +898,15 @@ func presetTagMatchesChat(_ tag: PresetTag, _ chatInfo: ChatInfo, _ chatStats: C
chatInfo.chatSettings?.favorite == true
case .contacts:
switch chatInfo {
- case let .direct(contact): !(contact.activeConn == nil && contact.profile.contactLink != nil && contact.active) && !contact.chatDeleted
+ case let .direct(contact): !contact.isContactCard && !contact.chatDeleted
case .contactRequest: true
case .contactConnection: true
- case let .group(groupInfo): groupInfo.businessChat?.chatType == .customer
+ case let .group(groupInfo, _): groupInfo.businessChat?.chatType == .customer
default: false
}
case .groups:
switch chatInfo {
- case let .group(groupInfo): groupInfo.businessChat == nil
+ case let .group(groupInfo, _): groupInfo.businessChat == nil
default: false
}
case .business:
diff --git a/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift b/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift
index b8c8233e6e..c56d947a5a 100644
--- a/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift
+++ b/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift
@@ -24,7 +24,7 @@ struct ChatPreviewView: View {
var dynamicMediaSize: CGFloat { dynamicSize(userFont).mediaSize }
var dynamicChatInfoSize: CGFloat { dynamicSize(userFont).chatInfoSize }
-
+
var body: some View {
let cItem = chat.chatItems.last
return ZStack {
@@ -35,7 +35,7 @@ struct ChatPreviewView: View {
.padding([.bottom, .trailing], 1)
}
.padding(.leading, 4)
-
+
let chatTs = if let cItem {
cItem.meta.itemTs
} else {
@@ -53,7 +53,7 @@ struct ChatPreviewView: View {
}
.padding(.bottom, 4)
.padding(.horizontal, 8)
-
+
ZStack(alignment: .topTrailing) {
let chat = activeContentPreview?.chat ?? chat
let ci = activeContentPreview?.ci ?? chat.chatItems.last
@@ -88,14 +88,14 @@ struct ChatPreviewView: View {
}
.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)
@@ -141,7 +141,7 @@ struct ChatPreviewView: View {
} else {
EmptyView()
}
- case let .group(groupInfo):
+ case let .group(groupInfo, _):
switch (groupInfo.membership.memberStatus) {
case .memRejected: inactiveIcon()
case .memLeft: inactiveIcon()
@@ -164,14 +164,26 @@ struct ChatPreviewView: View {
let t = Text(chat.chatInfo.chatViewName).font(.title3).fontWeight(.bold)
switch chat.chatInfo {
case let .direct(contact):
- previewTitle(contact.verified == true ? verifiedIcon + t : t).foregroundColor(deleting ? Color.secondary : nil)
- case let .group(groupInfo):
- 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, .memRejected: v.foregroundColor(theme.colors.secondary)
- default: if deleting { v.foregroundColor(theme.colors.secondary) } else { v }
+ let color =
+ deleting
+ ? theme.colors.secondary
+ : (contact.nextAcceptContactRequest && !(contact.groupDirectInv?.memberRemoved ?? false)) || contact.sendMsgToConnect
+ ? theme.colors.primary
+ : !contact.sndReady
+ ? theme.colors.secondary
+ : nil
+ previewTitle(contact.verified == true ? verifiedIcon + t : t).foregroundColor(color)
+ case let .group(groupInfo, _):
+ let color = if deleting {
+ theme.colors.secondary
+ } else {
+ switch (groupInfo.membership.memberStatus) {
+ case .memInvited: chat.chatInfo.incognito ? .indigo : theme.colors.primary
+ case .memAccepted, .memRejected: theme.colors.secondary
+ default: groupInfo.nextConnectPrepared ? theme.colors.primary : nil
+ }
}
+ previewTitle(t).foregroundColor(color)
default: previewTitle(t)
}
}
@@ -251,7 +263,7 @@ struct ChatPreviewView: View {
Color.clear.frame(width: 0)
}
}
-
+
private func mentionColor(_ chat: Chat) -> Color {
switch chat.chatInfo.chatSettings?.enableNtfs {
case .all: theme.colors.primary
@@ -262,7 +274,7 @@ struct ChatPreviewView: View {
private func messageDraft(_ draft: ComposeState) -> (Text, Bool) {
let msg = draft.message
- let r = messageText(msg, parseSimpleXMarkdown(msg), sender: nil, preview: true, mentions: draft.mentions, userMemberId: nil, showSecrets: nil, backgroundColor: UIColor(theme.colors.background))
+ let r = markdownText(msg, preview: true, mentions: draft.mentions, backgroundColor: theme.colors.background)
return (image("rectangle.and.pencil.and.ellipsis", color: theme.colors.primary)
+ attachment()
+ Text(AttributedString(r.string)),
@@ -285,7 +297,7 @@ struct ChatPreviewView: View {
func chatItemPreview(_ cItem: ChatItem) -> (Text, Bool) {
let itemText = cItem.meta.itemDeleted == nil ? cItem.text : markedDeletedText()
let itemFormattedText = cItem.meta.itemDeleted == nil ? cItem.formattedText : nil
- 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())
+ let r = messageText(itemText, itemFormattedText, sender: cItem.meta.showGroupAsSender ? nil : 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;
@@ -312,7 +324,7 @@ struct ChatPreviewView: View {
default: return nil
}
}
-
+
func prefix() -> NSAttributedString? {
switch cItem.content.msgContent {
case let .report(_, reason): reason.attrString
@@ -325,31 +337,50 @@ struct ChatPreviewView: View {
if chatModel.draftChatId == chat.id, let draft = chatModel.draft {
let (t, hasSecrets) = messageDraft(draft)
chatPreviewLayout(t, draft: true, hasFilePreview: hasFilePreview, hasSecrets: hasSecrets)
+ } else if cItem?.content.hasMsgContent != true, let previewText = chatPreviewInfoText() {
+ chatPreviewInfoTextLayout(previewText)
} else if let cItem = cItem {
let (t, hasSecrets) = chatItemPreview(cItem)
chatPreviewLayout(itemStatusMark(cItem) + t, hasFilePreview: hasFilePreview, hasSecrets: hasSecrets)
- } else {
- switch (chat.chatInfo) {
- case let .direct(contact):
- if contact.activeConn == nil && contact.profile.contactLink != nil && contact.active {
- chatPreviewInfoText("Tap to Connect")
- .foregroundColor(theme.colors.primary)
- } else if !contact.sndReady && contact.activeConn != nil {
- if contact.nextSendGrpInv {
- chatPreviewInfoText("send direct message")
- } else if contact.active {
- chatPreviewInfoText("connecting…")
- }
- }
- case let .group(groupInfo):
- switch (groupInfo.membership.memberStatus) {
- case .memRejected: chatPreviewInfoText("rejected")
- case .memInvited: groupInvitationPreviewText(groupInfo)
- case .memAccepted: chatPreviewInfoText("connecting…")
- default: EmptyView()
- }
- default: EmptyView()
+ }
+ }
+
+ private func chatPreviewInfoText() -> Text? {
+ switch (chat.chatInfo) {
+ case let .direct(contact):
+ if contact.isContactCard {
+ Text("Tap to Connect")
+ .foregroundColor(theme.colors.primary)
+ } else if contact.isBot && contact.nextConnectPrepared {
+ Text("Open to use bot")
+ } else if contact.sendMsgToConnect {
+ Text("Open to connect")
+ } else if contact.nextAcceptContactRequest {
+ Text("Open to accept")
+ } else if !contact.sndReady && contact.activeConn != nil && contact.active {
+ (contact.preparedContact?.uiConnLinkType == .con && !contact.isBot) || contact.contactGroupMemberId != nil
+ ? Text("contact should accept…")
+ : Text("connecting…")
+ } else {
+ nil
}
+ case let .group(groupInfo, _):
+ if groupInfo.nextConnectPrepared {
+ if groupInfo.businessChat?.chatType == .business {
+ Text("Open to connect")
+ } else {
+ Text("Open to join")
+ }
+ } else {
+ switch (groupInfo.membership.memberStatus) {
+ case .memRejected: Text("rejected")
+ case .memInvited: groupInvitationPreviewText(groupInfo)
+ case .memAccepted: Text("connecting…")
+ case .memPendingReview, .memPendingApproval: Text("reviewed by admins")
+ default: nil
+ }
+ }
+ default: nil
}
}
@@ -399,14 +430,14 @@ struct ChatPreviewView: View {
}
- @ViewBuilder private func groupInvitationPreviewText(_ groupInfo: GroupInfo) -> some View {
+ private func groupInvitationPreviewText(_ groupInfo: GroupInfo) -> Text {
groupInfo.membership.memberIncognito
- ? chatPreviewInfoText("join as \(groupInfo.membership.memberProfile.displayName)")
- : chatPreviewInfoText("you are invited to group")
+ ? Text("Join as \(groupInfo.membership.memberProfile.displayName)")
+ : Text("You are invited to group")
}
- private func chatPreviewInfoText(_ text: LocalizedStringKey) -> some View {
- Text(text)
+ private func chatPreviewInfoTextLayout(_ text: Text) -> some View {
+ text
.frame(maxWidth: .infinity, minHeight: 44, maxHeight: 44, alignment: .topLeading)
.padding([.leading, .trailing], 8)
.padding(.bottom, 4)
@@ -430,7 +461,7 @@ struct ChatPreviewView: View {
let size = dynamicSize(userFont).incognitoSize
switch chat.chatInfo {
case let .direct(contact):
- if contact.active && contact.activeConn != nil {
+ if contact.active, let status = contact.activeConn?.connStatus, status == .ready || status == .sndReady {
NetworkStatusView(contact: contact, size: size)
} else {
incognitoIcon(chat.chatInfo.incognito, theme.colors.secondary, size: size)
@@ -439,7 +470,11 @@ struct ChatPreviewView: View {
if progressByTimeout {
ProgressView()
} else if chat.chatStats.reportsCount > 0 {
- groupReportsIcon(size: size * 0.8)
+ flagIcon(size: size * 0.8, color: .red)
+ } else if chat.supportUnreadCount > 0 {
+ flagIcon(size: size * 0.8, color: theme.colors.primary)
+ } else if chat.chatInfo.groupInfo?.membership.memberPending ?? false {
+ flagIcon(size: size * 0.8, color: theme.colors.secondary)
} else {
incognitoIcon(chat.chatInfo.incognito, theme.colors.secondary, size: size)
}
@@ -485,12 +520,12 @@ struct ChatPreviewView: View {
}
}
-func groupReportsIcon(size: CGFloat) -> some View {
+func flagIcon(size: CGFloat, color: Color) -> some View {
Image(systemName: "flag")
.resizable()
.scaledToFit()
.frame(width: size, height: size)
- .foregroundColor(.red)
+ .foregroundColor(color)
}
func smallContentPreview(size: CGFloat, _ view: @escaping () -> some View) -> some View {
diff --git a/apps/ios/Shared/Views/ChatList/ContactConnectionInfo.swift b/apps/ios/Shared/Views/ChatList/ContactConnectionInfo.swift
index b9f5b984e1..124c5ee7ba 100644
--- a/apps/ios/Shared/Views/ChatList/ContactConnectionInfo.swift
+++ b/apps/ios/Shared/Views/ChatList/ContactConnectionInfo.swift
@@ -114,6 +114,7 @@ struct ContactConnectionInfo: View {
.onAppear {
localAlias = contactConnection.localAlias
}
+ .onDisappear(perform: setConnectionAlias)
}
private func setConnectionAlias() {
diff --git a/apps/ios/Shared/Views/ChatList/ServersSummaryView.swift b/apps/ios/Shared/Views/ChatList/ServersSummaryView.swift
index 8b0a8af888..ee7605dbd2 100644
--- a/apps/ios/Shared/Views/ChatList/ServersSummaryView.swift
+++ b/apps/ios/Shared/Views/ChatList/ServersSummaryView.swift
@@ -412,7 +412,7 @@ struct SubscriptionStatusIndicatorView: View {
var hasSess: Bool
var body: some View {
- let (color, variableValue, opacity, _) = subscriptionStatusColorAndPercentage(
+ let (color, variableValue, opacity) = subscriptionStatusInfo(
online: m.networkInfo.online,
usesProxy: networkUseOnionHostsGroupDefault.get() != .no || groupDefaults.string(forKey: GROUP_DEFAULT_NETWORK_SOCKS_PROXY) != nil,
subs: subs,
@@ -431,25 +431,19 @@ struct SubscriptionStatusIndicatorView: View {
struct SubscriptionStatusPercentageView: View {
@EnvironmentObject var m: ChatModel
- @EnvironmentObject var theme: AppTheme
var subs: SMPServerSubs
var hasSess: Bool
var body: some View {
- let (_, _, _, statusPercent) = subscriptionStatusColorAndPercentage(
- online: m.networkInfo.online,
- usesProxy: networkUseOnionHostsGroupDefault.get() != .no || groupDefaults.string(forKey: GROUP_DEFAULT_NETWORK_SOCKS_PROXY) != nil,
- subs: subs,
- hasSess: hasSess,
- primaryColor: theme.colors.primary
- )
- Text(verbatim: "\(Int(floor(statusPercent * 100)))%")
+ let statusPercent = subscriptionStatusPercent(online: m.networkInfo.online, subs: subs, hasSess: hasSess)
+ let percentText: String = subs.total > 0 || hasSess ? "\(Int(floor(statusPercent * 100)))%" : "%"
+ Text(percentText)
.foregroundColor(.secondary)
.font(.caption)
}
}
-func subscriptionStatusColorAndPercentage(online: Bool, usesProxy: Bool, subs: SMPServerSubs, hasSess: Bool, primaryColor: Color) -> (Color, Double, Double, Double) {
+func subscriptionStatusInfo(online: Bool, usesProxy: Bool, subs: SMPServerSubs, hasSess: Bool, primaryColor: Color) -> (Color, Double, Double) {
func roundedToQuarter(_ n: Double) -> Double {
n >= 1 ? 1
: n <= 0 ? 0
@@ -457,26 +451,28 @@ func subscriptionStatusColorAndPercentage(online: Bool, usesProxy: Bool, subs: S
}
let activeColor: Color = usesProxy ? .indigo : primaryColor
- let noConnColorAndPercent: (Color, Double, Double, Double) = (Color(uiColor: .tertiaryLabel), 1, 1, 0)
+ let noConnColorAndPercent: (Color, Double, Double) = (Color(uiColor: .tertiaryLabel), 1, 1)
let activeSubsRounded = roundedToQuarter(subs.shareOfActive)
return !online
? noConnColorAndPercent
- : (
- subs.total == 0 && !hasSess
- ? (activeColor, 0, 0.33, 0) // On freshly installed app (without chats) and on app start
- : (
- subs.ssActive == 0
- ? (
- hasSess ? (activeColor, activeSubsRounded, subs.shareOfActive, subs.shareOfActive) : noConnColorAndPercent
- )
- : ( // ssActive > 0
- hasSess
- ? (activeColor, activeSubsRounded, subs.shareOfActive, subs.shareOfActive)
- : (.orange, activeSubsRounded, subs.shareOfActive, subs.shareOfActive) // This would mean implementation error
- )
- )
+ : subs.total == 0 && !hasSess
+ ? (activeColor, 0, 0.33) // On freshly installed app (without chats) and on app start
+ : subs.ssActive == 0
+ ? (
+ hasSess ? (activeColor, activeSubsRounded, subs.shareOfActive) : noConnColorAndPercent
)
+ : ( // ssActive > 0
+ hasSess
+ ? (activeColor, activeSubsRounded, subs.shareOfActive)
+ : (.orange, activeSubsRounded, subs.shareOfActive) // This would mean implementation error
+ )
+}
+
+func subscriptionStatusPercent(online: Bool, subs: SMPServerSubs, hasSess: Bool) -> Double {
+ online && (hasSess || (subs.total > 0 && subs.ssActive > 0))
+ ? subs.shareOfActive
+ : 0
}
struct SMPServerSummaryView: View {
diff --git a/apps/ios/Shared/Views/ChatList/TagListView.swift b/apps/ios/Shared/Views/ChatList/TagListView.swift
index 2063fe15de..79d122eabf 100644
--- a/apps/ios/Shared/Views/ChatList/TagListView.swift
+++ b/apps/ios/Shared/Views/ChatList/TagListView.swift
@@ -63,10 +63,7 @@ struct TagListView: View {
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
- ),
+ cancelAlertAction,
UIAlertAction(
title: NSLocalizedString("Delete", comment: "alert action"),
style: .destructive,
diff --git a/apps/ios/Shared/Views/ChatList/UserPicker.swift b/apps/ios/Shared/Views/ChatList/UserPicker.swift
index dbe10ad997..b1cd4015c6 100644
--- a/apps/ios/Shared/Views/ChatList/UserPicker.swift
+++ b/apps/ios/Shared/Views/ChatList/UserPicker.swift
@@ -97,7 +97,7 @@ struct UserPicker: View {
}
.onAppear {
// This check prevents the call of listUsers after the app is suspended, and the database is closed.
- if case .active = scenePhase {
+ if case .active = scenePhase, hasChatCtrl() {
currentUser = m.currentUser?.userId
Task {
do {
@@ -124,7 +124,7 @@ struct UserPicker: View {
ZStack(alignment: .topTrailing) {
ProfileImage(imageStr: u.user.image, size: size, color: Color(uiColor: .tertiarySystemGroupedBackground))
if (u.unreadCount > 0) {
- UnreadBadge(userInfo: u).offset(x: 4, y: -4)
+ userUnreadBadge(u, theme: theme).offset(x: 4, y: -4)
}
}
.padding(.trailing, 6)
@@ -171,19 +171,27 @@ struct UserPicker: View {
}
}
+@inline(__always)
+func userUnreadBadge(_ userInfo: UserInfo, theme: AppTheme) -> some View {
+ UnreadBadge(
+ count: userInfo.unreadCount,
+ color: userInfo.user.showNtfs ? theme.colors.primary : theme.colors.secondary
+ )
+}
+
struct UnreadBadge: View {
- var userInfo: UserInfo
- @EnvironmentObject var theme: AppTheme
@Environment(\.dynamicTypeSize) private var userFont: DynamicTypeSize
+ var count: Int
+ var color: Color
var body: some View {
let size = dynamicSize(userFont).chatInfoSize
- unreadCountText(userInfo.unreadCount)
+ unreadCountText(count)
.font(userFont <= .xxxLarge ? .caption : .caption2)
.foregroundColor(.white)
.padding(.horizontal, dynamicSize(userFont).unreadPadding)
.frame(minWidth: size, minHeight: size)
- .background(userInfo.user.showNtfs ? theme.colors.primary : theme.colors.secondary)
+ .background(color)
.cornerRadius(dynamicSize(userFont).unreadCorner)
}
}
diff --git a/apps/ios/Shared/Views/Contacts/ContactListNavLink.swift b/apps/ios/Shared/Views/Contacts/ContactListNavLink.swift
index 456c46d318..fcfcde2c07 100644
--- a/apps/ios/Shared/Views/Contacts/ContactListNavLink.swift
+++ b/apps/ios/Shared/Views/Contacts/ContactListNavLink.swift
@@ -20,20 +20,17 @@ struct ContactListNavLink: View {
@State private var showContactRequestDialog = false
var body: some View {
- let contactType = chatContactType(chat)
-
Group {
switch (chat.chatInfo) {
case let .direct(contact):
- switch contactType {
- case .recent:
- recentContactNavLink(contact)
- case .chatDeleted:
- deletedChatNavLink(contact)
- case .card:
+ if contact.nextAcceptContactRequest {
+ contactWithRequestNavLink(contact)
+ } else if contact.isContactCard {
contactCardNavLink(contact)
- default:
- EmptyView()
+ } else if contact.chatDeleted {
+ deletedChatNavLink(contact)
+ } else if contact.active {
+ recentContactNavLink(contact)
}
case let .contactRequest(contactRequest):
contactRequestNavLink(contactRequest)
@@ -59,7 +56,7 @@ struct ContactListNavLink: View {
ItemsModel.shared.loadOpenChat(contact.id)
}
} label: {
- contactPreview(contact, titleColor: theme.colors.onBackground)
+ contactPreview(contact, titleColor: contact.sendMsgToConnect ? theme.colors.primary : theme.colors.onBackground)
}
.swipeActions(edge: .trailing, allowsFullSwipe: true) {
Button {
@@ -78,6 +75,67 @@ struct ContactListNavLink: View {
}
}
+ func contactWithRequestNavLink(_ contact: Contact) -> some View {
+ Button {
+ dismissAllSheets(animated: true) {
+ ItemsModel.shared.loadOpenChat(contact.id)
+ }
+ } label: {
+ contactRequestPreview(color: contact.groupDirectInv?.memberRemoved == true ? theme.colors.secondary : theme.colors.primary)
+ }
+ .swipeActions(edge: .trailing, allowsFullSwipe: true) {
+ if let contactRequestId = contact.contactRequestId {
+ Button {
+ Task { await acceptContactRequest(incognito: false, contactRequestId: contactRequestId) }
+ } label: {
+ Label("Accept", systemImage: "checkmark")
+ }
+ .tint(theme.colors.primary)
+ if !ChatModel.shared.addressShortLinkDataSet {
+ Button {
+ Task { await acceptContactRequest(incognito: true, contactRequestId: contactRequestId) }
+ } label: {
+ Label("Accept incognito", systemImage: "theatermasks")
+ }
+ .tint(.indigo)
+ }
+ Button {
+ alert = SomeAlert(alert: rejectContactRequestAlert(contactRequestId), id: "rejectContactRequestAlert")
+ } label: {
+ Label("Reject", systemImage: "multiply")
+ }
+ .tint(.red)
+ } else if let groupDirectInv = contact.groupDirectInv, !groupDirectInv.memberRemoved {
+ Button {
+ acceptMemberContactRequest(contact)
+ } label: {
+ Label("Accept", systemImage: "checkmark")
+ }
+ .tint(theme.colors.primary)
+ Button {
+ showRejectMemberContactRequestAlert(contact)
+ } label: {
+ Label("Reject", systemImage: "multiply")
+ }
+ .tint(.red)
+ } else {
+ Button {
+ deleteContactDialog(
+ chat,
+ contact,
+ dismissToChatList: false,
+ showAlert: { alert = $0 },
+ showActionSheet: { actionSheet = $0 },
+ showSheetContent: { sheet = $0 }
+ )
+ } label: {
+ Label("Delete", systemImage: "trash")
+ }
+ .tint(.red)
+ }
+ }
+ }
+
func deletedChatNavLink(_ contact: Contact) -> some View {
Button {
Task {
@@ -179,8 +237,14 @@ struct ContactListNavLink: View {
.tint(.red)
}
.confirmationDialog("Connect with \(contact.chatViewName)", isPresented: $showConnectContactViaAddressDialog, titleVisibility: .visible) {
- Button("Use current profile") { connectContactViaAddress_(contact, false) }
- Button("Use new incognito profile") { connectContactViaAddress_(contact, true) }
+ if !contact.profileChangeProhibited {
+ Button("Use current profile") { connectContactViaAddress_(contact, false) }
+ Button("Use new incognito profile") { connectContactViaAddress_(contact, true) }
+ } else if !contact.contactConnIncognito {
+ Button("Use current profile") { connectContactViaAddress_(contact, false) }
+ } else {
+ Button("Use incognito profile") { connectContactViaAddress_(contact, true) }
+ }
}
}
@@ -219,39 +283,43 @@ struct ContactListNavLink: View {
Button {
showContactRequestDialog = true
} label: {
- contactRequestPreview(contactRequest)
+ contactRequestPreview(color: theme.colors.primary)
}
.swipeActions(edge: .trailing, allowsFullSwipe: true) {
Button {
- Task { await acceptContactRequest(incognito: false, contactRequest: contactRequest) }
+ Task { await acceptContactRequest(incognito: false, contactRequestId: contactRequest.apiId) }
} label: { Label("Accept", systemImage: "checkmark") }
.tint(theme.colors.primary)
- Button {
- Task { await acceptContactRequest(incognito: true, contactRequest: contactRequest) }
- } label: {
- Label("Accept incognito", systemImage: "theatermasks")
+ if !ChatModel.shared.addressShortLinkDataSet {
+ Button {
+ Task { await acceptContactRequest(incognito: true, contactRequestId: contactRequest.apiId) }
+ } label: {
+ Label("Accept incognito", systemImage: "theatermasks")
+ }
+ .tint(.indigo)
}
- .tint(.indigo)
Button {
- alert = SomeAlert(alert: rejectContactRequestAlert(contactRequest), id: "rejectContactRequestAlert")
+ alert = SomeAlert(alert: rejectContactRequestAlert(contactRequest.apiId), id: "rejectContactRequestAlert")
} label: {
Label("Reject", systemImage: "multiply")
}
.tint(.red)
}
.confirmationDialog("Accept connection request?", isPresented: $showContactRequestDialog, titleVisibility: .visible) {
- Button("Accept") { Task { await acceptContactRequest(incognito: false, contactRequest: contactRequest) } }
- Button("Accept incognito") { Task { await acceptContactRequest(incognito: true, contactRequest: contactRequest) } }
- Button("Reject (sender NOT notified)", role: .destructive) { Task { await rejectContactRequest(contactRequest) } }
+ Button("Accept") { Task { await acceptContactRequest(incognito: false, contactRequestId: contactRequest.apiId) } }
+ if !ChatModel.shared.addressShortLinkDataSet {
+ Button("Accept incognito") { Task { await acceptContactRequest(incognito: true, contactRequestId: contactRequest.apiId) } }
+ }
+ Button("Reject (sender NOT notified)", role: .destructive) { Task { await rejectContactRequest(contactRequest.apiId) } }
}
}
- func contactRequestPreview(_ contactRequest: UserContactRequest) -> some View {
+ func contactRequestPreview(color: Color) -> some View {
HStack{
- ProfileImage(imageStr: contactRequest.image, size: 30)
+ ProfileImage(imageStr: chat.chatInfo.image, size: 30)
Text(chat.chatInfo.chatViewName)
- .foregroundColor(.accentColor)
+ .foregroundColor(color)
.lineLimit(1)
Spacer()
@@ -260,7 +328,7 @@ struct ContactListNavLink: View {
.resizable()
.scaledToFill()
.frame(width: 14, height: 14)
- .foregroundColor(.accentColor)
+ .foregroundColor(color)
}
}
}
diff --git a/apps/ios/Shared/Views/Database/DatabaseView.swift b/apps/ios/Shared/Views/Database/DatabaseView.swift
index 59eee1338b..a7e61b3105 100644
--- a/apps/ios/Shared/Views/Database/DatabaseView.swift
+++ b/apps/ios/Shared/Views/Database/DatabaseView.swift
@@ -21,7 +21,7 @@ enum DatabaseAlert: Identifiable {
case deleteLegacyDatabase
case deleteFilesAndMedia
case setChatItemTTL(ttl: ChatItemTTL)
- case error(title: LocalizedStringKey, error: String = "")
+ case error(title: String, error: String = "")
var id: String {
switch self {
@@ -456,7 +456,7 @@ struct DatabaseView: View {
}
} catch let error {
await MainActor.run {
- alert = .error(title: "Error exporting chat database", error: responseError(error))
+ alert = .error(title: NSLocalizedString("Error exporting chat database", comment: "alert title"), error: responseError(error))
progressIndicator = false
}
}
@@ -492,10 +492,10 @@ struct DatabaseView: View {
return migration
}
} catch let error {
- await operationEnded(.error(title: "Error importing chat database", error: responseError(error)), progressIndicator, alert)
+ await operationEnded(.error(title: NSLocalizedString("Error importing chat database", comment: "alert title"), error: responseError(error)), progressIndicator, alert)
}
} catch let error {
- await operationEnded(.error(title: "Error deleting chat database", error: responseError(error)), progressIndicator, alert)
+ await operationEnded(.error(title: NSLocalizedString("Error deleting chat database", comment: "alert title"), error: responseError(error)), progressIndicator, alert)
}
} else {
showAlert("Error accessing database file")
@@ -513,7 +513,7 @@ struct DatabaseView: View {
await DatabaseView.operationEnded(.chatDeleted, $progressIndicator, $alert)
return true
} catch let error {
- await DatabaseView.operationEnded(.error(title: "Error deleting database", error: responseError(error)), $progressIndicator, $alert)
+ await DatabaseView.operationEnded(.error(title: NSLocalizedString("Error deleting database", comment: "alert title"), error: responseError(error)), $progressIndicator, $alert)
return false
}
}
@@ -522,7 +522,7 @@ struct DatabaseView: View {
if removeLegacyDatabaseAndFiles() {
legacyDatabase = false
} else {
- alert = .error(title: "Error deleting old database")
+ alert = .error(title: NSLocalizedString("Error deleting old database", comment: "alert title"))
}
}
@@ -546,7 +546,7 @@ struct DatabaseView: View {
let (title, message) = chatDeletedAlertText()
showAlert(title, message: message, actions: { [okAlertActionWaiting] })
} else if case let .error(title, error) = dbAlert {
- showAlert("\(title)", message: error, actions: { [okAlertActionWaiting] })
+ showAlert(title, message: error, actions: { [okAlertActionWaiting] })
} else {
alert.wrappedValue = dbAlert
cont.resume()
@@ -567,7 +567,7 @@ struct DatabaseView: View {
}
} catch {
await MainActor.run {
- alert = .error(title: "Error changing setting", error: responseError(error))
+ alert = .error(title: NSLocalizedString("Error changing setting", comment: "alert title"), error: responseError(error))
chatItemTTL = currentChatItemTTL
afterSetCiTTL()
}
diff --git a/apps/ios/Shared/Views/Helpers/AppSheet.swift b/apps/ios/Shared/Views/Helpers/AppSheet.swift
index 1e334367e8..17fe95a058 100644
--- a/apps/ios/Shared/Views/Helpers/AppSheet.swift
+++ b/apps/ios/Shared/Views/Helpers/AppSheet.swift
@@ -33,7 +33,7 @@ extension View {
func appSheet(
isPresented: Binding,
onDismiss: (() -> Void)? = nil,
- content: @escaping () -> Content
+ @ViewBuilder content: @escaping () -> Content
) -> some View where Content: View {
sheet(isPresented: isPresented, onDismiss: onDismiss) {
content().modifier(PrivacySensitive())
@@ -43,7 +43,7 @@ extension View {
func appSheet(
item: Binding,
onDismiss: (() -> Void)? = nil,
- content: @escaping (T) -> Content
+ @ViewBuilder content: @escaping (T) -> Content
) -> some View where T: Identifiable, Content: View {
sheet(item: item, onDismiss: onDismiss) { it in
content(it).modifier(PrivacySensitive())
diff --git a/apps/ios/Shared/Views/Helpers/ChatItemClipShape.swift b/apps/ios/Shared/Views/Helpers/ChatItemClipShape.swift
index 9aa6ac86cf..980308f13c 100644
--- a/apps/ios/Shared/Views/Helpers/ChatItemClipShape.swift
+++ b/apps/ios/Shared/Views/Helpers/ChatItemClipShape.swift
@@ -76,7 +76,7 @@ struct ChatTailPadding: ViewModifier {
}
}
-private let msgRectMaxRadius: Double = 18
+let msgRectMaxRadius: Double = 18
private let msgBubbleMaxRadius: Double = msgRectMaxRadius * 1.2
private let msgTailWidth: Double = 9
private let msgTailMinHeight: Double = msgTailWidth * 1.254 // ~56deg
diff --git a/apps/ios/Shared/Views/Helpers/CustomTimePicker.swift b/apps/ios/Shared/Views/Helpers/CustomTimePicker.swift
index 09aae1cb15..edb10ef87d 100644
--- a/apps/ios/Shared/Views/Helpers/CustomTimePicker.swift
+++ b/apps/ios/Shared/Views/Helpers/CustomTimePicker.swift
@@ -220,6 +220,35 @@ struct DropdownCustomTimePicker: View {
}
}
+struct WrappedPicker: View {
+ var selection: Binding
+ @ViewBuilder var content: () -> Content
+ @ViewBuilder var label: () -> Label
+
+ init(_ title: LocalizedStringKey, selection: Binding, @ViewBuilder content: @escaping () -> Content) where Label == Text {
+ self.selection = selection
+ self.content = content
+ self.label = { Text(title) }
+ }
+
+ init(selection: Binding, @ViewBuilder content: @escaping () -> Content, @ViewBuilder label: @escaping () -> Label) {
+ self.selection = selection
+ self.content = content
+ self.label = label
+ }
+
+ var body: some View {
+ HStack(alignment: .firstTextBaseline) {
+ label()
+ Spacer()
+ Picker(selection: selection, content: content) {
+ EmptyView()
+ }
+ .frame(height: 36)
+ }
+ }
+}
+
struct CustomTimePicker_Previews: PreviewProvider {
static var previews: some View {
CustomTimePicker(
diff --git a/apps/ios/Shared/Views/Helpers/ProfileImage.swift b/apps/ios/Shared/Views/Helpers/ProfileImage.swift
index 3eedd56441..9c2916880c 100644
--- a/apps/ios/Shared/Views/Helpers/ProfileImage.swift
+++ b/apps/ios/Shared/Views/Helpers/ProfileImage.swift
@@ -27,8 +27,13 @@ struct ProfileImage: View {
Image(systemName: iconName)
.resizable()
.foregroundColor(c)
+ .scaledToFit()
.frame(width: size, height: size)
- .background(Circle().fill(backgroundColor != nil ? backgroundColor! : .clear))
+ .background(
+ Circle()
+ .fill(backgroundColor != nil ? backgroundColor! : .clear)
+ .frame(width: size - 2, height: size - 2) // less than size of Image to avoid slightly visible border
+ )
}
}
}
diff --git a/apps/ios/Shared/Views/Helpers/ShareSheet.swift b/apps/ios/Shared/Views/Helpers/ShareSheet.swift
index b8de0e4ceb..86a5dc7aaa 100644
--- a/apps/ios/Shared/Views/Helpers/ShareSheet.swift
+++ b/apps/ios/Shared/Views/Helpers/ShareSheet.swift
@@ -65,6 +65,252 @@ func showAlert(
}
}
+func showSheet(
+ _ title: String?,
+ message: String? = nil,
+ actions: () -> [UIAlertAction] = { [okAlertAction] },
+ sourceView: UIView? = nil // For iPad support
+) {
+ if let topController = getTopViewController() {
+ let sheet = UIAlertController(title: title, message: message, preferredStyle: .actionSheet)
+ for action in actions() { sheet.addAction(action) }
+
+ // Required for iPad: Configure popover presentation
+ if let popover = sheet.popoverPresentationController {
+ popover.sourceView = sourceView ?? topController.view
+ popover.sourceRect = sourceView?.bounds ?? CGRect(x: topController.view.bounds.midX, y: topController.view.bounds.midY, width: 0, height: 0)
+ popover.permittedArrowDirections = []
+ }
+
+ topController.present(sheet, animated: true)
+ }
+}
+
let okAlertAction = UIAlertAction(title: NSLocalizedString("Ok", comment: "alert button"), style: .default)
let cancelAlertAction = UIAlertAction(title: NSLocalizedString("Cancel", comment: "alert button"), style: .cancel)
+
+let alertProfileImageSize: CGFloat = 103
+
+let alertWidth: CGFloat = 270
+
+let alertButtonHeight: CGFloat = 44
+
+class OpenChatAlertViewController: UIViewController {
+ private let profileName: String
+ private let profileFullName: String
+ private let profileImage: UIView
+ private let cancelTitle: String
+ private let confirmTitle: String
+ private let onCancel: () -> Void
+ private let onConfirm: () -> Void
+
+ init(
+ profileName: String,
+ profileFullName: String,
+ profileImage: UIView,
+ cancelTitle: String = "Cancel",
+ confirmTitle: String = "Open",
+ onCancel: @escaping () -> Void,
+ onConfirm: @escaping () -> Void
+ ) {
+ self.profileName = profileName
+ self.profileFullName = profileFullName
+ self.profileImage = profileImage
+ self.cancelTitle = cancelTitle
+ self.confirmTitle = confirmTitle
+ self.onCancel = onCancel
+ self.onConfirm = onConfirm
+ super.init(nibName: nil, bundle: nil)
+
+ modalPresentationStyle = .overFullScreen
+ modalTransitionStyle = .crossDissolve
+ }
+
+ required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
+
+ override func viewDidLoad() {
+ super.viewDidLoad()
+
+ view.backgroundColor = UIColor.black.withAlphaComponent(0.3)
+
+ // Container view
+ let containerView = UIView()
+ containerView.backgroundColor = .systemBackground
+ containerView.layer.cornerRadius = 12
+ containerView.translatesAutoresizingMaskIntoConstraints = false
+ view.addSubview(containerView)
+
+ // Profile image sizing
+ profileImage.translatesAutoresizingMaskIntoConstraints = false
+ NSLayoutConstraint.activate([
+ profileImage.widthAnchor.constraint(equalToConstant: alertProfileImageSize),
+ profileImage.heightAnchor.constraint(equalToConstant: alertProfileImageSize)
+ ])
+
+ // Name label
+ let nameLabel = UILabel()
+ nameLabel.text = profileName
+ nameLabel.font = UIFont.preferredFont(forTextStyle: .headline)
+ nameLabel.textColor = .label
+ nameLabel.numberOfLines = 2
+ nameLabel.textAlignment = .center
+ nameLabel.translatesAutoresizingMaskIntoConstraints = false
+
+ var profileViews = [profileImage, nameLabel]
+
+ // Full name label
+ if !profileFullName.isEmpty && profileFullName != profileName {
+ let fullNameLabel = UILabel()
+ fullNameLabel.text = profileFullName
+ fullNameLabel.font = UIFont.preferredFont(forTextStyle: .subheadline)
+ fullNameLabel.textColor = .label
+ fullNameLabel.numberOfLines = 2
+ fullNameLabel.textAlignment = .center
+ fullNameLabel.translatesAutoresizingMaskIntoConstraints = false
+ profileViews.append(fullNameLabel)
+ }
+
+ // Horizontal stack for image + name
+ let stack = UIStackView(arrangedSubviews: profileViews)
+ stack.axis = .vertical
+ stack.spacing = 12
+ stack.alignment = .center
+ stack.translatesAutoresizingMaskIntoConstraints = false
+
+ let topRowContainer = UIView()
+ topRowContainer.translatesAutoresizingMaskIntoConstraints = false
+ topRowContainer.addSubview(stack)
+
+ NSLayoutConstraint.activate([
+ stack.topAnchor.constraint(equalTo: topRowContainer.topAnchor),
+ stack.bottomAnchor.constraint(equalTo: topRowContainer.bottomAnchor),
+ stack.leadingAnchor.constraint(equalTo: topRowContainer.leadingAnchor, constant: 20),
+ stack.trailingAnchor.constraint(equalTo: topRowContainer.trailingAnchor, constant: -20)
+ ])
+
+ // Buttons
+ let cancelButton = UIButton(type: .system)
+ cancelButton.setTitle(cancelTitle, for: .normal)
+ let bodyDescr = UIFontDescriptor.preferredFontDescriptor(withTextStyle: .body)
+ cancelButton.titleLabel?.font = UIFont(descriptor: bodyDescr.withSymbolicTraits(.traitBold) ?? bodyDescr, size: 0)
+ cancelButton.addTarget(self, action: #selector(cancelTapped), for: .touchUpInside)
+
+ let confirmButton = UIButton(type: .system)
+ confirmButton.setTitle(confirmTitle, for: .normal)
+ confirmButton.titleLabel?.font = UIFont.preferredFont(forTextStyle: .body)
+ confirmButton.addTarget(self, action: #selector(confirmTapped), for: .touchUpInside)
+
+ let verticalButtons = cancelButton.intrinsicContentSize.width + 20 >= alertWidth / 2 || confirmButton.intrinsicContentSize.width + 20 >= alertWidth / 2
+
+ // Button stack with equal width buttons
+ let buttonStack = UIStackView(arrangedSubviews: verticalButtons ? [confirmButton, cancelButton] : [cancelButton, confirmButton])
+ buttonStack.axis = verticalButtons ? .vertical : .horizontal
+ buttonStack.distribution = .fillEqually
+ buttonStack.spacing = 0 // no spacing, use divider instead
+ buttonStack.translatesAutoresizingMaskIntoConstraints = false
+ buttonStack.heightAnchor.constraint(greaterThanOrEqualToConstant: alertButtonHeight * (verticalButtons ? 2 : 1)).isActive = true
+
+ // Vertical stack containing hStack and buttonStack
+ let vStack = UIStackView(arrangedSubviews: [topRowContainer, buttonStack])
+ vStack.axis = .vertical
+ vStack.spacing = 16
+ vStack.alignment = .fill // important: buttons stretch full width
+ vStack.translatesAutoresizingMaskIntoConstraints = false
+
+ containerView.addSubview(vStack)
+
+ // Add horizontal divider above buttons
+ let horizontalDivider = UIView()
+ horizontalDivider.backgroundColor = UIColor.separator
+ horizontalDivider.translatesAutoresizingMaskIntoConstraints = false
+ containerView.addSubview(horizontalDivider)
+
+ // Add divider between buttons
+ let buttonDivider = UIView()
+ buttonDivider.backgroundColor = UIColor.separator
+ buttonDivider.translatesAutoresizingMaskIntoConstraints = false
+ buttonStack.addSubview(buttonDivider)
+
+ // Constraints
+ let buttonDividerConstraints = if verticalButtons {
+ [
+ buttonDivider.leadingAnchor.constraint(equalTo: containerView.leadingAnchor),
+ buttonDivider.trailingAnchor.constraint(equalTo: containerView.trailingAnchor),
+ buttonDivider.centerYAnchor.constraint(equalTo: buttonStack.centerYAnchor),
+ buttonDivider.heightAnchor.constraint(equalToConstant: 1 / UIScreen.main.scale)
+ ]
+ } else {
+ [
+ buttonDivider.topAnchor.constraint(equalTo: buttonStack.topAnchor),
+ buttonDivider.bottomAnchor.constraint(equalTo: containerView.bottomAnchor),
+ buttonDivider.centerXAnchor.constraint(equalTo: buttonStack.centerXAnchor),
+ buttonDivider.widthAnchor.constraint(equalToConstant: 1 / UIScreen.main.scale)
+ ]
+ }
+
+ NSLayoutConstraint.activate([
+ // Container view centering and fixed width
+ containerView.centerYAnchor.constraint(equalTo: view.centerYAnchor),
+ containerView.centerXAnchor.constraint(equalTo: view.centerXAnchor),
+ containerView.widthAnchor.constraint(equalToConstant: alertWidth),
+
+ // Vertical stack padding inside containerView
+ vStack.topAnchor.constraint(equalTo: containerView.topAnchor, constant: 20),
+ vStack.leadingAnchor.constraint(equalTo: containerView.leadingAnchor, constant: 0),
+ vStack.trailingAnchor.constraint(equalTo: containerView.trailingAnchor, constant: 0),
+ vStack.bottomAnchor.constraint(equalTo: containerView.bottomAnchor, constant: 0),
+
+ // Center hStack horizontally inside vStack's padded width
+ stack.centerXAnchor.constraint(equalTo: vStack.centerXAnchor),
+
+ // Horizontal divider above buttons
+ horizontalDivider.leadingAnchor.constraint(equalTo: containerView.leadingAnchor),
+ horizontalDivider.trailingAnchor.constraint(equalTo: containerView.trailingAnchor),
+ horizontalDivider.bottomAnchor.constraint(equalTo: buttonStack.topAnchor),
+ horizontalDivider.heightAnchor.constraint(equalToConstant: 1 / UIScreen.main.scale)
+ ] + buttonDividerConstraints)
+ }
+
+ @objc private func cancelTapped() {
+ dismiss(animated: true) {
+ self.onCancel()
+ }
+ }
+
+ @objc private func confirmTapped() {
+ dismiss(animated: true) {
+ self.onConfirm()
+ }
+ }
+}
+
+
+func showOpenChatAlert(
+ profileName: String,
+ profileFullName: String,
+ profileImage: Content,
+ theme: AppTheme,
+ cancelTitle: String = "Cancel",
+ confirmTitle: String = "Open",
+ onCancel: @escaping () -> Void = {},
+ onConfirm: @escaping () -> Void
+) {
+ let themedView = profileImage.environmentObject(theme)
+ let hostingController = UIHostingController(rootView: themedView)
+ let hostedView = hostingController.view!
+ hostedView.backgroundColor = .clear
+
+ if let topVC = getTopViewController() {
+ let alertVC = OpenChatAlertViewController(
+ profileName: profileName,
+ profileFullName: profileFullName,
+ profileImage: hostedView,
+ cancelTitle: cancelTitle,
+ confirmTitle: confirmTitle,
+ onCancel: onCancel,
+ onConfirm: onConfirm
+ )
+ topVC.present(alertVC, animated: true)
+ }
+}
diff --git a/apps/ios/Shared/Views/LocalAuth/LocalAuthView.swift b/apps/ios/Shared/Views/LocalAuth/LocalAuthView.swift
index 16ab26eff7..c21ff9be8b 100644
--- a/apps/ios/Shared/Views/LocalAuth/LocalAuthView.swift
+++ b/apps/ios/Shared/Views/LocalAuth/LocalAuthView.swift
@@ -66,6 +66,8 @@ struct LocalAuthView: View {
m.chatId = nil
ItemsModel.shared.reversedChatItems = []
ItemsModel.shared.chatState.clear()
+ ChatModel.shared.secondaryIM?.reversedChatItems = []
+ ChatModel.shared.secondaryIM?.chatState.clear()
m.updateChats([])
m.users = []
_ = kcAppPassword.set(password)
diff --git a/apps/ios/Shared/Views/NewChat/AddGroupView.swift b/apps/ios/Shared/Views/NewChat/AddGroupView.swift
index 87c0b80372..901b2deeab 100644
--- a/apps/ios/Shared/Views/NewChat/AddGroupView.swift
+++ b/apps/ios/Shared/Views/NewChat/AddGroupView.swift
@@ -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: CreatedConnLink?
+ @State private var groupLink: GroupLink?
@State private var groupLinkMemberRole: GroupMemberRole = .member
var body: some View {
@@ -104,7 +104,9 @@ struct AddGroupView: View {
}
.foregroundColor(theme.colors.secondary)
.frame(maxWidth: .infinity, alignment: .leading)
- .onTapGesture(perform: hideKeyboard)
+ .onTapGesture {
+ focusDisplayName = false
+ }
}
}
.onAppear() {
@@ -161,7 +163,8 @@ struct AddGroupView: View {
} else {
Image(systemName: "pencil").foregroundColor(theme.colors.secondary)
}
- textField("Enter group name…", text: $profile.displayName)
+ TextField("Enter group name…", text: $profile.displayName)
+ .padding(.leading, 36)
.focused($focusDisplayName)
.submitLabel(.continue)
.onSubmit {
@@ -170,11 +173,6 @@ struct AddGroupView: View {
}
}
- func textField(_ placeholder: LocalizedStringKey, text: Binding) -> some View {
- TextField(placeholder, text: text)
- .padding(.leading, 36)
- }
-
func sharedGroupProfileInfo(_ incognito: Bool) -> Text {
let name = ChatModel.shared.currentUser?.displayName ?? ""
return Text(
@@ -185,7 +183,7 @@ struct AddGroupView: View {
}
func createGroup() {
- hideKeyboard()
+ focusDisplayName = false
do {
profile.displayName = profile.displayName.trimmingCharacters(in: .whitespaces)
profile.groupPreferences = GroupPreferences(history: GroupPreference(enable: .on))
@@ -193,7 +191,7 @@ struct AddGroupView: View {
Task {
await m.loadGroupMembers(gInfo)
}
- let c = Chat(chatInfo: .group(groupInfo: gInfo), chatItems: [])
+ let c = Chat(chatInfo: .group(groupInfo: gInfo, groupChatScope: nil), chatItems: [])
m.addChat(c)
withAnimation {
groupInfo = gInfo
@@ -217,6 +215,8 @@ struct AddGroupView: View {
}
}
+// Using this method may freeze the app in some cases, so it should be avoided when possible, especially when combined with .focussed modifier.
+// It also must only be called from background thread.
func hideKeyboard() {
UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil)
}
diff --git a/apps/ios/Shared/Views/NewChat/NewChatMenuButton.swift b/apps/ios/Shared/Views/NewChat/NewChatMenuButton.swift
index e5263813fa..2e3119a8b8 100644
--- a/apps/ios/Shared/Views/NewChat/NewChatMenuButton.swift
+++ b/apps/ios/Shared/Views/NewChat/NewChatMenuButton.swift
@@ -9,10 +9,6 @@
import SwiftUI
import SimpleXChat
-enum ContactType: Int {
- case card, request, recent, chatDeleted, unlisted
-}
-
struct NewChatMenuButton: View {
// do not use chatModel here because it prevents showing AddGroupMembersView after group creation and QR code after link creation on iOS 16
// @EnvironmentObject var chatModel: ChatModel
@@ -20,7 +16,8 @@ struct NewChatMenuButton: View {
@State private var alert: SomeAlert? = nil
var body: some View {
- Button {
+ Button {
+ ConnectProgressManager.shared.cancelConnectProgress()
showNewChatSheet = true
} label: {
Image(systemName: "square.and.pencil")
@@ -42,7 +39,6 @@ private var indent: CGFloat = 36
struct NewChatSheet: View {
@EnvironmentObject var theme: AppTheme
- @State private var baseContactTypes: [ContactType] = [.card, .request, .recent]
@EnvironmentObject var chatModel: ChatModel
@State private var searchMode = false
@FocusState var searchFocussed: Bool
@@ -60,7 +56,7 @@ struct NewChatSheet: View {
@AppStorage(GROUP_DEFAULT_ONE_HAND_UI, store: groupDefaults) private var oneHandUI = true
var body: some View {
- let showArchive = !filterContactTypes(chats: chatModel.chats, contactTypes: [.chatDeleted]).isEmpty
+ let showArchive = chatModel.chats.contains { $0.chatInfo.contact?.chatDeleted == true }
let v = NavigationView {
viewBody(showArchive)
.navigationTitle("New message")
@@ -70,6 +66,8 @@ struct NewChatSheet: View {
.alert(item: $alert) { a in
return a.alert
}
+ }.onDisappear {
+ ConnectProgressManager.shared.cancelConnectProgress()
}
if #available(iOS 16.0, *), oneHandUI {
let sheetHeight: CGFloat = showArchive ? 575 : 500
@@ -125,7 +123,7 @@ struct NewChatSheet: View {
}
NavigationLink {
AddGroupView()
- .navigationTitle("Create secret group")
+ .navigationTitle("Create group")
.modifier(ThemedBackground(grouped: true))
.navigationBarTitleDisplayMode(.large)
} label: {
@@ -145,7 +143,7 @@ struct NewChatSheet: View {
}
ContactsList(
- baseContactTypes: $baseContactTypes,
+ chatPredicate: contactListChatPredicate,
searchMode: $searchMode,
searchText: $searchText,
header: "Your Contacts",
@@ -156,7 +154,15 @@ struct NewChatSheet: View {
)
}
}
-
+
+ private func contactListChatPredicate(_ chat: Chat, _ withSearch: Bool) -> Bool {
+ switch chat.chatInfo {
+ case .contactRequest: true
+ case let .direct(contact): contact.isContactCard || contact.active || (contact.chatDeleted && withSearch)
+ default: false
+ }
+ }
+
/// Extends label's tap area to match `.insetGrouped` list row insets
private func navigateOnTap(_ label: L, setActive: @escaping () -> Void) -> some View {
label
@@ -186,35 +192,24 @@ struct NewChatSheet: View {
}
}
-func chatContactType(_ chat: Chat) -> ContactType {
+func chatOrderRank(_ chat: Chat) -> Int {
switch chat.chatInfo {
- case .contactRequest:
- return .request
+ case .contactRequest: 4
case let .direct(contact):
- if contact.activeConn == nil && contact.profile.contactLink != nil && contact.active {
- return .card
- } else if contact.chatDeleted {
- return .chatDeleted
- } else if contact.contactStatus == .active {
- return .recent
- } else {
- return .unlisted
- }
- default:
- return .unlisted
- }
-}
-
-private func filterContactTypes(chats: [Chat], contactTypes: [ContactType]) -> [Chat] {
- return chats.filter { chat in
- contactTypes.contains(chatContactType(chat))
+ contact.isContactCard ? 5
+ : contact.nextAcceptContactRequest ? 4
+ : contact.nextConnectPrepared ? 3
+ : contact.active ? 2
+ : contact.chatDeleted ? 1
+ : 0
+ default: 0
}
}
struct ContactsList: View {
@EnvironmentObject var theme: AppTheme
@EnvironmentObject var chatModel: ChatModel
- @Binding var baseContactTypes: [ContactType]
+ var chatPredicate: (Chat, Bool) -> Bool // (chat, search) -> show
@Binding var searchMode: Bool
@Binding var searchText: String
var header: String? = nil
@@ -225,8 +220,7 @@ struct ContactsList: View {
@AppStorage(DEFAULT_SHOW_UNREAD_AND_FAVORITES) private var showUnreadAndFavorites = false
var body: some View {
- let contactTypes = contactTypesSearchTargets(baseContactTypes: baseContactTypes, searchEmpty: searchText.isEmpty)
- let contactChats = filterContactTypes(chats: chatModel.chats, contactTypes: contactTypes)
+ let contactChats = chatModel.chats.filter { chat in chatPredicate(chat, !searchText.isEmpty) }
let filteredContactChats = filteredContactChats(
showUnreadAndFavorites: showUnreadAndFavorites,
searchShowingSimplexLink: searchShowingSimplexLink,
@@ -269,26 +263,11 @@ struct ContactsList: View {
.listRowBackground(Color.clear)
.listRowInsets(EdgeInsets(top: 7, leading: 0, bottom: 7, trailing: 0))
}
-
- private func contactTypesSearchTargets(baseContactTypes: [ContactType], searchEmpty: Bool) -> [ContactType] {
- if baseContactTypes.contains(.chatDeleted) || searchEmpty {
- return baseContactTypes
- } else {
- return baseContactTypes + [.chatDeleted]
- }
- }
-
- private func chatsByTypeComparator(chat1: Chat, chat2: Chat) -> Bool {
- let chat1Type = chatContactType(chat1)
- let chat2Type = chatContactType(chat2)
- if chat1Type.rawValue < chat2Type.rawValue {
- return true
- } else if chat1Type.rawValue > chat2Type.rawValue {
- return false
- } else {
- return chat2.chatInfo.chatTs < chat1.chatInfo.chatTs
- }
+ private func chatComparator(chat1: Chat, chat2: Chat) -> Bool {
+ let r1 = chatOrderRank(chat1)
+ let r2 = chatOrderRank(chat2)
+ return r1 > r2 ? true : r1 < r2 ? false : chat1.chatInfo.chatTs > chat2.chatInfo.chatTs
}
private func filterChat(chat: Chat, searchText: String, showUnreadAndFavorites: Bool) -> Bool {
@@ -333,12 +312,13 @@ struct ContactsList: View {
}
}
- return filteredChats.sorted(by: chatsByTypeComparator)
+ return filteredChats.sorted(by: chatComparator)
}
}
struct ContactsListSearchBar: View {
@EnvironmentObject var m: ChatModel
+ @StateObject private var connectProgressManager = ConnectProgressManager.shared
@EnvironmentObject var theme: AppTheme
@Binding var searchMode: Bool
@FocusState.Binding var searchFocussed: Bool
@@ -346,8 +326,6 @@ struct ContactsListSearchBar: View {
@Binding var searchShowingSimplexLink: Bool
@Binding var searchChatFilteredBySimplexLink: String?
@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 {
@@ -364,6 +342,9 @@ struct ContactsListSearchBar: View {
.disabled(searchShowingSimplexLink)
.focused($searchFocussed)
.frame(maxWidth: .infinity)
+ if connectProgressManager.showConnectProgress != nil {
+ ProgressView()
+ }
if !searchText.isEmpty {
Image(systemName: "xmark.circle.fill")
.resizable()
@@ -400,7 +381,7 @@ struct ContactsListSearchBar: View {
} else {
if let link = strHasSingleSimplexLink(t.trimmingCharacters(in: .whitespaces)) { // if SimpleX link is pasted, show connection dialogue
searchFocussed = false
- if case let .simplexLink(linkType, _, smpHosts) = link.format {
+ if case let .simplexLink(_, linkType, _, smpHosts) = link.format {
ignoreSearchTextChange = true
searchText = simplexLinkText(linkType, smpHosts)
}
@@ -410,18 +391,14 @@ struct ContactsListSearchBar: View {
} else {
if t != "" { // if some other text is pasted, enter search mode
searchFocussed = true
+ } else {
+ connectProgressManager.cancelConnectProgress()
}
searchShowingSimplexLink = false
searchChatFilteredBySimplexLink = nil
}
}
}
- .alert(item: $alert) { a in
- planAndConnectAlert(a, dismiss: true, cleanup: { searchText = "" })
- }
- .actionSheet(item: $sheet) { s in
- planAndConnectActionSheet(s, dismiss: true, cleanup: { searchText = "" })
- }
}
private func toggleFilterButton() -> some View {
@@ -442,10 +419,12 @@ struct ContactsListSearchBar: View {
private func connect(_ link: String) {
planAndConnect(
link,
- showAlert: { alert = $0 },
- showActionSheet: { sheet = $0 },
+ theme: theme,
dismiss: true,
- incognito: nil,
+ cleanup: {
+ searchText = ""
+ searchFocussed = false
+ },
filterKnownContact: { searchChatFilteredBySimplexLink = $0.id }
)
}
@@ -453,7 +432,6 @@ struct ContactsListSearchBar: View {
struct DeletedChats: View {
- @State private var baseContactTypes: [ContactType] = [.chatDeleted]
@State private var searchMode = false
@FocusState var searchFocussed: Bool
@State private var searchText = ""
@@ -475,7 +453,7 @@ struct DeletedChats: View {
.frame(maxWidth: .infinity)
ContactsList(
- baseContactTypes: $baseContactTypes,
+ chatPredicate: { chat, _ in chat.chatInfo.contact?.chatDeleted == true },
searchMode: $searchMode,
searchText: $searchText,
searchFocussed: $searchFocussed,
diff --git a/apps/ios/Shared/Views/NewChat/NewChatView.swift b/apps/ios/Shared/Views/NewChat/NewChatView.swift
index 110eda7882..3de1fdb972 100644
--- a/apps/ios/Shared/Views/NewChat/NewChatView.swift
+++ b/apps/ios/Shared/Views/NewChat/NewChatView.swift
@@ -29,11 +29,9 @@ struct SomeSheet: Identifiable {
}
private enum NewChatViewAlert: Identifiable {
- case planAndConnectAlert(alert: PlanAndConnectAlert)
case newChatSomeAlert(alert: SomeAlert)
var id: String {
switch self {
- case let .planAndConnectAlert(alert): return "planAndConnectAlert \(alert.id)"
case let .newChatSomeAlert(alert): return "newChatSomeAlert \(alert.id)"
}
}
@@ -165,8 +163,6 @@ struct NewChatView: View {
}
.alert(item: $alert) { a in
switch(a) {
- case let .planAndConnectAlert(alert):
- return planAndConnectAlert(alert, dismiss: true, cleanup: { pastedLink = "" })
case let .newChatSomeAlert(a):
return a.alert
}
@@ -371,7 +367,6 @@ private struct ActiveProfilePicker: View {
.onAppear {
profiles = chatModel.users
.map { $0.user }
- .sorted { u, _ in u.activeUser }
}
.onChange(of: incognitoEnabled) { incognito in
if profileSwitchStatus != .switchingIncognito {
@@ -420,8 +415,8 @@ 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
connLinkInvitation = conn.connLinkInv ?? CreatedConnLink(connFullLink: "", connShortLink: nil)
@@ -429,7 +424,7 @@ private struct ActiveProfilePicker: View {
chatModel.updateContactConnection(conn)
}
do {
- try await changeActiveUserAsync_(profile.userId, viewPwd: profile.hidden ? trimmedSearchTextOrPassword : nil )
+ try await changeActiveUserAsync_(profile.userId, viewPwd: profile.hidden ? trimmedSearchTextOrPassword : nil)
await MainActor.run {
profileSwitchStatus = .idle
dismiss()
@@ -440,7 +435,7 @@ private struct ActiveProfilePicker: View {
alert = SomeAlert(
alert: Alert(
title: Text("Error switching profile"),
- message: Text("Your connection was moved to \(profile.chatViewName) but an unexpected error occurred while redirecting you to the profile.")
+ message: Text("Your connection was moved to \(profile.chatViewName) but an error happened when switching profile.")
),
id: "switchingProfileError"
)
@@ -479,8 +474,8 @@ private struct ActiveProfilePicker: View {
IncognitoHelp()
}
}
-
-
+
+
@ViewBuilder private func viewBody() -> some View {
profilePicker()
.allowsHitTesting(!switchingProfileByTimeout)
@@ -493,11 +488,11 @@ private struct ActiveProfilePicker: View {
}
}
}
-
+
private func filteredProfiles() -> [User] {
let s = trimmedSearchTextOrPassword
let lower = s.localizedLowercase
-
+
return profiles.filter { u in
if (u.activeUser || !u.hidden) && (s == "" || u.chatViewName.localizedLowercase.contains(lower)) {
return true
@@ -505,7 +500,7 @@ private struct ActiveProfilePicker: View {
return correctPassword(u, s)
}
}
-
+
private func profilerPickerUserOption(_ user: User) -> some View {
Button {
if selectedProfile == user && incognitoEnabled {
@@ -531,7 +526,7 @@ private struct ActiveProfilePicker: View {
}
}
}
-
+
@ViewBuilder private func profilePicker() -> some View {
let incognitoOption = Button {
if !incognitoEnabled {
@@ -557,14 +552,16 @@ private struct ActiveProfilePicker: View {
}
}
}
-
+
List {
let filteredProfiles = filteredProfiles()
let activeProfile = filteredProfiles.first { u in u.activeUser }
-
+
if let selectedProfile = activeProfile {
- let otherProfiles = filteredProfiles.filter { u in u.userId != activeProfile?.userId }
-
+ let otherProfiles = filteredProfiles
+ .filter { u in u.userId != activeProfile?.userId }
+ .sorted(using: KeyPathComparator(\.activeOrder, order: .reverse))
+
if incognitoFirst {
incognitoOption
profilerPickerUserOption(selectedProfile)
@@ -572,7 +569,7 @@ private struct ActiveProfilePicker: View {
profilerPickerUserOption(selectedProfile)
incognitoOption
}
-
+
ForEach(otherProfiles) { p in
profilerPickerUserOption(p)
}
@@ -588,12 +585,13 @@ private struct ActiveProfilePicker: View {
}
private struct ConnectView: View {
+ @StateObject private var connectProgressManager = ConnectProgressManager.shared
@Environment(\.dismiss) var dismiss: DismissAction
@EnvironmentObject var theme: AppTheme
@Binding var showQRCodeScanner: Bool
@Binding var pastedLink: String
@Binding var alert: NewChatViewAlert?
- @State private var sheet: PlanAndConnectActionSheet?
+ @State var scannerPaused: Bool = false
@State private var pasteboardHasStrings = UIPasteboard.general.hasStrings
var body: some View {
@@ -602,39 +600,49 @@ private struct ConnectView: View {
pasteLinkView()
}
Section(header: Text("Or scan QR code").foregroundColor(theme.colors.secondary)) {
- ScannerInView(showQRCodeScanner: $showQRCodeScanner, processQRCode: processQRCode)
+ ScannerInView(showQRCodeScanner: $showQRCodeScanner, scannerPaused: $scannerPaused, processQRCode: processQRCode)
}
}
- .actionSheet(item: $sheet) { s in
- planAndConnectActionSheet(s, dismiss: true, cleanup: { pastedLink = "" })
+ .onDisappear {
+ connectProgressManager.cancelConnectProgress()
}
}
@ViewBuilder private func pasteLinkView() -> some View {
if pastedLink == "" {
- Button {
- if let str = UIPasteboard.general.string {
- if let link = strHasSingleSimplexLink(str.trimmingCharacters(in: .whitespaces)) {
- pastedLink = link.text
- // It would be good to hide it, but right now it is not clear how to release camera in CodeScanner
- // https://github.com/twostraws/CodeScanner/issues/121
- // No known tricks worked (changing view ID, wrapping it in another view, etc.)
- // showQRCodeScanner = false
- connect(pastedLink)
- } else {
- alert = .newChatSomeAlert(alert: SomeAlert(
- alert: mkAlert(title: "Invalid link", message: "The text you pasted is not a SimpleX link."),
- id: "pasteLinkView: code is not a SimpleX link"
- ))
+ ZStack(alignment: .trailing) {
+ Button {
+ if let str = UIPasteboard.general.string {
+ if let link = strHasSingleSimplexLink(str.trimmingCharacters(in: .whitespaces)) {
+ pastedLink = link.text
+ // It would be good to hide it, but right now it is not clear how to release camera in CodeScanner
+ // https://github.com/twostraws/CodeScanner/issues/121
+ // No known tricks worked (changing view ID, wrapping it in another view, etc.)
+ // showQRCodeScanner = false
+ connect(pastedLink)
+ } else {
+ alert = .newChatSomeAlert(alert: SomeAlert(
+ alert: mkAlert(title: "Invalid link", message: "The text you pasted is not a SimpleX link."),
+ id: "pasteLinkView: code is not a SimpleX link"
+ ))
+ }
}
+ } label: {
+ Text("Tap to paste link")
+ }
+ .disabled(!pasteboardHasStrings)
+ .frame(maxWidth: .infinity, alignment: .center)
+ if connectProgressManager.showConnectProgress != nil {
+ ProgressView()
}
- } label: {
- Text("Tap to paste link")
}
- .disabled(!pasteboardHasStrings)
- .frame(maxWidth: .infinity, alignment: .center)
} else {
- linkTextView(pastedLink)
+ HStack {
+ linkTextView(pastedLink)
+ if connectProgressManager.showConnectProgress != nil {
+ ProgressView()
+ }
+ }
}
}
@@ -660,18 +668,22 @@ private struct ConnectView: View {
}
private func connect(_ link: String) {
+ scannerPaused = true
planAndConnect(
link,
- showAlert: { alert = .planAndConnectAlert(alert: $0) },
- showActionSheet: { sheet = $0 },
+ theme: theme,
dismiss: true,
- incognito: nil
+ cleanup: {
+ pastedLink = ""
+ scannerPaused = false
+ }
)
}
}
struct ScannerInView: View {
@Binding var showQRCodeScanner: Bool
+ var scannerPaused: Binding? = nil
let processQRCode: (_ resp: Result) -> Void
@State private var cameraAuthorizationStatus: AVAuthorizationStatus?
var scanMode: ScanMode = .continuous
@@ -679,7 +691,7 @@ struct ScannerInView: View {
var body: some View {
Group {
if showQRCodeScanner, case .authorized = cameraAuthorizationStatus {
- CodeScannerView(codeTypes: [.qr], scanMode: scanMode, completion: processQRCode)
+ CodeScannerView(codeTypes: [.qr], scanMode: scanMode, isPaused: scannerPaused?.wrappedValue ?? false, completion: processQRCode)
.aspectRatio(1, contentMode: .fit)
.cornerRadius(12)
.listRowBackground(Color.clear)
@@ -839,348 +851,570 @@ func sharedProfileInfo(_ incognito: Bool) -> Text {
)
}
-enum PlanAndConnectAlert: Identifiable {
- 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.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)"
- }
- }
+private func showInvitationLinkConnectingAlert(cleanup: (() -> Void)?) {
+ showAlert(
+ NSLocalizedString("Already connecting!", comment: "new chat sheet title"),
+ message: NSLocalizedString("You are already connecting via this one-time link!", comment: "new chat sheet message"),
+ actions: {[
+ okCleanupAlertAction(cleanup: cleanup)
+ ]}
+ )
}
-func planAndConnectAlert(_ alert: PlanAndConnectAlert, dismiss: Bool, cleanup: (() -> Void)? = nil) -> Alert {
- switch alert {
- case let .ownInvitationLinkConfirmConnect(connectionLink, connectionPlan, incognito):
- return Alert(
- title: Text("Connect to yourself?"),
- message: Text("This is your own one-time link!"),
- primaryButton: .destructive(
- Text(incognito ? "Connect incognito" : "Connect"),
- action: { connectViaLink(connectionLink, connectionPlan: connectionPlan, dismiss: dismiss, incognito: incognito, cleanup: cleanup) }
- ),
- secondaryButton: .cancel() { cleanup?() }
- )
- case .invitationLinkConnecting:
- return Alert(
- title: Text("Already connecting!"),
- message: Text("You are already connecting via this one-time link!"),
- dismissButton: .default(Text("OK")) { cleanup?() }
- )
- case let .ownContactAddressConfirmConnect(connectionLink, connectionPlan, incognito):
- return Alert(
- title: Text("Connect to yourself?"),
- message: Text("This is your own SimpleX address!"),
- primaryButton: .destructive(
- Text(incognito ? "Connect incognito" : "Connect"),
- action: { connectViaLink(connectionLink, connectionPlan: connectionPlan, dismiss: dismiss, incognito: incognito, cleanup: cleanup) }
- ),
- secondaryButton: .cancel() { cleanup?() }
- )
- case let .contactAddressConnectingConfirmReconnect(connectionLink, connectionPlan, incognito):
- return Alert(
- title: Text("Repeat connection request?"),
- message: Text("You have already requested connection via this address!"),
- primaryButton: .destructive(
- Text(incognito ? "Connect incognito" : "Connect"),
- action: { connectViaLink(connectionLink, connectionPlan: connectionPlan, dismiss: dismiss, incognito: incognito, cleanup: cleanup) }
- ),
- secondaryButton: .cancel() { cleanup?() }
- )
- case let .groupLinkConfirmConnect(connectionLink, connectionPlan, incognito):
- return Alert(
- title: Text("Join group?"),
- message: Text("You will connect to all group members."),
- primaryButton: .default(
- Text(incognito ? "Join incognito" : "Join"),
- action: { connectViaLink(connectionLink, connectionPlan: connectionPlan, dismiss: dismiss, incognito: incognito, cleanup: cleanup) }
- ),
- secondaryButton: .cancel() { cleanup?() }
- )
- case let .groupLinkConnectingConfirmReconnect(connectionLink, connectionPlan, incognito):
- return Alert(
- title: Text("Repeat join request?"),
- message: Text("You are already joining the group via this link!"),
- primaryButton: .destructive(
- Text(incognito ? "Join incognito" : "Join"),
- action: { connectViaLink(connectionLink, connectionPlan: connectionPlan, dismiss: dismiss, incognito: incognito, cleanup: cleanup) }
- ),
- secondaryButton: .cancel() { cleanup?() }
- )
- case let .groupLinkConnecting(_, groupInfo):
- if let groupInfo = groupInfo {
- return groupInfo.businessChat == nil
- ? Alert(
- title: Text("Group already exists!"),
- message: Text("You are already joining the group \(groupInfo.displayName)."),
- dismissButton: .default(Text("OK")) { cleanup?() }
- )
- : Alert(
- title: Text("Chat already exists!"),
- message: Text("You are already connecting to \(groupInfo.displayName)."),
- dismissButton: .default(Text("OK")) { cleanup?() }
+private func showGroupLinkConnectingAlert(groupInfo: GroupInfo?, cleanup: (() -> Void)?) {
+ if let groupInfo = groupInfo {
+ if groupInfo.businessChat == nil {
+ showAlert(
+ NSLocalizedString("Group already exists!", comment: "new chat sheet title"),
+ message:
+ String.localizedStringWithFormat(
+ NSLocalizedString("You are already joining the group %@.", comment: "new chat sheet message"),
+ groupInfo.displayName
+ ),
+ actions: {[
+ okCleanupAlertAction(cleanup: cleanup)
+ ]}
)
} else {
- return Alert(
- title: Text("Already joining the group!"),
- message: Text("You are already joining the group via this link."),
- dismissButton: .default(Text("OK")) { cleanup?() }
+ showAlert(
+ NSLocalizedString("Chat already exists!", comment: "new chat sheet title"),
+ message:
+ String.localizedStringWithFormat(
+ NSLocalizedString("You are already connecting to %@.", comment: "new chat sheet message"),
+ groupInfo.displayName
+ ),
+ actions: {[
+ okCleanupAlertAction(cleanup: cleanup)
+ ]}
)
}
- case let .error(_, alert): return alert
+ } else {
+ showAlert(
+ NSLocalizedString("Already joining the group!", comment: "new chat sheet title"),
+ message: NSLocalizedString("You are already joining the group via this link.", comment: "new chat sheet message"),
+ actions: {[
+ okCleanupAlertAction(cleanup: cleanup)
+ ]}
+ )
}
}
-enum PlanAndConnectActionSheet: Identifiable {
- case askCurrentOrIncognitoProfile(connectionLink: CreatedConnLink, connectionPlan: ConnectionPlan?, title: LocalizedStringKey)
- case askCurrentOrIncognitoProfileDestructive(connectionLink: CreatedConnLink, connectionPlan: ConnectionPlan, title: LocalizedStringKey)
- case askCurrentOrIncognitoProfileConnectContactViaAddress(contact: Contact)
- case ownGroupLinkConfirmConnect(connectionLink: CreatedConnLink, connectionPlan: ConnectionPlan, incognito: Bool?, groupInfo: GroupInfo)
-
- var id: String {
- switch self {
- 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.connFullLink)"
+private func okCleanupAlertAction(cleanup: (() -> Void)?) -> UIAlertAction {
+ UIAlertAction(
+ title: NSLocalizedString("Ok", comment: "new chat action"),
+ style: .default,
+ handler: { _ in
+ cleanup?()
}
- }
+ )
}
-func planAndConnectActionSheet(_ sheet: PlanAndConnectActionSheet, dismiss: Bool, cleanup: (() -> Void)? = nil) -> ActionSheet {
- switch sheet {
- case let .askCurrentOrIncognitoProfile(connectionLink, connectionPlan, title):
- return ActionSheet(
- title: Text(title),
- buttons: [
- .default(Text("Use current profile")) { connectViaLink(connectionLink, connectionPlan: connectionPlan, dismiss: dismiss, incognito: false, cleanup: cleanup) },
- .default(Text("Use new incognito profile")) { connectViaLink(connectionLink, connectionPlan: connectionPlan, dismiss: dismiss, incognito: true, cleanup: cleanup) },
- .cancel() { cleanup?() }
- ]
- )
- case let .askCurrentOrIncognitoProfileDestructive(connectionLink, connectionPlan, title):
- return ActionSheet(
- title: Text(title),
- buttons: [
- .destructive(Text("Use current profile")) { connectViaLink(connectionLink, connectionPlan: connectionPlan, dismiss: dismiss, incognito: false, cleanup: cleanup) },
- .destructive(Text("Use new incognito profile")) { connectViaLink(connectionLink, connectionPlan: connectionPlan, dismiss: dismiss, incognito: true, cleanup: cleanup) },
- .cancel() { cleanup?() }
- ]
- )
- case let .askCurrentOrIncognitoProfileConnectContactViaAddress(contact):
- return ActionSheet(
- title: Text("Connect with \(contact.chatViewName)"),
- buttons: [
- .default(Text("Use current profile")) { connectContactViaAddress_(contact, dismiss: dismiss, incognito: false, cleanup: cleanup) },
- .default(Text("Use new incognito profile")) { connectContactViaAddress_(contact, dismiss: dismiss, incognito: true, cleanup: cleanup) },
- .cancel() { cleanup?() }
- ]
- )
- case let .ownGroupLinkConfirmConnect(connectionLink, connectionPlan, incognito, groupInfo):
- if let incognito = incognito {
- return ActionSheet(
- title: Text("Join your group?\nThis is your link for group \(groupInfo.displayName)!"),
- buttons: [
- .default(Text("Open group")) { openKnownGroup(groupInfo, dismiss: dismiss, showAlreadyExistsAlert: nil) },
- .destructive(Text(incognito ? "Join incognito" : "Join with current profile")) { connectViaLink(connectionLink, connectionPlan: connectionPlan, dismiss: dismiss, incognito: incognito, cleanup: cleanup) },
- .cancel() { cleanup?() }
- ]
+private func showAskCurrentOrIncognitoProfileSheet(
+ title: String,
+ actionStyle: UIAlertAction.Style = .default,
+ connectionLink: CreatedConnLink,
+ connectionPlan: ConnectionPlan?,
+ dismiss: Bool,
+ cleanup: (() -> Void)?
+) {
+ showSheet(
+ title,
+ actions: {[
+ UIAlertAction(
+ title: NSLocalizedString("Use current profile", comment: "new chat action"),
+ style: actionStyle,
+ handler: { _ in
+ connectViaLink(connectionLink, connectionPlan: connectionPlan, dismiss: dismiss, incognito: false, cleanup: cleanup)
+ }
+ ),
+ UIAlertAction(
+ title: NSLocalizedString("Use new incognito profile", comment: "new chat action"),
+ style: actionStyle,
+ handler: { _ in
+ connectViaLink(connectionLink, connectionPlan: connectionPlan, dismiss: dismiss, incognito: true, cleanup: cleanup)
+ }
+ ),
+ UIAlertAction(
+ title: NSLocalizedString("Cancel", comment: "new chat action"),
+ style: .default,
+ handler: { _ in
+ cleanup?()
+ }
)
- } else {
- return ActionSheet(
- title: Text("Join your group?\nThis is your link for group \(groupInfo.displayName)!"),
- buttons: [
- .default(Text("Open group")) { openKnownGroup(groupInfo, dismiss: dismiss, showAlreadyExistsAlert: nil) },
- .destructive(Text("Use current profile")) { connectViaLink(connectionLink, connectionPlan: connectionPlan, dismiss: dismiss, incognito: false, cleanup: cleanup) },
- .destructive(Text("Use new incognito profile")) { connectViaLink(connectionLink, connectionPlan: connectionPlan, dismiss: dismiss, incognito: true, cleanup: cleanup) },
- .cancel() { cleanup?() }
- ]
+ ]}
+ )
+}
+
+private func showAskCurrentOrIncognitoProfileConnectContactViaAddressSheet(
+ contact: Contact,
+ dismiss: Bool,
+ cleanup: (() -> Void)?
+) {
+ showSheet(
+ String.localizedStringWithFormat(
+ NSLocalizedString("Connect with %@", comment: "new chat action"),
+ contact.chatViewName
+ ),
+ actions: {[
+ UIAlertAction(
+ title: NSLocalizedString("Use current profile", comment: "new chat action"),
+ style: .default,
+ handler: { _ in
+ connectContactViaAddress_(contact, dismiss: dismiss, incognito: false, cleanup: cleanup)
+ }
+ ),
+ UIAlertAction(
+ title: NSLocalizedString("Use new incognito profile", comment: "new chat action"),
+ style: .default,
+ handler: { _ in
+ connectContactViaAddress_(contact, dismiss: dismiss, incognito: true, cleanup: cleanup)
+ }
+ ),
+ UIAlertAction(
+ title: NSLocalizedString("Cancel", comment: "new chat action"),
+ style: .default,
+ handler: { _ in
+ cleanup?()
+ }
)
+ ]}
+ )
+}
+
+private func showOwnGroupLinkConfirmConnectSheet(
+ groupInfo: GroupInfo,
+ connectionLink: CreatedConnLink,
+ connectionPlan: ConnectionPlan?,
+ dismiss: Bool,
+ cleanup: (() -> Void)?
+) {
+ showSheet(
+ String.localizedStringWithFormat(
+ NSLocalizedString("Join your group?\nThis is your link for group %@!", comment: "new chat action"),
+ groupInfo.displayName
+ ),
+ actions: {[
+ UIAlertAction(
+ title: NSLocalizedString("Open group", comment: "new chat action"),
+ style: .default,
+ handler: { _ in
+ openKnownGroup(groupInfo, dismiss: dismiss, cleanup: cleanup)
+ }
+ ),
+ UIAlertAction(
+ title: NSLocalizedString("Use current profile", comment: "new chat action"),
+ style: .destructive,
+ handler: { _ in
+ connectViaLink(connectionLink, connectionPlan: connectionPlan, dismiss: dismiss, incognito: false, cleanup: cleanup)
+ }
+ ),
+ UIAlertAction(
+ title: NSLocalizedString("Use new incognito profile", comment: "new chat action"),
+ style: .destructive,
+ handler: { _ in
+ connectViaLink(connectionLink, connectionPlan: connectionPlan, dismiss: dismiss, incognito: true, cleanup: cleanup)
+ }
+ ),
+ UIAlertAction(
+ title: NSLocalizedString("Cancel", comment: "new chat action"),
+ style: .default,
+ handler: { _ in
+ cleanup?()
+ }
+ )
+ ]}
+ )
+}
+
+private func showPrepareContactAlert(
+ connectionLink: CreatedConnLink,
+ contactShortLinkData: ContactShortLinkData,
+ theme: AppTheme,
+ dismiss: Bool,
+ cleanup: (() -> Void)?
+) {
+ showOpenChatAlert(
+ profileName: contactShortLinkData.profile.displayName,
+ profileFullName: contactShortLinkData.profile.fullName,
+ profileImage:
+ ProfileImage(
+ imageStr: contactShortLinkData.profile.image,
+ iconName: contactShortLinkData.business
+ ? "briefcase.circle.fill"
+ : contactShortLinkData.profile.peerType == .bot
+ ? "cube.fill"
+ : "person.crop.circle.fill",
+ size: alertProfileImageSize
+ ),
+ theme: theme,
+ cancelTitle: NSLocalizedString("Cancel", comment: "new chat action"),
+ confirmTitle: NSLocalizedString("Open new chat", comment: "new chat action"),
+ onCancel: { cleanup?() },
+ onConfirm: {
+ Task {
+ do {
+ let chat = try await apiPrepareContact(connLink: connectionLink, contactShortLinkData: contactShortLinkData)
+ await MainActor.run {
+ ChatModel.shared.addChat(Chat(chat))
+ openKnownChat(chat.id, dismiss: dismiss, cleanup: cleanup)
+ }
+ } catch let error {
+ logger.error("showPrepareContactAlert apiPrepareContact error: \(error.localizedDescription)")
+ showAlert(NSLocalizedString("Error opening chat", comment: ""), message: responseError(error))
+ await MainActor.run {
+ cleanup?()
+ }
+ }
+ }
}
- }
+ )
+}
+
+private func showPrepareGroupAlert(
+ connectionLink: CreatedConnLink,
+ groupShortLinkData: GroupShortLinkData,
+ theme: AppTheme,
+ dismiss: Bool,
+ cleanup: (() -> Void)?
+) {
+ showOpenChatAlert(
+ profileName: groupShortLinkData.groupProfile.displayName,
+ profileFullName: groupShortLinkData.groupProfile.fullName,
+ profileImage: ProfileImage(imageStr: groupShortLinkData.groupProfile.image, iconName: "person.2.circle.fill", size: alertProfileImageSize),
+ theme: theme,
+ cancelTitle: NSLocalizedString("Cancel", comment: "new chat action"),
+ confirmTitle: NSLocalizedString("Open new group", comment: "new chat action"),
+ onCancel: { cleanup?() },
+ onConfirm: {
+ Task {
+ do {
+ let chat = try await apiPrepareGroup(connLink: connectionLink, groupShortLinkData: groupShortLinkData)
+ await MainActor.run {
+ ChatModel.shared.addChat(Chat(chat))
+ openKnownChat(chat.id, dismiss: dismiss, cleanup: cleanup)
+ }
+ } catch let error {
+ logger.error("showPrepareGroupAlert apiPrepareGroup error: \(error.localizedDescription)")
+ showAlert(NSLocalizedString("Error opening group", comment: ""), message: responseError(error))
+ await MainActor.run {
+ cleanup?()
+ }
+ }
+ }
+ }
+ )
+}
+
+private func showOpenKnownContactAlert(
+ _ contact: Contact,
+ theme: AppTheme,
+ dismiss: Bool
+) {
+ showOpenChatAlert(
+ profileName: contact.profile.displayName,
+ profileFullName: contact.profile.fullName,
+ profileImage:
+ ProfileImage(
+ imageStr: contact.profile.image,
+ iconName: contact.chatIconName,
+ size: alertProfileImageSize
+ ),
+ theme: theme,
+ cancelTitle: NSLocalizedString("Cancel", comment: "new chat action"),
+ confirmTitle:
+ contact.nextConnectPrepared
+ ? NSLocalizedString("Open new chat", comment: "new chat action")
+ : NSLocalizedString("Open chat", comment: "new chat action"),
+ onConfirm: {
+ openKnownContact(contact, dismiss: dismiss, cleanup: nil)
+ }
+ )
+}
+
+private func showOpenKnownGroupAlert(
+ _ groupInfo: GroupInfo,
+ theme: AppTheme,
+ dismiss: Bool
+) {
+ showOpenChatAlert(
+ profileName: groupInfo.groupProfile.displayName,
+ profileFullName: groupInfo.groupProfile.fullName,
+ profileImage:
+ ProfileImage(
+ imageStr: groupInfo.groupProfile.image,
+ iconName: groupInfo.chatIconName,
+ size: alertProfileImageSize
+ ),
+ theme: theme,
+ cancelTitle: NSLocalizedString("Cancel", comment: "new chat action"),
+ confirmTitle:
+ groupInfo.businessChat == nil
+ ? ( groupInfo.nextConnectPrepared
+ ? NSLocalizedString("Open new group", comment: "new chat action")
+ : NSLocalizedString("Open group", comment: "new chat action")
+ )
+ : ( groupInfo.nextConnectPrepared
+ ? NSLocalizedString("Open new chat", comment: "new chat action")
+ : NSLocalizedString("Open chat", comment: "new chat action")
+ ),
+ onConfirm: {
+ openKnownGroup(groupInfo, dismiss: dismiss, cleanup: nil)
+ }
+ )
}
func planAndConnect(
_ shortOrFullLink: String,
- showAlert: @escaping (PlanAndConnectAlert) -> Void,
- showActionSheet: @escaping (PlanAndConnectActionSheet) -> Void,
+ theme: AppTheme,
dismiss: Bool,
- incognito: Bool?,
cleanup: (() -> Void)? = nil,
filterKnownContact: ((Contact) -> Void)? = nil,
filterKnownGroup: ((GroupInfo) -> Void)? = nil
) {
- Task {
- let (result, alert) = await apiConnectPlan(connLink: shortOrFullLink)
- if let (connectionLink, connectionPlan) = result {
- switch connectionPlan {
- case let .invitationLink(ilp):
- switch ilp {
- case .ok:
- logger.debug("planAndConnect, .invitationLink, .ok, incognito=\(incognito?.description ?? "nil")")
- if let incognito = incognito {
- connectViaLink(connectionLink, connectionPlan: connectionPlan, dismiss: dismiss, incognito: incognito, cleanup: cleanup)
- } else {
- 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")")
- await MainActor.run {
- if let incognito = incognito {
- showAlert(.ownInvitationLinkConfirmConnect(connectionLink: connectionLink, connectionPlan: connectionPlan, incognito: incognito))
+ ConnectProgressManager.shared.cancelConnectProgress()
+ let inProgress = BoxedValue(true)
+ connectTask(inProgress)
+ ConnectProgressManager.shared.startConnectProgress(NSLocalizedString("Loading profile…", comment: "in progress text")) {
+ inProgress.boxedValue = false
+ cleanup?()
+ }
+
+ func connectTask(_ inProgress: BoxedValue) {
+ Task {
+ let (result, alert) = await apiConnectPlan(connLink: shortOrFullLink, inProgress: inProgress)
+ await MainActor.run {
+ ConnectProgressManager.shared.stopConnectProgress()
+ }
+ if !inProgress.boxedValue { return }
+ if let (connectionLink, connectionPlan) = result {
+ switch connectionPlan {
+ case let .invitationLink(ilp):
+ switch ilp {
+ case let .ok(contactSLinkData_):
+ if let contactSLinkData = contactSLinkData_ {
+ logger.debug("planAndConnect, .invitationLink, .ok, short link data present")
+ await MainActor.run {
+ showPrepareContactAlert(
+ connectionLink: connectionLink,
+ contactShortLinkData: contactSLinkData,
+ theme: theme,
+ dismiss: dismiss,
+ cleanup: cleanup
+ )
+ }
} else {
- showActionSheet(.askCurrentOrIncognitoProfileDestructive(connectionLink: connectionLink, connectionPlan: connectionPlan, title: "Connect to yourself?\nThis is your own one-time link!"))
+ logger.debug("planAndConnect, .invitationLink, .ok, no short link data")
+ await MainActor.run {
+ showAskCurrentOrIncognitoProfileSheet(
+ title: NSLocalizedString("Connect via one-time link", comment: "new chat sheet title"),
+ connectionLink: connectionLink,
+ connectionPlan: connectionPlan,
+ dismiss: dismiss,
+ cleanup: cleanup
+ )
+ }
}
- }
- case let .connecting(contact_):
- logger.debug("planAndConnect, .invitationLink, .connecting, incognito=\(incognito?.description ?? "nil")")
- await MainActor.run {
- if let contact = contact_ {
+ case .ownLink:
+ logger.debug("planAndConnect, .invitationLink, .ownLink")
+ await MainActor.run {
+ showAskCurrentOrIncognitoProfileSheet(
+ title: NSLocalizedString("Connect to yourself?\nThis is your own one-time link!", comment: "new chat sheet title"),
+ actionStyle: .destructive,
+ connectionLink: connectionLink,
+ connectionPlan: connectionPlan,
+ dismiss: dismiss,
+ cleanup: cleanup
+ )
+ }
+ case let .connecting(contact_):
+ logger.debug("planAndConnect, .invitationLink, .connecting")
+ await MainActor.run {
+ if let contact = contact_ {
+ if let f = filterKnownContact {
+ f(contact)
+ } else {
+ showOpenKnownContactAlert(contact, theme: theme, dismiss: dismiss)
+ }
+ } else {
+ showInvitationLinkConnectingAlert(cleanup: cleanup)
+ }
+ }
+ case let .known(contact):
+ logger.debug("planAndConnect, .invitationLink, .known")
+ await MainActor.run {
if let f = filterKnownContact {
f(contact)
} else {
- openKnownContact(contact, dismiss: dismiss) { AlertManager.shared.showAlert(contactAlreadyConnectingAlert(contact)) }
+ showOpenKnownContactAlert(contact, theme: theme, dismiss: dismiss)
+ }
+ }
+ }
+ case let .contactAddress(cap):
+ switch cap {
+ case let .ok(contactSLinkData_):
+ if let contactSLinkData = contactSLinkData_ {
+ logger.debug("planAndConnect, .contactAddress, .ok, short link data present")
+ await MainActor.run {
+ showPrepareContactAlert(
+ connectionLink: connectionLink,
+ contactShortLinkData: contactSLinkData,
+ theme: theme,
+ dismiss: dismiss,
+ cleanup: cleanup
+ )
}
} else {
- showAlert(.invitationLinkConnecting(connectionLink: connectionLink))
+ logger.debug("planAndConnect, .contactAddress, .ok, no short link data")
+ await MainActor.run {
+ showAskCurrentOrIncognitoProfileSheet(
+ title: NSLocalizedString("Connect via contact address", comment: "new chat sheet title"),
+ connectionLink: connectionLink,
+ connectionPlan: connectionPlan,
+ dismiss: dismiss,
+ cleanup: cleanup
+ )
+ }
}
- }
- case let .known(contact):
- logger.debug("planAndConnect, .invitationLink, .known, incognito=\(incognito?.description ?? "nil")")
- await MainActor.run {
- if let f = filterKnownContact {
- f(contact)
- } else {
- openKnownContact(contact, dismiss: dismiss) { AlertManager.shared.showAlert(contactAlreadyExistsAlert(contact)) }
- }
- }
- }
- case let .contactAddress(cap):
- switch cap {
- case .ok:
- logger.debug("planAndConnect, .contactAddress, .ok, incognito=\(incognito?.description ?? "nil")")
- if let incognito = incognito {
- connectViaLink(connectionLink, connectionPlan: connectionPlan, dismiss: dismiss, incognito: incognito, cleanup: cleanup)
- } else {
+ case .ownLink:
+ logger.debug("planAndConnect, .contactAddress, .ownLink")
await MainActor.run {
- showActionSheet(.askCurrentOrIncognitoProfile(connectionLink: connectionLink, connectionPlan: connectionPlan, title: "Connect via contact address"))
+ showAskCurrentOrIncognitoProfileSheet(
+ title: NSLocalizedString("Connect to yourself?\nThis is your own SimpleX address!", comment: "new chat sheet title"),
+ actionStyle: .destructive,
+ connectionLink: connectionLink,
+ connectionPlan: connectionPlan,
+ dismiss: dismiss,
+ cleanup: cleanup
+ )
}
- }
- case .ownLink:
- logger.debug("planAndConnect, .contactAddress, .ownLink, incognito=\(incognito?.description ?? "nil")")
- 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")")
- 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")")
- 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")")
- 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 {
+ case .connectingConfirmReconnect:
+ logger.debug("planAndConnect, .contactAddress, .connectingConfirmReconnect")
await MainActor.run {
- showActionSheet(.askCurrentOrIncognitoProfileConnectContactViaAddress(contact: contact))
+ showAskCurrentOrIncognitoProfileSheet(
+ title: NSLocalizedString("You have already requested connection!\nRepeat connection request?", comment: "new chat sheet title"),
+ actionStyle: .destructive,
+ connectionLink: connectionLink,
+ connectionPlan: connectionPlan,
+ dismiss: dismiss,
+ cleanup: cleanup
+ )
}
+ case let .connectingProhibit(contact):
+ logger.debug("planAndConnect, .contactAddress, .connectingProhibit")
+ await MainActor.run {
+ if let f = filterKnownContact {
+ f(contact)
+ } else {
+ showOpenKnownContactAlert(contact, theme: theme, dismiss: dismiss)
+ }
+ }
+ case let .known(contact):
+ logger.debug("planAndConnect, .contactAddress, .known")
+ await MainActor.run {
+ if let f = filterKnownContact {
+ f(contact)
+ } else {
+ showOpenKnownContactAlert(contact, theme: theme, dismiss: dismiss)
+ }
+ }
+ case let .contactViaAddress(contact):
+ logger.debug("planAndConnect, .contactAddress, .contactViaAddress")
+ await MainActor.run {
+ showAskCurrentOrIncognitoProfileConnectContactViaAddressSheet(
+ contact: contact,
+ dismiss: dismiss,
+ cleanup: cleanup
+ )
+ }
+ }
+ case let .groupLink(glp):
+ switch glp {
+ case let .ok(groupSLinkData_):
+ if let groupSLinkData = groupSLinkData_ {
+ logger.debug("planAndConnect, .groupLink, .ok, short link data present")
+ await MainActor.run {
+ showPrepareGroupAlert(
+ connectionLink: connectionLink,
+ groupShortLinkData: groupSLinkData,
+ theme: theme,
+ dismiss: dismiss,
+ cleanup: cleanup
+ )
+ }
+ } else {
+ logger.debug("planAndConnect, .groupLink, .ok, no short link data")
+ await MainActor.run {
+ showAskCurrentOrIncognitoProfileSheet(
+ title: NSLocalizedString("Join group", comment: "new chat sheet title"),
+ connectionLink: connectionLink,
+ connectionPlan: connectionPlan,
+ dismiss: dismiss,
+ cleanup: cleanup
+ )
+ }
+ }
+ case let .ownLink(groupInfo):
+ logger.debug("planAndConnect, .groupLink, .ownLink")
+ await MainActor.run {
+ if let f = filterKnownGroup {
+ f(groupInfo)
+ }
+ showOwnGroupLinkConfirmConnectSheet(
+ groupInfo: groupInfo,
+ connectionLink: connectionLink,
+ connectionPlan: connectionPlan,
+ dismiss: dismiss,
+ cleanup: cleanup
+ )
+ }
+ case .connectingConfirmReconnect:
+ logger.debug("planAndConnect, .groupLink, .connectingConfirmReconnect")
+ await MainActor.run {
+ showAskCurrentOrIncognitoProfileSheet(
+ title: NSLocalizedString("You are already joining the group!\nRepeat join request?", comment: "new chat sheet title"),
+ actionStyle: .destructive,
+ connectionLink: connectionLink,
+ connectionPlan: connectionPlan,
+ dismiss: dismiss,
+ cleanup: cleanup
+ )
+ }
+ case let .connectingProhibit(groupInfo_):
+ logger.debug("planAndConnect, .groupLink, .connectingProhibit")
+ await MainActor.run {
+ showGroupLinkConnectingAlert(groupInfo: groupInfo_, cleanup: cleanup)
+ }
+ case let .known(groupInfo):
+ logger.debug("planAndConnect, .groupLink, .known")
+ await MainActor.run {
+ if let f = filterKnownGroup {
+ f(groupInfo)
+ } else {
+ showOpenKnownGroupAlert(groupInfo, theme: theme, dismiss: dismiss)
+ }
+ }
+ }
+ case let .error(chatError):
+ logger.debug("planAndConnect, .error \(chatErrorString(chatError))")
+ showAskCurrentOrIncognitoProfileSheet(
+ title: NSLocalizedString("Connect via link", comment: "new chat sheet title"),
+ connectionLink: connectionLink,
+ connectionPlan: nil,
+ dismiss: dismiss,
+ cleanup: cleanup
+ )
+ }
+ } else {
+ await MainActor.run {
+ if let alert {
+ dismissAllSheets(animated: true) {
+ AlertManager.shared.showAlert(alert)
+ cleanup?()
+ }
+ } else {
+ cleanup?()
}
}
- case let .groupLink(glp):
- switch glp {
- case .ok:
- 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")")
- await MainActor.run {
- if let f = filterKnownGroup {
- f(groupInfo)
- }
- showActionSheet(.ownGroupLinkConfirmConnect(connectionLink: connectionLink, connectionPlan: connectionPlan, incognito: incognito, groupInfo: groupInfo))
- }
- case .connectingConfirmReconnect:
- logger.debug("planAndConnect, .groupLink, .connectingConfirmReconnect, incognito=\(incognito?.description ?? "nil")")
- 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")")
- await MainActor.run {
- showAlert(.groupLinkConnecting(connectionLink: connectionLink, groupInfo: groupInfo_))
- }
- case let .known(groupInfo):
- logger.debug("planAndConnect, .groupLink, .known, incognito=\(incognito?.description ?? "nil")")
- 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"))
- }
- }
- } else if let alert {
- await MainActor.run {
- showAlert(.error(shortOrFullLink: shortOrFullLink, alert: alert))
}
}
}
@@ -1239,37 +1473,29 @@ private func connectViaLink(
}
}
-func openKnownContact(_ contact: Contact, dismiss: Bool, showAlreadyExistsAlert: (() -> Void)?) {
- 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 openKnownContact(_ contact: Contact, dismiss: Bool, cleanup: (() -> Void)?) {
+ if let c = ChatModel.shared.getContactChat(contact.contactId) {
+ openKnownChat(c.id, dismiss: dismiss, cleanup: cleanup)
}
}
-func openKnownGroup(_ groupInfo: GroupInfo, dismiss: Bool, showAlreadyExistsAlert: (() -> Void)?) {
- 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?()
+func openKnownGroup(_ groupInfo: GroupInfo, dismiss: Bool, cleanup: (() -> Void)?) {
+ if let g = ChatModel.shared.getGroupChat(groupInfo.groupId) {
+ openKnownChat(g.id, dismiss: dismiss, cleanup: cleanup)
+ }
+}
+
+func openKnownChat(_ chatId: ChatId, dismiss: Bool, cleanup: (() -> Void)?) {
+ if dismiss {
+ dismissAllSheets(animated: true) {
+ ItemsModel.shared.loadOpenChat(chatId) {
+ cleanup?()
}
}
+ } else {
+ ItemsModel.shared.loadOpenChat(chatId) {
+ cleanup?()
+ }
}
}
diff --git a/apps/ios/Shared/Views/NewChat/QRCode.swift b/apps/ios/Shared/Views/NewChat/QRCode.swift
index 453149198b..c9054f30da 100644
--- a/apps/ios/Shared/Views/NewChat/QRCode.swift
+++ b/apps/ios/Shared/Views/NewChat/QRCode.swift
@@ -12,11 +12,12 @@ import SimpleXChat
struct MutableQRCode: View {
@Binding var uri: String
+ var small: Bool = false
var withLogo: Bool = true
var tintColor = UIColor(red: 0.023, green: 0.176, blue: 0.337, alpha: 1)
var body: some View {
- QRCode(uri: uri, withLogo: withLogo, tintColor: tintColor)
+ QRCode(uri: uri, small: small, withLogo: withLogo, tintColor: tintColor)
.id("simplex-qrcode-view-for-\(uri)")
}
}
@@ -27,7 +28,7 @@ struct SimpleXCreatedLinkQRCode: View {
var onShare: (() -> Void)? = nil
var body: some View {
- QRCode(uri: link.simplexChatUri(short: short), onShare: onShare)
+ QRCode(uri: link.simplexChatUri(short: short), small: short && link.connShortLink != nil, onShare: onShare)
}
}
@@ -38,50 +39,57 @@ struct SimpleXLinkQRCode: View {
var onShare: (() -> Void)? = nil
var body: some View {
- QRCode(uri: simplexChatLink(uri), withLogo: withLogo, tintColor: tintColor, onShare: onShare)
+ QRCode(uri: simplexChatLink(uri), small: uri.count < 200, withLogo: withLogo, tintColor: tintColor, onShare: onShare)
}
}
+private let smallQRRatio: CGFloat = 0.63
+
struct QRCode: View {
let uri: String
+ var small: Bool = false
var withLogo: Bool = true
var tintColor = UIColor(red: 0.023, green: 0.176, blue: 0.337, alpha: 1)
var onShare: (() -> Void)? = nil
@State private var image: UIImage? = nil
@State private var makeScreenshotFunc: () -> Void = {}
+ @State private var width: CGFloat = .infinity
var body: some View {
ZStack {
if let image = image {
- qrCodeImage(image)
- GeometryReader { geo in
+ qrCodeImage(image).frame(width: width, height: width)
+ GeometryReader { g in
+ let w = g.size.width * (small ? smallQRRatio : 1)
+ let l = w * (small ? 0.195 : 0.16)
+ let m = w * 0.005
ZStack {
if withLogo {
- let w = geo.size.width
Image("icon-light")
.resizable()
.scaledToFit()
- .frame(width: w * 0.16, height: w * 0.16)
- .frame(width: w * 0.165, height: w * 0.165)
+ .frame(width: l, height: l)
+ .frame(width: l + m, height: l + m)
.background(.white)
.clipShape(Circle())
}
}
.onAppear {
+ width = w
makeScreenshotFunc = {
let size = CGSizeMake(1024 / UIScreen.main.scale, 1024 / UIScreen.main.scale)
- showShareSheet(items: [makeScreenshot(geo.frame(in: .local).origin, size)])
+ showShareSheet(items: [makeScreenshot(g.frame(in: .local).origin, size)])
onShare?()
}
}
- .frame(width: geo.size.width, height: geo.size.height)
+ .frame(width: g.size.width, height: g.size.height)
}
} else {
- Color.clear.aspectRatio(1, contentMode: .fit)
+ Color.clear.aspectRatio(small ? 1 / smallQRRatio : 1, contentMode: .fit)
}
}
.onTapGesture(perform: makeScreenshotFunc)
- .task { image = await generateImage(uri, tintColor: tintColor) }
+ .task { image = await generateImage(uri, tintColor: tintColor, errorLevel: small ? "M" : "L") }
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}
@@ -94,10 +102,11 @@ private func qrCodeImage(_ image: UIImage) -> some View {
.textSelection(.enabled)
}
-private func generateImage(_ uri: String, tintColor: UIColor) async -> UIImage? {
+private func generateImage(_ uri: String, tintColor: UIColor, errorLevel: String) async -> UIImage? {
let context = CIContext()
let filter = CIFilter.qrCodeGenerator()
filter.message = Data(uri.utf8)
+ filter.correctionLevel = errorLevel
if let outputImage = filter.outputImage,
let cgImage = context.createCGImage(outputImage, from: outputImage.extent) {
return UIImage(cgImage: cgImage).replaceColor(UIColor.black, tintColor)
diff --git a/apps/ios/Shared/Views/Onboarding/CreateProfile.swift b/apps/ios/Shared/Views/Onboarding/CreateProfile.swift
index ae72cb1be5..f119beec50 100644
--- a/apps/ios/Shared/Views/Onboarding/CreateProfile.swift
+++ b/apps/ios/Shared/Views/Onboarding/CreateProfile.swift
@@ -25,10 +25,13 @@ enum UserProfileAlert: Identifiable {
}
}
+let MAX_BIO_LENGTH_BYTES = 160
+
struct CreateProfile: View {
@Environment(\.dismiss) var dismiss
@EnvironmentObject var theme: AppTheme
@State private var displayName: String = ""
+ @State private var profileBio: String = ""
@FocusState private var focusDisplayName
@State private var alert: UserProfileAlert?
@@ -37,12 +40,13 @@ struct CreateProfile: View {
Section {
TextField("Enter your name…", text: $displayName)
.focused($focusDisplayName)
+ TextField("Bio", text: $profileBio)
Button {
createProfile()
} label: {
Label("Create profile", systemImage: "checkmark")
}
- .disabled(!canCreateProfile(displayName))
+ .disabled(!canCreateProfile(displayName) || !bioFitsLimit())
} header: {
HStack {
Text("Your profile")
@@ -52,11 +56,14 @@ struct CreateProfile: View {
let validName = mkValidName(name)
if name != validName {
Spacer()
- Image(systemName: "exclamationmark.circle")
- .foregroundColor(.red)
- .onTapGesture {
- alert = .invalidNameError(validName: validName)
- }
+ validationErrorIndicator {
+ alert = .invalidNameError(validName: validName)
+ }
+ } else if !bioFitsLimit() {
+ Spacer()
+ validationErrorIndicator {
+ showAlert(NSLocalizedString("Bio too large", comment: "alert title"))
+ }
}
}
.frame(height: 20)
@@ -78,11 +85,25 @@ struct CreateProfile: View {
}
}
+ private func validationErrorIndicator(_ onTap: @escaping () -> Void) -> some View {
+ Image(systemName: "exclamationmark.circle")
+ .foregroundColor(.red)
+ .onTapGesture {
+ onTap()
+ }
+ }
+
+ private func bioFitsLimit() -> Bool {
+ chatJsonLength(profileBio) <= MAX_BIO_LENGTH_BYTES
+ }
+
private func createProfile() {
hideKeyboard()
+ let shortDescr: String? = if profileBio.isEmpty { nil } else { profileBio }
let profile = Profile(
displayName: displayName.trimmingCharacters(in: .whitespaces),
- fullName: ""
+ fullName: "",
+ shortDescr: shortDescr
)
let m = ChatModel.shared
do {
diff --git a/apps/ios/Shared/Views/Onboarding/CreateSimpleXAddress.swift b/apps/ios/Shared/Views/Onboarding/CreateSimpleXAddress.swift
index a2f5db7f03..03b0fcba1a 100644
--- a/apps/ios/Shared/Views/Onboarding/CreateSimpleXAddress.swift
+++ b/apps/ios/Shared/Views/Onboarding/CreateSimpleXAddress.swift
@@ -77,9 +77,10 @@ struct CreateSimpleXAddress: View {
progressIndicator = true
Task {
do {
- let connLinkContact = try await apiCreateUserAddress(short: false)
- DispatchQueue.main.async {
- m.userAddress = UserContactLink(connLinkContact: connLinkContact)
+ if let connLinkContact = try await apiCreateUserAddress() {
+ DispatchQueue.main.async {
+ m.userAddress = UserContactLink(connLinkContact)
+ }
}
await MainActor.run { progressIndicator = false }
} catch let error {
diff --git a/apps/ios/Shared/Views/Onboarding/WhatsNewView.swift b/apps/ios/Shared/Views/Onboarding/WhatsNewView.swift
index f65a21623a..916e3f9e78 100644
--- a/apps/ios/Shared/Views/Onboarding/WhatsNewView.swift
+++ b/apps/ios/Shared/Views/Onboarding/WhatsNewView.swift
@@ -579,6 +579,58 @@ private let versionDescriptions: [VersionDescription] = [
)),
]
),
+ VersionDescription(
+ version: "v6.4",
+ post: URL(string: "https://simplex.chat/blog/20250703-simplex-network-protocol-extension-for-securely-connecting-people.html"),
+ features: [
+ .feature(Description(
+ icon: "person",
+ title: "Connect faster! 🚀",
+ description: "Message instantly once you tap Connect."
+ )),
+ .feature(Description(
+ icon: { if #available(iOS 17, *) {"person.bubble"} else {"person.crop.square"} }(),
+ title: "Review group members",
+ description: "Chat with members before they join."
+ )),
+ .feature(Description(
+ icon: { if #available(iOS 16, *) {"questionmark.bubble"} else {"questionmark.square"} }(),
+ title: "Chat with admins",
+ description: "Send your private feedback to groups."
+ )),
+ .feature(Description(
+ icon: "flag",
+ title: "New group role: Moderator",
+ description: "Removes messages and blocks members."
+ )),
+ .feature(Description(
+ icon: "battery.50",
+ title: "Improved message delivery",
+ description: "Less traffic on mobile networks."
+ )),
+ ]
+ ),
+ VersionDescription(
+ version: "v6.4.1",
+ post: URL(string: "https://simplex.chat/blog/20250729-simplex-chat-v6-4-1-welcome-contacts-protect-groups-app-security.html"),
+ features: [
+ .feature(Description(
+ icon: "hand.wave",
+ title: "Welcome your contacts 👋",
+ description: "Set profile bio and welcome message."
+ )),
+ .feature(Description(
+ icon: "stopwatch",
+ title: "Keep your chats clean",
+ description: "Enable disappearing messages by default."
+ )),
+ .view(FeatureView(
+ icon: nil,
+ title: "Short SimpleX address",
+ view: { CreateUpdateAddressShortLink() }
+ ))
+ ]
+ ),
]
private let lastVersion = versionDescriptions.last!.version
@@ -610,6 +662,51 @@ fileprivate struct NewOperatorsView: View {
}
}
+fileprivate struct CreateUpdateAddressShortLink: View {
+ @EnvironmentObject private var chatModel: ChatModel
+ @EnvironmentObject var theme: AppTheme
+ @State private var showAddressSheet = false
+ @State private var progressIndicator = false
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 4) {
+ HStack(alignment: .center, spacing: 4) {
+ Image(systemName: "link")
+ .symbolRenderingMode(.monochrome)
+ .foregroundColor(theme.colors.secondary)
+ .frame(minWidth: 30, alignment: .center)
+ Text("Short SimpleX address").font(.title3).bold()
+ }
+ Group {
+ if let addr = chatModel.userAddress {
+ if addr.shouldBeUpgraded {
+ HStack(spacing: 8) {
+ Button("Upgrade your address") { upgradeAndShareAddressAlert(progressIndicator: $progressIndicator) }
+ if progressIndicator {
+ ProgressView()
+ }
+ }
+ } else {
+ Button("Share your address") { addr.shareAddress(short: true) }
+ }
+ } else {
+ Button("Create your address") { showAddressSheet = true }
+ }
+ }
+ .multilineTextAlignment(.leading)
+ .lineLimit(10)
+ }
+ .sheet(isPresented: $showAddressSheet) {
+ NavigationView {
+ UserAddressView(autoCreate: true)
+ .navigationTitle("SimpleX address")
+ .navigationBarTitleDisplayMode(.large)
+ .modifier(ThemedBackground(grouped: true))
+ }
+ }
+ }
+}
+
private enum WhatsNewViewSheet: Identifiable {
case showConditions
diff --git a/apps/ios/Shared/Views/TerminalView.swift b/apps/ios/Shared/Views/TerminalView.swift
index 554219eb69..ac143fe044 100644
--- a/apps/ios/Shared/Views/TerminalView.swift
+++ b/apps/ios/Shared/Views/TerminalView.swift
@@ -167,7 +167,7 @@ struct TerminalView: View {
func sendTerminalCmd(_ cmd: String) async {
let start: Date = .now
await withCheckedContinuation { (cont: CheckedContinuation) in
- let d = sendSimpleXCmdStr(cmd)
+ let d = sendSimpleXCmdStr(cmd, retryNum: 0)
Task {
guard let d else {
await TerminalItems.shared.addCommand(start, ChatCommand.string(cmd), APIResult.error(.invalidJSON(json: nil)))
diff --git a/apps/ios/Shared/Views/UserSettings/AppearanceSettings.swift b/apps/ios/Shared/Views/UserSettings/AppearanceSettings.swift
index c6d0e27289..02dec5a618 100644
--- a/apps/ios/Shared/Views/UserSettings/AppearanceSettings.swift
+++ b/apps/ios/Shared/Views/UserSettings/AppearanceSettings.swift
@@ -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, scrollToItemId: { _ in })
+ ChatItemView(chat: Chat.sampleData, im: ItemsModel.shared, chatItem: alice, scrollToItem: { _ in }, scrollToItemId: Binding.constant(nil))
.modifier(ChatItemClipped(alice, tailVisible: true))
Spacer()
}
HStack {
Spacer()
- ChatItemView(chat: Chat.sampleData, chatItem: bob, scrollToItemId: { _ in })
+ ChatItemView(chat: Chat.sampleData, im: ItemsModel.shared, chatItem: bob, scrollToItem: { _ in }, scrollToItemId: Binding.constant(nil))
.modifier(ChatItemClipped(bob, tailVisible: true))
.frame(alignment: .trailing)
}
diff --git a/apps/ios/Shared/Views/UserSettings/DeveloperView.swift b/apps/ios/Shared/Views/UserSettings/DeveloperView.swift
index 54454b7cef..6df2d5422e 100644
--- a/apps/ios/Shared/Views/UserSettings/DeveloperView.swift
+++ b/apps/ios/Shared/Views/UserSettings/DeveloperView.swift
@@ -14,6 +14,7 @@ struct DeveloperView: View {
@AppStorage(DEFAULT_DEVELOPER_TOOLS) private var developerTools = false
@AppStorage(GROUP_DEFAULT_CONFIRM_DB_UPGRADES, store: groupDefaults) private var confirmDatabaseUpgrades = false
@State private var hintsUnchanged = hintDefaultsUnchanged()
+ @State private var simplexLinkMode = privacySimplexLinkModeDefault.get()
@Environment(\.colorScheme) var colorScheme
@@ -65,6 +66,21 @@ struct DeveloperView: View {
Text("Developer options")
}
}
+ Section("Deprecated options") {
+ settingsRow("link", color: theme.colors.secondary) {
+ Picker("SimpleX links", selection: $simplexLinkMode) {
+ ForEach(
+ SimpleXLinkMode.values + (SimpleXLinkMode.values.contains(simplexLinkMode) ? [] : [simplexLinkMode])
+ ) { mode in
+ Text(mode.text)
+ }
+ }
+ }
+ .frame(height: 36)
+ .onChange(of: simplexLinkMode) { mode in
+ privacySimplexLinkModeDefault.set(mode)
+ }
+ }
}
}
}
diff --git a/apps/ios/Shared/Views/UserSettings/NetworkAndServers/AdvancedNetworkSettings.swift b/apps/ios/Shared/Views/UserSettings/NetworkAndServers/AdvancedNetworkSettings.swift
index fa698f8b7c..3a536c7b17 100644
--- a/apps/ios/Shared/Views/UserSettings/NetworkAndServers/AdvancedNetworkSettings.swift
+++ b/apps/ios/Shared/Views/UserSettings/NetworkAndServers/AdvancedNetworkSettings.swift
@@ -67,7 +67,7 @@ struct AdvancedNetworkSettings: View {
Text(netCfg.smpProxyMode.label)
}
}
-
+
NavigationLink {
List {
Section {
@@ -192,16 +192,15 @@ struct AdvancedNetworkSettings: View {
netCfg.requiredHostMode = requiredHostMode
}
}
-
+
if developerTools {
Section {
- Picker("Transport isolation", selection: $netCfg.sessionMode) {
+ WrappedPicker("Transport isolation", selection: $netCfg.sessionMode) {
let modes = TransportSessionMode.values.contains(netCfg.sessionMode)
? TransportSessionMode.values
: TransportSessionMode.values + [netCfg.sessionMode]
ForEach(modes, id: \.self) { Text($0.text) }
}
- .frame(height: 36)
} footer: {
sessionModeInfo(netCfg.sessionMode)
.foregroundColor(theme.colors.secondary)
@@ -209,10 +208,9 @@ struct AdvancedNetworkSettings: View {
}
Section {
- Picker("Use web port", selection: $netCfg.smpWebPortServers) {
+ WrappedPicker("Use web port", selection: $netCfg.smpWebPortServers) {
ForEach(SMPWebPortServers.allCases, id: \.self) { Text($0.text) }
}
- .frame(height: 36)
} header: {
Text("TCP port for messaging")
} footer: {
@@ -220,10 +218,12 @@ struct AdvancedNetworkSettings: View {
? 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)
+ timeoutSettingPicker("TCP connection timeout", selection: $netCfg.tcpConnectTimeout.interactiveTimeout, values: [10_000000, 15_000000, 20_000000, 30_000000], label: secondsLabel)
+ timeoutSettingPicker("TCP connection bg timeout", selection: $netCfg.tcpConnectTimeout.backgroundTimeout, values: [30_000000, 45_000000, 60_000000, 90_000000], label: secondsLabel)
+ timeoutSettingPicker("Protocol timeout", selection: $netCfg.tcpTimeout.interactiveTimeout, values: [5_000000, 7_000000, 10_000000, 15_000000, 20_000000], label: secondsLabel)
+ timeoutSettingPicker("Protocol background timeout", selection: $netCfg.tcpTimeout.backgroundTimeout, values: [15_000000, 20_000000, 30_000000, 45_000000, 60_000000], label: secondsLabel)
timeoutSettingPicker("Protocol timeout per KB", selection: $netCfg.tcpTimeoutPerKb, values: [2_500, 5_000, 10_000, 15_000, 20_000, 30_000], label: secondsLabel)
// intSettingPicker("Receiving concurrency", selection: $netCfg.rcvConcurrency, values: [1, 2, 4, 8, 12, 16, 24], label: "")
timeoutSettingPicker("PING interval", selection: $netCfg.smpPingInterval, values: [120_000000, 300_000000, 600_000000, 1200_000000, 2400_000000, 3600_000000], label: secondsLabel)
@@ -243,7 +243,7 @@ struct AdvancedNetworkSettings: View {
.foregroundColor(theme.colors.secondary)
}
}
-
+
Section {
Button("Reset to defaults") {
updateNetCfgView(NetCfg.defaults, NetworkProxy.def)
@@ -254,7 +254,7 @@ struct AdvancedNetworkSettings: View {
updateNetCfgView(netCfg.withProxyTimeouts, netProxy)
}
.disabled(netCfg.hasProxyTimeouts)
-
+
Button("Save and reconnect") {
showSettingsAlert = .update
}
@@ -351,16 +351,15 @@ struct AdvancedNetworkSettings: View {
}
private func timeoutSettingPicker(_ title: LocalizedStringKey, selection: Binding, values: [Int], label: String) -> some View {
- Picker(title, selection: selection) {
+ WrappedPicker(title, selection: selection) {
let v = selection.wrappedValue
let vs = values.contains(v) ? values : values + [v]
ForEach(vs, id: \.self) { value in
Text("\(String(format: "%g", (Double(value) / 1000000))) \(secondsLabel)")
}
}
- .frame(height: 36)
}
-
+
private func onionHostsInfo(_ hosts: OnionHosts) -> LocalizedStringKey {
switch hosts {
case .no: return "Onion hosts will not be used."
@@ -378,7 +377,7 @@ struct AdvancedNetworkSettings: View {
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.")
}
}
-
+
private func proxyModeInfo(_ mode: SMPProxyMode) -> LocalizedStringKey {
switch mode {
case .always: return "Always use private routing."
@@ -387,7 +386,7 @@ struct AdvancedNetworkSettings: View {
case .never: return "Do NOT use private routing."
}
}
-
+
private func proxyFallbackInfo(_ proxyFallback: SMPProxyFallback) -> LocalizedStringKey {
switch proxyFallback {
case .allow: return "Send messages directly when your or destination server does not support private routing."
diff --git a/apps/ios/Shared/Views/UserSettings/NetworkAndServers/NewServerView.swift b/apps/ios/Shared/Views/UserSettings/NetworkAndServers/NewServerView.swift
index 17a0ffdd1c..c8cb2349e7 100644
--- a/apps/ios/Shared/Views/UserSettings/NetworkAndServers/NewServerView.swift
+++ b/apps/ios/Shared/Views/UserSettings/NetworkAndServers/NewServerView.swift
@@ -65,7 +65,7 @@ struct NewServerView: View {
useServerSection(valid)
if valid {
Section(header: Text("Add to another device").foregroundColor(theme.colors.secondary)) {
- MutableQRCode(uri: $serverToEdit.server)
+ MutableQRCode(uri: $serverToEdit.server, small: true)
.listRowInsets(EdgeInsets(top: 12, leading: 12, bottom: 12, trailing: 12))
}
}
diff --git a/apps/ios/Shared/Views/UserSettings/NetworkAndServers/ProtocolServerView.swift b/apps/ios/Shared/Views/UserSettings/NetworkAndServers/ProtocolServerView.swift
index 13d01874ed..97bfd360cb 100644
--- a/apps/ios/Shared/Views/UserSettings/NetworkAndServers/ProtocolServerView.swift
+++ b/apps/ios/Shared/Views/UserSettings/NetworkAndServers/ProtocolServerView.swift
@@ -110,7 +110,7 @@ struct ProtocolServerView: View {
useServerSection(valid)
if valid {
Section(header: Text("Add to another device").foregroundColor(theme.colors.secondary)) {
- MutableQRCode(uri: $serverToEdit.server)
+ MutableQRCode(uri: $serverToEdit.server, small: true)
.listRowInsets(EdgeInsets(top: 12, leading: 12, bottom: 12, trailing: 12))
}
}
diff --git a/apps/ios/Shared/Views/UserSettings/PreferencesView.swift b/apps/ios/Shared/Views/UserSettings/PreferencesView.swift
index bd8171623a..eced372124 100644
--- a/apps/ios/Shared/Views/UserSettings/PreferencesView.swift
+++ b/apps/ios/Shared/Views/UserSettings/PreferencesView.swift
@@ -19,7 +19,7 @@ struct PreferencesView: View {
var body: some View {
VStack {
List {
- timedMessagesFeatureSection($preferences.timedMessages.allow)
+ timedMessagesFeatureSection($preferences.timedMessages.allow, $preferences.timedMessages.ttl)
featureSection(.fullDelete, $preferences.fullDelete.allow)
featureSection(.reactions, $preferences.reactions.allow)
featureSection(.voice, $preferences.voice.allow)
@@ -60,20 +60,35 @@ struct PreferencesView: View {
}
- private func timedMessagesFeatureSection(_ allowFeature: Binding) -> some View {
+ @ViewBuilder private func timedMessagesFeatureSection(_ allowFeature: Binding, _ ttl: Binding) -> some View {
+ let allow = Binding(
+ get: { allowFeature.wrappedValue == .always || allowFeature.wrappedValue == .yes },
+ set: { yes, _ in allowFeature.wrappedValue = yes ? .yes : .no }
+ )
Section {
- let allow = Binding(
- get: { allowFeature.wrappedValue == .always || allowFeature.wrappedValue == .yes },
- set: { yes, _ in allowFeature.wrappedValue = yes ? .yes : .no }
- )
settingsRow(ChatFeature.timedMessages.icon, color: theme.colors.secondary) {
Toggle(ChatFeature.timedMessages.text, isOn: allow)
}
+ if allow.wrappedValue {
+ Picker("Delete after", selection: ttl) {
+ ForEach(TimedMessagesPreference.profileLevelTTLValues, id: \.self) { value in
+ Text(timeText(value)).tag(value)
+ }
+ }
+ .frame(height: 36)
+ }
+ }
+ footer: {
+ let featureFooterText = featureFooter(.timedMessages, allowFeature).foregroundColor(theme.colors.secondary)
+ if allow.wrappedValue && ttl.wrappedValue != nil {
+ featureFooterText + textNewLine + Text("Time to disappear is set only for new contacts.")
+ } else {
+ featureFooterText
+ }
}
- footer: { featureFooter(.timedMessages, allowFeature).foregroundColor(theme.colors.secondary) }
}
- private func featureFooter(_ feature: ChatFeature, _ allowFeature: Binding) -> some View {
+ private func featureFooter(_ feature: ChatFeature, _ allowFeature: Binding) -> Text {
Text(feature.allowDescription(allowFeature.wrappedValue))
}
diff --git a/apps/ios/Shared/Views/UserSettings/PrivacySettings.swift b/apps/ios/Shared/Views/UserSettings/PrivacySettings.swift
index eba7f8066a..eec820833c 100644
--- a/apps/ios/Shared/Views/UserSettings/PrivacySettings.swift
+++ b/apps/ios/Shared/Views/UserSettings/PrivacySettings.swift
@@ -14,13 +14,12 @@ struct PrivacySettings: View {
@EnvironmentObject var theme: AppTheme
@AppStorage(DEFAULT_PRIVACY_ACCEPT_IMAGES) private var autoAcceptImages = true
@AppStorage(DEFAULT_PRIVACY_LINK_PREVIEWS) private var useLinkPreviews = true
+ @AppStorage(GROUP_DEFAULT_PRIVACY_SANITIZE_LINKS, store: groupDefaults) private var privacySanitizeLinks = false
@AppStorage(DEFAULT_PRIVACY_SHOW_CHAT_PREVIEWS) private var showChatPreviews = true
@AppStorage(DEFAULT_PRIVACY_SAVE_LAST_DRAFT) private var saveLastDraft = true
@AppStorage(GROUP_DEFAULT_PRIVACY_ENCRYPT_LOCAL_FILES, store: groupDefaults) private var encryptLocalFiles = true
@AppStorage(GROUP_DEFAULT_PRIVACY_ASK_TO_APPROVE_RELAYS, store: groupDefaults) private var askToApproveRelays = true
- @State private var simplexLinkMode = privacySimplexLinkModeDefault.get()
@AppStorage(DEFAULT_DEVELOPER_TOOLS) private var developerTools = false
- @AppStorage(DEFAULT_PRIVACY_SHORT_LINKS) private var shortSimplexLinks = false
@AppStorage(DEFAULT_PRIVACY_PROTECT_SCREEN) private var protectScreen = false
@AppStorage(DEFAULT_PERFORM_LA) private var prefPerformLA = false
@State private var currentLAMode = privacyLocalAuthModeDefault.get()
@@ -33,6 +32,8 @@ struct PrivacySettings: View {
@State private var groupReceiptsReset = false
@State private var groupReceiptsOverrides = 0
@State private var groupReceiptsDialogue = false
+ @State private var autoAcceptMemberContacts = false
+ @State private var autoAcceptMemberContactsReset = false
@State private var alert: PrivacySettingsViewAlert?
enum PrivacySettingsViewAlert: Identifiable {
@@ -74,8 +75,12 @@ struct PrivacySettings: View {
Toggle("Send link previews", isOn: $useLinkPreviews)
.onChange(of: useLinkPreviews) { linkPreviews in
privacyLinkPreviewsGroupDefault.set(linkPreviews)
+ privacyLinkPreviewsShowAlertGroupDefault.set(false) // to avoid showing alert to current users, show alert in v6.5
}
}
+ settingsRow("link", color: theme.colors.secondary) {
+ Toggle("Remove link tracking", isOn: $privacySanitizeLinks)
+ }
settingsRow("message", color: theme.colors.secondary) {
Toggle("Show last messages", isOn: $showChatPreviews)
}
@@ -88,24 +93,6 @@ struct PrivacySettings: View {
m.draftChatId = nil
}
}
- settingsRow("link", color: theme.colors.secondary) {
- Picker("SimpleX links", selection: $simplexLinkMode) {
- ForEach(
- SimpleXLinkMode.values + (SimpleXLinkMode.values.contains(simplexLinkMode) ? [] : [simplexLinkMode])
- ) { mode in
- Text(mode.text)
- }
- }
- }
- .frame(height: 36)
- .onChange(of: simplexLinkMode) { mode in
- privacySimplexLinkModeDefault.set(mode)
- }
- if developerTools {
- settingsRow("link.badge.plus", color: theme.colors.secondary) {
- Toggle("Use short links (BETA)", isOn: $shortSimplexLinks)
- }
- }
} header: {
Text("Chats")
.foregroundColor(theme.colors.secondary)
@@ -125,7 +112,7 @@ struct PrivacySettings: View {
}
}
settingsRow("circle.filled.pattern.diagonalline.rectangle", color: theme.colors.secondary) {
- Picker("Blur media", selection: $privacyMediaBlurRadius) {
+ WrappedPicker("Blur media", selection: $privacyMediaBlurRadius) {
let values = [0, 12, 24, 48] + ([0, 12, 24, 48].contains(privacyMediaBlurRadius) ? [] : [privacyMediaBlurRadius])
ForEach(values, id: \.self) { radius in
let text: String = switch radius {
@@ -139,7 +126,6 @@ struct PrivacySettings: View {
}
}
}
- .frame(height: 36)
settingsRow("network.badge.shield.half.filled", color: theme.colors.secondary) {
Toggle("Protect IP address", isOn: $askToApproveRelays)
}
@@ -156,6 +142,18 @@ struct PrivacySettings: View {
}
}
+ Section {
+ settingsRow("checkmark", color: theme.colors.secondary) {
+ Toggle("Auto-accept", isOn: $autoAcceptMemberContacts)
+ }
+ } header: {
+ Text("Contact requests from groups")
+ .foregroundColor(theme.colors.secondary)
+ } footer: {
+ Text("This setting is for your current profile **\(m.currentUser?.displayName ?? "")**.")
+ .foregroundColor(theme.colors.secondary)
+ }
+
Section {
settingsRow("person", color: theme.colors.secondary) {
Toggle("Contacts", isOn: $contactReceipts)
@@ -214,6 +212,13 @@ struct PrivacySettings: View {
setOrAskSendReceiptsGroups(groupReceipts)
}
}
+ .onChange(of: autoAcceptMemberContacts) { _ in
+ if autoAcceptMemberContactsReset {
+ autoAcceptMemberContactsReset = false
+ } else {
+ setAutoAcceptGrpDirectInvs(autoAcceptMemberContacts)
+ }
+ }
.onAppear {
if let u = m.currentUser {
if contactReceipts != u.sendRcptsContacts {
@@ -224,6 +229,10 @@ struct PrivacySettings: View {
groupReceiptsReset = true
groupReceipts = u.sendRcptsSmallGroups
}
+ if autoAcceptMemberContacts != u.autoAcceptMemberContacts {
+ autoAcceptMemberContactsReset = true
+ autoAcceptMemberContacts = u.autoAcceptMemberContacts
+ }
}
}
.alert(item: $alert) { alert in
@@ -340,6 +349,23 @@ struct PrivacySettings: View {
}
}
+ private func setAutoAcceptGrpDirectInvs(_ enable: Bool) {
+ Task {
+ do {
+ if let currentUser = m.currentUser {
+ try await apiSetUserAutoAcceptMemberContacts(currentUser.userId, enable: enable)
+ await MainActor.run {
+ var updatedUser = currentUser
+ updatedUser.autoAcceptMemberContacts = enable
+ m.updateUser(updatedUser)
+ }
+ }
+ } catch let error {
+ alert = .error(title: "Error setting auto-accept", error: "Error: \(responseError(error))")
+ }
+ }
+ }
+
private func simplexLockRow(_ value: LocalizedStringKey) -> some View {
HStack {
Text("SimpleX Lock")
@@ -452,7 +478,7 @@ struct SimplexLockView: View {
Toggle("Allow sharing", isOn: $allowShareExtension)
}
}
-
+
if performLA && laMode == .passcode {
Section(header: Text("Self-destruct passcode").foregroundColor(theme.colors.secondary)) {
Toggle(isOn: $selfDestruct) {
diff --git a/apps/ios/Shared/Views/UserSettings/SettingsView.swift b/apps/ios/Shared/Views/UserSettings/SettingsView.swift
index e06b1c4dd3..cb6fdf8597 100644
--- a/apps/ios/Shared/Views/UserSettings/SettingsView.swift
+++ b/apps/ios/Shared/Views/UserSettings/SettingsView.swift
@@ -32,7 +32,6 @@ let DEFAULT_PRIVACY_LINK_PREVIEWS = "privacyLinkPreviews" // deprecated, moved t
let DEFAULT_PRIVACY_SIMPLEX_LINK_MODE = "privacySimplexLinkMode"
let DEFAULT_PRIVACY_SHOW_CHAT_PREVIEWS = "privacyShowChatPreviews"
let DEFAULT_PRIVACY_SAVE_LAST_DRAFT = "privacySaveLastDraft"
-let DEFAULT_PRIVACY_SHORT_LINKS = "privacyShortLinks"
let DEFAULT_PRIVACY_PROTECT_SCREEN = "privacyProtectScreen"
let DEFAULT_PRIVACY_DELIVERY_RECEIPTS_SET = "privacyDeliveryReceiptsSet"
let DEFAULT_PRIVACY_MEDIA_BLUR_RADIUS = "privacyMediaBlurRadius"
@@ -58,6 +57,7 @@ let DEFAULT_CONNECT_VIA_LINK_TAB = "connectViaLinkTab"
let DEFAULT_LIVE_MESSAGE_ALERT_SHOWN = "liveMessageAlertShown"
let DEFAULT_SHOW_HIDDEN_PROFILES_NOTICE = "showHiddenProfilesNotice"
let DEFAULT_SHOW_MUTE_PROFILE_ALERT = "showMuteProfileAlert"
+let DEFAULT_SHOW_REPORTS_IN_SUPPORT_CHAT_ALERT = "showReportsInSupportChatAlert"
let DEFAULT_WHATS_NEW_VERSION = "defaultWhatsNewVersion"
let DEFAULT_ONBOARDING_STAGE = "onboardingStage"
let DEFAULT_MIGRATION_TO_STAGE = "migrationToStage"
@@ -99,7 +99,6 @@ let appDefaults: [String: Any] = [
DEFAULT_PRIVACY_SIMPLEX_LINK_MODE: SimpleXLinkMode.description.rawValue,
DEFAULT_PRIVACY_SHOW_CHAT_PREVIEWS: true,
DEFAULT_PRIVACY_SAVE_LAST_DRAFT: true,
- DEFAULT_PRIVACY_SHORT_LINKS: false,
DEFAULT_PRIVACY_PROTECT_SCREEN: false,
DEFAULT_PRIVACY_DELIVERY_RECEIPTS_SET: false,
DEFAULT_PRIVACY_MEDIA_BLUR_RADIUS: 0,
@@ -117,6 +116,7 @@ let appDefaults: [String: Any] = [
DEFAULT_LIVE_MESSAGE_ALERT_SHOWN: false,
DEFAULT_SHOW_HIDDEN_PROFILES_NOTICE: true,
DEFAULT_SHOW_MUTE_PROFILE_ALERT: true,
+ DEFAULT_SHOW_REPORTS_IN_SUPPORT_CHAT_ALERT: true,
DEFAULT_ONBOARDING_STAGE: OnboardingStage.onboardingComplete.rawValue,
DEFAULT_CUSTOM_DISAPPEARING_MESSAGE_TIME: 300,
DEFAULT_SHOW_UNREAD_AND_FAVORITES: false,
@@ -144,6 +144,7 @@ let hintDefaults = [
DEFAULT_LIVE_MESSAGE_ALERT_SHOWN,
DEFAULT_SHOW_HIDDEN_PROFILES_NOTICE,
DEFAULT_SHOW_MUTE_PROFILE_ALERT,
+ DEFAULT_SHOW_REPORTS_IN_SUPPORT_CHAT_ALERT,
DEFAULT_SHOW_DELETE_CONVERSATION_NOTICE,
DEFAULT_SHOW_DELETE_CONTACT_NOTICE
]
@@ -195,6 +196,8 @@ let customDisappearingMessageTimeDefault = IntDefault(defaults: UserDefaults.sta
let showDeleteConversationNoticeDefault = BoolDefault(defaults: UserDefaults.standard, forKey: DEFAULT_SHOW_DELETE_CONVERSATION_NOTICE)
let showDeleteContactNoticeDefault = BoolDefault(defaults: UserDefaults.standard, forKey: DEFAULT_SHOW_DELETE_CONTACT_NOTICE)
+let showReportsInSupportChatAlertDefault = BoolDefault(defaults: UserDefaults.standard, forKey: DEFAULT_SHOW_REPORTS_IN_SUPPORT_CHAT_ALERT)
+
/// after importing new database, this flag will be set and unset only after importing app settings in `initializeChat` */
let shouldImportAppSettingsDefault = BoolDefault(defaults: UserDefaults.standard, forKey: DEFAULT_SHOULD_IMPORT_APP_SETTINGS)
let currentThemeDefault = StringDefault(defaults: UserDefaults.standard, forKey: DEFAULT_CURRENT_THEME, withDefault: DefaultTheme.SYSTEM_THEME_NAME)
diff --git a/apps/ios/Shared/Views/UserSettings/UserAddressView.swift b/apps/ios/Shared/Views/UserSettings/UserAddressView.swift
index 4813edf96c..1e5b4bff16 100644
--- a/apps/ios/Shared/Views/UserSettings/UserAddressView.swift
+++ b/apps/ios/Shared/Views/UserSettings/UserAddressView.swift
@@ -17,8 +17,8 @@ struct UserAddressView: View {
@State var shareViaProfile = false
@State var autoCreate = false
@State private var showShortLink = true
- @State private var aas = AutoAcceptState()
- @State private var savedAAS = AutoAcceptState()
+ @State private var settings = AddressSettingsState()
+ @State private var savedSettings = AddressSettingsState()
@State private var showMailView = false
@State private var mailViewResult: Result? = nil
@State private var alert: UserAddressAlert?
@@ -66,8 +66,8 @@ struct UserAddressView: View {
if let userAddress = chatModel.userAddress {
existingAddressView(userAddress)
.onAppear {
- aas = AutoAcceptState(userAddress: userAddress)
- savedAAS = aas
+ settings = AddressSettingsState(settings: userAddress.addressSettings)
+ savedSettings = AddressSettingsState(settings: userAddress.addressSettings)
}
} else {
Section {
@@ -138,25 +138,28 @@ struct UserAddressView: View {
Section {
SimpleXCreatedLinkQRCode(link: userAddress.connLinkContact, short: $showShortLink)
.id("simplex-contact-address-qrcode-\(userAddress.connLinkContact.simplexChatUri(short: showShortLink))")
- shareQRCodeButton(userAddress)
+ if userAddress.shouldBeUpgraded {
+ upgradeAddressButton()
+ }
+ shareAddressButton(userAddress)
// if MFMailComposeViewController.canSendMail() {
// shareViaEmailButton(userAddress)
// }
settingsRow("briefcase", color: theme.colors.secondary) {
- Toggle("Business address", isOn: $aas.business)
- .onChange(of: aas.business) { ba in
+ Toggle("Business address", isOn: $settings.businessAddress)
+ .onChange(of: settings.businessAddress) { ba in
if ba {
- aas.enable = true
- aas.incognito = false
+ settings.autoAccept = true
+ settings.autoAcceptIncognito = false
}
- saveAAS($aas, $savedAAS)
+ saveAddressSettings(settings, $savedSettings)
}
}
addressSettingsButton(userAddress)
} header: {
ToggleShortLinkHeader(text: Text("For social media"), link: userAddress.connLinkContact, short: $showShortLink)
} footer: {
- if aas.business {
+ if settings.businessAddress {
Text("Add your team members to the conversations.")
.foregroundColor(theme.colors.secondary)
}
@@ -193,12 +196,12 @@ struct UserAddressView: View {
progressIndicator = true
Task {
do {
- let short = UserDefaults.standard.bool(forKey: DEFAULT_PRIVACY_SHORT_LINKS)
- let connLinkContact = try await apiCreateUserAddress(short: short)
- DispatchQueue.main.async {
- chatModel.userAddress = UserContactLink(connLinkContact: connLinkContact)
- alert = .shareOnCreate
- progressIndicator = false
+ if let connLinkContact = try await apiCreateUserAddress() {
+ DispatchQueue.main.async {
+ chatModel.userAddress = UserContactLink(connLinkContact)
+ alert = .shareOnCreate
+ progressIndicator = false
+ }
}
} catch let error {
logger.error("UserAddressView apiCreateUserAddress: \(responseError(error))")
@@ -209,6 +212,16 @@ struct UserAddressView: View {
}
}
+ private func upgradeAddressButton() -> some View {
+ Button {
+ upgradeAndShareAddressAlert(progressIndicator: $progressIndicator)
+ } label: {
+ settingsRow("arrow.up", color: theme.colors.primary) {
+ Text("Upgrade address")
+ }
+ }
+ }
+
private func createOneTimeLinkButton() -> some View {
NavigationLink {
NewChatView(selection: .invite)
@@ -230,9 +243,13 @@ struct UserAddressView: View {
}
}
- private func shareQRCodeButton(_ userAddress: UserContactLink) -> some View {
- Button {
- showShareSheet(items: [simplexChatLink(userAddress.connLinkContact.simplexChatUri(short: showShortLink))])
+ private func shareAddressButton(_ userAddress: UserContactLink) -> some View {
+ return Button {
+ if userAddress.shouldBeUpgraded {
+ upgradeAndShareAddressAlert(progressIndicator: $progressIndicator, shareAddress: { userAddress.shareAddress(short: showShortLink) })
+ } else {
+ userAddress.shareAddress(short: showShortLink)
+ }
} label: {
settingsRow("square.and.arrow.up", color: theme.colors.secondary) {
Text("Share address")
@@ -295,14 +312,55 @@ struct UserAddressView: View {
}
}
+func upgradeAndShareAddressAlert(progressIndicator: Binding, shareAddress: (() -> Void)? = nil) {
+ showAlert(
+ NSLocalizedString("Upgrade address?", comment: "alert message"),
+ message: NSLocalizedString("The address will be short, and your profile will be shared via the address.", comment: "alert message"),
+ actions: {
+ var actions = [UIAlertAction(title: NSLocalizedString("Upgrade", comment: "alert button"), style: .default) { _ in
+ addShortLink(progressIndicator: progressIndicator, shareOnCompletion: shareAddress != nil)
+ }]
+ if let shareAddress {
+ actions.append(UIAlertAction(title: NSLocalizedString("Share old address", comment: "alert button"), style: .default) { _ in
+ shareAddress()
+ })
+ }
+ actions.append(cancelAlertAction)
+ return actions
+ }
+ )
+}
+
+private func addShortLink(progressIndicator: Binding, shareOnCompletion: Bool = false) {
+ progressIndicator.wrappedValue = true
+ Task {
+ do {
+ let userAddress = try await apiAddMyAddressShortLink()
+ await MainActor.run {
+ ChatModel.shared.userAddress = userAddress
+ progressIndicator.wrappedValue = false
+ if shareOnCompletion, let userAddress {
+ userAddress.shareAddress(short: true)
+ }
+ }
+ } catch let error {
+ logger.error("apiAddMyAddressShortLink: \(responseError(error))")
+ showAlert("Error adding short link", message: responseError(error))
+ await MainActor.run { progressIndicator.wrappedValue = false }
+ }
+ }
+}
+
+
struct ToggleShortLinkHeader: View {
@EnvironmentObject var theme: AppTheme
+ @AppStorage(DEFAULT_DEVELOPER_TOOLS) private var developerTools = false
let text: Text
var link: CreatedConnLink
@Binding var short: Bool
-
+
var body: some View {
- if link.connShortLink == nil {
+ if link.connShortLink == nil || !developerTools {
text.foregroundColor(theme.colors.secondary)
} else {
HStack {
@@ -317,45 +375,27 @@ struct ToggleShortLinkHeader: View {
}
}
-private struct AutoAcceptState: Equatable {
- var enable = false
- var incognito = false
- var business = false
- var welcomeText = ""
+struct AddressSettingsState: Equatable {
+ var businessAddress = false
+ var autoAccept = false
+ var autoAcceptIncognito = false
+ var autoReply = ""
- init(enable: Bool = false, incognito: Bool = false, business: Bool = false, welcomeText: String = "") {
- self.enable = enable
- self.incognito = incognito
- self.business = business
- self.welcomeText = welcomeText
+ init() {}
+
+ init(settings: AddressSettings) {
+ self.businessAddress = settings.businessAddress
+ self.autoAccept = settings.autoAccept != nil
+ self.autoAcceptIncognito = settings.autoAccept?.acceptIncognito == true
+ self.autoReply = settings.autoReply?.text ?? ""
}
- init(userAddress: UserContactLink) {
- if let aa = userAddress.autoAccept {
- enable = true
- incognito = aa.acceptIncognito
- business = aa.businessAddress
- if let msg = aa.autoReply {
- welcomeText = msg.text
- } else {
- welcomeText = ""
- }
- } else {
- enable = false
- incognito = false
- business = false
- welcomeText = ""
- }
- }
-
- var autoAccept: AutoAccept? {
- if enable {
- var autoReply: MsgContent? = nil
- let s = welcomeText.trimmingCharacters(in: .whitespacesAndNewlines)
- if s != "" { autoReply = .text(s) }
- return AutoAccept(businessAddress: business, acceptIncognito: incognito, autoReply: autoReply)
- }
- return nil
+ var addressSettings: AddressSettings {
+ AddressSettings(
+ businessAddress: self.businessAddress,
+ autoAccept: self.autoAccept ? AutoAccept(acceptIncognito: self.autoAcceptIncognito) : nil,
+ autoReply: self.autoReply.isEmpty ? nil : MsgContent.text(self.autoReply)
+ )
}
}
@@ -380,30 +420,32 @@ struct UserAddressSettingsView: View {
@Environment(\.dismiss) var dismiss: DismissAction
@EnvironmentObject var theme: AppTheme
@Binding var shareViaProfile: Bool
- @State private var aas = AutoAcceptState()
- @State private var savedAAS = AutoAcceptState()
+ @State private var settings = AddressSettingsState()
+ @State private var savedSettings = AddressSettingsState()
@State private var ignoreShareViaProfileChange = false
@State private var progressIndicator = false
- @FocusState private var keyboardVisible: Bool
var body: some View {
ZStack {
if let userAddress = ChatModel.shared.userAddress {
userAddressSettingsView()
.onAppear {
- aas = AutoAcceptState(userAddress: userAddress)
- savedAAS = aas
+ settings = AddressSettingsState(settings: userAddress.addressSettings)
+ savedSettings = AddressSettingsState(settings: userAddress.addressSettings)
}
- .onChange(of: aas.enable) { aasEnabled in
- if !aasEnabled { aas = AutoAcceptState() }
+ .onChange(of: settings.autoAccept) { autoAccept in
+ if !autoAccept {
+ settings.businessAddress = false
+ settings.autoReply = ""
+ }
}
.onDisappear {
- if savedAAS != aas {
+ if savedSettings != settings {
showAlert(
- title: NSLocalizedString("Auto-accept settings", comment: "alert title"),
+ title: NSLocalizedString("SimpleX address settings", comment: "alert title"),
message: NSLocalizedString("Settings were changed.", comment: "alert message"),
buttonTitle: NSLocalizedString("Save", comment: "alert button"),
- buttonAction: { saveAAS($aas, $savedAAS) },
+ buttonAction: { saveAddressSettings(settings, $savedSettings) },
cancelButton: true
)
}
@@ -421,11 +463,22 @@ struct UserAddressSettingsView: View {
List {
Section {
shareWithContactsButton()
- autoAcceptToggle().disabled(aas.business)
+ autoAcceptToggle().disabled(settings.businessAddress)
+ if settings.autoAccept && !ChatModel.shared.addressShortLinkDataSet && !settings.businessAddress {
+ acceptIncognitoToggle()
+ }
}
- if aas.enable {
- autoAcceptSection()
+ Section {
+ messageEditor(placeholder: NSLocalizedString("Enter welcome message… (optional)", comment: "placeholder"), text: $settings.autoReply)
+ } header: {
+ Text("Welcome message")
+ .foregroundColor(theme.colors.secondary)
+ }
+
+ Section {
+ saveAddressSettingsButton()
+ .disabled(settings == savedSettings)
}
}
}
@@ -444,7 +497,7 @@ struct UserAddressSettingsView: View {
actions: {[
UIAlertAction(
title: NSLocalizedString("Cancel", comment: "alert action"),
- style: .default,
+ style: .cancel,
handler: { _ in
ignoreShareViaProfileChange = true
shareViaProfile = !on
@@ -466,7 +519,7 @@ struct UserAddressSettingsView: View {
actions: {[
UIAlertAction(
title: NSLocalizedString("Cancel", comment: "alert action"),
- style: .default,
+ style: .cancel,
handler: { _ in
ignoreShareViaProfileChange = true
shareViaProfile = !on
@@ -489,46 +542,31 @@ struct UserAddressSettingsView: View {
private func autoAcceptToggle() -> some View {
settingsRow("checkmark", color: theme.colors.secondary) {
- Toggle("Auto-accept", isOn: $aas.enable)
- .onChange(of: aas.enable) { _ in
- saveAAS($aas, $savedAAS)
+ Toggle("Auto-accept", isOn: $settings.autoAccept)
+ .onChange(of: settings.autoAccept) { _ in
+ saveAddressSettings(settings, $savedSettings)
}
}
}
- private func autoAcceptSection() -> some View {
- Section {
- if !aas.business {
- acceptIncognitoToggle()
- }
- welcomeMessageEditor()
- saveAASButton()
- .disabled(aas == savedAAS)
- } header: {
- Text("Auto-accept")
- .foregroundColor(theme.colors.secondary)
- }
- }
-
private func acceptIncognitoToggle() -> some View {
settingsRow(
- aas.incognito ? "theatermasks.fill" : "theatermasks",
- color: aas.incognito ? .indigo : theme.colors.secondary
+ settings.autoAcceptIncognito ? "theatermasks.fill" : "theatermasks",
+ color: settings.autoAcceptIncognito ? .indigo : theme.colors.secondary
) {
- Toggle("Accept incognito", isOn: $aas.incognito)
+ Toggle("Accept incognito", isOn: $settings.autoAcceptIncognito)
}
}
- private func welcomeMessageEditor() -> some View {
+ private func messageEditor(placeholder: String, text: Binding) -> some View {
ZStack {
Group {
- if aas.welcomeText.isEmpty {
- TextEditor(text: Binding.constant(NSLocalizedString("Enter welcome message… (optional)", comment: "placeholder")))
+ if text.wrappedValue.isEmpty {
+ TextEditor(text: Binding.constant(placeholder))
.foregroundColor(theme.colors.secondary)
.disabled(true)
}
- TextEditor(text: $aas.welcomeText)
- .focused($keyboardVisible)
+ TextEditor(text: text)
}
.padding(.horizontal, -5)
.padding(.top, -8)
@@ -537,27 +575,27 @@ struct UserAddressSettingsView: View {
}
}
- private func saveAASButton() -> some View {
+ private func saveAddressSettingsButton() -> some View {
Button {
- keyboardVisible = false
- saveAAS($aas, $savedAAS)
+ hideKeyboard()
+ saveAddressSettings(settings, $savedSettings)
} label: {
Text("Save")
}
}
}
-private func saveAAS(_ aas: Binding, _ savedAAS: Binding) {
+private func saveAddressSettings(_ settings: AddressSettingsState, _ savedSettings: Binding) {
Task {
do {
- if let address = try await userAddressAutoAccept(aas.wrappedValue.autoAccept) {
+ if let address = try await apiSetUserAddressSettings(settings.addressSettings) {
await MainActor.run {
ChatModel.shared.userAddress = address
- savedAAS.wrappedValue = aas.wrappedValue
+ savedSettings.wrappedValue = settings
}
}
} catch let error {
- logger.error("userAddressAutoAccept error: \(responseError(error))")
+ logger.error("apiSetUserAddressSettings error: \(responseError(error))")
}
}
}
@@ -565,9 +603,8 @@ private func saveAAS(_ aas: Binding, _ savedAAS: Binding Void
- ) -> some View {
- Image(systemName: systemName)
- .resizable()
- .aspectRatio(contentMode: .fit)
- .frame(height: 12)
- .foregroundColor(theme.colors.primary)
- .padding(6)
- .frame(width: 36, height: 36, alignment: .center)
- .background(radius >= 20 ? Color.clear : theme.colors.background.opacity(0.5))
- .clipShape(Circle())
- .contentShape(Circle())
- .padding([.trailing, edge], -12)
- .onTapGesture(perform: action)
- }
-
private func showFullName(_ user: User) -> Bool {
user.profile.fullName != "" && user.profile.fullName != user.profile.displayName
}
-
+
+ private func bioFitsLimit() -> Bool {
+ chatJsonLength(shortDescr) <= MAX_BIO_LENGTH_BYTES
+ }
+
private var canSaveProfile: Bool {
- currentProfileHash != profile.hashValue &&
+ (
+ currentProfileHash != profile.hashValue ||
+ (chatModel.currentUser?.profile.shortDescr ?? "") != shortDescr.trimmingCharacters(in: .whitespaces)
+ ) &&
profile.displayName.trimmingCharacters(in: .whitespaces) != "" &&
- validDisplayName(profile.displayName)
+ validDisplayName(profile.displayName) &&
+ bioFitsLimit()
}
private func saveProfile() {
@@ -167,6 +150,7 @@ struct UserProfile: View {
Task {
do {
profile.displayName = profile.displayName.trimmingCharacters(in: .whitespaces)
+ profile.shortDescr = shortDescr.trimmingCharacters(in: .whitespaces)
if let (newProfile, _) = try await apiUpdateProfile(profile: profile) {
await MainActor.run {
chatModel.updateCurrentUser(newProfile)
@@ -185,12 +169,59 @@ struct UserProfile: View {
if let user = chatModel.currentUser {
profile = fromLocalProfile(user.profile)
currentProfileHash = profile.hashValue
+ shortDescr = profile.shortDescr ?? ""
}
}
}
-func profileImageView(_ imageStr: String?) -> some View {
- ProfileImage(imageStr: imageStr, size: 192)
+struct EditProfileImage: View {
+ @EnvironmentObject var theme: AppTheme
+ @AppStorage(DEFAULT_PROFILE_IMAGE_CORNER_RADIUS) private var radius = defaultProfileImageCorner
+ @Binding var profileImage: String?
+ @Binding var showChooseSource: Bool
+
+ var body: some View {
+ Group {
+ if profileImage != nil {
+ ZStack(alignment: .bottomTrailing) {
+ ZStack(alignment: .topTrailing) {
+ ProfileImage(imageStr: profileImage, size: 160)
+ .onTapGesture { showChooseSource = true }
+ overlayButton("multiply", edge: .top) { profileImage = nil }
+ }
+ overlayButton("camera", edge: .bottom) { showChooseSource = true }
+ }
+ } else {
+ ZStack(alignment: .center) {
+ ProfileImage(imageStr: profileImage, size: 160)
+ editImageButton { showChooseSource = true }
+ }
+ }
+ }
+ .frame(maxWidth: .infinity, alignment: .center)
+ .listRowBackground(Color.clear)
+ .listRowInsets(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0))
+ .contentShape(Rectangle())
+ }
+
+ private func overlayButton(
+ _ systemName: String,
+ edge: Edge.Set,
+ action: @escaping () -> Void
+ ) -> some View {
+ Image(systemName: systemName)
+ .resizable()
+ .aspectRatio(contentMode: .fit)
+ .frame(height: 12)
+ .foregroundColor(theme.colors.primary)
+ .padding(6)
+ .frame(width: 36, height: 36, alignment: .center)
+ .background(radius >= 20 ? Color.clear : theme.colors.background.opacity(0.5))
+ .clipShape(Circle())
+ .contentShape(Circle())
+ .padding([.trailing, edge], -12)
+ .onTapGesture(perform: action)
+ }
}
func editImageButton(action: @escaping () -> Void) -> some View {
diff --git a/apps/ios/Shared/Views/UserSettings/UserProfilesView.swift b/apps/ios/Shared/Views/UserSettings/UserProfilesView.swift
index 887023b670..ddfe59e719 100644
--- a/apps/ios/Shared/Views/UserSettings/UserProfilesView.swift
+++ b/apps/ios/Shared/Views/UserSettings/UserProfilesView.swift
@@ -350,7 +350,7 @@ struct UserProfilesView: View {
Image(systemName: "checkmark").foregroundColor(theme.colors.onBackground)
} else {
if userInfo.unreadCount > 0 {
- UnreadBadge(userInfo: userInfo)
+ userUnreadBadge(userInfo, theme: theme)
}
if user.hidden {
Image(systemName: "lock").foregroundColor(theme.colors.secondary)
diff --git a/apps/ios/SimpleX Localizations/ar.xcloc/Localized Contents/ar.xliff b/apps/ios/SimpleX Localizations/ar.xcloc/Localized Contents/ar.xliff
index e965e5a1a5..427430b833 100644
--- a/apps/ios/SimpleX Localizations/ar.xcloc/Localized Contents/ar.xliff
+++ b/apps/ios/SimpleX Localizations/ar.xcloc/Localized Contents/ar.xliff
@@ -1374,12 +1374,14 @@
خطأ في تغيير الإعدادات
No comment provided by engineer.
-
+
Error creating address
+ خطأ في إنشاء العنوان
No comment provided by engineer.
-
+
Error creating group
+ خطأ في إنشاء المجموعة
No comment provided by engineer.
@@ -2229,8 +2231,8 @@ We will be adding server redundancy to prevent lost messages.
Please store passphrase securely, you will NOT be able to change it if you lose it.
No comment provided by engineer.
-
- Possibly, certificate fingerprint in server address is incorrect
+
+ Fingerprint in server address does not match certificate.
server test error
@@ -2557,8 +2559,8 @@ We will be adding server redundancy to prevent lost messages.
Sent messages will be deleted after set time.
No comment provided by engineer.
-
- Server requires authorization to create queues, check password
+
+ Server requires authorization to create queues, check password.
يتطلب الخادم إذنًا لإنشاء قوائم انتظار، تحقق من كلمة المرور
server test error
@@ -3618,7 +3620,7 @@ SimpleX servers cannot see your profile.
italic
No comment provided by engineer.
-
+
join as %@
No comment provided by engineer.
@@ -3800,8 +3802,8 @@ SimpleX servers cannot see your profile.
نعم
pref value
-
- you are invited to group
+
+ You are invited to group
أنت مدعو إلى المجموعة
No comment provided by engineer.
@@ -4247,8 +4249,8 @@ SimpleX servers cannot see your profile.
Server type
نوع الخادم
-
- Server requires authorization to upload, check password
+
+ Server requires authorization to upload, check password.
يتطلب الخادم إذنًا للرفع، تحقق من كلمة المرور
@@ -4587,8 +4589,8 @@ SimpleX servers cannot see your profile.
Ask
اسأل
-
- Auto-accept settings
+
+ SimpleX address settings
إعدادات القبول التلقائي
@@ -5757,6 +5759,102 @@ This is your own one-time link!
Encryption re-negotiation failed.
فشل إعادة التفاوض على التشفير.
+
+ Accept as member
+ اقبل كعضو
+
+
+ Accept as observer
+ اقبل كمراقب
+
+
+ Accept contact request
+ اقبل طلب الاتصال
+
+
+ Accept member
+ اقبل العضو
+
+
+ Add message
+ أضف رسالة
+
+
+ All servers
+ كل الخوادم
+
+
+ Bio
+ نبذة
+
+
+ Bio too large
+ النبذة كبيرة جدًا
+
+
+ Can't change profile
+ لا يمكن تغيير الحساب
+
+
+ Chat with admins
+ تحدث مع المدراء
+
+
+ Chat with member
+ تحدث مع العضو
+
+
+ Chat with members before they join.
+ تحدث مع الأعضاء قبل انضمامهم.
+
+
+ Chats with members
+ تحدث مع الأعضاء
+
+
+ Connect faster! 🚀
+ اتصل بسرعة! 🚀
+
+
+ Contact requests from groups
+ طلبات الاتصال من المجموعات
+
+
+ Create your address
+ أنشئ عنوانك
+
+
+ Delete chat with member?
+ حذف المحادثة مع العضو؟
+
+
+ Description too large
+ الوصف كبير جدًا
+
+
+ Empty message!
+ رسالة فارغة!
+
+
+ Enable disappearing messages by default.
+ فعّل حذف الرسائل تلقائيا.
+
+
+ Error accepting member
+ خطأ في قبول العضو
+
+
+ Error adding short link
+ خطأ في إضافة الرابط القصير
+
+
+ Error changing chat profile
+ خطأ في تغيير حساب المحادثات
+
+
+ Error connecting to forwarding server %@. Please try later.
+ خطأ في الإتصال بخادم التحويل @%. حاول مجددا بعد حين.
+